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,202 +1,57 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArcRotateCamera } from '@babylonjs/core/Cameras/arcRotateCamera';
3
- // scene.pick lives in Ray's side effect (Babylon 9.31 no longer pulls it in); gizmos pick through it.
4
- import '@babylonjs/core/Culling/ray';
5
- import { Engine } from '@babylonjs/core/Engines/engine';
6
- import { PointerEventTypes } from '@babylonjs/core/Events/pointerEvents';
7
- import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager';
8
- import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight';
9
- import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
10
- import { Vector3 as BVector3 } from '@babylonjs/core/Maths/math.vector';
11
- import { Viewport } from '@babylonjs/core/Maths/math.viewport';
12
- import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
13
- import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer';
14
- import { Scene } from '@babylonjs/core/scene';
15
- import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial';
16
- import { ArrowLeftIcon, ArrowPathIcon, ArrowsPointingOutIcon, ArrowUturnLeftIcon, ArrowUturnRightIcon, ChevronDownIcon, ChevronUpIcon, CubeTransparentIcon, CursorArrowRaysIcon, MapPinIcon, Square2StackIcon, XMarkIcon, } from '@heroicons/react/24/solid';
17
- import { BatchedRenderer, QuarksUtil } from 'babylon.quarks';
18
- import { useCallback, useDeferredValue, useEffect, useMemo, useReducer, useRef, useState } from 'react';
2
+ import { Engine } from '@babylonjs/core/Engines/engine.js';
3
+ import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
19
4
  import { createRoot } from 'react-dom/client';
20
- import { getAssetDragSession, setAssetDragSession } from '../core/assetDragSession';
21
- import { ASSET_DRAG_MIME, describeAssetDrag, readAssetDrag } from '../core/assetTree';
22
- import { collectJsonFiles, createEffectAsset, loadEffectAssets, mergeAssets, removeAssets, } from '../core/assets';
23
- import { createSceneDefaultEntry } from '../core/defaultEffect';
24
- import { serializeEffectTree } from '../core/effectTree';
25
- import { EmitterShapeWireframes } from '../core/emitterShapeWireframe';
26
- import { GalleryDropMarker } from '../core/galleryDropMarker';
27
- import { cancelGalleryCameraFocus, focusGalleryEntry } from '../core/galleryLayout';
28
- import { applyGalleryPlayback, countGalleryParticles, hasActivePreview, pauseFinishedPreviews, } from '../core/galleryPlayback';
29
- import { pickGroundPosition, placeSceneEntriesAt, setSceneEntryVisible } from '../core/galleryScene';
30
- import { GallerySelectionMarker } from '../core/gallerySelectionMarker';
31
- import { EffectHistory } from '../core/history';
32
- import { parseEffectFromJson } from '../core/loadEffect';
33
- import { pickAssetFolderFiles } from '../core/pickAssetFiles';
34
- import { clearFocusFinished, createPlaybackState, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, } from '../core/playback';
35
- import { logRenderMismatch } from '../core/renderDebug';
36
- import { bindingFromSceneEntry, countInstancesByAsset, disposeSceneEntry, instantiateSceneEntry, } from '../core/sceneEntries';
37
- import { pauseFocus, scrubFocusToSettled } from '../core/scrub';
38
- import { computeTimelineRows, computeTimelineSpan } from '../core/timeline';
39
- import { notifyTransformChanged, subscribeTransform } from '../core/transformStore';
40
- import { toggleStagePlayback } from '../core/transport';
41
- import { GalleryViewportPicker } from '../core/viewportPicker';
42
- import { AssetsPanel } from './AssetsPanel';
43
- import { ChoiceDialog } from './ChoiceDialog';
44
- import { EffectEditor } from './EffectEditor';
45
- import { EffectTreeView } from './EffectTreeView';
46
- import { MessageDialog } from './MessageDialog';
47
- import { PromptDialog } from './PromptDialog';
48
- import { ScenePanel } from './ScenePanel';
49
- import { TimelinePanel } from './TimelinePanel';
50
- import { TransportBar } from './TransportBar';
51
- import { iconStyle } from './icons';
52
- import { buttonStyle, globalEditorStyles, theme } from './theme';
53
- /**
54
- * The effect's JSON, with a warning when it points at object URLs: a texture
55
- * dropped in from disk lives at a `blob:` URL that dies with the page, so the
56
- * exported effect would load without it.
57
- */
58
- function exportEffectJSON(binding, warn) {
59
- const json = binding.exportJSON('EditorEffect');
60
- if (json.includes('"blob:')) {
61
- warn('This effect references blob: textures, which will not load after a reload. Re-pick them from a hosted URL.');
62
- }
63
- return json;
64
- }
65
- const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
66
- function toStoredAsset(asset) {
67
- return { path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles };
68
- }
69
- /** `Folder/Name` typed by the user → a clean asset path ending in `.json`. */
70
- function normalizeAssetPath(input) {
71
- const segments = input
72
- .replace(/\\/g, '/')
73
- .split('/')
74
- .map((part) => part.trim())
75
- .filter(Boolean);
76
- const path = segments.join('/') || 'Effect';
77
- return /\.json$/i.test(path) ? path : `${path}.json`;
78
- }
79
- const SCENE_PANEL_WIDTH = 280;
80
- const TIMELINE_HEIGHT = 200;
81
- const DRAWER_HEIGHT = 280;
82
- const GIZMO_MODE_BUTTONS = [
83
- { value: 'none', Icon: CursorArrowRaysIcon, title: 'Select (hide stage gizmo)' },
84
- { value: 'position', Icon: ArrowsPointingOutIcon, title: 'Move the stage node (position)' },
85
- { value: 'rotation', Icon: ArrowPathIcon, title: 'Rotate the stage node' },
86
- { value: 'scale', Icon: Square2StackIcon, title: 'Scale the stage node' },
87
- ];
88
- const squareButtonStyle = {
89
- ...buttonStyle,
90
- width: 28,
91
- height: 28,
92
- padding: 0,
93
- display: 'flex',
94
- alignItems: 'center',
95
- justifyContent: 'center',
96
- fontWeight: 700,
97
- };
98
- const activeSquareButtonStyle = {
99
- border: `1px solid ${theme.borderActive}`,
100
- background: 'rgba(120, 165, 255, 0.35)',
101
- };
102
- const dockTitleStyle = {
103
- fontSize: 11,
104
- fontWeight: 600,
105
- letterSpacing: 0.4,
106
- textTransform: 'uppercase',
107
- color: theme.text,
108
- };
109
- const dockIconButtonStyle = {
110
- ...buttonStyle,
111
- width: 24,
112
- height: 22,
113
- padding: 0,
114
- display: 'inline-flex',
115
- alignItems: 'center',
116
- justifyContent: 'center',
117
- };
118
- function isTextInput(target) {
119
- const el = target;
120
- return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
121
- }
122
- /**
123
- * Inspector value controls (numbers, checkboxes, selects): Ctrl+Z there means the effect's undo,
124
- * not the browser's per-field text undo. Free-text inputs (names, filters) keep the native one.
125
- */
126
- function isInspectorValueControl(target) {
127
- const el = target;
128
- if (!el)
129
- return false;
130
- if (el.tagName === 'SELECT')
131
- return true;
132
- return el.tagName === 'INPUT' && ['number', 'checkbox', 'range', 'color'].includes(el.type);
133
- }
134
- /** Position of `node` in its effect tree (0 = root), to find the same node after a rebuild. */
135
- function nodeIndexInEffect(root, node) {
136
- if (!node || node === root)
137
- return 0;
138
- return root.getDescendants(false).indexOf(node) + 1;
139
- }
140
- function nodeAtIndex(root, index) {
141
- if (index <= 0)
142
- return root;
143
- return root.getDescendants(false)[index - 1] ?? root;
144
- }
5
+ import { collectJsonFiles } from '../core/assets.js';
6
+ import { ASSET_DRAG_MIME } from '../core/assetTree.js';
7
+ import { createEditorSession, } from '../core/editorSession.js';
8
+ import { pickAssetFolderFiles } from '../core/pickAssetFiles.js';
9
+ import { createPlaybackState } from '../core/playback.js';
10
+ import { countInstancesByAsset } from '../core/sceneEntries.js';
11
+ import { toggleStagePlayback } from '../core/transport.js';
12
+ import { AssetsDrawer, TimelineDock } from './host/BottomDocks.js';
13
+ import { HostDialogs } from './host/HostDialogs.js';
14
+ import { useHostShortcuts, useStageGizmo, useUnsavedChangesGuard, useViewportInset } from './host/hostHooks.js';
15
+ import { InspectorSidebar } from './host/InspectorSidebar.js';
16
+ import { SceneSidebar } from './host/SceneSidebar.js';
17
+ import { ViewportPane } from './host/ViewportPane.js';
18
+ import { globalEditorStyles, theme } from './theme.js';
19
+ import { TimelinePanel } from './TimelinePanel.js';
20
+ import { TransportBar } from './TransportBar.js';
145
21
  /**
146
22
  * Self-contained effect editor: live Babylon preview, a Scene list of the effects placed in it,
147
23
  * an inspector, and a bottom dock with the timeline and the Assets browser. Assets are effect
148
24
  * files; dragging one into the viewport instantiates an independent copy, as many times as needed.
149
- * Designed for embedding — see QuarksEffectEditor.Show for the NME-style entry point.
25
+ * Designed for embedding — see {@link QuarksEffectEditor}.Show for the NME-style entry point.
26
+ *
27
+ * The scene, Assets, prefab mode and undo live in an {@link EditorSession}; this component is its
28
+ * React view.
150
29
  */
151
30
  export function EffectEditorHost(props) {
152
31
  const canvasRef = useRef(null);
153
32
  const counterRef = useRef(null);
154
33
  const fpsRef = useRef(null);
155
34
  const playheadRef = useRef(null);
35
+ const timelineCounterRef = useRef(null);
36
+ const mainAreaRef = useRef(null);
156
37
  const assetFolderInputRef = useRef(null);
157
- const actionsRef = useRef({
158
- importAssets: async (_files) => { },
159
- focusEntry: (_entry) => { },
160
- toggleVisible: (_entry) => { },
161
- toggleSolo: (_entry) => { },
162
- deleteEntry: (_entry) => { },
163
- createDefault: () => { },
164
- placeDrop: (_clientX, _clientY, _dataTransfer) => { },
165
- addToScene: (_payload) => { },
166
- deleteAssets: (_payload) => { },
167
- saveEntryAsAsset: (_entry, _path) => { },
168
- saveScene: async () => false,
169
- openPrefab: (_assetId, _discardChanges) => { },
170
- overwritePrefab: () => { },
171
- savePrefabAsNew: (_path) => { },
172
- closePrefab: () => { },
173
- prefabInstanceCount: () => 0,
174
- historyStep: (_redo) => { },
175
- });
176
- const dropMarkerRef = useRef(null);
177
- const selectionMarkerRef = useRef(null);
178
- const emitterWireframesRef = useRef(null);
179
- const viewportPickerRef = useRef(null);
180
38
  const assetDragDepthRef = useRef(0);
181
- const stateRef = useRef(null);
182
- const gizmoManagerRef = useRef(null);
39
+ const playbackRef = useRef(createPlaybackState());
40
+ const propsRef = useRef(props);
41
+ const [session, setSession] = useState(null);
183
42
  const [binding, setBinding] = useState(null);
184
43
  const [, force] = useReducer((x) => x + 1, 0);
185
- const playbackRef = useRef(createPlaybackState());
186
- const focusTimelineMetaRef = useRef({ rootId: -1, revision: -1, span: 1, anyLooping: false });
187
44
  const [stagePlaying, setStagePlaying] = useState(true);
188
45
  const [speed, setSpeed] = useState(1);
189
- const [renamingRoot, setRenamingRoot] = useState(false);
190
46
  const [gizmoMode, setGizmoMode] = useState('none');
191
47
  const [selectedNode, setSelectedNode] = useState(null);
192
48
  const selectedNodeRef = useRef(null);
193
- selectedNodeRef.current = selectedNode;
194
49
  const [sceneEntries, setSceneEntries] = useState([]);
195
50
  const [assets, setAssets] = useState([]);
196
51
  const [assetsLoading, setAssetsLoading] = useState(null);
197
52
  const [focusedRoot, setFocusedRoot] = useState(null);
198
53
  const [visibleRootIds, setVisibleRootIds] = useState([]);
199
- const [messageDialog, setMessageDialog] = useState(null);
54
+ const [message, setMessage] = useState(null);
200
55
  const [assetDragHover, setAssetDragHover] = useState(null);
201
56
  const [showEmitterWireframes, setShowEmitterWireframes] = useState(false);
202
57
  const [timelineCollapsed, setTimelineCollapsed] = useState(false);
@@ -205,12 +60,6 @@ export function EffectEditorHost(props) {
205
60
  // button); pinned, it is docked above the timeline instead.
206
61
  const [drawerOpen, setDrawerOpen] = useState(false);
207
62
  const [drawerPinned, setDrawerPinned] = useState(false);
208
- const drawerPinnedRef = useRef(false);
209
- drawerPinnedRef.current = drawerPinned;
210
- const timelineCounterRef = useRef(null);
211
- const mainAreaRef = useRef(null);
212
- /** Pixels at the bottom of the viewport covered by the floating Assets drawer. */
213
- const [viewportInset, setViewportInset] = useState(0);
214
63
  const [sceneDirty, setSceneDirty] = useState(false);
215
64
  const [projectLoading, setProjectLoading] = useState(Boolean(props.project));
216
65
  const [toast, setToast] = useState(null);
@@ -223,18 +72,150 @@ export function EffectEditorHost(props) {
223
72
  /** Asset waiting to replace an open prefab that has unsaved edits. */
224
73
  const [prefabSwitch, setPrefabSwitch] = useState(null);
225
74
  const toastTimerRef = useRef(0);
226
- const showToast = (text) => {
75
+ const showToast = useCallback((text) => {
227
76
  window.clearTimeout(toastTimerRef.current);
228
77
  setToast(text);
229
78
  toastTimerRef.current = window.setTimeout(() => setToast(null), 2600);
230
- };
79
+ }, []);
80
+ const showMessage = (title, text) => setMessage({ title, message: text });
81
+ /** Builds the session on `engine` and starts it; returns what undoes it, engine included. */
82
+ function mountSession(canvas, engine) {
83
+ const created = createEditorSession({
84
+ engine,
85
+ playback: playbackRef.current,
86
+ project: props.project,
87
+ effectJson: props.effectJson,
88
+ resolveTexture: props.resolveTexture,
89
+ defaultTextureUrl: props.textureOptions?.[0]?.url,
90
+ isAssetDragActive: () => assetDragDepthRef.current > 0,
91
+ getSelectedNode: () => selectedNodeRef.current,
92
+ view: {
93
+ bindingChanged: setBinding,
94
+ selectedNodeChanged: setSelectedNode,
95
+ focusedRootChanged: setFocusedRoot,
96
+ entriesChanged: setSceneEntries,
97
+ assetsChanged: setAssets,
98
+ visibleChanged: setVisibleRootIds,
99
+ soloChanged: setSoloRootId,
100
+ assetsLoadingChanged: setAssetsLoading,
101
+ sceneDirtyChanged: setSceneDirty,
102
+ projectLoaded: () => setProjectLoading(false),
103
+ prefabChanged: setPrefabInfo,
104
+ prefabDirtyChanged: setPrefabDirty,
105
+ prefabSwitchRequested: setPrefabSwitch,
106
+ dragHoverChanged: setAssetDragHover,
107
+ assetsRevealed: () => setDrawerOpen(true),
108
+ refresh: force,
109
+ toast: showToast,
110
+ message: (title, text) => setMessage({ title, message: text }),
111
+ ready: (handle) => propsRef.current.onReady?.(handle),
112
+ frame: (stats) => {
113
+ if (fpsRef.current) {
114
+ fpsRef.current.textContent = `${stats.fps} FPS`;
115
+ }
116
+ if (counterRef.current) {
117
+ counterRef.current.textContent = stats.counter;
118
+ }
119
+ },
120
+ },
121
+ });
122
+ setSession(created);
123
+ created.start();
124
+ const resizeObserver = new ResizeObserver(() => engine.resize());
125
+ resizeObserver.observe(canvas);
126
+ const onWindowDragEnd = () => {
127
+ assetDragDepthRef.current = 0;
128
+ created.clearDropPreview();
129
+ };
130
+ window.addEventListener('dragend', onWindowDragEnd);
131
+ return () => {
132
+ resizeObserver.disconnect();
133
+ window.removeEventListener('dragend', onWindowDragEnd);
134
+ assetDragDepthRef.current = 0;
135
+ setSession(null);
136
+ created.dispose();
137
+ engine.dispose();
138
+ };
139
+ }
140
+ useEffect(() => {
141
+ const canvas = canvasRef.current;
142
+ let unmounted = false;
143
+ let teardown = null;
144
+ const mount = (engine) => {
145
+ if (unmounted) {
146
+ engine.dispose();
147
+ return;
148
+ }
149
+ teardown = mountSession(canvas, engine);
150
+ };
151
+ const built = props.createEngine ? props.createEngine(canvas) : new Engine(canvas, true);
152
+ if (built instanceof Promise) {
153
+ built.then(mount, (err) => {
154
+ console.error('[Quarks editor] Could not create the engine:', err);
155
+ if (!unmounted) {
156
+ setMessage({ title: 'Engine', message: err instanceof Error ? err.message : String(err) });
157
+ }
158
+ });
159
+ }
160
+ else {
161
+ mount(built);
162
+ }
163
+ return () => {
164
+ unmounted = true;
165
+ window.clearTimeout(toastTimerRef.current);
166
+ teardown?.();
167
+ };
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount only: builds the engine and scene once
169
+ }, []);
170
+ useEffect(() => {
171
+ playbackRef.current.stagePlaying = stagePlaying;
172
+ session?.syncPlayback();
173
+ }, [session, stagePlaying, visibleRootIds, sceneEntries]);
174
+ useEffect(() => {
175
+ session?.setEmitterWireframesVisible(showEmitterWireframes);
176
+ }, [session, showEmitterWireframes]);
177
+ const viewportInset = useViewportInset(session, canvasRef, mainAreaRef, drawerOpen && !drawerPinned, timelineCollapsed);
178
+ useUnsavedChangesGuard(Boolean(props.project) && sceneDirty);
179
+ // The selection, with a node gone with an undo falling back to the effect's root; the gizmo
180
+ // follows it.
181
+ const liveSelection = selectedNode?.isDisposed() ? (session?.binding?.root ?? null) : selectedNode;
182
+ // The session's callbacks read the latest props and selection through these refs.
183
+ useLayoutEffect(() => {
184
+ propsRef.current = props;
185
+ selectedNodeRef.current = liveSelection;
186
+ });
187
+ const gizmoTarget = liveSelection && !liveSelection.isDisposed()
188
+ ? liveSelection
189
+ : binding?.root && !binding.root.isDisposed()
190
+ ? binding.root
191
+ : null;
192
+ useStageGizmo(session, gizmoMode, gizmoTarget, assetsLoading !== null);
231
193
  const closeFloatingDrawer = () => {
232
- if (!drawerPinnedRef.current) {
194
+ if (!drawerPinned) {
233
195
  setDrawerOpen(false);
234
196
  }
235
197
  };
236
- const isAssetsLoading = assetsLoading !== null;
237
- const showMessage = (title, message) => setMessageDialog({ title, message });
198
+ const savePrefabOrScene = () => {
199
+ if (session?.isPrefabOpen) {
200
+ setPrefabDialog('overwrite');
201
+ }
202
+ else {
203
+ void session?.saveScene();
204
+ }
205
+ };
206
+ useHostShortcuts({
207
+ save: savePrefabOrScene,
208
+ toggleAssets: () => setDrawerOpen((open) => !open),
209
+ togglePlayback: () => {
210
+ if (!session)
211
+ return;
212
+ const playback = playbackRef.current;
213
+ toggleStagePlayback(session.binding, session.scene, playback, playback.stagePlaying, setStagePlaying);
214
+ },
215
+ closeDrawer: closeFloatingDrawer,
216
+ historyStep: (redo) => session?.historyStep(redo),
217
+ deleteFocused: () => session?.deleteFocused() ?? false,
218
+ });
238
219
  /** Appends effect files to Assets without touching the scene. */
239
220
  const runAssetImport = async (files) => {
240
221
  if (collectJsonFiles(files).length === 0) {
@@ -244,11 +225,10 @@ export function EffectEditorHost(props) {
244
225
  return;
245
226
  }
246
227
  try {
247
- await actionsRef.current.importAssets(files);
228
+ await session?.importAssets(files);
248
229
  }
249
230
  catch (err) {
250
- const message = err instanceof Error ? err.message : String(err);
251
- showMessage('Import assets', message);
231
+ showMessage('Import assets', err instanceof Error ? err.message : String(err));
252
232
  console.error('Failed to import assets:', err);
253
233
  }
254
234
  force();
@@ -267,980 +247,17 @@ export function EffectEditorHost(props) {
267
247
  await runAssetImport(picked);
268
248
  }
269
249
  catch (err) {
270
- const message = err instanceof Error ? err.message : String(err);
271
- showMessage('Import assets', message);
250
+ showMessage('Import assets', err instanceof Error ? err.message : String(err));
272
251
  console.error('Failed to open folder picker:', err);
273
252
  }
274
253
  })();
275
254
  };
276
- useEffect(() => {
277
- const engine = new Engine(canvasRef.current, true);
278
- const scene = new Scene(engine);
279
- scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
280
- const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new BVector3(0, 1.4, 0), scene);
281
- camera.attachControl(canvasRef.current, true);
282
- camera.wheelDeltaPercentage = 0.01;
283
- // Mesh render mode particles are lit (billboard modes ignore these); the grid is visual
284
- // reference only and is not a physics collider.
285
- scene.ambientColor = new Color3(1, 1, 1);
286
- const sunLight = new DirectionalLight('sun', new BVector3(-1, -1, -1), scene);
287
- sunLight.intensity = 1.0;
288
- sunLight.diffuse = new Color3(1, 1, 1);
289
- sunLight.specular = new Color3(1, 1, 1);
290
- const groundMaterial = new GridMaterial('groundMaterial', scene);
291
- groundMaterial.majorUnitFrequency = 2;
292
- groundMaterial.minorUnitVisibility = 0.1;
293
- groundMaterial.gridRatio = 0.5;
294
- groundMaterial.backFaceCulling = false;
295
- groundMaterial.mainColor = new Color3(1, 1, 1);
296
- groundMaterial.lineColor = new Color3(1, 1, 1);
297
- groundMaterial.opacity = 0.5;
298
- // Draw only the lines. Babylon < 9.31 did this by itself for opacity < 1; 9.31 needs the flag,
299
- // or the whole plane is filled with the main color.
300
- groundMaterial.linesOnly = true;
301
- const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
302
- ground.material = groundMaterial;
303
- const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
304
- const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
305
- gizmoManager.usePointerToAttachGizmos = false;
306
- gizmoManagerRef.current = gizmoManager;
307
- const renderer = new BatchedRenderer('quarks-editor', scene);
308
- const history = new EffectHistory();
309
- /** Undo/redo of effects that are not focused right now, by root uniqueId. */
310
- const historyByRoot = new Map();
311
- const dropMarker = new GalleryDropMarker(scene);
312
- dropMarkerRef.current = dropMarker;
313
- const selectionMarker = new GallerySelectionMarker(scene);
314
- selectionMarkerRef.current = selectionMarker;
315
- const emitterWireframes = new EmitterShapeWireframes(scene);
316
- emitterWireframesRef.current = emitterWireframes;
317
- const viewportPicker = new GalleryViewportPicker(scene);
318
- viewportPickerRef.current = viewportPicker;
319
- stateRef.current = {
320
- engine,
321
- scene,
322
- camera,
323
- ground,
324
- renderer,
325
- binding: null,
326
- history,
327
- sceneEntries: [],
328
- visibleRootIds: [],
329
- assets: [],
330
- };
331
- const state = stateRef.current;
332
- const handleViewportPointerLeave = () => viewportPicker.setHover(null);
333
- canvasRef.current?.addEventListener('pointerleave', handleViewportPointerLeave);
334
- const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
335
- if (assetDragDepthRef.current > 0) {
336
- return;
337
- }
338
- const event = pointerInfo.event;
339
- if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
340
- const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
341
- const focused = state.binding?.root ?? null;
342
- viewportPicker.setHover(effectRoot && effectRoot !== focused ? effectRoot : null);
343
- return;
344
- }
345
- if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
346
- return;
347
- }
348
- const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
349
- if (!effectRoot) {
350
- return;
351
- }
352
- const entry = state.sceneEntries.find((item) => item.root === effectRoot);
353
- if (entry && state.binding?.root !== effectRoot) {
354
- focusEntry(entry);
355
- }
356
- setSelectedNode(effectRoot);
357
- });
358
- const clearAssetDragPreview = () => {
359
- assetDragDepthRef.current = 0;
360
- dropMarker.hide();
361
- setAssetDragHover(null);
362
- setAssetDragSession(null);
363
- };
364
- const onWindowDragEnd = () => clearAssetDragPreview();
365
- window.addEventListener('dragend', onWindowDragEnd);
366
- // ---- state commits ------------------------------------------------------------------
367
- /**
368
- * The open prefab, if any: its asset, and the scene set aside while it is edited. In prefab
369
- * mode `state.sceneEntries` holds just the prefab's instance, so playback, picking, undo and
370
- * the timeline all work on it unchanged.
371
- */
372
- let prefab = null;
373
- let prefabChanged = false;
374
- /** Scene changes since the last save; suppressed while a project scene loads. */
375
- let dirty = false;
376
- let loadingScene = Boolean(props.project);
377
- const markDirty = () => {
378
- if (prefab) {
379
- if (!prefabChanged) {
380
- prefabChanged = true;
381
- setPrefabDirty(true);
382
- }
383
- return;
384
- }
385
- if (!loadingScene && !dirty) {
386
- dirty = true;
387
- setSceneDirty(true);
388
- }
389
- };
390
- const markClean = () => {
391
- dirty = false;
392
- setSceneDirty(false);
393
- };
394
- const unsubscribeTransform = subscribeTransform(markDirty);
395
- const commitEntries = (next) => {
396
- state.sceneEntries = next;
397
- setSceneEntries(next);
398
- markDirty();
399
- };
400
- const commitAssets = (next) => {
401
- state.assets = next;
402
- setAssets(next);
403
- };
404
- const commitVisible = (next) => {
405
- state.visibleRootIds = next;
406
- setVisibleRootIds(next);
407
- markDirty();
408
- };
409
- const onBindingChange = () => {
410
- force();
411
- markDirty();
412
- };
413
- const isVisible = (entry) => state.visibleRootIds.includes(entry.root.uniqueId);
414
- const refreshPlayback = () => {
415
- applyGalleryPlayback(state.sceneEntries, new Set(state.visibleRootIds), playbackRef.current.stagePlaying, playbackRef.current.focusFinishedByRoot);
416
- renderer.refreshBatches();
417
- scene.render();
418
- force();
419
- };
420
- // ---- focus ----------------------------------------------------------------------------
421
- const attachBinding = (next) => {
422
- const previous = state.binding;
423
- if (previous && !previous.root.isDisposed()) {
424
- historyByRoot.set(previous.root.uniqueId, history.saveStacks());
425
- }
426
- history.restoreStacks(next ? historyByRoot.get(next.root.uniqueId) : undefined);
427
- if (next) {
428
- history.attach(next);
429
- next.subscribe(onBindingChange);
430
- }
431
- state.binding = next;
432
- setBinding(next);
433
- };
434
- const focusEntry = (entry, moveCamera = true) => {
435
- attachBinding(entry ? bindingFromSceneEntry(entry) : null);
436
- setSelectedNode(entry?.root ?? null);
437
- setFocusedRoot(entry?.root ?? null);
438
- playbackRef.current.focusRootId = entry?.root.uniqueId ?? null;
439
- if (entry && isVisible(entry)) {
440
- selectionMarker.show(entry.root);
441
- if (moveCamera) {
442
- focusGalleryEntry(camera, entry.root);
443
- }
444
- }
445
- else {
446
- selectionMarker.hide();
447
- }
448
- props.onReady?.(makeHandle());
449
- };
450
- // ---- scene entries ------------------------------------------------------------------
451
- /** Adds entries to the scene (visible) and focuses the first. */
452
- const addEntries = (entries, center) => {
453
- if (entries.length === 0) {
454
- return;
455
- }
456
- placeSceneEntriesAt(entries, center);
457
- for (const entry of entries) {
458
- entry.root.setEnabled(true);
459
- }
460
- commitEntries([...state.sceneEntries, ...entries]);
461
- commitVisible([...state.visibleRootIds, ...entries.map((entry) => entry.root.uniqueId)]);
462
- focusEntry(entries[0], false);
463
- refreshPlayback();
464
- };
465
- const refuseInPrefabMode = () => {
466
- if (!prefab)
467
- return false;
468
- showToast('Effects go into the scene — go back to the scene (← Scene) to add them');
469
- return true;
470
- };
471
- const instantiateAssets = (toPlace, center) => {
472
- if (refuseInPrefabMode()) {
473
- return;
474
- }
475
- const created = [];
476
- for (const asset of toPlace) {
477
- try {
478
- const entry = instantiateSceneEntry(scene, renderer, asset, [...state.sceneEntries, ...created]);
479
- if (entry) {
480
- created.push(entry);
481
- }
482
- }
483
- catch (err) {
484
- console.warn(`[Quarks editor] Could not instantiate "${asset.path}":`, err);
485
- }
486
- }
487
- if (created.length < toPlace.length) {
488
- showMessage('Add to scene', `${toPlace.length - created.length} of ${toPlace.length} effect(s) could not be loaded — see the console.`);
489
- }
490
- addEntries(created, center);
491
- };
492
- const toggleVisible = (entry) => {
493
- const id = entry.root.uniqueId;
494
- const show = !isVisible(entry);
495
- setSceneEntryVisible(entry, renderer, show);
496
- clearFocusFinished(playbackRef.current, id);
497
- commitVisible(show ? [...state.visibleRootIds, id] : state.visibleRootIds.filter((rootId) => rootId !== id));
498
- if (state.binding?.root === entry.root) {
499
- if (show) {
500
- selectionMarker.show(entry.root);
501
- }
502
- else {
503
- selectionMarker.hide();
504
- }
505
- }
506
- refreshPlayback();
507
- };
508
- /** Entry shown alone, and the entries solo hid (shown again when it ends). */
509
- let solo = null;
510
- const exitSolo = () => {
511
- if (!solo) {
512
- return;
513
- }
514
- const hidden = new Set(solo.hidden);
515
- solo = null;
516
- setSoloRootId(null);
517
- const toShow = state.sceneEntries.filter((entry) => hidden.has(entry.root.uniqueId) && !isVisible(entry));
518
- for (const entry of toShow) {
519
- setSceneEntryVisible(entry, renderer, true);
520
- clearFocusFinished(playbackRef.current, entry.root.uniqueId);
521
- }
522
- commitVisible([...state.visibleRootIds, ...toShow.map((entry) => entry.root.uniqueId)]);
523
- };
524
- const toggleSolo = (entry) => {
525
- if (prefab)
526
- return;
527
- const id = entry.root.uniqueId;
528
- const wasSolo = solo?.rootId === id;
529
- exitSolo();
530
- if (!wasSolo) {
531
- const others = state.sceneEntries.filter((item) => item !== entry && isVisible(item));
532
- for (const other of others) {
533
- setSceneEntryVisible(other, renderer, false);
534
- }
535
- const hiddenIds = others.map((other) => other.root.uniqueId);
536
- const visible = state.visibleRootIds.filter((rootId) => !hiddenIds.includes(rootId));
537
- if (!visible.includes(id)) {
538
- setSceneEntryVisible(entry, renderer, true);
539
- visible.push(id);
540
- }
541
- commitVisible(visible);
542
- solo = { rootId: id, hidden: hiddenIds };
543
- setSoloRootId(id);
544
- if (state.binding?.root !== entry.root) {
545
- focusEntry(entry);
546
- }
547
- else {
548
- selectionMarker.show(entry.root);
549
- focusGalleryEntry(camera, entry.root);
550
- }
551
- }
552
- refreshPlayback();
553
- };
554
- const deleteEntry = (entry) => {
555
- if (prefab)
556
- return;
557
- const index = state.sceneEntries.indexOf(entry);
558
- if (index < 0) {
559
- return;
560
- }
561
- const id = entry.root.uniqueId;
562
- if (solo?.rootId === id) {
563
- exitSolo();
564
- }
565
- const wasFocused = state.binding?.root === entry.root;
566
- if (wasFocused) {
567
- selectionMarker.hide();
568
- }
569
- disposeSceneEntry(entry, renderer);
570
- const remaining = state.sceneEntries.filter((item) => item !== entry);
571
- commitEntries(remaining);
572
- commitVisible(state.visibleRootIds.filter((rootId) => rootId !== id));
573
- clearFocusFinished(playbackRef.current, id);
574
- playbackRef.current.focusElapsedByRoot.delete(id);
575
- if (wasFocused) {
576
- focusEntry(remaining[Math.min(index, remaining.length - 1)] ?? null, false);
577
- }
578
- historyByRoot.delete(id);
579
- refreshPlayback();
580
- };
581
- const createDefault = () => {
582
- if (refuseInPrefabMode()) {
583
- return;
584
- }
585
- const entry = createSceneDefaultEntry(scene, state.sceneEntries, {
586
- resolveTexture: props.resolveTexture,
587
- textureUrl: props.textureOptions?.[0]?.url,
588
- });
589
- for (const system of entry.systems) {
590
- renderer.addSystem(system);
591
- }
592
- addEntries([entry], new BVector3(camera.target.x, 0, camera.target.z));
593
- };
594
- // ---- assets ---------------------------------------------------------------------------
595
- const placeDrop = (clientX, clientY, dataTransfer) => {
596
- const payload = (dataTransfer && readAssetDrag(dataTransfer)) ?? getAssetDragSession();
597
- clearAssetDragPreview();
598
- if (!payload) {
599
- return;
600
- }
601
- const { assets: toPlace } = describeAssetDrag(state.assets, payload);
602
- instantiateAssets(toPlace, pickGroundPosition(scene, clientX, clientY));
603
- };
604
- const addToScene = (payload) => {
605
- const { assets: toPlace } = describeAssetDrag(state.assets, payload);
606
- instantiateAssets(toPlace, new BVector3(camera.target.x, 0, camera.target.z));
607
- };
608
- /** Writes asset changes through to the project; failures are reported, not thrown. */
609
- const persist = (action) => {
610
- const project = props.project;
611
- if (!project)
612
- return;
613
- action(project.store, project.id).catch((err) => {
614
- console.error('[Quarks editor] Could not save to the project:', err);
615
- showMessage('Project storage', err instanceof Error ? err.message : String(err));
616
- });
617
- };
618
- const deleteAssets = (payload) => {
619
- const next = removeAssets(state.assets, payload);
620
- const removed = state.assets.filter((asset) => !next.includes(asset)).map((asset) => asset.path);
621
- commitAssets(next);
622
- persist((store, id) => store.deleteAssets(id, removed));
623
- };
624
- const importAssets = async (files) => {
625
- setAssetsLoading({ done: 0, total: 0, loaded: 0, failed: 0 });
626
- let incoming;
627
- try {
628
- incoming = await loadEffectAssets(files, (progress) => setAssetsLoading(progress));
629
- }
630
- finally {
631
- setAssetsLoading(null);
632
- }
633
- const { merged, added } = mergeAssets(state.assets, incoming);
634
- commitAssets(merged);
635
- setDrawerOpen(true);
636
- if (added.length > 0) {
637
- persist((store, id) => store.putAssets(id, added.map(toStoredAsset)));
638
- }
639
- if (incoming.length === 0) {
640
- throw new Error('No particle effects found in the selected file(s).');
641
- }
642
- if (added.length === 0) {
643
- throw new Error('All selected effects are already in Assets.');
644
- }
645
- };
646
- /**
647
- * Saves a scene instance as a new asset at `rawPath` (`Folder/Name`, never overwriting) and
648
- * makes the instance an instance of it.
649
- */
650
- /** New asset at `rawPath` (`Folder/Name`, a free path — never overwrites) from an effect tree. */
651
- const createAssetFromRoot = (root, rawPath) => {
652
- const taken = new Set(state.assets.map((asset) => asset.path));
653
- const wanted = normalizeAssetPath(rawPath);
654
- let path = wanted;
655
- for (let n = 2; taken.has(path); n++) {
656
- path = wanted.replace(/\.json$/i, ` ${n}.json`);
657
- }
658
- const name = path
659
- .split('/')
660
- .pop()
661
- .replace(/\.json$/i, '');
662
- const asset = createEffectAsset(name, path, JSON.parse(serializeEffectTree(root, name)));
663
- commitAssets([...state.assets, asset]);
664
- persist((store, id) => store.putAssets(id, [toStoredAsset(asset)]));
665
- return asset;
666
- };
667
- const saveEntryAsAsset = (entry, rawPath) => {
668
- const asset = createAssetFromRoot(entry.root, rawPath);
669
- entry.assetId = asset.id;
670
- entry.path = asset.path;
671
- commitEntries([...state.sceneEntries]);
672
- setDrawerOpen(true);
673
- showToast(`Saved as asset “${asset.path.replace(/\.json$/i, '')}”`);
674
- };
675
- // ---- prefab mode ------------------------------------------------------------------------
676
- /** Scene instances made from the open prefab — they keep their own copy when it is saved. */
677
- const prefabInstanceCount = () => prefab ? prefab.stash.entries.filter((entry) => entry.assetId === prefab.asset.id).length : 0;
678
- /** Drops the prefab instance(s) currently in `state.sceneEntries`, with their history. */
679
- const disposePrefabInstances = () => {
680
- for (const entry of state.sceneEntries) {
681
- const id = entry.root.uniqueId;
682
- disposeSceneEntry(entry, renderer);
683
- historyByRoot.delete(id);
684
- clearFocusFinished(playbackRef.current, id);
685
- playbackRef.current.focusElapsedByRoot.delete(id);
686
- }
687
- };
688
- /**
689
- * Opens an asset as a prefab. With another prefab open it is swapped in place — the scene stays
690
- * set aside — after asking about unsaved edits unless `discardChanges`.
691
- */
692
- const openPrefab = (assetId, discardChanges = false) => {
693
- if (prefab?.asset.id === assetId) {
694
- return;
695
- }
696
- const asset = state.assets.find((item) => item.id === assetId);
697
- if (!asset)
698
- return;
699
- if (prefab && prefabChanged && !discardChanges) {
700
- setPrefabSwitch({ id: asset.id, name: asset.name });
701
- return;
702
- }
703
- let entry = null;
704
- try {
705
- entry = instantiateSceneEntry(scene, renderer, asset, []);
706
- }
707
- catch (err) {
708
- console.warn(`[Quarks editor] Could not open prefab "${asset.path}":`, err);
709
- }
710
- if (!entry) {
711
- showMessage('Open prefab', `“${asset.name}” has no particle systems to edit.`);
712
- return;
713
- }
714
- if (prefab) {
715
- selectionMarker.hide();
716
- disposePrefabInstances();
717
- prefab.asset = asset;
718
- prefabChanged = false;
719
- setPrefabDirty(false);
720
- setPrefabInfo({ name: asset.name, path: asset.path });
721
- entry.root.setEnabled(true);
722
- state.sceneEntries = [entry];
723
- setSceneEntries([entry]);
724
- state.visibleRootIds = [entry.root.uniqueId];
725
- setVisibleRootIds([entry.root.uniqueId]);
726
- focusEntry(entry);
727
- refreshPlayback();
728
- return;
729
- }
730
- exitSolo();
731
- const stash = {
732
- entries: state.sceneEntries,
733
- visible: state.visibleRootIds,
734
- focusedRoot: state.binding?.root ?? null,
735
- camera: { target: camera.target.clone(), radius: camera.radius, alpha: camera.alpha, beta: camera.beta },
736
- };
737
- // Hide the scene without touching its saved visibility.
738
- for (const item of stash.entries) {
739
- if (stash.visible.includes(item.root.uniqueId)) {
740
- setSceneEntryVisible(item, renderer, false);
741
- }
742
- }
743
- selectionMarker.hide();
744
- prefab = { asset, stash };
745
- prefabChanged = false;
746
- setPrefabDirty(false);
747
- setPrefabInfo({ name: asset.name, path: asset.path });
748
- entry.root.setEnabled(true);
749
- state.sceneEntries = [entry];
750
- setSceneEntries([entry]);
751
- state.visibleRootIds = [entry.root.uniqueId];
752
- setVisibleRootIds([entry.root.uniqueId]);
753
- focusEntry(entry);
754
- refreshPlayback();
755
- };
756
- /** Leaves prefab mode without saving: drops the prefab instance and brings the scene back. */
757
- const closePrefab = () => {
758
- if (!prefab)
759
- return;
760
- const { stash } = prefab;
761
- prefab = null;
762
- selectionMarker.hide();
763
- disposePrefabInstances();
764
- state.sceneEntries = stash.entries;
765
- setSceneEntries(stash.entries);
766
- state.visibleRootIds = stash.visible;
767
- setVisibleRootIds(stash.visible);
768
- for (const entry of stash.entries) {
769
- if (stash.visible.includes(entry.root.uniqueId)) {
770
- setSceneEntryVisible(entry, renderer, true);
771
- clearFocusFinished(playbackRef.current, entry.root.uniqueId);
772
- }
773
- }
774
- cancelGalleryCameraFocus();
775
- camera.setTarget(stash.camera.target);
776
- camera.radius = stash.camera.radius;
777
- camera.alpha = stash.camera.alpha;
778
- camera.beta = stash.camera.beta;
779
- prefabChanged = false;
780
- setPrefabDirty(false);
781
- setPrefabInfo(null);
782
- focusEntry(stash.entries.find((entry) => entry.root === stash.focusedRoot) ?? null, false);
783
- refreshPlayback();
784
- };
785
- /** Overwrites the open prefab's asset with the edited instance. Scene instances keep theirs. */
786
- const overwritePrefab = () => {
787
- const root = state.sceneEntries[0]?.root;
788
- if (!prefab || !root)
789
- return;
790
- const updated = {
791
- ...prefab.asset,
792
- json: JSON.parse(serializeEffectTree(root, prefab.asset.name)),
793
- };
794
- const exists = state.assets.some((asset) => asset.id === updated.id);
795
- commitAssets(exists
796
- ? state.assets.map((asset) => (asset.id === updated.id ? updated : asset))
797
- : [...state.assets, updated]);
798
- persist((store, id) => store.putAssets(id, [toStoredAsset(updated)]));
799
- prefab.asset = updated;
800
- prefabChanged = false;
801
- setPrefabDirty(false);
802
- showToast(`Prefab “${updated.name}” saved · scene unchanged`);
803
- };
804
- /** Saves the edited prefab as a new asset and keeps editing that one. */
805
- const savePrefabAsNew = (rawPath) => {
806
- const root = state.sceneEntries[0]?.root;
807
- if (!prefab || !root)
808
- return;
809
- const asset = createAssetFromRoot(root, rawPath);
810
- prefab.asset = asset;
811
- prefabChanged = false;
812
- setPrefabDirty(false);
813
- setPrefabInfo({ name: asset.name, path: asset.path });
814
- showToast(`Saved as new prefab “${asset.path.replace(/\.json$/i, '')}”`);
815
- };
816
- // ---- project scene ----------------------------------------------------------------------
817
- /** Saves the scene to the project; resolves to false when it could not. */
818
- const saveScene = async () => {
819
- const project = props.project;
820
- if (!project || prefab)
821
- return false;
822
- const visibleIds = new Set(state.visibleRootIds);
823
- const assetPathById = new Map(state.assets.map((asset) => [asset.id, asset.path]));
824
- const stored = {
825
- savedAt: Date.now(),
826
- entries: state.sceneEntries.map((entry) => ({
827
- name: entry.name,
828
- assetPath: entry.assetId != null ? (assetPathById.get(entry.assetId) ?? null) : null,
829
- json: JSON.parse(serializeEffectTree(entry.root, entry.name)),
830
- visible: visibleIds.has(entry.root.uniqueId),
831
- })),
832
- };
833
- try {
834
- await project.store.saveScene(project.id, stored);
835
- }
836
- catch (err) {
837
- console.error('[Quarks editor] Could not save the scene:', err);
838
- showMessage('Save scene', err instanceof Error ? err.message : String(err));
839
- return false;
840
- }
841
- markClean();
842
- let infoSeen = true;
843
- try {
844
- infoSeen = localStorage.getItem(SCENE_SAVE_INFO_KEY) === '1';
845
- localStorage.setItem(SCENE_SAVE_INFO_KEY, '1');
846
- }
847
- catch {
848
- // Storage blocked — fall back to the toast.
849
- }
850
- if (infoSeen) {
851
- showToast('Scene saved · assets unchanged');
852
- }
853
- else {
854
- showMessage('Scene saved', 'Saving the scene stores the effects placed in it, with their own edits.\n\n' +
855
- 'It does not change the assets they came from. To keep an edited effect as an asset, ' +
856
- 'right-click it in the Scene list → “Save as new asset…”.');
857
- }
858
- return true;
859
- };
860
- const loadProject = async (project) => {
861
- try {
862
- const [storedAssets, storedScene] = await Promise.all([
863
- project.store.loadAssets(project.id),
864
- project.store.loadScene(project.id),
865
- ]);
866
- const loadedAssets = storedAssets.map((stored) => ({
867
- ...createEffectAsset(stored.name, stored.path, stored.json),
868
- missingFiles: stored.missingFiles,
869
- }));
870
- commitAssets(loadedAssets);
871
- const assetIdByPath = new Map(loadedAssets.map((asset) => [asset.path, asset.id]));
872
- if (!storedScene) {
873
- createDefault();
874
- return;
875
- }
876
- const created = [];
877
- const visible = [];
878
- for (const stored of storedScene.entries) {
879
- const entry = instantiateSceneEntry(scene, renderer, { id: -1, name: stored.name, path: stored.assetPath ?? '', json: stored.json, missingFiles: [] }, created);
880
- if (!entry)
881
- continue;
882
- entry.name = stored.name;
883
- entry.root.name = stored.name;
884
- entry.assetId = stored.assetPath != null ? (assetIdByPath.get(stored.assetPath) ?? null) : null;
885
- entry.path = stored.assetPath ?? `_created/${stored.name}.json`;
886
- setSceneEntryVisible(entry, renderer, stored.visible);
887
- if (stored.visible)
888
- visible.push(entry.root.uniqueId);
889
- created.push(entry);
890
- }
891
- commitEntries(created);
892
- commitVisible(visible);
893
- focusEntry(created[0] ?? null, false);
894
- refreshPlayback();
895
- }
896
- catch (err) {
897
- console.error('[Quarks editor] Could not open the project:', err);
898
- showMessage('Open project', err instanceof Error ? err.message : String(err));
899
- }
900
- finally {
901
- loadingScene = false;
902
- markClean();
903
- setProjectLoading(false);
904
- }
905
- };
906
- // ---- undo / redo ----------------------------------------------------------------------
907
- /** Rebuilds the focused entry from a history snapshot, in place in the scene list. */
908
- const historyStep = (redo) => {
909
- const current = state.binding;
910
- if (!current) {
911
- return;
912
- }
913
- const json = redo ? history.redo() : history.undo();
914
- const index = state.sceneEntries.findIndex((entry) => entry.root === current.root);
915
- const prev = index >= 0 ? state.sceneEntries[index] : null;
916
- if (!json || !prev) {
917
- force();
918
- return;
919
- }
920
- const selectedIndex = nodeIndexInEffect(current.root, selectedNodeRef.current);
921
- const visible = isVisible(prev);
922
- const { root, systems } = parseEffectFromJson(scene, renderer, json, {
923
- registerRenderer: true,
924
- autoplay: false,
925
- });
926
- if (systems.length === 0) {
927
- disposeSceneEntry({ ...prev, root, systems }, renderer);
928
- force();
929
- return;
930
- }
931
- disposeSceneEntry(prev, renderer);
932
- // The snapshot carries the root transform too, so a moved effect moves back.
933
- root.parent = null;
934
- root.name = prev.name;
935
- const nextEntry = { ...prev, root, systems };
936
- setSceneEntryVisible(nextEntry, renderer, visible);
937
- const entries = [...state.sceneEntries];
938
- entries[index] = nextEntry;
939
- commitEntries(entries);
940
- commitVisible(state.visibleRootIds.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id)));
941
- if (solo) {
942
- if (solo.rootId === prev.root.uniqueId) {
943
- solo.rootId = root.uniqueId;
944
- setSoloRootId(root.uniqueId);
945
- }
946
- solo.hidden = solo.hidden.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id));
947
- }
948
- const next = bindingFromSceneEntry(nextEntry);
949
- history.attach(next);
950
- next.subscribe(onBindingChange);
951
- state.binding = next;
952
- setBinding(next);
953
- setSelectedNode(nodeAtIndex(root, selectedIndex));
954
- setFocusedRoot(root);
955
- playbackRef.current.focusRootId = root.uniqueId;
956
- if (visible) {
957
- selectionMarker.show(root);
958
- }
959
- notifyTransformChanged();
960
- refreshPlayback();
961
- };
962
- // ---- handle -----------------------------------------------------------------------------
963
- const makeHandle = () => ({
964
- get binding() {
965
- const b = state.binding;
966
- if (!b) {
967
- throw new Error('No effect is focused — add one to the scene first.');
968
- }
969
- return b;
970
- },
971
- history,
972
- scene,
973
- renderer,
974
- importJson,
975
- importAssets,
976
- importGallery: importAssets,
977
- exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
978
- });
979
- /** Adds `json` to Assets and returns its single instance; throws when it has no systems. */
980
- const openJson = (json, name = 'Effect') => {
981
- const asset = createEffectAsset(name, `_opened/${name}.json`, json);
982
- const entry = instantiateSceneEntry(scene, renderer, asset, state.sceneEntries);
983
- if (!entry) {
984
- throw new Error('No particle systems found in the JSON.');
985
- }
986
- asset.name = entry.name;
987
- asset.path = `_opened/${entry.name}.json`;
988
- commitAssets([...state.assets, asset]);
989
- return entry;
990
- };
991
- const importJson = (json) => {
992
- closePrefab();
993
- const entry = openJson(json);
994
- for (const existing of state.sceneEntries) {
995
- disposeSceneEntry(existing, renderer);
996
- }
997
- historyByRoot.clear();
998
- solo = null;
999
- setSoloRootId(null);
1000
- commitEntries([]);
1001
- commitVisible([]);
1002
- addEntries([entry], BVector3.Zero());
1003
- };
1004
- actionsRef.current = {
1005
- importAssets,
1006
- focusEntry: (entry) => focusEntry(entry),
1007
- toggleVisible,
1008
- toggleSolo,
1009
- deleteEntry,
1010
- createDefault,
1011
- placeDrop,
1012
- addToScene,
1013
- deleteAssets,
1014
- saveEntryAsAsset,
1015
- saveScene,
1016
- openPrefab,
1017
- overwritePrefab,
1018
- savePrefabAsNew,
1019
- closePrefab,
1020
- prefabInstanceCount,
1021
- historyStep,
1022
- };
1023
- if (props.project) {
1024
- void loadProject(props.project);
1025
- }
1026
- else if (props.effectJson) {
1027
- addEntries([openJson(props.effectJson)], BVector3.Zero());
1028
- }
1029
- else {
1030
- createDefault();
1031
- }
1032
- engine.runRenderLoop(() => {
1033
- const playback = playbackRef.current;
1034
- const entries = state.sceneEntries;
1035
- const visibleIds = new Set(state.visibleRootIds);
1036
- const focusBinding = state.binding;
1037
- if (!playback.scrubbing) {
1038
- let delta = (engine.getDeltaTime() / 1000) * playback.speed;
1039
- const stepQueued = playback.stepQueued;
1040
- if (!playback.stagePlaying) {
1041
- delta = stepQueued ? 1 / 60 : 0;
1042
- playback.stepQueued = false;
1043
- }
1044
- if (delta > 0) {
1045
- const simulate = hasActivePreview(entries, visibleIds) && (playback.stagePlaying || stepQueued);
1046
- if (simulate) {
1047
- renderer.update(delta);
1048
- let needsRefresh = false;
1049
- if (pauseFinishedPreviews(entries, visibleIds, playback.focusFinishedByRoot)) {
1050
- needsRefresh = true;
1051
- }
1052
- const focusId = playback.focusRootId ?? focusBinding?.root.uniqueId ?? null;
1053
- if (focusId != null &&
1054
- focusBinding &&
1055
- playback.stagePlaying &&
1056
- !playback.focusFinishedByRoot.has(focusId) &&
1057
- !focusTimelineMetaRef.current.anyLooping &&
1058
- QuarksUtil.isEffectFinished(focusBinding.root)) {
1059
- const settleAt = getFocusElapsed(playback, focusId);
1060
- scrubFocusToSettled(focusBinding, renderer, settleAt);
1061
- markFocusFinished(playback, focusId);
1062
- pauseFocus(focusBinding);
1063
- needsRefresh = true;
1064
- }
1065
- else if (focusId != null &&
1066
- playback.stagePlaying &&
1067
- !playback.focusFinishedByRoot.has(focusId)) {
1068
- const prev = getFocusElapsed(playback, focusId);
1069
- setFocusElapsed(playback, focusId, prev + delta);
1070
- }
1071
- if (needsRefresh) {
1072
- renderer.refreshBatches();
1073
- }
1074
- logRenderMismatch(renderer, countGalleryParticles(entries, visibleIds));
1075
- }
1076
- else if (entries.length > 0) {
1077
- renderer.refreshBatches();
1078
- }
1079
- }
1080
- }
1081
- scene.render();
1082
- selectionMarker.follow();
1083
- viewportPicker.follow();
1084
- if (focusBinding) {
1085
- emitterWireframes.sync([focusBinding.system, ...focusBinding.subSystems], `${focusBinding.root.uniqueId}:${focusBinding.getRevision()}`);
1086
- }
1087
- const pickKey = entries
1088
- .map((entry) => `${entry.root.uniqueId}:${visibleIds.has(entry.root.uniqueId) ? 1 : 0}`)
1089
- .join('|');
1090
- viewportPicker.sync(entries, null, `${pickKey}::${focusBinding?.root.uniqueId ?? 'none'}`);
1091
- if (fpsRef.current) {
1092
- fpsRef.current.textContent = `${Math.round(engine.getFps())} FPS`;
1093
- }
1094
- if (focusBinding &&
1095
- (focusBinding.root.uniqueId !== focusTimelineMetaRef.current.rootId ||
1096
- focusBinding.getRevision() !== focusTimelineMetaRef.current.revision)) {
1097
- const rows = computeTimelineRows(focusBinding);
1098
- focusTimelineMetaRef.current = {
1099
- rootId: focusBinding.root.uniqueId,
1100
- revision: focusBinding.getRevision(),
1101
- span: computeTimelineSpan(rows),
1102
- anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
1103
- };
1104
- }
1105
- if (counterRef.current) {
1106
- const count = countGalleryParticles(entries, visibleIds);
1107
- const focused = entries.find((entry) => entry.root === focusBinding?.root);
1108
- const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
1109
- const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
1110
- const focusEnded = focusId != null && isFocusFinished(playback, focusId);
1111
- const meta = focusTimelineMetaRef.current;
1112
- const timeLabel = meta.anyLooping
1113
- ? `${focusElapsed.toFixed(1)}s ⟳`
1114
- : focusEnded
1115
- ? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
1116
- : `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
1117
- counterRef.current.textContent = focused ? `${timeLabel} · ${count} particles` : `${count} particles`;
1118
- }
1119
- });
1120
- const resizeObserver = new ResizeObserver(() => engine.resize());
1121
- resizeObserver.observe(canvasRef.current);
1122
- const onKey = (e) => {
1123
- const key = e.key.toLowerCase();
1124
- if ((e.ctrlKey || e.metaKey) && key === 's') {
1125
- e.preventDefault();
1126
- if (prefab) {
1127
- setPrefabDialog('overwrite');
1128
- }
1129
- else {
1130
- void saveScene();
1131
- }
1132
- return;
1133
- }
1134
- if (e.code === 'Space' && (e.ctrlKey || e.metaKey)) {
1135
- e.preventDefault();
1136
- setDrawerOpen((open) => !open);
1137
- return;
1138
- }
1139
- const target = e.target;
1140
- if (e.code === 'Space' && !isTextInput(target) && target?.tagName !== 'BUTTON') {
1141
- e.preventDefault();
1142
- const playback = playbackRef.current;
1143
- toggleStagePlayback(state.binding, scene, playback, playback.stagePlaying, setStagePlaying);
1144
- return;
1145
- }
1146
- if (e.key === 'Escape') {
1147
- closeFloatingDrawer();
1148
- }
1149
- if ((e.ctrlKey || e.metaKey) && (key === 'z' || key === 'y')) {
1150
- if (isTextInput(e.target) && !isInspectorValueControl(e.target)) {
1151
- return;
1152
- }
1153
- e.preventDefault();
1154
- // Leave the field first so it shows the restored value instead of its draft text.
1155
- e.target?.blur?.();
1156
- historyStep(key === 'y' || e.shiftKey);
1157
- return;
1158
- }
1159
- if (e.key === 'Delete' && !isTextInput(e.target)) {
1160
- const entry = state.sceneEntries.find((item) => item.root === state.binding?.root);
1161
- if (entry) {
1162
- e.preventDefault();
1163
- deleteEntry(entry);
1164
- }
1165
- }
1166
- };
1167
- window.addEventListener('keydown', onKey);
1168
- return () => {
1169
- unsubscribeTransform();
1170
- window.clearTimeout(toastTimerRef.current);
1171
- resizeObserver.disconnect();
1172
- window.removeEventListener('keydown', onKey);
1173
- window.removeEventListener('dragend', onWindowDragEnd);
1174
- clearAssetDragPreview();
1175
- cancelGalleryCameraFocus();
1176
- dropMarker.dispose();
1177
- dropMarkerRef.current = null;
1178
- selectionMarker.dispose();
1179
- selectionMarkerRef.current = null;
1180
- emitterWireframes.dispose();
1181
- emitterWireframesRef.current = null;
1182
- viewportPicker.dispose();
1183
- viewportPickerRef.current = null;
1184
- canvasRef.current?.removeEventListener('pointerleave', handleViewportPointerLeave);
1185
- scene.onPointerObservable.remove(pointerObserver);
1186
- gizmoManager.dispose();
1187
- gizmoUtilityLayer.dispose();
1188
- engine.dispose();
1189
- };
1190
- // eslint-disable-next-line react-hooks/exhaustive-deps
1191
- }, []);
1192
- useEffect(() => {
1193
- playbackRef.current.stagePlaying = stagePlaying;
1194
- const snap = stateRef.current;
1195
- if (!snap) {
1196
- return;
1197
- }
1198
- applyGalleryPlayback(snap.sceneEntries, new Set(visibleRootIds), stagePlaying, playbackRef.current.focusFinishedByRoot);
1199
- if (!stagePlaying) {
1200
- snap.renderer.refreshBatches();
1201
- }
1202
- }, [stagePlaying, visibleRootIds, sceneEntries]);
1203
- useEffect(() => {
1204
- emitterWireframesRef.current?.setVisible(showEmitterWireframes);
1205
- }, [showEmitterWireframes]);
1206
- // The floating drawer is opaque, so render the scene only in the part of the viewport above
1207
- // it: the view — and every "frame this effect" camera move — re-centres on what is visible.
1208
- useEffect(() => {
1209
- const update = () => {
1210
- const camera = stateRef.current?.camera;
1211
- const canvas = canvasRef.current;
1212
- const area = mainAreaRef.current;
1213
- if (!camera || !canvas || !area)
1214
- return;
1215
- const canvasRect = canvas.getBoundingClientRect();
1216
- const drawerTop = area.getBoundingClientRect().bottom - DRAWER_HEIGHT;
1217
- const covered = drawerOpen && !drawerPinned
1218
- ? Math.max(0, Math.min(canvasRect.height * 0.8, canvasRect.bottom - drawerTop))
1219
- : 0;
1220
- const fraction = canvasRect.height > 0 ? covered / canvasRect.height : 0;
1221
- camera.viewport = new Viewport(0, fraction, 1, 1 - fraction);
1222
- setViewportInset(covered);
1223
- };
1224
- update();
1225
- window.addEventListener('resize', update);
1226
- return () => window.removeEventListener('resize', update);
1227
- }, [drawerOpen, drawerPinned, timelineCollapsed]);
1228
- useEffect(() => {
1229
- if (!props.project || !sceneDirty)
1230
- return;
1231
- const onBeforeUnload = (e) => {
1232
- e.preventDefault();
1233
- e.returnValue = '';
1234
- };
1235
- window.addEventListener('beforeunload', onBeforeUnload);
1236
- return () => window.removeEventListener('beforeunload', onBeforeUnload);
1237
- }, [props.project, sceneDirty]);
1238
255
  const requestPrefabExit = () => {
1239
256
  if (prefabDirty) {
1240
257
  setPrefabDialog('exit');
1241
258
  }
1242
259
  else {
1243
- actionsRef.current.closePrefab();
260
+ session?.closePrefab();
1244
261
  }
1245
262
  };
1246
263
  const requestExitProject = () => {
@@ -1255,106 +272,33 @@ export function EffectEditorHost(props) {
1255
272
  props.onExitProject?.();
1256
273
  }
1257
274
  };
1258
- useEffect(() => {
1259
- const gm = gizmoManagerRef.current;
1260
- if (!gm)
1261
- return;
1262
- gm.positionGizmoEnabled = gizmoMode === 'position';
1263
- gm.rotationGizmoEnabled = gizmoMode === 'rotation';
1264
- gm.scaleGizmoEnabled = gizmoMode === 'scale';
1265
- const activeGizmo = gizmoMode === 'position'
1266
- ? gm.gizmos.positionGizmo
1267
- : gizmoMode === 'rotation'
1268
- ? gm.gizmos.rotationGizmo
1269
- : gizmoMode === 'scale'
1270
- ? gm.gizmos.scaleGizmo
1271
- : null;
1272
- let dragRaf = 0;
1273
- const scheduleTransformRefresh = () => {
1274
- if (dragRaf !== 0) {
1275
- return;
1276
- }
1277
- dragRaf = requestAnimationFrame(() => {
1278
- dragRaf = 0;
1279
- notifyTransformChanged();
1280
- });
1281
- };
1282
- // One gizmo drag = one undo step: snapshot at drag start, refresh the UI at the end.
1283
- const dragStartObserver = activeGizmo?.onDragStartObservable.add(() => stateRef.current?.binding?.onBeforeChange?.());
1284
- const dragObserver = activeGizmo?.onDragObservable.add(scheduleTransformRefresh);
1285
- const dragEndObserver = activeGizmo?.onDragEndObservable.add(() => {
1286
- notifyTransformChanged();
1287
- stateRef.current?.binding?.markChanged();
1288
- });
1289
- return () => {
1290
- if (dragRaf !== 0) {
1291
- cancelAnimationFrame(dragRaf);
1292
- }
1293
- if (activeGizmo && dragStartObserver) {
1294
- activeGizmo.onDragStartObservable.remove(dragStartObserver);
1295
- }
1296
- if (activeGizmo && dragObserver) {
1297
- activeGizmo.onDragObservable.remove(dragObserver);
1298
- }
1299
- if (activeGizmo && dragEndObserver) {
1300
- activeGizmo.onDragEndObservable.remove(dragEndObserver);
1301
- }
1302
- };
1303
- }, [gizmoMode]);
1304
- useEffect(() => {
1305
- const gm = gizmoManagerRef.current;
1306
- if (!gm)
1307
- return;
1308
- if (selectedNode && selectedNode.isDisposed()) {
1309
- setSelectedNode(stateRef.current?.binding?.root ?? null);
1310
- return;
1311
- }
1312
- if (isAssetsLoading) {
1313
- return;
1314
- }
1315
- const target = selectedNode && !selectedNode.isDisposed()
1316
- ? selectedNode
1317
- : binding?.root && !binding.root.isDisposed()
1318
- ? binding.root
1319
- : null;
1320
- gm.attachToNode(target);
1321
- }, [selectedNode, binding, isAssetsLoading]);
1322
- const state = stateRef.current;
1323
- const gizmoNode = selectedNode && !selectedNode.isDisposed()
1324
- ? selectedNode
1325
- : binding?.root && !binding.root.isDisposed()
1326
- ? binding.root
1327
- : null;
1328
275
  const allSystems = binding ? [binding.system, ...binding.subSystems] : [];
1329
- const activeSystem = binding ? (allSystems.find((s) => s.emitter === selectedNode) ?? binding.system) : null;
276
+ const activeSystem = binding ? (allSystems.find((s) => s.emitter === liveSelection) ?? binding.system) : null;
1330
277
  // The inspector is the expensive part of a selection change (every module re-renders for the
1331
278
  // new system). It renders from deferred values, so the click, tree highlight and gizmo update
1332
279
  // at once and the inspector catches up in an interruptible render instead of stalling frames.
1333
280
  const deferredBinding = useDeferredValue(binding);
1334
- const deferredNode = useDeferredValue(selectedNode);
281
+ const deferredNode = useDeferredValue(liveSelection);
1335
282
  const inspectorBinding = deferredBinding && !deferredBinding.root.isDisposed() ? deferredBinding : binding;
1336
283
  const inspectorNode = deferredNode && !deferredNode.isDisposed() && inspectorBinding === deferredBinding
1337
284
  ? deferredNode
1338
285
  : (inspectorBinding?.root ?? null);
1339
- const inspectorSystems = inspectorBinding ? [inspectorBinding.system, ...inspectorBinding.subSystems] : [];
1340
286
  const inspectorSystem = inspectorBinding
1341
- ? (inspectorSystems.find((s) => s.emitter === inspectorNode) ?? inspectorBinding.system)
287
+ ? ([inspectorBinding.system, ...inspectorBinding.subSystems].find((s) => s.emitter === inspectorNode) ??
288
+ inspectorBinding.system)
1342
289
  : null;
1343
290
  const visibleRootIdSet = useMemo(() => new Set(visibleRootIds), [visibleRootIds]);
1344
291
  const instanceCounts = useMemo(() => countInstancesByAsset(sceneEntries), [sceneEntries]);
1345
- const showTimeline = Boolean(binding && activeSystem && state);
1346
292
  const timelinePlaybackEnabled = !focusedRoot || visibleRootIdSet.has(focusedRoot.uniqueId);
1347
- const resolveTextureForEditor = useCallback((url) => {
1348
- const scene = stateRef.current?.scene;
1349
- return scene && props.resolveTexture ? props.resolveTexture(url, scene) : undefined;
1350
- }, [props.resolveTexture]);
293
+ const { resolveTexture, resolveGeometry } = props;
294
+ const resolveTextureForEditor = useCallback((url) => (session && resolveTexture ? resolveTexture(url, session.scene) : undefined), [session, resolveTexture]);
1351
295
  const resolveGeometryForEditor = useCallback((file) => {
1352
- const scene = stateRef.current?.scene;
1353
- if (!scene || !props.resolveGeometry) {
296
+ if (!session || !resolveGeometry) {
1354
297
  return Promise.reject(new Error('Geometry loader is not available.'));
1355
298
  }
1356
- return props.resolveGeometry(file, scene);
1357
- }, [props.resolveGeometry]);
299
+ return resolveGeometry(file, session.scene);
300
+ }, [session, resolveGeometry]);
301
+ const isAssetDrag = (e) => e.dataTransfer.types.includes(ASSET_DRAG_MIME);
1358
302
  return (_jsxs("div", { className: "qe-root", style: {
1359
303
  display: 'flex',
1360
304
  flexDirection: 'column',
@@ -1370,450 +314,79 @@ export function EffectEditorHost(props) {
1370
314
  display: 'flex',
1371
315
  flexDirection: 'column',
1372
316
  overflow: 'hidden',
1373
- }, children: [_jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsxs("aside", { style: {
1374
- width: SCENE_PANEL_WIDTH,
1375
- flexShrink: 0,
1376
- borderRight: `1px solid ${theme.border}`,
1377
- background: theme.panelBg,
1378
- display: 'flex',
1379
- flexDirection: 'column',
1380
- minHeight: 0,
1381
- }, children: [props.project && (_jsxs("div", { style: {
1382
- display: 'flex',
1383
- alignItems: 'center',
1384
- gap: 6,
1385
- padding: '8px 10px',
1386
- borderBottom: `1px solid ${theme.border}`,
1387
- }, children: [props.onExitProject && (_jsx("button", { type: "button", className: "qe-hover", title: "Back to projects", style: { ...dockIconButtonStyle, width: 26, height: 26 }, onClick: requestExitProject, children: _jsx(ArrowLeftIcon, { style: iconStyle(13) }) })), _jsxs("span", { title: props.project.name, style: {
1388
- flex: 1,
1389
- minWidth: 0,
1390
- fontSize: 13,
1391
- fontWeight: 600,
1392
- overflow: 'hidden',
1393
- textOverflow: 'ellipsis',
1394
- whiteSpace: 'nowrap',
1395
- }, children: [props.project.name, sceneDirty ? ' *' : ''] }), _jsx("button", { type: "button", className: "qe-hover", title: "Save the scene (Ctrl+S) \u2014 assets are not changed", disabled: projectLoading, style: {
1396
- ...buttonStyle,
1397
- padding: '4px 12px',
1398
- ...(sceneDirty
1399
- ? {
1400
- background: theme.accent,
1401
- color: '#0a1128',
1402
- border: `1px solid ${theme.accent}`,
1403
- }
1404
- : {}),
1405
- }, onClick: () => prefabInfo ? setPrefabDialog('overwrite') : void actionsRef.current.saveScene(), children: "Save" })] })), _jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { style: { fontSize: 14, fontWeight: 600 }, children: prefabInfo
1406
- ? 'Prefab'
1407
- : `Scene${sceneEntries.length > 0 ? ` · ${sceneEntries.length}` : ''}` }), _jsx("div", { style: { fontSize: 11, color: theme.textDim, marginTop: 4, lineHeight: 1.4 }, children: prefabInfo
1408
- ? 'The scene is set aside while you edit this asset'
1409
- : 'Effects in the viewport · click to edit · eye shows/hides' })] }), _jsx("div", { style: {
1410
- flex: 1,
1411
- minHeight: 0,
1412
- overflow: 'hidden',
1413
- padding: '8px 12px 14px',
1414
- display: 'flex',
1415
- flexDirection: 'column',
1416
- }, children: prefabInfo ? (_jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0 }, children: [_jsxs("div", { style: {
1417
- display: 'flex',
1418
- flexDirection: 'column',
1419
- gap: 10,
1420
- padding: 12,
1421
- border: `1px solid ${theme.borderActive}`,
1422
- borderRadius: 8,
1423
- background: 'rgba(120, 165, 255, 0.08)',
1424
- fontSize: 12,
1425
- color: theme.textDim,
1426
- lineHeight: 1.5,
1427
- }, children: [_jsxs("div", { style: { fontSize: 13, fontWeight: 600, color: theme.text }, children: [prefabInfo.name, prefabDirty ? ' *' : ''] }), _jsx("div", { style: { wordBreak: 'break-all' }, children: prefabInfo.path }), _jsx("div", { children: "Save (Ctrl+S) overwrites this asset. Effects already placed in the scene keep their own copy and do not change." }), _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx("button", { type: "button", className: "qe-hover", style: buttonStyle, onClick: requestPrefabExit, children: "\u2190 Scene" }), _jsx("button", { type: "button", className: "qe-hover", style: buttonStyle, onClick: () => setPrefabDialog('overwrite'), children: "Save prefab" })] })] }), sceneEntries[0] && (_jsx("div", { style: {
1428
- flex: 1,
1429
- minHeight: 0,
1430
- overflowY: 'auto',
1431
- border: `1px solid ${theme.border}`,
1432
- borderRadius: 8,
1433
- background: theme.sectionBg,
1434
- padding: '4px 0',
1435
- }, children: _jsx(EffectTreeView, { includeRoot: true, root: sceneEntries[0].root, selectedNode: selectedNode, onSelect: setSelectedNode }) }))] })) : (_jsx(ScenePanel, { entries: sceneEntries, focusedRoot: focusedRoot, visibleRootIds: visibleRootIdSet, onFocus: (entry) => actionsRef.current.focusEntry(entry), onToggleVisible: (entry) => actionsRef.current.toggleVisible(entry), soloRootId: soloRootId, onToggleSolo: (entry) => actionsRef.current.toggleSolo(entry), onSaveAsAsset: (entry) => setSaveAsTarget(entry), onDelete: (entry) => actionsRef.current.deleteEntry(entry), onCreateDefault: () => actionsRef.current.createDefault(), selectedNode: selectedNode, onSelectNode: (entry, node) => {
1436
- if (stateRef.current?.binding?.root !== entry.root) {
1437
- actionsRef.current.focusEntry(entry);
1438
- }
1439
- setSelectedNode(node);
1440
- } })) })] }), _jsxs("div", { style: { position: 'relative', flex: 1, minWidth: 0 }, onDragEnter: (e) => {
1441
- if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
1442
- return;
1443
- assetDragDepthRef.current += 1;
1444
- }, onDragLeave: (e) => {
1445
- if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
1446
- return;
1447
- assetDragDepthRef.current -= 1;
1448
- if (assetDragDepthRef.current <= 0) {
1449
- assetDragDepthRef.current = 0;
1450
- dropMarkerRef.current?.hide();
1451
- setAssetDragHover(null);
1452
- }
1453
- }, onDragOver: (e) => {
1454
- if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
1455
- return;
1456
- e.preventDefault();
1457
- e.dataTransfer.dropEffect = 'copy';
1458
- const payload = getAssetDragSession();
1459
- const snap = stateRef.current;
1460
- if (!payload || !snap)
1461
- return;
1462
- const described = describeAssetDrag(snap.assets, payload);
1463
- if (described.assets.length === 0)
1464
- return;
1465
- const position = pickGroundPosition(snap.scene, e.clientX, e.clientY);
1466
- dropMarkerRef.current?.show(position, described.assets.length);
1467
- setAssetDragHover({ label: described.label, count: described.assets.length });
1468
- }, onDrop: (e) => {
1469
- if (!e.dataTransfer.types.includes(ASSET_DRAG_MIME))
1470
- return;
1471
- e.preventDefault();
1472
- actionsRef.current.placeDrop(e.clientX, e.clientY, e.dataTransfer);
1473
- }, children: [_jsx("canvas", { ref: canvasRef, style: {
1474
- width: '100%',
1475
- height: '100%',
1476
- display: 'block',
1477
- touchAction: 'none',
1478
- outline: 'none',
1479
- } }), _jsx("div", { style: {
1480
- position: 'absolute',
1481
- bottom: 12 + viewportInset,
1482
- left: 12,
1483
- background: theme.panelBg,
1484
- border: `1px solid ${theme.border}`,
1485
- borderRadius: 8,
1486
- padding: '4px 10px',
1487
- fontSize: 12,
1488
- color: theme.textDim,
1489
- fontVariantNumeric: 'tabular-nums',
1490
- pointerEvents: 'none',
1491
- fontFamily: theme.font,
1492
- }, children: _jsx("span", { ref: fpsRef, children: "\u2014 FPS" }) }), state && (_jsx("div", { style: {
1493
- position: 'absolute',
1494
- bottom: 12 + viewportInset,
1495
- left: '50%',
1496
- transform: 'translateX(-50%)',
1497
- }, children: _jsx(TransportBar, { binding: binding, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, counterRef: counterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, enabled: timelinePlaybackEnabled }) })), assetDragHover && (_jsx("div", { style: {
1498
- position: 'absolute',
1499
- inset: 10,
1500
- border: '2px dashed rgba(120, 165, 255, 0.75)',
1501
- borderRadius: 12,
1502
- pointerEvents: 'none',
1503
- boxShadow: 'inset 0 0 0 1px rgba(120, 165, 255, 0.15)',
1504
- }, children: _jsxs("div", { style: {
1505
- position: 'absolute',
1506
- left: '50%',
1507
- top: 18,
1508
- transform: 'translateX(-50%)',
1509
- background: 'rgba(7, 11, 22, 0.88)',
1510
- border: `1px solid ${theme.border}`,
1511
- borderRadius: 8,
1512
- padding: '8px 14px',
1513
- fontSize: 13,
1514
- color: theme.text,
1515
- whiteSpace: 'nowrap',
1516
- }, children: ["Drop ", assetDragHover.count === 1 ? 'effect' : `${assetDragHover.count} effects`, " \u2014", ' ', assetDragHover.label] }) })), assetsLoading && (_jsx("div", { style: {
1517
- position: 'absolute',
1518
- inset: 0,
1519
- display: 'flex',
1520
- alignItems: 'center',
1521
- justifyContent: 'center',
1522
- background: 'rgba(7, 11, 22, 0.72)',
1523
- pointerEvents: 'none',
1524
- }, children: _jsxs("div", { style: {
1525
- background: theme.panelBg,
1526
- border: `1px solid ${theme.border}`,
1527
- borderRadius: 10,
1528
- padding: '16px 22px',
1529
- textAlign: 'center',
1530
- }, children: [_jsx("div", { style: { fontWeight: 600, marginBottom: 6 }, children: "Importing assets\u2026" }), _jsxs("div", { style: { fontSize: 13, color: theme.textDim }, children: [assetsLoading.done, " / ", assetsLoading.total || '…', " files", assetsLoading.failed > 0 ? ` · ${assetsLoading.failed} skipped` : ''] })] }) })), prefabInfo && (_jsxs("div", { style: {
1531
- position: 'absolute',
1532
- top: 12,
1533
- left: '50%',
1534
- transform: 'translateX(-50%)',
1535
- display: 'flex',
1536
- alignItems: 'center',
1537
- gap: 8,
1538
- padding: '4px 4px 4px 6px',
1539
- background: theme.panelBg,
1540
- border: `1px solid ${theme.borderActive}`,
1541
- borderRadius: 8,
1542
- fontSize: 12.5,
1543
- zIndex: 5,
1544
- }, children: [_jsx("button", { type: "button", className: "qe-hover", title: "Back to the scene", style: { ...buttonStyle, padding: '3px 10px' }, onClick: requestPrefabExit, children: "\u2190 Scene" }), _jsx("span", { style: { color: theme.textDim }, children: "\u203A" }), _jsxs("span", { style: { fontWeight: 600, whiteSpace: 'nowrap' }, children: [prefabInfo.name, prefabDirty ? ' *' : ''] }), _jsx("button", { type: "button", className: "qe-hover", title: "Overwrite the prefab asset (Ctrl+S)", style: {
1545
- ...buttonStyle,
1546
- padding: '3px 12px',
1547
- ...(prefabDirty
1548
- ? {
1549
- background: theme.accent,
1550
- color: '#0a1128',
1551
- border: `1px solid ${theme.accent}`,
1552
- }
1553
- : {}),
1554
- }, onClick: () => setPrefabDialog('overwrite'), children: "Save" })] })), props.backLink && !prefabInfo && (_jsxs("a", { className: "qe-hover", href: props.backLink.href, style: {
1555
- position: 'absolute',
1556
- top: 12,
1557
- left: 12,
1558
- display: 'inline-flex',
1559
- alignItems: 'center',
1560
- gap: 6,
1561
- background: theme.panelBg,
1562
- border: `1px solid ${theme.border}`,
1563
- borderRadius: 8,
1564
- padding: '6px 12px',
1565
- fontSize: 13,
1566
- fontWeight: 500,
1567
- color: theme.text,
1568
- textDecoration: 'none',
1569
- fontFamily: theme.font,
1570
- }, children: [_jsx(ArrowLeftIcon, { style: iconStyle(14) }), props.backLink.label.replace(/^←\s*/, '')] })), _jsxs("div", { style: {
1571
- position: 'absolute',
1572
- top: 12,
1573
- right: 12,
1574
- display: 'flex',
1575
- flexDirection: 'column',
1576
- alignItems: 'flex-end',
1577
- gap: 6,
1578
- }, children: [_jsxs("div", { style: {
1579
- display: 'flex',
1580
- gap: 4,
1581
- background: theme.panelBg,
1582
- border: `1px solid ${theme.border}`,
1583
- borderRadius: 8,
1584
- padding: 4,
1585
- }, children: [_jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Undo (Ctrl+Z)", disabled: !state?.history.canUndo || !binding, onClick: () => actionsRef.current.historyStep(false), children: _jsx(ArrowUturnLeftIcon, { style: iconStyle(14) }) }), _jsx("button", { className: "qe-hover", style: squareButtonStyle, title: "Redo (Ctrl+Shift+Z)", disabled: !state?.history.canRedo || !binding, onClick: () => actionsRef.current.historyStep(true), children: _jsx(ArrowUturnRightIcon, { style: iconStyle(14) }) })] }), _jsx("div", { style: {
1586
- display: 'flex',
1587
- flexDirection: 'column',
1588
- gap: 4,
1589
- background: theme.panelBg,
1590
- border: `1px solid ${theme.border}`,
1591
- borderRadius: 8,
1592
- padding: 4,
1593
- }, children: GIZMO_MODE_BUTTONS.map((m) => (_jsx("button", { className: "qe-hover", style: {
1594
- ...squareButtonStyle,
1595
- ...(gizmoMode === m.value ? activeSquareButtonStyle : {}),
1596
- }, title: m.title, onClick: () => setGizmoMode(m.value), children: _jsx(m.Icon, { style: iconStyle(14) }) }, m.value))) }), _jsx("div", { style: {
1597
- display: 'flex',
1598
- flexDirection: 'column',
1599
- gap: 4,
1600
- background: theme.panelBg,
1601
- border: `1px solid ${theme.border}`,
1602
- borderRadius: 8,
1603
- padding: 4,
1604
- }, children: _jsx("button", { className: "qe-hover", style: {
1605
- ...squareButtonStyle,
1606
- ...(showEmitterWireframes ? activeSquareButtonStyle : {}),
1607
- }, title: "Show emitter shape wireframes", disabled: !binding, onClick: () => setShowEmitterWireframes((on) => !on), children: _jsx(CubeTransparentIcon, { style: iconStyle(14) }) }) })] })] }), _jsxs("aside", { style: {
1608
- width: 340,
1609
- flexShrink: 0,
1610
- borderLeft: `1px solid ${theme.border}`,
1611
- background: theme.panelBg,
1612
- display: 'flex',
1613
- flexDirection: 'column',
1614
- minHeight: 0,
1615
- }, children: [_jsxs("div", { style: { padding: '12px 14px 10px', borderBottom: `1px solid ${theme.border}` }, children: [_jsx("div", { style: {
1616
- fontSize: 11,
1617
- fontWeight: 600,
1618
- color: theme.textDim,
1619
- textTransform: 'uppercase',
1620
- letterSpacing: 0.4,
1621
- marginBottom: 6,
1622
- }, children: "Inspector" }), _jsx("div", { className: "qe-hover-bg", title: binding ? 'Rename effect' : undefined, style: {
1623
- fontSize: 16,
1624
- fontWeight: 600,
1625
- cursor: binding ? 'pointer' : 'default',
1626
- borderRadius: 6,
1627
- padding: '2px 4px',
1628
- margin: '-2px -4px',
1629
- }, onClick: () => binding && setRenamingRoot(true), children: (binding?.root.name || props.title) ?? 'Effect editor' }), renamingRoot && binding && (_jsx(PromptDialog, { title: "Rename effect", defaultValue: binding.root.name, onSubmit: (name) => {
1630
- binding.apply(() => (binding.root.name = name));
1631
- const entry = sceneEntries.find((item) => item.root === binding.root);
1632
- if (entry) {
1633
- entry.name = name;
1634
- }
1635
- setRenamingRoot(false);
1636
- }, onCancel: () => setRenamingRoot(false) })), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10 }, children: [_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => {
1637
- if (!binding)
1638
- return;
1639
- const blob = new Blob([exportEffectJSON(binding, showToast)], {
1640
- type: 'application/json',
1641
- });
1642
- const link = document.createElement('a');
1643
- link.href = URL.createObjectURL(blob);
1644
- link.download = `${binding.root.name || 'effect'}.json`;
1645
- link.click();
1646
- URL.revokeObjectURL(link.href);
1647
- }, children: "Export" }), props.onSave && (_jsx("button", { className: "qe-hover", style: buttonStyle, disabled: !binding, onClick: () => binding && props.onSave(exportEffectJSON(binding, showToast)), children: "Save" }))] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '6px 14px 20px' }, children: inspectorBinding && inspectorSystem && inspectorNode ? (_jsx(EffectEditor, { binding: inspectorBinding, selectedSystem: inspectorSystem, gizmoTargetNode: inspectorNode, gizmoTargetLabel: inspectorNode.name || 'Node', textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })) : (_jsx("div", { style: { fontSize: 13, color: theme.textDim, lineHeight: 1.5 }, children: "No effect selected." })) })] })] }), _jsxs("div", { "aria-hidden": !drawerOpen && !drawerPinned, style: {
1648
- height: DRAWER_HEIGHT,
1649
- display: 'flex',
1650
- flexDirection: 'column',
1651
- background: theme.panelBg,
1652
- borderTop: `1px solid ${theme.border}`,
1653
- ...(drawerPinned
1654
- ? { flexShrink: 0 }
1655
- : {
1656
- position: 'absolute',
1657
- left: 0,
1658
- right: 0,
1659
- bottom: 0,
1660
- zIndex: 20,
1661
- background: '#0a0f20',
1662
- boxShadow: '0 -10px 30px rgba(0, 0, 0, 0.5)',
1663
- transform: drawerOpen ? 'none' : 'translateY(100%)',
1664
- opacity: drawerOpen ? 1 : 0,
1665
- pointerEvents: drawerOpen ? 'auto' : 'none',
1666
- transition: 'transform 0.18s ease, opacity 0.18s ease',
1667
- }),
1668
- }, children: [_jsxs("div", { style: {
1669
- display: 'flex',
1670
- alignItems: 'center',
1671
- gap: 6,
1672
- padding: '4px 6px 4px 12px',
1673
- borderBottom: `1px solid ${theme.border}`,
1674
- }, children: [_jsx("span", { style: dockTitleStyle, children: "Assets" }), _jsx("span", { style: { fontSize: 11, color: theme.textDim }, children: "Ctrl+Space" }), _jsx("span", { style: { flex: 1 } }), _jsx("button", { type: "button", className: "qe-hover", title: drawerPinned
1675
- ? 'Unpin — float over the viewport again'
1676
- : 'Pin — keep it docked above the timeline', style: { ...dockIconButtonStyle, ...(drawerPinned ? activeSquareButtonStyle : {}) }, onClick: () => {
1677
- setDrawerPinned((pinned) => !pinned);
1678
- setDrawerOpen(true);
1679
- }, children: _jsx(MapPinIcon, { style: iconStyle(13) }) }), _jsx("button", { type: "button", className: "qe-hover", title: "Close (Esc)", style: dockIconButtonStyle, onClick: () => {
1680
- setDrawerPinned(false);
1681
- setDrawerOpen(false);
1682
- }, children: _jsx(XMarkIcon, { style: iconStyle(13) }) })] }), _jsx(AssetsPanel, { assets: assets, instanceCounts: instanceCounts, loading: assetsLoading, folderInputRef: assetFolderInputRef, onImportFiles: (files) => void runAssetImport(files), onImportFolder: handleImportFolder, onAddToScene: (payload) => actionsRef.current.addToScene(payload), onOpenPrefab: (asset) => actionsRef.current.openPrefab(asset.id), onDelete: (payload) => actionsRef.current.deleteAssets(payload) })] })] }), _jsxs("div", { style: {
1683
- flexShrink: 0,
1684
- borderTop: `1px solid ${theme.border}`,
1685
- background: theme.panelBg,
1686
- display: 'flex',
1687
- flexDirection: 'column',
1688
- }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, padding: '3px 6px' }, children: [_jsx("button", { type: "button", className: "qe-hover-bg", title: timelineCollapsed ? 'Expand timeline' : 'Collapse timeline', style: { ...dockIconButtonStyle, border: 'none', background: 'transparent' }, onClick: () => setTimelineCollapsed((collapsed) => !collapsed), children: timelineCollapsed ? (_jsx(ChevronUpIcon, { style: iconStyle(13) })) : (_jsx(ChevronDownIcon, { style: iconStyle(13) })) }), _jsx("span", { style: dockTitleStyle, children: "Timeline" }), binding && (_jsx("span", { style: {
1689
- fontSize: 12,
1690
- color: theme.textDim,
1691
- overflow: 'hidden',
1692
- textOverflow: 'ellipsis',
1693
- whiteSpace: 'nowrap',
1694
- }, children: binding.root.name })), _jsx("span", { style: { flex: 1 } }), _jsxs("button", { type: "button", className: "qe-hover", title: "Assets (Ctrl+Space)", style: {
1695
- ...buttonStyle,
1696
- padding: '3px 10px',
1697
- ...(drawerOpen || drawerPinned ? activeSquareButtonStyle : {}),
1698
- }, onClick: () => {
1699
- if (drawerPinned) {
1700
- setDrawerPinned(false);
1701
- setDrawerOpen(false);
317
+ }, children: [_jsxs("div", { style: { display: 'flex', flex: 1, minHeight: 0 }, children: [_jsx(SceneSidebar, { projectName: props.project?.name, canExitProject: Boolean(props.onExitProject), sceneDirty: sceneDirty, projectLoading: projectLoading, prefabInfo: prefabInfo, prefabDirty: prefabDirty, entries: sceneEntries, focusedRoot: focusedRoot, visibleRootIds: visibleRootIdSet, soloRootId: soloRootId, selectedNode: liveSelection, onExitProject: requestExitProject, onSave: savePrefabOrScene, onExitPrefab: requestPrefabExit, onSavePrefab: () => setPrefabDialog('overwrite'), onFocus: (entry) => session?.focusEntry(entry), onToggleVisible: (entry) => session?.toggleVisible(entry), onToggleSolo: (entry) => session?.toggleSolo(entry), onSaveAsAsset: setSaveAsTarget, onDelete: (entry) => session?.deleteEntry(entry), onCreateDefault: () => session?.createDefault(), onSelectNode: (entry, node) => {
318
+ if (entry && session?.binding?.root !== entry.root) {
319
+ session?.focusEntry(entry);
1702
320
  }
1703
- else {
1704
- setDrawerOpen((open) => !open);
321
+ setSelectedNode(node);
322
+ } }), _jsx(ViewportPane, { canvasRef: canvasRef, fpsRef: fpsRef, inset: viewportInset, transport: session && (_jsx(TransportBar, { binding: binding, scene: session.scene, renderer: session.renderer, playbackRef: playbackRef, counterRef: counterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, enabled: timelinePlaybackEnabled })), dragHover: assetDragHover, assetsLoading: assetsLoading, prefabInfo: prefabInfo, prefabDirty: prefabDirty, backLink: props.backLink, canUndo: Boolean(session?.history.canUndo), canRedo: Boolean(session?.history.canRedo), hasFocus: binding !== null, gizmoMode: gizmoMode, showWireframes: showEmitterWireframes, onHistoryStep: (redo) => session?.historyStep(redo), onGizmoMode: setGizmoMode, onToggleWireframes: () => setShowEmitterWireframes((on) => !on), onExitPrefab: requestPrefabExit, onSavePrefab: () => setPrefabDialog('overwrite'), dragHandlers: {
323
+ onDragEnter: (e) => {
324
+ if (isAssetDrag(e))
325
+ assetDragDepthRef.current += 1;
326
+ },
327
+ onDragLeave: (e) => {
328
+ if (!isAssetDrag(e))
329
+ return;
330
+ assetDragDepthRef.current -= 1;
331
+ if (assetDragDepthRef.current <= 0) {
332
+ assetDragDepthRef.current = 0;
333
+ session?.hideDropPreview();
334
+ }
335
+ },
336
+ onDragOver: (e) => {
337
+ if (!isAssetDrag(e))
338
+ return;
339
+ e.preventDefault();
340
+ e.dataTransfer.dropEffect = 'copy';
341
+ session?.previewDrop(e.clientX, e.clientY);
342
+ },
343
+ onDrop: (e) => {
344
+ if (!isAssetDrag(e))
345
+ return;
346
+ e.preventDefault();
347
+ assetDragDepthRef.current = 0;
348
+ session?.placeDrop(e.clientX, e.clientY, e.dataTransfer);
349
+ },
350
+ } }), _jsx(InspectorSidebar, { binding: binding, inspector: inspectorBinding && inspectorSystem && inspectorNode
351
+ ? { binding: inspectorBinding, system: inspectorSystem, node: inspectorNode }
352
+ : null, title: props.title, onRenamed: (name) => {
353
+ const entry = sceneEntries.find((item) => item.root === binding?.root);
354
+ if (entry) {
355
+ entry.name = name;
1705
356
  }
1706
- }, children: ["Assets", assets.length > 0 ? ` · ${assets.length}` : ''] })] }), _jsx("div", { style: {
1707
- height: TIMELINE_HEIGHT,
1708
- display: timelineCollapsed ? 'none' : 'flex',
1709
- borderTop: `1px solid ${theme.border}`,
1710
- }, children: showTimeline && binding && activeSystem && state ? (_jsx(TimelinePanel, { docked: true, showTransport: false, binding: binding, selectedNode: selectedNode, onSelectNode: setSelectedNode, scene: state.scene, renderer: state.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: timelineCounterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, playbackEnabled: timelinePlaybackEnabled })) : (_jsx("div", { style: { padding: '14px', fontSize: 12, color: theme.textDim }, children: "Select an effect in the Scene to see its timeline." })) })] }), projectLoading && (_jsxs("div", { style: {
1711
- position: 'fixed',
1712
- inset: 0,
1713
- display: 'flex',
1714
- alignItems: 'center',
1715
- justifyContent: 'center',
1716
- background: 'rgba(7, 11, 22, 0.8)',
1717
- zIndex: 900,
1718
- color: theme.text,
1719
- fontSize: 14,
1720
- }, children: ["Opening ", props.project?.name ?? 'project', "\u2026"] })), toast && (_jsx("div", { role: "status", style: {
1721
- position: 'fixed',
1722
- top: 16,
1723
- left: '50%',
1724
- transform: 'translateX(-50%)',
1725
- zIndex: 950,
1726
- background: '#13204a',
1727
- border: `1px solid ${theme.borderActive}`,
1728
- borderRadius: 8,
1729
- padding: '8px 14px',
1730
- fontSize: 12.5,
1731
- color: theme.text,
1732
- boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
1733
- pointerEvents: 'none',
1734
- }, children: toast })), saveAsTarget && (_jsx(PromptDialog, { title: "Save as new asset \u2014 Folder/Name", defaultValue: saveAsTarget.name, placeholder: "Fire/Big explosion", confirmLabel: "Save", onSubmit: (path) => {
1735
- const entry = saveAsTarget;
1736
- setSaveAsTarget(null);
1737
- actionsRef.current.saveEntryAsAsset(entry, path);
1738
- }, onCancel: () => setSaveAsTarget(null) })), prefabDialog === 'overwrite' && prefabInfo && (_jsx(ChoiceDialog, { title: `Overwrite prefab “${prefabInfo.name}”?`, message: (() => {
1739
- const count = actionsRef.current.prefabInstanceCount();
1740
- return count > 0
1741
- ? `The asset is replaced with your edits. The ${count} “${prefabInfo.name}” effect(s) already in the scene will not change.`
1742
- : 'The asset is replaced with your edits. Effects already in the scene will not change.';
1743
- })(), actions: [
1744
- { label: 'Cancel', onSelect: () => setPrefabDialog(null) },
1745
- { label: 'Save as new…', onSelect: () => setPrefabDialog('saveAs') },
1746
- {
1747
- label: 'Overwrite',
1748
- primary: true,
1749
- onSelect: () => {
1750
- setPrefabDialog(null);
1751
- actionsRef.current.overwritePrefab();
1752
- },
1753
- },
1754
- ], onCancel: () => setPrefabDialog(null) })), prefabSwitch && prefabInfo && (_jsx(ChoiceDialog, { title: `Save changes to prefab “${prefabInfo.name}”?`, message: `Before opening “${prefabSwitch.name}”. Saving overwrites the prefab asset; effects already in the scene will not change.`, actions: [
1755
- { label: 'Cancel', onSelect: () => setPrefabSwitch(null) },
1756
- {
1757
- label: "Don't save",
1758
- danger: true,
1759
- onSelect: () => {
1760
- setPrefabSwitch(null);
1761
- actionsRef.current.openPrefab(prefabSwitch.id, true);
1762
- },
1763
- },
1764
- {
1765
- label: 'Save',
1766
- primary: true,
1767
- onSelect: () => {
1768
- setPrefabSwitch(null);
1769
- actionsRef.current.overwritePrefab();
1770
- actionsRef.current.openPrefab(prefabSwitch.id, true);
1771
- },
1772
- },
1773
- ], onCancel: () => setPrefabSwitch(null) })), prefabDialog === 'exit' && prefabInfo && (_jsx(ChoiceDialog, { title: `Save changes to prefab “${prefabInfo.name}”?`, message: "Saving overwrites the prefab asset. Effects already in the scene will not change.", actions: [
1774
- { label: 'Cancel', onSelect: () => setPrefabDialog(null) },
1775
- {
1776
- label: "Don't save",
1777
- danger: true,
1778
- onSelect: () => {
1779
- setPrefabDialog(null);
1780
- actionsRef.current.closePrefab();
1781
- },
1782
- },
1783
- {
1784
- label: 'Save',
1785
- primary: true,
1786
- onSelect: () => {
1787
- setPrefabDialog(null);
1788
- actionsRef.current.overwritePrefab();
1789
- actionsRef.current.closePrefab();
1790
- },
1791
- },
1792
- ], onCancel: () => setPrefabDialog(null) })), prefabDialog === 'saveAs' && prefabInfo && (_jsx(PromptDialog, { title: "Save prefab as new asset \u2014 Folder/Name", defaultValue: prefabInfo.path.replace(/\.json$/i, ''), confirmLabel: "Save", onSubmit: (path) => {
1793
- setPrefabDialog(null);
1794
- actionsRef.current.savePrefabAsNew(path);
1795
- }, onCancel: () => setPrefabDialog(null) })), exitPrompt && (_jsx(ChoiceDialog, { title: "Save changes to the scene?", message: "The scene has changes that are not saved. Assets are already saved.", actions: [
1796
- { label: 'Cancel', onSelect: () => setExitPrompt(false) },
1797
- {
1798
- label: "Don't save",
1799
- danger: true,
1800
- onSelect: () => {
1801
- setExitPrompt(false);
1802
- props.onExitProject?.();
1803
- },
1804
- },
1805
- {
1806
- label: 'Save',
1807
- primary: true,
1808
- onSelect: () => {
1809
- setExitPrompt(false);
1810
- void actionsRef.current.saveScene().then((saved) => saved && props.onExitProject?.());
1811
- },
1812
- },
1813
- ], onCancel: () => setExitPrompt(false) })), messageDialog && (_jsx(MessageDialog, { title: messageDialog.title, message: messageDialog.message, onClose: () => setMessageDialog(null) }))] }));
357
+ }, onSave: props.onSave, toast: showToast, textureOptions: props.textureOptions, geometryOptions: props.geometryOptions, resolveTexture: props.resolveTexture ? resolveTextureForEditor : undefined, resolveGeometry: props.resolveGeometry ? resolveGeometryForEditor : undefined })] }), _jsx(AssetsDrawer, { open: drawerOpen, pinned: drawerPinned, onTogglePin: () => {
358
+ setDrawerPinned((pinned) => !pinned);
359
+ setDrawerOpen(true);
360
+ }, onClose: () => {
361
+ setDrawerPinned(false);
362
+ setDrawerOpen(false);
363
+ }, panel: {
364
+ assets,
365
+ instanceCounts,
366
+ loading: assetsLoading,
367
+ folderInputRef: assetFolderInputRef,
368
+ onImportFiles: (files) => void runAssetImport(files),
369
+ onImportFolder: handleImportFolder,
370
+ onAddToScene: (payload) => session?.addToScene(payload),
371
+ onOpenPrefab: (asset) => session?.openPrefab(asset.id),
372
+ onDelete: (payload) => session?.deleteAssets(payload),
373
+ } })] }), _jsx(TimelineDock, { collapsed: timelineCollapsed, onToggleCollapsed: () => setTimelineCollapsed((collapsed) => !collapsed), effectName: binding?.root.name, assetCount: assets.length, assetsActive: drawerOpen || drawerPinned, onToggleAssets: () => {
374
+ if (drawerPinned) {
375
+ setDrawerPinned(false);
376
+ setDrawerOpen(false);
377
+ }
378
+ else {
379
+ setDrawerOpen((open) => !open);
380
+ }
381
+ }, children: binding && activeSystem && session ? (_jsx(TimelinePanel, { docked: true, showTransport: false, binding: binding, selectedNode: liveSelection, onSelectNode: setSelectedNode, scene: session.scene, renderer: session.renderer, playbackRef: playbackRef, playheadRef: playheadRef, counterRef: timelineCounterRef, stagePlaying: stagePlaying, setStagePlaying: setStagePlaying, speed: speed, setSpeed: setSpeed, playbackEnabled: timelinePlaybackEnabled })) : null }), _jsx(HostDialogs, { session: session, loadingProject: projectLoading ? (props.project?.name ?? 'project') : null, toast: toast, saveAsTarget: saveAsTarget, setSaveAsTarget: setSaveAsTarget, prefabInfo: prefabInfo, prefabDialog: prefabDialog, setPrefabDialog: setPrefabDialog, prefabSwitch: prefabSwitch, setPrefabSwitch: setPrefabSwitch, exitPrompt: exitPrompt, setExitPrompt: setExitPrompt, message: message, setMessage: setMessage, onExitProject: props.onExitProject })] }));
1814
382
  }
1815
383
  /** NME-style imperative entry point for hosts like BabylonJS Editor windows. */
1816
384
  export const QuarksEffectEditor = {
385
+ /**
386
+ * Mounts an {@link EffectEditorHost} into `hostElement`.
387
+ *
388
+ * @returns A handle whose `dispose()` unmounts the editor and frees its engine.
389
+ */
1817
390
  Show(options) {
1818
391
  const { hostElement, ...props } = options;
1819
392
  const root = createRoot(hostElement);