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,983 @@
|
|
|
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 { PointerEventTypes } from '@babylonjs/core/Events/pointerEvents.js';
|
|
5
|
+
import { GizmoManager } from '@babylonjs/core/Gizmos/gizmoManager.js';
|
|
6
|
+
import { DirectionalLight } from '@babylonjs/core/Lights/directionalLight.js';
|
|
7
|
+
import { Color3, Color4 } from '@babylonjs/core/Maths/math.color.js';
|
|
8
|
+
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js';
|
|
9
|
+
import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder.js';
|
|
10
|
+
import { UtilityLayerRenderer } from '@babylonjs/core/Rendering/utilityLayerRenderer.js';
|
|
11
|
+
import { Scene } from '@babylonjs/core/scene.js';
|
|
12
|
+
import { GridMaterial } from '@babylonjs/materials/grid/gridMaterial.js';
|
|
13
|
+
import { BatchedRenderer, QuarksUtil } from 'babylon.quarks';
|
|
14
|
+
import { getAssetDragSession, setAssetDragSession } from './assetDragSession.js';
|
|
15
|
+
import { describeAssetDrag, readAssetDrag } from './assetTree.js';
|
|
16
|
+
import { createEffectAsset, loadEffectAssets, mergeAssets, removeAssets, } from './assets.js';
|
|
17
|
+
import { createSceneDefaultEntry } from './defaultEffect.js';
|
|
18
|
+
import { serializeEffectTree } from './effectTree.js';
|
|
19
|
+
import { EmitterShapeWireframes } from './emitterShapeWireframe.js';
|
|
20
|
+
import { GalleryDropMarker } from './galleryDropMarker.js';
|
|
21
|
+
import { cancelGalleryCameraFocus, focusGalleryEntry } from './galleryLayout.js';
|
|
22
|
+
import { applyGalleryPlayback, countGalleryParticles, hasActivePreview, pauseFinishedPreviews, } from './galleryPlayback.js';
|
|
23
|
+
import { pickGroundPosition, placeSceneEntriesAt, setSceneEntryVisible } from './galleryScene.js';
|
|
24
|
+
import { GallerySelectionMarker } from './gallerySelectionMarker.js';
|
|
25
|
+
import { EffectHistory } from './history.js';
|
|
26
|
+
import { parseEffectFromJson } from './loadEffect.js';
|
|
27
|
+
import { clearFocusFinished, getFocusElapsed, isFocusFinished, markFocusFinished, setFocusElapsed, } from './playback.js';
|
|
28
|
+
import { logRenderMismatch } from './renderDebug.js';
|
|
29
|
+
import { bindingFromSceneEntry, disposeSceneEntry, instantiateSceneEntry } from './sceneEntries.js';
|
|
30
|
+
import { pauseFocus, scrubFocusToSettled } from './scrub.js';
|
|
31
|
+
import { computeTimelineRows, computeTimelineSpan } from './timeline.js';
|
|
32
|
+
import { notifyTransformChanged, subscribeTransform } from './transformStore.js';
|
|
33
|
+
import { GalleryViewportPicker } from './viewportPicker.js';
|
|
34
|
+
const SCENE_SAVE_INFO_KEY = 'babylon.quarks-editor:scene-save-info-seen';
|
|
35
|
+
function toStoredAsset(asset) {
|
|
36
|
+
return { path: asset.path, name: asset.name, json: asset.json, missingFiles: asset.missingFiles };
|
|
37
|
+
}
|
|
38
|
+
/** `Folder/Name` typed by the user → a clean asset path ending in `.json`. */
|
|
39
|
+
export function normalizeAssetPath(input) {
|
|
40
|
+
const segments = input
|
|
41
|
+
.replace(/\\/g, '/')
|
|
42
|
+
.split('/')
|
|
43
|
+
.map((part) => part.trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
const path = segments.join('/') || 'Effect';
|
|
46
|
+
return /\.json$/i.test(path) ? path : `${path}.json`;
|
|
47
|
+
}
|
|
48
|
+
/** Position of `node` in its effect tree (0 = root), to find the same node after a rebuild. */
|
|
49
|
+
export function nodeIndexInEffect(root, node) {
|
|
50
|
+
if (!node || node === root)
|
|
51
|
+
return 0;
|
|
52
|
+
return root.getDescendants(false).indexOf(node) + 1;
|
|
53
|
+
}
|
|
54
|
+
/** The node at `index` in an effect tree, as {@link nodeIndexInEffect} counts; the root when out of range. */
|
|
55
|
+
export function nodeAtIndex(root, index) {
|
|
56
|
+
if (index <= 0)
|
|
57
|
+
return root;
|
|
58
|
+
return root.getDescendants(false)[index - 1] ?? root;
|
|
59
|
+
}
|
|
60
|
+
function errorText(err) {
|
|
61
|
+
return err instanceof Error ? err.message : String(err);
|
|
62
|
+
}
|
|
63
|
+
/** Builds the editor's scene on `options.engine`; call `start()` once the host is ready for callbacks. */
|
|
64
|
+
export function createEditorSession(options) {
|
|
65
|
+
const { engine, playback } = options;
|
|
66
|
+
let disposed = false;
|
|
67
|
+
let started = false;
|
|
68
|
+
/** The host's view, silenced once the session is disposed; missing callbacks are no-ops. */
|
|
69
|
+
const view = new Proxy(options.view, {
|
|
70
|
+
get: (target, key) => {
|
|
71
|
+
const callback = Reflect.get(target, key);
|
|
72
|
+
return (...args) => {
|
|
73
|
+
if (!disposed && typeof callback === 'function') {
|
|
74
|
+
callback.apply(target, args);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const canvas = engine.getRenderingCanvas();
|
|
80
|
+
const scene = new Scene(engine);
|
|
81
|
+
scene.clearColor = new Color4(0.03, 0.04, 0.09, 1);
|
|
82
|
+
const camera = new ArcRotateCamera('cam', -Math.PI / 2, 1.15, 9, new Vector3(0, 1.4, 0), scene);
|
|
83
|
+
if (canvas) {
|
|
84
|
+
camera.attachControl(canvas, true);
|
|
85
|
+
}
|
|
86
|
+
camera.wheelDeltaPercentage = 0.01;
|
|
87
|
+
// Mesh render mode particles are lit (billboard modes ignore these); the grid is visual
|
|
88
|
+
// reference only and is not a physics collider.
|
|
89
|
+
scene.ambientColor = new Color3(1, 1, 1);
|
|
90
|
+
const sunLight = new DirectionalLight('sun', new Vector3(-1, -1, -1), scene);
|
|
91
|
+
sunLight.intensity = 1.0;
|
|
92
|
+
sunLight.diffuse = new Color3(1, 1, 1);
|
|
93
|
+
sunLight.specular = new Color3(1, 1, 1);
|
|
94
|
+
const groundMaterial = new GridMaterial('groundMaterial', scene);
|
|
95
|
+
groundMaterial.majorUnitFrequency = 2;
|
|
96
|
+
groundMaterial.minorUnitVisibility = 0.1;
|
|
97
|
+
groundMaterial.gridRatio = 0.5;
|
|
98
|
+
groundMaterial.backFaceCulling = false;
|
|
99
|
+
groundMaterial.mainColor = new Color3(1, 1, 1);
|
|
100
|
+
groundMaterial.lineColor = new Color3(1, 1, 1);
|
|
101
|
+
groundMaterial.opacity = 0.5;
|
|
102
|
+
// Draw only the lines. Babylon < 9.31 did this by itself for opacity < 1; 9.31 needs the flag,
|
|
103
|
+
// or the whole plane is filled with the main color.
|
|
104
|
+
groundMaterial.linesOnly = true;
|
|
105
|
+
const ground = MeshBuilder.CreateGround('ground', { width: 100, height: 100 }, scene);
|
|
106
|
+
ground.material = groundMaterial;
|
|
107
|
+
const gizmoUtilityLayer = new UtilityLayerRenderer(scene);
|
|
108
|
+
const gizmoManager = new GizmoManager(scene, undefined, gizmoUtilityLayer);
|
|
109
|
+
gizmoManager.usePointerToAttachGizmos = false;
|
|
110
|
+
const renderer = new BatchedRenderer('quarks-editor', scene);
|
|
111
|
+
const history = new EffectHistory();
|
|
112
|
+
/** Undo/redo of effects that are not focused right now, by root uniqueId. */
|
|
113
|
+
const historyByRoot = new Map();
|
|
114
|
+
const dropMarker = new GalleryDropMarker(scene);
|
|
115
|
+
const selectionMarker = new GallerySelectionMarker(scene);
|
|
116
|
+
const emitterWireframes = new EmitterShapeWireframes(scene);
|
|
117
|
+
const viewportPicker = new GalleryViewportPicker(scene);
|
|
118
|
+
let binding = null;
|
|
119
|
+
let sceneEntries = [];
|
|
120
|
+
let visibleRootIds = [];
|
|
121
|
+
let assets = [];
|
|
122
|
+
let focusTimelineMeta = { rootId: -1, revision: -1, span: 1, anyLooping: false };
|
|
123
|
+
const handleViewportPointerLeave = () => viewportPicker.setHover(null);
|
|
124
|
+
canvas?.addEventListener('pointerleave', handleViewportPointerLeave);
|
|
125
|
+
const pointerObserver = scene.onPointerObservable.add((pointerInfo) => {
|
|
126
|
+
if (options.isAssetDragActive?.()) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const event = pointerInfo.event;
|
|
130
|
+
if (pointerInfo.type === PointerEventTypes.POINTERMOVE) {
|
|
131
|
+
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
132
|
+
const focused = binding?.root ?? null;
|
|
133
|
+
viewportPicker.setHover(effectRoot && effectRoot !== focused ? effectRoot : null);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (pointerInfo.type !== PointerEventTypes.POINTERDOWN || event.button !== 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const effectRoot = viewportPicker.pick(event.clientX, event.clientY);
|
|
140
|
+
if (!effectRoot) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const entry = sceneEntries.find((item) => item.root === effectRoot);
|
|
144
|
+
if (entry && binding?.root !== effectRoot) {
|
|
145
|
+
focusEntry(entry);
|
|
146
|
+
}
|
|
147
|
+
view.selectedNodeChanged(effectRoot);
|
|
148
|
+
});
|
|
149
|
+
const hideDropPreview = () => {
|
|
150
|
+
dropMarker.hide();
|
|
151
|
+
view.dragHoverChanged(null);
|
|
152
|
+
};
|
|
153
|
+
const clearDropPreview = () => {
|
|
154
|
+
hideDropPreview();
|
|
155
|
+
setAssetDragSession(null);
|
|
156
|
+
};
|
|
157
|
+
// ---- state commits ------------------------------------------------------------------
|
|
158
|
+
/**
|
|
159
|
+
* The open prefab, if any: its asset, and the scene set aside while it is edited. In prefab
|
|
160
|
+
* mode `sceneEntries` holds just the prefab's instance, so playback, picking, undo and the
|
|
161
|
+
* timeline all work on it unchanged.
|
|
162
|
+
*/
|
|
163
|
+
let prefab = null;
|
|
164
|
+
let prefabChanged = false;
|
|
165
|
+
/** Scene changes since the last save; suppressed while a project scene loads. */
|
|
166
|
+
let dirty = false;
|
|
167
|
+
let loadingScene = Boolean(options.project);
|
|
168
|
+
const markDirty = () => {
|
|
169
|
+
if (prefab) {
|
|
170
|
+
if (!prefabChanged) {
|
|
171
|
+
prefabChanged = true;
|
|
172
|
+
view.prefabDirtyChanged(true);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!loadingScene && !dirty) {
|
|
177
|
+
dirty = true;
|
|
178
|
+
view.sceneDirtyChanged(true);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const markClean = () => {
|
|
182
|
+
dirty = false;
|
|
183
|
+
view.sceneDirtyChanged(false);
|
|
184
|
+
};
|
|
185
|
+
const setPrefabClean = () => {
|
|
186
|
+
prefabChanged = false;
|
|
187
|
+
view.prefabDirtyChanged(false);
|
|
188
|
+
};
|
|
189
|
+
const unsubscribeTransform = subscribeTransform(markDirty);
|
|
190
|
+
/** Sets the entries and their visibility without marking the scene changed. */
|
|
191
|
+
const replaceEntries = (entries, visible) => {
|
|
192
|
+
sceneEntries = entries;
|
|
193
|
+
view.entriesChanged(entries);
|
|
194
|
+
visibleRootIds = visible;
|
|
195
|
+
view.visibleChanged(visible);
|
|
196
|
+
};
|
|
197
|
+
const commitEntries = (next) => {
|
|
198
|
+
sceneEntries = next;
|
|
199
|
+
view.entriesChanged(next);
|
|
200
|
+
markDirty();
|
|
201
|
+
};
|
|
202
|
+
const commitAssets = (next) => {
|
|
203
|
+
assets = next;
|
|
204
|
+
view.assetsChanged(next);
|
|
205
|
+
};
|
|
206
|
+
const commitVisible = (next) => {
|
|
207
|
+
visibleRootIds = next;
|
|
208
|
+
view.visibleChanged(next);
|
|
209
|
+
markDirty();
|
|
210
|
+
};
|
|
211
|
+
const onBindingChange = () => {
|
|
212
|
+
view.refresh();
|
|
213
|
+
markDirty();
|
|
214
|
+
};
|
|
215
|
+
const isVisible = (entry) => visibleRootIds.includes(entry.root.uniqueId);
|
|
216
|
+
const refreshPlayback = () => {
|
|
217
|
+
applyGalleryPlayback(sceneEntries, new Set(visibleRootIds), playback.stagePlaying, playback.focusFinishedByRoot);
|
|
218
|
+
renderer.refreshBatches();
|
|
219
|
+
scene.render();
|
|
220
|
+
view.refresh();
|
|
221
|
+
};
|
|
222
|
+
// ---- focus ----------------------------------------------------------------------------
|
|
223
|
+
const setFocus = (next) => {
|
|
224
|
+
binding = next;
|
|
225
|
+
view.bindingChanged(next);
|
|
226
|
+
};
|
|
227
|
+
const attachBinding = (next) => {
|
|
228
|
+
const previous = binding;
|
|
229
|
+
if (previous && !previous.root.isDisposed()) {
|
|
230
|
+
historyByRoot.set(previous.root.uniqueId, history.saveStacks());
|
|
231
|
+
}
|
|
232
|
+
history.restoreStacks(next ? historyByRoot.get(next.root.uniqueId) : undefined);
|
|
233
|
+
if (next) {
|
|
234
|
+
history.attach(next);
|
|
235
|
+
next.subscribe(onBindingChange);
|
|
236
|
+
}
|
|
237
|
+
setFocus(next);
|
|
238
|
+
};
|
|
239
|
+
const focusEntry = (entry, moveCamera = true) => {
|
|
240
|
+
attachBinding(entry ? bindingFromSceneEntry(entry) : null);
|
|
241
|
+
view.selectedNodeChanged(entry?.root ?? null);
|
|
242
|
+
view.focusedRootChanged(entry?.root ?? null);
|
|
243
|
+
playback.focusRootId = entry?.root.uniqueId ?? null;
|
|
244
|
+
if (entry && isVisible(entry)) {
|
|
245
|
+
selectionMarker.show(entry.root);
|
|
246
|
+
if (moveCamera) {
|
|
247
|
+
focusGalleryEntry(camera, entry.root);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
selectionMarker.hide();
|
|
252
|
+
}
|
|
253
|
+
view.ready(makeHandle());
|
|
254
|
+
};
|
|
255
|
+
// ---- scene entries ------------------------------------------------------------------
|
|
256
|
+
/** Adds entries to the scene (visible) and focuses the first. */
|
|
257
|
+
const addEntries = (entries, center) => {
|
|
258
|
+
if (entries.length === 0) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
placeSceneEntriesAt(entries, center);
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
entry.root.setEnabled(true);
|
|
264
|
+
}
|
|
265
|
+
commitEntries([...sceneEntries, ...entries]);
|
|
266
|
+
commitVisible([...visibleRootIds, ...entries.map((entry) => entry.root.uniqueId)]);
|
|
267
|
+
focusEntry(entries[0], false);
|
|
268
|
+
refreshPlayback();
|
|
269
|
+
};
|
|
270
|
+
const refuseInPrefabMode = () => {
|
|
271
|
+
if (!prefab)
|
|
272
|
+
return false;
|
|
273
|
+
view.toast('Effects go into the scene — go back to the scene (← Scene) to add them');
|
|
274
|
+
return true;
|
|
275
|
+
};
|
|
276
|
+
const cameraGroundTarget = () => new Vector3(camera.target.x, 0, camera.target.z);
|
|
277
|
+
const instantiateAssets = (toPlace, center) => {
|
|
278
|
+
if (refuseInPrefabMode()) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const created = [];
|
|
282
|
+
for (const asset of toPlace) {
|
|
283
|
+
try {
|
|
284
|
+
const entry = instantiateSceneEntry(scene, renderer, asset, [...sceneEntries, ...created]);
|
|
285
|
+
if (entry) {
|
|
286
|
+
created.push(entry);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
console.warn(`[Quarks editor] Could not instantiate "${asset.path}":`, err);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (created.length < toPlace.length) {
|
|
294
|
+
view.message('Add to scene', `${toPlace.length - created.length} of ${toPlace.length} effect(s) could not be loaded — see the console.`);
|
|
295
|
+
}
|
|
296
|
+
addEntries(created, center);
|
|
297
|
+
};
|
|
298
|
+
const toggleVisible = (entry) => {
|
|
299
|
+
const id = entry.root.uniqueId;
|
|
300
|
+
const show = !isVisible(entry);
|
|
301
|
+
setSceneEntryVisible(entry, renderer, show);
|
|
302
|
+
clearFocusFinished(playback, id);
|
|
303
|
+
commitVisible(show ? [...visibleRootIds, id] : visibleRootIds.filter((rootId) => rootId !== id));
|
|
304
|
+
if (binding?.root === entry.root) {
|
|
305
|
+
if (show) {
|
|
306
|
+
selectionMarker.show(entry.root);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
selectionMarker.hide();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
refreshPlayback();
|
|
313
|
+
};
|
|
314
|
+
/** Entry shown alone, and the entries solo hid (shown again when it ends). */
|
|
315
|
+
let solo = null;
|
|
316
|
+
const exitSolo = () => {
|
|
317
|
+
if (!solo) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const hidden = new Set(solo.hidden);
|
|
321
|
+
solo = null;
|
|
322
|
+
view.soloChanged(null);
|
|
323
|
+
const toShow = sceneEntries.filter((entry) => hidden.has(entry.root.uniqueId) && !isVisible(entry));
|
|
324
|
+
for (const entry of toShow) {
|
|
325
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
326
|
+
clearFocusFinished(playback, entry.root.uniqueId);
|
|
327
|
+
}
|
|
328
|
+
commitVisible([...visibleRootIds, ...toShow.map((entry) => entry.root.uniqueId)]);
|
|
329
|
+
};
|
|
330
|
+
const toggleSolo = (entry) => {
|
|
331
|
+
if (prefab)
|
|
332
|
+
return;
|
|
333
|
+
const id = entry.root.uniqueId;
|
|
334
|
+
const wasSolo = solo?.rootId === id;
|
|
335
|
+
exitSolo();
|
|
336
|
+
if (!wasSolo) {
|
|
337
|
+
const others = sceneEntries.filter((item) => item !== entry && isVisible(item));
|
|
338
|
+
for (const other of others) {
|
|
339
|
+
setSceneEntryVisible(other, renderer, false);
|
|
340
|
+
}
|
|
341
|
+
const hiddenIds = others.map((other) => other.root.uniqueId);
|
|
342
|
+
const visible = visibleRootIds.filter((rootId) => !hiddenIds.includes(rootId));
|
|
343
|
+
if (!visible.includes(id)) {
|
|
344
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
345
|
+
visible.push(id);
|
|
346
|
+
}
|
|
347
|
+
commitVisible(visible);
|
|
348
|
+
solo = { rootId: id, hidden: hiddenIds };
|
|
349
|
+
view.soloChanged(id);
|
|
350
|
+
if (binding?.root !== entry.root) {
|
|
351
|
+
focusEntry(entry);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
selectionMarker.show(entry.root);
|
|
355
|
+
focusGalleryEntry(camera, entry.root);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
refreshPlayback();
|
|
359
|
+
};
|
|
360
|
+
const deleteEntry = (entry) => {
|
|
361
|
+
if (prefab)
|
|
362
|
+
return;
|
|
363
|
+
const index = sceneEntries.indexOf(entry);
|
|
364
|
+
if (index < 0) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const id = entry.root.uniqueId;
|
|
368
|
+
if (solo?.rootId === id) {
|
|
369
|
+
exitSolo();
|
|
370
|
+
}
|
|
371
|
+
const wasFocused = binding?.root === entry.root;
|
|
372
|
+
if (wasFocused) {
|
|
373
|
+
selectionMarker.hide();
|
|
374
|
+
}
|
|
375
|
+
disposeSceneEntry(entry, renderer);
|
|
376
|
+
const remaining = sceneEntries.filter((item) => item !== entry);
|
|
377
|
+
commitEntries(remaining);
|
|
378
|
+
commitVisible(visibleRootIds.filter((rootId) => rootId !== id));
|
|
379
|
+
clearFocusFinished(playback, id);
|
|
380
|
+
playback.focusElapsedByRoot.delete(id);
|
|
381
|
+
if (wasFocused) {
|
|
382
|
+
focusEntry(remaining[Math.min(index, remaining.length - 1)] ?? null, false);
|
|
383
|
+
}
|
|
384
|
+
historyByRoot.delete(id);
|
|
385
|
+
refreshPlayback();
|
|
386
|
+
};
|
|
387
|
+
const deleteFocused = () => {
|
|
388
|
+
const entry = sceneEntries.find((item) => item.root === binding?.root);
|
|
389
|
+
if (!entry || prefab) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
deleteEntry(entry);
|
|
393
|
+
return true;
|
|
394
|
+
};
|
|
395
|
+
const createDefault = () => {
|
|
396
|
+
if (refuseInPrefabMode()) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const entry = createSceneDefaultEntry(scene, sceneEntries, {
|
|
400
|
+
resolveTexture: options.resolveTexture,
|
|
401
|
+
textureUrl: options.defaultTextureUrl,
|
|
402
|
+
});
|
|
403
|
+
for (const system of entry.systems) {
|
|
404
|
+
renderer.addSystem(system);
|
|
405
|
+
}
|
|
406
|
+
addEntries([entry], cameraGroundTarget());
|
|
407
|
+
};
|
|
408
|
+
// ---- assets ---------------------------------------------------------------------------
|
|
409
|
+
const previewDrop = (clientX, clientY) => {
|
|
410
|
+
const payload = getAssetDragSession();
|
|
411
|
+
if (!payload)
|
|
412
|
+
return;
|
|
413
|
+
const described = describeAssetDrag(assets, payload);
|
|
414
|
+
if (described.assets.length === 0)
|
|
415
|
+
return;
|
|
416
|
+
dropMarker.show(pickGroundPosition(scene, clientX, clientY), described.assets.length);
|
|
417
|
+
view.dragHoverChanged({ label: described.label, count: described.assets.length });
|
|
418
|
+
};
|
|
419
|
+
const placeDrop = (clientX, clientY, dataTransfer) => {
|
|
420
|
+
const payload = (dataTransfer && readAssetDrag(dataTransfer)) ?? getAssetDragSession();
|
|
421
|
+
clearDropPreview();
|
|
422
|
+
if (!payload) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const { assets: toPlace } = describeAssetDrag(assets, payload);
|
|
426
|
+
instantiateAssets(toPlace, pickGroundPosition(scene, clientX, clientY));
|
|
427
|
+
};
|
|
428
|
+
const addToScene = (payload) => {
|
|
429
|
+
const { assets: toPlace } = describeAssetDrag(assets, payload);
|
|
430
|
+
instantiateAssets(toPlace, cameraGroundTarget());
|
|
431
|
+
};
|
|
432
|
+
/** Writes asset changes through to the project; failures are reported, not thrown. */
|
|
433
|
+
const persist = (action) => {
|
|
434
|
+
const project = options.project;
|
|
435
|
+
if (!project)
|
|
436
|
+
return;
|
|
437
|
+
action(project.store, project.id).catch((err) => {
|
|
438
|
+
console.error('[Quarks editor] Could not save to the project:', err);
|
|
439
|
+
view.message('Project storage', errorText(err));
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
const deleteAssets = (payload) => {
|
|
443
|
+
const next = removeAssets(assets, payload);
|
|
444
|
+
const removed = assets.filter((asset) => !next.includes(asset)).map((asset) => asset.path);
|
|
445
|
+
commitAssets(next);
|
|
446
|
+
persist((store, id) => store.deleteAssets(id, removed));
|
|
447
|
+
};
|
|
448
|
+
const importAssets = async (files) => {
|
|
449
|
+
view.assetsLoadingChanged({ done: 0, total: 0, loaded: 0, failed: 0 });
|
|
450
|
+
let incoming;
|
|
451
|
+
try {
|
|
452
|
+
incoming = await loadEffectAssets(files, (progress) => view.assetsLoadingChanged(progress));
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
view.assetsLoadingChanged(null);
|
|
456
|
+
}
|
|
457
|
+
if (disposed)
|
|
458
|
+
return;
|
|
459
|
+
const { merged, added } = mergeAssets(assets, incoming);
|
|
460
|
+
commitAssets(merged);
|
|
461
|
+
view.assetsRevealed();
|
|
462
|
+
if (added.length > 0) {
|
|
463
|
+
persist((store, id) => store.putAssets(id, added.map(toStoredAsset)));
|
|
464
|
+
}
|
|
465
|
+
if (incoming.length === 0) {
|
|
466
|
+
throw new Error('No particle effects found in the selected file(s).');
|
|
467
|
+
}
|
|
468
|
+
if (added.length === 0) {
|
|
469
|
+
throw new Error('All selected effects are already in Assets.');
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
/** New asset at `rawPath` (`Folder/Name`, a free path — never overwrites) from an effect tree. */
|
|
473
|
+
const createAssetFromRoot = (root, rawPath) => {
|
|
474
|
+
const taken = new Set(assets.map((asset) => asset.path));
|
|
475
|
+
const wanted = normalizeAssetPath(rawPath);
|
|
476
|
+
let path = wanted;
|
|
477
|
+
for (let n = 2; taken.has(path); n++) {
|
|
478
|
+
path = wanted.replace(/\.json$/i, ` ${n}.json`);
|
|
479
|
+
}
|
|
480
|
+
const name = path
|
|
481
|
+
.split('/')
|
|
482
|
+
.pop()
|
|
483
|
+
.replace(/\.json$/i, '');
|
|
484
|
+
const asset = createEffectAsset(name, path, JSON.parse(serializeEffectTree(root, name)));
|
|
485
|
+
commitAssets([...assets, asset]);
|
|
486
|
+
persist((store, id) => store.putAssets(id, [toStoredAsset(asset)]));
|
|
487
|
+
return asset;
|
|
488
|
+
};
|
|
489
|
+
const saveEntryAsAsset = (entry, rawPath) => {
|
|
490
|
+
const asset = createAssetFromRoot(entry.root, rawPath);
|
|
491
|
+
entry.assetId = asset.id;
|
|
492
|
+
entry.path = asset.path;
|
|
493
|
+
commitEntries([...sceneEntries]);
|
|
494
|
+
view.assetsRevealed();
|
|
495
|
+
view.toast(`Saved as asset “${asset.path.replace(/\.json$/i, '')}”`);
|
|
496
|
+
};
|
|
497
|
+
// ---- prefab mode ------------------------------------------------------------------------
|
|
498
|
+
/** Scene instances made from the open prefab — they keep their own copy when it is saved. */
|
|
499
|
+
const prefabInstanceCount = () => prefab ? prefab.stash.entries.filter((entry) => entry.assetId === prefab.asset.id).length : 0;
|
|
500
|
+
/** Drops the prefab instance(s) currently in `sceneEntries`, with their history. */
|
|
501
|
+
const disposePrefabInstances = () => {
|
|
502
|
+
for (const entry of sceneEntries) {
|
|
503
|
+
const id = entry.root.uniqueId;
|
|
504
|
+
disposeSceneEntry(entry, renderer);
|
|
505
|
+
historyByRoot.delete(id);
|
|
506
|
+
clearFocusFinished(playback, id);
|
|
507
|
+
playback.focusElapsedByRoot.delete(id);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
/** Shows `entry` as the open prefab's only instance, with `asset` as the prefab. */
|
|
511
|
+
const showPrefab = (asset, entry) => {
|
|
512
|
+
setPrefabClean();
|
|
513
|
+
view.prefabChanged({ name: asset.name, path: asset.path });
|
|
514
|
+
entry.root.setEnabled(true);
|
|
515
|
+
replaceEntries([entry], [entry.root.uniqueId]);
|
|
516
|
+
focusEntry(entry);
|
|
517
|
+
refreshPlayback();
|
|
518
|
+
};
|
|
519
|
+
/**
|
|
520
|
+
* Opens an asset as a prefab. With another prefab open it is swapped in place — the scene stays
|
|
521
|
+
* set aside — after asking about unsaved edits unless `discardChanges`.
|
|
522
|
+
*/
|
|
523
|
+
const openPrefab = (assetId, discardChanges = false) => {
|
|
524
|
+
if (prefab?.asset.id === assetId) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const asset = assets.find((item) => item.id === assetId);
|
|
528
|
+
if (!asset)
|
|
529
|
+
return;
|
|
530
|
+
if (prefab && prefabChanged && !discardChanges) {
|
|
531
|
+
view.prefabSwitchRequested({ id: asset.id, name: asset.name });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
let entry = null;
|
|
535
|
+
try {
|
|
536
|
+
entry = instantiateSceneEntry(scene, renderer, asset, []);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
console.warn(`[Quarks editor] Could not open prefab "${asset.path}":`, err);
|
|
540
|
+
}
|
|
541
|
+
if (!entry) {
|
|
542
|
+
view.message('Open prefab', `“${asset.name}” has no particle systems to edit.`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (prefab) {
|
|
546
|
+
selectionMarker.hide();
|
|
547
|
+
disposePrefabInstances();
|
|
548
|
+
prefab.asset = asset;
|
|
549
|
+
showPrefab(asset, entry);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
exitSolo();
|
|
553
|
+
const stash = {
|
|
554
|
+
entries: sceneEntries,
|
|
555
|
+
visible: visibleRootIds,
|
|
556
|
+
focusedRoot: binding?.root ?? null,
|
|
557
|
+
camera: { target: camera.target.clone(), radius: camera.radius, alpha: camera.alpha, beta: camera.beta },
|
|
558
|
+
};
|
|
559
|
+
// Hide the scene without touching its saved visibility.
|
|
560
|
+
for (const item of stash.entries) {
|
|
561
|
+
if (stash.visible.includes(item.root.uniqueId)) {
|
|
562
|
+
setSceneEntryVisible(item, renderer, false);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
selectionMarker.hide();
|
|
566
|
+
prefab = { asset, stash };
|
|
567
|
+
showPrefab(asset, entry);
|
|
568
|
+
};
|
|
569
|
+
/** Leaves prefab mode without saving: drops the prefab instance and brings the scene back. */
|
|
570
|
+
const closePrefab = () => {
|
|
571
|
+
if (!prefab)
|
|
572
|
+
return;
|
|
573
|
+
const { stash } = prefab;
|
|
574
|
+
prefab = null;
|
|
575
|
+
selectionMarker.hide();
|
|
576
|
+
disposePrefabInstances();
|
|
577
|
+
replaceEntries(stash.entries, stash.visible);
|
|
578
|
+
for (const entry of stash.entries) {
|
|
579
|
+
if (stash.visible.includes(entry.root.uniqueId)) {
|
|
580
|
+
setSceneEntryVisible(entry, renderer, true);
|
|
581
|
+
clearFocusFinished(playback, entry.root.uniqueId);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
cancelGalleryCameraFocus();
|
|
585
|
+
camera.setTarget(stash.camera.target);
|
|
586
|
+
camera.radius = stash.camera.radius;
|
|
587
|
+
camera.alpha = stash.camera.alpha;
|
|
588
|
+
camera.beta = stash.camera.beta;
|
|
589
|
+
setPrefabClean();
|
|
590
|
+
view.prefabChanged(null);
|
|
591
|
+
focusEntry(stash.entries.find((entry) => entry.root === stash.focusedRoot) ?? null, false);
|
|
592
|
+
refreshPlayback();
|
|
593
|
+
};
|
|
594
|
+
/** Overwrites the open prefab's asset with the edited instance. Scene instances keep theirs. */
|
|
595
|
+
const overwritePrefab = () => {
|
|
596
|
+
const root = sceneEntries[0]?.root;
|
|
597
|
+
if (!prefab || !root)
|
|
598
|
+
return;
|
|
599
|
+
const updated = {
|
|
600
|
+
...prefab.asset,
|
|
601
|
+
json: JSON.parse(serializeEffectTree(root, prefab.asset.name)),
|
|
602
|
+
};
|
|
603
|
+
const exists = assets.some((asset) => asset.id === updated.id);
|
|
604
|
+
commitAssets(exists ? assets.map((asset) => (asset.id === updated.id ? updated : asset)) : [...assets, updated]);
|
|
605
|
+
persist((store, id) => store.putAssets(id, [toStoredAsset(updated)]));
|
|
606
|
+
prefab.asset = updated;
|
|
607
|
+
setPrefabClean();
|
|
608
|
+
view.toast(`Prefab “${updated.name}” saved · scene unchanged`);
|
|
609
|
+
};
|
|
610
|
+
/** Saves the edited prefab as a new asset and keeps editing that one. */
|
|
611
|
+
const savePrefabAsNew = (rawPath) => {
|
|
612
|
+
const root = sceneEntries[0]?.root;
|
|
613
|
+
if (!prefab || !root)
|
|
614
|
+
return;
|
|
615
|
+
const asset = createAssetFromRoot(root, rawPath);
|
|
616
|
+
prefab.asset = asset;
|
|
617
|
+
setPrefabClean();
|
|
618
|
+
view.prefabChanged({ name: asset.name, path: asset.path });
|
|
619
|
+
view.toast(`Saved as new prefab “${asset.path.replace(/\.json$/i, '')}”`);
|
|
620
|
+
};
|
|
621
|
+
// ---- project scene ----------------------------------------------------------------------
|
|
622
|
+
/** Saves the scene to the project; resolves to false when it could not. */
|
|
623
|
+
const saveScene = async () => {
|
|
624
|
+
const project = options.project;
|
|
625
|
+
if (!project || prefab)
|
|
626
|
+
return false;
|
|
627
|
+
const visibleIds = new Set(visibleRootIds);
|
|
628
|
+
const assetPathById = new Map(assets.map((asset) => [asset.id, asset.path]));
|
|
629
|
+
const stored = {
|
|
630
|
+
savedAt: Date.now(),
|
|
631
|
+
entries: sceneEntries.map((entry) => ({
|
|
632
|
+
name: entry.name,
|
|
633
|
+
assetPath: entry.assetId != null ? (assetPathById.get(entry.assetId) ?? null) : null,
|
|
634
|
+
json: JSON.parse(serializeEffectTree(entry.root, entry.name)),
|
|
635
|
+
visible: visibleIds.has(entry.root.uniqueId),
|
|
636
|
+
})),
|
|
637
|
+
};
|
|
638
|
+
try {
|
|
639
|
+
await project.store.saveScene(project.id, stored);
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
console.error('[Quarks editor] Could not save the scene:', err);
|
|
643
|
+
view.message('Save scene', errorText(err));
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
markClean();
|
|
647
|
+
let infoSeen = true;
|
|
648
|
+
try {
|
|
649
|
+
infoSeen = localStorage.getItem(SCENE_SAVE_INFO_KEY) === '1';
|
|
650
|
+
localStorage.setItem(SCENE_SAVE_INFO_KEY, '1');
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Storage blocked — fall back to the toast.
|
|
654
|
+
}
|
|
655
|
+
if (infoSeen) {
|
|
656
|
+
view.toast('Scene saved · assets unchanged');
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
view.message('Scene saved', 'Saving the scene stores the effects placed in it, with their own edits.\n\n' +
|
|
660
|
+
'It does not change the assets they came from. To keep an edited effect as an asset, ' +
|
|
661
|
+
'right-click it in the Scene list → “Save as new asset…”.');
|
|
662
|
+
}
|
|
663
|
+
return true;
|
|
664
|
+
};
|
|
665
|
+
const loadProject = async (project) => {
|
|
666
|
+
try {
|
|
667
|
+
const [storedAssets, storedScene] = await Promise.all([
|
|
668
|
+
project.store.loadAssets(project.id),
|
|
669
|
+
project.store.loadScene(project.id),
|
|
670
|
+
]);
|
|
671
|
+
if (disposed)
|
|
672
|
+
return;
|
|
673
|
+
const loadedAssets = storedAssets.map((stored) => ({
|
|
674
|
+
...createEffectAsset(stored.name, stored.path, stored.json),
|
|
675
|
+
missingFiles: stored.missingFiles,
|
|
676
|
+
}));
|
|
677
|
+
commitAssets(loadedAssets);
|
|
678
|
+
const assetIdByPath = new Map(loadedAssets.map((asset) => [asset.path, asset.id]));
|
|
679
|
+
if (!storedScene) {
|
|
680
|
+
createDefault();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const created = [];
|
|
684
|
+
const visible = [];
|
|
685
|
+
for (const stored of storedScene.entries) {
|
|
686
|
+
const entry = instantiateSceneEntry(scene, renderer, { id: -1, name: stored.name, path: stored.assetPath ?? '', json: stored.json, missingFiles: [] }, created);
|
|
687
|
+
if (!entry)
|
|
688
|
+
continue;
|
|
689
|
+
entry.name = stored.name;
|
|
690
|
+
entry.root.name = stored.name;
|
|
691
|
+
entry.assetId = stored.assetPath != null ? (assetIdByPath.get(stored.assetPath) ?? null) : null;
|
|
692
|
+
entry.path = stored.assetPath ?? `_created/${stored.name}.json`;
|
|
693
|
+
setSceneEntryVisible(entry, renderer, stored.visible);
|
|
694
|
+
if (stored.visible)
|
|
695
|
+
visible.push(entry.root.uniqueId);
|
|
696
|
+
created.push(entry);
|
|
697
|
+
}
|
|
698
|
+
commitEntries(created);
|
|
699
|
+
commitVisible(visible);
|
|
700
|
+
focusEntry(created[0] ?? null, false);
|
|
701
|
+
refreshPlayback();
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
console.error('[Quarks editor] Could not open the project:', err);
|
|
705
|
+
view.message('Open project', errorText(err));
|
|
706
|
+
}
|
|
707
|
+
finally {
|
|
708
|
+
loadingScene = false;
|
|
709
|
+
markClean();
|
|
710
|
+
view.projectLoaded();
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
// ---- undo / redo ----------------------------------------------------------------------
|
|
714
|
+
/** Rebuilds the focused entry from a history snapshot, in place in the scene list. */
|
|
715
|
+
const historyStep = (redo) => {
|
|
716
|
+
const current = binding;
|
|
717
|
+
if (!current) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const json = redo ? history.redo() : history.undo();
|
|
721
|
+
const index = sceneEntries.findIndex((entry) => entry.root === current.root);
|
|
722
|
+
const prev = index >= 0 ? sceneEntries[index] : null;
|
|
723
|
+
if (!json || !prev) {
|
|
724
|
+
view.refresh();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const selectedIndex = nodeIndexInEffect(current.root, options.getSelectedNode?.() ?? null);
|
|
728
|
+
const visible = isVisible(prev);
|
|
729
|
+
const { root, systems } = parseEffectFromJson(scene, renderer, json, {
|
|
730
|
+
registerRenderer: true,
|
|
731
|
+
autoplay: false,
|
|
732
|
+
});
|
|
733
|
+
if (systems.length === 0) {
|
|
734
|
+
disposeSceneEntry({ ...prev, root, systems }, renderer);
|
|
735
|
+
view.refresh();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
disposeSceneEntry(prev, renderer);
|
|
739
|
+
// The snapshot carries the root transform too, so a moved effect moves back.
|
|
740
|
+
root.parent = null;
|
|
741
|
+
root.name = prev.name;
|
|
742
|
+
const nextEntry = { ...prev, root, systems };
|
|
743
|
+
setSceneEntryVisible(nextEntry, renderer, visible);
|
|
744
|
+
const entries = [...sceneEntries];
|
|
745
|
+
entries[index] = nextEntry;
|
|
746
|
+
commitEntries(entries);
|
|
747
|
+
commitVisible(visibleRootIds.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id)));
|
|
748
|
+
if (solo) {
|
|
749
|
+
if (solo.rootId === prev.root.uniqueId) {
|
|
750
|
+
solo.rootId = root.uniqueId;
|
|
751
|
+
view.soloChanged(root.uniqueId);
|
|
752
|
+
}
|
|
753
|
+
solo.hidden = solo.hidden.map((id) => (id === prev.root.uniqueId ? root.uniqueId : id));
|
|
754
|
+
}
|
|
755
|
+
const next = bindingFromSceneEntry(nextEntry);
|
|
756
|
+
history.attach(next);
|
|
757
|
+
next.subscribe(onBindingChange);
|
|
758
|
+
setFocus(next);
|
|
759
|
+
view.selectedNodeChanged(nodeAtIndex(root, selectedIndex));
|
|
760
|
+
view.focusedRootChanged(root);
|
|
761
|
+
playback.focusRootId = root.uniqueId;
|
|
762
|
+
if (visible) {
|
|
763
|
+
selectionMarker.show(root);
|
|
764
|
+
}
|
|
765
|
+
notifyTransformChanged();
|
|
766
|
+
refreshPlayback();
|
|
767
|
+
};
|
|
768
|
+
// ---- handle -----------------------------------------------------------------------------
|
|
769
|
+
const makeHandle = () => ({
|
|
770
|
+
get binding() {
|
|
771
|
+
if (!binding) {
|
|
772
|
+
throw new Error('No effect is focused — add one to the scene first.');
|
|
773
|
+
}
|
|
774
|
+
return binding;
|
|
775
|
+
},
|
|
776
|
+
history,
|
|
777
|
+
scene,
|
|
778
|
+
renderer,
|
|
779
|
+
importJson,
|
|
780
|
+
importAssets,
|
|
781
|
+
importGallery: importAssets,
|
|
782
|
+
exportJson: () => makeHandle().binding.exportJSON('EditorEffect'),
|
|
783
|
+
});
|
|
784
|
+
/** Adds `json` to Assets and returns its single instance; throws when it has no systems. */
|
|
785
|
+
const openJson = (json, name = 'Effect') => {
|
|
786
|
+
const asset = createEffectAsset(name, `_opened/${name}.json`, json);
|
|
787
|
+
const entry = instantiateSceneEntry(scene, renderer, asset, sceneEntries);
|
|
788
|
+
if (!entry) {
|
|
789
|
+
throw new Error('No particle systems found in the JSON.');
|
|
790
|
+
}
|
|
791
|
+
asset.name = entry.name;
|
|
792
|
+
asset.path = `_opened/${entry.name}.json`;
|
|
793
|
+
commitAssets([...assets, asset]);
|
|
794
|
+
return entry;
|
|
795
|
+
};
|
|
796
|
+
const importJson = (json) => {
|
|
797
|
+
closePrefab();
|
|
798
|
+
const entry = openJson(json);
|
|
799
|
+
for (const existing of sceneEntries) {
|
|
800
|
+
disposeSceneEntry(existing, renderer);
|
|
801
|
+
}
|
|
802
|
+
historyByRoot.clear();
|
|
803
|
+
solo = null;
|
|
804
|
+
view.soloChanged(null);
|
|
805
|
+
commitEntries([]);
|
|
806
|
+
commitVisible([]);
|
|
807
|
+
addEntries([entry], Vector3.Zero());
|
|
808
|
+
};
|
|
809
|
+
// ---- frame ------------------------------------------------------------------------------
|
|
810
|
+
const renderFrame = () => {
|
|
811
|
+
const entries = sceneEntries;
|
|
812
|
+
const visibleIds = new Set(visibleRootIds);
|
|
813
|
+
const focusBinding = binding;
|
|
814
|
+
if (!playback.scrubbing) {
|
|
815
|
+
let delta = (engine.getDeltaTime() / 1000) * playback.speed;
|
|
816
|
+
const stepQueued = playback.stepQueued;
|
|
817
|
+
if (!playback.stagePlaying) {
|
|
818
|
+
delta = stepQueued ? 1 / 60 : 0;
|
|
819
|
+
playback.stepQueued = false;
|
|
820
|
+
}
|
|
821
|
+
if (delta > 0) {
|
|
822
|
+
const simulate = hasActivePreview(entries, visibleIds) && (playback.stagePlaying || stepQueued);
|
|
823
|
+
if (simulate) {
|
|
824
|
+
renderer.update(delta);
|
|
825
|
+
let needsRefresh = false;
|
|
826
|
+
if (pauseFinishedPreviews(entries, visibleIds, playback.focusFinishedByRoot)) {
|
|
827
|
+
needsRefresh = true;
|
|
828
|
+
}
|
|
829
|
+
const focusId = playback.focusRootId ?? focusBinding?.root.uniqueId ?? null;
|
|
830
|
+
if (focusId != null &&
|
|
831
|
+
focusBinding &&
|
|
832
|
+
playback.stagePlaying &&
|
|
833
|
+
!playback.focusFinishedByRoot.has(focusId) &&
|
|
834
|
+
!focusTimelineMeta.anyLooping &&
|
|
835
|
+
QuarksUtil.isEffectFinished(focusBinding.root)) {
|
|
836
|
+
const settleAt = getFocusElapsed(playback, focusId);
|
|
837
|
+
scrubFocusToSettled(focusBinding, renderer, settleAt);
|
|
838
|
+
markFocusFinished(playback, focusId);
|
|
839
|
+
pauseFocus(focusBinding);
|
|
840
|
+
needsRefresh = true;
|
|
841
|
+
}
|
|
842
|
+
else if (focusId != null && playback.stagePlaying && !playback.focusFinishedByRoot.has(focusId)) {
|
|
843
|
+
const prev = getFocusElapsed(playback, focusId);
|
|
844
|
+
setFocusElapsed(playback, focusId, prev + delta);
|
|
845
|
+
}
|
|
846
|
+
if (needsRefresh) {
|
|
847
|
+
renderer.refreshBatches();
|
|
848
|
+
}
|
|
849
|
+
logRenderMismatch(renderer, countGalleryParticles(entries, visibleIds));
|
|
850
|
+
}
|
|
851
|
+
else if (entries.length > 0) {
|
|
852
|
+
renderer.refreshBatches();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
scene.render();
|
|
857
|
+
selectionMarker.follow();
|
|
858
|
+
viewportPicker.follow();
|
|
859
|
+
if (focusBinding) {
|
|
860
|
+
emitterWireframes.sync([focusBinding.system, ...focusBinding.subSystems], `${focusBinding.root.uniqueId}:${focusBinding.getRevision()}`);
|
|
861
|
+
}
|
|
862
|
+
const pickKey = entries
|
|
863
|
+
.map((entry) => `${entry.root.uniqueId}:${visibleIds.has(entry.root.uniqueId) ? 1 : 0}`)
|
|
864
|
+
.join('|');
|
|
865
|
+
viewportPicker.sync(entries, null, `${pickKey}::${focusBinding?.root.uniqueId ?? 'none'}`);
|
|
866
|
+
if (focusBinding &&
|
|
867
|
+
(focusBinding.root.uniqueId !== focusTimelineMeta.rootId ||
|
|
868
|
+
focusBinding.getRevision() !== focusTimelineMeta.revision)) {
|
|
869
|
+
const rows = computeTimelineRows(focusBinding);
|
|
870
|
+
focusTimelineMeta = {
|
|
871
|
+
rootId: focusBinding.root.uniqueId,
|
|
872
|
+
revision: focusBinding.getRevision(),
|
|
873
|
+
span: computeTimelineSpan(rows),
|
|
874
|
+
anyLooping: rows.some((row) => row.kind === 'track' && row.looping),
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
const count = countGalleryParticles(entries, visibleIds);
|
|
878
|
+
const focused = entries.find((entry) => entry.root === focusBinding?.root);
|
|
879
|
+
const focusId = playback.focusRootId ?? focused?.root.uniqueId ?? null;
|
|
880
|
+
const focusElapsed = focusId != null ? getFocusElapsed(playback, focusId) : 0;
|
|
881
|
+
const focusEnded = focusId != null && isFocusFinished(playback, focusId);
|
|
882
|
+
const meta = focusTimelineMeta;
|
|
883
|
+
const timeLabel = meta.anyLooping
|
|
884
|
+
? `${focusElapsed.toFixed(1)}s ⟳`
|
|
885
|
+
: focusEnded
|
|
886
|
+
? `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s (end)`
|
|
887
|
+
: `${focusElapsed.toFixed(1)}s / ${meta.span.toFixed(1)}s`;
|
|
888
|
+
view.frame({
|
|
889
|
+
fps: Math.round(engine.getFps()),
|
|
890
|
+
counter: focused ? `${timeLabel} · ${count} particles` : `${count} particles`,
|
|
891
|
+
});
|
|
892
|
+
};
|
|
893
|
+
const syncPlayback = () => {
|
|
894
|
+
applyGalleryPlayback(sceneEntries, new Set(visibleRootIds), playback.stagePlaying, playback.focusFinishedByRoot);
|
|
895
|
+
if (!playback.stagePlaying) {
|
|
896
|
+
renderer.refreshBatches();
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
const start = () => {
|
|
900
|
+
if (started || disposed)
|
|
901
|
+
return;
|
|
902
|
+
started = true;
|
|
903
|
+
if (options.project) {
|
|
904
|
+
void loadProject(options.project);
|
|
905
|
+
}
|
|
906
|
+
else if (options.effectJson) {
|
|
907
|
+
addEntries([openJson(options.effectJson)], Vector3.Zero());
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
createDefault();
|
|
911
|
+
}
|
|
912
|
+
engine.runRenderLoop(renderFrame);
|
|
913
|
+
};
|
|
914
|
+
const dispose = () => {
|
|
915
|
+
if (disposed)
|
|
916
|
+
return;
|
|
917
|
+
disposed = true;
|
|
918
|
+
unsubscribeTransform();
|
|
919
|
+
clearDropPreview();
|
|
920
|
+
cancelGalleryCameraFocus();
|
|
921
|
+
dropMarker.dispose();
|
|
922
|
+
selectionMarker.dispose();
|
|
923
|
+
emitterWireframes.dispose();
|
|
924
|
+
viewportPicker.dispose();
|
|
925
|
+
canvas?.removeEventListener('pointerleave', handleViewportPointerLeave);
|
|
926
|
+
scene.onPointerObservable.remove(pointerObserver);
|
|
927
|
+
gizmoManager.dispose();
|
|
928
|
+
gizmoUtilityLayer.dispose();
|
|
929
|
+
engine.stopRenderLoop(renderFrame);
|
|
930
|
+
scene.dispose();
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
engine,
|
|
934
|
+
scene,
|
|
935
|
+
camera,
|
|
936
|
+
ground,
|
|
937
|
+
renderer,
|
|
938
|
+
history,
|
|
939
|
+
gizmoManager,
|
|
940
|
+
get binding() {
|
|
941
|
+
return binding;
|
|
942
|
+
},
|
|
943
|
+
get sceneEntries() {
|
|
944
|
+
return sceneEntries;
|
|
945
|
+
},
|
|
946
|
+
get visibleRootIds() {
|
|
947
|
+
return visibleRootIds;
|
|
948
|
+
},
|
|
949
|
+
get assets() {
|
|
950
|
+
return assets;
|
|
951
|
+
},
|
|
952
|
+
get isPrefabOpen() {
|
|
953
|
+
return prefab !== null;
|
|
954
|
+
},
|
|
955
|
+
importAssets,
|
|
956
|
+
importJson,
|
|
957
|
+
focusEntry,
|
|
958
|
+
toggleVisible,
|
|
959
|
+
toggleSolo,
|
|
960
|
+
deleteEntry,
|
|
961
|
+
deleteFocused,
|
|
962
|
+
createDefault,
|
|
963
|
+
previewDrop,
|
|
964
|
+
placeDrop,
|
|
965
|
+
hideDropPreview,
|
|
966
|
+
clearDropPreview,
|
|
967
|
+
setEmitterWireframesVisible: (visible) => emitterWireframes.setVisible(visible),
|
|
968
|
+
addToScene,
|
|
969
|
+
deleteAssets,
|
|
970
|
+
saveEntryAsAsset,
|
|
971
|
+
saveScene,
|
|
972
|
+
openPrefab,
|
|
973
|
+
overwritePrefab,
|
|
974
|
+
savePrefabAsNew,
|
|
975
|
+
closePrefab,
|
|
976
|
+
prefabInstanceCount,
|
|
977
|
+
historyStep,
|
|
978
|
+
syncPlayback,
|
|
979
|
+
makeHandle,
|
|
980
|
+
start,
|
|
981
|
+
dispose,
|
|
982
|
+
};
|
|
983
|
+
}
|