a3d1e6adec
* Updated the fly controller to use the z up basis * Fixed very important compiler error * Removed the annoying delay when first holding down WASD keys before movement started * Updated LegacyViewer to use the hybrid camera controls * Added big baker * Trying to figure out the essence of this * Partly works * Pivotal coordinates now work * Smoothened out the math abit * Fixed sandbox error * Enabled the pivot sphere * feat(viewer-lib): Fixed some issues with orbiting around cursor * feat(viewer-lib): Updates to WEB-2313, orbiting around mouse cursor Orbiting around mouse cursor now works correctly with an orthographic projection as well as when toggling between orthographic and perspective. Disabled WASD navigation for now. SmoothOrbitControls now has protected members instead of private allowing extension Documented the important parts of the pivotal navigation code * feat(viewer-lib): Mouse orbiting now takes clipping planes into consideration * chore(viewer-lib): Fixed sandbox build error * fix(viewer-lib): Handled WEB-2449 and WEB-2450 Additionally fixed an issue where changing the orbit pivot would trigger a hard render, adding the unneeded noise of AO re-convergence * fix(viewer-lib): Fixed the issue with focusing and other camera animations caused by the introduction on the pivotal CS. Pivot sphere now shows only on orbit * feat(viewer-lib): Updates on mouse orbiting: - When clicking outside of the model, oribitig will switch to polar and use the last computed origin (which is still going to be based on the last pivot point) Made the pivot sphere speckle blue The pivot sphere now only shows when clicking on objects, when clicking outside of the model it will not show * feat(viewer-lib): Update for WASD aka fly mode: - Smoother combined navigation by using the immediate controler position and orientation as opposed the the goal ones - Pivot sphere properly hides when in fly mode - The bug where the camera would incorrectly jump when toggling between fly and oribit is now gone (or I cannot reproduce it anymore) * fix(viewer-lib): Fixed sandbox compile error * feat(viewer-lib): Added the hybrid fly orbit controller to the legacy viewer * feat(viewer-lib): Added a slower movement speed to WASD navigation when camera is close to geometry * fix(viewer-lib): Fixed the issue where opening the context menu while holding down a WASD key would make the camera move indefinetely * Feat(viewer-lib): Update to WASD controls: - Disabled cursor orbiting - Added an option to allow for world space up/down with e/q keys. By default it's enabled - Fixed the pan speed to work similar to WASD speed in two steps depending how close the camera is to geometry * chore(viewer-lib): Tidied up a bit * fix(viewer-lib): Fixed an ugly bug where the camera distance calculataion plane would flip, especially when WASD-ing, and mess up the min distance calculation which led the camera near plane to be way off * chore(viewer-lib): Swapped E to up and Q to down * Re-nabled cursor orbiting
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import EventEmitter from './EventEmitter.js'
|
|
|
|
import { Clock, Texture } from 'three'
|
|
import { Assets } from './Assets.js'
|
|
import { type Optional } from '../helpers/typeHelper.js'
|
|
import {
|
|
DefaultViewerParams,
|
|
type IViewer,
|
|
type SpeckleView,
|
|
type SunLightConfiguration,
|
|
UpdateFlags,
|
|
ViewerEvent,
|
|
type ViewerParams,
|
|
type ViewerEventPayload
|
|
} from '../IViewer.js'
|
|
import { World } from './World.js'
|
|
import { type TreeNode, WorldTree } from './tree/WorldTree.js'
|
|
import SpeckleRenderer from './SpeckleRenderer.js'
|
|
import { type PropertyInfo, PropertyManager } from './filtering/PropertyManager.js'
|
|
import type { Query, QueryArgsResultMap } from './queries/Query.js'
|
|
import { Queries } from './queries/Queries.js'
|
|
import { type Utils } from './Utils.js'
|
|
import { Extension } from './extensions/Extension.js'
|
|
import Input from './input/Input.js'
|
|
import { CameraController } from './extensions/CameraController.js'
|
|
import { SpeckleType } from './loaders/GeometryConverter.js'
|
|
import { Loader } from './loaders/Loader.js'
|
|
import { type Constructor } from 'type-fest'
|
|
import { RenderTree } from './tree/RenderTree.js'
|
|
import Logger from './utils/Logger.js'
|
|
import Stats from './three/stats.js'
|
|
|
|
export class Viewer extends EventEmitter implements IViewer {
|
|
/** Container and optional stats element */
|
|
protected container: HTMLElement
|
|
protected stats: Optional<ReturnType<typeof Stats>>
|
|
|
|
/** Viewer params used at init time */
|
|
protected startupParams: ViewerParams
|
|
|
|
/** Viewer components */
|
|
protected tree: WorldTree = new WorldTree()
|
|
protected world: World = new World()
|
|
public static readonly theAssets: Assets = new Assets()
|
|
public speckleRenderer: SpeckleRenderer
|
|
protected propertyManager: PropertyManager
|
|
|
|
/** Misc members */
|
|
protected inProgressOperations: number
|
|
protected clock: Clock
|
|
protected loaders: { [id: string]: Loader } = {}
|
|
|
|
protected extensions: {
|
|
[id: string]: Extension
|
|
} = {}
|
|
|
|
/** various utils/helpers */
|
|
protected utils: Utils | undefined
|
|
/** Gets the World object. Currently it's used for info mostly */
|
|
public get World(): World {
|
|
return this.world
|
|
}
|
|
|
|
public get Utils(): Utils {
|
|
if (!this.utils) {
|
|
this.utils = {
|
|
screenToNDC: this.speckleRenderer.screenToNDC.bind(this.speckleRenderer),
|
|
NDCToScreen: this.speckleRenderer.NDCToScreen.bind(this.speckleRenderer)
|
|
}
|
|
}
|
|
return this.utils
|
|
}
|
|
|
|
public get input(): Input {
|
|
return this.speckleRenderer.input
|
|
}
|
|
|
|
private getConstructorChain(obj: object) {
|
|
const cs = []
|
|
let pt = obj
|
|
do {
|
|
if ((pt = Object.getPrototypeOf(pt))) cs.push(pt.constructor.name || null)
|
|
} while (pt !== null)
|
|
return cs
|
|
}
|
|
|
|
public createExtension<T extends Extension>(type: Constructor<T>): T {
|
|
const extensionsToInject: Array<
|
|
new (viewer: IViewer, ...args: Extension[]) => Extension
|
|
> = type.prototype.inject
|
|
const injectedExtensions: Array<Extension> = []
|
|
extensionsToInject.forEach(
|
|
(value: new (viewer: IViewer, ...args: Extension[]) => Extension) => {
|
|
if (this.extensions[value.name]) {
|
|
injectedExtensions.push(this.extensions[value.name])
|
|
return
|
|
}
|
|
for (const k in this.extensions) {
|
|
const prototypeChain = this.getConstructorChain(this.extensions[k])
|
|
if (prototypeChain.includes(value.name)) {
|
|
injectedExtensions.push(this.extensions[k])
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
const extension = new type(this, ...injectedExtensions)
|
|
this.extensions[type.name] = extension
|
|
return extension
|
|
}
|
|
|
|
public getExtension<T extends Extension>(type: Constructor<T>): T {
|
|
let extension
|
|
if ((extension = this.getExtensionInternal(type)) !== null) return extension
|
|
|
|
throw new Error(`Could not get Extension of type ${type.name}. Is it created?`)
|
|
}
|
|
|
|
public hasExtension<T extends Extension>(type: Constructor<T>): boolean {
|
|
const extension = this.getExtensionInternal(type)
|
|
return extension ? true : false
|
|
}
|
|
|
|
private getExtensionInternal<T extends Extension>(type: Constructor<T>): T | null {
|
|
if (this.extensions[type.name]) return this.extensions[type.name] as T
|
|
else {
|
|
for (const k in this.extensions) {
|
|
const prototypeChain = this.getConstructorChain(this.extensions[k])
|
|
if (prototypeChain.includes(type.name)) {
|
|
return this.extensions[k] as T
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
public constructor(
|
|
container: HTMLElement,
|
|
params: ViewerParams = DefaultViewerParams
|
|
) {
|
|
super()
|
|
Logger.useDefaults()
|
|
Logger.setLevel(params.verbose ? Logger.TRACE : Logger.ERROR)
|
|
|
|
this.container = container || document.getElementById('renderer')
|
|
if (params.showStats) {
|
|
this.stats = Stats()
|
|
this.container.prepend(this.stats.dom)
|
|
this.stats.dom.style.position = 'relative' // Mad CSS skills
|
|
}
|
|
this.loaders = {}
|
|
this.startupParams = params
|
|
this.clock = new Clock()
|
|
this.inProgressOperations = 0
|
|
|
|
this.speckleRenderer = new SpeckleRenderer(this.tree, this)
|
|
this.speckleRenderer.create(this.container)
|
|
window.addEventListener('resize', this.resize.bind(this), false)
|
|
|
|
this.propertyManager = new PropertyManager()
|
|
|
|
this.frame()
|
|
this.resize()
|
|
}
|
|
|
|
public getContainer() {
|
|
return this.container
|
|
}
|
|
|
|
public getRenderer() {
|
|
return this.speckleRenderer
|
|
}
|
|
|
|
public resize() {
|
|
const width = this.container.offsetWidth
|
|
const height = this.container.offsetHeight
|
|
this.speckleRenderer.resize(width, height)
|
|
Object.values(this.extensions).forEach((value: Extension) => {
|
|
value.onResize()
|
|
})
|
|
}
|
|
|
|
public requestRender(flags: UpdateFlags = UpdateFlags.RENDER) {
|
|
if (flags & UpdateFlags.RENDER) {
|
|
this.speckleRenderer.needsRender = true
|
|
}
|
|
if (flags & UpdateFlags.SHADOWS) {
|
|
this.speckleRenderer.shadowMapNeedsUpdate = true
|
|
}
|
|
if (flags & UpdateFlags.CLIPPING_PLANES) {
|
|
this.speckleRenderer.updateClippingPlanes()
|
|
}
|
|
if (flags & UpdateFlags.RENDER_RESET) {
|
|
this.speckleRenderer.needsRender = true
|
|
this.speckleRenderer.resetPipeline()
|
|
}
|
|
}
|
|
|
|
private frame() {
|
|
this.update()
|
|
this.render()
|
|
}
|
|
|
|
private update() {
|
|
const delta = this.clock.getDelta() * 1000 // turn to miliseconds
|
|
const extensions = Object.values(this.extensions)
|
|
extensions.forEach((ext: Extension) => {
|
|
ext.onEarlyUpdate(delta)
|
|
})
|
|
this.speckleRenderer.update(delta)
|
|
extensions.forEach((ext: Extension) => {
|
|
ext.onLateUpdate(delta)
|
|
})
|
|
this.stats?.update()
|
|
requestAnimationFrame(this.frame.bind(this))
|
|
}
|
|
|
|
private render() {
|
|
this.speckleRenderer.render()
|
|
Object.values(this.extensions).forEach((ext: Extension) => {
|
|
ext.onRender()
|
|
})
|
|
}
|
|
|
|
public async init(): Promise<void> {
|
|
if (this.startupParams.environmentSrc) {
|
|
Assets.getEnvironment(
|
|
this.startupParams.environmentSrc,
|
|
this.speckleRenderer.renderer
|
|
)
|
|
.then((value: Texture) => {
|
|
this.speckleRenderer.indirectIBL = value
|
|
})
|
|
.catch((reason) => {
|
|
Logger.error(reason)
|
|
Logger.error('Environment failed to load!')
|
|
})
|
|
}
|
|
}
|
|
|
|
on<T extends ViewerEvent>(
|
|
eventType: T,
|
|
listener: (arg: ViewerEventPayload[T]) => void
|
|
): void {
|
|
super.on(eventType, listener)
|
|
}
|
|
|
|
public getObjectProperties(
|
|
resourceURL: string | null = null,
|
|
bypassCache = true
|
|
): Promise<PropertyInfo[]> {
|
|
return this.propertyManager.getProperties(this.tree, resourceURL, bypassCache)
|
|
}
|
|
|
|
public getDataTree(): void {
|
|
Logger.error('DataTree has been deprecated! Please use WorldTree')
|
|
}
|
|
|
|
public getWorldTree(): WorldTree {
|
|
return this.tree
|
|
}
|
|
|
|
public query<T extends Query>(query: T): QueryArgsResultMap[T['operation']] | null {
|
|
if (Queries.isPointQuery(query)) {
|
|
Queries.DefaultPointQuerySolver.setContext(this.speckleRenderer)
|
|
return Queries.DefaultPointQuerySolver.solve(query)
|
|
}
|
|
if (Queries.isIntersectionQuery(query)) {
|
|
Queries.DefaultIntersectionQuerySolver.setContext(this.speckleRenderer)
|
|
return Queries.DefaultIntersectionQuerySolver.solve(query)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
public setLightConfiguration(config: SunLightConfiguration): void {
|
|
this.speckleRenderer.setSunLightConfiguration(config)
|
|
}
|
|
|
|
public getViews(): SpeckleView[] {
|
|
return this.tree
|
|
.findAll((node: TreeNode) => {
|
|
return node.model.renderView?.speckleType === SpeckleType.View3D
|
|
})
|
|
.map((v: TreeNode) => {
|
|
return v.model.raw as SpeckleView
|
|
})
|
|
}
|
|
|
|
public screenshot(): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
// const sectionBoxVisible = this.sectionBox.display.visible
|
|
// if (sectionBoxVisible) {
|
|
// this.sectionBox.visible = false
|
|
// }
|
|
const screenshot = this.speckleRenderer.renderer.domElement.toDataURL('image/png')
|
|
// if (sectionBoxVisible) {
|
|
// this.sectionBox.visible = true
|
|
// }
|
|
resolve(screenshot)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* OBJECT LOADING/UNLOADING
|
|
*/
|
|
|
|
public async loadObject(loader: Loader, zoomToObject = true) {
|
|
if (zoomToObject) {
|
|
this.getExtension(CameraController)?.default()
|
|
}
|
|
if (++this.inProgressOperations === 1) this.emit(ViewerEvent.Busy, true)
|
|
|
|
this.loaders[loader.resource] = loader
|
|
const treeBuilt = await loader.load()
|
|
if (treeBuilt) {
|
|
const renderTree: RenderTree | null = this.tree.getRenderTree(loader.resource)
|
|
/** Catering to typescript
|
|
* The render tree can't be null, we've just built it
|
|
*/
|
|
if (!renderTree) {
|
|
throw new Error(`Could not get render tree ${loader.resource}`)
|
|
}
|
|
const t0 = performance.now()
|
|
for await (const step of this.speckleRenderer.addRenderTree(renderTree)) {
|
|
step
|
|
if (zoomToObject) {
|
|
const extension = this.getExtension(CameraController)
|
|
if (extension) {
|
|
extension.setCameraView([], false)
|
|
this.speckleRenderer.pipeline.render()
|
|
}
|
|
}
|
|
}
|
|
Logger.log(this.getRenderer().renderingStats)
|
|
Logger.log('ASYNC batch build time -> ', performance.now() - t0)
|
|
this.requestRender(UpdateFlags.RENDER_RESET | UpdateFlags.SHADOWS)
|
|
this.emit(ViewerEvent.LoadComplete, loader.resource)
|
|
}
|
|
|
|
if (this.loaders[loader.resource]) this.loaders[loader.resource].dispose()
|
|
delete this.loaders[loader.resource]
|
|
if (--this.inProgressOperations === 0) this.emit(ViewerEvent.Busy, false)
|
|
}
|
|
|
|
public async cancelLoad(resource: string, unload = false) {
|
|
this.loaders[resource].cancel()
|
|
this.tree.getRenderTree(resource)?.cancelBuild()
|
|
this.speckleRenderer.cancelRenderTree(resource)
|
|
if (unload) {
|
|
await this.unloadObject(resource)
|
|
} else {
|
|
if (--this.inProgressOperations === 0) this.emit(ViewerEvent.Busy, false)
|
|
}
|
|
}
|
|
|
|
public async unloadObject(resource: string) {
|
|
try {
|
|
if (++this.inProgressOperations === 1) this.emit(ViewerEvent.Busy, true)
|
|
if (this.tree.findSubtree(resource)) {
|
|
if (this.loaders[resource]) {
|
|
await this.cancelLoad(resource, true)
|
|
return
|
|
}
|
|
delete this.loaders[resource]
|
|
this.speckleRenderer.removeRenderTree(resource)
|
|
this.tree.getRenderTree(resource)?.purge()
|
|
this.tree.purge(resource)
|
|
this.requestRender(UpdateFlags.RENDER_RESET | UpdateFlags.SHADOWS)
|
|
}
|
|
} finally {
|
|
if (--this.inProgressOperations === 0) {
|
|
this.emit(ViewerEvent.Busy, false)
|
|
Logger.warn(`Removed subtree ${resource}`)
|
|
this.emit(ViewerEvent.UnloadComplete, resource)
|
|
}
|
|
}
|
|
}
|
|
|
|
public async unloadAll() {
|
|
try {
|
|
if (++this.inProgressOperations === 1) this.emit(ViewerEvent.Busy, true)
|
|
for (const key of Object.keys(this.loaders)) {
|
|
if (this.loaders[key]) await this.cancelLoad(key, false)
|
|
delete this.loaders[key]
|
|
}
|
|
this.tree.root.children.forEach((node: TreeNode) => {
|
|
this.speckleRenderer.removeRenderTree(node.model.id)
|
|
this.tree.getRenderTree()?.purge()
|
|
})
|
|
|
|
this.tree.purge()
|
|
} finally {
|
|
if (--this.inProgressOperations === 0) {
|
|
this.emit(ViewerEvent.Busy, false)
|
|
Logger.warn(`Removed all subtrees`)
|
|
this.emit(ViewerEvent.UnloadAllComplete)
|
|
}
|
|
}
|
|
}
|
|
|
|
public dispose() {
|
|
// TODO: currently it's easier to simply refresh the page :)
|
|
}
|
|
}
|