babylon.quarks-editor 0.19.0 → 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 (115) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/core/assetDragSession.d.ts +4 -0
  3. package/dist/core/assetDragSession.js +8 -0
  4. package/dist/core/assetTree.d.ts +39 -0
  5. package/dist/core/assetTree.js +112 -0
  6. package/dist/core/assets.d.ts +56 -0
  7. package/dist/core/assets.js +207 -0
  8. package/dist/core/binding.d.ts +5 -0
  9. package/dist/core/binding.js +7 -0
  10. package/dist/core/collision.d.ts +4 -5
  11. package/dist/core/collision.js +4 -5
  12. package/dist/core/defaultEffect.d.ts +4 -5
  13. package/dist/core/defaultEffect.js +5 -14
  14. package/dist/core/emitterShapeWireframe.d.ts +1 -0
  15. package/dist/core/emitterShapeWireframe.js +1 -0
  16. package/dist/core/galleryPlayback.d.ts +7 -7
  17. package/dist/core/galleryPlayback.js +2 -2
  18. package/dist/core/galleryScene.d.ts +8 -13
  19. package/dist/core/galleryScene.js +14 -42
  20. package/dist/core/gallerySelectionMarker.d.ts +1 -0
  21. package/dist/core/gallerySelectionMarker.js +1 -0
  22. package/dist/core/history.d.ts +12 -1
  23. package/dist/core/history.js +16 -3
  24. package/dist/core/indexedDbProjectStore.d.ts +35 -0
  25. package/dist/core/indexedDbProjectStore.js +299 -0
  26. package/dist/core/lineShaders.d.ts +2 -0
  27. package/dist/core/lineShaders.js +7 -0
  28. package/dist/core/pickAssetFiles.d.ts +6 -0
  29. package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
  30. package/dist/core/projectStore.d.ts +61 -0
  31. package/dist/core/projectStore.js +14 -0
  32. package/dist/core/sceneEntries.d.ts +35 -0
  33. package/dist/core/sceneEntries.js +62 -0
  34. package/dist/core/transport.d.ts +12 -0
  35. package/dist/core/transport.js +36 -0
  36. package/dist/core/viewportPicker.d.ts +4 -4
  37. package/dist/core/viewportPicker.js +3 -3
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.js +2 -0
  40. package/dist/react/AssetsPanel.d.ts +22 -0
  41. package/dist/react/AssetsPanel.js +252 -0
  42. package/dist/react/ChoiceDialog.d.ts +18 -0
  43. package/dist/react/ChoiceDialog.js +48 -0
  44. package/dist/react/ContextMenu.d.ts +16 -0
  45. package/dist/react/ContextMenu.js +70 -0
  46. package/dist/react/EditorWorkspace.d.ts +21 -0
  47. package/dist/react/EditorWorkspace.js +63 -0
  48. package/dist/react/EffectEditor.d.ts +1 -1
  49. package/dist/react/EffectEditor.js +7 -4
  50. package/dist/react/EffectEditorHost.d.ts +19 -2
  51. package/dist/react/EffectEditorHost.js +1385 -607
  52. package/dist/react/EffectTreeView.d.ts +17 -0
  53. package/dist/react/EffectTreeView.js +52 -0
  54. package/dist/react/ObjectModule.d.ts +4 -0
  55. package/dist/react/ObjectModule.js +7 -10
  56. package/dist/react/ProjectsPage.d.ts +9 -0
  57. package/dist/react/ProjectsPage.js +140 -0
  58. package/dist/react/ScenePanel.d.ts +26 -0
  59. package/dist/react/ScenePanel.js +134 -0
  60. package/dist/react/TimelinePanel.d.ts +8 -1
  61. package/dist/react/TimelinePanel.js +20 -41
  62. package/dist/react/TransportBar.d.ts +24 -0
  63. package/dist/react/TransportBar.js +43 -0
  64. package/dist/react/behaviorModules.js +5 -7
  65. package/dist/react/index.d.ts +16 -2
  66. package/dist/react/index.js +8 -1
  67. package/dist/react/inspectorHints.js +1 -2
  68. package/package.json +1 -1
  69. package/src/core/assetDragSession.ts +12 -0
  70. package/src/core/assetTree.ts +148 -0
  71. package/src/core/assets.ts +262 -0
  72. package/src/core/binding.ts +8 -0
  73. package/src/core/collision.ts +4 -5
  74. package/src/core/defaultEffect.ts +9 -22
  75. package/src/core/emitterShapeWireframe.ts +1 -0
  76. package/src/core/galleryPlayback.ts +9 -9
  77. package/src/core/galleryScene.ts +15 -59
  78. package/src/core/gallerySelectionMarker.ts +1 -0
  79. package/src/core/history.ts +24 -3
  80. package/src/core/indexedDbProjectStore.ts +357 -0
  81. package/src/core/lineShaders.ts +7 -0
  82. package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +13 -8
  83. package/src/core/projectStore.ts +81 -0
  84. package/src/core/sceneEntries.ts +96 -0
  85. package/src/core/transport.ts +53 -0
  86. package/src/core/viewportPicker.ts +5 -5
  87. package/src/index.ts +11 -0
  88. package/src/react/AssetsPanel.tsx +559 -0
  89. package/src/react/ChoiceDialog.tsx +93 -0
  90. package/src/react/ContextMenu.tsx +107 -0
  91. package/src/react/EditorWorkspace.tsx +95 -0
  92. package/src/react/EffectEditor.tsx +15 -4
  93. package/src/react/EffectEditorHost.tsx +1922 -813
  94. package/src/react/EffectTreeView.tsx +117 -0
  95. package/src/react/ObjectModule.tsx +17 -13
  96. package/src/react/ProjectsPage.tsx +306 -0
  97. package/src/react/ScenePanel.tsx +318 -0
  98. package/src/react/TimelinePanel.tsx +102 -103
  99. package/src/react/TransportBar.tsx +114 -0
  100. package/src/react/behaviorModules.tsx +7 -15
  101. package/src/react/index.ts +16 -2
  102. package/src/react/inspectorHints.ts +1 -2
  103. package/dist/core/galleryDragSession.d.ts +0 -4
  104. package/dist/core/galleryDragSession.js +0 -8
  105. package/dist/core/galleryTree.d.ts +0 -49
  106. package/dist/core/galleryTree.js +0 -141
  107. package/dist/core/loadEffectGallery.d.ts +0 -36
  108. package/dist/core/loadEffectGallery.js +0 -97
  109. package/dist/core/pickGalleryFiles.d.ts +0 -5
  110. package/dist/react/GalleryPanel.d.ts +0 -20
  111. package/dist/react/GalleryPanel.js +0 -253
  112. package/src/core/galleryDragSession.ts +0 -12
  113. package/src/core/galleryTree.ts +0 -190
  114. package/src/core/loadEffectGallery.ts +0 -150
  115. package/src/react/GalleryPanel.tsx +0 -561
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ApplyCollision, ApplyForce, ChangeEmitDirection, ColorBySpeed, ConstantValue, ForceOverLife, FrameOverLife, Gradient, InheritVelocity, IntervalValue, LimitSpeedOverLife, Noise, OrbitOverLife, RotationBySpeed, RotationOverLife, SizeBySpeed, SpeedOverLife, TurbulenceField, Vector3, VelocityOverLife, WidthOverLength, } from 'babylon.quarks';
3
- import { ensureGroundResolver } from '../core/collision';
4
- import { DEFAULT_GRADIENT_STOPS, buildGradient, findBehavior, readGradientStops } from '../core/colors';
2
+ import { ApplyCollision, ApplyForce, ChangeEmitDirection, ColorBySpeed, ConstantValue, ForceOverLife, FrameOverLife, getPhysicsResolver, Gradient, InheritVelocity, IntervalValue, LimitSpeedOverLife, Noise, OrbitOverLife, RotationBySpeed, RotationOverLife, SizeBySpeed, SpeedOverLife, TurbulenceField, Vector3, VelocityOverLife, WidthOverLength, } from 'babylon.quarks';
3
+ import { buildGradient, DEFAULT_GRADIENT_STOPS, findBehavior, readGradientStops } from '../core/colors';
5
4
  import { buildCurve, buildScalar, readPieces, readScalar } from '../core/values';
6
5
  import { CurveEditor } from './CurveEditor';
7
6
  import { NumberField, Row, SelectField } from './fields';
@@ -71,10 +70,9 @@ export function EmitDirectionModule({ binding }) {
71
70
  return (_jsx(BehaviorModule, { binding: binding, title: "Randomize Direction", type: "ChangeEmitDirection", create: () => new ChangeEmitDirection(new IntervalValue(0, Math.PI / 6)), children: (behavior) => (_jsx(ValueField, { label: "Angle (rad)", generator: behavior.angle, min: 0, curveMax: Math.PI, onChange: (g) => binding.apply(() => (behavior.angle = g)) })) }));
72
71
  }
73
72
  export function CollisionModule({ binding }) {
74
- // Ensure the shared ground-plane resolver exists before any ApplyCollision is created,
75
- // otherwise its update() would dereference an undefined resolver.
76
- const resolver = ensureGroundResolver();
77
- return (_jsx(BehaviorModule, { binding: binding, title: "Collision", type: "ApplyCollision", create: () => new ApplyCollision(ensureGroundResolver(), 0.5), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Bounce", children: _jsx(NumberField, { value: behavior.bounce, min: 0, step: 0.05, onChange: (v) => binding.apply(() => (behavior.bounce = v)) }) }), _jsx(Row, { label: "Floor Y", children: _jsx(NumberField, { value: resolver.y, step: 0.1, onChange: (v) => binding.apply(() => (resolver.y = v)) }) }), _jsx("div", { style: { fontSize: 11, opacity: 0.6, marginTop: 4 }, children: "Collides against a ground plane. Bounce is saved with the effect; Floor Y is an editor preview aid (a host can register its own colliders)." })] })) }));
73
+ // Like Unity: Collision stores bounce only. Colliders are host-provided via
74
+ // setPhysicsResolver — the editor never invents a ground plane.
75
+ return (_jsx(BehaviorModule, { binding: binding, title: "Collision", type: "ApplyCollision", create: () => new ApplyCollision(getPhysicsResolver(), 0.5), children: (behavior) => (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Bounce", children: _jsx(NumberField, { value: behavior.bounce, min: 0, step: 0.05, onChange: (v) => binding.apply(() => (behavior.bounce = v)) }) }), _jsx("div", { style: { fontSize: 11, opacity: 0.6, marginTop: 4 }, children: "Bounce is saved with the effect. Colliders come from the host (setPhysicsResolver) \u2014 without one, Collision is inert." })] })) }));
78
76
  }
79
77
  /** Shared speed-range rows for the "by Speed" trio. */
80
78
  function SpeedRangeRows({ binding, behavior }) {
@@ -1,24 +1,38 @@
1
+ export { AssetsPanel } from './AssetsPanel';
2
+ export type { AssetsPanelProps } from './AssetsPanel';
1
3
  export { CollisionModule, ColorBySpeedModule, EmitDirectionModule, ForceOverLifeModule, GravityModule, LimitSpeedOverLifeModule, NoiseModule, OrbitOverLifeModule, RotationBySpeedModule, RotationOverLifeModule, SizeBySpeedModule, SpeedOverLifeModule, TextureSheetModule, TurbulenceModule, WidthOverTrailModule, } from './behaviorModules';
4
+ export { ChoiceDialog } from './ChoiceDialog';
5
+ export type { ChoiceDialogAction, ChoiceDialogProps } from './ChoiceDialog';
6
+ export { ContextMenu } from './ContextMenu';
7
+ export type { ContextMenuItem, ContextMenuProps } from './ContextMenu';
2
8
  export { CurveEditor } from './CurveEditor';
9
+ export { EditorWorkspace, QuarksEditorWorkspace } from './EditorWorkspace';
10
+ export type { EditorWorkspaceProps } from './EditorWorkspace';
3
11
  export { EffectEditor } from './EffectEditor';
4
12
  export type { EffectEditorProps } from './EffectEditor';
5
13
  export { EffectEditorHost, QuarksEffectEditor } from './EffectEditorHost';
6
14
  export type { EffectEditorHostHandle, EffectEditorHostProps } from './EffectEditorHost';
15
+ export { EffectTreeView } from './EffectTreeView';
16
+ export type { EffectTreeViewProps } from './EffectTreeView';
7
17
  export { CheckboxField, NumberField, Row, SelectField } from './fields';
8
- export { GalleryPanel } from './GalleryPanel';
9
- export type { GalleryPanelProps } from './GalleryPanel';
10
18
  export { GradientEditor } from './GradientEditor';
11
19
  export { MessageDialog } from './MessageDialog';
12
20
  export type { MessageDialogProps } from './MessageDialog';
13
21
  export { ColorOverLifeModule, EmissionModule, MainModule, RendererModule, ShapeModule, SizeOverLifeModule, } from './modules';
14
22
  export type { GeometryData, GeometryOption, TextureOption } from './modules';
15
23
  export { ModuleSection } from './ModuleSection';
24
+ export { ProjectsPage } from './ProjectsPage';
25
+ export type { ProjectsPageProps } from './ProjectsPage';
16
26
  export { PromptDialog } from './PromptDialog';
17
27
  export type { PromptDialogProps } from './PromptDialog';
28
+ export { ScenePanel } from './ScenePanel';
29
+ export type { ScenePanelProps } from './ScenePanel';
18
30
  export { SubEmittersModule } from './SubEmittersModule';
19
31
  export { buttonStyle, inputStyle, labelStyle, rowStyle, theme } from './theme';
20
32
  export { TimelinePanel } from './TimelinePanel';
21
33
  export type { PlaybackState, TimelinePanelProps } from './TimelinePanel';
22
34
  export { TimelineTrackRow } from './TimelineTrackRow';
23
35
  export type { TimelineTrackRowProps } from './TimelineTrackRow';
36
+ export { TransportBar } from './TransportBar';
37
+ export type { TransportBarProps } from './TransportBar';
24
38
  export { ValueField } from './ValueField';
@@ -1,16 +1,23 @@
1
+ export { AssetsPanel } from './AssetsPanel';
1
2
  export { CollisionModule, ColorBySpeedModule, EmitDirectionModule, ForceOverLifeModule, GravityModule, LimitSpeedOverLifeModule, NoiseModule, OrbitOverLifeModule, RotationBySpeedModule, RotationOverLifeModule, SizeBySpeedModule, SpeedOverLifeModule, TextureSheetModule, TurbulenceModule, WidthOverTrailModule, } from './behaviorModules';
3
+ export { ChoiceDialog } from './ChoiceDialog';
4
+ export { ContextMenu } from './ContextMenu';
2
5
  export { CurveEditor } from './CurveEditor';
6
+ export { EditorWorkspace, QuarksEditorWorkspace } from './EditorWorkspace';
3
7
  export { EffectEditor } from './EffectEditor';
4
8
  export { EffectEditorHost, QuarksEffectEditor } from './EffectEditorHost';
9
+ export { EffectTreeView } from './EffectTreeView';
5
10
  export { CheckboxField, NumberField, Row, SelectField } from './fields';
6
- export { GalleryPanel } from './GalleryPanel';
7
11
  export { GradientEditor } from './GradientEditor';
8
12
  export { MessageDialog } from './MessageDialog';
9
13
  export { ColorOverLifeModule, EmissionModule, MainModule, RendererModule, ShapeModule, SizeOverLifeModule, } from './modules';
10
14
  export { ModuleSection } from './ModuleSection';
15
+ export { ProjectsPage } from './ProjectsPage';
11
16
  export { PromptDialog } from './PromptDialog';
17
+ export { ScenePanel } from './ScenePanel';
12
18
  export { SubEmittersModule } from './SubEmittersModule';
13
19
  export { buttonStyle, inputStyle, labelStyle, rowStyle, theme } from './theme';
14
20
  export { TimelinePanel } from './TimelinePanel';
15
21
  export { TimelineTrackRow } from './TimelineTrackRow';
22
+ export { TransportBar } from './TransportBar';
16
23
  export { ValueField } from './ValueField';
@@ -117,8 +117,7 @@ const INSPECTOR_HINTS = {
117
117
  'turbulence.strength': 'Per-axis multiplier for turbulent velocity.',
118
118
  'Time scale': 'How fast the turbulence field evolves over time (per axis).',
119
119
  'Angle (rad)': 'Maximum random deflection from the base emit direction, in radians.',
120
- Bounce: 'Energy retained after hitting the ground (0 = no bounce, 1 = full bounce).',
121
- 'Floor Y': 'World-space Y height of the preview ground plane used for collision.',
120
+ Bounce: 'Energy retained after a collision (0 = stick, 1 = full bounce). Colliders are host-provided.',
122
121
  'Speed range': 'Particle speed range (min, max) that maps to the color/size/rotation curve.',
123
122
  'Orbit speed': 'Angular speed of orbit around the configured axis over lifetime.',
124
123
  'rotationOverLife.velocity': 'Angular velocity applied over normalized lifetime (rad/s).',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks-editor",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Shuriken-style effect editor for babylon.quarks: headless edit-model core + React module-stack UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,12 @@
1
+ import type {AssetDragPayload} from './assetTree';
2
+
3
+ let activeDrag: AssetDragPayload | null = null;
4
+
5
+ /** Stores the current asset drag payload for viewport preview (getData is drop-only in most browsers). */
6
+ export function setAssetDragSession(payload: AssetDragPayload | null): void {
7
+ activeDrag = payload;
8
+ }
9
+
10
+ export function getAssetDragSession(): AssetDragPayload | null {
11
+ return activeDrag;
12
+ }
@@ -0,0 +1,148 @@
1
+ import type {EffectAsset} from './assets';
2
+
3
+ export interface AssetTreeFolder {
4
+ kind: 'folder';
5
+ name: string;
6
+ path: string;
7
+ children: AssetTreeNode[];
8
+ }
9
+
10
+ export interface AssetTreeEffect {
11
+ kind: 'asset';
12
+ name: string;
13
+ path: string;
14
+ asset: EffectAsset;
15
+ }
16
+
17
+ export type AssetTreeNode = AssetTreeFolder | AssetTreeEffect;
18
+
19
+ function compareNodes(a: AssetTreeNode, b: AssetTreeNode): number {
20
+ if (a.kind !== b.kind) {
21
+ return a.kind === 'folder' ? -1 : 1;
22
+ }
23
+ return a.name.localeCompare(b.name, undefined, {sensitivity: 'base'});
24
+ }
25
+
26
+ /** Builds a folder tree from asset relative paths (e.g. `VFX/Fire/Explosion.json`). */
27
+ export function buildAssetTree(assets: EffectAsset[]): AssetTreeNode[] {
28
+ const roots: AssetTreeNode[] = [];
29
+
30
+ for (const asset of assets) {
31
+ const segments = asset.path
32
+ .replace(/\.json$/i, '')
33
+ .split('/')
34
+ .filter(Boolean);
35
+ const fileName = segments.pop() ?? asset.name;
36
+ let level = roots;
37
+ let pathSoFar = '';
38
+
39
+ for (const segment of segments) {
40
+ pathSoFar = pathSoFar ? `${pathSoFar}/${segment}` : segment;
41
+ let folder = level.find((node): node is AssetTreeFolder => node.kind === 'folder' && node.name === segment);
42
+ if (!folder) {
43
+ folder = {kind: 'folder', name: segment, path: pathSoFar, children: []};
44
+ level.push(folder);
45
+ }
46
+ level = folder.children;
47
+ }
48
+
49
+ level.push({kind: 'asset', name: fileName, path: asset.path, asset});
50
+ }
51
+
52
+ const sortTree = (nodes: AssetTreeNode[]): void => {
53
+ nodes.sort(compareNodes);
54
+ for (const node of nodes) {
55
+ if (node.kind === 'folder') {
56
+ sortTree(node.children);
57
+ }
58
+ }
59
+ };
60
+
61
+ sortTree(roots);
62
+ return roots;
63
+ }
64
+
65
+ /** Children of the folder at `path` (`''` is the top level), or null when it no longer exists. */
66
+ export function folderChildren(tree: AssetTreeNode[], path: string): AssetTreeNode[] | null {
67
+ let level = tree;
68
+ for (const segment of path.split('/').filter(Boolean)) {
69
+ const folder = level.find((node): node is AssetTreeFolder => node.kind === 'folder' && node.name === segment);
70
+ if (!folder) {
71
+ return null;
72
+ }
73
+ level = folder.children;
74
+ }
75
+ return level;
76
+ }
77
+
78
+ /** Assets whose name or path contains `query`, across every folder, sorted by name. */
79
+ export function searchAssets(assets: EffectAsset[], query: string): EffectAsset[] {
80
+ const q = query.trim().toLowerCase();
81
+ return assets
82
+ .filter((asset) => `${asset.path} ${asset.name}`.toLowerCase().includes(q))
83
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
84
+ }
85
+
86
+ export const ASSET_DRAG_MIME = 'application/x-quarks-asset-drop';
87
+
88
+ export type AssetDragPayload = {kind: 'asset'; id: number} | {kind: 'folder'; path: string};
89
+
90
+ export function writeAssetDrag(dataTransfer: DataTransfer, payload: AssetDragPayload): void {
91
+ dataTransfer.setData(ASSET_DRAG_MIME, JSON.stringify(payload));
92
+ dataTransfer.effectAllowed = 'copy';
93
+ }
94
+
95
+ export function readAssetDrag(dataTransfer: DataTransfer): AssetDragPayload | null {
96
+ const raw = dataTransfer.getData(ASSET_DRAG_MIME);
97
+ if (!raw) {
98
+ return null;
99
+ }
100
+ try {
101
+ const parsed = JSON.parse(raw) as AssetDragPayload;
102
+ if (parsed?.kind === 'asset' && typeof parsed.id === 'number') {
103
+ return parsed;
104
+ }
105
+ if (parsed?.kind === 'folder' && typeof parsed.path === 'string') {
106
+ return parsed;
107
+ }
108
+ } catch {
109
+ return null;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /** Returns every asset whose JSON path sits inside `folderPath` (recursive). */
115
+ export function collectAssetsInFolder(assets: EffectAsset[], folderPath: string): EffectAsset[] {
116
+ const prefix = `${folderPath}/`;
117
+ return assets.filter((asset) => asset.path.startsWith(prefix));
118
+ }
119
+
120
+ /** Collects all assets under a folder tree node. */
121
+ export function collectFolderAssets(node: AssetTreeFolder): EffectAsset[] {
122
+ const collected: EffectAsset[] = [];
123
+ const walk = (nodes: AssetTreeNode[]): void => {
124
+ for (const child of nodes) {
125
+ if (child.kind === 'asset') {
126
+ collected.push(child.asset);
127
+ } else {
128
+ walk(child.children);
129
+ }
130
+ }
131
+ };
132
+ walk(node.children);
133
+ return collected;
134
+ }
135
+
136
+ /** Resolves a drag payload to the assets that would be instantiated on drop. */
137
+ export function describeAssetDrag(
138
+ assets: EffectAsset[],
139
+ payload: AssetDragPayload
140
+ ): {assets: EffectAsset[]; label: string} {
141
+ if (payload.kind === 'asset') {
142
+ const asset = assets.find((item) => item.id === payload.id);
143
+ return {assets: asset ? [asset] : [], label: asset?.name ?? 'Effect'};
144
+ }
145
+
146
+ const label = payload.path.split('/').filter(Boolean).pop() ?? payload.path;
147
+ return {assets: collectAssetsInFolder(assets, payload.path), label};
148
+ }
@@ -0,0 +1,262 @@
1
+ import {QuarksLoader} from 'babylon.quarks';
2
+
3
+ /**
4
+ * One effect file in the Assets panel. Holds the source JSON only — nothing is live in the
5
+ * Babylon scene until the asset is dropped into the viewport, and every drop instantiates a
6
+ * fresh, independent copy (see `instantiateSceneEntry`).
7
+ */
8
+ export interface EffectAsset {
9
+ id: number;
10
+ name: string;
11
+ /** Relative path inside the imported folder (preserves subfolders). */
12
+ path: string;
13
+ /** Quarks JSON with every external file reference already inlined. */
14
+ json: unknown;
15
+ /** Texture/geometry files the effect references that were not among the imported files. */
16
+ missingFiles: string[];
17
+ }
18
+
19
+ export interface AssetLoadProgress {
20
+ done: number;
21
+ total: number;
22
+ loaded: number;
23
+ failed: number;
24
+ }
25
+
26
+ const YIELD_EVERY = 8;
27
+ const IMAGE_FILE = /\.(png|jpe?g|webp|gif|bmp)$/i;
28
+
29
+ let nextAssetId = 1;
30
+
31
+ function yieldToBrowser(): Promise<void> {
32
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
33
+ }
34
+
35
+ export function relativePathOf(file: File): string {
36
+ return ((file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name).replace(/\\/g, '/');
37
+ }
38
+
39
+ /** Files an asset import reads: effect `.json` files plus the images/geometries they may reference. */
40
+ export function isAssetImportFile(name: string): boolean {
41
+ return /\.json$/i.test(name) || IMAGE_FILE.test(name);
42
+ }
43
+
44
+ /** Collects `.json` files from a picker result, sorted by relative path. */
45
+ export function collectJsonFiles(files: FileList | File[]): File[] {
46
+ return Array.from(files)
47
+ .filter((file) => file.name.toLowerCase().endsWith('.json'))
48
+ .sort((a, b) => relativePathOf(a).localeCompare(relativePathOf(b)));
49
+ }
50
+
51
+ /** Resolves `url` against the folder of `fromPath` (`a/b/fx.json` + `../t.png` → `a/t.png`). */
52
+ export function resolveRelativePath(fromPath: string, url: string): string {
53
+ const segments = fromPath.split('/').slice(0, -1);
54
+ for (const part of url.split('/')) {
55
+ if (part === '..') {
56
+ segments.pop();
57
+ } else if (part !== '.' && part !== '') {
58
+ segments.push(part);
59
+ }
60
+ }
61
+ return segments.join('/');
62
+ }
63
+
64
+ function isExternalUrl(url: string): boolean {
65
+ return /^(data|blob):/i.test(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('/');
66
+ }
67
+
68
+ function readAsDataUrl(file: File): Promise<string> {
69
+ return new Promise((resolve, reject) => {
70
+ const reader = new FileReader();
71
+ reader.onload = () => resolve(reader.result as string);
72
+ reader.onerror = () => reject(reader.error);
73
+ reader.readAsDataURL(file);
74
+ });
75
+ }
76
+
77
+ /** True for a Quarks effect envelope with at least one particle emitter. */
78
+ export function isEffectJson(json: unknown): boolean {
79
+ const object = (json as {object?: unknown} | null)?.object;
80
+ const walk = (node: unknown): boolean => {
81
+ if (!node || typeof node !== 'object') return false;
82
+ const typed = node as {type?: unknown; children?: unknown};
83
+ if (typed.type === 'ParticleEmitter') return true;
84
+ return Array.isArray(typed.children) && typed.children.some(walk);
85
+ };
86
+ return walk(object);
87
+ }
88
+
89
+ /**
90
+ * Looks files up by path relative to the import root, falling back to the bare file name — a
91
+ * multi-file "Import effect" pick has no folder structure, only names.
92
+ */
93
+ class ImportedFileIndex {
94
+ private readonly byPath = new Map<string, File>();
95
+ private readonly byName = new Map<string, File>();
96
+ private readonly cache = new Map<File, Promise<unknown>>();
97
+
98
+ constructor(files: File[]) {
99
+ for (const file of files) {
100
+ this.byPath.set(relativePathOf(file).toLowerCase(), file);
101
+ if (!this.byName.has(file.name.toLowerCase())) {
102
+ this.byName.set(file.name.toLowerCase(), file);
103
+ }
104
+ }
105
+ }
106
+
107
+ find(fromPath: string, url: string): File | undefined {
108
+ const clean = decodeURIComponent(url.split(/[?#]/)[0]);
109
+ return (
110
+ this.byPath.get(resolveRelativePath(fromPath, clean).toLowerCase()) ??
111
+ this.byName.get((clean.split('/').pop() ?? clean).toLowerCase())
112
+ );
113
+ }
114
+
115
+ /** Reads each file once, however many effects reference it. */
116
+ read<T>(file: File, read: (file: File) => Promise<T>): Promise<T> {
117
+ let pending = this.cache.get(file);
118
+ if (!pending) {
119
+ pending = read(file);
120
+ this.cache.set(file, pending);
121
+ }
122
+ return pending as Promise<T>;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Inlines what an "assets as files" export put in separate files: image urls become data URIs
128
+ * and external geometries are read into the JSON, so the effect loads (and later exports) as a
129
+ * self-contained effect. Returns the references that could not be found among the files.
130
+ */
131
+ async function inlineExternalFiles(json: any, effectPath: string, index: ImportedFileIndex): Promise<string[]> {
132
+ const missing: string[] = [];
133
+
134
+ for (const image of Array.isArray(json.images) ? json.images : []) {
135
+ const url = image?.url;
136
+ if (typeof url !== 'string' || isExternalUrl(url)) continue;
137
+ const file = index.find(effectPath, url);
138
+ if (file) {
139
+ image.url = await index.read(file, readAsDataUrl);
140
+ } else {
141
+ missing.push(url);
142
+ }
143
+ }
144
+
145
+ await QuarksLoader.resolveExternalGeometries(json, async (url) => {
146
+ const file = index.find(effectPath, url);
147
+ if (!file) {
148
+ missing.push(url);
149
+ throw new Error('not among the imported files');
150
+ }
151
+ return index.read(file, async (f) => JSON.parse(await f.text()));
152
+ });
153
+
154
+ return missing;
155
+ }
156
+
157
+ /**
158
+ * Reads every effect JSON among `files` into an asset. Files that are not effects (geometry
159
+ * files, unrelated JSON, images) are skipped silently; unreadable JSON is skipped with a warning.
160
+ */
161
+ export async function loadEffectAssets(
162
+ files: FileList | File[],
163
+ onProgress: (progress: AssetLoadProgress) => void
164
+ ): Promise<EffectAsset[]> {
165
+ const all = Array.from(files).filter((file) => isAssetImportFile(file.name));
166
+ const jsonFiles = collectJsonFiles(all);
167
+ const index = new ImportedFileIndex(all);
168
+ const assets: EffectAsset[] = [];
169
+ let failed = 0;
170
+
171
+ onProgress({done: 0, total: jsonFiles.length, loaded: 0, failed: 0});
172
+
173
+ for (let i = 0; i < jsonFiles.length; i++) {
174
+ const file = jsonFiles[i];
175
+ const path = relativePathOf(file);
176
+ try {
177
+ const json = JSON.parse(await file.text());
178
+ if (isEffectJson(json)) {
179
+ const missingFiles = await inlineExternalFiles(json, path, index);
180
+ if (missingFiles.length > 0) {
181
+ console.warn(`[Quarks editor] "${path}" references files that were not imported:`, missingFiles);
182
+ }
183
+ assets.push({id: nextAssetId++, name: file.name.replace(/\.json$/i, ''), path, json, missingFiles});
184
+ }
185
+ } catch (err) {
186
+ failed++;
187
+ console.warn(`[Quarks editor] Skipped "${path}":`, err);
188
+ }
189
+
190
+ onProgress({done: i + 1, total: jsonFiles.length, loaded: assets.length, failed});
191
+
192
+ if ((i + 1) % YIELD_EVERY === 0) {
193
+ await yieldToBrowser();
194
+ }
195
+ }
196
+
197
+ return assets;
198
+ }
199
+
200
+ /** Wraps an in-memory effect JSON (e.g. the host's `effectJson`) as an asset. */
201
+ export function createEffectAsset(name: string, path: string, json: unknown): EffectAsset {
202
+ return {id: nextAssetId++, name, path, json, missingFiles: []};
203
+ }
204
+
205
+ /** Merges newly imported assets, skipping paths already in the list. */
206
+ export function mergeAssets(
207
+ existing: EffectAsset[],
208
+ incoming: EffectAsset[]
209
+ ): {merged: EffectAsset[]; added: EffectAsset[]; skipped: number} {
210
+ const paths = new Set(existing.map((asset) => asset.path));
211
+ const added: EffectAsset[] = [];
212
+ for (const asset of incoming) {
213
+ if (paths.has(asset.path)) continue;
214
+ paths.add(asset.path);
215
+ added.push(asset);
216
+ }
217
+ return {merged: [...existing, ...added], added, skipped: incoming.length - added.length};
218
+ }
219
+
220
+ /** Removes one asset, or every asset under a folder path. */
221
+ export function removeAssets(
222
+ assets: EffectAsset[],
223
+ target: {kind: 'asset'; id: number} | {kind: 'folder'; path: string}
224
+ ): EffectAsset[] {
225
+ if (target.kind === 'asset') {
226
+ return assets.filter((asset) => asset.id !== target.id);
227
+ }
228
+ const prefix = `${target.path}/`;
229
+ return assets.filter((asset) => !asset.path.startsWith(prefix));
230
+ }
231
+
232
+ const iconCache = new WeakMap<EffectAsset, string | null>();
233
+
234
+ /**
235
+ * Url of the effect's main texture — the first emitter's material map — used as its icon in
236
+ * the Assets panel. Falls back to the first image; null when the effect has no texture.
237
+ */
238
+ export function assetIconUrl(asset: EffectAsset): string | null {
239
+ const cached = iconCache.get(asset);
240
+ if (cached !== undefined) {
241
+ return cached;
242
+ }
243
+ const json = asset.json as any;
244
+ const findMaterial = (node: any): string | undefined => {
245
+ if (!node || typeof node !== 'object') return undefined;
246
+ if (node.type === 'ParticleEmitter' && node.ps?.material) return node.ps.material;
247
+ for (const child of Array.isArray(node.children) ? node.children : []) {
248
+ const found = findMaterial(child);
249
+ if (found) return found;
250
+ }
251
+ return undefined;
252
+ };
253
+ const byUuid = (list: unknown, uuid: unknown) =>
254
+ (Array.isArray(list) ? list : []).find((item: any) => item?.uuid === uuid) as any;
255
+
256
+ const material = byUuid(json?.materials, findMaterial(json?.object));
257
+ const texture = byUuid(json?.textures, material?.map ?? material?.texture);
258
+ const image = byUuid(json?.images, texture?.image) ?? (Array.isArray(json?.images) ? json.images[0] : undefined);
259
+ const url = typeof image?.url === 'string' && image.url ? image.url : null;
260
+ iconCache.set(asset, url);
261
+ return url;
262
+ }
@@ -77,6 +77,14 @@ export class EffectBinding {
77
77
  this.notify();
78
78
  }
79
79
 
80
+ /**
81
+ * Notifies subscribers of a change made outside `apply()` — e.g. a transform edited by the
82
+ * gizmo, or a sub-system edited through its own nested binding.
83
+ */
84
+ markChanged(): void {
85
+ this.notify();
86
+ }
87
+
80
88
  private notify(): void {
81
89
  this.revision++;
82
90
  for (const listener of this.listeners) {
@@ -1,9 +1,8 @@
1
1
  import {Vector3, getPhysicsResolver, setPhysicsResolver} from 'babylon.quarks';
2
2
 
3
3
  /**
4
- * A horizontal ground plane collider. quarks.core's ApplyCollision behavior delegates to a
5
- * host-provided `PhysicsResolver` (a global singleton) and ships no collider primitive of its
6
- * own, so the editor supplies this — the common "particles bounce off the floor" case.
4
+ * A horizontal ground plane collider for hosts/demos that opt in via ensureGroundResolver /
5
+ * setPhysicsResolver. The editor never registers this automatically.
7
6
  *
8
7
  * `resolve` returns true and fills the surface normal when the point is at or below the plane,
9
8
  * which is exactly what ApplyCollision.update reflects the velocity against.
@@ -21,8 +20,8 @@ export class GroundPlaneResolver {
21
20
  }
22
21
 
23
22
  /**
24
- * Registers (once) a shared ground-plane resolver so ApplyCollision behaviors — whether added
25
- * in the inspector or deserialized from imported JSON — have something to collide against.
23
+ * Opt-in helper for hosts/demos that want a simple floor collider.
24
+ * The editor does not call this — like Unity, collision needs an explicit host-provided resolver.
26
25
  * Idempotent: an already-registered resolver is left untouched and returned.
27
26
  */
28
27
  export function ensureGroundResolver(): GroundPlaneResolver {
@@ -11,9 +11,9 @@ import {
11
11
  Vector4,
12
12
  } from 'babylon.quarks';
13
13
  import {DEFAULT_GRADIENT_STOPS, buildGradient} from './colors';
14
- import type {GalleryEntry} from './loadEffectGallery';
14
+ import {uniqueEntryName, type SceneEntry} from './sceneEntries';
15
15
 
16
- export interface CreateGalleryDefaultOptions {
16
+ export interface CreateSceneDefaultOptions {
17
17
  resolveTexture?: (url: string, scene: Scene) => unknown;
18
18
  textureUrl?: string;
19
19
  }
@@ -44,31 +44,18 @@ export function createDefaultEffect(
44
44
  return system;
45
45
  }
46
46
 
47
- /** Adds a blank preset effect to the hidden gallery catalog (not in the renderer yet). */
48
- export function createGalleryDefaultEntry(
47
+ /** Creates a preset effect as a new scene entry (systems not registered with the renderer yet). */
48
+ export function createSceneDefaultEntry(
49
49
  scene: Scene,
50
- catalogRoot: TransformNode,
51
- existing: GalleryEntry[],
52
- options: CreateGalleryDefaultOptions = {}
53
- ): GalleryEntry {
54
- const takenNames = new Set(existing.map((entry) => entry.name));
55
- const takenPaths = new Set(existing.map((entry) => entry.path));
56
-
57
- let name = existing.length === 0 ? 'Effect' : 'New Effect';
58
- let suffix = 2;
59
- while (takenNames.has(name) || takenPaths.has(`_created/${name}.json`)) {
60
- name = `New Effect ${suffix}`;
61
- suffix++;
62
- }
63
-
64
- const path = `_created/${name}.json`;
50
+ existing: SceneEntry[],
51
+ options: CreateSceneDefaultOptions = {}
52
+ ): SceneEntry {
53
+ const name = uniqueEntryName(existing.length === 0 ? 'Effect' : 'New Effect', existing);
65
54
  const system = createDefaultEffect(scene, options.resolveTexture, options.textureUrl);
66
55
  system.pause();
67
56
 
68
57
  const root = new TransformNode(name, scene);
69
58
  system.emitter.parent = root;
70
- root.parent = catalogRoot;
71
- root.setEnabled(false);
72
59
 
73
- return {name, path, root, systems: [system], inScene: false};
60
+ return {name, path: `_created/${name}.json`, assetId: null, root, systems: [system]};
74
61
  }
@@ -7,6 +7,7 @@ import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
7
7
  import type {Scene} from '@babylonjs/core/scene';
8
8
  import type {ParticleSystem} from 'babylon.quarks';
9
9
  import {EDITOR_SCENE_NODE_METADATA} from './editorScene';
10
+ import './lineShaders';
10
11
  import {getShapeType, readShapeParams} from './shapes';
11
12
 
12
13
  const LINE_COLOR = new Color3(0.45, 0.82, 1);