diff --git a/package-lock.json b/package-lock.json index 515e8982c..f247a7d11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,13 +24,14 @@ "lodash.debounce": "^4.0.8", "loglevel": "^1.7.1", "marked": "^2.0.7", - "ngl": "https://github.com/eternagame/ngl/archive/aedf4afa935ce60fd4dbcf4c4100d4381a69c9e0.tar.gz", + "ngl": "https://github.com/eternagame/ngl/archive/dc6f494f4231dd2626b036094fc2535185915598.tar.gz", "pchip": "^1.0.2", "pixi-filters": "^4.1.1", "pixi-multistyle-text": "https://github.com/eternagame/pixi-multistyle-text/archive/1f2283b1d4a1bb262b57783e1bf2f81802a17809.tar.gz", "pixi.js": "^6.0.4", "regenerator-runtime": "^0.13.7", "store": "^2.0.12", + "three": "^0.118.0", "upng-js": "^2.1.0", "uuid": "^8.3.2", "webfontloader": "^1.6.28" @@ -16950,8 +16951,8 @@ }, "node_modules/ngl": { "version": "2.0.0-dev.39", - "resolved": "https://github.com/eternagame/ngl/archive/aedf4afa935ce60fd4dbcf4c4100d4381a69c9e0.tar.gz", - "integrity": "sha512-EcfUmPD2IF8NmEvpMZSrURKjHgO51gBSqZCqmyLSc50UxwInPdZG/llHC0kq27vFEK/KQLbgXj3o94ElllDCEw==", + "resolved": "https://github.com/eternagame/ngl/archive/dc6f494f4231dd2626b036094fc2535185915598.tar.gz", + "integrity": "sha512-/p80p4uG/2gvzDuCbhWuaoEHnbmiK8SaanSJmwnIhrhmt/4zlvM8xj0vusoWUOyea1BD37J4dK+5sN17tJfQTg==", "license": "MIT", "dependencies": { "chroma-js": "^1.3.7", @@ -21125,9 +21126,9 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" }, "node_modules/three": { - "version": "0.118.3", - "resolved": "https://registry.npmjs.org/three/-/three-0.118.3.tgz", - "integrity": "sha512-ijECXrNzDkHieoeh2H69kgawTGH8DiamhR4uBN8jEM7VHSKvfTdEvOoHsA8Aq7dh7PHAxhlqBsN5arBI3KixSw==" + "version": "0.118.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.118.0.tgz", + "integrity": "sha512-lCzCH6ZdOj4aO/qdp98vNyOY8NK5uGkVcd++vw2hTJMZwYLrEhAYirJ96C4j0/2bxaKRNyE+VF1G4nWeJP2l9g==" }, "node_modules/throat": { "version": "6.0.1", @@ -36890,8 +36891,8 @@ "integrity": "sha1-ddpKkn7liH45BliABltzNkE7MQ0=" }, "ngl": { - "version": "https://github.com/eternagame/ngl/archive/aedf4afa935ce60fd4dbcf4c4100d4381a69c9e0.tar.gz", - "integrity": "sha512-EcfUmPD2IF8NmEvpMZSrURKjHgO51gBSqZCqmyLSc50UxwInPdZG/llHC0kq27vFEK/KQLbgXj3o94ElllDCEw==", + "version": "https://github.com/eternagame/ngl/archive/dc6f494f4231dd2626b036094fc2535185915598.tar.gz", + "integrity": "sha512-/p80p4uG/2gvzDuCbhWuaoEHnbmiK8SaanSJmwnIhrhmt/4zlvM8xj0vusoWUOyea1BD37J4dK+5sN17tJfQTg==", "requires": { "chroma-js": "^1.3.7", "promise-polyfill": "^8.0.0", @@ -40180,9 +40181,9 @@ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=" }, "three": { - "version": "0.118.3", - "resolved": "https://registry.npmjs.org/three/-/three-0.118.3.tgz", - "integrity": "sha512-ijECXrNzDkHieoeh2H69kgawTGH8DiamhR4uBN8jEM7VHSKvfTdEvOoHsA8Aq7dh7PHAxhlqBsN5arBI3KixSw==" + "version": "0.118.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.118.0.tgz", + "integrity": "sha512-lCzCH6ZdOj4aO/qdp98vNyOY8NK5uGkVcd++vw2hTJMZwYLrEhAYirJ96C4j0/2bxaKRNyE+VF1G4nWeJP2l9g==" }, "throat": { "version": "6.0.1", diff --git a/package.json b/package.json index 88403540c..ab3fd0c2e 100644 --- a/package.json +++ b/package.json @@ -79,13 +79,14 @@ "lodash.debounce": "^4.0.8", "loglevel": "^1.7.1", "marked": "^2.0.7", - "ngl": "https://github.com/eternagame/ngl/archive/aedf4afa935ce60fd4dbcf4c4100d4381a69c9e0.tar.gz", + "ngl": "https://github.com/eternagame/ngl/archive/dc6f494f4231dd2626b036094fc2535185915598.tar.gz", "pchip": "^1.0.2", "pixi-filters": "^4.1.1", "pixi-multistyle-text": "https://github.com/eternagame/pixi-multistyle-text/archive/1f2283b1d4a1bb262b57783e1bf2f81802a17809.tar.gz", "pixi.js": "^6.0.4", "regenerator-runtime": "^0.13.7", "store": "^2.0.12", + "three": "^0.118.0", "upng-js": "^2.1.0", "uuid": "^8.3.2", "webfontloader": "^1.6.28" diff --git a/src/eterna/EternaApp.ts b/src/eterna/EternaApp.ts index 8caf5a991..13ab9af5e 100644 --- a/src/eterna/EternaApp.ts +++ b/src/eterna/EternaApp.ts @@ -184,6 +184,19 @@ export default class EternaApp extends FlashbangApp { Eterna.client = new GameClient(Eterna.SERVER_URL); Eterna.gameDiv = document.getElementById(this._params.containerID); + // Without this, we stop the pointer events from propagating to NGL in Pose3D/PointerEventPropagator, + // but the original mouse events will still get fired, so NGL will get confused since it tracks some + // events that happen outside its canvas, and these will be targeted to the Pixi canvas. + if (window.PointerEvent) { + this.view.addEventListener('mousedown', (e) => e.stopImmediatePropagation()); + this.view.addEventListener('mouseenter', (e) => e.stopImmediatePropagation()); + this.view.addEventListener('mouseleave', (e) => e.stopImmediatePropagation()); + this.view.addEventListener('mousemove', (e) => e.stopImmediatePropagation()); + this.view.addEventListener('mouseout', (e) => e.stopImmediatePropagation()); + this.view.addEventListener('mouseover', (e) => e.stopImmediatePropagation()); + this.view.addEventListener('mouseup', (e) => e.stopImmediatePropagation()); + } + Assert.assertIsDefined(this._regs); this._regs.add(Eterna.settings.soundMute.connectNotify((mute) => { diff --git a/src/eterna/mode/GameMode.ts b/src/eterna/mode/GameMode.ts index ed3ed3072..e23072d33 100644 --- a/src/eterna/mode/GameMode.ts +++ b/src/eterna/mode/GameMode.ts @@ -292,16 +292,16 @@ export default abstract class GameMode extends AppMode { this.addObject(this._pose3D, this.dialogLayer); this.regs?.add(this._pose3D.baseHovered.connect((closestIndex) => { this._poses.forEach((pose) => { - pose.on3DPickingMouseMoved(closestIndex - 1); + pose.on3DPickingMouseMoved(closestIndex); }); })); this.regs?.add(this._pose3D.baseClicked.connect((closestIndex) => { this._poses.forEach((pose) => { - pose.simulateMousedownCallback(closestIndex - 1); + pose.simulateMousedownCallback(closestIndex); }); })); this.regs?.add(this._poses[0].baseHovered.connect( - (val: {index: number; color: number}) => this._pose3D?.hover3D(val.index, val.color) + (val: number) => this._pose3D?.hover3D(val) )); this.regs?.add(this._poses[0].baseMarked.connect( (val: number) => this._pose3D?.mark3D(val) diff --git a/src/eterna/pose2D/Pose2D.ts b/src/eterna/pose2D/Pose2D.ts index 2a44fdfdf..795d6a198 100644 --- a/src/eterna/pose2D/Pose2D.ts +++ b/src/eterna/pose2D/Pose2D.ts @@ -73,7 +73,7 @@ export default class Pose2D extends ContainerObject implements Updatable { public static readonly ZOOM_SPACINGS: number[] = [45, 30, 20, 14, 7]; public readonly baseMarked = new Signal(); - public readonly baseHovered = new Signal<{index: number; color: number;}>(); + public readonly baseHovered = new Signal(); public readonly basesSparked = new Signal(); constructor(poseField: PoseField, editable: boolean, annotationManager: AnnotationManager) { @@ -1236,7 +1236,7 @@ export default class Pose2D extends ContainerObject implements Updatable { } if (closestIndex >= 0 && this._currentColor >= 0) { - this.baseHovered.emit({index: closestIndex + 1, color: this._currentColor}); + this.baseHovered.emit(closestIndex); this.onBaseMouseMove(closestIndex); @@ -1260,7 +1260,7 @@ export default class Pose2D extends ContainerObject implements Updatable { } } else { this._lastColoredIndex = -1; - this.baseHovered.emit({index: -1, color: 0}); + this.baseHovered.emit(-1); } if (!this._coloring) { diff --git a/src/eterna/pose3D/BaseHighlightGroup.ts b/src/eterna/pose3D/BaseHighlightGroup.ts new file mode 100644 index 000000000..e888949e8 --- /dev/null +++ b/src/eterna/pose3D/BaseHighlightGroup.ts @@ -0,0 +1,193 @@ +import {Stage} from 'ngl'; +import { + BufferGeometry, Group, Mesh, MeshBasicMaterial, Vector3 +} from 'three'; +import {OutlinePass} from 'three/examples/jsm/postprocessing/OutlinePass'; +import EternaEllipsoidBuffer from './EternaEllipsoidBuffer'; + +type HighlightMesh = Mesh & {material: MeshBasicMaterial}; + +export default class BaseHighlightGroup extends Group { + constructor(stage: Stage, selectedOutlinePass: OutlinePass) { + super(); + this._stage = stage; + this._selectedOutlinePass = selectedOutlinePass; + } + + public clearHover() { + if (this._hoverHighlight) { + this.remove(this._hoverHighlight.mesh); + const baseIndex = this._hoverHighlight.baseIndex; + this._hoverHighlight = null; + // Since we're no longer highlighted, shrink the changed highlight to be just around + // the base rather than the highlight + if (this._changeHighlights.has(baseIndex)) { + this.addChanged(baseIndex, false); + } + } + } + + public switchHover(baseIndex: number, color: number) { + this.clearHover(); + + const newMesh = this.highlight(baseIndex, color, 0.6, 1.3); + if (newMesh) { + this._hoverHighlight = {baseIndex, mesh: newMesh}; + // Since we're now highlighted, expand the changed highlight to be around the entire + // hover highlight rather than just the base (as otherwise it would look weird since the + // change highlight would be partially masked by the hover highlight) + if (this._changeHighlights.has(baseIndex)) this.addChanged(baseIndex, false); + } + } + + public updateHoverColor(getColor: (baseIndex: number) => number) { + if (this._hoverHighlight) { + this.switchHover(this._hoverHighlight.baseIndex, getColor(this._hoverHighlight.baseIndex)); + } + } + + public addChanged(baseIndex: number, resetExpire: boolean = true) { + if (resetExpire) { + const oldHighlight = this._changeHighlights.get(baseIndex); + if (oldHighlight) { + clearTimeout(oldHighlight.expireHandle); + oldHighlight.expireHandle = setTimeout( + this.clearChangedHighlight.bind(this) as TimerHandler, 3000, baseIndex + ); + } else { + const hovered = this._hoverHighlight?.baseIndex === baseIndex; + const newMesh = this.highlight(baseIndex, 0xFFFFFF, 0, hovered ? 1.3 : 1); + if (newMesh) { + const expireHandle = setTimeout( + this.clearChangedHighlight.bind(this) as TimerHandler, 3000, baseIndex + ); + this._changeHighlights.set(baseIndex, {mesh: newMesh, expireHandle}); + } + } + } else { + // Just re-generate the mesh without affecting the highlight expiration + const highlight = this._changeHighlights.get(baseIndex); + if (!highlight) return; + + this.remove(highlight.mesh); + + const hovered = this._hoverHighlight?.baseIndex === baseIndex; + const newMesh = this.highlight(baseIndex, 0xFFFFFF, 0, hovered ? 1.3 : 1); + if (newMesh) highlight.mesh = newMesh; + } + + // While technically wasteful to do this in all cases even if highlights haven't changed, we'll + // do this here for robustness just to make sure we don't miss any edge cases + this._selectedOutlinePass.selectedObjects = Array + .from(this._changeHighlights.values()) + .map((highlight) => highlight.mesh); + } + + private clearChangedHighlight(baseIndex: number) { + const oldHighlight = this._changeHighlights.get(baseIndex); + if (oldHighlight) { + this.remove(oldHighlight.mesh); + this._changeHighlights.delete(baseIndex); + } + + this._selectedOutlinePass.selectedObjects = Array + .from(this._changeHighlights.values()) + .map((highlight) => highlight.mesh); + + this._stage.viewer.requestRender(); + } + + public toggleMark(baseIndex: number) { + const oldMesh = this._markHighlights.get(baseIndex); + if (oldMesh) { + this.remove(oldMesh); + this._markHighlights.delete(baseIndex); + } else { + const newMesh = this.highlight(baseIndex, 0x000000, 0.6, 1); + if (newMesh) this._markHighlights.set(baseIndex, newMesh); + } + this.updateFlashing(); + } + + private updateFlashing() { + const shouldBeFlashing = this._markHighlights.size > 0; + if (shouldBeFlashing && this._flashHandle === null) { + this._flashHandle = setInterval(this.flash.bind(this) as TimerHandler, 500); + } else if (!shouldBeFlashing && this._flashHandle !== null) { + clearInterval(this._flashHandle); + this._flashHandle = null; + } + } + + private flash() { + this._flashCount++; + + for (const mesh of this._markHighlights.values()) { + mesh.material.opacity = (this._flashCount % 2) * 0.6; + } + + this._stage.viewer.requestRender(); + } + + private highlight(baseIndex: number, color: number, opacity: number, scale: number): HighlightMesh | null { + const rep = this._stage.getRepresentationsByName('eterna').first; + if (!rep) return null; + + const baseBuff = rep.repr.bufferList.find( + (buff): buff is EternaEllipsoidBuffer => buff instanceof EternaEllipsoidBuffer + ); + if (!baseBuff) return null; + + const basePositions: Vector3[] = []; + const positions = baseBuff.geometry.getAttribute('position').array; + const ids = baseBuff.geometry.getAttribute('primitiveId').array; + for (let i = 0; i < ids.length; i++) { + if (ids[i] === baseIndex) { + basePositions.push(new Vector3( + positions[i * 3], + positions[i * 3 + 1], + positions[i * 3 + 2] + )); + } + } + + const selGeometry = new BufferGeometry(); + selGeometry.setFromPoints(basePositions); + + if (scale !== 1) { + const avgPos = new Vector3(); + for (const pos of basePositions) { + avgPos.add(pos); + } + avgPos.divideScalar(basePositions.length); + + selGeometry.translate(-avgPos.x, -avgPos.y, -avgPos.z); + selGeometry.scale(scale, scale, scale); + selGeometry.translate(avgPos.x, avgPos.y, avgPos.z); + } + + const mat = new MeshBasicMaterial({ + color, + opacity, + transparent: true, + depthWrite: false + }); + const mesh = new Mesh(selGeometry, mat); + mesh.name = baseIndex.toString(); + this.add(mesh); + + this._stage.viewer.requestRender(); + + return mesh; + } + + private _stage: Stage; + private _selectedOutlinePass: OutlinePass; + + private _hoverHighlight: {baseIndex: number, mesh: HighlightMesh} | null = null; + private _changeHighlights = new Map(); + private _markHighlights = new Map(); + + private _flashHandle: number | null = null; + private _flashCount = 0; +} diff --git a/src/eterna/pose3D/NGLColorScheme.ts b/src/eterna/pose3D/EternaColorScheme.ts similarity index 82% rename from src/eterna/pose3D/NGLColorScheme.ts rename to src/eterna/pose3D/EternaColorScheme.ts index d5d98b520..2efb74ee2 100644 --- a/src/eterna/pose3D/NGLColorScheme.ts +++ b/src/eterna/pose3D/EternaColorScheme.ts @@ -27,11 +27,11 @@ export function getBaseColor(base: RNABase) { } export default function createColorScheme(sequence: Value) { - class NGLColorScheme extends Colormaker { + class EternaColorScheme extends Colormaker { public atomColor(atom: AtomProxy): number { - return getBaseColor(sequence.value.nt(atom.resno - 1)); + return getBaseColor(sequence.value.nt(atom.residueIndex)); } } - return ColormakerRegistry._addUserScheme(NGLColorScheme); + return ColormakerRegistry._addUserScheme(EternaColorScheme); } diff --git a/src/eterna/pose3D/EternaEllipsoidBuffer.ts b/src/eterna/pose3D/EternaEllipsoidBuffer.ts new file mode 100644 index 000000000..f71cb599d --- /dev/null +++ b/src/eterna/pose3D/EternaEllipsoidBuffer.ts @@ -0,0 +1,28 @@ +import {EllipsoidBuffer} from 'ngl'; +import type {EllipsoidBufferData} from 'ngl/dist/declarations/buffer/ellipsoid-buffer'; + +type EternaEllipsoidBufferParameters = EternaEllipsoidBuffer['defaultParameters']; + +export default class EternaEllipsoidBuffer extends EllipsoidBuffer { + public get defaultParameters() { + return { + ...super.defaultParameters, + vScale: 1 + }; + } + + constructor(data: EllipsoidBufferData, params: Partial = {}) { + super(data, params); + this._vScale = params.vScale ?? 1; + // Reset the attributes now that we have our proper vScale set + this.setAttributes(data, true); + } + + public setAttributes(data: Partial = {}, initNormals?: boolean) { + // Scale sphere in 3 axises to make ellipsoid + const radius = data.radius ? data.radius.map((r) => r * this._vScale) : undefined; + super.setAttributes({...data, radius}, initNormals); + } + + private _vScale: number = 1; +} diff --git a/src/eterna/pose3D/EternaRepresentation.ts b/src/eterna/pose3D/EternaRepresentation.ts new file mode 100644 index 000000000..278cb7cc6 --- /dev/null +++ b/src/eterna/pose3D/EternaRepresentation.ts @@ -0,0 +1,420 @@ +import { + AtomProxy, ConeBuffer, + RepresentationRegistry, + Structure, StructureRepresentation, StructureRepresentationParameters, Viewer, WidelineBuffer +} from 'ngl'; +import type Buffer from 'ngl/dist/declarations/buffer/buffer'; +import type {ConeBufferData} from 'ngl/dist/declarations/buffer/cone-buffer'; +import type {CylinderBufferData} from 'ngl/dist/declarations/buffer/cylinder-buffer'; +import type {EllipsoidBufferData} from 'ngl/dist/declarations/buffer/ellipsoid-buffer'; +import type {WideLineBufferData} from 'ngl/dist/declarations/buffer/wideline-buffer'; +import type {StructureRepresentationData} from 'ngl/dist/declarations/representation/structure-representation'; +import type { + AtomDataFields, BondData, BondDataFields, BondDataParams +} from 'ngl/dist/declarations/structure/structure-data'; +import type StructureView from 'ngl/dist/declarations/structure/structure-view'; +import {v4 as uuidv4} from 'uuid'; +import {Value} from 'signals'; +import SecStruct from 'eterna/rnatypes/SecStruct'; +import Sequence from 'eterna/rnatypes/Sequence'; +import EternaEllipsoidBuffer from './EternaEllipsoidBuffer'; + +enum BondColor { + STRONG = 0xFFFFFF, + MEDIUM = 0x8F9DB0, + WEAK = 0x546986, + NONE = 0xFFFFFF +} + +export interface EternaRepresentationParameters extends StructureRepresentationParameters { + vScale: number; +} + +class EternaRepresentationImpl extends StructureRepresentation { + constructor( + structure: Structure, + viewer: Viewer, + params: Partial, + sequence: Value, + secStruct: Value + ) { + super(structure, viewer, params); + this.type = 'eterna'; + this._sequence = sequence; + this._secStruct = secStruct; + this.init(params); + } + + public init(params: Partial) { + const p = params || {}; + p.radiusSize = p.radiusSize ?? 0.3; + this.vScale = p.vScale ?? 1; + + super.init(p); + } + + private getBondData(sView: StructureView, what?: BondDataFields, params?: BondDataParams): BondData { + const p = this.getBondParams(what, params); + Object.assign(p.colorParams, {rung: true}); + + return sView.getRungBondData(p); + } + + public createData(sView: StructureView) { + const bufferList: Buffer[] = []; + + const p = this.getBondParams({position: true, picking: true}); + const rawBondData = sView.getBondData(p); + + const bondData = this.getBondData(sView); + this.fullBondData = bondData; + + const baseData = this.getBaseData(bondData, rawBondData); + + if (baseData) { + const ellipsoidBuffer = new EternaEllipsoidBuffer(baseData, {vScale: this.vScale}); + ellipsoidBuffer.geometry.name = 'eternabase'; + + bufferList.push(ellipsoidBuffer); + + const pairData = this.getPairData(bondData); + if (pairData !== null) { + const coneBuffer = new ConeBuffer( + (pairData[0] as ConeBufferData), + this.getBufferParams({ + openEnded: false, + radialSegments: this.radialSegments, + disableImpostor: this.disableImpostor, + dullInterior: true + }) + ); + bufferList.push(coneBuffer); + + const coneBuffer2 = new ConeBuffer( + (pairData[1] as ConeBufferData), + this.getBufferParams({ + openEnded: false, + radialSegments: this.radialSegments, + disableImpostor: this.disableImpostor, + dullInterior: true + }) + ); + bufferList.push(coneBuffer2); + + const lineBuffer = new WidelineBuffer( + pairData[2], + this.getBufferParams({linewidth: 1}) + ); + bufferList.push(lineBuffer); + } + } + + return {bufferList}; + } + + private getBaseData(bondData: BondData, rawBondData: BondData) { + const pos1 = bondData.position1; + const pos2 = bondData.position2; + + if (!pos1 || !pos2) return null; + + const majorAxis = []; + const minorAxis = []; + const position = new Float32Array(pos1.length); + const radius = new Float32Array(pos1.length / 3); + for (let i = 0; i < pos1.length / 3; i++) { + const i3 = i * 3; + position[i3] = (pos1[i3] + pos2[i3]) / 2; + position[i3 + 1] = (pos1[i3 + 1] + pos2[i3 + 1]) / 2; + position[i3 + 2] = (pos1[i3 + 2] + pos2[i3 + 2]) / 2; + + let r = 0; + r += (pos1[i3] - position[i3]) * (pos1[i3] - position[i3]); + r += (pos1[i3 + 1] - position[i3 + 1]) * (pos1[i3 + 1] - position[i3 + 1]); + r += (pos1[i3 + 2] - position[i3 + 2]) * (pos1[i3 + 2] - position[i3 + 2]); + radius[i] = Math.sqrt(r); + + const x = (pos2[i3] - position[i3]); + const y = (pos2[i3 + 1] - position[i3 + 1]); + const z = (pos2[i3 + 2] - position[i3 + 2]); + majorAxis.push(x); + majorAxis.push(y); + majorAxis.push(z); + + let x1; + let y1; + let z1; + if (bondData.picking && rawBondData.picking) { + const atomIndex2 = bondData.picking.bondStore.atomIndex2; + const id = atomIndex2[i]; + let id2; + let n1; + if ((n1 = rawBondData.picking.bondStore.atomIndex1.indexOf(id), n1 >= 0)) { + id2 = rawBondData.picking.bondStore.atomIndex2[n1]; + } else if ((n1 = rawBondData.picking.bondStore.atomIndex2.indexOf(id), n1 >= 0)) { + id2 = rawBondData.picking.bondStore.atomIndex1[n1]; + } else id2 = id + 1; + + const ap1 = new AtomProxy(bondData.picking.structure); + ap1.index = id; + const ap2 = new AtomProxy(bondData.picking.structure); + ap2.index = id2; + const dx = ap2.x - ap1.x; + const dy = ap2.y - ap1.y; + const dz = ap2.z - ap1.z; + x1 = y * dz - z * dy; + y1 = z * dx - x * dz; + z1 = x * dy - y * dx; + } else if (z !== 0) { + x1 = 1; + y1 = 1; + z1 = -(x1 * x + y1 * y) / z; + } else if (y !== 0) { + x1 = 1; + z1 = 1; + y1 = -(x1 * x + z1 * z) / y; + } else { + y1 = 1; + z1 = 1; + x1 = -(y1 * y + z1 * z) / x; + } + const d1 = Math.sqrt(x1 * x1 + y1 * y1 + z1 * z1); + x1 /= d1; + y1 /= d1; + z1 /= d1; + const wScale = 0.05; + minorAxis.push(x1 * r * wScale); + minorAxis.push(y1 * r * wScale); + minorAxis.push(z1 * r * wScale); + } + + return { + ...bondData, + position, + radius, + majorAxis: new Float32Array(majorAxis), + minorAxis: new Float32Array(minorAxis) + }; + } + + private getPairData(data: BondData): [ConeBufferData, ConeBufferData, WideLineBufferData] | null { + if (data.position2 !== undefined) { + const pairs = this._secStruct.value.pairs; + const seq = this._sequence.value.toString(); + + const pos01 = []; + const pos02 = []; + const colors0: number[] = []; + const pos1 = []; + const pos2 = []; + const colors: number[] = []; + const radius: number[] = []; + const pairMap = new Map(); + const strengthArray = []; + + for (let i = 0; i < pairs.length; i++) { + const pairNum = pairs[i]; + if (pairNum < 0) continue; + if (pairMap.get(i) === pairNum || pairMap.get(pairNum) === i) continue; + pairMap.set(i, pairNum); + + let strength = 0; + if ((seq[i] === 'G' && seq[pairNum] === 'C') || (seq[i] === 'C' && seq[pairNum] === 'G')) { + strength = 3; + } else if ((seq[i] === 'A' && seq[pairNum] === 'U') || (seq[i] === 'U' && seq[pairNum] === 'A')) { + strength = 2; + } else if ((seq[i] === 'U' && seq[pairNum] === 'G') || (seq[i] === 'G' && seq[pairNum] === 'U')) { + strength = 1; + } + + let x1 = data.position2[i * 3]; + let y1 = data.position2[i * 3 + 1]; + let z1 = data.position2[i * 3 + 2]; + let x2 = data.position2[pairNum * 3]; + let y2 = data.position2[pairNum * 3 + 1]; + let z2 = data.position2[pairNum * 3 + 2]; + const dx = x2 - x1; + x1 += dx / 40; + x2 -= dx / 40; + const dy = y2 - y1; + y1 += dy / 40; + y2 -= dy / 40; + const dz = z2 - z1; + z1 += dz / 40; + z2 -= dz / 40; + + if (strength > 0) { + pos1.push(x1); + pos1.push(y1); + pos1.push(z1); + pos2.push(x2); + pos2.push(y2); + pos2.push(z2); + + radius.push(0.2 * strength); + strengthArray.push(strength); + } else { + pos01.push(x1); + pos01.push(y1); + pos01.push(z1); + pos02.push(x2); + pos02.push(y2); + pos02.push(z2); + } + + if (strength === 3) { + const color = BondColor.STRONG; + const r = (color >> 16) & 255; + const g = (color >> 8) & 255; + const b = color & 255; + colors.push(r / 255.0); + colors.push(g / 255.0); + colors.push(b / 255.0); + } else if (strength === 2) { + const color = BondColor.MEDIUM; + const r = (color >> 16) & 255; + const g = (color >> 8) & 255; + const b = color & 255; + colors.push(r / 255.0); + colors.push(g / 255.0); + colors.push(b / 255.0); + } else if (strength === 1) { + const color:number = BondColor.WEAK; + const r = (color >> 16) & 255; + const g = (color >> 8) & 255; + const b = color & 255; + colors.push(r / 255.0); + colors.push(g / 255.0); + colors.push(b / 255.0); + } else { + const color:number = BondColor.NONE; + const r = (color >> 16) & 255; + const g = (color >> 8) & 255; + const b = color & 255; + colors0.push(r / 255.0); + colors0.push(g / 255.0); + colors0.push(b / 255.0); + } + } + + const bondData: CylinderBufferData = { + position1: new Float32Array(pos1), + position2: new Float32Array(pos2), + radius: new Float32Array(radius), + color: new Float32Array(colors), + color2: new Float32Array(colors) + }; + + const weight = [0.0, 0.55, 0.8, 1.0]; + const rweight = [0, 4, 4, 4]; + const bond1Pos = []; + if (bondData.position1 && bondData.position2) { + for (let i = 0; i < bondData.position1.length / 3; i++) { + const strength = strengthArray[i]; + bond1Pos.push( + bondData.position1[3 * i] * (1 - weight[strength]) + + bondData.position2[3 * i] * weight[strength] + ); + bond1Pos.push( + bondData.position1[3 * i + 1] * (1 - weight[strength]) + + bondData.position2[3 * i + 1] * weight[strength] + ); + bond1Pos.push( + bondData.position1[3 * i + 2] * (1 - weight[strength]) + + bondData.position2[3 * i + 2] * weight[strength] + ); + bondData.radius[i] = 0.2 * rweight[strength]; + } + } + const bondData1: CylinderBufferData = { + position1: bondData.position1, + position2: new Float32Array(bond1Pos), + radius: bondData.radius, + color: bondData.color, + color2: bondData.color2 + }; + + const bond2Pos = []; + if (bondData.position1 && bondData.position2) { + for (let i = 0; i < bondData.position1.length / 3; i++) { + const strength = strengthArray[i]; + bond2Pos.push( + bondData.position2[3 * i] * (1 - weight[strength]) + + bondData.position1[3 * i] * weight[strength] + ); + bond2Pos.push( + bondData.position2[3 * i + 1] * (1 - weight[strength]) + + bondData.position1[3 * i + 1] * weight[strength] + ); + bond2Pos.push( + bondData.position2[3 * i + 2] * (1 - weight[strength]) + + bondData.position1[3 * i + 2] * weight[strength] + ); + bondData.radius[i] = 0.2 * rweight[strength]; + } + } + const bondData2: CylinderBufferData = { + position1: bondData.position2, + position2: new Float32Array(bond2Pos), + radius: bondData.radius, + color: bondData.color, + color2: bondData.color2 + }; + + const bondData3: WideLineBufferData = { + position1: new Float32Array(pos01), + position2: new Float32Array(pos02), + color: new Float32Array(colors0), + color2: new Float32Array(colors0) + }; + + return [bondData1, bondData2, bondData3]; + } + return null; + } + + public updateData(what: BondDataFields | AtomDataFields, data: StructureRepresentationData) { + if (this.multipleBond !== 'off' && what && what.radius) { + what.position = true; + } + if (data.bufferList == null) return; + + const bondData = this.getBondData(data.sview as StructureView, what); + + const ellipsoidData: Partial = {}; + + if (!what || what.color) { + Object.assign(ellipsoidData, { + color: bondData.color, + color2: bondData.color2 + }); + } + data.bufferList[0].setAttributes(ellipsoidData); + + const pairData = this.getPairData(this.fullBondData); + if (pairData !== null) { + data.bufferList[1].setAttributes(pairData[0]); + data.bufferList[2].setAttributes(pairData[1]); + data.bufferList[3].setAttributes(pairData[2]); + } + this.build(); + } + + private _secStruct: Value; + private _sequence: Value; +} + +export default function createEternaRepresentation( + sequence: Value, + secStruct: Value +) { + class EternaRepresentation extends EternaRepresentationImpl { + constructor(structure: Structure, viewer: Viewer, params: Partial) { + super(structure, viewer, params, sequence, secStruct); + } + } + + const id = `eterna-${uuidv4()}`; + RepresentationRegistry.add(id, EternaRepresentation); + return id; +} diff --git a/src/eterna/pose3D/NGLPickingUtils.ts b/src/eterna/pose3D/NGLPickingUtils.ts index c710c5328..67c6264d2 100644 --- a/src/eterna/pose3D/NGLPickingUtils.ts +++ b/src/eterna/pose3D/NGLPickingUtils.ts @@ -1,22 +1,24 @@ +import EPars from 'eterna/EPars'; +import Sequence from 'eterna/rnatypes/Sequence'; import {PickingProxy} from 'ngl'; export default class NGLPickingUtils { /** * Checks if atom clicked in 3D is a base * @param pickingProxy PickingProxy passed to pick mouse action - * @returns Picked base number if it is base, otherwise null + * @returns Picked base index if it is base, otherwise null */ public static checkForBase(pickingProxy: PickingProxy): number | null { if (pickingProxy.bond) { if ( - (pickingProxy.bond.atom1.resno === pickingProxy.bond.atom2.resno) + (pickingProxy.bond.atom1.residueIndex === pickingProxy.bond.atom2.residueIndex) && pickingProxy.bond.atom1.atomname.includes("C4'") && ( pickingProxy.bond.atom2.atomname.includes('N1') || pickingProxy.bond.atom2.atomname.includes('N3') ) ) { - return pickingProxy.bond.atom1.resno; + return pickingProxy.bond.atom1.residueIndex; } } return null; @@ -27,20 +29,26 @@ export default class NGLPickingUtils { * @param pickingProxy PickingProxy passed to pick mouse action * @returns The contents of the label */ - public static getLabel(pickingProxy: PickingProxy, customNumbering: (number|null)[] | undefined) { + public static getLabel( + pickingProxy: PickingProxy, + sequence: Sequence, + customNumbering: (number|null)[] | undefined + ) { const clickedBase = NGLPickingUtils.checkForBase(pickingProxy); // For now we just won't label atoms that aren't bases. In the future, we may want to do // something like using pickingProxy.getLabel to get the default label instead. if (clickedBase === null) return ''; - let baseNumber: string = pickingProxy.bond.atom1.resno.toString(); + let baseNumber: string = (pickingProxy.bond.atom1.residueIndex + 1).toString(); if (customNumbering) { - const customNumber = customNumbering[pickingProxy.bond.atom1.resno - 1]; + const customNumber = customNumbering[pickingProxy.bond.atom1.residueIndex]; if (customNumber === null) baseNumber = 'N/A'; else baseNumber = customNumber.toString(); } - return `${baseNumber}: ${pickingProxy.bond.atom1.resname}`; + const baseType = EPars.nucleotideToString(sequence.nt(pickingProxy.bond.atom1.residueIndex)); + + return `${baseNumber}: ${baseType}`; } } diff --git a/src/eterna/pose3D/NGLRenderPass.ts b/src/eterna/pose3D/NGLRenderPass.ts new file mode 100644 index 000000000..4ee81f967 --- /dev/null +++ b/src/eterna/pose3D/NGLRenderPass.ts @@ -0,0 +1,19 @@ +import {Viewer} from 'ngl'; +import {WebGLRenderer, WebGLRenderTarget} from 'three'; +import {Pass} from 'three/examples/jsm/postprocessing/Pass'; + +export default class NGLRenderPass extends Pass { + constructor(renderFunc: typeof Viewer.prototype.render) { + super(); + + this.needsSwap = false; + + this._renderFunc = renderFunc; + } + + public render(_renderer: WebGLRenderer, _writeBuffer: WebGLRenderTarget, readBuffer: WebGLRenderTarget) { + this._renderFunc(false, this.renderToScreen ? undefined : readBuffer); + } + + private _renderFunc: typeof Viewer.prototype.render; +} diff --git a/src/eterna/pose3D/PointerEventPropagator.ts b/src/eterna/pose3D/PointerEventPropagator.ts index 1c08a04fd..57eaeb7b7 100644 --- a/src/eterna/pose3D/PointerEventPropagator.ts +++ b/src/eterna/pose3D/PointerEventPropagator.ts @@ -1,4 +1,3 @@ -import * as log from 'loglevel'; import {InteractionEvent} from 'pixi.js'; import {GameObject, PointerCapture, SceneObject} from 'flashbang'; @@ -45,6 +44,7 @@ export default class PointerEventPropagator extends GameObject { private handleEvent(e: InteractionEvent) { e.stopPropagation(); + e.data.originalEvent.stopImmediatePropagation(); if (e.type === 'pointerdown') { this.initPointerCapture(); @@ -65,9 +65,9 @@ export default class PointerEventPropagator extends GameObject { const originalTouch = e.data.originalEvent instanceof TouchEvent ? this.getTouchById(e.data.originalEvent, e.data.identifier) : e.data.originalEvent; - // This shouldn't be possible, but while it probably shouldn't cause issues, at least - // make a note of it in the console in case my assumption bites us later and we need to debug it - if (!originalTouch) log.warn('Forwarding touch event where the original touch could not be found'); + // This shouldn't be possible, amd would likely cause issues since the position would be + // set to 0, 0 or something like that + if (!originalTouch) throw new Error('Forwarding touch event where the original touch could not be found'); const touch = new Touch({ identifier: e.data.identifier, @@ -77,12 +77,12 @@ export default class PointerEventPropagator extends GameObject { // becomes important, we can use a PointerCapture and record interactions elsewhere, // and change this if the initial start of the touch came from elsewhere target: this._domElement, - clientX: e.data.getLocalPosition(this._target.display).x, - clientY: e.data.getLocalPosition(this._target.display).y, - screenX: originalTouch?.screenX, - screenY: originalTouch?.screenY, - pageX: originalTouch?.pageX, - pageY: originalTouch?.pageY, + clientX: originalTouch.clientX, + clientY: originalTouch.clientY, + screenX: originalTouch.screenX, + screenY: originalTouch.screenY, + pageX: originalTouch.pageX, + pageY: originalTouch.pageY, radiusX: e.data.width, radiusY: e.data.height, rotationAngle: e.data.rotationAngle, @@ -137,8 +137,8 @@ export default class PointerEventPropagator extends GameObject { shiftKey: e.data.originalEvent.shiftKey, button: e.data.button, buttons: e.data.buttons, - clientX: e.data.getLocalPosition(this._target.display).x, - clientY: e.data.getLocalPosition(this._target.display).y, + clientX: e.data.originalEvent.clientX, + clientY: e.data.originalEvent.clientY, movementX: e.data.originalEvent.movementX, movementY: e.data.originalEvent.movementY, screenX: e.data.originalEvent.screenX, diff --git a/src/eterna/pose3D/Pose3D.ts b/src/eterna/pose3D/Pose3D.ts index 484257408..39527812a 100644 --- a/src/eterna/pose3D/Pose3D.ts +++ b/src/eterna/pose3D/Pose3D.ts @@ -2,23 +2,30 @@ import { autoLoad, Component, getFileInfo, - MouseActions, ParserRegistry, PickingProxy, StageEx, Structure, ViewerEx + MouseActions, ParserRegistry, PickingProxy, StageEx, Structure, Vector2 } from 'ngl'; +import {EffectComposer} from 'three/examples/jsm/postprocessing/EffectComposer'; +import {OutlinePass} from 'three/examples/jsm/postprocessing/OutlinePass'; +import {ShaderPass} from 'three/examples/jsm/postprocessing/ShaderPass'; +import {FXAAShader} from 'three/examples/jsm/shaders/FXAAShader'; import {Assert, ContainerObject} from 'flashbang'; import {Signal, Value} from 'signals'; import Eterna from 'eterna/Eterna'; -import {RNABase} from 'eterna/EPars'; -import Bitmaps from 'eterna/resources/Bitmaps'; import SecStruct from 'eterna/rnatypes/SecStruct'; import Sequence from 'eterna/rnatypes/Sequence'; import NGLPickingUtils from './NGLPickingUtils'; -import createColorScheme, {getBaseColor} from './NGLColorScheme'; +import createColorScheme, {getBaseColor} from './EternaColorScheme'; import Pose3DWindow, {NGLDragState} from './Pose3DWindow'; +import NGLRenderPass from './NGLRenderPass'; +import createEternaRepresentation from './EternaRepresentation'; +import BaseHighlightGroup from './BaseHighlightGroup'; +import SparkGroup from './SparkGroup'; export default class Pose3D extends ContainerObject { public readonly baseClicked: Signal = new Signal(); public readonly baseHovered: Signal = new Signal(); public readonly sequence: Value; + public readonly secstruct: Value; public readonly structureFile: string | File | Blob; constructor( @@ -30,7 +37,7 @@ export default class Pose3D extends ContainerObject { ) { super(); this.structureFile = structureFile; - this._secStruct = secstruct; + this.secstruct = new Value(secstruct); this.sequence = new Value(sequence); this._customNumbering = customNumbering; @@ -50,6 +57,7 @@ export default class Pose3D extends ContainerObject { // create a wrapper div for it instead of just passing the dom parent directly. this._nglDiv = document.createElement('div'); this._nglDiv.style.pointerEvents = 'none'; + this._nglDiv.style.visibility = 'hidden'; // For some reason this is necessary for the pointer events to actually stop firing on the div // (which we need to ensure, otherwise our div will be over part of the canvas and the canvas) // won't receive the pointer events @@ -57,12 +65,22 @@ export default class Pose3D extends ContainerObject { this._domParent.appendChild(this._nglDiv); // Initialize NGL - this._stage = new StageEx(this._nglDiv); + this._stage = new StageEx(this._nglDiv, { + lightColor: 0xffffff, + ambientColor: 0xffffff + }); // Initialize UI this._window = new Pose3DWindow(this._stage); this.addObject(this._window, this.container); + // Customize initial viewer parameters + this._stage.viewer.setFog(0x222222); + this._stage.viewer.cameraDistance = 800; + + // Custom effects + this.initEffects(); + // Set our custom control scheme this.initControls(); @@ -109,25 +127,74 @@ export default class Pose3D extends ContainerObject { ); } + private initEffects() { + const composer = new EffectComposer(this._stage.viewer.renderer); + + const nglRender = this._stage.viewer.render.bind(this._stage.viewer); + const renderPass = new NGLRenderPass(nglRender); + composer.addPass(renderPass); + + const changedBaseOutlinePass = new OutlinePass( + new Vector2(this._window.nglWidth, this._window.nglHeight), + this._stage.viewer.scene, + this._stage.viewer.camera + ); + changedBaseOutlinePass.edgeStrength = 5; + changedBaseOutlinePass.edgeGlow = 0.5; + changedBaseOutlinePass.edgeThickness = 2; + composer.addPass(changedBaseOutlinePass); + + const effectFXAA = new ShaderPass(FXAAShader); + composer.addPass(effectFXAA); + + this._baseHighlights = new BaseHighlightGroup(this._stage, changedBaseOutlinePass); + this._baseHighlights.name = 'baseHighlightGroup'; + this._stage.viewer.rotationGroup.add(this._baseHighlights); + + this._sparkGroup = new SparkGroup(this._stage); + this._sparkGroup.name = 'sparkGroup'; + this._stage.viewer.rotationGroup.add(this._sparkGroup); + + this._stage.viewer.render = (picking) => { + // When render is called with picking, that makes NGL render to a renderTarget used for + // hit testing, rather than actually rendering to the screen. We don't want to apply our + // extra effects in that scenario, so just call nglRender directly. + if (picking) { + // We also don't want to render the base highlights while picking, or else NGL will + // think that because the highlight is the first thing behind the cursor, that we're + // hovering over that and not the base + this._baseHighlights.visible = false; + nglRender(true); + this._baseHighlights.visible = true; + } else { + composer.render(); + this._window.updateNGLTexture(); + } + }; + + this.regs.add(this._window.resized.connect(() => { + composer.setSize(this._window.nglWidth, this._window.nglHeight); + effectFXAA.uniforms['resolution'].value.set(1 / this._window.nglWidth, 1 / this._window.nglHeight); + })); + + this._stage.viewer.requestRender(); + } + private loadStructure() { this._stage.removeAllComponents(); this._component = null; - const pairs = this._secStruct.pairs; - this._stage.defaultFileParams = {firstModelOnly: true}; this._stage - .loadFile(this.structureFile, {}, pairs) + .loadFile(this.structureFile) .then((component: void | Component) => { if (component) { this._component = component; this._colorScheme = createColorScheme(this.sequence); - this._component.addRepresentation('ebase', {vScale: 0.5, color: this._colorScheme}); + const representationID = createEternaRepresentation(this.sequence, this.secstruct); + this._component.addRepresentation(representationID, {vScale: 0.5, color: this._colorScheme}); this._component.addRepresentation('backbone', {color: 0xff8000}); this._component.autoView(); - const viewer = this._stage.viewer as ViewerEx; - viewer.spark.setURL(Bitmaps.BonusSymbol); - viewer.setHBondColor([0xffffff, 0x8f9dc0, 0x546986, 0xffffff]); } }); } @@ -142,62 +209,65 @@ export default class Pose3D extends ContainerObject { } private tooltipPick(pickingProxy: PickingProxy) { - const viewer = this._stage.viewer as ViewerEx; - // We'll draw the tooltip ourselves, so hide the NGL one this._stage.tooltip.style.display = 'none'; const sp = this._stage.getParameters(); if (sp.tooltip && pickingProxy) { const mp = pickingProxy.mouse.position; - const label = NGLPickingUtils.getLabel(pickingProxy, this._customNumbering); + const label = NGLPickingUtils.getLabel(pickingProxy, this.sequence.value, this._customNumbering); if (label === '') { this._window.tooltip.display.visible = false; } else { this._window.tooltip.setText(label); - this._window.tooltip.display.position.set(10 + mp.x, mp.y); + // This doesn't take into account the Pixi view not being at the origin. Should it? + // I think there's other areas in the code dealing with HTML elements where we + // similarly rely on the Pixi view being at the origin... + const globalPos = this._window.display.getGlobalPosition(); + this._window.tooltip.display.position.set(10 + mp.x - globalPos.x, mp.y - globalPos.y); this._window.tooltip.display.visible = true; } - const clickedBase = NGLPickingUtils.checkForBase(pickingProxy); - if (clickedBase !== null) { - viewer.hoverEBaseObject(clickedBase - 1, true, 0xFFFF00); - this.baseHovered.emit(clickedBase); + const hoveredBase = NGLPickingUtils.checkForBase(pickingProxy); + if (hoveredBase !== null) { + this.hover3D(hoveredBase); + this.baseHovered.emit(hoveredBase); } else { - viewer.hoverEBaseObject(-1); + this.hover3D(-1); } } else { this._window.tooltip.display.visible = false; - viewer.hoverEBaseObject(-1); + this.hover3D(-1); } } private update3DSequence(oldSeq: Sequence, newSeq: Sequence) { for (let i = 0; i < oldSeq.length; i++) { if (oldSeq.nt(i) !== newSeq.nt(i)) { - (this._stage.viewer as ViewerEx).selectEBaseObject(i); + this._baseHighlights.addChanged(i); } } + this._baseHighlights.updateHoverColor((baseIndex) => getBaseColor(this.sequence.value.nt(baseIndex))); this._component?.updateRepresentations({color: this._colorScheme}); this._stage.viewer.requestRender(); } - public hover3D(index: number, base: RNABase) { - const color: number = getBaseColor(base); - (this._stage.viewer as ViewerEx).hoverEBaseObject(index - 1, false, color); + public hover3D(index: number) { + if (index !== -1) { + const color: number = getBaseColor(this.sequence.value.nt(index)); + this._baseHighlights.switchHover(index, color); + } else { + this._baseHighlights.clearHover(); + } } public mark3D(index: number) { - (this._stage.viewer as ViewerEx).markEBaseObject(index); + this._baseHighlights.toggleMark(index); } public spark3D(indices: number[]) { - (this._stage.viewer as ViewerEx).beginSpark(); - for (const index of indices) { - (this._stage.viewer as ViewerEx).addSpark(index + 1); - } - (this._stage.viewer as ViewerEx).endSpark(20); + this._sparkGroup.spark(indices); } /** @@ -224,13 +294,14 @@ export default class Pose3D extends ContainerObject { } private _customNumbering: (number | null)[] | undefined; - private _secStruct: SecStruct; private _domParent: HTMLElement; private _nglDiv: HTMLElement; private _stage: StageEx; private _component: Component | null; private _colorScheme: string; + private _baseHighlights: BaseHighlightGroup; + private _sparkGroup: SparkGroup; private _window: Pose3DWindow; } diff --git a/src/eterna/pose3D/Pose3DWindow.ts b/src/eterna/pose3D/Pose3DWindow.ts index 4d0abb6c6..874a69c5f 100644 --- a/src/eterna/pose3D/Pose3DWindow.ts +++ b/src/eterna/pose3D/Pose3DWindow.ts @@ -6,6 +6,7 @@ import { Assert, ContainerObject, DisplayUtil, Dragger, Flashbang, GameObjectRef, HAlign, HLayoutContainer, MathUtil, MouseWheelListener, SceneObject, SpriteObject, VAlign, VLayoutContainer } from 'flashbang'; +import {UnitSignal} from 'signals'; import Eterna from 'eterna/Eterna'; import Bitmaps from 'eterna/resources/Bitmaps'; import BitmapManager from 'eterna/resources/BitmapManager'; @@ -33,6 +34,7 @@ export enum NGLDragState { export default class Pose3DWindow extends ContainerObject implements MouseWheelListener { public nglDragState: NGLDragState = NGLDragState.PAN; public tooltip: TextBalloon; + public resized = new UnitSignal(); constructor(stage: Stage) { super(); @@ -71,7 +73,7 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL this.layout(); Assert.assertIsDefined(this.mode); - this.regs.add(this.mode.resized.connect(() => this.resized())); + this.regs.add(this.mode.resized.connect(() => this.modeResized())); Assert.assertIsDefined(this.mode); this.regs.add(this.mode.mouseWheelInput.pushListener(this)); @@ -140,14 +142,6 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL const nglContainer = new Container(); this._nglSprite = new SpriteObject(Sprite.from(this._nglStage.viewer.renderer.domElement)); - this._nglStage.viewer.signals.rendered.add(() => { - // We've removed the 3D view, but NGL hasn't been fully destroyed yet - if (!this._nglSprite.display.texture) return; - - this._nglSprite.display.texture.update(); - this._nglSprite.display.width = this._currentBounds.width; - this._nglSprite.display.height = this._currentBounds.height - this.ICON_SIZE; - }); this.addObject(this._nglSprite, nglContainer); const eventPropagator = new PointerEventPropagator(this._nglSprite, this._nglStage.viewer.renderer.domElement); @@ -184,6 +178,15 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL return nglContainer; } + public updateNGLTexture() { + // We've removed the 3D view, but NGL hasn't been fully destroyed yet + if (!this._nglSprite.display.texture) return; + + this._nglSprite.display.texture.update(); + this._nglSprite.display.width = this.nglWidth; + this._nglSprite.display.height = this.nglHeight; + } + public onMouseWheelEvent(e: WheelEvent): boolean { this._nglStage.viewer.renderer.domElement.dispatchEvent(new WheelEvent(e.type, e)); return true; @@ -378,7 +381,7 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL this.layout(); } - private resized() { + private modeResized() { Assert.assertIsDefined(Flashbang.stageWidth); // Don't make the window so small that our toolbar doesn't fit, but if that means // our window becomes smaller than the screen width, when we're bigger again don't be super @@ -409,9 +412,10 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL // No need to re-render if we're not visible if (this._nglSprite.display.visible) { this._nglStage.setSize( - `${this._currentBounds.width}px`, - `${this._currentBounds.height - this.ICON_SIZE}px` + `${this.nglWidth}px`, + `${this.nglHeight}px` ); + this.resized.emit(); } // Title bar drag handles should fill remaining space @@ -421,10 +425,7 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL - (this.ICON_SIZE + this._titleText.display.width / 2 + this.GAP * 2); // Resize handles go on the bottom left and right corners - const relativeNglBounds = new Rectangle( - 0, 0, - this._currentBounds.width, this._currentBounds.height - this.ICON_SIZE - ); + const relativeNglBounds = new Rectangle(0, 0, this.nglWidth, this.nglHeight); DisplayUtil.positionRelativeToBounds( this._dragHandleLeft.display, HAlign.LEFT, VAlign.BOTTOM, relativeNglBounds, HAlign.LEFT, VAlign.BOTTOM @@ -445,12 +446,24 @@ export default class Pose3DWindow extends ContainerObject implements MouseWheelL this.display.x = this._currentBounds.x; this.display.y = this._currentBounds.y; + + // Position the canvas so that when we fire events, NGL can interpret the positions correctly + this._nglStage.viewer.wrapper.style.left = `${this._currentBounds.x}px`; + this._nglStage.viewer.wrapper.style.top = `${this._currentBounds.y + this.ICON_SIZE}px`; } private get minWidth(): number { return this.ICON_SIZE * 3 + this._titleText.display.width + 50 + this.GAP * 5; } + public get nglWidth() { + return this._currentBounds.width; + } + + public get nglHeight() { + return this._currentBounds.height - this.ICON_SIZE; + } + private _nglStage: Stage; private _windowState: WindowState = WindowState.NORMAL; diff --git a/src/eterna/pose3D/SparkGroup.ts b/src/eterna/pose3D/SparkGroup.ts new file mode 100644 index 000000000..39ce6f5ba --- /dev/null +++ b/src/eterna/pose3D/SparkGroup.ts @@ -0,0 +1,103 @@ +import Bitmaps from 'eterna/resources/Bitmaps'; +import {Stage} from 'ngl'; +import { + Group, Sprite, SpriteMaterial, TextureLoader, Vector3 +} from 'three'; +import EternaEllipsoidBuffer from './EternaEllipsoidBuffer'; + +export default class SparkGroup extends Group { + constructor(stage: Stage) { + super(); + this._stage = stage; + } + + public spark(baseIndices: number[]) { + const group = new Group(); + this.add(group); + let sparkDistance = 0; + + const angles = new Map(); + + for (const baseIndex of baseIndices) { + const rep = this._stage.getRepresentationsByName('eterna').first; + if (!rep) return; + + const baseBuff = rep.repr.bufferList.find( + (buff): buff is EternaEllipsoidBuffer => buff instanceof EternaEllipsoidBuffer + ); + if (!baseBuff) return; + + const basePositions: Vector3[] = []; + const positions = baseBuff.geometry.getAttribute('position').array; + const ids = baseBuff.geometry.getAttribute('primitiveId').array; + for (let i = 0; i < ids.length; i++) { + if (ids[i] === baseIndex) { + basePositions.push(new Vector3( + positions[i * 3], + positions[i * 3 + 1], + positions[i * 3 + 2] + )); + } + } + + const avgPos = new Vector3(); + const maxPos = new Vector3(); + for (const pos of basePositions) { + avgPos.add(pos); + maxPos.max(pos); + } + avgPos.divideScalar(basePositions.length); + + const R = maxPos.distanceTo(avgPos); + sparkDistance = Math.max(sparkDistance, R); + + const forwardDirection = new Vector3().random(); + + const forwardSprite = new Sprite(this.MAT); + forwardSprite.position.set(avgPos.x, avgPos.y, avgPos.z); + group.add(forwardSprite); + angles.set(forwardSprite, forwardDirection); + + const reverseSprite = new Sprite(this.MAT); + reverseSprite.position.set(avgPos.x, avgPos.y, avgPos.z); + group.add(reverseSprite); + angles.set(reverseSprite, forwardDirection.clone().negate()); + } + + for (const sprite of group.children) { + sprite.scale.set(sparkDistance, sparkDistance, 1.0); + } + + const expiration = 300; + let updates = 0; + let handle: number; + const update: TimerHandler = () => { + if (updates > expiration) { + this.remove(group); + clearInterval(handle); + } + + const opacity = 1.0 - updates / expiration; + for (const sprite of group.children as Sprite[]) { + if (sprite.visible) { + const delta = (((sparkDistance / 24) * (expiration - updates)) / expiration); + const direction = angles.get(sprite); + if (direction) sprite.translateOnAxis(direction, delta); + sprite.material.opacity = opacity; + } + } + + updates++; + this._stage.viewer.requestRender(); + }; + handle = setInterval(update, 1); + } + + private _stage: Stage; + + private readonly MAT = new SpriteMaterial({ + map: new TextureLoader().load(Bitmaps.BonusSymbol), + color: 0xffffff, + fog: true + }); +} diff --git a/src/flashbang/core/FlashbangApp.ts b/src/flashbang/core/FlashbangApp.ts index a32433e31..d288d7b30 100644 --- a/src/flashbang/core/FlashbangApp.ts +++ b/src/flashbang/core/FlashbangApp.ts @@ -180,6 +180,8 @@ export default class FlashbangApp { } protected onMouseWheelEvent(e: WheelEvent): void { + if (e.target !== this.view) return; + const {topMode} = this._modeStack; if (topMode != null) { topMode.onMouseWheelEvent(e); @@ -187,6 +189,8 @@ export default class FlashbangApp { } protected onContextMenuEvent(e: Event): void { + if (e.target !== this.view) return; + const {topMode} = this._modeStack; if (topMode != null) { topMode.onContextMenuEvent(e); diff --git a/webpack.common.js b/webpack.common.js index c7ef83023..4e5cdd50f 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -58,7 +58,11 @@ module.exports = { signals: path.resolve(__dirname, 'src/signals'), flashbang: path.resolve(__dirname, 'src/flashbang'), eterna: path.resolve(__dirname, 'src/eterna'), - 'engines-bin': getEngineLocation() + 'engines-bin': getEngineLocation(), + // Because our signals conflicts with the ngl-imported signals, we need to use + // the version of ngl that bundles its externalized dependencies. In the future we + // should probably make aliases for our codebase scoped, like @eternagame/signals + 'ngl': path.resolve(__dirname, 'node_modules/ngl/dist/ngl.js') }, fallback: { // Our emscripten modules have code intended for non-web environments which import