babylon.quarks-editor 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +6 -6
  3. package/dist/core/assetDragSession.d.ts +4 -0
  4. package/dist/core/assetDragSession.js +8 -0
  5. package/dist/core/assetTree.d.ts +39 -0
  6. package/dist/core/assetTree.js +112 -0
  7. package/dist/core/assets.d.ts +56 -0
  8. package/dist/core/assets.js +207 -0
  9. package/dist/core/binding.d.ts +6 -1
  10. package/dist/core/binding.js +7 -0
  11. package/dist/core/collision.d.ts +4 -5
  12. package/dist/core/collision.js +4 -5
  13. package/dist/core/colors.d.ts +1 -1
  14. package/dist/core/defaultEffect.d.ts +4 -5
  15. package/dist/core/defaultEffect.js +5 -14
  16. package/dist/core/effectTree.d.ts +1 -1
  17. package/dist/core/effectTree.js +18 -4
  18. package/dist/core/emitterShapeWireframe.d.ts +2 -1
  19. package/dist/core/emitterShapeWireframe.js +2 -1
  20. package/dist/core/galleryDropMarker.js +1 -1
  21. package/dist/core/galleryPlayback.d.ts +7 -7
  22. package/dist/core/galleryPlayback.js +2 -2
  23. package/dist/core/galleryScene.d.ts +8 -13
  24. package/dist/core/galleryScene.js +14 -42
  25. package/dist/core/gallerySelectionMarker.d.ts +1 -0
  26. package/dist/core/gallerySelectionMarker.js +1 -0
  27. package/dist/core/history.d.ts +12 -1
  28. package/dist/core/history.js +16 -3
  29. package/dist/core/indexedDbProjectStore.d.ts +35 -0
  30. package/dist/core/indexedDbProjectStore.js +299 -0
  31. package/dist/core/lineShaders.d.ts +2 -0
  32. package/dist/core/lineShaders.js +7 -0
  33. package/dist/core/loadEffect.d.ts +2 -2
  34. package/dist/core/loadEffect.js +1 -1
  35. package/dist/core/pickAssetFiles.d.ts +6 -0
  36. package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
  37. package/dist/core/projectStore.d.ts +61 -0
  38. package/dist/core/projectStore.js +14 -0
  39. package/dist/core/sceneEntries.d.ts +35 -0
  40. package/dist/core/sceneEntries.js +62 -0
  41. package/dist/core/shapes.d.ts +1 -1
  42. package/dist/core/shapes.js +35 -8
  43. package/dist/core/systems.d.ts +1 -1
  44. package/dist/core/systems.js +3 -1
  45. package/dist/core/timeline.d.ts +1 -2
  46. package/dist/core/transport.d.ts +12 -0
  47. package/dist/core/transport.js +36 -0
  48. package/dist/core/values.d.ts +1 -1
  49. package/dist/core/viewportPicker.d.ts +4 -4
  50. package/dist/core/viewportPicker.js +3 -3
  51. package/dist/index.d.ts +15 -11
  52. package/dist/index.js +8 -6
  53. package/dist/react/AssetsPanel.d.ts +22 -0
  54. package/dist/react/AssetsPanel.js +252 -0
  55. package/dist/react/ChoiceDialog.d.ts +18 -0
  56. package/dist/react/ChoiceDialog.js +48 -0
  57. package/dist/react/ContextMenu.d.ts +16 -0
  58. package/dist/react/ContextMenu.js +70 -0
  59. package/dist/react/CurveEditor.d.ts +1 -2
  60. package/dist/react/CurveEditor.js +14 -3
  61. package/dist/react/EditorWorkspace.d.ts +21 -0
  62. package/dist/react/EditorWorkspace.js +63 -0
  63. package/dist/react/EffectEditor.d.ts +3 -3
  64. package/dist/react/EffectEditor.js +9 -6
  65. package/dist/react/EffectEditorHost.d.ts +20 -3
  66. package/dist/react/EffectEditorHost.js +1426 -558
  67. package/dist/react/EffectTreeView.d.ts +17 -0
  68. package/dist/react/EffectTreeView.js +52 -0
  69. package/dist/react/GradientEditor.d.ts +1 -2
  70. package/dist/react/GradientEditor.js +8 -1
  71. package/dist/react/MessageDialog.js +7 -1
  72. package/dist/react/ModuleSection.js +10 -3
  73. package/dist/react/ObjectModule.d.ts +5 -2
  74. package/dist/react/ObjectModule.js +8 -11
  75. package/dist/react/ProjectsPage.d.ts +9 -0
  76. package/dist/react/ProjectsPage.js +140 -0
  77. package/dist/react/ScenePanel.d.ts +26 -0
  78. package/dist/react/ScenePanel.js +134 -0
  79. package/dist/react/SubEmittersModule.d.ts +1 -2
  80. package/dist/react/TimelinePanel.d.ts +11 -4
  81. package/dist/react/TimelinePanel.js +65 -40
  82. package/dist/react/TimelineTrackRow.d.ts +1 -1
  83. package/dist/react/TimelineTrackRow.js +20 -6
  84. package/dist/react/TransportBar.d.ts +24 -0
  85. package/dist/react/TransportBar.js +43 -0
  86. package/dist/react/ValueField.d.ts +1 -2
  87. package/dist/react/behaviorModules.js +24 -12
  88. package/dist/react/index.d.ts +31 -17
  89. package/dist/react/index.js +18 -11
  90. package/dist/react/inspectorHints.js +1 -2
  91. package/dist/react/modules.js +55 -25
  92. package/package.json +68 -68
  93. package/src/core/assetDragSession.ts +12 -0
  94. package/src/core/assetTree.ts +148 -0
  95. package/src/core/assets.ts +262 -0
  96. package/src/core/binding.ts +10 -2
  97. package/src/core/collision.ts +4 -5
  98. package/src/core/colors.ts +1 -1
  99. package/src/core/defaultEffect.ts +10 -23
  100. package/src/core/effectTree.ts +22 -7
  101. package/src/core/emitterShapeWireframe.ts +9 -4
  102. package/src/core/galleryDropMarker.ts +6 -2
  103. package/src/core/galleryPlayback.ts +9 -9
  104. package/src/core/galleryScene.ts +15 -63
  105. package/src/core/gallerySelectionMarker.ts +6 -1
  106. package/src/core/history.ts +24 -3
  107. package/src/core/indexedDbProjectStore.ts +357 -0
  108. package/src/core/lineShaders.ts +7 -0
  109. package/src/core/loadEffect.ts +3 -3
  110. package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +16 -8
  111. package/src/core/projectStore.ts +81 -0
  112. package/src/core/sceneEntries.ts +96 -0
  113. package/src/core/shapes.ts +48 -10
  114. package/src/core/systems.ts +9 -3
  115. package/src/core/timeline.ts +8 -8
  116. package/src/core/transport.ts +53 -0
  117. package/src/core/values.ts +1 -1
  118. package/src/core/viewportPicker.ts +5 -5
  119. package/src/index.ts +29 -18
  120. package/src/react/AssetsPanel.tsx +559 -0
  121. package/src/react/ChoiceDialog.tsx +93 -0
  122. package/src/react/ContextMenu.tsx +107 -0
  123. package/src/react/CurveEditor.tsx +40 -10
  124. package/src/react/EditorWorkspace.tsx +95 -0
  125. package/src/react/EffectEditor.tsx +28 -16
  126. package/src/react/EffectEditorHost.tsx +2005 -721
  127. package/src/react/EffectTreeView.tsx +117 -0
  128. package/src/react/GradientEditor.tsx +21 -4
  129. package/src/react/InspectorInfoIcon.tsx +6 -2
  130. package/src/react/MessageDialog.tsx +9 -1
  131. package/src/react/ModuleSection.tsx +12 -3
  132. package/src/react/ObjectModule.tsx +41 -16
  133. package/src/react/ProjectsPage.tsx +306 -0
  134. package/src/react/PromptDialog.tsx +5 -1
  135. package/src/react/ScenePanel.tsx +318 -0
  136. package/src/react/SubEmittersModule.tsx +1 -2
  137. package/src/react/TimelinePanel.tsx +171 -99
  138. package/src/react/TimelineTrackRow.tsx +71 -18
  139. package/src/react/TransportBar.tsx +114 -0
  140. package/src/react/ValueField.tsx +0 -1
  141. package/src/react/behaviorModules.tsx +148 -42
  142. package/src/react/index.ts +47 -33
  143. package/src/react/inspectorHints.ts +1 -2
  144. package/src/react/modules.tsx +173 -57
  145. package/dist/core/galleryDragSession.d.ts +0 -4
  146. package/dist/core/galleryDragSession.js +0 -8
  147. package/dist/core/galleryTree.d.ts +0 -49
  148. package/dist/core/galleryTree.js +0 -138
  149. package/dist/core/loadEffectGallery.d.ts +0 -36
  150. package/dist/core/loadEffectGallery.js +0 -97
  151. package/dist/core/pickGalleryFiles.d.ts +0 -5
  152. package/dist/react/GalleryPanel.d.ts +0 -20
  153. package/dist/react/GalleryPanel.js +0 -246
  154. package/src/core/galleryDragSession.ts +0 -12
  155. package/src/core/galleryTree.ts +0 -187
  156. package/src/core/loadEffectGallery.ts +0 -146
  157. package/src/react/GalleryPanel.tsx +0 -517
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to the `babylon.quarks-editor` package are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project follows the `quarks.core` 0.x version line.
6
6
 
7
+ ## [0.20.0] — 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - **Projects**: a start page lists the projects saved in IndexedDB (new, rename, duplicate,
12
+ delete), with storage usage and a "make storage persistent" button. `ProjectStore` with an
13
+ `IndexedDbProjectStore` default keeps each texture once per project by content hash. The scene
14
+ is saved explicitly (Save / Ctrl+S, `*` while unsaved), and assets are written as they change.
15
+ - **Scene and Assets panels**: the Scene panel lists only the effects placed in the viewport
16
+ (show/hide, solo, delete, "Save as new asset…"), and the Assets drawer holds the library.
17
+ - **Prefab mode**: open an asset to edit it on its own, with the scene set aside. Double-clicking
18
+ another asset swaps it in place, asking Save / Don't save / Cancel when there are unsaved edits.
19
+ - **Effect tree** in the Scene and prefab panels: each effect unfolds into its group/emitter tree,
20
+ and clicking a node selects it for the inspector and gizmo, sub-emitters included, without
21
+ opening the timeline.
22
+
23
+ ### Changed
24
+
25
+ - Selecting a node no longer stalls the viewport: the inspector renders after the selection
26
+ instead of in the same frame, cutting main-thread blocking per switch from ~50 ms to ~15–19 ms.
27
+
28
+ ### Fixed
29
+
30
+ - The camera renders only above the floating Assets drawer, so a focused effect or an opened
31
+ prefab is centred where it can be seen.
32
+ - Selection rings rendered nothing under Vite's dev server: the color shaders they use are now
33
+ registered up front instead of being fetched at runtime.
34
+
7
35
  ## [0.17.9] — 2026-07-14
8
36
 
9
37
  ### Added
package/README.md CHANGED
@@ -13,17 +13,17 @@ npm install babylon.quarks-editor babylon.quarks @babylonjs/core react @heroicon
13
13
  ## Usage
14
14
 
15
15
  ```tsx
16
- import {EffectBinding, EffectHistory} from "babylon.quarks-editor";
17
- import {EffectEditor} from "babylon.quarks-editor/react";
16
+ import {EffectBinding, EffectHistory} from 'babylon.quarks-editor';
17
+ import {EffectEditor} from 'babylon.quarks-editor/react';
18
18
 
19
- const binding = new EffectBinding(particleSystem); // your babylon.quarks ParticleSystem
19
+ const binding = new EffectBinding(particleSystem); // your babylon.quarks ParticleSystem
20
20
  const history = new EffectHistory();
21
21
  history.attach(binding);
22
22
 
23
23
  <EffectEditor binding={binding} />;
24
24
 
25
25
  // Serialize to Quarks JSON (round-trips through QuarksLoader):
26
- const json = binding.exportJSON("MyEffect");
26
+ const json = binding.exportJSON('MyEffect');
27
27
  ```
28
28
 
29
29
  Modules: Main, Emission (bursts), Shape (7 emitter types), Speed/Limit Speed/Force/Rotation over Lifetime, Gravity, Color over Lifetime (gradient), Size over Lifetime (curve), Noise, Sub Emitters, Texture Sheet Animation, Renderer. Multi-system hierarchy with add/remove/rename, undo/redo via `EffectHistory`.
@@ -39,9 +39,9 @@ an opinion on your CDN, asset pipeline, or loader (`@babylonjs/loaders`, your ow
39
39
  "Load from file…" in the Renderer module) into whatever object you assign as `system.texture`.
40
40
  - `resolveGeometry(file) => Promise<GeometryData>` — turns a picked file (from "Load from file…"
41
41
  in the Mesh render mode's geometry dropdown) into vertex buffers: `{positions, indices?, uvs?,
42
- normals?}` (typed arrays or plain number arrays).
42
+ normals?}` (typed arrays or plain number arrays).
43
43
  - `textureOptions?: {label, url}[]` / `geometryOptions?: {label, positions, indices?, uvs?,
44
- normals?}[]` — preset lists shown alongside "Custom URL…" / "Load from file…".
44
+ normals?}[]` — preset lists shown alongside "Custom URL…" / "Load from file…".
45
45
 
46
46
  **Signature differs by entry point** — a common source of "works in the demo, throws in my
47
47
  host" bugs:
@@ -0,0 +1,4 @@
1
+ import type { AssetDragPayload } from './assetTree';
2
+ /** Stores the current asset drag payload for viewport preview (getData is drop-only in most browsers). */
3
+ export declare function setAssetDragSession(payload: AssetDragPayload | null): void;
4
+ export declare function getAssetDragSession(): AssetDragPayload | null;
@@ -0,0 +1,8 @@
1
+ let activeDrag = null;
2
+ /** Stores the current asset drag payload for viewport preview (getData is drop-only in most browsers). */
3
+ export function setAssetDragSession(payload) {
4
+ activeDrag = payload;
5
+ }
6
+ export function getAssetDragSession() {
7
+ return activeDrag;
8
+ }
@@ -0,0 +1,39 @@
1
+ import type { EffectAsset } from './assets';
2
+ export interface AssetTreeFolder {
3
+ kind: 'folder';
4
+ name: string;
5
+ path: string;
6
+ children: AssetTreeNode[];
7
+ }
8
+ export interface AssetTreeEffect {
9
+ kind: 'asset';
10
+ name: string;
11
+ path: string;
12
+ asset: EffectAsset;
13
+ }
14
+ export type AssetTreeNode = AssetTreeFolder | AssetTreeEffect;
15
+ /** Builds a folder tree from asset relative paths (e.g. `VFX/Fire/Explosion.json`). */
16
+ export declare function buildAssetTree(assets: EffectAsset[]): AssetTreeNode[];
17
+ /** Children of the folder at `path` (`''` is the top level), or null when it no longer exists. */
18
+ export declare function folderChildren(tree: AssetTreeNode[], path: string): AssetTreeNode[] | null;
19
+ /** Assets whose name or path contains `query`, across every folder, sorted by name. */
20
+ export declare function searchAssets(assets: EffectAsset[], query: string): EffectAsset[];
21
+ export declare const ASSET_DRAG_MIME = "application/x-quarks-asset-drop";
22
+ export type AssetDragPayload = {
23
+ kind: 'asset';
24
+ id: number;
25
+ } | {
26
+ kind: 'folder';
27
+ path: string;
28
+ };
29
+ export declare function writeAssetDrag(dataTransfer: DataTransfer, payload: AssetDragPayload): void;
30
+ export declare function readAssetDrag(dataTransfer: DataTransfer): AssetDragPayload | null;
31
+ /** Returns every asset whose JSON path sits inside `folderPath` (recursive). */
32
+ export declare function collectAssetsInFolder(assets: EffectAsset[], folderPath: string): EffectAsset[];
33
+ /** Collects all assets under a folder tree node. */
34
+ export declare function collectFolderAssets(node: AssetTreeFolder): EffectAsset[];
35
+ /** Resolves a drag payload to the assets that would be instantiated on drop. */
36
+ export declare function describeAssetDrag(assets: EffectAsset[], payload: AssetDragPayload): {
37
+ assets: EffectAsset[];
38
+ label: string;
39
+ };
@@ -0,0 +1,112 @@
1
+ function compareNodes(a, b) {
2
+ if (a.kind !== b.kind) {
3
+ return a.kind === 'folder' ? -1 : 1;
4
+ }
5
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
6
+ }
7
+ /** Builds a folder tree from asset relative paths (e.g. `VFX/Fire/Explosion.json`). */
8
+ export function buildAssetTree(assets) {
9
+ const roots = [];
10
+ for (const asset of assets) {
11
+ const segments = asset.path
12
+ .replace(/\.json$/i, '')
13
+ .split('/')
14
+ .filter(Boolean);
15
+ const fileName = segments.pop() ?? asset.name;
16
+ let level = roots;
17
+ let pathSoFar = '';
18
+ for (const segment of segments) {
19
+ pathSoFar = pathSoFar ? `${pathSoFar}/${segment}` : segment;
20
+ let folder = level.find((node) => node.kind === 'folder' && node.name === segment);
21
+ if (!folder) {
22
+ folder = { kind: 'folder', name: segment, path: pathSoFar, children: [] };
23
+ level.push(folder);
24
+ }
25
+ level = folder.children;
26
+ }
27
+ level.push({ kind: 'asset', name: fileName, path: asset.path, asset });
28
+ }
29
+ const sortTree = (nodes) => {
30
+ nodes.sort(compareNodes);
31
+ for (const node of nodes) {
32
+ if (node.kind === 'folder') {
33
+ sortTree(node.children);
34
+ }
35
+ }
36
+ };
37
+ sortTree(roots);
38
+ return roots;
39
+ }
40
+ /** Children of the folder at `path` (`''` is the top level), or null when it no longer exists. */
41
+ export function folderChildren(tree, path) {
42
+ let level = tree;
43
+ for (const segment of path.split('/').filter(Boolean)) {
44
+ const folder = level.find((node) => node.kind === 'folder' && node.name === segment);
45
+ if (!folder) {
46
+ return null;
47
+ }
48
+ level = folder.children;
49
+ }
50
+ return level;
51
+ }
52
+ /** Assets whose name or path contains `query`, across every folder, sorted by name. */
53
+ export function searchAssets(assets, query) {
54
+ const q = query.trim().toLowerCase();
55
+ return assets
56
+ .filter((asset) => `${asset.path} ${asset.name}`.toLowerCase().includes(q))
57
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
58
+ }
59
+ export const ASSET_DRAG_MIME = 'application/x-quarks-asset-drop';
60
+ export function writeAssetDrag(dataTransfer, payload) {
61
+ dataTransfer.setData(ASSET_DRAG_MIME, JSON.stringify(payload));
62
+ dataTransfer.effectAllowed = 'copy';
63
+ }
64
+ export function readAssetDrag(dataTransfer) {
65
+ const raw = dataTransfer.getData(ASSET_DRAG_MIME);
66
+ if (!raw) {
67
+ return null;
68
+ }
69
+ try {
70
+ const parsed = JSON.parse(raw);
71
+ if (parsed?.kind === 'asset' && typeof parsed.id === 'number') {
72
+ return parsed;
73
+ }
74
+ if (parsed?.kind === 'folder' && typeof parsed.path === 'string') {
75
+ return parsed;
76
+ }
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ return null;
82
+ }
83
+ /** Returns every asset whose JSON path sits inside `folderPath` (recursive). */
84
+ export function collectAssetsInFolder(assets, folderPath) {
85
+ const prefix = `${folderPath}/`;
86
+ return assets.filter((asset) => asset.path.startsWith(prefix));
87
+ }
88
+ /** Collects all assets under a folder tree node. */
89
+ export function collectFolderAssets(node) {
90
+ const collected = [];
91
+ const walk = (nodes) => {
92
+ for (const child of nodes) {
93
+ if (child.kind === 'asset') {
94
+ collected.push(child.asset);
95
+ }
96
+ else {
97
+ walk(child.children);
98
+ }
99
+ }
100
+ };
101
+ walk(node.children);
102
+ return collected;
103
+ }
104
+ /** Resolves a drag payload to the assets that would be instantiated on drop. */
105
+ export function describeAssetDrag(assets, payload) {
106
+ if (payload.kind === 'asset') {
107
+ const asset = assets.find((item) => item.id === payload.id);
108
+ return { assets: asset ? [asset] : [], label: asset?.name ?? 'Effect' };
109
+ }
110
+ const label = payload.path.split('/').filter(Boolean).pop() ?? payload.path;
111
+ return { assets: collectAssetsInFolder(assets, payload.path), label };
112
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * One effect file in the Assets panel. Holds the source JSON only — nothing is live in the
3
+ * Babylon scene until the asset is dropped into the viewport, and every drop instantiates a
4
+ * fresh, independent copy (see `instantiateSceneEntry`).
5
+ */
6
+ export interface EffectAsset {
7
+ id: number;
8
+ name: string;
9
+ /** Relative path inside the imported folder (preserves subfolders). */
10
+ path: string;
11
+ /** Quarks JSON with every external file reference already inlined. */
12
+ json: unknown;
13
+ /** Texture/geometry files the effect references that were not among the imported files. */
14
+ missingFiles: string[];
15
+ }
16
+ export interface AssetLoadProgress {
17
+ done: number;
18
+ total: number;
19
+ loaded: number;
20
+ failed: number;
21
+ }
22
+ export declare function relativePathOf(file: File): string;
23
+ /** Files an asset import reads: effect `.json` files plus the images/geometries they may reference. */
24
+ export declare function isAssetImportFile(name: string): boolean;
25
+ /** Collects `.json` files from a picker result, sorted by relative path. */
26
+ export declare function collectJsonFiles(files: FileList | File[]): File[];
27
+ /** Resolves `url` against the folder of `fromPath` (`a/b/fx.json` + `../t.png` → `a/t.png`). */
28
+ export declare function resolveRelativePath(fromPath: string, url: string): string;
29
+ /** True for a Quarks effect envelope with at least one particle emitter. */
30
+ export declare function isEffectJson(json: unknown): boolean;
31
+ /**
32
+ * Reads every effect JSON among `files` into an asset. Files that are not effects (geometry
33
+ * files, unrelated JSON, images) are skipped silently; unreadable JSON is skipped with a warning.
34
+ */
35
+ export declare function loadEffectAssets(files: FileList | File[], onProgress: (progress: AssetLoadProgress) => void): Promise<EffectAsset[]>;
36
+ /** Wraps an in-memory effect JSON (e.g. the host's `effectJson`) as an asset. */
37
+ export declare function createEffectAsset(name: string, path: string, json: unknown): EffectAsset;
38
+ /** Merges newly imported assets, skipping paths already in the list. */
39
+ export declare function mergeAssets(existing: EffectAsset[], incoming: EffectAsset[]): {
40
+ merged: EffectAsset[];
41
+ added: EffectAsset[];
42
+ skipped: number;
43
+ };
44
+ /** Removes one asset, or every asset under a folder path. */
45
+ export declare function removeAssets(assets: EffectAsset[], target: {
46
+ kind: 'asset';
47
+ id: number;
48
+ } | {
49
+ kind: 'folder';
50
+ path: string;
51
+ }): EffectAsset[];
52
+ /**
53
+ * Url of the effect's main texture — the first emitter's material map — used as its icon in
54
+ * the Assets panel. Falls back to the first image; null when the effect has no texture.
55
+ */
56
+ export declare function assetIconUrl(asset: EffectAsset): string | null;
@@ -0,0 +1,207 @@
1
+ import { QuarksLoader } from 'babylon.quarks';
2
+ const YIELD_EVERY = 8;
3
+ const IMAGE_FILE = /\.(png|jpe?g|webp|gif|bmp)$/i;
4
+ let nextAssetId = 1;
5
+ function yieldToBrowser() {
6
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
7
+ }
8
+ export function relativePathOf(file) {
9
+ return (file.webkitRelativePath || file.name).replace(/\\/g, '/');
10
+ }
11
+ /** Files an asset import reads: effect `.json` files plus the images/geometries they may reference. */
12
+ export function isAssetImportFile(name) {
13
+ return /\.json$/i.test(name) || IMAGE_FILE.test(name);
14
+ }
15
+ /** Collects `.json` files from a picker result, sorted by relative path. */
16
+ export function collectJsonFiles(files) {
17
+ return Array.from(files)
18
+ .filter((file) => file.name.toLowerCase().endsWith('.json'))
19
+ .sort((a, b) => relativePathOf(a).localeCompare(relativePathOf(b)));
20
+ }
21
+ /** Resolves `url` against the folder of `fromPath` (`a/b/fx.json` + `../t.png` → `a/t.png`). */
22
+ export function resolveRelativePath(fromPath, url) {
23
+ const segments = fromPath.split('/').slice(0, -1);
24
+ for (const part of url.split('/')) {
25
+ if (part === '..') {
26
+ segments.pop();
27
+ }
28
+ else if (part !== '.' && part !== '') {
29
+ segments.push(part);
30
+ }
31
+ }
32
+ return segments.join('/');
33
+ }
34
+ function isExternalUrl(url) {
35
+ return /^(data|blob):/i.test(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('/');
36
+ }
37
+ function readAsDataUrl(file) {
38
+ return new Promise((resolve, reject) => {
39
+ const reader = new FileReader();
40
+ reader.onload = () => resolve(reader.result);
41
+ reader.onerror = () => reject(reader.error);
42
+ reader.readAsDataURL(file);
43
+ });
44
+ }
45
+ /** True for a Quarks effect envelope with at least one particle emitter. */
46
+ export function isEffectJson(json) {
47
+ const object = json?.object;
48
+ const walk = (node) => {
49
+ if (!node || typeof node !== 'object')
50
+ return false;
51
+ const typed = node;
52
+ if (typed.type === 'ParticleEmitter')
53
+ return true;
54
+ return Array.isArray(typed.children) && typed.children.some(walk);
55
+ };
56
+ return walk(object);
57
+ }
58
+ /**
59
+ * Looks files up by path relative to the import root, falling back to the bare file name — a
60
+ * multi-file "Import effect" pick has no folder structure, only names.
61
+ */
62
+ class ImportedFileIndex {
63
+ constructor(files) {
64
+ this.byPath = new Map();
65
+ this.byName = new Map();
66
+ this.cache = new Map();
67
+ for (const file of files) {
68
+ this.byPath.set(relativePathOf(file).toLowerCase(), file);
69
+ if (!this.byName.has(file.name.toLowerCase())) {
70
+ this.byName.set(file.name.toLowerCase(), file);
71
+ }
72
+ }
73
+ }
74
+ find(fromPath, url) {
75
+ const clean = decodeURIComponent(url.split(/[?#]/)[0]);
76
+ return (this.byPath.get(resolveRelativePath(fromPath, clean).toLowerCase()) ??
77
+ this.byName.get((clean.split('/').pop() ?? clean).toLowerCase()));
78
+ }
79
+ /** Reads each file once, however many effects reference it. */
80
+ read(file, read) {
81
+ let pending = this.cache.get(file);
82
+ if (!pending) {
83
+ pending = read(file);
84
+ this.cache.set(file, pending);
85
+ }
86
+ return pending;
87
+ }
88
+ }
89
+ /**
90
+ * Inlines what an "assets as files" export put in separate files: image urls become data URIs
91
+ * and external geometries are read into the JSON, so the effect loads (and later exports) as a
92
+ * self-contained effect. Returns the references that could not be found among the files.
93
+ */
94
+ async function inlineExternalFiles(json, effectPath, index) {
95
+ const missing = [];
96
+ for (const image of Array.isArray(json.images) ? json.images : []) {
97
+ const url = image?.url;
98
+ if (typeof url !== 'string' || isExternalUrl(url))
99
+ continue;
100
+ const file = index.find(effectPath, url);
101
+ if (file) {
102
+ image.url = await index.read(file, readAsDataUrl);
103
+ }
104
+ else {
105
+ missing.push(url);
106
+ }
107
+ }
108
+ await QuarksLoader.resolveExternalGeometries(json, async (url) => {
109
+ const file = index.find(effectPath, url);
110
+ if (!file) {
111
+ missing.push(url);
112
+ throw new Error('not among the imported files');
113
+ }
114
+ return index.read(file, async (f) => JSON.parse(await f.text()));
115
+ });
116
+ return missing;
117
+ }
118
+ /**
119
+ * Reads every effect JSON among `files` into an asset. Files that are not effects (geometry
120
+ * files, unrelated JSON, images) are skipped silently; unreadable JSON is skipped with a warning.
121
+ */
122
+ export async function loadEffectAssets(files, onProgress) {
123
+ const all = Array.from(files).filter((file) => isAssetImportFile(file.name));
124
+ const jsonFiles = collectJsonFiles(all);
125
+ const index = new ImportedFileIndex(all);
126
+ const assets = [];
127
+ let failed = 0;
128
+ onProgress({ done: 0, total: jsonFiles.length, loaded: 0, failed: 0 });
129
+ for (let i = 0; i < jsonFiles.length; i++) {
130
+ const file = jsonFiles[i];
131
+ const path = relativePathOf(file);
132
+ try {
133
+ const json = JSON.parse(await file.text());
134
+ if (isEffectJson(json)) {
135
+ const missingFiles = await inlineExternalFiles(json, path, index);
136
+ if (missingFiles.length > 0) {
137
+ console.warn(`[Quarks editor] "${path}" references files that were not imported:`, missingFiles);
138
+ }
139
+ assets.push({ id: nextAssetId++, name: file.name.replace(/\.json$/i, ''), path, json, missingFiles });
140
+ }
141
+ }
142
+ catch (err) {
143
+ failed++;
144
+ console.warn(`[Quarks editor] Skipped "${path}":`, err);
145
+ }
146
+ onProgress({ done: i + 1, total: jsonFiles.length, loaded: assets.length, failed });
147
+ if ((i + 1) % YIELD_EVERY === 0) {
148
+ await yieldToBrowser();
149
+ }
150
+ }
151
+ return assets;
152
+ }
153
+ /** Wraps an in-memory effect JSON (e.g. the host's `effectJson`) as an asset. */
154
+ export function createEffectAsset(name, path, json) {
155
+ return { id: nextAssetId++, name, path, json, missingFiles: [] };
156
+ }
157
+ /** Merges newly imported assets, skipping paths already in the list. */
158
+ export function mergeAssets(existing, incoming) {
159
+ const paths = new Set(existing.map((asset) => asset.path));
160
+ const added = [];
161
+ for (const asset of incoming) {
162
+ if (paths.has(asset.path))
163
+ continue;
164
+ paths.add(asset.path);
165
+ added.push(asset);
166
+ }
167
+ return { merged: [...existing, ...added], added, skipped: incoming.length - added.length };
168
+ }
169
+ /** Removes one asset, or every asset under a folder path. */
170
+ export function removeAssets(assets, target) {
171
+ if (target.kind === 'asset') {
172
+ return assets.filter((asset) => asset.id !== target.id);
173
+ }
174
+ const prefix = `${target.path}/`;
175
+ return assets.filter((asset) => !asset.path.startsWith(prefix));
176
+ }
177
+ const iconCache = new WeakMap();
178
+ /**
179
+ * Url of the effect's main texture — the first emitter's material map — used as its icon in
180
+ * the Assets panel. Falls back to the first image; null when the effect has no texture.
181
+ */
182
+ export function assetIconUrl(asset) {
183
+ const cached = iconCache.get(asset);
184
+ if (cached !== undefined) {
185
+ return cached;
186
+ }
187
+ const json = asset.json;
188
+ const findMaterial = (node) => {
189
+ if (!node || typeof node !== 'object')
190
+ return undefined;
191
+ if (node.type === 'ParticleEmitter' && node.ps?.material)
192
+ return node.ps.material;
193
+ for (const child of Array.isArray(node.children) ? node.children : []) {
194
+ const found = findMaterial(child);
195
+ if (found)
196
+ return found;
197
+ }
198
+ return undefined;
199
+ };
200
+ const byUuid = (list, uuid) => (Array.isArray(list) ? list : []).find((item) => item?.uuid === uuid);
201
+ const material = byUuid(json?.materials, findMaterial(json?.object));
202
+ const texture = byUuid(json?.textures, material?.map ?? material?.texture);
203
+ const image = byUuid(json?.images, texture?.image) ?? (Array.isArray(json?.images) ? json.images[0] : undefined);
204
+ const url = typeof image?.url === 'string' && image.url ? image.url : null;
205
+ iconCache.set(asset, url);
206
+ return url;
207
+ }
@@ -1,5 +1,5 @@
1
- import type { ParticleSystem } from 'babylon.quarks';
2
1
  import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import type { ParticleSystem } from 'babylon.quarks';
3
3
  import type { EffectTreeNode } from './effectTree';
4
4
  export type EditorListener = () => void;
5
5
  /**
@@ -33,6 +33,11 @@ export declare class EffectBinding {
33
33
  * exists so there's always a designated fallback system to select.
34
34
  */
35
35
  promoteToMain(next: ParticleSystem): void;
36
+ /**
37
+ * Notifies subscribers of a change made outside `apply()` — e.g. a transform edited by the
38
+ * gizmo, or a sub-system edited through its own nested binding.
39
+ */
40
+ markChanged(): void;
36
41
  private notify;
37
42
  restart(): void;
38
43
  /**
@@ -56,6 +56,13 @@ export class EffectBinding {
56
56
  this.system = next;
57
57
  this.notify();
58
58
  }
59
+ /**
60
+ * Notifies subscribers of a change made outside `apply()` — e.g. a transform edited by the
61
+ * gizmo, or a sub-system edited through its own nested binding.
62
+ */
63
+ markChanged() {
64
+ this.notify();
65
+ }
59
66
  notify() {
60
67
  this.revision++;
61
68
  for (const listener of this.listeners) {
@@ -1,8 +1,7 @@
1
1
  import { Vector3 } from 'babylon.quarks';
2
2
  /**
3
- * A horizontal ground plane collider. quarks.core's ApplyCollision behavior delegates to a
4
- * host-provided `PhysicsResolver` (a global singleton) and ships no collider primitive of its
5
- * own, so the editor supplies this — the common "particles bounce off the floor" case.
3
+ * A horizontal ground plane collider for hosts/demos that opt in via ensureGroundResolver /
4
+ * setPhysicsResolver. The editor never registers this automatically.
6
5
  *
7
6
  * `resolve` returns true and fills the surface normal when the point is at or below the plane,
8
7
  * which is exactly what ApplyCollision.update reflects the velocity against.
@@ -13,8 +12,8 @@ export declare class GroundPlaneResolver {
13
12
  resolve(position: Vector3, normal: Vector3): boolean;
14
13
  }
15
14
  /**
16
- * Registers (once) a shared ground-plane resolver so ApplyCollision behaviors — whether added
17
- * in the inspector or deserialized from imported JSON — have something to collide against.
15
+ * Opt-in helper for hosts/demos that want a simple floor collider.
16
+ * The editor does not call this — like Unity, collision needs an explicit host-provided resolver.
18
17
  * Idempotent: an already-registered resolver is left untouched and returned.
19
18
  */
20
19
  export declare function ensureGroundResolver(): GroundPlaneResolver;
@@ -1,8 +1,7 @@
1
1
  import { getPhysicsResolver, setPhysicsResolver } from 'babylon.quarks';
2
2
  /**
3
- * A horizontal ground plane collider. quarks.core's ApplyCollision behavior delegates to a
4
- * host-provided `PhysicsResolver` (a global singleton) and ships no collider primitive of its
5
- * own, so the editor supplies this — the common "particles bounce off the floor" case.
3
+ * A horizontal ground plane collider for hosts/demos that opt in via ensureGroundResolver /
4
+ * setPhysicsResolver. The editor never registers this automatically.
6
5
  *
7
6
  * `resolve` returns true and fills the surface normal when the point is at or below the plane,
8
7
  * which is exactly what ApplyCollision.update reflects the velocity against.
@@ -20,8 +19,8 @@ export class GroundPlaneResolver {
20
19
  }
21
20
  }
22
21
  /**
23
- * Registers (once) a shared ground-plane resolver so ApplyCollision behaviors — whether added
24
- * in the inspector or deserialized from imported JSON — have something to collide against.
22
+ * Opt-in helper for hosts/demos that want a simple floor collider.
23
+ * The editor does not call this — like Unity, collision needs an explicit host-provided resolver.
25
24
  * Idempotent: an already-registered resolver is left untouched and returned.
26
25
  */
27
26
  export function ensureGroundResolver() {
@@ -1,5 +1,5 @@
1
- import { ColorOverLife, Gradient, Vector4 } from 'babylon.quarks';
2
1
  import type { Behavior } from 'babylon.quarks';
2
+ import { ColorOverLife, Gradient, Vector4 } from 'babylon.quarks';
3
3
  /** One RGBA stop of the editable gradient (all channels 0..1, pos 0..1). */
4
4
  export interface GradientStop {
5
5
  pos: number;
@@ -1,12 +1,11 @@
1
1
  import type { Scene } from '@babylonjs/core/scene';
2
- import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
2
  import { ParticleSystem } from 'babylon.quarks';
4
- import type { GalleryEntry } from './loadEffectGallery';
5
- export interface CreateGalleryDefaultOptions {
3
+ import { type SceneEntry } from './sceneEntries';
4
+ export interface CreateSceneDefaultOptions {
6
5
  resolveTexture?: (url: string, scene: Scene) => unknown;
7
6
  textureUrl?: string;
8
7
  }
9
8
  /** Creates a simple cone burst preset for new editor effects. */
10
9
  export declare function createDefaultEffect(scene: Scene, resolveTexture?: (url: string, scene: Scene) => unknown, textureUrl?: string): ParticleSystem;
11
- /** Adds a blank preset effect to the hidden gallery catalog (not in the renderer yet). */
12
- export declare function createGalleryDefaultEntry(scene: Scene, catalogRoot: TransformNode, existing: GalleryEntry[], options?: CreateGalleryDefaultOptions): GalleryEntry;
10
+ /** Creates a preset effect as a new scene entry (systems not registered with the renderer yet). */
11
+ export declare function createSceneDefaultEntry(scene: Scene, existing: SceneEntry[], options?: CreateSceneDefaultOptions): SceneEntry;
@@ -1,6 +1,7 @@
1
1
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
2
  import { ColorOverLife, ConeEmitter, ConstantColor, ConstantValue, IntervalValue, ParticleSystem, RenderMode, Vector4, } from 'babylon.quarks';
3
3
  import { DEFAULT_GRADIENT_STOPS, buildGradient } from './colors';
4
+ import { uniqueEntryName } from './sceneEntries';
4
5
  /** Creates a simple cone burst preset for new editor effects. */
5
6
  export function createDefaultEffect(scene, resolveTexture, textureUrl) {
6
7
  const system = new ParticleSystem({
@@ -22,22 +23,12 @@ export function createDefaultEffect(scene, resolveTexture, textureUrl) {
22
23
  system.emitter.rotation.x = -Math.PI / 2;
23
24
  return system;
24
25
  }
25
- /** Adds a blank preset effect to the hidden gallery catalog (not in the renderer yet). */
26
- export function createGalleryDefaultEntry(scene, catalogRoot, existing, options = {}) {
27
- const takenNames = new Set(existing.map((entry) => entry.name));
28
- const takenPaths = new Set(existing.map((entry) => entry.path));
29
- let name = existing.length === 0 ? 'Effect' : 'New Effect';
30
- let suffix = 2;
31
- while (takenNames.has(name) || takenPaths.has(`_created/${name}.json`)) {
32
- name = `New Effect ${suffix}`;
33
- suffix++;
34
- }
35
- const path = `_created/${name}.json`;
26
+ /** Creates a preset effect as a new scene entry (systems not registered with the renderer yet). */
27
+ export function createSceneDefaultEntry(scene, existing, options = {}) {
28
+ const name = uniqueEntryName(existing.length === 0 ? 'Effect' : 'New Effect', existing);
36
29
  const system = createDefaultEffect(scene, options.resolveTexture, options.textureUrl);
37
30
  system.pause();
38
31
  const root = new TransformNode(name, scene);
39
32
  system.emitter.parent = root;
40
- root.parent = catalogRoot;
41
- root.setEnabled(false);
42
- return { name, path, root, systems: [system], inScene: false };
33
+ return { name, path: `_created/${name}.json`, assetId: null, root, systems: [system] };
43
34
  }
@@ -1,5 +1,5 @@
1
- import type { ParticleSystem } from 'babylon.quarks';
2
1
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import type { ParticleSystem } from 'babylon.quarks';
3
3
  /** A node in the editor's effect hierarchy: a Group (organizational) or a ParticleEmitter. */
4
4
  export interface EffectTreeNode {
5
5
  node: TransformNode;