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.
Files changed (211) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/README.md +69 -9
  3. package/dist/core/assetDragSession.d.ts +1 -1
  4. package/dist/core/assetTree.d.ts +6 -1
  5. package/dist/core/assets.d.ts +7 -0
  6. package/dist/core/assets.js +11 -7
  7. package/dist/core/binding.d.ts +11 -2
  8. package/dist/core/binding.js +9 -1
  9. package/dist/core/collision.d.ts +4 -0
  10. package/dist/core/collision.js +4 -0
  11. package/dist/core/colors.d.ts +16 -0
  12. package/dist/core/colors.js +8 -0
  13. package/dist/core/defaultEffect.d.ts +5 -4
  14. package/dist/core/defaultEffect.js +4 -4
  15. package/dist/core/editorScene.d.ts +1 -1
  16. package/dist/core/editorSession.d.ts +229 -0
  17. package/dist/core/editorSession.js +983 -0
  18. package/dist/core/effectTree.d.ts +6 -1
  19. package/dist/core/effectTree.js +13 -88
  20. package/dist/core/emitterShapeWireframe.d.ts +2 -2
  21. package/dist/core/emitterShapeWireframe.js +45 -13
  22. package/dist/core/galleryDropMarker.d.ts +2 -2
  23. package/dist/core/galleryDropMarker.js +6 -6
  24. package/dist/core/galleryLayout.d.ts +3 -3
  25. package/dist/core/galleryLayout.js +1 -1
  26. package/dist/core/galleryPlayback.d.ts +1 -1
  27. package/dist/core/galleryPlayback.js +2 -1
  28. package/dist/core/galleryScene.d.ts +4 -4
  29. package/dist/core/galleryScene.js +3 -3
  30. package/dist/core/gallerySelectionMarker.d.ts +4 -4
  31. package/dist/core/gallerySelectionMarker.js +5 -5
  32. package/dist/core/groups.d.ts +1 -1
  33. package/dist/core/groups.js +1 -1
  34. package/dist/core/history.d.ts +7 -1
  35. package/dist/core/history.js +4 -0
  36. package/dist/core/indexedDbProjectStore.d.ts +14 -1
  37. package/dist/core/indexedDbProjectStore.js +13 -0
  38. package/dist/core/lineShaders.d.ts +2 -2
  39. package/dist/core/lineShaders.js +2 -2
  40. package/dist/core/loadEffect.d.ts +2 -2
  41. package/dist/core/loadEffect.js +1 -1
  42. package/dist/core/material.d.ts +12 -2
  43. package/dist/core/pickAssetFiles.js +1 -1
  44. package/dist/core/playback.d.ts +6 -0
  45. package/dist/core/projectStore.d.ts +26 -0
  46. package/dist/core/projectStore.js +1 -0
  47. package/dist/core/sceneEntries.d.ts +7 -4
  48. package/dist/core/sceneEntries.js +2 -2
  49. package/dist/core/scrub.d.ts +2 -2
  50. package/dist/core/scrub.js +2 -2
  51. package/dist/core/shapes.d.ts +25 -1
  52. package/dist/core/shapes.js +5 -0
  53. package/dist/core/systems.d.ts +4 -2
  54. package/dist/core/systems.js +3 -1
  55. package/dist/core/textureExport.d.ts +1 -1
  56. package/dist/core/timeline.d.ts +25 -5
  57. package/dist/core/transport.d.ts +3 -3
  58. package/dist/core/transport.js +2 -2
  59. package/dist/core/values.d.ts +21 -4
  60. package/dist/core/values.js +5 -0
  61. package/dist/core/viewportPicker.d.ts +4 -4
  62. package/dist/core/viewportPicker.js +8 -7
  63. package/dist/index.d.ts +25 -21
  64. package/dist/index.js +12 -12
  65. package/dist/react/AssetsPanel.d.ts +9 -2
  66. package/dist/react/AssetsPanel.js +6 -6
  67. package/dist/react/ChoiceDialog.d.ts +7 -0
  68. package/dist/react/ChoiceDialog.js +1 -1
  69. package/dist/react/ContextMenu.d.ts +10 -1
  70. package/dist/react/ContextMenu.js +1 -1
  71. package/dist/react/CurveEditor.d.ts +1 -1
  72. package/dist/react/CurveEditor.js +2 -2
  73. package/dist/react/EditorWorkspace.d.ts +9 -2
  74. package/dist/react/EditorWorkspace.js +10 -7
  75. package/dist/react/EffectEditor.d.ts +11 -5
  76. package/dist/react/EffectEditor.js +7 -7
  77. package/dist/react/EffectEditorHost.d.ts +33 -29
  78. package/dist/react/EffectEditorHost.js +247 -1674
  79. package/dist/react/EffectTreeView.d.ts +4 -1
  80. package/dist/react/EffectTreeView.js +3 -3
  81. package/dist/react/GradientEditor.d.ts +1 -1
  82. package/dist/react/GradientEditor.js +3 -3
  83. package/dist/react/InspectorInfoIcon.js +3 -3
  84. package/dist/react/InspectorLabel.js +3 -3
  85. package/dist/react/MessageDialog.d.ts +5 -0
  86. package/dist/react/MessageDialog.js +1 -1
  87. package/dist/react/ModuleSection.d.ts +10 -0
  88. package/dist/react/ModuleSection.js +8 -5
  89. package/dist/react/ObjectModule.d.ts +1 -1
  90. package/dist/react/ObjectModule.js +4 -4
  91. package/dist/react/ProjectsPage.d.ts +7 -1
  92. package/dist/react/ProjectsPage.js +33 -17
  93. package/dist/react/PromptDialog.d.ts +7 -0
  94. package/dist/react/PromptDialog.js +1 -1
  95. package/dist/react/ScenePanel.d.ts +9 -2
  96. package/dist/react/ScenePanel.js +12 -10
  97. package/dist/react/SubEmittersModule.d.ts +1 -1
  98. package/dist/react/SubEmittersModule.js +5 -5
  99. package/dist/react/TextureSelect.d.ts +24 -0
  100. package/dist/react/TextureSelect.js +74 -0
  101. package/dist/react/TextureUVField.d.ts +14 -0
  102. package/dist/react/TextureUVField.js +23 -0
  103. package/dist/react/TimelinePanel.d.ts +20 -7
  104. package/dist/react/TimelinePanel.js +21 -15
  105. package/dist/react/TimelineTrackRow.d.ts +13 -3
  106. package/dist/react/TimelineTrackRow.js +6 -6
  107. package/dist/react/TransportBar.d.ts +13 -4
  108. package/dist/react/TransportBar.js +3 -3
  109. package/dist/react/ValueField.d.ts +32 -5
  110. package/dist/react/ValueField.js +22 -11
  111. package/dist/react/behaviorModules.d.ts +19 -6
  112. package/dist/react/behaviorModules.js +68 -17
  113. package/dist/react/fields.d.ts +9 -0
  114. package/dist/react/fields.js +6 -3
  115. package/dist/react/host/BottomDocks.d.ts +36 -0
  116. package/dist/react/host/BottomDocks.js +63 -0
  117. package/dist/react/host/HostDialogs.d.ts +50 -0
  118. package/dist/react/host/HostDialogs.js +113 -0
  119. package/dist/react/host/InspectorSidebar.d.ts +32 -0
  120. package/dist/react/host/InspectorSidebar.js +59 -0
  121. package/dist/react/host/SceneSidebar.d.ts +53 -0
  122. package/dist/react/host/SceneSidebar.js +62 -0
  123. package/dist/react/host/ViewportPane.d.ts +56 -0
  124. package/dist/react/host/ViewportPane.js +91 -0
  125. package/dist/react/host/hostHooks.d.ts +44 -0
  126. package/dist/react/host/hostHooks.js +164 -0
  127. package/dist/react/host/hostStyles.d.ts +21 -0
  128. package/dist/react/host/hostStyles.js +57 -0
  129. package/dist/react/index.d.ts +42 -38
  130. package/dist/react/index.js +25 -23
  131. package/dist/react/inspectorHints.js +31 -0
  132. package/dist/react/modules.d.ts +19 -6
  133. package/dist/react/modules.js +134 -117
  134. package/dist/react/theme.d.ts +15 -0
  135. package/dist/react/theme.js +15 -0
  136. package/dist/session.d.ts +9 -0
  137. package/dist/session.js +8 -0
  138. package/package.json +18 -11
  139. package/src/core/assetDragSession.ts +1 -1
  140. package/src/core/assetTree.ts +15 -2
  141. package/src/core/assets.ts +25 -10
  142. package/src/core/binding.ts +12 -3
  143. package/src/core/collision.ts +4 -0
  144. package/src/core/colors.ts +21 -1
  145. package/src/core/defaultEffect.ts +8 -7
  146. package/src/core/editorScene.ts +1 -1
  147. package/src/core/editorSession.ts +1299 -0
  148. package/src/core/effectTree.ts +18 -101
  149. package/src/core/emitterShapeWireframe.ts +48 -16
  150. package/src/core/galleryDropMarker.ts +8 -8
  151. package/src/core/galleryLayout.ts +4 -4
  152. package/src/core/galleryPlayback.ts +3 -2
  153. package/src/core/galleryScene.ts +5 -5
  154. package/src/core/gallerySelectionMarker.ts +7 -7
  155. package/src/core/groups.ts +1 -1
  156. package/src/core/history.ts +7 -1
  157. package/src/core/indexedDbProjectStore.ts +14 -1
  158. package/src/core/lineShaders.ts +2 -2
  159. package/src/core/loadEffect.ts +4 -4
  160. package/src/core/material.ts +14 -4
  161. package/src/core/pickAssetFiles.ts +2 -2
  162. package/src/core/playback.ts +6 -0
  163. package/src/core/projectStore.ts +26 -0
  164. package/src/core/sceneEntries.ts +8 -5
  165. package/src/core/scrub.ts +3 -3
  166. package/src/core/shapes.ts +29 -2
  167. package/src/core/systems.ts +11 -4
  168. package/src/core/textureExport.ts +1 -1
  169. package/src/core/timeline.ts +43 -18
  170. package/src/core/transport.ts +4 -4
  171. package/src/core/values.ts +20 -4
  172. package/src/core/viewportPicker.ts +12 -11
  173. package/src/index.ts +25 -21
  174. package/src/react/AssetsPanel.tsx +13 -6
  175. package/src/react/ChoiceDialog.tsx +8 -1
  176. package/src/react/ContextMenu.tsx +11 -2
  177. package/src/react/CurveEditor.tsx +2 -2
  178. package/src/react/EditorWorkspace.tsx +16 -10
  179. package/src/react/EffectEditor.tsx +17 -9
  180. package/src/react/EffectEditorHost.tsx +415 -2354
  181. package/src/react/EffectTreeView.tsx +7 -4
  182. package/src/react/GradientEditor.tsx +3 -3
  183. package/src/react/InspectorInfoIcon.tsx +3 -3
  184. package/src/react/InspectorLabel.tsx +3 -3
  185. package/src/react/MessageDialog.tsx +6 -1
  186. package/src/react/ModuleSection.tsx +15 -4
  187. package/src/react/ObjectModule.tsx +5 -5
  188. package/src/react/ProjectsPage.tsx +44 -17
  189. package/src/react/PromptDialog.tsx +8 -1
  190. package/src/react/ScenePanel.tsx +21 -12
  191. package/src/react/SubEmittersModule.tsx +6 -6
  192. package/src/react/TextureSelect.tsx +118 -0
  193. package/src/react/TextureUVField.tsx +62 -0
  194. package/src/react/TimelinePanel.tsx +40 -21
  195. package/src/react/TimelineTrackRow.tsx +19 -9
  196. package/src/react/TransportBar.tsx +16 -7
  197. package/src/react/ValueField.tsx +60 -16
  198. package/src/react/behaviorModules.tsx +240 -55
  199. package/src/react/fields.tsx +22 -4
  200. package/src/react/host/BottomDocks.tsx +178 -0
  201. package/src/react/host/HostDialogs.tsx +228 -0
  202. package/src/react/host/InspectorSidebar.tsx +147 -0
  203. package/src/react/host/SceneSidebar.tsx +227 -0
  204. package/src/react/host/ViewportPane.tsx +300 -0
  205. package/src/react/host/hostHooks.ts +198 -0
  206. package/src/react/host/hostStyles.ts +65 -0
  207. package/src/react/index.ts +43 -38
  208. package/src/react/inspectorHints.ts +40 -0
  209. package/src/react/modules.tsx +440 -170
  210. package/src/react/theme.ts +15 -0
  211. package/src/session.ts +17 -0
@@ -1,14 +1,17 @@
1
- import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
1
+ import type {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
2
2
  import {ChevronDownIcon, ChevronRightIcon, FolderIcon, SparklesIcon} from '@heroicons/react/24/solid';
3
3
  import React, {useState} from 'react';
4
- import {buildEffectTree, type EffectTreeNode} from '../core/effectTree';
5
- import {iconStyle} from './icons';
6
- import {theme} from './theme';
4
+ import {buildEffectTree, type EffectTreeNode} from '../core/effectTree.js';
5
+ import {iconStyle} from './icons.js';
6
+ import {theme} from './theme.js';
7
7
 
8
+ /** Props of {@link EffectTreeView}. */
8
9
  export interface EffectTreeViewProps {
10
+ /** The effect's root. */
9
11
  root: TransformNode;
10
12
  /** Node shown in the inspector (and moved by the gizmo). */
11
13
  selectedNode: TransformNode | null;
14
+ /** Selects a group or emitter. */
12
15
  onSelect: (node: TransformNode) => void;
13
16
  /** Show the root itself as the first row (default: only its descendants). */
14
17
  includeRoot?: boolean;
@@ -1,7 +1,7 @@
1
1
  import {useRef, useState} from 'react';
2
- import {GradientStop, hexToRgb, rgbToHex, stopsToCssGradient} from '../core/colors';
3
- import {NumberField, Row} from './fields';
4
- import {buttonStyle, theme} from './theme';
2
+ import {GradientStop, hexToRgb, rgbToHex, stopsToCssGradient} from '../core/colors.js';
3
+ import {NumberField, Row} from './fields.js';
4
+ import {buttonStyle, theme} from './theme.js';
5
5
 
6
6
  const BAR_WIDTH = 218;
7
7
 
@@ -1,8 +1,8 @@
1
1
  import {InformationCircleIcon} from '@heroicons/react/24/solid';
2
2
  import React from 'react';
3
- import {iconStyle} from './icons';
4
- import {theme} from './theme';
5
- import {Tooltip} from './Tooltip';
3
+ import {iconStyle} from './icons.js';
4
+ import {theme} from './theme.js';
5
+ import {Tooltip} from './Tooltip.js';
6
6
 
7
7
  /** Small info icon with a themed inspector tooltip. */
8
8
  export function InspectorInfoIcon(props: {
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
- import {getInspectorHint} from './inspectorHints';
3
- import {InspectorInfoIcon} from './InspectorInfoIcon';
4
- import {labelStyle} from './theme';
2
+ import {getInspectorHint} from './inspectorHints.js';
3
+ import {InspectorInfoIcon} from './InspectorInfoIcon.js';
4
+ import {labelStyle} from './theme.js';
5
5
 
6
6
  /** Inspector row label with an optional info icon tooltip. */
7
7
  export function InspectorLabel(props: {label: string; hint?: string; hintKey?: string}): React.ReactElement | null {
@@ -1,10 +1,15 @@
1
1
  import React from 'react';
2
- import {buttonStyle, theme} from './theme';
2
+ import {buttonStyle, theme} from './theme.js';
3
3
 
4
+ /** Props of {@link MessageDialog}. */
4
5
  export interface MessageDialogProps {
6
+ /** The dialog's title. */
5
7
  title: string;
8
+ /** The message; line breaks are kept. */
6
9
  message: string;
10
+ /** The button's text. Default `OK`. */
7
11
  confirmLabel?: string;
12
+ /** Closes the dialog: the button, Enter, Escape or the backdrop. */
8
13
  onClose: () => void;
9
14
  }
10
15
 
@@ -1,11 +1,22 @@
1
1
  import {ChevronDownIcon, ChevronRightIcon} from '@heroicons/react/24/solid';
2
2
  import React, {useState} from 'react';
3
- import {iconStyle} from './icons';
4
- import {getInspectorHint} from './inspectorHints';
5
- import {InspectorInfoIcon} from './InspectorInfoIcon';
6
- import {theme} from './theme';
3
+ import type {EffectBinding} from '../core/binding.js';
4
+ import {iconStyle} from './icons.js';
5
+ import {getInspectorHint} from './inspectorHints.js';
6
+ import {InspectorInfoIcon} from './InspectorInfoIcon.js';
7
+ import {theme} from './theme.js';
7
8
 
8
9
  /** Unity-style module foldout with an optional enable checkbox. */
10
+ /** Props of every inspector module: the effect binding it edits, through `binding.apply`. */
11
+ export interface ModuleProps {
12
+ /** The focused effect; the module edits `binding.system`. */
13
+ binding: EffectBinding;
14
+ }
15
+
16
+ /**
17
+ * An inspector module: a foldout with a title, a hint, and an enable checkbox when `onToggle` is
18
+ * given. Unity's Particle System inspector modules look the same way.
19
+ */
9
20
  export function ModuleSection(props: {
10
21
  title: string;
11
22
  hint?: string;
@@ -1,9 +1,9 @@
1
- import {Quaternion} from '@babylonjs/core/Maths/math.vector';
2
- import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
1
+ import {Quaternion} from '@babylonjs/core/Maths/math.vector.js';
2
+ import type {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
3
3
  import {useSyncExternalStore} from 'react';
4
- import {getTransformRevision, notifyTransformChanged, subscribeTransform} from '../core/transformStore';
5
- import {ModuleSection} from './ModuleSection';
6
- import {NumberField, Row} from './fields';
4
+ import {getTransformRevision, notifyTransformChanged, subscribeTransform} from '../core/transformStore.js';
5
+ import {ModuleSection} from './ModuleSection.js';
6
+ import {NumberField, Row} from './fields.js';
7
7
 
8
8
  const RAD_TO_DEG = 180 / Math.PI;
9
9
  const DEG_TO_RAD = Math.PI / 180;
@@ -1,18 +1,27 @@
1
1
  import {ArrowLeftIcon, FolderIcon, PlusIcon} from '@heroicons/react/24/solid';
2
2
  import React, {useCallback, useEffect, useState} from 'react';
3
- import {formatBytes, type ProjectMeta, type ProjectStore, type StorageEstimate} from '../core/projectStore';
4
- import {ChoiceDialog} from './ChoiceDialog';
5
- import {ContextMenu, type ContextMenuItem} from './ContextMenu';
6
- import {iconStyle} from './icons';
7
- import {PromptDialog} from './PromptDialog';
8
- import {buttonStyle, globalEditorStyles, theme} from './theme';
3
+ import {formatBytes, type ProjectMeta, type ProjectStore, type StorageEstimate} from '../core/projectStore.js';
4
+ import {ChoiceDialog} from './ChoiceDialog.js';
5
+ import {ContextMenu, type ContextMenuItem} from './ContextMenu.js';
6
+ import {iconStyle} from './icons.js';
7
+ import {PromptDialog} from './PromptDialog.js';
8
+ import {buttonStyle, globalEditorStyles, theme} from './theme.js';
9
9
 
10
+ /** Props of {@link ProjectsPage}. */
10
11
  export interface ProjectsPageProps {
12
+ /** Where the projects live. */
11
13
  store: ProjectStore;
14
+ /** Opens a project in the editor. */
12
15
  onOpen: (project: ProjectMeta) => void;
16
+ /** The page's heading. */
13
17
  title?: string;
14
18
  /** Optional link back to the page that hosts the editor (e.g. a gallery), shown above the title. */
15
- backLink?: {href: string; label: string};
19
+ backLink?: {
20
+ /** Where the link goes. */
21
+ href: string;
22
+ /** The link's text. */
23
+ label: string;
24
+ };
16
25
  }
17
26
 
18
27
  type Dialog =
@@ -79,6 +88,18 @@ function StorageBar(props: {estimate: StorageEstimate | null; onPersist: () => v
79
88
  );
80
89
  }
81
90
 
91
+ /** What the projects page shows: the store's projects and usage, or why it could not read them. */
92
+ type ProjectListing = {list: ProjectMeta[]; storage: StorageEstimate} | {error: string};
93
+
94
+ async function listProjects(store: ProjectsPageProps['store']): Promise<ProjectListing> {
95
+ try {
96
+ const [list, storage] = await Promise.all([store.listProjects(), store.estimate()]);
97
+ return {list, storage};
98
+ } catch (err) {
99
+ return {error: err instanceof Error ? err.message : String(err)};
100
+ }
101
+ }
102
+
82
103
  /** Start page: the projects saved in the store, plus storage usage. */
83
104
  export function ProjectsPage(props: ProjectsPageProps): React.ReactElement {
84
105
  const {store, onOpen, title = 'Effect editor', backLink} = props;
@@ -88,20 +109,26 @@ export function ProjectsPage(props: ProjectsPageProps): React.ReactElement {
88
109
  const [menu, setMenu] = useState<{x: number; y: number; items: ContextMenuItem[]} | null>(null);
89
110
  const [error, setError] = useState<string | null>(null);
90
111
 
91
- const reload = useCallback(async () => {
92
- try {
93
- const [list, storage] = await Promise.all([store.listProjects(), store.estimate()]);
94
- setProjects(list);
95
- setEstimate(storage);
96
- } catch (err) {
97
- setError(err instanceof Error ? err.message : String(err));
112
+ const show = useCallback((listing: ProjectListing) => {
113
+ if ('error' in listing) {
114
+ setError(listing.error);
98
115
  setProjects([]);
116
+ } else {
117
+ setProjects(listing.list);
118
+ setEstimate(listing.storage);
99
119
  }
100
- }, [store]);
120
+ }, []);
121
+ const reload = useCallback(async () => show(await listProjects(store)), [store, show]);
101
122
 
102
123
  useEffect(() => {
103
- void reload();
104
- }, [reload]);
124
+ let cancelled = false;
125
+ void listProjects(store).then((listing) => {
126
+ if (!cancelled) show(listing);
127
+ });
128
+ return () => {
129
+ cancelled = true;
130
+ };
131
+ }, [store, show]);
105
132
 
106
133
  const run = (action: () => Promise<unknown>) => {
107
134
  setDialog(null);
@@ -1,14 +1,21 @@
1
1
  import React, {useEffect, useRef, useState} from 'react';
2
- import {buttonStyle, inputStyle, theme} from './theme';
2
+ import {buttonStyle, inputStyle, theme} from './theme.js';
3
3
 
4
+ /** Props of {@link PromptDialog}. */
4
5
  export interface PromptDialogProps {
6
+ /** What to enter. */
5
7
  title: string;
8
+ /** The text the field starts with, selected. */
6
9
  defaultValue?: string;
10
+ /** Hint shown in the empty field. */
7
11
  placeholder?: string;
12
+ /** The confirm button's text. Default `OK`. */
8
13
  confirmLabel?: string;
9
14
  /** Rejects empty/whitespace-only input by default (matches the old prompt() behavior). */
10
15
  allowEmpty?: boolean;
16
+ /** Receives the entered text, trimmed. */
11
17
  onSubmit: (value: string) => void;
18
+ /** Escape, the backdrop, Cancel, or an empty submit when empty is not allowed. */
12
19
  onCancel: () => void;
13
20
  }
14
21
 
@@ -1,4 +1,4 @@
1
- import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
1
+ import type {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
2
2
  import {
3
3
  ChevronDownIcon,
4
4
  ChevronRightIcon,
@@ -8,30 +8,37 @@ import {
8
8
  TrashIcon,
9
9
  ViewfinderCircleIcon,
10
10
  } from '@heroicons/react/24/solid';
11
- import React, {useEffect, useState} from 'react';
12
- import {buildEffectTree} from '../core/effectTree';
13
- import type {SceneEntry} from '../core/sceneEntries';
14
- import {ContextMenu, type ContextMenuItem} from './ContextMenu';
15
- import {EffectTreeView} from './EffectTreeView';
16
- import {iconStyle} from './icons';
17
- import {buttonStyle, theme} from './theme';
11
+ import React, {useState} from 'react';
12
+ import {buildEffectTree} from '../core/effectTree.js';
13
+ import type {SceneEntry} from '../core/sceneEntries.js';
14
+ import {ContextMenu, type ContextMenuItem} from './ContextMenu.js';
15
+ import {EffectTreeView} from './EffectTreeView.js';
16
+ import {iconStyle} from './icons.js';
17
+ import {buttonStyle, theme} from './theme.js';
18
18
 
19
19
  const ROW_HEIGHT = 34;
20
20
 
21
+ /** Props of {@link ScenePanel}. */
21
22
  export interface ScenePanelProps {
23
+ /** The effects in the scene, in list order. */
22
24
  entries: SceneEntry[];
23
25
  /** Effect shown in the inspector and timeline. */
24
26
  focusedRoot: TransformNode | null;
25
27
  /** Visible entries — they render and play with the stage transport. */
26
28
  visibleRootIds: ReadonlySet<number>;
29
+ /** Focuses an effect in the inspector and timeline. */
27
30
  onFocus: (entry: SceneEntry) => void;
31
+ /** Shows or hides an effect. */
28
32
  onToggleVisible: (entry: SceneEntry) => void;
29
33
  /** Root of the entry shown alone, or null. */
30
34
  soloRootId: number | null;
35
+ /** Shows an effect alone, or ends solo. */
31
36
  onToggleSolo: (entry: SceneEntry) => void;
37
+ /** Removes an effect from the scene. */
32
38
  onDelete: (entry: SceneEntry) => void;
33
39
  /** "Save as new asset…" in the row's right-click menu. */
34
40
  onSaveAsAsset?: (entry: SceneEntry) => void;
41
+ /** Adds a new default effect. */
35
42
  onCreateDefault: () => void;
36
43
  /** Node shown in the inspector; highlighted in the expanded effect trees. */
37
44
  selectedNode?: TransformNode | null;
@@ -213,11 +220,13 @@ export function ScenePanel(props: ScenePanelProps): React.ReactElement {
213
220
  const [menu, setMenu] = useState<{x: number; y: number; items: ContextMenuItem[]} | null>(null);
214
221
  const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
215
222
  // The focused effect opens its tree, so its sub-emitters are one click away.
216
- useEffect(() => {
217
- if (focusedRoot) {
218
- setExpanded((prev) => (prev.has(focusedRoot.uniqueId) ? prev : new Set(prev).add(focusedRoot.uniqueId)));
223
+ const [expandedFocus, setExpandedFocus] = useState<TransformNode | null>(null);
224
+ if (focusedRoot !== expandedFocus) {
225
+ setExpandedFocus(focusedRoot);
226
+ if (focusedRoot && !expanded.has(focusedRoot.uniqueId)) {
227
+ setExpanded(new Set(expanded).add(focusedRoot.uniqueId));
219
228
  }
220
- }, [focusedRoot]);
229
+ }
221
230
  const toggleExpanded = (entry: SceneEntry) =>
222
231
  setExpanded((prev) => {
223
232
  const next = new Set(prev);
@@ -1,11 +1,11 @@
1
1
  import type {ParticleEmitter, ParticleSystem} from 'babylon.quarks';
2
2
  import {EmitSubParticleSystem, SubParticleEmitMode} from 'babylon.quarks';
3
- import {EffectBinding} from '../core/binding';
4
- import {findBehavior} from '../core/colors';
5
- import {createChildSystem} from '../core/systems';
6
- import {ModuleSection} from './ModuleSection';
7
- import {CheckboxField, NumberField, Row, SelectField} from './fields';
8
- import {theme} from './theme';
3
+ import {EffectBinding} from '../core/binding.js';
4
+ import {findBehavior} from '../core/colors.js';
5
+ import {createChildSystem} from '../core/systems.js';
6
+ import {ModuleSection} from './ModuleSection.js';
7
+ import {CheckboxField, NumberField, Row, SelectField} from './fields.js';
8
+ import {theme} from './theme.js';
9
9
 
10
10
  /**
11
11
  * Sub Emitters module for the currently selected system. The spawned target lives as a
@@ -0,0 +1,118 @@
1
+ import type {Texture} from '@babylonjs/core/Materials/Textures/texture.js';
2
+ import {useState} from 'react';
3
+ import {SelectField} from './fields.js';
4
+ import type {TextureOption} from './modules.js';
5
+ import {PromptDialog} from './PromptDialog.js';
6
+
7
+ /** A texture-like object the host's `resolveTexture` returned; only its `url` is read here. */
8
+ type TextureLike = {url?: string} | null | undefined;
9
+
10
+ /** The url a texture-like object was loaded from, or '' for none. */
11
+ export function textureUrlOf(texture: unknown): string {
12
+ return (texture as TextureLike)?.url ?? '';
13
+ }
14
+
15
+ /**
16
+ * Picks a texture for one slot: a host preset, a custom URL, a file from disk, or none. The
17
+ * current texture stays selectable when it is not among the presets. Used by the Renderer
18
+ * module for the map and for the ramp, mask and noise of the texture effects.
19
+ */
20
+ export function TextureSelect(props: {
21
+ /** The slot's current texture (anything with a `url`), or null for none. */
22
+ value: unknown;
23
+ textureOptions?: TextureOption[];
24
+ /** Turns a URL or `data:` URI into a texture; null or undefined when it cannot. */
25
+ loadTexture: (url: string) => Texture | null | undefined;
26
+ /** The picked texture, or null for none. */
27
+ onChange: (texture: Texture | null) => void;
28
+ /** Label of the "none" entry. Default "(none)". */
29
+ noneLabel?: string;
30
+ /** Shows a thumbnail of the current texture under the select. Default false. */
31
+ preview?: boolean;
32
+ /** Title of the custom URL prompt. Default "Texture URL". */
33
+ promptTitle?: string;
34
+ }) {
35
+ const currentUrl = textureUrlOf(props.value);
36
+ const options = props.textureOptions ?? [];
37
+ const isPreset = options.some((o) => o.url === currentUrl);
38
+ const [prompting, setPrompting] = useState(false);
39
+ const apply = (url: string) => {
40
+ const texture = props.loadTexture(url);
41
+ if (texture) {
42
+ props.onChange(texture);
43
+ }
44
+ };
45
+ return (
46
+ <>
47
+ <SelectField
48
+ value={!currentUrl ? '__none' : isPreset ? currentUrl : '__current'}
49
+ options={[
50
+ {value: '__none', label: props.noneLabel ?? '(none)'},
51
+ ...(currentUrl && !isPreset
52
+ ? [{value: '__current', label: `(current) ${currentUrl.slice(-24)}`}]
53
+ : []),
54
+ ...options.map((o) => ({value: o.url, label: o.label})),
55
+ {value: '__url', label: 'Custom URL…'},
56
+ {value: '__file', label: 'Load from file…'},
57
+ ]}
58
+ onChange={(url) => {
59
+ if (url === '__current') {
60
+ return;
61
+ }
62
+ if (url === '__none') {
63
+ props.onChange(null);
64
+ return;
65
+ }
66
+ if (url === '__url') {
67
+ setPrompting(true);
68
+ return;
69
+ }
70
+ if (url === '__file') {
71
+ const input = document.createElement('input');
72
+ input.type = 'file';
73
+ input.accept = 'image/*';
74
+ input.onchange = () => {
75
+ const file = input.files?.[0];
76
+ if (!file) {
77
+ return;
78
+ }
79
+ const reader = new FileReader();
80
+ reader.onload = () => apply(reader.result as string);
81
+ reader.readAsDataURL(file);
82
+ };
83
+ input.click();
84
+ return;
85
+ }
86
+ apply(url);
87
+ }}
88
+ />
89
+ {prompting && (
90
+ <PromptDialog
91
+ title={props.promptTitle ?? 'Texture URL'}
92
+ defaultValue={currentUrl}
93
+ placeholder="https://…"
94
+ onSubmit={(url) => {
95
+ apply(url);
96
+ setPrompting(false);
97
+ }}
98
+ onCancel={() => setPrompting(false)}
99
+ />
100
+ )}
101
+ {props.preview && currentUrl && (
102
+ <img
103
+ src={currentUrl}
104
+ alt="texture preview"
105
+ style={{
106
+ width: 64,
107
+ height: 64,
108
+ marginTop: 6,
109
+ objectFit: 'contain',
110
+ borderRadius: 6,
111
+ border: '1px solid #2b3761',
112
+ background: 'repeating-conic-gradient(#2a3252 0% 25%, #171d33 0% 50%) 0 0 / 12px 12px',
113
+ }}
114
+ />
115
+ )}
116
+ </>
117
+ );
118
+ }
@@ -0,0 +1,62 @@
1
+ import type {TextureUV, UVChannel} from 'babylon.quarks';
2
+ import {NumberField, Row, SelectField} from './fields.js';
3
+
4
+ /** A `[u, v]` pair of number inputs. */
5
+ function PairField(props: {value: [number, number]; step: number; onChange: (next: [number, number]) => void}) {
6
+ return (
7
+ <div style={{display: 'flex', gap: 6}}>
8
+ {[0, 1].map((i) => (
9
+ <NumberField
10
+ key={i}
11
+ value={props.value[i]}
12
+ step={props.step}
13
+ onChange={(v) => {
14
+ const next: [number, number] = [props.value[0], props.value[1]];
15
+ next[i] = v;
16
+ props.onChange(next);
17
+ }}
18
+ />
19
+ ))}
20
+ </div>
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Rows for how a texture is laid over the particle (`TextureUV`): its UV channel, then
26
+ * tiling and offset as Unity's material Tiling / Offset, then a scroll as Unreal's Panner.
27
+ * One field for the map, the erosion mask and the distortion noise alike.
28
+ */
29
+ export function TextureUVField(props: {
30
+ /** The complete layout as the system holds it. */
31
+ value: TextureUV;
32
+ /** Called with the whole layout, one field changed. */
33
+ onChange: (next: TextureUV) => void;
34
+ /** Prefix of each row's label, so several layouts in one module stay apart. Default none. */
35
+ labelPrefix?: string;
36
+ }) {
37
+ const prefix = props.labelPrefix ?? '';
38
+ const patch = (fields: Partial<TextureUV>) => props.onChange({...props.value, ...fields});
39
+ return (
40
+ <>
41
+ <Row label={`${prefix}UV channel`} hintKey="UV channel">
42
+ <SelectField<UVChannel>
43
+ value={props.value.channel}
44
+ options={[
45
+ {value: 0, label: 'UV0 (sprite / mesh UV)'},
46
+ {value: 1, label: 'UV1 (mesh second set, along a trail)'},
47
+ ]}
48
+ onChange={(channel) => patch({channel})}
49
+ />
50
+ </Row>
51
+ <Row label={`${prefix}Tiling`} hintKey="Tiling">
52
+ <PairField value={props.value.tiling} step={0.5} onChange={(tiling) => patch({tiling})} />
53
+ </Row>
54
+ <Row label={`${prefix}Offset`} hintKey="Offset">
55
+ <PairField value={props.value.offset} step={0.05} onChange={(offset) => patch({offset})} />
56
+ </Row>
57
+ <Row label={`${prefix}Scroll`} hintKey="Scroll">
58
+ <PairField value={props.value.scroll} step={0.05} onChange={(scroll) => patch({scroll})} />
59
+ </Row>
60
+ </>
61
+ );
62
+ }
@@ -1,10 +1,10 @@
1
- import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
2
- import type {Scene} from '@babylonjs/core/scene';
1
+ import type {TransformNode} from '@babylonjs/core/Meshes/transformNode.js';
2
+ import type {Scene} from '@babylonjs/core/scene.js';
3
3
  import {ArrowPathIcon, ForwardIcon, PauseIcon, PlayIcon, RectangleGroupIcon} from '@heroicons/react/24/solid';
4
4
  import type {BatchedRenderer, ParticleSystem} from 'babylon.quarks';
5
5
  import React, {useMemo, useRef, useState} from 'react';
6
- import type {EffectBinding} from '../core/binding';
7
- import {groupNodes} from '../core/groups';
6
+ import type {EffectBinding} from '../core/binding.js';
7
+ import {groupNodes} from '../core/groups.js';
8
8
  import {
9
9
  clearFocusFinished,
10
10
  getFocusElapsed,
@@ -12,28 +12,41 @@ import {
12
12
  markFocusFinished,
13
13
  setFocusElapsed,
14
14
  type PlaybackState,
15
- } from '../core/playback';
16
- import {pauseFocus, playFocus, scrubFocusTo, scrubFocusToSettled} from '../core/scrub';
17
- import {computeTimelineRows, computeTimelineSpan} from '../core/timeline';
18
- import {PLAYBACK_SPEEDS, restartFocusPlayback, toggleStagePlayback} from '../core/transport';
19
- import {TimelineTrackRow} from './TimelineTrackRow';
20
- import {iconStyle} from './icons';
21
- import {buttonStyle, theme} from './theme';
15
+ } from '../core/playback.js';
16
+ import {pauseFocus, playFocus, scrubFocusTo, scrubFocusToSettled} from '../core/scrub.js';
17
+ import {computeTimelineRows, computeTimelineSpan} from '../core/timeline.js';
18
+ import {PLAYBACK_SPEEDS, restartFocusPlayback, toggleStagePlayback} from '../core/transport.js';
19
+ import {TimelineTrackRow} from './TimelineTrackRow.js';
20
+ import {iconStyle} from './icons.js';
21
+ import {buttonStyle, theme} from './theme.js';
22
22
 
23
- export type {PlaybackState} from '../core/playback';
23
+ export type {PlaybackState} from '../core/playback.js';
24
24
 
25
+ /** Props of {@link TimelinePanel}. */
25
26
  export interface TimelinePanelProps {
27
+ /** The focused effect, whose systems are the tracks. */
26
28
  binding: EffectBinding;
29
+ /** The node shown in the inspector; its track is highlighted. */
27
30
  selectedNode: TransformNode | null;
31
+ /** Selects a track's or group's node. */
28
32
  onSelectNode: (node: TransformNode | null) => void;
33
+ /** The preview scene, redrawn after a scrub. */
29
34
  scene: Scene;
35
+ /** The renderer the effect draws through, refreshed after a scrub. */
30
36
  renderer: BatchedRenderer;
37
+ /** Playback state shared with the host's render loop. */
31
38
  playbackRef: React.MutableRefObject<PlaybackState>;
32
- playheadRef: React.RefObject<HTMLDivElement>;
33
- counterRef: React.RefObject<HTMLSpanElement>;
39
+ /** The playhead line, moved every frame without a re-render. */
40
+ playheadRef: React.RefObject<HTMLDivElement | null>;
41
+ /** The time and particle counter in the header, written by the host's render loop. */
42
+ counterRef: React.RefObject<HTMLSpanElement | null>;
43
+ /** The stage is playing. */
34
44
  stagePlaying: boolean;
45
+ /** Plays or pauses the stage. */
35
46
  setStagePlaying: (playing: boolean) => void;
47
+ /** Playback speed, 1 for real time. */
36
48
  speed: number;
49
+ /** Sets the playback speed. */
37
50
  setSpeed: (speed: number) => void;
38
51
  /** False when the focused effect is hidden — it has nothing to play. */
39
52
  playbackEnabled?: boolean;
@@ -93,10 +106,13 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
93
106
 
94
107
  const focusRootId = binding.root.uniqueId;
95
108
 
96
- React.useEffect(() => {
109
+ // Another effect starts with every system shown and nothing selected.
110
+ const [shownBinding, setShownBinding] = useState(binding);
111
+ if (binding !== shownBinding) {
112
+ setShownBinding(binding);
97
113
  setHiddenSystems(new Set());
98
114
  setGroupSelection(new Set());
99
- }, [binding]);
115
+ }
100
116
 
101
117
  const toggleGroupSelect = (node: TransformNode) => {
102
118
  setGroupSelection((prev) => {
@@ -146,7 +162,9 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
146
162
  return () => observer.disconnect();
147
163
  }, []);
148
164
 
149
- const rows = useMemo(() => computeTimelineRows(binding), [binding, binding.getRevision()]);
165
+ const revision = binding.getRevision();
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the binding changes in place; its revision says when to recompute
167
+ const rows = useMemo(() => computeTimelineRows(binding), [binding, revision]);
150
168
  const timelineSpan = useMemo(() => computeTimelineSpan(rows), [rows]);
151
169
  const pxPerSecond = Math.max(1, bodyWidthPx - TIMELINE_INSET - RIGHT_PADDING) / timelineSpan;
152
170
  const anyLooping = useMemo(() => rows.some((r) => r.kind === 'track' && r.looping), [rows]);
@@ -200,17 +218,18 @@ export function TimelinePanel(props: TimelinePanelProps): React.ReactElement {
200
218
  ]);
201
219
 
202
220
  const visibleRows = useMemo(() => {
221
+ const visible: typeof rows = [];
203
222
  let hideUntilDepth: number | null = null;
204
- const visible = rows.filter((row) => {
223
+ for (const row of rows) {
205
224
  if (hideUntilDepth !== null) {
206
- if (row.depth > hideUntilDepth) return false;
225
+ if (row.depth > hideUntilDepth) continue;
207
226
  hideUntilDepth = null;
208
227
  }
209
228
  if (row.kind === 'group' && collapsedGroups.has(row.node)) {
210
229
  hideUntilDepth = row.depth;
211
230
  }
212
- return true;
213
- });
231
+ visible.push(row);
232
+ }
214
233
  return visible;
215
234
  }, [rows, collapsedGroups]);
216
235