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
@@ -1,6 +1,6 @@
1
- import { ParticleEmitter } from 'babylon.quarks';
2
- import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
1
  import { Matrix, Quaternion } from '@babylonjs/core/Maths/math.vector';
2
+ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
+ import { ParticleEmitter } from 'babylon.quarks';
4
4
  import { isEditorSceneNode } from './editorScene';
5
5
  /** Walks a loaded root TransformNode into the editor tree (groups + emitters). */
6
6
  export function buildEffectTree(root, depth = 0) {
@@ -51,7 +51,11 @@ function serializeNode(tree, meta, fallbackUuid) {
51
51
  children: children.length > 0 ? children : undefined,
52
52
  };
53
53
  if (tree.system) {
54
- return { ...base, type: 'ParticleEmitter', ps: tree.system.toJSON(meta, { useUrlForImage: true }) };
54
+ return {
55
+ ...base,
56
+ type: 'ParticleEmitter',
57
+ ps: tree.system.toJSON(meta, { useUrlForImage: true }),
58
+ };
55
59
  }
56
60
  return { ...base, type: 'Group' };
57
61
  }
@@ -92,10 +96,20 @@ function finishEnvelope(object, meta) {
92
96
  const textures = [];
93
97
  for (const [uuid, tex] of Object.entries(meta.textures)) {
94
98
  const url = tex?.url ?? tex?.name;
99
+ const invertY = typeof tex?.invertY === 'boolean' ? tex.invertY : undefined;
100
+ const isEnvAtlas = tex?.name === 'quarksEnvAtlas' || (typeof tex?.name === 'string' && tex.name.endsWith('_envAtlas'));
95
101
  if (url) {
96
102
  const imageUuid = `${uuid}-image`;
97
103
  images.push({ uuid: imageUuid, url });
98
- textures.push({ uuid, image: imageUuid });
104
+ const entry = {
105
+ uuid,
106
+ image: imageUuid,
107
+ };
108
+ if (invertY === false || isEnvAtlas) {
109
+ entry.invertY = false;
110
+ entry.noMipmap = true;
111
+ }
112
+ textures.push(entry);
99
113
  }
100
114
  else {
101
115
  textures.push({ uuid });
@@ -1,5 +1,6 @@
1
- import type { ParticleSystem } from 'babylon.quarks';
2
1
  import type { Scene } from '@babylonjs/core/scene';
2
+ import type { ParticleSystem } from 'babylon.quarks';
3
+ import './lineShaders';
3
4
  /**
4
5
  * Optional wireframe overlays for every emitter shape in the current effect.
5
6
  * Geometry follows quarks.core spawn math (local XY spawn plane, +Z emit axis).
@@ -2,8 +2,9 @@ import { Color3 } from '@babylonjs/core/Maths/math.color';
2
2
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
3
3
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
4
4
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
5
- import { getShapeType, readShapeParams } from './shapes';
6
5
  import { EDITOR_SCENE_NODE_METADATA } from './editorScene';
6
+ import './lineShaders';
7
+ import { getShapeType, readShapeParams } from './shapes';
7
8
  const LINE_COLOR = new Color3(0.45, 0.82, 1);
8
9
  const DIRECTION_COLOR = new Color3(0.75, 0.92, 1);
9
10
  /**
@@ -1,5 +1,5 @@
1
- import { Color3 } from '@babylonjs/core/Maths/math.color';
2
1
  import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial';
2
+ import { Color3 } from '@babylonjs/core/Maths/math.color';
3
3
  import { Mesh } from '@babylonjs/core/Meshes/mesh';
4
4
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
5
5
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
@@ -1,14 +1,14 @@
1
- import type { GalleryEntry } from './loadEffectGallery';
2
- /** Freezes every gallery entry; unpauses all in-scene entries in the preview set when transport is playing. */
3
- export declare function applyGalleryPlayback(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>, transportPlaying: boolean, focusFinishedRootIds?: ReadonlySet<number>): void;
1
+ import type { SceneEntry } from './sceneEntries';
2
+ /** Freezes every scene entry; unpauses the visible ones (the preview set) when transport is playing. */
3
+ export declare function applyGalleryPlayback(entries: SceneEntry[], previewRootIds: ReadonlySet<number>, transportPlaying: boolean, focusFinishedRootIds?: ReadonlySet<number>): void;
4
4
  /** Whether any preview effect on stage should keep simulating. */
5
- export declare function hasActivePreview(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>): boolean;
5
+ export declare function hasActivePreview(entries: SceneEntry[], previewRootIds: ReadonlySet<number>): boolean;
6
6
  /** In-scene preview effects currently in the preview set. */
7
- export declare function countActivePreviews(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>): number;
7
+ export declare function countActivePreviews(entries: SceneEntry[], previewRootIds: ReadonlySet<number>): number;
8
8
  /** Particle count across all active preview effects. */
9
- export declare function countGalleryParticles(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>): number;
9
+ export declare function countGalleryParticles(entries: SceneEntry[], previewRootIds: ReadonlySet<number>): number;
10
10
  /**
11
11
  * Pauses one-shot preview effects whose particle systems report `isFinished()`.
12
12
  * Returns true when any entry was paused (caller should refresh renderer batches).
13
13
  */
14
- export declare function pauseFinishedPreviews(entries: GalleryEntry[], previewRootIds: ReadonlySet<number>, focusFinishedRootIds?: ReadonlySet<number>): boolean;
14
+ export declare function pauseFinishedPreviews(entries: SceneEntry[], previewRootIds: ReadonlySet<number>, focusFinishedRootIds?: ReadonlySet<number>): boolean;
@@ -1,8 +1,8 @@
1
1
  import { QuarksUtil } from 'babylon.quarks';
2
2
  function isPreviewEntry(entry, previewRootIds) {
3
- return entry.inScene && previewRootIds.has(entry.root.uniqueId);
3
+ return previewRootIds.has(entry.root.uniqueId);
4
4
  }
5
- /** Freezes every gallery entry; unpauses all in-scene entries in the preview set when transport is playing. */
5
+ /** Freezes every scene entry; unpauses the visible ones (the preview set) when transport is playing. */
6
6
  export function applyGalleryPlayback(entries, previewRootIds, transportPlaying, focusFinishedRootIds = new Set()) {
7
7
  for (const entry of entries) {
8
8
  for (const system of entry.systems) {
@@ -1,20 +1,15 @@
1
1
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
2
2
  import type { Scene } from '@babylonjs/core/scene';
3
- import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
4
3
  import type { BatchedRenderer } from 'babylon.quarks';
5
- import type { GalleryEntry } from './loadEffectGallery';
6
- /** Clears live particles and freezes simulation for one placed catalog entry. */
7
- export declare function clearGalleryEntrySimulation(entry: GalleryEntry, renderer: BatchedRenderer): void;
8
- /** Parents a catalog entry into the live scene and registers its systems with the renderer. */
9
- export declare function placeGalleryEntryInScene(entry: GalleryEntry, renderer: BatchedRenderer, worldPosition: Vector3): void;
10
- /** Spawns a batch of catalog effects in a square grid centered on `center`. */
11
- export declare function placeGalleryEntriesAt(entries: GalleryEntry[], renderer: BatchedRenderer, center: Vector3, spacing?: number): void;
4
+ import type { SceneEntry } from './sceneEntries';
5
+ /** Clears live particles and freezes simulation for one scene entry. */
6
+ export declare function clearSceneEntrySimulation(entry: SceneEntry, renderer: BatchedRenderer): void;
12
7
  /**
13
- * Unregisters a placed effect from the renderer and returns it to the hidden catalog.
14
- * Systems stay alive for inspector editing; drag into the viewport to place again.
8
+ * Shows or hides a scene entry. A hidden entry keeps its place in the scene and stays editable,
9
+ * but has no particles, is not simulated and can't be picked in the viewport.
15
10
  */
16
- export declare function removeGalleryEntryFromScene(entry: GalleryEntry, renderer: BatchedRenderer, catalogRoot: TransformNode): void;
11
+ export declare function setSceneEntryVisible(entry: SceneEntry, renderer: BatchedRenderer, visible: boolean): void;
12
+ /** Lays out a batch of new entries in a square grid centered on `center`. */
13
+ export declare function placeSceneEntriesAt(entries: SceneEntry[], center: Vector3, spacing?: number): void;
17
14
  /** Raycasts the editor ground plane from canvas/client coordinates. */
18
15
  export declare function pickGroundPosition(scene: Scene, clientX: number, clientY: number): Vector3;
19
- /** Finds a gallery entry by Babylon node id from a drag payload. */
20
- export declare function findGalleryEntryById(entries: GalleryEntry[], id: string): GalleryEntry | undefined;
@@ -1,50 +1,30 @@
1
1
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
2
2
  import { DEFAULT_GALLERY_SPACING, galleryGridPosition } from './galleryLayout';
3
- /** Clears live particles and freezes simulation for one placed catalog entry. */
4
- export function clearGalleryEntrySimulation(entry, renderer) {
3
+ /** Clears live particles and freezes simulation for one scene entry. */
4
+ export function clearSceneEntrySimulation(entry, renderer) {
5
5
  for (const system of entry.systems) {
6
6
  system.restart();
7
7
  system.pause();
8
8
  }
9
9
  renderer.refreshBatches();
10
10
  }
11
- /** Parents a catalog entry into the live scene and registers its systems with the renderer. */
12
- export function placeGalleryEntryInScene(entry, renderer, worldPosition) {
13
- entry.root.setEnabled(true);
14
- entry.root.parent = null;
15
- entry.root.position.copyFrom(worldPosition);
16
- if (!entry.inScene) {
17
- for (const system of entry.systems) {
18
- renderer.addSystem(system);
19
- system.pause();
20
- }
21
- entry.inScene = true;
11
+ /**
12
+ * Shows or hides a scene entry. A hidden entry keeps its place in the scene and stays editable,
13
+ * but has no particles, is not simulated and can't be picked in the viewport.
14
+ */
15
+ export function setSceneEntryVisible(entry, renderer, visible) {
16
+ if (!visible) {
17
+ clearSceneEntrySimulation(entry, renderer);
22
18
  }
19
+ entry.root.setEnabled(visible);
23
20
  }
24
- /** Spawns a batch of catalog effects in a square grid centered on `center`. */
25
- export function placeGalleryEntriesAt(entries, renderer, center, spacing = DEFAULT_GALLERY_SPACING) {
21
+ /** Lays out a batch of new entries in a square grid centered on `center`. */
22
+ export function placeSceneEntriesAt(entries, center, spacing = DEFAULT_GALLERY_SPACING) {
26
23
  entries.forEach((entry, index) => {
27
- const offset = galleryGridPosition(index, entries.length, spacing);
28
- const position = center.add(new Vector3(offset.x, 0, offset.z));
29
- placeGalleryEntryInScene(entry, renderer, position);
24
+ const offset = entries.length === 1 ? { x: 0, z: 0 } : galleryGridPosition(index, entries.length, spacing);
25
+ entry.root.position.copyFrom(center.add(new Vector3(offset.x, 0, offset.z)));
30
26
  });
31
27
  }
32
- /**
33
- * Unregisters a placed effect from the renderer and returns it to the hidden catalog.
34
- * Systems stay alive for inspector editing; drag into the viewport to place again.
35
- */
36
- export function removeGalleryEntryFromScene(entry, renderer, catalogRoot) {
37
- if (!entry.inScene) {
38
- return;
39
- }
40
- clearGalleryEntrySimulation(entry, renderer);
41
- for (const system of entry.systems) {
42
- renderer.deleteSystem(system);
43
- }
44
- entry.root.parent = catalogRoot;
45
- entry.root.setEnabled(false);
46
- entry.inScene = false;
47
- }
48
28
  /** Raycasts the editor ground plane from canvas/client coordinates. */
49
29
  export function pickGroundPosition(scene, clientX, clientY) {
50
30
  const canvas = scene.getEngine().getRenderingCanvas();
@@ -55,11 +35,3 @@ export function pickGroundPosition(scene, clientX, clientY) {
55
35
  const pick = scene.pick(clientX - rect.left, clientY - rect.top, (mesh) => mesh.name === 'ground');
56
36
  return pick?.pickedPoint?.clone() ?? Vector3.Zero();
57
37
  }
58
- /** Finds a gallery entry by Babylon node id from a drag payload. */
59
- export function findGalleryEntryById(entries, id) {
60
- const numericId = Number(id);
61
- if (!Number.isFinite(numericId)) {
62
- return undefined;
63
- }
64
- return entries.find((entry) => entry.root.uniqueId === numericId);
65
- }
@@ -1,6 +1,7 @@
1
1
  import { Color3 } from '@babylonjs/core/Maths/math.color';
2
2
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
3
3
  import type { Scene } from '@babylonjs/core/scene';
4
+ import './lineShaders';
4
5
  export declare const GROUND_RING_RADIUS = 1.2;
5
6
  /** Flat ground ring that tracks a transform on the XZ plane. */
6
7
  declare class GroundRingMarker {
@@ -2,6 +2,7 @@ import { Color3 } from '@babylonjs/core/Maths/math.color';
2
2
  import { Vector3 } from '@babylonjs/core/Maths/math.vector';
3
3
  import { MeshBuilder } from '@babylonjs/core/Meshes/meshBuilder';
4
4
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
5
+ import './lineShaders';
5
6
  export const GROUND_RING_RADIUS = 1.2;
6
7
  const RING_SEGMENTS = 64;
7
8
  const RING_Y = 0.03;
@@ -1,4 +1,9 @@
1
1
  import type { EffectBinding } from './binding';
2
+ /** Undo/redo stacks of one effect, kept by the host while another effect is focused. */
3
+ export interface HistoryStacks {
4
+ undo: string[];
5
+ redo: string[];
6
+ }
2
7
  /**
3
8
  * Snapshot-based undo/redo over serialized effect JSON. Owned by the host (not the
4
9
  * binding) so history survives rebinding when a restore rebuilds the systems.
@@ -16,7 +21,13 @@ export declare class EffectHistory {
16
21
  get canUndo(): boolean;
17
22
  get canRedo(): boolean;
18
23
  clear(): void;
19
- /** Pushes an undo snapshot; rapid consecutive edits are coalesced into one step. */
24
+ /** The current stacks, to hand back to `restoreStacks` when this effect is focused again. */
25
+ saveStacks(): HistoryStacks;
26
+ restoreStacks(stacks: HistoryStacks | undefined): void;
27
+ /**
28
+ * Pushes an undo snapshot. A burst of edits — typing a number, dragging a curve handle or a
29
+ * slider — is one step: each edit within COALESCE_MS of the previous one extends the burst.
30
+ */
20
31
  capture(): void;
21
32
  /** Returns the previous effect state as parsed JSON, or null when nothing to undo. */
22
33
  undo(): unknown | null;
@@ -30,16 +30,29 @@ export class EffectHistory {
30
30
  this.redoStack = [];
31
31
  this.lastCaptureTime = 0;
32
32
  }
33
- /** Pushes an undo snapshot; rapid consecutive edits are coalesced into one step. */
33
+ /** The current stacks, to hand back to `restoreStacks` when this effect is focused again. */
34
+ saveStacks() {
35
+ return { undo: this.undoStack, redo: this.redoStack };
36
+ }
37
+ restoreStacks(stacks) {
38
+ this.undoStack = stacks?.undo ?? [];
39
+ this.redoStack = stacks?.redo ?? [];
40
+ this.lastCaptureTime = 0;
41
+ }
42
+ /**
43
+ * Pushes an undo snapshot. A burst of edits — typing a number, dragging a curve handle or a
44
+ * slider — is one step: each edit within COALESCE_MS of the previous one extends the burst.
45
+ */
34
46
  capture() {
35
47
  if (!this.binding) {
36
48
  return;
37
49
  }
38
50
  const now = Date.now();
39
- if (now - this.lastCaptureTime < COALESCE_MS && this.undoStack.length > 0) {
51
+ const continuesBurst = now - this.lastCaptureTime < COALESCE_MS && this.undoStack.length > 0;
52
+ this.lastCaptureTime = now;
53
+ if (continuesBurst) {
40
54
  return;
41
55
  }
42
- this.lastCaptureTime = now;
43
56
  this.undoStack.push(this.binding.exportJSON());
44
57
  if (this.undoStack.length > MAX_DEPTH) {
45
58
  this.undoStack.shift();
@@ -0,0 +1,35 @@
1
+ import type { ProjectMeta, ProjectStore, StorageEstimate, StoredAsset, StoredScene } from './projectStore';
2
+ /**
3
+ * Projects in the browser's IndexedDB. Texture data URIs are pulled out of the effect JSON into
4
+ * a per-project file store keyed by content hash — 50 effects sharing a texture store it once —
5
+ * and put back as data URIs on load, so the editor only ever sees self-contained effects.
6
+ */
7
+ export declare class IndexedDbProjectStore implements ProjectStore {
8
+ private dbPromise;
9
+ /** data URI → hash, so re-saving unchanged textures skips hashing. */
10
+ private readonly hashByDataUrl;
11
+ /** `${projectId}/${hash}` → data URI, so every effect sharing a file gets the same string. */
12
+ private readonly dataUrlByFile;
13
+ private db;
14
+ listProjects(): Promise<ProjectMeta[]>;
15
+ getProject(id: string): Promise<ProjectMeta | null>;
16
+ createProject(name: string): Promise<ProjectMeta>;
17
+ renameProject(id: string, name: string): Promise<void>;
18
+ duplicateProject(id: string, name: string): Promise<ProjectMeta>;
19
+ deleteProject(id: string): Promise<void>;
20
+ loadAssets(projectId: string): Promise<StoredAsset[]>;
21
+ putAssets(projectId: string, assets: StoredAsset[]): Promise<void>;
22
+ deleteAssets(projectId: string, paths: string[]): Promise<void>;
23
+ loadScene(projectId: string): Promise<StoredScene | null>;
24
+ saveScene(projectId: string, scene: StoredScene): Promise<void>;
25
+ estimate(): Promise<StorageEstimate>;
26
+ requestPersistence(): Promise<boolean>;
27
+ /** Clones `json` with each texture data URI replaced by a `quarks-file:<hash>` reference. */
28
+ private pack;
29
+ /** Puts data URIs back in place of file references. */
30
+ private unpack;
31
+ private readFile;
32
+ private writeFiles;
33
+ /** Drops files nothing references any more and updates the project's size/count. */
34
+ private refresh;
35
+ }
@@ -0,0 +1,299 @@
1
+ const DB_NAME = 'babylon-quarks-editor';
2
+ const DB_VERSION = 1;
3
+ const FILE_REF = 'quarks-file:';
4
+ const FILE_REF_PATTERN = /quarks-file:([0-9a-z]+)/g;
5
+ function request(req) {
6
+ return new Promise((resolve, reject) => {
7
+ req.onsuccess = () => resolve(req.result);
8
+ req.onerror = () => reject(req.error);
9
+ });
10
+ }
11
+ function done(tx) {
12
+ return new Promise((resolve, reject) => {
13
+ tx.oncomplete = () => resolve();
14
+ tx.onerror = () => reject(tx.error);
15
+ tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'));
16
+ });
17
+ }
18
+ function newId() {
19
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
20
+ ? crypto.randomUUID()
21
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
22
+ }
23
+ /** Content hash of a data URI, so the same texture is stored once per project. */
24
+ async function hashString(text) {
25
+ if (typeof crypto !== 'undefined' && crypto.subtle) {
26
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
27
+ return Array.from(new Uint8Array(digest).slice(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
28
+ }
29
+ // Insecure contexts have no crypto.subtle: a 53-bit string hash (cyrb53) twice, salted.
30
+ const cyrb53 = (seed) => {
31
+ let h1 = 0xdeadbeef ^ seed;
32
+ let h2 = 0x41c6ce57 ^ seed;
33
+ for (let i = 0; i < text.length; i++) {
34
+ const ch = text.charCodeAt(i);
35
+ h1 = Math.imul(h1 ^ ch, 2654435761);
36
+ h2 = Math.imul(h2 ^ ch, 1597334677);
37
+ }
38
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
39
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
40
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
41
+ };
42
+ return `${cyrb53(1)}${cyrb53(2)}`;
43
+ }
44
+ function blobToDataUrl(blob) {
45
+ return new Promise((resolve, reject) => {
46
+ const reader = new FileReader();
47
+ reader.onload = () => resolve(reader.result);
48
+ reader.onerror = () => reject(reader.error);
49
+ reader.readAsDataURL(blob);
50
+ });
51
+ }
52
+ function imagesOf(json) {
53
+ const images = json?.images;
54
+ return Array.isArray(images) ? images : [];
55
+ }
56
+ /**
57
+ * Projects in the browser's IndexedDB. Texture data URIs are pulled out of the effect JSON into
58
+ * a per-project file store keyed by content hash — 50 effects sharing a texture store it once —
59
+ * and put back as data URIs on load, so the editor only ever sees self-contained effects.
60
+ */
61
+ export class IndexedDbProjectStore {
62
+ constructor() {
63
+ this.dbPromise = null;
64
+ /** data URI → hash, so re-saving unchanged textures skips hashing. */
65
+ this.hashByDataUrl = new Map();
66
+ /** `${projectId}/${hash}` → data URI, so every effect sharing a file gets the same string. */
67
+ this.dataUrlByFile = new Map();
68
+ }
69
+ db() {
70
+ if (!this.dbPromise) {
71
+ this.dbPromise = new Promise((resolve, reject) => {
72
+ const open = indexedDB.open(DB_NAME, DB_VERSION);
73
+ open.onupgradeneeded = () => {
74
+ const db = open.result;
75
+ db.createObjectStore('projects', { keyPath: 'id' });
76
+ db.createObjectStore('assets', { keyPath: ['projectId', 'path'] }).createIndex('projectId', 'projectId');
77
+ db.createObjectStore('files', { keyPath: ['projectId', 'hash'] }).createIndex('projectId', 'projectId');
78
+ db.createObjectStore('scenes', { keyPath: 'projectId' });
79
+ };
80
+ open.onsuccess = () => resolve(open.result);
81
+ open.onerror = () => reject(open.error);
82
+ });
83
+ }
84
+ return this.dbPromise;
85
+ }
86
+ // ---- projects -------------------------------------------------------------------------
87
+ async listProjects() {
88
+ const db = await this.db();
89
+ const all = await request(db.transaction('projects').objectStore('projects').getAll());
90
+ return all.sort((a, b) => b.updatedAt - a.updatedAt);
91
+ }
92
+ async getProject(id) {
93
+ const db = await this.db();
94
+ return ((await request(db.transaction('projects').objectStore('projects').get(id))) ?? null);
95
+ }
96
+ async createProject(name) {
97
+ const now = Date.now();
98
+ const meta = { id: newId(), name, createdAt: now, updatedAt: now, assetCount: 0, sizeBytes: 0 };
99
+ const db = await this.db();
100
+ const tx = db.transaction('projects', 'readwrite');
101
+ tx.objectStore('projects').put(meta);
102
+ await done(tx);
103
+ return meta;
104
+ }
105
+ async renameProject(id, name) {
106
+ const meta = await this.getProject(id);
107
+ if (!meta)
108
+ return;
109
+ const db = await this.db();
110
+ const tx = db.transaction('projects', 'readwrite');
111
+ tx.objectStore('projects').put({ ...meta, name, updatedAt: Date.now() });
112
+ await done(tx);
113
+ }
114
+ async duplicateProject(id, name) {
115
+ const source = await this.getProject(id);
116
+ if (!source)
117
+ throw new Error('Project not found.');
118
+ const db = await this.db();
119
+ const read = db.transaction(['assets', 'files', 'scenes']);
120
+ const [assets, files, scene] = await Promise.all([
121
+ request(read.objectStore('assets').index('projectId').getAll(id)),
122
+ request(read.objectStore('files').index('projectId').getAll(id)),
123
+ request(read.objectStore('scenes').get(id)),
124
+ ]);
125
+ const now = Date.now();
126
+ const meta = { ...source, id: newId(), name, createdAt: now, updatedAt: now };
127
+ const tx = db.transaction(['projects', 'assets', 'files', 'scenes'], 'readwrite');
128
+ tx.objectStore('projects').put(meta);
129
+ for (const asset of assets)
130
+ tx.objectStore('assets').put({ ...asset, projectId: meta.id });
131
+ for (const file of files)
132
+ tx.objectStore('files').put({ ...file, projectId: meta.id });
133
+ if (scene)
134
+ tx.objectStore('scenes').put({ ...scene, projectId: meta.id });
135
+ await done(tx);
136
+ return meta;
137
+ }
138
+ async deleteProject(id) {
139
+ const db = await this.db();
140
+ const tx = db.transaction(['projects', 'assets', 'files', 'scenes'], 'readwrite');
141
+ tx.objectStore('projects').delete(id);
142
+ tx.objectStore('scenes').delete(id);
143
+ for (const name of ['assets', 'files']) {
144
+ const keys = await request(tx.objectStore(name).index('projectId').getAllKeys(id));
145
+ for (const key of keys)
146
+ tx.objectStore(name).delete(key);
147
+ }
148
+ await done(tx);
149
+ for (const key of [...this.dataUrlByFile.keys()]) {
150
+ if (key.startsWith(`${id}/`))
151
+ this.dataUrlByFile.delete(key);
152
+ }
153
+ }
154
+ // ---- assets ---------------------------------------------------------------------------
155
+ async loadAssets(projectId) {
156
+ const db = await this.db();
157
+ const records = await request(db.transaction('assets').objectStore('assets').index('projectId').getAll(projectId));
158
+ return Promise.all(records.map(async ({ path, name, json, missingFiles }) => ({
159
+ path,
160
+ name,
161
+ json: await this.unpack(projectId, json),
162
+ missingFiles,
163
+ })));
164
+ }
165
+ async putAssets(projectId, assets) {
166
+ const files = new Map();
167
+ const packed = await Promise.all(assets.map(async (asset) => ({ ...asset, projectId, json: await this.pack(asset.json, files) })));
168
+ const db = await this.db();
169
+ const tx = db.transaction(['assets', 'files'], 'readwrite');
170
+ for (const record of packed)
171
+ tx.objectStore('assets').put(record);
172
+ this.writeFiles(tx, projectId, files);
173
+ await done(tx);
174
+ await this.refresh(projectId);
175
+ }
176
+ async deleteAssets(projectId, paths) {
177
+ const db = await this.db();
178
+ const tx = db.transaction('assets', 'readwrite');
179
+ for (const path of paths)
180
+ tx.objectStore('assets').delete([projectId, path]);
181
+ await done(tx);
182
+ await this.refresh(projectId);
183
+ }
184
+ // ---- scene ----------------------------------------------------------------------------
185
+ async loadScene(projectId) {
186
+ const db = await this.db();
187
+ const record = await request(db.transaction('scenes').objectStore('scenes').get(projectId));
188
+ if (!record)
189
+ return null;
190
+ const entries = await Promise.all(record.entries.map(async (entry) => ({ ...entry, json: await this.unpack(projectId, entry.json) })));
191
+ return { entries, savedAt: record.savedAt };
192
+ }
193
+ async saveScene(projectId, scene) {
194
+ const files = new Map();
195
+ const entries = await Promise.all(scene.entries.map(async (entry) => ({ ...entry, json: await this.pack(entry.json, files) })));
196
+ const db = await this.db();
197
+ const tx = db.transaction(['scenes', 'files'], 'readwrite');
198
+ tx.objectStore('scenes').put({ projectId, entries, savedAt: scene.savedAt });
199
+ this.writeFiles(tx, projectId, files);
200
+ await done(tx);
201
+ await this.refresh(projectId);
202
+ }
203
+ // ---- storage ----------------------------------------------------------------------------
204
+ async estimate() {
205
+ const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
206
+ const estimate = storage?.estimate ? await storage.estimate() : undefined;
207
+ const persisted = storage?.persisted ? await storage.persisted() : false;
208
+ return { usage: estimate?.usage ?? null, quota: estimate?.quota ?? null, persisted };
209
+ }
210
+ async requestPersistence() {
211
+ const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
212
+ return storage?.persist ? storage.persist() : false;
213
+ }
214
+ // ---- files ------------------------------------------------------------------------------
215
+ /** Clones `json` with each texture data URI replaced by a `quarks-file:<hash>` reference. */
216
+ async pack(json, files) {
217
+ const clone = structuredClone(json);
218
+ for (const image of imagesOf(clone)) {
219
+ const url = image.url;
220
+ if (typeof url !== 'string' || !url.startsWith('data:'))
221
+ continue;
222
+ let hash = this.hashByDataUrl.get(url);
223
+ if (!hash) {
224
+ hash = await hashString(url);
225
+ this.hashByDataUrl.set(url, hash);
226
+ }
227
+ if (!files.has(hash)) {
228
+ files.set(hash, await (await fetch(url)).blob());
229
+ }
230
+ image.url = FILE_REF + hash;
231
+ }
232
+ return clone;
233
+ }
234
+ /** Puts data URIs back in place of file references. */
235
+ async unpack(projectId, json) {
236
+ for (const image of imagesOf(json)) {
237
+ const url = image.url;
238
+ if (typeof url !== 'string' || !url.startsWith(FILE_REF))
239
+ continue;
240
+ const dataUrl = await this.readFile(projectId, url.slice(FILE_REF.length));
241
+ image.url = dataUrl ?? '';
242
+ }
243
+ return json;
244
+ }
245
+ readFile(projectId, hash) {
246
+ const key = `${projectId}/${hash}`;
247
+ let pending = this.dataUrlByFile.get(key);
248
+ if (!pending) {
249
+ pending = (async () => {
250
+ const db = await this.db();
251
+ const record = await request(db.transaction('files').objectStore('files').get([projectId, hash]));
252
+ if (!record)
253
+ return null;
254
+ const dataUrl = await blobToDataUrl(record.blob);
255
+ this.hashByDataUrl.set(dataUrl, hash);
256
+ return dataUrl;
257
+ })();
258
+ this.dataUrlByFile.set(key, pending);
259
+ }
260
+ return pending;
261
+ }
262
+ writeFiles(tx, projectId, files) {
263
+ for (const [hash, blob] of files) {
264
+ tx.objectStore('files').put({ projectId, hash, blob });
265
+ }
266
+ }
267
+ /** Drops files nothing references any more and updates the project's size/count. */
268
+ async refresh(projectId) {
269
+ const db = await this.db();
270
+ const tx = db.transaction(['projects', 'assets', 'files', 'scenes'], 'readwrite');
271
+ const [meta, assets, files, scene] = await Promise.all([
272
+ request(tx.objectStore('projects').get(projectId)),
273
+ request(tx.objectStore('assets').index('projectId').getAll(projectId)),
274
+ request(tx.objectStore('files').index('projectId').getAll(projectId)),
275
+ request(tx.objectStore('scenes').get(projectId)),
276
+ ]);
277
+ const text = JSON.stringify([assets.map((asset) => asset.json), scene?.entries ?? []]);
278
+ const referenced = new Set([...text.matchAll(FILE_REF_PATTERN)].map((match) => match[1]));
279
+ let size = text.length;
280
+ for (const file of files) {
281
+ if (referenced.has(file.hash)) {
282
+ size += file.blob.size;
283
+ }
284
+ else {
285
+ tx.objectStore('files').delete([projectId, file.hash]);
286
+ this.dataUrlByFile.delete(`${projectId}/${file.hash}`);
287
+ }
288
+ }
289
+ if (meta) {
290
+ tx.objectStore('projects').put({
291
+ ...meta,
292
+ assetCount: assets.length,
293
+ sizeBytes: size,
294
+ updatedAt: Date.now(),
295
+ });
296
+ }
297
+ await done(tx);
298
+ }
299
+ }
@@ -0,0 +1,2 @@
1
+ import '@babylonjs/core/Shaders/color.fragment';
2
+ import '@babylonjs/core/Shaders/color.vertex';
@@ -0,0 +1,7 @@
1
+ // LinesMesh loads its "color" shader with a dynamic import. Under Vite's dev pre-bundling that
2
+ // import can resolve to a second copy of Babylon's ShaderStore, so the shader registers where
3
+ // Babylon never looks, it falls back to fetching `src/Shaders/color.fragment.fx`, gets the
4
+ // page's HTML back and the selection rings never render. Registering the shader up front, from
5
+ // the same module graph as the rest of the editor, makes the lookup succeed either way.
6
+ import '@babylonjs/core/Shaders/color.fragment';
7
+ import '@babylonjs/core/Shaders/color.vertex';
@@ -1,6 +1,6 @@
1
- import type { BatchedRenderer, ParticleSystem } from 'babylon.quarks';
2
- import type { Scene } from '@babylonjs/core/scene';
3
1
  import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
2
+ import type { Scene } from '@babylonjs/core/scene';
3
+ import type { BatchedRenderer, ParticleSystem } from 'babylon.quarks';
4
4
  export interface LoadEffectOptions {
5
5
  /** Registers systems with the batched renderer (default true). */
6
6
  registerRenderer?: boolean;