From 26b9ad7fad0eabb89b890785fa8bc72b9cbe224f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 9 Sep 2026 14:48:18 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=88=86=E7=A6=BB=E6=A0=91?= =?UTF-8?q?=E4=B8=8E=E9=9D=9E=E9=87=8D=E5=8F=A0=E6=89=B9=E6=AC=A1=E9=80=89?= =?UTF-8?q?=E6=8B=A9=20=E4=BF=9D=E7=95=99=E7=9C=9F=E5=AE=9E=E6=A0=B9?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=B9=B6=E8=AE=B0=E5=BD=95=20root=5Fextracta?= =?UTF-8?q?ble=20=E6=8C=89=E7=BB=88=E6=80=81=E5=8F=8D=E6=9F=A5=E5=92=8C=20?= =?UTF-8?q?DFS=20=E8=B4=AA=E5=BF=83=E7=AD=9B=E9=80=89=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=8F=B6=20=E6=8B=86=E5=87=BA=E7=88=B6=E5=AD=90=E5=8C=BA?= =?UTF-8?q?=E5=9F=9F=E6=A0=87=E8=AE=B0=E5=9B=BE=E7=94=9F=E6=88=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui_editor/commands/separation/marker.rs | 52 ++++ .../src/ui_editor/commands/separation/mod.rs | 16 +- .../ui_editor/commands/separation/model.rs | 3 +- .../commands/separation/persistence.rs | 8 +- .../src/ui_editor/commands/separation/tree.rs | 256 ++++++------------ .../ui_editor/commands/separation/workflow.rs | 193 ++----------- 6 files changed, 174 insertions(+), 354 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/marker.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/marker.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/marker.rs new file mode 100644 index 000000000..f39015389 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/marker.rs @@ -0,0 +1,52 @@ +use super::model::SeparationNode; +use base64::Engine as _; +use image::GenericImage; +use std::path::Path; +use std::path::PathBuf; + +pub async fn build_marked_image(source_url: String, nodes: Vec, target: PathBuf) -> Result { + tokio::task::spawn_blocking(move || build_marked_image_blocking(&source_url, &nodes, &target)) + .await + .map_err(|error| format!("构建标记图任务失败:{error}"))? +} + +fn build_marked_image_blocking(source_url: &str, nodes: &[SeparationNode], target: &Path) -> Result { + let encoded = source_url.split_once(',').map(|(_, d)| d).ok_or_else(|| "源图 data URL 无效".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|e| format!("解码源图失败:{e}"))?; + let mut image = image::load_from_memory(&bytes).map_err(|e| format!("读取源图失败:{e}"))?.to_rgba8(); + let width = image.width(); + let height = image.height(); + // Purple reconstruction comes first so the parent green frame remains visible on top. + for node in nodes { + for child in &node.children { + fill_rect(&mut image, child.global_pos_x_px, child.global_pos_y_px, child.width_px, child.height_px, image::Rgba([180, 0, 180, 120]), width, height); + } + } + for node in nodes { + draw_frame(&mut image, node.global_pos_x_px, node.global_pos_y_px, node.width_px, node.height_px, width, height); + } + image::DynamicImage::ImageRgba8(image.clone()).save_with_format(target, image::ImageFormat::Png).map_err(|e| format!("写入标记图失败:{e}"))?; + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| format!("编码标记图失败:{e}"))?; + Ok(format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png))) +} + +fn clipped_rect(x: u32, y: u32, w: u32, h: u32, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { + if width == 0 || height == 0 || w == 0 || h == 0 { return None; } + let x0 = x.min(width - 1); let y0 = y.min(height - 1); + let x1 = x.saturating_add(w).min(width).saturating_sub(1); + let y1 = y.saturating_add(h).min(height).saturating_sub(1); + (x0 <= x1 && y0 <= y1).then_some((x0, y0, x1, y1)) +} + +fn fill_rect(image: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, color: image::Rgba, width: u32, height: u32) { + let Some((x0, y0, x1, y1)) = clipped_rect(x, y, w, h, width, height) else { return; }; + for yy in y0..=y1 { for xx in x0..=x1 { image.put_pixel(xx, yy, color); } } +} + +fn draw_frame(image: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, width: u32, height: u32) { + let Some((x0, y0, x1, y1)) = clipped_rect(x, y, w, h, width, height) else { return; }; + let green = image::Rgba([0, 255, 0, 255]); + for xx in x0..=x1 { image.put_pixel(xx, y0, green); image.put_pixel(xx, y1, green); } + for yy in y0..=y1 { image.put_pixel(x0, yy, green); image.put_pixel(x1, yy, green); } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index 708d3c191..41f24538a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -1,4 +1,5 @@ mod model; +mod marker; mod persistence; mod prompt; mod tree; @@ -9,6 +10,7 @@ pub use persistence::*; pub use tree::*; pub use workflow::apply_batch_patch; pub(crate) use workflow::separate_ui_impl; +pub(crate) use marker::build_marked_image; #[cfg(test)] mod tests { use super::*; @@ -88,13 +90,13 @@ mod tests { ); let result = construct_separation_state(&state(root)); assert_eq!( - result.unprocessed_trees[0].root.children[0].id.as_str(), + result.trees[0].root.children[0].id.as_str(), "image" ); } #[test] - fn construction_uses_distinct_root_id_for_root_image() { + fn construction_keeps_real_root_for_root_image() { let image = Component::Image(ImageComponent { target_graphic: None, image_type: ImageType::Simple { @@ -103,9 +105,9 @@ mod tests { }); let root = node("root-image", vec![image], vec![]); let result = construct_separation_state(&state(root)); - let tree = &result.unprocessed_trees[0]; - assert_ne!(tree.root.id, tree.root.children[0].id); - assert_eq!(tree.root.children[0].id.as_str(), "root-image"); + let tree = &result.trees[0]; + assert_eq!(tree.root.id.as_str(), "root-image"); + assert!(tree.root_extractable); } #[test] fn binding_validation_requires_exact_batch_coverage() { @@ -131,7 +133,7 @@ mod tests { assert!(dir.to_string_lossy().ends_with("-separation")); } #[test] - fn patch_collects_bound_and_removes_leaf() { + fn patch_collects_bound_and_keeps_tree_topology() { let image = Component::Image(ImageComponent { target_graphic: None, image_type: ImageType::Simple { @@ -156,6 +158,6 @@ mod tests { let paths = HashMap::from([(id.clone(), "ui/.sidecar/cut.png".to_string())]); apply_batch_patch(&mut state, 0, &decisions, &paths).unwrap(); assert_eq!(state.bound[0].node_id, id); - assert!(state.unprocessed_trees[0].root.children.is_empty()); + assert_eq!(state.trees[0].root.children.len(), 1); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model.rs index 87ffc1832..a9a026b6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model.rs @@ -49,6 +49,7 @@ impl SeparationNode { pub struct SeparationTree { pub src_ui_design: UIDesignImageId, pub root: SeparationNode, + pub root_extractable: bool, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] @@ -67,7 +68,7 @@ pub struct ProblematicNode { #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct SeparationState { pub schema_version: String, - pub unprocessed_trees: Vec, + pub trees: Vec, pub bound: Vec, pub problematic_nodes: Vec, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs index 91ea757f0..e9d7c9296 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs @@ -35,7 +35,7 @@ pub fn write_separation_state(path: &Path, state: &SeparationState) -> Result<() path.file_name() .and_then(|name| name.to_str()) .unwrap_or(""), - state.unprocessed_trees.len(), + state.trees.len(), state.bound.len(), state.problematic_nodes.len() ); @@ -68,7 +68,7 @@ pub fn write_separation_state(path: &Path, state: &SeparationState) -> Result<() fs::metadata(path) .map(|metadata| metadata.len()) .unwrap_or(0), - state.unprocessed_trees.len(), + state.trees.len(), state.bound.len(), state.problematic_nodes.len() ); @@ -97,7 +97,7 @@ pub fn read_separation_state(path: &Path) -> Result { app_log!( "ui_separation.state_read.completed bytes={} trees={} bound={} problematic={}", bytes.len(), - state.unprocessed_trees.len(), + state.trees.len(), state.bound.len(), state.problematic_nodes.len() ); @@ -109,7 +109,7 @@ pub fn separation_dto(state: &SeparationState) -> SeparationDTO { "ui_separation.dto bound_nodes={} problematic_nodes={} remaining_trees={}", state.bound.len(), state.problematic_nodes.len(), - state.unprocessed_trees.len() + state.trees.len() ); SeparationDTO { bound_nodes: state.bound.clone(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs index fe8414e7f..5c84af6db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs @@ -1,31 +1,24 @@ use super::model::*; use crate::ui_editor::component::{image::ImageComponent, Component}; use crate::ui_editor::layout::node::Node; -use crate::ui_editor::state::{State, UITree}; +use crate::ui_editor::state::State; use crate::ui_editor::utils::NodeId; +use std::collections::HashSet; + fn is_unbound_image(node: &Node) -> bool { node.components.iter().any(|component| { - matches!( - component, - Component::Image(ImageComponent { - target_graphic: None, - .. - }) - ) + matches!(component, Component::Image(ImageComponent { target_graphic: None, .. })) }) } -fn node_pixel_rect( - node: &Node, - parent: &crate::ui_editor::layout::dimension::UIRect, - ppu: f32, -) -> (u32, u32, u32, u32) { +fn node_pixel_rect(node: &Node, parent: &crate::ui_editor::layout::dimension::UIRect, ppu: f32) -> (u32, u32, u32, u32) { let rect = node.layout.transform.resolve(parent); - let x = (rect.min.x * ppu).max(0.0).round() as u32; - let y = (rect.min.y * ppu).max(0.0).round() as u32; - let w = (rect.size.x * ppu).max(0.0).round() as u32; - let h = (rect.size.y * ppu).max(0.0).round() as u32; - (x, y, w, h) + ( + (rect.min.x * ppu).max(0.0).round() as u32, + (rect.min.y * ppu).max(0.0).round() as u32, + (rect.size.x * ppu).max(0.0).round() as u32, + (rect.size.y * ppu).max(0.0).round() as u32, + ) } fn node_description(node: &Node) -> String { @@ -39,31 +32,19 @@ fn node_description(node: &Node) -> String { } } -fn collect_todo_nodes( - node: &Node, - parent: &crate::ui_editor::layout::dimension::UIRect, - ppu: f32, - output: &mut Vec, -) { - let mut children = Vec::new(); +fn collect_todo_nodes(node: &Node, parent: &crate::ui_editor::layout::dimension::UIRect, ppu: f32, output: &mut Vec) { let rect = node.layout.transform.resolve(parent); + let mut children = Vec::new(); for child in &node.children { collect_todo_nodes(child, &rect, ppu, &mut children); } if is_unbound_image(node) { let (x, y, w, h) = node_pixel_rect(node, parent, ppu); output.push(SeparationNode { - id: node.id.clone(), - global_pos_x_px: x, - global_pos_y_px: y, - width_px: w, - height_px: h, - note: SeparationNote { - description: node_description(node), - text_note: String::new(), - }, - children, - rework_count: 0, + id: node.id.clone(), global_pos_x_px: x, global_pos_y_px: y, + width_px: w, height_px: h, + note: SeparationNote { description: node_description(node), text_note: String::new() }, + children, rework_count: 0, }); } else { output.extend(children); @@ -71,153 +52,82 @@ fn collect_todo_nodes( } pub fn construct_separation_state(state: &State) -> SeparationState { - app_log!( - "ui_separation.tree_construct.start ui_trees={} ui_images={}", - state.ui_trees.len(), - state.ui_design_images.len() - ); - let unprocessed_trees = state - .ui_trees - .iter() - .filter_map(|tree| { - let Some(image) = state.ui_design_images.get(&tree.src_ui_design) else { - app_log!( - "ui_separation.error stage=tree_construct reason=missing_ui_image image_id={}", - tree.src_ui_design.as_str() - ); - return None; - }; - let ppu = image.pixels_per_unit.get(); - let size = image.pixel_size / ppu; - let root_rect = - crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size); - let mut children = Vec::new(); - collect_todo_nodes(&tree.root, &root_rect, ppu, &mut children); - app_log!( - "ui_separation.tree_construct.tree image_id={} todo_nodes={} pixel_width={} pixel_height={}", - tree.src_ui_design.as_str(), - count_nodes(&children), - image.pixel_size.x.round() as u32, - image.pixel_size.y.round() as u32 - ); - (!children.is_empty()).then(|| SeparationTree { - src_ui_design: tree.src_ui_design.clone(), - root: SeparationNode { - // The synthetic root must never share an ID with a real - // UI node. A single-image design may use the original - // tree root as an eligible separation leaf. - id: NodeId::new(format!("separation-root-{}", uuid::Uuid::new_v4().simple())) - .expect("synthetic separation root id is valid"), - global_pos_x_px: 0, - global_pos_y_px: 0, - width_px: image.pixel_size.x.max(0.0).round() as u32, - height_px: image.pixel_size.y.max(0.0).round() as u32, - note: SeparationNote::default(), - children, - rework_count: 0, - }, - }) + let trees = state.ui_trees.iter().filter_map(|tree| { + let image = state.ui_design_images.get(&tree.src_ui_design)?; + let ppu = image.pixels_per_unit.get(); + let size = image.pixel_size / ppu; + let root_rect = crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size); + let mut children = Vec::new(); + for child in &tree.root.children { + collect_todo_nodes(child, &root_rect, ppu, &mut children); + } + let root_extractable = is_unbound_image(&tree.root); + if !root_extractable && children.is_empty() { return None; } + let (x, y, w, h) = node_pixel_rect(&tree.root, &root_rect, ppu); + Some(SeparationTree { + src_ui_design: tree.src_ui_design.clone(), + root: SeparationNode { + id: tree.root.id.clone(), global_pos_x_px: x, global_pos_y_px: y, + width_px: w, height_px: h, + note: SeparationNote { description: node_description(&tree.root), text_note: String::new() }, + children, rework_count: 0, + }, + root_extractable, }) - .collect::>(); - let result = SeparationState { - schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), - unprocessed_trees, - bound: Vec::new(), - problematic_nodes: Vec::new(), - }; - app_log!( - "ui_separation.tree_construct.completed trees={}", - result.unprocessed_trees.len() - ); - result + }).collect(); + SeparationState { schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), trees, bound: Vec::new(), problematic_nodes: Vec::new() } } -fn count_nodes(nodes: &[SeparationNode]) -> usize { - nodes - .iter() - .map(|node| 1 + count_nodes(&node.children)) - .sum() +fn terminal_ids(state: &SeparationState) -> HashSet { + state.bound.iter().map(|n| n.node_id.clone()) + .chain(state.problematic_nodes.iter().map(|n| n.node_id.clone())).collect() } -pub fn next_leaf_batch(tree: &SeparationTree) -> Vec<&SeparationNode> { - fn leaves<'a>(node: &'a SeparationNode, output: &mut Vec<&'a SeparationNode>) { - if node.children.is_empty() { - output.push(node); - } else { - for child in &node.children { - leaves(child, output); - } - } +fn logical_leaves<'a>(node: &'a SeparationNode, extractable: bool, terminal: &HashSet, output: &mut Vec<&'a SeparationNode>) { + let is_terminal = terminal.contains(&node.id); + let children_terminal = node.children.iter().all(|child| terminal.contains(&child.id)); + if extractable && !is_terminal && children_terminal { + output.push(node); + return; + } + for child in &node.children { + logical_leaves(child, true, terminal, output); } - let mut output = Vec::new(); - leaves(&tree.root, &mut output); - app_log!( - "ui_separation.batch_selected image_id={} leaf_nodes={}", - tree.src_ui_design.as_str(), - output.len() - ); - output } -pub fn validate_binding_response( - response: &BindingResp, - batch: &[&SeparationNode], -) -> Result<(), String> { - app_log!( - "ui_separation.binding_validate.start expected_nodes={} decisions={}", - batch.len(), - response.decisions.len() - ); - let expected = batch - .iter() - .map(|node| node.id.clone()) - .collect::>(); - let mut seen = std::collections::HashSet::new(); +fn overlaps(a: &SeparationNode, b: &SeparationNode) -> bool { + let ax1 = a.global_pos_x_px as u64 + a.width_px as u64; + let ay1 = a.global_pos_y_px as u64 + a.height_px as u64; + let bx1 = b.global_pos_x_px as u64 + b.width_px as u64; + let by1 = b.global_pos_y_px as u64 + b.height_px as u64; + let width = ax1.min(bx1).saturating_sub(a.global_pos_x_px.max(b.global_pos_x_px) as u64); + let height = ay1.min(by1).saturating_sub(a.global_pos_y_px.max(b.global_pos_y_px) as u64); + width > 0 && height > 0 +} + +pub fn next_leaf_batch<'a>(state: &SeparationState, tree: &'a SeparationTree) -> Vec<&'a SeparationNode> { + let terminal = terminal_ids(state); + let mut candidates = Vec::new(); + logical_leaves(&tree.root, tree.root_extractable, &terminal, &mut candidates); + let mut selected: Vec<&'a SeparationNode> = Vec::new(); + for candidate in candidates { + if selected.iter().all(|other| !overlaps(candidate, other)) { selected.push(candidate); } + } + app_log!("ui_separation.batch_selected image_id={} leaf_nodes={}", tree.src_ui_design.as_str(), selected.len()); + selected +} + +pub fn validate_binding_response(response: &BindingResp, batch: &[&SeparationNode]) -> Result<(), String> { + let expected = batch.iter().map(|node| node.id.clone()).collect::>(); + let mut seen = HashSet::new(); for decision in &response.decisions { - let node_id = match decision { - BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => { - to_node - } - }; - if !expected.contains(node_id) { - app_log!( - "ui_separation.error stage=binding_validate reason=unknown_node node_id={}", - node_id.as_str() - ); - return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str())); - } - if !seen.insert(node_id.clone()) { - app_log!( - "ui_separation.error stage=binding_validate reason=duplicate_node node_id={}", - node_id.as_str() - ); - return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str())); - } - if let BindingDecision::NeedRework { - problem_description, - .. - } = decision - { - if problem_description.trim().is_empty() { - app_log!( - "ui_separation.error stage=binding_validate reason=empty_problem_description node_id={}", - node_id.as_str() - ); - return Err("NeedRework 必须包含问题描述".to_string()); - } + let node_id = match decision { BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => to_node }; + if !expected.contains(node_id) { return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str())); } + if !seen.insert(node_id.clone()) { return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str())); } + if let BindingDecision::NeedRework { problem_description, .. } = decision { + if problem_description.trim().is_empty() { return Err("NeedRework 必须包含问题描述".to_string()); } } } - if seen.len() != expected.len() { - app_log!( - "ui_separation.error stage=binding_validate reason=incomplete_coverage expected={} seen={}", - expected.len(), - seen.len() - ); - return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string()); - } - app_log!( - "ui_separation.binding_validate.completed covered_nodes={}", - seen.len() - ); + if seen.len() != expected.len() { return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string()); } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs index cf03003a7..07221b0e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs @@ -29,22 +29,28 @@ pub fn apply_batch_patch( decisions.len(), cut_paths.len() ); - let tree = state - .unprocessed_trees - .get_mut(tree_index) - .ok_or_else(|| "separation tree 索引无效".to_string())?; - let batch = next_leaf_batch(tree); + let batch_nodes = { + let tree = state + .trees + .get(tree_index) + .ok_or_else(|| "separation tree 索引无效".to_string())?; + next_leaf_batch(state, tree) + .into_iter() + .cloned() + .collect::>() + }; + let batch = batch_nodes.iter().collect::>(); validate_binding_response( &BindingResp { decisions: decisions.to_vec(), }, &batch, )?; - let rework_counts = batch + let rework_counts = batch_nodes .iter() .map(|node| (node.id.clone(), node.rework_count)) .collect::>(); - let mut ids = std::collections::HashSet::new(); + let tree = state.trees.get_mut(tree_index).expect("tree index checked"); for decision in decisions { match decision { BindingDecision::Ok { to_node, .. } => { @@ -55,7 +61,6 @@ pub fn apply_batch_patch( node_id: to_node.clone(), cut_image_path: path.clone(), }); - ids.insert(to_node.clone()); } BindingDecision::NeedRework { to_node, @@ -68,18 +73,15 @@ pub fn apply_batch_patch( problem_description: problem_description.clone(), rework_count: count, }); - ids.insert(to_node.clone()); } else { increment_rework_count(&mut tree.root, to_node, count); } } } } - remove_ids(&mut tree.root, &ids); app_log!( - "ui_separation.batch_patch.completed tree_index={} removed_nodes={} bound={} problematic={} pending_root_children={}", + "ui_separation.batch_patch.completed tree_index={} bound={} problematic={} pending_root_children={}", tree_index, - ids.len(), state.bound.len(), state.problematic_nodes.len(), tree.root.children.len() @@ -87,34 +89,6 @@ pub fn apply_batch_patch( Ok(()) } -fn mark_batch_problematic( - state: &mut SeparationState, - tree_index: usize, - batch: &[&SeparationNode], - error: String, -) { - app_log!( - "ui_separation.batch_problematic tree_index={} nodes={} error={}", - tree_index, - batch.len(), - error - ); - let ids = batch - .iter() - .map(|node| node.id.clone()) - .collect::>(); - for node in batch { - state.problematic_nodes.push(ProblematicNode { - node_id: node.id.clone(), - problem_description: error.clone(), - rework_count: node.rework_count, - }); - } - if let Some(tree) = state.unprocessed_trees.get_mut(tree_index) { - remove_ids(&mut tree.root, &ids); - } -} - fn increment_rework_count(node: &mut SeparationNode, id: &NodeId, count: u32) { if node.id == *id { node.rework_count = count; @@ -222,108 +196,6 @@ async fn raw_image_edit( result } -async fn build_marked_image( - source_url: String, - nodes: Vec, - target: PathBuf, -) -> Result { - app_log!( - "ui_separation.mark_image.start nodes={} target_file={}", - nodes.len(), - target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("") - ); - tokio::task::spawn_blocking(move || { - let areas = nodes - .iter() - .map(|node| { - ( - node.global_pos_x_px, - node.global_pos_y_px, - node.width_px, - node.height_px, - ) - }) - .collect::>(); - build_marked_image_blocking(&source_url, &areas, &target) - }) - .await - .map_err(|error| format!("构建标记图任务失败:{error}"))? -} - -fn build_marked_image_blocking( - source_url: &str, - nodes: &[(u32, u32, u32, u32)], - target: &Path, -) -> Result { - let encoded = source_url - .split_once(',') - .map(|(_, d)| d) - .ok_or_else(|| "源图 data URL 无效".to_string())?; - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .map_err(|e| format!("解码源图失败:{e}"))?; - let mut image = image::load_from_memory(&bytes) - .map_err(|e| format!("读取源图失败:{e}"))? - .to_rgba8(); - let width = image.width(); - let height = image.height(); - app_log!( - "ui_separation.mark_image.decoded nodes={} width={} height={}", - nodes.len(), - width, - height - ); - for &(global_pos_x_px, global_pos_y_px, node_width_px, node_height_px) in nodes { - let x0 = global_pos_x_px.min(width.saturating_sub(1)); - let y0 = global_pos_y_px.min(height.saturating_sub(1)); - let x1 = global_pos_x_px - .saturating_add(node_width_px) - .min(width) - .saturating_sub(1); - let y1 = global_pos_y_px - .saturating_add(node_height_px) - .min(height) - .saturating_sub(1); - if x0 >= x1 || y0 >= y1 { - continue; - } - for x in x0..=x1 { - image.put_pixel(x, y0, image::Rgba([0, 255, 0, 255])); - image.put_pixel(x, y1, image::Rgba([0, 255, 0, 255])); - } - for y in y0..=y1 { - image.put_pixel(x0, y, image::Rgba([0, 255, 0, 255])); - image.put_pixel(x1, y, image::Rgba([0, 255, 0, 255])); - } - for y in y0..=y1 { - for x in x0..=x1 { - if x > x0 && x < x1 && y > y0 && y < y1 { - image.put_pixel(x, y, image::Rgba([180, 0, 180, 120])); - } - } - } - } - image::DynamicImage::ImageRgba8(image.clone()) - .save_with_format(target, image::ImageFormat::Png) - .map_err(|e| format!("写入标记图失败:{e}"))?; - let mut png = Vec::new(); - image::DynamicImage::ImageRgba8(image) - .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) - .map_err(|e| format!("编码标记图失败:{e}"))?; - let result = format!( - "data:image/png;base64,{}", - base64::engine::general_purpose::STANDARD.encode(png) - ); - app_log!( - "ui_separation.mark_image.completed data_url_chars={}", - result.len() - ); - Ok(result) -} - async fn write_processed_image(processed_url: String, target: PathBuf) -> Result<(), String> { app_log!( "ui_separation.processed_image.write.start target_file={} data_url_chars={}", @@ -503,12 +375,12 @@ pub(crate) async fn separate_ui_impl( "ui_separation.state_ready asset_id={} restored={} trees={} bound={} problematic={}", asset_id, restored, - separation.unprocessed_trees.len(), + separation.trees.len(), separation.bound.len(), separation.problematic_nodes.len() ); - for tree_index in 0..separation.unprocessed_trees.len() { - let tree = &separation.unprocessed_trees[tree_index]; + for tree_index in 0..separation.trees.len() { + let tree = &separation.trees[tree_index]; let image_id = tree.src_ui_design.clone(); let image = state .ui_design_images @@ -541,10 +413,10 @@ pub(crate) async fn separate_ui_impl( })?; let mut batch_index = 0usize; loop { - let Some(current_tree) = separation.unprocessed_trees.get(tree_index) else { + let Some(current_tree) = separation.trees.get(tree_index) else { break; }; - let batch_nodes = next_leaf_batch(current_tree) + let batch_nodes = next_leaf_batch(&separation, current_tree) .into_iter() .cloned() .collect::>(); @@ -584,10 +456,8 @@ pub(crate) async fn separate_ui_impl( tree_index, batch_index ); - mark_batch_problematic(&mut separation, tree_index, &batch, error); write_separation_state(&state_path, &separation)?; - batch_index += 1; - continue; + return Err(error); } }; let processed_url = match raw_image_edit( @@ -606,10 +476,8 @@ pub(crate) async fn separate_ui_impl( tree_index, batch_index ); - mark_batch_problematic(&mut separation, tree_index, &batch, error); write_separation_state(&state_path, &separation)?; - batch_index += 1; - continue; + return Err(error); } }; let processed_path = sidecar.join(format!("processed-{}.png", separation.bound.len())); @@ -621,10 +489,8 @@ pub(crate) async fn separate_ui_impl( tree_index, batch_index ); - mark_batch_problematic(&mut separation, tree_index, &batch, error); write_separation_state(&state_path, &separation)?; - batch_index += 1; - continue; + return Err(error); } let binding = match visual_binding(source_url.clone(), processed_url, &batch).await { Ok(value) => value, @@ -634,10 +500,8 @@ pub(crate) async fn separate_ui_impl( tree_index, batch_index ); - mark_batch_problematic(&mut separation, tree_index, &batch, error); write_separation_state(&state_path, &separation)?; - batch_index += 1; - continue; + return Err(error); } }; app_log!( @@ -686,10 +550,8 @@ pub(crate) async fn separate_ui_impl( tree_index, batch_index ); - mark_batch_problematic(&mut separation, tree_index, &batch, error); write_separation_state(&state_path, &separation)?; - batch_index += 1; - continue; + return Err(error); } apply_batch_patch(&mut separation, tree_index, &binding.decisions, &cut_paths)?; write_separation_state(&state_path, &separation)?; @@ -778,10 +640,3 @@ fn cut_processed_image_blocking( ); Ok(()) } - -fn remove_ids(node: &mut SeparationNode, ids: &std::collections::HashSet) { - node.children.retain(|child| !ids.contains(&child.id)); - for child in &mut node.children { - remove_ids(child, ids); - } -}