babylon.quarks-editor 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/dist/core/assetDragSession.d.ts +4 -0
- package/dist/core/assetDragSession.js +8 -0
- package/dist/core/assetTree.d.ts +39 -0
- package/dist/core/assetTree.js +112 -0
- package/dist/core/assets.d.ts +56 -0
- package/dist/core/assets.js +229 -0
- package/dist/core/binding.d.ts +5 -0
- package/dist/core/binding.js +7 -0
- package/dist/core/collision.d.ts +4 -5
- package/dist/core/collision.js +4 -5
- package/dist/core/defaultEffect.d.ts +4 -5
- package/dist/core/defaultEffect.js +5 -14
- package/dist/core/emitterShapeWireframe.d.ts +1 -0
- package/dist/core/emitterShapeWireframe.js +1 -0
- package/dist/core/galleryPlayback.d.ts +7 -7
- package/dist/core/galleryPlayback.js +2 -2
- package/dist/core/galleryScene.d.ts +8 -13
- package/dist/core/galleryScene.js +14 -42
- package/dist/core/gallerySelectionMarker.d.ts +1 -0
- package/dist/core/gallerySelectionMarker.js +1 -0
- package/dist/core/history.d.ts +12 -1
- package/dist/core/history.js +16 -3
- package/dist/core/indexedDbProjectStore.d.ts +35 -0
- package/dist/core/indexedDbProjectStore.js +299 -0
- package/dist/core/lineShaders.d.ts +2 -0
- package/dist/core/lineShaders.js +7 -0
- package/dist/core/pickAssetFiles.d.ts +6 -0
- package/dist/core/{pickGalleryFiles.js → pickAssetFiles.js} +13 -8
- package/dist/core/projectStore.d.ts +61 -0
- package/dist/core/projectStore.js +14 -0
- package/dist/core/sceneEntries.d.ts +35 -0
- package/dist/core/sceneEntries.js +62 -0
- package/dist/core/transport.d.ts +12 -0
- package/dist/core/transport.js +36 -0
- package/dist/core/viewportPicker.d.ts +4 -4
- package/dist/core/viewportPicker.js +3 -3
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/react/AssetsPanel.d.ts +22 -0
- package/dist/react/AssetsPanel.js +252 -0
- package/dist/react/ChoiceDialog.d.ts +18 -0
- package/dist/react/ChoiceDialog.js +48 -0
- package/dist/react/ContextMenu.d.ts +16 -0
- package/dist/react/ContextMenu.js +70 -0
- package/dist/react/EditorWorkspace.d.ts +21 -0
- package/dist/react/EditorWorkspace.js +63 -0
- package/dist/react/EffectEditor.d.ts +1 -1
- package/dist/react/EffectEditor.js +7 -4
- package/dist/react/EffectEditorHost.d.ts +19 -2
- package/dist/react/EffectEditorHost.js +1385 -607
- package/dist/react/EffectTreeView.d.ts +17 -0
- package/dist/react/EffectTreeView.js +52 -0
- package/dist/react/ObjectModule.d.ts +4 -0
- package/dist/react/ObjectModule.js +7 -10
- package/dist/react/ProjectsPage.d.ts +9 -0
- package/dist/react/ProjectsPage.js +140 -0
- package/dist/react/ScenePanel.d.ts +26 -0
- package/dist/react/ScenePanel.js +134 -0
- package/dist/react/TimelinePanel.d.ts +8 -1
- package/dist/react/TimelinePanel.js +20 -41
- package/dist/react/TransportBar.d.ts +24 -0
- package/dist/react/TransportBar.js +43 -0
- package/dist/react/behaviorModules.js +5 -7
- package/dist/react/index.d.ts +16 -2
- package/dist/react/index.js +8 -1
- package/dist/react/inspectorHints.js +1 -2
- package/package.json +1 -1
- package/src/core/assetDragSession.ts +12 -0
- package/src/core/assetTree.ts +148 -0
- package/src/core/assets.ts +285 -0
- package/src/core/binding.ts +8 -0
- package/src/core/collision.ts +4 -5
- package/src/core/defaultEffect.ts +9 -22
- package/src/core/emitterShapeWireframe.ts +1 -0
- package/src/core/galleryPlayback.ts +9 -9
- package/src/core/galleryScene.ts +15 -59
- package/src/core/gallerySelectionMarker.ts +1 -0
- package/src/core/history.ts +24 -3
- package/src/core/indexedDbProjectStore.ts +357 -0
- package/src/core/lineShaders.ts +7 -0
- package/src/core/{pickGalleryFiles.ts → pickAssetFiles.ts} +13 -8
- package/src/core/projectStore.ts +81 -0
- package/src/core/sceneEntries.ts +96 -0
- package/src/core/transport.ts +53 -0
- package/src/core/viewportPicker.ts +5 -5
- package/src/index.ts +11 -0
- package/src/react/AssetsPanel.tsx +559 -0
- package/src/react/ChoiceDialog.tsx +93 -0
- package/src/react/ContextMenu.tsx +107 -0
- package/src/react/EditorWorkspace.tsx +95 -0
- package/src/react/EffectEditor.tsx +15 -4
- package/src/react/EffectEditorHost.tsx +1922 -813
- package/src/react/EffectTreeView.tsx +117 -0
- package/src/react/ObjectModule.tsx +17 -13
- package/src/react/ProjectsPage.tsx +306 -0
- package/src/react/ScenePanel.tsx +318 -0
- package/src/react/TimelinePanel.tsx +102 -103
- package/src/react/TransportBar.tsx +114 -0
- package/src/react/behaviorModules.tsx +7 -15
- package/src/react/index.ts +16 -2
- package/src/react/inspectorHints.ts +1 -2
- package/dist/core/galleryDragSession.d.ts +0 -4
- package/dist/core/galleryDragSession.js +0 -8
- package/dist/core/galleryTree.d.ts +0 -49
- package/dist/core/galleryTree.js +0 -141
- package/dist/core/loadEffectGallery.d.ts +0 -36
- package/dist/core/loadEffectGallery.js +0 -97
- package/dist/core/pickGalleryFiles.d.ts +0 -5
- package/dist/react/GalleryPanel.d.ts +0 -20
- package/dist/react/GalleryPanel.js +0 -253
- package/src/core/galleryDragSession.ts +0 -12
- package/src/core/galleryTree.ts +0 -190
- package/src/core/loadEffectGallery.ts +0 -150
- package/src/react/GalleryPanel.tsx +0 -561
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
/** Freezes every
|
|
3
|
-
export declare function applyGalleryPlayback(entries:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
3
|
+
return previewRootIds.has(entry.root.uniqueId);
|
|
4
4
|
}
|
|
5
|
-
/** Freezes every
|
|
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
|
-
import type { TransformNode } from '@babylonjs/core/Meshes/transformNode';
|
|
3
2
|
import type { Scene } from '@babylonjs/core/scene';
|
|
4
3
|
import type { BatchedRenderer } from 'babylon.quarks';
|
|
5
|
-
import type {
|
|
6
|
-
/** Clears live particles and freezes simulation for one
|
|
7
|
-
export declare function
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
4
|
-
export function
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (!
|
|
17
|
-
|
|
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
|
-
/**
|
|
25
|
-
export function
|
|
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
|
-
|
|
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;
|
package/dist/core/history.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/core/history.js
CHANGED
|
@@ -30,16 +30,29 @@ export class EffectHistory {
|
|
|
30
30
|
this.redoStack = [];
|
|
31
31
|
this.lastCaptureTime = 0;
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
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
|
-
|
|
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,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';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opens a native folder picker when the host supports it. Returns `null` when the user
|
|
3
|
+
* cancels, or `undefined` when the API is unavailable (caller should fall back to
|
|
4
|
+
* `<input webkitdirectory>`).
|
|
5
|
+
*/
|
|
6
|
+
export declare function pickAssetFolderFiles(): Promise<File[] | null | undefined>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
3
|
-
|
|
1
|
+
import { isAssetImportFile, relativePathOf } from './assets';
|
|
2
|
+
/**
|
|
3
|
+
* Recursively collects effect `.json` files and the image files they may reference from a
|
|
4
|
+
* directory handle (File System Access API).
|
|
5
|
+
*/
|
|
6
|
+
async function readAssetFilesFromDirectory(dir, prefix = '') {
|
|
4
7
|
const files = [];
|
|
5
8
|
for await (const [name, entry] of dir.entries()) {
|
|
6
9
|
if (entry.kind === 'file') {
|
|
7
|
-
if (!name
|
|
10
|
+
if (!isAssetImportFile(name))
|
|
8
11
|
continue;
|
|
9
12
|
const file = await entry.getFile();
|
|
10
13
|
Object.defineProperty(file, 'webkitRelativePath', {
|
|
@@ -15,23 +18,25 @@ async function readJsonFilesFromDirectory(dir, prefix = '') {
|
|
|
15
18
|
continue;
|
|
16
19
|
}
|
|
17
20
|
if (entry.kind === 'directory') {
|
|
18
|
-
files.push(...(await
|
|
21
|
+
files.push(...(await readAssetFilesFromDirectory(entry, `${prefix}${name}/`)));
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
return files;
|
|
22
25
|
}
|
|
23
26
|
/**
|
|
24
27
|
* Opens a native folder picker when the host supports it. Returns `null` when the user
|
|
25
|
-
* cancels, or when the API is unavailable (caller should fall back to
|
|
28
|
+
* cancels, or `undefined` when the API is unavailable (caller should fall back to
|
|
29
|
+
* `<input webkitdirectory>`).
|
|
26
30
|
*/
|
|
27
|
-
export async function
|
|
31
|
+
export async function pickAssetFolderFiles() {
|
|
28
32
|
const picker = window.showDirectoryPicker;
|
|
29
33
|
if (typeof picker !== 'function') {
|
|
30
34
|
return undefined;
|
|
31
35
|
}
|
|
32
36
|
try {
|
|
33
37
|
const dir = (await picker.call(window, { mode: 'read' }));
|
|
34
|
-
|
|
38
|
+
const files = await readAssetFilesFromDirectory(dir);
|
|
39
|
+
return files.sort((a, b) => relativePathOf(a).localeCompare(relativePathOf(b)));
|
|
35
40
|
}
|
|
36
41
|
catch (err) {
|
|
37
42
|
if (err instanceof DOMException && err.name === 'AbortError') {
|