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
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
import {ArcRotateCamera} from '@babylonjs/core/Cameras/arcRotateCamera.js';
|
|
2
|
+
// scene.pick lives in Ray's side effect (Babylon 9.31 no longer pulls it in); gizmos pick through it.
|
|
3
|
+
import '@babylonjs/core/Culling/ray.js';
|
|
4
|
+
import type {AbstractEngine} from '@babylonjs/core/Engines/abstractEngine.js';
|
|
5
|
+
import {PointerEventTypes} from '@babylonjs/core/Events/pointerEvents.js';
|
|
6
|
+
import {GizmoManager} from '@babylonjs/core/Gizmos/gizmoManager.js';
|
|
7
|
+
import {DirectionalLight} from '@babylonjs/core/Lights/directionalLight.js';
|
|
8
|
+
import type {Texture} from '@babylonjs/core/Materials/Textures/texture.js';
|
|
9
|
+
import {Color3, Color4} from '@babylonjs/core/Maths/math.color.js';
|
|
10
|
+
import {Vector3} from '@babylonjs/core/Maths/math.vector.js';
|
|
11
|
+
import type {Mesh} from '@babylonjs/core/Meshes/mesh.js';
|
|
12
|
+
import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder.js';
|
|
13
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
|
|
14
|
+
import {UtilityLayerRenderer} from '@babylonjs/core/Rendering/utilityLayerRenderer.js';
|
|
15
|
+
import {Scene} from '@babylonjs/core/scene.js';
|
|
16
|
+
import {GridMaterial} from '@babylonjs/materials/grid/gridMaterial.js';
|
|
17
|
+
import {BatchedRenderer, QuarksUtil} from 'babylon.quarks';
|
|
18
|
+
import {getAssetDragSession, setAssetDragSession} from './assetDragSession.js';
|
|
19
|
+
import {describeAssetDrag, readAssetDrag, type AssetDragPayload} from './assetTree.js';
|
|
20
|
+
import {
|
|
21
|
+
createEffectAsset,
|
|
22
|
+
loadEffectAssets,
|
|
23
|
+
mergeAssets,
|
|
24
|
+
removeAssets,
|
|
25
|
+
type AssetLoadProgress,
|
|
26
|
+
type EffectAsset,
|
|
27
|
+
} from './assets.js';
|
|
28
|
+
import type {EffectBinding} from './binding.js';
|
|
29
|
+
import {createSceneDefaultEntry} from './defaultEffect.js';
|
|
30
|
+
import {serializeEffectTree} from './effectTree.js';
|
|
31
|
+
import {EmitterShapeWireframes} from './emitterShapeWireframe.js';
|
|
32
|
+
import {GalleryDropMarker} from './galleryDropMarker.js';
|
|
33
|
+
import {cancelGalleryCameraFocus, focusGalleryEntry} from './galleryLayout.js';
|
|
34
|
+
import {
|
|
35
|
+
applyGalleryPlayback,
|
|
36
|
+
countGalleryParticles,
|
|
37
|
+
hasActivePreview,
|
|
38
|
+
pauseFinishedPreviews,
|
|
39
|
+
} from './galleryPlayback.js';
|
|
40
|
+
import {pickGroundPosition, placeSceneEntriesAt, setSceneEntryVisible} from './galleryScene.js';
|
|
41
|
+
import {GallerySelectionMarker} from './gallerySelectionMarker.js';
|
|
42
|
+
import {EffectHistory, type HistoryStacks} from './history.js';
|
|
43
|
+
import {parseEffectFromJson} from './loadEffect.js';
|
|
44
|
+
import {
|
|
45
|
+
clearFocusFinished,
|
|
46
|
+
getFocusElapsed,
|
|
47
|
+
isFocusFinished,
|
|
48
|
+
markFocusFinished,
|
|
49
|
+
setFocusElapsed,
|
|
50
|
+
type PlaybackState,
|
|
51
|
+
} from './playback.js';
|
|
52
|
+
import type {ProjectStore, StoredAsset, StoredScene} from './projectStore.js';
|
|
53
|
+
import {logRenderMismatch} from './renderDebug.js';
|
|
54
|
+
import {bindingFromSceneEntry, disposeSceneEntry, instantiateSceneEntry, type SceneEntry} from './sceneEntries.js';
|
|
55
|
+
import {pauseFocus, scrubFocusToSettled} from './scrub.js';
|
|
56
|
+
import {computeTimelineRows, computeTimelineSpan} from './timeline.js';
|
|
57
|
+
import {notifyTransformChanged, subscribeTransform} from './transformStore.js';
|
|
58
|
+
import {GalleryViewportPicker} from './viewportPicker.js';
|
|
59
|
+
|
|
60
|
+
/** What an editor session hands its host once it is live, and again whenever the focused effect changes. */
|
|
61
|
+
export interface EffectEditorHostHandle {
|
|
62
|
+
/** The focused effect's binding; reading it throws when no effect is focused. */
|
|
63
|
+
binding: EffectBinding;
|
|
64
|
+
/** Undo/redo of the focused effect. */
|
|
65
|
+
history: EffectHistory;
|
|
66
|
+
/** The editor's preview scene. */
|
|
67
|
+
scene: Scene;
|
|
68
|
+
/** The renderer every effect in the scene draws through. */
|
|
69
|
+
renderer: BatchedRenderer;
|
|
70
|
+
/** Replaces the scene with this effect (it is also added to Assets). */
|
|
71
|
+
importJson: (json: unknown) => void;
|
|
72
|
+
/** Imports effect files (and the texture/geometry files they reference) into Assets. */
|
|
73
|
+
importAssets: (files: FileList | File[]) => Promise<void>;
|
|
74
|
+
/** @deprecated Renamed to `importAssets`. */
|
|
75
|
+
importGallery: (files: FileList | File[]) => Promise<void>;
|
|
76
|
+
/** The focused effect as quarks JSON; throws when no effect is focused. */
|
|
77
|
+
exportJson: () => string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A project an editor session loads its assets and scene from, and saves them to. */
|
|
81
|
+
export interface EditorProject {
|
|
82
|
+
/** Where the project lives. */
|
|
83
|
+
store: ProjectStore;
|
|
84
|
+
/** The project's id in `store`. */
|
|
85
|
+
id: string;
|
|
86
|
+
/** The project's display name. */
|
|
87
|
+
name: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Which prefab is open, as the host shows it. */
|
|
91
|
+
export interface PrefabInfo {
|
|
92
|
+
/** The asset's display name. */
|
|
93
|
+
name: string;
|
|
94
|
+
/** The asset's path in Assets. */
|
|
95
|
+
path: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Live readouts a session writes every frame. */
|
|
99
|
+
export interface EditorFrameStats {
|
|
100
|
+
/** Frames per second, rounded. */
|
|
101
|
+
fps: number;
|
|
102
|
+
/** Time and particle count of the focused effect, e.g. `1.2s / 3.0s · 240 particles`. */
|
|
103
|
+
counter: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* How an editor session reports to its host. A React host passes its state setters. Callbacks
|
|
108
|
+
* run synchronously from the action that changed the value, except `frame` (from the render
|
|
109
|
+
* loop) and the results of asynchronous work: asset import progress and a project's load. None
|
|
110
|
+
* runs after the session is disposed.
|
|
111
|
+
*/
|
|
112
|
+
export interface EditorSessionView {
|
|
113
|
+
/** The focused effect's binding changed (`null`: nothing is focused). */
|
|
114
|
+
bindingChanged(binding: EffectBinding | null): void;
|
|
115
|
+
/** The node selected in the focused effect changed. */
|
|
116
|
+
selectedNodeChanged(node: TransformNode | null): void;
|
|
117
|
+
/** The focused effect's root changed. */
|
|
118
|
+
focusedRootChanged(root: TransformNode | null): void;
|
|
119
|
+
/** The scene's entries changed. */
|
|
120
|
+
entriesChanged(entries: SceneEntry[]): void;
|
|
121
|
+
/** Assets changed. */
|
|
122
|
+
assetsChanged(assets: EffectAsset[]): void;
|
|
123
|
+
/** The set of shown entries changed, by root `uniqueId`. */
|
|
124
|
+
visibleChanged(rootIds: number[]): void;
|
|
125
|
+
/** The entry shown alone changed (`null`: none). */
|
|
126
|
+
soloChanged(rootId: number | null): void;
|
|
127
|
+
/** An asset import started, progressed (a progress value) or ended (`null`). */
|
|
128
|
+
assetsLoadingChanged(progress: AssetLoadProgress | null): void;
|
|
129
|
+
/** The scene has unsaved changes, or no longer has. */
|
|
130
|
+
sceneDirtyChanged(dirty: boolean): void;
|
|
131
|
+
/** The project's scene finished loading, or failed to. */
|
|
132
|
+
projectLoaded(): void;
|
|
133
|
+
/** The open prefab changed (`null`: back to the scene). */
|
|
134
|
+
prefabChanged(info: PrefabInfo | null): void;
|
|
135
|
+
/** The open prefab has unsaved edits, or no longer has. */
|
|
136
|
+
prefabDirtyChanged(dirty: boolean): void;
|
|
137
|
+
/** Opening another asset waits on what to do with the open prefab's unsaved edits. */
|
|
138
|
+
prefabSwitchRequested(asset: {id: number; name: string}): void;
|
|
139
|
+
/** The asset drag hovering the viewport changed (`null`: none). */
|
|
140
|
+
dragHoverChanged(hover: {label: string; count: number} | null): void;
|
|
141
|
+
/** Something was added to Assets that the user should see. */
|
|
142
|
+
assetsRevealed(): void;
|
|
143
|
+
/** Something changed that has no state of its own, such as an undo stack; re-render. */
|
|
144
|
+
refresh(): void;
|
|
145
|
+
/** A short notice. */
|
|
146
|
+
toast(text: string): void;
|
|
147
|
+
/** A notice the user dismisses. */
|
|
148
|
+
message(title: string, text: string): void;
|
|
149
|
+
/** The session is live, or what it hands out changed. */
|
|
150
|
+
ready(handle: EffectEditorHostHandle): void;
|
|
151
|
+
/** A frame was drawn. */
|
|
152
|
+
frame(stats: EditorFrameStats): void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What an editor session is built from. */
|
|
156
|
+
export interface EditorSessionOptions {
|
|
157
|
+
/** The engine to draw with; its rendering canvas, if any, drives the camera and picking. */
|
|
158
|
+
engine: AbstractEngine;
|
|
159
|
+
/** Playback state shared with the host's transport and timeline. */
|
|
160
|
+
playback: PlaybackState;
|
|
161
|
+
/** How the session reports to its host; a callback left out is not called. */
|
|
162
|
+
view: Partial<EditorSessionView>;
|
|
163
|
+
/** The project to load and save; without it the session works in memory only. */
|
|
164
|
+
project?: EditorProject;
|
|
165
|
+
/** Quarks JSON to open when there is no project; a default effect is created when omitted. */
|
|
166
|
+
effectJson?: unknown;
|
|
167
|
+
/** Texture loader for new default effects. */
|
|
168
|
+
resolveTexture?: (url: string, scene: Scene) => Texture | null | undefined;
|
|
169
|
+
/** Texture URL of new default effects. */
|
|
170
|
+
defaultTextureUrl?: string;
|
|
171
|
+
/** True while an asset is being dragged over the viewport; viewport picking pauses meanwhile. */
|
|
172
|
+
isAssetDragActive?: () => boolean;
|
|
173
|
+
/** The node selected in the host, kept across undo and redo. */
|
|
174
|
+
getSelectedNode?: () => TransformNode | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The editor's headless half: the preview scene, the effects placed in it, Assets, prefab mode,
|
|
179
|
+
* the project's scene and undo, without any UI. `EffectEditorHost` is its React view; any
|
|
180
|
+
* other host can drive it through these actions and {@link EditorSessionView}.
|
|
181
|
+
*/
|
|
182
|
+
export interface EditorSession {
|
|
183
|
+
/** The engine the session draws with. */
|
|
184
|
+
readonly engine: AbstractEngine;
|
|
185
|
+
/** The preview scene. */
|
|
186
|
+
readonly scene: Scene;
|
|
187
|
+
/** The orbit camera. */
|
|
188
|
+
readonly camera: ArcRotateCamera;
|
|
189
|
+
/** The grid the effects stand on. */
|
|
190
|
+
readonly ground: Mesh;
|
|
191
|
+
/** The renderer every effect draws through. */
|
|
192
|
+
readonly renderer: BatchedRenderer;
|
|
193
|
+
/** Undo/redo of the focused effect. */
|
|
194
|
+
readonly history: EffectHistory;
|
|
195
|
+
/** The transform gizmos. */
|
|
196
|
+
readonly gizmoManager: GizmoManager;
|
|
197
|
+
/** The focused effect's binding, if any. */
|
|
198
|
+
readonly binding: EffectBinding | null;
|
|
199
|
+
/** The effects in the scene (in prefab mode, just the prefab's instance). */
|
|
200
|
+
readonly sceneEntries: SceneEntry[];
|
|
201
|
+
/** The shown entries, by root `uniqueId`. */
|
|
202
|
+
readonly visibleRootIds: number[];
|
|
203
|
+
/** Assets. */
|
|
204
|
+
readonly assets: EffectAsset[];
|
|
205
|
+
/** True while a prefab is open. */
|
|
206
|
+
readonly isPrefabOpen: boolean;
|
|
207
|
+
/** Imports effect files into Assets; throws when nothing new was found. */
|
|
208
|
+
importAssets(files: FileList | File[]): Promise<void>;
|
|
209
|
+
/** Replaces the scene with this effect, also added to Assets. */
|
|
210
|
+
importJson(json: unknown): void;
|
|
211
|
+
/** Focuses an entry and frames it. */
|
|
212
|
+
focusEntry(entry: SceneEntry | null, moveCamera?: boolean): void;
|
|
213
|
+
/** Shows or hides an entry. */
|
|
214
|
+
toggleVisible(entry: SceneEntry): void;
|
|
215
|
+
/** Shows an entry alone, or ends solo when it already is. */
|
|
216
|
+
toggleSolo(entry: SceneEntry): void;
|
|
217
|
+
/** Removes an entry from the scene. */
|
|
218
|
+
deleteEntry(entry: SceneEntry): void;
|
|
219
|
+
/** Removes the focused entry, if any; returns whether it did. */
|
|
220
|
+
deleteFocused(): boolean;
|
|
221
|
+
/** Adds a default effect at the camera target. */
|
|
222
|
+
createDefault(): void;
|
|
223
|
+
/** Where dragged assets would land, and what they are, for the drag preview. */
|
|
224
|
+
previewDrop(clientX: number, clientY: number): void;
|
|
225
|
+
/** Places dragged assets where they were dropped. */
|
|
226
|
+
placeDrop(clientX: number, clientY: number, dataTransfer?: DataTransfer): void;
|
|
227
|
+
/** Hides the drag preview while the drag goes on elsewhere. */
|
|
228
|
+
hideDropPreview(): void;
|
|
229
|
+
/** Hides the drag preview and forgets the drag, once it ended. */
|
|
230
|
+
clearDropPreview(): void;
|
|
231
|
+
/** Shows or hides wireframes of the focused effect's emitter shapes. */
|
|
232
|
+
setEmitterWireframesVisible(visible: boolean): void;
|
|
233
|
+
/** Adds assets at the camera target. */
|
|
234
|
+
addToScene(payload: AssetDragPayload): void;
|
|
235
|
+
/** Removes assets. */
|
|
236
|
+
deleteAssets(payload: AssetDragPayload): void;
|
|
237
|
+
/** Saves an entry as a new asset at `Folder/Name` and makes it an instance of it. */
|
|
238
|
+
saveEntryAsAsset(entry: SceneEntry, path: string): void;
|
|
239
|
+
/** Saves the scene to the project; resolves to false when it could not. */
|
|
240
|
+
saveScene(): Promise<boolean>;
|
|
241
|
+
/** Opens an asset on its own, setting the scene aside. */
|
|
242
|
+
openPrefab(assetId: number, discardChanges?: boolean): void;
|
|
243
|
+
/** Overwrites the open prefab's asset with the edits. */
|
|
244
|
+
overwritePrefab(): void;
|
|
245
|
+
/** Saves the open prefab as a new asset and keeps editing that one. */
|
|
246
|
+
savePrefabAsNew(path: string): void;
|
|
247
|
+
/** Leaves prefab mode without saving. */
|
|
248
|
+
closePrefab(): void;
|
|
249
|
+
/** Scene instances of the open prefab's asset. */
|
|
250
|
+
prefabInstanceCount(): number;
|
|
251
|
+
/** Undoes (`redo` false) or redoes a change to the focused effect. */
|
|
252
|
+
historyStep(redo: boolean): void;
|
|
253
|
+
/** Re-applies playback to the scene after the host changed `playback.stagePlaying`. */
|
|
254
|
+
syncPlayback(): void;
|
|
255
|
+
/** The handle a host hands to its embedder. */
|
|
256
|
+
makeHandle(): EffectEditorHostHandle;
|
|
257
|
+
/** Loads the project, or the initial effect, and starts the render loop. Later calls do nothing. */
|
|
258
|
+
start(): void;
|
|
259
|
+
/**
|
|
260
|
+
* Stops the render loop and frees the scene, the markers and the gizmos. Work still in flight
|
|
261
|
+
* (a project load, an asset import) is dropped when it finishes. The engine is the caller's.
|
|
262
|
+
*/
|
|
263
|
+
dispose(): void;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
|
|
267
|
+
|
|
268
|
+
function toStoredAsset(asset: EffectAsset): StoredAsset {
|
|
269
|
+
return {path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** `Folder/Name` typed by the user → a clean asset path ending in `.json`. */
|
|
273
|
+
export function normalizeAssetPath(input: string): string {
|
|
274
|
+
const segments = input
|
|
275
|
+
.replace(/\\/g, '/')
|
|
276
|
+
.split('/')
|
|
277
|
+
.map((part) => part.trim())
|
|
278
|
+
.filter(Boolean);
|
|
279
|
+
const path = segments.join('/') || 'Effect';
|
|
280
|
+
return /\.json$/i.test(path) ? path : `${path}.json`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Position of `node` in its effect tree (0 = root), to find the same node after a rebuild. */
|
|
284
|
+
export function nodeIndexInEffect(root: TransformNode, node: TransformNode | null): number {
|
|
285
|
+
if (!node || node === root) return 0;
|
|
286
|
+
return root.getDescendants(false).indexOf(node) + 1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The node at `index` in an effect tree, as {@link nodeIndexInEffect} counts; the root when out of range. */
|
|
290
|
+
export function nodeAtIndex(root: TransformNode, index: number): TransformNode {
|
|
291
|
+
if (index <= 0) return root;
|
|
292
|
+
return (root.getDescendants(false)[index - 1] as TransformNode | undefined) ?? root;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function errorText(err: unknown): string {
|
|
296
|
+
return err instanceof Error ? err.message : String(err);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Builds the editor's scene on `options.engine`; call `start()` once the host is ready for callbacks. */
|
|
300
|
+
export function createEditorSession(options: EditorSessionOptions): EditorSession {
|
|
301
|
+
const {engine, playback} = options;
|
|
302
|
+
let disposed = false;
|
|
303
|
+
let started = false;
|
|
304
|
+
/** The host's view, silenced once the session is disposed; missing callbacks are no-ops. */
|
|
305
|
+
const view = new Proxy(options.view, {
|
|
306
|
+
get: (target, key) => {
|
|
307
|
+
const callback: unknown = Reflect.get(target, key);
|
|
308
|
+
return (...args: unknown[]) => {
|
|
309
|
+
if (!disposed && typeof callback === 'function') {
|
|
310
|
+
callback.apply(target, args);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
},
|
|
314
|
+
}) as EditorSessionView;
|
|
315
|
+
const canvas = engine.getRenderingCanvas();
|
|
316
|
+
const scene = new Scene(engine);
|
|
317
|
+
scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
|
|
318
|
+
const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new Vector3(0, 1.4, 0), scene);
|
|
319
|
+
if (canvas) {
|
|
320
|
+
camera.attachControl(canvas, true);
|
|
321
|
+
}
|
|
322
|
+
camera.wheelDeltaPercentage = 0.01;
|
|
323
|
+
|
|
324
|
+
// Mesh render mode particles are lit (billboard modes ignore these); the grid is visual
|
|
325
|
+
// reference only and is not a physics collider.
|
|
326
|
+
scene.ambientColor = new Color3(1, 1, 1);
|
|
327
|
+
const sunLight = new DirectionalLight('sun', new Vector3(-1, -1, -1), scene);
|
|
328
|
+
sunLight.intensity = 1.0;
|
|
329
|
+
sunLight.diffuse = new Color3(1, 1, 1);
|
|
330
|
+
sunLight.specular = new Color3(1, 1, 1);
|
|
331
|
+
|
|
332
|
+
const groundMaterial = new GridMaterial('groundMaterial', scene);
|
|
333
|
+
groundMaterial.majorUnitFrequency = 2;
|
|
334
|
+
groundMaterial.minorUnitVisibility = 0.1;
|
|
335
|
+
groundMaterial.gridRatio = 0.5;
|
|
336
|
+
groundMaterial.backFaceCulling = false;
|
|
337
|
+
groundMaterial.mainColor = new Color3(1, 1, 1);
|
|
338
|
+
groundMaterial.lineColor = new Color3(1, 1, 1);
|
|
339
|
+
groundMaterial.opacity = 0.5;
|
|
340
|
+
// Draw only the lines. Babylon < 9.31 did this by itself for opacity < 1; 9.31 needs the flag,
|
|
341
|
+
// or the whole plane is filled with the main color.
|
|
342
|
+
groundMaterial.linesOnly = true;
|
|
343
|
+
const ground = MeshBuilder.CreateGround('ground', {width: 100, height: 100}, scene);
|
|
344
|
+
ground.material = groundMaterial;
|
|
345
|
+
|
|
346
|
+
const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
|
|
347
|
+
const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
|
|
348
|
+
gizmoManager.usePointerToAttachGizmos = false;
|
|
349
|
+
|
|
350
|
+
const renderer = new BatchedRenderer('quarks-editor', scene);
|
|
351
|
+
const history = new EffectHistory();
|
|
352
|
+
/** Undo/redo of effects that are not focused right now, by root uniqueId. */
|
|
353
|
+
const historyByRoot = new Map<number, HistoryStacks>();
|
|
354
|
+
|
|
355
|
+
const dropMarker = new GalleryDropMarker(scene);
|
|
356
|
+
const selectionMarker = new GallerySelectionMarker(scene);
|
|
357
|
+
const emitterWireframes = new EmitterShapeWireframes(scene);
|
|
358
|
+
const viewportPicker = new GalleryViewportPicker(scene);
|
|
359
|
+
|
|
360
|
+
let binding: EffectBinding | null = null;
|
|
361
|
+
let sceneEntries: SceneEntry[] = [];
|
|
362
|
+
let visibleRootIds: number[] = [];
|
|
363
|
+
let assets: EffectAsset[] = [];
|
|
364
|
+
let focusTimelineMeta = {rootId: -1, revision: -1, span: 1, anyLooping: false};
|
|
365
|
+
|
|
366
|
+
const handleViewportPointerLeave = () => viewportPicker.setHover(null);
|
|
367
|
+
canvas?.addEventListener('pointerleave', handleViewportPointerLeave);
|
|
368
|
+
|
|
369
|
+
const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
|
|
370
|
+
if (options.isAssetDragActive?.()) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const event = pointerInfo.event;
|
|
374
|
+
if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
|
|
375
|
+
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
376
|
+
const focused = binding?.root ?? null;
|
|
377
|
+
viewportPicker.setHover(effectRoot && effectRoot !== focused ? effectRoot : null);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
384
|
+
if (!effectRoot) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const entry = sceneEntries.find((item) => item.root === effectRoot);
|
|
388
|
+
if (entry && binding?.root !== effectRoot) {
|
|
389
|
+
focusEntry(entry);
|
|
390
|
+
}
|
|
391
|
+
view.selectedNodeChanged(effectRoot);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const hideDropPreview = () => {
|
|
395
|
+
dropMarker.hide();
|
|
396
|
+
view.dragHoverChanged(null);
|
|
397
|
+
};
|
|
398
|
+
const clearDropPreview = () => {
|
|
399
|
+
hideDropPreview();
|
|
400
|
+
setAssetDragSession(null);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// ---- state commits ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The open prefab, if any: its asset, and the scene set aside while it is edited. In prefab
|
|
407
|
+
* mode `sceneEntries` holds just the prefab's instance, so playback, picking, undo and the
|
|
408
|
+
* timeline all work on it unchanged.
|
|
409
|
+
*/
|
|
410
|
+
let prefab: {
|
|
411
|
+
asset: EffectAsset;
|
|
412
|
+
stash: {
|
|
413
|
+
entries: SceneEntry[];
|
|
414
|
+
visible: number[];
|
|
415
|
+
focusedRoot: TransformNode | null;
|
|
416
|
+
camera: {target: Vector3; radius: number; alpha: number; beta: number};
|
|
417
|
+
};
|
|
418
|
+
} | null = null;
|
|
419
|
+
let prefabChanged = false;
|
|
420
|
+
|
|
421
|
+
/** Scene changes since the last save; suppressed while a project scene loads. */
|
|
422
|
+
let dirty = false;
|
|
423
|
+
let loadingScene = Boolean(options.project);
|
|
424
|
+
const markDirty = () => {
|
|
425
|
+
if (prefab) {
|
|
426
|
+
if (!prefabChanged) {
|
|
427
|
+
prefabChanged = true;
|
|
428
|
+
view.prefabDirtyChanged(true);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!loadingScene && !dirty) {
|
|
433
|
+
dirty = true;
|
|
434
|
+
view.sceneDirtyChanged(true);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const markClean = () => {
|
|
438
|
+
dirty = false;
|
|
439
|
+
view.sceneDirtyChanged(false);
|
|
440
|
+
};
|
|
441
|
+
const setPrefabClean = () => {
|
|
442
|
+
prefabChanged = false;
|
|
443
|
+
view.prefabDirtyChanged(false);
|
|
444
|
+
};
|
|
445
|
+
const unsubscribeTransform = subscribeTransform(markDirty);
|
|
446
|
+
|
|
447
|
+
/** Sets the entries and their visibility without marking the scene changed. */
|
|
448
|
+
const replaceEntries = (entries: SceneEntry[], visible: number[]) => {
|
|
449
|
+
sceneEntries = entries;
|
|
450
|
+
view.entriesChanged(entries);
|
|
451
|
+
visibleRootIds = visible;
|
|
452
|
+
view.visibleChanged(visible);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const commitEntries = (next: SceneEntry[]) => {
|
|
456
|
+
sceneEntries = next;
|
|
457
|
+
view.entriesChanged(next);
|
|
458
|
+
markDirty();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const commitAssets = (next: EffectAsset[]) => {
|
|
462
|
+
assets = next;
|
|
463
|
+
view.assetsChanged(next);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const commitVisible = (next: number[]) => {
|
|
467
|
+
visibleRootIds = next;
|
|
468
|
+
view.visibleChanged(next);
|
|
469
|
+
markDirty();
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const onBindingChange = () => {
|
|
473
|
+
view.refresh();
|
|
474
|
+
markDirty();
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const isVisible = (entry: SceneEntry) => visibleRootIds.includes(entry.root.uniqueId);
|
|
478
|
+
|
|
479
|
+
const refreshPlayback = () => {
|
|
480
|
+
applyGalleryPlayback(
|
|
481
|
+
sceneEntries,
|
|
482
|
+
new Set(visibleRootIds),
|
|
483
|
+
playback.stagePlaying,
|
|
484
|
+
playback.focusFinishedByRoot
|
|
485
|
+
);
|
|
486
|
+
renderer.refreshBatches();
|
|
487
|
+
scene.render();
|
|
488
|
+
view.refresh();
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// ---- focus ----------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
const setFocus = (next: EffectBinding | null) => {
|
|
494
|
+
binding = next;
|
|
495
|
+
view.bindingChanged(next);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const attachBinding = (next: EffectBinding | null) => {
|
|
499
|
+
const previous = binding;
|
|
500
|
+
if (previous && !previous.root.isDisposed()) {
|
|
501
|
+
historyByRoot.set(previous.root.uniqueId, history.saveStacks());
|
|
502
|
+
}
|
|
503
|
+
history.restoreStacks(next ? historyByRoot.get(next.root.uniqueId) : undefined);
|
|
504
|
+
if (next) {
|
|
505
|
+
history.attach(next);
|
|
506
|
+
next.subscribe(onBindingChange);
|
|
507
|
+
}
|
|
508
|
+
setFocus(next);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const focusEntry = (entry: SceneEntry | null, moveCamera = true) => {
|
|
512
|
+
attachBinding(entry ? bindingFromSceneEntry(entry) : null);
|
|
513
|
+
view.selectedNodeChanged(entry?.root ?? null);
|
|
514
|
+
view.focusedRootChanged(entry?.root ?? null);
|
|
515
|
+
playback.focusRootId = entry?.root.uniqueId ?? null;
|
|
516
|
+
if (entry && isVisible(entry)) {
|
|
517
|
+
selectionMarker.show(entry.root);
|
|
518
|
+
if (moveCamera) {
|
|
519
|
+
focusGalleryEntry(camera, entry.root);
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
selectionMarker.hide();
|
|
523
|
+
}
|
|
524
|
+
view.ready(makeHandle());
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// ---- scene entries ------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
/** Adds entries to the scene (visible) and focuses the first. */
|
|
530
|
+
const addEntries = (entries: SceneEntry[], center: Vector3) => {
|
|
531
|
+
if (entries.length === 0) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
placeSceneEntriesAt(entries, center);
|
|
535
|
+
for (const entry of entries) {
|
|
536
|
+
entry.root.setEnabled(true);
|
|
537
|
+
}
|
|
538
|
+
commitEntries([...sceneEntries, ...entries]);
|
|
539
|
+
commitVisible([...visibleRootIds, ...entries.map((entry) => entry.root.uniqueId)]);
|
|
540
|
+
focusEntry(entries[0], false);
|
|
541
|
+
refreshPlayback();
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const refuseInPrefabMode = () => {
|
|
545
|
+
if (!prefab) return false;
|
|
546
|
+
view.toast('Effects go into the scene — go back to the scene (← Scene) to add them');
|
|
547
|
+
return true;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const cameraGroundTarget = () => new Vector3(camera.target.x, 0, camera.target.z);
|
|
551
|
+
|
|
552
|
+
const instantiateAssets = (toPlace: EffectAsset[], center: Vector3) => {
|
|
553
|
+
if (refuseInPrefabMode()) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const created: SceneEntry[] = [];
|
|
557
|
+
for (const asset of toPlace) {
|
|
558
|
+
try {
|
|
559
|
+
const entry = instantiateSceneEntry(scene, renderer, asset, [...sceneEntries, ...created]);
|
|
560
|
+
if (entry) {
|
|
561
|
+
created.push(entry);
|
|
562
|
+
}
|
|
563
|
+
} catch (err) {
|
|
564
|
+
console.warn(`[Quarks editor] Could not instantiate "${asset.path}":`, err);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (created.length < toPlace.length) {
|
|
568
|
+
view.message(
|
|
569
|
+
'Add to scene',
|
|
570
|
+
`${toPlace.length - created.length} of ${toPlace.length} effect(s) could not be loaded — see the console.`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
addEntries(created, center);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const toggleVisible = (entry: SceneEntry) => {
|
|
577
|
+
const id = entry.root.uniqueId;
|
|
578
|
+
const show = !isVisible(entry);
|
|
579
|
+
setSceneEntryVisible(entry, renderer, show);
|
|
580
|
+
clearFocusFinished(playback, id);
|
|
581
|
+
commitVisible(show ? [...visibleRootIds, id] : visibleRootIds.filter((rootId) => rootId !== id));
|
|
582
|
+
if (binding?.root === entry.root) {
|
|
583
|
+
if (show) {
|
|
584
|
+
selectionMarker.show(entry.root);
|
|
585
|
+
} else {
|
|
586
|
+
selectionMarker.hide();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
refreshPlayback();
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/** Entry shown alone, and the entries solo hid (shown again when it ends). */
|
|
593
|
+
let solo: {rootId: number; hidden: number[]} | null = null;
|
|
594
|
+
|
|
595
|
+
const exitSolo = () => {
|
|
596
|
+
if (!solo) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const hidden = new Set(solo.hidden);
|
|
600
|
+
solo = null;
|
|
601
|
+
view.soloChanged(null);
|
|
602
|
+
const toShow = sceneEntries.filter((entry) => hidden.has(entry.root.uniqueId) && !isVisible(entry));
|
|
603
|
+
for (const entry of toShow) {
|
|
604
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
605
|
+
clearFocusFinished(playback, entry.root.uniqueId);
|
|
606
|
+
}
|
|
607
|
+
commitVisible([...visibleRootIds, ...toShow.map((entry) => entry.root.uniqueId)]);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const toggleSolo = (entry: SceneEntry) => {
|
|
611
|
+
if (prefab) return;
|
|
612
|
+
const id = entry.root.uniqueId;
|
|
613
|
+
const wasSolo = solo?.rootId === id;
|
|
614
|
+
exitSolo();
|
|
615
|
+
if (!wasSolo) {
|
|
616
|
+
const others = sceneEntries.filter((item) => item !== entry && isVisible(item));
|
|
617
|
+
for (const other of others) {
|
|
618
|
+
setSceneEntryVisible(other, renderer, false);
|
|
619
|
+
}
|
|
620
|
+
const hiddenIds = others.map((other) => other.root.uniqueId);
|
|
621
|
+
const visible = visibleRootIds.filter((rootId) => !hiddenIds.includes(rootId));
|
|
622
|
+
if (!visible.includes(id)) {
|
|
623
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
624
|
+
visible.push(id);
|
|
625
|
+
}
|
|
626
|
+
commitVisible(visible);
|
|
627
|
+
solo = {rootId: id, hidden: hiddenIds};
|
|
628
|
+
view.soloChanged(id);
|
|
629
|
+
if (binding?.root !== entry.root) {
|
|
630
|
+
focusEntry(entry);
|
|
631
|
+
} else {
|
|
632
|
+
selectionMarker.show(entry.root);
|
|
633
|
+
focusGalleryEntry(camera, entry.root);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
refreshPlayback();
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const deleteEntry = (entry: SceneEntry) => {
|
|
640
|
+
if (prefab) return;
|
|
641
|
+
const index = sceneEntries.indexOf(entry);
|
|
642
|
+
if (index < 0) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const id = entry.root.uniqueId;
|
|
646
|
+
if (solo?.rootId === id) {
|
|
647
|
+
exitSolo();
|
|
648
|
+
}
|
|
649
|
+
const wasFocused = binding?.root === entry.root;
|
|
650
|
+
if (wasFocused) {
|
|
651
|
+
selectionMarker.hide();
|
|
652
|
+
}
|
|
653
|
+
disposeSceneEntry(entry, renderer);
|
|
654
|
+
const remaining = sceneEntries.filter((item) => item !== entry);
|
|
655
|
+
commitEntries(remaining);
|
|
656
|
+
commitVisible(visibleRootIds.filter((rootId) => rootId !== id));
|
|
657
|
+
clearFocusFinished(playback, id);
|
|
658
|
+
playback.focusElapsedByRoot.delete(id);
|
|
659
|
+
if (wasFocused) {
|
|
660
|
+
focusEntry(remaining[Math.min(index, remaining.length - 1)] ?? null, false);
|
|
661
|
+
}
|
|
662
|
+
historyByRoot.delete(id);
|
|
663
|
+
refreshPlayback();
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const deleteFocused = () => {
|
|
667
|
+
const entry = sceneEntries.find((item) => item.root === binding?.root);
|
|
668
|
+
if (!entry || prefab) {
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
deleteEntry(entry);
|
|
672
|
+
return true;
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const createDefault = () => {
|
|
676
|
+
if (refuseInPrefabMode()) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const entry = createSceneDefaultEntry(scene, sceneEntries, {
|
|
680
|
+
resolveTexture: options.resolveTexture,
|
|
681
|
+
textureUrl: options.defaultTextureUrl,
|
|
682
|
+
});
|
|
683
|
+
for (const system of entry.systems) {
|
|
684
|
+
renderer.addSystem(system);
|
|
685
|
+
}
|
|
686
|
+
addEntries([entry], cameraGroundTarget());
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// ---- assets ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
const previewDrop = (clientX: number, clientY: number) => {
|
|
692
|
+
const payload = getAssetDragSession();
|
|
693
|
+
if (!payload) return;
|
|
694
|
+
const described = describeAssetDrag(assets, payload);
|
|
695
|
+
if (described.assets.length === 0) return;
|
|
696
|
+
dropMarker.show(pickGroundPosition(scene, clientX, clientY), described.assets.length);
|
|
697
|
+
view.dragHoverChanged({label: described.label, count: described.assets.length});
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const placeDrop = (clientX: number, clientY: number, dataTransfer?: DataTransfer) => {
|
|
701
|
+
const payload = (dataTransfer && readAssetDrag(dataTransfer)) ?? getAssetDragSession();
|
|
702
|
+
clearDropPreview();
|
|
703
|
+
if (!payload) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const {assets: toPlace} = describeAssetDrag(assets, payload);
|
|
707
|
+
instantiateAssets(toPlace, pickGroundPosition(scene, clientX, clientY));
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const addToScene = (payload: AssetDragPayload) => {
|
|
711
|
+
const {assets: toPlace} = describeAssetDrag(assets, payload);
|
|
712
|
+
instantiateAssets(toPlace, cameraGroundTarget());
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
/** Writes asset changes through to the project; failures are reported, not thrown. */
|
|
716
|
+
const persist = (action: (store: ProjectStore, projectId: string) => Promise<void>) => {
|
|
717
|
+
const project = options.project;
|
|
718
|
+
if (!project) return;
|
|
719
|
+
action(project.store, project.id).catch((err) => {
|
|
720
|
+
console.error('[Quarks editor] Could not save to the project:', err);
|
|
721
|
+
view.message('Project storage', errorText(err));
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const deleteAssets = (payload: AssetDragPayload) => {
|
|
726
|
+
const next = removeAssets(assets, payload);
|
|
727
|
+
const removed = assets.filter((asset) => !next.includes(asset)).map((asset) => asset.path);
|
|
728
|
+
commitAssets(next);
|
|
729
|
+
persist((store, id) => store.deleteAssets(id, removed));
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const importAssets = async (files: FileList | File[]) => {
|
|
733
|
+
view.assetsLoadingChanged({done: 0, total: 0, loaded: 0, failed: 0});
|
|
734
|
+
let incoming: EffectAsset[];
|
|
735
|
+
try {
|
|
736
|
+
incoming = await loadEffectAssets(files, (progress) => view.assetsLoadingChanged(progress));
|
|
737
|
+
} finally {
|
|
738
|
+
view.assetsLoadingChanged(null);
|
|
739
|
+
}
|
|
740
|
+
if (disposed) return;
|
|
741
|
+
const {merged, added} = mergeAssets(assets, incoming);
|
|
742
|
+
commitAssets(merged);
|
|
743
|
+
view.assetsRevealed();
|
|
744
|
+
if (added.length > 0) {
|
|
745
|
+
persist((store, id) => store.putAssets(id, added.map(toStoredAsset)));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (incoming.length === 0) {
|
|
749
|
+
throw new Error('No particle effects found in the selected file(s).');
|
|
750
|
+
}
|
|
751
|
+
if (added.length === 0) {
|
|
752
|
+
throw new Error('All selected effects are already in Assets.');
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/** New asset at `rawPath` (`Folder/Name`, a free path — never overwrites) from an effect tree. */
|
|
757
|
+
const createAssetFromRoot = (root: TransformNode, rawPath: string): EffectAsset => {
|
|
758
|
+
const taken = new Set(assets.map((asset) => asset.path));
|
|
759
|
+
const wanted = normalizeAssetPath(rawPath);
|
|
760
|
+
let path = wanted;
|
|
761
|
+
for (let n = 2; taken.has(path); n++) {
|
|
762
|
+
path = wanted.replace(/\.json$/i, ` ${n}.json`);
|
|
763
|
+
}
|
|
764
|
+
const name = path
|
|
765
|
+
.split('/')
|
|
766
|
+
.pop()!
|
|
767
|
+
.replace(/\.json$/i, '');
|
|
768
|
+
const asset = createEffectAsset(name, path, JSON.parse(serializeEffectTree(root, name)));
|
|
769
|
+
commitAssets([...assets, asset]);
|
|
770
|
+
persist((store, id) => store.putAssets(id, [toStoredAsset(asset)]));
|
|
771
|
+
return asset;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const saveEntryAsAsset = (entry: SceneEntry, rawPath: string) => {
|
|
775
|
+
const asset = createAssetFromRoot(entry.root, rawPath);
|
|
776
|
+
entry.assetId = asset.id;
|
|
777
|
+
entry.path = asset.path;
|
|
778
|
+
commitEntries([...sceneEntries]);
|
|
779
|
+
view.assetsRevealed();
|
|
780
|
+
view.toast(`Saved as asset “${asset.path.replace(/\.json$/i, '')}”`);
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
// ---- prefab mode ------------------------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
/** Scene instances made from the open prefab — they keep their own copy when it is saved. */
|
|
786
|
+
const prefabInstanceCount = () =>
|
|
787
|
+
prefab ? prefab.stash.entries.filter((entry) => entry.assetId === prefab!.asset.id).length : 0;
|
|
788
|
+
|
|
789
|
+
/** Drops the prefab instance(s) currently in `sceneEntries`, with their history. */
|
|
790
|
+
const disposePrefabInstances = () => {
|
|
791
|
+
for (const entry of sceneEntries) {
|
|
792
|
+
const id = entry.root.uniqueId;
|
|
793
|
+
disposeSceneEntry(entry, renderer);
|
|
794
|
+
historyByRoot.delete(id);
|
|
795
|
+
clearFocusFinished(playback, id);
|
|
796
|
+
playback.focusElapsedByRoot.delete(id);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
/** Shows `entry` as the open prefab's only instance, with `asset` as the prefab. */
|
|
801
|
+
const showPrefab = (asset: EffectAsset, entry: SceneEntry) => {
|
|
802
|
+
setPrefabClean();
|
|
803
|
+
view.prefabChanged({name: asset.name, path: asset.path});
|
|
804
|
+
entry.root.setEnabled(true);
|
|
805
|
+
replaceEntries([entry], [entry.root.uniqueId]);
|
|
806
|
+
focusEntry(entry);
|
|
807
|
+
refreshPlayback();
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Opens an asset as a prefab. With another prefab open it is swapped in place — the scene stays
|
|
812
|
+
* set aside — after asking about unsaved edits unless `discardChanges`.
|
|
813
|
+
*/
|
|
814
|
+
const openPrefab = (assetId: number, discardChanges = false) => {
|
|
815
|
+
if (prefab?.asset.id === assetId) {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const asset = assets.find((item) => item.id === assetId);
|
|
819
|
+
if (!asset) return;
|
|
820
|
+
if (prefab && prefabChanged && !discardChanges) {
|
|
821
|
+
view.prefabSwitchRequested({id: asset.id, name: asset.name});
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
let entry: SceneEntry | null = null;
|
|
825
|
+
try {
|
|
826
|
+
entry = instantiateSceneEntry(scene, renderer, asset, []);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
console.warn(`[Quarks editor] Could not open prefab "${asset.path}":`, err);
|
|
829
|
+
}
|
|
830
|
+
if (!entry) {
|
|
831
|
+
view.message('Open prefab', `“${asset.name}” has no particle systems to edit.`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (prefab) {
|
|
835
|
+
selectionMarker.hide();
|
|
836
|
+
disposePrefabInstances();
|
|
837
|
+
prefab.asset = asset;
|
|
838
|
+
showPrefab(asset, entry);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
exitSolo();
|
|
842
|
+
const stash = {
|
|
843
|
+
entries: sceneEntries,
|
|
844
|
+
visible: visibleRootIds,
|
|
845
|
+
focusedRoot: binding?.root ?? null,
|
|
846
|
+
camera: {target: camera.target.clone(), radius: camera.radius, alpha: camera.alpha, beta: camera.beta},
|
|
847
|
+
};
|
|
848
|
+
// Hide the scene without touching its saved visibility.
|
|
849
|
+
for (const item of stash.entries) {
|
|
850
|
+
if (stash.visible.includes(item.root.uniqueId)) {
|
|
851
|
+
setSceneEntryVisible(item, renderer, false);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
selectionMarker.hide();
|
|
855
|
+
prefab = {asset, stash};
|
|
856
|
+
showPrefab(asset, entry);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
/** Leaves prefab mode without saving: drops the prefab instance and brings the scene back. */
|
|
860
|
+
const closePrefab = () => {
|
|
861
|
+
if (!prefab) return;
|
|
862
|
+
const {stash} = prefab;
|
|
863
|
+
prefab = null;
|
|
864
|
+
selectionMarker.hide();
|
|
865
|
+
disposePrefabInstances();
|
|
866
|
+
replaceEntries(stash.entries, stash.visible);
|
|
867
|
+
for (const entry of stash.entries) {
|
|
868
|
+
if (stash.visible.includes(entry.root.uniqueId)) {
|
|
869
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
870
|
+
clearFocusFinished(playback, entry.root.uniqueId);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
cancelGalleryCameraFocus();
|
|
874
|
+
camera.setTarget(stash.camera.target);
|
|
875
|
+
camera.radius = stash.camera.radius;
|
|
876
|
+
camera.alpha = stash.camera.alpha;
|
|
877
|
+
camera.beta = stash.camera.beta;
|
|
878
|
+
setPrefabClean();
|
|
879
|
+
view.prefabChanged(null);
|
|
880
|
+
focusEntry(stash.entries.find((entry) => entry.root === stash.focusedRoot) ?? null, false);
|
|
881
|
+
refreshPlayback();
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
/** Overwrites the open prefab's asset with the edited instance. Scene instances keep theirs. */
|
|
885
|
+
const overwritePrefab = () => {
|
|
886
|
+
const root = sceneEntries[0]?.root;
|
|
887
|
+
if (!prefab || !root) return;
|
|
888
|
+
const updated: EffectAsset = {
|
|
889
|
+
...prefab.asset,
|
|
890
|
+
json: JSON.parse(serializeEffectTree(root, prefab.asset.name)),
|
|
891
|
+
};
|
|
892
|
+
const exists = assets.some((asset) => asset.id === updated.id);
|
|
893
|
+
commitAssets(
|
|
894
|
+
exists ? assets.map((asset) => (asset.id === updated.id ? updated : asset)) : [...assets, updated]
|
|
895
|
+
);
|
|
896
|
+
persist((store, id) => store.putAssets(id, [toStoredAsset(updated)]));
|
|
897
|
+
prefab.asset = updated;
|
|
898
|
+
setPrefabClean();
|
|
899
|
+
view.toast(`Prefab “${updated.name}” saved · scene unchanged`);
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
/** Saves the edited prefab as a new asset and keeps editing that one. */
|
|
903
|
+
const savePrefabAsNew = (rawPath: string) => {
|
|
904
|
+
const root = sceneEntries[0]?.root;
|
|
905
|
+
if (!prefab || !root) return;
|
|
906
|
+
const asset = createAssetFromRoot(root, rawPath);
|
|
907
|
+
prefab.asset = asset;
|
|
908
|
+
setPrefabClean();
|
|
909
|
+
view.prefabChanged({name: asset.name, path: asset.path});
|
|
910
|
+
view.toast(`Saved as new prefab “${asset.path.replace(/\.json$/i, '')}”`);
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
// ---- project scene ----------------------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
/** Saves the scene to the project; resolves to false when it could not. */
|
|
916
|
+
const saveScene = async (): Promise<boolean> => {
|
|
917
|
+
const project = options.project;
|
|
918
|
+
if (!project || prefab) return false;
|
|
919
|
+
const visibleIds = new Set(visibleRootIds);
|
|
920
|
+
const assetPathById = new Map(assets.map((asset) => [asset.id, asset.path]));
|
|
921
|
+
const stored: StoredScene = {
|
|
922
|
+
savedAt: Date.now(),
|
|
923
|
+
entries: sceneEntries.map((entry) => ({
|
|
924
|
+
name: entry.name,
|
|
925
|
+
assetPath: entry.assetId != null ? (assetPathById.get(entry.assetId) ?? null) : null,
|
|
926
|
+
json: JSON.parse(serializeEffectTree(entry.root, entry.name)),
|
|
927
|
+
visible: visibleIds.has(entry.root.uniqueId),
|
|
928
|
+
})),
|
|
929
|
+
};
|
|
930
|
+
try {
|
|
931
|
+
await project.store.saveScene(project.id, stored);
|
|
932
|
+
} catch (err) {
|
|
933
|
+
console.error('[Quarks editor] Could not save the scene:', err);
|
|
934
|
+
view.message('Save scene', errorText(err));
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
markClean();
|
|
938
|
+
let infoSeen = true;
|
|
939
|
+
try {
|
|
940
|
+
infoSeen = localStorage.getItem(SCENE_SAVE_INFO_KEY) === '1';
|
|
941
|
+
localStorage.setItem(SCENE_SAVE_INFO_KEY, '1');
|
|
942
|
+
} catch {
|
|
943
|
+
// Storage blocked — fall back to the toast.
|
|
944
|
+
}
|
|
945
|
+
if (infoSeen) {
|
|
946
|
+
view.toast('Scene saved · assets unchanged');
|
|
947
|
+
} else {
|
|
948
|
+
view.message(
|
|
949
|
+
'Scene saved',
|
|
950
|
+
'Saving the scene stores the effects placed in it, with their own edits.\n\n' +
|
|
951
|
+
'It does not change the assets they came from. To keep an edited effect as an asset, ' +
|
|
952
|
+
'right-click it in the Scene list → “Save as new asset…”.'
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
return true;
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const loadProject = async (project: EditorProject) => {
|
|
959
|
+
try {
|
|
960
|
+
const [storedAssets, storedScene] = await Promise.all([
|
|
961
|
+
project.store.loadAssets(project.id),
|
|
962
|
+
project.store.loadScene(project.id),
|
|
963
|
+
]);
|
|
964
|
+
if (disposed) return;
|
|
965
|
+
const loadedAssets = storedAssets.map((stored) => ({
|
|
966
|
+
...createEffectAsset(stored.name, stored.path, stored.json),
|
|
967
|
+
missingFiles: stored.missingFiles,
|
|
968
|
+
}));
|
|
969
|
+
commitAssets(loadedAssets);
|
|
970
|
+
const assetIdByPath = new Map(loadedAssets.map((asset) => [asset.path, asset.id]));
|
|
971
|
+
|
|
972
|
+
if (!storedScene) {
|
|
973
|
+
createDefault();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const created: SceneEntry[] = [];
|
|
977
|
+
const visible: number[] = [];
|
|
978
|
+
for (const stored of storedScene.entries) {
|
|
979
|
+
const entry = instantiateSceneEntry(
|
|
980
|
+
scene,
|
|
981
|
+
renderer,
|
|
982
|
+
{id: -1, name: stored.name, path: stored.assetPath ?? '', json: stored.json, missingFiles: []},
|
|
983
|
+
created
|
|
984
|
+
);
|
|
985
|
+
if (!entry) continue;
|
|
986
|
+
entry.name = stored.name;
|
|
987
|
+
entry.root.name = stored.name;
|
|
988
|
+
entry.assetId = stored.assetPath != null ? (assetIdByPath.get(stored.assetPath) ?? null) : null;
|
|
989
|
+
entry.path = stored.assetPath ?? `_created/${stored.name}.json`;
|
|
990
|
+
setSceneEntryVisible(entry, renderer, stored.visible);
|
|
991
|
+
if (stored.visible) visible.push(entry.root.uniqueId);
|
|
992
|
+
created.push(entry);
|
|
993
|
+
}
|
|
994
|
+
commitEntries(created);
|
|
995
|
+
commitVisible(visible);
|
|
996
|
+
focusEntry(created[0] ?? null, false);
|
|
997
|
+
refreshPlayback();
|
|
998
|
+
} catch (err) {
|
|
999
|
+
console.error('[Quarks editor] Could not open the project:', err);
|
|
1000
|
+
view.message('Open project', errorText(err));
|
|
1001
|
+
} finally {
|
|
1002
|
+
loadingScene = false;
|
|
1003
|
+
markClean();
|
|
1004
|
+
view.projectLoaded();
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
// ---- undo / redo ----------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
/** Rebuilds the focused entry from a history snapshot, in place in the scene list. */
|
|
1011
|
+
const historyStep = (redo: boolean) => {
|
|
1012
|
+
const current = binding;
|
|
1013
|
+
if (!current) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const json = redo ? history.redo() : history.undo();
|
|
1017
|
+
const index = sceneEntries.findIndex((entry) => entry.root === current.root);
|
|
1018
|
+
const prev = index >= 0 ? sceneEntries[index] : null;
|
|
1019
|
+
if (!json || !prev) {
|
|
1020
|
+
view.refresh();
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const selectedIndex = nodeIndexInEffect(current.root, options.getSelectedNode?.() ?? null);
|
|
1025
|
+
const visible = isVisible(prev);
|
|
1026
|
+
const {root, systems} = parseEffectFromJson(scene, renderer, json, {
|
|
1027
|
+
registerRenderer: true,
|
|
1028
|
+
autoplay: false,
|
|
1029
|
+
});
|
|
1030
|
+
if (systems.length === 0) {
|
|
1031
|
+
disposeSceneEntry({...prev, root, systems}, renderer);
|
|
1032
|
+
view.refresh();
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
disposeSceneEntry(prev, renderer);
|
|
1036
|
+
// The snapshot carries the root transform too, so a moved effect moves back.
|
|
1037
|
+
root.parent = null;
|
|
1038
|
+
root.name = prev.name;
|
|
1039
|
+
const nextEntry: SceneEntry = {...prev, root, systems};
|
|
1040
|
+
setSceneEntryVisible(nextEntry, renderer, visible);
|
|
1041
|
+
|
|
1042
|
+
const entries = [...sceneEntries];
|
|
1043
|
+
entries[index] = nextEntry;
|
|
1044
|
+
commitEntries(entries);
|
|
1045
|
+
commitVisible(visibleRootIds.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id)));
|
|
1046
|
+
if (solo) {
|
|
1047
|
+
if (solo.rootId === prev.root.uniqueId) {
|
|
1048
|
+
solo.rootId = root.uniqueId;
|
|
1049
|
+
view.soloChanged(root.uniqueId);
|
|
1050
|
+
}
|
|
1051
|
+
solo.hidden = solo.hidden.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const next = bindingFromSceneEntry(nextEntry);
|
|
1055
|
+
history.attach(next);
|
|
1056
|
+
next.subscribe(onBindingChange);
|
|
1057
|
+
setFocus(next);
|
|
1058
|
+
view.selectedNodeChanged(nodeAtIndex(root, selectedIndex));
|
|
1059
|
+
view.focusedRootChanged(root);
|
|
1060
|
+
playback.focusRootId = root.uniqueId;
|
|
1061
|
+
if (visible) {
|
|
1062
|
+
selectionMarker.show(root);
|
|
1063
|
+
}
|
|
1064
|
+
notifyTransformChanged();
|
|
1065
|
+
refreshPlayback();
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
// ---- handle -----------------------------------------------------------------------------
|
|
1069
|
+
|
|
1070
|
+
const makeHandle = (): EffectEditorHostHandle => ({
|
|
1071
|
+
get binding() {
|
|
1072
|
+
if (!binding) {
|
|
1073
|
+
throw new Error('No effect is focused — add one to the scene first.');
|
|
1074
|
+
}
|
|
1075
|
+
return binding;
|
|
1076
|
+
},
|
|
1077
|
+
history,
|
|
1078
|
+
scene,
|
|
1079
|
+
renderer,
|
|
1080
|
+
importJson,
|
|
1081
|
+
importAssets,
|
|
1082
|
+
importGallery: importAssets,
|
|
1083
|
+
exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
/** Adds `json` to Assets and returns its single instance; throws when it has no systems. */
|
|
1087
|
+
const openJson = (json: unknown, name = 'Effect') => {
|
|
1088
|
+
const asset = createEffectAsset(name, `_opened/${name}.json`, json);
|
|
1089
|
+
const entry = instantiateSceneEntry(scene, renderer, asset, sceneEntries);
|
|
1090
|
+
if (!entry) {
|
|
1091
|
+
throw new Error('No particle systems found in the JSON.');
|
|
1092
|
+
}
|
|
1093
|
+
asset.name = entry.name;
|
|
1094
|
+
asset.path = `_opened/${entry.name}.json`;
|
|
1095
|
+
commitAssets([...assets, asset]);
|
|
1096
|
+
return entry;
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
const importJson = (json: unknown) => {
|
|
1100
|
+
closePrefab();
|
|
1101
|
+
const entry = openJson(json);
|
|
1102
|
+
for (const existing of sceneEntries) {
|
|
1103
|
+
disposeSceneEntry(existing, renderer);
|
|
1104
|
+
}
|
|
1105
|
+
historyByRoot.clear();
|
|
1106
|
+
solo = null;
|
|
1107
|
+
view.soloChanged(null);
|
|
1108
|
+
commitEntries([]);
|
|
1109
|
+
commitVisible([]);
|
|
1110
|
+
addEntries([entry], Vector3.Zero());
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// ---- frame ------------------------------------------------------------------------------
|
|
1114
|
+
|
|
1115
|
+
const renderFrame = () => {
|
|
1116
|
+
const entries = sceneEntries;
|
|
1117
|
+
const visibleIds = new Set(visibleRootIds);
|
|
1118
|
+
const focusBinding = binding;
|
|
1119
|
+
if (!playback.scrubbing) {
|
|
1120
|
+
let delta = (engine.getDeltaTime() / 1000) * playback.speed;
|
|
1121
|
+
const stepQueued = playback.stepQueued;
|
|
1122
|
+
if (!playback.stagePlaying) {
|
|
1123
|
+
delta = stepQueued ? 1 / 60 : 0;
|
|
1124
|
+
playback.stepQueued = false;
|
|
1125
|
+
}
|
|
1126
|
+
if (delta > 0) {
|
|
1127
|
+
const simulate = hasActivePreview(entries, visibleIds) && (playback.stagePlaying || stepQueued);
|
|
1128
|
+
if (simulate) {
|
|
1129
|
+
renderer.update(delta);
|
|
1130
|
+
let needsRefresh = false;
|
|
1131
|
+
if (pauseFinishedPreviews(entries, visibleIds, playback.focusFinishedByRoot)) {
|
|
1132
|
+
needsRefresh = true;
|
|
1133
|
+
}
|
|
1134
|
+
const focusId = playback.focusRootId ?? focusBinding?.root.uniqueId ?? null;
|
|
1135
|
+
if (
|
|
1136
|
+
focusId != null &&
|
|
1137
|
+
focusBinding &&
|
|
1138
|
+
playback.stagePlaying &&
|
|
1139
|
+
!playback.focusFinishedByRoot.has(focusId) &&
|
|
1140
|
+
!focusTimelineMeta.anyLooping &&
|
|
1141
|
+
QuarksUtil.isEffectFinished(focusBinding.root)
|
|
1142
|
+
) {
|
|
1143
|
+
const settleAt = getFocusElapsed(playback, focusId);
|
|
1144
|
+
scrubFocusToSettled(focusBinding, renderer, settleAt);
|
|
1145
|
+
markFocusFinished(playback, focusId);
|
|
1146
|
+
pauseFocus(focusBinding);
|
|
1147
|
+
needsRefresh = true;
|
|
1148
|
+
} else if (focusId != null && playback.stagePlaying && !playback.focusFinishedByRoot.has(focusId)) {
|
|
1149
|
+
const prev = getFocusElapsed(playback, focusId);
|
|
1150
|
+
setFocusElapsed(playback, focusId, prev + delta);
|
|
1151
|
+
}
|
|
1152
|
+
if (needsRefresh) {
|
|
1153
|
+
renderer.refreshBatches();
|
|
1154
|
+
}
|
|
1155
|
+
logRenderMismatch(renderer, countGalleryParticles(entries, visibleIds));
|
|
1156
|
+
} else if (entries.length > 0) {
|
|
1157
|
+
renderer.refreshBatches();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
scene.render();
|
|
1162
|
+
selectionMarker.follow();
|
|
1163
|
+
viewportPicker.follow();
|
|
1164
|
+
if (focusBinding) {
|
|
1165
|
+
emitterWireframes.sync(
|
|
1166
|
+
[focusBinding.system, ...focusBinding.subSystems],
|
|
1167
|
+
`${focusBinding.root.uniqueId}:${focusBinding.getRevision()}`
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
const pickKey = entries
|
|
1171
|
+
.map((entry) => `${entry.root.uniqueId}:${visibleIds.has(entry.root.uniqueId) ? 1 : 0}`)
|
|
1172
|
+
.join('|');
|
|
1173
|
+
viewportPicker.sync(entries, null, `${pickKey}::${focusBinding?.root.uniqueId ?? 'none'}`);
|
|
1174
|
+
|
|
1175
|
+
if (
|
|
1176
|
+
focusBinding &&
|
|
1177
|
+
(focusBinding.root.uniqueId !== focusTimelineMeta.rootId ||
|
|
1178
|
+
focusBinding.getRevision() !== focusTimelineMeta.revision)
|
|
1179
|
+
) {
|
|
1180
|
+
const rows = computeTimelineRows(focusBinding);
|
|
1181
|
+
focusTimelineMeta = {
|
|
1182
|
+
rootId: focusBinding.root.uniqueId,
|
|
1183
|
+
revision: focusBinding.getRevision(),
|
|
1184
|
+
span: computeTimelineSpan(rows),
|
|
1185
|
+
anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
const count = countGalleryParticles(entries, visibleIds);
|
|
1189
|
+
const focused = entries.find((entry) => entry.root === focusBinding?.root);
|
|
1190
|
+
const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
|
|
1191
|
+
const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
|
|
1192
|
+
const focusEnded = focusId != null && isFocusFinished(playback, focusId);
|
|
1193
|
+
const meta = focusTimelineMeta;
|
|
1194
|
+
const timeLabel = meta.anyLooping
|
|
1195
|
+
? `${focusElapsed.toFixed(1)}s ⟳`
|
|
1196
|
+
: focusEnded
|
|
1197
|
+
? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
|
|
1198
|
+
: `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
|
|
1199
|
+
view.frame({
|
|
1200
|
+
fps: Math.round(engine.getFps()),
|
|
1201
|
+
counter: focused ? `${timeLabel} · ${count} particles` : `${count} particles`,
|
|
1202
|
+
});
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
const syncPlayback = () => {
|
|
1206
|
+
applyGalleryPlayback(
|
|
1207
|
+
sceneEntries,
|
|
1208
|
+
new Set(visibleRootIds),
|
|
1209
|
+
playback.stagePlaying,
|
|
1210
|
+
playback.focusFinishedByRoot
|
|
1211
|
+
);
|
|
1212
|
+
if (!playback.stagePlaying) {
|
|
1213
|
+
renderer.refreshBatches();
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const start = () => {
|
|
1218
|
+
if (started || disposed) return;
|
|
1219
|
+
started = true;
|
|
1220
|
+
if (options.project) {
|
|
1221
|
+
void loadProject(options.project);
|
|
1222
|
+
} else if (options.effectJson) {
|
|
1223
|
+
addEntries([openJson(options.effectJson)], Vector3.Zero());
|
|
1224
|
+
} else {
|
|
1225
|
+
createDefault();
|
|
1226
|
+
}
|
|
1227
|
+
engine.runRenderLoop(renderFrame);
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
const dispose = () => {
|
|
1231
|
+
if (disposed) return;
|
|
1232
|
+
disposed = true;
|
|
1233
|
+
unsubscribeTransform();
|
|
1234
|
+
clearDropPreview();
|
|
1235
|
+
cancelGalleryCameraFocus();
|
|
1236
|
+
dropMarker.dispose();
|
|
1237
|
+
selectionMarker.dispose();
|
|
1238
|
+
emitterWireframes.dispose();
|
|
1239
|
+
viewportPicker.dispose();
|
|
1240
|
+
canvas?.removeEventListener('pointerleave', handleViewportPointerLeave);
|
|
1241
|
+
scene.onPointerObservable.remove(pointerObserver);
|
|
1242
|
+
gizmoManager.dispose();
|
|
1243
|
+
gizmoUtilityLayer.dispose();
|
|
1244
|
+
engine.stopRenderLoop(renderFrame);
|
|
1245
|
+
scene.dispose();
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
return {
|
|
1249
|
+
engine,
|
|
1250
|
+
scene,
|
|
1251
|
+
camera,
|
|
1252
|
+
ground,
|
|
1253
|
+
renderer,
|
|
1254
|
+
history,
|
|
1255
|
+
gizmoManager,
|
|
1256
|
+
get binding() {
|
|
1257
|
+
return binding;
|
|
1258
|
+
},
|
|
1259
|
+
get sceneEntries() {
|
|
1260
|
+
return sceneEntries;
|
|
1261
|
+
},
|
|
1262
|
+
get visibleRootIds() {
|
|
1263
|
+
return visibleRootIds;
|
|
1264
|
+
},
|
|
1265
|
+
get assets() {
|
|
1266
|
+
return assets;
|
|
1267
|
+
},
|
|
1268
|
+
get isPrefabOpen() {
|
|
1269
|
+
return prefab !== null;
|
|
1270
|
+
},
|
|
1271
|
+
importAssets,
|
|
1272
|
+
importJson,
|
|
1273
|
+
focusEntry,
|
|
1274
|
+
toggleVisible,
|
|
1275
|
+
toggleSolo,
|
|
1276
|
+
deleteEntry,
|
|
1277
|
+
deleteFocused,
|
|
1278
|
+
createDefault,
|
|
1279
|
+
previewDrop,
|
|
1280
|
+
placeDrop,
|
|
1281
|
+
hideDropPreview,
|
|
1282
|
+
clearDropPreview,
|
|
1283
|
+
setEmitterWireframesVisible: (visible) => emitterWireframes.setVisible(visible),
|
|
1284
|
+
addToScene,
|
|
1285
|
+
deleteAssets,
|
|
1286
|
+
saveEntryAsAsset,
|
|
1287
|
+
saveScene,
|
|
1288
|
+
openPrefab,
|
|
1289
|
+
overwritePrefab,
|
|
1290
|
+
savePrefabAsNew,
|
|
1291
|
+
closePrefab,
|
|
1292
|
+
prefabInstanceCount,
|
|
1293
|
+
historyStep,
|
|
1294
|
+
syncPlayback,
|
|
1295
|
+
makeHandle,
|
|
1296
|
+
start,
|
|
1297
|
+
dispose,
|
|
1298
|
+
};
|
|
1299
|
+
}
|