diff --git a/e2e/mcp/api/component.e2e.test.ts b/e2e/mcp/api/component.e2e.test.ts index 4b62af0fb..8c96ff995 100644 --- a/e2e/mcp/api/component.e2e.test.ts +++ b/e2e/mcp/api/component.e2e.test.ts @@ -342,4 +342,55 @@ describe('MCP Component API', () => { expect(eraseResult.data).toEqual(insertResult.data); }); }); + + describe('PolygonCollider2D 专用操作', () => { + it('should regenerate rectangle fallback points through MCP', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.PolygonCollider2D', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const regenerateResult = await mcpClient.callTool('scene-regenerate-polygon-2d-points', { + options: { + path: addResult.data.path, + record: false, + }, + }); + + expect(regenerateResult.code).toBe(200); + expect(regenerateResult.data).toEqual({ + path: addResult.data.path, + changed: false, + pointCount: 4, + source: 'rect-fallback', + }); + }); + + it('should reject a non-PolygonCollider2D component', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.Label', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const regenerateResult = await mcpClient.callTool('scene-regenerate-polygon-2d-points', { + options: { + path: addResult.data.path, + record: false, + }, + }); + + expect(regenerateResult.code).toBe(400); + expect(regenerateResult.reason).toContain('component is not cc.PolygonCollider2D'); + }); + }); }); 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 a480462e5..1eec59ea4 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 @@ -7,6 +7,9 @@ exports[`DTS API compatibility assets.d.ts should match snapshot 1`] = ` export declare const animationGraph: { query(uuidOrUrlOrPath: string): Promise; queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + queryMotionPreviewData(uuidOrUrlOrPath: string, target: Extract): Promise; queryPoseGraphAssetDragHandlers(): Promise; queryStateMachineComponentTypes(): Promise; setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; @@ -115,6 +118,10 @@ export declare interface AnimationGraphLayerView { export declare type AnimationGraphMotionAddress = | (AnimationGraphStateAddress & { level: number[] }) | ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare interface AnimationGraphMotionPreviewData { + motion: AnimationGraphMotionView | null; + variables: AnimationGraphVariableView[]; +} export declare type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; export declare interface AnimationGraphMotionView { level: number[]; @@ -128,6 +135,7 @@ export declare interface AnimationGraphMotionView { valueX?: number; variableY?: string; valueY?: number; + algorithm?: number; threshold?: number | { x: number; y: number }; weight?: { value: number; variable: string }; children?: AnimationGraphMotionView[]; @@ -6538,6 +6546,7 @@ export declare interface IComponentService extends IServiceEvents { add(params: IAddComponentOptions): Promise; remove(params: IRemoveComponentOptions): Promise; setProperty(params: ISetPropertyOptions): Promise; + regeneratePolygon2DPoints(options: IRegeneratePolygon2DPointsOptions): Promise; query(params: IQueryComponentOptions | string): Promise; queryAll(): Promise; recalculateLODGroupBounds(options: IRecalculateLODGroupBoundsOptions): Promise; @@ -6727,6 +6736,42 @@ export declare interface ImageMeta { uri?: string; remap?: string; } +export declare interface IMotionPreviewService { + showMotion(desc: MotionPreviewDesc): Promise; + hideMotion(): Promise; + setMotionModel(uuid: string): Promise; + setMotionTime(time: number): Promise; + playMotion(): Promise; + pauseMotion(): Promise; + stopMotion(): Promise; + setMotionVariable(name: string, value: number): Promise; + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; + getMotionTimelineStats(): Promise<{ + timeLineLength: number; + } | null>; + isMotionActive(): Promise; + queryMotionImage(info: { + width: number; + height: number; + }): Promise; + onMotionMouseDown(action: { + x: number; + y: number; + button: number; + }): Promise; + onMotionMouseMove(action: { + movementX: number; + movementY: number; + }): Promise; + onMotionMouseUp(action: { + x: number; + y: number; + }): Promise; + onMotionMouseWheel(action: { + wheelDeltaY: number; + wheelDeltaX: number; + }): Promise; +} export declare interface IMoveArrayElementParams { nodePath: string; path: string; @@ -6865,7 +6910,7 @@ export declare interface IPreviewInstance { resetCameraView(): void; hide(): void; } -export declare interface IPreviewService { +export declare interface IPreviewService extends IMotionPreviewService { open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } @@ -7013,6 +7058,16 @@ export declare interface IReferenceImageState { export declare interface IReferenceImageVisibilityOptions { desiredVisible: boolean; } +export declare interface IRegeneratePolygon2DPointsOptions { + path: string; + record?: boolean; +} +export declare interface IRegeneratePolygon2DPointsResult { + path: string; + changed: boolean; + pointCount: number; + source: Polygon2DPointsSource; +} export declare interface IReloadOptions { urlOrUUID?: string; preserveUndoHistory?: boolean; @@ -7362,6 +7417,46 @@ export declare interface MeshSimplifyOptions { errorRate?: number; lockBoundary?: boolean; } +export declare interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} +export declare type MotionPreviewDescNode = { + kind: 'clip'; + clipUuid: string | null; +} | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: number; + }[]; +} | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: { + x: number; + y: number; + }; + }[]; +} | { + kind: 'blend-direct'; + children: { + motion: MotionPreviewDescNode | null; + weight: number; + }[]; +}; +export declare interface MotionPreviewVariable { + name: string; + value: number | null; +} export declare enum NodeEventType { TRANSFORM_CHANGED = "transform-changed", SIZE_CHANGED = "size-changed", @@ -7481,6 +7576,7 @@ export declare interface PluginScriptUserData { loadPluginInMiniGame?: boolean; loadPluginInNative?: boolean; } +export declare type Polygon2DPointsSource = 'sprite-alpha' | 'rect-fallback'; export declare interface PrefabAssetUserData { persistent?: boolean; syncNodeName?: string; @@ -7983,6 +8079,9 @@ export declare interface AnimationClipAssetUserData { export declare const animationGraph: { query(uuidOrUrlOrPath: string): Promise; queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + queryMotionPreviewData(uuidOrUrlOrPath: string, target: Extract): Promise; queryPoseGraphAssetDragHandlers(): Promise; queryStateMachineComponentTypes(): Promise; setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; @@ -8091,6 +8190,10 @@ export declare interface AnimationGraphLayerView { export declare type AnimationGraphMotionAddress = | (AnimationGraphStateAddress & { level: number[] }) | ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare interface AnimationGraphMotionPreviewData { + motion: AnimationGraphMotionView | null; + variables: AnimationGraphVariableView[]; +} export declare type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; export declare interface AnimationGraphMotionView { level: number[]; @@ -8104,6 +8207,7 @@ export declare interface AnimationGraphMotionView { valueX?: number; variableY?: string; valueY?: number; + algorithm?: number; threshold?: number | { x: number; y: number }; weight?: { value: number; variable: string }; children?: AnimationGraphMotionView[]; @@ -8502,6 +8606,7 @@ export declare namespace Assets { AnimationGraphStateMachineView, AnimationGraphLayerView, AnimationGraphVariableView, + AnimationGraphMotionPreviewData, AnimationGraphViewDump, AnimationGraphSnapshot, AnimationGraphInspectorPropertyCapabilities, @@ -8819,6 +8924,9 @@ export declare function getCurrentToolCallContext(): Readonly; export declare function getInfo_2(): Promise; export declare function getMetadata(): Promise; +export declare function getMotionTimelineStats(): Promise<{ + timeLineLength: number; +} | null>; export declare function getProgrammingFacet(): Promise; export declare function getRenderConfig(): Promise; export declare function getStatus(): { @@ -8903,6 +9011,7 @@ export declare interface GlTFUserData { options: LODsOption[]; }; } +export declare function hideMotion(): Promise; export declare namespace i18n { export { i18n_2 as default @@ -9367,6 +9476,7 @@ export declare interface ISceneCommandProvider { isConnect?(): boolean | undefined; dispose?(): void; } +export declare function isMotionActive(): Promise; export declare interface ISocketConfig { connection: (socket: any) => void; disconnect: (socket: any) => void; @@ -9585,6 +9695,46 @@ export declare interface ModuleRenderConfig { version: string; migrationScript?: string; } +export declare interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} +export declare type MotionPreviewDescNode = { + kind: 'clip'; + clipUuid: string | null; +} | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: number; + }[]; +} | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: { + x: number; + y: number; + }; + }[]; +} | { + kind: 'blend-direct'; + children: { + motion: MotionPreviewDescNode | null; + weight: number; + }[]; +}; +export declare interface MotionPreviewVariable { + name: string; + value: number | null; +} export declare function moveAsset(source: string, target: string, options?: AssetOperationOption): Promise; export declare enum NormalImportSetting { optional = 0, @@ -9604,6 +9754,23 @@ export declare function onCompileStart(listener: (e: { }) => void): () => void; export declare function onDBReady(listener: (dbInfo: IAssetDBInfo) => void): () => void; export declare function onDidSave(callback: () => void, scope?: ConfigurationScope): () => void; +export declare function onMotionMouseDown(action: { + x: number; + y: number; + button: number; +}): Promise; +export declare function onMotionMouseMove(action: { + movementX: number; + movementY: number; +}): Promise; +export declare function onMotionMouseUp(action: { + x: number; + y: number; +}): Promise; +export declare function onMotionMouseWheel(action: { + wheelDeltaY: number; + wheelDeltaX: number; +}): Promise; export declare function onPackBuildEnd(listener: (e: { targetName: string; }) => void): () => void; @@ -9655,6 +9822,8 @@ export declare interface ParticleAssetUserData { rotatePerSVar: number; spriteFrameUuid: string; } +export declare function pauseMotion(): Promise; +export declare function playMotion(): Promise; export declare interface PluginScriptInfo { file: string; uuid: string; @@ -9787,6 +9956,10 @@ export declare function queryLayerBuiltin(): Promise<{ export declare function queryMaterial(uuidOrUrlOrPath: string): Promise; export declare function queryMaterialAllEffects(): Promise>; export declare function queryMaterialEffect(effectNameOrUuid: string): Promise; +export declare function queryMotionImage(info: { + width: number; + height: number; +}): Promise; export declare function queryPath(urlOrUuid: string): Promise; export declare function queryPropertySchema(importer: string): Promise; export declare function querySerializedData(uuidOrUrlOrPath: string): Promise; @@ -9827,6 +10000,22 @@ export declare namespace Scene { startupWorker, setCommandProvider, resetCommandProvider, + showMotion, + hideMotion, + setMotionModel, + setMotionTime, + playMotion, + pauseMotion, + stopMotion, + setMotionVariable, + setMotionParameter, + getMotionTimelineStats, + isMotionActive, + queryMotionImage, + onMotionMouseDown, + onMotionMouseMove, + onMotionMouseUp, + onMotionMouseWheel, ISceneCommandProvider, SceneCommandProviderRegistration, SceneCommandRequestOptions, @@ -9893,6 +10082,10 @@ export declare interface SetAnimationGraphInspectorPropertyRequest extends Anima } export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; +export declare function setMotionModel(uuid: string): Promise; +export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; +export declare function setMotionTime(time: number): Promise; +export declare function setMotionVariable(name: string, value: number): Promise; export declare interface SharedSettings { useDefineForClassFields: boolean; allowDeclareFields: boolean; @@ -9908,6 +10101,7 @@ export declare interface SharedSettings { url: string; }; } +export declare function showMotion(desc: MotionPreviewDesc): Promise; export declare interface SimplifyOptions { targetRatio?: number; enableSmartLink?: boolean; @@ -9966,6 +10160,7 @@ export declare function startCompileScript(assetChanges?: AssetChangeInfo[]): Pr export declare function startEngineCompilation(force?: boolean): Promise; export declare function startupWorker(projectPath: string): Promise; export declare function stop_2(): Promise; +export declare function stopMotion(): Promise; export declare const SUPPORT_CREATE_TYPES: readonly ["animation-clip", "typescript", "auto-atlas", "effect", "scene", "prefab", "material", "texture-cube", "terrain", "physics-material", "label-atlas", "render-texture", "directory", "effect-header"]; export declare enum TangentImportSetting { exclude = 0, @@ -10122,6 +10317,10 @@ export { } exports[`DTS API compatibility scene.d.ts should match snapshot 1`] = ` "import type { ChildProcess } from 'child_process'; +export declare function getMotionTimelineStats(): Promise<{ + timeLineLength: number; +} | null>; +export declare function hideMotion(): Promise; export declare function init(): Promise; export declare interface ISceneCommandProvider { request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; @@ -10129,6 +10328,70 @@ export declare interface ISceneCommandProvider { isConnect?(): boolean | undefined; dispose?(): void; } +export declare function isMotionActive(): Promise; +export declare interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} +export declare type MotionPreviewDescNode = { + kind: 'clip'; + clipUuid: string | null; +} | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: number; + }[]; +} | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: { + x: number; + y: number; + }; + }[]; +} | { + kind: 'blend-direct'; + children: { + motion: MotionPreviewDescNode | null; + weight: number; + }[]; +}; +export declare interface MotionPreviewVariable { + name: string; + value: number | null; +} +export declare function onMotionMouseDown(action: { + x: number; + y: number; + button: number; +}): Promise; +export declare function onMotionMouseMove(action: { + movementX: number; + movementY: number; +}): Promise; +export declare function onMotionMouseUp(action: { + x: number; + y: number; +}): Promise; +export declare function onMotionMouseWheel(action: { + wheelDeltaY: number; + wheelDeltaX: number; +}): Promise; +export declare function pauseMotion(): Promise; +export declare function playMotion(): Promise; +export declare function queryMotionImage(info: { + width: number; + height: number; +}): Promise; export declare function resetCommandProvider(): void; export declare interface SceneCommandProviderRegistration { dispose(): void; @@ -10137,7 +10400,13 @@ export declare interface SceneCommandRequestOptions { timeout?: number; } export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; +export declare function setMotionModel(uuid: string): Promise; +export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; +export declare function setMotionTime(time: number): Promise; +export declare function setMotionVariable(name: string, value: number): Promise; +export declare function showMotion(desc: MotionPreviewDesc): Promise; export declare function startupWorker(projectPath: string): Promise; +export declare function stopMotion(): Promise; export declare class WorkerSceneCommandProvider implements ISceneCommandProvider { private readonly rpc; constructor(process: ChildProcess | NodeJS.Process); diff --git a/src/api/scene/component-schema.ts b/src/api/scene/component-schema.ts index f5fd1f87e..ef1843ecf 100644 --- a/src/api/scene/component-schema.ts +++ b/src/api/scene/component-schema.ts @@ -119,6 +119,23 @@ export const SchemaComponent: z.ZodType = SchemaComponentIdentif export const SchemaQueryAllComponentResult = z.array(z.string()).describe('Collection of all components, including built-in and custom components'); // 所有组件集合,包含内置与自定义组件 +// Regenerate PolygonCollider2D points // 重新生成 PolygonCollider2D 顶点 +export const SchemaRegeneratePolygon2DPointsOptions = z.object({ + path: z.string().min(1) + .describe('cc.PolygonCollider2D component path, UUID, or db:// URL'), + record: z.boolean().optional().describe('Whether to record undo, defaults to true'), +}).describe('Information required to regenerate cc.PolygonCollider2D points'); + +export const SchemaPolygon2DPointsSource = z.enum(['sprite-alpha', 'rect-fallback']) + .describe('Source used to generate the PolygonCollider2D points'); + +export const SchemaRegeneratePolygon2DPointsResult = z.object({ + path: z.string().min(1).describe('PolygonCollider2D component identifier supplied for the operation'), + changed: z.boolean().describe('Whether the generated points changed the component'), + pointCount: z.number().int().min(3).describe('Number of generated polygon points'), + source: SchemaPolygon2DPointsSource, +}).describe('Result of regenerating cc.PolygonCollider2D points'); + // Recalculate LODGroup bounds // 重新计算 LODGroup 包围盒 export const SchemaRecalculateLODGroupBoundsOptions = z.object({ path: z.string().min(1).describe('cc.LODGroup component path, e.g. "Root/LOD/cc.LODGroup"'), // cc.LODGroup 组件路径 @@ -170,6 +187,9 @@ export type TQueryComponentOptions = z.infer; export type TSetPropertyOptions = z.infer; export type TComponentResult = z.infer; export type TQueryAllComponentResult = z.infer; +export type TRegeneratePolygon2DPointsOptions = z.infer; +export type TPolygon2DPointsSource = z.infer; +export type TRegeneratePolygon2DPointsResult = z.infer; export type TRecalculateLODGroupBoundsOptions = z.infer; export type TLODGroupBoundsResult = z.infer; export type TInsertLODOptions = z.infer; diff --git a/src/api/scene/component.ts b/src/api/scene/component.ts index c1f5f4d4a..81c962e46 100644 --- a/src/api/scene/component.ts +++ b/src/api/scene/component.ts @@ -6,6 +6,8 @@ import { SchemaQueryAllComponentResult, SchemaQueryComponent, SchemaRemoveComponent, + SchemaRegeneratePolygon2DPointsOptions, + SchemaRegeneratePolygon2DPointsResult, SchemaRecalculateLODGroupBoundsOptions, SchemaLODGroupBoundsResult, SchemaInsertLODOptions, @@ -20,6 +22,8 @@ import { TQueryAllComponentResult, TRemoveComponentOptions, TQueryComponentOptions, + TRegeneratePolygon2DPointsOptions, + TRegeneratePolygon2DPointsResult, TRecalculateLODGroupBoundsOptions, TLODGroupBoundsResult, TInsertLODOptions, @@ -149,6 +153,30 @@ export class ComponentApi { } } + /** + * Regenerate PolygonCollider2D points // 重新生成 PolygonCollider2D 顶点 + */ + @tool('scene-regenerate-polygon-2d-points') + @title('Regenerate PolygonCollider2D points') + @description('Regenerate cc.PolygonCollider2D points from the alpha contour of a Sprite on the same node. Falls back to the UITransform rectangle when no usable Sprite source exists. This overwrites the current points and records undo by default.') + @result(SchemaRegeneratePolygon2DPointsResult) + async regeneratePolygon2DPoints( + @param(SchemaRegeneratePolygon2DPointsOptions) options: TRegeneratePolygon2DPointsOptions, + ): Promise> { + try { + const result = await Scene.Component.regeneratePolygon2DPoints(options); + return { + code: COMMON_STATUS.SUCCESS, + data: result, + }; + } catch (e) { + return { + code: getCommonErrorStatus(e), + reason: e instanceof Error ? e.message : String(e), + }; + } + } + /** * Recalculate LODGroup bounds // 重新计算 LODGroup 包围盒 */ diff --git a/src/core/assets/@types/public.d.ts b/src/core/assets/@types/public.d.ts index 7d2570104..c75e5f356 100644 --- a/src/core/assets/@types/public.d.ts +++ b/src/core/assets/@types/public.d.ts @@ -100,6 +100,8 @@ export interface AnimationGraphMotionView { valueX?: number; variableY?: string; valueY?: number; + /** Blend-2D 的插值算法(AnimationBlend2D.Algorithm 数值),供预览/编辑重建使用。 */ + algorithm?: number; threshold?: number | { x: number; y: number }; weight?: { value: number; variable: string }; children?: AnimationGraphMotionView[]; @@ -257,6 +259,12 @@ export interface AnimationGraphVariableView { resetMode?: number; } +/** Motion 预览数据:目标 Motion 的结构化视图与图内全部变量(含当前值)。 */ +export interface AnimationGraphMotionPreviewData { + motion: AnimationGraphMotionView | null; + variables: AnimationGraphVariableView[]; +} + export interface AnimationGraphViewDump { layers: AnimationGraphLayerView[]; variables: AnimationGraphVariableView[]; diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index 856d9a7c1..b874a91cd 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -10,6 +10,7 @@ import type { AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphLayerView, + AnimationGraphMotionPreviewData, AnimationGraphMotionType, AnimationGraphMotionView, AnimationGraphPoseGraphAssetDragHandlersEntry, @@ -24,6 +25,7 @@ import type { AnimationGraphTarget, AnimationGraphTransitionConditionView, AnimationGraphTransitionView, + AnimationGraphVariableView, AnimationGraphVersion, AnimationGraphViewDump, ExecuteAnimationGraphCommandRequest, @@ -126,6 +128,39 @@ class AnimationGraphAssetService { }); } + /** + * 查询 Motion 预览数据:目标 Motion 的结构化描述(供 Scene 进程预览服务重建引擎 + * Motion)以及图内全部变量(含当前值)。 + * + * @param uuidOrUrlOrPath - 动画图资源(uuid / url / 路径)。 + * @param target - 目标 Motion 的地址,与 Inspector 使用的 `AnimationGraphTarget` 一致。 + * @returns Motion 预览数据。 + * @throws {AnimationGraphEditError} 目标不存在或目标不是有效 Motion 时抛出 `TARGET_NOT_FOUND`。 + * + * ```mermaid + * flowchart LR + * Document[AnimationGraphDocument] --> Resolve[resolve target Motion] + * Resolve --> MotionView[_queryMotion 结构化 Motion 视图] + * Document --> Variables[图内变量 + 当前值] + * MotionView --> Payload[AnimationGraphMotionPreviewData] + * Variables --> Payload + * ``` + */ + async queryMotionPreviewData( + uuidOrUrlOrPath: string, + target: Extract, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + await this._refreshExternalState(document); + return { + motion: this._queryMotion(this._resolveMotion(document, target), target), + variables: this._queryGraphVariables(document), + }; + }); + } + async queryPoseGraphAssetDragHandlers(): Promise { const api = getNewGenAnim(); const js = getCC().js; @@ -611,6 +646,21 @@ class AnimationGraphAssetService { }; } + private _queryGraphVariables(document: AnimationGraphDocument): AnimationGraphVariableView[] { + const graph = document.graph; + const api = getNewGenAnim(); + return 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', @@ -805,6 +855,7 @@ class AnimationGraphAssetService { view.valueX = motion.paramX.value; view.variableY = motion.paramY.variable; view.valueY = motion.paramY.value; + view.algorithm = motion.algorithm; } if (threshold !== undefined) { view.threshold = isVec2Like(threshold) @@ -953,18 +1004,38 @@ class AnimationGraphAssetService { private _createStateBinding(state: any): InspectorBinding { const api = getNewGenAnim(); + const eventBindingAttrs = { type: 'String', default: '', group: { id: 'event-bindings', name: 'Event Bindings' } }; const properties: Record = { - name: directProperty(state, 'name', { type: 'String', default: '' }), + name: directProperty(state, 'name', { type: 'String', default: '', ui: { name: 'animationGraphRename' } }), }; 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: '' }); + // Speed Multiplier 与 Enabled 合并为一个字段:value 为 { enabled, multiplier }, + // 由 Inspector 侧 animationGraphSpeedMultiplier 渲染成 checkbox + 输入框。 + properties.speedMultiplier = { + get: () => ({ enabled: !!state.speedMultiplierEnabled, multiplier: String(state.speedMultiplier ?? '') }), + set: (value: any) => { + if (value && typeof value === 'object') { + state.speedMultiplierEnabled = !!value.enabled; + if (typeof value.multiplier === 'string') { + state.speedMultiplier = value.multiplier; + } + } + }, + // default 必须是完整对象工厂:Reset 时 setter 才能同时恢复 enabled 与 multiplier; + // 若为 null,Reset 写入 null 会被 setter 忽略,导致 Reset 静默失效。 + attrs: { + type: 'Object', + default: () => ({ enabled: false, multiplier: '' }), + displayName: 'Speed Multiplier', + ui: { name: 'animationGraphSpeedMultiplier' }, + }, + }; + properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', eventBindingAttrs); + properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', eventBindingAttrs); } else if (state instanceof api.ProceduralPoseState) { - properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', { type: 'String', default: '' }); - properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', { type: 'String', default: '' }); + properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', eventBindingAttrs); + properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', eventBindingAttrs); } return createAdapterBinding(getClassName(state), properties); } @@ -995,10 +1066,16 @@ class AnimationGraphAssetService { const api = getNewGenAnim(); const properties: Record = {}; if (motion instanceof api.ClipMotion) { - properties.clip = directProperty(motion, 'clip', { type: 'Object', ctor: getCC().AnimationClip, default: null }); + properties.clip = directProperty(motion, 'clip', { + type: 'Object', + ctor: getCC().AnimationClip, + default: null, + displayName: 'Clip', + group: { id: 'animation-clip-motion', name: 'Animation Clip Motion', displayOrder: 0, style: 'tab' }, + }); } if (motion instanceof api.AnimationBlend) { - properties.name = directProperty(motion, 'name', { type: 'String', default: '' }); + properties.name = directProperty(motion, 'name', { type: 'String', default: '', ui: { name: 'animationGraphRename' } }); } if (motion instanceof api.AnimationBlend1D) { properties.variable = nestedProperty(motion.param, 'variable', { type: 'String', default: '' }); @@ -1009,9 +1086,17 @@ class AnimationGraphAssetService { default: 0, enumList: enumList(api.AnimationBlend2D.Algorithm), }); - properties.variableX = nestedProperty(motion.paramX, 'variable', { type: 'String', default: '' }); + properties.variableX = nestedProperty(motion.paramX, 'variable', { + type: 'String', + default: '', + ui: { name: 'animationGraphVariableSelect' }, + }); properties.valueX = nestedProperty(motion.paramX, 'value', { type: 'Number', default: 0 }); - properties.variableY = nestedProperty(motion.paramY, 'variable', { type: 'String', default: '' }); + properties.variableY = nestedProperty(motion.paramY, 'variable', { + type: 'String', + default: '', + ui: { name: 'animationGraphVariableSelect' }, + }); properties.valueY = nestedProperty(motion.paramY, 'value', { type: 'Number', default: 0 }); } return createAdapterBinding(getClassName(motion), properties); @@ -1279,6 +1364,8 @@ class AnimationGraphAssetService { throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'A motion can only be attached to a motion state.', this._version(document)); } state.motion = this._createMotion(command.motionType || 'clip', command.clipUuid); + // 第一层的 motion 名称跟随 state 名称(对齐参考编辑器),避免引擎默认的 motion-0x 名。 + state.motion.name = state.name; } assignEditorData(state, command.editorData); return; @@ -1396,6 +1483,10 @@ class AnimationGraphAssetService { if (!(state instanceof api.MotionState)) { throw this._targetNotFound(document, command); } + if (motion) { + // 第一层的 motion 名称跟随 state 名称(对齐参考编辑器),避免引擎默认的 motion-0x 名。 + motion.name = state.name; + } state.motion = motion; } return; @@ -1495,6 +1586,66 @@ class AnimationGraphAssetService { 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); + // createPoseGraphNode 对无 factory 的具体类只做默认构造,这里把 + // createArg 中的同名字段(如 variableName、stashName)补写到节点上。 + if (command.createArg && typeof command.createArg === 'object') { + for (const [key, value] of Object.entries(command.createArg)) { + if (key in node) { + node[key] = value; + } + } + } + poseGraph.addNode(node); + assignEditorData(node, command.editorData); + this._nodeId(document, node); + return; + } + case 'create-pose-node-on-asset-drag': { + const poseGraph = this._resolvePoseGraph(document, command); + const js = getCC().js; + const asset = assetQuery.queryAsset(command.assetUuid); + if (!asset) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Asset can not be found: ${command.assetUuid}`, this._version(document)); + } + const assetType = assetQuery.queryAssetProperty(asset, 'type') as string; + const assetCtor = typeof assetType === 'string' ? js.getClassByName(assetType) : undefined; + if (!assetCtor || !js.isChildClassOf(assetCtor, getCC().Asset)) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Asset type can not be found: ${assetType}`, this._version(document)); + } + // 引擎按资产构造器精确匹配注册表(registry.get(asset.constructor)), + // 这里先自行校验,把引擎的 console.warn + undefined 转换为明确的错误。 + let registered: { handlers: Record } | undefined; + for (const [ctor, info] of api.getPoseGraphAssetDragHandlersMap()) { + if (ctor === assetCtor) { + registered = info; + break; + } + } + if (!registered) { + throw new AnimationGraphEditError( + 'TARGET_NOT_FOUND', + `No pose graph asset drag handlers for asset type: ${assetType}`, + this._version(document), + ); + } + if (!(command.handlerId in registered.handlers)) { + throw new AnimationGraphEditError( + 'TARGET_NOT_FOUND', + `Pose graph asset drag handler can not be found: ${command.handlerId}, existing handlers are ${Object.keys(registered.handlers).join(',')}`, + this._version(document), + ); + } + // serialize.asAsset 生成的 stub 是资产构造器的真实实例(仅设置 _uuid), + // 内置 handler 只是把它赋给 motion.clip 字段,因此 stub 即可满足。 + const reference = this._createAssetReference(command.assetUuid, assetCtor); + const node = api.createPoseNodeOnAssetDrag(reference, command.handlerId); + if (!node) { + throw new AnimationGraphEditError( + 'INVALID_PROPERTY_PATCH', + `Pose graph asset drag handler ${command.handlerId} did not create a pose node for asset: ${command.assetUuid}`, + this._version(document), + ); + } poseGraph.addNode(node); assignEditorData(node, command.editorData); this._nodeId(document, node); @@ -1717,7 +1868,7 @@ class AnimationGraphAssetService { } const poseGraph = this._getPoseGraphByContext(document, command.poseGraph); const originalNodes = Array.from(poseGraph.nodes() as Iterable); - const stashName = command.stashName ?? uniqueStashName(layer); + const stashName = command.stashName?.trim() || uniqueStashName(layer); if (layer.getStash(stashName)) { throw this._nameConflict(document, 'stash', stashName); } @@ -2248,6 +2399,11 @@ function isVec2Like(value: unknown): value is { x: number; y: number } { } function getNodeTitle(node: any): string { + // GetVariable 系列节点:标题固定为 `Variable {variableName}`, + // 引擎 getTitle 返回的是 i18n key 数组且 variableName 为空时为 undefined。 + if (typeof node.variableName === 'string') { + return `Variable ${node.variableName}`.trim(); + } const title = node.getTitle?.(); if (typeof title === 'string') { return title; diff --git a/src/core/assets/asset-handler/assets/sprite-frame.ts b/src/core/assets/asset-handler/assets/sprite-frame.ts index 800ab6600..7e579894a 100644 --- a/src/core/assets/asset-handler/assets/sprite-frame.ts +++ b/src/core/assets/asset-handler/assets/sprite-frame.ts @@ -8,6 +8,7 @@ import { SpriteFrameBaseAssetUserData, SpriteFrameAssetUserData } from '../../@t import { getTrimRect, getDependUUIDList } from '../utils'; import i18n from '../../../base/i18n'; import { makeDefaultSpriteFrameBaseAssetUserData } from './texture-base'; +import { resolveImageSourceFile } from '../../image-processing'; try { require('sharp'); @@ -172,13 +173,9 @@ export const SpriteFrameHandler: AssetHandler = { if (asset.parent.meta.importer === 'image') { const userData = asset.userData as SpriteFrameBaseAssetUserData; - let file; - // TODO 此处需要更换通用写法,这样容易漏掉一些新格式支持的更新 - // @ts-ignore - if (['.tga', '.hdr', '.bmp', '.exr', '.znt', '.psd'].includes(asset.parent.extname.toLowerCase())) { - file = asset.parent.library + '.png'; - } else { - file = asset.parent.source; + const file = resolveImageSourceFile(asset.parent); + if (!file) { + return false; } const MIN_SIZE = 1; const imageData = await Sharp(file).raw().toBuffer({ resolveWithObject: true }); diff --git a/src/core/assets/image-processing.ts b/src/core/assets/image-processing.ts new file mode 100644 index 000000000..fa5020e66 --- /dev/null +++ b/src/core/assets/image-processing.ts @@ -0,0 +1,102 @@ +import Sharp from 'sharp'; +import { existsSync } from 'fs-extra'; +import { extname } from 'path'; +import type { Asset, VirtualAsset } from '@cocos/asset-db'; + +export interface IImagePixelExtractionOptions { + rect: { + left: number; + top: number; + width: number; + height: number; + }; + rotation?: 0 | 90; +} + +export interface IExtractedImagePixels { + dataBase64: string; + width: number; + height: number; + channels: number; +} + +/** + * 解析资源导入后的图片文件,优先使用 library 中实际存在的导入产物。 + * + * @param asset 图片源资源或其虚拟子资源 + * @returns 可供图片处理器读取的绝对路径;没有可用文件时返回 null + */ +export function resolveImageSourceFile(asset: Asset | VirtualAsset): string | null { + const sourceAsset = asset.parent ?? asset; + const sourceExtension = extname(sourceAsset.source).toLowerCase(); + const extensions = ['.png', sourceExtension].filter((extension, index, all) => ( + extension && all.indexOf(extension) === index && sourceAsset.meta.files.includes(extension) + )); + + for (const extension of extensions) { + const file = sourceAsset.getFilePath(extension); + if (existsSync(file)) { + return file; + } + } + + return existsSync(sourceAsset.source) ? sourceAsset.source : null; +} + +/** + * 在 Node 进程中读取图片像素。 + * + * Scene Runtime 可能运行在浏览器中,不能直接加载 Sharp 原生模块,因此只通过 RPC + * 调用此方法并接收可 JSON 序列化的 Base64 数据。 + */ +export async function extractImagePixelsFromFile( + file: string, + options: IImagePixelExtractionOptions, +): Promise { + if (!file) { + throw new Error('Image file path is required.'); + } + + validateExtractionOptions(options); + + const { data, info } = await Sharp(file) + .extract(options.rect) + .rotate(options.rotation ?? 0) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + if (info.channels !== 4) { + throw new Error(`Expected RGBA image data, but Sharp returned ${info.channels} channels.`); + } + + return { + dataBase64: data.toString('base64'), + width: info.width, + height: info.height, + channels: info.channels, + }; +} + +function validateExtractionOptions(options: IImagePixelExtractionOptions): void { + if (!options || !options.rect) { + throw new Error('Image extraction rect is required.'); + } + + const { left, top, width, height } = options.rect; + assertInteger(left, 'rect.left', 0); + assertInteger(top, 'rect.top', 0); + assertInteger(width, 'rect.width', 1); + assertInteger(height, 'rect.height', 1); + + const rotation = options.rotation ?? 0; + if (rotation !== 0 && rotation !== 90) { + throw new Error(`Image extraction rotation must be 0 or 90, but received ${rotation}.`); + } +} + +function assertInteger(value: number, name: string, minimum: number): void { + if (!Number.isInteger(value) || value < minimum) { + throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`); + } +} diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index d12396f38..dc911cc23 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -11,6 +11,12 @@ import animationGraphVariant from '../animation-graph-variant'; import animationGraph from '../animation-graph-service'; import * as serializedData from '../serialized-data'; import * as materialService from '../material-service'; +import { + extractImagePixelsFromFile, + resolveImageSourceFile, + type IExtractedImagePixels, + type IImagePixelExtractionOptions, +} from '../image-processing'; /** * 对外暴露一系列的资源查询、操作接口等 @@ -62,6 +68,7 @@ class AssetManager extends EventEmitter { // ---------- animation graph ---------- queryAnimationGraph = animationGraph.query.bind(animationGraph); queryAnimationGraphInspector = animationGraph.queryInspector.bind(animationGraph); + queryAnimationGraphMotionPreviewData = animationGraph.queryMotionPreviewData.bind(animationGraph); queryAnimationGraphPoseGraphAssetDragHandlers = animationGraph.queryPoseGraphAssetDragHandlers.bind(animationGraph); queryAnimationGraphStateMachineComponentTypes = animationGraph.queryStateMachineComponentTypes.bind(animationGraph); setAnimationGraphInspectorProperty = animationGraph.setInspectorProperty.bind(animationGraph); @@ -90,6 +97,22 @@ class AssetManager extends EventEmitter { if (!asset) { return null; } return assetHandlerManager.generateThumbnail(asset, size); } + + async extractImagePixels( + urlOrUUIDOrPath: string, + options: IImagePixelExtractionOptions, + ): Promise { + const asset = this.queryAsset(urlOrUUIDOrPath); + if (!asset) { + return null; + } + const file = resolveImageSourceFile(asset); + if (!file) { + return null; + } + return extractImagePixelsFromFile(file, options); + } + getEffectBinPath() { return assetHandlerManager.getEffectBinPath(); }; @@ -394,6 +417,7 @@ export interface TypedAssetManager extends EventEmitter { queryAnimationGraph: typeof animationGraph.query; queryAnimationGraphInspector: typeof animationGraph.queryInspector; + queryAnimationGraphMotionPreviewData: typeof animationGraph.queryMotionPreviewData; queryAnimationGraphPoseGraphAssetDragHandlers: typeof animationGraph.queryPoseGraphAssetDragHandlers; queryAnimationGraphStateMachineComponentTypes: typeof animationGraph.queryStateMachineComponentTypes; setAnimationGraphInspectorProperty: typeof animationGraph.setInspectorProperty; @@ -417,6 +441,10 @@ export interface TypedAssetManager extends EventEmitter { getEffectBinPath: typeof assetHandlerManager.getEffectBinPath; generateThumbnail(urlOrUUIDOrPath: string, size?: ThumbnailSize): Promise; + extractImagePixels( + urlOrUUIDOrPath: string, + options: IImagePixelExtractionOptions, + ): Promise; onReady: typeof assetManager.onReady; onDBReady: typeof assetManager.onDBReady; diff --git a/src/core/assets/serialized-data.ts b/src/core/assets/serialized-data.ts index 3a0fefdde..0dc5a1906 100644 --- a/src/core/assets/serialized-data.ts +++ b/src/core/assets/serialized-data.ts @@ -52,6 +52,7 @@ const ATTRIBUTE_PROPS = [ 'bitmaskList', 'displayName', 'group', + 'ui', 'multiline', 'step', 'slide', diff --git a/src/core/assets/test/animation-graph-service.test.ts b/src/core/assets/test/animation-graph-service.test.ts index 773731a20..5b5d02d3c 100644 --- a/src/core/assets/test/animation-graph-service.test.ts +++ b/src/core/assets/test/animation-graph-service.test.ts @@ -874,8 +874,7 @@ describe('animation graph asset service', () => { let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, stateTarget); for (const [path, patch] of [ ['speed', 1.75], - ['speedMultiplier', 'speed'], - ['speedMultiplierEnabled', true], + ['speedMultiplier', { enabled: true, multiplier: 'speed' }], ] as const) { inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { target: stateTarget, @@ -906,9 +905,7 @@ describe('animation graph asset service', () => { 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, @@ -928,9 +925,7 @@ describe('animation graph asset service', () => { 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 }, }); @@ -1000,9 +995,7 @@ describe('animation graph asset service', () => { 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 }, }), }); @@ -1172,6 +1165,74 @@ describe('animation graph asset service', () => { ])); }); + it('auto names stashes for empty and whitespace-only names and keeps references after reload', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-empty-stash-name.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-stash', layerIndex: 0, name: 'Stash1' }, + expected: snapshot, + }); + + for (const stateName of ['Empty Name', 'Whitespace Name']) { + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'procedural-pose', + name: stateName, + }, + expected: snapshot, + }); + } + + const emptyStateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Empty Name')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: snapshot.graph.layers[0].stateMachine.states[emptyStateIndex].poseGraph!.context, + layerIndex: 0, + stashName: '', + }, + expected: snapshot, + }); + const emptyState = snapshot.graph.layers[0].stateMachine.states[emptyStateIndex]; + expect(snapshot.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Stash1', 'Stash2'])); + expect(emptyState.poseGraph!.nodes.find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash2' }); + + const whitespaceStateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Whitespace Name')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: snapshot.graph.layers[0].stateMachine.states[whitespaceStateIndex].poseGraph!.context, + layerIndex: 0, + stashName: ' ', + }, + expected: snapshot, + }); + const whitespaceState = snapshot.graph.layers[0].stateMachine.states[whitespaceStateIndex]; + expect(snapshot.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Stash1', 'Stash2', 'Stash3'])); + expect(whitespaceState.poseGraph!.nodes.find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash3' }); + + 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(['Stash1', 'Stash2', 'Stash3'])); + expect(reloaded.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Stash2')).toBeDefined(); + expect(reloaded.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Stash3')).toBeDefined(); + expect(reloaded.graph.layers[0].stateMachine.states[emptyStateIndex].poseGraph!.nodes + .find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash2' }); + expect(reloaded.graph.layers[0].stateMachine.states[whitespaceStateIndex].poseGraph!.nodes + .find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash3' }); + }); + 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`), @@ -1543,4 +1604,112 @@ describe('animation graph asset service', () => { expected: withBlend2DState, })).rejects.toMatchObject({ message: expect.stringContaining('missing-asset-uuid') }); }); + + it('queries serializable Motion preview data for clip and blend motions', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-motion-preview.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const clip = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-motion-preview-clip.anim`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-clip/default.anim', + ), 'utf8'), + overwrite: true, + }); + + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'speed', variableType: 0, initialValue: 2.5 }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Clip Motion', + }, + expected: snapshot, + }); + const stateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Clip Motion')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'clip', + clipUuid: clip.uuid, + }, + expected: snapshot, + }); + const clipTarget = snapshot.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + + const clipPreview = await assetManager.queryAnimationGraphMotionPreviewData(asset.uuid, clipTarget); + expect(clipPreview.motion).toMatchObject({ type: 'clip', clipUuid: clip.uuid }); + expect(Array.isArray(clipPreview.motion!.level)).toBe(true); + expect(clipPreview.variables).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'speed', type: 0 }), + ])); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'blend-2d', + }, + expected: snapshot, + }); + const blendTarget = snapshot.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, blendTarget); + for (const [path, patch] of [ + ['variableX', 'speed'], + ['variableY', 'speed'], + ] as const) { + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: blendTarget, + path, + patch, + expected: inspector, + }); + } + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-motion-child', target: blendTarget, motionType: 'clip', clipUuid: clip.uuid }, + expected: inspector, + }); + const blendWithChild = await assetManager.queryAnimationGraph(asset.uuid); + const blendTargetAfterChild = blendWithChild.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'set-motion-threshold', target: blendTargetAfterChild, childIndex: 0, threshold: { x: 0.3, y: 0.6 } }, + expected: blendWithChild, + }); + + const blendPreview = await assetManager.queryAnimationGraphMotionPreviewData(asset.uuid, blendTargetAfterChild); + expect(blendPreview.motion).toMatchObject({ + type: 'blend-2d', + variableX: 'speed', + variableY: 'speed', + }); + expect(typeof blendPreview.motion!.algorithm).toBe('number'); + expect(blendPreview.motion!.children).toEqual([ + expect.objectContaining({ type: 'clip', clipUuid: clip.uuid, threshold: { x: 0.3, y: 0.6 } }), + ]); + + const finalSnapshot = await assetManager.queryAnimationGraph(asset.uuid); + await assetManager.saveAnimationGraph(asset.uuid, finalSnapshot); + + await expect(assetManager.queryAnimationGraphMotionPreviewData(asset.uuid, { + kind: 'motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex: 9999, + level: [], + })).rejects.toMatchObject({ code: 'TARGET_NOT_FOUND' }); + }); }); diff --git a/src/core/assets/test/image-processing.test.ts b/src/core/assets/test/image-processing.test.ts new file mode 100644 index 000000000..15ae0dbcc --- /dev/null +++ b/src/core/assets/test/image-processing.test.ts @@ -0,0 +1,106 @@ +export {}; + +import { join } from 'path'; + +jest.mock('sharp', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockSharp = require('sharp').default as jest.Mock; +const imageProcessingModule = () => require('../image-processing') as typeof import('../image-processing'); + +describe('asset image processing', () => { + beforeEach(() => { + mockSharp.mockReset(); + }); + + it('prefers an existing imported library image over the source extension', () => { + const libraryImage = join( + __dirname, + '../../../../tests/fixtures/projects/asset-operation/library/b5/b5929d2c-caf4-4454-8f7e-4e84cb5ce144.png', + ); + const asset = { + source: 'D:/project/assets/image.tga', + library: libraryImage.slice(0, -4), + meta: { importer: 'image', files: ['.json', '.png'] }, + parent: null, + getFilePath: (extension: string) => `${libraryImage.slice(0, -4)}${extension}`, + }; + + expect(imageProcessingModule().resolveImageSourceFile(asset as any)).toBe(libraryImage); + }); + + it('resolves an image library output through a virtual image subasset', () => { + const libraryImage = join( + __dirname, + '../../../../tests/fixtures/projects/asset-operation/library/b5/b5929d2c-caf4-4454-8f7e-4e84cb5ce144.png', + ); + const parent = { + source: 'D:/project/assets/image.png', + library: libraryImage.slice(0, -4), + meta: { importer: 'image', files: ['.json', '.png'] }, + parent: null, + getFilePath: (extension: string) => `${libraryImage.slice(0, -4)}${extension}`, + }; + const asset = { + source: 'D:/project/assets/image.png@texture', + library: 'D:/project/library/image@texture', + meta: { importer: 'texture', files: ['.json'] }, + parent, + getFilePath: (extension: string) => `D:/project/library/image@texture${extension}`, + }; + + expect(imageProcessingModule().resolveImageSourceFile(asset as any)).toBe(libraryImage); + }); + + it('extracts RGBA pixels in Node and returns JSON-safe Base64 data', async () => { + const data = Buffer.from([ + 255, 0, 0, 255, + 0, 255, 0, 128, + ]); + const pipeline = { + extract: jest.fn().mockReturnThis(), + rotate: jest.fn().mockReturnThis(), + ensureAlpha: jest.fn().mockReturnThis(), + raw: jest.fn().mockReturnThis(), + toBuffer: jest.fn().mockResolvedValue({ + data, + info: { width: 1, height: 2, channels: 4 }, + }), + }; + mockSharp.mockReturnValue(pipeline); + + const result = await imageProcessingModule().extractImagePixelsFromFile( + 'D:/project/assets/atlas.png', + { + rect: { left: 3, top: 4, width: 2, height: 1 }, + rotation: 90, + }, + ); + + expect(mockSharp).toHaveBeenCalledWith('D:/project/assets/atlas.png'); + expect(pipeline.extract).toHaveBeenCalledWith({ left: 3, top: 4, width: 2, height: 1 }); + expect(pipeline.rotate).toHaveBeenCalledWith(90); + expect(pipeline.ensureAlpha).toHaveBeenCalledTimes(1); + expect(pipeline.raw).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + dataBase64: data.toString('base64'), + width: 1, + height: 2, + channels: 4, + }); + }); + + it('rejects invalid extraction rectangles before invoking Sharp', async () => { + await expect(imageProcessingModule().extractImagePixelsFromFile( + 'D:/project/assets/atlas.png', + { + rect: { left: 0, top: 0, width: 0, height: 1 }, + rotation: 0, + }, + )).rejects.toThrow('rect.width must be an integer greater than or equal to 1'); + + expect(mockSharp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/scene/common/component.ts b/src/core/scene/common/component.ts index 1319beb46..6bfa32caf 100644 --- a/src/core/scene/common/component.ts +++ b/src/core/scene/common/component.ts @@ -124,6 +124,32 @@ export interface IExecuteComponentMethodOptions { args: any[]; } +/** + * PolygonCollider2D 顶点重新生成的数据来源。 + */ +export type Polygon2DPointsSource = 'sprite-alpha' | 'rect-fallback'; + +/** + * 重新生成 PolygonCollider2D.points 的选项。 + */ +export interface IRegeneratePolygon2DPointsOptions { + /** PolygonCollider2D 组件路径、UUID 或 URL。 */ + path: string; + /** 是否记录 Undo;默认 true。 */ + record?: boolean; +} + +/** + * 重新生成 PolygonCollider2D.points 的成功结果。 + * 失败通过 Error 抛出,由 RPC/API 边界决定如何呈现。 + */ +export interface IRegeneratePolygon2DPointsResult { + path: string; + changed: boolean; + pointCount: number; + source: Polygon2DPointsSource; +} + /** * 查询注册类的过滤选项 */ @@ -207,6 +233,30 @@ export interface IComponentService extends IServiceEvents { */ setProperty(params: ISetPropertyOptions): Promise; + /** + * 根据同节点 Sprite 或 UITransform 重新生成 PolygonCollider2D.points。 + * + * Sprite Alpha 轮廓生成、资源读取、校验或提交失败时抛出 Error,不会修改组件现有 points。 + * 没有 Sprite、SpriteFrame 或无法解析源图片时,使用 UITransform 矩形回退。 + * + * @param options - 重新生成选项 + * @param options.path - PolygonCollider2D 组件路径、UUID 或 db:// URL + * @param options.record - 是否记录 Undo,默认 true + * @returns 生成结果,包含是否变更、最终顶点数和顶点来源 + * @throws 组件不存在或类型不正确,以及资源读取、轮廓生成、顶点校验或属性提交失败时抛出 Error + * + * @example + * ```ts + * const result = await regeneratePolygon2DPoints({ + * path: 'Canvas/MyNode/cc.PolygonCollider2D', + * record: true, + * }); + * ``` + */ + regeneratePolygon2DPoints( + options: IRegeneratePolygon2DPointsOptions, + ): Promise; + /** * 查询组件信息 * - 传入 IQueryComponentOptions 时,返回 IComponentInfo diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index 47d40e81d..23a5a60d9 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -23,13 +23,82 @@ export interface ISpinePreviewInstance extends IPreviewInstance { close(): void; } -export interface IPreviewService { +export interface IPreviewService extends IMotionPreviewService { open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } +/** + * 通用 Motion 预览描述(中立、可序列化)。 + * 由业务方(如 AnimationGraph 扩展)翻译自各自的资产数据后传入; + * scene 侧只理解本结构,不理解任何业务资产类型。 + */ +export type MotionPreviewDescNode = + | { kind: 'clip'; clipUuid: string | null } + | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { motion: MotionPreviewDescNode | null; threshold: number }[]; + } + | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { motion: MotionPreviewDescNode | null; threshold: { x: number; y: number } }[]; + } + | { + kind: 'blend-direct'; + children: { motion: MotionPreviewDescNode | null; weight: number }[]; + }; + +export interface MotionPreviewVariable { + name: string; + value: number | null; +} + +export interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} + +/** + * 通用 Motion 预览子能力(scene-process Preview 服务按同名方法透传)。 + * 入参均为中立描述,不携带业务资产寻址信息。 + */ +export interface IMotionPreviewService { + showMotion(desc: MotionPreviewDesc): Promise; + hideMotion(): Promise; + setMotionModel(uuid: string): Promise; + setMotionTime(time: number): Promise; + playMotion(): Promise; + pauseMotion(): Promise; + stopMotion(): Promise; + setMotionVariable(name: string, value: number): Promise; + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; + getMotionTimelineStats(): Promise<{ timeLineLength: number } | null>; + isMotionActive(): Promise; + queryMotionImage(info: { width: number; height: number }): Promise; + onMotionMouseDown(action: { x: number; y: number; button: number }): Promise; + onMotionMouseMove(action: { movementX: number; movementY: number }): Promise; + onMotionMouseUp(action: { x: number; y: number }): Promise; + onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise; +} + export type IPublicPreviewService = Pick; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index a69ebfc95..7b27880a2 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -7,6 +7,7 @@ import { AssetProxy } from './proxy/asset-proxy'; import { EngineProxy } from './proxy/engine-proxy'; import { PrefabProxy } from './proxy/prefab-proxy'; import { ReferenceImageProxy } from './proxy/reference-image-proxy'; +import { PreviewProxy } from './proxy/preview-proxy'; import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; @@ -31,6 +32,7 @@ export const Scene = { ...EngineProxy, ...PrefabProxy, ReferenceImage: ReferenceImageProxy, + Preview: PreviewProxy, // 节点相关的接口 Node: NodeProxy, // 组件相关的接口 diff --git a/src/core/scene/main-process/proxy/component-proxy.ts b/src/core/scene/main-process/proxy/component-proxy.ts index 780d8075c..29b0a7ae1 100644 --- a/src/core/scene/main-process/proxy/component-proxy.ts +++ b/src/core/scene/main-process/proxy/component-proxy.ts @@ -1,5 +1,7 @@ import { IAddComponentOptions, + IRegeneratePolygon2DPointsOptions, + IRegeneratePolygon2DPointsResult, IRemoveComponentOptions, IQueryComponentOptions, IPublicComponentService, @@ -111,6 +113,12 @@ export const ComponentProxy: IComponentProxy = { return true; }, + regeneratePolygon2DPoints( + options: IRegeneratePolygon2DPointsOptions, + ): Promise { + return Rpc.getInstance().request('Component', 'regeneratePolygon2DPoints', [options]); + }, + queryAll(): Promise { return Rpc.getInstance().request('Component', 'queryAll'); }, diff --git a/src/core/scene/main-process/proxy/preview-proxy.ts b/src/core/scene/main-process/proxy/preview-proxy.ts new file mode 100644 index 000000000..98a71f094 --- /dev/null +++ b/src/core/scene/main-process/proxy/preview-proxy.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) SUD. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { MotionPreviewDesc, IMotionPreviewService } from '../../common/preview'; +import { Rpc } from '../rpc'; + +/** + * 场景进程 PreviewService 的主进程 RPC 代理。 + */ +export const PreviewProxy: IMotionPreviewService = { + async showMotion(desc: MotionPreviewDesc): Promise { + const result = await Rpc.getInstance().request('Preview', 'showMotion', [desc]); + return result === true; + }, + + hideMotion(): Promise { + return Rpc.getInstance().request('Preview', 'hideMotion', []); + }, + + setMotionModel(uuid: string): Promise { + return Rpc.getInstance().request('Preview', 'setMotionModel', [uuid]); + }, + + setMotionTime(time: number): Promise { + return Rpc.getInstance().request('Preview', 'setMotionTime', [time]); + }, + + playMotion(): Promise { + return Rpc.getInstance().request('Preview', 'playMotion', []); + }, + + pauseMotion(): Promise { + return Rpc.getInstance().request('Preview', 'pauseMotion', []); + }, + + stopMotion(): Promise { + return Rpc.getInstance().request('Preview', 'stopMotion', []); + }, + + setMotionVariable(name: string, value: number): Promise { + return Rpc.getInstance().request('Preview', 'setMotionVariable', [name, value]); + }, + + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + return Rpc.getInstance().request('Preview', 'setMotionParameter', [axis, value]); + }, + + getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { + return Rpc.getInstance().request('Preview', 'getMotionTimelineStats', []); + }, + + async isMotionActive(): Promise { + const result = await Rpc.getInstance().request('Preview', 'isMotionActive', []); + return result === true; + }, + + queryMotionImage(info: { width: number; height: number }): Promise { + return Rpc.getInstance().request('Preview', 'queryMotionImage', [info]); + }, + + onMotionMouseDown(action: { x: number; y: number; button: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseDown', [action]); + }, + + onMotionMouseMove(action: { movementX: number; movementY: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseMove', [action]); + }, + + onMotionMouseUp(action: { x: number; y: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseUp', [action]); + }, + + onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseWheel', [action]); + }, +}; diff --git a/src/core/scene/scene-process/service/component.ts b/src/core/scene/scene-process/service/component.ts index aaa4891ab..0bf090748 100644 --- a/src/core/scene/scene-process/service/component.ts +++ b/src/core/scene/scene-process/service/component.ts @@ -1,4 +1,4 @@ -import { Component, Constructor, animation, Animation, Node, RigidBody, Collider, ERigidBodyType, EColliderType, MeshCollider, UITransform, director, Canvas, Scene } from 'cc'; +import { Component, Constructor, animation, Animation, Node, RigidBody, Collider, ERigidBodyType, EColliderType, MeshCollider, UITransform, director, Canvas, Scene, PolygonCollider2D } from 'cc'; import { Rpc } from '../rpc'; import { register, Service, BaseService } from './core'; import { @@ -19,6 +19,8 @@ import { IEraseLODOptions, IQueryLODGroupRelativeHeightOptions, ILODGroupLevelsResult, + IRegeneratePolygon2DPointsOptions, + IRegeneratePolygon2DPointsResult, } from '../../common'; import dumpUtil from './dump'; import compMgr from './component/index'; @@ -43,6 +45,14 @@ import { validateLODErase, validateLODInsert, } from './component/lod-group'; +import { + arePolygonPointsEqual, + createPolygonPointsPropertyDump, + generatePolygonPoints, + initializePolygonCollider2DPoints, + requirePolygonCollider2D, + validatePolygonPoints, +} from './component/polygon-collider-2d'; const NodeMgr = EditorExtends.Node; @@ -277,6 +287,9 @@ export class ComponentService extends BaseService implements I this.checkDynamicBodyShape(node); compMgr.onComponentAddedFromEditor(comp); + if (comp instanceof PolygonCollider2D) { + await initializePolygonCollider2DPoints(comp); + } this.emit('node:change', node, { type: NodeEventType.CREATE_COMPONENT }); const dump = dumpUtil.dumpComponent(comp as Component) as IComponent; @@ -397,6 +410,69 @@ export class ComponentService extends BaseService implements I } } + async regeneratePolygon2DPoints( + options: IRegeneratePolygon2DPointsOptions, + ): Promise { + const path = options.path; + + try { + await Service.Editor.lock(); + + const component = await this.findComponent(path); + const collider = requirePolygonCollider2D(component, path); + const generated = await generatePolygonPoints(collider); + validatePolygonPoints(generated.points); + + if (arePolygonPointsEqual(collider.points, generated.points)) { + return { + path, + changed: false, + pointCount: generated.points.length, + source: generated.source, + }; + } + + const componentIndex = collider.node.components.indexOf(collider); + if (componentIndex < 0) { + throw new Error('PolygonCollider2D is no longer attached to its node.'); + } + + const componentDump = dumpUtil.dumpComponent(collider) as IComponent; + const pointsDump = createPolygonPointsPropertyDump( + componentDump.value?.points, + generated.points, + ); + if (!pointsDump) { + throw new Error('Unable to encode PolygonCollider2D.points from the component dump.'); + } + + const nodePath = NodeMgr.getNodePath(collider.node) + || (collider.node === Service.Editor.getRootNode() ? '/' : ''); + if (!nodePath) { + throw new Error('Unable to resolve the PolygonCollider2D node path.'); + } + + const committed = await this.setProperty({ + nodePath, + path: `__comps__.${componentIndex}.points`, + dump: pointsDump, + record: options.record, + }); + if (!committed) { + throw new Error('Failed to commit PolygonCollider2D.points through ComponentService.setProperty().'); + } + + return { + path, + changed: true, + pointCount: generated.points.length, + source: generated.source, + }; + } finally { + Service.Editor.unlock(); + } + } + async setProperty(options: ISetPropertyOptions): Promise { // 多个节点更新值 if (Array.isArray(options.nodePath)) { diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts new file mode 100644 index 000000000..2f68d899f --- /dev/null +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts @@ -0,0 +1,281 @@ +import { Component, Physics2DUtils, PolygonCollider2D, Sprite, UITransform, Vec2, js } from 'cc'; +import type { SpriteFrame } from 'cc'; +import type { IProperty } from '../../../@types/public'; +import type { Polygon2DPointsSource } from '../../../common/component'; +import type { IExtractedImagePixels } from '../../../../assets/image-processing'; +import { Rpc } from '../../rpc'; +import { traceAlphaContour } from './polygon-collider-2d/contour'; +import { simplifyContour } from './polygon-collider-2d/simplify'; + +export interface IGeneratePolygonPointsResult { + source: Polygon2DPointsSource; + points: Vec2[]; +} + +const DEFAULT_RECT_SIZE = 100; + +/** + * 将通用组件收窄为 PolygonCollider2D。 + */ +export function requirePolygonCollider2D(component: Component | null, path: string): PolygonCollider2D { + if (!component || !component.isValid) { + throw new Error(`PolygonCollider2D component not found: ${path}`); + } + + if (!(component instanceof PolygonCollider2D)) { + const actualType = js.getClassName(component.constructor) || component.constructor?.name || 'unknown'; + throw new Error( + `Parameter error: component is not cc.PolygonCollider2D: ${path} (received ${actualType})`, + ); + } + + return component; +} + +/** + * 生成候选顶点,不直接修改组件。 + * + * Sprite Alpha 分支异步读取源图片并生成轮廓;无有效 Sprite 来源时回退到节点矩形。 + */ +export async function generatePolygonPoints( + collider: PolygonCollider2D, +): Promise { + const transform = collider.node.getComponent(UITransform); + if (!hasUsableTransform(transform)) { + return { + source: 'rect-fallback', + points: generateRectFallbackPoints(collider), + }; + } + + const sprite = collider.node.getComponent(Sprite); + if (sprite?.spriteFrame) { + const points = await generateSpriteAlphaPolygonPoints(collider, sprite, transform); + if (points) { + return { + source: 'sprite-alpha', + points, + }; + } + } + + return { + source: 'rect-fallback', + points: generateRectFallbackPoints(collider), + }; +} + +/** + * 初始化新添加的 PolygonCollider2D,不参与通用组件新增生命周期。 + * 生成失败时保留引擎默认 points,避免阻断 Add Component。 + */ +export async function initializePolygonCollider2DPoints(collider: PolygonCollider2D): Promise { + try { + const generated = await generatePolygonPoints(collider); + validatePolygonPoints(generated.points); + collider.points = generated.points; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.warn( + `Failed to initialize PolygonCollider2D.points; keeping the engine default points. ${reason}`, + ); + } +} + +/** + * 校验提交前的基础几何条件。 + * 首版只覆盖数量、有限数值和环形相邻重复点;复杂自交校验留给算法阶段。 + */ +export function validatePolygonPoints(points: readonly Readonly[]): void { + if (points.length < 3) { + throw new Error(`Polygon requires at least 3 points, but received ${points.length}.`); + } + + for (let index = 0; index < points.length; index++) { + const point = points[index]; + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Polygon point ${index} contains a non-finite coordinate.`); + } + + const next = points[(index + 1) % points.length]; + if (point.x === next.x && point.y === next.y) { + throw new Error(`Polygon points ${index} and ${(index + 1) % points.length} are adjacent duplicates.`); + } + } +} + +/** + * 判断候选点是否会实际改变 points,避免产生空 Undo。 + */ +export function arePolygonPointsEqual( + current: readonly Readonly[], + candidate: readonly Readonly[], +): boolean { + return current.length === candidate.length && current.every((point, index) => { + const other = candidate[index]; + return point.x === other.x && point.y === other.y; + }); +} + +/** + * 使用现有 points Dump 的元素模板编码候选 Vec2[]。 + */ +export function createPolygonPointsPropertyDump( + pointsProperty: unknown, + points: readonly Readonly[], +): IProperty | null { + if (!isProperty(pointsProperty) || !pointsProperty.isArray) { + return null; + } + + const currentValues = Array.isArray(pointsProperty.value) ? pointsProperty.value : []; + const elementTemplate = pointsProperty.elementTypeData ?? currentValues[0]; + if (!isProperty(elementTemplate)) { + return null; + } + + const dump = cloneDump(pointsProperty); + dump.value = points.map((point, index) => { + const item = cloneDump(elementTemplate); + item.name = String(index); + item.value = { x: point.x, y: point.y }; + return item; + }); + return dump; +} + +function generateRectFallbackPoints(collider: PolygonCollider2D): Vec2[] { + const transform = collider.node.getComponent(UITransform); + const usableTransform = hasUsableTransform(transform) ? transform : null; + const width = usableTransform?.contentSize.width ?? DEFAULT_RECT_SIZE; + const height = usableTransform?.contentSize.height ?? DEFAULT_RECT_SIZE; + const anchorX = usableTransform?.anchorX ?? 0.5; + const anchorY = usableTransform?.anchorY ?? 0.5; + + const left = -anchorX * width; + const right = (1 - anchorX) * width; + const bottom = -anchorY * height; + const top = (1 - anchorY) * height; + + return [ + new Vec2(left, bottom), + new Vec2(left, top), + new Vec2(right, top), + new Vec2(right, bottom), + ]; +} + +async function generateSpriteAlphaPolygonPoints( + collider: PolygonCollider2D, + sprite: Sprite, + transform: UITransform, +): Promise { + const spriteFrame = sprite.spriteFrame; + if (!spriteFrame) { + throw new Error('SpriteFrame was removed before PolygonCollider2D points could be generated.'); + } + + const spriteFrameUuid = (spriteFrame as { _uuid?: string })._uuid; + const sourceUuid = resolveSpriteFrameSourceUuid(spriteFrame); + if (!sourceUuid) { + return null; + } + + const rect = spriteFrame.getRect(); + const frameWidth = rect.width; + const frameHeight = rect.height; + const rotated = spriteFrame.isRotated(); + let imagePixels: IExtractedImagePixels | null; + try { + imagePixels = await Rpc.getInstance().request('assetManager', 'extractImagePixels', [ + sourceUuid, + { + rect: { + left: rect.x, + top: rect.y, + width: rotated ? frameHeight : frameWidth, + height: rotated ? frameWidth : frameHeight, + }, + rotation: rotated ? 90 : 0, + }, + ]) as IExtractedImagePixels | null; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to read source image pixels for SpriteFrame "${spriteFrameUuid}" from asset "${sourceUuid}": ${reason}`, + ); + } + if (!imagePixels) { + return null; + } + + const data = decodeImagePixels(imagePixels); + + const contour = traceAlphaContour(data, imagePixels.width, imagePixels.height, true); + const simplified = simplifyContour(contour, collider.threshold); + const points = simplified.length >= 4 ? simplified : contour; + + if ( + points.length > 0 + && points[0].x === points[points.length - 1].x + && points[0].y === points[points.length - 1].y + ) { + points.length -= 1; + } + + const width = transform.contentSize.width; + const height = transform.contentSize.height; + const result = points.map((point) => new Vec2( + point.x * width / frameWidth - transform.anchorX * width, + (frameHeight - point.y) * height / frameHeight - transform.anchorY * height, + )); + + Physics2DUtils.PolygonSeparator.ForceCounterClockWise(result); + return result; +} + +function resolveSpriteFrameSourceUuid(spriteFrame: SpriteFrame): string | undefined { + const originalTextureUuid = (spriteFrame.original as { _texture?: { _uuid?: string } } | null | undefined)?._texture?._uuid; + const textureUuid = originalTextureUuid ?? (spriteFrame.texture as { _uuid?: string } | null | undefined)?._uuid; + const spriteFrameUuid = (spriteFrame as { _uuid?: string })._uuid; + return (textureUuid ?? spriteFrameUuid)?.split('@')[0]; +} + +function hasUsableTransform(transform: UITransform | null): transform is UITransform { + return !!transform && !( + transform.contentSize.width === 0 + && transform.contentSize.height === 0 + ); +} + +function decodeImagePixels(imagePixels: IExtractedImagePixels): Uint8Array { + if ( + !Number.isInteger(imagePixels.width) + || imagePixels.width <= 0 + || !Number.isInteger(imagePixels.height) + || imagePixels.height <= 0 + || imagePixels.channels !== 4 + ) { + throw new Error('Invalid RGBA image metadata returned by the asset image processor.'); + } + + const binary = atob(imagePixels.dataBase64); + const data = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + data[index] = binary.charCodeAt(index); + } + + const expectedLength = imagePixels.width * imagePixels.height * imagePixels.channels; + if (data.length !== expectedLength) { + throw new Error(`Invalid RGBA image data length: expected ${expectedLength}, received ${data.length}.`); + } + return data; +} + +function isProperty(value: unknown): value is IProperty { + return !!value && typeof value === 'object' && 'value' in value; +} + +function cloneDump(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts new file mode 100644 index 000000000..e87906ccd --- /dev/null +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts @@ -0,0 +1,124 @@ +export interface IContourPoint { + x: number; + y: number; +} + +enum StepDirection { + NONE, + UP, + LEFT, + DOWN, + RIGHT, +} + +/** + * 提取 RGBA 数据中左上方第一个非透明连通区域的外轮廓。 + */ +export function traceAlphaContour( + data: Uint8Array, + width: number, + height: number, + loop = true, +): IContourPoint[] { + const start = findFirstOpaquePixel(data, width, height); + if (!start) { + return []; + } + + let x = start.x; + let y = start.y; + let previousStep = StepDirection.NONE; + const points: IContourPoint[] = [{ x, y }]; + + do { + const nextStep = resolveNextStep(data, width, height, x, y, previousStep); + previousStep = nextStep; + + switch (nextStep) { + case StepDirection.UP: + y--; + break; + case StepDirection.LEFT: + x--; + break; + case StepDirection.DOWN: + y++; + break; + case StepDirection.RIGHT: + x++; + break; + default: + return []; + } + + if (x >= 0 && x <= width && y >= 0 && y <= height && (loop || x !== start.x || y !== start.y)) { + points.push({ x, y }); + } + } while (x !== start.x || y !== start.y); + + return points; +} + +function findFirstOpaquePixel(data: Uint8Array, width: number, height: number): IContourPoint | null { + let offset = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++, offset += 4) { + if (data[offset + 3] > 0) { + return { x, y }; + } + } + } + return null; +} + +function resolveNextStep( + data: Uint8Array, + width: number, + height: number, + x: number, + y: number, + previousStep: StepDirection, +): StepDirection { + const width4 = width * 4; + const index = (y - 1) * width4 + (x - 1) * 4; + const canLeft = x > 0; + const canRight = x < width; + const canDown = y < height; + const canUp = y > 0; + + const upLeft = canUp && canLeft && data[index + 3] > 0; + const upRight = canUp && canRight && data[index + 7] > 0; + const downLeft = canDown && canLeft && data[index + width4 + 3] > 0; + const downRight = canDown && canRight && data[index + width4 + 7] > 0; + + let state = 0; + if (upLeft) state |= 1; + if (upRight) state |= 2; + if (downLeft) state |= 4; + if (downRight) state |= 8; + + switch (state) { + case 1: return StepDirection.UP; + case 2: + case 3: + case 7: + return StepDirection.RIGHT; + case 4: + case 12: + case 14: + return StepDirection.LEFT; + case 5: + case 13: + return StepDirection.UP; + case 6: + return previousStep === StepDirection.UP ? StepDirection.LEFT : StepDirection.RIGHT; + case 8: + case 10: + case 11: + return StepDirection.DOWN; + case 9: + return previousStep === StepDirection.RIGHT ? StepDirection.UP : StepDirection.DOWN; + default: + return StepDirection.NONE; + } +} diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts new file mode 100644 index 000000000..811d1d57e --- /dev/null +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts @@ -0,0 +1,52 @@ +import type { IContourPoint } from './contour'; + +/** + * 使用与 Creator Editor 相同的 Ramer-Douglas-Peucker 实现简化轮廓。 + */ +export function simplifyContour( + points: readonly IContourPoint[], + epsilon: number, +): IContourPoint[] { + if (points.length < 3) { + return points.map(clonePoint); + } + + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; + let index = -1; + let distance = 0; + + for (let i = 1; i < points.length - 1; i++) { + const currentDistance = findPerpendicularDistance(points[i], firstPoint, lastPoint); + if (currentDistance > distance) { + distance = currentDistance; + index = i; + } + } + + if (distance > epsilon && index > 0) { + const left = simplifyContour(points.slice(0, index + 1), epsilon); + const right = simplifyContour(points.slice(index), epsilon); + return left.slice(0, left.length - 1).concat(right); + } + + return [clonePoint(firstPoint), clonePoint(lastPoint)]; +} + +function findPerpendicularDistance( + point: IContourPoint, + lineStart: IContourPoint, + lineEnd: IContourPoint, +): number { + if (lineStart.x === lineEnd.x) { + return Math.abs(point.x - lineStart.x); + } + + const slope = (lineEnd.y - lineStart.y) / (lineEnd.x - lineStart.x); + const intercept = lineStart.y - slope * lineStart.x; + return Math.abs(slope * point.x - point.y + intercept) / Math.sqrt(slope * slope + 1); +} + +function clonePoint(point: IContourPoint): IContourPoint { + return { x: point.x, y: point.y }; +} diff --git a/src/core/scene/scene-process/service/component/utils.ts b/src/core/scene/scene-process/service/component/utils.ts index a29ee0b37..4735eead1 100644 --- a/src/core/scene/scene-process/service/component/utils.ts +++ b/src/core/scene/scene-process/service/component/utils.ts @@ -5,7 +5,6 @@ import { SphereCollider, BoxCollider, UITransformComponent, - PolygonCollider2D, MeshCollider, CapsuleCollider, CylinderCollider, @@ -185,10 +184,6 @@ class ComponentUtils { } }, - PolygonCollider2D(component: PolygonCollider2D, node: Node) { - //TODO: PhysicsUtils.resetPoints(component); - }, - Camera(component: Camera, node: Node) { const { Service } = require('../core/decorator'); if (Service.Camera?.is2D) { diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index f12a23ac8..c70b79ff2 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -1,5 +1,5 @@ import { PreviewBase } from './preview-base'; -import { scenePreview, ScenePreview } from './scene-preview'; +import { scenePreview } from './scene-preview'; import { MiniPreview } from './mini-preview'; import { MaterialPreview } from './material-preview'; import { ModelPreview } from './model-preview'; @@ -7,7 +7,9 @@ import { MeshPreview } from './mesh-preview'; import { SkeletonPreview } from './skeleton-preview'; import { PrefabPreview } from './prefab-preview'; import { SpinePreview } from './spine-preview'; +import { MotionPreview } from './motion-preview'; import { Camera, gfx } from 'cc'; +import type { MotionPreviewDesc } from '../../../common/preview'; import { BaseService, register, Service } from '../core'; import { Rpc } from '../../rpc'; import type { InteractivePreview } from './interactive-preview'; @@ -35,6 +37,7 @@ export class PreviewService extends BaseService implements IPrev skeletonPreview = new SkeletonPreview(); prefabPreview = new PrefabPreview(); spinePreview = new SpinePreview(); + motionPreview = new MotionPreview(); get activePreview(): IPreviewInstance | null { return this._activePreview; @@ -51,6 +54,7 @@ export class PreviewService extends BaseService implements IPrev this.initPreview('scene:skeleton-preview', 'query-skeleton-preview-data', this.skeletonPreview); this.initPreview('scene:prefab-preview', 'query-prefab-preview-data', this.prefabPreview); this.initPreview('scene:spine-preview', 'query-spine-preview-data', this.spinePreview); + this.initPreview('scene:motion-preview', 'query-motion-preview-data', this.motionPreview); this.initTypeMap(); console.log('[Preview] PreviewService initialized'); } @@ -73,8 +77,8 @@ export class PreviewService extends BaseService implements IPrev // importer name → preview type 的映射(用于 assetType 为 cc.Asset 等泛型的回退) private static readonly IMPORTER_MAP: Record = { - 'gltf': 'model', - 'fbx': 'model', + gltf: 'model', + fbx: 'model', 'spine-data': 'spine', }; @@ -110,6 +114,80 @@ export class PreviewService extends BaseService implements IPrev return false; } + // --- 通用 Motion 预览(透传到 motionPreview 实例) --- + + public async showMotion(desc: MotionPreviewDesc): Promise { + return this.motionPreview.showMotion(desc); + } + + public async hideMotion(): Promise { + // 结束未完成的相机手势,避免调用方在释放事件丢失后污染下一次预览。 + if (this.motionPreview.isActive) { + this.motionPreview.onMouseUp({ x: 0, y: 0 }); + } + await this.motionPreview.hideMotionPreview(); + } + + public async setMotionModel(uuid: string): Promise { + await this.motionPreview.setModel(uuid); + } + + public async setMotionTime(time: number): Promise { + await this.motionPreview.setTimeMotionPreview(time); + } + + public async playMotion(): Promise { + await this.motionPreview.playMotionPreview(); + } + + public async pauseMotion(): Promise { + await this.motionPreview.pauseMotionPreview(); + } + + public async stopMotion(): Promise { + await this.motionPreview.stopMotionPreview(); + } + + public async setMotionVariable(name: string, value: number): Promise { + await this.motionPreview.setMotionPreviewVariable(name, value); + } + + public async setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + await this.motionPreview.setMotionPreviewParameter(axis, value); + } + + public async getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { + return this.motionPreview.getMotionPreviewTimelineStats(); + } + + public async isMotionActive(): Promise { + return this.motionPreview.isActive; + } + + public async queryMotionImage(info: { width: number; height: number }): Promise { + return this.motionPreview.queryPreviewData(info); + } + + public async onMotionMouseDown(action: { x: number; y: number; button: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseDown(action); + } + + public async onMotionMouseMove(action: { movementX: number; movementY: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseMove(action); + } + + public async onMotionMouseUp(action: { x: number; y: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseUp(action); + } + + public async onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseWheel(action); + } + // --- 上屏预览 --- async open(uuid: string): Promise { diff --git a/src/core/scene/scene-process/service/preview/motion-preview.ts b/src/core/scene/scene-process/service/preview/motion-preview.ts new file mode 100644 index 000000000..0a5ccfc21 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/motion-preview.ts @@ -0,0 +1,419 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) SUD. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DirectionalLight, Node, Prefab, Scene, instantiate } from 'cc'; +import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; +import { loadPreviewAsset, removePreviewAssetCache } from './asset-reload'; +import { Rpc } from '../../rpc'; +import type { MotionPreviewDesc, MotionPreviewDescNode } from '../../../common/preview'; + +/** + * engine editor 模块:与动画剪辑预览一致的加载方式(scene-process 的 + * engine-bootstrap 已把 cc/editor/new-gen-anim 作为必须模块加载)。 + */ +function getNewGenAnim(): any { + return require('cc/editor/new-gen-anim'); +} + +/** + * 通用 Motion 预览器。 + * + * 只理解中立的 {@link MotionPreviewDesc}:由业务方把各自资产数据翻译成描述后传入, + * 本类负责加载预览 Prefab、按描述重建引擎 Motion、并驱动 `MotionPreviewer` + * 采样姿态到模型节点上;`queryPreviewData` 由外层按帧轮询取图。 + * + * ```mermaid + * sequenceDiagram + * participant PinK as PinK 主进程(Preview 代理) + * participant Preview as MotionPreview(scene-process) + * participant Engine as MotionPreviewer(cc/editor/new-gen-anim) + * PinK->>Preview: showMotion(desc) + * Preview->>Engine: new MotionPreviewer(modelNode) + setMotion(built motion) + * PinK->>Preview: setMotionTime / playMotion / pauseMotion / setMotionVariable + * Preview->>Engine: setTime(time) + evaluate() + * PinK->>Preview: queryPreviewData({width,height}) + * Preview-->>PinK: RGBA buffer(模型当前姿态帧) + * ``` + */ +export class MotionPreview extends InteractivePreview { + private lightComp: DirectionalLight | any; + private motionPreviewer: any = null; + private motionPreview: any = null; + private loadedClips = new Map(); + private active = false; + private playing = false; + private time = 0; + private lastPlayTick = 0; + // 最近一次外部时间下发(Inspector rAF 时钟经 show/play/stop/setTime 写入)的时间戳; + // headless 消费者(MCP/CLI 只轮询取帧)超过阈值未下发时,queryPreviewData 自推进。 + private lastExternalTimeAt = 0; + // 未等到模型时的待处理描述(调用方可能先下发 Motion 描述、再设置模型)。 + private pendingDesc: MotionPreviewDesc | null = null; + private operationVersion = 0; + + public createNodes(scene: Scene) { + this.lightComp = new Node('Motion Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.parent = scene; + } + + public get isActive(): boolean { + return this.active; + } + + public async setModel(uuid: string): Promise { + const operationVersion = ++this.operationVersion; + if (!uuid) { + throw new Error('Motion preview model UUID must be a non-empty string.'); + } + + const prefabUuid = await this._resolvePrefabUuid(uuid); + if (!prefabUuid) { + throw new Error(`Unable to preview model ${uuid}: the imported cc.Prefab sub-asset is unavailable.`); + } + + removePreviewAssetCache(uuid); + const prefabAsset = await loadPreviewAsset(prefabUuid, 'model', { reloadAsset: true }); + if (operationVersion !== this.operationVersion) { + return; + } + + const nextModelNode = instantiate(prefabAsset) as Node; + let nextMotionPreviewer: any; + const pending = this.pendingDesc; + let nextMotion: any = null; + let nextLoadedClips: Map | undefined; + try { + nextMotionPreviewer = this._createMotionPreviewer(nextModelNode); + if (pending) { + const prepared = await this._prepareMotion(pending); + if (operationVersion !== this.operationVersion) { + nextMotionPreviewer.destroy?.(); + nextModelNode.destroy(); + return; + } + nextMotion = prepared.motion; + nextLoadedClips = prepared.loadedClips; + this._configureMotionPreviewer(nextMotionPreviewer, pending); + nextMotionPreviewer.setMotion(nextMotion); + } + } catch (error) { + nextMotionPreviewer.destroy?.(); + nextModelNode.destroy(); + throw error; + } + + if (operationVersion !== this.operationVersion) { + nextMotionPreviewer.destroy?.(); + nextModelNode.destroy(); + return; + } + + this.motionPreviewer?.destroy?.(); + if (this._modelNode) { + this.scene.removeChild(this._modelNode); + if (this._modelNode.isValid) { + this._modelNode.destroy(); + } + } + + this._modelNode = nextModelNode; + this._modelNode.parent = this.scene; + + this.motionPreviewer = nextMotionPreviewer; + this.motionPreview = nextMotion; + if (pending) { + this.pendingDesc = null; + this.active = true; + this.loadedClips = nextLoadedClips ?? new Map(); + this.time = 0; + this._evaluate(); + } else { + this.active = false; + this.playing = false; + this.loadedClips = new Map(); + } + + this.cameraComp.enabled = true; + this.resetCameraView(); + } + + public async showMotion(desc: MotionPreviewDesc): Promise { + const operationVersion = ++this.operationVersion; + if (!this._modelNode) { + // 暂无模型:记住描述,等 setModel 后接入;返回 false 表示“等待模型”。 + this.pendingDesc = desc; + return false; + } + this.pendingDesc = null; + const prepared = await this._prepareMotion(desc); + if (operationVersion !== this.operationVersion) { + return false; + } + const nextMotionPreviewer = this._createMotionPreviewer(this._modelNode); + try { + this._configureMotionPreviewer(nextMotionPreviewer, desc); + nextMotionPreviewer.setMotion(prepared.motion); + } catch (error) { + nextMotionPreviewer.destroy?.(); + throw error; + } + if (operationVersion !== this.operationVersion) { + nextMotionPreviewer.destroy?.(); + return false; + } + this.motionPreviewer?.destroy?.(); + this.motionPreviewer = nextMotionPreviewer; + this.motionPreview = prepared.motion; + this.loadedClips = prepared.loadedClips; + this.active = true; + this.time = 0; + this.lastPlayTick = Date.now(); + this.lastExternalTimeAt = Date.now(); + this._evaluate(); + return true; + } + + public async hideMotionPreview(): Promise { + ++this.operationVersion; + this.pendingDesc = null; + this.active = false; + await this.pauseMotionPreview(); + if (this.motionPreviewer) { + this.motionPreviewer.destroy?.(); + this.motionPreviewer = null; + } + this.motionPreview = null; + this.hide(); + } + + public async playMotionPreview(): Promise { + if (!this.active) { + return; + } + this.playing = true; + this.lastPlayTick = Date.now(); + this.lastExternalTimeAt = Date.now(); + this._evaluate(); + } + + public async pauseMotionPreview(): Promise { + this.playing = false; + } + + public async stopMotionPreview(): Promise { + this.playing = false; + this.time = 0; + this.lastExternalTimeAt = Date.now(); + this._evaluate(); + } + + public async setTimeMotionPreview(time: number): Promise { + this.time = Math.max(0, time); + this.lastPlayTick = Date.now(); + this.lastExternalTimeAt = Date.now(); + this._evaluate(); + } + + /** + * 更新预览变量。变量实例由业务方随描述下发(静态值)或经本方法注入 + * MotionPreviewer(等价于引擎 updateVariable 语义)。 + */ + public async setMotionPreviewVariable(name: string, value: number): Promise { + if (!this.motionPreviewer) { + throw new Error('Motion preview is not active.'); + } + this.motionPreviewer.updateVariable(name, value); + this._evaluate(); + } + + /** 设置预览中 Blend Motion 的临时参数值,不回写任何资产。 */ + public async setMotionPreviewParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + if (!this.motionPreview || !Number.isFinite(value)) { + throw new Error('Motion preview parameter cannot be changed before a valid preview is active.'); + } + const api = getNewGenAnim(); + if (this.motionPreview instanceof api.AnimationBlend1D && axis === 'value') { + this.motionPreview.param.value = value; + } else if (this.motionPreview instanceof api.AnimationBlend2D) { + if (axis === 'x') { + this.motionPreview.paramX.value = value; + } else if (axis === 'y') { + this.motionPreview.paramY.value = value; + } else { + throw new Error(`Motion preview parameter axis '${axis}' is not valid for Blend 2D.`); + } + } else { + throw new Error(`Motion preview parameter axis '${axis}' is not valid for this Motion.`); + } + this._evaluate(); + } + + public getMotionPreviewTimelineStats(): { timeLineLength: number } | null { + return this.motionPreviewer?.timelineStats ?? null; + } + + public resetCameraView(): void { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.perfectCameraView(getBoundaryOfMeshNodes([this._modelNode])); + } + } + + public async queryPreviewData(info: { width: number; height: number }) { + if (this.playing && this.active && Date.now() - this.lastExternalTimeAt > 500) { + // Headless 兜底:Inspector 以外的消费者(MCP/CLI)只轮询取帧、不下发 setTime, + // 由场景进程按 wall-clock 推进;高频下发 setTime 的调用方存在时跳过, + // 避免两边各推进一次造成双倍播放速度。 + const now = Date.now(); + const delta = Math.max(0, (now - this.lastPlayTick) / 1000); + this.lastPlayTick = now; + if (delta > 0) { + this.time += delta; + this._evaluate(); + } + } + return super.queryPreviewData(info); + } + + /** 先完成所有资源加载,再创建 Motion,避免失败时破坏当前可用预览。 */ + private async _prepareMotion(desc: MotionPreviewDesc): Promise<{ motion: any; loadedClips: Map }> { + if (!desc?.motion) { + throw new Error(`Motion preview desc is empty, nothing to show.`); + } + + const loadedClips = new Map(); + await Promise.all( + Array.from(new Set(collectClipUuids(desc.motion))) + .filter(Boolean) + .map(async (clipUuid) => { + loadedClips.set(clipUuid, await loadPreviewAsset(clipUuid, 'animation-clip')); + }), + ); + + return { + motion: this._buildMotion(desc.motion, loadedClips), + loadedClips, + }; + } + + private _createMotionPreviewer(modelNode: Node): any { + const { MotionPreviewer } = getNewGenAnim(); + if (!MotionPreviewer) { + throw new Error('MotionPreviewer is not available in the engine module.'); + } + return new MotionPreviewer(modelNode); + } + + private _configureMotionPreviewer(previewer: any, desc: MotionPreviewDesc): void { + const api = getNewGenAnim(); + for (const variable of desc.variables ?? []) { + if (!variable.name) { + continue; + } + const value = typeof variable.value === 'number' && Number.isFinite(variable.value) ? variable.value : 0; + const description = api.createVariable(api.VariableType.FLOAT, value); + previewer.addVariable(variable.name, description); + } + } + + private _buildMotion(node: MotionPreviewDescNode | null | undefined, loadedClips: Map): any { + const api = getNewGenAnim(); + if (!node) { + return null; + } + switch (node.kind) { + case 'clip': { + const clipMotion = new api.ClipMotion(); + if (node.clipUuid) { + clipMotion.clip = loadedClips.get(node.clipUuid) ?? null; + } + return clipMotion; + } + case 'blend-1d': { + const blend = new api.AnimationBlend1D(); + blend.param.value = node.value; + blend.param.variable = node.variable ?? ''; + blend.items = (node.children ?? []).map((child) => { + const item = new api.AnimationBlend1D.Item(); + item.motion = this._buildMotion(child.motion, loadedClips); + item.threshold = child.threshold; + return item; + }); + return blend; + } + case 'blend-2d': { + const blend = new api.AnimationBlend2D(); + blend.paramX.value = node.valueX; + blend.paramX.variable = node.variableX ?? ''; + blend.paramY.value = node.valueY; + blend.paramY.variable = node.variableY ?? ''; + if (typeof node.algorithm === 'number') { + blend.algorithm = node.algorithm; + } + blend.items = (node.children ?? []).map((child) => { + const item = new api.AnimationBlend2D.Item(); + item.motion = this._buildMotion(child.motion, loadedClips); + item.threshold.set(child.threshold.x, child.threshold.y); + return item; + }); + return blend; + } + case 'blend-direct': { + const blend = new api.AnimationBlendDirect(); + blend.items = (node.children ?? []).map((child) => { + const item = new api.AnimationBlendDirect.Item(); + item.motion = this._buildMotion(child.motion, loadedClips); + item.weight.value = child.weight; + item.weight.variable = ''; + return item; + }); + return blend; + } + default: + return null; + } + } + + private _evaluate(): void { + if (!this.motionPreviewer) { + return; + } + this.motionPreviewer.setTime(this.time); + this.motionPreviewer.evaluate(); + } + + private async _resolvePrefabUuid(uuid: string): Promise { + const assetInfo = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [uuid, ['subAssets']]); + if (assetInfo?.type === 'cc.Prefab') { + return assetInfo.uuid || uuid; + } + for (const sub of Object.values(assetInfo?.subAssets || {})) { + if (sub?.type === 'cc.Prefab' || sub?.importer === 'gltf-scene') { + return sub.uuid; + } + } + return null; + } +} + +/** + * 收集 Motion 描述递归引用到的全部动画剪辑 uuid,供预览前并行加载。 + */ +function collectClipUuids(node: MotionPreviewDescNode | null | undefined, out: string[] = []): string[] { + if (!node) { + return out; + } + if (node.kind === 'clip') { + if (node.clipUuid) { + out.push(node.clipUuid); + } + return out; + } + for (const child of node.children) { + collectClipUuids(child.motion, out); + } + return out; +} diff --git a/src/core/scene/test/component-proxy-asset-validation.test.ts b/src/core/scene/test/component-proxy-asset-validation.test.ts index 71e94651c..1b6827478 100644 --- a/src/core/scene/test/component-proxy-asset-validation.test.ts +++ b/src/core/scene/test/component-proxy-asset-validation.test.ts @@ -135,4 +135,24 @@ describe('ComponentProxy Asset validation', () => { expect(setPropertyRequests()).toHaveLength(0); }); + + it('forwards PolygonCollider2D regeneration to the scene process', async () => { + const expected = { + path: '/Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 8, + source: 'sprite-alpha', + }; + mockRequest.mockResolvedValue(expected); + + await expect(ComponentProxy.regeneratePolygon2DPoints({ + path: '/Canvas/Polygon/cc.PolygonCollider2D', + record: false, + })).resolves.toEqual(expected); + + expect(mockRequest).toHaveBeenCalledWith('Component', 'regeneratePolygon2DPoints', [{ + path: '/Canvas/Polygon/cc.PolygonCollider2D', + record: false, + }]); + }); }); diff --git a/src/core/scene/test/polygon-collider-2d.test.ts b/src/core/scene/test/polygon-collider-2d.test.ts new file mode 100644 index 000000000..c6871cd34 --- /dev/null +++ b/src/core/scene/test/polygon-collider-2d.test.ts @@ -0,0 +1,409 @@ +export {}; + +const mockAssetRequest = jest.fn(); + +class MockVec2 { + constructor(public x = 0, public y = 0) {} +} + +class MockNode { + components: MockComponent[] = []; + + attach(component: T): T { + component.node = this; + this.components.push(component); + return component; + } + + getComponent(type: new (...args: any[]) => T): T | null { + return (this.components.find(component => component instanceof type) as T | undefined) ?? null; + } +} + +class MockComponent { + isValid = true; + node!: MockNode; +} + +class MockPolygonCollider2D extends MockComponent { + threshold = 1; + points = [ + new MockVec2(-1, -1), + new MockVec2(1, -1), + new MockVec2(1, 1), + new MockVec2(-1, 1), + ]; +} + +class MockSprite extends MockComponent { + spriteFrame: MockSpriteFrame | null = null; +} + +class MockSpriteFrame { + _uuid = 'texture-uuid@spriteFrame'; + texture = { _uuid: 'texture-uuid@texture' }; + original: { _texture: { _uuid: string } } | null = null; + + constructor( + private readonly rect = { x: 0, y: 0, width: 2, height: 2 }, + private readonly rotated = false, + ) {} + + getRect() { + return this.rect; + } + + isRotated() { + return this.rotated; + } +} + +class MockUITransform extends MockComponent { + contentSize = { width: 100, height: 100 }; + anchorX = 0.5; + anchorY = 0.5; +} + +jest.mock('cc', () => ({ + Component: MockComponent, + PolygonCollider2D: MockPolygonCollider2D, + Sprite: MockSprite, + UITransform: MockUITransform, + Vec2: MockVec2, + Physics2DUtils: { + PolygonSeparator: { + ForceCounterClockWise: jest.fn(), + }, + }, + js: { + getClassName: (ctor: { name?: string }) => ctor?.name || '', + }, +})); + +jest.mock('../scene-process/rpc', () => ({ + Rpc: { + getInstance: () => ({ request: mockAssetRequest }), + }, +})); + +const polygonModule = () => require('../scene-process/service/component/polygon-collider-2d') as typeof import('../scene-process/service/component/polygon-collider-2d'); + +describe('PolygonCollider2D regeneration helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAssetRequest.mockReset(); + }); + + it('uses UITransform size and anchor for the rectangle fallback', async () => { + const node = new MockNode(); + const transform = node.attach(new MockUITransform()); + transform.contentSize = { width: 200, height: 80 }; + transform.anchorX = 0.25; + transform.anchorY = 0.75; + const collider = node.attach(new MockPolygonCollider2D()); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result).toEqual({ + source: 'rect-fallback', + points: [ + { x: -50, y: -60 }, + { x: -50, y: 20 }, + { x: 150, y: 20 }, + { x: 150, y: -60 }, + ], + }); + }); + + it('uses a centered 100x100 rectangle when UITransform is absent', async () => { + const node = new MockNode(); + const collider = node.attach(new MockPolygonCollider2D()); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result).toMatchObject({ + source: 'rect-fallback', + points: [ + { x: -50, y: -50 }, + { x: -50, y: 50 }, + { x: 50, y: 50 }, + { x: 50, y: -50 }, + ], + }); + }); + + it('initializes a newly added collider with generated points', async () => { + const node = new MockNode(); + const transform = node.attach(new MockUITransform()); + transform.contentSize = { width: 40, height: 20 }; + const collider = node.attach(new MockPolygonCollider2D()); + + await polygonModule().initializePolygonCollider2DPoints(collider as any); + + expect(collider.points).toEqual([ + { x: -20, y: -10 }, + { x: -20, y: 10 }, + { x: 20, y: 10 }, + { x: 20, y: -10 }, + ]); + }); + + it('keeps engine defaults when add-time point generation fails', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockRejectedValue(new Error('decode failed')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(polygonModule().initializePolygonCollider2DPoints(collider as any)).resolves.toBeUndefined(); + + expect(collider.points).toBe(originalPoints); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('keeping the engine default points'), + ); + warn.mockRestore(); + }); + + it('generates Sprite Alpha points through the Node image RPC without changing the component directly', async () => { + const node = new MockNode(); + const transform = node.attach(new MockUITransform()); + transform.contentSize = { width: 200, height: 100 }; + transform.anchorX = 0.25; + transform.anchorY = 0.75; + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame({ x: 10, y: 20, width: 2, height: 2 }); + const collider = node.attach(new MockPolygonCollider2D()); + collider.threshold = 0; + const oldPoints = collider.points.slice(); + + const rgba = new Uint8Array(2 * 2 * 4); + for (let index = 3; index < rgba.length; index += 4) { + rgba[index] = 255; + } + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from(rgba).toString('base64'), + width: 2, + height: 2, + channels: 4, + }); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result.source).toBe('sprite-alpha'); + expect(result.points.length).toBeGreaterThanOrEqual(3); + expect(mockAssetRequest).toHaveBeenCalledWith('assetManager', 'extractImagePixels', [ + 'texture-uuid', + { + rect: { left: 10, top: 20, width: 2, height: 2 }, + rotation: 0, + }, + ]); + expect(collider.points).toEqual(oldPoints); + }); + + it('resolves the original texture UUID when a SpriteFrame is packed into a dynamic atlas', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + sprite.spriteFrame._uuid = 'sprite-frame-uuid@frame'; + sprite.spriteFrame.texture = { _uuid: 'dynamic-atlas-uuid@texture' }; + sprite.spriteFrame.original = { _texture: { _uuid: 'texture-uuid@texture' } }; + const collider = node.attach(new MockPolygonCollider2D()); + collider.threshold = 0; + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from(new Uint8Array(2 * 2 * 4)).toString('base64'), + width: 2, + height: 2, + channels: 4, + }); + + await polygonModule().generatePolygonPoints(collider as any); + + expect(mockAssetRequest).toHaveBeenCalledWith('assetManager', 'extractImagePixels', [ + 'texture-uuid', + expect.any(Object), + ]); + }); + + it('keeps one closing point for a thin opaque contour', () => { + const { traceAlphaContour } = require('../scene-process/service/component/polygon-collider-2d/contour'); + const rgba = new Uint8Array(4); + rgba[3] = 255; + + const contour = traceAlphaContour(rgba, 1, 1, true); + + expect(contour[0]).toEqual(contour[contour.length - 1]); + expect(contour[contour.length - 2]).not.toEqual(contour[0]); + }); + + it('initializes a thin Sprite Alpha collider with at least three points at the default threshold', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame({ x: 0, y: 0, width: 1, height: 1 }); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from([0, 0, 0, 255]).toString('base64'), + width: 1, + height: 1, + channels: 4, + }); + + await polygonModule().initializePolygonCollider2DPoints(collider as any); + + expect(collider.points).not.toBe(originalPoints); + expect(collider.points.length).toBeGreaterThanOrEqual(3); + }); + + it('keeps the Editor Marching Squares and RDP behavior in pure helpers', () => { + const { traceAlphaContour } = require('../scene-process/service/component/polygon-collider-2d/contour'); + const { simplifyContour } = require('../scene-process/service/component/polygon-collider-2d/simplify'); + const rgba = new Uint8Array(2 * 2 * 4); + for (let index = 3; index < rgba.length; index += 4) { + rgba[index] = 255; + } + + const contour = traceAlphaContour(rgba, 2, 2, true); + const simplified = simplifyContour(contour, 0); + + expect(contour[0]).toEqual(contour[contour.length - 1]); + expect(simplified.length).toBeGreaterThanOrEqual(4); + }); + + it('swaps the extraction size and rotates packed SpriteFrames', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame({ x: 3, y: 4, width: 2, height: 3 }, true); + const collider = node.attach(new MockPolygonCollider2D()); + collider.threshold = 0; + + const rgba = new Uint8Array(2 * 3 * 4); + for (let index = 3; index < rgba.length; index += 4) { + rgba[index] = 255; + } + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from(rgba).toString('base64'), + width: 2, + height: 3, + channels: 4, + }); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result.source).toBe('sprite-alpha'); + expect(mockAssetRequest).toHaveBeenCalledWith('assetManager', 'extractImagePixels', [ + 'texture-uuid', + { + rect: { left: 3, top: 4, width: 3, height: 2 }, + rotation: 90, + }, + ]); + }); + + it('falls back to the UITransform rectangle when the SpriteFrame source asset cannot be resolved', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockResolvedValue(null); + + await expect(polygonModule().generatePolygonPoints(collider as any)).resolves.toEqual({ + source: 'rect-fallback', + points: [ + { x: -50, y: -50 }, + { x: -50, y: 50 }, + { x: 50, y: 50 }, + { x: 50, y: -50 }, + ], + }); + + expect(collider.points).toBe(originalPoints); + }); + + it('adds SpriteFrame and source asset context when image extraction fails', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockRejectedValue(new Error('decode failed')); + + await expect(polygonModule().generatePolygonPoints(collider as any)) + .rejects.toThrow( + 'Failed to read source image pixels for SpriteFrame "texture-uuid@spriteFrame" from asset "texture-uuid": decode failed', + ); + + expect(collider.points).toBe(originalPoints); + }); + + it('validates point count, finite coordinates and cyclic adjacent duplicates', () => { + const { validatePolygonPoints } = polygonModule(); + + expect(() => validatePolygonPoints([new MockVec2(), new MockVec2(1, 0)] as any)) + .toThrow('at least 3 points'); + expect(() => validatePolygonPoints([ + new MockVec2(0, 0), + new MockVec2(Number.NaN, 0), + new MockVec2(0, 1), + ] as any)).toThrow('non-finite coordinate'); + expect(() => validatePolygonPoints([ + new MockVec2(0, 0), + new MockVec2(1, 0), + new MockVec2(0, 0), + ] as any)).toThrow('adjacent duplicates'); + expect(() => validatePolygonPoints([ + new MockVec2(0, 0), + new MockVec2(1, 0), + new MockVec2(0, 1), + ] as any)).not.toThrow(); + }); + + it('encodes candidate Vec2 values with the existing points element template', () => { + const { createPolygonPointsPropertyDump } = polygonModule(); + const property = { + name: 'points', + path: '', + type: 'cc.Vec2', + isArray: true, + elementTypeData: { + name: '', + path: '', + type: 'cc.Vec2', + value: { x: 0, y: 0 }, + }, + value: [], + }; + + const result = createPolygonPointsPropertyDump(property, [ + new MockVec2(-2, 3), + new MockVec2(4, 5), + new MockVec2(6, -7), + ] as any); + + expect(result?.value).toEqual([ + expect.objectContaining({ name: '0', type: 'cc.Vec2', value: { x: -2, y: 3 } }), + expect.objectContaining({ name: '1', type: 'cc.Vec2', value: { x: 4, y: 5 } }), + expect.objectContaining({ name: '2', type: 'cc.Vec2', value: { x: 6, y: -7 } }), + ]); + expect(property.value).toEqual([]); + }); + + it('distinguishes missing and wrong component types', () => { + const { requirePolygonCollider2D } = polygonModule(); + const wrong = new MockComponent(); + + expect(() => requirePolygonCollider2D(null, '/PolygonNode/cc.PolygonCollider2D')) + .toThrow('PolygonCollider2D component not found'); + expect(() => requirePolygonCollider2D(wrong as any, '/PolygonNode/cc.Label')) + .toThrow('Parameter error: component is not cc.PolygonCollider2D'); + }); +}); diff --git a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts index 2891db226..47b7ad460 100644 --- a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts +++ b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts @@ -2,20 +2,63 @@ const mockLock = jest.fn(async () => undefined); const mockUnlock = jest.fn(); const mockGetRootNode = jest.fn(); const mockGetNodeByPath = jest.fn(); +const mockGetNodePath = jest.fn(); const mockCreateShouldHideInHierarchyCanvasNode = jest.fn(); const mockOnComponentAddedFromEditor = jest.fn(); +const mockQueryFromPath = jest.fn(); const mockDumpComponent = jest.fn(() => ({ value: {} })); const mockCaptureMany = jest.fn(() => null); const mockGetClassById = jest.fn(() => null); -const mockGetClassByName = jest.fn((name: string) => name === 'cc.UITransform' ? MockUITransform : null); +const mockGetClassByName = jest.fn((name: string): any => { + if (name === 'cc.UITransform') { + return MockUITransform; + } + if (name === 'cc.PolygonCollider2D') { + return MockPolygonCollider2D; + } + return null; +}); const mockIsChildClassOf = jest.fn(() => true); +const mockAssetRequest = jest.fn(); const mockScene = { name: 'Scene' }; class MockCanvas {} class MockComponent { uuid = `component-${Math.random()}`; + isValid = true; + node!: MockNode; +} +class MockUITransform extends MockComponent { + contentSize = { width: 100, height: 100 }; + anchorX = 0.5; + anchorY = 0.5; +} +class MockVec2 { + constructor(public x = 0, public y = 0) {} +} +class MockSprite extends MockComponent { + spriteFrame: MockSpriteFrame | null = null; +} +class MockSpriteFrame { + _uuid = 'texture-uuid@spriteFrame'; + + getRect() { + return { x: 0, y: 0, width: 2, height: 2 }; + } + + isRotated() { + return false; + } +} +class MockPolygonCollider2D extends MockComponent { + threshold = 1; + points = [ + new MockVec2(-1, -1), + new MockVec2(1, -1), + new MockVec2(1, 1), + new MockVec2(-1, 1), + ]; } -class MockUITransform extends MockComponent {} class MockNode { uuid: string; @@ -25,6 +68,7 @@ class MockNode { components: any[] = []; addComponent = jest.fn((component: any) => { const instance = component === 'cc.UITransform' ? new MockUITransform() : new component(); + instance.node = this; this.components.push(instance); return instance; }); @@ -47,6 +91,7 @@ class MockNode { (global as any).EditorExtends = { Node: { getNodeByPath: mockGetNodeByPath, + getNodePath: mockGetNodePath, }, }; @@ -62,12 +107,21 @@ jest.mock('cc', () => ({ EColliderType: {}, MeshCollider: class MeshCollider {}, Node: MockNode, + PolygonCollider2D: MockPolygonCollider2D, RigidBody: class RigidBody {}, Scene: class Scene {}, + Sprite: MockSprite, UITransform: MockUITransform, + Vec2: MockVec2, + Physics2DUtils: { + PolygonSeparator: { + ForceCounterClockWise: jest.fn(), + }, + }, js: { getClassById: mockGetClassById, getClassByName: mockGetClassByName, + getClassName: (ctor: { name?: string }) => ctor?.name || '', isChildClassOf: mockIsChildClassOf, }, })); @@ -95,17 +149,23 @@ jest.mock('../../scene-process/service/core', () => ({ })); jest.mock('../../scene-process/rpc', () => ({ - Rpc: { getInstance: () => ({ request: jest.fn() }) }, + Rpc: { getInstance: () => ({ request: mockAssetRequest }) }, })); jest.mock('../../scene-process/service/dump', () => ({ __esModule: true, - default: { dumpComponent: mockDumpComponent }, + default: { + dumpComponent: mockDumpComponent, + restoreProperty: jest.fn(async () => undefined), + }, })); jest.mock('../../scene-process/service/component/index', () => ({ __esModule: true, - default: { onComponentAddedFromEditor: mockOnComponentAddedFromEditor }, + default: { + onComponentAddedFromEditor: mockOnComponentAddedFromEditor, + queryFromPath: mockQueryFromPath, + }, })); jest.mock('../../scene-process/service/component/utils', () => ({ @@ -165,7 +225,9 @@ describe('ComponentService prefab UI handling', () => { root.parent = new MockNode('Scene'); mockGetRootNode.mockReturnValue(root); mockGetNodeByPath.mockReturnValue(new MockNode('Target')); + mockGetNodePath.mockReturnValue('/Target'); mockCreateShouldHideInHierarchyCanvasNode.mockResolvedValue(new MockNode('PreviewCanvas')); + mockAssetRequest.mockReset(); }); async function addUITransform(params: Record = {}) { @@ -191,6 +253,40 @@ describe('ComponentService prefab UI handling', () => { expect(mockOnComponentAddedFromEditor).toHaveBeenCalledTimes(1); }); + it('notifies component creation before waiting for PolygonCollider2D points, but delays dumping', async () => { + const target = new MockNode('Target'); + target.addComponent(MockUITransform); + const sprite = target.addComponent(MockSprite) as MockSprite; + sprite.spriteFrame = new MockSpriteFrame(); + mockGetNodeByPath.mockReturnValue(target); + let rejectImageExtraction!: (reason: Error) => void; + mockAssetRequest.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectImageExtraction = reject; + })); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const adding = service.add({ + nodePath: '/Target', + component: 'cc.PolygonCollider2D', + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(mockAssetRequest).toHaveBeenCalled(); + expect(mockOnComponentAddedFromEditor).toHaveBeenCalledWith(expect.any(MockPolygonCollider2D)); + expect(mockDumpComponent).not.toHaveBeenCalled(); + expect(mockCaptureMany).not.toHaveBeenCalled(); + + rejectImageExtraction(new Error('decode failed')); + await expect(adding).resolves.toBeDefined(); + + expect(mockOnComponentAddedFromEditor).toHaveBeenCalledTimes(1); + expect(mockDumpComponent).toHaveBeenCalledTimes(1); + expect(mockCaptureMany).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + it('does not create a hidden Canvas when the prefab root already has Canvas', async () => { const root = mockGetRootNode(); @@ -217,4 +313,121 @@ describe('ComponentService prefab UI handling', () => { expect(mockCreateShouldHideInHierarchyCanvasNode).toHaveBeenCalledTimes(2); expect(root.addComponent).not.toHaveBeenCalledWith('cc.UITransform'); }); + + describe('PolygonCollider2D regeneration', () => { + function createColliderFixture() { + const node = new MockNode('PolygonNode'); + const transform = new MockUITransform(); + transform.node = node; + transform.contentSize = { width: 200, height: 80 }; + transform.anchorX = 0.25; + transform.anchorY = 0.75; + const collider = new MockPolygonCollider2D(); + collider.node = node; + node.components.push(transform, collider); + return { node, collider }; + } + + it('commits the generated points through the real component index and setProperty', async () => { + const { node, collider } = createColliderFixture(); + mockQueryFromPath.mockReturnValue(collider); + mockGetNodePath.mockReturnValue('/PolygonNode'); + mockDumpComponent.mockReturnValue({ + value: { + points: { + name: 'points', + path: '', + type: 'cc.Vec2', + isArray: true, + elementTypeData: { + name: '', + path: '', + type: 'cc.Vec2', + value: { x: 0, y: 0 }, + }, + value: [], + }, + }, + }); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const setProperty = jest.spyOn(service, 'setProperty').mockResolvedValue(true); + + const result = await service.regeneratePolygon2DPoints({ + path: '/PolygonNode/cc.PolygonCollider2D', + record: false, + }); + + expect(result).toEqual({ + path: '/PolygonNode/cc.PolygonCollider2D', + changed: true, + pointCount: 4, + source: 'rect-fallback', + }); + expect(setProperty).toHaveBeenCalledWith(expect.objectContaining({ + nodePath: '/PolygonNode', + path: '__comps__.1.points', + record: false, + dump: expect.objectContaining({ + value: [ + expect.objectContaining({ value: { x: -50, y: -60 } }), + expect.objectContaining({ value: { x: -50, y: 20 } }), + expect.objectContaining({ value: { x: 150, y: 20 } }), + expect.objectContaining({ value: { x: 150, y: -60 } }), + ], + }), + })); + expect(node.components.indexOf(collider)).toBe(1); + expect(mockLock).toHaveBeenCalled(); + expect(mockUnlock).toHaveBeenCalled(); + }); + + it('does not call setProperty when the generated rectangle is unchanged', async () => { + const { collider } = createColliderFixture(); + const transform = collider.node.components[0] as MockUITransform; + transform.contentSize = { width: 2, height: 2 }; + transform.anchorX = 0.5; + transform.anchorY = 0.5; + collider.points = [ + new MockVec2(-1, -1), + new MockVec2(-1, 1), + new MockVec2(1, 1), + new MockVec2(1, -1), + ]; + mockQueryFromPath.mockReturnValue(collider); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const setProperty = jest.spyOn(service, 'setProperty').mockResolvedValue(true); + + const result = await service.regeneratePolygon2DPoints({ + path: '/PolygonNode/cc.PolygonCollider2D', + }); + + expect(result).toMatchObject({ changed: false, pointCount: 4 }); + expect(setProperty).not.toHaveBeenCalled(); + }); + + it('propagates generation errors and still releases the editor lock', async () => { + const { node, collider } = createColliderFixture(); + const sprite = new MockSprite(); + sprite.node = node; + sprite.spriteFrame = new MockSpriteFrame(); + node.components.splice(1, 0, sprite); + mockQueryFromPath.mockReturnValue(collider); + mockAssetRequest.mockRejectedValue(new Error('decode failed')); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const setProperty = jest.spyOn(service, 'setProperty').mockResolvedValue(true); + + await expect(service.regeneratePolygon2DPoints({ + path: '/PolygonNode/cc.PolygonCollider2D', + })).rejects.toThrow('decode failed'); + + expect(setProperty).not.toHaveBeenCalled(); + expect(mockUnlock).toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index ac635b463..4db6a48ce 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -1,4 +1,4 @@ -import type { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphPoseGraphAssetDragHandlersEntry, 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 { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphMotionPreviewData, AnimationGraphPoseGraphAssetDragHandlersEntry, 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'; @@ -222,6 +222,13 @@ export const animationGraph = { return assetManager.queryAnimationGraphInspector(uuidOrUrlOrPath, target); }, + queryMotionPreviewData( + uuidOrUrlOrPath: string, + target: Extract, + ): Promise { + return assetManager.queryAnimationGraphMotionPreviewData(uuidOrUrlOrPath, target); + }, + queryPoseGraphAssetDragHandlers(): Promise { return assetManager.queryAnimationGraphPoseGraphAssetDragHandlers(); }, diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index a7903ce61..2d0b7ba39 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -1,10 +1,11 @@ -import { init as sceneInit } from '../../core/scene'; +import { init as sceneInit, Scene } from '../../core/scene'; import { GlobalPaths } from '../../global'; import { Rpc } from '../../core/scene/main-process/rpc'; import type { ISceneCommandProvider, SceneCommandProviderRegistration, } from '../../core/scene/main-process/rpc'; +import type { MotionPreviewDesc } from '../../core/scene/common/preview'; export type { ISceneCommandProvider, @@ -42,3 +43,87 @@ export function setCommandProvider( export function resetCommandProvider(): void { Rpc.resetCommandProvider(); } + +// ==================== 通用 Motion 预览 ==================== +// 将 scene-process PreviewService 的 Motion 门面经场景进程 RPC 暴露给 PinK。 +// 方法名与 IMotionPreviewService 一一对应,供主进程 cocosHostScene 通道透传调用。 + +/** 显示指定 Motion 描述的采样预览。 @param desc 中立的 Motion 预览描述。 @returns 是否已成功显示预览。 */ +export async function showMotion(desc: MotionPreviewDesc): Promise { + return Scene.Preview.showMotion(desc); +} + +/** 隐藏当前 Motion 预览。 */ +export function hideMotion(): Promise { + return Scene.Preview.hideMotion(); +} + +/** 为 Motion 预览设置展示模型资源。 @param uuid 模型资源 UUID。 */ +export async function setMotionModel(uuid: string): Promise { + return Scene.Preview.setMotionModel(uuid); +} + +/** 设置 Motion 预览的采样时间。 @param time 时间(秒)。 */ +export function setMotionTime(time: number): Promise { + return Scene.Preview.setMotionTime(time); +} + +/** 播放 Motion 预览。 */ +export function playMotion(): Promise { + return Scene.Preview.playMotion(); +} + +/** 暂停 Motion 预览。 */ +export function pauseMotion(): Promise { + return Scene.Preview.pauseMotion(); +} + +/** 停止 Motion 预览。 */ +export function stopMotion(): Promise { + return Scene.Preview.stopMotion(); +} + +/** 设置 Motion 预览使用的变量值。 @param name 变量名。 @param value 变量值。 */ +export function setMotionVariable(name: string, value: number): Promise { + return Scene.Preview.setMotionVariable(name, value); +} + +/** 设置 Motion 预览中 Blend 参数的临时值,不回写任何资产。 @param axis Blend 1D 使用 value,Blend 2D 使用 x/y。 @param value 参数值。 */ +export function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + return Scene.Preview.setMotionParameter(axis, value); +} + +/** 查询 Motion 预览时间轴长度。 @returns 当前 Motion 的时间轴统计,尚未建立预览时返回 null。 */ +export function getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { + return Scene.Preview.getMotionTimelineStats(); +} + +/** 查询当前是否有活跃的 Motion 预览。 @returns 存在返回 true。 */ +export async function isMotionActive(): Promise { + return Scene.Preview.isMotionActive(); +} + +/** 查询当前 Motion 预览的渲染图像帧。 @param info 图像尺寸。 @returns 图像帧数据。 */ +export function queryMotionImage(info: { width: number; height: number }): Promise { + return Scene.Preview.queryMotionImage(info); +} + +/** 转发预览相机左键/中键按下(轨道旋转 / 中键平移)。 @param action 事件参数(相对画布坐标与按键)。 */ +export function onMotionMouseDown(action: { x: number; y: number; button: number }): Promise { + return Scene.Preview.onMotionMouseDown(action); +} + +/** 转发预览相机鼠标移动(轨道旋转 / 平移)。 @param action 事件参数(相对位移)。 */ +export function onMotionMouseMove(action: { movementX: number; movementY: number }): Promise { + return Scene.Preview.onMotionMouseMove(action); +} + +/** 转发预览相机鼠标释放。 @param action 事件参数(相对画布坐标)。 */ +export function onMotionMouseUp(action: { x: number; y: number }): Promise { + return Scene.Preview.onMotionMouseUp(action); +} + +/** 转发预览相机滚轮缩放。 @param action 事件参数(滚轮增量)。 */ +export function onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise { + return Scene.Preview.onMotionMouseWheel(action); +} diff --git a/tests/component-polygon-regenerate-api.test.ts b/tests/component-polygon-regenerate-api.test.ts new file mode 100644 index 000000000..ad63ca219 --- /dev/null +++ b/tests/component-polygon-regenerate-api.test.ts @@ -0,0 +1,110 @@ +const mockRegeneratePolygon2DPoints = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/scene', () => ({ + Scene: { + Component: { + regeneratePolygon2DPoints: (...args: unknown[]) => mockRegeneratePolygon2DPoints(...args), + }, + }, +})); + +import { ComponentApi } from '../src/api/scene/component'; +import { + SchemaRegeneratePolygon2DPointsOptions, + SchemaRegeneratePolygon2DPointsResult, +} from '../src/api/scene/component-schema'; +import { HTTP_STATUS } from '../src/api/base/schema-base'; + +describe('PolygonCollider2D regeneration MCP API', () => { + beforeEach(() => { + mockRegeneratePolygon2DPoints.mockReset(); + }); + + it('validates the dedicated input and result schemas', () => { + expect(SchemaRegeneratePolygon2DPointsOptions.parse({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + record: false, + })).toEqual({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + record: false, + }); + expect(() => SchemaRegeneratePolygon2DPointsOptions.parse({ path: '' })).toThrow(); + + expect(SchemaRegeneratePolygon2DPointsResult.parse({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 4, + source: 'rect-fallback', + })).toEqual({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 4, + source: 'rect-fallback', + }); + expect(() => SchemaRegeneratePolygon2DPointsResult.parse({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 2, + source: 'unknown', + })).toThrow(); + }); + + it('forwards to the public scene API and returns the regeneration result', async () => { + const options = { path: 'Canvas/Polygon/cc.PolygonCollider2D', record: false }; + const regenerationResult = { + path: options.path, + changed: true, + pointCount: 8, + source: 'sprite-alpha' as const, + }; + mockRegeneratePolygon2DPoints.mockResolvedValue(regenerationResult); + + const result = await new ComponentApi().regeneratePolygon2DPoints(options); + + expect(mockRegeneratePolygon2DPoints).toHaveBeenCalledWith(options); + expect(result).toEqual({ code: HTTP_STATUS.OK, data: regenerationResult }); + }); + + it('maps an invalid component type to 400', async () => { + mockRegeneratePolygon2DPoints.mockRejectedValue( + new Error('Parameter error: component is not cc.PolygonCollider2D: Canvas/Polygon/cc.Label'), + ); + + const result = await new ComponentApi().regeneratePolygon2DPoints({ + path: 'Canvas/Polygon/cc.Label', + }); + + expect(result.code).toBe(HTTP_STATUS.BAD_REQUEST); + expect(result.reason).toContain('component is not cc.PolygonCollider2D'); + }); + + it('maps a missing component to 404', async () => { + mockRegeneratePolygon2DPoints.mockRejectedValue( + new Error('PolygonCollider2D component not found: Canvas/Polygon/cc.PolygonCollider2D'), + ); + + const result = await new ComponentApi().regeneratePolygon2DPoints({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + }); + + expect(result.code).toBe(HTTP_STATUS.NOT_FOUND); + }); + + it('maps image processing failures to 500', async () => { + mockRegeneratePolygon2DPoints.mockRejectedValue(new Error('Failed to read source image pixels')); + + const result = await new ComponentApi().regeneratePolygon2DPoints({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + }); + + expect(result.code).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR); + }); +});