diff --git a/src/commands/config_cmd.rs b/src/commands/config_cmd.rs index ed73ca84..10f40ba1 100644 --- a/src/commands/config_cmd.rs +++ b/src/commands/config_cmd.rs @@ -1965,14 +1965,24 @@ pub fn cmd_config(ctx: &RunContext, action: ConfigCmd) -> Result<()> { ConfigCmd::Layout { file, page, + only_new, save_as, } => { let data = fs::read(&file).with_context(|| format!("Cannot read {}", file))?; let mut editor = ConfigEditor::load(&data)?; let page_sel = page.as_deref().unwrap_or("Type:Page"); - let count = editor.grid_layout(page_sel)?; - println!("✓ Laid out {} elements", count); + if only_new { + let count = editor.incremental_layout(page_sel)?; + if count == 0 { + println!("✓ No unpositioned blocks — nothing to place"); + } else { + println!("✓ Placed {} new block(s), existing layout untouched", count); + } + } else { + let count = editor.grid_layout(page_sel)?; + println!("✓ Laid out {} elements", count); + } save_edited(&editor, &file, save_as.as_deref())?; } diff --git a/src/config_edit/layout.rs b/src/config_edit/layout.rs index 41869552..9ef7367f 100644 --- a/src/config_edit/layout.rs +++ b/src/config_edit/layout.rs @@ -315,4 +315,268 @@ impl ConfigEditor { Ok(count) } + + /// Snap a coordinate to the 96-unit Loxone editor grid. + fn snap96(v: i32) -> i32 { + ((v as f64 / 96.0).round() as i32) * 96 + } + + /// Place ONLY blocks that have no canvas position yet (`Px` unset), leaving every + /// already-positioned block untouched. Uses a Sugiyama-style layered layout so the + /// new blocks flow left→right along their wiring, snapped to the 96-grid, anchored + /// in the free area to the right of the page's existing content. + /// + /// This is the incremental counterpart to `grid_layout` (which re-arranges the whole + /// page). Returns the number of blocks positioned. + pub fn incremental_layout(&mut self, page_selector: &str) -> Result { + use std::collections::{HashMap, HashSet}; + + let page_paths = self.find_elements(page_selector); + let page_path = page_paths + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No page found matching '{}'", page_selector))?; + + // --- Pass 1: read-only harvest of every block on the page --- + struct Node { + idx: usize, + uuid: String, + btype: String, + has_px: bool, + ins: Vec, // source connector UUIDs this block consumes + } + let mut nodes: Vec = Vec::new(); + let mut conn_owner: HashMap = HashMap::new(); // connector UUID -> block UUID + // existing bounding box (only positioned blocks) + let mut max_px2 = i32::MIN; + let mut min_py = i32::MAX; + + let page = self.get_element(&page_path); + for (i, child) in page.children.iter().enumerate() { + let elem = match child.as_element() { + Some(e) if e.name == "C" => e, + _ => continue, + }; + let uuid = match elem.attributes.get("U") { + Some(u) => u.clone(), + None => continue, + }; + let btype = elem.attributes.get("Type").cloned().unwrap_or_default(); + if btype.is_empty() { + continue; + } + let has_px = elem.attributes.contains_key("Px"); + if has_px { + if let Some(px2) = elem.attributes.get("Px2").and_then(|v| v.parse::().ok()) { + max_px2 = max_px2.max(px2); + } else if let Some(px) = elem.attributes.get("Px").and_then(|v| v.parse::().ok()) { + max_px2 = max_px2.max(px); + } + if let Some(py) = elem.attributes.get("Py").and_then(|v| v.parse::().ok()) { + min_py = min_py.min(py); + } + } + let mut ins = Vec::new(); + for co in &elem.children { + if let Some(co_elem) = co.as_element() + && co_elem.name == "Co" + { + if let Some(cu) = co_elem.attributes.get("U") { + conn_owner.insert(cu.clone(), uuid.clone()); + } + for inp in &co_elem.children { + if let Some(in_elem) = inp.as_element() + && in_elem.name == "In" + && let Some(src) = in_elem.attributes.get("Input") + { + ins.push(src.clone()); + } + } + } + } + nodes.push(Node { idx: i, uuid, btype, has_px, ins }); + } + + // indices (into `nodes`) of the blocks we must place + let new_ids: Vec = (0..nodes.len()).filter(|&n| !nodes[n].has_px).collect(); + if new_ids.is_empty() { + return Ok(0); + } + let uuid_to_node: HashMap = + nodes.iter().enumerate().map(|(n, nd)| (nd.uuid.clone(), n)).collect(); + let new_set: HashSet = new_ids.iter().copied().collect(); + + // predecessors among NEW blocks only: which new nodes feed node n + let preds = |n: usize| -> Vec { + let mut out = Vec::new(); + for src in &nodes[n].ins { + if let Some(owner) = conn_owner.get(src) + && let Some(&pn) = uuid_to_node.get(owner) + && pn != n + && new_set.contains(&pn) + { + out.push(pn); + } + } + out + }; + + // --- layer assignment: longest path over the new-block sub-DAG --- + let mut layer: HashMap = HashMap::new(); + fn calc( + n: usize, + preds: &dyn Fn(usize) -> Vec, + layer: &mut HashMap, + seen: &mut Vec, + ) -> i32 { + if let Some(&l) = layer.get(&n) { + return l; + } + if seen.contains(&n) { + return 0; // cycle guard + } + seen.push(n); + let ps = preds(n); + let l = if ps.is_empty() { + 0 + } else { + 1 + ps.iter().map(|&p| calc(p, preds, layer, seen)).max().unwrap_or(0) + }; + seen.pop(); + layer.insert(n, l); + l + } + for &n in &new_ids { + let mut seen = Vec::new(); + calc(n, &preds, &mut layer, &mut seen); + } + + // group by layer, deterministic base order = document order + let mut by_layer: HashMap> = HashMap::new(); + for &n in &new_ids { + by_layer.entry(layer[&n]).or_default().push(n); + } + let mut layers: Vec = by_layer.keys().copied().collect(); + layers.sort_unstable(); + + // --- ordering within a layer: barycenter of predecessor rows --- + let mut row: HashMap = HashMap::new(); + for &l in &layers { + let mut lst = by_layer[&l].clone(); + if l > 0 { + lst.sort_by(|&a, &b| { + let bary = |n: usize| -> f64 { + let rs: Vec = preds(n) + .iter() + .filter_map(|p| row.get(p).map(|&r| r as f64)) + .collect(); + if rs.is_empty() { 0.0 } else { rs.iter().sum::() / rs.len() as f64 } + }; + bary(a).partial_cmp(&bary(b)).unwrap_or(std::cmp::Ordering::Equal) + }); + } + for (r, &n) in lst.iter().enumerate() { + row.insert(n, r); + } + by_layer.insert(l, lst); + } + + // --- coordinates --- + // base: free area to the right of existing content (fallback to editor origin) + let base_x = if max_px2 == i32::MIN { 576 } else { Self::snap96(max_px2 + 576) }; + let base_y = if min_py == i32::MAX { 576 } else { Self::snap96(min_py) }; + const COL_STEP: i32 = 2688; // widest block + gap (28 * 96) + const ROW_STEP: i32 = 960; // 10 * 96 + + // resolve target coordinates per new node + let mut targets: Vec<(usize, i32, i32, i32, i32)> = Vec::new(); // (child idx, Px, Py, Px2, Py2) + for &l in &layers { + for &n in &by_layer[&l] { + let (w, h) = block_size(&nodes[n].btype); + let px = Self::snap96(base_x + l * COL_STEP); + let py = Self::snap96(base_y + row[&n] as i32 * ROW_STEP); + targets.push((nodes[n].idx, px, py, px + w, py + h)); + } + } + + // --- Pass 2: apply (mutable, by child index) --- + let page = self.get_element_mut(&page_path); + let mut count = 0; + for (idx, px, py, px2, py2) in targets { + if let Some(elem) = page.children[idx].as_mut_element() { + elem.attributes.insert("Px".to_string(), px.to_string()); + elem.attributes.insert("Py".to_string(), py.to_string()); + elem.attributes.insert("Px2".to_string(), px2.to_string()); + elem.attributes.insert("Py2".to_string(), py2.to_string()); + count += 1; + } + } + Ok(count) + } + +} + +#[cfg(test)] +mod incremental_tests { + use super::super::ConfigEditor; + + // Src(positioned) --Q--> Not A(new) --Q--> And B(new) + // incremental_layout must place A and B in left→right layers and leave Src untouched. + const XML: &str = "\n\ +\n\ +\t\n\ +\t\t\n\ +\t\n\ +\t\n\ +\t\t\n\ +\t\t\n\ +\t\n\ +\t\n\ +\t\t\n\ +\t\t\n\ +\t\n\ +\n"; + + fn px_of(xml: &str, uuid: &str) -> Option { + // crude: find `U="uuid"` then the following `Px="..."` within the same tag + let key = format!("U=\"{uuid}\""); + let start = xml.find(&key)?; + let tag_end = xml[start..].find('>')? + start; + let seg = &xml[start..tag_end]; + let p = seg.find("Px=\"")? + 4; + let end = seg[p..].find('"')? + p; + seg[p..end].parse().ok() + } + + #[test] + fn incremental_places_only_new_in_layers() { + let mut editor = ConfigEditor::load(XML.as_bytes()).unwrap(); + let count = editor.incremental_layout("Type:Page").unwrap(); + assert_eq!(count, 2, "only the two unpositioned blocks are placed"); + + let out = String::from_utf8(editor.to_bytes().unwrap()).unwrap(); + // existing positioned block is untouched + assert_eq!(px_of(&out, "src"), Some(1000), "Src Px must not change"); + // A and B are now positioned + let ax = px_of(&out, "a").expect("A positioned"); + let bx = px_of(&out, "b").expect("B positioned"); + // B (fed by A) sits in a later layer → strictly further right + assert!(bx > ax, "B (layer 1) must be right of A (layer 0): ax={ax} bx={bx}"); + // grid-snapped + assert_eq!(ax % 96, 0, "A.Px snapped to 96-grid"); + assert_eq!(bx % 96, 0, "B.Px snapped to 96-grid"); + } + + #[test] + fn incremental_noop_when_all_positioned() { + // strip the two unpositioned gates → nothing to place + let only_src = "\n\ +\n\ +\t\n\ +\t\t\n\ +\t\n\ +\n"; + let mut editor = ConfigEditor::load(only_src.as_bytes()).unwrap(); + assert_eq!(editor.incremental_layout("Type:Page").unwrap(), 0); + } } diff --git a/src/main.rs b/src/main.rs index e00c2ce6..4d4bddb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -637,13 +637,18 @@ pub(crate) enum ConfigCmd { #[arg(long)] strict: bool, }, - /// Auto-arrange blocks on a Page using ELK layout engine + /// Auto-arrange blocks on a Page. Default re-arranges the whole page; with + /// `--only-new` it positions only blocks that have no coordinates yet and leaves + /// existing blocks untouched (layered layout along the wiring). #[command(name = "layout")] Layout { file: String, /// Page selector (default: first Page) #[arg(long)] page: Option, + /// Only place blocks without a canvas position; keep positioned blocks as-is + #[arg(long)] + only_new: bool, #[arg(long)] save_as: Option, }, diff --git "a/tests/eval/golden/configs/Netzgeb\303\274hr reduziert.Loxone" "b/tests/eval/golden/configs/Netzgeb\303\274hr reduziert.Loxone" new file mode 100644 index 00000000..a07ac576 --- /dev/null +++ "b/tests/eval/golden/configs/Netzgeb\303\274hr reduziert.Loxone" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03341421380509d56e0cd441b3bc112103cfaf622bab5db50a24ab6974f53842 +size 96257