threepipe
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

ViewHelper2.ts 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. import {
  2. BackSide,
  3. Camera,
  4. CanvasTexture,
  5. Clock,
  6. Color,
  7. Euler,
  8. LinearFilter,
  9. Material,
  10. Mesh,
  11. MeshBasicMaterial,
  12. Object3D,
  13. OrthographicCamera,
  14. PerspectiveCamera,
  15. Quaternion,
  16. Raycaster,
  17. RepeatWrapping,
  18. SphereGeometry,
  19. Sprite,
  20. SpriteMaterial,
  21. SRGBColorSpace,
  22. Vector2,
  23. Vector3,
  24. Vector4,
  25. WebGLRenderer,
  26. } from 'three'
  27. import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry.js'
  28. import {LineSegments2} from 'three/examples/jsm/lines/LineSegments2.js'
  29. import {LineMaterial} from 'three/examples/jsm/lines/LineMaterial.js'
  30. import {onChangeDispatchEvent} from 'ts-browser-helpers'
  31. const [POS_X, POS_Y, POS_Z, NEG_X, NEG_Y, NEG_Z] = Array(6)
  32. .fill(0)
  33. .map((_, i) => i)
  34. const axesColors = [
  35. new Color(0xff3653),
  36. new Color(0x8adb00),
  37. new Color(0x2c8fff),
  38. ]
  39. const clock = new Clock()
  40. const targetPosition = new Vector3()
  41. const targetQuaternion = new Quaternion()
  42. // const euler = new Euler()
  43. const q1 = new Quaternion()
  44. const q2 = new Quaternion()
  45. const point = new Vector3()
  46. // const dim = 128
  47. const turnRate = 2 * Math.PI // turn rate in angles per second
  48. const raycaster = new Raycaster()
  49. const mouse = new Vector2()
  50. // const mouseStart = new Vector2()
  51. // const mouseAngle = new Vector2()
  52. const dummy = new Object3D()
  53. let radius = 0
  54. export type GizmoOrientation = '+x' | '-x' | '+y' | '-y' | '+z' | '-z'
  55. export type DomPlacement =
  56. | 'top-left'
  57. | 'top-right'
  58. | 'top-center'
  59. | 'center-right'
  60. | 'center-left'
  61. | 'center-center'
  62. | 'bottom-left'
  63. | 'bottom-right'
  64. | 'bottom-center'
  65. /**
  66. * Extended ViewHelper implemented from the following source:
  67. * https://github.com/Fennec-hub/viewHelper
  68. * MIT License
  69. * Copyright (c) 2022 Fennec-hub
  70. */
  71. export class ViewHelper2 extends Object3D {
  72. camera: OrthographicCamera | PerspectiveCamera
  73. orthoCamera = new OrthographicCamera(-1.8, 1.8, 1.8, -1.8, 0, 4)
  74. isViewHelper = true
  75. @onChangeDispatchEvent()
  76. animating = false
  77. target = new Vector3()
  78. backgroundSphere: Mesh
  79. axesLines: LineSegments2
  80. spritePoints: Sprite[]
  81. domElement: HTMLElement
  82. domContainer: HTMLElement
  83. domRect: DOMRect
  84. // dragging = false
  85. renderer: WebGLRenderer
  86. // controls?: OrbitControls | TrackballControls
  87. // controlsChangeEvent: {listener: () => void}
  88. viewport: Vector4 = new Vector4()
  89. offsetHeight = 0
  90. constructor(
  91. camera: PerspectiveCamera | OrthographicCamera,
  92. canvas: HTMLCanvasElement,
  93. placement: DomPlacement = 'bottom-right',
  94. size = 128,
  95. pixelRatio = 2,
  96. ) {
  97. super()
  98. this.renderer = new WebGLRenderer({
  99. canvas: document.createElement('canvas'),
  100. alpha: true,
  101. antialias: true,
  102. preserveDrawingBuffer: false,
  103. })
  104. this.renderer.setPixelRatio(pixelRatio)
  105. this.camera = camera
  106. this.domElement = canvas
  107. this.orthoCamera.position.set(0, 0, 2)
  108. this.backgroundSphere = getBackgroundSphere()
  109. this.axesLines = getAxesLines()
  110. this.spritePoints = getAxesSpritePoints()
  111. this.add(this.backgroundSphere, this.axesLines, ...this.spritePoints)
  112. this.domContainer = getDomContainer(placement, size)
  113. this.domContainer.appendChild(this.renderer.domElement)
  114. this.renderer.domElement.style.width = '100%'
  115. this.renderer.domElement.style.height = '100%'
  116. // This may cause confusion if the parent isn't the body and doesn't have a `position:relative`
  117. this.domElement.parentElement!.appendChild(this.domContainer)
  118. this.domRect = this.domContainer.getBoundingClientRect()
  119. this.startListening()
  120. // this.controlsChangeEvent = {listener: () => this.updateOrientation()}
  121. this.update()
  122. this.updateOrientation()
  123. }
  124. startListening() {
  125. // this.domContainer.onpointerdown = (e) => this.onPointerDown(e)
  126. this.domContainer.onpointermove = (e) => this.onPointerMove(e)
  127. this.domContainer.onpointerleave = (e) => this.onPointerLeave(e)
  128. this.domContainer.onclick = (e) => this.handleClick(e)
  129. }
  130. // onPointerDown(e: PointerEvent) {
  131. // const drag = (e1: PointerEvent) => {
  132. // if (!this.dragging && isClick(e1, mouseStart)) return
  133. // if (!this.dragging) {
  134. // resetSprites(this.spritePoints)
  135. // this.dragging = true
  136. // }
  137. //
  138. // mouseAngle
  139. // .set(e1.clientX, e1.clientY)
  140. // .sub(mouseStart)
  141. // .multiplyScalar(1 / this.domRect.width * Math.PI)
  142. //
  143. // this.rotation.x = MathUtils.clamp(
  144. // rotationStart.x + mouseAngle.y,
  145. // Math.PI / -2 + 0.001,
  146. // Math.PI / 2 - 0.001
  147. // )
  148. // this.rotation.y = rotationStart.y + mouseAngle.x
  149. // this.updateMatrixWorld()
  150. //
  151. // q1.copy(this.quaternion).invert()
  152. //
  153. // this.camera.position
  154. // .set(0, 0, 1)
  155. // .applyQuaternion(q1)
  156. // .multiplyScalar(radius)
  157. // .add(this.target)
  158. //
  159. // this.camera.rotation.setFromQuaternion(q1)
  160. //
  161. // this.updateOrientation(false)
  162. // }
  163. // const endDrag = () => {
  164. // document.removeEventListener('pointermove', drag, false)
  165. // document.removeEventListener('pointerup', endDrag, false)
  166. //
  167. // if (!this.dragging) {
  168. // // this.handleClick(e)
  169. // return
  170. // }
  171. //
  172. // this.dragging = false
  173. // }
  174. //
  175. // if (this.animating === true) return
  176. // e.preventDefault()
  177. //
  178. // mouseStart.set(e.clientX, e.clientY)
  179. //
  180. // const rotationStart = euler.copy(this.rotation)
  181. //
  182. // setRadius(this.camera, this.target)
  183. //
  184. // document.addEventListener('pointermove', drag, false)
  185. // document.addEventListener('pointerup', endDrag, false)
  186. // }
  187. onPointerMove(e: PointerEvent) {
  188. // if (this.dragging) return;
  189. (this.backgroundSphere.material as Material).opacity = 0.4
  190. this.handleHover(e)
  191. this.dispatchEvent({type: 'update', event: e})
  192. }
  193. onPointerLeave(e: PointerEvent) {
  194. // if (this.dragging) return;
  195. (this.backgroundSphere.material as Material).opacity = 0.2
  196. resetSprites(this.spritePoints)
  197. this.domContainer.style.cursor = ''
  198. this.dispatchEvent({type: 'update', event: e})
  199. }
  200. handleClick(e: PointerEvent|MouseEvent) {
  201. const object = getIntersectionObject(
  202. e,
  203. this.domRect,
  204. this.orthoCamera,
  205. this.spritePoints
  206. )
  207. if (!object) return
  208. this.setOrientation(object.userData.type)
  209. }
  210. handleHover(e: PointerEvent) {
  211. const object = getIntersectionObject(
  212. e,
  213. this.domRect,
  214. this.orthoCamera,
  215. this.spritePoints
  216. )
  217. resetSprites(this.spritePoints)
  218. if (!object) {
  219. this.domContainer.style.cursor = ''
  220. } else {
  221. object.material.map!.offset.x = 0.5
  222. object.scale.multiplyScalar(1.2)
  223. this.domContainer.style.cursor = 'pointer'
  224. }
  225. }
  226. // setControls(controls?: OrbitControls | TrackballControls) {
  227. // if (this.controls) {
  228. // (this.controls as any).removeEventListener(
  229. // 'change',
  230. // this.controlsChangeEvent.listener
  231. // )
  232. // this.target = new Vector3()
  233. // }
  234. //
  235. // if (!controls) return
  236. //
  237. // this.controls = controls;
  238. // (controls as any).addEventListener('change', this.controlsChangeEvent.listener)
  239. // this.target = controls.target
  240. // }
  241. render() {
  242. const delta = clock.getDelta()
  243. if (this.animating) this.animate(Math.min(delta, 1 / 30.0))
  244. // const x = this.domRect.left
  245. // const y = this.offsetHeight - this.domRect.bottom
  246. const autoClear = this.renderer.autoClear
  247. this.renderer.autoClear = false
  248. // this.renderer.setViewport(x, y, dim, dim)
  249. this.renderer.render(this, this.orthoCamera)
  250. // this.renderer.setViewport(this.viewport)
  251. this.renderer.autoClear = autoClear
  252. }
  253. updateOrientation(fromCamera = true) {
  254. if (fromCamera) {
  255. this.quaternion.copy(this.camera.quaternion).invert()
  256. this.updateMatrixWorld()
  257. }
  258. updateSpritesOpacity(this.spritePoints, this.camera)
  259. }
  260. update() {
  261. this.domRect = this.domContainer.getBoundingClientRect()
  262. this.offsetHeight = this.domElement.offsetHeight
  263. setRadius(this.camera, this.target)
  264. this.renderer.getViewport(this.viewport)
  265. this.updateOrientation()
  266. }
  267. animate(delta: number) {
  268. const step = delta * turnRate
  269. // animate position by doing a slerp and then scaling the position on the unit sphere
  270. q1.rotateTowards(q2, step)
  271. this.camera.position
  272. .set(0, 0, 1)
  273. .applyQuaternion(q1)
  274. .multiplyScalar(radius)
  275. .add(this.target)
  276. // animate orientation
  277. this.camera.quaternion.rotateTowards(targetQuaternion, step)
  278. this.updateOrientation()
  279. if (q1.angleTo(q2) === 0) {
  280. this.animating = false
  281. }
  282. }
  283. setOrientation(orientation: GizmoOrientation) {
  284. prepareAnimationData(this.camera, this.target, orientation)
  285. this.animating = true
  286. this.dispatchEvent({type: 'update'})
  287. }
  288. dispose() {
  289. this.axesLines.geometry.dispose();
  290. (this.axesLines.material as Material).dispose()
  291. this.backgroundSphere.geometry.dispose();
  292. (this.backgroundSphere.material as Material).dispose()
  293. this.spritePoints.forEach((sprite) => {
  294. sprite.material.map!.dispose()
  295. sprite.material.dispose()
  296. })
  297. this.domContainer.remove()
  298. // ;(this.controls as any)?.removeEventListener(
  299. // 'change',
  300. // this.controlsChangeEvent.listener
  301. // )
  302. }
  303. }
  304. function getDomContainer(placement: DomPlacement, size: number) {
  305. const div = document.createElement('div')
  306. const style = div.style
  307. style.height = `${size}px`
  308. style.width = `${size}px`
  309. style.borderRadius = '100%'
  310. style.position = 'absolute'
  311. const [y, x] = placement.split('-')
  312. style.transform = ''
  313. style.left = x === 'left' ? '0' : x === 'center' ? '50%' : ''
  314. style.right = x === 'right' ? '0' : ''
  315. style.transform += x === 'center' ? 'translateX(-50%)' : ''
  316. style.top = y === 'top' ? '0' : y === 'bottom' ? '' : '50%'
  317. style.bottom = y === 'bottom' ? '0' : ''
  318. style.transform += y === 'center' ? 'translateY(-50%)' : ''
  319. return div
  320. }
  321. function getAxesLines() {
  322. const distance = 0.9
  323. const position = Array(3)
  324. .fill(0)
  325. .map((_, i) => [
  326. !i ? distance : 0,
  327. i === 1 ? distance : 0,
  328. i === 2 ? distance : 0,
  329. 0,
  330. 0,
  331. 0,
  332. ])
  333. .flat()
  334. const color = Array(6)
  335. .fill(0)
  336. .map((_, i) =>
  337. i < 2
  338. ? axesColors[0].toArray()
  339. : i < 4
  340. ? axesColors[1].toArray()
  341. : axesColors[2].toArray()
  342. )
  343. .flat()
  344. // const geometry = new BufferGeometry()
  345. // geometry.setAttribute(
  346. // 'position',
  347. // new BufferAttribute(new Float32Array(position), 3)
  348. // )
  349. // geometry.setAttribute(
  350. // 'color',
  351. // new BufferAttribute(new Float32Array(color), 3)
  352. // )
  353. const geometry = new LineSegmentsGeometry()
  354. geometry.setPositions(position)
  355. geometry.setColors(color)
  356. return new LineSegments2(
  357. geometry,
  358. new LineMaterial({
  359. linewidth: 0.02,
  360. vertexColors: true,
  361. })
  362. )
  363. }
  364. function getBackgroundSphere() {
  365. const geometry = new SphereGeometry(1.6)
  366. const sphere = new Mesh(
  367. geometry,
  368. new MeshBasicMaterial({
  369. color: 0xffffff,
  370. side: BackSide,
  371. transparent: true,
  372. opacity: 0.2,
  373. depthTest: false,
  374. })
  375. )
  376. return sphere
  377. }
  378. function getAxesSpritePoints() {
  379. const axes = ['x', 'y', 'z'] as const
  380. return Array(6)
  381. .fill(0)
  382. .map((_, i) => {
  383. const isPositive = i < 3
  384. const sign = isPositive ? '+' : '-'
  385. const axis = axes[i % 3]
  386. const color = axesColors[i % 3]
  387. const sprite = new Sprite(
  388. getSpriteMaterial(color, isPositive ? axis : null)
  389. )
  390. sprite.userData.type = `${sign}${axis}`
  391. sprite.scale.setScalar(isPositive ? 0.6 : 0.4)
  392. sprite.position[axis] = isPositive ? 1.2 : -1.2
  393. sprite.renderOrder = 1
  394. return sprite
  395. })
  396. }
  397. function getSpriteMaterial(color: Color, text: 'x' | 'y' | 'z' | null = null) {
  398. const canvas = document.createElement('canvas')
  399. const padding = 0
  400. const scale = 1
  401. const padding2 = 0 // has a bug
  402. canvas.width = 128 * scale + 4 * padding + padding2 * 2
  403. canvas.height = 64 * scale + 2 * padding + padding2 * 2
  404. const context = canvas.getContext('2d', {alpha: true})!
  405. context.beginPath()
  406. context.arc(32 * scale + padding, 32 * scale + padding, 32 * scale - padding, 0, 2 * Math.PI)
  407. context.closePath()
  408. context.fillStyle = color.getStyle()
  409. context.fill()
  410. // for black border due to interpolation, transparent slightly bigger circle
  411. context.beginPath()
  412. context.arc(32 * scale + padding, 32 * scale + padding, 35 * scale - padding, 0, 2 * Math.PI)
  413. context.closePath()
  414. context.fillStyle = '#' + color.getHexString() + '01'
  415. context.fill()
  416. context.beginPath()
  417. context.arc(96 * scale + padding * 3 + padding2, 32 * scale + padding + padding2, 32 * scale - padding - padding2, 0, 2 * Math.PI)
  418. context.closePath()
  419. context.fillStyle = '#FFF'
  420. context.fill()
  421. // for black border due to interpolation, transparent slightly bigger circle
  422. context.beginPath()
  423. context.arc(96 * scale + padding * 3 + padding2, 32 * scale + padding + padding2, 35 + scale - padding - padding2, 0, 2 * Math.PI)
  424. context.closePath()
  425. context.fillStyle = '#FFFFFF01'
  426. context.fill()
  427. if (text !== null) {
  428. context.font = 'bold calc(44px * ' + scale + ') Arial'
  429. context.textAlign = 'center'
  430. context.fillStyle = '#111'
  431. context.fillText(text.toUpperCase(), 32 * scale + padding, 48 * scale + padding)
  432. context.fillText(text.toUpperCase(), 96 * scale + padding * 3 + padding2, 48 * scale + padding + padding2)
  433. }
  434. // canvas.style.background = '#ff0000'
  435. const texture = new CanvasTexture(canvas)
  436. texture.wrapS = texture.wrapT = RepeatWrapping
  437. texture.repeat.x = 0.5
  438. texture.colorSpace = SRGBColorSpace
  439. texture.minFilter = LinearFilter
  440. texture.magFilter = LinearFilter
  441. texture.generateMipmaps = false
  442. texture.needsUpdate = true
  443. return new SpriteMaterial({
  444. map: texture,
  445. toneMapped: false,
  446. transparent: true,
  447. })
  448. }
  449. function prepareAnimationData(
  450. camera: OrthographicCamera | PerspectiveCamera,
  451. focusPoint: Vector3,
  452. axis: GizmoOrientation
  453. ) {
  454. switch (axis) {
  455. case '+x':
  456. targetPosition.set(1, 0, 0)
  457. targetQuaternion.setFromEuler(new Euler(0, Math.PI * 0.5, 0))
  458. break
  459. case '+y':
  460. targetPosition.set(0, 1, 0)
  461. targetQuaternion.setFromEuler(new Euler(-Math.PI * 0.5, 0, 0))
  462. break
  463. case '+z':
  464. targetPosition.set(0, 0, 1)
  465. targetQuaternion.setFromEuler(new Euler())
  466. break
  467. case '-x':
  468. targetPosition.set(-1, 0, 0)
  469. targetQuaternion.setFromEuler(new Euler(0, -Math.PI * 0.5, 0))
  470. break
  471. case '-y':
  472. targetPosition.set(0, -1, 0)
  473. targetQuaternion.setFromEuler(new Euler(Math.PI * 0.5, 0, 0))
  474. break
  475. case '-z':
  476. targetPosition.set(0, 0, -1)
  477. targetQuaternion.setFromEuler(new Euler(0, Math.PI, 0))
  478. break
  479. default:
  480. console.error('ViewHelper: Invalid axis.')
  481. }
  482. setRadius(camera, focusPoint)
  483. prepareQuaternions(camera, focusPoint)
  484. }
  485. function setRadius(camera: Camera, focusPoint: Vector3) {
  486. radius = camera.position.distanceTo(focusPoint)
  487. }
  488. function prepareQuaternions(camera: Camera, focusPoint: Vector3) {
  489. targetPosition.multiplyScalar(radius).add(focusPoint)
  490. dummy.position.copy(focusPoint)
  491. dummy.lookAt(camera.position)
  492. q1.copy(dummy.quaternion)
  493. dummy.lookAt(targetPosition)
  494. q2.copy(dummy.quaternion)
  495. }
  496. function updatePointer(
  497. e: PointerEvent|MouseEvent,
  498. domRect: DOMRect,
  499. orthoCamera: OrthographicCamera
  500. ) {
  501. mouse.x = (e.clientX - domRect.left) / domRect.width * 2 - 1
  502. mouse.y = -((e.clientY - domRect.top) / domRect.height) * 2 + 1
  503. raycaster.setFromCamera(mouse, orthoCamera)
  504. }
  505. // function isClick(
  506. // e: PointerEvent,
  507. // startCoords: Vector2,
  508. // threshold = 2
  509. // ) {
  510. // return (
  511. // Math.abs(e.clientX - startCoords.x) < threshold &&
  512. // Math.abs(e.clientY - startCoords.y) < threshold
  513. // )
  514. // }
  515. function getIntersectionObject(
  516. event: PointerEvent|MouseEvent,
  517. domRect: DOMRect,
  518. orthoCamera: OrthographicCamera,
  519. intersectionObjects: Sprite[]
  520. ) {
  521. updatePointer(event, domRect, orthoCamera)
  522. const intersects = raycaster.intersectObjects(intersectionObjects)
  523. if (!intersects.length) return null
  524. const intersection = intersects[0]
  525. return intersection.object as Sprite
  526. }
  527. function resetSprites(sprites: Sprite[]) {
  528. let i = sprites.length
  529. while (i--) {
  530. const scale = i < 3 ? 0.6 : 0.4
  531. sprites[i].scale.set(scale, scale, scale)
  532. sprites[i].material.map!.offset.x = 1
  533. }
  534. // sprites.forEach((sprite) => (sprite.material.map!.offset.x = 1));
  535. }
  536. function updateSpritesOpacity(sprites: Sprite[], camera: Camera) {
  537. point.set(0, 0, 1)
  538. point.applyQuaternion(camera.quaternion)
  539. if (point.x >= 0) {
  540. sprites[POS_X].material.opacity = 1
  541. sprites[NEG_X].material.opacity = 0.5
  542. } else {
  543. sprites[POS_X].material.opacity = 0.5
  544. sprites[NEG_X].material.opacity = 1
  545. }
  546. if (point.y >= 0) {
  547. sprites[POS_Y].material.opacity = 1
  548. sprites[NEG_Y].material.opacity = 0.5
  549. } else {
  550. sprites[POS_Y].material.opacity = 0.5
  551. sprites[NEG_Y].material.opacity = 1
  552. }
  553. if (point.z >= 0) {
  554. sprites[POS_Z].material.opacity = 1
  555. sprites[NEG_Z].material.opacity = 0.5
  556. } else {
  557. sprites[POS_Z].material.opacity = 0.5
  558. sprites[NEG_Z].material.opacity = 1
  559. }
  560. }