diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 63380237f..6018bbcaf 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -4,11 +4,271 @@ exports[`DTS API compatibility assets.d.ts should match snapshot 1`] = ` "export declare interface AnimationClipAssetUserData { name: string; } +export declare const animationGraph: { + query(uuidOrUrlOrPath: string): Promise; + queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; + resetInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; + createInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; + execute(uuidOrUrlOrPath: string, request: ExecuteAnimationGraphCommandRequest): Promise; + save(uuidOrUrlOrPath: string, expected: AnimationGraphExpectedVersion, sourceId?: string): Promise; + reload(uuidOrUrlOrPath: string, options?: ReloadAnimationGraphOptions, sourceId?: string): Promise; + onChanged(listener: (event: AnimationGraphChangedEvent) => void): () => void; +}; +export declare interface AnimationGraphChangedEvent { + uuid: string; + reason: 'inspector' | 'structure' | 'save' | 'reload' | 'external'; + version: AnimationGraphVersion; + sourceId?: string; + changedPaths?: string[]; +} +export declare type AnimationGraphCommand = +| { type: 'add-layer'; name?: string } +| { type: 'remove-layer'; layerIndex: number } +| { type: 'move-layer'; layerIndex: number; newIndex: number } +| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; editorData?: Record } & AnimationGraphStateMachineAddress) +| ({ type: 'remove-state' } & AnimationGraphStateAddress) +| ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) +| ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) +| ({ type: 'add-transition'; fromStateIndex: number; toStateIndex: number } & AnimationGraphStateMachineAddress) +| ({ type: 'remove-transition'; transitionIndex: number; allBetween?: boolean } & AnimationGraphStateMachineAddress) +| ({ type: 'move-transition'; transitionIndex: number; offset: number } & AnimationGraphStateMachineAddress) +| { type: 'add-transition-condition'; target: Extract; conditionType: AnimationGraphTransitionConditionType } +| { type: 'remove-transition-condition'; target: Extract; conditionIndex: number } +| { type: 'set-transition-condition-property'; target: Extract; conditionIndex: number; path: string; value: unknown } +| ({ type: 'set-motion'; motionType: AnimationGraphMotionType | 'none'; clipUuid?: string } & (AnimationGraphStateAddress | { poseGraph: AnimationGraphPoseGraphContext; nodeId: number })) +| { type: 'add-motion-child'; target: Extract; motionType: AnimationGraphMotionType; clipUuid?: string } +| { type: 'remove-motion'; target: Extract } +| { type: 'set-motion-editor-data'; target: Extract; editorData: Record } +| { type: 'set-motion-threshold'; target: Extract; childIndex: number; threshold: number | { x: number; y: number } } +| { type: 'set-direct-blend-weight'; target: Extract; childIndex: number; value?: number; variable?: string } +| ({ type: 'add-state-component'; componentType: string } & AnimationGraphStateAddress) +| ({ type: 'remove-state-component'; componentIndex: number } & AnimationGraphStateAddress) +| ({ type: 'add-pose-node'; nodeType: string; createArg?: unknown; editorData?: Record } & AnimationGraphPoseGraphAddress) +| { type: 'remove-pose-node'; target: Extract } +| ({ type: 'duplicate-pose-nodes'; nodeIds: number[] } & AnimationGraphPoseGraphAddress) +| { type: 'set-pose-node-editor-data'; target: Extract; editorData: Record } +| ({ type: 'connect-pose-nodes'; producerNodeId: number; producerOutputId: number; consumerNodeId: number; consumerInputId: string } & AnimationGraphPoseGraphAddress) +| { type: 'disconnect-pose-input'; target: Extract } +| { type: 'insert-pose-input'; target: Extract; insertId: string } +| { type: 'delete-pose-input'; target: Extract } +| { type: 'add-variable'; name: string; variableType: number; initialValue?: unknown } +| { type: 'set-variable-value'; name: string; patch: IProperty | unknown } +| { type: 'set-trigger-reset-mode'; name: string; resetMode: number } +| { type: 'remove-variable'; name: string } +| { type: 'rename-variable'; name: string; newName: string } +| { type: 'add-stash'; layerIndex: number; name: string } +| { type: 'remove-stash'; layerIndex: number; name: string } +| { type: 'rename-stash'; layerIndex: number; name: string; newName: string } +| { type: 'stash-pose-graph'; poseGraph: AnimationGraphPoseGraphContext; layerIndex: number; stashName?: string; editorData?: Record }; +export declare interface AnimationGraphComponentView { + index: number; + type: string; +} +export declare type AnimationGraphEditErrorCode = +| 'VERSION_CONFLICT' +| 'DOCUMENT_RELOADED' +| 'SOURCE_CHANGED' +| 'TARGET_NOT_FOUND' +| 'UNSUPPORTED_TARGET' +| 'UNSUPPORTED_PROPERTY_OPERATION' +| 'INVALID_PROPERTY_PATCH' +| 'READONLY_PROPERTY' +| 'NAME_CONFLICT' +| 'DIRTY_DOCUMENT'; +export declare interface AnimationGraphExpectedVersion { + documentId: string; + revision: number; +} +export declare interface AnimationGraphInspectorPropertyCapabilities { + set: boolean; + reset: boolean; + create: boolean; +} +export declare interface AnimationGraphInspectorPropertyOperationRequest { + target: AnimationGraphTarget; + path: string; + expected: AnimationGraphExpectedVersion; + sourceId?: string; +} +export declare interface AnimationGraphInspectorSnapshot extends AnimationGraphVersion { + uuid: string; + target: AnimationGraphTarget; + dump: IProperty; + propertyCapabilities?: Record; +} +export declare interface AnimationGraphLayerView { + index: number; + name: string; + weight: number; + additive: boolean; + maskUuid: string | null; + stashes: string[]; + stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView }>; + stateMachine: AnimationGraphStateMachineView; +} +export declare type AnimationGraphMotionAddress = +| (AnimationGraphStateAddress & { level: number[] }) +| ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; +export declare interface AnimationGraphMotionView { + level: number[]; + target: Extract; + type: 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct' | 'unknown'; + name: string; + clipUuid?: string | null; + variable?: string; + value?: number; + variableX?: string; + valueX?: number; + variableY?: string; + valueY?: number; + threshold?: number | { x: number; y: number }; + weight?: { value: number; variable: string }; + children?: AnimationGraphMotionView[]; + editorData?: Record; +} +export declare type AnimationGraphPoseGraphAddress = +| { layerIndex: number; stateMachinePath: number[]; stateIndex: number } +| { poseGraph: AnimationGraphPoseGraphContext }; +export declare type AnimationGraphPoseGraphContext = +| { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } +| { kind: 'layer-stash'; layerIndex: number; stashName: string }; +export declare interface AnimationGraphPoseInputView { + id: string; + displayName: string; + type: number; + deletable: boolean; + insertPoint: boolean; + connected: boolean; + producerNodeId?: number; + producerOutputId?: number; + value?: IProperty; +} +export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; +export declare interface AnimationGraphPoseNodeView { + id: number; + type: string; + title: string; + outputTypes: number[]; + inputs: AnimationGraphPoseInputView[]; + inputInsertInfos: Record; + stateMachine?: AnimationGraphStateMachineView; + motion?: AnimationGraphMotionView | null; + editorData?: Record; +} +export declare interface AnimationGraphPoseView { + context: AnimationGraphPoseGraphContext; + rootOutputNodeId: number; + nodes: AnimationGraphPoseNodeView[]; +} +export declare interface AnimationGraphSnapshot extends AnimationGraphVersion { + uuid: string; + url: string; + graph: AnimationGraphViewDump; +} +export declare type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; +export declare type AnimationGraphStateMachineAddress = +| { layerIndex: number; stateMachinePath: number[] } +| { stateMachine: AnimationGraphStateMachineContext }; +export declare type AnimationGraphStateMachineContext = +| { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } +| { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } +| { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; +export declare interface AnimationGraphStateMachineView { + context: AnimationGraphStateMachineContext; + path: number[]; + allowEmptyStates: boolean; + states: AnimationGraphStateView[]; + transitions: AnimationGraphTransitionView[]; + editorData?: Record; +} +export declare type AnimationGraphStateType = 'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose'; +export declare interface AnimationGraphStateView { + index: number; + type: 'entry' | 'exit' | 'any' | 'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose' | 'unknown'; + name: string; + incomingTransitionIndices: number[]; + outgoingTransitionIndices: number[]; + components: AnimationGraphComponentView[]; + speed?: number; + speedMultiplier?: string; + speedMultiplierEnabled?: boolean; + motion?: AnimationGraphMotionView | null; + stateMachine?: AnimationGraphStateMachineView; + poseGraph?: AnimationGraphPoseView; + editorData?: Record; +} +export declare type AnimationGraphTarget = +| { kind: 'layer'; layerIndex: number } +| ({ kind: 'state' } & AnimationGraphStateAddress) +| ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) +| ({ kind: 'motion' } & AnimationGraphMotionAddress) +| ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) +| ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) +| ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); +export declare type AnimationGraphTransitionConditionType = 'binary' | 'unary' | 'trigger'; +export declare type AnimationGraphTransitionConditionView = +| { + index: number; + type: 'BinaryCondition'; + operator: number; + lhs: number; + lhsBinding: Record; + rhs: number; + isRhsInteger: boolean; +} +| { + index: number; + type: 'UnaryCondition'; + operator: number; + operand: string; +} +| { + index: number; + type: 'TriggerCondition'; + trigger: string; +} +| { + index: number; + type: 'Unknown'; + className: string; +}; +export declare interface AnimationGraphTransitionView { + index: number; + type: 'animation' | 'empty-state' | 'procedural-pose' | 'transition'; + fromStateIndex: number; + toStateIndex: number; + priority: number; + conditions: AnimationGraphTransitionConditionView[]; + duration?: number; + relativeDuration?: boolean; + exitConditionEnabled?: boolean; + exitCondition?: number; + destinationStart?: number; + relativeDestinationStart?: boolean; + editorData?: Record; +} +export declare interface AnimationGraphVariableView { + name: string; + type: number; + value: IProperty; + resetMode?: number; +} export declare const animationGraphVariant: { query(uuid: string): Promise; change(uuid: string, dump: AnimGraphVariantDump): Promise; save(uuid: string): Promise; }; +export declare interface AnimationGraphVersion extends AnimationGraphExpectedVersion { + persistedRevision: number; + dirty: boolean; + externallyModified: boolean; +} +export declare interface AnimationGraphViewDump { + layers: AnimationGraphLayerView[]; + variables: AnimationGraphVariableView[]; +} export declare interface AnimationImportSetting { name: string; duration: number; @@ -214,6 +474,11 @@ export declare interface EffectAssetUserData { combinations?: any; editor?: any; } +export declare interface ExecuteAnimationGraphCommandRequest { + command: AnimationGraphCommand; + expected: AnimationGraphExpectedVersion; + sourceId?: string; +} export declare interface ExecuteAssetDBScriptMethodOptions { name: string; method: string; @@ -804,6 +1069,10 @@ export declare function queryUrl(uuidOrPath: string): Promise; export declare function queryUUID(urlOrPath: string): Promise; export declare function refresh(dir: string): Promise; export declare function reimportAsset(pathOrUrlOrUUID: string): Promise; +export declare interface ReloadAnimationGraphOptions { + expected?: AnimationGraphExpectedVersion; + discardDirty?: boolean; +} export declare function renameAsset(source: string, newName: string, options?: AssetOperationOption): Promise; export declare interface RenderTextureAssetUserData extends TextureBaseAssetUserData { width: number; @@ -841,6 +1110,9 @@ export declare const serializedData: { query: typeof querySerializedData; save: typeof saveSerializedData; }; +export declare interface SetAnimationGraphInspectorPropertyRequest extends AnimationGraphInspectorPropertyOperationRequest { + patch: IProperty | unknown; +} export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; export declare interface SimplifyOptions { targetRatio?: number; @@ -7674,11 +7946,271 @@ import { Response as Response_2 } from 'express'; export declare interface AnimationClipAssetUserData { name: string; } +export declare const animationGraph: { + query(uuidOrUrlOrPath: string): Promise; + queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; + resetInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; + createInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; + execute(uuidOrUrlOrPath: string, request: ExecuteAnimationGraphCommandRequest): Promise; + save(uuidOrUrlOrPath: string, expected: AnimationGraphExpectedVersion, sourceId?: string): Promise; + reload(uuidOrUrlOrPath: string, options?: ReloadAnimationGraphOptions, sourceId?: string): Promise; + onChanged(listener: (event: AnimationGraphChangedEvent) => void): () => void; +}; +export declare interface AnimationGraphChangedEvent { + uuid: string; + reason: 'inspector' | 'structure' | 'save' | 'reload' | 'external'; + version: AnimationGraphVersion; + sourceId?: string; + changedPaths?: string[]; +} +export declare type AnimationGraphCommand = +| { type: 'add-layer'; name?: string } +| { type: 'remove-layer'; layerIndex: number } +| { type: 'move-layer'; layerIndex: number; newIndex: number } +| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; editorData?: Record } & AnimationGraphStateMachineAddress) +| ({ type: 'remove-state' } & AnimationGraphStateAddress) +| ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) +| ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) +| ({ type: 'add-transition'; fromStateIndex: number; toStateIndex: number } & AnimationGraphStateMachineAddress) +| ({ type: 'remove-transition'; transitionIndex: number; allBetween?: boolean } & AnimationGraphStateMachineAddress) +| ({ type: 'move-transition'; transitionIndex: number; offset: number } & AnimationGraphStateMachineAddress) +| { type: 'add-transition-condition'; target: Extract; conditionType: AnimationGraphTransitionConditionType } +| { type: 'remove-transition-condition'; target: Extract; conditionIndex: number } +| { type: 'set-transition-condition-property'; target: Extract; conditionIndex: number; path: string; value: unknown } +| ({ type: 'set-motion'; motionType: AnimationGraphMotionType | 'none'; clipUuid?: string } & (AnimationGraphStateAddress | { poseGraph: AnimationGraphPoseGraphContext; nodeId: number })) +| { type: 'add-motion-child'; target: Extract; motionType: AnimationGraphMotionType; clipUuid?: string } +| { type: 'remove-motion'; target: Extract } +| { type: 'set-motion-editor-data'; target: Extract; editorData: Record } +| { type: 'set-motion-threshold'; target: Extract; childIndex: number; threshold: number | { x: number; y: number } } +| { type: 'set-direct-blend-weight'; target: Extract; childIndex: number; value?: number; variable?: string } +| ({ type: 'add-state-component'; componentType: string } & AnimationGraphStateAddress) +| ({ type: 'remove-state-component'; componentIndex: number } & AnimationGraphStateAddress) +| ({ type: 'add-pose-node'; nodeType: string; createArg?: unknown; editorData?: Record } & AnimationGraphPoseGraphAddress) +| { type: 'remove-pose-node'; target: Extract } +| ({ type: 'duplicate-pose-nodes'; nodeIds: number[] } & AnimationGraphPoseGraphAddress) +| { type: 'set-pose-node-editor-data'; target: Extract; editorData: Record } +| ({ type: 'connect-pose-nodes'; producerNodeId: number; producerOutputId: number; consumerNodeId: number; consumerInputId: string } & AnimationGraphPoseGraphAddress) +| { type: 'disconnect-pose-input'; target: Extract } +| { type: 'insert-pose-input'; target: Extract; insertId: string } +| { type: 'delete-pose-input'; target: Extract } +| { type: 'add-variable'; name: string; variableType: number; initialValue?: unknown } +| { type: 'set-variable-value'; name: string; patch: IProperty | unknown } +| { type: 'set-trigger-reset-mode'; name: string; resetMode: number } +| { type: 'remove-variable'; name: string } +| { type: 'rename-variable'; name: string; newName: string } +| { type: 'add-stash'; layerIndex: number; name: string } +| { type: 'remove-stash'; layerIndex: number; name: string } +| { type: 'rename-stash'; layerIndex: number; name: string; newName: string } +| { type: 'stash-pose-graph'; poseGraph: AnimationGraphPoseGraphContext; layerIndex: number; stashName?: string; editorData?: Record }; +export declare interface AnimationGraphComponentView { + index: number; + type: string; +} +export declare type AnimationGraphEditErrorCode = +| 'VERSION_CONFLICT' +| 'DOCUMENT_RELOADED' +| 'SOURCE_CHANGED' +| 'TARGET_NOT_FOUND' +| 'UNSUPPORTED_TARGET' +| 'UNSUPPORTED_PROPERTY_OPERATION' +| 'INVALID_PROPERTY_PATCH' +| 'READONLY_PROPERTY' +| 'NAME_CONFLICT' +| 'DIRTY_DOCUMENT'; +export declare interface AnimationGraphExpectedVersion { + documentId: string; + revision: number; +} +export declare interface AnimationGraphInspectorPropertyCapabilities { + set: boolean; + reset: boolean; + create: boolean; +} +export declare interface AnimationGraphInspectorPropertyOperationRequest { + target: AnimationGraphTarget; + path: string; + expected: AnimationGraphExpectedVersion; + sourceId?: string; +} +export declare interface AnimationGraphInspectorSnapshot extends AnimationGraphVersion { + uuid: string; + target: AnimationGraphTarget; + dump: IProperty; + propertyCapabilities?: Record; +} +export declare interface AnimationGraphLayerView { + index: number; + name: string; + weight: number; + additive: boolean; + maskUuid: string | null; + stashes: string[]; + stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView }>; + stateMachine: AnimationGraphStateMachineView; +} +export declare type AnimationGraphMotionAddress = +| (AnimationGraphStateAddress & { level: number[] }) +| ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; +export declare interface AnimationGraphMotionView { + level: number[]; + target: Extract; + type: 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct' | 'unknown'; + name: string; + clipUuid?: string | null; + variable?: string; + value?: number; + variableX?: string; + valueX?: number; + variableY?: string; + valueY?: number; + threshold?: number | { x: number; y: number }; + weight?: { value: number; variable: string }; + children?: AnimationGraphMotionView[]; + editorData?: Record; +} +export declare type AnimationGraphPoseGraphAddress = +| { layerIndex: number; stateMachinePath: number[]; stateIndex: number } +| { poseGraph: AnimationGraphPoseGraphContext }; +export declare type AnimationGraphPoseGraphContext = +| { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } +| { kind: 'layer-stash'; layerIndex: number; stashName: string }; +export declare interface AnimationGraphPoseInputView { + id: string; + displayName: string; + type: number; + deletable: boolean; + insertPoint: boolean; + connected: boolean; + producerNodeId?: number; + producerOutputId?: number; + value?: IProperty; +} +export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; +export declare interface AnimationGraphPoseNodeView { + id: number; + type: string; + title: string; + outputTypes: number[]; + inputs: AnimationGraphPoseInputView[]; + inputInsertInfos: Record; + stateMachine?: AnimationGraphStateMachineView; + motion?: AnimationGraphMotionView | null; + editorData?: Record; +} +export declare interface AnimationGraphPoseView { + context: AnimationGraphPoseGraphContext; + rootOutputNodeId: number; + nodes: AnimationGraphPoseNodeView[]; +} +export declare interface AnimationGraphSnapshot extends AnimationGraphVersion { + uuid: string; + url: string; + graph: AnimationGraphViewDump; +} +export declare type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; +export declare type AnimationGraphStateMachineAddress = +| { layerIndex: number; stateMachinePath: number[] } +| { stateMachine: AnimationGraphStateMachineContext }; +export declare type AnimationGraphStateMachineContext = +| { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } +| { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } +| { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; +export declare interface AnimationGraphStateMachineView { + context: AnimationGraphStateMachineContext; + path: number[]; + allowEmptyStates: boolean; + states: AnimationGraphStateView[]; + transitions: AnimationGraphTransitionView[]; + editorData?: Record; +} +export declare type AnimationGraphStateType = 'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose'; +export declare interface AnimationGraphStateView { + index: number; + type: 'entry' | 'exit' | 'any' | 'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose' | 'unknown'; + name: string; + incomingTransitionIndices: number[]; + outgoingTransitionIndices: number[]; + components: AnimationGraphComponentView[]; + speed?: number; + speedMultiplier?: string; + speedMultiplierEnabled?: boolean; + motion?: AnimationGraphMotionView | null; + stateMachine?: AnimationGraphStateMachineView; + poseGraph?: AnimationGraphPoseView; + editorData?: Record; +} +export declare type AnimationGraphTarget = +| { kind: 'layer'; layerIndex: number } +| ({ kind: 'state' } & AnimationGraphStateAddress) +| ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) +| ({ kind: 'motion' } & AnimationGraphMotionAddress) +| ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) +| ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) +| ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); +export declare type AnimationGraphTransitionConditionType = 'binary' | 'unary' | 'trigger'; +export declare type AnimationGraphTransitionConditionView = +| { + index: number; + type: 'BinaryCondition'; + operator: number; + lhs: number; + lhsBinding: Record; + rhs: number; + isRhsInteger: boolean; +} +| { + index: number; + type: 'UnaryCondition'; + operator: number; + operand: string; +} +| { + index: number; + type: 'TriggerCondition'; + trigger: string; +} +| { + index: number; + type: 'Unknown'; + className: string; +}; +export declare interface AnimationGraphTransitionView { + index: number; + type: 'animation' | 'empty-state' | 'procedural-pose' | 'transition'; + fromStateIndex: number; + toStateIndex: number; + priority: number; + conditions: AnimationGraphTransitionConditionView[]; + duration?: number; + relativeDuration?: boolean; + exitConditionEnabled?: boolean; + exitCondition?: number; + destinationStart?: number; + relativeDestinationStart?: boolean; + editorData?: Record; +} +export declare interface AnimationGraphVariableView { + name: string; + type: number; + value: IProperty; + resetMode?: number; +} export declare const animationGraphVariant: { query(uuid: string): Promise; change(uuid: string, dump: AnimGraphVariantDump): Promise; save(uuid: string): Promise; }; +export declare interface AnimationGraphVersion extends AnimationGraphExpectedVersion { + persistedRevision: number; + dirty: boolean; + externallyModified: boolean; +} +export declare interface AnimationGraphViewDump { + layers: AnimationGraphLayerView[]; + variables: AnimationGraphVariableView[]; +} export declare interface AnimationImportSetting { name: string; duration: number; @@ -7859,6 +8391,7 @@ export declare namespace Assets { IPluginScriptInfo, AnimGraphVariantDump, animationGraphVariant, + animationGraph, animationMask, serializedData, material, @@ -7874,6 +8407,41 @@ export declare namespace Assets { SerializedAssetDump, SerializedAssetPatch, SerializedAssetQueryResult, + AnimationGraphExpectedVersion, + AnimationGraphVersion, + AnimationGraphStateMachineContext, + AnimationGraphPoseGraphContext, + AnimationGraphStateMachineAddress, + AnimationGraphStateAddress, + AnimationGraphPoseGraphAddress, + AnimationGraphPoseNodeAddress, + AnimationGraphMotionAddress, + AnimationGraphTarget, + AnimationGraphComponentView, + AnimationGraphMotionView, + AnimationGraphPoseInputView, + AnimationGraphPoseNodeView, + AnimationGraphPoseView, + AnimationGraphStateView, + AnimationGraphTransitionView, + AnimationGraphTransitionConditionView, + AnimationGraphStateMachineView, + AnimationGraphLayerView, + AnimationGraphVariableView, + AnimationGraphViewDump, + AnimationGraphSnapshot, + AnimationGraphInspectorPropertyCapabilities, + AnimationGraphInspectorSnapshot, + AnimationGraphInspectorPropertyOperationRequest, + SetAnimationGraphInspectorPropertyRequest, + AnimationGraphStateType, + AnimationGraphMotionType, + AnimationGraphTransitionConditionType, + AnimationGraphCommand, + ExecuteAnimationGraphCommandRequest, + ReloadAnimationGraphOptions, + AnimationGraphChangedEvent, + AnimationGraphEditErrorCode, MaterialEffectInfo, MaterialPassDump, MaterialTechniqueDump, @@ -8122,6 +8690,11 @@ export declare interface EngineInfo { version: string; } export declare type EventEmitterMethods = Pick; +export declare interface ExecuteAnimationGraphCommandRequest { + command: AnimationGraphCommand; + expected: AnimationGraphExpectedVersion; + sourceId?: string; +} export declare interface ExecuteAssetDBScriptMethodOptions { name: string; method: string; @@ -9154,6 +9727,10 @@ export declare function register_2(name: string, module: IMiddlewareContribution export declare function registerToolCallFinalizer(key: string | symbol, callback: () => void | Promise): void; export declare function reimportAsset(pathOrUrlOrUUID: string): Promise; export declare function reload(): Promise; +export declare interface ReloadAnimationGraphOptions { + expected?: AnimationGraphExpectedVersion; + discardDirty?: boolean; +} export declare function remove(key: string, scope?: ConfigurationScope): Promise; export declare function renameAsset(source: string, newName: string, options?: AssetOperationOption): Promise; export declare interface RenderTextureAssetUserData extends TextureBaseAssetUserData { @@ -9237,6 +9814,9 @@ export declare namespace Server { } } export declare function set(key: string, value: T, scope?: ConfigurationScope): Promise; +export declare interface SetAnimationGraphInspectorPropertyRequest extends AnimationGraphInspectorPropertyOperationRequest { + patch: IProperty | unknown; +} export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; export declare interface SharedSettings { diff --git a/src/core/assets/@types/public.d.ts b/src/core/assets/@types/public.d.ts index bf9309331..dfb1d132c 100644 --- a/src/core/assets/@types/public.d.ts +++ b/src/core/assets/@types/public.d.ts @@ -38,6 +38,300 @@ export interface SerializedAssetQueryResult { dump: SerializedAssetDump; } +export interface AnimationGraphExpectedVersion { + documentId: string; + revision: number; +} + +export interface AnimationGraphVersion extends AnimationGraphExpectedVersion { + persistedRevision: number; + dirty: boolean; + externallyModified: boolean; +} + +export type AnimationGraphStateMachineContext = + | { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } + | { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } + | { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; + +export type AnimationGraphPoseGraphContext = + | { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } + | { kind: 'layer-stash'; layerIndex: number; stashName: string }; + +export type AnimationGraphStateMachineAddress = + | { layerIndex: number; stateMachinePath: number[] } + | { stateMachine: AnimationGraphStateMachineContext }; + +export type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; + +export type AnimationGraphPoseGraphAddress = + | { layerIndex: number; stateMachinePath: number[]; stateIndex: number } + | { poseGraph: AnimationGraphPoseGraphContext }; + +export type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; + +export type AnimationGraphMotionAddress = + | (AnimationGraphStateAddress & { level: number[] }) + | ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); + +export type AnimationGraphTarget = + | { kind: 'layer'; layerIndex: number } + | ({ kind: 'state' } & AnimationGraphStateAddress) + | ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) + | ({ kind: 'motion' } & AnimationGraphMotionAddress) + | ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) + | ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) + | ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); + +export interface AnimationGraphComponentView { + index: number; + type: string; +} + +export interface AnimationGraphMotionView { + level: number[]; + target: Extract; + type: 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct' | 'unknown'; + name: string; + clipUuid?: string | null; + variable?: string; + value?: number; + variableX?: string; + valueX?: number; + variableY?: string; + valueY?: number; + threshold?: number | { x: number; y: number }; + weight?: { value: number; variable: string }; + children?: AnimationGraphMotionView[]; + editorData?: Record; +} + +export interface AnimationGraphPoseInputView { + id: string; + displayName: string; + type: number; + deletable: boolean; + insertPoint: boolean; + connected: boolean; + producerNodeId?: number; + producerOutputId?: number; + value?: IProperty; +} + +export interface AnimationGraphPoseNodeView { + id: number; + type: string; + title: string; + outputTypes: number[]; + inputs: AnimationGraphPoseInputView[]; + inputInsertInfos: Record; + stateMachine?: AnimationGraphStateMachineView; + motion?: AnimationGraphMotionView | null; + editorData?: Record; +} + +export interface AnimationGraphPoseView { + context: AnimationGraphPoseGraphContext; + rootOutputNodeId: number; + nodes: AnimationGraphPoseNodeView[]; +} + +export interface AnimationGraphStateView { + index: number; + type: 'entry' | 'exit' | 'any' | 'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose' | 'unknown'; + name: string; + incomingTransitionIndices: number[]; + outgoingTransitionIndices: number[]; + components: AnimationGraphComponentView[]; + speed?: number; + speedMultiplier?: string; + speedMultiplierEnabled?: boolean; + motion?: AnimationGraphMotionView | null; + stateMachine?: AnimationGraphStateMachineView; + poseGraph?: AnimationGraphPoseView; + editorData?: Record; +} + +export interface AnimationGraphTransitionView { + index: number; + type: 'animation' | 'empty-state' | 'procedural-pose' | 'transition'; + fromStateIndex: number; + toStateIndex: number; + priority: number; + conditions: AnimationGraphTransitionConditionView[]; + duration?: number; + relativeDuration?: boolean; + exitConditionEnabled?: boolean; + exitCondition?: number; + destinationStart?: number; + relativeDestinationStart?: boolean; + editorData?: Record; +} + +export type AnimationGraphTransitionConditionView = + | { + index: number; + type: 'BinaryCondition'; + operator: number; + lhs: number; + lhsBinding: Record; + rhs: number; + isRhsInteger: boolean; + } + | { + index: number; + type: 'UnaryCondition'; + operator: number; + operand: string; + } + | { + index: number; + type: 'TriggerCondition'; + trigger: string; + } + | { + index: number; + type: 'Unknown'; + className: string; + }; + +export interface AnimationGraphStateMachineView { + context: AnimationGraphStateMachineContext; + path: number[]; + allowEmptyStates: boolean; + states: AnimationGraphStateView[]; + transitions: AnimationGraphTransitionView[]; + editorData?: Record; +} + +export interface AnimationGraphLayerView { + index: number; + name: string; + weight: number; + additive: boolean; + maskUuid: string | null; + stashes: string[]; + stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView }>; + stateMachine: AnimationGraphStateMachineView; +} + +export interface AnimationGraphVariableView { + name: string; + type: number; + value: IProperty; + resetMode?: number; +} + +export interface AnimationGraphViewDump { + layers: AnimationGraphLayerView[]; + variables: AnimationGraphVariableView[]; +} + +export interface AnimationGraphSnapshot extends AnimationGraphVersion { + uuid: string; + url: string; + graph: AnimationGraphViewDump; +} + +export interface AnimationGraphInspectorPropertyCapabilities { + set: boolean; + reset: boolean; + create: boolean; +} + +export interface AnimationGraphInspectorSnapshot extends AnimationGraphVersion { + uuid: string; + target: AnimationGraphTarget; + dump: IProperty; + propertyCapabilities?: Record; +} + +export interface AnimationGraphInspectorPropertyOperationRequest { + target: AnimationGraphTarget; + path: string; + expected: AnimationGraphExpectedVersion; + sourceId?: string; +} + +export interface SetAnimationGraphInspectorPropertyRequest extends AnimationGraphInspectorPropertyOperationRequest { + patch: IProperty | unknown; +} + +export type AnimationGraphStateType = 'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose'; +export type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; +export type AnimationGraphTransitionConditionType = 'binary' | 'unary' | 'trigger'; + +export type AnimationGraphCommand = + | { type: 'add-layer'; name?: string } + | { type: 'remove-layer'; layerIndex: number } + | { type: 'move-layer'; layerIndex: number; newIndex: number } + | ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; editorData?: Record } & AnimationGraphStateMachineAddress) + | ({ type: 'remove-state' } & AnimationGraphStateAddress) + | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) + | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) + | ({ type: 'add-transition'; fromStateIndex: number; toStateIndex: number } & AnimationGraphStateMachineAddress) + | ({ type: 'remove-transition'; transitionIndex: number; allBetween?: boolean } & AnimationGraphStateMachineAddress) + | ({ type: 'move-transition'; transitionIndex: number; offset: number } & AnimationGraphStateMachineAddress) + | { type: 'add-transition-condition'; target: Extract; conditionType: AnimationGraphTransitionConditionType } + | { type: 'remove-transition-condition'; target: Extract; conditionIndex: number } + | { type: 'set-transition-condition-property'; target: Extract; conditionIndex: number; path: string; value: unknown } + | ({ type: 'set-motion'; motionType: AnimationGraphMotionType | 'none'; clipUuid?: string } & (AnimationGraphStateAddress | { poseGraph: AnimationGraphPoseGraphContext; nodeId: number })) + | { type: 'add-motion-child'; target: Extract; motionType: AnimationGraphMotionType; clipUuid?: string } + | { type: 'remove-motion'; target: Extract } + | { type: 'set-motion-editor-data'; target: Extract; editorData: Record } + | { type: 'set-motion-threshold'; target: Extract; childIndex: number; threshold: number | { x: number; y: number } } + | { type: 'set-direct-blend-weight'; target: Extract; childIndex: number; value?: number; variable?: string } + | ({ type: 'add-state-component'; componentType: string } & AnimationGraphStateAddress) + | ({ type: 'remove-state-component'; componentIndex: number } & AnimationGraphStateAddress) + | ({ type: 'add-pose-node'; nodeType: string; createArg?: unknown; editorData?: Record } & AnimationGraphPoseGraphAddress) + | { type: 'remove-pose-node'; target: Extract } + | ({ type: 'duplicate-pose-nodes'; nodeIds: number[] } & AnimationGraphPoseGraphAddress) + | { type: 'set-pose-node-editor-data'; target: Extract; editorData: Record } + | ({ type: 'connect-pose-nodes'; producerNodeId: number; producerOutputId: number; consumerNodeId: number; consumerInputId: string } & AnimationGraphPoseGraphAddress) + | { type: 'disconnect-pose-input'; target: Extract } + | { type: 'insert-pose-input'; target: Extract; insertId: string } + | { type: 'delete-pose-input'; target: Extract } + | { type: 'add-variable'; name: string; variableType: number; initialValue?: unknown } + | { type: 'set-variable-value'; name: string; patch: IProperty | unknown } + | { type: 'set-trigger-reset-mode'; name: string; resetMode: number } + | { type: 'remove-variable'; name: string } + | { type: 'rename-variable'; name: string; newName: string } + | { type: 'add-stash'; layerIndex: number; name: string } + | { type: 'remove-stash'; layerIndex: number; name: string } + | { type: 'rename-stash'; layerIndex: number; name: string; newName: string } + | { type: 'stash-pose-graph'; poseGraph: AnimationGraphPoseGraphContext; layerIndex: number; stashName?: string; editorData?: Record }; + +export interface ExecuteAnimationGraphCommandRequest { + command: AnimationGraphCommand; + expected: AnimationGraphExpectedVersion; + sourceId?: string; +} + +export interface ReloadAnimationGraphOptions { + expected?: AnimationGraphExpectedVersion; + discardDirty?: boolean; +} + +export interface AnimationGraphChangedEvent { + uuid: string; + reason: 'inspector' | 'structure' | 'save' | 'reload' | 'external'; + version: AnimationGraphVersion; + sourceId?: string; + changedPaths?: string[]; +} + +export type AnimationGraphEditErrorCode = + | 'VERSION_CONFLICT' + | 'DOCUMENT_RELOADED' + | 'SOURCE_CHANGED' + | 'TARGET_NOT_FOUND' + | 'UNSUPPORTED_TARGET' + | 'UNSUPPORTED_PROPERTY_OPERATION' + | 'INVALID_PROPERTY_PATCH' + | 'READONLY_PROPERTY' + | 'NAME_CONFLICT' + | 'DIRTY_DOCUMENT'; + export interface MaterialEffectInfo { uuid: string; name: string; diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts new file mode 100644 index 000000000..6f0bcecf3 --- /dev/null +++ b/src/core/assets/animation-graph-service.ts @@ -0,0 +1,2314 @@ +import { createHash, randomUUID } from 'crypto'; +import { readFile, stat } from 'fs-extra'; + +import type { IAsset } from './@types/protected'; +import type { + AnimationGraphChangedEvent, + AnimationGraphCommand, + AnimationGraphExpectedVersion, + AnimationGraphInspectorPropertyCapabilities, + AnimationGraphInspectorPropertyOperationRequest, + AnimationGraphInspectorSnapshot, + AnimationGraphLayerView, + AnimationGraphMotionType, + AnimationGraphMotionView, + AnimationGraphPoseGraphContext, + AnimationGraphPoseView, + AnimationGraphSnapshot, + AnimationGraphStateMachineContext, + AnimationGraphStateMachineView, + AnimationGraphStateView, + AnimationGraphTarget, + AnimationGraphTransitionConditionView, + AnimationGraphTransitionView, + AnimationGraphVersion, + AnimationGraphViewDump, + ExecuteAnimationGraphCommandRequest, + ReloadAnimationGraphOptions, + SetAnimationGraphInspectorPropertyRequest, +} from './@types/public'; +import type { IProperty } from '../scene/@types/public'; +import { deserialize as deserializeAssetSource } from './asset-handler/utils'; +import assetOperation from './manager/operation'; +import assetQuery from './manager/query'; +import { + applyEncodedPropertyPatch, + applyEncodedPropertyOperation, + applyPropertyObjectOperation, + applyPropertyObjectPatch, + encodePropertyObject, + encodeSerializedObject, + getEncodedPropertyOperationCapabilities, + queryPropertyObjectOperationCapabilities, +} from './serialized-data'; +import { serialize as editorSerialize } from '../engine/editor-extends'; + +type AnimationGraphChangeListener = (event: AnimationGraphChangedEvent) => void; + +interface SourceFingerprint { + hash: string | null; + mtimeMs: number | null; + assetDbMtime: number | null; +} + +interface AnimationGraphDocument { + uuid: string; + url: string; + source: string; + graph: any; + documentId: string; + revision: number; + persistedRevision: number; + dirty: boolean; + externallyModified: boolean; + fingerprint: SourceFingerprint; + nodeIds: WeakMap; + nodesById: Map; + nextNodeId: number; +} + +interface InspectorBinding { + dump: IProperty; + propertyCapabilities: Record; + apply(path: string, patch: IProperty | unknown): Promise; + reset(path: string): Promise; + create(path: string): Promise; +} + +type InspectorOperation = + | { type: 'set'; patch: IProperty | unknown } + | { type: 'reset' } + | { type: 'create' }; + +interface AdapterProperty { + get(): unknown; + set?(value: any): void; + attrs?: Record; +} + +export class AnimationGraphEditError extends Error { + constructor( + public readonly code: import('./@types/public').AnimationGraphEditErrorCode, + message: string, + public readonly currentVersion?: AnimationGraphVersion, + ) { + super(message); + this.name = 'AnimationGraphEditError'; + } +} + +class AnimationGraphAssetService { + private readonly _documents = new Map(); + private readonly _queues = new Map>(); + private readonly _listeners = new Set(); + + async query(uuidOrUrlOrPath: string): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + await this._refreshExternalState(document); + return this._snapshot(document); + }); + } + + async queryInspector( + uuidOrUrlOrPath: string, + target: AnimationGraphTarget, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + await this._refreshExternalState(document); + return this._inspectorSnapshot(document, target); + }); + } + + async setInspectorProperty( + uuidOrUrlOrPath: string, + request: SetAnimationGraphInspectorPropertyRequest, + ): Promise { + return this._applyInspectorOperation(uuidOrUrlOrPath, request, { + type: 'set', + patch: request.patch, + }); + } + + async resetInspectorProperty( + uuidOrUrlOrPath: string, + request: AnimationGraphInspectorPropertyOperationRequest, + ): Promise { + return this._applyInspectorOperation(uuidOrUrlOrPath, request, { type: 'reset' }); + } + + async createInspectorProperty( + uuidOrUrlOrPath: string, + request: AnimationGraphInspectorPropertyOperationRequest, + ): Promise { + return this._applyInspectorOperation(uuidOrUrlOrPath, request, { type: 'create' }); + } + + private async _applyInspectorOperation( + uuidOrUrlOrPath: string, + request: AnimationGraphInspectorPropertyOperationRequest, + operation: InspectorOperation, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + this._assertExpectedVersion(document, request.expected); + await this._assertSourceUnchanged(document); + const before = this._serialize(document.graph); + const draft = this._cloneDocumentForMutation(document, before); + const binding = this._resolveInspectorBinding(draft, request.target); + try { + switch (operation.type) { + case 'set': + await binding.apply(request.path, operation.patch); + break; + case 'reset': + await binding.reset(request.path); + break; + case 'create': + await binding.create(request.path); + break; + } + } catch (error) { + if (error instanceof AnimationGraphEditError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + const code = /readonly|hidden/i.test(message) + ? 'READONLY_PROPERTY' + : /does not support (reset|create)/i.test(message) + ? 'UNSUPPORTED_PROPERTY_OPERATION' + : 'INVALID_PROPERTY_PATCH'; + throw new AnimationGraphEditError(code, message, this._version(document)); + } + if (this._serialize(draft.graph) === before) { + return this._inspectorSnapshot(document, request.target); + } + this._commitMutation(document, draft); + this._markChanged(document, 'inspector', request.sourceId, [request.path]); + return this._inspectorSnapshot(document, request.target); + }); + } + + async execute( + uuidOrUrlOrPath: string, + request: ExecuteAnimationGraphCommandRequest, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + this._assertExpectedVersion(document, request.expected); + await this._assertSourceUnchanged(document); + const before = this._serialize(document.graph); + const draft = this._cloneDocumentForMutation(document, before); + try { + await this._executeCommand(draft, request.command); + } catch (error) { + if (error instanceof AnimationGraphEditError) { + throw error; + } + throw new AnimationGraphEditError( + 'INVALID_PROPERTY_PATCH', + error instanceof Error ? error.message : String(error), + this._version(document), + ); + } + if (this._serialize(draft.graph) === before) { + return this._snapshot(document); + } + this._commitMutation(document, draft); + this._markChanged(document, 'structure', request.sourceId, [this._commandPath(request.command)]); + return this._snapshot(document); + }); + } + + async save( + uuidOrUrlOrPath: string, + expected: AnimationGraphExpectedVersion, + sourceId?: string, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + this._assertExpectedVersion(document, expected); + await this._assertSourceUnchanged(document); + + const serialized = this._serialize(document.graph); + await assetOperation.saveAnimationGraphDocument(document.uuid, serialized); + document.fingerprint = await this._readFingerprint(document.source, document.uuid); + document.persistedRevision = document.revision; + document.dirty = false; + document.externallyModified = false; + this._emitChanged(document, 'save', sourceId); + return this._snapshot(document); + }); + } + + async reload( + uuidOrUrlOrPath: string, + options: ReloadAnimationGraphOptions = {}, + sourceId?: string, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const current = this._documents.get(asset.uuid); + if (current && options.expected) { + this._assertExpectedVersion(current, options.expected); + } + if (current?.dirty && !options.discardDirty) { + throw new AnimationGraphEditError( + 'DIRTY_DOCUMENT', + `Animation Graph has unsaved changes: ${asset.uuid}`, + this._version(current), + ); + } + const document = await this._loadDocument(asset); + this._documents.set(asset.uuid, document); + this._emitChanged(document, 'reload', sourceId); + return this._snapshot(document); + }); + } + + onChanged(listener: AnimationGraphChangeListener): () => void { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } + + async runExternalWrite(uuidOrUrlOrPath: string, write: () => Promise): Promise { + const asset = assetQuery.queryAsset(uuidOrUrlOrPath); + if (!asset || (asset.meta?.importer !== 'animation-graph' && (asset as any).type !== 'cc.AnimationGraph')) { + return write(); + } + return this.runExternalWrites([asset.uuid], write); + } + + async runExternalWrites(uuids: string[], write: () => Promise): Promise { + const orderedUuids = Array.from(new Set(uuids)).sort((left, right) => left.localeCompare(right)); + const run = (index: number): Promise => { + if (index === orderedUuids.length) { + return write(); + } + const uuid = orderedUuids[index]; + return this._enqueue(uuid, async () => { + this.assertExternalWriteAllowed(uuid); + const result = await run(index + 1); + const document = this._documents.get(uuid); + if (document) { + await this._refreshExternalState(document); + } + return result; + }); + }; + return run(0); + } + + assertExternalWriteAllowed(uuidOrUrlOrPath: string): void { + const asset = assetQuery.queryAsset(uuidOrUrlOrPath); + const uuid = asset?.uuid || uuidOrUrlOrPath; + const document = this._documents.get(uuid); + if (document?.dirty) { + throw new AnimationGraphEditError( + 'DIRTY_DOCUMENT', + `Animation Graph has unsaved changes and can not be overwritten through the generic asset API: ${uuid}`, + this._version(document), + ); + } + } + + private async _getOrLoad(asset: IAsset): Promise { + const existing = this._documents.get(asset.uuid); + if (existing) { + return existing; + } + const document = await this._loadDocument(asset); + this._documents.set(asset.uuid, document); + return document; + } + + private async _loadDocument(asset: IAsset): Promise { + const content = await readFile(asset.source, 'utf8'); + let serialized: unknown; + try { + serialized = JSON.parse(content); + } catch (error) { + throw new Error(`Invalid JSON in Animation Graph ${asset.uuid}: ${error instanceof Error ? error.message : String(error)}`); + } + const graph = this._deserializeGraph(serialized, asset.uuid); + return { + uuid: asset.uuid, + url: asset.url, + source: asset.source, + graph, + documentId: randomUUID(), + revision: 0, + persistedRevision: 0, + dirty: false, + externallyModified: false, + fingerprint: await this._readFingerprint(asset.source, asset.uuid, content), + nodeIds: new WeakMap(), + nodesById: new Map(), + nextNodeId: 1, + }; + } + + private _queryAnimationGraphAsset(uuidOrUrlOrPath: string): IAsset { + const asset = assetQuery.queryAsset(uuidOrUrlOrPath); + if (!asset) { + throw new Error(`Animation Graph asset can not be found: ${uuidOrUrlOrPath}`); + } + const importer = asset.meta?.importer; + const type = (asset as any).type; + if (importer !== 'animation-graph' && type !== 'cc.AnimationGraph') { + throw new Error(`Expected cc.AnimationGraph asset, got importer ${importer || 'unknown'} type ${type || 'unknown'}: ${uuidOrUrlOrPath}`); + } + if (!asset.source) { + throw new Error(`Animation Graph asset has no source file: ${uuidOrUrlOrPath}`); + } + return asset; + } + + private async _readFingerprint(source: string, uuid: string, knownContent?: string): Promise { + let content = knownContent; + let mtimeMs: number | null = null; + try { + if (content === undefined) { + content = await readFile(source, 'utf8'); + } + mtimeMs = (await stat(source)).mtimeMs; + } catch { + content = undefined; + } + return { + hash: content === undefined ? null : createHash('sha256').update(content).digest('hex'), + mtimeMs, + assetDbMtime: assetQuery.queryAssetMtime(uuid), + }; + } + + private async _refreshExternalState(document: AnimationGraphDocument): Promise { + const current = await this._readFingerprint(document.source, document.uuid); + const externallyModified = !sameFingerprint(document.fingerprint, current); + if (externallyModified && !document.externallyModified) { + document.externallyModified = true; + this._emitChanged(document, 'external'); + } else { + document.externallyModified = externallyModified; + } + } + + private async _assertSourceUnchanged(document: AnimationGraphDocument): Promise { + await this._refreshExternalState(document); + if (document.externallyModified) { + throw new AnimationGraphEditError( + 'SOURCE_CHANGED', + `Animation Graph source changed after it was loaded: ${document.uuid}`, + this._version(document), + ); + } + } + + private _assertExpectedVersion(document: AnimationGraphDocument, expected: AnimationGraphExpectedVersion): void { + if (expected.documentId !== document.documentId) { + throw new AnimationGraphEditError( + 'DOCUMENT_RELOADED', + `Animation Graph document was reloaded: ${document.uuid}`, + this._version(document), + ); + } + if (expected.revision !== document.revision) { + throw new AnimationGraphEditError( + 'VERSION_CONFLICT', + `Animation Graph revision conflict: expected ${expected.revision}, current ${document.revision}`, + this._version(document), + ); + } + } + + private _markChanged( + document: AnimationGraphDocument, + reason: 'inspector' | 'structure', + sourceId?: string, + changedPaths?: string[], + ): void { + document.revision += 1; + document.dirty = true; + this._emitChanged(document, reason, sourceId, changedPaths); + } + + private _emitChanged( + document: AnimationGraphDocument, + reason: AnimationGraphChangedEvent['reason'], + sourceId?: string, + changedPaths?: string[], + ): void { + const event: AnimationGraphChangedEvent = { + uuid: document.uuid, + reason, + version: this._version(document), + sourceId, + changedPaths, + }; + for (const listener of this._listeners) { + try { + listener(event); + } catch (error) { + console.error('Animation Graph change listener failed.', error); + } + } + } + + private _version(document: AnimationGraphDocument): AnimationGraphVersion { + return { + documentId: document.documentId, + revision: document.revision, + persistedRevision: document.persistedRevision, + dirty: document.dirty, + externallyModified: document.externallyModified, + }; + } + + private _snapshot(document: AnimationGraphDocument): AnimationGraphSnapshot { + return { + uuid: document.uuid, + url: document.url, + ...this._version(document), + graph: this._queryGraph(document), + }; + } + + private _inspectorSnapshot(document: AnimationGraphDocument, target: AnimationGraphTarget): AnimationGraphInspectorSnapshot { + const binding = this._resolveInspectorBinding(document, target); + return { + uuid: document.uuid, + target: clonePlain(target), + ...this._version(document), + dump: binding.dump, + propertyCapabilities: clonePlain(binding.propertyCapabilities), + }; + } + + private _queryGraph(document: AnimationGraphDocument): AnimationGraphViewDump { + const graph = document.graph; + const api = getNewGenAnim(); + return { + layers: Array.from(graph.layers as Iterable).map((layer: any, index: number) => this._queryLayer(document, layer, index)), + variables: Array.from(graph.variables as Iterable<[string, any]>).map(([name, variable]) => { + const value = encodeSerializedObject(variable.value, api.getVariableValueAttributes(variable), variable, 'value'); + value.path = 'value'; + return { + name, + type: variable.type, + value, + resetMode: variable.type === api.VariableType.TRIGGER ? variable.resetMode : undefined, + }; + }), + }; + } + + private _queryLayer(document: AnimationGraphDocument, layer: any, index: number): AnimationGraphLayerView { + const stateMachineContext: AnimationGraphStateMachineContext = { + kind: 'layer-state-machine', + layerIndex: index, + stateMachinePath: [], + }; + return { + index, + name: layer.name, + weight: layer.weight, + additive: !!layer.additive, + maskUuid: getAssetUuid(layer.mask), + stashes: Array.from(layer.stashes() as Iterable<[string, unknown]>).map(([name]) => name), + stashPoseGraphs: Array.from(layer.stashes() as Iterable<[string, any]>).map(([name, stash]) => ({ + name, + poseGraph: this._queryPoseGraph(document, stash.graph, { + kind: 'layer-stash', + layerIndex: index, + stashName: name, + }), + })), + stateMachine: this._queryStateMachine(document, layer.stateMachine, stateMachineContext, []), + }; + } + + private _queryStateMachine( + document: AnimationGraphDocument, + stateMachine: any, + context: AnimationGraphStateMachineContext, + path: number[], + ): AnimationGraphStateMachineView { + const states = Array.from(stateMachine.states() as Iterable); + const transitions = Array.from(stateMachine.transitions() as Iterable); + return { + context: clonePlain(context), + path: [...path], + allowEmptyStates: !!stateMachine.allowEmptyStates, + states: states.map((state, index) => this._queryState(document, stateMachine, states, transitions, state, index, context, path)), + transitions: transitions.map((transition, index) => this._queryTransition(stateMachine, states, transition, index)), + editorData: getEditorData(stateMachine), + }; + } + + private _queryState( + document: AnimationGraphDocument, + stateMachine: any, + states: any[], + transitions: any[], + state: any, + index: number, + context: AnimationGraphStateMachineContext, + path: number[], + ): AnimationGraphStateView { + const api = getNewGenAnim(); + const type = getStateType(state, stateMachine, api); + const view: AnimationGraphStateView = { + index, + type, + name: state.name || '', + incomingTransitionIndices: Array.from(stateMachine.getIncomings(state) as Iterable).map((item) => transitions.indexOf(item)), + outgoingTransitionIndices: Array.from(stateMachine.getOutgoings(state) as Iterable).map((item) => transitions.indexOf(item)), + components: getStateComponents(state).map((component, componentIndex) => ({ + index: componentIndex, + type: getClassName(component), + })), + editorData: getEditorData(state), + }; + if (state instanceof api.MotionState) { + view.speed = state.speed; + view.speedMultiplier = state.speedMultiplier; + view.speedMultiplierEnabled = !!state.speedMultiplierEnabled; + view.motion = state.motion ? this._queryMotion(state.motion, { + kind: 'motion', + stateMachine: clonePlain(context), + stateIndex: index, + level: [0], + }) : null; + } else if (state instanceof api.SubStateMachine) { + const childContext: AnimationGraphStateMachineContext = context.kind === 'layer-state-machine' + ? { ...context, stateMachinePath: [...context.stateMachinePath, index] } + : { kind: 'sub-state-machine', stateMachine: clonePlain(context), stateIndex: index }; + view.stateMachine = this._queryStateMachine(document, state.stateMachine, childContext, [...path, index]); + } else if (state instanceof api.ProceduralPoseState) { + view.poseGraph = this._queryPoseGraph(document, state.graph, { + kind: 'state-pose-graph', + stateMachine: clonePlain(context), + stateIndex: index, + }); + } + return view; + } + + private _queryTransition(stateMachine: any, states: any[], transition: any, index: number): AnimationGraphTransitionView { + const api = getNewGenAnim(); + const outgoings = Array.from(stateMachine.getOutgoings(transition.from) as Iterable); + const view: AnimationGraphTransitionView = { + index, + type: api.isAnimationTransition(transition) + ? 'animation' + : transition instanceof api.EmptyStateTransition + ? 'empty-state' + : transition instanceof api.ProceduralPoseTransition + ? 'procedural-pose' + : 'transition', + fromStateIndex: states.indexOf(transition.from), + toStateIndex: states.indexOf(transition.to), + priority: outgoings.indexOf(transition), + conditions: Array.isArray(transition.conditions) + ? transition.conditions.map((condition: any, conditionIndex: number) => this._queryTransitionCondition(condition, conditionIndex)) + : [], + editorData: getEditorData(transition), + }; + if ( + api.isAnimationTransition(transition) + || transition instanceof api.EmptyStateTransition + || transition instanceof api.ProceduralPoseTransition + ) { + view.duration = transition.duration; + view.destinationStart = transition.destinationStart; + view.relativeDestinationStart = !!transition.relativeDestinationStart; + } + if (api.isAnimationTransition(transition)) { + view.relativeDuration = !!transition.relativeDuration; + view.exitConditionEnabled = !!transition.exitConditionEnabled; + view.exitCondition = transition.exitCondition; + } + return view; + } + + private _queryTransitionCondition(condition: any, index: number): AnimationGraphTransitionConditionView { + const api = getNewGenAnim(); + if (condition instanceof api.BinaryCondition) { + return { + index, + type: 'BinaryCondition', + operator: condition.operator, + lhs: condition.lhs, + lhsBinding: dumpTransitionConditionBinding(condition.lhsBinding), + rhs: condition.rhs, + isRhsInteger: condition.lhsBinding?.getValueType?.() === api.TCBindingValueType.INTEGER, + }; + } + if (condition instanceof api.UnaryCondition) { + return { + index, + type: 'UnaryCondition', + operator: condition.operator, + operand: condition.operand?.variable || '', + }; + } + if (condition instanceof api.TriggerCondition) { + return { + index, + type: 'TriggerCondition', + trigger: condition.trigger || '', + }; + } + return { + index, + type: 'Unknown', + className: getClassName(condition), + }; + } + + private _queryMotion( + motion: any, + target: Extract, + threshold?: unknown, + weight?: any, + ): AnimationGraphMotionView { + const api = getNewGenAnim(); + const type = getMotionType(motion, api); + const view: AnimationGraphMotionView = { + level: [...target.level], + target: clonePlain(target), + type, + name: motion?.name || motion?.clip?.name || getClassName(motion), + editorData: getEditorData(motion), + }; + if (motion instanceof api.ClipMotion) { + view.clipUuid = getAssetUuid(motion.clip); + } + if (motion instanceof api.AnimationBlend1D) { + view.variable = motion.param.variable; + view.value = motion.param.value; + } else if (motion instanceof api.AnimationBlend2D) { + view.variableX = motion.paramX.variable; + view.valueX = motion.paramX.value; + view.variableY = motion.paramY.variable; + view.valueY = motion.paramY.value; + } + if (threshold !== undefined) { + view.threshold = isVec2Like(threshold) + ? { x: threshold.x, y: threshold.y } + : threshold as number; + } + if (weight) { + view.weight = { + value: weight.value, + variable: weight.variable, + }; + } + if (isBlendMotion(motion, api)) { + view.children = Array.from(motion.items as Iterable).map((item: any, index: number) => ( + this._queryMotion( + item.motion, + { ...target, level: [...target.level, index] }, + item.threshold, + motion instanceof api.AnimationBlendDirect ? item.weight : undefined, + ) + )); + } + return view; + } + + private _queryPoseGraph( + document: AnimationGraphDocument, + poseGraph: any, + context: AnimationGraphPoseGraphContext, + ): AnimationGraphPoseView { + const api = getNewGenAnim(); + const nodes = Array.from(poseGraph.nodes() as Iterable); + const rootOutputNodeId = this._nodeId(document, poseGraph.outputNode); + return { + context: clonePlain(context), + rootOutputNodeId, + nodes: nodes.map((node) => { + const id = this._nodeId(document, node); + const view: import('./@types/public').AnimationGraphPoseNodeView = { + id, + type: getClassName(node), + title: getNodeTitle(node), + outputTypes: api.poseGraphOp.getOutputKeys(node).map((key: number) => api.poseGraphOp.getOutputType(node, key)), + inputs: api.poseGraphOp.getInputKeys(node).map((key: unknown) => { + const metadata = api.poseGraphOp.getInputMetadata(node, key) || {}; + const binding = api.poseGraphOp.getInputBinding(poseGraph, node, key); + const input: import('./@types/public').AnimationGraphPoseInputView = { + id: JSON.stringify(key), + displayName: getPoseInputDisplayName(key, metadata), + type: metadata.type, + deletable: !!metadata.deletable, + insertPoint: !!metadata.insertPoint, + connected: !!binding, + producerNodeId: binding ? this._nodeId(document, binding.producer) : undefined, + producerOutputId: binding?.outputIndex, + }; + if (!binding) { + input.value = this._encodePoseInputValue(node, key, metadata).dump; + } + return input; + }), + inputInsertInfos: clonePlain(api.poseGraphOp.getInputInsertInfos(node)), + editorData: getEditorData(node), + }; + const enterInfo = node.getEnterInfo?.(); + const nestedStateMachine = enterInfo?.type === 'state-machine' + ? enterInfo.target + : isStateMachineLike(node.stateMachine) ? node.stateMachine : undefined; + if (nestedStateMachine) { + view.stateMachine = this._queryStateMachine(document, nestedStateMachine, { + kind: 'pose-node-state-machine', + poseGraph: clonePlain(context), + nodeId: id, + }, []); + } + const embeddedMotion = node.motion; + if (embeddedMotion && getMotionType(embeddedMotion, api) !== 'unknown') { + view.motion = this._queryMotion(embeddedMotion, { + kind: 'motion', + poseGraph: clonePlain(context), + nodeId: id, + level: [0], + }); + } else if ('motion' in node) { + view.motion = null; + } + return view; + }), + }; + } + + private _resolveInspectorBinding(document: AnimationGraphDocument, target: AnimationGraphTarget): InspectorBinding { + switch (target.kind) { + case 'layer': { + const layer = this._getLayer(document, target.layerIndex); + return createAdapterBinding('Layer', { + name: directProperty(layer, 'name', { type: 'String', default: '' }), + weight: directProperty(layer, 'weight', { type: 'Number', default: 1, min: 0 }), + additive: directProperty(layer, 'additive', { type: 'Boolean', default: false }), + mask: directProperty(layer, 'mask', { type: 'Object', ctor: getNewGenAnim().AnimationMask, default: null }), + }); + } + case 'state': { + const { state } = this._resolveState(document, target); + return this._createStateBinding(state); + } + case 'transition': { + const { transition } = this._resolveTransition(document, target); + return this._createTransitionBinding(transition); + } + case 'motion': { + const motion = this._resolveMotion(document, target); + return this._createMotionBinding(motion); + } + case 'state-component': { + const { state } = this._resolveState(document, target); + const component = getStateComponents(state)[target.componentIndex]; + if (!component) { + throw this._targetNotFound(document, target); + } + return createDecoratedBinding(component, 'StateMachineComponent'); + } + case 'pose-node': { + const { poseGraph, node } = this._resolvePoseNode(document, target); + if (!Array.from(poseGraph.nodes() as Iterable).includes(node)) { + throw this._targetNotFound(document, target); + } + return createDecoratedBinding(node, 'PoseNode'); + } + case 'pose-input': + return this._createPoseInputBinding(document, target); + default: + throw new AnimationGraphEditError('UNSUPPORTED_TARGET', 'Unsupported Animation Graph target.', this._version(document)); + } + } + + private _createStateBinding(state: any): InspectorBinding { + const api = getNewGenAnim(); + const properties: Record = { + name: directProperty(state, 'name', { type: 'String', default: '' }), + }; + if (state instanceof api.MotionState) { + properties.speed = directProperty(state, 'speed', { type: 'Number', default: 1, min: 0 }); + properties.speedMultiplier = directProperty(state, 'speedMultiplier', { type: 'String', default: '' }); + properties.speedMultiplierEnabled = directProperty(state, 'speedMultiplierEnabled', { type: 'Boolean', default: false }); + properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', { type: 'String', default: '' }); + properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', { type: 'String', default: '' }); + } else if (state instanceof api.ProceduralPoseState) { + properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', { type: 'String', default: '' }); + properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', { type: 'String', default: '' }); + } + return createAdapterBinding(getClassName(state), properties); + } + + private _createTransitionBinding(transition: any): InspectorBinding { + const api = getNewGenAnim(); + const properties: Record = {}; + if ( + api.isAnimationTransition(transition) + || transition instanceof api.EmptyStateTransition + || transition instanceof api.ProceduralPoseTransition + ) { + properties.duration = directProperty(transition, 'duration', { type: 'Number', default: 0.3, min: 0 }); + properties.destinationStart = directProperty(transition, 'destinationStart', { type: 'Number', default: 0, min: 0 }); + properties.relativeDestinationStart = directProperty(transition, 'relativeDestinationStart', { type: 'Boolean', default: false }); + properties.startEvent = nestedProperty(transition.startEventBinding, 'methodName', { type: 'String', default: '' }); + properties.endEvent = nestedProperty(transition.endEventBinding, 'methodName', { type: 'String', default: '' }); + } + if (api.isAnimationTransition(transition)) { + properties.relativeDuration = directProperty(transition, 'relativeDuration', { type: 'Boolean', default: false }); + properties.exitConditionEnabled = directProperty(transition, 'exitConditionEnabled', { type: 'Boolean', default: true }); + properties.exitCondition = directProperty(transition, 'exitCondition', { type: 'Number', default: 1, min: 0 }); + } + return createAdapterBinding(getClassName(transition), properties); + } + + private _createMotionBinding(motion: any): InspectorBinding { + const api = getNewGenAnim(); + const properties: Record = {}; + if (motion instanceof api.ClipMotion) { + properties.clip = directProperty(motion, 'clip', { type: 'Object', ctor: getCC().AnimationClip, default: null }); + } + if (motion instanceof api.AnimationBlend) { + properties.name = directProperty(motion, 'name', { type: 'String', default: '' }); + } + if (motion instanceof api.AnimationBlend1D) { + properties.variable = nestedProperty(motion.param, 'variable', { type: 'String', default: '' }); + properties.value = nestedProperty(motion.param, 'value', { type: 'Number', default: 0 }); + } else if (motion instanceof api.AnimationBlend2D) { + properties.algorithm = directProperty(motion, 'algorithm', { + type: 'Enum', + default: 0, + enumList: enumList(api.AnimationBlend2D.Algorithm), + }); + properties.variableX = nestedProperty(motion.paramX, 'variable', { type: 'String', default: '' }); + properties.valueX = nestedProperty(motion.paramX, 'value', { type: 'Number', default: 0 }); + properties.variableY = nestedProperty(motion.paramY, 'variable', { type: 'String', default: '' }); + properties.valueY = nestedProperty(motion.paramY, 'value', { type: 'Number', default: 0 }); + } + return createAdapterBinding(getClassName(motion), properties); + } + + private _createPoseInputBinding(document: AnimationGraphDocument, target: Extract): InspectorBinding { + const api = getNewGenAnim(); + const { poseGraph, node } = this._resolvePoseNode(document, target); + const key = parsePoseInputId(api, target.inputId); + if (!key || !api.poseGraphOp.isValidInputKey(node, key)) { + throw this._targetNotFound(document, target); + } + const attrs = api.getPoseGraphNodeInputAttrs(node, key) || {}; + const metadata = api.poseGraphOp.getInputMetadata(node, key) || {}; + const { currentValue, propertyAttrs, dump } = this._encodePoseInputValue(node, key, metadata, attrs); + const pseudo = { value: currentValue }; + if (api.poseGraphOp.getInputBinding(poseGraph, node, key)) { + dump.visible = false; + } + const applyOperation = (operation: 'reset' | 'create'): void => { + applyEncodedPropertyOperation(pseudo, 'value', dump, propertyAttrs, operation); + setPoseInputValue(node, key, pseudo.value); + }; + return { + dump, + propertyCapabilities: { + value: getEncodedPropertyOperationCapabilities(dump, propertyAttrs), + }, + apply: async (path, patch) => { + if (path !== 'value') { + throw new Error(`Unknown property dump path: ${path}`); + } + await applyEncodedPropertyPatch(pseudo, 'value', dump, patch); + setPoseInputValue(node, key, pseudo.value); + }, + reset: async (path) => { + assertInspectorBindingPath(path, 'value'); + applyOperation('reset'); + }, + create: async (path) => { + assertInspectorBindingPath(path, 'value'); + applyOperation('create'); + }, + }; + } + + private _encodePoseInputValue( + node: any, + key: unknown, + metadata: any, + attrs = getNewGenAnim().getPoseGraphNodeInputAttrs(node, key) || {}, + ): { currentValue: unknown; propertyAttrs: Record; dump: IProperty } { + const currentValue = getNewGenAnim().poseGraphOp.getInputConstantValue(node, key); + const propertyAttrs = { ...attrs, visible: isInputVisible(node, attrs) }; + const dump = encodeSerializedObject(currentValue, propertyAttrs, node, 'value'); + dump.path = 'value'; + dump.displayName ||= getPoseInputDisplayName(key, metadata); + return { currentValue, propertyAttrs, dump }; + } + + private _getLayer(document: AnimationGraphDocument, layerIndex: number): any { + const layer = document.graph.layers[layerIndex]; + if (!layer) { + throw this._targetNotFound(document, { kind: 'layer', layerIndex }); + } + return layer; + } + + private _getLayerStateMachine(document: AnimationGraphDocument, layerIndex: number, path: number[]): any { + const api = getNewGenAnim(); + let stateMachine = this._getLayer(document, layerIndex).stateMachine; + for (const stateIndex of path) { + const state = Array.from(stateMachine.states() as Iterable)[stateIndex]; + if (!(state instanceof api.SubStateMachine)) { + throw this._targetNotFound(document, { kind: 'state', layerIndex, stateMachinePath: path, stateIndex }); + } + stateMachine = state.stateMachine; + } + return stateMachine; + } + + private _getStateMachineByContext(document: AnimationGraphDocument, context: AnimationGraphStateMachineContext): any { + switch (context.kind) { + case 'layer-state-machine': + return this._getLayerStateMachine(document, context.layerIndex, context.stateMachinePath); + case 'pose-node-state-machine': { + const { node } = this._resolvePoseNode(document, { + kind: 'pose-node', + poseGraph: context.poseGraph, + nodeId: context.nodeId, + }); + const enterInfo = node.getEnterInfo?.(); + const stateMachine = enterInfo?.type === 'state-machine' + ? enterInfo.target + : isStateMachineLike(node.stateMachine) ? node.stateMachine : undefined; + if (!stateMachine) { + throw this._targetNotFound(document, context); + } + return stateMachine; + } + case 'sub-state-machine': { + const parent = this._getStateMachineByContext(document, context.stateMachine); + const state = Array.from(parent.states() as Iterable)[context.stateIndex]; + if (!(state instanceof getNewGenAnim().SubStateMachine)) { + throw this._targetNotFound(document, context); + } + return state.stateMachine; + } + } + } + + private _getStateMachineForAddress(document: AnimationGraphDocument, address: any): any { + return address.stateMachine + ? this._getStateMachineByContext(document, address.stateMachine) + : this._getLayerStateMachine(document, address.layerIndex, address.stateMachinePath); + } + + private _resolveState( + document: AnimationGraphDocument, + target: any, + ): { stateMachine: any; state: any; states: any[] } { + const stateMachine = this._getStateMachineForAddress(document, target); + const states = Array.from(stateMachine.states() as Iterable); + const state = states[target.stateIndex]; + if (!state) { + throw this._targetNotFound(document, target); + } + return { stateMachine, state, states }; + } + + private _resolveTransition( + document: AnimationGraphDocument, + target: any, + ): { stateMachine: any; transition: any } { + const stateMachine = this._getStateMachineForAddress(document, target); + const transition = Array.from(stateMachine.transitions() as Iterable)[target.transitionIndex]; + if (!transition) { + throw this._targetNotFound(document, target); + } + return { stateMachine, transition }; + } + + private _resolveMotion(document: AnimationGraphDocument, target: Extract): any { + const api = getNewGenAnim(); + if (!target.level.length || target.level[0] !== 0) { + throw this._targetNotFound(document, target); + } + let motion: any; + if ('poseGraph' in target) { + const { node } = this._resolvePoseNode(document, { + kind: 'pose-node', + poseGraph: target.poseGraph, + nodeId: target.nodeId, + }); + motion = node.motion; + } else { + const { state } = this._resolveState(document, target); + if (!(state instanceof api.MotionState)) { + throw this._targetNotFound(document, target); + } + motion = state.motion; + } + if (!motion) { + throw this._targetNotFound(document, target); + } + for (const childIndex of target.level.slice(1)) { + if (!isBlendMotion(motion, api)) { + throw this._targetNotFound(document, target); + } + motion = Array.from(motion.items as Iterable)[childIndex]?.motion; + if (!motion) { + throw this._targetNotFound(document, target); + } + } + return motion; + } + + private _resolvePoseGraph( + document: AnimationGraphDocument, + target: any, + ): any { + if ('poseGraph' in target) { + return this._getPoseGraphByContext(document, target.poseGraph); + } + const { state } = this._resolveState(document, target); + if (!(state instanceof getNewGenAnim().ProceduralPoseState)) { + throw this._targetNotFound(document, target); + } + return state.graph; + } + + private _getPoseGraphByContext(document: AnimationGraphDocument, context: AnimationGraphPoseGraphContext): any { + switch (context.kind) { + case 'state-pose-graph': { + const stateMachine = this._getStateMachineByContext(document, context.stateMachine); + const state = Array.from(stateMachine.states() as Iterable)[context.stateIndex]; + if (!(state instanceof getNewGenAnim().ProceduralPoseState)) { + throw this._targetNotFound(document, context); + } + return state.graph; + } + case 'layer-stash': { + const stash = this._getLayer(document, context.layerIndex).getStash(context.stashName); + if (!stash) { + throw this._targetNotFound(document, context); + } + return stash.graph; + } + } + } + + private _resolvePoseNode( + document: AnimationGraphDocument, + target: any, + ): { poseGraph: any; node: any } { + const poseGraph = this._resolvePoseGraph(document, target); + const node = document.nodesById.get(target.nodeId); + if (!node || !Array.from(poseGraph.nodes() as Iterable).includes(node)) { + throw this._targetNotFound(document, target); + } + return { poseGraph, node }; + } + + private _nodeId(document: AnimationGraphDocument, node: object): number { + const existing = document.nodeIds.get(node); + if (existing !== undefined) { + return existing; + } + const id = document.nextNodeId++; + document.nodeIds.set(node, id); + document.nodesById.set(id, node); + return id; + } + + private async _executeCommand(document: AnimationGraphDocument, command: AnimationGraphCommand): Promise { + const api = getNewGenAnim(); + const graph = document.graph; + switch (command.type) { + case 'add-layer': { + const layer = graph.addLayer(); + if (command.name !== undefined) { + layer.name = command.name; + } + return; + } + case 'remove-layer': + this._getLayer(document, command.layerIndex); + graph.removeLayer(command.layerIndex); + return; + case 'move-layer': + this._getLayer(document, command.layerIndex); + if (!graph.layers[command.newIndex]) { + throw this._targetNotFound(document, command); + } + graph.moveLayer(command.layerIndex, command.newIndex); + return; + case 'add-state': { + const stateMachine = this._getStateMachineForAddress(document, command); + const state = createState(stateMachine, command.stateType); + state.name = command.name || uniqueStateName(stateMachine, defaultStateName(command.stateType)); + assignEditorData(state, command.editorData); + return; + } + case 'remove-state': { + const { stateMachine, state } = this._resolveState(document, command); + if (state === stateMachine.entryState || state === stateMachine.exitState || state === stateMachine.anyState) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Entry, Exit and Any states can not be removed.', this._version(document)); + } + stateMachine.remove(state); + return; + } + case 'duplicate-state': { + const { stateMachine, state } = this._resolveState(document, command); + if (state === stateMachine.entryState || state === stateMachine.exitState || state === stateMachine.anyState) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Entry, Exit and Any states can not be duplicated.', this._version(document)); + } + const clone = api.cloneState(stateMachine, state, !!command.includeTransitions); + clone.name = uniqueStateName(stateMachine, state.name || defaultStateName(getStateType(state, stateMachine, api))); + assignEditorData(clone, command.editorData); + return; + } + case 'set-state-editor-data': { + const { state } = this._resolveState(document, command); + assignEditorData(state, command.editorData); + return; + } + case 'add-transition': { + const stateMachine = this._getStateMachineForAddress(document, command); + const states = Array.from(stateMachine.states() as Iterable); + const from = states[command.fromStateIndex]; + const to = states[command.toStateIndex]; + if (!from || !to) { + throw this._targetNotFound(document, command); + } + stateMachine.connect(from, to); + return; + } + case 'remove-transition': { + const { stateMachine, transition } = this._resolveTransition(document, command); + if (command.allBetween) { + stateMachine.disconnect(transition.from, transition.to); + } else { + stateMachine.removeTransition(transition); + } + return; + } + case 'move-transition': { + const { stateMachine, transition } = this._resolveTransition(document, command); + stateMachine.adjustTransitionPriority(transition, command.offset); + return; + } + case 'add-transition-condition': { + const { transition } = this._resolveTransition(document, command.target); + const condition = createTransitionCondition(api, command.conditionType); + transition.conditions.push(condition); + return; + } + case 'remove-transition-condition': { + const { transition } = this._resolveTransition(document, command.target); + if (!transition.conditions[command.conditionIndex]) { + throw this._targetNotFound(document, command); + } + transition.conditions.splice(command.conditionIndex, 1); + return; + } + case 'set-transition-condition-property': { + const { transition } = this._resolveTransition(document, command.target); + const condition = transition.conditions[command.conditionIndex]; + if (!condition) { + throw this._targetNotFound(document, command); + } + setTransitionConditionProperty(condition, command.path, command.value, api); + return; + } + case 'set-motion': { + const motion = command.motionType === 'none' + ? null + : this._createMotion(command.motionType, command.clipUuid); + if ('poseGraph' in command) { + const { node } = this._resolvePoseNode(document, { + kind: 'pose-node', + poseGraph: command.poseGraph, + nodeId: command.nodeId, + }); + if (!('motion' in node)) { + throw this._targetNotFound(document, command); + } + node.motion = motion; + } else { + const { state } = this._resolveState(document, command); + if (!(state instanceof api.MotionState)) { + throw this._targetNotFound(document, command); + } + state.motion = motion; + } + return; + } + case 'add-motion-child': { + const motion = this._resolveMotion(document, command.target); + if (!isBlendMotion(motion, api)) { + throw this._targetNotFound(document, command.target); + } + const child = this._createMotion(command.motionType, command.clipUuid); + const item = createBlendItem(motion, api, child); + const items = Array.from(motion.items as Iterable); + items.push(item); + motion.items = items; + return; + } + case 'remove-motion': + this._removeMotion(document, command.target); + return; + case 'set-motion-editor-data': { + const motion = this._resolveMotion(document, command.target); + assignEditorData(motion, command.editorData); + return; + } + case 'set-motion-threshold': { + const motion = this._resolveMotion(document, command.target); + const items = isBlendMotion(motion, api) ? Array.from(motion.items as Iterable) : []; + const item = items[command.childIndex]; + if (!item) { + throw this._targetNotFound(document, command.target); + } + if (motion instanceof api.AnimationBlend1D && typeof command.threshold === 'number') { + item.threshold = command.threshold; + motion.items = items; + return; + } + if (motion instanceof api.AnimationBlend2D && isVec2Like(command.threshold)) { + item.threshold = new (getCC().Vec2)(command.threshold.x, command.threshold.y); + motion.items = items; + return; + } + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Motion threshold type does not match the blend type.', this._version(document)); + } + case 'set-direct-blend-weight': { + const motion = this._resolveMotion(document, command.target); + if (!(motion instanceof api.AnimationBlendDirect)) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Motion is not a direct blend.', this._version(document)); + } + const items = Array.from(motion.items as Iterable); + const item = items[command.childIndex]; + if (!item) { + throw this._targetNotFound(document, command.target); + } + if (command.value === undefined && command.variable === undefined) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Direct blend weight patch is empty.', this._version(document)); + } + if (command.value !== undefined) { + if (typeof command.value !== 'number' || !Number.isFinite(command.value)) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Direct blend weight expects a finite number.', this._version(document)); + } + item.weight.value = command.value; + } + if (command.variable !== undefined) { + if (typeof command.variable !== 'string') { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'Direct blend weight variable expects a string.', this._version(document)); + } + item.weight.variable = command.variable; + } + motion.items = items; + return; + } + case 'add-state-component': { + const { state } = this._resolveState(document, command); + if (!(state instanceof api.MotionState || state instanceof api.SubStateMachine)) { + throw this._targetNotFound(document, command); + } + const ctor = getCC().js.getClassByName(command.componentType); + if (!ctor) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `State machine component type can not be found: ${command.componentType}`, this._version(document)); + } + state.addComponent(ctor); + return; + } + case 'remove-state-component': { + const { state } = this._resolveState(document, command); + const component = getStateComponents(state)[command.componentIndex]; + if (!component || typeof state.removeComponent !== 'function') { + throw this._targetNotFound(document, command); + } + state.removeComponent(component); + return; + } + case 'add-pose-node': { + const poseGraph = this._resolvePoseGraph(document, command); + const ctor = getCC().js.getClassByName(command.nodeType); + if (!ctor) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Pose node type can not be found: ${command.nodeType}`, this._version(document)); + } + const node = api.createPoseGraphNode(ctor, command.createArg); + poseGraph.addNode(node); + assignEditorData(node, command.editorData); + this._nodeId(document, node); + return; + } + case 'remove-pose-node': { + const { poseGraph, node } = this._resolvePoseNode(document, command.target); + if (node === poseGraph.outputNode) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'The Pose Graph output node can not be removed.', this._version(document)); + } + poseGraph.removeNode(node); + document.nodesById.delete(command.target.nodeId); + return; + } + case 'duplicate-pose-nodes': { + const poseGraph = this._resolvePoseGraph(document, command); + const nodes = command.nodeIds.map((id) => document.nodesById.get(id)); + const poseGraphNodes = new Set(Array.from(poseGraph.nodes() as Iterable)); + if (nodes.some((node) => !node || node === poseGraph.outputNode || !poseGraphNodes.has(node))) { + throw this._targetNotFound(document, command); + } + const copyInfo = api.copyPoseGraphNodes(poseGraph, nodes); + const result = api.pastePoseGraphNodes(poseGraph, copyInfo); + for (const node of result.addedNodes) { + this._nodeId(document, node); + } + return; + } + case 'set-pose-node-editor-data': { + const { node } = this._resolvePoseNode(document, command.target); + assignEditorData(node, command.editorData); + return; + } + case 'connect-pose-nodes': { + const poseGraph = this._resolvePoseGraph(document, command); + const producer = document.nodesById.get(command.producerNodeId); + const consumer = document.nodesById.get(command.consumerNodeId); + const input = consumer && parsePoseInputId(api, command.consumerInputId); + const poseGraphNodes = new Set(Array.from(poseGraph.nodes() as Iterable)); + if ( + !producer + || !consumer + || !poseGraphNodes.has(producer) + || !poseGraphNodes.has(consumer) + || !input + || !api.poseGraphOp.isValidInputKey(consumer, input) + ) { + throw this._targetNotFound(document, command); + } + const outputs = api.poseGraphOp.getOutputKeys(producer); + if (!outputs.includes(command.producerOutputId)) { + throw this._targetNotFound(document, command); + } + api.poseGraphOp.connectNode(poseGraph, consumer, input, producer, command.producerOutputId); + return; + } + case 'disconnect-pose-input': { + const { poseGraph, node } = this._resolvePoseNode(document, command.target); + const input = parsePoseInputId(api, command.target.inputId); + if (!input || !api.poseGraphOp.isValidInputKey(node, input)) { + throw this._targetNotFound(document, command.target); + } + api.poseGraphOp.disconnectNode(poseGraph, node, input); + return; + } + case 'insert-pose-input': { + const { poseGraph, node } = this._resolvePoseNode(document, command.target); + if (!(command.insertId in api.poseGraphOp.getInputInsertInfos(node))) { + throw this._targetNotFound(document, command.target); + } + api.poseGraphOp.insertInput(poseGraph, node, command.insertId); + return; + } + case 'delete-pose-input': { + const { poseGraph, node } = this._resolvePoseNode(document, command.target); + const input = parsePoseInputId(api, command.target.inputId); + if (!input || !api.poseGraphOp.isValidInputKey(node, input) || !api.poseGraphOp.getInputMetadata(node, input)?.deletable) { + throw this._targetNotFound(document, command.target); + } + api.poseGraphOp.deleteInput(poseGraph, node, input); + return; + } + case 'add-variable': + if (graph.getVariable(command.name)) { + throw this._nameConflict(document, 'variable', command.name); + } + graph.addVariable(command.name, command.variableType, command.initialValue); + return; + case 'set-variable-value': { + const variable = graph.getVariable(command.name); + if (!variable) { + throw this._targetNotFound(document, command); + } + const dump = encodeSerializedObject(variable.value, api.getVariableValueAttributes(variable), variable, 'value'); + dump.path = 'value'; + await applyEncodedPropertyPatch(variable, 'value', dump, command.patch); + return; + } + case 'set-trigger-reset-mode': { + const variable = graph.getVariable(command.name); + if (!variable || variable.type !== api.VariableType.TRIGGER) { + throw this._targetNotFound(document, command); + } + const resetModes = Object.values(api.TriggerResetMode).filter((value): value is number => typeof value === 'number'); + if (!resetModes.includes(command.resetMode)) { + throw new AnimationGraphEditError( + 'INVALID_PROPERTY_PATCH', + `Invalid trigger reset mode: ${command.resetMode}`, + this._version(document), + ); + } + variable.resetMode = command.resetMode; + return; + } + case 'remove-variable': + if (!graph.getVariable(command.name)) { + throw this._targetNotFound(document, command); + } + graph.removeVariable(command.name); + return; + case 'rename-variable': + if (!graph.getVariable(command.name)) { + throw this._targetNotFound(document, command); + } + if (command.newName !== command.name && graph.getVariable(command.newName)) { + throw this._nameConflict(document, 'variable', command.newName); + } + graph.renameVariable(command.name, command.newName); + for (const variableBinding of api.viewVariableBindings(graph)) { + if (variableBinding.name === command.name) { + variableBinding.rebind(command.newName); + } + } + return; + case 'add-stash': { + const layer = this._getLayer(document, command.layerIndex); + if (layer.getStash(command.name)) { + throw this._nameConflict(document, 'stash', command.name); + } + layer.addStash(command.name); + return; + } + case 'remove-stash': { + const layer = this._getLayer(document, command.layerIndex); + if (!layer.getStash(command.name)) { + throw this._targetNotFound(document, command); + } + layer.removeStash(command.name); + return; + } + case 'rename-stash': { + const layer = this._getLayer(document, command.layerIndex); + if (!layer.getStash(command.name)) { + throw this._targetNotFound(document, command); + } + if (command.newName !== command.name && layer.getStash(command.newName)) { + throw this._nameConflict(document, 'stash', command.newName); + } + layer.renameStash(command.name, command.newName); + for (const reference of api.visitStashReferences(layer, command.name)) { + reference.alterReference(command.newName); + } + return; + } + case 'stash-pose-graph': { + const layer = this._getLayer(document, command.layerIndex); + if (getPoseGraphContextLayerIndex(command.poseGraph) !== command.layerIndex) { + throw this._targetNotFound(document, command.poseGraph); + } + const poseGraph = this._getPoseGraphByContext(document, command.poseGraph); + const originalNodes = Array.from(poseGraph.nodes() as Iterable); + const stashName = command.stashName ?? uniqueStashName(layer); + if (layer.getStash(stashName)) { + throw this._nameConflict(document, 'stash', stashName); + } + for (const node of originalNodes) { + assignEditorData(node, {}); + } + const result = withSerializableEditorExtras(collectEditorExtrasConstructors(poseGraph), () => ( + api.stashPoseGraph(layer, poseGraph, stashName) + )); + if (!result) { + throw new AnimationGraphEditError( + 'INVALID_PROPERTY_PATCH', + `Animation Graph Pose Graph can not be stashed as: ${stashName}`, + this._version(document), + ); + } + const remainingNodes = new Set(poseGraph.nodes() as Iterable); + for (const node of originalNodes) { + if (remainingNodes.has(node)) { + continue; + } + const nodeId = document.nodeIds.get(node); + if (nodeId !== undefined) { + document.nodesById.delete(nodeId); + } + document.nodeIds.delete(node); + } + assignEditorData(result.useStashNode, command.editorData); + this._nodeId(document, result.useStashNode); + return; + } + default: + throw new AnimationGraphEditError('UNSUPPORTED_TARGET', 'Unsupported Animation Graph command.', this._version(document)); + } + } + + private _createMotion(type: AnimationGraphMotionType, clipUuid?: string): any { + const api = getNewGenAnim(); + let motion: any; + switch (type) { + case 'clip': + motion = new api.ClipMotion(); + motion.clip = clipUuid ? this._createAssetReference(clipUuid, getCC().AnimationClip) : null; + break; + case 'blend-1d': + motion = new api.AnimationBlend1D(); + break; + case 'blend-2d': + motion = new api.AnimationBlend2D(); + break; + case 'blend-direct': + motion = new api.AnimationBlendDirect(); + break; + } + return motion; + } + + private _removeMotion(document: AnimationGraphDocument, target: Extract): void { + const api = getNewGenAnim(); + if (!target.level.length || target.level[0] !== 0) { + throw this._targetNotFound(document, target); + } + if (target.level.length === 1) { + if ('poseGraph' in target) { + const { node } = this._resolvePoseNode(document, { + kind: 'pose-node', + poseGraph: target.poseGraph, + nodeId: target.nodeId, + }); + if (!('motion' in node)) { + throw this._targetNotFound(document, target); + } + node.motion = null; + } else { + const { state } = this._resolveState(document, target); + if (!(state instanceof api.MotionState)) { + throw this._targetNotFound(document, target); + } + state.motion = null; + } + return; + } + const parentTarget = { ...target, level: target.level.slice(0, -1) }; + const parent = this._resolveMotion(document, parentTarget); + if (!isBlendMotion(parent, api)) { + throw this._targetNotFound(document, target); + } + const childIndex = target.level[target.level.length - 1]; + const items = Array.from(parent.items as Iterable); + if (!items[childIndex]) { + throw this._targetNotFound(document, target); + } + items.splice(childIndex, 1); + parent.items = items; + } + + private _createAssetReference(uuid: string, ctor: new () => any): any { + const asset = assetQuery.queryAsset(uuid); + if (!asset) { + throw new Error(`Asset can not be found: ${uuid}`); + } + const serialize = getEditorSerialize(); + const reference = serialize.asAsset(uuid, ctor); + if (!reference) { + throw new Error(`Can not create asset reference: ${uuid}`); + } + return reference; + } + + private _serialize(graph: any): string { + return withSerializableEditorExtras(collectEditorExtrasConstructors(graph), () => { + const serialized = getEditorSerialize()(graph); + return typeof serialized === 'string' ? serialized : JSON.stringify(serialized, null, 2); + }); + } + + private _deserializeGraph(serialized: unknown, uuid: string): any { + const data = typeof serialized === 'string' ? JSON.parse(serialized) : serialized; + const graph = withSerializableEditorExtras(collectSerializedEditorExtrasConstructors(data), () => ( + deserializeAssetSource(data as object) + )); + const { AnimationGraph } = getNewGenAnim(); + if (!(graph instanceof AnimationGraph)) { + throw new Error(`Asset is not an AnimationGraph: ${uuid}`); + } + graph.onLoaded?.(); + if ('_uuid' in graph) { + graph._uuid = uuid; + } + return graph; + } + + private _cloneDocumentForMutation(document: AnimationGraphDocument, serialized: string): AnimationGraphDocument { + const graph = this._deserializeGraph(serialized, document.uuid); + const draft: AnimationGraphDocument = { + ...document, + graph, + nodeIds: new WeakMap(), + nodesById: new Map(), + nextNodeId: document.nextNodeId, + }; + const currentNodes = this._collectPoseNodes(document.graph); + const draftNodes = this._collectPoseNodes(graph); + if (currentNodes.length !== draftNodes.length) { + throw new Error(`Animation Graph clone changed Pose Node count: ${document.uuid}`); + } + for (let index = 0; index < currentNodes.length; ++index) { + const id = document.nodeIds.get(currentNodes[index]); + if (id !== undefined) { + draft.nodeIds.set(draftNodes[index], id); + draft.nodesById.set(id, draftNodes[index]); + } + } + return draft; + } + + private _commitMutation(document: AnimationGraphDocument, draft: AnimationGraphDocument): void { + document.graph = draft.graph; + document.nodeIds = draft.nodeIds; + document.nodesById = draft.nodesById; + document.nextNodeId = draft.nextNodeId; + } + + private _collectPoseNodes(graph: any): object[] { + const api = getNewGenAnim(); + const nodes: object[] = []; + const visitedStateMachines = new Set(); + const visitedPoseGraphs = new Set(); + const visitStateMachine = (stateMachine: any): void => { + if (!stateMachine || visitedStateMachines.has(stateMachine)) { + return; + } + visitedStateMachines.add(stateMachine); + for (const state of stateMachine.states() as Iterable) { + if (state instanceof api.SubStateMachine) { + visitStateMachine(state.stateMachine); + } else if (state instanceof api.ProceduralPoseState) { + visitPoseGraph(state.graph); + } + } + }; + const visitPoseGraph = (poseGraph: any): void => { + if (!poseGraph || visitedPoseGraphs.has(poseGraph)) { + return; + } + visitedPoseGraphs.add(poseGraph); + for (const node of poseGraph.nodes() as Iterable) { + nodes.push(node); + const enterInfo = node.getEnterInfo?.(); + const stateMachine = enterInfo?.type === 'state-machine' + ? enterInfo.target + : isStateMachineLike(node.stateMachine) ? node.stateMachine : undefined; + if (stateMachine) { + visitStateMachine(stateMachine); + } + } + }; + for (const layer of graph.layers as Iterable) { + visitStateMachine(layer.stateMachine); + for (const [, stash] of layer.stashes() as Iterable<[string, any]>) { + visitPoseGraph(stash.graph); + } + } + return nodes; + } + + private _commandPath(command: AnimationGraphCommand): string { + return `graph.${command.type}`; + } + + private _targetNotFound(document: AnimationGraphDocument, target: unknown): AnimationGraphEditError { + return new AnimationGraphEditError( + 'TARGET_NOT_FOUND', + `Animation Graph target can not be found: ${JSON.stringify(target)}`, + this._version(document), + ); + } + + private _nameConflict(document: AnimationGraphDocument, kind: string, name: string): AnimationGraphEditError { + return new AnimationGraphEditError( + 'NAME_CONFLICT', + `Animation Graph ${kind} already exists: ${name}`, + this._version(document), + ); + } + + private _enqueue(uuid: string, task: () => Promise): Promise { + const previous = this._queues.get(uuid) || Promise.resolve(); + const result = previous.then(task, task); + const settled = result.then(() => undefined, () => undefined); + this._queues.set(uuid, settled); + void settled.then(() => { + if (this._queues.get(uuid) === settled) { + this._queues.delete(uuid); + } + }); + return result; + } +} + +function getCC(): any { + return require('cc'); +} + +function getNewGenAnim(): any { + return require('cc/editor/new-gen-anim'); +} + +function getEditorSerialize(): any { + const serialize = (globalThis as any).EditorExtends?.serialize || editorSerialize; + if (!serialize) { + throw new Error('EditorExtends.serialize is not initialized.'); + } + return serialize; +} + +function sameFingerprint(left: SourceFingerprint, right: SourceFingerprint): boolean { + return left.hash === right.hash + && left.mtimeMs === right.mtimeMs + && left.assetDbMtime === right.assetDbMtime; +} + +function clonePlain(value: T): T { + if (value === undefined || value === null) { + return value; + } + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return value; + } +} + +function getClassName(value: any): string { + if (!value) { + return ''; + } + return getCC().js.getClassName(value) || value.constructor?.name || 'Unknown'; +} + +function getAssetUuid(value: any): string | null { + const uuid = value?._uuid || value?.uuid; + return typeof uuid === 'string' && uuid ? uuid : null; +} + +function getEditorData(value: any): Record | undefined { + const data = value?.[getCC().editorExtrasTag]; + if (!data || typeof data !== 'object') { + return undefined; + } + return clonePlain(data); +} + +function assignEditorData(value: any, data?: Record): void { + if (!data) { + return; + } + const tag = getCC().editorExtrasTag; + Object.assign(value[tag] ||= {}, clonePlain(data)); +} + +interface ClassSerializationState { + constructor: any; + props?: PropertyDescriptor; + values?: PropertyDescriptor; + deserialize?: PropertyDescriptor; +} + +function withSerializableEditorExtras(constructors: Iterable, action: () => T): T { + const tag = getCC().editorExtrasTag || '__editorExtras__'; + const states: ClassSerializationState[] = []; + let operationFailed = false; + try { + for (const constructor of new Set(constructors)) { + if (typeof constructor !== 'function' || !Array.isArray(constructor.__values__) || constructor.__values__.includes(tag)) { + continue; + } + const state: ClassSerializationState = { + constructor, + props: Object.getOwnPropertyDescriptor(constructor, '__props__'), + values: Object.getOwnPropertyDescriptor(constructor, '__values__'), + deserialize: Object.getOwnPropertyDescriptor(constructor, '__deserialize__'), + }; + states.push(state); + if (Array.isArray(constructor.__props__) && !constructor.__props__.includes(tag)) { + constructor.__props__ = [...constructor.__props__, tag]; + } + constructor.__values__ = [...constructor.__values__, tag]; + if (!state.deserialize || state.deserialize.configurable !== false) { + delete constructor.__deserialize__; + } + } + return action(); + } catch (error) { + operationFailed = true; + throw error; + } finally { + let restorationFailed = false; + let restorationError: unknown; + for (const state of states.reverse()) { + for (const [key, descriptor] of [ + ['__deserialize__', state.deserialize], + ['__values__', state.values], + ['__props__', state.props], + ] as const) { + try { + restoreOwnProperty(state.constructor, key, descriptor); + } catch (error) { + if (!restorationFailed) { + restorationFailed = true; + restorationError = error; + } + } + } + } + if (restorationFailed && !operationFailed) { + throw restorationError; + } + } +} + +function restoreOwnProperty(owner: object, key: string, descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(owner, key, descriptor); + } else { + delete (owner as Record)[key]; + } +} + +function collectEditorExtrasConstructors(root: unknown): Set { + const tag = getCC().editorExtrasTag || '__editorExtras__'; + const constructors = new Set(); + visitObjectGraph(root, (value) => { + if (Object.prototype.hasOwnProperty.call(value, tag) && typeof value.constructor === 'function') { + constructors.add(value.constructor); + } + }); + return constructors; +} + +function collectSerializedEditorExtrasConstructors(root: unknown): Set { + const cc = getCC(); + const tag = cc.editorExtrasTag || '__editorExtras__'; + const constructors = new Set(); + visitObjectGraph(root, (value) => { + if (!Object.prototype.hasOwnProperty.call(value, tag) || typeof value.__type__ !== 'string') { + return; + } + const constructor = cc.js.getClassById?.(value.__type__) || cc.js.getClassByName?.(value.__type__); + if (constructor) { + constructors.add(constructor); + } + }); + return constructors; +} + +function visitObjectGraph(root: unknown, visitor: (value: Record) => void): void { + const pending: unknown[] = [root]; + const visited = new WeakSet(); + while (pending.length) { + const current = pending.pop(); + if (!current || typeof current !== 'object' || visited.has(current)) { + continue; + } + visited.add(current); + const object = current as Record; + visitor(object); + if (ArrayBuffer.isView(current)) { + continue; + } + if (current instanceof Map) { + for (const [key, value] of current) { + pending.push(key, value); + } + continue; + } + if (current instanceof Set) { + for (const value of current) { + pending.push(value); + } + continue; + } + for (const key of Object.keys(object)) { + try { + pending.push(object[key]); + } catch { + // Ignore engine accessors that are unavailable outside their owning runtime. + } + } + } +} + +function getStateComponents(state: any): any[] { + return state?.components ? Array.from(state.components as Iterable) : []; +} + +function getStateType(state: any, stateMachine: any, api: any): AnimationGraphStateView['type'] { + if (state === stateMachine.entryState) return 'entry'; + if (state === stateMachine.exitState) return 'exit'; + if (state === stateMachine.anyState) return 'any'; + if (state instanceof api.MotionState) return 'motion'; + if (state instanceof api.EmptyState) return 'empty'; + if (state instanceof api.SubStateMachine) return 'sub-state-machine'; + if (state instanceof api.ProceduralPoseState) return 'procedural-pose'; + return 'unknown'; +} + +function getMotionType(motion: any, api: any): AnimationGraphMotionView['type'] { + if (motion instanceof api.ClipMotion) return 'clip'; + if (motion instanceof api.AnimationBlend1D) return 'blend-1d'; + if (motion instanceof api.AnimationBlend2D) return 'blend-2d'; + if (motion instanceof api.AnimationBlendDirect) return 'blend-direct'; + return 'unknown'; +} + +function isBlendMotion(motion: any, api: any): boolean { + return motion instanceof api.AnimationBlend1D + || motion instanceof api.AnimationBlend2D + || motion instanceof api.AnimationBlendDirect; +} + +function isStateMachineLike(value: any): boolean { + return !!value + && typeof value.states === 'function' + && typeof value.transitions === 'function'; +} + +function isVec2Like(value: unknown): value is { x: number; y: number } { + return !!value && typeof value === 'object' + && typeof (value as { x?: unknown }).x === 'number' + && typeof (value as { y?: unknown }).y === 'number'; +} + +function getNodeTitle(node: any): string { + const title = node.getTitle?.(); + if (typeof title === 'string') { + return title; + } + if (Array.isArray(title) && typeof title[0] === 'string') { + return title[0]; + } + return getClassName(node); +} + +function getPoseInputDisplayName(key: unknown, metadata: any): string { + const displayName = metadata?.displayName; + if (typeof displayName === 'string') { + return displayName; + } + if (Array.isArray(displayName) && typeof displayName[0] === 'string') { + return displayName[0]; + } + return Array.isArray(key) ? key.join('.') : String(key); +} + +function isInputVisible(node: any, attrs: any): boolean { + const visible = attrs?.visible; + if (typeof visible === 'function') { + return !!visible.call(node); + } + return visible === undefined ? true : !!visible; +} + +function parsePoseInputId(api: any, inputId: string): any | undefined { + try { + const key = JSON.parse(inputId); + return api.poseGraphOp.isWellFormedInputKey(key) ? key : undefined; + } catch { + return undefined; + } +} + +function setPoseInputValue(node: any, inputKey: readonly (string | number)[], value: unknown): void { + let owner = node; + for (let index = 0; index < inputKey.length - 1; ++index) { + const key = inputKey[index]; + if (owner === null || owner === undefined || !(key in Object(owner))) { + throw new Error(`Pose input path is no longer valid: ${JSON.stringify(inputKey)}`); + } + owner = owner[key]; + } + const key = inputKey[inputKey.length - 1]; + if (key === undefined || owner === null || owner === undefined || !(key in Object(owner))) { + throw new Error(`Pose input path is no longer valid: ${JSON.stringify(inputKey)}`); + } + owner[key] = value; +} + +function directProperty(owner: any, key: string, attrs?: Record): AdapterProperty { + return { + get: () => owner[key], + set: (value) => { owner[key] = value; }, + attrs, + }; +} + +function nestedProperty(owner: any, key: string, attrs?: Record): AdapterProperty { + return directProperty(owner, key, attrs); +} + +function createAdapterBinding(type: string, properties: Record): InspectorBinding { + const holder: Record = {}; + const value: Record = {}; + const propertyCapabilities: Record = {}; + for (const [key, property] of Object.entries(properties)) { + Object.defineProperty(holder, key, { + enumerable: true, + configurable: false, + get: property.get, + set: property.set, + }); + const dump = encodeSerializedObject(property.get(), property.attrs || {}, holder, key); + dump.path = key; + value[key] = dump; + propertyCapabilities[key] = getEncodedPropertyOperationCapabilities(dump, property.attrs); + } + const root: IProperty = { + name: type, + type, + value, + visible: true, + readonly: false, + path: '', + }; + return { + dump: root, + propertyCapabilities, + apply: async (path, patch) => { + const current = value[path]; + if (!current || !properties[path]) { + throw new Error(`Unknown property dump path: ${path}`); + } + await applyEncodedPropertyPatch(holder, path, current, patch); + }, + reset: async (path) => { + const current = value[path]; + const property = properties[path]; + if (!current || !property) { + throw new Error(`Unknown property dump path: ${path}`); + } + applyEncodedPropertyOperation(holder, path, current, property.attrs, 'reset'); + }, + create: async (path) => { + const current = value[path]; + const property = properties[path]; + if (!current || !property) { + throw new Error(`Unknown property dump path: ${path}`); + } + applyEncodedPropertyOperation(holder, path, current, property.attrs, 'create'); + }, + }; +} + +function createDecoratedBinding(instance: any, name: string): InspectorBinding { + const dump = encodePropertyObject(instance, name); + return { + dump, + propertyCapabilities: queryPropertyObjectOperationCapabilities(instance, dump), + apply: (path, patch) => applyPropertyObjectPatch(instance, path, patch), + reset: async (path) => applyPropertyObjectOperation(instance, path, 'reset'), + create: async (path) => applyPropertyObjectOperation(instance, path, 'create'), + }; +} + +function assertInspectorBindingPath(path: string, expected: string): void { + if (path !== expected) { + throw new Error(`Unknown property dump path: ${path}`); + } +} + +function enumList(enumType: Record): Array<{ name: string; value: number }> { + return Object.entries(enumType) + .filter(([, value]) => typeof value === 'number') + .map(([name, value]) => ({ name, value: value as number })); +} + +function uniqueStateName(stateMachine: any, requested: string): string { + const names = new Set(Array.from(stateMachine.states() as Iterable).map((state) => state.name)); + if (!names.has(requested)) { + return requested; + } + let index = 1; + let candidate = `${requested}-${String(index).padStart(3, '0')}`; + while (names.has(candidate)) { + index += 1; + candidate = `${requested}-${String(index).padStart(3, '0')}`; + } + return candidate; +} + +function uniqueStashName(layer: any): string { + let index = 1; + while (layer.getStash(`Stash${index}`)) { + index += 1; + } + return `Stash${index}`; +} + +function getPoseGraphContextLayerIndex(context: AnimationGraphPoseGraphContext): number { + return context.kind === 'layer-stash' + ? context.layerIndex + : getStateMachineContextLayerIndex(context.stateMachine); +} + +function getStateMachineContextLayerIndex(context: AnimationGraphStateMachineContext): number { + switch (context.kind) { + case 'layer-state-machine': + return context.layerIndex; + case 'pose-node-state-machine': + return getPoseGraphContextLayerIndex(context.poseGraph); + case 'sub-state-machine': + return getStateMachineContextLayerIndex(context.stateMachine); + } +} + +function defaultStateName(type: AnimationGraphStateView['type'] | import('./@types/public').AnimationGraphStateType): string { + switch (type) { + case 'motion': return 'Motion'; + case 'empty': return 'Empty'; + case 'sub-state-machine': return 'State Machine'; + case 'procedural-pose': return 'Pose'; + default: return 'State'; + } +} + +function createState(stateMachine: any, type: import('./@types/public').AnimationGraphStateType): any { + switch (type) { + case 'motion': return stateMachine.addMotion(); + case 'empty': return stateMachine.addEmpty(); + case 'sub-state-machine': return stateMachine.addSubStateMachine(); + case 'procedural-pose': return stateMachine.addProceduralPoseState(); + } +} + +function createBlendItem(parent: any, api: any, motion: any): any { + let item: any; + if (parent instanceof api.AnimationBlend1D) { + item = new api.AnimationBlend1D.Item(); + item.threshold = Array.from(parent.items as Iterable).length; + } else if (parent instanceof api.AnimationBlend2D) { + item = new api.AnimationBlend2D.Item(); + item.threshold = new (getCC().Vec2)(); + } else if (parent instanceof api.AnimationBlendDirect) { + item = new api.AnimationBlendDirect.Item(); + } else { + throw new Error('Parent motion does not accept children.'); + } + item.motion = motion; + return item; +} + +function dumpTransitionConditionBinding(binding: any): Record { + if (!binding || typeof binding !== 'object') { + return {}; + } + const ctor = binding.constructor; + const result: Record = { + __type__: getCC().js.getClassId(ctor) || getClassName(binding), + }; + const properties = Array.isArray(ctor?.__props__) ? ctor.__props__ : []; + for (const property of properties) { + if (typeof property === 'string') { + result[property] = clonePlain(binding[property]); + } + } + return result; +} + +function createTransitionCondition(api: any, type: import('./@types/public').AnimationGraphTransitionConditionType): any { + switch (type) { + case 'binary': return new api.BinaryCondition(); + case 'unary': return new api.UnaryCondition(); + case 'trigger': return new api.TriggerCondition(); + } +} + +function setTransitionConditionProperty(condition: any, path: string, value: unknown, api: any): void { + if (condition instanceof api.BinaryCondition) { + switch (path) { + case 'operator': + condition.operator = requireIntegerInRange(path, value, 0, 5); + return; + case 'rhs': + condition.rhs = requireFiniteNumber(path, value); + return; + case 'lhsBinding.type': + condition.lhsBinding.type = requireEnumValue(path, value, [ + api.TCBindingValueType.FLOAT, + api.TCBindingValueType.INTEGER, + ]); + return; + case 'lhsBinding.variableName': + condition.lhsBinding.variableName = requireString(path, value); + return; + default: + throw new Error(`Unsupported BinaryCondition property path: ${path}`); + } + } + if (condition instanceof api.UnaryCondition) { + switch (path) { + case 'operator': + condition.operator = requireIntegerInRange(path, value, 0, 1); + return; + case 'operand.variable': + condition.operand.variable = requireString(path, value); + return; + default: + throw new Error(`Unsupported UnaryCondition property path: ${path}`); + } + } + if (condition instanceof api.TriggerCondition) { + if (path !== 'trigger') { + throw new Error(`Unsupported TriggerCondition property path: ${path}`); + } + condition.trigger = requireString(path, value); + return; + } + throw new Error(`Unsupported transition condition type: ${getClassName(condition)}`); +} + +function requireFiniteNumber(path: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Transition condition property ${path} expects a finite number.`); + } + return value; +} + +function requireIntegerInRange(path: string, value: unknown, min: number, max: number): number { + const number = requireFiniteNumber(path, value); + if (!Number.isInteger(number) || number < min || number > max) { + throw new Error(`Transition condition property ${path} expects an integer between ${min} and ${max}.`); + } + return number; +} + +function requireEnumValue(path: string, value: unknown, allowedValues: number[]): number { + const number = requireFiniteNumber(path, value); + if (!Number.isInteger(number) || !allowedValues.includes(number)) { + throw new Error(`Transition condition property ${path} expects one of: ${allowedValues.join(', ')}.`); + } + return number; +} + +function requireString(path: string, value: unknown): string { + if (typeof value !== 'string') { + throw new Error(`Transition condition property ${path} expects a string.`); + } + return value; +} + +const animationGraph = new AnimationGraphAssetService(); + +export default animationGraph; diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index cc4f658c9..13b7a2ec0 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -8,6 +8,7 @@ import assetQuery, { ASSET_TREE_INFO_DATA_KEYS } from './query'; import assetOperation from './operation'; import assetHandlerManager from './asset-handler'; import animationGraphVariant from '../animation-graph-variant'; +import animationGraph from '../animation-graph-service'; import * as serializedData from '../serialized-data'; import * as materialService from '../material-service'; @@ -58,6 +59,17 @@ class AssetManager extends EventEmitter { queryMaterialAllEffects = materialService.queryAllEffects; saveMaterial = materialService.saveMaterial; + // ---------- animation graph ---------- + queryAnimationGraph = animationGraph.query.bind(animationGraph); + queryAnimationGraphInspector = animationGraph.queryInspector.bind(animationGraph); + setAnimationGraphInspectorProperty = animationGraph.setInspectorProperty.bind(animationGraph); + resetAnimationGraphInspectorProperty = animationGraph.resetInspectorProperty.bind(animationGraph); + createAnimationGraphInspectorProperty = animationGraph.createInspectorProperty.bind(animationGraph); + executeAnimationGraphCommand = animationGraph.execute.bind(animationGraph); + saveAnimationGraph = animationGraph.save.bind(animationGraph); + reloadAnimationGraph = animationGraph.reload.bind(animationGraph); + onAnimationGraphChanged = animationGraph.onChanged.bind(animationGraph); + // ---------- animation graph variant --------- queryAnimationGraphVariant = animationGraphVariant.query.bind(animationGraphVariant); changeAnimationGraphVariant = animationGraphVariant.change.bind(animationGraphVariant); @@ -378,6 +390,16 @@ export interface TypedAssetManager extends EventEmitter { queryMaterialAllEffects: typeof materialService.queryAllEffects; saveMaterial: typeof materialService.saveMaterial; + queryAnimationGraph: typeof animationGraph.query; + queryAnimationGraphInspector: typeof animationGraph.queryInspector; + setAnimationGraphInspectorProperty: typeof animationGraph.setInspectorProperty; + resetAnimationGraphInspectorProperty: typeof animationGraph.resetInspectorProperty; + createAnimationGraphInspectorProperty: typeof animationGraph.createInspectorProperty; + executeAnimationGraphCommand: typeof animationGraph.execute; + saveAnimationGraph: typeof animationGraph.save; + reloadAnimationGraph: typeof animationGraph.reload; + onAnimationGraphChanged: typeof animationGraph.onChanged; + queryAnimationGraphVariant: typeof animationGraphVariant.query; changeAnimationGraphVariant: typeof animationGraphVariant.change; saveAnimationGraphVariant: typeof animationGraphVariant.save; diff --git a/src/core/assets/manager/operation.ts b/src/core/assets/manager/operation.ts index a29727d4a..26a3db8d9 100644 --- a/src/core/assets/manager/operation.ts +++ b/src/core/assets/manager/operation.ts @@ -356,6 +356,27 @@ class AssetOperation extends EventEmitter { throw new Error(`${i18n.t('assets.save_asset.fail.uuid')}`); } + return this._runAnimationGraphExternalWrite(asset, () => this._saveAssetContent(asset, content)); + } + + async saveAnimationGraphDocument(uuidOrURLOrPath: string, content: string | Buffer) { + const asset = assetQuery.queryAsset(uuidOrURLOrPath); + if (!asset) { + throw new Error(`${i18n.t('assets.save_asset.fail.asset', { asset: uuidOrURLOrPath })}`); + } + if (asset._assetDB.options.readonly) { + throw new Error(`${i18n.t('assets.operation.readonly')} \n url: ${asset.url}`); + } + if (content === undefined) { + throw new Error(`${i18n.t('assets.save_asset.fail.content')}`); + } + if (!asset.source) { + throw new Error(`${i18n.t('assets.save_asset.fail.uuid')}`); + } + return this._saveAssetContent(asset, content); + } + + private async _saveAssetContent(asset: IAsset, content: string | Buffer) { this._validateAssetContentBeforeSave(asset, content); const res = await assetHandlerManager.saveAsset(asset, content); if (res) { @@ -419,16 +440,19 @@ class AssetOperation extends EventEmitter { options.target = url2path(options.target); } options.target = this._checkOverwrite(options.target, options); - const assetPath = await assetHandlerManager.createAsset(options); - await this.refreshAsset(assetPath); - const asset = assetQuery.queryAsset(assetPath); - if (!asset) { - throw new Error(`Create asset in ${options.target} failed`); - } - if (asset && (!asset.imported || asset.invalid)) { - throw asset.importError || new Error(`Create asset in ${options.target} failed`); - } - return assetQuery.encodeAsset(asset); + const affectedGraphs = this._queryAnimationGraphAssetsAt(options.target); + return this._runAnimationGraphExternalWrites(affectedGraphs, async () => { + const assetPath = await assetHandlerManager.createAsset(options); + await assetDBManager.addTask(this._refreshAsset.bind(this), [assetPath]); + const asset = assetQuery.queryAsset(assetPath); + if (!asset) { + throw new Error(`Create asset in ${options.target} failed`); + } + if (!asset.imported || asset.invalid) { + throw asset.importError || new Error(`Create asset in ${options.target} failed`); + } + return assetQuery.encodeAsset(asset); + }); } /** @@ -488,30 +512,33 @@ class AssetOperation extends EventEmitter { private async _importAsset(source: string, targetPath: string, options?: AssetOperationOption): Promise { const isSamePath = this._isSameFilesystemPath(source, targetPath); + const reservation = isSamePath ? undefined : this._reserveImportTargetPath(targetPath, options); + targetPath = reservation?.targetPath ?? targetPath; + const affectedGraphs = this._queryAnimationGraphAssetsAt(targetPath); - if (!isSamePath) { - const reservation = this._reserveImportTargetPath(targetPath, options); - targetPath = reservation.targetPath; - try { - const copyOptions = options?.overwrite === undefined ? undefined : { overwrite: options.overwrite }; - await copyPath(source, targetPath, copyOptions); - } finally { - reservation.release(); - } - } + try { + return await this._runAnimationGraphExternalWrites(affectedGraphs, async () => { + if (!isSamePath) { + const copyOptions = options?.overwrite === undefined ? undefined : { overwrite: options.overwrite }; + await copyPath(source, targetPath, copyOptions); + } - const assetTarget = this._pathToDbUrlIfInsideAssetDB(targetPath); - await this.refreshAsset(assetTarget); - const assetInfo = assetQuery.queryAssetInfo(assetTarget); - if (!assetInfo) { - return []; - } - if (!assetInfo.isDirectory) { - return [assetInfo]; + const assetTarget = this._pathToDbUrlIfInsideAssetDB(targetPath); + await assetDBManager.addTask(this._refreshAsset.bind(this), [assetTarget]); + const assetInfo = assetQuery.queryAssetInfo(assetTarget); + if (!assetInfo) { + return []; + } + if (!assetInfo.isDirectory) { + return [assetInfo]; + } + return assetQuery.queryAssetInfos({ + pattern: `${assetInfo.url}/**/*` + }); + }); + } finally { + reservation?.release(); } - return assetQuery.queryAssetInfos({ - pattern: `${assetInfo.url}/**/*` - }); } private _queueImportByTargetPath(targetPath: string, task: () => Promise): Promise { @@ -592,6 +619,11 @@ class AssetOperation extends EventEmitter { throw new Error(`Cannot copy an asset into or over itself.\nsource: ${source}\ntarget: ${target}`); } + const affectedGraphs = this._queryAnimationGraphAssetsAt(target); + return this._runAnimationGraphExternalWrites(affectedGraphs, () => this._copyAssetSource(source, target, options)); + } + + private async _copyAssetSource(source: string, target: string, options?: AssetOperationOption): Promise { const transaction = await copyAssetSource(source, target, options); let copiedAsset: IAsset | null = null; try { @@ -685,8 +717,10 @@ class AssetOperation extends EventEmitter { * @returns boolean */ async refreshAsset(pathOrUrlOrUUID: string): Promise { - // 将实际的刷新任务塞到 db 管理器的队列内等待执行 - return await assetDBManager.addTask(this._refreshAsset.bind(this), [pathOrUrlOrUUID]); + return this._runAnimationGraphExternalWrites(this._queryAnimationGraphAssetsAt(pathOrUrlOrUUID), async () => { + // 将实际的刷新任务塞到 db 管理器的队列内等待执行 + return await assetDBManager.addTask(this._refreshAsset.bind(this), [pathOrUrlOrUUID]); + }); } private async _refreshAsset(pathOrUrlOrUUID: string, autoRefreshDir = true): Promise { @@ -735,7 +769,9 @@ class AssetOperation extends EventEmitter { * @returns */ async reimportAsset(pathOrUrlOrUUID: string): Promise { - return await assetDBManager.addTask(this._reimportAsset.bind(this), [pathOrUrlOrUUID]); + return this._runAnimationGraphExternalWrites(this._queryAnimationGraphAssetsAt(pathOrUrlOrUUID), async () => { + return await assetDBManager.addTask(this._reimportAsset.bind(this), [pathOrUrlOrUUID]); + }); } private async _reimportAsset(pathOrUrlOrUUID: string): Promise { @@ -792,22 +828,25 @@ class AssetOperation extends EventEmitter { this._checkReadonly(asset); source = asset.source; target = this._checkOverwrite(target, option); - await moveAssetSource(source, target, option); - - const url = queryUrl(target); - const reg = /db:\/\/[^/]+/.exec(url); - // 常规的资源移动:期望只有 change 消息 - if (reg && reg[0] && url.startsWith(reg[0])) { - await this.refreshAsset(target); - // 因为文件被移走之后,文件夹的 mtime 会变化,所以要主动刷新一次被移走文件的文件夹 - // 必须在目标位置文件刷新完成后再刷新,如果放到前面,会导致先识别到文件被删除,触发 delete 后再发送 add - await this.refreshAsset(dirname(source)); - } else { - // 跨数据库移动资源或者覆盖操作时需要先刷目标文件,触发 delete 后再发送 add - await this.refreshAsset(source); - await this.refreshAsset(target); - } - console.debug(`move asset from ${source} -> ${target} success`); + const affectedGraphs = this._queryAnimationGraphAssetsAt(source, target); + await this._runAnimationGraphExternalWrites(affectedGraphs, async () => { + await moveAssetSource(source, target, option); + + const url = queryUrl(target); + const reg = /db:\/\/[^/]+/.exec(url); + // 常规的资源移动:期望只有 change 消息 + if (reg && reg[0] && url.startsWith(reg[0])) { + await assetDBManager.addTask(this._refreshAsset.bind(this), [target]); + // 因为文件被移走之后,文件夹的 mtime 会变化,所以要主动刷新一次被移走文件的文件夹 + // 必须在目标位置文件刷新完成后再刷新,如果放到前面,会导致先识别到文件被删除,触发 delete 后再发送 add + await assetDBManager.addTask(this._refreshAsset.bind(this), [dirname(source)]); + } else { + // 跨数据库移动资源或者覆盖操作时需要先刷目标文件,触发 delete 后再发送 add + await assetDBManager.addTask(this._refreshAsset.bind(this), [source]); + await assetDBManager.addTask(this._refreshAsset.bind(this), [target]); + } + console.debug(`move asset from ${source} -> ${target} success`); + }); } /** @@ -837,19 +876,22 @@ class AssetOperation extends EventEmitter { throw new Error(`${i18n.t('assets.rename_asset.fail.parent')} \nsource: ${source}\ntarget: ${target}`); } - const temp = join(dirname(target), '.rename_temp'); + const affectedGraphs = this._queryAnimationGraphAssetsAt(source, target); + await this._runAnimationGraphExternalWrites(affectedGraphs, async () => { + const temp = join(dirname(target), '.rename_temp'); - // 改到临时路径,然后刷新,删除原来的缓存 - await renamePath(source + '.meta', temp + '.meta'); - await renamePath(source, temp); - await this._refreshAsset(source, false); + // 改到临时路径,然后刷新,删除原来的缓存 + await renamePath(source + '.meta', temp + '.meta'); + await renamePath(source, temp); + await this._refreshAsset(source, false); - // 改为真正的路径,然后刷新,用新名字重新导入 - await renamePath(temp + '.meta', target + '.meta'); - await renamePath(temp, target); - await this._refreshAsset(target); - // TODO 返回资源信息 - console.debug(`rename asset from ${source} -> ${target} success`); + // 改为真正的路径,然后刷新,用新名字重新导入 + await renamePath(temp + '.meta', target + '.meta'); + await renamePath(temp, target); + await this._refreshAsset(target); + // TODO 返回资源信息 + console.debug(`rename asset from ${source} -> ${target} success`); + }); } /** @@ -868,14 +910,91 @@ class AssetOperation extends EventEmitter { throw new Error(`子资源无法单独删除,请传递父资源的 URL 地址`); } const path = asset.source; - const res = await assetDBManager.addTask(this._removeAsset.bind(this), [path, options]); - return res ? assetQuery.encodeAsset(asset) : null; + return this._runAnimationGraphExternalWrites(this._queryAnimationGraphAssetsAt(asset.source), async () => { + const res = await assetDBManager.addTask(this._removeAsset.bind(this), [path, options]); + return res ? assetQuery.encodeAsset(asset) : null; + }); + } + + private async _runAnimationGraphExternalWrite(asset: IAsset | null | undefined, write: () => Promise): Promise { + return this._runAnimationGraphExternalWrites(asset ? [asset] : [], write); + } + + private _queryAnimationGraphAssetsAt(...pathsOrUrlsOrUuids: string[]): IAsset[] { + const result = new Map(); + let allAssets: IAsset[] | undefined; + const queryAllAssets = () => { + if (allAssets) { + return allAssets; + } + // Some embedders and tests expose only the single-asset query surface. + // File operations do not need a full database scan in that case. + allAssets = typeof assetQuery.queryAssets === 'function' + ? assetQuery.queryAssets() + : []; + return allAssets; + }; + for (const pathOrUrlOrUuid of pathsOrUrlsOrUuids) { + const root = assetQuery.queryAsset(pathOrUrlOrUuid); + if (!root) { + continue; + } + const candidates = root.meta?.importer === 'database' + ? queryAllAssets().filter((asset) => this._isAssetInDatabase(root, asset)) + : this._isAssetDirectory(root) + ? queryAllAssets().filter((asset) => this._isSameFilesystemPath(root.source, asset.source) || utils.Path.contains(root.source, asset.source)) + : [root]; + for (const asset of candidates) { + if (this._isAnimationGraphAsset(asset)) { + result.set(asset.uuid, asset); + } + } + } + return Array.from(result.values()).sort((left, right) => left.uuid.localeCompare(right.uuid)); + } + + private _isAssetDirectory(asset: IAsset): boolean { + try { + return typeof asset.isDirectory === 'function' && asset.isDirectory(); + } catch { + return false; + } + } + + private _isAssetInDatabase(databaseRoot: IAsset, asset: IAsset): boolean { + const databaseUrl = databaseRoot.source.replace(/[\\/]+$/, ''); + const assetUrl = typeof asset.url === 'string' ? asset.url : ''; + if (assetUrl === databaseUrl || assetUrl.startsWith(`${databaseUrl}/`) || assetUrl.startsWith(`${databaseUrl}@`)) { + return true; + } + + const databaseName = databaseRoot.meta?.name || databaseRoot.meta?.id || databaseUrl.slice('db://'.length); + return asset._assetDB?.options?.name === databaseName; + } + + private _isAnimationGraphAsset(asset: IAsset): boolean { + return asset.meta?.importer === 'animation-graph' || (asset as any).type === 'cc.AnimationGraph'; + } + + private async _runAnimationGraphExternalWrites(assets: IAsset[], write: () => Promise): Promise { + if (!assets.length) { + return write(); + } + // Dynamically require the service to keep the asset operation module independent from + // the document service during module initialization. + const animationGraph = require('../animation-graph-service').default as { + runExternalWrites(uuids: string[], task: () => Promise): Promise; + }; + return animationGraph.runExternalWrites(assets.map((asset) => asset.uuid), write); } private async _removeAsset(path: string, options: DeleteAssetOptions = { useTrash: true }): Promise { let res = false; await removeAssetSource(path, { useTrash: options.useTrash !== false }); - await this.refreshAsset(path); + // removeAsset() may already be running inside the Animation Graph document queue. + // Calling the public refreshAsset() here would enqueue the same graph again and + // deadlock while the outer delete waits for this refresh to finish. + await assetDBManager.addTask(this._refreshAsset.bind(this), [path]); res = true; console.debug(`remove asset ${path} success`); return res; diff --git a/src/core/assets/serialized-data.ts b/src/core/assets/serialized-data.ts index 87f6be0ee..3a0fefdde 100644 --- a/src/core/assets/serialized-data.ts +++ b/src/core/assets/serialized-data.ts @@ -17,6 +17,14 @@ import i18n from '../base/i18n'; export type SerializedAssetDump = Record | IProperty; export type SerializedAssetPatch = SerializedAssetDump | Partial>; +export type EncodedPropertyOperation = 'reset' | 'create'; + +export interface EncodedPropertyOperationCapabilities { + set: boolean; + reset: boolean; + create: boolean; +} + export interface SerializedAssetQueryResult { uuid: string; url: string; @@ -161,7 +169,7 @@ function encodeComponentAsset( if (!(key in instance)) { return; } - const attrs = cc.Class.attr(ctor, key); + const attrs = cc.Class.attr(instance, key); const dumpData = encodeSerializedObject(instance[key], attrs, instance, key); if (dumpData.type !== 'Unknown') { value[key] = dumpData; @@ -176,6 +184,298 @@ function encodeComponentAsset( return value; } +/** + * Encodes an engine instance and its decorated properties into an Inspector-ready root dump. + * + * This is intentionally kept in the asset/property layer so non-scene editors can reuse the + * same dynamic attribute, getter/setter and asset-reference behavior without depending on the + * scene service. + */ +export function encodePropertyObject(instance: any, name?: string): IProperty { + if (!instance || typeof instance !== 'object') { + throw new Error('Property dump target must be an object.'); + } + + const ctor = instance.constructor; + const dump: IProperty = { + name: name || getTypeName(ctor), + type: getTypeName(ctor), + value: encodeComponentAsset(instance, modifyPropName), + visible: true, + readonly: false, + path: '', + }; + assignPropertyPaths(dump); + return dump; +} + +/** + * Applies one value patch to an engine instance using a path returned by + * {@link encodePropertyObject}. Schema metadata from the caller is ignored. + */ +export async function applyPropertyObjectPatch( + instance: any, + path: string, + patch: IProperty | unknown, +): Promise { + const currentRoot = encodePropertyObject(instance); + const current = findPropertyDump(currentRoot, path); + if (!current) { + throw new Error(`Unknown property dump path: ${path}`); + } + + const next: IProperty = { + ...cloneDeep(current), + value: cloneDeep(isPropertyLike(patch) ? patch.value : patch), + }; + validateEditablePropertyPatch(path, current, next); + + const { owner, key } = resolvePropertyOwner(instance, path); + await setValue(owner, { [key]: next }, key); +} + +/** + * Applies a patch when the caller already owns the current property dump. This is used for + * virtual properties such as Pose Graph input constants. + */ +export async function applyEncodedPropertyPatch( + owner: any, + key: string, + current: IProperty, + patch: IProperty | unknown, +): Promise { + const next: IProperty = { + ...cloneDeep(current), + value: cloneDeep(isPropertyLike(patch) ? patch.value : patch), + }; + validateEditablePropertyPatch(current.path || key, current, next); + await setValue(owner, { [key]: next }, key); +} + +/** + * Applies Creator-compatible reset/create semantics to a decorated object property. + * The operation is resolved from the current engine instance instead of caller-provided dump + * metadata so default factories, cloneable values and constructors remain authoritative. + */ +export function applyPropertyObjectOperation( + instance: any, + path: string, + operation: EncodedPropertyOperation, +): void { + const currentRoot = encodePropertyObject(instance); + const current = findPropertyDump(currentRoot, path); + if (!current) { + throw new Error(`Unknown property dump path: ${path}`); + } + + const { owner, key } = resolvePropertyOwner(instance, path); + const attributes = getPropertyAttributes(owner, key); + applyEncodedPropertyOperation(owner, key, current, attributes, operation); +} + +/** + * Applies reset/create to a property whose dump and attributes are already known. This is used + * by virtual properties such as Pose Graph input constants and explicit Graph adapters. + */ +export function applyEncodedPropertyOperation( + owner: any, + key: string, + current: IProperty, + attributes: any, + operation: EncodedPropertyOperation, +): void { + assertEditablePropertyOperation(current.path || key, current); + const supported = operation === 'create' + ? canCreatePropertyValue(attributes) + : canResetPropertyValue(attributes); + if (!supported) { + throw new Error(`Property ${current.path || key} does not support ${operation}.`); + } + + const value = operation === 'create' + ? getPropertyCreateValue(attributes) + : getPropertyResetValue(attributes); + owner[key] = value; +} + +/** + * Returns per-path operation support for one decorated instance. Graph Inspector snapshots use + * this to avoid presenting reset/create actions that the engine attributes can not fulfill. + */ +export function queryPropertyObjectOperationCapabilities( + instance: any, + rootDump: IProperty = encodePropertyObject(instance), +): Record { + const capabilities: Record = {}; + visitPropertyDumps(rootDump, (path, current) => { + if (!path) { + return; + } + try { + const { owner, key } = resolvePropertyOwner(instance, path); + capabilities[path] = getEncodedPropertyOperationCapabilities( + current, + getPropertyAttributes(owner, key), + ); + } catch { + capabilities[path] = getEncodedPropertyOperationCapabilities(current, undefined); + } + }); + return capabilities; +} + +export function getEncodedPropertyOperationCapabilities( + current: IProperty, + attributes: any, +): EncodedPropertyOperationCapabilities { + const editable = current.visible !== false && current.readonly !== true; + return { + set: editable, + reset: editable && canResetPropertyValue(attributes), + create: editable && canCreatePropertyValue(attributes), + }; +} + +function assignPropertyPaths(property: IProperty, path = ''): void { + property.path = path; + if (Array.isArray(property.value)) { + property.value.forEach((child, index) => { + if (isPropertyLike(child)) { + assignPropertyPaths(child, path ? `${path}.${index}` : `${index}`); + } + }); + return; + } + if (!isRecord(property.value)) { + return; + } + for (const [key, child] of Object.entries(property.value)) { + if (isPropertyLike(child)) { + assignPropertyPaths(child, path ? `${path}.${key}` : key); + } + } +} + +function findPropertyDump(root: IProperty, path: string): IProperty | undefined { + if (!path) { + return root; + } + let current: IProperty | undefined = root; + for (const segment of path.split('.')) { + const value: unknown = current?.value; + if (Array.isArray(value)) { + const index = Number(segment); + current = Number.isInteger(index) ? value[index] as IProperty | undefined : undefined; + } else if (isRecord(value)) { + current = value[segment] as IProperty | undefined; + } else { + current = undefined; + } + if (!isPropertyLike(current)) { + return undefined; + } + } + return current; +} + +function visitPropertyDumps( + root: IProperty, + visitor: (path: string, property: IProperty) => void, +): void { + visitor(root.path || '', root); + if (Array.isArray(root.value)) { + root.value.forEach((child) => { + if (isPropertyLike(child)) { + visitPropertyDumps(child, visitor); + } + }); + return; + } + if (!isRecord(root.value)) { + return; + } + Object.values(root.value).forEach((child) => { + if (isPropertyLike(child)) { + visitPropertyDumps(child, visitor); + } + }); +} + +function resolvePropertyOwner(instance: any, path: string): { owner: any; key: string } { + const segments = path.split('.').filter(Boolean); + if (!segments.length) { + throw new Error('The root property dump can not be assigned directly.'); + } + const key = segments.pop()!; + let owner = instance; + for (const segment of segments) { + if (owner === null || owner === undefined || !(segment in Object(owner))) { + throw new Error(`Unknown property object path: ${path}`); + } + owner = owner[segment]; + } + return { owner, key }; +} + +function getPropertyAttributes(owner: any, key: string): any { + if (owner === null || owner === undefined) { + return undefined; + } + return cc.Class.attr(owner, key); +} + +function assertEditablePropertyOperation(path: string, current: IProperty): void { + if (current.visible === false || current.readonly === true) { + throw new Error(`Property ${path} is readonly or hidden and can not be modified.`); + } +} + +function validateEditablePropertyPatch(path: string, current: IProperty, next: IProperty): void { + validatePropertyPatch(path, current, next); + if (isEqual(current.value, next.value)) { + return; + } + + const value = next.value; + switch (current.type) { + case 'Boolean': + if (typeof value !== 'boolean') { + throw new Error(`Property ${path} expects a boolean value.`); + } + break; + case 'Number': + case 'Integer': + case 'Float': + case 'Enum': + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Property ${path} expects a finite number.`); + } + if (current.min !== undefined && value < current.min) { + throw new Error(`Property ${path} must be greater than or equal to ${current.min}.`); + } + if (current.max !== undefined && value > current.max) { + throw new Error(`Property ${path} must be less than or equal to ${current.max}.`); + } + if (current.type === 'Integer' && !Number.isInteger(value)) { + throw new Error(`Property ${path} expects an integer value.`); + } + if (current.type === 'Enum' && current.enumList?.length) { + const values = current.enumList.map((item) => item && typeof item === 'object' ? item.value : item); + if (!values.includes(value)) { + throw new Error(`Property ${path} is not a valid enum value.`); + } + } + break; + case 'String': + if (typeof value !== 'string') { + throw new Error(`Property ${path} expects a string value.`); + } + break; + default: + break; + } +} + export function encodeSerializedObject( object: any, attributes: any, @@ -310,6 +610,46 @@ function getPropertyDefault(attribute: any) { return typeof attribute.default === 'function' ? attribute.default() : attribute.default; } +function getPropertyResetValue(attribute: any): any { + let value = getPropertyDefault(attribute); + if (value && typeof value === 'object') { + if (typeof value.clone === 'function') { + value = value.clone(); + } else if (Array.isArray(value)) { + value = []; + } + } + return value; +} + +function getPropertyCreateValue(attribute: any): any { + const value = getPropertyResetValue(attribute); + if ((value === null || value === undefined) && typeof attribute.ctor === 'function') { + return new attribute.ctor(); + } + return value; +} + +function canResetPropertyValue(attribute: any): boolean { + return !!attribute + && typeof attribute === 'object' + && Object.prototype.hasOwnProperty.call(attribute, 'default'); +} + +function canCreatePropertyValue(attribute: any): boolean { + if (!attribute || typeof attribute !== 'object') { + return false; + } + if (typeof attribute.ctor === 'function') { + return true; + } + if (!canResetPropertyValue(attribute)) { + return false; + } + return typeof attribute.default === 'function' + || (attribute.default !== null && attribute.default !== undefined); +} + function getPropertyConstructor(object: any, attribute: any) { if (attribute && attribute.ctor) { return attribute.ctor; @@ -675,7 +1015,7 @@ function validatePropertyPatch(path: string, current: IProperty, next: IProperty for (const [key, value] of Object.entries(next.value)) { const currentChild = current.value[key]; - if (!currentChild) { + if (!Object.prototype.hasOwnProperty.call(current.value, key)) { throw new Error(`Unknown serialized field: ${path}.${key}`); } if (isPropertyLike(currentChild) && isPropertyLike(value)) { @@ -709,34 +1049,21 @@ async function applyFieldDumpPatch( } async function setValue(prop: any, dump: Record | any, key: string) { - if (!dump) { + if (dump === null || dump === undefined) { return; } - if (typeof dump !== 'object') { + const propertyDump = dump[key]; + if (!isPropertyLike(propertyDump)) { if (key === 'uuid' && '_uuid' in prop) { - prop._uuid = dump; + prop._uuid = propertyDump; return; } - prop[key] = dump; + prop[key] = cloneDeep(propertyDump); return; } - if (!dump[key].isArray) { - if (dump[key].value === null || typeof dump[key].value !== 'object') { - prop[key] = dump[key].value; - } else { - const names = Object.keys(dump[key].value); - for (const name of names) { - if (name === 'uuid') { - const uuid = extractUuidValue(dump[key].value[name]); - prop[key] = uuid ? createAssetReference(uuid, dump[key].type) : null; - } else { - await setValue(prop[key], dump[key].value, name); - } - } - } - } else { + if (propertyDump.isArray) { const propKeyAttr = cc.Class.attr(prop.constructor, key); if (!Array.isArray(prop[key])) { @@ -747,11 +1074,11 @@ async function setValue(prop: any, dump: Record | any, key: string) delete prop[key]; } else { const oldLength = prop[key].length; - const newLength = Array.isArray(dump[key].value) ? dump[key].value.length : 0; + const newLength = Array.isArray(propertyDump.value) ? propertyDump.value.length : 0; if (newLength > oldLength) { for (let i = oldLength; i < newLength; i++) { - prop[key][i] = createValueForDumpItem(dump[key].value[i]); - await setValue(prop[key], dump[key].value, i.toString()); + prop[key][i] = createValueForDumpItem(propertyDump.value[i]); + await setValue(prop[key], propertyDump.value, i.toString()); } } else if (newLength < oldLength) { while (prop[key].length > newLength) { @@ -761,15 +1088,16 @@ async function setValue(prop: any, dump: Record | any, key: string) const arrayClone = prop[key].slice(); prop[key] = []; for (let i = 0; i < oldLength; i++) { - if (dump[key].value[i] === undefined) { + if (propertyDump.value[i] === undefined) { continue; } - prop[key][i] = arrayClone[dump[key].value[i].name]; + const originalIndex = Number(propertyDump.value[i].name); + prop[key][i] = arrayClone[Number.isInteger(originalIndex) ? originalIndex : i]; } } for (let i = 0; i < prop[key].length; i++) { - const itemDump = dump[key].value[i]; + const itemDump = propertyDump.value[i]; if (itemDump?.type && (!prop[key][i] || itemDump.type !== prop[key][i].constructor.name)) { const typeClass = cc.js.getClassByName(itemDump.type); if (typeClass) { @@ -777,10 +1105,83 @@ async function setValue(prop: any, dump: Record | any, key: string) } } - await setValue(prop[key], dump[key].value, i.toString()); + await setValue(prop[key], propertyDump.value, i.toString()); } } + return; } + + const value = propertyDump.value; + if (isAssetPropertyDump(propertyDump)) { + const uuid = isRecord(value) ? extractUuidValue(value.uuid) : ''; + prop[key] = uuid ? createAssetReference(uuid, propertyDump.type) : null; + return; + } + + if (isValueTypePropertyDump(propertyDump)) { + prop[key] = createValueTypeValue(propertyDump, prop[key]); + return; + } + + if (value === null || typeof value !== 'object') { + prop[key] = value; + return; + } + + if (ArrayBuffer.isView(value)) { + const ctor = cc.js.getClassByName(propertyDump.type) || value.constructor; + prop[key] = new ctor(value); + return; + } + + const entries = Object.entries(value); + if (!entries.some(([, child]) => isPropertyLike(child))) { + prop[key] = cloneDeep(value); + return; + } + + if (prop[key] === null || typeof prop[key] !== 'object') { + const ctor = cc.js.getClassByName(propertyDump.type); + prop[key] = ctor ? new ctor() : {}; + } + for (const [name] of entries) { + await setValue(prop[key], value, name); + } +} + +function isAssetPropertyDump(dump: IProperty): boolean { + const ctor = dump.type ? cc.js.getClassByName(dump.type) : undefined; + return dump.type === 'cc.Asset' + || dump.extends?.includes('cc.Asset') + || isChildClassOf(ctor, cc.Asset); +} + +function isValueTypePropertyDump(dump: IProperty): boolean { + const ctor = dump.type ? cc.js.getClassByName(dump.type) : undefined; + return dump.type === 'cc.ValueType' + || dump.extends?.includes('cc.ValueType') + || isChildClassOf(ctor, cc.ValueType); +} + +function createValueTypeValue(dump: IProperty, current: any): any { + const ctor = dump.type ? cc.js.getClassByName(dump.type) : undefined; + if (!ctor) { + return cloneDeep(dump.value); + } + const value = new ctor(); + const source = isRecord(dump.value) ? dump.value : {}; + const currentSource = current && typeof current === 'object' ? current : {}; + const keys = Array.isArray(ctor.__props__) + ? ctor.__props__ + : Array.from(new Set([...Object.keys(currentSource), ...Object.keys(source)])); + for (const name of keys) { + if (Object.prototype.hasOwnProperty.call(source, name)) { + value[name] = cloneDeep(source[name]); + } else if (Object.prototype.hasOwnProperty.call(currentSource, name)) { + value[name] = currentSource[name]; + } + } + return value; } function createValueForDumpItem(itemDump: IProperty) { diff --git a/src/core/assets/test/animation-graph-service.test.ts b/src/core/assets/test/animation-graph-service.test.ts new file mode 100644 index 000000000..1fc9e9f2a --- /dev/null +++ b/src/core/assets/test/animation-graph-service.test.ts @@ -0,0 +1,1282 @@ +'use strict'; + +jest.mock('gl', () => { + const noop = () => undefined; + return () => ({ + VERTEX_SHADER: 35633, + FRAGMENT_SHADER: 35632, + COMPILE_STATUS: 35713, + LINK_STATUS: 35714, + getSupportedExtensions: () => [], + getExtension: noop, + createShader: (type: number) => ({ type }), + shaderSource: noop, + compileShader: noop, + getShaderParameter: () => true, + getShaderInfoLog: () => '', + deleteShader: noop, + createProgram: () => ({}), + attachShader: noop, + linkProgram: noop, + getProgramParameter: () => true, + getProgramInfoLog: () => '', + deleteProgram: noop, + }); +}); + +import { join } from 'path'; +import { readFileSync, remove } from 'fs-extra'; +import { globalSetup } from '../../test/global-setup'; +import { TestGlobalEnv } from '../../../tests/global-env'; +import { assetManager } from '..'; +import animationGraph from '../animation-graph-service'; +import type { IProperty } from '../../scene/@types/public'; + +describe('animation graph asset service', () => { + const name = `animation-graph-service-${Date.now()}`; + + function getDefaultGraphContent(): string { + return readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-graph/default.animgraph', + ), 'utf8'); + } + + beforeAll(async () => { + await globalSetup(); + }); + + afterAll(async () => { + try { + await assetManager.removeAsset(TestGlobalEnv.testRootUrl); + } catch { + // A failed test may already have removed the shared fixture directory. + } + await remove(TestGlobalEnv.testRoot); + await remove(TestGlobalEnv.testRoot + '.meta'); + }); + + it('queries, edits, mutates, saves and reloads one authoritative graph document', async () => { + const content = getDefaultGraphContent(); + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}.animgraph`), + content, + overwrite: true, + }); + + const initial = await assetManager.queryAnimationGraph(asset.uuid); + expect(initial).toMatchObject({ + uuid: asset.uuid, + revision: 0, + persistedRevision: 0, + dirty: false, + externallyModified: false, + }); + expect(initial.graph.layers).toHaveLength(1); + expect(initial.graph.layers[0].stateMachine.states.map((state) => state.type)).toEqual([ + 'entry', + 'exit', + 'any', + ]); + + const layerTarget = { kind: 'layer' as const, layerIndex: 0 }; + const layerInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, layerTarget); + const layerDump = layerInspector.dump.value as Record; + expect(layerDump.weight).toMatchObject({ path: 'weight', value: 1, type: 'Number' }); + + const firstEdit = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: layerTarget, + path: 'weight', + patch: { value: 0.5 }, + expected: layerInspector, + sourceId: 'inspector', + }); + expect(firstEdit).toMatchObject({ revision: 1, persistedRevision: 0, dirty: true }); + expect((firstEdit.dump.value as Record).weight.value).toBe(0.5); + + await expect(assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: layerTarget, + path: 'weight', + patch: { value: 0.75 }, + expected: layerInspector, + })).rejects.toMatchObject({ code: 'VERSION_CONFLICT' }); + + const withVariable = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'speed', variableType: 0, initialValue: 1.5 }, + expected: firstEdit, + }); + expect(withVariable.graph.variables).toContainEqual(expect.objectContaining({ + name: 'speed', + type: 0, + value: expect.objectContaining({ type: 'Number', value: 1.5, path: 'value' }), + })); + const withVariableValue = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'set-variable-value', name: 'speed', patch: 2.25 }, + expected: withVariable, + }); + expect(withVariableValue.graph.variables.find((variable) => variable.name === 'speed')?.value.value).toBe(2.25); + + const withMotionState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Idle', + editorData: { centerX: 24, centerY: 48 }, + }, + expected: withVariableValue, + sourceId: 'canvas', + }); + const motionState = withMotionState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Idle'); + expect(motionState).toMatchObject({ + type: 'motion', + speed: 1, + speedMultiplier: '', + speedMultiplierEnabled: false, + editorData: { centerX: 24, centerY: 48 }, + }); + + const stateTarget = { + kind: 'state' as const, + layerIndex: 0, + stateMachinePath: [], + stateIndex: motionState!.index, + }; + const stateInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, stateTarget); + expect((stateInspector.dump.value as Record).speed.value).toBe(1); + + const withComponent = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state-component', + layerIndex: 0, + stateMachinePath: [], + stateIndex: motionState!.index, + componentType: 'cc.animation.StateMachineComponent', + }, + expected: stateInspector, + }); + expect(withComponent.graph.layers[0].stateMachine.states[motionState!.index].components).toHaveLength(1); + const componentInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, { + kind: 'state-component', + layerIndex: 0, + stateMachinePath: [], + stateIndex: motionState!.index, + componentIndex: 0, + }); + expect(componentInspector.dump).toMatchObject({ path: '', visible: true, readonly: false }); + + const withMotion = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex: motionState!.index, + motionType: 'blend-1d', + }, + expected: componentInspector, + }); + expect(withMotion.graph.layers[0].stateMachine.states[motionState!.index].motion).toMatchObject({ + type: 'blend-1d', + level: [0], + variable: '', + value: 0, + }); + const blendTarget = withMotion.graph.layers[0].stateMachine.states[motionState!.index].motion!.target; + let blendInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, blendTarget); + blendInspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: blendTarget, + path: 'variable', + patch: 'speed', + expected: blendInspector, + }); + blendInspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: blendTarget, + path: 'value', + patch: 0.4, + expected: blendInspector, + }); + const withBlendParameters = await assetManager.queryAnimationGraph(asset.uuid); + expect(withBlendParameters.graph.layers[0].stateMachine.states[motionState!.index].motion).toMatchObject({ + variable: 'speed', + value: 0.4, + }); + + const withChild = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-motion-child', + target: { + kind: 'motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex: motionState!.index, + level: [0], + }, + motionType: 'clip', + }, + expected: withBlendParameters, + }); + expect(withChild.graph.layers[0].stateMachine.states[motionState!.index].motion?.children).toHaveLength(1); + + const withEmptyState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'empty', + name: 'Done', + }, + expected: withChild, + }); + const emptyState = withEmptyState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Done'); + const withTransition = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-transition', + layerIndex: 0, + stateMachinePath: [], + fromStateIndex: motionState!.index, + toStateIndex: emptyState!.index, + }, + expected: withEmptyState, + }); + const transition = withTransition.graph.layers[0].stateMachine.transitions.find((item) => ( + item.fromStateIndex === motionState!.index && item.toStateIndex === emptyState!.index + )); + expect(transition?.conditions).toEqual([]); + + const transitionTarget = { + kind: 'transition' as const, + layerIndex: 0, + stateMachinePath: [], + transitionIndex: transition!.index, + }; + const withCondition = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-transition-condition', + target: transitionTarget, + conditionType: 'binary', + }, + expected: withTransition, + }); + expect(withCondition.graph.layers[0].stateMachine.transitions[transition!.index].conditions[0]).toMatchObject({ + type: 'BinaryCondition', + operator: 0, + rhs: 0, + }); + + const withEditedCondition = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-transition-condition-property', + target: transitionTarget, + conditionIndex: 0, + path: 'lhsBinding.variableName', + value: 'speed', + }, + expected: withCondition, + }); + expect(withEditedCondition.graph.layers[0].stateMachine.transitions[transition!.index].conditions[0]).toMatchObject({ + type: 'BinaryCondition', + lhsBinding: { variableName: 'speed' }, + }); + + const withRenamedVariable = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'rename-variable', name: 'speed', newName: 'velocity' }, + expected: withEditedCondition, + }); + expect(withRenamedVariable.graph.variables.some((variable) => variable.name === 'velocity')).toBe(true); + expect(withRenamedVariable.graph.layers[0].stateMachine.transitions[transition!.index].conditions[0]).toMatchObject({ + type: 'BinaryCondition', + lhsBinding: { variableName: 'velocity' }, + }); + expect(withRenamedVariable.graph.layers[0].stateMachine.states[motionState!.index].motion).toMatchObject({ + variable: 'velocity', + value: 0.4, + }); + + const withPoseState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'procedural-pose', + name: 'Pose', + }, + expected: withRenamedVariable, + }); + const poseState = withPoseState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Pose'); + expect(poseState?.poseGraph?.nodes.length).toBeGreaterThan(0); + const outputNodeId = poseState!.poseGraph!.rootOutputNodeId; + const poseInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, { + kind: 'pose-node', + layerIndex: 0, + stateMachinePath: [], + stateIndex: poseState!.index, + nodeId: outputNodeId, + }); + expect(poseInspector.dump).toMatchObject({ path: '', visible: true }); + + const withPoseNode = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-pose-node', + layerIndex: 0, + stateMachinePath: [], + stateIndex: poseState!.index, + nodeType: 'cc.animation.PoseNodeBlendTwoPose', + }, + expected: poseInspector, + }); + const blendNode = withPoseNode.graph.layers[0].stateMachine.states[poseState!.index].poseGraph!.nodes.find((node) => ( + node.id !== outputNodeId && node.type.includes('PoseNodeBlendTwoPose') + )); + const ratioInput = blendNode!.inputs.find((input) => input.id.includes('ratio')); + expect(ratioInput?.value).toMatchObject({ path: 'value', type: 'Number', value: 1 }); + const inputTarget = { + kind: 'pose-input' as const, + layerIndex: 0, + stateMachinePath: [], + stateIndex: poseState!.index, + nodeId: blendNode!.id, + inputId: ratioInput!.id, + }; + const inputInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, inputTarget); + expect(inputInspector.dump).toMatchObject({ path: 'value', type: 'Number', value: 1 }); + const withEditedInput = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: inputTarget, + path: 'value', + patch: 0.25, + expected: inputInspector, + }); + expect(withEditedInput.dump.value).toBe(0.25); + const withResetInput = await assetManager.resetAnimationGraphInspectorProperty(asset.uuid, { + target: inputTarget, + path: 'value', + expected: withEditedInput, + }); + expect(withResetInput.dump.value).toBe(1); + + const concurrentResults = await Promise.allSettled([ + assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: layerTarget, + path: 'weight', + patch: 0.6, + expected: withResetInput, + }), + assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: layerTarget, + path: 'additive', + patch: true, + expected: withResetInput, + }), + ]); + expect(concurrentResults.map((result) => result.status).sort()).toEqual(['fulfilled', 'rejected']); + const afterConcurrentEdit = await assetManager.queryAnimationGraph(asset.uuid); + expect(afterConcurrentEdit.revision).toBe(withResetInput.revision + 1); + + await expect(assetManager.saveAsset(asset.uuid, content)).rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + + const saved = await assetManager.saveAnimationGraph(asset.uuid, afterConcurrentEdit); + expect(saved).toMatchObject({ dirty: false, persistedRevision: saved.revision }); + + const savedSource = readFileSync(asset.file, 'utf8'); + const genericWriteRace = await Promise.allSettled([ + assetManager.saveAsset(asset.uuid, `${savedSource}\n`), + assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: layerTarget, + path: 'weight', + patch: 0.7, + expected: saved, + }), + ]); + expect(genericWriteRace[0].status).toBe('fulfilled'); + expect(genericWriteRace[1]).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ code: 'SOURCE_CHANGED' }), + }); + const externallyChanged = await assetManager.queryAnimationGraph(asset.uuid); + expect(externallyChanged.externallyModified).toBe(true); + await expect(assetManager.saveAnimationGraph(asset.uuid, externallyChanged)).rejects.toMatchObject({ + code: 'SOURCE_CHANGED', + }); + + const reloaded = await assetManager.reloadAnimationGraph(asset.uuid, { expected: externallyChanged }); + expect(reloaded.documentId).not.toBe(saved.documentId); + expect(reloaded.revision).toBe(0); + expect(reloaded.graph.layers[0].weight).toBe(0.6); + expect(reloaded.graph.layers[0].stateMachine.states.some((state) => state.name === 'Idle')).toBe(true); + + // A direct Graph delete is coordinated by the same document queue. This also + // guards against re-entering the queue through the delete operation's refresh. + await expect(assetManager.removeAsset(asset.uuid, { useTrash: false })).resolves.toMatchObject({ + uuid: asset.uuid, + }); + }); + + it('keeps no-op and failed mutations transactional and rejects duplicate names', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-transaction.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const initial = await assetManager.queryAnimationGraph(asset.uuid); + + const noOp = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: { kind: 'layer', layerIndex: 0 }, + path: 'weight', + patch: 1, + expected: initial, + }); + expect(noOp).toMatchObject({ revision: initial.revision, dirty: false }); + + const withVariable = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'speed', variableType: 0, initialValue: 1 }, + expected: noOp, + }); + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'speed', variableType: 0, initialValue: 2 }, + expected: withVariable, + })).rejects.toMatchObject({ code: 'NAME_CONFLICT' }); + expect((await assetManager.queryAnimationGraph(asset.uuid)).revision).toBe(withVariable.revision); + + const withSecondVariable = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'direction', variableType: 0, initialValue: 0 }, + expected: withVariable, + }); + const sameName = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'rename-variable', name: 'speed', newName: 'speed' }, + expected: withSecondVariable, + }); + expect(sameName.revision).toBe(withSecondVariable.revision); + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'rename-variable', name: 'speed', newName: 'direction' }, + expected: sameName, + })).rejects.toMatchObject({ code: 'NAME_CONFLICT' }); + const afterVariableConflict = await assetManager.queryAnimationGraph(asset.uuid); + expect(afterVariableConflict.revision).toBe(withSecondVariable.revision); + expect(afterVariableConflict.graph.variables.map((variable) => variable.name)).toEqual(['speed', 'direction']); + + const withStash = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-stash', layerIndex: 0, name: 'Locomotion' }, + expected: afterVariableConflict, + }); + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-stash', layerIndex: 0, name: 'Locomotion' }, + expected: withStash, + })).rejects.toMatchObject({ code: 'NAME_CONFLICT' }); + expect((await assetManager.queryAnimationGraph(asset.uuid)).revision).toBe(withStash.revision); + + const withSecondStash = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-stash', layerIndex: 0, name: 'Secondary' }, + expected: withStash, + }); + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'rename-stash', layerIndex: 0, name: 'Secondary', newName: 'Locomotion' }, + expected: withSecondStash, + })).rejects.toMatchObject({ code: 'NAME_CONFLICT' }); + const afterStashConflict = await assetManager.queryAnimationGraph(asset.uuid); + expect(afterStashConflict.revision).toBe(withSecondStash.revision); + expect(afterStashConflict.graph.layers[0].stashes).toEqual(['Locomotion', 'Secondary']); + + const withDirectState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Direct', + }, + expected: afterStashConflict, + }); + const stateIndex = withDirectState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Direct')!.index; + const withDirectMotion = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'blend-direct', + }, + expected: withDirectState, + }); + const directTarget = withDirectMotion.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + const withDirectChild = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-motion-child', target: directTarget, motionType: 'clip' }, + expected: withDirectMotion, + }); + expect(withDirectChild.graph.layers[0].stateMachine.states[stateIndex].motion!.children![0].weight).toEqual({ + value: 0, + variable: '', + }); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-direct-blend-weight', + target: directTarget, + childIndex: 0, + value: 0.75, + variable: 1 as unknown as string, + }, + expected: withDirectChild, + })).rejects.toMatchObject({ code: 'INVALID_PROPERTY_PATCH' }); + const afterRollback = await assetManager.queryAnimationGraph(asset.uuid); + expect(afterRollback.revision).toBe(withDirectChild.revision); + expect(afterRollback.graph.layers[0].stateMachine.states[stateIndex].motion!.children![0].weight).toEqual({ + value: 0, + variable: '', + }); + + const withWeight = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-direct-blend-weight', + target: directTarget, + childIndex: 0, + value: 0.75, + variable: 'speed', + }, + expected: afterRollback, + }); + expect(withWeight.graph.layers[0].stateMachine.states[stateIndex].motion!.children![0].weight).toEqual({ + value: 0.75, + variable: 'speed', + }); + + await assetManager.saveAnimationGraph(asset.uuid, withWeight); + }); + + it('resets and creates Inspector properties through the authoritative Graph document', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-property-operations.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const target = { kind: 'layer' as const, layerIndex: 0 }; + const initial = await assetManager.queryAnimationGraphInspector(asset.uuid, target); + expect(initial.propertyCapabilities).toMatchObject({ + weight: { set: true, reset: true, create: true }, + additive: { set: true, reset: true, create: true }, + mask: { set: true, reset: true, create: true }, + }); + + const unchangedWeight = await assetManager.resetAnimationGraphInspectorProperty(asset.uuid, { + target, + path: 'weight', + expected: initial, + }); + expect(unchangedWeight.revision).toBe(initial.revision); + + const editedWeight = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target, + path: 'weight', + patch: 0.25, + expected: unchangedWeight, + }); + const resetWeight = await assetManager.resetAnimationGraphInspectorProperty(asset.uuid, { + target, + path: 'weight', + expected: editedWeight, + sourceId: 'inspector-reset', + }); + expect(resetWeight).toMatchObject({ revision: editedWeight.revision + 1, dirty: true }); + expect((resetWeight.dump.value as Record).weight.value).toBe(1); + + const editedAdditive = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target, + path: 'additive', + patch: true, + expected: resetWeight, + }); + const createdAdditive = await assetManager.createAnimationGraphInspectorProperty(asset.uuid, { + target, + path: 'additive', + expected: editedAdditive, + sourceId: 'inspector-create', + }); + expect(createdAdditive.revision).toBe(editedAdditive.revision + 1); + expect((createdAdditive.dump.value as Record).additive.value).toBe(false); + + await expect(assetManager.resetAnimationGraphInspectorProperty(asset.uuid, { + target, + path: 'weight', + expected: initial, + })).rejects.toMatchObject({ code: 'VERSION_CONFLICT' }); + + await assetManager.saveAnimationGraph(asset.uuid, createdAdditive); + }); + + it('supports value types, integer transition bindings and nested Creator graph contexts', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-contexts.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'position', variableType: 4 }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'set-variable-value', name: 'position', patch: { x: 1, y: 0, z: -2 } }, + expected: snapshot, + }); + expect(snapshot.graph.variables.find((variable) => variable.name === 'position')?.value.value).toEqual({ x: 1, y: 0, z: -2 }); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'rotation', variableType: 5 }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'set-variable-value', name: 'rotation', patch: { x: 0, y: 0.5, z: 0, w: 0.5 } }, + expected: snapshot, + }); + expect(snapshot.graph.variables.find((variable) => variable.name === 'rotation')?.value.value).toEqual({ x: 0, y: 0.5, z: 0, w: 0.5 }); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-stash', layerIndex: 0, name: 'Nested' }, + expected: snapshot, + }); + const stashPoseGraph = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')!.poseGraph; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-pose-node', + poseGraph: stashPoseGraph.context, + nodeType: 'cc.animation.PoseNodeStateMachine', + }, + expected: snapshot, + }); + const updatedStash = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')!.poseGraph; + const stateMachineNode = updatedStash.nodes.find((node) => node.type.includes('PoseNodeStateMachine'))!; + expect(stateMachineNode.stateMachine?.states.map((state) => state.type)).toEqual(['entry', 'exit', 'any']); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + stateMachine: stateMachineNode.stateMachine!.context, + stateType: 'procedural-pose', + name: 'Nested Pose', + }, + expected: snapshot, + }); + const nestedStash = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')!.poseGraph; + const nestedStateMachine = nestedStash.nodes.find((node) => node.id === stateMachineNode.id)!.stateMachine!; + expect(nestedStateMachine.states.some((state) => state.name === 'Nested Pose' && !!state.poseGraph)).toBe(true); + const nestedPoseState = nestedStateMachine.states.find((state) => state.name === 'Nested Pose')!; + const nestedStateInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, { + kind: 'state', + stateMachine: nestedStateMachine.context, + stateIndex: nestedPoseState.index, + }); + expect((nestedStateInspector.dump.value as Record).name.value).toBe('Nested Pose'); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-pose-node', + poseGraph: updatedStash.context, + nodeType: 'cc.animation.PoseNodePlayMotion', + createArg: { type: 'animation-blend-1d' }, + }, + expected: snapshot, + }); + const motionNode = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')! + .poseGraph.nodes.find((node) => node.type.includes('PoseNodePlayMotion'))!; + expect(motionNode.motion?.type).toBe('blend-1d'); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-motion-child', target: motionNode.motion!.target, motionType: 'clip' }, + expected: snapshot, + }); + const motionAfterChild = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')! + .poseGraph.nodes.find((node) => node.id === motionNode.id)!.motion; + expect(motionAfterChild?.children).toHaveLength(1); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'From', + }, + expected: snapshot, + }); + const fromIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'From')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'empty', + name: 'To', + }, + expected: snapshot, + }); + const toIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'To')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-transition', layerIndex: 0, stateMachinePath: [], fromStateIndex: fromIndex, toStateIndex: toIndex }, + expected: snapshot, + }); + const transitionIndex = snapshot.graph.layers[0].stateMachine.transitions.find((transition) => ( + transition.fromStateIndex === fromIndex && transition.toStateIndex === toIndex + ))!.index; + const transitionTarget = { kind: 'transition' as const, layerIndex: 0, stateMachinePath: [], transitionIndex }; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-transition-condition', target: transitionTarget, conditionType: 'binary' }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-transition-condition-property', + target: transitionTarget, + conditionIndex: 0, + path: 'lhsBinding.type', + value: 3, + }, + expected: snapshot, + }); + expect(snapshot.graph.layers[0].stateMachine.transitions[transitionIndex].conditions[0]).toMatchObject({ isRhsInteger: true }); + + const poseStateIndex = nestedPoseState.index; + const nestedPoseGraph = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')! + .poseGraph.nodes.find((node) => node.id === stateMachineNode.id)!.stateMachine!.states[poseStateIndex].poseGraph!; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-pose-node', + poseGraph: nestedPoseGraph.context, + nodeType: 'cc.animation.PoseNodeApplyTransform', + }, + expected: snapshot, + }); + const applyTransformNode = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')! + .poseGraph.nodes.find((node) => node.id === stateMachineNode.id)!.stateMachine!.states[poseStateIndex].poseGraph! + .nodes.find((node) => node.type.includes('PoseNodeApplyTransform'))!; + const poseNodeTarget = { kind: 'pose-node' as const, poseGraph: nestedPoseGraph.context, nodeId: applyTransformNode.id }; + let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, poseNodeTarget); + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: poseNodeTarget, + path: 'positionOperation', + patch: 1, + expected: inspector, + }); + const positionInput = applyTransformNode.inputs.find((input) => input.id.includes('position'))!; + const positionTarget = { ...poseNodeTarget, kind: 'pose-input' as const, inputId: positionInput.id }; + const positionInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, positionTarget); + const updatedPosition = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: positionTarget, + path: 'value', + patch: { x: 3, y: 0, z: -4 }, + expected: positionInspector, + }); + expect(updatedPosition.dump.value).toEqual({ x: 3, y: 0, z: -4 }); + + let rotationNodeInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, poseNodeTarget); + rotationNodeInspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: poseNodeTarget, + path: 'rotationOperation', + patch: 1, + expected: rotationNodeInspector, + }); + const rotationInput = applyTransformNode.inputs.find((input) => input.id.includes('rotation'))!; + const rotationTarget = { ...poseNodeTarget, kind: 'pose-input' as const, inputId: rotationInput.id }; + const rotationInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, rotationTarget); + const updatedRotation = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: rotationTarget, + path: 'value', + patch: { x: 0, y: 0.25, z: 0, w: 0.75 }, + expected: rotationInspector, + }); + expect(updatedRotation.dump.value).toEqual({ x: 0, y: 0.25, z: 0, w: 0.75 }); + + await assetManager.saveAnimationGraph(asset.uuid, updatedRotation); + }); + + it('returns Creator state, blend and transition values and persists editor extras', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-creator-values.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'speed', variableType: 0, initialValue: 1 }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'direction', variableType: 0, initialValue: 0 }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Blend', + editorData: { centerX: 120, centerY: 48, collapsed: true }, + }, + expected: snapshot, + }); + const stateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Blend')!.index; + const stateTarget = { kind: 'state' as const, layerIndex: 0, stateMachinePath: [], stateIndex }; + let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, stateTarget); + for (const [path, patch] of [ + ['speed', 1.75], + ['speedMultiplier', 'speed'], + ['speedMultiplierEnabled', true], + ] as const) { + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: stateTarget, + path, + patch, + expected: inspector, + }); + } + snapshot = await assetManager.queryAnimationGraph(asset.uuid); + expect(snapshot.graph.layers[0].stateMachine.states[stateIndex]).toMatchObject({ + speed: 1.75, + speedMultiplier: 'speed', + speedMultiplierEnabled: true, + editorData: { centerX: 120, centerY: 48, collapsed: true }, + }); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'blend-2d', + }, + expected: snapshot, + }); + const motionTarget = snapshot.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, motionTarget); + for (const [path, patch] of [ + ['variableX', 'speed'], + ['valueX', 0.25], + ['variableY', 'direction'], + ['valueY', -0.5], + ] as const) { + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: motionTarget, + path, + patch, + expected: inspector, + }); + } + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion-editor-data', + target: motionTarget, + editorData: { centerX: 16, centerY: 32, autoThreshold: false }, + }, + expected: inspector, + }); + expect(snapshot.graph.layers[0].stateMachine.states[stateIndex].motion).toMatchObject({ + type: 'blend-2d', + variableX: 'speed', + valueX: 0.25, + variableY: 'direction', + valueY: -0.5, + editorData: { centerX: 16, centerY: 32, autoThreshold: false }, + }); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'empty', + name: 'Destination', + }, + expected: snapshot, + }); + const destinationIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Destination')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-transition', + layerIndex: 0, + stateMachinePath: [], + fromStateIndex: stateIndex, + toStateIndex: destinationIndex, + }, + expected: snapshot, + }); + const transitionIndex = snapshot.graph.layers[0].stateMachine.transitions.find((transition) => ( + transition.fromStateIndex === stateIndex && transition.toStateIndex === destinationIndex + ))!.index; + const transitionTarget = { + kind: 'transition' as const, + layerIndex: 0, + stateMachinePath: [], + transitionIndex, + }; + inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, transitionTarget); + for (const [path, patch] of [ + ['duration', 0.45], + ['relativeDuration', true], + ['exitConditionEnabled', false], + ['exitCondition', 0.8], + ['destinationStart', 0.2], + ['relativeDestinationStart', true], + ] as const) { + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: transitionTarget, + path, + patch, + expected: inspector, + }); + } + snapshot = await assetManager.queryAnimationGraph(asset.uuid); + expect(snapshot.graph.layers[0].stateMachine.transitions[transitionIndex]).toMatchObject({ + duration: 0.45, + relativeDuration: true, + exitConditionEnabled: false, + exitCondition: 0.8, + destinationStart: 0.2, + relativeDestinationStart: true, + }); + expect(snapshot.graph.layers[0].stateMachine.transitions[transitionIndex]).not.toHaveProperty('interruptible'); + + const saved = await assetManager.saveAnimationGraph(asset.uuid, snapshot); + const reloaded = await assetManager.reloadAnimationGraph(asset.uuid, { expected: saved }); + expect(reloaded.graph.layers[0].stateMachine.states[stateIndex]).toMatchObject({ + speed: 1.75, + speedMultiplier: 'speed', + speedMultiplierEnabled: true, + editorData: { centerX: 120, centerY: 48, collapsed: true }, + motion: expect.objectContaining({ + variableX: 'speed', + valueX: 0.25, + variableY: 'direction', + valueY: -0.5, + editorData: { centerX: 16, centerY: 32, autoThreshold: false }, + }), + }); + expect(reloaded.graph.layers[0].stateMachine.transitions[transitionIndex]).toMatchObject({ + duration: 0.45, + exitCondition: 0.8, + destinationStart: 0.2, + }); + }); + + it('keeps serialized Creator editor extras through load, mutation, save and reload', async () => { + const content = JSON.parse(getDefaultGraphContent()); + content[2].__editorExtras__ = { centerX: 8, centerY: 12, name: 'Root Machine' }; + content[3].__editorExtras__ = { centerX: -40, centerY: 0, name: 'Entry' }; + const stateConstructor = require('cc').js.getClassByName('cc.animation.State'); + const stateValues = stateConstructor.__values__; + const stateProps = stateConstructor.__props__; + const stateDeserializer = Object.getOwnPropertyDescriptor(stateConstructor, '__deserialize__'); + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-editor-extras.animgraph`), + content: JSON.stringify(content, null, 2), + overwrite: true, + }); + + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + expect(snapshot.graph.layers[0].stateMachine.editorData).toEqual({ centerX: 8, centerY: 12, name: 'Root Machine' }); + expect(snapshot.graph.layers[0].stateMachine.states[0].editorData).toEqual({ centerX: -40, centerY: 0, name: 'Entry' }); + expect(stateConstructor.__values__).toBe(stateValues); + expect(stateConstructor.__props__).toBe(stateProps); + expect(Object.getOwnPropertyDescriptor(stateConstructor, '__deserialize__')).toEqual(stateDeserializer); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-state-editor-data', + layerIndex: 0, + stateMachinePath: [], + stateIndex: 0, + editorData: { centerX: -24 }, + }, + expected: snapshot, + }); + expect(snapshot.graph.layers[0].stateMachine.states[0].editorData).toEqual({ + centerX: -24, + centerY: 0, + name: 'Entry', + }); + const saved = await assetManager.saveAnimationGraph(asset.uuid, snapshot); + const reloaded = await assetManager.reloadAnimationGraph(asset.uuid, { expected: saved }); + expect(reloaded.graph.layers[0].stateMachine.editorData).toEqual({ centerX: 8, centerY: 12, name: 'Root Machine' }); + expect(reloaded.graph.layers[0].stateMachine.states[0].editorData).toEqual({ + centerX: -24, + centerY: 0, + name: 'Entry', + }); + expect(stateConstructor.__values__).toBe(stateValues); + expect(stateConstructor.__props__).toBe(stateProps); + expect(Object.getOwnPropertyDescriptor(stateConstructor, '__deserialize__')).toEqual(stateDeserializer); + }); + + it('stashes a Pose Graph with links, editor data, conflicts and Creator auto naming', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-stash-pose-graph.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'procedural-pose', + name: 'Procedural', + }, + expected: snapshot, + }); + const stateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Procedural')!.index; + let poseGraph = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-pose-node', + poseGraph: poseGraph.context, + nodeType: 'cc.animation.PoseNodeBlendTwoPose', + editorData: { centerX: -80, centerY: 24 }, + }, + expected: snapshot, + }); + poseGraph = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-pose-node', + poseGraph: poseGraph.context, + nodeType: 'cc.animation.PoseNodeApplyTransform', + editorData: { centerX: 40, centerY: 24 }, + }, + expected: snapshot, + }); + poseGraph = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; + const blendNode = poseGraph.nodes.find((node) => node.type.includes('PoseNodeBlendTwoPose'))!; + const transformNode = poseGraph.nodes.find((node) => node.type.includes('PoseNodeApplyTransform'))!; + const transformPoseInput = transformNode.inputs.find((input) => input.type === blendNode.outputTypes[0])!; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'connect-pose-nodes', + poseGraph: poseGraph.context, + producerNodeId: blendNode.id, + producerOutputId: 0, + consumerNodeId: transformNode.id, + consumerInputId: transformPoseInput.id, + }, + expected: snapshot, + }); + poseGraph = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; + const connectedInput = poseGraph.nodes.find((node) => node.id === transformNode.id)!.inputs.find((input) => input.id === transformPoseInput.id)!; + expect(connectedInput.connected).toBe(true); + expect(connectedInput).not.toHaveProperty('value'); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: poseGraph.context, + layerIndex: 0, + stashName: 'Locomotion', + editorData: { centerX: 160, centerY: 24 }, + }, + expected: snapshot, + }); + const stashed = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Locomotion')!.poseGraph; + expect(stashed.nodes.find((node) => node.type.includes('PoseNodeBlendTwoPose'))?.editorData).toEqual({ centerX: -80, centerY: 24 }); + expect(stashed.nodes.find((node) => node.type.includes('PoseNodeApplyTransform'))?.editorData).toEqual({ centerX: 40, centerY: 24 }); + expect(stashed.nodes.some((node) => node.inputs.some((input) => input.connected))).toBe(true); + const original = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; + const useStashNode = original.nodes.find((node) => node.type.includes('PoseNodeUseStashedPose'))!; + expect(useStashNode.editorData).toEqual({ centerX: 160, centerY: 24 }); + expect(original.nodes).toHaveLength(2); + const document = (animationGraph as unknown as { + _documents: Map }>; + })._documents.get(asset.uuid)!; + expect(document.nodesById.has(blendNode.id)).toBe(false); + expect(document.nodesById.has(transformNode.id)).toBe(false); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: original.context, + layerIndex: 0, + stashName: 'Locomotion', + }, + expected: snapshot, + })).rejects.toMatchObject({ code: 'NAME_CONFLICT' }); + expect((await assetManager.queryAnimationGraph(asset.uuid)).revision).toBe(snapshot.revision); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'stash-pose-graph', poseGraph: original.context, layerIndex: 0 }, + expected: snapshot, + }); + expect(snapshot.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Locomotion', 'Stash1'])); + const saved = await assetManager.saveAnimationGraph(asset.uuid, snapshot); + const reloaded = await assetManager.reloadAnimationGraph(asset.uuid, { expected: saved }); + expect(reloaded.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Locomotion', 'Stash1'])); + expect(reloaded.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Locomotion')!.poseGraph.nodes) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ editorData: { centerX: -80, centerY: 24 } }), + expect.objectContaining({ editorData: { centerX: 40, centerY: 24 } }), + ])); + }); + + it('restores temporary editor extras class state when registration fails', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-editor-extras-registration-error.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'procedural-pose', + name: 'Registration Error', + }, + expected: snapshot, + }); + const poseGraph = snapshot.graph.layers[0].stateMachine.states + .find((state) => state.name === 'Registration Error')!.poseGraph!; + const outputConstructor = require('cc').js.getClassByName('cc.animation.PoseGraphOutputNode'); + const propsDescriptor = Object.getOwnPropertyDescriptor(outputConstructor, '__props__'); + const valuesDescriptor = Object.getOwnPropertyDescriptor(outputConstructor, '__values__')!; + const deserializeDescriptor = Object.getOwnPropertyDescriptor(outputConstructor, '__deserialize__'); + expect(valuesDescriptor.value).not.toContain('__editorExtras__'); + const readonlyValuesDescriptor = { ...valuesDescriptor, writable: false }; + Object.defineProperty(outputConstructor, '__values__', readonlyValuesDescriptor); + try { + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: poseGraph.context, + layerIndex: 0, + stashName: 'Should Fail', + }, + expected: snapshot, + })).rejects.toThrow(); + expect(Object.getOwnPropertyDescriptor(outputConstructor, '__props__')).toEqual(propsDescriptor); + expect(Object.getOwnPropertyDescriptor(outputConstructor, '__values__')).toEqual(readonlyValuesDescriptor); + expect(Object.getOwnPropertyDescriptor(outputConstructor, '__deserialize__')).toEqual(deserializeDescriptor); + expect((await assetManager.queryAnimationGraph(asset.uuid)).revision).toBe(snapshot.revision); + } finally { + if (deserializeDescriptor) { + Object.defineProperty(outputConstructor, '__deserialize__', deserializeDescriptor); + } else { + delete outputConstructor.__deserialize__; + } + Object.defineProperty(outputConstructor, '__values__', valuesDescriptor); + if (propsDescriptor) { + Object.defineProperty(outputConstructor, '__props__', propsDescriptor); + } else { + delete outputConstructor.__props__; + } + } + }); + + it('sets and clears Animation Graph asset references through inspector dumps', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-references.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const mask = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}.animask`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-mask/default.animask', + ), 'utf8'), + overwrite: true, + }); + const clip = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}.anim`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-clip/default.anim', + ), 'utf8'), + overwrite: true, + }); + + let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, { kind: 'layer', layerIndex: 0 }); + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: { kind: 'layer', layerIndex: 0 }, + path: 'mask', + patch: { uuid: mask.uuid }, + expected: inspector, + }); + expect((inspector.dump.value as Record).mask.value).toEqual({ uuid: mask.uuid }); + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: { kind: 'layer', layerIndex: 0 }, + path: 'mask', + patch: { uuid: '' }, + expected: inspector, + }); + expect((inspector.dump.value as Record).mask.value).toEqual({ uuid: '' }); + + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Clip', + }, + expected: snapshot, + }); + const stateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Clip')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'clip', + clipUuid: clip.uuid, + }, + expected: snapshot, + }); + const motionTarget = snapshot.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + let motionInspector = await assetManager.queryAnimationGraphInspector(asset.uuid, motionTarget); + expect((motionInspector.dump.value as Record).clip.value).toEqual({ uuid: clip.uuid }); + motionInspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: motionTarget, + path: 'clip', + patch: { uuid: '' }, + expected: motionInspector, + }); + expect((motionInspector.dump.value as Record).clip.value).toEqual({ uuid: '' }); + + await assetManager.saveAnimationGraph(asset.uuid, motionInspector); + }); + + it('blocks generic overwrite and directory mutations while a graph document is dirty', async () => { + const directoryName = `${name}-dirty-directory`; + const directoryPath = join(TestGlobalEnv.testRoot, directoryName); + const targetPath = join(directoryPath, 'target.animgraph'); + const sourcePath = join(TestGlobalEnv.testRoot, `${name}-source.animgraph`); + const renameSourcePath = join(directoryPath, 'rename-source.animgraph'); + const source = await assetManager.createAsset({ target: sourcePath, content: getDefaultGraphContent(), overwrite: true }); + const renameSource = await assetManager.createAsset({ target: renameSourcePath, content: getDefaultGraphContent(), overwrite: true }); + const target = await assetManager.createAsset({ target: targetPath, content: getDefaultGraphContent(), overwrite: true }); + const initial = await assetManager.queryAnimationGraph(target.uuid); + const dirty = await assetManager.setAnimationGraphInspectorProperty(target.uuid, { + target: { kind: 'layer', layerIndex: 0 }, + path: 'weight', + patch: 0.25, + expected: initial, + }); + + await expect(assetManager.createAsset({ target: targetPath, content: getDefaultGraphContent(), overwrite: true })) + .rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.importAsset(sourcePath, targetPath, { overwrite: true })) + .rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.copyAsset(source.uuid, targetPath, { overwrite: true })) + .rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.moveAsset(sourcePath, targetPath, { overwrite: true })) + .rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.renameAsset(renameSource.uuid, 'target.animgraph', { overwrite: true })) + .rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.refreshAsset(directoryPath)).rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.moveAsset(directoryPath, join(TestGlobalEnv.testRoot, `${directoryName}-moved`), { overwrite: true })) + .rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + await expect(assetManager.removeAsset(directoryPath, { useTrash: false })).rejects.toMatchObject({ code: 'DIRTY_DOCUMENT' }); + + await assetManager.saveAnimationGraph(target.uuid, dirty); + }); +}); diff --git a/src/core/assets/test/delete-asset-options.test.ts b/src/core/assets/test/delete-asset-options.test.ts index 8ddd90424..d5b01d0a8 100644 --- a/src/core/assets/test/delete-asset-options.test.ts +++ b/src/core/assets/test/delete-asset-options.test.ts @@ -16,6 +16,8 @@ jest.mock('../utils', () => ({ url2path: jest.fn((value) => value), ensureOutputData: jest.fn(), url2uuid: jest.fn((value) => value), + pathToDbUrlIfAssetDBPath: jest.fn((value) => value), + dirnameForDbUrlOrPath: jest.fn((value: string) => value.replace(/[\\/][^\\/]*$/, '')), })); jest.mock('../manager/filesystem', () => ({ diff --git a/src/core/assets/test/operation-filesystem-bridge.test.ts b/src/core/assets/test/operation-filesystem-bridge.test.ts index 0eef5cfd6..bcf46a6a1 100644 --- a/src/core/assets/test/operation-filesystem-bridge.test.ts +++ b/src/core/assets/test/operation-filesystem-bridge.test.ts @@ -204,6 +204,8 @@ describe('asset operation filesystem bridge', () => { }); afterEach(() => { + const assetQuery = require('../manager/query').default as typeof import('../manager/query').default; + delete (assetQuery as any).queryAssets; jest.restoreAllMocks(); }); @@ -279,10 +281,41 @@ describe('asset operation filesystem bridge', () => { expect(mockReimport).toHaveBeenCalledTimes(1); expect(mockReimport).toHaveBeenCalledWith(requestPath); - expect(mockQueryAsset).not.toHaveBeenCalled(); + // The Animation Graph dirty-write guard performs one preflight lookup. A + // second lookup would indicate that reimport entered its busy retry path. + expect(mockQueryAsset).toHaveBeenCalledTimes(1); + expect(mockQueryAsset).toHaveBeenCalledWith(requestPath); expect(result).toEqual({ source: asset.source }); }); + it('scopes Animation Graph preflight queries to the requested database root', () => { + const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); + const assetQuery = require('../manager/query').default as typeof import('../manager/query').default; + const assetsGraph = { + uuid: 'assets-graph', + source: 'D:/project/assets/graph.animgraph', + url: 'db://assets/graph.animgraph', + meta: { importer: 'animation-graph' }, + }; + const internalGraph = { + uuid: 'internal-graph', + source: 'D:/project/internal/graph.animgraph', + url: 'db://internal/graph.animgraph', + meta: { importer: 'animation-graph' }, + }; + mockQueryAsset.mockReturnValue({ + uuid: 'db://assets', + source: 'db://assets', + meta: { importer: 'database', name: 'assets' }, + }); + (assetQuery as any).queryAssets = jest.fn(() => [assetsGraph, internalGraph]); + + const result = (assetOperation as any)._queryAnimationGraphAssetsAt('db://assets'); + + expect(result).toEqual([assetsGraph]); + expect((assetQuery as any).queryAssets).toHaveBeenCalledTimes(1); + }); + it('reimportAsset serializes the asset tree metadata contract', async () => { const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); const assetQuery = require('../manager/query').default as typeof import('../manager/query').default; diff --git a/src/core/assets/test/serialized-data.test.ts b/src/core/assets/test/serialized-data.test.ts index db6640347..763e523ec 100644 --- a/src/core/assets/test/serialized-data.test.ts +++ b/src/core/assets/test/serialized-data.test.ts @@ -31,6 +31,11 @@ import { globalSetup } from '../../test/global-setup'; import { TestGlobalEnv } from '../../../tests/global-env'; import { assetManager } from '..'; import type { IProperty } from '../../scene/@types/public'; +import { + applyPropertyObjectOperation, + encodePropertyObject, + queryPropertyObjectOperationCapabilities, +} from '../serialized-data'; type DumpMap = Record; @@ -141,6 +146,79 @@ describe('serialized asset data', function () { })).rejects.toThrow(/readonly|hidden/i); }); + it('applies Creator-compatible property reset/create semantics from current attributes', () => { + const engine = (globalThis as any).cc; + let cloneCalls = 0; + + class CloneableValue { + constructor(public value = 0) {} + + clone(): CloneableValue { + cloneCalls += 1; + return new CloneableValue(this.value); + } + } + + class OptionalValue { + enabled = true; + } + + class PropertyTarget { + count = 9; + cloneable = new CloneableValue(99); + items = [9, 8]; + optional: OptionalValue | null = null; + unsupported = 4; + hidden = 2; + } + (PropertyTarget as any).__props__ = ['count', 'cloneable', 'items', 'optional', 'unsupported', 'hidden']; + + const sharedCloneableDefault = new CloneableValue(7); + const attributes: Record = { + count: { type: 'Number', ctor: Number, default: () => 3 }, + cloneable: { ctor: CloneableValue, default: () => sharedCloneableDefault }, + items: { ctor: Number, default: () => [1, 2, 3] }, + optional: { type: 'Object', ctor: OptionalValue, default: null }, + unsupported: {}, + hidden: { type: 'Number', ctor: Number, default: 1, visible: false }, + }; + const attrSpy = jest.spyOn(engine.Class, 'attr').mockImplementation((...args: unknown[]) => attributes[String(args[1])]); + + try { + const target = new PropertyTarget(); + const dump = encodePropertyObject(target); + const capabilities = queryPropertyObjectOperationCapabilities(target, dump); + expect(capabilities).toMatchObject({ + count: { set: true, reset: true, create: true }, + cloneable: { set: true, reset: true, create: true }, + items: { set: true, reset: true, create: true }, + optional: { set: true, reset: true, create: true }, + unsupported: { set: true, reset: false, create: false }, + hidden: { set: false, reset: false, create: false }, + }); + + applyPropertyObjectOperation(target, 'count', 'reset'); + expect(target.count).toBe(3); + + applyPropertyObjectOperation(target, 'cloneable', 'reset'); + expect(target.cloneable).toEqual(new CloneableValue(7)); + expect(target.cloneable).not.toBe(sharedCloneableDefault); + expect(cloneCalls).toBe(1); + + applyPropertyObjectOperation(target, 'items', 'reset'); + expect(target.items).toEqual([]); + + applyPropertyObjectOperation(target, 'optional', 'create'); + expect(target.optional).toBeInstanceOf(OptionalValue); + expect(target.optional?.enabled).toBe(true); + + expect(() => applyPropertyObjectOperation(target, 'unsupported', 'reset')).toThrow(/does not support reset/i); + expect(() => applyPropertyObjectOperation(target, 'hidden', 'reset')).toThrow(/readonly|hidden/i); + } finally { + attrSpy.mockRestore(); + } + }); + it('queries RenderPipeline as a top-level IProperty with optionalTypes', async () => { const fixture = join( TestGlobalEnv.engineRoot, diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index 2c313d5a3..60ffa5cb7 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -1,4 +1,4 @@ -import type { AnimationMaskChange, AnimationMaskDump, AssetOperationOption, AssetPropertySchemaMap, CreateAssetByTypeOptions, DeleteAssetOptions, IAssetFileSystemProvider, IAssetInfo, IAssetMeta, ISupportCreateType, MaterialDump, MaterialEffectInfo, MaterialTechniqueDump, QueryAssetsOption, SerializedAssetPatch, SerializedAssetQueryResult } from '../../core/assets/@types/public'; +import type { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphSnapshot, AnimationGraphTarget, AssetOperationOption, AssetPropertySchemaMap, CreateAssetByTypeOptions, DeleteAssetOptions, ExecuteAnimationGraphCommandRequest, IAssetFileSystemProvider, IAssetInfo, IAssetMeta, ISupportCreateType, MaterialDump, MaterialEffectInfo, MaterialTechniqueDump, QueryAssetsOption, ReloadAnimationGraphOptions, SerializedAssetPatch, SerializedAssetQueryResult, SetAnimationGraphInspectorPropertyRequest, AnimationMaskChange, AnimationMaskDump } from '../../core/assets/@types/public'; import type { CreateAssetOptions, IAssetConfig, IAssetDBInfo, ICreateMenuInfo, IUerDataConfigItem, QueryAssetType, ThumbnailInfo, ThumbnailSize } from '../../core/assets/@types/protected'; import type { FilterPluginOptions, IPluginScriptInfo } from '../../core/scripting/interface'; import { assetDBManager, assetManager } from '../../core/assets'; @@ -210,6 +210,67 @@ export const animationGraphVariant = { }, }; +export const animationGraph = { + query(uuidOrUrlOrPath: string): Promise { + return assetManager.queryAnimationGraph(uuidOrUrlOrPath); + }, + + queryInspector( + uuidOrUrlOrPath: string, + target: AnimationGraphTarget, + ): Promise { + return assetManager.queryAnimationGraphInspector(uuidOrUrlOrPath, target); + }, + + setInspectorProperty( + uuidOrUrlOrPath: string, + request: SetAnimationGraphInspectorPropertyRequest, + ): Promise { + return assetManager.setAnimationGraphInspectorProperty(uuidOrUrlOrPath, request); + }, + + resetInspectorProperty( + uuidOrUrlOrPath: string, + request: AnimationGraphInspectorPropertyOperationRequest, + ): Promise { + return assetManager.resetAnimationGraphInspectorProperty(uuidOrUrlOrPath, request); + }, + + createInspectorProperty( + uuidOrUrlOrPath: string, + request: AnimationGraphInspectorPropertyOperationRequest, + ): Promise { + return assetManager.createAnimationGraphInspectorProperty(uuidOrUrlOrPath, request); + }, + + execute( + uuidOrUrlOrPath: string, + request: ExecuteAnimationGraphCommandRequest, + ): Promise { + return assetManager.executeAnimationGraphCommand(uuidOrUrlOrPath, request); + }, + + save( + uuidOrUrlOrPath: string, + expected: AnimationGraphExpectedVersion, + sourceId?: string, + ): Promise { + return assetManager.saveAnimationGraph(uuidOrUrlOrPath, expected, sourceId); + }, + + reload( + uuidOrUrlOrPath: string, + options?: ReloadAnimationGraphOptions, + sourceId?: string, + ): Promise { + return assetManager.reloadAnimationGraph(uuidOrUrlOrPath, options, sourceId); + }, + + onChanged(listener: (event: AnimationGraphChangedEvent) => void): () => void { + return assetManager.onAnimationGraphChanged(listener); + }, +}; + export const animationMask = { async query(uuid: string): Promise { const { queryAnimationMask } = await import('../../core/assets/animation-mask'); diff --git a/tests/lib/assets-api.test.ts b/tests/lib/assets-api.test.ts index 497755ef9..6ad7c09a0 100644 --- a/tests/lib/assets-api.test.ts +++ b/tests/lib/assets-api.test.ts @@ -9,6 +9,15 @@ const mockAssetManager = { queryMaterialEffect: jest.fn(), queryMaterial: jest.fn(), saveMaterial: jest.fn(), + queryAnimationGraph: jest.fn(), + queryAnimationGraphInspector: jest.fn(), + setAnimationGraphInspectorProperty: jest.fn(), + resetAnimationGraphInspectorProperty: jest.fn(), + createAnimationGraphInspectorProperty: jest.fn(), + executeAnimationGraphCommand: jest.fn(), + saveAnimationGraph: jest.fn(), + reloadAnimationGraph: jest.fn(), + onAnimationGraphChanged: jest.fn(), }; jest.mock('../../src/core/assets', () => ({ @@ -145,6 +154,60 @@ describe('lib assets api', () => { expect(mockAssetManager.saveMaterial).toHaveBeenCalledWith('material-uuid', materialDump); }); + it('exposes animationGraph namespace and delegates document operations to assetManager', async () => { + const snapshot = { + uuid: 'graph-uuid', + url: 'db://assets/test.animgraph', + documentId: 'document-id', + revision: 0, + persistedRevision: 0, + dirty: false, + externallyModified: false, + graph: { layers: [], variables: [] }, + }; + const target = { kind: 'layer' as const, layerIndex: 0 }; + const inspector = { ...snapshot, target, dump: { path: '', value: {} } }; + const request = { + target, + path: 'weight', + patch: { value: 0.5 }, + expected: { documentId: snapshot.documentId, revision: snapshot.revision }, + }; + const commandRequest = { + command: { type: 'add-layer' as const, name: 'Base' }, + expected: request.expected, + }; + const removeListener = jest.fn(); + mockAssetManager.queryAnimationGraph.mockResolvedValue(snapshot); + mockAssetManager.queryAnimationGraphInspector.mockResolvedValue(inspector); + mockAssetManager.setAnimationGraphInspectorProperty.mockResolvedValue(inspector); + mockAssetManager.resetAnimationGraphInspectorProperty.mockResolvedValue(inspector); + mockAssetManager.createAnimationGraphInspectorProperty.mockResolvedValue(inspector); + mockAssetManager.executeAnimationGraphCommand.mockResolvedValue(snapshot); + mockAssetManager.saveAnimationGraph.mockResolvedValue(snapshot); + mockAssetManager.reloadAnimationGraph.mockResolvedValue(snapshot); + mockAssetManager.onAnimationGraphChanged.mockReturnValue(removeListener); + + await expect(Assets.animationGraph.query('graph-uuid')).resolves.toBe(snapshot); + await expect(Assets.animationGraph.queryInspector('graph-uuid', target)).resolves.toBe(inspector); + await expect(Assets.animationGraph.setInspectorProperty('graph-uuid', request)).resolves.toBe(inspector); + await expect(Assets.animationGraph.resetInspectorProperty('graph-uuid', request)).resolves.toBe(inspector); + await expect(Assets.animationGraph.createInspectorProperty('graph-uuid', request)).resolves.toBe(inspector); + await expect(Assets.animationGraph.execute('graph-uuid', commandRequest)).resolves.toBe(snapshot); + await expect(Assets.animationGraph.save('graph-uuid', request.expected, 'inspector')).resolves.toBe(snapshot); + await expect(Assets.animationGraph.reload('graph-uuid', { expected: request.expected }, 'inspector')).resolves.toBe(snapshot); + expect(Assets.animationGraph.onChanged(jest.fn())).toBe(removeListener); + + expect(mockAssetManager.queryAnimationGraph).toHaveBeenCalledWith('graph-uuid'); + expect(mockAssetManager.queryAnimationGraphInspector).toHaveBeenCalledWith('graph-uuid', target); + expect(mockAssetManager.setAnimationGraphInspectorProperty).toHaveBeenCalledWith('graph-uuid', request); + expect(mockAssetManager.resetAnimationGraphInspectorProperty).toHaveBeenCalledWith('graph-uuid', request); + expect(mockAssetManager.createAnimationGraphInspectorProperty).toHaveBeenCalledWith('graph-uuid', request); + expect(mockAssetManager.executeAnimationGraphCommand).toHaveBeenCalledWith('graph-uuid', commandRequest); + expect(mockAssetManager.saveAnimationGraph).toHaveBeenCalledWith('graph-uuid', request.expected, 'inspector'); + expect(mockAssetManager.reloadAnimationGraph).toHaveBeenCalledWith('graph-uuid', { expected: request.expected }, 'inspector'); + }); + it('exposes queryPropertySchema and delegates to assetManager', async () => { const schema = { type: {