diff --git a/package-lock.json b/package-lock.json index 3f9e3279..ccbf132c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vim-webgl-component", - "version": "0.3.14", + "version": "0.3.17", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vim-webgl-component", - "version": "0.3.14", + "version": "0.3.17", "bundleDependencies": [ "vim-webgl-viewer" ], @@ -19,7 +19,7 @@ "react-tooltip": "^4.2.21", "stats-js": "^1.0.1", "tailwindcss-scoped-preflight": "^3.2.8", - "vim-webgl-viewer": "2.0.9" + "vim-webgl-viewer": "2.0.10" }, "devDependencies": { "@types/react": "^17.0.39", @@ -4614,18 +4614,18 @@ } }, "node_modules/vim-format": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/vim-format/-/vim-format-1.0.6.tgz", - "integrity": "sha512-yj3oah6KM+FhfOSpi7Hjtz1mB2xhATejRvZvZfOUPZEKhYC3QLq/cx+/RiJ5KaWOTfSZCAMfdmsxdXTS8GjRyA==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vim-format/-/vim-format-1.0.14.tgz", + "integrity": "sha512-aGVNeGUQf1zcH0HDGEG+8Imv51FGjdG3WBsso/JPw13w332LLAIVYCmRxtHHVXi6+Yw+U3nEHzBSBjudOpJzdQ==", "inBundle": true, "dependencies": { "pako": "^2.1.0" } }, "node_modules/vim-webgl-viewer": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/vim-webgl-viewer/-/vim-webgl-viewer-2.0.9.tgz", - "integrity": "sha512-HVUfchaZll9/3lkUoDjIWAulaHeQeFL9EHw/RZRLKzk1AZniqK9L6cHbTFBvVsErtacRplkYWFpSErTPBbxW1A==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/vim-webgl-viewer/-/vim-webgl-viewer-2.0.10.tgz", + "integrity": "sha512-3UvBCohjUa8HllsJ9NLTqWuq/XcXY2rFvls5sEilp2/qpjY0tORUATJfKesllBVaVGAqOntqdq0wm6azvWp9jg==", "bundleDependencies": [ "three" ], @@ -4638,7 +4638,7 @@ "ste-signals": "^3.0.9", "ste-simple-events": "^3.0.7", "three": "*", - "vim-format": "1.0.6" + "vim-format": "1.0.14" } }, "node_modules/vim-webgl-viewer/node_modules/three": { @@ -7930,17 +7930,17 @@ "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==" }, "vim-format": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/vim-format/-/vim-format-1.0.6.tgz", - "integrity": "sha512-yj3oah6KM+FhfOSpi7Hjtz1mB2xhATejRvZvZfOUPZEKhYC3QLq/cx+/RiJ5KaWOTfSZCAMfdmsxdXTS8GjRyA==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vim-format/-/vim-format-1.0.14.tgz", + "integrity": "sha512-aGVNeGUQf1zcH0HDGEG+8Imv51FGjdG3WBsso/JPw13w332LLAIVYCmRxtHHVXi6+Yw+U3nEHzBSBjudOpJzdQ==", "requires": { "pako": "^2.1.0" } }, "vim-webgl-viewer": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/vim-webgl-viewer/-/vim-webgl-viewer-2.0.9.tgz", - "integrity": "sha512-HVUfchaZll9/3lkUoDjIWAulaHeQeFL9EHw/RZRLKzk1AZniqK9L6cHbTFBvVsErtacRplkYWFpSErTPBbxW1A==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/vim-webgl-viewer/-/vim-webgl-viewer-2.0.10.tgz", + "integrity": "sha512-3UvBCohjUa8HllsJ9NLTqWuq/XcXY2rFvls5sEilp2/qpjY0tORUATJfKesllBVaVGAqOntqdq0wm6azvWp9jg==", "requires": { "@types/three": "^0.143.0", "deepmerge": "^4.2.2", @@ -7949,7 +7949,7 @@ "ste-signals": "^3.0.9", "ste-simple-events": "^3.0.7", "three": "*", - "vim-format": "1.0.6" + "vim-format": "1.0.14" }, "dependencies": { "three": { diff --git a/package.json b/package.json index 90b78d33..37bf8faf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vim-webgl-component", - "version": "0.3.15", + "version": "0.3.17", "description": "A demonstration app built on top of the vim-webgl-viewer", "files": [ "dist" @@ -61,7 +61,7 @@ "react-tooltip": "^4.2.21", "stats-js": "^1.0.1", "tailwindcss-scoped-preflight": "^3.2.8", - "vim-webgl-viewer": "2.0.9" + "vim-webgl-viewer": "2.0.10" }, "peerDependencies": { "react": "^18.2.0", diff --git a/src/component.tsx b/src/component.tsx index 06c2c0cd..50a4e065 100644 --- a/src/component.tsx +++ b/src/component.tsx @@ -12,7 +12,7 @@ import * as VIM from 'vim-webgl-viewer/' import { AxesPanelMemo } from './panels/axesPanel' import { ControlBar, ControlBarCustomization } from './controlbar/controlBar' import { RestOfScreen } from './controlbar/restOfScreen' -import { LoadingBoxMemo, MsgInfo, ComponentLoader } from './panels/loading' +import { LoadingBoxMemo, MsgInfo } from './panels/loading' import { OptionalBimPanel } from './bim/bimPanel' import { ContextMenuCustomization, @@ -40,6 +40,7 @@ import { VimComponentRef } from './vimComponentRef' import { createBimInfoState } from './bim/bimInfoData' import { whenTrue } from './helpers/utils' import { DeferredPromise } from './helpers/deferredPromise' +import { ComponentLoader } from './helpers/loading' export * as VIM from 'vim-webgl-viewer/' export const THREE = VIM.THREE @@ -47,6 +48,7 @@ export * as ContextMenu from './panels/contextMenu' export * as BimInfo from './bim/bimInfoData' export * as ControlBar from './controlbar/controlBar' export * as Icons from './panels/icons' +export * from './helpers/loadRequest' export * from './vimComponentRef' export { getLocalComponentSettings as getLocalSettings } from './settings/settingsStorage' export { type ComponentSettings as Settings, type PartialComponentSettings as PartialSettings, defaultSettings } from './settings/settings' @@ -127,7 +129,7 @@ export function VimComponent (props: { const help = useHelp() const viewerState = useViewerState(props.viewer) - const [msg, setMsg] = useState() + const [msg, setMsg] = useState() const treeRef = useRef() const performanceRef = useRef(null) @@ -154,6 +156,11 @@ export function VimComponent (props: { const subContext = props.viewer.inputs.onContextMenu.subscribe(showContextMenu) + // Patch load + loader.current.onProgress.sub(p => setMsg({ progress: p.loaded })) + loader.current.onError.sub((e) => setMsg({ progress: e })) + loader.current.onDone.sub(() => setMsg(undefined)) + props.onMount({ container: props.container, viewer: props.viewer, @@ -206,7 +213,7 @@ export function VimComponent (props: {
- {whenTrue(settings.value.ui.loadingBox, )} + {whenTrue(settings.value.ui.loadingBox, )} { - sectionGizmo.clip = true const subSection = sectionGizmo.onStateChanged.subscribe(() => setSection({ clip: sectionGizmo.clip, @@ -45,6 +44,7 @@ export function getSectionBoxState (viewer: VIM.Viewer) { sectionGizmo.visible = next if (next && first.current) { + sectionGizmo.clip = true sectionGizmo.fitBox(viewer.renderer.getBoundingBox()) first.current = false } diff --git a/src/helpers/loadRequest.ts b/src/helpers/loadRequest.ts new file mode 100644 index 00000000..fa1aa8fa --- /dev/null +++ b/src/helpers/loadRequest.ts @@ -0,0 +1,81 @@ +import * as VIM from 'vim-webgl-viewer/' +import { DeferredPromise } from './deferredPromise' + +type RequestCallbacks = { + onProgress: (p: VIM.IProgressLogs) => void + onError: (e: string) => void + onDone: () => void +} + +/** + * Class to handle loading a request. + */ +export class LoadRequest { + private _callbacks : RequestCallbacks + private _request: VIM.VimRequest + + private _progress: VIM.IProgressLogs = { loaded: 0, total: 0, all: new Map() } + private _progressPromise = new DeferredPromise() + + private _isDone: boolean = false + private _completionPromise = new DeferredPromise() + + constructor (callbacks: RequestCallbacks, source: VIM.RequestSource, settings: VIM.VimPartialSettings) { + this._callbacks = callbacks + + this.startRequest(source, settings) + } + + private async startRequest (source: VIM.RequestSource, settings: VIM.VimPartialSettings) { + this._request = await VIM.request(source, settings) + for await (const progress of this._request.getProgress()) { + this.onProgress(progress) + } + const result = await this._request.getResult() + if (result.isError()) { + this.onError(result.error) + } else { + this.onSuccess() + } + } + + private onProgress (progress: VIM.IProgressLogs) { + this._callbacks.onProgress(progress) + this._progress = progress + this._progressPromise.resolve() + this._progressPromise = new DeferredPromise() + } + + private onSuccess () { + this._callbacks.onDone() + this.end() + } + + private onError (error: string) { + this._callbacks.onError(error) + this.end() + } + + private end () { + this._isDone = true + this._progressPromise.resolve() + this._completionPromise.resolve() + } + + async * getProgress () : AsyncGenerator { + while (!this._isDone) { + await this._progressPromise + yield this._progress + } + } + + async getResult () { + await this._completionPromise + return this._request.getResult() + } + + abort () { + this._request.abort() + this.onError('Request aborted') + } +} diff --git a/src/helpers/loading.ts b/src/helpers/loading.ts new file mode 100644 index 00000000..24db23d1 --- /dev/null +++ b/src/helpers/loading.ts @@ -0,0 +1,140 @@ +/** + * @module viw-webgl-component + */ + +import * as VIM from 'vim-webgl-viewer/' +import { SimpleEventDispatcher } from 'ste-simple-events' +import { SignalDispatcher } from 'ste-signals' +import { LoadRequest } from './loadRequest' + +type AddSettings = { + /** + * Controls whether to frame the camera on a vim everytime it is updated. + * Default: true + */ + autoFrame?: boolean + + /** + * Controls whether to initially load the vim content or not. + * Default: false + */ + loadEmpty?: boolean +} + +export type OpenSettings = VIM.VimPartialSettings & AddSettings + +/** + * Provides functionality for asynchronously opening sources and tracking progress. + * Includes event emitters for progress updates and completion notifications. + */ +export class ComponentLoader { + private _viewer : VIM.Viewer + + constructor (viewer : VIM.Viewer) { + this._viewer = viewer + } + + /** + * Event emitter for progress updates. + */ + get onProgress () { + return this._onProgress.asEvent() + } + + private _onProgress = new SimpleEventDispatcher() + + /** + * Event emitter for error notifications. + */ + private _onError = new SimpleEventDispatcher() + get onError () { + return this._onError.asEvent() + } + + /** + * Event emitter for completion notifications. + */ + get onDone () { + return this._onDone.asEvent() + } + + private _onDone = new SignalDispatcher() + + /** + * Asynchronously opens a vim at source, applying the provided settings. + * @param source The source to open, either as a string or ArrayBuffer. + * @param settings Partial settings to apply to the opened source. + * @param onProgress Optional callback function to track progress during opening. + * Receives progress logs as input. + */ + async open ( + source: VIM.RequestSource, + settings: OpenSettings, + onProgress?: (p: VIM.IProgressLogs) => void + ) { + const request = await VIM.request(source, settings) + + for await (const progress of request.getProgress()) { + onProgress?.(progress) + this._onProgress.dispatch(progress) + } + + const result = await request.getResult() + if (result.isError()) { + console.log('Error loading vim', result.error) + this._onError.dispatch(result.error) + return + } + const vim = result.result + + this._onDone.dispatch() + return vim + } + + /** + * Creates a new load request for the provided source and settings. + * @param source The url to the vim file or a buffer of the file. + * @param settings Settings to apply to vim file. + * @returns A new load request instance to track progress and get result. + */ + request (source: VIM.RequestSource, + settings: VIM.VimPartialSettings) { + return new LoadRequest({ + onProgress: (p) => this._onProgress.dispatch(p), + onError: (e) => this._onError.dispatch(e), + onDone: () => this._onDone.dispatch() + }, source, settings) + } + + /* + * Adds a vim to the viewer and initializes it. + * @param vim Vim to add to the viewer. + * @param settings Optional settings to apply to the vim. + */ + add (vim: VIM.Vim, settings: AddSettings = {}) { + this.initVim(vim, settings) + } + + /** + * Removes the vim from the viewer and disposes it. + * @param vim Vim to remove from the viewer. + */ + remove (vim: VIM.Vim) { + this._viewer.remove(vim) + vim.dispose() + } + + private initVim (vim : VIM.Vim, settings: AddSettings) { + this._viewer.add(vim) + vim.onLoadingUpdate.subscribe(() => { + this._viewer.gizmos.loading.visible = vim.isLoading + if (settings.autoFrame !== false && !vim.isLoading) { + this._viewer.camera.snap().frame(vim) + this._viewer.camera.save() + } + }) + if (settings.loadEmpty !== true) { + vim.loadAll() + } + } +} diff --git a/src/helpers/requestResult.ts b/src/helpers/requestResult.ts new file mode 100644 index 00000000..49a17f24 --- /dev/null +++ b/src/helpers/requestResult.ts @@ -0,0 +1,34 @@ + +export class SuccessResult { + result: T + + constructor (result: T) { + this.result = result + } + + isSuccess (): true { + return true + } + + isError (): false { + return false + } +} + +export class ErrorResult { + error: string + + constructor (error: string) { + this.error = error + } + + isSuccess (): false { + return false + } + + isError (): this is ErrorResult { + return true + } +} + +export type RequestResult = SuccessResult | ErrorResult diff --git a/src/main.tsx b/src/main.tsx index 1e6da7cb..76e76f1f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,19 +15,24 @@ const url = params.has('vim') demo() async function demo () { const cmp = await createVimComponent(undefined, getLocalSettings()) - const time = Date.now() - const vim = await cmp.loader.open( - url ?? 'https://vim02.azureedge.net/samples/residence.v1.2.75.vim', - // url ?? 'https://vim02.azureedge.net/samples/skanska.vim', - // url ?? 'https://vim02.azureedge.net/samples/residence.v1.2.75.vimx', - { - progressive: true, - rotation: new THREE.Vector3(270, 0, 0) - } - ) + const request = await cmp.loader.request({ + url: url ?? 'https://vimdevelopment01storage.blob.core.windows.net/samples/Wolford_Residence.r2025.vim' + }, { + rotation: new THREE.Vector3(270, 0, 0) + }) + + for await (const progress of request.getProgress()) { + console.log(`Downloading Vim (${(progress.loaded / 1000).toFixed(0)} kb)`) + } + const result = await request.getResult() + if (result.isError()) { + console.error(result.error) + return + } + const vim = result.result + cmp.loader.add(vim) - console.log(`Loading completed in ${((Date.now() - time) / 1000).toFixed(2)} seconds`) globalThis.THREE = THREE globalThis.component = cmp globalThis.vim = vim diff --git a/src/panels/loading.tsx b/src/panels/loading.tsx index e4d87145..430cd338 100644 --- a/src/panels/loading.tsx +++ b/src/panels/loading.tsx @@ -2,175 +2,182 @@ * @module viw-webgl-component */ -import React, { useEffect, useState } from 'react' -import * as VIM from 'vim-webgl-viewer/' +import React, { useEffect } from 'react' import { setComponentBehind } from '../helpers/html' import * as Icons from './icons' -import { SimpleEventDispatcher } from 'ste-simple-events' -import { SignalDispatcher } from 'ste-signals' - -type Progress = 'processing' | number | string - -export type MsgInfo = { message: string; info: string } - -export type LoadSettings = VIM.VimPartialSettings & -{ - /** - * Controls whether to frame the camera on a vim everytime it is updated. - * Default: true - */ - autoFrame?: boolean - - /** - * Controls whether to initially load the vim content or not. - * Default: false - */ - loadEmpty?: boolean -} /** - * Memoized version of Loading Box + * Type representing the progress of an operation. + * - `'processing'`: Indicates that processing is ongoing. + * - `number`: Represents the progress in bytes. + * - `string`: Represents an error message. */ -export const LoadingBoxMemo = React.memo(LoadingBox) +type Progress = 'processing' | number | string; /** - * Provides functionality for asynchronously opening sources and tracking progress. - * Includes event emitters for progress updates and completion notifications. + * Interface for message information displayed in the LoadingBox. + * @property message - Optional main message text. + * @property info - Optional additional information or tooltip text. + * @property progress - The progress of an operation. */ -export class ComponentLoader { - private _viewer : VIM.Viewer - - constructor (viewer : VIM.Viewer) { - this._viewer = viewer - } - - /** - * Event emitter for progress updates. - */ - get onProgress () { - return this._onProgress.asEvent() - } - - private _onProgress = new SimpleEventDispatcher() - - /** - * Event emitter for completion notifications. - */ - get onDone () { - return this._onDone.asEvent() - } +export type MsgInfo = { + message?: string; + info?: string; + progress?: Progress; +}; - private _onDone = new SignalDispatcher() - - /** - * Asynchronously opens a vim at source, applying the provided settings. - * @param source The source to open, either as a string or ArrayBuffer. - * @param settings Partial settings to apply to the opened source. - * @param onProgress Optional callback function to track progress during opening. - * Receives progress logs as input. - */ - async open ( - source: string | ArrayBuffer, - settings: LoadSettings, - onProgress?: (p: VIM.IProgressLogs) => void - ) { - const vim = await VIM.open(source, settings, (p) => { - onProgress?.(p) - this._onProgress.dispatch(p) - }) - this._viewer.add(vim) - vim.onLoadingUpdate.subscribe(() => { - this._viewer.gizmos.loading.visible = vim.isLoading - if (settings.autoFrame !== false && !vim.isLoading) { - this._viewer.camera.snap().frame(vim) - this._viewer.camera.save() - } - }) - if (settings.loadEmpty !== true) { - vim.loadAll() - } - this._onDone.dispatch() - return vim - } +/** + * Compares two MsgInfo objects for equality. + * @param msg1 - The first message to compare. + * @param msg2 - The second message to compare. + * @returns True if all properties are equal, false otherwise. + */ +function msgEquals (msg1: MsgInfo, msg2: MsgInfo): boolean { + if (!msg1 && !msg2) return true + if (!msg1 || !msg2) return false + return ( + msg1.message === msg2.message && + msg1.info === msg2.info && + msg1.progress === msg2.progress + ) +} - /** - * Removes the vim from the viewer and disposes it. - * @param vim Vim to remove from the viewer. - */ - close (vim: VIM.Vim) { - this._viewer.remove(vim) - vim.dispose() - } +function msgEmpty (msg: MsgInfo) { + if (!msg) return true + return (msg.info ?? msg.message ?? msg.progress) === undefined } /** - * Loading box JSX Component that can also be used to show messages. + * Memoized version of the LoadingBox component. + * Prevents unnecessary re-renders by comparing previous and next props using MsgEquals. */ -function LoadingBox (props: { loader: ComponentLoader, content: MsgInfo }) { - const [progress, setProgress] = useState() - - // Patch load - useEffect(() => { - props.loader.onProgress.sub(p => setProgress(p.loaded)) - props.loader.onDone.sub(() => setProgress(null)) - }, []) +export const LoadingBoxMemo = React.memo( + LoadingBox, + (prev, next) => msgEquals(prev.content, next.content) +) +/** + * LoadingBox component that displays a loading message or other messages. + * @param props - Component props containing optional content. + * @returns The LoadingBox component or null if no content is provided. + */ +function LoadingBox (props: { content?: MsgInfo }) { + // Side effect to set the component behind state based on content presence. useEffect(() => { - setComponentBehind(progress !== undefined) - }, [progress]) + setComponentBehind(props.content !== undefined) + }, [props.content]) - const msg = props.content?.message ?? formatProgress(progress) + // If no content is provided, render nothing. + if (msgEmpty(props.content)) return null - if (!msg) return null return (
event.preventDefault()} >
-

- {' '} - {msg}{' '} - - {props.content?.info - ? Icons.help({ - height: '16', - width: '16', - fill: 'currentColor', - className: 'vc-inline' - }) - : null}{' '} - -

- {props.content?.message - ? null - : ( - - )} + {Content(props.content)} + {Gizmo(props.content)}
) } -function formatProgress (progress: Progress) { - return progress === 'processing' - ? ( - 'Processing' - ) - : typeof progress === 'number' - ? ( -
- Loading... - {Math.round(progress / 1000000)} MB -
- ) - : typeof progress === 'string' - ? ( - `Error: ${progress}` - ) - : undefined +/** + * Gizmo component that displays a loading widget if progress is a number. + * @param info - Message information containing progress. + * @returns A loading widget or null if progress is not a number. + */ +function Gizmo (info: MsgInfo) { + // Only render the gizmo if progress is a number (indicating loading progress). + if (typeof info.progress !== 'number') return null + return
+} + +/** + * Content component that displays the main content based on the provided info. + * @param info - Message information. + * @returns The content component with appropriate styling. + */ +function Content (info: MsgInfo) { + // Determine the class for centering text based on the progress type. + const center = + typeof info.progress === 'number' ? '' : 'vc-text-center vc-my-auto' + + return ( +

+ {Text(info)} +

+ ) +} + +/** + * Text component that renders the appropriate text based on the provided info. + * @param info - Message information. + * @returns The text component displaying status messages or errors. + */ +function Text (info: MsgInfo) { + // Handle the 'processing' state. + if (info.progress === 'processing') { + return 'Processing' + } + + // Handle numeric progress (loading with progress indicator). + if (typeof info.progress === 'number') { + return ( +
+ Loading... + {formatMBs(info.progress)} MB +
+ ) + } + + // Handle error state when progress is a string (other than 'processing'). + if (typeof info.progress === 'string') { + return ( + <> + {'Error Loading Vim File'} + {InfoBtn(info.progress)} + + ) + } + + // Default case: display the message and optional info button. + return ( + <> + {info.message} + {InfoBtn(info.info)} + + ) +} + +/** + * InfoBtn component that displays an information button with a tooltip. + * @param message - The tooltip message to display. + * @returns An info button or null if no message is provided. + */ +function InfoBtn (message: string | undefined) { + if (!message) return null + return ( + + {Icons.help({ + height: '16', + width: '16', + fill: 'currentColor', + className: 'vc-inline' + })} + + ) +} + +/** + * Formats bytes to megabytes with two decimal places. + * @param bytes - The number of bytes to format. + * @returns The formatted megabytes as a string. + */ +function formatMBs (bytes: number): string { + const BYTES_IN_MB = 1_000_000 + return (bytes / BYTES_IN_MB).toFixed(2) } diff --git a/src/vimComponentRef.ts b/src/vimComponentRef.ts index 5f348a53..6235e541 100644 --- a/src/vimComponentRef.ts +++ b/src/vimComponentRef.ts @@ -3,7 +3,6 @@ */ import * as VIM from 'vim-webgl-viewer/' -import { ComponentLoader } from './panels/loading' import { ContextMenuCustomization } from './panels/contextMenu' import { ComponentSettings } from './settings/settings' import { Isolation } from './helpers/isolation' @@ -11,6 +10,7 @@ import { ComponentCamera } from './helpers/camera' import { VimComponentContainer } from './container' import { BimInfoPanelRef } from './bim/bimInfoData' import { ControlBarCustomization } from './controlbar/controlBar' +import { ComponentLoader } from './helpers/loading' /** * Settings API managing settings applied to the component.