Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26c6bdbbd5 | |||
| 81dc7b0cd6 | |||
| e18a398cdf | |||
| d8c19d9187 | |||
| d5e06de33e | |||
| 0ab04d0f64 | |||
| f525b6dc70 | |||
| 96e7ba7808 | |||
| 65e06c4808 | |||
| 4cd5fb8d72 | |||
| 47c07f9d12 | |||
| 0904184412 | |||
| aaa2eb482a | |||
| 1679e72deb | |||
| 4844543061 | |||
| 99132c9fa7 | |||
| 944d2aa3db | |||
| 7c16a35fda | |||
| e4369cd82e | |||
| 54b9bd4354 | |||
| 365e530b5f | |||
| d46cdfb282 | |||
| 2142027f85 | |||
| 8fdf39f37e | |||
| 70c65c37d5 | |||
| e72637789c | |||
| 0a102ea1c3 | |||
| 1ab7fa9991 | |||
| 6d02a0c29f | |||
| a6c3db60fb | |||
| c921ae3b16 | |||
| 31c084fce6 | |||
| 1e7a3f17ff | |||
| 3ce2977e32 | |||
| f4e9414d63 | |||
| 2d8b51885a | |||
| 91f534ed66 | |||
| f455d3edb2 | |||
| bec225436f | |||
| ce309a3b28 | |||
| abd95a6231 | |||
| 08c3b89b67 | |||
| 3371337346 | |||
| 899a591cda | |||
| 0988c4907a | |||
| d370648867 | |||
| 4f4fb0873c | |||
| 6b8a106414 | |||
| 55a8513de4 | |||
| d986dbaeb0 | |||
| 16c6b9198e | |||
| 4df4708ba1 | |||
| 1e992bcdf8 | |||
| 8368aa262c | |||
| e9ddbf16da | |||
| 22ac1f4c0b | |||
| 9910a0eec0 | |||
| 70981b9ca9 | |||
| 8a0d5600b3 | |||
| 184d88dbb8 | |||
| 791794c0a6 | |||
| 9cfb47d945 | |||
| 27859d3e19 | |||
| 43f3780a38 | |||
| a1f9149c27 | |||
| f0bba18841 | |||
| f679cfa179 | |||
| f68ffcfecd | |||
| ec94a4fe00 | |||
| 46f8592375 | |||
| cdf2882302 | |||
| d156113e71 | |||
| a8f2ac17be | |||
| 43ac9a5761 | |||
| e992e0b35c | |||
| e389b54a3a | |||
| 84c780a6f8 | |||
| 03f900fbe5 |
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"description": "AI 游戏创作主窗口允许读写系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"core:image:allow-rgba",
|
||||
"core:image:allow-size",
|
||||
"core:resources:allow-close",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@ 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,
|
||||
strict_json_schema,
|
||||
required_tool_call_arguments, strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::component::text::FontSource;
|
||||
use crate::ui_editor::component::{Component, NodeComponent};
|
||||
@@ -403,12 +403,8 @@ pub(crate) async fn bind_components_impl_with_provider(
|
||||
.await
|
||||
}
|
||||
.map_err(|error| format!("组件绑定失败:{error}"))?;
|
||||
let call = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.find(|call| call.name == "bind_ui_components")
|
||||
.ok_or_else(|| "LLM 未返回 bind_ui_components 工具调用".to_string())?;
|
||||
let parsed = parse_binding_response(&call.arguments, editable_nodes.len())?;
|
||||
let arguments = required_tool_call_arguments(&response, "bind_ui_components")?;
|
||||
let parsed = parse_binding_response(arguments, editable_nodes.len())?;
|
||||
let known_sprite_ids = state.sprite_assets.keys().cloned().collect::<HashSet<_>>();
|
||||
let known_font_ids = state.font_assets.keys().cloned().collect::<HashSet<_>>();
|
||||
let result = validate_and_materialize(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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, request_ui_editor_llm, strict_json_schema,
|
||||
parse_limited_llm_tool_arguments, request_ui_editor_llm, required_tool_call_arguments,
|
||||
strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use platform_llm::{LlmFunctionTool, LlmMessage, LlmRunRequest, LlmToolChoice};
|
||||
@@ -113,65 +114,13 @@ 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(¤t)
|
||||
.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;
|
||||
use crate::ui_editor::layout::node::{
|
||||
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
|
||||
};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
|
||||
@@ -215,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());
|
||||
@@ -302,6 +252,7 @@ mod materialize {
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Exclusive,
|
||||
children: members.into_iter().map(|member| member.node).collect(),
|
||||
offset: NodeOffset::default(),
|
||||
},
|
||||
priority,
|
||||
src_ui_design,
|
||||
@@ -409,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
|
||||
@@ -485,15 +434,11 @@ pub(crate) async fn merge_ui_impl_with_provider(
|
||||
app_log!("ui_merge.error stage=llm_request error={error}");
|
||||
format!("UI 树合并失败:{error}")
|
||||
})?;
|
||||
let call = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.find(|call| call.name == MERGE_TOOL_NAME)
|
||||
.ok_or_else(|| {
|
||||
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call");
|
||||
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
|
||||
})?;
|
||||
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
|
||||
let arguments = required_tool_call_arguments(&response, MERGE_TOOL_NAME).map_err(|error| {
|
||||
app_log!("ui_merge.error stage=parse_tool_call reason=missing_tool_call error={error}");
|
||||
format!("LLM 未返回 {MERGE_TOOL_NAME} 工具调用")
|
||||
})?;
|
||||
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
|
||||
app_log!("ui_merge.error stage=parse_arguments error={error}");
|
||||
format!("UI 合并工具参数无效:{error}")
|
||||
})?;
|
||||
@@ -527,6 +472,7 @@ mod tests {
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
||||
@@ -548,6 +494,7 @@ mod tests {
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,17 +2,18 @@ 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,
|
||||
strict_json_schema,
|
||||
required_tool_call_arguments, strict_json_schema,
|
||||
};
|
||||
use crate::ui_editor::component::{Component, NodeComponent};
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
|
||||
use crate::ui_editor::utils::{random_node_id, UIDesignImageId};
|
||||
use nalgebra::{Point2, Vector2};
|
||||
use platform_llm::{
|
||||
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
|
||||
@@ -31,7 +32,7 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
任务:
|
||||
同时分析同一 UI 系统的全部参考图,建立UI树
|
||||
|
||||
用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构
|
||||
用户会给你一些UI截图(它们从属于同一个UI系统), 请用给定的工具描述UI结构
|
||||
|
||||
识别规则:
|
||||
* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构.
|
||||
@@ -322,6 +323,7 @@ fn convert_node(
|
||||
component: source.component.clone().into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -384,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)
|
||||
@@ -546,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 =
|
||||
@@ -589,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,
|
||||
@@ -655,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| {
|
||||
@@ -683,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)
|
||||
@@ -714,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
|
||||
),
|
||||
@@ -768,21 +729,18 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
!response.text.trim().is_empty(),
|
||||
response.tool_calls.len()
|
||||
);
|
||||
let call = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.find(|call| call.name == "recognize_ui_structure")
|
||||
.ok_or_else(|| {
|
||||
let arguments = required_tool_call_arguments(&response, "recognize_ui_structure")
|
||||
.map_err(|error| {
|
||||
app_log!(
|
||||
"ui_recognition.error stage=parse_tool_call root={} reason=missing_tool_call",
|
||||
"ui_recognition.error stage=parse_tool_call reason=missing_tool_call root={} error={error}",
|
||||
root_id.as_str()
|
||||
);
|
||||
format!(
|
||||
"根界面图 {} 的 LLM 未返回 recognize_ui_structure 工具调用",
|
||||
"LLM 未返回 recognize_ui_structure 工具调用(根界面图 {})",
|
||||
root_id.as_str()
|
||||
)
|
||||
})?;
|
||||
let arguments = parse_limited_llm_tool_arguments(&call.arguments).map_err(|error| {
|
||||
let arguments = parse_limited_llm_tool_arguments(arguments).map_err(|error| {
|
||||
app_log!(
|
||||
"ui_recognition.error stage=parse_arguments root={} error={error}",
|
||||
root_id.as_str()
|
||||
@@ -858,6 +816,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
component: recognition_root.component.into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
};
|
||||
// Tree identity and root identity are assigned by Rust, never chosen by the model.
|
||||
ui_trees.push(UITree {
|
||||
|
||||
@@ -22,6 +22,7 @@ mod tests {
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::node::Node;
|
||||
use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
|
||||
@@ -46,6 +47,7 @@ mod tests {
|
||||
component,
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
}
|
||||
}
|
||||
fn state(root: Node) -> State {
|
||||
@@ -58,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,350 +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,
|
||||
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 call = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.find(|call| call.name == "suggest_ui_design_semantics")
|
||||
.ok_or_else(|| {
|
||||
app_log!("ui_design_suggestion.error stage=parse_tool_call reason=missing_tool_call");
|
||||
"LLM 响应无效(详情:未返回 suggest_ui_design_semantics 工具调用)".to_string()
|
||||
})?;
|
||||
let arguments = parse_limited_llm_tool_arguments(&call.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);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,29 @@ pub(crate) fn parse_limited_llm_tool_arguments(
|
||||
serde_json::from_str(arguments).map_err(|error| format!("LLM 工具参数不是有效 JSON:{error}"))
|
||||
}
|
||||
|
||||
/// Stable structured-action adapter: locate the required tool call and parse its
|
||||
/// bounded JSON arguments. Prompt, schema and materialization stay in each
|
||||
/// operation-specific command.
|
||||
pub(crate) fn required_tool_arguments(
|
||||
response: &LlmRunResponse,
|
||||
tool_name: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let arguments = required_tool_call_arguments(response, tool_name)?;
|
||||
parse_limited_llm_tool_arguments(arguments)
|
||||
}
|
||||
|
||||
pub(crate) fn required_tool_call_arguments<'a>(
|
||||
response: &'a LlmRunResponse,
|
||||
tool_name: &str,
|
||||
) -> Result<&'a str, String> {
|
||||
response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.find(|call| call.name == tool_name)
|
||||
.map(|call| call.arguments.as_str())
|
||||
.ok_or_else(|| format!("LLM 未返回 {tool_name} 工具调用"))
|
||||
}
|
||||
|
||||
pub(crate) async fn read_ui_reference_image_data_url(path: PathBuf) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || read_ui_reference_image_data_url_blocking(&path))
|
||||
.await
|
||||
|
||||
@@ -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,4 +2,5 @@ pub mod children_display_mode;
|
||||
pub mod control_layout;
|
||||
pub mod dimension;
|
||||
pub mod node;
|
||||
pub mod offset;
|
||||
pub mod transform;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::utils::NodeId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -14,6 +15,7 @@ pub struct Node {
|
||||
pub component: Option<Component>,
|
||||
pub children_display_mode: ChildrenDisplayMode,
|
||||
pub children: Vec<Node>,
|
||||
pub offset: NodeOffset,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct NodeOffset {
|
||||
#[ts(as = "[f32; 2]")]
|
||||
pub min: [f32; 2],
|
||||
#[ts(as = "[f32; 2]")]
|
||||
pub max: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for NodeOffset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min: [0.0, 0.0],
|
||||
max: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -1128,18 +1088,14 @@ mod tests {
|
||||
}
|
||||
},
|
||||
"children_display_mode": "Stack",
|
||||
"children": []
|
||||
}]
|
||||
"children": [],
|
||||
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
|
||||
}],
|
||||
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
|
||||
}
|
||||
}],
|
||||
"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
|
||||
@@ -1311,17 +1267,12 @@ mod tests {
|
||||
},
|
||||
"component": null,
|
||||
"children_display_mode": "Stack",
|
||||
"children": []
|
||||
"children": [],
|
||||
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
|
||||
}
|
||||
}],
|
||||
"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
|
||||
@@ -1338,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();
|
||||
@@ -1545,17 +1457,12 @@ mod tests {
|
||||
}
|
||||
},
|
||||
"children_display_mode": "Stack",
|
||||
"children": []
|
||||
"children": [],
|
||||
"offset": { "min": [0.0, 0.0], "max": [0.0, 0.0] }
|
||||
}
|
||||
}],
|
||||
"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)
|
||||
|
||||
@@ -66,12 +66,6 @@ export async function prepareDesignImageBatch(
|
||||
scopeId,
|
||||
);
|
||||
const image: UIDesignImage = {
|
||||
metadata: {
|
||||
name: '',
|
||||
description: '',
|
||||
role: null,
|
||||
slave_to: null,
|
||||
},
|
||||
path: asset.localPath,
|
||||
pixel_size: pixelSize,
|
||||
pixels_per_unit: 1,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { State } from './types/State';
|
||||
import type { Transform } from './types/Transform';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
|
||||
export type ResizeAxis = 'horizontal' | 'vertical';
|
||||
|
||||
@@ -17,6 +19,32 @@ export type NodePageContext = {
|
||||
parentRect: PageRect;
|
||||
};
|
||||
|
||||
/** State-level geometry seam shared by preview, inspector and transitions. */
|
||||
export function findStateNodePageContext(
|
||||
state: State,
|
||||
treeId: UIDesignImageId,
|
||||
nodeId: NodeId,
|
||||
): NodePageContext | null {
|
||||
const tree = state.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
const image = state.ui_design_images[treeId];
|
||||
if (
|
||||
!tree ||
|
||||
!image ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const size: [number, number] = [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
];
|
||||
if (!size.every((value) => Number.isFinite(value) && value > 0)) return null;
|
||||
return findNodePageContext(tree.root, nodeId, pageRectFromSize(size));
|
||||
}
|
||||
|
||||
const MIN_NODE_SIZE = 1;
|
||||
|
||||
export function pageRectFromSize(size: [number, number]): PageRect {
|
||||
|
||||
@@ -38,37 +38,6 @@ export function validateComponentRecognitionPrerequisites(
|
||||
if (ids.length > 4) {
|
||||
issues.push({ code: 'design-image-limit', message: '界面图最多 4 张' });
|
||||
}
|
||||
for (const id of ids) {
|
||||
const image = state.ui_design_images[id]!;
|
||||
const { role, slave_to: slaveTo } = image.metadata;
|
||||
if (role === 'Page' && slaveTo !== null) {
|
||||
issues.push({
|
||||
code: 'page-has-slave-to',
|
||||
message: '主页面不能设置归属页面',
|
||||
resourceId: id,
|
||||
});
|
||||
} else if (role !== null && role !== 'Page') {
|
||||
if (slaveTo === null) {
|
||||
issues.push({
|
||||
code: 'missing-slave-to',
|
||||
message: '该界面角色需要选择归属主页面',
|
||||
resourceId: id,
|
||||
});
|
||||
} else if (slaveTo === id) {
|
||||
issues.push({
|
||||
code: 'self-slave-to',
|
||||
message: '界面不能归属于自身',
|
||||
resourceId: id,
|
||||
});
|
||||
} else if (state.ui_design_images[slaveTo]?.metadata.role !== 'Page') {
|
||||
issues.push({
|
||||
code: 'invalid-slave-to',
|
||||
message: '归属页面必须是有效主页面',
|
||||
resourceId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -131,24 +100,6 @@ function visitNodes(
|
||||
}
|
||||
|
||||
// 结果检查与进入步骤的前置条件保持分离;调用方只把结果作为非阻塞提示。
|
||||
export function validateReferenceAnalysisResult(
|
||||
state: State,
|
||||
): UiEditorPrerequisiteIssue[] {
|
||||
const images = Object.values(state.ui_design_images);
|
||||
if (images.length === 0) {
|
||||
return [{ code: 'missing-analysis-input', message: '尚未导入参考图' }];
|
||||
}
|
||||
if (images.every((image) => image.metadata.role === null)) {
|
||||
return [
|
||||
{
|
||||
code: 'missing-reference-analysis',
|
||||
message: '参考图尚未设置界面用途',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function validateStructureRecognitionResult(
|
||||
state: State,
|
||||
): UiEditorPrerequisiteIssue[] {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Node as UiNode } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { StageStatus } from './types/StageStatus';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
|
||||
node: UiNode;
|
||||
};
|
||||
|
||||
export type UiTreeNodeCursor = {
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
};
|
||||
|
||||
export type StageStatusOverview = {
|
||||
total: number;
|
||||
needsAttention: number;
|
||||
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
|
||||
|
||||
export function getNextUiTreeNodeTarget(
|
||||
targets: UiTreeNodeTarget[],
|
||||
previousNodeId: string | null,
|
||||
previous: string | UiTreeNodeCursor | null,
|
||||
): UiTreeNodeTarget | null {
|
||||
if (targets.length === 0) return null;
|
||||
const previousIndex = targets.findIndex(
|
||||
({ node }) => node.id === previousNodeId,
|
||||
);
|
||||
let previousIndex = -1;
|
||||
if (typeof previous === 'string') {
|
||||
previousIndex = targets.findIndex(({ node }) => node.id === previous);
|
||||
} else if (previous) {
|
||||
previousIndex = targets.findIndex(
|
||||
({ treeId, node }) =>
|
||||
treeId === previous.treeId && node.id === previous.nodeId,
|
||||
);
|
||||
}
|
||||
return targets[(previousIndex + 1) % targets.length] ?? null;
|
||||
}
|
||||
|
||||
export function getNextMatchingUiTreeNodeTarget(
|
||||
uiTrees: UITree[],
|
||||
previousNodeId: string | null,
|
||||
previous: string | UiTreeNodeCursor | null,
|
||||
matches: (target: UiTreeNodeTarget) => boolean,
|
||||
): UiTreeNodeTarget | null {
|
||||
return getNextUiTreeNodeTarget(
|
||||
collectUiTreeNodeTargets(uiTrees).filter(matches),
|
||||
previousNodeId,
|
||||
previous,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Node } from './types/Node';
|
||||
import type { State } from './types/State';
|
||||
|
||||
export type UiDesignInvariantIssue = {
|
||||
code: 'duplicate-node' | 'invalid-image' | 'missing-tree-image';
|
||||
message: string;
|
||||
};
|
||||
|
||||
/** Frontend display/save projection of persistable State invariants.
|
||||
* Rust remains authoritative; this seam only prevents obviously invalid saves
|
||||
* and gives the view a stable, user-visible failure message.
|
||||
*/
|
||||
export function validateUiDesignState(state: State): UiDesignInvariantIssue[] {
|
||||
const issues: UiDesignInvariantIssue[] = [];
|
||||
const nodeIds = new Set<string>();
|
||||
for (const [id, image] of Object.entries(state.ui_design_images)) {
|
||||
if (
|
||||
image.path.trim() === '' ||
|
||||
!image.pixel_size.every((value) => Number.isFinite(value) && value > 0) ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
issues.push({
|
||||
code: 'invalid-image',
|
||||
message: `界面图 ${id} 的尺寸或路径无效`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const visit = (node: Node) => {
|
||||
if (nodeIds.has(node.id)) {
|
||||
issues.push({
|
||||
code: 'duplicate-node',
|
||||
message: `节点 ID 重复:${node.id}`,
|
||||
});
|
||||
}
|
||||
nodeIds.add(node.id);
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
for (const tree of state.ui_trees) {
|
||||
if (!state.ui_design_images[tree.src_ui_design]) {
|
||||
issues.push({
|
||||
code: 'missing-tree-image',
|
||||
message: `UI 树引用了缺失界面图:${tree.src_ui_design}`,
|
||||
});
|
||||
}
|
||||
visit(tree.root);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function firstUiDesignInvariantMessage(state: State): string | null {
|
||||
return validateUiDesignState(state)[0]?.message ?? null;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Component } from './types/Component';
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { State } from './types/State';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
|
||||
export type StateTransitionFailure =
|
||||
| 'missing'
|
||||
| 'invalid'
|
||||
| `invalid:${string}`;
|
||||
|
||||
export type StateTransitionResult =
|
||||
| { ok: true; state: State }
|
||||
| { ok: false; reason: StateTransitionFailure };
|
||||
|
||||
export type UiEditorCommand =
|
||||
| {
|
||||
type: 'set-tree-offset';
|
||||
treeId: UIDesignImageId;
|
||||
min: [number, number];
|
||||
}
|
||||
| {
|
||||
type: 'set-node-metadata';
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
patch: Partial<
|
||||
Pick<
|
||||
NodeMetadata,
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'layout_status'
|
||||
| 'component_status'
|
||||
| 'allow_llm_edit_layout'
|
||||
| 'allow_llm_edit_component'
|
||||
>
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: 'set-node-component';
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
component: Component | null;
|
||||
};
|
||||
|
||||
function cloneState(state: State): State {
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
function findNode(node: Node, id: NodeId): Node | null {
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children) {
|
||||
const found = findNode(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function imageLogicalSize(
|
||||
state: State,
|
||||
treeId: UIDesignImageId,
|
||||
): [number, number] | null {
|
||||
const image = state.ui_design_images[treeId];
|
||||
if (
|
||||
!image ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const size: [number, number] = [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
];
|
||||
return size.every((value) => Number.isFinite(value) && value > 0)
|
||||
? size
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* React-free semantic State transition seam. The hook is an adapter that adds
|
||||
* locking/history; callers provide a command and receive a complete next State
|
||||
* or a typed failure, never a partially-mutated tree.
|
||||
*/
|
||||
export function applyUiEditorCommand(
|
||||
current: State,
|
||||
command: UiEditorCommand,
|
||||
): StateTransitionResult {
|
||||
const next = cloneState(current);
|
||||
const tree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === command.treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
|
||||
if (command.type === 'set-tree-offset') {
|
||||
if (!command.min.every(Number.isFinite))
|
||||
return { ok: false, reason: 'invalid' };
|
||||
const size = imageLogicalSize(next, command.treeId);
|
||||
if (!size) return { ok: false, reason: 'invalid' };
|
||||
tree.root.offset = {
|
||||
min: [...command.min],
|
||||
max: [command.min[0] + size[0], command.min[1] + size[1]],
|
||||
};
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
const node = findNode(tree.root, command.nodeId);
|
||||
if (!node) return { ok: false, reason: 'missing' };
|
||||
|
||||
if (command.type === 'set-node-component') {
|
||||
node.component = structuredClone(command.component);
|
||||
node.metadata.component_status = 'NoProblem';
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
|
||||
if (command.patch.component_status !== undefined && node.component === null) {
|
||||
const status = command.patch.component_status;
|
||||
if (status !== 'NoProblem') return { ok: false, reason: 'invalid' };
|
||||
}
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(command.patch).filter(([, value]) => value !== undefined),
|
||||
) as Partial<NodeMetadata>;
|
||||
node.metadata = { ...node.metadata, ...structuredClone(patch) };
|
||||
return { ok: true, state: next };
|
||||
}
|
||||
@@ -4,5 +4,6 @@ import type { Component } from "./Component";
|
||||
import type { ControlLayout } from "./ControlLayout";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { NodeMetadata } from "./NodeMetadata";
|
||||
import type { NodeOffset } from "./NodeOffset";
|
||||
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NodeOffset = { min: [number, number], max: [number, number], };
|
||||
@@ -1,4 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { UIDesignImageMetadata } from "./UIDesignImageMetadata";
|
||||
|
||||
export type UIDesignImage = { metadata: UIDesignImageMetadata, path: string, pixel_size: [number, number], pixels_per_unit: number, };
|
||||
export type UIDesignImage = { path: string, pixel_size: [number, number], pixels_per_unit: number, };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user