babylon.quarks-editor 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/README.md +69 -9
- package/dist/core/assetDragSession.d.ts +1 -1
- package/dist/core/assetTree.d.ts +6 -1
- package/dist/core/assets.d.ts +7 -0
- package/dist/core/assets.js +11 -7
- package/dist/core/binding.d.ts +11 -2
- package/dist/core/binding.js +9 -1
- package/dist/core/collision.d.ts +4 -0
- package/dist/core/collision.js +4 -0
- package/dist/core/colors.d.ts +16 -0
- package/dist/core/colors.js +8 -0
- package/dist/core/defaultEffect.d.ts +5 -4
- package/dist/core/defaultEffect.js +4 -4
- package/dist/core/editorScene.d.ts +1 -1
- package/dist/core/editorSession.d.ts +229 -0
- package/dist/core/editorSession.js +983 -0
- package/dist/core/effectTree.d.ts +6 -1
- package/dist/core/effectTree.js +13 -88
- package/dist/core/emitterShapeWireframe.d.ts +2 -2
- package/dist/core/emitterShapeWireframe.js +45 -13
- package/dist/core/galleryDropMarker.d.ts +2 -2
- package/dist/core/galleryDropMarker.js +6 -6
- package/dist/core/galleryLayout.d.ts +3 -3
- package/dist/core/galleryLayout.js +1 -1
- package/dist/core/galleryPlayback.d.ts +1 -1
- package/dist/core/galleryPlayback.js +2 -1
- package/dist/core/galleryScene.d.ts +4 -4
- package/dist/core/galleryScene.js +3 -3
- package/dist/core/gallerySelectionMarker.d.ts +4 -4
- package/dist/core/gallerySelectionMarker.js +5 -5
- package/dist/core/groups.d.ts +1 -1
- package/dist/core/groups.js +1 -1
- package/dist/core/history.d.ts +7 -1
- package/dist/core/history.js +4 -0
- package/dist/core/indexedDbProjectStore.d.ts +14 -1
- package/dist/core/indexedDbProjectStore.js +13 -0
- package/dist/core/lineShaders.d.ts +2 -2
- package/dist/core/lineShaders.js +2 -2
- package/dist/core/loadEffect.d.ts +2 -2
- package/dist/core/loadEffect.js +1 -1
- package/dist/core/material.d.ts +12 -2
- package/dist/core/pickAssetFiles.js +1 -1
- package/dist/core/playback.d.ts +6 -0
- package/dist/core/projectStore.d.ts +26 -0
- package/dist/core/projectStore.js +1 -0
- package/dist/core/sceneEntries.d.ts +7 -4
- package/dist/core/sceneEntries.js +2 -2
- package/dist/core/scrub.d.ts +2 -2
- package/dist/core/scrub.js +2 -2
- package/dist/core/shapes.d.ts +25 -1
- package/dist/core/shapes.js +5 -0
- package/dist/core/systems.d.ts +4 -2
- package/dist/core/systems.js +3 -1
- package/dist/core/textureExport.d.ts +1 -1
- package/dist/core/timeline.d.ts +25 -5
- package/dist/core/transport.d.ts +3 -3
- package/dist/core/transport.js +2 -2
- package/dist/core/values.d.ts +21 -4
- package/dist/core/values.js +5 -0
- package/dist/core/viewportPicker.d.ts +4 -4
- package/dist/core/viewportPicker.js +8 -7
- package/dist/index.d.ts +25 -21
- package/dist/index.js +12 -12
- package/dist/react/AssetsPanel.d.ts +9 -2
- package/dist/react/AssetsPanel.js +6 -6
- package/dist/react/ChoiceDialog.d.ts +7 -0
- package/dist/react/ChoiceDialog.js +1 -1
- package/dist/react/ContextMenu.d.ts +10 -1
- package/dist/react/ContextMenu.js +1 -1
- package/dist/react/CurveEditor.d.ts +1 -1
- package/dist/react/CurveEditor.js +2 -2
- package/dist/react/EditorWorkspace.d.ts +9 -2
- package/dist/react/EditorWorkspace.js +10 -7
- package/dist/react/EffectEditor.d.ts +11 -5
- package/dist/react/EffectEditor.js +7 -7
- package/dist/react/EffectEditorHost.d.ts +33 -29
- package/dist/react/EffectEditorHost.js +247 -1674
- package/dist/react/EffectTreeView.d.ts +4 -1
- package/dist/react/EffectTreeView.js +3 -3
- package/dist/react/GradientEditor.d.ts +1 -1
- package/dist/react/GradientEditor.js +3 -3
- package/dist/react/InspectorInfoIcon.js +3 -3
- package/dist/react/InspectorLabel.js +3 -3
- package/dist/react/MessageDialog.d.ts +5 -0
- package/dist/react/MessageDialog.js +1 -1
- package/dist/react/ModuleSection.d.ts +10 -0
- package/dist/react/ModuleSection.js +8 -5
- package/dist/react/ObjectModule.d.ts +1 -1
- package/dist/react/ObjectModule.js +4 -4
- package/dist/react/ProjectsPage.d.ts +7 -1
- package/dist/react/ProjectsPage.js +33 -17
- package/dist/react/PromptDialog.d.ts +7 -0
- package/dist/react/PromptDialog.js +1 -1
- package/dist/react/ScenePanel.d.ts +9 -2
- package/dist/react/ScenePanel.js +12 -10
- package/dist/react/SubEmittersModule.d.ts +1 -1
- package/dist/react/SubEmittersModule.js +5 -5
- package/dist/react/TextureSelect.d.ts +24 -0
- package/dist/react/TextureSelect.js +74 -0
- package/dist/react/TextureUVField.d.ts +14 -0
- package/dist/react/TextureUVField.js +23 -0
- package/dist/react/TimelinePanel.d.ts +20 -7
- package/dist/react/TimelinePanel.js +21 -15
- package/dist/react/TimelineTrackRow.d.ts +13 -3
- package/dist/react/TimelineTrackRow.js +6 -6
- package/dist/react/TransportBar.d.ts +13 -4
- package/dist/react/TransportBar.js +3 -3
- package/dist/react/ValueField.d.ts +32 -5
- package/dist/react/ValueField.js +22 -11
- package/dist/react/behaviorModules.d.ts +19 -6
- package/dist/react/behaviorModules.js +68 -17
- package/dist/react/fields.d.ts +9 -0
- package/dist/react/fields.js +6 -3
- package/dist/react/host/BottomDocks.d.ts +36 -0
- package/dist/react/host/BottomDocks.js +63 -0
- package/dist/react/host/HostDialogs.d.ts +50 -0
- package/dist/react/host/HostDialogs.js +113 -0
- package/dist/react/host/InspectorSidebar.d.ts +32 -0
- package/dist/react/host/InspectorSidebar.js +59 -0
- package/dist/react/host/SceneSidebar.d.ts +53 -0
- package/dist/react/host/SceneSidebar.js +62 -0
- package/dist/react/host/ViewportPane.d.ts +56 -0
- package/dist/react/host/ViewportPane.js +91 -0
- package/dist/react/host/hostHooks.d.ts +44 -0
- package/dist/react/host/hostHooks.js +164 -0
- package/dist/react/host/hostStyles.d.ts +21 -0
- package/dist/react/host/hostStyles.js +57 -0
- package/dist/react/index.d.ts +42 -38
- package/dist/react/index.js +25 -23
- package/dist/react/inspectorHints.js +31 -0
- package/dist/react/modules.d.ts +19 -6
- package/dist/react/modules.js +134 -117
- package/dist/react/theme.d.ts +15 -0
- package/dist/react/theme.js +15 -0
- package/dist/session.d.ts +9 -0
- package/dist/session.js +8 -0
- package/package.json +18 -11
- package/src/core/assetDragSession.ts +1 -1
- package/src/core/assetTree.ts +15 -2
- package/src/core/assets.ts +25 -10
- package/src/core/binding.ts +12 -3
- package/src/core/collision.ts +4 -0
- package/src/core/colors.ts +21 -1
- package/src/core/defaultEffect.ts +8 -7
- package/src/core/editorScene.ts +1 -1
- package/src/core/editorSession.ts +1299 -0
- package/src/core/effectTree.ts +18 -101
- package/src/core/emitterShapeWireframe.ts +48 -16
- package/src/core/galleryDropMarker.ts +8 -8
- package/src/core/galleryLayout.ts +4 -4
- package/src/core/galleryPlayback.ts +3 -2
- package/src/core/galleryScene.ts +5 -5
- package/src/core/gallerySelectionMarker.ts +7 -7
- package/src/core/groups.ts +1 -1
- package/src/core/history.ts +7 -1
- package/src/core/indexedDbProjectStore.ts +14 -1
- package/src/core/lineShaders.ts +2 -2
- package/src/core/loadEffect.ts +4 -4
- package/src/core/material.ts +14 -4
- package/src/core/pickAssetFiles.ts +2 -2
- package/src/core/playback.ts +6 -0
- package/src/core/projectStore.ts +26 -0
- package/src/core/sceneEntries.ts +8 -5
- package/src/core/scrub.ts +3 -3
- package/src/core/shapes.ts +29 -2
- package/src/core/systems.ts +11 -4
- package/src/core/textureExport.ts +1 -1
- package/src/core/timeline.ts +43 -18
- package/src/core/transport.ts +4 -4
- package/src/core/values.ts +20 -4
- package/src/core/viewportPicker.ts +12 -11
- package/src/index.ts +25 -21
- package/src/react/AssetsPanel.tsx +13 -6
- package/src/react/ChoiceDialog.tsx +8 -1
- package/src/react/ContextMenu.tsx +11 -2
- package/src/react/CurveEditor.tsx +2 -2
- package/src/react/EditorWorkspace.tsx +16 -10
- package/src/react/EffectEditor.tsx +17 -9
- package/src/react/EffectEditorHost.tsx +415 -2354
- package/src/react/EffectTreeView.tsx +7 -4
- package/src/react/GradientEditor.tsx +3 -3
- package/src/react/InspectorInfoIcon.tsx +3 -3
- package/src/react/InspectorLabel.tsx +3 -3
- package/src/react/MessageDialog.tsx +6 -1
- package/src/react/ModuleSection.tsx +15 -4
- package/src/react/ObjectModule.tsx +5 -5
- package/src/react/ProjectsPage.tsx +44 -17
- package/src/react/PromptDialog.tsx +8 -1
- package/src/react/ScenePanel.tsx +21 -12
- package/src/react/SubEmittersModule.tsx +6 -6
- package/src/react/TextureSelect.tsx +118 -0
- package/src/react/TextureUVField.tsx +62 -0
- package/src/react/TimelinePanel.tsx +40 -21
- package/src/react/TimelineTrackRow.tsx +19 -9
- package/src/react/TransportBar.tsx +16 -7
- package/src/react/ValueField.tsx +60 -16
- package/src/react/behaviorModules.tsx +240 -55
- package/src/react/fields.tsx +22 -4
- package/src/react/host/BottomDocks.tsx +178 -0
- package/src/react/host/HostDialogs.tsx +228 -0
- package/src/react/host/InspectorSidebar.tsx +147 -0
- package/src/react/host/SceneSidebar.tsx +227 -0
- package/src/react/host/ViewportPane.tsx +300 -0
- package/src/react/host/hostHooks.ts +198 -0
- package/src/react/host/hostStyles.ts +65 -0
- package/src/react/index.ts +43 -38
- package/src/react/inspectorHints.ts +40 -0
- package/src/react/modules.tsx +440 -170
- package/src/react/theme.ts +15 -0
- package/src/session.ts +17 -0
|
@@ -1,202 +1,57 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
|
|
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 {
|
|
21
|
-
import { ASSET_DRAG_MIME
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
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
|
|
182
|
-
const
|
|
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 [
|
|
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 (!
|
|
194
|
+
if (!drawerPinned) {
|
|
233
195
|
setDrawerOpen(false);
|
|
234
196
|
}
|
|
235
197
|
};
|
|
236
|
-
const
|
|
237
|
-
|
|
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
|
|
228
|
+
await session?.importAssets(files);
|
|
248
229
|
}
|
|
249
230
|
catch (err) {
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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(
|
|
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
|
-
? (
|
|
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
|
|
1348
|
-
|
|
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
|
-
|
|
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
|
|
1357
|
-
}, [
|
|
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: [
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
1704
|
-
|
|
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
|
-
},
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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);
|