diff --git a/docs/dev/scene/history/lightfx-bake-validation.md b/docs/dev/scene/history/lightfx-bake-validation.md new file mode 100644 index 000000000..67c18ff0f --- /dev/null +++ b/docs/dev/scene/history/lightfx-bake-validation.md @@ -0,0 +1,125 @@ +# LightFX 历史验证记录 + +以下为整改前的开发与联调记录,仅用于追溯。目录、接口和验收状态以 [当前文档](../lightfx-bake.md) 为准;临时路径不保证仍存在。 + +## 2026-09-11 最新产品决定与实施顺序 + +用户已明确不再为普通重烘焙 Undo 保留旧贴图。成功重烘焙应替换并清理旧产物;Clear 后同样不能恢复旧图/UV/效果,节点移动等普通编辑历史和探针 SH 撤销不变。此决定覆盖本文历史版本关于保留所有成功烘焙版本的描述。 + +当前 `455e8687` 已按场景 UUID 持久记录实际导入的根资产 UUID,Clear 合并当前绑定和已知旧产物,逐项核对引用、删除结果和源文件存在性;不保存历史像素副本,不做项目 GC。 + +本批补齐成功重烘焙的收尾链路:修改场景前校验 Host 的内部 `lightmapRebakeCleanupVersion === 1` 并读取旧候选;新结果应用、录制和保存确认后,使旧 Lightmap 历史失效(保留本次新结果的 Redo 和普通历史),检查实时剩余引用并清理旧候选。Host 只接受仍持有正确 Bake reservation 且原生已 commit 的 `action:bake` 清理。当前新结果、其他字段/场景/材质引用必须保留。删除失败、引用保留或回应不明时返回包含 `New Lightmap result is saved and retained` 的错误,保留已完成的新结果并明确报告,不再恢复旧内存冒充回滚;不能把它解释成 Bake 没有修改场景。 + +`saveScene:false` 不授权删除磁盘已保存场景仍依赖的贴图,不隐式保存,也不删除旧产物或固定发布;成功后的旧结果历史仍失效。保存失败/取消/应用失败不触发旧产物清理。下一次成功保存的 Bake 或删除模式 Clear 可重试已记录产物。`31598b0a` 已接入保存后的固定 PNG 发布;`2b13cfce` 已接入原生配套文件发布与清理,真实面板正常主链路已通过,异常与同场景 Creator 对照仍待专项验收。 + +## Lightmap 资产规则 + +### 原生配套文件接入(实施前记录) + +下一批复用 PNG 的暂存/保存后发布事务:原生完成时将实际 `tmp/lfx.in`、`output/lfx.out` 和存在的 `lfx.log` 导入本轮暂存目录,记录其根 UUID 为独立 auxiliary 成员,不混入贴图预览或纹理数量。保存后发布为 `<根>/tmp/lfx.in`、`<根>/output/lfx.out`、`<根>/lfx.log`。这比延长原生工作目录生命周期更小,commit 仍可按原机制清理工作目录。日志缺失不伪造文件,输入/输出缺失仍按实际失败处理。 + +成功重烘焙和删除式 Clear 清理已记录的旧配套资产;Bake 排除本次 UUID。配套文件不是 Lightmap 绑定,因此不得忽略当前场景对它的其他引用。删除/移动均核对磁盘、UUID 和元数据,固定目标冲突不覆盖。显式不保存和保存失败不提前删旧产物。复用现有归属文件的可选 auxiliary 字段,读写保留旧格式兼容,损坏字段在修改场景前报错;不扫描项目、不按目录猜归属、不留成功历史副本。新增内部能力位约束新旧 Host 混用;公共 MCP 不扩展新入口。 + +实现 `2b13cfce`:上述配套文件以独立 auxiliary UUID 记录,与 PNG 同批预检并移动;返回的 textureUrls 只含 PNG。Scene 在无纹理候选时仍执行 Host 清理以支持辅助资产失败重试,Clear 的资产计数包括辅助文件,绑定计数不变。内部能力位为 `lightmapAuxiliaryAssetsVersion:1`。原生输入文件可能引用临时纹理源,此批未承诺把全部纹理源附件发布成可脱离项目重放的输入包;不扩展为原生工程归档器。 + +先 `tsc -b`/Scene 与 editor-extends 构建,再定点 6 套/144 项、扩展 32 套/535 项通过(`/tmp/pink-native-products-final-tests.log`);定点 ESLint 无代码错误,保留已有配置告警。覆盖实际文件移动、固定冲突前置拒绝、部分移动失败、辅助字段损坏、回滚保留上轮、同场景其他引用保留和无纹理候选重试。 + +隔离工程 `/tmp/pink-native-products.SjK8Ju` 由主 agent 准备新 Host 后交全局 ui_verifier 执行真实按钮,证据 `/tmp/codex-ui-verifier.10oRvd`:128 Bake 产生固定 3 PNG+3 配套文件,256 Bake 替换为 2 PNG+3 新 UUID 配套文件;无缺图,保存成功。Clear 后这 5 个当前资产及 meta 实际不存在,归属 textures/auxiliary 为空,绑定及 UV 清空,43 点 SH 哈希不变,一次 Undo/Redo 未恢复。主 agent 已复核磁盘/JSON/截图。空目录及目录 meta 保留;旧版无归属历史不扫描删除。未直接查询 Asset DB 旧 UUID 缓存,也未在本批重复关闭重开或注入异常;不把正常主链路扩展为所有边界已验收。既有告警及点击 Clear 时瞬时 Console 计数差异均保留原始证据。 + +固定贴图发布的最小接入(实施前记录):继续使用独立临时导入目录完成纹理加载和场景保存;保存确认、旧产物清理完成后,通过 Asset DB 保留 UUID 移动到默认 `db://assets/LightFX/output`,指定 `outputUrl` 时移动到 `/output`。固定发布由原 Bake reservation 和实际 operation ID 校验,不接受任意外部 UUID。所有目标先检查冲突,逐项移动后核对 UUID、URL 和磁盘源/目标;不覆盖同名资产、不先复用旧 UUID。失败保留已保存的新结果位置,不删除新贴图。只用非递归空目录删除收敛已清空的 `bake-UUID`,其他文件存在时保留。`saveScene:false` 暂不固定发布,保护磁盘旧引用。本批不宣称 `lfx.in/out/log` 配套文件已经对齐。 + +Lightmap 先按每次烘焙的 operation UUID 导入到独立暂存目录(以下为省略 `outputUrl` 时的模板),这不是成功后保留的历史版本: + +```text +db://assets//lightmap/bake-/ +``` + +指定 `outputUrl` 时暂存到 `/bake-/`,例如 `db://assets/烘焙结果 Room A`。选择目录必须已存在且真实路径位于当前项目 assets 内;不接受任意磁盘路径、路径穿越或指向 assets 外的符号链接。参数仅改变本次输出位置,不自动保存为场景设置。Scene 的 `queryCapabilities().outputDirectory === true` 来自实际 Host 的 `lightmapOutputDirectory` 支持位;旧 Host 不支持时明确报错,不忽略选择后写入默认目录。 + +保存与旧图清理成功后,当前 PNG 保留 UUID 移动到 `db://assets/LightFX/output`;选择 `db://assets` 与省略参数相同,选择子目录则发布到 `/output`。固定发布额外要求内部 Host 的 `lightmapPublicationVersion === 1`。更新 CLI 后若出现能力不支持错误,需要重启实际 Cocos Host;单独 Reload Window 可能仍连接旧 Host。 + +典型文件包括: + +```text +LFX_Mesh_0000.png +LFX_Terrain_0000.png +``` + +- Mesh 与 Terrain 使用独立的类型和索引映射,避免两者均从索引 0 开始时串绑贴图。 +- 每次生成新 Asset UUID,不直接覆盖已发布像素。保存确认后精确删除旧产物,再把新图移动到固定 URL,不再供旧结果 Undo 使用;`saveScene:false` 的新结果留在独立暂存位置,不会改写或删除磁盘旧场景依赖的贴图。目标仍被占用时明确报错,不覆盖。 +- 旧版平铺目录中的 PNG/`.meta` 原样保留,不自动迁移、不复用其 UUID。调用方必须使用返回的 textureUrls 或真实绑定查询,不拼接固定文件路径。 +- 旧产物从场景归属记录及替换前实时绑定收集,不扫描目录猜测归属。成功保存的 Bake 和删除模式 Clear 会清理无引用候选;引用保留/失败项可以重试。旧版已解绑且从未记录归属的资产不自动猜测删除;已清空的 `bake-UUID` 目录只做非递归删除并刷新 Asset DB,含其他内容的目录保留。 +- 导入后将 `fixAlphaTransparencyArtifacts` 设置为 `false`,再加载 Texture2D 子资源并绑定。 +- 原生提交确认前的导入/加载失败尝试回滚本次新目录;提交确认后不再删除产物。应用失败恢复旧绑定,保存失败保留已录制结果,规则见“提交与保存失败”。旧版本目录不受影响。 +- 成功、失败、取消和超时进入 workspace 清理;回滚或 Asset DB 刷新失败时保留备份和互斥以便恢复,不能宣称所有错误都会完成清理。 + +## 错误与事务 + +### 2026-09-12 重新推进共享引用保护与面板日志 + +用户重新授权处理两项。修改前核对:清理只查询图片主 UUID,但资产依赖索引精确保存 Texture 子 UUID(如 `@6c48a`),导致其他保存场景的引用漏检。最小修复在 LightFX 删除入口同时检查主资源及其实际子资源的使用者,查询失败保留,不改通用依赖 API、不做项目 GC。Clear 与重烘焙共用该检查。 + +面板日志目前直接追加原始 `lfx.log`(时间戳、版本、线程等),缺 Creator 的生成图片阶段及场景统计。计划由实际导出 world 计算对象/灯光/三角形统计,通过内部 Host 诊断传递;真实原生进度100%触发生成图片阶段,实际输出提供UV信息,原始日志文件照常保留,不将其诊断噪声混入产品面板。失败仍显示真实错误,探针日志不改。先类型检查/构建、自动测试,再在新隔离窗口由全局 ui_verifier 核验真实按钮、磁盘文件与另一个场景的有效贴图。 + +实机补充依赖:共享引用保留正确,但原有重烘焙策略仍报告旧产物清理未完成,保存新结果而不固定发布;本次不改变该策略。PinK失败终态仅保留最后轮询的进度,漏掉原生结束日志。最小接入为Host诊断附带内部operationId,PinK终态查询仅接收与启动前不同的本轮诊断,避免GI参数前置拒绝时误取上一轮日志;不新增公开任务或重试Bake。 + +完成:`b21aa0f3` 修复主图/子资源引用保护,`dba06b7c` 实现阶段统计日志和内部任务标识。先 `tsc -b`/Scene bundle,再33套件/565项及定点ESLint通过。PinK同步终态诊断读取,客户端类型检查/构建后15项桥接测试通过。原始日志文件保留详细诊断,产品面板不再读取整份原生日志;以下旧批次关于读取原生日志的记录仅为历史。 + +全局 ui_verifier 实机证据 `/tmp/codex-ui-verifier.nd2IMr`、`/tmp/codex-ui-verifier.CctL3q`,均父代理准备隔离配置/临时工程后交控制并独立核对:共享场景Clear后文件hash保持,真正打开另一场景无粉红/missing,纹理和UV有效;正常GI25/1024生成到固定目录,完整进度及2对象/1灯/4108三角形统计与Creator同场景证据一致。GI65535前置拒绝不串旧日志、不改旧图;正常Clear实际删除本轮PNG/meta和三个辅助文件、绑定UV清空,43点完整SH不变。第二窗口单次重烘焙仍按既有策略因2个共享引用保留而报告清理未完成,但此次失败面板完整显示100%、统计、UV、End及失败;新结果有效保存、共享文件hash保持。保留Terrain UV扩展行,不宣称日志全文完全相同;没有扩大处理导入元数据或既有告警。 + +### Lightmap GI Samples 整数溢出防护(2026-09-12) + +已在隔离 PinK 实测 `giSamples=65535` 触发原生 `GenerateIntegrationSamples` 的 vector length_error/SIGABRT。随包 LightFX 对 Lightmap 采样数组长度以有符号32位计算 `giSamples² × 64 × 5`,故最大不溢出整数为2590(不是推荐值,也不保证大值在所有设备上的内存和耗时)。修复仅在公开参数schema、Scene直接入口及输入编码处前置拒绝非法值,不启动原生计算、不修改上一份结果、不静默clamp;Light Probe采样参数保持原契约。用户已决定暂不处理日志对齐,引用保护的隔离失败与用户手测不一致另行保留,不混入本次修复。 + +产品提交 `21e3b27d`。先通过 `tsc -b` 与 Scene bundle 构建,再通过32套件/556项回归测试及定向 ESLint。全局 ui_verifier 在新夹具 `/tmp/pink-gi-overflow.BNB4DT` 实际执行 GI25 生成 → GI65535 明确拒绝 → 改回25生成成功;拒绝前后场景、PNG、meta 的 hash 及绑定/UV不变,全量43点SH hash一致,两次正常生成均 `dirty:false`、无缺图。Host仅有两次正常任务的 begin/run,65535没有启动原生任务。原始截图、运行时及文件证据 `/tmp/codex-ui-verifier.Uszw6E`。未实机运行2590,不将算术上限当作性能验收;既有告警仍存在。 + +常见错误包括: + +- 当前没有打开已保存场景。 +- 探针不足、未生成或没有可烘焙 Mesh/Terrain。 +- 场景依赖资产缺失。 +- LightFX 缺失、启动失败、连接失败、超时或异常退出。 +- 输出协议不兼容或结果损坏。 +- Asset DB 导入、Texture2D 加载或场景保存失败。 +- 已有另一个 LightFX 任务运行。 +- 当前可见场景尚未加载完成,或同时存在多个可见的场景渲染器。 + +Bake 和非删除模式 Clear 的结果作为单次 Undo 记录。Lightmap 明确录制参与结果修改的 MeshRenderer/Terrain 组件(同一 Terrain 多 block 去重)及 Scene 标记,不能只录制不递归的 Scene 根节点;旧引擎缺类型的空纹理引用也会保留在快照中。成功自动保存后以该记录作为保存点;saveScene:false 不隐式保存场景。`deleteAssets:true` 是例外:保存成功后只取消本次 Clear 录制,不清空整个 Scene Undo/Redo 历史。恢复快照时使 Clear 前的所有烘焙绑定、UV 和标志失效,而节点移动、其他组件参数及探针系数仍按原历史恢复;即使旧纹理因其他引用保留,也不通过本场景旧快照恢复其烘焙效果。Clear 后新生成的烘焙记录仍可撤销。没有删除候选或全部资产保留时同样推进结果代次而保留普通历史。失败必须区分原生提交前、结果应用中和结果录制后保存阶段,不能对所有错误统一恢复绑定或删除资产,详见“提交与保存失败”。 + +成功 Lightmap Bake 使先前结果历史失效,保存确认后再清理旧像素;本次结果 Redo、普通属性和探针 SH 历史保留。非删除 Clear 仍可撤销恢复未失效的当前结果。`deleteAssets:true` 合并场景归属记录与实际绑定中可验证的 LightFX 根贴图 UUID,不删除整个目录;实时场景或其他磁盘资产仍引用的贴图保留并报告,删除不可撤销。此前已丢失的像素无法靠此修复找回。 + +## 验证范围 + +固定 PNG 发布依赖补充:Asset DB 的普通非覆盖移动原先先移 `.meta`,再移源文件,失败后仍吞错并刷新。仅对非覆盖移动补充错误传播;普通同级移动若源文件尚在、目标文件尚未生成,则无覆盖地回放已移动的元数据,阻止后续刷新生成不同 UUID。覆盖模式不在本次修改范围。该最小共用依赖必须用真实文件与故障注入验证,不能只靠 `moveAsset()` resolve 判成功。 + +默认入口补充:PinK 目录选择器总会传入 `outputUrl`,默认选中 `db://assets` 与省略参数同样发布到 `db://assets/LightFX/output`;选择 assets 内子目录才使用 `/output`。这保证直接接受目录选择器默认值也得到 Creator 风格目录,不要求 UI 绕过既有选择入口。 + +本轮首次 UI 验证发现 PinK 桥接仍硬编码 `saveScene:false`,实际跳过以上发布和旧图清理,虽然面板提示生成成功。该次结果不作为通过证据。PinK 面板入口改为显式保存,并在生成前告知保存/替换语义;CLI 非 UI 调用显式传 `saveScene:false` 仍保持不保存、不删除磁盘旧依赖的安全契约。证据 `/tmp/codex-ui-verifier.aG5Cnt`。 + +固定 PNG 发布 `31598b0a`、非覆盖移动保护 `60f9a975`:先 `tsc -b` 和 Scene/editor-extends 构建,再 **32 套/524 项**通过(`/tmp/pink-fixed-output-final2-tests.log`);定点 ESLint 无代码错误,保留已有 unused catch 和配置警告。PinK `17a0a25b7cb` 接通面板保存式 Bake,客户端类型检查/构建和扩展构建后,15 项 Electron 桥接测试、69 项扩展宿主测试及 1 套编译面板测试通过。实机结果另行记录,不以这些自动测试代替。 + +本轮最终隔离实机 `/tmp/codex-ui-verifier.4fxdtg`:真实面板 128→256 两次 Bake 均发布到 `LightFX/output`,由原生实际打包产生 3→2 张 PNG;旧 PNG/meta 和已空暂存版本目录实际删除、保存场景和运行时 UUID 一致。真实 Clear 后当前 PNG/meta 删除、Mesh 和两个 Terrain block 纹理/UV 清空,节点 X=1 不回退;Scene Undo 节点/最近一条 Bake、Redo Bake/节点不恢复旧图。真实保存、关闭 Scene 标签并从 Assets 重开后仍无绑定/缺图,X=1、dirty=false,43 点完整 SH 哈希不变。主 agent 准备隔离工程与新 Host 后交全局 `ui_verifier` 控制,并独立核对原始数据。旧版未记录归属且已解绑文件未猜删;没有验证所有更早历史、异常/取消/外部引用、禁用 Terrain 或保留历史软重载。原生配套文件固定发布及 Creator 同场景日志/预览对照仍待完成,不将本主链路称为全量产品对齐。已有 dump null/argv.json 告警不宣称消除。 + +前一批 `02f099e7`:成功保存后的旧产物精确清理和旧结果历史失效已接通。先 `tsc -b`/Scene 与 editor-extends 构建,后定点 5 套/120 项、扩展 29 套/472 项通过(`/tmp/pink-rebake-cleanup-final-tests.log`);定点 ESLint 无代码错误,已有配置提示保留。新增测试核对真实临时文件、Host 归属、保存失败/结果不明、当前有效 Redo 和普通历史,不等同实机。以下独立版本完整 Undo 的旧实机记录只作为历史证据,不能作为最新策略验收。 + +2026-09-11 产品对齐补充:Lightmap 日志保留真实 Log/Progress 顺序,原生结束后在临时目录清理前读取 `lfx.log`,补充真实 Mesh/Terrain 输出索引与 UV。日志最多 128 条、每条 2048 字符,原生日志文件读取上限 256 KiB,超限明确提示,缺失日志不伪造成几何统计,也不让烘焙失败。探针日志行为保持原状。此处日志修复不代表原生配套文件已固定发布,也不代替资源删除实机证据。 + +当前实现已经验证: + +- Light Probe Bake/Clear,包含 SH 数据保存和重新加载。 +- Mesh Lightmap Bake/Clear。 +- Terrain Lightmap Bake/Clear。 +- Mesh 与 Terrain 混合场景的独立贴图绑定。 +- 重复烘焙的独立版本目录/UUID、旧像素保留及旧平铺资产兼容。 +- Pink 当前可见场景中的即时结果应用、清理和取消。 +- TypeScript 编译、ESLint、API、协议和资产事务测试。 + +新增材质类型、灯光类型、LightFX 版本或目标平台时,应补充对应真实场景回归。 + +2026-09-10 结果历史专项:macOS arm64/隔离 PinK,真实带第二套 UV 的 Mesh 烘焙 128px 标准/高精度贴图;Bake、保留资产的 Clear、独立 Undo/Redo、渲染模型 UV、显式/自动保存、真正关闭重开通过,旁侧 43 点探针全部 SH 保持。Terrain 多 block 录制目标及失败恢复由服务测试覆盖,未在本次专项重做 Terrain 原生场景实测;也没有验收旧 PNG 像素版本撤销、资产删除撤销或最终画面质量。 + +随后版本隔离专项补验:三次真实 Mesh Bake 使用不同 URL/UUID,标准/高精度 PNG 的 SHA256 随 Undo/Redo 精确对应旧/新结果,关闭重开保留;未保存新 Bake 时磁盘 Scene 仍引用未变更的旧 PNG。旧平铺资产保持。真实文件事务测试覆盖同名场景多次输出互不覆盖、本次回滚/导入失败不影响旧版本;取消故障不作为新增实机验收,资产删除与历史 GC 仍待专门的归属协议。 + +Terrain 专项补验:快照恢复数组后,对已有 TerrainBlock 重新绑定对应 lightmap info(无元素时解绑)并让材质失效,避免 Terrain.onRestore 的 valid 快路径保留旧引用。实际单块和持久化 `.terrain` 双块+Mesh 混合场景,Bake/Clear、Undo/Redo、自动/显式保存、关闭重开通过;每个 block 的实际 texture/UV 与序列化结果一致,43 点探针 SH 不变。`bake().terrainCount` 当前是原生输出 block 条目数,`queryBakeInfo().terrainCount` 是拥有绑定的 Terrain 组件数,两者不应直接比较。地形尺寸/高度保存在 `.terrain` 资产,夹具通过 Terrain.saveManage/saveAssetDialog 正式写入,不靠修改内存后只保存 Scene 冒充持久化。 + +编辑与诊断专项补验(同为 macOS arm64/隔离 PinK):两组 16/27 点切组全选、真实复制/删除按钮、空白/球起手及 Shift 追加框选通过;复制→Undo→改父节点保持组件与全局表一致的 43 点,Undo 恢复原 SH、Redo 恢复新位置与失效状态。自身旋转/非均匀缩放的探针球与采样位置一致,祖先变换同步和 Undo 通过。真实 Probe Bake 显示 `Build lighting 100%`;Mesh+双块 Terrain Bake 观察到 `Build lighting 25%` 后取消,前后结果、历史以及 83 个资产/元数据文件哈希一致。另一场景不接收任务日志。上述不包含持久恢复、安全资产回收、跨磁盘失败原子性或其他 OS 的验收。 diff --git a/docs/dev/scene/lightfx-bake.md b/docs/dev/scene/lightfx-bake.md new file mode 100644 index 000000000..a16ef62f8 --- /dev/null +++ b/docs/dev/scene/lightfx-bake.md @@ -0,0 +1,382 @@ +# Light Probe 与 Lightmap 烘焙 + +## 功能概览 + +Cocos CLI 通过 Creator 随附的 LightFX 工具提供离线光照烘焙能力: + +- Light Probe:计算场景内所有有效光照探针的球谐光照系数,并写回场景。 +- Lightmap:为静态 Mesh 和 Terrain 生成 Lightmap,导入 Asset DB 并绑定到组件。 +- 清理:解除 Light Probe 或 Lightmap 的烘焙结果,可选择保存场景及删除 Lightmap 资产。 +- 取消:终止当前正在运行的 LightFX 任务。 + +MCP API 只负责参数校验和结果封装。场景运行时负责导出场景数据、应用烘焙结果、Undo、重绘和保存;Node Host 负责启动 LightFX、临时文件、Asset DB 导入和资产事务。Light Probe 与 Lightmap 共享场景导出、二进制协议、进程管理和临时目录管理。 + +在 Pink 等集成场景编辑器中,CLI 会把请求路由到当前可见且已加载场景的 WebGL Scene Webview,使烘焙结果立即显示在正在编辑的场景中。没有连接 Scene Webview 时,CLI 才回退到 scene-process worker。 + +## 使用前提 + +1. 当前场景必须是已保存的 `.scene` 资产;不支持未保存场景和 prefab。 +2. Light Probe 烘焙前,场景中需要至少 4 个已生成的有效探针。 +3. Lightmap 烘焙前,需要在 MeshRenderer、SkinnedMeshRenderer 或 Terrain 上配置有效的烘焙设置。 +4. 同一 Scene host 下,Light Probe/Lightmap 的 Bake/Clear 共享事务预留;导出、结果应用、保存、Undo、失败恢复与可选资产清理期间拒绝新的冲突操作。 +5. 在 Pink 中调用时,目标场景必须已在当前可见的场景视图中加载完成;不需要额外调用 `scene-open`。同时存在多个可见场景视图时,应先激活目标场景标签并关闭重复视图。 + +## 运行能力识别 + +公开 CLI API `Scene.LightProbeBake.queryCapabilities()` 会向实际 Scene renderer(没有 Webview 时为 worker)及其 Node host 查询: + +```ts +const capabilities = await cli.Scene.LightProbeBake.queryCapabilities(); +// { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy: false } +``` + +`resultLifecycleVersion: 1` 表明本 Scene 实现包含 SH Undo/Redo 与多组重开结果保留修复;`sceneTransactionVersion: 1` 表明 Scene 与实际 host 均使用完整 Bake/Clear 事务预留协议。旧 host 缺少查询或协议不匹配时拒绝返回能力,调用方不能只检测 bake 方法存在或只检查包版本。集成方遇到方法缺失/查询失败应显示不支持或连接错误,不得自动尝试烘焙。 + +Lightmap 使用独立能力查询,不能复用 Probe 的生命周期判断: + +```ts +const capabilities = await cli.Scene.LightmapBake.queryCapabilities(); +// { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, +// outputDirectory: true, assetCleanupVersion: 1, busy: false } +``` + +这里的 `resultLifecycleVersion: 1` 包含 Mesh/Terrain 的结果录制目标、空纹理引用、TerrainBlock 恢复刷新及保存基线;`assetVersion: 1` 必须由实际 Node host 的 `lightmapAssetVersion: 1` 确认,保证新 Bake 不覆盖旧纹理版本。旧 host 即使支持 Probe 事务,也可能缺少资产版本保护,此时 Lightmap 查询拒绝返回支持。`outputDirectory` 和 `assetCleanupVersion` 分别表示实际 Host 支持安全的自选输出目录和精确资产清理;字段缺失时不得调用对应能力。有归属取消另通过 `cancelVersion`/`cancellable` 声明,见下文。 + +`busy` 仅为共享宿主的瞬时占用提示,包含导出前预留、原生操作、提交后场景回写及失败恢复;查询不占锁、不释放锁、不返回内部凭据。即使 busy=false,执行入口仍需原子预留,调用方必须处理查询之后发生的并发拒绝。该接口不检查原生 LightFX 可执行文件、场景输入合法性或渲染质量,也不是持久任务/统一百分比协议。上方示例仅列基础字段;可选取消能力和原生诊断见下文。新旧 renderer 混用的限制仍见下文。 + +### 原生诊断 + +Probe/Lightmap 的 `queryCapabilities()` 和成功 Bake 结果可带 `diagnostics`:`{ version: 1, operationId?, stage, logs, progress?, rate? }`。Scene 只返回本运行实例、对应烘焙类型的当前或最近原生操作,内部 Host 查询校验 operation ID、target 与 transaction ID;不会返回其他场景的日志。没有可用诊断或查询失败时字段可缺省,集成方应降级显示,不能因此把烘焙成功改为失败。 + +Host 最多记住 32 个操作;每个操作保留最近 128 条日志,每条与进度文本上限为 2048 字符,隐藏该操作工作目录和目标资产目录的绝对路径。`progress` 保留 LightFX 原始文本(例如 `Build lighting 25%`);仅当专用 Progress 事件严格匹配该已验证格式且数值位于 0–100 时,另提供 `rate`。未知格式不得从日志或任意数字推断百分比。`stage` 是最近采样的原生阶段,不代替上层 Scene 的成功/取消/恢复状态。进程重启后诊断不保留,不提供持久任务身份或失联事务恢复。 + +## MCP 工具 + +### 并发与故障边界 + +Scene runtime 先通过内部 `reserveSceneOperation` 取得宿主生成的事务凭据,业务结束后通过 `releaseSceneOperation` 释放。多个 Webview/worker 共用同一宿主预留;本地锁仍防止同一 runtime 重入。原生任务的 `operationId` 与场景事务的 `transactionId` 不同,原生 commit/rollback 结束不代表上层场景回写已经结束。内部凭据不是公开任务查询接口,也不是用户认证机制。 + +宿主校验事务凭据、目标和动作;错误或已过期的凭据不能开始新的原生烘焙/清理,重复释放旧事务不能释放新持有者。没有场景预留的旧原生 begin 入口仍独占原生操作;旧资产删除入口也会在删除及 Asset DB 刷新期间临时预留。旧 renderer 若完全绕过新增协议执行内存 Clear,并不受此机制保护,集成时必须统一运行产物版本。 + +运行实例失联或释放失败时采用 fail-closed:宿主不自动超时放开场景预留,以免暂停的旧实例恢复后与新任务同时写回。此时不要自动重试烘焙;先处理原实例并重启其 Scene host。原生回滚失败时保留恢复备份,不得手工删除以“解除忙状态”。自动失联回收与公共持久任务仍未实现;当前 Cancel 已按本 Scene、烘焙类型和内部操作归属核对,具体契约见“取消烘焙”。 + +### 烘焙 Light Probe + +工具名:`scene-bake-light-probes` + +```json +{ + "options": { + "giScale": 8, + "giSamples": 4096, + "bounces": 1, + "reduceRinging": 0, + "showWireframe": true, + "showConvex": false, + "lightProbeSphereVolume": 1, + "saveScene": true, + "timeoutMs": 600000 + } +} +``` + +参数: + +| 参数 | 范围 | 默认行为 | +| --- | --- | --- | +| `giScale` | 0–100 | 使用场景 `lightProbeInfo.giScale` | +| `giSamples` | 64–65535,整数 | 使用场景 `lightProbeInfo.giSamples` | +| `bounces` | 1–4,整数 | 使用场景 `lightProbeInfo.bounces` | +| `reduceRinging` | 0–0.05 | 使用场景 `lightProbeInfo.reduceRinging` | +| `showWireframe` | boolean | 使用场景 `lightProbeInfo.showWireframe` | +| `showConvex` | boolean | 使用场景 `lightProbeInfo.showConvex` | +| `lightProbeSphereVolume` | 0–100 | 使用场景 `lightProbeInfo.lightProbeSphereVolume` | +| `saveScene` | boolean | `true` | +| `timeoutMs` | 1000–3600000 ms | 600000 ms | + +所有参数均可选,未传入时使用场景当前值。`giScale`、`giSamples` 和 `bounces` 参与 LightFX 计算;`reduceRinging`、`showWireframe`、`showConvex` 和 `lightProbeSphereVolume` 用于烘焙结果后处理或编辑器显示。烘焙成功后,本次的有效参数与 SH 结果作为同一次 Undo 操作写回 `LightProbeInfo`;计算失败、提交未确认或取消胜出时不应用结果。结果已录制后的保存失败保留新结果,详见下文。 + +平移已启用探针组或其父节点时,CLI 同步全局采样点和四面体,并保留所有组的原 SH 系数,刷新光照缓存;对齐 Creator 3.8.8 移动 A 组后 A/B 组系数都保留的行为。不重新生成组件内手工编辑过的采样点,也不自动重新烘焙。普通节点属性操作和 Gizmo recording 仍记录位置编辑的 Undo/Redo,保存仍由调用方决定;保留系数不代表已按新位置重烘焙。 + +此同步沿用当前引擎的 `localProbe + worldPosition` 约定,探针球、范围盒与框选投影也采用相同约定,不额外给局部采样点乘旋转/缩放。祖先旋转/缩放若改变子组世界位置,采样位置同步并使旧 SH 失效;改父级将受影响 Scene 的结果快照放在节点恢复之后,Scene 自身不参与重挂。组件 Undo 替换 probes 数组后重新同步引擎注册引用,避免后续变换再次使用旧数组。 + +### 探针组编辑与显示 + +`Scene.Gizmo` 提供探针 vertex/box 模式查询与切换、生成、全选/取消全选、选中数量、复制/删除以及区域选择接口。选择按实际可见、有效、启用的组统计;隐藏或池化实例换目标时清空旧选择。支持空白或探针球起手框选,Shift/Ctrl/Cmd 追加,追加框选缩小时按按下时的选择基线重新计算。 + +`duplicateSelectedLightProbes()`/`deleteSelectedLightProbes()` 返回 `Promise`,等待 CLI 的 Undo 录制结束后给出实际变更点数;复制副本位于原位置并选中新点。`generateLightProbes()` 只生成采样点,调用方需要为它建立一次 Undo recording,不能把生成当作 GI 烘焙。键盘操作应在场景焦点与正确编辑模式下路由,避免删除节点或修改其他组。 + +凸包外边界、边界法线与内部四面体线框分别绘制,读取 `showConvex`/`showWireframe`;缓存失效覆盖显示参数、采样数据和变换变化。范围逐坐标、复杂拖动/焦点组合、编辑结果保存重开及最终材质显示仍需按场景扩展验收。 + +### Light Probe 烘焙返回结果 + +成功返回示例: + +```json +{ + "result": { + "code": 200, + "data": { + "sceneUrl": "db://assets/LightProbe.scene", + "probeCount": 125, + "giScale": 8, + "giSamples": 4096, + "bounces": 1, + "reduceRinging": 0, + "showWireframe": true, + "showConvex": false, + "lightProbeSphereVolume": 1, + "durationMs": 1630 + } + } +} +``` + +### 清理 Light Probe + +工具名:`scene-clear-light-probes` + +```json +{ + "options": { + "saveScene": true + } +} +``` + +该操作清除当前场景全部探针的烘焙结果,通知引擎刷新,并作为一次 Undo 操作记录。成功结果中的 `probeCount` 表示处理的探针数量。 + +Probe Clear 保留完整旧 SH 撤销。Lightmap 的当前契约为:成功重烘焙后不恢复此前旧纹理/UV/场景标记,本次有效结果可以 Redo;删除模式 Clear 后本次及更早结果均失效。两者的 Undo 语义不同。 + +Probe/Lightmap Bake/Clear 的 `saveScene` 默认是 `true`:完整成功后,当前结果作为已保存基线;Undo 离开保存点会变脏,Redo 回到已保存的有效结果恢复干净。Lightmap 旧结果须经过失效过滤,不能因历史尚存就恢复已被替换的贴图。显式传 `false` 时只修改内存并保留 dirty,调用方需要另行保存。 + +### 提交与保存失败 + +Bake 按「确认原生产物提交 → 应用场景结果 → 完成 Undo 录制 → 可选保存」执行;Clear 无原生提交,先完成结果录制再保存。Host 的 `committed` 只表示产物不再被取消/超时/rollback 删除,不代表 Scene 已应用或保存,也不代表整个任务成功。 + +- 原生提交拒绝或回应丢失:不应用结果、不创建结果历史、不保存场景。Host 若实际上已提交,则新版本可能成为未引用资产;保留它,不强删、不自动重新 Bake。 +- 结果应用或录制前失败:恢复旧内存,不保存。已确认提交的产物仍保留,避免把不可逆的资产提交误当成可回滚事务。 +- Undo 已入栈后的保存失败/回应丢失:抛出包含 `LightFX result retained` 和原始原因的错误,**保留当前结果、Undo 和产物**。保存请求可能未写盘,也可能已写盘但没有返回确认;不能通过自动恢复旧内存或删除贴图来猜测磁盘状态。调用方应刷新实际结果,允许用户检查后重新保存或 Undo,不要把失败解释为“场景未改变”。 +- 失败时不会额外标记已保存。保存尚未写盘时结果保持 dirty;若保存已确认完成后才发生外层回应错误,内存与已保存结果相同,可以保持 clean。dirty 不是保存失败原因或磁盘写入状态的唯一证据。 +- 场景保存先等待 Terrain 资产保存。已注册 Terrain 服务抛错或批量结果报告失败时,不继续保存 `.scene`、不广播保存成功、不更新保存点;后一个 Terrain 成功也不能覆盖前一个失败。已经成功写入的 Terrain 文件不做猜测性回滚,失败项保留 dirty 供重试。 + +这保证正常运行实例中不先发布可被原生回滚删除的场景引用,但不是磁盘/Terrain/资产的多文件原子事务。进程崩溃恢复、任意并发编辑与保存协调、未引用版本安全回收仍需独立协议,不由此提交顺序承诺。 + +### 烘焙 Lightmap + +工具名:`scene-bake-lightmap` + +```json +{ + "options": { + "msaa": 4, + "resolution": 1024, + "filter": true, + "highp": false, + "giScale": 1, + "giSamples": 25, + "giPathLength": 4, + "aoLevel": 0, + "aoStrength": 0.5, + "aoRadius": 1, + "aoColor": [136, 136, 136, 255], + "threads": 1, + "saveScene": true, + "timeoutMs": 600000 + } +} +``` + +参数: + +| 参数 | 范围 | CLI 默认值 | +| --- | --- | --- | +| `outputUrl` | 已存在的 `db://assets` 内父目录 URL,仅 Lightmap 支持 | 发布父目录 `db://assets/LightFX` | +| `msaa` | 1、2、4、8 | 4 | +| `resolution` | 128、256、512、1024、2048 | 1024 | +| `filter` | boolean | `true` | +| `highp` | boolean | `false` | +| `giScale` | 0–100 | 1 | +| `giSamples` | 1–2590,整数 | 25 | +| `giPathLength` | 1、2、3、4 | 4 | +| `aoLevel` | 0、1、2 | 0 | +| `aoStrength` | ≥ 0 | 0.5 | +| `aoRadius` | ≥ 0 | 1 | +| `aoColor` | 3 个 RGB 值及可选 Alpha,单项 0–255 | `[136, 136, 136]` | +| `threads` | 1–256,整数 | 1 | +| `saveScene` | boolean | `true` | +| `timeoutMs` | 1000–3600000 ms | 600000 ms | + +未传入的参数使用 CLI 默认值。参数只影响本次烘焙,不写回 Creator 的 Lightmap 面板配置。 + +成功返回示例: + +```json +{ + "result": { + "code": 200, + "data": { + "sceneUrl": "db://assets/LightProbe.scene", + "textureUrls": [ + "db://assets/LightFX/scene-/output/LFX_Mesh_0000.png", + "db://assets/LightFX/scene-/output/LFX_Terrain_0000.png" + ], + "meshCount": 7, + "terrainCount": 1, + "durationMs": 4668 + } + } +} +``` + +### 查询 Lightmap 烘焙信息 + +工具名:`scene-query-lightmap-bake-info` + +该只读工具不接收参数。它从当前活动场景中 MeshRenderer 和 Terrain 的实际 Lightmap 绑定反查资源,不依赖 Creator Lightmap 面板的私有 Profile: + +```json +{ + "result": { + "code": 200, + "data": { + "sceneUrl": "db://assets/LightProbe.scene", + "baked": true, + "meshCount": 1, + "terrainCount": 0, + "highp": false, + "stationaryMainLight": false, + "textures": [ + { + "uuid": "texture-asset-uuid", + "url": "db://assets/LightFX/scene-/output/LFX_Mesh_0000.png", + "filename": "LFX_Mesh_0000.png", + "size": 45650, + "createdAt": 1788782429000, + "modifiedAt": 1788782429000 + } + ], + "missingTextureUuids": [] + } + } +} +``` + +`size` 的单位为字节,时间字段为 Unix 毫秒时间戳。`meshCount` 和 `terrainCount` 是当前绑定 Lightmap 的组件数量;重复使用的贴图在 `textures` 中只返回一次。场景仍然存在贴图绑定但 Asset DB 或源文件缺失时,根资源 UUID 会列入 `missingTextureUuids`。 + +Pink 应在场景打开、烘焙完成和清理完成后调用该工具刷新面板。缩略图加载、RGBA 通道切换和时间格式化由 Pink 根据资源 URL/UUID 实现,CLI 不传输图片像素。 + +#### 导出输入校验 + +`queryBakeInfo()` 只查询已烘焙的绑定与贴图信息,不提供额外的下次烘焙对象诊断。接收贴图的 Mesh 在实际导出时仍检查 UV1 长度是否等于顶点数的两倍、所有值是否有限;检查不包含 UV 重叠或自动展开。参与配置由 Inspector 的 Bake Settings 编辑;查询成功不代表输入有效或保证最终画质。 + +### 清理 Lightmap + +工具名:`scene-clear-lightmap` + +只解除场景绑定并保留贴图: + +```json +{ + "options": { + "saveScene": true, + "deleteAssets": false + } +} +``` + +解除绑定并删除没有其他引用的 LightFX 贴图及已登记的配套文件: + +```json +{ + "options": { + "saveScene": true, + "deleteAssets": true + } +} +``` + +`saveScene` 默认为 `true`,`deleteAssets` 默认为 `false`。调用 `deleteAssets:true` 前必须确认 `queryCapabilities().assetCleanupVersion === 1`;服务也会在修改场景前再次校验实际 Host 能力。成功结果中的 `clearedCount` 是解除绑定的 Mesh 和 Terrain block 总数,`deletedAssetCount`、`retainedAssetCount` 和 `failedAssetCount` 分别表示删除、因引用保留和删除失败的资产数量,包含原生配套文件。 + +删除模式先清空绑定,再序列化实时场景检查候选资源是否仍被其他字段引用;仍存在的根资源或子资源引用会保留。保存成功后才逐项删除经过 Asset DB 验证、归属明确且没有其他引用的产物,不删除父目录或同目录无关文件。外部引用、依赖查询失败或删除失败均保留并报告。 + +删除模式不清空节点移动等无关历史:保存成功后只取消 Clear 自身录制,并推进场景级结果代次。Undo/Redo 不恢复任何 Clear 前的 Lightmap 结果(包括未被物理删除的更早 Bake),但保留普通属性和 SH;Clear 后新的 Bake 历史仍可恢复。同一场景内部重建会转交代次,以兼容保留历史的软重载。实际删除的 UUID 另有悬空引用保护;明确保留/失败项解除删除保护,删除结果未知时保守保留。 + +成功 Bake 会替换完整场景结果:先清空旧绑定再应用本次输出,本次未参与的禁用/排除对象不继续展示旧结果;这些对象纳入结果记录及应用失败恢复范围。只有应用/录制/保存失败时才保留旧结果撤销;完整成功后旧 Lightmap 历史失效,普通属性和探针历史不变。 + +### 取消烘焙 + +工具名:`scene-cancel-lightfx-bake` + +该工具没有输入参数: + +```json +{} +``` + +成功返回示例: + +```json +{ + "result": { + "code": 200, + "data": { + "cancelled": true, + "target": "lightmap" + } + } +} +``` + +没有任务运行时,返回 `cancelled: false` 和 `target: null`。 + +Scene 侧 `LightProbeBake.cancel()`/`LightmapBake.cancel()` 只取消本 renderer 内对应类型的 Bake,不再取消共享 host 上其他场景/其他类型的任务。内部请求须带精确 operation ID、目标和 scene transaction ID;缺失/过期归属返回 `cancelled:false`,旧 host 缺少取消归属协议时拒绝请求,不回退到全局取消。导出阶段或 native begin 尚未返回 ID 时也返回 false;因此 false 不一定表示没有烘焙,而是这次请求没有取消任务。 + +`LightProbeBake.queryCapabilities()`/`LightmapBake.queryCapabilities()` 仅在实际 host 支持上述归属协议时额外返回 `cancelVersion:1` 和 `cancellable`,不支持时省略。`cancellable` 仅在本 Scene 对应类型的原生 operation 已取得 ID 时为 true;准备阶段为 false,供 UI 据实启用按钮,执行时仍核对精确归属。就绪快照不保证取消一定先于 commit,已提交的任务仍返回 false。该能力不代表持久任务快照;UI 可用自身的 renderer 会话 ID 保护取消消息,再调用该 Scene 的取消入口,任务结束仍以原 Bake Promise 完成回滚为准。 + +通用 MCP 工具保留按 Probe/Lightmap 依次尝试的行为,主进程已跟踪的 Bake 仍路由到原 renderer,不因切标签改投另一个场景。它不是跨客户端认证或公共持久任务句柄;需要严格防止客户端旧消息取消后续任务的 UI,仍须先接入独立任务身份契约。 + +取消成功后,取消工具本身返回 `code: 200`;原烘焙请求结束并返回 `code: 500`、`reason: "LightFX bake was cancelled."`。这是被取消任务的预期终态。 + +## 场景会话与资产规则 + +- Bake/Clear 在申请 Host 事务前同步捕获源 Scene 实例及编辑器会话代次;申请完成后再次校验,若已切换或重载则释放该事务并拒绝执行,不对新场景烘焙、清理或保存。原生烘焙期间允许打开或重载场景,结果应用前仍校验同一源会话;同 UUID 重载也视为新会话。 +- 结果应用、Undo 录制、保存和清理在原会话的生命周期队列内完成,打开、关闭、重载不会穿插其间。此保护不等同于锁住所有普通属性编辑;烘焙期间仍应避免修改输入几何和灯光。 +- 新产物先导入独立暂存目录:默认 `db://assets//lightmap/bake-/`,指定父目录时为 `/bake-/`。原生提交前失败或取消只回滚本轮产物。 +- 保存并清理旧产物成功后,PNG 保留 UUID 移动到 `<父目录>/scene-<完整场景UUID>/output/`。默认父目录为 `db://assets/LightFX`;`outputUrl: "db://assets"` 与省略相同。相同名称或相同自选父目录的不同场景也互相隔离。调用方必须使用返回的 `textureUrls`,不要拼路径。 +- 同一场景目录内包含 `output/LFX_Mesh_0000.png`、`output/LFX_Terrain_0000.png` 等 PNG,以及实际生成的 `tmp/lfx.in`、`output/lfx.out`、可选 `lfx.log`。配套文件不进入纹理预览列表;输入文件不承诺可脱离项目重放。 +- 新烘焙使用新 UUID,不覆盖同名资产。保存后精确清理旧产物,再发布新文件;冲突或部分移动失败保留已保存的新产物,不伪装成未修改场景。 +- `saveScene:false` 不固定发布、不删除旧资产、不隐式保存。保存失败也不清理旧产物。已确认提交的产物不再用原生 rollback 删除。 +- 归属记录在项目 `settings/lightfx-assets/<场景UUID>.json`,包含 textures 和 auxiliary UUID。旧平铺或自选目录中已记录的产物可以清理;旧版未记录且已解绑的文件不扫描、猜删。 +- 清理检查实时场景引用以及主资源和子资源的外部依赖,逐项确认源文件与 meta 删除。共享资源保留;当前重烘焙若有旧资源保留,仍报告清理未完成并保留已保存结果,不能当作未执行。 +- 已登记的资源在初始化完成、空闲的 Asset DB 中明确不存在时,移除失效归属记录,不计作本次物理删除。数据库未就绪、忙碌或查询抛错时保留记录供重试。 +- 文件移动遇到冲突不覆盖目标;失败时只恢复本次移走且内容未变化的 meta。若源/目标 meta 已被其他操作替换,明确报告安全恢复失败,保留现场,不覆盖别人的元数据。 +- 只非递归删除已清空的 `bake-UUID` 暂存目录;不递归删除输出父目录。损坏归属文件、恢复失败或宿主失联需要排查,不通过手工删记录来解除保护。 + +## Creator 互操作说明 + +CLI 烘焙并保存后,Creator 重新打开场景可以正常加载和显示 Light Probe 与 Lightmap 结果。在 Pink 中通过当前可见的 Scene Webview 烘焙时,结果会直接应用并重绘,无需重启编辑器。 + +Creator Lightmap 面板的“清除”操作依赖该面板自己保存的 `latestLightmapResultDir`。CLI 不写入 Creator 的私有面板状态,因此 Creator 面板可能无法清除 CLI 生成的 Lightmap。请使用 `scene-clear-lightmap` 清理 CLI 烘焙结果。CLI 不伪造 Creator Profile 状态,以避免耦合面板内部实现或误删资源。 + +Pink 的烘焙信息面板应使用 `scene-query-lightmap-bake-info`,以当前场景真实绑定作为数据源,不需要兼容 Creator 的 `latestLightmapResultMap`。 + +## 运行时兼容性 + +随 Creator 提供的 LightFX 可执行程序使用 Socket.IO 2.x 协议,而 CLI 现有服务使用 Socket.IO 4.x。项目通过 npm alias `socket.io-v2` 提供仅供 LightFX 本地进程桥接使用的 2.3.0 服务: + +- 只监听本机随机端口。 +- 不替换 MCP 或其他现有 Socket.IO 4.x 服务。 +- LightFX 升级并支持 Socket.IO 4.x 后可以移除该兼容依赖。 + +LightFX 当前可能输出 Creator 历史协议版本。解析器只接受已知兼容版本,并拒绝未知版本、截断数据、非法长度及非有限浮点数。 + +## main 合并兼容性与验证 + +- `Scene.Gizmo.deleteSelectedLightProbes()` 和 `duplicateSelectedLightProbes()` 返回 `Promise`,调用方须 `await` 后读取数量。 +- 地形保存异常会阻止场景保存,批量成功项不掩盖失败项;这修正了之前吞错的行为。 +- 普通无生成探针的场景跳过新增子树扫描。探针同步、结果历史保护仍集中于专用辅助模块,公共入口保留调用。 +- 烘焙目录结构已按场景 UUID 隔离;更新 CLI 后重启实际 Node Host 和 Scene runtime,不混用旧产物。 +- 自动回归覆盖场景切换/同 UUID 重载、生命周期队列内保存、双场景同目录重烘焙/清理、旧目录迁移、已删配套文件重试和 meta 冲突恢复。 +- Pink 手动验证:A 场景开始烘焙后打开/重载场景,确认旧任务拒绝写回;A/B 使用相同输出父目录分别 Bake,重烘焙并清理 A,确认 B 不变;删除生成的 log 后重试;最后验证普通节点编辑、Undo/Redo、Terrain 保存和关闭重开。 +- 自动测试不替代当前版本的原生烘焙与画面验收。历史联调证据见 [历史验证记录](history/lightfx-bake-validation.md),不作为当前版本全量通过的证明。 diff --git a/package-lock.json b/package-lock.json index 6a04697bd..bdd8a08c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "sharp": "0.32.6", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "socket.io-v2": "npm:socket.io@2.3.0", "strip-ansi": "^6.0.1", "tga-js": "^1.1.1", "tmp": "^0.0.33", @@ -9240,6 +9241,12 @@ "node": ">=0.4.0" } }, + "node_modules/after": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/after/-/after-0.8.2.tgz", + "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", @@ -9455,6 +9462,12 @@ "node": ">=0.10.0" } }, + "node_modules/arraybuffer.slice": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz", + "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==", + "license": "MIT" + }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmmirror.com/asn1.js/-/asn1.js-4.10.1.tgz", @@ -9560,6 +9573,12 @@ "license": "MIT", "optional": true }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" + }, "node_modules/async-settle": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/async-settle/-/async-settle-2.0.0.tgz", @@ -9942,6 +9961,12 @@ "node": ">=10.13.0" } }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -10082,6 +10107,14 @@ "node": ">= 0.4" } }, + "node_modules/base64-arraybuffer": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz", + "integrity": "sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -10130,6 +10163,17 @@ "node": ">=10.0.0" } }, + "node_modules/better-assert": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/better-assert/-/better-assert-1.0.2.tgz", + "integrity": "sha512-bYeph2DFlpK1XmGs6fvlLRUN29QISM3GBuUwSFsMY2XRx4AvC0WNCS57j4c/xGrK2RS24C1w3YoBOsw9fT46tQ==", + "dependencies": { + "callsite": "1.0.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmmirror.com/big.js/-/big.js-5.2.2.tgz", @@ -10216,6 +10260,12 @@ "node": ">= 6" } }, + "node_modules/blob": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/blob/-/blob-0.0.5.tgz", + "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz", @@ -10831,6 +10881,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", @@ -11351,6 +11409,11 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "license": "MIT" }, + "node_modules/component-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/component-bind/-/component-bind-1.0.0.tgz", + "integrity": "sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==" + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", @@ -11360,6 +11423,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/component-inherit": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/component-inherit/-/component-inherit-0.0.3.tgz", + "integrity": "sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz", @@ -14627,6 +14695,27 @@ "node": ">= 0.4.0" } }, + "node_modules/has-binary2": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/has-binary2/-/has-binary2-1.0.3.tgz", + "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==", + "license": "MIT", + "dependencies": { + "isarray": "2.0.1" + } + }, + "node_modules/has-binary2/node_modules/isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==", + "license": "MIT" + }, + "node_modules/has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==", + "license": "MIT" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", @@ -15046,6 +15135,11 @@ "node": ">=0.8.19" } }, + "node_modules/indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==" + }, "node_modules/infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/infer-owner/-/infer-owner-1.0.4.tgz", @@ -19855,6 +19949,11 @@ "node": ">=0.10.0" } }, + "node_modules/object-component": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/object-component/-/object-component-0.0.3.tgz", + "integrity": "sha512-S0sN3agnVh2SZNEIGc0N1X4Z5K0JeFbGBrnuZpsxuUh5XLF0BnvWkMjRXo/zGKLd/eghvNIKcx1pQkmUjXIyrA==" + }, "node_modules/object-copy": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/object-copy/-/object-copy-0.1.0.tgz", @@ -20371,6 +20470,24 @@ "node": ">=0.10.0" } }, + "node_modules/parseqs": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/parseqs/-/parseqs-0.0.5.tgz", + "integrity": "sha512-B3Nrjw2aL7aI4TDujOzfA4NsEc4u1lVcIRE0xesutH8kjeWF70uk+W5cBlIQx04zUH9NTBvuN36Y9xLRPK6Jjw==", + "license": "MIT", + "dependencies": { + "better-assert": "~1.0.0" + } + }, + "node_modules/parseuri": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/parseuri/-/parseuri-0.0.5.tgz", + "integrity": "sha512-ijhdxJu6l5Ru12jF0JvzXVPvsC+VibqeaExlNoMhWN6VQ79PGjkmc7oA4W1lp00sFkNyj0fx6ivPLdV51/UMog==", + "license": "MIT", + "dependencies": { + "better-assert": "~1.0.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", @@ -23253,6 +23370,303 @@ } } }, + "node_modules/socket.io-v2": { + "name": "socket.io", + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/socket.io/-/socket.io-2.3.0.tgz", + "integrity": "sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==", + "license": "MIT", + "dependencies": { + "debug": "~4.1.0", + "engine.io": "~3.4.0", + "has-binary2": "~1.0.2", + "socket.io-adapter": "~1.1.0", + "socket.io-client": "2.3.0", + "socket.io-parser": "~3.4.0" + } + }, + "node_modules/socket.io-v2/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha512-jPatnhd33viNplKjqXKRkGU345p263OIWzDL2wH3LGIGp5Kojo+uXizHmOADRvhGFFTnJqX3jBAKP6vvmSDKcA==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/debug": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/engine.io/-/engine.io-3.4.2.tgz", + "integrity": "sha512-b4Q85dFkGw+TqgytGPrGgACRUhsdKc9S9ErRAXpPGy/CXKs4tYoHDkvIRdsseAF7NjfVwjRFIn6KTnbw7LwJZg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "0.3.1", + "debug": "~4.1.0", + "engine.io-parser": "~2.2.0", + "ws": "^7.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-3.4.4.tgz", + "integrity": "sha512-iU4CRr38Fecj8HoZEnFtm2EiKGbYZcPn3cHxqNGl/tmdWRf60KhK+9vE0JeSjgnlS/0oynEfLgKbT9ALpim0sQ==", + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "component-inherit": "0.0.3", + "debug": "~3.1.0", + "engine.io-parser": "~2.2.0", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "parseqs": "0.0.6", + "parseuri": "0.0.6", + "ws": "~6.1.0", + "xmlhttprequest-ssl": "~1.5.4", + "yeast": "0.1.2" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/parseqs": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/parseqs/-/parseqs-0.0.6.tgz", + "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/parseuri": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/parseuri/-/parseuri-0.0.6.tgz", + "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/engine.io-client/node_modules/ws": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/ws/-/ws-6.1.4.tgz", + "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/engine.io-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz", + "integrity": "sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==", + "license": "MIT", + "dependencies": { + "after": "0.8.2", + "arraybuffer.slice": "~0.0.7", + "base64-arraybuffer": "0.1.4", + "blob": "0.0.5", + "has-binary2": "~1.0.2" + } + }, + "node_modules/socket.io-v2/node_modules/isarray": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.1.tgz", + "integrity": "sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-adapter": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz", + "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/socket.io-client": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-2.3.0.tgz", + "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==", + "license": "MIT", + "dependencies": { + "backo2": "1.0.2", + "base64-arraybuffer": "0.1.5", + "component-bind": "1.0.0", + "component-emitter": "1.2.1", + "debug": "~4.1.0", + "engine.io-client": "~3.4.0", + "has-binary2": "~1.0.2", + "has-cors": "1.1.0", + "indexof": "0.0.1", + "object-component": "0.0.3", + "parseqs": "0.0.5", + "parseuri": "0.0.5", + "socket.io-parser": "~3.3.0", + "to-array": "0.1.4" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/base64-arraybuffer": { + "version": "0.1.5", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", + "integrity": "sha512-437oANT9tP582zZMwSvZGy2nmSeAb8DW2me3y+Uv1Wp2Rulr8Mqlyrv3E7MLxmsiaPSMMDmiDVzgE+e8zlMx9g==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/socket.io-parser": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-3.3.6.tgz", + "integrity": "sha512-+VwteZF0qtYVkcO1nhKf6ZUVz5wq6Ya+Lx10y3Y81Sp5+iyGGY2GTcd81W36C/r1tjP+GhXjiXaLKlCMyE+yHQ==", + "license": "MIT", + "dependencies": { + "component-emitter": "~1.3.0", + "debug": "~3.1.0", + "isarray": "2.0.1" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/socket.io-parser/node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-client/node_modules/socket.io-parser/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/socket.io-parser": { + "version": "3.4.5", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-3.4.5.tgz", + "integrity": "sha512-nrhzTtwpgt8tN+la+9Tpzj2epV7FMtr+9lqcfpwopjoxBfUXvBR7TWwF+/UT7RiTbFcHiEJdvRZ7yptJgZxo1Q==", + "license": "MIT", + "dependencies": { + "component-emitter": "1.2.1", + "debug": "~4.1.0", + "isarray": "2.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-v2/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-v2/node_modules/xmlhttprequest-ssl": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", + "integrity": "sha512-/bFPLUgJrfGUL10AIv4Y7/CUt6so9CLtB/oFxQSHseSDNNCdC6vwwKEqwLN6wNPBg9YWXAiMu8jkf6RPRS/75Q==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/socket.io/node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -24238,6 +24652,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-array": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/to-array/-/to-array-0.1.4.tgz", + "integrity": "sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==" + }, "node_modules/to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", @@ -26728,6 +27147,12 @@ "decamelize": "^1.2.0" } }, + "node_modules/yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==", + "license": "MIT" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index dc3458abc..3c9d8b9d0 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "sharp": "0.32.6", "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", + "socket.io-v2": "npm:socket.io@2.3.0", "strip-ansi": "^6.0.1", "tga-js": "^1.1.1", "tmp": "^0.0.33", 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 ec9208aa4..794f8853e 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 @@ -6704,8 +6704,8 @@ export declare interface IGizmoService { selectAllLightProbes(): void; unselectAllLightProbes(): void; queryLightProbeSelectedCount(): number; - deleteSelectedLightProbes(): number; - duplicateSelectedLightProbes(): number; + deleteSelectedLightProbes(): Promise; + duplicateSelectedLightProbes(): Promise; generateLightProbes(): number; regionSelectLightProbes(left: number, right: number, top: number, bottom: number, additive: boolean): number; } @@ -6718,6 +6718,132 @@ export declare interface IInsertLODOptions { export declare interface IIsPrefabInstanceParams { nodePath: string; } +export declare interface ILightFXCancelResult { + cancelled: boolean; + target: 'light-probe' | 'lightmap' | null; +} +export declare interface ILightFXDiagnostics { + operationId?: string; + version: 1; + stage: string; + logs: string[]; + progress?: string; + rate?: number; +} +export declare interface ILightmapBakeCapabilities { + outputDirectory?: true; + diagnostics?: ILightFXDiagnostics; + version: 1; + resultLifecycleVersion: 1; + sceneTransactionVersion: 1; + assetVersion: 1; + assetCleanupVersion?: 1; + cancelVersion?: 1; + cancellable?: boolean; + busy: boolean; +} +export declare interface ILightmapBakeInfo { + sceneUrl: string; + baked: boolean; + meshCount: number; + terrainCount: number; + highp: boolean; + stationaryMainLight: boolean; + textures: ILightmapTextureInfo[]; + missingTextureUuids: string[]; +} +export declare interface ILightmapBakeOptions { + outputUrl?: string; + msaa?: 1 | 2 | 4 | 8; + resolution?: 128 | 256 | 512 | 1024 | 2048; + filter?: boolean; + highp?: boolean; + giScale?: number; + giSamples?: number; + giPathLength?: 1 | 2 | 3 | 4; + aoLevel?: 0 | 1 | 2; + aoStrength?: number; + aoRadius?: number; + aoColor?: [number, number, number, number?]; + threads?: number; + saveScene?: boolean; + timeoutMs?: number; +} +export declare interface ILightmapBakeResult { + diagnostics?: ILightFXDiagnostics; + sceneUrl: string; + textureUrls: string[]; + meshCount: number; + terrainCount: number; + durationMs: number; +} +export declare interface ILightmapBakeService extends IServiceEvents { + queryCapabilities(): Promise; + bake(options: ILightmapBakeOptions): Promise; + queryBakeInfo(): Promise; + clearBake(options?: { + saveScene?: boolean; + deleteAssets?: boolean; + }): Promise; + cancel(): Promise; +} +export declare interface ILightmapClearResult { + clearedCount: number; + deletedAssetCount: number; + retainedAssetCount: number; + failedAssetCount: number; +} +export declare interface ILightmapTextureInfo { + uuid: string; + url: string; + filename: string; + size: number; + createdAt: number; + modifiedAt: number; +} +export declare interface ILightProbeBakeCapabilities { + diagnostics?: ILightFXDiagnostics; + version: 1; + cancelVersion?: 1; + cancellable?: boolean; + resultLifecycleVersion: 1; + sceneTransactionVersion: 1; + busy: boolean; +} +export declare interface ILightProbeBakeOptions { + giScale?: number; + giSamples?: number; + bounces?: number; + reduceRinging?: number; + showWireframe?: boolean; + showConvex?: boolean; + lightProbeSphereVolume?: number; + saveScene?: boolean; + timeoutMs?: number; +} +export declare interface ILightProbeBakeResult { + diagnostics?: ILightFXDiagnostics; + sceneUrl: string; + probeCount: number; + giScale: number; + giSamples: number; + bounces: number; + reduceRinging: number; + showWireframe: boolean; + showConvex: boolean; + lightProbeSphereVolume: number; + durationMs: number; +} +export declare interface ILightProbeBakeService extends IServiceEvents { + queryCapabilities(): Promise; + bake(options: ILightProbeBakeOptions): Promise; + clearBake(options?: { + saveScene?: boolean; + }): Promise<{ + probeCount: number; + }>; + cancel(): Promise; +} export declare interface ILODGroupBoundsResult { localBoundaryCenter: IVec3; objectSize: number; @@ -7332,6 +7458,8 @@ export declare interface IServiceManager { UI: IUIService; ReflectionProbe: IReflectionProbeService; ReferenceImage: IReferenceImageService; + LightProbeBake: ILightProbeBakeService; + LightmapBake: ILightmapBakeService; Particle: IParticleService; Terrain: ITerrainService; } diff --git a/src/api/scene/lightfx-bake-schema.ts b/src/api/scene/lightfx-bake-schema.ts new file mode 100644 index 000000000..f092775c8 --- /dev/null +++ b/src/api/scene/lightfx-bake-schema.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import { MAX_LIGHTMAP_GI_SAMPLES } from '../../core/scene/common/lightfx-limits'; + +const SaveAndTimeout = { + saveScene: z.boolean().optional().describe('Save the current scene after applying the bake result; defaults to true'), + timeoutMs: z.number().int().min(1_000).max(3_600_000).optional().describe('Whole-operation timeout in milliseconds'), +}; + +export const SchemaLightProbeBakeOptions = z.object({ + giScale: z.number().finite().min(0).max(100).optional().describe('GI multiplier; defaults to the current scene value'), + giSamples: z.number().int().min(64).max(65535).optional().describe('GI probe sample count; defaults to the current scene value'), + bounces: z.number().int().min(1).max(4).optional().describe('Probe ray bounce count; defaults to the current scene value'), + reduceRinging: z.number().finite().min(0).max(0.05).optional().describe('Spherical-harmonic ringing reduction; defaults to the current scene value'), + showWireframe: z.boolean().optional().describe('Show light-probe connections in the scene view; defaults to the current scene value'), + showConvex: z.boolean().optional().describe('Show the light-probe convex hull in the scene view; defaults to the current scene value'), + lightProbeSphereVolume: z.number().finite().min(0).max(100).optional().describe('Light-probe sphere display size; defaults to the current scene value'), + ...SaveAndTimeout, +}).describe('Light probe bake options'); + +export const SchemaLightProbeBakeResult = z.object({ + sceneUrl: z.string(), probeCount: z.number().int().nonnegative(), + giScale: z.number(), giSamples: z.number().int(), bounces: z.number().int(), + reduceRinging: z.number(), showWireframe: z.boolean(), showConvex: z.boolean(), lightProbeSphereVolume: z.number(), + durationMs: z.number().nonnegative(), +}); + +export const SchemaLightmapBakeOptions = z.object({ + outputUrl: z.string().optional().describe('Existing parent directory under db://assets. Saved results publish into scene-/output; omitted or db://assets uses db://assets/LightFX as parent. Use returned texture URLs.'), + msaa: z.union([z.literal(1), z.literal(2), z.literal(4), z.literal(8)]).optional(), + resolution: z.union([z.literal(128), z.literal(256), z.literal(512), z.literal(1024), z.literal(2048)]).optional(), + filter: z.boolean().optional(), highp: z.boolean().optional(), + giScale: z.number().finite().min(0).max(100).optional(), + giSamples: z.number().int().min(1).max(MAX_LIGHTMAP_GI_SAMPLES).optional() + .describe('Lightmap GI sampling factor; 1–2590. Large values have quadratic memory and time costs.'), + giPathLength: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional(), + aoLevel: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + aoStrength: z.number().finite().min(0).optional(), + aoRadius: z.number().finite().min(0).optional(), + aoColor: z.tuple([z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255).optional()]).optional(), + threads: z.number().int().min(1).max(256).optional(), + ...SaveAndTimeout, +}).describe('Lightmap bake options'); + +export const SchemaLightmapBakeResult = z.object({ + sceneUrl: z.string(), textureUrls: z.array(z.string()), meshCount: z.number().int().nonnegative(), + terrainCount: z.number().int().nonnegative(), durationMs: z.number().nonnegative(), +}); + +export const SchemaLightmapTextureInfo = z.object({ + uuid: z.string(), + url: z.string(), + filename: z.string(), + size: z.number().int().nonnegative(), + createdAt: z.number().finite().nonnegative(), + modifiedAt: z.number().finite().nonnegative(), +}); + +export const SchemaLightmapBakeInfo = z.object({ + sceneUrl: z.string(), + baked: z.boolean(), + meshCount: z.number().int().nonnegative(), + terrainCount: z.number().int().nonnegative(), + highp: z.boolean(), + stationaryMainLight: z.boolean(), + textures: z.array(SchemaLightmapTextureInfo), + missingTextureUuids: z.array(z.string()), +}); + +export const SchemaLightFXCancelResult = z.object({ + cancelled: z.boolean(), target: z.enum(['light-probe', 'lightmap']).nullable(), +}); + +export const SchemaLightProbeClearOptions = z.object({ saveScene: z.boolean().optional() }); +export const SchemaLightmapClearOptions = z.object({ saveScene: z.boolean().optional(), deleteAssets: z.boolean().optional() }); +export const SchemaClearCountResult = z.object({ + probeCount: z.number().int().nonnegative().optional(), + clearedCount: z.number().int().nonnegative().optional(), + deletedAssetCount: z.number().int().nonnegative().optional(), + retainedAssetCount: z.number().int().nonnegative().optional(), + failedAssetCount: z.number().int().nonnegative().optional(), +}); + +export type TLightProbeBakeOptions = z.infer; +export type TLightProbeBakeResult = z.infer; +export type TLightmapBakeOptions = z.infer; +export type TLightmapBakeResult = z.infer; +export type TLightmapBakeInfo = z.infer; diff --git a/src/api/scene/lightfx-bake.ts b/src/api/scene/lightfx-bake.ts new file mode 100644 index 000000000..2de49d13c --- /dev/null +++ b/src/api/scene/lightfx-bake.ts @@ -0,0 +1,72 @@ +import { COMMON_STATUS, CommonResultType } from '../base/schema-base'; +import { description, param, result, title, tool } from '../decorator/decorator'; +import { Scene } from '../../core/scene'; +import { + SchemaClearCountResult, SchemaLightFXCancelResult, SchemaLightmapBakeInfo, + SchemaLightmapBakeOptions, SchemaLightmapBakeResult, + SchemaLightmapClearOptions, SchemaLightProbeBakeOptions, SchemaLightProbeBakeResult, SchemaLightProbeClearOptions, + TLightmapBakeInfo, TLightmapBakeOptions, TLightmapBakeResult, TLightProbeBakeOptions, TLightProbeBakeResult, +} from './lightfx-bake-schema'; + +async function execute(operation: () => Promise): Promise> { + try { return { code: COMMON_STATUS.SUCCESS, data: await operation() }; } + catch (error) { return { code: COMMON_STATUS.FAIL, reason: error instanceof Error ? error.message : String(error) }; } +} + +export class LightFXBakeApi { + @tool('scene-bake-light-probes') + @title('Bake light probes') + @description('Bake all light probes with the effective panel settings and write both settings and spherical-harmonic coefficients back to the current scene.') + @result(SchemaLightProbeBakeResult) + bakeLightProbes(@param(SchemaLightProbeBakeOptions) options: TLightProbeBakeOptions): Promise> { + return execute(() => Scene.LightProbeBake.bake(options)); + } + + @tool('scene-clear-light-probes') + @title('Clear baked light probes') + @description('Clear spherical-harmonic bake results from all light probes in the current scene.') + @result(SchemaClearCountResult) + clearLightProbes(@param(SchemaLightProbeClearOptions) options: { saveScene?: boolean }): Promise> { + return execute(() => Scene.LightProbeBake.clearBake(options)); + } + + @tool('scene-bake-lightmap') + @title('Bake lightmap') + @description('Bake the current scene lightmap with LightFX, import generated textures, bind them to renderers and save the scene.') + @result(SchemaLightmapBakeResult) + bakeLightmap(@param(SchemaLightmapBakeOptions) options: TLightmapBakeOptions): Promise> { + return execute(() => Scene.LightmapBake.bake(options)); + } + + @tool('scene-query-lightmap-bake-info') + @title('Query lightmap bake information') + @description('Query lightmap textures and bake flags currently bound to meshes and terrains in the active scene.') + @result(SchemaLightmapBakeInfo) + queryLightmapBakeInfo(): Promise> { + return execute(() => Scene.LightmapBake.queryBakeInfo()); + } + + @tool('scene-clear-lightmap') + @title('Clear baked lightmap') + @description('Unbind baked lightmaps and optionally delete unreferenced generated assets. Asset deletion saves the scene and prevents Undo/Redo from restoring pre-Clear baked results; unrelated edit history is preserved.') + @result(SchemaClearCountResult) + clearLightmap(@param(SchemaLightmapClearOptions) options: { saveScene?: boolean; deleteAssets?: boolean }): Promise> { + return execute(() => Scene.LightmapBake.clearBake(options)); + } + + @tool('scene-cancel-lightfx-bake') + @title('Cancel LightFX bake') + @description('Cancel the currently running light-probe or lightmap bake.') + @result(SchemaLightFXCancelResult) + cancel(): Promise> { + return execute(async () => { + const probe = await Scene.LightProbeBake.cancel(); + return probe.cancelled ? probe : Scene.LightmapBake.cancel(); + }); + } +} diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index ac5ba2325..b45e60c21 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -25,6 +25,7 @@ import { NodeApi } from './node'; import { PrefabApi } from './prefab'; import { ReflectionProbeApi } from './reflection-probe'; import { ReferenceImageApi } from './reference-image'; +import { LightFXBakeApi } from './lightfx-bake'; import { ParticleApi } from './particle'; import { options } from '../../core/builder/platforms/android/i18n/en'; @@ -34,6 +35,7 @@ export class SceneApi { public prefab: PrefabApi; public reflectionProbe: ReflectionProbeApi; public referenceImage: ReferenceImageApi; + public lightFXBake: LightFXBakeApi; public particle: ParticleApi; constructor() { @@ -42,6 +44,7 @@ export class SceneApi { this.prefab = new PrefabApi(); this.reflectionProbe = new ReflectionProbeApi(); this.referenceImage = new ReferenceImageApi(); + this.lightFXBake = new LightFXBakeApi(); this.particle = new ParticleApi(); } diff --git a/src/core/assets/manager/filesystem.ts b/src/core/assets/manager/filesystem.ts index 230ae9532..a6a3c7cfd 100644 --- a/src/core/assets/manager/filesystem.ts +++ b/src/core/assets/manager/filesystem.ts @@ -143,8 +143,27 @@ export async function moveAssetSource(source: string, target: string, options?: try { if (!Utils.Path.contains(source, target)) { - await renamePath(source + '.meta', target + '.meta', { overwrite: true }); - await renamePath(source, target, renameOptions); + const originalMeta = !renameOptions.overwrite ? Buffer.from(await readPath(source + '.meta')) : undefined; + await renamePath(source + '.meta', target + '.meta', renameOptions); + try { + await renamePath(source, target, renameOptions); + } catch (error) { + // Keep the original UUID when a non-overwriting source move fails. + // Propagate failure before Asset DB refresh can generate a replacement meta. + if (originalMeta && existsSync(source)) { + try { + // A competing destination PNG does not own our metadata. Restore only + // the exact bytes moved by this operation, never a replacement meta. + if (existsSync(source + '.meta') || !Buffer.from(await readPath(target + '.meta')).equals(originalMeta)) { + throw new Error('Asset metadata changed during the failed move; manual recovery is required.'); + } + await renamePath(target + '.meta', source + '.meta', { overwrite: false }); + } catch (recoveryError) { + throw new AggregateError([error, recoveryError], 'Asset move failed and original metadata could not be restored safely.'); + } + } + throw error; + } return; } @@ -168,5 +187,6 @@ export async function moveAssetSource(source: string, target: string, options?: } catch (error) { console.error(`asset db moveFile from ${source} -> ${target} fail!`); console.error(error); + if (!renameOptions.overwrite) throw error; } } diff --git a/src/core/assets/test/manager-filesystem.test.ts b/src/core/assets/test/manager-filesystem.test.ts index 8f84d6bff..ff9824220 100644 --- a/src/core/assets/test/manager-filesystem.test.ts +++ b/src/core/assets/test/manager-filesystem.test.ts @@ -44,7 +44,7 @@ jest.mock('../asset-config', () => ({ describe('asset filesystem manager', () => { beforeEach(() => { jest.resetModules(); - jest.clearAllMocks(); + jest.resetAllMocks(); }); it('should expose a provider-shaped local fallback and keep fallback methods after partial override', () => { @@ -118,9 +118,10 @@ describe('asset filesystem manager', () => { expect(mockRemove).not.toHaveBeenCalled(); }); - it('moveAssetSource should delegate rename to custom provider for source and meta files', async () => { + it.each([undefined, false, true])('moveAssetSource forwards overwrite=%s to custom provider for both files', async overwrite => { const filesystem = require('../manager/filesystem') as typeof import('../manager/filesystem'); const provider = { + readFile: jest.fn(async () => Buffer.from('{"uuid":"source"}')), rename: jest.fn(async () => {}), }; const source = 'D:/project/assets/source.txt'; @@ -128,10 +129,36 @@ describe('asset filesystem manager', () => { filesystem.setFileSystemProvider(provider); - await filesystem.moveAssetSource(source, target, { overwrite: false }); + await filesystem.moveAssetSource(source, target, overwrite === undefined ? undefined : { overwrite }); - expect(provider.rename).toHaveBeenNthCalledWith(1, `${source}.meta`, `${target}.meta`, { overwrite: true }); - expect(provider.rename).toHaveBeenNthCalledWith(2, source, target, { overwrite: false }); + // A non-overwriting move must not replace another asset's UUID either. + expect(provider.rename).toHaveBeenNthCalledWith(1, `${source}.meta`, `${target}.meta`, { overwrite: !!overwrite }); + expect(provider.rename).toHaveBeenNthCalledWith(2, source, target, { overwrite: !!overwrite }); + expect(provider.rename).toHaveBeenCalledTimes(2); + if (!overwrite) { + expect(provider.readFile).toHaveBeenCalledWith(`${source}.meta`, undefined); + } + expect(mockReadFile).not.toHaveBeenCalled(); expect(mockMove).not.toHaveBeenCalled(); }); + + it('does not move either file if the custom provider cannot read metadata for safe recovery', async () => { + const filesystem = require('../manager/filesystem') as typeof import('../manager/filesystem'); + const error = new Error('metadata read denied'); + const provider = { + readFile: jest.fn(async () => { throw error; }), + rename: jest.fn(async () => {}), + }; + filesystem.setFileSystemProvider(provider); + const log = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(filesystem.moveAssetSource('source.txt', 'target.txt')).rejects.toBe(error); + + expect(provider.rename).not.toHaveBeenCalled(); + expect(mockReadFile).not.toHaveBeenCalled(); + expect(mockMove).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); }); diff --git a/src/core/assets/test/move-source-failure.test.ts b/src/core/assets/test/move-source-failure.test.ts new file mode 100644 index 000000000..7b712c605 --- /dev/null +++ b/src/core/assets/test/move-source-failure.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, outputFile, readFile, pathExists, remove, move } from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +jest.mock('../asset-config', () => ({ __esModule: true, default: { data: {} } })); +jest.mock('../../base/utils', () => ({ __esModule: true, default: { Path: jest.requireActual('../../base/utils/path') } })); +import assetConfig from '../asset-config'; +import { moveAssetSource, resetFileSystemProvider, setFileSystemProvider } from '../manager/filesystem'; + +describe('asset source move safety and compatibility', () => { + let root: string, source: string, target: string; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'asset-move-failure-')); + assetConfig.data.root = root; + assetConfig.data.tempRoot = join(root, 'temp'); + source = join(root, 'source.png'); + target = join(root, 'output.png'); + await outputFile(source, 'new pixels'); + await outputFile(`${source}.meta`, '{"uuid":"original"}'); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(async () => { + resetFileSystemProvider(); + jest.restoreAllMocks(); + await remove(root); + }); + it('restores metadata and rejects before Asset DB can refresh a failed PNG move', async () => { + setFileSystemProvider({ rename: async (from, to, options) => { + if (from === source) throw new Error('PNG move denied'); + await move(from, to, { overwrite: !!options?.overwrite }); + } }); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow('PNG move denied'); + expect(await readFile(source, 'utf8')).toBe('new pixels'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(`${target}.meta`)).toBe(false); + expect(await pathExists(target)).toBe(false); + }); + it.each([undefined, { overwrite: false }])('does not overwrite target metadata with options %j', async options => { + await outputFile(`${target}.meta`, 'unrelated'); + await expect(moveAssetSource(source, target, options)).rejects.toThrow(); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('unrelated'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(true); + }); + it.each([undefined, { overwrite: false }, { overwrite: true }])('moves both files with their UUID using options %j', async options => { + await moveAssetSource(source, target, options); + expect(await readFile(target, 'utf8')).toBe('new pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(false); + expect(await pathExists(`${source}.meta`)).toBe(false); + }); + it('replaces both the target source and metadata when overwrite is explicitly allowed', async () => { + await outputFile(target, 'old pixels'); + await outputFile(`${target}.meta`, '{"uuid":"old-target"}'); + + await moveAssetSource(source, target, { overwrite: true }); + + expect(await readFile(target, 'utf8')).toBe('new pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(false); + expect(await pathExists(`${source}.meta`)).toBe(false); + }); + it('preserves both assets when the target already exists and overwrite is omitted', async () => { + await outputFile(target, 'unrelated pixels'); + await outputFile(`${target}.meta`, '{"uuid":"unrelated"}'); + + await expect(moveAssetSource(source, target)).rejects.toThrow(); + + expect(await readFile(source, 'utf8')).toBe('new pixels'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await readFile(target, 'utf8')).toBe('unrelated pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"unrelated"}'); + }); + it.each(['sibling', 'nested'])('preserves directory and child UUIDs when moving to a %s path', async location => { + const folder = join(root, 'folder'); + const destination = location === 'nested' ? join(folder, 'nested') : join(root, 'moved-folder'); + await outputFile(`${folder}.meta`, '{"uuid":"folder"}'); + await outputFile(join(folder, 'child.png'), 'child pixels'); + await outputFile(join(folder, 'child.png.meta'), '{"uuid":"child"}'); + + await moveAssetSource(folder, destination); + + expect(await readFile(`${destination}.meta`, 'utf8')).toBe('{"uuid":"folder"}'); + expect(await readFile(join(destination, 'child.png'), 'utf8')).toBe('child pixels'); + expect(await readFile(join(destination, 'child.png.meta'), 'utf8')).toBe('{"uuid":"child"}'); + expect(await pathExists(`${folder}.meta`)).toBe(false); + expect(await pathExists(join(folder, 'child.png'))).toBe(false); + expect(await pathExists(join(root, 'temp', 'move-temp', location === 'nested' ? 'folder/nested' : 'moved-folder'))).toBe(false); + }); + it('uses the local metadata reader when only rename is overridden', async () => { + const rename = jest.fn(async (from: string, to: string, options?: { overwrite?: boolean }) => { + await move(from, to, { overwrite: !!options?.overwrite }); + }); + setFileSystemProvider({ rename }); + + await moveAssetSource(source, target); + + expect(rename).toHaveBeenCalledTimes(2); + expect(await readFile(target, 'utf8')).toBe('new pixels'); + expect(await readFile(`${target}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(source)).toBe(false); + expect(await pathExists(`${source}.meta`)).toBe(false); + }); + it('restores the source metadata even when a competing target PNG appears', async () => { + setFileSystemProvider({ rename: async (from, to, options) => { + if (from === source) await outputFile(target, 'unrelated pixels'); + await move(from, to, { overwrite: !!options?.overwrite }); + } }); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow(); + expect(await readFile(target, 'utf8')).toBe('unrelated pixels'); + expect(await readFile(`${source}.meta`, 'utf8')).toBe('{"uuid":"original"}'); + expect(await pathExists(`${target}.meta`)).toBe(false); + }); + it.each(['source', 'target'])('never overwrites a replacement %s metadata during recovery', async location => { + setFileSystemProvider({ rename: async (from, to, options) => { + if (from === source) { + await outputFile(`${location === 'source' ? source : target}.meta`, 'unrelated meta'); + throw new Error('PNG move denied'); + } + await move(from, to, { overwrite: !!options?.overwrite }); + } }); + await expect(moveAssetSource(source, target, { overwrite: false })).rejects.toThrow('could not be restored safely'); + expect(await readFile(`${location === 'source' ? source : target}.meta`, 'utf8')).toBe('unrelated meta'); + expect(await readFile(source, 'utf8')).toBe('new pixels'); + }); +}); diff --git a/src/core/assets/test/operation-filesystem-bridge.test.ts b/src/core/assets/test/operation-filesystem-bridge.test.ts index 17d408be4..707397b54 100644 --- a/src/core/assets/test/operation-filesystem-bridge.test.ts +++ b/src/core/assets/test/operation-filesystem-bridge.test.ts @@ -19,7 +19,7 @@ const mockQueryUrl = jest.fn(); const mockAssetQueryUrl = jest.fn(); const mockRefresh = jest.fn(async (_pathOrUrlOrUUID: string) => 0); const mockReimport = jest.fn(); -const mockAddTask = jest.fn(async (func: Function, args: any[]) => await func(...args)); +const mockAddTask = jest.fn(async (func: (...args: any[]) => unknown, args: any[]) => await func(...args)); const mockAutoRefreshAssetLazy = jest.fn(); const mockGetCreateMenuByName = jest.fn(); const mockCreateAssetByHandler = jest.fn(); @@ -99,7 +99,7 @@ jest.mock('../manager/asset-copy', () => ({ jest.mock('../manager/asset-db', () => ({ __esModule: true, default: { - addTask: (func: Function, args: any[]) => mockAddTask(func, args), + addTask: (func: (...args: any[]) => unknown, args: any[]) => mockAddTask(func, args), autoRefreshAssetLazy: (...args: any[]) => mockAutoRefreshAssetLazy(...args), assetDBInfo: {}, assetDBMap: {}, @@ -467,6 +467,30 @@ describe('asset operation filesystem bridge', () => { expect(mockMoveAssetSource).toHaveBeenCalledWith(source, target, undefined); }); + it('moveAsset rejects without refreshing the database when the source move fails', async () => { + const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); + const source = 'D:/project/assets/source.txt'; + const target = 'D:/project/assets/folder/source.txt'; + mockQueryAsset.mockReturnValue({ + source, + _parent: null, + isDirectory: () => false, + _assetDB: { options: { readonly: false } }, + url: 'db://assets/source.txt', + }); + mockExistsSync.mockReturnValue(false); + mockQueryUrl.mockReturnValue('db://assets/folder/source.txt'); + const error = new Error('source move failed'); + mockMoveAssetSource.mockRejectedValueOnce(error); + + await expect(assetOperation.moveAsset(source, target, { overwrite: false })).rejects.toBe(error); + + expect(mockMoveAssetSource).toHaveBeenCalledWith(source, target, { overwrite: false }); + expect(mockRefresh).not.toHaveBeenCalled(); + expect(mockAutoRefreshAssetLazy).not.toHaveBeenCalled(); + expect(mockAddTask).toHaveBeenCalledTimes(1); + }); + it('importAsset should delegate copy to filesystem bridge', async () => { const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); const source = 'D:/outside/source.txt'; diff --git a/src/core/scene/common/gizmo.ts b/src/core/scene/common/gizmo.ts index 5d892c2c0..6cf695653 100644 --- a/src/core/scene/common/gizmo.ts +++ b/src/core/scene/common/gizmo.ts @@ -74,8 +74,8 @@ export interface IGizmoService { selectAllLightProbes(): void; unselectAllLightProbes(): void; queryLightProbeSelectedCount(): number; - deleteSelectedLightProbes(): number; - duplicateSelectedLightProbes(): number; + deleteSelectedLightProbes(): Promise; + duplicateSelectedLightProbes(): Promise; generateLightProbes(): number; // 框选探针(方案 A):上层框选时主动调用,屏幕矩形(left/output; omitted uses db://assets/LightFX. */ + outputUrl?: string; + msaa?: 1 | 2 | 4 | 8; + resolution?: 128 | 256 | 512 | 1024 | 2048; + filter?: boolean; + highp?: boolean; + giScale?: number; + giSamples?: number; + giPathLength?: 1 | 2 | 3 | 4; + aoLevel?: 0 | 1 | 2; + aoStrength?: number; + aoRadius?: number; + aoColor?: [number, number, number, number?]; + threads?: number; + saveScene?: boolean; + timeoutMs?: number; +} + +/** Implementation support, not native executable readiness or task recovery. */ +export interface ILightmapBakeCapabilities { + /** The actual host accepts a selected assets output directory. */ + outputDirectory?: true; + diagnostics?: ILightFXDiagnostics; + version: 1; + /** Mesh/Terrain bindings, null references and live blocks are restored with the result history. */ + resultLifecycleVersion: 1; + sceneTransactionVersion: 1; + /** The actual host stages new UUIDs separately before saving and replacing prior outputs. */ + assetVersion: 1; + /** Clear saves first, then deletes exact unreferenced immutable LightFX texture assets. */ + assetCleanupVersion?: 1; + /** Same-Scene cancellation requires the actual host ownership protocol. */ + cancelVersion?: 1; + /** Advisory: this Scene has obtained a native Lightmap operation ID. */ + cancellable?: boolean; + busy: boolean; +} + +export interface ILightmapBakeResult { + diagnostics?: ILightFXDiagnostics; + sceneUrl: string; + textureUrls: string[]; + meshCount: number; + terrainCount: number; + durationMs: number; +} + +export interface ILightmapBakeInfo { + sceneUrl: string; + baked: boolean; + meshCount: number; + terrainCount: number; + highp: boolean; + stationaryMainLight: boolean; + textures: ILightmapTextureInfo[]; + missingTextureUuids: string[]; +} + +export interface ILightmapClearResult { + clearedCount: number; + deletedAssetCount: number; + retainedAssetCount: number; + failedAssetCount: number; +} + +export interface ILightFXCancelResult { + cancelled: boolean; + target: 'light-probe' | 'lightmap' | null; +} + +export interface ILightFXBakeEvents { + 'lightfx:bake-start': [target: 'light-probe' | 'lightmap']; + 'lightfx:bake-end': [target: 'light-probe' | 'lightmap', error?: string]; +} + +export interface ILightProbeBakeService extends IServiceEvents { + /** Queries this Scene implementation and its actual host without modifying scene or task state. */ + queryCapabilities(): Promise; + bake(options: ILightProbeBakeOptions): Promise; + clearBake(options?: { saveScene?: boolean }): Promise<{ probeCount: number }>; + /** Cancels only this Scene's probe bake after native ownership is acquired; otherwise a no-op. */ + cancel(): Promise; +} + +export interface ILightmapBakeService extends IServiceEvents { + /** Queries this Scene and its actual host without modifying scene or task state. */ + queryCapabilities(): Promise; + bake(options: ILightmapBakeOptions): Promise; + queryBakeInfo(): Promise; + /** Asset deletion saves the scene; unrelated history is kept, but pre-Clear baked results cannot be restored. */ + clearBake(options?: { saveScene?: boolean; deleteAssets?: boolean }): Promise; + /** Cancels only this Scene's lightmap bake after native ownership is acquired; otherwise a no-op. */ + cancel(): Promise; +} + +export type IPublicLightProbeBakeService = Pick; +export type IPublicLightmapBakeService = Pick; diff --git a/src/core/scene/common/lightfx-host.ts b/src/core/scene/common/lightfx-host.ts new file mode 100644 index 000000000..64cd9e111 --- /dev/null +++ b/src/core/scene/common/lightfx-host.ts @@ -0,0 +1,197 @@ +/** A bake target supported by the native LightFX process. */ +export type LightFXBakeTarget = 'light-probe' | 'lightmap'; + +/** Internal scene transaction ownership; not a public task or authentication token. */ +export interface IReserveLightFXSceneOperationOptions { + target: LightFXBakeTarget; + action: 'bake' | 'clear'; +} + +export interface ILightFXSceneOperationToken { + transactionId: string; +} + +/** Read-only host protocol snapshot. Busy is advisory, not permission to start a transaction. */ +export interface ILightFXHostCapabilities { + sceneTransactionVersion: 1; + /** Absent on legacy hosts; version 1 publishes immutable per-operation Lightmap assets. */ + lightmapAssetVersion?: 1; + /** Accepts an existing assets directory as the Lightmap output parent. */ + lightmapOutputDirectory?: true; + /** Deletes only unreferenced immutable LightFX textures selected by exact UUID. */ + lightmapAssetCleanupVersion?: 1; + /** Supports exact cleanup inside the owning Bake transaction after Scene confirms saving. */ + lightmapRebakeCleanupVersion?: 1; + /** Post-save, UUID-preserving relocation into the current fixed output directory. */ + lightmapPublicationVersion?: 1; + /** Stages, publishes and precisely cleans up native lfx.in/out/log assets. */ + lightmapAuxiliaryAssetsVersion?: 1; + /** Version 1 requires the exact native operation, target and scene reservation to cancel. */ + cancelOwnershipVersion?: 1; + diagnosticsVersion?: 1; + busy: boolean; +} + +/** Native diagnostic data is informational and never controls the bake transaction. */ +export interface ILightFXDiagnostics { + /** Internal identity lets terminal readers reject logs from an earlier bake. */ + operationId?: string; + version: 1; + stage: string; + logs: string[]; + progress?: string; + /** Verified percentage from the native Progress channel; absent for unknown payload formats. */ + rate?: number; +} + +/** JSON-safe reference to a texture needed by a LightFX input file. */ +export interface ILightFXTextureSource { + uuid: string; + nativeExtension: string; + fileName: string; +} + +export interface IResolveLightFXTextureSourceOptions { + uuid: string; + nativeExtension: string; +} + +export interface IResolvedLightFXTextureSource { + fileName: string; +} + +/** Counts from the actual exported world, not the scene hierarchy or the native debug log. */ +export interface ILightFXSceneStats { + objects: number; + lights: number; + triangles: number; +} + +export interface IBeginLightFXBakeOptions { + sceneStats?: ILightFXSceneStats; + /** Stable saved scene identity for exact generated-asset cleanup after reopening. */ + sceneUuid?: string; + outputUrl?: string; + transactionId?: string; + target: LightFXBakeTarget; + sceneName: string; + textureSources: ILightFXTextureSource[]; + timeoutMs: number; +} + +export interface IBeginLightFXBakeResult { + operationId: string; +} + +export interface IAppendLightFXInputOptions { + operationId: string; + chunkBase64: string; +} + +export interface IRunLightFXBakeOptions { + operationId: string; +} + +export interface ILightFXMeshResult { + id: number; + index: number; + offset: number[]; + scale: number[]; +} + +export interface ILightFXTerrainResult extends ILightFXMeshResult { + blockId: number; +} + +export interface ILightFXProbeResult { + position: number[]; + normal: number[]; + coefficients: number[]; +} + +/** Decoded LightFX output. It intentionally contains JSON-safe values only. */ +export interface ILightFXResult { + version: number; + meshes: ILightFXMeshResult[]; + terrains: ILightFXTerrainResult[]; + probes: ILightFXProbeResult[]; +} + +export interface IRunLightFXBakeResult { + result: ILightFXResult; + textureUrls: string[]; +} + +export interface ILightFXOperationOptions { + operationId: string; +} + +export interface IPublishLightmapAssetsOptions extends ILightFXOperationOptions { + transactionId: string; +} + +export interface ICancelLightFXOperationOptions extends ILightFXOperationOptions { + target: LightFXBakeTarget; + transactionId?: string; +} + +export interface IRemoveLightmapAssetsOptions { + transactionId?: string; + /** Default Clear; Bake cleanup requires its still-held scene reservation. */ + action?: 'bake' | 'clear'; + /** Saved scene whose stale dependency entry may be ignored after Scene verified no live reference remains. */ + sceneUuid: string; + textureUuids: string[]; +} + +export interface IRemoveLightmapAssetsResult { + deletedTextureUuids: string[]; + retainedTextureUuids: string[]; + deletedAuxiliaryAssetUuids?: string[]; + retainedAuxiliaryAssetUuids?: string[]; + failures: Array<{ uuid: string; reason: string }>; +} + +export interface IQueryLightmapTextureInfoOptions { + uuids: string[]; + /** Also return this scene's known generated assets, without adding them to the preview list. */ + sceneUuid?: string; +} + +export interface ILightmapTextureInfo { + uuid: string; + url: string; + filename: string; + size: number; + createdAt: number; + modifiedAt: number; +} + +export interface IQueryLightmapTextureInfoResult { + textures: ILightmapTextureInfo[]; + missingTextureUuids: string[]; + ownedTextureUuids?: string[]; +} + +/** + * Node-hosted half of LightFX baking. + * + * The Scene runtime can be a child process or a browser Webview. Consequently every argument and + * return value in this contract must remain JSON serializable and must not expose host file paths. + */ +export interface ILightFXBakeHostService { + queryDiagnostics?(options: ICancelLightFXOperationOptions): Promise; + queryCapabilities(): Promise; + reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise; + releaseSceneOperation(options: ILightFXSceneOperationToken): Promise; + resolveTextureSource(options: IResolveLightFXTextureSourceOptions): Promise; + begin(options: IBeginLightFXBakeOptions): Promise; + appendInput(options: IAppendLightFXInputOptions): Promise; + run(options: IRunLightFXBakeOptions): Promise; + commit(options: ILightFXOperationOptions): Promise; + publishLightmapAssets(options: IPublishLightmapAssetsOptions): Promise<{ textureUrls: string[] }>; + rollback(options: ILightFXOperationOptions): Promise; + cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }>; + removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise; + queryLightmapTextureInfo(options: IQueryLightmapTextureInfoOptions): Promise; +} diff --git a/src/core/scene/common/lightfx-limits.ts b/src/core/scene/common/lightfx-limits.ts new file mode 100644 index 000000000..282b4ee36 --- /dev/null +++ b/src/core/scene/common/lightfx-limits.ts @@ -0,0 +1,10 @@ +// LightFX allocates giSamples² × 64 × 5 Vec2 entries using signed 32-bit arithmetic. +// This is an overflow boundary, not a memory/performance recommendation. +export const MAX_LIGHTMAP_GI_SAMPLES = Math.floor(Math.sqrt(0x7fffffff / (64 * 5))); + +/** Reject unsafe Lightmap input without silently changing the requested quality. */ +export function validateLightmapGISamples(value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_LIGHTMAP_GI_SAMPLES) { + throw new RangeError(`Lightmap GI Samples must be an integer between 1 and ${MAX_LIGHTMAP_GI_SAMPLES}. Higher values overflow the native LightFX sample buffer. Reduce GI Samples and bake again.`); + } +} diff --git a/src/core/scene/common/message.ts b/src/core/scene/common/message.ts index e92058aa8..942708d60 100644 --- a/src/core/scene/common/message.ts +++ b/src/core/scene/common/message.ts @@ -15,9 +15,9 @@ import type { IReferenceImageEvents } from './reference-image'; * messageManager 不在已有接口中的补充事件 */ export interface ISceneEvents { + 'scene:light-probe-edit-mode-changed': [enabled: boolean]; + 'scene:light-probe-bounding-box-edit-mode-changed': [enabled: boolean]; 'scene:dimension-changed': [is2D: boolean]; - 'scene:light-probe-edit-mode-changed': [mode: boolean]; - 'scene:light-probe-bounding-box-edit-mode-changed': [mode: boolean]; } /** diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index a0ab02daa..013763d3c 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -9,6 +9,7 @@ import { PrefabProxy } from './proxy/prefab-proxy'; import { ReflectionProbeProxy } from './proxy/reflection-probe-proxy'; import { reflectionProbeRenderer } from './reflection-probe-renderer'; import { ReferenceImageProxy } from './proxy/reference-image-proxy'; +import { LightmapBakeProxy, LightProbeBakeProxy } from './proxy/lightfx-bake-proxy'; import { PreviewProxy } from './proxy/preview-proxy'; import { ParticleProxy } from './proxy/particle-proxy'; @@ -18,6 +19,7 @@ import { sceneConfigInstance } from '../scene-configs'; import i18n from '../../base/i18n'; import { referenceImageFiles } from './reference-image-files'; import { referenceImageStore } from './reference-image-store'; +import { lightFXBakeHost } from './lightfx-bake-host'; import { reflectionProbeBakeHost } from './reflection-probe-bake-host'; export interface IMainModule { @@ -28,6 +30,7 @@ export interface IMainModule { 'reflectionProbeRenderer': typeof reflectionProbeRenderer; 'referenceImageFiles': typeof referenceImageFiles; 'referenceImageStore': typeof referenceImageStore; + 'lightFXBakeHost': typeof lightFXBakeHost; 'reflectionProbeBakeHost': typeof reflectionProbeBakeHost; } @@ -38,6 +41,8 @@ export const Scene = { ...EngineProxy, ...PrefabProxy, ReferenceImage: ReferenceImageProxy, + LightProbeBake: LightProbeBakeProxy, + LightmapBake: LightmapBakeProxy, Preview: PreviewProxy, // 粒子系统相关接口(play/pause/stop/restart/setPlaySpeed/queryPlayInfo) Particle: ParticleProxy, diff --git a/src/core/scene/main-process/lightfx-bake-host.ts b/src/core/scene/main-process/lightfx-bake-host.ts new file mode 100644 index 000000000..abfdf1248 --- /dev/null +++ b/src/core/scene/main-process/lightfx-bake-host.ts @@ -0,0 +1,1009 @@ +import { randomUUID } from 'crypto'; +import { + appendFile, + copy, + ensureDir, + outputFile, + pathExists, + readFile, + readdir, + realpath, + remove, + stat, +} from 'fs-extra'; +import { basename, dirname, isAbsolute, join, relative, sep } from 'path'; +import Utils from '../../base/utils'; +import type { + IAppendLightFXInputOptions, + IBeginLightFXBakeOptions, + IBeginLightFXBakeResult, + ILightFXBakeHostService, + ILightFXHostCapabilities, + ILightFXOperationOptions, + ILightFXTextureSource, + IQueryLightmapTextureInfoOptions, + IQueryLightmapTextureInfoResult, + IRemoveLightmapAssetsOptions, + IRemoveLightmapAssetsResult, + IResolvedLightFXTextureSource, + IResolveLightFXTextureSourceOptions, + IRunLightFXBakeOptions, + IRunLightFXBakeResult, + LightFXBakeTarget, + IReserveLightFXSceneOperationOptions, + ILightFXSceneOperationToken, + ICancelLightFXOperationOptions, + ILightFXDiagnostics, + IPublishLightmapAssetsOptions, +} from '../common/lightfx-host'; +import { assetDBManager, assetManager } from '../../assets'; +import type { IAssetInfo } from '../../assets/@types/public'; +import { LightmapAssetTransaction } from './lightfx/asset-transaction'; +import { LightmapAssetRecord } from './lightfx/asset-record'; +import { isLightmapTextureUrl, lightmapAuxiliaryPath, publishLightmapTextures, removeEmptyLightmapVersion } from './lightfx/asset-publication'; +import { decodeLightFXOutput } from './lightfx/output'; +import { LightFXProcess } from './lightfx/process'; + +type OperationState = 'accepting-input' | 'running' | 'awaiting-commit'; +type OperationTerminalState = 'committed' | 'rolled-back' | 'cancelled' | 'expired'; + +interface LightFXHostOperation { + sceneStats?: IBeginLightFXBakeOptions['sceneStats']; + id: string; + target: LightFXBakeTarget; + sceneName: string; + timeoutMs: number; + workspace: string; + inputPath: string; + outputDir: string; + targetDir: string; + targetUrl: string; + refreshUrl: string; + inputBytes: number; + inputWritePromise: Promise; + state: OperationState; + controller: AbortController; + runner: LightFXProcess; + assets: LightmapAssetTransaction | null; + assetRecord?: LightmapAssetRecord; + recordedTextureUuids?: string[]; + recordedAuxiliaryUuids?: string[]; + publicationRootUrl: string; + cleanupPromise: Promise | null; + expiryTimer: NodeJS.Timeout | null; + terminalState: OperationTerminalState | null; +} + +interface ResolvedTextureSource extends IResolvedLightFXTextureSource { + sourcePath: string; +} + +const MAX_REMEMBERED_OPERATIONS = 32; +const MAX_INPUT_CHUNK_BASE64_LENGTH = 1024 * 1024; +const MAX_INPUT_BYTES = 1024 * 1024 * 1024; +const MAX_TEXTURE_SOURCES = 10_000; + +function isImmutableLightmapTexture(url: string | undefined): boolean { + const parts = url?.startsWith('db://assets/') ? url.slice('db://assets/'.length).split('/') : []; + const version = parts.at(-2) ?? ''; + return /^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(parts.at(-1) ?? '') + && version.startsWith('bake-') && Utils.UUID.isUUID(version.slice('bake-'.length)); +} + +/** Reads only the native percentage format observed on the dedicated Progress channel. */ +export function parseLightFXProgressRate(value: unknown): number | undefined { + if (typeof value !== 'string') { return undefined; } + const match = /^Build lighting (?\d{1,3}(?:\.\d+)?)%$/.exec(value.trim()); + if (!match?.groups) { return undefined; } + const rate = Number(match.groups.rate); + return Number.isFinite(rate) && rate >= 0 && rate <= 100 ? rate : undefined; +} + +/** + * Executes every Node-only part of a LightFX bake on behalf of either a Scene worker or a browser + * Scene Webview. Only one operation can exist at a time, including the apply/save transaction gap. + */ +export class LightFXBakeHost implements ILightFXBakeHostService { + private operation: LightFXHostOperation | null = null; + private readonly completedOperations = new Map(); + private readonly diagnostics = new Map(); + private sceneOperation: (IReserveLightFXSceneOperationOptions & ILightFXSceneOperationToken & { + nativeStarted: boolean; nativeCommitted: boolean; removingAssets: boolean; + publication?: { operationId: string; textureUuids: string[]; auxiliaryUuids: string[]; stagingUrl: string; rootUrl: string }; + }) | null = null; + private readonly releasedSceneOperations = new Set(); + + public async queryCapabilities(): Promise { + return { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: this.sceneOperation !== null || this.operation !== null }; + } + + public async queryDiagnostics(options: ICancelLightFXOperationOptions): Promise { + const entry = this.diagnostics.get(options?.operationId); + if (!entry || entry.owner.target !== options.target || entry.owner.transactionId !== options.transactionId) { return undefined; } + return structuredClone(entry.value); + } + + private diagnosticText(operation: LightFXHostOperation, value: unknown): string { + let text: string; + try { text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''; } catch { return ''; } + for (const [path, label] of [[operation.workspace, ''], [operation.targetDir, '']]) { + if (!path) continue; + // Object payloads have JSON-escaped Windows paths; plain logs do not. + text = text.split(JSON.stringify(path).slice(1, -1)).join(label).split(path).join(label); + } + return text.slice(0, 2048); + } + + private appendLightmapLog(operation: LightFXHostOperation, message: unknown): void { + const logs = this.diagnostics.get(operation.id)!.value.logs; + const text = this.diagnosticText(operation, message).trim(); + if (!text || logs.at(-1) === text) return; + logs.push(text); + if (logs.length > 128) { + logs.splice(0, logs.length - 127); + logs.unshift('[Earlier baking log entries omitted.]'); + } + } + + private lightmapImagesStage(operation: LightFXHostOperation): void { + const message = 'The baking is ready to complete and begin generating images.'; + if (!this.diagnostics.get(operation.id)!.value.logs.includes(message)) this.appendLightmapLog(operation, message); + } + + public async reserveSceneOperation(options: IReserveLightFXSceneOperationOptions): Promise { + if (!options || !['light-probe', 'lightmap'].includes(options.target) || !['bake', 'clear'].includes(options.action)) { + throw new Error('Invalid LightFX scene operation.'); + } + if (this.sceneOperation || this.operation) throw new Error('A LightFX scene transaction is already in progress on the host.'); + const transactionId = randomUUID(); + // No await before reservation. A lost renderer keeps this locked rather than admitting + // another writer while its old scene transaction might still resume. + this.sceneOperation = { target: options.target, action: options.action, transactionId, nativeStarted: false, nativeCommitted: false, removingAssets: false }; + return { transactionId }; + } + + public async releaseSceneOperation(options: ILightFXSceneOperationToken): Promise { + const id = options?.transactionId; + if (typeof id !== 'string' || !id) throw new Error('Invalid LightFX scene transaction id.'); + if (this.releasedSceneOperations.has(id)) return; + if (this.sceneOperation?.transactionId !== id) throw new Error('Unknown LightFX scene transaction.'); + if (this.operation || this.sceneOperation.removingAssets) throw new Error('LightFX host cleanup has not finished; scene transaction remains reserved.'); + this.sceneOperation = null; + this.releasedSceneOperations.add(id); + if (this.releasedSceneOperations.size > MAX_REMEMBERED_OPERATIONS) { + this.releasedSceneOperations.delete(this.releasedSceneOperations.values().next().value!); + } + } + + private validateSceneOperation(transactionId: string | undefined, target: LightFXBakeTarget, action: 'bake' | 'clear'): void { + if (!this.sceneOperation && transactionId === undefined) return; // Legacy native callers still reserve this.operation. + const current = this.sceneOperation; + if (!current || current.transactionId !== transactionId || current.target !== target || current.action !== action) { + throw new Error('LightFX scene transaction ownership does not match.'); + } + } + + public async resolveTextureSource( + options: IResolveLightFXTextureSourceOptions, + ): Promise { + const resolved = await this.resolveHostTextureSource(options); + return resolved ? { fileName: resolved.fileName } : null; + } + + public async queryLightmapTextureInfo( + options: IQueryLightmapTextureInfoOptions, + ): Promise { + if (!options || !Array.isArray(options.uuids) || options.uuids.length > MAX_TEXTURE_SOURCES) { + throw new Error('Invalid Lightmap texture UUID list.'); + } + + const uuids = [...new Set(options.uuids.map((value) => { + if (typeof value !== 'string') { + throw new Error('Invalid Lightmap texture UUID.'); + } + const uuid = Utils.UUID.decompressUUID(value).split('@', 1)[0]; + if (!Utils.UUID.isUUID(uuid)) { + throw new Error('Invalid Lightmap texture UUID.'); + } + return uuid; + }))]; + const textures: IQueryLightmapTextureInfoResult['textures'] = []; + const missingTextureUuids: string[] = []; + for (const uuid of uuids) { + const info = assetManager.queryAssetInfo(uuid); + if (!info?.file || !info.url) { + missingTextureUuids.push(uuid); + continue; + } + try { + const fileStat = await stat(info.file); + textures.push({ + uuid: info.uuid || uuid, + url: info.url, + filename: basename(info.file), + size: fileStat.size, + createdAt: fileStat.birthtimeMs, + modifiedAt: fileStat.mtimeMs, + }); + } catch { + missingTextureUuids.push(uuid); + } + } + const ownedTextureUuids = options.sceneUuid === undefined ? undefined + : (await new LightmapAssetRecord(dirname(this.queryAssetRoot()), options.sceneUuid).read()) + .filter(uuid => !!assetManager.queryAssetInfo(uuid)); + return { textures, missingTextureUuids, ...(ownedTextureUuids ? { ownedTextureUuids } : {}) }; + } + + public async begin(options: IBeginLightFXBakeOptions): Promise { + if (this.operation) { + throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); + } + this.validateBeginOptions(options); + this.validateSceneOperation(options.transactionId, options.target, 'bake'); + if (this.sceneOperation?.nativeStarted) throw new Error('This LightFX scene transaction has already started a bake.'); + + const assetRoot = this.queryAssetRoot(); + const projectRoot = dirname(assetRoot); + const assetRecord = options.target === 'lightmap' && options.sceneUuid !== undefined + ? new LightmapAssetRecord(projectRoot, options.sceneUuid) : undefined; + const operationId = randomUUID(); + const workspace = join( + projectRoot, + 'temp', + 'lightfx-bake', + `${options.target}-${Date.now()}-${process.pid}-${operationId.slice(0, 8)}`, + ); + const tmpDir = join(workspace, 'tmp'); + const outputDir = join(workspace, 'output'); + // Import the new result separately until the scene save is confirmed. + // Fixed publication follows exact cleanup; this is not an Undo pixel archive. + const version = `bake-${operationId}`; + const parentUrl = options.outputUrl ?? `db://assets/${options.sceneName}/lightmap`; + const parentDir = join(assetRoot, parentUrl.slice('db://assets'.length)); + const targetDir = join(parentDir, version); + const targetUrl = `${parentUrl}/${version}`; + const publicationBase = options.outputUrl && options.outputUrl !== 'db://assets' ? options.outputUrl : 'db://assets/LightFX'; + const publicationIdentity = options.sceneUuid + ? `scene-${Utils.UUID.decompressUUID(options.sceneUuid).split('@', 1)[0]}` : version; + const operation: LightFXHostOperation = { + sceneStats: options.sceneStats ? { ...options.sceneStats } : undefined, + id: operationId, + target: options.target, + sceneName: options.sceneName, + timeoutMs: options.timeoutMs, + workspace, + inputPath: join(tmpDir, 'lfx.in'), + outputDir, + targetDir, + targetUrl, + publicationRootUrl: `${publicationBase}/${publicationIdentity}`, + refreshUrl: options.outputUrl ?? `db://assets/${options.sceneName}`, + inputBytes: 0, + inputWritePromise: Promise.resolve(), + state: 'accepting-input', + controller: new AbortController(), + runner: new LightFXProcess(), + assets: null, + assetRecord, + cleanupPromise: null, + expiryTimer: null, + terminalState: null, + }; + + // Reserve the global operation before the first asynchronous filesystem call. + this.operation = operation; + this.diagnostics.set(operationId, { owner: { operationId, target: options.target, transactionId: options.transactionId }, value: { version: 1, operationId, stage: 'accepting-input', logs: [] } }); + if (this.diagnostics.size > MAX_REMEMBERED_OPERATIONS) { this.diagnostics.delete(this.diagnostics.keys().next().value!); } + if (this.sceneOperation) this.sceneOperation.nativeStarted = true; + try { + // A corrupt/unreadable record must fail before native work or asset publication. + await assetRecord?.read(); + if (options.outputUrl !== undefined) { + const path = relative(await realpath(assetRoot), await realpath(parentDir)); + if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) || !(await stat(parentDir)).isDirectory()) { + throw new Error('Lightmap output directory must be an existing folder inside assets.'); + } + } + await ensureDir(tmpDir); + await ensureDir(outputDir); + await outputFile(operation.inputPath, Buffer.alloc(0)); + await this.copyTextureSources(options.textureSources, tmpDir); + this.armExpiry(operation); + return { operationId }; + } catch (error) { + this.decideTerminalState(operation, 'rolled-back'); + await this.cleanup(operation, false); + throw error; + } + } + + public async appendInput(options: IAppendLightFXInputOptions): Promise { + const operation = this.requireActiveOperation(options.operationId); + if (operation.state !== 'accepting-input') { + throw new Error('LightFX input can only be appended before the bake starts.'); + } + const chunk = this.decodeBase64Chunk(options.chunkBase64); + if (!chunk.length) { + return; + } + if (operation.inputBytes + chunk.length > MAX_INPUT_BYTES) { + this.decideTerminalState(operation, 'rolled-back'); + await this.cleanup(operation, true); + throw new Error('LightFX input exceeds the 1 GiB limit.'); + } + const writePromise = operation.inputWritePromise.then(async () => { + this.throwIfTerminated(operation); + await appendFile(operation.inputPath, chunk); + operation.inputBytes += chunk.length; + }); + operation.inputWritePromise = writePromise.catch(() => undefined); + await writePromise; + } + + public async run(options: IRunLightFXBakeOptions): Promise { + const operation = this.requireActiveOperation(options.operationId); + if (operation.state !== 'accepting-input') { + throw new Error('LightFX bake has already started.'); + } + operation.state = 'running'; + this.diagnostics.get(operation.id)!.value.stage = 'running'; + if (operation.target === 'lightmap') this.appendLightmapLog(operation, 'Baking started'); + + try { + await operation.inputWritePromise; + this.throwIfTerminated(operation); + if (!operation.inputBytes) { + throw new Error('LightFX input is empty.'); + } + await operation.runner.run({ + cwd: operation.workspace, + timeoutMs: operation.timeoutMs, + signal: operation.controller.signal, + onLog: message => { + if (this.operation !== operation || operation.terminalState) { return; } + console.log(`[LightFX] ${message}`); + if (operation.target === 'lightmap') { + // Product logs are assembled from actual progress/export/output below. + // Keep native warnings/errors visible; verbose diagnostics remain in lfx.log. + if (/\b(?:error|warning|failed|failure)\b/i.test(message)) this.appendLightmapLog(operation, message); + return; + } + const logs = this.diagnostics.get(operation.id)!.value.logs; + logs.push(this.diagnosticText(operation, message)); + if (logs.length > 128) { logs.shift(); } + }, + onProgress: progress => { + if (this.operation !== operation || operation.terminalState) { return; } + const diagnostic = this.diagnostics.get(operation.id)!.value; + diagnostic.progress = this.diagnosticText(operation, progress); + const rate = parseLightFXProgressRate(progress); + if (operation.target === 'lightmap' && rate !== undefined) { + this.appendLightmapLog(operation, progress); + if (rate === 100) this.lightmapImagesStage(operation); + } + if (rate === undefined) { delete diagnostic.rate; } + else { diagnostic.rate = rate; } + }, + }); + this.throwIfTerminated(operation); + const result = decodeLightFXOutput(await readFile(join(operation.outputDir, 'lfx.out'))); + if (operation.target === 'lightmap') { + this.lightmapImagesStage(operation); + if (operation.sceneStats) { + const { objects, lights, triangles } = operation.sceneStats; + this.appendLightmapLog(operation, `Bake scene stats: objects ${objects} lights ${lights} triangles ${triangles}`); + } + for (const item of result.meshes) { + if (!this.diagnostics.get(operation.id)!.value.logs.some(line => line.startsWith(`Mesh ${item.id}:`))) { + this.appendLightmapLog(operation, `Mesh ${item.id}: Index(${item.index}) Offset(${item.offset.join(', ')}) Scale(${item.scale.join(', ')})`); + } + } + for (const item of result.terrains) { + this.appendLightmapLog(operation, `Terrain ${item.id} Block ${item.blockId}: Index(${item.index}) Offset(${item.offset.join(', ')}) Scale(${item.scale.join(', ')})`); + } + } + const textureUrls = operation.target === 'lightmap' + ? await this.stageLightmapAssets(operation) + : []; + this.throwIfTerminated(operation); + operation.state = 'awaiting-commit'; + this.diagnostics.get(operation.id)!.value.stage = 'awaiting-commit'; + if (operation.target === 'lightmap' && !this.diagnostics.get(operation.id)!.value.logs.includes('End of the baking.')) { + this.appendLightmapLog(operation, 'End of the baking.'); + } + return { result, textureUrls }; + } catch (error) { + const terminalError = operation.terminalState === 'cancelled' || operation.terminalState === 'expired' + ? this.terminalOperationError(operation.terminalState) + : error; + if (!operation.terminalState) { + this.decideTerminalState(operation, 'rolled-back'); + } + await this.cleanup(operation, true); + throw terminalError; + } + } + + public async commit(options: ILightFXOperationOptions): Promise { + this.validateOperationId(options.operationId); + const completedState = this.completedOperations.get(options.operationId); + if (completedState === 'committed') { + return; + } + if (completedState) { + throw this.cannotCommitError(completedState); + } + const operation = this.requireOperation(options.operationId); + if (operation.terminalState === 'committed') { + await operation.cleanupPromise; + return; + } + if (operation.terminalState) { + throw this.cannotCommitError(operation.terminalState); + } + if (operation.state !== 'awaiting-commit') { + throw new Error('LightFX bake cannot be committed before it finishes.'); + } + // This synchronous decision is the linearization point shared with cancellation and expiry. + this.decideTerminalState(operation, 'committed'); + if (this.sceneOperation) { + this.sceneOperation.nativeCommitted = true; + if (operation.target === 'lightmap' && operation.recordedTextureUuids) { + this.sceneOperation.publication = { operationId: operation.id, textureUuids: operation.recordedTextureUuids, + auxiliaryUuids: operation.recordedAuxiliaryUuids ?? [], stagingUrl: operation.targetUrl, rootUrl: operation.publicationRootUrl }; + } + } + try { + await this.cleanup(operation, false); + } catch (error) { + // The scene and generated assets are already committed. A temporary-workspace cleanup + // failure must not turn a successful bake into a rollback request from the Scene side. + console.warn('[LightFX] Failed to remove the completed bake workspace:', error); + } + } + + public async rollback(options: ILightFXOperationOptions): Promise { + this.validateOperationId(options.operationId); + const completedState = this.completedOperations.get(options.operationId); + if (completedState === 'committed') { + throw new Error('A committed LightFX bake cannot be rolled back.'); + } + if (completedState) { + return; + } + const operation = this.requireOperation(options.operationId); + if (operation.terminalState === 'committed') { + throw new Error('A committed LightFX bake cannot be rolled back.'); + } + if (operation.terminalState && operation.terminalState !== 'rolled-back') { + if (operation.cleanupPromise) { + await operation.cleanupPromise; + } + return; + } + if (!operation.terminalState) { + this.decideTerminalState(operation, 'rolled-back'); + } + operation.controller.abort(); + await operation.runner.cancel(); + await this.cleanup(operation, true); + } + + public async publishLightmapAssets(options: IPublishLightmapAssetsOptions): Promise<{ textureUrls: string[] }> { + this.validateSceneOperation(options?.transactionId, 'lightmap', 'bake'); + const owner = this.sceneOperation; + const publication = owner?.publication; + if (!owner?.nativeCommitted || !publication || publication.operationId !== options?.operationId) { + throw new Error('Lightmap publication requires its committed Bake ownership.'); + } + if (this.operation || owner.removingAssets) throw new Error('Lightmap asset publication or cleanup is already in progress.'); + owner.removingAssets = true; + try { + return { textureUrls: await publishLightmapTextures(this.queryAssetRoot(), publication.textureUuids, publication.stagingUrl, publication.rootUrl, publication.auxiliaryUuids) }; + } finally { + owner.removingAssets = false; + } + } + + public async cancel(options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { + const operation = this.operation; + // Missing/late credentials are a no-op, never a request to cancel whoever is now active. + // Legacy native callers without a scene reservation must still name their operation. + if (!operation || !options || options.operationId !== operation.id || options.target !== operation.target + || options.transactionId !== this.sceneOperation?.transactionId) { + return { cancelled: false, target: null }; + } + if (operation.terminalState) { + return { cancelled: false, target: null }; + } + this.decideTerminalState(operation, 'cancelled'); + operation.controller.abort(); + await operation.runner.cancel(); + // While run() owns output staging, its catch path must also own rollback. Cleaning here + // could otherwise restore the backup concurrently with stageLightmapAssets(). + if (operation.state !== 'running') { + await this.cleanup(operation, true); + } + return { cancelled: true, target: operation.target }; + } + + public async removeLightmapAssets(options: IRemoveLightmapAssetsOptions): Promise { + if (this.operation) { + throw new Error(`A ${this.operation.target} LightFX bake is already in progress.`); + } + if (!options || typeof options.sceneUuid !== 'string' || !Array.isArray(options.textureUuids) || options.textureUuids.length > MAX_TEXTURE_SOURCES) { + throw new Error('Invalid Lightmap texture UUID list.'); + } + const sceneUuid = Utils.UUID.decompressUUID(options.sceneUuid).split('@', 1)[0]; + if (!Utils.UUID.isUUID(sceneUuid)) throw new Error('Invalid Lightmap scene UUID.'); + const action = options.action ?? 'clear'; + if (action !== 'clear' && action !== 'bake') throw new Error('Invalid Lightmap cleanup action.'); + if (action === 'bake' && (!options.transactionId || !this.sceneOperation?.nativeCommitted)) { + throw new Error('Lightmap rebake cleanup requires its completed native bake ownership.'); + } + this.validateSceneOperation(options.transactionId, 'lightmap', action); + const legacy = options.transactionId === undefined; + const token = legacy ? await this.reserveSceneOperation({ target: 'lightmap', action: 'clear' }) : { transactionId: options.transactionId! }; + const owner = this.sceneOperation!; + if (owner.removingAssets) throw new Error('Lightmap assets are already being removed.'); + owner.removingAssets = true; + try { + const uuids = [...new Set(options.textureUuids.map(value => { + if (typeof value !== 'string') throw new Error('Invalid Lightmap texture UUID.'); + const uuid = Utils.UUID.decompressUUID(value).split('@', 1)[0]; + if (!Utils.UUID.isUUID(uuid)) throw new Error('Invalid Lightmap texture UUID.'); + return uuid; + }))]; + const record = new LightmapAssetRecord(dirname(this.queryAssetRoot()), sceneUuid); + const recorded = new Set(await record.read()); + // Native files never occur in a Lightmap binding. Their complete membership is + // host-owned, including retries after Clear already removed all texture bindings. + const currentAuxiliary = new Set(action === 'bake' ? owner.publication?.auxiliaryUuids ?? [] : []); + const auxiliary = new Set((await record.readAuxiliary()).filter(uuid => !currentAuxiliary.has(uuid))); + const candidates = [...new Set([...uuids, ...auxiliary])]; + const infos = new Map(candidates.map(uuid => [uuid, assetManager.queryAssetInfo(uuid)])); + const managed = (uuid: string): boolean => isImmutableLightmapTexture(infos.get(uuid)?.url) + || (recorded.has(uuid) && isLightmapTextureUrl(infos.get(uuid)?.url)) + || (auxiliary.has(uuid) && !!infos.get(uuid)?.url?.startsWith('db://assets/') + && !!lightmapAuxiliaryPath(infos.get(uuid)!.url!.split('/').at(-1)!)); + const known = uuids.filter(uuid => !auxiliary.has(uuid) && managed(uuid)); + // Include legacy currently-bound candidates before deletion so a retained/failed + // delete can be retried after the saved scene no longer has any Lightmap binding. + if (known.length) await record.add(known); + const result: IRemoveLightmapAssetsResult = { deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }; + const absent: string[] = []; + if (auxiliary.size) { + result.deletedAuxiliaryAssetUuids = []; + result.retainedAuxiliaryAssetUuids = []; + } + for (const uuid of candidates) { + const info = infos.get(uuid); + // Only an authoritative miss in an initialized, idle Asset DB invalidates + // membership. Exceptions, startup and refresh gaps must retain the record. + if (!info && (recorded.has(uuid) || auxiliary.has(uuid)) && assetDBManager?.ready + && !assetDBManager.isBusy() && assetManager.queryAssetInfo(uuid) === null) { + absent.push(uuid); + continue; + } + if (!info || !managed(uuid)) { + result.failures.push({ uuid, reason: 'Asset is not a managed LightFX texture.' }); + continue; + } + try { + // Dependency records name Texture/SpriteFrame subassets exactly. A parent + // image with no direct users can still own a texture used by another scene. + const identities = new Set([uuid]); + const collectSubassets = (asset: IAssetInfo): void => { + for (const child of Object.values(asset.subAssets ?? {})) { + const childUuid = Utils.UUID.decompressUUID(child.uuid); + if (childUuid.split('@', 1)[0] !== uuid || !Utils.UUID.isUUID(childUuid)) { + throw new Error('Invalid Lightmap subasset identity; asset retained.'); + } + if (identities.has(childUuid)) continue; + identities.add(childUuid); + collectSubassets(child); + } + }; + collectSubassets(info); + const users: string[] = []; + for (const identity of identities) users.push(...await assetManager.queryAssetUsers(identity)); + const hasOtherUser = users.some((user) => { + try { + const userUuid = Utils.UUID.decompressUUID(user).split('@', 1)[0]; + return userUuid !== uuid && (auxiliary.has(uuid) || userUuid !== sceneUuid); + } catch { + // An unknown dependency identifier is retained conservatively. + return true; + } + }); + if (hasOtherUser) { + (auxiliary.has(uuid) ? result.retainedAuxiliaryAssetUuids! : result.retainedTextureUuids).push(uuid); + continue; + } + await assetManager.removeAsset(uuid); + if (info.file && await pathExists(info.file)) { + throw new Error('Lightmap texture file still exists after asset deletion.'); + } + if (info.file && await pathExists(`${info.file}.meta`)) { + throw new Error('Lightmap asset metadata still exists after asset deletion.'); + } + (auxiliary.has(uuid) ? result.deletedAuxiliaryAssetUuids! : result.deletedTextureUuids).push(uuid); + if (info.file) await removeEmptyLightmapVersion(this.queryAssetRoot(), dirname(info.url!)); + } catch (error) { + result.failures.push({ uuid, reason: error instanceof Error ? error.message : String(error) }); + } + } + const deleted = [...result.deletedTextureUuids, ...(result.deletedAuxiliaryAssetUuids ?? []), ...absent]; + if (deleted.length > 0) { + await record.forget(deleted); + } + return result; + } finally { + owner.removingAssets = false; + if (legacy) await this.releaseSceneOperation(token); + } + } + + /** Releases an abandoned operation when its owning Scene host shuts down. */ + public async dispose(): Promise { + const operation = this.operation; + if (!operation) { + return; + } + if (operation.terminalState === 'committed') { + await operation.cleanupPromise; + return; + } + if (!operation.terminalState) { + this.decideTerminalState(operation, 'cancelled'); + } + operation.controller.abort(); + await operation.runner.cancel(); + await this.cleanup(operation, true); + } + + private validateBeginOptions(options: IBeginLightFXBakeOptions): void { + if (!options || (options.target !== 'light-probe' && options.target !== 'lightmap')) { + throw new Error('Invalid LightFX bake target.'); + } + this.validateSceneName(options.sceneName); + if (options.sceneStats !== undefined && (!options.sceneStats || + !['objects', 'lights', 'triangles'].every(key => Number.isSafeInteger(options.sceneStats![key as keyof typeof options.sceneStats]) + && options.sceneStats![key as keyof typeof options.sceneStats] >= 0))) { + throw new Error('Invalid LightFX exported scene statistics.'); + } + if (options.outputUrl !== undefined) { + const url = options.outputUrl; + if (options.target !== 'lightmap' || typeof url !== 'string' + || (url !== 'db://assets' && !url.startsWith('db://assets/')) + || (url !== 'db://assets' && url.slice('db://assets/'.length).split('/').some(part => + !part || part === '.' || part === '..' || /[<>:"\\|?*#%]/.test(part) + || [...part].some(char => char.charCodeAt(0) < 32) || /[. ]$/.test(part)))) { + throw new Error('Invalid Lightmap output directory URL; choose an existing folder under db://assets.'); + } + } + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 3_600_000) { + throw new Error('LightFX timeout must be an integer between 1000 and 3600000 milliseconds.'); + } + if (!Array.isArray(options.textureSources) || options.textureSources.length > MAX_TEXTURE_SOURCES) { + throw new Error('Invalid LightFX texture source list.'); + } + } + + private validateSceneName(sceneName: string): void { + if ( + typeof sceneName !== 'string' + || !sceneName.trim() + || sceneName === '.' + || sceneName === '..' + || /[<>:"/\\|?*\0]/.test(sceneName) + || /[. ]$/.test(sceneName) + ) { + throw new Error('Invalid LightFX scene name.'); + } + } + + private async copyTextureSources(textureSources: ILightFXTextureSource[], textureDir: string): Promise { + const fileNames = new Set(); + for (const texture of textureSources) { + const resolved = await this.resolveHostTextureSource(texture); + if (!resolved) { + throw new Error(`LightFX texture source is unavailable: ${texture.uuid}`); + } + if (texture.fileName !== resolved.fileName || basename(texture.fileName) !== texture.fileName) { + throw new Error(`Invalid LightFX texture file name: ${texture.fileName}`); + } + if (fileNames.has(texture.fileName)) { + continue; + } + fileNames.add(texture.fileName); + await copy(resolved.sourcePath, join(textureDir, texture.fileName)); + } + } + + private async resolveHostTextureSource( + options: IResolveLightFXTextureSourceOptions, + ): Promise { + if (!options || typeof options.uuid !== 'string') { + throw new Error('Invalid LightFX texture UUID.'); + } + const uuid = Utils.UUID.decompressUUID(options.uuid); + if (!Utils.UUID.isUUID(uuid)) { + throw new Error('Invalid LightFX texture UUID.'); + } + if ( + typeof options.nativeExtension !== 'string' + || !/^(?:\.[a-zA-Z0-9_-]+)?$/.test(options.nativeExtension) + ) { + throw new Error('Invalid LightFX texture native extension.'); + } + + let sourcePath: string | null = null; + if (uuid.includes('@')) { + const projectRoot = dirname(this.queryAssetRoot()); + sourcePath = join( + projectRoot, + 'library', + uuid.slice(0, 2), + `${uuid}${options.nativeExtension}`, + ); + } else { + sourcePath = assetManager.queryPath(uuid) || null; + } + if (!sourcePath || !(await pathExists(sourcePath))) { + return null; + } + const safeUuid = uuid.replace(/[^a-zA-Z0-9_.-]/g, '_'); + return { + sourcePath, + fileName: `${safeUuid}-${basename(sourcePath)}`, + }; + } + + private decodeBase64Chunk(value: string): Buffer { + if ( + typeof value !== 'string' + || value.length > MAX_INPUT_CHUNK_BASE64_LENGTH + || value.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + throw new Error('Invalid base64 LightFX input chunk.'); + } + return Buffer.from(value, 'base64'); + } + + private async stageLightmapAssets(operation: LightFXHostOperation): Promise { + const files = (await readdir(operation.outputDir)) + .filter((file) => file.toLowerCase().endsWith('.png')) + .sort((a, b) => a.localeCompare(b)); + if (!files.length) { + throw new Error('LightFX did not produce any lightmap textures.'); + } + + const assets = new LightmapAssetTransaction(operation.targetDir, operation.workspace); + operation.assets = assets; + await assets.prepare(); + for (const file of files) { + if (basename(file) !== file) { + throw new Error(`Invalid LightFX output file name: ${file}`); + } + await copy(join(operation.outputDir, file), join(operation.targetDir, file), { overwrite: true }); + await assets.preserveMeta(file); + } + const auxiliaryFiles: string[] = []; + if (operation.assetRecord && this.sceneOperation) { + // Copy before native commit cleans the workspace. Flat staging avoids a second + // directory transaction; publication maps these exact names to Creator's layout. + for (const file of ['lfx.in', 'lfx.out', 'lfx.log']) { + const source = join(operation.workspace, lightmapAuxiliaryPath(file)!); + if (file === 'lfx.log' && !(await pathExists(source))) continue; + await copy(source, join(operation.targetDir, file), { overwrite: false, errorOnExist: true }); + auxiliaryFiles.push(file); + } + } + await assetManager.refreshAsset(operation.targetUrl); + + const generatedUuids: string[] = []; + for (const file of files) { + this.throwIfTerminated(operation); + const url = `${operation.targetUrl}/${file}`; + const uuid = await this.waitForAsset(operation, url, Math.min(operation.timeoutMs, 60_000)); + await this.disableAlphaFix(uuid); + generatedUuids.push(uuid); + } + if (operation.assetRecord) { + const auxiliaryUuids: string[] = []; + for (const file of auxiliaryFiles) { + auxiliaryUuids.push(await this.waitForAsset(operation, `${operation.targetUrl}/${file}`, Math.min(operation.timeoutMs, 60_000))); + } + await operation.assetRecord.add(generatedUuids, auxiliaryUuids); + operation.recordedTextureUuids = generatedUuids; + operation.recordedAuxiliaryUuids = auxiliaryUuids; + this.throwIfTerminated(operation); + } + return files.map((file) => `${operation.targetUrl}/${file}`); + } + + private async waitForAsset(operation: LightFXHostOperation, url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + this.throwIfTerminated(operation); + const uuid = assetManager.queryUUID(url); + if (uuid) { + return uuid; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + throw new Error(`Lightmap texture import timed out: ${url}`); + } + + private async disableAlphaFix(uuid: string): Promise { + const meta = assetManager.queryAssetMeta(uuid) as any; + if (!meta) { + throw new Error(`Lightmap texture metadata is unavailable: ${uuid}`); + } + if (meta.userData?.fixAlphaTransparencyArtifacts === false) { + return; + } + meta.userData ??= {}; + meta.userData.fixAlphaTransparencyArtifacts = false; + await assetManager.saveAssetMeta(uuid, meta); + } + + private queryAssetRoot(): string { + const assetRoot = assetManager.queryPath('db://assets'); + if (!assetRoot) { + throw new Error('The db://assets directory is unavailable.'); + } + return assetRoot; + } + + private requireOperation(operationId: string): LightFXHostOperation { + this.validateOperationId(operationId); + const operation = this.operation; + if (!operation || operation.id !== operationId) { + throw new Error(`Unknown LightFX operation: ${operationId}`); + } + return operation; + } + + private requireActiveOperation(operationId: string): LightFXHostOperation { + this.validateOperationId(operationId); + const operation = this.operation; + if (operation?.id === operationId) { + this.throwIfTerminated(operation); + return operation; + } + const terminalState = this.completedOperations.get(operationId); + if (terminalState) { + throw this.terminalOperationError(terminalState); + } + throw new Error(`Unknown LightFX operation: ${operationId}`); + } + + private validateOperationId(operationId: string): void { + if (typeof operationId !== 'string' || !operationId) { + throw new Error('Invalid LightFX operation id.'); + } + } + + private cannotCommitError(state: Exclude): Error { + return new Error(`LightFX bake was ${state} and cannot be committed.`); + } + + private terminalOperationError(state: OperationTerminalState): Error { + switch (state) { + case 'cancelled': + return new Error('LightFX bake was cancelled.'); + case 'expired': + return new Error('LightFX bake timed out.'); + case 'rolled-back': + return new Error('LightFX bake was rolled back.'); + case 'committed': + return new Error('LightFX bake has already completed.'); + } + } + + private decideTerminalState(operation: LightFXHostOperation, state: OperationTerminalState): void { + const diagnostic = this.diagnostics.get(operation.id); + if (diagnostic && !operation.terminalState) { diagnostic.value.stage = state; } + if (operation.terminalState) { + if (operation.terminalState === state) { + return; + } + throw new Error(`LightFX bake was already ${operation.terminalState}.`); + } + operation.terminalState = state; + } + + private throwIfTerminated(operation: LightFXHostOperation): void { + if (operation.terminalState) { + throw this.terminalOperationError(operation.terminalState); + } + if (operation.controller.signal.aborted) { + throw new Error('LightFX bake was cancelled.'); + } + } + + private armExpiry(operation: LightFXHostOperation): void { + operation.expiryTimer = setTimeout(() => { + if (this.operation !== operation || operation.terminalState) { + return; + } + this.decideTerminalState(operation, 'expired'); + operation.controller.abort(); + void (async () => { + await operation.runner.cancel(); + // A running operation serializes rollback through run()'s catch path. + if (operation.state !== 'running') { + await this.cleanup(operation, true); + } + })().catch((error) => console.error('[LightFX] Failed to clean up an expired bake:', error)); + }, operation.timeoutMs); + operation.expiryTimer.unref?.(); + } + + private cleanup(operation: LightFXHostOperation, rollbackAssets: boolean): Promise { + if (operation.cleanupPromise) { + return operation.cleanupPromise; + } + const cleanupPromise = (async () => { + // All terminal paths converge here. Do not remove the workspace while an input chunk + // that was accepted before cancellation, rollback, expiry or disposal is still writing. + await operation.inputWritePromise; + if (operation.expiryTimer) { + clearTimeout(operation.expiryTimer); + operation.expiryTimer = null; + } + if (rollbackAssets && operation.assets) { + // Do not remove the workspace: it owns the only backup from which rollback can be + // retried when either restoration or the following Asset DB refresh fails. + await operation.assets.rollback(); + await assetManager.refreshAsset(operation.refreshUrl); + if (operation.recordedTextureUuids) { + await operation.assetRecord?.forget([...operation.recordedTextureUuids, ...(operation.recordedAuxiliaryUuids ?? [])]); + } + } + let cleanupError: unknown; + try { + await remove(operation.workspace); + } catch (error) { + cleanupError = error; + if (rollbackAssets) { + throw error; + } + } + if (!operation.terminalState) { + throw new Error('LightFX operation cleanup requires a terminal state.'); + } + this.rememberCompletedOperation(operation.id, operation.terminalState); + if (this.operation === operation) { + this.operation = null; + } + if (cleanupError) { + throw cleanupError; + } + })().catch((error) => { + // A failed rollback must remain active and retryable, with its backup workspace intact. + if (operation.cleanupPromise === cleanupPromise) { + operation.cleanupPromise = null; + } + throw error; + }); + operation.cleanupPromise = cleanupPromise; + return cleanupPromise; + } + + private rememberCompletedOperation(operationId: string, state: OperationTerminalState): void { + this.completedOperations.set(operationId, state); + if (this.completedOperations.size > MAX_REMEMBERED_OPERATIONS) { + const oldest = this.completedOperations.keys().next().value as string | undefined; + if (oldest) { + this.completedOperations.delete(oldest); + } + } + } +} + +export const lightFXBakeHost = new LightFXBakeHost(); diff --git a/src/core/scene/main-process/lightfx-bake-renderer.ts b/src/core/scene/main-process/lightfx-bake-renderer.ts new file mode 100644 index 000000000..8c5488745 --- /dev/null +++ b/src/core/scene/main-process/lightfx-bake-renderer.ts @@ -0,0 +1,118 @@ +import type { RemoteSocket } from 'socket.io'; +import type { DefaultEventsMap } from 'socket.io/dist/typed-events'; +import { SCENE_RENDERER_ROOM, socketService } from '../../../server/socket'; + +type LightFXModule = 'LightProbeBake' | 'LightmapBake'; +type LightFXMethod = 'bake' | 'queryBakeInfo' | 'queryCapabilities' | 'clearBake' | 'cancel'; + +interface RendererSocketData { + sceneUrl?: string; + sceneRendererVisible?: boolean; +} + +interface LightFXResponse { + result?: T; + sceneUrl?: string; + error?: string; +} + +type RendererSocket = RemoteSocket; + +function selectActiveRenderer(sockets: RendererSocket[]): RendererSocket { + const loaded = sockets.filter((socket) => Boolean(socket.data.sceneUrl)); + const visible = loaded.filter((socket) => socket.data.sceneRendererVisible === true); + if (visible.length === 1) return visible[0]; + if (visible.length > 1) { + throw new Error('Multiple visible scene renderers are open. Close the duplicate scene views and retry.'); + } + if (sockets.some((socket) => socket.data.sceneRendererVisible === true)) { + throw new Error('The visible scene renderer has not finished loading a scene. Wait for it and retry.'); + } + + const candidates = loaded.filter((socket) => socket.data.sceneRendererVisible !== false); + if (candidates.length === 1) return candidates[0]; + if (candidates.length === 0) { + throw new Error('No loaded scene renderer is currently visible. Activate the target scene tab and retry.'); + } + throw new Error('Multiple scene renderers are open. Activate the target scene tab and retry.'); +} + +function requestRenderer( + socket: RendererSocket, + module: LightFXModule, + method: LightFXMethod, + args: unknown[], + timeoutMs: number, +): Promise { + const sceneUrl = socket.data.sceneUrl || ''; + return new Promise((resolve, reject) => { + socket.timeout(timeoutMs).emit( + 'scene:invoke-lightfx', + { sceneUrl, module, method, args }, + (error: Error | null, response?: LightFXResponse) => { + if (error) { + reject(new Error(`The active scene renderer did not complete the LightFX request. (${error.message})`)); + } else if (response?.error) { + reject(new Error(response.error)); + } else if (!response || !Object.prototype.hasOwnProperty.call(response, 'result')) { + reject(new Error('The active scene renderer returned an invalid LightFX response.')); + } else if (method !== 'cancel' && response.sceneUrl !== sceneUrl) { + reject(new Error( + `The active scene changed during the LightFX request: expected ${sceneUrl}, ` + + `got ${response.sceneUrl || 'unknown'}.`, + )); + } else { + resolve(response.result as T); + } + }, + ); + }); +} + +class LightFXBakeRenderer { + private activeBakeRendererId: string | null = null; + + async invoke( + module: LightFXModule, + method: LightFXMethod, + args: unknown[], + timeoutMs: number, + fallback: () => Promise, + trackBake = false, + ): Promise { + const io = socketService.io; + if (!io) return fallback(); + const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets() as RendererSocket[]; + if (sockets.length === 0) return fallback(); + + const renderer = selectActiveRenderer(sockets); + if (trackBake && this.activeBakeRendererId) { + throw new Error('A LightFX bake is already in progress in the scene renderer.'); + } + if (trackBake) this.activeBakeRendererId = renderer.id; + try { + return await requestRenderer(renderer, module, method, args, timeoutMs); + } finally { + if (trackBake && this.activeBakeRendererId === renderer.id) { + this.activeBakeRendererId = null; + } + } + } + + async cancel(module: LightFXModule, fallback: () => Promise, timeoutMs = 30_000): Promise { + const io = socketService.io; + if (!io) return fallback(); + const sockets = await io.in(SCENE_RENDERER_ROOM).fetchSockets() as RendererSocket[]; + if (sockets.length === 0) return fallback(); + + const renderer = this.activeBakeRendererId + ? sockets.find((socket) => socket.id === this.activeBakeRendererId) + : selectActiveRenderer(sockets); + if (!renderer) { + throw new Error('The scene renderer running the LightFX bake is no longer connected.'); + } + return requestRenderer(renderer, module, 'cancel', [], timeoutMs); + } +} + +export const lightFXBakeRenderer = new LightFXBakeRenderer(); diff --git a/src/core/scene/main-process/lightfx/asset-publication.ts b/src/core/scene/main-process/lightfx/asset-publication.ts new file mode 100644 index 000000000..05a115937 --- /dev/null +++ b/src/core/scene/main-process/lightfx/asset-publication.ts @@ -0,0 +1,101 @@ +import { lstat, realpath, rmdir } from 'fs/promises'; +import { ensureDir, pathExists } from 'fs-extra'; +import { basename, dirname, isAbsolute, join, relative, sep } from 'path'; +import { assetManager } from '../../../assets'; +import Utils from '../../../base/utils'; + +export function isLightmapTextureUrl(url: string | undefined): boolean { + return !!url?.startsWith('db://assets/') && /^LFX_(?:Mesh|Terrain)_\d{4,}\.png$/.test(url.split('/').at(-1) ?? ''); +} + +/** Only these native products belong to the Lightmap publication contract. */ +export function lightmapAuxiliaryPath(filename: string): string | undefined { + switch (filename) { + case 'lfx.in': return 'tmp/lfx.in'; + case 'lfx.out': return 'output/lfx.out'; + case 'lfx.log': return 'lfx.log'; + default: return undefined; + } +} + +/** Reject symlinks (including dangling ones) before creating or moving managed outputs. */ +async function assertAssetPath(assetRoot: string, path: string): Promise { + const local = relative(assetRoot, path); + if (!local || local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new Error('Lightmap publication must remain inside assets.'); + } + let current = assetRoot; + for (const part of local.split(sep)) { + current = join(current, part); + try { + if ((await lstat(current)).isSymbolicLink()) throw new Error('Lightmap publication does not follow symbolic links.'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + // Validate the existing root as well; descendants above cannot redirect outside it. + await realpath(assetRoot); +} + +/** Removes only a verified empty temporary import directory, never its contents. */ +export async function removeEmptyLightmapVersion(assetRoot: string, url: string): Promise { + const name = url.split('/').at(-1) ?? ''; + if (!url.startsWith('db://assets/') || !name.startsWith('bake-') || !Utils.UUID.isUUID(name.slice(5))) return; + const path = join(assetRoot, url.slice('db://assets/'.length)); + await assertAssetPath(assetRoot, path); + try { + await rmdir(path); + } catch (error) { + if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) return; + throw error; + } + // Let Asset DB reconcile its now-missing directory and metadata through the normal importer. + await assetManager.refreshAsset(dirname(url)); +} + +/** Post-save relocation: UUIDs stay valid even if a later move fails. Never overwrite assets. */ +export async function publishLightmapTextures(assetRoot: string, uuids: readonly string[], stagingUrl: string, rootUrl: string, + auxiliaryUuids: readonly string[] = []): Promise { + if (!rootUrl.startsWith('db://assets') || (rootUrl !== 'db://assets' && !rootUrl.startsWith('db://assets/'))) { + throw new Error('Invalid Lightmap publication directory.'); + } + const outputUrl = `${rootUrl}/output`; + const outputDir = join(assetRoot, outputUrl.slice('db://assets/'.length)); + await assertAssetPath(assetRoot, outputDir); + const auxiliary = new Set(auxiliaryUuids); + const files = [...uuids, ...auxiliaryUuids].map(uuid => { + const info = assetManager.queryAssetInfo(uuid); + if (!info?.file || !info.url?.startsWith('db://assets/')) throw new Error('Published Lightmap asset is missing.'); + const filename = basename(info.file); + const path = auxiliary.has(uuid) ? lightmapAuxiliaryPath(filename) : isLightmapTextureUrl(info.url) ? `output/${filename}` : undefined; + if (!path) throw new Error('Invalid Lightmap publication asset.'); + const url = `${rootUrl}/${path}`; + if (info.url !== `${stagingUrl}/${filename}` && info.url !== url) throw new Error('Lightmap publication source no longer belongs to this bake.'); + return { uuid, source: info.url, sourceFile: info.file, url, file: join(assetRoot, url.slice('db://assets/'.length)) }; + }); + if (new Set(files.map(file => file.url)).size !== files.length) throw new Error('Duplicate Lightmap output names.'); + // Preflight every target before the first move. Partial moves still keep their original UUIDs. + for (const file of files) { + await assertAssetPath(assetRoot, file.sourceFile); + await assertAssetPath(assetRoot, `${file.sourceFile}.meta`); + await assertAssetPath(assetRoot, file.file); + await assertAssetPath(assetRoot, `${file.file}.meta`); + if (!(await pathExists(file.sourceFile))) throw new Error('Lightmap publication source file is missing.'); + if (file.source !== file.url && (await pathExists(file.file) || await pathExists(`${file.file}.meta`) || assetManager.queryUUID(file.url))) { + throw new Error(`Lightmap output is occupied; refusing to overwrite: ${file.url}`); + } + } + await ensureDir(outputDir); + for (const file of files) await ensureDir(dirname(file.file)); + await assetManager.refreshAsset(rootUrl); + for (const file of files) { + if (file.source !== file.url) await assetManager.moveAsset(file.source, file.url, { overwrite: false, rename: false }); + const info = assetManager.queryAssetInfo(file.uuid); + if (info?.url !== file.url || assetManager.queryUUID(file.url) !== file.uuid || !(await pathExists(file.file)) + || (file.source !== file.url && await pathExists(file.sourceFile))) { + throw new Error(`Lightmap publication was not confirmed: ${file.url}`); + } + } + await removeEmptyLightmapVersion(assetRoot, stagingUrl); + return files.filter(file => !auxiliary.has(file.uuid)).map(file => file.url); +} diff --git a/src/core/scene/main-process/lightfx/asset-record.ts b/src/core/scene/main-process/lightfx/asset-record.ts new file mode 100644 index 000000000..26536f55f --- /dev/null +++ b/src/core/scene/main-process/lightfx/asset-record.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'crypto'; +import { ensureDir, readFile, rename, outputFile, remove, stat } from 'fs-extra'; +import { dirname, join } from 'path'; +import Utils from '../../../base/utils'; + +/** Exact generated-asset membership, not an asset/pixel backup or project-wide collector. */ +export class LightmapAssetRecord { + private readonly file: string; + + constructor(projectRoot: string, sceneUuid: string) { + const uuid = Utils.UUID.decompressUUID(sceneUuid).split('@', 1)[0]; + if (!Utils.UUID.isUUID(uuid)) throw new Error('Invalid Lightmap scene UUID.'); + this.file = join(projectRoot, 'settings', 'lightfx-assets', `${uuid}.json`); + } + + async read(): Promise { + return (await this.readRecord()).textures; + } + + async readAuxiliary(): Promise { + return (await this.readRecord()).auxiliary ?? []; + } + + private async readRecord(): Promise<{ textures: string[]; auxiliary?: string[] }> { + let text: string; + try { + if ((await stat(this.file)).size > 512 * 1024) throw new Error('Lightmap generated-asset record is too large.'); + text = await readFile(this.file, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { textures: [] }; + throw error; + } + const record = JSON.parse(text) as { version?: number; textures?: unknown; auxiliary?: unknown }; + if (record?.version !== 1 || !Array.isArray(record.textures) || record.textures.length > 10_000 + || record.textures.some(uuid => typeof uuid !== 'string' || !Utils.UUID.isUUID(uuid)) + || (record.auxiliary !== undefined && (!Array.isArray(record.auxiliary) || record.auxiliary.length > 10_000 + || record.auxiliary.some(uuid => typeof uuid !== 'string' || !Utils.UUID.isUUID(uuid))))) { + throw new Error('Invalid Lightmap generated-asset record.'); + } + return { textures: [...new Set(record.textures as string[])], + ...(record.auxiliary !== undefined ? { auxiliary: [...new Set(record.auxiliary as string[])] } : {}) }; + } + + async add(uuids: readonly string[], auxiliaryUuids: readonly string[] = []): Promise { + const roots = uuids.map(uuid => Utils.UUID.decompressUUID(uuid).split('@', 1)[0]); + const auxiliary = auxiliaryUuids.map(uuid => Utils.UUID.decompressUUID(uuid).split('@', 1)[0]); + if ([...roots, ...auxiliary].some(uuid => !Utils.UUID.isUUID(uuid))) throw new Error('Invalid generated Lightmap texture UUID.'); + const previous = await this.readRecord(); + const textures = [...new Set([...previous.textures, ...roots])]; + const combined = [...new Set([...(previous.auxiliary ?? []), ...auxiliary])]; + if (textures.length + combined.length > 10_000) throw new Error('Too many recorded Lightmap assets; clear unused bake results first.'); + await this.write({ textures, ...(combined.length || previous.auxiliary ? { auxiliary: combined } : {}) }); + } + + async forget(uuids: readonly string[]): Promise { + const deleted = new Set(uuids); + const previous = await this.readRecord(); + const textures = previous.textures.filter(uuid => !deleted.has(uuid)); + const auxiliary = previous.auxiliary?.filter(uuid => !deleted.has(uuid)); + if (textures.length !== previous.textures.length || auxiliary?.length !== previous.auxiliary?.length) { + await this.write({ textures, ...(auxiliary ? { auxiliary } : {}) }); + } + } + + private async write(record: { textures: string[]; auxiliary?: string[] }): Promise { + await ensureDir(dirname(this.file)); + const temporary = `${this.file}.${randomUUID()}.tmp`; + try { + await outputFile(temporary, JSON.stringify({ version: 1, ...record })); + await rename(temporary, this.file); + } finally { + await remove(temporary); + } + } +} diff --git a/src/core/scene/main-process/lightfx/asset-transaction.ts b/src/core/scene/main-process/lightfx/asset-transaction.ts new file mode 100644 index 000000000..e343f1891 --- /dev/null +++ b/src/core/scene/main-process/lightfx/asset-transaction.ts @@ -0,0 +1,50 @@ +import { copy, ensureDir, pathExists, remove } from 'fs-extra'; +import { dirname, join } from 'path'; + +/** Replaces one lightmap directory while retaining enough state for a later rollback. */ +export class LightmapAssetTransaction { + private readonly backupDir: string; + private hadTarget = false; + private prepared = false; + + constructor(private readonly targetDir: string, workspace: string) { + this.backupDir = join(workspace, 'lightmap-asset-backup'); + } + + public async prepare(): Promise { + if (this.prepared) { + return; + } + this.hadTarget = await pathExists(this.targetDir); + if (this.hadTarget) { + await copy(this.targetDir, this.backupDir); + } + await remove(this.targetDir); + await ensureDir(this.targetDir); + this.prepared = true; + } + + public async rollback(): Promise { + if (!this.prepared) { + return; + } + await remove(this.targetDir); + if (this.hadTarget) { + await ensureDir(dirname(this.targetDir)); + await copy(this.backupDir, this.targetDir); + } + // Keep the transaction retryable until the previous asset directory is fully restored. + this.prepared = false; + } + + public async preserveMeta(relativeAssetPath: string): Promise { + if (!this.hadTarget) { + return; + } + const metaPath = `${relativeAssetPath}.meta`; + const source = join(this.backupDir, metaPath); + if (await pathExists(source)) { + await copy(source, join(this.targetDir, metaPath)); + } + } +} diff --git a/src/core/scene/main-process/lightfx/output.ts b/src/core/scene/main-process/lightfx/output.ts new file mode 100644 index 000000000..f49d79263 --- /dev/null +++ b/src/core/scene/main-process/lightfx/output.ts @@ -0,0 +1,121 @@ +import type { ILightFXResult } from '../../common/lightfx-host'; + +const LIGHTFX_OUTPUT_VERSIONS = new Set([0x2000, 0x2002, 0x2003, 0x3730]); +const MAX_COLLECTION_LENGTH = 10_000_000; + +const enum LightFXChunk { + EOF = 0, + TERRAIN = 1, + MESH = 2, + LIGHT_PROBE = 4, +} + +class LightFXOutputReader { + private readonly view: DataView; + private cursor = 0; + + constructor(input: Uint8Array) { + this.view = new DataView(input.buffer, input.byteOffset, input.byteLength); + } + + public get remaining(): number { + return this.view.byteLength - this.cursor; + } + + public readInt32(): number { + this.ensure(4); + const value = this.view.getInt32(this.cursor, true); + this.cursor += 4; + return value; + } + + public readFloat(): number { + this.ensure(4); + const value = this.view.getFloat32(this.cursor, true); + this.cursor += 4; + if (!Number.isFinite(value)) { + throw new Error('LightFX output contains a non-finite float.'); + } + return value; + } + + public readFloats(count: number): number[] { + this.validateCount(count); + return Array.from({ length: count }, () => this.readFloat()); + } + + public readCount(label: string): number { + const count = this.readInt32(); + if (count < 0 || count > MAX_COLLECTION_LENGTH) { + throw new Error(`Invalid LightFX ${label} count: ${count}.`); + } + return count; + } + + private validateCount(count: number): void { + if (!Number.isInteger(count) || count < 0 || count > MAX_COLLECTION_LENGTH) { + throw new Error(`Invalid LightFX array length: ${count}.`); + } + } + + private ensure(size: number): void { + if (this.cursor + size > this.view.byteLength) { + throw new Error('LightFX output is truncated.'); + } + } +} + +export function decodeLightFXOutput(input: Uint8Array): ILightFXResult { + const reader = new LightFXOutputReader(input); + const result: ILightFXResult = { + version: reader.readInt32(), + meshes: [], + terrains: [], + probes: [], + }; + if (!LIGHTFX_OUTPUT_VERSIONS.has(result.version)) { + throw new Error(`Unsupported LightFX output version: 0x${result.version.toString(16)}.`); + } + + while (reader.remaining > 0) { + const chunk = reader.readInt32(); + if (chunk === LightFXChunk.EOF) { + return result; + } + if (chunk === LightFXChunk.TERRAIN) { + const id = reader.readInt32(); + const count = reader.readCount('terrain'); + for (let i = 0; i < count; i++) { + result.terrains.push({ + id, + blockId: reader.readInt32(), + index: reader.readInt32(), + offset: reader.readFloats(2), + scale: reader.readFloats(2), + }); + } + } else if (chunk === LightFXChunk.MESH) { + const count = reader.readCount('mesh'); + for (let i = 0; i < count; i++) { + result.meshes.push({ + id: reader.readInt32(), + index: reader.readInt32(), + offset: reader.readFloats(2), + scale: reader.readFloats(2), + }); + } + } else if (chunk === LightFXChunk.LIGHT_PROBE) { + const count = reader.readCount('light probe'); + for (let i = 0; i < count; i++) { + result.probes.push({ + position: reader.readFloats(3), + normal: reader.readFloats(3), + coefficients: reader.readFloats(reader.readCount('coefficient')), + }); + } + } else { + throw new Error(`Unknown LightFX output chunk: ${chunk}.`); + } + } + throw new Error('LightFX output has no EOF chunk.'); +} diff --git a/src/core/scene/main-process/lightfx/process.ts b/src/core/scene/main-process/lightfx/process.ts new file mode 100644 index 000000000..ef1287a8e --- /dev/null +++ b/src/core/scene/main-process/lightfx/process.ts @@ -0,0 +1,140 @@ +import type { ChildProcess } from 'child_process'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { createServer, type Server as HttpServer } from 'http'; +import { join } from 'path'; +import { GlobalPaths } from '../../../../global'; + +// LightFX embeds a Socket.IO 2.x client. Keep the legacy server in the Node host so the browser +// Scene runtime never has to load either Socket.IO or Node's networking/process modules. +const createLegacySocketServer = require('socket.io-v2') as (server: HttpServer, options: object) => any; + +export interface LightFXProcessOptions { + cwd: string; + timeoutMs: number; + signal?: AbortSignal; + onLog?: (message: string) => void; + onProgress?: (progress: unknown) => void; +} + +export class LightFXProcess { + private child: ChildProcess | null = null; + private http: HttpServer | null = null; + private io: any = null; + private settled = false; + private closePromise: Promise | null = null; + + public async run(options: LightFXProcessOptions): Promise { + if (this.child || this.io) { + throw new Error('LightFX process is already running.'); + } + if (options.signal?.aborted) { + throw new Error('LightFX bake was cancelled.'); + } + + const executable = join( + GlobalPaths.staticDir, + 'tools', + 'lightmap-tools', + process.platform === 'win32' ? 'LightFX.exe' : 'LightFX', + ); + if (!existsSync(executable)) { + throw new Error(`LightFX executable was not found: ${executable}`); + } + + this.settled = false; + this.closePromise = null; + await new Promise((resolve, reject) => { + const finish = async (error?: unknown): Promise => { + if (this.settled) { + return; + } + this.settled = true; + clearTimeout(timer); + options.signal?.removeEventListener('abort', abort); + await this.close(); + if (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } else { + resolve(); + } + }; + const fail = (error: unknown): void => { void finish(error); }; + const succeed = (): void => { void finish(); }; + const abort = (): void => fail(new Error('LightFX bake was cancelled.')); + + const timer = setTimeout(() => fail(new Error('LightFX bake timed out.')), options.timeoutMs); + options.signal?.addEventListener('abort', abort, { once: true }); + + void (async () => { + try { + this.http = createServer(); + this.io = createLegacySocketServer(this.http, { + serveClient: false, + transports: ['websocket', 'polling'], + }); + this.io.on('connection', (socket: any) => { + socket.once('Login', () => socket.emit('Start')); + socket.on('Log', (data: unknown) => options.onLog?.(String(data))); + socket.on('Progress', (data: unknown) => options.onProgress?.(data)); + socket.once('Finished', () => { + socket.emit('Stop'); + succeed(); + }); + }); + await new Promise((ready, listenReject) => { + this.http!.once('error', listenReject); + this.http!.listen(0, '127.0.0.1', ready); + }); + const address = this.http.address(); + if (!address || typeof address === 'string') { + throw new Error('LightFX server did not allocate a TCP port.'); + } + this.child = spawn(executable, [`http://127.0.0.1:${address.port}`], { + cwd: options.cwd, + windowsHide: true, + }); + this.child.once('error', fail); + this.child.once('exit', (code, signal) => { + if (!this.settled) { + fail(new Error(`LightFX exited before completion (code=${code}, signal=${signal}).`)); + } + }); + } catch (error) { + fail(error); + } + })(); + }); + } + + public async cancel(): Promise { + const running = Boolean(this.child || this.io); + await this.close(); + return running; + } + + private close(): Promise { + if (this.closePromise) { + return this.closePromise; + } + this.closePromise = (async () => { + if (this.child) { + this.child.kill(); + this.child = null; + } + if (this.io) { + const io = this.io; + this.io = null; + await new Promise((resolve) => io.close(resolve)); + } + if (this.http) { + const http = this.http; + this.http = null; + if (http.listening) { + await new Promise((resolve) => http.close(() => resolve())); + } + } + })(); + return this.closePromise; + } +} diff --git a/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts new file mode 100644 index 000000000..0511da657 --- /dev/null +++ b/src/core/scene/main-process/proxy/lightfx-bake-proxy.ts @@ -0,0 +1,45 @@ +import type { IPublicLightProbeBakeService, IPublicLightmapBakeService } from '../../common'; +import { lightFXBakeRenderer } from '../lightfx-bake-renderer'; +import { Rpc } from '../rpc'; + +export const LightProbeBakeProxy: IPublicLightProbeBakeService = { + queryCapabilities: () => lightFXBakeRenderer.invoke( + 'LightProbeBake', 'queryCapabilities', [], 30_000, + () => Rpc.getInstance().request('LightProbeBake', 'queryCapabilities'), + ), + bake: (options) => lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, + () => Rpc.getInstance().request('LightProbeBake', 'bake', [options]), true, + ), + clearBake: (options) => lightFXBakeRenderer.invoke( + 'LightProbeBake', 'clearBake', [options], 120_000, + () => Rpc.getInstance().request('LightProbeBake', 'clearBake', [options]), + ), + cancel: () => lightFXBakeRenderer.cancel( + 'LightProbeBake', + () => Rpc.getInstance().request('LightProbeBake', 'cancel'), + ), +}; + +export const LightmapBakeProxy: IPublicLightmapBakeService = { + queryCapabilities: () => lightFXBakeRenderer.invoke( + 'LightmapBake', 'queryCapabilities', [], 30_000, + () => Rpc.getInstance().request('LightmapBake', 'queryCapabilities'), + ), + bake: (options) => lightFXBakeRenderer.invoke( + 'LightmapBake', 'bake', [options], (options.timeoutMs ?? 600_000) + 30_000, + () => Rpc.getInstance().request('LightmapBake', 'bake', [options]), true, + ), + queryBakeInfo: () => lightFXBakeRenderer.invoke( + 'LightmapBake', 'queryBakeInfo', [], 120_000, + () => Rpc.getInstance().request('LightmapBake', 'queryBakeInfo'), + ), + clearBake: (options) => lightFXBakeRenderer.invoke( + 'LightmapBake', 'clearBake', [options], 120_000, + () => Rpc.getInstance().request('LightmapBake', 'clearBake', [options]), + ), + cancel: () => lightFXBakeRenderer.cancel( + 'LightmapBake', + () => Rpc.getInstance().request('LightmapBake', 'cancel'), + ), +}; diff --git a/src/core/scene/main-process/scene-host-local-executor.ts b/src/core/scene/main-process/scene-host-local-executor.ts index 471b689d4..969890bdc 100644 --- a/src/core/scene/main-process/scene-host-local-executor.ts +++ b/src/core/scene/main-process/scene-host-local-executor.ts @@ -5,6 +5,7 @@ import { ProcessRPC } from '../process-rpc'; import { sceneConfigInstance } from '../scene-configs'; import { referenceImageFiles } from './reference-image-files'; import { referenceImageStore } from './reference-image-store'; +import { lightFXBakeHost } from './lightfx-bake-host'; import { reflectionProbeRenderer } from './reflection-probe-renderer'; import { reflectionProbeBakeHost } from './reflection-probe-bake-host'; @@ -15,6 +16,7 @@ export interface SceneHostModules { i18n: typeof i18n; referenceImageFiles: typeof referenceImageFiles; referenceImageStore: typeof referenceImageStore; + lightFXBakeHost: typeof lightFXBakeHost; reflectionProbeRenderer: typeof reflectionProbeRenderer; reflectionProbeBakeHost: typeof reflectionProbeBakeHost; } @@ -27,6 +29,8 @@ const defaultSceneHostModules: SceneHostModules = { // Feature-owned Node modules: external file reads and serialized local configuration writes. referenceImageFiles, referenceImageStore, + // Native LightFX execution, filesystem staging and Asset DB transactions stay in Node. + lightFXBakeHost, reflectionProbeRenderer, // Filesystem writes, native cmft execution and output transactions must never run in a Webview. reflectionProbeBakeHost, @@ -58,6 +62,9 @@ export class SceneHostLocalExecutor { public dispose(): void { this.rpc.dispose(); + void this.modules.lightFXBakeHost.dispose().catch((error) => { + console.error('[Node] Failed to dispose the LightFX bake host:', error); + }); void this.modules.reflectionProbeBakeHost.dispose().catch((error) => { console.error('[Node] Failed to dispose the reflection-probe bake host:', error); }); diff --git a/src/core/scene/scene-process/engine-bootstrap.ts b/src/core/scene/scene-process/engine-bootstrap.ts index f2d631d40..79312fd35 100644 --- a/src/core/scene/scene-process/engine-bootstrap.ts +++ b/src/core/scene/scene-process/engine-bootstrap.ts @@ -3,12 +3,13 @@ import * as EditorExtends from '../../engine/editor-extends'; import { Rpc } from './rpc'; import { serviceManager } from './service/service-manager'; import { Service as DecoratorService } from './service/core/decorator'; +import { ServiceEvents } from './service/core'; import { ReferenceImageService } from './service/reference-image'; import { messageManager } from './service/message'; import { initLocalI18n } from './i18n'; import { CUSTOM_PIPELINE_MODULE } from '../../engine/graphics-config'; import { fetchSceneEditorSettings, syncSceneEditorBundles } from './scene-editor-assets'; -import { ServiceEvents } from './service/core/global-events'; +import { installLightProbeNormalReset } from './light-probe-normal-reset'; import './service'; @@ -122,6 +123,7 @@ export async function startup(options: { cc.physics.selector.runInEditor = true; await cc.game.init(config); + installLightProbeNormalReset(cc); // scene 进程运行在编辑器内嵌视图中,屏幕方向无意义;项目设置默认 'auto' 会让 // screenAdapter.orientation 停在 Orientation.AUTO(13),引擎 resize 时对未映射方向打 DEBUG 告警,这里固定为竖屏。 cc.view.setOrientation(cc.macro.ORIENTATION_PORTRAIT); @@ -241,8 +243,9 @@ async function setupBrowserInvokeChannel(serverURL: string) { rendererVisible = visible; socket.emit('scene-renderer:visibility', { visible }); }; - // Pink sends this event to each retained scene Webview when its editor - // tab is shown or hidden. Observe it without taking over Pink's bridge. + // Pink retains a hidden, empty Scene Webview for preloading. Track the + // host-reported visibility without taking over Pink's bridge, so Node-side + // tools select the displayed scene. window.addEventListener('message', (event: MessageEvent) => { const message = event.data; if (message?.kind === 'event' @@ -276,6 +279,41 @@ async function setupBrowserInvokeChannel(serverURL: string) { invoke(msg.module, msg.method, msg.args); } }); + socket.on('scene:invoke-lightfx', async ( + msg: { + sceneUrl?: string; + module?: 'LightProbeBake' | 'LightmapBake'; + method?: 'bake' | 'queryBakeInfo' | 'queryCapabilities' | 'clearBake' | 'cancel'; + args?: unknown[]; + }, + reply: (response: { result?: unknown; sceneUrl?: string; error?: string }) => void, + ) => { + try { + const methods = msg?.module === 'LightProbeBake' + ? new Set(['bake', 'queryCapabilities', 'clearBake', 'cancel']) + : msg?.module === 'LightmapBake' + ? new Set(['bake', 'queryCapabilities', 'queryBakeInfo', 'clearBake', 'cancel']) + : null; + if (!methods?.has(msg.method || '')) { + throw new Error('Invalid LightFX scene request.'); + } + + const currentSceneUrl = await querySceneUrl(); + if (msg.method !== 'cancel' && (!currentSceneUrl || currentSceneUrl !== msg.sceneUrl)) { + throw new Error( + `The selected scene renderer is not displaying the requested scene: ${msg.sceneUrl || 'unknown'}.`, + ); + } + + const service = (DecoratorService as any)[msg.module!]; + const result = await service[msg.method!](...(msg.args || [])); + const finalSceneUrl = await querySceneUrl().catch(() => ''); + if (finalSceneUrl) updateRendererScene(finalSceneUrl); + reply({ result, sceneUrl: finalSceneUrl }); + } catch (error) { + reply({ error: error instanceof Error ? error.message : String(error) }); + } + }); socket.on('scene:capture-reflection-probe', async ( msg: { source?: IReflectionProbeSceneIdentity; sceneUrl?: string; nodePath?: string; componentUuid?: string; timeoutMs?: number }, reply: (response: { result?: unknown; error?: string }) => void, diff --git a/src/core/scene/scene-process/light-probe-normal-reset.ts b/src/core/scene/scene-process/light-probe-normal-reset.ts new file mode 100644 index 000000000..41f145b79 --- /dev/null +++ b/src/core/scene/scene-process/light-probe-normal-reset.ts @@ -0,0 +1,44 @@ +type ProbeNormal = { + set(x: number, y: number, z: number): unknown; +}; + +type Probe = { + normal?: ProbeNormal; +}; + +type LightProbesData = { + probes?: Probe[]; +}; + +type UpdateTetrahedrons = (this: LightProbesData, ...args: unknown[]) => unknown; + +const NORMAL_RESET_PATCH = Symbol('cocos-cli-light-probe-normal-reset'); + +type PatchedUpdateTetrahedrons = UpdateTetrahedrons & { + [NORMAL_RESET_PATCH]?: boolean; +}; + +/** + * Cocos 4.0 accumulates convex-hull normals into the serialized Vertex.normal + * values on every tetrahedron rebuild. Clear the derived values at the CLI + * adapter boundary so edits and scene reloads always recompute from geometry. + */ +export function installLightProbeNormalReset(engine: unknown): boolean { + const prototype = (engine as { + internal?: { LightProbesData?: { prototype?: { updateTetrahedrons?: PatchedUpdateTetrahedrons } } }; + })?.internal?.LightProbesData?.prototype; + const original = prototype?.updateTetrahedrons; + if (!prototype || typeof original !== 'function' || original[NORMAL_RESET_PATCH]) { + return false; + } + + const patched: PatchedUpdateTetrahedrons = function (...args: unknown[]): unknown { + for (const probe of this.probes ?? []) { + probe.normal?.set(0, 0, 0); + } + return original.apply(this, args); + }; + Object.defineProperty(patched, NORMAL_RESET_PATCH, { value: true }); + prototype.updateTetrahedrons = patched; + return true; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/baker.ts b/src/core/scene/scene-process/service/baking/lightfx/baker.ts new file mode 100644 index 000000000..ca81604fe --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/baker.ts @@ -0,0 +1,114 @@ +import { Scene } from 'cc'; +import { encodeLightFXBase64 } from './buffer'; +import { encodeLightFXInput } from './format'; +import { LightFXExporter, LightFXExport } from './exporter'; +import { lightFXBakeHost } from './host'; +import { lightFXSceneOperation } from './scene-operation'; +import { LightFXBakeTarget, LightFXResult, LightFXSettings } from './types'; +import type { ICancelLightFXOperationOptions, ILightFXDiagnostics, IRemoveLightmapAssetsResult } from '../../../../common/lightfx-host'; + +const INPUT_CHUNK_SIZE = 512 * 1024; + +export interface LightFXBakeOutput extends LightFXExport { + result: LightFXResult; + operationId: string; + textureUrls: string[]; +} + +export class LightFXCoordinator { + private target: LightFXBakeTarget | null = null; + private operation: ICancelLightFXOperationOptions | null = null; + private lastOperation: ICancelLightFXOperationOptions | null = null; + + async queryDiagnostics(target: LightFXBakeTarget): Promise { + const owner = this.operation ?? this.lastOperation; + if (owner?.target !== target) { return undefined; } + try { + const value = await lightFXBakeHost.queryDiagnostics?.(owner); + return owner === (this.operation ?? this.lastOperation) ? value : undefined; + } catch { return undefined; } + } + + get activeTarget(): LightFXBakeTarget | null { return this.target; } + + canCancel(target: LightFXBakeTarget): boolean { return this.operation?.target === target; } + + async bake(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings, timeoutMs: number, outputUrl?: string): Promise { + if (this.target) throw new Error(`A ${this.target} LightFX bake is already in progress.`); + this.target = target; + this.lastOperation = null; + let operationId: string | undefined; + try { + if (outputUrl !== undefined && (await lightFXBakeHost.queryCapabilities())?.lightmapOutputDirectory !== true) { + throw new Error('The LightFX host does not support choosing a Lightmap output directory.'); + } + const exported = await new LightFXExporter().export(scene, target, settings); + const transactionId = lightFXSceneOperation.hostTransactionId; + ({ operationId } = await lightFXBakeHost.begin({ + transactionId, + target, + sceneName: scene.name, + ...(target === 'lightmap' ? { sceneUuid: scene.uuid, sceneStats: exported.sceneStats } : {}), + textureSources: exported.textureSources, + timeoutMs, + ...(outputUrl !== undefined ? { outputUrl } : {}), + })); + this.operation = { operationId, transactionId, target }; + this.lastOperation = this.operation; + const input = encodeLightFXInput(exported.world); + for (let offset = 0; offset < input.length; offset += INPUT_CHUNK_SIZE) { + await lightFXBakeHost.appendInput({ + operationId, + chunkBase64: encodeLightFXBase64(input.subarray(offset, Math.min(offset + INPUT_CHUNK_SIZE, input.length))), + }); + } + const output = await lightFXBakeHost.run({ operationId }); + return { ...exported, result: output.result, textureUrls: output.textureUrls, operationId }; + } catch (error) { + if (operationId) await lightFXBakeHost.rollback({ operationId }).catch(() => undefined); + this.target = null; + this.operation = null; + throw error; + } + } + + async commit(operationId: string): Promise { + try { + await lightFXBakeHost.commit({ operationId }); + } finally { + this.target = null; + this.operation = null; + } + } + + async rollback(operationId: string): Promise { + try { + await lightFXBakeHost.rollback({ operationId }); + } finally { + this.target = null; + this.operation = null; + } + } + + removeLightmapAssets(sceneUuid: string, textureUuids: string[], action: 'bake' | 'clear' = 'clear'): Promise { + return lightFXBakeHost.removeLightmapAssets({ sceneUuid, textureUuids, transactionId: lightFXSceneOperation.hostTransactionId, + ...(action === 'bake' ? { action } : {}) }); + } + + publishLightmapAssets(operationId: string): Promise<{ textureUrls: string[] }> { + return lightFXBakeHost.publishLightmapAssets({ operationId, transactionId: lightFXSceneOperation.hostTransactionId }); + } + + async cancel(target: LightFXBakeTarget): Promise<{ cancelled: boolean; target: LightFXBakeTarget | null }> { + const operation = this.operation; + if (!operation || operation.target !== target) return { cancelled: false, target: null }; + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.cancelOwnershipVersion !== 1) { + throw new Error('LightFX cancellation requires host ownership protocol version 1.'); + } + // Capture before the handshake; neither a late response nor a newer bake may retarget it. + return lightFXBakeHost.cancel(operation); + } +} + +export const lightFXCoordinator = new LightFXCoordinator(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/buffer.ts b/src/core/scene/scene-process/service/baking/lightfx/buffer.ts new file mode 100644 index 000000000..4927972ef --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/buffer.ts @@ -0,0 +1,43 @@ +const MAX_COLLECTION_LENGTH = 10_000_000; + +export class LightFXBuffer { + private data: Uint8Array; + private view: DataView; + private cursor = 0; + private length = 0; + + constructor(input?: Uint8Array) { + this.data = input ?? new Uint8Array(2048); + this.view = new DataView(this.data.buffer, this.data.byteOffset, this.data.byteLength); + this.length = input?.byteLength ?? 0; + } + toUint8Array(): Uint8Array { return this.data.slice(0, this.length); } + get remaining(): number { return this.length - this.cursor; } + writeInt8(value: number): void { this.reserve(1); this.view.setInt8(this.length, value); this.length++; } + writeInt32(value: number): void { this.reserve(4); this.view.setInt32(this.length, value, true); this.length += 4; } + writeFloat(value: number): void { this.reserve(4); this.view.setFloat32(this.length, value, true); this.length += 4; } + writeInts(values: number[]): void { values.forEach((value) => this.writeInt32(value)); } + writeFloats(values: number[]): void { values.forEach((value) => this.writeFloat(value)); } + writeHeightField(values: Uint16Array): void { this.reserve(values.length * 2); for (const value of values) { this.view.setUint16(this.length, value, true); this.length += 2; } } + writeString(value: string): void { const encoded = new TextEncoder().encode(value); this.writeInt32(encoded.length); this.reserve(encoded.length); this.data.set(encoded, this.length); this.length += encoded.length; } + readInt8(): number { this.ensure(1); const value = this.view.getInt8(this.cursor); this.cursor++; return value; } + readInt32(): number { this.ensure(4); const value = this.view.getInt32(this.cursor, true); this.cursor += 4; return value; } + readFloat(): number { this.ensure(4); const value = this.view.getFloat32(this.cursor, true); this.cursor += 4; if (!Number.isFinite(value)) throw new Error('LightFX output contains a non-finite float.'); return value; } + readFloats(count: number): number[] { this.validateCount(count); return Array.from({ length: count }, () => this.readFloat()); } + readCount(label: string): number { const count = this.readInt32(); if (count < 0 || count > MAX_COLLECTION_LENGTH) throw new Error(`Invalid LightFX ${label} count: ${count}.`); return count; } + private validateCount(count: number): void { if (!Number.isInteger(count) || count < 0 || count > MAX_COLLECTION_LENGTH) throw new Error(`Invalid LightFX array length: ${count}.`); } + private ensure(size: number): void { if (this.cursor + size > this.length) throw new Error('LightFX output is truncated.'); } + private reserve(size: number): void { const required = this.length + size; if (required <= this.data.byteLength) return; let capacity = this.data.byteLength || 1; while (capacity < required) capacity *= 2; const next = new Uint8Array(capacity); next.set(this.data); this.data = next; this.view = new DataView(next.buffer); } +} + +/** Encode bytes without depending on Node's Buffer. Available in Window and Worker runtimes. */ +export function encodeLightFXBase64(input: Uint8Array): string { + const encode = globalThis.btoa; + if (typeof encode !== 'function') throw new Error('Base64 encoding is unavailable in the scene runtime.'); + const parts: string[] = []; + const chunkSize = 0x8000; + for (let offset = 0; offset < input.length; offset += chunkSize) { + parts.push(String.fromCharCode(...input.subarray(offset, Math.min(offset + chunkSize, input.length)))); + } + return encode(parts.join('')); +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts b/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts new file mode 100644 index 000000000..071502cc1 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/deleted-lightmap-assets.ts @@ -0,0 +1,137 @@ +/** Scene-local Clear epochs and deleted asset barriers; no persistent or global Undo state. */ +export class DeletedLightmapAssets { + private readonly scenes = new WeakMap>(); + private readonly epochs = new WeakMap(); + private readonly snapshots = new WeakMap(); + private readonly replacements = new WeakMap>(); + + constructor(private readonly normalize: (uuid: string) => string = rootLightmapAssetUuid) {} + + /** A soft reload replaces the Scene but can retain its Undo history. */ + transfer(scene: object, replacement: object): void { + const blocked = this.scenes.get(scene); + if (blocked) this.scenes.set(replacement, blocked); + const epoch = this.epochs.get(scene); + if (epoch) this.epochs.set(replacement, epoch); + } + + /** Tag the actual history object, without adding fields to scene or asset serialization. */ + capture(scene: object | null | undefined, snapshot: T): T { + if (scene && snapshot && typeof snapshot === 'object') { + this.snapshots.set(snapshot, this.epochs.get(scene)?.value ?? 0); + this.replacements.get(scene)?.add(snapshot); + } + return snapshot; + } + + /** Clear invalidates all older Lightmap results, not ordinary edits or probe coefficients. */ + clearResults(scene: object): void { + const epoch = this.epochs.get(scene) ?? { value: 0 }; + epoch.value++; + this.epochs.set(scene, epoch); + } + + /** Start after the Bake's before snapshot. Commit only after recording/save succeeds. */ + beginReplacement(scene: object): { commit(): void; cancel(): void } { + if (this.replacements.has(scene)) throw new Error('A Lightmap result replacement is already being recorded.'); + const captured = new Set(); + this.replacements.set(scene, captured); + let active = true; + const cancel = (): void => { + if (!active) return; + active = false; + this.replacements.delete(scene); + captured.clear(); + }; + return { + commit: () => { + if (!active) return; + this.clearResults(scene); + const epoch = this.epochs.get(scene)!.value; + // The completed Bake can Redo its current result; earlier snapshots cannot + // revive replaced results, even when external references kept their pixels. + for (const snapshot of captured) this.snapshots.set(snapshot, epoch); + cancel(); + }, + cancel, + }; + } + + /** Protect in-flight deletes too; an unconfirmed response must not revive possibly deleted assets. */ + begin(scene: object, uuids: readonly string[]): (deleted: readonly string[]) => void { + const blocked = this.scenes.get(scene) ?? new Set(); + this.scenes.set(scene, blocked); + const added = uuids.map(this.normalize).filter(uuid => !blocked.has(uuid)); + added.forEach(uuid => blocked.add(uuid)); + return deleted => { + const confirmed = new Set(deleted.map(this.normalize)); + added.forEach(uuid => { if (!confirmed.has(uuid)) blocked.delete(uuid); }); + if (blocked.size === 0) this.scenes.delete(scene); + }; + } + + /** Copy on write: clear stale baked fields and deleted references, preserving unrelated history. */ + filter(scene: object | null | undefined, snapshot: T, format: 'dump' | 'serialized', history: object = snapshot as object): T { + const blocked = scene && this.scenes.get(scene); + const stale = (this.snapshots.get(history) ?? 0) < (scene ? this.epochs.get(scene)?.value ?? 0 : 0); + if (!blocked?.size && !stale) return snapshot; + const isBlocked = (uuid: unknown): boolean => typeof uuid === 'string' && !!blocked?.has(this.normalize(uuid)); + const visit = (value: unknown): unknown => { + if (!value || typeof value !== 'object') return value; + if (Array.isArray(value)) { + const items = value.map(visit); + return items.some((item, i) => item !== value[i]) ? items : value; + } + const record = value as Record; + if (format === 'serialized' && isBlocked(record.__uuid__)) return null; + if (format === 'dump' && typeof record.type === 'string' && isBlocked(record.value?.uuid)) { + return { ...record, value: { ...record.value, uuid: '' } }; + } + let result = record; + for (const [key, child] of Object.entries(record)) { + const filtered = visit(child); + if (filtered !== child) { + if (result === record) result = { ...record }; + result[key] = filtered; + } + } + const type = format === 'dump' ? record.type : record.__type__; + const fields = format === 'dump' ? record.value : record; + const textureUuid = format === 'dump' ? fields?.texture?.value?.uuid : fields?.texture?.__uuid__; + if ((type === 'cc.ModelBakeSettings' || type === 'cc.TerrainBlockLightmapInfo') && (stale || isBlocked(textureUuid))) { + const cleared = { ...(format === 'dump' ? result.value : result) }; + if (cleared.texture) { + cleared.texture = format === 'dump' + ? { ...cleared.texture, value: { ...cleared.texture.value, uuid: '' } } : null; + } + if (type === 'cc.ModelBakeSettings' && cleared.uvParam) { + cleared.uvParam = format === 'dump' + ? { ...cleared.uvParam, value: { ...cleared.uvParam.value, x: 0, y: 0, z: 0, w: 0 } } + : { ...cleared.uvParam, x: 0, y: 0, z: 0, w: 0 }; + } else if (type === 'cc.TerrainBlockLightmapInfo') { + for (const key of ['UOff', 'VOff', 'UScale', 'VScale']) { + if (key in cleared) cleared[key] = format === 'dump' ? { ...cleared[key], value: 0 } : 0; + } + } + result = format === 'dump' ? { ...result, value: cleared } : cleared; + } + if (stale && type === 'cc.SceneGlobals') { + const cleared = { ...(format === 'dump' ? result.value : result) }; + for (const key of ['bakedWithHighpLightmap', 'bakedWithStationaryMainLight']) { + if (key in cleared) cleared[key] = format === 'dump' ? { ...cleared[key], value: false } : false; + } + result = format === 'dump' ? { ...result, value: cleared } : cleared; + } + return result; + }; + return visit(snapshot) as T; + } +} + +export function rootLightmapAssetUuid(uuid: string): string { + const root = uuid.split('@', 1)[0]; + const decompress = (globalThis as any).EditorExtends?.UuidUtils?.decompressUUID; + return typeof decompress === 'function' ? decompress(root) : root; +} + +export const deletedLightmapAssets = new DeletedLightmapAssets(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/exporter.ts b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts new file mode 100644 index 000000000..51e6b004f --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/exporter.ts @@ -0,0 +1,114 @@ +import { DirectionalLight, director, gfx, Light, MeshRenderer, MobilityMode, renderer, Scene, SphereLight, SpotLight, Terrain, TERRAIN_BLOCK_TILE_COMPLEXITY, Texture2D, Vec3 } from 'cc'; +import type { ILightFXTextureSource, ILightFXSceneStats } from '../../../../common/lightfx-host'; +import { lightmapSceneStats } from './scene-stats'; +import { lightFXBakeHost } from './host'; +import { LightFXBakeTarget, LightFXLight, LightFXMaterial, LightFXMesh, LightFXSettings, LightFXTerrain, LightFXWorld } from './types'; +import { validLightmapUV } from './lightmap-uv'; + +export interface LightFXExport { + sceneStats?: ILightFXSceneStats; + world: LightFXWorld; + models: MeshRenderer[]; + terrains: Terrain[]; + stationaryMainLight: boolean; + textureSources: ILightFXTextureSource[]; +} + +export class LightFXExporter { + private readonly textureSources = new Map(); + + async export(scene: Scene, target: LightFXBakeTarget, settings: LightFXSettings): Promise { + const world: LightFXWorld = { name: scene.name, settings, meshes: [], terrains: [], lights: [], probes: [], textures: [] }; + const models: MeshRenderer[] = []; const terrains: Terrain[] = []; + let hasMainLight = false; let stationaryMainLight = false; + const hdr = director.root?.pipeline.pipelineSceneData.isHDR ?? false; + const visit = async (node: any): Promise => { + if (node !== scene && (node.mobility === MobilityMode.Movable || !node.activeInHierarchy || (node._objFlags & (1 << 10)))) return; + if (node !== scene) { + const terrain = node.getComponent(Terrain) as Terrain | null; + if (terrain?.enabled) { world.terrains.push(this.exportTerrain(terrain)); terrains.push(terrain); } + for (const model of node.getComponents(MeshRenderer) as MeshRenderer[]) { + if (!model.enabled) continue; const exported = await this.exportMesh(model, target); + if (exported) { world.meshes.push(exported); models.push(model); } + } + for (const light of node.getComponents(Light) as Light[]) if (light.enabled) { const exported = this.exportLight(light, hdr); if (exported) { world.lights.push(exported); if (!hasMainLight && light instanceof DirectionalLight) { hasMainLight = true; stationaryMainLight = light.node.mobility === MobilityMode.Stationary; } } } + } + for (const child of node.children) await visit(child); + }; + await visit(scene); + const exposure = hdr ? renderer.scene.Camera.standardExposureValue : 1; + for (const light of world.lights) light.color = light.color.map((value) => value * exposure); + if (scene.globals.lightProbeInfo.data) for (const probe of scene.globals.lightProbeInfo.data.probes) world.probes.push({ position: [probe.position.x, probe.position.y, probe.position.z], normal: [probe.normal.x, probe.normal.y, probe.normal.z] }); + return { world, models, terrains, stationaryMainLight, textureSources: [...this.textureSources.values()], + ...(target === 'lightmap' ? { sceneStats: lightmapSceneStats(world, TERRAIN_BLOCK_TILE_COMPLEXITY) } : {}) }; + } + + private exportTerrain(terrain: Terrain): LightFXTerrain { + const p = terrain.node.worldPosition; const info: any = terrain.info; + return { position: [p.x, p.y, p.z], tileSize: info.tileSize, blockCount: [...info.blockCount], lightmapSize: info.lightMapSize, heightField: (terrain as any).getHeightField() }; + } + + private async exportMesh(model: MeshRenderer, target: LightFXBakeTarget): Promise { + const mesh: any = model.mesh; if (!mesh) return null; const bake: any = model.bakeSettings; + if (target === 'light-probe' && !bake.bakeToLightProbe) return null; + if (target === 'lightmap' && !bake.bakeable && !bake.castShadow) return null; + const out: LightFXMesh = { castShadow: target === 'light-probe' ? true : bake.castShadow, receiveShadow: target === 'lightmap' && bake.receiveShadow, lightmapSize: target === 'lightmap' && bake.bakeable ? bake.lightmapSize : 0, vertices: [], triangles: [], materials: [] }; + const matrix = model.node.worldMatrix; let start = 0; + for (let primitive = 0; primitive < mesh.struct.primitives.length; primitive++) { + const positions: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_POSITION); const normals: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_NORMAL); const indices: any = mesh.readIndices(primitive); + const uvs: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD); const luvs: any = mesh.readAttribute(primitive, gfx.AttributeName.ATTR_TEX_COORD1); + if (!positions || !normals || !indices || positions.length !== normals.length) throw new Error(`Mesh has invalid position, normal or index data: ${model.node.name}`); + if (target === 'lightmap' && out.lightmapSize > 0 && !validLightmapUV(luvs, positions.length / 3)) throw new Error(`Mesh has missing or invalid lightmap UV: ${model.node.name}`); + for (let i = 0; i < positions.length / 3; i++) { + const p = new Vec3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); const n = new Vec3(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); + Vec3.transformMat4(p, p, matrix); Vec3.transformMat4Normal(n, n, matrix).normalize(); + out.vertices.push({ position: [p.x, p.y, p.z], normal: [n.x, n.y, n.z], uv: uvs ? [uvs[i * 2], uvs[i * 2 + 1]] : [0, 0], lightmapUV: luvs ? [luvs[i * 2], luvs[i * 2 + 1]] : [0, 0] }); + } + for (let i = 0; i < indices.length; i += 3) out.triangles.push({ indices: [indices[i] + start, indices[i + 1] + start, indices[i + 2] + start], materialId: Math.min(primitive, Math.max(0, model.materials.length - 1)) }); + start = out.vertices.length; + } + if (model.materials.length) for (const material of model.materials) out.materials.push(await this.exportMaterial(material)); + else out.materials.push(this.defaultMaterial()); + return out; + } + + private async exportMaterial(material: any): Promise { + const out = this.defaultMaterial(); if (!material) return out; + const color = material.getProperty('mainColor', 0); if (color) out.diffuse = [color.x, color.y, color.z]; + const emissive = material.getProperty('emissive', 0); if (emissive) out.emissive = [emissive.x, emissive.y, emissive.z]; + this.applyColorScale(out.diffuse, material.getProperty('albedoScale', 0)); + this.applyColorScale(out.emissive, material.getProperty('emissiveScale', 0)); + out.metallic = Number(material.getProperty('metallic', 0) ?? 0.6); out.roughness = Number(material.getProperty('roughness', 0) ?? 0.8); out.alphaCutoff = Number(material.getProperty('alphaThreshold', 0) ?? 0.5); + out.texture = await this.resolveTexture(material.getProperty('mainTexture', 0) ?? material.getProperty('albedoMap', 0)); + out.pbrMap = await this.resolveTexture(material.getProperty('pbrMap', 0)); out.emissiveMap = await this.resolveTexture(material.getProperty('emissiveMap', 0)); + return out; + } + private defaultMaterial(): LightFXMaterial { return { alphaCutoff: 0.5, metallic: 0.6, roughness: 0.8, diffuse: [1, 1, 1], emissive: [0, 0, 0], texture: '', pbrMap: '', emissiveMap: '' }; } + private applyColorScale(color: number[], scale: Vec3 | number | null): void { + if (typeof scale === 'number') { color[0] *= scale; color[1] *= scale; color[2] *= scale; } + else if (scale) { color[0] *= scale.x; color[1] *= scale.y; color[2] *= scale.z; } + } + private async resolveTexture(texture: Texture2D | null): Promise { + const pixelFormat = Texture2D.PixelFormat; + if (texture && texture.getPixelFormat() !== pixelFormat.RGBA8888 && texture.getPixelFormat() !== pixelFormat.RGB888) return ''; + const image: any = texture?.mipmaps?.[0]; if (!image?._uuid) return ''; + const uuid = String(image._uuid); + const nativeExtension = String(image._native ?? ''); + const key = `${uuid}\0${nativeExtension}`; + const existing = this.textureSources.get(key); + if (existing) return existing.fileName; + const resolved = await lightFXBakeHost.resolveTextureSource({ uuid, nativeExtension }); + if (!resolved) return ''; + this.textureSources.set(key, { uuid, nativeExtension, fileName: resolved.fileName }); + return resolved.fileName; + } + + private exportLight(light: Light, hdr: boolean): LightFXLight | null { + const p = light.node.worldPosition; const d = new Vec3(0, 0, -1); Vec3.transformQuat(d, d, light.node.worldRotation); const c: any = light.color; + const out: LightFXLight = { type: 2, position: [p.x, p.y, p.z], direction: [d.x, d.y, d.z], color: [c.x, c.y, c.z], size: 0, range: 0, attenuationFalloff: 1, spotInner: 1, spotOuter: .7071, spotFalloff: 1, directScale: light.node.mobility === MobilityMode.Static ? 1 : 0, indirectScale: 1, giEnabled: true, castShadow: (light as any).staticSettings.castShadow, shadowMask: 0 }; + if (light instanceof DirectionalLight) { out.type = 2; out.shadowMask = 1 - light.shadowSaturation; out.color = out.color.map((v) => v * light.illuminance); } + else if (light instanceof SphereLight) { out.type = 0; out.size = light.size; out.range = light.range; out.color = out.color.map((v) => v * light.luminance * (hdr ? 10_000 : 1)); } + else if (light instanceof SpotLight) { out.type = 1; out.size = light.size; out.range = light.range; out.spotInner = Math.cos(light.spotAngle / 4 * Math.PI / 180); out.spotOuter = Math.cos(light.spotAngle / 2 * Math.PI / 180); out.color = out.color.map((v) => v * light.luminance * (hdr ? 10_000 : 1)); } + else return null; return out; + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/format.ts b/src/core/scene/scene-process/service/baking/lightfx/format.ts new file mode 100644 index 000000000..0f93e1bff --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/format.ts @@ -0,0 +1,25 @@ +import { LightFXBuffer } from './buffer'; +import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from './types'; +import { validateLightmapGISamples } from '../../../../common/lightfx-limits'; + +export function encodeLightFXInput(world: LightFXWorld): Uint8Array { + if (world.settings.bakeLightmap) validateLightmapGISamples(world.settings.giSamples); + const b = new LightFXBuffer(); const s = world.settings; + b.writeInt32(LIGHTFX_FILE_VERSION); b.writeString(world.name); b.writeFloats([0, 0, 0]); b.writeFloats(s.skyRadiance); + b.writeInt32(s.msaa); b.writeInt32(s.size); b.writeFloat(s.gamma); b.writeInt8(s.highp ? 1 : 0); + b.writeFloat(s.giScale); b.writeInt32(s.giSamples); b.writeInt32(s.giPathLength); + b.writeFloat(s.giProbeScale); b.writeInt32(s.giProbeSamples); b.writeInt32(s.giProbePathLength); + b.writeInt32(s.aoLevel); b.writeFloat(s.aoStrength); b.writeFloat(s.aoRadius); b.writeFloats(s.aoColor.slice(0, 3).map((v) => v / 255)); + b.writeInt32(s.threads); b.writeInt8(s.filter ? 1 : 0); b.writeInt8(s.bakeLightmap ? 1 : 0); b.writeInt8(s.bakeLightProbe ? 1 : 0); + for (const t of world.terrains) { b.writeInt32(LightFXChunk.TERRAIN); b.writeFloats(t.position); b.writeFloat(t.tileSize); b.writeInts(t.blockCount); b.writeInt32(t.lightmapSize); b.writeHeightField(t.heightField); } + for (const m of world.meshes) { + b.writeInt32(LightFXChunk.MESH); b.writeInt8(m.castShadow ? 1 : 0); b.writeInt8(m.receiveShadow ? 1 : 0); b.writeInt32(m.lightmapSize); + b.writeInt32(m.vertices.length); b.writeInt32(m.triangles.length); b.writeInt32(m.materials.length); + m.vertices.forEach((v) => { b.writeFloats(v.position); b.writeFloats(v.normal); b.writeFloats(v.uv); b.writeFloats(v.lightmapUV); }); + m.triangles.forEach((t) => { b.writeInts(t.indices); b.writeInt32(t.materialId); }); + m.materials.forEach((m) => { b.writeFloat(m.alphaCutoff); b.writeFloat(m.metallic); b.writeFloat(m.roughness); b.writeFloats(m.diffuse); b.writeFloats(m.emissive); b.writeString(m.texture); b.writeString(m.pbrMap); b.writeString(m.emissiveMap); }); + } + for (const l of world.lights) { b.writeInt32(LightFXChunk.LIGHT); b.writeInt32(l.type); b.writeFloats(l.position); b.writeFloats(l.direction); b.writeFloats(l.color); b.writeFloat(l.size); b.writeFloat(l.range); b.writeFloat(l.attenuationFalloff); b.writeFloat(l.spotInner); b.writeFloat(l.spotOuter); b.writeFloat(l.spotFalloff); b.writeFloat(l.directScale); b.writeFloat(l.indirectScale); b.writeInt8(l.giEnabled ? 1 : 0); b.writeInt8(l.castShadow ? 1 : 0); b.writeFloat(l.shadowMask); } + for (const p of world.probes) { b.writeInt32(LightFXChunk.LIGHT_PROBE); b.writeFloats(p.position); b.writeFloats(p.normal); } + b.writeInt32(LightFXChunk.EOF); return b.toUint8Array(); +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/host.ts b/src/core/scene/scene-process/service/baking/lightfx/host.ts new file mode 100644 index 000000000..aed5ee6bf --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/host.ts @@ -0,0 +1,35 @@ +import type { + IAppendLightFXInputOptions, + IBeginLightFXBakeOptions, + IBeginLightFXBakeResult, + ILightFXBakeHostService, + ILightFXOperationOptions, + IQueryLightmapTextureInfoOptions, + IQueryLightmapTextureInfoResult, + IRemoveLightmapAssetsOptions, + IRemoveLightmapAssetsResult, + IResolveLightFXTextureSourceOptions, + IResolvedLightFXTextureSource, + IRunLightFXBakeOptions, + IRunLightFXBakeResult, + ICancelLightFXOperationOptions, +} from '../../../../common/lightfx-host'; +import { Rpc } from '../../../rpc'; + +/** JSON-only bridge from either a child scene process or a browser scene Webview to the Node host. */ +export const lightFXBakeHost: ILightFXBakeHostService = { + queryDiagnostics: options => Rpc.getInstance().request('lightFXBakeHost', 'queryDiagnostics', [options]), + queryCapabilities: () => Rpc.getInstance().request('lightFXBakeHost', 'queryCapabilities'), + reserveSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'reserveSceneOperation', [options]), + releaseSceneOperation: (options) => Rpc.getInstance().request('lightFXBakeHost', 'releaseSceneOperation', [options]), + resolveTextureSource: (options: IResolveLightFXTextureSourceOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'resolveTextureSource', [options]), + begin: (options: IBeginLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'begin', [options]), + appendInput: (options: IAppendLightFXInputOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'appendInput', [options]), + run: (options: IRunLightFXBakeOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'run', [options]), + commit: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'commit', [options]), + publishLightmapAssets: options => Rpc.getInstance().request('lightFXBakeHost', 'publishLightmapAssets', [options]), + rollback: (options: ILightFXOperationOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'rollback', [options]), + cancel: (options?: ICancelLightFXOperationOptions): Promise<{ cancelled: boolean; target: 'light-probe' | 'lightmap' | null }> => Rpc.getInstance().request('lightFXBakeHost', 'cancel', [options]), + removeLightmapAssets: (options: IRemoveLightmapAssetsOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'removeLightmapAssets', [options]), + queryLightmapTextureInfo: (options: IQueryLightmapTextureInfoOptions): Promise => Rpc.getInstance().request('lightFXBakeHost', 'queryLightmapTextureInfo', [options]), +}; diff --git a/src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts b/src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts new file mode 100644 index 000000000..f5f92123a --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/lightmap-uv.ts @@ -0,0 +1,8 @@ +/** UV presence alone is insufficient: truncated or non-finite attributes cannot be exported safely. */ +export function validLightmapUV(uv: ArrayLike | null, vertexCount: number): boolean { + if (!uv || !Number.isInteger(vertexCount) || vertexCount <= 0 || uv.length !== vertexCount * 2) { return false; } + for (let index = 0; index < uv.length; index++) { + if (!Number.isFinite(uv[index])) { return false; } + } + return true; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts b/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts new file mode 100644 index 000000000..72e4a3337 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/saved-recording.ts @@ -0,0 +1,35 @@ +import type { IUndoService } from '../../../../common'; + +/** The result is already in Undo history and must not be silently restored by the caller. */ +export class LightFXResultRetainedError extends Error { + constructor(stage: 'recording' | 'save', cause: unknown) { + super(`LightFX result retained in the scene and Undo history; ${stage} was not confirmed. Check the scene before saving again or undoing. ${cause instanceof Error ? cause.message : String(cause)}`); + this.name = 'LightFXResultRetainedError'; + } +} + +/** Record before attempting I/O: a rejected save may already have written the scene to disk. */ +export async function finishSavedLightFXRecording( + undo: Pick, + recordingId: string, + save?: () => Promise, +): Promise { + const before = undo.createCheckpoint(); + try { + await undo.endRecording(recordingId); + } catch (error) { + const current = undo.createCheckpoint(); + if (current.commandId === recordingId && current.generation === before.generation) { + throw new LightFXResultRetainedError('recording', error); + } + throw error; + } + if (save) { + try { + // Editor.save now marks the completed recording, not its predecessor. + await save(); + } catch (error) { + throw new LightFXResultRetainedError('save', error); + } + } +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts new file mode 100644 index 000000000..6c9b17517 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-context.ts @@ -0,0 +1,44 @@ +import { director, type Scene } from 'cc'; +import { Service } from '../../core'; +import type { IEditorSessionService } from '../../core/editor-session'; +import { lightFXSceneOperation } from './scene-operation'; +import type { LightFXBakeTarget } from './types'; + +export type LightFXSceneContext = ReturnType; + +/** Capture the caller's scene before reserving the host; never retarget an accepted request. */ +export function runLightFXSceneOperation(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: (context: LightFXSceneContext) => Promise): Promise { + let context: LightFXSceneContext; + return lightFXSceneOperation.run(target, action, () => { + // Inside the reservation's cleanup scope so rejection releases this exact token. + context.assertCurrent(); + return operation(context); + }, () => { + const scene = director.getScene(); + if (!scene) throw new Error('No scene is currently open.'); + context = captureLightFXScene(scene); + }); +} + +/** Pin both the saved editor session and the actual Scene, including same-URL reloads. */ +export function captureLightFXScene(scene: Scene) { + const editor = Service.Editor as unknown as IEditorSessionService; + const session = editor.getEditorSession(); + const assertCurrent = () => { + if (director.getScene() !== scene || !editor.isCurrentEditorSession(session)) { + throw new Error('The source scene changed during the LightFX operation. Retry in the intended scene.'); + } + }; + assertCurrent(); + return { + scene, + assertCurrent, + // Only result application/save/cleanup holds the lifecycle queue; native work does not. + run(operation: (save: () => Promise) => Promise): Promise { + return editor.runForSession(session, async save => { + assertCurrent(); + return operation(async () => { assertCurrent(); return save(); }); + }); + }, + }; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts new file mode 100644 index 000000000..fd4137b8b --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-operation.ts @@ -0,0 +1,50 @@ +import type { LightFXBakeTarget } from './types'; +import type { ILightFXBakeHostService } from '../../../../common/lightfx-host'; +import { lightFXBakeHost } from './host'; + +/** Local serialization plus a host reservation covering export through final scene rollback. */ +export class LightFXSceneOperation { + private active: { target: LightFXBakeTarget; action: 'bake' | 'clear' } | null = null; + private transactionId: string | undefined; + + constructor(private readonly host: Pick = lightFXBakeHost) {} + + get hostTransactionId(): string { + if (!this.transactionId) throw new Error('No LightFX scene transaction is reserved.'); + return this.transactionId; + } + + async run(target: LightFXBakeTarget, action: 'bake' | 'clear', operation: () => Promise, beforeReserve?: () => void): Promise { + if (this.active) { + throw new Error(`A ${this.active.target} LightFX ${this.active.action} operation is already in progress.`); + } + // Reserve before invoking user code or reaching its first await. Rejected operations must + // not enter snapshot/rollback code belonging to the current owner. + const owner = { target, action }; + this.active = owner; + try { + // Capture synchronous request context after the busy check but before the first await. + beforeReserve?.(); + const token = await this.host.reserveSceneOperation(owner); + this.transactionId = token.transactionId; + let result: T; + try { + result = await operation(); + } catch (error) { + // Preserve the scene failure if host cleanup must remain locked and retryable. + await this.host.releaseSceneOperation(token).catch((releaseError) => { + console.error('[LightFX] Scene reservation remains locked after failure:', releaseError); + }); + throw error; + } + // A failed release must not be reported as successful completion. + await this.host.releaseSceneOperation(token); + return result; + } finally { + this.transactionId = undefined; + if (this.active === owner) this.active = null; + } + } +} + +export const lightFXSceneOperation = new LightFXSceneOperation(); diff --git a/src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts b/src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts new file mode 100644 index 000000000..010a468cd --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/scene-stats.ts @@ -0,0 +1,12 @@ +import type { ILightFXSceneStats } from '../../../../common/lightfx-host'; +import type { LightFXWorld } from './types'; + +/** Count only geometry actually exported to LightFX, including two triangles per terrain tile. */ +export function lightmapSceneStats(world: LightFXWorld, terrainBlockTiles: number): ILightFXSceneStats { + return { + objects: world.meshes.length + world.terrains.length, + lights: world.lights.length, + triangles: world.meshes.reduce((sum, mesh) => sum + mesh.triangles.length, 0) + + world.terrains.reduce((sum, terrain) => sum + terrain.blockCount[0] * terrain.blockCount[1] * terrainBlockTiles ** 2 * 2, 0), + }; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/settings.ts b/src/core/scene/scene-process/service/baking/lightfx/settings.ts new file mode 100644 index 000000000..7a031da96 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/settings.ts @@ -0,0 +1,25 @@ +import { LightFXBakeTarget, LightFXSettings } from './types'; + +export function createDefaultLightFXSettings(target: LightFXBakeTarget): LightFXSettings { + return { + msaa: 4, + size: 1024, + gamma: 2.2, + highp: false, + skyRadiance: [0, 0, 0], + giScale: 1, + giSamples: 25, + giPathLength: 4, + giProbeScale: 1, + giProbeSamples: 1024, + giProbePathLength: 2, + aoLevel: 0, + aoStrength: 0.5, + aoRadius: 1, + aoColor: [136, 136, 136], + threads: 1, + filter: true, + bakeLightmap: target === 'lightmap', + bakeLightProbe: target === 'light-probe', + }; +} diff --git a/src/core/scene/scene-process/service/baking/lightfx/types.ts b/src/core/scene/scene-process/service/baking/lightfx/types.ts new file mode 100644 index 000000000..287e23496 --- /dev/null +++ b/src/core/scene/scene-process/service/baking/lightfx/types.ts @@ -0,0 +1,23 @@ +import type { ILightFXResult } from '../../../../common/lightfx-host'; + +export type { LightFXBakeTarget } from '../../../../common/lightfx-host'; + +export interface LightFXSettings { + msaa: number; size: number; gamma: number; highp: boolean; skyRadiance: number[]; + giScale: number; giSamples: number; giPathLength: number; + giProbeScale: number; giProbeSamples: number; giProbePathLength: number; + aoLevel: number; aoStrength: number; aoRadius: number; aoColor: number[]; + threads: number; filter: boolean; bakeLightmap: boolean; bakeLightProbe: boolean; +} +export interface LightFXVertex { position: number[]; normal: number[]; uv: number[]; lightmapUV: number[] } +export interface LightFXTriangle { indices: number[]; materialId: number } +export interface LightFXMaterial { alphaCutoff: number; metallic: number; roughness: number; diffuse: number[]; emissive: number[]; texture: string; pbrMap: string; emissiveMap: string } +export interface LightFXMesh { castShadow: boolean; receiveShadow: boolean; lightmapSize: number; vertices: LightFXVertex[]; triangles: LightFXTriangle[]; materials: LightFXMaterial[] } +export interface LightFXTerrain { position: number[]; tileSize: number; blockCount: number[]; lightmapSize: number; heightField: Uint16Array } +export interface LightFXLight { type: number; position: number[]; direction: number[]; color: number[]; size: number; range: number; attenuationFalloff: number; spotInner: number; spotOuter: number; spotFalloff: number; directScale: number; indirectScale: number; giEnabled: boolean; castShadow: boolean; shadowMask: number } +export interface LightFXProbe { position: number[]; normal: number[] } +export interface LightFXWorld { name: string; settings: LightFXSettings; meshes: LightFXMesh[]; terrains: LightFXTerrain[]; lights: LightFXLight[]; probes: LightFXProbe[]; textures: string[] } +export type LightFXResult = ILightFXResult; + +export const LIGHTFX_FILE_VERSION = 0x3730; +export const enum LightFXChunk { EOF = 0, TERRAIN = 1, MESH = 2, LIGHT = 3, LIGHT_PROBE = 4 } diff --git a/src/core/scene/scene-process/service/core/editor-session.ts b/src/core/scene/scene-process/service/core/editor-session.ts index 6c6eb9704..39b5aff88 100644 --- a/src/core/scene/scene-process/service/core/editor-session.ts +++ b/src/core/scene/scene-process/service/core/editor-session.ts @@ -10,4 +10,6 @@ export interface IEditorSessionService { getEditorSession(): IEditorSessionSnapshot; isCurrentEditorSession(session: IEditorSessionSnapshot): boolean; reloadForSession(params: IReloadOptions, session: IEditorSessionSnapshot): Promise; + /** Serialize a short result transaction with open/close/reload. Use the supplied save, not Editor.save. */ + runForSession(session: IEditorSessionSnapshot, operation: (save: () => Promise) => Promise): Promise; } diff --git a/src/core/scene/scene-process/service/dump/encode.ts b/src/core/scene/scene-process/service/dump/encode.ts index fc6bbf5fe..64cda0cca 100644 --- a/src/core/scene/scene-process/service/dump/encode.ts +++ b/src/core/scene/scene-process/service/dump/encode.ts @@ -6,6 +6,8 @@ declare const EditorExtends: any; import dumpUtil from './utils'; import { getDumpComponentAccess } from './service-access'; import { applyParticleInspectorMetadata } from './particle-inspector-metadata'; +import { withLightProbeCoefficientType } from './light-probe-metadata'; +import { withLightmapTextureType } from './lightmap-metadata'; import { DumpDefines } from './dump-defines'; import { IProperty } from '../../../@types/public'; @@ -620,6 +622,8 @@ function _checkObjFlags(node: any, data: INode) { * @param objectKey 输出有效信息,当前数据 key,以便问题排查 */ export function encodeObject(object: any, attributes: any, owner: any = null, objectKey?: string, isTemplate?: boolean): IProperty { + attributes = withLightProbeCoefficientType(attributes, owner, objectKey); + attributes = withLightmapTextureType(attributes, owner, objectKey); const ctor = dumpUtil.getConstructor(object, attributes); let defValue = dumpUtil.getDefault(attributes); diff --git a/src/core/scene/scene-process/service/dump/index.ts b/src/core/scene/scene-process/service/dump/index.ts index b6ad0a9ef..ce968e77a 100644 --- a/src/core/scene/scene-process/service/dump/index.ts +++ b/src/core/scene/scene-process/service/dump/index.ts @@ -174,6 +174,10 @@ class DumpUtil { for (const [globalKey, globalPropertyDump] of Object.entries(propertyDump)) { if (globalPropertyDump) { await this.restoreProperty(node, `_globals.${globalKey}`, globalPropertyDump); + if (globalKey === 'lightProbeInfo' && node instanceof Scene) { + // Restoring SH must also invalidate the models' cached lighting. + node.globals.lightProbeInfo.onProbeBakeFinished(); + } } } } diff --git a/src/core/scene/scene-process/service/dump/light-probe-group-restore.ts b/src/core/scene/scene-process/service/dump/light-probe-group-restore.ts new file mode 100644 index 000000000..a972d978b --- /dev/null +++ b/src/core/scene/scene-process/service/dump/light-probe-group-restore.ts @@ -0,0 +1,11 @@ +import type { LightProbeGroup } from 'cc'; + +/** Replace the engine's registered array reference without rebuilding restored SH. */ +export function restoreLightProbeGroupCache(component: object, dump: { type?: string; extends?: string[]; value?: object }): void { + if ((dump.type !== 'cc.LightProbeGroup' && !dump.extends?.includes('cc.LightProbeGroup')) || !dump.value || !('probes' in dump.value || '_probes' in dump.value)) return; + const group = component as LightProbeGroup; + if (!group.isValid || !group.enabledInHierarchy) return; + // onRestore is absent on LightProbeGroup. Calling onProbeChanged here would + // rebuild the global table before every group's snapshot has been restored. + group.node.scene?.globals.lightProbeInfo.syncData(group.node, group.probes); +} diff --git a/src/core/scene/scene-process/service/dump/light-probe-metadata.ts b/src/core/scene/scene-process/service/dump/light-probe-metadata.ts new file mode 100644 index 000000000..ea4f4ed45 --- /dev/null +++ b/src/core/scene/scene-process/service/dump/light-probe-metadata.ts @@ -0,0 +1,15 @@ +import { js, Vec3 } from 'cc'; + +/** Older engines serialize Vertex.coefficients without declaring its array element type. */ +export function withLightProbeCoefficientType( + attributes: T, + owner: object | null, + key?: string, +): T | (T & { ctor: typeof Vec3 }) { + if ((!('ctor' in attributes) || !attributes.ctor) && key === 'coefficients' && owner && js.getClassName(owner) === 'cc.Vertex') { + // Use the declared SH representation even for an empty/cleared array. Do not + // mutate engine metadata or infer an arbitrary array's type from its first item. + return { ...attributes, ctor: Vec3 }; + } + return attributes; +} diff --git a/src/core/scene/scene-process/service/dump/lightmap-metadata.ts b/src/core/scene/scene-process/service/dump/lightmap-metadata.ts new file mode 100644 index 000000000..3d8ed0dc4 --- /dev/null +++ b/src/core/scene/scene-process/service/dump/lightmap-metadata.ts @@ -0,0 +1,12 @@ +import { js, Texture2D } from 'cc'; + +/** Keep cleared lightmap bindings in snapshots even on engines without asset type metadata. */ +export function withLightmapTextureType(attributes: T, owner: object | null, key?: string): T | (T & { ctor: typeof Texture2D }) { + if ((!('ctor' in attributes) || !attributes.ctor) && owner && key === 'texture') { + const type = js.getClassName(owner); + if (type === 'cc.ModelBakeSettings' || type === 'cc.TerrainBlockLightmapInfo') { + return { ...attributes, ctor: Texture2D }; + } + } + return attributes; +} diff --git a/src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts b/src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts new file mode 100644 index 000000000..dde81085f --- /dev/null +++ b/src/core/scene/scene-process/service/dump/terrain-lightmap-restore.ts @@ -0,0 +1,13 @@ +interface TerrainLightmapRestoreTarget { + _lightmapInfos: readonly unknown[]; + getBlocks(): readonly { _updateLightmap(info: unknown): void }[]; +} + +/** Rebind existing blocks after dump restoration replaces their serialized lightmap entries. */ +export function restoreTerrainLightmapBindings(component: object, dump: { type?: string; extends?: string[]; value?: object }): void { + if ((dump.type !== 'cc.Terrain' && !dump.extends?.includes('cc.Terrain')) || !dump.value || !('_lightmapInfos' in dump.value)) return; + const terrain = component as TerrainLightmapRestoreTarget; + // Terrain.onRestore may retain already-built blocks. Update their references + // and invalidate their material even when the restored array is empty. + terrain.getBlocks().forEach((block, index) => block._updateLightmap(terrain._lightmapInfos[index] ?? null)); +} diff --git a/src/core/scene/scene-process/service/editor.ts b/src/core/scene/scene-process/service/editor.ts index e7b57bf3a..9adb024e7 100644 --- a/src/core/scene/scene-process/service/editor.ts +++ b/src/core/scene/scene-process/service/editor.ts @@ -1,5 +1,5 @@ import cc from 'cc'; -import { BaseService, register, Service } from './core'; +import { BaseService, register, Service, queryRegisteredService } from './core'; import { InternalServiceEvents } from './core/internal-events'; import { IBaseIdentifier, @@ -18,6 +18,7 @@ import { IAssetInfo } from '../../../assets/@types/public'; import { Rpc } from '../rpc'; import { enrichMissingDependencyError } from './error-utils'; import type { IEditorSessionService, IEditorSessionSnapshot } from './core/editor-session'; +import type { ITerrainService } from '../../common/terrain'; /** * EditorAsset - 统一的编辑器管理入口 @@ -95,6 +96,22 @@ export class EditorService extends BaseService implements IEditor && this.isOpen; } + public runForSession(session: IEditorSessionSnapshot, operation: (save: () => Promise) => Promise): Promise { + return this.runLifecycle(async () => { + const assertCurrent = () => { + if (!session.uuid || !this.isCurrentEditorSession(session)) { + throw new Error('The source scene session changed before its result could be applied.'); + } + }; + assertCurrent(); + return operation(async () => { + assertCurrent(); + // Already inside the lifecycle queue. Re-entering save() would deadlock. + return this.saveUnlocked({ urlOrUUID: session.uuid! }); + }); + }); + } + private invalidateEditorSession(): void { this.editorSessionGeneration++; } @@ -308,17 +325,12 @@ export class EditorService extends BaseService implements IEditor /** Terrain data lives in .terrain assets, not in the scene JSON. */ private async saveTerrainAssets(): Promise { - try { - const terrain = (Service as any).Terrain; - if (!terrain?.saveAsset) return; - const result = await terrain.saveAsset(false); - if (result === 2) { - throw new Error('Terrain asset save failed or requires a Save As target.'); - } - } catch (error) { - // During early bootstrap or isolated editor tests TerrainService may - // not be registered. Real terrain save failures use the explicit error above. - if (error instanceof Error && error.message.includes('requires a Save As')) throw error; + // Missing registration during bootstrap is distinct from a registered service failing. + const terrain = queryRegisteredService('Terrain'); + if (!terrain) return; + const result = await terrain.saveAsset(false); + if (result === 2) { + throw new Error('Terrain asset save failed or requires a Save As target.'); } } diff --git a/src/core/scene/scene-process/service/editors/scene-editor.ts b/src/core/scene/scene-process/service/editors/scene-editor.ts index 48b6e3d54..bbc3a829a 100644 --- a/src/core/scene/scene-process/service/editors/scene-editor.ts +++ b/src/core/scene/scene-process/service/editors/scene-editor.ts @@ -6,6 +6,7 @@ import { BaseEditor } from './base-editor'; import type { IAssetInfo } from '../../../../assets/@types/public'; import { editorPrefabUtils } from '../prefab/prefab-editor-utils'; +import { deletedLightmapAssets } from '../baking/lightfx/deleted-lightmap-assets'; /** * SceneEditor - 场景编辑器 @@ -98,6 +99,7 @@ export class SceneEditor extends BaseEditor { const prefabUUIDMap = editorPrefabUtils.storePrefabUUID(scene); const serializeJSON = sceneUtils.serialize(scene); const sceneAfterLoad = await sceneUtils.runSceneImmediateByJson(serializeJSON); + deletedLightmapAssets.transfer(scene, sceneAfterLoad); editorPrefabUtils.restorePrefabUUID(sceneAfterLoad, prefabUUIDMap); this.entity.instance = sceneAfterLoad; return this.encode(undefined, this._lastOpenOptions); diff --git a/src/core/scene/scene-process/service/gizmo.ts b/src/core/scene/scene-process/service/gizmo.ts index c2ff8e90f..25d2f19ae 100644 --- a/src/core/scene/scene-process/service/gizmo.ts +++ b/src/core/scene/scene-process/service/gizmo.ts @@ -1175,7 +1175,7 @@ export class GizmoService extends BaseService implements IGizmoSer } else { this.execGizmoMethods('cc.LightProbeGroup', 'changeEditMode', ['none']); } - return mode; + return this.queryLightProbeEditMode(); } queryLightProbeEditMode(): boolean { @@ -1191,7 +1191,7 @@ export class GizmoService extends BaseService implements IGizmoSer } else { this.execGizmoMethods('cc.LightProbeGroup', 'changeEditMode', ['none']); } - return mode; + return this.queryLightProbeBoundingBoxEditMode(); } queryLightProbeBoundingBoxEditMode(): boolean { @@ -1212,12 +1212,12 @@ export class GizmoService extends BaseService implements IGizmoSer } // 删除 / 复制 vertex 模式下选中的探针(也可由 gizmo onKeyDown 直接触发)。 - deleteSelectedLightProbes(): number { - return this.execGizmoMethods('cc.LightProbeGroup', 'deleteSelectedProbes', []) ?? 0; + async deleteSelectedLightProbes(): Promise { + return await this.execGizmoMethods('cc.LightProbeGroup', 'deleteSelectedProbes', []) ?? 0; } - duplicateSelectedLightProbes(): number { - return this.execGizmoMethods('cc.LightProbeGroup', 'duplicateSelectedProbes', []) ?? 0; + async duplicateSelectedLightProbes(): Promise { + return await this.execGizmoMethods('cc.LightProbeGroup', 'duplicateSelectedProbes', []) ?? 0; } // 框选探针(方案 A):上层框选时主动调用,把屏幕矩形交给 light-probe gizmo 自行投影判定。 diff --git a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts index 4f025861a..1162f6fe5 100644 --- a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/index.ts @@ -7,6 +7,7 @@ import BoxController from '../../controller/box'; import PositionController from '../../node/position-controller'; import ControllerUtils from '../../utils/controller-utils'; import { addMeshToNode, create3DNode, getModel, setMeshColor } from '../../utils/engine-utils'; +import { buildLightProbeConvex } from '../../utils/light-probe-convex'; import { registerGizmo } from '../../gizmo-defines'; // 探针数量超过该阈值时只画包围盒/线框、不逐个建球,避免海量节点 @@ -27,6 +28,7 @@ const tempDragB = new Vec3(); // 框选投影用临时量(复用避免每帧分配) const tempRegionWorld = new Vec3(); const tempRegionScreen = new Vec3(); +const unitScale = new Vec3(1, 1, 1); // 进入 vertex 模式时备份的变换工具 pivot,退出时还原(模块级:pivot 是全局共享状态)。 let pivotBackup: string | undefined; @@ -37,6 +39,7 @@ let pivotBackup: string | undefined; // 导致拖动移动的是整个 LightProbeGroup 节点而非单个探针(对齐 Creator:进入探针 // 编辑模式后不显示节点变换 gizmo)。 let toolNameBackup: string | undefined; +let viewModeBackup: string | undefined; // ── 编辑模式状态机(内联复刻 Creator manager.ts + types.ts,本仓库约定单文件)────────── @@ -57,8 +60,13 @@ const GizmoList: LightProbeGroupComponentGizmo[] = []; let sharedProbeController: PositionController | null = null; let sharedProbeControllerOwner: LightProbeGroupComponentGizmo | null = null; +function isUsableProbeGizmo(comp: LightProbeGroupComponentGizmo): boolean { + const target = comp.target as any; + return comp.visible() && !!target && target.isValid !== false && target.enabledInHierarchy !== false; +} + function isActiveProbeGizmo(comp: LightProbeGroupComponentGizmo): boolean { - return comp.visible() && !!comp.target && (comp as any)._vertexEditMode === true && + return isUsableProbeGizmo(comp) && (comp as any)._vertexEditMode === true && (comp as any)._selected?.size > 0; } @@ -154,6 +162,7 @@ export function getEditMode(): LightEditMode { /** 切换编辑模式:先发 MODE_CHANGED(new, old),再落地 currentEditMode。 */ export function changeEditMode(mode: LightEditMode | `${LightEditMode}`): void { const next = mode as LightEditMode; + if (next === currentEditMode) return; eventEmitter.emit(MODE_CHANGED, next, currentEditMode); currentEditMode = next; } @@ -172,6 +181,7 @@ export function lightProbeInfoChanged(): void { /** 全选所有组的所有探针(vertex 模式生效)。 */ export function selectAllProbes(): void { for (const comp of GizmoList) { + if (!isUsableProbeGizmo(comp)) continue; try { comp.selectAllProbes(); } catch (e) { @@ -194,34 +204,47 @@ export function unselectAllProbes(): void { /** 查询当前选中探针总数(跨所有组求和)。 */ export function getSelectedProbeCount(): number { let count = 0; - for (const comp of GizmoList) count += comp.selectedProbeCount; + for (const comp of GizmoList) { + if (isUsableProbeGizmo(comp)) count += comp.selectedProbeCount; + } return count; } /** 删除所有组中选中的探针,返回删除总数。 */ -export function deleteSelectedProbes(): number { +async function editSelectedProbes(operation: 'delete' | 'duplicate'): Promise { + const groups = GizmoList.filter(isActiveProbeGizmo); + if (groups.length === 0) return 0; + const service = getService(); + let recording: string | null = null; + try { + recording = service?.Undo?.beginRecording?.(groups.map(comp => comp.target!.node.uuid)) ?? null; + } catch { + recording = null; + } let total = 0; - for (const comp of GizmoList) { - try { - total += comp.deleteCurrentSelectedProbes(); - } catch (e) { - console.warn('[LightProbeGroup] deleteSelectedProbes failed:', e); + try { + for (const comp of groups) { + try { + total += operation === 'delete' + ? comp.deleteCurrentSelectedProbes() + : comp.duplicateCurrentSelectedProbes(); + } catch (e) { + console.warn(`[LightProbeGroup] ${operation}SelectedProbes failed:`, e); + } } + } finally { + if (recording) await service?.Undo?.endRecording?.(recording); } return total; } +export async function deleteSelectedProbes(): Promise { + return editSelectedProbes('delete'); +} + /** 复制所有组中选中的探针(原位副本),返回新增总数。 */ -export function duplicateSelectedProbes(): number { - let total = 0; - for (const comp of GizmoList) { - try { - total += comp.duplicateCurrentSelectedProbes(); - } catch (e) { - console.warn('[LightProbeGroup] duplicateSelectedProbes failed:', e); - } - } - return total; +export async function duplicateSelectedProbes(): Promise { + return editSelectedProbes('duplicate'); } /** @@ -237,6 +260,7 @@ export function regionSelectProbes( ): number { let total = 0; for (const comp of GizmoList) { + if (!isUsableProbeGizmo(comp)) continue; try { total += comp.regionSelectProbes(left, right, top, bottom, additive); } catch (e) { @@ -251,7 +275,7 @@ export function generateLightProbes(): number { let total = 0; for (const comp of GizmoList) { // 当前项目的 gizmo 实例在选中节点时才会保持可见并绑定 target。 - if (!comp.visible() || !comp.target) continue; + if (!isUsableProbeGizmo(comp)) continue; if (comp.generateLightProbes()) total++; } return total; @@ -272,19 +296,23 @@ eventEmitter.on(MODE_CHANGED, (newMode: LightEditMode, _oldMode: LightEditMode) const ttd = gizmoSvc?.transformToolData; if (gizmoSvc && ttd) { if (vertexOn) { - if (toolNameBackup === undefined) toolNameBackup = ttd.toolName; + if (toolNameBackup === undefined) { + toolNameBackup = ttd.toolName; + viewModeBackup = ttd.viewMode; + } // toolName='view' 的 setter 是 toggle 语义(依据当前 viewMode 翻转)。 // 先强制 viewMode='select',再设 'view',确保结果稳定为 view 模式, // 从而 changeTool 换成空的 ViewGizmo 并隐藏 position/rotation/scale controller。 - if (ttd.toolName !== 'view' || ttd.viewMode !== 'view') { - ttd.viewMode = 'select'; - gizmoSvc.transformToolName = 'view'; - } + if (ttd.toolName !== 'view') gizmoSvc.transformToolName = 'view'; + ttd.viewMode = 'select'; } else { // 退出 vertex(切到 BOX 或 NONE):还原为备份工具名,无备份则回到 position。 - const restore = toolNameBackup ?? 'position'; + const restoreTool = toolNameBackup; + const restoreViewMode = viewModeBackup; toolNameBackup = undefined; - if (ttd.toolName !== restore) gizmoSvc.transformToolName = restore; + viewModeBackup = undefined; + if (restoreTool !== undefined && ttd.toolName !== restoreTool) gizmoSvc.transformToolName = restoreTool; + if (restoreViewMode !== undefined) ttd.viewMode = restoreViewMode; } } } catch (e) { @@ -312,6 +340,9 @@ class LightProbeGroupComponentGizmo extends GizmoBase { private _dotsRoot: Node | null = null; // 探针球容器(跟随节点世界变换) private _wireframeNode: Node | null = null; // 四面体线框(世界坐标、单位阵) private _probesRef: Vec3[] | null = null; + private _convexNode: Node | null = null; + private _normalNode: Node | null = null; + private _boundTarget: LightProbeGroup | null = null; private _dotsVolume = -1; // 上次建点用的球体积,用于失效缓存 private _reuseMesh: any = null; private _lastInfoSig = ''; // lightProbeInfo 显示设置/数据签名,用于按需刷新 @@ -399,15 +430,18 @@ class LightProbeGroupComponentGizmo extends GizmoBase { onHide() { this._shown = false; + this._selected.clear(); this._controller.hide(); this._probeController?.hide(); if (this._dotsRoot) this._dotsRoot.active = false; if (this._wireframeNode) this._wireframeNode.active = false; + if (this._convexNode) this._convexNode.active = false; + if (this._normalNode) this._normalNode.active = false; this._lastInfoSig = ''; // 与 onShow 的「选中即进 vertex」配对:取消选中/切走节点时自动退出 vertex 模式, // 让 currentEditMode 复位为 none(否则状态机会一直停留在 vertex,导致下次选中不再自动进入, // 且面板 "Exit Probe Edit Mode" 按钮态无法复位)。box 模式为用户主动开启,这里不动它。 - if (getEditMode() !== LightEditMode.NONE) { + if (getEditMode() !== LightEditMode.NONE && !GizmoList.some(isUsableProbeGizmo)) { changeEditMode(LightEditMode.NONE); } } @@ -430,6 +464,13 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._wireframeNode.parent = gizmoRoot; this._wireframeNode.active = false; + this._convexNode = create3DNode('LightProbeConvex'); + this._convexNode.parent = gizmoRoot; + this._convexNode.active = false; + this._normalNode = create3DNode('LightProbeConvexNormals'); + this._normalNode.parent = gizmoRoot; + this._normalNode.active = false; + // 包围盒默认隐藏:仅在 box 编辑模式(_boxEditMode===true)下才显示。 this._controller.hide(); } @@ -438,7 +479,7 @@ class LightProbeGroupComponentGizmo extends GizmoBase { if (!this._isInitialized || this.target === null) return; this._minPos.set(this.target.minPos); this._maxPos.set(this.target.maxPos); - this._scale = this.target.node.getWorldScale(); + this._scale.set(1, 1, 1); this._minPropPath = this.getCompPropPath('minPos'); this._maxPropPath = this.getCompPropPath('maxPos'); } @@ -514,6 +555,10 @@ class LightProbeGroupComponentGizmo extends GizmoBase { updateControllerData() { if (!this._isInitialized || this.target == null) return; + if (this._boundTarget !== this.target) { + this._selected.clear(); + this._boundTarget = this.target; + } // 未处于显示状态(节点未选中/已切走):强制隐藏所有可视元素并早退。 // 否则 onUpdate 的每帧刷新会把探针球/线框重新激活,导致取消选中后探针不消失。 if (!this._shown) { @@ -521,28 +566,30 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._probeController?.hide(); if (this._dotsRoot) this._dotsRoot.active = false; if (this._wireframeNode) this._wireframeNode.active = false; + if (this._convexNode) this._convexNode.active = false; + if (this._normalNode) this._normalNode.active = false; return; } - if (!(this.target instanceof LightProbeGroup)) { + if (!(this.target instanceof LightProbeGroup) || (this.target as any).enabledInHierarchy === false) { this._controller.hide(); if (this._dotsRoot) this._dotsRoot.active = false; if (this._wireframeNode) this._wireframeNode.active = false; + if (this._convexNode) this._convexNode.active = false; + if (this._normalNode) this._normalNode.active = false; return; } const node = this.target.node; - const worldScale = node.getWorldScale(); const worldPos = node.getWorldPosition(); - const worldRot = tempQuat_a; - node.getWorldRotation(worldRot); + Quat.identity(tempQuat_a); // 绿色可拖包围盒:仅在 box 编辑模式下显示并可编辑,否则隐藏(避免选中即进入 Edit Area Box)。 if (this._boxEditMode) { this._controller.show(); this._controller.checkEdit(); - this._controller.setScale(worldScale); + this._controller.setScale(unitScale); this._controller.setPosition(worldPos); - this._controller.setRotation(worldRot); + this._controller.setRotation(tempQuat_a); const min = this.target.minPos; const max = this.target.maxPos; const center = Vec3.multiplyScalar(new Vec3(), Vec3.add(new Vec3(), min, max), 0.5); @@ -555,14 +602,15 @@ class LightProbeGroupComponentGizmo extends GizmoBase { // 探针球容器跟随节点世界变换 if (this._dotsRoot) { this._dotsRoot.setWorldPosition(worldPos); - this._dotsRoot.setWorldRotation(worldRot); - this._dotsRoot.setWorldScale(worldScale); + this._dotsRoot.setWorldRotation(tempQuat_a); + this._dotsRoot.setWorldScale(1, 1, 1); } this._rebuildDots(false); // 探针坐标可能因 undo/redo 等外部改动而变,但引擎的四面体数据(info.data)未随之重算, // 会导致线框与探针球脱节。检测到脱节时先让引擎重剖分,再重画线框。 this._syncEngineIfProbesChanged(); this._rebuildWireframe(); + this._rebuildConvex(); // vertex 模式下探针数据可能因 undo/redo 等外部变化而改变位置: // 数据刷新后同步把 3 轴 gizmo 重新移到当前选中探针中心(拖动中不重定位,见内部 _ctrlDragging 守卫), // 否则会出现「探针 undo 跳回原位、但 gizmo 停在旧位置」的不同步。 @@ -686,6 +734,30 @@ class LightProbeGroupComponentGizmo extends GizmoBase { ControllerUtils.drawLines(this._wireframeNode, positions, indices, WIREFRAME_COLOR); } + private _rebuildConvex(): void { + if (!this._convexNode || !this._normalNode) return; + this._convexNode.active = false; + this._normalNode.active = false; + const info = this._getLightProbeInfo(); + const data = info?.data; + if (!this.target || !info?.showConvex || !data || data.empty?.()) return; + + const geometry = buildLightProbeConvex(data.probes ?? [], data.tetrahedrons ?? []); + for (const node of [this._convexNode, this._normalNode]) { + node.setWorldPosition(0, 0, 0); + node.setRotationFromEuler(0, 0, 0); + node.setWorldScale(1, 1, 1); + } + if (geometry.indices.length > 0) { + ControllerUtils.drawLines(this._convexNode, geometry.positions, geometry.indices, WIREFRAME_COLOR); + this._convexNode.active = true; + } + if (geometry.normalIndices.length > 0) { + ControllerUtils.drawLines(this._normalNode, geometry.normalPositions, geometry.normalIndices, WIREFRAME_COLOR); + this._normalNode.active = true; + } + } + /** * 拖动开始:把当前线框的顶点【世界起点】与线段拓扑快照下来,并标记每个顶点是否属于 * 选中探针(属于则拖动中跟随世界增量移动)。之后每帧只做端点平移 + 重画,不做四面体重剖分。 @@ -1299,15 +1371,6 @@ class LightProbeGroupComponentGizmo extends GizmoBase { if (delIndices.length === 0) return 0; const node = this.target.node; - const svc = getService(); - const propPath = this.getCompPropPath('probes'); - let undoId: string | null = null; - try { - undoId = svc?.Undo?.beginRecording?.([node.uuid]) ?? null; - } catch (e) { - undoId = null; - } - if (propPath) this.onControlUpdate(propPath); // 过滤掉被选中的探针(用 Set 判定索引)。 const delSet = new Set(delIndices); @@ -1326,12 +1389,6 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this.onComponentChanged(node); // 选中已清空:隐藏 3 轴 gizmo。 this._updateProbeControllerTransform(); - if (propPath) this.onControlEnd(propPath); - try { - if (undoId) svc?.Undo?.endRecording?.(undoId); - } catch (e) { - // ignore - } this._repaint(); return delIndices.length; } @@ -1345,15 +1402,6 @@ class LightProbeGroupComponentGizmo extends GizmoBase { if (dupIndices.length === 0) return 0; const node = this.target.node; - const svc = getService(); - const propPath = this.getCompPropPath('probes'); - let undoId: string | null = null; - try { - undoId = svc?.Undo?.beginRecording?.([node.uuid]) ?? null; - } catch (e) { - undoId = null; - } - if (propPath) this.onControlUpdate(propPath); const next = probes.slice(); for (const i of dupIndices) { @@ -1375,12 +1423,6 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this.onComponentChanged(node); // 选中变为新复制出来的探针:把 3 轴 gizmo 移到它们的世界中心。 this._updateProbeControllerTransform(); - if (propPath) this.onControlEnd(propPath); - try { - if (undoId) svc?.Undo?.endRecording?.(undoId); - } catch (e) { - // ignore - } this._repaint(); return dupIndices.length; } @@ -1400,11 +1442,11 @@ class LightProbeGroupComponentGizmo extends GizmoBase { return; } if (isDuplicate) { - this.duplicateCurrentSelectedProbes(); + void duplicateSelectedProbes().catch(e => console.warn('[LightProbeGroup] duplicateSelectedProbes failed:', e)); return false; } if (isDelete) { - this.deleteCurrentSelectedProbes(); + void deleteSelectedProbes().catch(e => console.warn('[LightProbeGroup] deleteSelectedProbes failed:', e)); return false; } } @@ -1465,9 +1507,11 @@ class LightProbeGroupComponentGizmo extends GizmoBase { info ? (info.lightProbeSphereVolume ?? 1) : 1, info ? (info.showProbe ?? true) : true, info ? (info.showWireframe ?? true) : true, + info ? (info.showConvex ?? false) : false, data?.tetrahedrons?.length ?? 0, data?.probes?.length ?? 0, posSum.toFixed(3), + this.target?.node.worldPosition?.toString?.() ?? '', ].join('|'); } @@ -1484,6 +1528,14 @@ class LightProbeGroupComponentGizmo extends GizmoBase { this._wireframeNode.destroy(); this._wireframeNode = null; } + if (this._convexNode) { + this._convexNode.destroy(); + this._convexNode = null; + } + if (this._normalNode) { + this._normalNode.destroy(); + this._normalNode = null; + } if (sharedProbeControllerOwner === this) { sharedProbeController?.hide(); (sharedProbeController as any)?.destroy?.(); diff --git a/src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts new file mode 100644 index 000000000..1c2d03b8e --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/components/light-probe-group/selection.ts @@ -0,0 +1,36 @@ +/** Distinguishes probe sphere hits from transform handles without extending general mouse events. */ +export const probeSelectionEvents = new WeakSet(); + +/** Probe selection belongs to a particular component, never to a reusable Gizmo slot. */ +export class ProbeSelection { + private owner: object | null = null; + private count = 0; + readonly indices = new Set(); + private regionStart: Set | undefined; + + bind(owner: object | null, count: number): void { + if (this.owner !== owner || this.count !== count) { + this.indices.clear(); + this.regionStart = undefined; + } + this.owner = owner; + this.count = count; + } + + all(): void { + this.indices.clear(); + for (let index = 0; index < this.count; index++) { this.indices.add(index); } + } + + beginRegion(): void { this.regionStart = new Set(this.indices); } + endRegion(): void { this.regionStart = undefined; } + + /** Every drag frame uses its initial snapshot, so shrinking an additive box removes transient hits. */ + region(hits: Iterable, additive: boolean): void { + const baseline = additive ? this.regionStart ?? new Set(this.indices) : []; + this.indices.clear(); + for (const index of [...baseline, ...hits]) { + if (index >= 0 && index < this.count) { this.indices.add(index); } + } + } +} diff --git a/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts b/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts index 2b25d3052..3e775e6a2 100644 --- a/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts +++ b/src/core/scene/scene-process/service/gizmo/gizmo-operation.ts @@ -8,6 +8,7 @@ import { getRaycastResults, raycast, RaycastResults } from './utils/engine-utils import { getRaycastResultNodes, getRegionNodes } from './utils/node-utils'; import { getSelectNode } from './utils/selection-utils'; import { getEditorNodeByPath, getEditorNodePath } from './utils/editor-node'; +import { probeSelectionEvents } from './components/light-probe-group/selection'; function getService(): any { try { @@ -81,13 +82,31 @@ class GizmoOperation { private _noGizmoMouseDownEvent: GizmoMouseEvent | null = null; private _mouseDownRaycastGizmos: RaycastResults | null = null; private _anyKeyDown = false; + private _probeRegionDown: GizmoMouseEvent | undefined; + private _probeRegionDragged = false; + + private beginProbeRegion(event: GizmoMouseEvent): boolean { + if (!event.leftButton || event.altKey || (event.propagationStopped && !probeSelectionEvents.has(event)) || !getServiceProp('Gizmo')?.queryLightProbeEditMode?.() + || getServiceProp('Camera')?.controller?.isMoving?.()) { return false; } + this._probeRegionDown = event; + this._probeRegionDragged = false; + return true; + } + + private endProbeRegion(): void { + this._probeRegionDown = undefined; + this._probeRegionDragged = false; + this.clearMouseDownState(); + getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'endRegion'); + this._hideSelectionRegion(); + } - // ── light-probe vertex 模式:从「空白/线框处」起手的框选 ────────────── - // 命中的是 gizmo(线框等)但不是探针球时,若处于 vertex 模式,也允许在此起手框选, - // 这样「点探针拖」与「点空白拖」都能出现白色选区框并框选探针。 - private _probeRegionActive = false; - private _probeRegionDragging = false; - private _probeRegionDownEvent: GizmoMouseEvent | null = null; + private clearMouseDownState(): void { + this._curMouseDownInfos.length = 0; + this._gizmoMouseDownEvent = null; + this._noGizmoMouseDownEvent = null; + this._mouseDownRaycastGizmos = null; + } /** * Raycast against gizmo nodes @@ -182,64 +201,16 @@ class GizmoOperation { this._emitEventToNode(info.node, event); if (event.propagationStopped) break; } - // light-probe vertex 模式下:若命中的 gizmo 不是探针球(没有探针处理器消费事件, - // propagationStopped 仍为 false,例如命中线框),则在此起手框选, - // 使「点空白/线框处拖动」也能画白框并框选探针。 - if (!event.propagationStopped && !event.ctrlKey && !event.shiftKey && !event.metaKey) { - const gizmoSvc = getServiceProp('Gizmo'); - if (gizmoSvc?.queryLightProbeEditMode?.()) { - this._probeRegionActive = true; - this._probeRegionDragging = false; - this._probeRegionDownEvent = event; - } - } return false; } return true; } - /** vertex 框选(从 gizmo 起手):拖动达到阈值即画白框并按矩形框选探针。 */ - private _handleProbeRegionMove(event: GizmoMouseEvent): void { - const down = this._probeRegionDownEvent; - if (!down) return; - const dx = event.x - down.x; - const dy = event.y - down.y; - const distance = Math.sqrt(dx * dx + dy * dy); - if (!this._probeRegionDragging && distance < 10) return; - this._probeRegionDragging = true; - - const revertX = down.x > event.x; - const revertY = down.y < event.y; - const left = revertX ? event.x : down.x; - const right = revertX ? down.x : event.x; - const bottom = revertY ? down.y : event.y; - const top = revertY ? event.y : down.y; - - // 画白色选区框(与场景节点框选同一套绘制)。 - this._showSelectionRegion(left, right, top, bottom); - // 每帧 additive=false:以当前矩形为准重算命中集,天然幂等、gizmo 实时居中。 - getServiceProp('Gizmo')?.regionSelectLightProbes?.(left, right, top, bottom, false); - } - private _onGizmoMouseUp(event: GizmoMouseEvent): boolean { // 与 cocos-editor 一致:相机移动中不处理 const cameraCtrl = getServiceProp('Camera')?.controller; if (cameraCtrl?.isMoving?.()) return true; - // vertex 框选(从 gizmo 起手)收尾:隐藏白框,重置状态。 - if (this._probeRegionActive) { - const wasDragging = this._probeRegionDragging; - this._probeRegionActive = false; - this._probeRegionDragging = false; - this._probeRegionDownEvent = null; - if (wasDragging) { - this._hideSelectionRegion(); - this._curMouseDownInfos.length = 0; - return false; - } - // 未拖动:当作普通点击,继续走下面命中节点的 mouseUp 派发。 - } - if (this._curMouseDownInfos.length > 0) { for (const info of this._curMouseDownInfos) { event.hitPoint = info.hitPoint; @@ -262,11 +233,6 @@ class GizmoOperation { } private _onGizmoMouseMove(event: GizmoMouseEvent, results: RaycastResults) { - // vertex 框选(从 gizmo 起手):优先处理,画白框 + 框选探针。 - if (this._probeRegionActive) { - this._handleProbeRegionMove(event); - return; - } if (this._curMouseDownInfos.length > 0) { const map = new Map(); results.forEach((info: any) => map.set(info.node, info.hitPoint || new Vec3())); @@ -281,10 +247,16 @@ class GizmoOperation { // --- Main event handlers --- public onMouseDown(event: ISceneMouseEvent): boolean | void { + // A fresh press must not inherit a region whose mouse-up was lost outside the view. + if (this._probeRegionDown) { this.endProbeRegion(); } this._gizmoMoved = false; this._anyKeyDown = event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; const customEvent = createGizmoMouseEvent('mouseDown', event); + // Snapshot before point-click selection changes, not after; modifiers belong to mouse-down. + if (customEvent.leftButton && !customEvent.altKey && getServiceProp('Gizmo')?.queryLightProbeEditMode?.()) { + getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'beginRegion'); + } // 与 cocos-editor 一致:不区分按键,始终做 raycast const results = this.raycastGizmos(customEvent.x, customEvent.y); @@ -292,9 +264,13 @@ class GizmoOperation { if (results.length > 0) { this._gizmoMouseDownEvent = customEvent; - return this._onGizmoMouseDown(customEvent, results); + const result = this._onGizmoMouseDown(customEvent, results); + // Blank-space and unconsumed Gizmo hits share one region state and cleanup path. + if (!this.beginProbeRegion(customEvent)) { getServiceProp('Gizmo')?.execGizmoMethods('cc.LightProbeGroup', 'endRegion'); } + return result; } + if (this.beginProbeRegion(customEvent)) { return false; } this._noGizmoMouseDownEvent = customEvent; this._onNotGizmoMouseDown(customEvent); } @@ -303,6 +279,14 @@ class GizmoOperation { this._anyKeyDown = false; const customEvent = createGizmoMouseEvent('mouseUp', event); + if (this._probeRegionDown) { + if (!this._probeRegionDragged && !probeSelectionEvents.has(this._probeRegionDown) && !this._probeRegionDown.ctrlKey && !this._probeRegionDown.metaKey && !this._probeRegionDown.shiftKey) { + getServiceProp('Gizmo')?.unselectAllLightProbes?.(); + } + this.endProbeRegion(); + return false; + } + if (this._mouseDownRaycastGizmos && this._mouseDownRaycastGizmos.length > 0) { if (!this._gizmoMouseDownEvent) return true; this._gizmoMouseDownEvent = null; @@ -317,6 +301,19 @@ class GizmoOperation { public onMouseMove(event: ISceneMouseEvent): boolean | void { this._gizmoMoved = true; const customEvent = createGizmoMouseEvent('mouseMove', event); + const probeDown = this._probeRegionDown; + if (probeDown) { + if (!customEvent.leftButton || !getServiceProp('Gizmo')?.queryLightProbeEditMode?.()) { this.endProbeRegion(); return false; } + if (Math.hypot(customEvent.x - probeDown.x, customEvent.y - probeDown.y) < 10 && !this._probeRegionDragged) { return false; } + this._probeRegionDragged = true; + const left = Math.min(probeDown.x, customEvent.x); + const right = Math.max(probeDown.x, customEvent.x); + const bottom = Math.min(probeDown.y, customEvent.y); + const top = Math.max(probeDown.y, customEvent.y); + this._showSelectionRegion(left, right, top, bottom); + getServiceProp('Gizmo')?.regionSelectLightProbes?.(left, right, top, bottom, probeDown.ctrlKey || probeDown.metaKey || probeDown.shiftKey); + return false; + } const results = this.raycastGizmos(customEvent.x, customEvent.y); if (this._mouseDownRaycastGizmos && this._mouseDownRaycastGizmos.length > 0) { @@ -569,10 +566,12 @@ class GizmoOperation { } public clear() { - this._gizmoMouseDownEvent = null; - this._noGizmoMouseDownEvent = null; + if (this._probeRegionDown) { + this.endProbeRegion(); + } else { + this.clearMouseDownState(); + } this._hoverInNodeMap.clear(); - this._curMouseDownInfos.length = 0; } } diff --git a/src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts b/src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts new file mode 100644 index 000000000..c2b438935 --- /dev/null +++ b/src/core/scene/scene-process/service/gizmo/utils/light-probe-convex.ts @@ -0,0 +1,71 @@ +import { Vec3 } from 'cc'; + +interface ProbeVertex { + position: Readonly; + normal: Readonly; +} + +interface ProbeTetrahedron { + vertex0: number; + vertex1: number; + vertex2: number; + vertex3: number; +} + +const finite = (v: Readonly) => Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); + +/** Outer cells (-1 and -2) carry convex boundary triangles; inner tetrahedra do not. */ +export function buildLightProbeConvex(vertices: readonly ProbeVertex[], tetrahedrons: readonly ProbeTetrahedron[]) { + const positions: Vec3[] = []; + const indices: number[] = []; + const normalPositions: Vec3[] = []; + const normalIndices: number[] = []; + const vertexMap = new Map(); + const seenEdges = new Set(); + for (const tet of tetrahedrons) { + if (tet.vertex3 !== -1 && tet.vertex3 !== -2) continue; + const ids = [tet.vertex0, tet.vertex1, tet.vertex2]; + if (new Set(ids).size !== 3 || ids.some(id => !Number.isInteger(id) || id < 0 || id >= vertices.length || !finite(vertices[id].position))) continue; + const [a, b, c] = ids.map(id => vertices[id].position); + const ab = new Vec3(b.x - a.x, b.y - a.y, b.z - a.z); + const ac = new Vec3(c.x - a.x, c.y - a.y, c.z - a.z); + const area = Math.hypot(ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x); + if (!Number.isFinite(area) || area === 0) continue; + for (const id of ids) { + if (!vertexMap.has(id)) { + vertexMap.set(id, positions.length); + positions.push(Vec3.clone(vertices[id].position)); + } + } + for (let i = 0; i < 3; i++) { + const from = ids[i]; + const to = ids[(i + 1) % 3]; + const edge = from < to ? `${from}:${to}` : `${to}:${from}`; + if (seenEdges.has(edge)) continue; + seenEdges.add(edge); + indices.push(vertexMap.get(from)!, vertexMap.get(to)!); + } + } + + if (positions.length) { + const min = Vec3.clone(positions[0]); + const max = Vec3.clone(positions[0]); + for (const position of positions) { + Vec3.min(min, min, position); + Vec3.max(max, max, position); + } + // A scene-relative display length, independent of node scale and probe sphere size. + const length = Math.max(Math.hypot(max.x - min.x, max.y - min.y, max.z - min.z) * 0.08, 0.01); + for (const id of vertexMap.keys()) { + const { position, normal } = vertices[id]; + const magnitude = Math.hypot(normal.x, normal.y, normal.z); + if (!finite(normal) || !Number.isFinite(length) || !Number.isFinite(magnitude) || magnitude === 0) continue; + const scale = length / magnitude; + const end = new Vec3(position.x + normal.x * scale, position.y + normal.y * scale, position.z + normal.z * scale); + if (!finite(end)) continue; + normalIndices.push(normalPositions.length, normalPositions.length + 1); + normalPositions.push(Vec3.clone(position), end); + } + } + return { positions, indices, normalPositions, normalIndices }; +} diff --git a/src/core/scene/scene-process/service/index.ts b/src/core/scene/scene-process/service/index.ts index ec3866930..8e4fc9524 100644 --- a/src/core/scene/scene-process/service/index.ts +++ b/src/core/scene/scene-process/service/index.ts @@ -24,4 +24,6 @@ export * from './reflection-probe'; // registration module instead of replacing its CommonJS side-effect import // with an empty tree-shaken namespace. export { ReferenceImageService } from './reference-image'; +export { LightProbeBakeService } from './light-probe-bake'; +export { LightmapBakeService } from './lightmap-bake'; export * from './core/global-events'; diff --git a/src/core/scene/scene-process/service/interfaces.ts b/src/core/scene/scene-process/service/interfaces.ts index fd4052248..93524cac5 100644 --- a/src/core/scene/scene-process/service/interfaces.ts +++ b/src/core/scene/scene-process/service/interfaces.ts @@ -36,6 +36,10 @@ import { IPublicReflectionProbeService, IPublicReferenceImageService, IReferenceImageService, + IPublicLightProbeBakeService, + ILightProbeBakeService, + IPublicLightmapBakeService, + ILightmapBakeService, IParticleService, IPublicParticleService, IPublicTerrainService, @@ -64,6 +68,8 @@ export interface IPublicServiceManager { UI: IPublicUIService, ReflectionProbe: IPublicReflectionProbeService, ReferenceImage: IPublicReferenceImageService, + LightProbeBake: IPublicLightProbeBakeService, + LightmapBake: IPublicLightmapBakeService, Particle: IPublicParticleService, Terrain: IPublicTerrainService, } @@ -88,6 +94,8 @@ export interface IServiceManager { UI: IUIService, ReflectionProbe: IReflectionProbeService, ReferenceImage: IReferenceImageService, + LightProbeBake: ILightProbeBakeService, + LightmapBake: ILightmapBakeService, Particle: IParticleService, Terrain: ITerrainService, } diff --git a/src/core/scene/scene-process/service/light-probe-bake.ts b/src/core/scene/scene-process/service/light-probe-bake.ts new file mode 100644 index 000000000..250947524 --- /dev/null +++ b/src/core/scene/scene-process/service/light-probe-bake.ts @@ -0,0 +1,224 @@ +import { SH, Vec3 } from 'cc'; +import type { + ILightFXBakeEvents, + ILightFXCancelResult, + ILightProbeBakeOptions, + ILightProbeBakeCapabilities, + ILightProbeBakeResult, + ILightProbeBakeService, +} from '../../common'; +import { lightFXCoordinator, LightFXBakeOutput } from './baking/lightfx/baker'; +import { createDefaultLightFXSettings } from './baking/lightfx/settings'; +import { lightFXBakeHost } from './baking/lightfx/host'; +import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; +import { BaseService, register, Service } from './core'; +import { runLightFXSceneOperation, type LightFXSceneContext } from './baking/lightfx/scene-context'; + +interface ProbeSnapshot { + normal: Vec3; + coefficients: Vec3[]; +} + +interface LightProbeSettings { + giScale: number; + giSamples: number; + bounces: number; + reduceRinging: number; + showWireframe: boolean; + showConvex: boolean; + lightProbeSphereVolume: number; +} + +@register('LightProbeBake') +export class LightProbeBakeService extends BaseService implements ILightProbeBakeService { + async queryCapabilities(): Promise { + const host = await lightFXBakeHost.queryCapabilities(); + if (host?.sceneTransactionVersion !== 1 || typeof host.busy !== 'boolean') { + throw new Error('The LightFX host does not support scene transaction protocol version 1.'); + } + return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, + ...(host.diagnosticsVersion === 1 ? { diagnostics: await lightFXCoordinator.queryDiagnostics('light-probe') } : {}), + ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('light-probe') } : {}), busy: host.busy }; + } + + async bake(options: ILightProbeBakeOptions = {}): Promise { + return runLightFXSceneOperation('light-probe', 'bake', context => this.bakeExclusive(options, context)); + } + + private async bakeExclusive(options: ILightProbeBakeOptions, context: LightFXSceneContext): Promise { + const started = Date.now(); + const { scene } = context; + const sceneUrl = await this.querySceneUrl(); + context.assertCurrent(); + const info: any = scene.globals.lightProbeInfo; + const probes: any[] = info.data?.probes ?? []; + if (probes.length < 4) throw new Error('At least four generated light probes are required.'); + + const previousSettings = this.getSettings(info); + const settingsToApply: LightProbeSettings = { + giScale: options.giScale ?? previousSettings.giScale, + giSamples: options.giSamples ?? previousSettings.giSamples, + bounces: options.bounces ?? previousSettings.bounces, + reduceRinging: options.reduceRinging ?? previousSettings.reduceRinging, + showWireframe: options.showWireframe ?? previousSettings.showWireframe, + showConvex: options.showConvex ?? previousSettings.showConvex, + lightProbeSphereVolume: options.lightProbeSphereVolume ?? previousSettings.lightProbeSphereVolume, + }; + const settings = createDefaultLightFXSettings('light-probe'); + settings.giProbeScale = settingsToApply.giScale; + settings.giProbeSamples = settingsToApply.giSamples; + settings.giProbePathLength = settingsToApply.bounces; + + const previous = this.snapshot(probes); + let output: LightFXBakeOutput | undefined; + let nativeCommitted = false; + this.broadcast('lightfx:bake-start', 'light-probe'); + try { + output = await lightFXCoordinator.bake(scene, 'light-probe', settings, options.timeoutMs ?? 600_000); + const completed = output; + return await context.run(async save => { + const output = completed; + this.validateResult(probes, output); + await lightFXCoordinator.commit(output.operationId); + nativeCommitted = true; + + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Bake light probes' }); + try { + this.applySettings(info, settingsToApply); + this.applyResult(probes, output); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + } catch (error) { + if (!(error instanceof LightFXResultRetainedError)) { + Service.Undo.cancelRecording(undo); + this.restore(probes, previous); + this.applySettings(info, previousSettings); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + } + throw error; + } + + this.broadcast('lightfx:bake-end', 'light-probe'); + return { + sceneUrl, + probeCount: probes.length, + ...settingsToApply, + durationMs: Date.now() - started, + diagnostics: await lightFXCoordinator.queryDiagnostics?.('light-probe'), + }; + }); + } catch (error) { + if (output && !nativeCommitted) await lightFXCoordinator.rollback(output.operationId).catch(() => undefined); + this.broadcast('lightfx:bake-end', 'light-probe', this.errorMessage(error)); + throw error; + } + } + + async clearBake(options: { saveScene?: boolean } = {}): Promise<{ probeCount: number }> { + return runLightFXSceneOperation('light-probe', 'clear', context => this.clearBakeExclusive(options, context)); + } + + private async clearBakeExclusive(options: { saveScene?: boolean }, context: LightFXSceneContext): Promise<{ probeCount: number }> { + const { scene } = context; + return context.run(async save => { + const info: any = scene.globals.lightProbeInfo; + const probes: any[] = info.data?.probes ?? []; + const previous = this.snapshot(probes); + const undo = Service.Undo.beginRecording([scene.uuid], { label: 'Clear light probes' }); + try { + info.onProbeBakeCleared(); + await Service.Engine.repaintInEditMode(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + return { probeCount: probes.length }; + } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; + Service.Undo.cancelRecording(undo); + this.restore(probes, previous); + info.onProbeBakeFinished(); + await Service.Engine.repaintInEditMode(); + throw error; + } + }); + } + + cancel(): Promise { + return lightFXCoordinator.cancel('light-probe'); + } + + private async querySceneUrl(): Promise { + const current = await Service.Editor.queryCurrent(); + const sceneUrl = ((current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl) as string | undefined; + if (!sceneUrl?.endsWith('.scene')) throw new Error('Light probes can only be baked in a saved scene asset.'); + return sceneUrl; + } + + private validateResult(probes: any[], output: LightFXBakeOutput): void { + const result = output.result.probes; + if (result.length !== probes.length) throw new Error(`LightFX returned ${result.length} probes, expected ${probes.length}.`); + const coefficientCount = SH.getBasisCount() * 3; + result.forEach((item, index) => { + if (item.coefficients.length !== coefficientCount) throw new Error(`Light probe ${index} has an invalid SH coefficient count.`); + const position = probes[index].position; + const dx = position.x - item.position[0]; + const dy = position.y - item.position[1]; + const dz = position.z - item.position[2]; + if (dx * dx + dy * dy + dz * dz > 1e-6) throw new Error(`Light probe ${index} does not match the exported scene position.`); + }); + } + + private applyResult(probes: any[], output: LightFXBakeOutput): void { + const basisCount = SH.getBasisCount(); + output.result.probes.forEach((item, index) => { + probes[index].normal.set(...item.normal); + probes[index].coefficients = Array.from({ length: basisCount }, (_, coefficient) => new Vec3( + item.coefficients[coefficient * 3], + item.coefficients[coefficient * 3 + 1], + item.coefficients[coefficient * 3 + 2], + )); + }); + } + + private snapshot(probes: any[]): ProbeSnapshot[] { + return probes.map((probe) => ({ + normal: probe.normal.clone(), + coefficients: probe.coefficients.map((coefficient: Vec3) => coefficient.clone()), + })); + } + + private restore(probes: any[], snapshot: ProbeSnapshot[]): void { + snapshot.forEach((item, index) => { + probes[index].normal.set(item.normal); + probes[index].coefficients = item.coefficients; + }); + } + + private getSettings(info: any): LightProbeSettings { + return { + giScale: info.giScale, + giSamples: info.giSamples, + bounces: info.bounces, + reduceRinging: info.reduceRinging, + showWireframe: info.showWireframe, + showConvex: info.showConvex, + lightProbeSphereVolume: info.lightProbeSphereVolume, + }; + } + + private applySettings(info: any, settings: LightProbeSettings): void { + info.giScale = settings.giScale; + info.giSamples = settings.giSamples; + info.bounces = settings.bounces; + info.reduceRinging = settings.reduceRinging; + info.showWireframe = settings.showWireframe; + info.showConvex = settings.showConvex; + info.lightProbeSphereVolume = settings.lightProbeSphereVolume; + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/core/scene/scene-process/service/lightmap-bake.ts b/src/core/scene/scene-process/service/lightmap-bake.ts new file mode 100644 index 000000000..fca1fa18b --- /dev/null +++ b/src/core/scene/scene-process/service/lightmap-bake.ts @@ -0,0 +1,493 @@ +import { director, MeshRenderer, Scene, Terrain, Texture2D } from 'cc'; +import type { + ILightFXBakeEvents, ILightFXCancelResult, ILightmapBakeOptions, + ILightmapBakeInfo, ILightmapBakeResult, ILightmapBakeService, ILightmapBakeCapabilities, ILightmapClearResult, +} from '../../common'; +import { Rpc } from '../rpc'; +import { lightFXCoordinator } from './baking/lightfx/baker'; +import type { LightFXBakeOutput } from './baking/lightfx/baker'; +import { lightFXBakeHost } from './baking/lightfx/host'; +import { createDefaultLightFXSettings } from './baking/lightfx/settings'; +import { finishSavedLightFXRecording, LightFXResultRetainedError } from './baking/lightfx/saved-recording'; +import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets'; +import { BaseService, register, Service } from './core'; +import { loadPreviewAsset } from './preview/asset-reload'; +import { validateLightmapGISamples } from '../../common/lightfx-limits'; +import { runLightFXSceneOperation, type LightFXSceneContext } from './baking/lightfx/scene-context'; + +interface LightmapBinding { + target: any; + blockId?: number; + texture: Texture2D | null; + uv: { x: number; y: number; z: number; w: number }; +} + +@register('LightmapBake') +export class LightmapBakeService extends BaseService implements ILightmapBakeService { + async queryCapabilities(): Promise { + const host = await lightFXBakeHost.queryCapabilities(); + if (host?.sceneTransactionVersion !== 1 || host.lightmapAssetVersion !== 1 || typeof host.busy !== 'boolean') { + throw new Error('The LightFX host does not support scene transaction and immutable Lightmap asset protocol version 1.'); + } + return { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, + ...(host.lightmapOutputDirectory === true ? { outputDirectory: true as const } : {}), + ...(host.lightmapAssetCleanupVersion === 1 ? { assetCleanupVersion: 1 as const } : {}), + ...(host.diagnosticsVersion === 1 ? { diagnostics: await lightFXCoordinator.queryDiagnostics('lightmap') } : {}), + ...(host.cancelOwnershipVersion === 1 ? { cancelVersion: 1 as const, cancellable: lightFXCoordinator.canCancel('lightmap') } : {}), busy: host.busy }; + } + + async bake(options: ILightmapBakeOptions = {}): Promise { + // Scene callers (including PinK) do not necessarily pass through the public API schema. + if (options.giSamples !== undefined) validateLightmapGISamples(options.giSamples); + return runLightFXSceneOperation('lightmap', 'bake', context => this.bakeExclusive(options, context)); + } + + private async bakeExclusive(options: ILightmapBakeOptions, context: LightFXSceneContext): Promise { + const started = Date.now(); + const { scene } = context; + const sceneUrl = await this.querySceneUrl(); + // Preflight before native publication or scene mutation, not after a successful save. + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.lightmapRebakeCleanupVersion !== 1 || capabilities.lightmapPublicationVersion !== 1 + || capabilities.lightmapAuxiliaryAssetsVersion !== 1) { + throw new Error('The LightFX host does not support current Lightmap publication and cleanup. Restart the Cocos host after updating the CLI; reloading only the window may keep the old host.'); + } + const owned = (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? []; + context.assertCurrent(); + const settings = createDefaultLightFXSettings('lightmap'); + Object.assign(settings, { + msaa: options.msaa ?? settings.msaa, + size: options.resolution ?? settings.size, + filter: options.filter ?? settings.filter, + highp: options.highp ?? settings.highp, + giScale: options.giScale ?? settings.giScale, + giSamples: options.giSamples ?? settings.giSamples, + giPathLength: options.giPathLength ?? settings.giPathLength, + aoLevel: options.aoLevel ?? settings.aoLevel, + aoStrength: options.aoStrength ?? settings.aoStrength, + aoRadius: options.aoRadius ?? settings.aoRadius, + aoColor: options.aoColor?.slice(0, 3) ?? settings.aoColor, + threads: options.threads ?? settings.threads, + }); + + const timeoutMs = options.timeoutMs ?? 600_000; + let output: LightFXBakeOutput | undefined; + let nativeCommitted = false; + this.broadcast('lightfx:bake-start', 'lightmap'); + try { + output = await lightFXCoordinator.bake(scene, 'lightmap', settings, timeoutMs, options.outputUrl); + if (!output.models.length && !output.terrains.length) { + throw new Error('No bakeable meshes or terrains were found.'); + } + + const targetUrl = `db://assets/${scene.name}/lightmap`; + const textures = await this.loadOutputTextures(output, targetUrl, timeoutMs); + const completed = output; + return await context.run(async save => { + const output = completed; + // No scene/history/disk reference may precede the host's decision to retain assets. + // An unconfirmed commit can leave an orphan version, never a dangling scene binding. + await lightFXCoordinator.commit(output.operationId); + nativeCommitted = true; + const previousBindings = this.snapshotSceneBindings(scene); + const previousTextureUuids = [...new Set([...owned, ...previousBindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) + .map(uuid => this.rootAssetUuid(uuid)))]; + const affectedBindings = [...previousBindings, ...this.snapshotBindings(output)]; + const previousHighp = (scene.globals as any).bakedWithHighpLightmap; + const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + // Scene recordings do not recursively capture child components. + // Keep the flags last, after restoring each affected result binding. + const targets = [...new Set([...output.models, ...output.terrains, ...previousBindings.map(binding => binding.target)] + .map(component => component.uuid)), scene.uuid]; + const undo = Service.Undo.beginRecording(targets, { label: 'Bake lightmap' }); + const replacement = deletedLightmapAssets.beginReplacement(scene); + try { + // A successful bake replaces the complete result, including disabled objects + // that were excluded from this export but still have older bindings. + this.clearBindings(previousBindings); + this.applyBakeResult(output, textures); + (scene.globals as any).bakedWithHighpLightmap = settings.highp; + (scene.globals as any).bakedWithStationaryMainLight = output.stationaryMainLight; + await Service.Engine.repaintInEditMode(); + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + replacement.commit(); + } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; + this.restoreBindings(affectedBindings); + (scene.globals as any).bakedWithHighpLightmap = previousHighp; + (scene.globals as any).bakedWithStationaryMainLight = previousStationary; + Service.Undo.cancelRecording(undo); + throw error; + } finally { + replacement.cancel(); + } + + // Never put deletion in the apply rollback scope. The scene may already be on disk. + // Explicitly unsaved bakes retain pixels still needed by the saved scene, not for Undo. + if (options.saveScene !== false) { + await this.cleanupPreviousBake(scene, previousTextureUuids, textures); + try { + output.textureUrls = (await lightFXCoordinator.publishLightmapAssets(output.operationId)).textureUrls; + } catch (error) { + throw new Error(`New Lightmap result is saved and retained; fixed output publication was not completed. ${this.errorMessage(error)}`); + } + } + + this.broadcast('lightfx:bake-end', 'lightmap'); + return { + sceneUrl, + textureUrls: output.textureUrls, + meshCount: output.result.meshes.length, + terrainCount: output.result.terrains.length, + durationMs: Date.now() - started, + diagnostics: await lightFXCoordinator.queryDiagnostics?.('lightmap'), + }; + }); + } catch (error) { + if (output && !nativeCommitted) await lightFXCoordinator.rollback(output.operationId).catch((rollbackError) => { + console.error('[LightFX] Failed to roll back lightmap assets:', rollbackError); + }); + this.broadcast('lightfx:bake-end', 'lightmap', this.errorMessage(error)); + throw error; + } + } + + private async cleanupPreviousBake(scene: Scene, candidates: string[], textures: Map): Promise { + try { + const current = new Set([...textures.values()].map(texture => this.rootAssetUuid(texture.uuid))); + const previous = candidates.filter(uuid => !current.has(uuid)); + const retained = await this.queryRemainingSceneTextureUuids(previous); + const deletable = previous.filter(uuid => !retained.has(uuid)); + const finishDeletion = deletedLightmapAssets.begin(scene, deletable); + const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletable, 'bake'); + finishDeletion(result.deletedTextureUuids); + const retainedCount = retained.size + result.retainedTextureUuids.length + (result.retainedAuxiliaryAssetUuids?.length ?? 0); + if (retainedCount || result.failures.length) { + throw new Error(`Previous Lightmap cleanup incomplete: ${retainedCount} referenced assets retained, ${result.failures.length} deletions failed.`); + } + } catch (error) { + throw new Error(`New Lightmap result is saved and retained; previous asset cleanup was not completed. ${this.errorMessage(error)}`); + } + } + + async queryBakeInfo(): Promise { + const scene = director.getScene() as Scene | null; + if (!scene) throw new Error('No scene is currently open.'); + + const textureUuids = new Set(); + let meshCount = 0; + let terrainCount = 0; + const addTexture = (texture: any): boolean => { + const uuid = texture?.uuid ?? texture?._uuid; + if (typeof uuid !== 'string' || !uuid) return false; + textureUuids.add(uuid); + return true; + }; + const visit = (node: any): void => { + for (const model of node.getComponents(MeshRenderer) as any[]) { + if (addTexture(model.bakeSettings?.texture)) meshCount += 1; + } + for (const terrain of node.getComponents(Terrain) as any[]) { + let hasLightmap = false; + for (const info of (terrain._lightmapInfos ?? []) as any[]) { + hasLightmap = addTexture(info?.texture) || hasLightmap; + } + if (hasLightmap) terrainCount += 1; + } + node.children.forEach(visit); + }; + visit(scene); + + const assetInfo = await lightFXBakeHost.queryLightmapTextureInfo({ + uuids: [...textureUuids], + }); + return { + sceneUrl: await this.querySceneUrl(), + baked: meshCount > 0 || terrainCount > 0, + meshCount, + terrainCount, + highp: Boolean((scene.globals as any).bakedWithHighpLightmap), + stationaryMainLight: Boolean((scene.globals as any).bakedWithStationaryMainLight), + ...assetInfo, + }; + } + + async clearBake(options: { saveScene?: boolean; deleteAssets?: boolean } = {}): Promise { + return runLightFXSceneOperation('lightmap', 'clear', context => this.clearBakeExclusive(options, context)); + } + + private async clearBakeExclusive(options: { saveScene?: boolean; deleteAssets?: boolean }, context: LightFXSceneContext): Promise { + const { scene } = context; + return context.run(async save => { + if (options.deleteAssets === true && options.saveScene === false) { + throw new Error('deleteAssets requires saveScene so the saved scene cannot retain deleted lightmap references.'); + } + if (options.deleteAssets === true) { + const capabilities = await lightFXBakeHost.queryCapabilities(); + if (capabilities?.lightmapAssetCleanupVersion !== 1 || capabilities.lightmapAuxiliaryAssetsVersion !== 1) { + throw new Error('The LightFX host does not support exact Lightmap asset cleanup. Restart the Cocos host after updating the CLI.'); + } + } + + // Query before recording/clearing so a damaged ownership record cannot partially Clear. + // Older hosts ignore sceneUuid and omit the optional list, retaining exact-bound cleanup. + const owned = options.deleteAssets === true + ? (await lightFXBakeHost.queryLightmapTextureInfo({ uuids: [], sceneUuid: scene.uuid })).ownedTextureUuids ?? [] + : []; + const bindings = this.snapshotSceneBindings(scene); + const textureUuids = [...new Set([...owned, ...bindings.map(binding => binding.texture?.uuid ?? (binding.texture as any)?._uuid)] + .filter((uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0) + .map(uuid => this.rootAssetUuid(uuid)))]; + const previousHighp = (scene.globals as any).bakedWithHighpLightmap; + const previousStationary = (scene.globals as any).bakedWithStationaryMainLight; + const targets = [...new Set(bindings.map(binding => binding.target.uuid as string)), scene.uuid]; + const undo = Service.Undo.beginRecording(targets, { label: 'Clear lightmap' }); + let retainedSceneTextureUuids = new Set(); + try { + this.clearBindings(bindings); + (scene.globals as any).bakedWithHighpLightmap = false; + (scene.globals as any).bakedWithStationaryMainLight = false; + await Service.Engine.repaintInEditMode(); + if (options.deleteAssets === true) { + retainedSceneTextureUuids = await this.queryRemainingSceneTextureUuids(textureUuids); + try { + await save(); + } catch (error) { + try { + await Service.Undo.endRecording(undo); + } catch (recordingError) { + throw new LightFXResultRetainedError('recording', recordingError); + } + throw new LightFXResultRetainedError('save', error); + } + } else { + await finishSavedLightFXRecording(Service.Undo, undo, + options.saveScene !== false ? save : undefined); + } + } catch (error) { + if (error instanceof LightFXResultRetainedError) throw error; + Service.Undo.cancelRecording(undo); + this.restoreBindings(bindings); + (scene.globals as any).bakedWithHighpLightmap = previousHighp; + (scene.globals as any).bakedWithStationaryMainLight = previousStationary; + await Service.Engine.repaintInEditMode(); + throw error; + } + + if (options.deleteAssets === true) { + // Keep earlier edits, but invalidate all pre-Clear baked results. This stays outside + // rollback: a notification failure must not restore only the already-saved memory state. + deletedLightmapAssets.clearResults(scene); + Service.Undo.cancelRecording(undo); + const deletableTextureUuids = textureUuids.filter(uuid => !retainedSceneTextureUuids.has(uuid)); + const finishDeletion = deletedLightmapAssets.begin(scene, deletableTextureUuids); + const result = await lightFXCoordinator.removeLightmapAssets(scene.uuid, deletableTextureUuids); + finishDeletion(result.deletedTextureUuids); + return { + clearedCount: bindings.length, + deletedAssetCount: result.deletedTextureUuids.length + (result.deletedAuxiliaryAssetUuids?.length ?? 0), + retainedAssetCount: retainedSceneTextureUuids.size + result.retainedTextureUuids.length + (result.retainedAuxiliaryAssetUuids?.length ?? 0), + failedAssetCount: result.failures.length, + }; + } + return { clearedCount: bindings.length, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0 }; + }); + } + + /** Returns candidates still referenced by the live scene after its Lightmap bindings are cleared. */ + private async queryRemainingSceneTextureUuids(textureUuids: readonly string[]): Promise> { + const candidates = new Set(textureUuids.map(uuid => this.rootAssetUuid(uuid))); + const retained = new Set(); + if (candidates.size === 0) return retained; + + const pending: unknown[] = [JSON.parse(await Service.Editor.querySceneSerializedData()) as unknown]; + while (pending.length > 0) { + const value = pending.pop(); + if (!value || typeof value !== 'object') continue; + const assetUuid = (value as { __uuid__?: unknown }).__uuid__; + if (typeof assetUuid === 'string') { + const rootUuid = this.rootAssetUuid(assetUuid); + if (candidates.has(rootUuid)) retained.add(rootUuid); + } + pending.push(...Object.values(value)); + } + return retained; + } + + private rootAssetUuid(uuid: string): string { + try { + const decompressed = (globalThis as any).EditorExtends?.UuidUtils?.decompressUUID?.(uuid); + if (typeof decompressed === 'string' && decompressed.length > 0) return decompressed.split('@', 1)[0]; + } catch { + // Invalid identifiers are left unchanged for the Host to reject conservatively. + } + return uuid.split('@', 1)[0]; + } + + cancel(): Promise { + return lightFXCoordinator.cancel('lightmap'); + } + + private async querySceneUrl(): Promise { + const current = await Service.Editor.queryCurrent(); + const sceneUrl = ((current as any)?.__identifier__?.assetUrl ?? (current as any)?.assetUrl) as string | undefined; + if (!sceneUrl?.endsWith('.scene')) throw new Error('Lightmaps can only be baked in a saved scene asset.'); + return sceneUrl; + } + + private async loadOutputTextures( + output: LightFXBakeOutput, + targetUrl: string, + timeoutMs: number, + ): Promise> { + const textures = new Map(); + for (const item of output.result.meshes) { + await this.loadIndexedTexture(textures, 'mesh', item.index, output.textureUrls, targetUrl, timeoutMs); + } + for (const item of output.result.terrains) { + await this.loadIndexedTexture(textures, 'terrain', item.index, output.textureUrls, targetUrl, timeoutMs); + } + return textures; + } + + private async loadIndexedTexture( + textures: Map, kind: 'mesh' | 'terrain', index: number, + textureUrls: readonly string[], targetUrl: string, timeoutMs: number, + ): Promise { + const key = `${kind}:${index}`; + if (textures.has(key)) return; + const prefix = kind === 'mesh' ? 'Mesh' : 'Terrain'; + const file = `LFX_${prefix}_${String(index).padStart(4, '0')}.png`; + const textureUrl = textureUrls.find((url) => url === `${targetUrl}/${file}` || url.endsWith(`/${file}`)); + if (!textureUrl) throw new Error(`LightFX did not produce the expected lightmap texture: ${file}`); + const uuid = await this.waitForAsset(textureUrl, Math.min(timeoutMs, 60_000)); + textures.set(key, await this.loadTexture(`${uuid}@6c48a`, timeoutMs)); + } + + private applyBakeResult(output: LightFXBakeOutput, textures: Map): void { + for (const terrain of output.terrains as any[]) { + if (terrain.lightMapSize > 0) terrain._resetLightmap(true); + } + for (const item of output.result.meshes) { + const model: any = output.models[item.id]; + if (!model) throw new Error(`LightFX returned invalid mesh id: ${item.id}`); + model._updateLightmap( + textures.get(`mesh:${item.index}`), + item.offset[0], item.offset[1], item.scale[0], item.scale[1], + ); + model.node._dirtyFlags = 1; + } + for (const item of output.result.terrains) { + const terrain: any = output.terrains[item.id]; + if (!terrain) throw new Error(`LightFX returned invalid terrain id: ${item.id}`); + terrain._updateLightmap( + item.blockId, textures.get(`terrain:${item.index}`), + item.offset[0], item.offset[1], item.scale[0], item.scale[1], + ); + } + } + + private snapshotBindings(output: LightFXBakeOutput): LightmapBinding[] { + return [ + ...output.models.map((model: any) => ({ + target: model, + texture: model.bakeSettings.texture, + uv: model.bakeSettings.uvParam.clone(), + })), + ...this.snapshotTerrainBindings(output.terrains), + ]; + } + + private snapshotSceneBindings(scene: Scene): LightmapBinding[] { + const bindings: LightmapBinding[] = []; + const visit = (node: any): void => { + for (const model of node.getComponents(MeshRenderer) as any[]) { + if (model.bakeSettings.texture) { + bindings.push({ + target: model, + texture: model.bakeSettings.texture, + uv: model.bakeSettings.uvParam.clone(), + }); + } + } + bindings.push(...this.snapshotTerrainBindings(node.getComponents(Terrain))); + node.children.forEach(visit); + }; + visit(scene); + return bindings; + } + + private snapshotTerrainBindings(terrains: readonly any[]): LightmapBinding[] { + const bindings: LightmapBinding[] = []; + for (const terrain of terrains) { + ((terrain._lightmapInfos ?? []) as any[]).forEach((info, blockId) => { + if (!info?.texture) return; + bindings.push({ + target: terrain, + blockId, + texture: info.texture, + uv: info.uvParam?.clone?.() ?? { x: info.UOff, y: info.VOff, z: info.UScale, w: info.VScale }, + }); + }); + } + return bindings; + } + + private clearBindings(bindings: LightmapBinding[]): void { + for (const binding of bindings) { + if (binding.blockId === undefined) binding.target._updateLightmap(null, 0, 0, 0, 0); + else this.updateTerrainBinding(binding, null, 0, 0, 0, 0); + } + } + + private restoreBindings(bindings: LightmapBinding[]): void { + for (const binding of bindings) { + const { x, y, z, w } = binding.uv; + if (binding.blockId === undefined) binding.target._updateLightmap(binding.texture, x, y, z, w); + else this.updateTerrainBinding(binding, binding.texture, x, y, z, w); + } + } + + private updateTerrainBinding(binding: LightmapBinding, texture: Texture2D | null, x: number, y: number, z: number, w: number): void { + const terrain = binding.target; + const blockId = binding.blockId!; + if (terrain.getBlocks()[blockId]) { + terrain._updateLightmap(blockId, texture, x, y, z, w); + return; + } + // A terrain reopened under an inactive parent has serialized results but no + // runtime blocks. The engine setter assumes a block exists and throws after + // mutating the entry. Preserve the data without enabling or rebuilding nodes; + // TerrainBlock.build will consume it when the terrain is later enabled. + const info = terrain._lightmapInfos[blockId]; + if (!info) throw new Error(`Missing Terrain lightmap entry: ${blockId}`); + info.texture = texture; + info.UOff = x; + info.VOff = y; + info.UScale = z; + info.VScale = w; + } + + private async waitForAsset(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + do { + const uuid = await Rpc.getInstance().request('assetManager', 'queryUUID', [url]) as string | null; + if (uuid) return uuid; + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + throw new Error(`Lightmap texture import timed out: ${url}`); + } + + private loadTexture(uuid: string, timeoutMs: number): Promise { + return loadPreviewAsset(uuid, 'lightmap texture', { + reloadAsset: true, + timeoutMs, + }); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/core/scene/scene-process/service/node/index.ts b/src/core/scene/scene-process/service/node/index.ts index b74174f4a..675cc89cd 100644 --- a/src/core/scene/scene-process/service/node/index.ts +++ b/src/core/scene/scene-process/service/node/index.ts @@ -47,6 +47,7 @@ import { type IScene } from '../../../common/editor/scene'; import { loadAny } from './node-create'; import compMgr from '../component/index'; import { Rpc } from '../../rpc'; +import { synchronizeLightProbeTransform } from '../scene/light-probe-transform'; const creatableAssetTypes = [ 'cc.AnimationClip', @@ -219,6 +220,7 @@ export class NodeManager { } onNodeTransformChanged(node: Node, transformBit: any) { + synchronizeLightProbeTransform(node, transformBit === Node.TransformBit.POSITION); const changeOpts: IChangeNodeOptions = { type: NodeEventType.TRANSFORM_CHANGED, source: EventSourceType.ENGINE }; switch (transformBit) { @@ -269,6 +271,7 @@ export class NodeManager { const childAdded = child.parent === parent; if (childAdded) { NodeMgr.updateNodeParent(child.uuid, parent.uuid); + synchronizeLightProbeTransform(child); } this.emit('node:change', parent, { type: NodeEventType.CHILD_CHANGED }); diff --git a/src/core/scene/scene-process/service/node/node-undo.ts b/src/core/scene/scene-process/service/node/node-undo.ts index 16dec47b6..f4b0f56f2 100644 --- a/src/core/scene/scene-process/service/node/node-undo.ts +++ b/src/core/scene/scene-process/service/node/node-undo.ts @@ -8,6 +8,7 @@ import { SnapshotCommand, type ISnapshotAdapter } from '../undo/commands/snapsho import type { INodeStructureCaptureTarget } from '../undo/commands/node-structure-command-utils'; import { createUndoId, restoreNodeSnapshotDump, snapshotMapsEqual } from '../undo/commands/command-utils-shared'; import { isRootNodePath } from '../../../../engine/editor-extends/manager/path-utils'; +import { withLightProbeTransformScenes } from '../scene/light-probe-transform'; const NodeMgr = EditorExtends.Node; @@ -30,6 +31,8 @@ export interface IComponentOrderSnapshot { } export interface INodeReparentSnapshot extends INodeSnapshot { + /** Affected probe globals restore last; the scene itself must never be reparented. */ + probeScene?: true; parentUuid: string | null; parentPath: string; siblingIndex: number; @@ -91,7 +94,7 @@ export class NodeUndoHelper { return mutate(); } - const before = this.captureNodeSnapshots([node]); + const before = this.captureNodeSnapshots(withLightProbeTransformScenes([node])); const result = await mutate(); if (!result) { return result; @@ -102,7 +105,7 @@ export class NodeUndoHelper { return result; } - const after = this.captureNodeSnapshots([latestNode]); + const after = this.captureNodeSnapshots(this.findSnapshotNodes(before)); this.pushNodeSnapshotCommand(options.type, options.label, before, after, options.scope); return result; } @@ -154,7 +157,7 @@ export class NodeUndoHelper { captureReparentSnapshots(nodes: Node[]): Map { const snapshots = new Map(); - for (const node of nodes) { + for (const node of withLightProbeTransformScenes(nodes)) { if (!node?.isValid) { continue; } @@ -166,6 +169,7 @@ export class NodeUndoHelper { parentUuid: parent?.uuid ?? null, parentPath: parent ? (NodeMgr.getNodePath(parent) ?? '/') : '/', siblingIndex: node.getSiblingIndex(), + ...(node === node.scene ? { probeScene: true as const } : {}), }); } return snapshots; @@ -180,9 +184,7 @@ export class NodeUndoHelper { if (!before || changedUuids.length === 0) { return; } - const afterNodes = changedUuids - .map(uuid => NodeMgr.getNode(uuid) as Node | null) - .filter((node): node is Node => !!node?.isValid); + const afterNodes = this.findSnapshotNodes(before); const after = this.captureReparentSnapshots(afterNodes); if (this.snapshotMapsEqual(before, after)) { return; @@ -534,7 +536,7 @@ export class NodeUndoHelper { } private async _applyReparentSnapshots(data: Map): Promise { - const snapshots = [...data.values()].sort((a, b) => a.siblingIndex - b.siblingIndex); + const snapshots = [...data.values()].sort((a, b) => Number(!!a.probeScene) - Number(!!b.probeScene) || a.siblingIndex - b.siblingIndex); for (const snapshot of snapshots) { const result = await this._applyReparentSnapshot(snapshot); if (!result.success) { @@ -549,6 +551,16 @@ export class NodeUndoHelper { if (!node) { return { success: false, reason: `Node not found: ${snapshot.path || snapshot.uuid}` }; } + if (snapshot.probeScene) { + if (node !== node.scene) { return { success: false, reason: 'Probe scene snapshot target is not the current scene.' }; } + try { + await this._restoreNodeSnapshotDump(node, snapshot.dump); + this._emit('node:change', node, { source: 'undo', type: NodeEventType.COMPONENT_CHANGED }); + return { success: true }; + } catch (error) { + return { success: false, reason: error instanceof Error ? error.message : String(error) }; + } + } const parent = this._findReparentParent(snapshot); if (!parent) { return { success: false, reason: `Parent node not found: ${snapshot.parentPath || snapshot.parentUuid || '/'}` }; diff --git a/src/core/scene/scene-process/service/scene/light-probe-data.ts b/src/core/scene/scene-process/service/scene/light-probe-data.ts new file mode 100644 index 000000000..f4b14b659 --- /dev/null +++ b/src/core/scene/scene-process/service/scene/light-probe-data.ts @@ -0,0 +1,44 @@ +import { Vec3 } from 'cc'; +import type { Scene } from 'cc'; + +interface SavedProbeCoefficients { + values: Vec3[][]; + next: number; +} + +/** + * Keep deserialized SH alive while LightProbeGroup.onLoad registers groups one + * by one. Older engines resize the saved probe array after each registration, + * truncating later groups before they have had a chance to load. + */ +export function preserveLightProbeCoefficients(scene: Scene): (loaded: Scene) => void { + const byPosition = new Map(); + const keyOf = (position: Readonly) => JSON.stringify([position.x, position.y, position.z]); + for (const probe of scene.globals.lightProbeInfo.data?.probes ?? []) { + const key = keyOf(probe.position); + let entry = byPosition.get(key); + if (!entry) { + entry = { values: [], next: 0 }; + byPosition.set(key, entry); + } + entry.values.push(probe.coefficients.map(value => Vec3.clone(value))); + } + + return loaded => { + // Never apply an old scene's data to a replacement/later editor session. + if (loaded !== scene || byPosition.size === 0) return; + const info = loaded.globals.lightProbeInfo; + let restored = false; + for (const probe of info.data?.probes ?? []) { + const entry = byPosition.get(keyOf(probe.position)); + const values = entry?.values[entry.next++]; + if (!values) continue; + // Match positions, not rebuilt array indices. Consume duplicate positions + // separately and leave new/unmatched probes to the engine's own lifecycle. + probe.coefficients = values.map(value => Vec3.clone(value)); + restored = true; + } + byPosition.clear(); + if (restored) info.onProbeBakeFinished(); + }; +} diff --git a/src/core/scene/scene-process/service/scene/light-probe-transform.ts b/src/core/scene/scene-process/service/scene/light-probe-transform.ts new file mode 100644 index 000000000..4d171e839 --- /dev/null +++ b/src/core/scene/scene-process/service/scene/light-probe-transform.ts @@ -0,0 +1,39 @@ +import { Vec3, type Node, type Scene } from 'cc'; + +/** Finds the scene whose registered probe positions can be affected by this subtree. */ +export function getLightProbeTransformScene(node: Node): Scene | undefined { + const scene = node.scene; + // Ordinary scenes do not need a subtree scan on every transform/Undo capture. + if (!node.isValid || !scene?.globals?.lightProbeInfo?.data?.probes?.length) return; + const groups = node.getComponentsInChildren('cc.LightProbeGroup'); + return groups.some(group => group.isValid && group.enabledInHierarchy) ? scene : undefined; +} + +/** Keeps the engine's world-position probe convention current after a node transform. */ +export function synchronizeLightProbeTransform(node: Node, preserveCoefficients = false): void { + const scene = getLightProbeTransformScene(node); + if (!scene) return; + const info = scene.globals.lightProbeInfo; + const before = (info.data?.probes ?? []).map(probe => Vec3.clone(probe.position)); + // The engine knows the registration order and active groups. Do not regenerate + // local sample points, reorder groups or introduce a second transform convention. + info.update(false); + const after = info.data?.probes ?? []; + if (before.length === after.length && before.every((point, index) => Vec3.strictEquals(point, after[index].position))) return; + info.update(true); + // Creator retains every group's baked coefficients when a group/ancestor is translated. + // Other edit paths keep their existing invalidation policy until separately verified. + if (preserveCoefficients) info.onProbeBakeFinished(); + else info.onProbeBakeCleared(); +} + +/** Adds affected scene snapshots last so Undo restores node poses before probe data. */ +export function withLightProbeTransformScenes(nodes: Node[]): Node[] { + const result = new Set(nodes); + const scenes = new Set(nodes.map(getLightProbeTransformScene).filter((scene): scene is Scene => !!scene)); + for (const scene of scenes) { + result.delete(scene); + result.add(scene); + } + return [...result]; +} diff --git a/src/core/scene/scene-process/service/scene/utils.ts b/src/core/scene/scene-process/service/scene/utils.ts index 4e720f7be..02a46eeb1 100644 --- a/src/core/scene/scene-process/service/scene/utils.ts +++ b/src/core/scene/scene-process/service/scene/utils.ts @@ -5,6 +5,7 @@ import dumpUtil from '../dump'; import { encodePrefab } from '../dump/encode'; import type { INode, IPrefab, INodeDumpOptions } from '../../../common'; import type { IScene } from '../../../common/editor/scene'; +import { preserveLightProbeCoefficients } from './light-probe-data'; class SceneUtil { /** 默认超时:1分钟 */ @@ -15,6 +16,8 @@ class SceneUtil { * @param sceneAsset */ runScene(sceneAsset: cc.SceneAsset | cc.Scene): Promise { + const scene = sceneAsset instanceof cc.SceneAsset ? sceneAsset.scene : sceneAsset; + const restoreProbeCoefficients = scene ? preserveLightProbeCoefficients(scene) : undefined; // 重要:清空节点与组件的 path 缓存,否则会出现数据重复的问题 EditorExtends.Node.clear(); EditorExtends.Component.clear(); @@ -29,7 +32,12 @@ class SceneUtil { reject(err ?? new Error('Unknown scene run error')); return; } - resolve(instance); + try { + restoreProbeCoefficients?.(instance); + resolve(instance); + } catch (error) { + reject(error); + } } ); }); diff --git a/src/core/scene/scene-process/service/service-manager.ts b/src/core/scene/scene-process/service/service-manager.ts index 4fc16d205..1f3a1f608 100644 --- a/src/core/scene/scene-process/service/service-manager.ts +++ b/src/core/scene/scene-process/service/service-manager.ts @@ -14,6 +14,8 @@ type EventMap = { // 仅需 messageManager 转发、无服务方法扇出的事件 const MESSAGE_ONLY_EVENTS = [ + 'scene:light-probe-edit-mode-changed', + 'scene:light-probe-bounding-box-edit-mode-changed', 'dirty:changed', 'animation:state-changed', 'animation:time-changed', diff --git a/src/core/scene/scene-process/service/terrain.ts b/src/core/scene/scene-process/service/terrain.ts index 877a873a7..8df240991 100644 --- a/src/core/scene/scene-process/service/terrain.ts +++ b/src/core/scene/scene-process/service/terrain.ts @@ -740,7 +740,7 @@ export class TerrainService extends BaseService implements ITerr continue; } this.setDirty(terrain, false); - result = 0; + if (result !== 2) result = 0; } catch (error) { console.error('[Terrain] saveAsset failed:', error); result = 2; @@ -757,7 +757,7 @@ export class TerrainService extends BaseService implements ITerr if (uuid) { const code = await this.saveAsset(isClose, terrain); if (code === 2) result = 2; - else if (code === 0) result = 0; + else if (code === 0 && result !== 2) result = 0; continue; } @@ -776,7 +776,7 @@ export class TerrainService extends BaseService implements ITerr if (created) { (terrain as any)._asset = await loadAny(created.uuid ?? created); this.setDirty(terrain, false); - result = 0; + if (result !== 2) result = 0; } else { result = 2; } diff --git a/src/core/scene/scene-process/service/undo.ts b/src/core/scene/scene-process/service/undo.ts index 3c9815d9b..c7169c7cb 100644 --- a/src/core/scene/scene-process/service/undo.ts +++ b/src/core/scene/scene-process/service/undo.ts @@ -7,6 +7,8 @@ import { ServiceEvents } from './core/global-events'; import type { ISnapshotAdapter } from './undo/commands/snapshot-command'; import { restoreComponentSnapshotDump, restoreNodeSnapshotDump, snapshotMapsEqual } from './undo/commands/command-utils-shared'; import dumpUtil from './dump'; +import { withLightProbeTransformScenes } from './scene/light-probe-transform'; +import { deletedLightmapAssets } from './baking/lightfx/deleted-lightmap-assets'; interface IRecordingComponentSnapshot { uuid: string; @@ -44,7 +46,16 @@ export class UndoService extends BaseService implements IUndoServic } beginRecording(uuids: string[], options?: IUndoBeginOptions): string { - return this._undoMgr.beginRecording(uuids, options); + const nodes = uuids.map(uuid => { + const node = this._getEditorNodeManager()?.getNode?.(uuid) as Node | undefined; + return node ?? (this._getEditorComponentManager()?.getComponent?.(uuid) as Component | undefined)?.node; + }).filter((node): node is Node => this._isNodeInCurrentScene(node)); + // Fix the target set before the mutation. Even if a component is disabled + // while recording, before/after must capture the same scene globals. + const scenes = withLightProbeTransformScenes(nodes).filter(node => node === node.scene); + const targets = new Set(uuids); + for (const scene of scenes) { targets.delete(scene.uuid); targets.add(scene.uuid); } + return this._undoMgr.beginRecording([...targets], options); } async endRecording(commandId: string): Promise { @@ -241,7 +252,7 @@ export class UndoService extends BaseService implements IUndoServic kind: 'node', uuid: node.uuid, path: this._getNodePath(node), - dump: this._cloneDump(dumpUtil.dumpNode(node, { includeComponents: false })), + dump: deletedLightmapAssets.capture(node.scene, this._cloneDump(dumpUtil.dumpNode(node, { includeComponents: false }))), components: node.components .map(component => this._captureComponentSnapshot(component as Component)) .filter((snapshot): snapshot is IRecordingComponentSnapshot => !!snapshot), @@ -260,7 +271,7 @@ export class UndoService extends BaseService implements IUndoServic nodePath: this._getNodePath(component.node), index: component.node.components.indexOf(component), type: this._getComponentType(component), - dump: this._cloneDump(dumpUtil.dumpComponent(component)), + dump: deletedLightmapAssets.capture(component.node.scene, this._cloneDump(dumpUtil.dumpComponent(component))), }; } diff --git a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts index c6360bcde..8d4a9de36 100644 --- a/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts +++ b/src/core/scene/scene-process/service/undo/commands/command-utils-shared.ts @@ -1,5 +1,8 @@ import { Component, Node } from 'cc'; import type { IUndoCommandMeta, IUndoRedoResult } from '../../../../common'; +import { restoreTerrainLightmapBindings } from '../../dump/terrain-lightmap-restore'; +import { restoreLightProbeGroupCache } from '../../dump/light-probe-group-restore'; +import { deletedLightmapAssets } from '../../baking/lightfx/deleted-lightmap-assets'; export function createUndoId(prefix: string): string { try { @@ -89,6 +92,8 @@ export async function restoreNodeSnapshotDump( return; } + dump = deletedLightmapAssets.filter(node.scene, dump, 'dump'); + if (dump.name && dump.name.value !== node.name) { const name = dump.name.value as string; if (options.updateNodeName) { @@ -135,7 +140,10 @@ export async function restoreComponentSnapshotDump( if (!dump?.value) { return; } + dump = deletedLightmapAssets.filter(component.node?.scene, dump, 'dump'); const { default: dumpUtil } = await import('../../dump'); await dumpUtil.restoreComponentSnapshotProperties(component, dump); (component as any).onRestore?.(); + restoreTerrainLightmapBindings(component, dump); + restoreLightProbeGroupCache(component, dump); } diff --git a/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts b/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts index 948aa9885..f05d73e6c 100644 --- a/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts +++ b/src/core/scene/scene-process/service/undo/commands/component-command-utils.ts @@ -2,6 +2,7 @@ import { Component, Node } from 'cc'; import { EventSourceType, NodeEventType, type IUndoCommandMeta, type IUndoRedoResult } from '../../../../common'; import compMgr from '../../component/index'; import dumpUtil from '../../dump'; +import { deletedLightmapAssets } from '../../baking/lightfx/deleted-lightmap-assets'; import { createUndoId, success, @@ -52,7 +53,7 @@ export function captureComponentStructureSnapshot(component: Component): ICompon nodePath: getNodePath(component.node), index: component.node.components.indexOf(component), type: getComponentType(component), - dump: cloneDump(dump), + dump: deletedLightmapAssets.capture(component.node.scene, cloneDump(dump)), }; } diff --git a/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts b/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts index a8a9e63c9..1e8808ac7 100644 --- a/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts +++ b/src/core/scene/scene-process/service/undo/commands/node-structure-command-utils.ts @@ -4,6 +4,7 @@ import nodeMgr from '../../node/index'; import { editorPrefabUtils } from '../../prefab/prefab-editor-utils'; import { nodeOperation } from '../../prefab/node'; import { sceneUtils } from '../../scene/utils'; +import { deletedLightmapAssets } from '../../baking/lightfx/deleted-lightmap-assets'; import { createUndoId, success, @@ -75,7 +76,7 @@ export function captureNodeStructureSnapshot( return null; } - return { + return deletedLightmapAssets.capture(node.scene, { uuid: node.uuid, path: getNodePath(node) || fallbackPath, parentUuid: parent?.uuid ?? null, @@ -84,7 +85,7 @@ export function captureNodeStructureSnapshot( serializedJson, prefabAssetUuid: getPrefabAssetUuid(node), uuidTree: captureUuidTree(node), - }; + }); } function serializeNodeStructure(node: Node, serialization: NodeStructureSerialization): string { @@ -112,7 +113,7 @@ export async function restoreNodeStructureSnapshot(snapshot: INodeStructureSnaps return failure(meta, `Parent node not found: ${snapshot.parentPath || snapshot.parentUuid || '/'}`); } - const restoredNode = await deserializeNode(snapshot); + const restoredNode = await deserializeNode(snapshot, parent.scene); if (!restoredNode) { return failure(meta, `Failed to deserialize node: ${snapshot.path || snapshot.uuid}`); } @@ -295,7 +296,7 @@ function unregisterNodeTree(node: Node): void { } } -function deserializeNode(snapshot: INodeStructureSnapshot): Promise { +function deserializeNode(snapshot: INodeStructureSnapshot, scene: Node | null): Promise { return new Promise((resolve) => { try { const loadWithJson = (cc as any).assetManager?.loadWithJson; @@ -304,7 +305,7 @@ function deserializeNode(snapshot: INodeStructureSnapshot): Promise return; } - const json = JSON.parse(snapshot.serializedJson); + const json = deletedLightmapAssets.filter(scene, JSON.parse(snapshot.serializedJson), 'serialized', snapshot); loadWithJson.call((cc as any).assetManager, json, null, (error: Error | null, asset: any) => { if (error) { resolve(null); diff --git a/src/core/scene/test/deleted-lightmap-assets.test.ts b/src/core/scene/test/deleted-lightmap-assets.test.ts new file mode 100644 index 000000000..01aea4468 --- /dev/null +++ b/src/core/scene/test/deleted-lightmap-assets.test.ts @@ -0,0 +1,170 @@ +import { DeletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; + +const uv = { x: 1, y: 2, z: 3, w: 4 }; +const mesh = (uuid: string) => ({ type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid } }, uvParam: { type: 'cc.Vec4', value: { ...uv } }, bakeable: { value: true }, +} }); +const terrain = (uuid: string) => ({ type: 'cc.TerrainBlockLightmapInfo', value: { + texture: { type: 'cc.Texture2D', value: { uuid } }, + UOff: { value: 1 }, VOff: { value: 2 }, UScale: { value: 3 }, VScale: { value: 4 }, +} }); + +describe('Deleted Lightmap snapshot references', () => { + it('invalidates successive rebakes while preserving current Redo, ordinary edits and SH', async () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + let state = { position: 0, mesh: mesh('A'), terrain: [terrain('A')], sh: [1, 2, 3] }; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['state', deleted.capture(scene, structuredClone(state))]]), + equals: (a, b) => JSON.stringify([...a]) === JSON.stringify([...b]), + apply: snapshots => { state = deleted.filter(scene, snapshots.get('state'), 'dump'); return { success: true }; }, + } }); + const move = manager.beginRecording(['node']); + state.position = 10; + await manager.endRecording(move); + for (const texture of ['B', 'C']) { + const bake = manager.beginRecording(['mesh']); + const replacement = deleted.beginReplacement(scene); + state.mesh = mesh(texture); state.terrain = [terrain(texture)]; + await manager.endRecording(bake); + manager.markSaved(); + replacement.commit(); + replacement.commit(); // Completion is idempotent. + } + await manager.undo(); + expect([state.mesh.value.texture.value.uuid, state.terrain[0].value.UScale.value, state.position, state.sh]) + .toEqual(['', 0, 10, [1, 2, 3]]); + await manager.undo(); await manager.undo(); + expect(state.position).toBe(0); + await manager.redo(); await manager.redo(); + expect([state.position, state.mesh.value.texture.value.uuid]).toEqual([10, '']); + await manager.redo(); + expect([state.mesh, state.terrain, state.sh, manager.isDirty()]).toEqual([mesh('C'), [terrain('C')], [1, 2, 3], false]); + deleted.clearResults(scene); + await manager.undo(); await manager.redo(); + expect(state.mesh.value.texture.value.uuid).toBe(''); + }); + + it('does not invalidate results on failed recording/save and releases replacement capture state', () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + const before = deleted.capture(scene, mesh('A')); + const failed = deleted.beginReplacement(scene); + expect(() => deleted.beginReplacement(scene)).toThrow('already being recorded'); + const retained = deleted.capture(scene, mesh('B')); + failed.cancel(); failed.commit(); + expect(deleted.filter(scene, before, 'dump')).toBe(before); + expect(deleted.filter(scene, retained, 'dump')).toBe(retained); + const retry = deleted.beginReplacement(scene); + const after = deleted.capture(scene, mesh('C')); + retry.commit(); + expect(deleted.filter(scene, retained, 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter(scene, after, 'dump')).toBe(after); + }); + it('keeps ordinary edits but invalidates both Bake A and B at Clear, while allowing later Bake C history', async () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + let state = { position: 0, mesh: mesh('A'), terrain: [terrain('A'), terrain('A')], sh: [1, 2, 3] }; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['state', deleted.capture(scene, structuredClone(state))]]), + equals: (a, b) => JSON.stringify([...a]) === JSON.stringify([...b]), + apply: snapshots => { state = deleted.filter(scene, snapshots.get('state'), 'dump'); return { success: true }; }, + } }); + const bake = manager.beginRecording(['mesh']); + state.mesh = mesh('B@6c48a'); state.terrain = [terrain('B@6c48a'), terrain('B@6c48a')]; + await manager.endRecording(bake); + const move = manager.beginRecording(['node']); + state.position = 10; + await manager.endRecording(move); + const clear = manager.beginRecording(['mesh']); + state.mesh = mesh(''); state.terrain = [terrain(''), terrain('')]; + manager.markSaved(); manager.cancelRecording(clear); + deleted.clearResults(scene); + deleted.begin(scene, ['B'])(['B']); + + expect((await manager.undo()).success).toBe(true); + expect({ x: state.position, texture: state.mesh.value.texture.value.uuid, uv: state.mesh.value.uvParam.value, + terrain: state.terrain.map(block => [block.value.texture.value.uuid, block.value.UScale.value]), sh: state.sh }) + .toEqual({ x: 0, texture: '', uv: { x: 0, y: 0, z: 0, w: 0 }, terrain: [['', 0], ['', 0]], sh: [1, 2, 3] }); + await manager.undo(); + expect(state.mesh.value.texture.value.uuid).toBe(''); + await manager.redo(); await manager.redo(); + expect({ x: state.position, texture: state.mesh.value.texture.value.uuid, sh: state.sh }) + .toEqual({ x: 10, texture: '', sh: [1, 2, 3] }); + expect(manager.canRedo()).toBe(false); + const nextBake = manager.beginRecording(['mesh']); + state.mesh = mesh('C'); + await manager.endRecording(nextBake); + await manager.undo(); + expect(state.mesh.value.texture.value.uuid).toBe(''); + await manager.redo(); + expect(state.mesh).toEqual(mesh('C')); + }); + + it('filters serialized node reconstruction without mutating the historical JSON', () => { + const scene = {}, deleted = new DeletedLightmapAssets(); + const json = [ + { __type__: 'cc.Node', _lpos: { x: 10 } }, + { __type__: 'cc.ModelBakeSettings', texture: { __uuid__: 'B@6c48a' }, uvParam: { ...uv } }, + { __type__: 'cc.TerrainBlockLightmapInfo', texture: { __uuid__: 'B' }, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, + { __type__: 'Custom', texture: { __uuid__: 'B' }, other: { __uuid__: 'A' } }, + ]; + const original = structuredClone(json); + deleted.begin(scene, ['B'])(['B']); + expect(deleted.filter(scene, json, 'serialized')).toEqual([ + json[0], + { __type__: 'cc.ModelBakeSettings', texture: null, uvParam: { x: 0, y: 0, z: 0, w: 0 } }, + { __type__: 'cc.TerrainBlockLightmapInfo', texture: null, UOff: 0, VOff: 0, UScale: 0, VScale: 0 }, + { __type__: 'Custom', texture: null, other: { __uuid__: 'A' } }, + ]); + expect(json).toEqual(original); + }); + + it('normalizes compressed/subasset UUIDs, isolates Scene instances and releases retained assets', () => { + const deleted = new DeletedLightmapAssets(uuid => uuid.split('@')[0].replace('short', 'long')); + const scene = {}, snapshot = mesh('short@6c48a'); + const finish = deleted.begin(scene, ['long']); + expect(deleted.filter(scene, snapshot, 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter({}, snapshot, 'dump')).toBe(snapshot); + finish([]); + expect(deleted.filter(scene, snapshot, 'dump')).toBe(snapshot); + }); + + it('does not release a previously deleted asset when another deletion is retained', () => { + const deleted = new DeletedLightmapAssets(), scene = {}; + deleted.begin(scene, ['B'])(['B']); + deleted.begin(scene, ['B', 'C'])([]); + expect(deleted.filter(scene, mesh('B'), 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter(scene, mesh('C'), 'dump')).toEqual(mesh('C')); + }); + + it('transfers in-flight protection across a Scene replacement without affecting another scene', () => { + const deleted = new DeletedLightmapAssets(), scene = {}, replacement = {}; + const finish = deleted.begin(scene, ['B', 'C']); + deleted.transfer(scene, replacement); + expect(deleted.filter(replacement, mesh('B'), 'dump').value.texture.value.uuid).toBe(''); + finish(['B']); + expect(deleted.filter(replacement, mesh('B'), 'dump').value.texture.value.uuid).toBe(''); + expect(deleted.filter(replacement, mesh('C'), 'dump')).toEqual(mesh('C')); + expect(deleted.filter({}, mesh('B'), 'dump')).toEqual(mesh('B')); + }); + + it('clears only pre-Clear baked fields, preserving external references and SH across soft reload', () => { + const deleted = new DeletedLightmapAssets(), scene = {}, replacement = {}; + const before = deleted.capture(scene, [ + { __type__: 'cc.ModelBakeSettings', texture: { __uuid__: 'retained-A' }, uvParam: { ...uv } }, + { __type__: 'cc.SceneGlobals', bakedWithHighpLightmap: true, bakedWithStationaryMainLight: true, sh: [1, 2] }, + { __type__: 'Custom', texture: { __uuid__: 'retained-A' } }, + ]); + deleted.clearResults(scene); + deleted.transfer(scene, replacement); + expect(deleted.filter(replacement, before, 'serialized')).toEqual([ + { __type__: 'cc.ModelBakeSettings', texture: null, uvParam: { x: 0, y: 0, z: 0, w: 0 } }, + { __type__: 'cc.SceneGlobals', bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false, sh: [1, 2] }, + before[2], + ]); + const after = deleted.capture(replacement, mesh('C')); + expect(deleted.filter(replacement, after, 'dump')).toBe(after); + deleted.clearResults(replacement); + expect(deleted.filter(replacement, after, 'dump').value.texture.value.uuid).toBe(''); + expect(before[0].texture).toEqual({ __uuid__: 'retained-A' }); + }); +}); diff --git a/src/core/scene/test/editor-close-options.test.ts b/src/core/scene/test/editor-close-options.test.ts index 019bd25ec..7d13523f0 100644 --- a/src/core/scene/test/editor-close-options.test.ts +++ b/src/core/scene/test/editor-close-options.test.ts @@ -20,6 +20,7 @@ jest.mock('../scene-process/service/scene/utils', () => ({ generateNodeDump: jest.fn(), loadAny: jest.fn(), runScene: jest.fn(async () => undefined), + runSceneImmediateByJson: jest.fn(), serialize: jest.fn(), }, })); @@ -51,6 +52,7 @@ import { SceneEditor } from '../scene-process/service/editors/scene-editor'; import { PrefabEditor } from '../scene-process/service/editors/prefab-editor'; import { sceneUtils } from '../scene-process/service/scene/utils'; import { editorPrefabUtils } from '../scene-process/service/prefab/prefab-editor-utils'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; type CloseableEditor = SceneEditor | PrefabEditor; @@ -90,6 +92,23 @@ describe('Editor close options', () => { await expectCloseSaveCalls(new SceneEditor(), { save: false }, 0); }); + it('scene reload carries deleted Lightmap protection to the replacement Scene', async () => { + const editor = new SceneEditor(); + setOpen(editor); + const scene = editor.getRootNode()!; + const replacement = {}; + (sceneUtils.runSceneImmediateByJson as jest.Mock).mockResolvedValue(replacement); + (sceneUtils.generateNodeDump as jest.Mock).mockReturnValue({}); + deletedLightmapAssets.begin(scene, ['deleted-texture'])(['deleted-texture']); + + await editor.reload(); + + expect(editor.getRootNode()).toBe(replacement); + expect(deletedLightmapAssets.filter(replacement, { __uuid__: 'deleted-texture' }, 'serialized')).toBeNull(); + expect(deletedLightmapAssets.filter({}, { __uuid__: 'deleted-texture' }, 'serialized')) + .toEqual({ __uuid__: 'deleted-texture' }); + }); + it('prefab close saves by default and can skip save', async () => { await expectCloseSaveCalls(new PrefabEditor(), undefined, 1); await expectCloseSaveCalls(new PrefabEditor(), { save: false }, 0); diff --git a/src/core/scene/test/editor-save-as.test.ts b/src/core/scene/test/editor-save-as.test.ts index 54eb79231..2c4194cb1 100644 --- a/src/core/scene/test/editor-save-as.test.ts +++ b/src/core/scene/test/editor-save-as.test.ts @@ -18,6 +18,7 @@ jest.mock('../scene-process/service/core', () => ({ protected broadcast() { } }, register: () => (target: unknown) => target, + queryRegisteredService: () => undefined, Service: { Undo: { clearHistory: jest.fn(), @@ -50,6 +51,56 @@ describe('EditorService Save As', () => { globalEventEmitter.removeAllListeners(); }); + it.each([false, true])('rejects an obsolete result session inside the lifecycle queue (same UUID=%s)', async sameUuid => { + editorService.currentEditorUuid = 'source'; + editorService.isOpen = true; + const session = editorService.getEditorSession(); + const change = editorService.runLifecycle(async () => { + editorService.invalidateEditorSession(); + editorService.currentEditorUuid = sameUuid ? 'source' : 'replacement'; + }); + const apply = jest.fn(); + const result = editorService.runForSession(session, apply); + await change; + await expect(result).rejects.toThrow('source scene session changed'); + expect(apply).not.toHaveBeenCalled(); + }); + + it('holds the original session through result save and cleanup without re-entering the lifecycle queue', async () => { + editorService.currentEditorUuid = 'source'; + editorService.isOpen = true; + const session = editorService.getEditorSession(); + const events: string[] = []; + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const saveUnlocked = jest.spyOn(editorService, 'saveUnlocked').mockImplementation(async (...args: unknown[]) => { + expect(args[0]).toEqual({ urlOrUUID: 'source' }); + expect(editorService.currentEditorUuid).toBe('source'); + events.push('save'); + }); + const apply = editorService.runForSession(session, async (save: () => Promise) => { + events.push('apply'); + entered(); + await gate; + await save(); + events.push('cleanup'); + }); + await started; + const change = editorService.runLifecycle(async () => { + editorService.invalidateEditorSession(); + editorService.currentEditorUuid = 'replacement'; + events.push('switch'); + }); + await Promise.resolve(); + expect(events).toEqual(['apply']); + release(); + await Promise.all([apply, change]); + expect(events).toEqual(['apply', 'save', 'cleanup', 'switch']); + expect(saveUnlocked).toHaveBeenCalledTimes(1); + }); + it('requires Save As for a target other than the existing source asset', async () => { const sourceUuid = 'source-uuid'; const target = { uuid: 'target-uuid', url: 'db://assets/copied.scene', type: 'scene' }; diff --git a/src/core/scene/test/light-probe-convex.test.ts b/src/core/scene/test/light-probe-convex.test.ts new file mode 100644 index 000000000..59df8bc71 --- /dev/null +++ b/src/core/scene/test/light-probe-convex.test.ts @@ -0,0 +1,58 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(v: { x: number; y: number; z: number }) { return new this(v.x, v.y, v.z); } + static min(out: Vec3, a: Vec3, b: Vec3) { out.x = Math.min(a.x, b.x); out.y = Math.min(a.y, b.y); out.z = Math.min(a.z, b.z); } + static max(out: Vec3, a: Vec3, b: Vec3) { out.x = Math.max(a.x, b.x); out.y = Math.max(a.y, b.y); out.z = Math.max(a.z, b.z); } + }, +})); + +import { Vec3 } from 'cc'; +import { buildLightProbeConvex } from '../scene-process/service/gizmo/utils/light-probe-convex'; + +const vertex = (x: number, y: number, z: number) => ({ position: new Vec3(x, y, z), normal: new Vec3(1, 0, 0) }); +const tet = (a: number, b: number, c: number, d: number) => ({ vertex0: a, vertex1: b, vertex2: c, vertex3: d }); + +describe('Light probe convex display geometry', () => { + const vertices = [vertex(0, 0, 0), vertex(1, 0, 0), vertex(0, 1, 0), vertex(0, 0, 1), vertex(0.1, 0.1, 0.1)]; + + it('draws only outer faces, deduplicates edges and emits one normal per boundary vertex', () => { + const result = buildLightProbeConvex(vertices, [tet(0, 1, 2, 4), tet(0, 1, 2, -1), tet(0, 2, 3, -2), tet(2, 1, 0, -1)]); + expect(result.positions).toEqual(vertices.slice(0, 4).map(v => v.position)); + expect(result.indices).toHaveLength(10); + expect(result.normalIndices).toHaveLength(8); + for (let i = 0; i < 4; i++) { + expect(result.normalPositions[i * 2]).toEqual(vertices[i].position); + expect(result.normalPositions[i * 2 + 1].x).toBeCloseTo(vertices[i].position.x + Math.sqrt(3) * 0.08); + } + }); + + it('does not treat inner tetrahedron edges as a convex hull', () => { + expect(buildLightProbeConvex(vertices, [tet(0, 1, 2, 3)]).indices).toEqual([]); + }); + + it('skips invalid indices, repeated vertices, non-finite points and collinear faces', () => { + const points = [...vertices, vertex(NaN, 0, 0), vertex(2, 0, 0)]; + const result = buildLightProbeConvex(points, [tet(0, 0, 2, -1), tet(-1, 1, 2, -1), tet(0, 1, 99, -1), tet(0, 1, 1.5, -1), tet(0, 1, 5, -1), tet(0, 1, 6, -1)]); + expect(result).toEqual({ positions: [], indices: [], normalPositions: [], normalIndices: [] }); + }); + + it('does not draw zero or invalid normals, and does not mutate engine data', () => { + const points = vertices.slice(0, 3).map(v => ({ position: Vec3.clone(v.position), normal: new Vec3() })); + points[1].normal.x = Infinity; + points[2].normal.x = 4; + const before = points.map(v => [v.position.x, v.position.y, v.position.z, v.normal.x]); + const result = buildLightProbeConvex(points, [tet(0, 1, 2, -1)]); + expect(result.indices).toHaveLength(6); + expect(result.normalIndices).toHaveLength(2); + expect(points.map(v => [v.position.x, v.position.y, v.position.z, v.normal.x])).toEqual(before); + expect(result.positions[0]).not.toBe(points[0].position); + }); + + it('keeps world-space coordinates and is independent of input ordering', () => { + const points = vertices.slice(0, 3).map(v => ({ position: new Vec3(v.position.x + 100, v.position.y + 200, v.position.z + 300), normal: v.normal })); + const result = buildLightProbeConvex(points, [tet(2, 0, 1, -2)]); + expect(result.positions).toEqual([points[2].position, points[0].position, points[1].position]); + expect(result.indices).toEqual([0, 1, 1, 2, 2, 0]); + }); +}); diff --git a/src/core/scene/test/light-probe-data.test.ts b/src/core/scene/test/light-probe-data.test.ts new file mode 100644 index 000000000..7b859e720 --- /dev/null +++ b/src/core/scene/test/light-probe-data.test.ts @@ -0,0 +1,87 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(v: { x: number; y: number; z: number }) { return new this(v.x, v.y, v.z); } + }, +})); + +import { Vec3 } from 'cc'; +import type { Scene } from 'cc'; +import { preserveLightProbeCoefficients } from '../scene-process/service/scene/light-probe-data'; + +function probe(x: number, coefficient: number | null) { + return { position: new Vec3(x, 0, 0), coefficients: coefficient === null ? [] : Array.from({ length: 9 }, (_, i) => new Vec3(coefficient + i, coefficient, coefficient)) }; +} + +function fixture(probes: ReturnType[]) { + const info = { data: { probes }, onProbeBakeFinished: jest.fn() }; + const scene = { globals: { lightProbeInfo: info } } as unknown as Scene; + return { info, scene }; +} + +describe('Light probe data across scene activation', () => { + it('preserves both groups when activation truncates then expands the probe array', () => { + const original = Array.from({ length: 43 }, (_, i) => probe(i, i + 1)); + const { scene, info } = fixture(original.slice()); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes.length = 16; + info.data.probes.push(...Array.from({ length: 27 }, (_, i) => probe(i + 16, 0))); + restore(scene); + expect(info.data.probes).toEqual(original); + expect(info.onProbeBakeFinished).toHaveBeenCalledTimes(1); + }); + + it('matches reordered positions and independently consumes duplicates', () => { + const originals = [probe(1, 10), probe(1, 20), probe(2, 30)]; + const { scene, info } = fixture(originals.slice()); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes = [probe(2, 0), probe(1, 0), probe(1, 0)]; + restore(scene); + expect(info.data.probes).toEqual([originals[2], originals[0], originals[1]]); + }); + + it('copies values before activation mutates existing vertices in place', () => { + const { scene, info } = fixture([probe(1, 10)]); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes[0].coefficients[0].x = 999; + restore(scene); + expect(info.data.probes).toEqual([probe(1, 10)]); + }); + + it('preserves cleared arrays rather than restoring zero-filled baked results', () => { + const { scene, info } = fixture([probe(1, null), probe(2, null)]); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes = [probe(1, 0), probe(2, 0)]; + restore(scene); + expect(info.data.probes).toEqual([probe(1, null), probe(2, null)]); + }); + + it('does not apply a saved coefficient to a new position or excess duplicate', () => { + const { scene, info } = fixture([probe(1, 10)]); + const restore = preserveLightProbeCoefficients(scene); + info.data.probes = [probe(2, 0), probe(1, 0), probe(1, 0)]; + restore(scene); + expect(info.data.probes).toEqual([probe(2, 0), probe(1, 10), probe(1, 0)]); + }); + + it('does not write to a different scene or reapply a consumed snapshot', () => { + const first = fixture([probe(1, 10)]); + const second = fixture([probe(1, 0)]); + const restore = preserveLightProbeCoefficients(first.scene); + restore(second.scene); + expect(second.info.data.probes).toEqual([probe(1, 0)]); + expect(second.info.onProbeBakeFinished).not.toHaveBeenCalled(); + restore(first.scene); + first.info.data.probes = [probe(1, null)]; + restore(first.scene); + expect(first.info.data.probes).toEqual([probe(1, null)]); + expect(first.info.onProbeBakeFinished).toHaveBeenCalledTimes(1); + }); + + it('leaves empty scenes and their notifications untouched', () => { + const { scene, info } = fixture([]); + preserveLightProbeCoefficients(scene)(scene); + expect(info.data.probes).toEqual([]); + expect(info.onProbeBakeFinished).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/scene/test/light-probe-edit-gizmo.test.ts b/src/core/scene/test/light-probe-edit-gizmo.test.ts new file mode 100644 index 000000000..d7374fbb3 --- /dev/null +++ b/src/core/scene/test/light-probe-edit-gizmo.test.ts @@ -0,0 +1,192 @@ +import { TransformToolData, type TransformToolDataToolNameType, type TransformToolDataViewMode } from '../scene-process/service/gizmo/transform-tool'; + +const mockService = { + Gizmo: { + transformToolData: new TransformToolData(), + get transformToolName() { return this.transformToolData.toolName; }, + set transformToolName(value: TransformToolDataToolNameType) { this.transformToolData.toolName = value; }, + }, + Engine: { repaintInEditMode: jest.fn() }, + Undo: { beginRecording: jest.fn(() => 'record'), endRecording: jest.fn(async (_id: string) => {}) }, +}; +jest.mock('../scene-process/service/core/decorator', () => ({ Service: mockService })); +jest.mock('../scene-process/service/core/global-events', () => ({ ServiceEvents: { broadcast: jest.fn() } })); +jest.mock('cc', () => { + class Vec3 { + public x: number; + public y: number; + public z: number; + constructor(x: number | Vec3 = 0, y = 0, z = 0) { + if (typeof x === 'object') { + this.x = x.x; + this.y = x.y; + this.z = x.z; + } else { + this.x = x; + this.y = y; + this.z = z; + } + } + static clone(point: Vec3) { return new Vec3(point.x, point.y, point.z); } + } + return { Vec3, Quat: class {}, Color: class {}, LightProbeGroup: class {}, js: { getClassName: () => 'cc.LightProbeGroup' } }; +}); +jest.mock('../scene-process/service/gizmo/base/gizmo-base', () => ({ + __esModule: true, + default: class { + private value: unknown; + constructor(value: unknown) { this.value = value; } + get target() { return this.value; } + set target(value: unknown) { this.value = value; } + visible() { return (this as any)._shown === true; } + onComponentChanged() {} + }, +})); +jest.mock('../scene-process/service/gizmo/base/gizmo-icon', () => ({ __esModule: true, default: class {} })); +jest.mock('../scene-process/service/gizmo/controller/box', () => ({ __esModule: true, default: class {} })); +jest.mock('../scene-process/service/gizmo/node/position-controller', () => ({ __esModule: true, default: class {} })); +jest.mock('../scene-process/service/gizmo/utils/controller-utils', () => ({ __esModule: true, default: {} })); +jest.mock('../scene-process/service/gizmo/utils/engine-utils', () => ({})); +jest.mock('../scene-process/service/gizmo/gizmo-defines', () => ({ registerGizmo: jest.fn() })); + +import type { LightProbeGroup } from 'cc'; +import { SelectGizmo, methods } from '../scene-process/service/gizmo/components/light-probe-group'; + +function target(uuid: string, count: number): LightProbeGroup { + return { isValid: true, enabledInHierarchy: true, node: { uuid }, + probes: Array.from({ length: count }, (_, x) => ({ x, y: 0, z: 0 })) } as unknown as LightProbeGroup; +} + +const created: InstanceType[] = []; +function group(uuid: string, count: number) { + const gizmo = new SelectGizmo(target(uuid, count)); + const refreshTargetState = () => { + const state = gizmo as any; + if (state._boundTarget !== gizmo.target) { + state._selected.clear(); + state._boundTarget = gizmo.target; + } + state._probeIndexByName.clear(); + for (let index = 0; index < (gizmo.target?.probes.length ?? 0); index++) { + state._probeIndexByName.set(`LightProbeSphere_${index}`, index); + } + }; + jest.spyOn(gizmo, 'createController').mockImplementation(() => { + Object.assign(gizmo, { _controller: { hide: jest.fn(), shape: { destroy: jest.fn() } } }); + }); + jest.spyOn(gizmo, 'updateControllerData').mockImplementation(refreshTargetState); + jest.spyOn(gizmo as any, '_rebuildDots').mockImplementation(refreshTargetState); + jest.spyOn(gizmo as any, '_rebuildWireframe').mockImplementation(() => {}); + jest.spyOn(gizmo as any, '_updateProbeControllerTransform').mockImplementation(() => {}); + gizmo.init(); + gizmo.onShow(); + created.push(gizmo); + return gizmo; +} + +beforeEach(() => { + mockService.Gizmo.transformToolData = new TransformToolData(); +}); + +afterEach(() => { + for (const gizmo of created.splice(0)) { gizmo.onHide(); gizmo.onDestroy(); } + methods.changeEditMode('none'); + jest.clearAllMocks(); +}); + +describe('Probe editing pooled Gizmos', () => { + it.each<[TransformToolDataToolNameType, TransformToolDataViewMode]>([ + ['position', 'select'], + ['rotation', 'select'], + ['scale', 'select'], + ['rect', 'select'], + ['view', 'select'], + ['view', 'view'], + ])('uses probe selection and restores the original %s/%s tool state', (toolName, viewMode) => { + group('a', 4); + const tool = mockService.Gizmo.transformToolData; + tool.toolName = toolName; + tool.viewMode = viewMode; + + methods.changeEditMode('vertex'); + const during = { toolName: tool.toolName, viewMode: tool.viewMode }; + // Repeating the current mode must not replace the original tool snapshot. + methods.changeEditMode('vertex'); + methods.changeEditMode('none'); + + expect({ during, after: { toolName: tool.toolName, viewMode: tool.viewMode } }).toEqual({ + during: { toolName: 'view', viewMode: 'select' }, + after: { toolName, viewMode }, + }); + }); + + it('restores browsing when switching to box mode and when the last probe group hides', () => { + const gizmo = group('a', 4); + const tool = mockService.Gizmo.transformToolData; + tool.toolName = 'view'; + tool.viewMode = 'view'; + + methods.changeEditMode('vertex'); + methods.changeEditMode('box'); + const box = { mode: methods.getEditMode(), toolName: tool.toolName, viewMode: tool.viewMode }; + methods.changeEditMode('vertex'); + gizmo.onHide(); + + expect({ box, hidden: { mode: methods.getEditMode(), toolName: tool.toolName, viewMode: tool.viewMode } }).toEqual({ + box: { mode: 'box', toolName: 'view', viewMode: 'view' }, + hidden: { mode: 'none', toolName: 'view', viewMode: 'view' }, + }); + }); + + it('counts only visible valid groups and clears a reused target with the same probe count', () => { + const first = group('a', 32); + const second = group('b', 32); + methods.changeEditMode('vertex'); + methods.selectAllProbes(); + expect(methods.getSelectedProbeCount()).toBe(64); + first.onHide(); + methods.selectAllProbes(); + expect([methods.getSelectedProbeCount(), (first as any)._selected.size]).toEqual([32, 0]); + first.target = target('c', 32); + first.onShow(); + expect(methods.getSelectedProbeCount()).toBe(32); + second.onHide(); + methods.selectAllProbes(); + expect([methods.getSelectedProbeCount(), (second as any)._selected.size]).toEqual([32, 0]); + }); + + it('does not count disabled targets and returns to normal tools after the last group hides', () => { + const gizmo = group('a', 4); + methods.changeEditMode('vertex'); + methods.selectAllProbes(); + Object.assign(gizmo.target!, { enabledInHierarchy: false }); + expect(methods.getSelectedProbeCount()).toBe(0); + gizmo.onHide(); + expect([methods.getEditMode(), mockService.Gizmo.transformToolName]).toEqual(['none', 'position']); + }); + + it('duplicates selected probes but never a hidden group and waits for the recording', async () => { + const hidden = group('hidden', 4); + const active = group('active', 4); + methods.changeEditMode('vertex'); + methods.selectAllProbes(); + hidden.onHide(); + let settle!: () => void; + mockService.Undo.endRecording.mockImplementationOnce(() => new Promise(resolve => { settle = resolve; })); + let finished = false; + const operation = methods.duplicateSelectedProbes().then(count => { finished = true; return count; }); + await Promise.resolve(); + expect([hidden.target!.probes.length, active.target!.probes.length, finished]).toEqual([4, 8, false]); + expect(mockService.Undo.beginRecording).toHaveBeenCalledWith(['active']); + settle(); + expect(await operation).toBe(4); + expect([...(active as any)._selected]).toEqual([ + 'LightProbeSphere_4', + 'LightProbeSphere_5', + 'LightProbeSphere_6', + 'LightProbeSphere_7', + ]); + expect(await methods.deleteSelectedProbes()).toBe(4); + expect([active.target!.probes.length, methods.getSelectedProbeCount()]).toEqual([4, 0]); + }); +}); diff --git a/src/core/scene/test/light-probe-normal-reset.test.ts b/src/core/scene/test/light-probe-normal-reset.test.ts new file mode 100644 index 000000000..2bc7b0324 --- /dev/null +++ b/src/core/scene/test/light-probe-normal-reset.test.ts @@ -0,0 +1,46 @@ +import { installLightProbeNormalReset } from '../scene-process/light-probe-normal-reset'; + +function createEngine(updateTetrahedrons: (...args: unknown[]) => unknown) { + return { + internal: { + LightProbesData: { + prototype: { updateTetrahedrons }, + }, + }, + }; +} + +describe('light probe normal reset', () => { + it('clears restored normals before rebuilding tetrahedrons', () => { + const observed: number[][] = []; + const original = jest.fn(function (this: { probes: Array<{ normal: { values: number[] } }> }, token: string) { + observed.push(...this.probes.map(probe => [...probe.normal.values])); + return token; + }); + const engine = createEngine(original as unknown as (...args: unknown[]) => unknown); + const data = { + probes: [ + { normal: { values: [1, 2, 3], set(x: number, y: number, z: number) { this.values = [x, y, z]; } } }, + { normal: { values: [-1, 4, 8], set(x: number, y: number, z: number) { this.values = [x, y, z]; } } }, + ], + }; + + expect(installLightProbeNormalReset(engine)).toBe(true); + expect(engine.internal.LightProbesData.prototype.updateTetrahedrons.call(data, 'result')).toBe('result'); + expect(observed).toEqual([[0, 0, 0], [0, 0, 0]]); + expect(original).toHaveBeenCalledTimes(1); + }); + + it('installs only once and tolerates missing probe data', () => { + const original = jest.fn(); + const engine = createEngine(original); + + expect(installLightProbeNormalReset(engine)).toBe(true); + const patched = engine.internal.LightProbesData.prototype.updateTetrahedrons; + expect(installLightProbeNormalReset(engine)).toBe(false); + expect(engine.internal.LightProbesData.prototype.updateTetrahedrons).toBe(patched); + expect(() => patched.call({})).not.toThrow(); + expect(original).toHaveBeenCalledTimes(1); + expect(installLightProbeNormalReset({})).toBe(false); + }); +}); diff --git a/src/core/scene/test/light-probe-reparent.test.ts b/src/core/scene/test/light-probe-reparent.test.ts new file mode 100644 index 000000000..0ee2543a9 --- /dev/null +++ b/src/core/scene/test/light-probe-reparent.test.ts @@ -0,0 +1,81 @@ +export {}; + +const mockNodes = new Map(); +const mockEvents: string[] = []; +let mockCommand: any; +jest.mock('cc', () => ({ Node: class {}, Component: class {} })); +jest.mock('../scene-process/service/core', () => ({ Service: { Undo: { push: (command: any) => { mockCommand = command; } } } })); +jest.mock('../scene-process/service/node/index', () => ({ __esModule: true, default: {} })); +jest.mock('../scene-process/service/dump', () => ({ + __esModule: true, + default: { dumpNode: (node: any) => ({ marker: node.marker }) }, +})); +jest.mock('../scene-process/service/undo/commands/create-node-command', () => ({ CreateNodeCommand: {} })); +jest.mock('../scene-process/service/undo/commands/snapshot-command', () => ({ + SnapshotCommand: class { + constructor(public options: any, public before: any, public after: any, public adapter: any) {} + }, +})); +jest.mock('../scene-process/service/undo/commands/command-utils-shared', () => ({ + createUndoId: () => 'reparent', + snapshotMapsEqual: (before: Map, after: Map) => JSON.stringify([...before]) === JSON.stringify([...after]), + restoreNodeSnapshotDump: async (node: any, dump: any) => { mockEvents.push(`restore:${node.uuid}`); node.marker = dump.marker; }, +})); + +const previousExtends: unknown = Reflect.get(globalThis, 'EditorExtends'); +Object.assign(globalThis, { EditorExtends: { Node: { + getNode: (uuid: string) => mockNodes.get(uuid), + getNodePath: (node: { uuid: string }) => `/${node.uuid}`, + getNodeByPath: (path: string) => mockNodes.get(path.slice(1)), +} } }); +const { NodeUndoHelper } = require('../scene-process/service/node/node-undo'); + +function node(uuid: string, scene?: any): any { + const result: any = { uuid, isValid: true, scene, marker: `${uuid}:before`, parent: null, + getSiblingIndex: () => 0, setSiblingIndex: jest.fn(), + getComponentsInChildren: () => uuid === 'unrelated' ? [] : [{ isValid: true, enabledInHierarchy: true }], + setParent: jest.fn((parent: any) => { mockEvents.push(`parent:${uuid}`); result.parent = parent; }), + }; + mockNodes.set(uuid, result); + return result; +} + +afterAll(() => { Object.assign(globalThis, { EditorExtends: previousExtends }); }); +beforeEach(() => { mockNodes.clear(); mockEvents.length = 0; mockCommand = undefined; }); + +describe('Probe globals in reparent history', () => { + it('captures one affected scene and restores it after parent and node data without reparenting the root', async () => { + const scene = node('scene'); + scene.scene = scene; + scene.globals = { lightProbeInfo: { data: { probes: [{}] } } }; + const oldParent = node('old', scene); + const newParent = node('new', scene); + const group = node('group', scene); + group.parent = oldParent; + const helper = new NodeUndoHelper(() => {}); + const before = helper.captureReparentSnapshots([group]); + expect([...before.keys()]).toEqual(['group', 'scene']); + group.parent = newParent; + group.marker = 'group:after'; + scene.marker = 'scene:after'; + helper.recordReparentSnapshots('reparent', 'Set Parent', before, ['group']); + expect([...mockCommand.after.keys()]).toEqual(['group', 'scene']); + expect(await mockCommand.adapter.apply(before)).toEqual({ success: true }); + expect(mockEvents).toEqual(['parent:group', 'restore:group', 'restore:scene']); + expect([group.parent.uuid, group.marker, scene.marker, scene.setParent.mock.calls.length]) + .toEqual(['old', 'group:before', 'scene:before', 0]); + mockEvents.length = 0; + expect(await mockCommand.adapter.apply(mockCommand.after)).toEqual({ success: true }); + expect(mockEvents).toEqual(['parent:group', 'restore:group', 'restore:scene']); + expect([group.parent.uuid, scene.marker]).toEqual(['new', 'scene:after']); + }); + + it('does not capture scene globals for a subtree with no enabled probes', () => { + const scene = node('scene'); + scene.scene = scene; + scene.globals = { lightProbeInfo: {} }; + const unrelated = node('unrelated', scene); + const helper = new NodeUndoHelper(() => {}); + expect([...helper.captureReparentSnapshots([unrelated]).keys()]).toEqual(['unrelated']); + }); +}); diff --git a/src/core/scene/test/light-probe-selection.test.ts b/src/core/scene/test/light-probe-selection.test.ts new file mode 100644 index 000000000..910793e7a --- /dev/null +++ b/src/core/scene/test/light-probe-selection.test.ts @@ -0,0 +1,182 @@ +import { ProbeSelection } from '../scene-process/service/gizmo/components/light-probe-group/selection'; +import type { ISceneMouseEvent } from '../scene-process/service/operation/types'; + +const mockRaycastGizmos = jest.fn(); +const mockService = { + Gizmo: { + gizmoRootNode: {}, + queryLightProbeEditMode: jest.fn(() => true), + execGizmoMethods: jest.fn(), + regionSelectLightProbes: jest.fn(), + unselectAllLightProbes: jest.fn(), + }, + Camera: { controller: { isMoving: () => false } }, + Engine: { repaintInEditMode: jest.fn() }, +}; + +jest.mock('../scene-process/service/core/decorator', () => ({ Service: mockService })); +jest.mock('cc', () => ({ + Vec3: class Vec3 { clone() { return new Vec3(); } }, + Event: class { constructor(public type: string) {} }, + Layers: { Enum: { IGNORE_RAYCAST: 0 } }, +})); +jest.mock('../scene-process/service/gizmo/utils/engine-utils', () => ({ + getRaycastResults: (...args: unknown[]) => mockRaycastGizmos(...args), +})); +jest.mock('../scene-process/service/gizmo/utils/node-utils', () => ({})); +jest.mock('../scene-process/service/gizmo/utils/selection-utils', () => ({})); +jest.mock('../scene-process/service/gizmo/utils/editor-node', () => ({})); + +import GizmoOperation from '../scene-process/service/gizmo/gizmo-operation'; + +describe('ProbeSelection', () => { + it('does not carry selection across pooled targets, including equally sized groups', () => { + const selection = new ProbeSelection(); + const first = {}; + selection.bind(first, 32); + selection.all(); + expect(selection.indices.size).toBe(32); + selection.bind({}, 32); + expect([...selection.indices]).toEqual([]); + selection.all(); + selection.bind(null, 0); + expect([...selection.indices]).toEqual([]); + selection.bind(first, 32); + expect([...selection.indices]).toEqual([]); + }); + + it('retains selection during a same-target move but drops invalid indices after regeneration', () => { + const selection = new ProbeSelection(); + const owner = {}; + selection.bind(owner, 4); + selection.all(); + selection.bind(owner, 4); + expect([...selection.indices]).toEqual([0, 1, 2, 3]); + selection.bind(owner, 2); + expect([...selection.indices]).toEqual([]); + }); + + it('uses the original additive selection on every frame and drops transient rectangle hits', () => { + const selection = new ProbeSelection(); + selection.bind({}, 5); + selection.indices.add(0); + selection.beginRegion(); + selection.region([1, 2, 3], true); + expect([...selection.indices]).toEqual([0, 1, 2, 3]); + selection.region([2], true); + expect([...selection.indices]).toEqual([0, 2]); + selection.region([], true); + expect([...selection.indices]).toEqual([0]); + selection.endRegion(); + selection.region([4], true); + expect([...selection.indices]).toEqual([0, 4]); + }); + + it('replacement selection ignores both the baseline and invalid indices', () => { + const selection = new ProbeSelection(); + selection.bind({}, 4); + selection.all(); + selection.beginRegion(); + selection.region([-1, 2, 9], false); + expect([...selection.indices]).toEqual([2]); + selection.region([], false); + expect([...selection.indices]).toEqual([]); + }); +}); + +describe('probe region gesture lifecycle', () => { + let previousCC: unknown; + + beforeEach(() => { + jest.clearAllMocks(); + mockService.Gizmo.queryLightProbeEditMode.mockReturnValue(true); + mockRaycastGizmos.mockReturnValue([]); + previousCC = (globalThis as any).cc; + (globalThis as any).cc = { game: { canvas: { height: 720 } } }; + }); + + afterEach(() => { + (globalThis as any).cc = previousCC; + }); + + function mouse(x: number, y: number): ISceneMouseEvent { + return { x, y, leftButton: true, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false } as ISceneMouseEvent; + } + + function beginRegion(operation: GizmoOperation, hit: 'wireframe' | 'blank'): void { + mockRaycastGizmos.mockReturnValue(hit === 'wireframe' ? [{ node: { emit: jest.fn() } }] : []); + operation.onMouseDown(mouse(10, 600)); + operation.onMouseMove(mouse(30, 580)); + expect(mockService.Gizmo.regionSelectLightProbes).toHaveBeenCalledTimes(1); + } + + function expectNextHandleDrag(operation: GizmoOperation): void { + const emitted: string[] = []; + const handle = { + emit(type: string, event: { propagationStopped: boolean }) { + emitted.push(type); + event.propagationStopped = true; + }, + }; + mockRaycastGizmos.mockReturnValue([{ node: handle }]); + mockService.Gizmo.regionSelectLightProbes.mockClear(); + operation.onMouseDown(mouse(80, 500)); + operation.onMouseMove(mouse(100, 480)); + operation.onMouseUp(mouse(100, 480)); + + expect(emitted).toEqual(['mouseDown', 'mouseMove', 'mouseUp']); + expect(mockService.Gizmo.regionSelectLightProbes).not.toHaveBeenCalled(); + } + + it.each(['wireframe', 'blank'] as const)('releases a completed %s box before the next consumed handle drag', hit => { + const operation = new GizmoOperation(); + beginRegion(operation, hit); + + operation.onMouseUp(mouse(30, 580)); + + expectNextHandleDrag(operation); + }); + + it.each(['wireframe', 'blank'] as const)('clear cancels an in-progress %s box before another handle drag', hit => { + const operation = new GizmoOperation(); + beginRegion(operation, hit); + + operation.clear(); + + expectNextHandleDrag(operation); + }); + + it('leaves ordinary Gizmo dispatch intact after exiting probe mode during a box gesture', () => { + const operation = new GizmoOperation(); + beginRegion(operation, 'wireframe'); + + mockService.Gizmo.queryLightProbeEditMode.mockReturnValue(false); + operation.onMouseMove(mouse(40, 570)); + operation.onMouseUp(mouse(40, 570)); + + expectNextHandleDrag(operation); + }); + + it('starts a fresh handle gesture if the previous probe mouse-up was lost', () => { + const operation = new GizmoOperation(); + beginRegion(operation, 'wireframe'); + + expectNextHandleDrag(operation); + }); + + it('stops an interrupted box when movement reports that the left button was released', () => { + const operation = new GizmoOperation(); + beginRegion(operation, 'wireframe'); + + operation.onMouseMove({ ...mouse(40, 570), leftButton: false }); + + expect(mockService.Gizmo.regionSelectLightProbes).toHaveBeenCalledTimes(1); + expectNextHandleDrag(operation); + }); + + it('forwards ordinary consumed handle events when probe editing is disabled', () => { + mockService.Gizmo.queryLightProbeEditMode.mockReturnValue(false); + + expectNextHandleDrag(new GizmoOperation()); + }); +}); diff --git a/src/core/scene/test/light-probe-transform.test.ts b/src/core/scene/test/light-probe-transform.test.ts new file mode 100644 index 000000000..25fdff870 --- /dev/null +++ b/src/core/scene/test/light-probe-transform.test.ts @@ -0,0 +1,107 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + static clone(v: { x: number; y: number; z: number }) { return new this(v.x, v.y, v.z); } + static strictEquals(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }) { + return a.x === b.x && a.y === b.y && a.z === b.z; + } + }, +})); + +import { Vec3, type Node, type Scene } from 'cc'; +import { getLightProbeTransformScene, synchronizeLightProbeTransform, withLightProbeTransformScenes } from '../scene-process/service/scene/light-probe-transform'; + +function fixture() { + const group = { isValid: true, enabledInHierarchy: true }; + const nextPositions = Array.from({ length: 4 }, (_, index) => new Vec3(index, 0, 0)); + const probes = nextPositions.map(position => ({ position: Vec3.clone(position), coefficients: [new Vec3(1, 2, 3)] })); + const events: string[] = []; + const info = { + data: { probes }, + update: jest.fn((tet: boolean) => { + events.push(tet ? 'tetrahedrons' : 'positions'); + // Model the engine's in-place mutation and retention of stale SH. + probes.forEach((probe, i) => Object.assign(probe.position, nextPositions[i])); + }), + onProbeBakeCleared: jest.fn(() => { events.push('clear'); probes.forEach(probe => { probe.coefficients = []; }); }), + onProbeBakeFinished: jest.fn(() => { events.push('refresh'); }), + }; + const scene = { isValid: true, globals: { lightProbeInfo: info }, getComponentsInChildren: () => [group] } as unknown as Scene; + Object.defineProperty(scene, 'scene', { value: scene }); + const node = { isValid: true, scene, getComponentsInChildren: () => [group] } as unknown as Node; + return { scene, node, group, nextPositions, info, events }; +} + +describe('Light probe position synchronization', () => { + it.each([null, { probes: [] }])('does not scan ordinary scene subtrees without generated probes (%s)', data => { + const { node, scene, info } = fixture(); + (scene.globals.lightProbeInfo as any).data = data; + const scan = jest.spyOn(node, 'getComponentsInChildren'); + synchronizeLightProbeTransform(node); + expect(withLightProbeTransformScenes([node])).toEqual([node]); + expect(scan).not.toHaveBeenCalled(); + expect(info.update).not.toHaveBeenCalled(); + }); + it('retains moved and stationary groups coefficients when translating a group', () => { + const { node, nextPositions, info, events } = fixture(); + // First two samples belong to A, last two to the stationary group B. + info.data.probes.forEach((probe, index) => { probe.coefficients = [new Vec3(index + 1, 2, 3)]; }); + const coefficients = info.data.probes.map(probe => probe.coefficients.map(value => Vec3.clone(value))); + nextPositions.slice(0, 2).forEach(point => { point.x += 7; }); + synchronizeLightProbeTransform(node, true); + expect({ events, positions: info.data.probes.map(probe => probe.position), coefficients: info.data.probes.map(probe => probe.coefficients) }) + .toEqual({ events: ['positions', 'tetrahedrons', 'refresh'], positions: nextPositions, coefficients }); + synchronizeLightProbeTransform(node, true); + expect(info.onProbeBakeCleared).not.toHaveBeenCalled(); + expect(info.onProbeBakeFinished).toHaveBeenCalledTimes(1); + expect(info.update.mock.calls).toEqual([[false], [true], [false]]); + }); + + it('updates positions, rebuilds once and invalidates SH only when samples actually moved', () => { + const { node, nextPositions, info, events } = fixture(); + nextPositions.forEach(point => { point.x += 7; }); + synchronizeLightProbeTransform(node); + expect({ events, positions: info.data.probes.map(probe => probe.position), coefficients: info.data.probes.map(probe => probe.coefficients) }) + .toEqual({ events: ['positions', 'tetrahedrons', 'clear'], positions: nextPositions, coefficients: [[], [], [], []] }); + synchronizeLightProbeTransform(node); + expect(info.onProbeBakeCleared).toHaveBeenCalledTimes(1); + expect(info.update.mock.calls).toEqual([[false], [true], [false]]); + }); + + it('preserves baked coefficients and tetrahedrons for unchanged sample positions', () => { + const { node, info } = fixture(); + const before = JSON.stringify(info.data); + synchronizeLightProbeTransform(node); + expect(JSON.stringify(info.data)).toBe(before); + expect(info.onProbeBakeCleared).not.toHaveBeenCalled(); + expect(info.update.mock.calls).toEqual([[false]]); + }); + + it.each(['disabled', 'destroyed', 'unrelated'] as const)('does not rebuild a %s subtree', kind => { + const { node, group, info } = fixture(); + if (kind === 'disabled') group.enabledInHierarchy = false; + if (kind === 'destroyed') group.isValid = false; + if (kind === 'unrelated') node.getComponentsInChildren = (() => []) as Node['getComponentsInChildren']; + synchronizeLightProbeTransform(node); + expect(info.update).not.toHaveBeenCalled(); + }); + + it('handles ancestor transforms through descendants without relying on selection', () => { + const { scene, group } = fixture(); + const parent = { isValid: true, scene, getComponentsInChildren: () => [group] } as unknown as Node; + expect(getLightProbeTransformScene(parent)).toBe(scene); + }); + + it('captures one scene after all affected nodes, even when explicitly selected first', () => { + const { scene, node } = fixture(); + const other = { isValid: true, scene, getComponentsInChildren: () => [] } as unknown as Node; + expect(withLightProbeTransformScenes([scene, node, other, node])).toEqual([node, other, scene]); + expect(withLightProbeTransformScenes([other])).toEqual([other]); + }); + + it('ignores detached or invalid nodes without a scene', () => { + const nodes = [{ isValid: true }, { isValid: false }] as Node[]; + expect(withLightProbeTransformScenes(nodes)).toEqual(nodes); + nodes.forEach(node => synchronizeLightProbeTransform(node)); + }); +}); diff --git a/src/core/scene/test/lightfx-asset-management.test.ts b/src/core/scene/test/lightfx-asset-management.test.ts new file mode 100644 index 000000000..f27cc069c --- /dev/null +++ b/src/core/scene/test/lightfx-asset-management.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, outputFile, pathExists, readFile, readdir, remove } from 'fs-extra'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { LightmapAssetRecord } from '../main-process/lightfx/asset-record'; +import { LightmapAssetTransaction } from '../main-process/lightfx/asset-transaction'; + +describe('Exact scene Lightmap asset membership', () => { + let root: string; + const scene = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const other = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const a = '11111111-1111-4111-8111-111111111111'; + const b = '22222222-2222-4222-8222-222222222222'; + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'lightfx-record-')); }); + afterEach(async () => { await remove(root); }); + + it('persists only deduplicated root UUIDs and keeps different scenes separate across reopen', async () => { + const record = new LightmapAssetRecord(root, scene); + await record.add([a, `${a}@6c48a`]); + await new LightmapAssetRecord(root, scene).add([b]); + await new LightmapAssetRecord(root, other).add([a]); + await record.forget([a]); + expect(await record.read()).toEqual([b]); + expect(await new LightmapAssetRecord(root, other).read()).toEqual([a]); + expect(await readdir(join(root, 'settings', 'lightfx-assets'))).toEqual(expect.arrayContaining([`${scene}.json`, `${other}.json`])); + expect(await readFile(join(root, 'settings', 'lightfx-assets', `${scene}.json`), 'utf8')) + .toBe(JSON.stringify({ version: 1, textures: [b] })); + }); + + it('keeps auxiliary membership separate while preserving it through texture changes and restart', async () => { + const record = new LightmapAssetRecord(root, scene); + await record.add([a], [`${b}@sub`, b]); + await record.forget([a]); + await new LightmapAssetRecord(root, scene).add([a]); + expect([await record.read(), await record.readAuxiliary()]).toEqual([[a], [b]]); + await record.forget([b]); + expect([await record.read(), await record.readAuxiliary()]).toEqual([[a], []]); + }); + + it.each(['../outside', '', 'scene/name'])('rejects unsafe scene identity: %s', invalid => { + expect(() => new LightmapAssetRecord(root, invalid)).toThrow('scene UUID'); + }); + + it.each(['{broken', 'null', '{"version":2,"textures":[]}', '{"version":1,"textures":["../outside"]}', + '{"version":1,"textures":[],"auxiliary":["../outside"]}', '{"version":1,"textures":[],"auxiliary":null}'])('does not overwrite a damaged record: %s', async content => { + const file = join(root, 'settings', 'lightfx-assets', `${scene}.json`); + await outputFile(file, content); + const record = new LightmapAssetRecord(root, scene); + await expect(record.add([a])).rejects.toThrow(); + await expect(record.forget([a])).rejects.toThrow(); + expect(await readFile(file, 'utf8')).toBe(content); + }); +}); + +describe('LightmapAssetTransaction', () => { + let root: string; + + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'lightfx-assets-')); }); + afterEach(async () => { await remove(root); }); + + it('restores an existing lightmap directory after a failed import', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + await outputFile(join(target, 'old.png'), 'old'); + await outputFile(join(target, 'old.png.meta'), 'meta'); + const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); + await transaction.prepare(); + expect(await pathExists(join(target, 'old.png'))).toBe(false); + await transaction.preserveMeta('old.png'); + expect((await readFile(join(target, 'old.png.meta'))).toString()).toBe('meta'); + await outputFile(join(target, 'new.png'), 'new'); + await transaction.rollback(); + expect((await readFile(join(target, 'old.png'))).toString()).toBe('old'); + expect(await pathExists(join(target, 'new.png'))).toBe(false); + }); + + it('removes a newly created lightmap directory after rollback', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + const transaction = new LightmapAssetTransaction(target, join(root, 'workspace')); + await transaction.prepare(); + await outputFile(join(target, 'new.png'), 'new'); + await transaction.rollback(); + expect(await pathExists(target)).toBe(false); + }); + + it('keeps rollback retryable when restoring the backup fails', async () => { + const target = join(root, 'assets', 'Scene', 'lightmap'); + await outputFile(join(target, 'old.png'), 'old'); + const workspace = join(root, 'workspace'); + const backup = join(workspace, 'lightmap-asset-backup'); + const transaction = new LightmapAssetTransaction(target, workspace); + await transaction.prepare(); + await remove(backup); + + await expect(transaction.rollback()).rejects.toThrow(); + await outputFile(join(backup, 'old.png'), 'old'); + await expect(transaction.rollback()).resolves.toBeUndefined(); + await expect(readFile(join(target, 'old.png'), 'utf8')).resolves.toBe('old'); + }); +}); diff --git a/src/core/scene/test/lightfx-asset-publication.test.ts b/src/core/scene/test/lightfx-asset-publication.test.ts new file mode 100644 index 000000000..969b47d51 --- /dev/null +++ b/src/core/scene/test/lightfx-asset-publication.test.ts @@ -0,0 +1,120 @@ +import { randomUUID } from 'crypto'; +import { mkdtemp, ensureDir, outputFile, readFile, pathExists, move, remove, symlink } from 'fs-extra'; +import { dirname, join } from 'path'; +import { tmpdir } from 'os'; + +const mockAssets = { queryAssetInfo: jest.fn(), queryUUID: jest.fn(), moveAsset: jest.fn(), refreshAsset: jest.fn() }; +jest.mock('../../assets', () => ({ assetManager: mockAssets })); +import { publishLightmapTextures, removeEmptyLightmapVersion } from '../main-process/lightfx/asset-publication'; + +describe('Fixed Lightmap publication after saving', () => { + let project: string, root: string, staging: string; + let infos: Map; + const path = (url: string) => join(root, url.slice('db://assets/'.length)); + beforeEach(async () => { + project = await mkdtemp(join(tmpdir(), 'lightfx-publish-')); + root = join(project, 'assets'); + await ensureDir(root); + staging = `db://assets/staging/bake-${randomUUID()}`; + infos = new Map(); + mockAssets.queryAssetInfo.mockReset().mockImplementation(uuid => infos.get(uuid)); + mockAssets.queryUUID.mockReset().mockImplementation(url => [...infos.values()].find(info => info.url === url)?.uuid); + mockAssets.refreshAsset.mockReset().mockResolvedValue(undefined); + mockAssets.moveAsset.mockReset().mockImplementation(async (source: string, target: string) => { + const info = [...infos.values()].find(item => item.url === source)!; + await move(info.file, path(target), { overwrite: false }); + await move(`${info.file}.meta`, `${path(target)}.meta`, { overwrite: false }); + Object.assign(info, { url: target, file: path(target) }); + }); + }); + afterEach(async () => { await remove(project); }); + async function add(name: string) { + const uuid = randomUUID(), url = `${staging}/${name}`; + await outputFile(path(url), `pixels:${name}`); + await outputFile(`${path(url)}.meta`, JSON.stringify({ uuid })); + infos.set(uuid, { uuid, url, file: path(url) }); + return uuid; + } + + it('moves Mesh and Terrain files without copying old pixels or changing UUIDs, and removes only the empty staging directory', async () => { + const ids = await Promise.all([add('LFX_Mesh_0000.png'), add('LFX_Terrain_0000.png')]); + const result = await publishLightmapTextures(root, ids, staging, 'db://assets/LightFX'); + expect(result).toEqual(['db://assets/LightFX/output/LFX_Mesh_0000.png', 'db://assets/LightFX/output/LFX_Terrain_0000.png']); + for (let i = 0; i < ids.length; i++) { + expect(JSON.parse(await readFile(`${path(result[i])}.meta`, 'utf8')).uuid).toBe(ids[i]); + } + expect(await pathExists(path(staging))).toBe(false); + }); + it.each(['file', 'meta', 'database'])('preflights all targets and refuses a %s collision without moving the first file', async kind => { + const ids = await Promise.all([add('LFX_Mesh_0000.png'), add('LFX_Terrain_0000.png')]); + const target = 'db://assets/LightFX/output/LFX_Terrain_0000.png'; + if (kind === 'database') infos.set('other', { uuid: 'other', url: target, file: path(target) }); + else await outputFile(`${path(target)}${kind === 'meta' ? '.meta' : ''}`, 'unrelated'); + await expect(publishLightmapTextures(root, ids, staging, 'db://assets/LightFX')).rejects.toThrow('occupied'); + expect(mockAssets.moveAsset).not.toHaveBeenCalled(); + expect(await readFile(infos.get(ids[0])!.file, 'utf8')).toBe('pixels:LFX_Mesh_0000.png'); + }); + it('retains resolvable new UUIDs after partial move failure, then can retry without overwriting', async () => { + const ids = await Promise.all([add('LFX_Mesh_0000.png'), add('LFX_Terrain_0000.png')]); + const original = mockAssets.moveAsset.getMockImplementation()!; + mockAssets.moveAsset.mockImplementationOnce(original).mockRejectedValueOnce(new Error('move denied')); + await expect(publishLightmapTextures(root, ids, staging, 'db://assets/LightFX')).rejects.toThrow('move denied'); + expect(ids.map(id => infos.get(id)!.url)).toEqual(['db://assets/LightFX/output/LFX_Mesh_0000.png', `${staging}/LFX_Terrain_0000.png`]); + for (const info of infos.values()) expect(await pathExists(info.file)).toBe(true); + await publishLightmapTextures(root, ids, staging, 'db://assets/LightFX'); + expect(await pathExists(path(staging))).toBe(false); + }); + it('does not trust a successful move response without actual files and UUID mapping', async () => { + const uuid = await add('LFX_Mesh_0000.png'); + mockAssets.moveAsset.mockResolvedValueOnce(undefined); + await expect(publishLightmapTextures(root, [uuid], staging, 'db://assets/LightFX')).rejects.toThrow('not confirmed'); + expect(await pathExists(infos.get(uuid)!.file)).toBe(true); + }); + it.each(['directory', 'dangling-meta'])('rejects %s symlinks without modifying outside files', async kind => { + const uuid = await add('LFX_Mesh_0000.png'); + const outside = join(project, 'outside'); + await ensureDir(outside); + if (kind === 'directory') await symlink(outside, path('db://assets/LightFX')); + else { + const meta = `${path('db://assets/LightFX/output/LFX_Mesh_0000.png')}.meta`; + await ensureDir(dirname(meta)); + await symlink(join(outside, 'absent'), meta); + } + await expect(publishLightmapTextures(root, [uuid], staging, 'db://assets/LightFX')).rejects.toThrow('symbolic'); + expect(mockAssets.moveAsset).not.toHaveBeenCalled(); + }); + it('publishes native file bytes with their UUIDs but returns only preview texture URLs', async () => { + const texture = await add('LFX_Mesh_0000.png'); + const native = await Promise.all(['lfx.in', 'lfx.out', 'lfx.log'].map(add)); + const result = await publishLightmapTextures(root, [texture], staging, 'db://assets/Chosen', native); + expect(result).toEqual(['db://assets/Chosen/output/LFX_Mesh_0000.png']); + const urls = ['tmp/lfx.in', 'output/lfx.out', 'lfx.log'].map(file => `db://assets/Chosen/${file}`); + expect(native.map(uuid => infos.get(uuid)?.url)).toEqual(urls); + expect(await Promise.all(urls.map(url => readFile(path(url), 'utf8')))).toEqual(['pixels:lfx.in', 'pixels:lfx.out', 'pixels:lfx.log']); + expect(await pathExists(path(staging))).toBe(false); + }); + it('preflights native collisions before moving any PNG and never replaces unrelated files', async () => { + const texture = await add('LFX_Mesh_0000.png'), log = await add('lfx.log'); + const destination = path('db://assets/LightFX/lfx.log'); + await outputFile(destination, 'user log'); + await expect(publishLightmapTextures(root, [texture], staging, 'db://assets/LightFX', [log])).rejects.toThrow('occupied'); + expect(mockAssets.moveAsset).not.toHaveBeenCalled(); + expect(await readFile(destination, 'utf8')).toBe('user log'); + }); + it('retains new assets on a partial native move failure and can retry without duplicate PNG moves', async () => { + const texture = await add('LFX_Mesh_0000.png'), input = await add('lfx.in'); + const original = mockAssets.moveAsset.getMockImplementation()!; + mockAssets.moveAsset.mockImplementationOnce(original).mockRejectedValueOnce(new Error('native move denied')); + await expect(publishLightmapTextures(root, [texture], staging, 'db://assets/LightFX', [input])).rejects.toThrow('native move denied'); + expect([await pathExists(infos.get(texture)!.file), await pathExists(infos.get(input)!.file)]).toEqual([true, true]); + await publishLightmapTextures(root, [texture], staging, 'db://assets/LightFX', [input]); + expect(await pathExists(path(staging))).toBe(false); + }); + it('never deletes a nonempty version folder or a regular output directory', async () => { + await outputFile(path(`${staging}/unrelated.txt`), 'keep'); + await removeEmptyLightmapVersion(root, staging); + await ensureDir(path('db://assets/LightFX/output')); + await removeEmptyLightmapVersion(root, 'db://assets/LightFX/output'); + expect([await readFile(path(`${staging}/unrelated.txt`), 'utf8'), await pathExists(path('db://assets/LightFX/output'))]).toEqual(['keep', true]); + }); +}); diff --git a/src/core/scene/test/lightfx-asset-versions.test.ts b/src/core/scene/test/lightfx-asset-versions.test.ts new file mode 100644 index 000000000..3e816093b --- /dev/null +++ b/src/core/scene/test/lightfx-asset-versions.test.ts @@ -0,0 +1,434 @@ +import { ensureDir, existsSync, mkdtemp, outputFile, pathExists, readFile, remove, symlink, move } from 'fs-extra'; +import { randomUUID } from 'crypto'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const mockAssets = { + queryPath: jest.fn(), refreshAsset: jest.fn(), queryUUID: jest.fn(), + queryAssetMeta: jest.fn(() => ({ userData: { fixAlphaTransparencyArtifacts: false } })), + queryAssetInfo: jest.fn(), queryAssetUsers: jest.fn(), removeAsset: jest.fn(), moveAsset: jest.fn(), +}; +const mockRun = jest.fn(); +const mockAssetDB = { ready: true, isBusy: jest.fn(() => false) }; +jest.mock('../../assets', () => ({ assetManager: mockAssets, assetDBManager: mockAssetDB })); +jest.mock('../main-process/lightfx/process', () => ({ LightFXProcess: jest.fn(() => ({ run: mockRun, cancel: async () => undefined })) })); +jest.mock('../main-process/lightfx/output', () => ({ decodeLightFXOutput: () => ({ version: 1, meshes: [], terrains: [], probes: [] }) })); +import { LightFXBakeHost } from '../main-process/lightfx-bake-host'; +import { LightmapAssetRecord } from '../main-process/lightfx/asset-record'; + +describe('Immutable Lightmap asset versions', () => { + let root: string; + let assetRoot: string; + let host: LightFXBakeHost; + const assetPath = (url: string) => join(assetRoot, url.slice('db://assets/'.length)); + const opts = { target: 'lightmap' as const, sceneName: 'SharedName', textureSources: [], timeoutMs: 120_000 }; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'lightfx-versions-')); + assetRoot = join(root, 'assets'); + mockAssets.queryPath.mockReset().mockReturnValue(assetRoot); + mockAssets.refreshAsset.mockReset().mockResolvedValue(undefined); + // Asset identity stands for its unique import URL; the filesystem transaction is real. + mockAssets.queryUUID.mockReset().mockImplementation((url: string) => url); + mockRun.mockReset(); + host = new LightFXBakeHost(); + mockAssetDB.ready = true; + mockAssetDB.isBusy.mockReturnValue(false); + }); + afterEach(async () => { await host.dispose(); await remove(root); }); + + async function bake(bytes: string, outputUrl?: string, sceneUuid?: string, transactionId?: string) { + mockRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + await outputFile(join(cwd, 'output', 'LFX_Mesh_0000.png'), bytes); + await outputFile(join(cwd, 'lfx.log'), `native ${bytes}`); + }); + const token = await host.begin({ ...opts, outputUrl, sceneUuid, transactionId }); + await host.appendInput({ ...token, chunkBase64: Buffer.from('input').toString('base64') }); + const output = await host.run(token); + return { token, url: output.textureUrls[0], path: assetPath(output.textureUrls[0]) }; + } + + function realAssetFiles() { + const identities = new Map(); + mockAssets.queryUUID.mockImplementation((url: string) => { + const existing = [...identities.values()].find(info => info.url === url); + if (existing) return existing.uuid; + if (!existsSync(assetPath(url))) return null; + const uuid = randomUUID(); + identities.set(uuid, { uuid, url, file: assetPath(url) }); + return uuid; + }); + mockAssets.queryAssetInfo.mockImplementation((uuid: string) => { + const info = identities.get(uuid); + return info && existsSync(info.file) ? info : null; + }); + mockAssets.queryAssetUsers.mockReset().mockResolvedValue([]); + mockAssets.removeAsset.mockReset().mockImplementation(async (uuid: string) => { + const info = identities.get(uuid)!; + await remove(info.file); + await remove(`${info.file}.meta`); + identities.delete(uuid); + }); + mockAssets.moveAsset.mockReset().mockImplementation(async (source: string, target: string) => { + const info = [...identities.values()].find(info => info.url === source)!; + await move(info.file, assetPath(target), { overwrite: false }); + info.url = target; + info.file = assetPath(target); + }); + return identities; + } + + it.each([undefined, 'db://assets', 'db://assets/Chosen'])('publishes fixed current textures with the same UUID and supports Clear after reopening (%s)', async outputUrl => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + if (outputUrl) await ensureDir(assetPath(outputUrl)); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('pixels A', outputUrl, sceneUuid, owner.transactionId); + const uuid = [...identities.keys()][0]; + const request = { ...a.token, ...owner }; + await expect(host.publishLightmapAssets(request)).rejects.toThrow('ownership'); + await host.commit(a.token); + await expect(host.publishLightmapAssets({ ...request, operationId: randomUUID() })).rejects.toThrow('ownership'); + const output = await host.publishLightmapAssets(request); + const target = `${outputUrl && outputUrl !== 'db://assets' ? outputUrl : 'db://assets/LightFX'}/scene-${sceneUuid}/output/LFX_Mesh_0000.png`; + expect(output.textureUrls).toEqual([target]); + expect([identities.get(uuid)?.url, await readFile(assetPath(target), 'utf8'), await pathExists(a.path)]).toEqual([target, 'pixels A', false]); + const auxRoot = target.slice(0, -'/output/LFX_Mesh_0000.png'.length); + expect(await Promise.all(['tmp/lfx.in', 'output/lfx.out', 'lfx.log'].map(path => readFile(assetPath(`${auxRoot}/${path}`), 'utf8')))) + .toEqual(['input', '', 'native pixels A']); + const auxiliary = await new LightmapAssetRecord(root, sceneUuid).readAuxiliary(); + expect(auxiliary).toHaveLength(3); + await expect(host.publishLightmapAssets(request)).resolves.toEqual(output); + await host.releaseSceneOperation(owner); + host = new LightFXBakeHost(); + expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids).toEqual([uuid]); + const cleared = await host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] }); + expect([cleared.deletedTextureUuids, await pathExists(assetPath(target))]).toEqual([[uuid], false]); + expect(cleared.deletedAuxiliaryAssetUuids).toEqual(auxiliary); + expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual([]); + }); + + it('cleans prior files inside the committed Bake reservation without deleting the new result', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + const oldUuid = [...identities.keys()][0]; + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const cleanup = { ...owner, sceneUuid, textureUuids: [oldUuid], action: 'bake' as const }; + await expect(host.removeLightmapAssets(cleanup)).rejects.toThrow('ownership'); + const b = await bake('pixels B', undefined, sceneUuid, owner.transactionId); + await expect(host.removeLightmapAssets(cleanup)).rejects.toThrow('already in progress'); + await host.commit(b.token); + await expect(host.removeLightmapAssets({ ...cleanup, transactionId: randomUUID() })).rejects.toThrow('ownership'); + await expect(host.removeLightmapAssets(cleanup)).resolves.toEqual({ deletedTextureUuids: [oldUuid], retainedTextureUuids: [], failures: [] }); + expect([await pathExists(a.path), await readFile(b.path, 'utf8'), (await host.queryCapabilities()).busy]) + .toEqual([false, 'pixels B', true]); + await host.releaseSceneOperation(owner); + expect((await host.queryLightmapTextureInfo({ sceneUuid, uuids: [] })).ownedTextureUuids) + .toEqual([...identities.values()].filter(info => info.url.endsWith('.png')).map(info => info.uuid)); + }); + + it('replaces all native products after saving and protects the current set during rebake cleanup', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const first = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, first.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...first }); + const oldIds = [...identities.keys()]; + await host.releaseSceneOperation(first); + const second = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('B', undefined, sceneUuid, second.transactionId); + await host.commit(b.token); + const cleaned = await host.removeLightmapAssets({ ...second, sceneUuid, action: 'bake', textureUuids: [oldIds[0]] }); + expect([cleaned.deletedTextureUuids, cleaned.deletedAuxiliaryAssetUuids, cleaned.failures]) + .toEqual([[oldIds[0]], oldIds.slice(1), []]); + expect(await pathExists(b.path)).toBe(true); + await host.publishLightmapAssets({ ...b.token, ...second }); + expect(await readFile(assetPath(`db://assets/LightFX/scene-${sceneUuid}/lfx.log`), 'utf8')).toBe('native B'); + expect([...identities.values()].map(info => info.url)).toEqual([ + `db://assets/LightFX/scene-${sceneUuid}/output/LFX_Mesh_0000.png`, `db://assets/LightFX/scene-${sceneUuid}/tmp/lfx.in`, + `db://assets/LightFX/scene-${sceneUuid}/output/lfx.out`, `db://assets/LightFX/scene-${sceneUuid}/lfx.log`, + ]); + await host.releaseSceneOperation(second); + }); + + it('protects native products referenced by the same scene and retries with no texture candidates after restart', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, owner.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...owner }); + await host.releaseSceneOperation(owner); + const [png, ...auxiliary] = [...identities.keys()]; + mockAssets.queryAssetUsers.mockImplementation(async uuid => auxiliary.includes(uuid) ? [sceneUuid] : []); + const result = await host.removeLightmapAssets({ sceneUuid, textureUuids: [png] }); + expect([result.deletedTextureUuids, result.retainedAuxiliaryAssetUuids]).toEqual([[png], auxiliary]); + host = new LightFXBakeHost(); + mockAssets.queryAssetUsers.mockResolvedValue([]); + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).deletedAuxiliaryAssetUuids).toEqual(auxiliary); + expect(identities.size).toBe(0); + }); + it.each([undefined, 'db://assets', 'db://assets/Shared'])('isolates two same-name scenes, repeated bake and Clear in %s', async outputUrl => { + const identities = realAssetFiles(); + if (outputUrl) await ensureDir(assetPath(outputUrl)); + const scenes = [randomUUID(), randomUUID()]; + const published: string[] = []; + for (const sceneUuid of scenes) { + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake(sceneUuid, outputUrl, sceneUuid, owner.transactionId); + await host.commit(a.token); + published.push((await host.publishLightmapAssets({ ...a.token, ...owner })).textureUrls[0]); + await host.releaseSceneOperation(owner); + } + expect(published[0]).not.toBe(published[1]); + const oldIds = await new LightmapAssetRecord(root, scenes[0]).read(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const next = await bake('replacement', outputUrl, scenes[0], owner.transactionId); + await host.commit(next.token); + await host.removeLightmapAssets({ ...owner, sceneUuid: scenes[0], textureUuids: oldIds, action: 'bake' }); + expect((await host.publishLightmapAssets({ ...owner, ...next.token })).textureUrls[0]).toBe(published[0]); + await host.releaseSceneOperation(owner); + await host.removeLightmapAssets({ sceneUuid: scenes[0], textureUuids: await new LightmapAssetRecord(root, scenes[0]).read() }); + expect(await pathExists(assetPath(published[0]))).toBe(false); + expect(await readFile(assetPath(published[1]), 'utf8')).toBe(scenes[1]); + expect([...identities.values()].filter(info => info.url.includes(scenes[1]))).toHaveLength(4); + }); + it('forgets auxiliary membership already removed through Asset DB, including retries after restart', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, owner.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...owner }); + await host.releaseSceneOperation(owner); + const log = [...identities.values()].find(info => info.url.endsWith('/lfx.log'))!; + await mockAssets.removeAsset(log.uuid); + const png = [...identities.values()].find(info => info.url.endsWith('.png'))!; + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [png.uuid] })).failures).toEqual([]); + host = new LightFXBakeHost(); + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).failures).toEqual([]); + expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual([]); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('B', undefined, sceneUuid, next.transactionId); + await host.commit(b.token); + await expect(host.publishLightmapAssets({ ...b.token, ...next })).resolves.toHaveProperty('textureUrls'); + await host.releaseSceneOperation(next); + }); + it('cleans recorded legacy flat output before publishing into the scene-specific directory', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const first = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('legacy', undefined, sceneUuid, first.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...first }); + await host.releaseSceneOperation(first); + const oldPaths: string[] = []; + for (const info of [...identities.values()]) { + const legacy = info.url.replace(`/scene-${sceneUuid}`, ''); + await mockAssets.moveAsset(info.url, legacy); + oldPaths.push(assetPath(legacy)); + } + const old = await new LightmapAssetRecord(root, sceneUuid).read(); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('new', undefined, sceneUuid, next.transactionId); + await host.commit(b.token); + expect((await host.removeLightmapAssets({ ...next, sceneUuid, textureUuids: old, action: 'bake' })).failures).toEqual([]); + const result = await host.publishLightmapAssets({ ...b.token, ...next }); + expect(result.textureUrls[0]).toContain(`/scene-${sceneUuid}/output/`); + expect(await Promise.all(oldPaths.map(file => pathExists(file)))).toEqual([false, false, false, false]); + await host.releaseSceneOperation(next); + }); + it.each(['startup', 'busy', 'query-error'])('retains missing membership during %s', async state => { + realAssetFiles(); + const sceneUuid = randomUUID(), missing = randomUUID(); + const record = new LightmapAssetRecord(root, sceneUuid); + await record.add([], [missing]); + if (state === 'startup') mockAssetDB.ready = false; + if (state === 'busy') mockAssetDB.isBusy.mockReturnValue(true); + if (state === 'query-error') { + mockAssets.queryAssetInfo.mockImplementation(() => { throw new Error('database unavailable'); }); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).rejects.toThrow('database unavailable'); + } else { + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [] })).failures).toHaveLength(1); + } + expect(await record.readAuxiliary()).toEqual([missing]); + }); + + it('rolls back only newly staged native products without altering a previous fixed result', async () => { + const identities = realAssetFiles(), sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const a = await bake('A', undefined, sceneUuid, owner.transactionId); + await host.commit(a.token); + await host.publishLightmapAssets({ ...a.token, ...owner }); + await host.releaseSceneOperation(owner); + const oldAuxiliary = await new LightmapAssetRecord(root, sceneUuid).readAuxiliary(); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('B', undefined, sceneUuid, next.transactionId); + await host.rollback(b.token); + expect(await new LightmapAssetRecord(root, sceneUuid).readAuxiliary()).toEqual(oldAuxiliary); + expect(await readFile(assetPath(`db://assets/LightFX/scene-${sceneUuid}/lfx.log`), 'utf8')).toBe('native A'); + expect(await pathExists(b.path)).toBe(false); + await host.releaseSceneOperation(next); + expect([...identities.values()].filter(info => existsSync(info.file))).toHaveLength(4); + }); + + it('does not accept rebake cleanup after a rolled-back native operation or without a reservation', async () => { + const sceneUuid = randomUUID(); + const owner = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const b = await bake('pixels B', undefined, undefined, owner.transactionId); + await host.rollback(b.token); + await expect(host.removeLightmapAssets({ ...owner, sceneUuid, textureUuids: [], action: 'bake' })).rejects.toThrow('ownership'); + await host.releaseSceneOperation(owner); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [], action: 'bake' })).rejects.toThrow('ownership'); + }); + + it('remembers unbound products across Host restart and deletes actual files in custom directories', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + await ensureDir(assetRoot); + const a = await bake('pixels A', 'db://assets', sceneUuid); + await host.commit(a.token); + const b = await bake('pixels B', undefined, sceneUuid); + await host.commit(b.token); + await host.dispose(); + host = new LightFXBakeHost(); + const membership = await host.queryLightmapTextureInfo({ uuids: [], sceneUuid }); + expect(membership).toEqual({ textures: [], missingTextureUuids: [], ownedTextureUuids: [...identities.keys()] }); + const cleared = await host.removeLightmapAssets({ sceneUuid, textureUuids: membership.ownedTextureUuids! }); + expect([cleared.deletedTextureUuids.length, cleared.failures, await pathExists(a.path), await pathExists(b.path)]) + .toEqual([2, [], false, false]); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids).toEqual([]); + }); + + it('retains externally used products for retry and isolates identical scene names by UUID', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const otherScene = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + const b = await bake('pixels B', undefined, otherScene); + await host.commit(b.token); + const [aUuid, bUuid] = [...identities.keys()]; + mockAssets.queryAssetUsers.mockResolvedValueOnce([otherScene]); + expect((await host.removeLightmapAssets({ sceneUuid, textureUuids: [aUuid] })).retainedTextureUuids).toEqual([aUuid]); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids).toEqual([aUuid]); + await host.removeLightmapAssets({ sceneUuid, textureUuids: [aUuid] }); + expect([await pathExists(a.path), await pathExists(b.path)]).toEqual([false, true]); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid: otherScene })).ownedTextureUuids).toEqual([bUuid]); + }); + + it('does not count an asset API success when its PNG still exists', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + mockAssets.removeAsset.mockResolvedValueOnce(undefined); + const result = await host.removeLightmapAssets({ sceneUuid, textureUuids: [...identities.keys()] }); + expect([result.deletedTextureUuids, result.failures[0]?.reason, await pathExists(a.path)]) + .toEqual([[], 'Lightmap texture file still exists after asset deletion.', true]); + }); + + it('forgets rolled-back imports without losing earlier product membership', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const a = await bake('pixels A', undefined, sceneUuid); + await host.commit(a.token); + const aUuid = [...identities.keys()][0]; + const b = await bake('pixels B', undefined, sceneUuid); + await host.rollback(b.token); + expect((await host.queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids).toEqual([aUuid]); + expect([await pathExists(a.path), await pathExists(b.path)]).toEqual([true, false]); + }); + + it('keeps a failed legacy bound-asset delete in the record for later retry', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + // Legacy begin has no scene identity and therefore no generated-asset record yet. + const a = await bake('legacy pixels'); + await host.commit(a.token); + const uuid = [...identities.keys()][0]; + mockAssets.removeAsset.mockRejectedValueOnce(new Error('busy')); + const result = await host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] }); + expect(result.failures).toEqual([{ uuid, reason: 'busy' }]); + const retry = (await new LightFXBakeHost().queryLightmapTextureInfo({ uuids: [], sceneUuid })).ownedTextureUuids!; + expect(retry).toEqual([uuid]); + await host.removeLightmapAssets({ sceneUuid, textureUuids: retry }); + expect(await pathExists(a.path)).toBe(false); + }); + + it('rejects a damaged record before native execution or asset deletion', async () => { + const identities = realAssetFiles(); + const sceneUuid = randomUUID(); + const a = await bake('keep pixels'); + await host.commit(a.token); + await outputFile(join(root, 'settings', 'lightfx-assets', `${sceneUuid}.json`), '{broken'); + await expect(host.begin({ ...opts, sceneUuid })).rejects.toThrow(); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [...identities.keys()] })).rejects.toThrow(); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(await readFile(a.path, 'utf8')).toBe('keep pixels'); + expect(mockAssets.removeAsset).not.toHaveBeenCalled(); + }); + + it('publishes distinct assets across same-name bakes without touching legacy files or earlier versions', async () => { + const legacy = join(assetRoot, opts.sceneName, 'lightmap', 'LFX_Mesh_0000.png'); + await outputFile(legacy, 'legacy pixels'); + await outputFile(`${legacy}.meta`, 'legacy UUID'); + const a = await bake('pixels A'); + await host.commit(a.token); + const b = await bake('pixels B'); + await host.commit(b.token); + expect(a.url).not.toBe(b.url); + expect(a.url).toContain(`/bake-${a.token.operationId}/LFX_Mesh_0000.png`); + expect(b.url).toContain(`/bake-${b.token.operationId}/LFX_Mesh_0000.png`); + expect(await readFile(a.path, 'utf8')).toBe('pixels A'); + expect(await readFile(b.path, 'utf8')).toBe('pixels B'); + expect(await readFile(legacy, 'utf8')).toBe('legacy pixels'); + expect(await readFile(`${legacy}.meta`, 'utf8')).toBe('legacy UUID'); + }); + + it('rolls back only the current version and retains the previous published assets', async () => { + const a = await bake('pixels A'); + await host.commit(a.token); + const b = await bake('pixels B'); + await host.rollback(b.token); + expect(await readFile(a.path, 'utf8')).toBe('pixels A'); + expect(await pathExists(b.path)).toBe(false); + }); + + it.each(['db://assets', 'db://assets/烘焙结果/Room A'])('publishes and rolls back within the selected directory: %s', async outputUrl => { + await ensureDir(join(assetRoot, outputUrl.slice('db://assets'.length))); + const a = await bake('custom A', outputUrl); + await host.commit(a.token); + const b = await bake('custom B', outputUrl); + expect(a.url).toBe(`${outputUrl}/bake-${a.token.operationId}/LFX_Mesh_0000.png`); + expect(b.url).not.toBe(a.url); + await host.rollback(b.token); + expect(await readFile(a.path, 'utf8')).toBe('custom A'); + expect(await pathExists(b.path)).toBe(false); + expect(await pathExists(join(assetRoot, opts.sceneName))).toBe(false); + }); + + it.each(['', '/tmp/results', 'db://assets-other', 'db://assets/../outside', 'db://assets//folder', 'db://assets/folder/', 'db://assets/%2e%2e', 'db://assets/a\\b', 'db://assets/a?b', 'db://assets/a\nb', 'db://assets/a\0b'])('rejects invalid output URL before reserving or writing: %s', async outputUrl => { + await expect(host.begin({ ...opts, outputUrl })).rejects.toThrow('output directory'); + expect((await host.queryCapabilities()).busy).toBe(false); + expect(await pathExists(join(root, 'temp'))).toBe(false); + }); + + it('rejects missing folders, files and symlinks escaping assets without starting native work', async () => { + await ensureDir(assetRoot); + await outputFile(join(assetRoot, 'file'), 'keep'); + await symlink(root, join(assetRoot, 'outside'), 'dir'); + for (const name of ['missing', 'file', 'outside']) { + await expect(host.begin({ ...opts, outputUrl: `db://assets/${name}` })).rejects.toThrow(); + expect((await host.queryCapabilities()).busy).toBe(false); + } + expect(mockRun).not.toHaveBeenCalled(); + expect(await readFile(join(assetRoot, 'file'), 'utf8')).toBe('keep'); + }); + + it('keeps previous assets when importing a new version fails', async () => { + const a = await bake('pixels A'); + await host.commit(a.token); + mockAssets.refreshAsset.mockRejectedValueOnce(new Error('import unavailable')); + await expect(bake('pixels B')).rejects.toThrow('import unavailable'); + expect(await readFile(a.path, 'utf8')).toBe('pixels A'); + await expect(host.queryCapabilities()).resolves.toEqual({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }); + }); +}); diff --git a/src/core/scene/test/lightfx-bake-host.test.ts b/src/core/scene/test/lightfx-bake-host.test.ts new file mode 100644 index 000000000..09ba4be01 --- /dev/null +++ b/src/core/scene/test/lightfx-bake-host.test.ts @@ -0,0 +1,623 @@ +import { mkdtemp, outputFile, pathExists, readFile, readdir, remove } from 'fs-extra'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const mockAssetManager = { + queryPath: jest.fn(), + refreshAsset: jest.fn(), + queryUUID: jest.fn(), + queryAssetInfo: jest.fn(), + queryAssetUsers: jest.fn(), + removeAsset: jest.fn(), + queryAssetMeta: jest.fn(), + saveAssetMeta: jest.fn(), +}; +const mockRunnerRun = jest.fn(); +const mockRunnerCancel = jest.fn(); +const mockDecodedResult = { version: 1, meshes: [], terrains: [], probes: [] }; + +jest.mock('../../assets', () => ({ assetManager: mockAssetManager })); +jest.mock('../main-process/lightfx/process', () => ({ + LightFXProcess: jest.fn().mockImplementation(() => ({ + run: mockRunnerRun, + cancel: mockRunnerCancel, + })), +})); +jest.mock('../main-process/lightfx/asset-transaction', () => ({ + LightmapAssetTransaction: jest.fn(), +})); +jest.mock('../main-process/lightfx/output', () => ({ + decodeLightFXOutput: jest.fn(() => mockDecodedResult), +})); + +import { LightFXBakeHost, parseLightFXProgressRate } from '../main-process/lightfx-bake-host'; + +describe('LightFXBakeHost', () => { + const sceneUuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + let root: string; + let assetRoot: string; + let host: LightFXBakeHost; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'lightfx-host-')); + assetRoot = join(root, 'assets'); + mockAssetManager.queryPath.mockReset().mockImplementation((value: string) => ( + value === 'db://assets' ? assetRoot : null + )); + mockAssetManager.refreshAsset.mockReset().mockResolvedValue(undefined); + mockAssetManager.queryUUID.mockReset(); + mockAssetManager.queryAssetInfo.mockReset(); + mockAssetManager.queryAssetUsers.mockReset().mockResolvedValue([]); + mockAssetManager.removeAsset.mockReset().mockResolvedValue({}); + mockAssetManager.queryAssetMeta.mockReset(); + mockAssetManager.saveAssetMeta.mockReset(); + mockRunnerRun.mockReset(); + mockRunnerCancel.mockReset().mockResolvedValue(undefined); + host = new LightFXBakeHost(); + }); + + afterEach(async () => { + await host.dispose(); + await remove(root); + }); + + async function finishLightProbe(transactionId?: string): Promise { + mockRunnerRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const { operationId } = await host.begin({ + transactionId, + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await expect(host.run({ operationId })).resolves.toEqual({ result: mockDecodedResult, textureUrls: [] }); + return operationId; + } + + it('extracts only verified percentages from the native Progress channel', () => { + expect([ + parseLightFXProgressRate('Build lighting 0%'), + parseLightFXProgressRate('Build lighting 25%\n'), + parseLightFXProgressRate('Build lighting 99.5%'), + parseLightFXProgressRate('Build lighting 100%'), + ]).toEqual([0, 25, 99.5, 100]); + for (const value of ['[2,400]', 'Build lighting 101%', 'Build lighting -1%', 'Other stage 25%', '25%', { rate: 25 }]) { + expect(parseLightFXProgressRate(value)).toBeUndefined(); + } + }); + + it('queries protocol and occupancy without reserving, releasing or exposing ownership', async () => { + const idle = { sceneTransactionVersion: 1, lightmapAssetVersion: 1, lightmapOutputDirectory: true, lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1, cancelOwnershipVersion: 1, diagnosticsVersion: 1, busy: false }; + const busy = { ...idle, busy: true }; + await expect(host.queryCapabilities()).resolves.toEqual(idle); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + const operationId = await finishLightProbe(token.transactionId); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await host.commit({ operationId }); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await host.releaseSceneOperation(token); + await expect(host.queryCapabilities()).resolves.toEqual(idle); + const legacyId = await finishLightProbe(); + await expect(host.queryCapabilities()).resolves.toEqual(busy); + await host.rollback({ operationId: legacyId }); + await expect(host.queryCapabilities()).resolves.toEqual(idle); + }); + + it('bounds native diagnostics, checks exact ownership and retains terminal logs without accepting late callbacks', async () => { + let lateLog!: (message: string) => void; + mockRunnerRun.mockImplementationOnce(async ({ cwd, onLog, onProgress }: { + cwd: string; onLog: (message: string) => void; onProgress: (value: unknown) => void; + }) => { + lateLog = onLog; + for (let index = 0; index < 150; index++) { onLog(`line ${index}`); } + onProgress({ native: [1, 4], file: cwd }); + onProgress('Build lighting 25%\n'); + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const { operationId } = await host.begin({ ...token, target: 'light-probe', sceneName: 'Probe', textureSources: [], timeoutMs: 120_000 }); + const owner = { ...token, operationId, target: 'light-probe' as const }; + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await host.run({ operationId }); + const diagnostic = (await host.queryDiagnostics(owner))!; + expect([diagnostic.stage, diagnostic.logs.length, diagnostic.logs[0], diagnostic.logs.at(-1), diagnostic.progress, diagnostic.rate]) + .toEqual(['awaiting-commit', 128, 'line 22', 'line 149', 'Build lighting 25%\n', 25]); + diagnostic.logs.length = 0; + await expect(host.queryDiagnostics({ ...owner, target: 'lightmap' })).resolves.toBeUndefined(); + await expect(host.queryDiagnostics({ ...owner, transactionId: undefined })).resolves.toBeUndefined(); + await expect(host.queryDiagnostics({ ...owner, operationId: 'old' })).resolves.toBeUndefined(); + await host.commit({ operationId }); + lateLog('late callback'); + const completed = (await host.queryDiagnostics(owner))!; + expect([completed.stage, completed.logs.length, completed.logs.at(-1)]).toEqual(['committed', 128, 'line 149']); + await host.releaseSceneOperation(token); + }); + + it('assembles product logs from real progress and exported counts, not the verbose native file', async () => { + jest.spyOn(host as any, 'stageLightmapAssets').mockResolvedValue([]); + mockRunnerRun.mockImplementationOnce(async ({ cwd, onProgress }: { cwd: string; onProgress: (value: string) => void }) => { + onProgress('Build lighting 25%'); + onProgress('Build lighting 50%'); + onProgress('Build lighting 100%'); + await outputFile(join(cwd, 'lfx.log'), '2026-09-12 Version 382\nCmdLine: /native/LightFX\nStarting thread 0\n'); + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const { operationId } = await host.begin({ ...token, target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000, + sceneStats: { objects: 3, lights: 1, triangles: 224 } }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await host.run({ operationId }); + await host.commit({ operationId }); + expect((await host.queryDiagnostics({ ...token, operationId, target: 'lightmap' }))?.logs).toEqual([ + 'Baking started', 'Build lighting 25%', 'Build lighting 50%', 'Build lighting 100%', + 'The baking is ready to complete and begin generating images.', + 'Bake scene stats: objects 3 lights 1 triangles 224', 'End of the baking.', + ]); + await host.releaseSceneOperation(token); + }); + + it('does not report successful image generation or statistics when native work fails', async () => { + mockRunnerRun.mockImplementationOnce(async ({ onLog, onProgress }) => { + onLog('Version 382'); + onLog('Warning: native sample warning'); + onProgress('Build lighting 25%'); + throw new Error('native bake failed'); + }); + const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const { operationId } = await host.begin({ ...token, target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000, + sceneStats: { objects: 2, lights: 1, triangles: 4108 } }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await expect(host.run({ operationId })).rejects.toThrow('native bake failed'); + expect((await host.queryDiagnostics({ ...token, operationId, target: 'lightmap' }))?.logs) + .toEqual(['Baking started', 'Warning: native sample warning', 'Build lighting 25%']); + await host.releaseSceneOperation(token); + }); + + it.each([-1, NaN, 1.5])('rejects malformed exported statistics %s before starting native work', async triangles => { + await expect(host.begin({ target: 'lightmap', sceneName: 'Scene', textureSources: [], timeoutMs: 120_000, + sceneStats: { objects: 2, lights: 1, triangles } })).rejects.toThrow('exported scene statistics'); + expect(mockRunnerRun).not.toHaveBeenCalled(); + expect((await host.queryCapabilities()).busy).toBe(false); + }); + + it('reserves before export, rejects missing/wrong ownership and keeps the lease past native commit', async () => { + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const opts = { target: 'light-probe' as const, sceneName: 'LightProbe', textureSources: [], timeoutMs: 120_000 }; + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await expect(host.begin(opts)).rejects.toThrow('ownership'); + await expect(host.begin({ ...opts, transactionId: 'other' })).rejects.toThrow('ownership'); + await expect(host.begin({ ...opts, ...token, target: 'lightmap' })).rejects.toThrow('ownership'); + await expect(host.releaseSceneOperation({ transactionId: 'other' })).rejects.toThrow('Unknown'); + const operationId = await finishLightProbe(token.transactionId); + await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); + await host.commit({ operationId }); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await expect(host.begin({ ...opts, ...token })).rejects.toThrow('already started'); + await host.releaseSceneOperation(token); + const next = await host.reserveSceneOperation({ target: 'lightmap', action: 'clear' }); + await host.releaseSceneOperation(token); // A repeated release cannot unlock next. + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).rejects.toThrow('already in progress'); + await host.releaseSceneOperation(next); + await expect(host.begin({ ...opts, ...token })).rejects.toThrow('ownership'); + }); + + it('keeps a reservation after native rollback until scene recovery has finished', async () => { + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const operationId = await finishLightProbe(token.transactionId); + await host.rollback({ operationId }); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await host.releaseSceneOperation(token); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).resolves.toHaveProperty('transactionId'); + }); + + it('rejects invalid reservation and clear credentials without deleting assets', async () => { + await expect(host.reserveSceneOperation({ target: 'invalid' as any, action: 'clear' })).rejects.toThrow('Invalid'); + await expect(host.releaseSceneOperation({ transactionId: '' })).rejects.toThrow('Invalid'); + const uuid = '11111111-1111-4111-8111-111111111111'; + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'clear' }); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid], ...token })).rejects.toThrow('ownership'); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] })).rejects.toThrow('ownership'); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + await host.releaseSceneOperation(token); + }); + + it.each([false, true])('keeps exact asset deletion locked (legacy=%s)', async (legacy) => { + let finish!: () => void; + let entered!: () => void; + const enteredRemoval = new Promise(resolve => { entered = resolve; }); + const uuid = '11111111-1111-4111-8111-111111111111'; + mockAssetManager.queryAssetInfo.mockReturnValue({ uuid, url: `db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png` }); + mockAssetManager.removeAsset.mockImplementationOnce(() => new Promise(resolve => { finish = () => resolve({}); entered(); })); + const token = legacy ? undefined : await host.reserveSceneOperation({ target: 'lightmap', action: 'clear' }); + const removing = host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid], ...token }); + await enteredRemoval; + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).rejects.toThrow('already in progress'); + if (token) { + await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid], ...token })).rejects.toThrow('already being removed'); + } + finish(); await removing; + if (token) await host.releaseSceneOperation(token); + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'bake' })).resolves.toHaveProperty('transactionId'); + }); + + it('deletes only exact unreferenced immutable LightFX textures', async () => { + const deleted = '11111111-1111-4111-8111-111111111111'; + const retained = '22222222-2222-4222-8222-222222222222'; + const invalid = '33333333-3333-4333-8333-333333333333'; + const failed = '44444444-4444-4444-8444-444444444444'; + mockAssetManager.queryAssetInfo.mockImplementation((uuid: string) => ({ + uuid, + url: uuid === invalid + ? 'db://assets/User/texture.png' + : `db://assets/Maps/bake-55555555-5555-4555-8555-555555555555/LFX_Terrain_0000.png`, + })); + mockAssetManager.queryAssetUsers.mockImplementation(async (uuid: string) => uuid === retained + ? ['66666666-6666-4666-8666-666666666666'] + : uuid === deleted ? [`${deleted}@6c48a`, sceneUuid] : []); + mockAssetManager.removeAsset.mockImplementation(async (uuid: string) => { + if (uuid === failed) throw new Error('trash unavailable'); + return {}; + }); + + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [`${deleted}@6c48a`, deleted, retained, invalid, failed] })).resolves.toEqual({ + deletedTextureUuids: [deleted], + retainedTextureUuids: [retained], + failures: [ + { uuid: invalid, reason: 'Asset is not a managed LightFX texture.' }, + { uuid: failed, reason: 'trash unavailable' }, + ], + }); + expect(mockAssetManager.queryAssetUsers).toHaveBeenCalledTimes(3); + expect(mockAssetManager.removeAsset).toHaveBeenCalledTimes(2); + }); + + it.each(['6c48a', 'f9941'])('retains a parent image referenced only through subasset %s', async suffix => { + const uuid = '11111111-1111-4111-8111-111111111111'; + const child = `${uuid}@${suffix}`; + mockAssetManager.queryAssetInfo.mockReturnValue({ uuid, + url: 'db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png', + subAssets: { [suffix]: { uuid: child, subAssets: {} } }, + }); + mockAssetManager.queryAssetUsers.mockImplementation(async (id: string) => id === child + ? ['66666666-6666-4666-8666-666666666666'] : []); + const options = { sceneUuid, textureUuids: [uuid] }; + await expect(host.removeLightmapAssets(options)).resolves.toEqual({ deletedTextureUuids: [], retainedTextureUuids: [uuid], failures: [] }); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + expect(mockAssetManager.queryAssetUsers).toHaveBeenCalledWith(child); + // Once external references are removed, internal subasset dependencies do not + // prevent exact cleanup. The same candidate can be retried after Clear. + mockAssetManager.queryAssetUsers.mockResolvedValue([child]); + await expect(host.removeLightmapAssets(options)).resolves.toEqual({ deletedTextureUuids: [uuid], retainedTextureUuids: [], failures: [] }); + }); + + it('retains an image when only its subasset dependency query fails', async () => { + const uuid = '11111111-1111-4111-8111-111111111111'; + mockAssetManager.queryAssetInfo.mockReturnValue({ uuid, + url: 'db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png', + subAssets: { texture: { uuid: `${uuid}@6c48a` } }, + }); + mockAssetManager.queryAssetUsers.mockImplementation(async (id: string) => { + if (id.includes('@')) throw new Error('subasset index unavailable'); + return []; + }); + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] })).resolves.toEqual({ + deletedTextureUuids: [], retainedTextureUuids: [], failures: [{ uuid, reason: 'subasset index unavailable' }], + }); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + }); + + it('reports dependency query failures without attempting that deletion', async () => { + const uuid = '11111111-1111-4111-8111-111111111111'; + mockAssetManager.queryAssetInfo.mockReturnValue({ + uuid, + url: 'db://assets/Maps/bake-22222222-2222-4222-8222-222222222222/LFX_Mesh_0000.png', + }); + mockAssetManager.queryAssetUsers.mockRejectedValueOnce(new Error('dependency index unavailable')); + + await expect(host.removeLightmapAssets({ sceneUuid, textureUuids: [uuid] })).resolves.toEqual({ + deletedTextureUuids: [], + retainedTextureUuids: [], + failures: [{ uuid, reason: 'dependency index unavailable' }], + }); + expect(mockAssetManager.removeAsset).not.toHaveBeenCalled(); + }); + + it('reserves against legacy native operations and keeps ownership after begin validation failure', async () => { + const operationId = await finishLightProbe(); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await host.rollback({ operationId }); + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + await expect(host.begin({ ...token, target: 'light-probe', sceneName: 'Scene', textureSources: [], timeoutMs: 1 })).rejects.toThrow('timeout'); + await expect(host.reserveSceneOperation({ target: 'lightmap', action: 'clear' })).rejects.toThrow('already in progress'); + await host.releaseSceneOperation(token); + }); + + it('accepts chunked input, reserves one operation, and rolls it back idempotently', async () => { + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + + await expect(host.begin({ + target: 'lightmap', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + })).rejects.toThrow('A light-probe LightFX bake is already in progress.'); + + await host.appendInput({ operationId, chunkBase64: Buffer.from('first').toString('base64') }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('-second').toString('base64') }); + + const workspaces = await readdir(join(root, 'temp', 'lightfx-bake')); + expect(workspaces).toHaveLength(1); + await expect(readFile(join(root, 'temp', 'lightfx-bake', workspaces[0], 'tmp', 'lfx.in'), 'utf8')) + .resolves.toBe('first-second'); + + await host.rollback({ operationId }); + await expect(host.rollback({ operationId })).resolves.toBeUndefined(); + expect(mockRunnerCancel).toHaveBeenCalledTimes(1); + await expect(pathExists(join(root, 'temp', 'lightfx-bake', workspaces[0]))) + .resolves.toBe(false); + + await expect(host.begin({ + target: 'lightmap', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + })).resolves.toEqual({ operationId: expect.any(String) }); + }); + + it('rejects invalid requests without leaving a reserved operation behind', async () => { + await expect(host.begin({ + target: 'light-probe', + sceneName: '../LightProbe', + textureSources: [], + timeoutMs: 120_000, + })).rejects.toThrow('Invalid LightFX scene name.'); + await expect(host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 999, + })).rejects.toThrow('LightFX timeout must be an integer between 1000 and 3600000 milliseconds.'); + + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + await expect(host.appendInput({ operationId, chunkBase64: 'not-base64' })) + .rejects.toThrow('Invalid base64 LightFX input chunk.'); + await expect(host.appendInput({ operationId: 'missing', chunkBase64: '' })) + .rejects.toThrow('Unknown LightFX operation: missing'); + }); + + it('returns a stable no-op result when there is no operation to cancel', async () => { + await expect(host.cancel()).resolves.toEqual({ cancelled: false, target: null }); + expect(mockRunnerCancel).not.toHaveBeenCalled(); + }); + + it('queries display-safe metadata for bound lightmap textures', async () => { + const uuid = '11111111-1111-4111-8111-111111111111'; + const missingUuid = '22222222-2222-4222-8222-222222222222'; + const file = join(assetRoot, 'Lightmap', 'lightmap', 'LFX_Mesh_0000.png'); + await outputFile(file, Buffer.from('lightmap')); + mockAssetManager.queryAssetInfo.mockImplementation((value: string) => value === uuid ? { + uuid, + url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + file, + } : null); + + await expect(host.queryLightmapTextureInfo({ + uuids: [`${uuid}@6c48a`, uuid, missingUuid], + })).resolves.toEqual({ + textures: [{ + uuid, + url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + filename: 'LFX_Mesh_0000.png', + size: 8, + createdAt: expect.any(Number), + modifiedAt: expect.any(Number), + }], + missingTextureUuids: [missingUuid], + }); + expect(mockAssetManager.queryAssetInfo).toHaveBeenCalledTimes(2); + }); + + it('rejects unowned and stale cancellation without stopping the current reserved bake', async () => { + const token = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const operationId = await finishLightProbe(token.transactionId); + const current = { operationId, ...token, target: 'light-probe' as const }; + for (const request of [undefined, { ...current, operationId: 'old' }, { ...current, transactionId: undefined }, + { ...current, transactionId: 'other' }, { ...current, target: 'lightmap' as const }]) { + await expect(host.cancel(request)).resolves.toEqual({ cancelled: false, target: null }); + } + expect(mockRunnerCancel).not.toHaveBeenCalled(); + await expect(host.cancel(current)).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await host.releaseSceneOperation(token); + const nextToken = await host.reserveSceneOperation({ target: 'light-probe', action: 'bake' }); + const nextId = await finishLightProbe(nextToken.transactionId); + await expect(host.cancel(current)).resolves.toEqual({ cancelled: false, target: null }); + await expect(host.cancel({ ...current, ...nextToken })).resolves.toEqual({ cancelled: false, target: null }); + expect(mockRunnerCancel).toHaveBeenCalledTimes(1); + await host.commit({ operationId: nextId }); + await host.releaseSceneOperation(nextToken); + }); + + it('reports cancellation instead of an unknown operation when upload continues after cancel', async () => { + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + + await expect(host.cancel({ operationId, target: 'light-probe' })).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await expect(host.appendInput({ + operationId, + chunkBase64: Buffer.from('late chunk').toString('base64'), + })).rejects.toThrow('LightFX bake was cancelled.'); + await expect(host.run({ operationId })).rejects.toThrow('LightFX bake was cancelled.'); + }); + + it('waits for an accepted input write before removing the operation workspace', async () => { + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + const operation = (host as any).operation; + let finishWrite!: () => void; + operation.inputWritePromise = new Promise((resolve) => { + finishWrite = resolve; + }); + + const rollingBack = host.rollback({ operationId }); + await Promise.resolve(); + await expect(pathExists(operation.workspace)).resolves.toBe(true); + + finishWrite(); + await expect(rollingBack).resolves.toBeUndefined(); + await expect(pathExists(operation.workspace)).resolves.toBe(false); + }); + + it('makes commit idempotent but rejects commit after rollback', async () => { + const committedId = await finishLightProbe(); + await expect(Promise.all([ + host.commit({ operationId: committedId }), + host.commit({ operationId: committedId }), + ])).resolves.toEqual([undefined, undefined]); + await expect(host.commit({ operationId: committedId })).resolves.toBeUndefined(); + + const { operationId: rolledBackId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + await host.rollback({ operationId: rolledBackId }); + await expect(host.commit({ operationId: rolledBackId })) + .rejects.toThrow('LightFX bake was rolled-back and cannot be committed.'); + }); + + it('lets cancel win atomically after run and prevents a stale scene result from committing', async () => { + const operationId = await finishLightProbe(); + const cancelling = host.cancel({ operationId, target: 'light-probe' }); + + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was cancelled and cannot be committed.'); + await expect(cancelling).resolves.toEqual({ cancelled: true, target: 'light-probe' }); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was cancelled and cannot be committed.'); + }); + + it('lets commit win atomically over a concurrent cancel request', async () => { + const operationId = await finishLightProbe(); + const committing = host.commit({ operationId }); + + await expect(host.cancel({ operationId, target: 'light-probe' })).resolves.toEqual({ cancelled: false, target: null }); + await expect(committing).resolves.toBeUndefined(); + await expect(host.commit({ operationId })).resolves.toBeUndefined(); + }); + + it('preserves a rollback backup and the active operation when restoration fails', async () => { + const token = await host.reserveSceneOperation({ target: 'lightmap', action: 'bake' }); + const { operationId } = await host.begin({ + ...token, + target: 'lightmap', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 120_000, + }); + const operation = (host as any).operation; + const rollbackAssets = jest.fn() + .mockRejectedValueOnce(new Error('restore failed')) + .mockResolvedValueOnce(undefined); + operation.assets = { rollback: rollbackAssets }; + + await expect(host.rollback({ operationId })).rejects.toThrow('restore failed'); + await expect(pathExists(operation.workspace)).resolves.toBe(true); + expect((host as any).completedOperations.has(operationId)).toBe(false); + expect((host as any).operation).toBe(operation); + await expect(host.releaseSceneOperation(token)).rejects.toThrow('cleanup has not finished'); + await expect(host.reserveSceneOperation({ target: 'light-probe', action: 'clear' })).rejects.toThrow('already in progress'); + + await expect(host.rollback({ operationId })).resolves.toBeUndefined(); + expect(rollbackAssets).toHaveBeenCalledTimes(2); + await expect(pathExists(operation.workspace)).resolves.toBe(false); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was rolled-back and cannot be committed.'); + await host.releaseSceneOperation(token); + }); + + it('marks an awaiting commit as expired before asynchronous cleanup starts', async () => { + jest.useFakeTimers(); + try { + mockRunnerRun.mockImplementationOnce(async ({ cwd }: { cwd: string }) => { + await outputFile(join(cwd, 'output', 'lfx.out'), Buffer.alloc(0)); + }); + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 1_000, + }); + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + await host.run({ operationId }); + + jest.advanceTimersByTime(1_000); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was expired and cannot be committed.'); + await Promise.resolve(); + await Promise.resolve(); + } finally { + jest.useRealTimers(); + } + }); + + it('lets run serialize cleanup when expiry interrupts an active bake', async () => { + jest.useFakeTimers(); + try { + let rejectRun!: (error: Error) => void; + mockRunnerRun.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRun = reject; + })); + const { operationId } = await host.begin({ + target: 'light-probe', + sceneName: 'LightProbe', + textureSources: [], + timeoutMs: 1_000, + }); + const operation = (host as any).operation; + await host.appendInput({ operationId, chunkBase64: Buffer.from('input').toString('base64') }); + const runResult = expect(host.run({ operationId })).rejects.toThrow('LightFX bake timed out.'); + await Promise.resolve(); + expect(mockRunnerRun).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1_000); + await Promise.resolve(); + await Promise.resolve(); + expect(mockRunnerCancel).toHaveBeenCalledTimes(1); + expect(operation.cleanupPromise).toBeNull(); + await expect(pathExists(operation.workspace)).resolves.toBe(true); + + rejectRun(new Error('process stopped')); + await runResult; + await expect(pathExists(operation.workspace)).resolves.toBe(false); + await expect(host.commit({ operationId })) + .rejects.toThrow('LightFX bake was expired and cannot be committed.'); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/src/core/scene/test/lightfx-bake-renderer.test.ts b/src/core/scene/test/lightfx-bake-renderer.test.ts new file mode 100644 index 000000000..96845ff88 --- /dev/null +++ b/src/core/scene/test/lightfx-bake-renderer.test.ts @@ -0,0 +1,145 @@ +const mockSocketService: { io?: any } = {}; + +jest.mock('../../../server/socket', () => ({ + SCENE_RENDERER_ROOM: 'scene-renderer', + socketService: mockSocketService, +})); + +import { lightFXBakeRenderer } from '../main-process/lightfx-bake-renderer'; + +interface FakeSocketOptions { + id: string; + sceneUrl?: string; + visible?: boolean; + result?: unknown; +} + +function createSocket(options: FakeSocketOptions) { + const emit = jest.fn((_event, request, callback) => callback(null, { + result: options.result ?? { sceneUrl: options.sceneUrl }, + sceneUrl: options.sceneUrl, + })); + return { + id: options.id, + data: { + sceneUrl: options.sceneUrl, + sceneRendererVisible: options.visible, + }, + timeout: jest.fn(() => ({ emit })), + emit, + }; +} + +function useSockets(sockets: ReturnType[]) { + mockSocketService.io = { + in: jest.fn(() => ({ fetchSockets: jest.fn(async () => sockets) })), + }; +} + +describe('LightFX active scene renderer routing', () => { + afterEach(() => { + mockSocketService.io = undefined; + jest.clearAllMocks(); + }); + + it('routes a bake to the visible loaded renderer instead of a hidden preload renderer', async () => { + const hidden = createSocket({ id: 'hidden', sceneUrl: '', visible: false }); + const visible = createSocket({ + id: 'visible', + sceneUrl: 'db://assets/LightProbe.scene', + visible: true, + result: { sceneUrl: 'db://assets/LightProbe.scene', probeCount: 8 }, + }); + useSockets([hidden, visible]); + const fallback = jest.fn(); + + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [{}], 600_000, fallback, true, + )).resolves.toEqual({ sceneUrl: 'db://assets/LightProbe.scene', probeCount: 8 }); + + expect(fallback).not.toHaveBeenCalled(); + expect(hidden.timeout).not.toHaveBeenCalled(); + expect(visible.emit).toHaveBeenCalledWith( + 'scene:invoke-lightfx', + expect.objectContaining({ + sceneUrl: 'db://assets/LightProbe.scene', + module: 'LightProbeBake', + method: 'bake', + }), + expect.any(Function), + ); + }); + + it('falls back to the Scene Worker when no WebGL scene renderer is connected', async () => { + useSockets([]); + const fallback = jest.fn(async () => ({ probeCount: 4 })); + + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [{}], 600_000, fallback, true, + )).resolves.toEqual({ probeCount: 4 }); + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it.each(['LightProbeBake', 'LightmapBake'] as const)('routes %s capability queries to the actual renderer, with worker fallback only when absent', async module => { + const result = { version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, ...(module === 'LightmapBake' ? { assetVersion: 1 } : {}), busy: true }; + const visible = createSocket({ id: 'visible', sceneUrl: 'db://assets/Probe.scene', visible: true, result }); + useSockets([visible]); + const fallback = jest.fn(async () => result); + await expect(lightFXBakeRenderer.invoke( + module, 'queryCapabilities', [], 30_000, fallback, + )).resolves.toEqual(result); + expect(fallback).not.toHaveBeenCalled(); + expect(visible.emit).toHaveBeenCalledWith('scene:invoke-lightfx', expect.objectContaining({ + module, method: 'queryCapabilities', sceneUrl: 'db://assets/Probe.scene', + }), expect.any(Function)); + useSockets([]); + await expect(lightFXBakeRenderer.invoke( + module, 'queryCapabilities', [], 30_000, fallback, + )).resolves.toEqual(result); + expect(fallback).toHaveBeenCalledTimes(1); + }); + + it('routes a lightmap bake-info query to the active renderer', async () => { + const visible = createSocket({ + id: 'visible', + sceneUrl: 'db://assets/Lightmap.scene', + visible: true, + result: { baked: true, textures: [] }, + }); + useSockets([visible]); + + await expect(lightFXBakeRenderer.invoke( + 'LightmapBake', 'queryBakeInfo', [], 120_000, jest.fn(), + )).resolves.toEqual({ baked: true, textures: [] }); + expect(visible.emit).toHaveBeenCalledWith( + 'scene:invoke-lightfx', + expect.objectContaining({ module: 'LightmapBake', method: 'queryBakeInfo' }), + expect.any(Function), + ); + }); + + it('does not silently bake in the Scene Worker when the visible renderer has no scene', async () => { + useSockets([ + createSocket({ id: 'visible', sceneUrl: '', visible: true }), + createSocket({ id: 'hidden', sceneUrl: 'db://assets/Other.scene', visible: false }), + ]); + const fallback = jest.fn(); + + await expect(lightFXBakeRenderer.invoke( + 'LightProbeBake', 'bake', [{}], 600_000, fallback, true, + )).rejects.toThrow('visible scene renderer has not finished loading'); + expect(fallback).not.toHaveBeenCalled(); + }); + + it.each(['LightProbeBake', 'LightmapBake'] as const)('preserves the %s module when routing cancellation', async module => { + const visible = createSocket({ id: 'visible', sceneUrl: 'db://assets/Test.scene', visible: true }); + useSockets([visible]); + const fallback = jest.fn(); + await lightFXBakeRenderer.cancel(module, fallback); + expect(visible.emit).toHaveBeenCalledWith('scene:invoke-lightfx', expect.objectContaining({ module, method: 'cancel' }), expect.any(Function)); + expect(fallback).not.toHaveBeenCalled(); + useSockets([]); + await lightFXBakeRenderer.cancel(module, fallback); + expect(fallback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/scene/test/lightfx-cancel-owner.test.ts b/src/core/scene/test/lightfx-cancel-owner.test.ts new file mode 100644 index 000000000..77daf09ff --- /dev/null +++ b/src/core/scene/test/lightfx-cancel-owner.test.ts @@ -0,0 +1,98 @@ +const mockExport = jest.fn(); +jest.mock('cc', () => ({})); +jest.mock('../scene-process/service/baking/lightfx/exporter', () => ({ LightFXExporter: jest.fn(() => ({ export: mockExport })) })); +jest.mock('../scene-process/service/baking/lightfx/format', () => ({ encodeLightFXInput: () => new Uint8Array([1]) })); +jest.mock('../scene-process/service/baking/lightfx/buffer', () => ({ encodeLightFXBase64: () => 'AQ==' })); +jest.mock('../scene-process/service/baking/lightfx/scene-operation', () => ({ lightFXSceneOperation: { hostTransactionId: 'owner' } })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + begin: jest.fn(), appendInput: jest.fn(), run: jest.fn(), commit: jest.fn(), rollback: jest.fn(), + queryCapabilities: jest.fn(), cancel: jest.fn(), +} })); + +import type { Scene } from 'cc'; +import { LightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; +import { lightFXBakeHost as host } from '../scene-process/service/baking/lightfx/host'; +import type { LightFXSettings } from '../scene-process/service/baking/lightfx/types'; + +describe('LightFX cancellation ownership', () => { + const scene = { name: 'Test' } as Scene; + const settings = {} as LightFXSettings; + beforeEach(() => { + jest.clearAllMocks(); + for (const method of Object.values(host)) { + if (jest.isMockFunction(method)) method.mockReset(); + } + mockExport.mockReset().mockResolvedValue({ textureSources: [], world: {} }); + jest.mocked(host.begin).mockResolvedValue({ operationId: 'first' }); + jest.mocked(host.rollback).mockResolvedValue(undefined); + jest.mocked(host.run).mockResolvedValue({ result: { version: 1, meshes: [], terrains: [], probes: [] }, textureUrls: [] }); + jest.mocked(host.queryCapabilities).mockResolvedValue({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); + jest.mocked(host.cancel).mockResolvedValue({ cancelled: true, target: 'light-probe' }); + }); + + it('does not contact the host for another runtime, wrong target or pre-native export', async () => { + const owner = new LightFXCoordinator(), other = new LightFXCoordinator(); + let finish!: (value: object) => void; + mockExport.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const bake = owner.bake(scene, 'light-probe', settings, 1000); + expect(owner.canCancel('light-probe')).toBe(false); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + finish({ textureSources: [], world: {} }); + await bake; + expect(owner.canCancel('light-probe')).toBe(true); + expect(owner.canCancel('lightmap')).toBe(false); + expect(other.canCancel('light-probe')).toBe(false); + await expect(other.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + await expect(owner.cancel('lightmap')).resolves.toEqual({ cancelled: false, target: null }); + expect(host.queryCapabilities).not.toHaveBeenCalled(); + expect(host.cancel).not.toHaveBeenCalled(); + await owner.cancel('light-probe'); + expect(host.cancel).toHaveBeenCalledWith({ operationId: 'first', transactionId: 'owner', target: 'light-probe' }); + }); + + it('refuses an older host and never falls back to unscoped cancellation', async () => { + const owner = new LightFXCoordinator(); + await owner.bake(scene, 'light-probe', settings, 1000); + jest.mocked(host.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, busy: true }); + await expect(owner.cancel('light-probe')).rejects.toThrow('ownership protocol'); + expect(host.cancel).not.toHaveBeenCalled(); + }); + + it('a late handshake retains the old operation identity, and completed owners are cleared', async () => { + const owner = new LightFXCoordinator(); + await owner.bake(scene, 'light-probe', settings, 1000); + let finish!: (value: Awaited>) => void; + jest.mocked(host.queryCapabilities).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const cancel = owner.cancel('light-probe'); + await owner.commit('first'); + expect(owner.canCancel('light-probe')).toBe(false); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + jest.mocked(host.begin).mockResolvedValueOnce({ operationId: 'second' }); + await owner.bake(scene, 'light-probe', settings, 1000); + finish({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); + await cancel; + expect(host.cancel).toHaveBeenCalledWith({ operationId: 'first', transactionId: 'owner', target: 'light-probe' }); + await owner.rollback('second'); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + }); + + it('clears ownership when native execution fails', async () => { + const owner = new LightFXCoordinator(); + jest.mocked(host.run).mockRejectedValueOnce(new Error('cancelled')); + await expect(owner.bake(scene, 'light-probe', settings, 1000)).rejects.toThrow('cancelled'); + await expect(owner.cancel('light-probe')).resolves.toEqual({ cancelled: false, target: null }); + expect(host.rollback).toHaveBeenCalledWith({ operationId: 'first' }); + }); + + it('forwards the selected output directory only after the actual host confirms support', async () => { + const owner = new LightFXCoordinator(); + await expect(owner.bake(scene, 'lightmap', settings, 1000, 'db://assets/Lightmaps')).rejects.toThrow('output directory'); + expect(mockExport).not.toHaveBeenCalled(); + expect(host.begin).not.toHaveBeenCalled(); + expect(owner.activeTarget).toBe(null); + jest.mocked(host.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapOutputDirectory: true, busy: true }); + await owner.bake(scene, 'lightmap', settings, 1000, 'db://assets/Lightmaps'); + expect(host.begin).toHaveBeenCalledWith(expect.objectContaining({ outputUrl: 'db://assets/Lightmaps', transactionId: 'owner' })); + await owner.commit('first'); + }); +}); diff --git a/src/core/scene/test/lightfx-io.test.ts b/src/core/scene/test/lightfx-io.test.ts new file mode 100644 index 000000000..5a7c2fd40 --- /dev/null +++ b/src/core/scene/test/lightfx-io.test.ts @@ -0,0 +1,79 @@ +import { LightFXBuffer } from '../scene-process/service/baking/lightfx/buffer'; +import { encodeLightFXInput } from '../scene-process/service/baking/lightfx/format'; +import { LIGHTFX_FILE_VERSION, LightFXChunk, LightFXWorld } from '../scene-process/service/baking/lightfx/types'; +import { createDefaultLightFXSettings } from '../scene-process/service/baking/lightfx/settings'; +import { decodeLightFXOutput } from '../main-process/lightfx/output'; +import { MAX_LIGHTMAP_GI_SAMPLES } from '../common/lightfx-limits'; +import { validLightmapUV } from '../scene-process/service/baking/lightfx/lightmap-uv'; +import { lightmapSceneStats } from '../scene-process/service/baking/lightfx/scene-stats'; + +describe('LightFX binary format', () => { + it('uses the last non-overflowing native Lightmap sampling factor', () => { + expect(MAX_LIGHTMAP_GI_SAMPLES).toBe(2590); + expect(MAX_LIGHTMAP_GI_SAMPLES ** 2 * 64 * 5).toBeLessThanOrEqual(0x7fffffff); + expect((MAX_LIGHTMAP_GI_SAMPLES + 1) ** 2 * 64 * 5).toBeGreaterThan(0x7fffffff); + }); + + it.each([1, 25, 1024, 2590])('encodes valid Lightmap sampling factor %s unchanged', giSamples => { + const world: LightFXWorld = { name: 'Scene', settings: { ...createDefaultLightFXSettings('lightmap'), giSamples }, textures: [], terrains: [], meshes: [], lights: [], probes: [] }; + const encoded = encodeLightFXInput(world); + // Version + length-prefixed name + origin/sky + msaa/size/gamma/highp + giScale. + const offset = 4 + 4 + 'Scene'.length + 24 + 12 + 1 + 4; + expect(new DataView(encoded.buffer, encoded.byteOffset).getInt32(offset, true)).toBe(giSamples); + }); + + it.each([2591, 65535, 65536, 0, -1, 25.5, NaN, Infinity])('rejects unsafe Lightmap sampling factor %s before encoding', giSamples => { + const world: LightFXWorld = { name: 'Scene', settings: { ...createDefaultLightFXSettings('lightmap'), giSamples }, textures: [], terrains: [], meshes: [], lights: [], probes: [] }; + expect(() => encodeLightFXInput(world)).toThrow('Lightmap GI Samples must be an integer between 1 and 2590'); + }); + + it('encodes both bake target flags and scene chunks', () => { + const world: LightFXWorld = { name: 'Scene', settings: createDefaultLightFXSettings('light-probe'), textures: [], terrains: [], meshes: [], lights: [], probes: [{ position: [1, 2, 3], normal: [0, 1, 0] }] }; + const encoded = encodeLightFXInput(world); + expect(encoded.byteLength).toBeGreaterThan(80); + expect(new DataView(encoded.buffer, encoded.byteOffset).getInt32(0, true)).toBe(LIGHTFX_FILE_VERSION); + }); + + it('decodes mesh, terrain and probe results', () => { + const b = new LightFXBuffer(); b.writeInt32(LIGHTFX_FILE_VERSION); + b.writeInt32(LightFXChunk.MESH); b.writeInt32(1); b.writeInt32(2); b.writeInt32(3); b.writeFloats([.1, .2, .3, .4]); + b.writeInt32(LightFXChunk.TERRAIN); b.writeInt32(4); b.writeInt32(1); b.writeInt32(5); b.writeInt32(6); b.writeFloats([.2, .3, .4, .5]); + b.writeInt32(LightFXChunk.LIGHT_PROBE); b.writeInt32(1); b.writeFloats([1, 2, 3, 0, 1, 0]); b.writeInt32(27); b.writeFloats(Array.from({ length: 27 }, (_, i) => i)); + b.writeInt32(LightFXChunk.EOF); + const result = decodeLightFXOutput(b.toUint8Array()); + expect(result.meshes[0]).toMatchObject({ id: 2, index: 3 }); expect(result.terrains[0]).toMatchObject({ id: 4, blockId: 5, index: 6 }); expect(result.probes[0].coefficients).toHaveLength(27); + }); + + it('accepts the legacy output version emitted by the bundled LightFX tool', () => { + const b = new LightFXBuffer(); b.writeInt32(0x2000); b.writeInt32(LightFXChunk.EOF); + expect(decodeLightFXOutput(b.toUint8Array()).version).toBe(0x2000); + }); + + it('rejects incompatible, truncated and unknown output', () => { + const version = new LightFXBuffer(); version.writeInt32(1); expect(() => decodeLightFXOutput(version.toUint8Array())).toThrow('Unsupported'); + const truncated = new LightFXBuffer(); truncated.writeInt32(LIGHTFX_FILE_VERSION); truncated.writeInt32(LightFXChunk.MESH); expect(() => decodeLightFXOutput(truncated.toUint8Array())).toThrow('truncated'); + const unknown = new LightFXBuffer(); unknown.writeInt32(LIGHTFX_FILE_VERSION); unknown.writeInt32(99); expect(() => decodeLightFXOutput(unknown.toUint8Array())).toThrow('Unknown'); + }); +}); + +describe('Lightmap export UV validation', () => { + it.each([ + [null, 3, false], [[0, 0], 3, false], [[0, NaN], 1, false], [[Infinity, 0], 1, false], + [[0, 0, 1, 0, 0, 1], 3, true], [new Float32Array([0, 1]), 1, true], [[], 0, false], + ])('validates UV1 %p for %p vertices', (uv, count, expected) => { + expect(validLightmapUV(uv as number[] | null, count as number)).toBe(expected); + }); +}); + +describe('Lightmap exported scene statistics', () => { + it('counts mesh triangles plus full terrain tiles, not packed images or terrain tasks', () => { + const world = { meshes: [{ triangles: Array(12) }], terrains: [{ blockCount: [2, 1] }], lights: [{}] } as LightFXWorld; + expect(lightmapSceneStats(world, 32)).toEqual({ objects: 2, lights: 1, triangles: 4108 }); + }); + it('counts mesh-only and empty exported worlds without inventing objects', () => { + const world = { meshes: [{ triangles: Array(12) }, { triangles: Array(200) }, { triangles: Array(12) }], terrains: [], lights: [{}] } as unknown as LightFXWorld; + expect(lightmapSceneStats(world, 32)).toEqual({ objects: 3, lights: 1, triangles: 224 }); + expect(lightmapSceneStats({ meshes: [], terrains: [], lights: [] } as unknown as LightFXWorld, 32)) + .toEqual({ objects: 0, lights: 0, triangles: 0 }); + }); +}); diff --git a/src/core/scene/test/lightfx-metadata.test.ts b/src/core/scene/test/lightfx-metadata.test.ts new file mode 100644 index 000000000..e3c9c831f --- /dev/null +++ b/src/core/scene/test/lightfx-metadata.test.ts @@ -0,0 +1,53 @@ +jest.mock('cc', () => ({ + Vec3: class Vec3 {}, + Texture2D: class Texture2D {}, + js: { + getClassName: (value: { type?: string }) => value.type + ?? (value.constructor.name === 'Vertex' ? 'cc.Vertex' : 'cc.Other'), + }, +})); + +import { Vec3, Texture2D } from 'cc'; +import { withLightProbeCoefficientType } from '../scene-process/service/dump/light-probe-metadata'; +import { withLightmapTextureType } from '../scene-process/service/dump/lightmap-metadata'; + +class Vertex { coefficients: Vec3[] = []; } + +describe('Light probe dump metadata', () => { + it('supplies Vec3 for legacy SH arrays without mutating engine attributes', () => { + const attributes = Object.freeze({ default: () => [], serializable: true, visible: false }); + const owner = new Vertex(); + expect(withLightProbeCoefficientType(attributes, owner, 'coefficients')).toEqual({ ...attributes, ctor: Vec3 }); + expect(attributes).not.toHaveProperty('ctor'); + }); + + it('preserves an engine-provided element constructor', () => { + const attributes = { ctor: Vec3, serializable: true }; + expect(withLightProbeCoefficientType(attributes, new Vertex(), 'coefficients')).toBe(attributes); + }); + + it.each([ + [new Vertex(), 'position'], + [{ coefficients: [] }, 'coefficients'], + [null, 'coefficients'], + ])('does not change unrelated metadata (%p, %s)', (owner, key) => { + const attributes = { ctor: undefined, default: () => [] }; + expect(withLightProbeCoefficientType(attributes, owner, key)).toBe(attributes); + }); +}); + +describe('Lightmap texture snapshot metadata', () => { + it.each(['cc.ModelBakeSettings', 'cc.TerrainBlockLightmapInfo'])('types even cleared texture references on %s without changing engine metadata', type => { + const attributes = Object.freeze({ default: null }); + expect(withLightmapTextureType(attributes, { type }, 'texture')).toEqual({ default: null, ctor: Texture2D }); + expect(attributes).toEqual({ default: null }); + }); + it('preserves declared constructors', () => { + const attributes = { ctor: class CustomTexture {} }; + expect(withLightmapTextureType(attributes, { type: 'cc.ModelBakeSettings' }, 'texture')).toBe(attributes); + }); + it.each([[null, 'texture'], [{ type: 'cc.Other' }, 'texture'], [{ type: 'cc.ModelBakeSettings' }, 'uvParam']])('does not change unrelated properties (%p, %s)', (owner, key) => { + const attributes = {}; + expect(withLightmapTextureType(attributes, owner as object | null, key as string)).toBe(attributes); + }); +}); diff --git a/src/core/scene/test/lightfx-result-failures.test.ts b/src/core/scene/test/lightfx-result-failures.test.ts new file mode 100644 index 000000000..03e83a7eb --- /dev/null +++ b/src/core/scene/test/lightfx-result-failures.test.ts @@ -0,0 +1,299 @@ +const mockGetScene = jest.fn(); +let mockSessionGeneration = 0; +const mockReserveSceneOperation = jest.fn(), mockReleaseSceneOperation = jest.fn(); +const mockMeshRenderer = class MeshRenderer {}; +const mockTerrain = class Terrain {}; +class MockVec3 { + constructor(public x = 0, public y = 0, public z = 0) {} + clone() { return new MockVec3(this.x, this.y, this.z); } + set(x: number | MockVec3, y?: number, z?: number) { + Object.assign(this, typeof x === 'number' ? { x, y, z } : x); + } +} +const mockBake = jest.fn(), mockCommit = jest.fn(), mockRollback = jest.fn(); +const mockSave = jest.fn(), mockRepaint = jest.fn(); +const mockRemoveLightmapAssets = jest.fn(), mockQuerySceneSerializedData = jest.fn(); +const mockUndo = { beginRecording: jest.fn(), endRecording: jest.fn(), cancelRecording: jest.fn(), createCheckpoint: jest.fn() }; +jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain, + Vec3: MockVec3, SH: { getBasisCount: () => 9 } })); +jest.mock('../scene-process/service/core', () => ({ + BaseService: class { broadcast() {} }, register: () => () => undefined, + Service: { Undo: mockUndo, Editor: { + save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData, + getEditorSession: () => ({ uuid: mockGetScene()?.uuid, generation: mockSessionGeneration }), + isCurrentEditorSession: (session: any) => session.uuid === mockGetScene()?.uuid && session.generation === mockSessionGeneration, + runForSession: async (_session: any, action: any) => action(mockSave), + }, Engine: { repaintInEditMode: mockRepaint } }, +})); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { + bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, + publishLightmapAssets: async () => ({ textureUrls: ['db://assets/LightFX/output/LFX_Mesh_0000.png'] }), +} })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + reserveSceneOperation: mockReserveSceneOperation, releaseSceneOperation: mockReleaseSceneOperation, + queryCapabilities: async () => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 }), + queryLightmapTextureInfo: async () => ({ textures: [], missingTextureUuids: [], ownedTextureUuids: [] }), +} })); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); +jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); + +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; +import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; + +function fixture(target: 'probe' | 'lightmap') { + const events: string[] = []; + const oldTexture = { uuid: 'old' }, texture = { uuid: 'new' }; + let assets = ['old', 'new']; + let committed = false; + const model = { uuid: 'mesh', node: {}, bakeSettings: { texture: oldTexture as { uuid: string } | null, + uvParam: { x: 1, y: 2, z: 3, w: 4, clone() { return { x: this.x, y: this.y, z: this.z, w: this.w }; } } }, + _updateLightmap(value: { uuid: string } | null, x: number, y: number, z: number, w: number) { + this.bakeSettings.texture = value; + Object.assign(this.bakeSettings.uvParam, { x, y, z, w }); + } }; + const probes = Array.from({ length: 4 }, (_, x) => ({ position: new MockVec3(x), normal: new MockVec3(), coefficients: [new MockVec3(1)] })); + const info = { data: { probes }, giScale: 1, onProbeBakeFinished() {}, onProbeBakeCleared() { probes.forEach(p => { p.coefficients = []; }); } }; + const scene = { uuid: 'scene', name: 'test', globals: { lightProbeInfo: info, bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : [], + }; + const read = () => ({ texture: model.bakeSettings.texture?.uuid ?? null, uv: model.bakeSettings.uvParam.clone(), + highp: scene.globals.bakedWithHighpLightmap, stationary: scene.globals.bakedWithStationaryMainLight, + giScale: info.giScale, probes: probes.map(p => ({ normal: p.normal.clone(), coefficients: p.coefficients.map(c => c.clone()) })) }); + let disk = read(); + const capture = () => ({ state: read(), baked: { + __type__: 'cc.ModelBakeSettings', texture: model.bakeSettings.texture ? { __uuid__: model.bakeSettings.texture.uuid } : null, + uvParam: model.bakeSettings.uvParam.clone(), + }, globals: { __type__: 'cc.SceneGlobals', bakedWithHighpLightmap: scene.globals.bakedWithHighpLightmap, + bakedWithStationaryMainLight: scene.globals.bakedWithStationaryMainLight } }); + mockQuerySceneSerializedData.mockImplementation(async () => JSON.stringify(capture())); + mockRemoveLightmapAssets.mockImplementation(async (_scene: string, uuids: string[]) => { + assets = assets.filter(uuid => !uuids.includes(uuid)); + return { deletedTextureUuids: uuids, retainedTextureUuids: [], failures: [] }; + }); + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['scene', deletedLightmapAssets.capture(scene, capture())]]), + equals: (a, b) => JSON.stringify(a.get('scene')) === JSON.stringify(b.get('scene')), + apply: data => { + const filtered = deletedLightmapAssets.filter(scene, data.get('scene') as ReturnType, 'serialized'); + const state = { ...filtered.state, texture: filtered.baked.texture?.__uuid__ ?? null, uv: filtered.baked.uvParam, + highp: filtered.globals.bakedWithHighpLightmap, stationary: filtered.globals.bakedWithStationaryMainLight }; + model._updateLightmap(state.texture ? { uuid: state.texture } : null, state.uv.x, state.uv.y, state.uv.z, state.uv.w); + scene.globals.bakedWithHighpLightmap = state.highp; + scene.globals.bakedWithStationaryMainLight = state.stationary; + info.giScale = state.giScale; + state.probes.forEach((p, i) => { probes[i].normal.set(p.normal); probes[i].coefficients = p.coefficients.map(c => c.clone()); }); + return { success: true }; + }, + } }); + mockUndo.beginRecording.mockImplementation(uuids => manager.beginRecording(uuids)); + mockUndo.endRecording.mockImplementation(async id => { events.push('record'); await manager.endRecording(id); }); + mockUndo.cancelRecording.mockImplementation(id => manager.cancelRecording(id)); + mockUndo.createCheckpoint.mockImplementation(() => manager.createCheckpoint()); + const save = async () => { events.push('save'); disk = read(); manager.markSaved(); }; + mockSave.mockImplementation(save); + mockCommit.mockImplementation(async () => { events.push('commit'); committed = true; }); + mockRollback.mockImplementation(async () => { if (committed) throw new Error('already committed'); assets = ['old']; }); + mockBake.mockResolvedValue({ models: [model], terrains: [], operationId: 'operation', stationaryMainLight: true, textureUrls: [], result: { + meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], terrains: [], + probes: probes.map(p => ({ position: [p.position.x, 0, 0], normal: [0, 1, 0], coefficients: new Array(27).fill(2) })), + } }); + mockGetScene.mockReturnValue(scene); + const service = target === 'probe' ? new LightProbeBakeService() : new LightmapBakeService(); + jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); + if (service instanceof LightmapBakeService) jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture]])); + return { scene, service, manager, read, disk: () => disk, assets: () => assets, events, save, + commit: async () => { committed = true; }, bake: () => service.bake({ giScale: 2, highp: true }), old: read() }; +} + +beforeEach(() => { + jest.resetAllMocks(); + mockSessionGeneration = 0; + mockReserveSceneOperation.mockResolvedValue({ transactionId: 'owner' }); + mockReleaseSceneOperation.mockResolvedValue(undefined); +}); + +describe.each(['probe', 'lightmap'] as const)('%s result failure consistency', target => { + for (const action of ['bake', 'clear', ...(target === 'lightmap' ? ['clear-delete'] : [])]) { + it.each(['switch', 'reload', 'session-generation'])(`${action} rejects %s during reservation and releases ownership for retry`, async change => { + const source = fixture(target); + const replacement = change === 'session-generation' ? source : fixture(target); + if (change === 'switch') replacement.scene.uuid = 'other-scene'; + mockGetScene.mockReturnValue(source.scene); + let reserve!: (token: { transactionId: string }) => void; + mockReserveSceneOperation.mockReturnValueOnce(new Promise(resolve => { reserve = resolve; })); + const invoke = () => action === 'bake' ? source.bake() + : source.service.clearBake({ saveScene: true, ...(action === 'clear-delete' ? { deleteAssets: true } : {}) }); + const pending = invoke(); + const rejected = expect(pending).rejects.toThrow('source scene changed'); + expect(mockReserveSceneOperation).toHaveBeenCalledTimes(1); + expect(mockBake).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + mockGetScene.mockReturnValue(replacement.scene); + if (change === 'session-generation') mockSessionGeneration++; + reserve({ transactionId: 'obsolete-owner' }); + await rejected; + expect(mockReleaseSceneOperation).toHaveBeenCalledTimes(1); + expect(mockReleaseSceneOperation).toHaveBeenCalledWith({ transactionId: 'obsolete-owner' }); + expect(mockBake).not.toHaveBeenCalled(); + expect(mockCommit).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + for (const f of [source, replacement]) { + expect(f.read()).toEqual(f.old); + expect(f.disk()).toEqual(f.old); + expect(f.assets()).toEqual(['old', 'new']); + } + // The rejected request must not poison the local reservation for the next request. + await invoke(); + expect(mockReserveSceneOperation).toHaveBeenCalledTimes(2); + expect(mockReleaseSceneOperation).toHaveBeenCalledTimes(2); + expect(mockReleaseSceneOperation).toHaveBeenLastCalledWith({ transactionId: 'owner' }); + expect(mockSave).toHaveBeenCalledTimes(1); + }); + } + it('confirms asset retention before recording or saving', async () => { + const f = fixture(target); + await f.bake(); + expect({ events: f.events, disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ events: ['commit', 'record', 'save'], disk: f.read(), dirty: false }); + }); + it.each([false, true])('rejects a replaced scene before committing, saving or deleting (same UUID=%s)', sameUuid => { + const f = fixture(target); + const source = mockGetScene(); + const original = mockBake.getMockImplementation()!; + mockBake.mockImplementationOnce(async (...args) => { + const output = await original(...args); + mockGetScene.mockReturnValue({ ...source, uuid: sameUuid ? source.uuid : 'other-scene' }); + return output; + }); + return expect(f.bake()).rejects.toThrow('source scene changed').then(() => { + expect(mockSave).not.toHaveBeenCalled(); + expect(mockCommit).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(f.read()).toEqual(f.old); + }); + }); + + it.each([false, true])('keeps ordinary edits and only current Lightmap results in edit → rebake → clear history (save=%s)', async saveScene => { + const f = fixture(target); + const scene = mockGetScene(); + const edit = f.manager.beginRecording(['scene']); + scene.globals.lightProbeInfo.giScale = 1.5; + await f.manager.endRecording(edit); + const edited = f.read(); + const previous = (value: ReturnType) => target === 'lightmap' + ? { ...value, texture: null, uv: { x: 0, y: 0, z: 0, w: 0 }, highp: false, stationary: false } : value; + + await f.service.bake({ giScale: 2, highp: true, saveScene }); + const baked = f.read(); + await f.service.clearBake({ saveScene }); + const cleared = f.read(); + expect(cleared).not.toEqual(baked); + expect(f.manager.isDirty()).toBe(!saveScene); + + await f.manager.undo(); + expect(f.read()).toEqual(baked); + await f.manager.undo(); + expect(f.read()).toEqual(previous(edited)); + await f.manager.undo(); + expect(f.read()).toEqual(previous(f.old)); + expect(f.manager.canUndo()).toBe(false); + + await f.manager.redo(); + expect(f.read()).toEqual(previous(edited)); + await f.manager.redo(); + expect(f.read()).toEqual(baked); + await f.manager.redo(); + expect(f.read()).toEqual(cleared); + expect(f.manager.canRedo()).toBe(false); + expect(f.manager.isDirty()).toBe(!saveScene); + expect(f.assets()).toEqual(target === 'lightmap' && saveScene ? ['new'] : ['old', 'new']); + }); + + it.each([false, true])('does not mutate scene, disk or history on commit failure (host committed=%s)', async committed => { + const f = fixture(target); + mockCommit.mockImplementationOnce(async () => { if (committed) await f.commit(); throw new Error('commit response failed'); }); + await expect(f.bake()).rejects.toThrow('commit response failed'); + expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), undo: f.manager.canUndo() }).toEqual({ + memory: f.old, disk: f.old, assets: committed ? ['old', 'new'] : ['old'], undo: false, + }); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + }); + + it.each(['bake', 'clear'] as const)('retains %s after pre-write save failure, supports Undo/Redo and retry', async action => { + const f = fixture(target); + mockSave.mockRejectedValueOnce(new Error('disk unavailable')); + await expect(action === 'bake' ? f.bake() : f.service.clearBake()).rejects.toThrow('result retained'); + const result = f.read(); + expect(result).not.toEqual(f.old); + expect({ disk: f.disk(), assets: f.assets(), dirty: f.manager.isDirty() }).toEqual({ disk: f.old, assets: ['old', 'new'], dirty: true }); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + await f.manager.undo(); + expect(f.read()).toEqual(f.old); + await f.manager.redo(); + expect(f.read()).toEqual(result); + await f.save(); + expect({ disk: f.disk(), dirty: f.manager.isDirty() }).toEqual({ disk: result, dirty: false }); + }); + + it.each(['bake', 'clear'] as const)('retains %s when disk was written but its response failed', async action => { + const f = fixture(target); + mockSave.mockImplementationOnce(async () => { await f.save(); throw new Error('save response lost'); }); + await expect(action === 'bake' ? f.bake() : f.service.clearBake()).rejects.toThrow('save response lost'); + const result = f.read(); + expect(f.disk()).toEqual(result); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + await f.manager.undo(); + expect(f.read()).toEqual(f.old); + expect(f.manager.isDirty()).toBe(true); + await f.manager.redo(); + expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), dirty: f.manager.isDirty() }).toEqual({ + memory: result, disk: result, assets: ['old', 'new'], dirty: false, + }); + }); + + it('restores an application failure without deleting committed assets or creating history', async () => { + const f = fixture(target); + mockRepaint.mockRejectedValueOnce(new Error('application repaint failed')); + await expect(f.bake()).rejects.toThrow('application repaint failed'); + expect({ memory: f.read(), disk: f.disk(), assets: f.assets(), undo: f.manager.canUndo() }).toEqual({ + memory: f.old, disk: f.old, assets: ['old', 'new'], undo: false, + }); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + }); + + it('restores Clear when application fails before recording the result', async () => { + const f = fixture(target); + mockRepaint.mockRejectedValueOnce(new Error('clear repaint failed')); + await expect(f.service.clearBake({ saveScene: false })).rejects.toThrow('clear repaint failed'); + expect({ memory: f.read(), dirty: f.manager.isDirty(), undo: f.manager.canUndo() }).toEqual({ memory: f.old, dirty: false, undo: false }); + expect(mockSave).not.toHaveBeenCalled(); + }); +}); + +describe('Lightmap first bake history', () => { + it('restores an empty binding on Undo and preserves later rebake and Clear records', async () => { + const f = fixture('lightmap'); + mockGetScene().getComponents(mockMeshRenderer)[0].bakeSettings.texture = null; + await f.service.bake({ saveScene: false }); + expect(mockUndo.beginRecording).toHaveBeenCalled(); + await f.manager.undo(); + expect(f.read().texture).toBeNull(); + await f.manager.redo(); + await f.service.bake({ saveScene: false }); + const latest = f.read(); + await f.service.clearBake({ saveScene: false }); + expect(f.read().texture).toBeNull(); + await f.manager.undo(); + expect(f.read()).toEqual(latest); + }); +}); diff --git a/src/core/scene/test/lightfx-saved-recording.test.ts b/src/core/scene/test/lightfx-saved-recording.test.ts new file mode 100644 index 000000000..e4de4022c --- /dev/null +++ b/src/core/scene/test/lightfx-saved-recording.test.ts @@ -0,0 +1,90 @@ +import { SceneUndoManager } from '../scene-process/service/undo/scene-undo-manager'; +import { finishSavedLightFXRecording, LightFXResultRetainedError } from '../scene-process/service/baking/lightfx/saved-recording'; + +function fixture() { + let data = 'old SH'; + let disk = data; + const events: string[] = []; + const manager = new SceneUndoManager({ snapshotAdapter: { + capture: () => new Map([['scene', data]]), + apply: snapshot => { data = snapshot.get('scene'); return { success: true }; }, + equals: (before, after) => before.get('scene') === after.get('scene'), + } }); + const undo = { + endRecording: async (id: string) => { events.push('record'); await manager.endRecording(id); }, + createCheckpoint: () => manager.createCheckpoint(), + markSaved: jest.fn(() => { events.push('mark'); manager.markSaved(); }), + }; + const save = async () => { events.push('save'); disk = data; manager.markSaved(); }; + const record = (value: string) => { const id = manager.beginRecording(['scene']); data = value; return id; }; + return { manager, undo, save, record, events, read: () => ({ data, disk, dirty: manager.isDirty() }) }; +} + +describe('LightFX result save baseline', () => { + it.each(['new baked SH', ''])('marks the completed saved result, Undo becomes dirty and Redo returns to saved (%s)', async value => { + const f = fixture(); + const id = f.record(value); + await finishSavedLightFXRecording(f.undo, id, f.save); + expect({ ...f.read(), events: f.events }).toEqual({ data: value, disk: value, dirty: false, events: ['record', 'save'] }); + await f.manager.undo(); + expect(f.read()).toEqual({ data: 'old SH', disk: value, dirty: true }); + await f.manager.redo(); + expect(f.read()).toEqual({ data: value, disk: value, dirty: false }); + }); + + it('leaves an explicitly unsaved result dirty and does not write disk', async () => { + const f = fixture(); + await finishSavedLightFXRecording(f.undo, f.record('new SH')); + expect({ ...f.read(), events: f.events }).toEqual({ data: 'new SH', disk: 'old SH', dirty: true, events: ['record'] }); + }); + + it('retains the result and Undo if saving fails before writing disk', async () => { + const f = fixture(); + const id = f.record('new SH'); + await expect(finishSavedLightFXRecording(f.undo, id, async () => { throw new Error('save failed'); })).rejects.toThrow('save failed'); + expect(f.read()).toEqual({ data: 'new SH', disk: 'old SH', dirty: true }); + expect(f.manager.hasActiveRecording()).toBe(false); + await f.manager.undo(); + expect(f.read()).toEqual({ data: 'old SH', disk: 'old SH', dirty: false }); + await f.manager.redo(); + expect(f.read()).toEqual({ data: 'new SH', disk: 'old SH', dirty: true }); + }); + + it('retains the saved result if the response fails after writing disk', async () => { + const f = fixture(); + await expect(finishSavedLightFXRecording(f.undo, f.record('new SH'), async () => { + await f.save(); + throw new Error('response lost'); + })).rejects.toBeInstanceOf(LightFXResultRetainedError); + expect(f.read()).toEqual({ data: 'new SH', disk: 'new SH', dirty: false }); + await f.manager.undo(); + expect(f.read()).toEqual({ data: 'old SH', disk: 'new SH', dirty: true }); + await f.manager.redo(); + expect(f.read()).toEqual({ data: 'new SH', disk: 'new SH', dirty: false }); + }); + + it('retains a recording if endRecording reports failure after pushing history', async () => { + const f = fixture(); + const undo = { ...f.undo, endRecording: async (id: string) => { + await f.undo.endRecording(id); + throw new Error('notification failed'); + } }; + await expect(finishSavedLightFXRecording(undo, f.record('new SH'), f.save)).rejects.toBeInstanceOf(LightFXResultRetainedError); + expect(f.events).toEqual(['record']); + expect(f.read()).toEqual({ data: 'new SH', disk: 'old SH', dirty: true }); + }); + + it('does not attempt saving when history cannot be captured', async () => { + const f = fixture(); + const undo = { ...f.undo, endRecording: async () => { throw new Error('capture failed'); } }; + await expect(finishSavedLightFXRecording(undo, f.record('new SH'), f.save)).rejects.toThrow('capture failed'); + expect(f.events).toEqual([]); + }); + + it('leaves no-op recordings clean and does not add a second saved mark', async () => { + const f = fixture(); + await finishSavedLightFXRecording(f.undo, f.record('old SH'), f.save); + expect(f.undo.markSaved).not.toHaveBeenCalled(); + expect(f.read()).toEqual({ data: 'old SH', disk: 'old SH', dirty: false }); + }); +}); diff --git a/src/core/scene/test/lightfx-scene-entrances.test.ts b/src/core/scene/test/lightfx-scene-entrances.test.ts new file mode 100644 index 000000000..3ec2aa74b --- /dev/null +++ b/src/core/scene/test/lightfx-scene-entrances.test.ts @@ -0,0 +1,115 @@ +const mockGetScene = jest.fn(); +jest.mock('cc', () => ({ director: { getScene: mockGetScene } })); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { cancel: jest.fn(), canCancel: jest.fn(() => false) } })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + queryCapabilities: jest.fn(), + reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), + releaseSceneOperation: jest.fn(async () => undefined), +} })); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: jest.fn() })); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); +jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); + +import { LightProbeBakeService } from '../scene-process/service/light-probe-bake'; +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; +import { lightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; +import { lightFXBakeHost } from '../scene-process/service/baking/lightfx/host'; +import { lightFXCoordinator } from '../scene-process/service/baking/lightfx/baker'; + +describe('LightFX service entrance ownership', () => { + it('advertises directory selection only when the actual host supports it', async () => { + for (const supported of [false, true]) { + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false, ...(supported ? { lightmapOutputDirectory: true as const } : {}) }); + expect((await new LightmapBakeService().queryCapabilities()).outputDirectory).toBe(supported ? true : undefined); + } + }); + it('advertises exact asset cleanup only when the actual host supports it', async () => { + for (const supported of [false, true]) { + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy: false, ...(supported ? { lightmapAssetCleanupVersion: 1 as const } : {}) }); + expect((await new LightmapBakeService().queryCapabilities()).assetCleanupVersion).toBe(supported ? 1 : undefined); + } + }); + it.each([false, true])('advertises actual Lightmap cancellation readiness (%s)', async cancellable => { + jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, cancelOwnershipVersion: 1, busy: true }); + await expect(new LightmapBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, cancelVersion: 1, cancellable, busy: true, + }); + expect(lightFXCoordinator.canCancel).toHaveBeenLastCalledWith('lightmap'); + }); + it.each([false, true])('advertises actual probe cancellation readiness (%s)', async cancellable => { + jest.mocked(lightFXCoordinator.canCancel).mockReturnValueOnce(cancellable); + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, cancelOwnershipVersion: 1, busy: true }); + await expect(new LightProbeBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, cancelVersion: 1, cancellable, busy: true, + }); + }); + it('passes the actual service target to cancellation', async () => { + await new LightProbeBakeService().cancel(); + expect(lightFXCoordinator.cancel).toHaveBeenLastCalledWith('light-probe'); + await new LightmapBakeService().cancel(); + expect(lightFXCoordinator.cancel).toHaveBeenLastCalledWith('lightmap'); + }); + it.each([false, true])('queries Lightmap lifecycle and actual host asset support without reserving (busy=%s)', async busy => { + jest.mocked(lightFXBakeHost.queryCapabilities).mockResolvedValueOnce({ sceneTransactionVersion: 1, lightmapAssetVersion: 1, busy }); + const reserveCalls = jest.mocked(lightFXBakeHost.reserveSceneOperation).mock.calls.length; + await expect(new LightmapBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, assetVersion: 1, busy, + }); + expect(lightFXBakeHost.reserveSceneOperation).toHaveBeenCalledTimes(reserveCalls); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it('rejects Lightmap capability on a legacy, mismatched or unreachable host', async () => { + const query = jest.mocked(lightFXBakeHost.queryCapabilities); + for (const value of [null, {}, { sceneTransactionVersion: 1, busy: false }, { sceneTransactionVersion: 1, lightmapAssetVersion: 2, busy: false }, { sceneTransactionVersion: 2, lightmapAssetVersion: 1, busy: false }, { sceneTransactionVersion: 1, lightmapAssetVersion: 1 }]) { + query.mockResolvedValueOnce(value as Awaited>); + await expect(new LightmapBakeService().queryCapabilities()).rejects.toThrow('protocol version 1'); + } + query.mockRejectedValueOnce(new Error('Disconnected')); + await expect(new LightmapBakeService().queryCapabilities()).rejects.toThrow('Disconnected'); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it.each([false, true])('queries the actual host without taking a reservation (busy=%s)', async (busy) => { + const query = jest.mocked(lightFXBakeHost.queryCapabilities); + query.mockResolvedValueOnce({ sceneTransactionVersion: 1, busy }); + const reserveCalls = jest.mocked(lightFXBakeHost.reserveSceneOperation).mock.calls.length; + await expect(new LightProbeBakeService().queryCapabilities()).resolves.toEqual({ + version: 1, resultLifecycleVersion: 1, sceneTransactionVersion: 1, busy, + }); + expect(lightFXBakeHost.reserveSceneOperation).toHaveBeenCalledTimes(reserveCalls); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it('does not advertise support when the host query fails or returns an incompatible protocol', async () => { + const query = jest.mocked(lightFXBakeHost.queryCapabilities); + query.mockRejectedValueOnce(new Error('Method queryCapabilities is not available')); + await expect(new LightProbeBakeService().queryCapabilities()).rejects.toThrow('not available'); + for (const value of [null, {}, { sceneTransactionVersion: 2, busy: false }, { sceneTransactionVersion: 1 }]) { + query.mockResolvedValueOnce(value as Awaited>); + await expect(new LightProbeBakeService().queryCapabilities()).rejects.toThrow('protocol version 1'); + } + }); + + it('rejects all four entrances before querying scene, snapshotting or rolling back another owner', async () => { + const probe = new LightProbeBakeService(); + const lightmap = new LightmapBakeService(); + await lightFXSceneOperation.run('lightmap', 'clear', async () => { + for (const invoke of [() => probe.bake(), () => probe.clearBake(), () => lightmap.bake(), () => lightmap.clearBake()]) { + await expect(invoke()).rejects.toThrow('lightmap LightFX clear operation is already in progress'); + } + }); + expect(mockGetScene).not.toHaveBeenCalled(); + }); + + it('releases service preflight failures so all following entrances may run', async () => { + mockGetScene.mockReturnValue(null); + const probe = new LightProbeBakeService(); + const lightmap = new LightmapBakeService(); + for (const invoke of [() => probe.bake(), () => probe.clearBake(), () => lightmap.bake(), () => lightmap.clearBake()]) { + await expect(invoke()).rejects.toThrow('No scene is currently open.'); + } + expect(mockGetScene).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/core/scene/test/lightfx-scene-operation.test.ts b/src/core/scene/test/lightfx-scene-operation.test.ts new file mode 100644 index 000000000..dedaf1807 --- /dev/null +++ b/src/core/scene/test/lightfx-scene-operation.test.ts @@ -0,0 +1,104 @@ +import { LightFXSceneOperation } from '../scene-process/service/baking/lightfx/scene-operation'; + +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ + lightFXBakeHost: { + reserveSceneOperation: jest.fn(async () => ({ transactionId: 'test-owner' })), + releaseSceneOperation: jest.fn(async () => undefined), + }, +})); + +const targets = ['light-probe', 'lightmap'] as const; +const actions = ['bake', 'clear'] as const; + +describe('LightFX scene-local transactions', () => { + it('captures request context synchronously and releases the local guard when capture fails', async () => { + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner' })), releaseSceneOperation: jest.fn(async () => undefined) }; + const guard = new LightFXSceneOperation(host); + const operation = jest.fn(async () => 1); + const capture = jest.fn(() => { throw new Error('No source scene'); }); + const failed = guard.run('lightmap', 'clear', operation, capture); + expect(capture).toHaveBeenCalledTimes(1); + await expect(failed).rejects.toThrow('No source scene'); + expect(host.reserveSceneOperation).not.toHaveBeenCalled(); + expect(host.releaseSceneOperation).not.toHaveBeenCalled(); + expect(operation).not.toHaveBeenCalled(); + await expect(guard.run('lightmap', 'clear', operation)).resolves.toBe(1); + }); + + it('does not run scene code or release another owner when the host rejects reservation', async () => { + const host = { reserveSceneOperation: jest.fn(async () => { throw new Error('Host busy'); }), releaseSceneOperation: jest.fn() }; + const operation = jest.fn(); + await expect(new LightFXSceneOperation(host).run('light-probe', 'clear', operation)).rejects.toThrow('Host busy'); + expect(operation).not.toHaveBeenCalled(); + expect(host.releaseSceneOperation).not.toHaveBeenCalled(); + }); + + it('holds local ownership until the host release finishes and passes the exact token', async () => { + let release!: () => void; + const releasing = new Promise(resolve => { release = resolve; }); + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner-A' })), releaseSceneOperation: jest.fn(() => releasing) }; + const guard = new LightFXSceneOperation(host); + const current = guard.run('light-probe', 'bake', async () => guard.hostTransactionId); + await Promise.resolve(); await Promise.resolve(); + await expect(guard.run('lightmap', 'clear', async () => 0)).rejects.toThrow('already in progress'); + expect(host.releaseSceneOperation).toHaveBeenCalledWith({ transactionId: 'owner-A' }); + release(); + await expect(current).resolves.toBe('owner-A'); + expect(() => guard.hostTransactionId).toThrow('No LightFX scene transaction'); + }); + + it('reports release failure without claiming successful completion', async () => { + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner-A' })), releaseSceneOperation: jest.fn(async () => { throw new Error('Cleanup pending'); }) }; + await expect(new LightFXSceneOperation(host).run('lightmap', 'clear', async () => 1)).rejects.toThrow('Cleanup pending'); + }); + + it('preserves the scene failure when host release also fails', async () => { + const host = { reserveSceneOperation: jest.fn(async () => ({ transactionId: 'owner-A' })), releaseSceneOperation: jest.fn(async () => { throw new Error('Rollback pending'); }) }; + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(new LightFXSceneOperation(host).run('light-probe', 'bake', async () => { throw new Error('Scene apply failed'); })).rejects.toThrow('Scene apply failed'); + expect(host.releaseSceneOperation).toHaveBeenCalledWith({ transactionId: 'owner-A' }); + expect(errorLog).toHaveBeenCalled(); + } finally { errorLog.mockRestore(); } + }); + + for (const target of targets) for (const action of actions) { + it(`${target} ${action} excludes all four entrances until the entire transaction settles`, async () => { + const guard = new LightFXSceneOperation(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const current = guard.run(target, action, async () => { await held; return 42; }); + const rejected = jest.fn(async () => 0); + for (const otherTarget of targets) for (const otherAction of actions) { + await expect(guard.run(otherTarget, otherAction, rejected)).rejects.toThrow(`${target} LightFX ${action}`); + } + expect(rejected).not.toHaveBeenCalled(); + release(); + await expect(current).resolves.toBe(42); + await expect(guard.run('light-probe', 'clear', async () => 7)).resolves.toBe(7); + }); + } + + it('reserves before the first await and keeps ownership during asynchronous failure cleanup', async () => { + const guard = new LightFXSceneOperation(); + let finishCleanup!: () => void; + const cleanup = new Promise(resolve => { finishCleanup = resolve; }); + const current = guard.run('light-probe', 'bake', async () => { + try { + await expect(guard.run('lightmap', 'clear', async () => 1)).rejects.toThrow('already in progress'); + throw new Error('Bake failed'); + } finally { await cleanup; } + }); + const failure = expect(current).rejects.toThrow('Bake failed'); + await expect(guard.run('light-probe', 'clear', async () => 1)).rejects.toThrow('already in progress'); + finishCleanup(); + await failure; + await expect(guard.run('lightmap', 'clear', async () => 2)).resolves.toBe(2); + }); + + it('releases a synchronous exception without masking it', async () => { + const guard = new LightFXSceneOperation(); + await expect(guard.run('lightmap', 'bake', () => { throw new Error('Prepare failed'); })).rejects.toThrow('Prepare failed'); + await expect(guard.run('lightmap', 'bake', async () => true)).resolves.toBe(true); + }); +}); diff --git a/src/core/scene/test/lightmap-bake-info.test.ts b/src/core/scene/test/lightmap-bake-info.test.ts new file mode 100644 index 000000000..4a408fecb --- /dev/null +++ b/src/core/scene/test/lightmap-bake-info.test.ts @@ -0,0 +1,95 @@ +const mockGetScene = jest.fn(); +const mockQueryLightmapTextureInfo = jest.fn(); +const mockMeshRenderer = class MeshRenderer {}; +const mockTerrain = class Terrain {}; + +jest.mock('cc', () => ({ + director: { getScene: mockGetScene }, + MeshRenderer: mockMeshRenderer, + Scene: class Scene {}, + Terrain: mockTerrain, + Texture2D: class Texture2D {}, +})); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ + lightFXCoordinator: {}, +})); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ + lightFXBakeHost: { + queryLightmapTextureInfo: (...args: unknown[]) => mockQueryLightmapTextureInfo(...args), + }, +})); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ + createDefaultLightFXSettings: jest.fn(), +})); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ + loadPreviewAsset: jest.fn(), +})); +jest.mock('../scene-process/rpc', () => ({ + Rpc: { getInstance: jest.fn() }, +})); + +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; + +function node(models: unknown[] = [], terrains: unknown[] = [], children: unknown[] = []) { + return { + children, + getComponents: jest.fn((type) => type === mockMeshRenderer ? models : type === mockTerrain ? terrains : []), + }; +} + +describe('LightmapBakeService bake information', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('queries unique texture metadata from the current scene bindings', async () => { + const meshTexture = { uuid: '11111111-1111-4111-8111-111111111111@6c48a' }; + const terrainTexture = { uuid: '22222222-2222-4222-8222-222222222222@6c48a' }; + const scene = { + ...node([], [], [ + node([ + { bakeSettings: { texture: meshTexture }, get mesh() { throw new Error('Result queries must not scan bake inputs'); } }, + { bakeSettings: { texture: meshTexture } }, + ]), + node([], [{ + _lightmapInfos: [ + { texture: meshTexture }, + { texture: terrainTexture }, + ], + }]), + ]), + globals: { + bakedWithHighpLightmap: true, + bakedWithStationaryMainLight: false, + }, + }; + mockGetScene.mockReturnValue(scene); + const textureInfo = { + textures: [{ + uuid: '11111111-1111-4111-8111-111111111111', + url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + filename: 'LFX_Mesh_0000.png', + size: 128, + createdAt: 1, + modifiedAt: 2, + }], + missingTextureUuids: ['22222222-2222-4222-8222-222222222222'], + }; + mockQueryLightmapTextureInfo.mockResolvedValue(textureInfo); + const service = new LightmapBakeService(); + jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/Lightmap.scene'); + + await expect(service.queryBakeInfo()).resolves.toEqual({ + sceneUrl: 'db://assets/Lightmap.scene', + baked: true, + meshCount: 2, + terrainCount: 1, + highp: true, + stationaryMainLight: false, + ...textureInfo, + }); + expect(mockQueryLightmapTextureInfo).toHaveBeenCalledWith({ + uuids: [meshTexture.uuid, terrainTexture.uuid], + }); + }); +}); diff --git a/src/core/scene/test/lightmap-result-recording.test.ts b/src/core/scene/test/lightmap-result-recording.test.ts new file mode 100644 index 000000000..54f559e57 --- /dev/null +++ b/src/core/scene/test/lightmap-result-recording.test.ts @@ -0,0 +1,407 @@ +const mockGetScene = jest.fn(); +const mockMeshRenderer = class MeshRenderer {}; +const mockTerrain = class Terrain {}; +const mockBake = jest.fn(); +const mockCommit = jest.fn(); +const mockRollback = jest.fn(); +const mockRemoveLightmapAssets = jest.fn(); +const mockPublishLightmapAssets = jest.fn(); +const mockQueryCapabilities = jest.fn(async (): Promise<{ lightmapAssetCleanupVersion?: 1; lightmapRebakeCleanupVersion?: 1; lightmapPublicationVersion?: 1; lightmapAuxiliaryAssetsVersion?: 1 }> => ({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 })); +const mockUndo = { + beginRecording: jest.fn(() => 'recording'), + endRecording: jest.fn(async () => undefined), + cancelRecording: jest.fn(), + clearHistory: jest.fn(), + createCheckpoint: jest.fn(() => ({ commandId: 'recording', generation: 1 })), + markSaved: jest.fn(), +}; +const mockSave = jest.fn(async () => undefined); +const mockQuerySceneSerializedData = jest.fn(async () => '[]'); +const mockQueryTextureInfo = jest.fn(async (): Promise<{ textures: []; missingTextureUuids: []; ownedTextureUuids?: string[] }> => ({ textures: [], missingTextureUuids: [] })); +jest.mock('cc', () => ({ director: { getScene: mockGetScene }, MeshRenderer: mockMeshRenderer, Terrain: mockTerrain })); +jest.mock('../scene-process/service/core', () => ({ + BaseService: class { broadcast() {} }, register: () => () => undefined, + Service: { Undo: mockUndo, Editor: { + save: mockSave, querySceneSerializedData: mockQuerySceneSerializedData, + getEditorSession: () => ({ uuid: mockGetScene()?.uuid, generation: 0 }), + isCurrentEditorSession: (session: any) => session.uuid === mockGetScene()?.uuid, + runForSession: async (_session: any, action: any) => action(mockSave), + }, Engine: { repaintInEditMode: async () => undefined } }, +})); +jest.mock('../scene-process/service/baking/lightfx/baker', () => ({ lightFXCoordinator: { bake: mockBake, commit: mockCommit, rollback: mockRollback, removeLightmapAssets: mockRemoveLightmapAssets, publishLightmapAssets: mockPublishLightmapAssets } })); +jest.mock('../scene-process/service/baking/lightfx/host', () => ({ lightFXBakeHost: { + queryLightmapTextureInfo: mockQueryTextureInfo, + queryCapabilities: mockQueryCapabilities, + reserveSceneOperation: async () => ({ transactionId: 'owner' }), releaseSceneOperation: async () => undefined, +} })); +jest.mock('../scene-process/service/baking/lightfx/settings', () => ({ createDefaultLightFXSettings: () => ({}) })); +jest.mock('../scene-process/service/preview/asset-reload', () => ({ loadPreviewAsset: jest.fn() })); +jest.mock('../scene-process/rpc', () => ({ Rpc: { getInstance: jest.fn() } })); +import { LightmapBakeService } from '../scene-process/service/lightmap-bake'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; + +function fixture() { + const oldTexture = { uuid: 'old-texture' }; + const model = { uuid: 'mesh', node: {}, bakeSettings: { texture: oldTexture, uvParam: { clone: () => ({ x: 1, y: 2, z: 3, w: 4 }) } }, _updateLightmap: jest.fn() }; + const terrain = { uuid: 'terrain', lightMapSize: 64, _lightmapInfos: [ + { texture: oldTexture, UOff: 1, VOff: 2, UScale: 3, VScale: 4 }, + { texture: oldTexture, UOff: 5, VOff: 6, UScale: 7, VScale: 8 }, + ], getBlocks: () => [{}, {}], _resetLightmap: jest.fn(), _updateLightmap: jest.fn() }; + const scene = { uuid: 'scene', name: 'test', globals: { bakedWithHighpLightmap: false, bakedWithStationaryMainLight: false }, + children: [], getComponents: (type: unknown) => type === mockMeshRenderer ? [model] : type === mockTerrain ? [terrain] : [], + }; + mockGetScene.mockReturnValue(scene); + const service = new LightmapBakeService(); + jest.spyOn(service as any, 'querySceneUrl').mockResolvedValue('db://assets/test.scene'); + const texture = { uuid: 'new-texture' }; + jest.spyOn(service as any, 'loadOutputTextures').mockResolvedValue(new Map([['mesh:0', texture], ['terrain:0', texture]])); + mockBake.mockResolvedValue({ models: [model], terrains: [terrain], operationId: 'operation', stationaryMainLight: true, textureUrls: [], result: { + meshes: [{ id: 0, index: 0, offset: [0.1, 0.2], scale: [0.3, 0.4] }], + terrains: [{ id: 0, index: 0, blockId: 1, offset: [0.5, 0.6], scale: [0.7, 0.8] }], + } }); + return { service, scene, model, terrain, texture, oldTexture }; +} + +describe('Lightmap result recording targets', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockBake.mockReset(); + mockQueryCapabilities.mockResolvedValue({ lightmapAssetCleanupVersion: 1, lightmapRebakeCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapAuxiliaryAssetsVersion: 1 }); + mockPublishLightmapAssets.mockReset().mockResolvedValue({ textureUrls: ['db://assets/LightFX/output/LFX_Mesh_0000.png'] }); + mockQuerySceneSerializedData.mockResolvedValue('[]'); + mockQueryTextureInfo.mockReset().mockResolvedValue({ textures: [], missingTextureUuids: [] }); + mockRemoveLightmapAssets.mockResolvedValue({ deletedTextureUuids: [], retainedTextureUuids: [], failures: [] }); + }); + it.each([2591, 65535, 0, -1, 25.5, NaN, Infinity])('rejects unsafe GI samples %s before native work or result mutation', async giSamples => { + const f = fixture(); + await expect(f.service.bake({ giSamples })).rejects.toThrow('Lightmap GI Samples must be an integer between 1 and 2590'); + expect([mockQueryCapabilities, mockBake, mockSave, mockCommit, mockRemoveLightmapAssets, mockUndo.beginRecording, + f.model._updateLightmap, f.terrain._updateLightmap].map(mock => mock.mock.calls.length)).toEqual(Array(8).fill(0)); + expect(f.model.bakeSettings.texture).toBe(f.oldTexture); + // Rejection must not leave an operation owner or poison the next bake. + await f.service.bake({ giSamples: 25 }); + expect(mockBake).toHaveBeenCalledTimes(1); + }); + it.each([false, true])('records Mesh and Terrain components before scene flags for Bake (save=%s)', async saveScene => { + const f = fixture(); + await f.service.bake({ saveScene }); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Bake lightmap' }); + expect(f.model._updateLightmap).toHaveBeenCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, f.texture, 0.5, 0.6, 0.7, 0.8); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockCommit).toHaveBeenCalledWith('operation'); + expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); + expect(mockUndo.markSaved).not.toHaveBeenCalled(); + }); + it.each([false, true])('deduplicates multiple Terrain blocks and records all cleared bindings (save=%s)', async saveScene => { + const f = fixture(); + await expect(f.service.clearBake({ saveScene })).resolves.toEqual({ + clearedCount: 3, deletedAssetCount: 0, retainedAssetCount: 0, failedAssetCount: 0, + }); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Clear lightmap' }); + expect(f.model._updateLightmap).toHaveBeenCalledWith(null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenCalledWith(0, null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenCalledWith(1, null, 0, 0, 0, 0); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockSave).toHaveBeenCalledTimes(saveScene ? 1 : 0); + expect(mockUndo.markSaved).not.toHaveBeenCalled(); + }); + it('deletes previous and older unbound products only after the new result is saved', async () => { + const f = fixture(); + mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'old-texture', 'new-texture'] }); + const result = await f.service.bake(); + expect(result.textureUrls).toEqual(['db://assets/LightFX/output/LFX_Mesh_0000.png']); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['older-A', 'old-texture'], 'bake'); + expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + expect(mockRemoveLightmapAssets.mock.invocationCallOrder[0]).toBeLessThan(mockPublishLightmapAssets.mock.invocationCallOrder[0]); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + }); + it('keeps disk-dependent old pixels for an explicitly unsaved Bake, without retaining old result history', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-texture' } }, + } }); + await f.service.bake({ saveScene: false }); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(mockPublishLightmapAssets).not.toHaveBeenCalled(); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump').value.texture.value.uuid).toBe(''); + }); + it('does not revert saved bindings or remove new files on fixed publication failure', async () => { + const f = fixture(); + mockPublishLightmapAssets.mockRejectedValueOnce(new Error('target occupied')); + await expect(f.service.bake()).rejects.toThrow('fixed output publication was not completed'); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockRollback).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + }); + it('retains and reports old products referenced by non-Lightmap scene fields', async () => { + const f = fixture(); + mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify({ custom: { __uuid__: 'old-texture@f9941' } })); + await expect(f.service.bake()).rejects.toThrow('New Lightmap result is saved and retained'); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', [], 'bake'); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + }); + it.each(['referenced', 'failed', 'unknown', 'serialization'])('retains the saved new result on %s cleanup failure', async failure => { + const f = fixture(); + if (failure === 'serialization') mockQuerySceneSerializedData.mockRejectedValueOnce(new Error('serialization failed')); + else if (failure === 'unknown') mockRemoveLightmapAssets.mockRejectedValueOnce(new Error('response lost')); + else mockRemoveLightmapAssets.mockResolvedValueOnce({ deletedTextureUuids: [], + retainedTextureUuids: failure === 'referenced' ? ['old-texture'] : [], + failures: failure === 'failed' ? [{ uuid: 'old-texture', reason: 'permission denied' }] : [], + }); + await expect(f.service.bake()).rejects.toThrow('New Lightmap result is saved and retained'); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0.1, 0.2, 0.3, 0.4); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRollback).not.toHaveBeenCalled(); + }); + it('does not invalidate old history or delete pixels after an unconfirmed Bake save', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-texture' } }, + } }); + mockSave.mockRejectedValueOnce(new Error('save response lost')); + await expect(f.service.bake()).rejects.toThrow('result retained'); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump')).toBe(old); + // Failure releases the replacement capture scope, permitting a later retry. + await f.service.bake(); + }); + it.each(['capability', 'ownership'])('rejects unavailable %s before native Bake or scene mutation', async failure => { + const f = fixture(); + if (failure === 'capability') mockQueryCapabilities.mockResolvedValueOnce({ lightmapAssetCleanupVersion: 1 }); + else mockQueryTextureInfo.mockRejectedValueOnce(new Error('record unavailable')); + await expect(f.service.bake()).rejects.toThrow(); + expect(mockBake).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + }); + it('saves before exact deletion and cancels only the Clear recording', async () => { + const f = fixture(); + mockRemoveLightmapAssets.mockResolvedValueOnce({ + deletedTextureUuids: ['old-texture'], retainedTextureUuids: ['shared'], failures: [{ uuid: 'failed', reason: 'busy' }], + }); + await expect(f.service.clearBake({ deleteAssets: true })).resolves.toEqual({ + clearedCount: 3, deletedAssetCount: 1, retainedAssetCount: 1, failedAssetCount: 1, + }); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.endRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['old-texture']); + expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + expect(mockUndo.cancelRecording.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + }); + it('retains a generated texture still referenced elsewhere in the cleared scene', async () => { + const f = fixture(); + mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify([ + { __type__: 'cc.Component', unrelatedTexture: { __uuid__: 'old-texture@f9941' } }, + ])); + await expect(f.service.clearBake({ deleteAssets: true })).resolves.toEqual({ + clearedCount: 3, deletedAssetCount: 0, retainedAssetCount: 1, failedAssetCount: 0, + }); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', []); + }); + it('restores bindings without saving when the live scene reference check fails', async () => { + const f = fixture(); + mockQuerySceneSerializedData.mockRejectedValueOnce(new Error('serialization failed')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('serialization failed'); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.oldTexture, 1, 2, 3, 4); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, f.oldTexture, 5, 6, 7, 8); + }); + it('does not restore only memory or delete assets when recording cancellation fails after saving', async () => { + const f = fixture(); + mockUndo.cancelRecording.mockImplementationOnce(() => { throw new Error('notification failed'); }); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('notification failed'); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(mockUndo.cancelRecording).toHaveBeenCalledTimes(1); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); + }); + it.each(['deleted', 'retained', 'failed', 'unknown'])('protects in-flight deletes and settles %s results without clearing history', async outcome => { + const f = fixture(); + const snapshot = { type: 'cc.Texture2D', value: { uuid: 'old-texture@6c48a' } }; + mockRemoveLightmapAssets.mockImplementationOnce(async () => { + expect(deletedLightmapAssets.filter(f.scene, snapshot, 'dump').value.uuid).toBe(''); + if (outcome === 'unknown') throw new Error('response lost'); + return { + deletedTextureUuids: outcome === 'deleted' ? ['old-texture'] : [], + retainedTextureUuids: outcome === 'retained' ? ['old-texture'] : [], + failures: outcome === 'failed' ? [{ uuid: 'old-texture', reason: 'busy' }] : [], + }; + }); + if (outcome === 'unknown') await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('response lost'); + else await f.service.clearBake({ deleteAssets: true }); + expect(deletedLightmapAssets.filter(f.scene, snapshot, 'dump').value.uuid) + .toBe(outcome === 'deleted' || outcome === 'unknown' ? '' : snapshot.value.uuid); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + }); + it('clears known older products even after all current bindings were removed', async () => { + const f = fixture(); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'older-B'] }); + await f.service.clearBake({ deleteAssets: true }); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', ['older-A', 'older-B']); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + }); + it('retains older products used by non-baking fields in the live scene', async () => { + const f = fixture(); + mockQueryTextureInfo.mockResolvedValueOnce({ textures: [], missingTextureUuids: [], ownedTextureUuids: ['older-A', 'old-texture'] }); + mockQuerySceneSerializedData.mockResolvedValueOnce(JSON.stringify({ custom: { __uuid__: 'older-A@f9941' } })); + const result = await f.service.clearBake({ deleteAssets: true }); + expect([result.retainedAssetCount, mockRemoveLightmapAssets.mock.calls]).toEqual([1, [['scene', ['old-texture']]]]); + }); + it('fails before modifying the scene if generated-asset membership cannot be read', async () => { + const f = fixture(); + mockQueryTextureInfo.mockRejectedValueOnce(new Error('record unavailable')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('record unavailable'); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + }); + it('keeps history when there are no texture candidates', async () => { + const f = fixture(); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + await f.service.clearBake({ deleteAssets: true }); + expect(mockUndo.clearHistory).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).toHaveBeenCalledWith('scene', []); + }); + it('counts auxiliary deletion and retention even after texture bindings were already cleared', async () => { + const f = fixture(); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + mockRemoveLightmapAssets.mockResolvedValueOnce({ deletedTextureUuids: [], retainedTextureUuids: [], + deletedAuxiliaryAssetUuids: ['input', 'log'], retainedAuxiliaryAssetUuids: ['output'], failures: [] }); + await expect(f.service.clearBake({ deleteAssets: true })).resolves.toEqual({ + clearedCount: 0, deletedAssetCount: 2, retainedAssetCount: 1, failedAssetCount: 0, + }); + expect(mockSave.mock.invocationCallOrder[0]).toBeLessThan(mockRemoveLightmapAssets.mock.invocationCallOrder[0]); + }); + it('rejects a host lacking auxiliary cleanup before changing bindings or saving', async () => { + const f = fixture(); + mockQueryCapabilities.mockResolvedValueOnce({ lightmapAssetCleanupVersion: 1, lightmapPublicationVersion: 1, lightmapRebakeCleanupVersion: 1 }); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('Restart the Cocos host'); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + }); + it('replaces excluded objects old bindings, recording them for normal rebake Undo', async () => { + const f = fixture(); + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + await f.service.bake({ saveScene: false }); + expect(mockUndo.beginRecording).toHaveBeenCalledWith(['mesh', 'terrain', 'scene'], { label: 'Bake lightmap' }); + expect(f.terrain._updateLightmap.mock.calls).toEqual([[0, null, 0, 0, 0, 0], [1, null, 0, 0, 0, 0]]); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(f.texture, 0, 0, 1, 1); + }); + it.each(['bake', 'clear', 'delete'] as const)('clears serialized bindings of a reopened inactive Terrain during %s without requiring runtime blocks', async operation => { + const f = fixture(); + f.terrain.getBlocks = () => []; + // Match the engine precondition: calling this setter without a block fails. + f.terrain._updateLightmap.mockImplementation(() => { throw new Error('unbuilt terrain block'); }); + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + if (operation === 'bake') await f.service.bake({ saveScene: false }); + else await f.service.clearBake({ deleteAssets: operation === 'delete' }); + expect(f.terrain._lightmapInfos).toEqual([ + { texture: null, UOff: 0, VOff: 0, UScale: 0, VScale: 0 }, + { texture: null, UOff: 0, VOff: 0, UScale: 0, VScale: 0 }, + ]); + expect(f.terrain._updateLightmap).not.toHaveBeenCalled(); + }); + it.each(['bake', 'clear'] as const)('restores every inactive Terrain block on %s failure without masking the original error', async operation => { + const f = fixture(); + f.terrain.getBlocks = () => []; + const previous = f.terrain._lightmapInfos.map(info => ({ ...info })); + f.terrain._updateLightmap.mockImplementation(() => { throw new Error('unbuilt terrain block'); }); + if (operation === 'bake') { + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + f.model._updateLightmap.mockImplementationOnce(() => {}).mockImplementationOnce(() => { throw new Error('apply failed'); }); + await expect(f.service.bake({ saveScene: false })).rejects.toThrow('apply failed'); + } else { + mockQuerySceneSerializedData.mockRejectedValueOnce(new Error('serialization failed')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('serialization failed'); + } + expect(f.terrain._lightmapInfos).toEqual(previous); + expect(f.terrain._updateLightmap).not.toHaveBeenCalled(); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + }); + it('establishes a Clear history barrier even when current results were already unbound', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-A' } }, + } }); + f.model.bakeSettings.texture = null as any; + f.terrain._lightmapInfos = []; + await f.service.clearBake({ deleteAssets: true }); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump').value.texture.value.uuid).toBe(''); + }); + it('does not invalidate history if Clear cannot save the scene', async () => { + const f = fixture(); + const old = deletedLightmapAssets.capture(f.scene, { type: 'cc.ModelBakeSettings', value: { + texture: { type: 'cc.Texture2D', value: { uuid: 'old-A' } }, + } }); + mockSave.mockRejectedValueOnce(new Error('save failed')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow(); + expect(deletedLightmapAssets.filter(f.scene, old, 'dump')).toBe(old); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('restores excluded objects old bindings if applying the new Bake fails', async () => { + const f = fixture(); + mockBake.mockResolvedValueOnce({ models: [f.model], terrains: [], operationId: 'operation', stationaryMainLight: false, + textureUrls: [], result: { meshes: [{ id: 0, index: 0, offset: [0, 0], scale: [1, 1] }], terrains: [] } }); + f.model._updateLightmap.mockImplementationOnce(() => {}).mockImplementationOnce(() => { throw new Error('apply failed'); }); + await expect(f.service.bake({ saveScene: false })).rejects.toThrow('apply failed'); + expect(f.terrain._updateLightmap.mock.calls.slice(-2)).toEqual([ + [0, f.oldTexture, 1, 2, 3, 4], [1, f.oldTexture, 5, 6, 7, 8], + ]); + expect(mockUndo.cancelRecording).toHaveBeenCalledWith('recording'); + }); + it('rejects deletion without saving before changing the scene', async () => { + const f = fixture(); + await expect(f.service.clearBake({ saveScene: false, deleteAssets: true })).rejects.toThrow('deleteAssets requires saveScene'); + expect(f.model._updateLightmap).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('rejects a legacy cleanup host before changing the scene', async () => { + const f = fixture(); + mockQueryCapabilities.mockResolvedValueOnce({}); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('does not support exact Lightmap asset cleanup'); + expect(f.model._updateLightmap).not.toHaveBeenCalled(); + expect(mockUndo.beginRecording).not.toHaveBeenCalled(); + expect(mockSave).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('does not delete assets when the required save is unconfirmed', async () => { + const f = fixture(); + mockSave.mockRejectedValueOnce(new Error('save response lost')); + await expect(f.service.clearBake({ deleteAssets: true })).rejects.toThrow('result retained'); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockRemoveLightmapAssets).not.toHaveBeenCalled(); + }); + it('retains cleared bindings and history if saving fails', async () => { + const f = fixture(); + mockSave.mockRejectedValueOnce(new Error('disk unavailable')); + await expect(f.service.clearBake()).rejects.toThrow('disk unavailable'); + expect(mockUndo.cancelRecording).not.toHaveBeenCalled(); + expect(mockUndo.endRecording).toHaveBeenCalledWith('recording'); + expect(mockUndo.markSaved).not.toHaveBeenCalled(); + expect(f.model._updateLightmap).toHaveBeenLastCalledWith(null, 0, 0, 0, 0); + expect(f.terrain._updateLightmap).toHaveBeenLastCalledWith(1, null, 0, 0, 0, 0); + }); +}); diff --git a/src/core/scene/test/service-core/message-callsite.test.ts b/src/core/scene/test/service-core/message-callsite.test.ts index fdc198ba5..2f08e3e09 100644 --- a/src/core/scene/test/service-core/message-callsite.test.ts +++ b/src/core/scene/test/service-core/message-callsite.test.ts @@ -289,7 +289,7 @@ describe('ServiceEvents 事件发射集成测试', () => { ); }); - it('forwards all LightProbe facade operations with the expected method names and parameters', () => { + it('forwards all LightProbe facade operations with the expected method names and parameters', async () => { const exec = jest.fn((name: string, method: string) => { if (method === 'getEditMode') return 'none'; if (method === 'getSelectedProbeCount') return 3; @@ -308,8 +308,8 @@ describe('ServiceEvents 事件发射集成测试', () => { gizmoService.selectAllLightProbes(); gizmoService.unselectAllLightProbes(); expect(gizmoService.queryLightProbeSelectedCount()).toBe(3); - expect(gizmoService.deleteSelectedLightProbes()).toBe(1); - expect(gizmoService.duplicateSelectedLightProbes()).toBe(2); + await expect(gizmoService.deleteSelectedLightProbes()).resolves.toBe(1); + await expect(gizmoService.duplicateSelectedLightProbes()).resolves.toBe(2); expect(gizmoService.regionSelectLightProbes(1, 2, 3, 4, true)).toBe(4); expect(gizmoService.generateLightProbes()).toBe(2); @@ -611,6 +611,34 @@ describe('ServiceEvents 事件发射集成测试', () => { expect(listener).toHaveBeenCalledTimes(1); }); + + it.each(['reported', 'thrown'])('does not write the scene or mark it saved after a %s Terrain failure', async failure => { + const { SceneEditor } = require('../../scene-process/service/editors'); + const core = require('../../scene-process/service/core/decorator'); + const terrain = { saveAsset: jest.fn(async () => { + if (failure === 'thrown') throw new Error('Terrain storage failed'); + return 2; + }) }; + const query = jest.spyOn(core, 'queryRegisteredService').mockReturnValue(terrain); + const markSaved = jest.spyOn(editorService, '_markUndoSaved'); + const listener = jest.fn(); + globalEventEmitter.on('editor:save', listener); + const uuid = 'terrain-save-failed-scene'; + const editor = Object.assign(Object.create(SceneEditor.prototype), { save: jest.fn() }); + editorService.editorMap.set(uuid, editor); + editorService.currentEditorUuid = uuid; + mockRpcRequest.mockResolvedValueOnce({ uuid, url: 'test.scene', type: 'scene' }); + try { + await expect(editorService.save({})).rejects.toThrow(failure === 'thrown' ? 'Terrain storage failed' : 'Terrain asset save failed'); + expect(terrain.saveAsset).toHaveBeenCalledWith(false); + expect(editor.save).not.toHaveBeenCalled(); + expect(markSaved).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + } finally { + query.mockRestore(); + markSaved.mockRestore(); + } + }); }); // ── NodeService: setProperty(name) → ServiceEvents ── diff --git a/src/core/scene/test/terrain-lightmap-restore.test.ts b/src/core/scene/test/terrain-lightmap-restore.test.ts new file mode 100644 index 000000000..b6da86c33 --- /dev/null +++ b/src/core/scene/test/terrain-lightmap-restore.test.ts @@ -0,0 +1,33 @@ +import { restoreTerrainLightmapBindings } from '../scene-process/service/dump/terrain-lightmap-restore'; + +describe('Terrain lightmap snapshot rendering', () => { + const dump = { type: 'cc.Terrain', value: { _lightmapInfos: {} } }; + function fixture(infos: unknown[]) { + const blocks = [{ _updateLightmap: jest.fn() }, { _updateLightmap: jest.fn() }]; + return { component: { _lightmapInfos: infos, getBlocks: () => blocks }, blocks }; + } + it('rebinds every block to the restored entry without copying or changing the serialized array', () => { + const infos = [{ texture: 'A' }, { texture: 'B' }]; + const f = fixture(infos); + restoreTerrainLightmapBindings(f.component, dump); + expect(f.blocks[0]._updateLightmap).toHaveBeenCalledWith(infos[0]); + expect(f.blocks[1]._updateLightmap).toHaveBeenCalledWith(infos[1]); + expect(f.component._lightmapInfos).toBe(infos); + expect(infos).toEqual([{ texture: 'A' }, { texture: 'B' }]); + }); + it.each([[[]], [[{ texture: null }]]])('explicitly unbinds missing entries after restoring %p', infos => { + const f = fixture(infos); + restoreTerrainLightmapBindings(f.component, dump); + expect(f.blocks[0]._updateLightmap).toHaveBeenCalledWith(infos[0] ?? null); + expect(f.blocks[1]._updateLightmap).toHaveBeenCalledWith(null); + }); + it('handles derived terrain components and not-yet-built terrain', () => { + const f = fixture([]); + restoreTerrainLightmapBindings(f.component, { ...dump, type: 'CustomTerrain', extends: ['cc.Terrain'] }); + expect(f.blocks[0]._updateLightmap).toHaveBeenCalledWith(null); + expect(() => restoreTerrainLightmapBindings({ _lightmapInfos: [], getBlocks: () => [] }, dump)).not.toThrow(); + }); + it.each([{ type: 'cc.MeshRenderer', value: { _lightmapInfos: {} } }, { type: 'cc.Terrain', value: {} }, { type: 'cc.Terrain' }])('does not touch unrelated or partial snapshots (%p)', other => { + restoreTerrainLightmapBindings({}, other); + }); +}); diff --git a/src/core/scene/test/terrain-service.test.ts b/src/core/scene/test/terrain-service.test.ts index 79142ae8d..63fad89f1 100644 --- a/src/core/scene/test/terrain-service.test.ts +++ b/src/core/scene/test/terrain-service.test.ts @@ -832,6 +832,38 @@ describe('TerrainService target-safe public capability', () => { consoleError.mockRestore(); }); + it.each(['saveAsset', 'saveAssetDialog'] as const)('%s preserves an earlier failure when a later terrain saves', async method => { + const failed = createFixture('failed', 'failed-terrain'); + const saved = createFixture('saved', 'saved-terrain'); + for (const f of [failed, saved]) { + (f.terrain as any)._asset = { _uuid: f.target.componentUuid }; + (f.terrain as any).isTerrainChange = true; + } + mockAssetBinarySave.mockResolvedValueOnce(null).mockResolvedValueOnce({ uuid: saved.target.componentUuid }); + const service = new TerrainService(); + service.editedComponents.push(failed.terrain, saved.terrain); + jest.spyOn(service, 'serialize').mockReturnValue(new Uint8Array([1])); + await expect(service[method]()).resolves.toBe(2); + expect({ failedDirty: (failed.terrain as any).isTerrainChange, savedDirty: (saved.terrain as any).isTerrainChange }).toEqual({ failedDirty: true, savedDirty: false }); + }); + + it('does not hide a failed existing terrain when a later unsaved terrain is created', async () => { + const failed = createFixture('failed', 'failed-terrain'); + const created = createFixture('created', 'created-terrain'); + (failed.terrain as any)._asset = { _uuid: 'failed' }; + (created.terrain as any)._asset = null; + (failed.terrain as any).isTerrainChange = true; + (created.terrain as any).isTerrainChange = true; + mockAssetBinarySave.mockResolvedValue(null); + mockAssetBinaryCreate.mockResolvedValue({ uuid: 'created' }); + mockLoadAny.mockResolvedValue({ _uuid: 'created' }); + const service = new TerrainService(); + service.editedComponents.push(failed.terrain, created.terrain); + jest.spyOn(service, 'serialize').mockReturnValue(new Uint8Array([1])); + await expect(service.saveAssetDialog('db://assets/created.terrain')).resolves.toBe(2); + expect({ failedDirty: (failed.terrain as any).isTerrainChange, createdDirty: (created.terrain as any).isTerrainChange }).toEqual({ failedDirty: true, createdDirty: false }); + }); + it('creates a Terrain asset through the binary client using the requested db:// target', async () => { const fixture = createFixture(); (fixture.terrain as any)._asset = null; diff --git a/src/core/scene/test/undo-node-restore.test.ts b/src/core/scene/test/undo-node-restore.test.ts index 93d1cd1ea..6a218cf0a 100644 --- a/src/core/scene/test/undo-node-restore.test.ts +++ b/src/core/scene/test/undo-node-restore.test.ts @@ -6,6 +6,7 @@ import { NODE_SNAPSHOT_RESTORE_PROPERTY_PATHS, COMPONENT_SNAPSHOT_RESTORE_SKIP_KEYS, } from '../scene-process/service/dump/restore-policy'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; // 模拟 dump 模块,让 restoreNodeSnapshotDump / restoreComponentSnapshotDump // 可以调用 dumpUtil 方法,同时避免加载依赖真实引擎环境的 dump 模块。 @@ -69,6 +70,46 @@ describe('restoreComponentSnapshotDump', () => { mockRestoreComponentSnapshotProperties.mockReset(); }); + it('filters deleted references at the actual component restore boundary without changing other properties', async () => { + const scene = {}, component = { node: { scene }, onRestore: jest.fn() }; + const dump = { value: { enabled: { value: true }, texture: { type: 'cc.Texture2D', value: { uuid: 'deleted@6c48a' } } } }; + deletedLightmapAssets.begin(scene, ['deleted'])(['deleted']); + await restoreComponentSnapshotDump(component as any, dump); + expect(mockRestoreComponentSnapshotProperties).toHaveBeenCalledWith(component, { + value: { enabled: { value: true }, texture: { type: 'cc.Texture2D', value: { uuid: '' } } }, + }); + expect(dump.value.texture.value.uuid).toBe('deleted@6c48a'); + }); + + it.each(['cc.LightProbeGroup', 'CustomProbeGroup'])('rebinds restored %s probe arrays without rebuilding global data', async type => { + const old = [1, 2, 3, 4, 5]; + const restored = [1, 2, 3, 4]; + const info = { syncData: jest.fn(), update: jest.fn() }; + const component = { isValid: true, enabledInHierarchy: true, probes: old, node: { scene: { globals: { lightProbeInfo: info } } } }; + mockRestoreComponentSnapshotProperties.mockImplementationOnce(async () => { component.probes = restored; }); + await restoreComponentSnapshotDump(component as any, { type, extends: ['cc.LightProbeGroup'], value: { _probes: {} } }); + expect(info.syncData).toHaveBeenCalledWith(component.node, restored); + expect(info.update).not.toHaveBeenCalled(); + }); + + it('refreshes Terrain block bindings after properties and the engine lifecycle have restored', async () => { + const events: string[] = []; + const restoredInfo = { texture: 'restored-texture' }; + const block = { _updateLightmap: jest.fn(() => events.push('bind')) }; + const component = { + _lightmapInfos: [] as unknown[], + onRestore: () => { events.push('lifecycle'); }, + getBlocks: () => [block], + }; + mockRestoreComponentSnapshotProperties.mockImplementationOnce(async () => { + events.push('properties'); + component._lightmapInfos = [restoredInfo]; + }); + await restoreComponentSnapshotDump(component as any, { type: 'cc.Terrain', value: { _lightmapInfos: {} } }); + expect(events).toEqual(['properties', 'lifecycle', 'bind']); + expect(block._updateLightmap).toHaveBeenCalledWith(restoredInfo); + }); + it('delegates property restoration to dump and calls onRestore lifecycle', async () => { const component = { onRestore: jest.fn(), diff --git a/src/core/scene/test/undo-node-structure-serialization.test.ts b/src/core/scene/test/undo-node-structure-serialization.test.ts index 3ef0d0913..78e6179b3 100644 --- a/src/core/scene/test/undo-node-structure-serialization.test.ts +++ b/src/core/scene/test/undo-node-structure-serialization.test.ts @@ -68,6 +68,7 @@ jest.mock('../scene-process/service/undo/commands/command-utils-shared', () => ( import { editorExtrasTag } from 'cc'; import { captureNodeStructureSnapshot } from '../scene-process/service/undo/commands/node-structure-command-utils'; +import { deletedLightmapAssets } from '../scene-process/service/baking/lightfx/deleted-lightmap-assets'; describe('captureNodeStructureSnapshot serialization', () => { let mockEditorSerialize: jest.Mock; @@ -277,8 +278,10 @@ describe('restoreNodeStructureSnapshot asset map registration', () => { expect(registered[0]._prefab.asset._uuid).toBe('prefab-asset-uuid'); }); - it('does not register in assetToNodesMap when prefabAssetUuid is absent', async () => { + it('filters deleted texture references before loading a historical node without a prefab asset', async () => { const parentNode = new MockNode('parent', 'Parent') as any; + parentNode.scene = {}; + deletedLightmapAssets.begin(parentNode.scene, ['deleted'])(['deleted']); parentNode.addChild = jest.fn((child: any) => { child.parent = parentNode; parentNode.children.push(child); @@ -310,7 +313,7 @@ describe('restoreNodeStructureSnapshot asset map registration', () => { parentUuid: 'parent', parentPath: '/Parent', siblingIndex: 0, - serializedJson: JSON.stringify({ __type__: 'cc.Node' }), + serializedJson: JSON.stringify({ __type__: 'cc.Node', customTexture: { __uuid__: 'deleted@6c48a' }, position: { x: 10 } }), uuidTree: { uuid: 'child-node', componentUuids: [], children: [] }, }; const meta = { id: 'test:id', label: 'test', type: 'test', scope: {}, timestamp: 1 }; @@ -318,6 +321,7 @@ describe('restoreNodeStructureSnapshot asset map registration', () => { await restoreNodeStructureSnapshot(snapshot, meta); expect(mockAssetToNodesMap.size).toBe(0); + expect(mockLoadWithJson.mock.calls[0][0]).toEqual({ __type__: 'cc.Node', customTexture: null, position: { x: 10 } }); }); it('still restores node successfully when prefab asset loading fails', async () => { diff --git a/tests/lightfx-bake-api.test.ts b/tests/lightfx-bake-api.test.ts new file mode 100644 index 000000000..91bdacb82 --- /dev/null +++ b/tests/lightfx-bake-api.test.ts @@ -0,0 +1,73 @@ +import 'reflect-metadata'; +import { COMMON_STATUS } from '../src/api/base/schema-base'; +import { + SchemaLightmapBakeInfo, + SchemaLightmapBakeOptions, + SchemaLightProbeBakeOptions, +} from '../src/api/scene/lightfx-bake-schema'; + +const probeBake = jest.fn(); const lightmapBake = jest.fn(); const queryLightmapBakeInfo = jest.fn(); +const probeCancel = jest.fn(); const lightmapCancel = jest.fn(); +jest.mock('../src/api/decorator/decorator', () => ({ description: () => jest.fn(), param: () => jest.fn(), result: () => jest.fn(), title: () => jest.fn(), tool: () => jest.fn() })); +jest.mock('../src/core/scene', () => ({ Scene: { LightProbeBake: { bake: (...args: unknown[]) => probeBake(...args), clearBake: jest.fn(), cancel: () => probeCancel() }, LightmapBake: { bake: (...args: unknown[]) => lightmapBake(...args), queryBakeInfo: (...args: unknown[]) => queryLightmapBakeInfo(...args), clearBake: jest.fn(), cancel: () => lightmapCancel() } } })); +import { LightFXBakeApi } from '../src/api/scene/lightfx-bake'; + +describe('LightFX bake API', () => { + beforeEach(() => { probeBake.mockReset(); lightmapBake.mockReset(); queryLightmapBakeInfo.mockReset(); probeCancel.mockReset(); lightmapCancel.mockReset(); }); + it.each([true, false])('tries Lightmap cancel only when Probe did not cancel (probe=%s)', async cancelled => { + const probe = { cancelled, target: cancelled ? 'light-probe' : null }; + const lightmap = { cancelled: true, target: 'lightmap' }; + probeCancel.mockResolvedValue(probe); + lightmapCancel.mockResolvedValue(lightmap); + await expect(new LightFXBakeApi().cancel()).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data: cancelled ? probe : lightmap }); + expect(lightmapCancel).toHaveBeenCalledTimes(cancelled ? 0 : 1); + }); + it('does not cancel another type after an uncertain cancellation error', async () => { + probeCancel.mockRejectedValue(new Error('Disconnected')); + await expect(new LightFXBakeApi().cancel()).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'Disconnected' }); + expect(lightmapCancel).not.toHaveBeenCalled(); + }); + it('validates all Creator light-probe panel parameters', () => { + const options = { + giScale: 8, giSamples: 4096, bounces: 1, reduceRinging: 0.02, + showWireframe: true, showConvex: false, lightProbeSphereVolume: 2, + }; + expect(SchemaLightProbeBakeOptions.parse(options)).toEqual(options); + expect(() => SchemaLightProbeBakeOptions.parse({ giSamples: 1 })).toThrow(); + expect(() => SchemaLightProbeBakeOptions.parse({ bounces: 5 })).toThrow(); + expect(() => SchemaLightProbeBakeOptions.parse({ reduceRinging: 0.051 })).toThrow(); + expect(() => SchemaLightProbeBakeOptions.parse({ lightProbeSphereVolume: 101 })).toThrow(); + }); + it('validates all Creator lightmap calculation parameters', () => { + const options = { msaa: 4 as const, resolution: 1024, filter: true, highp: false, giScale: 1, giSamples: 25, giPathLength: 4, aoLevel: 0, aoStrength: .5, aoRadius: 1, aoColor: [136, 136, 136, 255] as [number, number, number, number], threads: 4 }; + expect(SchemaLightmapBakeOptions.parse(options)).toEqual(options); + expect(() => SchemaLightmapBakeOptions.parse({ resolution: 4096 })).toThrow(); + expect(() => SchemaLightmapBakeOptions.parse({ giPathLength: 5 })).toThrow(); + expect(() => SchemaLightmapBakeOptions.parse({ aoLevel: 3 })).toThrow(); + }); + it('rejects overflowing Lightmap GI samples without restricting probe samples', () => { + expect(SchemaLightmapBakeOptions.parse({ giSamples: 2590 })).toEqual({ giSamples: 2590 }); + for (const giSamples of [2591, 65535, 65536, 0, -1, 25.5, NaN, Infinity]) { + expect(SchemaLightmapBakeOptions.safeParse({ giSamples }).success).toBe(false); + } + expect(SchemaLightProbeBakeOptions.parse({ giSamples: 65535 })).toEqual({ giSamples: 65535 }); + }); + it('forwards probe bake and wraps success', async () => { const data = { sceneUrl: 'db://assets/a.scene', probeCount: 4, giScale: 1, giSamples: 64, bounces: 1, reduceRinging: 0, showWireframe: true, showConvex: false, lightProbeSphereVolume: 1, durationMs: 10 }; probeBake.mockResolvedValue(data); await expect(new LightFXBakeApi().bakeLightProbes({ saveScene: true })).resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); expect(probeBake).toHaveBeenCalledWith({ saveScene: true }); }); + it('wraps LightFX failure', async () => { lightmapBake.mockRejectedValue(new Error('LightFX failed')); await expect(new LightFXBakeApi().bakeLightmap({})).resolves.toEqual({ code: COMMON_STATUS.FAIL, reason: 'LightFX failed' }); }); + it('queries the current lightmap bake information', async () => { + const data = { + sceneUrl: 'db://assets/Lightmap.scene', baked: true, meshCount: 1, terrainCount: 0, + highp: false, stationaryMainLight: false, + textures: [{ + uuid: 'texture-uuid', url: 'db://assets/Lightmap/lightmap/LFX_Mesh_0000.png', + filename: 'LFX_Mesh_0000.png', size: 1024, createdAt: 1, modifiedAt: 2, + }], + missingTextureUuids: [], + }; + expect(SchemaLightmapBakeInfo.parse(data)).toEqual(data); + queryLightmapBakeInfo.mockResolvedValue(data); + await expect(new LightFXBakeApi().queryLightmapBakeInfo()) + .resolves.toEqual({ code: COMMON_STATUS.SUCCESS, data }); + expect(queryLightmapBakeInfo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/reflection-probe-renderer.test.ts b/tests/reflection-probe-renderer.test.ts index c47b3b796..c105347df 100644 --- a/tests/reflection-probe-renderer.test.ts +++ b/tests/reflection-probe-renderer.test.ts @@ -95,7 +95,16 @@ function rendererSocket(options: IMockRendererOptions = {}) { } describe('reflection probe WebGL renderer bridge', () => { - beforeEach(() => jest.clearAllMocks()); + let nowMs: number; + let dateNow: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + nowMs = 10_000; + // Routing assertions must not depend on whether the real clock ticks during selection. + dateNow = jest.spyOn(Date, 'now').mockImplementation(() => nowMs); + }); + afterEach(() => jest.restoreAllMocks()); it('selects an explicitly requested scene', async () => { @@ -119,7 +128,7 @@ describe('reflection probe WebGL renderer bridge', () => { const preloader = rendererSocket({ id: 'preloader', sceneUrl: '', visible: false }); mockFetchSockets.mockResolvedValue([scene, preloader]); // captureActive forwards the remaining budget, not the original timeout. - jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValue(1000 + elapsedMs); + dateNow.mockReturnValueOnce(1000).mockReturnValue(1000 + elapsedMs); await expect(reflectionProbeRenderer.captureActive('Probe', 1500)).resolves.toEqual({ ...captureResult('db://assets/Target.scene'), @@ -196,6 +205,38 @@ describe('reflection probe WebGL renderer bridge', () => { ); }); + it.each(['capture', 'clear'] as const)('deducts source-selection time from the %s timeout budget', async action => { + const source = { runtimeId: 'runtime-a', sceneUuid: 'scene-a', generation: 2 }; + const active = rendererSocket({ source }); + const respond = active.emit.getMockImplementation()!; + active.emit.mockImplementation((event, request, reply) => { + if (event === 'scene:list-reflection-probes') { + // Model time spent confirming the source renderer, without a real sleep. + nowMs += 123; + } + respond(event, request, reply); + }); + mockFetchSockets.mockResolvedValue([active]); + + if (action === 'capture') { + await reflectionProbeRenderer.captureActive('Probe', 1500, source); + } else { + await reflectionProbeRenderer.clearActive(true, 1500, source); + } + + expect(active.timeout.mock.calls).toEqual([[1500], [1377]]); + expect(active.emit).toHaveBeenLastCalledWith( + action === 'capture' ? 'scene:capture-reflection-probe' : 'scene:clear-reflection-probes', + { + sceneUrl: 'db://assets/Target.scene', + source, + timeoutMs: 1377, + ...(action === 'capture' ? { nodePath: 'Probe' } : { saveScene: true }), + }, + expect.any(Function), + ); + }); + it('fails safely when multiple loaded scenes have not reported visibility', async () => { const first = rendererSocket({ id: 'first', sceneUrl: 'db://assets/First.scene' }); const second = rendererSocket({ id: 'second', sceneUrl: 'db://assets/Second.scene' }); diff --git a/workflow/build-scene-bundle.js b/workflow/build-scene-bundle.js index 53b211b6b..38dc3c67b 100644 --- a/workflow/build-scene-bundle.js +++ b/workflow/build-scene-bundle.js @@ -402,7 +402,7 @@ async function buildSceneBundle() { }); const bundleOutputFile = path.join(workspaceDir, 'static', 'web', 'scene-bundle.js'); - await bundle.write({ + const sceneBundleOutput = await bundle.write({ file: bundleOutputFile, format: 'system', sourcemap: true, @@ -436,6 +436,14 @@ async function buildSceneBundle() { ` }); + const unexpectedImports = sceneBundleOutput.output + .filter((item) => item.type === 'chunk') + .flatMap((item) => item.imports) + .filter((id) => id !== 'cc'); + if (unexpectedImports.length) { + throw new Error(`Scene Web bundle contains unsupported external imports: ${[...new Set(unexpectedImports)].join(', ')}`); + } + console.log('[Build] Successfully bundled to', bundleOutputFile); }