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.
318 lines (250 loc) • 11.6 kB
text/typescript
import {EventDispatcher, Intersection, Raycaster, Vector2} from 'three'
import {JSUndoManager, now, onChangeDispatchEvent} from 'ts-browser-helpers'
import {ICamera, IMaterial, IObject3D} from '../../core'
export interface ObjectPickerEventMap{
hoverObjectChanged: {object: IObject3D | null, material: IMaterial | null, value: IObject3D | IMaterial | null},
selectedObjectChanged: {object: IObject3D | null, material: IMaterial | null, value: IObject3D | IMaterial | null},
hitObject: {time: number, intersects: {selectedObject: IObject3D | null, intersect: Intersection<IObject3D> | null, intersects: Intersection<IObject3D>[]}}
selectionModeChanged: {detail: {key: 'selectionMode', value: 'object' | 'material', oldValue: 'object' | 'material'}}
}
export class ObjectPicker extends EventDispatcher<ObjectPickerEventMap> {
private _firstHit: IObject3D | undefined
hoverEnabled = false
selectionMode: 'object' | 'material' = 'object'
/**
* Time threshold for a pointer click event
*/
static PointerClickMaxTime = 200
/**
* Distance threshold for a pointer click event
*/
static PointerClickMaxDistance = 0.1 // 1/20 of the canvas
undoManager?: JSUndoManager
private _root: IObject3D
private _camera: ICamera
private _mouseDownTime: number
private _mouseDownPos: Vector2 = new Vector2()
private _mouseUpTime: number
private _time: number
public selectionCondition: (o: IObject3D) => boolean
public raycaster: Raycaster
public mouse: Vector2
private _selected: IObject3D[] | IMaterial[]
private _hovering: IObject3D[] | IMaterial[]
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.setSelected(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 | IMaterial | null {
return this._selected.length > 0 ? this._selected[0] : null
}
// set selectedObject(object) {
// this.setSelected(object)
// }
setSelected(object: IObject3D | IMaterial | null, record = true) {
if ((object as IObject3D)?.isObject3D && this.selectionMode === 'material' ||
(object as IMaterial)?.isMaterial && this.selectionMode === 'object') {
this.selectionMode = (object as IMaterial)?.isMaterial ? 'material' : 'object'
}
if (!this._selected.length && !object || this._selected.length === 1 && this._selected[0] === object) return
const current = [...this._selected]
this._selected = object ? Array.isArray(object) ? [...object] : [object] : []
const obj = this.selectedObject
this.dispatchEvent({
type: 'selectedObjectChanged',
object: (obj as IObject3D)?.isObject3D ? (obj as IObject3D) : null,
material: (obj as IMaterial)?.isMaterial ? (obj as IMaterial) : null,
value: obj,
})
record && this.undoManager?.record({
undo: () => this.setSelected(current.length ? current[0] : null, false),
redo: () => this.setSelected(object, false),
})
}
get hoverObject(): IObject3D | IMaterial | null {
return this._hovering.length > 0 ? this._hovering[0] : null
}
set hoverObject(object: IObject3D | IObject3D[] | IMaterial | IMaterial[] | null) {
if (!this._hovering.length && !object || this._hovering.length === 1 && this._hovering[0] === object) return
this._hovering = (object ? Array.isArray(object) ? [...object] : [object] : []) as (IObject3D[] | IMaterial[])
const obj = this.hoverObject
this.dispatchEvent({
type: 'hoverObjectChanged',
object: (obj as IObject3D)?.isObject3D ? (obj as IObject3D) : null,
material: (obj as IMaterial)?.isMaterial ? (obj as IMaterial) : null,
value: obj,
})
}
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
this._mouseDownPos.copy(this.mouse)
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
const dist = this._mouseDownPos.distanceTo(this.mouse)
if (delta < ObjectPicker.PointerClickMaxTime && dist < ObjectPicker.PointerClickMaxDistance) {
// 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})
else this.dispatchEvent({type: 'hitObject', time: this._mouseUpTime, intersects: {selectedObject: null, intersect: null, intersects: []}})
let obj: IObject3D|IMaterial|null = intersects?.selectedObject || null
if (this.selectionMode === 'material' && obj && obj.material) {
obj = Array.isArray(obj.material) ? obj.material[0] : obj.material
}
this.setSelected(obj)
}
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.
}
}