threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
209 lines (199 loc) • 8.24 kB
text/typescript
import {UiObjectConfig} from 'uiconfig.js'
import {AViewerPlugin, AViewerPluginSync, ThreeViewer} from '../../viewer'
import {Class} from 'ts-browser-helpers'
/**
* A plugin that allows dynamic loading and unloading of other plugins at runtime, with support for hot module replacement (HMR) during development.
* This plugin provides a simple UI to load and unload plugins by specifying their module paths.
* It supports both direct path strings and module objects (imported or promises).
* The loaded plugins are tracked and can be managed through the provided UI.
*
* For HMR with vite, see {@link sampleThreepipeViteHmrPlugin}
* Note: This plugin is primarily intended for development and testing purposes.
*/
export class DynamicImportPlugin extends AViewerPluginSync {
public static readonly PluginType = 'DynamicImportPlugin'
enabled = true
constructor() {
super()
// for vite, see sampleThreepipeViteHmrPlugin
if ((import.meta as any).hot) {
(import.meta as any).hot.on('custom-tp-plugin-update', async(data: any) => {
const base = (import.meta as any).hot.ownerPath.split('/').slice(0, -1).join('/') + '/'
const path = this.plugins.has(data.id) ? data.id : data.url.replace(base, './')
// console.log(base, import.meta.hot, path)
if (this.plugins.has(path)) {
await this.unloadPlugin(path)
await this.loadPlugin(path)
this.uiConfig.uiRefresh?.(true, 'postFrame')
}
// const path = data.path.split('')
})
}
}
onAdded(viewer: ThreeViewer) {
super.onAdded(viewer)
}
plugins = new Map<string, Class<AViewerPlugin>>
/**
* Loads and adds a plugin to the viewer.
* The plugin can be specified as a path string or as a module object (imported or promise).
* If a path string is provided, it should point to a module that exports a default class extending AViewerPlugin.
* If a module object is provided, it should either have a default export or a named export that is a class extending AViewerPlugin.
* The module can also have a __tpPluginPath property to identify its path.
*
* Usage examples:
* ```ts
* // Load plugin from a path
* await DynamicImportPlugin.loadPlugin('./path/to/MyPlugin.js');
*
* // Load plugin from an imported module
* import MyPluginModule from './path/to/MyPlugin.js';
* await DynamicImportPlugin.loadPlugin(MyPluginModule);
*
* // Load plugin with dynamic import
* await DynamicImportPlugin.loadPlugin(import('./path/to/MyPlugin.js'));
* ```
*
* @returns The instance of the loaded plugin.
* @param pathOrModule
*/
async loadPlugin(pathOrModule: string | PluginModule | Promise<PluginModule>) {
const viewer = this._viewer
if (!viewer) throw new Error('Plugin not added to viewer.')
if (typeof pathOrModule === 'object' && typeof pathOrModule.then === 'function') {
pathOrModule = await pathOrModule
}
pathOrModule = pathOrModule as string | PluginModule
const path: string = typeof pathOrModule === 'string' ? pathOrModule : pathOrModule.__tpPluginPath || ''
if (path?.length && this.plugins.has(path)) throw new Error('Plugin already loaded: ' + path)
const mod = typeof pathOrModule === 'object' ? pathOrModule : typeof path === 'string' && path ? await import(
/* webpackIgnore: true */
/* @vite-ignore */
path + '?t=' + Date.now() // prevent caching during development
) : null
if (!mod) throw new Error('Could not find/load module: ' + path)
const plugin = mod.default || Object.values(mod)[0] as Class<AViewerPlugin>
if (!plugin)
throw new Error('No plugin found in module: ' + path)
if (typeof plugin !== 'function')
throw new Error('Plugin is not a class or function in module: ' + pathOrModule)
if (!(plugin.prototype && plugin.prototype instanceof AViewerPlugin))
throw new Error('Plugin is not a subclass of AViewerPlugin in module: ' + pathOrModule)
const pluginType = plugin.PluginType
console.log(pluginType)
console.log(mod)
if (viewer.getPlugin(pluginType))
throw new Error('Plugin of type ' + pluginType + ' already added to viewer')
if (path?.length) this.plugins.set(path, plugin)
const p = await viewer.addPlugin(plugin)
return p
}
/**
* Unloads and removes a plugin from the viewer by its path.
* The path should match the one used when loading the plugin.
*
* @returns A promise that resolves when the plugin is removed.
* @param path
*/
async unloadPlugin(path: string) {
const viewer = this._viewer
if (!viewer) throw new Error('Plugin not added to viewer.')
const pluginC = this.plugins.get(path)
if (!pluginC)
throw new Error('Plugin not loaded: ' + path)
const plugin = viewer.getPlugin(pluginC)
if (!plugin)
throw new Error('Plugin not found in viewer: ' + path)
await viewer.removePlugin(plugin)
this.plugins.delete(path)
}
private _path = './TestPlugin.ts'
uiConfig: UiObjectConfig = {
type: 'folder',
label: 'Viewer Scripts',
expanded: true,
children: [
{
type: 'input',
label: 'Path',
property: [this, '_path'],
},
{
type: 'button',
label: 'Load Plugin',
value: async() => {
await this.loadPlugin(this._path)
this.uiConfig.uiRefresh?.(true, 'postFrame')
},
},
{
type: 'button',
label: 'Unload Plugin',
value: async() => {
await this.unloadPlugin(this._path)
this.uiConfig.uiRefresh?.(true, 'postFrame')
},
},
{
type: 'button',
label: 'Refresh UI',
value: async() => {
this.uiConfig.uiRefresh?.(true, 'postFrame')
},
},
{
type: 'folder',
label: 'Loaded Plugins',
expanded: true,
children: [
() => {
return [...this.plugins.values()].map(v => {
const p = this._viewer?.getPlugin(v)
return p?.uiConfig
})
},
],
},
],
}
}
export interface PluginModule{
__tpPluginPath: string
default?: Class<AViewerPlugin>
[key: string]: Class<AViewerPlugin> | any
}
// import type {HotUpdateOptions, ModuleNode} from 'vite'
export const sampleThreepipeViteHmrPlugin = {
handleHotUpdate({server, modules, timestamp}: /* HotUpdateOptions */ any) {
// Invalidate modules manually
const invalidatedModules = new Set< /* ModuleNode */ any>()
const res = []
for (const mod of modules) {
console.log(mod.id, mod.url)
if (!mod.url.endsWith('.plugin.ts')) res.push(mod)
else {
server.moduleGraph.invalidateModule(
mod as any,
invalidatedModules,
timestamp,
true,
)
server.ws.send({
type: 'custom',
event: 'custom-tp-plugin-update',
data: {
url: mod.url,
id: mod.id,
},
})
}
}
return res
},
transform(code: string, id: string) {
if (id.endsWith('.plugin.ts')) {
const pathExport = `\nexport const __tpPluginPath = "${id}";\n`
return code + pathExport
}
},
}