babylon.quarks-editor 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -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 +207 -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 +262 -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,13 +1,13 @@
|
|
|
1
1
|
import {QuarksUtil} from 'babylon.quarks';
|
|
2
|
-
import type {
|
|
2
|
+
import type {SceneEntry} from './sceneEntries';
|
|
3
3
|
|
|
4
|
-
function isPreviewEntry(entry:
|
|
5
|
-
return
|
|
4
|
+
function isPreviewEntry(entry: SceneEntry, previewRootIds: ReadonlySet<number>): boolean {
|
|
5
|
+
return previewRootIds.has(entry.root.uniqueId);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
/** Freezes every
|
|
8
|
+
/** Freezes every scene entry; unpauses the visible ones (the preview set) when transport is playing. */
|
|
9
9
|
export function applyGalleryPlayback(
|
|
10
|
-
entries:
|
|
10
|
+
entries: SceneEntry[],
|
|
11
11
|
previewRootIds: ReadonlySet<number>,
|
|
12
12
|
transportPlaying: boolean,
|
|
13
13
|
focusFinishedRootIds: ReadonlySet<number> = new Set()
|
|
@@ -36,17 +36,17 @@ export function applyGalleryPlayback(
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Whether any preview effect on stage should keep simulating. */
|
|
39
|
-
export function hasActivePreview(entries:
|
|
39
|
+
export function hasActivePreview(entries: SceneEntry[], previewRootIds: ReadonlySet<number>): boolean {
|
|
40
40
|
return entries.some((entry) => isPreviewEntry(entry, previewRootIds));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** In-scene preview effects currently in the preview set. */
|
|
44
|
-
export function countActivePreviews(entries:
|
|
44
|
+
export function countActivePreviews(entries: SceneEntry[], previewRootIds: ReadonlySet<number>): number {
|
|
45
45
|
return entries.filter((entry) => isPreviewEntry(entry, previewRootIds)).length;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** Particle count across all active preview effects. */
|
|
49
|
-
export function countGalleryParticles(entries:
|
|
49
|
+
export function countGalleryParticles(entries: SceneEntry[], previewRootIds: ReadonlySet<number>): number {
|
|
50
50
|
let count = 0;
|
|
51
51
|
for (const entry of entries) {
|
|
52
52
|
if (!isPreviewEntry(entry, previewRootIds)) {
|
|
@@ -64,7 +64,7 @@ export function countGalleryParticles(entries: GalleryEntry[], previewRootIds: R
|
|
|
64
64
|
* Returns true when any entry was paused (caller should refresh renderer batches).
|
|
65
65
|
*/
|
|
66
66
|
export function pauseFinishedPreviews(
|
|
67
|
-
entries:
|
|
67
|
+
entries: SceneEntry[],
|
|
68
68
|
previewRootIds: ReadonlySet<number>,
|
|
69
69
|
focusFinishedRootIds: ReadonlySet<number> = new Set()
|
|
70
70
|
): boolean {
|
package/src/core/galleryScene.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
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
4
|
import {DEFAULT_GALLERY_SPACING, galleryGridPosition} from './galleryLayout';
|
|
6
|
-
import type {
|
|
5
|
+
import type {SceneEntry} from './sceneEntries';
|
|
7
6
|
|
|
8
|
-
/** Clears live particles and freezes simulation for one
|
|
9
|
-
export function
|
|
7
|
+
/** Clears live particles and freezes simulation for one scene entry. */
|
|
8
|
+
export function clearSceneEntrySimulation(entry: SceneEntry, renderer: BatchedRenderer): void {
|
|
10
9
|
for (const system of entry.systems) {
|
|
11
10
|
system.restart();
|
|
12
11
|
system.pause();
|
|
@@ -14,59 +13,25 @@ export function clearGalleryEntrySimulation(entry: GalleryEntry, renderer: Batch
|
|
|
14
13
|
renderer.refreshBatches();
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (const system of entry.systems) {
|
|
25
|
-
renderer.addSystem(system);
|
|
26
|
-
system.pause();
|
|
27
|
-
}
|
|
28
|
-
entry.inScene = true;
|
|
16
|
+
/**
|
|
17
|
+
* Shows or hides a scene entry. A hidden entry keeps its place in the scene and stays editable,
|
|
18
|
+
* but has no particles, is not simulated and can't be picked in the viewport.
|
|
19
|
+
*/
|
|
20
|
+
export function setSceneEntryVisible(entry: SceneEntry, renderer: BatchedRenderer, visible: boolean): void {
|
|
21
|
+
if (!visible) {
|
|
22
|
+
clearSceneEntrySimulation(entry, renderer);
|
|
29
23
|
}
|
|
24
|
+
entry.root.setEnabled(visible);
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
/**
|
|
33
|
-
export function
|
|
34
|
-
entries: GalleryEntry[],
|
|
35
|
-
renderer: BatchedRenderer,
|
|
36
|
-
center: Vector3,
|
|
37
|
-
spacing = DEFAULT_GALLERY_SPACING
|
|
38
|
-
): void {
|
|
27
|
+
/** Lays out a batch of new entries in a square grid centered on `center`. */
|
|
28
|
+
export function placeSceneEntriesAt(entries: SceneEntry[], center: Vector3, spacing = DEFAULT_GALLERY_SPACING): void {
|
|
39
29
|
entries.forEach((entry, index) => {
|
|
40
|
-
const offset = galleryGridPosition(index, entries.length, spacing);
|
|
41
|
-
|
|
42
|
-
placeGalleryEntryInScene(entry, renderer, position);
|
|
30
|
+
const offset = entries.length === 1 ? {x: 0, z: 0} : galleryGridPosition(index, entries.length, spacing);
|
|
31
|
+
entry.root.position.copyFrom(center.add(new Vector3(offset.x, 0, offset.z)));
|
|
43
32
|
});
|
|
44
33
|
}
|
|
45
34
|
|
|
46
|
-
/**
|
|
47
|
-
* Unregisters a placed effect from the renderer and returns it to the hidden catalog.
|
|
48
|
-
* Systems stay alive for inspector editing; drag into the viewport to place again.
|
|
49
|
-
*/
|
|
50
|
-
export function removeGalleryEntryFromScene(
|
|
51
|
-
entry: GalleryEntry,
|
|
52
|
-
renderer: BatchedRenderer,
|
|
53
|
-
catalogRoot: TransformNode
|
|
54
|
-
): void {
|
|
55
|
-
if (!entry.inScene) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
clearGalleryEntrySimulation(entry, renderer);
|
|
60
|
-
|
|
61
|
-
for (const system of entry.systems) {
|
|
62
|
-
renderer.deleteSystem(system);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
entry.root.parent = catalogRoot;
|
|
66
|
-
entry.root.setEnabled(false);
|
|
67
|
-
entry.inScene = false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
35
|
/** Raycasts the editor ground plane from canvas/client coordinates. */
|
|
71
36
|
export function pickGroundPosition(scene: Scene, clientX: number, clientY: number): Vector3 {
|
|
72
37
|
const canvas = scene.getEngine().getRenderingCanvas();
|
|
@@ -77,12 +42,3 @@ export function pickGroundPosition(scene: Scene, clientX: number, clientY: numbe
|
|
|
77
42
|
const pick = scene.pick(clientX - rect.left, clientY - rect.top, (mesh) => mesh.name === 'ground');
|
|
78
43
|
return pick?.pickedPoint?.clone() ?? Vector3.Zero();
|
|
79
44
|
}
|
|
80
|
-
|
|
81
|
-
/** Finds a gallery entry by Babylon node id from a drag payload. */
|
|
82
|
-
export function findGalleryEntryById(entries: GalleryEntry[], id: string): GalleryEntry | undefined {
|
|
83
|
-
const numericId = Number(id);
|
|
84
|
-
if (!Number.isFinite(numericId)) {
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
return entries.find((entry) => entry.root.uniqueId === numericId);
|
|
88
|
-
}
|
|
@@ -4,6 +4,7 @@ import {LinesMesh} from '@babylonjs/core/Meshes/linesMesh';
|
|
|
4
4
|
import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder';
|
|
5
5
|
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
6
6
|
import type {Scene} from '@babylonjs/core/scene';
|
|
7
|
+
import './lineShaders';
|
|
7
8
|
|
|
8
9
|
export const GROUND_RING_RADIUS = 1.2;
|
|
9
10
|
const RING_SEGMENTS = 64;
|
package/src/core/history.ts
CHANGED
|
@@ -3,6 +3,12 @@ import type {EffectBinding} from './binding';
|
|
|
3
3
|
const COALESCE_MS = 400;
|
|
4
4
|
const MAX_DEPTH = 50;
|
|
5
5
|
|
|
6
|
+
/** Undo/redo stacks of one effect, kept by the host while another effect is focused. */
|
|
7
|
+
export interface HistoryStacks {
|
|
8
|
+
undo: string[];
|
|
9
|
+
redo: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
13
|
* Snapshot-based undo/redo over serialized effect JSON. Owned by the host (not the
|
|
8
14
|
* binding) so history survives rebinding when a restore rebuilds the systems.
|
|
@@ -36,16 +42,31 @@ export class EffectHistory {
|
|
|
36
42
|
this.lastCaptureTime = 0;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
/**
|
|
45
|
+
/** The current stacks, to hand back to `restoreStacks` when this effect is focused again. */
|
|
46
|
+
saveStacks(): HistoryStacks {
|
|
47
|
+
return {undo: this.undoStack, redo: this.redoStack};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
restoreStacks(stacks: HistoryStacks | undefined): void {
|
|
51
|
+
this.undoStack = stacks?.undo ?? [];
|
|
52
|
+
this.redoStack = stacks?.redo ?? [];
|
|
53
|
+
this.lastCaptureTime = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pushes an undo snapshot. A burst of edits — typing a number, dragging a curve handle or a
|
|
58
|
+
* slider — is one step: each edit within COALESCE_MS of the previous one extends the burst.
|
|
59
|
+
*/
|
|
40
60
|
capture(): void {
|
|
41
61
|
if (!this.binding) {
|
|
42
62
|
return;
|
|
43
63
|
}
|
|
44
64
|
const now = Date.now();
|
|
45
|
-
|
|
65
|
+
const continuesBurst = now - this.lastCaptureTime < COALESCE_MS && this.undoStack.length > 0;
|
|
66
|
+
this.lastCaptureTime = now;
|
|
67
|
+
if (continuesBurst) {
|
|
46
68
|
return;
|
|
47
69
|
}
|
|
48
|
-
this.lastCaptureTime = now;
|
|
49
70
|
this.undoStack.push(this.binding.exportJSON());
|
|
50
71
|
if (this.undoStack.length > MAX_DEPTH) {
|
|
51
72
|
this.undoStack.shift();
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProjectMeta,
|
|
3
|
+
ProjectStore,
|
|
4
|
+
StorageEstimate,
|
|
5
|
+
StoredAsset,
|
|
6
|
+
StoredScene,
|
|
7
|
+
StoredSceneEntry,
|
|
8
|
+
} from './projectStore';
|
|
9
|
+
|
|
10
|
+
const DB_NAME = 'babylon-quarks-editor';
|
|
11
|
+
const DB_VERSION = 1;
|
|
12
|
+
const FILE_REF = 'quarks-file:';
|
|
13
|
+
const FILE_REF_PATTERN = /quarks-file:([0-9a-z]+)/g;
|
|
14
|
+
|
|
15
|
+
interface AssetRecord extends StoredAsset {
|
|
16
|
+
projectId: string;
|
|
17
|
+
}
|
|
18
|
+
interface FileRecord {
|
|
19
|
+
projectId: string;
|
|
20
|
+
hash: string;
|
|
21
|
+
blob: Blob;
|
|
22
|
+
}
|
|
23
|
+
interface SceneRecord extends StoredScene {
|
|
24
|
+
projectId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function request<T>(req: IDBRequest<T>): Promise<T> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
req.onsuccess = () => resolve(req.result);
|
|
30
|
+
req.onerror = () => reject(req.error);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function done(tx: IDBTransaction): Promise<void> {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
tx.oncomplete = () => resolve();
|
|
37
|
+
tx.onerror = () => reject(tx.error);
|
|
38
|
+
tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted'));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function newId(): string {
|
|
43
|
+
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
44
|
+
? crypto.randomUUID()
|
|
45
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Content hash of a data URI, so the same texture is stored once per project. */
|
|
49
|
+
async function hashString(text: string): Promise<string> {
|
|
50
|
+
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
51
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
52
|
+
return Array.from(new Uint8Array(digest).slice(0, 16), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
53
|
+
}
|
|
54
|
+
// Insecure contexts have no crypto.subtle: a 53-bit string hash (cyrb53) twice, salted.
|
|
55
|
+
const cyrb53 = (seed: number) => {
|
|
56
|
+
let h1 = 0xdeadbeef ^ seed;
|
|
57
|
+
let h2 = 0x41c6ce57 ^ seed;
|
|
58
|
+
for (let i = 0; i < text.length; i++) {
|
|
59
|
+
const ch = text.charCodeAt(i);
|
|
60
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
61
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
62
|
+
}
|
|
63
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
64
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
65
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
|
|
66
|
+
};
|
|
67
|
+
return `${cyrb53(1)}${cyrb53(2)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function blobToDataUrl(blob: Blob): Promise<string> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const reader = new FileReader();
|
|
73
|
+
reader.onload = () => resolve(reader.result as string);
|
|
74
|
+
reader.onerror = () => reject(reader.error);
|
|
75
|
+
reader.readAsDataURL(blob);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function imagesOf(json: unknown): Array<{url?: unknown}> {
|
|
80
|
+
const images = (json as {images?: unknown} | null)?.images;
|
|
81
|
+
return Array.isArray(images) ? images : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Projects in the browser's IndexedDB. Texture data URIs are pulled out of the effect JSON into
|
|
86
|
+
* a per-project file store keyed by content hash — 50 effects sharing a texture store it once —
|
|
87
|
+
* and put back as data URIs on load, so the editor only ever sees self-contained effects.
|
|
88
|
+
*/
|
|
89
|
+
export class IndexedDbProjectStore implements ProjectStore {
|
|
90
|
+
private dbPromise: Promise<IDBDatabase> | null = null;
|
|
91
|
+
/** data URI → hash, so re-saving unchanged textures skips hashing. */
|
|
92
|
+
private readonly hashByDataUrl = new Map<string, string>();
|
|
93
|
+
/** `${projectId}/${hash}` → data URI, so every effect sharing a file gets the same string. */
|
|
94
|
+
private readonly dataUrlByFile = new Map<string, Promise<string | null>>();
|
|
95
|
+
|
|
96
|
+
private db(): Promise<IDBDatabase> {
|
|
97
|
+
if (!this.dbPromise) {
|
|
98
|
+
this.dbPromise = new Promise((resolve, reject) => {
|
|
99
|
+
const open = indexedDB.open(DB_NAME, DB_VERSION);
|
|
100
|
+
open.onupgradeneeded = () => {
|
|
101
|
+
const db = open.result;
|
|
102
|
+
db.createObjectStore('projects', {keyPath: 'id'});
|
|
103
|
+
db.createObjectStore('assets', {keyPath: ['projectId', 'path']}).createIndex(
|
|
104
|
+
'projectId',
|
|
105
|
+
'projectId'
|
|
106
|
+
);
|
|
107
|
+
db.createObjectStore('files', {keyPath: ['projectId', 'hash']}).createIndex(
|
|
108
|
+
'projectId',
|
|
109
|
+
'projectId'
|
|
110
|
+
);
|
|
111
|
+
db.createObjectStore('scenes', {keyPath: 'projectId'});
|
|
112
|
+
};
|
|
113
|
+
open.onsuccess = () => resolve(open.result);
|
|
114
|
+
open.onerror = () => reject(open.error);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return this.dbPromise;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- projects -------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async listProjects(): Promise<ProjectMeta[]> {
|
|
123
|
+
const db = await this.db();
|
|
124
|
+
const all = await request<ProjectMeta[]>(db.transaction('projects').objectStore('projects').getAll());
|
|
125
|
+
return all.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getProject(id: string): Promise<ProjectMeta | null> {
|
|
129
|
+
const db = await this.db();
|
|
130
|
+
return (
|
|
131
|
+
(await request<ProjectMeta | undefined>(db.transaction('projects').objectStore('projects').get(id))) ?? null
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async createProject(name: string): Promise<ProjectMeta> {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const meta: ProjectMeta = {id: newId(), name, createdAt: now, updatedAt: now, assetCount: 0, sizeBytes: 0};
|
|
138
|
+
const db = await this.db();
|
|
139
|
+
const tx = db.transaction('projects', 'readwrite');
|
|
140
|
+
tx.objectStore('projects').put(meta);
|
|
141
|
+
await done(tx);
|
|
142
|
+
return meta;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async renameProject(id: string, name: string): Promise<void> {
|
|
146
|
+
const meta = await this.getProject(id);
|
|
147
|
+
if (!meta) return;
|
|
148
|
+
const db = await this.db();
|
|
149
|
+
const tx = db.transaction('projects', 'readwrite');
|
|
150
|
+
tx.objectStore('projects').put({...meta, name, updatedAt: Date.now()});
|
|
151
|
+
await done(tx);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async duplicateProject(id: string, name: string): Promise<ProjectMeta> {
|
|
155
|
+
const source = await this.getProject(id);
|
|
156
|
+
if (!source) throw new Error('Project not found.');
|
|
157
|
+
const db = await this.db();
|
|
158
|
+
const read = db.transaction(['assets', 'files', 'scenes']);
|
|
159
|
+
const [assets, files, scene] = await Promise.all([
|
|
160
|
+
request<AssetRecord[]>(read.objectStore('assets').index('projectId').getAll(id)),
|
|
161
|
+
request<FileRecord[]>(read.objectStore('files').index('projectId').getAll(id)),
|
|
162
|
+
request<SceneRecord | undefined>(read.objectStore('scenes').get(id)),
|
|
163
|
+
]);
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
const meta: ProjectMeta = {...source, id: newId(), name, createdAt: now, updatedAt: now};
|
|
166
|
+
const tx = db.transaction(['projects', 'assets', 'files', 'scenes'], 'readwrite');
|
|
167
|
+
tx.objectStore('projects').put(meta);
|
|
168
|
+
for (const asset of assets) tx.objectStore('assets').put({...asset, projectId: meta.id});
|
|
169
|
+
for (const file of files) tx.objectStore('files').put({...file, projectId: meta.id});
|
|
170
|
+
if (scene) tx.objectStore('scenes').put({...scene, projectId: meta.id});
|
|
171
|
+
await done(tx);
|
|
172
|
+
return meta;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async deleteProject(id: string): Promise<void> {
|
|
176
|
+
const db = await this.db();
|
|
177
|
+
const tx = db.transaction(['projects', 'assets', 'files', 'scenes'], 'readwrite');
|
|
178
|
+
tx.objectStore('projects').delete(id);
|
|
179
|
+
tx.objectStore('scenes').delete(id);
|
|
180
|
+
for (const name of ['assets', 'files'] as const) {
|
|
181
|
+
const keys = await request(tx.objectStore(name).index('projectId').getAllKeys(id));
|
|
182
|
+
for (const key of keys) tx.objectStore(name).delete(key);
|
|
183
|
+
}
|
|
184
|
+
await done(tx);
|
|
185
|
+
for (const key of [...this.dataUrlByFile.keys()]) {
|
|
186
|
+
if (key.startsWith(`${id}/`)) this.dataUrlByFile.delete(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- assets ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
async loadAssets(projectId: string): Promise<StoredAsset[]> {
|
|
193
|
+
const db = await this.db();
|
|
194
|
+
const records = await request<AssetRecord[]>(
|
|
195
|
+
db.transaction('assets').objectStore('assets').index('projectId').getAll(projectId)
|
|
196
|
+
);
|
|
197
|
+
return Promise.all(
|
|
198
|
+
records.map(async ({path, name, json, missingFiles}) => ({
|
|
199
|
+
path,
|
|
200
|
+
name,
|
|
201
|
+
json: await this.unpack(projectId, json),
|
|
202
|
+
missingFiles,
|
|
203
|
+
}))
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async putAssets(projectId: string, assets: StoredAsset[]): Promise<void> {
|
|
208
|
+
const files = new Map<string, Blob>();
|
|
209
|
+
const packed = await Promise.all(
|
|
210
|
+
assets.map(async (asset) => ({...asset, projectId, json: await this.pack(asset.json, files)}))
|
|
211
|
+
);
|
|
212
|
+
const db = await this.db();
|
|
213
|
+
const tx = db.transaction(['assets', 'files'], 'readwrite');
|
|
214
|
+
for (const record of packed) tx.objectStore('assets').put(record);
|
|
215
|
+
this.writeFiles(tx, projectId, files);
|
|
216
|
+
await done(tx);
|
|
217
|
+
await this.refresh(projectId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async deleteAssets(projectId: string, paths: string[]): Promise<void> {
|
|
221
|
+
const db = await this.db();
|
|
222
|
+
const tx = db.transaction('assets', 'readwrite');
|
|
223
|
+
for (const path of paths) tx.objectStore('assets').delete([projectId, path]);
|
|
224
|
+
await done(tx);
|
|
225
|
+
await this.refresh(projectId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- scene ----------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
async loadScene(projectId: string): Promise<StoredScene | null> {
|
|
231
|
+
const db = await this.db();
|
|
232
|
+
const record = await request<SceneRecord | undefined>(
|
|
233
|
+
db.transaction('scenes').objectStore('scenes').get(projectId)
|
|
234
|
+
);
|
|
235
|
+
if (!record) return null;
|
|
236
|
+
const entries = await Promise.all(
|
|
237
|
+
record.entries.map(async (entry) => ({...entry, json: await this.unpack(projectId, entry.json)}))
|
|
238
|
+
);
|
|
239
|
+
return {entries, savedAt: record.savedAt};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async saveScene(projectId: string, scene: StoredScene): Promise<void> {
|
|
243
|
+
const files = new Map<string, Blob>();
|
|
244
|
+
const entries: StoredSceneEntry[] = await Promise.all(
|
|
245
|
+
scene.entries.map(async (entry) => ({...entry, json: await this.pack(entry.json, files)}))
|
|
246
|
+
);
|
|
247
|
+
const db = await this.db();
|
|
248
|
+
const tx = db.transaction(['scenes', 'files'], 'readwrite');
|
|
249
|
+
tx.objectStore('scenes').put({projectId, entries, savedAt: scene.savedAt});
|
|
250
|
+
this.writeFiles(tx, projectId, files);
|
|
251
|
+
await done(tx);
|
|
252
|
+
await this.refresh(projectId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- storage ----------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
async estimate(): Promise<StorageEstimate> {
|
|
258
|
+
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
|
|
259
|
+
const estimate = storage?.estimate ? await storage.estimate() : undefined;
|
|
260
|
+
const persisted = storage?.persisted ? await storage.persisted() : false;
|
|
261
|
+
return {usage: estimate?.usage ?? null, quota: estimate?.quota ?? null, persisted};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async requestPersistence(): Promise<boolean> {
|
|
265
|
+
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
|
|
266
|
+
return storage?.persist ? storage.persist() : false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- files ------------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
/** Clones `json` with each texture data URI replaced by a `quarks-file:<hash>` reference. */
|
|
272
|
+
private async pack(json: unknown, files: Map<string, Blob>): Promise<unknown> {
|
|
273
|
+
const clone = structuredClone(json);
|
|
274
|
+
for (const image of imagesOf(clone)) {
|
|
275
|
+
const url = image.url;
|
|
276
|
+
if (typeof url !== 'string' || !url.startsWith('data:')) continue;
|
|
277
|
+
let hash = this.hashByDataUrl.get(url);
|
|
278
|
+
if (!hash) {
|
|
279
|
+
hash = await hashString(url);
|
|
280
|
+
this.hashByDataUrl.set(url, hash);
|
|
281
|
+
}
|
|
282
|
+
if (!files.has(hash)) {
|
|
283
|
+
files.set(hash, await (await fetch(url)).blob());
|
|
284
|
+
}
|
|
285
|
+
image.url = FILE_REF + hash;
|
|
286
|
+
}
|
|
287
|
+
return clone;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Puts data URIs back in place of file references. */
|
|
291
|
+
private async unpack(projectId: string, json: unknown): Promise<unknown> {
|
|
292
|
+
for (const image of imagesOf(json)) {
|
|
293
|
+
const url = image.url;
|
|
294
|
+
if (typeof url !== 'string' || !url.startsWith(FILE_REF)) continue;
|
|
295
|
+
const dataUrl = await this.readFile(projectId, url.slice(FILE_REF.length));
|
|
296
|
+
image.url = dataUrl ?? '';
|
|
297
|
+
}
|
|
298
|
+
return json;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private readFile(projectId: string, hash: string): Promise<string | null> {
|
|
302
|
+
const key = `${projectId}/${hash}`;
|
|
303
|
+
let pending = this.dataUrlByFile.get(key);
|
|
304
|
+
if (!pending) {
|
|
305
|
+
pending = (async () => {
|
|
306
|
+
const db = await this.db();
|
|
307
|
+
const record = await request<FileRecord | undefined>(
|
|
308
|
+
db.transaction('files').objectStore('files').get([projectId, hash])
|
|
309
|
+
);
|
|
310
|
+
if (!record) return null;
|
|
311
|
+
const dataUrl = await blobToDataUrl(record.blob);
|
|
312
|
+
this.hashByDataUrl.set(dataUrl, hash);
|
|
313
|
+
return dataUrl;
|
|
314
|
+
})();
|
|
315
|
+
this.dataUrlByFile.set(key, pending);
|
|
316
|
+
}
|
|
317
|
+
return pending;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private writeFiles(tx: IDBTransaction, projectId: string, files: Map<string, Blob>): void {
|
|
321
|
+
for (const [hash, blob] of files) {
|
|
322
|
+
tx.objectStore('files').put({projectId, hash, blob} satisfies FileRecord);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Drops files nothing references any more and updates the project's size/count. */
|
|
327
|
+
private async refresh(projectId: string): Promise<void> {
|
|
328
|
+
const db = await this.db();
|
|
329
|
+
const tx = db.transaction(['projects', 'assets', 'files', 'scenes'], 'readwrite');
|
|
330
|
+
const [meta, assets, files, scene] = await Promise.all([
|
|
331
|
+
request<ProjectMeta | undefined>(tx.objectStore('projects').get(projectId)),
|
|
332
|
+
request<AssetRecord[]>(tx.objectStore('assets').index('projectId').getAll(projectId)),
|
|
333
|
+
request<FileRecord[]>(tx.objectStore('files').index('projectId').getAll(projectId)),
|
|
334
|
+
request<SceneRecord | undefined>(tx.objectStore('scenes').get(projectId)),
|
|
335
|
+
]);
|
|
336
|
+
const text = JSON.stringify([assets.map((asset) => asset.json), scene?.entries ?? []]);
|
|
337
|
+
const referenced = new Set([...text.matchAll(FILE_REF_PATTERN)].map((match) => match[1]));
|
|
338
|
+
let size = text.length;
|
|
339
|
+
for (const file of files) {
|
|
340
|
+
if (referenced.has(file.hash)) {
|
|
341
|
+
size += file.blob.size;
|
|
342
|
+
} else {
|
|
343
|
+
tx.objectStore('files').delete([projectId, file.hash]);
|
|
344
|
+
this.dataUrlByFile.delete(`${projectId}/${file.hash}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (meta) {
|
|
348
|
+
tx.objectStore('projects').put({
|
|
349
|
+
...meta,
|
|
350
|
+
assetCount: assets.length,
|
|
351
|
+
sizeBytes: size,
|
|
352
|
+
updatedAt: Date.now(),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
await done(tx);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {isAssetImportFile, relativePathOf} from './assets';
|
|
2
2
|
|
|
3
3
|
interface DirectoryPickerWindow {
|
|
4
4
|
showDirectoryPicker?: (options?: {mode?: 'read' | 'readwrite'}) => Promise<FileSystemDirectoryHandle>;
|
|
@@ -13,12 +13,15 @@ interface DirectoryHandleWithEntries {
|
|
|
13
13
|
entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Recursively collects effect `.json` files and the image files they may reference from a
|
|
18
|
+
* directory handle (File System Access API).
|
|
19
|
+
*/
|
|
20
|
+
async function readAssetFilesFromDirectory(dir: DirectoryHandleWithEntries, prefix = ''): Promise<File[]> {
|
|
18
21
|
const files: File[] = [];
|
|
19
22
|
for await (const [name, entry] of dir.entries()) {
|
|
20
23
|
if (entry.kind === 'file') {
|
|
21
|
-
if (!name
|
|
24
|
+
if (!isAssetImportFile(name)) continue;
|
|
22
25
|
const file = await entry.getFile();
|
|
23
26
|
Object.defineProperty(file, 'webkitRelativePath', {
|
|
24
27
|
value: prefix + name,
|
|
@@ -29,7 +32,7 @@ async function readJsonFilesFromDirectory(dir: DirectoryHandleWithEntries, prefi
|
|
|
29
32
|
}
|
|
30
33
|
if (entry.kind === 'directory') {
|
|
31
34
|
files.push(
|
|
32
|
-
...(await
|
|
35
|
+
...(await readAssetFilesFromDirectory(
|
|
33
36
|
entry as unknown as DirectoryHandleWithEntries,
|
|
34
37
|
`${prefix}${name}/`
|
|
35
38
|
))
|
|
@@ -41,9 +44,10 @@ async function readJsonFilesFromDirectory(dir: DirectoryHandleWithEntries, prefi
|
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Opens a native folder picker when the host supports it. Returns `null` when the user
|
|
44
|
-
* cancels, or when the API is unavailable (caller should fall back to
|
|
47
|
+
* cancels, or `undefined` when the API is unavailable (caller should fall back to
|
|
48
|
+
* `<input webkitdirectory>`).
|
|
45
49
|
*/
|
|
46
|
-
export async function
|
|
50
|
+
export async function pickAssetFolderFiles(): Promise<File[] | null | undefined> {
|
|
47
51
|
const picker = (window as DirectoryPickerWindow).showDirectoryPicker;
|
|
48
52
|
if (typeof picker !== 'function') {
|
|
49
53
|
return undefined;
|
|
@@ -51,7 +55,8 @@ export async function pickGalleryJsonFiles(): Promise<File[] | null | undefined>
|
|
|
51
55
|
|
|
52
56
|
try {
|
|
53
57
|
const dir = (await picker.call(window, {mode: 'read'})) as DirectoryHandleWithEntries;
|
|
54
|
-
|
|
58
|
+
const files = await readAssetFilesFromDirectory(dir);
|
|
59
|
+
return files.sort((a, b) => relativePathOf(a).localeCompare(relativePathOf(b)));
|
|
55
60
|
} catch (err) {
|
|
56
61
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
57
62
|
return null;
|