diff --git a/editor/package.json b/editor/package.json index 82651ee72..d281f408b 100644 --- a/editor/package.json +++ b/editor/package.json @@ -23,6 +23,7 @@ "license": "(Apache-2.0)", "devDependencies": { "@electron/rebuild": "4.0.2", + "@types/adm-zip": "^0.5.7", "@types/decompress": "4.2.7", "@types/fluent-ffmpeg": "^2.1.27", "@types/node": "^22", @@ -59,6 +60,8 @@ "@radix-ui/react-menubar": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^1.2.0", @@ -68,11 +71,11 @@ "@radix-ui/react-toggle": "^1.1.1", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "^1.0.7", - "@radix-ui/react-radio-group": "^1.1.3", "@recast-navigation/core": "0.43.0", "@recast-navigation/generators": "0.43.0", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.1.0-beta.22", + "adm-zip": "^0.5.16", "assimpjs": "0.0.10", "axios": "1.12.0", "babylonjs": "8.41.0", @@ -101,6 +104,7 @@ "framer-motion": "12.23.24", "fs-extra": "11.2.0", "glob": "11.1.0", + "js-yaml": "^4.1.1", "markdown-to-jsx": "7.6.2", "math-expression-evaluator": "^2.0.6", "md5": "^2.3.0", diff --git a/editor/src/editor/layout/inspector/fields/geometry.tsx b/editor/src/editor/layout/inspector/fields/geometry.tsx new file mode 100644 index 000000000..8e1f856ed --- /dev/null +++ b/editor/src/editor/layout/inspector/fields/geometry.tsx @@ -0,0 +1,230 @@ +import { DragEvent, Component, PropsWithChildren, ReactNode } from "react"; +import { extname } from "path/posix"; + +import { toast } from "sonner"; + +import { XMarkIcon } from "@heroicons/react/20/solid"; +import { MdOutlineQuestionMark } from "react-icons/md"; + +import { Scene, Mesh } from "babylonjs"; + +import { isScene } from "../../../../tools/guards/scene"; +import { registerUndoRedo } from "../../../../tools/undoredo"; + +import { configureImportedNodeIds, loadImportedSceneFile } from "../../preview/import/import"; +import { EditorInspectorNumberField } from "./number"; + +export interface IEditorInspectorGeometryFieldProps extends PropsWithChildren { + title: string; + property: string; + object: any; + + noUndoRedo?: boolean; + + scene?: Scene; + onChange?: (mesh: Mesh | null) => void; +} + +export interface IEditorInspectorGeometryFieldState { + dragOver: boolean; + loading: boolean; +} + +export class EditorInspectorGeometryField extends Component { + public constructor(props: IEditorInspectorGeometryFieldProps) { + super(props); + + this.state = { + dragOver: false, + loading: false, + }; + } + + public render(): ReactNode { + const mesh = this.props.object[this.props.property] as Mesh | null | undefined; + + return ( +
this._handleDrop(ev)} + onDragOver={(ev) => this._handleDragOver(ev)} + onDragLeave={(ev) => this._handleDragLeave(ev)} + className={`flex flex-col w-full p-5 rounded-lg ${this.state.dragOver ? "bg-muted-foreground/75 dark:bg-muted-foreground/20" : "bg-muted-foreground/10 dark:bg-muted-foreground/5"} transition-all duration-300 ease-in-out`} + > +
+ {this._getPreviewComponent(mesh)} + +
+
+
{this.props.title}
+ {mesh &&
{mesh.name}
} +
+ + {mesh && ( +
+ + +
+ )} +
+
{ + const oldMesh = this.props.object[this.props.property]; + + this.props.object[this.props.property] = null; + this.props.onChange?.(null); + + if (!this.props.noUndoRedo) { + registerUndoRedo({ + executeRedo: true, + undo: () => { + this.props.object[this.props.property] = oldMesh; + }, + redo: () => { + this.props.object[this.props.property] = null; + }, + }); + } + + this.forceUpdate(); + }} + className="flex justify-center items-center w-24 h-full hover:bg-muted-foreground rounded-lg transition-all duration-300" + > + {mesh && } +
+
+ + {mesh && this.props.children} +
+ ); + } + + private _getPreviewComponent(mesh: Mesh | null | undefined): ReactNode { + return ( +
+ {mesh ? ( +
+
{mesh.name}
+
+ ) : ( + + )} +
+ ); + } + + private _handleDragOver(ev: DragEvent): void { + ev.preventDefault(); + this.setState({ dragOver: true }); + } + + private _handleDragLeave(ev: DragEvent): void { + ev.preventDefault(); + this.setState({ dragOver: false }); + } + + private async _handleDrop(ev: DragEvent): Promise { + ev.preventDefault(); + this.setState({ dragOver: false, loading: true }); + + try { + const absolutePath = JSON.parse(ev.dataTransfer.getData("assets"))[0]; + const extension = extname(absolutePath).toLowerCase(); + + const supportedExtensions = [".x", ".b3d", ".dae", ".glb", ".gltf", ".fbx", ".stl", ".lwo", ".dxf", ".obj", ".3ds", ".ms3d", ".blend", ".babylon"]; + + if (!supportedExtensions.includes(extension)) { + toast.error(`Unsupported geometry format: ${extension}`); + this.setState({ loading: false }); + return; + } + + const scene = this.props.scene ?? (isScene(this.props.object) ? this.props.object : this.props.object.getScene?.()); + + if (!scene) { + toast.error("Scene is not available"); + this.setState({ loading: false }); + return; + } + + const result = await loadImportedSceneFile(scene, absolutePath); + + if (!result || !result.meshes || result.meshes.length === 0) { + toast.error("Failed to load geometry file"); + this.setState({ loading: false }); + return; + } + + // Use the first mesh or find a mesh without parent + let importedMesh: Mesh | null = null; + for (const m of result.meshes) { + if (m instanceof Mesh && !m.parent) { + importedMesh = m; + break; + } + } + + if (!importedMesh && result.meshes.length > 0 && result.meshes[0] instanceof Mesh) { + importedMesh = result.meshes[0]; + } + + if (!importedMesh) { + toast.error("No valid mesh found in geometry file"); + this.setState({ loading: false }); + return; + } + + // Configure imported mesh + configureImportedNodeIds(importedMesh); + importedMesh.setEnabled(false); // Hide the source mesh + + const oldMesh = this.props.object[this.props.property]; + + this.props.object[this.props.property] = importedMesh; + this.props.onChange?.(importedMesh); + + if (!this.props.noUndoRedo) { + registerUndoRedo({ + executeRedo: true, + undo: () => { + this.props.object[this.props.property] = oldMesh; + if (importedMesh && importedMesh !== oldMesh) { + importedMesh.dispose(); + } + }, + redo: () => { + this.props.object[this.props.property] = importedMesh; + }, + onLost: () => { + if (importedMesh && importedMesh !== oldMesh) { + importedMesh.dispose(); + } + }, + }); + } + + // Dispose other meshes from the imported file + for (const m of result.meshes) { + if (m !== importedMesh) { + m.dispose(); + } + } + + // Dispose transform nodes + for (const tn of result.transformNodes) { + tn.dispose(); + } + + this.forceUpdate(); + } catch (error) { + console.error("Failed to load geometry:", error); + toast.error(`Failed to load geometry: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.setState({ loading: false }); + } + } +} diff --git a/editor/src/editor/layout/inspector/fields/gradient.tsx b/editor/src/editor/layout/inspector/fields/gradient.tsx new file mode 100644 index 000000000..2c51755d6 --- /dev/null +++ b/editor/src/editor/layout/inspector/fields/gradient.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { Button, Popover } from "@blueprintjs/core"; +import { GradientPicker, type IGradientKey } from "../../../../ui/gradient-picker"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../../ui/shadcn/ui/tooltip"; +import { MdOutlineInfo } from "react-icons/md"; +import { registerUndoRedo } from "../../../../tools/undoredo"; +import { getInspectorPropertyValue } from "../../../../tools/property"; +import { IEditorInspectorFieldProps } from "./field"; + +export interface IEditorInspectorColorGradientFieldProps extends IEditorInspectorFieldProps { + onChange?: (colorKeys: IGradientKey[], alphaKeys?: IGradientKey[]) => void; + onFinishChange?: (colorKeys: IGradientKey[], alphaKeys?: IGradientKey[], oldColorKeys?: IGradientKey[], oldAlphaKeys?: IGradientKey[]) => void; +} + +/** + * Inspector field for editing color gradients + * Works with objects that have colorKeys and alphaKeys properties + * Similar to EditorInspectorColorField but for gradients + */ +export function EditorInspectorColorGradientField(props: IEditorInspectorColorGradientFieldProps) { + // Get colorKeys and alphaKeys from object + const getColorKeys = (): IGradientKey[] => { + if (props.property) { + return (getInspectorPropertyValue(props.object, `${props.property}.colorKeys`) || []) as IGradientKey[]; + } + return ((props.object as any)?.colorKeys || []) as IGradientKey[]; + }; + + const getAlphaKeys = (): IGradientKey[] => { + if (props.property) { + return (getInspectorPropertyValue(props.object, `${props.property}.alphaKeys`) || []) as IGradientKey[]; + } + return ((props.object as any)?.alphaKeys || []) as IGradientKey[]; + }; + + const colorKeys = getColorKeys(); + const alphaKeys = getAlphaKeys(); + + const [value, setValue] = useState({ colorKeys, alphaKeys }); + const [oldValue, setOldValue] = useState({ colorKeys: [...colorKeys], alphaKeys: [...alphaKeys] }); + + // Generate preview gradient CSS + const generatePreview = (): string => { + const sorted = [...value.colorKeys].sort((a, b) => (a.pos || 0) - (b.pos || 0)); + if (sorted.length === 0) { + return "linear-gradient(to right, rgba(0, 0, 0, 1) 0%, rgba(1, 1, 1, 1) 100%)"; + } + + const stops = sorted.map((key) => { + const pos = (key.pos || 0) * 100; + let color = "rgba(0, 0, 0, 1)"; + if (Array.isArray(key.value)) { + const [r, g, b, a = 1] = key.value; + color = `rgba(${r * 255}, ${g * 255}, ${b * 255}, ${a})`; + } else if (typeof key.value === "object" && "r" in key.value) { + const r = key.value.r * 255; + const g = key.value.g * 255; + const b = key.value.b * 255; + const a = ("a" in key.value && key.value.a !== undefined ? key.value.a : 1) * 255; + color = `rgba(${r}, ${g}, ${b}, ${a / 255})`; + } + return `${color} ${pos}%`; + }); + return `linear-gradient(to right, ${stops.join(", ")})`; + }; + + function getPopoverContent() { + return ( + { + const updatedValue = { colorKeys: newColorKeys, alphaKeys: newAlphaKeys || value.alphaKeys }; + setValue(updatedValue); + + // Update object properties + if (props.object && props.property) { + (props.object as any)[props.property] = { + ...(props.object as any)[props.property], + colorKeys: newColorKeys, + alphaKeys: newAlphaKeys || value.alphaKeys, + }; + } + + props.onChange?.(newColorKeys, newAlphaKeys); + }} + onFinish={(newColorKeys, newAlphaKeys) => { + const updatedValue = { colorKeys: newColorKeys, alphaKeys: newAlphaKeys || value.alphaKeys }; + setValue(updatedValue); + + // Update object properties + if (props.object) { + if (props.property) { + (props.object as any)[props.property] = { + ...(props.object as any)[props.property], + colorKeys: newColorKeys, + alphaKeys: newAlphaKeys || value.alphaKeys, + }; + } else { + (props.object as any).colorKeys = newColorKeys; + (props.object as any).alphaKeys = newAlphaKeys || value.alphaKeys; + } + } + + if (!props.noUndoRedo) { + const newValue = { colorKeys: [...newColorKeys], alphaKeys: [...(newAlphaKeys || value.alphaKeys)] }; + + registerUndoRedo({ + undo: () => { + if (props.object) { + if (props.property) { + (props.object as any)[props.property] = { + ...(props.object as any)[props.property], + colorKeys: oldValue.colorKeys, + alphaKeys: oldValue.alphaKeys, + }; + } else { + (props.object as any).colorKeys = oldValue.colorKeys; + (props.object as any).alphaKeys = oldValue.alphaKeys; + } + } + setValue(oldValue); + }, + redo: () => { + if (props.object) { + if (props.property) { + (props.object as any)[props.property] = { + ...(props.object as any)[props.property], + colorKeys: newValue.colorKeys, + alphaKeys: newValue.alphaKeys, + }; + } else { + (props.object as any).colorKeys = newValue.colorKeys; + (props.object as any).alphaKeys = newValue.alphaKeys; + } + } + setValue(newValue); + }, + }); + + setOldValue(newValue); + } + + props.onFinishChange?.(newColorKeys, newAlphaKeys || value.alphaKeys, oldValue.colorKeys, oldValue.alphaKeys); + }} + /> + ); + } + + return ( +
+
+ {props.label} + + {props.tooltip && ( + + + + + + {props.tooltip} + + + )} +
+ +
+ +
+
+ ); +} diff --git a/editor/src/editor/layout/inspector/fields/vector.tsx b/editor/src/editor/layout/inspector/fields/vector.tsx index e2447bbb1..24977e195 100644 --- a/editor/src/editor/layout/inspector/fields/vector.tsx +++ b/editor/src/editor/layout/inspector/fields/vector.tsx @@ -22,10 +22,15 @@ export interface IEditorInspectorVectorFieldProps extends IEditorInspectorFieldP } export function EditorInspectorVectorField(props: IEditorInspectorVectorFieldProps) { - const value = props.object[props.property] as IVector4Like; + const value = props.object?.[props.property] as IVector4Like | undefined; const [pointerOver, setPointerOver] = useState(false); + // Return null if value is undefined or null + if (!value || typeof value !== "object") { + return null; + } + return (
setPointerOver(true)} onMouseLeave={() => setPointerOver(false)}>
{ Window + this._handleOpenFXEditor()}>FX Editor... + + + ipcRenderer.send("window:minimize")}> Minimize CTRL+M @@ -258,4 +263,10 @@ export class EditorToolbar extends Component { const p = await execNodePty(`code "${join(dirname(this.props.editor.state.projectPath), "/")}"`); await p.wait(); } + + private _handleOpenFXEditor(): void { + ipcRenderer.send("window:open", "build/src/editor/windows/effect-editor", { + projectConfiguration: { ...projectConfiguration }, + }); + } } diff --git a/editor/src/editor/windows/effect-editor/animation.tsx b/editor/src/editor/windows/effect-editor/animation.tsx new file mode 100644 index 000000000..8724ee478 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/animation.tsx @@ -0,0 +1,18 @@ +import { Component, ReactNode } from "react"; +import { IEffectEditor } from "."; + +export interface IEffectEditorAnimationProps { + filePath: string | null; + editor: IEffectEditor; +} + +export class EffectEditorAnimation extends Component { + public render(): ReactNode { + return ( +
+
Animation Panel
+
Animation timeline will be displayed here
+
+ ); + } +} diff --git a/editor/src/editor/windows/effect-editor/converters/index.ts b/editor/src/editor/windows/effect-editor/converters/index.ts new file mode 100644 index 000000000..76a222ac3 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/converters/index.ts @@ -0,0 +1,2 @@ +export * from "./quarksConverter"; +export * from "./unityConverter"; diff --git a/editor/src/editor/windows/effect-editor/converters/quarksConverter.ts b/editor/src/editor/windows/effect-editor/converters/quarksConverter.ts new file mode 100644 index 000000000..e6c35d717 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/converters/quarksConverter.ts @@ -0,0 +1,1226 @@ +import { Matrix, Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { Quaternion } from "@babylonjs/core/Maths/math.vector"; +import { Color3 } from "@babylonjs/core/Maths/math.color"; +import { Texture as BabylonTexture } from "@babylonjs/core/Materials/Textures"; +import { ParticleSystem } from "@babylonjs/core/Particles/particleSystem"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import { Tools } from "@babylonjs/core/Misc/tools"; +import type { + IQuarksJSON, + IQuarksMaterial, + IQuarksTexture, + IQuarksImage, + IQuarksGeometry, + IQuarksObject, + IQuarksParticleEmitterConfig, + IQuarksBehavior, + IQuarksValue, + IQuarksColor, + IQuarksStartSize, + IQuarksStartColor, + IQuarksRotation, + IQuarksGradientKey, + IQuarksShape, + IQuarksColorOverLifeBehavior, + IQuarksGradientColor, + IQuarksConstantColorColor, + IQuarksRandomColorBetweenGradient, + IQuarksSizeOverLifeBehavior, + IQuarksRotationOverLifeBehavior, + IQuarksForceOverLifeBehavior, + IQuarksGravityForceBehavior, + IQuarksSpeedOverLifeBehavior, + IQuarksFrameOverLifeBehavior, + IQuarksLimitSpeedOverLifeBehavior, + IQuarksColorBySpeedBehavior, + IQuarksSizeBySpeedBehavior, + IQuarksRotationBySpeedBehavior, + IQuarksOrbitOverLifeBehavior, +} from "./quarksTypes"; +import type { + ITransform, + IGroup, + IEmitter, + IData, + IMaterial, + ITexture, + IImage, + IGeometry, + IGeometryData, + IParticleSystemConfig, + Behavior, + IColorFunction, + IForceOverLifeBehavior, + ISpeedOverLifeBehavior, + ILimitSpeedOverLifeBehavior, + ISizeBySpeedBehavior, + Value, + IGradientKey, + IShape, +} from "babylonjs-editor-tools"; + +/** + * Converts Quarks Effect to Babylon.js Effect format + * All coordinate system conversions happen here, once + */ +export class QuarksConverter { + // Constants + private static readonly DEFAULT_DURATION = 5; + private static readonly DEFAULT_COLOR = { r: 1, g: 1, b: 1, a: 1 }; + private static readonly DEFAULT_COLOR_HEX = 0xffffff; + private static readonly PREWARM_FPS = 60; + private static readonly DEFAULT_PREWARM_STEP_OFFSET = 1 / QuarksConverter.PREWARM_FPS; + + // Three.js constants + private static readonly THREE_REPEAT_WRAPPING = 1000; + private static readonly THREE_CLAMP_TO_EDGE_WRAPPING = 1001; + private static readonly THREE_MIRRORED_REPEAT_WRAPPING = 1002; + private static readonly THREE_LINEAR_FILTER = 1006; + private static readonly THREE_NEAREST_MIPMAP_NEAREST_FILTER = 1007; + private static readonly THREE_LINEAR_MIPMAP_NEAREST_FILTER = 1008; + private static readonly THREE_NEAREST_MIPMAP_LINEAR_FILTER = 1009; + /** + * Convert Quarks Effect to Babylon.js Effect format + * Handles errors gracefully and returns partial data if conversion fails + */ + public convert(data: IQuarksJSON): IData { + let root: IGroup | IEmitter | null = null; + + try { + root = this._convertObject(data.object, null); + } catch (error) { + console.error(`Failed to convert root object: ${error instanceof Error ? error.message : String(error)}`); + } + + // Convert all resources with error handling + const materials = this._convertResources(data.materials, (m) => this._convertMaterial(m), "materials"); + const textures = this._convertResources(data.textures, (t) => this._convertTexture(t), "textures"); + const images = this._convertResources(data.images, (i) => this._convertImage(i), "images"); + const geometries = this._convertResources(data.geometries, (g) => this._convertGeometry(g), "geometries"); + + return { + root, + materials, + textures, + images, + geometries, + }; + } + + /** + * Helper: Convert resources array with error handling + */ + private _convertResources(items: T[] | undefined, converter: (item: T) => R, resourceName: string): R[] { + try { + return (items || []).map(converter); + } catch (error) { + console.error(`Failed to convert ${resourceName}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + } + + /** + * Convert a IQuarks object to Babylon.js format + */ + private _convertObject(obj: IQuarksObject, parentUuid: string | null): IGroup | IEmitter | null { + if (!obj || typeof obj !== "object") { + return null; + } + + // Convert transform from right-handed to left-handed + const transform = this._convertTransform(obj.matrix, obj.position, obj.rotation, obj.scale); + + if (obj.type === "Group") { + const group: IGroup = { + uuid: obj.uuid || Tools.RandomId(), + name: obj.name || "Group", + transform, + children: [], + }; + + // Convert children + if (obj.children && Array.isArray(obj.children)) { + for (const child of obj.children) { + const convertedChild = this._convertObject(child, group.uuid); + if (convertedChild) { + group.children.push(convertedChild); + } + } + } + + return group; + } else if (obj.type === "ParticleEmitter" && obj.ps) { + // Convert emitter config from IQuarks to format + const config = this._convertEmitterConfig(obj.ps); + + const emitter: IEmitter = { + uuid: obj.uuid || Tools.RandomId(), + name: obj.name || "ParticleEmitter", + transform, + config, + materialId: obj.ps.material, + parentUuid: parentUuid ?? undefined, + systemType: config.systemType, // systemType is set in _convertEmitterConfig + matrix: obj.matrix, // Store original matrix for rotation extraction + }; + + return emitter; + } + + return null; + } + + /** + * Convert transform from IQuarks (right-handed) to Babylon.js (left-handed) + * This is the ONLY place where handedness conversion happens + */ + private _convertTransform(matrixArray?: number[], positionArray?: number[], rotationArray?: number[], scaleArray?: number[]): ITransform { + const position = Vector3.Zero(); + const rotation = Quaternion.Identity(); + const scale = Vector3.One(); + + if (matrixArray && Array.isArray(matrixArray) && matrixArray.length >= 16) { + // Use matrix (most accurate) + const matrix = Matrix.FromArray(matrixArray); + const tempPos = Vector3.Zero(); + const tempRot = Quaternion.Zero(); + const tempScale = Vector3.Zero(); + matrix.decompose(tempScale, tempRot, tempPos); + + // Convert from right-handed to left-handed + position.copyFrom(tempPos); + position.z = -position.z; // Negate Z position + + rotation.copyFrom(tempRot); + // Convert rotation quaternion: invert X component for proper X-axis rotation conversion + // This handles the case where X=-90° in RH looks like X=0° in LH + rotation.x *= -1; + + scale.copyFrom(tempScale); + } else { + // Use individual components + if (positionArray && Array.isArray(positionArray)) { + position.set(positionArray[0] || 0, positionArray[1] || 0, positionArray[2] || 0); + position.z = -position.z; // Convert to left-handed + } + + if (rotationArray && Array.isArray(rotationArray)) { + // If rotation is Euler angles, convert to quaternion + const eulerX = rotationArray[0] || 0; + const eulerY = rotationArray[1] || 0; + const eulerZ = rotationArray[2] || 0; + Quaternion.RotationYawPitchRollToRef(eulerY, eulerX, -eulerZ, rotation); // Negate Z for handedness + rotation.x *= -1; // Adjust X rotation component + } + + if (scaleArray && Array.isArray(scaleArray)) { + scale.set(scaleArray[0] || 1, scaleArray[1] || 1, scaleArray[2] || 1); + } + } + + return { + position, + rotation, + scale, + }; + } + + /** + * Convert emitter config from IQuarks to format + */ + private _convertEmitterConfig(config: IQuarksParticleEmitterConfig): IParticleSystemConfig { + const result = this._convertBasicEmitterConfig(config); + this._convertLifeProperties(config, result); + this._convertEmissionProperties(config, result); + this._convertVisualProperties(config, result); + this._convertBehaviorsAndShape(config, result); + this._convertBillboardConfig(config, result); + return result; + } + + /** + * Convert basic emitter configuration (system type, duration, prewarm, etc.) + */ + private _convertBasicEmitterConfig(config: IQuarksParticleEmitterConfig): IParticleSystemConfig { + const systemType: "solid" | "base" = config.renderMode === 2 ? "solid" : "base"; + const duration = config.duration ?? QuarksConverter.DEFAULT_DURATION; + const targetStopDuration = config.looping ? 0 : duration; + + // Convert prewarm to native preWarmCycles + let preWarmCycles = 0; + let preWarmStepOffset = QuarksConverter.DEFAULT_PREWARM_STEP_OFFSET; + if (config.prewarm) { + preWarmCycles = Math.ceil(duration * QuarksConverter.PREWARM_FPS); + preWarmStepOffset = QuarksConverter.DEFAULT_PREWARM_STEP_OFFSET; + } + + const isLocal = config.worldSpace === undefined ? false : !config.worldSpace; + const disposeOnStop = config.autoDestroy ?? false; + + return { + version: config.version, + systemType, + targetStopDuration, + preWarmCycles, + preWarmStepOffset, + isLocal, + disposeOnStop, + instancingGeometry: config.instancingGeometry, + renderOrder: config.renderOrder, + layers: config.layers, + uTileCount: config.uTileCount, + vTileCount: config.vTileCount, + }; + } + + /** + * Convert life-related properties (lifeTime, size, rotation, color) + */ + private _convertLifeProperties(config: IQuarksParticleEmitterConfig, result: IParticleSystemConfig): void { + if (config.startLife !== undefined) { + const lifeResult = this._convertValueToMinMax(config.startLife); + result.minLifeTime = lifeResult.min; + result.maxLifeTime = lifeResult.max; + if (lifeResult.gradients) { + result.lifeTimeGradients = lifeResult.gradients; + } + } + + if (config.startSize !== undefined) { + const sizeResult = this._convertStartSizeToMinMax(config.startSize); + result.minSize = sizeResult.min; + result.maxSize = sizeResult.max; + if (sizeResult.gradients) { + result.startSizeGradients = sizeResult.gradients; + } + } + + if (config.startRotation !== undefined) { + const rotResult = this._convertRotationToMinMax(config.startRotation); + result.minInitialRotation = rotResult.min; + result.maxInitialRotation = rotResult.max; + } + + if (config.startColor !== undefined) { + const colorResult = this._convertStartColorToColor4(config.startColor); + result.color1 = colorResult.color1; + result.color2 = colorResult.color2; + } + } + + /** + * Convert emission-related properties (speed, rate, bursts) + */ + private _convertEmissionProperties(config: IQuarksParticleEmitterConfig, result: IParticleSystemConfig): void { + if (config.startSpeed !== undefined) { + const speedResult = this._convertValueToMinMax(config.startSpeed); + result.minEmitPower = speedResult.min; + result.maxEmitPower = speedResult.max; + } + + if (config.emissionOverTime !== undefined) { + const emitResult = this._convertValueToMinMax(config.emissionOverTime); + result.emitRate = emitResult.min; + if (emitResult.gradients) { + result.emitRateGradients = emitResult.gradients; + } + } + + if (config.emissionOverDistance !== undefined) { + result.emissionOverDistance = this._convertValue(config.emissionOverDistance); + } + + if (config.emissionBursts !== undefined && Array.isArray(config.emissionBursts)) { + result.emissionBursts = config.emissionBursts.map((burst) => ({ + time: this._convertValue(burst.time), + count: this._convertValue(burst.count), + })); + } + } + + /** + * Convert visual properties (sprite animation, shape) + */ + private _convertVisualProperties(config: IQuarksParticleEmitterConfig, result: IParticleSystemConfig): void { + if (config.startTileIndex !== undefined) { + result.startTileIndex = this._convertValue(config.startTileIndex); + } + + if (config.shape !== undefined) { + result.shape = this._convertShape(config.shape); + } + } + + /** + * Convert behaviors and shape + */ + private _convertBehaviorsAndShape(config: IQuarksParticleEmitterConfig, result: IParticleSystemConfig): void { + if (config.behaviors !== undefined && Array.isArray(config.behaviors)) { + result.behaviors = config.behaviors.map((behavior) => this._convertBehavior(behavior)); + } + } + + /** + * Convert billboard configuration from renderMode + */ + private _convertBillboardConfig(config: IQuarksParticleEmitterConfig, result: IParticleSystemConfig): void { + const billboardConfig = this._convertRenderMode(config.renderMode); + result.isBillboardBased = billboardConfig.isBillboardBased; + if (billboardConfig.billboardMode !== undefined) { + result.billboardMode = billboardConfig.billboardMode; + } + } + + /** + * Helper: Convert optional IQuarksValue to optional Value + */ + private _convertOptionalValue(value: IQuarksValue | undefined): Value | undefined { + return value !== undefined ? this._convertValue(value) : undefined; + } + + /** + * Helper: Convert array of gradient keys + */ + private _convertGradientKeys(keys: IQuarksGradientKey[] | undefined): IGradientKey[] { + return keys ? keys.map((k) => this._convertGradientKey(k)) : []; + } + + /** + * Helper: Convert speed/frame value (can be Value or object with keys) + */ + private _convertSpeedOrFrameValue( + value: IQuarksValue | { keys?: IQuarksGradientKey[]; functions?: unknown[] } | undefined + ): Value | { keys?: IGradientKey[]; functions?: unknown[] } | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value === "object" && value !== null && "keys" in value) { + const result: { keys?: IGradientKey[]; functions?: unknown[] } = {}; + if (value.keys) { + result.keys = this._convertGradientKeys(value.keys); + } + if ("functions" in value && value.functions) { + result.functions = value.functions; + } + return result; + } + if (typeof value === "number" || (typeof value === "object" && value !== null && "type" in value)) { + return this._convertValue(value as IQuarksValue); + } + return undefined; + } + + /** + * Helper: Create Color4 from RGBA with fallbacks + */ + private _createColor4(r: number | undefined, g: number | undefined, b: number | undefined, a: number | undefined = 1): Color4 { + return new Color4(r ?? 1, g ?? 1, b ?? 1, a ?? 1); + } + + /** + * Helper: Create Color4 from array + */ + private _createColor4FromArray(arr: [number, number, number, number] | undefined): Color4 { + return this._createColor4(arr?.[0], arr?.[1], arr?.[2], arr?.[3]); + } + + /** + * Helper: Create Color4 from RGBA object + */ + private _createColor4FromRGBA(rgba: { r: number; g: number; b: number; a?: number } | undefined): Color4 { + return rgba ? this._createColor4(rgba.r, rgba.g, rgba.b, rgba.a) : this._createColor4(1, 1, 1, 1); + } + + /** + * Helper: Convert renderMode to billboard config + */ + private _convertRenderMode(renderMode: number | undefined): { isBillboardBased: boolean; billboardMode?: number } { + const renderModeMap: Record = { + 0: { isBillboardBased: true, billboardMode: ParticleSystem.BILLBOARDMODE_ALL }, + 1: { isBillboardBased: true, billboardMode: ParticleSystem.BILLBOARDMODE_STRETCHED }, + 2: { isBillboardBased: false, billboardMode: ParticleSystem.BILLBOARDMODE_ALL }, + 3: { isBillboardBased: true, billboardMode: ParticleSystem.BILLBOARDMODE_ALL }, + 4: { isBillboardBased: true, billboardMode: ParticleSystem.BILLBOARDMODE_Y }, + 5: { isBillboardBased: true, billboardMode: ParticleSystem.BILLBOARDMODE_Y }, + }; + + if (renderMode !== undefined && renderMode in renderModeMap) { + return renderModeMap[renderMode]; + } + return { isBillboardBased: true, billboardMode: ParticleSystem.BILLBOARDMODE_ALL }; + } + + /** + * Helper: Flip Z coordinate in array (for left-handed conversion) + */ + private _flipZCoordinate(array: number[], itemSize: number = 3): number[] { + const result = Array.from(array); + for (let i = itemSize - 1; i < result.length; i += itemSize) { + result[i] = -result[i]; + } + return result; + } + + /** + * Helper: Convert attribute array + */ + private _convertAttribute(attr: { array: number[]; itemSize: number } | undefined, flipZ: boolean = false): { array: number[]; itemSize: number } | undefined { + if (!attr) { + return undefined; + } + return { + array: flipZ ? this._flipZCoordinate(attr.array, attr.itemSize) : Array.from(attr.array), + itemSize: attr.itemSize, + }; + } + + /** + * Convert IQuarks value to value + */ + private _convertValue(value: IQuarksValue): Value { + if (typeof value === "number") { + return value; + } + if (value.type === "ConstantValue") { + return { + type: "ConstantValue", + value: value.value, + }; + } + if (value.type === "IntervalValue") { + return { + type: "IntervalValue", + min: value.a ?? 0, + max: value.b ?? 0, + }; + } + if (value.type === "PiecewiseBezier") { + return { + type: "PiecewiseBezier", + functions: value.functions.map((f) => ({ + function: f.function, + start: f.start, + })), + }; + } + // Fallback: return as Value (should not happen with proper types) + return value as Value; + } + + /** + * Convert IQuarksStartSize to min/max (handles Vector3Function) + * - ConstantValue → min = max = value + * - IntervalValue → min = a, max = b + * - PiecewiseBezier → gradients array + */ + private _convertStartSizeToMinMax(startSize: IQuarksStartSize): { min: number; max: number; gradients?: Array<{ gradient: number; factor: number; factor2?: number }> } { + // Handle Vector3Function type + if (typeof startSize === "object" && startSize !== null && "type" in startSize && startSize.type === "Vector3Function") { + // For Vector3Function, use the main value or average of x, y, z + if (startSize.value !== undefined) { + return this._convertValueToMinMax(startSize.value); + } + // Fallback: use x value if available + if (startSize.x !== undefined) { + return this._convertValueToMinMax(startSize.x); + } + return { min: 1, max: 1 }; + } + // Otherwise treat as IQuarksValue + return this._convertValueToMinMax(startSize as IQuarksValue); + } + + private _convertValueToMinMax(value: IQuarksValue): { min: number; max: number; gradients?: Array<{ gradient: number; factor: number; factor2?: number }> } { + if (typeof value === "number") { + return { min: value, max: value }; + } + if (value.type === "ConstantValue") { + return { min: value.value, max: value.value }; + } + if (value.type === "IntervalValue") { + return { min: value.a ?? 0, max: value.b ?? 0 }; + } + if (value.type === "PiecewiseBezier" && value.functions) { + // Convert PiecewiseBezier to gradients + const gradients: Array<{ gradient: number; factor: number; factor2?: number }> = []; + let minVal = Infinity; + let maxVal = -Infinity; + + for (const func of value.functions) { + const startTime = func.start; + // Evaluate bezier at start and end points + const startValue = this._evaluateBezierAt(func.function, 0); + const endValue = this._evaluateBezierAt(func.function, 1); + + gradients.push({ gradient: startTime, factor: startValue }); + + // Track min/max for fallback + minVal = Math.min(minVal, startValue, endValue); + maxVal = Math.max(maxVal, startValue, endValue); + } + + // Add final point at gradient 1.0 if not present + if (gradients.length > 0 && gradients[gradients.length - 1].gradient < 1) { + const lastFunc = value.functions[value.functions.length - 1]; + const endValue = this._evaluateBezierAt(lastFunc.function, 1); + gradients.push({ gradient: 1, factor: endValue }); + } + + return { + min: minVal === Infinity ? 1 : minVal, + max: maxVal === -Infinity ? 1 : maxVal, + gradients: gradients.length > 0 ? gradients : undefined, + }; + } + return { min: 1, max: 1 }; + } + + /** + * Evaluate bezier curve at time t + * Bezier format: { p0, p1, p2, p3 } for cubic bezier + */ + private _evaluateBezierAt(bezier: { p0: number; p1: number; p2: number; p3: number }, t: number): number { + const { p0, p1, p2, p3 } = bezier; + const t2 = t * t; + const t3 = t2 * t; + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + return mt3 * p0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * p3; + } + + /** + * Helper: Extract min/max from IQuarksValue + */ + private _extractMinMaxFromValue(value: IQuarksValue | undefined): { min: number; max: number } { + if (value === undefined) { + return { min: 0, max: 0 }; + } + if (typeof value === "number") { + return { min: value, max: value }; + } + if (value.type === "ConstantValue") { + return { min: value.value, max: value.value }; + } + if (value.type === "IntervalValue") { + return { min: value.a ?? 0, max: value.b ?? 0 }; + } + return { min: 0, max: 0 }; + } + + /** + * Convert IQuarks rotation to native min/max radians + * Supports: number, ConstantValue, IntervalValue, Euler, AxisAngle, RandomQuat + */ + private _convertRotationToMinMax(rotation: IQuarksRotation): { min: number; max: number } { + if (typeof rotation === "number") { + return { min: rotation, max: rotation }; + } + + if (typeof rotation === "object" && rotation !== null && "type" in rotation) { + const rotationType = rotation.type; + + if (rotationType === "ConstantValue") { + return this._extractMinMaxFromValue(rotation as IQuarksValue); + } + + if (rotationType === "IntervalValue") { + return this._extractMinMaxFromValue(rotation as IQuarksValue); + } + + // Handle Euler type - for 2D/billboard particles we use angleZ, fallback to angleX + if (rotationType === "Euler") { + const euler = rotation as { type: string; angleZ?: IQuarksValue; angleX?: IQuarksValue }; + if (euler.angleZ !== undefined) { + return this._extractMinMaxFromValue(euler.angleZ); + } + if (euler.angleX !== undefined) { + return this._extractMinMaxFromValue(euler.angleX); + } + } + } + + return { min: 0, max: 0 }; + } + + /** + * Convert IQuarksStartColor to native Babylon.js Color4 (color1, color2) + */ + private _convertStartColorToColor4(startColor: IQuarksStartColor): { color1: Color4; color2: Color4 } { + // Handle Gradient type + if (typeof startColor === "object" && startColor !== null && "type" in startColor) { + if (startColor.type === "Gradient") { + // For Gradient, extract color from CLinearFunction if available + const gradientColor = startColor as IQuarksGradientColor; + if (gradientColor.color?.keys && gradientColor.color.keys.length > 0) { + const firstKey = gradientColor.color.keys[0]; + const lastKey = gradientColor.color.keys[gradientColor.color.keys.length - 1]; + const color1 = this._extractColorFromGradientKey(firstKey); + const color2 = this._extractColorFromGradientKey(lastKey); + return { color1, color2 }; + } + } + if (startColor.type === "ColorRange") { + // For ColorRange, use a and b colors + const colorRange = startColor as { type: string; a?: { r: number; g: number; b: number; a?: number }; b?: { r: number; g: number; b: number; a?: number } }; + const color1 = this._createColor4FromRGBA(colorRange.a); + const color2 = this._createColor4FromRGBA(colorRange.b); + return { color1, color2 }; + } + } + // Otherwise treat as IQuarksColor + return this._convertColorToColor4(startColor as IQuarksColor); + } + + /** + * Extract Color4 from gradient key value + */ + private _extractColorFromGradientKey(key: IQuarksGradientKey): Color4 { + if (Array.isArray(key.value)) { + return this._createColor4FromArray(key.value as [number, number, number, number]); + } + if (typeof key.value === "object" && key.value !== null && "r" in key.value) { + return this._createColor4FromRGBA(key.value as { r: number; g: number; b: number; a?: number }); + } + return this._createColor4(1, 1, 1, 1); + } + + /** + * Convert IQuarks color to native Babylon.js Color4 (color1, color2) + */ + private _convertColorToColor4(color: IQuarksColor): { color1: Color4; color2: Color4 } { + if (Array.isArray(color)) { + const c = this._createColor4FromArray(color as [number, number, number, number]); + return { color1: c, color2: c }; + } + + if (typeof color === "object" && color !== null && "type" in color) { + if (color.type === "ConstantColor") { + const constColor = color as IQuarksConstantColorColor; + if (constColor.value && Array.isArray(constColor.value)) { + const c = this._createColor4FromArray(constColor.value); + return { color1: c, color2: c }; + } + if (constColor.color) { + const c = this._createColor4FromRGBA(constColor.color); + return { color1: c, color2: c }; + } + } + // Handle RandomColor (interpolation between two colors) + const randomColor = color as { type: string; a?: [number, number, number, number]; b?: [number, number, number, number] }; + if (randomColor.type === "RandomColor" && randomColor.a && randomColor.b) { + const color1 = this._createColor4FromArray(randomColor.a); + const color2 = this._createColor4FromArray(randomColor.b); + return { color1, color2 }; + } + } + + const white = this._createColor4(1, 1, 1, 1); + return { color1: white, color2: white }; + } + + /** + * Convert IQuarks gradient key to gradient key + */ + private _convertGradientKey(key: IQuarksGradientKey): IGradientKey { + return { + time: key.time, + value: key.value, + pos: key.pos, + }; + } + + /** + * Convert IQuarks shape to shape + */ + private _convertShape(shape: IQuarksShape): IShape { + const result: IShape = { + type: shape.type, + radius: shape.radius, + arc: shape.arc, + thickness: shape.thickness, + angle: shape.angle, + mode: shape.mode, + spread: shape.spread, + size: shape.size, + height: shape.height, + }; + if (shape.speed !== undefined) { + result.speed = this._convertValue(shape.speed); + } + return result; + } + + /** + * Convert IQuarks behavior to behavior + */ + private _convertBehavior(behavior: IQuarksBehavior): Behavior { + switch (behavior.type) { + case "ColorOverLife": + return this._convertColorOverLifeBehavior(behavior as IQuarksColorOverLifeBehavior); + case "SizeOverLife": + return this._convertSizeOverLifeBehavior(behavior as IQuarksSizeOverLifeBehavior); + case "RotationOverLife": + case "Rotation3DOverLife": + return this._convertRotationOverLifeBehavior(behavior as IQuarksRotationOverLifeBehavior); + case "ForceOverLife": + case "ApplyForce": + return this._convertForceOverLifeBehavior(behavior as IQuarksForceOverLifeBehavior); + case "GravityForce": + return this._convertGravityForceBehavior(behavior as IQuarksGravityForceBehavior); + case "SpeedOverLife": + return this._convertSpeedOverLifeBehavior(behavior as IQuarksSpeedOverLifeBehavior); + case "FrameOverLife": + return this._convertFrameOverLifeBehavior(behavior as IQuarksFrameOverLifeBehavior); + case "LimitSpeedOverLife": + return this._convertLimitSpeedOverLifeBehavior(behavior as IQuarksLimitSpeedOverLifeBehavior); + case "ColorBySpeed": + return this._convertColorBySpeedBehavior(behavior as IQuarksColorBySpeedBehavior); + case "SizeBySpeed": + return this._convertSizeBySpeedBehavior(behavior as IQuarksSizeBySpeedBehavior); + case "RotationBySpeed": + return this._convertRotationBySpeedBehavior(behavior as IQuarksRotationBySpeedBehavior); + case "OrbitOverLife": + return this._convertOrbitOverLifeBehavior(behavior as IQuarksOrbitOverLifeBehavior); + default: + // Fallback for unknown behaviors - copy as-is + return behavior as Behavior; + } + } + + /** + * Extract color from ConstantColor behavior + */ + private _extractConstantColor(constantColor: IQuarksConstantColorColor): { r: number; g: number; b: number; a: number } { + if (constantColor.color) { + return { + r: constantColor.color.r, + g: constantColor.color.g, + b: constantColor.color.b, + a: constantColor.color.a ?? 1, + }; + } + if (constantColor.value && Array.isArray(constantColor.value) && constantColor.value.length >= 4) { + return { + r: constantColor.value[0], + g: constantColor.value[1], + b: constantColor.value[2], + a: constantColor.value[3], + }; + } + return QuarksConverter.DEFAULT_COLOR; + } + + /** + * Convert ColorOverLife behavior + */ + private _convertColorOverLifeBehavior(behavior: IQuarksColorOverLifeBehavior): Behavior { + if (!behavior.color) { + return { + type: "ColorOverLife", + color: { + colorFunctionType: "ConstantColor", + data: {}, + }, + }; + } + + const colorType = behavior.color.type; + let colorFunction: IColorFunction; + + if (colorType === "Gradient") { + const gradientColor = behavior.color as IQuarksGradientColor; + colorFunction = { + colorFunctionType: "Gradient", + data: { + colorKeys: this._convertGradientKeys(gradientColor.color?.keys), + alphaKeys: this._convertGradientKeys(gradientColor.alpha?.keys), + }, + }; + } else if (colorType === "ConstantColor") { + const constantColor = behavior.color as IQuarksConstantColorColor; + const color = this._extractConstantColor(constantColor); + colorFunction = { + colorFunctionType: "ConstantColor", + data: { + color: { + r: color.r ?? 1, + g: color.g ?? 1, + b: color.b ?? 1, + a: color.a !== undefined ? color.a : 1, + }, + }, + }; + } else if (colorType === "RandomColorBetweenGradient") { + const randomColor = behavior.color as IQuarksRandomColorBetweenGradient; + colorFunction = { + colorFunctionType: "RandomColorBetweenGradient", + data: { + gradient1: { + colorKeys: this._convertGradientKeys(randomColor.gradient1?.color?.keys), + alphaKeys: this._convertGradientKeys(randomColor.gradient1?.alpha?.keys), + }, + gradient2: { + colorKeys: this._convertGradientKeys(randomColor.gradient2?.color?.keys), + alphaKeys: this._convertGradientKeys(randomColor.gradient2?.alpha?.keys), + }, + }, + }; + } else { + // Fallback: try to detect format from keys + const colorData = behavior.color as { color?: { keys?: IQuarksGradientKey[] }; alpha?: { keys?: IQuarksGradientKey[] }; keys?: IQuarksGradientKey[] }; + const hasColorKeys = colorData.color?.keys && colorData.color.keys.length > 0; + const hasAlphaKeys = colorData.alpha?.keys && colorData.alpha.keys.length > 0; + const hasKeys = colorData.keys && colorData.keys.length > 0; + + if (hasColorKeys || hasAlphaKeys || hasKeys) { + const colorKeys = hasColorKeys ? this._convertGradientKeys(colorData.color?.keys) : hasKeys ? this._convertGradientKeys(colorData.keys) : []; + const alphaKeys = hasAlphaKeys ? this._convertGradientKeys(colorData.alpha?.keys) : []; + colorFunction = { + colorFunctionType: "Gradient", + data: { + colorKeys, + alphaKeys, + }, + }; + } else { + // Default to ConstantColor + colorFunction = { + colorFunctionType: "ConstantColor", + data: {}, + }; + } + } + + return { + type: "ColorOverLife", + color: colorFunction, + }; + } + + /** + * Convert SizeOverLife behavior + */ + private _convertSizeOverLifeBehavior(behavior: IQuarksSizeOverLifeBehavior): Behavior { + if (!behavior.size) { + return { type: "SizeOverLife" }; + } + return { + type: "SizeOverLife", + size: { + ...(behavior.size.keys && { keys: this._convertGradientKeys(behavior.size.keys) }), + ...(behavior.size.functions && { functions: behavior.size.functions }), + }, + }; + } + + /** + * Convert RotationOverLife behavior + */ + private _convertRotationOverLifeBehavior(behavior: IQuarksRotationOverLifeBehavior): Behavior { + return { + type: behavior.type, + angularVelocity: this._convertOptionalValue(behavior.angularVelocity), + }; + } + + /** + * Convert ForceOverLife behavior + */ + private _convertForceOverLifeBehavior(behavior: IQuarksForceOverLifeBehavior): Behavior { + const result: IForceOverLifeBehavior = { type: behavior.type }; + if (behavior.force) { + result.force = { + x: this._convertOptionalValue(behavior.force.x), + y: this._convertOptionalValue(behavior.force.y), + z: this._convertOptionalValue(behavior.force.z), + }; + } + result.x = this._convertOptionalValue(behavior.x); + result.y = this._convertOptionalValue(behavior.y); + result.z = this._convertOptionalValue(behavior.z); + return result; + } + + /** + * Convert GravityForce behavior + */ + private _convertGravityForceBehavior(behavior: IQuarksGravityForceBehavior): Behavior { + return { + type: "GravityForce", + gravity: this._convertOptionalValue(behavior.gravity), + } as Behavior; + } + + /** + * Convert SpeedOverLife behavior + */ + private _convertSpeedOverLifeBehavior(behavior: IQuarksSpeedOverLifeBehavior): Behavior { + const speed = this._convertSpeedOrFrameValue(behavior.speed); + return { type: "SpeedOverLife", ...(speed !== undefined && { speed }) } as ISpeedOverLifeBehavior; + } + + /** + * Convert FrameOverLife behavior + */ + private _convertFrameOverLifeBehavior(behavior: IQuarksFrameOverLifeBehavior): Behavior { + const frame = this._convertSpeedOrFrameValue(behavior.frame); + return { type: "FrameOverLife", ...(frame !== undefined && { frame }) } as Behavior; + } + + /** + * Convert LimitSpeedOverLife behavior + */ + private _convertLimitSpeedOverLifeBehavior(behavior: IQuarksLimitSpeedOverLifeBehavior): Behavior { + const speed = this._convertSpeedOrFrameValue(behavior.speed); + return { + type: "LimitSpeedOverLife", + maxSpeed: this._convertOptionalValue(behavior.maxSpeed), + ...(speed !== undefined && { speed }), + dampen: this._convertOptionalValue(behavior.dampen), + } as ILimitSpeedOverLifeBehavior; + } + + /** + * Convert ColorBySpeed behavior + */ + private _convertColorBySpeedBehavior(behavior: IQuarksColorBySpeedBehavior): Behavior { + const colorFunction: IColorFunction = behavior.color?.keys + ? { + colorFunctionType: "Gradient", + data: { + colorKeys: this._convertGradientKeys(behavior.color.keys), + alphaKeys: [], + }, + } + : { + colorFunctionType: "ConstantColor", + data: {}, + }; + + return { + type: "ColorBySpeed", + color: colorFunction, + minSpeed: this._convertOptionalValue(behavior.minSpeed), + maxSpeed: this._convertOptionalValue(behavior.maxSpeed), + }; + } + + /** + * Convert SizeBySpeed behavior + */ + private _convertSizeBySpeedBehavior(behavior: IQuarksSizeBySpeedBehavior): Behavior { + return { + type: "SizeBySpeed", + minSpeed: this._convertOptionalValue(behavior.minSpeed), + maxSpeed: this._convertOptionalValue(behavior.maxSpeed), + ...(behavior.size?.keys && { size: { keys: this._convertGradientKeys(behavior.size.keys) } }), + } as ISizeBySpeedBehavior; + } + + /** + * Convert RotationBySpeed behavior + */ + private _convertRotationBySpeedBehavior(behavior: IQuarksRotationBySpeedBehavior): Behavior { + return { + type: "RotationBySpeed", + angularVelocity: this._convertOptionalValue(behavior.angularVelocity), + minSpeed: this._convertOptionalValue(behavior.minSpeed), + maxSpeed: this._convertOptionalValue(behavior.maxSpeed), + } as Behavior; + } + + /** + * Convert OrbitOverLife behavior + */ + private _convertOrbitOverLifeBehavior(behavior: IQuarksOrbitOverLifeBehavior): Behavior { + return { + type: "OrbitOverLife", + center: behavior.center, + radius: this._convertOptionalValue(behavior.radius), + speed: this._convertOptionalValue(behavior.speed), + } as Behavior; + } + + /** + * Convert IQuarks materials to materials + */ + private _convertMaterial(material: IQuarksMaterial): IMaterial { + const babylonMaterial: IMaterial = { + uuid: material.uuid, + type: material.type, + transparent: material.transparent, + depthWrite: material.depthWrite, + side: material.side, + map: material.map, + }; + + // Convert color from hex to Color3 + if (material.color !== undefined) { + const colorHex = typeof material.color === "number" ? material.color : parseInt(String(material.color).replace("#", ""), 16) || QuarksConverter.DEFAULT_COLOR_HEX; + const r = ((colorHex >> 16) & 0xff) / 255; + const g = ((colorHex >> 8) & 0xff) / 255; + const b = (colorHex & 0xff) / 255; + babylonMaterial.color = new Color3(r, g, b); + } + + // Convert blending mode (Three.js → Babylon.js) + if (material.blending !== undefined) { + const blendModeMap: Record = { + 0: 0, // NoBlending → ALPHA_DISABLE + 1: 1, // NormalBlending → ALPHA_COMBINE + 2: 2, // AdditiveBlending → ALPHA_ADD + }; + babylonMaterial.blending = blendModeMap[material.blending] ?? material.blending; + } + + return babylonMaterial; + } + + /** + * Convert IQuarks textures to textures + */ + private _convertTexture(texture: IQuarksTexture): ITexture { + const babylonTexture: ITexture = { + uuid: texture.uuid, + image: texture.image, + generateMipmaps: texture.generateMipmaps, + flipY: texture.flipY, + }; + + // Convert wrap mode (Three.js → Babylon.js) + if (texture.wrap && Array.isArray(texture.wrap)) { + const wrapModeMap: Record = { + [QuarksConverter.THREE_REPEAT_WRAPPING]: BabylonTexture.WRAP_ADDRESSMODE, + [QuarksConverter.THREE_CLAMP_TO_EDGE_WRAPPING]: BabylonTexture.CLAMP_ADDRESSMODE, + [QuarksConverter.THREE_MIRRORED_REPEAT_WRAPPING]: BabylonTexture.MIRROR_ADDRESSMODE, + }; + babylonTexture.wrapU = wrapModeMap[texture.wrap[0]] ?? BabylonTexture.WRAP_ADDRESSMODE; + babylonTexture.wrapV = wrapModeMap[texture.wrap[1]] ?? BabylonTexture.WRAP_ADDRESSMODE; + } + + // Convert repeat to scale + if (texture.repeat && Array.isArray(texture.repeat)) { + babylonTexture.uScale = texture.repeat[0] || 1; + babylonTexture.vScale = texture.repeat[1] || 1; + } + + // Convert offset + if (texture.offset && Array.isArray(texture.offset)) { + babylonTexture.uOffset = texture.offset[0] || 0; + babylonTexture.vOffset = texture.offset[1] || 0; + } + + // Convert rotation + if (texture.rotation !== undefined) { + babylonTexture.uAng = texture.rotation; + } + + // Convert channel + if (typeof texture.channel === "number") { + babylonTexture.coordinatesIndex = texture.channel; + } + + // Convert sampling mode (Three.js filters → Babylon.js sampling mode) + if (texture.minFilter !== undefined) { + if (texture.minFilter === QuarksConverter.THREE_LINEAR_MIPMAP_NEAREST_FILTER || texture.minFilter === QuarksConverter.THREE_NEAREST_MIPMAP_LINEAR_FILTER) { + babylonTexture.samplingMode = BabylonTexture.TRILINEAR_SAMPLINGMODE; + } else if (texture.minFilter === QuarksConverter.THREE_NEAREST_MIPMAP_NEAREST_FILTER || texture.minFilter === QuarksConverter.THREE_LINEAR_FILTER) { + babylonTexture.samplingMode = BabylonTexture.BILINEAR_SAMPLINGMODE; + } else { + babylonTexture.samplingMode = BabylonTexture.NEAREST_SAMPLINGMODE; + } + } else if (texture.magFilter !== undefined) { + babylonTexture.samplingMode = texture.magFilter === QuarksConverter.THREE_LINEAR_FILTER ? BabylonTexture.BILINEAR_SAMPLINGMODE : BabylonTexture.NEAREST_SAMPLINGMODE; + } else { + babylonTexture.samplingMode = BabylonTexture.TRILINEAR_SAMPLINGMODE; + } + + return babylonTexture; + } + + /** + * Convert IQuarks images to images (normalize URLs) + */ + private _convertImage(image: IQuarksImage): IImage { + return { + uuid: image.uuid, + url: image.url || "", + }; + } + + /** + * Convert IQuarks geometries to geometries (convert to left-handed) + */ + private _convertGeometry(geometry: IQuarksGeometry): IGeometry { + if (geometry.type === "PlaneGeometry") { + // PlaneGeometry - simple properties + const planeGeometry = geometry as IQuarksGeometry & { width?: number; height?: number }; + return { + uuid: geometry.uuid, + type: "PlaneGeometry" as const, + width: planeGeometry.width ?? 1, + height: planeGeometry.height ?? 1, + }; + } else if (geometry.type === "BufferGeometry") { + // BufferGeometry - convert attributes to left-handed + const result: IGeometry = { + uuid: geometry.uuid, + type: "BufferGeometry", + }; + + if (geometry.data?.attributes) { + const attributes: IGeometryData["attributes"] = {}; + const sourceAttrs = geometry.data.attributes; + + // Convert position and normal (right-hand → left-hand: flip Z) + const positionAttr = this._convertAttribute(sourceAttrs.position, true); + if (positionAttr) { + attributes.position = positionAttr; + } + + const normalAttr = this._convertAttribute(sourceAttrs.normal, true); + if (normalAttr) { + attributes.normal = normalAttr; + } + + // UV and color - no conversion needed + const uvAttr = this._convertAttribute(sourceAttrs.uv, false); + if (uvAttr) { + attributes.uv = uvAttr; + } + + const colorAttr = this._convertAttribute(sourceAttrs.color, false); + if (colorAttr) { + attributes.color = colorAttr; + } + + result.data = { + attributes, + }; + + // Convert indices (reverse winding order for left-handed) + if (geometry.data.index) { + const indices = Array.from(geometry.data.index.array); + // Reverse winding: swap every 2nd and 3rd index in each triangle + for (let i = 0; i < indices.length; i += 3) { + const temp = indices[i + 1]; + indices[i + 1] = indices[i + 2]; + indices[i + 2] = temp; + } + result.data.index = { + array: indices, + }; + } + } + + return result; + } + + // Unknown geometry type - return as-is + return { + uuid: geometry.uuid, + type: geometry.type as "PlaneGeometry" | "BufferGeometry", + }; + } +} diff --git a/editor/src/editor/windows/effect-editor/converters/quarksTypes.ts b/editor/src/editor/windows/effect-editor/converters/quarksTypes.ts new file mode 100644 index 000000000..0ebc564f1 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/converters/quarksTypes.ts @@ -0,0 +1,481 @@ +/** + * Type definitions for Quarks JSON structures + * These represent the incoming format from Quarks + */ + +/** + * Common Bezier function structure used across multiple types + */ +export interface IQuarksBezierFunction { + p0: number; + p1: number; + p2: number; + p3: number; +} + +export interface IQuarksBezierFunctionSegment { + function: IQuarksBezierFunction; + start: number; +} + +/** + * Common RGBA color structure + */ +export interface IQuarksRGBA { + r: number; + g: number; + b: number; + a?: number; +} + +/** + * Quarks value types + */ +export interface IQuarksConstantValue { + type: "ConstantValue"; + value: number; +} + +export interface IQuarksIntervalValue { + type: "IntervalValue"; + a: number; // min + b: number; // max +} + +export interface IQuarksPiecewiseBezier { + type: "PiecewiseBezier"; + functions: IQuarksBezierFunctionSegment[]; +} + +export type IQuarksValue = IQuarksConstantValue | IQuarksIntervalValue | IQuarksPiecewiseBezier | number; + +/** + * Quarks color types + */ +export interface IQuarksConstantColor { + type: "ConstantColor"; + color?: IQuarksRGBA; + value?: [number, number, number, number]; // RGBA array alternative +} + +export type IQuarksColor = IQuarksConstantColor | [number, number, number, number] | string; + +/** + * Quarks rotation types + */ +export interface IQuarksEulerRotation { + type: "Euler"; + angleX?: IQuarksValue; + angleY?: IQuarksValue; + angleZ?: IQuarksValue; + eulerOrder?: string; + functions?: IQuarksBezierFunctionSegment[]; + a?: number; + b?: number; + value?: number; +} + +export type IQuarksRotation = IQuarksEulerRotation | IQuarksValue; + +/** + * Quarks gradient key + */ +export interface IQuarksGradientKey { + time?: number; + value: number | [number, number, number, number] | IQuarksRGBA; + pos?: number; +} + +/** + * Quarks shape configuration + */ +export interface IQuarksShape { + type: string; + radius?: number; + arc?: number; + thickness?: number; + angle?: number; + mode?: number; + spread?: number; + speed?: IQuarksValue; + size?: number[]; + height?: number; +} + +/** + * Quarks emission burst + */ +export interface IQuarksEmissionBurst { + time: IQuarksValue; + count: IQuarksValue; + cycle?: number; + interval?: number; + probability?: number; +} + +/** + * Quarks behavior types + */ +export interface IQuarksCLinearFunction { + type: "CLinearFunction"; + subType: "Color" | "Number"; + keys: IQuarksGradientKey[]; +} + +export interface IQuarksGradientColor { + type: "Gradient"; + color?: IQuarksCLinearFunction; + alpha?: IQuarksCLinearFunction; +} + +export interface IQuarksConstantColorColor { + type: "ConstantColor"; + color?: IQuarksRGBA; + value?: [number, number, number, number]; +} + +export interface IQuarksRandomColorBetweenGradient { + type: "RandomColorBetweenGradient"; + gradient1?: IQuarksGradientColor; + gradient2?: IQuarksGradientColor; +} + +export type IQuarksColorOverLifeColor = IQuarksGradientColor | IQuarksConstantColorColor | IQuarksRandomColorBetweenGradient; + +export interface IQuarksColorOverLifeBehavior { + type: "ColorOverLife"; + color?: IQuarksColorOverLifeColor; +} + +export interface IQuarksSizeOverLifeBehavior { + type: "SizeOverLife"; + size?: { + keys?: IQuarksGradientKey[]; + functions?: IQuarksBezierFunctionSegment[]; + type?: string; + }; +} + +export interface IQuarksRotationOverLifeBehavior { + type: "RotationOverLife" | "Rotation3DOverLife"; + angularVelocity?: IQuarksValue; +} + +export interface IQuarksForceOverLifeBehavior { + type: "ForceOverLife" | "ApplyForce"; + force?: { + x?: IQuarksValue; + y?: IQuarksValue; + z?: IQuarksValue; + }; + x?: IQuarksValue; + y?: IQuarksValue; + z?: IQuarksValue; +} + +export interface IQuarksGravityForceBehavior { + type: "GravityForce"; + gravity?: IQuarksValue; +} + +export interface IQuarksSpeedOverLifeBehavior { + type: "SpeedOverLife"; + speed?: + | { + keys?: IQuarksGradientKey[]; + functions?: Array<{ + start: number; + function: { + p0?: number; + p3?: number; + }; + }>; + } + | IQuarksValue; +} + +export interface IQuarksFrameOverLifeBehavior { + type: "FrameOverLife"; + frame?: + | { + keys?: IQuarksGradientKey[]; + } + | IQuarksValue; +} + +export interface IQuarksLimitSpeedOverLifeBehavior { + type: "LimitSpeedOverLife"; + maxSpeed?: IQuarksValue; + speed?: IQuarksValue | { keys?: IQuarksGradientKey[] }; + dampen?: IQuarksValue; +} + +/** + * Base interface for speed-based behaviors + */ +interface IQuarksSpeedBasedBehavior { + minSpeed?: IQuarksValue; + maxSpeed?: IQuarksValue; +} + +export interface IQuarksColorBySpeedBehavior extends IQuarksSpeedBasedBehavior { + type: "ColorBySpeed"; + color?: { + keys: IQuarksGradientKey[]; + }; +} + +export interface IQuarksSizeBySpeedBehavior extends IQuarksSpeedBasedBehavior { + type: "SizeBySpeed"; + size?: { + keys: IQuarksGradientKey[]; + }; +} + +export interface IQuarksRotationBySpeedBehavior extends IQuarksSpeedBasedBehavior { + type: "RotationBySpeed"; + angularVelocity?: IQuarksValue; +} + +export interface IQuarksOrbitOverLifeBehavior { + type: "OrbitOverLife"; + center?: { + x?: number; + y?: number; + z?: number; + }; + radius?: IQuarksValue; + speed?: IQuarksValue; +} + +export interface IQuarksNoiseBehavior { + type: "Noise"; + frequency?: IQuarksValue; + power?: IQuarksValue; + positionAmount?: IQuarksValue; + rotationAmount?: IQuarksValue; + x?: IQuarksValue; + y?: IQuarksValue; + z?: IQuarksValue; +} + +export type IQuarksBehavior = + | IQuarksColorOverLifeBehavior + | IQuarksSizeOverLifeBehavior + | IQuarksRotationOverLifeBehavior + | IQuarksForceOverLifeBehavior + | IQuarksGravityForceBehavior + | IQuarksSpeedOverLifeBehavior + | IQuarksFrameOverLifeBehavior + | IQuarksLimitSpeedOverLifeBehavior + | IQuarksColorBySpeedBehavior + | IQuarksSizeBySpeedBehavior + | IQuarksRotationBySpeedBehavior + | IQuarksOrbitOverLifeBehavior + | IQuarksNoiseBehavior + | { type: string; [key: string]: unknown }; // Fallback for unknown behaviors + +/** + * Quarks start size with Vector3Function support + */ +export interface IQuarksVector3FunctionSize { + type: "Vector3Function"; + x?: IQuarksValue; + y?: IQuarksValue; + z?: IQuarksValue; + functions?: IQuarksBezierFunctionSegment[]; + a?: number; + b?: number; + value?: number; +} + +export type IQuarksStartSize = IQuarksValue | IQuarksVector3FunctionSize; + +/** + * Quarks start color with Gradient and ColorRange support + */ +export interface IQuarksGradientStartColor { + type: "Gradient"; + alpha?: IQuarksCLinearFunction; + color?: IQuarksCLinearFunction; +} + +export interface IQuarksColorRangeStartColor { + type: "ColorRange"; + a?: IQuarksRGBA; + b?: IQuarksRGBA; + color?: IQuarksCLinearFunction; + alpha?: IQuarksCLinearFunction; +} + +export type IQuarksStartColor = IQuarksColor | IQuarksGradientStartColor | IQuarksColorRangeStartColor; + +/** + * Quarks particle emitter configuration + */ +export interface IQuarksParticleEmitterConfig { + version?: string; + autoDestroy?: boolean; + looping?: boolean; + prewarm?: boolean; + duration?: number; + shape?: IQuarksShape; + startLife?: IQuarksValue; + startSpeed?: IQuarksValue; + startRotation?: IQuarksRotation; + startSize?: IQuarksStartSize; + startColor?: IQuarksStartColor; + emissionOverTime?: IQuarksValue; + emissionOverDistance?: IQuarksValue; + emissionBursts?: IQuarksEmissionBurst[]; + onlyUsedByOther?: boolean; + instancingGeometry?: string; + renderOrder?: number; + renderMode?: number; + rendererEmitterSettings?: Record; + material?: string; + layers?: number; + startTileIndex?: IQuarksValue; + uTileCount?: number; + vTileCount?: number; + blendTiles?: boolean; + softParticles?: boolean; + softFarFade?: number; + softNearFade?: number; + behaviors?: IQuarksBehavior[]; + worldSpace?: boolean; +} + +/** + * Base interface for Quarks objects with common transform properties + */ +interface IQuarksObjectBase { + uuid: string; + name: string; + matrix: number[]; + layers: number; + up: number[]; + children: IQuarksObject[]; + position?: number[]; + rotation?: number[]; + scale?: number[]; +} + +/** + * Quarks object types + */ +export interface IQuarksGroup extends IQuarksObjectBase { + type: "Group"; +} + +export interface IQuarksParticleEmitter extends IQuarksObjectBase { + type: "ParticleEmitter"; + ps: IQuarksParticleEmitterConfig; +} + +export type IQuarksObject = IQuarksGroup | IQuarksParticleEmitter; + +/** + * Base interface for Quarks resources (materials, textures, images, geometries) + */ +interface IQuarksResource { + uuid: string; + name?: string; +} + +/** + * Quarks material + */ +export interface IQuarksMaterial extends IQuarksResource { + type: string; + color?: number; + map?: string; + blending?: number; + blendColor?: number; + side?: number; + transparent?: boolean; + depthWrite?: boolean; + envMapRotation?: number[]; + reflectivity?: number; + refractionRatio?: number; +} + +/** + * Quarks texture + */ +export interface IQuarksTexture extends IQuarksResource { + image?: string; + mapping?: number; + wrap?: number[]; + repeat?: number[]; + offset?: number[]; + center?: number[]; + rotation?: number; + minFilter?: number; + magFilter?: number; + flipY?: boolean; + generateMipmaps?: boolean; + format?: number; + internalFormat?: number | null; + type?: number; + channel?: number; + anisotropy?: number; + colorSpace?: string; + premultiplyAlpha?: boolean; + unpackAlignment?: number; +} + +/** + * Quarks image + */ +export interface IQuarksImage extends IQuarksResource { + url?: string; +} + +/** + * Quarks geometry + */ +export interface IQuarksGeometry extends IQuarksResource { + type: string; + data?: { + attributes?: Record< + string, + { + itemSize: number; + type: string; + array: number[]; + normalized?: boolean; + } + >; + index?: { + type: string; + array: number[]; + }; + }; + // Geometry-specific properties (for different geometry types) + height?: number; + heightSegments?: number; + width?: number; + widthSegments?: number; + radius?: number; + phiLength?: number; + phiStart?: number; + thetaLength?: number; + thetaStart?: number; +} + +/** + * Quarks JSON structure + */ +export interface IQuarksJSON { + metadata: { + version: number; + type: string; + generator: string; + }; + geometries: IQuarksGeometry[]; + materials: IQuarksMaterial[]; + textures: IQuarksTexture[]; + images: IQuarksImage[]; + object: IQuarksObject; +} diff --git a/editor/src/editor/windows/effect-editor/converters/unityConverter.ts b/editor/src/editor/windows/effect-editor/converters/unityConverter.ts new file mode 100644 index 000000000..3056d9838 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/converters/unityConverter.ts @@ -0,0 +1,1184 @@ +/** + * Unity Prefab → Babylon.js Effect Converter + * + * Converts Unity particle system prefabs directly to our Babylon.js effect format, + * bypassing the Quarks JSON intermediate step. + * + * Based on extracted Unity → Quarks converter logic, but outputs IData format. + */ + +import { Vector3, Quaternion } from "@babylonjs/core/Maths/math.vector"; +import { Color4, Color3 } from "@babylonjs/core/Maths/math.color"; +import { Scene } from "@babylonjs/core/scene"; +import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader"; +import { Mesh } from "@babylonjs/core/Meshes/mesh"; +import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData"; +import { Tools } from "@babylonjs/core/Misc/tools"; +import type { + IData, + IEmitter, + IGroup, + IParticleSystemConfig, + Behavior, + Value, + IConstantColor, + IGradientColor, + IRandomColor, + IRandomColorBetweenGradient, + IMaterial, + ITexture, + IImage, + IGeometry, +} from "babylonjs-editor-tools/src/effect/types"; +import * as yaml from "js-yaml"; + +// Note: Babylon.js loaders (FBXFileLoader, OBJFileLoader) are imported in toolbar.tsx +// via "babylonjs-loaders" to register them with SceneLoader globally. +// This allows SceneLoader.ImportMeshAsync to work with FBX/OBJ files. + +/** + * Helper to get component by type from GameObject + */ +function getComponentByType(gameObject: any, componentType: string, components: Map): any | null { + if (!gameObject.m_Component) return null; + + for (const compRef of gameObject.m_Component) { + const compId = compRef.component?.fileID || compRef.component; + const comp = components.get(compId); + if (comp && comp[componentType]) { + return comp[componentType]; + } + } + + return null; +} + +/** + * Find root GameObject in hierarchy (Transform with no parent) + * Based on original Unity converter logic + */ +function findRootGameObject(components: Map): string | null { + console.log(`[findRootGameObject] Searching in ${components.size} components`); + + // Look for Transform component with m_Father.fileID === "0" + let transformCount = 0; + let gameObjectCount = 0; + + for (const [_id, comp] of components) { + if (comp.Transform) transformCount++; + if (comp.GameObject) gameObjectCount++; + + // Check if this component is a Transform + if (comp.Transform) { + // Check if Transform has m_Father with fileID === "0" (no parent = root) + if (comp.Transform.m_Father !== undefined && comp.Transform.m_Father !== null) { + const fatherFileID = typeof comp.Transform.m_Father === "object" ? comp.Transform.m_Father.fileID : comp.Transform.m_Father; + const fatherFileIDStr = String(fatherFileID); + + if (fatherFileIDStr === "0") { + // Found root Transform, get the GameObject it belongs to + const gameObjectRef = comp.Transform.m_GameObject; + if (gameObjectRef) { + const gameObjectFileID = typeof gameObjectRef === "object" ? gameObjectRef.fileID : gameObjectRef; + const gameObjectFileIDStr = String(gameObjectFileID); + + // IMPORTANT: Return the component ID (key in Map) that contains this GameObject + // The gameObjectFileIDStr is the fileID reference, but we need to find the component with that ID + // Components are stored with their YAML anchor ID as the key (e.g., "195608") + const gameObjectComponent = components.get(gameObjectFileIDStr); + if (gameObjectComponent && gameObjectComponent.GameObject) { + console.log( + `[findRootGameObject] Found root Transform with m_Father === "0", GameObject fileID: ${gameObjectFileIDStr}, component ID: ${gameObjectFileIDStr}` + ); + return gameObjectFileIDStr; // This is the component ID/key + } else { + console.warn(`[findRootGameObject] GameObject ${gameObjectFileIDStr} not found in components map`); + } + } + } + } else if (comp.Transform.m_GameObject) { + // If no m_Father, it might be root (check if it's the only Transform) + const gameObjectRef = comp.Transform.m_GameObject; + const gameObjectFileID = typeof gameObjectRef === "object" ? gameObjectRef.fileID : gameObjectRef; + // Try this as root if we don't find one with m_Father === "0" + const candidate = String(gameObjectFileID); + // But first check if there's a Transform with explicit m_Father === "0" + let hasExplicitRoot = false; + for (const [_id2, comp2] of components) { + if (comp2.Transform && comp2.Transform.m_Father !== undefined && comp2.Transform.m_Father !== null) { + const fatherFileID2 = typeof comp2.Transform.m_Father === "object" ? comp2.Transform.m_Father.fileID : comp2.Transform.m_Father; + if (String(fatherFileID2) === "0") { + hasExplicitRoot = true; + break; + } + } + } + if (!hasExplicitRoot) { + console.log(`[findRootGameObject] Using Transform without m_Father as root, GameObject fileID: ${candidate}`); + return candidate; + } + } + } + } + + console.log(`[findRootGameObject] No Transform with m_Father === "0" found. Transform count: ${transformCount}, GameObject count: ${gameObjectCount}`); + + // Fallback: find first GameObject if no root Transform found + for (const [_id, comp] of components) { + if (comp.GameObject) { + console.log(`[findRootGameObject] Fallback: Using first GameObject found, component ID: ${_id}`); + return _id; // Use component ID as GameObject ID + } + } + + console.warn(`[findRootGameObject] No GameObject found at all! Component keys:`, Array.from(components.keys()).slice(0, 10)); + console.log(`[findRootGameObject] Sample component structure:`, Array.from(components.entries())[0]); + return null; +} + +/** + * Unity to Babylon.js coordinate system conversion + * Unity: Y-up, left-handed → Babylon.js: Y-up, left-handed (same!) + * But Quarks was Three.js (right-handed), so no conversion needed for us + */ +function convertVector3(unityVec: { x: string; y: string; z: string }): [number, number, number] { + return [parseFloat(unityVec.x), parseFloat(unityVec.y), parseFloat(unityVec.z)]; +} + +/** + * Convert Unity Color to our Color4 + */ +function convertColor(unityColor: { r: string; g: string; b: string; a: string }): Color4 { + return new Color4(parseFloat(unityColor.r), parseFloat(unityColor.g), parseFloat(unityColor.b), parseFloat(unityColor.a)); +} + +/** + * Convert Unity AnimationCurve to our PiecewiseBezier Value + */ +function convertAnimationCurve(curve: any, scalar: number = 1): Value { + const m_Curve = curve.m_Curve; + if (!m_Curve || m_Curve.length === 0) { + return { type: "ConstantValue", value: 0 }; + } + + // If only one key, return constant + if (m_Curve.length === 1) { + return { type: "ConstantValue", value: parseFloat(m_Curve[0].value) * scalar }; + } + + // Convert to PiecewiseBezier + const functions: Array<{ + function: { + p0: number; + p1: number; + p2: number; + p3: number; + }; + start: number; + }> = []; + + // Add initial key if curve doesn't start at 0 + if (m_Curve.length >= 1 && parseFloat(m_Curve[0].time) > 0) { + const val = parseFloat(m_Curve[0].value) * scalar; + functions.push({ + function: { + p0: val, + p1: val, + p2: val, + p3: val, + }, + start: 0, + }); + } + + // Convert each segment + for (let i = 0; i < m_Curve.length - 1; i++) { + const curr = m_Curve[i]; + const next = m_Curve[i + 1]; + const segmentDuration = parseFloat(next.time) - parseFloat(curr.time); + + const p0 = parseFloat(curr.value) * scalar; + const p1 = (parseFloat(curr.value) + (parseFloat(curr.outSlope) * segmentDuration) / 3) * scalar; + const p2 = (parseFloat(next.value) - (parseFloat(next.inSlope) * segmentDuration) / 3) * scalar; + const p3 = parseFloat(next.value) * scalar; + + functions.push({ + function: { + p0, + p1, + p2, + p3, + }, + start: parseFloat(curr.time), + }); + } + + // Add final key if curve doesn't end at 1 + if (m_Curve.length >= 2 && parseFloat(m_Curve[m_Curve.length - 1].time) < 1) { + const val = parseFloat(m_Curve[m_Curve.length - 1].value) * scalar; + functions.push({ + function: { + p0: val, + p1: val, + p2: val, + p3: val, + }, + start: parseFloat(m_Curve[m_Curve.length - 1].time), + }); + } + + return { + type: "PiecewiseBezier", + functions, + }; +} + +/** + * Convert Unity MinMaxCurve to our Value + */ +function convertMinMaxCurve(minMaxCurve: any): Value { + const minMaxState = minMaxCurve.minMaxState; + const scalar = parseFloat(minMaxCurve.scalar || "1"); + + switch (minMaxState) { + case "0": // Constant + return { type: "ConstantValue", value: scalar }; + case "1": // Curve + return convertAnimationCurve(minMaxCurve.maxCurve, scalar); + case "2": // Random between two constants + return { + type: "IntervalValue", + min: parseFloat(minMaxCurve.minScalar || "0") * scalar, + max: scalar, + }; + case "3": // Random between two curves + // For now, just use max curve (proper implementation would need RandomColor equivalent for Value) + return convertAnimationCurve(minMaxCurve.maxCurve, scalar); + default: + return { type: "ConstantValue", value: scalar }; + } +} + +/** + * Convert Unity Gradient to our Color + */ +function convertGradient(gradient: any): IConstantColor | IGradientColor { + const colorKeys: Array<{ time: number; value: [number, number, number, number] }> = []; + + // Parse color keys + for (let i = 0; i < gradient.m_NumColorKeys; i++) { + const key = gradient[`key${i}`]; + const time = parseFloat(gradient[`ctime${i}`]) / 65535; // Unity stores time as 0-65535 + colorKeys.push({ + time, + value: [parseFloat(key.r), parseFloat(key.g), parseFloat(key.b), 1], + }); + } + + // Parse alpha keys + const alphaKeys: Array<{ time: number; value: number }> = []; + for (let i = 0; i < gradient.m_NumAlphaKeys; i++) { + const key = gradient[`key${i}`]; + const time = parseFloat(gradient[`atime${i}`]) / 65535; + alphaKeys.push({ + time, + value: parseFloat(key.a), + }); + } + + // If only one color key and one alpha key, return constant color + if (colorKeys.length === 1 && alphaKeys.length === 1) { + return { + type: "ConstantColor", + value: [...colorKeys[0].value.slice(0, 3), alphaKeys[0].value] as [number, number, number, number], + }; + } + + // Return gradient + return { + type: "Gradient", + colorKeys: colorKeys.map((k) => ({ time: k.time, value: k.value as [number, number, number, number] })), + alphaKeys: alphaKeys.map((k) => ({ time: k.time, value: k.value })), + }; +} + +/** + * Convert Unity MinMaxGradient to our Color + */ +function convertMinMaxGradient(minMaxGradient: any): IConstantColor | IGradientColor | IRandomColor | IRandomColorBetweenGradient { + const minMaxState = minMaxGradient.minMaxState; + + switch (minMaxState) { + case "0": // Constant color + return { + type: "ConstantColor", + value: [ + parseFloat(minMaxGradient.maxColor.r), + parseFloat(minMaxGradient.maxColor.g), + parseFloat(minMaxGradient.maxColor.b), + parseFloat(minMaxGradient.maxColor.a), + ] as [number, number, number, number], + }; + case "1": // Gradient + return convertGradient(minMaxGradient.maxGradient); + case "2": // Random between two colors + return { + type: "RandomColor", + colorA: [ + parseFloat(minMaxGradient.minColor.r), + parseFloat(minMaxGradient.minColor.g), + parseFloat(minMaxGradient.minColor.b), + parseFloat(minMaxGradient.minColor.a), + ] as [number, number, number, number], + colorB: [ + parseFloat(minMaxGradient.maxColor.r), + parseFloat(minMaxGradient.maxColor.g), + parseFloat(minMaxGradient.maxColor.b), + parseFloat(minMaxGradient.maxColor.a), + ] as [number, number, number, number], + }; + case "3": // Random between two gradients + const grad1 = convertGradient(minMaxGradient.minGradient); + const grad2 = convertGradient(minMaxGradient.maxGradient); + if (grad1.type === "Gradient" && grad2.type === "Gradient") { + return { + type: "RandomColorBetweenGradient", + gradient1: { + colorKeys: grad1.colorKeys, + alphaKeys: grad1.alphaKeys, + }, + gradient2: { + colorKeys: grad2.colorKeys, + alphaKeys: grad2.alphaKeys, + }, + }; + } + // Fallback to constant color if conversion failed + return { type: "ConstantColor", value: [1, 1, 1, 1] }; + default: + return { type: "ConstantColor", value: [1, 1, 1, 1] }; + } +} + +/** + * Convert Unity ParticleSystem shape to our emitter shape + */ +function convertShape(shapeModule: any): any { + if (!shapeModule || shapeModule.enabled !== "1") { + return { type: "point" }; // Default to point emitter + } + + const shapeType = shapeModule.type; + + switch (shapeType) { + case "0": // Sphere + return { + type: "sphere", + radius: parseFloat(shapeModule.radius?.value || "1"), + arc: (parseFloat(shapeModule.arc?.value || "360") / 180) * Math.PI, + thickness: parseFloat(shapeModule.radiusThickness || "1"), + }; + case "4": // Cone + return { + type: "cone", + radius: parseFloat(shapeModule.radius?.value || "1"), + arc: (parseFloat(shapeModule.arc?.value || "360") / 180) * Math.PI, + thickness: parseFloat(shapeModule.radiusThickness || "1"), + angle: (parseFloat(shapeModule.angle?.value || "25") / 180) * Math.PI, + }; + case "5": // Box + return { + type: "box", + width: parseFloat(shapeModule.boxThickness?.x || "1"), + height: parseFloat(shapeModule.boxThickness?.y || "1"), + depth: parseFloat(shapeModule.boxThickness?.z || "1"), + }; + case "10": // Circle + return { + type: "sphere", // Use sphere with arc for circle + radius: parseFloat(shapeModule.radius?.value || "1"), + arc: (parseFloat(shapeModule.arc?.value || "360") / 180) * Math.PI, + }; + default: + return { type: "point" }; + } +} + +/** + * Convert Unity ParticleSystem to our IParticleSystemConfig + */ +function convertParticleSystem(unityPS: any, _renderer: any): IParticleSystemConfig { + const main = unityPS.InitialModule; + + const config: IParticleSystemConfig = { + version: "2.0", + systemType: "base", // Unity uses GPU particles, similar to our base system + + // Basic properties + minLifeTime: parseFloat(main.startLifetime?.minScalar || main.startLifetime?.scalar || "5"), + maxLifeTime: parseFloat(main.startLifetime?.scalar || "5"), + minSize: parseFloat(main.startSize?.minScalar || main.startSize?.scalar || "1"), + maxSize: parseFloat(main.startSize?.scalar || "1"), + minEmitPower: parseFloat(main.startSpeed?.minScalar || main.startSpeed?.scalar || "5"), + maxEmitPower: parseFloat(main.startSpeed?.scalar || "5"), + emitRate: parseFloat(unityPS.EmissionModule?.rateOverTime?.scalar || "10"), + + // Duration and looping + targetStopDuration: main.looping === "1" ? 0 : parseFloat(main.duration?.scalar || "5"), + preWarmCycles: main.prewarm === "1" ? 100 : 0, + isLocal: main.simulationSpace === "0", // 0 = Local, 1 = World + + // Color + color1: convertColor({ + r: main.startColor?.maxColor?.r || "1", + g: main.startColor?.maxColor?.g || "1", + b: main.startColor?.maxColor?.b || "1", + a: main.startColor?.maxColor?.a || "1", + }), + color2: convertColor({ + r: main.startColor?.maxColor?.r || "1", + g: main.startColor?.maxColor?.g || "1", + b: main.startColor?.maxColor?.b || "1", + a: main.startColor?.maxColor?.a || "1", + }), + + // Rotation + minInitialRotation: parseFloat(main.startRotation?.minScalar || main.startRotation?.scalar || "0"), + maxInitialRotation: parseFloat(main.startRotation?.scalar || "0"), + + // Gravity (if enabled) + gravity: main.gravityModifier?.scalar ? new Vector3(0, parseFloat(main.gravityModifier.scalar) * -9.81, 0) : undefined, + + // Shape/Emitter + shape: convertShape(unityPS.ShapeModule), + + // Behaviors + behaviors: [], + }; + + // Convert modules to behaviors + const behaviors: Behavior[] = []; + + // ColorOverLife + if (unityPS.ColorModule && unityPS.ColorModule.enabled === "1") { + const colorGradient = convertMinMaxGradient(unityPS.ColorModule.gradient); + + // Convert Color type to IColorFunction + let colorFunction: { colorFunctionType: string; data: any }; + if (colorGradient.type === "ConstantColor") { + colorFunction = { + colorFunctionType: "ConstantColor", + data: { + color: { + r: colorGradient.value[0], + g: colorGradient.value[1], + b: colorGradient.value[2], + a: colorGradient.value[3], + }, + }, + }; + } else if (colorGradient.type === "Gradient") { + colorFunction = { + colorFunctionType: "Gradient", + data: { + colorKeys: colorGradient.colorKeys, + alphaKeys: colorGradient.alphaKeys || [], + }, + }; + } else if (colorGradient.type === "RandomColor") { + colorFunction = { + colorFunctionType: "ColorRange", + data: { + colorA: colorGradient.colorA, + colorB: colorGradient.colorB, + }, + }; + } else if (colorGradient.type === "RandomColorBetweenGradient") { + colorFunction = { + colorFunctionType: "RandomColorBetweenGradient", + data: { + gradient1: { + colorKeys: colorGradient.gradient1.colorKeys, + alphaKeys: colorGradient.gradient1.alphaKeys || [], + }, + gradient2: { + colorKeys: colorGradient.gradient2.colorKeys, + alphaKeys: colorGradient.gradient2.alphaKeys || [], + }, + }, + }; + } else { + colorFunction = { + colorFunctionType: "ConstantColor", + data: { color: { r: 1, g: 1, b: 1, a: 1 } }, + }; + } + + behaviors.push({ + type: "ColorOverLife", + color: colorFunction, + }); + } + + // SizeOverLife + if (unityPS.SizeModule && unityPS.SizeModule.enabled === "1") { + const sizeValue = convertMinMaxCurve(unityPS.SizeModule.curve); + behaviors.push({ + type: "SizeOverLife", + size: sizeValue, + }); + } + + // RotationOverLife + if (unityPS.RotationOverLifetimeModule && unityPS.RotationOverLifetimeModule.enabled === "1") { + const rotationZ = convertMinMaxCurve(unityPS.RotationOverLifetimeModule.z || unityPS.RotationOverLifetimeModule.curve); + behaviors.push({ + type: "RotationOverLife", + angularVelocity: rotationZ, + }); + } + + // Rotation3DOverLife (if separate X, Y, Z) + if (unityPS.RotationOverLifetimeModule && unityPS.RotationOverLifetimeModule.enabled === "1" && unityPS.RotationOverLifetimeModule.separateAxes === "1") { + behaviors.push({ + type: "Rotation3DOverLife", + angularVelocityX: convertMinMaxCurve(unityPS.RotationOverLifetimeModule.x), + angularVelocityY: convertMinMaxCurve(unityPS.RotationOverLifetimeModule.y), + angularVelocityZ: convertMinMaxCurve(unityPS.RotationOverLifetimeModule.z), + }); + } + + // VelocityOverLife (SpeedOverLife) + if (unityPS.VelocityModule && unityPS.VelocityModule.enabled === "1") { + const speedModifier = unityPS.VelocityModule.speedModifier || { minMaxState: "0", scalar: "1" }; + behaviors.push({ + type: "SpeedOverLife", + speed: convertMinMaxCurve(speedModifier), + }); + } + + // LimitVelocityOverLife + if (unityPS.ClampVelocityModule && unityPS.ClampVelocityModule.enabled === "1") { + behaviors.push({ + type: "LimitSpeedOverLife", + limitVelocity: convertMinMaxCurve(unityPS.ClampVelocityModule.magnitude), + dampen: parseFloat(unityPS.ClampVelocityModule.dampen || "0.1"), + }); + } + + // ForceOverLife (from Unity's Force module or gravity) + if (unityPS.ForceModule && unityPS.ForceModule.enabled === "1") { + behaviors.push({ + type: "ForceOverLife", + force: { + x: parseFloat(unityPS.ForceModule.x?.scalar || "0"), + y: parseFloat(unityPS.ForceModule.y?.scalar || "0"), + z: parseFloat(unityPS.ForceModule.z?.scalar || "0"), + }, + }); + } + + // ColorBySpeed + if (unityPS.ColorBySpeedModule && unityPS.ColorBySpeedModule.enabled === "1") { + const range = unityPS.ColorBySpeedModule.range; + const colorGradient = convertMinMaxGradient(unityPS.ColorBySpeedModule.gradient); + + let colorFunction: { colorFunctionType: string; data: any }; + if (colorGradient.type === "Gradient") { + colorFunction = { + colorFunctionType: "Gradient", + data: { + colorKeys: colorGradient.colorKeys, + alphaKeys: colorGradient.alphaKeys || [], + }, + }; + } else { + colorFunction = { + colorFunctionType: "ConstantColor", + data: { color: { r: 1, g: 1, b: 1, a: 1 } }, + }; + } + + behaviors.push({ + type: "ColorBySpeed", + color: colorFunction, + minSpeed: { type: "ConstantValue", value: parseFloat(range?.x || "0") }, + maxSpeed: { type: "ConstantValue", value: parseFloat(range?.y || "1") }, + }); + } + + // SizeBySpeed + if (unityPS.SizeBySpeedModule && unityPS.SizeBySpeedModule.enabled === "1") { + const range = unityPS.SizeBySpeedModule.range; + behaviors.push({ + type: "SizeBySpeed", + size: convertMinMaxCurve(unityPS.SizeBySpeedModule.curve), + minSpeed: { type: "ConstantValue", value: parseFloat(range?.x || "0") }, + maxSpeed: { type: "ConstantValue", value: parseFloat(range?.y || "1") }, + }); + } + + // RotationBySpeed + if (unityPS.RotationBySpeedModule && unityPS.RotationBySpeedModule.enabled === "1") { + const range = unityPS.RotationBySpeedModule.range; + behaviors.push({ + type: "RotationBySpeed", + angularVelocity: convertMinMaxCurve(unityPS.RotationBySpeedModule.curve), + minSpeed: { type: "ConstantValue", value: parseFloat(range?.x || "0") }, + maxSpeed: { type: "ConstantValue", value: parseFloat(range?.y || "1") }, + }); + } + + // NoiseModule (approximation) + if (unityPS.NoiseModule && unityPS.NoiseModule.enabled === "1") { + config.noiseStrength = new Vector3( + parseFloat(unityPS.NoiseModule.strengthX?.scalar || "0"), + parseFloat(unityPS.NoiseModule.strengthY?.scalar || "0"), + parseFloat(unityPS.NoiseModule.strengthZ?.scalar || "0") + ); + } + + config.behaviors = behaviors; + + return config; +} + +/** + * Intermediate format for convertGameObject (before conversion to IData format) + */ +interface IIntermediateGameObject { + type: "emitter" | "group"; + name: string; + position: [number, number, number]; + scale: [number, number, number]; + rotation: [number, number, number, number]; + emitter?: IParticleSystemConfig; + renderMode?: number; + materialId?: string; // GUID of material from ParticleSystemRenderer + children?: IIntermediateGameObject[]; +} + +/** + * Convert Unity GameObject hierarchy to our IGroup/IEmitter structure + */ +function convertGameObject(gameObject: any, components: Map): IIntermediateGameObject { + // Get Transform component + const transform = getComponentByType(gameObject, "Transform", components); + + const position = transform ? convertVector3(transform.m_LocalPosition) : ([0, 0, 0] as [number, number, number]); + const scale = transform ? convertVector3(transform.m_LocalScale) : ([1, 1, 1] as [number, number, number]); + const rotation = transform + ? ([parseFloat(transform.m_LocalRotation.x), parseFloat(transform.m_LocalRotation.y), parseFloat(transform.m_LocalRotation.z), parseFloat(transform.m_LocalRotation.w)] as [ + number, + number, + number, + number, + ]) + : ([0, 0, 0, 1] as [number, number, number, number]); + + // Check if this GameObject has a ParticleSystem component + const ps = getComponentByType(gameObject, "ParticleSystem", components); + + if (ps) { + // It's a particle emitter + const renderer = getComponentByType(gameObject, "ParticleSystemRenderer", components); + const emitterConfig = convertParticleSystem(ps, renderer); + + // Determine render mode from renderer + let renderMode = 0; // Default: BillBoard + let materialId: string | undefined; + if (renderer) { + const m_RenderMode = parseInt(renderer.m_RenderMode || "0"); + switch (m_RenderMode) { + case 0: + renderMode = 0; // BillBoard + break; + case 1: + renderMode = 1; // StretchedBillBoard + break; + case 2: + renderMode = 2; // HorizontalBillBoard + break; + case 3: + renderMode = 3; // VerticalBillBoard + break; + case 4: + renderMode = 4; // Mesh + break; + } + + // Extract material GUID from renderer + if (renderer.m_Materials && Array.isArray(renderer.m_Materials) && renderer.m_Materials.length > 0) { + const materialRef = renderer.m_Materials[0]; + if (materialRef && materialRef.guid) { + materialId = materialRef.guid; + } + } + } + + const emitter: IIntermediateGameObject = { + type: "emitter", + name: gameObject.m_Name || "ParticleSystem", + position, + scale, + rotation, + emitter: emitterConfig, + renderMode, + materialId, + }; + + return emitter; + } else { + // It's a group (container) + const group: IIntermediateGameObject = { + type: "group", + name: gameObject.m_Name || "Group", + position, + scale, + rotation, + children: [], + }; + + // Recursively convert children + if (transform && transform.m_Children) { + for (const childRef of transform.m_Children) { + const childTransform = components.get(childRef.fileID); + if (childTransform && childTransform.Transform) { + const childGORef = childTransform.Transform.m_GameObject; + const childGOId = childGORef?.fileID || childGORef; + const childGO = components.get(childGOId); + + if (childGO && childGO.GameObject) { + if (!group.children) { + group.children = []; + } + group.children.push(convertGameObject(childGO.GameObject, components)); + } + } + } + } + + return group; + } +} + +/** + * Convert convertGameObject result to IGroup or IEmitter format + * Recursively processes children + */ +function _convertToIDataFormat(converted: IIntermediateGameObject): IGroup | IEmitter | null { + if (!converted) { + return null; + } + + const uuid = Tools.RandomId(); + + if (converted.type === "group") { + // Convert children recursively + const children: (IGroup | IEmitter)[] = []; + if (converted.children && Array.isArray(converted.children)) { + for (const child of converted.children) { + const childConverted = _convertToIDataFormat(child); + if (childConverted) { + children.push(childConverted); + } + } + } + + const group: IGroup = { + uuid, + name: converted.name, + transform: { + position: new Vector3(converted.position[0], converted.position[1], converted.position[2]), + rotation: new Quaternion(converted.rotation[0], converted.rotation[1], converted.rotation[2], converted.rotation[3]), + scale: new Vector3(converted.scale[0], converted.scale[1], converted.scale[2]), + }, + children: children, + }; + return group; + } else { + if (!converted.emitter) { + console.warn("Emitter config is missing for", converted.name); + return null; + } + const emitter: IEmitter = { + uuid, + name: converted.name, + transform: { + position: new Vector3(converted.position[0], converted.position[1], converted.position[2]), + rotation: new Quaternion(converted.rotation[0], converted.rotation[1], converted.rotation[2], converted.rotation[3]), + scale: new Vector3(converted.scale[0], converted.scale[1], converted.scale[2]), + }, + config: converted.emitter, + systemType: converted.renderMode === 4 ? "solid" : "base", // Mesh = solid, others = base + materialId: converted.materialId, // Link material to emitter + }; + return emitter; + } +} + +/** + * Convert Unity model buffer to IGeometry using Babylon.js loaders + */ +async function convertUnityModel(guid: string, buffer: Buffer, extension: string, scene: Scene): Promise { + try { + // Determine MIME type based on extension + let mimeType = "application/octet-stream"; + if (extension === "obj") { + mimeType = "text/plain"; + } else if (extension === "fbx") { + mimeType = "application/octet-stream"; + } + + // Create data URL from buffer + const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`; + + // Import mesh using Babylon.js SceneLoader + const result = await SceneLoader.ImportMeshAsync("", dataUrl, "", scene); + + if (!result || !result.meshes || result.meshes.length === 0) { + return null; + } + + // Find the first mesh + const mesh = result.meshes.find((m) => m instanceof Mesh) as Mesh | undefined; + if (!mesh || !mesh.geometry) { + return null; + } + + // Extract vertex data + const vertexData = VertexData.ExtractFromMesh(mesh); + if (!vertexData) { + return null; + } + + // Convert to IGeometry format + const geometry: IGeometry = { + uuid: guid, + type: "BufferGeometry", + data: { + attributes: {}, + }, + }; + + // Convert positions + if (vertexData.positions) { + geometry.data!.attributes.position = { + array: Array.from(vertexData.positions), + itemSize: 3, + }; + } + + // Convert normals + if (vertexData.normals) { + geometry.data!.attributes.normal = { + array: Array.from(vertexData.normals), + itemSize: 3, + }; + } + + // Convert UVs + if (vertexData.uvs) { + geometry.data!.attributes.uv = { + array: Array.from(vertexData.uvs), + itemSize: 2, + }; + } + + // Convert indices + if (vertexData.indices) { + geometry.data!.index = { + array: Array.from(vertexData.indices), + }; + } + + // Cleanup: dispose imported meshes + for (const m of result.meshes) { + m.dispose(); + } + + return geometry; + } catch (error) { + console.warn(`Failed to convert Unity model ${guid}:`, error); + return null; + } +} + +/** + * Convert Unity prefab components to IData + * + * @param components - Already parsed Unity components Map (parsed in modal) + * @param dependencies - Optional dependencies (textures, materials, models, sounds) + * @param scene - Babylon.js Scene for loading models (required for model parsing) + * @returns IData structure ready for our Effect system + */ +export async function convertUnityPrefabToData( + components: Map, + dependencies?: { + textures?: Map; + materials?: Map; + models?: Map; + sounds?: Map; + meta?: Map; + }, + scene?: Scene +): Promise { + // Validate components is a Map + if (!(components instanceof Map)) { + console.error("convertUnityPrefabToData: components must be a Map, got:", typeof components, components); + throw new Error("components must be a Map"); + } + + let root: IGroup | IEmitter | null = null; + + // Find root GameObject + const rootGameObjectId = findRootGameObject(components); + if (!rootGameObjectId) { + console.warn("No root GameObject found in Unity prefab"); + return { + root: null, + materials: [], + textures: [], + images: [], + geometries: [], + }; + } + + const rootComponent = components.get(rootGameObjectId); + if (!rootComponent) { + console.warn(`[convertUnityPrefabToData] Root GameObject component ${rootGameObjectId} not found in components map`); + console.log("[convertUnityPrefabToData] Available component IDs (first 20):", Array.from(components.keys()).slice(0, 20)); + console.log("[convertUnityPrefabToData] Component structure samples:"); + for (const [id, comp] of Array.from(components.entries()).slice(0, 5)) { + console.log(` Component ${id}:`, { + keys: Object.keys(comp), + hasTransform: !!comp.Transform, + hasGameObject: !!comp.GameObject, + __type: comp.__type, + }); + } + return { + root: null, + materials: [], + textures: [], + images: [], + geometries: [], + }; + } + + console.log(`[convertUnityPrefabToData] Found root component ${rootGameObjectId}:`, { + keys: Object.keys(rootComponent), + hasGameObject: !!rootComponent.GameObject, + hasTransform: !!rootComponent.Transform, + __type: rootComponent.__type, + }); + + // Get GameObject from component (could be rootComponent.GameObject or rootComponent itself) + const gameObject = rootComponent.GameObject || rootComponent; + if (!gameObject || (typeof gameObject === "object" && !gameObject.m_Name && !gameObject.m_Component)) { + console.warn(`[convertUnityPrefabToData] Root GameObject ${rootGameObjectId} structure invalid:`, rootComponent); + console.log("[convertUnityPrefabToData] Available keys in rootComponent:", Object.keys(rootComponent)); + console.log("[convertUnityPrefabToData] gameObject:", gameObject); + + // Try to find GameObject component directly + for (const [_id, comp] of components) { + if (comp.GameObject && comp.GameObject.m_Name) { + console.log(`[convertUnityPrefabToData] Found GameObject component ${_id} with name:`, comp.GameObject.m_Name); + const foundGameObject = comp.GameObject; + if (foundGameObject.m_Component) { + console.log(`[convertUnityPrefabToData] Using GameObject from component ${_id}`); + const converted = convertGameObject(foundGameObject, components); + root = _convertToIDataFormat(converted); + break; + } + } + } + + if (!root) { + return { + root: null, + materials: [], + textures: [], + images: [], + geometries: [], + }; + } + } else { + // Convert root GameObject and its hierarchy recursively + console.log(`[convertUnityPrefabToData] Converting GameObject:`, gameObject.m_Name); + const converted = convertGameObject(gameObject, components); + root = _convertToIDataFormat(converted); + } + + // Process dependencies if provided + const materials: IMaterial[] = []; + const textures: ITexture[] = []; + const images: IImage[] = []; + const geometries: IGeometry[] = []; + + if (dependencies) { + // Convert materials from YAML to IData format + if (dependencies.materials) { + for (const [guid, yamlContent] of dependencies.materials) { + try { + const material = convertUnityMaterial(guid, yamlContent, dependencies); + if (material) { + materials.push(material); + } + } catch (error) { + console.warn(`Failed to convert material ${guid}:`, error); + } + } + } + + // Convert textures to IData format + if (dependencies.textures) { + for (const [guid, buffer] of dependencies.textures) { + // Create image entry for texture + const imageId = `image-${guid}`; + images.push({ + uuid: imageId, + url: `data:image/png;base64,${buffer.toString("base64")}`, // Convert buffer to data URL + }); + + // Create texture entry + textures.push({ + uuid: guid, + image: imageId, + wrapU: 0, // Repeat + wrapV: 0, // Repeat + generateMipmaps: true, + flipY: false, + }); + } + } + + // Convert models to IData format (for mesh particles) + // Parse models using Babylon.js loaders if Scene is provided + if (dependencies.models && scene) { + for (const [guid, buffer] of dependencies.models) { + // Determine file extension from meta + const meta = dependencies.meta?.get(guid); + const path = meta?.path || ""; + const ext = path.split(".").pop()?.toLowerCase() || "fbx"; + + try { + const geometry = await convertUnityModel(guid, buffer, ext, scene); + if (geometry) { + geometries.push(geometry); + } else { + // Fallback: store placeholder if parsing failed + geometries.push({ + uuid: guid, + type: "BufferGeometry", + }); + } + } catch (error) { + console.warn(`Failed to parse model ${guid}:`, error); + // Fallback: store placeholder + geometries.push({ + uuid: guid, + type: "BufferGeometry", + }); + } + } + } else if (dependencies.models) { + // If no Scene provided, store placeholders (models will be loaded later) + for (const [guid] of dependencies.models) { + geometries.push({ + uuid: guid, + type: "BufferGeometry", + }); + } + } + } + + return { + root, + materials, + textures, + images, + geometries, + }; +} + +/** + * Convert Unity Material YAML to IMaterial + */ +function convertUnityMaterial(guid: string, yamlContent: string, dependencies: any): IMaterial | null { + try { + // Parse Unity material YAML + const parsed = yaml.load(yamlContent) as any; + if (!parsed || !parsed.Material) { + return null; + } + + const unityMat = parsed.Material; + const material: IMaterial = { + uuid: guid, + }; + + // Extract color + if (unityMat.m_SavedProperties?.m_Colors) { + const colorProps = unityMat.m_SavedProperties.m_Colors; + for (const colorProp of colorProps) { + if (colorProp._Color) { + const r = parseFloat(colorProp._Color.r || "1"); + const g = parseFloat(colorProp._Color.g || "1"); + const b = parseFloat(colorProp._Color.b || "1"); + material.color = new Color3(r, g, b); + break; + } + } + } + + // Extract texture (MainTex) + if (unityMat.m_SavedProperties?.m_TexEnvs) { + for (const texEnv of unityMat.m_SavedProperties.m_TexEnvs) { + if (texEnv._MainTex && texEnv._MainTex.m_Texture) { + const texRef = texEnv._MainTex.m_Texture; + const textureGuid = texRef.guid || texRef.fileID; + if (textureGuid && dependencies.textures?.has(textureGuid)) { + material.map = textureGuid; // Reference to texture UUID + } + break; + } + } + } + + // Extract transparency + if (unityMat.stringTagMap?.RenderType === "Transparent") { + material.transparent = true; + } + + // Extract opacity + if (unityMat.m_SavedProperties?.m_Colors) { + for (const colorProp of unityMat.m_SavedProperties.m_Colors) { + if (colorProp._Color && colorProp._Color.a !== undefined) { + material.opacity = parseFloat(colorProp._Color.a || "1"); + break; + } + } + } + + // Extract blending mode from shader + const shaderFileID = unityMat.m_Shader?.fileID; + if (shaderFileID) { + // Unity shader IDs: 200 = Standard, 203 = Unlit, etc. + // For now, use default blending + material.blending = 0; // Normal blending + } + + return material; + } catch (error) { + console.warn(`Failed to parse Unity material ${guid}:`, error); + return null; + } +} + +/** + * Convert Unity prefab ZIP to IData + * + * @param zipBuffer - Unity prefab ZIP file buffer + * @returns Array of IData structures + */ diff --git a/editor/src/editor/windows/effect-editor/editors/bezier.tsx b/editor/src/editor/windows/effect-editor/editors/bezier.tsx new file mode 100644 index 000000000..104acf0f7 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/bezier.tsx @@ -0,0 +1,546 @@ +import { Component, ReactNode, MouseEvent } from "react"; +import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../../ui/shadcn/ui/dropdown-menu"; +import { HiOutlineArrowPath } from "react-icons/hi2"; +import { Button } from "../../../../ui/shadcn/ui/button"; + +export interface IBezierCurve { + p0: number; + p1: number; + p2: number; + p3: number; + start: number; +} + +export interface IBezierEditorProps { + value: any; + onChange: () => void; +} + +export interface IBezierEditorState { + curve: IBezierCurve; + dragging: boolean; + dragPoint: "p0" | "p1" | "p2" | "p3" | null; + hoveredPoint: "p0" | "p1" | "p2" | "p3" | null; + width: number; + height: number; + showValues: boolean; +} + +type CurvePreset = "linear" | "easeIn" | "easeOut" | "easeInOut" | "easeInBack" | "easeOutBack"; + +const CURVE_PRESETS: Record = { + linear: { p0: 0, p1: 0, p2: 1, p3: 1, start: 0 }, + easeIn: { p0: 0, p1: 0.42, p2: 1, p3: 1, start: 0 }, + easeOut: { p0: 0, p1: 0, p2: 0.58, p3: 1, start: 0 }, + easeInOut: { p0: 0, p1: 0.42, p2: 0.58, p3: 1, start: 0 }, + easeInBack: { p0: 0, p1: -0.36, p2: 0.36, p3: 1, start: 0 }, + easeOutBack: { p0: 0, p1: 0.64, p2: 1.36, p3: 1, start: 0 }, +}; + +export class BezierEditor extends Component { + private _svgRef: SVGSVGElement | null = null; + private _containerRef: HTMLDivElement | null = null; + + public constructor(props: IBezierEditorProps) { + super(props); + this.state = { + curve: this._getCurveFromValue(), + dragging: false, + dragPoint: null, + hoveredPoint: null, + width: 400, + height: 250, + showValues: false, + }; + } + + public componentDidMount(): void { + this._updateDimensions(); + window.addEventListener("resize", this._updateDimensions); + } + + public componentWillUnmount(): void { + window.removeEventListener("resize", this._updateDimensions); + } + + private _updateDimensions = (): void => { + if (this._containerRef) { + const rect = this._containerRef.getBoundingClientRect(); + this.setState({ + width: Math.max(300, rect.width - 20), + height: 250, + }); + } + }; + + private _getCurveFromValue(): IBezierCurve { + if (!this.props.value || !this.props.value.data) { + return CURVE_PRESETS.linear; + } + + // Support both old format (array) and new format (direct object) + if (this.props.value.data.functions && Array.isArray(this.props.value.data.functions)) { + const firstFunction = this.props.value.data.functions[0]; + if (firstFunction && firstFunction.function) { + return { + p0: firstFunction.function.p0 ?? 0, + p1: firstFunction.function.p1 ?? 1.0 / 3, + p2: firstFunction.function.p2 ?? (1.0 / 3) * 2, + p3: firstFunction.function.p3 ?? 1, + start: 0, + }; + } + } + + // New format: direct function object + if (this.props.value.data.function) { + return { + p0: this.props.value.data.function.p0 ?? 0, + p1: this.props.value.data.function.p1 ?? 1.0 / 3, + p2: this.props.value.data.function.p2 ?? (1.0 / 3) * 2, + p3: this.props.value.data.function.p3 ?? 1, + start: 0, + }; + } + + return CURVE_PRESETS.linear; + } + + private _saveCurveToValue(): void { + if (!this.props.value) { + return; + } + + if (!this.props.value.data) { + this.props.value.data = {}; + } + + // Save as direct function object (not array) + this.props.value.data.function = { + p0: Math.max(0, Math.min(1, this.state.curve.p0)), + p1: Math.max(0, Math.min(1, this.state.curve.p1)), + p2: Math.max(0, Math.min(1, this.state.curve.p2)), + p3: Math.max(0, Math.min(1, this.state.curve.p3)), + }; + } + + private _applyPreset(preset: CurvePreset): void { + const presetCurve = CURVE_PRESETS[preset]; + this.setState({ curve: { ...presetCurve } }, () => { + this._saveCurveToValue(); + this.props.onChange(); + }); + } + + private _screenToSvg(clientX: number, clientY: number): { x: number; y: number } { + if (!this._svgRef) { + return { x: 0, y: 0 }; + } + + const rect = this._svgRef.getBoundingClientRect(); + const vb = this._svgRef.viewBox?.baseVal; + if (!vb) { + return { + x: clientX - rect.left, + y: clientY - rect.top, + }; + } + + const scaleX = rect.width / vb.width; + const scaleY = rect.height / vb.height; + const useScale = Math.min(scaleX, scaleY); + + const offsetX = (rect.width - vb.width * useScale) / 2; + const offsetY = (rect.height - vb.height * useScale) / 2; + + return { + x: (clientX - rect.left - offsetX) / useScale, + y: (clientY - rect.top - offsetY) / useScale, + }; + } + + private _valueToSvgY(value: number): number { + // Map value from [0, 1] to SVG Y coordinate + // Center is at height/2, full range is height * 0.8 (40% above and below center) + const padding = this.state.height * 0.1; + const range = this.state.height * 0.8; + return padding + (1 - value) * range; + } + + private _svgYToValue(svgY: number): number { + const padding = this.state.height * 0.1; + const range = this.state.height * 0.8; + return Math.max(0, Math.min(1, (this.state.height - svgY - padding) / range)); + } + + private _handleMouseDown = (ev: MouseEvent, point: "p0" | "p1" | "p2" | "p3"): void => { + ev.stopPropagation(); + if (ev.button !== 0) { + return; + } + + this.setState({ + dragging: true, + dragPoint: point, + }); + + let mouseMoveListener: (event: globalThis.MouseEvent) => void; + let mouseUpListener: (event: globalThis.MouseEvent) => void; + + mouseMoveListener = (ev: globalThis.MouseEvent) => { + if (!this.state.dragging || !this.state.dragPoint) { + return; + } + + const svgPos = this._screenToSvg(ev.clientX, ev.clientY); + const value = this._svgYToValue(svgPos.y); + + const curve = { ...this.state.curve }; + + if (this.state.dragPoint === "p0") { + curve.p0 = value; + } else if (this.state.dragPoint === "p1") { + curve.p1 = value; + } else if (this.state.dragPoint === "p2") { + curve.p2 = value; + } else if (this.state.dragPoint === "p3") { + curve.p3 = value; + } + + this.setState({ curve }); + }; + + mouseUpListener = () => { + document.body.removeEventListener("mousemove", mouseMoveListener); + document.body.removeEventListener("mouseup", mouseUpListener); + document.body.style.cursor = ""; + + this._saveCurveToValue(); + this.props.onChange(); + + this.setState({ + dragging: false, + dragPoint: null, + }); + }; + + document.body.style.cursor = "move"; + document.body.addEventListener("mousemove", mouseMoveListener); + document.body.addEventListener("mouseup", mouseUpListener); + }; + + private _bezierValue(t: number, p0: number, p1: number, p2: number, p3: number): number { + const t2 = t * t; + const t3 = t2 * t; + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + return p0 * mt3 + p1 * mt2 * t * 3 + p2 * mt * t2 * 3 + p3 * t3; + } + + private _renderCurve(curve: IBezierCurve): ReactNode { + const segments = 100; + const pathData: string[] = []; + const gradientId = `gradient-${Math.random().toString(36).substr(2, 9)}`; + + // Calculate actual Bezier curve points + // For cubic Bezier: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ + // But we're using p0, p1, p2, p3 as control values, not positions + // We need to map them to actual control points + const p0X = 0; + const p0Y = this._valueToSvgY(curve.p0); + const p1X = this.state.width / 3; + const p1Y = this._valueToSvgY(curve.p1); + const p2X = (this.state.width * 2) / 3; + const p2Y = this._valueToSvgY(curve.p2); + const p3X = this.state.width; + const p3Y = this._valueToSvgY(curve.p3); + + // Generate curve path + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const x = t * this.state.width; + const y = this._bezierValue(t, p0Y, p1Y, p2Y, p3Y); + + if (i === 0) { + pathData.push(`M ${x} ${y}`); + } else { + pathData.push(`L ${x} ${y}`); + } + } + + const isHovered = (point: "p0" | "p1" | "p2" | "p3") => this.state.hoveredPoint === point || this.state.dragPoint === point; + const getPointRadius = (point: "p0" | "p1" | "p2" | "p3") => { + if (point === "p0" || point === "p3") { + return isHovered(point) ? 7 : 5; + } + return isHovered(point) ? 6 : 4; + }; + const getPointColor = (point: "p0" | "p1" | "p2" | "p3") => { + if (point === "p0" || point === "p3") { + return isHovered(point) ? "#3b82f6" : "#2563eb"; + } + return isHovered(point) ? "#8b5cf6" : "#7c3aed"; + }; + + return ( + + + + + + + + + {/* Filled area under curve */} + + + {/* Curve line */} + + + {/* Control lines */} + + + + {/* Control points */} + {(["p0", "p1", "p2", "p3"] as const).map((point) => { + const x = point === "p0" ? p0X : point === "p1" ? p1X : point === "p2" ? p2X : p3X; + const y = point === "p0" ? p0Y : point === "p1" ? p1Y : point === "p2" ? p2Y : p3Y; + const value = curve[point]; + const radius = getPointRadius(point); + const color = getPointColor(point); + + return ( + + {/* Outer glow when hovered */} + {isHovered(point) && } + {/* Point circle */} + this._handleMouseDown(ev, point)} + onMouseEnter={() => this.setState({ hoveredPoint: point, showValues: true })} + onMouseLeave={() => this.setState({ hoveredPoint: null, showValues: false })} + /> + {/* Value label */} + {isHovered(point) && ( + + {value.toFixed(2)} + + )} + + ); + })} + + ); + } + + private _renderGrid(): ReactNode { + const gridLines: ReactNode[] = []; + + // Horizontal grid lines (value markers) + for (let i = 0; i <= 10; i++) { + const value = i / 10; + const y = this._valueToSvgY(value); + const isMainLine = i % 5 === 0; + + gridLines.push( + + + {isMainLine && ( + + {value.toFixed(1)} + + )} + + ); + } + + // Vertical grid lines (time markers) + for (let i = 0; i <= 10; i++) { + const t = i / 10; + const x = t * this.state.width; + const isMainLine = i % 5 === 0; + + gridLines.push( + + + {isMainLine && ( + + {t.toFixed(1)} + + )} + + ); + } + + // Center line (value = 0.5) + gridLines.push( + + ); + + return {gridLines}; + } + + public render(): ReactNode { + return ( +
(this._containerRef = ref)} className="flex flex-col gap-3 w-full"> + {/* Toolbar */} +
+
+ Curve Editor +
+
+ + + + + + this._applyPreset("linear")}>Linear + this._applyPreset("easeIn")}>Ease In + this._applyPreset("easeOut")}>Ease Out + this._applyPreset("easeInOut")}>Ease In-Out + this._applyPreset("easeInBack")}>Ease In Back + this._applyPreset("easeOutBack")}>Ease Out Back + + + +
+
+ + {/* SVG Canvas */} +
+ (this._svgRef = ref)} + width={this.state.width} + height={this.state.height} + viewBox={`0 0 ${this.state.width} ${this.state.height}`} + className="w-full h-full" + style={{ background: "var(--background)" }} + > + {/* Grid */} + {this._renderGrid()} + + {/* Curve */} + {this._renderCurve(this.state.curve)} + + + {/* Axis labels */} +
Time
+
Value
+
+ + {/* Value inputs */} + +
+
+ + { + this.setState({ curve: { ...this.state.curve } }); + this._saveCurveToValue(); + this.props.onChange(); + }} + /> +
+
+ + { + this.setState({ curve: { ...this.state.curve } }); + this._saveCurveToValue(); + this.props.onChange(); + }} + /> +
+
+ + { + this.setState({ curve: { ...this.state.curve } }); + this._saveCurveToValue(); + this.props.onChange(); + }} + /> +
+
+ + { + this.setState({ curve: { ...this.state.curve } }); + this._saveCurveToValue(); + this.props.onChange(); + }} + /> +
+
+
+
+ ); + } +} diff --git a/editor/src/editor/windows/effect-editor/editors/color-function.tsx b/editor/src/editor/windows/effect-editor/editors/color-function.tsx new file mode 100644 index 000000000..f177aaa75 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/color-function.tsx @@ -0,0 +1,356 @@ +import { ReactNode } from "react"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; + +import { EditorInspectorColorField } from "../../../layout/inspector/fields/color"; +import { EditorInspectorColorGradientField } from "../../../layout/inspector/fields/gradient"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; +import type { IGradientKey } from "../../../../ui/gradient-picker"; + +export type ColorFunctionType = "ConstantColor" | "ColorRange" | "Gradient" | "RandomColor" | "RandomColorBetweenGradient"; + +export interface IColorFunctionEditorProps { + value: any; + onChange: () => void; + label: string; +} + +export function ColorFunctionEditor(props: IColorFunctionEditorProps): ReactNode { + const { value, onChange, label } = props; + + // Convert from Quarks format to UI format if needed + // Quarks format: { color: { type: "Gradient" | "ConstantColor" | "RandomColorBetweenGradient", ... } } + // UI format: { colorFunctionType: "Gradient" | "ConstantColor" | "RandomColorBetweenGradient", data: {...} } + if (value && !value.colorFunctionType) { + // Check if this is Quarks format + if (value.color && typeof value.color === "object" && "type" in value.color) { + const colorType = value.color.type; + + if (colorType === "Gradient") { + // Convert Gradient format + value.colorFunctionType = "Gradient"; + value.data = { + colorKeys: value.color.color?.keys || [], + alphaKeys: value.color.alpha?.keys || [], + }; + delete value.color; + } else if (colorType === "ConstantColor") { + // Convert ConstantColor format + value.colorFunctionType = "ConstantColor"; + const color = + value.color.color || + (value.color.value ? { r: value.color.value[0], g: value.color.value[1], b: value.color.value[2], a: value.color.value[3] } : { r: 1, g: 1, b: 1, a: 1 }); + value.data = { + color: { + r: color.r ?? 1, + g: color.g ?? 1, + b: color.b ?? 1, + a: color.a !== undefined ? color.a : 1, + }, + }; + delete value.color; + } else if (colorType === "RandomColorBetweenGradient") { + // Convert RandomColorBetweenGradient format + value.colorFunctionType = "RandomColorBetweenGradient"; + value.data = { + gradient1: { + colorKeys: value.color.gradient1?.color?.keys || [], + alphaKeys: value.color.gradient1?.alpha?.keys || [], + }, + gradient2: { + colorKeys: value.color.gradient2?.color?.keys || [], + alphaKeys: value.color.gradient2?.alpha?.keys || [], + }, + }; + delete value.color; + } else { + // Fallback: try old format + const hasColorKeys = value.color.color?.keys && value.color.color.keys.length > 0; + const hasAlphaKeys = value.color.alpha?.keys && value.color.alpha.keys.length > 0; + const hasKeys = value.color.keys && value.color.keys.length > 0; + + if (hasColorKeys || hasAlphaKeys || hasKeys) { + value.colorFunctionType = "Gradient"; + value.data = { + colorKeys: hasColorKeys ? value.color.color.keys : hasKeys ? value.color.keys : [], + alphaKeys: hasAlphaKeys ? value.color.alpha.keys : [], + }; + delete value.color; + } else { + value.colorFunctionType = "ConstantColor"; + value.data = {}; + } + } + } else if (value.color) { + // Old Quarks format without type + const hasColorKeys = value.color.color?.keys && value.color.color.keys.length > 0; + const hasAlphaKeys = value.color.alpha?.keys && value.color.alpha.keys.length > 0; + const hasKeys = value.color.keys && value.color.keys.length > 0; + + if (hasColorKeys || hasAlphaKeys || hasKeys) { + value.colorFunctionType = "Gradient"; + value.data = { + colorKeys: hasColorKeys ? value.color.color.keys : hasKeys ? value.color.keys : [], + alphaKeys: hasAlphaKeys ? value.color.alpha.keys : [], + }; + delete value.color; + } else { + value.colorFunctionType = "ConstantColor"; + value.data = {}; + } + } else { + // Initialize color function type if not set + value.colorFunctionType = "ConstantColor"; + value.data = {}; + } + } + + const functionType = value.colorFunctionType as ColorFunctionType; + + // Ensure data object exists + if (!value.data) { + value.data = {}; + } + + const typeItems = [ + { text: "Color", value: "ConstantColor" }, + { text: "Color Range", value: "ColorRange" }, + { text: "Gradient", value: "Gradient" }, + { text: "Random Color", value: "RandomColor" }, + { text: "Random Between Gradient", value: "RandomColorBetweenGradient" }, + ]; + + return ( + <> + { + // Reset data when type changes and initialize defaults + const newType = value.colorFunctionType; + value.data = {}; + if (newType === "ConstantColor") { + value.data.color = new Color4(1, 1, 1, 1); + } else if (newType === "ColorRange") { + value.data.colorA = new Color4(0, 0, 0, 1); + value.data.colorB = new Color4(1, 1, 1, 1); + } else if (newType === "Gradient") { + value.data.colorKeys = [ + { pos: 0, value: [0, 0, 0, 1] }, + { pos: 1, value: [1, 1, 1, 1] }, + ]; + value.data.alphaKeys = [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ]; + } else if (newType === "RandomColor") { + value.data.colorA = new Color4(0, 0, 0, 1); + value.data.colorB = new Color4(1, 1, 1, 1); + } else if (newType === "RandomColorBetweenGradient") { + value.data.gradient1 = { + colorKeys: [ + { pos: 0, value: [0, 0, 0, 1] }, + { pos: 1, value: [1, 1, 1, 1] }, + ], + alphaKeys: [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ], + }; + value.data.gradient2 = { + colorKeys: [ + { pos: 0, value: [1, 0, 0, 1] }, + { pos: 1, value: [0, 1, 0, 1] }, + ], + alphaKeys: [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ], + }; + } + onChange(); + }} + /> + + {functionType === "ConstantColor" && ( + <> + {!value.data.color && (value.data.color = new Color4(1, 1, 1, 1))} + + + )} + + {functionType === "ColorRange" && ( + <> + {!value.data.colorA && (value.data.colorA = new Color4(0, 0, 0, 1))} + {!value.data.colorB && (value.data.colorB = new Color4(1, 1, 1, 1))} + + + + )} + + {functionType === "Gradient" && + (() => { + // Convert old format (Vector3 + position) to new format (array + pos) if needed + const convertColorKeys = (keys: any[]): IGradientKey[] => { + if (!keys || keys.length === 0) { + return [ + { pos: 0, value: [0, 0, 0, 1] }, + { pos: 1, value: [1, 1, 1, 1] }, + ]; + } + return keys.map((key) => { + if (key.color && key.color instanceof Vector3) { + // Old format: { color: Vector3, position: number } + return { + pos: key.position ?? key.pos ?? 0, + value: [key.color.x, key.color.y, key.color.z, 1], + }; + } + // Already in new format or other format + return { + pos: key.pos ?? key.position ?? 0, + value: Array.isArray(key.value) + ? key.value + : typeof key.value === "object" && "r" in key.value + ? [key.value.r, key.value.g, key.value.b, key.value.a ?? 1] + : [0, 0, 0, 1], + }; + }); + }; + + const convertAlphaKeys = (keys: any[]): IGradientKey[] => { + if (!keys || keys.length === 0) { + return [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ]; + } + return keys.map((key) => ({ + pos: key.pos ?? key.position ?? 0, + value: typeof key.value === "number" ? key.value : 1, + })); + }; + + const wrapperGradient = { + colorKeys: convertColorKeys(value.data.colorKeys), + alphaKeys: convertAlphaKeys(value.data.alphaKeys), + }; + + return ( + { + value.data.colorKeys = newColorKeys; + value.data.alphaKeys = newAlphaKeys; + onChange(); + }} + /> + ); + })()} + + {functionType === "RandomColor" && ( + <> + {!value.data.colorA && (value.data.colorA = new Color4(0, 0, 0, 1))} + {!value.data.colorB && (value.data.colorB = new Color4(1, 1, 1, 1))} + + + + )} + + {functionType === "RandomColorBetweenGradient" && + (() => { + // Convert old format to new format if needed + const convertColorKeys = (keys: any[]): IGradientKey[] => { + if (!keys || keys.length === 0) { + return [ + { pos: 0, value: [0, 0, 0, 1] }, + { pos: 1, value: [1, 1, 1, 1] }, + ]; + } + return keys.map((key) => { + if (key.color && key.color instanceof Vector3) { + return { + pos: key.position ?? key.pos ?? 0, + value: [key.color.x, key.color.y, key.color.z, 1], + }; + } + return { + pos: key.pos ?? key.position ?? 0, + value: Array.isArray(key.value) + ? key.value + : typeof key.value === "object" && "r" in key.value + ? [key.value.r, key.value.g, key.value.b, key.value.a ?? 1] + : [0, 0, 0, 1], + }; + }); + }; + + const convertAlphaKeys = (keys: any[]): IGradientKey[] => { + if (!keys || keys.length === 0) { + return [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ]; + } + return keys.map((key) => ({ + pos: key.pos ?? key.position ?? 0, + value: typeof key.value === "number" ? key.value : 1, + })); + }; + + if (!value.data.gradient1) { + value.data.gradient1 = {}; + } + if (!value.data.gradient2) { + value.data.gradient2 = {}; + } + + const wrapperGradient1 = { + colorKeys: convertColorKeys(value.data.gradient1.colorKeys), + alphaKeys: convertAlphaKeys(value.data.gradient1.alphaKeys), + }; + + const wrapperGradient2 = { + colorKeys: convertColorKeys(value.data.gradient2.colorKeys), + alphaKeys: convertAlphaKeys(value.data.gradient2.alphaKeys), + }; + + return ( + <> + +
Gradient 1
+ { + value.data.gradient1.colorKeys = newColorKeys; + value.data.gradient1.alphaKeys = newAlphaKeys; + onChange(); + }} + /> +
+ +
Gradient 2
+ { + value.data.gradient2.colorKeys = newColorKeys; + value.data.gradient2.alphaKeys = newAlphaKeys; + onChange(); + }} + /> +
+ + ); + })()} + + ); +} diff --git a/editor/src/editor/windows/effect-editor/editors/color.tsx b/editor/src/editor/windows/effect-editor/editors/color.tsx new file mode 100644 index 000000000..442d7a53c --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/color.tsx @@ -0,0 +1,325 @@ +import { ReactNode } from "react"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; + +import { EditorInspectorColorField } from "../../../layout/inspector/fields/color"; +import { EditorInspectorColorGradientField } from "../../../layout/inspector/fields/gradient"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; + +import { type Color, ValueUtils } from "babylonjs-editor-tools"; + +export type EffectColorType = "ConstantColor" | "ColorRange" | "Gradient" | "RandomColor" | "RandomColorBetweenGradient"; + +export interface IEffectColorEditorProps { + value: Color | undefined; + onChange: (newValue: Color) => void; + label?: string; +} + +/** + * Editor for VEffectColor (ConstantColor, ColorRange, Gradient, RandomColor, RandomColorBetweenGradient) + * Works directly with VEffectColor types, not wrappers + */ +export function EffectColorEditor(props: IEffectColorEditorProps): ReactNode { + const { value, onChange, label } = props; + + // Determine current type from value + let currentType: EffectColorType = "ConstantColor"; + if (value) { + if (typeof value === "string" || Array.isArray(value)) { + currentType = "ConstantColor"; + } else if ("type" in value) { + if (value.type === "ConstantColor") { + currentType = "ConstantColor"; + } else if (value.type === "ColorRange") { + currentType = "ColorRange"; + } else if (value.type === "Gradient") { + currentType = "Gradient"; + } else if (value.type === "RandomColor") { + currentType = "RandomColor"; + } else if (value.type === "RandomColorBetweenGradient") { + currentType = "RandomColorBetweenGradient"; + } + } + } + + const typeItems = [ + { text: "Color", value: "ConstantColor" }, + { text: "Color Range", value: "ColorRange" }, + { text: "Gradient", value: "Gradient" }, + { text: "Random Color", value: "RandomColor" }, + { text: "Random Between Gradient", value: "RandomColorBetweenGradient" }, + ]; + + // Wrapper object for EditorInspectorListField + const wrapper = { + get type() { + return currentType; + }, + set type(newType: EffectColorType) { + currentType = newType; + // Convert value to new type + let newValue: Color; + const currentColor = value ? ValueUtils.parseConstantColor(value) : new Color4(1, 1, 1, 1); + if (newType === "ConstantColor") { + newValue = { type: "ConstantColor", value: [currentColor.r, currentColor.g, currentColor.b, currentColor.a] }; + } else if (newType === "ColorRange") { + newValue = { + type: "ColorRange", + colorA: [currentColor.r, currentColor.g, currentColor.b, currentColor.a], + colorB: [1, 1, 1, 1], + }; + } else if (newType === "Gradient") { + newValue = { + type: "Gradient", + colorKeys: [ + { pos: 0, value: [currentColor.r, currentColor.g, currentColor.b, currentColor.a] }, + { pos: 1, value: [1, 1, 1, 1] }, + ], + alphaKeys: [ + { pos: 0, value: currentColor.a }, + { pos: 1, value: 1 }, + ], + }; + } else if (newType === "RandomColor") { + newValue = { + type: "RandomColor", + colorA: [currentColor.r, currentColor.g, currentColor.b, currentColor.a], + colorB: [1, 1, 1, 1], + }; + } else { + // RandomColorBetweenGradient + newValue = { + type: "RandomColorBetweenGradient", + gradient1: { + colorKeys: [ + { pos: 0, value: [currentColor.r, currentColor.g, currentColor.b, currentColor.a] }, + { pos: 1, value: [1, 1, 1, 1] }, + ], + alphaKeys: [ + { pos: 0, value: currentColor.a }, + { pos: 1, value: 1 }, + ], + }, + gradient2: { + colorKeys: [ + { pos: 0, value: [1, 0, 0, 1] }, + { pos: 1, value: [0, 1, 0, 1] }, + ], + alphaKeys: [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ], + }, + }; + } + onChange(newValue); + }, + }; + + return ( + <> + { + // Type change is handled by setter + }} + /> + + {currentType === "ConstantColor" && ( + <> + {(() => { + const constantColor = value ? ValueUtils.parseConstantColor(value) : new Color4(1, 1, 1, 1); + const wrapperColor = { + get color() { + return constantColor; + }, + set color(newColor: Color4) { + onChange({ type: "ConstantColor", value: [newColor.r, newColor.g, newColor.b, newColor.a] }); + }, + }; + return {}} />; + })()} + + )} + + {currentType === "ColorRange" && ( + <> + {(() => { + const colorRange = value && typeof value === "object" && "type" in value && value.type === "ColorRange" ? value : null; + const colorA = colorRange ? new Color4(colorRange.colorA[0], colorRange.colorA[1], colorRange.colorA[2], colorRange.colorA[3]) : new Color4(0, 0, 0, 1); + const colorB = colorRange ? new Color4(colorRange.colorB[0], colorRange.colorB[1], colorRange.colorB[2], colorRange.colorB[3]) : new Color4(1, 1, 1, 1); + const wrapperRange = { + get colorA() { + return colorA; + }, + set colorA(newColor: Color4) { + const currentB = colorRange ? colorRange.colorB : [1, 1, 1, 1]; + onChange({ type: "ColorRange", colorA: [newColor.r, newColor.g, newColor.b, newColor.a], colorB: currentB as [number, number, number, number] }); + }, + get colorB() { + return colorB; + }, + set colorB(newColor: Color4) { + const currentA = colorRange ? colorRange.colorA : [0, 0, 0, 1]; + onChange({ + type: "ColorRange", + colorA: currentA as [number, number, number, number], + colorB: [newColor.r, newColor.g, newColor.b, newColor.a] as [number, number, number, number], + }); + }, + }; + return ( + <> + {}} /> + {}} /> + + ); + })()} + + )} + + {currentType === "Gradient" && + (() => { + const gradientValue = value && typeof value === "object" && "type" in value && value.type === "Gradient" ? value : null; + const defaultColorKeys = [ + { pos: 0, value: [0, 0, 0, 1] }, + { pos: 1, value: [1, 1, 1, 1] }, + ]; + const defaultAlphaKeys = [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ]; + const wrapperGradient = { + colorKeys: gradientValue?.colorKeys || defaultColorKeys, + alphaKeys: gradientValue?.alphaKeys || defaultAlphaKeys, + }; + return ( + { + onChange({ + type: "Gradient", + colorKeys: newColorKeys, + alphaKeys: newAlphaKeys, + }); + }} + /> + ); + })()} + + {currentType === "RandomColor" && ( + <> + {(() => { + const randomColor = value && typeof value === "object" && "type" in value && value.type === "RandomColor" ? value : null; + const colorA = randomColor + ? new Color4(randomColor.colorA[0], randomColor.colorA[1], randomColor.colorA[2], randomColor.colorA[3]) + : new Color4(0, 0, 0, 1); + const colorB = randomColor + ? new Color4(randomColor.colorB[0], randomColor.colorB[1], randomColor.colorB[2], randomColor.colorB[3]) + : new Color4(1, 1, 1, 1); + const wrapperRandom = { + get colorA() { + return colorA; + }, + set colorA(newColor: Color4) { + const currentB = randomColor ? randomColor.colorB : [1, 1, 1, 1]; + onChange({ type: "RandomColor", colorA: [newColor.r, newColor.g, newColor.b, newColor.a], colorB: currentB as [number, number, number, number] }); + }, + get colorB() { + return colorB; + }, + set colorB(newColor: Color4) { + const currentA = randomColor ? randomColor.colorA : [0, 0, 0, 1]; + onChange({ + type: "RandomColor", + colorA: currentA as [number, number, number, number], + colorB: [newColor.r, newColor.g, newColor.b, newColor.a] as [number, number, number, number], + }); + }, + }; + return ( + <> + {}} /> + {}} /> + + ); + })()} + + )} + + {currentType === "RandomColorBetweenGradient" && + (() => { + const randomGradient = value && typeof value === "object" && "type" in value && value.type === "RandomColorBetweenGradient" ? value : null; + const defaultColorKeys = [ + { pos: 0, value: [0, 0, 0, 1] }, + { pos: 1, value: [1, 1, 1, 1] }, + ]; + const defaultAlphaKeys = [ + { pos: 0, value: 1 }, + { pos: 1, value: 1 }, + ]; + + const wrapperGradient1 = { + colorKeys: randomGradient?.gradient1?.colorKeys || defaultColorKeys, + alphaKeys: randomGradient?.gradient1?.alphaKeys || defaultAlphaKeys, + }; + + const wrapperGradient2 = { + colorKeys: randomGradient?.gradient2?.colorKeys || defaultColorKeys, + alphaKeys: randomGradient?.gradient2?.alphaKeys || defaultAlphaKeys, + }; + + return ( + <> + +
Gradient 1
+ { + if (randomGradient) { + onChange({ + type: "RandomColorBetweenGradient", + gradient1: { + colorKeys: newColorKeys, + alphaKeys: newAlphaKeys, + }, + gradient2: randomGradient.gradient2, + }); + } + }} + /> +
+ +
Gradient 2
+ { + if (randomGradient) { + onChange({ + type: "RandomColorBetweenGradient", + gradient1: randomGradient.gradient1, + gradient2: { + colorKeys: newColorKeys, + alphaKeys: newAlphaKeys, + }, + }); + } + }} + /> +
+ + ); + })()} + + ); +} diff --git a/editor/src/editor/windows/effect-editor/editors/function.tsx b/editor/src/editor/windows/effect-editor/editors/function.tsx new file mode 100644 index 000000000..88acb9d27 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/function.tsx @@ -0,0 +1,130 @@ +import { ReactNode } from "react"; + +import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; + +import { BezierEditor } from "./bezier"; + +export type FunctionType = "ConstantValue" | "IntervalValue" | "PiecewiseBezier" | "Vector3Function"; + +export interface IFunctionEditorProps { + value: any; + onChange: () => void; + availableTypes?: FunctionType[]; + label: string; +} + +export function FunctionEditor(props: IFunctionEditorProps): ReactNode { + const { value, onChange, availableTypes, label } = props; + + // Default available types if not specified + const types = availableTypes || ["ConstantValue", "IntervalValue", "PiecewiseBezier", "Vector3Function"]; + + // Initialize function type if not set + if (!value || !value.functionType) { + value.functionType = types[0]; + value.data = {}; + } + + const functionType = value.functionType as FunctionType; + + // Ensure data object exists + if (!value.data) { + value.data = {}; + } + + const typeItems = types.map((type) => ({ + text: type, + value: type, + })); + + return ( + <> + { + // Reset data when type changes and initialize defaults + const newType = value.functionType; + value.data = {}; + if (newType === "ConstantValue") { + value.data.value = 1.0; + } else if (newType === "IntervalValue") { + value.data.min = 0; + value.data.max = 1; + } else if (newType === "PiecewiseBezier") { + value.data.function = { p0: 0, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 }; + } else if (newType === "Vector3Function") { + value.data.x = { functionType: "ConstantValue", data: { value: 0 } }; + value.data.y = { functionType: "ConstantValue", data: { value: 0 } }; + value.data.z = { functionType: "ConstantValue", data: { value: 0 } }; + } + onChange(); + }} + /> + + {functionType === "ConstantValue" && ( + <> + {value.data.value === undefined && (value.data.value = 1.0)} + + + )} + + {functionType === "IntervalValue" && ( + <> + {value.data.min === undefined && (value.data.min = 0)} + {value.data.max === undefined && (value.data.max = 1)} + +
{label ? "Range" : ""}
+
+ + +
+
+ + )} + + {functionType === "PiecewiseBezier" && ( + <> + {!value.data.function && (value.data.function = { p0: 0, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 })} + + + )} + + {functionType === "Vector3Function" && ( + <> + +
X
+ +
+ +
Y
+ +
+ +
Z
+ +
+ + )} + + ); +} diff --git a/editor/src/editor/windows/effect-editor/editors/index.ts b/editor/src/editor/windows/effect-editor/editors/index.ts new file mode 100644 index 000000000..15478b643 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/index.ts @@ -0,0 +1,6 @@ +export * from "./value"; +export * from "./color"; +export * from "./rotation"; +export * from "./function"; +export * from "./color-function"; +export * from "./bezier"; diff --git a/editor/src/editor/windows/effect-editor/editors/rotation.tsx b/editor/src/editor/windows/effect-editor/editors/rotation.tsx new file mode 100644 index 000000000..267b7dd09 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/rotation.tsx @@ -0,0 +1,318 @@ +import { ReactNode } from "react"; + +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; + +import { type Rotation, type IEulerRotation, type IAxisAngleRotation, type IRandomQuatRotation, ValueUtils, type Value } from "babylonjs-editor-tools"; +import { EffectValueEditor } from "./value"; + +export type EffectRotationType = IEulerRotation["type"] | IAxisAngleRotation["type"] | IRandomQuatRotation["type"]; + +export interface IEffectRotationEditorProps { + value: Rotation | undefined; + onChange: (newValue: Rotation) => void; + label?: string; +} + +/** + * Editor for VEffectRotation (Euler, AxisAngle, RandomQuat) + * Works directly with VEffectRotation types, not wrappers + */ +export function EffectRotationEditor(props: IEffectRotationEditorProps): ReactNode { + const { value, onChange, label } = props; + + // Determine current type from value + let currentType: EffectRotationType = "Euler"; + if (value) { + if ( + typeof value === "number" || + (typeof value === "object" && "type" in value && (value.type === "ConstantValue" || value.type === "IntervalValue" || value.type === "PiecewiseBezier")) + ) { + // Simple VEffectValue - convert to Euler + currentType = "Euler"; + } else if (typeof value === "object" && "type" in value) { + if (value.type === "Euler") { + currentType = "Euler"; + } else if (value.type === "AxisAngle") { + currentType = "AxisAngle"; + } else if (value.type === "RandomQuat") { + currentType = "RandomQuat"; + } + } + } + + const typeItems = [ + { text: "Euler", value: "Euler" }, + { text: "Axis Angle", value: "AxisAngle" }, + { text: "Random Quat", value: "RandomQuat" }, + ]; + + // Wrapper object for EditorInspectorListField + const wrapper = { + get type() { + return currentType; + }, + set type(newType: EffectRotationType) { + currentType = newType; + // Convert value to new type + let newValue: Rotation; + if (newType === "Euler") { + // Convert current value to Euler + if (value && typeof value === "object" && "type" in value && value.type === "Euler") { + newValue = value; + } else { + const angleZ = value ? (typeof value === "number" ? value : ValueUtils.parseConstantValue(value as Value)) : 0; + newValue = { + type: "Euler", + angleZ: { type: "ConstantValue", value: angleZ }, + order: "xyz", + }; + } + } else if (newType === "AxisAngle") { + // Convert to AxisAngle + const angle = value ? (typeof value === "number" ? value : ValueUtils.parseConstantValue(value as Value)) : 0; + newValue = { + type: "AxisAngle", + x: { type: "ConstantValue", value: 0 }, + y: { type: "ConstantValue", value: 0 }, + z: { type: "ConstantValue", value: 1 }, + angle: { type: "ConstantValue", value: angle }, + }; + } else { + // RandomQuat + newValue = { type: "RandomQuat" }; + } + onChange(newValue); + }, + }; + + return ( + <> + { + // Type change is handled by setter + }} + /> + + {currentType === "Euler" && ( + <> + {(() => { + const eulerValue = value && typeof value === "object" && "type" in value && value.type === "Euler" ? value : null; + const angleX = eulerValue?.angleX || { type: "ConstantValue" as const, value: 0 }; + const angleY = eulerValue?.angleY || { type: "ConstantValue" as const, value: 0 }; + const angleZ = eulerValue?.angleZ || { type: "ConstantValue" as const, value: 0 }; + const order = eulerValue?.order || "xyz"; + + const orderWrapper = { + get order() { + return order; + }, + set order(newOrder: "xyz" | "zyx") { + if (eulerValue) { + onChange({ ...eulerValue, order: newOrder }); + } else { + onChange({ + type: "Euler", + angleX, + angleY, + angleZ, + order: newOrder, + }); + } + }, + }; + + return ( + <> + {}} + /> + +
Angle X
+ { + if (eulerValue) { + onChange({ ...eulerValue, angleX: newAngleX as Value }); + } else { + onChange({ + type: "Euler", + angleX: newAngleX as Value, + angleY, + angleZ, + order, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ +
Angle Y
+ { + if (eulerValue) { + onChange({ ...eulerValue, angleY: newAngleY as Value }); + } else { + onChange({ + type: "Euler", + angleX, + angleY: newAngleY as Value, + angleZ, + order, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ +
Angle Z
+ { + if (eulerValue) { + onChange({ ...eulerValue, angleZ: newAngleZ as Value }); + } else { + onChange({ + type: "Euler", + angleX, + angleY, + angleZ: newAngleZ as Value, + order, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ + ); + })()} + + )} + + {currentType === "AxisAngle" && ( + <> + {(() => { + const axisAngleValue = value && typeof value === "object" && "type" in value && value.type === "AxisAngle" ? value : null; + const x = axisAngleValue?.x || { type: "ConstantValue" as const, value: 0 }; + const y = axisAngleValue?.y || { type: "ConstantValue" as const, value: 0 }; + const z = axisAngleValue?.z || { type: "ConstantValue" as const, value: 1 }; + const angle = axisAngleValue?.angle || { type: "ConstantValue" as const, value: 0 }; + + return ( + <> + +
Axis X
+ { + if (axisAngleValue) { + onChange({ ...axisAngleValue, x: newX as Value }); + } else { + onChange({ + type: "AxisAngle", + x: newX as Value, + y, + z, + angle, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ +
Axis Y
+ { + if (axisAngleValue) { + onChange({ ...axisAngleValue, y: newY as Value }); + } else { + onChange({ + type: "AxisAngle", + x, + y: newY as Value, + z, + angle, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ +
Axis Z
+ { + if (axisAngleValue) { + onChange({ ...axisAngleValue, z: newZ as Value }); + } else { + onChange({ + type: "AxisAngle", + x, + y, + z: newZ as Value, + angle, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ +
Angle
+ { + if (axisAngleValue) { + onChange({ ...axisAngleValue, angle: newAngle as Value }); + } else { + onChange({ + type: "AxisAngle", + x, + y, + z, + angle: newAngle as Value, + }); + } + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> +
+ + ); + })()} + + )} + + {currentType === "RandomQuat" && ( + <> +
Random quaternion rotation will be applied to each particle
+ + )} + + ); +} diff --git a/editor/src/editor/windows/effect-editor/editors/value.tsx b/editor/src/editor/windows/effect-editor/editors/value.tsx new file mode 100644 index 000000000..c299850d5 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/editors/value.tsx @@ -0,0 +1,338 @@ +import { ReactNode } from "react"; + +import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; + +import { type Value, type IConstantValue, type IIntervalValue, ValueUtils } from "babylonjs-editor-tools"; + +type PiecewiseBezier = Extract; +import { BezierEditor } from "./bezier"; + +// Vec3Function is a custom editor extension, not part of the core Value type +export type EffectValueType = IConstantValue["type"] | IIntervalValue["type"] | PiecewiseBezier["type"] | "Vec3Function"; + +export interface IVec3Function { + type: "Vec3Function"; + x: Value; + y: Value; + z: Value; +} + +export interface IEffectValueEditorProps { + value: Value | IVec3Function | undefined; + onChange: (newValue: Value | IVec3Function) => void; + label?: string; + availableTypes?: EffectValueType[]; + min?: number; + step?: number; +} + +/** + * Editor for VEffectValue (ConstantValue, IntervalValue, PiecewiseBezier, Vec3Function) + * Works directly with VEffectValue types, not wrappers + */ +export function EffectValueEditor(props: IEffectValueEditorProps): ReactNode { + const { value, onChange, label, availableTypes, min, step } = props; + + const types = availableTypes || ["ConstantValue", "IntervalValue", "PiecewiseBezier"]; + + // Determine current type from value + let currentType: EffectValueType = "ConstantValue"; + if (value) { + if (typeof value === "number") { + currentType = "ConstantValue"; + } else if ("type" in value) { + if (value.type === "Vec3Function") { + currentType = "Vec3Function"; + } else if (value.type === "ConstantValue") { + currentType = "ConstantValue"; + } else if (value.type === "IntervalValue") { + currentType = "IntervalValue"; + } else if (value.type === "PiecewiseBezier") { + currentType = "PiecewiseBezier"; + } + } + } + + const typeItems = types.map((type) => ({ + text: type, + value: type, + })); + + // Wrapper object for EditorInspectorListField + const wrapper = { + get type() { + return currentType; + }, + set type(newType: EffectValueType) { + currentType = newType; + // Convert value to new type + let newValue: Value | IVec3Function; + if (newType === "ConstantValue") { + const currentValue = + value && typeof value !== "number" && "type" in value && value.type !== "Vec3Function" + ? ValueUtils.parseConstantValue(value as Value) + : typeof value === "number" + ? value + : 1; + newValue = { type: "ConstantValue", value: currentValue }; + } else if (newType === "IntervalValue") { + const interval = + value && typeof value !== "number" && "type" in value && value.type !== "Vec3Function" ? ValueUtils.parseIntervalValue(value as Value) : { min: 0, max: 1 }; + newValue = { type: "IntervalValue", min: interval.min, max: interval.max }; + } else if (newType === "Vec3Function") { + const currentValue = + value && typeof value !== "number" && "type" in value && value.type !== "Vec3Function" + ? ValueUtils.parseConstantValue(value as Value) + : typeof value === "number" + ? value + : 1; + newValue = { + type: "Vec3Function", + x: { type: "ConstantValue", value: currentValue }, + y: { type: "ConstantValue", value: currentValue }, + z: { type: "ConstantValue", value: currentValue }, + }; + } else { + // PiecewiseBezier - convert from current value + const currentValue = + value && typeof value !== "number" && "type" in value && value.type !== "Vec3Function" + ? ValueUtils.parseConstantValue(value as Value) + : typeof value === "number" + ? value + : 1; + newValue = { + type: "PiecewiseBezier", + functions: [ + { + function: { p0: currentValue, p1: currentValue, p2: currentValue, p3: currentValue }, + start: 0, + }, + ], + }; + } + onChange(newValue); + }, + }; + + return ( + <> + { + // Type change is handled by setter + }} + /> + + {currentType === "ConstantValue" && ( + <> + {(() => { + const constantValue = + value && typeof value !== "number" && "type" in value && value.type !== "Vec3Function" + ? ValueUtils.parseConstantValue(value as Value) + : typeof value === "number" + ? value + : 1; + const wrapperValue = { + get value() { + return constantValue; + }, + set value(newVal: number) { + onChange({ type: "ConstantValue", value: newVal }); + }, + }; + return ( + { + // Value change is handled by setter + }} + /> + ); + })()} + + )} + + {currentType === "IntervalValue" && ( + <> + {(() => { + const interval = + value && typeof value !== "number" && "type" in value && value.type !== "Vec3Function" + ? ValueUtils.parseIntervalValue(value as Value) + : { min: 0, max: 1 }; + const wrapperInterval = { + get min() { + return interval.min; + }, + set min(newMin: number) { + const currentMax = value && typeof value !== "number" && "type" in value && value.type === "IntervalValue" ? value.max : interval.max; + onChange({ type: "IntervalValue", min: newMin, max: currentMax }); + }, + get max() { + return interval.max; + }, + set max(newMax: number) { + const currentMin = value && typeof value !== "number" && "type" in value && value.type === "IntervalValue" ? value.min : interval.min; + onChange({ type: "IntervalValue", min: currentMin, max: newMax }); + }, + }; + return ( + +
{label ? "Range" : ""}
+
+ { + // Value change is handled by setter + }} + /> + { + // Value change is handled by setter + }} + /> +
+
+ ); + })()} + + )} + + {currentType === "PiecewiseBezier" && ( + <> + {(() => { + // Convert VEffectValue to wrapper format for BezierEditor + const bezierValue = value && typeof value !== "number" && "type" in value && value.type === "PiecewiseBezier" ? value : null; + const wrapperBezier = { + get functionType() { + return "PiecewiseBezier"; + }, + set functionType(_: string) {}, + get data() { + if (!bezierValue || bezierValue.functions.length === 0) { + return { + function: { p0: 1, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 }, + }; + } + // Use first function for editing + return { + function: bezierValue.functions[0].function, + }; + }, + set data(newData: any) { + // Update first function or create new + if (!bezierValue) { + onChange({ + type: "PiecewiseBezier", + functions: [ + { + function: newData.function || { p0: 1, p1: 1.0 / 3, p2: (1.0 / 3) * 2, p3: 1 }, + start: 0, + }, + ], + }); + } else { + const newFunctions = [...bezierValue.functions]; + newFunctions[0] = { + ...newFunctions[0], + function: newData.function || newFunctions[0].function, + }; + onChange({ + type: "PiecewiseBezier", + functions: newFunctions, + }); + } + }, + }; + return {}} />; + })()} + + )} + + {currentType === "Vec3Function" && ( + <> + {(() => { + const vec3Value = value && typeof value !== "number" && "type" in value && value.type === "Vec3Function" ? value : null; + const currentX = vec3Value ? vec3Value.x : { type: "ConstantValue" as const, value: 1 }; + const currentY = vec3Value ? vec3Value.y : { type: "ConstantValue" as const, value: 1 }; + const currentZ = vec3Value ? vec3Value.z : { type: "ConstantValue" as const, value: 1 }; + return ( + <> + +
X
+ { + onChange({ + type: "Vec3Function", + x: newX as Value, + y: currentY, + z: currentZ, + }); + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + min={min} + step={step} + /> +
+ +
Y
+ { + onChange({ + type: "Vec3Function", + x: currentX, + y: newY as Value, + z: currentZ, + }); + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + min={min} + step={step} + /> +
+ +
Z
+ { + onChange({ + type: "Vec3Function", + x: currentX, + y: currentY, + z: newZ as Value, + }); + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + min={min} + step={step} + /> +
+ + ); + })()} + + )} + + ); +} diff --git a/editor/src/editor/windows/effect-editor/graph.tsx b/editor/src/editor/windows/effect-editor/graph.tsx new file mode 100644 index 000000000..cab58db10 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/graph.tsx @@ -0,0 +1,838 @@ +import { Component, ReactNode } from "react"; +import { Tree, TreeNodeInfo } from "@blueprintjs/core"; + +import { AiOutlinePlus, AiOutlineClose } from "react-icons/ai"; +import { IoSparklesSharp } from "react-icons/io5"; +import { HiOutlineFolder } from "react-icons/hi2"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, +} from "../../../ui/shadcn/ui/context-menu"; +import { IEffectEditor } from "."; +import { saveSingleFileDialog } from "../../../tools/dialog"; +import { readJSON, writeJSON } from "fs-extra"; +import { toast } from "sonner"; +import { Effect, type IEffectNode, EffectSolidParticleSystem, type IData } from "babylonjs-editor-tools"; +import { IQuarksJSON } from "./converters/quarksTypes"; +import { QuarksConverter } from "./converters"; +import { basename, dirname } from "path"; + +export interface IEffectEditorGraphProps { + filePath: string | null; + onNodeSelected?: (nodeId: string | number | null) => void; + editor: IEffectEditor; +} + +export interface IEffectEditorGraphState { + nodes: TreeNodeInfo[]; + selectedNodeId: string | number | null; +} + +interface IEffectInfo { + id: string; + name: string; + effect: Effect; + originalJsonData?: any; // Store original JSON data for export +} + +export class EffectEditorGraph extends Component { + private _effects: Map = new Map(); + /** Map of node instances to unique IDs for tree nodes */ + private _nodeIdMap: Map = new Map(); + + public constructor(props: IEffectEditorGraphProps) { + super(props); + + this.state = { + nodes: [], + selectedNodeId: null, + }; + } + + /** + * Get the first effect (for backward compatibility) + */ + public getEffect(): Effect | null { + const firstEffect = this._effects.values().next().value; + return firstEffect ? firstEffect.effect : null; + } + + /** + * Get all effects + */ + public getAllEffects(): Effect[] { + return Array.from(this._effects.values()).map((info) => info.effect); + } + + /** + * Get effect by ID + */ + public getEffectById(id: string): Effect | null { + const info = this._effects.get(id); + return info ? info.effect : null; + } + + /** + * Finds a node in the tree by ID + */ + private _findNodeById(nodes: TreeNodeInfo[], nodeId: string | number): TreeNodeInfo | null { + for (const node of nodes) { + if (node.id === nodeId) { + return node; + } + if (node.childNodes) { + const found = this._findNodeById(node.childNodes, nodeId); + if (found) { + return found; + } + } + } + return null; + } + + /** + * Gets node data by ID from tree + */ + public getNodeData(nodeId: string | number): IEffectNode | null { + const node = this._findNodeById(this.state.nodes, nodeId); + return node?.nodeData || null; + } + + public componentDidMount(): void {} + + public componentDidUpdate(_prevProps: IEffectEditorGraphProps): void {} + + /** + * Loads nodes from Quarks JSON file + */ + public async loadFromQuarksFile(filePath: string): Promise { + try { + if (!this.props.editor.preview?.scene) { + console.error("Scene is not available"); + return; + } + + const dirnamePath = dirname(filePath); + const originalJsonData = await readJSON(filePath); + + const parser = new QuarksConverter(); + const parseResult = parser.convert(originalJsonData as IQuarksJSON); + + const effect = new Effect(parseResult, this.props.editor.preview.scene, dirnamePath + "/"); + + const effectId = `effect-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const effectName = basename(filePath, ".json") || "Effect"; + console.log(effect); + this._effects.set(effectId, { + id: effectId, + name: effectName, + effect: effect, + originalJsonData, + }); + + this._rebuildTree(); + + effect.start(); + + setTimeout(() => { + if (this.props.editor?.preview) { + (this.props.editor.preview as any).forceUpdate?.(); + } + }, 100); + } catch (error) { + console.error("Failed to load Effect file:", error); + } + } + /** + * Loads nodes from JSON file + */ + public async loadFromFile(filePath: string): Promise { + try { + if (!this.props.editor.preview?.scene) { + console.error("Scene is not available"); + return; + } + + // Load Quarks JSON and parse to IData + const dirnamePath = dirname(filePath); + const originalJsonData = await readJSON(filePath); + + const effect = new Effect(originalJsonData, this.props.editor.preview!.scene, dirnamePath + "/"); + + const effectId = `effect-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const effectName = basename(filePath, ".json") || "Effect"; + + this._effects.set(effectId, { + id: effectId, + name: effectName, + effect: effect, + originalJsonData, + }); + + this._rebuildTree(); + + effect.start(); + + setTimeout(() => { + if (this.props.editor?.preview) { + (this.props.editor.preview as any).forceUpdate?.(); + } + }, 100); + } catch (error) { + console.error("Failed to load Effect file:", error); + } + } + + /** + * Load effect from Unity IData (converted from prefab) + */ + public async loadFromUnityData(data: any, prefabName: string): Promise { + try { + if (!this.props.editor.preview?.scene) { + console.error("Scene is not available"); + return; + } + + // Create effect from IData + const effectName = prefabName.replace(".prefab", "").split("/").pop() || "Unity Effect"; + const effect = new Effect(data, this.props.editor.preview.scene); + + // Generate unique ID for effect + const effectId = `unity-effect-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + // Store effect with data for export + this._effects.set(effectId, { + id: effectId, + name: effectName, + effect: effect, + originalJsonData: data, + }); + + // Rebuild tree with all effects + this._rebuildTree(); + + // Start systems + effect.start(); + + // Notify preview to sync playing state + setTimeout(() => { + if (this.props.editor?.preview) { + (this.props.editor.preview as any).forceUpdate?.(); + } + }, 100); + } catch (error) { + console.error("Failed to load Unity prefab:", error); + throw error; + } + } + + /** + * Rebuild tree from all effects + */ + private _rebuildTree(): void { + // Clear node ID map when rebuilding tree to ensure unique IDs + this._nodeIdMap.clear(); + + const nodes: TreeNodeInfo[] = []; + + for (const [effectId, effectInfo] of this._effects.entries()) { + if (effectInfo.effect.root) { + // Use effect root directly as the tree node, but update its name to effect name + effectInfo.effect.root.name = effectInfo.name; + effectInfo.effect.root.uuid = effectId; + + const treeNode = this._convertNodeToTreeNode(effectInfo.effect.root, true); + nodes.push(treeNode); + } + } + + this.setState({ nodes }); + } + + /** + * Generate unique ID for a node + */ + private _generateUniqueNodeId(Node: IEffectNode): string { + // Check if we already have an ID for this node instance + if (this._nodeIdMap.has(Node)) { + return this._nodeIdMap.get(Node)!; + } + + // Generate unique ID + const uniqueId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this._nodeIdMap.set(Node, uniqueId); + return uniqueId; + } + + /** + * Converts IEffectNode to TreeNodeInfo recursively + */ + private _convertNodeToTreeNode(Node: IEffectNode, isEffectRoot: boolean = false): TreeNodeInfo { + // Always use unique ID instead of uuid or name + const nodeId = this._generateUniqueNodeId(Node); + const childNodes = Node.children.length > 0 ? Node.children.map((child) => this._convertNodeToTreeNode(child, false)) : undefined; + + // Check if solid particle system + const isSolid = Node.data instanceof EffectSolidParticleSystem; + + // Determine icon based on node type (sparkles for all particles, with color coding) + let icon: JSX.Element; + if (isEffectRoot) { + icon = ; + } else if (Node.type === "particle") { + icon = ; + } else { + icon = ; + } + + // Get system type label for particles + const secondaryLabel = + Node.type === "particle" ? ( + {isSolid ? "Solid" : "Base"} + ) : undefined; + + return { + id: nodeId, + label: this._getNodeLabelComponent({ id: nodeId, nodeData: Node } as any, Node.name), + icon, + secondaryLabel, + isExpanded: isEffectRoot || Node.type === "group", + childNodes, + isSelected: false, + hasCaret: isEffectRoot || Node.type === "group" || (childNodes && childNodes.length > 0), + nodeData: Node, + }; + } + + /** + * Updates node names in the graph (called when name changes in properties) + */ + public updateNodeNames(): void { + const nodes = this._updateAllNodeNames(this.state.nodes); + this.setState({ nodes }); + } + + /** + * Updates all node names in the tree from actual data + */ + private _updateAllNodeNames(nodes: TreeNodeInfo[]): TreeNodeInfo[] { + return nodes.map((n) => { + const nodeName = n.nodeData?.name || "Unknown"; + const childNodes = n.childNodes ? this._updateAllNodeNames(n.childNodes) : undefined; + return { + ...n, + label: this._getNodeLabelComponent(n, nodeName), + childNodes, + }; + }); + } + + public render(): ReactNode { + return ( +
+ {this.state.nodes.length > 0 && ( +
+ this._handleNodeExpanded(n)} + onNodeCollapse={(n) => this._handleNodeCollapsed(n)} + onNodeClick={(n) => this._handleNodeClicked(n)} + /> +
+ )} + +
{ + ev.preventDefault(); + ev.dataTransfer.dropEffect = "copy"; + }} + onDrop={(ev) => this._handleDropEmpty(ev)} + > + + +
+ {this.state.nodes.length === 0 &&
No particles. Right-click to add.
} +
+
+ + + + Add + + + { + ev.dataTransfer.setData("Effect-editor/create-effect", "effect"); + }} + onClick={() => this._handleCreateEffect()} + > + Effect + + + + +
+
+
+ ); + } + + private _handleNodeExpanded(node: TreeNodeInfo): void { + const nodeId = node.id; + const nodes = this._updateNodeExpanded(this.state.nodes, nodeId as string | number, true); + this.setState({ nodes }); + } + + private _handleNodeCollapsed(node: TreeNodeInfo): void { + const nodeId = node.id; + const nodes = this._updateNodeExpanded(this.state.nodes, nodeId as string | number, false); + this.setState({ nodes }); + } + + private _updateNodeExpanded(nodes: TreeNodeInfo[], nodeId: string | number, isExpanded: boolean): TreeNodeInfo[] { + return nodes.map((n) => { + const nodeName = n.nodeData?.name || "Unknown"; + if (n.id === nodeId) { + return { + ...n, + label: this._getNodeLabelComponent(n, nodeName), + isExpanded, + childNodes: n.childNodes ? this._updateNodeExpanded(n.childNodes, nodeId, isExpanded) : undefined, + }; + } + const childNodes = n.childNodes ? this._updateNodeExpanded(n.childNodes, nodeId, isExpanded) : undefined; + return { + ...n, + label: this._getNodeLabelComponent(n, nodeName), + childNodes, + }; + }); + } + + private _handleNodeClicked(node: TreeNodeInfo): void { + const selectedId = node.id as string | number; + const nodes = this._updateNodeSelection(this.state.nodes, selectedId); + this.setState({ nodes, selectedNodeId: selectedId }); + this.props.onNodeSelected?.(selectedId); + } + + private _updateNodeSelection(nodes: TreeNodeInfo[], selectedId: string | number): TreeNodeInfo[] { + return nodes.map((n) => { + const nodeName = n.nodeData?.name || "Unknown"; + const isSelected = n.id === selectedId; + const childNodes = n.childNodes ? this._updateNodeSelection(n.childNodes, selectedId) : undefined; + return { + ...n, + label: this._getNodeLabelComponent(n, nodeName), + isSelected, + childNodes, + }; + }); + } + + private _getNodeLabelComponent(node: TreeNodeInfo, name: string): JSX.Element { + const label = ( +
{ + if (node.nodeData?.type === "group") { + ev.preventDefault(); + ev.stopPropagation(); + ev.dataTransfer.dropEffect = "copy"; + } + }} + onDrop={(ev) => { + if (node.nodeData?.type === "group") { + ev.preventDefault(); + ev.stopPropagation(); + this._handleDropOnNode(node, ev); + } + }} + > + {name} +
+ ); + + return ( + + {label} + + + + Add + + + {node.nodeData?.type === "group" && ( + <> + { + ev.dataTransfer.setData("Effect-editor/create-item", "base-particle"); + }} + onClick={() => this._handleAddParticleSystemToNode(node, "base")} + > + Base Particle + + { + ev.dataTransfer.setData("Effect-editor/create-item", "solid-particle"); + }} + onClick={() => this._handleAddParticleSystemToNode(node, "solid")} + > + Solid Particle + + { + ev.dataTransfer.setData("Effect-editor/create-item", "group"); + }} + onClick={() => this._handleAddGroupToNode(node)} + > + Group + + + )} + + + {this._isEffectRootNode(node) && ( + <> + + this._handleExportEffect(node)}>Export + + )} + + this._handleDeleteNode(node)}> + Delete + + + + ); + } + + /** + * Check if node is an effect root node + */ + private _isEffectRootNode(node: TreeNodeInfo): boolean { + const nodeData = node.nodeData; + if (!nodeData || !nodeData.uuid) { + return false; + } + + // Check if this node is the root of any effect + return this._effects.has(nodeData.uuid); + } + + /** + * Export effect to JSON file + */ + private async _handleExportEffect(node: TreeNodeInfo): Promise { + const nodeData = node.nodeData; + if (!nodeData || !nodeData.uuid) { + return; + } + + const effectInfo = this._effects.get(nodeData.uuid); + if (!effectInfo || !effectInfo.originalJsonData) { + toast.error("Cannot export effect: original data not available"); + return; + } + + const filePath = saveSingleFileDialog({ + title: "Export Effect", + filters: [{ name: "Effect Files", extensions: ["effect"] }], + defaultPath: `${effectInfo.name}.effect`, + }); + + if (!filePath) { + return; + } + + try { + await writeJSON(filePath, effectInfo.originalJsonData, { + spaces: "\t", + encoding: "utf-8", + }); + + toast.success(`Effect exported successfully to ${filePath}`); + } catch (error) { + console.error("Failed to export effect:", error); + toast.error(`Failed to export effect: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private _handleCreateEffect(): void { + if (!this.props.editor.preview?.scene) { + console.error("Scene is not available"); + return; + } + + // Create empty effect with empty IData + const emptyData: IData = { + root: null, + materials: [], + textures: [], + images: [], + geometries: [], + }; + const effect = new Effect(emptyData, this.props.editor.preview.scene); + + // Generate unique ID and name for effect + const effectId = `effect-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + let effectName = "Effect"; + let counter = 1; + while (Array.from(this._effects.values()).some((info) => info.name === effectName)) { + effectName = `Effect ${counter}`; + counter++; + } + + // Store effect + this._effects.set(effectId, { + id: effectId, + name: effectName, + effect: effect, + }); + + // Rebuild tree with all effects + this._rebuildTree(); + } + + private _findEffectForNode(node: TreeNodeInfo): Effect | null { + // Find the effect that contains this node by traversing up the tree + const nodeData = node.nodeData; + if (!nodeData) { + return null; + } + + // First check if this is an effect root node + if (nodeData.uuid) { + const effectInfo = this._effects.get(nodeData.uuid); + if (effectInfo) { + return effectInfo.effect; + } + } + + // Find effect by checking if node is in any effect's hierarchy + for (const effectInfo of this._effects.values()) { + const effect = effectInfo.effect; + if (effect.root) { + // Check if node is part of this effect's hierarchy + const findNodeInHierarchy = (current: IEffectNode): boolean => { + // Use instance comparison and uuid for matching + if (current === nodeData || (current.uuid && nodeData.uuid && current.uuid === nodeData.uuid)) { + return true; + } + for (const child of current.children) { + if (findNodeInHierarchy(child)) { + return true; + } + } + return false; + }; + + if (findNodeInHierarchy(effect.root)) { + return effect; + } + } + } + + return null; + } + + private _handleAddParticleSystemToNode(node: TreeNodeInfo, systemType: "solid" | "base"): void { + const effect = this._findEffectForNode(node); + if (!effect) { + console.error("No effect found for node"); + return; + } + + const nodeData = node.nodeData; + if (!nodeData || nodeData.type !== "group") { + console.error("Cannot add particle system: parent is not a group"); + return; + } + + const newNode = effect.createParticleSystem(nodeData, systemType); + if (newNode) { + // Rebuild tree with all effects + this._rebuildTree(); + } + } + + private _handleAddGroupToNode(node: TreeNodeInfo): void { + const effect = this._findEffectForNode(node); + if (!effect) { + console.error("No effect found for node"); + return; + } + + const nodeData = node.nodeData; + if (!nodeData || nodeData.type !== "group") { + console.error("Cannot add group: parent is not a group"); + return; + } + + const newNode = effect.createGroup(nodeData); + if (newNode) { + // Rebuild tree with all effects + this._rebuildTree(); + } + } + + private _handleDropEmpty(ev: React.DragEvent): void { + ev.preventDefault(); + ev.stopPropagation(); + + try { + const data = ev.dataTransfer.getData("Effect-editor/create-effect"); + if (data === "effect") { + this._handleCreateEffect(); + } + } catch (e) { + // Ignore errors + } + } + + private _handleDropOnNode(node: TreeNodeInfo, ev: React.DragEvent): void { + ev.preventDefault(); + ev.stopPropagation(); + + if (!node.nodeData || node.nodeData.type !== "group") { + return; + } + + try { + const data = ev.dataTransfer.getData("Effect-editor/create-item"); + if (data === "solid-particle") { + this._handleAddParticleSystemToNode(node, "solid"); + } else if (data === "base-particle") { + this._handleAddParticleSystemToNode(node, "base"); + } else if (data === "group") { + this._handleAddGroupToNode(node); + } + } catch (e) { + // Ignore errors + } + } + + private _addNodeToParent(nodes: TreeNodeInfo[], parentId: string | number, newNode: TreeNodeInfo): TreeNodeInfo[] { + return nodes.map((n) => { + if (n.id === parentId) { + const childNodes = n.childNodes || []; + return { + ...n, + childNodes: [...childNodes, newNode], + hasCaret: true, + isExpanded: true, + }; + } + if (n.childNodes) { + return { + ...n, + childNodes: this._addNodeToParent(n.childNodes, parentId, newNode), + }; + } + return n; + }); + } + + private _handleDeleteNode(node: TreeNodeInfo): void { + const nodeData = node.nodeData; + if (!nodeData) { + return; + } + + // Check if this is an effect root node (uuid matches an effect ID) + const effectId = nodeData.uuid; + if (effectId && this._effects.has(effectId)) { + // Delete entire effect + const effectInfo = this._effects.get(effectId); + if (effectInfo) { + effectInfo.effect.dispose(); + this._effects.delete(effectId); + this._rebuildTree(); + } + } else { + // Delete node from effect hierarchy + const effect = this._findEffectForNode(node); + if (!effect) { + return; + } + + // Find and remove node from effect hierarchy using instance comparison + const removeNodeFromHierarchy = (current: IEffectNode): boolean => { + // Remove from children - use instance comparison primarily + const index = current.children.findIndex((child) => { + // Primary: instance comparison + if (child === nodeData) { + return true; + } + // Fallback: uuid comparison (if both have uuid) + if (child.uuid && nodeData.uuid && child.uuid === nodeData.uuid) { + return true; + } + return false; + }); + + if (index !== -1) { + const removedNode = current.children[index]; + // Recursively dispose all children first + this._disposeNodeRecursive(removedNode); + current.children.splice(index, 1); + return true; + } + + // Recursively search in children + for (const child of current.children) { + if (removeNodeFromHierarchy(child)) { + return true; + } + } + return false; + }; + + if (effect.root) { + removeNodeFromHierarchy(effect.root); + this._rebuildTree(); + } + } + + // Clear selection if deleted node was selected + if (this.state.selectedNodeId === node.id) { + this.setState({ selectedNodeId: null }); + this.props.onNodeSelected?.(null); + } + } + + /** + * Recursively dispose a node and all its children + */ + private _disposeNodeRecursive(node: IEffectNode): void { + // First dispose all children + for (const child of node.children) { + this._disposeNodeRecursive(child); + } + + // Dispose system if it's a particle system + if (node.data) { + node.data.dispose(); + } + // Dispose group if it's a group + if (node.data) { + node.data.dispose(); + } + + // Clear the node ID from our map + this._nodeIdMap.delete(node); + } +} diff --git a/editor/src/editor/windows/effect-editor/index.tsx b/editor/src/editor/windows/effect-editor/index.tsx new file mode 100644 index 000000000..05238bc74 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/index.tsx @@ -0,0 +1,224 @@ +import { ipcRenderer } from "electron"; +import { readJSON, writeJSON } from "fs-extra"; + +import { toast } from "sonner"; + +import { Component, ReactNode } from "react"; + +import { Toaster } from "../../../ui/shadcn/ui/sonner"; + +import { EffectEditorLayout } from "./layout"; +import { EffectEditorToolbar } from "./toolbar"; +import { UnityImportModal } from "./modals/unity-import-modal"; + +import { projectConfiguration, onProjectConfigurationChangedObservable, IProjectConfiguration } from "../../../project/configuration"; +import { EffectEditorAnimation } from "./animation"; +import { EffectEditorGraph } from "./graph"; +import { EffectEditorPreview } from "./preview"; +import { EffectEditorResources } from "./resources"; +import { convertUnityPrefabToData } from "./converters"; + +export interface IEffectEditorWindowProps { + filePath?: string; + projectConfiguration?: IProjectConfiguration; +} + +export interface IEffectEditorWindowState { + filePath: string | null; + isUnityImportModalOpen: boolean; +} + +export interface IEffectEditor { + layout: EffectEditorLayout | null; + preview: EffectEditorPreview | null; + graph: EffectEditorGraph | null; + animation: EffectEditorAnimation | null; + resources: EffectEditorResources | null; +} +export default class EffectEditorWindow extends Component { + public editor: IEffectEditor = { + layout: null, + preview: null, + graph: null, + animation: null, + resources: null, + }; + + public constructor(props: IEffectEditorWindowProps) { + super(props); + + this.state = { + filePath: props.filePath || null, + isUnityImportModalOpen: false, + }; + } + + public render(): ReactNode { + return ( + <> +
+ + +
+ (this.editor.layout = r)} filePath={this.state.filePath || ""} editor={this.editor} /> +
+
+ + {/* Unity Import Modal */} + this.setState({ isUnityImportModalOpen: false })} + onImport={(contexts, prefabNames) => this.importUnityData(contexts, prefabNames)} + /> + + + + ); + } + + public async componentDidMount(): Promise { + ipcRenderer.on("save", () => this.save()); + ipcRenderer.on("editor:close-window", () => this.close()); + + // Set project configuration if provided + if (this.props.projectConfiguration) { + projectConfiguration.path = this.props.projectConfiguration.path; + projectConfiguration.compressedTexturesEnabled = this.props.projectConfiguration.compressedTexturesEnabled; + onProjectConfigurationChangedObservable.notifyObservers(projectConfiguration); + } + } + + public close(): void { + ipcRenderer.send("window:close"); + } + + public async loadFile(filePath: string): Promise { + this.setState({ filePath }); + // TODO: Load file data into editor + } + + public async save(): Promise { + if (!this.state.filePath) { + return; + } + + try { + const data = await readJSON(this.state.filePath); + await writeJSON(this.state.filePath, data, { spaces: 4 }); + toast.success("Effect saved"); + ipcRenderer.send("editor:asset-updated", "Effect", data); + } catch (error) { + toast.error("Failed to save Effect"); + } + } + + public async saveAs(filePath: string): Promise { + this.setState({ filePath }); + await this.save(); + } + + public async importFile(filePath: string): Promise { + try { + if (this.editor.graph) { + await this.editor.graph.loadFromFile(filePath); + toast.success("Effect imported"); + } else { + toast.error("Failed to import Effect: Graph not available"); + } + } catch (error) { + console.error("Failed to import Effect:", error); + toast.error("Failed to import Effect"); + } + } + + public async importQuarksFile(filePath: string): Promise { + try { + if (this.editor.graph) { + await this.editor.graph.loadFromQuarksFile(filePath); + toast.success("Quarks file imported"); + } else { + toast.error("Failed to import Quarks file: Graph not available"); + } + } catch (error) { + console.error("Failed to import Quarks file:", error); + toast.error("Failed to import Quarks file"); + } + } + + /** + * Import Unity prefab data and create Effect + * @param contexts - Array of Unity asset contexts (parsed components + dependencies) + * @param prefabNames - Array of prefab names corresponding to contexts + */ + public async importUnityData(contexts: any[], prefabNames: string[]): Promise { + try { + // Get Scene from preview for model loading + let scene = this.editor.preview?.scene || undefined; + if (!scene) { + // Try waiting a bit for preview to initialize + await new Promise((resolve) => setTimeout(resolve, 100)); + scene = this.editor.preview?.scene || undefined; + } + if (!scene) { + console.warn("Scene not available for model loading, models will be placeholders"); + } + + // Convert each prefab with its dependencies + let successCount = 0; + for (let i = 0; i < contexts.length; i++) { + try { + const context = contexts[i]; + const prefabName = prefabNames[i]; + + // Validate context structure + if (!context) { + console.error("Context is null/undefined:", context); + toast.error(`Invalid prefab data for ${prefabName}`); + continue; + } + + if (!context.prefabComponents) { + console.error("prefabComponents is missing in context:", context); + toast.error(`Missing prefab components for ${prefabName}`); + continue; + } + + if (!(context.prefabComponents instanceof Map)) { + console.error("prefabComponents is not a Map:", typeof context.prefabComponents, context.prefabComponents); + toast.error(`Invalid prefab components type for ${prefabName}`); + continue; + } + + // Convert to IData (pass already parsed components, dependencies, and Scene for model parsing) + const data = await convertUnityPrefabToData(context.prefabComponents, context.dependencies, scene as any); + + // Import into graph + if (this.editor.graph) { + await this.editor.graph.loadFromUnityData(data, prefabName); + successCount++; + } else { + toast.error(`Failed to import ${prefabName}: Graph not available`); + } + } catch (error) { + console.error(`Failed to import prefab ${prefabNames[i]}:`, error); + toast.error(`Failed to import ${prefabNames[i]}`); + } + } + + if (successCount > 0) { + toast.success(`Successfully imported ${successCount} prefab${successCount > 1 ? "s" : ""}`); + } + } catch (error) { + console.error("Failed to import Unity prefabs:", error); + toast.error("Failed to import Unity prefabs"); + throw error; + } + } + + /** + * Open Unity import modal + */ + public openUnityImportModal(): void { + this.setState({ isUnityImportModalOpen: true }); + } +} diff --git a/editor/src/editor/windows/effect-editor/layout.tsx b/editor/src/editor/windows/effect-editor/layout.tsx new file mode 100644 index 000000000..c762da8b8 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/layout.tsx @@ -0,0 +1,306 @@ +import { Component, ReactNode } from "react"; +import { IJsonModel, Layout, Model, TabNode } from "flexlayout-react"; + +import { waitNextAnimationFrame } from "../../../tools/tools"; + +import { EffectEditorPreview } from "./preview"; +import { EffectEditorGraph } from "./graph"; +import { EffectEditorAnimation } from "./animation"; +import { EffectEditorPropertiesTab } from "./properties/tab"; +import { EffectEditorResources } from "./resources"; +import { IEffectEditor } from "."; + +const layoutModel: IJsonModel = { + global: { + tabSetEnableMaximize: true, + tabEnableRename: false, + tabSetMinHeight: 50, + tabSetMinWidth: 240, + enableEdgeDock: false, + }, + layout: { + type: "row", + width: 100, + height: 100, + children: [ + { + type: "row", + weight: 75, + children: [ + { + type: "tabset", + weight: 75, + children: [ + { + type: "tab", + id: "preview", + name: "Preview", + component: "preview", + enableClose: false, + enableRenderOnDemand: false, + }, + ], + }, + { + type: "tabset", + weight: 25, + children: [ + { + type: "tab", + id: "animation", + name: "Animation", + component: "animation", + enableClose: false, + enableRenderOnDemand: false, + }, + ], + }, + ], + }, + { + type: "row", + weight: 25, + children: [ + { + type: "tabset", + weight: 40, + children: [ + { + type: "tab", + id: "graph", + name: "Particles", + component: "graph", + enableClose: false, + enableRenderOnDemand: false, + }, + { + type: "tab", + id: "resources", + name: "Resources", + component: "resources", + enableClose: false, + enableRenderOnDemand: false, + }, + ], + }, + { + type: "tabset", + weight: 60, + children: [ + { + type: "tab", + id: "properties-object", + name: "Object", + component: "properties-object", + enableClose: false, + enableRenderOnDemand: false, + }, + { + type: "tab", + id: "properties-emission", + name: "Emission", + component: "properties-emission", + enableClose: false, + enableRenderOnDemand: false, + }, + { + type: "tab", + id: "properties-renderer", + name: "Renderer", + component: "properties-renderer", + enableClose: false, + enableRenderOnDemand: false, + }, + { + type: "tab", + id: "properties-initialization", + name: "Initialization", + component: "properties-initialization", + enableClose: false, + enableRenderOnDemand: false, + }, + { + type: "tab", + id: "properties-behaviors", + name: "Behaviors", + component: "properties-behaviors", + enableClose: false, + enableRenderOnDemand: false, + }, + ], + }, + ], + }, + ], + }, +}; + +export interface IEffectEditorLayoutProps { + filePath: string | null; + editor: IEffectEditor; +} + +export interface IEffectEditorLayoutState { + selectedNodeId: string | number | null; + resources: any[]; + propertiesKey: number; +} + +export class EffectEditorLayout extends Component { + private _model: Model = Model.fromJson(layoutModel as any); + + private _components: Record = {}; + + public constructor(props: IEffectEditorLayoutProps) { + super(props); + + this.state = { + selectedNodeId: null, + resources: [], + propertiesKey: 0, + }; + } + + public componentDidMount(): void { + this._updateComponents(); + } + + public componentDidUpdate(): void { + this._updateComponents(); + } + + private _handleNodeSelected = (nodeId: string | number | null): void => { + this.setState( + (prevState) => ({ + selectedNodeId: nodeId, + propertiesKey: prevState.propertiesKey + 1, // Increment key to force component recreation + }), + () => { + // Update components immediately after state change + this._updateComponents(); + // Force update layout to ensure flexlayout-react sees the new component + this.forceUpdate(); + } + ); + }; + + private _updateComponents(): void { + this._components = { + preview: ( + (this.props.editor.preview = r!)} + filePath={this.props.filePath} + editor={this.props.editor} + selectedNodeId={this.state.selectedNodeId} + onSceneReady={() => { + // Update graph when scene is ready + if (this.props.editor.graph) { + this.props.editor.graph.forceUpdate(); + } + }} + /> + ), + graph: ( + (this.props.editor.graph = r!)} + filePath={this.props.filePath} + onNodeSelected={this._handleNodeSelected} + editor={this.props.editor} + // onResourcesLoaded={(resources) => { + // this.setState({ resources }); + // }} + /> + ), + resources: (this.props.editor.resources = r!)} resources={this.state.resources} />, + animation: (this.props.editor.animation = r!)} filePath={this.props.filePath} editor={this.props.editor} />, + "properties-object": ( + { + // Update graph node names when name changes + if (this.props.editor.graph) { + this.props.editor.graph.updateNodeNames(); + } + }} + getNodeData={(nodeId) => this.props.editor.graph?.getNodeData(nodeId) || null} + /> + ), + "properties-renderer": ( + this.props.editor.graph?.getNodeData(nodeId) || null} + /> + ), + "properties-emission": ( + this.props.editor.graph?.getNodeData(nodeId) || null} + /> + ), + "properties-initialization": ( + this.props.editor.graph?.getNodeData(nodeId) || null} + /> + ), + "properties-behaviors": ( + this.props.editor.graph?.getNodeData(nodeId) || null} + /> + ), + }; + } + + public render(): ReactNode { + return ( +
+ this._layoutFactory(n)} /> +
+ ); + } + + private _layoutFactory(node: TabNode): ReactNode { + const componentName = node.getComponent(); + if (!componentName) { + return
Error, see console...
; + } + + // Always update components before returning, especially for properties tabs + // This ensures flexlayout-react gets the latest component with updated props + if (componentName.startsWith("properties-")) { + this._updateComponents(); + } + + const component = this._components[componentName]; + if (!component) { + return
Error, see console...
; + } + + node.setEventListener("resize", () => { + waitNextAnimationFrame().then(() => this.props.editor.preview?.resize()); + }); + + return component; + } +} diff --git a/editor/src/editor/windows/effect-editor/modals/unity-import-modal.tsx b/editor/src/editor/windows/effect-editor/modals/unity-import-modal.tsx new file mode 100644 index 000000000..9ca2f662b --- /dev/null +++ b/editor/src/editor/windows/effect-editor/modals/unity-import-modal.tsx @@ -0,0 +1,1107 @@ +/** + * Unity Asset Import Modal + * Allows importing Unity Particle System prefabs from ZIP archives + */ + +import { Component, ReactNode } from "react"; +import { Tree, TreeNodeInfo } from "@blueprintjs/core"; +import { Button } from "../../../../ui/shadcn/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../../../ui/shadcn/ui/dialog"; +import { Checkbox } from "../../../../ui/shadcn/ui/checkbox"; +import { Upload, FileArchive, Search } from "lucide-react"; +import { HiOutlineFolder } from "react-icons/hi2"; +import { toast } from "sonner"; +import * as yaml from "js-yaml"; +import { Input } from "../../../../ui/shadcn/ui/input"; +import { ScrollArea } from "../../../../ui/shadcn/ui/scroll-area"; + +/** + * Recursively process Unity inline objects like {fileID: 123} + */ +function processUnityInlineObjects(obj: any): any { + if (typeof obj === "string") { + const inlineMatch = obj.match(/^\s*\{([^}]+)\}\s*$/); + if (inlineMatch) { + const pairs = inlineMatch[1].split(","); + const result: any = {}; + for (const pair of pairs) { + const [key, value] = pair.split(":").map((s) => s.trim()); + if (key && value !== undefined) { + if (value === "true") result[key] = true; + else if (value === "false") result[key] = false; + else if (/^-?\d+$/.test(value)) result[key] = parseInt(value, 10); + else if (/^-?\d*\.\d+$/.test(value)) result[key] = parseFloat(value); + else result[key] = value.replace(/^["']|["']$/g, ""); + } + } + return result; + } + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => processUnityInlineObjects(item)); + } + + if (obj && typeof obj === "object") { + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = processUnityInlineObjects(value); + } + return result; + } + + return obj; +} + +/** + * Parse Unity YAML string into component map (same logic as in converter) + */ +function parseUnityYAML(yamlContent: string): Map { + // Validate input + if (typeof yamlContent !== "string") { + console.error("parseUnityYAML: yamlContent must be a string, got:", typeof yamlContent, yamlContent); + throw new Error("parseUnityYAML: yamlContent must be a string"); + } + + const components = new Map(); + const documents = yamlContent.split(/^---\s+/gm).filter(Boolean); + + for (const doc of documents) { + const match = doc.match(/^!u!(\d+)\s+&(\d+)/); + if (!match) continue; + + const [, componentType, componentId] = match; + const yamlWithoutTag = doc.replace(/^!u!(\d+)\s+&(\d+)\s*\n/, ""); + + try { + const parsed = yaml.load(yamlWithoutTag, { + schema: yaml.DEFAULT_SCHEMA, + }) as any; + + if (!parsed || typeof parsed !== "object") { + continue; + } + + const processed = processUnityInlineObjects(parsed); + + const component: any = { + id: componentId, + __type: componentType, + ...processed, + }; + + components.set(componentId, component); + + const mainKey = Object.keys(processed).find((k) => k !== "id" && k !== "__type"); + if (mainKey && processed[mainKey] && typeof processed[mainKey] === "object") { + processed[mainKey].__id = componentId; + } + } catch (error) { + console.warn(`Failed to parse Unity YAML component ${componentId}:`, error); + continue; + } + } + + return components; +} + +export interface IUnityPrefabNode { + name: string; + path: string; + type: "prefab" | "folder"; + children?: IUnityPrefabNode[]; +} + +export interface IUnityAssetContext { + prefabComponents: Map; // Already parsed Unity components + dependencies: { + textures: Map; // GUID -> file data + materials: Map; // GUID -> YAML content + models: Map; // GUID -> file data + sounds: Map; // GUID -> file data + meta: Map; // GUID -> meta data + }; +} + +export interface IUnityImportModalProps { + isOpen: boolean; + onClose: () => void; + onImport: (contexts: IUnityAssetContext[], prefabNames: string[]) => void; +} + +export interface IUnityImportModalState { + isDragging: boolean; + zipFile: File | null; + treeNodes: TreeNodeInfo[]; + selectedPrefabs: Set; + isProcessing: boolean; + searchQuery: string; +} + +/** + * Modal for importing Unity prefabs from ZIP archives + */ +export class UnityImportModal extends Component { + private _dropZoneRef: HTMLDivElement | null = null; + + constructor(props: IUnityImportModalProps) { + super(props); + + this.state = { + isDragging: false, + zipFile: null, + treeNodes: [], + selectedPrefabs: new Set(), + isProcessing: false, + searchQuery: "", + }; + } + + public render(): ReactNode { + const { isOpen } = this.props; + const { isDragging, zipFile, treeNodes, selectedPrefabs, isProcessing } = this.state; + + const { searchQuery } = this.state; + const filteredTreeNodes = this._filterTreeNodes(treeNodes, searchQuery); + + return ( + !open && this._handleClose()}> + + + + + Import Unity Assets + + + {/* Search and Controls - only show when tree is loaded */} + {treeNodes.length > 0 && ( +
+
+
+ + + {selectedPrefabs.size} of {this._countPrefabs(treeNodes)} prefab{this._countPrefabs(treeNodes) !== 1 ? "s" : ""} selected + +
+
+ + +
+
+ + {/* Search Input */} +
+ + this.setState({ searchQuery: e.target.value })} + className="pl-9 h-9" + /> +
+
+ )} +
+ +
+ {/* Drop Zone */} + {!zipFile && ( +
(this._dropZoneRef = ref)} + className={` + border-2 border-dashed rounded-lg p-12 + flex flex-col items-center justify-center gap-4 + transition-all duration-200 cursor-pointer + ${isDragging ? "border-primary bg-primary/5 scale-[1.02]" : "border-border hover:border-primary/50 hover:bg-muted/30"} + `} + onDragOver={(e) => this._handleDragOver(e)} + onDragLeave={(e) => this._handleDragLeave(e)} + onDrop={(e) => this._handleDrop(e)} + onClick={() => this._handleClickUpload()} + > +
+ +
+
+

{isDragging ? "Drop ZIP archive here" : "Drag & drop Unity ZIP archive"}

+

or click to browse

+
+

Supported: .zip (Unity Prefab + Meta files)

+
+ )} + + {/* File Info */} + {zipFile && treeNodes.length === 0 && ( +
+
+ +
+
+

{zipFile.name}

+

{this._formatFileSize(zipFile.size)}

+
+ +
+ )} + + {/* Prefab Tree */} + {treeNodes.length > 0 && ( + +
+ +
+
+ )} + + {/* Processing State */} + {isProcessing && ( +
+
+
+
+
+
+

Processing ZIP archive...

+

Please wait

+
+
+
+ )} +
+ + + + + +
+
+ ); + } + + /** + * Build tree structure from ZIP file paths + */ + private _buildTreeFromPaths(prefabPaths: string[]): IUnityPrefabNode[] { + // Build folder tree structure + const folderMap = new Map(); + + // Helper to get or create folder node + const getOrCreateFolder = (folderPath: string, folderName: string): IUnityPrefabNode => { + if (!folderMap.has(folderPath)) { + folderMap.set(folderPath, { + name: folderName, + path: folderPath, + type: "folder", + children: [], + }); + } + return folderMap.get(folderPath)!; + }; + + // Process each prefab path + for (const path of prefabPaths) { + const parts = path.split("/").filter(Boolean); + const fileName = parts.pop() || path; + const folderParts = parts; + + // Build folder hierarchy + let currentPath = ""; + let parentNode: IUnityPrefabNode | null = null; + + for (let i = 0; i < folderParts.length; i++) { + const folderName = folderParts[i]; + currentPath = currentPath ? `${currentPath}/${folderName}` : folderName; + + const folderNode = getOrCreateFolder(currentPath, folderName); + if (parentNode && parentNode.children) { + // Check if already added + if (!parentNode.children.some((c) => c.path === currentPath)) { + parentNode.children.push(folderNode); + } + } + parentNode = folderNode; + } + + // Add prefab to parent folder (or root) + const prefabNode: IUnityPrefabNode = { + name: fileName.replace(".prefab", ""), + path: path, + type: "prefab", + }; + + if (parentNode) { + if (!parentNode.children) { + parentNode.children = []; + } + parentNode.children.push(prefabNode); + } else { + // Root level prefab - add to root + if (!folderMap.has("")) { + folderMap.set("", { + name: "", + path: "", + type: "folder", + children: [], + }); + } + folderMap.get("")!.children!.push(prefabNode); + } + } + + // Get root folders (those without parent in map) + const rootNodes: IUnityPrefabNode[] = []; + for (const [path, node] of folderMap) { + if (path === "" || !folderMap.has(path.split("/").slice(0, -1).join("/"))) { + rootNodes.push(node); + } + } + + // Sort: folders first, then prefabs, alphabetically + const sortNode = (node: IUnityPrefabNode): void => { + if (node.children) { + node.children.sort((a, b) => { + if (a.type === "folder" && b.type === "prefab") return -1; + if (a.type === "prefab" && b.type === "folder") return 1; + return a.name.localeCompare(b.name); + }); + node.children.forEach(sortNode); + } + }; + + rootNodes.forEach(sortNode); + rootNodes.sort((a, b) => a.name.localeCompare(b.name)); + + return rootNodes; + } + + /** + * Convert IUnityPrefabNode to TreeNodeInfo + */ + private _convertToTreeNode(node: IUnityPrefabNode): TreeNodeInfo { + const isSelected = this.state.selectedPrefabs.has(node.path); + const childNodes = node.children ? node.children.map((child) => this._convertToTreeNode(child)) : undefined; + + const label = ( +
+ {node.type === "prefab" && ( + this._handleTogglePrefab(node.path, checked as boolean)} onClick={(e) => e.stopPropagation()} /> + )} + {node.name || "Root"} +
+ ); + + return { + id: node.path, + label, + icon: node.type === "folder" ? : undefined, + isExpanded: node.type === "folder", + hasCaret: node.type === "folder" && childNodes && childNodes.length > 0, + childNodes, + isSelected: false, // Tree selection is handled by checkbox + nodeData: node, + }; + } + + /** + * Handle node click + */ + private _handleNodeClick = (nodeData: TreeNodeInfo): void => { + // Toggle checkbox for prefab nodes + if (nodeData.nodeData?.type === "prefab") { + const isSelected = this.state.selectedPrefabs.has(nodeData.nodeData.path); + this._handleTogglePrefab(nodeData.nodeData.path, !isSelected); + } + }; + + /** + * Handle node expand + */ + private _handleNodeExpand = (nodeData: TreeNodeInfo): void => { + // Update tree to reflect expansion + const updateNodes = (nodes: TreeNodeInfo[]): TreeNodeInfo[] => { + return nodes.map((n) => { + if (n.id === nodeData.id) { + return { ...n, isExpanded: true }; + } + if (n.childNodes) { + return { ...n, childNodes: updateNodes(n.childNodes) }; + } + return n; + }); + }; + this.setState({ treeNodes: updateNodes(this.state.treeNodes) }); + }; + + /** + * Handle node collapse + */ + private _handleNodeCollapse = (nodeData: TreeNodeInfo): void => { + // Update tree to reflect collapse + const updateNodes = (nodes: TreeNodeInfo[]): TreeNodeInfo[] => { + return nodes.map((n) => { + if (n.id === nodeData.id) { + return { ...n, isExpanded: false }; + } + if (n.childNodes) { + return { ...n, childNodes: updateNodes(n.childNodes) }; + } + return n; + }); + }; + this.setState({ treeNodes: updateNodes(this.state.treeNodes) }); + }; + + /** + * Handle drag over + */ + private _handleDragOver(e: React.DragEvent): void { + e.preventDefault(); + e.stopPropagation(); + this.setState({ isDragging: true }); + } + + /** + * Handle drag leave + */ + private _handleDragLeave(e: React.DragEvent): void { + e.preventDefault(); + e.stopPropagation(); + if (e.currentTarget === this._dropZoneRef) { + this.setState({ isDragging: false }); + } + } + + /** + * Handle drop + */ + private async _handleDrop(e: React.DragEvent): Promise { + e.preventDefault(); + e.stopPropagation(); + this.setState({ isDragging: false }); + + const files = Array.from(e.dataTransfer.files); + const zipFile = files.find((f) => f.name.endsWith(".zip")); + + if (!zipFile) { + toast.error("Please drop a ZIP archive"); + return; + } + + await this._processZipFile(zipFile); + } + + /** + * Handle click upload + */ + private _handleClickUpload(): void { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".zip"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + await this._processZipFile(file); + } + }; + input.click(); + } + + /** + * Process ZIP file and extract prefab list with folder structure + */ + private async _processZipFile(file: File): Promise { + this.setState({ zipFile: file, isProcessing: true }); + + try { + // Convert File to Buffer for adm-zip (Electron-compatible) + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Use adm-zip for Electron (better than jszip for Node.js/Electron) + const AdmZip = (await import("adm-zip")).default; + const zip = new AdmZip(buffer); + + const prefabPaths: string[] = []; + + // Get all entries from ZIP + const zipEntries = zip.getEntries(); + + // Find all .prefab files (ignore .meta files and directories) + for (const entry of zipEntries) { + if (entry.entryName.endsWith(".prefab") && !entry.isDirectory && !entry.entryName.endsWith(".meta")) { + prefabPaths.push(entry.entryName); + } + } + + if (prefabPaths.length === 0) { + toast.error("No .prefab files found in ZIP archive"); + this.setState({ isProcessing: false, zipFile: null }); + return; + } + + // Build tree structure from paths + const treeStructure = this._buildTreeFromPaths(prefabPaths); + + // Convert to TreeNodeInfo + const treeNodes = treeStructure.map((node) => this._convertToTreeNode(node)); + + this.setState({ + treeNodes, + isProcessing: false, + }); + + toast.success(`Found ${prefabPaths.length} prefab${prefabPaths.length > 1 ? "s" : ""} in archive`); + } catch (error) { + console.error("Failed to process ZIP:", error); + toast.error("Failed to process ZIP archive"); + this.setState({ isProcessing: false, zipFile: null }); + } + } + + /** + * Handle toggle prefab selection + */ + private _handleTogglePrefab(path: string, checked: boolean): void { + const selectedPrefabs = new Set(this.state.selectedPrefabs); + if (checked) { + selectedPrefabs.add(path); + } else { + selectedPrefabs.delete(path); + } + + // Update tree nodes to reflect checkbox state + const updateNodes = (nodes: TreeNodeInfo[]): TreeNodeInfo[] => { + return nodes.map((n) => { + if (n.nodeData?.path === path) { + // Update label with new checkbox state + const node = n.nodeData; + const label = ( +
+ {node.type === "prefab" && ( + this._handleTogglePrefab(path, c as boolean)} onClick={(e) => e.stopPropagation()} /> + )} + {node.name || "Root"} +
+ ); + return { ...n, label }; + } + if (n.childNodes) { + return { ...n, childNodes: updateNodes(n.childNodes) }; + } + return n; + }); + }; + + this.setState({ + selectedPrefabs, + treeNodes: updateNodes(this.state.treeNodes), + }); + } + + /** + * Handle select all prefabs + */ + private _handleSelectAll(): void { + const allPaths = this._collectAllPaths(this.state.treeNodes); + this.setState({ selectedPrefabs: new Set(allPaths) }); + + // Update tree to reflect all selected + const updateNodes = (nodes: TreeNodeInfo[]): TreeNodeInfo[] => { + return nodes.map((n) => { + if (n.nodeData?.type === "prefab") { + const node = n.nodeData; + const label = ( +
+ this._handleTogglePrefab(node.path, c as boolean)} onClick={(e) => e.stopPropagation()} /> + {node.name} +
+ ); + return { ...n, label }; + } + if (n.childNodes) { + return { ...n, childNodes: updateNodes(n.childNodes) }; + } + return n; + }); + }; + this.setState({ treeNodes: updateNodes(this.state.treeNodes) }); + } + + /** + * Handle select none + */ + private _handleSelectNone(): void { + this.setState({ selectedPrefabs: new Set() }); + + // Update tree to reflect none selected + const updateNodes = (nodes: TreeNodeInfo[]): TreeNodeInfo[] => { + return nodes.map((n) => { + if (n.nodeData?.type === "prefab") { + const node = n.nodeData; + const label = ( +
+ this._handleTogglePrefab(node.path, c as boolean)} onClick={(e) => e.stopPropagation()} /> + {node.name} +
+ ); + return { ...n, label }; + } + if (n.childNodes) { + return { ...n, childNodes: updateNodes(n.childNodes) }; + } + return n; + }); + }; + this.setState({ treeNodes: updateNodes(this.state.treeNodes) }); + } + + /** + * Collect all prefab paths recursively from tree nodes + */ + private _collectAllPaths(nodes: TreeNodeInfo[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.nodeData?.type === "prefab") { + paths.push(node.nodeData.path); + } + if (node.childNodes) { + paths.push(...this._collectAllPaths(node.childNodes)); + } + } + return paths; + } + + /** + * Count total prefabs in tree + */ + private _countPrefabs(nodes: TreeNodeInfo[]): number { + let count = 0; + for (const node of nodes) { + if (node.nodeData?.type === "prefab") { + count++; + } + if (node.childNodes) { + count += this._countPrefabs(node.childNodes); + } + } + return count; + } + + /** + * Filter tree nodes by search query + */ + private _filterTreeNodes(nodes: TreeNodeInfo[], query: string): TreeNodeInfo[] { + if (!query.trim()) { + return nodes; + } + + const lowerQuery = query.toLowerCase(); + + const filterNode = (node: TreeNodeInfo): TreeNodeInfo | null => { + const nodeName = node.nodeData?.name?.toLowerCase() || ""; + const matchesQuery = nodeName.includes(lowerQuery); + + // Filter children first + let filteredChildren: TreeNodeInfo[] | undefined; + if (node.childNodes) { + filteredChildren = node.childNodes.map(filterNode).filter((n) => n !== null) as TreeNodeInfo[]; + } + + // If this node matches or has matching children, include it + if (matchesQuery || (filteredChildren && filteredChildren.length > 0)) { + return { + ...node, + childNodes: filteredChildren && filteredChildren.length > 0 ? filteredChildren : undefined, + isExpanded: matchesQuery || (filteredChildren && filteredChildren.length > 0) ? true : node.isExpanded, + }; + } + + return null; + }; + + return nodes.map(filterNode).filter((n) => n !== null) as TreeNodeInfo[]; + } + + /** + * Handle remove file + */ + private _handleRemoveFile(): void { + this.setState({ + zipFile: null, + treeNodes: [], + selectedPrefabs: new Set(), + searchQuery: "", + }); + } + + /** + * Collect all fileID references from parsed YAML object + */ + private _collectFileIDs(obj: any, fileIDs: Set): void { + if (typeof obj !== "object" || obj === null) { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + this._collectFileIDs(item, fileIDs); + } + return; + } + + // Check for Unity fileID references: {fileID: "123"} or {fileID: 123} + if (obj.fileID !== undefined) { + const fileID = String(obj.fileID); + if (fileID !== "0" && fileID !== "4294967295") { + // 0 = null, 4294967295 = missing + fileIDs.add(fileID); + } + } + + // Recursively check all properties + for (const value of Object.values(obj)) { + this._collectFileIDs(value, fileIDs); + } + } + + /** + * Parse Unity .meta file to extract GUID and fileID mappings + */ + private _parseMetaFile(metaContent: string): { guid: string; fileIDToGUID: Map } | null { + try { + const parsed = yaml.load(metaContent) as any; + if (!parsed || !parsed.guid) { + return null; + } + + const fileIDToGUID = new Map(); + const guid = parsed.guid; + + // Unity stores fileID -> GUID mappings in the meta file + // Look for external references in the meta file + if (parsed.ExternalObjects) { + for (const [key, value] of Object.entries(parsed.ExternalObjects)) { + if (value && typeof value === "object" && (value as any).guid) { + fileIDToGUID.set(key, (value as any).guid); + } + } + } + + // Also check for fileIDToRecycleName which maps fileID to GUID + if (parsed.fileIDToRecycleName) { + for (const [fileID, guidOrName] of Object.entries(parsed.fileIDToRecycleName)) { + // Sometimes it's a GUID directly, sometimes it needs lookup + if (typeof guidOrName === "string") { + fileIDToGUID.set(fileID, guidOrName); + } + } + } + + return { + guid, + fileIDToGUID, + }; + } catch (error) { + console.warn("Failed to parse meta file:", error); + return null; + } + } + + /** + * Collect all dependencies for a prefab + */ + private async _collectDependencies(zip: any, prefabPath: string, allEntries: any[]): Promise { + const dependencies: IUnityAssetContext["dependencies"] = { + textures: new Map(), + materials: new Map(), + models: new Map(), + sounds: new Map(), + meta: new Map(), + }; + + // Read prefab YAML + const prefabEntry = zip.getEntry(prefabPath); + if (!prefabEntry) { + return dependencies; + } + + const prefabYaml = prefabEntry.getData().toString("utf8"); + + // Parse Unity YAML to find fileID references + const components = parseUnityYAML(prefabYaml); + + // Build fileID -> GUID mapping from all .meta files FIRST + const fileIDToGUID = new Map(); + const guidToPath = new Map(); + const guidToMeta = new Map(); + + // First pass: collect all meta files and build GUID -> path mapping + for (const entry of allEntries) { + if (entry.entryName.endsWith(".meta") && !entry.isDirectory) { + try { + const metaContent = entry.getData().toString("utf8"); + const meta = this._parseMetaFile(metaContent); + if (meta) { + const assetPath = entry.entryName.replace(/\.meta$/, ""); + guidToPath.set(meta.guid, assetPath); + guidToMeta.set(meta.guid, meta); + + // Map fileID -> GUID from this meta file + for (const [fileID, guid] of meta.fileIDToGUID) { + fileIDToGUID.set(fileID, guid); + } + } + } catch (error) { + console.warn(`Failed to parse meta file ${entry.entryName}:`, error); + } + } + } + + // Collect component IDs (internal references within prefab) + const componentIDs = new Set(components.keys()); + + // Collect all fileID references from components + const allFileIDs = new Set(); + for (const [_id, component] of components) { + this._collectFileIDs(component, allFileIDs); + } + + // Find external fileID references (those that are NOT component IDs but have GUID mappings) + // These are references to external assets (textures, materials, models, etc.) + const externalFileIDs = new Set(); + for (const fileID of allFileIDs) { + // If fileID is not a component ID (internal) and has a GUID mapping, it's an external asset reference + if (!componentIDs.has(fileID) && fileIDToGUID.has(fileID)) { + externalFileIDs.add(fileID); + } + } + + // Also check for direct GUID references in components (m_Texture, m_Material, etc.) + const referencedGUIDs = new Set(); + for (const [_id, component] of components) { + this._collectGUIDReferences(component, referencedGUIDs); + } + + // Collect all assets that are referenced + const collectedGUIDs = new Set(); + for (const fileID of externalFileIDs) { + const guid = fileIDToGUID.get(fileID); + if (guid) { + collectedGUIDs.add(guid); + } + } + for (const guid of referencedGUIDs) { + collectedGUIDs.add(guid); + } + + // Collect dependencies by GUID (ONLY those that are actually referenced) + for (const guid of collectedGUIDs) { + const assetPath = guidToPath.get(guid); + if (!assetPath) { + continue; + } + + const assetEntry = zip.getEntry(assetPath); + if (!assetEntry || assetEntry.isDirectory) { + continue; + } + + const ext = assetPath.split(".").pop()?.toLowerCase(); + const meta = guidToMeta.get(guid); + + if (meta) { + dependencies.meta.set(guid, { path: assetPath, guid }); + + // Categorize by extension + if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "tga" || ext === "tiff" || ext === "dds") { + dependencies.textures.set(guid, assetEntry.getData()); + } else if (ext === "mat") { + const matContent = assetEntry.getData().toString("utf8"); + dependencies.materials.set(guid, matContent); + } else if (ext === "fbx" || ext === "obj" || ext === "dae" || ext === "mesh") { + dependencies.models.set(guid, assetEntry.getData()); + } else if (ext === "wav" || ext === "mp3" || ext === "ogg" || ext === "aac") { + dependencies.sounds.set(guid, assetEntry.getData()); + } + } + } + + console.log(`Collected dependencies for ${prefabPath}:`, { + textures: dependencies.textures.size, + materials: dependencies.materials.size, + models: dependencies.models.size, + sounds: dependencies.sounds.size, + meta: dependencies.meta.size, + }); + + return dependencies; + } + + /** + * Collect GUID references from Unity component (m_Texture, m_Material, etc.) + */ + private _collectGUIDReferences(obj: any, guids: Set): void { + if (typeof obj !== "object" || obj === null) { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + this._collectGUIDReferences(item, guids); + } + return; + } + + // Check for GUID fields (Unity uses guid field in some references) + if (obj.guid && typeof obj.guid === "string") { + guids.add(obj.guid); + } + + // Check for common Unity asset reference patterns + if (obj.m_Texture && obj.m_Texture.guid) { + guids.add(obj.m_Texture.guid); + } + if (obj.m_Material && obj.m_Material.guid) { + guids.add(obj.m_Material.guid); + } + if (obj.m_Mesh && obj.m_Mesh.guid) { + guids.add(obj.m_Mesh.guid); + } + + // Recursively check all properties + for (const value of Object.values(obj)) { + this._collectGUIDReferences(value, guids); + } + } + + /** + * Handle import - collect all dependencies and pass to converter + */ + private async _handleImport(): Promise { + const { zipFile, selectedPrefabs } = this.state; + if (!zipFile || selectedPrefabs.size === 0) { + toast.error("No prefabs selected"); + return; + } + + this.setState({ isProcessing: true }); + + try { + // Convert File to Buffer + const arrayBuffer = await zipFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Load ZIP + const AdmZip = (await import("adm-zip")).default; + const zip = new AdmZip(buffer); + const allEntries = zip.getEntries(); + + // Process each selected prefab + const contexts: IUnityAssetContext[] = []; + const prefabNames: string[] = []; + + for (const prefabPath of Array.from(selectedPrefabs)) { + try { + // Read prefab YAML + const prefabEntry = zip.getEntry(prefabPath); + if (!prefabEntry || prefabEntry.isDirectory) { + continue; + } + + const prefabYaml = prefabEntry.getData().toString("utf8"); + + // Validate YAML content + if (typeof prefabYaml !== "string" || !prefabYaml.trim()) { + console.error(`Invalid prefab YAML for ${prefabPath}`); + toast.error(`Invalid prefab file: ${prefabPath.split("/").pop()}`); + continue; + } + + // Parse Unity YAML here (already done in _collectDependencies, but we need it here too) + const prefabComponents = parseUnityYAML(prefabYaml); + + // Validate parsed components + if (!(prefabComponents instanceof Map)) { + console.error(`Failed to parse prefab components for ${prefabPath}, got:`, typeof prefabComponents); + toast.error(`Failed to parse prefab: ${prefabPath.split("/").pop()}`); + continue; + } + + // Collect all dependencies + const dependencies = await this._collectDependencies(zip, prefabPath, allEntries); + + contexts.push({ + prefabComponents, + dependencies, + }); + + prefabNames.push(prefabPath.split("/").pop()?.replace(".prefab", "") || prefabPath); + } catch (error) { + console.error(`Failed to process prefab ${prefabPath}:`, error); + toast.error(`Failed to process ${prefabPath.split("/").pop()}`); + } + } + + if (contexts.length > 0) { + this.props.onImport(contexts, prefabNames); + this._handleClose(); + } else { + toast.error("No valid prefabs to import"); + } + } catch (error) { + console.error("Failed to import Unity prefabs:", error); + toast.error("Failed to import Unity prefabs"); + } finally { + this.setState({ isProcessing: false }); + } + } + + /** + * Handle close + */ + private _handleClose(): void { + this.setState({ + isDragging: false, + zipFile: null, + treeNodes: [], + selectedPrefabs: new Set(), + isProcessing: false, + searchQuery: "", + }); + this.props.onClose(); + } + + /** + * Format file size + */ + private _formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } +} diff --git a/editor/src/editor/windows/effect-editor/preview.tsx b/editor/src/editor/windows/effect-editor/preview.tsx new file mode 100644 index 000000000..cafdf5da3 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/preview.tsx @@ -0,0 +1,341 @@ +import { Component, ReactNode } from "react"; + +import { Scene } from "@babylonjs/core/scene"; +import { Engine } from "@babylonjs/core/Engines/engine"; +import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera"; +import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight"; +import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder"; +import { Color3, Color4 } from "@babylonjs/core/Maths/math.color"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { GridMaterial } from "@babylonjs/materials"; + +import { Button } from "../../../ui/shadcn/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../../ui/shadcn/ui/tooltip"; + +import { IoPlay, IoStop, IoRefresh } from "react-icons/io5"; +import type { IEffectEditor } from "."; +import { Effect, type IEffectNode } from "babylonjs-editor-tools"; + +export interface IEffectEditorPreviewProps { + filePath: string | null; + onSceneReady?: (scene: Scene) => void; + editor?: IEffectEditor; + selectedNodeId?: string | number | null; +} + +export interface IEffectEditorPreviewState { + playing: boolean; +} + +export class EffectEditorPreview extends Component { + public engine: Engine | null = null; + public scene: Scene | null = null; + public camera: ArcRotateCamera | null = null; + + private _canvasRef: HTMLCanvasElement | null = null; + private _renderLoopId: number = -1; + + public constructor(props: IEffectEditorPreviewProps) { + super(props); + + this.state = { + playing: false, + }; + } + + public render(): ReactNode { + return ( +
+ this._onGotCanvasRef(r!)} className="w-full h-full outline-none" /> + + {/* Play/Stop/Restart buttons - only show if a node is selected */} + {this.props.selectedNodeId && ( +
+ + + + + + {this.state.playing ? "Stop" : "Play"} + + + {this.state.playing && ( + + + + + Restart + + )} + +
+ )} +
+ ); + } + + public componentDidMount(): void { + // Canvas ref will be set in render, _onGotCanvasRef will be called automatically + // Sync playing state with effect state + this._syncPlayingState(); + } + + private _syncPlayingState(): void { + if (!this.props.selectedNodeId) { + // No node selected, hide buttons + if (this.state.playing) { + this.setState({ playing: false }); + } + return; + } + + const nodeData = this.props.editor?.graph?.getNodeData(this.props.selectedNodeId); + if (!nodeData) { + if (this.state.playing) { + this.setState({ playing: false }); + } + return; + } + + // Find the effect that contains this node + const effect = this._findEffectForNode(nodeData); + if (!effect) { + if (this.state.playing) { + this.setState({ playing: false }); + } + return; + } + + // Check if this is an effect root node + const isEffectRoot = this._isEffectRootNode(nodeData); + if (isEffectRoot) { + // For effect root, check if entire effect is started + const isStarted = effect.isStarted(); + if (this.state.playing !== isStarted) { + this.setState({ playing: isStarted }); + } + } else { + // For group or system, check if node is started + const isStarted = effect.isNodeStarted(nodeData); + if (this.state.playing !== isStarted) { + this.setState({ playing: isStarted }); + } + } + } + + /** + * Find the effect that contains the given node + */ + private _findEffectForNode(node: IEffectNode): Effect | null { + const effects = this.props.editor?.graph?.getAllEffects() || []; + for (const effect of effects) { + // Check if node is part of this effect's hierarchy + if (this._isNodeInEffect(node, effect)) { + return effect; + } + } + return null; + } + + /** + * Check if node is part of effect's hierarchy + */ + private _isNodeInEffect(node: IEffectNode, effect: Effect): boolean { + if (!effect.root) { + return false; + } + + const findNode = (current: IEffectNode): boolean => { + if (current === node || current.uuid === node.uuid || current.name === node.name) { + return true; + } + for (const child of current.children) { + if (findNode(child)) { + return true; + } + } + return false; + }; + + return findNode(effect.root); + } + + /** + * Check if node is an effect root node + */ + private _isEffectRootNode(node: IEffectNode): boolean { + if (!node.uuid) { + return false; + } + + // Check if this node's UUID matches an effect ID (effect root has effect ID as uuid) + const effects = this.props.editor?.graph?.getAllEffects() || []; + for (const effect of effects) { + if (effect.root && effect.root.uuid === node.uuid) { + return true; + } + } + return false; + } + + public componentWillUnmount(): void { + if (this._renderLoopId !== -1) { + cancelAnimationFrame(this._renderLoopId); + } + + this.scene?.dispose(); + this.engine?.dispose(); + } + + /** + * Resizes the engine. + */ + public resize(): void { + this.engine?.resize(); + } + + private async _onGotCanvasRef(canvas: HTMLCanvasElement | null): Promise { + if (!canvas || this.engine) { + return; + } + + this._canvasRef = canvas; + + this.engine = new Engine(this._canvasRef, true, { + antialias: true, + adaptToDeviceRatio: true, + }); + + this.scene = new Scene(this.engine); + + // Scene settings + this.scene.clearColor = new Color4(0.1, 0.1, 0.1, 1.0); + this.scene.ambientColor = new Color3(1, 1, 1); + + // Camera + this.camera = new ArcRotateCamera("Camera", 0, 0.8, 4, Vector3.Zero(), this.scene); + this.camera.doNotSerialize = true; + this.camera.lowerRadiusLimit = 3; + this.camera.upperRadiusLimit = 10; + this.camera.wheelPrecision = 20; + this.camera.minZ = 0.001; + this.camera.attachControl(canvas, true); + this.camera.useFramingBehavior = true; + this.camera.wheelDeltaPercentage = 0.01; + this.camera.pinchDeltaPercentage = 0.01; + + // Directional light (sun) + const sunLight = new DirectionalLight("sun", new Vector3(-1, -1, -1), this.scene); + sunLight.intensity = 1.0; + sunLight.diffuse = new Color3(1, 1, 1); + sunLight.specular = new Color3(1, 1, 1); + + // Ground with grid material + const groundMaterial = new GridMaterial("groundMaterial", this.scene); + groundMaterial.majorUnitFrequency = 2; + groundMaterial.minorUnitVisibility = 0.1; + groundMaterial.gridRatio = 0.5; + groundMaterial.backFaceCulling = false; + groundMaterial.mainColor = new Color3(1, 1, 1); + groundMaterial.lineColor = new Color3(1.0, 1.0, 1.0); + groundMaterial.opacity = 0.5; + + const ground = MeshBuilder.CreateGround("ground", { width: 100, height: 100 }, this.scene); + ground.material = groundMaterial; + + // Render loop + this.engine.runRenderLoop(() => { + if (this.scene) { + this.scene.render(); + } + }); + + // Handle resize + window.addEventListener("resize", () => { + this.engine?.resize(); + }); + + // Notify parent that scene is ready + this.props.onSceneReady?.(this.scene); + + this.forceUpdate(); + } + + private _handlePlayStop(): void { + if (!this.props.selectedNodeId) { + return; + } + + const nodeData = this.props.editor?.graph?.getNodeData(this.props.selectedNodeId); + if (!nodeData) { + return; + } + + const effect = this._findEffectForNode(nodeData); + if (!effect) { + return; + } + + // Check if this is an effect root node + const isEffectRoot = this._isEffectRootNode(nodeData); + if (isEffectRoot) { + // For effect root, manage entire effect + if (effect.isStarted()) { + effect.stop(); + } else { + effect.start(); + } + } else if (effect.isNodeStarted(nodeData)) { + // For group or system, manage only this node + effect.stopNode(nodeData); + } else { + effect.startNode(nodeData); + } + + this._syncPlayingState(); + } + + private _handleRestart(): void { + if (!this.props.selectedNodeId) { + return; + } + + const nodeData = this.props.editor?.graph?.getNodeData(this.props.selectedNodeId); + if (!nodeData) { + return; + } + + const effect = this._findEffectForNode(nodeData); + if (!effect) { + return; + } + + // Check if this is an effect root node + const isEffectRoot = this._isEffectRootNode(nodeData); + if (isEffectRoot) { + // For effect root, restart entire effect + effect.reset(); + effect.start(); + } else { + // For group or system, restart only this node + effect.resetNode(nodeData); + effect.startNode(nodeData); + } + + this.setState({ playing: true }); + } + + public componentDidUpdate(prevProps: IEffectEditorPreviewProps): void { + // Sync playing state when selected node changes or when props change + if (prevProps.selectedNodeId !== this.props.selectedNodeId) { + this._syncPlayingState(); + } else { + // Update playing state based on actual node state + this._syncPlayingState(); + } + } +} diff --git a/editor/src/editor/windows/effect-editor/properties/behaviors.tsx b/editor/src/editor/windows/effect-editor/properties/behaviors.tsx new file mode 100644 index 000000000..bfe10c176 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/properties/behaviors.tsx @@ -0,0 +1,555 @@ +import { ReactNode } from "react"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; + +import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number"; +import { EditorInspectorVectorField } from "../../../layout/inspector/fields/vector"; +import { EditorInspectorColorField } from "../../../layout/inspector/fields/color"; +import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch"; +import { EditorInspectorStringField } from "../../../layout/inspector/fields/string"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; +import { EditorInspectorSectionField } from "../../../layout/inspector/fields/section"; + +import { Button } from "../../../../ui/shadcn/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../../../ui/shadcn/ui/dropdown-menu"; +import { HiOutlineTrash } from "react-icons/hi2"; +import { IoAddSharp } from "react-icons/io5"; + +import { type IEffectNode, EffectParticleSystem, EffectSolidParticleSystem } from "babylonjs-editor-tools"; +import { FunctionEditor, ColorFunctionEditor } from "../editors"; + +// Types +export type FunctionType = "ConstantValue" | "IntervalValue" | "PiecewiseBezier" | "Vector3Function"; +export type ColorFunctionType = "ConstantColor" | "ColorRange" | "Gradient" | "RandomColor" | "RandomColorBetweenGradient"; + +export interface IBehaviorProperty { + name: string; + type: "vector3" | "number" | "color" | "range" | "boolean" | "string" | "function" | "enum" | "colorFunction"; + label: string; + default?: any; + enumItems?: Array<{ text: string; value: any }>; + functionTypes?: FunctionType[]; + colorFunctionTypes?: ColorFunctionType[]; +} + +export interface IBehaviorDefinition { + type: string; + label: string; + properties: IBehaviorProperty[]; +} + +// Behavior Registry +export const BehaviorRegistry: { [key: string]: IBehaviorDefinition } = { + ApplyForce: { + type: "ApplyForce", + label: "Apply Force", + properties: [ + { name: "direction", type: "vector3", label: "Direction", default: { x: 0, y: 1, z: 0 } }, + { + name: "magnitude", + type: "function", + label: "Magnitude", + default: null, + functionTypes: ["ConstantValue", "IntervalValue"], + }, + ], + }, + Noise: { + type: "Noise", + label: "Noise", + properties: [ + { + name: "frequency", + type: "function", + label: "Frequency", + default: 1.0, + functionTypes: ["ConstantValue", "IntervalValue"], + }, + { + name: "power", + type: "function", + label: "Power", + default: 1.0, + functionTypes: ["ConstantValue", "IntervalValue"], + }, + { + name: "positionAmount", + type: "function", + label: "Position Amount", + default: 1.0, + functionTypes: ["ConstantValue", "IntervalValue"], + }, + { + name: "rotationAmount", + type: "function", + label: "Rotation Amount", + default: 0.0, + functionTypes: ["ConstantValue", "IntervalValue"], + }, + ], + }, + TurbulenceField: { + type: "TurbulenceField", + label: "Turbulence Field", + properties: [ + { name: "scale", type: "vector3", label: "Scale", default: { x: 1, y: 1, z: 1 } }, + { name: "octaves", type: "number", label: "Octaves", default: 1 }, + { name: "velocityMultiplier", type: "vector3", label: "Velocity Multiplier", default: { x: 1, y: 1, z: 1 } }, + { name: "timeScale", type: "vector3", label: "Time Scale", default: { x: 1, y: 1, z: 1 } }, + ], + }, + GravityForce: { + type: "GravityForce", + label: "Gravity Force", + properties: [ + { name: "center", type: "vector3", label: "Center", default: { x: 0, y: 0, z: 0 } }, + { name: "magnitude", type: "number", label: "Magnitude", default: 1.0 }, + ], + }, + ColorOverLife: { + type: "ColorOverLife", + label: "Color Over Life", + properties: [ + { + name: "color", + type: "colorFunction", + label: "Color", + default: null, + colorFunctionTypes: ["ConstantColor", "ColorRange", "Gradient", "RandomColorBetweenGradient"], + }, + ], + }, + RotationOverLife: { + type: "RotationOverLife", + label: "Rotation Over Life", + properties: [ + { + name: "angularVelocity", + type: "function", + label: "Angular Velocity", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + ], + }, + Rotation3DOverLife: { + type: "Rotation3DOverLife", + label: "Rotation 3D Over Life", + properties: [ + { + name: "angularVelocity", + type: "function", + label: "Angular Velocity", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + ], + }, + SizeOverLife: { + type: "SizeOverLife", + label: "Size Over Life", + properties: [ + { + name: "size", + type: "function", + label: "Size", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier", "Vector3Function"], + }, + ], + }, + ColorBySpeed: { + type: "ColorBySpeed", + label: "Color By Speed", + properties: [ + { + name: "color", + type: "colorFunction", + label: "Color", + default: null, + colorFunctionTypes: ["ConstantColor", "ColorRange", "Gradient", "RandomColorBetweenGradient"], + }, + { name: "speedRange", type: "range", label: "Speed Range", default: { min: 0, max: 10 } }, + ], + }, + RotationBySpeed: { + type: "RotationBySpeed", + label: "Rotation By Speed", + properties: [ + { + name: "angularVelocity", + type: "function", + label: "Angular Velocity", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + { name: "speedRange", type: "range", label: "Speed Range", default: { min: 0, max: 10 } }, + ], + }, + SizeBySpeed: { + type: "SizeBySpeed", + label: "Size By Speed", + properties: [ + { + name: "size", + type: "function", + label: "Size", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier", "Vector3Function"], + }, + { name: "speedRange", type: "range", label: "Speed Range", default: { min: 0, max: 10 } }, + ], + }, + SpeedOverLife: { + type: "SpeedOverLife", + label: "Speed Over Life", + properties: [ + { + name: "speed", + type: "function", + label: "Speed", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + ], + }, + FrameOverLife: { + type: "FrameOverLife", + label: "Frame Over Life", + properties: [ + { + name: "frame", + type: "function", + label: "Frame", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + ], + }, + ForceOverLife: { + type: "ForceOverLife", + label: "Force Over Life", + properties: [ + { + name: "x", + type: "function", + label: "X", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + { + name: "y", + type: "function", + label: "Y", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + { + name: "z", + type: "function", + label: "Z", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + ], + }, + OrbitOverLife: { + type: "OrbitOverLife", + label: "Orbit Over Life", + properties: [ + { + name: "orbitSpeed", + type: "function", + label: "Orbit Speed", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + { name: "axis", type: "vector3", label: "Axis", default: { x: 0, y: 1, z: 0 } }, + ], + }, + WidthOverLength: { + type: "WidthOverLength", + label: "Width Over Length", + properties: [ + { + name: "width", + type: "function", + label: "Width", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + ], + }, + ChangeEmitDirection: { + type: "ChangeEmitDirection", + label: "Change Emit Direction", + properties: [ + { + name: "angle", + type: "function", + label: "Angle", + default: 0.0, + functionTypes: ["ConstantValue", "IntervalValue"], + }, + ], + }, + EmitSubParticleSystem: { + type: "EmitSubParticleSystem", + label: "Emit Sub Particle System", + properties: [ + { name: "subParticleSystem", type: "string", label: "Sub Particle System", default: "" }, + { name: "useVelocityAsBasis", type: "boolean", label: "Use Velocity As Basis", default: false }, + { + name: "mode", + type: "enum", + label: "Mode", + default: 0, + enumItems: [ + { text: "Death", value: 0 }, + { text: "Birth", value: 1 }, + { text: "Frame", value: 2 }, + ], + }, + { name: "emitProbability", type: "number", label: "Emit Probability", default: 1.0 }, + ], + }, + LimitSpeedOverLife: { + type: "LimitSpeedOverLife", + label: "Limit Speed Over Life", + properties: [ + { + name: "speed", + type: "function", + label: "Speed", + default: null, + functionTypes: ["ConstantValue", "IntervalValue", "PiecewiseBezier"], + }, + { name: "dampen", type: "number", label: "Dampen", default: 0.0 }, + ], + }, +}; + +// Utility functions +export function getBehaviorDefinition(type: string): IBehaviorDefinition | undefined { + return BehaviorRegistry[type]; +} + +export function createDefaultBehaviorData(type: string): any { + const definition = BehaviorRegistry[type]; + if (!definition) { + return { type }; + } + + const data: any = { type }; + for (const prop of definition.properties) { + if (prop.type === "function") { + data[prop.name] = { + functionType: prop.functionTypes?.[0] || "ConstantValue", + data: {}, + }; + if (data[prop.name].functionType === "ConstantValue") { + data[prop.name].data.value = prop.default !== undefined ? prop.default : 1.0; + } else if (data[prop.name].functionType === "IntervalValue") { + data[prop.name].data.min = 0; + data[prop.name].data.max = 1; + } + } else if (prop.type === "colorFunction") { + data[prop.name] = { + colorFunctionType: prop.colorFunctionTypes?.[0] || "ConstantColor", + data: {}, + }; + } else if (prop.default !== undefined) { + if (prop.type === "vector3") { + data[prop.name] = { x: prop.default.x, y: prop.default.y, z: prop.default.z }; + } else if (prop.type === "range") { + data[prop.name] = { min: prop.default.min, max: prop.default.max }; + } else { + data[prop.name] = prop.default; + } + } + } + return data; +} + +// Helper function to render a single property +function renderProperty(prop: IBehaviorProperty, behavior: any, onChange: () => void): ReactNode { + switch (prop.type) { + case "vector3": + if (!behavior[prop.name]) { + const defaultVal = prop.default || { x: 0, y: 0, z: 0 }; + behavior[prop.name] = new Vector3(defaultVal.x, defaultVal.y, defaultVal.z); + } else if (!(behavior[prop.name] instanceof Vector3)) { + const obj = behavior[prop.name]; + behavior[prop.name] = new Vector3(obj.x || 0, obj.y || 0, obj.z || 0); + } + return ; + + case "number": + if (behavior[prop.name] === undefined) { + behavior[prop.name] = prop.default !== undefined ? prop.default : 0; + } + return ; + + case "color": + if (!behavior[prop.name]) { + behavior[prop.name] = prop.default ? new Color4(prop.default.r, prop.default.g, prop.default.b, prop.default.a) : new Color4(1, 1, 1, 1); + } + return ; + + case "range": + if (!behavior[prop.name]) { + behavior[prop.name] = prop.default ? { ...prop.default } : { min: 0, max: 1 }; + } + return ( + +
{prop.label}
+
+ + +
+
+ ); + + case "boolean": + if (behavior[prop.name] === undefined) { + behavior[prop.name] = prop.default !== undefined ? prop.default : false; + } + return ; + + case "string": + if (behavior[prop.name] === undefined) { + behavior[prop.name] = prop.default !== undefined ? prop.default : ""; + } + return ; + + case "enum": + if (behavior[prop.name] === undefined) { + behavior[prop.name] = prop.default !== undefined ? prop.default : (prop.enumItems?.[0]?.value ?? 0); + } + if (!prop.enumItems || prop.enumItems.length === 0) { + return null; + } + return ; + + case "colorFunction": + // All color functions are now stored uniformly in behavior[prop.name] + if (!behavior[prop.name]) { + behavior[prop.name] = { + colorFunctionType: prop.colorFunctionTypes?.[0] || "ConstantColor", + data: {}, + }; + } + return ; + + case "function": + if (!behavior[prop.name]) { + behavior[prop.name] = { + functionType: prop.functionTypes?.[0] || "ConstantValue", + data: {}, + }; + } + return ; + + default: + return null; + } +} + +// Component to render behavior properties +interface IBehaviorPropertiesProps { + behavior: any; + onChange: () => void; +} + +function BehaviorProperties(props: IBehaviorPropertiesProps): ReactNode { + const { behavior, onChange } = props; + const definition = getBehaviorDefinition(behavior.type); + + if (!definition) { + return null; + } + + return <>{definition.properties.map((prop) => renderProperty(prop, behavior, onChange))}; +} + +// Main component +export interface IEffectEditorBehaviorsPropertiesProps { + nodeData: IEffectNode; + onChange: () => void; +} + +export function EffectEditorBehaviorsProperties(props: IEffectEditorBehaviorsPropertiesProps): ReactNode { + const { nodeData, onChange } = props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + const behaviorConfigs: any[] = system instanceof EffectParticleSystem || system instanceof EffectSolidParticleSystem ? system.behaviorConfigs || [] : []; + + const handleAddBehavior = (behaviorType: string): void => { + const newBehavior = createDefaultBehaviorData(behaviorType); + newBehavior.id = `behavior-${Date.now()}-${Math.random()}`; + behaviorConfigs.push(newBehavior); + onChange(); + }; + + const handleRemoveBehavior = (index: number): void => { + behaviorConfigs.splice(index, 1); + onChange(); + }; + + const handleBehaviorChange = (): void => { + onChange(); + }; + + return ( + <> + {behaviorConfigs.length === 0 &&
No behaviors. Click "Add Behavior" to add one.
} + {behaviorConfigs.map((behavior, index) => { + const definition = getBehaviorDefinition(behavior.type); + const title = definition?.label || behavior.type || `Behavior ${index + 1}`; + + return ( + + {title} + +
+ } + > + + + ); + })} + + + + + + + {Object.values(BehaviorRegistry).map((definition) => ( + handleAddBehavior(definition.type)}> + {definition.label} + + ))} + + + + ); +} diff --git a/editor/src/editor/windows/effect-editor/properties/emission.tsx b/editor/src/editor/windows/effect-editor/properties/emission.tsx new file mode 100644 index 000000000..97c861ee1 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/properties/emission.tsx @@ -0,0 +1,504 @@ +import { ReactNode } from "react"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; + +import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number"; +import { EditorInspectorVectorField } from "../../../layout/inspector/fields/vector"; +import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; +import { EditorInspectorSectionField } from "../../../layout/inspector/fields/section"; + +import { + type IEffectNode, + type IEmissionBurst, + EffectSolidParticleSystem, + EffectParticleSystem, + SolidSphereParticleEmitter, + SolidConeParticleEmitter, + SolidBoxParticleEmitter, + SolidHemisphericParticleEmitter, + SolidCylinderParticleEmitter, + Value, +} from "babylonjs-editor-tools"; +import { EffectValueEditor } from "../editors/value"; + +export interface IEffectEditorEmissionPropertiesProps { + nodeData: IEffectNode; + onChange: () => void; +} + +/** + * Renders emitter shape properties for SolidParticleSystem + */ +function renderSolidParticleSystemEmitter(system: EffectSolidParticleSystem, onChange: () => void): ReactNode { + const emitter = system.particleEmitterType; + const emitterType = emitter ? emitter.constructor.name : "Point"; + + const emitterTypeMap: Record = { + SolidPointParticleEmitter: "point", + SolidSphereParticleEmitter: "sphere", + SolidConeParticleEmitter: "cone", + SolidBoxParticleEmitter: "box", + SolidHemisphericParticleEmitter: "hemisphere", + SolidCylinderParticleEmitter: "cylinder", + }; + + const currentType = emitterTypeMap[emitterType] || "point"; + const emitterTypes = [ + { text: "Point", value: "point" }, + { text: "Box", value: "box" }, + { text: "Sphere", value: "sphere" }, + { text: "Cone", value: "cone" }, + { text: "Hemisphere", value: "hemisphere" }, + { text: "Cylinder", value: "cylinder" }, + ]; + + // Helper to get current values from various emitter types + const getRadius = (): number => { + if (emitter instanceof SolidSphereParticleEmitter || emitter instanceof SolidConeParticleEmitter) { + return emitter.radius; + } + if (emitter instanceof SolidHemisphericParticleEmitter || emitter instanceof SolidCylinderParticleEmitter) { + return emitter.radius; + } + return 1; + }; + + const getRadiusRange = (): number => { + if (emitter instanceof SolidHemisphericParticleEmitter || emitter instanceof SolidCylinderParticleEmitter) { + return emitter.radiusRange; + } + return 1; + }; + + const getDirectionRandomizer = (): number => { + if (emitter instanceof SolidHemisphericParticleEmitter || emitter instanceof SolidCylinderParticleEmitter) { + return emitter.directionRandomizer; + } + return 0; + }; + + return ( + <> + ({ text: t.text, value: t.value }))} + onChange={(value) => { + const currentRadius = getRadius(); + const currentArc = emitter instanceof SolidSphereParticleEmitter || emitter instanceof SolidConeParticleEmitter ? emitter.arc : Math.PI * 2; + const currentThickness = emitter instanceof SolidSphereParticleEmitter || emitter instanceof SolidConeParticleEmitter ? emitter.thickness : 1; + const currentAngle = emitter instanceof SolidConeParticleEmitter ? emitter.angle : Math.PI / 6; + const currentHeight = emitter instanceof SolidCylinderParticleEmitter ? emitter.height : 1; + const currentRadiusRange = getRadiusRange(); + const currentDirRandomizer = getDirectionRandomizer(); + + switch (value) { + case "point": + system.createPointEmitter(); + break; + case "box": + system.createBoxEmitter(); + break; + case "sphere": + system.createSphereEmitter(currentRadius, currentArc, currentThickness); + break; + case "cone": + system.createConeEmitter(currentRadius, currentArc, currentThickness, currentAngle); + break; + case "hemisphere": + system.createHemisphericEmitter(currentRadius, currentRadiusRange, currentDirRandomizer); + break; + case "cylinder": + system.createCylinderEmitter(currentRadius, currentHeight, currentRadiusRange, currentDirRandomizer); + break; + } + onChange(); + }} + /> + + {emitter instanceof SolidSphereParticleEmitter && ( + <> + + + + + )} + + {emitter instanceof SolidConeParticleEmitter && ( + <> + + + + + + )} + + {emitter instanceof SolidBoxParticleEmitter && ( + <> + +
Direction
+ + +
+ +
Emit Box
+ + +
+ + )} + + {emitter instanceof SolidHemisphericParticleEmitter && ( + <> + + + + + )} + + {emitter instanceof SolidCylinderParticleEmitter && ( + <> + + + + + + )} + + ); +} + +/** + * Renders emitter shape properties for ParticleSystem + */ +function renderParticleSystemEmitter(system: EffectParticleSystem, onChange: () => void): ReactNode { + const emitter = system.particleEmitterType; + if (!emitter) { + return
No emitter found.
; + } + + const emitterType = emitter.getClassName(); + const emitterTypeMap: Record = { + PointParticleEmitter: "point", + BoxParticleEmitter: "box", + SphereParticleEmitter: "sphere", + SphereDirectedParticleEmitter: "sphere", + ConeParticleEmitter: "cone", + ConeDirectedParticleEmitter: "cone", + HemisphericParticleEmitter: "hemisphere", + CylinderParticleEmitter: "cylinder", + CylinderDirectedParticleEmitter: "cylinder", + }; + + const currentType = emitterTypeMap[emitterType] || "point"; + const emitterTypes = [ + { text: "Point", value: "point" }, + { text: "Box", value: "box" }, + { text: "Sphere", value: "sphere" }, + { text: "Cone", value: "cone" }, + { text: "Hemisphere", value: "hemisphere" }, + { text: "Cylinder", value: "cylinder" }, + ]; + + return ( + <> + ({ text: t.text, value: t.value }))} + onChange={(value) => { + const currentRadius = "radius" in emitter ? (emitter as any).radius : 1; + const currentAngle = "angle" in emitter ? (emitter as any).angle : Math.PI / 6; + const currentHeight = "height" in emitter ? (emitter as any).height : 1; + const currentDirection1 = "direction1" in emitter ? (emitter as any).direction1?.clone() : Vector3.Zero(); + const currentDirection2 = "direction2" in emitter ? (emitter as any).direction2?.clone() : Vector3.Zero(); + const currentMinEmitBox = "minEmitBox" in emitter ? (emitter as any).minEmitBox?.clone() : new Vector3(-0.5, -0.5, -0.5); + const currentMaxEmitBox = "maxEmitBox" in emitter ? (emitter as any).maxEmitBox?.clone() : new Vector3(0.5, 0.5, 0.5); + + switch (value) { + case "point": + system.createPointEmitter(currentDirection1, currentDirection2); + break; + case "box": + system.createBoxEmitter(currentDirection1, currentDirection2, currentMinEmitBox, currentMaxEmitBox); + break; + case "sphere": + system.createSphereEmitter(currentRadius); + break; + case "cone": + system.createConeEmitter(currentRadius, currentAngle); + break; + case "hemisphere": + system.createHemisphericEmitter(currentRadius); + break; + case "cylinder": + system.createCylinderEmitter(currentRadius, currentHeight); + break; + } + onChange(); + }} + /> + + {emitterType === "BoxParticleEmitter" && ( + <> + +
Direction
+ + +
+ +
Emit Box
+ + +
+ + )} + + {(emitterType === "ConeParticleEmitter" || emitterType === "ConeDirectedParticleEmitter") && ( + <> + + + + + + + {emitterType === "ConeDirectedParticleEmitter" && ( + +
Direction
+ + +
+ )} + + )} + + {(emitterType === "CylinderParticleEmitter" || emitterType === "CylinderDirectedParticleEmitter") && ( + <> + + + + + + {emitterType === "CylinderDirectedParticleEmitter" && ( + +
Direction
+ + +
+ )} + + )} + + {(emitterType === "SphereParticleEmitter" || emitterType === "SphereDirectedParticleEmitter") && ( + <> + + + + + {emitterType === "SphereDirectedParticleEmitter" && ( + +
Direction
+ + +
+ )} + + )} + + {emitterType === "PointParticleEmitter" && ( + +
Direction
+ + +
+ )} + + {emitterType === "HemisphericParticleEmitter" && ( + <> + + + + + )} + + ); +} + +/** + * Renders emitter shape properties + */ +function renderEmitterShape(nodeData: IEffectNode, onChange: () => void): ReactNode { + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + if (system instanceof EffectSolidParticleSystem) { + return renderSolidParticleSystemEmitter(system, onChange); + } + + if (system instanceof EffectParticleSystem) { + return renderParticleSystemEmitter(system, onChange); + } + + return null; +} + +/** + * Renders emission bursts + */ +function renderBursts(system: EffectParticleSystem | EffectSolidParticleSystem, onChange: () => void): ReactNode { + const bursts: (IEmissionBurst & { cycle?: number; interval?: number; probability?: number })[] = Array.isArray((system as any).emissionBursts) + ? (system as any).emissionBursts + : []; + + const addBurst = () => { + bursts.push({ + time: 0, + count: 1, + cycle: 1, + interval: 0, + probability: 1, + }); + (system as any).emissionBursts = bursts; + onChange(); + }; + + const removeBurst = (index: number) => { + bursts.splice(index, 1); + (system as any).emissionBursts = bursts; + onChange(); + }; + + return ( + +
+ {bursts.map((burst, idx) => ( +
+
+
Burst #{idx + 1}
+ +
+
+ { + burst.time = val as Value; + onChange(); + }} + /> + { + burst.count = val as Value; + onChange(); + }} + /> + + + +
+
+ ))} + +
+
+ ); +} + +/** + * Renders emission parameters (looping, duration, emit over time/distance, bursts) + */ +function renderEmissionParameters(nodeData: IEffectNode, onChange: () => void): ReactNode { + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + // Proxy for looping (targetStopDuration === 0 means looping) + const loopingProxy = { + get isLooping() { + return (system as any).targetStopDuration === 0; + }, + set isLooping(value: boolean) { + if (value) { + (system as any).targetStopDuration = 0; + } else if ((system as any).targetStopDuration === 0) { + (system as any).targetStopDuration = 5; // Default duration + } + }, + }; + + // Proxy for prewarm (preWarmCycles > 0 means prewarm enabled) + const prewarmProxy = { + get prewarm() { + return (system as any).preWarmCycles > 0; + }, + set prewarm(value: boolean) { + if (value && (system as any).preWarmCycles === 0) { + (system as any).preWarmCycles = Math.ceil((system as any).targetStopDuration || 5) * 60; + (system as any).preWarmStepOffset = 1 / 60; + } else if (!value) { + (system as any).preWarmCycles = 0; + } + }, + }; + + return ( + <> + + + + + {/* Emit Rate (native Babylon.js property) */} + + + {/* Emit Over Distance - only for SolidParticleSystem */} + {system instanceof EffectSolidParticleSystem && ( + + { + (system as EffectSolidParticleSystem).emissionOverDistance = val as Value; + onChange(); + }} + /> + + )} + + {renderBursts(system as any, onChange)} + + ); +} + +/** + * Combined emission properties component + * Includes both emitter shape and emission parameters + */ +export function EffectEditorEmissionProperties(props: IEffectEditorEmissionPropertiesProps): ReactNode { + const { nodeData, onChange } = props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + return ( + <> + {renderEmitterShape(nodeData, onChange)} + + {renderEmissionParameters(nodeData, onChange)} + + ); +} diff --git a/editor/src/editor/windows/effect-editor/properties/initialization.tsx b/editor/src/editor/windows/effect-editor/properties/initialization.tsx new file mode 100644 index 000000000..408b038cd --- /dev/null +++ b/editor/src/editor/windows/effect-editor/properties/initialization.tsx @@ -0,0 +1,221 @@ +import { ReactNode } from "react"; + +import { EditorInspectorBlockField } from "../../../layout/inspector/fields/block"; + +import { type IEffectNode, ValueUtils, Value, Color, Rotation } from "babylonjs-editor-tools"; +import { EffectValueEditor, type IVec3Function } from "../editors/value"; +import { EffectColorEditor } from "../editors/color"; + +export interface IEffectEditorParticleInitializationPropertiesProps { + nodeData: IEffectNode; + onChange?: () => void; +} + +export function EffectEditorParticleInitializationProperties(props: IEffectEditorParticleInitializationPropertiesProps): ReactNode { + const { nodeData } = props; + const onChange = props.onChange || (() => {}); + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + // Helper to get/set startLife - both systems use native minLifeTime/maxLifeTime + const getStartLife = (): Value | undefined => { + // Both systems have native minLifeTime/maxLifeTime properties + return { type: "IntervalValue", min: (system as any).minLifeTime, max: (system as any).maxLifeTime }; + }; + + const setStartLife = (value: Value): void => { + const interval = ValueUtils.parseIntervalValue(value); + (system as any).minLifeTime = interval.min; + (system as any).maxLifeTime = interval.max; + onChange(); + }; + + // Helper to get/set startSize - both systems use native minSize/maxSize + const getStartSize = (): Value | IVec3Function | undefined => { + // Both systems have native minSize/maxSize properties + return { type: "IntervalValue", min: (system as any).minSize, max: (system as any).maxSize }; + }; + + const setStartSize = (value: Value | IVec3Function): void => { + if (typeof value === "object" && "type" in value && value.type === "Vec3Function") { + // For Vec3Function, use average of x, y, z + const x = ValueUtils.parseConstantValue(value.x); + const y = ValueUtils.parseConstantValue(value.y); + const z = ValueUtils.parseConstantValue(value.z); + const avg = (x + y + z) / 3; + (system as any).minSize = avg; + (system as any).maxSize = avg; + } else { + const interval = ValueUtils.parseIntervalValue(value as Value); + (system as any).minSize = interval.min; + (system as any).maxSize = interval.max; + } + onChange(); + }; + + // Helper to get/set startSpeed - both systems use native minEmitPower/maxEmitPower + const getStartSpeed = (): Value | undefined => { + // Both systems have native minEmitPower/maxEmitPower properties + return { type: "IntervalValue", min: (system as any).minEmitPower, max: (system as any).maxEmitPower }; + }; + + const setStartSpeed = (value: Value): void => { + const interval = ValueUtils.parseIntervalValue(value); + (system as any).minEmitPower = interval.min; + (system as any).maxEmitPower = interval.max; + onChange(); + }; + + // Helper to get/set startColor - both systems use native color1 + const getStartColor = (): Color | undefined => { + // Both systems have native color1 property + if ((system as any).color1) { + return { type: "ConstantColor", value: [(system as any).color1.r, (system as any).color1.g, (system as any).color1.b, (system as any).color1.a] }; + } + return undefined; + }; + + const setStartColor = (value: Color): void => { + const color = ValueUtils.parseConstantColor(value); + (system as any).color1 = color; + onChange(); + }; + + // Helper to get/set startRotation - both systems use native minInitialRotation/maxInitialRotation + const getStartRotation = (): Rotation | undefined => { + // Both systems have native minInitialRotation/maxInitialRotation properties + return { + type: "Euler", + angleZ: { type: "IntervalValue", min: (system as any).minInitialRotation, max: (system as any).maxInitialRotation }, + order: "xyz", + }; + }; + + const setStartRotation = (value: Rotation): void => { + // Extract angleZ from rotation + if (typeof value === "object" && "type" in value && value.type === "Euler" && value.angleZ) { + const interval = ValueUtils.parseIntervalValue(value.angleZ); + (system as any).minInitialRotation = interval.min; + (system as any).maxInitialRotation = interval.max; + } else if ( + typeof value === "number" || + (typeof value === "object" && "type" in value && (value.type === "ConstantValue" || value.type === "IntervalValue" || value.type === "PiecewiseBezier")) + ) { + const interval = ValueUtils.parseIntervalValue(value as Value); + (system as any).minInitialRotation = interval.min; + (system as any).maxInitialRotation = interval.max; + } + onChange(); + }; + + // Helper to get/set angular speed - both systems use native minAngularSpeed/maxAngularSpeed + const getAngularSpeed = (): Value | undefined => { + return { type: "IntervalValue", min: (system as any).minAngularSpeed, max: (system as any).maxAngularSpeed }; + }; + + const setAngularSpeed = (value: Value): void => { + const interval = ValueUtils.parseIntervalValue(value); + (system as any).minAngularSpeed = interval.min; + (system as any).maxAngularSpeed = interval.max; + onChange(); + }; + + // Helper to get/set scale X - both systems use native minScaleX/maxScaleX + const getScaleX = (): Value | undefined => { + return { type: "IntervalValue", min: (system as any).minScaleX, max: (system as any).maxScaleX }; + }; + + const setScaleX = (value: Value): void => { + const interval = ValueUtils.parseIntervalValue(value); + (system as any).minScaleX = interval.min; + (system as any).maxScaleX = interval.max; + onChange(); + }; + + // Helper to get/set scale Y - both systems use native minScaleY/maxScaleY + const getScaleY = (): Value | undefined => { + return { type: "IntervalValue", min: (system as any).minScaleY, max: (system as any).maxScaleY }; + }; + + const setScaleY = (value: Value): void => { + const interval = ValueUtils.parseIntervalValue(value); + (system as any).minScaleY = interval.min; + (system as any).maxScaleY = interval.max; + onChange(); + }; + + return ( + <> + +
Start Life
+ +
+ + +
Start Size
+ +
+ + +
Scale X
+ +
+ + +
Scale Y
+ +
+ + +
Start Speed
+ +
+ + +
Start Color
+ +
+ + +
Start Rotation
+ {(() => { + // Both systems use native minInitialRotation/maxInitialRotation + const rotation = getStartRotation(); + const angleZ = + rotation && typeof rotation === "object" && "type" in rotation && rotation.type === "Euler" && rotation.angleZ + ? rotation.angleZ + : { type: "IntervalValue" as const, min: 0, max: 0 }; + return ( + { + setStartRotation({ + type: "Euler", + angleZ: newAngleZ as Value, + order: "xyz", + }); + }} + availableTypes={["ConstantValue", "IntervalValue", "PiecewiseBezier"]} + step={0.1} + /> + ); + })()} +
+ + +
Angular Speed
+ +
+ + ); +} diff --git a/editor/src/editor/windows/effect-editor/properties/object.tsx b/editor/src/editor/windows/effect-editor/properties/object.tsx new file mode 100644 index 000000000..be8cb3d78 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/properties/object.tsx @@ -0,0 +1,123 @@ +import { ReactNode } from "react"; +import { Quaternion } from "@babylonjs/core/Maths/math.vector"; + +import { EditorInspectorStringField } from "../../../layout/inspector/fields/string"; +import { EditorInspectorVectorField } from "../../../layout/inspector/fields/vector"; +import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch"; + +import { type IEffectNode, EffectSolidParticleSystem, EffectParticleSystem } from "babylonjs-editor-tools"; + +export interface IEffectEditorObjectPropertiesProps { + nodeData: IEffectNode; + onChange?: () => void; +} + +/** + * Creates a rotation inspector that handles rotationQuaternion properly + */ +function getRotationInspector(object: any, onChange?: () => void): ReactNode { + if (!object) { + return null; + } + + // Check if rotationQuaternion exists and is valid + if (object.rotationQuaternion && object.rotationQuaternion instanceof Quaternion) { + const valueRef = object.rotationQuaternion.toEulerAngles(); + + const proxy = new Proxy(valueRef, { + get(target, prop) { + return target[prop as keyof typeof target]; + }, + set(obj, prop, value) { + (obj as any)[prop] = value; + if (object.rotationQuaternion) { + object.rotationQuaternion.copyFrom((obj as any).toQuaternion()); + } + onChange?.(); + return true; + }, + }); + + const o = { proxy }; + + return ; + } + + // Fallback to rotation if it exists + if (object.rotation && typeof object.rotation === "object" && object.rotation.x !== undefined) { + return ; + } + + return null; +} + +export function EffectEditorObjectProperties(props: IEffectEditorObjectPropertiesProps): ReactNode { + const { nodeData, onChange } = props; + + // For groups, use transformNode directly + if (nodeData.type === "group" && nodeData.data) { + const group = nodeData.data; + + return ( + <> + + + {(group as any).position && } + {getRotationInspector(group, onChange)} + {(group as any).scaling && } + + ); + } + + // For particles, use system.emitter for VEffectParticleSystem or system.mesh for VEffectSolidParticleSystem + if (nodeData.type === "particle" && nodeData.data) { + const system = nodeData.data; + + // For VEffectSolidParticleSystem, use mesh (common mesh for all particles) + if (system instanceof EffectSolidParticleSystem) { + const mesh = system.mesh; + if (!mesh) { + return ( + <> + +
Mesh not available
+ + ); + } + + return ( + <> + + + {mesh.position && } + {getRotationInspector(mesh, onChange)} + {mesh.scaling && } + + ); + } + + // For VEffectParticleSystem, use emitter + if (system instanceof EffectParticleSystem) { + const emitter = (system as any).emitter; + if (!emitter) { + return ( + <> + +
Emitter not available
+ + ); + } + + return ( + <> + + {emitter.position && } + {getRotationInspector(emitter, onChange)} + {emitter.scaling && } + + ); + } + } + + return null; +} diff --git a/editor/src/editor/windows/effect-editor/properties/renderer.tsx b/editor/src/editor/windows/effect-editor/properties/renderer.tsx new file mode 100644 index 000000000..f416f3128 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/properties/renderer.tsx @@ -0,0 +1,352 @@ +import { Component, ReactNode } from "react"; + +import { EditorInspectorSectionField } from "../../../layout/inspector/fields/section"; +import { EditorInspectorNumberField } from "../../../layout/inspector/fields/number"; +import { EditorInspectorSwitchField } from "../../../layout/inspector/fields/switch"; +import { EditorInspectorListField } from "../../../layout/inspector/fields/list"; +import { EditorInspectorTextureField } from "../../../layout/inspector/fields/texture"; +import { EditorInspectorGeometryField } from "../../../layout/inspector/fields/geometry"; + +import { Material } from "@babylonjs/core/Materials/material"; +import { ParticleSystem } from "@babylonjs/core/Particles/particleSystem"; +import { Mesh } from "@babylonjs/core/Meshes/mesh"; + +import { EditorPBRMaterialInspector } from "../../../layout/inspector/material/pbr"; +import { EditorStandardMaterialInspector } from "../../../layout/inspector/material/standard"; +import { EditorNodeMaterialInspector } from "../../../layout/inspector/material/node"; +import { EditorMultiMaterialInspector } from "../../../layout/inspector/material/multi"; +import { EditorSkyMaterialInspector } from "../../../layout/inspector/material/sky"; +import { EditorGridMaterialInspector } from "../../../layout/inspector/material/grid"; +import { EditorNormalMaterialInspector } from "../../../layout/inspector/material/normal"; +import { EditorWaterMaterialInspector } from "../../../layout/inspector/material/water"; +import { EditorLavaMaterialInspector } from "../../../layout/inspector/material/lava"; +import { EditorTriPlanarMaterialInspector } from "../../../layout/inspector/material/tri-planar"; +import { EditorCellMaterialInspector } from "../../../layout/inspector/material/cell"; +import { EditorFireMaterialInspector } from "../../../layout/inspector/material/fire"; +import { EditorGradientMaterialInspector } from "../../../layout/inspector/material/gradient"; + +import { type IEffectNode, EffectSolidParticleSystem, EffectParticleSystem } from "babylonjs-editor-tools"; +import { IEffectEditor } from ".."; + +export interface IEffectEditorParticleRendererPropertiesProps { + nodeData: IEffectNode; + editor: IEffectEditor; + onChange: () => void; +} + +export interface IEffectEditorParticleRendererPropertiesState { + meshDragOver: boolean; +} + +export class EffectEditorParticleRendererProperties extends Component { + public constructor(props: IEffectEditorParticleRendererPropertiesProps) { + super(props); + this.state = { + meshDragOver: false, + }; + } + + public render(): ReactNode { + const { nodeData } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + const isEffectSolidParticleSystem = system instanceof EffectSolidParticleSystem; + const isEffectParticleSystem = system instanceof EffectParticleSystem; + const systemType = isEffectSolidParticleSystem ? "solid" : "base"; + + return ( + <> + {/* System Mode */} +
System Mode: {systemType === "solid" ? "Mesh (Solid)" : "Billboard (Base)"}
+ + {/* Billboard Mode - только для base */} + {isEffectParticleSystem && ( + <> + this.props.onChange()} + /> + this.props.onChange()} /> + + )} + + {/* World Space (isLocal inverted) */} + {(() => { + const proxy = { + get worldSpace() { + return !(system as any).isLocal; + }, + set worldSpace(value: boolean) { + (system as any).isLocal = !value; + }, + }; + return this.props.onChange()} />; + })()} + + {/* Material Inspector - только для solid с материалом */} + {isEffectSolidParticleSystem && this._getMaterialInspector()} + + {/* Blend Mode - только для base */} + {isEffectParticleSystem && ( + this.props.onChange()} + /> + )} + + {/* Texture */} + {this._getTextureField()} + + {/* Render Order */} + {this._getRenderOrderField()} + + {/* UV Tile */} + {this._getUVTileSection()} + + {/* Start Tile Index */} + {this._getStartTileIndexField()} + + {/* Soft Particles - only for ParticleSystem */} + {isEffectParticleSystem && this.props.onChange()} />} + + {/* Geometry - только для solid */} + {isEffectSolidParticleSystem && this._getGeometryField()} + + ); + } + + private _getMaterialInspector(): ReactNode { + const { nodeData } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + // Получаем material только для VEffectSolidParticleSystem + if (!(system instanceof EffectSolidParticleSystem) || !system.mesh || !system.mesh.material) { + return null; + } + + const material = system.mesh.material; + return this._getMaterialInspectorComponent(material, system.mesh); + } + + private _getMaterialInspectorComponent(material: Material, mesh?: any): ReactNode { + switch (material.getClassName()) { + case "PBRMaterial": + return ; + + case "StandardMaterial": + return ; + + case "NodeMaterial": + return ; + + case "MultiMaterial": + return ; + + case "SkyMaterial": + return ; + + case "GridMaterial": + return ; + + case "NormalMaterial": + return ; + + case "WaterMaterial": + return ; + + case "LavaMaterial": + return ; + + case "TriPlanarMaterial": + return ; + + case "CellMaterial": + return ; + + case "FireMaterial": + return ; + + case "GradientMaterial": + return ; + + default: + return null; + } + } + + private _getTextureField(): ReactNode { + const { nodeData, editor } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data || !editor.preview?.scene) { + return null; + } + + const system = nodeData.data; + + // For VEffectParticleSystem, use particleTexture + // For VEffectSolidParticleSystem, textures are handled by the material inspector + if (system instanceof EffectParticleSystem) { + return ( + this.props.onChange()} + /> + ); + } + + return null; + } + + private _getRenderOrderField(): ReactNode { + const { nodeData } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + // Для VEffectParticleSystem, renderOrder хранится в renderingGroupId + if (system instanceof EffectParticleSystem) { + return this.props.onChange()} />; + } + + // Для VEffectSolidParticleSystem, renderOrder хранится в system.renderOrder и применяется к mesh.renderingGroupId + if (system instanceof EffectSolidParticleSystem) { + // Создаем proxy объект для доступа к renderOrder через mesh.renderingGroupId + const proxy = { + get renderingGroupId() { + return system.mesh?.renderingGroupId ?? system.renderOrder ?? 0; + }, + set renderingGroupId(value: number) { + if (system.mesh) { + system.mesh.renderingGroupId = value; + } + system.renderOrder = value; + }, + }; + + return this.props.onChange()} />; + } + + return null; + } + + private _getUVTileSection(): ReactNode { + const { nodeData } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + // UV Tile only available for ParticleSystem (sprite sheets) + if (system instanceof EffectParticleSystem) { + return ( + + this.props.onChange()} /> + this.props.onChange()} /> + + ); + } + + // SolidParticleSystem uses mesh UVs, no tile settings + return null; + } + + private _getStartTileIndexField(): ReactNode { + const { nodeData } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data) { + return null; + } + + const system = nodeData.data; + + // Start Tile Index only available for ParticleSystem (sprite sheets) + if (system instanceof EffectParticleSystem) { + return this.props.onChange()} />; + } + + // SolidParticleSystem uses mesh UVs, no tile index + return null; + } + + private _getGeometryField(): ReactNode { + const { nodeData, editor } = this.props; + + if (nodeData.type !== "particle" || !nodeData.data || !(nodeData.data instanceof EffectSolidParticleSystem) || !editor.preview?.scene) { + return null; + } + + const system = nodeData.data as EffectSolidParticleSystem; + + // Store reference to source mesh in a custom property + // Since SPS disposes the source mesh after addShape, we need to store it separately + if (!(system as any)._sourceMesh) { + (system as any)._sourceMesh = null; + } + + const proxy = { + get particleMesh() { + // Return stored source mesh or null + return (system as any)._sourceMesh || null; + }, + set particleMesh(value: Mesh | null) { + if (!value) { + // Clear geometry + (system as any)._sourceMesh = null; + return; + } + + // Clone mesh to avoid disposing the original asset + const clonedMesh = value.clone(`${system.name}_particleMesh_temp`, null, false, false); + if (!clonedMesh) { + console.error("[Geometry Field] Failed to clone mesh"); + return; + } + + // Store reference to source mesh for UI display + (system as any)._sourceMesh = value; + + // Replace the particle mesh (this will rebuild the entire SPS) + system.replaceParticleMesh(clonedMesh); + + // Notify change + this.props.onChange(); + }, + }; + + return this.props.onChange()} />; + } +} diff --git a/editor/src/editor/windows/effect-editor/properties/tab.tsx b/editor/src/editor/windows/effect-editor/properties/tab.tsx new file mode 100644 index 000000000..f69eee40b --- /dev/null +++ b/editor/src/editor/windows/effect-editor/properties/tab.tsx @@ -0,0 +1,102 @@ +import { Component, ReactNode } from "react"; +import type { IEffectNode } from "babylonjs-editor-tools"; +import { IEffectEditor } from ".."; +import { EffectEditorObjectProperties } from "./object"; +import { EffectEditorParticleRendererProperties } from "./renderer"; +import { EffectEditorEmissionProperties } from "./emission"; +import { EffectEditorParticleInitializationProperties } from "./initialization"; +import { EffectEditorBehaviorsProperties } from "./behaviors"; + +export interface IEffectEditorPropertiesTabProps { + filePath: string | null; + selectedNodeId: string | number | null; + editor: IEffectEditor; + tabType: "object" | "emission" | "renderer" | "initialization" | "behaviors"; + onNameChanged?: () => void; + getNodeData: (nodeId: string | number) => IEffectNode | null; +} + +export class EffectEditorPropertiesTab extends Component { + public render(): ReactNode { + const { selectedNodeId, tabType, getNodeData, editor, onNameChanged } = this.props; + + if (!selectedNodeId) { + return ( +
+

{tabType === "object" ? "No node selected" : "No particle selected"}

+
+ ); + } + + const nodeData = getNodeData(selectedNodeId); + + if (!nodeData) { + return ( +
+

Node not found

+
+ ); + } + + // For groups, only show object properties + if (nodeData.type === "group" && tabType !== "object") { + return ( +
+

Select a particle system

+
+ ); + } + + // For particles, check if system exists + if (nodeData.type === "particle" && !nodeData.data && tabType !== "object") { + return ( +
+

Select a particle system

+
+ ); + } + + const commonProps = { + nodeData, + onChange: () => { + this.forceUpdate(); + onNameChanged?.(); + }, + }; + + switch (tabType) { + case "object": + return ( +
+ +
+ ); + case "emission": + return ( +
+ +
+ ); + case "renderer": + return ( +
+ +
+ ); + case "initialization": + return ( +
+ +
+ ); + case "behaviors": + return ( +
+ +
+ ); + default: + return null; + } + } +} diff --git a/editor/src/editor/windows/effect-editor/resources.tsx b/editor/src/editor/windows/effect-editor/resources.tsx new file mode 100644 index 000000000..5be69723e --- /dev/null +++ b/editor/src/editor/windows/effect-editor/resources.tsx @@ -0,0 +1,78 @@ +import { Component, ReactNode } from "react"; +import { Tree, TreeNodeInfo } from "@blueprintjs/core"; + +import { IoImageOutline, IoCubeOutline } from "react-icons/io5"; + +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "../../../ui/shadcn/ui/context-menu"; + +export interface IEffectEditorResourcesProps { + resources: any[]; +} + +export interface IEffectEditorResourcesState { + nodes: TreeNodeInfo[]; +} + +export class EffectEditorResources extends Component { + public constructor(props: IEffectEditorResourcesProps) { + super(props); + + this.state = { + nodes: this._convertToTreeNodeInfo(props.resources), + }; + } + + public componentDidUpdate(prevProps: IEffectEditorResourcesProps): void { + if (prevProps.resources !== this.props.resources) { + this.setState({ + nodes: this._convertToTreeNodeInfo(this.props.resources), + }); + } + } + + private _convertToTreeNodeInfo(resources: any[]): TreeNodeInfo[] { + return resources.map((resource) => { + const icon = resource.type === "texture" ? : ; + + const label = ( + + +
{resource.name}
+
+ + UUID: {resource.resourceData?.uuid || resource.id} + {resource.resourceData?.path && Path: {resource.resourceData.path}} + +
+ ); + + return { + id: resource.id, + label, + icon, + isExpanded: false, + childNodes: undefined, + isSelected: false, + hasCaret: false, + }; + }); + } + + public render(): ReactNode { + if (this.state.nodes.length === 0) { + return ( +
+

No resources

+
+ ); + } + + return ( +
+
+ +
+
+ ); + } +} diff --git a/editor/src/editor/windows/effect-editor/toolbar.tsx b/editor/src/editor/windows/effect-editor/toolbar.tsx new file mode 100644 index 000000000..b83cb31b9 --- /dev/null +++ b/editor/src/editor/windows/effect-editor/toolbar.tsx @@ -0,0 +1,160 @@ +import { Component, ReactNode } from "react"; + +import { + Menubar, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarSeparator, + MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +} from "../../../ui/shadcn/ui/menubar"; + +import { openSingleFileDialog, saveSingleFileDialog } from "../../../tools/dialog"; +import { ToolbarComponent } from "../../../ui/toolbar"; + +import IEffectEditor from "./index"; + +export interface IEffectEditorToolbarProps { + editor: IEffectEditor; +} + +export interface IEffectEditorToolbarState { + // No state needed - modal is managed by editor +} + +export class EffectEditorToolbar extends Component { + constructor(props: IEffectEditorToolbarProps) { + super(props); + this.state = {}; + } + public render(): ReactNode { + return ( + + + + + {/* File */} + + File + + this._handleOpen()}> + Open... CTRL+O + + + + + this._handleSave()}> + Save CTRL+S + + + this._handleSaveAs()}> + Save As... CTRL+SHIFT+S + + + + + {/* Import Submenu */} + + Import... + + this._handleImportBabylonEffect()}>Babylon Effect JSON + this._handleImportQuarks()}>Quarks JSON + + this._handleImportUnity()}>Unity Assets + + + + + + +
+
+ Effect Editor + {this.props.editor.state.filePath && ( +
(...{this.props.editor.state.filePath.substring(this.props.editor.state.filePath.length - 30)})
+ )} +
+
+
+ ); + } + + private _handleOpen(): void { + const file = openSingleFileDialog({ + title: "Open Effect File", + filters: [{ name: "Effect Files", extensions: ["Effect", "json"] }], + }); + + if (!file) { + return; + } + + this.props.editor.loadFile(file); + } + + private _handleSave(): void { + if (!this.props.editor.state.filePath) { + this._handleSaveAs(); + return; + } + + this.props.editor.save(); + } + + private _handleSaveAs(): void { + const file = saveSingleFileDialog({ + title: "Save Effect File", + filters: [{ name: "Effect Files", extensions: ["Effect", "json"] }], + defaultPath: this.props.editor.state.filePath || "untitled.Effect", + }); + + if (!file) { + return; + } + + this.props.editor.saveAs(file); + } + + /** + * Handle import Babylon Effect JSON + */ + private _handleImportBabylonEffect(): void { + const file = openSingleFileDialog({ + title: "Import Babylon Effect JSON", + filters: [{ name: "Effect Files", extensions: ["effect"] }], + }); + + if (!file) { + return; + } + + this.props.editor.importFile(file); + } + + /** + * Handle import Quarks JSON + */ + private _handleImportQuarks(): void { + const file = openSingleFileDialog({ + title: "Import Quarks JSON", + filters: [{ name: "Quarks Files", extensions: ["json"] }], + }); + + if (!file) { + return; + } + + this.props.editor.importQuarksFile(file); + } + + /** + * Handle import Unity assets (open modal) + */ + private _handleImportUnity(): void { + this.props.editor.openUnityImportModal(); + } +} diff --git a/editor/src/ui/gradient-picker.tsx b/editor/src/ui/gradient-picker.tsx new file mode 100644 index 000000000..25f02aeed --- /dev/null +++ b/editor/src/ui/gradient-picker.tsx @@ -0,0 +1,407 @@ +import { ReactNode, MouseEvent, useState, useRef, useEffect } from "react"; +import { Color3, Color4 } from "babylonjs"; +import { ColorPicker } from "./color-picker"; +import { Button } from "../ui/shadcn/ui/button"; +import { AiOutlineClose } from "react-icons/ai"; + +/** + * Universal gradient key type (not tied to Effect) + */ +export interface IGradientKey { + time?: number; + value: number | [number, number, number, number] | { r: number; g: number; b: number; a?: number }; + pos?: number; +} + +export interface IGradientPickerProps { + colorKeys: IGradientKey[]; + alphaKeys?: IGradientKey[]; + onChange: (colorKeys: IGradientKey[], alphaKeys?: IGradientKey[]) => void; + onFinish?: (colorKeys: IGradientKey[], alphaKeys?: IGradientKey[]) => void; + className?: string; +} + +/** + * Visual gradient picker component + * Allows users to visually edit gradient by clicking on gradient bar, dragging stops, and picking colors + */ +export function GradientPicker(props: IGradientPickerProps): ReactNode { + const { colorKeys, alphaKeys = [], onChange, onFinish, className } = props; + const [selectedKeyIndex, setSelectedKeyIndex] = useState(null); + const [selectedAlphaIndex, setSelectedAlphaIndex] = useState(null); + const gradientRef = useRef(null); + const alphaRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [dragKeyIndex, setDragKeyIndex] = useState(null); + const [isAlphaDragging, setIsAlphaDragging] = useState(false); + const [dragAlphaIndex, setDragAlphaIndex] = useState(null); + + // Sort keys by position + const sortedColorKeys = [...colorKeys].sort((a, b) => (a.pos || 0) - (b.pos || 0)); + const sortedAlphaKeys = [...alphaKeys].sort((a, b) => (a.pos || 0) - (b.pos || 0)); + + // Generate gradient CSS string + const generateGradient = (keys: IGradientKey[]): string => { + const sorted = [...keys].sort((a, b) => (a.pos || 0) - (b.pos || 0)); + const stops = sorted.map((key) => { + const pos = (key.pos || 0) * 100; + let color = "rgba(0, 0, 0, 1)"; + if (Array.isArray(key.value)) { + const [r, g, b, a = 1] = key.value; + color = `rgba(${r * 255}, ${g * 255}, ${b * 255}, ${a})`; + } else if (typeof key.value === "object" && "r" in key.value) { + const r = key.value.r * 255; + const g = key.value.g * 255; + const b = key.value.b * 255; + const a = ("a" in key.value && key.value.a !== undefined ? key.value.a : 1) * 255; + color = `rgba(${r}, ${g}, ${b}, ${a / 255})`; + } + return `${color} ${pos}%`; + }); + return `linear-gradient(to right, ${stops.join(", ")})`; + }; + + // Get color value from key + const getColorFromKey = (key: IGradientKey): Color4 => { + if (Array.isArray(key.value)) { + const [r, g, b, a = 1] = key.value; + return new Color4(r, g, b, a); + } else if (typeof key.value === "object" && "r" in key.value) { + return new Color4(key.value.r, key.value.g, key.value.b, "a" in key.value ? key.value.a || 1 : 1); + } + return new Color4(0, 0, 0, 1); + }; + + // Interpolate color at position + const interpolateColorAtPosition = (keys: IGradientKey[], pos: number): Color4 => { + if (keys.length === 0) { + return new Color4(1, 1, 1, 1); + } + if (keys.length === 1) { + return getColorFromKey(keys[0]); + } + + for (let i = 0; i < keys.length - 1; i++) { + const key1 = keys[i]; + const key2 = keys[i + 1]; + const pos1 = key1.pos || 0; + const pos2 = key2.pos || 0; + + if (pos >= pos1 && pos <= pos2) { + const t = (pos - pos1) / (pos2 - pos1); + const color1 = getColorFromKey(key1); + const color2 = getColorFromKey(key2); + return new Color4( + color1.r + (color2.r - color1.r) * t, + color1.g + (color2.g - color1.g) * t, + color1.b + (color2.b - color1.b) * t, + color1.a + (color2.a - color1.a) * t + ); + } + } + + // Outside range, return nearest + if (pos <= (keys[0].pos || 0)) { + return getColorFromKey(keys[0]); + } + return getColorFromKey(keys[keys.length - 1]); + }; + + // Handle click on gradient bar to add/select key + const handleGradientClick = (e: MouseEvent, isAlpha: boolean = false) => { + const rect = isAlpha ? alphaRef.current?.getBoundingClientRect() : gradientRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + const x = e.clientX - rect.left; + const pos = Math.max(0, Math.min(1, x / rect.width)); + + if (isAlpha) { + // Check if clicked near existing alpha key + const nearKeyIndex = sortedAlphaKeys.findIndex((key) => Math.abs((key.pos || 0) - pos) < 0.05); + if (nearKeyIndex >= 0) { + setSelectedAlphaIndex(nearKeyIndex); + return; + } + + // Add new alpha key + const newAlphaKeys = [...alphaKeys, { pos, value: 1 }]; + const sorted = newAlphaKeys.sort((a, b) => (a.pos || 0) - (b.pos || 0)); + const newIndex = sorted.findIndex((key) => key.pos === pos); + setSelectedAlphaIndex(newIndex); + onChange(colorKeys, sorted); + } else { + // Check if clicked near existing color key + const nearKeyIndex = sortedColorKeys.findIndex((key) => Math.abs((key.pos || 0) - pos) < 0.05); + if (nearKeyIndex >= 0) { + setSelectedKeyIndex(nearKeyIndex); + return; + } + + // Interpolate color at position + const color = interpolateColorAtPosition(sortedColorKeys, pos); + const newKey: IGradientKey = { pos, value: [color.r, color.g, color.b, color.a] as [number, number, number, number] }; + const newColorKeys: IGradientKey[] = [...colorKeys, newKey]; + const sorted = newColorKeys.sort((a, b) => (a.pos || 0) - (b.pos || 0)); + const newIndex = sorted.findIndex((key) => key.pos === pos); + setSelectedKeyIndex(newIndex); + onChange(sorted, alphaKeys); + } + }; + + // Handle mouse down on key stop + const handleKeyMouseDown = (e: MouseEvent, index: number, isAlpha: boolean) => { + e.stopPropagation(); + if (isAlpha) { + setIsAlphaDragging(true); + setDragAlphaIndex(index); + setSelectedAlphaIndex(index); + } else { + setIsDragging(true); + setDragKeyIndex(index); + setSelectedKeyIndex(index); + } + }; + + // Handle mouse move for dragging + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging && dragKeyIndex !== null && gradientRef.current) { + const rect = gradientRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pos = Math.max(0, Math.min(1, x / rect.width)); + + const newColorKeys = [...colorKeys]; + const key = sortedColorKeys[dragKeyIndex]; + const originalIndex = colorKeys.findIndex((k) => k === key); + if (originalIndex >= 0) { + newColorKeys[originalIndex] = { ...key, pos }; + onChange(newColorKeys, alphaKeys); + } + } + + if (isAlphaDragging && dragAlphaIndex !== null && alphaRef.current) { + const rect = alphaRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pos = Math.max(0, Math.min(1, x / rect.width)); + + const newAlphaKeys = [...alphaKeys]; + const key = sortedAlphaKeys[dragAlphaIndex]; + const originalIndex = alphaKeys.findIndex((k) => k === key); + if (originalIndex >= 0) { + newAlphaKeys[originalIndex] = { ...key, pos }; + onChange(colorKeys, newAlphaKeys); + } + } + }; + + const handleMouseUp = () => { + if (isDragging || isAlphaDragging) { + setIsDragging(false); + setIsAlphaDragging(false); + setDragKeyIndex(null); + setDragAlphaIndex(null); + if (onFinish) { + onFinish(colorKeys, alphaKeys); + } + } + }; + + if (isDragging || isAlphaDragging) { + window.addEventListener("mousemove", handleMouseMove as any); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove as any); + window.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, isAlphaDragging, dragKeyIndex, dragAlphaIndex, colorKeys, alphaKeys, onChange, onFinish, sortedColorKeys, sortedAlphaKeys]); + + // Handle color change for selected key + const handleColorChange = (color: Color3 | Color4) => { + if (selectedKeyIndex === null) { + return; + } + + const key = sortedColorKeys[selectedKeyIndex]; + const originalIndex = colorKeys.findIndex((k) => k === key); + if (originalIndex >= 0) { + const newColorKeys = [...colorKeys]; + newColorKeys[originalIndex] = { + ...key, + value: [color.r, color.g, color.b, color instanceof Color4 ? color.a : 1], + }; + onChange(newColorKeys, alphaKeys); + } + }; + + // Handle alpha change for selected alpha key + const handleAlphaChange = (value: number) => { + if (selectedAlphaIndex === null) { + return; + } + + const key = sortedAlphaKeys[selectedAlphaIndex]; + const originalIndex = alphaKeys.findIndex((k) => k === key); + if (originalIndex >= 0) { + const newAlphaKeys = [...alphaKeys]; + newAlphaKeys[originalIndex] = { ...key, value }; + onChange(colorKeys, newAlphaKeys); + } + }; + + // Handle delete key + const handleDeleteKey = (index: number, isAlpha: boolean) => { + if (isAlpha) { + if (alphaKeys.length <= 2) { + return; // Keep at least 2 keys + } + const newAlphaKeys = alphaKeys.filter((_, i) => i !== index); + setSelectedAlphaIndex(null); + onChange(colorKeys, newAlphaKeys); + } else { + if (colorKeys.length <= 2) { + return; // Keep at least 2 keys + } + const newColorKeys = colorKeys.filter((_, i) => i !== index); + setSelectedKeyIndex(null); + onChange(newColorKeys, alphaKeys); + } + }; + + return ( +
+ {/* Color Gradient Bar */} +
+
Color Gradient
+
handleGradientClick(e, false)} + > + {sortedColorKeys.map((key, index) => { + const pos = (key.pos || 0) * 100; + const color = getColorFromKey(key); + const isSelected = selectedKeyIndex === index; + return ( +
handleKeyMouseDown(e, index, false)} + > +
+
+ ); + })} +
+ + {/* Color Picker for Selected Key */} + {selectedKeyIndex !== null && ( +
+
+ handleColorChange(new Color4(color.r, color.g, color.b, color.a))} + onFinish={(color) => { + handleColorChange(new Color4(color.r, color.g, color.b, color.a)); + if (onFinish) { + onFinish(colorKeys, alphaKeys); + } + }} + /> +
+ +
+ )} +
+ + {/* Alpha Gradient Bar */} +
+
Alpha Gradient
+
handleGradientClick(e, true)} + > + {sortedAlphaKeys.map((key, index) => { + const pos = (key.pos || 0) * 100; + const alphaValue = typeof key.value === "number" ? key.value : Array.isArray(key.value) ? key.value[3] || 1 : 1; + const isSelected = selectedAlphaIndex === index; + return ( +
handleKeyMouseDown(e, index, true)} + > +
+
+ ); + })} +
+ + {/* Alpha Slider for Selected Key */} + {selectedAlphaIndex !== null && ( +
+
+ handleAlphaChange(parseFloat(e.target.value))} + className="w-full" + /> +
+ +
+ )} +
+
+ ); +} diff --git a/editor/src/ui/shadcn/ui/scroll-area.tsx b/editor/src/ui/shadcn/ui/scroll-area.tsx new file mode 100644 index 000000000..541bed078 --- /dev/null +++ b/editor/src/ui/shadcn/ui/scroll-area.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "../../utils"; + +const ScrollArea = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, children, ...props }, ref) => ( + + {children} + + + + ) +); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/tools/src/effect/behaviors/colorBySpeed.ts b/tools/src/effect/behaviors/colorBySpeed.ts new file mode 100644 index 000000000..9bbb495c0 --- /dev/null +++ b/tools/src/effect/behaviors/colorBySpeed.ts @@ -0,0 +1,68 @@ +import type { IColorBySpeedBehavior } from "../types"; +import type { Particle } from "@babylonjs/core/Particles/particle"; +import { interpolateColorKeys } from "./utils"; + +/** + * Apply ColorBySpeed behavior to ParticleSystem (per-particle) + * Uses unified IColorFunction structure: behavior.color = { colorFunctionType, data } + */ +export function applyColorBySpeedPS(behavior: IColorBySpeedBehavior, particle: Particle): void { + // New structure: behavior.color.data.colorKeys + if (!behavior.color || !behavior.color.data?.colorKeys || !particle.color || !particle.direction) { + return; + } + + const minSpeed = behavior.minSpeed !== undefined ? (typeof behavior.minSpeed === "number" ? behavior.minSpeed : 0) : 0; + const maxSpeed = behavior.maxSpeed !== undefined ? (typeof behavior.maxSpeed === "number" ? behavior.maxSpeed : 1) : 1; + const colorKeys = behavior.color.data.colorKeys; + + if (!colorKeys || colorKeys.length === 0) { + return; + } + + const vel = particle.direction; + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + const interpolatedColor = interpolateColorKeys(colorKeys, speedRatio); + + particle.color.r = interpolatedColor.r; + particle.color.g = interpolatedColor.g; + particle.color.b = interpolatedColor.b; + particle.color.a = interpolatedColor.a; +} + +/** + * Apply ColorBySpeed behavior to SolidParticleSystem (per-particle) + * Uses unified IColorFunction structure: behavior.color = { colorFunctionType, data } + */ +export function applyColorBySpeedSPS(behavior: IColorBySpeedBehavior, particle: any): void { + // New structure: behavior.color.data.colorKeys + if (!behavior.color || !behavior.color.data?.colorKeys || !particle.color) { + return; + } + + const minSpeed = behavior.minSpeed !== undefined ? (typeof behavior.minSpeed === "number" ? behavior.minSpeed : 0) : 0; + const maxSpeed = behavior.maxSpeed !== undefined ? (typeof behavior.maxSpeed === "number" ? behavior.maxSpeed : 1) : 1; + const colorKeys = behavior.color.data.colorKeys; + + if (!colorKeys || colorKeys.length === 0) { + return; + } + + const vel = particle.velocity; + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + const interpolatedColor = interpolateColorKeys(colorKeys, speedRatio); + const startColor = particle.props?.startColor; + + if (startColor) { + particle.color.r = interpolatedColor.r * startColor.r; + particle.color.g = interpolatedColor.g * startColor.g; + particle.color.b = interpolatedColor.b * startColor.b; + particle.color.a = startColor.a; + } else { + particle.color.r = interpolatedColor.r; + particle.color.g = interpolatedColor.g; + particle.color.b = interpolatedColor.b; + } +} diff --git a/tools/src/effect/behaviors/colorOverLife.ts b/tools/src/effect/behaviors/colorOverLife.ts new file mode 100644 index 000000000..30ee7e11f --- /dev/null +++ b/tools/src/effect/behaviors/colorOverLife.ts @@ -0,0 +1,298 @@ +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import type { IColorOverLifeBehavior } from "../types"; +import { extractColorFromValue, extractAlphaFromValue } from "./utils"; +import type { EffectSolidParticleSystem, EffectParticleSystem } from "../systems"; +/** + * Apply ColorOverLife behavior to ParticleSystem + * Uses unified IColorFunction structure: behavior.color = { colorFunctionType, data } + */ +export function applyColorOverLifePS(particleSystem: EffectParticleSystem, behavior: IColorOverLifeBehavior | any): void { + // New unified structure: behavior.color is IColorFunction + const colorFunction = behavior.color; + if (!colorFunction) { + return; + } + + const colorFunctionType = colorFunction.colorFunctionType; + const data = colorFunction.data; + + // Handle ConstantColor + if (colorFunctionType === "ConstantColor" && data?.color) { + const color = data.color; + particleSystem.color1 = new Color4(color.r, color.g, color.b, color.a); + particleSystem.color2 = new Color4(color.r, color.g, color.b, color.a); + return; + } + + // Handle RandomColorBetweenGradient - apply first gradient (TODO: implement proper random selection per particle) + if (colorFunctionType === "RandomColorBetweenGradient" && data?.gradient1) { + const colorKeys = data.gradient1.colorKeys || []; + const alphaKeys = data.gradient1.alphaKeys || []; + + // Apply first gradient + for (const key of colorKeys) { + if (key.value !== undefined && key.pos !== undefined) { + let color: { r: number; g: number; b: number }; + let alpha: number; + + if (Array.isArray(key.value)) { + color = { r: key.value[0], g: key.value[1], b: key.value[2] }; + alpha = key.value[3] !== undefined ? key.value[3] : 1; + } else { + color = extractColorFromValue(key.value); + alpha = extractAlphaFromValue(key.value); + } + + particleSystem.addColorGradient(key.pos, new Color4(color.r, color.g, color.b, alpha)); + } + } + + for (const key of alphaKeys) { + if (key.value !== undefined && key.pos !== undefined) { + const alpha = typeof key.value === "number" ? key.value : extractAlphaFromValue(key.value); + const existingGradients = particleSystem.getColorGradients(); + const existingGradient = existingGradients?.find((g) => Math.abs(g.gradient - key.pos) < 0.001); + if (existingGradient) { + existingGradient.color1.a = alpha; + if (existingGradient.color2) { + existingGradient.color2.a = alpha; + } + } else { + particleSystem.addColorGradient(key.pos, new Color4(1, 1, 1, alpha)); + } + } + } + return; + } + + // Handle Gradient + if (colorFunctionType === "Gradient" && data) { + const colorKeys = data.colorKeys || []; + const alphaKeys = data.alphaKeys || []; + + // Apply color keys + for (const key of colorKeys) { + if (key.value !== undefined && key.pos !== undefined) { + let color: { r: number; g: number; b: number }; + let alpha: number; + + if (Array.isArray(key.value)) { + // UI format: [r, g, b, a] + color = { r: key.value[0], g: key.value[1], b: key.value[2] }; + alpha = key.value[3] !== undefined ? key.value[3] : 1; + } else { + // Quarks format: extract from value + color = extractColorFromValue(key.value); + alpha = extractAlphaFromValue(key.value); + } + + particleSystem.addColorGradient(key.pos, new Color4(color.r, color.g, color.b, alpha)); + } + } + + // Apply alpha keys (merge with existing color gradients) + for (const key of alphaKeys) { + if (key.value !== undefined && key.pos !== undefined) { + const alpha = typeof key.value === "number" ? key.value : extractAlphaFromValue(key.value); + const existingGradients = particleSystem.getColorGradients(); + const existingGradient = existingGradients?.find((g) => Math.abs(g.gradient - key.pos) < 0.001); + if (existingGradient) { + existingGradient.color1.a = alpha; + if (existingGradient.color2) { + existingGradient.color2.a = alpha; + } + } else { + particleSystem.addColorGradient(key.pos, new Color4(1, 1, 1, alpha)); + } + } + } + return; + } +} + +/** + * Apply ColorOverLife behavior to SolidParticleSystem + * Uses unified IColorFunction structure: behavior.color = { colorFunctionType, data } + */ +export function applyColorOverLifeSPS(system: EffectSolidParticleSystem, behavior: IColorOverLifeBehavior | any): void { + // New unified structure: behavior.color is IColorFunction + const colorFunction = behavior.color; + if (!colorFunction) { + return; + } + + const colorFunctionType = colorFunction.colorFunctionType; + const data = colorFunction.data; + let colorKeys: any[] = []; + let alphaKeys: any[] = []; + + // Handle ConstantColor + if (colorFunctionType === "ConstantColor" && data?.color) { + const color = data.color; + system.color1 = new Color4(color.r, color.g, color.b, color.a); + system.color2 = new Color4(color.r, color.g, color.b, color.a); + return; + } + + // Handle RandomColorBetweenGradient - apply first gradient (TODO: implement proper random selection per particle) + if (colorFunctionType === "RandomColorBetweenGradient" && data?.gradient1) { + colorKeys = data.gradient1.colorKeys || []; + alphaKeys = data.gradient1.alphaKeys || []; + } else if (colorFunctionType === "Gradient" && data) { + colorKeys = data.colorKeys || []; + alphaKeys = data.alphaKeys || []; + } else { + return; + } + + // Collect all unique positions from both color and alpha keys + const allPositions = new Set(); + for (const key of colorKeys) { + if (key.pos !== undefined) { + allPositions.add(key.pos); + } + } + for (const key of alphaKeys) { + const pos = key.pos ?? key.time ?? 0; + allPositions.add(pos); + } + + if (allPositions.size === 0) { + return; + } + + // Sort positions and create gradients at each position + const sortedPositions = Array.from(allPositions).sort((a, b) => a - b); + for (const pos of sortedPositions) { + // Get color at this position + let color = { r: 1, g: 1, b: 1 }; + if (colorKeys.length > 0) { + const exactColorKey = colorKeys.find((k) => k.pos !== undefined && Math.abs(k.pos - pos) < 0.001); + if (exactColorKey && exactColorKey.value !== undefined) { + if (Array.isArray(exactColorKey.value)) { + color = { r: exactColorKey.value[0], g: exactColorKey.value[1], b: exactColorKey.value[2] }; + } else { + color = extractColorFromValue(exactColorKey.value); + } + } else { + // Interpolate color from surrounding keys + color = interpolateColorFromKeys(colorKeys, pos); + } + } + + // Get alpha at this position + let alpha = 1; + if (alphaKeys.length > 0) { + const exactAlphaKey = alphaKeys.find((k) => { + const kPos = k.pos ?? k.time ?? 0; + return Math.abs(kPos - pos) < 0.001; + }); + if (exactAlphaKey && exactAlphaKey.value !== undefined) { + if (typeof exactAlphaKey.value === "number") { + alpha = exactAlphaKey.value; + } else { + alpha = extractAlphaFromValue(exactAlphaKey.value); + } + } else { + // Interpolate alpha from surrounding keys + alpha = interpolateAlphaFromKeys(alphaKeys, pos); + } + } else if (colorKeys.length > 0) { + // If no alpha keys, try to get alpha from color key + const exactColorKey = colorKeys.find((k) => k.pos !== undefined && Math.abs(k.pos - pos) < 0.001); + if (exactColorKey && exactColorKey.value !== undefined) { + if (Array.isArray(exactColorKey.value)) { + alpha = exactColorKey.value[3] !== undefined ? exactColorKey.value[3] : 1; + } else { + alpha = extractAlphaFromValue(exactColorKey.value); + } + } + } + + system.addColorGradient(pos, new Color4(color.r, color.g, color.b, alpha)); + } +} + +/** + * Interpolate color from gradient keys at a given position + */ +function interpolateColorFromKeys(keys: any[], pos: number): { r: number; g: number; b: number } { + if (keys.length === 0) { + return { r: 1, g: 1, b: 1 }; + } + if (keys.length === 1) { + const value = keys[0].value; + return Array.isArray(value) ? { r: value[0], g: value[1], b: value[2] } : extractColorFromValue(value); + } + + // Find surrounding keys + let before = keys[0]; + let after = keys[keys.length - 1]; + for (let i = 0; i < keys.length - 1; i++) { + const k1 = keys[i]; + const k2 = keys[i + 1]; + if (k1.pos !== undefined && k2.pos !== undefined && k1.pos <= pos && k2.pos >= pos) { + before = k1; + after = k2; + break; + } + } + + if (before === after) { + const value = before.value; + return Array.isArray(value) ? { r: value[0], g: value[1], b: value[2] } : extractColorFromValue(value); + } + + // Interpolate + const t = (pos - (before.pos ?? 0)) / ((after.pos ?? 1) - (before.pos ?? 0)); + const c1 = Array.isArray(before.value) ? { r: before.value[0], g: before.value[1], b: before.value[2] } : extractColorFromValue(before.value); + const c2 = Array.isArray(after.value) ? { r: after.value[0], g: after.value[1], b: after.value[2] } : extractColorFromValue(after.value); + + return { + r: c1.r + (c2.r - c1.r) * t, + g: c1.g + (c2.g - c1.g) * t, + b: c1.b + (c2.b - c1.b) * t, + }; +} + +/** + * Interpolate alpha from gradient keys at a given position + */ +function interpolateAlphaFromKeys(keys: any[], pos: number): number { + if (keys.length === 0) { + return 1; + } + if (keys.length === 1) { + const value = keys[0].value; + return typeof value === "number" ? value : extractAlphaFromValue(value); + } + + // Find surrounding keys + let before = keys[0]; + let after = keys[keys.length - 1]; + for (let i = 0; i < keys.length - 1; i++) { + const k1 = keys[i]; + const k2 = keys[i + 1]; + const k1Pos = k1.pos ?? k1.time ?? 0; + const k2Pos = k2.pos ?? k2.time ?? 1; + if (k1Pos <= pos && k2Pos >= pos) { + before = k1; + after = k2; + break; + } + } + + if (before === after) { + const value = before.value; + return typeof value === "number" ? value : extractAlphaFromValue(value); + } + + // Interpolate + const beforePos = before.pos ?? before.time ?? 0; + const afterPos = after.pos ?? after.time ?? 1; + const t = (pos - beforePos) / (afterPos - beforePos); + const a1 = typeof before.value === "number" ? before.value : extractAlphaFromValue(before.value); + const a2 = typeof after.value === "number" ? after.value : extractAlphaFromValue(after.value); + + return a1 + (a2 - a1) * t; +} diff --git a/tools/src/effect/behaviors/forceOverLife.ts b/tools/src/effect/behaviors/forceOverLife.ts new file mode 100644 index 000000000..fd4a8f842 --- /dev/null +++ b/tools/src/effect/behaviors/forceOverLife.ts @@ -0,0 +1,34 @@ +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import type { IForceOverLifeBehavior, IGravityForceBehavior } from "../types"; +import { ValueUtils } from "../utils"; +import type { EffectParticleSystem } from "../systems"; +/** + * Apply ForceOverLife behavior to ParticleSystem + */ +export function applyForceOverLifePS(particleSystem: EffectParticleSystem, behavior: IForceOverLifeBehavior): void { + if (behavior.force) { + const forceX = behavior.force.x !== undefined ? ValueUtils.parseConstantValue(behavior.force.x) : 0; + const forceY = behavior.force.y !== undefined ? ValueUtils.parseConstantValue(behavior.force.y) : 0; + const forceZ = behavior.force.z !== undefined ? ValueUtils.parseConstantValue(behavior.force.z) : 0; + if (Math.abs(forceY) > 0.01 || Math.abs(forceX) > 0.01 || Math.abs(forceZ) > 0.01) { + particleSystem.gravity = new Vector3(forceX, forceY, forceZ); + } + } else if (behavior.x !== undefined || behavior.y !== undefined || behavior.z !== undefined) { + const forceX = behavior.x !== undefined ? ValueUtils.parseConstantValue(behavior.x) : 0; + const forceY = behavior.y !== undefined ? ValueUtils.parseConstantValue(behavior.y) : 0; + const forceZ = behavior.z !== undefined ? ValueUtils.parseConstantValue(behavior.z) : 0; + if (Math.abs(forceY) > 0.01 || Math.abs(forceX) > 0.01 || Math.abs(forceZ) > 0.01) { + particleSystem.gravity = new Vector3(forceX, forceY, forceZ); + } + } +} + +/** + * Apply GravityForce behavior to ParticleSystem + */ +export function applyGravityForcePS(particleSystem: EffectParticleSystem, behavior: IGravityForceBehavior): void { + if (behavior.gravity !== undefined) { + const gravity = ValueUtils.parseConstantValue(behavior.gravity); + particleSystem.gravity = new Vector3(0, -gravity, 0); + } +} diff --git a/tools/src/effect/behaviors/frameOverLife.ts b/tools/src/effect/behaviors/frameOverLife.ts new file mode 100644 index 000000000..3d92c0a4c --- /dev/null +++ b/tools/src/effect/behaviors/frameOverLife.ts @@ -0,0 +1,34 @@ +import type { IFrameOverLifeBehavior } from "../types"; +import { ValueUtils } from "../utils"; +import type { EffectParticleSystem } from "../systems"; +/** + * Apply FrameOverLife behavior to ParticleSystem + */ +export function applyFrameOverLifePS(particleSystem: EffectParticleSystem, behavior: IFrameOverLifeBehavior): void { + if (!behavior.frame) { + return; + } + + particleSystem.isAnimationSheetEnabled = true; + if (typeof behavior.frame === "object" && behavior.frame !== null && "keys" in behavior.frame && behavior.frame.keys && Array.isArray(behavior.frame.keys)) { + const frames = behavior.frame.keys.map((k) => { + const val = k.value; + const pos = k.pos ?? k.time ?? 0; + if (typeof val === "number") { + return val; + } + if (Array.isArray(val)) { + return val[0] || 0; + } + return pos; + }); + if (frames.length > 0) { + particleSystem.startSpriteCellID = Math.floor(frames[0]); + particleSystem.endSpriteCellID = Math.floor(frames[frames.length - 1] || frames[0]); + } + } else if (typeof behavior.frame === "number" || (typeof behavior.frame === "object" && behavior.frame !== null && "type" in behavior.frame)) { + const frameValue = ValueUtils.parseConstantValue(behavior.frame); + particleSystem.startSpriteCellID = Math.floor(frameValue); + particleSystem.endSpriteCellID = Math.floor(frameValue); + } +} diff --git a/tools/src/effect/behaviors/index.ts b/tools/src/effect/behaviors/index.ts new file mode 100644 index 000000000..849a07acf --- /dev/null +++ b/tools/src/effect/behaviors/index.ts @@ -0,0 +1,18 @@ +/** + * Behavior modules for VFX particle systems + * + * Each behavior module exports functions for both ParticleSystem (PS) and SolidParticleSystem (SPS) + */ + +export * from "./colorOverLife"; +export * from "./sizeOverLife"; +export * from "./rotationOverLife"; +export * from "./forceOverLife"; +export * from "./speedOverLife"; +export * from "./colorBySpeed"; +export * from "./sizeBySpeed"; +export * from "./rotationBySpeed"; +export * from "./orbitOverLife"; +export * from "./frameOverLife"; +export * from "./limitSpeedOverLife"; +export * from "./utils"; diff --git a/tools/src/effect/behaviors/limitSpeedOverLife.ts b/tools/src/effect/behaviors/limitSpeedOverLife.ts new file mode 100644 index 000000000..6266a0ba5 --- /dev/null +++ b/tools/src/effect/behaviors/limitSpeedOverLife.ts @@ -0,0 +1,66 @@ +import type { ILimitSpeedOverLifeBehavior } from "../types"; +import { extractNumberFromValue } from "./utils"; +import { ValueUtils } from "../utils"; +import type { EffectSolidParticleSystem, EffectParticleSystem } from "../systems"; +/** + * Apply LimitSpeedOverLife behavior to ParticleSystem + */ +export function applyLimitSpeedOverLifePS(particleSystem: EffectParticleSystem, behavior: ILimitSpeedOverLifeBehavior): void { + if (behavior.dampen !== undefined) { + const dampen = ValueUtils.parseConstantValue(behavior.dampen); + particleSystem.limitVelocityDamping = dampen; + } + + if (behavior.maxSpeed !== undefined) { + const speedLimit = ValueUtils.parseConstantValue(behavior.maxSpeed); + particleSystem.addLimitVelocityGradient(0, speedLimit); + particleSystem.addLimitVelocityGradient(1, speedLimit); + } else if (behavior.speed !== undefined) { + if (typeof behavior.speed === "object" && behavior.speed !== null && "keys" in behavior.speed && behavior.speed.keys && Array.isArray(behavior.speed.keys)) { + for (const key of behavior.speed.keys) { + const pos = key.pos ?? key.time ?? 0; + const val = key.value; + if (val !== undefined && pos !== undefined) { + const numVal = extractNumberFromValue(val); + particleSystem.addLimitVelocityGradient(pos, numVal); + } + } + } else if (typeof behavior.speed === "number" || (typeof behavior.speed === "object" && behavior.speed !== null && "type" in behavior.speed)) { + const speedLimit = ValueUtils.parseConstantValue(behavior.speed); + particleSystem.addLimitVelocityGradient(0, speedLimit); + particleSystem.addLimitVelocityGradient(1, speedLimit); + } + } +} + +/** + * Apply LimitSpeedOverLife behavior to SolidParticleSystem + * Adds limit velocity gradients to the system (similar to ParticleSystem native gradients) + */ +export function applyLimitSpeedOverLifeSPS(system: EffectSolidParticleSystem, behavior: ILimitSpeedOverLifeBehavior): void { + if (behavior.dampen !== undefined) { + const dampen = ValueUtils.parseConstantValue(behavior.dampen); + system.limitVelocityDamping = dampen; + } + + if (behavior.maxSpeed !== undefined) { + const speedLimit = ValueUtils.parseConstantValue(behavior.maxSpeed); + system.addLimitVelocityGradient(0, speedLimit); + system.addLimitVelocityGradient(1, speedLimit); + } else if (behavior.speed !== undefined) { + if (typeof behavior.speed === "object" && behavior.speed !== null && "keys" in behavior.speed && behavior.speed.keys && Array.isArray(behavior.speed.keys)) { + for (const key of behavior.speed.keys) { + const pos = key.pos ?? key.time ?? 0; + const val = key.value; + if (val !== undefined && pos !== undefined) { + const numVal = extractNumberFromValue(val); + system.addLimitVelocityGradient(pos, numVal); + } + } + } else if (typeof behavior.speed === "number" || (typeof behavior.speed === "object" && behavior.speed !== null && "type" in behavior.speed)) { + const speedLimit = ValueUtils.parseConstantValue(behavior.speed); + system.addLimitVelocityGradient(0, speedLimit); + system.addLimitVelocityGradient(1, speedLimit); + } + } +} diff --git a/tools/src/effect/behaviors/orbitOverLife.ts b/tools/src/effect/behaviors/orbitOverLife.ts new file mode 100644 index 000000000..f10ec4c9c --- /dev/null +++ b/tools/src/effect/behaviors/orbitOverLife.ts @@ -0,0 +1,107 @@ +import { Particle } from "@babylonjs/core/Particles/particle"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import type { IOrbitOverLifeBehavior, Value } from "../types"; +import { extractNumberFromValue, interpolateGradientKeys } from "./utils"; +import { ValueUtils } from "../utils"; + +/** + * Apply OrbitOverLife behavior to Particle + * Gets lifeRatio from particle (age / lifeTime) + */ +export function applyOrbitOverLifePS(particle: Particle, behavior: IOrbitOverLifeBehavior): void { + if (!behavior.radius || particle.lifeTime <= 0) { + return; + } + + // Get lifeRatio from particle + const lifeRatio = particle.age / particle.lifeTime; + + // Parse radius (can be Value with keys or constant/interval) + let radius = 1; + const radiusValue = behavior.radius; + + // Check if radius is an object with keys (gradient) + if ( + radiusValue !== undefined && + radiusValue !== null && + typeof radiusValue === "object" && + "keys" in radiusValue && + Array.isArray(radiusValue.keys) && + radiusValue.keys.length > 0 + ) { + radius = interpolateGradientKeys(radiusValue.keys, lifeRatio, extractNumberFromValue); + } else if (radiusValue !== undefined && radiusValue !== null) { + // Parse as Value (number, ConstantValue, or IntervalValue) + const parsedRadius = ValueUtils.parseIntervalValue(radiusValue as Value); + radius = parsedRadius.min + (parsedRadius.max - parsedRadius.min) * lifeRatio; + } + + const speed = behavior.speed !== undefined ? ValueUtils.parseConstantValue(behavior.speed) : 1; + const angle = lifeRatio * speed * Math.PI * 2; + + // Calculate orbit offset relative to center + const centerX = behavior.center?.x ?? 0; + const centerY = behavior.center?.y ?? 0; + const centerZ = behavior.center?.z ?? 0; + + const orbitX = Math.cos(angle) * radius; + const orbitY = Math.sin(angle) * radius; + const orbitZ = 0; // 2D orbit + + // Apply orbit offset to particle position + if (particle.position) { + particle.position.x = centerX + orbitX; + particle.position.y = centerY + orbitY; + particle.position.z = centerZ + orbitZ; + } +} + +/** + * Apply OrbitOverLife behavior to SolidParticle + * Gets lifeRatio from particle (age / lifeTime) + */ +export function applyOrbitOverLifeSPS(particle: SolidParticle, behavior: IOrbitOverLifeBehavior): void { + if (!behavior.radius || particle.lifeTime <= 0) { + return; + } + + // Get lifeRatio from particle + const lifeRatio = particle.age / particle.lifeTime; + + // Parse radius (can be Value with keys or constant/interval) + let radius = 1; + const radiusValue = behavior.radius; + + // Check if radius is an object with keys (gradient) + if ( + radiusValue !== undefined && + radiusValue !== null && + typeof radiusValue === "object" && + "keys" in radiusValue && + Array.isArray(radiusValue.keys) && + radiusValue.keys.length > 0 + ) { + radius = interpolateGradientKeys(radiusValue.keys, lifeRatio, extractNumberFromValue); + } else if (radiusValue !== undefined && radiusValue !== null) { + // Parse as Value (number, ConstantValue, or IntervalValue) + const parsedRadius = ValueUtils.parseIntervalValue(radiusValue as Value); + radius = parsedRadius.min + (parsedRadius.max - parsedRadius.min) * lifeRatio; + } + + const speed = behavior.speed !== undefined ? ValueUtils.parseConstantValue(behavior.speed) : 1; + const angle = lifeRatio * speed * Math.PI * 2; + + // Calculate orbit offset relative to center + const centerX = behavior.center?.x ?? 0; + const centerY = behavior.center?.y ?? 0; + const centerZ = behavior.center?.z ?? 0; + + const orbitX = Math.cos(angle) * radius; + const orbitY = Math.sin(angle) * radius; + const orbitZ = 0; // 2D orbit + + // Apply orbit offset to particle position + particle.position.x = centerX + orbitX; + particle.position.y = centerY + orbitY; + particle.position.z = centerZ + orbitZ; +} diff --git a/tools/src/effect/behaviors/rotationBySpeed.ts b/tools/src/effect/behaviors/rotationBySpeed.ts new file mode 100644 index 000000000..b46f7a35d --- /dev/null +++ b/tools/src/effect/behaviors/rotationBySpeed.ts @@ -0,0 +1,81 @@ +import { Particle } from "@babylonjs/core/Particles/particle"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { extractNumberFromValue, interpolateGradientKeys } from "./utils"; +import { ValueUtils } from "../utils"; +import { ParticleWithSystem, SolidParticleWithSystem, type IRotationBySpeedBehavior } from "../types"; + +/** + * Apply RotationBySpeed behavior to Particle + * Gets currentSpeed from particle.direction magnitude and updateSpeed from system + */ +export function applyRotationBySpeedPS(particle: Particle, behavior: IRotationBySpeedBehavior): void { + if (!behavior.angularVelocity || !particle.direction) { + return; + } + + // Get current speed from particle velocity/direction + const currentSpeed = Vector3.Distance(Vector3.Zero(), particle.direction); + + // Get updateSpeed from system (stored in particle or use default) + const particleWithSystem = particle as ParticleWithSystem; + const updateSpeed = particleWithSystem.particleSystem?.updateSpeed ?? 0.016; + + // angularVelocity can be Value (constant/interval) or object with keys + let angularSpeed = 0; + if ( + typeof behavior.angularVelocity === "object" && + behavior.angularVelocity !== null && + "keys" in behavior.angularVelocity && + Array.isArray(behavior.angularVelocity.keys) && + behavior.angularVelocity.keys.length > 0 + ) { + const minSpeed = behavior.minSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.minSpeed) : 0; + const maxSpeed = behavior.maxSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.maxSpeed) : 1; + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + angularSpeed = interpolateGradientKeys(behavior.angularVelocity.keys, speedRatio, extractNumberFromValue); + } else { + const angularVel = ValueUtils.parseIntervalValue(behavior.angularVelocity); + angularSpeed = angularVel.min + (angularVel.max - angularVel.min) * 0.5; // Use middle value + } + + particle.angle += angularSpeed * updateSpeed; +} + +/** + * Apply RotationBySpeed behavior to SolidParticle + * Gets currentSpeed from particle.velocity magnitude and updateSpeed from system + */ +export function applyRotationBySpeedSPS(particle: SolidParticle, behavior: IRotationBySpeedBehavior): void { + if (!behavior.angularVelocity) { + return; + } + + // Get current speed from particle velocity + const currentSpeed = Math.sqrt(particle.velocity.x * particle.velocity.x + particle.velocity.y * particle.velocity.y + particle.velocity.z * particle.velocity.z); + + // Get updateSpeed from system (stored in particle.props or use default) + const particleWithSystem = particle as SolidParticleWithSystem; + const updateSpeed = particleWithSystem.system?.updateSpeed ?? 0.016; + + // angularVelocity can be Value (constant/interval) or object with keys + let angularSpeed = 0; + if ( + typeof behavior.angularVelocity === "object" && + behavior.angularVelocity !== null && + "keys" in behavior.angularVelocity && + Array.isArray(behavior.angularVelocity.keys) && + behavior.angularVelocity.keys.length > 0 + ) { + const minSpeed = behavior.minSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.minSpeed) : 0; + const maxSpeed = behavior.maxSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.maxSpeed) : 1; + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + angularSpeed = interpolateGradientKeys(behavior.angularVelocity.keys, speedRatio, extractNumberFromValue); + } else { + const angularVel = ValueUtils.parseIntervalValue(behavior.angularVelocity); + angularSpeed = angularVel.min + (angularVel.max - angularVel.min) * 0.5; // Use middle value + } + + // SolidParticle uses rotation.z for 2D rotation + particle.rotation.z += angularSpeed * updateSpeed; +} diff --git a/tools/src/effect/behaviors/rotationOverLife.ts b/tools/src/effect/behaviors/rotationOverLife.ts new file mode 100644 index 000000000..3e086311d --- /dev/null +++ b/tools/src/effect/behaviors/rotationOverLife.ts @@ -0,0 +1,107 @@ +import type { IRotationOverLifeBehavior } from "../types"; +import { ValueUtils } from "../utils"; +import { extractNumberFromValue } from "./utils"; +import type { EffectSolidParticleSystem, EffectParticleSystem } from "../systems"; +/** + * Apply RotationOverLife behavior to ParticleSystem + * Uses addAngularSpeedGradient for gradient support (Babylon.js native) + */ +export function applyRotationOverLifePS(particleSystem: EffectParticleSystem, behavior: IRotationOverLifeBehavior): void { + if (!behavior.angularVelocity) { + return; + } + + // Check if angularVelocity has gradient keys + if ( + typeof behavior.angularVelocity === "object" && + behavior.angularVelocity !== null && + "keys" in behavior.angularVelocity && + Array.isArray(behavior.angularVelocity.keys) && + behavior.angularVelocity.keys.length > 0 + ) { + // Use gradient for keys + for (const key of behavior.angularVelocity.keys) { + const pos = key.pos ?? key.time ?? 0; + const val = key.value; + if (val !== undefined && pos !== undefined) { + const numVal = extractNumberFromValue(val); + particleSystem.addAngularSpeedGradient(pos, numVal); + } + } + } else if ( + typeof behavior.angularVelocity === "object" && + behavior.angularVelocity !== null && + "functions" in behavior.angularVelocity && + Array.isArray(behavior.angularVelocity.functions) && + behavior.angularVelocity.functions.length > 0 + ) { + // Use gradient for functions + for (const func of behavior.angularVelocity.functions) { + if (func.function && func.start !== undefined) { + const startSpeed = func.function.p0 || 0; + const endSpeed = func.function.p3 !== undefined ? func.function.p3 : startSpeed; + particleSystem.addAngularSpeedGradient(func.start, startSpeed); + if (func.function.p3 !== undefined) { + particleSystem.addAngularSpeedGradient(Math.min(func.start + 0.5, 1), endSpeed); + } + } + } + } else { + // Fallback to interval (min/max) - use gradient with min at 0 and max at 1 + const angularVel = ValueUtils.parseIntervalValue(behavior.angularVelocity); + particleSystem.addAngularSpeedGradient(0, angularVel.min); + particleSystem.addAngularSpeedGradient(1, angularVel.max); + } +} + +/** + * Apply RotationOverLife behavior to SolidParticleSystem + * Adds angular speed gradients to the system (similar to ParticleSystem native gradients) + */ +export function applyRotationOverLifeSPS(system: EffectSolidParticleSystem, behavior: IRotationOverLifeBehavior): void { + if (!behavior.angularVelocity) { + return; + } + + // Check if angularVelocity has gradient keys + if ( + typeof behavior.angularVelocity === "object" && + behavior.angularVelocity !== null && + "keys" in behavior.angularVelocity && + Array.isArray(behavior.angularVelocity.keys) && + behavior.angularVelocity.keys.length > 0 + ) { + // Use gradient for keys + for (const key of behavior.angularVelocity.keys) { + const pos = key.pos ?? key.time ?? 0; + const val = key.value; + if (val !== undefined && pos !== undefined) { + const numVal = extractNumberFromValue(val); + system.addAngularSpeedGradient(pos, numVal); + } + } + } else if ( + typeof behavior.angularVelocity === "object" && + behavior.angularVelocity !== null && + "functions" in behavior.angularVelocity && + Array.isArray(behavior.angularVelocity.functions) && + behavior.angularVelocity.functions.length > 0 + ) { + // Use gradient for functions + for (const func of behavior.angularVelocity.functions) { + if (func.function && func.start !== undefined) { + const startSpeed = func.function.p0 || 0; + const endSpeed = func.function.p3 !== undefined ? func.function.p3 : startSpeed; + system.addAngularSpeedGradient(func.start, startSpeed); + if (func.function.p3 !== undefined) { + system.addAngularSpeedGradient(Math.min(func.start + 0.5, 1), endSpeed); + } + } + } + } else { + // Fallback to interval (min/max) - use gradient with min at 0 and max at 1 + const angularVel = ValueUtils.parseIntervalValue(behavior.angularVelocity); + system.addAngularSpeedGradient(0, angularVel.min); + system.addAngularSpeedGradient(1, angularVel.max); + } +} diff --git a/tools/src/effect/behaviors/sizeBySpeed.ts b/tools/src/effect/behaviors/sizeBySpeed.ts new file mode 100644 index 000000000..ed18708e5 --- /dev/null +++ b/tools/src/effect/behaviors/sizeBySpeed.ts @@ -0,0 +1,51 @@ +import { Particle } from "@babylonjs/core/Particles/particle"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import type { ISizeBySpeedBehavior } from "../types"; +import { extractNumberFromValue, interpolateGradientKeys } from "./utils"; +import { ValueUtils } from "../utils"; + +/** + * Apply SizeBySpeed behavior to Particle + * Gets currentSpeed from particle.direction magnitude + */ +export function applySizeBySpeedPS(particle: Particle, behavior: ISizeBySpeedBehavior): void { + if (!behavior.size || !behavior.size.keys || !particle.direction) { + return; + } + + // Get current speed from particle velocity/direction + const currentSpeed = Vector3.Distance(Vector3.Zero(), particle.direction); + + const sizeKeys = behavior.size.keys; + const minSpeed = behavior.minSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.minSpeed) : 0; + const maxSpeed = behavior.maxSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.maxSpeed) : 1; + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + + const sizeMultiplier = interpolateGradientKeys(sizeKeys, speedRatio, extractNumberFromValue); + const startSize = particle.size || 1; + particle.size = startSize * sizeMultiplier; +} + +/** + * Apply SizeBySpeed behavior to SolidParticle + * Gets currentSpeed from particle.velocity magnitude + */ +export function applySizeBySpeedSPS(particle: SolidParticle, behavior: ISizeBySpeedBehavior): void { + if (!behavior.size || !behavior.size.keys) { + return; + } + + // Get current speed from particle velocity + const currentSpeed = Math.sqrt(particle.velocity.x * particle.velocity.x + particle.velocity.y * particle.velocity.y + particle.velocity.z * particle.velocity.z); + + const sizeKeys = behavior.size.keys; + const minSpeed = behavior.minSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.minSpeed) : 0; + const maxSpeed = behavior.maxSpeed !== undefined ? ValueUtils.parseConstantValue(behavior.maxSpeed) : 1; + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + + const sizeMultiplier = interpolateGradientKeys(sizeKeys, speedRatio, extractNumberFromValue); + const startSize = particle.props?.startSize ?? 1; + const newSize = startSize * sizeMultiplier; + particle.scaling.setAll(newSize); +} diff --git a/tools/src/effect/behaviors/sizeOverLife.ts b/tools/src/effect/behaviors/sizeOverLife.ts new file mode 100644 index 000000000..c6e9b6891 --- /dev/null +++ b/tools/src/effect/behaviors/sizeOverLife.ts @@ -0,0 +1,66 @@ +import type { ISizeOverLifeBehavior } from "../types"; +import { extractNumberFromValue } from "./utils"; +import type { EffectSolidParticleSystem, EffectParticleSystem } from "../systems"; +/** + * Apply SizeOverLife behavior to ParticleSystem + * In Quarks, SizeOverLife values are multipliers relative to initial particle size + * In Babylon.js, sizeGradients are absolute values, so we multiply by average initial size + */ +export function applySizeOverLifePS(particleSystem: EffectParticleSystem, behavior: ISizeOverLifeBehavior): void { + // Get average initial size from minSize/maxSize to use as base for multipliers + const avgInitialSize = (particleSystem.minSize + particleSystem.maxSize) / 2; + + if (behavior.size && behavior.size.functions) { + const functions = behavior.size.functions; + for (const func of functions) { + if (func.function && func.start !== undefined) { + // Values from Quarks are multipliers, convert to absolute values + const startSizeMultiplier = func.function.p0 || 1; + const endSizeMultiplier = func.function.p3 !== undefined ? func.function.p3 : startSizeMultiplier; + particleSystem.addSizeGradient(func.start, startSizeMultiplier * avgInitialSize); + if (func.function.p3 !== undefined) { + particleSystem.addSizeGradient(func.start + 0.5, endSizeMultiplier * avgInitialSize); + } + } + } + } else if (behavior.size && behavior.size.keys) { + for (const key of behavior.size.keys) { + if (key.value !== undefined && key.pos !== undefined) { + // Values from Quarks are multipliers, convert to absolute values + const sizeMultiplier = extractNumberFromValue(key.value); + particleSystem.addSizeGradient(key.pos, sizeMultiplier * avgInitialSize); + } + } + } +} + +/** + * Apply SizeOverLife behavior to SolidParticleSystem + * Adds size gradients to the system (similar to ParticleSystem native gradients) + */ +export function applySizeOverLifeSPS(system: EffectSolidParticleSystem, behavior: ISizeOverLifeBehavior): void { + if (!behavior.size) { + return; + } + + if (behavior.size.functions) { + const functions = behavior.size.functions; + for (const func of functions) { + if (func.function && func.start !== undefined) { + const startSize = func.function.p0 || 1; + const endSize = func.function.p3 !== undefined ? func.function.p3 : startSize; + system.addSizeGradient(func.start, startSize); + if (func.function.p3 !== undefined) { + system.addSizeGradient(Math.min(func.start + 0.5, 1), endSize); + } + } + } + } else if (behavior.size.keys) { + for (const key of behavior.size.keys) { + if (key.value !== undefined && key.pos !== undefined) { + const size = extractNumberFromValue(key.value); + system.addSizeGradient(key.pos, size); + } + } + } +} diff --git a/tools/src/effect/behaviors/speedOverLife.ts b/tools/src/effect/behaviors/speedOverLife.ts new file mode 100644 index 000000000..6a6b3b219 --- /dev/null +++ b/tools/src/effect/behaviors/speedOverLife.ts @@ -0,0 +1,84 @@ +import type { ISpeedOverLifeBehavior } from "../types"; +import { extractNumberFromValue } from "./utils"; +import { ValueUtils } from "../utils"; +import type { EffectSolidParticleSystem, EffectParticleSystem } from "../systems"; +/** + * Apply SpeedOverLife behavior to ParticleSystem + */ +export function applySpeedOverLifePS(particleSystem: EffectParticleSystem, behavior: ISpeedOverLifeBehavior): void { + if (behavior.speed) { + if (typeof behavior.speed === "object" && behavior.speed !== null && "keys" in behavior.speed && behavior.speed.keys && Array.isArray(behavior.speed.keys)) { + for (const key of behavior.speed.keys) { + const pos = key.pos ?? key.time ?? 0; + const val = key.value; + if (val !== undefined && pos !== undefined) { + const numVal = extractNumberFromValue(val); + particleSystem.addVelocityGradient(pos, numVal); + } + } + } else if ( + typeof behavior.speed === "object" && + behavior.speed !== null && + "functions" in behavior.speed && + behavior.speed.functions && + Array.isArray(behavior.speed.functions) + ) { + for (const func of behavior.speed.functions) { + if (func.function && func.start !== undefined) { + const startSpeed = func.function.p0 || 1; + const endSpeed = func.function.p3 !== undefined ? func.function.p3 : startSpeed; + particleSystem.addVelocityGradient(func.start, startSpeed); + if (func.function.p3 !== undefined) { + particleSystem.addVelocityGradient(Math.min(func.start + 0.5, 1), endSpeed); + } + } + } + } else if (typeof behavior.speed === "number" || (typeof behavior.speed === "object" && behavior.speed !== null && "type" in behavior.speed)) { + const speedValue = ValueUtils.parseIntervalValue(behavior.speed); + particleSystem.addVelocityGradient(0, speedValue.min); + particleSystem.addVelocityGradient(1, speedValue.max); + } + } +} + +/** + * Apply SpeedOverLife behavior to SolidParticleSystem + * Adds velocity gradients to the system (similar to ParticleSystem native gradients) + */ +export function applySpeedOverLifeSPS(system: EffectSolidParticleSystem, behavior: ISpeedOverLifeBehavior): void { + if (!behavior.speed) { + return; + } + + if (typeof behavior.speed === "object" && behavior.speed !== null && "keys" in behavior.speed && behavior.speed.keys && Array.isArray(behavior.speed.keys)) { + for (const key of behavior.speed.keys) { + const pos = key.pos ?? key.time ?? 0; + const val = key.value; + if (val !== undefined && pos !== undefined) { + const numVal = extractNumberFromValue(val); + system.addVelocityGradient(pos, numVal); + } + } + } else if ( + typeof behavior.speed === "object" && + behavior.speed !== null && + "functions" in behavior.speed && + behavior.speed.functions && + Array.isArray(behavior.speed.functions) + ) { + for (const func of behavior.speed.functions) { + if (func.function && func.start !== undefined) { + const startSpeed = func.function.p0 || 1; + const endSpeed = func.function.p3 !== undefined ? func.function.p3 : startSpeed; + system.addVelocityGradient(func.start, startSpeed); + if (func.function.p3 !== undefined) { + system.addVelocityGradient(Math.min(func.start + 0.5, 1), endSpeed); + } + } + } + } else if (typeof behavior.speed === "number" || (typeof behavior.speed === "object" && behavior.speed !== null && "type" in behavior.speed)) { + const speedValue = ValueUtils.parseIntervalValue(behavior.speed); + system.addVelocityGradient(0, speedValue.min); + system.addVelocityGradient(1, speedValue.max); + } +} diff --git a/tools/src/effect/behaviors/utils.ts b/tools/src/effect/behaviors/utils.ts new file mode 100644 index 000000000..f7afcac4c --- /dev/null +++ b/tools/src/effect/behaviors/utils.ts @@ -0,0 +1,165 @@ +import type { IGradientKey } from "../types"; + +/** + * Extract RGB color from gradient key value + */ +export function extractColorFromValue(value: number | number[] | { r: number; g: number; b: number; a?: number } | undefined): { r: number; g: number; b: number } { + if (value === undefined) { + return { r: 1, g: 1, b: 1 }; + } + + if (typeof value === "number") { + return { r: value, g: value, b: value }; + } + + if (Array.isArray(value)) { + return { + r: value[0] || 0, + g: value[1] || 0, + b: value[2] || 0, + }; + } + + if (typeof value === "object" && "r" in value) { + return { + r: value.r || 0, + g: value.g || 0, + b: value.b || 0, + }; + } + + return { r: 1, g: 1, b: 1 }; +} + +/** + * Extract alpha from gradient key value + */ +export function extractAlphaFromValue(value: number | number[] | { r: number; g: number; b: number; a?: number } | undefined): number { + if (value === undefined) { + return 1; + } + + if (typeof value === "number") { + return value; + } + + if (Array.isArray(value)) { + return value[3] !== undefined ? value[3] : 1; + } + + if (typeof value === "object" && "a" in value) { + return value.a !== undefined ? value.a : 1; + } + + return 1; +} + +/** + * Extract number from gradient key value + */ +export function extractNumberFromValue(value: number | number[] | { r: number; g: number; b: number; a?: number } | undefined): number { + if (value === undefined) { + return 1; + } + + if (typeof value === "number") { + return value; + } + + if (Array.isArray(value)) { + return value[0] || 0; + } + + return 1; +} + +/** + * Interpolate between two gradient keys + */ +export function interpolateGradientKeys( + keys: IGradientKey[], + ratio: number, + extractValue: (value: number | number[] | { r: number; g: number; b: number; a?: number } | undefined) => number +): number { + if (!keys || keys.length === 0) { + return 1; + } + + if (keys.length === 1) { + return extractValue(keys[0].value); + } + + // Find the two keys to interpolate between + for (let i = 0; i < keys.length - 1; i++) { + const pos1 = keys[i].pos ?? keys[i].time ?? 0; + const pos2 = keys[i + 1].pos ?? keys[i + 1].time ?? 1; + + if (ratio >= pos1 && ratio <= pos2) { + const t = pos2 - pos1 !== 0 ? (ratio - pos1) / (pos2 - pos1) : 0; + const val1 = extractValue(keys[i].value); + const val2 = extractValue(keys[i + 1].value); + return val1 + (val2 - val1) * t; + } + } + + // Clamp to first or last key + if (ratio <= (keys[0].pos ?? keys[0].time ?? 0)) { + return extractValue(keys[0].value); + } + return extractValue(keys[keys.length - 1].value); +} + +/** + * Interpolate color between two gradient keys + */ +export function interpolateColorKeys(keys: IGradientKey[], ratio: number): { r: number; g: number; b: number; a: number } { + if (!keys || keys.length === 0) { + return { r: 1, g: 1, b: 1, a: 1 }; + } + + if (keys.length === 1) { + const val = keys[0].value; + return { + ...extractColorFromValue(val), + a: extractAlphaFromValue(val), + }; + } + + // Find the two keys to interpolate between + for (let i = 0; i < keys.length - 1; i++) { + const pos1 = keys[i].pos ?? keys[i].time ?? 0; + const pos2 = keys[i + 1].pos ?? keys[i + 1].time ?? 1; + + if (ratio >= pos1 && ratio <= pos2) { + const t = pos2 - pos1 !== 0 ? (ratio - pos1) / (pos2 - pos1) : 0; + const val1 = keys[i].value; + const val2 = keys[i + 1].value; + + const c1 = extractColorFromValue(val1); + const c2 = extractColorFromValue(val2); + const a1 = extractAlphaFromValue(val1); + const a2 = extractAlphaFromValue(val2); + + return { + r: c1.r + (c2.r - c1.r) * t, + g: c1.g + (c2.g - c1.g) * t, + b: c1.b + (c2.b - c1.b) * t, + a: a1 + (a2 - a1) * t, + }; + } + } + + // Clamp to first or last key + if (ratio <= (keys[0].pos ?? keys[0].time ?? 0)) { + const val = keys[0].value; + return { + ...extractColorFromValue(val), + a: extractAlphaFromValue(val), + }; + } + const val = keys[keys.length - 1].value; + return { + ...extractColorFromValue(val), + a: extractAlphaFromValue(val), + }; +} diff --git a/tools/src/effect/effect.ts b/tools/src/effect/effect.ts new file mode 100644 index 000000000..87f971300 --- /dev/null +++ b/tools/src/effect/effect.ts @@ -0,0 +1,477 @@ +import { IDisposable, Scene } from "@babylonjs/core/scene"; +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; +import { EffectParticleSystem, EffectSolidParticleSystem } from "./systems"; +import { IData, IEffectNode, ILoaderOptions, IParticleSystemConfig } from "./types"; +import { NodeFactory } from "./factories"; + +/** + * Effect containing multiple particle systems with hierarchy support + * Main entry point for loading and creating from Three.js particle JSON files + */ +export class Effect implements IDisposable { + /** Root node of the effect hierarchy */ + private _root: IEffectNode | null = null; + + /** + * Get root node of the effect hierarchy + */ + public get root(): IEffectNode | null { + return this._root; + } + + /** NodeFactory for creating groups and systems */ + private _nodeFactory: NodeFactory | null = null; + + /** + * Create Effect from IData + * + * + * @param data IData structure (required) + * @param scene Babylon.js scene (required) + * @param rootUrl Root URL for loading textures (optional) + * @param options Optional parsing options + */ + constructor(data: IData, scene: Scene, rootUrl: string = "", options?: ILoaderOptions) { + if (!data || !scene) { + throw new Error("Effect constructor requires IData and Scene"); + } + + this._nodeFactory = new NodeFactory(scene, data, rootUrl, options); + this._root = this._nodeFactory.create(); + } + + /** + * Recursively find a node by name in the tree + */ + private _findNodeByName(node: IEffectNode | null, name: string): IEffectNode | null { + if (!node) { + return null; + } + if (node.name === name) { + return node; + } + for (const child of node.children) { + const found = this._findNodeByName(child, name); + if (found) { + return found; + } + } + return null; + } + + /** + * Recursively find a node by UUID in the tree + */ + private _findNodeByUuid(node: IEffectNode | null, uuid: string): IEffectNode | null { + if (!node) { + return null; + } + if (node.uuid === uuid) { + return node; + } + for (const child of node.children) { + const found = this._findNodeByUuid(child, uuid); + if (found) { + return found; + } + } + return null; + } + + /** + * Recursively collect all systems from the tree + */ + private _collectAllSystems(node: IEffectNode | null, systems: (EffectParticleSystem | EffectSolidParticleSystem)[]): void { + if (!node) { + return; + } + if (node.type === "particle") { + const system = node.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system) { + systems.push(system); + } + } + for (const child of node.children) { + this._collectAllSystems(child, systems); + } + } + + /** + * Find a particle system by name + */ + public findSystemByName(name: string): EffectParticleSystem | EffectSolidParticleSystem | null { + const node = this._findNodeByName(this._root, name); + if (node && node.type === "particle") { + return node.data as EffectParticleSystem | EffectSolidParticleSystem; + } + return null; + } + + /** + * Find a particle system by UUID + */ + public findSystemByUuid(uuid: string): EffectParticleSystem | EffectSolidParticleSystem | null { + const node = this._findNodeByUuid(this._root, uuid); + if (node && node.type === "particle") { + return node.data as EffectParticleSystem | EffectSolidParticleSystem; + } + return null; + } + + /** + * Find a group by name + */ + public findGroupByName(name: string): TransformNode | null { + const node = this._findNodeByName(this._root, name); + if (node && node.type === "group") { + return node.data as TransformNode; + } + return null; + } + + /** + * Find a group by UUID + */ + public findGroupByUuid(uuid: string): TransformNode | null { + const node = this._findNodeByUuid(this._root, uuid); + if (node && node.type === "group") { + return node.data as TransformNode; + } + return null; + } + + /** + * Find a node (system or group) by name + */ + public findNodeByName(name: string): IEffectNode | null { + return this._findNodeByName(this._root, name); + } + + /** + * Find a node (system or group) by UUID + */ + public findNodeByUuid(uuid: string): IEffectNode | null { + return this._findNodeByUuid(this._root, uuid); + } + + /** + * Get all systems in a group (recursively) + * Includes systems from nested child groups as well. + * Example: If Group1 contains Group2, and Group2 contains System1, + * then getSystemsInGroup("Group1") will return System1. + */ + public getSystemsInGroup(groupName: string): (EffectParticleSystem | EffectSolidParticleSystem)[] { + const groupNode = this.findNodeByName(groupName); + if (!groupNode || groupNode.type !== "group") { + return []; + } + + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + this._collectSystemsInGroupNode(groupNode, systems); + return systems; + } + + /** + * Recursively collect systems in a group node (including systems from all nested child groups) + */ + private _collectSystemsInGroupNode(groupNode: IEffectNode, systems: (EffectParticleSystem | EffectSolidParticleSystem)[]): void { + if (groupNode.type === "particle") { + const system = groupNode.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system) { + systems.push(system); + } + } + for (const child of groupNode.children) { + this._collectSystemsInGroupNode(child, systems); + } + } + + /** + * Start a specific system by name + */ + public startSystem(name: string): boolean { + const system = this.findSystemByName(name); + if (system) { + system.start(); + return true; + } + return false; + } + + /** + * Stop a specific system by name + */ + public stopSystem(name: string): boolean { + const system = this.findSystemByName(name); + if (system) { + system.stop(); + return true; + } + return false; + } + + /** + * Start all systems in a group + */ + public startGroup(groupName: string): void { + const systems = this.getSystemsInGroup(groupName); + for (const system of systems) { + system.start(); + } + } + + /** + * Stop all systems in a group + */ + public stopGroup(groupName: string): void { + const systems = this.getSystemsInGroup(groupName); + for (const system of systems) { + system.stop(); + } + } + + /** + * Start a node (system or group) + */ + public startNode(node: IEffectNode): void { + if (node.type === "particle") { + const system = node.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system && typeof system.start === "function") { + system.start(); + } + } else if (node.type === "group") { + // Find all systems in this group recursively + const systems = this._getSystemsInNode(node); + for (const system of systems) { + system.start(); + } + } + } + + /** + * Stop a node (system or group) + */ + public stopNode(node: IEffectNode): void { + if (node.type === "particle") { + const system = node.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system && typeof system.stop === "function") { + system.stop(); + } + } else if (node.type === "group") { + // Find all systems in this group recursively + const systems = this._getSystemsInNode(node); + for (const system of systems) { + system.stop(); + } + } + } + + /** + * Reset a node (system or group) + */ + public resetNode(node: IEffectNode): void { + if (node.type === "particle") { + const system = node.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system && typeof system.reset === "function") { + system.reset(); + } + } else if (node.type === "group") { + // Find all systems in this group recursively + const systems = this._getSystemsInNode(node); + for (const system of systems) { + system.reset(); + } + } + } + + /** + * Check if a node is started (system or group) + */ + public isNodeStarted(node: IEffectNode): boolean { + if (node.type === "particle") { + const system = node.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system instanceof EffectParticleSystem) { + return (system as any).isStarted ? (system as any).isStarted() : false; + } else if (system instanceof EffectSolidParticleSystem) { + return (system as any)._started && !(system as any)._stopped; + } + return false; + } else if (node.type === "group") { + // Check if any system in this group is started + const systems = this._getSystemsInNode(node); + return systems.some((system) => { + if (system instanceof EffectParticleSystem) { + return (system as any).isStarted ? (system as any).isStarted() : false; + } else if (system instanceof EffectSolidParticleSystem) { + return (system as any)._started && !(system as any)._stopped; + } + return false; + }); + } + return false; + } + + /** + * Get all systems in a node recursively + */ + private _getSystemsInNode(node: IEffectNode): (EffectParticleSystem | EffectSolidParticleSystem)[] { + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + + if (node.type === "particle") { + const system = node.data as EffectParticleSystem | EffectSolidParticleSystem; + if (system) { + systems.push(system); + } + } else if (node.type === "group") { + // Recursively collect all systems from children + for (const child of node.children) { + systems.push(...this._getSystemsInNode(child)); + } + } + + return systems; + } + + /** + * Start all particle systems + */ + public start(): void { + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + this._collectAllSystems(this._root, systems); + for (const system of systems) { + system.start(); + } + } + + /** + * Stop all particle systems + */ + public stop(): void { + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + this._collectAllSystems(this._root, systems); + for (const system of systems) { + system.stop(); + } + } + + /** + * Reset all particle systems (stop and clear particles) + */ + public reset(): void { + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + this._collectAllSystems(this._root, systems); + for (const system of systems) { + system.reset(); + } + } + + /** + * Check if any system is started + */ + public isStarted(): boolean { + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + this._collectAllSystems(this._root, systems); + for (const system of systems) { + if (system instanceof EffectParticleSystem) { + if ((system as any).isStarted && (system as any).isStarted()) { + return true; + } + } else if (system instanceof EffectSolidParticleSystem) { + // Check internal _started flag for SPS + if ((system as any)._started && !(system as any)._stopped) { + return true; + } + } + } + return false; + } + + /** + * Create a new group node + * @param parentNode Parent node (if null, adds to root) + * @param name Optional name (defaults to "Group") + * @returns Created group node + */ + public createGroup(parentNode: IEffectNode | null = null, name: string = "Group"): IEffectNode | null { + if (!this._nodeFactory) { + console.error("Cannot create group: NodeFactory is not available"); + return null; + } + + const parent = parentNode || this._root; + if (!parent || parent.type !== "group") { + console.error("Cannot create group: parent is not a group"); + return null; + } + + // Ensure unique name + let uniqueName = name; + let counter = 1; + while (this._findNodeByName(this._root, uniqueName)) { + uniqueName = `${name} ${counter}`; + counter++; + } + + // Create group using NodeFactory + const newNode = this._nodeFactory.createGroup(uniqueName, parent); + + // Add to parent's children + parent.children.push(newNode); + + return newNode; + } + + /** + * Create a new particle system + * @param parentNode Parent node (if null, adds to root) + * @param systemType Type of system ("solid" or "base") + * @param name Optional name (defaults to "ParticleSystem") + * @param config Optional particle system config + * @returns Created particle system node + */ + public createParticleSystem( + parentNode: IEffectNode | null = null, + systemType: "solid" | "base" = "base", + name: string = "ParticleSystem", + config?: Partial + ): IEffectNode | null { + if (!this._nodeFactory) { + console.error("Cannot create particle system: NodeFactory is not available"); + return null; + } + + const parent = parentNode || this._root; + if (!parent || parent.type !== "group") { + console.error("Cannot create particle system: parent is not a group"); + return null; + } + + // Ensure unique name + let uniqueName = name; + let counter = 1; + while (this._findNodeByName(this._root, uniqueName)) { + uniqueName = `${name} ${counter}`; + counter++; + } + + // Create particle system using NodeFactory + const newNode = this._nodeFactory.createParticleSystem(uniqueName, systemType, config, parent); + + // Add to parent's children + parent.children.push(newNode); + + return newNode; + } + + /** + * Dispose all resources + */ + public dispose(): void { + const systems: (EffectParticleSystem | EffectSolidParticleSystem)[] = []; + this._collectAllSystems(this._root, systems); + for (const system of systems) { + system.dispose(); + } + this._root = null; + } +} diff --git a/tools/src/effect/emitters/index.ts b/tools/src/effect/emitters/index.ts new file mode 100644 index 000000000..2c35fc332 --- /dev/null +++ b/tools/src/effect/emitters/index.ts @@ -0,0 +1,6 @@ +export { SolidPointParticleEmitter } from "./solidPointEmitter"; +export { SolidSphereParticleEmitter } from "./solidSphereEmitter"; +export { SolidConeParticleEmitter } from "./solidConeEmitter"; +export { SolidBoxParticleEmitter } from "./solidBoxEmitter"; +export { SolidHemisphericParticleEmitter } from "./solidHemisphericEmitter"; +export { SolidCylinderParticleEmitter } from "./solidCylinderEmitter"; diff --git a/tools/src/effect/emitters/solidBoxEmitter.ts b/tools/src/effect/emitters/solidBoxEmitter.ts new file mode 100644 index 000000000..3fedcc5c2 --- /dev/null +++ b/tools/src/effect/emitters/solidBoxEmitter.ts @@ -0,0 +1,70 @@ +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { ISolidParticleEmitterType } from "../types"; + +/** + * Box emitter for SolidParticleSystem + * Emits particles from inside a box with random direction between direction1 and direction2 + */ +export class SolidBoxParticleEmitter implements ISolidParticleEmitterType { + /** + * Random direction of each particle after it has been emitted, between direction1 and direction2 vectors. + */ + public direction1: Vector3 = new Vector3(0, 1, 0); + + /** + * Random direction of each particle after it has been emitted, between direction1 and direction2 vectors. + */ + public direction2: Vector3 = new Vector3(0, 1, 0); + + /** + * Minimum box point around the emitter center. + */ + public minEmitBox: Vector3 = new Vector3(-0.5, -0.5, -0.5); + + /** + * Maximum box point around the emitter center. + */ + public maxEmitBox: Vector3 = new Vector3(0.5, 0.5, 0.5); + + constructor(direction1?: Vector3, direction2?: Vector3, minEmitBox?: Vector3, maxEmitBox?: Vector3) { + if (direction1) { + this.direction1 = direction1; + } + if (direction2) { + this.direction2 = direction2; + } + if (minEmitBox) { + this.minEmitBox = minEmitBox; + } + if (maxEmitBox) { + this.maxEmitBox = maxEmitBox; + } + } + + /** + * Random range helper + */ + private _randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); + } + + /** + * Initialize particle position and velocity + * Note: Direction is NOT normalized, matching ParticleSystem behavior. + * The direction vector magnitude affects final velocity. + */ + public initializeParticle(particle: SolidParticle, startSpeed: number): void { + // Random position within the box + const randX = this._randomRange(this.minEmitBox.x, this.maxEmitBox.x); + const randY = this._randomRange(this.minEmitBox.y, this.maxEmitBox.y); + const randZ = this._randomRange(this.minEmitBox.z, this.maxEmitBox.z); + particle.position.set(randX, randY, randZ); + + // Random direction between direction1 and direction2 (NOT normalized, like ParticleSystem) + const dirX = this._randomRange(this.direction1.x, this.direction2.x); + const dirY = this._randomRange(this.direction1.y, this.direction2.y); + const dirZ = this._randomRange(this.direction1.z, this.direction2.z); + particle.velocity.set(dirX * startSpeed, dirY * startSpeed, dirZ * startSpeed); + } +} diff --git a/tools/src/effect/emitters/solidConeEmitter.ts b/tools/src/effect/emitters/solidConeEmitter.ts new file mode 100644 index 000000000..18d4b1ff9 --- /dev/null +++ b/tools/src/effect/emitters/solidConeEmitter.ts @@ -0,0 +1,35 @@ +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { ISolidParticleEmitterType } from "../types"; + +/** + * Cone emitter for SolidParticleSystem + */ +export class SolidConeParticleEmitter implements ISolidParticleEmitterType { + public radius: number; + public arc: number; + public thickness: number; + public angle: number; + + constructor(radius: number = 1, arc: number = Math.PI * 2, thickness: number = 1, angle: number = Math.PI / 6) { + this.radius = radius; + this.arc = arc; + this.thickness = thickness; + this.angle = angle; + } + + public initializeParticle(particle: SolidParticle, startSpeed: number): void { + const u = Math.random(); + const rand = 1 - this.thickness + Math.random() * this.thickness; + const theta = u * this.arc; + const r = Math.sqrt(rand); + const sinTheta = Math.sin(theta); + const cosTheta = Math.cos(theta); + + particle.position.set(r * cosTheta, r * sinTheta, 0); + const coneAngle = this.angle * r; + particle.velocity.set(0, 0, Math.cos(coneAngle)); + particle.velocity.addInPlace(particle.position.scale(Math.sin(coneAngle))); + particle.velocity.scaleInPlace(startSpeed); + particle.position.scaleInPlace(this.radius); + } +} diff --git a/tools/src/effect/emitters/solidCylinderEmitter.ts b/tools/src/effect/emitters/solidCylinderEmitter.ts new file mode 100644 index 000000000..c024ad5eb --- /dev/null +++ b/tools/src/effect/emitters/solidCylinderEmitter.ts @@ -0,0 +1,79 @@ +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { ISolidParticleEmitterType } from "../types"; + +/** + * Cylinder emitter for SolidParticleSystem + * Emits particles from inside a cylinder + */ +export class SolidCylinderParticleEmitter implements ISolidParticleEmitterType { + /** + * The radius of the emission cylinder + */ + public radius: number; + + /** + * The height of the emission cylinder + */ + public height: number; + + /** + * The range of emission [0-1] 0 Surface only, 1 Entire Radius + */ + public radiusRange: number; + + /** + * How much to randomize the particle direction [0-1] + */ + public directionRandomizer: number; + + private _tempVector: Vector3 = Vector3.Zero(); + + constructor(radius: number = 1, height: number = 1, radiusRange: number = 1, directionRandomizer: number = 0) { + this.radius = radius; + this.height = height; + this.radiusRange = radiusRange; + this.directionRandomizer = directionRandomizer; + } + + /** + * Random range helper + */ + private _randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); + } + + /** + * Initialize particle position and velocity + */ + public initializeParticle(particle: SolidParticle, startSpeed: number): void { + // Random height position + const yPos = this._randomRange(-this.height / 2, this.height / 2); + + // Random angle around cylinder + const angle = this._randomRange(0, 2 * Math.PI); + + // Pick a properly distributed point within the circle + // https://programming.guide/random-point-within-circle.html + const radiusDistribution = this._randomRange((1 - this.radiusRange) * (1 - this.radiusRange), 1); + const positionRadius = Math.sqrt(radiusDistribution) * this.radius; + + const xPos = positionRadius * Math.cos(angle); + const zPos = positionRadius * Math.sin(angle); + + particle.position.set(xPos, yPos, zPos); + + // Direction is outward from cylinder axis with randomization + this._tempVector.set(xPos, 0, zPos); + this._tempVector.normalize(); + + // Apply direction randomization + const randY = this._randomRange(-this.directionRandomizer / 2, this.directionRandomizer / 2); + let dirAngle = Math.atan2(this._tempVector.x, this._tempVector.z); + dirAngle += this._randomRange(-Math.PI / 2, Math.PI / 2) * this.directionRandomizer; + + particle.velocity.set(Math.sin(dirAngle), randY, Math.cos(dirAngle)); + particle.velocity.normalize(); + particle.velocity.scaleInPlace(startSpeed); + } +} diff --git a/tools/src/effect/emitters/solidHemisphericEmitter.ts b/tools/src/effect/emitters/solidHemisphericEmitter.ts new file mode 100644 index 000000000..5c74d16f1 --- /dev/null +++ b/tools/src/effect/emitters/solidHemisphericEmitter.ts @@ -0,0 +1,68 @@ +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { ISolidParticleEmitterType } from "../types"; + +/** + * Hemispheric emitter for SolidParticleSystem + * Emits particles from the inside of a hemisphere (upper half of a sphere) + */ +export class SolidHemisphericParticleEmitter implements ISolidParticleEmitterType { + /** + * The radius of the emission hemisphere + */ + public radius: number; + + /** + * The range of emission [0-1] 0 Surface only, 1 Entire Radius + */ + public radiusRange: number; + + /** + * How much to randomize the particle direction [0-1] + */ + public directionRandomizer: number; + + constructor(radius: number = 1, radiusRange: number = 1, directionRandomizer: number = 0) { + this.radius = radius; + this.radiusRange = radiusRange; + this.directionRandomizer = directionRandomizer; + } + + /** + * Random range helper + */ + private _randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); + } + + /** + * Initialize particle position and velocity + */ + public initializeParticle(particle: SolidParticle, startSpeed: number): void { + // Calculate random position within hemisphere + const randRadius = this.radius - this._randomRange(0, this.radius * this.radiusRange); + const v = Math.random(); + const phi = this._randomRange(0, 2 * Math.PI); + const theta = Math.acos(2 * v - 1); + + const x = randRadius * Math.cos(phi) * Math.sin(theta); + const y = randRadius * Math.cos(theta); + const z = randRadius * Math.sin(phi) * Math.sin(theta); + + // Use absolute y to keep particles in upper hemisphere + particle.position.set(x, Math.abs(y), z); + + // Direction is outward from center with optional randomization + particle.velocity.copyFrom(particle.position); + particle.velocity.normalize(); + + // Apply direction randomization + if (this.directionRandomizer > 0) { + particle.velocity.x += this._randomRange(0, this.directionRandomizer); + particle.velocity.y += this._randomRange(0, this.directionRandomizer); + particle.velocity.z += this._randomRange(0, this.directionRandomizer); + particle.velocity.normalize(); + } + + particle.velocity.scaleInPlace(startSpeed); + } +} diff --git a/tools/src/effect/emitters/solidPointEmitter.ts b/tools/src/effect/emitters/solidPointEmitter.ts new file mode 100644 index 000000000..c03402663 --- /dev/null +++ b/tools/src/effect/emitters/solidPointEmitter.ts @@ -0,0 +1,17 @@ +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { ISolidParticleEmitterType } from "../types"; + +/** + * Point emitter for SolidParticleSystem + */ +export class SolidPointParticleEmitter implements ISolidParticleEmitterType { + public initializeParticle(particle: SolidParticle, startSpeed: number): void { + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2.0 * Math.random() - 1.0); + const direction = new Vector3(Math.sin(phi) * Math.cos(theta), Math.sin(phi) * Math.sin(theta), Math.cos(phi)); + particle.position.setAll(0); + particle.velocity.copyFrom(direction); + particle.velocity.scaleInPlace(startSpeed); + } +} diff --git a/tools/src/effect/emitters/solidSphereEmitter.ts b/tools/src/effect/emitters/solidSphereEmitter.ts new file mode 100644 index 000000000..43bfc986c --- /dev/null +++ b/tools/src/effect/emitters/solidSphereEmitter.ts @@ -0,0 +1,34 @@ +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { ISolidParticleEmitterType } from "../types"; + +/** + * Sphere emitter for SolidParticleSystem + */ +export class SolidSphereParticleEmitter implements ISolidParticleEmitterType { + public radius: number; + public arc: number; + public thickness: number; + + constructor(radius: number = 1, arc: number = Math.PI * 2, thickness: number = 1) { + this.radius = radius; + this.arc = arc; + this.thickness = thickness; + } + + public initializeParticle(particle: SolidParticle, startSpeed: number): void { + const u = Math.random(); + const v = Math.random(); + const rand = 1 - this.thickness + Math.random() * this.thickness; + const theta = u * this.arc; + const phi = Math.acos(2.0 * v - 1.0); + const sinTheta = Math.sin(theta); + const cosTheta = Math.cos(theta); + const sinPhi = Math.sin(phi); + const cosPhi = Math.cos(phi); + + particle.position.set(sinPhi * cosTheta, sinPhi * sinTheta, cosPhi); + particle.velocity.copyFrom(particle.position); + particle.velocity.scaleInPlace(startSpeed); + particle.position.scaleInPlace(this.radius * rand); + } +} diff --git a/tools/src/effect/factories/geometryFactory.ts b/tools/src/effect/factories/geometryFactory.ts new file mode 100644 index 000000000..f8b40f9f7 --- /dev/null +++ b/tools/src/effect/factories/geometryFactory.ts @@ -0,0 +1,214 @@ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; +import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData"; +import { CreatePlane } from "@babylonjs/core/Meshes/Builders/planeBuilder"; +import { Scene } from "@babylonjs/core/scene"; +import type { IGeometryFactory } from "../types"; +import { Logger } from "../loggers/logger"; +import type { IData, IGeometry, ILoaderOptions } from "../types"; +import { Nullable } from "@babylonjs/core/types"; + +/** + * Factory for creating meshes from Three.js geometry data + */ +export class GeometryFactory implements IGeometryFactory { + private _logger: Logger; + private _data: IData; + + constructor(data: IData, options?: ILoaderOptions) { + this._data = data; + this._logger = new Logger("[GeometryFactory]", options); + } + + /** + * Create a mesh from geometry ID + */ + public createMesh(geometryId: string, name: string, scene: Scene): Mesh { + this._logger.log(`Creating mesh from geometry ID: ${geometryId}, name: ${name}`); + + const geometryData = this._findGeometry(geometryId); + if (!geometryData) { + return new Mesh(name, scene); + } + + const geometryName = geometryData.type || geometryId; + this._logger.log(`Found geometry: ${geometryName} (type: ${geometryData.type})`); + + const mesh = this._createMeshFromGeometry(geometryData, name, scene); + if (!mesh) { + this._logger.warn(`Failed to create mesh from geometry ${geometryId}`); + return new Mesh(name, scene); + } + + return mesh; + } + + /** + * Create or load particle mesh for SPS + * Tries to load geometry if specified, otherwise creates default plane + */ + public createParticleMesh(config: { instancingGeometry?: string }, name: string, scene: Scene): Mesh { + let particleMesh = this._loadParticleGeometry(config, name, scene); + + if (!particleMesh) { + particleMesh = this._createDefaultPlaneMesh(name, scene); + } + + if (!particleMesh) { + this._logger.warn(` Cannot create particle mesh: particleMesh is null`); + } + + return particleMesh; + } + + /** + * Loads particle geometry if specified + */ + private _loadParticleGeometry(config: { instancingGeometry?: string }, name: string, scene: Scene): Nullable { + if (!config.instancingGeometry) { + return null; + } + + this._logger.log(` Loading geometry: ${config.instancingGeometry}`); + const mesh = this.createMesh(config.instancingGeometry, name + "_shape", scene); + if (!mesh) { + this._logger.warn(` Failed to load geometry ${config.instancingGeometry}, will create default plane`); + } + + return mesh; + } + + /** + * Creates default plane mesh + */ + private _createDefaultPlaneMesh(name: string, scene: Scene): Mesh { + this._logger.log(` Creating default plane geometry`); + return CreatePlane(name + "_shape", { width: 1, height: 1 }, scene); + } + + /** + * Finds geometry by UUID + */ + private _findGeometry(geometryId: string): IGeometry | null { + if (!this._data.geometries || this._data.geometries.length === 0) { + this._logger.warn("No geometries data available"); + return null; + } + + const geometry = this._data.geometries.find((g) => g.uuid === geometryId); + if (!geometry) { + this._logger.warn(`Geometry not found: ${geometryId}`); + return null; + } + + return geometry; + } + + /** + * Creates mesh from geometry data based on type + */ + private _createMeshFromGeometry(geometryData: IGeometry, name: string, scene: Scene): Nullable { + this._logger.log(`createMeshFromGeometry: type=${geometryData.type}, name=${name}`); + + const geometryTypeHandlers: Record Nullable> = { + PlaneGeometry: (data, meshName, scene) => this._createPlaneGeometry(data, meshName, scene), + BufferGeometry: (data, meshName, scene) => this._createBufferGeometry(data, meshName, scene), + }; + + const handler = geometryTypeHandlers[geometryData.type]; + if (!handler) { + this._logger.warn(`Unsupported geometry type: ${geometryData.type}`); + return null; + } + + return handler(geometryData, name, scene); + } + + /** + * Creates plane geometry mesh + */ + private _createPlaneGeometry(geometryData: IGeometry, name: string, scene: Scene): Nullable { + const width = geometryData.width ?? 1; + const height = geometryData.height ?? 1; + + this._logger.log(`Creating PlaneGeometry: width=${width}, height=${height}`); + + const mesh = CreatePlane(name, { width, height }, scene); + if (mesh) { + this._logger.log(`PlaneGeometry created successfully`); + } else { + this._logger.warn(`Failed to create PlaneGeometry`); + } + + return mesh; + } + + /** + * Creates buffer geometry mesh (already converted to left-handed) + */ + private _createBufferGeometry(geometryData: IGeometry, name: string, scene: Scene): Nullable { + if (!geometryData.data?.attributes) { + this._logger.warn("BufferGeometry missing data or attributes"); + return null; + } + + const vertexData = this._createVertexDataFromAttributes(geometryData); + if (!vertexData) { + return null; + } + + const mesh = new Mesh(name, scene); + vertexData.applyToMesh(mesh); + // Geometry is already converted to left-handed in DataConverter + + return mesh; + } + + /** + * Creates VertexData from BufferGeometry attributes (already converted to left-handed) + */ + private _createVertexDataFromAttributes(geometryData: IGeometry): Nullable { + if (!geometryData.data?.attributes) { + return null; + } + + const attrs = geometryData.data.attributes; + const positions = attrs.position; + if (!positions?.array) { + this._logger.warn("BufferGeometry missing position attribute"); + return null; + } + + const vertexData = new VertexData(); + vertexData.positions = Array.from(positions.array); + + this._applyAttribute(vertexData, attrs.normal, "normals"); + this._applyAttribute(vertexData, attrs.uv, "uvs"); + this._applyAttribute(vertexData, attrs.color, "colors"); + + const indices = geometryData.data.index; + if (indices?.array) { + vertexData.indices = Array.from(indices.array); + } else { + vertexData.indices = this._generateIndices(vertexData.positions.length); + } + + return vertexData; + } + + /** + * Applies attribute data to VertexData if available + */ + private _applyAttribute(vertexData: VertexData, attribute: { array?: number[] } | undefined, property: "normals" | "uvs" | "colors"): void { + if (attribute?.array) { + (vertexData as any)[property] = Array.from(attribute.array); + } + } + + /** + * Generates sequential indices for vertices + */ + private _generateIndices(positionsLength: number): number[] { + const vertexCount = positionsLength / 3; + return Array.from({ length: vertexCount }, (_, i) => i); + } +} diff --git a/tools/src/effect/factories/index.ts b/tools/src/effect/factories/index.ts new file mode 100644 index 000000000..974bb9ae1 --- /dev/null +++ b/tools/src/effect/factories/index.ts @@ -0,0 +1,3 @@ +export { MaterialFactory } from "./materialFactory"; +export { GeometryFactory } from "./geometryFactory"; +export { NodeFactory } from "./nodeFactory"; diff --git a/tools/src/effect/factories/materialFactory.ts b/tools/src/effect/factories/materialFactory.ts new file mode 100644 index 000000000..71d72a168 --- /dev/null +++ b/tools/src/effect/factories/materialFactory.ts @@ -0,0 +1,317 @@ +import { Texture as BabylonTexture } from "@babylonjs/core/Materials/Textures/texture"; +import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial"; +import { Material as BabylonMaterial } from "@babylonjs/core/Materials/material"; +import { Constants } from "@babylonjs/core/Engines/constants"; +import { Tools } from "@babylonjs/core/Misc/tools"; +import { Scene } from "@babylonjs/core/scene"; +import { Color3 } from "@babylonjs/core/Maths/math.color"; + +import { Logger } from "../loggers/logger"; +import type { IMaterialFactory, ILoaderOptions, IData, IMaterial, ITexture, IImage } from "../types"; + +/** + * Factory for creating materials and textures from Three.js JSON data + */ +export class MaterialFactory implements IMaterialFactory { + private _logger: Logger; + private _scene: Scene; + private _data: IData; + private _rootUrl: string; + constructor(scene: Scene, data: IData, rootUrl: string, options?: ILoaderOptions) { + this._scene = scene; + this._data = data; + this._rootUrl = rootUrl; + this._logger = new Logger("[MaterialFactory]", options); + } + + /** + * Create a texture from material ID (for ParticleSystem - no material needed) + */ + public createTexture(materialId: string): BabylonTexture { + const textureData = this._resolveTextureData(materialId); + if (!textureData) { + return new BabylonTexture(materialId, this._scene); + } + + const { texture, image } = textureData; + const textureUrl = this._buildTextureUrl(image); + return this._createTextureFromData(textureUrl, texture); + } + + /** + * Get blend mode from material blending value + */ + public getBlendMode(materialId: string): number | undefined { + const material = this._data.materials?.find((m: any) => m.uuid === materialId); + + if (material?.blending === undefined) { + return undefined; + } + + const blendModeMap: Record = { + 0: Constants.ALPHA_DISABLE, // NoBlending + 1: Constants.ALPHA_COMBINE, // NormalBlending + 2: Constants.ALPHA_ADD, // AdditiveBlending + }; + + return blendModeMap[material.blending]; + } + + /** + * Resolves material, texture, and image data from material ID + */ + private _resolveTextureData(materialId: string): { material: IMaterial; texture: ITexture; image: IImage } | null { + if (!this._hasRequiredData()) { + this._logger.warn(`Missing materials/textures/images data for material ${materialId}`); + return null; + } + + const material = this._findMaterial(materialId); + if (!material || !material.map) { + return null; + } + + const texture = this._findTexture(material.map); + if (!texture || !texture.image) { + return null; + } + + const image = this._findImage(texture.image); + if (!image || !image.url) { + return null; + } + + return { material, texture, image }; + } + + /** + * Checks if required JSON data is available + */ + private _hasRequiredData(): boolean { + return !!(this._data.materials && this._data.textures && this._data.images); + } + + /** + * Finds material by UUID + */ + private _findMaterial(materialId: string): IMaterial | null { + const material = this._data.materials?.find((m) => m.uuid === materialId); + if (!material) { + this._logger.warn(`Material not found: ${materialId}`); + return null; + } + return material; + } + + /** + * Finds texture by UUID + */ + private _findTexture(textureId: string): ITexture | null { + const texture = this._data.textures?.find((t) => t.uuid === textureId); + if (!texture) { + this._logger.warn(`Texture not found: ${textureId}`); + return null; + } + return texture; + } + + /** + * Finds image by UUID + */ + private _findImage(imageId: string): IImage | null { + const image = this._data.images?.find((img) => img.uuid === imageId); + if (!image) { + this._logger.warn(`Image not found: ${imageId}`); + return null; + } + return image; + } + + /** + * Builds texture URL from image data + */ + private _buildTextureUrl(image: IImage): string { + if (!image.url) { + return ""; + } + const isBase64 = image.url.startsWith("data:"); + return isBase64 ? image.url : Tools.GetAssetUrl(this._rootUrl + image.url); + } + + /** + * Applies texture properties from texture data to Babylon.js texture + */ + private _applyTextureProperties(babylonTexture: BabylonTexture, texture: ITexture): void { + if (texture.wrapU !== undefined) { + babylonTexture.wrapU = texture.wrapU; + } + if (texture.wrapV !== undefined) { + babylonTexture.wrapV = texture.wrapV; + } + if (texture.uScale !== undefined) { + babylonTexture.uScale = texture.uScale; + } + if (texture.vScale !== undefined) { + babylonTexture.vScale = texture.vScale; + } + if (texture.uOffset !== undefined) { + babylonTexture.uOffset = texture.uOffset; + } + if (texture.vOffset !== undefined) { + babylonTexture.vOffset = texture.vOffset; + } + if (texture.coordinatesIndex !== undefined) { + babylonTexture.coordinatesIndex = texture.coordinatesIndex; + } + if (texture.uAng !== undefined) { + babylonTexture.uAng = texture.uAng; + } + } + + /** + * Creates Babylon.js texture from texture data + */ + private _createTextureFromData(textureUrl: string, texture: ITexture): BabylonTexture { + const samplingMode = texture.samplingMode ?? BabylonTexture.TRILINEAR_SAMPLINGMODE; + + const babylonTexture = new BabylonTexture(textureUrl, this._scene, { + noMipmap: !texture.generateMipmaps, + invertY: texture.flipY !== false, + samplingMode, + }); + + this._applyTextureProperties(babylonTexture, texture); + return babylonTexture; + } + + /** + * Create a material with texture from material ID + */ + public createMaterial(materialId: string, name: string): PBRMaterial { + this._logger.log(`Creating material for ID: ${materialId}, name: ${name}`); + + const textureData = this._resolveTextureData(materialId); + if (!textureData) { + return new PBRMaterial(name + "_material", this._scene); + } + + const { material, texture, image } = textureData; + const materialType = material.type || "MeshStandardMaterial"; + + this._logger.log(`Found material: type=${materialType}, uuid=${material.uuid}, transparent=${material.transparent}, blending=${material.blending}`); + this._logger.log(`Found texture: ${JSON.stringify({ uuid: texture.uuid, image: texture.image })}`); + const imageInfo = image.url ? (image.url.split("/").pop() || image.url).substring(0, 50) : "unknown"; + this._logger.log(`Found image: file: ${imageInfo}`); + + const textureUrl = this._buildTextureUrl(image); + const babylonTexture = this._createTextureFromData(textureUrl, texture); + const materialColor = material.color || new Color3(1, 1, 1); + + if (materialType === "MeshBasicMaterial") { + return this._createUnlitMaterial(name, material, babylonTexture, materialColor); + } + + // Create PBR material for other material types + // Note: Vertex colors are automatically used by PBR materials if mesh has vertex colors + // The VERTEXCOLOR define is set automatically based on mesh.isVerticesDataPresent(VertexBuffer.ColorKind) + const pbrMaterial = new PBRMaterial(name + "_material", this._scene); + pbrMaterial.albedoTexture = babylonTexture; + pbrMaterial.albedoColor = materialColor; + + this._applyTransparency(pbrMaterial, material, babylonTexture); + this._applyDepthWrite(pbrMaterial, material); + this._applySideSettings(pbrMaterial, material); + this._applyBlendMode(pbrMaterial, material); + + this._logger.log(`Created PBRMaterial with albedoTexture (vertex colors will be used automatically if mesh has them)`); + return pbrMaterial; + } + + /** + * Creates unlit material (MeshBasicMaterial equivalent) + */ + private _createUnlitMaterial(name: string, material: IMaterial, texture: BabylonTexture, color: Color3): PBRMaterial { + const unlitMaterial = new PBRMaterial(name + "_material", this._scene); + + unlitMaterial.unlit = true; + unlitMaterial.albedoColor = color; + unlitMaterial.albedoTexture = texture; + // Note: Vertex colors are automatically used by PBR materials if mesh has vertex colors + + this._applyTransparency(unlitMaterial, material, texture); + this._applyDepthWrite(unlitMaterial, material); + this._applySideSettings(unlitMaterial, material); + this._applyBlendMode(unlitMaterial, material); + + this._logger.log(`Using MeshBasicMaterial: PBRMaterial with unlit=true, albedoTexture (vertex colors will be used automatically if mesh has them)`); + this._logger.log(`Material created successfully: ${name}_material`); + + return unlitMaterial; + } + + /** + * Applies transparency settings to material + */ + private _applyTransparency(material: PBRMaterial, Material: IMaterial, texture: BabylonTexture): void { + if (Material.transparent) { + material.transparencyMode = BabylonMaterial.MATERIAL_ALPHABLEND; + material.needDepthPrePass = false; + texture.hasAlpha = true; + material.useAlphaFromAlbedoTexture = true; + this._logger.log(`Material is transparent (transparencyMode: ALPHABLEND, alphaMode: COMBINE)`); + } else { + material.transparencyMode = BabylonMaterial.MATERIAL_OPAQUE; + material.alpha = 1.0; + } + } + + /** + * Applies depth write settings to material + */ + private _applyDepthWrite(material: PBRMaterial, Material: IMaterial): void { + if (Material.depthWrite !== undefined) { + material.disableDepthWrite = !Material.depthWrite; + this._logger.log(`Set disableDepthWrite: ${!Material.depthWrite}`); + } else { + material.disableDepthWrite = true; + } + } + + /** + * Applies side orientation settings to material + */ + private _applySideSettings(material: PBRMaterial, Material: IMaterial): void { + material.backFaceCulling = false; + + if (Material.side !== undefined) { + material.sideOrientation = Material.side; + this._logger.log(`Set sideOrientation: ${Material.side}`); + } + } + + /** + * Applies blend mode to material + */ + private _applyBlendMode(material: PBRMaterial, Material: IMaterial): void { + if (Material.blending === undefined) { + return; + } + + const blendModeMap: Record = { + 0: Constants.ALPHA_DISABLE, // NoBlending + 1: Constants.ALPHA_COMBINE, // NormalBlending + 2: Constants.ALPHA_ADD, // AdditiveBlending + }; + + const alphaMode = blendModeMap[Material.blending]; + if (alphaMode !== undefined) { + material.alphaMode = alphaMode; + const modeNames: Record = { + 0: "NO_BLENDING", + 1: "NORMAL", + 2: "ADDITIVE", + }; + this._logger.log(`Set blend mode: ${modeNames[Material.blending]}`); + } + } +} diff --git a/tools/src/effect/factories/nodeFactory.ts b/tools/src/effect/factories/nodeFactory.ts new file mode 100644 index 000000000..557e927ed --- /dev/null +++ b/tools/src/effect/factories/nodeFactory.ts @@ -0,0 +1,521 @@ +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; +import { Scene } from "@babylonjs/core/scene"; +import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; +import { Tools } from "@babylonjs/core/Misc/tools"; +import { Quaternion } from "@babylonjs/core/Maths/math.vector"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; + +import { EffectParticleSystem, EffectSolidParticleSystem } from "../systems"; +import { IData, IGroup, IEmitter, ITransform, IParticleSystemConfig, ILoaderOptions, IMaterialFactory, IGeometryFactory, IEffectNode, isSystem } from "../types"; +import { Logger } from "../loggers/logger"; +import { CapacityCalculator, ValueUtils } from "../utils"; +import { MaterialFactory } from "./materialFactory"; +import { GeometryFactory } from "./geometryFactory"; +/** + * Factory for creating particle systems from data + * Creates all nodes, sets parents, and applies transformations in a single pass + */ +export class NodeFactory { + private _logger: Logger; + private _scene: Scene; + private _data: IData; + + private _materialFactory: IMaterialFactory; + private _geometryFactory: IGeometryFactory; + + constructor(scene: Scene, data: IData, rootUrl: string, options?: ILoaderOptions) { + this._scene = scene; + this._data = data; + this._logger = new Logger("[SystemFactory]", options); + this._materialFactory = new MaterialFactory(scene, data, rootUrl, options); + this._geometryFactory = new GeometryFactory(data, options); + } + + /** + * Create particle systems from data + * Creates all nodes, sets parents, and applies transformations in one pass + */ + public create(): IEffectNode { + if (!this._data.root) { + this._logger.warn("No root object found in data"); + const rootGroup = new TransformNode("Root", this._scene); + const rootUuid = Tools.RandomId(); + rootGroup.id = rootUuid; + + const rootNode: IEffectNode = { + name: "Root", + uuid: rootUuid, + data: rootGroup, + children: [], + type: "group", + }; + return rootNode; + } + return this._createNode(this._data.root, null); + } + /** + * Recursively process object hierarchy + * Creates nodes, sets parents, and applies transformations in one pass + */ + private _createNode(obj: IGroup | IEmitter, parentNode: IEffectNode | null): IEffectNode { + this._logger.log(`Processing object: ${obj.name}`); + + if ("children" in obj && obj.children) { + const groupNode = this._createGroupNode(obj as IGroup, parentNode); + groupNode.children = this._createChildrenNodes(obj.children, groupNode); + return groupNode; + } else { + const emitterNode = this._createParticleNode(obj as IEmitter, parentNode); + return emitterNode; + } + } + + /** + * Process children of a group recursively + */ + private _createChildrenNodes(children: (IGroup | IEmitter)[] | undefined, parentNode: IEffectNode | null): IEffectNode[] { + if (!children || children.length === 0) { + return []; + } + + this._logger.log(`Processing ${children.length} children for parent node: ${parentNode?.name || "none"}`); + return children.map((child) => { + return this._createNode(child, parentNode); + }); + } + + /** + * Create a TransformNode for a Group + */ + private _createGroupNode(group: IGroup, parentNode: IEffectNode | null): IEffectNode { + const transformNode = new TransformNode(group.name, this._scene); + transformNode.id = group.uuid; + const node: IEffectNode = { + name: group.name, + uuid: group.uuid, + children: [], + data: transformNode, + type: "group", + }; + + this._applyTransform(node, group.transform); + + if (parentNode) { + this._setParent(node, parentNode); + } + + this._logger.log(`Created group node: ${group.name}`); + return node; + } + + /** + * Create a particle system from a Emitter + */ + private _createParticleNode(emitter: IEmitter, parentNode: IEffectNode | null): IEffectNode { + const parentName = parentNode ? parentNode.name : "none"; + const systemType = emitter.systemType; + this._logger.log(`Processing emitter: ${emitter.name} (parent: ${parentName})`); + + // const cumulativeScale = this._calculateCumulativeScale(parentGroup); + + let particleSystem: EffectParticleSystem | EffectSolidParticleSystem; + + if (systemType === "solid") { + particleSystem = this._createEffectSolidParticleSystem(emitter, parentNode); + } else { + particleSystem = this._createEffectParticleSystem(emitter, parentNode); + } + + const node: IEffectNode = { + name: emitter.name, + uuid: emitter.uuid, + children: [], + data: particleSystem, + type: "particle", + }; + + this._logger.log(`Created particle system: ${emitter.name}`); + + return node; + } + + /** + * Apply common native properties to both ParticleSystem and SolidParticleSystem + */ + private _applyCommonProperties(system: EffectParticleSystem | EffectSolidParticleSystem, config: IParticleSystemConfig): void { + if (config.minSize !== undefined) { + system.minSize = config.minSize; + } + if (config.maxSize !== undefined) { + system.maxSize = config.maxSize; + } + if (config.minLifeTime !== undefined) { + system.minLifeTime = config.minLifeTime; + } + if (config.maxLifeTime !== undefined) { + system.maxLifeTime = config.maxLifeTime; + } + if (config.minEmitPower !== undefined) { + system.minEmitPower = config.minEmitPower; + } + if (config.maxEmitPower !== undefined) { + system.maxEmitPower = config.maxEmitPower; + } + if (config.emitRate !== undefined) { + system.emitRate = config.emitRate; + } + if (config.targetStopDuration !== undefined) { + system.targetStopDuration = config.targetStopDuration; + } + if (config.manualEmitCount !== undefined) { + system.manualEmitCount = config.manualEmitCount; + } + if (config.preWarmCycles !== undefined) { + system.preWarmCycles = config.preWarmCycles; + } + if (config.preWarmStepOffset !== undefined) { + system.preWarmStepOffset = config.preWarmStepOffset; + } + if (config.color1 !== undefined) { + system.color1 = config.color1; + } + if (config.color2 !== undefined) { + system.color2 = config.color2; + } + if (config.colorDead !== undefined) { + system.colorDead = config.colorDead; + } + if (config.minInitialRotation !== undefined) { + system.minInitialRotation = config.minInitialRotation; + } + if (config.maxInitialRotation !== undefined) { + system.maxInitialRotation = config.maxInitialRotation; + } + if (config.isLocal !== undefined) { + system.isLocal = config.isLocal; + } + if (config.disposeOnStop !== undefined) { + system.disposeOnStop = config.disposeOnStop; + } + if (config.gravity !== undefined) { + system.gravity = config.gravity; + } + if (config.noiseStrength !== undefined) { + system.noiseStrength = config.noiseStrength; + } + if (config.updateSpeed !== undefined) { + system.updateSpeed = config.updateSpeed; + } + if (config.minAngularSpeed !== undefined) { + system.minAngularSpeed = config.minAngularSpeed; + } + if (config.maxAngularSpeed !== undefined) { + system.maxAngularSpeed = config.maxAngularSpeed; + } + if (config.minScaleX !== undefined) { + system.minScaleX = config.minScaleX; + } + if (config.maxScaleX !== undefined) { + system.maxScaleX = config.maxScaleX; + } + if (config.minScaleY !== undefined) { + system.minScaleY = config.minScaleY; + } + if (config.maxScaleY !== undefined) { + system.maxScaleY = config.maxScaleY; + } + } + + /** + * Apply gradients (PiecewiseBezier) to both ParticleSystem and SolidParticleSystem + */ + private _applyGradients(system: EffectParticleSystem | EffectSolidParticleSystem, config: IParticleSystemConfig): void { + if (config.startSizeGradients) { + for (const grad of config.startSizeGradients) { + system.addStartSizeGradient(grad.gradient, grad.factor, grad.factor2); + } + } + if (config.lifeTimeGradients) { + for (const grad of config.lifeTimeGradients) { + system.addLifeTimeGradient(grad.gradient, grad.factor, grad.factor2); + } + } + if (config.emitRateGradients) { + for (const grad of config.emitRateGradients) { + system.addEmitRateGradient(grad.gradient, grad.factor, grad.factor2); + } + } + } + + /** + * Apply common rendering and behavior options + */ + private _applyCommonOptions(system: EffectParticleSystem | EffectSolidParticleSystem, config: IParticleSystemConfig): void { + // Rendering + if (config.renderOrder !== undefined) { + if (system instanceof EffectParticleSystem) { + system.renderingGroupId = config.renderOrder; + } else { + system.renderOrder = config.renderOrder; + } + } + if (config.layers !== undefined) { + if (system instanceof EffectParticleSystem) { + system.layerMask = config.layers; + } else { + system.layers = config.layers; + } + } + + // Billboard + if (config.isBillboardBased !== undefined) { + system.isBillboardBased = config.isBillboardBased; + } + + // Behaviors + if (config.behaviors) { + system.setBehaviors(config.behaviors); + } + } + + /** + * Apply emission bursts by converting them to emit rate gradients + * Unified approach for both ParticleSystem and SolidParticleSystem + */ + private _applyEmissionBursts(system: EffectParticleSystem | EffectSolidParticleSystem, config: IParticleSystemConfig): void { + if (!config.emissionBursts || config.emissionBursts.length === 0) { + return; + } + + const duration = config.targetStopDuration !== undefined && config.targetStopDuration > 0 ? config.targetStopDuration : 5; + const baseEmitRate = config.emitRate || 10; + for (const burst of config.emissionBursts) { + if (burst.time !== undefined && burst.count !== undefined) { + const burstTime = ValueUtils.parseConstantValue(burst.time); + const burstCount = ValueUtils.parseConstantValue(burst.count); + const timeRatio = Math.min(Math.max(burstTime / duration, 0), 1); + const windowSize = 0.02; + const burstEmitRate = burstCount / windowSize; + const beforeTime = Math.max(0, timeRatio - windowSize); + const afterTime = Math.min(1, timeRatio + windowSize); + system.addEmitRateGradient(beforeTime, baseEmitRate); + system.addEmitRateGradient(timeRatio, burstEmitRate); + system.addEmitRateGradient(afterTime, baseEmitRate); + } + } + } + + /** + * Create a ParticleSystem instance + */ + private _createEffectParticleSystem(emitter: IEmitter, _parentNode: IEffectNode | null): EffectParticleSystem { + const { name, config } = emitter; + + this._logger.log(`Creating ParticleSystem: ${name}`); + + // Calculate capacity + const duration = config.targetStopDuration !== undefined && config.targetStopDuration > 0 ? config.targetStopDuration : 5; + const emitRate = config.emitRate || 10; + const capacity = CapacityCalculator.calculateForParticleSystem(emitRate, duration); + + // Create instance (simple constructor) + const particleSystem = new EffectParticleSystem(name, capacity, this._scene); + + // Apply common properties and gradients + this._applyCommonProperties(particleSystem, config); + this._applyGradients(particleSystem, config); + + // === Настройка текстуры и blend mode === + if (emitter.materialId) { + const texture = this._materialFactory.createTexture(emitter.materialId); + if (texture) { + particleSystem.particleTexture = texture; + } + const blendMode = this._materialFactory.getBlendMode(emitter.materialId); + if (blendMode !== undefined) { + particleSystem.blendMode = blendMode; + } + } + + // === Настройка sprite tiles === + if (config.uTileCount !== undefined && config.vTileCount !== undefined) { + if (config.uTileCount > 1 || config.vTileCount > 1) { + particleSystem.isAnimationSheetEnabled = true; + particleSystem.spriteCellWidth = config.uTileCount; + particleSystem.spriteCellHeight = config.vTileCount; + if (config.startTileIndex !== undefined) { + const startTile = ValueUtils.parseConstantValue(config.startTileIndex); + particleSystem.startSpriteCellID = Math.floor(startTile); + particleSystem.endSpriteCellID = Math.floor(startTile); + } + } + } + + // Apply common rendering and behavior options + this._applyCommonOptions(particleSystem, config); + + // Apply emission bursts (converted to gradients) + this._applyEmissionBursts(particleSystem, config); + + // ParticleSystem-specific: billboard mode + if (config.billboardMode !== undefined) { + particleSystem.billboardMode = config.billboardMode; + } + + // // === Создание emitter === + // const rotationMatrix = emitter.matrix ? MatrixUtils.extractRotationMatrix(emitter.matrix) : null; + if (config.shape) { + particleSystem.configureEmitterFromShape(config.shape); + } + + this._logger.log(`ParticleSystem created: ${name}`); + return particleSystem; + } + + /** + * Create a SolidParticleSystem instance + */ + private _createEffectSolidParticleSystem(emitter: IEmitter, _parentNode: IEffectNode | null): EffectSolidParticleSystem { + const { name, config } = emitter; + + this._logger.log(`Creating SolidParticleSystem: ${name}`); + + // Create or load particle mesh + const particleMesh = this._geometryFactory.createParticleMesh(config, name, this._scene); + + if (emitter.materialId) { + const material = this._materialFactory.createMaterial(emitter.materialId, name); + if (material) { + particleMesh.material = material; + } + } + + const sps = new EffectSolidParticleSystem(name, this._scene, { + updatable: true, + }); + + this._applyCommonProperties(sps, config); + this._applyGradients(sps, config); + this._applyCommonOptions(sps, config); + this._applyEmissionBursts(sps, config); + + if (config.emissionOverDistance !== undefined) { + sps.emissionOverDistance = config.emissionOverDistance; + } + + sps.configureEmitterFromShape(config.shape); + + this._logger.log(`SolidParticleSystem created: ${name}`); + return sps; + } + + /** + * Apply transform to a node + */ + private _applyTransform(node: IEffectNode, transform: ITransform): void { + if (!transform) { + this._logger.warn(`Transform is undefined for node: ${node.name}`); + return; + } + + if (!isSystem(node.data)) { + if (transform.position && node.data.position) { + node.data.position.copyFrom(transform.position); + } + + if (transform.rotation) { + node.data.rotationQuaternion = transform.rotation.clone(); + } + + if (transform.scale && node.data.scaling) { + node.data.scaling.copyFrom(transform.scale); + } + } + + this._logger.log( + `Applied transform: pos=(${transform.position.x.toFixed(2)}, ${transform.position.y.toFixed(2)}, ${transform.position.z.toFixed(2)}), scale=(${transform.scale.x.toFixed(2)}, ${transform.scale.y.toFixed(2)}, ${transform.scale.z.toFixed(2)})` + ); + } + + /** + * Set parent for a node + */ + private _setParent(node: IEffectNode, parent: IEffectNode | null): void { + if (!parent) { + return; + } + if (isSystem(parent.data)) { + // to-do emmiter as vector3 + node.data.setParent(parent.data.emitter as AbstractMesh | null); + } else { + node.data.setParent(parent.data); + } + + this._logger.log(`Set parent: ${node.name} -> ${parent?.name || "none"}`); + } + + /** + * Create a new group node + * @param name Group name + * @param parentNode Parent node (optional) + * @returns Created group node + */ + public createGroup(name: string, parentNode: IEffectNode | null = null): IEffectNode { + const groupUuid = Tools.RandomId(); + const group: IGroup = { + uuid: groupUuid, + name, + transform: { + position: Vector3.Zero(), + rotation: Quaternion.Identity(), + scale: Vector3.One(), + }, + children: [], + }; + + return this._createGroupNode(group, parentNode); + } + + /** + * Create a new particle system node + * @param name System name + * @param systemType Type of system ("solid" or "base") + * @param config Optional particle system config + * @param parentNode Parent node (optional) + * @returns Created particle system node + */ + public createParticleSystem(name: string, systemType: "solid" | "base" = "base", config?: Partial, parentNode: IEffectNode | null = null): IEffectNode { + const systemUuid = Tools.RandomId(); + const defaultConfig: IParticleSystemConfig = { + systemType, + targetStopDuration: 0, // looping + manualEmitCount: -1, + emitRate: 10, + minLifeTime: 1, + maxLifeTime: 1, + minEmitPower: 1, + maxEmitPower: 1, + minSize: 1, + maxSize: 1, + color1: new Color4(1, 1, 1, 1), + color2: new Color4(1, 1, 1, 1), + behaviors: [], + ...config, + }; + + const emitter: IEmitter = { + uuid: systemUuid, + name, + transform: { + position: Vector3.Zero(), + rotation: Quaternion.Identity(), + scale: Vector3.One(), + }, + config: defaultConfig, + systemType, + }; + + return this._createParticleNode(emitter, parentNode); + } +} diff --git a/tools/src/effect/index.ts b/tools/src/effect/index.ts new file mode 100644 index 000000000..013ac32fb --- /dev/null +++ b/tools/src/effect/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./factories"; +export * from "./utils"; +export * from "./systems"; +export * from "./effect"; +export * from "./emitters"; +export * from "./behaviors"; +export * from "./loggers"; diff --git a/tools/src/effect/loggers/index.ts b/tools/src/effect/loggers/index.ts new file mode 100644 index 000000000..41c7bf273 --- /dev/null +++ b/tools/src/effect/loggers/index.ts @@ -0,0 +1 @@ +export * from "./logger"; diff --git a/tools/src/effect/loggers/logger.ts b/tools/src/effect/loggers/logger.ts new file mode 100644 index 000000000..838a885f7 --- /dev/null +++ b/tools/src/effect/loggers/logger.ts @@ -0,0 +1,40 @@ +import { Logger as BabylonLogger } from "@babylonjs/core/Misc/logger"; +import type { ILoaderOptions } from "../types"; + +/** + * Logger utility for operations + */ +export class Logger { + private _prefix: string; + private _options?: ILoaderOptions; + + constructor(prefix: string = "[]", options?: ILoaderOptions) { + this._prefix = prefix; + this._options = options; + } + + /** + * Log a message if verbose mode is enabled + */ + public log(message: string): void { + if (this._options?.verbose) { + BabylonLogger.Log(`${this._prefix} ${message}`); + } + } + + /** + * Log a warning if verbose or validate mode is enabled + */ + public warn(message: string): void { + if (this._options?.verbose || this._options?.validate) { + BabylonLogger.Warn(`${this._prefix} ${message}`); + } + } + + /** + * Log an error + */ + public error(message: string): void { + BabylonLogger.Error(`${this._prefix} ${message}`); + } +} diff --git a/tools/src/effect/systems/effectParticleSystem.ts b/tools/src/effect/systems/effectParticleSystem.ts new file mode 100644 index 000000000..6b158f79f --- /dev/null +++ b/tools/src/effect/systems/effectParticleSystem.ts @@ -0,0 +1,266 @@ +import { ParticleSystem } from "@babylonjs/core/Particles/particleSystem"; +import { Scene } from "@babylonjs/core/scene"; +import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; +import { Particle } from "@babylonjs/core/Particles/particle"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import type { + Behavior, + IColorOverLifeBehavior, + ISizeOverLifeBehavior, + IRotationOverLifeBehavior, + IForceOverLifeBehavior, + IGravityForceBehavior, + ISpeedOverLifeBehavior, + IFrameOverLifeBehavior, + ILimitSpeedOverLifeBehavior, + IColorBySpeedBehavior, + ISizeBySpeedBehavior, + IRotationBySpeedBehavior, + IOrbitOverLifeBehavior, + PerParticleBehaviorFunction, + ISystem, + ParticleWithSystem, + IShape, +} from "../types"; +import { + applyColorOverLifePS, + applySizeOverLifePS, + applyRotationOverLifePS, + applyForceOverLifePS, + applyGravityForcePS, + applySpeedOverLifePS, + applyFrameOverLifePS, + applyLimitSpeedOverLifePS, + applyColorBySpeedPS, + applySizeBySpeedPS, + applyRotationBySpeedPS, + applyOrbitOverLifePS, +} from "../behaviors"; + +/** + * Extended ParticleSystem with behaviors support + * Integrates per-particle behaviors (ColorBySpeed, OrbitOverLife, etc.) + * into the native Babylon.js particle update loop + */ +export class EffectParticleSystem extends ParticleSystem implements ISystem { + private _perParticleBehaviors: PerParticleBehaviorFunction[]; + private _behaviorConfigs: Behavior[]; + private _parent: AbstractMesh | TransformNode | null; + + /** Store reference to default updateFunction */ + private _defaultUpdateFunction: (particles: Particle[]) => void; + + constructor(name: string, capacity: number, scene: Scene) { + super(name, capacity, scene); + this._perParticleBehaviors = []; + this._behaviorConfigs = []; + + // Store reference to the default updateFunction created by ParticleSystem + this._defaultUpdateFunction = this.updateFunction; + + // Override updateFunction to integrate per-particle behaviors + this._setupCustomUpdateFunction(); + } + + public get parent(): AbstractMesh | TransformNode | null { + return this._parent; + } + + public set parent(parent: AbstractMesh | TransformNode | null) { + this._parent = parent; + } + + public setParent(parent: AbstractMesh | TransformNode | null): void { + this._parent = parent; + } + + /** + * Setup custom updateFunction that extends default behavior + * with per-particle behavior execution + */ + private _setupCustomUpdateFunction(): void { + this.updateFunction = (particles: Particle[]): void => { + // First, run the default Babylon.js update logic + // This handles: age, gradients (color, size, angular speed, velocity), position, gravity, etc. + this._defaultUpdateFunction(particles); + + // Then apply per-particle behaviors if any exist + if (this._perParticleBehaviors.length === 0) { + return; + } + + // Apply per-particle behaviors to each active particle + for (const particle of particles) { + // Attach system reference for behaviors that need it + (particle as ParticleWithSystem).particleSystem = this; + + // Execute all per-particle behavior functions + for (const behaviorFn of this._perParticleBehaviors) { + behaviorFn(particle); + } + } + }; + } + + /** + * Get the parent node (emitter) for hierarchy operations + * Required by ISystem interface + */ + public getParentNode(): AbstractMesh | TransformNode | null { + return this.emitter instanceof AbstractMesh ? this.emitter : null; + } + + /** + * Get current behavior configurations + */ + public get behaviorConfigs(): Behavior[] { + return this._behaviorConfigs; + } + + /** + * Set behaviors and apply them to the particle system + * System-level behaviors configure gradients, per-particle behaviors run each frame + */ + public setBehaviors(behaviors: Behavior[]): void { + this._behaviorConfigs = behaviors; + + // Apply system-level behaviors (gradients) to ParticleSystem + this._applySystemLevelBehaviors(behaviors); + + // Build per-particle behavior functions for update loop + this._perParticleBehaviors = this._buildPerParticleBehaviors(behaviors); + } + + /** + * Add a single behavior + */ + public addBehavior(behavior: Behavior): void { + this._behaviorConfigs.push(behavior); + this.setBehaviors(this._behaviorConfigs); + } + + /** + * Build per-particle behavior functions from configurations + * Per-particle behaviors run each frame for each particle (ColorBySpeed, OrbitOverLife, etc.) + */ + private _buildPerParticleBehaviors(behaviors: Behavior[]): PerParticleBehaviorFunction[] { + const functions: PerParticleBehaviorFunction[] = []; + + for (const behavior of behaviors) { + switch (behavior.type) { + case "ColorBySpeed": { + const b = behavior as IColorBySpeedBehavior; + functions.push((particle: Particle) => applyColorBySpeedPS(b, particle)); + break; + } + + case "SizeBySpeed": { + const b = behavior as ISizeBySpeedBehavior; + functions.push((particle: Particle) => applySizeBySpeedPS(particle, b)); + break; + } + + case "RotationBySpeed": { + const b = behavior as IRotationBySpeedBehavior; + functions.push((particle: Particle) => applyRotationBySpeedPS(particle, b)); + break; + } + + case "OrbitOverLife": { + const b = behavior as IOrbitOverLifeBehavior; + functions.push((particle: Particle) => applyOrbitOverLifePS(particle, b)); + break; + } + } + } + + return functions; + } + + /** + * Apply system-level behaviors (gradients) to ParticleSystem + * These configure native Babylon.js gradients once, not per-particle + */ + private _applySystemLevelBehaviors(behaviors: Behavior[]): void { + for (const behavior of behaviors) { + if (!behavior.type) { + continue; + } + + switch (behavior.type) { + case "ColorOverLife": + applyColorOverLifePS(this, behavior as IColorOverLifeBehavior); + break; + case "SizeOverLife": + applySizeOverLifePS(this, behavior as ISizeOverLifeBehavior); + break; + case "RotationOverLife": + case "Rotation3DOverLife": + applyRotationOverLifePS(this, behavior as IRotationOverLifeBehavior); + break; + case "ForceOverLife": + case "ApplyForce": + applyForceOverLifePS(this, behavior as IForceOverLifeBehavior); + break; + case "GravityForce": + applyGravityForcePS(this, behavior as IGravityForceBehavior); + break; + case "SpeedOverLife": + applySpeedOverLifePS(this, behavior as ISpeedOverLifeBehavior); + break; + case "FrameOverLife": + applyFrameOverLifePS(this, behavior as IFrameOverLifeBehavior); + break; + case "LimitSpeedOverLife": + applyLimitSpeedOverLifePS(this, behavior as ILimitSpeedOverLifeBehavior); + break; + } + } + } + + /** + * Configure emitter from shape config + * This replaces the need for EmitterFactory + */ + public configureEmitterFromShape(shape: IShape): void { + if (!shape || !shape.type) { + this.createPointEmitter(new Vector3(0, 1, 0), new Vector3(0, 1, 0)); + return; + } + + const shapeType = shape.type.toLowerCase(); + const radius = shape.radius ?? 1; + const angle = shape.angle ?? Math.PI / 4; + + switch (shapeType) { + case "cone": + this.createConeEmitter(radius, angle); + break; + case "sphere": + this.createSphereEmitter(radius); + break; + case "point": + this.createPointEmitter(new Vector3(0, 1, 0), new Vector3(0, 1, 0)); + break; + case "box": { + const boxSize = shape.size || [1, 1, 1]; + const minBox = new Vector3(-boxSize[0] / 2, -boxSize[1] / 2, -boxSize[2] / 2); + const maxBox = new Vector3(boxSize[0] / 2, boxSize[1] / 2, boxSize[2] / 2); + this.createBoxEmitter(new Vector3(0, 1, 0), new Vector3(0, 1, 0), minBox, maxBox); + break; + } + case "hemisphere": + this.createHemisphericEmitter(radius); + break; + case "cylinder": { + const height = shape.height ?? 1; + this.createCylinderEmitter(radius, height); + break; + } + default: + this.createPointEmitter(new Vector3(0, 1, 0), new Vector3(0, 1, 0)); + break; + } + } +} diff --git a/tools/src/effect/systems/effectSolidParticleSystem.ts b/tools/src/effect/systems/effectSolidParticleSystem.ts new file mode 100644 index 000000000..72d2e29c0 --- /dev/null +++ b/tools/src/effect/systems/effectSolidParticleSystem.ts @@ -0,0 +1,1352 @@ +import { Quaternion, Vector3, Matrix } from "@babylonjs/core/Maths/math.vector"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; +import { Mesh } from "@babylonjs/core/Meshes/mesh"; +import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; +import { SolidParticleSystem } from "@babylonjs/core/Particles/solidParticleSystem"; +import type { + Behavior, + IForceOverLifeBehavior, + IColorBySpeedBehavior, + ISizeBySpeedBehavior, + IRotationBySpeedBehavior, + IOrbitOverLifeBehavior, + IEmissionBurst, + ISolidParticleEmitterType, + PerSolidParticleBehaviorFunction, + ISystem, + SolidParticleWithSystem, + Value, +} from "../types"; +import { + SolidPointParticleEmitter, + SolidSphereParticleEmitter, + SolidConeParticleEmitter, + SolidBoxParticleEmitter, + SolidHemisphericParticleEmitter, + SolidCylinderParticleEmitter, +} from "../emitters"; +import { ValueUtils, CapacityCalculator, ColorGradientSystem, NumberGradientSystem } from "../utils"; +import { + applyColorOverLifeSPS, + applyLimitSpeedOverLifeSPS, + applyRotationOverLifeSPS, + applySizeOverLifeSPS, + applySpeedOverLifeSPS, + interpolateColorKeys, + interpolateGradientKeys, + extractNumberFromValue, +} from "../behaviors"; + +/** + * Emission state matching three.quarks EmissionState structure + */ +interface IEmissionState { + time: number; + waitEmiting: number; + travelDistance: number; + previousWorldPos?: Vector3; + burstIndex: number; + burstWaveIndex: number; + burstParticleIndex: number; + burstParticleCount: number; + isBursting: boolean; +} + +/** + * Extended SolidParticleSystem implementing three.quarks Mesh systemType (systemType = "solid") logic + * This class replicates the exact behavior of three.quarks ParticleSystem with systemType = "solid" + */ +export class EffectSolidParticleSystem extends SolidParticleSystem implements ISystem { + private _emissionState: IEmissionState; + private _behaviors: PerSolidParticleBehaviorFunction[]; + public particleEmitterType: ISolidParticleEmitterType | null; + private _emitEnded: boolean; + private _emitter: AbstractMesh | null; + private _parent: AbstractMesh | TransformNode | null; + // Gradient systems for "OverLife" behaviors (similar to ParticleSystem native gradients) + private _colorGradients: ColorGradientSystem; + private _sizeGradients: NumberGradientSystem; + private _velocityGradients: NumberGradientSystem; + private _angularSpeedGradients: NumberGradientSystem; + private _limitVelocityGradients: NumberGradientSystem; + private _limitVelocityDamping: number; + + // === Native Babylon.js properties (like ParticleSystem) === + public minSize: number = 1; + public maxSize: number = 1; + public minLifeTime: number = 1; + public maxLifeTime: number = 1; + public minEmitPower: number = 1; + public maxEmitPower: number = 1; + public emitRate: number = 10; + public targetStopDuration: number = 5; + public manualEmitCount: number = -1; + public preWarmCycles: number = 0; + public preWarmStepOffset: number = 0.016; + public color1: Color4 = new Color4(1, 1, 1, 1); + public color2: Color4 = new Color4(1, 1, 1, 1); + public colorDead: Color4 = new Color4(1, 1, 1, 0); + public minInitialRotation: number = 0; + public maxInitialRotation: number = 0; + public isLocal: boolean = false; + public disposeOnStop: boolean = false; + public gravity?: Vector3; + public noiseStrength?: Vector3; + // Note: inherited from SolidParticleSystem, default is 0.01 + // We don't override it, using the base class default + public minAngularSpeed: number = 0; + public maxAngularSpeed: number = 0; + public minScaleX: number = 1; + public maxScaleX: number = 1; + public minScaleY: number = 1; + public maxScaleY: number = 1; + + // Gradients for PiecewiseBezier (like ParticleSystem) + private _startSizeGradients: NumberGradientSystem; + private _lifeTimeGradients: NumberGradientSystem; + private _emitRateGradients: NumberGradientSystem; + + // === Other properties === + public emissionOverDistance?: Value; // For distance-based emission + public emissionBursts?: IEmissionBurst[]; // Legacy: converted to gradients in Factory + public renderOrder?: number; + public layers?: number; + public isBillboardBased?: boolean; + private _behaviorConfigs: Behavior[]; + + /** + * Get current behavior configurations + */ + public get behaviorConfigs(): Behavior[] { + return this._behaviorConfigs; + } + + /** + * Set behaviors and apply them to the system + */ + public setBehaviors(behaviors: Behavior[]): void { + this._behaviorConfigs = behaviors; + this._applyBehaviors(); + } + + /** + * Add a single behavior + */ + public addBehavior(behavior: Behavior): void { + this._behaviorConfigs.push(behavior); + this._applyBehaviors(); + } + + /** + * Apply behaviors - system-level (gradients) and per-particle + */ + private _applyBehaviors(): void { + // Clear existing gradients + this._colorGradients.clear(); + this._sizeGradients.clear(); + this._velocityGradients.clear(); + this._angularSpeedGradients.clear(); + this._limitVelocityGradients.clear(); + + // Apply system-level behaviors (gradients) + this._applySystemLevelBehaviors(); + + // Build per-particle behavior functions + this._behaviors = this._buildPerParticleBehaviors(this._behaviorConfigs); + } + + /** + * Add start size gradient (like ParticleSystem) + */ + public addStartSizeGradient(gradient: number, factor: number, factor2?: number): void { + if (factor2 !== undefined) { + this._startSizeGradients.addGradient(gradient, factor); + this._startSizeGradients.addGradient(gradient, factor2); + } else { + this._startSizeGradients.addGradient(gradient, factor); + } + } + + /** + * Add life time gradient (like ParticleSystem) + */ + public addLifeTimeGradient(gradient: number, factor: number, factor2?: number): void { + if (factor2 !== undefined) { + this._lifeTimeGradients.addGradient(gradient, factor); + this._lifeTimeGradients.addGradient(gradient, factor2); + } else { + this._lifeTimeGradients.addGradient(gradient, factor); + } + } + + /** + * Add emit rate gradient (like ParticleSystem) + */ + public addEmitRateGradient(gradient: number, factor: number, factor2?: number): void { + if (factor2 !== undefined) { + this._emitRateGradients.addGradient(gradient, factor); + this._emitRateGradients.addGradient(gradient, factor2); + } else { + this._emitRateGradients.addGradient(gradient, factor); + } + } + + /** + * Get the parent node (mesh) for hierarchy operations + * Implements ISystem interface + */ + public getParentNode(): AbstractMesh | TransformNode | null { + return this.mesh || null; + } + + public get parent(): AbstractMesh | TransformNode | null { + return this._parent; + } + + public set parent(parent: AbstractMesh | TransformNode | null) { + this._parent = parent; + } + + public setParent(parent: AbstractMesh | TransformNode | null): void { + this._parent = parent; + } + /** + * Emitter property (like ParticleSystem) + * Sets the parent for the mesh - the point from which particles emit + */ + public get emitter(): AbstractMesh | null { + return this._emitter; + } + public set emitter(value: AbstractMesh | null) { + this._emitter = value; + // If mesh is already created, set its parent + if (this.mesh && value) { + this.mesh.setParent(value, false, true); + } + } + + /** + * Set particle mesh to use for rendering + * Initializes the SPS with this mesh + */ + public set particleMesh(mesh: Mesh) { + this._initializeMesh(mesh); + } + + /** + * Replace the current particle mesh with a new one + * This completely rebuilds the SPS with the new geometry + */ + public replaceParticleMesh(newMesh: Mesh): void { + if (!newMesh) { + return; + } + + // Stop the system before rebuilding + const wasStarted = this._started; + if (wasStarted) { + this.stop(); + } + + // Dispose old mesh if exists + if (this.mesh) { + this.mesh.dispose(false, true); + } + + // Clear all particles and shapes + this.particles = []; + this.nbParticles = 0; + + // Calculate capacity (same as before) + const isLooping = this.targetStopDuration === 0; + const capacity = CapacityCalculator.calculateForSolidParticleSystem(this.emitRate, this.targetStopDuration, isLooping); + + // Add new shape + this.addShape(newMesh, capacity); + + // Set billboard mode + if (this.isBillboardBased !== undefined) { + this.billboard = this.isBillboardBased; + } else { + this.billboard = false; + } + + // Build mesh + this.buildMesh(); + this._setupMeshProperties(); + + // Initialize all particles as dead/invisible + this._initializeDeadParticles(); + this.setParticles(); + + // Dispose the source mesh (SPS has already cloned it) + newMesh.dispose(); + + // Restart if was running + if (wasStarted) { + this.start(); + } + } + + /** + * Start the particle system + * Overrides base class to ensure proper initialization + */ + public override start(delay = 0): void { + // Call base class start + super.start(delay); + + // Reset emission state when starting + if (delay === 0) { + this._emissionState.time = 0; + this._emissionState.waitEmiting = 0; + this._emissionState.travelDistance = 0; + this._emissionState.burstIndex = 0; + this._emissionState.burstWaveIndex = 0; + this._emissionState.burstParticleIndex = 0; + this._emissionState.burstParticleCount = 0; + this._emissionState.isBursting = false; + this._emitEnded = false; + } + } + + /** + * Stop the particle system + * Overrides base class to hide all particles when stopped + */ + public override stop(): void { + // Hide all particles before stopping + const particles = this.particles; + const nbParticles = this.nbParticles; + for (let i = 0; i < nbParticles; i++) { + const particle = particles[i]; + if (particle.alive) { + particle.isVisible = false; + } + } + + // Update particles to apply visibility changes + this.setParticles(); + + // Call base class stop + super.stop(); + } + + /** + * Reset the particle system (stop and clear all particles) + * Stops emission, resets emission state, and rebuilds particles to initial state + */ + public reset(): void { + // Stop the system if it's running + this.stop(); + + // Reset emission state + this._emissionState.time = 0; + this._emissionState.waitEmiting = 0; + this._emissionState.travelDistance = 0; + this._emissionState.burstIndex = 0; + this._emissionState.burstWaveIndex = 0; + this._emissionState.burstParticleIndex = 0; + this._emissionState.burstParticleCount = 0; + this._emissionState.isBursting = false; + this._emitEnded = false; + + // Rebuild mesh to reset all particles to initial state (reset=true) + this.rebuildMesh(true); + } + + /** + * Get behavior functions (internal use) + */ + public get behaviors(): PerSolidParticleBehaviorFunction[] { + return this._behaviors; + } + + /** + * Add color gradient (for ColorOverLife behavior) + */ + public addColorGradient(gradient: number, color: Color4): void { + this._colorGradients.addGradient(gradient, color); + } + + /** + * Add size gradient (for SizeOverLife behavior) + */ + public addSizeGradient(gradient: number, size: number): void { + this._sizeGradients.addGradient(gradient, size); + } + + /** + * Add velocity gradient (for SpeedOverLife behavior) + */ + public addVelocityGradient(gradient: number, velocity: number): void { + this._velocityGradients.addGradient(gradient, velocity); + } + + /** + * Add angular speed gradient (for RotationOverLife behavior) + */ + public addAngularSpeedGradient(gradient: number, angularSpeed: number): void { + this._angularSpeedGradients.addGradient(gradient, angularSpeed); + } + + /** + * Add limit velocity gradient (for LimitSpeedOverLife behavior) + */ + public addLimitVelocityGradient(gradient: number, limit: number): void { + this._limitVelocityGradients.addGradient(gradient, limit); + } + + /** + * Set limit velocity damping (for LimitSpeedOverLife behavior) + */ + public set limitVelocityDamping(value: number) { + this._limitVelocityDamping = value; + } + + public get limitVelocityDamping(): number { + return this._limitVelocityDamping; + } + + /** + * Initialize mesh for SPS (internal use) + * Adds the mesh as a shape and configures billboard mode + */ + private _initializeMesh(particleMesh: Mesh): void { + if (!particleMesh) { + return; + } + + const isLooping = this.targetStopDuration === 0; + const capacity = CapacityCalculator.calculateForSolidParticleSystem(this.emitRate, this.targetStopDuration, isLooping); + this.addShape(particleMesh, capacity); + + if (this.isBillboardBased !== undefined) { + this.billboard = this.isBillboardBased; + } else { + this.billboard = false; + } + + this.buildMesh(); + this._setupMeshProperties(); + + // Initialize all particles as dead/invisible immediately after build + this._initializeDeadParticles(); + this.setParticles(); // Apply visibility changes to mesh + + particleMesh.dispose(); + } + + private _normalMatrix: Matrix; + private _tempVec: Vector3; + private _tempVec2: Vector3; + private _tempQuat: Quaternion; + + constructor( + name: string, + scene: any, + options?: { + updatable?: boolean; + isPickable?: boolean; + enableDepthSort?: boolean; + particleIntersection?: boolean; + boundingSphereOnly?: boolean; + bSphereRadiusFactor?: number; + expandable?: boolean; + useModelMaterial?: boolean; + enableMultiMaterial?: boolean; + computeBoundingBox?: boolean; + autoFixFaceOrientation?: boolean; + } + ) { + super(name, scene, options); + + this.name = name; + this._behaviors = []; + this.particleEmitterType = new SolidBoxParticleEmitter(); // Default emitter (like ParticleSystem) + this._emitter = null; + + // Gradient systems for "OverLife" behaviors + this._colorGradients = new ColorGradientSystem(); + this._sizeGradients = new NumberGradientSystem(); + this._velocityGradients = new NumberGradientSystem(); + this._angularSpeedGradients = new NumberGradientSystem(); + this._limitVelocityGradients = new NumberGradientSystem(); + this._limitVelocityDamping = 0.1; + + // Gradients for PiecewiseBezier (like ParticleSystem) + this._startSizeGradients = new NumberGradientSystem(); + this._lifeTimeGradients = new NumberGradientSystem(); + this._emitRateGradients = new NumberGradientSystem(); + + this._behaviorConfigs = []; + this._behaviors = []; + + this._emitEnded = false; + this._normalMatrix = new Matrix(); + this._tempVec = Vector3.Zero(); + this._tempVec2 = Vector3.Zero(); + this._tempQuat = Quaternion.Identity(); + + this.updateParticle = this._updateParticle.bind(this); + + this._emissionState = { + time: 0, + waitEmiting: 0, + travelDistance: 0, + burstIndex: 0, + burstWaveIndex: 0, + burstParticleIndex: 0, + burstParticleCount: 0, + isBursting: false, + }; + } + + /** + * Find a dead particle for recycling + */ + private _findDeadParticle(): SolidParticle | null { + const particles = this.particles; + const nbParticles = this.nbParticles; + for (let j = 0; j < nbParticles; j++) { + if (!particles[j].alive) { + return particles[j]; + } + } + return null; + } + + /** + * Reset particle to initial state for recycling + */ + private _resetParticle(particle: SolidParticle): void { + particle.age = 0; + particle.alive = true; + particle.isVisible = true; + particle._stillInvisible = false; + particle.position.setAll(0); + particle.velocity.setAll(0); + particle.rotation.setAll(0); + particle.scaling.setAll(1); + + if (particle.color) { + particle.color.set(1, 1, 1, 1); + } else { + particle.color = new Color4(1, 1, 1, 1); + } + + const props = (particle.props ||= {}); + props.speedModifier = 1.0; + } + + /** + * Initialize particle color + */ + private _initializeParticleColor(particle: SolidParticle): void { + const props = particle.props!; + props.startColor = this.color1.clone(); + if (particle.color) { + particle.color.copyFrom(this.color1); + } else { + particle.color = this.color1.clone(); + } + } + + /** + * Initialize particle speed + * Uses minEmitPower/maxEmitPower like ParticleSystem + */ + private _initializeParticleSpeed(particle: SolidParticle): void { + const props = particle.props!; + // Simply use random between min and max emit power (like ParticleSystem) + props.startSpeed = this._randomRange(this.minEmitPower, this.maxEmitPower); + } + + /** + * Initialize particle lifetime + */ + private _initializeParticleLife(particle: SolidParticle, normalizedTime: number): void { + // Use min/max or gradient + const lifeTimeGradients = this._lifeTimeGradients.getGradients(); + if (lifeTimeGradients.length > 0 && this.targetStopDuration > 0) { + const ratio = Math.max(0, Math.min(1, normalizedTime)); + const gradientValue = this._lifeTimeGradients.getValue(ratio); + if (gradientValue !== null) { + particle.lifeTime = gradientValue; + return; + } + } + particle.lifeTime = this._randomRange(this.minLifeTime, this.maxLifeTime); + } + + /** + * Initialize particle size + * Uses minSize/maxSize and minScaleX/maxScaleX/minScaleY/maxScaleY (like ParticleSystem) + */ + private _initializeParticleSize(particle: SolidParticle, normalizedTime: number): void { + const props = particle.props!; + // Use min/max or gradient for base size + let sizeValue: number; + const startSizeGradients = this._startSizeGradients.getGradients(); + if (startSizeGradients.length > 0 && this.targetStopDuration > 0) { + const ratio = Math.max(0, Math.min(1, normalizedTime)); + const gradientValue = this._startSizeGradients.getValue(ratio); + if (gradientValue !== null) { + sizeValue = gradientValue; + } else { + sizeValue = this._randomRange(this.minSize, this.maxSize); + } + } else { + sizeValue = this._randomRange(this.minSize, this.maxSize); + } + props.startSize = sizeValue; + + // Apply scale modifiers (like ParticleSystem: scale.copyFromFloats) + const scaleX = this._randomRange(this.minScaleX, this.maxScaleX); + const scaleY = this._randomRange(this.minScaleY, this.maxScaleY); + props.startScaleX = scaleX; + props.startScaleY = scaleY; + particle.scaling.set(sizeValue * scaleX, sizeValue * scaleY, sizeValue); + } + + /** + * Random range helper + */ + private _randomRange(min: number, max: number): number { + return min + Math.random() * (max - min); + } + + /** + * Initialize particle rotation and angular speed + * Uses minInitialRotation/maxInitialRotation and minAngularSpeed/maxAngularSpeed (like ParticleSystem) + */ + private _initializeParticleRotation(particle: SolidParticle, _normalizedTime: number): void { + const props = particle.props!; + const angleZ = this._randomRange(this.minInitialRotation, this.maxInitialRotation); + particle.rotation.set(0, 0, angleZ); + // Store angular speed for per-frame rotation (like ParticleSystem) + props.startAngularSpeed = this._randomRange(this.minAngularSpeed, this.maxAngularSpeed); + } + + /** + * Spawn particles from dead pool + */ + private _spawn(count: number): void { + if (count <= 0) { + return; + } + + const emissionState = this._emissionState; + + const emitterMatrix = this._getEmitterMatrix(); + const translation = this._tempVec; + const quaternion = this._tempQuat; + const scale = this._tempVec2; + emitterMatrix.decompose(scale, quaternion, translation); + emitterMatrix.toNormalMatrix(this._normalMatrix); + + const normalizedTime = this.targetStopDuration > 0 ? this._emissionState.time / this.targetStopDuration : 0; + + for (let i = 0; i < count; i++) { + emissionState.burstParticleIndex = i; + + const particle = this._findDeadParticle(); + if (!particle) { + break; + } + + this._resetParticle(particle); + this._initializeParticleColor(particle); + this._initializeParticleSpeed(particle); + this._initializeParticleLife(particle, normalizedTime); + this._initializeParticleSize(particle, normalizedTime); + this._initializeParticleRotation(particle, normalizedTime); + this._initializeEmitterShape(particle); + } + } + + /** + * Initialize emitter shape for particle using particleEmitterType + */ + private _initializeEmitterShape(particle: SolidParticle): void { + const startSpeed = particle.props?.startSpeed ?? 0; + if (this.particleEmitterType) { + this.particleEmitterType.initializeParticle(particle, startSpeed); + } else { + particle.position.setAll(0); + particle.velocity.set(0, 1, 0); + particle.velocity.scaleInPlace(startSpeed); + } + } + + /** + * Create point emitter for SolidParticleSystem + */ + public createPointEmitter(): SolidPointParticleEmitter { + const emitter = new SolidPointParticleEmitter(); + this.particleEmitterType = emitter; + return emitter; + } + + /** + * Create sphere emitter for SolidParticleSystem + */ + public createSphereEmitter(radius: number = 1, arc: number = Math.PI * 2, thickness: number = 1): SolidSphereParticleEmitter { + const emitter = new SolidSphereParticleEmitter(radius, arc, thickness); + this.particleEmitterType = emitter; + return emitter; + } + + /** + * Create cone emitter for SolidParticleSystem + */ + public createConeEmitter(radius: number = 1, arc: number = Math.PI * 2, thickness: number = 1, angle: number = Math.PI / 6): SolidConeParticleEmitter { + const emitter = new SolidConeParticleEmitter(radius, arc, thickness, angle); + this.particleEmitterType = emitter; + return emitter; + } + + /** + * Create box emitter for SolidParticleSystem + */ + public createBoxEmitter( + direction1: Vector3 = new Vector3(0, 1, 0), + direction2: Vector3 = new Vector3(0, 1, 0), + minEmitBox: Vector3 = new Vector3(-0.5, -0.5, -0.5), + maxEmitBox: Vector3 = new Vector3(0.5, 0.5, 0.5) + ): SolidBoxParticleEmitter { + const emitter = new SolidBoxParticleEmitter(direction1, direction2, minEmitBox, maxEmitBox); + this.particleEmitterType = emitter; + return emitter; + } + + /** + * Create hemispheric emitter for SolidParticleSystem + */ + public createHemisphericEmitter(radius: number = 1, radiusRange: number = 1, directionRandomizer: number = 0): SolidHemisphericParticleEmitter { + const emitter = new SolidHemisphericParticleEmitter(radius, radiusRange, directionRandomizer); + this.particleEmitterType = emitter; + return emitter; + } + + /** + * Create cylinder emitter for SolidParticleSystem + */ + public createCylinderEmitter(radius: number = 1, height: number = 1, radiusRange: number = 1, directionRandomizer: number = 0): SolidCylinderParticleEmitter { + const emitter = new SolidCylinderParticleEmitter(radius, height, radiusRange, directionRandomizer); + this.particleEmitterType = emitter; + return emitter; + } + + /** + * Configure emitter from shape config + * This replaces the need for EmitterFactory + */ + public configureEmitterFromShape(shape: any): void { + if (!shape || !shape.type) { + this.createPointEmitter(); + return; + } + + const shapeType = shape.type.toLowerCase(); + const radius = shape.radius ?? 1; + const arc = shape.arc ?? Math.PI * 2; + const thickness = shape.thickness ?? 1; + const angle = shape.angle ?? Math.PI / 6; + const height = shape.height ?? 1; + const radiusRange = shape.radiusRange ?? 1; + const directionRandomizer = shape.directionRandomizer ?? 0; + + switch (shapeType) { + case "sphere": + this.createSphereEmitter(radius, arc, thickness); + break; + case "cone": + this.createConeEmitter(radius, arc, thickness, angle); + break; + case "box": { + const minEmitBox = shape.minEmitBox + ? new Vector3(shape.minEmitBox[0] ?? -0.5, shape.minEmitBox[1] ?? -0.5, shape.minEmitBox[2] ?? -0.5) + : new Vector3(-0.5, -0.5, -0.5); + const maxEmitBox = shape.maxEmitBox ? new Vector3(shape.maxEmitBox[0] ?? 0.5, shape.maxEmitBox[1] ?? 0.5, shape.maxEmitBox[2] ?? 0.5) : new Vector3(0.5, 0.5, 0.5); + const direction1 = shape.direction1 ? new Vector3(shape.direction1[0] ?? 0, shape.direction1[1] ?? 1, shape.direction1[2] ?? 0) : new Vector3(0, 1, 0); + const direction2 = shape.direction2 ? new Vector3(shape.direction2[0] ?? 0, shape.direction2[1] ?? 1, shape.direction2[2] ?? 0) : new Vector3(0, 1, 0); + this.createBoxEmitter(direction1, direction2, minEmitBox, maxEmitBox); + break; + } + case "hemisphere": + this.createHemisphericEmitter(radius, radiusRange, directionRandomizer); + break; + case "cylinder": + this.createCylinderEmitter(radius, height, radiusRange, directionRandomizer); + break; + case "point": + this.createPointEmitter(); + break; + default: + this.createPointEmitter(); + break; + } + } + + private _getEmitterMatrix(): Matrix { + const matrix = Matrix.Identity(); + if (this.mesh) { + this.mesh.computeWorldMatrix(true); + matrix.copyFrom(this.mesh.getWorldMatrix()); + } + return matrix; + } + + private _handleEmissionLooping(): void { + const emissionState = this._emissionState; + const isLooping = this.targetStopDuration === 0; + const duration = isLooping ? 5 : this.targetStopDuration; // Use default 5s for looping + + if (emissionState.time > duration) { + if (isLooping) { + // Loop: reset time and burst index + emissionState.time -= duration; + emissionState.burstIndex = 0; + } else if (!this._emitEnded) { + // Not looping: end emission + this._emitEnded = true; + } + } + } + + private _spawnFromWaitEmiting(): void { + const emissionState = this._emissionState; + const totalSpawn = Math.floor(emissionState.waitEmiting); + if (totalSpawn > 0) { + this._spawn(totalSpawn); + emissionState.waitEmiting -= totalSpawn; + } + } + + private _spawnBursts(): void { + const emissionState = this._emissionState; + + if (!this.emissionBursts || !Array.isArray(this.emissionBursts)) { + return; + } + + while (emissionState.burstIndex < this.emissionBursts.length && this._getBurstTime(this.emissionBursts[emissionState.burstIndex]) <= emissionState.time) { + const burst = this.emissionBursts[emissionState.burstIndex]; + const burstCount = ValueUtils.parseConstantValue(burst.count); + emissionState.isBursting = true; + emissionState.burstParticleCount = burstCount; + this._spawn(burstCount); + emissionState.isBursting = false; + emissionState.burstIndex++; + } + } + + private _accumulateEmission(delta: number): void { + const emissionState = this._emissionState; + + if (this._emitEnded) { + return; + } + + // Check for manual emit count (like ParticleSystem) + // When manualEmitCount > -1, emit that exact number and reset to 0 + if (this.manualEmitCount > -1) { + emissionState.waitEmiting = this.manualEmitCount; + this.manualEmitCount = 0; + return; + } + + // Get emit rate (use gradient if available) + let emissionRate = this.emitRate; + const emitRateGradients = this._emitRateGradients.getGradients(); + if (emitRateGradients.length > 0 && this.targetStopDuration > 0) { + const normalizedTime = this.targetStopDuration > 0 ? this._emissionState.time / this.targetStopDuration : 0; + const ratio = Math.max(0, Math.min(1, normalizedTime)); + const gradientValue = this._emitRateGradients.getValue(ratio); + if (gradientValue !== null) { + emissionRate = gradientValue; + } + } + emissionState.waitEmiting += delta * emissionRate; + + if (this.emissionOverDistance !== undefined && this.mesh && this.mesh.position) { + const emitPerMeter = ValueUtils.parseConstantValue(this.emissionOverDistance); + if (emitPerMeter > 0 && emissionState.previousWorldPos) { + const distance = Vector3.Distance(emissionState.previousWorldPos, this.mesh.position); + emissionState.travelDistance += distance; + if (emissionState.travelDistance * emitPerMeter > 0) { + const count = Math.floor(emissionState.travelDistance * emitPerMeter); + emissionState.travelDistance -= count / emitPerMeter; + emissionState.waitEmiting += count; + } + } + if (!emissionState.previousWorldPos) { + emissionState.previousWorldPos = Vector3.Zero(); + } + emissionState.previousWorldPos.copyFrom(this.mesh.position); + } + } + + private _emit(delta: number): void { + this._accumulateEmission(delta); + this._spawnFromWaitEmiting(); + this._spawnBursts(); + } + + private _getBurstTime(burst: IEmissionBurst): number { + return ValueUtils.parseConstantValue(burst.time); + } + + /** + * Override buildMesh to enable vertex colors and alpha + * This is required for ColorOverLife behavior to work visually + * Note: PBR materials automatically use vertex colors if mesh has them + * The VERTEXCOLOR define is set automatically based on mesh.isVerticesDataPresent(VertexBuffer.ColorKind) + */ + public override buildMesh(): Mesh { + const mesh = super.buildMesh(); + + if (mesh) { + mesh.hasVertexAlpha = true; + // Vertex colors are already enabled via _computeParticleColor = true + // PBR materials will automatically use them if mesh has vertex color data + } + + return mesh; + } + + private _setupMeshProperties(): void { + if (!this.mesh) { + return; + } + + if (!this.mesh.hasVertexAlpha) { + this.mesh.hasVertexAlpha = true; + } + + if (this.renderOrder !== undefined) { + this.mesh.renderingGroupId = this.renderOrder; + } + + if (this.layers !== undefined) { + this.mesh.layerMask = this.layers; + } + + // Emitter is the point from which particles emit (like ParticleSystem.emitter) + if (this._emitter) { + this.mesh.setParent(this._emitter, false, true); + } + } + + private _initializeDeadParticles(): void { + for (let i = 0; i < this.nbParticles; i++) { + const particle = this.particles[i]; + particle.alive = false; + particle.isVisible = false; + particle.age = 0; + particle.lifeTime = Infinity; + particle.position.setAll(0); + particle.velocity.setAll(0); + particle.rotation.setAll(0); + particle.scaling.setAll(1); + if (particle.color) { + particle.color.set(1, 1, 1, 1); + } else { + particle.color = new Color4(1, 1, 1, 1); + } + } + } + + private _resetEmissionState(): void { + this._emissionState.time = 0; + this._emissionState.waitEmiting = 0; + this._emissionState.travelDistance = 0; + this._emissionState.burstIndex = 0; + this._emissionState.burstWaveIndex = 0; + this._emissionState.burstParticleIndex = 0; + this._emissionState.burstParticleCount = 0; + this._emissionState.isBursting = false; + if (this.mesh && this.mesh.position) { + this._emissionState.previousWorldPos = this.mesh.position.clone(); + } + this._emitEnded = false; + } + + public override initParticles(): void { + this._setupMeshProperties(); + this._initializeDeadParticles(); + this._resetEmissionState(); + } + + /** + * Build per-particle behavior functions from configurations + * Per-particle behaviors run each frame for each particle + * "OverLife" behaviors are handled by gradients (system-level) + * + * IMPORTANT: Parse all values ONCE here, not every frame! + */ + private _buildPerParticleBehaviors(behaviors: Behavior[]): PerSolidParticleBehaviorFunction[] { + const functions: PerSolidParticleBehaviorFunction[] = []; + + for (const behavior of behaviors) { + switch (behavior.type) { + case "ForceOverLife": + case "ApplyForce": { + const b = behavior as IForceOverLifeBehavior; + // Pre-parse force values ONCE (not every frame!) + const forceX = b.x ?? b.force?.x; + const forceY = b.y ?? b.force?.y; + const forceZ = b.z ?? b.force?.z; + const fx = forceX !== undefined ? ValueUtils.parseConstantValue(forceX) : 0; + const fy = forceY !== undefined ? ValueUtils.parseConstantValue(forceY) : 0; + const fz = forceZ !== undefined ? ValueUtils.parseConstantValue(forceZ) : 0; + + if (fx !== 0 || fy !== 0 || fz !== 0) { + // Capture 'this' to access _scaledUpdateSpeed + const system = this; + functions.push((particle: SolidParticle) => { + // Use _scaledUpdateSpeed for FPS-independent force application + const deltaTime = system._scaledUpdateSpeed || system.updateSpeed; + particle.velocity.x += fx * deltaTime; + particle.velocity.y += fy * deltaTime; + particle.velocity.z += fz * deltaTime; + }); + } + break; + } + + case "ColorBySpeed": { + const b = behavior as IColorBySpeedBehavior; + // Pre-parse min/max speed ONCE + const minSpeed = b.minSpeed !== undefined ? ValueUtils.parseConstantValue(b.minSpeed) : 0; + const maxSpeed = b.maxSpeed !== undefined ? ValueUtils.parseConstantValue(b.maxSpeed) : 1; + // New structure: b.color is IColorFunction with data.colorKeys + const colorKeys = b.color?.data?.colorKeys; + + if (colorKeys && colorKeys.length > 0) { + functions.push((particle: SolidParticle) => { + if (!particle.color) { + return; + } + const vel = particle.velocity; + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + const interpolatedColor = interpolateColorKeys(colorKeys, speedRatio); + const startColor = particle.props?.startColor; + + if (startColor) { + particle.color.r = interpolatedColor.r * startColor.r; + particle.color.g = interpolatedColor.g * startColor.g; + particle.color.b = interpolatedColor.b * startColor.b; + particle.color.a = startColor.a; + } else { + particle.color.r = interpolatedColor.r; + particle.color.g = interpolatedColor.g; + particle.color.b = interpolatedColor.b; + } + }); + } + break; + } + + case "SizeBySpeed": { + const b = behavior as ISizeBySpeedBehavior; + // Pre-parse min/max speed ONCE + const minSpeed = b.minSpeed !== undefined ? ValueUtils.parseConstantValue(b.minSpeed) : 0; + const maxSpeed = b.maxSpeed !== undefined ? ValueUtils.parseConstantValue(b.maxSpeed) : 1; + const sizeKeys = b.size?.keys; + + if (sizeKeys && sizeKeys.length > 0) { + functions.push((particle: SolidParticle) => { + const vel = particle.velocity; + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + const sizeMultiplier = interpolateGradientKeys(sizeKeys, speedRatio, extractNumberFromValue); + const startSize = particle.props?.startSize ?? 1; + const newSize = startSize * sizeMultiplier; + particle.scaling.setAll(newSize); + }); + } + break; + } + + case "RotationBySpeed": { + const b = behavior as IRotationBySpeedBehavior; + // Pre-parse values ONCE + const minSpeed = b.minSpeed !== undefined ? ValueUtils.parseConstantValue(b.minSpeed) : 0; + const maxSpeed = b.maxSpeed !== undefined ? ValueUtils.parseConstantValue(b.maxSpeed) : 1; + const angularVelocity = b.angularVelocity; + const hasKeys = + typeof angularVelocity === "object" && + angularVelocity !== null && + "keys" in angularVelocity && + Array.isArray(angularVelocity.keys) && + angularVelocity.keys.length > 0; + + // Pre-parse constant angular velocity if not using keys + let constantAngularSpeed = 0; + if (!hasKeys && angularVelocity) { + const parsed = ValueUtils.parseIntervalValue(angularVelocity); + constantAngularSpeed = (parsed.min + parsed.max) / 2; + } + + const system = this; + functions.push((particle: SolidParticle) => { + const vel = particle.velocity; + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); + const deltaTime = system._scaledUpdateSpeed || system.updateSpeed; + + let angularSpeed = constantAngularSpeed; + if (hasKeys) { + const speedRatio = Math.max(0, Math.min(1, (currentSpeed - minSpeed) / (maxSpeed - minSpeed || 1))); + angularSpeed = interpolateGradientKeys((angularVelocity as any).keys, speedRatio, extractNumberFromValue); + } + + particle.rotation.z += angularSpeed * deltaTime; + }); + break; + } + + case "OrbitOverLife": { + const b = behavior as IOrbitOverLifeBehavior; + // Pre-parse constant values ONCE + const speed = b.speed !== undefined ? ValueUtils.parseConstantValue(b.speed) : 1; + const centerX = b.center?.x ?? 0; + const centerY = b.center?.y ?? 0; + const centerZ = b.center?.z ?? 0; + const hasRadiusKeys = + b.radius !== undefined && + b.radius !== null && + typeof b.radius === "object" && + "keys" in b.radius && + Array.isArray(b.radius.keys) && + b.radius.keys.length > 0; + + // Pre-parse constant radius if not using keys + let constantRadius = 1; + if (!hasRadiusKeys && b.radius !== undefined) { + const parsed = ValueUtils.parseIntervalValue(b.radius as Value); + constantRadius = (parsed.min + parsed.max) / 2; + } + + functions.push((particle: SolidParticle) => { + if (particle.lifeTime <= 0) { + return; + } + + const lifeRatio = particle.age / particle.lifeTime; + + // Get radius (from keys or constant) + let radius = constantRadius; + if (hasRadiusKeys) { + radius = interpolateGradientKeys((b.radius as any).keys, lifeRatio, extractNumberFromValue); + } + + const angle = lifeRatio * speed * Math.PI * 2; + + // Calculate orbit offset (NOT replacement!) + const orbitX = Math.cos(angle) * radius; + const orbitY = Math.sin(angle) * radius; + + // Store initial position if not stored yet + const props = (particle.props ||= {}) as any; + if (props.orbitInitialPos === undefined) { + props.orbitInitialPos = { + x: particle.position.x, + y: particle.position.y, + z: particle.position.z, + }; + } + + // Apply orbit as OFFSET from initial position (NOT replacement!) + particle.position.x = props.orbitInitialPos.x + centerX + orbitX; + particle.position.y = props.orbitInitialPos.y + centerY + orbitY; + particle.position.z = props.orbitInitialPos.z + centerZ; + }); + break; + } + } + } + + return functions; + } + + /** + * Apply system-level behaviors (gradients) to SolidParticleSystem + * These are applied once when behaviors change, not per-particle + * Similar to ParticleSystem native gradients + */ + private _applySystemLevelBehaviors(): void { + for (const behavior of this.behaviorConfigs) { + if (!behavior.type) { + continue; + } + + switch (behavior.type) { + case "ColorOverLife": + applyColorOverLifeSPS(this, behavior as any); + break; + case "SizeOverLife": + applySizeOverLifeSPS(this, behavior as any); + break; + case "RotationOverLife": + case "Rotation3DOverLife": + applyRotationOverLifeSPS(this, behavior as any); + break; + case "SpeedOverLife": + applySpeedOverLifeSPS(this, behavior as any); + break; + case "LimitSpeedOverLife": + applyLimitSpeedOverLifeSPS(this, behavior as any); + break; + } + } + } + + public override beforeUpdateParticles(start?: number, stop?: number, update?: boolean): void { + super.beforeUpdateParticles(start, stop, update); + + // Hide particles when stopped + if (this._stopped) { + const particles = this.particles; + const nbParticles = this.nbParticles; + for (let i = 0; i < nbParticles; i++) { + const particle = particles[i]; + if (particle.alive) { + particle.isVisible = false; + } + } + } + } + + /** + * Called AFTER particle updates in setParticles(). + * This is the correct place for emission because _scaledUpdateSpeed is already calculated. + */ + public override afterUpdateParticles(start?: number, stop?: number, update?: boolean): void { + super.afterUpdateParticles(start, stop, update); + + if (this._stopped || !this._started) { + return; + } + + // Use _scaledUpdateSpeed for emission (same as ThinParticleSystem) + // Now it's properly calculated by the base class + const deltaTime = this._scaledUpdateSpeed; + + // Debug logging (aggregated per second) + this._debugFrameCount++; + this._debugDeltaSum += deltaTime; + const now = performance.now(); + if (now - this._debugLastLog > 1000) { + this._debugLastLog = now; + this._debugFrameCount = 0; + this._debugDeltaSum = 0; + } + + this._emissionState.time += deltaTime; + + this._emit(deltaTime); + + this._handleEmissionLooping(); + } + + private _debugLastLog = 0; + private _debugFrameCount = 0; + private _debugDeltaSum = 0; + + private _updateParticle(particle: SolidParticle): SolidParticle { + if (!particle.alive) { + particle.isVisible = false; + + if (!particle._stillInvisible && this._positions32 && particle._model) { + const shape = particle._model._shape; + const startIdx = particle._pos; + const positions32 = this._positions32; + for (let pt = 0, len = shape.length; pt < len; pt++) { + const idx = startIdx + pt * 3; + positions32[idx] = positions32[idx + 1] = positions32[idx + 2] = 0; + } + particle._stillInvisible = true; + } + + return particle; + } + + const lifeRatio = particle.lifeTime > 0 ? particle.age / particle.lifeTime : 0; + + this._applyGradients(particle, lifeRatio); + + const particleWithSystem = particle as SolidParticleWithSystem; + particleWithSystem.system = this; + + const behaviors = this._behaviors; + for (let i = 0, len = behaviors.length; i < len; i++) { + behaviors[i](particle); + } + + const props = particle.props; + const speedModifier = props?.speedModifier ?? 1.0; + // Use _scaledUpdateSpeed for FPS-independent movement (like ParticleSystem) + const deltaTime = this._scaledUpdateSpeed || this.updateSpeed; + particle.position.addInPlace(particle.velocity.scale(deltaTime * speedModifier)); + + return particle; + } + + /** + * Apply gradients to particle based on lifeRatio + */ + private _applyGradients(particle: SolidParticle, lifeRatio: number): void { + const props = (particle.props ||= {}); + // Use _scaledUpdateSpeed for FPS-independent gradients + const deltaTime = this._scaledUpdateSpeed || this.updateSpeed; + + const color = this._colorGradients.getValue(lifeRatio); + if (color && particle.color) { + particle.color.copyFrom(color); + + const startColor = props.startColor; + if (startColor) { + particle.color.r *= startColor.r; + particle.color.g *= startColor.g; + particle.color.b *= startColor.b; + particle.color.a *= startColor.a; + } + } + + // Apply size gradients with scale modifiers (like ParticleSystem) + const size = this._sizeGradients.getValue(lifeRatio); + if (size !== null && props.startSize !== undefined) { + const scaleX = props.startScaleX ?? 1; + const scaleY = props.startScaleY ?? 1; + particle.scaling.set(props.startSize * size * scaleX, props.startSize * size * scaleY, props.startSize * size); + } + + const velocity = this._velocityGradients.getValue(lifeRatio); + if (velocity !== null) { + props.speedModifier = velocity; + } + + // Apply angular speed: use gradient if available, otherwise use particle's startAngularSpeed (like ParticleSystem) + const angularSpeedFromGradient = this._angularSpeedGradients.getValue(lifeRatio); + if (angularSpeedFromGradient !== null) { + particle.rotation.z += angularSpeedFromGradient * deltaTime; + } else if (props.startAngularSpeed !== undefined && props.startAngularSpeed !== 0) { + // Apply base angular speed (like ParticleSystem._ProcessAngularSpeed) + particle.rotation.z += props.startAngularSpeed * deltaTime; + } + + const limitVelocity = this._limitVelocityGradients.getValue(lifeRatio); + if (limitVelocity !== null && this._limitVelocityDamping > 0) { + const vel = particle.velocity; + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y + vel.z * vel.z); + if (currentSpeed > limitVelocity) { + vel.scaleInPlace((limitVelocity / currentSpeed) * this._limitVelocityDamping); + } + } + } +} diff --git a/tools/src/effect/systems/index.ts b/tools/src/effect/systems/index.ts new file mode 100644 index 000000000..e0db1369e --- /dev/null +++ b/tools/src/effect/systems/index.ts @@ -0,0 +1,2 @@ +export { EffectParticleSystem } from "./effectParticleSystem"; +export { EffectSolidParticleSystem } from "./effectSolidParticleSystem"; diff --git a/tools/src/effect/types/behaviors.ts b/tools/src/effect/types/behaviors.ts new file mode 100644 index 000000000..0531e79e4 --- /dev/null +++ b/tools/src/effect/types/behaviors.ts @@ -0,0 +1,172 @@ +import type { Value } from "./values"; +import type { IGradientKey } from "./gradients"; +import { Particle } from "@babylonjs/core/Particles/particle"; +import { ParticleSystem } from "@babylonjs/core/Particles/particleSystem"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { SolidParticleSystem } from "@babylonjs/core/Particles/solidParticleSystem"; + +/** + * Per-particle behavior function for ParticleSystem + * Behavior config is captured in closure, only particle is needed + */ +export type PerParticleBehaviorFunction = (particle: Particle) => void; + +/** + * Per-particle behavior function for SolidParticleSystem + * Behavior config is captured in closure, only particle is needed + */ +export type PerSolidParticleBehaviorFunction = (particle: SolidParticle) => void; + +/** + * System-level behavior function (applied once during initialization) + * Takes only system and behavior config - all data comes from system + */ +export type SystemBehaviorFunction = (system: ParticleSystem | SolidParticleSystem, behavior: Behavior) => void; + +/** + * behavior types (converted from Quarks) + */ +/** + * Color function - unified structure for all color-related behaviors + */ +export interface IColorFunction { + colorFunctionType: "Gradient" | "ConstantColor" | "ColorRange" | "RandomColor" | "RandomColorBetweenGradient"; + data: { + color?: { r: number; g: number; b: number; a: number }; + colorA?: { r: number; g: number; b: number; a: number }; + colorB?: { r: number; g: number; b: number; a: number }; + colorKeys?: IGradientKey[]; + alphaKeys?: IGradientKey[]; + gradient1?: { + colorKeys?: IGradientKey[]; + alphaKeys?: IGradientKey[]; + }; + gradient2?: { + colorKeys?: IGradientKey[]; + alphaKeys?: IGradientKey[]; + }; + }; +} + +export interface IColorOverLifeBehavior { + type: "ColorOverLife"; + color: IColorFunction; +} + +export interface ISizeOverLifeBehavior { + type: "SizeOverLife"; + size?: { + keys?: IGradientKey[]; + functions?: Array<{ + start: number; + function: { + p0?: number; + p3?: number; + }; + }>; + }; +} + +export interface IRotationOverLifeBehavior { + type: "RotationOverLife" | "Rotation3DOverLife"; + angularVelocity?: Value; +} + +export interface IForceOverLifeBehavior { + type: "ForceOverLife" | "ApplyForce"; + force?: { + x?: Value; + y?: Value; + z?: Value; + }; + x?: Value; + y?: Value; + z?: Value; +} + +export interface IGravityForceBehavior { + type: "GravityForce"; + gravity?: Value; +} + +export interface ISpeedOverLifeBehavior { + type: "SpeedOverLife"; + speed?: + | { + keys?: IGradientKey[]; + functions?: Array<{ + start: number; + function: { + p0?: number; + p3?: number; + }; + }>; + } + | Value; +} + +export interface IFrameOverLifeBehavior { + type: "FrameOverLife"; + frame?: + | { + keys?: IGradientKey[]; + } + | Value; +} + +export interface ILimitSpeedOverLifeBehavior { + type: "LimitSpeedOverLife"; + maxSpeed?: Value; + speed?: Value | { keys?: IGradientKey[] }; + dampen?: Value; +} + +export interface IColorBySpeedBehavior { + type: "ColorBySpeed"; + color: IColorFunction; + minSpeed?: Value; + maxSpeed?: Value; + speedRange?: { min: number; max: number }; +} + +export interface ISizeBySpeedBehavior { + type: "SizeBySpeed"; + size?: { + keys: IGradientKey[]; + }; + minSpeed?: Value; + maxSpeed?: Value; +} + +export interface IRotationBySpeedBehavior { + type: "RotationBySpeed"; + angularVelocity?: Value; + minSpeed?: Value; + maxSpeed?: Value; +} + +export interface IOrbitOverLifeBehavior { + type: "OrbitOverLife"; + center?: { + x?: number; + y?: number; + z?: number; + }; + radius?: Value | { keys?: IGradientKey[] }; + speed?: Value; +} + +export type Behavior = + | IColorOverLifeBehavior + | ISizeOverLifeBehavior + | IRotationOverLifeBehavior + | IForceOverLifeBehavior + | IGravityForceBehavior + | ISpeedOverLifeBehavior + | IFrameOverLifeBehavior + | ILimitSpeedOverLifeBehavior + | IColorBySpeedBehavior + | ISizeBySpeedBehavior + | IRotationBySpeedBehavior + | IOrbitOverLifeBehavior + | { type: string; [key: string]: unknown }; // Fallback for unknown behaviors diff --git a/tools/src/effect/types/colors.ts b/tools/src/effect/types/colors.ts new file mode 100644 index 000000000..304f99201 --- /dev/null +++ b/tools/src/effect/types/colors.ts @@ -0,0 +1,41 @@ +import type { IGradientKey } from "./gradients"; + +/** + * color types (converted from Quarks) + */ +export interface IConstantColor { + type: "ConstantColor"; + value: [number, number, number, number]; // RGBA +} + +export interface IColorRange { + type: "ColorRange"; + colorA: [number, number, number, number]; // RGBA + colorB: [number, number, number, number]; // RGBA +} + +export interface IGradientColor { + type: "Gradient"; + colorKeys: IGradientKey[]; + alphaKeys?: IGradientKey[]; +} + +export interface IRandomColor { + type: "RandomColor"; + colorA: [number, number, number, number]; // RGBA + colorB: [number, number, number, number]; // RGBA +} + +export interface IRandomColorBetweenGradient { + type: "RandomColorBetweenGradient"; + gradient1: { + colorKeys: IGradientKey[]; + alphaKeys?: IGradientKey[]; + }; + gradient2: { + colorKeys: IGradientKey[]; + alphaKeys?: IGradientKey[]; + }; +} + +export type Color = IConstantColor | IColorRange | IGradientColor | IRandomColor | IRandomColorBetweenGradient | [number, number, number, number] | string; diff --git a/tools/src/effect/types/emitter.ts b/tools/src/effect/types/emitter.ts new file mode 100644 index 000000000..e7f0dc090 --- /dev/null +++ b/tools/src/effect/types/emitter.ts @@ -0,0 +1,118 @@ +import { Nullable } from "@babylonjs/core/types"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import type { IEmitter } from "./hierarchy"; +import type { Value } from "./values"; +import type { IShape } from "./shapes"; +import type { Behavior } from "./behaviors"; + +/** + * emission burst (converted from Quarks) + */ +export interface IEmissionBurst { + time: Value; + count: Value; +} + +/** + * Particle system configuration (converted from Quarks to native Babylon.js properties) + */ +export interface IParticleSystemConfig { + version?: string; + systemType: "solid" | "base"; + + // === Native Babylon.js properties (converted from Quarks Value) === + + // Life & Size + minLifeTime?: number; + maxLifeTime?: number; + minSize?: number; + maxSize?: number; + minScaleX?: number; + maxScaleX?: number; + minScaleY?: number; + maxScaleY?: number; + + // Speed & Power + minEmitPower?: number; + maxEmitPower?: number; + emitRate?: number; + + // Rotation + minInitialRotation?: number; + maxInitialRotation?: number; + minAngularSpeed?: number; + maxAngularSpeed?: number; + + // Color + color1?: Color4; + color2?: Color4; + colorDead?: Color4; + + // Duration & Looping + targetStopDuration?: number; // 0 = infinite (looping), >0 = duration + manualEmitCount?: number; // -1 = automatic, otherwise specific count + + // Prewarm + preWarmCycles?: number; + preWarmStepOffset?: number; + + // Physics + gravity?: Vector3; + noiseStrength?: Vector3; + updateSpeed?: number; + + // World space + isLocal?: boolean; + + // Auto destroy + disposeOnStop?: boolean; + + // Gradients for PiecewiseBezier + startSizeGradients?: Array<{ gradient: number; factor: number; factor2?: number }>; + lifeTimeGradients?: Array<{ gradient: number; factor: number; factor2?: number }>; + emitRateGradients?: Array<{ gradient: number; factor: number; factor2?: number }>; + + // === Other properties === + shape?: IShape; + emissionBursts?: IEmissionBurst[]; + emissionOverDistance?: Value; // For solid system only + instancingGeometry?: string; // Custom geometry ID for SPS + renderOrder?: number; + layers?: number; + isBillboardBased?: boolean; + billboardMode?: number; + // Sprite animation (ParticleSystem only) + startTileIndex?: Value; + uTileCount?: number; + vTileCount?: number; + // Behaviors + behaviors?: Behavior[]; +} + +/** + * Data structure for emitter creation + */ +export interface IEmitterData { + name: string; + config: IParticleSystemConfig; + materialId?: string; + matrix?: number[]; + position?: number[]; + parentGroup: Nullable; + cumulativeScale: Vector3; + emitter?: IEmitter; +} + +/** + * Interface for SolidParticleSystem emitter types + * Similar to IParticleEmitterType for ParticleSystem + */ +export interface ISolidParticleEmitterType { + /** + * Initialize particle position and velocity based on emitter shape + */ + initializeParticle(particle: SolidParticle, startSpeed: number): void; +} diff --git a/tools/src/effect/types/factories.ts b/tools/src/effect/types/factories.ts new file mode 100644 index 000000000..f63b31481 --- /dev/null +++ b/tools/src/effect/types/factories.ts @@ -0,0 +1,18 @@ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; +import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial"; +import { Texture } from "@babylonjs/core/Materials/Textures/texture"; +import { Scene } from "@babylonjs/core/scene"; + +/** + * Factory interfaces for dependency injection + */ +export interface IMaterialFactory { + createMaterial(materialId: string, name: string): PBRMaterial; + createTexture(materialId: string): Texture; + getBlendMode(materialId: string): number | undefined; +} + +export interface IGeometryFactory { + createMesh(geometryId: string, name: string, scene: Scene): Mesh; + createParticleMesh(config: { instancingGeometry?: string }, name: string, scene: Scene): Mesh; +} diff --git a/tools/src/effect/types/gradients.ts b/tools/src/effect/types/gradients.ts new file mode 100644 index 000000000..ff5467855 --- /dev/null +++ b/tools/src/effect/types/gradients.ts @@ -0,0 +1,8 @@ +/** + * gradient key (converted from Quarks) + */ +export interface IGradientKey { + time?: number; + value: number | [number, number, number, number] | { r: number; g: number; b: number; a?: number }; + pos?: number; +} diff --git a/tools/src/effect/types/hierarchy.ts b/tools/src/effect/types/hierarchy.ts new file mode 100644 index 000000000..851053698 --- /dev/null +++ b/tools/src/effect/types/hierarchy.ts @@ -0,0 +1,49 @@ +import { Vector3 } from "@babylonjs/core/Maths/math.vector"; +import { Quaternion } from "@babylonjs/core/Maths/math.vector"; +import type { IParticleSystemConfig } from "./emitter"; +import type { IMaterial, ITexture, IImage, IGeometry } from "./resources"; + +/** + * transform (converted from Quarks, left-handed coordinate system) + */ +export interface ITransform { + position: Vector3; + rotation: Quaternion; + scale: Vector3; +} + +/** + * group (converted from Quarks) + */ +export interface IGroup { + uuid: string; + name: string; + transform: ITransform; + children: (IGroup | IEmitter)[]; +} + +/** + * emitter (converted from Quarks) + */ +export interface IEmitter { + uuid: string; + name: string; + transform: ITransform; + config: IParticleSystemConfig; + materialId?: string; + parentUuid?: string; + systemType: "solid" | "base"; // Determined from renderMode: 2 = solid, otherwise base + matrix?: number[]; // Original Three.js matrix array for rotation extraction +} + +/** + * data (converted from Quarks) + * Contains the converted structure with groups, emitters, and resources + */ +export interface IData { + root: IGroup | IEmitter | null; + materials: IMaterial[]; + textures: ITexture[]; + images: IImage[]; + geometries: IGeometry[]; +} diff --git a/tools/src/effect/types/index.ts b/tools/src/effect/types/index.ts new file mode 100644 index 000000000..82e419c92 --- /dev/null +++ b/tools/src/effect/types/index.ts @@ -0,0 +1,12 @@ +export * from "./values"; +export * from "./colors"; +export * from "./rotations"; +export * from "./gradients"; +export * from "./shapes"; +export * from "./behaviors"; +export * from "./emitter"; +export * from "./system"; +export * from "./loader"; +export * from "./hierarchy"; +export * from "./resources"; +export * from "./factories"; diff --git a/tools/src/effect/types/loader.ts b/tools/src/effect/types/loader.ts new file mode 100644 index 000000000..ea9636afb --- /dev/null +++ b/tools/src/effect/types/loader.ts @@ -0,0 +1,13 @@ +/** + * Options for loading effect + */ +export interface ILoaderOptions { + /** + * Enable verbose logging for debugging + */ + verbose?: boolean; + /** + * Validate parsed data and log warnings + */ + validate?: boolean; +} diff --git a/tools/src/effect/types/resources.ts b/tools/src/effect/types/resources.ts new file mode 100644 index 000000000..cfa14e87f --- /dev/null +++ b/tools/src/effect/types/resources.ts @@ -0,0 +1,84 @@ +import { Color3 } from "@babylonjs/core/Maths/math.color"; + +/** + * Material (converted from Quarks, ready for Babylon.js) + */ +export interface IMaterial { + uuid: string; + type?: string; + color?: Color3; // Converted from hex/array to Color3 + opacity?: number; + transparent?: boolean; + depthWrite?: boolean; + side?: number; + blending?: number; // Converted to Babylon.js constants + map?: string; // Texture UUID reference +} + +/** + * Texture (converted from Quarks, ready for Babylon.js) + */ +export interface ITexture { + uuid: string; + image?: string; // Image UUID reference + wrapU?: number; // Converted to Babylon.js wrap mode + wrapV?: number; // Converted to Babylon.js wrap mode + uScale?: number; // From repeat[0] + vScale?: number; // From repeat[1] + uOffset?: number; // From offset[0] + vOffset?: number; // From offset[1] + uAng?: number; // From rotation + coordinatesIndex?: number; // From channel + samplingMode?: number; // Converted from Three.js filters to Babylon.js sampling mode + generateMipmaps?: boolean; + flipY?: boolean; +} + +/** + * Image (converted from Quarks, normalized URL) + */ +export interface IImage { + uuid: string; + url: string; // Normalized URL (ready for use) +} + +/** + * Geometry Attribute Data + */ +export interface IGeometryAttribute { + array: number[]; + itemSize?: number; +} + +/** + * Geometry Index Data + */ +export interface IGeometryIndex { + array: number[]; +} + +/** + * Geometry Data (converted from Quarks, left-handed coordinate system) + */ +export interface IGeometryData { + attributes: { + position?: IGeometryAttribute; + normal?: IGeometryAttribute; + uv?: IGeometryAttribute; + color?: IGeometryAttribute; + }; + index?: IGeometryIndex; +} + +/** + * Geometry (converted from Quarks, ready for Babylon.js) + */ +export interface IGeometry { + uuid: string; + type: "PlaneGeometry" | "BufferGeometry"; + // For PlaneGeometry + width?: number; + height?: number; + // For BufferGeometry (already converted to left-handed) + data?: IGeometryData; +} diff --git a/tools/src/effect/types/rotations.ts b/tools/src/effect/types/rotations.ts new file mode 100644 index 000000000..92b7a6a56 --- /dev/null +++ b/tools/src/effect/types/rotations.ts @@ -0,0 +1,26 @@ +import type { Value } from "./values"; + +/** + * rotation types (converted from Quarks) + */ +export interface IEulerRotation { + type: "Euler"; + angleX?: Value; + angleY?: Value; + angleZ?: Value; + order?: "xyz" | "zyx"; +} + +export interface IAxisAngleRotation { + type: "AxisAngle"; + x?: Value; + y?: Value; + z?: Value; + angle?: Value; +} + +export interface IRandomQuatRotation { + type: "RandomQuat"; +} + +export type Rotation = IEulerRotation | IAxisAngleRotation | IRandomQuatRotation | Value; diff --git a/tools/src/effect/types/shapes.ts b/tools/src/effect/types/shapes.ts new file mode 100644 index 000000000..04232da8f --- /dev/null +++ b/tools/src/effect/types/shapes.ts @@ -0,0 +1,17 @@ +import type { Value } from "./values"; + +/** + * shape configuration (converted from Quarks) + */ +export interface IShape { + type: string; + radius?: number; + arc?: number; + thickness?: number; + angle?: number; + mode?: number; + spread?: number; + speed?: Value; + size?: number[]; + height?: number; +} diff --git a/tools/src/effect/types/system.ts b/tools/src/effect/types/system.ts new file mode 100644 index 000000000..996b210e7 --- /dev/null +++ b/tools/src/effect/types/system.ts @@ -0,0 +1,72 @@ +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; +import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh"; +import { Particle } from "@babylonjs/core/Particles/particle"; +import { SolidParticle } from "@babylonjs/core/Particles/solidParticle"; +import type { EffectParticleSystem, EffectSolidParticleSystem } from "../systems"; + +/** + * Common interface for all particle systems + * Provides type-safe access to common properties and methods + */ +export interface ISystem { + /** System name */ + name: string; + /** Get the parent node (mesh or emitter) for hierarchy operations */ + getParentNode(): AbstractMesh | TransformNode | null; + /** Start the particle system */ + start(): void; + /** Stop the particle system */ + stop(): void; + /** Dispose the particle system */ + dispose(): void; +} + +/** + * Extended Particle type with system reference + * Used for behaviors that need access to the particle system + * Uses intersection type to add custom property without conflicting with base type + */ +export type ParticleWithSystem = Particle & { + particleSystem?: EffectParticleSystem; +}; + +/** + * Extended SolidParticle type with system reference + * Used for behaviors that need access to the solid particle system + * Uses intersection type to add custom property without conflicting with base type + */ +export type SolidParticleWithSystem = SolidParticle & { + system?: EffectSolidParticleSystem; +}; + +/** + * Type guard to check if a system implements ISystem + */ +export function isSystem(system: unknown): system is ISystem { + return ( + typeof system === "object" && + system !== null && + "getParentNode" in system && + typeof (system as ISystem).getParentNode === "function" && + "start" in system && + typeof (system as ISystem).start === "function" && + "stop" in system && + typeof (system as ISystem).stop === "function" + ); +} + +/** + * Effect Node - represents either a particle system or a group + */ +export interface IEffectNode { + /** Node name */ + name: string; + /** Node UUID from original JSON */ + uuid: string; + /** Particle system (if this is a particle emitter) */ + data: EffectParticleSystem | EffectSolidParticleSystem | TransformNode; + /** Child nodes */ + children: IEffectNode[]; + /** Node type */ + type: "particle" | "group"; +} diff --git a/tools/src/effect/types/values.ts b/tools/src/effect/types/values.ts new file mode 100644 index 000000000..3b300e578 --- /dev/null +++ b/tools/src/effect/types/values.ts @@ -0,0 +1,27 @@ +/** + * value types (converted from Quarks) + */ +export interface IConstantValue { + type: "ConstantValue"; + value: number; +} + +export interface IIntervalValue { + type: "IntervalValue"; + min: number; + max: number; +} + +export interface IPiecewiseBezier { + type: "PiecewiseBezier"; + functions: Array<{ + function: { + p0: number; + p1: number; + p2: number; + p3: number; + }; + start: number; + }>; +} +export type Value = IConstantValue | IIntervalValue | IPiecewiseBezier | number; diff --git a/tools/src/effect/utils/capacityCalculator.ts b/tools/src/effect/utils/capacityCalculator.ts new file mode 100644 index 000000000..1d88bc640 --- /dev/null +++ b/tools/src/effect/utils/capacityCalculator.ts @@ -0,0 +1,32 @@ +import { ValueUtils } from "./valueParser"; +import type { Value } from "../types/values"; + +/** + * Utility for calculating particle system capacity + */ +export class CapacityCalculator { + /** + * Calculate capacity for ParticleSystem + * Formula: emissionRate * duration * 2 (for non-looping systems) + */ + public static calculateForParticleSystem(emissionOverTime: Value | undefined, duration: number): number { + const emissionRate = emissionOverTime !== undefined ? ValueUtils.parseConstantValue(emissionOverTime) : 10; + return Math.ceil(emissionRate * duration * 2); + } + + /** + * Calculate capacity for SolidParticleSystem + * Formula depends on looping: + * - Looping: max(emissionRate * particleLifetime, 1) + * - Non-looping: emissionRate * particleLifetime * 2 + */ + public static calculateForSolidParticleSystem(emissionOverTime: Value | undefined, duration: number, isLooping: boolean): number { + const emissionRate = emissionOverTime !== undefined ? ValueUtils.parseConstantValue(emissionOverTime) : 10; + const particleLifetime = duration || 5; + + if (isLooping) { + return Math.max(Math.ceil(emissionRate * particleLifetime), 1); + } + return Math.ceil(emissionRate * particleLifetime * 2); + } +} diff --git a/tools/src/effect/utils/gradientSystem.ts b/tools/src/effect/utils/gradientSystem.ts new file mode 100644 index 000000000..3bdcc4c3a --- /dev/null +++ b/tools/src/effect/utils/gradientSystem.ts @@ -0,0 +1,99 @@ +import { Color4 } from "@babylonjs/core/Maths/math.color"; + +/** + * Generic gradient system for storing and interpolating gradient values + * Similar to Babylon.js native gradients but for SolidParticleSystem + */ +export class GradientSystem { + private _gradients: Array<{ gradient: number; value: T }>; + + constructor() { + this._gradients = []; + } + + /** + * Add a gradient point + */ + public addGradient(gradient: number, value: T): void { + // Insert in sorted order + const index = this._gradients.findIndex((g) => g.gradient > gradient); + if (index === -1) { + this._gradients.push({ gradient, value }); + } else { + this._gradients.splice(index, 0, { gradient, value }); + } + } + + /** + * Get interpolated value at given gradient position (0-1) + */ + public getValue(gradient: number): T | null { + if (this._gradients.length === 0) { + return null; + } + + if (this._gradients.length === 1) { + return this._gradients[0].value; + } + + // Clamp gradient to [0, 1] + const clampedGradient = Math.max(0, Math.min(1, gradient)); + + // Find the two gradients to interpolate between + for (let i = 0; i < this._gradients.length - 1; i++) { + const g1 = this._gradients[i]; + const g2 = this._gradients[i + 1]; + + if (clampedGradient >= g1.gradient && clampedGradient <= g2.gradient) { + const t = g2.gradient - g1.gradient !== 0 ? (clampedGradient - g1.gradient) / (g2.gradient - g1.gradient) : 0; + return this.interpolate(g1.value, g2.value, t); + } + } + + // Clamp to first or last gradient + if (clampedGradient <= this._gradients[0].gradient) { + return this._gradients[0].value; + } + return this._gradients[this._gradients.length - 1].value; + } + + /** + * Clear all gradients + */ + public clear(): void { + this._gradients = []; + } + + /** + * Get all gradients (for debugging) + */ + public getGradients(): Array<{ gradient: number; value: T }> { + return [...this._gradients]; + } + + /** + * Interpolate between two values (to be overridden by subclasses) + */ + protected interpolate(value1: T, _value2: T, _t: number): T { + // Default implementation - should be overridden + return value1; + } +} + +/** + * Color gradient system for Color4 + */ +export class ColorGradientSystem extends GradientSystem { + protected interpolate(value1: Color4, value2: Color4, t: number): Color4 { + return new Color4(value1.r + (value2.r - value1.r) * t, value1.g + (value2.g - value1.g) * t, value1.b + (value2.b - value1.b) * t, value1.a + (value2.a - value1.a) * t); + } +} + +/** + * Number gradient system + */ +export class NumberGradientSystem extends GradientSystem { + protected interpolate(value1: number, value2: number, t: number): number { + return value1 + (value2 - value1) * t; + } +} diff --git a/tools/src/effect/utils/index.ts b/tools/src/effect/utils/index.ts new file mode 100644 index 000000000..ff40dd376 --- /dev/null +++ b/tools/src/effect/utils/index.ts @@ -0,0 +1,4 @@ +export * from "./valueParser"; +export * from "./capacityCalculator"; +export * from "./matrixUtils"; +export * from "./gradientSystem"; diff --git a/tools/src/effect/utils/matrixUtils.ts b/tools/src/effect/utils/matrixUtils.ts new file mode 100644 index 000000000..32b0dbfa1 --- /dev/null +++ b/tools/src/effect/utils/matrixUtils.ts @@ -0,0 +1,21 @@ +import { Matrix } from "@babylonjs/core/Maths/math.vector"; + +/** + * Utility functions for matrix operations + */ +export class MatrixUtils { + /** + * Extracts rotation matrix from Three.js matrix array + * @param matrix Three.js matrix array (16 elements) + * @returns Rotation matrix or null if invalid + */ + public static extractRotationMatrix(matrix: number[] | undefined): Matrix | null { + if (!matrix || matrix.length < 16) { + return null; + } + + const mat = Matrix.FromArray(matrix); + mat.transpose(); + return mat.getRotationMatrix(); + } +} diff --git a/tools/src/effect/utils/valueParser.ts b/tools/src/effect/utils/valueParser.ts new file mode 100644 index 000000000..b5e1e10cc --- /dev/null +++ b/tools/src/effect/utils/valueParser.ts @@ -0,0 +1,195 @@ +import { Color4 } from "@babylonjs/core/Maths/math.color"; +import { ColorGradient } from "@babylonjs/core/Misc/gradients"; +import type { IPiecewiseBezier, Value, Color, IGradientKey } from "../types"; + +/** + * Static utility functions for parsing values + * These are stateless and don't require an instance + */ +export class ValueUtils { + /** + * Parse a constant value + */ + public static parseConstantValue(value: Value): number { + if (value && typeof value === "object" && value.type === "ConstantValue") { + return value.value || 0; + } + return typeof value === "number" ? value : 0; + } + + /** + * Parse an interval value (returns min and max) + */ + public static parseIntervalValue(value: Value): { min: number; max: number } { + if (value && typeof value === "object" && "type" in value && value.type === "IntervalValue") { + return { + min: value.min ?? 0, + max: value.max ?? 0, + }; + } + const constant = this.parseConstantValue(value); + return { min: constant, max: constant }; + } + + /** + * Parse a constant color + * Supports formats: + * - { type: "ConstantColor", value: [r, g, b, a] } + * - { type: "ConstantColor", color: { r, g, b, a } } + * - [r, g, b, a] (array) + */ + public static parseConstantColor(value: Color): Color4 { + if (value && typeof value === "object" && !Array.isArray(value)) { + if ("type" in value && value.type === "ConstantColor") { + // Format: { type: "ConstantColor", value: [r, g, b, a] } + if (value.value && Array.isArray(value.value)) { + return new Color4(value.value[0] || 0, value.value[1] || 0, value.value[2] || 0, value.value[3] !== undefined ? value.value[3] : 1); + } + // Format: { type: "ConstantColor", color: { r, g, b, a } } + const anyValue = value as any; + if (anyValue.color && typeof anyValue.color === "object") { + return new Color4(anyValue.color.r ?? 1, anyValue.color.g ?? 1, anyValue.color.b ?? 1, anyValue.color.a !== undefined ? anyValue.color.a : 1); + } + } + } + // Array format [r, g, b, a?] + if (Array.isArray(value) && value.length >= 3) { + return new Color4(value[0] || 0, value[1] || 0, value[2] || 0, value[3] !== undefined ? value[3] : 1); + } + return new Color4(1, 1, 1, 1); + } + + /** + * Parse a value for particle spawn (returns a single value based on type) + * Handles ConstantValue, IntervalValue, PiecewiseBezier, and number + * @param value The value to parse + * @param normalizedTime Normalized time (0-1) for PiecewiseBezier, default is random for IntervalValue + */ + public static parseValue(value: Value, normalizedTime?: number): number { + if (!value || typeof value === "number") { + return typeof value === "number" ? value : 0; + } + + if (value.type === "ConstantValue") { + return value.value || 0; + } + + if (value.type === "IntervalValue") { + const min = value.min ?? 0; + const max = value.max ?? 0; + return min + Math.random() * (max - min); + } + + if (value.type === "PiecewiseBezier") { + // Use provided normalizedTime or random for spawn + const t = normalizedTime !== undefined ? normalizedTime : Math.random(); + return this._evaluatePiecewiseBezier(value, t); + } + + // Fallback + return 0; + } + + /** + * Evaluate PiecewiseBezier at normalized time t (0-1) + */ + private static _evaluatePiecewiseBezier(bezier: IPiecewiseBezier, t: number): number { + if (!bezier.functions || bezier.functions.length === 0) { + return 0; + } + + // Clamp t to [0, 1] + const clampedT = Math.max(0, Math.min(1, t)); + + // Find which function segment contains t + let segmentIndex = -1; + for (let i = 0; i < bezier.functions.length; i++) { + const func = bezier.functions[i]; + const start = func.start; + const end = i < bezier.functions.length - 1 ? bezier.functions[i + 1].start : 1; + + if (clampedT >= start && clampedT < end) { + segmentIndex = i; + break; + } + } + + // If t is at the end (1.0), use last segment + if (segmentIndex === -1 && clampedT >= 1) { + segmentIndex = bezier.functions.length - 1; + } + + // If still not found, use first segment + if (segmentIndex === -1) { + segmentIndex = 0; + } + + const func = bezier.functions[segmentIndex]; + const start = func.start; + const end = segmentIndex < bezier.functions.length - 1 ? bezier.functions[segmentIndex + 1].start : 1; + + // Normalize t within this segment + const segmentT = end > start ? (clampedT - start) / (end - start) : 0; + + // Evaluate cubic Bezier: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ + const p0 = func.function.p0; + const p1 = func.function.p1; + const p2 = func.function.p2; + const p3 = func.function.p3; + + const t2 = segmentT * segmentT; + const t3 = t2 * segmentT; + const mt = 1 - segmentT; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + + return mt3 * p0 + 3 * mt2 * segmentT * p1 + 3 * mt * t2 * p2 + t3 * p3; + } + + /** + * Parse gradient color keys + */ + public static parseGradientColorKeys(keys: IGradientKey[]): ColorGradient[] { + const gradients: ColorGradient[] = []; + for (const key of keys) { + const pos = key.pos ?? key.time ?? 0; + if (key.value !== undefined && pos !== undefined) { + let color4: Color4; + if (typeof key.value === "number") { + // Single number - grayscale + color4 = new Color4(key.value, key.value, key.value, 1); + } else if (Array.isArray(key.value)) { + // Array format [r, g, b, a?] + color4 = new Color4(key.value[0] || 0, key.value[1] || 0, key.value[2] || 0, key.value[3] !== undefined ? key.value[3] : 1); + } else { + // Object format { r, g, b, a? } + color4 = new Color4(key.value.r || 0, key.value.g || 0, key.value.b || 0, key.value.a !== undefined ? key.value.a : 1); + } + gradients.push(new ColorGradient(pos, color4)); + } + } + return gradients; + } + + /** + * Parse gradient alpha keys + */ + public static parseGradientAlphaKeys(keys: IGradientKey[]): { gradient: number; factor: number }[] { + const gradients: { gradient: number; factor: number }[] = []; + for (const key of keys) { + const pos = key.pos ?? key.time ?? 0; + if (key.value !== undefined && pos !== undefined) { + let factor: number; + if (typeof key.value === "number") { + factor = key.value; + } else if (Array.isArray(key.value)) { + factor = key.value[3] !== undefined ? key.value[3] : 1; + } else { + factor = key.value.a !== undefined ? key.value.a : 1; + } + gradients.push({ gradient: pos, factor }); + } + } + return gradients; + } +} diff --git a/tools/src/index.ts b/tools/src/index.ts index acc015555..43e23c42d 100644 --- a/tools/src/index.ts +++ b/tools/src/index.ts @@ -33,3 +33,5 @@ export * from "./cinematic/typings"; export * from "./cinematic/generate"; export * from "./cinematic/guards"; export * from "./cinematic/cinematic"; + +export * from "./effect"; diff --git a/yarn.lock b/yarn.lock index aafd20c64..db0a37489 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1771,6 +1771,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46" integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== +"@radix-ui/number@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" + integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== + "@radix-ui/primitive@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd" @@ -2309,6 +2314,21 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-controllable-state" "1.2.2" +"@radix-ui/react-scroll-area@^1.2.10": + version "1.2.10" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz#e4fd3b4a79bb77bec1a52f0c8f26d8f3f1ca4b22" + integrity sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A== + dependencies: + "@radix-ui/number" "1.1.1" + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-select@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.0.0.tgz#a3511792a51a7018d6559357323a7f52e0e38887" @@ -3067,6 +3087,13 @@ dependencies: tslib "^2.4.0" +"@types/adm-zip@^0.5.7": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.5.7.tgz#eec10b6f717d3948beb64aca0abebc4b344ac7e9" + integrity sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw== + dependencies: + "@types/node" "*" + "@types/babel__core@^7.20.4": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -3572,6 +3599,11 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== +adm-zip@^0.5.16: + version "0.5.16" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909" + integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -3949,8 +3981,9 @@ babylonjs-editor-tools@latest: resolved "https://registry.yarnpkg.com/babylonjs-editor-tools/-/babylonjs-editor-tools-5.0.0.tgz#d2e1919cc5d4defbcbcf60259a1eb73a589cf643" integrity sha512-AREjL0WjtjyOvud0EMG/II3zH73KlSif/u0HV965tPWmUZHrxr+g/4iX6eU0mIYlIjOuepfRAopaF04IYJOaHA== -"babylonjs-editor-tools@link:../../AppData/Local/Yarn/Cache/v6/npm-babylonjs-editor-5.2.4-3cce3a704dc0c4572a85041a993264060376230a-integrity/node_modules/tools": +"babylonjs-editor-tools@link:../../../Library/Caches/Yarn/v6/npm-babylonjs-editor-5.2.4-3cce3a704dc0c4572a85041a993264060376230a-integrity/node_modules/tools": version "0.0.0" + uid "" "babylonjs-editor-tools@link:tools": version "5.2.4" @@ -3997,7 +4030,7 @@ babylonjs-editor@latest: axios "1.12.0" babylonjs "8.41.0" babylonjs-addons "8.41.0" - babylonjs-editor-tools "link:../../../Library/Caches/Yarn/v6/npm-babylonjs-editor-5.2.4-3cce3a704dc0c4572a85041a993264060376230a-integrity/node_modules/tools" + babylonjs-editor-tools "link:C:/Users/soull/AppData/Local/Yarn/Cache/v6/npm-babylonjs-editor-5.2.4-3cce3a704dc0c4572a85041a993264060376230a-integrity/node_modules/tools" babylonjs-gui "8.41.0" babylonjs-gui-editor "8.41.0" babylonjs-loaders "8.41.0" @@ -6963,7 +6996,7 @@ js-tokens@^9.0.1: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== -js-yaml@^4.1.0: +js-yaml@^4.1.0, js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==