diff --git a/editor/src/editor/layout/animation/timeline/timeline.tsx b/editor/src/editor/layout/animation/timeline/timeline.tsx index 2ab8d1c4a..bf6f0347b 100644 --- a/editor/src/editor/layout/animation/timeline/timeline.tsx +++ b/editor/src/editor/layout/animation/timeline/timeline.tsx @@ -221,6 +221,7 @@ export class EditorAnimationTimelinePanel extends Component { tracks.forEach((track, index) => { diff --git a/editor/src/editor/layout/animation/timeline/track.tsx b/editor/src/editor/layout/animation/timeline/track.tsx index 54baa2632..4fc20fe79 100644 --- a/editor/src/editor/layout/animation/timeline/track.tsx +++ b/editor/src/editor/layout/animation/timeline/track.tsx @@ -118,6 +118,7 @@ export class EditorAnimationTimelineItem extends Component { const index = this.props.animation.getKeys().indexOf(key); @@ -138,6 +139,7 @@ export class EditorAnimationTimelineItem extends Component { animationsKeyConfigurationsToMove.forEach((configurations) => { @@ -172,6 +174,7 @@ export class EditorAnimationTimelineItem extends Component keys.splice(index, 0, key), redo: () => keys.splice(index, 1), diff --git a/editor/src/editor/layout/animation/tracks/tracks.tsx b/editor/src/editor/layout/animation/tracks/tracks.tsx index 39f421cc2..5046c62c3 100644 --- a/editor/src/editor/layout/animation/tracks/tracks.tsx +++ b/editor/src/editor/layout/animation/tracks/tracks.tsx @@ -112,6 +112,7 @@ export class EditorAnimationTracksPanel extends Component { const index = animatable.animations?.indexOf(animation) ?? -1; if (index !== -1) { @@ -139,6 +140,7 @@ export class EditorAnimationTracksPanel extends Component { animatable.animations?.splice(index, 0, animation); diff --git a/editor/src/editor/layout/cinematic/curves/handle.tsx b/editor/src/editor/layout/cinematic/curves/handle.tsx index 6c74e5d33..13dffb4c9 100644 --- a/editor/src/editor/layout/cinematic/curves/handle.tsx +++ b/editor/src/editor/layout/cinematic/curves/handle.tsx @@ -129,6 +129,7 @@ export function CinematicEditorCurveHandle(props: ICinematicEditorCurveHandlePro const newInTangent = props.nextEditableTangentProperty ? getEditablePropertyValue(props.nextEditableTangentProperty) : null; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { if (oldOutTangent !== null && props.editableTangentProperty) { diff --git a/editor/src/editor/layout/cinematic/curves/point.tsx b/editor/src/editor/layout/cinematic/curves/point.tsx index 1bb7a308e..58e5bf4b6 100644 --- a/editor/src/editor/layout/cinematic/curves/point.tsx +++ b/editor/src/editor/layout/cinematic/curves/point.tsx @@ -112,6 +112,7 @@ export function CinematicEditorPropertyPoint(props: ICinematicEditorPropertyPoin const newValue = getEditablePropertyValue(props.editableProperty); registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, action: () => { props.cinematicEditor.cinematic.tracks.forEach((track) => { diff --git a/editor/src/editor/layout/cinematic/inspector/events/base.tsx b/editor/src/editor/layout/cinematic/inspector/events/base.tsx index 46f2c43f0..32d2f8aa3 100644 --- a/editor/src/editor/layout/cinematic/inspector/events/base.tsx +++ b/editor/src/editor/layout/cinematic/inspector/events/base.tsx @@ -28,6 +28,7 @@ export function CinematicEditorBaseEventKeyInspector(props: ICinematicEditorBase const oldData = props.cinematicKey.data; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => (props.cinematicKey.data = oldData), redo: () => { diff --git a/editor/src/editor/layout/cinematic/inspector/key-cut.tsx b/editor/src/editor/layout/cinematic/inspector/key-cut.tsx index 9d601642d..364df43a7 100644 --- a/editor/src/editor/layout/cinematic/inspector/key-cut.tsx +++ b/editor/src/editor/layout/cinematic/inspector/key-cut.tsx @@ -38,6 +38,7 @@ export function CinematicEditorKeyCutInspector(props: ICinematicEditorKeyCutInsp newValue = newValue.clone?.() ?? newValue; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: false, action: () => { props.cinematicEditor.updateTracksAtCurrentTime(); diff --git a/editor/src/editor/layout/cinematic/inspector/key.tsx b/editor/src/editor/layout/cinematic/inspector/key.tsx index b165d9644..feac1b7bc 100644 --- a/editor/src/editor/layout/cinematic/inspector/key.tsx +++ b/editor/src/editor/layout/cinematic/inspector/key.tsx @@ -38,6 +38,7 @@ export function CinematicEditorKeyInspector(props: ICinematicEditorKeyInspectorP newValue = newValue.clone?.() ?? newValue; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: false, action: () => { props.cinematicEditor.updateTracksAtCurrentTime(); diff --git a/editor/src/editor/layout/cinematic/timelines/add.ts b/editor/src/editor/layout/cinematic/timelines/add.ts index 79904df22..1a80d5a74 100644 --- a/editor/src/editor/layout/cinematic/timelines/add.ts +++ b/editor/src/editor/layout/cinematic/timelines/add.ts @@ -86,6 +86,7 @@ export function addAnimationKey( } as ICinematicKeyCut); registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { const index = keyFrameAnimations.indexOf(key); @@ -139,6 +140,7 @@ export function addSoundKey(cinematicEditor: CinematicEditor, track: ICinematicT } as ICinematicSound; registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { const index = track.sounds!.indexOf(key); @@ -179,6 +181,7 @@ export function addEventKey(cinematicEditor: CinematicEditor, track: ICinematicT } as ICinematicKeyEvent; registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { const index = track.keyFrameEvents!.indexOf(key); @@ -221,6 +224,7 @@ export function addAnimationGroupKey(cinematicEditor: CinematicEditor, track: IC } as ICinematicAnimationGroup; registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { const index = track.animationGroups!.indexOf(key); diff --git a/editor/src/editor/layout/cinematic/timelines/move.ts b/editor/src/editor/layout/cinematic/timelines/move.ts index e35be800a..ed7665b41 100644 --- a/editor/src/editor/layout/cinematic/timelines/move.ts +++ b/editor/src/editor/layout/cinematic/timelines/move.ts @@ -176,6 +176,7 @@ export function registerKeysMovedUndoRedo(cinematicEditor: CinematicEditor, anim }); registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { animationsKeyConfigurationsToMove.forEach((trackConfiguration) => { diff --git a/editor/src/editor/layout/cinematic/timelines/remove.ts b/editor/src/editor/layout/cinematic/timelines/remove.ts index 19ae30732..20b77b363 100644 --- a/editor/src/editor/layout/cinematic/timelines/remove.ts +++ b/editor/src/editor/layout/cinematic/timelines/remove.ts @@ -14,6 +14,7 @@ export function removeAnimationKey(cinematicEditor: CinematicEditor, track: ICin } registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { if (keyFrameAnimationsIndex !== -1) { @@ -53,6 +54,7 @@ export function removeSoundKey(cinematicEditor: CinematicEditor, track: ICinemat } registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => track.sounds!.splice(index, 0, soundKey), redo: () => track.sounds!.splice(index, 1), @@ -68,6 +70,7 @@ export function removeEventKey(cinematicEditor: CinematicEditor, track: ICinemat } registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => track.keyFrameEvents!.splice(index, 0, eventKey), redo: () => track.keyFrameEvents!.splice(index, 1), @@ -83,6 +86,7 @@ export function removeAnimationGroupKey(cinematicEditor: CinematicEditor, track: } registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => track.animationGroups!.splice(index, 0, animationGroup), redo: () => track.animationGroups!.splice(index, 1), diff --git a/editor/src/editor/layout/cinematic/timelines/tools.ts b/editor/src/editor/layout/cinematic/timelines/tools.ts index 13bac0cec..f9314d7bd 100644 --- a/editor/src/editor/layout/cinematic/timelines/tools.ts +++ b/editor/src/editor/layout/cinematic/timelines/tools.ts @@ -50,6 +50,7 @@ export function transformKeyAs(cinematicEditor: CinematicEditor, cinematicKey: I } registerUndoRedo({ + object: cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { Object.keys(cinematicKey).forEach((key) => { diff --git a/editor/src/editor/layout/cinematic/tracks.tsx b/editor/src/editor/layout/cinematic/tracks.tsx index bfc875878..d3e0858d3 100644 --- a/editor/src/editor/layout/cinematic/tracks.tsx +++ b/editor/src/editor/layout/cinematic/tracks.tsx @@ -176,6 +176,7 @@ export class CinematicEditorTracks extends Component { const index = cinematic.tracks.indexOf(track); @@ -198,6 +199,7 @@ export class CinematicEditorTracks extends Component { cinematic.tracks.splice(index, 0, track); diff --git a/editor/src/editor/layout/cinematic/tracks/animation-group.tsx b/editor/src/editor/layout/cinematic/tracks/animation-group.tsx index b5e2bd354..d2c515527 100644 --- a/editor/src/editor/layout/cinematic/tracks/animation-group.tsx +++ b/editor/src/editor/layout/cinematic/tracks/animation-group.tsx @@ -25,6 +25,7 @@ export function CinematicEditorAnimationGroupTrack(props: ICinematicEditorAnimat const oldAnimationGroup = props.track.animationGroup; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => (props.track.animationGroup = oldAnimationGroup), redo: () => (props.track.animationGroup = animationGroup), diff --git a/editor/src/editor/layout/cinematic/tracks/property.tsx b/editor/src/editor/layout/cinematic/tracks/property.tsx index 93875cd52..2b175a70c 100644 --- a/editor/src/editor/layout/cinematic/tracks/property.tsx +++ b/editor/src/editor/layout/cinematic/tracks/property.tsx @@ -105,6 +105,7 @@ export function CinematicEditorPropertyTrack(props: ICinematicEditorPropertyTrac const oldKeyFrameAnimations = props.track.keyFrameAnimations; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { props.track.node = oldNode; @@ -155,6 +156,7 @@ export function CinematicEditorPropertyTrack(props: ICinematicEditorPropertyTrac const oldKeyFrameAnimations = props.track.keyFrameAnimations; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { props.track.propertyPath = oldPropertyPath; diff --git a/editor/src/editor/layout/cinematic/tracks/sound.tsx b/editor/src/editor/layout/cinematic/tracks/sound.tsx index 9d4e2cfd0..0764ae6d7 100644 --- a/editor/src/editor/layout/cinematic/tracks/sound.tsx +++ b/editor/src/editor/layout/cinematic/tracks/sound.tsx @@ -49,6 +49,7 @@ export function CinematicEditorSoundTrack(props: ICinematicEditorSoundTrackProps const oldSounds = props.track.sounds; registerUndoRedo({ + object: props.cinematicEditor.editor.layout.preview.scene, executeRedo: true, undo: () => { props.track.sound = oldSound; diff --git a/editor/src/editor/layout/graph.tsx b/editor/src/editor/layout/graph.tsx index d58c7e23c..f1b0839aa 100644 --- a/editor/src/editor/layout/graph.tsx +++ b/editor/src/editor/layout/graph.tsx @@ -3,7 +3,7 @@ import { extname } from "path/posix"; import { Component, DragEvent, ReactNode } from "react"; import { Button, Tree, TreeNodeInfo } from "@blueprintjs/core"; -import { FaLink } from "react-icons/fa6"; +import { FaCube, FaLink } from "react-icons/fa6"; import { IoMdCube } from "react-icons/io"; import { AiOutlinePlus } from "react-icons/ai"; import { HiSpeakerWave } from "react-icons/hi2"; @@ -11,15 +11,17 @@ import { SiBabylondotjs } from "react-icons/si"; import { MdOutlineQuestionMark } from "react-icons/md"; import { GiBrickWall, GiSparkles } from "react-icons/gi"; import { HiOutlineCubeTransparent } from "react-icons/hi"; -import { IoCheckmark, IoSparklesSharp } from "react-icons/io5"; +import { IoCheckmark, IoPlayCircle, IoSparklesSharp } from "react-icons/io5"; import { TbGhost2Filled, TbServerSpark, TbBrandAdobeIndesign } from "react-icons/tb"; import { FaCamera, FaImage, FaLightbulb, FaBone, FaRegLightbulb } from "react-icons/fa"; import { AdvancedDynamicTexture } from "babylonjs-gui"; -import { BaseTexture, Node, Scene, Tools, IParticleSystem, Sprite, Skeleton, TransformNode, AbstractMesh } from "babylonjs"; +import { BaseTexture, Node, Observable, Scene, Tools, IParticleSystem, Sprite, Skeleton, TransformNode, AbstractMesh } from "babylonjs"; import { Editor } from "../main"; +import { Badge } from "../../ui/shadcn/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/shadcn/ui/tabs"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../ui/shadcn/ui/dropdown-menu"; import { ContextMenu, @@ -69,10 +71,12 @@ import { onNodesAddedObservable, onParticleSystemAddedObservable, onParticleSystemModifiedObservable, + onPlaySceneChangedObservable, onSkeletonModifiedObservable, onSpriteModifiedObservable, onTextureModifiedObservable, } from "../../tools/observables"; +import { getObjectScene, isPlaySceneObject, isScenePlaying } from "../../tools/scene/play/runtime"; import { getNodeCommands } from "../dialogs/command-palette/node"; import { getMeshCommands } from "../dialogs/command-palette/mesh"; @@ -105,6 +109,10 @@ export interface IEditorGraphState { * The nodes of the graph. */ nodes: TreeNodeInfo[]; + /** + * The nodes of the play scene graph. Empty when the game / application is not playing. + */ + runtimeNodes: TreeNodeInfo[]; /** * Defines wether or not the preview is focused. @@ -126,15 +134,24 @@ export interface IEditorGraphState { hideInstancedMeshes: boolean; } +type GraphTreeKind = "scene" | "runtime"; + export class EditorGraph extends Component { public _nodeToCopyTransform: Node | null = null; public _objectsToCopy: TreeNodeInfo[] = []; + private _playSceneObserverRemovers: (() => void)[] = []; + private _playSceneRefreshTimeout: number | null = null; + + private _previousTreeNodes: Map = new Map(); + private _activeTab: GraphTreeKind = "runtime"; + public constructor(props: IEditorGraphProps) { super(props); this.state = { nodes: [], + runtimeNodes: [], search: "", isFocused: false, @@ -147,17 +164,25 @@ export class EditorGraph extends Component onNodesAddedObservable.add(() => this.refresh()); onParticleSystemAddedObservable.add(() => this.refresh()); + onPlaySceneChangedObservable.add((scene) => this._handlePlaySceneChanged(scene)); + onNodeModifiedObservable.add((node) => this._handleObjectModified(node)); onSpriteModifiedObservable.add((node) => this._handleObjectModified(node)); onTextureModifiedObservable.add((texture) => this._handleObjectModified(texture)); onSkeletonModifiedObservable.add((skeleton) => this._handleObjectModified(skeleton)); onParticleSystemModifiedObservable.add((particleSystem) => this._handleObjectModified(particleSystem)); - document.addEventListener("copy", () => !isDomTextInputFocused() && this.copySelectedNodes()); - document.addEventListener("paste", () => !isDomTextInputFocused() && this.pasteSelectedNodes()); + document.addEventListener("copy", () => !isDomTextInputFocused() && !this.isRuntimeTabActive() && this.copySelectedNodes()); + document.addEventListener("paste", () => !isDomTextInputFocused() && !this.isRuntimeTabActive() && this.pasteSelectedNodes()); } public render(): ReactNode { + const playing = isScenePlaying(this.props.editor); + if (!playing) { + // Tabs are unmounted: reset so the next Play starts on the "Runtime" tab (onValueChange doesn't fire on mount). + this._activeTab = "runtime"; + } + return (
+ {!playing && this._getSceneTreeComponent()} + + {playing && ( + this._handleTabChanged(value as GraphTreeKind)} + className="flex flex-col gap-2 w-full h-full px-2 overflow-hidden" + > + + + Scene + + + + Runtime + + + + + {this._getSceneTreeComponent()} + + + + {this._getRuntimeTreeComponent()} + + + )} + + ); + } + + private _getSceneTreeComponent(): ReactNode { + return ( + <> this._handleNodeExpanded(n)} - onNodeCollapse={(n) => this._handleNodeCollapsed(n)} - onNodeClick={(n, _, ev) => this._handleNodeClicked(n, ev)} - onNodeContextMenu={(n, _, ev) => this._handleNodeContextMenu(n, ev)} - onNodeDoubleClick={(n, _, ev) => this._handleNodeDoubleClicked(n, ev)} + onNodeExpand={(n) => this._handleNodeExpanded(n, "scene")} + onNodeCollapse={(n) => this._handleNodeCollapsed(n, "scene")} + onNodeClick={(n, _, ev) => this._handleNodeClicked(n, ev, "scene")} + onNodeContextMenu={(n, _, ev) => this._handleNodeContextMenu(n, ev, "scene")} + onNodeDoubleClick={(n, _, ev) => this._handleNodeDoubleClicked(n, ev, "scene")} />
ev.preventDefault()} onDrop={(ev) => this._handleDropEmpty(ev)}> @@ -280,28 +339,191 @@ export class EditorGraph extends Component
+ + ); + } + + private _getRuntimeTreeComponent(): ReactNode { + return ( +
+ + + Runtime scene — structure is read-only, changes are not saved. + + + this._handleNodeExpanded(n, "runtime")} + onNodeCollapse={(n) => this._handleNodeCollapsed(n, "runtime")} + onNodeClick={(n, _, ev) => this._handleNodeClicked(n, ev, "runtime")} + onNodeContextMenu={(n, _, ev) => this._handleNodeContextMenu(n, ev, "runtime")} + onNodeDoubleClick={(n, _, ev) => this._handleNodeDoubleClicked(n, ev, "runtime")} + />
); } + private _handleTabChanged(tab: GraphTreeKind): void { + this._activeTab = tab; + + let selected: any = null; + this._forEachNode(this._getTreeNodes(tab), (n) => (selected ??= n.isSelected ? n.nodeData : null)); + + if (tab === "scene") { + this.props.editor.layout.inspector.setEditedObject(selected ?? this.props.editor.layout.preview.scene); + this.props.editor.layout.animations.setEditedObject(selected ?? null); + + if (selected && !isPlaySceneObject(this.props.editor, selected) && (isNode(selected) || isSprite(selected))) { + this.props.editor.layout.preview.gizmo.setAttachedObject(selected); + } + } else { + this.props.editor.layout.inspector.setEditedObject(selected ?? this.props.editor.layout.preview.play?.scene ?? null); + this.props.editor.layout.animations.setEditedObject(selected ?? null); + } + } + + /** + * Returns wether or not the "Runtime" tab of the graph is active while the game / application is playing. + */ + public isRuntimeTabActive(): boolean { + return isScenePlaying(this.props.editor) && this._activeTab === "runtime"; + } + public componentDidMount(): void { onProjectConfigurationChangedObservable.add(() => { this.refresh(); }); } + /** + * Called on the play scene changed (play started, stopped or restarted). + * Subscribes to the play scene events to keep the graph live while playing, and cleans + * all the references to the play scene before it gets disposed. + */ + private _handlePlaySceneChanged(scene: Scene | null): void { + this._playSceneObserverRemovers.forEach((remove) => remove()); + this._playSceneObserverRemovers = []; + + if (this._playSceneRefreshTimeout !== null) { + window.clearTimeout(this._playSceneRefreshTimeout); + this._playSceneRefreshTimeout = null; + } + + if (scene) { + const track = (observable: Observable): void => { + const observer = observable.add(() => this._schedulePlaySceneRefresh()); + this._playSceneObserverRemovers.push(() => observable.remove(observer)); + }; + + track(scene.onNewMeshAddedObservable); + track(scene.onMeshRemovedObservable); + track(scene.onNewTransformNodeAddedObservable); + track(scene.onTransformNodeRemovedObservable); + track(scene.onNewLightAddedObservable); + track(scene.onLightRemovedObservable); + track(scene.onNewCameraAddedObservable); + track(scene.onCameraRemovedObservable); + } else { + this.setState({ runtimeNodes: [] }); + + const previewScene = this.props.editor.layout.preview.scene; + + let selected: any = null; + this._forEachNode(this.state.nodes, (n) => (selected ??= n.isSelected ? n.nodeData : null)); + + // The play scene is already null here: compare against the edited scene to know + // wether or not the inspector was showing a runtime object. + const editedObject = this.props.editor.layout.inspector.state.editedObject; + const editedObjectScene = getObjectScene(editedObject); + + if (editedObject && editedObjectScene && editedObjectScene !== previewScene) { + this.props.editor.layout.inspector.setEditedObject(selected ?? previewScene); + } + + // The animations panel tracks its own object: it may retain a disposed animatable even + // when the inspector was re-pointed elsewhere during the play (ex. using "Edit Camera"). + const animatable = this.props.editor.layout.animations.state.animatable; + const animatableScene = getObjectScene(animatable); + + if (animatable && animatableScene && animatableScene !== previewScene) { + this.props.editor.layout.animations.setEditedObject(null); + + if (selected) { + this.props.editor.layout.animations.setEditedObject(selected); + } + } + } + + this.refresh(); + } + + /** + * Schedules a debounced refresh of the graph while the play scene is running. + * Scripts may add/remove lots of nodes per frame: a debounce avoids refreshing the graph for each one. + */ + private _schedulePlaySceneRefresh(): void { + if (this._playSceneRefreshTimeout !== null) { + return; + } + + this._playSceneRefreshTimeout = window.setTimeout(() => { + this._playSceneRefreshTimeout = null; + + if (this.props.editor.layout.preview.play?.canPlayScene) { + this._refreshRuntimeNodes(); + } + }, 300); + } + /** * Refreshes the graph. */ public refresh(): Promise { - const scene = this.props.editor.layout.preview.scene; - const clusteredLightContainer = this.props.editor.layout.preview.clusteredLightContainer; + this._refreshSceneNodes(); + + if (this.props.editor.layout.preview.play?.canPlayScene) { + this._refreshRuntimeNodes(); + } else if (this.state.runtimeNodes.length) { + this.setState({ runtimeNodes: [] }); + } + return waitNextAnimationFrame(); + } + + private _refreshSceneNodes(): void { + this._previousTreeNodes = this._getTreeNodesIndex(this.state.nodes); + this.setState({ nodes: this._buildTreeNodes(this.props.editor.layout.preview.scene, true) }); + } + + private _refreshRuntimeNodes(): void { + const scene = this.props.editor.layout.preview.play?.scene; + if (!scene) { + return; + } + + this._previousTreeNodes = this._getTreeNodesIndex(this.state.runtimeNodes); + this.setState({ runtimeNodes: this._buildTreeNodes(scene, false) }); + } + + /** + * Returns the given tree nodes indexed by id, used to preserve the selection and expansion + * states across refreshes in constant time (runtime scenes may contain tens of thousands of nodes). + */ + private _getTreeNodesIndex(nodes: TreeNodeInfo[]): Map { + const index = new Map(); + this._forEachNode(nodes, (n) => index.set(n.id, n)); + + return index; + } + + private _buildTreeNodes(scene: Scene, isEditedScene: boolean): TreeNodeInfo[] { let nodes: (TreeNodeInfo | null)[] = []; if (this.state.showOnlyLights || this.state.showOnlyDecals) { if (this.state.showOnlyLights) { - nodes.push(...scene.lights.concat(clusteredLightContainer.lights).map((light) => this._parseSceneNode(light, true))); + // The clustered light container belongs to the edited scene: don't mix its lights when playing. + const lights = isEditedScene ? scene.lights.concat(this.props.editor.layout.preview.clusteredLightContainer.lights) : scene.lights.slice(); + nodes.push(...lights.map((light) => this._parseSceneNode(light, true))); } if (this.state.showOnlyDecals) { @@ -328,19 +550,20 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(scene, "Scene", false), }); - this.setState({ - nodes: nodes.filter((n) => n !== null) as TreeNodeInfo[], - }); - - return waitNextAnimationFrame(); + return nodes.filter((n) => n !== null) as TreeNodeInfo[]; } /** * Sets the given node selected in the graph. All other selected nodes * become unselected to have only the given node selected. All parents are expanded. + * Nodes belonging to the play scene are ignored: the selection only tracks the edited scene. * @param node defines the reference tot the node to select in the graph. */ public setSelectedNode(node: Node | IParticleSystem | Sprite): void { + if (isPlaySceneObject(this.props.editor, node)) { + return; + } + const originalSource = (isAnyParticleSystem(node) ? (node.emitter as AbstractMesh) : node) as Node | null; let source = originalSource; @@ -434,6 +657,7 @@ export class EditorGraph extends Component const nodesToCopy = this._objectsToCopy.map((n) => n.nodeData); registerUndoRedo({ + object: nodesToCopy[0], executeRedo: true, action: () => { this.refresh(); @@ -556,6 +780,7 @@ export class EditorGraph extends Component const savedTargetDirection = targetDirection?.clone(); registerUndoRedo({ + object: node, executeRedo: true, undo: () => { if (savedTargetPosition && targetPosition) { @@ -618,34 +843,53 @@ export class EditorGraph extends Component }); } - private _handleNodeClicked(node: TreeNodeInfo, ev: React.MouseEvent): void { + private _getTreeNodes(tree: GraphTreeKind): TreeNodeInfo[] { + return tree === "scene" ? this.state.nodes : this.state.runtimeNodes; + } + + private _commitTreeNodes(tree: GraphTreeKind): void { + if (tree === "scene") { + this.setState({ nodes: this.state.nodes }); + } else { + this.setState({ runtimeNodes: this.state.runtimeNodes }); + } + } + + private _handleNodeClicked(node: TreeNodeInfo, ev: React.MouseEvent, tree: GraphTreeKind): void { this.props.editor.layout.inspector.setEditedObject(node.nodeData); this.props.editor.layout.animations.setEditedObject(node.nodeData); - if (isNode(node.nodeData) || isSprite(node.nodeData)) { - this.props.editor.layout.preview.gizmo.setAttachedObject(node.nodeData); - } + // Gizmos and camera preview belong to the edited scene: don't attach them to play scene objects. + if (!isPlaySceneObject(this.props.editor, node.nodeData)) { + if (isNode(node.nodeData) || isSprite(node.nodeData)) { + this.props.editor.layout.preview.gizmo.setAttachedObject(node.nodeData); + } - if (isCamera(node.nodeData)) { - this.props.editor.layout.preview.setCameraPreviewActive(node.nodeData); + if (isCamera(node.nodeData)) { + this.props.editor.layout.preview.setCameraPreviewActive(node.nodeData); + } } + const nodes = this._getTreeNodes(tree); + if (ev.ctrlKey || ev.metaKey) { - this._forEachNode(this.state.nodes, (n) => n.id === node.id && (n.isSelected = !n.isSelected)); + this._forEachNode(nodes, (n) => n.id === node.id && (n.isSelected = !n.isSelected)); } else if (ev.shiftKey) { - this._handleShiftSelect(node); + this._handleShiftSelect(node, tree); } else { - this._forEachNode(this.state.nodes, (n) => (n.isSelected = n.id === node.id)); + this._forEachNode(nodes, (n) => (n.isSelected = n.id === node.id)); } - this.setState({ nodes: this.state.nodes }); + this._commitTreeNodes(tree); } - private _handleShiftSelect(node: TreeNodeInfo): void { + private _handleShiftSelect(node: TreeNodeInfo, tree: GraphTreeKind): void { let lastSelected!: TreeNodeInfo; let firstSelected!: TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { + const nodes = this._getTreeNodes(tree); + + this._forEachNode(nodes, (n) => { if (n.id === node.id) { if (!firstSelected) { firstSelected = n; @@ -666,7 +910,7 @@ export class EditorGraph extends Component } let select = false; - this._forEachNode(this.state.nodes, (n) => { + this._forEachNode(nodes, (n) => { if (n.id === firstSelected.id) { select = true; } @@ -681,27 +925,27 @@ export class EditorGraph extends Component }); } - private _handleNodeContextMenu(node: TreeNodeInfo, ev: React.MouseEvent): void { + private _handleNodeContextMenu(node: TreeNodeInfo, ev: React.MouseEvent, tree: GraphTreeKind): void { if (!node.isSelected) { - this._handleNodeClicked(node, ev); + this._handleNodeClicked(node, ev, tree); } } - private _handleNodeExpanded(node: TreeNodeInfo): void { - this._forEachNode(this.state.nodes, (n) => n.id === node.id && (n.isExpanded = true)); - this.setState({ nodes: this.state.nodes }); + private _handleNodeExpanded(node: TreeNodeInfo, tree: GraphTreeKind): void { + this._forEachNode(this._getTreeNodes(tree), (n) => n.id === node.id && (n.isExpanded = true)); + this._commitTreeNodes(tree); } - private _handleNodeCollapsed(node: TreeNodeInfo): void { - this._forEachNode(this.state.nodes, (n) => n.id === node.id && (n.isExpanded = false)); - this.setState({ nodes: this.state.nodes }); + private _handleNodeCollapsed(node: TreeNodeInfo, tree: GraphTreeKind): void { + this._forEachNode(this._getTreeNodes(tree), (n) => n.id === node.id && (n.isExpanded = false)); + this._commitTreeNodes(tree); } - private _handleNodeDoubleClicked(node: TreeNodeInfo, ev: React.MouseEvent): void { - this._forEachNode(this.state.nodes, (n) => n.id === node.id && (n.isExpanded = !n.isExpanded)); + private _handleNodeDoubleClicked(node: TreeNodeInfo, ev: React.MouseEvent, tree: GraphTreeKind): void { + this._forEachNode(this._getTreeNodes(tree), (n) => n.id === node.id && (n.isExpanded = !n.isExpanded)); - this._handleNodeClicked(node, ev); - this.setState({ nodes: this.state.nodes }); + this._handleNodeClicked(node, ev, tree); + this._commitTreeNodes(tree); } public _forEachNode(nodes: TreeNodeInfo[] | undefined, callback: (node: TreeNodeInfo, index: number) => void): void { @@ -740,12 +984,11 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(scene, "Skeletons", false), } as TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { - if (n.id === rootSkeletonNode.id) { - rootSkeletonNode.isSelected = n.isSelected; - rootSkeletonNode.isExpanded = n.isExpanded; - } - }); + const previousSkeletonNode = this._previousTreeNodes.get(rootSkeletonNode.id); + if (previousSkeletonNode) { + rootSkeletonNode.isSelected = previousSkeletonNode.isSelected; + rootSkeletonNode.isExpanded = previousSkeletonNode.isExpanded; + } return rootSkeletonNode; } @@ -758,11 +1001,10 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(skeleton, skeleton.name, false), } as TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { - if (n.id === info.id) { - info.isSelected = n.isSelected; - } - }); + const previousNode = this._previousTreeNodes.get(info.id); + if (previousNode) { + info.isSelected = previousNode.isSelected; + } return info; } @@ -775,12 +1017,11 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(particleSystem, particleSystem.name, false), } as TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { - if (n.id === info.id) { - info.isSelected = n.isSelected; - info.isExpanded = n.isExpanded; - } - }); + const previousNode = this._previousTreeNodes.get(info.id); + if (previousNode) { + info.isSelected = previousNode.isSelected; + info.isExpanded = previousNode.isExpanded; + } return info; } @@ -793,12 +1034,11 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(sprite, sprite.name, false), } as TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { - if (n.id === info.id) { - info.isSelected = n.isSelected; - info.isExpanded = n.isExpanded; - } - }); + const previousNode = this._previousTreeNodes.get(info.id); + if (previousNode) { + info.isSelected = previousNode.isSelected; + info.isExpanded = previousNode.isExpanded; + } return info; } @@ -823,12 +1063,11 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(texture, texture.name, false), } as TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { - if (n.id === info.id) { - info.isSelected = n.isSelected; - info.isExpanded = n.isExpanded; - } - }); + const previousNode = this._previousTreeNodes.get(info.id); + if (previousNode) { + info.isSelected = previousNode.isSelected; + info.isExpanded = previousNode.isExpanded; + } childNodes.push(info); }); @@ -845,12 +1084,11 @@ export class EditorGraph extends Component label: this._getNodeLabelComponent(scene, "Gui", false), } as TreeNodeInfo; - this._forEachNode(this.state.nodes, (n) => { - if (n.id === rootGuiNode.id) { - rootGuiNode.isSelected = n.isSelected; - rootGuiNode.isExpanded = n.isExpanded; - } - }); + const previousGuiNode = this._previousTreeNodes.get(rootGuiNode.id); + if (previousGuiNode) { + rootGuiNode.isSelected = previousGuiNode.isSelected; + rootGuiNode.isExpanded = previousGuiNode.isExpanded; + } return rootGuiNode; } @@ -906,7 +1144,7 @@ export class EditorGraph extends Component // Handle particle systems if (isAbstractMesh(node) && !noChildren) { - const particleSystems = this.props.editor.layout.preview.scene.particleSystems.filter((ps) => ps.emitter === node); + const particleSystems = node.getScene().particleSystems.filter((ps) => ps.emitter === node); particleSystems.forEach((particleSystem) => { if ( (isParticleSystem(particleSystem) || isGPUParticleSystem(particleSystem)) && @@ -946,12 +1184,11 @@ export class EditorGraph extends Component return null; } - this._forEachNode(this.state.nodes, (n) => { - if (n.id === info.id) { - info.isSelected = n.isSelected; - info.isExpanded = n.isExpanded; - } - }); + const previousNode = this._previousTreeNodes.get(info.id); + if (previousNode) { + info.isSelected = previousNode.isSelected; + info.isExpanded = previousNode.isExpanded; + } return info; } @@ -960,6 +1197,10 @@ export class EditorGraph extends Component return (
{ + if (isPlaySceneObject(this.props.editor, node)) { + return; + } + const enabled = !node.isEnabled(); let selectedNodeData = this.getSelectedNodes().map((n) => n.nodeData); @@ -987,7 +1228,11 @@ export class EditorGraph extends Component } private _getAdvancedTextureIconComponent(texture: AdvancedDynamicTexture): ReactNode { - const layer = this.props.editor.layout.preview.scene.layers.find((l) => l.texture === texture); + if (isPlaySceneObject(this.props.editor, texture)) { + return this._getIcon(texture); + } + + const layer = getObjectScene(texture)?.layers.find((l) => l.texture === texture); if (!layer) { return null; } @@ -1081,13 +1326,15 @@ export class EditorGraph extends Component } private _handleObjectModified(node: Node | BaseTexture | IParticleSystem | Sprite | Skeleton): void { - this._forEachNode(this.state.nodes, (n) => { - if (n.nodeData === node) { - n.label = this._getNodeLabelComponent(node, node.name); - } + [this.state.nodes, this.state.runtimeNodes].forEach((nodes) => { + this._forEachNode(nodes, (n) => { + if (n.nodeData === node) { + n.label = this._getNodeLabelComponent(node, node.name); + } + }); }); - this.setState({ nodes: this.state.nodes }); + this.setState({ nodes: this.state.nodes, runtimeNodes: this.state.runtimeNodes }); } private _handleDropEmpty(ev: DragEvent): void { diff --git a/editor/src/editor/layout/graph/context-menu.tsx b/editor/src/editor/layout/graph/context-menu.tsx index 693a5678b..2409c046f 100644 --- a/editor/src/editor/layout/graph/context-menu.tsx +++ b/editor/src/editor/layout/graph/context-menu.tsx @@ -1,484 +1,491 @@ -import { platform } from "os"; - -import { Component, PropsWithChildren, ReactNode } from "react"; - -import { IoMdCube } from "react-icons/io"; -import { AiOutlinePlus, AiOutlineClose } from "react-icons/ai"; - -import { Mesh, Node, InstancedMesh, Sprite, IParticleSystem } from "babylonjs"; - -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubTrigger, - ContextMenuSubContent, - ContextMenuShortcut, - ContextMenuCheckboxItem, -} from "../../../ui/shadcn/ui/context-menu"; - -import { showConfirm } from "../../../ui/dialog"; -import { Separator } from "../../../ui/shadcn/ui/separator"; - -import { getNodeCommands } from "../../dialogs/command-palette/node"; -import { getMeshCommands } from "../../dialogs/command-palette/mesh"; -import { getLightCommands } from "../../dialogs/command-palette/light"; -import { getCameraCommands } from "../../dialogs/command-palette/camera"; -import { getSpriteCommands } from "../../dialogs/command-palette/sprite"; - -import { registerUndoRedo } from "../../../tools/undoredo"; -import { waitNextAnimationFrame } from "../../../tools/tools"; -import { isClusteredLight } from "../../../tools/light/cluster"; -import { createMeshInstance } from "../../../tools/mesh/instance"; -import { onNodesAddedObservable } from "../../../tools/observables"; -import { isAnyParticleSystem } from "../../../tools/guards/particles"; -import { isScene, isSceneLinkNode } from "../../../tools/guards/scene"; -import { cloneNode, ICloneNodeOptions } from "../../../tools/node/clone"; -import { isSprite, isSpriteMapNode } from "../../../tools/guards/sprites"; -import { isNodeLocked, isNodeSerializable, isNodeVisibleInGraph, setNodeLocked, setNodeSerializable } from "../../../tools/node/metadata"; -import { isAbstractMesh, isCamera, isClusteredLightContainer, isLight, isMesh, isNode, isTransformNode } from "../../../tools/guards/nodes"; - -import { addPointLight, addSpotLight } from "../../../project/add/light"; -import { addGPUParticleSystem, addParticleSystem } from "../../../project/add/particles"; - -import { addSoundNode } from "../../../project/add/sound"; - -import { EditorInspectorSwitchField } from "../inspector/fields/switch"; - -import { configureImportedMaterial, configureImportedNodeIds } from "../preview/import/import"; - -import { Editor } from "../../main"; - -import { removeNodes } from "./remove"; -import { exportScene, exportNode } from "./export"; -import { showUpdateResourcesFromAsset } from "./update-resources"; - -export interface IEditorGraphContextMenuProps extends PropsWithChildren { - editor: Editor; - object: any | null; - - onOpenChange?(open: boolean): void; -} - -export interface IEditorGraphContextMenuState { - selectedMeshes: Mesh[]; -} - -export class EditorGraphContextMenu extends Component { - public constructor(props: IEditorGraphContextMenuProps) { - super(props); - - this.state = { - selectedMeshes: [], - }; - } - - public render(): ReactNode { - const parent = this.props.object && isScene(this.props.object) ? undefined : this.props.object; - - return ( - this._handleContextMenuOpenChange(o)}> - {this.props.children} - - {this.props.object && ( - - <> - {isNode(this.props.object) && ( - <> - {this._getMeshItems()} - - - )} - - {!isScene(this.props.object) && !isClusteredLightContainer(this.props.object) && ( - <> - this._cloneNode(this.props.object)}>Clone - - {!isSprite(this.props.object) && ( - <> - - - this.props.editor.layout.graph.copySelectedNodes()}> - Copy {platform() === "darwin" ? "⌘+C" : "CTRL+C"} - - - {isNode(this.props.object) && ( - this.props.editor.layout.graph.pasteSelectedNodes(this.props.object, ev.shiftKey)} - > - Paste {platform() === "darwin" ? "⌘+V" : "CTRL+V"} - - )} - - {isNode(this.props.object) && ( - <> - - this.props.editor.layout.graph.copySelectedNodeTransform(this.props.object)}> - Copy Transform - - this.props.editor.layout.graph.pasteSelectedNodeTransform(this.props.object)} - > - Paste Transform - - - - )} - - )} - - {isNode(this.props.object) && !isScene(this.props.object) && this.props.editor.state.enableExperimentalFeatures && ( - <> - exportNode(this.props.editor, this.props.object)}>Export Node (.babylon) - - showUpdateResourcesFromAsset(this.props.editor, this.props.object)}> - Update Resources... - - - - )} - - )} - - {isScene(this.props.object) && this.props.editor.state.enableExperimentalFeatures && ( - <> - exportScene(this.props.editor)}>Export Scene (.babylon) - - - )} - - {(isNode(this.props.object) || isScene(this.props.object)) && - !isSceneLinkNode(this.props.object) && - !(isLight(this.props.object) && isClusteredLight(this.props.object, this.props.editor)) && ( - - - Add - - - {getLightCommands(this.props.editor, parent).map((command) => ( - - {command.text} - - ))} - - {getNodeCommands(this.props.editor, parent).map((command) => { - return ( - - {command.text} - - ); - })} - - - - Meshes - - - {getMeshCommands(this.props.editor, parent).map((command) => ( - - {command.text} - - ))} - - - - {getCameraCommands(this.props.editor, parent).map((command) => ( - - {command.text} - - ))} - {isAbstractMesh(this.props.object) && ( - <> - - addParticleSystem(this.props.editor, this.props.object)}>Particle System - addGPUParticleSystem(this.props.editor, this.props.object)}> - GPU Particle System - - - )} - {(isAbstractMesh(this.props.object) || isTransformNode(this.props.object) || isScene(this.props.object)) && ( - <> - - addSoundNode(this.props.editor, isScene(this.props.object) ? null : this.props.object)}> - Sound Node - - - )} - - {getSpriteCommands(this.props.editor, parent).map((command) => ( - - {command.text} - - ))} - - - )} - - {!isScene(this.props.object) && - !isSprite(this.props.object) && - !isAnyParticleSystem(this.props.object) && - !isClusteredLightContainer(this.props.object) && ( - <> - - this._handleSetNodeLocked()}> - Locked - - this._handleSetNodeSerializable()}> - Do not serialize - - - )} - - {!isScene(this.props.object) && !isClusteredLightContainer(this.props.object) && ( - <> - - {this._getRemoveItems()} - - )} - - {isClusteredLightContainer(this.props.object) && ( - - - Add - - - addPointLight(this.props.editor, this.props.object)}>Point Light - addSpotLight(this.props.editor, this.props.object)}>Spot Light - - - )} - - - )} - - ); - } - - private _handleContextMenuOpenChange(open: boolean): void { - if (open) { - this.setState({ - selectedMeshes: this.props.editor.layout.graph - .getSelectedNodes() - .filter((node) => isMesh(node.nodeData) && node.nodeData.geometry) - .map((node) => node.nodeData as Mesh), - }); - } - - this.props.onOpenChange?.(open); - } - - private _getRemoveItems(): ReactNode { - return ( - removeNodes(this.props.editor)}> - Remove - - ); - } - - private _getMeshItems(): ReactNode { - return ( - <> - this.props.editor.layout.preview.focusObject(this.props.object)}> - Focus - {platform() === "darwin" ? "⌘+F" : "CTRL+F"} - - - {isMesh(this.props.object) && ( - <> - - this._createMeshInstance(this.props.object)}>Create Instance - - {isMesh(this.props.object) && this.state.selectedMeshes.length > 1 && ( - this._handleMergeMeshes(this.state.selectedMeshes, this.props.object.parent)}>Merge meshes... - )} - - )} - - ); - } - - private _handleMergeMeshes(meshes: Mesh[], parent: Node | null): void { - const savedMeshesParents = meshes.map((mesh) => ({ - mesh, - parent: mesh.parent, - position: mesh.position.clone(), - rotation: mesh.rotation.clone(), - scaling: mesh.scaling.clone(), - rotationQuaternion: mesh.rotationQuaternion?.clone() ?? null, - })); - - meshes.forEach((mesh) => { - mesh.parent = null; - mesh.computeWorldMatrix(true); - }); - - try { - const mergedMesh = Mesh.MergeMeshes(meshes, false, true, undefined, true, true); - if (mergedMesh) { - configureImportedNodeIds(mergedMesh); - - if (mergedMesh.material) { - configureImportedMaterial(mergedMesh.material); - } - - mergedMesh.parent = parent; - } - } catch (e) { - console.error(e); - } - - savedMeshesParents.forEach((item) => { - item.mesh.parent = item.parent; - item.mesh.position.copyFrom(item.position); - item.mesh.rotation.copyFrom(item.rotation); - item.mesh.scaling.copyFrom(item.scaling); - item.mesh.rotationQuaternion = item.rotationQuaternion; - }); - - onNodesAddedObservable.notifyObservers(); - } - - private _handleSetNodeLocked(): void { - const locked = !isNodeLocked(this.props.object); - - this.props.editor.layout.graph.getSelectedNodes().forEach((node) => { - if (isNode(node.nodeData)) { - setNodeLocked(node.nodeData, locked); - - if (isCamera(node.nodeData) && this.props.editor.layout.preview.scene.activeCamera === node.nodeData) { - if (locked) { - node.nodeData.detachControl(); - } else { - node.nodeData.attachControl(true); - } - } - } - }); - this.props.editor.layout.graph.refresh(); - } - - private _handleSetNodeSerializable(): void { - const serializable = !isNodeSerializable(this.props.object); - - this.props.editor.layout.graph.getSelectedNodes().forEach((node) => { - if (isNode(node.nodeData)) { - setNodeSerializable(node.nodeData, serializable); - } - }); - this.props.editor.layout.graph.refresh(); - } - - private _createMeshInstance(mesh: Mesh): void { - let instance: InstancedMesh | null = null; - - registerUndoRedo({ - executeRedo: true, - action: () => { - this.props.editor.layout.graph.refresh(); - - waitNextAnimationFrame().then(() => { - if (instance) { - this.props.editor.layout.graph.setSelectedNode(instance); - this.props.editor.layout.animations.setEditedObject(instance); - } - - this.props.editor.layout.inspector.setEditedObject(instance); - this.props.editor.layout.preview.gizmo.setAttachedObject(instance); - }); - }, - undo: () => { - instance?.dispose(false, false); - instance = null; - }, - redo: () => { - instance = createMeshInstance(this.props.editor, mesh); - }, - }); - } - - private async _cloneNode(node: any): Promise { - if (isNode(node) && node.parent && isSpriteMapNode(node.parent) && node.parent.outputPlane === node) { - node = node.parent; - } - - let clone: Node | Sprite | IParticleSystem | null = null; - - const cloneOptions: ICloneNodeOptions = { - shareGeometry: true, - shareSkeleton: true, - cloneMaterial: true, - cloneThinInstances: true, - }; - - let allNodes = isNode(node) ? [node, ...node.getDescendants(false)] : [node]; - allNodes = allNodes.filter((n) => { - if (!isNodeVisibleInGraph(n)) { - return false; - } - - if (isAbstractMesh(n) && n._masterMesh) { - return false; - } - - return true; - }); - - if (allNodes.find((node) => isMesh(node))) { - const result = await showConfirm( - "Clone options", -
- - -
Options for meshes
- -
- - - - -
-
, - { - asChild: true, - confirmText: "Clone", - } - ); - - if (!result) { - return; - } - } - - registerUndoRedo({ - executeRedo: true, - action: () => { - this.props.editor.layout.graph.refresh(); - - waitNextAnimationFrame().then(() => { - if (clone) { - this.props.editor.layout.graph.setSelectedNode(clone); - this.props.editor.layout.animations.setEditedObject(clone); - } - - this.props.editor.layout.inspector.setEditedObject(clone); - - if (isNode(clone) || isSprite(clone)) { - this.props.editor.layout.preview.gizmo.setAttachedObject(clone); - } - }); - }, - undo: () => { - clone?.dispose(false, false); - clone = null; - }, - redo: () => { - clone = cloneNode(this.props.editor, node, cloneOptions); - }, - }); - } -} +import { platform } from "os"; + +import { Component, PropsWithChildren, ReactNode } from "react"; + +import { IoMdCube } from "react-icons/io"; +import { AiOutlinePlus, AiOutlineClose } from "react-icons/ai"; + +import { Mesh, Node, InstancedMesh, Sprite, IParticleSystem } from "babylonjs"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, + ContextMenuShortcut, + ContextMenuCheckboxItem, +} from "../../../ui/shadcn/ui/context-menu"; + +import { showConfirm } from "../../../ui/dialog"; +import { Separator } from "../../../ui/shadcn/ui/separator"; + +import { getNodeCommands } from "../../dialogs/command-palette/node"; +import { getMeshCommands } from "../../dialogs/command-palette/mesh"; +import { getLightCommands } from "../../dialogs/command-palette/light"; +import { getCameraCommands } from "../../dialogs/command-palette/camera"; +import { getSpriteCommands } from "../../dialogs/command-palette/sprite"; + +import { registerUndoRedo } from "../../../tools/undoredo"; +import { waitNextAnimationFrame } from "../../../tools/tools"; +import { isPlaySceneObject } from "../../../tools/scene/play/runtime"; +import { isClusteredLight } from "../../../tools/light/cluster"; +import { createMeshInstance } from "../../../tools/mesh/instance"; +import { onNodesAddedObservable } from "../../../tools/observables"; +import { isAnyParticleSystem } from "../../../tools/guards/particles"; +import { isScene, isSceneLinkNode } from "../../../tools/guards/scene"; +import { cloneNode, ICloneNodeOptions } from "../../../tools/node/clone"; +import { isSprite, isSpriteMapNode } from "../../../tools/guards/sprites"; +import { isNodeLocked, isNodeSerializable, isNodeVisibleInGraph, setNodeLocked, setNodeSerializable } from "../../../tools/node/metadata"; +import { isAbstractMesh, isCamera, isClusteredLightContainer, isLight, isMesh, isNode, isTransformNode } from "../../../tools/guards/nodes"; + +import { addPointLight, addSpotLight } from "../../../project/add/light"; +import { addGPUParticleSystem, addParticleSystem } from "../../../project/add/particles"; + +import { addSoundNode } from "../../../project/add/sound"; + +import { EditorInspectorSwitchField } from "../inspector/fields/switch"; + +import { configureImportedMaterial, configureImportedNodeIds } from "../preview/import/import"; + +import { Editor } from "../../main"; + +import { removeNodes } from "./remove"; +import { exportScene, exportNode } from "./export"; +import { showUpdateResourcesFromAsset } from "./update-resources"; + +export interface IEditorGraphContextMenuProps extends PropsWithChildren { + editor: Editor; + object: any | null; + + onOpenChange?(open: boolean): void; +} + +export interface IEditorGraphContextMenuState { + selectedMeshes: Mesh[]; +} + +export class EditorGraphContextMenu extends Component { + public constructor(props: IEditorGraphContextMenuProps) { + super(props); + + this.state = { + selectedMeshes: [], + }; + } + + public render(): ReactNode { + if (isPlaySceneObject(this.props.editor, this.props.object)) { + return this.props.children; + } + + const parent = this.props.object && isScene(this.props.object) ? undefined : this.props.object; + + return ( + this._handleContextMenuOpenChange(o)}> + {this.props.children} + + {this.props.object && ( + + <> + {isNode(this.props.object) && ( + <> + {this._getMeshItems()} + + + )} + + {!isScene(this.props.object) && !isClusteredLightContainer(this.props.object) && ( + <> + this._cloneNode(this.props.object)}>Clone + + {!isSprite(this.props.object) && ( + <> + + + this.props.editor.layout.graph.copySelectedNodes()}> + Copy {platform() === "darwin" ? "⌘+C" : "CTRL+C"} + + + {isNode(this.props.object) && ( + this.props.editor.layout.graph.pasteSelectedNodes(this.props.object, ev.shiftKey)} + > + Paste {platform() === "darwin" ? "⌘+V" : "CTRL+V"} + + )} + + {isNode(this.props.object) && ( + <> + + this.props.editor.layout.graph.copySelectedNodeTransform(this.props.object)}> + Copy Transform + + this.props.editor.layout.graph.pasteSelectedNodeTransform(this.props.object)} + > + Paste Transform + + + + )} + + )} + + {isNode(this.props.object) && !isScene(this.props.object) && this.props.editor.state.enableExperimentalFeatures && ( + <> + exportNode(this.props.editor, this.props.object)}>Export Node (.babylon) + + showUpdateResourcesFromAsset(this.props.editor, this.props.object)}> + Update Resources... + + + + )} + + )} + + {isScene(this.props.object) && this.props.editor.state.enableExperimentalFeatures && ( + <> + exportScene(this.props.editor)}>Export Scene (.babylon) + + + )} + + {(isNode(this.props.object) || isScene(this.props.object)) && + !isSceneLinkNode(this.props.object) && + !(isLight(this.props.object) && isClusteredLight(this.props.object, this.props.editor)) && ( + + + Add + + + {getLightCommands(this.props.editor, parent).map((command) => ( + + {command.text} + + ))} + + {getNodeCommands(this.props.editor, parent).map((command) => { + return ( + + {command.text} + + ); + })} + + + + Meshes + + + {getMeshCommands(this.props.editor, parent).map((command) => ( + + {command.text} + + ))} + + + + {getCameraCommands(this.props.editor, parent).map((command) => ( + + {command.text} + + ))} + {isAbstractMesh(this.props.object) && ( + <> + + addParticleSystem(this.props.editor, this.props.object)}>Particle System + addGPUParticleSystem(this.props.editor, this.props.object)}> + GPU Particle System + + + )} + {(isAbstractMesh(this.props.object) || isTransformNode(this.props.object) || isScene(this.props.object)) && ( + <> + + addSoundNode(this.props.editor, isScene(this.props.object) ? null : this.props.object)}> + Sound Node + + + )} + + {getSpriteCommands(this.props.editor, parent).map((command) => ( + + {command.text} + + ))} + + + )} + + {!isScene(this.props.object) && + !isSprite(this.props.object) && + !isAnyParticleSystem(this.props.object) && + !isClusteredLightContainer(this.props.object) && ( + <> + + this._handleSetNodeLocked()}> + Locked + + this._handleSetNodeSerializable()}> + Do not serialize + + + )} + + {!isScene(this.props.object) && !isClusteredLightContainer(this.props.object) && ( + <> + + {this._getRemoveItems()} + + )} + + {isClusteredLightContainer(this.props.object) && ( + + + Add + + + addPointLight(this.props.editor, this.props.object)}>Point Light + addSpotLight(this.props.editor, this.props.object)}>Spot Light + + + )} + + + )} + + ); + } + + private _handleContextMenuOpenChange(open: boolean): void { + if (open) { + this.setState({ + selectedMeshes: this.props.editor.layout.graph + .getSelectedNodes() + .filter((node) => isMesh(node.nodeData) && node.nodeData.geometry) + .map((node) => node.nodeData as Mesh), + }); + } + + this.props.onOpenChange?.(open); + } + + private _getRemoveItems(): ReactNode { + return ( + removeNodes(this.props.editor)}> + Remove + + ); + } + + private _getMeshItems(): ReactNode { + return ( + <> + this.props.editor.layout.preview.focusObject(this.props.object)}> + Focus + {platform() === "darwin" ? "⌘+F" : "CTRL+F"} + + + {isMesh(this.props.object) && ( + <> + + this._createMeshInstance(this.props.object)}>Create Instance + + {isMesh(this.props.object) && this.state.selectedMeshes.length > 1 && ( + this._handleMergeMeshes(this.state.selectedMeshes, this.props.object.parent)}>Merge meshes... + )} + + )} + + ); + } + + private _handleMergeMeshes(meshes: Mesh[], parent: Node | null): void { + const savedMeshesParents = meshes.map((mesh) => ({ + mesh, + parent: mesh.parent, + position: mesh.position.clone(), + rotation: mesh.rotation.clone(), + scaling: mesh.scaling.clone(), + rotationQuaternion: mesh.rotationQuaternion?.clone() ?? null, + })); + + meshes.forEach((mesh) => { + mesh.parent = null; + mesh.computeWorldMatrix(true); + }); + + try { + const mergedMesh = Mesh.MergeMeshes(meshes, false, true, undefined, true, true); + if (mergedMesh) { + configureImportedNodeIds(mergedMesh); + + if (mergedMesh.material) { + configureImportedMaterial(mergedMesh.material); + } + + mergedMesh.parent = parent; + } + } catch (e) { + console.error(e); + } + + savedMeshesParents.forEach((item) => { + item.mesh.parent = item.parent; + item.mesh.position.copyFrom(item.position); + item.mesh.rotation.copyFrom(item.rotation); + item.mesh.scaling.copyFrom(item.scaling); + item.mesh.rotationQuaternion = item.rotationQuaternion; + }); + + onNodesAddedObservable.notifyObservers(); + } + + private _handleSetNodeLocked(): void { + const locked = !isNodeLocked(this.props.object); + + this.props.editor.layout.graph.getSelectedNodes().forEach((node) => { + if (isNode(node.nodeData)) { + setNodeLocked(node.nodeData, locked); + + if (isCamera(node.nodeData) && this.props.editor.layout.preview.scene.activeCamera === node.nodeData) { + if (locked) { + node.nodeData.detachControl(); + } else { + node.nodeData.attachControl(true); + } + } + } + }); + this.props.editor.layout.graph.refresh(); + } + + private _handleSetNodeSerializable(): void { + const serializable = !isNodeSerializable(this.props.object); + + this.props.editor.layout.graph.getSelectedNodes().forEach((node) => { + if (isNode(node.nodeData)) { + setNodeSerializable(node.nodeData, serializable); + } + }); + this.props.editor.layout.graph.refresh(); + } + + private _createMeshInstance(mesh: Mesh): void { + let instance: InstancedMesh | null = null; + + registerUndoRedo({ + object: mesh, + executeRedo: true, + action: () => { + this.props.editor.layout.graph.refresh(); + + waitNextAnimationFrame().then(() => { + if (instance) { + this.props.editor.layout.graph.setSelectedNode(instance); + this.props.editor.layout.animations.setEditedObject(instance); + } + + this.props.editor.layout.inspector.setEditedObject(instance); + this.props.editor.layout.preview.gizmo.setAttachedObject(instance); + }); + }, + undo: () => { + instance?.dispose(false, false); + instance = null; + }, + redo: () => { + instance = createMeshInstance(this.props.editor, mesh); + }, + }); + } + + private async _cloneNode(node: any): Promise { + if (isNode(node) && node.parent && isSpriteMapNode(node.parent) && node.parent.outputPlane === node) { + node = node.parent; + } + + let clone: Node | Sprite | IParticleSystem | null = null; + + const cloneOptions: ICloneNodeOptions = { + shareGeometry: true, + shareSkeleton: true, + cloneMaterial: true, + cloneThinInstances: true, + }; + + let allNodes = isNode(node) ? [node, ...node.getDescendants(false)] : [node]; + allNodes = allNodes.filter((n) => { + if (!isNodeVisibleInGraph(n)) { + return false; + } + + if (isAbstractMesh(n) && n._masterMesh) { + return false; + } + + return true; + }); + + if (allNodes.find((node) => isMesh(node))) { + const result = await showConfirm( + "Clone options", +
+ + +
Options for meshes
+ +
+ + + + +
+
, + { + asChild: true, + confirmText: "Clone", + } + ); + + if (!result) { + return; + } + } + + registerUndoRedo({ + object: node, + executeRedo: true, + action: () => { + this.props.editor.layout.graph.refresh(); + + waitNextAnimationFrame().then(() => { + if (clone) { + this.props.editor.layout.graph.setSelectedNode(clone); + this.props.editor.layout.animations.setEditedObject(clone); + } + + this.props.editor.layout.inspector.setEditedObject(clone); + + if (isNode(clone) || isSprite(clone)) { + this.props.editor.layout.preview.gizmo.setAttachedObject(clone); + } + }); + }, + undo: () => { + clone?.dispose(false, false); + clone = null; + }, + redo: () => { + clone = cloneNode(this.props.editor, node, cloneOptions); + }, + }); + } +} diff --git a/editor/src/editor/layout/graph/label.tsx b/editor/src/editor/layout/graph/label.tsx index d3ae51255..2e031ba9e 100644 --- a/editor/src/editor/layout/graph/label.tsx +++ b/editor/src/editor/layout/graph/label.tsx @@ -10,6 +10,7 @@ import { Input } from "../../../ui/shadcn/ui/input"; import { isDarwin } from "../../../tools/os"; import { isScene } from "../../../tools/guards/scene"; import { registerUndoRedo } from "../../../tools/undoredo"; +import { isPlaySceneObject } from "../../../tools/scene/play/runtime"; import { isClusteredLight } from "../../../tools/light/cluster"; import { isNodeSerializable, isNodeLocked } from "../../../tools/node/metadata"; import { isClusteredLightContainer, isInstancedMesh, isLight, isMesh, isNode, isTransformNode } from "../../../tools/guards/nodes"; @@ -72,6 +73,10 @@ export function EditorGraphLabel(props: IEditorGraphLabelProps) { }); function handleDragStart(ev: DragEvent) { + if (isPlaySceneObject(props.editor, props.object)) { + return ev.preventDefault(); + } + const selectedNodes = props.editor.layout.graph.getSelectedNodes(); const alreadySelected = selectedNodes.find((n) => n.nodeData === props.object); @@ -108,6 +113,10 @@ export function EditorGraphLabel(props: IEditorGraphLabelProps) { setOver(false); + if (isPlaySceneObject(props.editor, props.object)) { + return; + } + if (!isNode(props.object) && !isScene(props.object) && !isClusteredLightContainer(props.object)) { return; } @@ -158,6 +167,10 @@ export function EditorGraphLabel(props: IEditorGraphLabelProps) { } function handleRenameObject() { + if (isPlaySceneObject(props.editor, props.object)) { + return; + } + if (props.object.name) { setRenaming(!renaming); } @@ -165,6 +178,7 @@ export function EditorGraphLabel(props: IEditorGraphLabelProps) { function handleInputNameBlurred() { registerUndoRedo({ + object: props.object, executeRedo: true, undo: () => (props.object.name = props.name), redo: () => (props.object.name = name), diff --git a/editor/src/editor/layout/graph/move.ts b/editor/src/editor/layout/graph/move.ts index 1f7f2ab7c..7845b5451 100644 --- a/editor/src/editor/layout/graph/move.ts +++ b/editor/src/editor/layout/graph/move.ts @@ -4,6 +4,7 @@ import { unique } from "../../../tools/tools"; import { isScene } from "../../../tools/guards/scene"; import { registerUndoRedo } from "../../../tools/undoredo"; import { isClusteredLight } from "../../../tools/light/cluster"; +import { isPlaySceneObject } from "../../../tools/scene/play/runtime"; import { isAnyParticleSystem } from "../../../tools/guards/particles"; import { isAbstractMesh, isClusteredLightContainer, isLight, isNode } from "../../../tools/guards/nodes"; import { applyNodeParentingConfiguration, applyTransformNodeParentingConfiguration, IOldNodeHierarchyConfiguration } from "../../../tools/node/parenting"; @@ -15,6 +16,10 @@ export function setNewParentForGraphSelectedNodes(editor: Editor, newParent: any const nodesToMove = unique(editor.layout.graph.getSelectedNodes(), "id"); const clusteredLightContainer = editor.layout.preview.clusteredLightContainer; + if (isPlaySceneObject(editor, newParent) || nodesToMove.some((n) => isPlaySceneObject(editor, n.nodeData))) { + return; + } + nodesToMove.forEach((n) => { if (n.nodeData && n.nodeData !== newParent) { if (isLight(n.nodeData) && isClusteredLight(n.nodeData, editor)) { @@ -55,6 +60,7 @@ export function setNewParentForGraphSelectedNodes(editor: Editor, newParent: any } registerUndoRedo({ + object: nodesToMove[0]?.nodeData, executeRedo: true, undo: () => { nodesToMove.forEach((n) => { diff --git a/editor/src/editor/layout/graph/remove.ts b/editor/src/editor/layout/graph/remove.ts index e479becf3..4a5a72e87 100644 --- a/editor/src/editor/layout/graph/remove.ts +++ b/editor/src/editor/layout/graph/remove.ts @@ -8,6 +8,7 @@ import { isClusteredLight } from "../../../tools/light/cluster"; import { isAnyParticleSystem } from "../../../tools/guards/particles"; import { isAdvancedDynamicTexture } from "../../../tools/guards/texture"; import { getLinkedAnimationGroupsFor } from "../../../tools/animation/group"; +import { isPlaySceneObject } from "../../../tools/scene/play/runtime"; import { isNode, isMesh, isAbstractMesh, isInstancedMesh, isCollisionInstancedMesh, isLight, isCamera, isAnyTransformNode, isSkeleton } from "../../../tools/guards/nodes"; import { Editor } from "../../main"; @@ -34,6 +35,10 @@ export function removeNodes(editor: Editor) { .filter((n) => n.nodeData) .map((n) => n.nodeData); + if (allData.some((n) => isPlaySceneObject(editor, n))) { + return; + } + const nodes = allData .filter((n) => isNode(n)) .map((node) => { @@ -83,6 +88,7 @@ export function removeNodes(editor: Editor) { const animationGroups = getLinkedAnimationGroupsFor([...particleSystems, ...advancedGuiTextures, ...nodes.map((d) => d.node)], scene); registerUndoRedo({ + object: allData[0], executeRedo: true, action: () => { editor.layout.graph.refresh(); diff --git a/editor/src/editor/layout/inspector.tsx b/editor/src/editor/layout/inspector.tsx index 1d0386575..b2fdea71a 100644 --- a/editor/src/editor/layout/inspector.tsx +++ b/editor/src/editor/layout/inspector.tsx @@ -14,6 +14,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "../../ui/shadcn/u import { isNode } from "../../tools/guards/nodes"; import { isNodeLocked } from "../../tools/node/metadata"; +import { isPlaySceneObject } from "../../tools/scene/play/runtime"; import { setInspectorSearch } from "./inspector/fields/field"; import { IEditorInspectorImplementationProps } from "./inspector/inspector"; @@ -114,6 +115,7 @@ export class EditorInspector extends Component @@ -140,6 +142,13 @@ export class EditorInspector extends Component )} + {isRuntimeObject && ( + + + Runtime object — changes are not saved and will be lost on stop. + + )} + scene.removeMesh(decalMesh), redo: () => scene.addMesh(decalMesh), diff --git a/editor/src/editor/layout/inspector/fields/color.tsx b/editor/src/editor/layout/inspector/fields/color.tsx index b9b1d226d..469dfceaa 100644 --- a/editor/src/editor/layout/inspector/fields/color.tsx +++ b/editor/src/editor/layout/inspector/fields/color.tsx @@ -56,6 +56,7 @@ export function EditorInspectorColorField(props: IEditorInspectorColorFieldProps const newColor = color.clone(); registerUndoRedo({ + object: props.object, undo: () => color.set(oldValue.r, oldValue.g, oldValue.b, (oldValue as any).a), redo: () => color.set(newColor.r, newColor.g, newColor.b, (newColor as any).a), }); diff --git a/editor/src/editor/layout/inspector/fields/texture.tsx b/editor/src/editor/layout/inspector/fields/texture.tsx index 9f6ba9e6e..853921098 100644 --- a/editor/src/editor/layout/inspector/fields/texture.tsx +++ b/editor/src/editor/layout/inspector/fields/texture.tsx @@ -174,6 +174,7 @@ export class EditorInspectorTextureField extends Component { this.props.object[this.props.property] = oldTexture; @@ -648,6 +649,7 @@ export class EditorInspectorTextureField extends Component (this.props.object[this.props.property] = oldTexture), redo: () => (this.props.object[this.props.property] = newTexture), @@ -674,6 +676,7 @@ export class EditorInspectorTextureField extends Component (this.props.object[this.props.property] = oldTexture), redo: () => (this.props.object[this.props.property] = newTexture), @@ -713,6 +716,7 @@ export class EditorInspectorTextureField extends Component { this.props.object[this.props.property] = oldTexture; diff --git a/editor/src/editor/layout/inspector/mesh/mesh.tsx b/editor/src/editor/layout/inspector/mesh/mesh.tsx index 5073d7307..6a96e9375 100644 --- a/editor/src/editor/layout/inspector/mesh/mesh.tsx +++ b/editor/src/editor/layout/inspector/mesh/mesh.tsx @@ -30,6 +30,7 @@ import { waitNextAnimationFrame } from "../../../../tools/tools"; import { onNodeModifiedObservable } from "../../../../tools/observables"; import { updateIblShadowsRenderPipeline } from "../../../../tools/light/ibl"; import { isAbstractMesh, isInstancedMesh, isMesh } from "../../../../tools/guards/nodes"; +import { isPlaySceneObject } from "../../../../tools/scene/play/runtime"; import { updateAllLights, updateLightShadowMapRefreshRate, updatePointLightShadowMapRenderListPredicate } from "../../../../tools/light/shadows"; import { applyMaterialAssetToObject } from "../../preview/import/material"; @@ -114,7 +115,12 @@ export class EditorMeshInspector extends Component { const instance = this.props.object as InstancedMesh; - this.props.editor.layout.preview.gizmo.setAttachedObject(instance.sourceMesh); + + // Gizmos belong to the edited scene: don't attach them to play scene meshes. + if (!isPlaySceneObject(this.props.editor, instance.sourceMesh)) { + this.props.editor.layout.preview.gizmo.setAttachedObject(instance.sourceMesh); + } + this.props.editor.layout.graph.setSelectedNode(instance.sourceMesh); this.props.editor.layout.inspector.setEditedObject(instance.sourceMesh); }} @@ -214,7 +220,10 @@ export class EditorMeshInspector extends Component - {this._getPhysicsComponent()} - - {this._getDefaultRenderingPipelineComponent()} - {this._getTAARenderingPipelineComponent()} - {this._getSSAO2RenderingPipelineComponent()} - {this._getMotionBlurPostProcessComponent()} - {this._getSSRPipelineComponent()} - {this._getVLSComponent()} + {/* Physics and rendering pipelines are global to the edited scene: don't expose them for the play scene. */} + {!isPlaySceneObject(this.props.editor, this.props.object) && ( + <> + {this._getPhysicsComponent()} + + {this._getDefaultRenderingPipelineComponent()} + {this._getTAARenderingPipelineComponent()} + {this._getSSAO2RenderingPipelineComponent()} + {this._getMotionBlurPostProcessComponent()} + {this._getSSRPipelineComponent()} + {this._getVLSComponent()} + + )} {/* {this.props.editor.state.enableExperimentalFeatures && this._getIblShadowsRenderingPipelineComponent()} */} diff --git a/editor/src/editor/layout/preview.tsx b/editor/src/editor/layout/preview.tsx index e6550581d..fdd694661 100644 --- a/editor/src/editor/layout/preview.tsx +++ b/editor/src/editor/layout/preview.tsx @@ -1269,6 +1269,7 @@ export class EditorPreview extends Component { nodesToMove.forEach((n) => { diff --git a/editor/src/editor/layout/preview/import/material.ts b/editor/src/editor/layout/preview/import/material.ts index 0775a7eec..e0d2f543f 100644 --- a/editor/src/editor/layout/preview/import/material.ts +++ b/editor/src/editor/layout/preview/import/material.ts @@ -39,6 +39,7 @@ export function applyMaterialToObject(editor: Editor, object: any, material: Mat const oldMaterial = object.material; registerUndoRedo({ + object, executeRedo: true, undo: () => { object.material = oldMaterial; diff --git a/editor/src/editor/layout/preview/play.tsx b/editor/src/editor/layout/preview/play.tsx index 85afbcac6..a19cf76ce 100644 --- a/editor/src/editor/layout/preview/play.tsx +++ b/editor/src/editor/layout/preview/play.tsx @@ -15,8 +15,11 @@ import { Scene, Vector3, HavokPlugin } from "babylonjs"; import { ensureTemporaryDirectoryExists } from "../../../tools/project"; import { compileScript } from "../../../tools/compile"; +import { setUndoRedoVolatilePredicate } from "../../../tools/undoredo"; import { wait, waitNextAnimationFrame } from "../../../tools/tools"; +import { onPlaySceneChangedObservable } from "../../../tools/observables"; import { forceCompileAllSceneMaterials } from "../../../tools/scene/materials"; +import { getObjectScene, isPlaySceneObject } from "../../../tools/scene/play/runtime"; import { applyOverrides, restorePlayOverrides } from "../../../tools/scene/play/override"; import { exportProject } from "../../../project/export/export"; @@ -146,6 +149,10 @@ export class EditorPreviewPlayComponent extends Component { - if (!this.state.playing) { + // Restarting is possible only once the scene is fully loaded: this guards against + // re-entrance (double restart, src watcher events, ipc triggers while preparing). + if (!this.canPlayScene) { return; } - this.scene?.dispose(); + const scene = this.scene; this.scene = null; + // Notify before disposing so the observers (graph, inspector, etc.) release their + // references to the play scene before the dispose events storm. + onPlaySceneChangedObservable.notifyObservers(null); + + scene?.dispose(); + restorePlayOverrides(this.props.editor); this.props.editor.layout.preview.engine.wipeCaches(true); @@ -197,6 +212,10 @@ export class EditorPreviewPlayComponent extends Component { + const objectScene = item.object !== undefined && item.object !== null ? getObjectScene(item.object) : null; + if (objectScene) { + return objectScene === (this.scene ?? null); + } + + // Items without a determinable scene (color curves, script parameters, proxies, etc.) come + // from the inspector: volatile if and only if the inspector is editing a runtime object. + return isPlaySceneObject(this.props.editor, this.props.editor.layout.inspector.state.editedObject); + }); + this.props.editor.layout.preview.setState({ pickingEnabled: false, }); @@ -364,9 +402,21 @@ export class EditorPreviewPlayComponent extends Component { + // Notify once the play scene is fully loaded and ready (canPlayScene === true). + if (this.scene === scene) { + onPlaySceneChangedObservable.notifyObservers(scene); + } + } + ); } private _requireCompiledScripts(): void { @@ -396,7 +446,10 @@ export class EditorPreviewPlayComponent extends Component { preventDefault: true, label: "Delete Selected Objects", onKeyDown: () => { - if (!isDomTextInputFocused()) { + if (!isDomTextInputFocused() && !this.layout.graph.isRuntimeTabActive()) { const selectedNodes = this.layout.graph.getSelectedNodes(); if (selectedNodes.length > 0) { removeNodes(this); diff --git a/editor/src/tools/observables.ts b/editor/src/tools/observables.ts index 1cbd435f5..d38576554 100644 --- a/editor/src/tools/observables.ts +++ b/editor/src/tools/observables.ts @@ -1,10 +1,17 @@ -import { BaseTexture, Node, Observable, IParticleSystem, Sprite, Skeleton } from "babylonjs"; +import { BaseTexture, Node, Observable, IParticleSystem, Sprite, Skeleton, Scene } from "babylonjs"; /** * Observable for when the project has been saved. */ export const onProjectSavedObservable = new Observable(); +/** + * Observable for when the scene being played in the editor has changed. + * The observers receive the new play scene once it is fully loaded, or `null` when the + * play mode has been stopped (or is being restarted) and the editor is back to the edited scene. + */ +export const onPlaySceneChangedObservable = new Observable(); + /** * Observable for when new nodes have been added to the scene. */ diff --git a/editor/src/tools/scene/materials.ts b/editor/src/tools/scene/materials.ts index c308f0afc..072c65d2e 100644 --- a/editor/src/tools/scene/materials.ts +++ b/editor/src/tools/scene/materials.ts @@ -8,7 +8,7 @@ import { isInstancedMesh } from "../guards/nodes"; * @param scene The scene to force compile all materials */ export function forceCompileAllSceneMaterials(scene: Scene) { - return Promise.all( + const compilation = Promise.all( scene.materials.map(async (material) => { const meshes = material.getBindedMeshes(); @@ -25,5 +25,14 @@ export function forceCompileAllSceneMaterials(scene: Scene) { }) ); }) - ); + ).catch(() => { + // Compilation is a best-effort warm-up: materials of a scene disposed mid-compilation may fail to compile. + }); + + // Pending compilations of a disposed scene may never settle: resolve on dispose so callers never hang. + const disposed = new Promise((resolve) => { + scene.onDisposeObservable.addOnce(() => resolve()); + }); + + return Promise.race([compilation, disposed]); } diff --git a/editor/src/tools/scene/play/runtime.ts b/editor/src/tools/scene/play/runtime.ts new file mode 100644 index 000000000..30e9abf2e --- /dev/null +++ b/editor/src/tools/scene/play/runtime.ts @@ -0,0 +1,46 @@ +import { Scene } from "babylonjs"; + +import { Editor } from "../../../editor/main"; + +/** + * Returns wether or not the game / application is currently playing in the editor. + * Safe to call during the first renders of the editor layout: refs are assigned on commit, + * so `editor.layout` (and its children) don't exist yet while the first render is in progress. + * @param editor defines the reference to the editor. + */ +export function isScenePlaying(editor: Editor): boolean { + return editor.layout?.preview?.play?.state.playing ?? false; +} + +/** + * Returns the scene the given object belongs to, or null if it cannot be determined. + * Handles nodes, materials, textures, skeletons and particle systems (getScene), + * sounds (_scene, which has no public accessor) and sprites (manager). + * @param object defines the reference to the object to get the scene of. + */ +export function getObjectScene(object: any): Scene | null { + if (!object) { + return null; + } + + if (object instanceof Scene) { + return object; + } + + return object.getScene?.() ?? object._scene ?? object.manager?.scene ?? null; +} + +/** + * Returns wether or not the given object belongs to the play scene (aka is a runtime object). + * Objects whose scene cannot be determined are considered NOT runtime (safe default for graph guards). + * @param editor defines the reference to the editor. + * @param object defines the reference to the object to check. + */ +export function isPlaySceneObject(editor: Editor, object: any): boolean { + const playScene = editor.layout?.preview?.play?.scene ?? null; + if (!playScene) { + return false; + } + + return getObjectScene(object) === playScene; +} diff --git a/editor/src/tools/undoredo.ts b/editor/src/tools/undoredo.ts index dfb2265f3..075c02c54 100644 --- a/editor/src/tools/undoredo.ts +++ b/editor/src/tools/undoredo.ts @@ -17,6 +17,8 @@ export type SimpleUndoRedoStackItem = { }; export type UndoRedoStackItem = { + object?: any; + undo: () => void; redo: () => void; @@ -42,7 +44,29 @@ export function clearUndoRedo() { stack.splice(0, stack.length); } +export type UndoRedoVolatilePredicate = (item: UndoRedoStackItem) => boolean; + +let volatilePredicate: UndoRedoVolatilePredicate | null = null; + +/** + * Sets the predicate used to detect volatile undo/redo items while the game / application is playing. + * Volatile items (edits made on play scene objects) are executed but not recorded in the stack: + * they are lost on stop by design. Edits made on the edited scene keep being recorded as usual. + * @param predicate defines the predicate to use, or null to record all items again. + */ +export function setUndoRedoVolatilePredicate(predicate: UndoRedoVolatilePredicate | null): void { + volatilePredicate = predicate; +} + export function registerUndoRedo(configuration: UndoRedoStackItem) { + if (volatilePredicate?.(configuration)) { + if (configuration.executeRedo) { + configuration.redo(); + configuration.action?.(); + } + return; + } + const deleted = stack.splice(index + 1, stack.length); deleted.forEach((item) => { item.onLost?.(); @@ -66,6 +90,7 @@ export function registerUndoRedo(configuration: UndoRedoStackItem) { export function registerSimpleUndoRedo(configuration: SimpleUndoRedoStackItem) { registerUndoRedo({ + object: configuration.object, undo: () => { setInspectorEffectivePropertyValue(configuration.object, configuration.property, configuration.oldValue); }, diff --git a/editor/test/tools/scene/play/runtime.test.mts b/editor/test/tools/scene/play/runtime.test.mts new file mode 100644 index 000000000..8d655a390 --- /dev/null +++ b/editor/test/tools/scene/play/runtime.test.mts @@ -0,0 +1,146 @@ +import { describe, expect, test, beforeEach, afterEach, vi } from "vitest"; + +import { NullEngine, Scene, TransformNode } from "babylonjs"; + +vi.mock("babylonjs-editor-tools", () => ({})); + +import { Editor } from "../../../../src/editor/main"; +import { getObjectScene, isPlaySceneObject, isScenePlaying } from "../../../../src/tools/scene/play/runtime"; + +describe("tools/scene/play/runtime", () => { + let engine: NullEngine; + let scene: Scene; + let playScene: Scene; + let editor: Editor; + + beforeEach(() => { + engine = new NullEngine(); + scene = new Scene(engine); + playScene = new Scene(engine); + + editor = { + layout: { + preview: { + scene, + play: null, + }, + }, + } as any; + }); + + afterEach(() => { + scene.dispose(); + playScene.dispose(); + engine.dispose(); + }); + + function setPlayComponent(options: { playing: boolean; loading?: boolean; preparingPlay?: boolean; scene?: Scene | null }): void { + (editor.layout.preview as any).play = { + state: { + playing: options.playing, + loading: options.loading ?? false, + preparingPlay: options.preparingPlay ?? false, + }, + scene: options.scene ?? null, + }; + } + + describe("isScenePlaying", () => { + test("should return false when the editor layout is not assigned yet", () => { + expect(isScenePlaying({} as Editor)).toBe(false); + }); + + test("should return false when the play component is not available", () => { + expect(isScenePlaying(editor)).toBe(false); + }); + + test("should return true when playing", () => { + setPlayComponent({ playing: true, scene: playScene }); + expect(isScenePlaying(editor)).toBe(true); + }); + + test("should return true while the play is being prepared", () => { + setPlayComponent({ playing: true, preparingPlay: true, scene: null }); + expect(isScenePlaying(editor)).toBe(true); + }); + + test("should return false when stopped", () => { + setPlayComponent({ playing: false, scene: null }); + expect(isScenePlaying(editor)).toBe(false); + }); + }); + + describe("getObjectScene", () => { + test("should return null when the object is null or undefined", () => { + expect(getObjectScene(null)).toBeNull(); + expect(getObjectScene(undefined)).toBeNull(); + }); + + test("should return null when the scene cannot be determined", () => { + expect(getObjectScene({})).toBeNull(); + }); + + test("should return the scene itself when the object is a scene", () => { + expect(getObjectScene(scene)).toBe(scene); + }); + + test("should return the scene of a node", () => { + const node = new TransformNode("node", scene); + expect(getObjectScene(node)).toBe(scene); + }); + + test("should return the scene of a sound through its private reference", () => { + const sound = { + _scene: scene, + }; + + expect(getObjectScene(sound)).toBe(scene); + }); + + test("should return the scene of a sprite through its manager", () => { + const sprite = { + manager: { + scene, + }, + }; + + expect(getObjectScene(sprite)).toBe(scene); + }); + }); + + describe("isPlaySceneObject", () => { + test("should return false when the editor layout is not assigned yet", () => { + const node = new TransformNode("node", scene); + expect(isPlaySceneObject({} as Editor, node)).toBe(false); + }); + + test("should return false when no play scene is available", () => { + const node = new TransformNode("node", scene); + expect(isPlaySceneObject(editor, node)).toBe(false); + + setPlayComponent({ playing: true, scene: null }); + expect(isPlaySceneObject(editor, node)).toBe(false); + }); + + test("should return true for objects belonging to the play scene", () => { + setPlayComponent({ playing: true, scene: playScene }); + + const node = new TransformNode("node", playScene); + expect(isPlaySceneObject(editor, node)).toBe(true); + }); + + test("should return false for objects of the edited scene while playing", () => { + setPlayComponent({ playing: true, scene: playScene }); + + const node = new TransformNode("node", scene); + expect(isPlaySceneObject(editor, node)).toBe(false); + }); + + test("should return false for objects whose scene cannot be determined", () => { + setPlayComponent({ playing: true, scene: playScene }); + + expect(isPlaySceneObject(editor, {})).toBe(false); + expect(isPlaySceneObject(editor, null)).toBe(false); + }); + }); +}); diff --git a/editor/test/tools/undoredo.test.mts b/editor/test/tools/undoredo.test.mts index eb23a1334..a4abf62ed 100644 --- a/editor/test/tools/undoredo.test.mts +++ b/editor/test/tools/undoredo.test.mts @@ -1,4 +1,4 @@ -import { describe, expect, test, afterEach, vi } from "vitest"; +import { describe, expect, test, beforeEach, afterEach, vi } from "vitest"; vi.mock("electron", () => ({ shell: { @@ -6,11 +6,16 @@ vi.mock("electron", () => ({ }, })); -import { registerSimpleUndoRedo, stack, undo, redo, clearUndoRedo, registerUndoRedo } from "../../src/tools/undoredo"; +import { registerSimpleUndoRedo, stack, undo, redo, clearUndoRedo, registerUndoRedo, setUndoRedoVolatilePredicate } from "../../src/tools/undoredo"; import { shell } from "electron"; describe("tools/undoredo", () => { + beforeEach(() => { + setUndoRedoVolatilePredicate(null); + clearUndoRedo(); + }); + afterEach(() => { clearUndoRedo(); }); @@ -145,4 +150,119 @@ describe("tools/undoredo", () => { expect(shell.beep).toHaveBeenCalledTimes(2); }); }); + + describe("setUndoRedoVolatilePredicate", () => { + test("should execute the item without recording it when the predicate returns true", () => { + setUndoRedoVolatilePredicate(() => true); + + const configuration = { + executeRedo: true, + undo: vi.fn(), + redo: vi.fn(), + action: vi.fn(), + }; + + registerUndoRedo(configuration); + + expect(stack.length).toBe(0); + expect(configuration.redo).toHaveBeenCalledTimes(1); + expect(configuration.action).toHaveBeenCalledTimes(1); + }); + + test("should not move the index when a volatile item is registered", () => { + const o = { + a: 1, + }; + + registerSimpleUndoRedo({ + object: o, + property: "a", + oldValue: 1, + newValue: 2, + executeRedo: true, + }); + + expect(o.a).toBe(2); + + setUndoRedoVolatilePredicate(() => true); + + registerUndoRedo({ + executeRedo: true, + undo: vi.fn(), + redo: vi.fn(), + }); + + expect(stack.length).toBe(1); + + undo(); + expect(o.a).toBe(1); + }); + + test("should classify interleaved items by object and undo only the recorded ones", () => { + const editedObject = { + a: 0, + }; + const runtimeObject = { + a: 0, + }; + + setUndoRedoVolatilePredicate((item) => item.object === runtimeObject); + + registerSimpleUndoRedo({ object: editedObject, property: "a", oldValue: 0, newValue: 1, executeRedo: true }); + registerSimpleUndoRedo({ object: runtimeObject, property: "a", oldValue: 0, newValue: 1, executeRedo: true }); + registerSimpleUndoRedo({ object: editedObject, property: "a", oldValue: 1, newValue: 2, executeRedo: true }); + + expect(stack.length).toBe(2); + expect(editedObject.a).toBe(2); + expect(runtimeObject.a).toBe(1); + + undo(); + expect(editedObject.a).toBe(1); + + undo(); + expect(editedObject.a).toBe(0); + expect(runtimeObject.a).toBe(1); + }); + + test("should record all items again when the predicate is set back to null", () => { + setUndoRedoVolatilePredicate(() => true); + + registerUndoRedo({ + executeRedo: true, + undo: vi.fn(), + redo: vi.fn(), + }); + + expect(stack.length).toBe(0); + + setUndoRedoVolatilePredicate(null); + + registerUndoRedo({ + executeRedo: true, + undo: vi.fn(), + redo: vi.fn(), + }); + + expect(stack.length).toBe(1); + }); + + test("should receive the object propagated by registerSimpleUndoRedo", () => { + const o = { + a: 1, + }; + + const predicate = vi.fn(() => false); + setUndoRedoVolatilePredicate(predicate); + + registerSimpleUndoRedo({ + object: o, + property: "a", + oldValue: 1, + newValue: 2, + }); + + expect(predicate).toHaveBeenCalledWith(expect.objectContaining({ object: o })); + expect(stack[0].object).toBe(o); + }); + }); });