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.
Files changed (211) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +69 -9
  3. package/dist/core/assetDragSession.d.ts +1 -1
  4. package/dist/core/assetTree.d.ts +6 -1
  5. package/dist/core/assets.d.ts +7 -0
  6. package/dist/core/assets.js +11 -7
  7. package/dist/core/binding.d.ts +11 -2
  8. package/dist/core/binding.js +9 -1
  9. package/dist/core/collision.d.ts +4 -0
  10. package/dist/core/collision.js +4 -0
  11. package/dist/core/colors.d.ts +16 -0
  12. package/dist/core/colors.js +8 -0
  13. package/dist/core/defaultEffect.d.ts +5 -4
  14. package/dist/core/defaultEffect.js +4 -4
  15. package/dist/core/editorScene.d.ts +1 -1
  16. package/dist/core/editorSession.d.ts +229 -0
  17. package/dist/core/editorSession.js +983 -0
  18. package/dist/core/effectTree.d.ts +6 -1
  19. package/dist/core/effectTree.js +13 -88
  20. package/dist/core/emitterShapeWireframe.d.ts +2 -2
  21. package/dist/core/emitterShapeWireframe.js +45 -13
  22. package/dist/core/galleryDropMarker.d.ts +2 -2
  23. package/dist/core/galleryDropMarker.js +6 -6
  24. package/dist/core/galleryLayout.d.ts +3 -3
  25. package/dist/core/galleryLayout.js +1 -1
  26. package/dist/core/galleryPlayback.d.ts +1 -1
  27. package/dist/core/galleryPlayback.js +2 -1
  28. package/dist/core/galleryScene.d.ts +4 -4
  29. package/dist/core/galleryScene.js +3 -3
  30. package/dist/core/gallerySelectionMarker.d.ts +4 -4
  31. package/dist/core/gallerySelectionMarker.js +5 -5
  32. package/dist/core/groups.d.ts +1 -1
  33. package/dist/core/groups.js +1 -1
  34. package/dist/core/history.d.ts +7 -1
  35. package/dist/core/history.js +4 -0
  36. package/dist/core/indexedDbProjectStore.d.ts +14 -1
  37. package/dist/core/indexedDbProjectStore.js +13 -0
  38. package/dist/core/lineShaders.d.ts +2 -2
  39. package/dist/core/lineShaders.js +2 -2
  40. package/dist/core/loadEffect.d.ts +2 -2
  41. package/dist/core/loadEffect.js +1 -1
  42. package/dist/core/material.d.ts +12 -2
  43. package/dist/core/pickAssetFiles.js +1 -1
  44. package/dist/core/playback.d.ts +6 -0
  45. package/dist/core/projectStore.d.ts +26 -0
  46. package/dist/core/projectStore.js +1 -0
  47. package/dist/core/sceneEntries.d.ts +7 -4
  48. package/dist/core/sceneEntries.js +2 -2
  49. package/dist/core/scrub.d.ts +2 -2
  50. package/dist/core/scrub.js +2 -2
  51. package/dist/core/shapes.d.ts +25 -1
  52. package/dist/core/shapes.js +5 -0
  53. package/dist/core/systems.d.ts +4 -2
  54. package/dist/core/systems.js +3 -1
  55. package/dist/core/textureExport.d.ts +1 -1
  56. package/dist/core/timeline.d.ts +25 -5
  57. package/dist/core/transport.d.ts +3 -3
  58. package/dist/core/transport.js +2 -2
  59. package/dist/core/values.d.ts +21 -4
  60. package/dist/core/values.js +5 -0
  61. package/dist/core/viewportPicker.d.ts +4 -4
  62. package/dist/core/viewportPicker.js +8 -7
  63. package/dist/index.d.ts +25 -21
  64. package/dist/index.js +12 -12
  65. package/dist/react/AssetsPanel.d.ts +9 -2
  66. package/dist/react/AssetsPanel.js +6 -6
  67. package/dist/react/ChoiceDialog.d.ts +7 -0
  68. package/dist/react/ChoiceDialog.js +1 -1
  69. package/dist/react/ContextMenu.d.ts +10 -1
  70. package/dist/react/ContextMenu.js +1 -1
  71. package/dist/react/CurveEditor.d.ts +1 -1
  72. package/dist/react/CurveEditor.js +2 -2
  73. package/dist/react/EditorWorkspace.d.ts +9 -2
  74. package/dist/react/EditorWorkspace.js +10 -7
  75. package/dist/react/EffectEditor.d.ts +11 -5
  76. package/dist/react/EffectEditor.js +7 -7
  77. package/dist/react/EffectEditorHost.d.ts +33 -29
  78. package/dist/react/EffectEditorHost.js +247 -1674
  79. package/dist/react/EffectTreeView.d.ts +4 -1
  80. package/dist/react/EffectTreeView.js +3 -3
  81. package/dist/react/GradientEditor.d.ts +1 -1
  82. package/dist/react/GradientEditor.js +3 -3
  83. package/dist/react/InspectorInfoIcon.js +3 -3
  84. package/dist/react/InspectorLabel.js +3 -3
  85. package/dist/react/MessageDialog.d.ts +5 -0
  86. package/dist/react/MessageDialog.js +1 -1
  87. package/dist/react/ModuleSection.d.ts +10 -0
  88. package/dist/react/ModuleSection.js +8 -5
  89. package/dist/react/ObjectModule.d.ts +1 -1
  90. package/dist/react/ObjectModule.js +4 -4
  91. package/dist/react/ProjectsPage.d.ts +7 -1
  92. package/dist/react/ProjectsPage.js +33 -17
  93. package/dist/react/PromptDialog.d.ts +7 -0
  94. package/dist/react/PromptDialog.js +1 -1
  95. package/dist/react/ScenePanel.d.ts +9 -2
  96. package/dist/react/ScenePanel.js +12 -10
  97. package/dist/react/SubEmittersModule.d.ts +1 -1
  98. package/dist/react/SubEmittersModule.js +5 -5
  99. package/dist/react/TextureSelect.d.ts +24 -0
  100. package/dist/react/TextureSelect.js +74 -0
  101. package/dist/react/TextureUVField.d.ts +14 -0
  102. package/dist/react/TextureUVField.js +23 -0
  103. package/dist/react/TimelinePanel.d.ts +20 -7
  104. package/dist/react/TimelinePanel.js +21 -15
  105. package/dist/react/TimelineTrackRow.d.ts +13 -3
  106. package/dist/react/TimelineTrackRow.js +6 -6
  107. package/dist/react/TransportBar.d.ts +13 -4
  108. package/dist/react/TransportBar.js +3 -3
  109. package/dist/react/ValueField.d.ts +32 -5
  110. package/dist/react/ValueField.js +22 -11
  111. package/dist/react/behaviorModules.d.ts +19 -6
  112. package/dist/react/behaviorModules.js +68 -17
  113. package/dist/react/fields.d.ts +9 -0
  114. package/dist/react/fields.js +6 -3
  115. package/dist/react/host/BottomDocks.d.ts +36 -0
  116. package/dist/react/host/BottomDocks.js +63 -0
  117. package/dist/react/host/HostDialogs.d.ts +50 -0
  118. package/dist/react/host/HostDialogs.js +113 -0
  119. package/dist/react/host/InspectorSidebar.d.ts +32 -0
  120. package/dist/react/host/InspectorSidebar.js +59 -0
  121. package/dist/react/host/SceneSidebar.d.ts +53 -0
  122. package/dist/react/host/SceneSidebar.js +62 -0
  123. package/dist/react/host/ViewportPane.d.ts +56 -0
  124. package/dist/react/host/ViewportPane.js +91 -0
  125. package/dist/react/host/hostHooks.d.ts +44 -0
  126. package/dist/react/host/hostHooks.js +164 -0
  127. package/dist/react/host/hostStyles.d.ts +21 -0
  128. package/dist/react/host/hostStyles.js +57 -0
  129. package/dist/react/index.d.ts +42 -38
  130. package/dist/react/index.js +25 -23
  131. package/dist/react/inspectorHints.js +31 -0
  132. package/dist/react/modules.d.ts +19 -6
  133. package/dist/react/modules.js +134 -117
  134. package/dist/react/theme.d.ts +15 -0
  135. package/dist/react/theme.js +15 -0
  136. package/dist/session.d.ts +9 -0
  137. package/dist/session.js +8 -0
  138. package/package.json +18 -11
  139. package/src/core/assetDragSession.ts +1 -1
  140. package/src/core/assetTree.ts +15 -2
  141. package/src/core/assets.ts +25 -10
  142. package/src/core/binding.ts +12 -3
  143. package/src/core/collision.ts +4 -0
  144. package/src/core/colors.ts +21 -1
  145. package/src/core/defaultEffect.ts +8 -7
  146. package/src/core/editorScene.ts +1 -1
  147. package/src/core/editorSession.ts +1299 -0
  148. package/src/core/effectTree.ts +18 -101
  149. package/src/core/emitterShapeWireframe.ts +48 -16
  150. package/src/core/galleryDropMarker.ts +8 -8
  151. package/src/core/galleryLayout.ts +4 -4
  152. package/src/core/galleryPlayback.ts +3 -2
  153. package/src/core/galleryScene.ts +5 -5
  154. package/src/core/gallerySelectionMarker.ts +7 -7
  155. package/src/core/groups.ts +1 -1
  156. package/src/core/history.ts +7 -1
  157. package/src/core/indexedDbProjectStore.ts +14 -1
  158. package/src/core/lineShaders.ts +2 -2
  159. package/src/core/loadEffect.ts +4 -4
  160. package/src/core/material.ts +14 -4
  161. package/src/core/pickAssetFiles.ts +2 -2
  162. package/src/core/playback.ts +6 -0
  163. package/src/core/projectStore.ts +26 -0
  164. package/src/core/sceneEntries.ts +8 -5
  165. package/src/core/scrub.ts +3 -3
  166. package/src/core/shapes.ts +29 -2
  167. package/src/core/systems.ts +11 -4
  168. package/src/core/textureExport.ts +1 -1
  169. package/src/core/timeline.ts +43 -18
  170. package/src/core/transport.ts +4 -4
  171. package/src/core/values.ts +20 -4
  172. package/src/core/viewportPicker.ts +12 -11
  173. package/src/index.ts +25 -21
  174. package/src/react/AssetsPanel.tsx +13 -6
  175. package/src/react/ChoiceDialog.tsx +8 -1
  176. package/src/react/ContextMenu.tsx +11 -2
  177. package/src/react/CurveEditor.tsx +2 -2
  178. package/src/react/EditorWorkspace.tsx +16 -10
  179. package/src/react/EffectEditor.tsx +17 -9
  180. package/src/react/EffectEditorHost.tsx +415 -2354
  181. package/src/react/EffectTreeView.tsx +7 -4
  182. package/src/react/GradientEditor.tsx +3 -3
  183. package/src/react/InspectorInfoIcon.tsx +3 -3
  184. package/src/react/InspectorLabel.tsx +3 -3
  185. package/src/react/MessageDialog.tsx +6 -1
  186. package/src/react/ModuleSection.tsx +15 -4
  187. package/src/react/ObjectModule.tsx +5 -5
  188. package/src/react/ProjectsPage.tsx +44 -17
  189. package/src/react/PromptDialog.tsx +8 -1
  190. package/src/react/ScenePanel.tsx +21 -12
  191. package/src/react/SubEmittersModule.tsx +6 -6
  192. package/src/react/TextureSelect.tsx +118 -0
  193. package/src/react/TextureUVField.tsx +62 -0
  194. package/src/react/TimelinePanel.tsx +40 -21
  195. package/src/react/TimelineTrackRow.tsx +19 -9
  196. package/src/react/TransportBar.tsx +16 -7
  197. package/src/react/ValueField.tsx +60 -16
  198. package/src/react/behaviorModules.tsx +240 -55
  199. package/src/react/fields.tsx +22 -4
  200. package/src/react/host/BottomDocks.tsx +178 -0
  201. package/src/react/host/HostDialogs.tsx +228 -0
  202. package/src/react/host/InspectorSidebar.tsx +147 -0
  203. package/src/react/host/SceneSidebar.tsx +227 -0
  204. package/src/react/host/ViewportPane.tsx +300 -0
  205. package/src/react/host/hostHooks.ts +198 -0
  206. package/src/react/host/hostStyles.ts +65 -0
  207. package/src/react/index.ts +43 -38
  208. package/src/react/inspectorHints.ts +40 -0
  209. package/src/react/modules.tsx +440 -170
  210. package/src/react/theme.ts +15 -0
  211. package/src/session.ts +17 -0
@@ -1,310 +1,122 @@
1
- import {ArcRotateCamera} from '@babylonjs/core/Cameras/arcRotateCamera';
2
- // scene.pick lives in Ray's side effect (Babylon 9.31 no longer pulls it in); gizmos pick through it.
3
- import '@babylonjs/core/Culling/ray';
4
- import {Engine} from '@babylonjs/core/Engines/engine';
5
- import {PointerEventTypes} from '@babylonjs/core/Events/pointerEvents';
6
- import {GizmoManager} from '@babylonjs/core/Gizmos/gizmoManager';
7
- import {DirectionalLight} from '@babylonjs/core/Lights/directionalLight';
8
- import {Color3, Color4} from '@babylonjs/core/Maths/math.color';
9
- import {Vector3 as BVector3} from '@babylonjs/core/Maths/math.vector';
10
- import {Viewport} from '@babylonjs/core/Maths/math.viewport';
11
- import type {Mesh} from '@babylonjs/core/Meshes/mesh';
12
- import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder';
13
- import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
14
- import {UtilityLayerRenderer} from '@babylonjs/core/Rendering/utilityLayerRenderer';
15
- import {Scene} from '@babylonjs/core/scene';
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 {getAssetDragSession, setAssetDragSession} from '../core/assetDragSession';
35
- import {ASSET_DRAG_MIME, describeAssetDrag, readAssetDrag, type AssetDragPayload} from '../core/assetTree';
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
- bindingFromSceneEntry,
75
- countInstancesByAsset,
76
- disposeSceneEntry,
77
- instantiateSceneEntry,
78
- type SceneEntry,
79
- } from '../core/sceneEntries';
80
- import {pauseFocus, scrubFocusToSettled} from '../core/scrub';
81
- import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
82
- import {notifyTransformChanged, subscribeTransform} from '../core/transformStore';
83
- import {toggleStagePlayback} from '../core/transport';
84
- import {GalleryViewportPicker} from '../core/viewportPicker';
85
- import {AssetsPanel} from './AssetsPanel';
86
- import {ChoiceDialog} from './ChoiceDialog';
87
- import {EffectEditor} from './EffectEditor';
88
- import {EffectTreeView} from './EffectTreeView';
89
- import {MessageDialog} from './MessageDialog';
90
- import {PromptDialog} from './PromptDialog';
91
- import {ScenePanel} from './ScenePanel';
92
- import {TimelinePanel} from './TimelinePanel';
93
- import {TransportBar} from './TransportBar';
94
- import {iconStyle, type HeroIcon} from './icons';
95
- import type {GeometryData, GeometryOption, TextureOption} from './modules';
96
- import {buttonStyle, globalEditorStyles, theme} from './theme';
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/scene/binding are live (and after every import). */
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
- resolveTexture?: (url: string, scene: Scene) => unknown;
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?: {href: string; label: string};
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?: {store: ProjectStore; id: string; name: string};
73
+ project?: EditorProject;
148
74
  /** Shows a "← Projects" button; called once unsaved scene changes are dealt with. */
149
75
  onExitProject?: () => void;
150
- }
151
-
152
- const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
153
-
154
- function toStoredAsset(asset: EffectAsset): StoredAsset {
155
- return {path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles};
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 stateRef = useRef<{
279
- engine: Engine;
280
- scene: Scene;
281
- camera: ArcRotateCamera;
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 [messageDialog, setMessageDialog] = useState<{title: string; message: string} | null>(null);
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<{name: string; path: string} | null>(null);
134
+ const [prefabInfo, setPrefabInfo] = useState<PrefabInfo | null>(null);
329
135
  const [prefabDirty, setPrefabDirty] = useState(false);
330
- const [prefabDialog, setPrefabDialog] = useState<'overwrite' | 'exit' | 'saveAs' | null>(null);
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 (!drawerPinnedRef.current) {
273
+ if (!drawerPinned) {
341
274
  setDrawerOpen(false);
342
275
  }
343
276
  };
344
-
345
- const isAssetsLoading = assetsLoading !== null;
346
-
347
- const showMessage = (title: string, message: string) => setMessageDialog({title, message});
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 actionsRef.current.importAssets(files);
309
+ await session?.importAssets(files);
363
310
  } catch (err) {
364
- const message = err instanceof Error ? err.message : String(err);
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
- const message = err instanceof Error ? err.message : String(err);
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
- actionsRef.current.closePrefab();
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 === selectedNode) ?? binding.system) : null;
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(selectedNode);
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
- ? (inspectorSystems.find((s) => s.emitter === inspectorNode) ?? inspectorBinding.system)
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
- const scene = stateRef.current?.scene;
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
- const scene = stateRef.current?.scene;
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 props.resolveGeometry(file, scene);
385
+ return resolveGeometry(file, session.scene);
1571
386
  },
1572
- [props.resolveGeometry]
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
- <aside
1602
- style={{
1603
- width: SCENE_PANEL_WIDTH,
1604
- flexShrink: 0,
1605
- borderRight: `1px solid ${theme.border}`,
1606
- background: theme.panelBg,
1607
- display: 'flex',
1608
- flexDirection: 'column',
1609
- minHeight: 0,
1610
- }}
1611
- >
1612
- {props.project && (
1613
- <div
1614
- style={{
1615
- display: 'flex',
1616
- alignItems: 'center',
1617
- gap: 6,
1618
- padding: '8px 10px',
1619
- borderBottom: `1px solid ${theme.border}`,
1620
- }}
1621
- >
1622
- {props.onExitProject && (
1623
- <button
1624
- type="button"
1625
- className="qe-hover"
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
- onDragOver={(e) => {
1798
- if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME)) return;
1799
- e.preventDefault();
1800
- e.dataTransfer.dropEffect = 'copy';
1801
-
1802
- const payload = getAssetDragSession();
1803
- const snap = stateRef.current;
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={state.scene}
1859
- renderer={state.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
- </div>
1869
- )}
1870
- {assetDragHover && (
1871
- <div
1872
- style={{
1873
- position: 'absolute',
1874
- inset: 10,
1875
- border: '2px dashed rgba(120, 165, 255, 0.75)',
1876
- borderRadius: 12,
1877
- pointerEvents: 'none',
1878
- boxShadow: 'inset 0 0 0 1px rgba(120, 165, 255, 0.15)',
1879
- }}
1880
- >
1881
- <div
1882
- style={{
1883
- position: 'absolute',
1884
- left: '50%',
1885
- top: 18,
1886
- transform: 'translateX(-50%)',
1887
- background: 'rgba(7, 11, 22, 0.88)',
1888
- border: `1px solid ${theme.border}`,
1889
- borderRadius: 8,
1890
- padding: '8px 14px',
1891
- fontSize: 13,
1892
- color: theme.text,
1893
- whiteSpace: 'nowrap',
1894
- }}
1895
- >
1896
- Drop {assetDragHover.count === 1 ? 'effect' : `${assetDragHover.count} effects`} —{' '}
1897
- {assetDragHover.label}
1898
- </div>
1899
- </div>
1900
- )}
1901
- {assetsLoading && (
1902
- <div
1903
- style={{
1904
- position: 'absolute',
1905
- inset: 0,
1906
- display: 'flex',
1907
- alignItems: 'center',
1908
- justifyContent: 'center',
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
- </div>
2284
- </div>
2285
- <div
2286
- style={{
2287
- flexShrink: 0,
2288
- borderTop: `1px solid ${theme.border}`,
2289
- background: theme.panelBg,
2290
- display: 'flex',
2291
- flexDirection: 'column',
2292
- }}
2293
- >
2294
- <div style={{display: 'flex', alignItems: 'center', gap: 6, padding: '3px 6px'}}>
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
- Assets{assets.length > 0 ? ` · ${assets.length}` : ''}
2342
- </button>
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
- <div
2345
- style={{
2346
- height: TIMELINE_HEIGHT,
2347
- display: timelineCollapsed ? 'none' : 'flex',
2348
- borderTop: `1px solid ${theme.border}`,
529
+ <AssetsDrawer
530
+ open={drawerOpen}
531
+ pinned={drawerPinned}
532
+ onTogglePin={() => {
533
+ setDrawerPinned((pinned) => !pinned);
534
+ setDrawerOpen(true);
2349
535
  }}
2350
- >
2351
- {showTimeline && binding && activeSystem && state ? (
2352
- <TimelinePanel
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
- Opening {props.project?.name ?? 'project'}…
2391
- </div>
2392
- )}
2393
- {toast && (
2394
- <div
2395
- role="status"
2396
- style={{
2397
- position: 'fixed',
2398
- top: 16,
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
- {prefabDialog === 'saveAs' && prefabInfo && (
2508
- <PromptDialog
2509
- title="Save prefab as new asset — Folder/Name"
2510
- defaultValue={prefabInfo.path.replace(/\.json$/i, '')}
2511
- confirmLabel="Save"
2512
- onSubmit={(path) => {
2513
- setPrefabDialog(null);
2514
- actionsRef.current.savePrefabAsNew(path);
2515
- }}
2516
- onCancel={() => setPrefabDialog(null)}
2517
- />
2518
- )}
2519
- {exitPrompt && (
2520
- <ChoiceDialog
2521
- title="Save changes to the scene?"
2522
- message="The scene has changes that are not saved. Assets are already saved."
2523
- actions={[
2524
- {label: 'Cancel', onSelect: () => setExitPrompt(false)},
2525
- {
2526
- label: "Don't save",
2527
- danger: true,
2528
- onSelect: () => {
2529
- setExitPrompt(false);
2530
- props.onExitProject?.();
2531
- },
2532
- },
2533
- {
2534
- label: 'Save',
2535
- primary: true,
2536
- onSelect: () => {
2537
- setExitPrompt(false);
2538
- void actionsRef.current.saveScene().then((saved) => saved && props.onExitProject?.());
2539
- },
2540
- },
2541
- ]}
2542
- onCancel={() => setExitPrompt(false)}
2543
- />
2544
- )}
2545
- {messageDialog && (
2546
- <MessageDialog
2547
- title={messageDialog.title}
2548
- message={messageDialog.message}
2549
- onClose={() => setMessageDialog(null)}
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
- Show(options: EffectEditorHostProps & {hostElement: HTMLElement}): {dispose: () => void} {
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} />);