| @@ -91,6 +91,7 @@ To make changes and run the example, click on the CodePen button on the top righ | |||
| - [DepthBufferPlugin](#depthbufferplugin) - Pre-rendering of depth buffer | |||
| - [NormalBufferPlugin](#normalbufferplugin) - Pre-rendering of normal buffer | |||
| - [GBufferPlugin](#depthnormalbufferplugin) - Pre-rendering of depth and normal buffers in a single pass buffer | |||
| - [PickingPlugin](#pickingplugin) - Adds support for selecting objects in the viewer with user interactions and selection widgets | |||
| - [GLTFAnimationPlugin](#gltfanimationplugin) - Add support for playing and seeking gltf animations | |||
| - [PopmotionPlugin](#popmotionplugin) - Integrates with popmotion.io library for animation/tweening | |||
| - [RenderTargetPreviewPlugin](#rendertargetpreviewplugin) - Preview any render target in a UI panel over the canvas | |||
| @@ -2075,6 +2076,76 @@ const normalTarget = normalPlugin.target; | |||
| todo | |||
| ## PickingPlugin | |||
| [//]: # (todo: image) | |||
| Example: https://threepipe.org/examples/#picking-plugin/ | |||
| Source Code: [src/plugins/pipeline/PickingPlugin.ts](./src/plugins/interaction/PickingPlugin.ts) | |||
| API Reference: [PickingPlugin](https://threepipe.org/docs/classes/PickingPlugin.html) | |||
| Picking Plugin adds support for selecting and hovering over objects in the viewer with user interactions and selection widgets. | |||
| When the plugin is added to the viewer, it starts listening to the mouse move and click events over the canvas. | |||
| When an object is clicked, it is selected, | |||
| and if a UI plugin is added, the uiconfig for the selected object is populated in the interface. | |||
| The events `selectedObjectChanged`, `hoverObjectChanged`, and `hitObject` can be listened to on the plugin. | |||
| Picking plugin internally uses [ObjectPicker](https://threepipe.org/docs/classes/ObjectPicker.html), | |||
| check out the documentation or source code for more information. | |||
| ```typescript | |||
| import {ThreeViewer, PickingPlugin} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const pickingPlugin = viewer.addPluginSync(new PickingPlugin()) | |||
| // Hovering events are also supported, but since its computationally expensive for large scenes it is disabled by default. | |||
| pickingPlugin.hoverEnabled = true | |||
| pickingPlugin.addEventListener('hitObject', (e)=>{ | |||
| // This is fired when the user clicks on the canvas. | |||
| // The selected object hasn't been changed yet, and we have the option to change it or disable selection at this point. | |||
| // e.intersects.selectedObject contains the object that the user clicked on. | |||
| console.log('Hit: ', e.intersects.selectedObject) | |||
| // It can be changed here | |||
| // e.intersects.selectedObject = e.intersects.selectedObject.parent // select the parent | |||
| // e.intersects.selectedObject = null // unselect | |||
| // Check other properties on the event like intersects, mouse position, normal etc. | |||
| console.log(e) | |||
| }) | |||
| pickingPlugin.addEventListener('selectedObjectChanged', (e)=>{ | |||
| // This is fired when the selected object is changed. | |||
| // e.object contains the new selected object. It can be null if nothing is selected. | |||
| console.log('Selected: ', e.object) | |||
| }) | |||
| // Objects can be programmatically selected and unselected | |||
| // to select | |||
| pickingPlugin.setSelectedObject(object) | |||
| // get the selected object | |||
| console.log(pickingPlugin.getSelectedObject()) | |||
| // to unselect | |||
| pickingPlugin.setSelectedObject(null) | |||
| // Select object with camera animation to the object | |||
| pickingPlugin.setSelectedObject(object, true) | |||
| pickingPlugin.addEventListener('hoverObjectChanged', (e)=>{ | |||
| // This is fired when the hovered object is changed. | |||
| // e.object contains the new hovered object. | |||
| console.log('Hovering: ', e.object) | |||
| }) | |||
| ``` | |||
| ## GLTFAnimationPlugin | |||
| @@ -0,0 +1,36 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Picking Plugin</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <!-- Import maps polyfill --> | |||
| <!-- Remove this when import maps will be widely supported --> | |||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||
| <script type="importmap"> | |||
| { | |||
| "imports": { | |||
| "threepipe": "./../../dist/index.mjs", | |||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs" | |||
| } | |||
| } | |||
| </script> | |||
| <style id="example-style"> | |||
| html, body, #canvas-container, #mcanvas { | |||
| width: 100%; | |||
| height: 100%; | |||
| margin: 0; | |||
| overflow: hidden; | |||
| } | |||
| </style> | |||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||
| </head> | |||
| <body> | |||
| <div id="canvas-container"> | |||
| <canvas id="mcanvas"></canvas> | |||
| </div> | |||
| </body> | |||
| @@ -0,0 +1,32 @@ | |||
| import {_testFinish, IObject3D, PickingPlugin, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| }) | |||
| const picking = viewer.addPluginSync(PickingPlugin) | |||
| picking.hoverEnabled = true | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| await viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf') | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| ui.setupPluginUi(PickingPlugin) | |||
| picking.addEventListener('hitObject', (e)=>{ | |||
| console.log('Hit object', e) | |||
| }) | |||
| picking.addEventListener('selectedObjectChanged', (e)=>{ | |||
| console.log('Selected Object Changed', e) | |||
| }) | |||
| picking.addEventListener('hoverObjectChanged', (e)=>{ | |||
| console.log('Hover Object Changed', e) | |||
| }) | |||
| } | |||
| init().then(_testFinish) | |||
| @@ -19,6 +19,7 @@ export interface IObject3DEvent<T extends string = IObject3DEventTypes> extends | |||
| oldMaterial?: IMaterial|undefined|IMaterial[] // from materialChanged | |||
| geometry?: IGeometry|undefined // from geometryUpdate, geometryChanged | |||
| oldGeometry?: IGeometry|undefined // from geometryChanged | |||
| source?: any | |||
| } | |||
| export interface ISetDirtyCommonOptions { | |||
| @@ -90,6 +91,11 @@ export interface IObject3DUserData extends IImportResultUserData { | |||
| license?: string | |||
| /** | |||
| * When false, this object will not be selectable when clicking on it. | |||
| */ | |||
| userSelectable?: boolean | |||
| // region root scene model root | |||
| /** | |||
| @@ -136,7 +142,7 @@ export interface IObject3DUserData extends IImportResultUserData { | |||
| } | |||
| export interface IObject3D<E extends Event = IObject3DEvent, ET = IObject3DEventTypes> extends Object3D<E, ET>, IUiConfigContainer, IDisposable { | |||
| assetType: 'model' | 'light' | 'camera' | |||
| assetType: 'model' | 'light' | 'camera' | 'widget' | |||
| isLight?: boolean | |||
| isCamera?: boolean | |||
| isMesh?: boolean | |||
| @@ -144,6 +150,7 @@ export interface IObject3D<E extends Event = IObject3DEvent, ET = IObject3DEvent | |||
| // isGroup?: boolean | |||
| isScene?: boolean | |||
| // isHelper?: boolean | |||
| isWidget?: boolean | |||
| readonly isObject3D: true | |||
| material?: IMaterial | IMaterial[] | |||
| @@ -60,6 +60,8 @@ export type ISceneEventTypes = IObject3DEventTypes | 'sceneUpdate' | 'addSceneOb | |||
| export interface ISceneEvent<T extends string = ISceneEventTypes> extends IObject3DEvent<T> { | |||
| scene?: IScene | null | |||
| hierarchyChanged?: boolean // for 'sceneUpdate' event | |||
| // change?: string | |||
| } | |||
| export type ISceneSetDirtyOptions = IObjectSetDirtyOptions | |||
| @@ -67,7 +69,12 @@ export type ISceneSetDirtyOptions = IObjectSetDirtyOptions | |||
| export type ISceneUserData = IObject3DUserData | |||
| export type IWidget = IObject3D // todo | |||
| // todo improve | |||
| export interface IWidget { | |||
| attach(object: any): this; | |||
| detach(): this; | |||
| isWidget: true; | |||
| } | |||
| export interface IScene<E extends ISceneEvent = ISceneEvent, ET extends ISceneEventTypes = ISceneEventTypes> | |||
| extends Scene<E, ET>, IObject3D<E, ET>, IShaderPropertiesUpdater { | |||
| @@ -68,7 +68,7 @@ export function makeIObject3DUiConfig(this: IObject3D, isMesh?:boolean): UiObjec | |||
| label: 'Pick/Focus', | |||
| value: ()=>{ | |||
| // todo instead of dispatching, make a IObject3D.select function | |||
| this.dispatchEvent({type: 'select', ui: true, value: this, bubbleToParent: true, focusCamera: true}) | |||
| this.dispatchEvent({type: 'select', ui: true, object: this, bubbleToParent: true, focusCamera: true}) | |||
| }, | |||
| }, | |||
| { | |||
| @@ -78,7 +78,7 @@ export function makeIObject3DUiConfig(this: IObject3D, isMesh?:boolean): UiObjec | |||
| value: ()=>{ | |||
| const parent = this.parent | |||
| if (parent) { | |||
| parent.dispatchEvent({type: 'select', ui: true, bubbleToParent: true, value: parent}) | |||
| parent.dispatchEvent({type: 'select', ui: true, bubbleToParent: true, object: parent}) | |||
| } | |||
| }, | |||
| }, | |||
| @@ -343,6 +343,7 @@ function upgradeObject3D(this: IObject3D, parent?: IObject3D|undefined, objectPr | |||
| if (this.isLight) this.assetType = 'light' | |||
| else if (this.isCamera) this.assetType = 'camera' | |||
| else if (this.isWidget) this.assetType = 'widget' | |||
| else this.assetType = 'model' | |||
| if (parent) this.parentRoot = parent | |||
| @@ -20,6 +20,7 @@ export {SceneUiConfigPlugin} from './ui/SceneUiConfigPlugin' | |||
| // interaction | |||
| export {DropzonePlugin, type DropzonePluginOptions} from './interaction/DropzonePlugin' | |||
| export {FullScreenPlugin} from './interaction/FullScreenPlugin' | |||
| export {PickingPlugin} from './interaction/PickingPlugin' | |||
| // import | |||
| export {Rhino3dmLoadPlugin} from './import/Rhino3dmLoadPlugin' | |||
| @@ -0,0 +1,248 @@ | |||
| import {Object3D} from 'three' | |||
| import {Class, serialize} from 'ts-browser-helpers' | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {ObjectPicker} from '../../three/utils/ObjectPicker' | |||
| import {IObject3D, IObject3DEvent, ISceneEvent} from '../../core' | |||
| import {IUiConfigContainer, UiObjectConfig} from 'uiconfig.js' | |||
| import {BoxSelectionWidget, SelectionWidget} from '../../three/utils/SelectionWidget' | |||
| export class PickingPlugin extends AViewerPluginSync<'selectedObjectChanged'|'hoverObjectChanged'|'hitObject'> { | |||
| @serialize() enabled = true | |||
| private _enableWidget = true | |||
| get picker(): ObjectPicker|undefined { | |||
| return this._picker | |||
| } | |||
| static readonly PluginType = 'Picking' | |||
| private _picker?: ObjectPicker | |||
| private _widget?: SelectionWidget | |||
| private _pickUi: boolean | |||
| get hoverEnabled() { | |||
| return this._picker?.hoverEnabled ?? false | |||
| } | |||
| set hoverEnabled(v: boolean) { | |||
| if (!this._picker) return | |||
| this._picker.hoverEnabled = v | |||
| this.uiConfig && this.uiConfig.uiRefresh?.() | |||
| } | |||
| @serialize() | |||
| autoFocus = false | |||
| public setDirty() { | |||
| this._viewer?.setDirty() | |||
| } | |||
| constructor(selection: Class<SelectionWidget>|undefined = BoxSelectionWidget, pickUi = true, autoFocus = false) { | |||
| super() | |||
| if (selection) { | |||
| this._widget = new selection() | |||
| } | |||
| this._pickUi = pickUi | |||
| this.autoFocus = autoFocus | |||
| this.dispatchEvent = this.dispatchEvent.bind(this) | |||
| } | |||
| getSelectedObject<T extends IObject3D = IObject3D>(): T|undefined { | |||
| if (!this.enabled) return | |||
| return this._picker?.selectedObject as T || undefined | |||
| } | |||
| setSelectedObject(object: IObject3D|undefined, focusCamera = false) { // todo: listen to object dispose | |||
| if (!this.enabled) return | |||
| if (!this._picker) return | |||
| const t = this.autoFocus | |||
| this.autoFocus = false | |||
| this._picker.selectedObject = object || null | |||
| this.autoFocus = t | |||
| if (t || focusCamera) this.focusObject(object) | |||
| } | |||
| onAdded(viewer: ThreeViewer): void { | |||
| super.onAdded(viewer) | |||
| this._picker = new ObjectPicker(viewer.scene.modelRoot, viewer.canvas, viewer.scene.mainCamera, (obj)=>{ | |||
| const hasMat = obj.material | |||
| if (!hasMat) return false | |||
| let o: IObject3D|null = obj | |||
| let ret = false | |||
| while (o) { | |||
| if (!o.visible) return false | |||
| if (o.assetType === 'model' || o.assetType === 'light') ret = true | |||
| if (o.assetType === 'widget') return false | |||
| if (o.userData.userSelectable === false) return false | |||
| if (o.userData.bboxVisible === false) return false | |||
| o = o.parent | |||
| } | |||
| return ret | |||
| }) | |||
| if (this._widget) viewer.scene.addObject(this._widget, {addToRoot: true}) | |||
| this._picker.addEventListener('selectedObjectChanged', this._selectedObjectChanged) | |||
| this._picker.addEventListener('hoverObjectChanged', this.dispatchEvent) | |||
| this._picker.addEventListener('hitObject', this._onObjectHit) | |||
| // on material drop on selected object | |||
| // viewer.scene.addEventListener('addSceneObject', async(e) => { | |||
| // const obj = e.object | |||
| // const selected: IModel<Mesh> = this.getSelectedObject()! as any | |||
| // if (selected | |||
| // && obj?.assetType === 'material' | |||
| // && typeof selected?.setMaterial === 'function' | |||
| // && selected?.modelObject?.isMesh | |||
| // && await viewer.confirm('Applying material: Apply material to the selected object?') | |||
| // ) { | |||
| // const oldMat = selected.material | |||
| // if (Array.isArray(oldMat)) { | |||
| // console.warn('Dropping on material array not yet fully supported.') | |||
| // selected.setMaterial(obj) | |||
| // } else { | |||
| // let meshes: IModel<Mesh>[] = Array.from(oldMat?.userData.__appliedMeshes ?? []) | |||
| // const c = meshes.length > 1 ? !await viewer.confirm('Applying material: Apply to all objects using this material?') : meshes.length < 1 | |||
| // if (c) meshes = [selected] | |||
| // for (const mesh of meshes) { | |||
| // if (mesh) mesh.setMaterial?.(obj) | |||
| // } | |||
| // } | |||
| // } | |||
| // }) | |||
| viewer.scene.addEventListener('select', this._onObjectSelectEvent) | |||
| viewer.scene.addEventListener('sceneUpdate', this._onSceneUpdate) | |||
| viewer.scene.addEventListener('mainCameraChange', this._mainCameraChange) | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| viewer.scene.removeEventListener('select', this._onObjectSelectEvent) | |||
| viewer.scene.removeEventListener('sceneUpdate', this._onSceneUpdate) | |||
| viewer.scene.removeEventListener('mainCameraChange', this._mainCameraChange) | |||
| this._widget?.removeFromParent() | |||
| if (this._picker) { | |||
| this._picker.removeEventListener('selectedObjectChanged', this._selectedObjectChanged) | |||
| this._picker.removeEventListener('hoverObjectChanged', this.dispatchEvent) | |||
| this._picker.removeEventListener('hitObject', this._onObjectHit) | |||
| this._picker.dispose() | |||
| this._picker = undefined | |||
| } | |||
| super.onRemove(viewer) | |||
| } | |||
| dispose() { | |||
| super.dispose() | |||
| this._widget?.dispose() | |||
| } | |||
| private _mainCameraChange = ()=>{ | |||
| if (!this._picker || !this._viewer) return | |||
| this._picker.camera = this._viewer.scene.mainCamera | |||
| } | |||
| private _onSceneUpdate = (e: ISceneEvent)=>{ | |||
| if (!e.hierarchyChanged) return | |||
| const s = this.getSelectedObject() | |||
| let inScene = false | |||
| s?.traverseAncestors((o)=>{ | |||
| if (o === this._viewer?.scene) inScene = true | |||
| }) | |||
| if (!inScene) this.setSelectedObject(undefined) | |||
| } | |||
| private _onObjectSelectEvent = (e: IObject3DEvent)=>{ | |||
| if (e.source === PickingPlugin.PluginType) return | |||
| if (e.object === undefined && e.value === undefined) console.error('e.object or e.value must be set for picking, can be null to unselect') | |||
| else this.setSelectedObject(e.value, this.autoFocus || e.focusCamera) | |||
| } | |||
| private _selectedObjectChanged = (e: any) => { | |||
| this.dispatchEvent(e) | |||
| const selected = this._picker?.selectedObject || undefined | |||
| if (this._pickUi) { | |||
| const sUiConfig = (selected as IUiConfigContainer)?.uiConfig | |||
| const ui = this.uiConfig | |||
| ui.children = [...this._uiConfigChildren] | |||
| if (sUiConfig) ui.children.push(sUiConfig) | |||
| ui.uiRefresh?.() | |||
| } | |||
| const widget = this._widget | |||
| if (widget && this._enableWidget) { | |||
| if (selected) widget.attach(selected) | |||
| else widget.detach() | |||
| } | |||
| // if (selected) selected.dispatchEvent({type: 'selected', source: PickingPlugin.PluginType, object: selected}) | |||
| this._viewer?.setDirty() | |||
| if (this.autoFocus) { | |||
| // this._viewer?.resetCamera({rootObject: selected, centerOffset: new Vector3(4, 4, 4)}) | |||
| this.focusObject(selected) | |||
| } | |||
| } | |||
| private _onObjectHit = (e: any)=>{ | |||
| if (!this._viewer) return | |||
| if (!this.enabled) { | |||
| e.intersects.selectedObject = null | |||
| return | |||
| } | |||
| this.dispatchEvent(e) | |||
| } | |||
| // @ts-expect-error temporary | |||
| public async focusObject(selected?: Object3D) { | |||
| // const camViews = this._viewer?.getPluginByType<CameraViewPlugin>('CameraViews') | |||
| // await camViews?.animateToFitObject(selected, 1.25, 1000, 'easeOut', {min: (this._viewer?.scene.activeCamera.getControls<OrbitControls3>()?.minDistance ?? 0.5) + 0.5, max: 50.0}) | |||
| } | |||
| public enableWidget(enable: boolean): void { | |||
| this._enableWidget = enable | |||
| if (enable) { | |||
| const selected = this._picker?.selectedObject || undefined | |||
| if (selected) | |||
| this._widget?.attach(selected) | |||
| } else { | |||
| this._widget?.detach() | |||
| } | |||
| } | |||
| private _uiConfigChildren: UiObjectConfig[] = [ | |||
| { | |||
| label: 'Enabled', | |||
| type: 'checkbox', | |||
| property: [this, 'enabled'], | |||
| }, | |||
| { | |||
| label: 'Hover Enabled', | |||
| type: 'checkbox', | |||
| property: [this, 'hoverEnabled'], | |||
| }, | |||
| { | |||
| label: 'AutoFocus', | |||
| type: 'checkbox', | |||
| property: [this, 'autoFocus'], | |||
| onChange: ()=>{ | |||
| const o = this.getSelectedObject() | |||
| if (this.autoFocus && o) this.setSelectedObject(o, true) | |||
| }, | |||
| }, | |||
| ] | |||
| uiConfig: UiObjectConfig = { | |||
| type: 'panel', | |||
| label: 'Picker', | |||
| expanded: true, | |||
| children: [ | |||
| ...this._uiConfigChildren, | |||
| ], | |||
| } | |||
| get widget(): SelectionWidget | undefined { | |||
| return this._widget | |||
| } | |||
| } | |||
| @@ -0,0 +1,262 @@ | |||
| import {Event, EventDispatcher, Intersection, Raycaster, Vector2} from 'three' | |||
| import {now} from 'ts-browser-helpers' | |||
| import {ICamera, IObject3D} from '../../core' | |||
| export class ObjectPicker extends EventDispatcher<Event, 'hoverObjectChanged'|'selectedObjectChanged'|'hitObject'> { | |||
| private _firstHit: IObject3D | undefined | |||
| hoverEnabled = false | |||
| private _root: IObject3D | |||
| private _camera: ICamera | |||
| private _mouseDownTime: number | |||
| private _mouseUpTime: number | |||
| private _time: number | |||
| public selectionCondition: (o: IObject3D) => boolean | |||
| public raycaster: Raycaster | |||
| public mouse: Vector2 | |||
| private _selected: IObject3D[] | |||
| private _hovering: IObject3D[] | |||
| public cursorStyles: {default: string; down: string} | |||
| public domElement: HTMLElement | |||
| constructor(root: IObject3D, domElement: HTMLElement, camera: ICamera, selectionCondition?: (o:IObject3D)=>boolean) { | |||
| super() | |||
| this._root = root | |||
| this._camera = camera | |||
| this.domElement = domElement | |||
| this._time = this.time | |||
| this._mouseDownTime = 0 | |||
| this._mouseUpTime = 1 | |||
| this.selectionCondition = selectionCondition ?? ( | |||
| (selectedObject: any) => { | |||
| return selectedObject.userData.userSelectable !== false && selectedObject.userData.bboxVisible !== false && selectedObject.material != null && selectedObject.material.type !== 'ShadowMaterial' // sample to select only mesh with material and not shadowmaterial. | |||
| }) | |||
| this.raycaster = new Raycaster() | |||
| this.mouse = new Vector2() | |||
| this._selected = [] | |||
| this._hovering = [] | |||
| this.cursorStyles = { | |||
| default: 'grab', | |||
| down: 'grabbing', | |||
| } | |||
| this.domElement.style.touchAction = 'none' | |||
| // this.domElement.style.cursor = this.cursorStyles.default | |||
| this.domElement.addEventListener('pointermove', this._onPointerMove) | |||
| this.domElement.addEventListener('pointerleave', this._onPointerLeave) | |||
| this.domElement.addEventListener('pointerout', this._onPointerLeave) | |||
| this.domElement.addEventListener('pointercancel', this._onPointerCancel) | |||
| this.domElement.addEventListener('pointerenter', this._onPointerEnter) | |||
| this.domElement.addEventListener('pointerdown', this._onPointerDown) | |||
| this.domElement.addEventListener('pointerup', this._onPointerUp) | |||
| } | |||
| dispose() { | |||
| this.selectedObject = null | |||
| this.hoverObject = null | |||
| this.domElement.removeEventListener('pointermove', this._onPointerMove) | |||
| this.domElement.removeEventListener('pointerleave', this._onPointerLeave) | |||
| this.domElement.removeEventListener('pointerout', this._onPointerLeave) | |||
| this.domElement.removeEventListener('pointercancel', this._onPointerCancel) | |||
| this.domElement.removeEventListener('pointerenter', this._onPointerEnter) | |||
| this.domElement.removeEventListener('pointerdown', this._onPointerDown) | |||
| this.domElement.removeEventListener('pointerup', this._onPointerUp) | |||
| } | |||
| get camera() { | |||
| return this._camera | |||
| } | |||
| set camera(value) { | |||
| this._camera = value | |||
| } | |||
| get selectedObject(): IObject3D | null { | |||
| return this._selected.length > 0 ? this._selected[0] : null | |||
| } | |||
| set selectedObject(object) { | |||
| if (!this._selected.length && !object || this._selected.length === 1 && this._selected[0] === object) return | |||
| this._selected = object ? Array.isArray(object) ? [...object] : [object] : [] | |||
| this.dispatchEvent({type: 'selectedObjectChanged', object: this.selectedObject}) | |||
| } | |||
| get hoverObject() { | |||
| return this._hovering.length > 0 ? this._hovering[0] : null | |||
| } | |||
| set hoverObject(object: IObject3D | IObject3D[] | null) { | |||
| if (!this._hovering.length && !object || this._hovering.length === 1 && this._hovering[0] === object) return | |||
| this._hovering = object ? Array.isArray(object) ? [...object] : [object] : [] | |||
| this.dispatchEvent({type: 'hoverObjectChanged', object: this.hoverObject}) | |||
| } | |||
| get time() { | |||
| this._time = now() | |||
| return this._time | |||
| } | |||
| get isMouseDown() { | |||
| return this.mouseDownDeltaTime < 0 | |||
| } | |||
| get mouseDownDeltaTime() { | |||
| return this._mouseUpTime - this._mouseDownTime | |||
| } | |||
| private _onPointerMove = (event: PointerEvent) => { | |||
| if (event.isPrimary === false) return | |||
| this.updateMouseFromEvent(event) | |||
| if (this.hoverEnabled) | |||
| this.hoverObject = this.checkIntersection()?.intersects[0].object ?? null | |||
| } | |||
| private _onPointerLeave = (event: PointerEvent) => { | |||
| if (event.isPrimary === false) return | |||
| this.domElement.style.cursor = this.cursorStyles.default | |||
| // this.updateMouseFromEvent(event); | |||
| if (this.hoverEnabled || this.hoverObject) | |||
| this.hoverObject = null | |||
| } | |||
| private _onPointerEnter = (_: PointerEvent) => { | |||
| // todo dispatch event? | |||
| } | |||
| private _onPointerCancel = (_: PointerEvent) => { | |||
| // todo dispatch event? | |||
| } | |||
| updateMouseFromEvent(event: PointerEvent) { | |||
| const rect = this.domElement.getBoundingClientRect() | |||
| this.mouse.x = (event.clientX - rect.x) / rect.width * 2 - 1 | |||
| this.mouse.y = -((event.clientY - rect.y) / rect.height) * 2 + 1 | |||
| } | |||
| private _onPointerDown = (event: PointerEvent) => { | |||
| if (event.isPrimary === false) return | |||
| this.domElement.style.cursor = this.cursorStyles.down | |||
| this._mouseDownTime = this.time | |||
| return undefined | |||
| } | |||
| private _onPointerUp = (event: PointerEvent) => { | |||
| if (event.isPrimary === false) return | |||
| this.domElement.style.cursor = this.cursorStyles.default | |||
| this._mouseUpTime = this.time | |||
| const delta = this.mouseDownDeltaTime | |||
| if (delta < 200) { | |||
| // click | |||
| this._onPointerClick(event) | |||
| } | |||
| return undefined | |||
| } | |||
| private _onPointerClick = (event: PointerEvent) => { | |||
| if (event.isPrimary === false) return | |||
| this.updateMouseFromEvent(event) | |||
| const intersects = this.checkIntersection() | |||
| if (intersects) | |||
| this.dispatchEvent({type: 'hitObject', time: this._mouseUpTime, intersects}) | |||
| this.selectedObject = intersects?.selectedObject || null | |||
| } | |||
| checkIntersection() { | |||
| const camera = this._camera | |||
| if (!camera) return null | |||
| this.raycaster.setFromCamera(this.mouse, camera) | |||
| let intersects = this.raycaster.intersectObject<IObject3D>(this._root, true) | |||
| const uniqueIds: number[] = [] | |||
| const uniqueIntersects = intersects.filter(element => { | |||
| const isDuplicate = uniqueIds.includes(element.object.id) | |||
| if (!isDuplicate) { | |||
| uniqueIds.push(element.object.id) | |||
| return true | |||
| } | |||
| return false | |||
| }) | |||
| intersects = uniqueIntersects | |||
| let selectedObject:IObject3D | null = null | |||
| let intersect: Intersection<IObject3D> | undefined | |||
| const intersects2 = [] | |||
| for (const intersect1 of intersects) { | |||
| selectedObject = intersect1.object | |||
| intersect = intersect1 | |||
| while (selectedObject != null && (!selectedObject.visible || !this.selectionCondition(selectedObject))) { | |||
| selectedObject = selectedObject.parent | |||
| } | |||
| if (selectedObject != null) intersects2.push(intersect1) | |||
| } | |||
| intersects = intersects2 | |||
| if (intersects.length > 0) { | |||
| selectedObject = intersects[0].object | |||
| intersect = intersects[0] | |||
| if (this._firstHit && selectedObject.id !== this._firstHit.id) { | |||
| selectedObject = intersect.object | |||
| } else { | |||
| for (let i = 0; i < intersects.length; i++) { | |||
| if (this.selectedObject && this.selectedObject.id === intersects[i].object.id) { | |||
| const n = i + 1 // Use ( i + 1 ) % intersects.length for looping through objects | |||
| if (n < intersects.length) { | |||
| intersect = intersects[n] | |||
| selectedObject = intersect.object | |||
| } else { | |||
| return null | |||
| } | |||
| } | |||
| } | |||
| } | |||
| this._firstHit = intersects[0].object | |||
| } | |||
| if (selectedObject && intersect) { | |||
| if (selectedObject) // sorted by distance | |||
| return {selectedObject, intersect, intersects, mouse: this.mouse.toArray()} | |||
| return null | |||
| } else { | |||
| return null | |||
| } | |||
| } | |||
| isHovering() { | |||
| return this.hoverObject != null // if something is highlighted. | |||
| } | |||
| isSelected() { | |||
| return this.selectedObject != null // if something is selected. | |||
| } | |||
| } | |||
| @@ -0,0 +1,110 @@ | |||
| import {Group, Sphere, Vector2} from 'three' | |||
| import {LineMaterial} from 'three/examples/jsm/lines/LineMaterial.js' | |||
| import {AnyOptions} from 'ts-browser-helpers' | |||
| import {Box3B} from '../math/Box3B' | |||
| import {IObject3D, IWidget} from '../../core' | |||
| import {LineSegments2} from 'three/examples/jsm/lines/LineSegments2.js' | |||
| import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry.js' | |||
| export class SelectionWidget extends Group implements IWidget { | |||
| isWidget = true as const | |||
| private _object: IObject3D | null = null | |||
| boundingScaleMultiplier = 1. | |||
| setDirty?: (options?: AnyOptions) => void | |||
| protected _updater() { | |||
| const selected: IObject3D | null | undefined = this._object | |||
| if (selected) { | |||
| const bbox = new Box3B().expandByObject(selected, false) | |||
| bbox.getCenter(this.position) | |||
| const scale = bbox.getBoundingSphere(new Sphere()).radius | |||
| this.scale.setScalar(scale * this.boundingScaleMultiplier) | |||
| this.setVisible(true) | |||
| } else { | |||
| this.setVisible(false) | |||
| } | |||
| } | |||
| constructor() { | |||
| super() | |||
| this.position.set(0, 0, 0) | |||
| this.visible = false | |||
| this.renderOrder = 100 // Don't draw too early, thus obscuring other transparent objects | |||
| this.userData.bboxVisible = false | |||
| this._updater = this._updater.bind(this) | |||
| } | |||
| setVisible(v: boolean) { | |||
| if (v !== this.visible) { | |||
| this.visible = v | |||
| this.setDirty?.({sceneUpdate: false}) | |||
| } | |||
| } | |||
| attach(object: IObject3D): this { | |||
| this.detach() | |||
| if (!object) return this | |||
| this._object = object | |||
| this._object.addEventListener('objectUpdate', this._updater) | |||
| this._updater() | |||
| return this | |||
| } | |||
| detach(): this { | |||
| if (!this._object) return this | |||
| this._object?.removeEventListener('objectUpdate', this._updater) | |||
| this._object = null | |||
| this._updater() | |||
| return this | |||
| } | |||
| get object(): IObject3D | null { | |||
| return this._object | |||
| } | |||
| dispose() { | |||
| this.detach() | |||
| } | |||
| } | |||
| export class BoxSelectionWidget extends SelectionWidget { | |||
| constructor() { | |||
| super() | |||
| const matLine = new LineMaterial({ | |||
| color: '#ff2222' as any, transparent: true, opacity: 0.9, | |||
| linewidth: 5, // in pixels | |||
| resolution: new Vector2(1024, 1024), // to be set by renderer, eventually | |||
| dashed: false, | |||
| toneMapped: false, | |||
| }) | |||
| const ls = new LineSegmentsGeometry() | |||
| ls.setPositions([1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1].map(v=>v - 0.5)) | |||
| const wireframe = new LineSegments2(ls, matLine) | |||
| wireframe.computeLineDistances() | |||
| wireframe.scale.set(1, 1, 1) | |||
| wireframe.visible = true | |||
| this.add(wireframe) | |||
| } | |||
| protected _updater() { | |||
| super._updater() | |||
| const selected = this.object | |||
| if (selected) { | |||
| const bbox = new Box3B().expandByObject(selected, false) | |||
| // const scale = bbox.getBoundingSphere(new Sphere()).radius | |||
| bbox.getSize(this.scale).multiplyScalar(this.boundingScaleMultiplier).clampScalar(0.1, 100) | |||
| this.setVisible(true) | |||
| } | |||
| } | |||
| } | |||
| @@ -6,5 +6,7 @@ export {getEncodingComponents, getTexelEncoding, getTexelDecoding, getTexelDecod | |||
| export {generateUUID, toIndexedGeometry, isInScene} from './misc' | |||
| export {getTextureDataType, textureToCanvas, textureDataToImageData, textureToDataUrl, texImageToCanvas} from './texture' | |||
| export {threeConstMappings} from './const-mappings' | |||
| export {ObjectPicker} from './ObjectPicker' | |||
| export {SelectionWidget, BoxSelectionWidget} from './SelectionWidget' | |||
| // export {} from './constants' | |||
| @@ -1 +1 @@ | |||
| export const VERSION = '0.0.14' | |||
| export const VERSION = '0.0.13' | |||