Files
speckle-server/packages/viewer/src/modules/Viewer.ts
T
Alexandru Popovici a3d1e6adec Orbit WASDEQ Controls (#3875)
* 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
2025-01-30 16:05:51 +02:00

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 :)
}
}