新增自动切分结果落 State 的镜像步骤

- Node 新增 find_mut,深度优先可变定位节点,镜像前端 findUiNodeLocation
- 新增 design_doc/steps/separate.rs:素材合并去重与资源校验、按 bound_nodes 回填 target_graphic、统一写 problematic_nodes 的 NeedReview
- 覆盖重复素材、缺失素材、节点缺失、非 Image 组件、已绑定其他素材与问题文案回退的单测
This commit is contained in:
2026-09-23 19:51:16 +08:00
parent b1a356aa0d
commit 3c4c546ed5
3 changed files with 424 additions and 0 deletions
@@ -1 +1,2 @@
pub(crate) mod recognize;
pub(crate) mod separate;
@@ -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<State, String> {
let mut unique: HashMap<SpriteAssetId, &SpriteAsset> = 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<String, SpriteAsset>,
) -> Vec<String> {
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<String> {
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::<Vec<_>>()
.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<String, SpriteAsset>,
errors: &mut Vec<String>,
) {
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<f32>) -> 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<String, SpriteAsset> {
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 次)");
}
}
@@ -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 {