diff --git a/editor/js/Config.js b/editor/js/Config.js index 620596d1dd62bd..5210b74ff682d3 100644 --- a/editor/js/Config.js +++ b/editor/js/Config.js @@ -32,7 +32,8 @@ function Config() { 'settings/shortcuts/undo': 'z', 'settings/shortcuts/focus': 'f', 'settings/shortcuts/perspective': 'p', - 'settings/shortcuts/orthographic': 'o' + 'settings/shortcuts/orthographic': 'o', + 'settings/shortcuts/selectAll': 'a' }; if ( window.localStorage[ name ] === undefined ) { diff --git a/editor/js/Selector.js b/editor/js/Selector.js index b6703b8f1fde6c..d32d079496f45d 100644 --- a/editor/js/Selector.js +++ b/editor/js/Selector.js @@ -3,6 +3,12 @@ import * as THREE from 'three'; const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); +const _box = new THREE.Box3(); +const _vector = new THREE.Vector3(); +const _deltaMatrix = new THREE.Matrix4(); +const _objectMatrix = new THREE.Matrix4(); +const _parentMatrixInverse = new THREE.Matrix4(); + class Selector { constructor( editor ) { @@ -12,9 +18,18 @@ class Selector { this.editor = editor; this.signals = signals; + this.selection = []; + + // an intermediate group used as pivot when transforming multiple objects + + this.group = new THREE.Group(); + + this._groupMatrixWorldInverse = new THREE.Matrix4(); + this._memberStates = []; + // signals - signals.intersectionsDetected.add( ( intersects ) => { + signals.intersectionsDetected.add( ( intersects, shiftKey ) => { if ( intersects.length > 0 ) { @@ -40,23 +55,53 @@ class Selector { } - // Cycle through objects if the first one is already selected - - const index = objects.indexOf( editor.selected ); + if ( shiftKey === true && this.selection.length > 0 && editor.selected !== editor.scene && editor.selected !== editor.camera ) { - if ( index !== - 1 && index < objects.length - 1 ) { + // Shift-click toggles membership in the current selection - this.select( objects[ index + 1 ] ); + this.toggle( objects[ 0 ] ); } else { - this.select( objects[ 0 ] ); + // Cycle through objects if the first one is already selected + + const index = objects.indexOf( editor.selected ); + + if ( index !== - 1 && index < objects.length - 1 ) { + + this.select( objects[ index + 1 ] ); + + } else { + + this.select( objects[ 0 ] ); + + } } } else { - this.select( null ); + if ( shiftKey !== true ) this.select( null ); // keep the selection when shift-clicking empty space + + } + + } ); + + signals.objectChanged.add( ( object ) => { + + if ( this.selection.length < 2 ) return; + + if ( object === this.group ) { + + // the group has been transformed (e.g. via TransformControls), sync its members + + this.applyGroupTransform(); + + } else if ( this.selection.indexOf( object ) !== - 1 ) { + + // a member has been changed independently (e.g. via undo/redo), re-anchor the pivot + + this.updateGroup(); } @@ -94,22 +139,132 @@ class Selector { } + getSelectionBox( target ) { + + target.makeEmpty(); + + for ( let i = 0; i < this.selection.length; i ++ ) { + + const object = this.selection[ i ]; + + target.expandByObject( object, true ); + target.expandByPoint( object.getWorldPosition( _vector ) ); // objects without geometry (e.g. lights) + + } + + return target; + + } + select( object ) { - if ( this.editor.selected === object ) return; + this.setSelection( object === null ? [] : [ object ] ); - let uuid = null; + } - if ( object !== null ) { + toggle( object ) { - uuid = object.uuid; + const selection = this.selection.slice(); + + const index = selection.indexOf( object ); + + if ( index === - 1 ) { + + selection.push( object ); + + } else { + + selection.splice( index, 1 ); } - this.editor.selected = object; - this.editor.config.setKey( 'selected', uuid ); + this.setSelection( selection ); + + } + + setSelection( objects ) { + + const editor = this.editor; + + const hadGroupSelection = this.group.parent !== null; + + this.selection = objects.slice(); - this.signals.objectSelected.dispatch( object ); + if ( objects.length > 1 ) { + + editor.sceneHelpers.add( this.group ); + this.updateGroup(); + + editor.selected = null; + editor.config.setKey( 'selected', null ); + + this.signals.objectSelected.dispatch( null ); + + } else { + + const object = ( objects.length === 1 ) ? objects[ 0 ] : null; + + editor.sceneHelpers.remove( this.group ); + + if ( editor.selected === object && hadGroupSelection === false ) return; + + editor.selected = object; + editor.config.setKey( 'selected', ( object !== null ) ? object.uuid : null ); + + this.signals.objectSelected.dispatch( object ); + + } + + } + + updateGroup() { + + const group = this.group; + + // use the center of the selection's AABB as pivot point + + this.getSelectionBox( _box ).getCenter( group.position ); + group.quaternion.identity(); + group.scale.set( 1, 1, 1 ); + group.updateWorldMatrix(); + + this._groupMatrixWorldInverse.copy( group.matrixWorld ).invert(); + + // members with a selected ancestor are updated by that ancestor's transform + + const members = this.selection.filter( ( object ) => hasSelectedAncestor( object, this.selection ) === false ); + + this._memberStates = members.map( ( object ) => ( { object: object, matrixWorld: object.matrixWorld.clone() } ) ); + + } + + applyGroupTransform() { + + const group = this.group; + + group.updateWorldMatrix(); + + _deltaMatrix.multiplyMatrices( group.matrixWorld, this._groupMatrixWorldInverse ); + + const states = this._memberStates; + + for ( let i = 0; i < states.length; i ++ ) { + + const object = states[ i ].object; + const parent = object.parent; + + _objectMatrix.multiplyMatrices( _deltaMatrix, states[ i ].matrixWorld ); + _parentMatrixInverse.copy( parent.matrixWorld ).invert(); + _objectMatrix.premultiply( _parentMatrixInverse ); + _objectMatrix.decompose( object.position, object.quaternion, object.scale ); + + object.updateWorldMatrix( false, true ); + + const helper = this.editor.helpers[ object.id ]; + + if ( helper !== undefined && helper.isSkeletonHelper !== true ) helper.update(); + + } } @@ -121,4 +276,20 @@ class Selector { } +function hasSelectedAncestor( object, selection ) { + + let parent = object.parent; + + while ( parent !== null ) { + + if ( selection.indexOf( parent ) !== - 1 ) return true; + + parent = parent.parent; + + } + + return false; + +} + export { Selector }; diff --git a/editor/js/Sidebar.Scene.js b/editor/js/Sidebar.Scene.js index 4d517cab4747d0..deec653b589527 100644 --- a/editor/js/Sidebar.Scene.js +++ b/editor/js/Sidebar.Scene.js @@ -131,11 +131,28 @@ function SidebarScene( editor ) { const outliner = new UIOutliner( editor ); outliner.setId( 'outliner' ); - outliner.onChange( function () { + outliner.onChange( function ( event ) { + + const id = parseInt( outliner.getValue() ); + + if ( event.shiftKey === true && editor.selector.selection.length > 0 && editor.selected !== editor.scene && editor.selected !== editor.camera ) { + + const object = ( id === editor.camera.id ) ? editor.camera : editor.scene.getObjectById( id ); + + // scene and main camera can't be part of a group selection + + if ( object !== editor.scene && object !== editor.camera ) { + + editor.selector.toggle( object ); + return; + + } + + } ignoreObjectSelectedSignal = true; - editor.selectById( parseInt( outliner.getValue() ) ); + editor.selectById( id ); ignoreObjectSelectedSignal = false; @@ -414,6 +431,18 @@ function SidebarScene( editor ) { outliner.setValue( editor.selected.id ); + } else { + + const selection = editor.selector.selection; + + if ( selection.length > 1 ) { + + // restore highlights of a group selection (e.g. after a graph change) + + outliner.setValues( selection.map( ( object ) => object.id ) ); + + } + } backgroundType.setValue( editor.backgroundType ); @@ -542,30 +571,57 @@ function SidebarScene( editor ) { } ); + function expandAncestors( object ) { + + let needsRefresh = false; + let parent = object.parent; + + while ( parent !== editor.scene ) { + + if ( nodeStates.get( parent ) !== true ) { + + nodeStates.set( parent, true ); + needsRefresh = true; + + } + + parent = parent.parent; + + } + + return needsRefresh; + + } + signals.objectSelected.add( function ( object ) { if ( ignoreObjectSelectedSignal === true ) return; - if ( object !== null && object.parent !== null ) { + const selection = editor.selector.selection; - let needsRefresh = false; - let parent = object.parent; + if ( selection.length > 1 ) { - while ( parent !== editor.scene ) { + // highlight all members of a group selection - if ( nodeStates.get( parent ) !== true ) { + let needsRefresh = false; - nodeStates.set( parent, true ); - needsRefresh = true; + const values = []; - } + for ( let i = 0; i < selection.length; i ++ ) { - parent = parent.parent; + values.push( selection[ i ].id ); + needsRefresh = expandAncestors( selection[ i ] ) || needsRefresh; } if ( needsRefresh ) refreshUI(); + outliner.setValues( values ); + + } else if ( object !== null && object.parent !== null ) { + + if ( expandAncestors( object ) ) refreshUI(); + outliner.setValue( object.id ); } else { diff --git a/editor/js/Sidebar.Settings.Shortcuts.js b/editor/js/Sidebar.Settings.Shortcuts.js index 20b92a5ba0a513..5e6c37e4407f3d 100644 --- a/editor/js/Sidebar.Settings.Shortcuts.js +++ b/editor/js/Sidebar.Settings.Shortcuts.js @@ -24,7 +24,7 @@ function SidebarSettingsShortcuts( editor ) { headerRow.add( new UIText( strings.getKey( 'sidebar/settings/shortcuts' ).toUpperCase() ) ); container.add( headerRow ); - const shortcuts = [ 'translate', 'rotate', 'scale', 'undo', 'focus', 'perspective', 'orthographic' ]; + const shortcuts = [ 'translate', 'rotate', 'scale', 'undo', 'focus', 'perspective', 'orthographic', 'selectAll' ]; function createShortcutInput( name ) { @@ -106,27 +106,81 @@ function SidebarSettingsShortcuts( editor ) { // fall-through - case 'delete': + case 'delete': { - const object = editor.selected; + const objects = editor.selector.selection; - if ( object === null || object.parent === null ) return; + const commands = []; - if ( object.isSpotLight || object.isDirectionalLight ) { + for ( let i = 0; i < objects.length; i ++ ) { - editor.execute( new MultiCmdsCommand( editor, [ - new RemoveObjectCommand( editor, object ), - new RemoveObjectCommand( editor, object.target ) - ] ) ); + const object = objects[ i ]; + + if ( object.parent === null ) continue; // avoid deleting the camera or scene + + if ( object.isSpotLight || object.isDirectionalLight ) { + + commands.push( new RemoveObjectCommand( editor, object ) ); + commands.push( new RemoveObjectCommand( editor, object.target ) ); + + } else { + + commands.push( new RemoveObjectCommand( editor, object ) ); + + } + + } + + if ( commands.length === 1 ) { + + editor.execute( commands[ 0 ] ); + + } else if ( commands.length > 1 ) { + + editor.execute( new MultiCmdsCommand( editor, commands ) ); + + } + + break; + + } + + case config.getKey( 'settings/shortcuts/selectAll' ): { + + if ( event.altKey === true || event.ctrlKey === true || event.metaKey === true ) break; + + // toggle between selecting and deselecting all scene objects + + const objects = editor.scene.children; + const selection = editor.selector.selection; + + let allSelected = objects.length > 0; + + for ( let i = 0; i < objects.length; i ++ ) { + + if ( selection.indexOf( objects[ i ] ) === - 1 ) { + + allSelected = false; + break; + + } + + } + + if ( allSelected === true ) { + + editor.deselect(); } else { - editor.execute( new RemoveObjectCommand( editor, object ) ); + editor.selector.setSelection( objects ); } break; + } + case config.getKey( 'settings/shortcuts/translate' ): signals.transformModeChanged.dispatch( 'translate' ); diff --git a/editor/js/Strings.js b/editor/js/Strings.js index 7627143f21731a..cc429b3c599dd8 100644 --- a/editor/js/Strings.js +++ b/editor/js/Strings.js @@ -400,6 +400,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': 'مقیاس', 'sidebar/settings/shortcuts/undo': 'بازگشت به عقب', 'sidebar/settings/shortcuts/focus': 'فوکوس', + 'sidebar/settings/shortcuts/selectAll': 'انتخاب همه', 'sidebar/history': 'هیستوری', 'sidebar/history/clear': 'پاک کردن', @@ -852,6 +853,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/focus': 'Focus', 'sidebar/settings/shortcuts/perspective': 'Perspective', 'sidebar/settings/shortcuts/orthographic': 'Orthographic', + 'sidebar/settings/shortcuts/selectAll': 'Select All', 'sidebar/history': 'History', 'sidebar/history/clear': 'Clear', @@ -1302,6 +1304,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': 'Échelle', 'sidebar/settings/shortcuts/undo': 'Annuler', 'sidebar/settings/shortcuts/focus': 'Focus', + 'sidebar/settings/shortcuts/selectAll': 'Tout sélectionner', 'sidebar/history': 'Historique', 'sidebar/history/clear': 'Supprimer', @@ -1752,6 +1755,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': '缩放', 'sidebar/settings/shortcuts/undo': '撤销', 'sidebar/settings/shortcuts/focus': '聚焦', + 'sidebar/settings/shortcuts/selectAll': '全选', 'sidebar/history': '历史记录', 'sidebar/history/clear': '清空', @@ -2202,6 +2206,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': 'スケール', 'sidebar/settings/shortcuts/undo': '元に戻す', 'sidebar/settings/shortcuts/focus': 'フォーカス', + 'sidebar/settings/shortcuts/selectAll': 'すべて選択', 'sidebar/history': '履歴', 'sidebar/history/clear': 'クリア', @@ -2651,6 +2656,7 @@ function Strings( config ) { 'sidebar/settings/shortcuts/scale': '스케일', 'sidebar/settings/shortcuts/undo': '되돌리기', 'sidebar/settings/shortcuts/focus': '포커스', + 'sidebar/settings/shortcuts/selectAll': '모두 선택', 'sidebar/history': '기록', 'sidebar/history/clear': '지우기', diff --git a/editor/js/Viewport.js b/editor/js/Viewport.js index f399da7235bf74..2a9027f0d3e95a 100644 --- a/editor/js/Viewport.js +++ b/editor/js/Viewport.js @@ -16,6 +16,7 @@ import { XR } from './Viewport.XR.js'; import { SetPositionCommand } from './commands/SetPositionCommand.js'; import { SetRotationCommand } from './commands/SetRotationCommand.js'; import { SetScaleCommand } from './commands/SetScaleCommand.js'; +import { MultiCmdsCommand } from './commands/MultiCmdsCommand.js'; import { ColorEnvironment } from 'three/addons/environments/ColorEnvironment.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; @@ -72,9 +73,7 @@ function Viewport( editor ) { selectionBox.visible = false; sceneHelpers.add( selectionBox ); - let objectPositionOnDown = null; - let objectRotationOnDown = null; - let objectScaleOnDown = null; + let objectStatesOnDown = []; const transformControls = new TransformControls( camera ); transformControls.addEventListener( 'axis-changed', function () { @@ -91,50 +90,56 @@ function Viewport( editor ) { const object = transformControls.object; - objectPositionOnDown = object.position.clone(); - objectRotationOnDown = object.rotation.clone(); - objectScaleOnDown = object.scale.clone(); + const objects = ( object === selector.group ) ? selector.selection : [ object ]; + + objectStatesOnDown = objects.map( ( object ) => ( { + object: object, + position: object.position.clone(), + rotation: object.rotation.clone(), + scale: object.scale.clone() + } ) ); controls.enabled = false; } ); transformControls.addEventListener( 'mouseUp', function () { - const object = transformControls.object; + if ( transformControls.object !== undefined ) { - if ( object !== undefined ) { + const commands = []; - switch ( transformControls.getMode() ) { + for ( let i = 0; i < objectStatesOnDown.length; i ++ ) { - case 'translate': + const state = objectStatesOnDown[ i ]; + const object = state.object; - if ( ! objectPositionOnDown.equals( object.position ) ) { + if ( ! state.position.equals( object.position ) ) { - editor.execute( new SetPositionCommand( editor, object, object.position, objectPositionOnDown ) ); + commands.push( new SetPositionCommand( editor, object, object.position, state.position ) ); - } + } - break; + if ( ! state.rotation.equals( object.rotation ) ) { - case 'rotate': + commands.push( new SetRotationCommand( editor, object, object.rotation, state.rotation ) ); - if ( ! objectRotationOnDown.equals( object.rotation ) ) { + } - editor.execute( new SetRotationCommand( editor, object, object.rotation, objectRotationOnDown ) ); + if ( ! state.scale.equals( object.scale ) ) { - } + commands.push( new SetScaleCommand( editor, object, object.scale, state.scale ) ); - break; + } - case 'scale': + } - if ( ! objectScaleOnDown.equals( object.scale ) ) { + if ( commands.length === 1 ) { - editor.execute( new SetScaleCommand( editor, object, object.scale, objectScaleOnDown ) ); + editor.execute( commands[ 0 ] ); - } + } else if ( commands.length > 1 ) { - break; + editor.execute( new MultiCmdsCommand( editor, commands ) ); } @@ -193,12 +198,12 @@ function Viewport( editor ) { } - function handleClick() { + function handleClick( event ) { if ( onDownPosition.distanceTo( onUpPosition ) === 0 ) { const intersects = selector.getPointerIntersects( onUpPosition, camera ); - signals.intersectionsDetected.dispatch( intersects ); + signals.intersectionsDetected.dispatch( intersects, event.shiftKey ); render(); @@ -224,7 +229,7 @@ function Viewport( editor ) { const array = getMousePosition( container.dom, event.clientX, event.clientY ); onUpPosition.fromArray( array ); - handleClick(); + handleClick( event ); document.removeEventListener( 'mouseup', onMouseUp ); @@ -248,7 +253,7 @@ function Viewport( editor ) { const array = getMousePosition( container.dom, touch.clientX, touch.clientY ); onUpPosition.fromArray( array ); - handleClick(); + handleClick( event ); document.removeEventListener( 'touchend', onTouchEnd ); @@ -441,7 +446,14 @@ function Viewport( editor ) { selectionBox.visible = false; transformControls.detach(); - if ( object !== null && object !== scene && object !== camera ) { + if ( selector.selection.length > 1 ) { + + selector.getSelectionBox( box ); + + selectionBox.visible = true; + transformControls.attach( selector.group ); + + } else if ( object !== null && object !== scene && object !== camera ) { box.setFromObject( object, true ); @@ -484,6 +496,10 @@ function Viewport( editor ) { box.setFromObject( object, true ); + } else if ( selector.selection.length > 1 && ( object === selector.group || selector.selection.indexOf( object ) !== - 1 ) ) { + + selector.getSelectionBox( box ); + } if ( object.isPerspectiveCamera ) { diff --git a/editor/js/libs/ui.three.js b/editor/js/libs/ui.three.js index 0ac5490810715c..8d84c7007eb237 100644 --- a/editor/js/libs/ui.three.js +++ b/editor/js/libs/ui.three.js @@ -349,11 +349,12 @@ class UIOutliner extends UIDiv { } - function onClick() { + function onClick( event ) { scope.setValue( this.value ); const changeEvent = new Event( 'change', { bubbles: true, cancelable: true } ); + changeEvent.shiftKey = event.shiftKey; scope.dom.dispatchEvent( changeEvent ); } @@ -551,6 +552,31 @@ class UIOutliner extends UIDiv { } + setValues( values ) { + + for ( let i = 0; i < this.options.length; i ++ ) { + + const element = this.options[ i ]; + + if ( values.indexOf( element.value ) !== - 1 ) { + + element.classList.add( 'active' ); + + } else { + + element.classList.remove( 'active' ); + + } + + } + + this.selectedIndex = - 1; + this.selectedValue = null; + + return this; + + } + } class UIPoints extends UISpan {