退役界面图参考语义建议的 Rust 链路

- 删除 ui_editor/commands/ui_design_suggestion.rs 与 main.rs 的 suggest_ui_design_semantic 命令注册

- 收敛 UIDesignImage 为 path/pixel_size/pixels_per_unit,删除 UIDesignImageRole 与 UIDesignImageMetadata

- 结构识别改为每张界面图各自一棵树,删除 recognition_root_image_ids 与 slave_image_ids 及 ROOT/SLAVE 上下文标注

- 多树合并删除 slave_to 祖先链优先级,输入树优先级恒置 0 并留 TODO

- 持久化校验删除 slave_to 引用与环校验,html 片段注释不再携带界面图 name/description

- 页面级工作流构造页面设计图时不再写入界面图元数据
This commit is contained in:
2026-09-23 13:29:39 +08:00
parent d8c19d9187
commit e18a398cdf
10 changed files with 21 additions and 625 deletions
@@ -197,14 +197,6 @@ use swarm_cli::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
async fn suggest_ui_design_semantic(
project_path: String,
state: ui_editor::state::State,
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
}
#[tauri::command]
async fn recognize_ui(
project_path: String,
@@ -2665,7 +2657,6 @@ fn main() {
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
separate_ui,
inspect_separation_recovery,
@@ -114,59 +114,6 @@ mod llm_contract {
}
}
mod priority {
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use std::collections::{HashMap, HashSet};
fn ancestor_chain(
image_id: &UIDesignImageId,
images: &HashMap<UIDesignImageId, UIDesignImage>,
) -> Result<Vec<UIDesignImageId>, String> {
let mut chain = Vec::new();
let mut seen = HashSet::new();
let mut current = image_id.clone();
loop {
if !seen.insert(current.clone()) {
return Err(format!(
"界面图 slave_to 关系存在循环:{}",
current.as_str()
));
}
chain.push(current.clone());
let image = images
.get(&current)
.ok_or_else(|| format!("界面图 slave_to 引用了缺失界面图:{}", current.as_str()))?;
match &image.metadata.slave_to {
Some(parent) => current = parent.clone(),
None => return Ok(chain),
}
}
}
pub(super) fn for_state(state: &State) -> Result<Vec<u32>, String> {
let source_ids = state
.ui_trees
.iter()
.map(|tree| tree.src_ui_design.clone())
.collect::<Vec<_>>();
let chains = source_ids
.iter()
.map(|source_id| ancestor_chain(source_id, &state.ui_design_images))
.collect::<Result<Vec<_>, _>>()?;
Ok(source_ids
.iter()
.map(|candidate| {
chains
.iter()
.filter(|chain| chain.contains(candidate))
.count() as u32
})
.collect())
}
}
mod materialize {
use super::llm_contract::Node;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
@@ -217,6 +164,7 @@ mod materialize {
Ok(())
}
// TODO(ui-merge): 优先级恒 0 时该函数固定取 merged_from 的首个成员;优先级来源待重新设计。
fn highest_priority_member_index(members: &[BuiltNode]) -> Result<usize, String> {
if members.is_empty() {
return Err("MergedNode.merged_from 不能为空".to_string());
@@ -412,10 +360,8 @@ pub(crate) async fn merge_ui_impl_with_provider(
app_log!("ui_merge.error stage=validate_input error={error}");
error
})?;
let priorities = priority::for_state(&state).map_err(|error| {
app_log!("ui_merge.error stage=build_priority error={error}");
error
})?;
// TODO(ui-merge): 输入树优先级来源待重新设计,当前所有输入树等权(恒 0)。
let priorities = vec![0u32; state.ui_trees.len()];
let trees = llm_contract::input_trees(&state, &priorities).map_err(|error| {
app_log!("ui_merge.error stage=build_input error={error}");
error
@@ -2,7 +2,6 @@ pub mod binding;
pub mod merge;
pub mod recognition;
pub mod separation;
pub mod ui_design_suggestion;
pub mod utils;
pub use binding::BindingDTO;
@@ -13,5 +12,3 @@ pub use recognition::RecognitionDTO;
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
pub(crate) use separation::separate_ui_impl;
pub use separation::{SeparationDTO, SeparationRecoveryDTO};
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
@@ -32,7 +32,7 @@ const SYSTEM_PROMPT: &str = r#"
任务:
同时分析同一 UI 系统的全部参考图,建立UI树
用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构
用户会给你一些UI截图(它们从属于同一个UI系统), 请用给定的工具描述UI结构
识别规则:
* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构.
@@ -386,12 +386,6 @@ mod tests {
fn test_image() -> UIDesignImage {
UIDesignImage {
metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata {
name: "测试图".to_string(),
description: String::new(),
role: None,
slave_to: None,
},
path: "test.png".to_string(),
pixel_size: Vector2::new(1000.0, 500.0),
pixels_per_unit: typed_floats::tf32::StrictlyPositiveFinite::new(2.0)
@@ -548,7 +542,7 @@ mod tests {
#[test]
fn conversion_stays_in_the_tree_image_coordinate_system() {
let image_id = UIDesignImageId::new("slave").expect("valid image id");
let image_id = UIDesignImageId::new("second").expect("valid image id");
let image = test_image();
let root_rect = UIRect::new(Point2::origin(), image_layout_size(&image).unwrap());
let converted =
@@ -591,56 +585,29 @@ mod tests {
#[test]
fn tree_validation_requires_exactly_one_tree_per_context_image() {
let page = UIDesignImageId::new("page").expect("valid image id");
let slave = UIDesignImageId::new("slave").expect("valid image id");
let second = UIDesignImageId::new("second").expect("valid image id");
let tree = |id: UIDesignImageId| RecognitionTree {
src_ui_design_image_id: id,
root: test_node(),
};
assert!(validate_tree_image_ids(
&[tree(page.clone()), tree(slave.clone())],
&[page.clone(), slave.clone()],
&[tree(page.clone()), tree(second.clone())],
&[page.clone(), second.clone()],
)
.is_ok());
assert!(
validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), slave.clone()]).is_err()
);
assert!(
validate_tree_image_ids(&[tree(page.clone()), tree(page.clone())], &[page, slave],)
validate_tree_image_ids(&[tree(page.clone())], &[page.clone(), second.clone()])
.is_err()
);
assert!(validate_tree_image_ids(
&[tree(page.clone()), tree(page.clone())],
&[page, second],
)
.is_err());
}
}
fn recognition_root_image_ids(state: &State) -> Vec<UIDesignImageId> {
state
.ui_design_images
.iter()
.filter_map(|(id, image)| {
let is_page = image.metadata.role
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page);
let is_direct_slave_of_page = image.metadata.slave_to.as_ref().is_some_and(|parent| {
state
.ui_design_images
.get(parent)
.and_then(|parent_image| parent_image.metadata.role)
== Some(crate::ui_editor::resource::ui_design_image::UIDesignImageRole::Page)
});
(is_page || !is_direct_slave_of_page).then(|| id.clone())
})
.collect()
}
fn slave_image_ids(state: &State, root_id: &UIDesignImageId) -> Vec<UIDesignImageId> {
state
.ui_design_images
.iter()
.filter_map(|(id, image)| {
(image.metadata.slave_to.as_ref() == Some(root_id)).then(|| id.clone())
})
.collect()
}
pub(crate) async fn recognize_ui_impl_with_provider(
project_path: String,
state: State,
@@ -657,11 +624,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
);
return Err("界面图最多 4 张".to_string());
}
let root_ids = recognition_root_image_ids(&state);
if root_ids.is_empty() {
app_log!("ui_recognition.error stage=validate reason=no_root_image");
return Err("至少需要一张可作为识别上下文根的界面图".to_string());
}
let root_ids = state.ui_design_images.keys().cloned().collect::<Vec<_>>();
let (llm, client) = if provider_identity.is_none() {
let llm = load_game_creator_app_config()
.map_err(|error| {
@@ -685,12 +648,9 @@ pub(crate) async fn recognize_ui_impl_with_provider(
let root = Path::new(project_path.trim());
let mut ui_trees = Vec::with_capacity(root_ids.len());
for root_id in root_ids {
let mut context_ids = vec![root_id.clone()];
context_ids.extend(slave_image_ids(&state, &root_id));
// Page 仍可把直接 slave 参考图放在同一识别上下文;没有 Page
// 归属关系的其它界面图各自作为独立上下文根。
let context_ids = vec![root_id.clone()];
let mut parts = Vec::with_capacity(context_ids.len() * 2);
for (index, context_id) in context_ids.iter().enumerate() {
for context_id in context_ids.iter() {
let image = state
.ui_design_images
.get(context_id)
@@ -716,8 +676,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
})?;
parts.push(LlmMessageContentPart::InputText {
text: format!(
"{} id={} pixel_size={:?}",
if index == 0 { "ROOT" } else { "SLAVE" },
"ROOT id={} pixel_size={:?}",
context_id.as_str(),
image.pixel_size
),
@@ -60,12 +60,6 @@ mod tests {
ui_design_images: HashMap::from([(
image_id,
UIDesignImage {
metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata {
name: "page".to_string(),
description: String::new(),
role: None,
slave_to: None,
},
path: "page.png".to_string(),
pixel_size: Vector2::new(100.0, 100.0),
pixels_per_unit: StrictlyPositiveFinite::new(1.0).unwrap(),
@@ -1,349 +0,0 @@
use crate::config::build_game_creator_llm_client_from_llm_config;
use crate::config::load_game_creator_app_config;
use crate::ui_editor::commands::utils::{
parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm,
required_tool_call_arguments, strict_json_schema,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImageRole;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use ts_rs::TS;
const SYSTEM_PROMPT: &str = r#"
请识别这些 UI 参考图的界面语义,并调用 suggest_ui_design_semantics 工具返回结果。
不要在工具调用外输出 JSON、Markdown、代码围栏、注释、额外字段或解释文字。
字段含义:
- id:必填,必须逐字匹配当前输入参考图的 id。它标识“这条建议属于哪张图”,不是新 ID,不能为 null。
- name:要写入该图片 metadata 的简短、可读名称;
- description:要写入该图片 metadata 的简短语义描述,例如“带底部导航的主游戏页面”。
- role:要写入该图片 metadata 的界面角色。
Page 表示完整主页面,
Section 表示同一主页面中的子界面或页签,
Modal/Drawer/Popover 表示浮层或局部覆盖界面,
State 表示同一界面的状态变体,
Scrolled 表示滚动或分页后的内容,
Detail 表示局部详情或补充证据。
- children:该节点直接包含的子界面图。
- 根节点必须是 Page,子节点不能是 Page
所有字段都必须出现,禁止省略字段、使用 null 或返回空字符串。每张参考图最多出现一次;id 必须逐字匹配输入图片 id。
以下 UI 设计图中的 metadata 部分信息已确定,请根据已有信息补全语义.
"#;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct UIDesignSuggestionTreeNode {
pub id: UIDesignImageId,
pub name: String,
pub description: String,
pub role: UIDesignImageRole,
pub children: Vec<UIDesignSuggestionTreeNode>,
}
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
#[serde(deny_unknown_fields)]
struct UIDesignSuggestion {
ui_designs: Vec<UIDesignSuggestionTreeNode>,
}
fn ui_design_suggestion_json_schema() -> Result<serde_json::Value, String> {
strict_json_schema::<UIDesignSuggestion>()
}
const MAX_REFERENCES: usize = 4;
const MAX_SUGGESTION_TREE_DEPTH: usize = 4;
fn validate_suggestion_response_shape(value: &serde_json::Value) -> Result<(), String> {
let roots = value
.get("ui_designs")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "UI 语义建议工具参数缺少 ui_designs 数组".to_string())?;
let mut stack = roots.iter().map(|node| (node, 1usize)).collect::<Vec<_>>();
let mut node_count = 0usize;
while let Some((node, depth)) = stack.pop() {
if depth > MAX_SUGGESTION_TREE_DEPTH {
return Err(format!(
"UI 语义建议树最大深度不能超过 {MAX_SUGGESTION_TREE_DEPTH}"
));
}
node_count += 1;
if node_count > MAX_REFERENCES {
return Err(format!("UI 语义建议最多包含 {MAX_REFERENCES} 个节点"));
}
let children = node
.get("children")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "UI 语义建议节点缺少 children 数组".to_string())?;
stack.extend(children.iter().map(|child| (child, depth + 1)));
}
Ok(())
}
fn validate_suggestions(
suggestions: &[UIDesignSuggestionTreeNode],
image_ids: &HashSet<UIDesignImageId>,
) -> Result<(), String> {
let mut seen = HashSet::new();
fn visit(
nodes: &[UIDesignSuggestionTreeNode],
is_root: bool,
image_ids: &HashSet<UIDesignImageId>,
seen: &mut HashSet<UIDesignImageId>,
) -> Result<(), String> {
for node in nodes {
if !image_ids.contains(&node.id) {
return Err("LLM 返回了未知界面图 ID".to_string());
}
if !seen.insert(node.id.clone()) {
return Err("LLM 返回了重复界面图建议".to_string());
}
if node.name.trim().is_empty() || node.description.trim().is_empty() {
return Err("界面图语义字段不能是空字符串".to_string());
}
if is_root {
if node.role != UIDesignImageRole::Page {
return Err("界面图树根节点必须是 Page".to_string());
}
} else if node.role == UIDesignImageRole::Page {
return Err("Page 不能作为其它界面图的子节点".to_string());
}
visit(&node.children, false, image_ids, seen)?;
}
Ok(())
}
visit(suggestions, true, image_ids, &mut seen)?;
if seen != *image_ids {
return Err("LLM 未为每张界面图返回语义建议".to_string());
}
Ok(())
}
#[tauri::command]
pub(crate) async fn suggest_ui_design_semantic_impl(
project_path: String,
state: State,
) -> Result<Vec<UIDesignSuggestionTreeNode>, String> {
if state.ui_design_images.is_empty() {
app_log!("ui_design_suggestion.error stage=validate reason=no_images");
return Err("请先导入界面图".to_string());
}
if state.ui_design_images.len() > MAX_REFERENCES {
app_log!(
"ui_design_suggestion.error stage=validate reason=too_many_images count={}",
state.ui_design_images.len()
);
return Err("界面图最多 4 张".to_string());
}
let root = Path::new(project_path.trim());
let mut parts = Vec::new();
let mut ids = HashSet::new();
for (id, image) in &state.ui_design_images {
ids.insert(id.clone());
let absolute =
crate::project::resolve_local_project_path(root, &image.path).map_err(|error| {
app_log!(
"ui_design_suggestion.error stage=resolve_image id={} error={error}",
id.as_str()
);
error
})?;
let image_url = read_ui_reference_image_data_url(absolute)
.await
.map_err(|error| {
app_log!(
"ui_design_suggestion.error stage=read_image id={} error={error}",
id.as_str()
);
error
})?;
parts.push(LlmMessageContentPart::InputText {
text: format!("REFERENCE id:{} metadata:{}", id.as_str(), image.metadata,),
});
parts.push(LlmMessageContentPart::InputImage { image_url });
}
let llm = load_game_creator_app_config()
.map_err(|error| {
eprintln!("ui_design_suggestion.error stage=build_client error={error}");
error
})?
.llm;
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm").map_err(|error| {
app_log!("ui_design_suggestion.error stage=build_client error={error}");
error
})?;
let schema = ui_design_suggestion_json_schema().map_err(|error| {
app_log!("ui_design_suggestion.error stage=build_schema error={error}");
error
})?;
let tool = LlmFunctionTool::new(
"suggest_ui_design_semantics",
"为所有 UI 参考图补全界面语义 metadata",
schema,
)
.with_strict(true);
let response = request_ui_editor_llm(
&client,
&llm,
LlmRunRequest::new(vec![
LlmMessage::system(SYSTEM_PROMPT),
LlmMessage::user_multimodal(parts),
])
.with_function_tools(vec![tool])
.with_tool_choice(LlmToolChoice::Required),
)
.await
.map_err(|error| {
app_log!("ui_design_suggestion.error stage=llm_request error={error}");
format!("UI 参考图语义识别失败:{error}")
})?;
app_log!(
"ui_design_suggestion.llm_output text_present={} tool_call_count={}",
!response.text.trim().is_empty(),
response.tool_calls.len()
);
let arguments = required_tool_call_arguments(&response, "suggest_ui_design_semantics")
.map_err(|error| {
app_log!(
"ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call error={error}"
);
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
})?;
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?;
validate_suggestion_response_shape(&arguments).map_err(|error| {
app_log!("ui_design_suggestion.error stage=validate_arguments error={error}");
format!("LLM 响应无效(详情:{error}")
})?;
let suggestions = serde_json::from_value::<UIDesignSuggestion>(arguments)
.map_err(|error| {
app_log!("ui_design_suggestion.error stage=parse_arguments error={error}");
format!("LLM 响应无效(详情:UI 语义建议工具参数无效:{error}")
})?
.ui_designs;
validate_suggestions(&suggestions, &ids).map_err(|error| {
app_log!("ui_design_suggestion.error stage=validate_result error={error}");
format!("LLM 响应无效(详情:{error}")
})?;
Ok(suggestions)
}
#[cfg(test)]
mod tests {
use super::*;
fn id(value: &str) -> UIDesignImageId {
UIDesignImageId::new(value).expect("valid image id")
}
fn node(
value: &str,
role: UIDesignImageRole,
children: Vec<UIDesignSuggestionTreeNode>,
) -> UIDesignSuggestionTreeNode {
UIDesignSuggestionTreeNode {
id: id(value),
name: value.to_string(),
description: format!("{value} description"),
role,
children,
}
}
fn image_ids(values: &[&str]) -> HashSet<UIDesignImageId> {
values.iter().map(|value| id(value)).collect()
}
#[test]
fn validates_page_tree() {
let suggestions = vec![node(
"page",
UIDesignImageRole::Page,
vec![node("section", UIDesignImageRole::Section, Vec::new())],
)];
assert!(validate_suggestions(&suggestions, &image_ids(&["page", "section"])).is_ok());
}
#[test]
fn suggestion_shape_rejects_more_than_four_nodes_or_four_levels() {
let oversized = (0..=MAX_REFERENCES)
.map(|_| serde_json::json!({"children": []}))
.collect::<Vec<_>>();
assert!(validate_suggestion_response_shape(&serde_json::json!({
"ui_designs": oversized
}))
.is_err());
let mut nested = serde_json::json!({"children": []});
for _ in 0..MAX_SUGGESTION_TREE_DEPTH {
nested = serde_json::json!({"children": [nested]});
}
assert!(validate_suggestion_response_shape(&serde_json::json!({
"ui_designs": [nested]
}))
.is_err());
}
#[test]
fn rejects_duplicate_and_unknown_ids() {
let duplicate = vec![
node(
"page",
UIDesignImageRole::Page,
vec![node("section", UIDesignImageRole::Section, Vec::new())],
),
node("page", UIDesignImageRole::Page, Vec::new()),
];
assert!(validate_suggestions(&duplicate, &image_ids(&["page", "section"])).is_err());
let unknown = vec![node("missing", UIDesignImageRole::Page, Vec::new())];
assert!(validate_suggestions(&unknown, &image_ids(&["page"])).is_err());
}
#[test]
fn rejects_invalid_root_roles_and_nested_pages() {
let non_page_root = vec![node("section", UIDesignImageRole::Section, Vec::new())];
assert!(validate_suggestions(&non_page_root, &image_ids(&["section"])).is_err());
let nested_page = vec![node(
"page",
UIDesignImageRole::Page,
vec![node("nested", UIDesignImageRole::Page, Vec::new())],
)];
assert!(validate_suggestions(&nested_page, &image_ids(&["page", "nested"])).is_err());
}
#[test]
fn strict_schema_requires_tree_fields() {
let schema = ui_design_suggestion_json_schema().expect("schema");
assert_eq!(schema["required"], serde_json::json!(["ui_designs"]));
let node_schema = &schema["properties"]["ui_designs"]["items"];
let required = node_schema["required"]
.as_array()
.expect("node required")
.iter()
.filter_map(serde_json::Value::as_str)
.collect::<HashSet<_>>();
assert_eq!(
required,
["id", "name", "description", "role", "children"]
.into_iter()
.collect()
);
assert_eq!(node_schema["additionalProperties"], false);
}
}
@@ -84,8 +84,6 @@ pub(crate) fn render_ui_design_state_js(
"genarrative-ui-tree",
json!({
"srcUiDesign": tree.src_ui_design.as_str(),
"name": image.metadata.name,
"description": image.metadata.description,
}),
)
.into_string();
@@ -2,9 +2,7 @@ use crate::ui_editor::component::text::FontSource;
use crate::ui_editor::component::Component;
use crate::ui_editor::html_renderer::render_ui_design_state_js;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::resource::ui_design_image::{
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::State;
use crate::ui_editor::utils::UIDesignImageId;
use crate::*;
@@ -139,12 +137,6 @@ pub(crate) fn initialize_ui_design_state_with_source_image_at(
document.state.ui_design_images.insert(
source_image_id,
UIDesignImage {
metadata: UIDesignImageMetadata {
name: "游戏界面原型".to_string(),
description: "由画布 UI 原型桥接载入".to_string(),
role: Some(UIDesignImageRole::Page),
slave_to: None,
},
path: source_image_path,
pixel_size: Vector2::new(pixel_size.0 as f32, pixel_size.1 as f32),
pixels_per_unit,
@@ -614,16 +606,7 @@ fn validate_state(state: &State) -> Result<(), String> {
{
return Err("界面图像素尺寸必须为正有限数值".to_string());
}
if image
.metadata
.slave_to
.as_ref()
.is_some_and(|owner| !state.ui_design_images.contains_key(owner))
{
return Err("界面图 slaveTo 引用了不存在的界面图".to_string());
}
}
validate_slave_to_acyclic(state)?;
for (id, sprite) in &state.sprite_assets {
validate_id(id.as_str(), "独立素材 ID")?;
if sprite.asset_id != *id {
@@ -649,23 +632,6 @@ fn validate_state(state: &State) -> Result<(), String> {
Ok(())
}
fn validate_slave_to_acyclic(state: &State) -> Result<(), String> {
for start in state.ui_design_images.keys() {
let mut current = Some(start);
let mut visited = HashSet::new();
while let Some(id) = current {
if !visited.insert(id) {
return Err("界面图 slaveTo 不能形成循环".to_string());
}
current = state
.ui_design_images
.get(id)
.and_then(|image| image.metadata.slave_to.as_ref());
}
}
Ok(())
}
fn validate_node(
node: &Node,
state: &State,
@@ -1050,12 +1016,6 @@ mod tests {
"ui_trees": [],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": path,
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1136,12 +1096,6 @@ mod tests {
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "assets/page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1319,12 +1273,6 @@ mod tests {
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "missing.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1341,45 +1289,6 @@ mod tests {
);
}
#[test]
fn rejects_cyclic_slave_to_relationships() {
let state: State = serde_json::from_value(serde_json::json!({
"ui_trees": [],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": "section"
},
"path": "page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
},
"section": {
"metadata": {
"name": "子页面",
"description": "",
"role": "Section",
"slave_to": "page"
},
"path": "section.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
}
},
"sprite_assets": {},
"font_assets": {}
}))
.expect("deserialize cyclic state");
assert_eq!(
validate_state(&state),
Err("界面图 slaveTo 不能形成循环".to_string())
);
}
#[test]
fn recovers_last_valid_state_when_primary_is_corrupt() {
let (directory, asset_id) = fixture();
@@ -1554,12 +1463,6 @@ mod tests {
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "assets/page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
@@ -1,48 +1,14 @@
use crate::ui_editor::utils::UIDesignImageId;
use nalgebra::Vector2;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use ts_rs::TS;
use typed_floats::tf32::StrictlyPositiveFinite;
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub enum UIDesignImageRole {
Page,
Section,
Modal,
Drawer,
Popover,
State,
Scrolled,
Detail,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct UIDesignImage {
pub(crate) metadata: UIDesignImageMetadata,
pub(crate) path: String,
#[ts(as = "[f32; 2]")]
pub(crate) pixel_size: Vector2<f32>,
#[ts(as = "f32")]
pub(crate) pixels_per_unit: StrictlyPositiveFinite,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
pub struct UIDesignImageMetadata {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) role: Option<UIDesignImageRole>,
pub(crate) slave_to: Option<UIDesignImageId>,
}
impl Display for UIDesignImageMetadata {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"name: {}, description: {}, role: {:?}, slave_to: {:?}",
self.name, self.description, self.role, self.slave_to
)
}
}
@@ -11,9 +11,7 @@ use crate::ui_editor::persistence::{
};
use crate::ui_editor::resource::font::FontAsset;
use crate::ui_editor::resource::sprite::{SpriteAsset, SpriteAssetMetadata, SpriteBorder};
use crate::ui_editor::resource::ui_design_image::{
UIDesignImage, UIDesignImageMetadata, UIDesignImageRole,
};
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::utils::{SpriteAssetId, UIDesignImageId};
use crate::*;
use image::GenericImageView as _;
@@ -801,7 +799,7 @@ fn ensure_page_ui_resource(
})?;
let image_id = UIDesignImageId::new(page.page_id.clone())
.map_err(|error| format!("页面 image ID 无效:{error}"))?;
let expected_image = workflow_design_image(root, page, design_asset)?;
let expected_image = workflow_design_image(root, design_asset)?;
let mut state = snapshot.state;
match state.ui_design_images.get(&image_id) {
Some(current) if current != &expected_image => {
@@ -1235,7 +1233,6 @@ fn workflow_stage_rank(kind: &str) -> Option<u8> {
fn workflow_design_image(
root: &Path,
page: &UiWorkflowPageInput,
design_asset: &GameCreationAppAssetManifestEntry,
) -> Result<UIDesignImage, String> {
let absolute = resolve_local_project_path(root, &design_asset.local_path)?;
@@ -1248,12 +1245,6 @@ fn workflow_design_image(
return Err("页面设计图尺寸无效".to_string());
}
Ok(UIDesignImage {
metadata: UIDesignImageMetadata {
name: page.title.trim().to_string(),
description: page.description.trim().to_string(),
role: Some(UIDesignImageRole::Page),
slave_to: None,
},
path: design_asset.local_path.clone(),
pixel_size: Vector2::new(width as f32, height as f32),
pixels_per_unit: StrictlyPositiveFinite::new(1.0)