新增自动切分结果落 State 的镜像步骤
- Node 新增 find_mut,深度优先可变定位节点,镜像前端 findUiNodeLocation - 新增 design_doc/steps/separate.rs:素材合并去重与资源校验、按 bound_nodes 回填 target_graphic、统一写 problematic_nodes 的 NeedReview - 覆盖重复素材、缺失素材、节点缺失、非 Image 组件、已绑定其他素材与问题文案回退的单测
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user