ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

为 three.js Editor 的撤销/重做(Undo/Redo)扩展自定义命令(Command)

为 three.js Editor 的撤销/重做(Undo/Redo)扩展自定义命令(Command) 为 three.js Editor 的撤销/重做Undo/Redo扩展自定义命令Command【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js本指南以 three.js 官方编辑器editor自带的命令模式Command Pattern实现为骨架系统讲解如何为撤销/重做功能编写、注册并执行自定义命令。文中既包含可直接照搬的模板代码、execute/undo/toJSON/fromJSON四函数规范与可更新updatable命令的合并机制也结合 editor/js/History.js、editor/js/Command.js 等源码揭示了命令入栈、序列化持久化与界面联动Sidebar.History的底层原理读完即可在 three.js Editor 中新增任意自定义操作并纳入撤销/重做历史。一、背景为什么 Editor 选用命令模式实现撤销/重做three.js Editor 在权衡多种设计模式后最终采用经典的命令模式Command Pattern来实现撤销/重做功能。核心思想是把每一次用户操作封装成一个命令对象command object该对象内部保存了恢复上一个状态所需的全部相关信息。这样撤销undo与重做redo就不再是回退场景快照这种重量级操作而只是依次调用命令对象上已定义好的方法。命令模式在 Editor 中的落点包括editor/js/Command.js所有命令的基类定义了id、inMemory、updatable、type、name、editor等基础字段以及toJSON()/fromJSON()的默认实现editor/js/History.js管理undos与redos两个栈负责命令的执行、合并、序列化与反序列化editor/js/commands/内置命令集合共 24 个统一在 editor/js/commands/Commands.js 中导出editor/js/Sidebar.Settings.History.js历史记录面板Sidebar.History实时展示 undo/redo 栈内容支持跳转到任意历史状态、清空历史、开启持久化。状态存储策略只存变化而非全量Editor 的实现在存储状态时采用了精简策略分别保存旧状态old state与新状态new state且保存的不是完整场景快照而是被修改的属性 属性值。例如SetColorCommand保存的是某个THREE.Color属性的新旧十六进制值而不是整个对象的序列化副本。这种设计在方案选型上还有另一个可选方向只存储新旧状态之间的差异difference进一步减少内存占用。Editor 选择新旧值各存一份是因为它更简单、更直接且能天然支持后面的 updatable 命令合并机制见第四节。重要前提在动手实现自己的命令之前请先检查是否可以复用已有的命令。绝大多数改一个属性值的场景都不需要新建命令见下节。二、内置命令一览先复用再扩展对于数字、字符串、布尔值这类简单类型属性直接使用SetValueCommandeditor/js/commands/SetValueCommand.js即可其构造签名与关键逻辑为constructor( editor, object null, attributeName , newValue null ) { super( editor ); this.type SetValueCommand; this.name editor.strings.getKey( command/SetValue ) : attributeName; this.updatable true; this.object object; this.attributeName attributeName; this.oldValue ( object ! null ) ? object[ attributeName ] : null; // 构造时自动捕获旧值 this.newValue newValue; } execute() { this.object[ this.attributeName ] this.newValue; this.editor.signals.objectChanged.dispatch( this.object ); // 通知界面刷新 } undo() { this.object[ this.attributeName ] this.oldValue; this.editor.signals.objectChanged.dispatch( this.object ); }其余内置命令按操作对象类型划分覆盖了编辑器中最常见的属性修改场景类别命令类说明颜色属性SetColorCommand修改THREE.Color类型的属性如material.color内部使用setHex()读写十六进制值贴图/纹理SetMaterialMapCommand设置THREE.Texture贴图属性几何体SetGeometryCommand、SetGeometryValueCommand整体替换或修改几何体参数材质SetMaterialCommand、SetMaterialValueCommand、SetMaterialColorCommand、SetMaterialVectorCommand、SetMaterialRangeCommand替换材质、修改材质数值/颜色/向量/范围属性变换SetPositionCommand、SetRotationCommand、SetScaleCommand修改对象的 position / rotation / scale场景结构AddObjectCommand、RemoveObjectCommand、MoveObjectCommand添加、删除、移动对象脚本AddScriptCommand、RemoveScriptCommand、SetScriptValueCommand编辑器中脚本的增删与属性修改其他SetSceneCommand、SetUuidCommand、SetShadowValueCommand、SetTextureParametersCommand、MultiCmdsCommand场景替换、UUID 修改、阴影参数、纹理参数以及将多条子命令打包为一步的复合命令其中 MultiCmdsCommand 是一个值得关注的复合命令它在execute()中按顺序执行cmdArray中所有子命令在undo()中逆序逐个撤销for ( let i this.cmdArray.length - 1; i 0; i -- )从而保证撤销顺序与执行顺序严格相反适合把一次用户操作引发的多个变更合并为单步撤销。当你的操作逻辑确实无法由上述任一命令表达时再按下一节的模板编写新命令。三、新命令开发模板四要素与五函数每个命令都必须有一个构造函数并且在构造函数中完成两件必做的事调用基类构造函数、声明类型与名称。以文档中的模板为例function DoSomethingCommand( editor ) { Command.call( this, editor ); // 必做调用默认构造函数基类 this.type DoSomethingCommand; // 必做必须与对象名一致 this.name Set/Do/Update Something; // 必做命令描述会显示在 Sidebar.History 中 // TODO: 存储恢复旧状态与新状态所需的全部相关信息 // 例如 this.object、this.attributeName、this.oldValue、this.newValue 等 }需要特别强调的是this.type与对象名的一致性在 History.fromJSON() 中历史记录恢复时是通过new Commands cmdJSON.type来按type字符串实例化命令的因此type必须与导出的类名严格一致否则持久化后的历史将无法反序列化。构造函数之外命令原型上必须实现四个函数函数职责execute()应用变更使对象到达新状态重做redo时也调用它undo()将对象恢复到旧状态回滚execute()所做的修改toJSON()序列化命令使撤销/重做历史在浏览器刷新后仍可恢复fromJSON( json )从 JSON 反序列化恢复命令实例DoSomethingCommand.prototype { execute: function () { // TODO: 将 object 应用到新状态 // 例如this.object[ this.attributeName ] this.newValue; // 完成后记得派发对应的 editor.signals 事件通知界面刷新 }, undo: function () { // TODO: 将 object 恢复为旧状态 // 例如this.object[ this.attributeName ] this.oldValue; }, toJSON: function () { var output Command.prototype.toJSON.call( this ); // 必做调用原型 Command 的 toJSON // TODO: 将所有必要信息写入 outputJSON 格式 // 以便 fromJSON 能够完整恢复 // 典型写法output.objectUuid this.object.uuid; output.newValue this.newValue; return output; }, fromJSON: function ( json ) { Command.prototype.fromJSON.call( this, json ); // 必做调用原型 Command 的 fromJSON // TODO: 从 json 恢复命令状态 // 典型写法this.object this.editor.objectByUuid( json.objectUuid ); } };从真实命令看模板的落地以 SetColorCommand 为例它与模板完全对应构造函数中调用super( editor )并设置this.type SetColorCommand、this.updatable true同时从object[ attributeName ]上自动捕获旧值getHex()execute()通过setHex( this.newValue )应用新值并派发objectChanged信号undo()通过setHex( this.oldValue )回滚同样派发objectChangedtoJSON()在super.toJSON( this )的基础上额外写入objectUuid、attributeName、oldValue、newValue四个字段fromJSON()先调用super.fromJSON( json )再通过this.editor.objectByUuid( json.objectUuid )重新定位对象引用。其中的关键技巧是objectUuid而非对象引用JSON 序列化无法携带内存中的对象引用所以toJSON里只记录对象的uuidfromJSON时再通过 Editor.objectByUuid()内部调用scene.getObjectByProperty( uuid, uuid, true )找回真实对象。对于更复杂的对象如新增一个对象AddObjectCommand 甚至会在fromJSON中直接用ObjectLoader.parse()重建整个对象——这就是刷新浏览器后历史依然可恢复的实现基础。四、执行命令唯一入口 editor.execute()所有命令都必须经由主编辑器对象editor这一唯一入口才能进入撤销/重做历史。调用方式如下editor.execute( new DoSomethingCommand() );editor.execute()editor/js/Editor.js内部会转调history.execute( cmd )editor/js/History.js其完整处理流程为合并判断取 undo 栈顶命令lastCmd比较时间差与命令属性详见下节入栈对于不可合并或超过合并窗口的命令this.undos.push( cmd )并分配自增id执行调用cmd.execute()并将cmd.inMemory true持久化序列化若配置项settings/history为开启状态则立即调用cmd.toJSON()并把结果挂到cmd.json上供刷新后恢复也供undo/redo时惰性反序列化清理 redo 栈this.redos []——因为一旦执行新命令此前的重做分支即失效派发信号historyChanged信号驱动 Sidebar.History 面板刷新。History还提供了配套的undo()/redo()分别从undos/redos栈弹出命令若命令因持久化被惰性加载——inMemory false——则先调用cmd.fromJSON( cmd.json )再执行以及clear()清空全部历史、goToState( id )跳转到指定历史节点等功能。注意在播放器运行startPlayer信号触发期间historyDisabled为 true此时 undo/redo 会被禁止并弹出提示。五、Updatable 命令把一连串微小修改合并成一步撤销默认情况下命令不可更新updatable false。若要使命令可更新需要实现原型上的第五个函数update其语义是只更新命令的新状态旧状态保持不变。文档中给出的SetColorCommand示例update: function ( cmd ) { this.newValue cmd.newValue; }即当一个新的同类型命令到达时把旧命令的newValue覆盖为最新值旧值仍保留为最初捕获的那个。可更新命令清单以下内置命令均声明了updatable trueSetColorCommandSetGeometryCommandSetMaterialColorCommandSetMaterialValueCommandSetPositionCommandSetRotationCommandSetScaleCommandSetValueCommandSetScriptValueCommand合并机制的底层判定合并逻辑位于 History.execute()const isUpdatableCmd lastCmd lastCmd.updatable cmd.updatable lastCmd.object cmd.object lastCmd.type cmd.type lastCmd.script cmd.script lastCmd.attributeName cmd.attributeName;可见合并并非无条件新老命令必须都声明可更新且作用于同一个对象、同一种类型、同一个脚本、同一个属性。满足条件后若cmd.type SetScriptValueCommand忽略时间差直接合并脚本编辑场景下不限制 500ms 窗口其他类型仅当与上一条命令的间隔timeDifference 500毫秒时才合并超时则作为独立历史条目入栈合并时调用lastCmd.update( cmd )更新新值并把待执行命令替换为栈顶那条避免重复入栈。设计动机在侧边栏Sidebar中用鼠标拖拽 x 位置字段时会触发成百上千次微小变更。用户的预期绝不是逐个撤销这些中间值而是一次性回到开始拖拽之前的位置。updatable 命令正是为此而生拖拽期间的连续变更合并为一步撤销同样地脚本编辑CodeMirror过程中的连续改动也会被合并为单步撤销。六、历史持久化toJSON / fromJSON 与 Sidebar.History命令的toJSON()/fromJSON()承担着两个职责浏览器刷新后恢复历史以及让 Sidebar.History 面板能够展示和跳转。序列化细节与惰性反序列化Command基类的toJSON()editor/js/Command.js默认输出{ type, id, name }派生命令在此基础上追加各自的状态字段在 History.execute() 中若配置settings/history开启命令执行后立即序列化并存入cmd.jsonHistory.toJSON() 会把undos与redos栈中所有带json属性的命令收集为数组写入项目文件Editor 的toJSON中history: this.history.toJSON()加载项目时History.fromJSON() 遍历json.undos/json.redos按cmdJSON.type动态实例化对应命令类new Commands cmdJSON.type并恢复idCounter避免 id 冲突由于反序列化的命令尚未真正执行其inMemory为 false直到用户触发undo()/redo()时才临时调用fromJSON( cmd.json )补齐引用后执行——这种惰性反序列化避免了加载项目时一次性重放全部命令的开销。在 Sidebar.History 中的呈现Sidebar.Settings.History.js 是历史功能的可视层下拉选择器outliner中undo 栈按正序新命令在下、redo 栈按逆序以 30% 透明度区分列出每一条目显示cmd.name切换下拉选项会调用editor.history.goToState( id )History.goToState()目标 id 大于当前栈顶则连续redo()否则连续undo()期间暂时关闭sceneGraphChanged/historyChanged信号以避免界面抖动完成后统一派发刷新面板右上角的Persistent History开关对应配置键settings/history开启时调用enableSerialization( id )History.enableSerialization()——它会先回到最早的命令再逐个重放并补调用toJSON()确保此前所有命令都拥有可持久化的序列化结果Clear History按钮在确认后调用history.clear()清空两个栈。因此当你为自己的命令写好name人类可读的描述与正确的toJSON/fromJSON后它无需任何额外工作就能自动出现在历史面板中支持点击跳转、跨刷新恢复与项目文件存取。七、动手实践一个完整的自定义命令示例综合以上全部要点下面实现一个自定义对象命令的完整范例把对象上任意数值属性如自定义的userData字段的修改纳入撤销/重做。它完整覆盖构造函数、四函数、updatable 五要素与序列化import { Command } from ../Command.js; class SetCustomValueCommand extends Command { constructor( editor, object null, attributeName , newValue null ) { super( editor ); this.type SetCustomValueCommand; // 必做与类名一致供 fromJSON 实例化 this.name Set Custom Value: attributeName; // 必做显示在 Sidebar.History this.updatable true; // 可选声明可更新以支持连续变更合并 this.object object; this.attributeName attributeName; this.oldValue ( object ! null ) ? object[ attributeName ] : null; // 构造时捕获旧值 this.newValue newValue; } execute() { this.object[ this.attributeName ] this.newValue; this.editor.signals.objectChanged.dispatch( this.object ); // 通知界面刷新 } undo() { this.object[ this.attributeName ] this.oldValue; this.editor.signals.objectChanged.dispatch( this.object ); } update( cmd ) { // updatable 命令的第五个函数只更新新值旧值保持不变 this.newValue cmd.newValue; } toJSON() { const output super.toJSON( this ); output.objectUuid this.object.uuid; // 存 uuid 而非对象引用 output.attributeName this.attributeName; output.oldValue this.oldValue; output.newValue this.newValue; return output; } fromJSON( json ) { super.fromJSON( json ); this.object this.editor.objectByUuid( json.objectUuid ); // 反序列化时找回对象 this.attributeName json.attributeName; this.oldValue json.oldValue; this.newValue json.newValue; } } export { SetCustomValueCommand };使用方式editor.execute( new SetCustomValueCommand( selectedObject, customProperty, 42 ) );将该文件放入 editor/js/commands/ 目录后还需在 editor/js/commands/Commands.js 中补充导出如export { SetCustomValueCommand } from ./SetCustomValueCommand.js;这样History.fromJSON才能按type字符串正确实例化它。八、实现要点速查先复用再新建数字/字符串/布尔用SetValueCommand颜色、贴图、几何、材质、变换、脚本均有专属命令构造函数必做调用super( editor )设置与类名一致的this.type和人类可读的this.name并保存新旧状态所需信息四个原型函数execute兼作 redo、undo、toJSON、fromJSON缺一不可序列化用 uuidtoJSON写入object.uuidfromJSON通过editor.objectByUuid()找回对象单一入口所有命令通过editor.execute( cmd )提交由 History 统一入栈、执行、序列化并派发historyChanged信号合并的代价是 update需要把连续微调合并为一步撤销的命令声明updatable true并实现update( cmd )合并时仅更新newValue脚本命令不受 500ms 时间窗限制其余命令需在 500ms 内且对象/类型/属性一致才会合并类型即契约Commands[ cmdJSON.type ]的实例化方式要求type字符串与导出的命令类名严格一致这是刷新后历史能否恢复的关键。按此规范你可以把编辑器中的任意新操作自定义变换、批量修改、材质参数联动等安全地接入撤销/重做体系并与历史面板、项目持久化无缝协同。【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表