Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/api/assets/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,26 @@ import {
TUUIDOrPath,
TUrlOrUUID,
TUrlOrPath,
SchemaAnimationMaskDump,
SchemaAnimationMaskChanges,
SchemaVoidResult,
TAnimationMaskDump,
TAnimationMaskChanges,
TVoidResult,
} from './schema';
import { z } from 'zod';
import { description, param, result, title, tool } from '../decorator/decorator.js';
import { COMMON_STATUS, CommonResultType, getCommonErrorStatus, HttpStatusCode } from '../base/schema-base';
import { assetDBManager, assetManager } from '../../core/assets';
import { IAssetInfo } from '../../core/assets/@types/public';
import { SchemaUrlOrPath, SchemaUrlOrUUID, SchemaUUIDOrPath } from '../base/schema-identifier';
import {
changeAnimationMaskDump as changeAnimationMaskDumpCore,
clearAnimationMaskNodes as clearAnimationMaskNodesCore,
importAnimationMaskSkeleton as importAnimationMaskSkeletonCore,
queryAnimationMask as queryAnimationMaskCore,
saveAnimationMask as saveAnimationMaskCore,
} from '../../core/assets/animation-mask';

export class AssetsApi {

Expand Down Expand Up @@ -408,6 +421,117 @@ export class AssetsApi {
return ret;
}

@tool('assets-animation-mask-query')
@title('Query Animation Mask')
@description('Query a .animask AnimationMask asset and return a stable DTO containing joint paths, enabled states, and tree structure. This tool does not expose Creator inspector reflection dump.')
@result(SchemaAnimationMaskDump)
async queryAnimationMask(@param(SchemaUrlOrUUIDOrPath) uuid: TUrlOrUUIDOrPath): Promise<CommonResultType<TAnimationMaskDump | null>> {
const ret: CommonResultType<TAnimationMaskDump | null> = {
code: COMMON_STATUS.SUCCESS,
data: null,
};

try {
ret.data = await queryAnimationMaskCore(uuid);
} catch (e) {
ret.code = getCommonErrorStatus(e);
console.error('query animation mask fail:', e instanceof Error ? e.message : String(e));
ret.reason = e instanceof Error ? e.message : String(e);
}

return ret;
}

@tool('assets-animation-mask-import-skeleton')
@title('Import Animation Mask Skeleton')
@description('Import joint paths from a Prefab or glTF-scene asset into an AnimationMask. Existing joint states are preserved and missing paths are appended as enabled. Pass the glTF-scene sub-asset UUID when possible.')
@result(SchemaAnimationMaskDump)
async importAnimationMaskSkeleton(
@param(SchemaUrlOrUUIDOrPath) uuid: TUrlOrUUIDOrPath,
@param(SchemaUrlOrUUIDOrPath) skeletonSourceUuid: TUrlOrUUIDOrPath
): Promise<CommonResultType<TAnimationMaskDump | null>> {
const ret: CommonResultType<TAnimationMaskDump | null> = {
code: COMMON_STATUS.SUCCESS,
data: null,
};

try {
ret.data = await importAnimationMaskSkeletonCore(uuid, skeletonSourceUuid);
} catch (e) {
ret.code = getCommonErrorStatus(e);
console.error('import animation mask skeleton fail:', e instanceof Error ? e.message : String(e));
ret.reason = e instanceof Error ? e.message : String(e);
}

return ret;
}

@tool('assets-animation-mask-clear-nodes')
@title('Clear Animation Mask Nodes')
@description('Clear all joint paths from an AnimationMask asset and return the updated stable DTO.')
@result(SchemaAnimationMaskDump)
async clearAnimationMaskNodes(@param(SchemaUrlOrUUIDOrPath) uuid: TUrlOrUUIDOrPath): Promise<CommonResultType<TAnimationMaskDump | null>> {
const ret: CommonResultType<TAnimationMaskDump | null> = {
code: COMMON_STATUS.SUCCESS,
data: null,
};

try {
ret.data = await clearAnimationMaskNodesCore(uuid);
} catch (e) {
ret.code = getCommonErrorStatus(e);
console.error('clear animation mask nodes fail:', e instanceof Error ? e.message : String(e));
ret.reason = e instanceof Error ? e.message : String(e);
}

return ret;
}

@tool('assets-animation-mask-change-dump')
@title('Change Animation Mask Dump')
@description('Apply path-based changes to an AnimationMask stable DTO. recursive defaults to false; pass recursive=true to update descendant paths.')
@result(SchemaAnimationMaskDump)
async changeAnimationMaskDump(
@param(SchemaUrlOrUUIDOrPath) uuid: TUrlOrUUIDOrPath,
@param(SchemaAnimationMaskChanges) changes: TAnimationMaskChanges
): Promise<CommonResultType<TAnimationMaskDump | null>> {
const ret: CommonResultType<TAnimationMaskDump | null> = {
code: COMMON_STATUS.SUCCESS,
data: null,
};

try {
ret.data = await changeAnimationMaskDumpCore(uuid, changes);
} catch (e) {
ret.code = getCommonErrorStatus(e);
console.error('change animation mask dump fail:', e instanceof Error ? e.message : String(e));
ret.reason = e instanceof Error ? e.message : String(e);
}

return ret;
}

@tool('assets-animation-mask-save')
@title('Save Animation Mask')
@description('Normalize and save the current AnimationMask asset content, then reimport the asset.')
@result(SchemaVoidResult)
async saveAnimationMask(@param(SchemaUrlOrUUIDOrPath) uuid: TUrlOrUUIDOrPath): Promise<CommonResultType<TVoidResult>> {
const ret: CommonResultType<TVoidResult> = {
code: COMMON_STATUS.SUCCESS,
data: null,
};

try {
await saveAnimationMaskCore(uuid);
} catch (e) {
ret.code = getCommonErrorStatus(e);
console.error('save animation mask fail:', e instanceof Error ? e.message : String(e));
ret.reason = e instanceof Error ? e.message : String(e);
}

return ret;
}

/**
* Query Asset UUID // 查询资源 UUID
*/
Expand Down
19 changes: 19 additions & 0 deletions src/api/assets/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ export const SchemaRefreshDirResult = z.null().describe('Refresh asset directory
export const SchemaUUIDResult = z.string().nullable().describe('Unique identifier UUID of the asset'); // 资源的唯一标识符 UUID
export const SchemaPathResult = z.string().nullable().describe('File system path of the asset'); // 资源的文件系统路径
export const SchemaUrlResult = z.string().nullable().describe('Database URL address of the asset'); // 资源的数据库 URL 地址
export const SchemaAnimationMaskJoint: z.ZodType<any> = z.lazy(() => z.object({
path: z.string().min(1).describe('Joint path relative to the skeleton root, for example "spine/leftArm"'), // 相对骨骼根节点的关节路径
enabled: z.boolean().describe('Whether this joint is enabled in the animation mask'), // 关节是否启用
children: z.array(SchemaAnimationMaskJoint).optional().describe('Child joint masks'), // 子关节遮罩
}));
export const SchemaAnimationMaskDump = z.object({
version: z.literal(1).describe('Animation mask DTO schema version'), // DTO 版本
assetUuid: z.string().min(1).describe('AnimationMask asset UUID'), // AnimationMask 资源 UUID
joints: z.array(SchemaAnimationMaskJoint).describe('Animation mask joint tree'), // 关节遮罩树
}).describe('Stable AnimationMask DTO for panel and CLI consumption'); // 面板/CLI 专用稳定 AnimationMask DTO
export const SchemaAnimationMaskChanges = z.array(z.object({
path: z.string().min(1).describe('Joint path to update'), // 要更新的关节路径
enabled: z.boolean().describe('New enabled state'), // 新的启用状态
recursive: z.boolean().optional().describe('Whether to apply the change to descendant joint paths. Defaults to false.'), // 是否递归更新子树
})).describe('AnimationMask path patch list'); // AnimationMask 路径 patch 列表
export const SchemaVoidResult = z.null().describe('No result data'); // 无返回数据

// Asset operation related // 资源操作相关
export const SchemaQueryAssetType = z.enum(['asset', 'script', 'all']).describe('Query asset type: asset (normal asset), script (script), all (all)'); // 查询资源类型:asset(普通资源)、script(脚本)、all(全部)
Expand Down Expand Up @@ -241,6 +257,9 @@ export type TRefreshDirResult = z.infer<typeof SchemaRefreshDirResult>;
export type TUUIDResult = z.infer<typeof SchemaUUIDResult>;
export type TPathResult = z.infer<typeof SchemaPathResult>;
export type TUrlResult = z.infer<typeof SchemaUrlResult>;
export type TAnimationMaskDump = z.infer<typeof SchemaAnimationMaskDump>;
export type TAnimationMaskChanges = z.infer<typeof SchemaAnimationMaskChanges>;
export type TVoidResult = z.infer<typeof SchemaVoidResult>;
export type TQueryAssetType = z.infer<typeof SchemaQueryAssetType>;
export type TFilterPluginOptions = z.infer<typeof SchemaFilterPluginOptions>;
export type TPluginScriptInfo = z.infer<typeof SchemaPluginScriptInfo>;
Expand Down
18 changes: 18 additions & 0 deletions src/core/assets/@types/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ export interface DeleteAssetOptions {
useTrash?: boolean;
}

export interface AnimationMaskDump {
version: 1;
assetUuid: string;
joints: AnimationMaskJoint[];
}

export interface AnimationMaskJoint {
path: string;
enabled: boolean;
children?: AnimationMaskJoint[];
}

export interface AnimationMaskChange {
path: string;
enabled: boolean;
recursive?: boolean;
}

// Basic information about the resource
// 资源的基础信息
export interface AssetInfo extends IAssetInfo {
Expand Down
168 changes: 168 additions & 0 deletions src/core/assets/animation-mask-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { AnimationMaskChange, AnimationMaskDump, AnimationMaskJoint } from './@types/public';

export const ANIMATION_MASK_TYPE = 'cc.animation.AnimationMask';
export const JOINT_MASK_TYPE = 'cc.JointMask';
export const PREFAB_TYPE = 'cc.Prefab';
export const NODE_TYPE = 'cc.Node';

export interface SerializedJointMask {
__type__?: string;
path: string;
enabled: boolean;
}

export type SerializedNodeRef = { __id__: number };

export interface SerializedNode {
__type__?: string;
_name?: string;
_children?: SerializedNodeRef[];
}

export function assertRecord(value: unknown, message: string): asserts value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(message);
}
}

export function normalizeJointPath(path: string): string {
return path.split('/').map((segment) => segment.trim()).filter(Boolean).join('/');
}

export function normalizeJointMasks(value: unknown): SerializedJointMask[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new Error('AnimationMask _jointMasks must be an array');
}

const result: SerializedJointMask[] = [];
const seen = new Set<string>();
for (const item of value) {
assertRecord(item, 'AnimationMask joint mask item must be an object');
const path = normalizeJointPath(String(item.path ?? ''));
if (!path) {
continue;
}
if (seen.has(path)) {
throw new Error(`Duplicate AnimationMask joint path: ${path}`);
}
seen.add(path);
result.push({
__type__: typeof item.__type__ === 'string' ? item.__type__ : JOINT_MASK_TYPE,
path,
enabled: item.enabled !== false,
});
}
return result;
}

export function jointMasksToDump(assetUuid: string, jointMasks: SerializedJointMask[]): AnimationMaskDump {
const nodeMap = new Map<string, AnimationMaskJoint>();
for (const mask of jointMasks) {
nodeMap.set(mask.path, {
path: mask.path,
enabled: mask.enabled,
});
}

const roots: AnimationMaskJoint[] = [];
const sorted = Array.from(nodeMap.values()).sort((a, b) => a.path.localeCompare(b.path));
for (const node of sorted) {
const parentPath = findNearestExplicitParentPath(node.path, nodeMap);
if (!parentPath) {
roots.push(node);
continue;
}
const parent = nodeMap.get(parentPath)!;
parent.children = parent.children || [];
parent.children.push(node);
}

return {
version: 1,
assetUuid,
joints: roots,
};
}

function findNearestExplicitParentPath(path: string, nodeMap: Map<string, AnimationMaskJoint>): string | null {
let index = path.lastIndexOf('/');
while (index > 0) {
const parentPath = path.slice(0, index);
if (nodeMap.has(parentPath)) {
return parentPath;
}
index = parentPath.lastIndexOf('/');
}
return null;
}

function isNodeRef(value: unknown): value is SerializedNodeRef {
return typeof value === 'object'
&& value !== null
&& !Array.isArray(value)
&& typeof (value as SerializedNodeRef).__id__ === 'number';
}

export function extractPrefabJointPaths(prefabJSON: unknown[]): string[] {
const root = prefabJSON[1];
assertRecord(root, 'Prefab JSON must contain root node at index 1');
if (root.__type__ !== NODE_TYPE) {
throw new Error('Prefab JSON root entry must be cc.Node');
}

const paths: string[] = [];
const seen = new Set<string>();
visitChildren(root as SerializedNode, '');
return paths;

function visitChildren(node: SerializedNode, parentPath: string): void {
const children = Array.isArray(node._children) ? node._children : [];
for (const childRef of children) {
if (!isNodeRef(childRef)) {
continue;
}
const child = prefabJSON[childRef.__id__] as SerializedNode | undefined;
if (!child || child.__type__ !== NODE_TYPE) {
continue;
}
const name = String(child._name ?? '').trim();
if (!name) {
continue;
}
const path = parentPath ? `${parentPath}/${name}` : name;
if (seen.has(path)) {
throw new Error(`Duplicate skeleton joint path: ${path}`);
}
seen.add(path);
paths.push(path);
visitChildren(child, path);
}
}
}

export function applyJointChanges(jointMasks: SerializedJointMask[], changes: AnimationMaskChange[]): SerializedJointMask[] {
const result = jointMasks.map((joint) => ({ ...joint }));
const pathToJoint = new Map(result.map((joint) => [joint.path, joint]));

for (const change of changes) {
const path = normalizeJointPath(change.path);
if (!path) {
throw new Error('AnimationMask change path must not be empty');
}
const target = pathToJoint.get(path);
if (!target) {
throw new Error(`AnimationMask joint path not found: ${path}`);
}
const shouldUpdate = (joint: SerializedJointMask) => joint.path === path || (!!change.recursive && joint.path.startsWith(`${path}/`));
for (const joint of result) {
if (shouldUpdate(joint)) {
joint.enabled = change.enabled;
}
}
}

return result;
}
Loading
Loading