From 3c4c546ed5c32089b671f6afe8e9cbaea591c0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 19:51:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=8A=A8=E5=88=87?= =?UTF-8?q?=E5=88=86=E7=BB=93=E6=9E=9C=E8=90=BD=20State=20=E7=9A=84?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Node 新增 find_mut,深度优先可变定位节点,镜像前端 findUiNodeLocation - 新增 design_doc/steps/separate.rs:素材合并去重与资源校验、按 bound_nodes 回填 target_graphic、统一写 problematic_nodes 的 NeedReview - 覆盖重复素材、缺失素材、节点缺失、非 Image 组件、已绑定其他素材与问题文案回退的单测 --- .../src/ui_editor/design_doc/steps/mod.rs | 1 + .../ui_editor/design_doc/steps/separate.rs | 411 ++++++++++++++++++ .../src-tauri/src/ui_editor/layout/node.rs | 12 + 3 files changed, 424 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/separate.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/mod.rs index e12827440..7ef20d8a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/mod.rs @@ -1 +1,2 @@ pub(crate) mod recognize; +pub(crate) mod separate; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/separate.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/separate.rs new file mode 100644 index 000000000..9ba1a3aeb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/design_doc/steps/separate.rs @@ -0,0 +1,411 @@ +//! 自动切分结果落到 State。镜像前端 `useUiEditorPage.ts` 的切分回填循环、 +//! `features/ui-editor/useUiEditorState.ts` 的 `addSpriteAssetsToState` 与 +//! `features/ui-editor/separationStatus.ts` 的问题状态写回。 +//! +//! 切分图片的登记不是这里的事:调用方先按 `bound_nodes` 的路径登记资源并构造 +//! `SpriteAsset`,本模块只做纯 State 变换。 + +use crate::ui_editor::commands::separation::{BoundNode, ProblematicNode}; +use crate::ui_editor::commands::SeparationDTO; +use crate::ui_editor::component::Component; +use crate::ui_editor::layout::node::StageStatus; +use crate::ui_editor::resource::sprite::SpriteAsset; +use crate::ui_editor::state::State; +use crate::ui_editor::utils::SpriteAssetId; +use std::collections::HashMap; + +/// 把切分产出的素材合入 State。同一 id 已有不同内容时报错,镜像 +/// `addSpriteAssetsToState` 的 `duplicate` 与资源校验。 +pub(crate) fn add_sprite_assets(state: &State, sprites: &[SpriteAsset]) -> Result { + let mut unique: HashMap = HashMap::new(); + for sprite in sprites { + let asset_id = sprite.asset_id().clone(); + let existing = unique + .get(&asset_id) + .copied() + .or_else(|| state.sprite_assets.get(&asset_id)); + if let Some(existing) = existing { + if existing != sprite { + return Err(format!("素材 {} 已存在且内容不同", asset_id.as_str())); + } + } + unique.insert(asset_id, sprite); + } + for sprite in unique.values() { + if let Err(reason) = sprite_asset_error(sprite) { + return Err(format!("{}:{reason}", sprite.asset_id().as_str())); + } + } + let mut next = state.clone(); + for sprite in unique.values() { + next.sprite_assets + .insert(sprite.asset_id().clone(), (*sprite).clone()); + } + Ok(next) +} + +/// 回填切分结果:先按 `bound_nodes` 顺序写 `target_graphic` 并清 +/// `component_status`,再统一写 `problematic_nodes` 的 `NeedReview`。返回值是需要 +/// 人工处理的说明,镜像前端 `backfillErrors`。`state` 必须已经含本次素材。 +pub(crate) fn apply_separation( + state: &mut State, + dto: &SeparationDTO, + sprite_by_path: &HashMap, +) -> Vec { + let mut errors = Vec::new(); + for bound in &dto.bound_nodes { + backfill_bound_node(state, bound, sprite_by_path, &mut errors); + } + errors.extend(apply_problematic_statuses(state, &dto.problematic_nodes)); + errors +} + +/// `problematic_nodes` 的统一回写:节点存在且带 Image 组件时写 `NeedReview`, +/// 否则只保留问题记录并回报。 +pub(crate) fn apply_problematic_statuses( + state: &mut State, + problematic_nodes: &[ProblematicNode], +) -> Vec { + let mut errors = Vec::new(); + for problematic in problematic_nodes { + let node_id = problematic.node_id.clone(); + let Some(node) = state + .ui_trees + .iter_mut() + .find_map(|tree| tree.root.find_mut(&node_id)) + else { + errors.push(format!( + "问题节点 {} 已不存在,已保留问题记录", + node_id.as_str() + )); + continue; + }; + if !matches!(node.component, Some(Component::Image(_))) { + errors.push(format!( + "问题节点 {} 不是可处理的 Image 组件,已保留问题记录", + node_id.as_str() + )); + continue; + } + node.metadata.component_status = + StageStatus::NeedReview(separation_problem_reason(problematic)); + } + errors +} + +/// `NeedReview` 文案,镜像 `separationStatus.ts` 的 `separationProblemReason`。 +pub(crate) fn separation_problem_reason(problematic: &ProblematicNode) -> String { + let history = problematic + .problem_history + .iter() + .filter(|item| !item.trim().is_empty()) + .cloned() + .collect::>() + .join("\n"); + let details = if history.is_empty() { + problematic.problem_description.clone() + } else { + history + }; + let head = format!("自动切分重试已达上限({} 次)", problematic.rework_count); + if details.is_empty() { + head + } else { + format!("{head}\n{details}") + } +} + +fn backfill_bound_node( + state: &mut State, + bound: &BoundNode, + sprite_by_path: &HashMap, + errors: &mut Vec, +) { + let path = normalize_cut_path(&bound.cut_image_path); + let Some(sprite) = sprite_by_path.get(&path) else { + errors.push(format!( + "节点 {} 缺少已登记的自动切分素材图片:{path}", + bound.node_id.as_str() + )); + return; + }; + let node_id = bound.node_id.clone(); + let Some(node) = state + .ui_trees + .iter_mut() + .find_map(|tree| tree.root.find_mut(&node_id)) + else { + errors.push(format!("节点 {} 已不存在,素材已保留", node_id.as_str())); + return; + }; + let cleared = match node.component.as_mut() { + Some(Component::Image(component)) => match component.target_graphic.as_ref() { + Some(target) if target == sprite.asset_id() => true, + Some(_) => { + errors.push(format!( + "节点 {} 已绑定其他素材,自动切分素材已保留", + node_id.as_str() + )); + false + } + None => { + component.target_graphic = Some(sprite.asset_id().clone()); + true + } + }, + _ => { + errors.push(format!( + "节点 {} 没有可回填的 Image 组件,素材已保留", + node_id.as_str() + )); + false + } + }; + if cleared { + node.metadata.component_status = StageStatus::NoProblem; + } +} + +/// 镜像前端 `normalizeProjectRelativePath`:只做分隔符与根斜杠归一,不做路径校验。 +fn normalize_cut_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches('/').to_string() +} + +fn sprite_asset_error(sprite: &SpriteAsset) -> Result<(), String> { + if sprite.asset_id().as_str().trim().is_empty() { + return Err("缺少素材 ID".to_string()); + } + if sprite.path().trim().is_empty() { + return Err("缺少素材路径".to_string()); + } + let size = sprite.pixel_size(); + if !size.x.is_finite() || !size.y.is_finite() || size.x <= 0.0 || size.y <= 0.0 { + return Err(format!("素材尺寸无效:{}", format_pixel_size(size))); + } + let pixels_per_unit = sprite.pixels_per_unit().get(); + if !pixels_per_unit.is_finite() || pixels_per_unit <= 0.0 { + return Err(format!("像素单位无效:{pixels_per_unit}")); + } + validate_sprite_border(sprite) +} + +/// 镜像 `spriteBorder.ts` 的 `validateSpriteBorder`;Rust 侧边距是 `u32`, +/// 非负整数与上界由类型本身保证,只剩中心至少留 1 px 的检查。 +fn validate_sprite_border(sprite: &SpriteAsset) -> Result<(), String> { + let border = sprite.border(); + if !border.has_border() { + return Ok(()); + } + let size = sprite.pixel_size(); + if !size.x.is_finite() || !size.y.is_finite() || size.x <= 0.0 || size.y <= 0.0 { + return Err("素材尺寸无效".to_string()); + } + let horizontal = f64::from(border.left()) + f64::from(border.right()) + 1.0; + if horizontal > f64::from(size.x.floor()) { + return Err("水平中心至少保留 1 px".to_string()); + } + let vertical = f64::from(border.top()) + f64::from(border.bottom()) + 1.0; + if vertical > f64::from(size.y.floor()) { + return Err("垂直中心至少保留 1 px".to_string()); + } + Ok(()) +} + +fn format_pixel_size(size: nalgebra::Vector2) -> String { + format!("[{}, {}]", size.x, size.y) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_editor::commands::separation::BoundNode; + use crate::ui_editor::design_doc::test_support::{image_node, sprite, state_with, tree}; + use crate::ui_editor::utils::SpriteAssetId; + + fn sprite_by_path(sprites: &[SpriteAsset]) -> HashMap { + sprites + .iter() + .map(|sprite| (sprite.path().to_string(), sprite.clone())) + .collect() + } + + fn bound(node_id: &str, cut_path: &str) -> BoundNode { + BoundNode { + node_id: crate::ui_editor::utils::NodeId::new(node_id.to_string()).expect("node id"), + cut_image_path: cut_path.to_string(), + } + } + + fn problematic(node_id: &str, history: &[&str], description: &str) -> ProblematicNode { + ProblematicNode { + node_id: crate::ui_editor::utils::NodeId::new(node_id.to_string()).expect("node id"), + problem_description: description.to_string(), + problem_history: history.iter().map(|item| item.to_string()).collect(), + rework_count: 3, + } + } + + fn state_with_image_node(target_graphic: Option<&str>) -> State { + let mut state = state_with(&[("page-a", 100.0, 50.0)]); + state + .ui_trees + .push(tree("page-a", image_node("node-a", target_graphic))); + state + } + + #[test] + fn sprite_assets_merge_and_duplicates_must_match() { + let state = state_with(&[]); + let merged = add_sprite_assets(&state, &[sprite("cut-1", "assets/cut-1.png")]) + .expect("merge sprite"); + assert!(merged + .sprite_assets + .contains_key(&SpriteAssetId::new("cut-1".to_string()).expect("sprite id"))); + // 同 id 同内容可以重复合并。 + let again = add_sprite_assets(&merged, &[sprite("cut-1", "assets/cut-1.png")]) + .expect("merge identical sprite"); + assert_eq!(again.sprite_assets.len(), 1); + // 同 id 不同内容报错。 + let error = add_sprite_assets(&merged, &[sprite("cut-1", "assets/other.png")]) + .expect_err("different sprite content must fail"); + assert!(error.contains("cut-1")); + } + + #[test] + fn bound_node_without_registered_sprite_keeps_error() { + let mut state = state_with_image_node(None); + let errors = apply_separation( + &mut state, + &SeparationDTO { + bound_nodes: vec![bound("node-a", "assets/cut-1.png")], + problematic_nodes: Vec::new(), + }, + &HashMap::new(), + ); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("缺少已登记的自动切分素材图片")); + } + + #[test] + fn missing_node_and_non_image_component_keep_the_sprite() { + let sprite = sprite("cut-1", "assets/cut-1.png"); + let mut state = state_with(&[("page-a", 100.0, 50.0)]); + state.ui_trees.push(tree( + "page-a", + crate::ui_editor::design_doc::test_support::node("node-b"), + )); + let errors = apply_separation( + &mut state, + &SeparationDTO { + bound_nodes: vec![ + bound("node-x", "assets/cut-1.png"), + bound("node-b", "assets/cut-1.png"), + ], + problematic_nodes: Vec::new(), + }, + &sprite_by_path(&[sprite]), + ); + assert_eq!( + errors, + vec![ + "节点 node-x 已不存在,素材已保留".to_string(), + "节点 node-b 没有可回填的 Image 组件,素材已保留".to_string(), + ] + ); + } + + #[test] + fn backfill_binds_free_graphic_and_clears_status() { + let mut state = state_with_image_node(None); + state.ui_trees[0].root.metadata.component_status = StageStatus::NeedReview("旧问题".into()); + let errors = apply_separation( + &mut state, + &SeparationDTO { + bound_nodes: vec![bound("node-a", "assets/cut-1.png")], + problematic_nodes: Vec::new(), + }, + &sprite_by_path(&[sprite("cut-1", "assets/cut-1.png")]), + ); + assert!(errors.is_empty()); + let node = &state.ui_trees[0].root; + assert_eq!( + node.component + .as_ref() + .and_then(|component| match component { + Component::Image(image) => image.target_graphic.clone(), + Component::Text(_) => None, + }) + .expect("target graphic") + .as_str(), + "cut-1" + ); + assert_eq!(node.metadata.component_status, StageStatus::NoProblem); + } + + #[test] + fn already_bound_graphic_keeps_other_sprite_and_reports() { + let mut state = state_with_image_node(Some("cut-2")); + let errors = apply_separation( + &mut state, + &SeparationDTO { + bound_nodes: vec![bound("node-a", "assets/cut-1.png")], + problematic_nodes: Vec::new(), + }, + &sprite_by_path(&[ + sprite("cut-1", "assets/cut-1.png"), + sprite("cut-2", "assets/cut-2.png"), + ]), + ); + assert_eq!( + errors, + vec!["节点 node-a 已绑定其他素材,自动切分素材已保留".to_string()] + ); + } + + #[test] + fn same_graphic_only_clears_status() { + let mut state = state_with_image_node(Some("cut-1")); + state.ui_trees[0].root.metadata.component_status = StageStatus::NeedReview("旧问题".into()); + let errors = apply_separation( + &mut state, + &SeparationDTO { + bound_nodes: vec![bound("node-a", "assets/cut-1.png")], + problematic_nodes: Vec::new(), + }, + &sprite_by_path(&[sprite("cut-1", "assets/cut-1.png")]), + ); + assert!(errors.is_empty()); + assert_eq!( + state.ui_trees[0].root.metadata.component_status, + StageStatus::NoProblem + ); + } + + #[test] + fn problematic_nodes_write_review_status_or_report() { + let mut state = state_with_image_node(None); + let errors = apply_problematic_statuses( + &mut state, + &[ + problematic("node-a", &["第一次", " ", "第二次"], "缺描述"), + problematic("node-x", &[], "缺描述"), + ], + ); + assert_eq!( + errors, + vec!["问题节点 node-x 已不存在,已保留问题记录".to_string()] + ); + assert_eq!( + state.ui_trees[0].root.metadata.component_status, + StageStatus::NeedReview("自动切分重试已达上限(3 次)\n第一次\n第二次".to_string()) + ); + } + + #[test] + fn problem_reason_falls_back_to_description() { + let reason = separation_problem_reason(&problematic("node-a", &[" "], "切分失败")); + assert_eq!(reason, "自动切分重试已达上限(3 次)\n切分失败"); + let bare = separation_problem_reason(&problematic("node-a", &[], "")); + assert_eq!(bare, "自动切分重试已达上限(3 次)"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index 49e499138..ae93e2bd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -18,6 +18,18 @@ pub struct Node { pub offset: NodeOffset, } +impl Node { + /// 深度优先查找节点,用可变引用定位,镜像前端 `findUiNodeLocation` 的定位部分。 + pub fn find_mut(&mut self, node_id: &NodeId) -> Option<&mut Node> { + if &self.id == node_id { + return Some(self); + } + self.children + .iter_mut() + .find_map(|child| child.find_mut(node_id)) + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub enum NodeSource {