This commit is contained in:
2026-05-11 16:15:48 +08:00
parent 0c9254502c
commit e30b733b17
87 changed files with 3527 additions and 1261 deletions

View File

@@ -14,6 +14,7 @@ use axum::{
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use futures_util::future::try_join_all;
use image::{GenericImageView, ImageFormat};
use module_match3d::{
MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX,
@@ -79,9 +80,12 @@ const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4;
const MATCH3D_GENERATED_ITEM_COUNT: usize = 3;
const MATCH3D_GENERATED_CLEAR_COUNT: u32 = 3;
const MATCH3D_MATERIAL_GRID_SIZE: u32 = 2;
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 36;
const MATCH3D_RODIN_STATUS_MAX_ATTEMPTS: usize = 120;
const MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS: u64 = 5_000;
const MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS: usize = 60;
const MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS: u64 = 5_000;
const MATCH3D_RODIN_MAX_MODEL_BYTES: usize = 120 * 1024 * 1024;
const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10你要创作的关卡是难度几";
@@ -116,6 +120,12 @@ struct Match3DGeneratedItemAsset {
error: Option<String>,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedWorkMetadata {
game_name: String,
tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DGeneratedItemAssetJson {
@@ -146,6 +156,16 @@ struct Match3DAssetUpload {
object_key: String,
}
#[derive(Clone, Debug)]
struct Match3DGeneratedItemModelSeed {
item_id: String,
item_name: String,
item_slug: String,
image_upload: Match3DAssetUpload,
image_bytes: Vec<u8>,
generated_at_micros: i64,
}
struct Match3DRodinModelAsset {
task_uuid: String,
subscription_key: String,
@@ -172,6 +192,19 @@ pub(crate) struct CompileMatch3DDraftRequest {
cover_image_src: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsRequest {
game_name: String,
theme_text: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GenerateMatch3DWorkTagsResponse {
tags: Vec<String>,
}
pub async fn create_match3d_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -571,6 +604,26 @@ pub async fn put_match3d_work(
))
}
pub async fn generate_match3d_work_tags(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<GenerateMatch3DWorkTagsRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
let tags = generate_match3d_work_tags_for_profile(
&state,
payload.game_name.as_str(),
payload.theme_text.as_str(),
)
.await;
Ok(json_success_body(
Some(&request_context),
GenerateMatch3DWorkTagsResponse { tags },
))
}
pub async fn publish_match3d_work(
State(state): State<AppState>,
Path(profile_id): Path<String>,
@@ -994,11 +1047,8 @@ async fn compile_match3d_draft_for_session(
));
}
let tags_json = tags
.as_ref()
.map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default());
let profile_id = build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX);
let generated_work_metadata = generate_match3d_work_metadata(state, &config).await;
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
@@ -1008,6 +1058,13 @@ async fn compile_match3d_draft_for_session(
&config,
)
.await?;
let resolved_game_name =
normalize_optional_match3d_text(game_name).unwrap_or(generated_work_metadata.game_name);
let resolved_tags = tags
.map(normalize_tags)
.filter(|items| !items.is_empty())
.unwrap_or(generated_work_metadata.tags);
let tags_json = Some(serde_json::to_string(&resolved_tags).unwrap_or_default());
let session = state
.spacetime_client()
@@ -1016,8 +1073,8 @@ async fn compile_match3d_draft_for_session(
owner_user_id,
profile_id,
author_display_name: resolve_author_display_name(state, authenticated),
game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))),
summary_text: summary,
game_name: Some(resolved_game_name),
summary_text: normalize_optional_match3d_text(summary).or_else(|| Some(String::new())),
tags_json,
cover_image_src,
cover_asset_id: None,
@@ -1535,11 +1592,11 @@ fn first_positive_integer(text: &str) -> Option<u32> {
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut result = Vec::new();
let mut result: Vec<String> = Vec::new();
for tag in tags {
let trimmed = tag.trim();
if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) {
result.push(trimmed.to_string());
let trimmed = normalize_match3d_tag(tag.as_str());
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
result.push(trimmed);
}
if result.len() >= 6 {
break;
@@ -1548,6 +1605,138 @@ fn normalize_tags(tags: Vec<String>) -> Vec<String> {
result
}
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn normalize_match3d_tag(value: &str) -> String {
let trimmed = value.trim();
let without_number_prefix = trimmed
.char_indices()
.find_map(|(index, ch)| {
if index == 0 || !matches!(ch, '.' | '、' | ')' | '') {
return None;
}
let prefix = &trimmed[..index];
if prefix.chars().all(|candidate| candidate.is_ascii_digit()) {
Some(trimmed[index + ch.len_utf8()..].trim_start())
} else {
None
}
})
.unwrap_or(trimmed);
without_number_prefix
.trim_matches(|ch: char| {
ch.is_ascii_punctuation()
|| matches!(
ch,
'' | '。' | '、' | '' | '' | '' | '' | '“' | '”' | '《' | '》'
)
})
.trim()
.chars()
.filter(|ch| !matches!(ch, '#' | '"' | '\'' | '`'))
.collect::<String>()
.chars()
.take(6)
.collect::<String>()
}
fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
where
S: AsRef<str>,
{
let mut tags = Vec::new();
for candidate in candidates {
let normalized = normalize_match3d_tag(candidate.as_ref());
if normalized.is_empty() || tags.iter().any(|tag| tag == &normalized) {
continue;
}
tags.push(normalized);
if tags.len() >= 6 {
break;
}
}
for fallback in ["抓大鹅", "经典消除", "3D素材", "轻量休闲", "收集", "挑战"] {
if tags.len() >= 6 {
break;
}
if !tags.iter().any(|tag| tag == fallback) {
tags.push(fallback.to_string());
}
}
tags
}
async fn generate_match3d_work_tags_for_profile(
state: &AppState,
game_name: &str,
theme_text: &str,
) -> Vec<String> {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_work_tags(game_name, theme_text);
};
let user_prompt = format!(
"题材设定:{}\n作品名称:{}\n请生成 3 到 6 个适合抓大鹅作品发布的中文短标签。要求:只返回 JSON 数组,每项 2 到 6 个汉字,不要解释。",
theme_text, game_name
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system("你是抓大鹅作品标签编辑,只返回 JSON 字符串数组。"),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => {
let tags = parse_match3d_tags_from_text(response.content.as_str());
if tags.len() >= MATCH3D_MIN_GENERATED_TAG_COUNT {
return tags;
}
fallback_match3d_work_tags(game_name, theme_text)
}
Err(error) => {
tracing::warn!(
provider = MATCH3D_WORKS_PROVIDER,
game_name,
error = %error,
"抓大鹅 AI 标签生成失败,降级使用本地标签"
);
fallback_match3d_work_tags(game_name, theme_text)
}
}
}
const MATCH3D_MIN_GENERATED_TAG_COUNT: usize = 3;
fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('[')
&& let Some(end) = raw.rfind(']')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let parsed = serde_json::from_str::<Vec<String>>(json_text).unwrap_or_default();
normalize_match3d_tag_candidates(parsed)
}
fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "3D素材"])
}
fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
if assets.is_empty() {
return None;
@@ -1634,7 +1823,7 @@ async fn generate_match3d_item_assets(
let item_images = slice_match3d_material_sheet(&material_sheet.image, &item_names)
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let mut item_assets = Vec::with_capacity(item_images.len());
let mut model_seeds = Vec::with_capacity(item_images.len());
for (index, item_image) in item_images.into_iter().enumerate() {
let item_name = item_names
.get(index)
@@ -1661,36 +1850,61 @@ async fn generate_match3d_item_assets(
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
let model_asset = generate_match3d_rodin_model_asset(
state,
owner_user_id,
session_id,
profile_id,
&item_slug,
&item_name,
config,
image_bytes,
generated_at_micros.saturating_add(100 + index as i64),
)
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
item_assets.push(Match3DGeneratedItemAsset {
model_seeds.push(Match3DGeneratedItemModelSeed {
item_id,
item_name,
image_src: Some(image_upload.src),
image_object_key: Some(image_upload.object_key),
model_src: Some(model_asset.upload.src),
model_object_key: Some(model_asset.upload.object_key),
model_file_name: Some(model_asset.model_file_name),
task_uuid: Some(model_asset.task_uuid),
subscription_key: Some(model_asset.subscription_key),
status: "model_ready".to_string(),
error: None,
item_slug,
image_upload,
image_bytes,
generated_at_micros: generated_at_micros.saturating_add(100 + index as i64),
});
}
// 中文注释Rodin 单个模型耗时不可控,必须在图片切割和入库后并行提交所有图生模型,
// 避免多个物品的排队和轮询时间串行叠加导致 action 超时。
let model_results = try_join_all(model_seeds.into_iter().map(|seed| {
async move {
let Match3DGeneratedItemModelSeed {
item_id,
item_name,
item_slug,
image_upload,
image_bytes,
generated_at_micros,
} = seed;
let model_asset = generate_match3d_rodin_model_asset(
state,
owner_user_id,
session_id,
profile_id,
item_slug.as_str(),
item_name.as_str(),
config,
image_bytes,
generated_at_micros,
)
.await?;
Ok::<_, AppError>(Match3DGeneratedItemAsset {
item_id,
item_name,
image_src: Some(image_upload.src),
image_object_key: Some(image_upload.object_key),
model_src: Some(model_asset.upload.src),
model_object_key: Some(model_asset.upload.object_key),
model_file_name: Some(model_asset.model_file_name),
task_uuid: Some(model_asset.task_uuid),
subscription_key: Some(model_asset.subscription_key),
status: "model_ready".to_string(),
error: None,
})
}
}))
.await
.map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?;
// 中文注释:草稿阶段必须同时产出 GLB 模型,结果页直接加载模型预览。
Ok(item_assets)
Ok(model_results)
}
struct Match3DMaterialSheet {
@@ -1702,6 +1916,93 @@ struct Match3DSlicedItemImage {
bytes: Vec<u8>,
}
async fn generate_match3d_work_metadata(
state: &AppState,
config: &Match3DConfigJson,
) -> Match3DGeneratedWorkMetadata {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_work_metadata(config.theme_text.as_str());
};
let system_prompt = "你是抓大鹅游戏的作品命名编辑,只返回 JSON。";
let user_prompt = format!(
"题材设定:{}\n请生成抓大鹅游戏作品元信息。要求:只返回 JSON 对象,字段为 gameName、tags。gameName 为 4 到 12 个中文字符不要包含“作品”“游戏”tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字。不要生成描述。",
config.theme_text
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => parse_match3d_work_metadata(response.content.as_str())
.unwrap_or_else(|| fallback_match3d_work_metadata(config.theme_text.as_str())),
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
theme_text = config.theme_text.as_str(),
error = %error,
"抓大鹅作品名称生成失败,降级使用本地元信息"
);
fallback_match3d_work_metadata(config.theme_text.as_str())
}
}
}
fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('{')
&& let Some(end) = raw.rfind('}')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let value = serde_json::from_str::<Value>(json_text).ok()?;
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
if game_name.is_empty() {
return None;
}
let tags = value
.get("tags")
.and_then(Value::as_array)
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
.unwrap_or_default();
Some(Match3DGeneratedWorkMetadata {
game_name,
tags: normalize_match3d_tag_candidates(tags),
})
}
fn normalize_match3d_game_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(16)
.collect::<String>()
.trim()
.to_string()
}
fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
Match3DGeneratedWorkMetadata {
game_name: format!("{normalized_theme}抓大鹅"),
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "3D素材"]),
}
}
async fn generate_match3d_item_names(
state: &AppState,
config: &Match3DConfigJson,
@@ -1844,25 +2145,12 @@ async fn generate_match3d_rodin_model_asset(
)
.await?;
wait_for_match3d_rodin_model(
state,
submit_response.subscription_key.as_str(),
item_name,
)
.await?;
let download_response = query_downloads(
state,
hyper3d_contract::Hyper3dDownloadRequest {
task_uuid: submit_response.task_uuid.clone(),
},
)
.await?;
let model_file = select_match3d_glb_download(
&download_response.files,
submit_response.task_uuid.as_str(),
item_name,
)?;
let downloaded_model = download_match3d_rodin_model(model_file).await?;
wait_for_match3d_rodin_model(state, submit_response.subscription_key.as_str(), item_name)
.await?;
let model_file =
wait_for_match3d_rodin_download_file(state, submit_response.task_uuid.as_str(), item_name)
.await?;
let downloaded_model = download_match3d_rodin_model(&model_file).await?;
let uploaded_model = persist_match3d_generated_bytes(
state,
owner_user_id,
@@ -1940,10 +2228,7 @@ async fn wait_for_match3d_rodin_model(
}
if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS,
))
.await;
tokio::time::sleep(Duration::from_millis(MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS)).await;
}
}
@@ -1952,25 +2237,77 @@ async fn wait_for_match3d_rodin_model(
)))
}
fn select_match3d_glb_download<'a>(
files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload],
async fn wait_for_match3d_rodin_download_file(
state: &AppState,
task_uuid: &str,
item_name: &str,
) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
) -> Result<hyper3d_contract::Hyper3dDownloadFilePayload, AppError> {
for attempt in 0..MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS {
let download_response = query_downloads(
state,
hyper3d_contract::Hyper3dDownloadRequest {
task_uuid: task_uuid.to_string(),
},
)
.await?;
if let Some(model_file) = find_match3d_glb_download(&download_response.files) {
return Ok(model_file.clone());
}
// 中文注释Rodin 状态 Done 后下载列表偶尔会延迟发布,短轮询避免把已完成任务误判失败。
if attempt + 1 < MATCH3D_RODIN_DOWNLOAD_MAX_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
MATCH3D_RODIN_DOWNLOAD_POLL_INTERVAL_MS,
))
.await;
}
}
Err(match3d_bad_gateway(format!(
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
)))
}
fn find_match3d_glb_download(
files: &[hyper3d_contract::Hyper3dDownloadFilePayload],
) -> Option<&hyper3d_contract::Hyper3dDownloadFilePayload> {
files
.iter()
.find(|file| {
file.name.to_ascii_lowercase().ends_with(".glb")
|| file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb")
})
.or_else(|| files.first())
.ok_or_else(|| {
match3d_bad_gateway(format!(
"{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}"
))
.find(|file| match3d_download_file_has_extension(file, ".glb"))
.or_else(|| {
files
.iter()
.find(|file| !is_match3d_preview_or_image_download(file))
})
}
fn match3d_download_file_has_extension(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
extension: &str,
) -> bool {
file.name.to_ascii_lowercase().ends_with(extension)
|| file
.url
.to_ascii_lowercase()
.split('?')
.next()
.unwrap_or("")
.ends_with(extension)
}
fn is_match3d_preview_or_image_download(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> bool {
let name = file.name.to_ascii_lowercase();
let url_path = file.url.to_ascii_lowercase();
let url_path = url_path.split('?').next().unwrap_or(url_path.as_str());
name.contains("preview")
|| url_path.contains("preview")
|| [".png", ".jpg", ".jpeg", ".webp", ".gif"]
.iter()
.any(|extension| name.ends_with(extension) || url_path.ends_with(extension))
}
async fn download_match3d_rodin_model(
file: &hyper3d_contract::Hyper3dDownloadFilePayload,
) -> Result<Match3DDownloadedModel, AppError> {
@@ -2010,17 +2347,22 @@ async fn download_match3d_rodin_model(
fn normalize_match3d_model_file_name(raw: &str) -> String {
let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim();
let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim();
let sanitized = sanitize_match3d_asset_segment(without_query, "model");
if sanitized.to_ascii_lowercase().ends_with(".glb") {
sanitized
} else {
"model.glb".to_string()
}
let normalized = without_query.to_ascii_lowercase();
let stem = without_query
.strip_suffix(".glb")
.or_else(|| {
normalized
.strip_suffix(".glb")
.map(|_| &without_query[..without_query.len().saturating_sub(4)])
})
.unwrap_or(without_query);
let sanitized_stem = sanitize_match3d_asset_segment(stem, "model");
format!("{sanitized_stem}.glb")
}
fn normalize_match3d_model_content_type(raw: &str) -> String {
let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase();
if normalized == "model/gltf-binary" || normalized == "application/octet-stream" {
if normalized == "model/gltf-binary" {
return normalized;
}
"model/gltf-binary".to_string()
@@ -2445,6 +2787,96 @@ mod tests {
);
}
#[test]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅","经典消除","轻量休闲"]}"#,
)
.expect("metadata should parse");
assert_eq!(metadata.game_name, "果园大鹅宴");
assert_eq!(
metadata.tags,
vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "3D素材", "收集"]
);
}
#[test]
fn match3d_work_metadata_fallback_keeps_empty_description_boundary() {
let metadata = fallback_match3d_work_metadata("水果");
assert_eq!(metadata.game_name, "水果抓大鹅");
assert!(metadata.tags.contains(&"水果".to_string()));
assert!(metadata.tags.contains(&"抓大鹅".to_string()));
}
#[test]
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材");
assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲");
}
#[test]
fn match3d_model_download_metadata_normalizes_to_glb() {
assert_eq!(
normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"),
"fruit-model.glb"
);
assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb");
assert_eq!(
normalize_match3d_model_content_type("application/octet-stream"),
"model/gltf-binary"
);
assert_eq!(
normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"),
"model/gltf-binary"
);
}
#[test]
fn match3d_model_download_prefers_glb_file() {
let files = vec![
hyper3d_contract::Hyper3dDownloadFilePayload {
name: "preview.png".to_string(),
url: "https://cdn.example/preview.png".to_string(),
},
hyper3d_contract::Hyper3dDownloadFilePayload {
name: "model".to_string(),
url: "https://cdn.example/model.glb?token=1".to_string(),
},
];
let selected = find_match3d_glb_download(&files).expect("glb download should be selected");
assert_eq!(selected.url, "https://cdn.example/model.glb?token=1");
}
#[test]
fn match3d_model_download_falls_back_to_first_file() {
let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload {
name: "model".to_string(),
url: "https://cdn.example/download?id=1".to_string(),
}];
let selected =
find_match3d_glb_download(&files).expect("opaque download url should be accepted");
assert_eq!(selected.url, "https://cdn.example/download?id=1");
}
#[test]
fn match3d_model_download_does_not_accept_preview_image_only() {
let files = vec![hyper3d_contract::Hyper3dDownloadFilePayload {
name: "preview.png".to_string(),
url: "https://cdn.example/preview.png".to_string(),
}];
let result = find_match3d_glb_download(&files);
assert!(result.is_none());
}
#[test]
fn match3d_work_summary_maps_persisted_generated_item_assets() {
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {