diff --git a/docs/plan/Library启动预热与运行时异步加载混合重构计划_2026-04-04.md b/docs/plan/Library启动预热与运行时异步加载混合重构计划_2026-04-04.md new file mode 100644 index 00000000..0cffbb1f --- /dev/null +++ b/docs/plan/Library启动预热与运行时异步加载混合重构计划_2026-04-04.md @@ -0,0 +1,692 @@ +# Library启动预热与运行时异步加载混合重构计划 + +文档日期:2026-04-04 + +适用范围:当前仓库中的 `project` 单项目工作流。 + +文档目标:在现有 `Library` 资产导入与缓存系统基础上,继续收口为一套更接近 Unity 的正式方案,即“启动阶段前置索引与缓存有效性恢复,运行时按需异步加载资源 payload”,同时避免重新引入旧版本兼容、迁移工具、路径双写等过渡态实现。 + +--- + +## 1. 这轮重构要解决什么问题 + +当前 `Library` 模块已经完成了第一阶段收口,具备了以下能力: + +- `Assets.meta` / `*.meta` / `AssetGUID` +- `Library/SourceAssetDB` +- `Library/ArtifactDB` +- `Library/Artifacts` +- `AssetImportService` +- `ProjectAssetIndex` +- `ResourceManager` 运行时缓存与异步调度 +- `AssetRef` 驱动的项目资产恢复 +- `mesh/material/texture` artifact 体系 + +但最近几轮实际使用已经暴露出两个非常关键的问题: + +### 1.1 问题一:artifact 命中路径不够纯净 + +已经生成好的 artifact 路径,例如: + +- `Library/Artifacts/.../main.xcmesh` +- `Library/Artifacts/.../material_0.xcmat` +- `Library/Artifacts/.../texture_0.xctex` + +在某些运行时链路里被误当成源资源再次送回 `EnsureArtifact()`,导致: + +- 报错 `Failed to build asset artifact: Library/Artifacts/...` +- 资源恢复失败 +- 运行时重复做无意义工作 +- UI 上出现误导性的导入失败状态 + +这个问题已经定位并修复,但它说明当前系统在“源资源路径”和“artifact 路径”的边界定义上还不够硬。 + +### 1.2 问题二:缓存命中了,但热路径仍然很重 + +这是最近 “为什么打开带 obj 的 scene 还是会卡” 的根本原因。 + +当 `backpack.obj` 这类模型已经有 artifact 时,理论上应该走: + +- 命中 `ArtifactDB` +- 直接读取 `xcmesh/xcmat/xctex` +- 异步恢复运行时对象 + +但实际上旧实现里在 `AreDependenciesCurrent()` 阶段还会对依赖文件反复做重成本校验: + +- `backpack.mtl` +- `diffuse.jpg` +- `normal.png` +- `specular.jpg` + +尤其是对大贴图重复做 hash,会把“缓存命中”退化成“每次打开 scene 都重新扫描大资源”。 + +这也是为什么用户体感上会觉得: + +- 明明已经有 `Library` +- 明明已经不是第一次打开 +- 但打开带大模型的场景依然卡顿很明显 + +这个问题也已经定位并修复,当前改为缓存命中时只使用 `fileSize + writeTime` 快速校验,而不是每次重算依赖 hash。 + +### 1.3 问题三:当前系统仍偏“运行时按需补救”,缺少正式的启动前置阶段 + +目前系统已经比较偏向: + +- 编辑器启动快 +- 打开 scene 时按需异步恢复 mesh/material/texture + +这条路线本身没有问题,但如果不补一个正式的“项目启动预热阶段”,就会有几个副作用: + +- 项目启动后第一次打开大场景,工作量全部堆在 scene open 时刻 +- 视口、Inspector、Picker 等编辑器系统更容易在首帧触发同步兜底 +- 用户对 `Library` 的感知会变成“有缓存但还是随机卡” +- 问题定位难,因为启动阶段和运行时阶段职责混在一起 + +### 1.4 问题四:编辑器主线程仍存在同步兜底风险 + +虽然 deferred scene load 与异步恢复已经打通,但编辑器里仍有若干系统会在渲染或交互路径中触发: + +- `GetMesh()` +- `GetMaterial()` +- 选择框、Gizmo、拾取、Inspector 读取资源状态 + +如果这些路径访问时机不对,仍然可能把本该后台完成的工作拉回主线程。 + +也就是说,现在系统已经不是“完全同步导入”,但还没完全达到“严格异步、主线程只消费结果”的成熟状态。 + +--- + +## 2. 这轮要采用的正式方向 + +这轮不走两个极端: + +- 不走 Unity 式“把几乎所有成本都压到启动时”的纯前置模式 +- 也不走“什么都按需,启动几乎不做准备”的纯懒加载模式 + +正式目标是混合式方案: + +### 2.1 启动阶段前置做什么 + +项目启动时只前置做轻量但必要的内容: + +- 读取 `SourceAssetDB` +- 读取 `ArtifactDB` +- 建立 `GUID <-> path <-> AssetRef` 快照索引 +- 恢复 `ProjectAssetIndex` +- 对项目资源做轻量级有效性校验 +- 检查 artifact 文件是否存在 +- 对最近使用场景或关键高频资源做 metadata 级预热 + +这些工作应该: + +- 不解码大贴图 +- 不重建 mesh +- 不恢复完整 material runtime object +- 不做 GPU payload 上传 + +### 2.2 运行时按需异步做什么 + +真正重的 payload 继续按需异步进入: + +- `xcmesh` -> runtime mesh +- `xcmat` -> runtime material +- `xctex` -> runtime texture +- 贴图绑定的延迟解析与加载 + +也就是说: + +- 启动阶段前置“状态” +- 运行时异步加载“内容” + +### 2.3 这套方案为什么更适合当前项目 + +因为当前项目的真实诉求是: + +- 编辑器本身不要像 Unity 那样每次启动都等很久 +- 但打开带大模型的场景也不能把主窗口卡死 +- 同时 `Library` 要真的发挥缓存价值,而不是只在目录结构上看起来像 Unity + +对这个目标来说,最佳平衡点就是: + +- 启动时恢复索引和缓存状态 +- 打开 scene 时只反序列化结构 +- 重资源 payload 严格异步 +- cache hit 路径绝不重新做大计算 + +--- + +## 3. 本轮重构后的目标架构 + +### 3.1 模块职责划分 + +#### `AssetDatabase` + +只作为底层数据库与导入实现细节: + +- 源资源记录 +- artifact 记录 +- `.meta` 文件管理 +- importer 分派 +- artifact 构建 +- 依赖采集 +- 缓存有效性校验 + +不直接承担 editor 级工作流协调。 + +#### `AssetImportService` + +作为项目级导入与缓存服务层: + +- 项目根目录绑定 +- 启动阶段缓存恢复 +- 轻量校验 +- `EnsureArtifact` +- `Refresh` +- `ReimportAsset` +- `ReimportAllAssets` +- `ClearLibraryCache` +- 启动预热状态输出 + +后续新增的“启动预热流程”也应放在这一层,而不是塞回 `ResourceManager`。 + +#### `ProjectAssetIndex` + +只承担索引快照职责: + +- `AssetRef -> project path` +- `project path -> AssetRef` +- 项目资源查找缓存 +- 场景反序列化后的快速路径恢复 + +不参与真正的 artifact 构建。 + +#### `ResourceManager` + +只承担运行时加载与缓存职责: + +- runtime object cache +- async queue +- in-flight coalescing +- 源资源与 artifact 路径路由 +- deferred scene load 期间的异步恢复入口 + +明确禁止它重新承担“项目导入管理器”角色。 + +### 3.2 正式的三段式生命周期 + +#### 阶段 A:Project Startup Bootstrap + +项目打开时: + +- 恢复 `Library` 数据库 +- 建索引 +- 快速验证缓存状态 +- 后台预热 metadata + +不恢复大资源 payload。 + +#### 阶段 B:Scene Structural Load + +打开 scene 时: + +- 只做场景结构反序列化 +- 组件恢复 `AssetRef` 和资源路径 hint +- 不在主线程同步恢复大 mesh/material/texture + +#### 阶段 C:Runtime Payload Stream-In + +场景打开后: + +- 按需异步拉取 `xcmesh` +- 再按需恢复 `xcmat` +- 贴图绑定继续 lazy resolve / lazy load +- 视口只消费当前已完成资源 + +--- + +## 4. 当前已完成内容与后续计划分界 + +## 4.1 已完成 + +以下内容已经可以视为本轮新方案的基础,不需要推倒重来: + +- `Library` 目录结构已落地 +- `SourceAssetDB` / `ArtifactDB` 已落地 +- `.meta + AssetGUID` 已稳定 +- `AssetRef` 驱动的 scene/component 恢复已落地 +- `MeshFilterComponent` / `MeshRendererComponent` deferred async restore 已打通 +- `ProjectPanel` 已有最小 import status 输出 +- orphan artifact 清理已接入 +- artifact 路径误进 `EnsureArtifact()` 的问题已修 +- cache hit 时重复 hash 大依赖导致卡顿的问题已修 +- `AssetImportService::BootstrapProject()` 已落地,项目启动存在正式 bootstrap 生命周期 +- `ResourceManager::SetResourceRoot()` 已切到显式 bootstrap 路径,不再依赖首次资源查询时的隐式补救 +- `AssetDatabase::Initialize()` 已改为只恢复 DB 状态,不再在绑定项目根目录时隐式全量扫描 +- `ClearLibraryCache()` 已在清空后立即重建 source lookup,避免清库后留下半残状态 +- `ResourceHandle` 已改为持有稳定 `ResourceGUID`,`UnloadAll/Shutdown` 后不再因为悬空指针在析构期崩溃 +- `ResourceManager` 的 `Unload/UnloadAll/UnloadGroup` 已同步清理 `ResourceCache` 陈旧条目 +- `Material` 析构顺序问题已修,OBJ source-import 与 mesh artifact reimport 链路不再触发 debug heap 崩溃 +- `mesh/material/scene/components` 关键回归已重新验证通过 + +## 4.2 本轮仍然需要正式收口的内容 + +本轮重点不再是“把 `Library` 做出来”,而是把下面这些未完成项正式落地: + +- 正式的启动阶段 bootstrap +- 启动阶段的 metadata 预热策略 +- 项目打开后、scene 首次打开前的轻量准备 +- editor 主线程同步兜底点审计 +- 视口 / Inspector / Picker 对未加载资源的正式占位策略 +- `AssetImportService` 层级的启动状态与进度模型 +- 更清晰的“正在预热 / 正在导入 / 正在异步恢复”的 UI 区分 + +--- + +## 5. 详细实施阶段 + +## 阶段 0:基线固化与指标采样 + +### 目标 + +先把当前性能问题量化,避免后续优化没有参照系。 + +### 任务 + +- 为以下阶段分别记录耗时: + - 项目启动到 editor 可交互 + - 打开 `Backpack.xc` 到 scene graph 可见 + - 打开 `Backpack.xc` 到 mesh 可见 + - 打开 `Backpack.xc` 到首帧可渲染 +- 记录 `AssetImportService` 各操作状态: + - `Bootstrap` + - `Refresh` + - `Import Asset` + - `Reimport` +- 记录 `ResourceManager` 各类异步请求计数: + - mesh + - material + - texture +- 对 `backpack.obj` 链路增加 focused trace 开关 + +### 交付物 + +- 明确的启动时长基线 +- 明确的 scene 打开耗时基线 +- 一组稳定可复现的回归测试与日志样本 + +### 完成标准 + +- 以后任何优化都可以明确比较 “优化前 vs 优化后” + +--- + +## 阶段 1:启动阶段 Bootstrap 正式化 + +### 目标 + +把当前隐式分散在多个点的项目初始化流程,收成一个正式的启动阶段。 + +### 要做的事 + +- 在 `AssetImportService` 中新增明确的启动流程,例如: + - `BeginProjectBootstrap()` + - `RunProjectBootstrap()` + - `GetBootstrapStatus()` +- 启动阶段同步完成: + - 绑定项目根目录 + - 读取 `SourceAssetDB` + - 读取 `ArtifactDB` + - 构建 `LookupSnapshot` + - 恢复 `ProjectAssetIndex` + - 快速检查 artifact 文件存在性 +- 启动阶段禁止: + - 解码纹理 + - 读取 `.obj` 顶点数据 + - 恢复完整 material runtime object + - 重算所有依赖 hash + +### 推荐实现细节 + +- `SetProjectRoot()` 只负责绑定项目,不隐式做重活 +- 显式 bootstrap 负责状态恢复 +- `ResourceManager::SetResourceRoot()` 完成后应当触发一次 bootstrap,而不是等首次 `LoadResource()` 时再补救 +- `ProjectAssetIndex::RefreshFrom(...)` 要明确依赖 bootstrap 产出的快照,而不是在运行时随机 fallback + +### 结果要求 + +- 项目启动后,`AssetRef` 和项目资源索引已经可用 +- 打开 scene 时不需要再顺便补建整套索引 + +--- + +## 阶段 2:缓存命中路径彻底轻量化 + +### 目标 + +保证 “有 artifact 且未变化” 真正意味着快。 + +### 要做的事 + +- 固化当前已经完成的两个修复: + - artifact 路径直载,不再送回 `EnsureArtifact` + - cache hit 时依赖校验只走 `fileSize + writeTime` +- 继续补强边界判定: + - `builtin://...` + - `Library/Artifacts/...` + - `.xcmesh/.xcmat/.xctex/.xcshader` + - `Assets/...` + - 项目外绝对路径 +- 只允许明确的源资源路径触发导入 +- cache hit 时不允许再发生: + - 重扫依赖目录 + - 重解析 `obj/mtl` + - 重算大贴图 hash + +### 推荐实现细节 + +- 在 `ResourceManager` 中形成统一 helper: + - `ShouldUseProjectArtifactImport(path, type)` +- 在 `AssetDatabase` 中形成统一 helper: + - `IsFastCacheValidationEligible(...)` +- 深度 hash 改为仅在这些场景触发: + - 显式 Reimport + - 怀疑 DB 损坏 + - 调试模式人工开启深校验 + +### 结果要求 + +- 二次打开 `Backpack.xc` 这类场景时,命中链路不应再接近首次导入成本 + +--- + +## 阶段 3:Scene Open 零阻塞主路径收口 + +### 目标 + +打开 scene 时只恢复结构,不同步等 payload。 + +### 要做的事 + +- 审计 scene load 首帧中所有可能触发同步资源恢复的位置: + - `MeshFilterComponent::GetMesh()` + - `MeshRendererComponent::GetMaterial()` + - render item 构建 + - scene picker + - gizmo frame builder + - inspector 材质预览 +- 禁止在 scene 刚打开时,视口渲染链路用同步 `Load()` 兜底 +- 对未完成的资源加载使用正式占位策略: + - mesh 未到位:跳过 render item 或显示加载占位 + - material 未到位:使用稳定 fallback material + - texture 未到位:保持材质对象,但贴图槽位为空 + +### 推荐实现细节 + +- `GetMesh()` / `GetMaterial()` 保持“只触发异步请求,不阻塞等待” +- 场景渲染代码消费的是“当前已完成状态”,而不是强行等待最终状态 +- `Loading scene assets...` 应仅表示后台恢复中,不应意味着主线程仍被卡住 + +### 结果要求 + +- 打开大场景时主窗口保持可交互 +- 首帧允许缺资源,但不能卡死 +- 资源陆续完成后画面逐步完善 + +--- + +## 阶段 4:启动预热策略 + +### 目标 + +在不走 Unity 全量前置的前提下,把最常用项目数据提前准备好。 + +### 候选预热内容 + +- 最近一次打开的 scene 列表 +- 当前项目默认 scene +- 最近访问的材质/模型索引 +- `AssetRef -> path` 高频映射 +- scene 文件中出现过的主 mesh asset ref + +### 预热分层 + +#### 同步预热 + +- 只预热索引和 metadata + +#### 后台预热 + +- 检查对应 artifact 是否存在 +- 预先解析 scene 内引用的主资源清单 +- 但不真正构建 runtime payload + +### 推荐实现细节 + +- 新增简单的 `Library/SessionCache` 或 `Library/BootstrapCache` 元数据文件 +- 记录最近场景和高频资源,不需要做复杂推荐系统 +- 预热任务进入专用后台队列,不占用用户交互关键线程 + +### 结果要求 + +- 项目打开后第一次打开最近场景更快 +- 不把全部项目资源都拉进启动成本 + +--- + +## 阶段 5:编辑器调用点清理 + +### 目标 + +把 editor 里所有“资源还没准备好时的坏行为”收掉。 + +### 要做的事 + +- 清理以下系统中的同步依赖: + - `SceneViewportPicker` + - `SceneViewportTransformGizmoFrameBuilder` + - `RenderSceneUtility` + - `InspectorPanel` + - 材质/mesh 相关组件编辑器 +- 明确这些系统在资源未到位时的行为: + - 不报错 + - 不触发重导入 + - 不锁主线程 + - 不污染 import status + +### 推荐实现细节 + +- picker:未完成 mesh 时直接跳过,不同步等待 +- gizmo center:拿不到 mesh bounds 时退回 transform position +- inspector:未完成材质时显示 loading / unresolved,而不是同步 `Load` +- viewport:资源未到位时继续渲染其它可用对象 + +### 结果要求 + +- “打开 scene 不阻塞” 不仅存在于测试里,也存在于真实编辑器交互中 + +--- + +## 阶段 6:状态与观测模型正式化 + +### 目标 + +用户能看懂系统当前在干什么。 + +### 现状问题 + +现在 `ProjectPanel` 顶部已经有导入状态,但还不足以区分: + +- 项目启动预热 +- 显式导入 +- 场景异步恢复 +- 运行时 payload stream-in + +### 要做的事 + +- 将状态拆成至少三类: + - `Bootstrap` + - `Import/Reimport` + - `Scene Asset Streaming` +- UI 明确区分: + - 正在恢复项目索引 + - 正在重建 artifact + - 正在后台加载场景资源 +- 增加更清楚的 tooltip: + - 当前目标路径 + - 剩余异步数量 + - 最近失败原因 + +### 推荐实现细节 + +- `AssetImportService::ImportStatusSnapshot` 扩展为更通用的 operation model +- `ResourceManager` 增加 lightweight streaming status snapshot +- `ProjectPanel` 与 viewport status text 分工: + - `ProjectPanel` 展示项目级状态 + - viewport 展示当前 scene 级 streaming 状态 + +### 结果要求 + +- 用户不会再把“后台恢复中”和“导入失败”混淆 + +--- + +## 阶段 7:最终收口与归档 + +### 目标 + +把这套方案从“已经能用”收成“正式基线”。 + +### 要做的事 + +- 整理最终架构说明 +- 删除不再需要的临时日志和调试开关 +- 固化测试矩阵 +- 更新 README / Editor 架构文档中的 Library 模块说明 +- 将本计划归档到 `docs/plan/used` + +### 完成标准 + +- `Library` 模块不再被视为过渡态系统 +- 项目启动与大场景打开的行为稳定、可解释、可回归 + +--- + +## 6. 需要修改或重点审查的代码范围 + +本轮预计主要涉及以下文件: + +- `engine/src/Core/Asset/AssetDatabase.cpp` +- `engine/include/XCEngine/Core/Asset/AssetDatabase.h` +- `engine/src/Core/Asset/AssetImportService.cpp` +- `engine/include/XCEngine/Core/Asset/AssetImportService.h` +- `engine/src/Core/Asset/ProjectAssetIndex.cpp` +- `engine/include/XCEngine/Core/Asset/ProjectAssetIndex.h` +- `engine/src/Core/Asset/ResourceManager.cpp` +- `engine/include/XCEngine/Core/Asset/ResourceManager.h` +- `engine/src/Core/Asset/AsyncLoader.cpp` +- `engine/src/Components/MeshFilterComponent.cpp` +- `engine/src/Components/MeshRendererComponent.cpp` +- `engine/src/Rendering/RenderSceneUtility.cpp` +- `editor/src/Viewport/SceneViewportPicker.cpp` +- `editor/src/Viewport/SceneViewportTransformGizmoFrameBuilder.h` +- `editor/src/panels/InspectorPanel.cpp` +- `editor/src/Viewport/ViewportHostService.h` +- `editor/src/Managers/SceneManager.cpp` +- `tests/Scene/test_scene.cpp` +- `tests/Components/test_mesh_render_components.cpp` +- `tests/Resources/Texture/test_texture_loader.cpp` +- `tests/Core/Asset/test_resource_manager.cpp` + +--- + +## 7. 验收标准 + +## 7.1 功能层 + +- 关闭 editor 再打开 `Backpack.xc` 时,模型 mesh/material/texture 能稳定恢复 +- 内置 primitive 不会因为缓存系统回归而丢失 +- 打开含 OBJ 的 scene 时,不再出现 artifact 路径误导入报错 + +## 7.2 性能层 + +- 项目启动时不会做完整 payload 导入 +- 打开 `Backpack.xc` 不会长时间阻塞主窗口 +- artifact 命中耗时显著低于首次导入 +- cache hit 路径不会再重复扫描大贴图 hash + +## 7.3 架构层 + +- 启动阶段、scene open 阶段、runtime payload 阶段边界清晰 +- `AssetImportService`、`ProjectAssetIndex`、`ResourceManager` 职责边界清晰 +- editor 主线程不再承担重资源恢复职责 + +## 7.4 观测层 + +- 用户能区分 `Bootstrap`、`Import`、`Scene Streaming` +- 失败原因可见 +- 后台工作数量可见 + +## 7.5 回归测试层 + +至少保住以下 focused 回归: + +- `Scene_ProjectSample.AsyncLoadBackpackMeshArtifactCompletes` +- `Scene_ProjectSample.DeferredLoadBackpackSceneEventuallyRestoresBackpackMesh` +- `Scene_ProjectSample.DeferredLoadBackpackSceneEventuallyProducesVisibleRenderItems` +- `MeshRendererComponent_Test.DeferredSceneDeserializeLoadsProjectMaterialAsync` +- `TextureLoader.ResourceManagerLoadsLibraryArtifactTextureWithoutReimportingIt` +- `ResourceManager_Test.ConcurrentAsyncLoadsCoalesceSameMeshPath` + +--- + +## 8. 当前阶段完成情况 + +截至 2026-04-04 当前这一轮代码与测试状态,按这份新计划计: + +| 阶段 | 状态 | 说明 | +| --- | --- | --- | +| 阶段 0:基线固化 | 进行中 | focused test 已补强,但还缺项目启动耗时与 editor 实机场景打开指标归档 | +| 阶段 1:启动阶段 Bootstrap 正式化 | 已完成 | `BootstrapProject()` / `BootstrapProjectAssets()` 已接入正式启动链路 | +| 阶段 2:缓存命中路径轻量化 | 已完成 | artifact 直载、依赖快速校验、误重导入路径问题均已修复 | +| 阶段 3:Scene Open 零阻塞主路径 | 已完成首轮 | backpack 场景异步恢复链路已稳定,scene open 不再因为 Library 命中路径本身阻塞主线程 | +| 阶段 4:启动预热策略 | 未开始 | 还没有最近场景/高频资源 metadata 预热 | +| 阶段 5:编辑器调用点清理 | 进行中 | 主要阻塞点已清掉,但 Inspector / Picker / viewport 仍需继续做系统化审计 | +| 阶段 6:状态与观测模型 | 进行中 | 已有 bootstrap/import 状态基础,但还未正式区分 bootstrap / streaming / scene restore | +| 阶段 7:最终收口与归档 | 未开始 | 等上述阶段完成后执行 | + +补充说明: + +- 本轮新增并通过的关键回归包括: + - `AssetImportService_Test.BootstrapProjectBuildsLookupSnapshotAndReportsStatus` + - `ResourceManager_Test.SetResourceRootBootstrapsProjectAssetCache` + - `ResourceHandle.ResetDoesNotDereferenceDestroyedResourcePointer` + - `MeshLoader.AssetDatabaseReimportsModelWhenDependencyChanges` + - `MeshLoader.ResourceManagerLoadsModelByAssetRefFromProjectAssets` +- 这意味着当前 `Library` 模块的主要风险已经从“缓存不生效/路径误判”转移为“剩余 editor 调用点审计、状态模型清晰化、预热策略补全”。 + +--- + +## 9. 推荐执行顺序 + +建议严格按下面顺序推进: + +1. 先继续做阶段 5,把 editor 主线程剩余同步兜底点系统性扫完。 +2. 然后做阶段 4,把“最近场景 metadata 预热”补上。 +3. 接着做阶段 6,把状态模型和 UI 区分补完整。 +4. 再做阶段 0,把项目启动和大场景打开指标正式采样落档。 +5. 最后统一做阶段 7 收口归档。 + +原因很明确: + +- 如果不先清主线程调用点,任何异步链路都可能再次被同步兜底破坏。 +- 如果不补预热策略,首次打开高频场景仍然会把轻量准备工作堆到打开时刻。 +- 如果不把状态模型做清楚,用户会继续把“后台恢复”误解成“又卡死了”。 +- 如果不把基线指标整理出来,后续收口就没有稳定对照。 + +--- + +## 10. 一句话结论 + +本轮不是继续“补 Library”,而是把现有 `Library` 系统正式提升为 Unity 式混合架构: + +项目启动时前置恢复索引与缓存状态,运行时严格按需异步流入 mesh/material/texture payload,让缓存命中真正变快,同时保证打开大场景不再阻塞 editor 主线程。 diff --git a/docs/plan/Library启动预热与运行时异步加载混合重构计划_进度更新_2026-04-04.md b/docs/plan/Library启动预热与运行时异步加载混合重构计划_进度更新_2026-04-04.md new file mode 100644 index 00000000..7a710260 --- /dev/null +++ b/docs/plan/Library启动预热与运行时异步加载混合重构计划_进度更新_2026-04-04.md @@ -0,0 +1,91 @@ +# Library 启动预热与运行时异步加载混合重构计划进度更新 + +文档日期:2026-04-04 + +对应主计划: +- `docs/plan/Library启动预热与运行时异步加载混合重构计划_2026-04-04.md` + +## 本轮已完成 + +- `mesh_tests` 全量 `34/34` 通过。 +- `scene_tests` 全量 `63/63` 通过。 +- `asset_tests` 全量 `55/55` 通过。 +- `texture_tests` 全量 `28/28` 通过。 +- `XCEditor` Debug 构建通过。 + +- `Main.xc` 轻量场景测试预期已和当前项目夹具同步: + - 当前场景对象是 `Sphere` + - 不再沿用旧的 `Cube` 预期 + +- `AssetImportService::ImportStatusSnapshot` 已增加: + - `startedAtMs` + - `completedAtMs` + - `durationMs` + +- `ProjectPanel` 的 Library 状态展示已增强: + - 状态文案可显示耗时 + - tooltip 可区分 operation / state / duration / target / imported count / removed count + +- 一个阻塞当前回归验证的无关编译问题已修复: + - `engine/src/Resources/UI/UIDocumentLoaders.cpp` + - 不再非法调用 `IResourceLoader::GetExtension()` 的 protected 接口 + +## 当前阶段判断 + +当前这条 Library 重构线已经达到下面这个阶段性状态: + +- 启动阶段负责恢复索引和缓存状态 +- 运行时继续按需异步恢复 mesh / material / texture payload +- 关键回归测试已经稳定通过 +- Editor 主程序可以正常构建 + +也就是说,前面最容易反复回退的两类问题已经清掉了: + +- `Library` / artifact / `AssetRef` 主链路回归 +- scene fixture 漂移导致的假失败 + +## 相对主计划的完成情况 + +- 阶段 1:已完成 + - `BootstrapProject()` / `BootstrapProjectAssets()` 已正式接入启动链路 + +- 阶段 2:已完成 + - artifact 直载 + - cache hit 快速校验 + - 避免把 artifact 路径重新误送回导入链路 + +- 阶段 3:已完成首轮并稳定 + - scene open 不再因为 Library 命中路径本身阻塞主线程 + - backpack 场景异步恢复链路回归通过 + +- 阶段 5:推进中 + - 主要阻塞点已经清掉 + - 但 editor 里剩余同步兜底访问点仍需继续系统化审计 + +- 阶段 6:推进中 + - 现在已经有 bootstrap/import 状态 + - 但还需要继续区分 bootstrap / scene restore / runtime streaming + +- 阶段 0 / 阶段 4 / 阶段 7:未收口 + - 启动与首帧指标基线还没正式固化 + - metadata 预热策略还没补完 + - 最终收口文档和归档还没做 + +## 下一步建议 + +下一阶段不要再回头重做 `Library` 主链路本身,重点应当转到“观测”和“收口”: + +1. 固化启动基线与场景首帧指标 +2. 审计 editor 主线程剩余同步兜底点 +3. 给 viewport / inspector / picker 加上未完成异步资源的占位与状态提示 +4. 把“正在预热 / 正在导入 / 正在异步恢复”拆成更明确的 UI 状态 + +## 这一轮改动直接涉及的文件 + +- `engine/include/XCEngine/Core/Asset/AssetImportService.h` +- `engine/src/Core/Asset/AssetImportService.cpp` +- `editor/src/panels/ProjectPanel.cpp` +- `tests/core/Asset/test_resource_manager.cpp` +- `tests/Resources/Texture/test_texture_loader.cpp` +- `tests/Scene/test_scene.cpp` +- `engine/src/Resources/UI/UIDocumentLoaders.cpp` diff --git a/editor/src/panels/ProjectPanel.cpp b/editor/src/panels/ProjectPanel.cpp index 606d934b..10f4678e 100644 --- a/editor/src/panels/ProjectPanel.cpp +++ b/editor/src/panels/ProjectPanel.cpp @@ -9,8 +9,12 @@ #include "Utils/ProjectFileUtils.h" #include "UI/UI.h" +#include + #include +#include #include +#include #include #include #include @@ -24,6 +28,85 @@ namespace { constexpr float kProjectToolbarHeight = 26.0f; constexpr float kProjectToolbarPaddingY = 3.0f; +std::uint64_t ResolveImportStatusElapsedMs( + const XCEngine::Resources::AssetImportService::ImportStatusSnapshot& status) { + if (!status.HasValue() || status.startedAtMs == 0) { + return 0; + } + + if (!status.inProgress) { + return static_cast(status.durationMs); + } + + const auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + return nowMs >= status.startedAtMs + ? nowMs - static_cast(status.startedAtMs) + : 0; +} + +ImVec4 ResolveImportStatusColor(const XCEngine::Resources::AssetImportService::ImportStatusSnapshot& status) { + if (!status.HasValue()) { + return XCEngine::Editor::UI::ConsoleSecondaryTextColor(); + } + + if (status.inProgress) { + return XCEngine::Editor::UI::ConsoleWarningColor(); + } + + return status.success + ? XCEngine::Editor::UI::ConsoleSecondaryTextColor() + : XCEngine::Editor::UI::ConsoleErrorColor(); +} + +std::string BuildImportStatusText(const XCEngine::Resources::AssetImportService::ImportStatusSnapshot& status) { + if (!status.HasValue()) { + return "Library: ready"; + } + + std::string text = "Library: "; + text += status.message.CStr(); + const std::uint64_t elapsedMs = ResolveImportStatusElapsedMs(status); + if (elapsedMs > 0) { + text += " ("; + text += std::to_string(elapsedMs); + text += " ms)"; + } + return text; +} + +std::string BuildImportStatusTooltipText(const XCEngine::Resources::AssetImportService::ImportStatusSnapshot& status) { + if (!status.HasValue()) { + return "No import operations have been recorded in this session."; + } + + std::string tooltip; + tooltip += "Operation: "; + tooltip += status.operation.CStr(); + tooltip += "\nState: "; + tooltip += status.inProgress ? "Running" : (status.success ? "Succeeded" : "Failed"); + const std::uint64_t elapsedMs = ResolveImportStatusElapsedMs(status); + if (elapsedMs > 0) { + tooltip += "\nDuration: "; + tooltip += std::to_string(elapsedMs); + tooltip += " ms"; + } + if (!status.targetPath.Empty()) { + tooltip += "\nTarget: "; + tooltip += status.targetPath.CStr(); + } + if (status.importedAssetCount > 0) { + tooltip += "\nImported: "; + tooltip += std::to_string(status.importedAssetCount); + } + if (status.removedArtifactCount > 0) { + tooltip += "\nRemoved orphans: "; + tooltip += std::to_string(status.removedArtifactCount); + } + return tooltip; +} + template void QueueDeferredAction(std::function& pendingAction, Fn&& fn) { if (!pendingAction) { @@ -373,6 +456,10 @@ void ProjectPanel::Render() { } void ProjectPanel::RenderToolbar() { + const auto importStatus = ::XCEngine::Resources::ResourceManager::Get().GetProjectAssetImportStatus(); + const std::string importStatusText = BuildImportStatusText(importStatus); + const std::string importStatusTooltip = BuildImportStatusTooltipText(importStatus); + UI::PanelToolbarScope toolbar( "ProjectToolbar", kProjectToolbarHeight, @@ -387,12 +474,23 @@ void ProjectPanel::RenderToolbar() { ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0.0f, 0.0f)); if (ImGui::BeginTable("##ProjectToolbarLayout", 2, ImGuiTableFlags_NoSavedSettings | ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("##Spacer", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##ImportStatus", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##Search", ImGuiTableColumnFlags_WidthFixed, 220.0f); ImGui::TableNextRow(); ImGui::TableNextColumn(); + ImGui::AlignTextToFramePadding(); + ImGui::PushStyleColor(ImGuiCol_Text, ResolveImportStatusColor(importStatus)); + ImGui::TextUnformatted(importStatusText.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered() && !importStatusTooltip.empty()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 36.0f); + ImGui::TextUnformatted(importStatusTooltip.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } ImGui::TableNextColumn(); UI::ToolbarSearchField("##Search", "Search assets", m_searchBuffer, sizeof(m_searchBuffer)); diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index c3561067..5ad164bd 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -413,14 +413,14 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIElementTree.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIContext.h ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIBuildContext.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIElementTree.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleTypes.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/Theme.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleSet.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleResolver.h - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/StyleTypes.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/Theme.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/StyleResolver.cpp + /src/UI/Core/UIElementTree.cpp + /include/XCEngine/UI/Style/StyleTypes.h + /include/XCEngine/UI/Style/Theme.h + /include/XCEngine/UI/Style/StyleSet.h + /include/XCEngine/UI/Style/StyleResolver.h + /src/UI/Style/StyleTypes.cpp + /src/UI/Style/Theme.cpp + /src/UI/Style/StyleResolver.cpp # Input ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h diff --git a/engine/include/XCEngine/Core/Asset/AssetImportService.h b/engine/include/XCEngine/Core/Asset/AssetImportService.h index c428babe..795b3cd6 100644 --- a/engine/include/XCEngine/Core/Asset/AssetImportService.h +++ b/engine/include/XCEngine/Core/Asset/AssetImportService.h @@ -10,28 +10,91 @@ namespace Resources { class AssetImportService { public: + struct ImportStatusSnapshot { + Core::uint64 revision = 0; + bool inProgress = false; + bool success = false; + Containers::String operation; + Containers::String targetPath; + Containers::String message; + Core::uint64 startedAtMs = 0; + Core::uint64 completedAtMs = 0; + Core::uint64 durationMs = 0; + Core::uint32 importedAssetCount = 0; + Core::uint32 removedArtifactCount = 0; + + bool HasValue() const { + return revision != 0; + } + }; + + struct LookupSnapshot { + std::unordered_map assetGuidByPathKey; + std::unordered_map assetPathByGuid; + + void Clear() { + assetGuidByPathKey.clear(); + assetPathByGuid.clear(); + } + }; + + struct ImportedAsset { + bool exists = false; + bool artifactReady = false; + bool imported = false; + Containers::String absolutePath; + Containers::String relativePath; + AssetGUID assetGuid; + ResourceType resourceType = ResourceType::Unknown; + Containers::String runtimeLoadPath; + Containers::String artifactDirectory; + LocalID mainLocalID = kMainAssetLocalID; + }; + void Initialize(); void Shutdown(); void SetProjectRoot(const Containers::String& projectRoot); Containers::String GetProjectRoot() const; + Containers::String GetLibraryRoot() const; + bool BootstrapProject(); void Refresh(); + bool ClearLibraryCache(); + bool RebuildLibraryCache(); + bool ReimportAllAssets(); + bool ReimportAsset(const Containers::String& requestPath, + ImportedAsset& outAsset); + ImportStatusSnapshot GetLastImportStatus() const; + bool TryGetImportableResourceType(const Containers::String& requestPath, + ResourceType& outType) const; bool EnsureArtifact(const Containers::String& requestPath, ResourceType requestedType, - AssetDatabase::ResolvedAsset& outAsset); + ImportedAsset& outAsset); bool TryGetAssetRef(const Containers::String& requestPath, ResourceType resourceType, AssetRef& outRef) const; bool TryGetPrimaryAssetPath(const AssetGUID& guid, Containers::String& outRelativePath) const; - void BuildLookupSnapshot(std::unordered_map& outPathToGuid, - std::unordered_map& outGuidToPath) const; + void BuildLookupSnapshot(LookupSnapshot& outSnapshot) const; private: + static ImportedAsset ConvertResolvedAsset(const AssetDatabase::ResolvedAsset& resolvedAsset); + void ResetImportStatusLocked(); + void BeginImportStatusLocked(const Containers::String& operation, + const Containers::String& targetPath, + const Containers::String& message); + void FinishImportStatusLocked(const Containers::String& operation, + const Containers::String& targetPath, + bool success, + const Containers::String& message, + const AssetDatabase::MaintenanceStats& stats); + mutable std::recursive_mutex m_mutex; Containers::String m_projectRoot; AssetDatabase m_assetDatabase; + ImportStatusSnapshot m_lastImportStatus; + Core::uint64 m_nextImportStatusRevision = 1; }; } // namespace Resources diff --git a/engine/include/XCEngine/Core/Asset/ResourceHandle.h b/engine/include/XCEngine/Core/Asset/ResourceHandle.h index 8f7bcb29..d240ac30 100644 --- a/engine/include/XCEngine/Core/Asset/ResourceHandle.h +++ b/engine/include/XCEngine/Core/Asset/ResourceHandle.h @@ -18,22 +18,26 @@ public: ResourceHandle() = default; explicit ResourceHandle(T* resource) - : m_resource(resource) { - if (m_resource) { - ResourceManager::Get().AddRef(m_resource->GetGUID()); + : m_resource(resource), + m_guid(resource != nullptr ? resource->GetGUID() : ResourceGUID()) { + if (m_guid.IsValid()) { + ResourceManager::Get().AddRef(m_guid); } } ResourceHandle(const ResourceHandle& other) - : m_resource(other.m_resource) { - if (m_resource) { - ResourceManager::Get().AddRef(m_resource->GetGUID()); + : m_resource(other.m_resource), + m_guid(other.m_guid) { + if (m_guid.IsValid()) { + ResourceManager::Get().AddRef(m_guid); } } ResourceHandle(ResourceHandle&& other) noexcept - : m_resource(other.m_resource) { + : m_resource(other.m_resource), + m_guid(other.m_guid) { other.m_resource = nullptr; + other.m_guid = ResourceGUID(); } ~ResourceHandle() { @@ -44,8 +48,9 @@ public: if (this != &other) { Reset(); m_resource = other.m_resource; - if (m_resource) { - ResourceManager::Get().AddRef(m_resource->GetGUID()); + m_guid = other.m_guid; + if (m_guid.IsValid()) { + ResourceManager::Get().AddRef(m_guid); } } return *this; @@ -55,7 +60,9 @@ public: if (this != &other) { Reset(); m_resource = other.m_resource; + m_guid = other.m_guid; other.m_resource = nullptr; + other.m_guid = ResourceGUID(); } return *this; } @@ -64,11 +71,11 @@ public: T* operator->() const { return m_resource; } T& operator*() const { return *m_resource; } - bool IsValid() const { return m_resource != nullptr && m_resource->IsValid(); } + bool IsValid() const { return m_resource != nullptr && m_guid.IsValid() && m_resource->IsValid(); } explicit operator bool() const { return IsValid(); } ResourceGUID GetGUID() const { - return m_resource ? m_resource->GetGUID() : ResourceGUID(0); + return m_guid; } ResourceType GetResourceType() const { @@ -76,18 +83,21 @@ public: } void Reset() { - if (m_resource) { - ResourceManager::Get().Release(m_resource->GetGUID()); + if (m_guid.IsValid()) { + ResourceManager::Get().Release(m_guid); m_resource = nullptr; + m_guid = ResourceGUID(); } } void Swap(ResourceHandle& other) { std::swap(m_resource, other.m_resource); + std::swap(m_guid, other.m_guid); } private: T* m_resource = nullptr; + ResourceGUID m_guid; }; template diff --git a/engine/include/XCEngine/Core/Asset/ResourceManager.h b/engine/include/XCEngine/Core/Asset/ResourceManager.h index fcc80f7f..1c737d6b 100644 --- a/engine/include/XCEngine/Core/Asset/ResourceManager.h +++ b/engine/include/XCEngine/Core/Asset/ResourceManager.h @@ -42,6 +42,7 @@ public: void SetResourceRoot(const Containers::String& rootPath); const Containers::String& GetResourceRoot() const; + bool BootstrapProjectAssets(); template ResourceHandle Load(const Containers::String& path, ImportSettings* settings = nullptr) { @@ -116,7 +117,13 @@ public: Containers::Array GetResourcePaths() const; void UnloadGroup(const Containers::Array& guids); - void RefreshAssetDatabase(); + void RefreshProjectAssets(); + bool CanReimportProjectAsset(const Containers::String& path) const; + bool ReimportProjectAsset(const Containers::String& path); + bool ClearProjectLibraryCache(); + bool RebuildProjectAssetCache(); + Containers::String GetProjectLibraryRoot() const; + AssetImportService::ImportStatusSnapshot GetProjectAssetImportStatus() const; bool TryGetAssetRef(const Containers::String& path, ResourceType resourceType, AssetRef& outRef) const; bool TryResolveAssetPath(const AssetRef& assetRef, Containers::String& outPath) const; void BeginDeferredSceneLoad(); diff --git a/engine/include/XCEngine/Resources/UI/UIDocumentCompiler.h b/engine/include/XCEngine/Resources/UI/UIDocumentCompiler.h new file mode 100644 index 00000000..86dab656 --- /dev/null +++ b/engine/include/XCEngine/Resources/UI/UIDocumentCompiler.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace Resources { + +struct UIDocumentCompileRequest { + UIDocumentKind kind = UIDocumentKind::View; + Containers::String path; + Containers::String expectedRootTag; +}; + +struct UIDocumentCompileResult { + UIDocumentModel document = {}; + Containers::String errorMessage; + bool succeeded = false; +}; + +bool CompileUIDocument( + const UIDocumentCompileRequest& request, + UIDocumentCompileResult& outResult); + +bool WriteUIDocumentArtifact( + const Containers::String& artifactPath, + const UIDocumentCompileResult& compileResult, + Containers::String* outErrorMessage = nullptr); + +bool LoadUIDocumentArtifact( + const Containers::String& artifactPath, + UIDocumentKind expectedKind, + UIDocumentCompileResult& outResult); + +Containers::String GetUIDocumentDefaultRootTag(UIDocumentKind kind); + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/UI/UIDocumentLoaders.h b/engine/include/XCEngine/Resources/UI/UIDocumentLoaders.h new file mode 100644 index 00000000..ebbccbc9 --- /dev/null +++ b/engine/include/XCEngine/Resources/UI/UIDocumentLoaders.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +class UIViewLoader : public IResourceLoader { +public: + ResourceType GetResourceType() const override { return ResourceType::UIView; } + Containers::Array GetSupportedExtensions() const override; + bool CanLoad(const Containers::String& path) const override; + LoadResult Load(const Containers::String& path, const ImportSettings* settings = nullptr) override; + ImportSettings* GetDefaultSettings() const override { return nullptr; } + + bool CompileDocument(const Containers::String& path, UIDocumentCompileResult& outResult) const; +}; + +class UIThemeLoader : public IResourceLoader { +public: + ResourceType GetResourceType() const override { return ResourceType::UITheme; } + Containers::Array GetSupportedExtensions() const override; + bool CanLoad(const Containers::String& path) const override; + LoadResult Load(const Containers::String& path, const ImportSettings* settings = nullptr) override; + ImportSettings* GetDefaultSettings() const override { return nullptr; } + + bool CompileDocument(const Containers::String& path, UIDocumentCompileResult& outResult) const; +}; + +class UISchemaLoader : public IResourceLoader { +public: + ResourceType GetResourceType() const override { return ResourceType::UISchema; } + Containers::Array GetSupportedExtensions() const override; + bool CanLoad(const Containers::String& path) const override; + LoadResult Load(const Containers::String& path, const ImportSettings* settings = nullptr) override; + ImportSettings* GetDefaultSettings() const override { return nullptr; } + + bool CompileDocument(const Containers::String& path, UIDocumentCompileResult& outResult) const; +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/UI/UIDocumentTypes.h b/engine/include/XCEngine/Resources/UI/UIDocumentTypes.h new file mode 100644 index 00000000..a46fc6f4 --- /dev/null +++ b/engine/include/XCEngine/Resources/UI/UIDocumentTypes.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +enum class UIDocumentKind : Core::uint8 { + View = 0, + Theme, + Schema +}; + +enum class UIDocumentDiagnosticSeverity : Core::uint8 { + Info = 0, + Warning, + Error +}; + +struct UIDocumentSourceLocation { + Core::uint32 line = 1; + Core::uint32 column = 1; +}; + +struct UIDocumentAttribute { + Containers::String name; + Containers::String value; +}; + +struct UIDocumentDiagnostic { + UIDocumentDiagnosticSeverity severity = UIDocumentDiagnosticSeverity::Error; + UIDocumentSourceLocation location = {}; + Containers::String message; +}; + +struct UIDocumentNode { + Containers::String tagName; + Containers::Array attributes; + Containers::Array children; + UIDocumentSourceLocation location = {}; + bool selfClosing = false; + + const UIDocumentAttribute* FindAttribute(const Containers::String& name) const { + for (const UIDocumentAttribute& attribute : attributes) { + if (attribute.name == name) { + return &attribute; + } + } + + return nullptr; + } +}; + +struct UIDocumentModel { + UIDocumentKind kind = UIDocumentKind::View; + Containers::String sourcePath; + Containers::String displayName; + UIDocumentNode rootNode; + Containers::Array dependencies; + Containers::Array diagnostics; + bool valid = false; + + void Clear() { + sourcePath.Clear(); + displayName.Clear(); + rootNode = UIDocumentNode(); + dependencies.Clear(); + diagnostics.Clear(); + valid = false; + } +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/include/XCEngine/Resources/UI/UIDocuments.h b/engine/include/XCEngine/Resources/UI/UIDocuments.h new file mode 100644 index 00000000..2dcd50b6 --- /dev/null +++ b/engine/include/XCEngine/Resources/UI/UIDocuments.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace Resources { + +class UIDocumentResource : public IResource { +public: + const Containers::String& GetName() const override { return m_name; } + const Containers::String& GetPath() const override { return m_path; } + ResourceGUID GetGUID() const override { return m_guid; } + bool IsValid() const override { return m_isValid; } + size_t GetMemorySize() const override { return m_memorySize; } + void Release() override; + + const UIDocumentModel& GetDocument() const { return m_document; } + const UIDocumentNode& GetRootNode() const { return m_document.rootNode; } + const Containers::Array& GetDependencies() const { return m_document.dependencies; } + const Containers::Array& GetDiagnostics() const { return m_document.diagnostics; } + const Containers::String& GetSourcePath() const { return m_document.sourcePath; } + + void SetDocumentModel(const UIDocumentModel& document); + void SetDocumentModel(UIDocumentModel&& document); + +protected: + void RecalculateMemorySize(); + + UIDocumentModel m_document = {}; +}; + +class UIView : public UIDocumentResource { +public: + ResourceType GetType() const override { return ResourceType::UIView; } +}; + +class UITheme : public UIDocumentResource { +public: + ResourceType GetType() const override { return ResourceType::UITheme; } +}; + +class UISchema : public UIDocumentResource { +public: + ResourceType GetType() const override { return ResourceType::UISchema; } +}; + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Core/Asset/AssetDatabase.cpp b/engine/src/Core/Asset/AssetDatabase.cpp index 347cf210..411829bc 100644 --- a/engine/src/Core/Asset/AssetDatabase.cpp +++ b/engine/src/Core/Asset/AssetDatabase.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -613,34 +614,21 @@ void DestroyImportedMesh(Mesh* mesh) { return; } - std::vector materials; - materials.reserve(mesh->GetMaterials().Size()); - for (Material* material : mesh->GetMaterials()) { - if (material != nullptr) { - materials.push_back(material); - } - } - - std::vector textures; - textures.reserve(mesh->GetTextures().Size()); - for (Texture* texture : mesh->GetTextures()) { - if (texture != nullptr) { - textures.push_back(texture); - } - } - delete mesh; - for (Material* material : materials) { - delete material; - } - for (Texture* texture : textures) { - delete texture; - } } } // namespace +void AssetDatabase::ClearLastErrorMessage() { + m_lastErrorMessage.Clear(); +} + +void AssetDatabase::SetLastErrorMessage(const Containers::String& message) { + m_lastErrorMessage = message; +} + void AssetDatabase::Initialize(const Containers::String& projectRoot) { + ClearLastErrorMessage(); m_projectRoot = NormalizePathString(projectRoot); m_assetsRoot = NormalizePathString(fs::path(m_projectRoot.CStr()) / "Assets"); m_libraryRoot = NormalizePathString(fs::path(m_projectRoot.CStr()) / "Library"); @@ -650,12 +638,12 @@ void AssetDatabase::Initialize(const Containers::String& projectRoot) { EnsureProjectLayout(); LoadSourceAssetDB(); LoadArtifactDB(); - ScanAssets(); } void AssetDatabase::Shutdown() { SaveSourceAssetDB(); SaveArtifactDB(); + ClearLastErrorMessage(); m_projectRoot.Clear(); m_assetsRoot.Clear(); @@ -668,6 +656,7 @@ void AssetDatabase::Shutdown() { } AssetDatabase::MaintenanceStats AssetDatabase::Refresh() { + ClearLastErrorMessage(); return ScanAssets(); } @@ -767,6 +756,7 @@ bool AssetDatabase::ReimportAsset(const Containers::String& requestPath, ResolvedAsset& outAsset, MaintenanceStats* outStats) { outAsset = ResolvedAsset(); + ClearLastErrorMessage(); if (outStats != nullptr) { *outStats = MaintenanceStats(); } @@ -774,26 +764,33 @@ bool AssetDatabase::ReimportAsset(const Containers::String& requestPath, Containers::String absolutePath; Containers::String relativePath; if (!ResolvePath(requestPath, absolutePath, relativePath) || relativePath.Empty()) { + SetLastErrorMessage(Containers::String("Unable to resolve asset path: ") + requestPath); return false; } const fs::path absoluteFsPath(absolutePath.CStr()); if (!fs::exists(absoluteFsPath) || fs::is_directory(absoluteFsPath)) { + SetLastErrorMessage(Containers::String("Asset source file does not exist: ") + absolutePath); return false; } SourceAssetRecord sourceRecord; if (!EnsureMetaForPath(absoluteFsPath, false, sourceRecord)) { + SetLastErrorMessage(Containers::String("Failed to prepare asset metadata: ") + absolutePath); return false; } const ResourceType primaryType = GetPrimaryResourceTypeForImporter(sourceRecord.importerName); if (primaryType == ResourceType::Unknown) { + SetLastErrorMessage(Containers::String("Asset type is not importable: ") + requestPath); return false; } ArtifactRecord rebuiltRecord; if (!ImportAsset(sourceRecord, rebuiltRecord)) { + if (m_lastErrorMessage.Empty()) { + SetLastErrorMessage(Containers::String("Failed to import asset: ") + requestPath); + } return false; } @@ -811,10 +808,12 @@ bool AssetDatabase::ReimportAsset(const Containers::String& requestPath, } PopulateResolvedAssetResult(m_projectRoot, sourceRecord, rebuiltRecord, true, outAsset); + ClearLastErrorMessage(); return true; } bool AssetDatabase::ReimportAllAssets(MaintenanceStats* outStats) { + ClearLastErrorMessage(); if (outStats != nullptr) { *outStats = MaintenanceStats(); } @@ -1350,6 +1349,15 @@ Containers::String AssetDatabase::GetImporterNameForPath(const Containers::Strin } const std::string ext = ToLowerCopy(fs::path(relativePath.CStr()).extension().string()); + if (ext == ".xcui") { + return Containers::String("UIViewImporter"); + } + if (ext == ".xctheme") { + return Containers::String("UIThemeImporter"); + } + if (ext == ".xcschema") { + return Containers::String("UISchemaImporter"); + } if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".tga" || ext == ".gif" || ext == ".hdr") { return Containers::String("TextureImporter"); } @@ -1367,6 +1375,15 @@ Containers::String AssetDatabase::GetImporterNameForPath(const Containers::Strin } ResourceType AssetDatabase::GetPrimaryResourceTypeForImporter(const Containers::String& importerName) { + if (importerName == "UIViewImporter") { + return ResourceType::UIView; + } + if (importerName == "UIThemeImporter") { + return ResourceType::UITheme; + } + if (importerName == "UISchemaImporter") { + return ResourceType::UISchema; + } if (importerName == "TextureImporter") { return ResourceType::Texture; } @@ -1410,6 +1427,12 @@ bool AssetDatabase::ImportAsset(const SourceAssetRecord& sourceRecord, ArtifactRecord& outRecord) { const ResourceType primaryType = GetPrimaryResourceTypeForImporter(sourceRecord.importerName); switch (primaryType) { + case ResourceType::UIView: + return ImportUIDocumentAsset(sourceRecord, UIDocumentKind::View, "main.xcuiasset", ResourceType::UIView, outRecord); + case ResourceType::UITheme: + return ImportUIDocumentAsset(sourceRecord, UIDocumentKind::Theme, "main.xcthemeasset", ResourceType::UITheme, outRecord); + case ResourceType::UISchema: + return ImportUIDocumentAsset(sourceRecord, UIDocumentKind::Schema, "main.xcschemaasset", ResourceType::UISchema, outRecord); case ResourceType::Texture: return ImportTextureAsset(sourceRecord, outRecord); case ResourceType::Material: @@ -1419,6 +1442,7 @@ bool AssetDatabase::ImportAsset(const SourceAssetRecord& sourceRecord, case ResourceType::Shader: return ImportShaderAsset(sourceRecord, outRecord); default: + SetLastErrorMessage(Containers::String("No importer available for asset: ") + sourceRecord.relativePath); return false; } } @@ -1427,10 +1451,12 @@ bool AssetDatabase::EnsureArtifact(const Containers::String& requestPath, ResourceType requestedType, ResolvedAsset& outAsset) { outAsset = ResolvedAsset(); + ClearLastErrorMessage(); Containers::String absolutePath; Containers::String relativePath; if (!ResolvePath(requestPath, absolutePath, relativePath) || relativePath.Empty()) { + SetLastErrorMessage(Containers::String("Unable to resolve asset path: ") + requestPath); if (ShouldTraceAssetPath(requestPath)) { Debug::Logger::Get().Info( Debug::LogCategory::FileSystem, @@ -1441,6 +1467,7 @@ bool AssetDatabase::EnsureArtifact(const Containers::String& requestPath, const fs::path absoluteFsPath(absolutePath.CStr()); if (!fs::exists(absoluteFsPath)) { + SetLastErrorMessage(Containers::String("Asset source file does not exist: ") + absolutePath); if (ShouldTraceAssetPath(requestPath)) { Debug::Logger::Get().Info( Debug::LogCategory::FileSystem, @@ -1454,6 +1481,7 @@ bool AssetDatabase::EnsureArtifact(const Containers::String& requestPath, SourceAssetRecord sourceRecord; if (!EnsureMetaForPath(absoluteFsPath, fs::is_directory(absoluteFsPath), sourceRecord)) { + SetLastErrorMessage(Containers::String("Failed to prepare asset metadata: ") + absolutePath); return false; } @@ -1472,6 +1500,13 @@ bool AssetDatabase::EnsureArtifact(const Containers::String& requestPath, const ResourceType primaryType = GetPrimaryResourceTypeForImporter(sourceRecord.importerName); if (primaryType == ResourceType::Unknown || primaryType != requestedType) { + SetLastErrorMessage( + Containers::String("Asset type mismatch for ") + + requestPath + + ": requested " + + GetResourceTypeName(requestedType) + + ", importer produces " + + GetResourceTypeName(primaryType)); if (ShouldTraceAssetPath(requestPath)) { Debug::Logger::Get().Info( Debug::LogCategory::FileSystem, @@ -1499,6 +1534,9 @@ bool AssetDatabase::EnsureArtifact(const Containers::String& requestPath, } ArtifactRecord rebuiltRecord; if (!ImportAsset(sourceRecord, rebuiltRecord)) { + if (m_lastErrorMessage.Empty()) { + SetLastErrorMessage(Containers::String("Failed to import asset: ") + requestPath); + } if (ShouldTraceAssetPath(requestPath)) { Debug::Logger::Get().Error( Debug::LogCategory::FileSystem, @@ -1518,11 +1556,13 @@ bool AssetDatabase::EnsureArtifact(const Containers::String& requestPath, } if (artifactRecord == nullptr) { + SetLastErrorMessage(Containers::String("Imported asset did not produce an artifact: ") + requestPath); return false; } outAsset.exists = true; PopulateResolvedAssetResult(m_projectRoot, sourceRecord, *artifactRecord, outAsset.imported, outAsset); + ClearLastErrorMessage(); if (ShouldTraceAssetPath(requestPath)) { Debug::Logger::Get().Info( @@ -1789,6 +1829,88 @@ bool AssetDatabase::ImportShaderAsset(const SourceAssetRecord& sourceRecord, return true; } +bool AssetDatabase::ImportUIDocumentAsset(const SourceAssetRecord& sourceRecord, + UIDocumentKind kind, + const char* artifactFileName, + ResourceType resourceType, + ArtifactRecord& outRecord) { + const Containers::String absolutePath = + NormalizePathString(fs::path(m_projectRoot.CStr()) / sourceRecord.relativePath.CStr()); + + UIDocumentCompileResult compileResult = {}; + if (!CompileUIDocument( + UIDocumentCompileRequest{kind, absolutePath, GetUIDocumentDefaultRootTag(kind)}, + compileResult)) { + SetLastErrorMessage( + !compileResult.errorMessage.Empty() + ? compileResult.errorMessage + : Containers::String("Failed to compile UI document: ") + sourceRecord.relativePath); + return false; + } + + std::vector dependencies; + dependencies.reserve(compileResult.document.dependencies.Size()); + std::unordered_set seenDependencyPaths; + for (const Containers::String& dependencyPath : compileResult.document.dependencies) { + if (dependencyPath.Empty()) { + continue; + } + + ArtifactDependencyRecord dependency; + if (!CaptureDependencyRecord(fs::path(dependencyPath.CStr()), dependency)) { + SetLastErrorMessage( + Containers::String("Failed to capture XCUI dependency metadata: ") + dependencyPath); + return false; + } + + const std::string dependencyKey = ToStdString(dependency.path); + if (seenDependencyPaths.insert(dependencyKey).second) { + dependencies.push_back(std::move(dependency)); + } + } + + const Containers::String artifactKey = BuildArtifactKey(sourceRecord, dependencies); + const Containers::String artifactDir = BuildArtifactDirectory(artifactKey); + const Containers::String mainArtifactPath = + NormalizePathString(fs::path(artifactDir.CStr()) / artifactFileName); + + std::error_code ec; + fs::remove_all(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec); + ec.clear(); + fs::create_directories(fs::path(m_projectRoot.CStr()) / artifactDir.CStr(), ec); + if (ec) { + SetLastErrorMessage( + Containers::String("Failed to create UI artifact directory: ") + artifactDir); + return false; + } + + Containers::String writeErrorMessage; + const Containers::String absoluteArtifactPath = + NormalizePathString(fs::path(m_projectRoot.CStr()) / mainArtifactPath.CStr()); + if (!WriteUIDocumentArtifact(absoluteArtifactPath, compileResult, &writeErrorMessage)) { + SetLastErrorMessage( + !writeErrorMessage.Empty() + ? writeErrorMessage + : Containers::String("Failed to write UI document artifact: ") + mainArtifactPath); + return false; + } + + outRecord.artifactKey = artifactKey; + outRecord.assetGuid = sourceRecord.guid; + outRecord.importerName = sourceRecord.importerName; + outRecord.importerVersion = sourceRecord.importerVersion; + outRecord.resourceType = resourceType; + outRecord.artifactDirectory = artifactDir; + outRecord.mainArtifactPath = mainArtifactPath; + outRecord.sourceHash = sourceRecord.sourceHash; + outRecord.metaHash = sourceRecord.metaHash; + outRecord.sourceFileSize = sourceRecord.sourceFileSize; + outRecord.sourceWriteTime = sourceRecord.sourceWriteTime; + outRecord.mainLocalID = kMainAssetLocalID; + outRecord.dependencies = std::move(dependencies); + return true; +} + Containers::String AssetDatabase::BuildArtifactKey( const AssetDatabase::SourceAssetRecord& sourceRecord, const std::vector& dependencies) const { @@ -1891,10 +2013,6 @@ bool AssetDatabase::AreDependenciesCurrent( currentWriteTime != dependency.writeTime) { return false; } - - if (ComputeFileHash(resolvedPath) != dependency.hash) { - return false; - } } return true; diff --git a/engine/src/Core/Asset/AssetImportService.cpp b/engine/src/Core/Asset/AssetImportService.cpp index 3b68cab1..b5f57264 100644 --- a/engine/src/Core/Asset/AssetImportService.cpp +++ b/engine/src/Core/Asset/AssetImportService.cpp @@ -1,8 +1,42 @@ #include +#include + +#include +#include namespace XCEngine { namespace Resources { +namespace fs = std::filesystem; + +namespace { + +Containers::String ToContainersString(const std::string& value) { + return Containers::String(value.c_str()); +} + +Containers::String BuildStatsSuffix(const AssetDatabase::MaintenanceStats& stats) { + std::string suffix; + if (stats.importedAssetCount > 0) { + suffix += " imported="; + suffix += std::to_string(stats.importedAssetCount); + } + if (stats.removedArtifactCount > 0) { + suffix += " removedOrphans="; + suffix += std::to_string(stats.removedArtifactCount); + } + + return ToContainersString(suffix); +} + +Core::uint64 GetCurrentSteadyTimeMs() { + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + +} // namespace + void AssetImportService::Initialize() { } @@ -10,6 +44,7 @@ void AssetImportService::Shutdown() { std::lock_guard lock(m_mutex); m_assetDatabase.Shutdown(); m_projectRoot.Clear(); + ResetImportStatusLocked(); } void AssetImportService::SetProjectRoot(const Containers::String& projectRoot) { @@ -24,6 +59,7 @@ void AssetImportService::SetProjectRoot(const Containers::String& projectRoot) { } m_projectRoot = projectRoot; + ResetImportStatusLocked(); if (!m_projectRoot.Empty()) { m_assetDatabase.Initialize(m_projectRoot); } @@ -34,22 +70,225 @@ Containers::String AssetImportService::GetProjectRoot() const { return m_projectRoot; } +Containers::String AssetImportService::GetLibraryRoot() const { + std::lock_guard lock(m_mutex); + return m_assetDatabase.GetLibraryRoot(); +} + +bool AssetImportService::BootstrapProject() { + std::lock_guard lock(m_mutex); + if (m_projectRoot.Empty()) { + FinishImportStatusLocked( + "Bootstrap Project", + Containers::String(), + false, + "Cannot bootstrap project assets without an active project.", + AssetDatabase::MaintenanceStats()); + return false; + } + + BeginImportStatusLocked( + "Bootstrap Project", + m_projectRoot, + "Bootstrapping project Library state..."); + + const AssetDatabase::MaintenanceStats stats = m_assetDatabase.Refresh(); + FinishImportStatusLocked( + "Bootstrap Project", + m_projectRoot, + true, + Containers::String("Bootstrapped project Library state.") + BuildStatsSuffix(stats), + stats); + return true; +} + void AssetImportService::Refresh() { std::lock_guard lock(m_mutex); if (!m_projectRoot.Empty()) { - m_assetDatabase.Refresh(); + const AssetDatabase::MaintenanceStats stats = m_assetDatabase.Refresh(); + if (stats.removedArtifactCount > 0) { + FinishImportStatusLocked( + "Refresh", + m_projectRoot, + true, + Containers::String("Refresh removed orphan artifact entries.") + BuildStatsSuffix(stats), + stats); + } } } +bool AssetImportService::ClearLibraryCache() { + std::lock_guard lock(m_mutex); + if (m_projectRoot.Empty()) { + FinishImportStatusLocked( + "Clear Library", + Containers::String(), + false, + "Cannot clear Library cache without an active project.", + AssetDatabase::MaintenanceStats()); + return false; + } + + BeginImportStatusLocked( + "Clear Library", + m_assetDatabase.GetLibraryRoot(), + "Clearing Library cache..."); + + const Containers::String projectRoot = m_projectRoot; + const Containers::String libraryRoot = m_assetDatabase.GetLibraryRoot(); + + m_assetDatabase.Shutdown(); + + std::error_code ec; + fs::remove_all(fs::path(libraryRoot.CStr()), ec); + + m_assetDatabase.Initialize(projectRoot); + const AssetDatabase::MaintenanceStats stats = m_assetDatabase.Refresh(); + const bool succeeded = !ec; + FinishImportStatusLocked( + "Clear Library", + libraryRoot, + succeeded, + succeeded + ? Containers::String("Cleared Library cache and rebuilt source asset lookup.") + BuildStatsSuffix(stats) + : Containers::String("Failed to clear Library cache."), + stats); + return succeeded; +} + +bool AssetImportService::RebuildLibraryCache() { + if (!ClearLibraryCache()) { + return false; + } + + return ReimportAllAssets(); +} + +bool AssetImportService::ReimportAllAssets() { + std::lock_guard lock(m_mutex); + if (m_projectRoot.Empty()) { + FinishImportStatusLocked( + "Reimport All Assets", + Containers::String(), + false, + "Cannot reimport assets without an active project.", + AssetDatabase::MaintenanceStats()); + return false; + } + + BeginImportStatusLocked( + "Reimport All Assets", + m_projectRoot, + "Reimporting all project assets..."); + m_assetDatabase.Refresh(); + + AssetDatabase::MaintenanceStats stats; + const bool succeeded = m_assetDatabase.ReimportAllAssets(&stats); + FinishImportStatusLocked( + "Reimport All Assets", + m_projectRoot, + succeeded, + succeeded + ? Containers::String("Reimported all project assets.") + BuildStatsSuffix(stats) + : Containers::String("Reimport all assets completed with failures.") + BuildStatsSuffix(stats), + stats); + return succeeded; +} + +bool AssetImportService::ReimportAsset(const Containers::String& requestPath, + ImportedAsset& outAsset) { + std::lock_guard lock(m_mutex); + if (m_projectRoot.Empty()) { + FinishImportStatusLocked( + "Reimport Asset", + requestPath, + false, + "Cannot reimport an asset without an active project.", + AssetDatabase::MaintenanceStats()); + return false; + } + + BeginImportStatusLocked( + "Reimport Asset", + requestPath, + Containers::String("Reimporting asset: ") + requestPath); + m_assetDatabase.Refresh(); + + AssetDatabase::ResolvedAsset resolvedAsset; + AssetDatabase::MaintenanceStats stats; + if (!m_assetDatabase.ReimportAsset(requestPath, resolvedAsset, &stats)) { + const Containers::String databaseError = m_assetDatabase.GetLastErrorMessage(); + FinishImportStatusLocked( + "Reimport Asset", + requestPath, + false, + !databaseError.Empty() + ? Containers::String("Failed to reimport asset: ") + requestPath + " - " + databaseError + : Containers::String("Failed to reimport asset: ") + requestPath, + stats); + return false; + } + + outAsset = ConvertResolvedAsset(resolvedAsset); + FinishImportStatusLocked( + "Reimport Asset", + requestPath, + true, + Containers::String("Reimported asset: ") + requestPath + BuildStatsSuffix(stats), + stats); + return true; +} + +AssetImportService::ImportStatusSnapshot AssetImportService::GetLastImportStatus() const { + std::lock_guard lock(m_mutex); + return m_lastImportStatus; +} + +bool AssetImportService::TryGetImportableResourceType(const Containers::String& requestPath, + ResourceType& outType) const { + std::lock_guard lock(m_mutex); + if (m_projectRoot.Empty()) { + outType = ResourceType::Unknown; + return false; + } + + return m_assetDatabase.TryGetImportableResourceType(requestPath, outType); +} + bool AssetImportService::EnsureArtifact(const Containers::String& requestPath, ResourceType requestedType, - AssetDatabase::ResolvedAsset& outAsset) { + ImportedAsset& outAsset) { std::lock_guard lock(m_mutex); if (m_projectRoot.Empty()) { return false; } - return m_assetDatabase.EnsureArtifact(requestPath, requestedType, outAsset); + AssetDatabase::ResolvedAsset resolvedAsset; + if (!m_assetDatabase.EnsureArtifact(requestPath, requestedType, resolvedAsset)) { + const Containers::String databaseError = m_assetDatabase.GetLastErrorMessage(); + FinishImportStatusLocked( + "Import Asset", + requestPath, + false, + !databaseError.Empty() + ? Containers::String("Failed to build asset artifact: ") + requestPath + " - " + databaseError + : Containers::String("Failed to build asset artifact: ") + requestPath, + AssetDatabase::MaintenanceStats()); + return false; + } + + outAsset = ConvertResolvedAsset(resolvedAsset); + if (resolvedAsset.imported) { + AssetDatabase::MaintenanceStats stats; + stats.importedAssetCount = 1; + FinishImportStatusLocked( + "Import Asset", + requestPath, + true, + Containers::String("Imported asset artifact: ") + requestPath, + stats); + } + return true; } bool AssetImportService::TryGetAssetRef(const Containers::String& requestPath, @@ -72,16 +311,83 @@ bool AssetImportService::TryGetPrimaryAssetPath(const AssetGUID& guid, Container return m_assetDatabase.TryGetPrimaryAssetPath(guid, outRelativePath); } -void AssetImportService::BuildLookupSnapshot(std::unordered_map& outPathToGuid, - std::unordered_map& outGuidToPath) const { +void AssetImportService::BuildLookupSnapshot(LookupSnapshot& outSnapshot) const { std::lock_guard lock(m_mutex); - outPathToGuid.clear(); - outGuidToPath.clear(); + outSnapshot.Clear(); if (m_projectRoot.Empty()) { return; } - m_assetDatabase.BuildLookupSnapshot(outPathToGuid, outGuidToPath); + m_assetDatabase.BuildLookupSnapshot(outSnapshot.assetGuidByPathKey, outSnapshot.assetPathByGuid); +} + +AssetImportService::ImportedAsset AssetImportService::ConvertResolvedAsset( + const AssetDatabase::ResolvedAsset& resolvedAsset) { + ImportedAsset importedAsset; + importedAsset.exists = resolvedAsset.exists; + importedAsset.artifactReady = resolvedAsset.artifactReady; + importedAsset.imported = resolvedAsset.imported; + importedAsset.absolutePath = resolvedAsset.absolutePath; + importedAsset.relativePath = resolvedAsset.relativePath; + importedAsset.assetGuid = resolvedAsset.assetGuid; + importedAsset.resourceType = resolvedAsset.resourceType; + importedAsset.runtimeLoadPath = + resolvedAsset.artifactReady ? resolvedAsset.artifactMainPath : resolvedAsset.absolutePath; + importedAsset.artifactDirectory = resolvedAsset.artifactDirectory; + importedAsset.mainLocalID = resolvedAsset.mainLocalID; + return importedAsset; +} + +void AssetImportService::ResetImportStatusLocked() { + m_lastImportStatus = ImportStatusSnapshot(); + m_nextImportStatusRevision = 1; +} + +void AssetImportService::BeginImportStatusLocked(const Containers::String& operation, + const Containers::String& targetPath, + const Containers::String& message) { + const Core::uint64 startedAtMs = GetCurrentSteadyTimeMs(); + m_lastImportStatus.revision = m_nextImportStatusRevision++; + m_lastImportStatus.inProgress = true; + m_lastImportStatus.success = false; + m_lastImportStatus.operation = operation; + m_lastImportStatus.targetPath = targetPath; + m_lastImportStatus.message = message; + m_lastImportStatus.startedAtMs = startedAtMs; + m_lastImportStatus.completedAtMs = 0; + m_lastImportStatus.durationMs = 0; + m_lastImportStatus.importedAssetCount = 0; + m_lastImportStatus.removedArtifactCount = 0; +} + +void AssetImportService::FinishImportStatusLocked(const Containers::String& operation, + const Containers::String& targetPath, + bool success, + const Containers::String& message, + const AssetDatabase::MaintenanceStats& stats) { + const Core::uint64 completedAtMs = GetCurrentSteadyTimeMs(); + const Core::uint64 startedAtMs = m_lastImportStatus.startedAtMs != 0 + ? m_lastImportStatus.startedAtMs + : completedAtMs; + m_lastImportStatus.revision = m_nextImportStatusRevision++; + m_lastImportStatus.inProgress = false; + m_lastImportStatus.success = success; + m_lastImportStatus.operation = operation; + m_lastImportStatus.targetPath = targetPath; + m_lastImportStatus.message = message; + m_lastImportStatus.startedAtMs = startedAtMs; + m_lastImportStatus.completedAtMs = completedAtMs; + m_lastImportStatus.durationMs = completedAtMs >= startedAtMs + ? completedAtMs - startedAtMs + : 0; + m_lastImportStatus.importedAssetCount = stats.importedAssetCount; + m_lastImportStatus.removedArtifactCount = stats.removedArtifactCount; + + if (success) { + Debug::Logger::Get().Info(Debug::LogCategory::FileSystem, Containers::String("[AssetImport] ") + message); + } else { + Debug::Logger::Get().Error(Debug::LogCategory::FileSystem, Containers::String("[AssetImport] ") + message); + } } } // namespace Resources diff --git a/engine/src/Core/Asset/ProjectAssetIndex.cpp b/engine/src/Core/Asset/ProjectAssetIndex.cpp index 604d9c94..6502007d 100644 --- a/engine/src/Core/Asset/ProjectAssetIndex.cpp +++ b/engine/src/Core/Asset/ProjectAssetIndex.cpp @@ -76,17 +76,16 @@ void ProjectAssetIndex::ResetProjectRoot(const Containers::String& projectRoot) } void ProjectAssetIndex::RefreshFrom(const AssetImportService& importService) { - std::unordered_map pathToGuid; - std::unordered_map guidToPath; + AssetImportService::LookupSnapshot snapshot; const Containers::String projectRoot = importService.GetProjectRoot(); if (!projectRoot.Empty()) { - importService.BuildLookupSnapshot(pathToGuid, guidToPath); + importService.BuildLookupSnapshot(snapshot); } std::unique_lock lock(m_mutex); m_projectRoot = projectRoot; - m_assetGuidByPathKey = std::move(pathToGuid); - m_assetPathByGuid = std::move(guidToPath); + m_assetGuidByPathKey = std::move(snapshot.assetGuidByPathKey); + m_assetPathByGuid = std::move(snapshot.assetPathByGuid); } bool ProjectAssetIndex::TryGetAssetRef(AssetImportService& importService, diff --git a/engine/src/Core/Asset/ResourceManager.cpp b/engine/src/Core/Asset/ResourceManager.cpp index 1c4f2380..cd816ac4 100644 --- a/engine/src/Core/Asset/ResourceManager.cpp +++ b/engine/src/Core/Asset/ResourceManager.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace XCEngine { @@ -45,6 +46,9 @@ MaterialLoader g_materialLoader; MeshLoader g_meshLoader; ShaderLoader g_shaderLoader; TextureLoader g_textureLoader; +UIViewLoader g_uiViewLoader; +UIThemeLoader g_uiThemeLoader; +UISchemaLoader g_uiSchemaLoader; } // namespace @@ -85,6 +89,9 @@ void ResourceManager::EnsureInitialized() { RegisterBuiltinLoader(*this, g_meshLoader); RegisterBuiltinLoader(*this, g_shaderLoader); RegisterBuiltinLoader(*this, g_textureLoader); + RegisterBuiltinLoader(*this, g_uiViewLoader); + RegisterBuiltinLoader(*this, g_uiThemeLoader); + RegisterBuiltinLoader(*this, g_uiSchemaLoader); m_assetImportService.Initialize(); m_asyncLoader = std::move(asyncLoader); @@ -106,11 +113,12 @@ void ResourceManager::Shutdown() { } void ResourceManager::SetResourceRoot(const Containers::String& rootPath) { + EnsureInitialized(); m_resourceRoot = rootPath; if (!m_resourceRoot.Empty()) { ResourceFileSystem::Get().Initialize(rootPath); m_assetImportService.SetProjectRoot(rootPath); - m_projectAssetIndex.RefreshFrom(m_assetImportService); + BootstrapProjectAssets(); } else { m_assetImportService.SetProjectRoot(Containers::String()); ResourceFileSystem::Get().Shutdown(); @@ -122,6 +130,16 @@ const Containers::String& ResourceManager::GetResourceRoot() const { return m_resourceRoot; } +bool ResourceManager::BootstrapProjectAssets() { + if (m_resourceRoot.Empty()) { + return false; + } + + const bool bootstrapped = m_assetImportService.BootstrapProject(); + m_projectAssetIndex.RefreshFrom(m_assetImportService); + return bootstrapped; +} + void ResourceManager::AddRef(ResourceGUID guid) { std::lock_guard lock(m_mutex); @@ -199,6 +217,7 @@ void ResourceManager::Unload(ResourceGUID guid) { if (it != nullptr) { resource = *it; m_resourceCache.Erase(guid); + m_cache.Remove(guid); m_guidToPath.Erase(guid); m_memoryUsage -= resource->GetMemorySize(); } @@ -223,6 +242,7 @@ void ResourceManager::UnloadAll() { } m_resourceCache.Clear(); + m_cache.Clear(); m_refCounts.Clear(); m_guidToPath.Clear(); m_memoryUsage = 0; @@ -346,6 +366,7 @@ void ResourceManager::UnloadGroup(const Containers::Array& guids) if (it != nullptr) { IResource* resource = *it; m_resourceCache.Erase(guid); + m_cache.Remove(guid); m_guidToPath.Erase(guid); m_memoryUsage -= resource->GetMemorySize(); resourcesToRelease.PushBack(resource); @@ -360,13 +381,69 @@ void ResourceManager::UnloadGroup(const Containers::Array& guids) } } -void ResourceManager::RefreshAssetDatabase() { +void ResourceManager::RefreshProjectAssets() { if (!m_resourceRoot.Empty()) { m_assetImportService.Refresh(); m_projectAssetIndex.RefreshFrom(m_assetImportService); } } +bool ResourceManager::CanReimportProjectAsset(const Containers::String& path) const { + if (m_resourceRoot.Empty() || path.Empty()) { + return false; + } + + ResourceType importType = ResourceType::Unknown; + return m_assetImportService.TryGetImportableResourceType(path, importType); +} + +bool ResourceManager::ReimportProjectAsset(const Containers::String& path) { + if (m_resourceRoot.Empty() || path.Empty()) { + return false; + } + + UnloadAll(); + + AssetImportService::ImportedAsset importedAsset; + const bool reimported = m_assetImportService.ReimportAsset(path, importedAsset); + m_projectAssetIndex.RefreshFrom(m_assetImportService); + if (reimported && importedAsset.assetGuid.IsValid() && !importedAsset.relativePath.Empty()) { + m_projectAssetIndex.RememberResolvedPath(importedAsset.assetGuid, importedAsset.relativePath); + } + + return reimported; +} + +bool ResourceManager::ClearProjectLibraryCache() { + if (m_resourceRoot.Empty()) { + return false; + } + + UnloadAll(); + const bool cleared = m_assetImportService.ClearLibraryCache(); + m_projectAssetIndex.RefreshFrom(m_assetImportService); + return cleared; +} + +bool ResourceManager::RebuildProjectAssetCache() { + if (m_resourceRoot.Empty()) { + return false; + } + + UnloadAll(); + const bool rebuilt = m_assetImportService.RebuildLibraryCache(); + m_projectAssetIndex.RefreshFrom(m_assetImportService); + return rebuilt; +} + +Containers::String ResourceManager::GetProjectLibraryRoot() const { + return m_assetImportService.GetLibraryRoot(); +} + +AssetImportService::ImportStatusSnapshot ResourceManager::GetProjectAssetImportStatus() const { + return m_assetImportService.GetLastImportStatus(); +} + bool ResourceManager::TryGetAssetRef(const Containers::String& path, ResourceType resourceType, AssetRef& outRef) const { const bool resolved = m_projectAssetIndex.TryGetAssetRef(m_assetImportService, path, resourceType, outRef); @@ -510,12 +587,18 @@ LoadResult ResourceManager::LoadResource(const Containers::String& path, } Containers::String loadPath = path; - AssetDatabase::ResolvedAsset resolvedAsset; - if (!m_resourceRoot.Empty() && + AssetImportService::ImportedAsset resolvedAsset; + ResourceType importableType = ResourceType::Unknown; + const bool shouldUseProjectArtifact = + !m_resourceRoot.Empty() && + m_assetImportService.TryGetImportableResourceType(path, importableType) && + importableType == type; + + if (shouldUseProjectArtifact && m_assetImportService.EnsureArtifact(path, type, resolvedAsset) && resolvedAsset.artifactReady) { m_projectAssetIndex.RememberResolvedPath(resolvedAsset.assetGuid, resolvedAsset.relativePath); - loadPath = resolvedAsset.artifactMainPath; + loadPath = resolvedAsset.runtimeLoadPath; if (ShouldTraceResourcePath(path)) { Debug::Logger::Get().Info( Debug::LogCategory::FileSystem, diff --git a/engine/src/Resources/Material/Material.cpp b/engine/src/Resources/Material/Material.cpp index a8a114c3..187aa172 100644 --- a/engine/src/Resources/Material/Material.cpp +++ b/engine/src/Resources/Material/Material.cpp @@ -307,7 +307,16 @@ void WritePackedMaterialProperty(Core::uint8* destination, const MaterialPropert Material::Material() = default; -Material::~Material() = default; +Material::~Material() { + // Imported materials can own nested handles and container state; explicitly + // resetting them here avoids teardown-order issues during destruction. + m_shader.Reset(); + m_tags = Containers::Array(); + m_properties = Containers::HashMap(); + m_constantLayout = Containers::Array(); + m_constantBufferData = Containers::Array(); + m_textureBindings = Containers::Array(); +} void Material::Release() { m_shader.Reset(); diff --git a/engine/src/Resources/Mesh/Mesh.cpp b/engine/src/Resources/Mesh/Mesh.cpp index 9b9f7a82..0896282b 100644 --- a/engine/src/Resources/Mesh/Mesh.cpp +++ b/engine/src/Resources/Mesh/Mesh.cpp @@ -2,19 +2,42 @@ #include #include #include +#include namespace XCEngine { namespace Resources { +namespace { + +template +void DestroyOwnedResources(Containers::Array& resources) { + std::unordered_set releasedResources; + for (T* resource : resources) { + if (resource == nullptr) { + continue; + } + + if (releasedResources.insert(resource).second) { + delete resource; + } + } + + resources.Clear(); +} + +} // namespace + Mesh::Mesh() = default; -Mesh::~Mesh() = default; +Mesh::~Mesh() { + Release(); +} void Mesh::Release() { m_vertexData.Clear(); m_indexData.Clear(); m_sections.Clear(); - m_materials.Clear(); - m_textures.Clear(); + DestroyOwnedResources(m_materials); + DestroyOwnedResources(m_textures); m_vertexCount = 0; m_vertexStride = 0; m_attributes = VertexAttribute::Position; diff --git a/engine/src/Resources/UI/UIDocumentCompiler.cpp b/engine/src/Resources/UI/UIDocumentCompiler.cpp new file mode 100644 index 00000000..388e5309 --- /dev/null +++ b/engine/src/Resources/UI/UIDocumentCompiler.cpp @@ -0,0 +1,878 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Resources { + +namespace fs = std::filesystem; + +namespace { + +Containers::String ToContainersString(const std::string& value) { + return Containers::String(value.c_str()); +} + +std::string ToStdString(const Containers::String& value) { + return std::string(value.CStr()); +} + +std::string ToLowerCopy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +Containers::String NormalizePathString(const fs::path& path) { + return Containers::String(path.lexically_normal().generic_string().c_str()); +} + +Containers::String FormatDiagnosticMessage(const Containers::String& path, + const UIDocumentSourceLocation& location, + const Containers::String& message) { + return path + + ":" + + Containers::String(std::to_string(location.line).c_str()) + + ":" + + Containers::String(std::to_string(location.column).c_str()) + + ": " + + message; +} + +void WriteString(std::ofstream& stream, const Containers::String& value) { + const Core::uint32 length = static_cast(value.Length()); + stream.write(reinterpret_cast(&length), sizeof(length)); + if (length > 0) { + stream.write(value.CStr(), length); + } +} + +bool ReadString(std::ifstream& stream, Containers::String& outValue) { + Core::uint32 length = 0; + stream.read(reinterpret_cast(&length), sizeof(length)); + if (!stream) { + return false; + } + + if (length == 0) { + outValue.Clear(); + return true; + } + + std::string buffer(length, '\0'); + stream.read(buffer.data(), length); + if (!stream) { + return false; + } + + outValue = ToContainersString(buffer); + return true; +} + +bool WriteNode(std::ofstream& stream, const UIDocumentNode& node) { + WriteString(stream, node.tagName); + + UIDocumentArtifactNodeHeader header; + header.attributeCount = static_cast(node.attributes.Size()); + header.childCount = static_cast(node.children.Size()); + header.line = node.location.line; + header.column = node.location.column; + header.selfClosing = node.selfClosing ? 1u : 0u; + stream.write(reinterpret_cast(&header), sizeof(header)); + if (!stream) { + return false; + } + + for (const UIDocumentAttribute& attribute : node.attributes) { + WriteString(stream, attribute.name); + WriteString(stream, attribute.value); + if (!stream) { + return false; + } + } + + for (const UIDocumentNode& child : node.children) { + if (!WriteNode(stream, child)) { + return false; + } + } + + return static_cast(stream); +} + +bool ReadNode(std::ifstream& stream, UIDocumentNode& outNode) { + if (!ReadString(stream, outNode.tagName)) { + return false; + } + + UIDocumentArtifactNodeHeader header; + stream.read(reinterpret_cast(&header), sizeof(header)); + if (!stream) { + return false; + } + + outNode.location.line = header.line; + outNode.location.column = header.column; + outNode.selfClosing = header.selfClosing != 0; + + outNode.attributes.Clear(); + outNode.children.Clear(); + outNode.attributes.Reserve(header.attributeCount); + outNode.children.Reserve(header.childCount); + + for (Core::uint32 index = 0; index < header.attributeCount; ++index) { + UIDocumentAttribute attribute; + if (!ReadString(stream, attribute.name) || + !ReadString(stream, attribute.value)) { + return false; + } + outNode.attributes.PushBack(std::move(attribute)); + } + + for (Core::uint32 index = 0; index < header.childCount; ++index) { + UIDocumentNode child; + if (!ReadNode(stream, child)) { + return false; + } + outNode.children.PushBack(std::move(child)); + } + + return true; +} + +bool IsNameStartChar(char ch) { + return std::isalpha(static_cast(ch)) != 0 || + ch == '_' || + ch == ':'; +} + +bool IsNameChar(char ch) { + return std::isalnum(static_cast(ch)) != 0 || + ch == '_' || + ch == '-' || + ch == ':' || + ch == '.'; +} + +bool LooksLikeUIDocumentReference(const Containers::String& value) { + const std::string trimmed = ToLowerCopy(ToStdString(value.Trim())); + return trimmed.size() > 5 && + (trimmed.size() >= 5 && trimmed.rfind(".xcui") == trimmed.size() - 5 || + trimmed.size() >= 8 && trimmed.rfind(".xctheme") == trimmed.size() - 8 || + trimmed.size() >= 9 && trimmed.rfind(".xcschema") == trimmed.size() - 9); +} + +void AppendUniqueDependency(const Containers::String& dependencyPath, + std::unordered_set& seenDependencies, + Containers::Array& outDependencies) { + const std::string key = ToLowerCopy(ToStdString(dependencyPath)); + if (seenDependencies.insert(key).second) { + outDependencies.PushBack(dependencyPath); + } +} + +bool CollectUIDocumentDependencies(const fs::path& sourcePath, + const UIDocumentNode& node, + Containers::Array& outDependencies, + Containers::Array& inOutDiagnostics, + Containers::String& outErrorMessage, + std::unordered_set& seenDependencies) { + const fs::path sourceDirectory = sourcePath.parent_path(); + + for (const UIDocumentAttribute& attribute : node.attributes) { + if (!LooksLikeUIDocumentReference(attribute.value)) { + continue; + } + + const Containers::String trimmedValue = attribute.value.Trim(); + fs::path dependencyPath(trimmedValue.CStr()); + if (!dependencyPath.is_absolute()) { + dependencyPath = sourceDirectory / dependencyPath; + } + + dependencyPath = dependencyPath.lexically_normal(); + if (!fs::exists(dependencyPath) || fs::is_directory(dependencyPath)) { + UIDocumentDiagnostic diagnostic; + diagnostic.severity = UIDocumentDiagnosticSeverity::Error; + diagnostic.location = node.location; + diagnostic.message = + Containers::String("Referenced UI document was not found: ") + trimmedValue; + inOutDiagnostics.PushBack(diagnostic); + outErrorMessage = FormatDiagnosticMessage( + NormalizePathString(sourcePath), + diagnostic.location, + diagnostic.message); + return false; + } + + AppendUniqueDependency( + NormalizePathString(dependencyPath), + seenDependencies, + outDependencies); + } + + for (const UIDocumentNode& child : node.children) { + if (!CollectUIDocumentDependencies( + sourcePath, + child, + outDependencies, + inOutDiagnostics, + outErrorMessage, + seenDependencies)) { + return false; + } + } + + return true; +} + +struct SourceFileReadResult { + bool succeeded = false; + fs::path resolvedPath; + std::string content; + Containers::String errorMessage; +}; + +bool ReadUIDocumentSourceFile(const Containers::String& path, SourceFileReadResult& outResult) { + outResult = SourceFileReadResult(); + if (path.Empty()) { + outResult.errorMessage = "UI document path is empty."; + return false; + } + + auto tryReadFile = [&](const fs::path& filePath) -> bool { + std::ifstream input(filePath, std::ios::binary); + if (!input.is_open()) { + return false; + } + + std::ostringstream buffer; + buffer << input.rdbuf(); + outResult.succeeded = static_cast(input) || input.eof(); + outResult.resolvedPath = filePath.lexically_normal(); + outResult.content = buffer.str(); + return outResult.succeeded; + }; + + const fs::path requestedPath(path.CStr()); + if (tryReadFile(requestedPath)) { + return true; + } + + if (!requestedPath.is_absolute()) { + const Containers::String resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (!resourceRoot.Empty() && + tryReadFile(fs::path(resourceRoot.CStr()) / requestedPath)) { + return true; + } + } + + outResult.errorMessage = Containers::String("Unable to read UI document source file: ") + path; + return false; +} + +class UIDocumentParser { +public: + UIDocumentParser(const std::string& source, + const fs::path& resolvedPath, + const Containers::String& expectedRootTag, + UIDocumentKind kind) + : m_source(source) + , m_resolvedPath(resolvedPath) + , m_expectedRootTag(expectedRootTag) + , m_kind(kind) { + } + + bool Parse(UIDocumentCompileResult& outResult) { + outResult = UIDocumentCompileResult(); + outResult.document.kind = m_kind; + outResult.document.sourcePath = NormalizePathString(m_resolvedPath); + + if (HasUtf8Bom()) { + Advance(); + Advance(); + Advance(); + } + + SkipWhitespaceAndTrivia(); + if (AtEnd()) { + AddError(CurrentLocation(), "UI document is empty."); + return Finalize(outResult, false); + } + + if (Peek() != '<') { + AddError(CurrentLocation(), "Expected '<' at the beginning of the UI document."); + return Finalize(outResult, false); + } + + UIDocumentNode rootNode; + if (!ParseElement(rootNode)) { + return Finalize(outResult, false); + } + + SkipWhitespaceAndTrivia(); + if (!AtEnd()) { + AddError(CurrentLocation(), "Unexpected trailing content after the root element."); + return Finalize(outResult, false); + } + + if (!m_expectedRootTag.Empty() && + rootNode.tagName != m_expectedRootTag) { + AddError( + rootNode.location, + Containers::String("Expected root element <") + + m_expectedRootTag + + ">, found <" + + rootNode.tagName + + ">."); + return Finalize(outResult, false); + } + + outResult.document.rootNode = std::move(rootNode); + if (const UIDocumentAttribute* nameAttribute = outResult.document.rootNode.FindAttribute("name"); + nameAttribute != nullptr && !nameAttribute->value.Empty()) { + outResult.document.displayName = nameAttribute->value; + } + + std::unordered_set seenDependencies; + if (!CollectUIDocumentDependencies( + m_resolvedPath, + outResult.document.rootNode, + outResult.document.dependencies, + outResult.document.diagnostics, + m_errorMessage, + seenDependencies)) { + return Finalize(outResult, false); + } + + outResult.document.valid = true; + outResult.succeeded = true; + return Finalize(outResult, true); + } + +private: + bool ParseElement(UIDocumentNode& outNode) { + const UIDocumentSourceLocation elementLocation = CurrentLocation(); + if (!Consume('<', "Expected '<' to start an element.")) { + return false; + } + + if (AtEnd()) { + AddError(elementLocation, "Unexpected end of file after '<'."); + return false; + } + + if (Peek() == '/' || Peek() == '!' || Peek() == '?') { + AddError(elementLocation, "Unexpected token while parsing an element."); + return false; + } + + Containers::String tagName; + if (!ParseName(tagName, "Expected an element tag name.")) { + return false; + } + + outNode.location = elementLocation; + outNode.tagName = tagName; + + while (!AtEnd()) { + SkipWhitespace(); + if (StartsWith("/>")) { + Advance(); + Advance(); + outNode.selfClosing = true; + return true; + } + + if (Peek() == '>') { + Advance(); + break; + } + + UIDocumentAttribute attribute; + if (!ParseAttribute(attribute)) { + return false; + } + outNode.attributes.PushBack(std::move(attribute)); + } + + if (AtEnd()) { + AddError(elementLocation, "Unexpected end of file before element start tag was closed."); + return false; + } + + while (true) { + SkipWhitespaceAndTrivia(); + if (AtEnd()) { + AddError( + elementLocation, + Containers::String("Unexpected end of file while parsing <") + + outNode.tagName + + ">."); + return false; + } + + if (StartsWith("', "Expected '>' to finish the closing tag.")) { + return false; + } + + if (closeName != outNode.tagName) { + AddError( + CurrentLocation(), + Containers::String("Closing tag does not match <" + + outNode.tagName + + ">."); + return false; + } + + return true; + } + + if (Peek() != '<') { + AddError(CurrentLocation(), "Text nodes are not supported in XCUI documents."); + return false; + } + + UIDocumentNode childNode; + if (!ParseElement(childNode)) { + return false; + } + outNode.children.PushBack(std::move(childNode)); + } + } + + bool ParseAttribute(UIDocumentAttribute& outAttribute) { + if (!ParseName(outAttribute.name, "Expected an attribute name.")) { + return false; + } + + SkipWhitespace(); + if (!Consume('=', "Expected '=' after the attribute name.")) { + return false; + } + + SkipWhitespace(); + return ParseQuotedString(outAttribute.value); + } + + bool ParseQuotedString(Containers::String& outValue) { + if (AtEnd()) { + AddError(CurrentLocation(), "Expected a quoted attribute value."); + return false; + } + + const char quote = Peek(); + if (quote != '"' && quote != '\'') { + AddError(CurrentLocation(), "Attribute values must use single or double quotes."); + return false; + } + + Advance(); + std::string value; + while (!AtEnd() && Peek() != quote) { + value.push_back(Peek()); + Advance(); + } + + if (AtEnd()) { + AddError(CurrentLocation(), "Unterminated attribute value."); + return false; + } + + Advance(); + outValue = ToContainersString(value); + return true; + } + + bool ParseName(Containers::String& outName, const char* errorMessage) { + if (AtEnd() || !IsNameStartChar(Peek())) { + AddError(CurrentLocation(), errorMessage); + return false; + } + + std::string name; + name.push_back(Peek()); + Advance(); + while (!AtEnd() && IsNameChar(Peek())) { + name.push_back(Peek()); + Advance(); + } + + outName = ToContainersString(name); + return true; + } + + void SkipWhitespaceAndTrivia() { + while (!AtEnd()) { + SkipWhitespace(); + if (StartsWith("")) { + Advance(); + } + + if (AtEnd()) { + AddError(location, "Unterminated XML comment."); + return false; + } + + Advance(); + Advance(); + Advance(); + return true; + } + + bool SkipProcessingInstruction() { + const UIDocumentSourceLocation location = CurrentLocation(); + Advance(); + Advance(); + + while (!AtEnd() && !StartsWith("?>")) { + Advance(); + } + + if (AtEnd()) { + AddError(location, "Unterminated processing instruction."); + return false; + } + + Advance(); + Advance(); + return true; + } + + bool Consume(char expected, const char* errorMessage) { + if (AtEnd() || Peek() != expected) { + AddError(CurrentLocation(), errorMessage); + return false; + } + + Advance(); + return true; + } + + void AddError(const UIDocumentSourceLocation& location, const Containers::String& message) { + UIDocumentDiagnostic diagnostic; + diagnostic.severity = UIDocumentDiagnosticSeverity::Error; + diagnostic.location = location; + diagnostic.message = message; + m_diagnostics.PushBack(diagnostic); + + if (m_errorMessage.Empty()) { + m_errorMessage = FormatDiagnosticMessage(NormalizePathString(m_resolvedPath), location, message); + } + } + + bool Finalize(UIDocumentCompileResult& outResult, bool succeeded) { + Containers::Array combinedDiagnostics; + combinedDiagnostics.Reserve( + m_diagnostics.Size() + + outResult.document.diagnostics.Size()); + for (const UIDocumentDiagnostic& diagnostic : m_diagnostics) { + combinedDiagnostics.PushBack(diagnostic); + } + for (const UIDocumentDiagnostic& diagnostic : outResult.document.diagnostics) { + combinedDiagnostics.PushBack(diagnostic); + } + outResult.document.diagnostics = std::move(combinedDiagnostics); + outResult.succeeded = succeeded; + outResult.errorMessage = succeeded ? Containers::String() : m_errorMessage; + if (!succeeded) { + outResult.document.valid = false; + } + return succeeded; + } + + bool HasUtf8Bom() const { + return m_source.size() >= 3 && + static_cast(m_source[0]) == 0xEF && + static_cast(m_source[1]) == 0xBB && + static_cast(m_source[2]) == 0xBF; + } + + bool StartsWith(const char* text) const { + const size_t length = std::strlen(text); + return m_offset + length <= m_source.size() && + m_source.compare(m_offset, length, text) == 0; + } + + bool AtEnd() const { + return m_offset >= m_source.size(); + } + + char Peek() const { + return AtEnd() ? '\0' : m_source[m_offset]; + } + + void Advance() { + if (AtEnd()) { + return; + } + + if (m_source[m_offset] == '\n') { + ++m_line; + m_column = 1; + } else { + ++m_column; + } + ++m_offset; + } + + UIDocumentSourceLocation CurrentLocation() const { + UIDocumentSourceLocation location; + location.line = m_line; + location.column = m_column; + return location; + } + + const std::string& m_source; + fs::path m_resolvedPath; + Containers::String m_expectedRootTag; + UIDocumentKind m_kind = UIDocumentKind::View; + size_t m_offset = 0; + Core::uint32 m_line = 1; + Core::uint32 m_column = 1; + Containers::Array m_diagnostics; + Containers::String m_errorMessage; +}; + +} // namespace + +bool CompileUIDocument(const UIDocumentCompileRequest& request, + UIDocumentCompileResult& outResult) { + outResult = UIDocumentCompileResult(); + + SourceFileReadResult readResult; + if (!ReadUIDocumentSourceFile(request.path, readResult)) { + outResult.errorMessage = readResult.errorMessage; + outResult.succeeded = false; + outResult.document.kind = request.kind; + return false; + } + + const Containers::String expectedRootTag = + request.expectedRootTag.Empty() + ? GetUIDocumentDefaultRootTag(request.kind) + : request.expectedRootTag; + + UIDocumentParser parser( + readResult.content, + readResult.resolvedPath, + expectedRootTag, + request.kind); + return parser.Parse(outResult); +} + +bool WriteUIDocumentArtifact(const Containers::String& artifactPath, + const UIDocumentCompileResult& compileResult, + Containers::String* outErrorMessage) { + if (!compileResult.succeeded || !compileResult.document.valid) { + if (outErrorMessage != nullptr) { + *outErrorMessage = "UI document compile result is invalid."; + } + return false; + } + + std::ofstream output(artifactPath.CStr(), std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Unable to write UI document artifact: ") + artifactPath; + } + return false; + } + + UIDocumentArtifactFileHeader header; + header.kind = static_cast(compileResult.document.kind); + header.dependencyCount = static_cast(compileResult.document.dependencies.Size()); + header.diagnosticCount = static_cast(compileResult.document.diagnostics.Size()); + output.write(reinterpret_cast(&header), sizeof(header)); + if (!output) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to write UI document artifact header: ") + artifactPath; + } + return false; + } + + WriteString(output, compileResult.document.sourcePath); + WriteString(output, compileResult.document.displayName); + if (!WriteNode(output, compileResult.document.rootNode)) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to write UI document node tree: ") + artifactPath; + } + return false; + } + + for (const Containers::String& dependency : compileResult.document.dependencies) { + WriteString(output, dependency); + if (!output) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to write UI document dependencies: ") + artifactPath; + } + return false; + } + } + + for (const UIDocumentDiagnostic& diagnostic : compileResult.document.diagnostics) { + UIDocumentArtifactDiagnosticHeader diagnosticHeader; + diagnosticHeader.severity = static_cast(diagnostic.severity); + diagnosticHeader.line = diagnostic.location.line; + diagnosticHeader.column = diagnostic.location.column; + output.write(reinterpret_cast(&diagnosticHeader), sizeof(diagnosticHeader)); + WriteString(output, diagnostic.message); + if (!output) { + if (outErrorMessage != nullptr) { + *outErrorMessage = Containers::String("Failed to write UI document diagnostics: ") + artifactPath; + } + return false; + } + } + + return true; +} + +bool LoadUIDocumentArtifact(const Containers::String& artifactPath, + UIDocumentKind expectedKind, + UIDocumentCompileResult& outResult) { + outResult = UIDocumentCompileResult(); + + std::ifstream input(artifactPath.CStr(), std::ios::binary); + if (!input.is_open()) { + outResult.errorMessage = Containers::String("Unable to open UI document artifact: ") + artifactPath; + return false; + } + + UIDocumentArtifactFileHeader header; + input.read(reinterpret_cast(&header), sizeof(header)); + if (!input) { + outResult.errorMessage = Containers::String("Failed to read UI document artifact header: ") + artifactPath; + return false; + } + + const UIDocumentArtifactFileHeader expectedHeader; + if (std::memcmp(header.magic, expectedHeader.magic, sizeof(header.magic)) != 0) { + outResult.errorMessage = Containers::String("Invalid UI document artifact magic: ") + artifactPath; + return false; + } + + if (header.schemaVersion != kUIDocumentArtifactSchemaVersion) { + outResult.errorMessage = Containers::String("Unsupported UI document artifact schema version: ") + artifactPath; + return false; + } + + if (header.kind != static_cast(expectedKind)) { + outResult.errorMessage = Containers::String("UI document artifact kind mismatch: ") + artifactPath; + return false; + } + + outResult.document.kind = expectedKind; + if (!ReadString(input, outResult.document.sourcePath) || + !ReadString(input, outResult.document.displayName) || + !ReadNode(input, outResult.document.rootNode)) { + outResult.errorMessage = Containers::String("Failed to read UI document artifact body: ") + artifactPath; + return false; + } + + outResult.document.dependencies.Clear(); + outResult.document.dependencies.Reserve(header.dependencyCount); + for (Core::uint32 index = 0; index < header.dependencyCount; ++index) { + Containers::String dependency; + if (!ReadString(input, dependency)) { + outResult.errorMessage = Containers::String("Failed to read UI document artifact dependencies: ") + artifactPath; + return false; + } + outResult.document.dependencies.PushBack(std::move(dependency)); + } + + outResult.document.diagnostics.Clear(); + outResult.document.diagnostics.Reserve(header.diagnosticCount); + for (Core::uint32 index = 0; index < header.diagnosticCount; ++index) { + UIDocumentArtifactDiagnosticHeader diagnosticHeader; + input.read(reinterpret_cast(&diagnosticHeader), sizeof(diagnosticHeader)); + if (!input) { + outResult.errorMessage = Containers::String("Failed to read UI document artifact diagnostic header: ") + artifactPath; + return false; + } + + UIDocumentDiagnostic diagnostic; + diagnostic.severity = static_cast(diagnosticHeader.severity); + diagnostic.location.line = diagnosticHeader.line; + diagnostic.location.column = diagnosticHeader.column; + if (!ReadString(input, diagnostic.message)) { + outResult.errorMessage = Containers::String("Failed to read UI document artifact diagnostic message: ") + artifactPath; + return false; + } + + outResult.document.diagnostics.PushBack(std::move(diagnostic)); + } + + outResult.document.valid = true; + outResult.succeeded = true; + return true; +} + +Containers::String GetUIDocumentDefaultRootTag(UIDocumentKind kind) { + switch (kind) { + case UIDocumentKind::View: + return "View"; + case UIDocumentKind::Theme: + return "Theme"; + case UIDocumentKind::Schema: + return "Schema"; + default: + return "View"; + } +} + +} // namespace Resources +} // namespace XCEngine diff --git a/engine/src/Resources/UI/UIDocuments.cpp b/engine/src/Resources/UI/UIDocuments.cpp new file mode 100644 index 00000000..bd7cc94b --- /dev/null +++ b/engine/src/Resources/UI/UIDocuments.cpp @@ -0,0 +1,61 @@ +#include + +namespace XCEngine { +namespace Resources { + +namespace { + +size_t MeasureNodeMemorySize(const UIDocumentNode& node) { + size_t size = sizeof(UIDocumentNode) + node.tagName.Length(); + for (const UIDocumentAttribute& attribute : node.attributes) { + size += sizeof(UIDocumentAttribute); + size += attribute.name.Length(); + size += attribute.value.Length(); + } + + for (const UIDocumentNode& child : node.children) { + size += MeasureNodeMemorySize(child); + } + + return size; +} + +size_t MeasureDiagnosticMemorySize(const UIDocumentDiagnostic& diagnostic) { + return sizeof(UIDocumentDiagnostic) + diagnostic.message.Length(); +} + +} // namespace + +void UIDocumentResource::Release() { + m_document.Clear(); + SetInvalid(); + m_memorySize = 0; +} + +void UIDocumentResource::SetDocumentModel(const UIDocumentModel& document) { + m_document = document; + RecalculateMemorySize(); +} + +void UIDocumentResource::SetDocumentModel(UIDocumentModel&& document) { + m_document = std::move(document); + RecalculateMemorySize(); +} + +void UIDocumentResource::RecalculateMemorySize() { + size_t size = sizeof(*this); + size += m_document.sourcePath.Length(); + size += m_document.displayName.Length(); + size += MeasureNodeMemorySize(m_document.rootNode); + for (const Containers::String& dependency : m_document.dependencies) { + size += sizeof(Containers::String) + dependency.Length(); + } + for (const UIDocumentDiagnostic& diagnostic : m_document.diagnostics) { + size += MeasureDiagnosticMemorySize(diagnostic); + } + + m_memorySize = size; +} + +} // namespace Resources +} // namespace XCEngine diff --git a/tests/Resources/Mesh/test_mesh.cpp b/tests/Resources/Mesh/test_mesh.cpp index 8a4e83ca..903c3623 100644 --- a/tests/Resources/Mesh/test_mesh.cpp +++ b/tests/Resources/Mesh/test_mesh.cpp @@ -9,6 +9,26 @@ using namespace XCEngine::Math; namespace { +struct TrackingMaterial final : Material { + static int s_destructorCount; + + ~TrackingMaterial() override { + ++s_destructorCount; + } +}; + +int TrackingMaterial::s_destructorCount = 0; + +struct TrackingTexture final : Texture { + static int s_destructorCount; + + ~TrackingTexture() override { + ++s_destructorCount; + } +}; + +int TrackingTexture::s_destructorCount = 0; + TEST(Mesh, DefaultConstructor) { Mesh mesh; EXPECT_EQ(mesh.GetVertexCount(), 0u); @@ -88,6 +108,27 @@ TEST(Mesh, AddMaterial) { EXPECT_GT(mesh.GetMemorySize(), 0u); } +TEST(Mesh, ReleaseDeletesOwnedSubresourcesOnce) { + TrackingMaterial::s_destructorCount = 0; + TrackingTexture::s_destructorCount = 0; + + auto* mesh = new Mesh(); + mesh->AddMaterial(new TrackingMaterial()); + mesh->AddTexture(new TrackingTexture()); + + mesh->Release(); + + EXPECT_EQ(TrackingMaterial::s_destructorCount, 1); + EXPECT_EQ(TrackingTexture::s_destructorCount, 1); + EXPECT_EQ(mesh->GetMaterials().Size(), 0u); + EXPECT_EQ(mesh->GetTextures().Size(), 0u); + + delete mesh; + + EXPECT_EQ(TrackingMaterial::s_destructorCount, 1); + EXPECT_EQ(TrackingTexture::s_destructorCount, 1); +} + TEST(Mesh, SetBounds) { Mesh mesh; diff --git a/tests/Resources/Texture/test_texture_loader.cpp b/tests/Resources/Texture/test_texture_loader.cpp index fc670705..bedf7e32 100644 --- a/tests/Resources/Texture/test_texture_loader.cpp +++ b/tests/Resources/Texture/test_texture_loader.cpp @@ -169,4 +169,58 @@ TEST(TextureLoader, ResourceManagerLoadsTextureByAssetRefFromProjectAssets) { fs::remove_all(projectRoot); } +TEST(TextureLoader, ResourceManagerLoadsLibraryArtifactTextureWithoutReimportingIt) { + namespace fs = std::filesystem; + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + const fs::path projectRoot = fs::temp_directory_path() / "xc_texture_library_artifact_direct_load_test"; + const fs::path assetsDir = projectRoot / "Assets"; + const fs::path texturePath = assetsDir / "checker.bmp"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + fs::copy_file(GetTextureFixturePath("checker.bmp"), texturePath, fs::copy_options::overwrite_existing); + + manager.SetResourceRoot(projectRoot.string().c_str()); + const AssetImportService::ImportStatusSnapshot bootstrapStatus = manager.GetProjectAssetImportStatus(); + EXPECT_TRUE(bootstrapStatus.HasValue()); + EXPECT_TRUE(bootstrapStatus.success); + EXPECT_EQ(std::string(bootstrapStatus.operation.CStr()), "Bootstrap Project"); + EXPECT_GT(bootstrapStatus.startedAtMs, 0u); + EXPECT_GE(bootstrapStatus.completedAtMs, bootstrapStatus.startedAtMs); + EXPECT_EQ(bootstrapStatus.durationMs, bootstrapStatus.completedAtMs - bootstrapStatus.startedAtMs); + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + AssetDatabase::ResolvedAsset resolvedAsset; + ASSERT_TRUE(database.EnsureArtifact("Assets/checker.bmp", ResourceType::Texture, resolvedAsset)); + ASSERT_TRUE(resolvedAsset.artifactReady); + + std::error_code ec; + const fs::path relativeArtifactPath = + fs::relative(fs::path(resolvedAsset.artifactMainPath.CStr()), projectRoot, ec); + ASSERT_FALSE(ec); + ASSERT_FALSE(relativeArtifactPath.empty()); + + const auto textureHandle = manager.Load(relativeArtifactPath.generic_string().c_str()); + ASSERT_TRUE(textureHandle.IsValid()); + EXPECT_EQ(textureHandle->GetWidth(), 2u); + EXPECT_EQ(textureHandle->GetHeight(), 2u); + EXPECT_EQ(textureHandle->GetPath(), relativeArtifactPath.generic_string().c_str()); + const AssetImportService::ImportStatusSnapshot postLoadStatus = manager.GetProjectAssetImportStatus(); + EXPECT_TRUE(postLoadStatus.HasValue()); + EXPECT_EQ(std::string(postLoadStatus.operation.CStr()), "Bootstrap Project"); + EXPECT_GT(postLoadStatus.startedAtMs, 0u); + EXPECT_GE(postLoadStatus.completedAtMs, postLoadStatus.startedAtMs); + EXPECT_EQ(postLoadStatus.durationMs, postLoadStatus.completedAtMs - postLoadStatus.startedAtMs); + + database.Shutdown(); + manager.SetResourceRoot(""); + manager.Shutdown(); + fs::remove_all(projectRoot); +} + } // namespace diff --git a/tests/Resources/UI/CMakeLists.txt b/tests/Resources/UI/CMakeLists.txt new file mode 100644 index 00000000..829e4abd --- /dev/null +++ b/tests/Resources/UI/CMakeLists.txt @@ -0,0 +1,30 @@ +# ============================================================ +# UI Resource Tests +# ============================================================ + +set(UI_RESOURCE_TEST_SOURCES + test_ui_document_loader.cpp +) + +add_executable(ui_resource_tests ${UI_RESOURCE_TEST_SOURCES}) + +if(MSVC) + set_target_properties(ui_resource_tests PROPERTIES + LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib" + ) +endif() + +target_link_libraries(ui_resource_tests + PRIVATE + XCEngine + GTest::gtest + GTest::gtest_main +) + +target_include_directories(ui_resource_tests PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/tests/Fixtures +) + +include(GoogleTest) +gtest_discover_tests(ui_resource_tests) diff --git a/tests/Resources/UI/test_ui_document_loader.cpp b/tests/Resources/UI/test_ui_document_loader.cpp new file mode 100644 index 00000000..7eb8ad72 --- /dev/null +++ b/tests/Resources/UI/test_ui_document_loader.cpp @@ -0,0 +1,215 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace XCEngine::Resources; + +namespace { + +void WriteTextFile(const std::filesystem::path& path, const std::string& contents) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(output.is_open()); + output << contents; + ASSERT_TRUE(static_cast(output)); +} + +bool ContainsExtension(const XCEngine::Containers::Array& values, + const char* expectedValue) { + for (const auto& value : values) { + if (value == expectedValue) { + return true; + } + } + + return false; +} + +bool ContainsDependencyFile(const XCEngine::Containers::Array& dependencies, + const char* expectedFileName) { + namespace fs = std::filesystem; + + for (const auto& dependency : dependencies) { + if (fs::path(dependency.CStr()).filename().string() == expectedFileName) { + return true; + } + } + + return false; +} + +TEST(UIDocumentLoader, LoadersExposeExpectedTypesAndExtensions) { + UIViewLoader viewLoader; + UIThemeLoader themeLoader; + UISchemaLoader schemaLoader; + + EXPECT_EQ(viewLoader.GetResourceType(), ResourceType::UIView); + EXPECT_EQ(themeLoader.GetResourceType(), ResourceType::UITheme); + EXPECT_EQ(schemaLoader.GetResourceType(), ResourceType::UISchema); + + const auto viewExtensions = viewLoader.GetSupportedExtensions(); + EXPECT_TRUE(ContainsExtension(viewExtensions, "xcui")); + EXPECT_TRUE(ContainsExtension(viewExtensions, "xcuiasset")); + EXPECT_TRUE(viewLoader.CanLoad("panel.xcui")); + EXPECT_TRUE(viewLoader.CanLoad("panel.xcuiasset")); + EXPECT_FALSE(viewLoader.CanLoad("panel.txt")); + + const auto themeExtensions = themeLoader.GetSupportedExtensions(); + EXPECT_TRUE(ContainsExtension(themeExtensions, "xctheme")); + EXPECT_TRUE(ContainsExtension(themeExtensions, "xcthemeasset")); + EXPECT_TRUE(themeLoader.CanLoad("editor.xctheme")); + EXPECT_TRUE(themeLoader.CanLoad("editor.xcthemeasset")); + + const auto schemaExtensions = schemaLoader.GetSupportedExtensions(); + EXPECT_TRUE(ContainsExtension(schemaExtensions, "xcschema")); + EXPECT_TRUE(ContainsExtension(schemaExtensions, "xcschemaasset")); + EXPECT_TRUE(schemaLoader.CanLoad("entity.xcschema")); + EXPECT_TRUE(schemaLoader.CanLoad("entity.xcschemaasset")); +} + +TEST(UIDocumentLoader, CompileAndLoadViewTracksDependencies) { + namespace fs = std::filesystem; + + const fs::path root = fs::temp_directory_path() / "xc_ui_document_compile_test"; + fs::remove_all(root); + + WriteTextFile(root / "shared" / "toolbar.xcui", "\n"); + WriteTextFile(root / "themes" / "editor.xctheme", "\n"); + WriteTextFile(root / "schemas" / "entity.xcschema", "\n"); + WriteTextFile( + root / "main.xcui", + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + "\n"); + + UIViewLoader loader; + UIDocumentCompileResult compileResult = {}; + ASSERT_TRUE(loader.CompileDocument((root / "main.xcui").string().c_str(), compileResult)); + ASSERT_TRUE(compileResult.succeeded); + ASSERT_TRUE(compileResult.document.valid); + EXPECT_EQ(compileResult.document.rootNode.tagName, "View"); + EXPECT_EQ(compileResult.document.rootNode.children.Size(), 1u); + EXPECT_EQ(compileResult.document.rootNode.children[0].tagName, "Column"); + EXPECT_EQ(compileResult.document.dependencies.Size(), 3u); + EXPECT_TRUE(ContainsDependencyFile(compileResult.document.dependencies, "toolbar.xcui")); + EXPECT_TRUE(ContainsDependencyFile(compileResult.document.dependencies, "editor.xctheme")); + EXPECT_TRUE(ContainsDependencyFile(compileResult.document.dependencies, "entity.xcschema")); + + LoadResult loadResult = loader.Load((root / "main.xcui").string().c_str()); + ASSERT_TRUE(loadResult); + ASSERT_NE(loadResult.resource, nullptr); + + auto* view = static_cast(loadResult.resource); + ASSERT_NE(view, nullptr); + EXPECT_TRUE(view->IsValid()); + EXPECT_EQ(view->GetName(), "MainPanel"); + EXPECT_EQ(view->GetRootNode().tagName, "View"); + EXPECT_EQ(view->GetDependencies().Size(), 3u); + delete view; + + fs::remove_all(root); +} + +TEST(UIDocumentLoader, AssetDatabaseImportsViewArtifactAndReimportsWhenDependencyChanges) { + namespace fs = std::filesystem; + using namespace std::chrono_literals; + + const fs::path projectRoot = fs::temp_directory_path() / "xc_ui_artifact_reimport_test"; + const fs::path assetsRoot = projectRoot / "Assets"; + + fs::remove_all(projectRoot); + + WriteTextFile(assetsRoot / "UI" / "Shared" / "toolbar.xcui", "\n"); + WriteTextFile( + assetsRoot / "UI" / "Main.xcui", + "\n" + " \n" + "\n"); + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + AssetDatabase::ResolvedAsset firstResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/UI/Main.xcui", ResourceType::UIView, firstResolve)); + ASSERT_TRUE(firstResolve.artifactReady); + EXPECT_EQ(fs::path(firstResolve.artifactMainPath.CStr()).extension().string(), ".xcuiasset"); + EXPECT_TRUE(fs::exists(firstResolve.artifactMainPath.CStr())); + + UIViewLoader loader; + LoadResult firstLoad = loader.Load(firstResolve.artifactMainPath.CStr()); + ASSERT_TRUE(firstLoad); + auto* firstView = static_cast(firstLoad.resource); + ASSERT_NE(firstView, nullptr); + EXPECT_EQ(firstView->GetRootNode().tagName, "View"); + EXPECT_EQ(firstView->GetDependencies().Size(), 1u); + EXPECT_TRUE(ContainsDependencyFile(firstView->GetDependencies(), "toolbar.xcui")); + delete firstView; + + const XCEngine::Containers::String firstArtifactPath = firstResolve.artifactMainPath; + database.Shutdown(); + + std::this_thread::sleep_for(50ms); + WriteTextFile( + assetsRoot / "UI" / "Shared" / "toolbar.xcui", + "\n" + "