From 2a6d7c7dc7972cb9277957b89ec2e5307dfa1c3b Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 25 Jun 2024 12:21:29 +0100 Subject: [PATCH 01/18] first --- rollup.config.mjs | 10 ++-- src/camera.ts | 2 +- src/editor.ts | 12 ++++- src/splat.ts | 6 ++- src/style.scss | 43 +++++++++++++---- src/ui/editor.ts | 13 +++++- src/ui/histogram.ts | 107 +++++++++++++++++++++++++++++++++++++++++++ src/ui/info-panel.ts | 27 +++++++++++ 8 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 src/ui/histogram.ts create mode 100644 src/ui/info-panel.ts diff --git a/rollup.config.mjs b/rollup.config.mjs index 03218c51..7129271a 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -15,15 +15,17 @@ if (process.env.BUILD_TYPE === 'prod') { process.env.BUILD_TYPE = 'release'; } -// debug, profile, release const HREF = process.env.BASE_HREF || ''; + +// debug, profile, release const BUILD_TYPE = process.env.BUILD_TYPE || 'release'; -const ENGINE_DIR = process.env.ENGINE_PATH || './node_modules/playcanvas'; -const PCUI_DIR = path.resolve(process.env.PCUI_PATH || 'node_modules/@playcanvas/pcui'); -const ENGINE_NAME = BUILD_TYPE === 'debug' ? 'playcanvas.dbg/src/index.js' : 'playcanvas/src/index.js'; +const ENGINE_DIR = process.env.ENGINE_PATH || './node_modules/playcanvas'; +const ENGINE_NAME = (BUILD_TYPE === 'debug') ? 'playcanvas.dbg/src/index.js' : 'playcanvas/src/index.js'; const ENGINE_PATH = path.resolve(ENGINE_DIR, 'build', ENGINE_NAME); +const PCUI_DIR = path.resolve(process.env.PCUI_PATH || 'node_modules/@playcanvas/pcui'); + const aliasEntries = { playcanvas: ENGINE_PATH, pcui: PCUI_DIR diff --git a/src/camera.ts b/src/camera.ts index 80aa60e8..9b6f2e57 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -363,7 +363,7 @@ class Camera extends Element { const renderTarget = this.entity.camera.renderTarget; // resolve msaa buffer - if (renderTarget._samples > 1) { + if (renderTarget.samples > 1) { renderTarget.resolve(true, false); } diff --git a/src/editor.ts b/src/editor.ts index c4f467b5..34630076 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -225,9 +225,17 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S const state = splatData.getProp('state') as Uint8Array; const opacity = splatData.getProp('opacity') as Float32Array; + const sigmoid = (v: number) => { + if (v > 0) { + return 1 / (1 + Math.exp(-v)); + } + + const t = Math.exp(v); + return t / (1 + t); + }; + processSelection(state, op, (i) => { - const t = Math.exp(opacity[i]); - return ((1 / (1 + t)) < value); + return sigmoid(opacity[i]) < value; }); splat.updateState(); diff --git a/src/splat.ts b/src/splat.ts index f73d711a..4cdef151 100644 --- a/src/splat.ts +++ b/src/splat.ts @@ -47,7 +47,7 @@ void main(void) const fragmentShader = /*glsl*/` #ifdef PICK_PASS -flat varying highp uint vertexId; + flat varying highp uint vertexId; #endif flat varying highp uint vertexState; @@ -199,6 +199,8 @@ class Splat extends Element { } this.scene.forceRender = true; + + this.scene.events.fire('splat.stateChanged', this); } get worldTransform() { @@ -296,7 +298,7 @@ class Splat extends Element { const state = this.splatData.getProp('state') as Uint8Array; const localBound = this.localBoundStorage; - if (!this.splatData.calcAabb(localBound, (i: number) => (state[i] & State.deleted) === 0)) { + if (!this.splatData.calcAabbExact(localBound, (i: number) => (state[i] & State.deleted) === 0)) { localBound.center.set(0, 0, 0); localBound.halfExtents.set(0.5, 0.5, 0.5); } diff --git a/src/style.scss b/src/style.scss index 142f4f96..f4a2112b 100644 --- a/src/style.scss +++ b/src/style.scss @@ -41,6 +41,39 @@ body { border-right: 1px solid $bcg-darker; } +#main-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + border: 0; + padding: 0; + margin: 0; + flex-grow: 1; +} + +#canvas-container { + width: 100%; + background-color: #666666; + display: flex; + border: 0; + padding: 0; + margin: 0; + flex-grow: 1; +} + +#info-panel-container { + width: 100%; + height: 256px; + flex-grow: 0; + flex-shrink: 0; +} + +#histogram-canvas { + height: 100%; + width: 100%; +} + #coord-space-toggle.active { background-color: $bcg-dark !important; color: #f60; @@ -224,16 +257,6 @@ body { opacity: 0.4; } -#canvas-container { - width: 100%; - background-color: #666666; - display: flex; - border: 0; - padding: 0; - margin: 0; - flex-grow: 1; -} - #canvas { width: 100%; height: 100%; diff --git a/src/ui/editor.ts b/src/ui/editor.ts index c27dc6d5..9ad03e10 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -1,5 +1,6 @@ import { Container, Label } from 'pcui'; import { ControlPanel } from './control-panel'; +import { InfoPanel } from './info-panel'; import { Toolbar } from './toolbar'; import { Events } from '../events'; import { Popup } from './popup'; @@ -72,9 +73,19 @@ class EditorUI { // control panel const controlPanel = new ControlPanel(events, remoteStorageMode); + // main container + const mainContainer = new Container({ + id: 'main-container' + }); + + const infoPanel = new InfoPanel(events); + + mainContainer.append(canvasContainer); + mainContainer.append(infoPanel); + editorContainer.append(toolbar); editorContainer.append(controlPanel); - editorContainer.append(canvasContainer); + editorContainer.append(mainContainer); // message popup this.popup = new Popup(topContainer); diff --git a/src/ui/histogram.ts b/src/ui/histogram.ts new file mode 100644 index 00000000..3a95d2aa --- /dev/null +++ b/src/ui/histogram.ts @@ -0,0 +1,107 @@ + +class HistogramData { + bins: Uint32Array; + minValue: number; + maxValue: number; + + constructor(numBins: number) { + this.bins = new Uint32Array(numBins); + } + + calc(data: Float32Array, transform: (v: number) => number) { + // calculate min, max + let min = transform(data[0]); + let max = min; + for (let i = 0; i < data.length; i++) { + const v = transform(data[i]); + if (v < min) min = v; else if (v > max) max = v; + } + + // fill bins + const bins = this.bins; + for (let i = 0; i < bins.length; ++i) { + bins[i] = 0; + } + + for (let i = 0; i < data.length; i++) { + const v = transform(data[i]); + const bin = Math.min(bins.length - 1, Math.floor((v - min) / (max - min) * bins.length)); + bins[bin]++; + } + + this.minValue = min; + this.maxValue = max; + } +} + +class Histogram { + root: HTMLElement; + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + histogram: HistogramData; + pixelData: ImageData; + minValue: number; + maxValue: number; + + constructor(numBins: number, height: number) { + const canvas = document.createElement('canvas'); + canvas.classList.add('histogram-canvas'); + canvas.width = numBins; + canvas.height = height; + canvas.style.width = `100%`; + canvas.style.height = `100%`; + + const context = canvas.getContext('2d'); + context.globalCompositeOperation = 'copy'; + + this.canvas = canvas; + this.context = context; + this.histogram = new HistogramData(numBins); + this.pixelData = context.createImageData(canvas.width, canvas.height); + + this.canvas.addEventListener('mousemove', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = this.canvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const h = this.histogram; + const bin = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); + + console.log(`bin: ${bin} value: ${h.bins[bin]}`); + }); + } + + update(data: Float32Array, transform: (v: number) => number) { + this.histogram.calc(data, transform); + + // convert bin values to log scale + const bins = this.histogram.bins; + const vals = []; + for (let i = 0; i < bins.length; ++i) { + vals[i] = Math.log(bins[i] + 1); + } + const valMax = Math.max(...vals); + + // draw histogram + const canvas = this.canvas; + const context = this.context; + const pixelData = this.pixelData; + const pixels = new Uint32Array(pixelData.data.buffer); + + let i = 0; + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < vals.length; x++) { + if (vals[x] / valMax > (canvas.height - 1 - y) / canvas.height) { + pixels[i++] = 0xffffffff; + } else { + pixels[i++] = 0xff000000; + } + } + } + + context.putImageData(pixelData, 0, 0); + } +} + +export { Histogram }; diff --git a/src/ui/info-panel.ts b/src/ui/info-panel.ts new file mode 100644 index 00000000..22c1e721 --- /dev/null +++ b/src/ui/info-panel.ts @@ -0,0 +1,27 @@ +import { Container } from 'pcui'; +import { Events } from '../events'; +import { Splat } from '../splat'; +import { Histogram } from './histogram'; + +class InfoPanel extends Container { + constructor(events: Events, args = { }) { + args = Object.assign(args, { + id: 'info-panel-container' + }); + + super(args); + + const histogram = new Histogram(512, 256); + + this.dom.appendChild(histogram.canvas); + + events.on('splat.stateChanged', (splat: Splat) => { + const state = splat.splatData.getProp('scale_0'); + if (state) { + histogram.update(state, (v) => Math.exp(v)); + } + }); + } +} + +export { InfoPanel }; From 35e472d417b8f85762e82106876a495771169eb5 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 26 Jun 2024 11:23:03 +0100 Subject: [PATCH 02/18] latest --- src/style.scss | 39 ++++++- src/ui/data-panel.ts | 261 +++++++++++++++++++++++++++++++++++++++++++ src/ui/editor.ts | 6 +- src/ui/histogram.ts | 102 +++++++++++++---- src/ui/info-panel.ts | 27 ----- 5 files changed, 377 insertions(+), 58 deletions(-) create mode 100644 src/ui/data-panel.ts delete mode 100644 src/ui/info-panel.ts diff --git a/src/style.scss b/src/style.scss index f4a2112b..13c9cc08 100644 --- a/src/style.scss +++ b/src/style.scss @@ -62,18 +62,48 @@ body { flex-grow: 1; } -#info-panel-container { +#data-panel { width: 100%; - height: 256px; + height: 320px; +} + +#sep-container { + background-color: $bcg-darker; +} + +#sep-container > span { + color: white; +} + +#data-controls-container { + width: 256px; flex-grow: 0; flex-shrink: 0; + overflow-y: auto; + // display: flex; + // flex-direction: column; } -#histogram-canvas { - height: 100%; +#data-controls { width: 100%; } +#histogram-canvas { + +} + +#data-panel-popup-container { + position: absolute; + left: 50px; + top: 50px; + pointer-events: none; +} + +#data-panel-popup-label { + background-color: $bcg-dark; + color: $text-primary; +} + #coord-space-toggle.active { background-color: $bcg-dark !important; color: #f60; @@ -214,6 +244,7 @@ body { flex-shrink: 0; flex-grow: 0; line-height: 24px; + margin: 2px 6px 2px 6px; } .control-element { diff --git a/src/ui/data-panel.ts b/src/ui/data-panel.ts new file mode 100644 index 00000000..151fe822 --- /dev/null +++ b/src/ui/data-panel.ts @@ -0,0 +1,261 @@ +import { BooleanInput, Container, Label, Panel, SelectInput } from 'pcui'; +import { Events } from '../events'; +import { Splat } from '../splat'; +import { Histogram } from './histogram'; +import { State } from '../edit-ops'; + +const SH_C0 = 0.28209479177387814; + +const identity = (v: number) => v; +const scaleFunc = (v: number) => Math.exp(v); +const colorFunc = (v: number) => 0.5 + v * SH_C0; +const sigmoid = (v: number) => { + if (v > 0) { + return 1 / (1 + Math.exp(-v)); + } + + const t = Math.exp(v); + return t / (1 + t); +}; + +const dataFuncs = { + x: identity, + y: identity, + z: identity, + scale_0: scaleFunc, + scale_1: scaleFunc, + scale_2: scaleFunc, + f_dc_0: colorFunc, + f_dc_1: colorFunc, + f_dc_2: colorFunc, + opacity: sigmoid +}; + +class DataPanel extends Panel { + constructor(events: Events, args = { }) { + args = Object.assign(args, { + headerText: 'Data', + id: 'data-panel', + resizable: 'top', + resizeMax: 1000, + collapsed: true, + collapsible: true, + collapseHorizontally: false, + flex: true, + flexDirection: 'row' + }); + + super(args); + + // create a seperator label + const sep = (parent: Container, labelText: string) => { + const container = new Container({ + class: 'control-parent', + id: 'sep-container' + }); + + container.class.add('sep-container'); + + const label = new Label({ + class: 'contol-element-expand', + text: labelText + }); + + container.append(label); + + parent.append(container); + } + + // create a new data label + const dataLabel = (parent: Container, labelText: string) => { + const container = new Container({ + class: 'control-parent' + }); + + const label = new Label({ + class: 'control-label', + text: labelText + }); + + const value = new Label({ + class: 'control-element-expand' + }); + + container.append(label); + container.append(value); + + parent.append(container); + + return value; + }; + + + // create data controls + const controlsContainer = new Container({ + id: 'data-controls-container' + }); + + const controls = new Container({ + id: 'data-controls' + }); + + sep(controls, 'Histogram'); + + const dataSelector = new SelectInput({ + class: 'control-element-expand', + defaultValue: 'scale_0', + options: [ + { v: 'x', t: 'X' }, + { v: 'y', t: 'Y' }, + { v: 'z', t: 'Z' }, + { v: 'volume', t: 'Volume' }, + { v: 'scale_0', t: 'Scale X' }, + { v: 'scale_1', t: 'Scale Y' }, + { v: 'scale_2', t: 'Scale Z' }, + { v: 'f_dc_0', t: 'Red' }, + { v: 'f_dc_1', t: 'Green' }, + { v: 'f_dc_2', t: 'Blue' }, + { v: 'opacity', t: 'Opacity' }, + ] + }); + + const logScale = new Container({ + class: 'control-parent' + }); + + const logScaleLabel = new Label({ + class: 'control-label', + text: 'Log Scale' + }); + + const logScaleValue = new BooleanInput({ + class: 'control-element', + value: false + }); + + logScale.append(logScaleLabel); + logScale.append(logScaleValue); + + controls.append(dataSelector); + controls.append(logScale); + + sep(controls, 'Totals') + + const splatsValue = dataLabel(controls, 'Splats'); + const selectedValue = dataLabel(controls, 'Selected'); + const hiddenValue = dataLabel(controls, 'Hidden'); + const deletedValue = dataLabel(controls, 'Deleted'); + + controlsContainer.append(controls); + + // build histogram + const histogram = new Histogram(512, 256); + + this.content.dom.appendChild(histogram.canvas); + this.content.append(controlsContainer); + + let splat: Splat; + const updateHistogram = () => { + if (!splat || this.collapsed) return; + + const state = splat.splatData.getProp('state') as Uint8Array; + if (state) { + // calculate totals + let selected = 0; + let hidden = 0; + let deleted = 0; + for (let i = 0; i < state.length; ++i) { + if (state[i] & State.selected) { + selected++; + } + if (state[i] & State.hidden) { + hidden++; + } + if (state[i] & State.deleted) { + deleted++; + } + } + + splatsValue.text = state.length.toString(); + selectedValue.text = selected.toString(); + hiddenValue.text = hidden.toString(); + deletedValue.text = deleted.toString(); + + // update histogram + let func: (i: number) => number; + + // @ts-ignore + const dataFunc = dataFuncs[dataSelector.value]; + const data = splat.splatData.getProp(dataSelector.value); + + if (dataFunc && data) { + func = (i) => dataFunc(data[i]); + } else if (dataSelector.value === 'volume') { + const sx = splat.splatData.getProp('scale_0'); + const sy = splat.splatData.getProp('scale_1'); + const sz = splat.splatData.getProp('scale_2'); + func = (i) => scaleFunc(sx[i]) * scaleFunc(sy[i]) * scaleFunc(sz[i]); + } else { + func = (i) => undefined; + } + + // update histogram + histogram.update( + state.length, + (i) => (state[i] === State.selected) ? func(i) : undefined, + { + logScale: logScaleValue.value + } + ); + } + }; + + this.on('expand', () => { + updateHistogram(); + }); + + events.on('splat.stateChanged', (splat_: Splat) => { + splat = splat_; + updateHistogram(); + }); + + events.on('selection.changed', (selection: Element) => { + if (selection instanceof Splat) { + splat = selection; + updateHistogram(); + } + }); + + dataSelector.on('change', updateHistogram); + logScaleValue.on('change', updateHistogram); + + const popupContainer = new Container({ + id: 'data-panel-popup-container', + hidden: true + }); + + const popupLabel = new Label({ + id: 'data-panel-popup-label', + text: '' + }); + + popupContainer.append(popupLabel); + this.content.append(popupContainer); + + histogram.events.on('mouseenter', () => { + popupContainer.hidden = false; + }); + + histogram.events.on('mouseleave', () => { + popupContainer.hidden = true; + }); + + histogram.events.on('mousemove', (info: any) => { + popupContainer.style.left = `${info.x + 14}px`; + popupContainer.style.top = `${info.y}px`; + popupLabel.text = `${info.value.toFixed(2)} - ${info.count} (${(info.total ? info.count / info.total * 100 : 0).toFixed(2)}%)`; + }); + } +} + +export { DataPanel }; diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 9ad03e10..2ac539cc 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -1,6 +1,6 @@ import { Container, Label } from 'pcui'; import { ControlPanel } from './control-panel'; -import { InfoPanel } from './info-panel'; +import { DataPanel } from './data-panel'; import { Toolbar } from './toolbar'; import { Events } from '../events'; import { Popup } from './popup'; @@ -78,10 +78,10 @@ class EditorUI { id: 'main-container' }); - const infoPanel = new InfoPanel(events); + const dataPanel = new DataPanel(events); mainContainer.append(canvasContainer); - mainContainer.append(infoPanel); + mainContainer.append(dataPanel); editorContainer.append(toolbar); editorContainer.append(controlPanel); diff --git a/src/ui/histogram.ts b/src/ui/histogram.ts index 3a95d2aa..cb8def3f 100644 --- a/src/ui/histogram.ts +++ b/src/ui/histogram.ts @@ -1,6 +1,8 @@ +import { Events } from '../events'; class HistogramData { bins: Uint32Array; + numValues: number; minValue: number; maxValue: number; @@ -8,40 +10,65 @@ class HistogramData { this.bins = new Uint32Array(numBins); } - calc(data: Float32Array, transform: (v: number) => number) { - // calculate min, max - let min = transform(data[0]); - let max = min; - for (let i = 0; i < data.length; i++) { - const v = transform(data[i]); - if (v < min) min = v; else if (v > max) max = v; - } - - // fill bins + calc(count: number, value: (v: number) => number | undefined) { + // clear bins const bins = this.bins; for (let i = 0; i < bins.length; ++i) { bins[i] = 0; } - for (let i = 0; i < data.length; i++) { - const v = transform(data[i]); - const bin = Math.min(bins.length - 1, Math.floor((v - min) / (max - min) * bins.length)); - bins[bin]++; + // calculate min, max + let min, max, i; + for (i = 0; i < count; i++) { + const v = value(i); + if (v !== undefined) { + min = max = v; + break; + } } + // no data + if (i === count) { + return; + } + + // continue min/max calc + for (; i < count; i++) { + const v = value(i); + if (v !== undefined) { + if (v < min) min = v; else if (v > max) max = v; + } + } + + // fill bins + for (let i = 0; i < count; i++) { + const v = value(i); + if (v !== undefined) { + const n = min === max ? 0 : (v - min) / (max - min); + const bin = Math.min(bins.length - 1, Math.floor(n * bins.length)); + bins[bin]++; + } + } + this.numValues = bins.reduce((t, v) => t + v, 0); this.minValue = min; this.maxValue = max; } + + bucketValue(bucket: number) { + return this.minValue + bucket * this.bucketSize; + } + + get bucketSize() { + return (this.maxValue - this.minValue) / this.bins.length; + } } class Histogram { - root: HTMLElement; canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; histogram: HistogramData; pixelData: ImageData; - minValue: number; - maxValue: number; + events = new Events(); constructor(numBins: number, height: number) { const canvas = document.createElement('canvas'); @@ -50,10 +77,14 @@ class Histogram { canvas.height = height; canvas.style.width = `100%`; canvas.style.height = `100%`; + canvas.style.imageRendering = 'pixelated'; const context = canvas.getContext('2d'); context.globalCompositeOperation = 'copy'; + context.fillStyle = 'black'; + context.fillRect(0, 0, canvas.width, canvas.height); + this.canvas = canvas; this.context = context; this.histogram = new HistogramData(numBins); @@ -63,23 +94,46 @@ class Histogram { e.preventDefault(); e.stopPropagation(); - const rect = this.canvas.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const h = this.histogram; - const bin = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); - console.log(`bin: ${bin} value: ${h.bins[bin]}`); + if (h.numValues) { + const rect = this.canvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + + const bin = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); + + this.events.fire('mousemove', { + x: e.offsetX, + y: e.offsetY, + value: h.bucketValue(bin), + size: h.bucketSize, + count: h.bins[bin], + total: h.numValues + }); + } + }); + + this.canvas.addEventListener('mouseenter', (e: MouseEvent) => { + this.events.fire('mouseenter'); + }); + + this.canvas.addEventListener('mouseleave', (e: MouseEvent) => { + this.events.fire('mouseleave'); }); } - update(data: Float32Array, transform: (v: number) => number) { - this.histogram.calc(data, transform); + // + // options = { + // logScale: boolean + // } + update(count: number, value: (v: number) => number | undefined, options: { logScale?: boolean } = {}) { + this.histogram.calc(count, value); // convert bin values to log scale const bins = this.histogram.bins; const vals = []; for (let i = 0; i < bins.length; ++i) { - vals[i] = Math.log(bins[i] + 1); + vals[i] = options?.logScale ? Math.log(bins[i] + 1) : bins[i]; } const valMax = Math.max(...vals); diff --git a/src/ui/info-panel.ts b/src/ui/info-panel.ts deleted file mode 100644 index 22c1e721..00000000 --- a/src/ui/info-panel.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Container } from 'pcui'; -import { Events } from '../events'; -import { Splat } from '../splat'; -import { Histogram } from './histogram'; - -class InfoPanel extends Container { - constructor(events: Events, args = { }) { - args = Object.assign(args, { - id: 'info-panel-container' - }); - - super(args); - - const histogram = new Histogram(512, 256); - - this.dom.appendChild(histogram.canvas); - - events.on('splat.stateChanged', (splat: Splat) => { - const state = splat.splatData.getProp('scale_0'); - if (state) { - histogram.update(state, (v) => Math.exp(v)); - } - }); - } -} - -export { InfoPanel }; From 24947be2fb893b5534380f8022508a8907445839 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 28 Jun 2024 11:02:26 +0100 Subject: [PATCH 03/18] latest --- package-lock.json | 128 +++++++++++++++++++++---------------------- package.json | 12 ++-- src/editor.ts | 9 +++ src/splat.ts | 28 ++++++++-- src/style.scss | 20 +++++++ src/ui/data-panel.ts | 114 ++++++++++++++++++++++++++++---------- src/ui/histogram.ts | 80 ++++++++++++++++++++++++--- 7 files changed, 279 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30e44830..af41aaae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", - "@playcanvas/pcui": "^4.3.0", + "@playcanvas/pcui": "^4.4.0", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-json": "^6.1.0", @@ -19,19 +19,19 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@types/wicg-file-system-access": "^2023.10.5", - "@typescript-eslint/eslint-plugin": "^7.10.0", - "@typescript-eslint/parser": "^7.10.0", + "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typescript-eslint/parser": "^7.14.1", "concurrently": "^8.2.2", "cors": "^2.8.5", "cross-env": "^7.0.3", "eslint": "^8.56.0", "jest": "^29.7.0", - "playcanvas": "^1.71.5", + "playcanvas": "^1.72.0", "rollup": "^4.18.0", - "rollup-plugin-sass": "^1.12.22", + "rollup-plugin-sass": "^1.13.0", "rollup-plugin-visualizer": "^5.12.0", "serve": "^14.2.3", - "tslib": "^2.6.2" + "tslib": "^2.6.3" } }, "node_modules/@ampproject/remapping": { @@ -1329,9 +1329,9 @@ "dev": true }, "node_modules/@playcanvas/pcui": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@playcanvas/pcui/-/pcui-4.3.0.tgz", - "integrity": "sha512-nhyF+u55ws1FPA4A3EkD9p4ujK2HfGxuA6GRWJWe2A9tPyKRMGTNNjrubc/4GnmCxfZP7ux9QEd/a82MXSoU3g==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@playcanvas/pcui/-/pcui-4.4.0.tgz", + "integrity": "sha512-pMFM4adUwICRP304b4miWmnfOJQFXiOYQiGVtnzSDWl87PMLEVAoeGnxDIZp/HZ3VCgcXd0j5Vbr3cKURmcWqg==", "dev": true, "dependencies": { "@playcanvas/observer": "^1.4.0" @@ -1882,16 +1882,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.10.0.tgz", - "integrity": "sha512-PzCr+a/KAef5ZawX7nbyNwBDtM1HdLIT53aSA2DDlxmxMngZ43O8SIePOeX8H5S+FHXeI6t97mTt/dDdzY4Fyw==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.14.1.tgz", + "integrity": "sha512-aAJd6bIf2vvQRjUG3ZkNXkmBpN+J7Wd0mfQiiVCJMu9Z5GcZZdcc0j8XwN/BM97Fl7e3SkTXODSk4VehUv7CGw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.10.0", - "@typescript-eslint/type-utils": "7.10.0", - "@typescript-eslint/utils": "7.10.0", - "@typescript-eslint/visitor-keys": "7.10.0", + "@typescript-eslint/scope-manager": "7.14.1", + "@typescript-eslint/type-utils": "7.14.1", + "@typescript-eslint/utils": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1915,15 +1915,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.10.0.tgz", - "integrity": "sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.14.1.tgz", + "integrity": "sha512-8lKUOebNLcR0D7RvlcloOacTOWzOqemWEWkKSVpMZVF/XVcwjPR+3MD08QzbW9TCGJ+DwIc6zUSGZ9vd8cO1IA==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.10.0", - "@typescript-eslint/types": "7.10.0", - "@typescript-eslint/typescript-estree": "7.10.0", - "@typescript-eslint/visitor-keys": "7.10.0", + "@typescript-eslint/scope-manager": "7.14.1", + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/typescript-estree": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1", "debug": "^4.3.4" }, "engines": { @@ -1943,13 +1943,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.10.0.tgz", - "integrity": "sha512-7L01/K8W/VGl7noe2mgH0K7BE29Sq6KAbVmxurj8GGaPDZXPr8EEQ2seOeAS+mEV9DnzxBQB6ax6qQQ5C6P4xg==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.14.1.tgz", + "integrity": "sha512-gPrFSsoYcsffYXTOZ+hT7fyJr95rdVe4kGVX1ps/dJ+DfmlnjFN/GcMxXcVkeHDKqsq6uAcVaQaIi3cFffmAbA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.10.0", - "@typescript-eslint/visitor-keys": "7.10.0" + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1960,13 +1960,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.10.0.tgz", - "integrity": "sha512-D7tS4WDkJWrVkuzgm90qYw9RdgBcrWmbbRkrLA4d7Pg3w0ttVGDsvYGV19SH8gPR5L7OtcN5J1hTtyenO9xE9g==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.14.1.tgz", + "integrity": "sha512-/MzmgNd3nnbDbOi3LfasXWWe292+iuo+umJ0bCCMCPc1jLO/z2BQmWUUUXvXLbrQey/JgzdF/OV+I5bzEGwJkQ==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.10.0", - "@typescript-eslint/utils": "7.10.0", + "@typescript-eslint/typescript-estree": "7.14.1", + "@typescript-eslint/utils": "7.14.1", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1987,9 +1987,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.10.0.tgz", - "integrity": "sha512-7fNj+Ya35aNyhuqrA1E/VayQX9Elwr8NKZ4WueClR3KwJ7Xx9jcCdOrLW04h51de/+gNbyFMs+IDxh5xIwfbNg==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.14.1.tgz", + "integrity": "sha512-mL7zNEOQybo5R3AavY+Am7KLv8BorIv7HCYS5rKoNZKQD9tsfGUpO4KdAn3sSUvTiS4PQkr2+K0KJbxj8H9NDg==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2000,13 +2000,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.10.0.tgz", - "integrity": "sha512-LXFnQJjL9XIcxeVfqmNj60YhatpRLt6UhdlFwAkjNc6jSUlK8zQOl1oktAP8PlWFzPQC1jny/8Bai3/HPuvN5g==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.14.1.tgz", + "integrity": "sha512-k5d0VuxViE2ulIO6FbxxSZaxqDVUyMbXcidC8rHvii0I56XZPv8cq+EhMns+d/EVIL41sMXqRbK3D10Oza1bbA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.10.0", - "@typescript-eslint/visitor-keys": "7.10.0", + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/visitor-keys": "7.14.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2028,15 +2028,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.10.0.tgz", - "integrity": "sha512-olzif1Fuo8R8m/qKkzJqT7qwy16CzPRWBvERS0uvyc+DHd8AKbO4Jb7kpAvVzMmZm8TrHnI7hvjN4I05zow+tg==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.14.1.tgz", + "integrity": "sha512-CMmVVELns3nak3cpJhZosDkm63n+DwBlDX8g0k4QUa9BMnF+lH2lr3d130M1Zt1xxmB3LLk3NV7KQCq86ZBBhQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.10.0", - "@typescript-eslint/types": "7.10.0", - "@typescript-eslint/typescript-estree": "7.10.0" + "@typescript-eslint/scope-manager": "7.14.1", + "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/typescript-estree": "7.14.1" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2050,12 +2050,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.10.0.tgz", - "integrity": "sha512-9ntIVgsi6gg6FIq9xjEO4VQJvwOqA3jaBFQJ/6TK5AvEup2+cECI6Fh7QiBxmfMHXU0V0J4RyPeOU1VDNzl9cg==", + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.14.1.tgz", + "integrity": "sha512-Crb+F75U1JAEtBeQGxSKwI60hZmmzaqA3z9sYsVm8X7W5cwLEm5bRe0/uXS6+MR/y8CVpKSR/ontIAIEPFcEkA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.10.0", + "@typescript-eslint/types": "7.14.1", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -5846,9 +5846,9 @@ } }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -6299,12 +6299,12 @@ } }, "node_modules/playcanvas": { - "version": "1.71.5", - "resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-1.71.5.tgz", - "integrity": "sha512-1Gba1sdye9HhcSl4DstBAxCgtnKy/oHFgDzmRxcOlcZLvlYIK+G7SxPluiXfRewnTpWIGn03yZ4841zhtNAZDg==", + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-1.72.0.tgz", + "integrity": "sha512-emtQBXvBrr5gl/9bNT+FK+fl7Zc1Z2/9faxDuo/t/J1Vrz2XFimhjQeJt6QB2F2G2typwylXm6HvRxjVF5TX8w==", "dev": true, "dependencies": { - "@types/webxr": "^0.5.15", + "@types/webxr": "^0.5.16", "@webgpu/types": "^0.1.40" }, "engines": { @@ -6651,9 +6651,9 @@ } }, "node_modules/rollup-plugin-sass": { - "version": "1.12.22", - "resolved": "https://registry.npmjs.org/rollup-plugin-sass/-/rollup-plugin-sass-1.12.22.tgz", - "integrity": "sha512-bwlXqkmRDc1rVjkbN+wxXFh1jfnMEi3vHr5TufM+loUFYKd6RskGMx5Xz4sIVdxIu1oAilZkM4Px8P3QlmwfUA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-sass/-/rollup-plugin-sass-1.13.0.tgz", + "integrity": "sha512-TL/pBbuqN3Qftiub1rLWiPnUGyL5PC7/+4x1ZgFJWzu1Y8n2vwYILt1kPR83AUzPOwqgFfD+B/LqgV6ee0+CYQ==", "dev": true, "dependencies": { "@rollup/pluginutils": "^3 || ^4 || ^5", @@ -7462,9 +7462,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "dev": true }, "node_modules/type-check": { diff --git a/package.json b/package.json index c1b6be43..5a60afa9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ }, "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", - "@playcanvas/pcui": "^4.3.0", + "@playcanvas/pcui": "^4.4.0", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-json": "^6.1.0", @@ -61,18 +61,18 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@types/wicg-file-system-access": "^2023.10.5", - "@typescript-eslint/eslint-plugin": "^7.10.0", - "@typescript-eslint/parser": "^7.10.0", + "@typescript-eslint/eslint-plugin": "^7.14.1", + "@typescript-eslint/parser": "^7.14.1", "concurrently": "^8.2.2", "cors": "^2.8.5", "cross-env": "^7.0.3", "eslint": "^8.56.0", "jest": "^29.7.0", - "playcanvas": "^1.71.5", + "playcanvas": "^1.72.0", "rollup": "^4.18.0", - "rollup-plugin-sass": "^1.12.22", + "rollup-plugin-sass": "^1.13.0", "rollup-plugin-visualizer": "^5.12.0", "serve": "^14.2.3", - "tslib": "^2.6.2" + "tslib": "^2.6.3" } } diff --git a/src/editor.ts b/src/editor.ts index 34630076..274b72ee 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -187,6 +187,15 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S }); }); + events.on('select.pred', (op, pred: (i: number) => boolean) => { + selectedSplats().forEach((splat) => { + const splatData = splat.splatData; + const state = splatData.getProp('state') as Uint8Array; + processSelection(state, op, pred); + splat.updateState(); + }); + }); + events.on('select.bySize', (op: string, value: number) => { selectedSplats().forEach((splat) => { const splatData = splat.splatData; diff --git a/src/splat.ts b/src/splat.ts index 4cdef151..15010148 100644 --- a/src/splat.ts +++ b/src/splat.ts @@ -26,15 +26,33 @@ flat varying highp uint vertexState; flat varying highp uint vertexId; #endif +vec4 discardVec = vec4(0.0, 0.0, 2.0, 1.0); + void main(void) { - // evaluate center of the splat in object space - vec3 centerLocal = evalCenter(); + // calculate splat uv + if (!calcSplatUV()) { + gl_Position = discardVec; + return; + } - // evaluate the rest of the splat using world space center - vec4 centerWorld = matrix_model * vec4(centerLocal, 1.0); + // read data + readData(); + + vec4 pos; + if (!evalSplat(pos)) { + gl_Position = discardVec; + return; + } - gl_Position = evalSplat(centerWorld); + gl_Position = pos; + + texCoord = vertex_position.xy; + color = getColor(); + + #ifndef DITHER_NONE + id = float(splatId); + #endif vertexState = uint(texelFetch(splatState, splatUV, 0).r * 255.0); diff --git a/src/style.scss b/src/style.scss index 13c9cc08..a47b68ad 100644 --- a/src/style.scss +++ b/src/style.scss @@ -88,10 +88,30 @@ body { width: 100%; } +#histogram-container { + flex-grow: 1; + flex-shrink: 1; +} + #histogram-canvas { } +#histogram-svg { + pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +#histogram-rect { + // fill: rgba() +} + #data-panel-popup-container { position: absolute; left: 50px; diff --git a/src/ui/data-panel.ts b/src/ui/data-panel.ts index 151fe822..9b5a1e96 100644 --- a/src/ui/data-panel.ts +++ b/src/ui/data-panel.ts @@ -149,12 +149,41 @@ class DataPanel extends Panel { controlsContainer.append(controls); // build histogram - const histogram = new Histogram(512, 256); + const histogram = new Histogram(256, 256); - this.content.dom.appendChild(histogram.canvas); + const histogramContainer = new Container({ + id: 'histogram-container' + }); + + histogramContainer.dom.appendChild(histogram.canvas); + + this.content.append(histogramContainer); this.content.append(controlsContainer); + // current splat let splat: Splat; + + // returns a function that calculates the value for the current data selector + const getValueFunc = () => { + // @ts-ignore + const dataFunc = dataFuncs[dataSelector.value]; + const data = splat.splatData.getProp(dataSelector.value); + + let func: (i: number) => number; + if (dataFunc && data) { + func = (i) => dataFunc(data[i]); + } else if (dataSelector.value === 'volume') { + const sx = splat.splatData.getProp('scale_0'); + const sy = splat.splatData.getProp('scale_1'); + const sz = splat.splatData.getProp('scale_2'); + func = (i) => scaleFunc(sx[i]) * scaleFunc(sy[i]) * scaleFunc(sz[i]); + } else { + func = (i) => undefined; + } + + return func; + }; + const updateHistogram = () => { if (!splat || this.collapsed) return; @@ -165,14 +194,12 @@ class DataPanel extends Panel { let hidden = 0; let deleted = 0; for (let i = 0; i < state.length; ++i) { - if (state[i] & State.selected) { - selected++; - } - if (state[i] & State.hidden) { - hidden++; - } if (state[i] & State.deleted) { deleted++; + } else if (state[i] & State.hidden) { + hidden++; + } else if (state[i] & State.selected) { + selected++; } } @@ -182,27 +209,12 @@ class DataPanel extends Panel { deletedValue.text = deleted.toString(); // update histogram - let func: (i: number) => number; - - // @ts-ignore - const dataFunc = dataFuncs[dataSelector.value]; - const data = splat.splatData.getProp(dataSelector.value); - - if (dataFunc && data) { - func = (i) => dataFunc(data[i]); - } else if (dataSelector.value === 'volume') { - const sx = splat.splatData.getProp('scale_0'); - const sy = splat.splatData.getProp('scale_1'); - const sz = splat.splatData.getProp('scale_2'); - func = (i) => scaleFunc(sx[i]) * scaleFunc(sy[i]) * scaleFunc(sz[i]); - } else { - func = (i) => undefined; - } + const func = getValueFunc(); // update histogram histogram.update( state.length, - (i) => (state[i] === State.selected) ? func(i) : undefined, + (i) => (selected === 0 ? state[i] === 0 : state[i] === State.selected) ? func(i) : undefined, { logScale: logScaleValue.value } @@ -242,18 +254,62 @@ class DataPanel extends Panel { popupContainer.append(popupLabel); this.content.append(popupContainer); - histogram.events.on('mouseenter', () => { + histogram.events.on('showOverlay', () => { popupContainer.hidden = false; }); - histogram.events.on('mouseleave', () => { + histogram.events.on('hideOverlay', () => { popupContainer.hidden = true; }); - histogram.events.on('mousemove', (info: any) => { + histogram.events.on('updateOverlay', (info: any) => { popupContainer.style.left = `${info.x + 14}px`; popupContainer.style.top = `${info.y}px`; - popupLabel.text = `${info.value.toFixed(2)} - ${info.count} (${(info.total ? info.count / info.total * 100 : 0).toFixed(2)}%)`; + popupLabel.text = `value: ${info.value.toFixed(2)} - cnt: ${info.count} (${(info.total ? info.count / info.total * 100 : 0).toFixed(2)}%)`; + }); + + // highlight + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute('id', 'histogram-svg'); + + // create rect element + const rect = document.createElementNS(svg.namespaceURI, 'rect') as SVGRectElement; + rect.setAttribute('id', 'highlight-rect'); + rect.setAttribute('fill', 'rgba(255, 0, 0, 0.2)'); + rect.setAttribute('stroke', '#f60'); + rect.setAttribute('stroke-width', '1'); + rect.setAttribute('stroke-dasharray', '5, 5'); + + svg.appendChild(rect); + histogramContainer.dom.appendChild(svg); + + histogram.events.on('highlight', (info: any) => { + rect.setAttribute('x', info.x.toString()); + rect.setAttribute('y', info.y.toString()); + rect.setAttribute('width', info.width.toString()); + rect.setAttribute('height', info.height.toString()); + + svg.style.display = 'inline'; + }); + + histogram.events.on('select', (start: number, end: number) => { + svg.style.display = 'none'; + + const state = splat.splatData.getProp('state') as Uint8Array; + const selection = state.some((s) => s === State.selected); + const func = getValueFunc(); + + // perform selection + events.fire('select.pred', 'set', (i: number) => { + if (state[i] !== (selection ? State.selected : 0)) { + return false; + } + + // select all splats that fall in the given bucket range (inclusive) + const value = func(i); + const bucket = histogram.histogram.valueToBucket(value); + return bucket >= start && bucket <= end; + }); }); } } diff --git a/src/ui/histogram.ts b/src/ui/histogram.ts index cb8def3f..25c3e29c 100644 --- a/src/ui/histogram.ts +++ b/src/ui/histogram.ts @@ -61,6 +61,11 @@ class HistogramData { get bucketSize() { return (this.maxValue - this.minValue) / this.bins.length; } + + valueToBucket(value: number) { + const n = this.minValue === this.maxValue ? 0 : (value - this.minValue) / (this.maxValue - this.minValue); + return Math.min(this.bins.length - 1, Math.floor(n * this.bins.length)); + } } class Histogram { @@ -90,19 +95,77 @@ class Histogram { this.histogram = new HistogramData(numBins); this.pixelData = context.createImageData(canvas.width, canvas.height); - this.canvas.addEventListener('mousemove', (e: MouseEvent) => { + let dragging = false; + let dragStart = 0; + let dragEnd = 0; + + const offsetToBucket = (offset: number) => { + const rect = this.canvas.getBoundingClientRect(); + const bins = this.histogram.bins.length; + return Math.max(0, Math.min(bins - 1, Math.floor((offset - rect.left) / rect.width * bins))); + }; + + const bucketToOffset = (bucket: number) => { + const rect = this.canvas.getBoundingClientRect(); + return bucket / this.histogram.bins.length * rect.width; + }; + + const updateHighlight = () => { + const rect = this.canvas.getBoundingClientRect(); + const start = Math.min(dragStart, dragEnd); + const end = Math.max(dragStart, dragEnd); + this.events.fire('highlight', { + x: bucketToOffset(start), + y: 0, + width: (end - start + 1) / this.histogram.bins.length * rect.width, + height: rect.height + }); + }; + + this.canvas.addEventListener('pointerdown', (e: PointerEvent) => { e.preventDefault(); e.stopPropagation(); const h = this.histogram; if (h.numValues) { + this.canvas.setPointerCapture(e.pointerId); + dragging = true; + dragStart = dragEnd = offsetToBucket(e.clientX); + updateHighlight(); + } + }); + + this.canvas.addEventListener('pointerup', (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (dragging) { + this.canvas.releasePointerCapture(e.pointerId); + + this.events.fire('select', Math.min(dragStart, dragEnd), Math.max(dragStart, dragEnd)); + dragging = false; + } + }); + + this.canvas.addEventListener('pointermove', (e: PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const h = this.histogram; + + if (h.numValues) { + if (dragging) { + dragEnd = offsetToBucket(e.clientX); + updateHighlight(); + } + const rect = this.canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const bin = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); - this.events.fire('mousemove', { + this.events.fire('updateOverlay', { x: e.offsetX, y: e.offsetY, value: h.bucketValue(bin), @@ -113,17 +176,16 @@ class Histogram { } }); - this.canvas.addEventListener('mouseenter', (e: MouseEvent) => { - this.events.fire('mouseenter'); + this.canvas.addEventListener('pointerenter', (e: PointerEvent) => { + this.events.fire('showOverlay'); }); - this.canvas.addEventListener('mouseleave', (e: MouseEvent) => { - this.events.fire('mouseleave'); + this.canvas.addEventListener('pointerleave', (e: PointerEvent) => { + this.events.fire('hideOverlay'); }); } - // - // options = { + // options: { // logScale: boolean // } update(count: number, value: (v: number) => number | undefined, options: { logScale?: boolean } = {}) { @@ -156,6 +218,8 @@ class Histogram { context.putImageData(pixelData, 0, 0); } + + } export { Histogram }; From da28eaaa9cfdf73371b7e01321462e9b9b783523 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 28 Jun 2024 16:30:49 +0100 Subject: [PATCH 04/18] latest --- src/camera.ts | 80 +++++- src/controllers.ts | 472 +++++++++------------------------- src/scene.ts | 8 +- src/tools/brush-selection.ts | 1 + src/tools/picker-selection.ts | 1 + src/tools/rect-selection.ts | 1 + src/ui/control-panel.ts | 4 + src/ui/editor.ts | 1 + 8 files changed, 197 insertions(+), 371 deletions(-) diff --git a/src/camera.ts b/src/camera.ts index 9b6f2e57..63653a5d 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -9,6 +9,8 @@ import { Entity, EventHandler, Picker, + Plane, + Ray, RenderTarget, Texture, Vec3, @@ -22,7 +24,7 @@ import { import { Element, ElementType } from './element'; import { TweenValue } from './tween-value'; import { Serializer } from './serializer'; -import { MouseController, TouchController } from './controllers'; +import { PointerController } from './controllers'; import { Splat } from './splat'; // calculate the forward vector given azimuth and elevation @@ -39,14 +41,16 @@ const calcForwardVec = (result: Vec3, azim: number, elev: number) => { // work globals const forwardVec = new Vec3(); const cameraPosition = new Vec3(); +const plane = new Plane(); +const ray = new Ray(); const vec = new Vec3(); +const vecb = new Vec3(); // modulo dealing with negative numbers const mod = (n: number, m: number) => ((n % m) + m) % m; class Camera extends Element { - mouseController: MouseController; - touchController: TouchController; + controller: PointerController; entity: Entity; focalPointTween = new TweenValue({x: 0, y: 0.5, z: 0}); azimElevTween = new TweenValue({azim: 30, elev: -15}); @@ -175,17 +179,12 @@ class Camera extends Element { this.entity.camera.setShaderPass(`debug_${this.scene.config.camera.debug_render}`); } - this.mouseController = new MouseController(this); - this.touchController = new TouchController(this); + this.controller = new PointerController(this, this.scene.canvas); // apply scene config const config = this.scene.config; const controls = config.controls; - this.mouseController.enableOrbit = this.touchController.enableOrbit = controls.enableRotate; - this.mouseController.enablePan = this.touchController.enablePan = controls.enablePan; - this.mouseController.enableZoom = this.touchController.enableZoom = controls.enableZoom; - // configure background const clr = config.backgroundColor; this.entity.camera.clearColor.set(clr.r, clr.g, clr.b, clr.a); @@ -219,11 +218,8 @@ class Camera extends Element { } remove() { - this.mouseController.destroy(); - this.mouseController = null; - - this.touchController.destroy(); - this.touchController = null; + this.controller.destroy(); + this.controller = null; this.entity.camera.layers = this.entity.camera.layers.filter(layer => layer !== this.scene.shadowLayer.id); this.scene.cameraRoot.removeChild(this.entity); @@ -398,6 +394,62 @@ class Camera extends Element { this.autoRotateDelayValue = config.controls.autoRotateDelay; } + // interesect the scene at the given screen coordinate and focus the camera on this location + pickFocalPoint(screenX: number, screenY: number) { + const scene = this.scene; + const cameraPos = this.entity.getPosition(); + + // @ts-ignore + const target = scene.canvas; + const sx = screenX / target.clientWidth * scene.targetSize.width; + const sy = screenY / target.clientHeight * scene.targetSize.height; + + const splats = scene.getElementsByType(ElementType.splat); + + const dist = (a: Vec3, b: Vec3) => { + return vecb.sub2(a, b).length(); + }; + + let closestD = 0; + const closestP = new Vec3(); + let closestSplat = null; + + for (let i = 0; i < splats.length; ++i) { + const splat = splats[i] as Splat; + + this.pickPrep(splat); + const pickId = this.pick(sx, sy); + + if (pickId !== -1) { + splat.getSplatWorldPosition(pickId, vec); + + // create a plane at the world position facing perpendicular to the camera + plane.setFromPointNormal(vec, this.entity.forward); + + // create the pick ray in world space + const res = this.entity.camera.screenToWorld(screenX, screenY, 1.0, vec); + vec.sub2(res, cameraPos); + vec.normalize(); + ray.set(cameraPos, vec); + + // find intersection + if (plane.intersectsRay(ray, vec)) { + const distance = dist(vec, cameraPos); + if (!closestSplat || distance < closestD) { + closestD = distance; + closestP.copy(vec); + closestSplat = splat; + } + } + } + } + + if (closestSplat) { + this.setFocalPoint(closestP); + scene.events.fire('selection', closestSplat); + } + } + // pick mode // render picker contents diff --git a/src/controllers.ts b/src/controllers.ts index 001abf15..cfa94078 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -1,379 +1,151 @@ import { Camera } from './camera'; -import { ElementType } from './element'; -import { Splat } from './splat'; -import { - EVENT_MOUSEDOWN, - EVENT_MOUSEUP, - EVENT_MOUSEMOVE, - EVENT_MOUSEWHEEL, - MOUSEBUTTON_LEFT, - MOUSEBUTTON_MIDDLE, - MOUSEBUTTON_RIGHT, - EVENT_TOUCHSTART, - EVENT_TOUCHEND, - EVENT_TOUCHCANCEL, - EVENT_TOUCHMOVE, - MouseEvent, - Plane, - Ray, - Touch, - TouchDevice, - TouchEvent, - Vec2, - Vec3, -} from 'playcanvas'; +import { Vec3 } from 'playcanvas'; -const plane = new Plane(); -const ray = new Ray(); -const vec = new Vec3(); -const vecb = new Vec3(); const fromWorldPoint = new Vec3(); const toWorldPoint = new Vec3(); const worldDiff = new Vec3(); -// MouseController +class PointerController { + destroy: () => void; -class MouseController { - camera: Camera; - leftButton = false; - middleButton = false; - rightButton = false; - lastPoint = new Vec2(); + constructor(camera: Camera, target: HTMLElement) { - enableOrbit = true; - enablePan = true; - enableZoom = true; - zoomedIn = 1; - xMouse = 0; - yMouse = 0; - - onMouseOutFunc = () => { - this.onMouseOut(); - }; - - onDblClick = (event: globalThis.MouseEvent) => { - const scene = this.camera.scene; - const cameraPos = this.camera.entity.getPosition(); + const orbit = (dx: number, dy: number) => { + const azim = camera.azim - dx * camera.scene.config.controls.orbitSensitivity; + const elev = camera.elevation - dy * camera.scene.config.controls.orbitSensitivity; + camera.setAzimElev(azim, elev); + } - // @ts-ignore - const target = scene.app.mouse._target; - const sx = event.offsetX / target.clientWidth * scene.targetSize.width; - const sy = event.offsetY / target.clientHeight * scene.targetSize.height; + const pan = (x: number, y: number, dx: number, dy: number) => { + // For panning to work at any zoom level, we use screen point to world projection + // to work out how far we need to pan the pivotEntity in world space + const c = camera.entity.camera; + const distance = camera.focusDistance * camera.distanceTween.value.distance; - const splats = scene.getElementsByType(ElementType.splat); + c.screenToWorld(x, y, distance, fromWorldPoint); + c.screenToWorld(x - dx, y - dy, distance, toWorldPoint); - const dist = (a: Vec3, b: Vec3) => { - return vecb.sub2(a, b).length(); + worldDiff.sub2(toWorldPoint, fromWorldPoint); + worldDiff.add(camera.focalPoint); + + camera.setFocalPoint(worldDiff); }; - let closestD = 0; - const closestP = new Vec3(); - let closestSplat = null; - - for (let i = 0; i < splats.length; ++i) { - const splat = splats[i] as Splat; - - this.camera.pickPrep(splat); - const pickId = this.camera.pick(sx, sy); - - if (pickId !== -1) { - splat.getSplatWorldPosition(pickId, vec); - - // create a plane at the world position facing perpendicular to the camera - plane.setFromPointNormal(vec, this.camera.entity.forward); - - // create the pick ray in world space - const res = this.camera.entity.camera.screenToWorld(event.offsetX, event.offsetY, 1.0, vec); - vec.sub2(res, cameraPos); - vec.normalize(); - ray.set(cameraPos, vec); + const zoom = (amount: number) => { + camera.setDistance(camera.distance * (1 - amount * camera.scene.config.controls.zoomSensitivity), 2); + }; - // find intersection - if (plane.intersectsRay(ray, vec)) { - const distance = dist(vec, cameraPos); - if (!closestSplat || distance < closestD) { - closestD = distance; - closestP.copy(vec); - closestSplat = splat; - } + let buttons = [false, false, false]; + let x: number, y: number; + + let touches: { id: number, x: number, y: number}[] = []; + let midx: number, midy: number, midlen: number; + + const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); + + const pointerdown = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + buttons[event.button] = true; + x = event.offsetX; + y = event.offsetY; + } else if (event.pointerType === 'touch') { + touches.push({ + x: event.offsetX, + y: event.offsetY, + id: event.pointerId + }); + + if (touches.length === 2) { + midx = (touches[0].x + touches[1].x) * 0.5; + midy = (touches[0].y + touches[1].y) * 0.5; + midlen = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y); } } - } - - if (closestSplat) { - this.camera.setFocalPoint(closestP); - scene.events.fire('selection', closestSplat); - } - }; - - constructor(camera: Camera) { - this.camera = camera; - const mouse = camera.scene.app.mouse; - mouse.on(EVENT_MOUSEDOWN, this.onMouseDown, this); - mouse.on(EVENT_MOUSEUP, this.onMouseUp, this); - mouse.on(EVENT_MOUSEMOVE, this.onMouseMove, this); - mouse.on(EVENT_MOUSEWHEEL, this.onMouseWheel, this); - - // Listen to when the mouse travels out of the window - // window.addEventListener('mouseout', this.onMouseOutFunc, false); - - // @ts-ignore - mouse._target.addEventListener('dblclick', this.onDblClick.bind(this)); - - // Disabling the context menu stops the browser displaying a menu when - // you right-click the page - mouse.disableContextMenu(); - } - - destroy() { - const mouse = this.camera.scene.app.mouse; - mouse.off(EVENT_MOUSEDOWN, this.onMouseDown, this); - mouse.off(EVENT_MOUSEUP, this.onMouseUp, this); - mouse.off(EVENT_MOUSEMOVE, this.onMouseMove, this); - mouse.off(EVENT_MOUSEWHEEL, this.onMouseWheel, this); - - // window.removeEventListener('mouseout', this.onMouseOutFunc, false); - - // @ts-ignore - mouse._target.removeEventListener('dblclick', this.onDblClick); - } - orbit(dx: number, dy: number) { - if (!this.enableOrbit) { - return; - } - const azim = this.camera.azim - dx * this.camera.scene.config.controls.orbitSensitivity; - const elev = this.camera.elevation - dy * this.camera.scene.config.controls.orbitSensitivity; - this.camera.setAzimElev(azim, elev); - } - - pan(x: number, y: number) { - if (!this.enablePan) { - return; - } - // For panning to work at any zoom level, we use screen point to world projection - // to work out how far we need to pan the pivotEntity in world space - const camera = this.camera.entity.camera; - const distance = this.camera.focusDistance * this.camera.distanceTween.value.distance; - - camera.screenToWorld(x, y, distance, fromWorldPoint); - camera.screenToWorld(this.lastPoint.x, this.lastPoint.y, distance, toWorldPoint); - - worldDiff.sub2(toWorldPoint, fromWorldPoint); - worldDiff.add(this.camera.focalPoint); - - this.camera.setFocalPoint(worldDiff); - } - - zoom(amount: number) { - if (!this.enableZoom) { - return; - } - this.camera.setDistance(this.camera.distance * (1 - amount), 2); - } - - hasDragged(event: MouseEvent): boolean { - return this.xMouse !== event.x || this.yMouse !== event.y; - } - - onMouseDown(event: MouseEvent) { - this.xMouse = event.x; - this.yMouse = event.y; - switch (event.button) { - case MOUSEBUTTON_LEFT: - this.leftButton = true; - this.camera.notify('mouseStart'); - break; - case MOUSEBUTTON_MIDDLE: - this.middleButton = true; - this.camera.notify('mouseStart'); - break; - case MOUSEBUTTON_RIGHT: - this.rightButton = true; - this.camera.notify('mouseStart'); - break; - } - } - - onMouseUp(event: MouseEvent) { - switch (event.button) { - case MOUSEBUTTON_LEFT: - this.leftButton = false; - this.camera.notify('mouseEnd'); - break; - case MOUSEBUTTON_MIDDLE: - this.middleButton = false; - this.camera.notify('mouseEnd'); - break; - case MOUSEBUTTON_RIGHT: - this.rightButton = false; - this.camera.notify('mouseEnd'); - break; - } - - if (this.hasDragged(event)) { - this.xMouse = event.x; - this.yMouse = event.y; - } - } + console.log(event); + }; - onMouseMove(event: MouseEvent) { - if (this.leftButton) { - if (event.ctrlKey) { - this.zoom(event.dx * -0.02); - } else if (event.shiftKey) { - this.pan(event.x, event.y); + const pointerup = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + buttons[event.button] = false; } else { - this.orbit(event.dx, event.dy); + touches = touches.filter((touch) => touch.id !== event.pointerId); } - } else if (this.rightButton) { - this.pan(event.x, event.y); - } else if (this.middleButton) { - this.zoom(event.dx * -0.02); - } - - this.lastPoint.set(event.x, event.y); - } - - onMouseWheel(event: MouseEvent) { - this.zoom(event.wheelDelta * -0.2 * this.camera.scene.config.controls.zoomSensitivity); - this.camera.notify('mouseZoom'); - event.event.preventDefault(); - if (event.wheelDelta !== this.zoomedIn) { - this.zoomedIn = event.wheelDelta; - } - } - - onMouseOut() { - this.leftButton = this.middleButton = this.rightButton = false; - } -} - -// TouchController - -class TouchController { - touch: TouchDevice; - camera: Camera; - lastTouchPoint = new Vec2(); - lastPinchMidPoint = new Vec2(); - lastPinchDistance = 0; - pinchMidPoint = new Vec2(); - - enableOrbit = true; - enablePan = true; - enableZoom = true; - xTouch: number; - yTouch: number; - - constructor(camera: Camera) { - this.camera = camera; - this.xTouch = 0; - this.yTouch = 0; - // Use the same callback for the touchStart, touchEnd and touchCancel events as they - // all do the same thing which is to deal the possible multiple touches to the screen - const touch = this.camera.scene.app.touch; - touch.on(EVENT_TOUCHSTART, this.onTouchStartEndCancel, this); - touch.on(EVENT_TOUCHEND, this.onTouchStartEndCancel, this); - touch.on(EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this); - touch.on(EVENT_TOUCHMOVE, this.onTouchMove, this); - } - - destroy() { - const touch = this.camera.scene.app.touch; - touch.off(EVENT_TOUCHSTART, this.onTouchStartEndCancel, this); - touch.off(EVENT_TOUCHEND, this.onTouchStartEndCancel, this); - touch.off(EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this); - touch.off(EVENT_TOUCHMOVE, this.onTouchMove, this); - } - - getPinchDistance(pointA: Touch, pointB: Touch) { - // Return the distance between the two points - const dx = pointA.x - pointB.x; - const dy = pointA.y - pointB.y; - return Math.sqrt(dx * dx + dy * dy); - } - - calcMidPoint(pointA: Touch, pointB: Touch, result: Vec2) { - result.set(pointB.x - pointA.x, pointB.y - pointA.y); - result.mulScalar(0.5); - result.x += pointA.x; - result.y += pointA.y; - } - - onTouchStartEndCancel(event: TouchEvent) { - // We only care about the first touch for camera rotation. As the user touches the screen, - // we stored the current touch position - const touches = event.touches; - if (touches.length === 1) { - this.lastTouchPoint.set(touches[0].x, touches[0].y); - this.camera.notify('touchStart'); - this.xTouch = touches[0].x; - this.yTouch = touches[0].y; - } else if (touches.length === 2) { - // If there are 2 touches on the screen, then set the pinch distance - this.lastPinchDistance = this.getPinchDistance(touches[0], touches[1]); - this.calcMidPoint(touches[0], touches[1], this.lastPinchMidPoint); - this.camera.notify('touchStart'); - } else { - this.camera.notify('touchEnd'); - } - } + }; - pan(midPoint: Vec2) { - if (!this.enablePan) { - return; - } + const pointermove = (event: PointerEvent) => { + if (event.pointerType === 'mouse') { + const dx = event.offsetX - x; + const dy = event.offsetY - y; + x = event.offsetX; + y = event.offsetY; + + if (buttons[0]) { + orbit(dx, dy); + } else if (buttons[1]) { + zoom(dy * -0.02); + } else if (buttons[2]) { + pan(x, y, dx, dy); + } + } else { + if (touches.length === 1) { + const touch = touches[0]; + const dx = event.offsetX - touch.x; + const dy = event.offsetY - touch.y; + touch.x = event.offsetX; + touch.y = event.offsetY; + orbit(dx, dy); + } else if (touches.length === 2) { + const touch = touches[touches.map(t => t.id).indexOf(event.pointerId)]; + touch.x = event.offsetX; + touch.y = event.offsetY; + + const mx = (touches[0].x + touches[1].x) * 0.5; + const my = (touches[0].y + touches[1].y) * 0.5; + const ml = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y); + + pan(mx, my, (mx - midx), (my - midy)); + zoom((ml - midlen) * 0.01); + + midx = mx; + midy = my; + midlen = ml; + } + } + }; - // For panning to work at any zoom level, we use screen point to world projection - // to work out how far we need to pan the pivotEntity in world space - const camera = this.camera.entity.camera; - const distance = this.camera.distance; + const wheel = (event: WheelEvent) => { + event.preventDefault(); + const sign = (v: number) => v > 0 ? 1 : v < 0 ? -1 : 0; + zoom(sign(event.deltaY) * 0.2); + orbit(sign(event.deltaX) * 2.0, 0); + }; - camera.screenToWorld(midPoint.x, midPoint.y, distance, fromWorldPoint); - camera.screenToWorld(this.lastPinchMidPoint.x, this.lastPinchMidPoint.y, distance, toWorldPoint); + const dblclick = (event: globalThis.MouseEvent) => { + camera.pickFocalPoint(event.offsetX, event.offsetY); + }; - worldDiff.sub2(toWorldPoint, fromWorldPoint); - worldDiff.add(this.camera.focalPoint); + const contextmenu = (event: globalThis.MouseEvent) => { + event.preventDefault(); + }; - this.camera.setFocalPoint(worldDiff); + target.addEventListener('pointerdown', pointerdown); + target.addEventListener('pointerup', pointerup); + target.addEventListener('pointermove', pointermove); + target.addEventListener('wheel', wheel); + target.addEventListener('dblclick', dblclick); + target.addEventListener('contextmenu', contextmenu); + + this.destroy = () => { + target.removeEventListener('pointerdown', pointerdown); + target.removeEventListener('pointerup', pointerup); + target.removeEventListener('pointermove', pointermove); + target.removeEventListener('wheel', wheel); + target.removeEventListener('dblclick', dblclick); + target.removeEventListener('contextmenu', contextmenu); + }; } - onTouchMove(event: TouchEvent) { - const pinchMidPoint = this.pinchMidPoint; - - // We only care about the first touch for camera rotation. Work out the difference moved since the last event - // and use that to update the camera target position - const touches = event.touches; - if (touches.length === 1 && this.enableOrbit) { - const touch = touches[0]; - const elev = - this.camera.elevation - - (touch.y - this.lastTouchPoint.y) * this.camera.scene.config.controls.orbitSensitivity; - const azim = - this.camera.azim - - (touch.x - this.lastTouchPoint.x) * this.camera.scene.config.controls.orbitSensitivity; - this.camera.setAzimElev(azim, elev); - this.lastTouchPoint.set(touch.x, touch.y); - } else if (touches.length === 2 && this.enableZoom) { - // Calculate the difference in pinch distance since the last event - const currentPinchDistance = this.getPinchDistance(touches[0], touches[1]); - const diffInPinchDistance = currentPinchDistance - this.lastPinchDistance; - this.lastPinchDistance = currentPinchDistance; - - const distance = - this.camera.distance - - diffInPinchDistance * - this.camera.scene.config.controls.zoomSensitivity * - 0.1 * - (this.camera.distance * 0.1); - this.camera.setDistance(distance); - - // Calculate pan difference - this.calcMidPoint(touches[0], touches[1], pinchMidPoint); - this.pan(pinchMidPoint); - this.lastPinchMidPoint.copy(pinchMidPoint); - } - } } -export {MouseController, TouchController}; +export { PointerController }; diff --git a/src/scene.ts b/src/scene.ts index 61c0d82c..02277bed 100644 --- a/src/scene.ts +++ b/src/scene.ts @@ -5,8 +5,6 @@ import { Color, Entity, Layer, - Mouse, - TouchDevice, GraphicsDevice } from 'playcanvas'; import { PCApp } from './pc-app'; @@ -62,11 +60,7 @@ class Scene { // configure the playcanvas application. we render to an offscreen buffer so require // only the simplest of backbuffers. - this.app = new PCApp(canvas, { - mouse: new Mouse(canvas), - touch: new TouchDevice(canvas), - graphicsDevice: graphicsDevice - }); + this.app = new PCApp(canvas, { graphicsDevice }); // only render the scene when instructed this.app.autoRender = false; diff --git a/src/tools/brush-selection.ts b/src/tools/brush-selection.ts index 89937e20..48ba0c07 100644 --- a/src/tools/brush-selection.ts +++ b/src/tools/brush-selection.ts @@ -19,6 +19,7 @@ class BrushSelection { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.id = 'select-svg'; svg.style.display = 'inline'; + root.style.touchAction = 'none'; // create circle element const circle = document.createElementNS(svg.namespaceURI, 'circle') as SVGCircleElement; diff --git a/src/tools/picker-selection.ts b/src/tools/picker-selection.ts index f0a29b89..25f7348c 100644 --- a/src/tools/picker-selection.ts +++ b/src/tools/picker-selection.ts @@ -7,6 +7,7 @@ class PickerSelection { constructor(events: Events, parent: HTMLElement) { this.root = document.createElement('div'); this.root.id = 'select-root'; + this.root.style.touchAction = 'none'; this.root.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary) { diff --git a/src/tools/rect-selection.ts b/src/tools/rect-selection.ts index 5b35f2c3..95e2f5dd 100644 --- a/src/tools/rect-selection.ts +++ b/src/tools/rect-selection.ts @@ -22,6 +22,7 @@ class RectSelection { // create input dom const root = document.createElement('div'); root.id = 'select-root'; + root.style.touchAction = 'none'; let dragId: number | undefined; diff --git a/src/ui/control-panel.ts b/src/ui/control-panel.ts index f1513c03..f2757dae 100644 --- a/src/ui/control-panel.ts +++ b/src/ui/control-panel.ts @@ -70,6 +70,10 @@ class ControlPanel extends Panel { const item = items.get(selection); if (item) { item.selected = true; + + // Temporary workaround for shortcuts not working after load - remove focus from tree element + // @ts-ignore + document.activeElement?.blur(); } }); diff --git a/src/ui/editor.ts b/src/ui/editor.ts index c27dc6d5..7957998c 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -56,6 +56,7 @@ class EditorUI { // canvas const canvas = document.createElement('canvas'); canvas.id = 'canvas'; + canvas.style.touchAction = 'none'; // filename label const filenameLabel = new Label({ From bbc734d57b16cee44df29ed2ebd094d808f0c709 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 28 Jun 2024 16:37:53 +0100 Subject: [PATCH 05/18] add capture for camera controls --- src/controllers.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/controllers.ts b/src/controllers.ts index cfa94078..bf9e5f99 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -45,10 +45,16 @@ class PointerController { const pointerdown = (event: PointerEvent) => { if (event.pointerType === 'mouse') { + if (buttons.every(b => !b)) { + target.setPointerCapture(event.pointerId); + } buttons[event.button] = true; x = event.offsetX; y = event.offsetY; } else if (event.pointerType === 'touch') { + if (touches.length === 0) { + target.setPointerCapture(event.pointerId); + } touches.push({ x: event.offsetX, y: event.offsetY, @@ -61,15 +67,19 @@ class PointerController { midlen = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y); } } - - console.log(event); }; const pointerup = (event: PointerEvent) => { if (event.pointerType === 'mouse') { buttons[event.button] = false; + if (buttons.every(b => !b)) { + target.releasePointerCapture(event.pointerId); + } } else { touches = touches.filter((touch) => touch.id !== event.pointerId); + if (touches.length === 0) { + target.setPointerCapture(event.pointerId); + } } }; @@ -117,7 +127,7 @@ class PointerController { const wheel = (event: WheelEvent) => { event.preventDefault(); const sign = (v: number) => v > 0 ? 1 : v < 0 ? -1 : 0; - zoom(sign(event.deltaY) * 0.2); + zoom(sign(event.deltaY) * -0.2); orbit(sign(event.deltaX) * 2.0, 0); }; From a7cff4045b9ada6a0ca33e3718c19abb97bd5ac6 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 28 Jun 2024 16:38:23 +0100 Subject: [PATCH 06/18] bump package version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4ed19a6..983847e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supersplat", - "version": "0.19.3", + "version": "0.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supersplat", - "version": "0.19.3", + "version": "0.20.0", "license": "MIT", "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", diff --git a/package.json b/package.json index d9c7730d..be2a3513 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "supersplat", - "version": "0.19.3", + "version": "0.20.0", "author": "PlayCanvas", "homepage": "https://playcanvas.com/supersplat/editor", "description": "3D Gaussian Splat Editor", From 281ad4dd8d274dd6b84739e3b5015c48359d7086 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Fri, 28 Jun 2024 16:42:17 +0100 Subject: [PATCH 07/18] lint --- src/controllers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/controllers.ts b/src/controllers.ts index bf9e5f99..5b52ccd4 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -5,6 +5,9 @@ const fromWorldPoint = new Vec3(); const toWorldPoint = new Vec3(); const worldDiff = new Vec3(); +// calculate the distance between two 2d points +const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); + class PointerController { destroy: () => void; @@ -35,14 +38,14 @@ class PointerController { camera.setDistance(camera.distance * (1 - amount * camera.scene.config.controls.zoomSensitivity), 2); }; - let buttons = [false, false, false]; + // mouse state + const buttons = [false, false, false]; let x: number, y: number; + // touch state let touches: { id: number, x: number, y: number}[] = []; let midx: number, midy: number, midlen: number; - const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); - const pointerdown = (event: PointerEvent) => { if (event.pointerType === 'mouse') { if (buttons.every(b => !b)) { @@ -155,7 +158,6 @@ class PointerController { target.removeEventListener('contextmenu', contextmenu); }; } - } export { PointerController }; From c3f92db78fd202bbd11cb747b90596ff52bbbf1c Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 8 Jul 2024 14:34:11 +0100 Subject: [PATCH 08/18] update deps, camera control, simple 2d gs support --- package-lock.json | 108 ++++++++++++++++++++++---------------------- package.json | 10 ++-- src/asset-loader.ts | 12 +++++ src/camera.ts | 4 ++ src/controllers.ts | 46 ++++++++++++++++++- 5 files changed, 120 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 983847e5..c894ef9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supersplat", - "version": "0.20.0", + "version": "0.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supersplat", - "version": "0.20.0", + "version": "0.20.1", "license": "MIT", "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", @@ -19,16 +19,16 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@types/wicg-file-system-access": "^2023.10.5", - "@typescript-eslint/eslint-plugin": "^7.14.1", - "@typescript-eslint/parser": "^7.14.1", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", "concurrently": "^8.2.2", "cors": "^2.8.5", "cross-env": "^7.0.3", "eslint": "^8.56.0", "jest": "^29.7.0", - "playcanvas": "^1.72.0", + "playcanvas": "^1.72.1", "rollup": "^4.18.0", - "rollup-plugin-sass": "^1.13.0", + "rollup-plugin-sass": "^1.13.1", "rollup-plugin-visualizer": "^5.12.0", "serve": "^14.2.3", "tslib": "^2.6.3" @@ -1882,16 +1882,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.14.1.tgz", - "integrity": "sha512-aAJd6bIf2vvQRjUG3ZkNXkmBpN+J7Wd0mfQiiVCJMu9Z5GcZZdcc0j8XwN/BM97Fl7e3SkTXODSk4VehUv7CGw==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.15.0.tgz", + "integrity": "sha512-uiNHpyjZtFrLwLDpHnzaDlP3Tt6sGMqTCiqmxaN4n4RP0EfYZDODJyddiFDF44Hjwxr5xAcaYxVKm9QKQFJFLA==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.14.1", - "@typescript-eslint/type-utils": "7.14.1", - "@typescript-eslint/utils": "7.14.1", - "@typescript-eslint/visitor-keys": "7.14.1", + "@typescript-eslint/scope-manager": "7.15.0", + "@typescript-eslint/type-utils": "7.15.0", + "@typescript-eslint/utils": "7.15.0", + "@typescript-eslint/visitor-keys": "7.15.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1915,15 +1915,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.14.1.tgz", - "integrity": "sha512-8lKUOebNLcR0D7RvlcloOacTOWzOqemWEWkKSVpMZVF/XVcwjPR+3MD08QzbW9TCGJ+DwIc6zUSGZ9vd8cO1IA==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.15.0.tgz", + "integrity": "sha512-k9fYuQNnypLFcqORNClRykkGOMOj+pV6V91R4GO/l1FDGwpqmSwoOQrOHo3cGaH63e+D3ZiCAOsuS/D2c99j/A==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.14.1", - "@typescript-eslint/types": "7.14.1", - "@typescript-eslint/typescript-estree": "7.14.1", - "@typescript-eslint/visitor-keys": "7.14.1", + "@typescript-eslint/scope-manager": "7.15.0", + "@typescript-eslint/types": "7.15.0", + "@typescript-eslint/typescript-estree": "7.15.0", + "@typescript-eslint/visitor-keys": "7.15.0", "debug": "^4.3.4" }, "engines": { @@ -1943,13 +1943,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.14.1.tgz", - "integrity": "sha512-gPrFSsoYcsffYXTOZ+hT7fyJr95rdVe4kGVX1ps/dJ+DfmlnjFN/GcMxXcVkeHDKqsq6uAcVaQaIi3cFffmAbA==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.15.0.tgz", + "integrity": "sha512-Q/1yrF/XbxOTvttNVPihxh1b9fxamjEoz2Os/Pe38OHwxC24CyCqXxGTOdpb4lt6HYtqw9HetA/Rf6gDGaMPlw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.14.1", - "@typescript-eslint/visitor-keys": "7.14.1" + "@typescript-eslint/types": "7.15.0", + "@typescript-eslint/visitor-keys": "7.15.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1960,13 +1960,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.14.1.tgz", - "integrity": "sha512-/MzmgNd3nnbDbOi3LfasXWWe292+iuo+umJ0bCCMCPc1jLO/z2BQmWUUUXvXLbrQey/JgzdF/OV+I5bzEGwJkQ==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.15.0.tgz", + "integrity": "sha512-SkgriaeV6PDvpA6253PDVep0qCqgbO1IOBiycjnXsszNTVQe5flN5wR5jiczoEoDEnAqYFSFFc9al9BSGVltkg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.14.1", - "@typescript-eslint/utils": "7.14.1", + "@typescript-eslint/typescript-estree": "7.15.0", + "@typescript-eslint/utils": "7.15.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1987,9 +1987,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.14.1.tgz", - "integrity": "sha512-mL7zNEOQybo5R3AavY+Am7KLv8BorIv7HCYS5rKoNZKQD9tsfGUpO4KdAn3sSUvTiS4PQkr2+K0KJbxj8H9NDg==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.15.0.tgz", + "integrity": "sha512-aV1+B1+ySXbQH0pLK0rx66I3IkiZNidYobyfn0WFsdGhSXw+P3YOqeTq5GED458SfB24tg+ux3S+9g118hjlTw==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2000,13 +2000,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.14.1.tgz", - "integrity": "sha512-k5d0VuxViE2ulIO6FbxxSZaxqDVUyMbXcidC8rHvii0I56XZPv8cq+EhMns+d/EVIL41sMXqRbK3D10Oza1bbA==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.15.0.tgz", + "integrity": "sha512-gjyB/rHAopL/XxfmYThQbXbzRMGhZzGw6KpcMbfe8Q3nNQKStpxnUKeXb0KiN/fFDR42Z43szs6rY7eHk0zdGQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.14.1", - "@typescript-eslint/visitor-keys": "7.14.1", + "@typescript-eslint/types": "7.15.0", + "@typescript-eslint/visitor-keys": "7.15.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2028,15 +2028,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.14.1.tgz", - "integrity": "sha512-CMmVVELns3nak3cpJhZosDkm63n+DwBlDX8g0k4QUa9BMnF+lH2lr3d130M1Zt1xxmB3LLk3NV7KQCq86ZBBhQ==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.15.0.tgz", + "integrity": "sha512-hfDMDqaqOqsUVGiEPSMLR/AjTSCsmJwjpKkYQRo1FNbmW4tBwBspYDwO9eh7sKSTwMQgBw9/T4DHudPaqshRWA==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.14.1", - "@typescript-eslint/types": "7.14.1", - "@typescript-eslint/typescript-estree": "7.14.1" + "@typescript-eslint/scope-manager": "7.15.0", + "@typescript-eslint/types": "7.15.0", + "@typescript-eslint/typescript-estree": "7.15.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2050,12 +2050,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.14.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.14.1.tgz", - "integrity": "sha512-Crb+F75U1JAEtBeQGxSKwI60hZmmzaqA3z9sYsVm8X7W5cwLEm5bRe0/uXS6+MR/y8CVpKSR/ontIAIEPFcEkA==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.15.0.tgz", + "integrity": "sha512-Hqgy/ETgpt2L5xueA/zHHIl4fJI2O4XUE9l4+OIfbJIRSnTJb/QscncdqqZzofQegIJugRIF57OJea1khw2SDw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.14.1", + "@typescript-eslint/types": "7.15.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -6299,9 +6299,9 @@ } }, "node_modules/playcanvas": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-1.72.0.tgz", - "integrity": "sha512-emtQBXvBrr5gl/9bNT+FK+fl7Zc1Z2/9faxDuo/t/J1Vrz2XFimhjQeJt6QB2F2G2typwylXm6HvRxjVF5TX8w==", + "version": "1.72.1", + "resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-1.72.1.tgz", + "integrity": "sha512-I6mx9wzi5yTCU+YLPI0S876R0Kf9K/t554/u7r2syRd4+Or/QjBqu6YJYtTMcSFtp2s0gtVjwVZKHcsg//NtvQ==", "dev": true, "dependencies": { "@types/webxr": "^0.5.16", @@ -6651,9 +6651,9 @@ } }, "node_modules/rollup-plugin-sass": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-sass/-/rollup-plugin-sass-1.13.0.tgz", - "integrity": "sha512-TL/pBbuqN3Qftiub1rLWiPnUGyL5PC7/+4x1ZgFJWzu1Y8n2vwYILt1kPR83AUzPOwqgFfD+B/LqgV6ee0+CYQ==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-sass/-/rollup-plugin-sass-1.13.1.tgz", + "integrity": "sha512-ZppWT9mHha0KT2mYOCqRujFP7BMavOsKcWX1X7fz3foAFpjcEA50704oJWf/BI2zx3wTt8gC0+DqlPvV2a1maA==", "dev": true, "dependencies": { "@rollup/pluginutils": "^3 || ^4 || ^5", diff --git a/package.json b/package.json index be2a3513..8112a727 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "supersplat", - "version": "0.20.0", + "version": "0.21.0", "author": "PlayCanvas", "homepage": "https://playcanvas.com/supersplat/editor", "description": "3D Gaussian Splat Editor", @@ -61,16 +61,16 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@types/wicg-file-system-access": "^2023.10.5", - "@typescript-eslint/eslint-plugin": "^7.14.1", - "@typescript-eslint/parser": "^7.14.1", + "@typescript-eslint/eslint-plugin": "^7.15.0", + "@typescript-eslint/parser": "^7.15.0", "concurrently": "^8.2.2", "cors": "^2.8.5", "cross-env": "^7.0.3", "eslint": "^8.56.0", "jest": "^29.7.0", - "playcanvas": "^1.72.0", + "playcanvas": "^1.72.1", "rollup": "^4.18.0", - "rollup-plugin-sass": "^1.13.0", + "rollup-plugin-sass": "^1.13.1", "rollup-plugin-visualizer": "^5.12.0", "serve": "^14.2.3", "tslib": "^2.6.3" diff --git a/src/asset-loader.ts b/src/asset-loader.ts index 82fbfaa3..cf5d8a69 100644 --- a/src/asset-loader.ts +++ b/src/asset-loader.ts @@ -53,6 +53,18 @@ class AssetLoader { ); asset.on('load', () => { stopSpinner(); + + // support loading 2d splats by adding scale_2 property with almost 0 scale + const splatData = asset.resource.splatData; + if (splatData.getProp('scale_0') && splatData.getProp('scale_1') && !splatData.getProp('scale_2')) { + const scale2 = new Float32Array(splatData.numSplats); + const value = Math.log(1e-6); + for (let i = 0; i < splatData.numSplats; i++) { + scale2[i] = value; + } + splatData.addProp('scale_2', scale2); + } + resolve(new Splat(asset)); }); asset.on('error', (err: string) => { diff --git a/src/camera.ts b/src/camera.ts index 63653a5d..ebb84189 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -317,6 +317,9 @@ class Camera extends Element { } } + // controller update + this.controller.update(deltaTime); + // update underlying values this.focalPointTween.update(deltaTime); this.azimElevTween.update(deltaTime); @@ -445,6 +448,7 @@ class Camera extends Element { } if (closestSplat) { + this.setDistance(cameraPos.sub(closestP).length() / this.focusDistance); this.setFocalPoint(closestP); scene.events.fire('selection', closestSplat); } diff --git a/src/controllers.ts b/src/controllers.ts index 5b52ccd4..e8e9123a 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -10,6 +10,7 @@ const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - class PointerController { destroy: () => void; + update: (deltaTime: number) => void; constructor(camera: Camera, target: HTMLElement) { @@ -30,7 +31,7 @@ class PointerController { worldDiff.sub2(toWorldPoint, fromWorldPoint); worldDiff.add(camera.focalPoint); - + camera.setFocalPoint(worldDiff); }; @@ -87,6 +88,7 @@ class PointerController { }; const pointermove = (event: PointerEvent) => { + let preventDefault = true; if (event.pointerType === 'mouse') { const dx = event.offsetX - x; const dy = event.offsetY - y; @@ -142,12 +144,52 @@ class PointerController { event.preventDefault(); }; + // key state + const keys: any = { + ArrowUp: 0, + ArrowDown: 0, + ArrowLeft: 0, + ArrowRight: 0 + }; + + const keydown = (event: KeyboardEvent) => { + if (keys.hasOwnProperty(event.key)) { + keys[event.key] = event.shiftKey ? 10 : (event.ctrlKey || event.metaKey || event.altKey ? 0.1 : 1); + event.preventDefault(); + event.stopPropagation(); + } + }; + + const keyup = (event: KeyboardEvent) => { + if (keys.hasOwnProperty(event.key)) { + keys[event.key] = 0; + event.preventDefault(); + event.stopPropagation(); + } + }; + + this.update = (deltaTime: number) => { + const x = keys.ArrowRight - keys.ArrowLeft; + const z = keys.ArrowDown - keys.ArrowUp; + + if (x || z) { + const factor = deltaTime * camera.distance * 20; + const worldTransform = camera.entity.getWorldTransform(); + const xAxis = worldTransform.getX().mulScalar(x * factor); + const zAxis = worldTransform.getZ().mulScalar(z * factor); + const p = camera.focalPoint.add(xAxis).add(zAxis); + camera.setFocalPoint(p); + } + }; + target.addEventListener('pointerdown', pointerdown); target.addEventListener('pointerup', pointerup); target.addEventListener('pointermove', pointermove); target.addEventListener('wheel', wheel); target.addEventListener('dblclick', dblclick); target.addEventListener('contextmenu', contextmenu); + document.addEventListener('keydown', keydown); + document.addEventListener('keyup', keyup); this.destroy = () => { target.removeEventListener('pointerdown', pointerdown); @@ -156,6 +198,8 @@ class PointerController { target.removeEventListener('wheel', wheel); target.removeEventListener('dblclick', dblclick); target.removeEventListener('contextmenu', contextmenu); + document.removeEventListener('keydown', keydown); + document.removeEventListener('keyup', keyup); }; } } From f6fbe53631ac89304f38f3ad37164c2178aef5d5 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 8 Jul 2024 14:38:53 +0100 Subject: [PATCH 09/18] linbt --- src/controllers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers.ts b/src/controllers.ts index e8e9123a..99159013 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -88,7 +88,6 @@ class PointerController { }; const pointermove = (event: PointerEvent) => { - let preventDefault = true; if (event.pointerType === 'mouse') { const dx = event.offsetX - x; const dy = event.offsetY - y; From a9458477d17a99ba6ae28716c9dd10494e88d057 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 8 Jul 2024 17:19:24 +0100 Subject: [PATCH 10/18] simplify tools ui --- src/editor.ts | 2 +- src/main.ts | 6 +- src/style.scss | 16 +--- src/tools/brush-selection.ts | 134 ++++++++++++++-------------------- src/tools/picker-selection.ts | 38 ++++------ src/tools/rect-selection.ts | 99 ++++++++++++------------- 6 files changed, 123 insertions(+), 172 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index ba4d5463..2034a19c 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -27,7 +27,7 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S // get the list of selected splats (currently limited to just a single one) const selectedSplats = () => { const selected = events.invoke('selection') as Splat; - return [selected]; + return selected ? [selected] : []; }; const debugSphereCenter = new Vec3(); diff --git a/src/main.ts b/src/main.ts index a3a6746c..5a12233b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -153,9 +153,9 @@ const main = async () => { toolManager.register('move', new MoveTool(events, editHistory, scene)); toolManager.register('rotate', new RotateTool(events, editHistory, scene)); toolManager.register('scale', new ScaleTool(events, editHistory, scene)); - toolManager.register('rectSelection', new RectSelection(events, editorUI.canvasContainer.dom)); - toolManager.register('brushSelection', new BrushSelection(events, editorUI.canvasContainer.dom)); - toolManager.register('pickerSelection', new PickerSelection(events, editorUI.canvasContainer.dom)); + toolManager.register('rectSelection', new RectSelection(events, editorUI.canvasContainer.dom, editorUI.canvas)); + toolManager.register('brushSelection', new BrushSelection(events, editorUI.canvasContainer.dom, editorUI.canvas)); + toolManager.register('pickerSelection', new PickerSelection(events, editorUI.canvasContainer.dom, editorUI.canvas)); window.scene = scene; diff --git a/src/style.scss b/src/style.scss index 142f4f96..a4d601f2 100644 --- a/src/style.scss +++ b/src/style.scss @@ -201,27 +201,19 @@ body { background-color: #f60 !important; } -#select-root { +.select-svg { display: none; position: absolute; width: 100%; height: 100%; + pointer-events: none; } -#select-svg { - display: none; - width: 100%; - height: 100%; -} - -#select-canvas { +#brush-select-canvas { display: none; position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; opacity: 0.4; + pointer-events: none; } #canvas-container { diff --git a/src/tools/brush-selection.ts b/src/tools/brush-selection.ts index 48ba0c07..7e60f0f7 100644 --- a/src/tools/brush-selection.ts +++ b/src/tools/brush-selection.ts @@ -1,41 +1,33 @@ import { Events } from "../events"; class BrushSelection { - events: Events; - root: HTMLElement; - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; - svg: SVGElement; - circle: SVGCircleElement; - radius = 40; - prev = { x: 0, y: 0 }; - - constructor(events: Events, parent: HTMLElement) { - // create input dom - const root = document.createElement('div'); - root.id = 'select-root'; + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, canvas: HTMLCanvasElement) { + let radius = 40; // create svg const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.id = 'select-svg'; - svg.style.display = 'inline'; - root.style.touchAction = 'none'; + svg.id = 'brush-select-svg'; + svg.classList.add('select-svg'); // create circle element const circle = document.createElementNS(svg.namespaceURI, 'circle') as SVGCircleElement; - circle.setAttribute('r', this.radius.toString()); + circle.setAttribute('r', radius.toString()); circle.setAttribute('fill', 'rgba(255, 102, 0, 0.2)'); circle.setAttribute('stroke', '#f60'); circle.setAttribute('stroke-width', '1'); circle.setAttribute('stroke-dasharray', '5, 5'); // create canvas - const canvas = document.createElement('canvas'); - canvas.id = 'select-canvas'; + const selectCanvas = document.createElement('canvas'); + selectCanvas.id = 'brush-select-canvas'; - const context = canvas.getContext('2d'); + const context = selectCanvas.getContext('2d'); context.globalCompositeOperation = 'copy'; + const prev = { x: 0, y: 0 }; let dragId: number | undefined; const update = (e: PointerEvent) => { @@ -49,114 +41,98 @@ class BrushSelection { context.beginPath(); context.strokeStyle = '#f60'; context.lineCap = 'round'; - context.lineWidth = this.radius * 2; - context.moveTo(this.prev.x, this.prev.y); + context.lineWidth = radius * 2; + context.moveTo(prev.x, prev.y); context.lineTo(x, y); context.stroke(); - this.prev.x = x; - this.prev.y = y; + prev.x = x; + prev.y = y; } }; - root.addEventListener('contextmenu', (e) => { - e.preventDefault(); - }); - - root.addEventListener('pointerdown', (e) => { + const pointerdown = (e: PointerEvent) => { if (dragId === undefined && (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary)) { e.preventDefault(); e.stopPropagation(); dragId = e.pointerId; - root.setPointerCapture(dragId); + canvas.setPointerCapture(dragId); // initialize canvas - if (canvas.width !== parent.clientWidth || canvas.height !== parent.clientHeight) { - canvas.width = parent.clientWidth; - canvas.height = parent.clientHeight; + if (selectCanvas.width !== parent.clientWidth || selectCanvas.height !== parent.clientHeight) { + selectCanvas.width = parent.clientWidth; + selectCanvas.height = parent.clientHeight; } // clear canvas - context.clearRect(0, 0, canvas.width, canvas.height); + context.clearRect(0, 0, selectCanvas.width, selectCanvas.height); // display it - canvas.style.display = 'inline'; + selectCanvas.style.display = 'inline'; - this.prev.x = e.offsetX; - this.prev.y = e.offsetY; + prev.x = e.offsetX; + prev.y = e.offsetY; update(e); } - }); + }; - root.addEventListener('pointermove', (e) => { + const pointermove = (e: PointerEvent) => { if (dragId !== undefined) { e.preventDefault(); e.stopPropagation(); } update(e); - }); + }; - root.addEventListener('pointerup', (e) => { + const pointerup = (e: PointerEvent) => { if (e.pointerId === dragId) { e.preventDefault(); e.stopPropagation(); - root.releasePointerCapture(dragId); + canvas.releasePointerCapture(dragId); dragId = undefined; - canvas.style.display = 'none'; + selectCanvas.style.display = 'none'; - this.events.fire( + events.fire( 'select.byMask', e.shiftKey ? 'add' : (e.ctrlKey ? 'remove' : 'set'), - context.getImageData(0, 0, canvas.width, canvas.height) + context.getImageData(0, 0, selectCanvas.width, selectCanvas.height) ); } - }); + }; - parent.appendChild(root); - root.appendChild(svg); - svg.appendChild(circle); - root.appendChild(canvas); + this.activate = () => { + svg.style.display = 'inline'; + canvas.addEventListener('pointerdown', pointerdown, true); + canvas.addEventListener('pointermove', pointermove, true); + canvas.addEventListener('pointerup', pointerup, true); + + }; + + this.deactivate = () => { + svg.style.display = 'none'; + canvas.removeEventListener('pointerdown', pointerdown, true); + canvas.removeEventListener('pointermove', pointermove, true); + canvas.removeEventListener('pointerup', pointerup, true); + }; events.on('tool.brushSelection.smaller', () => { - this.smaller(); + radius = Math.max(1, radius / 1.05); + circle.setAttribute('r', radius.toString()); }); events.on('tool.brushSelection.bigger', () => { - this.bigger(); + radius = Math.min(500, radius * 1.05); + circle.setAttribute('r', radius.toString()); }); - this.events = events; - this.root = root; - this.svg = svg; - this.circle = circle; - this.canvas = canvas; - this.context = context; - - canvas.width = parent.clientWidth; - canvas.height = parent.clientHeight; - } - - activate() { - this.root.style.display = 'block'; - } - - deactivate() { - this.root.style.display = 'none'; - } - - smaller() { - this.radius = Math.max(1, this.radius / 1.05); - this.circle.setAttribute('r', this.radius.toString()); - } - - bigger() { - this.radius = Math.min(500, this.radius * 1.05); - this.circle.setAttribute('r', this.radius.toString()); + svg.appendChild(circle); + parent.appendChild(svg); + parent.appendChild(selectCanvas); } } diff --git a/src/tools/picker-selection.ts b/src/tools/picker-selection.ts index 25f7348c..560559b7 100644 --- a/src/tools/picker-selection.ts +++ b/src/tools/picker-selection.ts @@ -1,15 +1,11 @@ import { Events } from "../events"; class PickerSelection { - events: Events; - root: HTMLElement; + activate: () => void; + deactivate: () => void; - constructor(events: Events, parent: HTMLElement) { - this.root = document.createElement('div'); - this.root.id = 'select-root'; - this.root.style.touchAction = 'none'; - - this.root.addEventListener('pointerdown', (e) => { + constructor(events: Events, parent: HTMLElement, canvas: HTMLCanvasElement) { + const pointerdown = (e: PointerEvent) => { if (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary) { e.preventDefault(); e.stopPropagation(); @@ -17,24 +13,18 @@ class PickerSelection { events.fire( 'select.point', e.shiftKey ? 'add' : (e.ctrlKey ? 'remove' : 'set'), - { x: e.offsetX / this.root.clientWidth, y: e.offsetY / this.root.clientHeight } + { x: e.offsetX / canvas.clientWidth, y: e.offsetY / canvas.clientHeight } ); } - }); - - parent.appendChild(this.root); - - this.root.addEventListener('contextmenu', (e) => { - e.preventDefault(); - }); - } - - activate() { - this.root.style.display = 'block'; - } - - deactivate() { - this.root.style.display = 'none'; + }; + + this.activate = () => { + canvas.addEventListener('pointerdown', pointerdown, true); + } + + this.deactivate = () => { + canvas.removeEventListener('pointerdown', pointerdown, true); + } } } diff --git a/src/tools/rect-selection.ts b/src/tools/rect-selection.ts index 95e2f5dd..43e4646b 100644 --- a/src/tools/rect-selection.ts +++ b/src/tools/rect-selection.ts @@ -1,16 +1,14 @@ import { Events } from '../events'; class RectSelection { - events: Events; - root: HTMLElement; - svg: SVGElement; - rect: SVGRectElement; - start = { x: 0, y: 0 }; - end = { x: 0, y: 0 }; - - constructor(events: Events, parent: HTMLElement) { + + activate: () => void; + deactivate: () => void; + + constructor(events: Events, parent: HTMLElement, canvas: HTMLCanvasElement) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.id = 'select-svg'; + svg.id = 'rect-select-svg'; + svg.classList.add('select-svg'); // create rect element const rect = document.createElementNS(svg.namespaceURI, 'rect') as SVGRectElement; @@ -19,18 +17,15 @@ class RectSelection { rect.setAttribute('stroke-width', '1'); rect.setAttribute('stroke-dasharray', '5, 5'); - // create input dom - const root = document.createElement('div'); - root.id = 'select-root'; - root.style.touchAction = 'none'; - + const start = { x: 0, y: 0 }; + const end = { x: 0, y: 0 }; let dragId: number | undefined; const updateRect = () => { - const x = Math.min(this.start.x, this.end.x); - const y = Math.min(this.start.y, this.end.y); - const width = Math.abs(this.start.x - this.end.x); - const height = Math.abs(this.start.y - this.end.y); + const x = Math.min(start.x, end.x); + const y = Math.min(start.y, end.y); + const width = Math.abs(start.x - end.x); + const height = Math.abs(start.y - end.y); rect.setAttribute('x', x.toString()); rect.setAttribute('y', y.toString()); @@ -38,75 +33,73 @@ class RectSelection { rect.setAttribute('height', height.toString()); }; - root.addEventListener('contextmenu', (e) => { - e.preventDefault(); - }); - - root.addEventListener('pointerdown', (e) => { + const pointerdown = (e: PointerEvent) => { if (dragId === undefined && (e.pointerType === 'mouse' ? e.button === 0 : e.isPrimary)) { e.preventDefault(); e.stopPropagation(); dragId = e.pointerId; - root.setPointerCapture(dragId); + canvas.setPointerCapture(dragId); - this.start.x = this.end.x = e.offsetX; - this.start.y = this.end.y = e.offsetY; + start.x = end.x = e.offsetX; + start.y = end.y = e.offsetY; updateRect(); - this.svg.style.display = 'inline'; + svg.style.display = 'inline'; } - }); + }; - root.addEventListener('pointermove', (e) => { + const pointermove = (e: PointerEvent) => { if (e.pointerId === dragId) { e.preventDefault(); e.stopPropagation(); - this.end.x = e.offsetX; - this.end.y = e.offsetY; + end.x = e.offsetX; + end.y = e.offsetY; updateRect(); } - }); + }; - root.addEventListener('pointerup', (e) => { + const pointerup = (e: PointerEvent) => { if (e.pointerId === dragId) { e.preventDefault(); e.stopPropagation(); - const w = root.clientWidth; - const h = root.clientHeight; + const w = canvas.clientWidth; + const h = canvas.clientHeight; - root.releasePointerCapture(dragId); + canvas.releasePointerCapture(dragId); dragId = undefined; - this.svg.style.display = 'none'; + svg.style.display = 'none'; - this.events.fire('select.rect', e.shiftKey ? 'add' : (e.ctrlKey ? 'remove' : 'set'), { - start: { x: Math.min(this.start.x, this.end.x) / w, y: Math.min(this.start.y, this.end.y) / h }, - end: { x: Math.max(this.start.x, this.end.x) / w, y: Math.max(this.start.y, this.end.y) / h }, + events.fire('select.rect', e.shiftKey ? 'add' : (e.ctrlKey ? 'remove' : 'set'), { + start: { x: Math.min(start.x, end.x) / w, y: Math.min(start.y, end.y) / h }, + end: { x: Math.max(start.x, end.x) / w, y: Math.max(start.y, end.y) / h }, }); } - }); + }; - parent.appendChild(root); - root.appendChild(svg); - svg.appendChild(rect); + this.activate = () => { + canvas.addEventListener('pointerdown', pointerdown, true); + canvas.addEventListener('pointermove', pointermove, true); + canvas.addEventListener('pointerup', pointerup, true); + }; - this.events = events; - this.root = root; - this.svg = svg; - this.rect = rect; - } + this.deactivate = () => { + canvas.removeEventListener('pointerdown', pointerdown, true); + canvas.removeEventListener('pointermove', pointermove, true); + canvas.removeEventListener('pointerup', pointerup, true); + }; - activate() { - this.root.style.display = 'block'; + parent.appendChild(svg); + svg.appendChild(rect); } - deactivate() { - this.root.style.display = 'none'; + destroy() { + } } From 7736a20e43c1de3396ae2c4338e501824203f86b Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 9 Jul 2024 09:19:10 +0100 Subject: [PATCH 11/18] add camera modifier on right click --- src/controllers.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/controllers.ts b/src/controllers.ts index 99159013..a44be45b 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -94,11 +94,17 @@ class PointerController { x = event.offsetX; y = event.offsetY; - if (buttons[0]) { + // right button can be used to orbit with ctrl key and to zoom with alt | meta key + const mod = buttons[2] ? + (event.shiftKey ? 'orbit' : + (event.altKey || event.metaKey ? 'zoom' : null)) : + null; + + if (mod === 'orbit' || (mod === null && buttons[0])) { orbit(dx, dy); - } else if (buttons[1]) { + } else if (mod === 'zoom' || (mod === null && buttons[1])) { zoom(dy * -0.02); - } else if (buttons[2]) { + } else if (mod === 'pan' || (mod === null && buttons[2])) { pan(x, y, dx, dy); } } else { From 07f0841d7e1bf52177e467f4a55c3ecf2e80510f Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 9 Jul 2024 09:33:04 +0100 Subject: [PATCH 12/18] small --- src/controllers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers.ts b/src/controllers.ts index a44be45b..87ba0818 100644 --- a/src/controllers.ts +++ b/src/controllers.ts @@ -96,7 +96,7 @@ class PointerController { // right button can be used to orbit with ctrl key and to zoom with alt | meta key const mod = buttons[2] ? - (event.shiftKey ? 'orbit' : + (event.shiftKey || event.ctrlKey ? 'orbit' : (event.altKey || event.metaKey ? 'zoom' : null)) : null; From e6470a68a93d9861915dd817b0bfc5f6926c9841 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 9 Jul 2024 10:00:43 +0100 Subject: [PATCH 13/18] make tools toggleable --- src/main.ts | 12 ++++++------ src/shortcuts.ts | 42 +++++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/main.ts b/src/main.ts index 5a12233b..0e57e04b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,15 +62,15 @@ const initShortcuts = (events: Events) => { shortcuts.register(['Delete', 'Backspace'], { event: 'select.delete' }); shortcuts.register(['Escape'], { event: 'tool.deactivate' }); shortcuts.register(['Tab'], { event: 'selection.next' }); - shortcuts.register(['1'], { event: 'tool.move' }); - shortcuts.register(['2'], { event: 'tool.rotate' }); - shortcuts.register(['3'], { event: 'tool.scale' }); + shortcuts.register(['1'], { event: 'tool.move', toggle: true }); + shortcuts.register(['2'], { event: 'tool.rotate', toggle: true }); + shortcuts.register(['3'], { event: 'tool.scale', toggle: true }); shortcuts.register(['G', 'g'], { event: 'show.gridToggle' }); shortcuts.register(['C', 'c'], { event: 'tool.toggleCoordSpace' }); shortcuts.register(['F', 'f'], { event: 'camera.focus' }); - shortcuts.register(['B', 'b'], { event: 'tool.brushSelection' }); - shortcuts.register(['R', 'r'], { event: 'tool.rectSelection' }); - shortcuts.register(['P', 'p'], { event: 'tool.pickerSelection' }); + shortcuts.register(['B', 'b'], { event: 'tool.brushSelection', toggle: true }); + shortcuts.register(['R', 'r'], { event: 'tool.rectSelection', toggle: true }); + shortcuts.register(['P', 'p'], { event: 'tool.pickerSelection', toggle: true }); shortcuts.register(['A', 'a'], { event: 'select.all' }); shortcuts.register(['A', 'a'], { event: 'select.none', shift: true }); shortcuts.register(['I', 'i'], { event: 'select.invert' }); diff --git a/src/shortcuts.ts b/src/shortcuts.ts index e17c8eda..6d62d215 100644 --- a/src/shortcuts.ts +++ b/src/shortcuts.ts @@ -3,38 +3,66 @@ import { Events } from "./events"; interface ShortcutOptions { ctrl?: boolean; shift?: boolean; + toggle?: boolean; func?: () => void; event?: string; } class Shortcuts { - shortcuts: { keys: string[], options: ShortcutOptions }[] = []; + shortcuts: { keys: string[], options: ShortcutOptions, toggled: boolean }[] = []; constructor(events: Events) { const shortcuts = this.shortcuts; - // register keyboard handler - document.addEventListener('keydown', (e) => { + const handleEvent = (e: KeyboardEvent, down: boolean) => { // skip keys in input fields if (e.target !== document.body) return; for (let i = 0; i < shortcuts.length; i++) { - if (shortcuts[i].keys.includes(e.key) && - !!shortcuts[i].options.ctrl === !!(e.ctrlKey || e.metaKey) && - !!shortcuts[i].options.shift === !!e.shiftKey) { + const shortcut = shortcuts[i]; + const options = shortcut.options; + + if (shortcut.keys.includes(e.key) && + !!options.ctrl === !!(e.ctrlKey || e.metaKey) && + !!options.shift === !!e.shiftKey) { + + // handle toggle shortcuts + if (options.toggle) { + if (down) { + shortcut.toggled = e.repeat; + } + + if (down === shortcut.toggled) { + return; + } + } else { + // ignore up events on non-toggled shortcuts + if (!down) return; + } + if (shortcuts[i].options.event) { events.fire(shortcuts[i].options.event); } else { shortcuts[i].options.func(); } + break; } } + }; + + // register keyboard handler + document.addEventListener('keydown', (e) => { + handleEvent(e, true); + }); + + document.addEventListener('keyup', (e) => { + handleEvent(e, false); }); } register(keys: string[], options: ShortcutOptions) { - this.shortcuts.push({ keys, options }); + this.shortcuts.push({ keys, options, toggled: false }); } } From 0059d7afcb858652ff9cdf47852a067243f6b244 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Tue, 9 Jul 2024 11:24:31 +0100 Subject: [PATCH 14/18] rename --- src/main.ts | 12 ++++++------ src/shortcuts.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main.ts b/src/main.ts index 0e57e04b..f8c0353e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -62,15 +62,15 @@ const initShortcuts = (events: Events) => { shortcuts.register(['Delete', 'Backspace'], { event: 'select.delete' }); shortcuts.register(['Escape'], { event: 'tool.deactivate' }); shortcuts.register(['Tab'], { event: 'selection.next' }); - shortcuts.register(['1'], { event: 'tool.move', toggle: true }); - shortcuts.register(['2'], { event: 'tool.rotate', toggle: true }); - shortcuts.register(['3'], { event: 'tool.scale', toggle: true }); + shortcuts.register(['1'], { event: 'tool.move', sticky: true }); + shortcuts.register(['2'], { event: 'tool.rotate', sticky: true }); + shortcuts.register(['3'], { event: 'tool.scale', sticky: true }); shortcuts.register(['G', 'g'], { event: 'show.gridToggle' }); shortcuts.register(['C', 'c'], { event: 'tool.toggleCoordSpace' }); shortcuts.register(['F', 'f'], { event: 'camera.focus' }); - shortcuts.register(['B', 'b'], { event: 'tool.brushSelection', toggle: true }); - shortcuts.register(['R', 'r'], { event: 'tool.rectSelection', toggle: true }); - shortcuts.register(['P', 'p'], { event: 'tool.pickerSelection', toggle: true }); + shortcuts.register(['B', 'b'], { event: 'tool.brushSelection', sticky: true }); + shortcuts.register(['R', 'r'], { event: 'tool.rectSelection', sticky: true }); + shortcuts.register(['P', 'p'], { event: 'tool.pickerSelection', sticky: true }); shortcuts.register(['A', 'a'], { event: 'select.all' }); shortcuts.register(['A', 'a'], { event: 'select.none', shift: true }); shortcuts.register(['I', 'i'], { event: 'select.invert' }); diff --git a/src/shortcuts.ts b/src/shortcuts.ts index 6d62d215..57b81683 100644 --- a/src/shortcuts.ts +++ b/src/shortcuts.ts @@ -3,7 +3,7 @@ import { Events } from "./events"; interface ShortcutOptions { ctrl?: boolean; shift?: boolean; - toggle?: boolean; + sticky?: boolean; func?: () => void; event?: string; } @@ -26,8 +26,8 @@ class Shortcuts { !!options.ctrl === !!(e.ctrlKey || e.metaKey) && !!options.shift === !!e.shiftKey) { - // handle toggle shortcuts - if (options.toggle) { + // handle sticky shortcuts + if (options.sticky) { if (down) { shortcut.toggled = e.repeat; } @@ -36,7 +36,7 @@ class Shortcuts { return; } } else { - // ignore up events on non-toggled shortcuts + // ignore up events on non-sticky shortcuts if (!down) return; } From af7373d2607e7b662768904821e81388742f8370 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 10 Jul 2024 08:05:51 +0100 Subject: [PATCH 15/18] added visible and remove buttons --- src/editor.ts | 2 +- src/file-handler.ts | 4 +- src/selection.ts | 16 ++-- src/splat.ts | 17 +++- src/style.scss | 73 +++++++++++++++ src/ui/control-panel.ts | 194 ++++++++++++++++++++++++++++++++++------ 6 files changed, 268 insertions(+), 38 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index 2034a19c..ce041c0b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -27,7 +27,7 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S // get the list of selected splats (currently limited to just a single one) const selectedSplats = () => { const selected = events.invoke('selection') as Splat; - return selected ? [selected] : []; + return selected?.visible ? [selected] : []; }; const debugSphereCenter = new Vec3(); diff --git a/src/file-handler.ts b/src/file-handler.ts index e66696a0..40695bc8 100644 --- a/src/file-handler.ts +++ b/src/file-handler.ts @@ -119,9 +119,9 @@ const initFileHandler = async (scene: Scene, events: Events, canvas: HTMLCanvasE } }); - // get the active splat + // get the array of visible splats const getSplats = () => { - return scene.getElementsByType(ElementType.splat) as Splat[]; + return (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible); }; events.function('scene.canSave', () => { diff --git a/src/selection.ts b/src/selection.ts index fbf7dbf9..1d93cc22 100644 --- a/src/selection.ts +++ b/src/selection.ts @@ -1,4 +1,5 @@ import { Element, ElementType } from './element'; +import { Splat } from './splat'; import { Events } from './events'; import { Scene } from './scene'; @@ -21,20 +22,13 @@ const initSelection = (events: Events, scene: Scene) => { }); events.on('selection', (element: Element) => { - if (element !== selection) { + if (element !== selection && (!element || (element as Splat).visible)) { selection = element; events.fire('selection.changed', selection); scene.forceRender = true; } }); - events.on('selection.byUid', (uid: number) => { - const splat = scene.getElementsByType(ElementType.splat).find(v => v.uid === uid); - if (splat) { - events.fire('selection', splat); - } - }); - events.function('selection', () => { return selection; }); @@ -46,6 +40,12 @@ const initSelection = (events: Events, scene: Scene) => { events.fire('selection', splats[(idx + 1) % splats.length]); } }); + + events.on('splat.vis', (splat: Splat) => { + if (splat === selection && !splat.visible) { + events.fire('selection', null); + } + }); }; export { initSelection }; diff --git a/src/splat.ts b/src/splat.ts index d1e949a4..73818093 100644 --- a/src/splat.ts +++ b/src/splat.ts @@ -139,6 +139,7 @@ class Splat extends Element { worldBoundStorage: BoundingBox; localBoundDirty = true; worldBoundDirty = true; + visible_ = true; constructor(asset: Asset) { super(ElementType.splat); @@ -263,6 +264,7 @@ class Splat extends Element { serialize(serializer: Serializer) { serializer.packa(this.entity.getWorldTransform().data); serializer.pack(this.changedCounter); + serializer.pack(this.visible); } onPreRender() { @@ -276,10 +278,12 @@ class Splat extends Element { material.setParameter('ringSize', (selected && cameraMode === 'rings' && splatSize > 0) ? 0.04 : 0); // render splat centers - if (selected && cameraMode === 'centers' && splatSize > 0) { + if (this.visible && selected && cameraMode === 'centers' && splatSize > 0) { this.splatDebug.splatSize = splatSize; this.scene.app.drawMeshInstance(this.splatDebug.meshInstance); } + + this.entity.enabled = this.visible; } focalPoint() { @@ -342,6 +346,17 @@ class Splat extends Element { return this.worldBoundStorage; } + + get visible() { + return this.visible_; + } + + set visible(value: boolean) { + if (value !== this.visible) { + this.visible_ = value; + this.scene.events.fire('splat.vis', this); + } + } } export { Splat }; diff --git a/src/style.scss b/src/style.scss index a4d601f2..dd438d2d 100644 --- a/src/style.scss +++ b/src/style.scss @@ -92,6 +92,79 @@ body { background-color: royalblue; } +.scene-panel-splat-item { + display: flex; + flex-direction: row; + + border-bottom: 1px solid $bcg-darker; + + &:hover:not(.selected) { + background-color: $bcg-darkest; + } + + &.selected { + color: $text-primary; + background-color: royalblue; + } +} + +.scene-panel-splat-item-text { + flex-grow: 1; + flex-shrink: 1; + pointer-events: none; + + .selected & { + color: $text-primary; + } +} + +.scene-panel-splat-item-visible { + flex-grow: 0; + flex-shrink: 0; + + // border: 1px solid black; + padding: 4px; + width: 16px; + height: 16px; + line-height: 16px; + + color: black; + background-color: transparent; + + cursor: pointer; + + &.checked { + color: $text-active; + } + + &::after { + font-family: pc-icon; + font-size: 18px; + content: '\E117'; + } +} + +.scene-panel-splat-item-delete { + flex-grow: 0; + flex-shrink: 0; + + // border: 1px solid black; + padding: 4px; + width: 16px; + height: 16px; + line-height: 16px; + + color: $text-secondary; + + cursor: pointer; + + &::after { + font-family: pc-icon; + font-size: 16px; + content: '\E124'; + } +} + #file-selector { display: none; } diff --git a/src/ui/control-panel.ts b/src/ui/control-panel.ts index f2757dae..1100dc82 100644 --- a/src/ui/control-panel.ts +++ b/src/ui/control-panel.ts @@ -1,9 +1,134 @@ -import { BooleanInput, Button, Container, Label, NumericInput, Panel, RadioButton, SelectInput, SliderInput, TreeView, TreeViewItem, VectorInput } from 'pcui'; +import { BooleanInput, Button, Container, Element as PcuiElement, Label, NumericInput, Panel, RadioButton, SelectInput, SliderInput, TreeViewItem, VectorInput } from 'pcui'; import { Events } from '../events'; import { Element, ElementType } from '../element'; import { Splat } from '../splat'; import { version as appVersion } from '../../package.json'; +class SplatItem extends Container { + getSelected: () => boolean; + setSelected: (value: boolean) => void; + getVisible: () => boolean; + setVisible: (value: boolean) => void; + destroy: () => void; + + constructor(name: string, args = {}) { + args = Object.assign(args, { + class: 'scene-panel-splat-item' + }); + + super(args); + + const text = new Label({ + class: 'scene-panel-splat-item-text', + text: name + }); + + const visible = new PcuiElement({ + class: ['scene-panel-splat-item-visible', 'checked'] + }); + + const remove = new PcuiElement({ + class: 'scene-panel-splat-item-delete' + }); + + this.append(text); + this.append(visible); + this.append(remove); + + this.getSelected = () => { + return this.class.contains('selected'); + }; + + this.setSelected = (value: boolean) => { + if (value !== this.selected) { + if (value) { + this.class.add('selected'); + this.emit('select', this); + } else { + this.class.remove('selected'); + this.emit('unselect', this); + } + } + }; + + this.getVisible = () => { + return visible.class.contains('checked'); + }; + + this.setVisible = (value: boolean) => { + if (value !== this.visible) { + if (value) { + visible.class.add('checked'); + this.emit('visible', this); + } else { + visible.class.remove('checked'); + this.emit('invisible', this); + } + } + }; + + const toggleVisible = (event: MouseEvent) => { + event.stopPropagation(); + this.visible = !this.visible; + }; + + const handleRemove = (event: MouseEvent) => { + event.stopPropagation(); + this.emit('removeClicked', this); + }; + + // handle clicks + visible.dom.addEventListener('click', toggleVisible, true); + remove.dom.addEventListener('click', handleRemove, true); + + this.destroy = () => { + visible.dom.removeEventListener('click', toggleVisible, true); + remove.dom.removeEventListener('click', handleRemove, true); + } + } + + get selected() { + return this.getSelected(); + } + + set selected(value) { + this.setSelected(value); + } + + get visible() { + return this.getVisible(); + } + + set visible(value) { + this.setVisible(value); + } +} + +class SplatList extends Container { + protected _onAppendChild(element: PcuiElement): void { + super._onAppendChild(element); + + if (element instanceof SplatItem) { + element.on('click', () => { + this.emit('click', element); + }); + + element.on('removeClicked', () => { + this.emit('removeClicked', element); + }); + } + } + + protected _onRemoveChild(element: PcuiElement): void { + if (element instanceof SplatItem) { + element.unbind('click'); + element.unbind('removeClicked'); + } + + super._onRemoveChild(element); + } +} + class ControlPanel extends Panel { constructor(events: Events, remoteStorageMode: boolean, args = { }) { Object.assign(args, { @@ -26,14 +151,12 @@ class ControlPanel extends Panel { }); const splatListContainer = new Container({ - id: 'scene-panel-splat-list-container' + id: 'scene-panel-splat-list-container', + resizable: 'bottom', }); - const splatList = new TreeView({ - id: 'scene-panel-splat-list', - allowDrag: false, - allowReordering: false, - allowRenaming: false + const splatList = new SplatList({ + id: 'scene-panel-splat-list' }); splatListContainer.append(splatList); @@ -41,17 +164,17 @@ class ControlPanel extends Panel { // handle selection and scene updates - const items = new Map(); + const items = new Map(); events.on('scene.elementAdded', (element: Element) => { if (element.type === ElementType.splat) { const splat = element as Splat; - const item = new TreeViewItem({ - text: splat.filename, - open: false - }); + const item = new SplatItem(splat.filename); splatList.append(item); items.set(splat, item); + + item.on('visible', () => splat.visible = true); + item.on('invisible', () => splat.visible = false); } }); @@ -67,28 +190,47 @@ class ControlPanel extends Panel { }); events.on('selection.changed', (selection: Splat) => { - const item = items.get(selection); - if (item) { - item.selected = true; + items.forEach((value, key) => { + value.selected = key === selection; + }); + }); - // Temporary workaround for shortcuts not working after load - remove focus from tree element - // @ts-ignore - document.activeElement?.blur(); + events.on('splat.vis', (splat: Splat) => { + const item = items.get(splat); + if (item) { + item.visible = splat.visible; } }); - splatList.on('select', (item: TreeViewItem) => { - let splat: Splat = null; - items.forEach((value, key) => { - if (value === item) { + splatList.on('click', (item: SplatItem) => { + for (const [key, value] of items) { + if (item === value) { + events.fire('selection', key); + break; + } + } + }); + + splatList.on('removeClicked', async (item: SplatItem) => { + let splat; + for (const [key, value] of items) { + if (item === value) { splat = key; - } else { - value.selected = false; + break; } + } + + if (!splat) { + return; + } + + const result = await events.invoke('showPopup', { + type: 'yesno', + message: `Would you like to remove '${splat.filename}' from the scene?` }); - if (splat) { - events.fire('selection.byUid', splat.uid); + if (result?.action === 'yes') { + splat.destroy(); } }); From 71e677de3f7a758504d4e54d3df61d9bfc4704af Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 10 Jul 2024 12:31:21 +0100 Subject: [PATCH 16/18] updates --- src/main.ts | 1 + src/splat.ts | 2 +- src/ui/color.ts | 66 +++++++++++++ src/ui/data-panel.ts | 189 ++++++++++++++++++++++++-------------- src/ui/histogram.ts | 82 +++++++++++------ src/ui/shortcuts-popup.ts | 3 +- 6 files changed, 244 insertions(+), 99 deletions(-) create mode 100644 src/ui/color.ts diff --git a/src/main.ts b/src/main.ts index f8c0353e..2c278abf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -81,6 +81,7 @@ const initShortcuts = (events: Events) => { shortcuts.register(['Z', 'z'], { event: 'edit.undo', ctrl: true }); shortcuts.register(['Z', 'z'], { event: 'edit.redo', ctrl: true, shift: true }); shortcuts.register(['M', 'm'], { event: 'camera.toggleMode' }); + shortcuts.register(['D', 'd'], { event: 'dataPanel.toggle' }); // keep tabs on splat size changes let splatSizeSave = 2; diff --git a/src/splat.ts b/src/splat.ts index c5306af5..31d375eb 100644 --- a/src/splat.ts +++ b/src/splat.ts @@ -320,7 +320,7 @@ class Splat extends Element { const state = this.splatData.getProp('state') as Uint8Array; const localBound = this.localBoundStorage; - if (!this.splatData.calcAabbExact(localBound, (i: number) => (state[i] & State.deleted) === 0)) { + if (!this.splatData.calcAabb(localBound, (i: number) => (state[i] & State.deleted) === 0)) { localBound.center.set(0, 0, 0); localBound.halfExtents.set(0.5, 0.5, 0.5); } diff --git a/src/ui/color.ts b/src/ui/color.ts new file mode 100644 index 00000000..b252026e --- /dev/null +++ b/src/ui/color.ts @@ -0,0 +1,66 @@ + +const rgb2hsv = (rgb: { r: number, g: number, b: number }) => { + const r = rgb.r; + const g = rgb.g; + const b = rgb.b; + const v = Math.max(r, g, b); + const diff = v - Math.min(r, g, b); + + const diffc = (c: number) => { + return (v - c) / 6 / diff + 1 / 2; + }; + + let h, s; + + if (diff === 0) { + h = s = 0; + } else { + s = diff / v; + const rr = diffc(r); + const gg = diffc(g); + const bb = diffc(b); + + if (r === v) { + h = bb - gg; + } else if (g === v) { + h = (1 / 3) + rr - bb; + } else if (b === v) { + h = (2 / 3) + gg - rr; + } + if (h < 0) { + h += 1; + } else if (h > 1) { + h -= 1; + } + } + + return { h, s, v }; +}; + +const hsv2rgb = (hsv: { h: number, s: number, v: number }) => { + const h = hsv.h; + const s = hsv.s; + const v = hsv.v; + + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + let r, g, b; + + switch (i % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + + return { r, g, b }; +}; + +export { rgb2hsv, hsv2rgb }; + diff --git a/src/ui/data-panel.ts b/src/ui/data-panel.ts index 9b5a1e96..ffbd2ba5 100644 --- a/src/ui/data-panel.ts +++ b/src/ui/data-panel.ts @@ -3,6 +3,7 @@ import { Events } from '../events'; import { Splat } from '../splat'; import { Histogram } from './histogram'; import { State } from '../edit-ops'; +import { rgb2hsv } from './color'; const SH_C0 = 0.28209479177387814; @@ -31,6 +32,48 @@ const dataFuncs = { opacity: sigmoid }; +// build a separator label +const sepLabel = (labelText: string) => { + const container = new Container({ + class: 'control-parent', + id: 'sep-container' + }); + + container.class.add('sep-container'); + + const label = new Label({ + class: 'contol-element-expand', + text: labelText + }); + + container.append(label); + + return container; +} + +// build a data label +const dataLabel = (parent: Container, labelText: string) => { + const container = new Container({ + class: 'control-parent' + }); + + const label = new Label({ + class: 'control-label', + text: labelText + }); + + const value = new Label({ + class: 'control-element-expand' + }); + + container.append(label); + container.append(value); + + parent.append(container); + + return value; +}; + class DataPanel extends Panel { constructor(events: Events, args = { }) { args = Object.assign(args, { @@ -47,50 +90,7 @@ class DataPanel extends Panel { super(args); - // create a seperator label - const sep = (parent: Container, labelText: string) => { - const container = new Container({ - class: 'control-parent', - id: 'sep-container' - }); - - container.class.add('sep-container'); - - const label = new Label({ - class: 'contol-element-expand', - text: labelText - }); - - container.append(label); - - parent.append(container); - } - - // create a new data label - const dataLabel = (parent: Container, labelText: string) => { - const container = new Container({ - class: 'control-parent' - }); - - const label = new Label({ - class: 'control-label', - text: labelText - }); - - const value = new Label({ - class: 'control-element-expand' - }); - - container.append(label); - container.append(value); - - parent.append(container); - - return value; - }; - - - // create data controls + // build the data controls const controlsContainer = new Container({ id: 'data-controls-container' }); @@ -99,16 +99,17 @@ class DataPanel extends Panel { id: 'data-controls' }); - sep(controls, 'Histogram'); + controls.append(sepLabel('Histogram')); const dataSelector = new SelectInput({ class: 'control-element-expand', - defaultValue: 'scale_0', + defaultValue: 'surface-area', options: [ { v: 'x', t: 'X' }, { v: 'y', t: 'Y' }, { v: 'z', t: 'Z' }, { v: 'volume', t: 'Volume' }, + { v: 'surface-area', t: 'Surface Area' }, { v: 'scale_0', t: 'Scale X' }, { v: 'scale_1', t: 'Scale Y' }, { v: 'scale_2', t: 'Scale Z' }, @@ -116,6 +117,9 @@ class DataPanel extends Panel { { v: 'f_dc_1', t: 'Green' }, { v: 'f_dc_2', t: 'Blue' }, { v: 'opacity', t: 'Opacity' }, + { v: 'hue', t: 'Hue' }, + { v: 'saturation', t: 'Saturation' }, + { v: 'value', t: 'Value' } ] }); @@ -139,7 +143,7 @@ class DataPanel extends Panel { controls.append(dataSelector); controls.append(logScale); - sep(controls, 'Totals') + controls.append(sepLabel('Totals')); const splatsValue = dataLabel(controls, 'Splats'); const selectedValue = dataLabel(controls, 'Selected'); @@ -149,7 +153,7 @@ class DataPanel extends Panel { controlsContainer.append(controls); // build histogram - const histogram = new Histogram(256, 256); + const histogram = new Histogram(256, 128); const histogramContainer = new Container({ id: 'histogram-container' @@ -163,7 +167,14 @@ class DataPanel extends Panel { // current splat let splat: Splat; - // returns a function that calculates the value for the current data selector + // returns a function which will interpret the splat data for purposes of + // viewing it in the histogram. + // the returned values will depend on the currently selected data type: + // * some value functions return the raw splat data, like 'x'. + // * other value functions must transform the data for histogram visualization + // (for example 'scale_0', which must be exponentiated). + // * still other values are calculated/derived from multiple values of splat + // data like 'volume' and 'surface area'. const getValueFunc = () => { // @ts-ignore const dataFunc = dataFuncs[dataSelector.value]; @@ -172,13 +183,47 @@ class DataPanel extends Panel { let func: (i: number) => number; if (dataFunc && data) { func = (i) => dataFunc(data[i]); - } else if (dataSelector.value === 'volume') { - const sx = splat.splatData.getProp('scale_0'); - const sy = splat.splatData.getProp('scale_1'); - const sz = splat.splatData.getProp('scale_2'); - func = (i) => scaleFunc(sx[i]) * scaleFunc(sy[i]) * scaleFunc(sz[i]); } else { - func = (i) => undefined; + switch (dataSelector.value) { + case 'volume': { + const sx = splat.splatData.getProp('scale_0'); + const sy = splat.splatData.getProp('scale_1'); + const sz = splat.splatData.getProp('scale_2'); + func = (i) => scaleFunc(sx[i]) * scaleFunc(sy[i]) * scaleFunc(sz[i]); + break; + } + case 'surface-area': { + const sx = splat.splatData.getProp('scale_0'); + const sy = splat.splatData.getProp('scale_1'); + const sz = splat.splatData.getProp('scale_2'); + func = (i) => scaleFunc(sx[i]) ** 2 + scaleFunc(sy[i]) ** 2 + scaleFunc(sz[i]) ** 2; + break; + } + case 'hue': { + const r = splat.splatData.getProp('f_dc_0'); + const g = splat.splatData.getProp('f_dc_1'); + const b = splat.splatData.getProp('f_dc_2'); + func = (i) => rgb2hsv({r: colorFunc(r[i]), g: colorFunc(g[i]), b: colorFunc(b[i])}).h * 360; + break; + } + case 'saturation': { + const r = splat.splatData.getProp('f_dc_0'); + const g = splat.splatData.getProp('f_dc_1'); + const b = splat.splatData.getProp('f_dc_2'); + func = (i) => rgb2hsv({r: colorFunc(r[i]), g: colorFunc(g[i]), b: colorFunc(b[i])}).s; + break; + } + case 'value': { + const r = splat.splatData.getProp('f_dc_0'); + const g = splat.splatData.getProp('f_dc_1'); + const b = splat.splatData.getProp('f_dc_2'); + func = (i) => rgb2hsv({r: colorFunc(r[i]), g: colorFunc(g[i]), b: colorFunc(b[i])}).v; + break; + } + default: + func = (i) => undefined; + break; + } } return func; @@ -212,13 +257,12 @@ class DataPanel extends Panel { const func = getValueFunc(); // update histogram - histogram.update( - state.length, - (i) => (selected === 0 ? state[i] === 0 : state[i] === State.selected) ? func(i) : undefined, - { - logScale: logScaleValue.value - } - ); + histogram.update({ + count: state.length, + valueFunc: (i) => (state[i] === 0 || state[i] === State.selected) ? func(i) : undefined, + selectedFunc: (i) => state[i] === State.selected, + logScale: logScaleValue.value + }); } }; @@ -238,6 +282,11 @@ class DataPanel extends Panel { } }); + events.on('dataPanel.toggle', () => { + this.collapsed = !this.collapsed; + updateHistogram(); + }); + dataSelector.on('change', updateHistogram); logScaleValue.on('change', updateHistogram); @@ -248,7 +297,8 @@ class DataPanel extends Panel { const popupLabel = new Label({ id: 'data-panel-popup-label', - text: '' + text: '', + unsafe: true }); popupContainer.append(popupLabel); @@ -265,7 +315,12 @@ class DataPanel extends Panel { histogram.events.on('updateOverlay', (info: any) => { popupContainer.style.left = `${info.x + 14}px`; popupContainer.style.top = `${info.y}px`; - popupLabel.text = `value: ${info.value.toFixed(2)} - cnt: ${info.count} (${(info.total ? info.count / info.total * 100 : 0).toFixed(2)}%)`; + + const binValue = info.value.toFixed(2); + const count = info.selected + info.unselected; + const percentage = (info.total ? count / info.total * 100 : 0).toFixed(2); + + popupLabel.text = `value: ${binValue} cnt: ${count} (${percentage}%) sel: ${info.selected}`; }); // highlight @@ -292,7 +347,7 @@ class DataPanel extends Panel { svg.style.display = 'inline'; }); - histogram.events.on('select', (start: number, end: number) => { + histogram.events.on('select', (op: string, start: number, end: number) => { svg.style.display = 'none'; const state = splat.splatData.getProp('state') as Uint8Array; @@ -300,8 +355,8 @@ class DataPanel extends Panel { const func = getValueFunc(); // perform selection - events.fire('select.pred', 'set', (i: number) => { - if (state[i] !== (selection ? State.selected : 0)) { + events.fire('select.pred', op, (i: number) => { + if (state[i] !== 0 && state[i] !== State.selected) { return false; } diff --git a/src/ui/histogram.ts b/src/ui/histogram.ts index 25c3e29c..79602e56 100644 --- a/src/ui/histogram.ts +++ b/src/ui/histogram.ts @@ -1,26 +1,29 @@ import { Events } from '../events'; class HistogramData { - bins: Uint32Array; + bins: { selected: number, unselected: number }[]; numValues: number; minValue: number; maxValue: number; constructor(numBins: number) { - this.bins = new Uint32Array(numBins); + this.bins = []; + for (let i = 0; i < numBins; ++i) { + this.bins.push({ selected: 0, unselected: 0 }); + } } - calc(count: number, value: (v: number) => number | undefined) { + calc(count: number, valueFunc: (v: number) => number | undefined, selectedFunc: (v: number) => boolean) { // clear bins const bins = this.bins; for (let i = 0; i < bins.length; ++i) { - bins[i] = 0; + bins[i].selected = bins[i].unselected = 0; } // calculate min, max let min, max, i; for (i = 0; i < count; i++) { - const v = value(i); + const v = valueFunc(i); if (v !== undefined) { min = max = v; break; @@ -34,7 +37,7 @@ class HistogramData { // continue min/max calc for (; i < count; i++) { - const v = value(i); + const v = valueFunc(i); if (v !== undefined) { if (v < min) min = v; else if (v > max) max = v; } @@ -42,14 +45,17 @@ class HistogramData { // fill bins for (let i = 0; i < count; i++) { - const v = value(i); + const v = valueFunc(i); if (v !== undefined) { const n = min === max ? 0 : (v - min) / (max - min); const bin = Math.min(bins.length - 1, Math.floor(n * bins.length)); - bins[bin]++; + if (selectedFunc(i)) + bins[bin].selected++; + else + bins[bin].unselected++; } } - this.numValues = bins.reduce((t, v) => t + v, 0); + this.numValues = bins.reduce((t, v) => t + v.selected + v.unselected, 0); this.minValue = min; this.maxValue = max; } @@ -68,6 +74,13 @@ class HistogramData { } } +interface UpdateOptions { + count: number; + valueFunc: (v: number) => number | undefined; + selectedFunc: (v: number) => boolean; + logScale?: boolean +} + class Histogram { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D; @@ -143,7 +156,8 @@ class Histogram { if (dragging) { this.canvas.releasePointerCapture(e.pointerId); - this.events.fire('select', Math.min(dragStart, dragEnd), Math.max(dragStart, dragEnd)); + const op = e.shiftKey ? 'add' : (e.ctrlKey ? 'remove' : 'set'); + this.events.fire('select', op, Math.min(dragStart, dragEnd), Math.max(dragStart, dragEnd)); dragging = false; } }); @@ -163,14 +177,16 @@ class Histogram { const rect = this.canvas.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const bin = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); + const binIndex = Math.min(h.bins.length - 1, Math.floor(x * h.bins.length)); + const bin = h.bins[binIndex]; this.events.fire('updateOverlay', { x: e.offsetX, y: e.offsetY, - value: h.bucketValue(bin), + value: h.bucketValue(binIndex), size: h.bucketSize, - count: h.bins[bin], + selected: bin.selected, + unselected: bin.unselected, total: h.numValues }); } @@ -183,21 +199,16 @@ class Histogram { this.canvas.addEventListener('pointerleave', (e: PointerEvent) => { this.events.fire('hideOverlay'); }); + + this.canvas.addEventListener('contextmenu', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, true); } - // options: { - // logScale: boolean - // } - update(count: number, value: (v: number) => number | undefined, options: { logScale?: boolean } = {}) { - this.histogram.calc(count, value); - - // convert bin values to log scale - const bins = this.histogram.bins; - const vals = []; - for (let i = 0; i < bins.length; ++i) { - vals[i] = options?.logScale ? Math.log(bins[i] + 1) : bins[i]; - } - const valMax = Math.max(...vals); + update(options: UpdateOptions) { + // update histogram data + this.histogram.calc(options.count, options.valueFunc, options.selectedFunc); // draw histogram const canvas = this.canvas; @@ -205,13 +216,24 @@ class Histogram { const pixelData = this.pixelData; const pixels = new Uint32Array(pixelData.data.buffer); + const bins = this.histogram.bins; + const binMax = bins.reduce((a, v) => Math.max(a, v.selected + v.unselected), 0); + let i = 0; for (let y = 0; y < canvas.height; y++) { - for (let x = 0; x < vals.length; x++) { - if (vals[x] / valMax > (canvas.height - 1 - y) / canvas.height) { - pixels[i++] = 0xffffffff; - } else { + for (let x = 0; x < bins.length; x++) { + const bin = bins[x]; + const targetMin = binMax / canvas.height * (canvas.height - 1 - y); + + if (targetMin >= bin.selected + bin.unselected) { pixels[i++] = 0xff000000; + } else { + const targetMax = targetMin + binMax / canvas.height; + if (bin.selected === 0 || targetMax < bin.unselected) { + pixels[i++] = 0xffff7777; + } else { + pixels[i++] = 0xff00ffff; + } } } } diff --git a/src/ui/shortcuts-popup.ts b/src/ui/shortcuts-popup.ts index 119a0211..4560cb21 100644 --- a/src/ui/shortcuts-popup.ts +++ b/src/ui/shortcuts-popup.ts @@ -20,11 +20,12 @@ const shortcutList = [ { header: 'SHOW' }, { key: 'H', action: 'Hide Selected Splats' }, { key: 'U', action: 'Unhide All Splats' }, + { key: 'D', action: 'Toggle Data Panel' }, { header: 'OTHER' }, { key: 'Tab', action: 'Select Next Splat' }, { key: 'Ctrl + Z', action: 'Undo' }, { key: 'Ctrl + Shift + Z', action: 'Redo' }, - { key: 'Space', action: 'Toggle Debug Splat Display' }, + { key: 'Space', action: 'Toggle Splat Overlay' }, { key: 'F', action: 'Focus Camera on current selection' }, { key: 'M', action: 'Toggle Camera Mode'}, { key: 'G', action: 'Toggle Grid' }, From 030d48c91e829a169bdb545b3bae27ed4e355fa9 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 10 Jul 2024 13:48:21 +0100 Subject: [PATCH 17/18] fix log scale, remove select by size and opacity --- package-lock.json | 4 +-- package.json | 2 +- src/editor.ts | 55 ----------------------------------- src/ui/control-panel.ts | 64 ++++------------------------------------- src/ui/histogram.ts | 14 ++++++--- 5 files changed, 18 insertions(+), 121 deletions(-) diff --git a/package-lock.json b/package-lock.json index c894ef9e..f1836f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "supersplat", - "version": "0.20.1", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "supersplat", - "version": "0.20.1", + "version": "0.22.0", "license": "MIT", "devDependencies": { "@playcanvas/eslint-config": "^1.7.1", diff --git a/package.json b/package.json index 8112a727..85fb69ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "supersplat", - "version": "0.21.0", + "version": "0.22.0", "author": "PlayCanvas", "homepage": "https://playcanvas.com/supersplat/editor", "description": "3D Gaussian Splat Editor", diff --git a/src/editor.ts b/src/editor.ts index 830de535..e05602c7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -196,61 +196,6 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S }); }); - events.on('select.bySize', (op: string, value: number) => { - selectedSplats().forEach((splat) => { - const splatData = splat.splatData; - const state = splatData.getProp('state') as Uint8Array; - const scale_0 = splatData.getProp('scale_0'); - const scale_1 = splatData.getProp('scale_1'); - const scale_2 = splatData.getProp('scale_2'); - - // calculate min and max size - let first = true; - let scaleMin; - let scaleMax; - for (let i = 0; i < splatData.numSplats; ++i) { - if (state[i] & State.deleted) continue; - if (first) { - first = false; - scaleMin = Math.min(scale_0[i], scale_1[i], scale_2[i]); - scaleMax = Math.max(scale_0[i], scale_1[i], scale_2[i]); - } else { - scaleMin = Math.min(scaleMin, scale_0[i], scale_1[i], scale_2[i]); - scaleMax = Math.max(scaleMax, scale_0[i], scale_1[i], scale_2[i]); - } - } - - const maxScale = Math.log(Math.exp(scaleMin) + value * (Math.exp(scaleMax) - Math.exp(scaleMin))); - - processSelection(state, op, (i) => scale_0[i] > maxScale || scale_1[i] > maxScale || scale_2[i] > maxScale); - - splat.updateState(); - }); - }); - - events.on('select.byOpacity', (op: string, value: number) => { - selectedSplats().forEach((splat) => { - const splatData = splat.splatData; - const state = splatData.getProp('state') as Uint8Array; - const opacity = splatData.getProp('opacity') as Float32Array; - - const sigmoid = (v: number) => { - if (v > 0) { - return 1 / (1 + Math.exp(-v)); - } - - const t = Math.exp(v); - return t / (1 + t); - }; - - processSelection(state, op, (i) => { - return sigmoid(opacity[i]) < value; - }); - - splat.updateState(); - }); - }); - events.on('select.bySpherePlacement', (sphere: number[]) => { debugSphereCenter.set(sphere[0], sphere[1], sphere[2]); debugSphereRadius = sphere[3]; diff --git a/src/ui/control-panel.ts b/src/ui/control-panel.ts index 1100dc82..eac06741 100644 --- a/src/ui/control-panel.ts +++ b/src/ui/control-panel.ts @@ -346,54 +346,6 @@ class ControlPanel extends Panel { selectGlobal.append(selectNoneButton); selectGlobal.append(invertSelectionButton); - // select by size - const selectBySize = new Container({ - class: 'control-parent' - }); - - const selectBySizeRadio = new RadioButton({ - class: 'control-element' - }); - - const selectBySizeLabel = new Label({ - class: 'control-label', - text: 'Splat Size' - }); - - const selectBySizeSlider = new SliderInput({ - class: 'control-element-expand', - precision: 4, - enabled: false - }); - - selectBySize.append(selectBySizeRadio); - selectBySize.append(selectBySizeLabel); - selectBySize.append(selectBySizeSlider); - - // select by opacity - const selectByOpacity = new Container({ - class: 'control-parent' - }); - - const selectByOpacityRadio = new RadioButton({ - class: 'control-element' - }); - - const selectByOpacityLabel = new Label({ - class: 'control-label', - text: 'Splat Opacity' - }); - - const selectByOpacitySlider = new SliderInput({ - class: 'control-element-expand', - precision: 4, - enabled: false - }); - - selectByOpacity.append(selectByOpacityRadio); - selectByOpacity.append(selectByOpacityLabel); - selectByOpacity.append(selectByOpacitySlider); - // select by sphere const selectBySphere = new Container({ class: 'control-parent' @@ -513,8 +465,6 @@ class ControlPanel extends Panel { selectTools.append(pickerSelectButton); selectionPanel.append(selectGlobal); - selectionPanel.append(selectBySize); - selectionPanel.append(selectByOpacity); selectionPanel.append(selectBySphere); selectionPanel.append(selectByPlane); selectionPanel.append(setAddRemove); @@ -648,7 +598,7 @@ class ControlPanel extends Panel { }); // radio logic - const radioGroup = [selectBySizeRadio, selectByOpacityRadio, selectBySphereRadio, selectByPlaneRadio]; + const radioGroup = [selectBySphereRadio, selectByPlaneRadio]; radioGroup.forEach((radio, index) => { radio.on('change', () => { if (radio.value) { @@ -682,8 +632,6 @@ class ControlPanel extends Panel { removeButton.enabled = index !== null; const controlSet = [ - [selectBySizeSlider], - [selectByOpacitySlider], [selectBySphereCenter], [selectByPlaneAxis, selectByPlaneOffset] ]; @@ -694,16 +642,14 @@ class ControlPanel extends Panel { }); }); - events.fire('select.bySpherePlacement', index === 2 ? selectBySphereCenter.value : [0, 0, 0, 0]); - events.fire('select.byPlanePlacement', index === 3 ? axes[selectByPlaneAxis.value] : [0, 0, 0], selectByPlaneOffset.value); + events.fire('select.bySpherePlacement', index === 0 ? selectBySphereCenter.value : [0, 0, 0, 0]); + events.fire('select.byPlanePlacement', index === 1 ? axes[selectByPlaneAxis.value] : [0, 0, 0], selectByPlaneOffset.value); }); const performSelect = (op: string) => { switch (radioSelection) { - case 0: events.fire('select.bySize', op, selectBySizeSlider.value); break; - case 1: events.fire('select.byOpacity', op, selectByOpacitySlider.value); break; - case 2: events.fire('select.bySphere', op, selectBySphereCenter.value); break; - case 3: events.fire('select.byPlane', op, axes[selectByPlaneAxis.value], selectByPlaneOffset.value); break; + case 0: events.fire('select.bySphere', op, selectBySphereCenter.value); break; + case 1: events.fire('select.byPlane', op, axes[selectByPlaneAxis.value], selectByPlaneOffset.value); break; } }; diff --git a/src/ui/histogram.ts b/src/ui/histogram.ts index 79602e56..1717d090 100644 --- a/src/ui/histogram.ts +++ b/src/ui/histogram.ts @@ -216,8 +216,14 @@ class Histogram { const pixelData = this.pixelData; const pixels = new Uint32Array(pixelData.data.buffer); - const bins = this.histogram.bins; - const binMax = bins.reduce((a, v) => Math.max(a, v.selected + v.unselected), 0); + const binMap = options.logScale ? (x: number) => Math.log(x + 1) : (x: number) => x; + const bins = this.histogram.bins.map(v => { + return { + selected: binMap(v.unselected + v.selected), + unselected: binMap(v.unselected) + } + }); + const binMax = bins.reduce((a, v) => Math.max(a, v.selected), 0); let i = 0; for (let y = 0; y < canvas.height; y++) { @@ -225,11 +231,11 @@ class Histogram { const bin = bins[x]; const targetMin = binMax / canvas.height * (canvas.height - 1 - y); - if (targetMin >= bin.selected + bin.unselected) { + if (targetMin >= bin.selected) { pixels[i++] = 0xff000000; } else { const targetMax = targetMin + binMax / canvas.height; - if (bin.selected === 0 || targetMax < bin.unselected) { + if (bin.selected === bin.unselected || targetMax < bin.unselected) { pixels[i++] = 0xffff7777; } else { pixels[i++] = 0xff00ffff; From 1f767ea3e74f96ba85499a51e634ad3606f3af71 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Wed, 10 Jul 2024 16:10:57 +0100 Subject: [PATCH 18/18] update args handling, tweak ui and camera --- src/camera.ts | 2 +- src/scene-config.ts | 6 +++--- src/style.scss | 33 +++++++++++++++++++++++++++++++-- src/ui/control-panel.ts | 31 ++++++++++++++++++++----------- src/ui/data-panel.ts | 9 +++++---- src/ui/shortcuts-popup.ts | 5 +++-- src/ui/toolbar.ts | 5 +++-- 7 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/camera.ts b/src/camera.ts index ebb84189..2d847fad 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -285,7 +285,7 @@ class Camera extends Element { autoResolve: false }); this.entity.camera.renderTarget = renderTarget; - this.entity.camera.camera.horizontalFov = width < height; + this.entity.camera.camera.horizontalFov = width > height; // create pick mode render target this.pickModeColorBuffer = createTexture(width, height, pixelFormat); diff --git a/src/scene-config.ts b/src/scene-config.ts index 11e3c01c..ddf96501 100644 --- a/src/scene-config.ts +++ b/src/scene-config.ts @@ -21,7 +21,7 @@ const sceneConfig = { camera: { pixelScale: 1, multisample: false, - fov: 36, + fov: 50, dollyZoom: true, exposure: 1.0, toneMapping: 'linear', @@ -40,8 +40,8 @@ const sceneConfig = { maxPolarAngle: 2.8, minZoom: 0.001, maxZoom: 2.0, - initialAzim: 0, - initialElev: -27, + initialAzim: -45, + initialElev: -10, initialZoom: 1.0, autoRotate: false, autoRotateSpeed: -2.0, diff --git a/src/style.scss b/src/style.scss index 57e613e4..09082e54 100644 --- a/src/style.scss +++ b/src/style.scss @@ -135,6 +135,17 @@ body { border-right: 1px solid #20292b; display: flex; flex-direction: column; + flex-grow: 0; + flex-shrink: 0; +} + +#control-panel-controls { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + + overflow-y: auto; } .pcui-panel-header-title::before { @@ -152,6 +163,10 @@ body { #keyboard-panel & { content: '\E136'}; } +#scene-panel { + flex-shrink: 0; +} + #scene-panel-splat-list-container { // padding: 10px; overflow: scroll; @@ -316,15 +331,29 @@ body { text-shadow: 0 0 4px black; } -.control-panel > .pcui-panel-header { +#control-panel > .pcui-panel-header { background-color: $bcg-dark; } -.control-panel > .pcui-panel-content { +#control-panel > .pcui-panel-content { + display: flex; + flex-direction: column; +} + +#camera-panel > .pcui-panel-content { + display: flex; + flex-direction: column; +} + +#modify-panel > .pcui-panel-content { display: flex; flex-direction: column; } +.control-panel { + flex-shrink: 0; +} + .control-parent { width: 100%; display: flex; diff --git a/src/ui/control-panel.ts b/src/ui/control-panel.ts index eac06741..531fcf5d 100644 --- a/src/ui/control-panel.ts +++ b/src/ui/control-panel.ts @@ -12,9 +12,10 @@ class SplatItem extends Container { destroy: () => void; constructor(name: string, args = {}) { - args = Object.assign(args, { + args = { + ...args, class: 'scene-panel-splat-item' - }); + }; super(args); @@ -131,7 +132,8 @@ class SplatList extends Container { class ControlPanel extends Panel { constructor(events: Events, remoteStorageMode: boolean, args = { }) { - Object.assign(args, { + args = { + ...args, headerText: `SUPERSPLAT v${appVersion}`, id: 'control-panel', resizable: 'right', @@ -139,7 +141,7 @@ class ControlPanel extends Panel { collapsible: true, collapseHorizontally: true, scrollable: true - }); + }; super(args); @@ -153,6 +155,7 @@ class ControlPanel extends Panel { const splatListContainer = new Container({ id: 'scene-panel-splat-list-container', resizable: 'bottom', + resizeMin: 50 }); const splatList = new SplatList({ @@ -504,13 +507,13 @@ class ControlPanel extends Panel { }); const deleteSelectionButton = new Button({ - class: 'control-element', + class: 'control-element-expand', text: 'Delete Selected Splats', icon: 'E124' }); const resetButton = new Button({ - class: 'control-element', + class: 'control-element-expand', text: 'Reset Splats' }); @@ -571,13 +574,19 @@ class ControlPanel extends Panel { optionsPanel.append(allData); + const controlsContainer = new Container({ + id: 'control-panel-controls' + }); + + controlsContainer.append(cameraPanel) + controlsContainer.append(selectionPanel); + controlsContainer.append(showPanel); + controlsContainer.append(modifyPanel); + controlsContainer.append(optionsPanel); + // append this.content.append(scenePanel); - this.content.append(cameraPanel); - this.content.append(selectionPanel); - this.content.append(showPanel); - this.content.append(modifyPanel); - this.content.append(optionsPanel); + this.content.append(controlsContainer); rectSelectButton.on('click', () => { events.fire('tool.rectSelection'); diff --git a/src/ui/data-panel.ts b/src/ui/data-panel.ts index ffbd2ba5..757fbb11 100644 --- a/src/ui/data-panel.ts +++ b/src/ui/data-panel.ts @@ -76,8 +76,9 @@ const dataLabel = (parent: Container, labelText: string) => { class DataPanel extends Panel { constructor(events: Events, args = { }) { - args = Object.assign(args, { - headerText: 'Data', + args = { + ...args, + headerText: 'DATA', id: 'data-panel', resizable: 'top', resizeMax: 1000, @@ -86,7 +87,7 @@ class DataPanel extends Panel { collapseHorizontally: false, flex: true, flexDirection: 'row' - }); + }; super(args); @@ -330,7 +331,7 @@ class DataPanel extends Panel { // create rect element const rect = document.createElementNS(svg.namespaceURI, 'rect') as SVGRectElement; rect.setAttribute('id', 'highlight-rect'); - rect.setAttribute('fill', 'rgba(255, 0, 0, 0.2)'); + rect.setAttribute('fill', 'rgba(255, 102, 0, 0.2)'); rect.setAttribute('stroke', '#f60'); rect.setAttribute('stroke-width', '1'); rect.setAttribute('stroke-dasharray', '5, 5'); diff --git a/src/ui/shortcuts-popup.ts b/src/ui/shortcuts-popup.ts index 4560cb21..152d2725 100644 --- a/src/ui/shortcuts-popup.ts +++ b/src/ui/shortcuts-popup.ts @@ -34,11 +34,12 @@ const shortcutList = [ class ShortcutsPopup extends Overlay { constructor(args = {}) { - args = Object.assign(args, { + args = { + ...args, id: 'shortcuts-popup', clickable: true, hidden: true - }); + }; super(args); diff --git a/src/ui/toolbar.ts b/src/ui/toolbar.ts index 2e81402a..9ecc474c 100644 --- a/src/ui/toolbar.ts +++ b/src/ui/toolbar.ts @@ -6,9 +6,10 @@ import logo from './playcanvas-logo.png'; class Toolbar extends Container { constructor(events: Events, appContainer: Container, tooltipsContainer: Container, args = {}) { - args = Object.assign(args, { + args = { + ...args, id: 'toolbar-container' - }); + }; super(args);