资源画布支持引擎资源预览与模型交互预览 (#413)
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

## 目的

资源画布支持引擎(Cocos Creator)资源的**只读预览**:能被发现、登记、进入画布,并按类型出预览。

## 主要改动

- 发现层识别 Cocos 资源并区分 `model` / `binary` 两个发现类别;`.meta` 等导入侧车文件仍只可发现
- 登记层按扩展名写入**既有** canonical kind(模型/场景/预制体 → `scene`,动画 → `character-animation`,材质/特效 → `code`,图集与容器 → `document`),不新增 manifest 契约字段
- 引擎工程的 `library/` / `temp/` / `profiles/` / `local/` 不再进入发现结果(仅当目录确实是 Cocos 工程),同名目录在非引擎工程里照常列出
- 资源画布新增三个卡面分支:模型缩略图(整页共用一个 WebGL 上下文)、结构摘要(Cocos 序列化资源)、类型卡(客户端解不了的容器)
- 新增模型放大预览浮层:左键旋转 / 右键或中键平移 / 滚轮缩放 / 复位视角
- 模型预览上限 32 MiB;缩略图按布局尺寸 2 倍超采样,缓存键改用稳定身份
- 引擎图像容器(tga/tif/tiff/hdr)在原生侧转码成 PNG 后复用既有图片预览链路
- 修复资源卡状态边框吃掉内容盒导致的卡面与角标位移
- 同步 Agent 提示词与 AGC 技能文档,并补里程碑与踩坑文档

## 验证

- `cargo check`、`cargo fmt --check`
- Rust 定向:`cargo test … cocos` 9 条、`resource_inspect::tests` 7 条、`agent_asset_import_tests` 9 条、生成目录过滤用例
- 前端:`resource` + `project` 套件 52 文件 / 546 用例;`appSurface` 450 通过 / 20 跳过;app `tsc --noEmit`
- `npm run check:encoding`、`git diff --check`、`npm run check:doc-index`
- 真机:在真实客户端内打开本地 Cocos 夹具工程,逐栏核对模型缩略图 / 结构摘要 / TGA 转码 / 类型卡,并量过卡片在指针移开、悬停、选中三态下几何完全一致

## 未验证

- 模型缩略图与放大预览的真机视觉只覆盖自带夹具工程;多文件 glTF(外部 .bin/贴图)与超大模型仍是类型卡

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/413
This commit was merged in pull request #413.
This commit is contained in:
2026-09-17 20:28:39 +08:00
parent 724d94c95c
commit d5743cd4b8
33 changed files with 3060 additions and 53 deletions
+2
View File
@@ -61,6 +61,7 @@
"react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"three": "^0.184.0",
"vite": "^6.2.0",
"zustand": "^5.0.14"
},
@@ -72,6 +73,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"@types/react-window": "^1.8.8",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
@@ -33,7 +33,11 @@ chromiumoxide = "0.9.1"
futures = "0.3"
getrandom = "0.3"
http = "1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的
# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。
# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让
# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] }
jsonschema = { version = "0.49.3", default-features = false }
oxc_allocator = "0.143.0"
oxc_ast = "0.143.0"
@@ -10,7 +10,7 @@ Let the client derive projections from real disk changes and trusted tool result
## Workflow
1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId.
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text.
@@ -8,7 +8,7 @@ The client projects three distinct facts:
Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools.
Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.19",
"version": "2026-08-26.20",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1"
"sha256": "93210c0eeb73b279d35aa85c201c226139b0bdf041f3300ac2c6e2c1bdd63afe"
}
]
}
@@ -5305,7 +5305,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
if prepare_art {
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。");
emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码");
} else {
system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。");
@@ -1380,6 +1380,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>)
"gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp"
| "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql"
| "gql" | "vue" | "svelte" => ("code", Some("text/plain")),
/*
* Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。
*
* 这里只做**发现分类**`model` / `binary` 是发现层新词,与 manifest 资产 `kind`
* 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。
* 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层
* 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的
* 第三份投影,同样要跟改。
*/
"glb" => ("model", Some("model/gltf-binary")),
"gltf" => ("model", Some("model/gltf+json")),
"fbx" => ("model", Some("application/octet-stream")),
"mesh" | "skeleton" => ("model", Some("application/json")),
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => {
("document", Some("application/json"))
}
"tmx" | "plist" => ("document", Some("application/xml")),
"effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")),
"tga" => ("image", Some("image/x-tga")),
"tif" | "tiff" => ("image", Some("image/tiff")),
"hdr" => ("image", Some("image/vnd.radiance")),
"exr" => ("image", Some("image/x-exr")),
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
("binary", Some("application/octet-stream"))
}
"pcm" => ("audio", Some("audio/pcm")),
_ => ("other", None),
}
}
@@ -1421,8 +1448,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
.map(|value| value.to_lowercase());
let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)?
.unwrap_or_else(|| "all".to_string());
if !["all", "image", "font", "audio", "video", "document", "code"]
.contains(&requested_kind.as_str())
if ![
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
]
.contains(&requested_kind.as_str())
{
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
@@ -3181,7 +3210,9 @@ mod tests {
"supported raster image should be importable: {path}"
);
}
for path in ["assets/theme.bin", "assets/unknown.xyz"] {
// `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler),
// 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。
for path in ["assets/theme.dat", "assets/unknown.xyz"] {
assert!(
!bridge_project_file_is_asset_importable(path),
"unsupported project file must not be advertised as importable: {path}"
@@ -3201,6 +3232,64 @@ mod tests {
}
}
/// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType`
/// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。
///
/// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。
#[test]
fn bridge_project_file_class_covers_cocos_creator_assets() {
for (path, expected_class, expected_media_type) in [
("assets/model/hero.glb", "model", "model/gltf-binary"),
("assets/model/hero.gltf", "model", "model/gltf+json"),
("assets/model/hero.fbx", "model", "application/octet-stream"),
("assets/model/hero.mesh", "model", "application/json"),
("assets/model/hero.skeleton", "model", "application/json"),
("assets/scene/main.scene", "document", "application/json"),
("assets/scene/enemy.prefab", "document", "application/json"),
("assets/anim/walk.anim", "document", "application/json"),
(
"assets/anim/graph.animgraph",
"document",
"application/json",
),
("assets/mtl/hero.mtl", "document", "application/json"),
("assets/shader/glow.effect", "document", "text/plain"),
("assets/atlas/hero.plist", "document", "application/xml"),
("assets/map/level.tmx", "document", "application/xml"),
("assets/font/bitmap.fnt", "document", "text/plain"),
("assets/atlas/auto.pac", "document", "application/json"),
("assets/tex/grass.tga", "image", "image/x-tga"),
("assets/tex/height.hdr", "image", "image/vnd.radiance"),
(
"assets/tex/hero.texture",
"binary",
"application/octet-stream",
),
(
"assets/spine/hero.skel",
"binary",
"application/octet-stream",
),
("assets/audio/voice.pcm", "audio", "audio/pcm"),
] {
let (class, media_type) = bridge_project_file_class(path);
assert_eq!(class, expected_class, "{path}");
assert_eq!(media_type, Some(expected_media_type), "{path}");
assert!(
bridge_project_file_is_asset_importable(path),
"Cocos 资源必须可登记:{path}"
);
}
// 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。
for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] {
assert_eq!(bridge_project_file_class(path).0, "other", "{path}");
assert!(
!bridge_project_file_is_asset_importable(path),
"非资源文件不得可登记:{path}"
);
}
}
#[test]
fn bridge_project_file_listing_projects_importability_per_file() {
let temporary = tempfile::tempdir().expect("create project file listing root");
@@ -369,7 +369,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
},
"kind": {
"type": "string",
"enum": ["all", "image", "font", "audio", "video", "document", "code"]
"enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"],
"description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源"
},
"offset": { "type": "integer", "minimum": 0, "maximum": 500 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
@@ -771,7 +772,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String>
}
if arguments.get("kind").is_some() {
let kind = bounded_tool_string(arguments, "kind", 16)?;
if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str())
if ![
"all", "image", "font", "audio", "video", "document", "code", "model", "binary",
]
.contains(&kind.as_str())
{
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
@@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
output.push_str(
"# 项目内未登记媒体文件(仅发现,不是正式资产)\n\n\
- 这些文件真实存在于当前项目,但尚未取得 manifest assetId/localAssetId、来源或 provenance。\n\
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档代码文件可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
- 只有 `assetImportable=true` 的已识别图片、字体、音频、视频、文档代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等)可交给当前资源导入工具;其它文件只可发现。需要正式使用时,先用 `file.list`/`agc_list_project_files` 确认路径,再用受控导入工具登记;不要把路径文本当作已登记资源身份。\n",
);
for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = !media_type.is_empty();
@@ -228,6 +228,27 @@ fn prompt_context_media_type(path: &str) -> Option<&'static str> {
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts"
| "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh"
| "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
/*
* Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与
* `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名,
* Agent 的提示词就会把「其实可以登记」的资源说成只能发现。
*/
"glb" => Some("model/gltf-binary"),
"gltf" => Some("model/gltf+json"),
"fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
Some("application/octet-stream")
}
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh"
| "skeleton" => Some("application/json"),
"tmx" | "plist" => Some("application/xml"),
"effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"),
"tga" => Some("image/x-tga"),
"tif" | "tiff" => Some("image/tiff"),
"hdr" => Some("image/vnd.radiance"),
"exr" => Some("image/x-exr"),
"pcm" => Some("audio/pcm"),
_ => None,
}
}
@@ -246,10 +267,21 @@ mod tests {
("assets/data.json", "application/json"),
("game/index.html", "text/html"),
("game/main.rs", "text/plain"),
// 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名,
// Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。
("assets/model/hero.glb", "model/gltf-binary"),
("assets/model/hero.fbx", "application/octet-stream"),
("assets/anim/walk.anim", "application/json"),
("assets/scene/main.scene", "application/json"),
("assets/shader/glow.effect", "text/plain"),
("assets/atlas/hero.plist", "application/xml"),
("assets/tex/grass.tga", "image/x-tga"),
("assets/audio/voice.pcm", "audio/pcm"),
] {
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
}
assert_eq!(prompt_context_media_type("assets/unknown.bin"), None);
// `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。
assert_eq!(prompt_context_media_type("assets/unknown.dat"), None);
}
}
@@ -3092,7 +3092,7 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_registers_multiple_types_and_is_idempotent() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
@@ -3185,7 +3185,7 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
@@ -3202,7 +3202,7 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_rejects_hidden_and_build_tree_sources() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
@@ -3232,20 +3232,147 @@ mod agent_asset_import_tests {
#[test]
fn local_project_asset_import_rejects_unknown_and_invalid_text_files() {
let project = tempfile::tempdir().expect("create project directory");
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
let root = project.path();
init_local_game_project_at(root, "agent-local-import", "Agent local import")
.expect("initialize project");
fs::create_dir_all(root.join("assets")).expect("create assets directory");
fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file");
fs::write(root.join("assets/unknown.dat"), b"bytes").expect("write unknown file");
fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source");
assert!(
import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()])
import_local_project_assets_for_agent(root, &["assets/unknown.dat".to_string()])
.is_err()
);
assert!(
import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err()
);
// `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类:
// 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。
fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset");
let imported =
import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()])
.expect("import engine buffer asset");
assert_eq!(imported.assets.len(), 1);
assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document"));
}
/// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest,
/// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。
///
/// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。
#[test]
fn local_project_asset_import_registers_cocos_creator_assets() {
// 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录
// 在 Windows owner 校验下直接失败。
let project = crate::tests::canonical_test_tempdir("cocos-asset-import-");
let root = project.path();
init_local_game_project_at(root, "cocos-import", "Cocos import")
.expect("initialize project");
for directory in [
"model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio",
] {
fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir");
}
let mut glb = b"glTF".to_vec();
glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]);
let mut fbx = b"Kaydara FBX Binary \x00".to_vec();
fbx.extend_from_slice(&[0; 16]);
for (path, bytes) in [
("assets/model/hero.glb", glb),
("assets/model/hero.fbx", fbx),
(
"assets/anim/walk.anim",
b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(),
),
(
"assets/anim/graph.animgraph",
b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(),
),
(
"assets/scene/main.scene",
b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(),
),
(
"assets/scene/enemy.prefab",
b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(),
),
(
"assets/mtl/hero.mtl",
b"{\"__type__\":\"cc.Material\"}".to_vec(),
),
("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()),
(
"assets/atlas/hero.plist",
b"<?xml version=\"1.0\"?><plist/>".to_vec(),
),
(
"assets/map/level.tmx",
b"<?xml version=\"1.0\"?><map/>".to_vec(),
),
("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]),
("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]),
] {
fs::write(root.join(path), bytes).expect("write cocos asset");
}
let relative_paths = [
"assets/model/hero.glb",
"assets/model/hero.fbx",
"assets/anim/walk.anim",
"assets/anim/graph.animgraph",
"assets/scene/main.scene",
"assets/scene/enemy.prefab",
"assets/mtl/hero.mtl",
"assets/shader/glow.effect",
"assets/atlas/hero.plist",
"assets/map/level.tmx",
"assets/tex/hero.texture",
"assets/audio/voice.pcm",
]
.map(str::to_string)
.to_vec();
let imported =
import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos");
let kinds = imported
.assets
.iter()
.map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref()))
.collect::<BTreeMap<_, _>>();
assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene")));
assert_eq!(
kinds.get("assets/anim/walk.anim"),
Some(&Some("character-animation"))
);
assert_eq!(
kinds.get("assets/anim/graph.animgraph"),
Some(&Some("character-animation"))
);
assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene")));
assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code")));
assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code")));
assert_eq!(
kinds.get("assets/atlas/hero.plist"),
Some(&Some("document"))
);
// 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。
assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene")));
assert_eq!(
kinds.get("assets/tex/hero.texture"),
Some(&Some("document"))
);
assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio")));
// 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8,
// 否则「结构预览」拿到的是一堆乱码。
fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe])
.expect("write invalid prefab");
assert!(import_local_project_assets_for_agent(
root,
&["assets/scene/broken.prefab".to_string()]
)
.is_err());
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**,
@@ -3962,6 +4089,140 @@ fn agent_local_project_file_type(
},
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
/*
* Cocos Creator3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与
* 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道**
* 能支撑的 `media_type`。
*
* - `document`Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览;
* - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空;
* - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。
*
* 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现
* 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。
*/
"scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "scene",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tmx" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "scene",
media_type: "application/xml",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => {
Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "character-animation",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "code",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"effect" | "chunk" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "code",
media_type: "text/plain",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"plist" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "application/xml",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"labelatlas" | "pac" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"fnt" | "atlas" => Some(AgentLocalProjectFileType {
category: "document",
asset_kind: "document",
media_type: "text/plain",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"glb" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "model/gltf-binary",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"gltf" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "model/gltf+json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"fbx" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "scene",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"mesh" | "skeleton" => Some(AgentLocalProjectFileType {
// Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是
// JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由
// 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。
category: "binary",
asset_kind: "scene",
media_type: "application/json",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => {
Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "document",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
})
}
"psd" | "znt" => Some(AgentLocalProjectFileType {
category: "binary",
asset_kind: "image",
media_type: "application/octet-stream",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tga" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/x-tga",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"tif" | "tiff" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/tiff",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"hdr" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/vnd.radiance",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"exr" => Some(AgentLocalProjectFileType {
category: "image",
asset_kind: "image",
media_type: "image/x-exr",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
"pcm" => Some(AgentLocalProjectFileType {
category: "audio",
asset_kind: "audio",
media_type: "audio/pcm",
max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE,
}),
_ => None,
};
let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?;
@@ -5055,6 +5316,65 @@ pub(crate) fn read_local_project_text_preview_at(
Ok(preview)
}
/**
* 读取引擎(Cocos)序列化资源的**只读结构预览**
*
* 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于
* 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变
* UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体
* 降级成类型卡(`content: null`),不把「不能预览」报成错误。
*/
#[tauri::command]
pub(crate) async fn read_local_project_structured_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectStructuredPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_structured_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_structured_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectStructuredPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let registered_media_type = manifest
.assets
.iter()
.find(|asset| asset.local_path == normalized_path)
.map(|asset| asset.media_type.clone());
let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| {
is_supported_project_structured_resource(&normalized_path, media_type)
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_structured_resource(&normalized_path, "")
});
if !is_registered_structured {
return Err("只能读取当前项目已登记的引擎资源".to_string());
}
cancellation.check()?;
load_local_project_structured_preview_with_cancellation(
root,
&normalized_path,
registered_media_type.as_deref().unwrap_or(""),
cancellation,
)
}
#[tauri::command]
pub(crate) async fn read_local_project_media_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
@@ -5094,7 +5414,8 @@ pub(crate) fn read_local_project_media_preview_at(
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
"model" => ProjectMediaPreviewKind::Model,
_ => return Err("媒体预览类别只支持 art、audio 或 model".to_string()),
};
let is_registered_media = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
@@ -5105,6 +5426,9 @@ pub(crate) fn read_local_project_media_preview_at(
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
}
ProjectMediaPreviewKind::Model => {
is_supported_project_model_resource(&asset.local_path, &asset.media_type)
}
}
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
@@ -5116,6 +5440,9 @@ pub(crate) fn read_local_project_media_preview_at(
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&normalized_path, "")
}
ProjectMediaPreviewKind::Model => {
is_supported_project_model_resource(&normalized_path, "")
}
}
});
if !is_registered_media {
@@ -2630,6 +2630,7 @@ fn main() {
read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview,
read_local_project_structured_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
@@ -1,5 +1,21 @@
use super::*;
/**
* Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。
*
* 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源
* 目录里的普通文件夹,不属于这里。
*/
fn is_engine_generated_root_directory(relative_path: &str) -> bool {
if relative_path.contains('/') {
return false;
}
matches!(
relative_path.to_ascii_lowercase().as_str(),
"library" | "temp" | "profiles" | "local"
)
}
pub(crate) fn list_local_project_files_at(
root: &Path,
) -> Result<ListLocalProjectFilesResult, String> {
@@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at(
});
}
/*
* 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。
*
* 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在
* 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的
* `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让
* 前端资源树加载一堆永远用不上的条目。
*/
let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some();
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
@@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at(
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0);
if file_type.is_dir() {
if skip_engine_generated_directories
&& is_engine_generated_root_directory(&relative_path)
{
continue;
}
files.push(LocalProjectFileEntry {
path: relative_path,
kind: "directory".to_string(),
File diff suppressed because it is too large Load Diff
@@ -2989,6 +2989,65 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() {
fs::remove_dir_all(root).ok();
}
/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效:
/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。
#[test]
fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() {
let cocos = canonical_test_tempdir("engine-generated-dirs-");
let cocos = cocos.path();
fs::create_dir_all(cocos.join("assets")).expect("create assets");
fs::create_dir_all(cocos.join("library/imported")).expect("create library");
fs::create_dir_all(cocos.join("temp/programming")).expect("create temp");
fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles");
fs::create_dir_all(cocos.join("local")).expect("create local");
fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model");
fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file");
fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file");
fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file");
fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file");
fs::write(
cocos.join("package.json"),
r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#,
)
.expect("write cocos package.json");
let listed = list_local_project_files_at(cocos).expect("list cocos project files");
let paths = listed
.files
.iter()
.map(|file| file.path.as_str())
.collect::<Vec<_>>();
assert!(paths.contains(&"assets/hero.glb"), "{paths:?}");
for skipped in [
"library",
"library/imported/hero.json",
"temp",
"temp/programming/packer.cpp",
"profiles",
"profiles/v2/user.json",
"local",
"local/settings.json",
] {
assert!(
!paths.contains(&skipped),
"引擎生成目录必须被过滤:{skipped} / {paths:?}"
);
}
let plain = canonical_test_tempdir("plain-project-dirs-");
let plain = plain.path();
fs::create_dir_all(plain.join("library")).expect("create plain library");
fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file");
let listed = list_local_project_files_at(plain).expect("list plain project files");
assert!(
listed
.files
.iter()
.any(|file| file.path == "library/index.ts"),
"非引擎工程的同名目录不得被过滤"
);
}
#[test]
fn local_project_export_package_uses_runtime_whitelist_and_records() {
let root = unique_project_path();
@@ -942,6 +942,94 @@
overscroll-behavior: contain;
}
/*
* 引擎模型放大预览:与文档预览同一套浮层口径(宽度 / 圆角 / 头尾结构),
* 只有主体换成交互式三维视口 —— 视口自己吃掉指针与滚轮事件。
*/
.game-resource-model-preview {
display: flex;
flex-direction: column;
width: min(1040px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
min-width: 0;
border: 1px solid var(--platform-surface-border);
border-radius: 16px;
overflow: hidden;
}
.game-resource-model-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--platform-surface-border);
}
.game-resource-model-preview__header strong {
min-width: 0;
overflow-wrap: anywhere;
}
.game-resource-model-preview__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
.game-resource-model-preview__body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
min-width: 0;
padding: 16px 20px 20px;
}
.game-resource-model-preview__hint {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--platform-warm-text, #8b5b45);
font-size: 12px;
}
.game-resource-model-viewer {
position: relative;
flex: 1 1 auto;
min-height: min(62dvh, 560px);
border: 1px solid var(--platform-surface-border);
border-radius: 12px;
background: linear-gradient(145deg, #f7f2ec, #e6ddd2);
overflow: hidden;
/* 视口内的指针手势一律给三维视角,不冒泡去拖动画布。 */
touch-action: none;
}
.game-resource-model-viewer__canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.game-resource-model-viewer__canvas:active {
cursor: grabbing;
}
.game-resource-model-viewer__notice {
display: grid;
height: 100%;
margin: 0;
padding: 24px;
place-items: center;
color: #8b5b45;
font-size: 13px;
text-align: center;
}
/* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */
.game-resource-panel {
width: min(880px, calc(100% - 32px));
+102 -4
View File
@@ -7326,7 +7326,16 @@ iframe.preview-frame {
min-height: var(--resource-card-height);
padding: 0;
overflow: visible;
border: 0;
/*
* 边框**常驻 1px 透明**状态只改颜色
*
* 卡片是 `box-sizing: border-box`且卡面`.game-resource-card-visual`与角标都是
* 绝对定位 **padding box** 为包含块底态若是 `border: 0`悬停 / 选中时才出现的
* 1px 边框会把内容盒四边各吃掉 1px卡面与角标当场位移并缩小 2px 用户看到的就是
* 一悬浮卡片里面的东西跟着动常驻透明边框让所有状态的 padding box 完全一致
* 外尺寸仍然等于 `--resource-card-width/height`border-box画布坐标不受影响
*/
border: 1px solid transparent;
border-radius: 12px;
background: #fff;
color: #4e382f;
@@ -7348,9 +7357,8 @@ iframe.preview-frame {
0 0 0 2px rgb(216 115 66 / 24%);
}
/* 卡片本体是 `border: 0`下面那条基规则`border-color` 单独写没有意义0 宽的边框
画不出颜色所以选中 / 悬停 / 聚焦都必须写成完整的 `border`否则这三个状态在视觉上
完全看不出来宽度与圆角保持 1px / 12px`box-sizing: border-box` 下不会改变卡片尺寸 */
/* 基态已经是 1px 透明边框这里只把颜色点亮宽度与圆角保持 1px / 12px
padding box 不变因此卡面与角标在状态切换时不会位移 */
.game-resource-card:hover,
.game-resource-card:focus-within,
.game-resource-card.is-selected {
@@ -7686,6 +7694,96 @@ iframe.preview-frame {
gap: 10px;
}
/*
* 引擎资源卡引擎三维模型 / 序列化资源 / 无法解码的容器
*
* 三者的共同点卡面要么是渲染出来的缩略图要么是类型 + 扩展名的类型卡
* 都必须与既有卡片同族 因此沿用同一套浅色渐变底与居中排布不新造一套视觉语言
*/
.game-resource-card-model-visual {
/*
* 绝对定位铺满卡面`width/height: 100%` `place-items: center` 的网格里会因为
* 行高不确定而退化成"按内容定高"缩略图就以自然比例溢出卡面被裁掉看起来像被拉伸
* `inset: 0` 把盒子定死图片才能在固定框里做 letterbox
*/
position: absolute;
inset: 0;
display: grid;
width: 100%;
height: 100%;
background: linear-gradient(145deg, #f7f2ec, #e6ddd2);
place-items: center;
}
.game-resource-card-model-visual img {
/*
* 图片绝对定位铺满宿主宿主是 `place-items: center` 的网格网格项的高度会退化成
* "按内容定高"`height: 100%` 解析成 auto缩略图就会按自身比例长过卡片并被裁掉
* 铺满 + `object-fit: contain` 才是不拉伸不裁切的口径
*/
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.game-resource-card-engine-visual {
position: absolute;
inset: 0;
display: grid;
gap: 8px;
width: 100%;
height: 100%;
padding: 12px;
background: linear-gradient(145deg, #f7f2ec, #e6ddd2);
color: #8b5b45;
align-content: center;
place-items: center;
}
.game-resource-card-engine-label {
max-width: 100%;
padding: 1px 7px;
border: 1px solid rgb(139 91 69 / 28%);
border-radius: 999px;
background: rgb(255 255 255 / 72%);
color: #8b5b45;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
text-align: center;
word-break: break-word;
}
/* 引擎序列化资源:结构摘要按"最多三行"截断,与文档卡的卡面口径一致。 */
.game-resource-card-structured-visual {
position: absolute;
inset: 0;
display: grid;
gap: 8px;
width: 100%;
height: 100%;
padding: 14px;
background: linear-gradient(145deg, #fff8f1, #f2ded2);
color: #8b5b45;
align-content: center;
place-items: center;
}
.game-resource-card-structured-text {
display: -webkit-box;
max-height: 100%;
overflow: hidden;
color: #674c41;
font-size: 11px;
line-height: 1.55;
text-align: left;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.game-resource-card-version-relations {
color: #8e6f62;
font-size: 9px;
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from 'react';
import { renderResourceModelThumbnail } from './resourceModelThumbnail';
type ResourceModelPreviewProps = {
/** 预览管线给出的模型字节 URLblob URL)。 */
sourceUrl: string;
/** 原生侧判定的媒体类型(`model/gltf-binary` / `model/gltf+json` / FBX 的 octet-stream)。 */
mediaType: string;
/** 缩略图缓存身份(见 `resourceModelThumbnailIdentity`):blob URL 会变,身份不会。 */
identity: string;
/** 渲染不出来时画在同一个位置上的类型卡文案(例如「模型 · GLB」)。 */
fallbackLabel: string;
};
/**
* 缩略图超采样倍数。
*
* 资源画布用 `transform: scale(--resource-section-zoom)` 放大栏目视图(真机 1.5 倍),而
* `ResizeObserver` 与 `offsetWidth` 都只看**布局盒**:按布局尺寸 1:1 渲染出来的图,被画布
* 放大后就是糊的。按 2 倍超采样既有余量覆盖画布缩放,也不至于让单张缩略图失控。
*/
const RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE = 2;
const RESOURCE_MODEL_THUMBNAIL_MAX_EDGE = 1024;
/**
* 资源卡上的引擎模型缩略图。
*
* 这一层只关心「把渲染结果画出来」,渲染本身在 `resourceModelThumbnail` 的单例队列里
* (整页只有一个 WebGL 上下文)。渲染失败**不算预览失败**:卡面就地降级成类型卡,
* 预览管线里已经读到的字节与状态保持不变,排障信息通过
* `data-model-preview-status` 暴露在 DOM 上。
*/
export function ResourceModelPreview({
sourceUrl,
mediaType,
identity,
fallbackLabel,
}: ResourceModelPreviewProps) {
const hostRef = useRef<HTMLSpanElement>(null);
const [thumbnail, setThumbnail] = useState<string | null>(null);
const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>(
'loading',
);
/**
* 渲染尺寸按**宿主真实几何**(含画布缩放后的视觉尺寸)取,并且跟着宿主的尺寸变化重渲。
*
* 卡片在总览小图与栏目大图里是同一个组件:只在挂载时量一次,会拿到当时那个更小的盒子,
* 之后卡片放大也不会重画 —— 表现为缩略图被放大到发虚、比例与卡面不一致。
*/
const [renderSize, setRenderSize] = useState<{
width: number;
height: number;
} | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) {
return undefined;
}
const measure = () => {
/*
* 取**布局尺寸**`offsetWidth` 不受祖先 transform 影响)而不是 `getBoundingClientRect`
* 画布缩放是 transform,像素值会随缩放变化;布局尺寸才是稳定的口径,再乘超采样倍数。
*/
const width = Math.min(
RESOURCE_MODEL_THUMBNAIL_MAX_EDGE,
Math.max(
192,
Math.round(
(host.offsetWidth || 320) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE,
),
),
);
const height = Math.min(
RESOURCE_MODEL_THUMBNAIL_MAX_EDGE,
Math.max(
144,
Math.round(
(host.offsetHeight || 240) * RESOURCE_MODEL_THUMBNAIL_SUPERSAMPLE,
),
),
);
setRenderSize((current) =>
current && current.width === width && current.height === height
? current
: { width, height },
);
};
measure();
// 测试用的 jsdom 没有 ResizeObserver:没有它就退化成"只在挂载时量一次"。
if (typeof ResizeObserver === 'undefined') {
return undefined;
}
const observer = new ResizeObserver(measure);
observer.observe(host);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!renderSize) {
return undefined;
}
let disposed = false;
setThumbnail(null);
setStatus('loading');
const request = {
sourceUrl,
mediaType,
identity,
width: renderSize.width,
height: renderSize.height,
};
void renderResourceModelThumbnail(request)
.then((dataUrl) => {
if (disposed) {
return;
}
setThumbnail(dataUrl);
setStatus('ready');
})
.catch(() => {
if (!disposed) {
setStatus('failed');
}
});
return () => {
disposed = true;
};
}, [identity, mediaType, renderSize, sourceUrl]);
return (
<span
ref={hostRef}
className="game-resource-card-model-visual"
data-model-preview-status={status}
// 渲染尺寸暴露到 DOM:缩略图糊 / 比例不对时,先看它和卡片实际几何是否一致。
data-model-render-size={
renderSize ? `${renderSize.width}x${renderSize.height}` : undefined
}
>
{thumbnail ? (
<img src={thumbnail} alt="" decoding="async" />
) : (
<span className="game-resource-card-engine-badge">{fallbackLabel}</span>
)}
</span>
);
}
@@ -0,0 +1,114 @@
import { Maximize2, RotateCcw, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformIconButton } from '../../../../../packages/shared/src/components/PlatformIconButton';
import { ThemedModal } from '../../components/modal/ThemedModal';
import type { ProjectResourceCardPreviewState } from './resourceCardPreviewModel';
import {
ResourceModelViewer,
type ResourceModelViewerHandle,
} from './ResourceModelViewer';
import type { ProjectResource } from './resourceProjectionModel';
/**
* 引擎模型的**放大预览浮层**:在独立浮层里给出与 3D 建模软件一致的视角操作。
*
* 卡片上仍然是静态缩略图(一张画布上十几张模型卡共用缩略图那一个 WebGL 上下文);
* 只有打开这个浮层时才会新建一个交互式上下文,关闭即释放。浮层内的指针与滚轮事件由
* three 的 OrbitControls 独占:左键旋转、右键/中键平移、滚轮推拉。
*/
export function ResourceModelPreviewDialog({
resource,
identity,
preview,
onRequestPreview,
onClose,
}: {
resource: ProjectResource;
identity: string;
preview: ProjectResourceCardPreviewState;
onRequestPreview: (
resource: ProjectResource,
identity: string,
reason: 'detail',
) => void;
onClose: () => void;
}) {
const [resetSignal, setResetSignal] = useState(0);
const viewerHandleRef = useRef<ResourceModelViewerHandle | null>(null);
useEffect(() => {
onRequestPreview(resource, identity, 'detail');
}, [resource, identity, onRequestPreview]);
const handleReady = useCallback((handle: ResourceModelViewerHandle) => {
viewerHandleRef.current = handle;
}, []);
const sourceUrl =
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
return (
<ThemedModal
open
ariaLabel="模型预览"
onClose={onClose}
panelClassName="game-resource-model-preview"
>
<header className="game-resource-model-preview__header">
<strong title={resource.label}>{resource.label}</strong>
<div className="game-resource-model-preview__actions">
<PlatformActionButton
tone="secondary"
onClick={() => setResetSignal((current) => current + 1)}
>
<RotateCcw size={15} aria-hidden="true" />
<span></span>
</PlatformActionButton>
<PlatformIconButton
label="关闭模型预览"
title="关闭"
icon={<X size={18} aria-hidden="true" />}
onClick={onClose}
/>
</div>
</header>
<div className="game-resource-model-preview__body">
{preview.status === 'failed' ? (
<>
<p role="alert">{preview.error}</p>
{preview.retryable ? (
<PlatformActionButton
tone="secondary"
onClick={() => onRequestPreview(resource, identity, 'detail')}
>
</PlatformActionButton>
) : null}
</>
) : preview.status === 'loaded' && !sourceUrl ? (
<p role="alert"></p>
) : sourceUrl ? (
<ResourceModelViewer
source={{
sourceUrl,
mediaType:
preview.status === 'loaded'
? preview.preview.mediaType
: resource.mediaType,
}}
resetSignal={resetSignal}
onReady={handleReady}
/>
) : (
<p role="status"></p>
)}
<p className="game-resource-model-preview__hint">
<Maximize2 size={13} aria-hidden="true" />
<span> · · </span>
</p>
</div>
</ThemedModal>
);
}
@@ -0,0 +1,192 @@
import { useEffect, useRef, useState } from 'react';
import {
addResourceModelLights,
disposeResourceModelObject,
frameResourceModelInCamera,
loadResourceModelObject,
type ResourceModelSource,
} from './resourceModelScene';
export type ResourceModelViewerHandle = {
/** 复位视角(回到打开时的取景);供浮层上的「复位视角」按钮调用。 */
resetView: () => void;
};
type ResourceModelViewerProps = {
source: ResourceModelSource;
/** 复位视角按钮的点击计数:变化即复位,避免把 three 对象提到 React 状态里。 */
resetSignal: number;
onReady: (handle: ResourceModelViewerHandle) => void;
};
/**
* 交互式模型预览画布:视角操作与 3D 建模软件一致。
*
* - 左键拖拽:旋转(Orbit)
* - 右键 / 中键拖拽:平移(Pan)
* - 滚轮:推拉(Zoom
*
* 与缩略图共用 `resourceModelScene` 的加载 / 取景 / 释放口径,所以「卡片上看得见、放大后
* 看不见」这类分叉不会出现。渲染器是本浮层自己的 WebGL 上下文,关闭时立即 `dispose`:
* 资源画布上十几张模型卡仍然只共用缩略图那一个上下文(见 `resourceModelThumbnail`)。
*/
export function ResourceModelViewer({
source,
resetSignal,
onReady,
}: ResourceModelViewerProps) {
const hostRef = useRef<HTMLDivElement>(null);
const resetRef = useRef<(() => void) | null>(null);
/**
* 用 ref 保存 source:调用方通常直接传对象字面量,把它放进依赖数组会让模型每次渲染
* 都重建一次 WebGL 上下文;真正的重建条件只有「换了模型」。
*/
const sourceRef = useRef(source);
sourceRef.current = source;
const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>(
'loading',
);
const [error, setError] = useState('');
useEffect(() => {
let disposed = false;
let cleanup: (() => void) | undefined;
setStatus('loading');
setError('');
const host = hostRef.current;
if (!host) {
return undefined;
}
void (async () => {
try {
const THREE = await import('three');
const { OrbitControls } = await import(
'three/examples/jsm/controls/OrbitControls.js'
);
if (disposed) {
return;
}
const width = Math.max(240, Math.round(host.clientWidth));
const height = Math.max(200, Math.round(host.clientHeight));
const modelSource = {
sourceUrl: sourceRef.current.sourceUrl,
mediaType: sourceRef.current.mediaType,
};
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(width, height, false);
renderer.domElement.className = 'game-resource-model-viewer__canvas';
host.appendChild(renderer.domElement);
const scene = new THREE.Scene();
addResourceModelLights(THREE, scene);
const camera = new THREE.PerspectiveCamera(
35,
width / height,
0.01,
10_000,
);
const controls = new OrbitControls(camera, renderer.domElement);
// 阻尼让拖动有惯性,手感与建模软件一致;它要求每帧 update,因此下面常驻 rAF。
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
controls.zoomSpeed = 0.9;
// 允许贴到模型内部看细节,但不允许转到背面时被裁剪面切掉模型。
controls.minDistance = 0.01;
controls.maxDistance = Number.POSITIVE_INFINITY;
const object = await loadResourceModelObject(modelSource);
if (disposed) {
disposeResourceModelObject(object);
renderer.dispose();
renderer.domElement.remove();
return;
}
scene.add(object);
const frame = () => {
const { center } = frameResourceModelInCamera(THREE, object, camera);
// 轨道中心 = 模型包围盒中心:缩放与旋转都绕着模型本身,和建模软件的取景一致。
controls.target.copy(center);
controls.update();
};
frame();
controls.update();
const resizeObserver = new ResizeObserver(() => {
const nextWidth = Math.max(240, Math.round(host.clientWidth));
const nextHeight = Math.max(200, Math.round(host.clientHeight));
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight, false);
});
resizeObserver.observe(host);
let frameHandle = 0;
const renderLoop = () => {
controls.update();
renderer.render(scene, camera);
frameHandle = window.requestAnimationFrame(renderLoop);
};
frameHandle = window.requestAnimationFrame(renderLoop);
resetRef.current = () => {
frame();
};
onReady({ resetView: () => resetRef.current?.() });
setStatus('ready');
cleanup = () => {
window.cancelAnimationFrame(frameHandle);
resizeObserver.disconnect();
controls.dispose();
scene.remove(object);
disposeResourceModelObject(object);
renderer.dispose();
renderer.domElement.remove();
resetRef.current = null;
};
} catch (viewerError) {
if (!disposed) {
setStatus('failed');
setError(
viewerError instanceof Error
? viewerError.message
: String(viewerError),
);
}
}
})();
return () => {
disposed = true;
cleanup?.();
};
}, [onReady, source.mediaType, source.sourceUrl]);
useEffect(() => {
if (resetSignal > 0) {
resetRef.current?.();
}
}, [resetSignal]);
return (
<div
ref={hostRef}
className="game-resource-model-viewer"
data-model-viewer-status={status}
>
{status === 'ready' ? null : (
<p className="game-resource-model-viewer__notice" role="status">
{status === 'loading'
? '正在加载模型…'
: `当前环境无法渲染三维预览${error ? `${error}` : ''}`}
</p>
)}
</div>
);
}
@@ -12,8 +12,13 @@ import {
type ProjectResourceCardPreviewState,
projectResourceCardPreviewVariant,
projectResourceJsonPresentation,
projectResourcePathExtension,
projectResourceStructuredPreviewText,
} from './resourceCardPreviewModel';
import { ResourceModelPreview } from './ResourceModelPreview';
import { resourceModelThumbnailIdentity } from './resourceModelThumbnail';
import type { ProjectResource } from './resourceProjectionModel';
import { projectResourceTypeLabel } from './resourceProjectionModel';
type ResourcePreviewMediaProps = {
/**
@@ -96,7 +101,59 @@ export function ResourcePreviewMedia({
: null;
const sourceUrl =
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
const engineBadgeLabel = (() => {
if (!resource) {
return '引擎资源';
}
const extension = projectResourcePathExtension(resource.path);
const typeLabel = projectResourceTypeLabel(resource);
return extension ? `${typeLabel} · ${extension.toUpperCase()}` : typeLabel;
})();
const visual = (() => {
// 引擎资源的三个分支与资源卡同源(判据都来自 `projectResourceCardPreviewKind`),
// 差别只在尺寸:面板里的缩略图更大,因此模型缩略图按面板几何渲染。
if (kind === 'model') {
return sourceUrl ? (
<ResourceModelPreview
sourceUrl={sourceUrl}
mediaType={
preview.status === 'loaded' ? preview.preview.mediaType : ''
}
identity={
resource
? resourceModelThumbnailIdentity({
resourceKey: resource.manifestAssetId ?? resource.id,
path: resource.path,
byteLen:
preview.status === 'loaded' ? preview.preview.byteLen : 0,
})
: 'engine-model'
}
fallbackLabel={engineBadgeLabel}
/>
) : (
<span className="game-resource-card-engine-label">
{engineBadgeLabel}
</span>
);
}
if (kind === 'structured' || kind === 'binary') {
const structuredText =
kind === 'structured'
? projectResourceStructuredPreviewText(
preview.status === 'loaded' ? preview.preview.content : undefined,
)
: '';
return structuredText ? (
<span className="game-resource-card-structured-text">
{structuredText}
</span>
) : (
<span className="game-resource-card-engine-label">
{engineBadgeLabel}
</span>
);
}
const jsonPresentation = resource
? projectResourceJsonPresentation(resource, preview)
: null;
@@ -17,6 +17,7 @@ import {
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import {
AtSign,
Box,
Crosshair,
Eye,
FileCode2,
@@ -299,6 +300,8 @@ import {
projectResourceCodeTypeLabel,
projectResourceDocumentPreviewText,
projectResourceJsonPresentation,
projectResourcePathExtension,
projectResourceStructuredPreviewText,
} from './resourceCardPreviewModel';
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
import {
@@ -331,6 +334,9 @@ import {
ResourceInfoFieldsView,
ResourceInfoPanelView,
} from './ResourceInfoPanelView';
import { ResourceModelPreview } from './ResourceModelPreview';
import { ResourceModelPreviewDialog } from './ResourceModelPreviewDialog';
import { resourceModelThumbnailIdentity } from './resourceModelThumbnail';
import { ResourcePreviewMedia } from './ResourcePreviewMedia';
import {
type ProjectAgentResultSummary,
@@ -803,6 +809,25 @@ const ResourceCard = memo(function ResourceCard({
previewVariant === 'markdown',
)
: '';
/**
*
*
* - `engineTypeLabel` / / ****
* "图片 / 文档"
* - `structuredPreviewText`
* `content === undefined`
*/
const engineTypeLabel = projectResourceTypeLabel(resource);
const engineExtension = projectResourcePathExtension(resource.path);
const engineBadgeLabel = engineExtension
? `${engineTypeLabel} · ${engineExtension.toUpperCase()}`
: engineTypeLabel;
const structuredPreviewText =
kind === 'structured'
? projectResourceStructuredPreviewText(
preview.status === 'loaded' ? preview.preview.content : undefined,
)
: '';
useEffect(() => {
const element = cardRef.current;
@@ -832,6 +857,66 @@ const ResourceCard = memo(function ResourceCard({
}, [previewIdentity, sourceUrl]);
const visual = (() => {
/*
* / /
* - `model`
* - `structured`
* - `binary`
*/
if (kind === 'model') {
return sourceUrl ? (
<ResourceModelPreview
sourceUrl={sourceUrl}
mediaType={
preview.status === 'loaded'
? preview.preview.mediaType
: resource.mediaType
}
identity={resourceModelThumbnailIdentity({
resourceKey: resource.manifestAssetId ?? resource.id,
path: resource.path,
// 字节数只有在读出来后才知道;未读到时先用 0——同一份资源读完必然一致,
// 因此不会出现"同一个模型两个缓存键"。
byteLen: preview.status === 'loaded' ? preview.preview.byteLen : 0,
})}
fallbackLabel={engineBadgeLabel}
/>
) : (
<span className="game-resource-card-engine-visual">
<Shapes size={26} aria-hidden="true" />
<span className="game-resource-card-engine-label">
{engineBadgeLabel}
</span>
</span>
);
}
if (kind === 'structured') {
return structuredPreviewText ? (
<span className="game-resource-card-structured-visual">
<FileCode2 size={20} aria-hidden="true" />
<span className="game-resource-card-structured-text">
{structuredPreviewText}
</span>
</span>
) : (
<span className="game-resource-card-engine-visual">
<FileCode2 size={26} aria-hidden="true" />
<span className="game-resource-card-engine-label">
{engineBadgeLabel}
</span>
</span>
);
}
if (kind === 'binary') {
return (
<span className="game-resource-card-engine-visual">
<PackageOpen size={26} aria-hidden="true" />
<span className="game-resource-card-engine-label">
{engineBadgeLabel}
</span>
</span>
);
}
if (jsonPresentation) {
return (
<span className="game-resource-card-code-visual">
@@ -1614,6 +1699,14 @@ export default function ProjectDevelopmentView({
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] =
useState<string | null>(null);
/**
*
*
* `resources`
* **** manifest
*/
const [resourceModelPreviewIdentity, setResourceModelPreviewIdentity] =
useState<string | null>(null);
/**
*
*
@@ -6423,10 +6516,18 @@ export default function ProjectDevelopmentView({
) {
setResourceDocumentPreviewIdentity(null);
}
if (
mode !== 'resources' ||
uiEditorRoute ||
resourceModelPreviewIdentity !== selectedResourcePreviewIdentity
) {
setResourceModelPreviewIdentity(null);
}
}, [
mode,
uiEditorRoute,
resourceDocumentPreviewIdentity,
resourceModelPreviewIdentity,
selectedResourcePreviewIdentity,
]);
@@ -6436,6 +6537,12 @@ export default function ProjectDevelopmentView({
return () => protectResourceCardPreview(null);
}, [protectResourceCardPreview, resourceDocumentPreviewIdentity]);
useEffect(() => {
if (!resourceModelPreviewIdentity) return;
protectResourceCardPreview(resourceModelPreviewIdentity);
return () => protectResourceCardPreview(null);
}, [protectResourceCardPreview, resourceModelPreviewIdentity]);
/**
* revision
*
@@ -8002,6 +8109,36 @@ export default function ProjectDevelopmentView({
<span></span>
</CanvasChromeButton>
) : null}
{/*
*
* `projectResourceCardPreviewKind`
* 3D
*/}
{selectedResource &&
selectedResourcePreviewIdentity &&
projectResourceCardPreviewKind(
selectedResource,
) === 'model' ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="3D 预览"
title="放大预览(可旋转 / 平移 / 缩放)"
icon={<Box className="h-4 w-4" />}
onClick={() => {
stopActiveCardMedia();
resourceCardPreviews.requestPreview(
selectedResource,
selectedResourcePreviewIdentity,
'detail',
);
setResourceModelPreviewIdentity(
selectedResourcePreviewIdentity,
);
}}
>
<span>3D </span>
</CanvasChromeButton>
) : null}
{selectedResource &&
selectedResourceOpensUiEditor ? (
<CanvasChromeButton
@@ -8904,6 +9041,23 @@ export default function ProjectDevelopmentView({
onClose={() => setResourceDocumentPreviewIdentity(null)}
/>
) : null}
{mode === 'resources' &&
!uiEditorRoute &&
selectedResource &&
resourceModelPreviewIdentity &&
resourceModelPreviewIdentity === selectedResourcePreviewIdentity ? (
<ResourceModelPreviewDialog
key={resourceModelPreviewIdentity}
resource={selectedResource}
identity={resourceModelPreviewIdentity}
preview={
resourceCardPreviews.previews.get(resourceModelPreviewIdentity) ??
resourceCardPreviews.idlePreview
}
onRequestPreview={resourceCardPreviews.requestPreview}
onClose={() => setResourceModelPreviewIdentity(null)}
/>
) : null}
{resourcePanelOpen ? (
<ResourceCanvasPanelView
entries={resourcePanelEntries}
@@ -29,6 +29,12 @@ export type ProjectResourceCardPreviewKind =
| 'audio'
| 'code'
| 'document'
/** 引擎序列化文本资源(Cocos 的 `.prefab` / `.scene` / `.anim` / `.effect` …):只读结构预览。 */
| 'structured'
/** 引擎三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给模型渲染器出缩略图。 */
| 'model'
/** 客户端解不了的引擎容器(压缩纹理、Spine 二进制、PSD/EXR、裸 PCM…):只画类型卡,不读取。 */
| 'binary'
| 'version'
| 'placeholder';
@@ -207,12 +213,44 @@ export function projectResourceCardPreviewEvictionIdentities(
}
/** `read_local_project_media_preview` 只认这两个线上取值,与画布栏目轴无关。 */
export type ProjectResourceMediaPreviewCategory = 'art' | 'audio';
/**
* `read_local_project_media_preview` 的线上 `category` 入参:只按**文件读取分支**分三支,
* 与画布栏目轴无关 —— `model` 是引擎三维模型的读取分支(整份字节送进模型渲染器)。
*/
export type ProjectResourceMediaPreviewCategory = 'art' | 'audio' | 'model';
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
/**
* 引擎三维模型:卡面走模型渲染器(`.glb` / `.gltf` / `.fbx` 是三种能被通用三维库直接
* 加载的格式)。`.mesh` / `.skeleton` 是引擎自己的实例化数据,归入结构预览而不是这里。
*/
const modelExtension = /\.(glb|gltf|fbx)$/iu;
/**
* 引擎序列化文本资源:Cocos 把场景、预制体、动画、材质、特效、图集配置都存成
* JSON / XML / 自定义 DSL 文本,卡面按「结构摘要」预览。
*
* `.plist` 同时用于图集与粒子,`.pac` 是自动图集配置,`.atlas` 是 Spine 图集文本描述;
* 它们可能是二进制变体,此时原生读取返回 `content: null`,卡面退化到类型卡。
*/
const structuredExtension =
/\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|mtl|material|pmtl|effect|chunk|tmx|terrain|plist|labelatlas|atlas|fnt|pac|mesh|skeleton)$/iu;
/**
* 客户端无法解码、也拿不到有意义结构的引擎容器:**不发起任何读取**,卡面直接画类型卡。
*
* - 压缩纹理 / 渲染纹理(`.texture` / `.cubemap` / `.rt`)只有引擎自己能解;
* - `.skel` / `.dbbin` 是 Spine、DragonBones 的二进制骨架;
* - `.psd` / `.exr` 当前没有可用解码器(`.exr` 的解码依赖在本仓库依赖源里取不到);
* - `.pcm` 是无头裸音频,浏览器拿到也没法解码播放;
* - `.bin` 是引擎的 BufferAsset,语义取决于使用方。
*/
const opaqueContainerExtension =
/\.(dbbin|bin|skel|texture|cubemap|rt|psd|znt|exr|pcm)$/iu;
/** Markdown 文档:卡面显示前几行,并做轻量标记清理。 */
const markdownExtension = /\.(md|markdown|mdx)$/iu;
/**
@@ -290,6 +328,21 @@ export function projectResourceCardPreviewKind(
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
/*
* 引擎资源的三个新分支必须排在 mediaType 兜底之前:
* `.pcm` 的 mediaType 是 `audio/*`、`.tga` 是 `image/*`、`.glb` 是 `model/*`
* 一旦让通用分支先跑,就会出现「音频卡点播放却解不出来」「模型卡按图片解码失败」
* 这类看起来像坏掉的预览。
*/
if (modelExtension.test(resource.path)) {
return 'model';
}
if (structuredExtension.test(resource.path)) {
return 'structured';
}
if (opaqueContainerExtension.test(resource.path)) {
return 'binary';
}
const kind = projectResourceDisplayKind(resource);
if (kind === 'code') {
return 'code';
@@ -338,6 +391,8 @@ export type ProjectResourceCardPreviewVariant =
| 'markdown'
| 'code'
| 'plain-text'
/** 引擎序列化文本资源:卡面显示结构摘要,不显示原始正文。 */
| 'structured'
| null;
export function projectResourceCardPreviewVariant(
@@ -352,6 +407,9 @@ export function projectResourceCardPreviewVariant(
if (cardCodeExtension.test(resource.path)) {
return 'code';
}
if (projectResourceCardPreviewKind(resource) === 'structured') {
return 'structured';
}
return projectResourceCardPreviewKind(resource) === 'document'
? 'plain-text'
: null;
@@ -365,7 +423,10 @@ export function projectResourceCardPreviewVariant(
export function projectResourceCardPreviewReadsContent(
resource: ProjectResource,
): boolean {
return projectResourceCardPreviewVariant(resource) !== 'code';
return (
projectResourceCardPreviewVariant(resource) !== 'code' &&
projectResourceCardPreviewKind(resource) !== 'binary'
);
}
/**
@@ -381,7 +442,11 @@ export function projectResourceCardPreviewReadsContent(
export function projectResourceMediaPreviewCategory(
resource: ProjectResource,
): ProjectResourceMediaPreviewCategory {
return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art';
const kind = projectResourceCardPreviewKind(resource);
if (kind === 'audio') {
return 'audio';
}
return kind === 'model' ? 'model' : 'art';
}
export function projectResourceCardPreviewIdentity(input: {
@@ -461,3 +526,89 @@ export function projectResourceDocumentPreviewText(
}
return lines.join('\n');
}
/**
* 引擎(Cocos)序列化资源的**结构摘要**。
*
* Cocos 把场景、预制体、动画剪辑、材质都序列化成「`{ __type__: 'cc.Xxx', ... }` 对象的
* 数组」,直接显示原始 JSON 前几行对用户没有信息量(第一行永远是 `[` 和第一个组件的
* 大段属性)。这里只抽取三类稳定事实:根类型、节点数与类型数、以及动画时长/名称。
*
* 解析失败或不是这种形状时返回 `null`,调用方回退到「显示前几行文本」,不猜内容。
*/
export function projectResourceCocosStructureSummary(
content: string,
): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return null;
}
if (!Array.isArray(parsed)) {
return null;
}
const entries = parsed.filter(
(entry): entry is Record<string, unknown> =>
typeof entry === 'object' && entry !== null && !Array.isArray(entry),
);
const typeCounts = new Map<string, number>();
for (const entry of entries) {
const type = entry['__type__'];
if (typeof type === 'string' && type) {
typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
}
}
const rootType = entries
.map((entry) => entry['__type__'])
.find(
(type): type is string => typeof type === 'string' && type.length > 0,
);
if (!rootType) {
return null;
}
const parts: string[] = [rootType];
const nodeCount = typeCounts.get('cc.Node') ?? 0;
if (nodeCount > 0) {
parts.push(`${nodeCount} 个节点`);
}
const duration = entries
.map((entry) => entry['_duration'])
.find((value): value is number => typeof value === 'number' && value > 0);
if (duration !== undefined) {
parts.push(`${duration.toFixed(2)}`);
}
const name = entries
.map((entry) => entry['_name'])
.find(
(value): value is string => typeof value === 'string' && value.length > 0,
);
if (name) {
parts.push(name);
}
const assetReferences = entries.filter(
(entry) => entry['__uuid__'] !== undefined,
).length;
if (assetReferences > 0) {
parts.push(`${assetReferences} 处资源引用`);
}
parts.push(`${typeCounts.size} 种类型`);
return parts.join(' · ');
}
/**
* 引擎序列化资源的卡面预览文本:优先结构摘要,其次前几行正文。
*
* `content` 为 `undefined`(原生读取判定的二进制变体)时返回空串,卡面画类型卡。
*/
export function projectResourceStructuredPreviewText(
content: string | undefined,
): string {
if (content === undefined) {
return '';
}
return (
projectResourceCocosStructureSummary(content) ??
projectResourceDocumentPreviewText(content, false)
);
}
@@ -0,0 +1,118 @@
import type * as ThreeTypes from 'three';
/**
* 引擎三维模型的**共用场景能力**:加载、取景、释放。
*
* 卡片缩略图(`resourceModelThumbnail`)和交互式预览浮层(`ResourceModelViewer`)都走这里,
* 避免出现「缩略图能打开、放大后打不开」这种两套加载逻辑的分叉 —— 格式支持面、取景算法和
* 释放口径必须逐字一致。
*/
export type ResourceModelSource = {
/** 预览管线给出的 blob URL(模型整份字节)。 */
sourceUrl: string;
/** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`FBX)。 */
mediaType: string;
};
export function isResourceModelFbx(source: ResourceModelSource) {
return (
source.mediaType === 'application/octet-stream' ||
source.sourceUrl.toLowerCase().includes('.fbx')
);
}
/**
* 按媒体类型加载模型。
*
* 只支持**自包含**的模型:`.glb`、单文件 `.gltf`buffer 内嵌)、`.fbx`。多文件 glTF
* `baseURI` 指向外部 `.bin` / 贴图)在 blob URL 下无法解析相对路径,加载失败由调用方
* 降级成类型卡,不做静默半渲染。
*/
export async function loadResourceModelObject(
source: ResourceModelSource,
): Promise<ThreeTypes.Object3D> {
if (isResourceModelFbx(source)) {
const { FBXLoader } = await import(
'three/examples/jsm/loaders/FBXLoader.js'
);
return new FBXLoader().loadAsync(source.sourceUrl);
}
const { GLTFLoader } = await import(
'three/examples/jsm/loaders/GLTFLoader.js'
);
const gltf = await new GLTFLoader().loadAsync(source.sourceUrl);
if (!gltf.scene) {
throw new Error('模型内容为空');
}
return gltf.scene;
}
/**
* 把相机摆到能完整看见整个模型的位置,并返回模型中心与尺寸。
*
* 取景口径与 3D 软件一致:按包围盒最大边计算距离,留 35% 余量,从右上前方俯视。
* 缩略图与交互预览共用同一条公式,尺寸窗口不同也不会出现「一边看得见、一边看不见」。
*/
export function frameResourceModelInCamera(
THREE: typeof ThreeTypes,
object: ThreeTypes.Object3D,
camera: ThreeTypes.PerspectiveCamera,
) {
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z);
if (!Number.isFinite(maxDimension) || maxDimension <= 0) {
throw new Error('模型几何尺寸无效');
}
const distance =
(maxDimension / 2 / Math.tan((camera.fov * Math.PI) / 360)) * 1.35;
camera.position.set(
center.x + distance * 0.55,
center.y + distance * 0.42,
center.z + distance * 0.75,
);
camera.near = Math.max(distance / 500, 0.001);
camera.far = distance * 20;
camera.lookAt(center);
camera.updateProjectionMatrix();
return { center, size, maxDimension, distance };
}
/** 环境光照:半球光 + 一盏主光,保证没有材质贴图的模型也有体积感。 */
export function addResourceModelLights(
THREE: typeof ThreeTypes,
scene: ThreeTypes.Scene,
) {
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 2.2));
const keyLight = new THREE.DirectionalLight(0xffffff, 2.0);
keyLight.position.set(2, 3, 4);
scene.add(keyLight);
}
/** 释放模型对象占用的几何、材质与贴图,避免反复开关预览时显存只涨不降。 */
export function disposeResourceModelObject(object: ThreeTypes.Object3D) {
object.traverse((child) => {
const mesh = child as ThreeTypes.Mesh;
mesh.geometry?.dispose?.();
const material = mesh.material;
const materials = Array.isArray(material)
? material
: material
? [material]
: [];
for (const entry of materials) {
for (const value of Object.values(
entry as unknown as Record<string, unknown>,
)) {
const texture = value as
| { isTexture?: boolean; dispose?: () => void }
| undefined;
if (texture?.isTexture && typeof texture.dispose === 'function') {
texture.dispose();
}
}
(entry as { dispose?: () => void }).dispose?.();
}
});
}
@@ -0,0 +1,164 @@
import type * as ThreeTypes from 'three';
import {
addResourceModelLights,
disposeResourceModelObject,
frameResourceModelInCamera,
loadResourceModelObject,
} from './resourceModelScene';
/**
* 引擎三维模型缩略图渲染器(模块级单例)。
*
* 为什么是单例:一张资源画布上可能同时停着十几张模型卡,而浏览器能给一个页面的
* WebGL 上下文是**有上限**的(超出后最早的上下文会被丢弃,表现为"有些卡片莫名其妙变白")。
* 这里只保留**一个**离屏渲染器 + 一个串行队列:谁需要缩略图谁排队,渲染完把像素画进
* 卡片自己的 2D canvas。因此无论画布上有多少张模型卡,WebGL 上下文始终只有 1 个。
*
* 渲染是**一次性**的静态帧(不跑动画循环):卡片只是缩略图,让十几张卡各自跑一个
* requestAnimationFrame 循环会白烧 GPU 和电量。
*/
export type ResourceModelThumbnailRequest = {
/** 预览管线给出的 blob URL(模型整份字节)。 */
sourceUrl: string;
/** `model/gltf-binary`、`model/gltf+json` 或 `application/octet-stream`FBX)。 */
mediaType: string;
width: number;
height: number;
/**
* 缩略图的**稳定身份**(资源身份 + 路径 + 字节数)。
*
* blob URL 每次读取都会变,拿它当缓存键会让「预览缓存淘汰后重读同一个模型」变成
* 一次重新解析 + 重新渲染;用稳定身份作键,重读只会重新取字节,缩略图直接命中缓存。
*/
identity: string;
};
const THUMBNAIL_CACHE_LIMIT = 24;
const thumbnailCache = new Map<string, string>();
type RendererBundle = {
renderer: ThreeTypes.WebGLRenderer;
scene: ThreeTypes.Scene;
camera: ThreeTypes.PerspectiveCamera;
};
let rendererPromise: Promise<RendererBundle> | null = null;
let renderQueue: Promise<unknown> = Promise.resolve();
export function resourceModelThumbnailCacheSize() {
return thumbnailCache.size;
}
/**
* 缩略图缓存键:**不含 blob URL**。
*
* 预览缓存淘汰后同一份模型会被重新读取、拿到新的 blob URL;若把 URL 放进键里,
* 每一次重读都会变成一次重新解析 + 重新渲染。用稳定身份作键,重读只会重新取字节。
*/
export function resourceModelThumbnailCacheKey(
request: Pick<
ResourceModelThumbnailRequest,
'mediaType' | 'identity' | 'width' | 'height'
>,
) {
return `${request.mediaType}|${request.identity}|${request.width}x${request.height}`;
}
export function resourceModelThumbnailIdentity(input: {
resourceKey: string;
path: string;
byteLen?: number;
}) {
return `${input.resourceKey}|${input.path}|${input.byteLen ?? 0}`;
}
function rememberThumbnail(cacheKey: string, dataUrl: string) {
thumbnailCache.delete(cacheKey);
thumbnailCache.set(cacheKey, dataUrl);
while (thumbnailCache.size > THUMBNAIL_CACHE_LIMIT) {
const oldest = thumbnailCache.keys().next().value;
if (oldest === undefined) {
break;
}
thumbnailCache.delete(oldest);
}
}
async function createRendererBundle(): Promise<RendererBundle> {
// 动态导入:三维渲染器只在真的出现模型卡时才加载,不进主包、不影响其它卡片的启动成本。
const THREE = await import('three');
const canvas = document.createElement('canvas');
const renderer = new THREE.WebGLRenderer({
canvas,
alpha: true,
antialias: true,
// 没有它,`toDataURL` 拿到的可能是被清空的缓冲(否则渲染后立刻被交换掉)。
preserveDrawingBuffer: true,
});
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10_000);
addResourceModelLights(THREE, scene);
return { renderer, scene, camera };
}
function rendererBundle() {
rendererPromise ??= createRendererBundle().catch((error: unknown) => {
// 失败不缓存:下一次卡片进入视口时还有机会(例如 WebGL 上下文被临时耗尽)。
rendererPromise = null;
throw error;
});
return rendererPromise;
}
async function renderOnce(
request: ResourceModelThumbnailRequest,
): Promise<string> {
const THREE = await import('three');
const { renderer, scene, camera } = await rendererBundle();
const width = Math.max(64, Math.round(request.width));
const height = Math.max(64, Math.round(request.height));
const object = await loadResourceModelObject(request);
try {
scene.add(object);
/**
* 相机宽高比必须跟着这次渲染的像素尺寸走。
*
* 这个相机是单例复用件(默认 `aspect = 1`),而卡片是宽扁的:不更新宽高比时,
* 方形投影会被塞进非方形的绘制缓冲,模型在卡面上就是**被拉伸**的 —— 与 3D 软件里
* 同一个模型的比例对不上。浮层里的交互相机已经在创建 / 改尺寸时设过,这里补齐缩略图。
*/
camera.aspect = width / height;
frameResourceModelInCamera(THREE, object, camera);
renderer.setPixelRatio(1);
renderer.setSize(width, height, false);
renderer.render(scene, camera);
return renderer.domElement.toDataURL('image/png');
} finally {
scene.remove(object);
disposeResourceModelObject(object);
}
}
/**
* 取一张模型缩略图(同一份字节只渲染一次)。
*
* 队列是串行的:渲染器只有一个,两个模型同时渲染会互相覆盖同一个 canvas。
*/
export async function renderResourceModelThumbnail(
request: ResourceModelThumbnailRequest,
): Promise<string> {
const cacheKey = resourceModelThumbnailCacheKey(request);
const cached = thumbnailCache.get(cacheKey);
if (cached) {
rememberThumbnail(cacheKey, cached);
return cached;
}
const task = renderQueue.then(() => renderOnce(request));
// 队列本身不能因为某一张失败就断掉:失败向调用方抛,队列继续排下一个。
renderQueue = task.catch(() => undefined);
const dataUrl = await task;
rememberThumbnail(cacheKey, dataUrl);
return dataUrl;
}
@@ -74,6 +74,20 @@ export type ProjectResourceTypeLabel =
| 'Agent 回执'
| '项目版本'
| '游戏代码'
| '模型'
| '动画'
| '骨骼动画'
| '场景'
| '预制体'
| '瓦片地图'
| '材质'
| '特效'
| '图集'
| '位图字体'
| '纹理'
| '自动图集'
| '音频片段'
| '二进制'
| '未知';
const documentExtension =
@@ -82,6 +96,20 @@ const gameCodeExtension =
/\.(html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
/*
* 引擎(Cocos 3.8.8)资源扩展名。四组扩展名与原生侧的三份表
* `bridge_project_file_class` / `agent_local_project_file_type` / `prompt_context_media_type`
* 必须同步:少一个扩展名在这里,资源就会「登记得了、画布不显示」,而且没有任何报错。
*/
/** 三维模型与网格数据。 */
const engineModelExtension = /\.(glb|gltf|fbx|mesh|skeleton)$/iu;
/** 图像容器(浏览器解不了,原生侧转码成 PNG 后按图片显示)。 */
const engineImageContainerExtension = /\.(tga|tif|tiff|hdr|exr|psd|znt)$/iu;
/** 材质与特效:登记为 `code`,卡面另行按结构预览。 */
const engineMaterialExtension = /\.(mtl|material|pmtl|effect|chunk)$/iu;
/** 场景、动画、图集与容器:登记为 `document`,卡面按结构预览或类型卡。 */
const engineStructuredExtension =
/\.(scene|fire|prefab|anim|animation|animgraph|animgraphvari|animask|tmx|terrain|plist|labelatlas|atlas|fnt|pac|dbbin|bin|skel|texture|cubemap|rt)$/iu;
const gameCodeKind =
/(?:^|[-_])(game-(?:entry|style|script)|code|source)(?:$|[-_])/iu;
const artKind =
@@ -132,10 +160,17 @@ export function projectedResourceKind(input: {
if (
normalizedMediaType.startsWith('image/') ||
normalizedMediaType.startsWith('video/') ||
artExtension.test(normalizedPath)
artExtension.test(normalizedPath) ||
engineModelExtension.test(normalizedPath) ||
engineImageContainerExtension.test(normalizedPath)
) {
return 'art';
}
// 引擎材质 / 特效排在文档之前:它们的 mediaType 多为 `application/json`
// 一旦先落到文档分支,画布分类就和登记时的 kind 对不上。
if (engineMaterialExtension.test(normalizedPath)) {
return 'code';
}
if (
normalizedMediaType === 'text/html' ||
normalizedMediaType === 'text/css' ||
@@ -149,7 +184,8 @@ export function projectedResourceKind(input: {
normalizedMediaType.includes('json') ||
normalizedMediaType.includes('yaml') ||
normalizedMediaType.startsWith('text/') ||
documentExtension.test(normalizedPath)
documentExtension.test(normalizedPath) ||
engineStructuredExtension.test(normalizedPath)
) {
return 'document';
}
@@ -203,6 +239,55 @@ export function projectResourceTypeLabel(
if (subtype === 'agent-result') {
return 'Agent 回执';
}
/*
* 引擎资源的类型角标先按扩展名判定。
*
* 它们在 manifest 里复用的是既有 canonical kind(模型/场景 → `scene`,动画 →
* `character-animation`,材质/特效 → `code`,容器 → `document`),只用 kind 反推
* 会把这些资源一律说成「图片」「文档」或「游戏代码」,用户看不出这是什么。
*/
if (/\.(glb|gltf|fbx|mesh)$/iu.test(path)) {
return '模型';
}
if (/\.(anim|animation|animgraph|animgraphvari|animask)$/iu.test(path)) {
return '动画';
}
if (/\.(skeleton|skel|dbbin)$/iu.test(path)) {
return '骨骼动画';
}
if (/\.(scene|fire|terrain)$/iu.test(path)) {
return '场景';
}
if (/\.prefab$/iu.test(path)) {
return '预制体';
}
if (/\.tmx$/iu.test(path)) {
return '瓦片地图';
}
if (/\.(mtl|material|pmtl)$/iu.test(path)) {
return '材质';
}
if (/\.(effect|chunk)$/iu.test(path)) {
return '特效';
}
if (/\.(plist|atlas|labelatlas)$/iu.test(path)) {
return '图集';
}
if (/\.fnt$/iu.test(path)) {
return '位图字体';
}
if (/\.(texture|cubemap|rt)$/iu.test(path)) {
return '纹理';
}
if (/\.pac$/iu.test(path)) {
return '自动图集';
}
if (/\.pcm$/iu.test(path)) {
return '音频片段';
}
if (/\.bin$/iu.test(path)) {
return '二进制';
}
if (kind === 'code') {
return '游戏代码';
}

Some files were not shown because too many files have changed in this diff Show More