babylon.quarks-editor 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/README.md +69 -9
- package/dist/core/assetDragSession.d.ts +1 -1
- package/dist/core/assetTree.d.ts +6 -1
- package/dist/core/assets.d.ts +7 -0
- package/dist/core/assets.js +11 -7
- package/dist/core/binding.d.ts +11 -2
- package/dist/core/binding.js +9 -1
- package/dist/core/collision.d.ts +4 -0
- package/dist/core/collision.js +4 -0
- package/dist/core/colors.d.ts +16 -0
- package/dist/core/colors.js +8 -0
- package/dist/core/defaultEffect.d.ts +5 -4
- package/dist/core/defaultEffect.js +4 -4
- package/dist/core/editorScene.d.ts +1 -1
- package/dist/core/editorSession.d.ts +229 -0
- package/dist/core/editorSession.js +983 -0
- package/dist/core/effectTree.d.ts +6 -1
- package/dist/core/effectTree.js +13 -88
- package/dist/core/emitterShapeWireframe.d.ts +2 -2
- package/dist/core/emitterShapeWireframe.js +45 -13
- package/dist/core/galleryDropMarker.d.ts +2 -2
- package/dist/core/galleryDropMarker.js +6 -6
- package/dist/core/galleryLayout.d.ts +3 -3
- package/dist/core/galleryLayout.js +1 -1
- package/dist/core/galleryPlayback.d.ts +1 -1
- package/dist/core/galleryPlayback.js +2 -1
- package/dist/core/galleryScene.d.ts +4 -4
- package/dist/core/galleryScene.js +3 -3
- package/dist/core/gallerySelectionMarker.d.ts +4 -4
- package/dist/core/gallerySelectionMarker.js +5 -5
- package/dist/core/groups.d.ts +1 -1
- package/dist/core/groups.js +1 -1
- package/dist/core/history.d.ts +7 -1
- package/dist/core/history.js +4 -0
- package/dist/core/indexedDbProjectStore.d.ts +14 -1
- package/dist/core/indexedDbProjectStore.js +13 -0
- package/dist/core/lineShaders.d.ts +2 -2
- package/dist/core/lineShaders.js +2 -2
- package/dist/core/loadEffect.d.ts +2 -2
- package/dist/core/loadEffect.js +1 -1
- package/dist/core/material.d.ts +12 -2
- package/dist/core/pickAssetFiles.js +1 -1
- package/dist/core/playback.d.ts +6 -0
- package/dist/core/projectStore.d.ts +26 -0
- package/dist/core/projectStore.js +1 -0
- package/dist/core/sceneEntries.d.ts +7 -4
- package/dist/core/sceneEntries.js +2 -2
- package/dist/core/scrub.d.ts +2 -2
- package/dist/core/scrub.js +2 -2
- package/dist/core/shapes.d.ts +25 -1
- package/dist/core/shapes.js +5 -0
- package/dist/core/systems.d.ts +4 -2
- package/dist/core/systems.js +3 -1
- package/dist/core/textureExport.d.ts +1 -1
- package/dist/core/timeline.d.ts +25 -5
- package/dist/core/transport.d.ts +3 -3
- package/dist/core/transport.js +2 -2
- package/dist/core/values.d.ts +21 -4
- package/dist/core/values.js +5 -0
- package/dist/core/viewportPicker.d.ts +4 -4
- package/dist/core/viewportPicker.js +8 -7
- package/dist/index.d.ts +25 -21
- package/dist/index.js +12 -12
- package/dist/react/AssetsPanel.d.ts +9 -2
- package/dist/react/AssetsPanel.js +6 -6
- package/dist/react/ChoiceDialog.d.ts +7 -0
- package/dist/react/ChoiceDialog.js +1 -1
- package/dist/react/ContextMenu.d.ts +10 -1
- package/dist/react/ContextMenu.js +1 -1
- package/dist/react/CurveEditor.d.ts +1 -1
- package/dist/react/CurveEditor.js +2 -2
- package/dist/react/EditorWorkspace.d.ts +9 -2
- package/dist/react/EditorWorkspace.js +10 -7
- package/dist/react/EffectEditor.d.ts +11 -5
- package/dist/react/EffectEditor.js +7 -7
- package/dist/react/EffectEditorHost.d.ts +33 -29
- package/dist/react/EffectEditorHost.js +247 -1674
- package/dist/react/EffectTreeView.d.ts +4 -1
- package/dist/react/EffectTreeView.js +3 -3
- package/dist/react/GradientEditor.d.ts +1 -1
- package/dist/react/GradientEditor.js +3 -3
- package/dist/react/InspectorInfoIcon.js +3 -3
- package/dist/react/InspectorLabel.js +3 -3
- package/dist/react/MessageDialog.d.ts +5 -0
- package/dist/react/MessageDialog.js +1 -1
- package/dist/react/ModuleSection.d.ts +10 -0
- package/dist/react/ModuleSection.js +8 -5
- package/dist/react/ObjectModule.d.ts +1 -1
- package/dist/react/ObjectModule.js +4 -4
- package/dist/react/ProjectsPage.d.ts +7 -1
- package/dist/react/ProjectsPage.js +33 -17
- package/dist/react/PromptDialog.d.ts +7 -0
- package/dist/react/PromptDialog.js +1 -1
- package/dist/react/ScenePanel.d.ts +9 -2
- package/dist/react/ScenePanel.js +12 -10
- package/dist/react/SubEmittersModule.d.ts +1 -1
- package/dist/react/SubEmittersModule.js +5 -5
- package/dist/react/TextureSelect.d.ts +24 -0
- package/dist/react/TextureSelect.js +74 -0
- package/dist/react/TextureUVField.d.ts +14 -0
- package/dist/react/TextureUVField.js +23 -0
- package/dist/react/TimelinePanel.d.ts +20 -7
- package/dist/react/TimelinePanel.js +21 -15
- package/dist/react/TimelineTrackRow.d.ts +13 -3
- package/dist/react/TimelineTrackRow.js +6 -6
- package/dist/react/TransportBar.d.ts +13 -4
- package/dist/react/TransportBar.js +3 -3
- package/dist/react/ValueField.d.ts +32 -5
- package/dist/react/ValueField.js +22 -11
- package/dist/react/behaviorModules.d.ts +19 -6
- package/dist/react/behaviorModules.js +68 -17
- package/dist/react/fields.d.ts +9 -0
- package/dist/react/fields.js +6 -3
- package/dist/react/host/BottomDocks.d.ts +36 -0
- package/dist/react/host/BottomDocks.js +63 -0
- package/dist/react/host/HostDialogs.d.ts +50 -0
- package/dist/react/host/HostDialogs.js +113 -0
- package/dist/react/host/InspectorSidebar.d.ts +32 -0
- package/dist/react/host/InspectorSidebar.js +59 -0
- package/dist/react/host/SceneSidebar.d.ts +53 -0
- package/dist/react/host/SceneSidebar.js +62 -0
- package/dist/react/host/ViewportPane.d.ts +56 -0
- package/dist/react/host/ViewportPane.js +91 -0
- package/dist/react/host/hostHooks.d.ts +44 -0
- package/dist/react/host/hostHooks.js +164 -0
- package/dist/react/host/hostStyles.d.ts +21 -0
- package/dist/react/host/hostStyles.js +57 -0
- package/dist/react/index.d.ts +42 -38
- package/dist/react/index.js +25 -23
- package/dist/react/inspectorHints.js +31 -0
- package/dist/react/modules.d.ts +19 -6
- package/dist/react/modules.js +134 -117
- package/dist/react/theme.d.ts +15 -0
- package/dist/react/theme.js +15 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.js +8 -0
- package/package.json +18 -11
- package/src/core/assetDragSession.ts +1 -1
- package/src/core/assetTree.ts +15 -2
- package/src/core/assets.ts +25 -10
- package/src/core/binding.ts +12 -3
- package/src/core/collision.ts +4 -0
- package/src/core/colors.ts +21 -1
- package/src/core/defaultEffect.ts +8 -7
- package/src/core/editorScene.ts +1 -1
- package/src/core/editorSession.ts +1299 -0
- package/src/core/effectTree.ts +18 -101
- package/src/core/emitterShapeWireframe.ts +48 -16
- package/src/core/galleryDropMarker.ts +8 -8
- package/src/core/galleryLayout.ts +4 -4
- package/src/core/galleryPlayback.ts +3 -2
- package/src/core/galleryScene.ts +5 -5
- package/src/core/gallerySelectionMarker.ts +7 -7
- package/src/core/groups.ts +1 -1
- package/src/core/history.ts +7 -1
- package/src/core/indexedDbProjectStore.ts +14 -1
- package/src/core/lineShaders.ts +2 -2
- package/src/core/loadEffect.ts +4 -4
- package/src/core/material.ts +14 -4
- package/src/core/pickAssetFiles.ts +2 -2
- package/src/core/playback.ts +6 -0
- package/src/core/projectStore.ts +26 -0
- package/src/core/sceneEntries.ts +8 -5
- package/src/core/scrub.ts +3 -3
- package/src/core/shapes.ts +29 -2
- package/src/core/systems.ts +11 -4
- package/src/core/textureExport.ts +1 -1
- package/src/core/timeline.ts +43 -18
- package/src/core/transport.ts +4 -4
- package/src/core/values.ts +20 -4
- package/src/core/viewportPicker.ts +12 -11
- package/src/index.ts +25 -21
- package/src/react/AssetsPanel.tsx +13 -6
- package/src/react/ChoiceDialog.tsx +8 -1
- package/src/react/ContextMenu.tsx +11 -2
- package/src/react/CurveEditor.tsx +2 -2
- package/src/react/EditorWorkspace.tsx +16 -10
- package/src/react/EffectEditor.tsx +17 -9
- package/src/react/EffectEditorHost.tsx +415 -2354
- package/src/react/EffectTreeView.tsx +7 -4
- package/src/react/GradientEditor.tsx +3 -3
- package/src/react/InspectorInfoIcon.tsx +3 -3
- package/src/react/InspectorLabel.tsx +3 -3
- package/src/react/MessageDialog.tsx +6 -1
- package/src/react/ModuleSection.tsx +15 -4
- package/src/react/ObjectModule.tsx +5 -5
- package/src/react/ProjectsPage.tsx +44 -17
- package/src/react/PromptDialog.tsx +8 -1
- package/src/react/ScenePanel.tsx +21 -12
- package/src/react/SubEmittersModule.tsx +6 -6
- package/src/react/TextureSelect.tsx +118 -0
- package/src/react/TextureUVField.tsx +62 -0
- package/src/react/TimelinePanel.tsx +40 -21
- package/src/react/TimelineTrackRow.tsx +19 -9
- package/src/react/TransportBar.tsx +16 -7
- package/src/react/ValueField.tsx +60 -16
- package/src/react/behaviorModules.tsx +240 -55
- package/src/react/fields.tsx +22 -4
- package/src/react/host/BottomDocks.tsx +178 -0
- package/src/react/host/HostDialogs.tsx +228 -0
- package/src/react/host/InspectorSidebar.tsx +147 -0
- package/src/react/host/SceneSidebar.tsx +227 -0
- package/src/react/host/ViewportPane.tsx +300 -0
- package/src/react/host/hostHooks.ts +198 -0
- package/src/react/host/hostStyles.ts +65 -0
- package/src/react/index.ts +43 -38
- package/src/react/inspectorHints.ts +40 -0
- package/src/react/modules.tsx +440 -170
- package/src/react/theme.ts +15 -0
- package/src/session.ts +17 -0
|
@@ -1,310 +1,122 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import '@babylonjs/core/
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import {GridMaterial} from '@babylonjs/materials/grid/gridMaterial';
|
|
17
|
-
import {
|
|
18
|
-
ArrowLeftIcon,
|
|
19
|
-
ArrowPathIcon,
|
|
20
|
-
ArrowsPointingOutIcon,
|
|
21
|
-
ArrowUturnLeftIcon,
|
|
22
|
-
ArrowUturnRightIcon,
|
|
23
|
-
ChevronDownIcon,
|
|
24
|
-
ChevronUpIcon,
|
|
25
|
-
CubeTransparentIcon,
|
|
26
|
-
CursorArrowRaysIcon,
|
|
27
|
-
MapPinIcon,
|
|
28
|
-
Square2StackIcon,
|
|
29
|
-
XMarkIcon,
|
|
30
|
-
} from '@heroicons/react/24/solid';
|
|
31
|
-
import {BatchedRenderer, QuarksUtil} from 'babylon.quarks';
|
|
32
|
-
import React, {useCallback, useDeferredValue, useEffect, useMemo, useReducer, useRef, useState} from 'react';
|
|
1
|
+
import type {AbstractEngine} from '@babylonjs/core/Engines/abstractEngine.js';
|
|
2
|
+
import {Engine} from '@babylonjs/core/Engines/engine.js';
|
|
3
|
+
import type {Texture} from '@babylonjs/core/Materials/Textures/texture.js';
|
|
4
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
|
|
5
|
+
import type {Scene} from '@babylonjs/core/scene.js';
|
|
6
|
+
import React, {
|
|
7
|
+
useCallback,
|
|
8
|
+
useDeferredValue,
|
|
9
|
+
useEffect,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useReducer,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from 'react';
|
|
33
16
|
import {createRoot} from 'react-dom/client';
|
|
34
|
-
import {
|
|
35
|
-
import {ASSET_DRAG_MIME
|
|
36
|
-
import {
|
|
37
|
-
collectJsonFiles,
|
|
38
|
-
createEffectAsset,
|
|
39
|
-
loadEffectAssets,
|
|
40
|
-
mergeAssets,
|
|
41
|
-
removeAssets,
|
|
42
|
-
type AssetLoadProgress,
|
|
43
|
-
type EffectAsset,
|
|
44
|
-
} from '../core/assets';
|
|
45
|
-
import {EffectBinding} from '../core/binding';
|
|
46
|
-
import {createSceneDefaultEntry} from '../core/defaultEffect';
|
|
47
|
-
import {serializeEffectTree} from '../core/effectTree';
|
|
48
|
-
import {EmitterShapeWireframes} from '../core/emitterShapeWireframe';
|
|
49
|
-
import {GalleryDropMarker} from '../core/galleryDropMarker';
|
|
50
|
-
import {cancelGalleryCameraFocus, focusGalleryEntry} from '../core/galleryLayout';
|
|
51
|
-
import {
|
|
52
|
-
applyGalleryPlayback,
|
|
53
|
-
countGalleryParticles,
|
|
54
|
-
hasActivePreview,
|
|
55
|
-
pauseFinishedPreviews,
|
|
56
|
-
} from '../core/galleryPlayback';
|
|
57
|
-
import {pickGroundPosition, placeSceneEntriesAt, setSceneEntryVisible} from '../core/galleryScene';
|
|
58
|
-
import {GallerySelectionMarker} from '../core/gallerySelectionMarker';
|
|
59
|
-
import {EffectHistory, type HistoryStacks} from '../core/history';
|
|
60
|
-
import {parseEffectFromJson} from '../core/loadEffect';
|
|
61
|
-
import {pickAssetFolderFiles} from '../core/pickAssetFiles';
|
|
62
|
-
import {
|
|
63
|
-
clearFocusFinished,
|
|
64
|
-
createPlaybackState,
|
|
65
|
-
getFocusElapsed,
|
|
66
|
-
isFocusFinished,
|
|
67
|
-
markFocusFinished,
|
|
68
|
-
setFocusElapsed,
|
|
69
|
-
type PlaybackState,
|
|
70
|
-
} from '../core/playback';
|
|
71
|
-
import type {ProjectStore, StoredAsset, StoredScene} from '../core/projectStore';
|
|
72
|
-
import {logRenderMismatch} from '../core/renderDebug';
|
|
17
|
+
import {collectJsonFiles, type AssetLoadProgress, type EffectAsset} from '../core/assets.js';
|
|
18
|
+
import {ASSET_DRAG_MIME} from '../core/assetTree.js';
|
|
19
|
+
import type {EffectBinding} from '../core/binding.js';
|
|
73
20
|
import {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
type
|
|
79
|
-
} from '../core/
|
|
80
|
-
import {
|
|
81
|
-
import {
|
|
82
|
-
import {
|
|
83
|
-
import {toggleStagePlayback} from '../core/transport';
|
|
84
|
-
import {
|
|
85
|
-
import {
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
88
|
-
import {
|
|
89
|
-
import {
|
|
90
|
-
import {
|
|
91
|
-
import {
|
|
92
|
-
import {TimelinePanel} from './TimelinePanel';
|
|
93
|
-
import {TransportBar} from './TransportBar';
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* The effect's JSON, with a warning when it points at object URLs: a texture
|
|
100
|
-
* dropped in from disk lives at a `blob:` URL that dies with the page, so the
|
|
101
|
-
* exported effect would load without it.
|
|
102
|
-
*/
|
|
103
|
-
function exportEffectJSON(binding: EffectBinding, warn: (message: string) => void): string {
|
|
104
|
-
const json = binding.exportJSON('EditorEffect');
|
|
105
|
-
if (json.includes('"blob:')) {
|
|
106
|
-
warn(
|
|
107
|
-
'This effect references blob: textures, which will not load after a reload. Re-pick them from a hosted URL.'
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
return json;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface EffectEditorHostHandle {
|
|
114
|
-
binding: EffectBinding;
|
|
115
|
-
history: EffectHistory;
|
|
116
|
-
scene: Scene;
|
|
117
|
-
renderer: BatchedRenderer;
|
|
118
|
-
/** Replaces the scene with this effect (it is also added to Assets). */
|
|
119
|
-
importJson: (json: unknown) => void;
|
|
120
|
-
/** Imports effect files (and the texture/geometry files they reference) into Assets. */
|
|
121
|
-
importAssets: (files: FileList | File[]) => Promise<void>;
|
|
122
|
-
/** @deprecated Renamed to `importAssets`. */
|
|
123
|
-
importGallery: (files: FileList | File[]) => Promise<void>;
|
|
124
|
-
exportJson: () => string;
|
|
125
|
-
}
|
|
126
|
-
|
|
21
|
+
createEditorSession,
|
|
22
|
+
type EditorProject,
|
|
23
|
+
type EditorSession,
|
|
24
|
+
type EffectEditorHostHandle,
|
|
25
|
+
type PrefabInfo,
|
|
26
|
+
} from '../core/editorSession.js';
|
|
27
|
+
import {pickAssetFolderFiles} from '../core/pickAssetFiles.js';
|
|
28
|
+
import {createPlaybackState, type PlaybackState} from '../core/playback.js';
|
|
29
|
+
import {countInstancesByAsset, type SceneEntry} from '../core/sceneEntries.js';
|
|
30
|
+
import {toggleStagePlayback} from '../core/transport.js';
|
|
31
|
+
import {AssetsDrawer, TimelineDock} from './host/BottomDocks.js';
|
|
32
|
+
import {HostDialogs, type PrefabDialog} from './host/HostDialogs.js';
|
|
33
|
+
import {useHostShortcuts, useStageGizmo, useUnsavedChangesGuard, useViewportInset} from './host/hostHooks.js';
|
|
34
|
+
import {InspectorSidebar} from './host/InspectorSidebar.js';
|
|
35
|
+
import {SceneSidebar} from './host/SceneSidebar.js';
|
|
36
|
+
import {ViewportPane, type GizmoMode} from './host/ViewportPane.js';
|
|
37
|
+
import type {GeometryData, GeometryOption, TextureOption} from './modules.js';
|
|
38
|
+
import {globalEditorStyles, theme} from './theme.js';
|
|
39
|
+
import {TimelinePanel} from './TimelinePanel.js';
|
|
40
|
+
import {TransportBar} from './TransportBar.js';
|
|
41
|
+
|
|
42
|
+
export type {EditorProject, EffectEditorHostHandle} from '../core/editorSession.js';
|
|
43
|
+
|
|
44
|
+
/** Props of {@link EffectEditorHost}. */
|
|
127
45
|
export interface EffectEditorHostProps {
|
|
128
46
|
/** Quarks JSON export to open; a default effect is created when omitted. */
|
|
129
47
|
effectJson?: unknown;
|
|
130
48
|
/** When set, a Save button appears and receives the serialized effect. */
|
|
131
49
|
onSave?: (json: string) => void;
|
|
132
|
-
/** Called once the engine
|
|
50
|
+
/** Called once the engine, scene and binding are live, and again whenever the focused effect changes. */
|
|
133
51
|
onReady?: (handle: EffectEditorHostHandle) => void;
|
|
52
|
+
/** Inspector header shown while the focused effect has no name. */
|
|
134
53
|
title?: string;
|
|
54
|
+
/** Texture presets offered in the Renderer module; the first is the default effect's texture. */
|
|
135
55
|
textureOptions?: TextureOption[];
|
|
136
56
|
/** Mesh presets for the Mesh render mode (e.g. asset meshes from a host editor). */
|
|
137
57
|
geometryOptions?: GeometryOption[];
|
|
138
|
-
|
|
58
|
+
/** Host texture loader for texture URLs picked in the editor. */
|
|
59
|
+
resolveTexture?: (url: string, scene: Scene) => Texture | null | undefined;
|
|
139
60
|
/** Host loader for the Mesh "Load from file…" option (e.g. GLB via @babylonjs/loaders). */
|
|
140
61
|
resolveGeometry?: (file: File, scene: Scene) => Promise<GeometryData>;
|
|
141
62
|
/** Optional link rendered in the viewport's top-left corner (e.g. back to a host gallery page). */
|
|
142
|
-
backLink?: {
|
|
63
|
+
backLink?: {
|
|
64
|
+
/** Where the link goes. */
|
|
65
|
+
href: string;
|
|
66
|
+
/** The link's text; a leading `←` is dropped, an arrow icon is drawn instead. */
|
|
67
|
+
label: string;
|
|
68
|
+
};
|
|
143
69
|
/**
|
|
144
70
|
* Project to edit: its assets load from and save to `store`, and the scene is saved with
|
|
145
71
|
* Save / Ctrl+S. Without it the editor works in memory only.
|
|
146
72
|
*/
|
|
147
|
-
project?:
|
|
73
|
+
project?: EditorProject;
|
|
148
74
|
/** Shows a "← Projects" button; called once unsaved scene changes are dealt with. */
|
|
149
75
|
onExitProject?: () => void;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
/** `Folder/Name` typed by the user → a clean asset path ending in `.json`. */
|
|
159
|
-
function normalizeAssetPath(input: string): string {
|
|
160
|
-
const segments = input
|
|
161
|
-
.replace(/\\/g, '/')
|
|
162
|
-
.split('/')
|
|
163
|
-
.map((part) => part.trim())
|
|
164
|
-
.filter(Boolean);
|
|
165
|
-
const path = segments.join('/') || 'Effect';
|
|
166
|
-
return /\.json$/i.test(path) ? path : `${path}.json`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
type GizmoMode = 'none' | 'position' | 'rotation' | 'scale';
|
|
170
|
-
const SCENE_PANEL_WIDTH = 280;
|
|
171
|
-
const TIMELINE_HEIGHT = 200;
|
|
172
|
-
const DRAWER_HEIGHT = 280;
|
|
173
|
-
|
|
174
|
-
const GIZMO_MODE_BUTTONS: Array<{value: GizmoMode; Icon: HeroIcon; title: string}> = [
|
|
175
|
-
{value: 'none', Icon: CursorArrowRaysIcon, title: 'Select (hide stage gizmo)'},
|
|
176
|
-
{value: 'position', Icon: ArrowsPointingOutIcon, title: 'Move the stage node (position)'},
|
|
177
|
-
{value: 'rotation', Icon: ArrowPathIcon, title: 'Rotate the stage node'},
|
|
178
|
-
{value: 'scale', Icon: Square2StackIcon, title: 'Scale the stage node'},
|
|
179
|
-
];
|
|
180
|
-
|
|
181
|
-
const squareButtonStyle: React.CSSProperties = {
|
|
182
|
-
...buttonStyle,
|
|
183
|
-
width: 28,
|
|
184
|
-
height: 28,
|
|
185
|
-
padding: 0,
|
|
186
|
-
display: 'flex',
|
|
187
|
-
alignItems: 'center',
|
|
188
|
-
justifyContent: 'center',
|
|
189
|
-
fontWeight: 700,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const activeSquareButtonStyle: React.CSSProperties = {
|
|
193
|
-
border: `1px solid ${theme.borderActive}`,
|
|
194
|
-
background: 'rgba(120, 165, 255, 0.35)',
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const dockTitleStyle: React.CSSProperties = {
|
|
198
|
-
fontSize: 11,
|
|
199
|
-
fontWeight: 600,
|
|
200
|
-
letterSpacing: 0.4,
|
|
201
|
-
textTransform: 'uppercase',
|
|
202
|
-
color: theme.text,
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const dockIconButtonStyle: React.CSSProperties = {
|
|
206
|
-
...buttonStyle,
|
|
207
|
-
width: 24,
|
|
208
|
-
height: 22,
|
|
209
|
-
padding: 0,
|
|
210
|
-
display: 'inline-flex',
|
|
211
|
-
alignItems: 'center',
|
|
212
|
-
justifyContent: 'center',
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
function isTextInput(target: EventTarget | null): boolean {
|
|
216
|
-
const el = target as HTMLElement | null;
|
|
217
|
-
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Inspector value controls (numbers, checkboxes, selects): Ctrl+Z there means the effect's undo,
|
|
222
|
-
* not the browser's per-field text undo. Free-text inputs (names, filters) keep the native one.
|
|
223
|
-
*/
|
|
224
|
-
function isInspectorValueControl(target: EventTarget | null): boolean {
|
|
225
|
-
const el = target as HTMLElement | null;
|
|
226
|
-
if (!el) return false;
|
|
227
|
-
if (el.tagName === 'SELECT') return true;
|
|
228
|
-
return el.tagName === 'INPUT' && ['number', 'checkbox', 'range', 'color'].includes((el as HTMLInputElement).type);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Position of `node` in its effect tree (0 = root), to find the same node after a rebuild. */
|
|
232
|
-
function nodeIndexInEffect(root: TransformNode, node: TransformNode | null): number {
|
|
233
|
-
if (!node || node === root) return 0;
|
|
234
|
-
return root.getDescendants(false).indexOf(node) + 1;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function nodeAtIndex(root: TransformNode, index: number): TransformNode {
|
|
238
|
-
if (index <= 0) return root;
|
|
239
|
-
return (root.getDescendants(false)[index - 1] as TransformNode | undefined) ?? root;
|
|
76
|
+
/**
|
|
77
|
+
* Builds the engine the editor draws with, on the canvas it is given, for example a
|
|
78
|
+
* `WebGPUEngine` after `await engine.initAsync()`. It may return a promise. It must build a new
|
|
79
|
+
* engine on every call: the editor disposes the engine when it unmounts, and React may mount
|
|
80
|
+
* it more than once (StrictMode). Defaults to a WebGL `Engine` with antialiasing.
|
|
81
|
+
*/
|
|
82
|
+
createEngine?: (canvas: HTMLCanvasElement) => AbstractEngine | Promise<AbstractEngine>;
|
|
240
83
|
}
|
|
241
84
|
|
|
242
85
|
/**
|
|
243
86
|
* Self-contained effect editor: live Babylon preview, a Scene list of the effects placed in it,
|
|
244
87
|
* an inspector, and a bottom dock with the timeline and the Assets browser. Assets are effect
|
|
245
88
|
* files; dragging one into the viewport instantiates an independent copy, as many times as needed.
|
|
246
|
-
* Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
|
|
89
|
+
* Designed for embedding — see {@link QuarksEffectEditor}.Show for the NME-style entry point.
|
|
90
|
+
*
|
|
91
|
+
* The scene, Assets, prefab mode and undo live in an {@link EditorSession}; this component is its
|
|
92
|
+
* React view.
|
|
247
93
|
*/
|
|
248
|
-
export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
94
|
+
export function EffectEditorHost(props: EffectEditorHostProps): React.ReactElement {
|
|
249
95
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
250
96
|
const counterRef = useRef<HTMLSpanElement>(null);
|
|
251
97
|
const fpsRef = useRef<HTMLSpanElement>(null);
|
|
252
98
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
99
|
+
const timelineCounterRef = useRef<HTMLSpanElement>(null);
|
|
100
|
+
const mainAreaRef = useRef<HTMLDivElement>(null);
|
|
253
101
|
const assetFolderInputRef = useRef<HTMLInputElement>(null);
|
|
254
|
-
const actionsRef = useRef({
|
|
255
|
-
importAssets: async (_files: FileList | File[]) => {},
|
|
256
|
-
focusEntry: (_entry: SceneEntry) => {},
|
|
257
|
-
toggleVisible: (_entry: SceneEntry) => {},
|
|
258
|
-
toggleSolo: (_entry: SceneEntry) => {},
|
|
259
|
-
deleteEntry: (_entry: SceneEntry) => {},
|
|
260
|
-
createDefault: () => {},
|
|
261
|
-
placeDrop: (_clientX: number, _clientY: number, _dataTransfer?: DataTransfer) => {},
|
|
262
|
-
addToScene: (_payload: AssetDragPayload) => {},
|
|
263
|
-
deleteAssets: (_payload: AssetDragPayload) => {},
|
|
264
|
-
saveEntryAsAsset: (_entry: SceneEntry, _path: string) => {},
|
|
265
|
-
saveScene: async (): Promise<boolean> => false,
|
|
266
|
-
openPrefab: (_assetId: number, _discardChanges?: boolean) => {},
|
|
267
|
-
overwritePrefab: () => {},
|
|
268
|
-
savePrefabAsNew: (_path: string) => {},
|
|
269
|
-
closePrefab: () => {},
|
|
270
|
-
prefabInstanceCount: (): number => 0,
|
|
271
|
-
historyStep: (_redo: boolean) => {},
|
|
272
|
-
});
|
|
273
|
-
const dropMarkerRef = useRef<GalleryDropMarker | null>(null);
|
|
274
|
-
const selectionMarkerRef = useRef<GallerySelectionMarker | null>(null);
|
|
275
|
-
const emitterWireframesRef = useRef<EmitterShapeWireframes | null>(null);
|
|
276
|
-
const viewportPickerRef = useRef<GalleryViewportPicker | null>(null);
|
|
277
102
|
const assetDragDepthRef = useRef(0);
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
ground: Mesh;
|
|
283
|
-
renderer: BatchedRenderer;
|
|
284
|
-
binding: EffectBinding | null;
|
|
285
|
-
history: EffectHistory;
|
|
286
|
-
sceneEntries: SceneEntry[];
|
|
287
|
-
visibleRootIds: number[];
|
|
288
|
-
assets: EffectAsset[];
|
|
289
|
-
} | null>(null);
|
|
290
|
-
const gizmoManagerRef = useRef<GizmoManager | null>(null);
|
|
103
|
+
const playbackRef = useRef<PlaybackState>(createPlaybackState());
|
|
104
|
+
const propsRef = useRef(props);
|
|
105
|
+
|
|
106
|
+
const [session, setSession] = useState<EditorSession | null>(null);
|
|
291
107
|
const [binding, setBinding] = useState<EffectBinding | null>(null);
|
|
292
108
|
const [, force] = useReducer((x: number) => x + 1, 0);
|
|
293
|
-
const playbackRef = useRef<PlaybackState>(createPlaybackState());
|
|
294
|
-
const focusTimelineMetaRef = useRef({rootId: -1, revision: -1, span: 1, anyLooping: false});
|
|
295
109
|
const [stagePlaying, setStagePlaying] = useState(true);
|
|
296
110
|
const [speed, setSpeed] = useState(1);
|
|
297
|
-
const [renamingRoot, setRenamingRoot] = useState(false);
|
|
298
111
|
const [gizmoMode, setGizmoMode] = useState<GizmoMode>('none');
|
|
299
112
|
const [selectedNode, setSelectedNode] = useState<TransformNode | null>(null);
|
|
300
113
|
const selectedNodeRef = useRef<TransformNode | null>(null);
|
|
301
|
-
selectedNodeRef.current = selectedNode;
|
|
302
114
|
const [sceneEntries, setSceneEntries] = useState<SceneEntry[]>([]);
|
|
303
115
|
const [assets, setAssets] = useState<EffectAsset[]>([]);
|
|
304
116
|
const [assetsLoading, setAssetsLoading] = useState<AssetLoadProgress | null>(null);
|
|
305
117
|
const [focusedRoot, setFocusedRoot] = useState<TransformNode | null>(null);
|
|
306
118
|
const [visibleRootIds, setVisibleRootIds] = useState<number[]>([]);
|
|
307
|
-
const [
|
|
119
|
+
const [message, setMessage] = useState<{title: string; message: string} | null>(null);
|
|
308
120
|
const [assetDragHover, setAssetDragHover] = useState<{label: string; count: number} | null>(null);
|
|
309
121
|
const [showEmitterWireframes, setShowEmitterWireframes] = useState(false);
|
|
310
122
|
const [timelineCollapsed, setTimelineCollapsed] = useState(false);
|
|
@@ -313,38 +125,174 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
313
125
|
// button); pinned, it is docked above the timeline instead.
|
|
314
126
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
315
127
|
const [drawerPinned, setDrawerPinned] = useState(false);
|
|
316
|
-
const drawerPinnedRef = useRef(false);
|
|
317
|
-
drawerPinnedRef.current = drawerPinned;
|
|
318
|
-
const timelineCounterRef = useRef<HTMLSpanElement>(null);
|
|
319
|
-
const mainAreaRef = useRef<HTMLDivElement>(null);
|
|
320
|
-
/** Pixels at the bottom of the viewport covered by the floating Assets drawer. */
|
|
321
|
-
const [viewportInset, setViewportInset] = useState(0);
|
|
322
128
|
const [sceneDirty, setSceneDirty] = useState(false);
|
|
323
129
|
const [projectLoading, setProjectLoading] = useState(Boolean(props.project));
|
|
324
130
|
const [toast, setToast] = useState<string | null>(null);
|
|
325
131
|
const [saveAsTarget, setSaveAsTarget] = useState<SceneEntry | null>(null);
|
|
326
132
|
const [exitPrompt, setExitPrompt] = useState(false);
|
|
327
133
|
// Prefab mode: one asset opened on its own, the scene set aside until "← Scene".
|
|
328
|
-
const [prefabInfo, setPrefabInfo] = useState<
|
|
134
|
+
const [prefabInfo, setPrefabInfo] = useState<PrefabInfo | null>(null);
|
|
329
135
|
const [prefabDirty, setPrefabDirty] = useState(false);
|
|
330
|
-
const [prefabDialog, setPrefabDialog] = useState<
|
|
136
|
+
const [prefabDialog, setPrefabDialog] = useState<PrefabDialog>(null);
|
|
331
137
|
/** Asset waiting to replace an open prefab that has unsaved edits. */
|
|
332
138
|
const [prefabSwitch, setPrefabSwitch] = useState<{id: number; name: string} | null>(null);
|
|
333
139
|
const toastTimerRef = useRef(0);
|
|
334
|
-
const showToast = (text: string) => {
|
|
140
|
+
const showToast = useCallback((text: string) => {
|
|
335
141
|
window.clearTimeout(toastTimerRef.current);
|
|
336
142
|
setToast(text);
|
|
337
143
|
toastTimerRef.current = window.setTimeout(() => setToast(null), 2600);
|
|
338
|
-
};
|
|
144
|
+
}, []);
|
|
145
|
+
const showMessage = (title: string, text: string) => setMessage({title, message: text});
|
|
146
|
+
|
|
147
|
+
/** Builds the session on `engine` and starts it; returns what undoes it, engine included. */
|
|
148
|
+
function mountSession(canvas: HTMLCanvasElement, engine: AbstractEngine): () => void {
|
|
149
|
+
const created = createEditorSession({
|
|
150
|
+
engine,
|
|
151
|
+
playback: playbackRef.current,
|
|
152
|
+
project: props.project,
|
|
153
|
+
effectJson: props.effectJson,
|
|
154
|
+
resolveTexture: props.resolveTexture,
|
|
155
|
+
defaultTextureUrl: props.textureOptions?.[0]?.url,
|
|
156
|
+
isAssetDragActive: () => assetDragDepthRef.current > 0,
|
|
157
|
+
getSelectedNode: () => selectedNodeRef.current,
|
|
158
|
+
view: {
|
|
159
|
+
bindingChanged: setBinding,
|
|
160
|
+
selectedNodeChanged: setSelectedNode,
|
|
161
|
+
focusedRootChanged: setFocusedRoot,
|
|
162
|
+
entriesChanged: setSceneEntries,
|
|
163
|
+
assetsChanged: setAssets,
|
|
164
|
+
visibleChanged: setVisibleRootIds,
|
|
165
|
+
soloChanged: setSoloRootId,
|
|
166
|
+
assetsLoadingChanged: setAssetsLoading,
|
|
167
|
+
sceneDirtyChanged: setSceneDirty,
|
|
168
|
+
projectLoaded: () => setProjectLoading(false),
|
|
169
|
+
prefabChanged: setPrefabInfo,
|
|
170
|
+
prefabDirtyChanged: setPrefabDirty,
|
|
171
|
+
prefabSwitchRequested: setPrefabSwitch,
|
|
172
|
+
dragHoverChanged: setAssetDragHover,
|
|
173
|
+
assetsRevealed: () => setDrawerOpen(true),
|
|
174
|
+
refresh: force,
|
|
175
|
+
toast: showToast,
|
|
176
|
+
message: (title, text) => setMessage({title, message: text}),
|
|
177
|
+
ready: (handle) => propsRef.current.onReady?.(handle),
|
|
178
|
+
frame: (stats) => {
|
|
179
|
+
if (fpsRef.current) {
|
|
180
|
+
fpsRef.current.textContent = `${stats.fps} FPS`;
|
|
181
|
+
}
|
|
182
|
+
if (counterRef.current) {
|
|
183
|
+
counterRef.current.textContent = stats.counter;
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
setSession(created);
|
|
189
|
+
created.start();
|
|
190
|
+
|
|
191
|
+
const resizeObserver = new ResizeObserver(() => engine.resize());
|
|
192
|
+
resizeObserver.observe(canvas);
|
|
193
|
+
const onWindowDragEnd = () => {
|
|
194
|
+
assetDragDepthRef.current = 0;
|
|
195
|
+
created.clearDropPreview();
|
|
196
|
+
};
|
|
197
|
+
window.addEventListener('dragend', onWindowDragEnd);
|
|
198
|
+
return () => {
|
|
199
|
+
resizeObserver.disconnect();
|
|
200
|
+
window.removeEventListener('dragend', onWindowDragEnd);
|
|
201
|
+
assetDragDepthRef.current = 0;
|
|
202
|
+
setSession(null);
|
|
203
|
+
created.dispose();
|
|
204
|
+
engine.dispose();
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const canvas = canvasRef.current!;
|
|
210
|
+
let unmounted = false;
|
|
211
|
+
let teardown: (() => void) | null = null;
|
|
212
|
+
const mount = (engine: AbstractEngine) => {
|
|
213
|
+
if (unmounted) {
|
|
214
|
+
engine.dispose();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
teardown = mountSession(canvas, engine);
|
|
218
|
+
};
|
|
219
|
+
const built = props.createEngine ? props.createEngine(canvas) : new Engine(canvas, true);
|
|
220
|
+
if (built instanceof Promise) {
|
|
221
|
+
built.then(mount, (err: unknown) => {
|
|
222
|
+
console.error('[Quarks editor] Could not create the engine:', err);
|
|
223
|
+
if (!unmounted) {
|
|
224
|
+
setMessage({title: 'Engine', message: err instanceof Error ? err.message : String(err)});
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
mount(built);
|
|
229
|
+
}
|
|
230
|
+
return () => {
|
|
231
|
+
unmounted = true;
|
|
232
|
+
window.clearTimeout(toastTimerRef.current);
|
|
233
|
+
teardown?.();
|
|
234
|
+
};
|
|
235
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only: builds the engine and scene once
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
playbackRef.current.stagePlaying = stagePlaying;
|
|
240
|
+
session?.syncPlayback();
|
|
241
|
+
}, [session, stagePlaying, visibleRootIds, sceneEntries]);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
session?.setEmitterWireframesVisible(showEmitterWireframes);
|
|
245
|
+
}, [session, showEmitterWireframes]);
|
|
246
|
+
|
|
247
|
+
const viewportInset = useViewportInset(
|
|
248
|
+
session,
|
|
249
|
+
canvasRef,
|
|
250
|
+
mainAreaRef,
|
|
251
|
+
drawerOpen && !drawerPinned,
|
|
252
|
+
timelineCollapsed
|
|
253
|
+
);
|
|
254
|
+
useUnsavedChangesGuard(Boolean(props.project) && sceneDirty);
|
|
255
|
+
|
|
256
|
+
// The selection, with a node gone with an undo falling back to the effect's root; the gizmo
|
|
257
|
+
// follows it.
|
|
258
|
+
const liveSelection = selectedNode?.isDisposed() ? (session?.binding?.root ?? null) : selectedNode;
|
|
259
|
+
// The session's callbacks read the latest props and selection through these refs.
|
|
260
|
+
useLayoutEffect(() => {
|
|
261
|
+
propsRef.current = props;
|
|
262
|
+
selectedNodeRef.current = liveSelection;
|
|
263
|
+
});
|
|
264
|
+
const gizmoTarget =
|
|
265
|
+
liveSelection && !liveSelection.isDisposed()
|
|
266
|
+
? liveSelection
|
|
267
|
+
: binding?.root && !binding.root.isDisposed()
|
|
268
|
+
? binding.root
|
|
269
|
+
: null;
|
|
270
|
+
useStageGizmo(session, gizmoMode, gizmoTarget, assetsLoading !== null);
|
|
271
|
+
|
|
339
272
|
const closeFloatingDrawer = () => {
|
|
340
|
-
if (!
|
|
273
|
+
if (!drawerPinned) {
|
|
341
274
|
setDrawerOpen(false);
|
|
342
275
|
}
|
|
343
276
|
};
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
277
|
+
const savePrefabOrScene = () => {
|
|
278
|
+
if (session?.isPrefabOpen) {
|
|
279
|
+
setPrefabDialog('overwrite');
|
|
280
|
+
} else {
|
|
281
|
+
void session?.saveScene();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
useHostShortcuts({
|
|
285
|
+
save: savePrefabOrScene,
|
|
286
|
+
toggleAssets: () => setDrawerOpen((open) => !open),
|
|
287
|
+
togglePlayback: () => {
|
|
288
|
+
if (!session) return;
|
|
289
|
+
const playback = playbackRef.current;
|
|
290
|
+
toggleStagePlayback(session.binding, session.scene, playback, playback.stagePlaying, setStagePlaying);
|
|
291
|
+
},
|
|
292
|
+
closeDrawer: closeFloatingDrawer,
|
|
293
|
+
historyStep: (redo) => session?.historyStep(redo),
|
|
294
|
+
deleteFocused: () => session?.deleteFocused() ?? false,
|
|
295
|
+
});
|
|
348
296
|
|
|
349
297
|
/** Appends effect files to Assets without touching the scene. */
|
|
350
298
|
const runAssetImport = async (files: FileList | File[]) => {
|
|
@@ -357,12 +305,10 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
357
305
|
);
|
|
358
306
|
return;
|
|
359
307
|
}
|
|
360
|
-
|
|
361
308
|
try {
|
|
362
|
-
await
|
|
309
|
+
await session?.importAssets(files);
|
|
363
310
|
} catch (err) {
|
|
364
|
-
|
|
365
|
-
showMessage('Import assets', message);
|
|
311
|
+
showMessage('Import assets', err instanceof Error ? err.message : String(err));
|
|
366
312
|
console.error('Failed to import assets:', err);
|
|
367
313
|
}
|
|
368
314
|
force();
|
|
@@ -373,7 +319,6 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
373
319
|
assetFolderInputRef.current?.click();
|
|
374
320
|
return;
|
|
375
321
|
}
|
|
376
|
-
|
|
377
322
|
void (async () => {
|
|
378
323
|
try {
|
|
379
324
|
const picked = await pickAssetFolderFiles();
|
|
@@ -382,1067 +327,17 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
382
327
|
}
|
|
383
328
|
await runAssetImport(picked);
|
|
384
329
|
} catch (err) {
|
|
385
|
-
|
|
386
|
-
showMessage('Import assets', message);
|
|
330
|
+
showMessage('Import assets', err instanceof Error ? err.message : String(err));
|
|
387
331
|
console.error('Failed to open folder picker:', err);
|
|
388
332
|
}
|
|
389
333
|
})();
|
|
390
334
|
};
|
|
391
335
|
|
|
392
|
-
useEffect(() => {
|
|
393
|
-
const engine = new Engine(canvasRef.current!, true);
|
|
394
|
-
const scene = new Scene(engine);
|
|
395
|
-
scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
|
|
396
|
-
const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new BVector3(0, 1.4, 0), scene);
|
|
397
|
-
camera.attachControl(canvasRef.current!, true);
|
|
398
|
-
camera.wheelDeltaPercentage = 0.01;
|
|
399
|
-
|
|
400
|
-
// Mesh render mode particles are lit (billboard modes ignore these); the grid is visual
|
|
401
|
-
// reference only and is not a physics collider.
|
|
402
|
-
scene.ambientColor = new Color3(1, 1, 1);
|
|
403
|
-
const sunLight = new DirectionalLight('sun', new BVector3(-1, -1, -1), scene);
|
|
404
|
-
sunLight.intensity = 1.0;
|
|
405
|
-
sunLight.diffuse = new Color3(1, 1, 1);
|
|
406
|
-
sunLight.specular = new Color3(1, 1, 1);
|
|
407
|
-
|
|
408
|
-
const groundMaterial = new GridMaterial('groundMaterial', scene);
|
|
409
|
-
groundMaterial.majorUnitFrequency = 2;
|
|
410
|
-
groundMaterial.minorUnitVisibility = 0.1;
|
|
411
|
-
groundMaterial.gridRatio = 0.5;
|
|
412
|
-
groundMaterial.backFaceCulling = false;
|
|
413
|
-
groundMaterial.mainColor = new Color3(1, 1, 1);
|
|
414
|
-
groundMaterial.lineColor = new Color3(1, 1, 1);
|
|
415
|
-
groundMaterial.opacity = 0.5;
|
|
416
|
-
// Draw only the lines. Babylon < 9.31 did this by itself for opacity < 1; 9.31 needs the flag,
|
|
417
|
-
// or the whole plane is filled with the main color.
|
|
418
|
-
groundMaterial.linesOnly = true;
|
|
419
|
-
const ground = MeshBuilder.CreateGround('ground', {width: 100, height: 100}, scene);
|
|
420
|
-
ground.material = groundMaterial;
|
|
421
|
-
|
|
422
|
-
const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
|
|
423
|
-
const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
|
|
424
|
-
gizmoManager.usePointerToAttachGizmos = false;
|
|
425
|
-
gizmoManagerRef.current = gizmoManager;
|
|
426
|
-
|
|
427
|
-
const renderer = new BatchedRenderer('quarks-editor', scene);
|
|
428
|
-
const history = new EffectHistory();
|
|
429
|
-
/** Undo/redo of effects that are not focused right now, by root uniqueId. */
|
|
430
|
-
const historyByRoot = new Map<number, HistoryStacks>();
|
|
431
|
-
|
|
432
|
-
const dropMarker = new GalleryDropMarker(scene);
|
|
433
|
-
dropMarkerRef.current = dropMarker;
|
|
434
|
-
const selectionMarker = new GallerySelectionMarker(scene);
|
|
435
|
-
selectionMarkerRef.current = selectionMarker;
|
|
436
|
-
const emitterWireframes = new EmitterShapeWireframes(scene);
|
|
437
|
-
emitterWireframesRef.current = emitterWireframes;
|
|
438
|
-
const viewportPicker = new GalleryViewportPicker(scene);
|
|
439
|
-
viewportPickerRef.current = viewportPicker;
|
|
440
|
-
|
|
441
|
-
stateRef.current = {
|
|
442
|
-
engine,
|
|
443
|
-
scene,
|
|
444
|
-
camera,
|
|
445
|
-
ground,
|
|
446
|
-
renderer,
|
|
447
|
-
binding: null,
|
|
448
|
-
history,
|
|
449
|
-
sceneEntries: [],
|
|
450
|
-
visibleRootIds: [],
|
|
451
|
-
assets: [],
|
|
452
|
-
};
|
|
453
|
-
const state = stateRef.current;
|
|
454
|
-
|
|
455
|
-
const handleViewportPointerLeave = () => viewportPicker.setHover(null);
|
|
456
|
-
canvasRef.current?.addEventListener('pointerleave', handleViewportPointerLeave);
|
|
457
|
-
|
|
458
|
-
const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
|
|
459
|
-
if (assetDragDepthRef.current > 0) {
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
const event = pointerInfo.event;
|
|
463
|
-
if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
|
|
464
|
-
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
465
|
-
const focused = state.binding?.root ?? null;
|
|
466
|
-
viewportPicker.setHover(effectRoot && effectRoot !== focused ? effectRoot : null);
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
473
|
-
if (!effectRoot) {
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
const entry = state.sceneEntries.find((item) => item.root === effectRoot);
|
|
477
|
-
if (entry && state.binding?.root !== effectRoot) {
|
|
478
|
-
focusEntry(entry);
|
|
479
|
-
}
|
|
480
|
-
setSelectedNode(effectRoot);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
const clearAssetDragPreview = () => {
|
|
484
|
-
assetDragDepthRef.current = 0;
|
|
485
|
-
dropMarker.hide();
|
|
486
|
-
setAssetDragHover(null);
|
|
487
|
-
setAssetDragSession(null);
|
|
488
|
-
};
|
|
489
|
-
|
|
490
|
-
const onWindowDragEnd = () => clearAssetDragPreview();
|
|
491
|
-
window.addEventListener('dragend', onWindowDragEnd);
|
|
492
|
-
|
|
493
|
-
// ---- state commits ------------------------------------------------------------------
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* The open prefab, if any: its asset, and the scene set aside while it is edited. In prefab
|
|
497
|
-
* mode `state.sceneEntries` holds just the prefab's instance, so playback, picking, undo and
|
|
498
|
-
* the timeline all work on it unchanged.
|
|
499
|
-
*/
|
|
500
|
-
let prefab: {
|
|
501
|
-
asset: EffectAsset;
|
|
502
|
-
stash: {
|
|
503
|
-
entries: SceneEntry[];
|
|
504
|
-
visible: number[];
|
|
505
|
-
focusedRoot: TransformNode | null;
|
|
506
|
-
camera: {target: BVector3; radius: number; alpha: number; beta: number};
|
|
507
|
-
};
|
|
508
|
-
} | null = null;
|
|
509
|
-
let prefabChanged = false;
|
|
510
|
-
|
|
511
|
-
/** Scene changes since the last save; suppressed while a project scene loads. */
|
|
512
|
-
let dirty = false;
|
|
513
|
-
let loadingScene = Boolean(props.project);
|
|
514
|
-
const markDirty = () => {
|
|
515
|
-
if (prefab) {
|
|
516
|
-
if (!prefabChanged) {
|
|
517
|
-
prefabChanged = true;
|
|
518
|
-
setPrefabDirty(true);
|
|
519
|
-
}
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
if (!loadingScene && !dirty) {
|
|
523
|
-
dirty = true;
|
|
524
|
-
setSceneDirty(true);
|
|
525
|
-
}
|
|
526
|
-
};
|
|
527
|
-
const markClean = () => {
|
|
528
|
-
dirty = false;
|
|
529
|
-
setSceneDirty(false);
|
|
530
|
-
};
|
|
531
|
-
const unsubscribeTransform = subscribeTransform(markDirty);
|
|
532
|
-
|
|
533
|
-
const commitEntries = (next: SceneEntry[]) => {
|
|
534
|
-
state.sceneEntries = next;
|
|
535
|
-
setSceneEntries(next);
|
|
536
|
-
markDirty();
|
|
537
|
-
};
|
|
538
|
-
|
|
539
|
-
const commitAssets = (next: EffectAsset[]) => {
|
|
540
|
-
state.assets = next;
|
|
541
|
-
setAssets(next);
|
|
542
|
-
};
|
|
543
|
-
|
|
544
|
-
const commitVisible = (next: number[]) => {
|
|
545
|
-
state.visibleRootIds = next;
|
|
546
|
-
setVisibleRootIds(next);
|
|
547
|
-
markDirty();
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
const onBindingChange = () => {
|
|
551
|
-
force();
|
|
552
|
-
markDirty();
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
const isVisible = (entry: SceneEntry) => state.visibleRootIds.includes(entry.root.uniqueId);
|
|
556
|
-
|
|
557
|
-
const refreshPlayback = () => {
|
|
558
|
-
applyGalleryPlayback(
|
|
559
|
-
state.sceneEntries,
|
|
560
|
-
new Set(state.visibleRootIds),
|
|
561
|
-
playbackRef.current.stagePlaying,
|
|
562
|
-
playbackRef.current.focusFinishedByRoot
|
|
563
|
-
);
|
|
564
|
-
renderer.refreshBatches();
|
|
565
|
-
scene.render();
|
|
566
|
-
force();
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
// ---- focus ----------------------------------------------------------------------------
|
|
570
|
-
|
|
571
|
-
const attachBinding = (next: EffectBinding | null) => {
|
|
572
|
-
const previous = state.binding;
|
|
573
|
-
if (previous && !previous.root.isDisposed()) {
|
|
574
|
-
historyByRoot.set(previous.root.uniqueId, history.saveStacks());
|
|
575
|
-
}
|
|
576
|
-
history.restoreStacks(next ? historyByRoot.get(next.root.uniqueId) : undefined);
|
|
577
|
-
if (next) {
|
|
578
|
-
history.attach(next);
|
|
579
|
-
next.subscribe(onBindingChange);
|
|
580
|
-
}
|
|
581
|
-
state.binding = next;
|
|
582
|
-
setBinding(next);
|
|
583
|
-
};
|
|
584
|
-
|
|
585
|
-
const focusEntry = (entry: SceneEntry | null, moveCamera = true) => {
|
|
586
|
-
attachBinding(entry ? bindingFromSceneEntry(entry) : null);
|
|
587
|
-
setSelectedNode(entry?.root ?? null);
|
|
588
|
-
setFocusedRoot(entry?.root ?? null);
|
|
589
|
-
playbackRef.current.focusRootId = entry?.root.uniqueId ?? null;
|
|
590
|
-
if (entry && isVisible(entry)) {
|
|
591
|
-
selectionMarker.show(entry.root);
|
|
592
|
-
if (moveCamera) {
|
|
593
|
-
focusGalleryEntry(camera, entry.root);
|
|
594
|
-
}
|
|
595
|
-
} else {
|
|
596
|
-
selectionMarker.hide();
|
|
597
|
-
}
|
|
598
|
-
props.onReady?.(makeHandle());
|
|
599
|
-
};
|
|
600
|
-
|
|
601
|
-
// ---- scene entries ------------------------------------------------------------------
|
|
602
|
-
|
|
603
|
-
/** Adds entries to the scene (visible) and focuses the first. */
|
|
604
|
-
const addEntries = (entries: SceneEntry[], center: BVector3) => {
|
|
605
|
-
if (entries.length === 0) {
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
placeSceneEntriesAt(entries, center);
|
|
609
|
-
for (const entry of entries) {
|
|
610
|
-
entry.root.setEnabled(true);
|
|
611
|
-
}
|
|
612
|
-
commitEntries([...state.sceneEntries, ...entries]);
|
|
613
|
-
commitVisible([...state.visibleRootIds, ...entries.map((entry) => entry.root.uniqueId)]);
|
|
614
|
-
focusEntry(entries[0], false);
|
|
615
|
-
refreshPlayback();
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
const refuseInPrefabMode = () => {
|
|
619
|
-
if (!prefab) return false;
|
|
620
|
-
showToast('Effects go into the scene — go back to the scene (← Scene) to add them');
|
|
621
|
-
return true;
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
const instantiateAssets = (toPlace: EffectAsset[], center: BVector3) => {
|
|
625
|
-
if (refuseInPrefabMode()) {
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
const created: SceneEntry[] = [];
|
|
629
|
-
for (const asset of toPlace) {
|
|
630
|
-
try {
|
|
631
|
-
const entry = instantiateSceneEntry(scene, renderer, asset, [...state.sceneEntries, ...created]);
|
|
632
|
-
if (entry) {
|
|
633
|
-
created.push(entry);
|
|
634
|
-
}
|
|
635
|
-
} catch (err) {
|
|
636
|
-
console.warn(`[Quarks editor] Could not instantiate "${asset.path}":`, err);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
if (created.length < toPlace.length) {
|
|
640
|
-
showMessage(
|
|
641
|
-
'Add to scene',
|
|
642
|
-
`${toPlace.length - created.length} of ${toPlace.length} effect(s) could not be loaded — see the console.`
|
|
643
|
-
);
|
|
644
|
-
}
|
|
645
|
-
addEntries(created, center);
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
const toggleVisible = (entry: SceneEntry) => {
|
|
649
|
-
const id = entry.root.uniqueId;
|
|
650
|
-
const show = !isVisible(entry);
|
|
651
|
-
setSceneEntryVisible(entry, renderer, show);
|
|
652
|
-
clearFocusFinished(playbackRef.current, id);
|
|
653
|
-
commitVisible(
|
|
654
|
-
show ? [...state.visibleRootIds, id] : state.visibleRootIds.filter((rootId) => rootId !== id)
|
|
655
|
-
);
|
|
656
|
-
if (state.binding?.root === entry.root) {
|
|
657
|
-
if (show) {
|
|
658
|
-
selectionMarker.show(entry.root);
|
|
659
|
-
} else {
|
|
660
|
-
selectionMarker.hide();
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
refreshPlayback();
|
|
664
|
-
};
|
|
665
|
-
|
|
666
|
-
/** Entry shown alone, and the entries solo hid (shown again when it ends). */
|
|
667
|
-
let solo: {rootId: number; hidden: number[]} | null = null;
|
|
668
|
-
|
|
669
|
-
const exitSolo = () => {
|
|
670
|
-
if (!solo) {
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
const hidden = new Set(solo.hidden);
|
|
674
|
-
solo = null;
|
|
675
|
-
setSoloRootId(null);
|
|
676
|
-
const toShow = state.sceneEntries.filter((entry) => hidden.has(entry.root.uniqueId) && !isVisible(entry));
|
|
677
|
-
for (const entry of toShow) {
|
|
678
|
-
setSceneEntryVisible(entry, renderer, true);
|
|
679
|
-
clearFocusFinished(playbackRef.current, entry.root.uniqueId);
|
|
680
|
-
}
|
|
681
|
-
commitVisible([...state.visibleRootIds, ...toShow.map((entry) => entry.root.uniqueId)]);
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
const toggleSolo = (entry: SceneEntry) => {
|
|
685
|
-
if (prefab) return;
|
|
686
|
-
const id = entry.root.uniqueId;
|
|
687
|
-
const wasSolo = solo?.rootId === id;
|
|
688
|
-
exitSolo();
|
|
689
|
-
if (!wasSolo) {
|
|
690
|
-
const others = state.sceneEntries.filter((item) => item !== entry && isVisible(item));
|
|
691
|
-
for (const other of others) {
|
|
692
|
-
setSceneEntryVisible(other, renderer, false);
|
|
693
|
-
}
|
|
694
|
-
const hiddenIds = others.map((other) => other.root.uniqueId);
|
|
695
|
-
const visible = state.visibleRootIds.filter((rootId) => !hiddenIds.includes(rootId));
|
|
696
|
-
if (!visible.includes(id)) {
|
|
697
|
-
setSceneEntryVisible(entry, renderer, true);
|
|
698
|
-
visible.push(id);
|
|
699
|
-
}
|
|
700
|
-
commitVisible(visible);
|
|
701
|
-
solo = {rootId: id, hidden: hiddenIds};
|
|
702
|
-
setSoloRootId(id);
|
|
703
|
-
if (state.binding?.root !== entry.root) {
|
|
704
|
-
focusEntry(entry);
|
|
705
|
-
} else {
|
|
706
|
-
selectionMarker.show(entry.root);
|
|
707
|
-
focusGalleryEntry(camera, entry.root);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
refreshPlayback();
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
const deleteEntry = (entry: SceneEntry) => {
|
|
714
|
-
if (prefab) return;
|
|
715
|
-
const index = state.sceneEntries.indexOf(entry);
|
|
716
|
-
if (index < 0) {
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
const id = entry.root.uniqueId;
|
|
720
|
-
if (solo?.rootId === id) {
|
|
721
|
-
exitSolo();
|
|
722
|
-
}
|
|
723
|
-
const wasFocused = state.binding?.root === entry.root;
|
|
724
|
-
if (wasFocused) {
|
|
725
|
-
selectionMarker.hide();
|
|
726
|
-
}
|
|
727
|
-
disposeSceneEntry(entry, renderer);
|
|
728
|
-
const remaining = state.sceneEntries.filter((item) => item !== entry);
|
|
729
|
-
commitEntries(remaining);
|
|
730
|
-
commitVisible(state.visibleRootIds.filter((rootId) => rootId !== id));
|
|
731
|
-
clearFocusFinished(playbackRef.current, id);
|
|
732
|
-
playbackRef.current.focusElapsedByRoot.delete(id);
|
|
733
|
-
if (wasFocused) {
|
|
734
|
-
focusEntry(remaining[Math.min(index, remaining.length - 1)] ?? null, false);
|
|
735
|
-
}
|
|
736
|
-
historyByRoot.delete(id);
|
|
737
|
-
refreshPlayback();
|
|
738
|
-
};
|
|
739
|
-
|
|
740
|
-
const createDefault = () => {
|
|
741
|
-
if (refuseInPrefabMode()) {
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
const entry = createSceneDefaultEntry(scene, state.sceneEntries, {
|
|
745
|
-
resolveTexture: props.resolveTexture,
|
|
746
|
-
textureUrl: props.textureOptions?.[0]?.url,
|
|
747
|
-
});
|
|
748
|
-
for (const system of entry.systems) {
|
|
749
|
-
renderer.addSystem(system);
|
|
750
|
-
}
|
|
751
|
-
addEntries([entry], new BVector3(camera.target.x, 0, camera.target.z));
|
|
752
|
-
};
|
|
753
|
-
|
|
754
|
-
// ---- assets ---------------------------------------------------------------------------
|
|
755
|
-
|
|
756
|
-
const placeDrop = (clientX: number, clientY: number, dataTransfer?: DataTransfer) => {
|
|
757
|
-
const payload = (dataTransfer && readAssetDrag(dataTransfer)) ?? getAssetDragSession();
|
|
758
|
-
clearAssetDragPreview();
|
|
759
|
-
if (!payload) {
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
const {assets: toPlace} = describeAssetDrag(state.assets, payload);
|
|
763
|
-
instantiateAssets(toPlace, pickGroundPosition(scene, clientX, clientY));
|
|
764
|
-
};
|
|
765
|
-
|
|
766
|
-
const addToScene = (payload: AssetDragPayload) => {
|
|
767
|
-
const {assets: toPlace} = describeAssetDrag(state.assets, payload);
|
|
768
|
-
instantiateAssets(toPlace, new BVector3(camera.target.x, 0, camera.target.z));
|
|
769
|
-
};
|
|
770
|
-
|
|
771
|
-
/** Writes asset changes through to the project; failures are reported, not thrown. */
|
|
772
|
-
const persist = (action: (store: ProjectStore, projectId: string) => Promise<void>) => {
|
|
773
|
-
const project = props.project;
|
|
774
|
-
if (!project) return;
|
|
775
|
-
action(project.store, project.id).catch((err) => {
|
|
776
|
-
console.error('[Quarks editor] Could not save to the project:', err);
|
|
777
|
-
showMessage('Project storage', err instanceof Error ? err.message : String(err));
|
|
778
|
-
});
|
|
779
|
-
};
|
|
780
|
-
|
|
781
|
-
const deleteAssets = (payload: AssetDragPayload) => {
|
|
782
|
-
const next = removeAssets(state.assets, payload);
|
|
783
|
-
const removed = state.assets.filter((asset) => !next.includes(asset)).map((asset) => asset.path);
|
|
784
|
-
commitAssets(next);
|
|
785
|
-
persist((store, id) => store.deleteAssets(id, removed));
|
|
786
|
-
};
|
|
787
|
-
|
|
788
|
-
const importAssets = async (files: FileList | File[]) => {
|
|
789
|
-
setAssetsLoading({done: 0, total: 0, loaded: 0, failed: 0});
|
|
790
|
-
let incoming: EffectAsset[];
|
|
791
|
-
try {
|
|
792
|
-
incoming = await loadEffectAssets(files, (progress) => setAssetsLoading(progress));
|
|
793
|
-
} finally {
|
|
794
|
-
setAssetsLoading(null);
|
|
795
|
-
}
|
|
796
|
-
const {merged, added} = mergeAssets(state.assets, incoming);
|
|
797
|
-
commitAssets(merged);
|
|
798
|
-
setDrawerOpen(true);
|
|
799
|
-
if (added.length > 0) {
|
|
800
|
-
persist((store, id) => store.putAssets(id, added.map(toStoredAsset)));
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
if (incoming.length === 0) {
|
|
804
|
-
throw new Error('No particle effects found in the selected file(s).');
|
|
805
|
-
}
|
|
806
|
-
if (added.length === 0) {
|
|
807
|
-
throw new Error('All selected effects are already in Assets.');
|
|
808
|
-
}
|
|
809
|
-
};
|
|
810
|
-
|
|
811
|
-
/**
|
|
812
|
-
* Saves a scene instance as a new asset at `rawPath` (`Folder/Name`, never overwriting) and
|
|
813
|
-
* makes the instance an instance of it.
|
|
814
|
-
*/
|
|
815
|
-
/** New asset at `rawPath` (`Folder/Name`, a free path — never overwrites) from an effect tree. */
|
|
816
|
-
const createAssetFromRoot = (root: TransformNode, rawPath: string): EffectAsset => {
|
|
817
|
-
const taken = new Set(state.assets.map((asset) => asset.path));
|
|
818
|
-
const wanted = normalizeAssetPath(rawPath);
|
|
819
|
-
let path = wanted;
|
|
820
|
-
for (let n = 2; taken.has(path); n++) {
|
|
821
|
-
path = wanted.replace(/\.json$/i, ` ${n}.json`);
|
|
822
|
-
}
|
|
823
|
-
const name = path
|
|
824
|
-
.split('/')
|
|
825
|
-
.pop()!
|
|
826
|
-
.replace(/\.json$/i, '');
|
|
827
|
-
const asset = createEffectAsset(name, path, JSON.parse(serializeEffectTree(root, name)));
|
|
828
|
-
commitAssets([...state.assets, asset]);
|
|
829
|
-
persist((store, id) => store.putAssets(id, [toStoredAsset(asset)]));
|
|
830
|
-
return asset;
|
|
831
|
-
};
|
|
832
|
-
|
|
833
|
-
const saveEntryAsAsset = (entry: SceneEntry, rawPath: string) => {
|
|
834
|
-
const asset = createAssetFromRoot(entry.root, rawPath);
|
|
835
|
-
entry.assetId = asset.id;
|
|
836
|
-
entry.path = asset.path;
|
|
837
|
-
commitEntries([...state.sceneEntries]);
|
|
838
|
-
setDrawerOpen(true);
|
|
839
|
-
showToast(`Saved as asset “${asset.path.replace(/\.json$/i, '')}”`);
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
// ---- prefab mode ------------------------------------------------------------------------
|
|
843
|
-
|
|
844
|
-
/** Scene instances made from the open prefab — they keep their own copy when it is saved. */
|
|
845
|
-
const prefabInstanceCount = () =>
|
|
846
|
-
prefab ? prefab.stash.entries.filter((entry) => entry.assetId === prefab!.asset.id).length : 0;
|
|
847
|
-
|
|
848
|
-
/** Drops the prefab instance(s) currently in `state.sceneEntries`, with their history. */
|
|
849
|
-
const disposePrefabInstances = () => {
|
|
850
|
-
for (const entry of state.sceneEntries) {
|
|
851
|
-
const id = entry.root.uniqueId;
|
|
852
|
-
disposeSceneEntry(entry, renderer);
|
|
853
|
-
historyByRoot.delete(id);
|
|
854
|
-
clearFocusFinished(playbackRef.current, id);
|
|
855
|
-
playbackRef.current.focusElapsedByRoot.delete(id);
|
|
856
|
-
}
|
|
857
|
-
};
|
|
858
|
-
|
|
859
|
-
/**
|
|
860
|
-
* Opens an asset as a prefab. With another prefab open it is swapped in place — the scene stays
|
|
861
|
-
* set aside — after asking about unsaved edits unless `discardChanges`.
|
|
862
|
-
*/
|
|
863
|
-
const openPrefab = (assetId: number, discardChanges = false) => {
|
|
864
|
-
if (prefab?.asset.id === assetId) {
|
|
865
|
-
return;
|
|
866
|
-
}
|
|
867
|
-
const asset = state.assets.find((item) => item.id === assetId);
|
|
868
|
-
if (!asset) return;
|
|
869
|
-
if (prefab && prefabChanged && !discardChanges) {
|
|
870
|
-
setPrefabSwitch({id: asset.id, name: asset.name});
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
let entry: SceneEntry | null = null;
|
|
874
|
-
try {
|
|
875
|
-
entry = instantiateSceneEntry(scene, renderer, asset, []);
|
|
876
|
-
} catch (err) {
|
|
877
|
-
console.warn(`[Quarks editor] Could not open prefab "${asset.path}":`, err);
|
|
878
|
-
}
|
|
879
|
-
if (!entry) {
|
|
880
|
-
showMessage('Open prefab', `“${asset.name}” has no particle systems to edit.`);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
if (prefab) {
|
|
884
|
-
selectionMarker.hide();
|
|
885
|
-
disposePrefabInstances();
|
|
886
|
-
prefab.asset = asset;
|
|
887
|
-
prefabChanged = false;
|
|
888
|
-
setPrefabDirty(false);
|
|
889
|
-
setPrefabInfo({name: asset.name, path: asset.path});
|
|
890
|
-
entry.root.setEnabled(true);
|
|
891
|
-
state.sceneEntries = [entry];
|
|
892
|
-
setSceneEntries([entry]);
|
|
893
|
-
state.visibleRootIds = [entry.root.uniqueId];
|
|
894
|
-
setVisibleRootIds([entry.root.uniqueId]);
|
|
895
|
-
focusEntry(entry);
|
|
896
|
-
refreshPlayback();
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
exitSolo();
|
|
900
|
-
const stash = {
|
|
901
|
-
entries: state.sceneEntries,
|
|
902
|
-
visible: state.visibleRootIds,
|
|
903
|
-
focusedRoot: state.binding?.root ?? null,
|
|
904
|
-
camera: {target: camera.target.clone(), radius: camera.radius, alpha: camera.alpha, beta: camera.beta},
|
|
905
|
-
};
|
|
906
|
-
// Hide the scene without touching its saved visibility.
|
|
907
|
-
for (const item of stash.entries) {
|
|
908
|
-
if (stash.visible.includes(item.root.uniqueId)) {
|
|
909
|
-
setSceneEntryVisible(item, renderer, false);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
selectionMarker.hide();
|
|
913
|
-
prefab = {asset, stash};
|
|
914
|
-
prefabChanged = false;
|
|
915
|
-
setPrefabDirty(false);
|
|
916
|
-
setPrefabInfo({name: asset.name, path: asset.path});
|
|
917
|
-
entry.root.setEnabled(true);
|
|
918
|
-
state.sceneEntries = [entry];
|
|
919
|
-
setSceneEntries([entry]);
|
|
920
|
-
state.visibleRootIds = [entry.root.uniqueId];
|
|
921
|
-
setVisibleRootIds([entry.root.uniqueId]);
|
|
922
|
-
focusEntry(entry);
|
|
923
|
-
refreshPlayback();
|
|
924
|
-
};
|
|
925
|
-
|
|
926
|
-
/** Leaves prefab mode without saving: drops the prefab instance and brings the scene back. */
|
|
927
|
-
const closePrefab = () => {
|
|
928
|
-
if (!prefab) return;
|
|
929
|
-
const {stash} = prefab;
|
|
930
|
-
prefab = null;
|
|
931
|
-
selectionMarker.hide();
|
|
932
|
-
disposePrefabInstances();
|
|
933
|
-
state.sceneEntries = stash.entries;
|
|
934
|
-
setSceneEntries(stash.entries);
|
|
935
|
-
state.visibleRootIds = stash.visible;
|
|
936
|
-
setVisibleRootIds(stash.visible);
|
|
937
|
-
for (const entry of stash.entries) {
|
|
938
|
-
if (stash.visible.includes(entry.root.uniqueId)) {
|
|
939
|
-
setSceneEntryVisible(entry, renderer, true);
|
|
940
|
-
clearFocusFinished(playbackRef.current, entry.root.uniqueId);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
cancelGalleryCameraFocus();
|
|
944
|
-
camera.setTarget(stash.camera.target);
|
|
945
|
-
camera.radius = stash.camera.radius;
|
|
946
|
-
camera.alpha = stash.camera.alpha;
|
|
947
|
-
camera.beta = stash.camera.beta;
|
|
948
|
-
prefabChanged = false;
|
|
949
|
-
setPrefabDirty(false);
|
|
950
|
-
setPrefabInfo(null);
|
|
951
|
-
focusEntry(stash.entries.find((entry) => entry.root === stash.focusedRoot) ?? null, false);
|
|
952
|
-
refreshPlayback();
|
|
953
|
-
};
|
|
954
|
-
|
|
955
|
-
/** Overwrites the open prefab's asset with the edited instance. Scene instances keep theirs. */
|
|
956
|
-
const overwritePrefab = () => {
|
|
957
|
-
const root = state.sceneEntries[0]?.root;
|
|
958
|
-
if (!prefab || !root) return;
|
|
959
|
-
const updated: EffectAsset = {
|
|
960
|
-
...prefab.asset,
|
|
961
|
-
json: JSON.parse(serializeEffectTree(root, prefab.asset.name)),
|
|
962
|
-
};
|
|
963
|
-
const exists = state.assets.some((asset) => asset.id === updated.id);
|
|
964
|
-
commitAssets(
|
|
965
|
-
exists
|
|
966
|
-
? state.assets.map((asset) => (asset.id === updated.id ? updated : asset))
|
|
967
|
-
: [...state.assets, updated]
|
|
968
|
-
);
|
|
969
|
-
persist((store, id) => store.putAssets(id, [toStoredAsset(updated)]));
|
|
970
|
-
prefab.asset = updated;
|
|
971
|
-
prefabChanged = false;
|
|
972
|
-
setPrefabDirty(false);
|
|
973
|
-
showToast(`Prefab “${updated.name}” saved · scene unchanged`);
|
|
974
|
-
};
|
|
975
|
-
|
|
976
|
-
/** Saves the edited prefab as a new asset and keeps editing that one. */
|
|
977
|
-
const savePrefabAsNew = (rawPath: string) => {
|
|
978
|
-
const root = state.sceneEntries[0]?.root;
|
|
979
|
-
if (!prefab || !root) return;
|
|
980
|
-
const asset = createAssetFromRoot(root, rawPath);
|
|
981
|
-
prefab.asset = asset;
|
|
982
|
-
prefabChanged = false;
|
|
983
|
-
setPrefabDirty(false);
|
|
984
|
-
setPrefabInfo({name: asset.name, path: asset.path});
|
|
985
|
-
showToast(`Saved as new prefab “${asset.path.replace(/\.json$/i, '')}”`);
|
|
986
|
-
};
|
|
987
|
-
|
|
988
|
-
// ---- project scene ----------------------------------------------------------------------
|
|
989
|
-
|
|
990
|
-
/** Saves the scene to the project; resolves to false when it could not. */
|
|
991
|
-
const saveScene = async (): Promise<boolean> => {
|
|
992
|
-
const project = props.project;
|
|
993
|
-
if (!project || prefab) return false;
|
|
994
|
-
const visibleIds = new Set(state.visibleRootIds);
|
|
995
|
-
const assetPathById = new Map(state.assets.map((asset) => [asset.id, asset.path]));
|
|
996
|
-
const stored: StoredScene = {
|
|
997
|
-
savedAt: Date.now(),
|
|
998
|
-
entries: state.sceneEntries.map((entry) => ({
|
|
999
|
-
name: entry.name,
|
|
1000
|
-
assetPath: entry.assetId != null ? (assetPathById.get(entry.assetId) ?? null) : null,
|
|
1001
|
-
json: JSON.parse(serializeEffectTree(entry.root, entry.name)),
|
|
1002
|
-
visible: visibleIds.has(entry.root.uniqueId),
|
|
1003
|
-
})),
|
|
1004
|
-
};
|
|
1005
|
-
try {
|
|
1006
|
-
await project.store.saveScene(project.id, stored);
|
|
1007
|
-
} catch (err) {
|
|
1008
|
-
console.error('[Quarks editor] Could not save the scene:', err);
|
|
1009
|
-
showMessage('Save scene', err instanceof Error ? err.message : String(err));
|
|
1010
|
-
return false;
|
|
1011
|
-
}
|
|
1012
|
-
markClean();
|
|
1013
|
-
let infoSeen = true;
|
|
1014
|
-
try {
|
|
1015
|
-
infoSeen = localStorage.getItem(SCENE_SAVE_INFO_KEY) === '1';
|
|
1016
|
-
localStorage.setItem(SCENE_SAVE_INFO_KEY, '1');
|
|
1017
|
-
} catch {
|
|
1018
|
-
// Storage blocked — fall back to the toast.
|
|
1019
|
-
}
|
|
1020
|
-
if (infoSeen) {
|
|
1021
|
-
showToast('Scene saved · assets unchanged');
|
|
1022
|
-
} else {
|
|
1023
|
-
showMessage(
|
|
1024
|
-
'Scene saved',
|
|
1025
|
-
'Saving the scene stores the effects placed in it, with their own edits.\n\n' +
|
|
1026
|
-
'It does not change the assets they came from. To keep an edited effect as an asset, ' +
|
|
1027
|
-
'right-click it in the Scene list → “Save as new asset…”.'
|
|
1028
|
-
);
|
|
1029
|
-
}
|
|
1030
|
-
return true;
|
|
1031
|
-
};
|
|
1032
|
-
|
|
1033
|
-
const loadProject = async (project: NonNullable<EffectEditorHostProps['project']>) => {
|
|
1034
|
-
try {
|
|
1035
|
-
const [storedAssets, storedScene] = await Promise.all([
|
|
1036
|
-
project.store.loadAssets(project.id),
|
|
1037
|
-
project.store.loadScene(project.id),
|
|
1038
|
-
]);
|
|
1039
|
-
const loadedAssets = storedAssets.map((stored) => ({
|
|
1040
|
-
...createEffectAsset(stored.name, stored.path, stored.json),
|
|
1041
|
-
missingFiles: stored.missingFiles,
|
|
1042
|
-
}));
|
|
1043
|
-
commitAssets(loadedAssets);
|
|
1044
|
-
const assetIdByPath = new Map(loadedAssets.map((asset) => [asset.path, asset.id]));
|
|
1045
|
-
|
|
1046
|
-
if (!storedScene) {
|
|
1047
|
-
createDefault();
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
const created: SceneEntry[] = [];
|
|
1051
|
-
const visible: number[] = [];
|
|
1052
|
-
for (const stored of storedScene.entries) {
|
|
1053
|
-
const entry = instantiateSceneEntry(
|
|
1054
|
-
scene,
|
|
1055
|
-
renderer,
|
|
1056
|
-
{id: -1, name: stored.name, path: stored.assetPath ?? '', json: stored.json, missingFiles: []},
|
|
1057
|
-
created
|
|
1058
|
-
);
|
|
1059
|
-
if (!entry) continue;
|
|
1060
|
-
entry.name = stored.name;
|
|
1061
|
-
entry.root.name = stored.name;
|
|
1062
|
-
entry.assetId = stored.assetPath != null ? (assetIdByPath.get(stored.assetPath) ?? null) : null;
|
|
1063
|
-
entry.path = stored.assetPath ?? `_created/${stored.name}.json`;
|
|
1064
|
-
setSceneEntryVisible(entry, renderer, stored.visible);
|
|
1065
|
-
if (stored.visible) visible.push(entry.root.uniqueId);
|
|
1066
|
-
created.push(entry);
|
|
1067
|
-
}
|
|
1068
|
-
commitEntries(created);
|
|
1069
|
-
commitVisible(visible);
|
|
1070
|
-
focusEntry(created[0] ?? null, false);
|
|
1071
|
-
refreshPlayback();
|
|
1072
|
-
} catch (err) {
|
|
1073
|
-
console.error('[Quarks editor] Could not open the project:', err);
|
|
1074
|
-
showMessage('Open project', err instanceof Error ? err.message : String(err));
|
|
1075
|
-
} finally {
|
|
1076
|
-
loadingScene = false;
|
|
1077
|
-
markClean();
|
|
1078
|
-
setProjectLoading(false);
|
|
1079
|
-
}
|
|
1080
|
-
};
|
|
1081
|
-
|
|
1082
|
-
// ---- undo / redo ----------------------------------------------------------------------
|
|
1083
|
-
|
|
1084
|
-
/** Rebuilds the focused entry from a history snapshot, in place in the scene list. */
|
|
1085
|
-
const historyStep = (redo: boolean) => {
|
|
1086
|
-
const current = state.binding;
|
|
1087
|
-
if (!current) {
|
|
1088
|
-
return;
|
|
1089
|
-
}
|
|
1090
|
-
const json = redo ? history.redo() : history.undo();
|
|
1091
|
-
const index = state.sceneEntries.findIndex((entry) => entry.root === current.root);
|
|
1092
|
-
const prev = index >= 0 ? state.sceneEntries[index] : null;
|
|
1093
|
-
if (!json || !prev) {
|
|
1094
|
-
force();
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
const selectedIndex = nodeIndexInEffect(current.root, selectedNodeRef.current);
|
|
1099
|
-
const visible = isVisible(prev);
|
|
1100
|
-
const {root, systems} = parseEffectFromJson(scene, renderer, json, {
|
|
1101
|
-
registerRenderer: true,
|
|
1102
|
-
autoplay: false,
|
|
1103
|
-
});
|
|
1104
|
-
if (systems.length === 0) {
|
|
1105
|
-
disposeSceneEntry({...prev, root, systems}, renderer);
|
|
1106
|
-
force();
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
disposeSceneEntry(prev, renderer);
|
|
1110
|
-
// The snapshot carries the root transform too, so a moved effect moves back.
|
|
1111
|
-
root.parent = null;
|
|
1112
|
-
root.name = prev.name;
|
|
1113
|
-
const nextEntry: SceneEntry = {...prev, root, systems};
|
|
1114
|
-
setSceneEntryVisible(nextEntry, renderer, visible);
|
|
1115
|
-
|
|
1116
|
-
const entries = [...state.sceneEntries];
|
|
1117
|
-
entries[index] = nextEntry;
|
|
1118
|
-
commitEntries(entries);
|
|
1119
|
-
commitVisible(state.visibleRootIds.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id)));
|
|
1120
|
-
if (solo) {
|
|
1121
|
-
if (solo.rootId === prev.root.uniqueId) {
|
|
1122
|
-
solo.rootId = root.uniqueId;
|
|
1123
|
-
setSoloRootId(root.uniqueId);
|
|
1124
|
-
}
|
|
1125
|
-
solo.hidden = solo.hidden.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id));
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const next = bindingFromSceneEntry(nextEntry);
|
|
1129
|
-
history.attach(next);
|
|
1130
|
-
next.subscribe(onBindingChange);
|
|
1131
|
-
state.binding = next;
|
|
1132
|
-
setBinding(next);
|
|
1133
|
-
setSelectedNode(nodeAtIndex(root, selectedIndex));
|
|
1134
|
-
setFocusedRoot(root);
|
|
1135
|
-
playbackRef.current.focusRootId = root.uniqueId;
|
|
1136
|
-
if (visible) {
|
|
1137
|
-
selectionMarker.show(root);
|
|
1138
|
-
}
|
|
1139
|
-
notifyTransformChanged();
|
|
1140
|
-
refreshPlayback();
|
|
1141
|
-
};
|
|
1142
|
-
|
|
1143
|
-
// ---- handle -----------------------------------------------------------------------------
|
|
1144
|
-
|
|
1145
|
-
const makeHandle = (): EffectEditorHostHandle => ({
|
|
1146
|
-
get binding() {
|
|
1147
|
-
const b = state.binding;
|
|
1148
|
-
if (!b) {
|
|
1149
|
-
throw new Error('No effect is focused — add one to the scene first.');
|
|
1150
|
-
}
|
|
1151
|
-
return b;
|
|
1152
|
-
},
|
|
1153
|
-
history,
|
|
1154
|
-
scene,
|
|
1155
|
-
renderer,
|
|
1156
|
-
importJson,
|
|
1157
|
-
importAssets,
|
|
1158
|
-
importGallery: importAssets,
|
|
1159
|
-
exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
/** Adds `json` to Assets and returns its single instance; throws when it has no systems. */
|
|
1163
|
-
const openJson = (json: unknown, name = 'Effect') => {
|
|
1164
|
-
const asset = createEffectAsset(name, `_opened/${name}.json`, json);
|
|
1165
|
-
const entry = instantiateSceneEntry(scene, renderer, asset, state.sceneEntries);
|
|
1166
|
-
if (!entry) {
|
|
1167
|
-
throw new Error('No particle systems found in the JSON.');
|
|
1168
|
-
}
|
|
1169
|
-
asset.name = entry.name;
|
|
1170
|
-
asset.path = `_opened/${entry.name}.json`;
|
|
1171
|
-
commitAssets([...state.assets, asset]);
|
|
1172
|
-
return entry;
|
|
1173
|
-
};
|
|
1174
|
-
|
|
1175
|
-
const importJson = (json: unknown) => {
|
|
1176
|
-
closePrefab();
|
|
1177
|
-
const entry = openJson(json);
|
|
1178
|
-
for (const existing of state.sceneEntries) {
|
|
1179
|
-
disposeSceneEntry(existing, renderer);
|
|
1180
|
-
}
|
|
1181
|
-
historyByRoot.clear();
|
|
1182
|
-
solo = null;
|
|
1183
|
-
setSoloRootId(null);
|
|
1184
|
-
commitEntries([]);
|
|
1185
|
-
commitVisible([]);
|
|
1186
|
-
addEntries([entry], BVector3.Zero());
|
|
1187
|
-
};
|
|
1188
|
-
|
|
1189
|
-
actionsRef.current = {
|
|
1190
|
-
importAssets,
|
|
1191
|
-
focusEntry: (entry) => focusEntry(entry),
|
|
1192
|
-
toggleVisible,
|
|
1193
|
-
toggleSolo,
|
|
1194
|
-
deleteEntry,
|
|
1195
|
-
createDefault,
|
|
1196
|
-
placeDrop,
|
|
1197
|
-
addToScene,
|
|
1198
|
-
deleteAssets,
|
|
1199
|
-
saveEntryAsAsset,
|
|
1200
|
-
saveScene,
|
|
1201
|
-
openPrefab,
|
|
1202
|
-
overwritePrefab,
|
|
1203
|
-
savePrefabAsNew,
|
|
1204
|
-
closePrefab,
|
|
1205
|
-
prefabInstanceCount,
|
|
1206
|
-
historyStep,
|
|
1207
|
-
};
|
|
1208
|
-
|
|
1209
|
-
if (props.project) {
|
|
1210
|
-
void loadProject(props.project);
|
|
1211
|
-
} else if (props.effectJson) {
|
|
1212
|
-
addEntries([openJson(props.effectJson)], BVector3.Zero());
|
|
1213
|
-
} else {
|
|
1214
|
-
createDefault();
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
engine.runRenderLoop(() => {
|
|
1218
|
-
const playback = playbackRef.current;
|
|
1219
|
-
const entries = state.sceneEntries;
|
|
1220
|
-
const visibleIds = new Set(state.visibleRootIds);
|
|
1221
|
-
const focusBinding = state.binding;
|
|
1222
|
-
if (!playback.scrubbing) {
|
|
1223
|
-
let delta = (engine.getDeltaTime() / 1000) * playback.speed;
|
|
1224
|
-
const stepQueued = playback.stepQueued;
|
|
1225
|
-
if (!playback.stagePlaying) {
|
|
1226
|
-
delta = stepQueued ? 1 / 60 : 0;
|
|
1227
|
-
playback.stepQueued = false;
|
|
1228
|
-
}
|
|
1229
|
-
if (delta > 0) {
|
|
1230
|
-
const simulate = hasActivePreview(entries, visibleIds) && (playback.stagePlaying || stepQueued);
|
|
1231
|
-
if (simulate) {
|
|
1232
|
-
renderer.update(delta);
|
|
1233
|
-
let needsRefresh = false;
|
|
1234
|
-
if (pauseFinishedPreviews(entries, visibleIds, playback.focusFinishedByRoot)) {
|
|
1235
|
-
needsRefresh = true;
|
|
1236
|
-
}
|
|
1237
|
-
const focusId = playback.focusRootId ?? focusBinding?.root.uniqueId ?? null;
|
|
1238
|
-
if (
|
|
1239
|
-
focusId != null &&
|
|
1240
|
-
focusBinding &&
|
|
1241
|
-
playback.stagePlaying &&
|
|
1242
|
-
!playback.focusFinishedByRoot.has(focusId) &&
|
|
1243
|
-
!focusTimelineMetaRef.current.anyLooping &&
|
|
1244
|
-
QuarksUtil.isEffectFinished(focusBinding.root)
|
|
1245
|
-
) {
|
|
1246
|
-
const settleAt = getFocusElapsed(playback, focusId);
|
|
1247
|
-
scrubFocusToSettled(focusBinding, renderer, settleAt);
|
|
1248
|
-
markFocusFinished(playback, focusId);
|
|
1249
|
-
pauseFocus(focusBinding);
|
|
1250
|
-
needsRefresh = true;
|
|
1251
|
-
} else if (
|
|
1252
|
-
focusId != null &&
|
|
1253
|
-
playback.stagePlaying &&
|
|
1254
|
-
!playback.focusFinishedByRoot.has(focusId)
|
|
1255
|
-
) {
|
|
1256
|
-
const prev = getFocusElapsed(playback, focusId);
|
|
1257
|
-
setFocusElapsed(playback, focusId, prev + delta);
|
|
1258
|
-
}
|
|
1259
|
-
if (needsRefresh) {
|
|
1260
|
-
renderer.refreshBatches();
|
|
1261
|
-
}
|
|
1262
|
-
logRenderMismatch(renderer, countGalleryParticles(entries, visibleIds));
|
|
1263
|
-
} else if (entries.length > 0) {
|
|
1264
|
-
renderer.refreshBatches();
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
scene.render();
|
|
1269
|
-
selectionMarker.follow();
|
|
1270
|
-
viewportPicker.follow();
|
|
1271
|
-
if (focusBinding) {
|
|
1272
|
-
emitterWireframes.sync(
|
|
1273
|
-
[focusBinding.system, ...focusBinding.subSystems],
|
|
1274
|
-
`${focusBinding.root.uniqueId}:${focusBinding.getRevision()}`
|
|
1275
|
-
);
|
|
1276
|
-
}
|
|
1277
|
-
const pickKey = entries
|
|
1278
|
-
.map((entry) => `${entry.root.uniqueId}:${visibleIds.has(entry.root.uniqueId) ? 1 : 0}`)
|
|
1279
|
-
.join('|');
|
|
1280
|
-
viewportPicker.sync(entries, null, `${pickKey}::${focusBinding?.root.uniqueId ?? 'none'}`);
|
|
1281
|
-
|
|
1282
|
-
if (fpsRef.current) {
|
|
1283
|
-
fpsRef.current.textContent = `${Math.round(engine.getFps())} FPS`;
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
if (
|
|
1287
|
-
focusBinding &&
|
|
1288
|
-
(focusBinding.root.uniqueId !== focusTimelineMetaRef.current.rootId ||
|
|
1289
|
-
focusBinding.getRevision() !== focusTimelineMetaRef.current.revision)
|
|
1290
|
-
) {
|
|
1291
|
-
const rows = computeTimelineRows(focusBinding);
|
|
1292
|
-
focusTimelineMetaRef.current = {
|
|
1293
|
-
rootId: focusBinding.root.uniqueId,
|
|
1294
|
-
revision: focusBinding.getRevision(),
|
|
1295
|
-
span: computeTimelineSpan(rows),
|
|
1296
|
-
anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
|
|
1297
|
-
};
|
|
1298
|
-
}
|
|
1299
|
-
if (counterRef.current) {
|
|
1300
|
-
const count = countGalleryParticles(entries, visibleIds);
|
|
1301
|
-
const focused = entries.find((entry) => entry.root === focusBinding?.root);
|
|
1302
|
-
const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
|
|
1303
|
-
const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
|
|
1304
|
-
const focusEnded = focusId != null && isFocusFinished(playback, focusId);
|
|
1305
|
-
const meta = focusTimelineMetaRef.current;
|
|
1306
|
-
const timeLabel = meta.anyLooping
|
|
1307
|
-
? `${focusElapsed.toFixed(1)}s ⟳`
|
|
1308
|
-
: focusEnded
|
|
1309
|
-
? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
|
|
1310
|
-
: `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
|
|
1311
|
-
counterRef.current.textContent = focused ? `${timeLabel} · ${count} particles` : `${count} particles`;
|
|
1312
|
-
}
|
|
1313
|
-
});
|
|
1314
|
-
|
|
1315
|
-
const resizeObserver = new ResizeObserver(() => engine.resize());
|
|
1316
|
-
resizeObserver.observe(canvasRef.current!);
|
|
1317
|
-
const onKey = (e: KeyboardEvent) => {
|
|
1318
|
-
const key = e.key.toLowerCase();
|
|
1319
|
-
if ((e.ctrlKey || e.metaKey) && key === 's') {
|
|
1320
|
-
e.preventDefault();
|
|
1321
|
-
if (prefab) {
|
|
1322
|
-
setPrefabDialog('overwrite');
|
|
1323
|
-
} else {
|
|
1324
|
-
void saveScene();
|
|
1325
|
-
}
|
|
1326
|
-
return;
|
|
1327
|
-
}
|
|
1328
|
-
if (e.code === 'Space' && (e.ctrlKey || e.metaKey)) {
|
|
1329
|
-
e.preventDefault();
|
|
1330
|
-
setDrawerOpen((open) => !open);
|
|
1331
|
-
return;
|
|
1332
|
-
}
|
|
1333
|
-
const target = e.target as HTMLElement | null;
|
|
1334
|
-
if (e.code === 'Space' && !isTextInput(target) && target?.tagName !== 'BUTTON') {
|
|
1335
|
-
e.preventDefault();
|
|
1336
|
-
const playback = playbackRef.current;
|
|
1337
|
-
toggleStagePlayback(state.binding, scene, playback, playback.stagePlaying, setStagePlaying);
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
if (e.key === 'Escape') {
|
|
1341
|
-
closeFloatingDrawer();
|
|
1342
|
-
}
|
|
1343
|
-
if ((e.ctrlKey || e.metaKey) && (key === 'z' || key === 'y')) {
|
|
1344
|
-
if (isTextInput(e.target) && !isInspectorValueControl(e.target)) {
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
e.preventDefault();
|
|
1348
|
-
// Leave the field first so it shows the restored value instead of its draft text.
|
|
1349
|
-
(e.target as HTMLElement | null)?.blur?.();
|
|
1350
|
-
historyStep(key === 'y' || e.shiftKey);
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
if (e.key === 'Delete' && !isTextInput(e.target)) {
|
|
1354
|
-
const entry = state.sceneEntries.find((item) => item.root === state.binding?.root);
|
|
1355
|
-
if (entry) {
|
|
1356
|
-
e.preventDefault();
|
|
1357
|
-
deleteEntry(entry);
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
};
|
|
1361
|
-
window.addEventListener('keydown', onKey);
|
|
1362
|
-
return () => {
|
|
1363
|
-
unsubscribeTransform();
|
|
1364
|
-
window.clearTimeout(toastTimerRef.current);
|
|
1365
|
-
resizeObserver.disconnect();
|
|
1366
|
-
window.removeEventListener('keydown', onKey);
|
|
1367
|
-
window.removeEventListener('dragend', onWindowDragEnd);
|
|
1368
|
-
clearAssetDragPreview();
|
|
1369
|
-
cancelGalleryCameraFocus();
|
|
1370
|
-
dropMarker.dispose();
|
|
1371
|
-
dropMarkerRef.current = null;
|
|
1372
|
-
selectionMarker.dispose();
|
|
1373
|
-
selectionMarkerRef.current = null;
|
|
1374
|
-
emitterWireframes.dispose();
|
|
1375
|
-
emitterWireframesRef.current = null;
|
|
1376
|
-
viewportPicker.dispose();
|
|
1377
|
-
viewportPickerRef.current = null;
|
|
1378
|
-
canvasRef.current?.removeEventListener('pointerleave', handleViewportPointerLeave);
|
|
1379
|
-
scene.onPointerObservable.remove(pointerObserver);
|
|
1380
|
-
gizmoManager.dispose();
|
|
1381
|
-
gizmoUtilityLayer.dispose();
|
|
1382
|
-
engine.dispose();
|
|
1383
|
-
};
|
|
1384
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1385
|
-
}, []);
|
|
1386
|
-
|
|
1387
|
-
useEffect(() => {
|
|
1388
|
-
playbackRef.current.stagePlaying = stagePlaying;
|
|
1389
|
-
const snap = stateRef.current;
|
|
1390
|
-
if (!snap) {
|
|
1391
|
-
return;
|
|
1392
|
-
}
|
|
1393
|
-
applyGalleryPlayback(
|
|
1394
|
-
snap.sceneEntries,
|
|
1395
|
-
new Set(visibleRootIds),
|
|
1396
|
-
stagePlaying,
|
|
1397
|
-
playbackRef.current.focusFinishedByRoot
|
|
1398
|
-
);
|
|
1399
|
-
if (!stagePlaying) {
|
|
1400
|
-
snap.renderer.refreshBatches();
|
|
1401
|
-
}
|
|
1402
|
-
}, [stagePlaying, visibleRootIds, sceneEntries]);
|
|
1403
|
-
|
|
1404
|
-
useEffect(() => {
|
|
1405
|
-
emitterWireframesRef.current?.setVisible(showEmitterWireframes);
|
|
1406
|
-
}, [showEmitterWireframes]);
|
|
1407
|
-
|
|
1408
|
-
// The floating drawer is opaque, so render the scene only in the part of the viewport above
|
|
1409
|
-
// it: the view — and every "frame this effect" camera move — re-centres on what is visible.
|
|
1410
|
-
useEffect(() => {
|
|
1411
|
-
const update = () => {
|
|
1412
|
-
const camera = stateRef.current?.camera;
|
|
1413
|
-
const canvas = canvasRef.current;
|
|
1414
|
-
const area = mainAreaRef.current;
|
|
1415
|
-
if (!camera || !canvas || !area) return;
|
|
1416
|
-
const canvasRect = canvas.getBoundingClientRect();
|
|
1417
|
-
const drawerTop = area.getBoundingClientRect().bottom - DRAWER_HEIGHT;
|
|
1418
|
-
const covered =
|
|
1419
|
-
drawerOpen && !drawerPinned
|
|
1420
|
-
? Math.max(0, Math.min(canvasRect.height * 0.8, canvasRect.bottom - drawerTop))
|
|
1421
|
-
: 0;
|
|
1422
|
-
const fraction = canvasRect.height > 0 ? covered / canvasRect.height : 0;
|
|
1423
|
-
camera.viewport = new Viewport(0, fraction, 1, 1 - fraction);
|
|
1424
|
-
setViewportInset(covered);
|
|
1425
|
-
};
|
|
1426
|
-
update();
|
|
1427
|
-
window.addEventListener('resize', update);
|
|
1428
|
-
return () => window.removeEventListener('resize', update);
|
|
1429
|
-
}, [drawerOpen, drawerPinned, timelineCollapsed]);
|
|
1430
|
-
|
|
1431
|
-
useEffect(() => {
|
|
1432
|
-
if (!props.project || !sceneDirty) return;
|
|
1433
|
-
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
1434
|
-
e.preventDefault();
|
|
1435
|
-
e.returnValue = '';
|
|
1436
|
-
};
|
|
1437
|
-
window.addEventListener('beforeunload', onBeforeUnload);
|
|
1438
|
-
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
1439
|
-
}, [props.project, sceneDirty]);
|
|
1440
|
-
|
|
1441
336
|
const requestPrefabExit = () => {
|
|
1442
337
|
if (prefabDirty) {
|
|
1443
338
|
setPrefabDialog('exit');
|
|
1444
339
|
} else {
|
|
1445
|
-
|
|
340
|
+
session?.closePrefab();
|
|
1446
341
|
}
|
|
1447
342
|
};
|
|
1448
343
|
|
|
@@ -1458,119 +353,40 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
1458
353
|
}
|
|
1459
354
|
};
|
|
1460
355
|
|
|
1461
|
-
useEffect(() => {
|
|
1462
|
-
const gm = gizmoManagerRef.current;
|
|
1463
|
-
if (!gm) return;
|
|
1464
|
-
gm.positionGizmoEnabled = gizmoMode === 'position';
|
|
1465
|
-
gm.rotationGizmoEnabled = gizmoMode === 'rotation';
|
|
1466
|
-
gm.scaleGizmoEnabled = gizmoMode === 'scale';
|
|
1467
|
-
const activeGizmo =
|
|
1468
|
-
gizmoMode === 'position'
|
|
1469
|
-
? gm.gizmos.positionGizmo
|
|
1470
|
-
: gizmoMode === 'rotation'
|
|
1471
|
-
? gm.gizmos.rotationGizmo
|
|
1472
|
-
: gizmoMode === 'scale'
|
|
1473
|
-
? gm.gizmos.scaleGizmo
|
|
1474
|
-
: null;
|
|
1475
|
-
let dragRaf = 0;
|
|
1476
|
-
const scheduleTransformRefresh = () => {
|
|
1477
|
-
if (dragRaf !== 0) {
|
|
1478
|
-
return;
|
|
1479
|
-
}
|
|
1480
|
-
dragRaf = requestAnimationFrame(() => {
|
|
1481
|
-
dragRaf = 0;
|
|
1482
|
-
notifyTransformChanged();
|
|
1483
|
-
});
|
|
1484
|
-
};
|
|
1485
|
-
// One gizmo drag = one undo step: snapshot at drag start, refresh the UI at the end.
|
|
1486
|
-
const dragStartObserver = activeGizmo?.onDragStartObservable.add(() =>
|
|
1487
|
-
stateRef.current?.binding?.onBeforeChange?.()
|
|
1488
|
-
);
|
|
1489
|
-
const dragObserver = activeGizmo?.onDragObservable.add(scheduleTransformRefresh);
|
|
1490
|
-
const dragEndObserver = activeGizmo?.onDragEndObservable.add(() => {
|
|
1491
|
-
notifyTransformChanged();
|
|
1492
|
-
stateRef.current?.binding?.markChanged();
|
|
1493
|
-
});
|
|
1494
|
-
return () => {
|
|
1495
|
-
if (dragRaf !== 0) {
|
|
1496
|
-
cancelAnimationFrame(dragRaf);
|
|
1497
|
-
}
|
|
1498
|
-
if (activeGizmo && dragStartObserver) {
|
|
1499
|
-
activeGizmo.onDragStartObservable.remove(dragStartObserver);
|
|
1500
|
-
}
|
|
1501
|
-
if (activeGizmo && dragObserver) {
|
|
1502
|
-
activeGizmo.onDragObservable.remove(dragObserver);
|
|
1503
|
-
}
|
|
1504
|
-
if (activeGizmo && dragEndObserver) {
|
|
1505
|
-
activeGizmo.onDragEndObservable.remove(dragEndObserver);
|
|
1506
|
-
}
|
|
1507
|
-
};
|
|
1508
|
-
}, [gizmoMode]);
|
|
1509
|
-
|
|
1510
|
-
useEffect(() => {
|
|
1511
|
-
const gm = gizmoManagerRef.current;
|
|
1512
|
-
if (!gm) return;
|
|
1513
|
-
if (selectedNode && selectedNode.isDisposed()) {
|
|
1514
|
-
setSelectedNode(stateRef.current?.binding?.root ?? null);
|
|
1515
|
-
return;
|
|
1516
|
-
}
|
|
1517
|
-
if (isAssetsLoading) {
|
|
1518
|
-
return;
|
|
1519
|
-
}
|
|
1520
|
-
const target =
|
|
1521
|
-
selectedNode && !selectedNode.isDisposed()
|
|
1522
|
-
? selectedNode
|
|
1523
|
-
: binding?.root && !binding.root.isDisposed()
|
|
1524
|
-
? binding.root
|
|
1525
|
-
: null;
|
|
1526
|
-
gm.attachToNode(target);
|
|
1527
|
-
}, [selectedNode, binding, isAssetsLoading]);
|
|
1528
|
-
|
|
1529
|
-
const state = stateRef.current;
|
|
1530
|
-
const gizmoNode =
|
|
1531
|
-
selectedNode && !selectedNode.isDisposed()
|
|
1532
|
-
? selectedNode
|
|
1533
|
-
: binding?.root && !binding.root.isDisposed()
|
|
1534
|
-
? binding.root
|
|
1535
|
-
: null;
|
|
1536
|
-
|
|
1537
356
|
const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
|
|
1538
|
-
const activeSystem = binding ? (allSystems.find((s) => s.emitter ===
|
|
357
|
+
const activeSystem = binding ? (allSystems.find((s) => s.emitter === liveSelection) ?? binding.system) : null;
|
|
1539
358
|
// The inspector is the expensive part of a selection change (every module re-renders for the
|
|
1540
359
|
// new system). It renders from deferred values, so the click, tree highlight and gizmo update
|
|
1541
360
|
// at once and the inspector catches up in an interruptible render instead of stalling frames.
|
|
1542
361
|
const deferredBinding = useDeferredValue(binding);
|
|
1543
|
-
const deferredNode = useDeferredValue(
|
|
362
|
+
const deferredNode = useDeferredValue(liveSelection);
|
|
1544
363
|
const inspectorBinding = deferredBinding && !deferredBinding.root.isDisposed() ? deferredBinding : binding;
|
|
1545
364
|
const inspectorNode =
|
|
1546
365
|
deferredNode && !deferredNode.isDisposed() && inspectorBinding === deferredBinding
|
|
1547
366
|
? deferredNode
|
|
1548
367
|
: (inspectorBinding?.root ?? null);
|
|
1549
|
-
const inspectorSystems = inspectorBinding ? [inspectorBinding.system, ...inspectorBinding.subSystems] : [];
|
|
1550
368
|
const inspectorSystem = inspectorBinding
|
|
1551
|
-
? (
|
|
369
|
+
? ([inspectorBinding.system, ...inspectorBinding.subSystems].find((s) => s.emitter === inspectorNode) ??
|
|
370
|
+
inspectorBinding.system)
|
|
1552
371
|
: null;
|
|
1553
372
|
const visibleRootIdSet = useMemo(() => new Set(visibleRootIds), [visibleRootIds]);
|
|
1554
373
|
const instanceCounts = useMemo(() => countInstancesByAsset(sceneEntries), [sceneEntries]);
|
|
1555
|
-
const showTimeline = Boolean(binding && activeSystem && state);
|
|
1556
374
|
const timelinePlaybackEnabled = !focusedRoot || visibleRootIdSet.has(focusedRoot.uniqueId);
|
|
375
|
+
const {resolveTexture, resolveGeometry} = props;
|
|
1557
376
|
const resolveTextureForEditor = useCallback(
|
|
1558
|
-
(url: string) =>
|
|
1559
|
-
|
|
1560
|
-
return scene && props.resolveTexture ? props.resolveTexture(url, scene) : undefined;
|
|
1561
|
-
},
|
|
1562
|
-
[props.resolveTexture]
|
|
377
|
+
(url: string) => (session && resolveTexture ? resolveTexture(url, session.scene) : undefined),
|
|
378
|
+
[session, resolveTexture]
|
|
1563
379
|
);
|
|
1564
380
|
const resolveGeometryForEditor = useCallback(
|
|
1565
381
|
(file: File) => {
|
|
1566
|
-
|
|
1567
|
-
if (!scene || !props.resolveGeometry) {
|
|
382
|
+
if (!session || !resolveGeometry) {
|
|
1568
383
|
return Promise.reject(new Error('Geometry loader is not available.'));
|
|
1569
384
|
}
|
|
1570
|
-
return
|
|
385
|
+
return resolveGeometry(file, session.scene);
|
|
1571
386
|
},
|
|
1572
|
-
[
|
|
387
|
+
[session, resolveGeometry]
|
|
1573
388
|
);
|
|
389
|
+
const isAssetDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(ASSET_DRAG_MIME);
|
|
1574
390
|
|
|
1575
391
|
return (
|
|
1576
392
|
<div
|
|
@@ -1598,265 +414,45 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
1598
414
|
}}
|
|
1599
415
|
>
|
|
1600
416
|
<div style={{display: 'flex', flex: 1, minHeight: 0}}>
|
|
1601
|
-
<
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
{
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
title="Back to projects"
|
|
1627
|
-
style={{...dockIconButtonStyle, width: 26, height: 26}}
|
|
1628
|
-
onClick={requestExitProject}
|
|
1629
|
-
>
|
|
1630
|
-
<ArrowLeftIcon style={iconStyle(13)} />
|
|
1631
|
-
</button>
|
|
1632
|
-
)}
|
|
1633
|
-
<span
|
|
1634
|
-
title={props.project.name}
|
|
1635
|
-
style={{
|
|
1636
|
-
flex: 1,
|
|
1637
|
-
minWidth: 0,
|
|
1638
|
-
fontSize: 13,
|
|
1639
|
-
fontWeight: 600,
|
|
1640
|
-
overflow: 'hidden',
|
|
1641
|
-
textOverflow: 'ellipsis',
|
|
1642
|
-
whiteSpace: 'nowrap',
|
|
1643
|
-
}}
|
|
1644
|
-
>
|
|
1645
|
-
{props.project.name}
|
|
1646
|
-
{sceneDirty ? ' *' : ''}
|
|
1647
|
-
</span>
|
|
1648
|
-
<button
|
|
1649
|
-
type="button"
|
|
1650
|
-
className="qe-hover"
|
|
1651
|
-
title="Save the scene (Ctrl+S) — assets are not changed"
|
|
1652
|
-
disabled={projectLoading}
|
|
1653
|
-
style={{
|
|
1654
|
-
...buttonStyle,
|
|
1655
|
-
padding: '4px 12px',
|
|
1656
|
-
...(sceneDirty
|
|
1657
|
-
? {
|
|
1658
|
-
background: theme.accent,
|
|
1659
|
-
color: '#0a1128',
|
|
1660
|
-
border: `1px solid ${theme.accent}`,
|
|
1661
|
-
}
|
|
1662
|
-
: {}),
|
|
1663
|
-
}}
|
|
1664
|
-
onClick={() =>
|
|
1665
|
-
prefabInfo ? setPrefabDialog('overwrite') : void actionsRef.current.saveScene()
|
|
1666
|
-
}
|
|
1667
|
-
>
|
|
1668
|
-
Save
|
|
1669
|
-
</button>
|
|
1670
|
-
</div>
|
|
1671
|
-
)}
|
|
1672
|
-
<div style={{padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}`}}>
|
|
1673
|
-
<div style={{fontSize: 14, fontWeight: 600}}>
|
|
1674
|
-
{prefabInfo
|
|
1675
|
-
? 'Prefab'
|
|
1676
|
-
: `Scene${sceneEntries.length > 0 ? ` · ${sceneEntries.length}` : ''}`}
|
|
1677
|
-
</div>
|
|
1678
|
-
<div style={{fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4}}>
|
|
1679
|
-
{prefabInfo
|
|
1680
|
-
? 'The scene is set aside while you edit this asset'
|
|
1681
|
-
: 'Effects in the viewport · click to edit · eye shows/hides'}
|
|
1682
|
-
</div>
|
|
1683
|
-
</div>
|
|
1684
|
-
<div
|
|
1685
|
-
style={{
|
|
1686
|
-
flex: 1,
|
|
1687
|
-
minHeight: 0,
|
|
1688
|
-
overflow: 'hidden',
|
|
1689
|
-
padding: '8px 12px 14px',
|
|
1690
|
-
display: 'flex',
|
|
1691
|
-
flexDirection: 'column',
|
|
1692
|
-
}}
|
|
1693
|
-
>
|
|
1694
|
-
{prefabInfo ? (
|
|
1695
|
-
<div style={{display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0}}>
|
|
1696
|
-
<div
|
|
1697
|
-
style={{
|
|
1698
|
-
display: 'flex',
|
|
1699
|
-
flexDirection: 'column',
|
|
1700
|
-
gap: 10,
|
|
1701
|
-
padding: 12,
|
|
1702
|
-
border: `1px solid ${theme.borderActive}`,
|
|
1703
|
-
borderRadius: 8,
|
|
1704
|
-
background: 'rgba(120, 165, 255, 0.08)',
|
|
1705
|
-
fontSize: 12,
|
|
1706
|
-
color: theme.textDim,
|
|
1707
|
-
lineHeight: 1.5,
|
|
1708
|
-
}}
|
|
1709
|
-
>
|
|
1710
|
-
<div style={{fontSize: 13, fontWeight: 600, color: theme.text}}>
|
|
1711
|
-
{prefabInfo.name}
|
|
1712
|
-
{prefabDirty ? ' *' : ''}
|
|
1713
|
-
</div>
|
|
1714
|
-
<div style={{wordBreak: 'break-all'}}>{prefabInfo.path}</div>
|
|
1715
|
-
<div>
|
|
1716
|
-
Save (Ctrl+S) overwrites this asset. Effects already placed in the scene
|
|
1717
|
-
keep their own copy and do not change.
|
|
1718
|
-
</div>
|
|
1719
|
-
<div style={{display: 'flex', gap: 6}}>
|
|
1720
|
-
<button
|
|
1721
|
-
type="button"
|
|
1722
|
-
className="qe-hover"
|
|
1723
|
-
style={buttonStyle}
|
|
1724
|
-
onClick={requestPrefabExit}
|
|
1725
|
-
>
|
|
1726
|
-
← Scene
|
|
1727
|
-
</button>
|
|
1728
|
-
<button
|
|
1729
|
-
type="button"
|
|
1730
|
-
className="qe-hover"
|
|
1731
|
-
style={buttonStyle}
|
|
1732
|
-
onClick={() => setPrefabDialog('overwrite')}
|
|
1733
|
-
>
|
|
1734
|
-
Save prefab
|
|
1735
|
-
</button>
|
|
1736
|
-
</div>
|
|
1737
|
-
</div>
|
|
1738
|
-
{sceneEntries[0] && (
|
|
1739
|
-
<div
|
|
1740
|
-
style={{
|
|
1741
|
-
flex: 1,
|
|
1742
|
-
minHeight: 0,
|
|
1743
|
-
overflowY: 'auto',
|
|
1744
|
-
border: `1px solid ${theme.border}`,
|
|
1745
|
-
borderRadius: 8,
|
|
1746
|
-
background: theme.sectionBg,
|
|
1747
|
-
padding: '4px 0',
|
|
1748
|
-
}}
|
|
1749
|
-
>
|
|
1750
|
-
<EffectTreeView
|
|
1751
|
-
includeRoot
|
|
1752
|
-
root={sceneEntries[0].root}
|
|
1753
|
-
selectedNode={selectedNode}
|
|
1754
|
-
onSelect={setSelectedNode}
|
|
1755
|
-
/>
|
|
1756
|
-
</div>
|
|
1757
|
-
)}
|
|
1758
|
-
</div>
|
|
1759
|
-
) : (
|
|
1760
|
-
<ScenePanel
|
|
1761
|
-
entries={sceneEntries}
|
|
1762
|
-
focusedRoot={focusedRoot}
|
|
1763
|
-
visibleRootIds={visibleRootIdSet}
|
|
1764
|
-
onFocus={(entry) => actionsRef.current.focusEntry(entry)}
|
|
1765
|
-
onToggleVisible={(entry) => actionsRef.current.toggleVisible(entry)}
|
|
1766
|
-
soloRootId={soloRootId}
|
|
1767
|
-
onToggleSolo={(entry) => actionsRef.current.toggleSolo(entry)}
|
|
1768
|
-
onSaveAsAsset={(entry) => setSaveAsTarget(entry)}
|
|
1769
|
-
onDelete={(entry) => actionsRef.current.deleteEntry(entry)}
|
|
1770
|
-
onCreateDefault={() => actionsRef.current.createDefault()}
|
|
1771
|
-
selectedNode={selectedNode}
|
|
1772
|
-
onSelectNode={(entry, node) => {
|
|
1773
|
-
if (stateRef.current?.binding?.root !== entry.root) {
|
|
1774
|
-
actionsRef.current.focusEntry(entry);
|
|
1775
|
-
}
|
|
1776
|
-
setSelectedNode(node);
|
|
1777
|
-
}}
|
|
1778
|
-
/>
|
|
1779
|
-
)}
|
|
1780
|
-
</div>
|
|
1781
|
-
</aside>
|
|
1782
|
-
<div
|
|
1783
|
-
style={{position: 'relative', flex: 1, minWidth: 0}}
|
|
1784
|
-
onDragEnter={(e) => {
|
|
1785
|
-
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME)) return;
|
|
1786
|
-
assetDragDepthRef.current += 1;
|
|
1787
|
-
}}
|
|
1788
|
-
onDragLeave={(e) => {
|
|
1789
|
-
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME)) return;
|
|
1790
|
-
assetDragDepthRef.current -= 1;
|
|
1791
|
-
if (assetDragDepthRef.current <= 0) {
|
|
1792
|
-
assetDragDepthRef.current = 0;
|
|
1793
|
-
dropMarkerRef.current?.hide();
|
|
1794
|
-
setAssetDragHover(null);
|
|
417
|
+
<SceneSidebar
|
|
418
|
+
projectName={props.project?.name}
|
|
419
|
+
canExitProject={Boolean(props.onExitProject)}
|
|
420
|
+
sceneDirty={sceneDirty}
|
|
421
|
+
projectLoading={projectLoading}
|
|
422
|
+
prefabInfo={prefabInfo}
|
|
423
|
+
prefabDirty={prefabDirty}
|
|
424
|
+
entries={sceneEntries}
|
|
425
|
+
focusedRoot={focusedRoot}
|
|
426
|
+
visibleRootIds={visibleRootIdSet}
|
|
427
|
+
soloRootId={soloRootId}
|
|
428
|
+
selectedNode={liveSelection}
|
|
429
|
+
onExitProject={requestExitProject}
|
|
430
|
+
onSave={savePrefabOrScene}
|
|
431
|
+
onExitPrefab={requestPrefabExit}
|
|
432
|
+
onSavePrefab={() => setPrefabDialog('overwrite')}
|
|
433
|
+
onFocus={(entry) => session?.focusEntry(entry)}
|
|
434
|
+
onToggleVisible={(entry) => session?.toggleVisible(entry)}
|
|
435
|
+
onToggleSolo={(entry) => session?.toggleSolo(entry)}
|
|
436
|
+
onSaveAsAsset={setSaveAsTarget}
|
|
437
|
+
onDelete={(entry) => session?.deleteEntry(entry)}
|
|
438
|
+
onCreateDefault={() => session?.createDefault()}
|
|
439
|
+
onSelectNode={(entry, node) => {
|
|
440
|
+
if (entry && session?.binding?.root !== entry.root) {
|
|
441
|
+
session?.focusEntry(entry);
|
|
1795
442
|
}
|
|
443
|
+
setSelectedNode(node);
|
|
1796
444
|
}}
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
if (!payload || !snap) return;
|
|
1805
|
-
|
|
1806
|
-
const described = describeAssetDrag(snap.assets, payload);
|
|
1807
|
-
if (described.assets.length === 0) return;
|
|
1808
|
-
|
|
1809
|
-
const position = pickGroundPosition(snap.scene, e.clientX, e.clientY);
|
|
1810
|
-
dropMarkerRef.current?.show(position, described.assets.length);
|
|
1811
|
-
setAssetDragHover({label: described.label, count: described.assets.length});
|
|
1812
|
-
}}
|
|
1813
|
-
onDrop={(e) => {
|
|
1814
|
-
if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME)) return;
|
|
1815
|
-
e.preventDefault();
|
|
1816
|
-
actionsRef.current.placeDrop(e.clientX, e.clientY, e.dataTransfer);
|
|
1817
|
-
}}
|
|
1818
|
-
>
|
|
1819
|
-
<canvas
|
|
1820
|
-
ref={canvasRef}
|
|
1821
|
-
style={{
|
|
1822
|
-
width: '100%',
|
|
1823
|
-
height: '100%',
|
|
1824
|
-
display: 'block',
|
|
1825
|
-
touchAction: 'none',
|
|
1826
|
-
outline: 'none',
|
|
1827
|
-
}}
|
|
1828
|
-
/>
|
|
1829
|
-
<div
|
|
1830
|
-
style={{
|
|
1831
|
-
position: 'absolute',
|
|
1832
|
-
bottom: 12 + viewportInset,
|
|
1833
|
-
left: 12,
|
|
1834
|
-
background: theme.panelBg,
|
|
1835
|
-
border: `1px solid ${theme.border}`,
|
|
1836
|
-
borderRadius: 8,
|
|
1837
|
-
padding: '4px 10px',
|
|
1838
|
-
fontSize: 12,
|
|
1839
|
-
color: theme.textDim,
|
|
1840
|
-
fontVariantNumeric: 'tabular-nums',
|
|
1841
|
-
pointerEvents: 'none',
|
|
1842
|
-
fontFamily: theme.font,
|
|
1843
|
-
}}
|
|
1844
|
-
>
|
|
1845
|
-
<span ref={fpsRef}>— FPS</span>
|
|
1846
|
-
</div>
|
|
1847
|
-
{state && (
|
|
1848
|
-
<div
|
|
1849
|
-
style={{
|
|
1850
|
-
position: 'absolute',
|
|
1851
|
-
bottom: 12 + viewportInset,
|
|
1852
|
-
left: '50%',
|
|
1853
|
-
transform: 'translateX(-50%)',
|
|
1854
|
-
}}
|
|
1855
|
-
>
|
|
445
|
+
/>
|
|
446
|
+
<ViewportPane
|
|
447
|
+
canvasRef={canvasRef}
|
|
448
|
+
fpsRef={fpsRef}
|
|
449
|
+
inset={viewportInset}
|
|
450
|
+
transport={
|
|
451
|
+
session && (
|
|
1856
452
|
<TransportBar
|
|
1857
453
|
binding={binding}
|
|
1858
|
-
scene={
|
|
1859
|
-
renderer={
|
|
454
|
+
scene={session.scene}
|
|
455
|
+
renderer={session.renderer}
|
|
1860
456
|
playbackRef={playbackRef}
|
|
1861
457
|
counterRef={counterRef}
|
|
1862
458
|
stagePlaying={stagePlaying}
|
|
@@ -1865,697 +461,162 @@ export function EffectEditorHost(props: EffectEditorHostProps) {
|
|
|
1865
461
|
setSpeed={setSpeed}
|
|
1866
462
|
enabled={timelinePlaybackEnabled}
|
|
1867
463
|
/>
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
{assetDragHover
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
background: 'rgba(7, 11, 22, 0.72)',
|
|
1910
|
-
pointerEvents: 'none',
|
|
1911
|
-
}}
|
|
1912
|
-
>
|
|
1913
|
-
<div
|
|
1914
|
-
style={{
|
|
1915
|
-
background: theme.panelBg,
|
|
1916
|
-
border: `1px solid ${theme.border}`,
|
|
1917
|
-
borderRadius: 10,
|
|
1918
|
-
padding: '16px 22px',
|
|
1919
|
-
textAlign: 'center',
|
|
1920
|
-
}}
|
|
1921
|
-
>
|
|
1922
|
-
<div style={{fontWeight: 600, marginBottom: 6}}>Importing assets…</div>
|
|
1923
|
-
<div style={{fontSize: 13, color: theme.textDim}}>
|
|
1924
|
-
{assetsLoading.done} / {assetsLoading.total || '…'} files
|
|
1925
|
-
{assetsLoading.failed > 0 ? ` · ${assetsLoading.failed} skipped` : ''}
|
|
1926
|
-
</div>
|
|
1927
|
-
</div>
|
|
1928
|
-
</div>
|
|
1929
|
-
)}
|
|
1930
|
-
{prefabInfo && (
|
|
1931
|
-
<div
|
|
1932
|
-
style={{
|
|
1933
|
-
position: 'absolute',
|
|
1934
|
-
top: 12,
|
|
1935
|
-
left: '50%',
|
|
1936
|
-
transform: 'translateX(-50%)',
|
|
1937
|
-
display: 'flex',
|
|
1938
|
-
alignItems: 'center',
|
|
1939
|
-
gap: 8,
|
|
1940
|
-
padding: '4px 4px 4px 6px',
|
|
1941
|
-
background: theme.panelBg,
|
|
1942
|
-
border: `1px solid ${theme.borderActive}`,
|
|
1943
|
-
borderRadius: 8,
|
|
1944
|
-
fontSize: 12.5,
|
|
1945
|
-
zIndex: 5,
|
|
1946
|
-
}}
|
|
1947
|
-
>
|
|
1948
|
-
<button
|
|
1949
|
-
type="button"
|
|
1950
|
-
className="qe-hover"
|
|
1951
|
-
title="Back to the scene"
|
|
1952
|
-
style={{...buttonStyle, padding: '3px 10px'}}
|
|
1953
|
-
onClick={requestPrefabExit}
|
|
1954
|
-
>
|
|
1955
|
-
← Scene
|
|
1956
|
-
</button>
|
|
1957
|
-
<span style={{color: theme.textDim}}>›</span>
|
|
1958
|
-
<span style={{fontWeight: 600, whiteSpace: 'nowrap'}}>
|
|
1959
|
-
{prefabInfo.name}
|
|
1960
|
-
{prefabDirty ? ' *' : ''}
|
|
1961
|
-
</span>
|
|
1962
|
-
<button
|
|
1963
|
-
type="button"
|
|
1964
|
-
className="qe-hover"
|
|
1965
|
-
title="Overwrite the prefab asset (Ctrl+S)"
|
|
1966
|
-
style={{
|
|
1967
|
-
...buttonStyle,
|
|
1968
|
-
padding: '3px 12px',
|
|
1969
|
-
...(prefabDirty
|
|
1970
|
-
? {
|
|
1971
|
-
background: theme.accent,
|
|
1972
|
-
color: '#0a1128',
|
|
1973
|
-
border: `1px solid ${theme.accent}`,
|
|
1974
|
-
}
|
|
1975
|
-
: {}),
|
|
1976
|
-
}}
|
|
1977
|
-
onClick={() => setPrefabDialog('overwrite')}
|
|
1978
|
-
>
|
|
1979
|
-
Save
|
|
1980
|
-
</button>
|
|
1981
|
-
</div>
|
|
1982
|
-
)}
|
|
1983
|
-
{props.backLink && !prefabInfo && (
|
|
1984
|
-
<a
|
|
1985
|
-
className="qe-hover"
|
|
1986
|
-
href={props.backLink.href}
|
|
1987
|
-
style={{
|
|
1988
|
-
position: 'absolute',
|
|
1989
|
-
top: 12,
|
|
1990
|
-
left: 12,
|
|
1991
|
-
display: 'inline-flex',
|
|
1992
|
-
alignItems: 'center',
|
|
1993
|
-
gap: 6,
|
|
1994
|
-
background: theme.panelBg,
|
|
1995
|
-
border: `1px solid ${theme.border}`,
|
|
1996
|
-
borderRadius: 8,
|
|
1997
|
-
padding: '6px 12px',
|
|
1998
|
-
fontSize: 13,
|
|
1999
|
-
fontWeight: 500,
|
|
2000
|
-
color: theme.text,
|
|
2001
|
-
textDecoration: 'none',
|
|
2002
|
-
fontFamily: theme.font,
|
|
2003
|
-
}}
|
|
2004
|
-
>
|
|
2005
|
-
<ArrowLeftIcon style={iconStyle(14)} />
|
|
2006
|
-
{props.backLink.label.replace(/^←\s*/, '')}
|
|
2007
|
-
</a>
|
|
2008
|
-
)}
|
|
2009
|
-
<div
|
|
2010
|
-
style={{
|
|
2011
|
-
position: 'absolute',
|
|
2012
|
-
top: 12,
|
|
2013
|
-
right: 12,
|
|
2014
|
-
display: 'flex',
|
|
2015
|
-
flexDirection: 'column',
|
|
2016
|
-
alignItems: 'flex-end',
|
|
2017
|
-
gap: 6,
|
|
2018
|
-
}}
|
|
2019
|
-
>
|
|
2020
|
-
<div
|
|
2021
|
-
style={{
|
|
2022
|
-
display: 'flex',
|
|
2023
|
-
gap: 4,
|
|
2024
|
-
background: theme.panelBg,
|
|
2025
|
-
border: `1px solid ${theme.border}`,
|
|
2026
|
-
borderRadius: 8,
|
|
2027
|
-
padding: 4,
|
|
2028
|
-
}}
|
|
2029
|
-
>
|
|
2030
|
-
<button
|
|
2031
|
-
className="qe-hover"
|
|
2032
|
-
style={squareButtonStyle}
|
|
2033
|
-
title="Undo (Ctrl+Z)"
|
|
2034
|
-
disabled={!state?.history.canUndo || !binding}
|
|
2035
|
-
onClick={() => actionsRef.current.historyStep(false)}
|
|
2036
|
-
>
|
|
2037
|
-
<ArrowUturnLeftIcon style={iconStyle(14)} />
|
|
2038
|
-
</button>
|
|
2039
|
-
<button
|
|
2040
|
-
className="qe-hover"
|
|
2041
|
-
style={squareButtonStyle}
|
|
2042
|
-
title="Redo (Ctrl+Shift+Z)"
|
|
2043
|
-
disabled={!state?.history.canRedo || !binding}
|
|
2044
|
-
onClick={() => actionsRef.current.historyStep(true)}
|
|
2045
|
-
>
|
|
2046
|
-
<ArrowUturnRightIcon style={iconStyle(14)} />
|
|
2047
|
-
</button>
|
|
2048
|
-
</div>
|
|
2049
|
-
<div
|
|
2050
|
-
style={{
|
|
2051
|
-
display: 'flex',
|
|
2052
|
-
flexDirection: 'column',
|
|
2053
|
-
gap: 4,
|
|
2054
|
-
background: theme.panelBg,
|
|
2055
|
-
border: `1px solid ${theme.border}`,
|
|
2056
|
-
borderRadius: 8,
|
|
2057
|
-
padding: 4,
|
|
2058
|
-
}}
|
|
2059
|
-
>
|
|
2060
|
-
{GIZMO_MODE_BUTTONS.map((m) => (
|
|
2061
|
-
<button
|
|
2062
|
-
key={m.value}
|
|
2063
|
-
className="qe-hover"
|
|
2064
|
-
style={{
|
|
2065
|
-
...squareButtonStyle,
|
|
2066
|
-
...(gizmoMode === m.value ? activeSquareButtonStyle : {}),
|
|
2067
|
-
}}
|
|
2068
|
-
title={m.title}
|
|
2069
|
-
onClick={() => setGizmoMode(m.value)}
|
|
2070
|
-
>
|
|
2071
|
-
<m.Icon style={iconStyle(14)} />
|
|
2072
|
-
</button>
|
|
2073
|
-
))}
|
|
2074
|
-
</div>
|
|
2075
|
-
<div
|
|
2076
|
-
style={{
|
|
2077
|
-
display: 'flex',
|
|
2078
|
-
flexDirection: 'column',
|
|
2079
|
-
gap: 4,
|
|
2080
|
-
background: theme.panelBg,
|
|
2081
|
-
border: `1px solid ${theme.border}`,
|
|
2082
|
-
borderRadius: 8,
|
|
2083
|
-
padding: 4,
|
|
2084
|
-
}}
|
|
2085
|
-
>
|
|
2086
|
-
<button
|
|
2087
|
-
className="qe-hover"
|
|
2088
|
-
style={{
|
|
2089
|
-
...squareButtonStyle,
|
|
2090
|
-
...(showEmitterWireframes ? activeSquareButtonStyle : {}),
|
|
2091
|
-
}}
|
|
2092
|
-
title="Show emitter shape wireframes"
|
|
2093
|
-
disabled={!binding}
|
|
2094
|
-
onClick={() => setShowEmitterWireframes((on) => !on)}
|
|
2095
|
-
>
|
|
2096
|
-
<CubeTransparentIcon style={iconStyle(14)} />
|
|
2097
|
-
</button>
|
|
2098
|
-
</div>
|
|
2099
|
-
</div>
|
|
2100
|
-
</div>
|
|
2101
|
-
<aside
|
|
2102
|
-
style={{
|
|
2103
|
-
width: 340,
|
|
2104
|
-
flexShrink: 0,
|
|
2105
|
-
borderLeft: `1px solid ${theme.border}`,
|
|
2106
|
-
background: theme.panelBg,
|
|
2107
|
-
display: 'flex',
|
|
2108
|
-
flexDirection: 'column',
|
|
2109
|
-
minHeight: 0,
|
|
2110
|
-
}}
|
|
2111
|
-
>
|
|
2112
|
-
<div style={{padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}`}}>
|
|
2113
|
-
<div
|
|
2114
|
-
style={{
|
|
2115
|
-
fontSize: 11,
|
|
2116
|
-
fontWeight: 600,
|
|
2117
|
-
color: theme.textDim,
|
|
2118
|
-
textTransform: 'uppercase',
|
|
2119
|
-
letterSpacing: 0.4,
|
|
2120
|
-
marginBottom: 6,
|
|
2121
|
-
}}
|
|
2122
|
-
>
|
|
2123
|
-
Inspector
|
|
2124
|
-
</div>
|
|
2125
|
-
<div
|
|
2126
|
-
className="qe-hover-bg"
|
|
2127
|
-
title={binding ? 'Rename effect' : undefined}
|
|
2128
|
-
style={{
|
|
2129
|
-
fontSize: 16,
|
|
2130
|
-
fontWeight: 600,
|
|
2131
|
-
cursor: binding ? 'pointer' : 'default',
|
|
2132
|
-
borderRadius: 6,
|
|
2133
|
-
padding: '2px 4px',
|
|
2134
|
-
margin: '-2px -4px',
|
|
2135
|
-
}}
|
|
2136
|
-
onClick={() => binding && setRenamingRoot(true)}
|
|
2137
|
-
>
|
|
2138
|
-
{(binding?.root.name || props.title) ?? 'Effect editor'}
|
|
2139
|
-
</div>
|
|
2140
|
-
{renamingRoot && binding && (
|
|
2141
|
-
<PromptDialog
|
|
2142
|
-
title="Rename effect"
|
|
2143
|
-
defaultValue={binding.root.name}
|
|
2144
|
-
onSubmit={(name) => {
|
|
2145
|
-
binding.apply(() => (binding.root.name = name));
|
|
2146
|
-
const entry = sceneEntries.find((item) => item.root === binding.root);
|
|
2147
|
-
if (entry) {
|
|
2148
|
-
entry.name = name;
|
|
2149
|
-
}
|
|
2150
|
-
setRenamingRoot(false);
|
|
2151
|
-
}}
|
|
2152
|
-
onCancel={() => setRenamingRoot(false)}
|
|
2153
|
-
/>
|
|
2154
|
-
)}
|
|
2155
|
-
<div style={{display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10}}>
|
|
2156
|
-
<button
|
|
2157
|
-
className="qe-hover"
|
|
2158
|
-
style={buttonStyle}
|
|
2159
|
-
disabled={!binding}
|
|
2160
|
-
onClick={() => {
|
|
2161
|
-
if (!binding) return;
|
|
2162
|
-
const blob = new Blob([exportEffectJSON(binding, showToast)], {
|
|
2163
|
-
type: 'application/json',
|
|
2164
|
-
});
|
|
2165
|
-
const link = document.createElement('a');
|
|
2166
|
-
link.href = URL.createObjectURL(blob);
|
|
2167
|
-
link.download = `${binding.root.name || 'effect'}.json`;
|
|
2168
|
-
link.click();
|
|
2169
|
-
URL.revokeObjectURL(link.href);
|
|
2170
|
-
}}
|
|
2171
|
-
>
|
|
2172
|
-
Export
|
|
2173
|
-
</button>
|
|
2174
|
-
{props.onSave && (
|
|
2175
|
-
<button
|
|
2176
|
-
className="qe-hover"
|
|
2177
|
-
style={buttonStyle}
|
|
2178
|
-
disabled={!binding}
|
|
2179
|
-
onClick={() => binding && props.onSave!(exportEffectJSON(binding, showToast))}
|
|
2180
|
-
>
|
|
2181
|
-
Save
|
|
2182
|
-
</button>
|
|
2183
|
-
)}
|
|
2184
|
-
</div>
|
|
2185
|
-
</div>
|
|
2186
|
-
<div style={{flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px'}}>
|
|
2187
|
-
{inspectorBinding && inspectorSystem && inspectorNode ? (
|
|
2188
|
-
<EffectEditor
|
|
2189
|
-
binding={inspectorBinding}
|
|
2190
|
-
selectedSystem={inspectorSystem}
|
|
2191
|
-
gizmoTargetNode={inspectorNode}
|
|
2192
|
-
gizmoTargetLabel={inspectorNode.name || 'Node'}
|
|
2193
|
-
textureOptions={props.textureOptions}
|
|
2194
|
-
geometryOptions={props.geometryOptions}
|
|
2195
|
-
resolveTexture={props.resolveTexture ? resolveTextureForEditor : undefined}
|
|
2196
|
-
resolveGeometry={props.resolveGeometry ? resolveGeometryForEditor : undefined}
|
|
2197
|
-
/>
|
|
2198
|
-
) : (
|
|
2199
|
-
<div style={{fontSize: 13, color: theme.textDim, lineHeight: 1.5}}>
|
|
2200
|
-
No effect selected.
|
|
2201
|
-
</div>
|
|
2202
|
-
)}
|
|
2203
|
-
</div>
|
|
2204
|
-
</aside>
|
|
2205
|
-
</div>
|
|
2206
|
-
<div
|
|
2207
|
-
aria-hidden={!drawerOpen && !drawerPinned}
|
|
2208
|
-
style={{
|
|
2209
|
-
height: DRAWER_HEIGHT,
|
|
2210
|
-
display: 'flex',
|
|
2211
|
-
flexDirection: 'column',
|
|
2212
|
-
background: theme.panelBg,
|
|
2213
|
-
borderTop: `1px solid ${theme.border}`,
|
|
2214
|
-
...(drawerPinned
|
|
2215
|
-
? {flexShrink: 0}
|
|
2216
|
-
: {
|
|
2217
|
-
position: 'absolute',
|
|
2218
|
-
left: 0,
|
|
2219
|
-
right: 0,
|
|
2220
|
-
bottom: 0,
|
|
2221
|
-
zIndex: 20,
|
|
2222
|
-
background: '#0a0f20',
|
|
2223
|
-
boxShadow: '0 -10px 30px rgba(0, 0, 0, 0.5)',
|
|
2224
|
-
transform: drawerOpen ? 'none' : 'translateY(100%)',
|
|
2225
|
-
opacity: drawerOpen ? 1 : 0,
|
|
2226
|
-
pointerEvents: drawerOpen ? 'auto' : 'none',
|
|
2227
|
-
transition: 'transform 0.18s ease, opacity 0.18s ease',
|
|
2228
|
-
}),
|
|
2229
|
-
}}
|
|
2230
|
-
>
|
|
2231
|
-
<div
|
|
2232
|
-
style={{
|
|
2233
|
-
display: 'flex',
|
|
2234
|
-
alignItems: 'center',
|
|
2235
|
-
gap: 6,
|
|
2236
|
-
padding: '4px 6px 4px 12px',
|
|
2237
|
-
borderBottom: `1px solid ${theme.border}`,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
dragHover={assetDragHover}
|
|
467
|
+
assetsLoading={assetsLoading}
|
|
468
|
+
prefabInfo={prefabInfo}
|
|
469
|
+
prefabDirty={prefabDirty}
|
|
470
|
+
backLink={props.backLink}
|
|
471
|
+
canUndo={Boolean(session?.history.canUndo)}
|
|
472
|
+
canRedo={Boolean(session?.history.canRedo)}
|
|
473
|
+
hasFocus={binding !== null}
|
|
474
|
+
gizmoMode={gizmoMode}
|
|
475
|
+
showWireframes={showEmitterWireframes}
|
|
476
|
+
onHistoryStep={(redo) => session?.historyStep(redo)}
|
|
477
|
+
onGizmoMode={setGizmoMode}
|
|
478
|
+
onToggleWireframes={() => setShowEmitterWireframes((on) => !on)}
|
|
479
|
+
onExitPrefab={requestPrefabExit}
|
|
480
|
+
onSavePrefab={() => setPrefabDialog('overwrite')}
|
|
481
|
+
dragHandlers={{
|
|
482
|
+
onDragEnter: (e) => {
|
|
483
|
+
if (isAssetDrag(e)) assetDragDepthRef.current += 1;
|
|
484
|
+
},
|
|
485
|
+
onDragLeave: (e) => {
|
|
486
|
+
if (!isAssetDrag(e)) return;
|
|
487
|
+
assetDragDepthRef.current -= 1;
|
|
488
|
+
if (assetDragDepthRef.current <= 0) {
|
|
489
|
+
assetDragDepthRef.current = 0;
|
|
490
|
+
session?.hideDropPreview();
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
onDragOver: (e) => {
|
|
494
|
+
if (!isAssetDrag(e)) return;
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
497
|
+
session?.previewDrop(e.clientX, e.clientY);
|
|
498
|
+
},
|
|
499
|
+
onDrop: (e) => {
|
|
500
|
+
if (!isAssetDrag(e)) return;
|
|
501
|
+
e.preventDefault();
|
|
502
|
+
assetDragDepthRef.current = 0;
|
|
503
|
+
session?.placeDrop(e.clientX, e.clientY, e.dataTransfer);
|
|
504
|
+
},
|
|
2238
505
|
}}
|
|
2239
|
-
>
|
|
2240
|
-
<span style={dockTitleStyle}>Assets</span>
|
|
2241
|
-
<span style={{fontSize: 11, color: theme.textDim}}>Ctrl+Space</span>
|
|
2242
|
-
<span style={{flex: 1}} />
|
|
2243
|
-
<button
|
|
2244
|
-
type="button"
|
|
2245
|
-
className="qe-hover"
|
|
2246
|
-
title={
|
|
2247
|
-
drawerPinned
|
|
2248
|
-
? 'Unpin — float over the viewport again'
|
|
2249
|
-
: 'Pin — keep it docked above the timeline'
|
|
2250
|
-
}
|
|
2251
|
-
style={{...dockIconButtonStyle, ...(drawerPinned ? activeSquareButtonStyle : {})}}
|
|
2252
|
-
onClick={() => {
|
|
2253
|
-
setDrawerPinned((pinned) => !pinned);
|
|
2254
|
-
setDrawerOpen(true);
|
|
2255
|
-
}}
|
|
2256
|
-
>
|
|
2257
|
-
<MapPinIcon style={iconStyle(13)} />
|
|
2258
|
-
</button>
|
|
2259
|
-
<button
|
|
2260
|
-
type="button"
|
|
2261
|
-
className="qe-hover"
|
|
2262
|
-
title="Close (Esc)"
|
|
2263
|
-
style={dockIconButtonStyle}
|
|
2264
|
-
onClick={() => {
|
|
2265
|
-
setDrawerPinned(false);
|
|
2266
|
-
setDrawerOpen(false);
|
|
2267
|
-
}}
|
|
2268
|
-
>
|
|
2269
|
-
<XMarkIcon style={iconStyle(13)} />
|
|
2270
|
-
</button>
|
|
2271
|
-
</div>
|
|
2272
|
-
<AssetsPanel
|
|
2273
|
-
assets={assets}
|
|
2274
|
-
instanceCounts={instanceCounts}
|
|
2275
|
-
loading={assetsLoading}
|
|
2276
|
-
folderInputRef={assetFolderInputRef}
|
|
2277
|
-
onImportFiles={(files) => void runAssetImport(files)}
|
|
2278
|
-
onImportFolder={handleImportFolder}
|
|
2279
|
-
onAddToScene={(payload) => actionsRef.current.addToScene(payload)}
|
|
2280
|
-
onOpenPrefab={(asset) => actionsRef.current.openPrefab(asset.id)}
|
|
2281
|
-
onDelete={(payload) => actionsRef.current.deleteAssets(payload)}
|
|
2282
506
|
/>
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
<button
|
|
2296
|
-
type="button"
|
|
2297
|
-
className="qe-hover-bg"
|
|
2298
|
-
title={timelineCollapsed ? 'Expand timeline' : 'Collapse timeline'}
|
|
2299
|
-
style={{...dockIconButtonStyle, border: 'none', background: 'transparent'}}
|
|
2300
|
-
onClick={() => setTimelineCollapsed((collapsed) => !collapsed)}
|
|
2301
|
-
>
|
|
2302
|
-
{timelineCollapsed ? (
|
|
2303
|
-
<ChevronUpIcon style={iconStyle(13)} />
|
|
2304
|
-
) : (
|
|
2305
|
-
<ChevronDownIcon style={iconStyle(13)} />
|
|
2306
|
-
)}
|
|
2307
|
-
</button>
|
|
2308
|
-
<span style={dockTitleStyle}>Timeline</span>
|
|
2309
|
-
{binding && (
|
|
2310
|
-
<span
|
|
2311
|
-
style={{
|
|
2312
|
-
fontSize: 12,
|
|
2313
|
-
color: theme.textDim,
|
|
2314
|
-
overflow: 'hidden',
|
|
2315
|
-
textOverflow: 'ellipsis',
|
|
2316
|
-
whiteSpace: 'nowrap',
|
|
2317
|
-
}}
|
|
2318
|
-
>
|
|
2319
|
-
{binding.root.name}
|
|
2320
|
-
</span>
|
|
2321
|
-
)}
|
|
2322
|
-
<span style={{flex: 1}} />
|
|
2323
|
-
<button
|
|
2324
|
-
type="button"
|
|
2325
|
-
className="qe-hover"
|
|
2326
|
-
title="Assets (Ctrl+Space)"
|
|
2327
|
-
style={{
|
|
2328
|
-
...buttonStyle,
|
|
2329
|
-
padding: '3px 10px',
|
|
2330
|
-
...(drawerOpen || drawerPinned ? activeSquareButtonStyle : {}),
|
|
2331
|
-
}}
|
|
2332
|
-
onClick={() => {
|
|
2333
|
-
if (drawerPinned) {
|
|
2334
|
-
setDrawerPinned(false);
|
|
2335
|
-
setDrawerOpen(false);
|
|
2336
|
-
} else {
|
|
2337
|
-
setDrawerOpen((open) => !open);
|
|
507
|
+
<InspectorSidebar
|
|
508
|
+
binding={binding}
|
|
509
|
+
inspector={
|
|
510
|
+
inspectorBinding && inspectorSystem && inspectorNode
|
|
511
|
+
? {binding: inspectorBinding, system: inspectorSystem, node: inspectorNode}
|
|
512
|
+
: null
|
|
513
|
+
}
|
|
514
|
+
title={props.title}
|
|
515
|
+
onRenamed={(name) => {
|
|
516
|
+
const entry = sceneEntries.find((item) => item.root === binding?.root);
|
|
517
|
+
if (entry) {
|
|
518
|
+
entry.name = name;
|
|
2338
519
|
}
|
|
2339
520
|
}}
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
521
|
+
onSave={props.onSave}
|
|
522
|
+
toast={showToast}
|
|
523
|
+
textureOptions={props.textureOptions}
|
|
524
|
+
geometryOptions={props.geometryOptions}
|
|
525
|
+
resolveTexture={props.resolveTexture ? resolveTextureForEditor : undefined}
|
|
526
|
+
resolveGeometry={props.resolveGeometry ? resolveGeometryForEditor : undefined}
|
|
527
|
+
/>
|
|
2343
528
|
</div>
|
|
2344
|
-
<
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
529
|
+
<AssetsDrawer
|
|
530
|
+
open={drawerOpen}
|
|
531
|
+
pinned={drawerPinned}
|
|
532
|
+
onTogglePin={() => {
|
|
533
|
+
setDrawerPinned((pinned) => !pinned);
|
|
534
|
+
setDrawerOpen(true);
|
|
2349
535
|
}}
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
docked
|
|
2354
|
-
showTransport={false}
|
|
2355
|
-
binding={binding}
|
|
2356
|
-
selectedNode={selectedNode}
|
|
2357
|
-
onSelectNode={setSelectedNode}
|
|
2358
|
-
scene={state.scene}
|
|
2359
|
-
renderer={state.renderer}
|
|
2360
|
-
playbackRef={playbackRef}
|
|
2361
|
-
playheadRef={playheadRef}
|
|
2362
|
-
counterRef={timelineCounterRef}
|
|
2363
|
-
stagePlaying={stagePlaying}
|
|
2364
|
-
setStagePlaying={setStagePlaying}
|
|
2365
|
-
speed={speed}
|
|
2366
|
-
setSpeed={setSpeed}
|
|
2367
|
-
playbackEnabled={timelinePlaybackEnabled}
|
|
2368
|
-
/>
|
|
2369
|
-
) : (
|
|
2370
|
-
<div style={{padding: '14px', fontSize: 12, color: theme.textDim}}>
|
|
2371
|
-
Select an effect in the Scene to see its timeline.
|
|
2372
|
-
</div>
|
|
2373
|
-
)}
|
|
2374
|
-
</div>
|
|
2375
|
-
</div>
|
|
2376
|
-
{projectLoading && (
|
|
2377
|
-
<div
|
|
2378
|
-
style={{
|
|
2379
|
-
position: 'fixed',
|
|
2380
|
-
inset: 0,
|
|
2381
|
-
display: 'flex',
|
|
2382
|
-
alignItems: 'center',
|
|
2383
|
-
justifyContent: 'center',
|
|
2384
|
-
background: 'rgba(7, 11, 22, 0.8)',
|
|
2385
|
-
zIndex: 900,
|
|
2386
|
-
color: theme.text,
|
|
2387
|
-
fontSize: 14,
|
|
536
|
+
onClose={() => {
|
|
537
|
+
setDrawerPinned(false);
|
|
538
|
+
setDrawerOpen(false);
|
|
2388
539
|
}}
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
left: '50%',
|
|
2400
|
-
transform: 'translateX(-50%)',
|
|
2401
|
-
zIndex: 950,
|
|
2402
|
-
background: '#13204a',
|
|
2403
|
-
border: `1px solid ${theme.borderActive}`,
|
|
2404
|
-
borderRadius: 8,
|
|
2405
|
-
padding: '8px 14px',
|
|
2406
|
-
fontSize: 12.5,
|
|
2407
|
-
color: theme.text,
|
|
2408
|
-
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
|
2409
|
-
pointerEvents: 'none',
|
|
540
|
+
panel={{
|
|
541
|
+
assets,
|
|
542
|
+
instanceCounts,
|
|
543
|
+
loading: assetsLoading,
|
|
544
|
+
folderInputRef: assetFolderInputRef,
|
|
545
|
+
onImportFiles: (files) => void runAssetImport(files),
|
|
546
|
+
onImportFolder: handleImportFolder,
|
|
547
|
+
onAddToScene: (payload) => session?.addToScene(payload),
|
|
548
|
+
onOpenPrefab: (asset) => session?.openPrefab(asset.id),
|
|
549
|
+
onDelete: (payload) => session?.deleteAssets(payload),
|
|
2410
550
|
}}
|
|
2411
|
-
>
|
|
2412
|
-
{toast}
|
|
2413
|
-
</div>
|
|
2414
|
-
)}
|
|
2415
|
-
{saveAsTarget && (
|
|
2416
|
-
<PromptDialog
|
|
2417
|
-
title="Save as new asset — Folder/Name"
|
|
2418
|
-
defaultValue={saveAsTarget.name}
|
|
2419
|
-
placeholder="Fire/Big explosion"
|
|
2420
|
-
confirmLabel="Save"
|
|
2421
|
-
onSubmit={(path) => {
|
|
2422
|
-
const entry = saveAsTarget;
|
|
2423
|
-
setSaveAsTarget(null);
|
|
2424
|
-
actionsRef.current.saveEntryAsAsset(entry, path);
|
|
2425
|
-
}}
|
|
2426
|
-
onCancel={() => setSaveAsTarget(null)}
|
|
2427
|
-
/>
|
|
2428
|
-
)}
|
|
2429
|
-
{prefabDialog === 'overwrite' && prefabInfo && (
|
|
2430
|
-
<ChoiceDialog
|
|
2431
|
-
title={`Overwrite prefab “${prefabInfo.name}”?`}
|
|
2432
|
-
message={(() => {
|
|
2433
|
-
const count = actionsRef.current.prefabInstanceCount();
|
|
2434
|
-
return count > 0
|
|
2435
|
-
? `The asset is replaced with your edits. The ${count} “${prefabInfo.name}” effect(s) already in the scene will not change.`
|
|
2436
|
-
: 'The asset is replaced with your edits. Effects already in the scene will not change.';
|
|
2437
|
-
})()}
|
|
2438
|
-
actions={[
|
|
2439
|
-
{label: 'Cancel', onSelect: () => setPrefabDialog(null)},
|
|
2440
|
-
{label: 'Save as new…', onSelect: () => setPrefabDialog('saveAs')},
|
|
2441
|
-
{
|
|
2442
|
-
label: 'Overwrite',
|
|
2443
|
-
primary: true,
|
|
2444
|
-
onSelect: () => {
|
|
2445
|
-
setPrefabDialog(null);
|
|
2446
|
-
actionsRef.current.overwritePrefab();
|
|
2447
|
-
},
|
|
2448
|
-
},
|
|
2449
|
-
]}
|
|
2450
|
-
onCancel={() => setPrefabDialog(null)}
|
|
2451
|
-
/>
|
|
2452
|
-
)}
|
|
2453
|
-
{prefabSwitch && prefabInfo && (
|
|
2454
|
-
<ChoiceDialog
|
|
2455
|
-
title={`Save changes to prefab “${prefabInfo.name}”?`}
|
|
2456
|
-
message={`Before opening “${prefabSwitch.name}”. Saving overwrites the prefab asset; effects already in the scene will not change.`}
|
|
2457
|
-
actions={[
|
|
2458
|
-
{label: 'Cancel', onSelect: () => setPrefabSwitch(null)},
|
|
2459
|
-
{
|
|
2460
|
-
label: "Don't save",
|
|
2461
|
-
danger: true,
|
|
2462
|
-
onSelect: () => {
|
|
2463
|
-
setPrefabSwitch(null);
|
|
2464
|
-
actionsRef.current.openPrefab(prefabSwitch.id, true);
|
|
2465
|
-
},
|
|
2466
|
-
},
|
|
2467
|
-
{
|
|
2468
|
-
label: 'Save',
|
|
2469
|
-
primary: true,
|
|
2470
|
-
onSelect: () => {
|
|
2471
|
-
setPrefabSwitch(null);
|
|
2472
|
-
actionsRef.current.overwritePrefab();
|
|
2473
|
-
actionsRef.current.openPrefab(prefabSwitch.id, true);
|
|
2474
|
-
},
|
|
2475
|
-
},
|
|
2476
|
-
]}
|
|
2477
|
-
onCancel={() => setPrefabSwitch(null)}
|
|
2478
|
-
/>
|
|
2479
|
-
)}
|
|
2480
|
-
{prefabDialog === 'exit' && prefabInfo && (
|
|
2481
|
-
<ChoiceDialog
|
|
2482
|
-
title={`Save changes to prefab “${prefabInfo.name}”?`}
|
|
2483
|
-
message="Saving overwrites the prefab asset. Effects already in the scene will not change."
|
|
2484
|
-
actions={[
|
|
2485
|
-
{label: 'Cancel', onSelect: () => setPrefabDialog(null)},
|
|
2486
|
-
{
|
|
2487
|
-
label: "Don't save",
|
|
2488
|
-
danger: true,
|
|
2489
|
-
onSelect: () => {
|
|
2490
|
-
setPrefabDialog(null);
|
|
2491
|
-
actionsRef.current.closePrefab();
|
|
2492
|
-
},
|
|
2493
|
-
},
|
|
2494
|
-
{
|
|
2495
|
-
label: 'Save',
|
|
2496
|
-
primary: true,
|
|
2497
|
-
onSelect: () => {
|
|
2498
|
-
setPrefabDialog(null);
|
|
2499
|
-
actionsRef.current.overwritePrefab();
|
|
2500
|
-
actionsRef.current.closePrefab();
|
|
2501
|
-
},
|
|
2502
|
-
},
|
|
2503
|
-
]}
|
|
2504
|
-
onCancel={() => setPrefabDialog(null)}
|
|
2505
551
|
/>
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
{
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
}
|
|
2533
|
-
{
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
552
|
+
</div>
|
|
553
|
+
<TimelineDock
|
|
554
|
+
collapsed={timelineCollapsed}
|
|
555
|
+
onToggleCollapsed={() => setTimelineCollapsed((collapsed) => !collapsed)}
|
|
556
|
+
effectName={binding?.root.name}
|
|
557
|
+
assetCount={assets.length}
|
|
558
|
+
assetsActive={drawerOpen || drawerPinned}
|
|
559
|
+
onToggleAssets={() => {
|
|
560
|
+
if (drawerPinned) {
|
|
561
|
+
setDrawerPinned(false);
|
|
562
|
+
setDrawerOpen(false);
|
|
563
|
+
} else {
|
|
564
|
+
setDrawerOpen((open) => !open);
|
|
565
|
+
}
|
|
566
|
+
}}
|
|
567
|
+
>
|
|
568
|
+
{binding && activeSystem && session ? (
|
|
569
|
+
<TimelinePanel
|
|
570
|
+
docked
|
|
571
|
+
showTransport={false}
|
|
572
|
+
binding={binding}
|
|
573
|
+
selectedNode={liveSelection}
|
|
574
|
+
onSelectNode={setSelectedNode}
|
|
575
|
+
scene={session.scene}
|
|
576
|
+
renderer={session.renderer}
|
|
577
|
+
playbackRef={playbackRef}
|
|
578
|
+
playheadRef={playheadRef}
|
|
579
|
+
counterRef={timelineCounterRef}
|
|
580
|
+
stagePlaying={stagePlaying}
|
|
581
|
+
setStagePlaying={setStagePlaying}
|
|
582
|
+
speed={speed}
|
|
583
|
+
setSpeed={setSpeed}
|
|
584
|
+
playbackEnabled={timelinePlaybackEnabled}
|
|
585
|
+
/>
|
|
586
|
+
) : null}
|
|
587
|
+
</TimelineDock>
|
|
588
|
+
<HostDialogs
|
|
589
|
+
session={session}
|
|
590
|
+
loadingProject={projectLoading ? (props.project?.name ?? 'project') : null}
|
|
591
|
+
toast={toast}
|
|
592
|
+
saveAsTarget={saveAsTarget}
|
|
593
|
+
setSaveAsTarget={setSaveAsTarget}
|
|
594
|
+
prefabInfo={prefabInfo}
|
|
595
|
+
prefabDialog={prefabDialog}
|
|
596
|
+
setPrefabDialog={setPrefabDialog}
|
|
597
|
+
prefabSwitch={prefabSwitch}
|
|
598
|
+
setPrefabSwitch={setPrefabSwitch}
|
|
599
|
+
exitPrompt={exitPrompt}
|
|
600
|
+
setExitPrompt={setExitPrompt}
|
|
601
|
+
message={message}
|
|
602
|
+
setMessage={setMessage}
|
|
603
|
+
onExitProject={props.onExitProject}
|
|
604
|
+
/>
|
|
2552
605
|
</div>
|
|
2553
606
|
);
|
|
2554
607
|
}
|
|
2555
608
|
|
|
2556
609
|
/** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
|
|
2557
610
|
export const QuarksEffectEditor = {
|
|
2558
|
-
|
|
611
|
+
/**
|
|
612
|
+
* Mounts an {@link EffectEditorHost} into `hostElement`.
|
|
613
|
+
*
|
|
614
|
+
* @returns A handle whose `dispose()` unmounts the editor and frees its engine.
|
|
615
|
+
*/
|
|
616
|
+
Show(options: EffectEditorHostProps & {hostElement: HTMLElement}): {
|
|
617
|
+
/** Unmounts the editor. */
|
|
618
|
+
dispose: () => void;
|
|
619
|
+
} {
|
|
2559
620
|
const {hostElement, ...props} = options;
|
|
2560
621
|
const root = createRoot(hostElement);
|
|
2561
622
|
root.render(<EffectEditorHost {...props} />);
|