babylon.quarks-editor 0.17.8 → 0.18.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 +46 -0
- package/README.md +1 -1
- package/dist/core/binding.js +2 -5
- package/dist/core/defaultEffect.d.ts +12 -0
- package/dist/core/defaultEffect.js +43 -0
- package/dist/core/editorScene.d.ts +7 -0
- package/dist/core/editorScene.js +11 -0
- package/dist/core/effectTree.js +2 -1
- package/dist/core/emitterShapeWireframe.d.ts +20 -0
- package/dist/core/emitterShapeWireframe.js +329 -0
- package/dist/core/galleryDragSession.d.ts +4 -0
- package/dist/core/galleryDragSession.js +8 -0
- package/dist/core/galleryDropMarker.d.ts +13 -0
- package/dist/core/galleryDropMarker.js +42 -0
- package/dist/core/galleryLayout.d.ts +20 -0
- package/dist/core/galleryLayout.js +68 -0
- package/dist/core/galleryPlayback.d.ts +14 -0
- package/dist/core/galleryPlayback.js +72 -0
- package/dist/core/galleryScene.d.ts +20 -0
- package/dist/core/galleryScene.js +65 -0
- package/dist/core/gallerySelectionMarker.d.ts +25 -0
- package/dist/core/gallerySelectionMarker.js +69 -0
- package/dist/core/galleryTree.d.ts +49 -0
- package/dist/core/galleryTree.js +138 -0
- package/dist/core/loadEffect.d.ts +13 -0
- package/dist/core/loadEffect.js +31 -17
- package/dist/core/loadEffectGallery.d.ts +36 -0
- package/dist/core/loadEffectGallery.js +97 -0
- package/dist/core/material.d.ts +13 -0
- package/dist/core/material.js +68 -0
- package/dist/core/pickGalleryFiles.d.ts +5 -0
- package/dist/core/pickGalleryFiles.js +42 -0
- package/dist/core/playback.d.ts +23 -0
- package/dist/core/playback.js +32 -0
- package/dist/core/renderDebug.d.ts +8 -0
- package/dist/core/renderDebug.js +42 -0
- package/dist/core/scrub.d.ts +14 -0
- package/dist/core/scrub.js +70 -0
- package/dist/core/timeline.d.ts +4 -1
- package/dist/core/timeline.js +30 -3
- package/dist/core/transformStore.d.ts +3 -0
- package/dist/core/transformStore.js +16 -0
- package/dist/core/viewportPicker.d.ts +25 -0
- package/dist/core/viewportPicker.js +86 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/react/EffectEditor.js +1 -4
- package/dist/react/EffectEditorHost.d.ts +7 -0
- package/dist/react/EffectEditorHost.js +745 -115
- package/dist/react/GalleryPanel.d.ts +20 -0
- package/dist/react/GalleryPanel.js +246 -0
- package/dist/react/InspectorInfoIcon.d.ts +7 -0
- package/dist/react/InspectorInfoIcon.js +22 -0
- package/dist/react/InspectorLabel.d.ts +7 -0
- package/dist/react/InspectorLabel.js +12 -0
- package/dist/react/MessageDialog.d.ts +9 -0
- package/dist/react/MessageDialog.js +24 -0
- package/dist/react/ModuleSection.d.ts +2 -0
- package/dist/react/ModuleSection.js +8 -1
- package/dist/react/ObjectModule.js +14 -2
- package/dist/react/TimelinePanel.d.ts +8 -11
- package/dist/react/TimelinePanel.js +129 -98
- package/dist/react/TimelineTrackRow.js +29 -15
- package/dist/react/Tooltip.d.ts +6 -0
- package/dist/react/Tooltip.js +65 -0
- package/dist/react/ValueField.d.ts +2 -0
- package/dist/react/ValueField.js +1 -1
- package/dist/react/behaviorModules.js +8 -8
- package/dist/react/fields.d.ts +2 -0
- package/dist/react/fields.js +14 -5
- package/dist/react/icons.d.ts +7 -0
- package/dist/react/icons.js +10 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/inspectorHints.d.ts +2 -0
- package/dist/react/inspectorHints.js +138 -0
- package/dist/react/modules.js +109 -81
- package/dist/react/theme.d.ts +2 -0
- package/dist/react/theme.js +53 -1
- package/package.json +8 -4
- package/src/core/binding.ts +2 -5
- package/src/core/defaultEffect.ts +74 -0
- package/src/core/editorScene.ts +14 -0
- package/src/core/effectTree.ts +2 -1
- package/src/core/emitterShapeWireframe.ts +373 -0
- package/src/core/galleryDragSession.ts +12 -0
- package/src/core/galleryDropMarker.ts +57 -0
- package/src/core/galleryLayout.ts +90 -0
- package/src/core/galleryPlayback.ts +90 -0
- package/src/core/galleryScene.ts +92 -0
- package/src/core/gallerySelectionMarker.ts +84 -0
- package/src/core/galleryTree.ts +187 -0
- package/src/core/loadEffect.ts +55 -18
- package/src/core/loadEffectGallery.ts +146 -0
- package/src/core/material.ts +84 -0
- package/src/core/pickGalleryFiles.ts +58 -0
- package/src/core/playback.ts +49 -0
- package/src/core/renderDebug.ts +46 -0
- package/src/core/scrub.ts +76 -1
- package/src/core/timeline.ts +34 -3
- package/src/core/transformStore.ts +19 -0
- package/src/core/viewportPicker.ts +114 -0
- package/src/index.ts +2 -0
- package/src/react/EffectEditor.tsx +5 -7
- package/src/react/EffectEditorHost.tsx +936 -142
- package/src/react/GalleryPanel.tsx +517 -0
- package/src/react/InspectorInfoIcon.tsx +36 -0
- package/src/react/InspectorLabel.tsx +18 -0
- package/src/react/MessageDialog.tsx +57 -0
- package/src/react/ModuleSection.tsx +17 -2
- package/src/react/ObjectModule.tsx +17 -6
- package/src/react/TimelinePanel.tsx +227 -174
- package/src/react/TimelineTrackRow.tsx +58 -23
- package/src/react/Tooltip.tsx +93 -0
- package/src/react/ValueField.tsx +3 -1
- package/src/react/behaviorModules.tsx +13 -8
- package/src/react/fields.tsx +15 -6
- package/src/react/icons.ts +19 -0
- package/src/react/index.ts +4 -0
- package/src/react/inspectorHints.ts +150 -0
- package/src/react/modules.tsx +198 -183
- package/src/react/theme.ts +54 -1
package/src/core/binding.ts
CHANGED
|
@@ -85,13 +85,10 @@ export class EffectBinding {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
restart(): void {
|
|
88
|
-
//
|
|
89
|
-
// reset — their parents re-drive them, so playing them directly would double-emit.
|
|
88
|
+
// onlyUsedByOther systems skip self-emission in update(); play() only unpauses sim.
|
|
90
89
|
for (const system of [this.system, ...this.subSystems]) {
|
|
91
90
|
system.restart();
|
|
92
|
-
|
|
93
|
-
system.play();
|
|
94
|
-
}
|
|
91
|
+
system.play();
|
|
95
92
|
}
|
|
96
93
|
}
|
|
97
94
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {Scene} from '@babylonjs/core/scene';
|
|
2
|
+
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
3
|
+
import {
|
|
4
|
+
ColorOverLife,
|
|
5
|
+
ConeEmitter,
|
|
6
|
+
ConstantColor,
|
|
7
|
+
ConstantValue,
|
|
8
|
+
IntervalValue,
|
|
9
|
+
ParticleSystem,
|
|
10
|
+
RenderMode,
|
|
11
|
+
Vector4,
|
|
12
|
+
} from 'babylon.quarks';
|
|
13
|
+
import {DEFAULT_GRADIENT_STOPS, buildGradient} from './colors';
|
|
14
|
+
import type {GalleryEntry} from './loadEffectGallery';
|
|
15
|
+
|
|
16
|
+
export interface CreateGalleryDefaultOptions {
|
|
17
|
+
resolveTexture?: (url: string, scene: Scene) => unknown;
|
|
18
|
+
textureUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Creates a simple cone burst preset for new editor effects. */
|
|
22
|
+
export function createDefaultEffect(
|
|
23
|
+
scene: Scene,
|
|
24
|
+
resolveTexture?: (url: string, scene: Scene) => unknown,
|
|
25
|
+
textureUrl?: string
|
|
26
|
+
): ParticleSystem {
|
|
27
|
+
const system = new ParticleSystem({
|
|
28
|
+
scene,
|
|
29
|
+
duration: 4,
|
|
30
|
+
looping: true,
|
|
31
|
+
startLife: new IntervalValue(1.2, 1.8),
|
|
32
|
+
startSpeed: new IntervalValue(3, 5),
|
|
33
|
+
startSize: new IntervalValue(0.25, 0.5),
|
|
34
|
+
startColor: new ConstantColor(new Vector4(1, 1, 1, 1)),
|
|
35
|
+
emissionOverTime: new ConstantValue(80),
|
|
36
|
+
shape: new ConeEmitter({radius: 0.3, angle: 0.25}),
|
|
37
|
+
renderMode: RenderMode.BillBoard,
|
|
38
|
+
texture: (textureUrl && resolveTexture ? resolveTexture(textureUrl, scene) : undefined) as never,
|
|
39
|
+
transparent: true,
|
|
40
|
+
blendMode: 1,
|
|
41
|
+
});
|
|
42
|
+
system.addBehavior(new ColorOverLife(buildGradient(DEFAULT_GRADIENT_STOPS)));
|
|
43
|
+
system.emitter.rotation.x = -Math.PI / 2;
|
|
44
|
+
return system;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Adds a blank preset effect to the hidden gallery catalog (not in the renderer yet). */
|
|
48
|
+
export function createGalleryDefaultEntry(
|
|
49
|
+
scene: Scene,
|
|
50
|
+
catalogRoot: TransformNode,
|
|
51
|
+
existing: GalleryEntry[],
|
|
52
|
+
options: CreateGalleryDefaultOptions = {}
|
|
53
|
+
): GalleryEntry {
|
|
54
|
+
const takenNames = new Set(existing.map((entry) => entry.name));
|
|
55
|
+
const takenPaths = new Set(existing.map((entry) => entry.path));
|
|
56
|
+
|
|
57
|
+
let name = existing.length === 0 ? 'Effect' : 'New Effect';
|
|
58
|
+
let suffix = 2;
|
|
59
|
+
while (takenNames.has(name) || takenPaths.has(`_created/${name}.json`)) {
|
|
60
|
+
name = `New Effect ${suffix}`;
|
|
61
|
+
suffix++;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const path = `_created/${name}.json`;
|
|
65
|
+
const system = createDefaultEffect(scene, options.resolveTexture, options.textureUrl);
|
|
66
|
+
system.pause();
|
|
67
|
+
|
|
68
|
+
const root = new TransformNode(name, scene);
|
|
69
|
+
system.emitter.parent = root;
|
|
70
|
+
root.parent = catalogRoot;
|
|
71
|
+
root.setEnabled(false);
|
|
72
|
+
|
|
73
|
+
return {name, path, root, systems: [system], inScene: false};
|
|
74
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
2
|
+
|
|
3
|
+
/** Marks meshes/nodes created only for editor interaction or overlays (not part of the effect). */
|
|
4
|
+
export const EDITOR_SCENE_NODE_METADATA = {quarksEditorSceneNode: true as const};
|
|
5
|
+
|
|
6
|
+
/** True for viewport pick proxies, shape wireframes, and other non-exportable scene helpers. */
|
|
7
|
+
export function isEditorSceneNode(node: TransformNode): boolean {
|
|
8
|
+
const meta = node.metadata as {quarksEditorPick?: boolean; quarksEditorSceneNode?: boolean} | undefined;
|
|
9
|
+
if (meta?.quarksEditorPick || meta?.quarksEditorSceneNode) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
const name = node.name;
|
|
13
|
+
return name.startsWith('viewport-pick-root-') || name.startsWith('wf-');
|
|
14
|
+
}
|
package/src/core/effectTree.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {ParticleEmitter} from 'babylon.quarks';
|
|
|
2
2
|
import type {ParticleSystem} from 'babylon.quarks';
|
|
3
3
|
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
4
4
|
import {Matrix, Quaternion} from '@babylonjs/core/Maths/math.vector';
|
|
5
|
+
import {isEditorSceneNode} from './editorScene';
|
|
5
6
|
|
|
6
7
|
/** A node in the editor's effect hierarchy: a Group (organizational) or a ParticleEmitter. */
|
|
7
8
|
export interface EffectTreeNode {
|
|
@@ -24,7 +25,7 @@ export function buildEffectTree(root: TransformNode, depth = 0): EffectTreeNode
|
|
|
24
25
|
children: [],
|
|
25
26
|
};
|
|
26
27
|
for (const child of root.getChildren()) {
|
|
27
|
-
if (child instanceof TransformNode) {
|
|
28
|
+
if (child instanceof TransformNode && !isEditorSceneNode(child)) {
|
|
28
29
|
treeNode.children.push(buildEffectTree(child, depth + 1));
|
|
29
30
|
}
|
|
30
31
|
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type {ParticleSystem} from 'babylon.quarks';
|
|
2
|
+
import {Color3} from '@babylonjs/core/Maths/math.color';
|
|
3
|
+
import {Vector3} from '@babylonjs/core/Maths/math.vector';
|
|
4
|
+
import {Mesh} from '@babylonjs/core/Meshes/mesh';
|
|
5
|
+
import {LinesMesh} from '@babylonjs/core/Meshes/linesMesh';
|
|
6
|
+
import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder';
|
|
7
|
+
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
8
|
+
import type {Scene} from '@babylonjs/core/scene';
|
|
9
|
+
import {getShapeType, readShapeParams} from './shapes';
|
|
10
|
+
import {EDITOR_SCENE_NODE_METADATA} from './editorScene';
|
|
11
|
+
|
|
12
|
+
const LINE_COLOR = new Color3(0.45, 0.82, 1);
|
|
13
|
+
const DIRECTION_COLOR = new Color3(0.75, 0.92, 1);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optional wireframe overlays for every emitter shape in the current effect.
|
|
17
|
+
* Geometry follows quarks.core spawn math (local XY spawn plane, +Z emit axis).
|
|
18
|
+
*/
|
|
19
|
+
export class EmitterShapeWireframes {
|
|
20
|
+
private readonly scene: Scene;
|
|
21
|
+
private readonly container: TransformNode;
|
|
22
|
+
private meshes: Mesh[] = [];
|
|
23
|
+
private visible = false;
|
|
24
|
+
private syncKey = '';
|
|
25
|
+
|
|
26
|
+
constructor(scene: Scene) {
|
|
27
|
+
this.scene = scene;
|
|
28
|
+
this.container = new TransformNode('emitter-wireframes', scene);
|
|
29
|
+
this.container.setEnabled(false);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setVisible(on: boolean): void {
|
|
33
|
+
this.visible = on;
|
|
34
|
+
if (!on) {
|
|
35
|
+
this.clearMeshes();
|
|
36
|
+
this.container.setEnabled(false);
|
|
37
|
+
this.syncKey = '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Rebuilds overlays when the binding revision changes. */
|
|
42
|
+
sync(systems: ParticleSystem[], syncKey: string): void {
|
|
43
|
+
if (!this.visible) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (syncKey === this.syncKey) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.syncKey = syncKey;
|
|
50
|
+
this.rebuild(systems);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
dispose(): void {
|
|
54
|
+
this.setVisible(false);
|
|
55
|
+
this.container.dispose();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private rebuild(systems: ParticleSystem[]): void {
|
|
59
|
+
this.clearMeshes();
|
|
60
|
+
this.container.setEnabled(true);
|
|
61
|
+
for (const system of systems) {
|
|
62
|
+
if (system.emitter.isDisposed()) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
for (const mesh of buildEmitterWireframe(system, this.scene)) {
|
|
66
|
+
mesh.parent = system.emitter;
|
|
67
|
+
mesh.metadata = {...(mesh.metadata ?? {}), ...EDITOR_SCENE_NODE_METADATA};
|
|
68
|
+
mesh.isPickable = false;
|
|
69
|
+
mesh.renderingGroupId = 1;
|
|
70
|
+
this.meshes.push(mesh);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private clearMeshes(): void {
|
|
76
|
+
for (const mesh of this.meshes) {
|
|
77
|
+
mesh.dispose();
|
|
78
|
+
}
|
|
79
|
+
this.meshes = [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildEmitterWireframe(system: ParticleSystem, scene: Scene): Mesh[] {
|
|
84
|
+
const type = getShapeType(system.emitterShape);
|
|
85
|
+
const params = readShapeParams(system.emitterShape);
|
|
86
|
+
const meshes: Mesh[] = [];
|
|
87
|
+
const lines = (name: string, points: Vector3[], color = LINE_COLOR) => {
|
|
88
|
+
if (points.length < 2) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const mesh = MeshBuilder.CreateLines(name, {points}, scene) as LinesMesh;
|
|
92
|
+
mesh.color = color.clone();
|
|
93
|
+
mesh.isPickable = false;
|
|
94
|
+
meshes.push(mesh);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
switch (type) {
|
|
98
|
+
case 'point': {
|
|
99
|
+
// Spawn at origin; velocity is random in a unit ball.
|
|
100
|
+
const s = 0.12;
|
|
101
|
+
lines('wf-point-x', [new Vector3(-s, 0, 0), new Vector3(s, 0, 0)]);
|
|
102
|
+
lines('wf-point-y', [new Vector3(0, -s, 0), new Vector3(0, s, 0)]);
|
|
103
|
+
lines('wf-point-z', [new Vector3(0, 0, -s), new Vector3(0, 0, s)], DIRECTION_COLOR);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'cone': {
|
|
107
|
+
// Spawn: disk/ring on XY at z=0 (radius, thickness, arc).
|
|
108
|
+
// Velocity: +Z with spread angle — rim dir = (sinα·û, cosα).
|
|
109
|
+
const radius = Math.max(params.radius, 0.001);
|
|
110
|
+
const angle = Math.max(0, params.angle);
|
|
111
|
+
const arc = clampArc(params.arc);
|
|
112
|
+
const thickness = clamp01(params.thickness);
|
|
113
|
+
lines('wf-cone-outer', xyArc(radius, arc));
|
|
114
|
+
if (thickness < 1) {
|
|
115
|
+
lines('wf-cone-inner', xyArc(radius * (1 - thickness), arc));
|
|
116
|
+
}
|
|
117
|
+
if (arc < Math.PI * 2 - 1e-3) {
|
|
118
|
+
lines('wf-cone-spoke-a', [Vector3.Zero(), arcPoint(radius, 0)]);
|
|
119
|
+
lines('wf-cone-spoke-b', [Vector3.Zero(), arcPoint(radius, arc)]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const preview = Math.max(radius * 2, 1.25);
|
|
123
|
+
const farRadius = radius + preview * Math.tan(angle);
|
|
124
|
+
const spokeCount = Math.max(4, Math.ceil((arc / (Math.PI * 2)) * 12));
|
|
125
|
+
for (let i = 0; i <= spokeCount; i++) {
|
|
126
|
+
const theta = (i / spokeCount) * arc;
|
|
127
|
+
const rim = arcPoint(radius, theta);
|
|
128
|
+
const dir = new Vector3(Math.sin(angle) * Math.cos(theta), Math.sin(angle) * Math.sin(theta), Math.cos(angle));
|
|
129
|
+
const travel = preview / Math.max(Math.cos(angle), 0.05);
|
|
130
|
+
lines(`wf-cone-ray-${i}`, [rim, rim.add(dir.scale(travel))], DIRECTION_COLOR);
|
|
131
|
+
}
|
|
132
|
+
lines('wf-cone-far', xyArc(farRadius, arc, 32, preview), DIRECTION_COLOR);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'sphere': {
|
|
136
|
+
// Spawn on spherical shell/volume; velocity radial from center.
|
|
137
|
+
const radius = Math.max(params.radius, 0.001);
|
|
138
|
+
const arc = clampArc(params.arc);
|
|
139
|
+
const thickness = clamp01(params.thickness);
|
|
140
|
+
drawSphereShell(lines, 'wf-sphere', radius, arc);
|
|
141
|
+
if (thickness < 1) {
|
|
142
|
+
drawSphereShell(lines, 'wf-sphere-inner', radius * (1 - thickness), arc);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case 'hemisphere': {
|
|
147
|
+
// Spawn on +Z hemisphere (phi 0..π/2): flat on XY through origin, dome toward +Z.
|
|
148
|
+
const radius = Math.max(params.radius, 0.001);
|
|
149
|
+
const arc = clampArc(params.arc);
|
|
150
|
+
const thickness = clamp01(params.thickness);
|
|
151
|
+
drawHemisphereShell(lines, 'wf-hemi', radius, arc);
|
|
152
|
+
lines('wf-hemi-base', xyArc(radius, arc));
|
|
153
|
+
if (thickness < 1) {
|
|
154
|
+
drawHemisphereShell(lines, 'wf-hemi-inner', radius * (1 - thickness), arc);
|
|
155
|
+
lines('wf-hemi-base-inner', xyArc(radius * (1 - thickness), arc));
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'circle': {
|
|
160
|
+
// Spawn on circle/ring in XY; velocity radial in XY.
|
|
161
|
+
const radius = Math.max(params.radius, 0.001);
|
|
162
|
+
const arc = clampArc(params.arc);
|
|
163
|
+
const thickness = clamp01(params.thickness);
|
|
164
|
+
lines('wf-circle', xyArc(radius, arc));
|
|
165
|
+
if (thickness < 1) {
|
|
166
|
+
lines('wf-circle-inner', xyArc(radius * (1 - thickness), arc));
|
|
167
|
+
}
|
|
168
|
+
if (arc < Math.PI * 2 - 1e-3) {
|
|
169
|
+
lines('wf-circle-spoke-a', [Vector3.Zero(), arcPoint(radius, 0)]);
|
|
170
|
+
lines('wf-circle-spoke-b', [Vector3.Zero(), arcPoint(radius, arc)]);
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'donut': {
|
|
175
|
+
// Spawn on torus: major ring of `radius` in XY, tube radius `donutRadius`.
|
|
176
|
+
const radius = Math.max(params.radius, 0.001);
|
|
177
|
+
const tube = Math.max(params.donutRadius, 0.001);
|
|
178
|
+
const arc = clampArc(params.arc);
|
|
179
|
+
const thickness = clamp01(params.thickness);
|
|
180
|
+
lines('wf-donut-major', xyArc(radius, arc));
|
|
181
|
+
lines('wf-donut-outer', xyArc(radius + tube, arc));
|
|
182
|
+
lines('wf-donut-inner', xyArc(Math.max(radius - tube, 0), arc));
|
|
183
|
+
if (thickness < 1) {
|
|
184
|
+
lines('wf-donut-tube-inner', xyArc(radius + tube * (1 - thickness), arc));
|
|
185
|
+
}
|
|
186
|
+
const tubeSamples = Math.max(4, Math.ceil((arc / (Math.PI * 2)) * 8));
|
|
187
|
+
for (let i = 0; i <= tubeSamples; i++) {
|
|
188
|
+
const theta = (i / tubeSamples) * arc;
|
|
189
|
+
lines(`wf-donut-tube-${i}`, torusTubeRing(radius, tube, theta));
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'rectangle': {
|
|
194
|
+
// Spawn on rectangle perimeter (thickness fills toward center); velocity outward in XY.
|
|
195
|
+
const thickness = clamp01(params.thickness);
|
|
196
|
+
lines('wf-rect', xyRect(params.width, params.height));
|
|
197
|
+
if (thickness > 0 && thickness < 1) {
|
|
198
|
+
lines('wf-rect-inner', xyRect(params.width * (1 - thickness), params.height * (1 - thickness)));
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case 'grid': {
|
|
203
|
+
// Discrete spawn cells in XY; velocity straight +Z.
|
|
204
|
+
const cols = Math.max(1, Math.floor(params.column));
|
|
205
|
+
const rows = Math.max(1, Math.floor(params.row));
|
|
206
|
+
const halfW = params.width / 2;
|
|
207
|
+
const halfH = params.height / 2;
|
|
208
|
+
lines('wf-grid-border', xyRect(params.width, params.height));
|
|
209
|
+
const mark = Math.min(params.width / cols, params.height / rows, 0.12) * 0.35;
|
|
210
|
+
const ray = Math.max(mark * 4, 0.25);
|
|
211
|
+
for (let c = 0; c < cols; c++) {
|
|
212
|
+
for (let r = 0; r < rows; r++) {
|
|
213
|
+
const x = (c * params.width) / cols - halfW;
|
|
214
|
+
const y = (r * params.height) / rows - halfH;
|
|
215
|
+
const p = new Vector3(x, y, 0);
|
|
216
|
+
lines(`wf-grid-x-${c}-${r}`, [new Vector3(x - mark, y, 0), new Vector3(x + mark, y, 0)]);
|
|
217
|
+
lines(`wf-grid-y-${c}-${r}`, [new Vector3(x, y - mark, 0), new Vector3(x, y + mark, 0)]);
|
|
218
|
+
lines(`wf-grid-ray-${c}-${r}`, [p, new Vector3(x, y, ray)], DIRECTION_COLOR);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'mesh_surface': {
|
|
224
|
+
const source = params.mesh;
|
|
225
|
+
if (source && !source.isDisposed()) {
|
|
226
|
+
const positions = source.getVerticesData('position');
|
|
227
|
+
const indices = source.getIndices();
|
|
228
|
+
if (positions && indices && indices.length >= 3) {
|
|
229
|
+
const edgeSet = new Set<string>();
|
|
230
|
+
const pushEdge = (a: number, b: number) => {
|
|
231
|
+
const key = a < b ? `${a}_${b}` : `${b}_${a}`;
|
|
232
|
+
if (edgeSet.has(key)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
edgeSet.add(key);
|
|
236
|
+
lines(`wf-mesh-e-${key}`, [
|
|
237
|
+
new Vector3(positions[a * 3], positions[a * 3 + 1], positions[a * 3 + 2]),
|
|
238
|
+
new Vector3(positions[b * 3], positions[b * 3 + 1], positions[b * 3 + 2]),
|
|
239
|
+
]);
|
|
240
|
+
};
|
|
241
|
+
const maxTris = Math.min(indices.length / 3, 400);
|
|
242
|
+
for (let t = 0; t < maxTris; t++) {
|
|
243
|
+
const i = t * 3;
|
|
244
|
+
pushEdge(indices[i], indices[i + 1]);
|
|
245
|
+
pushEdge(indices[i + 1], indices[i + 2]);
|
|
246
|
+
pushEdge(indices[i + 2], indices[i]);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
lines('wf-mesh-missing', xyRect(0.6, 0.6));
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
lines('wf-mesh-missing', xyRect(0.6, 0.6));
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return meshes;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type LineFn = (name: string, points: Vector3[], color?: Color3) => void;
|
|
264
|
+
|
|
265
|
+
function drawSphereShell(lines: LineFn, prefix: string, radius: number, arc: number): void {
|
|
266
|
+
// Latitude rings + longitude arcs matching spherical spawn (theta around Z via arc).
|
|
267
|
+
const latCount = 4;
|
|
268
|
+
for (let i = 1; i < latCount; i++) {
|
|
269
|
+
const phi = (i / latCount) * Math.PI;
|
|
270
|
+
const ringR = radius * Math.sin(phi);
|
|
271
|
+
const z = radius * Math.cos(phi);
|
|
272
|
+
lines(`${prefix}-lat-${i}`, xyArc(ringR, arc, 48, z));
|
|
273
|
+
}
|
|
274
|
+
const lonCount = Math.max(4, Math.ceil((arc / (Math.PI * 2)) * 8));
|
|
275
|
+
for (let i = 0; i <= lonCount; i++) {
|
|
276
|
+
const theta = (i / lonCount) * arc;
|
|
277
|
+
const points: Vector3[] = [];
|
|
278
|
+
for (let j = 0; j <= 16; j++) {
|
|
279
|
+
const phi = (j / 16) * Math.PI;
|
|
280
|
+
points.push(
|
|
281
|
+
new Vector3(
|
|
282
|
+
radius * Math.sin(phi) * Math.cos(theta),
|
|
283
|
+
radius * Math.sin(phi) * Math.sin(theta),
|
|
284
|
+
radius * Math.cos(phi)
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
lines(`${prefix}-lon-${i}`, points);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function drawHemisphereShell(lines: LineFn, prefix: string, radius: number, arc: number): void {
|
|
293
|
+
// Quarks: phi = acos(v) ∈ [0, π/2] → z = cos(phi) ∈ [0, 1].
|
|
294
|
+
const latCount = 3;
|
|
295
|
+
for (let i = 1; i <= latCount; i++) {
|
|
296
|
+
const phi = (i / (latCount + 1)) * (Math.PI / 2);
|
|
297
|
+
const ringR = radius * Math.sin(phi);
|
|
298
|
+
const z = radius * Math.cos(phi);
|
|
299
|
+
lines(`${prefix}-lat-${i}`, xyArc(ringR, arc, 32, z));
|
|
300
|
+
}
|
|
301
|
+
const lonCount = Math.max(4, Math.ceil((arc / (Math.PI * 2)) * 8));
|
|
302
|
+
for (let i = 0; i <= lonCount; i++) {
|
|
303
|
+
const theta = (i / lonCount) * arc;
|
|
304
|
+
const points: Vector3[] = [new Vector3(0, 0, radius)];
|
|
305
|
+
for (let j = 1; j <= 12; j++) {
|
|
306
|
+
const phi = (j / 12) * (Math.PI / 2);
|
|
307
|
+
points.push(
|
|
308
|
+
new Vector3(
|
|
309
|
+
radius * Math.sin(phi) * Math.cos(theta),
|
|
310
|
+
radius * Math.sin(phi) * Math.sin(theta),
|
|
311
|
+
radius * Math.cos(phi)
|
|
312
|
+
)
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
lines(`${prefix}-lon-${i}`, points);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function torusTubeRing(major: number, tube: number, theta: number): Vector3[] {
|
|
320
|
+
const cos = Math.cos(theta);
|
|
321
|
+
const sin = Math.sin(theta);
|
|
322
|
+
const points: Vector3[] = [];
|
|
323
|
+
for (let i = 0; i <= 16; i++) {
|
|
324
|
+
const phi = (i / 16) * Math.PI * 2;
|
|
325
|
+
points.push(
|
|
326
|
+
new Vector3(
|
|
327
|
+
(major + tube * Math.cos(phi)) * cos,
|
|
328
|
+
(major + tube * Math.cos(phi)) * sin,
|
|
329
|
+
tube * Math.sin(phi)
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return points;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function xyArc(radius: number, arc: number, segments = 48, z = 0): Vector3[] {
|
|
337
|
+
const points: Vector3[] = [];
|
|
338
|
+
const steps = Math.max(8, Math.ceil(segments * (arc / (Math.PI * 2))));
|
|
339
|
+
for (let i = 0; i <= steps; i++) {
|
|
340
|
+
points.push(arcPoint(radius, (i / steps) * arc, z));
|
|
341
|
+
}
|
|
342
|
+
return points;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function arcPoint(radius: number, theta: number, z = 0): Vector3 {
|
|
346
|
+
return new Vector3(Math.cos(theta) * radius, Math.sin(theta) * radius, z);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function xyRect(width: number, height: number): Vector3[] {
|
|
350
|
+
const halfW = width / 2;
|
|
351
|
+
const halfH = height / 2;
|
|
352
|
+
return [
|
|
353
|
+
new Vector3(-halfW, -halfH, 0),
|
|
354
|
+
new Vector3(halfW, -halfH, 0),
|
|
355
|
+
new Vector3(halfW, halfH, 0),
|
|
356
|
+
new Vector3(-halfW, halfH, 0),
|
|
357
|
+
new Vector3(-halfW, -halfH, 0),
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function clampArc(arc: number): number {
|
|
362
|
+
if (!Number.isFinite(arc) || arc <= 0) {
|
|
363
|
+
return Math.PI * 2;
|
|
364
|
+
}
|
|
365
|
+
return Math.min(Math.PI * 2, arc);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function clamp01(value: number): number {
|
|
369
|
+
if (!Number.isFinite(value)) {
|
|
370
|
+
return 1;
|
|
371
|
+
}
|
|
372
|
+
return Math.min(1, Math.max(0, value));
|
|
373
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type {GalleryDragPayload} from './galleryTree';
|
|
2
|
+
|
|
3
|
+
let activeDrag: GalleryDragPayload | null = null;
|
|
4
|
+
|
|
5
|
+
/** Stores the current gallery drag payload for viewport preview (getData is drop-only in most browsers). */
|
|
6
|
+
export function setGalleryDragSession(payload: GalleryDragPayload | null): void {
|
|
7
|
+
activeDrag = payload;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getGalleryDragSession(): GalleryDragPayload | null {
|
|
11
|
+
return activeDrag;
|
|
12
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {Color3} from '@babylonjs/core/Maths/math.color';
|
|
2
|
+
import {Vector3} from '@babylonjs/core/Maths/math.vector';
|
|
3
|
+
import {StandardMaterial} from '@babylonjs/core/Materials/standardMaterial';
|
|
4
|
+
import {Mesh} from '@babylonjs/core/Meshes/mesh';
|
|
5
|
+
import {MeshBuilder} from '@babylonjs/core/Meshes/meshBuilder';
|
|
6
|
+
import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
7
|
+
import type {Scene} from '@babylonjs/core/scene';
|
|
8
|
+
import {DEFAULT_GALLERY_SPACING, gallerySpan} from './galleryLayout';
|
|
9
|
+
|
|
10
|
+
/** Ground decal shown while dragging gallery items over the viewport. */
|
|
11
|
+
export class GalleryDropMarker {
|
|
12
|
+
private readonly root: TransformNode;
|
|
13
|
+
private readonly disc: Mesh;
|
|
14
|
+
private readonly material: StandardMaterial;
|
|
15
|
+
|
|
16
|
+
constructor(scene: Scene) {
|
|
17
|
+
this.root = new TransformNode('gallery-drop-root', scene);
|
|
18
|
+
this.disc = MeshBuilder.CreateDisc('gallery-drop-fill', {radius: 1, tessellation: 48, sideOrientation: Mesh.DOUBLESIDE}, scene);
|
|
19
|
+
this.disc.parent = this.root;
|
|
20
|
+
this.disc.rotation.x = Math.PI / 2;
|
|
21
|
+
|
|
22
|
+
this.material = new StandardMaterial('gallery-drop-fill-mat', scene);
|
|
23
|
+
this.material.emissiveColor = new Color3(0.35, 0.55, 1);
|
|
24
|
+
this.material.alpha = 0.24;
|
|
25
|
+
this.material.disableLighting = true;
|
|
26
|
+
this.material.backFaceCulling = false;
|
|
27
|
+
|
|
28
|
+
this.disc.material = this.material;
|
|
29
|
+
this.disc.isPickable = false;
|
|
30
|
+
this.disc.renderingGroupId = 1;
|
|
31
|
+
this.hide();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Positions the marker for a single effect or a folder grid footprint. */
|
|
35
|
+
show(position: Vector3, effectCount: number, spacing = DEFAULT_GALLERY_SPACING): void {
|
|
36
|
+
const span = Math.max(gallerySpan(effectCount, spacing), spacing);
|
|
37
|
+
const radius = Math.max(0.375, span * 0.25 + spacing * 0.175);
|
|
38
|
+
|
|
39
|
+
this.root.position.set(position.x, 0.03, position.z);
|
|
40
|
+
this.root.scaling.set(radius, radius, radius);
|
|
41
|
+
|
|
42
|
+
this.material.alpha = effectCount > 1 ? 0.16 : 0.24;
|
|
43
|
+
this.material.emissiveColor = effectCount > 1 ? new Color3(0.45, 0.9, 0.75) : new Color3(0.55, 0.78, 1);
|
|
44
|
+
|
|
45
|
+
this.root.setEnabled(true);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hide(): void {
|
|
49
|
+
this.root.setEnabled(false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
dispose(): void {
|
|
53
|
+
this.material.dispose();
|
|
54
|
+
this.disc.dispose();
|
|
55
|
+
this.root.dispose();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {ArcRotateCamera} from '@babylonjs/core/Cameras/arcRotateCamera';
|
|
2
|
+
import {Vector3} from '@babylonjs/core/Maths/math.vector';
|
|
3
|
+
import type {Mesh} from '@babylonjs/core/Meshes/mesh';
|
|
4
|
+
import type {TransformNode} from '@babylonjs/core/Meshes/transformNode';
|
|
5
|
+
|
|
6
|
+
/** Default gap between neighbouring effects in a folder gallery import. */
|
|
7
|
+
export const DEFAULT_GALLERY_SPACING = 4;
|
|
8
|
+
|
|
9
|
+
/** Square-grid column count for `count` items. */
|
|
10
|
+
export function galleryColumnCount(count: number): number {
|
|
11
|
+
return Math.max(1, Math.ceil(Math.sqrt(count)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** World X/Z offset for the `index`th effect in a square grid. */
|
|
15
|
+
export function galleryGridPosition(
|
|
16
|
+
index: number,
|
|
17
|
+
count: number,
|
|
18
|
+
spacing = DEFAULT_GALLERY_SPACING
|
|
19
|
+
): {x: number; z: number} {
|
|
20
|
+
const cols = galleryColumnCount(count);
|
|
21
|
+
const rows = Math.ceil(count / cols);
|
|
22
|
+
const col = index % cols;
|
|
23
|
+
const row = Math.floor(index / cols);
|
|
24
|
+
return {
|
|
25
|
+
x: (col - (cols - 1) / 2) * spacing,
|
|
26
|
+
z: (row - (rows - 1) / 2) * spacing,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Longest side of the grid footprint in world units. */
|
|
31
|
+
export function gallerySpan(count: number, spacing = DEFAULT_GALLERY_SPACING): number {
|
|
32
|
+
const cols = galleryColumnCount(count);
|
|
33
|
+
const rows = Math.ceil(count / cols);
|
|
34
|
+
return Math.max((cols - 1) * spacing, (rows - 1) * spacing, spacing);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pulls the camera back and scales the ground plane to frame a gallery grid. */
|
|
38
|
+
export function fitGalleryView(
|
|
39
|
+
camera: ArcRotateCamera,
|
|
40
|
+
ground: Mesh,
|
|
41
|
+
count: number,
|
|
42
|
+
spacing = DEFAULT_GALLERY_SPACING
|
|
43
|
+
): void {
|
|
44
|
+
const span = gallerySpan(count, spacing);
|
|
45
|
+
camera.setTarget(new Vector3(0, 1.4, 0));
|
|
46
|
+
camera.radius = Math.max(span * 0.85, 12);
|
|
47
|
+
camera.beta = Math.min(camera.beta, 1.1);
|
|
48
|
+
const groundSize = Math.max(span + spacing * 4, 100);
|
|
49
|
+
ground.scaling.x = groundSize / 100;
|
|
50
|
+
ground.scaling.z = groundSize / 100;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let cameraFocusFrame = 0;
|
|
54
|
+
|
|
55
|
+
function easeOutCubic(t: number): number {
|
|
56
|
+
return 1 - (1 - t) ** 3;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stops an in-flight gallery camera focus animation. */
|
|
60
|
+
export function cancelGalleryCameraFocus(): void {
|
|
61
|
+
if (cameraFocusFrame) {
|
|
62
|
+
cancelAnimationFrame(cameraFocusFrame);
|
|
63
|
+
cameraFocusFrame = 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Smoothly frames the camera on an in-scene gallery effect. */
|
|
68
|
+
export function focusGalleryEntry(camera: ArcRotateCamera, entryRoot: TransformNode, durationMs = 450): void {
|
|
69
|
+
cancelGalleryCameraFocus();
|
|
70
|
+
|
|
71
|
+
const pos = entryRoot.getAbsolutePosition();
|
|
72
|
+
const target = new Vector3(pos.x, pos.y + 1.4, pos.z);
|
|
73
|
+
const startTarget = camera.target.clone();
|
|
74
|
+
const startRadius = camera.radius;
|
|
75
|
+
const jumpDist = Vector3.Distance(startTarget, target);
|
|
76
|
+
const desiredRadius = jumpDist > 3 ? 12 : Math.min(startRadius, 16);
|
|
77
|
+
const start = performance.now();
|
|
78
|
+
|
|
79
|
+
const tick = () => {
|
|
80
|
+
const t = easeOutCubic(Math.min(1, (performance.now() - start) / durationMs));
|
|
81
|
+
camera.setTarget(Vector3.Lerp(startTarget, target, t));
|
|
82
|
+
camera.radius = startRadius + (desiredRadius - startRadius) * t;
|
|
83
|
+
if (t < 1) {
|
|
84
|
+
cameraFocusFrame = requestAnimationFrame(tick);
|
|
85
|
+
} else {
|
|
86
|
+
cameraFocusFrame = 0;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
cameraFocusFrame = requestAnimationFrame(tick);
|
|
90
|
+
}
|