Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
27 changes: 23 additions & 4 deletions docs/dev/scene/core/undo-redo.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Scene Undo/Redo

Last updated: 2026-06-10
Last updated: 2026-09-11

## 目标

Expand Down Expand Up @@ -41,7 +41,7 @@ Undo/redo 只覆盖“当前正在编辑的 scene/prefab 资源中,会被保

| 范围 | 已覆盖 API / 行为 | 记录方式 |
| --- | --- | --- |
| Node 生命周期 | create、delete、copy paste、duplicate | structure command |
| Node 生命周期 | create、delete、copy paste、duplicate、createBySerializedData | structure command |
| Node 属性 | setProperty、reset、resetProperty、updatePropertyFromNull、setNodeAndChildrenLayer、changeNodeLock | snapshot command |
| Node 层级 | setParent、reorder、children moveArrayElement、cut paste | reparent / order snapshot |
| Component 生命周期 | add、remove、removeArrayElement(`__comps__`) | component structure command |
Expand All @@ -56,6 +56,7 @@ Undo/redo 只覆盖“当前正在编辑的 scene/prefab 资源中,会被保
| 范围 | API / 行为 | 原因 |
| --- | --- | --- |
| 查询 | query、queryNodeTree、getPrefabInfo、isPrefabInstance、canUndo、canRedo、isDirty | 只读 |
| 节点序列化 | `Node.serialize` | 返回可传输数据,不修改场景、复制缓存或历史记录 |
| 选择状态 | Selection.select、unselect、clear、query | 编辑器临时状态,不写 scene/prefab 持久化数据 |
| 预览属性 | previewSetProperty、cancelPreviewSetProperty | 预览态,可取消,不形成持久化 command |
| Camera / SceneView | camera pan/orbit/zoom、scene view light/visibility/view config | 视图状态,不是 scene 数据 dirty 来源 |
Expand Down Expand Up @@ -109,6 +110,8 @@ Command:
- `src/core/scene/scene-process/service/undo/commands/snapshot-command.ts`
- `src/core/scene/scene-process/service/undo/commands/composite-command.ts`
- `src/core/scene/scene-process/service/undo/commands/create-node-command.ts`
- `src/core/scene/scene-process/service/undo/commands/create-serialized-nodes-command.ts`
- `CreateSerializedNodesCommand`:整批创建节点的 Undo/Redo,共用一份对象图快照。
- `src/core/scene/scene-process/service/undo/commands/remove-node-command.ts`
- `src/core/scene/scene-process/service/undo/commands/add-component-command.ts`
- `src/core/scene/scene-process/service/undo/commands/remove-component-command.ts`
Expand All @@ -125,6 +128,8 @@ Command:
- RPC/service 入口,负责参数解析、锁、调用 node manager、调用 undo helper。
- `src/core/scene/scene-process/service/node/node-undo.ts`
- Node 相关 undo/redo helper,负责 snapshot capture/apply、children order、component order、reparent、create-node command 捕获。
- `src/core/scene/scene-process/service/node/serialized-node-mount.ts`
- 首次创建与 Redo 共用的批量挂载流程,负责重名、插入位置、变换、Prefab 引用记录和失败回滚。
- `src/core/scene/scene-process/service/ui.ts`
- `alignSelection` / `distributeSelection` 通过 `Undo.beginRecording/endRecording` 记录选中节点位置变化。
- `src/core/scene/scene-process/service/prefab.ts`
Expand All @@ -137,6 +142,7 @@ Command:

- `createByType`
- `createByAsset`
- `createBySerializedData`
- `delete`
- `setProperty`
- `reset`
Expand Down Expand Up @@ -377,6 +383,7 @@ dirty 规则:
| --- | --- | --- | --- |
| 创建节点 | `NodeService.createByType` | `node:create` | `src/core/scene/scene-process/service/node.ts` |
| 通过资源创建节点 | `NodeService.createByAsset` | `node:create` | `src/core/scene/scene-process/service/node.ts` |
| 从序列化数据批量创建节点 | `NodeService.createBySerializedData` | `node:create-serialized` | `src/core/scene/scene-process/service/undo/commands/create-serialized-nodes-command.ts` |
| 删除节点 | `NodeService.delete` | `node:delete` | `src/core/scene/scene-process/service/node.ts` |
| 设置节点属性 | `NodeService.setProperty` | `node:set-property` snapshot | `src/core/scene/scene-process/service/node.ts` |
| 重置节点 | `NodeService.reset` | `node:reset` snapshot | `src/core/scene/scene-process/service/node.ts` |
Expand Down Expand Up @@ -421,10 +428,22 @@ Structure command 适合对象结构变化:
- 节点创建/删除。
- 组件添加/删除。

结构命令不能只依赖 uuid。恢复时还需要 path、parent path、sibling index、component index 等兜底信息,避免对象被删除后找不回来。
结构命令需要记录恢复对象所需的身份、数据和位置信息。常规结构快照保存 uuid、path、parent path、sibling index、component index 等信息,用于重新定位和还原对象。

`CreateSerializedNodesCommand` 使用整批对象图快照、根节点和父节点 UUID、sibling index,以及编辑会话标识。它要求恢复时保留 UUID,并在执行前确认仍属于原编辑会话;目标或父节点缺失时明确失败,不按路径替换为另一个同名对象。这里的路径不能作为对象身份兜底,不能仅为补齐快照字段而放宽该校验。

## 特殊实现说明

### `createBySerializedData`

- 首次创建先校验数据、加载资源并还原节点,再调用 `mountSerializedNodes` 整批挂载。
- 挂载成功后才捕获保留完整 Prefab 信息的快照,并 push 一个 `CreateSerializedNodesCommand`。
- 挂载或快照捕获失败时,回滚本批节点和父级 Prefab 元数据,不新增 Undo 记录。
- Undo 先检查全部根节点及其父级身份,再移除本批引用记录和节点;Redo 使用同一份快照恢复原 UUID,并复用批量挂载流程。
- 同一编辑会话内保留历史的重载可以继续 Undo/Redo;命令每次从当前编辑器获取根节点。切换编辑会话后拒绝执行旧命令。

对应测试集中在 `node-serialized-data.testcase.ts`,由 `scene.test.ts` 加载;`serialized-node-data.engine-test.ts` 另外覆盖真实引擎中的引用、Prefab、重载和会话校验。

### `setNodeAndChildrenLayer`

这个操作会递归修改整棵子树的 layer。当前实现会:
Expand Down Expand Up @@ -528,7 +547,7 @@ Recording 的恢复范围是现有对象的 dump。不要用它包 `Node.create/
9. capture after。
10. before/after 相同则不入栈。
11. push command。
12. 添加 `undo-redo.testcase.ts` 集成测试。
12. 添加 Undo/Redo 集成测试。通用历史行为放在 `undo-redo.testcase.ts`;业务专用行为可以集中在对应的 `*.testcase.ts`,并确保由 `scene.test.ts` 加载。
13. 如新增公开 API,更新 `common/*`、proxy、dts snapshot。

不要把 `cancelGroup` 当 rollback 使用。业务失败后是否回滚,需要调用方显式补偿或 reload。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6574,6 +6574,13 @@ export declare interface ICreateByAssetParams extends IBaseCreateNodeParams {
export declare interface ICreateByNodeTypeParams extends IBaseCreateNodeParams {
nodeType: NodeType;
}
export declare interface ICreateBySerializedDataParams {
data: SerializedNodeData;
parentPath: string;
siblingIndex?: number;
keepWorldTransform?: boolean;
externalReferences?: 'resolve' | 'clear';
}
export declare interface ICreateNodePreflightResult {
action: 'create' | 'choose-prefab-canvas-handling';
canvasRequired: boolean;
Expand Down Expand Up @@ -6938,6 +6945,8 @@ export declare interface INodeDumpOptions {
includeComponents?: boolean;
}
export declare interface INodeService extends IServiceEvents {
serialize(params: ISerializeNodesParams): Promise<SerializedNodeData>;
createBySerializedData(params: ICreateBySerializedDataParams): Promise<string[]>;
createByType(params: ICreateByNodeTypeParams): Promise<INode | null>;
createByAsset(params: ICreateByAssetParams): Promise<INode | null>;
preflightCreate(params: ICreateByNodeTypeParams | ICreateByAssetParams): Promise<ICreateNodePreflightResult>;
Expand Down Expand Up @@ -7110,6 +7119,12 @@ export declare type IPropertyLock = {
message: string
};
export declare type IPropertyValueType = IProperty | IProperty[] | null | undefined | number | boolean | string | Vec4 | Vec3 | Vec2 | Mat4 | any | Array<unknown>
export declare interface IQuat {
x: number;
y: number;
z: number;
w: number;
}
export declare interface IQueryClassesOptions {
extends?: string | string[];
excludeSelf?: boolean;
Expand Down Expand Up @@ -7409,6 +7424,9 @@ export declare interface ISelectionService {
isSelect(path: string): boolean;
reset(): void;
}
export declare interface ISerializeNodesParams {
paths: string[];
}
export declare interface IServiceEvents {
onEditorOpened?(): void;
onEditorReload?(): void;
Expand Down Expand Up @@ -7993,6 +8011,20 @@ export declare interface SerializedAssetFinder {
materials?: Array<string | null>;
scenes?: Array<string | null>;
}
export declare interface SerializedNodeData {
version: 1;
serialized: string;
rootTransforms: {
position: IVec3;
rotation: IQuat;
scale: IVec3;
}[];
externalReferences: {
id: string;
type: 'node' | 'component';
uuid: string;
}[];
}
export declare interface SimplifyOptions {
targetRatio?: number;
enableSmartLink?: boolean;
Expand Down
40 changes: 40 additions & 0 deletions src/api/scene/node-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';

import { NodeType } from '../../core/scene';
import { INodeInfo } from '../../core/scene';
import type { ICreateBySerializedDataParams, ISerializeNodesParams, SerializedNodeData } from '../../core/scene';
import { SchemaVec3 } from '../base/schema-value-types';
import { SchemaNodeIdentifier, SchemaComponentIdentifier } from '../base/schema-identifier';
import { SchemaPrefabInfo } from './prefab-info-schema';
Expand Down Expand Up @@ -110,7 +111,46 @@ export const SchemaNodeCreateByType = SchemaNodeCreateWithPreflight.extend({
nodeType: z.enum(Object.values(NodeType) as [string, ...string[]]).describe('Node type'), // 节点类型
});

const SchemaSerializedNodeVec3 = z.object({
x: z.number().finite(),
y: z.number().finite(),
z: z.number().finite(),
});

export const SchemaSerializedNodeData = z.object({
Comment thread
YuchenWell marked this conversation as resolved.
version: z.literal(1).describe('Serialized node data format version'),
serialized: z.string().min(1).describe('Opaque Cocos JSON object graph returned by scene-serialize-nodes; pass it unchanged'),
rootTransforms: z.array(z.object({
position: SchemaSerializedNodeVec3,
rotation: SchemaSerializedNodeVec3.extend({ w: z.number().finite() }),
scale: SchemaSerializedNodeVec3,
})).min(1).describe('Saved world transforms in serialized root order'),
externalReferences: z.array(z.object({
id: z.string().min(1).describe('Reference placeholder ID in the serialized graph'),
type: z.enum(['node', 'component']),
uuid: z.string().min(1).describe('Original target UUID, resolved only within the destination Runtime'),
})).describe('References to nodes or components outside this batch; asset references remain in the serialized graph'),
}).describe('Complete node batch returned by scene-serialize-nodes, including all reference and transform metadata. Pass the entire object unchanged to scene-create-nodes-by-serialized-data.') satisfies z.ZodType<SerializedNodeData>;

export const SchemaNodeSerialize = z.object({
paths: z.array(z.string().min(1)).min(1).describe('Node paths in the source scene, such as Canvas/Panel; the editor root cannot be serialized'),
}).describe('Serialize the selected nodes and their subtrees as one batch') satisfies z.ZodType<ISerializeNodesParams>;

export const SchemaNodeCreateBySerializedData = z.object({
data: SchemaSerializedNodeData,
parentPath: z.string().min(1).describe('Existing parent path in the destination scene; use / for the editor root'),
siblingIndex: z.number().int().nonnegative().optional().describe('Zero-based insertion index; defaults to appending after existing children'),
keepWorldTransform: z.boolean().optional().describe('Restore saved world transforms when true; defaults to preserving local transforms'),
externalReferences: z.enum(['clear', 'resolve']).optional().describe('Defaults to clear for cross-scene copies. Use resolve only in the source Runtime to look up original UUIDs; missing references become null'),
}).describe('Create a complete node batch in the destination scene from previously serialized data') satisfies z.ZodType<ICreateBySerializedDataParams>;

export const SchemaNodeCreateBySerializedDataResult = z.array(z.string()).describe('New root node paths in serialized order, after resolving name conflicts');

// 类型导出
export type TSerializedNodeData = z.infer<typeof SchemaSerializedNodeData>;
export type TSerializeNodesOptions = z.infer<typeof SchemaNodeSerialize>;
export type TCreateNodesBySerializedDataOptions = z.infer<typeof SchemaNodeCreateBySerializedData>;
export type TCreateNodesBySerializedDataResult = z.infer<typeof SchemaNodeCreateBySerializedDataResult>;
export type TDeleteNodeOptions = z.infer<typeof SchemaNodeDelete>;
export type TUpdateNodeOptions = z.infer<typeof SchemaNodeUpdate>;
export type TCreateNodeByAssetOptions = z.infer<typeof SchemaNodeCreateByAsset>;
Expand Down
46 changes: 46 additions & 0 deletions src/api/scene/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,59 @@ import {
SchemaNodeQueryResult,
SchemaNodeDeleteResult,
SchemaNodeUpdateResult,
SchemaSerializedNodeData,
SchemaNodeSerialize,
SchemaNodeCreateBySerializedData,
SchemaNodeCreateBySerializedDataResult,
TSerializedNodeData,
TSerializeNodesOptions,
TCreateNodesBySerializedDataOptions,
TCreateNodesBySerializedDataResult,
} from './node-schema';
import { description, param, result, title, tool } from '../decorator/decorator.js';
import { COMMON_STATUS, CommonResultType, getCommonErrorStatus } from '../base/schema-base';
import { ICreateByNodeTypeParams, INodeInfo, Scene } from '../../core/scene';

export class NodeApi {

/** 将指定节点及其子树导出为可传输的数据 */
@tool('scene-serialize-nodes')
Comment thread
YuchenWell marked this conversation as resolved.
@title('Serialize Scene Nodes')
@description('Serialize nodes and their subtrees from the currently opened source scene as one transferable batch. Removes duplicate selections and descendants already covered by a selected parent, preserving references within the batch. Does not modify the scene, clipboard, or undo history. Keep the complete returned data unchanged, then open the destination scene in the same project and pass it to scene-create-nodes-by-serialized-data. The source scene may be closed after serialization.')
@result(SchemaSerializedNodeData)
async serializeNodes(@param(SchemaNodeSerialize) options: TSerializeNodesOptions): Promise<CommonResultType<TSerializedNodeData>> {
try {
const data = await Scene.Node.serialize(options);
return { code: COMMON_STATUS.SUCCESS, data };
} catch (e) {
console.error('Failed to serialize nodes:', e);
return {
code: getCommonErrorStatus(e),
reason: e instanceof Error ? e.message : String(e),
};
}
}

/** 从序列化数据整批创建节点,成功后记录一次撤销 */
@tool('scene-create-nodes-by-serialized-data')
@title('Create Nodes From Serialized Data')
@description('Create a batch of nodes in the currently opened destination scene using the complete data returned by scene-serialize-nodes in the same project. The parent must exist. Creates new node and component identities, preserves internal references, asset references, and complete Prefab instances, and resolves name conflicts. External node and component references default to clear; use resolve only when pasting into the source Runtime. Success records one undo operation; failure rolls back the batch. Returns the new root node paths. Does not save the scene automatically; use scene-save to persist the result.')
@result(SchemaNodeCreateBySerializedDataResult)
async createNodesBySerializedData(
@param(SchemaNodeCreateBySerializedData) options: TCreateNodesBySerializedDataOptions,
): Promise<CommonResultType<TCreateNodesBySerializedDataResult>> {
try {
const data = await Scene.Node.createBySerializedData(options);
return { code: COMMON_STATUS.SUCCESS, data };
} catch (e) {
console.error('Failed to create nodes from serialized data:', e);
return {
code: getCommonErrorStatus(e),
reason: e instanceof Error ? e.message : String(e),
};
}
}

/**
* Create Node // 创建节点
*/
Expand Down
17 changes: 13 additions & 4 deletions src/core/engine/editor-extends/utils/serialize/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface IObjParsingInfo { }
// export type IObjParsingInfo = Object | null;

export interface IParserOptions {
/** 在处理共享引用前替换属性值,不修改原对象 */
valueReplacer?: (owner: object, key: string | number, value: unknown) => unknown;
Comment thread
YuchenWell marked this conversation as resolved.
// 是否压缩 uuid
compressUuid?: boolean;
discardInvalid?: boolean;
Expand Down Expand Up @@ -139,6 +141,7 @@ function isMountedChild(node: CCNode) {
}

export class Parser {
private readonly valueReplacer: IParserOptions['valueReplacer'];
exporting: boolean;
mustCompresseUuid: boolean;
discardInvalid: boolean;
Expand All @@ -162,6 +165,7 @@ export class Parser {

constructor(builder: Builder, options: IParserOptions) {
options = options || {};
this.valueReplacer = options.valueReplacer;
this.exporting = !!options._exporting;
this.mustCompresseUuid = !!options.compressUuid;
this.discardInvalid = 'discardInvalid' in options ? !!options.discardInvalid : true;
Expand Down Expand Up @@ -351,7 +355,7 @@ export class Parser {
const props = customProps || ccclass.__values__;
for (let p = 0; p < props.length; p++) {
const propName = props[p];
let val = owner[propName];
let val = this.replaceValue(owner, propName, owner[propName]);
if (this.isObjRemoved(val)) {
continue;
}
Expand Down Expand Up @@ -542,6 +546,7 @@ export class Parser {

const serializationOutput: cc.SerializationOutput = {
writeProperty: (propertyName: string, propertyValue: unknown) => {
propertyValue = this.replaceValue(val, propertyName, propertyValue);
if (this.isObjRemoved(propertyValue)) {
return;
} else if (this.setParsedObj(valueInfo, propertyName, propertyValue, null)) {
Expand Down Expand Up @@ -653,7 +658,7 @@ export class Parser {
this.parsingInfos.set(val, valueInfo);
// enumerateArray
for (let i = 0; i < filteredArray.length; ++i) {
let element = filteredArray[i];
let element = this.replaceValue(val, i, filteredArray[i]);
if (this.setParsedObj(valueInfo, i, element, null)) {
continue;
}
Expand All @@ -680,7 +685,7 @@ export class Parser {
) {
continue;
}
let val = obj[key];
let val = this.replaceValue(obj, key, obj[key]);
if (this.isObjRemoved(val)) {
val = null;
}
Expand All @@ -703,7 +708,7 @@ export class Parser {
) {
continue;
}
let val = obj[key];
let val = this.replaceValue(obj, key, obj[key]);
if (typeof val === 'function') {
continue;
}
Expand All @@ -720,6 +725,10 @@ export class Parser {
this.parseField(obj, objInfo, key, val, null);
}
}

private replaceValue(owner: object, key: string | number, value: unknown): unknown {
return this.valueReplacer ? this.valueReplacer(owner, key, value) : value;
}
}

export interface IOptions extends IParserOptions, IBuilderOptions { }
Expand Down
Loading
Loading