diff --git a/Cargo.lock b/Cargo.lock index a5e4228..9cf02a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,7 @@ checksum = "bdba45c0f7a85ef0ec0bfb766b387dbd90fc6141237ac74b559d08a424750bc6" [[package]] name = "stack-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "roxmltree", "serde", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "stack-engine-wasm" -version = "0.8.0" +version = "0.9.0" dependencies = [ "js-sys", "serde", @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "stack-theme" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b3f19b223133b8da80e0a10c4cf5a7ec3024fb48105c1f9f2e0d635a9e8d56" +checksum = "3ebdb1422bcb8498be09c97722702a67a44b1d3d555d34c92c6e3b3e58cc1786" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8102d15..102d94b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,4 @@ serde_json = "=1.0.151" sha2 = "=0.11.0" stack-compiler = "=0.1.0" stack-formatter = { path = "crates/stack-formatter", version = "=0.1.0" } -stack-theme = "=0.6.0" +stack-theme = "=0.7.0" diff --git a/README.md b/README.md index 7282810..9fe4176 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Both packages provide format, check, render, completion, and hover operations ov ## Rust quick start ```sh -cargo add stack-engine@0.8.0 +cargo add stack-engine@0.9.0 ``` ```rust @@ -43,7 +43,7 @@ The crate supports Rust 1.85 or newer. ## Browser quick start ```sh -npm install @stack-sh/engine@0.8.0 +npm install @stack-sh/engine@0.9.0 ``` ```js @@ -86,7 +86,7 @@ Filesystem behavior, process exit codes, user authentication, billing, entitleme ## Architecture -The design records cover the [pure engine facade](./docs/decisions/0002-use-a-pure-versioned-engine-facade.md), [deterministic layout](./docs/decisions/0003-use-integer-ranked-scene-layout.md), [orthogonal routing](./docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md), [safe SVG](./docs/decisions/0005-serialize-safe-standalone-svg.md), [browser adapter](./docs/decisions/0006-expose-one-typed-browser-wasm-adapter.md), [language intelligence](./docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md), and [label-aware graph composition](./docs/decisions/0008-compose-graphs-with-reserved-label-geometry.md). +The design records cover the [pure engine facade](./docs/decisions/0002-use-a-pure-versioned-engine-facade.md), [deterministic layout](./docs/decisions/0003-use-integer-ranked-scene-layout.md), [orthogonal routing](./docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md), [safe SVG](./docs/decisions/0005-serialize-safe-standalone-svg.md), [browser adapter](./docs/decisions/0006-expose-one-typed-browser-wasm-adapter.md), [language intelligence](./docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md), [label-aware graph composition](./docs/decisions/0008-compose-graphs-with-reserved-label-geometry.md), and [distributed terminals and lanes](./docs/decisions/0009-distribute-terminals-and-routing-lanes.md). See [CONTRIBUTING.md](./CONTRIBUTING.md) for repository setup, quality gates, the reviewed layout corpus, and release verification. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 179d1f5..9d44c95 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -5,7 +5,7 @@ | Component | Revision | License | Source | Notes | | --- | --- | --- | --- | --- | | `stack-compiler` | `0.1.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | -| `stack-theme` | `0.6.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `stack-theme` | `0.7.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses caller-owned processed provider SVG before allowlisted in-memory embedding. | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Verifies provider asset hashes and computes deterministic provider-pack revisions. | | `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | MIT OR Apache-2.0 | , | Target-specific SHA-256 acceleration support. | diff --git a/crates/stack-engine-wasm/Cargo.toml b/crates/stack-engine-wasm/Cargo.toml index 8c84271..32c0d80 100644 --- a/crates/stack-engine-wasm/Cargo.toml +++ b/crates/stack-engine-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine-wasm" -version = "0.8.0" +version = "0.9.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/stack-engine/Cargo.toml b/crates/stack-engine/Cargo.toml index 1297d15..b148608 100644 --- a/crates/stack-engine/Cargo.toml +++ b/crates/stack-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine" -version = "0.8.0" +version = "0.9.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/stack-engine/README.md b/crates/stack-engine/README.md index 618e058..1c15813 100644 --- a/crates/stack-engine/README.md +++ b/crates/stack-engine/README.md @@ -4,7 +4,7 @@ Pure Rust layout and SVG rendering for Stack diagrams. The engine reuses the reg ```toml [dependencies] -stack-engine = "=0.8.0" +stack-engine = "=0.9.0" ``` Rust 1.85 or newer is supported. See the [API documentation](https://docs.rs/stack-engine) and [repository documentation](https://github.com/stack-sh/engine) for rendering, limits, and compatibility. The browser adapter is distributed separately as `@stack-sh/engine` on npm; this crate is the native library. diff --git a/crates/stack-engine/THIRD_PARTY_LICENSES.md b/crates/stack-engine/THIRD_PARTY_LICENSES.md index 179d1f5..9d44c95 100644 --- a/crates/stack-engine/THIRD_PARTY_LICENSES.md +++ b/crates/stack-engine/THIRD_PARTY_LICENSES.md @@ -5,7 +5,7 @@ | Component | Revision | License | Source | Notes | | --- | --- | --- | --- | --- | | `stack-compiler` | `0.1.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | -| `stack-theme` | `0.6.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `stack-theme` | `0.7.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses caller-owned processed provider SVG before allowlisted in-memory embedding. | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Verifies provider asset hashes and computes deterministic provider-pack revisions. | | `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | MIT OR Apache-2.0 | , | Target-specific SHA-256 acceleration support. | diff --git a/crates/stack-engine/src/labels.rs b/crates/stack-engine/src/labels.rs index 6629679..6fbf50c 100644 --- a/crates/stack-engine/src/labels.rs +++ b/crates/stack-engine/src/labels.rs @@ -498,6 +498,7 @@ mod tests { width: 100_000, height: 62_000, }, + offset_port_sides: [true; 4], }]; let titles = [Rect { x: 0, @@ -709,6 +710,7 @@ mod tests { width: 150_000, height: 37_000, }, + offset_port_sides: [true; 4], }]; let titles = [Rect { x: 0, diff --git a/crates/stack-engine/src/layout_congestion.rs b/crates/stack-engine/src/layout_congestion.rs new file mode 100644 index 0000000..400dc71 --- /dev/null +++ b/crates/stack-engine/src/layout_congestion.rs @@ -0,0 +1,344 @@ +//! Regression metrics for concentrated terminals and routing lanes. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fs; +use std::path::Path; + +use crate::Engine; +use crate::routing::{Marker, Point, SceneEdge}; +use crate::scene::{self, Scene}; + +const SOURCE: &str = include_str!("../tests/fixtures/sample-service-congestion.stack"); + +#[derive(Debug, PartialEq, Eq)] +struct CongestionMetrics { + reused_terminal_points: usize, + proper_crossings: usize, + crossing_pairs: Vec, + ambiguous_junctions: usize, + junction_pairs: Vec, + shared_length_milli_px: i64, + close_parallel_length_milli_px: i64, +} + +fn sample_scene() -> Result> { + let compiled = stack_compiler::compile_bytes(SOURCE.as_bytes()); + if !compiled.diagnostics.is_empty() { + return Err(format!("fixture produced diagnostics: {:?}", compiled.diagnostics).into()); + } + let diagram = compiled.diagram.ok_or("fixture produced no diagram")?; + Ok(scene::layout(&diagram, stack_theme::catalog())?) +} + +fn overlap_length(left_start: i64, left_end: i64, right_start: i64, right_end: i64) -> i64 { + (left_start.max(left_end).min(right_start.max(right_end)) + - left_start.min(left_end).max(right_start.min(right_end))) + .max(0) +} + +fn segment_projection_overlap(left: [Point; 2], right: [Point; 2]) -> i64 { + if left[0].y == left[1].y && right[0].y == right[1].y { + overlap_length(left[0].x, left[1].x, right[0].x, right[1].x).max(0) + } else if left[0].x == left[1].x && right[0].x == right[1].x { + overlap_length(left[0].y, left[1].y, right[0].y, right[1].y).max(0) + } else { + 0 + } +} + +fn proper_crossing(left: [Point; 2], right: [Point; 2]) -> bool { + let Some(point) = orthogonal_intersection(left, right) else { + return false; + }; + let (horizontal, vertical) = if left[0].y == left[1].y && right[0].x == right[1].x { + (left, right) + } else if right[0].y == right[1].y && left[0].x == left[1].x { + (right, left) + } else { + return false; + }; + point.x > horizontal[0].x.min(horizontal[1].x) + && point.x < horizontal[0].x.max(horizontal[1].x) + && point.y > vertical[0].y.min(vertical[1].y) + && point.y < vertical[0].y.max(vertical[1].y) +} + +fn orthogonal_intersection(left: [Point; 2], right: [Point; 2]) -> Option { + let (horizontal, vertical) = if left[0].y == left[1].y && right[0].x == right[1].x { + (left, right) + } else if right[0].y == right[1].y && left[0].x == left[1].x { + (right, left) + } else { + return None; + }; + let point = Point { + x: vertical[0].x, + y: horizontal[0].y, + }; + ((horizontal[0].x.min(horizontal[1].x)..=horizontal[0].x.max(horizontal[1].x)) + .contains(&point.x) + && (vertical[0].y.min(vertical[1].y)..=vertical[0].y.max(vertical[1].y)).contains(&point.y)) + .then_some(point) +} + +fn congestion_metrics(edges: &[SceneEdge]) -> CongestionMetrics { + let mut terminals = BTreeMap::::new(); + for edge in edges { + if let Some(point) = edge.path.first() { + *terminals.entry(*point).or_default() += 1; + } + if let Some(point) = edge.path.last() { + *terminals.entry(*point).or_default() += 1; + } + } + let reused_terminal_points = terminals.values().filter(|uses| **uses > 1).count(); + + let mut proper_crossings = 0; + let mut crossing_pairs = Vec::new(); + let mut junctions = BTreeSet::new(); + let mut shared_length_milli_px = 0; + let mut close_parallel_length_milli_px = 0; + for left_index in 0..edges.len() { + for right_index in left_index + 1..edges.len() { + for left in edges[left_index].path.windows(2) { + for right in edges[right_index].path.windows(2) { + let left = [left[0], left[1]]; + let right = [right[0], right[1]]; + if proper_crossing(left, right) { + proper_crossings += 1; + crossing_pairs.push(format!( + "{} -> {} crosses {} -> {}", + edges[left_index].from, + edges[left_index].to, + edges[right_index].from, + edges[right_index].to + )); + } else if let Some(point) = orthogonal_intersection(left, right) { + junctions.insert((left_index, right_index, point)); + } + let overlap = segment_projection_overlap(left, right); + if overlap == 0 { + continue; + } + let separation = if left[0].y == left[1].y && right[0].y == right[1].y { + (left[0].y - right[0].y).abs() + } else if left[0].x == left[1].x && right[0].x == right[1].x { + (left[0].x - right[0].x).abs() + } else { + continue; + }; + if separation == 0 { + shared_length_milli_px += overlap; + } else if separation < 16_000 { + close_parallel_length_milli_px += overlap; + } + } + } + } + } + + let junction_pairs = junctions + .iter() + .map(|(left_index, right_index, point)| { + format!( + "{} -> {} joins {} -> {} at ({}, {})", + edges[*left_index].from, + edges[*left_index].to, + edges[*right_index].from, + edges[*right_index].to, + point.x, + point.y + ) + }) + .collect::>(); + + CongestionMetrics { + reused_terminal_points, + proper_crossings, + crossing_pairs, + ambiguous_junctions: junctions.len(), + junction_pairs, + shared_length_milli_px, + close_parallel_length_milli_px, + } +} + +fn write_candidate_svg() -> Result<(), Box> { + let output = Engine::bundled().render(SOURCE.as_bytes())?; + if !output.diagnostics.is_empty() { + return Err(format!("fixture produced diagnostics: {:?}", output.diagnostics).into()); + } + let output_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("target/layout-congestion"); + fs::create_dir_all(&output_root)?; + fs::write( + output_root.join("sample-service.svg"), + output.svg.ok_or("fixture produced no SVG")?, + )?; + Ok(()) +} + +#[test] +fn sample_service_avoids_terminal_and_lane_congestion() -> Result<(), Box> { + let scene = sample_scene()?; + let metrics = congestion_metrics(&scene.edges); + write_candidate_svg()?; + + assert!(scene.geometry_is_valid()); + assert_eq!(metrics.proper_crossings, 0, "{metrics:#?}"); + assert_eq!(metrics.ambiguous_junctions, 0, "{metrics:#?}"); + assert_eq!(metrics.reused_terminal_points, 0, "{metrics:#?}"); + assert_eq!(metrics.shared_length_milli_px, 0, "{metrics:#?}"); + assert_eq!(metrics.close_parallel_length_milli_px, 0, "{metrics:#?}"); + Ok(()) +} + +#[test] +fn interval_overlap_is_orientation_independent_and_excludes_touching() { + for (left, right, expected) in [ + ((0, 10), (5, 15), 5), + ((10, 0), (5, 15), 5), + ((0, 10), (15, 5), 5), + ((10, 0), (15, 5), 5), + ((0, 20), (5, 15), 10), + ((0, 10), (10, 20), 0), + ((0, 10), (11, 20), 0), + ] { + assert_eq!(overlap_length(left.0, left.1, right.0, right.1), expected); + } +} + +#[test] +fn intersection_metric_distinguishes_crossings_junctions_and_disjoint_segments() { + let horizontal = [Point { x: 0, y: 10 }, Point { x: 20, y: 10 }]; + let crossing = [Point { x: 10, y: 0 }, Point { x: 10, y: 20 }]; + let tee = [Point { x: 20, y: 0 }, Point { x: 20, y: 20 }]; + let elbow = [Point { x: 20, y: 10 }, Point { x: 20, y: 30 }]; + let disjoint = [Point { x: 30, y: 0 }, Point { x: 30, y: 20 }]; + + assert!(proper_crossing(horizontal, crossing)); + assert!(proper_crossing(crossing, horizontal)); + assert_eq!( + orthogonal_intersection(horizontal, crossing), + Some(Point { x: 10, y: 10 }) + ); + assert!(!proper_crossing(horizontal, tee)); + assert_eq!( + orthogonal_intersection(horizontal, tee), + Some(Point { x: 20, y: 10 }) + ); + assert!(!proper_crossing(horizontal, elbow)); + assert_eq!( + orthogonal_intersection(horizontal, elbow), + Some(Point { x: 20, y: 10 }) + ); + assert_eq!(orthogonal_intersection(horizontal, disjoint), None); +} + +#[test] +fn shared_segment_metric_is_orientation_independent() { + let forward = [Point { x: 0, y: 10 }, Point { x: 20, y: 10 }]; + let reverse = [Point { x: 15, y: 10 }, Point { x: 5, y: 10 }]; + let separated = [Point { x: 5, y: 20 }, Point { x: 15, y: 20 }]; + + assert_eq!(segment_projection_overlap(forward, reverse), 10); + assert_eq!(segment_projection_overlap(forward, separated), 10); +} + +fn metric_edge(from: &str, to: &str, path: Vec) -> SceneEdge { + SceneEdge { + from: from.to_owned(), + to: to.to_owned(), + direction: stack_compiler::ir::EdgeDirection::Forward, + kind: stack_compiler::ir::EdgeKind::Flow, + label: None, + path, + start_marker: Marker::None, + end_marker: Marker::Arrow, + label_anchor: None, + label_rect: None, + } +} + +#[test] +fn congestion_metrics_report_each_independent_failure_signal() { + let edges = [ + metric_edge( + "a", + "b", + vec![Point { x: 0, y: 0 }, Point { x: 20_000, y: 0 }], + ), + metric_edge( + "c", + "d", + vec![ + Point { + x: 10_000, + y: -10_000, + }, + Point { + x: 10_000, + y: 10_000, + }, + ], + ), + metric_edge( + "e", + "f", + vec![ + Point { + x: 20_000, + y: -10_000, + }, + Point { x: 20_000, y: 0 }, + ], + ), + metric_edge( + "g", + "h", + vec![Point { x: 5_000, y: 0 }, Point { x: 15_000, y: 0 }], + ), + metric_edge( + "i", + "j", + vec![ + Point { + x: 5_000, + y: 10_000, + }, + Point { + x: 15_000, + y: 10_000, + }, + ], + ), + ]; + let metrics = congestion_metrics(&edges); + + assert!(metrics.reused_terminal_points > 0, "{metrics:#?}"); + assert!(metrics.proper_crossings > 0, "{metrics:#?}"); + assert!(!metrics.crossing_pairs.is_empty(), "{metrics:#?}"); + assert!(metrics.ambiguous_junctions > 0, "{metrics:#?}"); + assert!(!metrics.junction_pairs.is_empty(), "{metrics:#?}"); + assert!(metrics.shared_length_milli_px > 0, "{metrics:#?}"); + assert!(metrics.close_parallel_length_milli_px > 0, "{metrics:#?}"); +} + +#[test] +fn congestion_metrics_accept_an_edge_without_routed_points() { + let metrics = congestion_metrics(&[metric_edge("missing", "route", Vec::new())]); + assert_eq!( + metrics, + CongestionMetrics { + reused_terminal_points: 0, + proper_crossings: 0, + crossing_pairs: Vec::new(), + ambiguous_junctions: 0, + junction_pairs: Vec::new(), + shared_length_milli_px: 0, + close_parallel_length_milli_px: 0, + } + ); +} diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index fdc286e..f5f0074 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -33,6 +33,9 @@ mod svg; #[cfg(test)] mod layout_quality; +#[cfg(test)] +mod layout_congestion; + #[cfg(test)] mod placement_quality; @@ -686,7 +689,7 @@ mod tests { output.metadata.language_version, Some(LanguageVersion { major: 1, minor: 0 }) ); - assert_eq!(output.metadata.theme_catalog_version, "0.6.0"); + assert_eq!(output.metadata.theme_catalog_version, "0.7.0"); assert_eq!( output.metadata.theme_catalog_revision, stack_theme::CATALOG_REVISION @@ -730,10 +733,10 @@ mod tests { ("ai", "Artificial intelligence system"), ]; let catalog = stack_theme::catalog(); - assert_eq!(catalog.catalog_version, "0.6.0"); + assert_eq!(catalog.catalog_version, "0.7.0"); assert_eq!( stack_theme::CATALOG_REVISION, - "sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1" + "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a" ); for theme in &catalog.themes { for (identifier, subject) in expected_icons { @@ -752,7 +755,7 @@ mod tests { let rendered = Engine::bundled().render(source)?; assert!(checked.diagnostics.is_empty()); assert!(rendered.diagnostics.is_empty()); - assert_eq!(rendered.metadata.theme_catalog_version, "0.6.0"); + assert_eq!(rendered.metadata.theme_catalog_version, "0.7.0"); assert_eq!( rendered.metadata.theme_catalog_revision, stack_theme::CATALOG_REVISION diff --git a/crates/stack-engine/src/placement_quality.rs b/crates/stack-engine/src/placement_quality.rs index 6e1e0be..dde2653 100644 --- a/crates/stack-engine/src/placement_quality.rs +++ b/crates/stack-engine/src/placement_quality.rs @@ -67,6 +67,13 @@ fn assert_progresses(before: Rect, after: Rect, direction: SceneDirection) { ); } +fn cross_axis_center(rect: Rect, direction: SceneDirection) -> i64 { + match direction { + SceneDirection::Right => 2 * rect.y + rect.height, + SceneDirection::Down => 2 * rect.x + rect.width, + } +} + fn assert_reordered_dag_progresses(direction: SceneDirection) -> Result<(), Box> { let authored_direction = match direction { SceneDirection::Right => "right", @@ -111,6 +118,110 @@ fn down_directed_dag_progresses_by_connections_instead_of_declaration_order() assert_reordered_dag_progresses(SceneDirection::Down) } +fn assert_external_neighbor_alignment(direction: SceneDirection) -> Result<(), Box> { + let authored_direction = match direction { + SceneDirection::Right => "right", + SceneDirection::Down => "down", + }; + let source = format!( + r#"stack 1.0 +diagram "External neighbor alignment" {{ + layout {{ direction {authored_direction} }} + group outer "Outer" {{ + layout {{ direction {authored_direction} }} + node first-source "First source" + node second-source "Second source" + group boundary "Boundary" {{ + layout {{ + direction {authored_direction} + rank same [second-target, unanchored, first-target] + }} + node second-target "Second target" + node unanchored "Unanchored" + node first-target "First target" + }} + }} + edge first-source -> first-target + edge second-source -> second-target +}}"# + ); + let scene = scene_from(&source)?; + let first_source = cross_axis_center(node_rect(&scene, "first-source")?, direction); + let second_source = cross_axis_center(node_rect(&scene, "second-source")?, direction); + let first_target = cross_axis_center(node_rect(&scene, "first-target")?, direction); + let unanchored = cross_axis_center(node_rect(&scene, "unanchored")?, direction); + let second_target = cross_axis_center(node_rect(&scene, "second-target")?, direction); + + assert!( + first_source < second_source, + "sources: {first_source}, {second_source}" + ); + assert!( + first_target < unanchored, + "targets: first={first_target}, unanchored={unanchored}, second={second_target}" + ); + assert!( + unanchored < second_target, + "targets: first={first_target}, unanchored={unanchored}, second={second_target}" + ); + assert!(scene.geometry_is_valid()); + Ok(()) +} + +#[test] +fn down_layout_aligns_rank_members_with_external_neighbors_without_moving_unanchored_slots() +-> Result<(), Box> { + assert_external_neighbor_alignment(SceneDirection::Down) +} + +#[test] +fn right_layout_aligns_rank_members_with_external_neighbors_without_moving_unanchored_slots() +-> Result<(), Box> { + assert_external_neighbor_alignment(SceneDirection::Right) +} + +#[test] +fn explicit_cross_axis_order_overrides_external_neighbor_alignment() -> Result<(), Box> { + let scene = scene_from( + r#"stack 1.0 +diagram "Authored order" { + layout { direction down } + group outer "Outer" { + layout { direction down } + node first-source "First source" + node second-source "Second source" + group boundary "Boundary" { + layout { + direction down + rank same [second-target, unanchored, first-target] + order [second-target, unanchored, first-target] + } + node second-target "Second target" + node unanchored "Unanchored" + node first-target "First target" + } + } + edge first-source -> first-target + edge second-source -> second-target +}"#, + )?; + let second = cross_axis_center(node_rect(&scene, "second-target")?, SceneDirection::Down); + let unanchored = cross_axis_center(node_rect(&scene, "unanchored")?, SceneDirection::Down); + let first = cross_axis_center(node_rect(&scene, "first-target")?, SceneDirection::Down); + + assert!( + second < unanchored, + "second={second}, unanchored={unanchored}, first={first}" + ); + assert!( + unanchored < first, + "second={second}, unanchored={unanchored}, first={first}" + ); + assert!(scene.unsatisfied_orders.is_empty()); + assert!(scene.geometry_is_valid()); + Ok(()) +} + #[test] fn graph_ranking_preserves_mandatory_same_rank_and_authored_cross_axis_order() -> Result<(), Box> { diff --git a/crates/stack-engine/src/routing.rs b/crates/stack-engine/src/routing.rs index 16f9fc6..a97c284 100644 --- a/crates/stack-engine/src/routing.rs +++ b/crates/stack-engine/src/routing.rs @@ -1,7 +1,7 @@ //! Deterministic orthogonal edge routing for the internal scene. use std::cmp::Reverse; -use std::collections::BinaryHeap; +use std::collections::{BTreeMap, BinaryHeap}; use stack_compiler::ir::{Edge, EdgeDirection, EdgeKind}; @@ -11,6 +11,9 @@ const ROUTE_MARGIN: i64 = 8_000; const BEND_PENALTY: i64 = 32_000; const CROSSING_PENALTY: i64 = 48_000; const SHARED_LENGTH_PENALTY: i64 = 3; +const PORT_REUSE_PENALTY: i64 = 96_000; +const OFF_CENTER_PORT_PENALTY: i64 = 2_000; +const ALTERNATIVE_PORT_PAIR_LIMIT: usize = 32; const FRAME_CLEARANCE: i64 = 16_000; // Reserve two extra pixels for the core connector and frame stroke radii. // The independent SVG gate uses the actual painted stroke widths. @@ -52,31 +55,43 @@ pub(crate) fn route( fixed_obstacles: &[Rect], frames: &[Rect], ) -> Result, RoutingError> { - let mut router = GridRouter::new(nodes, bounds, fixed_obstacles, frames); + let mut router = GridRouter::new_for_edges(nodes, bounds, fixed_obstacles, frames, edges); edges .iter() .map(|edge| { let source = node_rect(nodes, &edge.from).ok_or(RoutingError)?; let target = node_rect(nodes, &edge.to).ok_or(RoutingError)?; let path = router.route(source, target).ok_or(RoutingError)?; - let (start_marker, end_marker) = markers(edge.direction); - let label_anchor = edge.label.as_ref().map(|_| path_midpoint(&path)); - Ok(SceneEdge { - from: edge.from.clone(), - to: edge.to.clone(), - direction: edge.direction, - kind: edge.kind, - label: edge.label.clone(), - path, - start_marker, - end_marker, - label_anchor, - label_rect: None, - }) + Ok(scene_edge(edge, path)) }) .collect() } +pub(crate) fn route_next( + edge: &Edge, + context_edges: &[Edge], + reserved_edges: &[SceneEdge], + nodes: &[SceneNode], + bounds: Rect, + fixed_obstacles: &[Rect], + frames: &[Rect], +) -> Result { + let source = node_rect(nodes, &edge.from).ok_or(RoutingError)?; + let target = node_rect(nodes, &edge.to).ok_or(RoutingError)?; + let mut router = GridRouter::new_for_edge( + nodes, + bounds, + fixed_obstacles, + frames, + context_edges, + edge, + reserved_edges, + ); + router.reserve_edges(reserved_edges); + let path = router.route(source, target).ok_or(RoutingError)?; + Ok(scene_edge(edge, path)) +} + pub(crate) fn geometry_is_valid( edges: &[SceneEdge], nodes: &[SceneNode], @@ -141,55 +156,120 @@ pub(crate) fn geometry_is_valid( }) } +#[cfg(test)] pub(crate) fn alternative_routes( edge: &Edge, nodes: &[SceneNode], bounds: Rect, fixed_obstacles: &[Rect], frames: &[Rect], +) -> Result, RoutingError> { + alternative_routes_with_context( + edge, + std::slice::from_ref(edge), + &[], + nodes, + bounds, + fixed_obstacles, + frames, + ) +} + +pub(crate) fn alternative_routes_with_context( + edge: &Edge, + context_edges: &[Edge], + reserved_edges: &[SceneEdge], + nodes: &[SceneNode], + bounds: Rect, + fixed_obstacles: &[Rect], + frames: &[Rect], ) -> Result, RoutingError> { let source = node_rect(nodes, &edge.from).ok_or(RoutingError)?; let target = node_rect(nodes, &edge.to).ok_or(RoutingError)?; - let mut router = GridRouter::new(nodes, bounds, fixed_obstacles, frames); - let mut paths = Vec::new(); - for source_port in 0..4 { - for target_port in 0..4 { - if let Some(path) = - router.route_between(source, target, Some((source_port, target_port))) - { - paths.push(path); + let mut router = GridRouter::new_for_edge( + nodes, + bounds, + fixed_obstacles, + frames, + context_edges, + edge, + reserved_edges, + ); + router.reserve_edges(reserved_edges); + let source_stubs = terminal_stubs(source); + let target_stubs = terminal_stubs(target); + let mut port_pairs = Vec::new(); + for (source_index, (source_port, _, _, source_preference)) in source_stubs.iter().enumerate() { + if !router.port_is_enabled(source, source_index) { + continue; + } + for (target_index, (target_port, _, _, target_preference)) in + target_stubs.iter().enumerate() + { + if !router.port_is_enabled(target, target_index) { + continue; } + let reuse = i64::from( + router.used_ports.get(source_port).copied().unwrap_or(0) + + router.used_ports.get(target_port).copied().unwrap_or(0), + ) * PORT_REUSE_PENALTY; + port_pairs.push(( + *source_preference + + *target_preference + + reuse + + manhattan(*source_port, *target_port), + source_index, + target_index, + )); } } - paths.sort_by_cached_key(|path| { - let length = path - .windows(2) - .map(|segment| manhattan(segment[0], segment[1])) - .sum::(); - ( - length + path.len().saturating_sub(2) as i64 * BEND_PENALTY, - path.clone(), - ) - }); - paths.dedup(); - let (start_marker, end_marker) = markers(edge.direction); + port_pairs.sort(); + let (midpoint_pairs, offset_pairs): (Vec<_>, Vec<_>) = port_pairs + .into_iter() + .partition(|(_, source_port, target_port)| source_port % 3 == 0 && target_port % 3 == 0); + let mut paths = Vec::new(); + for (_, source_port, target_port) in midpoint_pairs { + if let Some((cost, path)) = + router.route_between_with_cost(source, target, Some((source_port, target_port))) + { + paths.push((cost, path)); + } + } + for (_, source_port, target_port) in offset_pairs { + if paths.len() >= ALTERNATIVE_PORT_PAIR_LIMIT { + break; + } + if let Some((cost, path)) = + router.route_between_with_cost(source, target, Some((source_port, target_port))) + { + paths.push((cost, path)); + } + } + paths.sort(); + paths.dedup_by(|left, right| left.1 == right.1); Ok(paths .into_iter() - .map(|path| SceneEdge { - from: edge.from.clone(), - to: edge.to.clone(), - direction: edge.direction, - kind: edge.kind, - label: edge.label.clone(), - label_anchor: edge.label.as_ref().map(|_| path_midpoint(&path)), - label_rect: None, - path, - start_marker, - end_marker, - }) + .map(|(_, path)| scene_edge(edge, path)) .collect()) } +fn scene_edge(edge: &Edge, path: Vec) -> SceneEdge { + let (start_marker, end_marker) = markers(edge.direction); + let label_anchor = edge.label.as_ref().map(|_| path_midpoint(&path)); + SceneEdge { + from: edge.from.clone(), + to: edge.to.clone(), + direction: edge.direction, + kind: edge.kind, + label: edge.label.clone(), + path, + start_marker, + end_marker, + label_anchor, + label_rect: None, + } +} + fn node_rect(nodes: &[SceneNode], identifier: &str) -> Option { nodes .iter() @@ -205,28 +285,70 @@ fn markers(direction: EdgeDirection) -> (Marker, Marker) { } } -fn ports(rect: Rect) -> [Point; 4] { +fn ports(rect: Rect) -> [Point; 12] { + let horizontal = [ + rect.x + rect.width / 2, + rect.x + rect.width / 4, + rect.x + 3 * rect.width / 4, + ]; + let vertical = [ + rect.y + rect.height / 2, + rect.y + rect.height / 4, + rect.y + 3 * rect.height / 4, + ]; [ Point { x: rect.x + rect.width, - y: rect.y + rect.height / 2, + y: vertical[0], + }, + Point { + x: rect.x + rect.width, + y: vertical[1], + }, + Point { + x: rect.x + rect.width, + y: vertical[2], + }, + Point { + x: horizontal[0], + y: rect.y + rect.height, + }, + Point { + x: horizontal[1], + y: rect.y + rect.height, }, Point { - x: rect.x + rect.width / 2, + x: horizontal[2], y: rect.y + rect.height, }, Point { x: rect.x, - y: rect.y + rect.height / 2, + y: vertical[0], }, Point { - x: rect.x + rect.width / 2, + x: rect.x, + y: vertical[1], + }, + Point { + x: rect.x, + y: vertical[2], + }, + Point { + x: horizontal[0], + y: rect.y, + }, + Point { + x: horizontal[1], + y: rect.y, + }, + Point { + x: horizontal[2], y: rect.y, }, ] } -fn terminal_stubs(rect: Rect) -> [(Point, Point, usize); 4] { +fn terminal_stubs(rect: Rect) -> [(Point, Point, usize, i64); 12] { let ports = ports(rect); [ ( @@ -236,30 +358,106 @@ fn terminal_stubs(rect: Rect) -> [(Point, Point, usize); 4] { y: ports[0].y, }, 1, + 0, ), ( ports[1], Point { - x: ports[1].x, - y: ports[1].y + ROUTE_MARGIN, + x: ports[1].x + ROUTE_MARGIN, + y: ports[1].y, }, - 2, + 1, + OFF_CENTER_PORT_PENALTY, ), ( ports[2], Point { - x: ports[2].x - ROUTE_MARGIN, + x: ports[2].x + ROUTE_MARGIN, y: ports[2].y, }, 1, + OFF_CENTER_PORT_PENALTY, ), ( ports[3], Point { x: ports[3].x, - y: ports[3].y - ROUTE_MARGIN, + y: ports[3].y + ROUTE_MARGIN, + }, + 2, + 0, + ), + ( + ports[4], + Point { + x: ports[4].x, + y: ports[4].y + ROUTE_MARGIN, + }, + 2, + OFF_CENTER_PORT_PENALTY, + ), + ( + ports[5], + Point { + x: ports[5].x, + y: ports[5].y + ROUTE_MARGIN, + }, + 2, + OFF_CENTER_PORT_PENALTY, + ), + ( + ports[6], + Point { + x: ports[6].x - ROUTE_MARGIN, + y: ports[6].y, + }, + 1, + 0, + ), + ( + ports[7], + Point { + x: ports[7].x - ROUTE_MARGIN, + y: ports[7].y, + }, + 1, + OFF_CENTER_PORT_PENALTY, + ), + ( + ports[8], + Point { + x: ports[8].x - ROUTE_MARGIN, + y: ports[8].y, + }, + 1, + OFF_CENTER_PORT_PENALTY, + ), + ( + ports[9], + Point { + x: ports[9].x, + y: ports[9].y - ROUTE_MARGIN, + }, + 2, + 0, + ), + ( + ports[10], + Point { + x: ports[10].x, + y: ports[10].y - ROUTE_MARGIN, + }, + 2, + OFF_CENTER_PORT_PENALTY, + ), + ( + ports[11], + Point { + x: ports[11].x, + y: ports[11].y - ROUTE_MARGIN, }, 2, + OFF_CENTER_PORT_PENALTY, ), ] } @@ -427,6 +625,26 @@ impl Rect { } } +fn preferred_sides(source: Rect, target: Rect) -> (usize, usize) { + let source_center = Point { + x: 2 * source.x + source.width, + y: 2 * source.y + source.height, + }; + let target_center = Point { + x: 2 * target.x + target.width, + y: 2 * target.y + target.height, + }; + let horizontal = target_center.x - source_center.x; + let vertical = target_center.y - source_center.y; + if horizontal.abs() > vertical.abs() { + if horizontal >= 0 { (0, 2) } else { (2, 0) } + } else if vertical >= 0 { + (1, 3) + } else { + (3, 1) + } +} + #[derive(Debug)] struct GridRouter<'a> { nodes: &'a [SceneNode], @@ -440,15 +658,85 @@ struct GridRouter<'a> { links: Vec<[Option; 4]>, shared: Vec<[u32; 2]>, occupied: Vec<[u32; 2]>, + used_ports: BTreeMap, + multi_port_sides: Vec<[bool; 4]>, } impl<'a> GridRouter<'a> { + #[cfg(test)] fn new( nodes: &'a [SceneNode], bounds: Rect, fixed_obstacles: &'a [Rect], frames: &'a [Rect], ) -> Self { + Self::new_for_edges(nodes, bounds, fixed_obstacles, frames, &[]) + } + + fn new_for_edges( + nodes: &'a [SceneNode], + bounds: Rect, + fixed_obstacles: &'a [Rect], + frames: &'a [Rect], + edges: &[Edge], + ) -> Self { + Self::new_with_context(nodes, bounds, fixed_obstacles, frames, edges, None, &[]) + } + + fn new_for_edge( + nodes: &'a [SceneNode], + bounds: Rect, + fixed_obstacles: &'a [Rect], + frames: &'a [Rect], + edges: &[Edge], + active_edge: &Edge, + reserved_edges: &[SceneEdge], + ) -> Self { + Self::new_with_context( + nodes, + bounds, + fixed_obstacles, + frames, + edges, + Some(active_edge), + reserved_edges, + ) + } + + fn new_with_context( + nodes: &'a [SceneNode], + bounds: Rect, + fixed_obstacles: &'a [Rect], + frames: &'a [Rect], + edges: &[Edge], + active_edge: Option<&Edge>, + reserved_edges: &[SceneEdge], + ) -> Self { + let mut side_demand = vec![[0_u32; 4]; nodes.len()]; + for edge in edges { + let Some(source_index) = nodes.iter().position(|node| node.id == edge.from) else { + continue; + }; + let Some(target_index) = nodes.iter().position(|node| node.id == edge.to) else { + continue; + }; + let (source_side, target_side) = + preferred_sides(nodes[source_index].rect, nodes[target_index].rect); + side_demand[source_index][source_side] += 1; + side_demand[target_index][target_side] += 1; + } + let multi_port_sides = side_demand + .into_iter() + .zip(nodes) + .map(|(demand, node)| { + let requested = if demand.iter().sum::() >= 3 { + [true; 4] + } else { + demand.map(|count| count > 1) + }; + std::array::from_fn(|side| requested[side] && node.offset_port_sides[side]) + }) + .collect::>(); let mut xs = vec![ bounds.x + ROUTE_MARGIN, bounds.x + bounds.width - ROUTE_MARGIN, @@ -457,7 +745,7 @@ impl<'a> GridRouter<'a> { bounds.y + ROUTE_MARGIN, bounds.y + bounds.height - ROUTE_MARGIN, ]; - for node in nodes { + for (node_index, node) in nodes.iter().enumerate() { let rect = node.rect; xs.extend([ rect.x - ROUTE_MARGIN, @@ -469,6 +757,13 @@ impl<'a> GridRouter<'a> { rect.y + rect.height / 2, rect.y + rect.height + ROUTE_MARGIN, ]); + let active = active_edge.is_none_or(|edge| edge.from == node.id || edge.to == node.id); + if active && (multi_port_sides[node_index][1] || multi_port_sides[node_index][3]) { + xs.extend([rect.x + rect.width / 4, rect.x + 3 * rect.width / 4]); + } + if active && (multi_port_sides[node_index][0] || multi_port_sides[node_index][2]) { + ys.extend([rect.y + rect.height / 4, rect.y + 3 * rect.height / 4]); + } } for rect in fixed_obstacles { xs.extend([rect.x - ROUTE_MARGIN, rect.x + rect.width + ROUTE_MARGIN]); @@ -482,6 +777,12 @@ impl<'a> GridRouter<'a> { ys.extend([side - FRAME_MARGIN, side + FRAME_MARGIN]); } } + for edge in reserved_edges { + for point in &edge.path { + xs.push(point.x); + ys.push(point.y); + } + } xs.retain(|x| *x >= bounds.x && *x <= bounds.x + bounds.width); ys.retain(|y| *y >= bounds.y && *y <= bounds.y + bounds.height); xs.sort_unstable(); @@ -526,6 +827,8 @@ impl<'a> GridRouter<'a> { links: vec![[None; 4]; valid.len()], shared: vec![[0; 2]; valid.len()], occupied: vec![[0; 2]; valid.len()], + used_ports: BTreeMap::new(), + multi_port_sides, valid, bend_allowed, }; @@ -575,15 +878,30 @@ impl<'a> GridRouter<'a> { target: Rect, port_pair: Option<(usize, usize)>, ) -> Option> { + self.route_between_with_cost(source, target, port_pair) + .map(|(_, path)| path) + } + + fn route_between_with_cost( + &mut self, + source: Rect, + target: Rect, + port_pair: Option<(usize, usize)>, + ) -> Option<(i64, Vec)> { let state_count = self.valid.len() * 3; let mut distances = vec![i64::MAX; state_count]; let mut parents = vec![None; state_count]; let mut pending = BinaryHeap::new(); let mut starts = Vec::new(); - for (index, (port, stub, axis)) in terminal_stubs(source).into_iter().enumerate() { + for (index, (port, stub, axis, preference)) in + terminal_stubs(source).into_iter().enumerate() + { if port_pair.is_some_and(|(source_port, _)| source_port != index) { continue; } + if !self.port_is_enabled(source, index) { + continue; + } let Some(vertex) = self.vertex(stub) else { continue; }; @@ -591,8 +909,11 @@ impl<'a> GridRouter<'a> { continue; } let state = vertex * 3 + axis; - distances[state] = ROUTE_MARGIN; - pending.push(Reverse((ROUTE_MARGIN, state))); + let cost = ROUTE_MARGIN + + preference + + i64::from(self.used_ports.get(&port).copied().unwrap_or(0)) * PORT_REUSE_PENALTY; + distances[state] = cost; + pending.push(Reverse((cost, state))); starts.push((state, port)); if source == target { break; @@ -601,14 +922,17 @@ impl<'a> GridRouter<'a> { let targets = terminal_stubs(target) .into_iter() .enumerate() - .filter_map(|(index, (port, stub, axis))| { + .filter_map(|(index, (port, stub, axis, preference))| { if port_pair.is_some_and(|(_, target_port)| target_port != index) { return None; } + if !self.port_is_enabled(target, index) { + return None; + } let vertex = self.vertex(stub)?; (self.stub_is_clear(port, stub, target) && !(source == target && starts.iter().any(|(_, start)| *start == port))) - .then_some((vertex, port, axis)) + .then_some((vertex, port, axis, preference)) }) .collect::>(); if starts.is_empty() || targets.is_empty() { @@ -625,10 +949,13 @@ impl<'a> GridRouter<'a> { } let vertex = state / 3; let incoming_axis = state % 3; - for &(target_vertex, port, axis) in &targets { + for &(target_vertex, port, axis, preference) in &targets { if target_vertex == vertex && (incoming_axis == axis || self.bend_allowed[vertex]) { let candidate = ( cost + ROUTE_MARGIN + + preference + + i64::from(self.used_ports.get(&port).copied().unwrap_or(0)) + * PORT_REUSE_PENALTY + if incoming_axis == axis { 0 } else { @@ -671,7 +998,7 @@ impl<'a> GridRouter<'a> { } } } - let (_, state, target_port) = best?; + let (cost, state, target_port) = best?; let (middle, root) = self.reconstruct(state, &parents); let source_port = starts.iter().find(|(state, _)| *state == root)?.1; let mut path = Vec::with_capacity(middle.len() + 2); @@ -683,8 +1010,11 @@ impl<'a> GridRouter<'a> { } if port_pair.is_none() { self.reserve_path(&path); + for port in [source_port, target_port] { + *self.used_ports.entry(port).or_default() += 1; + } } - Some(path) + Some((cost, path)) } fn stub_is_clear(&self, port: Point, stub: Point, terminal: Rect) -> bool { @@ -704,6 +1034,15 @@ impl<'a> GridRouter<'a> { .all(|frame| frame_segment_is_clear(port, stub, *frame)) } + fn port_is_enabled(&self, rect: Rect, port_index: usize) -> bool { + port_index % 3 == 0 + || self + .nodes + .iter() + .position(|node| node.rect == rect) + .is_some_and(|node_index| self.multi_port_sides[node_index][port_index / 3]) + } + fn reserve_path(&mut self, path: &[Point]) { for segment in path.windows(2) { let horizontal = segment[0].y == segment[1].y; @@ -743,6 +1082,18 @@ impl<'a> GridRouter<'a> { } } + fn reserve_edges(&mut self, edges: &[SceneEdge]) { + for edge in edges { + self.reserve_path(&edge.path); + if let Some(port) = edge.path.first() { + *self.used_ports.entry(*port).or_default() += 1; + } + if let Some(port) = edge.path.last() { + *self.used_ports.entry(*port).or_default() += 1; + } + } + } + fn vertex(&self, point: Point) -> Option { let x = self.xs.binary_search(&point.x).ok()?; let y = self.ys.binary_search(&point.y).ok()?; @@ -810,6 +1161,7 @@ mod tests { width: 100_000, height: 100_000, }, + offset_port_sides: [true; 4], } } @@ -828,6 +1180,16 @@ mod tests { } } + fn ir_edge(from: &str, to: &str) -> stack_compiler::ir::Edge { + stack_compiler::ir::Edge { + from: from.to_owned(), + to: to.to_owned(), + direction: EdgeDirection::Forward, + kind: EdgeKind::Flow, + label: None, + } + } + fn test_bounds() -> Rect { Rect { x: 0, @@ -1163,6 +1525,106 @@ mod tests { Ok(()) } + #[test] + fn contextual_routing_preserves_reserved_ports_for_label_fallbacks() + -> Result<(), Box> { + let nodes = [ + test_node("source", 20_000, 150_000), + test_node("upper", 320_000, 50_000), + test_node("lower", 320_000, 250_000), + ]; + let edges = [ir_edge("source", "upper"), ir_edge("source", "lower")]; + let first = super::route_next(&edges[0], &edges, &[], &nodes, test_bounds(), &[], &[]) + .map_err(|_| "first route is missing")?; + let second = super::route_next( + &edges[1], + &edges, + std::slice::from_ref(&first), + &nodes, + test_bounds(), + &[], + &[], + ) + .map_err(|_| "second route is missing")?; + assert_ne!(first.path.first(), second.path.first()); + + let alternatives = super::alternative_routes_with_context( + &edges[1], + &edges, + std::slice::from_ref(&first), + &nodes, + test_bounds(), + &[], + &[], + ) + .map_err(|_| "alternative routes are missing")?; + let preferred = alternatives.first().ok_or("missing alternative route")?; + assert_ne!(first.path.first(), preferred.path.first()); + assert!(super::geometry_is_valid( + &[first, second, preferred.clone()], + &nodes, + test_bounds(), + &[] + )); + Ok(()) + } + + #[test] + fn bounded_alternatives_keep_midpoint_escape_routes_when_preferred_stubs_are_blocked() + -> Result<(), Box> { + let source = test_node("source", 170_000, 150_000); + let target = test_node("target", 320_000, 150_000); + let mut duplicate_target_one = target.clone(); + duplicate_target_one.id = "duplicate-one".to_owned(); + let mut duplicate_target_two = target.clone(); + duplicate_target_two.id = "duplicate-two".to_owned(); + let nodes = [source, target, duplicate_target_one, duplicate_target_two]; + let current = ir_edge("source", "target"); + let context = [ + current.clone(), + ir_edge("source", "duplicate-one"), + ir_edge("source", "duplicate-two"), + ]; + let blocked_stubs = [ + Rect { + x: 270_000, + y: 130_000, + width: 20_000, + height: 140_000, + }, + Rect { + x: 150_000, + y: 120_000, + width: 140_000, + height: 30_000, + }, + Rect { + x: 150_000, + y: 250_000, + width: 140_000, + height: 30_000, + }, + ]; + + let alternatives = super::alternative_routes_with_context( + ¤t, + &context, + &[], + &nodes, + test_bounds(), + &blocked_stubs, + &[], + ) + .map_err(|_| "alternative route search failed")?; + assert!(!alternatives.is_empty()); + assert!(alternatives.iter().all(|edge| { + edge.path + .first() + .is_some_and(|point| point.x == nodes[0].rect.x) + })); + Ok(()) + } + #[test] fn self_edges_leave_and_return_through_different_normal_ports() -> Result<(), Box> { let nodes = [test_node("source", 100_000, 100_000)]; diff --git a/crates/stack-engine/src/scene.rs b/crates/stack-engine/src/scene.rs index bb47944..b820235 100644 --- a/crates/stack-engine/src/scene.rs +++ b/crates/stack-engine/src/scene.rs @@ -58,6 +58,9 @@ pub(crate) struct SceneNode { pub(crate) id: String, pub(crate) parent_group_id: Option, pub(crate) rect: Rect, + // Off-center ports are enabled on straight painted sides only. Side order + // matches routing terminals: right, bottom, left, top. + pub(crate) offset_port_sides: [bool; 4], } #[derive(Debug, Clone, PartialEq, Eq)] @@ -344,10 +347,18 @@ fn layout_attempt(context: &LayoutContext<'_>) -> Result { .node_rects .get(&node.id) .copied() - .map(|rect| SceneNode { - id: node.id.clone(), - parent_group_id: node.parent_group_id.clone(), - rect, + .map(|rect| { + let visual = node_visual(theme, node.kind); + SceneNode { + id: node.id.clone(), + parent_group_id: node.parent_group_id.clone(), + rect, + offset_port_sides: offset_port_sides( + visual.shape, + visual.corner_radius_milli_px, + rect, + ), + } }) .ok_or(SceneError::InvalidIntermediateRepresentation) }) @@ -476,16 +487,16 @@ fn route_with_reserved_labels( for &index in &order { let mut obstacles = fixed_text.to_vec(); obstacles.extend(previous.iter().filter_map(|edge| edge.label_rect)); - let mut edge = routing::route( - &context.diagram.edges[index..=index], + let mut edge = routing::route_next( + &context.diagram.edges[index], + &context.diagram.edges, + &previous, nodes, bounds, &obstacles, frames, ) - .map_err(|_| SceneError::EdgeRoutingFailed)? - .pop() - .ok_or(SceneError::EdgeRoutingFailed)?; + .map_err(|_| SceneError::EdgeRoutingFailed)?; if crate::labels::place_next( &mut edge, &previous, @@ -497,8 +508,10 @@ fn route_with_reserved_labels( ) .is_err() { - edge = routing::alternative_routes( + edge = routing::alternative_routes_with_context( &context.diagram.edges[index], + &context.diagram.edges, + &previous, nodes, bounds, &obstacles, @@ -649,6 +662,25 @@ fn node_size(node: &Node, theme: &Theme, metrics: &FontMetrics) -> Size { } } +fn offset_port_sides(shape: NodeShape, corner_radius_milli_px: u32, rect: Rect) -> [bool; 4] { + match shape { + NodeShape::RoundedRectangle | NodeShape::Capsule => { + let radius = i64::from(corner_radius_milli_px); + let vertical_sides_are_straight = radius <= rect.height / 4; + let horizontal_sides_are_straight = radius <= rect.width / 4; + [ + vertical_sides_are_straight, + horizontal_sides_are_straight, + vertical_sides_are_straight, + horizontal_sides_are_straight, + ] + } + NodeShape::Circle => [false; 4], + NodeShape::Cylinder => [true, false, true, false], + NodeShape::Hexagon => [false, true, false, true], + } +} + fn group_size( group: &Group, content: Size, @@ -690,7 +722,8 @@ impl<'a> Placer<'a> { layout: Option<&Layout>, origin: Point, ) -> Result<(), SceneError> { - let arrangement = arrange(children, layout, self.sizes, self.context)?; + let mut arrangement = arrange(children, layout, self.sizes, self.context)?; + self.align_rank_members_to_external_neighbors(children, layout, &mut arrangement); for placed in arrangement.items { let child = children .get(placed.index) @@ -754,6 +787,170 @@ impl<'a> Placer<'a> { } Ok(()) } + + fn align_rank_members_to_external_neighbors( + &self, + children: &[ElementId], + layout: Option<&Layout>, + arrangement: &mut Arrangement, + ) { + if layout.is_some_and(|layout| layout.order.is_some()) { + return; + } + + let mut ranks = BTreeMap::>::new(); + for (item_index, item) in arrangement.items.iter().enumerate() { + let primary = match arrangement.direction { + SceneDirection::Right => item.rect.x, + SceneDirection::Down => item.rect.y, + }; + ranks.entry(primary).or_default().push(item_index); + } + + for rank in ranks.values_mut() { + if rank.len() < 2 { + continue; + } + let anchors = rank + .iter() + .map(|item_index| { + let child_index = arrangement.items[*item_index].index; + ( + *item_index, + self.external_neighbor_anchor(children, child_index, arrangement.direction), + child_index, + ) + }) + .collect::>(); + if anchors + .iter() + .filter(|(_, anchor, _)| anchor.is_some()) + .count() + < 2 + { + continue; + } + + let mut anchored = anchors + .iter() + .filter(|(_, anchor, _)| anchor.is_some()) + .copied() + .collect::>(); + anchored.sort_by(|left, right| match (left.1, right.1) { + (Some((left_sum, left_count)), Some((right_sum, right_count))) => (left_sum + * right_count) + .cmp(&(right_sum * left_count)) + .then_with(|| left.2.cmp(&right.2)), + _ => std::cmp::Ordering::Equal, + }); + + rank.sort_by_key(|item_index| { + let rect = arrangement.items[*item_index].rect; + match arrangement.direction { + SceneDirection::Right => rect.y, + SceneDirection::Down => rect.x, + } + }); + let mut anchored_index = 0; + let ordered = rank + .iter() + .map(|item_index| { + let fallback = (*item_index, None, arrangement.items[*item_index].index); + let current = anchors + .iter() + .find(|(candidate, _, _)| candidate == item_index) + .copied() + .unwrap_or(fallback); + if current.1.is_some() { + let replacement = anchored.get(anchored_index).copied().unwrap_or(current); + anchored_index += 1; + replacement + } else { + current + } + }) + .collect::>(); + let rank_start = rank + .iter() + .map(|item_index| { + let rect = arrangement.items[*item_index].rect; + match arrangement.direction { + SceneDirection::Right => rect.y, + SceneDirection::Down => rect.x, + } + }) + .min() + .unwrap_or(0); + let rank_end = rank + .iter() + .map(|item_index| { + let rect = arrangement.items[*item_index].rect; + match arrangement.direction { + SceneDirection::Right => rect.y + rect.height, + SceneDirection::Down => rect.x + rect.width, + } + }) + .max() + .unwrap_or(rank_start); + let occupied = ordered + .iter() + .map(|(item_index, _, _)| { + let rect = arrangement.items[*item_index].rect; + match arrangement.direction { + SceneDirection::Right => rect.height, + SceneDirection::Down => rect.width, + } + }) + .sum::(); + let gap = (rank_end - rank_start - occupied) + / i64::try_from(rank.len().saturating_sub(1)).unwrap_or(1); + let mut cursor = rank_start; + for (item_index, _, _) in ordered { + let rect = &mut arrangement.items[item_index].rect; + match arrangement.direction { + SceneDirection::Right => { + rect.y = cursor; + cursor += rect.height + gap; + } + SceneDirection::Down => { + rect.x = cursor; + cursor += rect.width + gap; + } + } + } + } + } + + fn external_neighbor_anchor( + &self, + children: &[ElementId], + child_index: usize, + direction: SceneDirection, + ) -> Option<(i128, i128)> { + let mut sum = 0_i128; + let mut count = 0_i128; + for edge in &self.context.diagram.edges { + let from = scope_owner(children, &edge.from, self.context.diagram); + let to = scope_owner(children, &edge.to, self.context.diagram); + let external = if from == Some(child_index) && to.is_none() { + Some(edge.to.as_str()) + } else if to == Some(child_index) && from.is_none() { + Some(edge.from.as_str()) + } else { + None + }; + let Some(rect) = external.and_then(|identifier| self.node_rects.get(identifier)) else { + continue; + }; + let center = match direction { + SceneDirection::Right => 2 * rect.y + rect.height, + SceneDirection::Down => 2 * rect.x + rect.width, + }; + sum += i128::from(center); + count += 1; + } + (count > 0).then_some((sum, count)) + } } fn arrange( @@ -1156,7 +1353,11 @@ mod tests { use stack_compiler::ir::ElementId; - use super::{Scene, SceneDirection, SceneError, glyph_advance, layout, selected_theme}; + use stack_theme::NodeShape; + + use super::{ + Scene, SceneDirection, SceneError, glyph_advance, layout, offset_port_sides, selected_theme, + }; fn scene_from(source: &[u8]) -> Result> { let compiled = stack_compiler::compile_bytes(source); @@ -1171,6 +1372,41 @@ mod tests { scene.nodes.iter().find(|node| node.id == identifier) } + #[test] + fn offset_ports_are_limited_to_straight_painted_sides() { + let rect = super::Rect { + x: 0, + y: 0, + width: 160_000, + height: 72_000, + }; + assert_eq!( + offset_port_sides(NodeShape::RoundedRectangle, 8_000, rect), + [true; 4] + ); + assert_eq!( + offset_port_sides(NodeShape::Capsule, 16_000, rect), + [true; 4] + ); + assert_eq!( + offset_port_sides(NodeShape::RoundedRectangle, 36_000, rect), + [false, true, false, true] + ); + assert_eq!( + offset_port_sides(NodeShape::Capsule, 80_000, rect), + [false; 4] + ); + assert_eq!(offset_port_sides(NodeShape::Circle, 0, rect), [false; 4]); + assert_eq!( + offset_port_sides(NodeShape::Cylinder, 0, rect), + [true, false, true, false] + ); + assert_eq!( + offset_port_sides(NodeShape::Hexagon, 0, rect), + [false, true, false, true] + ); + } + #[test] fn right_direction_satisfies_same_rank_and_cross_axis_order() -> Result<(), Box> { let scene = scene_from( @@ -1406,8 +1642,13 @@ mod tests { )?; let scene = scene_from(&source)?; let actual = scene_snapshot(&scene); - let expected = include_str!("../tests/snapshots/complete-semantics.scene.txt"); - assert_eq!(actual, expected); + let snapshot = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/snapshots/complete-semantics.scene.txt"); + if std::env::var_os("UPDATE_STACK_SNAPSHOTS").is_some() { + std::fs::write(&snapshot, &actual)?; + } else { + assert_eq!(actual, std::fs::read_to_string(&snapshot)?); + } Ok(()) } diff --git a/crates/stack-engine/src/svg.rs b/crates/stack-engine/src/svg.rs index 13b6f81..2c314e1 100644 --- a/crates/stack-engine/src/svg.rs +++ b/crates/stack-engine/src/svg.rs @@ -727,7 +727,7 @@ mod tests { ("actor", " Result<(), Box> { + let rect = Rect { + x: -1250, + y: 267_800, + width: 208_300, + height: 72_000, + }; + let source = b"stack 1.0 diagram \"Shape\" { node a \"A\" { kind cache } }"; + let diagram = stack_compiler::compile_bytes(source) + .diagram + .ok_or("valid diagram")?; + let mut catalog = stack_theme::catalog().clone(); + catalog.themes[0].node_kind_fallbacks.cache.shape = stack_theme::NodeShape::Cylinder; + catalog.themes[0] + .node_kind_fallbacks + .cache + .corner_radius_milli_px = 0; + let resources = + Resources::resolve(&diagram, &catalog, &[]).map_err(|error| error.reason())?; + let mut output = String::new(); + + super::render_node_shape( + &mut output, + rect, + resources.node("a").ok_or("resolved node")?, + resources.theme, + ); + + assert!(output.contains( + " Result<(), Box> { diff --git a/crates/stack-engine/tests/fixtures/sample-service-congestion.stack b/crates/stack-engine/tests/fixtures/sample-service-congestion.stack new file mode 100644 index 0000000..0296081 --- /dev/null +++ b/crates/stack-engine/tests/fixtures/sample-service-congestion.stack @@ -0,0 +1,47 @@ +stack 1.0 + +diagram "Sample Service" { + layout { direction down } + + node customer "Customer" { kind actor } + node operator "Operator" { kind actor } + node cdn "Cloudflare" { kind cache } + + group gcp "Google Cloud" { + node alb-for-customer "ALB(for User)" { kind service icon "load-balancer" } + node alb-for-operator "ALB(for Admin)" { kind service icon "load-balancer" } + + group vpc "VPC" { + node iap "Identity Aware Proxy" { kind service icon "identity" } + node web "Web" { kind service icon "server" } + node admin "Admin" { kind service icon "server" } + node api "API" { kind service icon "server" } + node db "Spanner" { kind database } + node scheduler "Cloud Scheduler" { + kind service + icon "scheduler" + detail "every 1min" + } + node pubsub "Cloud Pub/Sub" { kind worker icon "event" } + node worker1 "Worker" { + kind worker + icon "server" + detail "Cloud Run Jobs Worker" + } + } + } + + edge customer <-> cdn "If cached Return Cache" + edge cdn -> alb-for-customer + edge alb-for-customer -> web + edge web -> api + edge api -> pubsub + edge operator -> alb-for-operator + edge alb-for-operator -> iap + edge iap -> admin + edge admin -> api + edge api -> db + edge scheduler -> worker1 + edge pubsub -> worker1 + edge worker1 -> db "Batch" +} diff --git a/crates/stack-engine/tests/render_snapshots.rs b/crates/stack-engine/tests/render_snapshots.rs index 78b1e4f..b5b350d 100644 --- a/crates/stack-engine/tests/render_snapshots.rs +++ b/crates/stack-engine/tests/render_snapshots.rs @@ -53,7 +53,7 @@ fn explicit_core_icon_matches_standalone_svg_snapshot() -> Result<(), Box - + Complete semantics Architecture diagram with 10 nodes, 3 groups, and 8 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a @@ -38,11 +38,11 @@ API flows to Function: Invoke - + Function flows to Database: SQL - + Function flows to Queue: Job @@ -54,11 +54,11 @@ API flows to Cache: Read - + API flows to External provider: API - + @@ -106,8 +106,7 @@ Cache - - + Cache @@ -143,12 +142,12 @@ WebSocket - - Invoke + + Invoke - - SQL + + SQL @@ -163,8 +162,8 @@ Read - - API + + API diff --git a/crates/stack-engine/tests/snapshots/render/default-normalization.svg b/crates/stack-engine/tests/snapshots/render/default-normalization.svg index 005656c..e3085d7 100644 --- a/crates/stack-engine/tests/snapshots/render/default-normalization.svg +++ b/crates/stack-engine/tests/snapshots/render/default-normalization.svg @@ -1,8 +1,8 @@ - + Default normalization Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a diff --git a/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg b/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg index 9e75e7a..8676b22 100644 --- a/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg +++ b/crates/stack-engine/tests/snapshots/render/explicit-core-icon.svg @@ -1,8 +1,8 @@ - + Core icon Architecture diagram with 1 node, 0 groups, and 0 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a diff --git a/crates/stack-formatter/THIRD_PARTY_LICENSES.md b/crates/stack-formatter/THIRD_PARTY_LICENSES.md index 179d1f5..9d44c95 100644 --- a/crates/stack-formatter/THIRD_PARTY_LICENSES.md +++ b/crates/stack-formatter/THIRD_PARTY_LICENSES.md @@ -5,7 +5,7 @@ | Component | Revision | License | Source | Notes | | --- | --- | --- | --- | --- | | `stack-compiler` | `0.1.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency; its license and notice obligations apply to distributions that include it. | -| `stack-theme` | `0.6.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `stack-theme` | `0.7.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses caller-owned processed provider SVG before allowlisted in-memory embedding. | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Verifies provider asset hashes and computes deterministic provider-pack revisions. | | `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | MIT OR Apache-2.0 | , | Target-specific SHA-256 acceleration support. | diff --git a/docs/cargo-releasing.md b/docs/cargo-releasing.md index 1333340..59393d8 100644 --- a/docs/cargo-releasing.md +++ b/docs/cargo-releasing.md @@ -1,6 +1,6 @@ # Cargo publication -The native `stack-formatter` 0.1.0 and `stack-engine` 0.8.0 crates use exact registry dependencies. The workspace retains local paths for development, with registry versions declared for Cargo packaging. `stack-engine-wasm` is not published to crates.io; its browser output remains the npm package. +The native `stack-formatter` 0.1.0 and `stack-engine` 0.9.0 crates use exact registry dependencies. The workspace retains local paths for development, with registry versions declared for Cargo packaging. `stack-engine-wasm` is not published to crates.io; its browser output remains the npm package. ## Initial publication diff --git a/docs/decisions/0009-distribute-terminals-and-routing-lanes.md b/docs/decisions/0009-distribute-terminals-and-routing-lanes.md new file mode 100644 index 0000000..833716e --- /dev/null +++ b/docs/decisions/0009-distribute-terminals-and-routing-lanes.md @@ -0,0 +1,28 @@ +# ADR-0009: Distribute terminals and routing lanes + +## Status + +Accepted for Engine 0.9.0 after visual review. Scene and SVG references record the approved output with Theme 0.7.0. + +## Context + +One midpoint port per node side can force unrelated edges onto the same terminal and corridor. In dense diagrams this creates ambiguous junctions, nearly overlapping parallel lines, and local clusters even when every route technically avoids node interiors. Declaration-order placement can also put connected peers opposite their upstream neighbors and add unnecessary crossovers. + +These defects are not safely hidden by a single weighted beauty score. Text, node, and group-frame collisions remain hard failures. Crossings, reused terminals, shared route length, close parallel lanes, bends, route length, and canvas size remain separate review signals so an improvement in one cannot conceal damage in another. + +## Decision + +Expose three deterministic ports on each node side: the midpoint and two offset slots. Use offset slots only on sides with demonstrated multi-edge demand and enough geometry to leave normally. Preserve midpoint preference for simple diagrams. Penalize reused terminal points and close or shared route lanes, while retaining deterministic length, bend, crossing, and declaration-order tie breaks. + +Route and label edges incrementally when joint placement cannot reserve every label. Each candidate sees already accepted routes and terminals, so fallback routing cannot silently recreate a shared port or corridor. Bound alternative port-pair search and preserve authored edge order in the final scene. + +Within a same-rank set without an explicit authored order, compare connected external neighbors and permute only anchored slots to reduce crossovers. Keep unanchored slots stable. An explicit order remains authoritative even when a different visual order would be shorter. + +Do not merge independent edges into a shared trunk. A common segment without an explicit junction would obscure edge identity and arrow semantics. Separation is preferred whenever a clear lane exists. + +## Consequences + +- Dense fan-in, fan-out, and cross-boundary diagrams can use distinct terminals and lanes without changing Stack syntax or the public Engine API. +- Exact SVG geometry intentionally changes. Approved snapshots, native/WASM parity, the seven-case corpus, and release-mode budgets remain required. +- The representative congestion fixture must report zero reused terminals, proper crossings, ambiguous junctions, shared route length, and close parallel length when clear alternatives exist. +- The search remains finite and heuristic. If no valid separated route exists, the Engine returns an operational error instead of hiding a collision or inventing an unlabeled junction. diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md index d576e5a..e180d87 100644 --- a/docs/dependency-audit.md +++ b/docs/dependency-audit.md @@ -8,7 +8,7 @@ Audit date: 2026-09-06 - `stack-compiler` at `84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820` for byte decoding, parsing, validation, normalized IR, source maps, and compiler diagnostics; - the workspace-local `stack-formatter` for canonical source output; -- `stack-theme` `0.6.0` from crates.io, source `dd366fbacfe1edceeb8c1bde04687027ccc99c24`, for the embedded core catalog with database cards, 30 provider-neutral explicit icons, the local-only provider-pack contract, SVG bytes, deterministic font metrics, catalog version, and catalog revision; +- `stack-theme` `0.7.0` from crates.io, source `07934713ab37fe35811f1d54da8937e3671a1a92`, for the embedded core catalog with database and cache cards, 30 provider-neutral explicit icons, the local-only provider-pack contract, SVG bytes, deterministic font metrics, catalog version, and catalog revision; - `roxmltree`, `serde_json`, and `sha2` for pure in-memory provider manifest serialization, processed-asset hash verification, pack revision computation, and defensive SVG validation. Vendor asset bytes are not included. `stack-engine-wasm` adds `serde`, `serde_json`, and the asset-free `stack-theme` types for its serializable native parity model and local provider-pack input, plus, only on `wasm32`, version-matched `wasm-bindgen` and `js-sys` for the JavaScript ABI, typed-array input, JSON-compatible local data, and plain object construction. It does not use `web-sys` or a WASI target. diff --git a/docs/releases/v0.9.0.md b/docs/releases/v0.9.0.md new file mode 100644 index 0000000..1780289 --- /dev/null +++ b/docs/releases/v0.9.0.md @@ -0,0 +1,16 @@ +# Stack Engine 0.9.0 + +## Calmer dense diagrams + +- Distribute busy edges across multiple deterministic ports instead of stacking unrelated routes on one node-side midpoint. +- Penalize shared and nearly overlapping route lanes, and align same-rank nodes with their connected neighbors when no explicit order overrides the layout. +- Keep independent edges separate rather than introducing implicit shared trunks that could obscure identity or arrow direction. +- Bundle Theme 0.7.0 so built-in cache nodes use the same clean rounded-card treatment as database nodes while retaining their semantic cache icon. + +The native Rust engine and browser WASM adapter share these changes. Public operation and language-intelligence contracts are unchanged; generated SVG geometry intentionally differs from 0.8.0. Consumers comparing exact SVG bytes should update reviewed references. + +## Verification and limits + +The release adds a representative dense-service fixture and independent metrics for reused terminals, proper crossings, ambiguous junctions, shared length, and close parallel length. Existing text, node, group-frame, native/WASM parity, seven-diagram corpus, coverage, and performance gates remain required. These measurements supplement visual review; they do not convert beauty into one opaque score or prove an optimal layout for every graph. + +The Engine remains pure Rust with no runtime filesystem, network, clock, DOM, or host-font access. No custom-palette configuration format or theme-authoring service is introduced in this release. diff --git a/layout-corpus/catalog.json b/layout-corpus/catalog.json index ad4a8f0..11464cc 100644 --- a/layout-corpus/catalog.json +++ b/layout-corpus/catalog.json @@ -1,7 +1,7 @@ { "$schema": "./schema.json", "schemaVersion": "1.0", - "engineVersion": "0.8.0", + "engineVersion": "0.9.0", "performance": { "warmupIterations": 3, "measuredIterations": 20, diff --git a/layout-corpus/snapshots/dense-commerce.svg b/layout-corpus/snapshots/dense-commerce.svg index 96be8fa..7e842f5 100644 --- a/layout-corpus/snapshots/dense-commerce.svg +++ b/layout-corpus/snapshots/dense-commerce.svg @@ -1,8 +1,8 @@ - + Dense commerce platform Architecture diagram with 13 nodes, 5 groups, and 12 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a @@ -48,39 +48,39 @@ Edge gateway flows to Catalog API: Catalog requests - + Edge gateway flows to Checkout API: Checkout requests - + Catalog API flows to Product database: SQL - + Catalog API flows to Product media: Media URLs - + Checkout API flows to Order database: Transactions - + Checkout API flows to Payment provider: Payment API - + Checkout API flows to Event bus: OrderPlaced - + Event bus flows to Fulfillment worker: OrderPlaced - + Event bus flows to Notification worker: OrderPlaced - + Notification worker flows to Email provider: Send receipt @@ -194,8 +194,8 @@ HTTPS - - Catalog requests + + Catalog requests @@ -206,28 +206,28 @@ SQL - - Media URLs + + Media URLs - - Transactions + + Transactions - - Payment API + + Payment API - - OrderPlaced + + OrderPlaced - - OrderPlaced + + OrderPlaced - - OrderPlaced + + OrderPlaced diff --git a/layout-corpus/snapshots/fanout-cross-edges.svg b/layout-corpus/snapshots/fanout-cross-edges.svg index c769c1d..d7301e2 100644 --- a/layout-corpus/snapshots/fanout-cross-edges.svg +++ b/layout-corpus/snapshots/fanout-cross-edges.svg @@ -1,8 +1,8 @@ - + Fan-out and cross edges Architecture diagram with 8 nodes, 0 groups, and 10 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a @@ -15,7 +15,7 @@ Ingress flows to Identity service: Authenticate - + Ingress flows to Catalog service: Browse @@ -23,19 +23,19 @@ Ingress flows to Checkout service: Purchase - + Identity service flows to Primary database: Session - + Catalog service flows to Primary database: Products - + Checkout service flows to Order events: OrderPlaced - + Order events flows to Order worker: Dispatch @@ -43,15 +43,15 @@ Order worker flows to Primary database: Persist - + Identity service flows to Audit archive: Access log - + Checkout service flows to Audit archive: Checkout log - + @@ -118,44 +118,44 @@ diff --git a/layout-corpus/snapshots/medium-group-flow.svg b/layout-corpus/snapshots/medium-group-flow.svg index 1266a4c..877d98b 100644 --- a/layout-corpus/snapshots/medium-group-flow.svg +++ b/layout-corpus/snapshots/medium-group-flow.svg @@ -1,8 +1,8 @@ - + Medium group flow Architecture diagram with 6 nodes, 2 groups, and 5 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a @@ -25,15 +25,15 @@ Browser flows to Edge gateway: Browser HTTPS - + Mobile app flows to Edge gateway: Mobile HTTPS - + Edge gateway flows to Application API: Route - + Application API flows to Background worker: Dispatch @@ -95,12 +95,12 @@ Browser HTTPS - - Mobile HTTPS + + Mobile HTTPS - - Route + + Route diff --git a/layout-corpus/snapshots/multilingual-long-labels.svg b/layout-corpus/snapshots/multilingual-long-labels.svg index 3d6b7de..850caf9 100644 --- a/layout-corpus/snapshots/multilingual-long-labels.svg +++ b/layout-corpus/snapshots/multilingual-long-labels.svg @@ -1,8 +1,8 @@ - + Multilingual long labels Architecture diagram with 4 nodes, 0 groups, and 3 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a diff --git a/layout-corpus/snapshots/nested-platform.svg b/layout-corpus/snapshots/nested-platform.svg index c0f093f..405f310 100644 --- a/layout-corpus/snapshots/nested-platform.svg +++ b/layout-corpus/snapshots/nested-platform.svg @@ -1,8 +1,8 @@ - + Nested platform boundaries Architecture diagram with 7 nodes, 4 groups, and 6 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a @@ -43,7 +43,7 @@ Public API flows to Checkout: Invoke - + Checkout flows to Order events: OrderPlaced @@ -51,11 +51,11 @@ Checkout flows to Orders: Transaction - + Public API is associated with Error monitoring: Telemetry - + @@ -118,20 +118,20 @@ Live checkout - - Invoke + + Invoke OrderPlaced - - Transaction + + Transaction - - Telemetry + + Telemetry diff --git a/layout-corpus/snapshots/provider-icon-boundary.svg b/layout-corpus/snapshots/provider-icon-boundary.svg index a25bcba..13711ee 100644 --- a/layout-corpus/snapshots/provider-icon-boundary.svg +++ b/layout-corpus/snapshots/provider-icon-boundary.svg @@ -1,8 +1,8 @@ - + Provider icon boundary Architecture diagram with 3 nodes, 1 group, and 2 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1; providers example at sha256:6e05b396567a5fa3f141df079c515a8866033b98d11ec4027f486af31f14fa43 using example:storage + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a; providers example at sha256:6e05b396567a5fa3f141df079c515a8866033b98d11ec4027f486af31f14fa43 using example:storage diff --git a/layout-corpus/snapshots/small-request-path.svg b/layout-corpus/snapshots/small-request-path.svg index ce3f510..d380428 100644 --- a/layout-corpus/snapshots/small-request-path.svg +++ b/layout-corpus/snapshots/small-request-path.svg @@ -1,8 +1,8 @@ - + Small request path Architecture diagram with 3 nodes, 0 groups, and 2 relationships. - stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + stack-engine 0.9.0; language 1.0; theme 0.7.0 at sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a diff --git a/package-lock.json b/package-lock.json index 91de3e0..78444b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stack-engine-workspace", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stack-engine-workspace", - "version": "0.8.0", + "version": "0.9.0", "workspaces": [ "packages/engine" ], @@ -454,7 +454,7 @@ }, "packages/engine": { "name": "@stack-sh/engine", - "version": "0.8.0", + "version": "0.9.0", "license": "Apache-2.0" } } diff --git a/package.json b/package.json index 5534f9d..d0aa7b4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stack-engine-workspace", "private": true, - "version": "0.8.0", + "version": "0.9.0", "workspaces": [ "packages/engine" ], diff --git a/packages/engine/THIRD_PARTY_LICENSES.md b/packages/engine/THIRD_PARTY_LICENSES.md index 706c690..52c755f 100644 --- a/packages/engine/THIRD_PARTY_LICENSES.md +++ b/packages/engine/THIRD_PARTY_LICENSES.md @@ -5,7 +5,7 @@ | Component | Version / revision | Selected license | Source | | --- | --- | --- | --- | | `stack-compiler` | `0.1.0 (crates.io)` | Apache-2.0 | | -| `stack-theme` | `0.6.0 (crates.io)` | Apache-2.0 | | +| `stack-theme` | `0.7.0 (crates.io)` | Apache-2.0 | | | `roxmltree` | `0.21.1` | Apache-2.0 | | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | Apache-2.0 | | | `libc` / `cpufeatures` | `0.2.189`, `0.3.1` | Apache-2.0 | , | diff --git a/packages/engine/package.json b/packages/engine/package.json index 1f3dfb2..8e7c9df 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@stack-sh/engine", - "version": "0.8.0", + "version": "0.9.0", "description": "Browser WebAssembly adapter for Stack diagram operations", "type": "module", "license": "Apache-2.0", diff --git a/scripts/validate-svg.py b/scripts/validate-svg.py index 279188c..aa2d48f 100644 --- a/scripts/validate-svg.py +++ b/scripts/validate-svg.py @@ -95,10 +95,10 @@ def main() -> None: "association", } assert values(explicit_icon, "data-icon-id") == {"gateway"} - assert explicit_icon.attrib.get("data-theme-version") == "0.6.0" + assert explicit_icon.attrib.get("data-theme-version") == "0.7.0" assert ( explicit_icon.attrib.get("data-theme-revision") - == "sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1" + == "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a" ) diff --git a/tests/wasm.test.mjs b/tests/wasm.test.mjs index a08d15d..f8852d0 100644 --- a/tests/wasm.test.mjs +++ b/tests/wasm.test.mjs @@ -210,7 +210,7 @@ test("browser diagnostics preserve actionable compiler guidance", () => { ); assert.ok(actionable); assert.equal(actionable.render.svg, null); - assert.equal(actionable.check.metadata.engineVersion, "0.8.0"); + assert.equal(actionable.check.metadata.engineVersion, "0.9.0"); assert.deepEqual(actionable.check.diagnostics[0], { code: "STK2002", severity: "error", @@ -232,11 +232,11 @@ test("browser rendering resolves the bundled explicit core icon", () => { assert.ok(explicitIcon); assert.deepEqual(explicitIcon.check.diagnostics, []); assert.deepEqual(explicitIcon.render.diagnostics, []); - assert.equal(explicitIcon.render.metadata.engineVersion, "0.8.0"); - assert.equal(explicitIcon.render.metadata.themeCatalogVersion, "0.6.0"); + assert.equal(explicitIcon.render.metadata.engineVersion, "0.9.0"); + assert.equal(explicitIcon.render.metadata.themeCatalogVersion, "0.7.0"); assert.equal( explicitIcon.render.metadata.themeCatalogRevision, - "sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1", + "sha256:4a8b94b746c6b120998bfbe701edd722449a28c89c424b0a33f67561756ded5a", ); assert.match(explicitIcon.render.svg, /data-icon-id="gateway"/); assert.doesNotMatch(explicitIcon.render.svg, /data-icon-id="kind-external"/);