From be60d92cdcb465c141ffe489e7fa082812572f8d Mon Sep 17 00:00:00 2001 From: marquezmiguel Date: Fri, 10 Jul 2026 13:30:14 -0300 Subject: [PATCH] feat: Scene | Runtime tabs in the graph during play mode (#898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While playing, the graph shows two tabs following the editor's tabs pattern: "Scene" keeps the edited scene fully editable exactly as before (undo/redo, context menu, drag and drop, save), and "Runtime" shows the live hierarchy of the play scene — script-created objects appear as they spawn, can be selected and tweaked in the inspector (volatile changes, lost on stop). The play scene lives in a separate tree state so id collisions with the exported scene never leak selection or expansion between tabs, and preservation matching is indexed by id so large runtime scenes (tens of thousands of nodes) refresh in milliseconds. Structural operations are guarded per object instead of globally, undo/redo registration classifies items by the scene the edited object belongs to, and the play lifecycle is hardened against re-entrance and stop-during-loading races. Co-Authored-By: Claude Opus 4.8 --- .../layout/animation/timeline/timeline.tsx | 1 + .../layout/animation/timeline/track.tsx | 3 + .../editor/layout/animation/tracks/tracks.tsx | 2 + .../editor/layout/cinematic/curves/handle.tsx | 1 + .../editor/layout/cinematic/curves/point.tsx | 1 + .../cinematic/inspector/events/base.tsx | 1 + .../layout/cinematic/inspector/key-cut.tsx | 1 + .../editor/layout/cinematic/inspector/key.tsx | 1 + .../editor/layout/cinematic/timelines/add.ts | 4 + .../editor/layout/cinematic/timelines/move.ts | 1 + .../layout/cinematic/timelines/remove.ts | 4 + .../layout/cinematic/timelines/tools.ts | 1 + editor/src/editor/layout/cinematic/tracks.tsx | 2 + .../cinematic/tracks/animation-group.tsx | 1 + .../layout/cinematic/tracks/property.tsx | 2 + .../editor/layout/cinematic/tracks/sound.tsx | 1 + editor/src/editor/layout/graph.tsx | 429 ++++++-- .../src/editor/layout/graph/context-menu.tsx | 975 +++++++++--------- editor/src/editor/layout/graph/label.tsx | 14 + editor/src/editor/layout/graph/move.ts | 6 + editor/src/editor/layout/graph/remove.ts | 6 + editor/src/editor/layout/inspector.tsx | 9 + .../editor/layout/inspector/decals/decals.tsx | 1 + .../editor/layout/inspector/fields/color.tsx | 1 + .../layout/inspector/fields/texture.tsx | 4 + .../src/editor/layout/inspector/mesh/mesh.tsx | 13 +- .../editor/layout/inspector/scene/scene.tsx | 22 +- editor/src/editor/layout/preview.tsx | 1 + .../editor/layout/preview/import/material.ts | 1 + editor/src/editor/layout/preview/play.tsx | 67 +- editor/src/editor/main.tsx | 2 +- editor/src/tools/observables.ts | 9 +- editor/src/tools/scene/materials.ts | 13 +- editor/src/tools/scene/play/runtime.ts | 46 + editor/src/tools/undoredo.ts | 25 + editor/test/tools/scene/play/runtime.test.mts | 146 +++ editor/test/tools/undoredo.test.mts | 124 ++- 37 files changed, 1343 insertions(+), 598 deletions(-) create mode 100644 editor/src/tools/scene/play/runtime.ts create mode 100644 editor/test/tools/scene/play/runtime.test.mts 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); + }); + }); });