资源画布支持引擎资源预览与模型交互预览 #413
@@ -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"
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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 Creator(3.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));
|
||||
|
||||
@@ -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 = {
|
||||
/** 预览管线给出的模型字节 URL(blob 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>
|
||||
);
|
||||
}
|
||||
+114
@@ -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}
|
||||
|
||||
+154
-3
@@ -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
Reference in New Issue
Block a user