diff --git a/Cargo.lock b/Cargo.lock index c458df8..a5e4228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,7 @@ checksum = "bdba45c0f7a85ef0ec0bfb766b387dbd90fc6141237ac74b559d08a424750bc6" [[package]] name = "stack-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "roxmltree", "serde", @@ -209,7 +209,7 @@ dependencies = [ [[package]] name = "stack-engine-wasm" -version = "0.7.0" +version = "0.8.0" dependencies = [ "js-sys", "serde", @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "stack-theme" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98e0418c819d61897beded19d5333b4f6c7a8dcf1be46d0a8c62409ff2e6db7" +checksum = "87b3f19b223133b8da80e0a10c4cf5a7ec3024fb48105c1f9f2e0d635a9e8d56" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index ac7051b..8102d15 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.5.0" +stack-theme = "=0.6.0" diff --git a/README.md b/README.md index 88f8299..dfafbf2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ npm run layout:gallery The review-first snapshot policy and corpus contract are documented in [`layout-corpus/README.md`](./layout-corpus/README.md). +The independent text, edge, and frame quality gates run under the `conformance` feature with the repository's source corpus. CI runs these gates explicitly and includes them in its 95% line, function, and region coverage checks. Packaged crate unit tests do not require the repository-only corpus. See the [0.8.0 release notes](./docs/releases/v0.8.0.md) for the approved layout and SVG changes. + `stack-formatter` is pure and accepts source bytes or UTF-8 text. Lexical and syntax errors return diagnostics without formatted output. Syntactically valid source remains formattable when semantic diagnostics exist. `stack-engine` exposes byte-oriented `format`, `check`, and `render` methods plus UTF-8 `completion` and `hover` methods through an engine bound to the embedded or a caller-provided validated catalog. Language-intelligence results implement schema version 1.0 from the pinned compiler and echo the caller's document version. The Engine derives completion entries from its core theme catalog and validated provider packs, while the compiler remains the single owner of grammar, context, diagnostics, hover semantics, and text edits. `ProviderPack::new` accepts a typed user-imported manifest and caller-owned SVG strings, verifies exact asset hashes and safe SVG structure, and computes a deterministic content revision before `Engine::with_provider_packs` can resolve namespaced IDs. Every normal format, check, or render output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs or provider packs, invalid language-intelligence positions, and violated normalized pipeline invariants use a separate operational-error channel. Checks and renders resolve the requested theme and provider packs, validate deterministic integer geometry, and route ordered edges outside node interiors. Missing themes and icons produce source-mapped `STK6001` and `STK5001` warnings while a fallback SVG remains available. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. @@ -72,6 +74,7 @@ Public npm releases are produced from GitHub Releases after the repository check - [`docs/decisions/0005-serialize-safe-standalone-svg.md`](./docs/decisions/0005-serialize-safe-standalone-svg.md) - [`docs/decisions/0006-expose-one-typed-browser-wasm-adapter.md`](./docs/decisions/0006-expose-one-typed-browser-wasm-adapter.md) - [`docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md`](./docs/decisions/0007-adapt-language-intelligence-with-engine-catalogs.md) +- [`docs/decisions/0008-compose-graphs-with-reserved-label-geometry.md`](./docs/decisions/0008-compose-graphs-with-reserved-label-geometry.md) - [`docs/dependency-audit.md`](./docs/dependency-audit.md) ## Licensing diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 23a695b..179d1f5 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.5.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `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. | | `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 edecb8f..8c84271 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.7.0" +version = "0.8.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 d99e8c5..1297d15 100644 --- a/crates/stack-engine/Cargo.toml +++ b/crates/stack-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-engine" -version = "0.7.0" +version = "0.8.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 6834e4f..618e058 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.7.0" +stack-engine = "=0.8.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 23a695b..179d1f5 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.5.0 (crates.io)` | Apache-2.0 | | Unmodified Rust dependency with repository-authored core assets and the asset-free provider-pack contract. | +| `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. | | `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 new file mode 100644 index 0000000..6629679 --- /dev/null +++ b/crates/stack-engine/src/labels.rs @@ -0,0 +1,780 @@ +//! Deterministic label placement adjacent to each edge's own route. + +use std::cmp::Reverse; + +use stack_theme::{FontMetrics, Theme}; + +use crate::routing::{Point, RoutingError, SceneEdge}; +use crate::scene::{self, Rect, SceneNode}; + +const HORIZONTAL_PADDING: i64 = 6_000; +const VERTICAL_PADDING: i64 = 4_000; +const ROUTE_GAP: i64 = 8_000; + +// Preserve core geometry while keeping labels outside thicker custom strokes. +fn route_gap(stroke_width: i64) -> i64 { + ROUTE_GAP.max(stroke_width / 2 + stroke_width % 2 + 1_000) +} + +#[derive(Clone, Copy)] +struct Placement { + rect: Rect, + attachment: Point, +} + +pub(crate) fn dimensions(label: &str, theme: &Theme, metrics: &FontMetrics) -> Rect { + let typography = &theme.typography; + Rect { + x: 0, + y: 0, + width: scene::text_width(label, typography.edge_label_size_milli_px, metrics) + + 2 * HORIZONTAL_PADDING, + height: scene::line_height(typography.edge_label_size_milli_px, typography) + + 2 * VERTICAL_PADDING, + } +} + +pub(crate) fn place( + edges: &mut [SceneEdge], + nodes: &[SceneNode], + bounds: Rect, + fixed_text: &[Rect], + theme: &Theme, + metrics: &FontMetrics, +) -> Result<(), RoutingError> { + let mut order = edges + .iter() + .enumerate() + .filter_map(|(index, edge)| { + edge.label + .as_ref() + .map(|label| (index, dimensions(label, theme, metrics))) + }) + .collect::>(); + order.sort_by_key(|(index, size)| (Reverse(size.width), *index)); + let mut occupied = nodes.iter().map(|node| node.rect).collect::>(); + occupied.extend_from_slice(fixed_text); + let mut placements = vec![None; edges.len()]; + let stroke_width = i64::from(theme.connector.width_milli_px); + + for (index, size) in order { + let placement = candidates(&edges[index].path, size, bounds, route_gap(stroke_width)) + .into_iter() + .find(|candidate| rect_is_clear(candidate.rect, bounds, &occupied, edges, stroke_width)) + .ok_or(RoutingError)?; + occupied.push(placement.rect); + placements[index] = Some(placement); + } + + // Commit only a complete placement so a failed attempt leaves its input intact. + for (edge, placement) in edges.iter_mut().zip(placements) { + edge.label_rect = placement.map(|placement| placement.rect); + edge.label_anchor = placement.map(|placement| placement.attachment); + } + Ok(()) +} + +pub(crate) fn place_next( + edge: &mut SceneEdge, + previous: &[SceneEdge], + nodes: &[SceneNode], + bounds: Rect, + fixed_text: &[Rect], + theme: &Theme, + metrics: &FontMetrics, +) -> Result<(), RoutingError> { + let Some(label) = edge.label.as_ref() else { + edge.label_rect = None; + edge.label_anchor = None; + return Ok(()); + }; + let mut occupied = nodes.iter().map(|node| node.rect).collect::>(); + occupied.extend_from_slice(fixed_text); + occupied.extend(previous.iter().filter_map(|edge| edge.label_rect)); + let stroke_width = i64::from(theme.connector.width_milli_px); + let placement = candidates( + &edge.path, + dimensions(label, theme, metrics), + bounds, + route_gap(stroke_width), + ) + .into_iter() + .find(|candidate| { + rect_is_clear(candidate.rect, bounds, &occupied, previous, stroke_width) + && rect_is_clear( + candidate.rect, + bounds, + &[], + std::slice::from_ref(edge), + stroke_width, + ) + }) + .ok_or(RoutingError)?; + edge.label_rect = Some(placement.rect); + edge.label_anchor = Some(placement.attachment); + Ok(()) +} + +pub(crate) fn geometry_is_valid( + edges: &[SceneEdge], + nodes: &[SceneNode], + bounds: Rect, + fixed_text: &[Rect], + stroke_width: i64, +) -> bool { + let mut occupied = nodes.iter().map(|node| node.rect).collect::>(); + occupied.extend_from_slice(fixed_text); + for edge in edges { + match (&edge.label, edge.label_rect, edge.label_anchor) { + (None, None, None) => {} + (Some(_), Some(rect), Some(attachment)) => { + if !rect_is_clear(rect, bounds, &occupied, edges, stroke_width) + || !edge.path.windows(2).any(|segment| { + is_adjacent( + rect, + attachment, + segment[0], + segment[1], + route_gap(stroke_width), + ) + }) + { + return false; + } + occupied.push(rect); + } + _ => return false, + } + } + true +} + +fn candidates(path: &[Point], size: Rect, bounds: Rect, gap: i64) -> Vec { + let mut segments = path.windows(2).enumerate().collect::>(); + segments.sort_by_key(|(index, segment)| { + ( + Reverse((segment[1].x - segment[0].x).abs() + (segment[1].y - segment[0].y).abs()), + *index, + ) + }); + let mut candidates = Vec::new(); + for (_, segment) in segments { + let start = segment[0]; + let end = segment[1]; + if start == end { + continue; + } + if start.y == end.y { + let positions = axis_positions(start.x, end.x, size.width, bounds.x, bounds.width); + for y in [start.y - gap - size.height, start.y + gap] { + for &x in &positions { + candidates.push(Placement { + rect: Rect { + x: x - size.width / 2, + y, + ..size + }, + attachment: Point { x, y: start.y }, + }); + } + } + } else if start.x == end.x { + let positions = axis_positions(start.y, end.y, size.height, bounds.y, bounds.height); + for x in [start.x + gap, start.x - gap - size.width] { + for &y in &positions { + candidates.push(Placement { + rect: Rect { + x, + y: y - size.height / 2, + ..size + }, + attachment: Point { x: start.x, y }, + }); + } + } + } + } + candidates +} + +fn axis_positions(start: i64, end: i64, size: i64, origin: i64, extent: i64) -> Vec { + let low = start.min(end); + let high = start.max(end); + let minimum = (low + 1).max(origin + size / 2); + let maximum = (high - 1).min(origin + extent - (size - size / 2)); + if size <= 0 || minimum > maximum { + return Vec::new(); + } + let length = high - low; + let mut positions = Vec::new(); + for preferred in [ + low + length / 2, + low + length / 3, + low + 2 * length / 3, + low + size / 2, + high - (size - size / 2), + ] { + let position = preferred.clamp(minimum, maximum); + if !positions.contains(&position) { + positions.push(position); + } + } + positions +} + +fn is_adjacent(rect: Rect, attachment: Point, start: Point, end: Point, gap: i64) -> bool { + if start.y == end.y && start.x != end.x { + attachment.y == start.y + && attachment.x > start.x.min(end.x) + && attachment.x < start.x.max(end.x) + && attachment.x == rect.x + rect.width / 2 + && (rect.y + rect.height + gap == attachment.y || rect.y - gap == attachment.y) + } else if start.x == end.x && start.y != end.y { + attachment.x == start.x + && attachment.y > start.y.min(end.y) + && attachment.y < start.y.max(end.y) + && attachment.y == rect.y + rect.height / 2 + && (rect.x - gap == attachment.x || rect.x + rect.width + gap == attachment.x) + } else { + false + } +} + +fn rect_is_clear( + rect: Rect, + bounds: Rect, + occupied: &[Rect], + edges: &[SceneEdge], + stroke_width: i64, +) -> bool { + rect.width > 0 + && rect.height > 0 + && stroke_width >= 0 + && rect.x >= bounds.x + && rect.y >= bounds.y + && rect.x + rect.width <= bounds.x + bounds.width + && rect.y + rect.height <= bounds.y + bounds.height + && occupied.iter().all(|other| !rects_touch(rect, *other)) + && edges.iter().all(|edge| { + edge.path.windows(2).all(|segment| { + !stroke_hits_rect(segment[0], segment[1], rect, (stroke_width + 1) / 2) + }) + }) +} + +fn rects_touch(left: Rect, right: Rect) -> bool { + left.x <= right.x + right.width + && right.x <= left.x + left.width + && left.y <= right.y + right.height + && right.y <= left.y + left.height +} + +fn stroke_hits_rect(start: Point, end: Point, rect: Rect, radius: i64) -> bool { + if start == end || (start.x != end.x && start.y != end.y) { + return true; + } + let envelope = Rect { + x: start.x.min(end.x) - radius, + y: start.y.min(end.y) - radius, + width: (end.x - start.x).abs() + 2 * radius, + height: (end.y - start.y).abs() + 2 * radius, + }; + rects_touch(rect, envelope) +} + +#[cfg(test)] +mod tests { + use stack_compiler::ir::{EdgeDirection, EdgeKind}; + + use super::{dimensions, geometry_is_valid, place, place_next}; + use crate::routing::{Marker, Point, SceneEdge}; + use crate::scene::{self, Rect, SceneNode}; + + type TestResult = Result>; + + fn resources() -> TestResult<( + &'static stack_theme::Theme, + &'static stack_theme::FontMetrics, + )> { + let catalog = stack_theme::catalog(); + let theme = catalog + .themes + .first() + .ok_or("bundled catalog has no theme")?; + let metrics = catalog + .font_metrics + .iter() + .find(|metrics| metrics.id == theme.typography.font_metrics_id) + .ok_or("bundled theme has no font metrics")?; + Ok((theme, metrics)) + } + + fn bounds() -> Rect { + Rect { + x: 0, + y: 0, + width: 800_000, + height: 600_000, + } + } + + fn edge(label: Option<&str>, path: &[(i64, i64)]) -> SceneEdge { + SceneEdge { + from: "source".to_owned(), + to: "target".to_owned(), + direction: EdgeDirection::Forward, + kind: EdgeKind::Flow, + label: label.map(str::to_owned), + path: path.iter().map(|&(x, y)| Point { x, y }).collect(), + start_marker: Marker::None, + end_marker: Marker::Arrow, + label_anchor: None, + label_rect: None, + } + } + + fn place_labels(edges: &mut [SceneEdge], nodes: &[SceneNode], fixed: &[Rect]) -> TestResult { + let (theme, metrics) = resources()?; + place(edges, nodes, bounds(), fixed, theme, metrics) + .map_err(|_| "labels do not fit synthetic scene")?; + assert!(geometry_is_valid( + edges, + nodes, + bounds(), + fixed, + i64::from(theme.connector.width_milli_px), + )); + Ok(()) + } + + #[test] + fn dimensions_use_font_metrics_and_background_padding() -> TestResult { + let (theme, metrics) = resources()?; + let measured = dimensions("Read records", theme, metrics); + assert_eq!(measured.x, 0); + assert_eq!(measured.y, 0); + assert_eq!( + measured.width, + scene::text_width( + "Read records", + theme.typography.edge_label_size_milli_px, + metrics + ) + 12_000, + ); + assert_eq!( + measured.height, + scene::line_height(theme.typography.edge_label_size_milli_px, &theme.typography) + + 8_000, + ); + Ok(()) + } + + #[test] + fn wide_connector_labels_clear_their_own_painted_stroke() -> TestResult { + let (theme, metrics) = resources()?; + for width in [1_500, 15_000, 15_999, 16_000, 32_000] { + let mut theme = theme.clone(); + theme.connector.width_milli_px = width; + let mut edges = [edge( + Some("Call"), + &[(100_000, 300_000), (700_000, 300_000)], + )]; + place(&mut edges, &[], bounds(), &[], &theme, metrics) + .map_err(|_| "wide connector label failed")?; + assert!(geometry_is_valid( + &edges, + &[], + bounds(), + &[], + i64::from(width) + )); + let label = edges[0].label_rect.ok_or("missing label rectangle")?; + let gap = 300_000 - label.y - label.height; + assert!(gap > (i64::from(width) + 1) / 2); + if width == 1_500 { + assert_eq!(gap, 8_000); + } + } + Ok(()) + } + + #[test] + fn malformed_routes_cannot_produce_valid_label_attachments() -> TestResult { + let (theme, metrics) = resources()?; + for path in [ + vec![], + vec![(100_000, 100_000)], + vec![(100_000, 100_000), (100_000, 100_000)], + vec![(100_000, 100_000), (200_000, 200_000)], + ] { + let original = edge(Some("Call"), &path); + let mut candidate = original.clone(); + assert!(place_next(&mut candidate, &[], &[], bounds(), &[], theme, metrics).is_err()); + assert_eq!(candidate, original); + assert!(!geometry_is_valid(&[candidate], &[], bounds(), &[], 1_500)); + } + let rect = Rect { + x: 200_000, + y: 200_000, + width: 50_000, + height: 30_000, + }; + let start = Point { + x: 100_000, + y: 100_000, + }; + let end = Point { + x: 300_000, + y: 300_000, + }; + assert!(super::stroke_hits_rect(start, end, rect, 1_500)); + assert!(super::stroke_hits_rect(start, start, rect, -1)); + assert!(!super::is_adjacent(rect, start, start, end, 8_000)); + assert!(!super::is_adjacent(rect, start, start, start, 8_000)); + Ok(()) + } + + #[test] + fn horizontal_label_prefers_above_with_its_attachment_on_the_segment() -> TestResult { + let mut edges = [edge( + Some("HTTPS"), + &[(100_000, 200_000), (600_000, 200_000)], + )]; + place_labels(&mut edges, &[], &[])?; + let rect = edges[0].label_rect.ok_or("missing label rectangle")?; + let anchor = edges[0].label_anchor.ok_or("missing label attachment")?; + assert_eq!( + anchor, + Point { + x: 350_000, + y: 200_000 + } + ); + assert_eq!(rect.x + rect.width / 2, anchor.x); + assert_eq!(rect.y + rect.height + 8_000, anchor.y); + Ok(()) + } + + #[test] + fn long_vertical_label_uses_reserved_space_on_the_right() -> TestResult { + let mut edges = [edge( + Some("A long description of the database connection"), + &[(100_000, 100_000), (100_000, 500_000)], + )]; + place_labels(&mut edges, &[], &[])?; + let rect = edges[0].label_rect.ok_or("missing label rectangle")?; + let anchor = edges[0].label_anchor.ok_or("missing label attachment")?; + assert_eq!(rect.x, 108_000); + assert_eq!(rect.y + rect.height / 2, anchor.y); + assert_eq!(anchor.x, 100_000); + Ok(()) + } + + #[test] + fn unlabeled_routes_block_label_backgrounds() -> TestResult { + let mut edges = [ + edge(Some("HTTPS"), &[(100_000, 200_000), (600_000, 200_000)]), + edge(None, &[(0, 180_000), (800_000, 180_000)]), + ]; + place_labels(&mut edges, &[], &[])?; + assert!(edges[0].label_rect.ok_or("missing label rectangle")?.y > 200_000); + assert_eq!(edges[1].label_anchor, None); + assert_eq!(edges[1].label_rect, None); + Ok(()) + } + + #[test] + fn nodes_and_titles_can_force_a_non_midpoint_candidate() -> TestResult { + let mut edges = [edge( + Some("HTTPS"), + &[(100_000, 300_000), (700_000, 300_000)], + )]; + let nodes = [SceneNode { + id: "blocker".to_owned(), + parent_group_id: None, + rect: Rect { + x: 350_000, + y: 230_000, + width: 100_000, + height: 62_000, + }, + }]; + let titles = [Rect { + x: 0, + y: 308_000, + width: 800_000, + height: 80_000, + }]; + place_labels(&mut edges, &nodes, &titles)?; + assert_ne!( + edges[0].label_anchor.ok_or("missing label attachment")?.x, + 400_000 + ); + Ok(()) + } + + #[test] + fn labels_avoid_each_other_without_reordering_edges() -> TestResult { + let mut edges = [ + edge( + Some("First connection"), + &[(100_000, 250_000), (700_000, 250_000)], + ), + edge( + Some("Second connection"), + &[(100_000, 250_000), (700_000, 250_000)], + ), + edge(Some("Third"), &[(100_000, 250_000), (700_000, 250_000)]), + ]; + let original = edges.clone(); + place_labels(&mut edges, &[], &[])?; + for (index, placed) in edges.iter().enumerate() { + assert_eq!(placed.label, original[index].label); + assert_eq!(placed.path, original[index].path); + assert_eq!(placed.direction, original[index].direction); + } + let mut repeated = original; + place_labels(&mut repeated, &[], &[])?; + assert_eq!(edges, repeated); + Ok(()) + } + + #[test] + fn placement_failure_does_not_send_a_label_to_unrelated_distant_space() -> TestResult { + let (theme, metrics) = resources()?; + let mut edges = [edge( + Some("No room"), + &[(200_000, 200_000), (300_000, 200_000)], + )]; + let fixed = [Rect { + x: 100_000, + y: 100_000, + width: 300_000, + height: 200_000, + }]; + let original = edges.clone(); + assert!(place(&mut edges, &[], bounds(), &fixed, theme, metrics).is_err()); + assert_eq!(edges, original); + Ok(()) + } + + #[test] + fn labels_at_canvas_edges_keep_the_attachment_inside_the_segment() -> TestResult { + let mut edges = [edge(Some("Read"), &[(0, 10_000), (100_000, 10_000)])]; + place_labels(&mut edges, &[], &[])?; + let rect = edges[0].label_rect.ok_or("missing label rectangle")?; + let anchor = edges[0].label_anchor.ok_or("missing label attachment")?; + assert!(rect.x >= 0); + assert!(rect.y > 10_000); + assert!(anchor.x > 0 && anchor.x < 100_000); + Ok(()) + } + + #[test] + fn labels_too_wide_for_the_canvas_report_failure() -> TestResult { + let (theme, metrics) = resources()?; + let label = "A connection description ".repeat(50); + let mut edges = [edge( + Some(&label), + &[(100_000, 100_000), (100_000, 500_000)], + )]; + assert!(place(&mut edges, &[], bounds(), &[], theme, metrics).is_err()); + Ok(()) + } + + #[test] + fn validator_rejects_labels_outside_bounds_or_touching_fixed_text() -> TestResult { + let (theme, _) = resources()?; + let width = i64::from(theme.connector.width_milli_px); + let mut edges = [edge( + Some("HTTPS"), + &[(100_000, 200_000), (600_000, 200_000)], + )]; + place_labels(&mut edges, &[], &[])?; + let rect = edges[0].label_rect.ok_or("missing label rectangle")?; + let small_bounds = Rect { + height: 100_000, + ..bounds() + }; + assert!(!geometry_is_valid(&edges, &[], small_bounds, &[], width)); + let title = Rect { + y: rect.y - rect.height, + ..rect + }; + assert!(!geometry_is_valid(&edges, &[], bounds(), &[title], width)); + Ok(()) + } + + #[test] + fn validator_rejects_a_label_missing_its_rectangle_or_attached_far_away() -> TestResult { + let (theme, _) = resources()?; + let width = i64::from(theme.connector.width_milli_px); + let mut edges = [edge( + Some("HTTPS"), + &[(100_000, 200_000), (600_000, 200_000)], + )]; + place_labels(&mut edges, &[], &[])?; + let original = edges.clone(); + edges[0].label_rect = None; + assert!(!geometry_is_valid(&edges, &[], bounds(), &[], width)); + edges = original.clone(); + edges[0].label_anchor = Some(Point { + x: 350_000, + y: 400_000, + }); + assert!(!geometry_is_valid(&edges, &[], bounds(), &[], width)); + edges = original; + edges[0] + .label_rect + .as_mut() + .ok_or("missing label rectangle")? + .y -= 100_000; + assert!(!geometry_is_valid(&edges, &[], bounds(), &[], width)); + Ok(()) + } + + #[test] + fn validator_rejects_contact_with_the_stroke_envelope() -> TestResult { + let mut edges = vec![edge( + Some("HTTPS"), + &[(100_000, 200_000), (600_000, 200_000)], + )]; + place_labels(&mut edges, &[], &[])?; + let rect = edges[0].label_rect.ok_or("missing label rectangle")?; + edges.push(edge( + None, + &[(0, rect.y - 1_000), (800_000, rect.y - 1_000)], + )); + assert!(!geometry_is_valid(&edges, &[], bounds(), &[], 2_000)); + Ok(()) + } + + #[test] + fn validator_rejects_overlapping_labels_and_unlabeled_rectangles() -> TestResult { + let (theme, _) = resources()?; + let width = i64::from(theme.connector.width_milli_px); + let mut edges = vec![edge( + Some("HTTPS"), + &[(100_000, 200_000), (600_000, 200_000)], + )]; + place_labels(&mut edges, &[], &[])?; + edges.push(edges[0].clone()); + assert!(!geometry_is_valid(&edges, &[], bounds(), &[], width)); + edges.pop(); + edges[0].label = None; + assert!(!geometry_is_valid(&edges, &[], bounds(), &[], width)); + Ok(()) + } + + #[test] + fn place_next_preserves_previous_labels_and_routes() -> TestResult { + let (theme, metrics) = resources()?; + let mut previous = vec![edge( + Some("Already placed"), + &[(100_000, 250_000), (700_000, 250_000)], + )]; + place_labels(&mut previous, &[], &[])?; + let original = previous.clone(); + let mut next = edge( + Some("Next connection"), + &[(100_000, 250_000), (700_000, 250_000)], + ); + let original_next = next.clone(); + place_next(&mut next, &previous, &[], bounds(), &[], theme, metrics) + .map_err(|_| "next label does not fit")?; + assert_eq!(previous, original); + assert_eq!(next.path, original_next.path); + assert_eq!(next.label, original_next.label); + assert_ne!(next.label_rect, previous[0].label_rect); + previous.push(next); + assert!(geometry_is_valid( + &previous, + &[], + bounds(), + &[], + i64::from(theme.connector.width_milli_px), + )); + Ok(()) + } + + #[test] + fn place_next_avoids_previous_labels_all_routes_nodes_and_titles() -> TestResult { + let (theme, metrics) = resources()?; + let nodes = [SceneNode { + id: "blocker".to_owned(), + parent_group_id: None, + rect: Rect { + x: 460_000, + y: 208_000, + width: 150_000, + height: 37_000, + }, + }]; + let titles = [Rect { + x: 0, + y: 208_000, + width: 340_000, + height: 37_000, + }]; + let mut previous = vec![ + edge(Some("Old"), &[(100_000, 250_000), (700_000, 250_000)]), + edge(None, &[(0, 180_000), (800_000, 180_000)]), + ]; + place_labels(&mut previous, &nodes, &titles)?; + let mut next = edge(Some("HTTPS"), &[(100_000, 200_000), (700_000, 200_000)]); + place_next( + &mut next, + &previous, + &nodes, + bounds(), + &titles, + theme, + metrics, + ) + .map_err(|_| "next label does not fit beside the remaining segment")?; + let rect = next.label_rect.ok_or("missing next label rectangle")?; + assert!(rect.x > 610_000); + assert!(rect.y > 200_000); + previous.push(next); + assert!(geometry_is_valid( + &previous, + &nodes, + bounds(), + &titles, + i64::from(theme.connector.width_milli_px), + )); + Ok(()) + } + + #[test] + fn place_next_failure_preserves_the_entire_new_edge() -> TestResult { + let (theme, metrics) = resources()?; + let mut next = edge(Some("No room"), &[(200_000, 200_000), (300_000, 200_000)]); + place_labels(std::slice::from_mut(&mut next), &[], &[])?; + let original = next.clone(); + let occupied = [Rect { + x: 100_000, + y: 100_000, + width: 300_000, + height: 200_000, + }]; + assert!(place_next(&mut next, &[], &[], bounds(), &occupied, theme, metrics).is_err()); + assert_eq!(next, original); + Ok(()) + } + + #[test] + fn place_next_leaves_an_unlabeled_edge_without_label_metadata() -> TestResult { + let (theme, metrics) = resources()?; + let mut next = edge(Some("Old label"), &[(100_000, 200_000), (700_000, 200_000)]); + place_labels(std::slice::from_mut(&mut next), &[], &[])?; + next.label = None; + let original_path = next.path.clone(); + place_next(&mut next, &[], &[], bounds(), &[], theme, metrics) + .map_err(|_| "unlabeled edge placement failed")?; + assert_eq!(next.label_rect, None); + assert_eq!(next.label_anchor, None); + assert_eq!(next.path, original_path); + Ok(()) + } +} diff --git a/crates/stack-engine/src/layout_quality.rs b/crates/stack-engine/src/layout_quality.rs new file mode 100644 index 0000000..b6e8119 --- /dev/null +++ b/crates/stack-engine/src/layout_quality.rs @@ -0,0 +1,2153 @@ +//! Independent quality gates over the final SVG, not approved snapshot equality. +//! +//! This module exists only in test builds. Geometry is a logical layout envelope, +//! not a claim about platform-specific glyph rasterization or arbitrary SVG paths. + +mod geometry; + +use std::collections::BTreeSet; +use std::error::Error; +#[cfg(feature = "conformance")] +use std::fs; +#[cfg(feature = "conformance")] +use std::path::{Path, PathBuf}; + +use roxmltree::{Document, Node}; +#[cfg(feature = "conformance")] +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{Engine, scene}; +#[cfg(feature = "conformance")] +use crate::{ProviderAsset, ProviderPack}; +use geometry::{Point, Rect}; + +type Result = std::result::Result>; + +const MINIMUM_PAINTED_FRAME_CLEARANCE: i64 = 16_000; +const MINIMUM_LABEL_FRAME_CLEARANCE: i64 = 8_000; + +#[derive(Debug)] +struct TextBox { + id: String, + rect: Rect, +} + +#[derive(Debug)] +struct Route { + id: String, + from: String, + to: String, + points: Vec, + radius: i64, +} + +#[derive(Debug)] +struct GroupFrame { + id: String, + rect: Rect, + radius: i64, +} + +#[derive(Debug)] +struct Drawing { + bounds: Rect, + texts: Vec, + nodes: Vec<(String, Rect)>, + frames: Vec, + routes: Vec, +} + +fn rect(value: scene::Rect) -> Rect { + Rect { + x: value.x, + y: value.y, + width: value.width, + height: value.height, + } +} + +fn required<'a>(node: Node<'a, '_>, name: &str) -> Result<&'a str> { + node.attribute(name) + .ok_or_else(|| format!("missing {name} on {}", node.tag_name().name()).into()) +} + +fn pixel_value(value: &str) -> Result { + // SVG uses pixel decimals; geometry stays in exact integer milli-pixels. + // Reject SVG's wider numeric grammar rather than silently rounding it. + let (negative, magnitude) = value + .strip_prefix('-') + .map_or((false, value), |magnitude| (true, magnitude)); + let (whole, fraction) = match magnitude.split_once('.') { + Some((whole, fraction)) => { + if fraction.is_empty() + || fraction.len() > 3 + || !fraction.bytes().all(|digit| digit.is_ascii_digit()) + { + return Err("unsupported pixel fraction".into()); + } + (whole, fraction) + } + None => (magnitude, ""), + }; + if whole.is_empty() + || !whole.bytes().all(|digit| digit.is_ascii_digit()) + || (whole.len() > 1 && whole.starts_with('0')) + { + return Err("unsupported pixel integer".into()); + } + let whole: i128 = whole.parse()?; + let fraction = fraction + .bytes() + .chain(std::iter::repeat_n(b'0', 3 - fraction.len())) + .fold(0_i128, |value, digit| value * 10 + i128::from(digit - b'0')); + let magnitude = whole + .checked_mul(1000) + .and_then(|value| value.checked_add(fraction)) + .ok_or("pixel coordinate overflow")?; + let value = if negative { -magnitude } else { magnitude }; + Ok(i64::try_from(value)?) +} + +fn number(node: Node<'_, '_>, name: &str) -> Result { + pixel_value(required(node, name)?) +} + +fn rectangle(node: Node<'_, '_>) -> Result { + let value = Rect { + x: number(node, "x")?, + y: number(node, "y")?, + width: number(node, "width")?, + height: number(node, "height")?, + }; + if value.width <= 0 || value.height <= 0 { + return Err("non-positive rectangle".into()); + } + Ok(value) +} + +fn is_embedded(node: Node<'_, '_>) -> bool { + node.ancestors() + .skip(1) + .any(|ancestor| ancestor.has_tag_name("svg") && ancestor.parent_element().is_some()) +} + +fn text_rectangle( + node: Node<'_, '_>, + prepared: &crate::PreparedScene<'_>, + size: u32, +) -> Result { + let metrics = prepared.resources.metrics; + let width = scene::text_width(node.text().ok_or("empty text")?, size, metrics); + let ascent = (i64::from(metrics.ascent) * i64::from(size) + i64::from(metrics.units_per_em) + - 1) + / i64::from(metrics.units_per_em); + let descent = (i64::from(-metrics.descent) * i64::from(size) + i64::from(metrics.units_per_em) + - 1) + / i64::from(metrics.units_per_em); + let height = + scene::line_height(size, &prepared.resources.theme.typography).max(ascent + descent); + let offset = match node.attribute("text-anchor").unwrap_or("start") { + "start" => 0, + "middle" => width / 2, + "end" => width, + _ => return Err("unsupported text-anchor".into()), + }; + let x = number(node, "x")? + .checked_sub(offset) + .ok_or("text coordinate overflow")?; + let y = number(node, "y")?; + let offset = match node.attribute("dominant-baseline").unwrap_or("alphabetic") { + "alphabetic" => ascent + (height - ascent - descent) / 2, + // A deterministic layout reservation, not the browser's exact glyph box: + // SVG middle depends on the font's x-height, absent from current metrics. + "middle" => height / 2, + _ => return Err("unsupported dominant-baseline".into()), + }; + let top = y.checked_sub(offset).ok_or("text coordinate overflow")?; + Ok(Rect { + x, + y: top, + width, + height, + }) +} + +fn point_list(value: &str) -> Result> { + value + .split_whitespace() + .map(|pair| { + let (x, y) = pair.split_once(',').ok_or("invalid point")?; + Ok(Point { + x: pixel_value(x)?, + y: pixel_value(y)?, + }) + }) + .collect() +} + +fn point_envelope(points: &[Point]) -> Result { + let x = points + .iter() + .map(|point| point.x) + .min() + .ok_or("empty geometry")?; + let y = points + .iter() + .map(|point| point.y) + .min() + .ok_or("empty geometry")?; + let right = points + .iter() + .map(|point| point.x) + .max() + .ok_or("empty geometry")?; + let bottom = points + .iter() + .map(|point| point.y) + .max() + .ok_or("empty geometry")?; + let width = right.checked_sub(x).ok_or("geometry overflow")?; + let height = bottom.checked_sub(y).ok_or("geometry overflow")?; + if width <= 0 || height <= 0 { + return Err("non-positive geometry envelope".into()); + } + Ok(Rect { + x, + y, + width, + height, + }) +} + +fn shape_envelope(node: Node<'_, '_>) -> Result { + match node.tag_name().name() { + "rect" => rectangle(node), + "circle" | "ellipse" => { + let rx = number( + node, + if node.has_tag_name("circle") { + "r" + } else { + "rx" + }, + )?; + let ry = number( + node, + if node.has_tag_name("circle") { + "r" + } else { + "ry" + }, + )?; + if rx <= 0 || ry <= 0 { + return Err("invalid shape radius".into()); + } + Ok(Rect { + x: number(node, "cx")? + .checked_sub(rx) + .ok_or("geometry overflow")?, + y: number(node, "cy")? + .checked_sub(ry) + .ok_or("geometry overflow")?, + width: rx.checked_mul(2).ok_or("geometry overflow")?, + height: ry.checked_mul(2).ok_or("geometry overflow")?, + }) + } + "polygon" => point_envelope(&point_list(required(node, "points")?)?), + "path" => { + // Only the current absolute cylinder grammar is supported. Control + // points bound each Bezier's convex hull, not its exact painted arc. + let tokens = required(node, "d")?.split_whitespace().collect::>(); + if tokens.len() != 22 + || tokens[0] != "M" + || tokens[3] != "V" + || tokens[5] != "C" + || tokens[12] != "V" + || tokens[14] != "C" + || tokens[21] != "Z" + { + return Err("unsupported node path grammar".into()); + } + let n = |index: usize| pixel_value(tokens[index]); + let mut points = vec![Point { x: n(1)?, y: n(2)? }, Point { x: n(1)?, y: n(4)? }]; + for start in [6, 8, 10, 15, 17, 19] { + points.push(Point { + x: n(start)?, + y: n(start + 1)?, + }); + } + points.push(Point { + x: n(10)?, + y: n(13)?, + }); + point_envelope(&points) + } + _ => Err("unsupported node shape".into()), + } +} + +fn drawing_layer<'a, 'input>(root: Node<'a, 'input>, name: &str) -> Result> { + let layers = root + .children() + .filter(|node| node.attribute("data-stack-layer") == Some(name)) + .collect::>(); + if layers.len() != 1 || !layers[0].has_tag_name("g") { + return Err(format!("expected one visible {name} group layer").into()); + } + Ok(layers[0]) +} + +fn viewport_dimension(node: Node<'_, '_>, name: &str) -> Result { + let value = number(node, name)?; + if value <= 0 { + return Err("non-positive viewport".into()); + } + Ok(value) +} + +fn node_envelopes( + root: Node<'_, '_>, + prepared: &crate::PreparedScene<'_>, +) -> Result> { + let layer = drawing_layer(root, "nodes")?; + let groups = layer + .children() + .filter(Node::is_element) + .collect::>(); + if groups.len() != prepared.scene.nodes.len() { + return Err("node inventory drift".into()); + } + let mut nodes = Vec::new(); + for (group, expected) in groups.into_iter().zip(&prepared.scene.nodes) { + if !group.has_tag_name("g") + || !group.has_attribute("data-node-kind") + || required(group, "data-stack-id")? != expected.id + { + return Err("node inventory drift".into()); + } + let shapes = group + .children() + .filter(Node::is_element) + .filter(|node| !matches!(node.tag_name().name(), "title" | "text" | "svg")) + .collect::>(); + let shape = prepared + .resources + .node(&expected.id) + .ok_or("missing resolved node")? + .visual + .shape; + let expected_tags: &[&str] = match shape { + stack_theme::NodeShape::RoundedRectangle | stack_theme::NodeShape::Capsule => &["rect"], + stack_theme::NodeShape::Circle => &["circle"], + stack_theme::NodeShape::Cylinder => &["path", "ellipse"], + stack_theme::NodeShape::Hexagon => &["polygon"], + }; + if shapes + .iter() + .map(|node| node.tag_name().name()) + .collect::>() + != expected_tags + { + return Err("node shape inventory drift".into()); + } + let mut paint_order = vec!["title"]; + paint_order.extend_from_slice(expected_tags); + paint_order.push("svg"); + paint_order.extend( + group + .children() + .filter(|node| node.has_tag_name("text")) + .map(|_| "text"), + ); + if group + .children() + .filter(Node::is_element) + .map(|node| node.tag_name().name()) + .collect::>() + != paint_order + { + return Err("node paint order drift".into()); + } + let envelope = shape_envelope(shapes[0])?; + if envelope != rect(expected.rect) { + return Err("SVG/scene node envelope drift".into()); + } + for detail in &shapes[1..] { + if !geometry::contains(envelope, shape_envelope(*detail)?) { + return Err("node decoration outside layout envelope".into()); + } + } + let icons = group + .children() + .filter(|node| node.has_tag_name("svg")) + .collect::>(); + if icons.len() != 1 + || required(icons[0], "data-icon-id")? + != prepared + .resources + .node(&expected.id) + .ok_or("missing resolved node")? + .icon_id + || !geometry::contains(envelope, rectangle(icons[0])?) + { + return Err("unverified icon SVG viewport".into()); + } + nodes.push((expected.id.clone(), envelope)); + } + Ok(nodes) +} + +fn group_frames( + root: Node<'_, '_>, + prepared: &crate::PreparedScene<'_>, +) -> Result> { + let groups = drawing_layer(root, "groups")? + .children() + .filter(Node::is_element) + .collect::>(); + if groups.len() != prepared.scene.groups.len() { + return Err("group frame inventory drift".into()); + } + let mut frames = Vec::new(); + for (group, expected) in groups.into_iter().zip(&prepared.scene.groups) { + if !group.has_tag_name("g") + || group.has_attribute("data-node-kind") + || required(group, "data-stack-id")? != expected.id + { + return Err("group frame owner drift".into()); + } + let elements = group + .children() + .filter(Node::is_element) + .collect::>(); + if elements + .iter() + .map(|element| element.tag_name().name()) + .collect::>() + != ["title", "rect", "text"] + { + return Err("group frame shape inventory drift".into()); + } + let element = elements[1]; + let envelope = rectangle(element)?; + if envelope != rect(expected.rect) { + return Err("SVG/scene group frame drift".into()); + } + if matches!(required(element, "stroke")?, "none" | "transparent") + || element.ancestors().any(|ancestor| { + [ + "stroke-opacity", + "stroke-dasharray", + "stroke-dashoffset", + "vector-effect", + ] + .iter() + .any(|attribute| ancestor.has_attribute(*attribute)) + }) + { + return Err("unsupported group frame stroke".into()); + } + let width = number(element, "stroke-width")?; + if width <= 0 { + return Err("non-positive group frame stroke width".into()); + } + frames.push(GroupFrame { + id: expected.id.clone(), + rect: envelope, + radius: width / 2 + width % 2, + }); + } + Ok(frames) +} + +fn read_drawing( + svg: &str, + prepared: &crate::PreparedScene<'_>, + diagram: &stack_compiler::ir::Diagram, +) -> Result { + let document = Document::parse(svg)?; + if document.descendants().any(|node| node.is_pi()) { + return Err("unsupported SVG processing instruction".into()); + } + let root = document.root_element(); + if !root.has_tag_name(("http://www.w3.org/2000/svg", "svg")) { + return Err("expected SVG root".into()); + } + let view_box = required(root, "viewBox")? + .split_whitespace() + .map(pixel_value) + .collect::>>()?; + let expected = prepared.scene.bounds; + if view_box != [expected.x, expected.y, expected.width, expected.height] { + return Err("SVG/scene bounds drift".into()); + } + if viewport_dimension(root, "width")? != expected.width + || viewport_dimension(root, "height")? != expected.height + { + return Err("SVG/scene viewport drift".into()); + } + let nodes_layer = drawing_layer(root, "nodes")?; + let groups_layer = drawing_layer(root, "groups")?; + let edge_layer = drawing_layer(root, "edges")?; + let labels_layer = drawing_layer(root, "edge-labels")?; + let paint_order = root + .children() + .filter(Node::is_element) + .filter(|node| { + !matches!( + node.tag_name().name(), + "title" | "desc" | "metadata" | "defs" + ) + }) + .map(|node| { + node.attribute("data-stack-layer") + .unwrap_or(node.tag_name().name()) + }) + .collect::>(); + if paint_order != ["rect", "text", "groups", "edges", "nodes", "edge-labels"] { + return Err("root paint order drift".into()); + } + for tag in ["title", "desc", "metadata", "rect", "defs", "text"] { + if root + .children() + .filter(|node| node.has_tag_name(tag)) + .count() + != 1 + { + return Err(format!("unexpected root {tag} inventory").into()); + } + } + // The owned drawing uses untransformed pixel decimals. A new transform or + // text positioning mode must be supported, not silently skipped. + for node in root + .descendants() + .filter(Node::is_element) + .filter(|node| !is_embedded(*node)) + { + if node.tag_name().namespace() != Some("http://www.w3.org/2000/svg") { + return Err("unexpected drawing namespace".into()); + } + if node.has_attribute("data-stack-layer") + && (node.parent_element() != Some(root) + || ![nodes_layer, groups_layer, edge_layer, labels_layer].contains(&node)) + { + return Err("unexpected drawing layer".into()); + } + let parent = node.parent_element(); + let in_group = |layer| { + parent.is_some_and(|parent| { + parent.has_tag_name("g") && parent.parent_element() == Some(layer) + }) + }; + let in_root = parent == Some(root); + let in_marker = parent.is_some_and(|parent| { + parent.has_tag_name("marker") + && parent.parent_element().is_some_and(|defs| { + defs.has_tag_name("defs") && defs.parent_element() == Some(root) + }) + }); + let positioned = match node.tag_name().name() { + "svg" => node == root || (in_group(nodes_layer) && node.has_attribute("data-icon-id")), + "g" => { + [nodes_layer, groups_layer, edge_layer, labels_layer].contains(&node) + || parent.is_some_and(|parent| { + [nodes_layer, groups_layer, edge_layer, labels_layer].contains(&parent) + }) + } + "text" => { + in_root || in_group(nodes_layer) || in_group(groups_layer) || in_group(labels_layer) + } + "title" => { + in_root || in_group(nodes_layer) || in_group(groups_layer) || in_group(edge_layer) + } + "desc" | "metadata" | "defs" => in_root, + "marker" => parent.is_some_and(|parent| { + parent.has_tag_name("defs") && parent.parent_element() == Some(root) + }), + "rect" => { + in_root || in_group(nodes_layer) || in_group(groups_layer) || in_group(labels_layer) + } + "circle" | "ellipse" | "polygon" => in_group(nodes_layer), + "path" => in_group(nodes_layer) || in_marker, + "polyline" => in_group(edge_layer), + _ => false, + }; + if !positioned { + return Err(format!( + "unsupported drawing element placement: {}", + node.tag_name().name() + ) + .into()); + } + if node.has_attribute("stroke-opacity") + || (node.has_attribute("fill-opacity") + && !(node.has_tag_name("rect") && in_group(groups_layer))) + { + return Err("unsupported drawing paint opacity".into()); + } + for attribute in [ + "transform", + "style", + "clip-path", + "mask", + "display", + "visibility", + "opacity", + "filter", + "dx", + "dy", + "rotate", + "textLength", + "lengthAdjust", + "alignment-baseline", + "baseline-shift", + "writing-mode", + "direction", + "letter-spacing", + "word-spacing", + "font-style", + "font-stretch", + ] { + if node.has_attribute(attribute) { + return Err(format!("unsupported drawing attribute {attribute}").into()); + } + } + if !node.has_tag_name("text") + && [ + "dominant-baseline", + "text-anchor", + "font-family", + "font-size", + "font-weight", + ] + .iter() + .any(|attribute| node.has_attribute(*attribute)) + { + return Err("unsupported inherited text properties".into()); + } + } + let mut texts = Vec::new(); + let mut owners = BTreeSet::new(); + let mut edge_label_index = 0; + for node in root + .descendants() + .filter(|node| node.has_tag_name("text") && !is_embedded(*node)) + { + if node.children().count() != 1 + || !node.first_child().is_some_and(|child| child.is_text()) + || ["dx", "dy", "rotate", "textLength", "lengthAdjust"] + .iter() + .any(|attribute| node.has_attribute(*attribute)) + { + return Err("unsupported text positioning".into()); + } + let parent = node.parent_element().ok_or("text has no parent")?; + let value = node.text().ok_or("empty semantic text")?; + if required(node, "font-family")? != prepared.resources.metrics.family { + return Err("text uses unmeasured font family".into()); + } + let size = u32::try_from(number(node, "font-size")?)?; + if !visible_color(required(node, "fill")?) { + return Err("invisible semantic text".into()); + } + let text_bounds = text_rectangle(node, prepared, size)?; + let (id, owner, bounds) = if parent.has_attribute("data-edge-label") { + if !parent.has_tag_name("g") || parent.parent_element() != Some(labels_layer) { + return Err("edge label outside visible label layer".into()); + } + let expected_label = diagram + .edges + .iter() + .filter_map(|edge| edge.label.as_deref()) + .nth(edge_label_index) + .ok_or("unexpected edge label")?; + if value != expected_label || required(parent, "data-edge-label")? != expected_label { + return Err("edge label inventory drift".into()); + } + if size != prepared.resources.theme.typography.edge_label_size_milli_px { + return Err("edge label font size drift".into()); + } + let backgrounds = parent + .children() + .filter(|child| child.has_tag_name("rect")) + .collect::>(); + if backgrounds.len() != 1 { + return Err("edge label background inventory drift".into()); + } + if parent + .children() + .filter(Node::is_element) + .map(|child| child.tag_name().name()) + .collect::>() + != ["rect", "text"] + { + return Err("edge label paint order drift".into()); + } + let background = rectangle(backgrounds[0])?; + if !geometry::contains(background, text_bounds) { + return Err("edge label text outside its background".into()); + } + let id = format!("edge-label:{edge_label_index}:{value}"); + let owner = format!("edge-label:{edge_label_index}"); + edge_label_index += 1; + (id, owner, background) + } else { + let (owner, expected_text, expected_size) = if let Some(id) = + parent.attribute("data-stack-id") + { + if parent.has_attribute("data-node-kind") { + if !parent.has_tag_name("g") || parent.parent_element() != Some(nodes_layer) { + return Err("node text outside visible node layer".into()); + } + let authored = diagram + .nodes + .iter() + .find(|candidate| candidate.id == id) + .ok_or("unknown text node")?; + let ordinal = parent + .children() + .filter(|child| child.has_tag_name("text")) + .position(|child| child == node) + .ok_or("text position missing")?; + match ordinal { + 0 => ( + format!("node:{id}:label"), + authored.label.as_str(), + prepared.resources.theme.typography.node_label_size_milli_px, + ), + 1 => ( + format!("node:{id}:detail"), + authored.detail.as_deref().ok_or("unexpected node detail")?, + prepared + .resources + .theme + .typography + .node_detail_size_milli_px, + ), + _ => return Err("unexpected node text".into()), + } + } else { + if !parent.has_tag_name("g") || parent.parent_element() != Some(groups_layer) { + return Err("group title outside visible group layer".into()); + } + let group = diagram + .groups + .iter() + .find(|candidate| candidate.id == id) + .ok_or("unknown group title")?; + ( + format!("group:{id}:title"), + group.label.as_str(), + prepared + .resources + .theme + .typography + .group_label_size_milli_px, + ) + } + } else if parent == root { + ( + "diagram:title".to_owned(), + diagram.title.as_str(), + prepared + .resources + .theme + .typography + .group_label_size_milli_px, + ) + } else { + return Err("unknown semantic text owner".into()); + }; + if value != expected_text || size != expected_size { + return Err("semantic text content or size drift".into()); + } + let id = format!( + "text:{}:{value}", + parent.attribute("data-stack-id").unwrap_or("diagram-title") + ); + (id, owner, text_bounds) + }; + if !owners.insert(owner) { + return Err("duplicate semantic text owner".into()); + } + texts.push(TextBox { id, rect: bounds }); + } + let expected_texts = 1 + + diagram.groups.len() + + diagram.nodes.len() + + diagram + .nodes + .iter() + .filter(|node| node.detail.is_some()) + .count() + + diagram + .edges + .iter() + .filter(|edge| edge.label.is_some()) + .count(); + if texts.len() != expected_texts + || edge_label_index + != diagram + .edges + .iter() + .filter(|edge| edge.label.is_some()) + .count() + { + return Err("semantic text inventory drift".into()); + } + let nodes = node_envelopes(root, prepared)?; + let frames = group_frames(root, prepared)?; + let mut routes = Vec::new(); + for (index, group) in edge_layer.children().filter(Node::is_element).enumerate() { + if !group.has_tag_name("g") { + return Err("edge outside visible group".into()); + } + let expected = prepared.scene.edges.get(index).ok_or("unexpected edge")?; + let lines = group + .children() + .filter(|node| node.has_tag_name("polyline")) + .collect::>(); + if lines.len() != 1 { + return Err("unsupported edge geometry: expected one polyline".into()); + } + let line = lines[0]; + let points = point_list(required(line, "points")?)?; + let expected_points = expected + .path + .iter() + .map(|point| Point { + x: point.x, + y: point.y, + }) + .collect::>(); + if points != expected_points { + return Err("SVG/scene route drift".into()); + } + if points.len() < 2 + || points.windows(2).any(|pair| { + pair[0] == pair[1] || (pair[0].x != pair[1].x && pair[0].y != pair[1].y) + }) + { + return Err("unsupported non-orthogonal or degenerate route".into()); + } + let width = number(line, "stroke-width")?; + if !visible_color(required(line, "stroke")?) { + return Err("invisible edge stroke".into()); + } + if width <= 0 { + return Err("invalid stroke width".into()); + } + routes.push(Route { + id: format!("edge:{index}:{}->{}", expected.from, expected.to), + from: expected.from.clone(), + to: expected.to.clone(), + points, + radius: width / 2 + width % 2, + }); + } + if routes.len() != diagram.edges.len() { + return Err("edge inventory drift".into()); + } + Ok(Drawing { + bounds: rect(expected), + texts, + nodes, + frames, + routes, + }) +} + +fn rectangle_json(rect: Rect) -> Value { + json!({"x":rect.x,"y":rect.y,"width":rect.width,"height":rect.height}) +} + +fn visible_color(color: &str) -> bool { + let Some(hex) = color.strip_prefix('#') else { + return false; + }; + matches!(hex.len(), 6 | 8) + && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + && (hex.len() == 6 || &hex[6..] != "00") +} + +#[test] +fn paint_colors_require_nontransparent_literal_hex() { + for color in ["#123456", "#ABCDEF", "#123456ff", "#12345601"] { + assert!(visible_color(color)); + } + for color in [ + "none", + "transparent", + "currentColor", + "url(#paint)", + "#123", + "#12345600", + "#12zzzz", + "#123456789", + "#123456é", + ] { + assert!(!visible_color(color)); + } +} + +fn composition_metrics(drawing: &Drawing) -> Value { + let mut length = 0_i64; + let mut bends = 0; + for route in &drawing.routes { + for pair in route.points.windows(2) { + length += (pair[0].x - pair[1].x).abs() + (pair[0].y - pair[1].y).abs(); + } + for triple in route.points.windows(3) { + if (triple[0].x == triple[1].x) != (triple[1].x == triple[2].x) { + bends += 1; + } + } + } + json!({"widthMilliPx":drawing.bounds.width,"heightMilliPx":drawing.bounds.height,"routeLengthMilliPx":length,"bends":bends,"nodes":drawing.nodes.len(),"textBoxes":drawing.texts.len(),"edges":drawing.routes.len()}) +} + +fn text_violations(drawing: &Drawing) -> Vec { + let mut violations = Vec::new(); + for (index, text) in drawing.texts.iter().enumerate() { + if !geometry::contains(drawing.bounds, text.rect) { + violations.push(json!({"kind":"text-out-of-bounds","entity":text.id,"rect":rectangle_json(text.rect)})); + } + for other in &drawing.texts[index + 1..] { + if geometry::overlaps(text.rect, other.rect) { + violations.push(json!({"kind":"text-overlap","entities":[text.id,other.id],"rectangles":[rectangle_json(text.rect),rectangle_json(other.rect)]})); + } + } + } + violations +} + +fn edge_violations(drawing: &Drawing) -> Result> { + let mut violations = Vec::new(); + for route in &drawing.routes { + for text in &drawing.texts { + for pair in route.points.windows(2) { + if geometry::segment_hits_rect(pair[0], pair[1], text.rect, route.radius)? { + violations.push(json!({"kind":"edge-text-collision","entities":[route.id,text.id],"rect":rectangle_json(text.rect)})); + break; + } + } + } + for (id, bounds) in &drawing.nodes { + for (index, pair) in route.points.windows(2).enumerate() { + let allow_start = index == 0 && id == &route.from; + let allow_end = index + 2 == route.points.len() && id == &route.to; + if geometry::segment_hits_node( + pair[0], + pair[1], + *bounds, + route.radius, + allow_start, + allow_end, + )? { + violations.push(json!({"kind":"edge-node-collision","entities":[route.id,id],"rect":rectangle_json(*bounds)})); + break; + } + } + } + } + Ok(violations) +} + +fn frame_violations(drawing: &Drawing) -> Result> { + let mut violations = Vec::new(); + for route in &drawing.routes { + for frame in &drawing.frames { + let clearance = MINIMUM_PAINTED_FRAME_CLEARANCE + .checked_add(route.radius) + .and_then(|value| value.checked_add(frame.radius)) + .ok_or("frame clearance overflow")?; + for (index, pair) in route.points.windows(2).enumerate() { + if geometry::parallel_frame_contact(pair[0], pair[1], frame.rect, clearance)? { + violations.push(json!({ + "kind":"edge-group-frame-clearance", + "entities":[route.id,frame.id], + "segmentIndex":index, + "segment":[{"x":pair[0].x,"y":pair[0].y},{"x":pair[1].x,"y":pair[1].y}], + "rect":rectangle_json(frame.rect), + "requiredCenterlineClearanceMilliPx":clearance, + "routeStrokeRadiusMilliPx":route.radius, + "frameStrokeRadiusMilliPx":frame.radius + })); + } + } + } + } + for label in drawing + .texts + .iter() + .filter(|text| text.id.starts_with("edge-label:")) + { + for frame in &drawing.frames { + let clearance = MINIMUM_LABEL_FRAME_CLEARANCE + .checked_add(frame.radius) + .ok_or("label frame clearance overflow")?; + if geometry::label_frame_contact(label.rect, frame.rect, clearance)? { + violations.push(json!({ + "kind":"label-group-frame-clearance", + "entities":[label.id,frame.id], + "labelRect":rectangle_json(label.rect), + "frameRect":rectangle_json(frame.rect), + "requiredCenterlineClearanceMilliPx":clearance, + "frameStrokeRadiusMilliPx":frame.radius + })); + } + } + } + Ok(violations) +} + +#[cfg(feature = "conformance")] +#[derive(Deserialize)] +struct Catalog { + cases: Vec, +} +#[cfg(feature = "conformance")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Case { + id: String, + source: String, + provider_fixture: Option, +} +#[cfg(feature = "conformance")] +#[derive(Deserialize)] +struct PackInput { + manifest: stack_theme::ProviderPack, + assets: Vec, +} +#[cfg(feature = "conformance")] +#[derive(Deserialize)] +struct AssetInput { + path: String, + svg: String, +} + +#[cfg(feature = "conformance")] +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +#[cfg(feature = "conformance")] +fn run_corpus(gate: &str) -> Result<()> { + let root = root(); + let catalog: Catalog = + serde_json::from_slice(&fs::read(root.join("layout-corpus/catalog.json"))?)?; + if catalog.cases.len() != 7 { + return Err("review corpus inventory changed; update quality coverage explicitly".into()); + } + let output_dir = root.join("target/layout-quality").join(gate); + fs::create_dir_all(&output_dir)?; + let mut results = Vec::new(); + let mut count = 0; + for case in catalog.cases { + let source = fs::read(root.join("layout-corpus").join(case.source))?; + let mut packs = Vec::new(); + if let Some(provider) = case.provider_fixture { + let inputs: Vec = + serde_json::from_slice(&fs::read(root.join("layout-corpus").join(provider))?)?; + for input in inputs { + packs.push(ProviderPack::new( + input.manifest, + input + .assets + .into_iter() + .map(|asset| ProviderAsset::new(asset.path, asset.svg)) + .collect(), + )?); + } + } + let engine = Engine::with_provider_packs(&packs)?; + let output = engine.render(&source)?; + if !output.diagnostics.is_empty() { + return Err(format!("{}: unexpected diagnostics", case.id).into()); + } + let svg = output.svg.ok_or("render returned no SVG")?; + let compiled = stack_compiler::compile_bytes_with_source_map(&source); + let diagram = compiled.diagram.as_ref().ok_or("no compiled diagram")?; + let prepared = engine.prepare_scene( + diagram, + compiled.source_map.as_ref().ok_or("no source map")?, + )?; + let drawing = read_drawing(&svg, &prepared, diagram)?; + let violations = match gate { + "text" => text_violations(&drawing), + "edge" => edge_violations(&drawing)?, + "frame" => frame_violations(&drawing)?, + _ => return Err("unknown layout quality gate".into()), + }; + count += violations.len(); + fs::write(output_dir.join(format!("{}.svg", case.id)), svg)?; + eprintln!("{}: {} {gate} violation(s)", case.id, violations.len()); + results.push(json!({"id":case.id,"violations":violations,"composition":composition_metrics(&drawing)})); + } + let mut report = json!({"schemaVersion":"1.0","gate":gate,"units":"1/1000 CSS px","engineVersion":crate::ENGINE_VERSION,"limitations":["logical text envelopes, not raster glyph bounds","middle-baseline reservation is centered line height, not font x-height measurement","node SVG and Bezier control-point envelopes, not exact painted shape outlines","arrowheads and group frame collisions are not measured","global composition metrics are not a beauty score"],"violations":count,"cases":results}); + if gate == "frame" { + report["minimumPaintedClearanceMilliPx"] = json!(MINIMUM_PAINTED_FRAME_CLEARANCE); + report["minimumLabelPaintedFrameClearanceMilliPx"] = json!(MINIMUM_LABEL_FRAME_CLEARANCE); + report["limitations"] = json!([ + "finite group frame centerline rectangles with conservative rounded-corner clearance", + "perpendicular crossings away from corners are allowed", + "arrowheads are not measured", + "global composition metrics are not a beauty score" + ]); + } + fs::write( + output_dir.join("report.json"), + serde_json::to_vec_pretty(&report)?, + )?; + if count > 0 { + return Err(format!( + "{count} {gate} quality violations; inspect {}", + output_dir.display() + ) + .into()); + } + Ok(()) +} + +#[test] +#[cfg(feature = "conformance")] +fn corpus_text_quality() -> Result<()> { + run_corpus("text") +} + +#[test] +#[cfg(feature = "conformance")] +fn corpus_edge_quality() -> Result<()> { + run_corpus("edge") +} + +#[test] +#[cfg(feature = "conformance")] +fn corpus_frame_quality() -> Result<()> { + run_corpus("frame") +} + +#[test] +fn detector_flags_text_collision_and_clipping_without_requiring_a_snapshot() { + let drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + texts: vec![ + TextBox { + id: "title".into(), + rect: Rect { + x: 0, + y: 0, + width: 40, + height: 20, + }, + }, + TextBox { + id: "label".into(), + rect: Rect { + x: -5, + y: 10, + width: 30, + height: 20, + }, + }, + ], + nodes: vec![], + frames: vec![], + routes: vec![], + }; + let violations = text_violations(&drawing); + assert_eq!(violations.len(), 2); + assert!( + violations + .iter() + .any(|value| value["kind"] == "text-overlap") + ); + assert!( + violations + .iter() + .any(|value| value["kind"] == "text-out-of-bounds") + ); +} + +#[test] +fn detector_allows_separated_text_inside_the_canvas() { + let drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + texts: vec![ + TextBox { + id: "left".into(), + rect: Rect { + x: 0, + y: 0, + width: 40, + height: 20, + }, + }, + TextBox { + id: "right".into(), + rect: Rect { + x: 60, + y: 0, + width: 40, + height: 20, + }, + }, + ], + nodes: vec![], + frames: vec![], + routes: vec![], + }; + assert!(text_violations(&drawing).is_empty()); +} + +#[test] +fn metrics_do_not_trade_collisions_for_a_smaller_drawing() { + let drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + texts: vec![], + nodes: vec![], + frames: vec![], + routes: vec![Route { + id: "edge:0".into(), + from: "a".into(), + to: "b".into(), + radius: 1, + points: vec![ + Point { x: 10, y: 10 }, + Point { x: 40, y: 10 }, + Point { x: 40, y: 20 }, + Point { x: 40, y: 40 }, + ], + }], + }; + let metrics = composition_metrics(&drawing); + assert_eq!(metrics["routeLengthMilliPx"], 60); + assert_eq!(metrics["bends"], 1); + assert!(metrics.get("beautyScore").is_none()); +} + +#[test] +fn detector_does_not_exempt_an_edges_own_label() -> Result<()> { + let drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + texts: vec![TextBox { + id: "edge-label:0:Request".into(), + rect: Rect { + x: 40, + y: 40, + width: 20, + height: 20, + }, + }], + nodes: vec![], + frames: vec![], + routes: vec![Route { + id: "edge:0".into(), + from: "a".into(), + to: "b".into(), + points: vec![Point { x: 0, y: 50 }, Point { x: 100, y: 50 }], + radius: 1, + }], + }; + let violations = edge_violations(&drawing)?; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0]["kind"], "edge-text-collision"); + Ok(()) +} + +#[test] +fn detector_allows_only_terminal_node_contact() -> Result<()> { + let mut drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 100, + height: 100, + }, + texts: vec![], + nodes: vec![ + ( + "a".into(), + Rect { + x: 0, + y: 0, + width: 20, + height: 20, + }, + ), + ( + "b".into(), + Rect { + x: 80, + y: 0, + width: 20, + height: 20, + }, + ), + ], + frames: vec![], + routes: vec![Route { + id: "edge:0".into(), + from: "a".into(), + to: "b".into(), + points: vec![Point { x: 20, y: 10 }, Point { x: 80, y: 10 }], + radius: 1, + }], + }; + assert!(edge_violations(&drawing)?.is_empty()); + drawing.nodes.push(( + "unrelated".into(), + Rect { + x: 40, + y: 0, + width: 20, + height: 20, + }, + )); + let violations = edge_violations(&drawing)?; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0]["entities"][1], "unrelated"); + // Re-entering the source on a later segment must not be exempted. + drawing.routes[0].points = vec![ + Point { x: 20, y: 10 }, + Point { x: 30, y: 10 }, + Point { x: 30, y: 30 }, + Point { x: 10, y: 30 }, + Point { x: 10, y: 10 }, + Point { x: 80, y: 10 }, + ]; + assert!( + edge_violations(&drawing)? + .iter() + .any(|violation| violation["entities"][1] == "a") + ); + Ok(()) +} + +#[test] +fn parser_rejects_unmeasured_geometry_and_dropped_content() -> Result<()> { + let source = + b"stack 1.0 diagram \"Example\" { node a \"A\" node b \"B\" edge a -> b \"Request\" }"; + let engine = Engine::bundled(); + let output = engine.render(source)?; + let svg = output.svg.ok_or("missing SVG")?; + let compiled = stack_compiler::compile_bytes_with_source_map(source); + let diagram = compiled.diagram.as_ref().ok_or("missing diagram")?; + let prepared = engine.prepare_scene( + diagram, + compiled.source_map.as_ref().ok_or("missing source map")?, + )?; + assert!(read_drawing(&svg, &prepared, diagram).is_ok()); + for mutant in [ + svg.replacen("Example", ">Wrong", 1), + svg.replacen(" Result<()> { + let source = + b"stack 1.0 diagram \"Example\" { node a \"A\" node b \"B\" edge a -> b \"Request\" }"; + let engine = Engine::bundled(); + let svg = engine.render(source)?.svg.ok_or("missing SVG")?; + let compiled = stack_compiler::compile_bytes_with_source_map(source); + let diagram = compiled.diagram.as_ref().ok_or("missing diagram")?; + let prepared = engine.prepare_scene( + diagram, + compiled.source_map.as_ref().ok_or("missing source map")?, + )?; + let document = Document::parse(&svg)?; + let title = document + .root_element() + .children() + .find(|node| node.has_tag_name("text")) + .ok_or("missing title")?; + let owner = document + .descendants() + .find(|node| node.attribute("data-stack-id") == Some("a")) + .ok_or("missing node")?; + let label = owner + .children() + .find(|node| node.has_tag_name("text")) + .ok_or("missing node label")?; + let shape = owner + .children() + .find(|node| node.has_tag_name("rect")) + .ok_or("missing node shape")?; + let edge_text = document + .descendants() + .find(|node| { + node.has_tag_name("text") + && node + .parent_element() + .is_some_and(|parent| parent.has_attribute("data-edge-label")) + }) + .ok_or("missing edge text")?; + let replace = |node: Node<'_, '_>, replacement: &str| { + let mut mutant = svg.clone(); + mutant.replace_range(node.range(), replacement); + mutant + }; + let edge_markup = &svg[edge_text.range()]; + let moved_edge = edge_markup.replacen( + &format!("x=\"{}\"", required(edge_text, "x")?), + "x=\"-999.999\"", + 1, + ); + let oversized_edge = edge_markup.replacen( + &format!("font-size=\"{}\"", required(edge_text, "font-size")?), + "font-size=\"900\"", + 1, + ); + let moved_shape = svg[shape.range()].replacen( + &format!("x=\"{}\"", required(shape, "x")?), + "x=\"-999.999\"", + 1, + ); + let mut duplicated_title = replace(label, ""); + let end = duplicated_title.rfind("").ok_or("missing root end")?; + duplicated_title.insert_str(end, &svg[title.range()]); + let hide_group = |group: Node<'_, '_>| -> Result { + let mut hidden = svg[group.range()].replacen("").ok_or("missing group end")?; + hidden.replace_range(close..close + 4, ""); + Ok(replace(group, &hidden)) + }; + let layer = |name: &str| -> Result> { + document + .root_element() + .children() + .find(|node| node.attribute("data-stack-layer") == Some(name)) + .ok_or_else(|| "missing layer".into()) + }; + let hidden_edge_group = hide_group( + layer("edges")? + .children() + .find(Node::is_element) + .ok_or("missing edge group")?, + )?; + let mutants = [ + ( + "edge text moved outside its background", + replace(edge_text, &moved_edge), + ), + ("edge text enlarged", replace(edge_text, &oversized_edge)), + ("node shape moved", replace(shape, &moved_shape)), + ("node shape removed", replace(shape, "")), + ("node label replaced by duplicate title", duplicated_title), + ( + "unsupported baseline", + svg.replacen( + "dominant-baseline=\"middle\"", + "dominant-baseline=\"text-before-edge\"", + 1, + ), + ), + ( + "stylesheet hides text", + svg.replacen("", "", 1), + ), + ( + "inherited text rotation", + svg.replacen("", &format!("{}", &svg[shape.range()])); + let label_background_last = svg[label.range()] + .replace(&svg[label_background.range()], "") + .replace("", &format!("{}", &svg[label_background.range()])); + let mutants = [ + background_last, + replace(node, node_shape_last), + replace(label, label_background_last), + attribute(line, "stroke", "none")?, + attribute(line, "stroke", "transparent")?, + attribute(node_text, "fill", "none")?, + replace( + line, + svg[line.range()].replacen(" Result<()> { + for kind in ["actor", "client", "function", "worker", "database", "cache"] { + let source = format!("stack 1.0 diagram \"Shape\" {{ node a \"A\" {{ kind {kind} }} }}"); + let engine = Engine::bundled(); + let svg = engine.render(source.as_bytes())?.svg.ok_or("missing SVG")?; + let compiled = stack_compiler::compile_bytes_with_source_map(source.as_bytes()); + let diagram = compiled.diagram.as_ref().ok_or("missing diagram")?; + let prepared = engine.prepare_scene( + diagram, + compiled.source_map.as_ref().ok_or("missing source map")?, + )?; + let drawing = read_drawing(&svg, &prepared, diagram)?; + assert_eq!( + drawing.nodes, + vec![("a".to_owned(), rect(prepared.scene.nodes[0].rect))], + "{kind}" + ); + } + Ok(()) +} + +#[test] +fn frame_detector_includes_both_stroke_radii_without_terminal_exemptions() -> Result<()> { + let mut drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 500_000, + height: 800_000, + }, + texts: vec![], + nodes: vec![], + frames: vec![GroupFrame { + id: "a".into(), + rect: Rect { + x: 100_000, + y: 200_000, + width: 300_000, + height: 400_000, + }, + radius: 500, + }], + routes: vec![Route { + id: "edge:0".into(), + from: "a".into(), + to: "b".into(), + points: vec![ + Point { + x: 150_000, + y: 182_751, + }, + Point { + x: 350_000, + y: 182_751, + }, + ], + radius: 750, + }], + }; + let violations = frame_violations(&drawing)?; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0]["requiredCenterlineClearanceMilliPx"], 17_250); + for point in &mut drawing.routes[0].points { + point.y = 182_750; + } + assert!(frame_violations(&drawing)?.is_empty()); + for point in &mut drawing.routes[0].points { + point.y = 200_000; + } + assert_eq!(frame_violations(&drawing)?.len(), 1); + drawing.routes[0].points = vec![ + Point { x: 0, y: 400_000 }, + Point { + x: 500_000, + y: 400_000, + }, + ]; + assert!(frame_violations(&drawing)?.is_empty()); + Ok(()) +} + +#[test] +fn parser_measures_final_svg_group_frames_and_rejects_missing_or_hidden_frames() -> Result<()> { + let source = b"stack 1.0 diagram \"Frame\" { group system \"System\" { node a \"A\" } }"; + let engine = Engine::bundled(); + let svg = engine.render(source)?.svg.ok_or("missing SVG")?; + let compiled = stack_compiler::compile_bytes_with_source_map(source); + let diagram = compiled.diagram.as_ref().ok_or("missing diagram")?; + let prepared = engine.prepare_scene( + diagram, + compiled.source_map.as_ref().ok_or("missing source map")?, + )?; + let document = Document::parse(&svg)?; + let owner = document + .descendants() + .find(|node| node.attribute("data-stack-id") == Some("system")) + .ok_or("missing group frame owner")?; + let frame = owner + .children() + .find(|node| node.has_tag_name("rect")) + .ok_or("missing group frame")?; + let frame_markup = &svg[frame.range()]; + let replace = |node: Node<'_, '_>, replacement: &str| { + let mut mutant = svg.clone(); + mutant.replace_range(node.range(), replacement); + mutant + }; + let drawing = read_drawing(&svg, &prepared, diagram)?; + assert_eq!(drawing.frames.len(), 1); + assert_eq!(drawing.frames[0].rect, rectangle(frame)?); + assert_eq!(drawing.frames[0].radius, 500); + + let wider_stroke = replace( + frame, + &frame_markup.replacen( + &format!("stroke-width=\"{}\"", required(frame, "stroke-width")?), + "stroke-width=\"1.001\"", + 1, + ), + ); + assert_ne!(wider_stroke, svg, "mutation must widen the frame stroke"); + let wider = read_drawing(&wider_stroke, &prepared, diagram)?; + assert_eq!(wider.frames[0].radius, 501); + + let mutations = [ + ("removed frame", replace(frame, "")), + ( + "duplicate frame", + replace(frame, &format!("{frame_markup}{frame_markup}")), + ), + ( + "moved frame", + replace( + frame, + &frame_markup.replacen( + &format!("x=\"{}\"", required(frame, "x")?), + "x=\"-999.999\"", + 1, + ), + ), + ), + ( + "zero frame width", + replace( + frame, + &frame_markup.replacen( + &format!("width=\"{}\"", required(frame, "width")?), + "width=\"0\"", + 1, + ), + ), + ), + ( + "zero frame stroke", + replace( + frame, + &frame_markup.replacen( + &format!("stroke-width=\"{}\"", required(frame, "stroke-width")?), + "stroke-width=\"0\"", + 1, + ), + ), + ), + ( + "invisible frame stroke", + replace( + frame, + &frame_markup.replacen( + &format!("stroke=\"{}\"", required(frame, "stroke")?), + "stroke=\"none\"", + 1, + ), + ), + ), + ( + "inherited invisible frame", + replace( + owner, + &svg[owner.range()].replacen(" Result<()> +{ + let mut drawing = Drawing { + bounds: Rect { + x: 0, + y: 0, + width: 500_000, + height: 800_000, + }, + texts: vec![TextBox { + id: "edge-label:0:Request".into(), + rect: Rect { + x: 150_000, + y: 190_000, + width: 100_000, + height: 20_000, + }, + }], + nodes: vec![], + frames: vec![GroupFrame { + id: "system".into(), + rect: Rect { + x: 100_000, + y: 200_000, + width: 300_000, + height: 400_000, + }, + radius: 500, + }], + routes: vec![], + }; + let violations = frame_violations(&drawing)?; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0]["kind"], "label-group-frame-clearance"); + assert_eq!(violations[0]["requiredCenterlineClearanceMilliPx"], 8_500); + drawing.texts[0].rect.y = 208_500; + assert!(frame_violations(&drawing)?.is_empty()); + drawing.texts[0].rect.y -= 1; + assert_eq!(frame_violations(&drawing)?.len(), 1); + drawing.texts[0].rect.y = 171_500; + assert!(frame_violations(&drawing)?.is_empty()); + Ok(()) +} + +#[test] +fn px_parser_restores_exact_milli_pixels_without_floating_point() -> Result<()> { + for (value, expected) in [ + ("0", 0), + ("-0", 0), + ("32", 32_000), + ("-32", -32_000), + ("1.5", 1_500), + ("1.25", 1_250), + ("1.001", 1_001), + ("0.001", 1), + ("-0.001", -1), + ("123.000", 123_000), + ("9223372036854775.807", i64::MAX), + ("-9223372036854775.808", i64::MIN), + ] { + assert_eq!(pixel_value(value)?, expected, "{value}"); + } + Ok(()) +} + +#[test] +fn px_parser_rejects_invalid_signs_precision_and_overflow() { + for value in [ + "", + "+1", + "--1", + "-+1", + "1-", + "-", + ".5", + "-.5", + "1.", + "1.2.3", + "1.0001", + "-0.0001", + "1e3", + "NaN", + "Infinity", + "1px", + "1%", + " 1", + "1 ", + "1\n", + "1,5", + "12", + "01", + "00.5", + "9223372036854775.808", + "-9223372036854775.809", + "9223372036854776", + "-9223372036854776", + "999999999999999999999999999999999999999999999999", + ] { + assert!( + pixel_value(value).is_err(), + "accepted unsupported px value {value:?}" + ); + } +} + +#[test] +fn px_parser_scales_rectangle_points_and_absolute_path_coordinates() -> Result<()> { + assert_eq!( + point_list("-1.125,2.5 4,5.875")?, + vec![ + Point { + x: -1_125, + y: 2_500 + }, + Point { x: 4_000, y: 5_875 } + ] + ); + for (markup, expected) in [ + ( + r#""#, + Rect { + x: -1_125, + y: 2_500, + width: 4_000, + height: 5_875, + }, + ), + ( + r#""#, + Rect { + x: 1_000, + y: 2_000, + width: 2_250, + height: 5_000, + }, + ), + ( + r#""#, + Rect { + x: -1_125, + y: 1_250, + width: 5_125, + height: 4_625, + }, + ), + ( + r#""#, + Rect { + x: 1_250, + y: 1_125, + width: 4_625, + height: 6_375, + }, + ), + ] { + assert_eq!( + shape_envelope(Document::parse(markup)?.root_element())?, + expected, + "{markup}" + ); + } + Ok(()) +} diff --git a/crates/stack-engine/src/layout_quality/geometry.rs b/crates/stack-engine/src/layout_quality/geometry.rs new file mode 100644 index 0000000..b4cf2de --- /dev/null +++ b/crates/stack-engine/src/layout_quality/geometry.rs @@ -0,0 +1,807 @@ +//! Independent integer geometry for the test-only layout quality gates. +//! +//! Coordinates and stroke radii use thousandths of a CSS pixel. Computations use +//! wider intermediates so that adding dimensions or expanding an obstacle never +//! wraps at an `i64` coordinate boundary. + +use std::fmt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct Rect { + pub(super) x: i64, + pub(super) y: i64, + pub(super) width: i64, + pub(super) height: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct Point { + pub(super) x: i64, + pub(super) y: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum GeometryError { + InvalidRect, + NegativeRadius, + DiagonalSegment, +} + +impl fmt::Display for GeometryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidRect => "segment obstacles must have positive width and height", + Self::NegativeRadius => "stroke radius must not be negative", + Self::DiagonalSegment => "only axis-aligned segments are supported", + }) + } +} + +impl std::error::Error for GeometryError {} + +#[derive(Clone, Copy)] +struct Bounds { + left: i128, + top: i128, + right: i128, + bottom: i128, +} + +impl Bounds { + fn new(rect: Rect) -> Self { + Self { + left: i128::from(rect.x), + top: i128::from(rect.y), + right: i128::from(rect.x) + i128::from(rect.width), + bottom: i128::from(rect.y) + i128::from(rect.height), + } + } + + fn expand(self, radius: i64) -> Self { + let radius = i128::from(radius); + Self { + left: self.left - radius, + top: self.top - radius, + right: self.right + radius, + bottom: self.bottom + radius, + } + } +} + +/// Returns whether two nonempty rectangles share positive area. +/// Empty or inverted rectangles cannot overlap; exact edge contact is excluded. +pub(super) fn overlaps(left: Rect, right: Rect) -> bool { + if left.width <= 0 || left.height <= 0 || right.width <= 0 || right.height <= 0 { + return false; + } + let left = Bounds::new(left); + let right = Bounds::new(right); + left.left.max(right.left) < left.right.min(right.right) + && left.top.max(right.top) < left.bottom.min(right.bottom) +} + +/// Returns inclusive containment, including zero-area inner rectangles. +/// Negative dimensions are invalid and return false. +pub(super) fn contains(outer: Rect, inner: Rect) -> bool { + if outer.width < 0 || outer.height < 0 || inner.width < 0 || inner.height < 0 { + return false; + } + let outer = Bounds::new(outer); + let inner = Bounds::new(inner); + outer.left <= inner.left + && inner.right <= outer.right + && outer.top <= inner.top + && inner.bottom <= outer.bottom +} + +/// Tests a centerline against a rectangle expanded by the stroke radius. +/// Positive-length contact on the expanded boundary counts; a single touching +/// point does not. Zero-length segments therefore return false. This models an +/// axis-aligned stroke envelope, not arrowheads or arbitrary stroke cap shapes. +pub(super) fn segment_hits_rect( + start: Point, + end: Point, + rect: Rect, + radius: i64, +) -> Result { + if rect.width <= 0 || rect.height <= 0 { + return Err(GeometryError::InvalidRect); + } + if radius < 0 { + return Err(GeometryError::NegativeRadius); + } + let bounds = Bounds::new(rect).expand(radius); + if start == end { + return Ok(false); + } + if start.x == end.x { + let x = i128::from(start.x); + return Ok(bounds.left <= x + && x <= bounds.right + && i128::from(start.y.min(end.y)).max(bounds.top) + < i128::from(start.y.max(end.y)).min(bounds.bottom)); + } + if start.y == end.y { + let y = i128::from(start.y); + return Ok(bounds.top <= y + && y <= bounds.bottom + && i128::from(start.x.min(end.x)).max(bounds.left) + < i128::from(start.x.max(end.x)).min(bounds.right)); + } + Err(GeometryError::DiagonalSegment) +} + +/// Detects a segment running parallel to a finite group frame side without the +/// requested centerline clearance. The projection extends by the same clearance +/// around each corner, so a nearby bend cannot use a perpendicular crossing to +/// hide its adjacent parallel segment. Ordinary perpendicular crossings away +/// from corners remain valid. Exact clearance is allowed; travel on the frame +/// is always contact. There are no source/target terminal exemptions. +pub(super) fn parallel_frame_contact( + start: Point, + end: Point, + frame: Rect, + clearance: i64, +) -> Result { + if frame.width <= 0 || frame.height <= 0 { + return Err(GeometryError::InvalidRect); + } + if clearance < 0 { + return Err(GeometryError::NegativeRadius); + } + if start == end { + return Ok(false); + } + let frame = Bounds::new(frame); + let clearance = i128::from(clearance); + let contact = |coordinate: i64, first: i64, last: i64, sides: [i128; 2], low, high| { + let coordinate = i128::from(coordinate); + let projected_length = i128::from(first.min(last)).max(low - clearance) + < i128::from(first.max(last)).min(high + clearance); + projected_length + && sides.iter().any(|side| { + let distance = (coordinate - side).abs(); + distance == 0 || distance < clearance + }) + }; + if start.y == end.y { + return Ok(contact( + start.y, + start.x, + end.x, + [frame.top, frame.bottom], + frame.left, + frame.right, + )); + } + if start.x == end.x { + return Ok(contact( + start.x, + start.y, + end.y, + [frame.left, frame.right], + frame.top, + frame.bottom, + )); + } + Err(GeometryError::DiagonalSegment) +} + +/// Detects a label rectangle touching or approaching the finite frame perimeter. +/// The frame interior remains usable: only its four sides and their corner +/// clearance constrain labels. Callers include the painted frame stroke radius +/// in the required clearance. Exact clearance is allowed. +pub(super) fn label_frame_contact( + label: Rect, + frame: Rect, + clearance: i64, +) -> Result { + if label.width <= 0 || label.height <= 0 || frame.width <= 0 || frame.height <= 0 { + return Err(GeometryError::InvalidRect); + } + if clearance < 0 { + return Err(GeometryError::NegativeRadius); + } + let label = Bounds::new(label); + let frame = Bounds::new(frame); + let clearance = i128::from(clearance); + let near_interval = |side: i128, low: i128, high: i128| { + let distance = if side < low { + low - side + } else if side > high { + side - high + } else { + 0 + }; + distance == 0 || distance < clearance + }; + let horizontal_projection = + label.left.max(frame.left - clearance) < label.right.min(frame.right + clearance); + let vertical_projection = + label.top.max(frame.top - clearance) < label.bottom.min(frame.bottom + clearance); + Ok((horizontal_projection + && [frame.top, frame.bottom] + .iter() + .any(|side| near_interval(*side, label.top, label.bottom))) + || (vertical_projection + && [frame.left, frame.right] + .iter() + .any(|side| near_interval(*side, label.left, label.right)))) +} + +/// Allows only a real terminal's normal departure or arrival at the original +/// node boundary. Callers must enable these flags only on the first/last path +/// segment for that segment's actual source/target node. Every other segment +/// must be checked without the corresponding exemption, including later +/// segments that return to the same node. +pub(super) fn segment_hits_node( + start: Point, + end: Point, + rect: Rect, + radius: i64, + allow_start: bool, + allow_end: bool, +) -> Result { + if !segment_hits_rect(start, end, rect, radius)? { + return Ok(false); + } + let bounds = Bounds::new(rect); + let allowed_departure = allow_start && departs_normally(start, end, bounds); + let allowed_arrival = allow_end && departs_normally(end, start, bounds); + Ok(!allowed_departure && !allowed_arrival) +} + +fn departs_normally(terminal: Point, other: Point, rect: Bounds) -> bool { + let x = i128::from(terminal.x); + let y = i128::from(terminal.y); + if terminal.y == other.y && rect.top <= y && y <= rect.bottom { + return (x == rect.left && other.x < terminal.x) + || (x == rect.right && terminal.x < other.x); + } + if terminal.x == other.x && rect.left <= x && x <= rect.right { + return (y == rect.top && other.y < terminal.y) + || (y == rect.bottom && terminal.y < other.y); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + const NODE: Rect = rect(10, 20, 30, 40); + + const fn rect(x: i64, y: i64, width: i64, height: i64) -> Rect { + Rect { + x, + y, + width, + height, + } + } + + const fn point(x: i64, y: i64) -> Point { + Point { x, y } + } + + fn assert_rect_hit(start: Point, end: Point, radius: i64, expected: bool) { + assert_eq!( + segment_hits_rect(start, end, NODE, radius), + Ok(expected), + "forward: {start:?} -> {end:?}, radius {radius}" + ); + assert_eq!( + segment_hits_rect(end, start, NODE, radius), + Ok(expected), + "reverse: {end:?} -> {start:?}, radius {radius}" + ); + } + + #[test] + fn rectangle_overlap_requires_positive_area() { + for (other, expected) in [ + (NODE, true), + (rect(11, 21, 1, 1), true), + (rect(9, 19, 2, 2), true), + (rect(0, 0, 50, 70), true), + (rect(40, 20, 10, 40), false), + (rect(10, 60, 30, 10), false), + (rect(40, 60, 10, 10), false), + (rect(41, 20, 10, 40), false), + (rect(11, 21, 0, 1), false), + (rect(11, 21, 1, 0), false), + (rect(11, 21, -1, 1), false), + ] { + assert_eq!(overlaps(NODE, other), expected, "{other:?}"); + assert_eq!(overlaps(other, NODE), expected, "{other:?}"); + } + } + + #[test] + fn containment_includes_the_boundary_and_empty_inner_rectangles() { + for inner in [NODE, rect(10, 20, 1, 1), rect(40, 60, 0, 0)] { + assert!(contains(NODE, inner), "{inner:?}"); + } + for inner in [ + rect(9, 20, 2, 1), + rect(10, 19, 1, 2), + rect(39, 20, 2, 1), + rect(10, 59, 1, 2), + rect(41, 60, 0, 0), + rect(11, 21, -1, 1), + ] { + assert!(!contains(NODE, inner), "{inner:?}"); + } + assert!(contains(rect(5, 5, 0, 0), rect(5, 5, 0, 0))); + assert!(!contains(rect(10, 20, -1, 40), NODE)); + } + + #[test] + fn crossing_and_travel_along_any_rectangle_edge_are_collisions() { + for (start, end) in [ + (point(0, 40), point(50, 40)), + (point(25, 0), point(25, 70)), + (point(0, 20), point(50, 20)), + (point(0, 60), point(50, 60)), + (point(10, 0), point(10, 70)), + (point(40, 0), point(40, 70)), + ] { + assert_rect_hit(start, end, 0, true); + } + } + + #[test] + fn point_only_contact_is_excluded_but_one_unit_of_boundary_travel_is_not() { + for (start, end) in [ + (point(0, 20), point(10, 20)), + (point(0, 40), point(10, 40)), + (point(40, 60), point(50, 60)), + (point(10, 0), point(10, 20)), + (point(40, 60), point(40, 70)), + ] { + assert_rect_hit(start, end, 0, false); + } + assert_rect_hit(point(0, 20), point(11, 20), 0, true); + assert_rect_hit(point(10, 0), point(10, 21), 0, true); + assert_rect_hit(point(0, 17), point(7, 17), 3, false); + assert_rect_hit(point(0, 17), point(8, 17), 3, true); + } + + #[test] + fn stroke_expansion_detects_collisions_without_centerline_overlap() { + assert_rect_hit(point(0, 18), point(50, 18), 0, false); + assert_rect_hit(point(0, 18), point(50, 18), 3, true); + assert_rect_hit(point(8, 0), point(8, 70), 0, false); + assert_rect_hit(point(8, 0), point(8, 70), 3, true); + assert_rect_hit(point(0, 17), point(50, 17), 3, true); + assert_rect_hit(point(7, 0), point(7, 70), 3, true); + assert_rect_hit(point(0, 16), point(50, 16), 3, false); + assert_rect_hit(point(6, 0), point(6, 70), 3, false); + } + + #[test] + fn only_the_correct_terminal_flag_allows_normal_contact_on_all_sides() { + for (terminal, outside) in [ + (point(10, 40), point(0, 40)), + (point(40, 40), point(50, 40)), + (point(25, 20), point(25, 10)), + (point(25, 60), point(25, 70)), + ] { + assert_rect_hit(terminal, outside, 3, true); + for (allow_start, allow_end, expected) in [ + (false, false, true), + (true, false, false), + (false, true, true), + (true, true, false), + ] { + assert_eq!( + segment_hits_node(terminal, outside, NODE, 3, allow_start, allow_end), + Ok(expected), + "departure: {terminal:?} -> {outside:?}" + ); + assert_eq!( + segment_hits_node(outside, terminal, NODE, 3, allow_end, allow_start), + Ok(expected), + "arrival: {outside:?} -> {terminal:?}" + ); + } + } + } + + #[test] + fn terminal_flags_never_exempt_travel_along_the_node_boundary() { + for (start, end) in [ + (point(10, 20), point(40, 20)), + (point(10, 60), point(40, 60)), + (point(10, 20), point(10, 60)), + (point(40, 20), point(40, 60)), + (point(10, 40), point(10, 10)), + (point(25, 20), point(50, 20)), + ] { + for radius in [0, 3] { + assert_eq!( + segment_hits_node(start, end, NODE, radius, true, true), + Ok(true), + "{start:?} -> {end:?}, radius {radius}" + ); + assert_eq!( + segment_hits_node(end, start, NODE, radius, true, true), + Ok(true) + ); + } + } + } + + #[test] + fn a_corner_can_depart_outward_but_cannot_run_into_its_adjacent_boundary() { + for (terminal, outside) in [ + (point(10, 20), point(0, 20)), + (point(10, 20), point(10, 10)), + (point(40, 60), point(50, 60)), + (point(40, 60), point(40, 70)), + ] { + assert_eq!( + segment_hits_node(terminal, outside, NODE, 3, true, false), + Ok(false) + ); + assert_eq!( + segment_hits_node(outside, terminal, NODE, 3, false, true), + Ok(false) + ); + } + assert_eq!( + segment_hits_node(point(10, 20), point(20, 20), NODE, 3, true, true), + Ok(true) + ); + assert_eq!( + segment_hits_node(point(40, 60), point(40, 50), NODE, 3, true, true), + Ok(true) + ); + } + + #[test] + fn an_allowed_endpoint_cannot_cross_through_its_own_node() { + for (terminal, opposite_outside) in [ + (point(10, 40), point(50, 40)), + (point(40, 40), point(0, 40)), + (point(25, 20), point(25, 70)), + (point(25, 60), point(25, 10)), + ] { + assert_eq!( + segment_hits_node(terminal, opposite_outside, NODE, 3, true, true), + Ok(true) + ); + assert_eq!( + segment_hits_node(opposite_outside, terminal, NODE, 3, true, true), + Ok(true) + ); + } + } + + #[test] + fn interior_endpoints_are_collisions_even_when_both_flags_are_enabled() { + for (inside, other) in [ + (point(20, 40), point(0, 40)), + (point(25, 30), point(25, 10)), + (point(20, 40), point(30, 40)), + (point(25, 30), point(25, 40)), + ] { + assert_eq!( + segment_hits_node(inside, other, NODE, 3, true, true), + Ok(true) + ); + assert_eq!( + segment_hits_node(other, inside, NODE, 3, true, true), + Ok(true) + ); + } + } + + #[test] + fn a_terminal_must_be_on_the_original_rectangle_not_just_its_stroke_halo() { + for (near_boundary, outside) in [ + (point(8, 40), point(0, 40)), + (point(42, 40), point(50, 40)), + (point(25, 18), point(25, 10)), + (point(25, 62), point(25, 70)), + ] { + assert_eq!( + segment_hits_node(near_boundary, outside, NODE, 3, true, true), + Ok(true) + ); + assert_eq!( + segment_hits_node(outside, near_boundary, NODE, 3, true, true), + Ok(true) + ); + } + } + + #[test] + fn a_clean_departure_does_not_exempt_later_reentry() { + let path = [ + point(10, 40), + point(0, 40), + point(0, 10), + point(25, 10), + point(25, 40), + ]; + let expected = [false, false, false, true]; + for (index, segment) in path.windows(2).enumerate() { + assert_eq!( + segment_hits_node(segment[0], segment[1], NODE, 3, index == 0, false), + Ok(expected[index]), + "segment {index}" + ); + } + } + + #[test] + fn a_short_normal_departure_does_not_exempt_the_next_segment_in_the_halo() { + assert_eq!( + segment_hits_node(point(25, 20), point(25, 18), NODE, 3, true, false), + Ok(false) + ); + assert_eq!( + segment_hits_node(point(25, 18), point(35, 18), NODE, 3, false, false), + Ok(true) + ); + } + + #[test] + fn unsupported_geometry_returns_errors_instead_of_appearing_collision_free() { + assert_eq!( + segment_hits_rect(point(0, 0), point(50, 70), NODE, 3), + Err(GeometryError::DiagonalSegment) + ); + assert_eq!( + segment_hits_node(point(10, 20), point(0, 0), NODE, 3, true, true), + Err(GeometryError::DiagonalSegment) + ); + assert_eq!( + segment_hits_rect(point(0, 40), point(50, 40), NODE, -1), + Err(GeometryError::NegativeRadius) + ); + for obstacle in [ + rect(10, 20, 0, 40), + rect(10, 20, 30, 0), + rect(10, 20, -1, 40), + rect(10, 20, 30, -1), + ] { + assert_eq!( + segment_hits_rect(point(0, 40), point(50, 40), obstacle, 3), + Err(GeometryError::InvalidRect) + ); + } + } + + #[test] + fn zero_length_segments_have_no_positive_length_contact_but_validate_inputs() { + for position in [point(25, 40), point(10, 20), point(0, 0)] { + assert_rect_hit(position, position, 3, false); + assert_eq!( + segment_hits_node(position, position, NODE, 3, true, true), + Ok(false) + ); + } + assert_eq!( + segment_hits_rect(point(25, 40), point(25, 40), NODE, -1), + Err(GeometryError::NegativeRadius) + ); + assert_eq!( + segment_hits_rect(point(25, 40), point(25, 40), rect(0, 0, 0, 0), 0), + Err(GeometryError::InvalidRect) + ); + } + + #[test] + fn dimensions_and_stroke_expansion_do_not_overflow_i64_coordinates() { + let high = rect(i64::MAX - 5, 0, 10, 10); + let inner = rect(i64::MAX - 1, 1, 5, 5); + assert!(contains(high, inner)); + assert!(overlaps(high, inner)); + assert_eq!( + segment_hits_rect(point(i64::MAX, -10), point(i64::MAX, 20), high, 0), + Ok(true) + ); + let low = rect(i64::MIN + 2, -2, 4, 4); + assert_eq!( + segment_hits_rect(point(i64::MIN, -10), point(i64::MIN, 10), low, i64::MAX), + Ok(true) + ); + assert_eq!( + segment_hits_node( + point(i64::MIN + 2, 0), + point(i64::MIN, 0), + low, + i64::MAX, + true, + false + ), + Ok(false) + ); + assert_eq!( + segment_hits_rect(point(i64::MIN, 5), point(i64::MAX, 5), high, i64::MAX), + Ok(true) + ); + } + + const FRAME: Rect = rect(100, 200, 300, 400); + + fn assert_frame_contact(start: Point, end: Point, clearance: i64, expected: bool) { + assert_eq!( + parallel_frame_contact(start, end, FRAME, clearance), + Ok(expected), + "forward frame contact: {start:?} -> {end:?}, clearance {clearance}" + ); + assert_eq!( + parallel_frame_contact(end, start, FRAME, clearance), + Ok(expected), + "reverse frame contact: {end:?} -> {start:?}, clearance {clearance}" + ); + } + + #[test] + fn frame_parallel_clearance_applies_inside_and_outside_all_four_sides() { + for offset in [-17_i64, -16, -15, 0, 15, 16, 17] { + let expected = offset.abs() < 16; + for (start, end) in [ + (point(150, 200 + offset), point(350, 200 + offset)), + (point(150, 600 + offset), point(350, 600 + offset)), + (point(100 + offset, 250), point(100 + offset, 550)), + (point(400 + offset, 250), point(400 + offset, 550)), + ] { + assert_frame_contact(start, end, 16, expected); + } + } + assert_frame_contact(point(150, 200), point(350, 200), 0, true); + } + + #[test] + fn frame_perpendicular_crossings_are_allowed_away_from_corners() { + for (start, end) in [ + (point(0, 400), point(500, 400)), + (point(250, 100), point(250, 700)), + (point(0, 400), point(100, 400)), + (point(400, 400), point(500, 400)), + (point(250, 100), point(250, 200)), + (point(250, 600), point(250, 700)), + ] { + assert_frame_contact(start, end, 16, false); + } + } + + #[test] + fn frame_corner_bends_cannot_hide_behind_a_perpendicular_crossing() { + for path in [ + [point(50, 190), point(90, 190), point(90, 250)], + [point(450, 190), point(410, 190), point(410, 250)], + [point(50, 610), point(90, 610), point(90, 550)], + [point(450, 610), point(410, 610), point(410, 550)], + ] { + for segment in path.windows(2) { + assert_frame_contact(segment[0], segment[1], 16, true); + } + } + } + + #[test] + fn frame_sides_have_finite_projections_including_corner_clearance() { + for (start, end, expected) in [ + (point(0, 200), point(84, 200), false), + (point(0, 200), point(85, 200), true), + (point(416, 600), point(500, 600), false), + (point(415, 600), point(500, 600), true), + (point(100, 100), point(100, 184), false), + (point(100, 100), point(100, 185), true), + (point(400, 616), point(400, 700), false), + (point(400, 615), point(400, 700), true), + ] { + assert_frame_contact(start, end, 16, expected); + } + } + + #[test] + fn frame_detector_rejects_invalid_geometry_and_uses_wide_intermediates() { + assert_eq!( + parallel_frame_contact(point(0, 0), point(1, 1), FRAME, 16), + Err(GeometryError::DiagonalSegment) + ); + assert_eq!( + parallel_frame_contact(point(0, 0), point(1, 0), FRAME, -1), + Err(GeometryError::NegativeRadius) + ); + assert_eq!( + parallel_frame_contact(point(0, 0), point(1, 0), rect(0, 0, 0, 1), 16), + Err(GeometryError::InvalidRect) + ); + assert_frame_contact(point(100, 200), point(100, 200), 16, false); + assert_eq!( + parallel_frame_contact( + point(i64::MAX - 3, -5), + point(i64::MAX, -5), + rect(i64::MAX - 5, 0, 10, 10), + 16 + ), + Ok(true) + ); + assert_eq!( + parallel_frame_contact( + point(i64::MIN, -5), + point(i64::MIN + 3, -5), + rect(i64::MIN + 2, 0, 10, 10), + 16 + ), + Ok(true) + ); + } + + #[test] + fn label_frame_clearance_rejects_masking_and_nearby_labels_on_all_sides() { + for gap in [0, 7, 8, 9] { + for label in [ + rect(150, 180 - gap, 100, 20), + rect(150, 600 + gap, 100, 20), + rect(80 - gap, 250, 20, 100), + rect(400 + gap, 250, 20, 100), + rect(150, 200 + gap, 100, 20), + rect(150, 580 - gap, 100, 20), + rect(100 + gap, 250, 20, 100), + rect(380 - gap, 250, 20, 100), + ] { + assert_eq!( + label_frame_contact(label, FRAME, 8), + Ok(gap < 8), + "{label:?}" + ); + } + } + for label in [ + rect(150, 195, 100, 20), + rect(150, 595, 100, 20), + rect(95, 250, 20, 100), + rect(395, 250, 20, 100), + ] { + assert_eq!(label_frame_contact(label, FRAME, 8), Ok(true), "{label:?}"); + } + } + + #[test] + fn label_frame_clearance_uses_finite_sides_and_preserves_interior_space() { + for label in [ + rect(150, 250, 100, 100), + rect(0, 0, 50, 50), + rect(0, 195, 90, 20), + rect(410, 195, 90, 20), + ] { + assert_eq!(label_frame_contact(label, FRAME, 8), Ok(false), "{label:?}"); + } + for label in [ + rect(80, 180, 15, 15), + rect(405, 180, 15, 15), + rect(80, 605, 15, 15), + rect(405, 605, 15, 15), + ] { + assert_eq!(label_frame_contact(label, FRAME, 8), Ok(true), "{label:?}"); + } + } + + #[test] + fn label_frame_clearance_validates_inputs_and_avoids_coordinate_overflow() { + assert_eq!( + label_frame_contact(rect(0, 0, 0, 1), FRAME, 8), + Err(GeometryError::InvalidRect) + ); + assert_eq!( + label_frame_contact(rect(0, 0, 1, 1), FRAME, -1), + Err(GeometryError::NegativeRadius) + ); + assert_eq!( + label_frame_contact( + rect(i64::MAX - 2, -4, 5, 2), + rect(i64::MAX - 5, 0, 10, 10), + 8 + ), + Ok(true) + ); + } +} diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index c8de954..fdc286e 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -24,11 +24,18 @@ use std::fmt; use stack_compiler::diagnostic as compiler_diagnostic; +mod labels; mod resources; mod routing; mod scene; mod svg; +#[cfg(test)] +mod layout_quality; + +#[cfg(test)] +mod placement_quality; + mod language; mod provider; pub use language::{ @@ -638,6 +645,35 @@ mod tests { const VALID_SOURCE: &[u8] = b"stack 1.0 diagram \"API\" { node api \"API\" }"; + #[test] + fn valid_wide_connectors_render_labelled_edges_in_both_directions() -> Result<(), Box> + { + for width in [1_500, 15_000, 15_998, 15_999, 16_000, 32_000] { + let mut catalog = stack_theme::catalog().clone(); + for theme in &mut catalog.themes { + theme.connector.width_milli_px = width; + } + let engine = Engine::with_catalog(&catalog, stack_theme::CATALOG_REVISION)?; + for direction in ["right", "down"] { + let source = format!( + "stack 1.0 diagram \"Wide stroke\" {{ layout {{ direction {direction} }} node a \"A\" node b \"B\" edge a -> b \"Request\" }}" + ); + let output = engine.render(source.as_bytes())?; + assert!( + output.diagnostics.is_empty(), + "width={width}, direction={direction}" + ); + assert!( + output + .svg + .ok_or("missing wide-stroke SVG")? + .contains("data-edge-label=\"Request\"") + ); + } + } + Ok(()) + } + #[test] fn bundled_engine_reports_all_version_metadata() { let engine = Engine::bundled(); @@ -650,7 +686,7 @@ mod tests { output.metadata.language_version, Some(LanguageVersion { major: 1, minor: 0 }) ); - assert_eq!(output.metadata.theme_catalog_version, "0.5.0"); + assert_eq!(output.metadata.theme_catalog_version, "0.6.0"); assert_eq!( output.metadata.theme_catalog_revision, stack_theme::CATALOG_REVISION @@ -694,10 +730,10 @@ mod tests { ("ai", "Artificial intelligence system"), ]; let catalog = stack_theme::catalog(); - assert_eq!(catalog.catalog_version, "0.5.0"); + assert_eq!(catalog.catalog_version, "0.6.0"); assert_eq!( stack_theme::CATALOG_REVISION, - "sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53" + "sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1" ); for theme in &catalog.themes { for (identifier, subject) in expected_icons { @@ -716,7 +752,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.5.0"); + assert_eq!(rendered.metadata.theme_catalog_version, "0.6.0"); assert_eq!( rendered.metadata.theme_catalog_revision, stack_theme::CATALOG_REVISION @@ -827,7 +863,7 @@ mod tests { #[test] fn layout_warnings_follow_compiler_warnings() -> Result<(), Box> { let mut source = String::from( - "stack 1.0 diagram \"Warnings\" { layout { direction right order [n1, n0] } node hub \"Hub\" ", + "stack 1.0 diagram \"Warnings\" { layout { direction right order [hub, n0] } node hub \"Hub\" ", ); for index in 0..13 { source.push_str(&format!( diff --git a/crates/stack-engine/src/placement_quality.rs b/crates/stack-engine/src/placement_quality.rs new file mode 100644 index 0000000..6e1e0be --- /dev/null +++ b/crates/stack-engine/src/placement_quality.rs @@ -0,0 +1,401 @@ +//! Behavioral regression tests for graph-aware scene composition. + +use std::error::Error; + +#[cfg(feature = "conformance")] +use crate::routing::Point; +use crate::scene::{self, Rect, Scene, SceneDirection}; + +#[test] +fn labelled_dag_inventory_renders_without_hidden_collisions() -> Result<(), Box> { + let fixtures: serde_json::Value = serde_json::from_str(include_str!( + "../tests/fixtures/labelled-dag-regressions.json" + ))?; + let cases = fixtures.as_array().ok_or("fixture array")?; + assert_eq!(cases.len(), 60); + let mut failures = Vec::new(); + for case in cases { + let name = case["name"].as_str().ok_or("fixture name")?; + let source = case["input"]["value"].as_str().ok_or("fixture source")?; + match scene_from(source) { + Ok(scene) => { + assert!(scene.geometry_is_valid(), "{name}: geometry"); + assert_eq!(scene, scene_from(source)?, "{name}: deterministic scene"); + } + Err(error) => failures.push(format!("{name}: {error}")), + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + Ok(()) +} + +fn scene_from(source: &str) -> 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 node_rect(scene: &Scene, id: &str) -> Result> { + scene + .nodes + .iter() + .find(|node| node.id == id) + .map(|node| node.rect) + .ok_or_else(|| format!("missing node {id}").into()) +} + +fn group_rect(scene: &Scene, id: &str) -> Result> { + scene + .groups + .iter() + .find(|group| group.id == id) + .map(|group| group.rect) + .ok_or_else(|| format!("missing group {id}").into()) +} + +fn assert_progresses(before: Rect, after: Rect, direction: SceneDirection) { + let (before_end, after_start) = match direction { + SceneDirection::Right => (before.x + before.width, after.x), + SceneDirection::Down => (before.y + before.height, after.y), + }; + assert!( + before_end < after_start, + "connected elements must progress along {direction:?}: {before:?} -> {after:?}" + ); +} + +fn assert_reordered_dag_progresses(direction: SceneDirection) -> Result<(), Box> { + let authored_direction = match direction { + SceneDirection::Right => "right", + SceneDirection::Down => "down", + }; + let source = format!( + r#"stack 1.0 +diagram "Reordered pipeline" {{ + layout {{ direction {authored_direction} }} + node sink "Sink" + node source "Source" + node processor "Processor" + edge source -> processor + edge processor -> sink +}}"# + ); + let scene = scene_from(&source)?; + assert_eq!(scene.direction, direction); + assert_progresses( + node_rect(&scene, "source")?, + node_rect(&scene, "processor")?, + direction, + ); + assert_progresses( + node_rect(&scene, "processor")?, + node_rect(&scene, "sink")?, + direction, + ); + assert!(scene.geometry_is_valid()); + Ok(()) +} + +#[test] +fn right_directed_dag_progresses_by_connections_instead_of_declaration_order() +-> Result<(), Box> { + assert_reordered_dag_progresses(SceneDirection::Right) +} + +#[test] +fn down_directed_dag_progresses_by_connections_instead_of_declaration_order() +-> Result<(), Box> { + assert_reordered_dag_progresses(SceneDirection::Down) +} + +#[test] +fn graph_ranking_preserves_mandatory_same_rank_and_authored_cross_axis_order() +-> Result<(), Box> { + for (authored_direction, direction) in [ + ("right", SceneDirection::Right), + ("down", SceneDirection::Down), + ] { + let source = format!( + r#"stack 1.0 +diagram "Constrained fanout" {{ + layout {{ + direction {authored_direction} + rank same [primary, secondary] + order [secondary, primary] + }} + node sink "Sink" + node primary "Primary service" + node source "Source" + node secondary "Secondary service" + edge source -> primary + edge source -> secondary + edge primary -> sink + edge secondary -> sink +}}"# + ); + let scene = scene_from(&source)?; + let source = node_rect(&scene, "source")?; + let sink = node_rect(&scene, "sink")?; + let primary = node_rect(&scene, "primary")?; + let secondary = node_rect(&scene, "secondary")?; + match direction { + SceneDirection::Right => { + assert_eq!(primary.x, secondary.x); + assert!(secondary.y + secondary.height < primary.y); + } + SceneDirection::Down => { + assert_eq!(primary.y, secondary.y); + assert!(secondary.x + secondary.width < primary.x); + } + } + for branch in [primary, secondary] { + assert_progresses(source, branch, direction); + assert_progresses(branch, sink, direction); + } + assert!(scene.unsatisfied_orders.is_empty()); + assert!(scene.geometry_is_valid()); + } + Ok(()) +} + +#[test] +fn reversing_descendant_connection_reverses_group_progression() -> Result<(), Box> { + let source = r#"stack 1.0 +diagram "Projected group connection" { + layout { direction right } + group storage "Storage" { + layout { direction down } + group persistence "Persistence" { node database "Database" } + } + group platform "Platform" { + layout { direction down } + group application "Application" { node api "API" } + } + edge api -> database +}"#; + let forward = scene_from(source)?; + let reverse = scene_from(&source.replace("edge api -> database", "edge database -> api"))?; + + assert_progresses( + group_rect(&forward, "platform")?, + group_rect(&forward, "storage")?, + SceneDirection::Right, + ); + assert_progresses( + group_rect(&reverse, "storage")?, + group_rect(&reverse, "platform")?, + SceneDirection::Right, + ); + for scene in [&forward, &reverse] { + for id in ["storage", "platform"] { + let group = scene + .groups + .iter() + .find(|group| group.id == id) + .ok_or("missing directed group")?; + assert_eq!(group.direction, SceneDirection::Down); + } + assert!(scene.geometry_is_valid()); + } + Ok(()) +} + +#[test] +fn cyclic_graph_composition_is_deterministic() -> Result<(), Box> { + let source = r#"stack 1.0 +diagram "Deterministic cycle" { + layout { direction right } + node downstream "Downstream" + node second "Second" + node upstream "Upstream" + node first "First" + edge upstream -> first + edge first -> second + edge second -> first + edge second -> downstream +}"#; + let first = scene_from(source)?; + let second = scene_from(source)?; + assert_eq!(first, second); + assert!(first.geometry_is_valid()); + Ok(()) +} + +#[cfg(feature = "conformance")] +fn contains(outer: Rect, inner: Rect) -> bool { + inner.x >= outer.x + && inner.y >= outer.y + && inner.x + inner.width <= outer.x + outer.width + && inner.y + inner.height <= outer.y + outer.height +} + +#[cfg(feature = "conformance")] +fn overlaps(left: Rect, right: Rect) -> bool { + left.x < right.x + right.width + && right.x < left.x + left.width + && left.y < right.y + right.height + && right.y < left.y + left.height +} + +#[cfg(feature = "conformance")] +fn point_on_segment(point: Point, start: Point, end: Point) -> bool { + if start.x == end.x { + point.x == start.x && (start.y.min(end.y)..=start.y.max(end.y)).contains(&point.y) + } else if start.y == end.y { + point.y == start.y && (start.x.min(end.x)..=start.x.max(end.x)).contains(&point.x) + } else { + false + } +} + +#[cfg(feature = "conformance")] +fn segment_touches_rect(start: Point, end: Point, rect: Rect) -> bool { + if start.x == end.x { + (rect.x..=rect.x + rect.width).contains(&start.x) + && start.y.min(end.y) <= rect.y + rect.height + && start.y.max(end.y) >= rect.y + } else if start.y == end.y { + (rect.y..=rect.y + rect.height).contains(&start.y) + && start.x.min(end.x) <= rect.x + rect.width + && start.x.max(end.x) >= rect.x + } else { + // An unsupported segment is never accepted as collision-free. + true + } +} + +#[test] +#[cfg(feature = "conformance")] +fn multilingual_labels_have_scene_rectangles_beside_their_routes_without_collisions_or_clipping() +-> Result<(), Box> { + let catalog = stack_theme::catalog(); + let theme = catalog + .themes + .iter() + .find(|theme| theme.id == "light") + .ok_or("missing light theme")?; + let metrics = catalog + .font_metrics + .iter() + .find(|metrics| metrics.id == theme.typography.font_metrics_id) + .ok_or("missing light theme metrics")?; + let source = include_str!("../../../layout-corpus/sources/multilingual-long-labels.stack"); + + for direction in ["right", "down"] { + let source = source.replace("direction down", &format!("direction {direction}")); + let scene = scene_from(&source)?; + let mut occupied_labels = Vec::new(); + for edge in &scene.edges { + let label = edge.label.as_deref().ok_or("missing authored edge label")?; + let rect = edge + .label_rect + .ok_or("label geometry must be resolved by the scene")?; + let attachment = edge.label_anchor.ok_or("missing route attachment")?; + assert!(rect.width > 0 && rect.height > 0); + assert!( + rect.width + >= scene::text_width(label, theme.typography.edge_label_size_milli_px, metrics), + "the complete measured label must fit: {label}" + ); + assert!( + contains(scene.bounds, rect), + "label must stay inside the canvas: {label} {rect:?}" + ); + assert!( + edge.path + .windows(2) + .any(|segment| point_on_segment(attachment, segment[0], segment[1])), + "label attachment must stay on its own route: {label}" + ); + let nearest_x = attachment.x.clamp(rect.x, rect.x + rect.width); + let nearest_y = attachment.y.clamp(rect.y, rect.y + rect.height); + let gap_x = (attachment.x - nearest_x).abs(); + let gap_y = (attachment.y - nearest_y).abs(); + assert!( + (gap_x == 0 || gap_y == 0) + && gap_x + gap_y > 0 + && gap_x + gap_y <= rect.width.min(rect.height), + "label must remain immediately beside its attachment: {label} {rect:?} {attachment:?}" + ); + for node in &scene.nodes { + assert!( + !overlaps(rect, node.rect), + "label overlaps node {}: {label}", + node.id + ); + } + for occupied in &occupied_labels { + assert!(!overlaps(rect, *occupied), "labels overlap: {label}"); + } + for route in &scene.edges { + assert!( + route + .path + .windows(2) + .all(|segment| !segment_touches_rect(segment[0], segment[1], rect)), + "route {} -> {} touches label {label}", + route.from, + route.to + ); + } + occupied_labels.push(rect); + } + assert_eq!(occupied_labels.len(), 3); + assert!(scene.geometry_is_valid()); + } + Ok(()) +} + +#[test] +fn long_labels_on_same_rank_connections_get_space_outside_the_node_column() +-> Result<(), Box> { + for direction in ["direction right", ""] { + let source = format!( + r#"stack 1.0 diagram "Flow" {{ + layout {{ {direction} rank same [a, b] }} + node a "A" node b "B" + edge a -> b "retry request after timeout" + }}"# + ); + let scene = scene_from(&source)?; + assert!(scene.geometry_is_valid()); + assert!(scene.edges[0].label_rect.is_some()); + } + Ok(()) +} + +#[test] +fn labelled_skip_edges_have_outer_lanes_separate_from_the_central_spine() +-> Result<(), Box> { + let source = r#"stack 1.0 diagram "Probe" { + layout { direction down } + node n0 "Node 0" node n1 "Node 1" node n2 "Node 2" + edge n0 -> n1 "Connection 0" + edge n1 -> n2 "Connection 0" + edge n0 -> n2 "Connection 0" + }"#; + let scene = scene_from(source)?; + assert!(scene.geometry_is_valid()); + assert!(scene.edges.iter().all(|edge| edge.label_rect.is_some())); + Ok(()) +} + +#[test] +fn fanout_labels_reserve_space_before_later_routes_choose_nearby_lanes() +-> Result<(), Box> { + let scene = scene_from( + r#"stack 1.0 diagram "Probe" { + layout { direction down } + node n0 "Node 0" node n1 "Node 1" node n2 "Node 2" node n3 "Node 3" + edge n0 -> n3 "Connection 0" + edge n0 -> n2 "Connection 1" + edge n0 -> n1 "Connection 2" + }"#, + )?; + assert!(scene.geometry_is_valid()); + assert!(scene.edges.iter().all(|edge| edge.label_rect.is_some())); + Ok(()) +} diff --git a/crates/stack-engine/src/resources.rs b/crates/stack-engine/src/resources.rs index ea0fb3c..03752f2 100644 --- a/crates/stack-engine/src/resources.rs +++ b/crates/stack-engine/src/resources.rs @@ -5,6 +5,65 @@ use stack_theme::{Catalog, FontMetrics, NodeVisual, ProviderIcon, Theme}; use crate::{ProviderNotice, ProviderNoticeIcon, ProviderNoticeSource, ProviderPack}; +#[cfg(test)] +mod tests { + use super::{ResourceError, Resources}; + + #[test] + fn missing_catalog_records_return_explicit_resolution_errors() + -> Result<(), Box> { + let diagram = + stack_compiler::compile_bytes(b"stack 1.0 diagram \"Resources\" { node a \"A\" }") + .diagram + .ok_or("missing diagram")?; + for (case, expected) in [ + (0, "missing-theme fallback is unavailable"), + (1, "resolved theme font metrics are unavailable"), + ( + 2, + "node-kind fallback icon is unavailable in the resolved theme", + ), + (3, "resolved icon bytes are not embedded in stack-theme"), + ( + 4, + "missing-icon fallback is unavailable in the resolved theme", + ), + ] { + let mut catalog = stack_theme::catalog().clone(); + let mut diagram = diagram.clone(); + match case { + 0 => catalog.themes.clear(), + 1 => catalog.font_metrics.clear(), + 2 => { + for theme in &mut catalog.themes { + theme.icons.clear(); + } + } + 3 => { + for theme in &mut catalog.themes { + for icon in &mut theme.icons { + icon.asset.path = "missing.svg".into(); + } + } + } + _ => { + diagram.nodes[0].icon_id = Some("unknown".into()); + for theme in &mut catalog.themes { + theme.icons.clear(); + } + } + } + assert_eq!( + Resources::resolve(&diagram, &catalog, &[]) + .map(|_| ()) + .map_err(ResourceError::reason), + Err(expected) + ); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResourceWarning { MissingTheme(String), diff --git a/crates/stack-engine/src/routing.rs b/crates/stack-engine/src/routing.rs index 31d43c9..16f9fc6 100644 --- a/crates/stack-engine/src/routing.rs +++ b/crates/stack-engine/src/routing.rs @@ -9,6 +9,12 @@ use crate::scene::{Rect, SceneNode}; 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 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. +const FRAME_MARGIN: i64 = FRAME_CLEARANCE + 2_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct Point { @@ -33,6 +39,7 @@ pub(crate) struct SceneEdge { pub(crate) start_marker: Marker, pub(crate) end_marker: Marker, pub(crate) label_anchor: Option, + pub(crate) label_rect: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,8 +49,10 @@ pub(crate) fn route( edges: &[Edge], nodes: &[SceneNode], bounds: Rect, + fixed_obstacles: &[Rect], + frames: &[Rect], ) -> Result, RoutingError> { - let router = GridRouter::new(nodes, bounds); + let mut router = GridRouter::new(nodes, bounds, fixed_obstacles, frames); edges .iter() .map(|edge| { @@ -62,12 +71,18 @@ pub(crate) fn route( start_marker, end_marker, label_anchor, + label_rect: None, }) }) .collect() } -pub(crate) fn geometry_is_valid(edges: &[SceneEdge], nodes: &[SceneNode], bounds: Rect) -> bool { +pub(crate) fn geometry_is_valid( + edges: &[SceneEdge], + nodes: &[SceneNode], + bounds: Rect, + frames: &[Rect], +) -> bool { edges.iter().all(|edge| { let Some(source) = node_rect(nodes, &edge.from) else { return false; @@ -79,13 +94,34 @@ pub(crate) fn geometry_is_valid(edges: &[SceneEdge], nodes: &[SceneNode], bounds || !source.has_boundary_point(edge.path[0]) || !target.has_boundary_point(edge.path[edge.path.len() - 1]) || edge.path.iter().any(|point| !bounds.contains_point(*point)) - || edge.path.windows(2).any(|segment| { + || !departs_normally(edge.path[0], edge.path[1], source) + || !departs_normally( + edge.path[edge.path.len() - 1], + edge.path[edge.path.len() - 2], + target, + ) + || edge.path.windows(2).enumerate().any(|(index, segment)| { segment[0] == segment[1] || !segment_is_axis_aligned(segment[0], segment[1]) + || frames + .iter() + .any(|frame| !frame_segment_is_clear(segment[0], segment[1], *frame)) || nodes.iter().any(|node| { - segment_crosses_rect_interior(segment[0], segment[1], node.rect) + segment_hits_rect(segment[0], segment[1], node.rect) + && !(index == 0 + && node.id == edge.from + && departs_normally(segment[0], segment[1], node.rect)) + && !(index + 2 == edge.path.len() + && node.id == edge.to + && departs_normally(segment[1], segment[0], node.rect)) }) }) + || edge.path.windows(3).any(|points| { + (points[0].x == points[1].x) != (points[1].x == points[2].x) + && frames + .iter() + .any(|frame| !frame_bend_is_clear(points[1], *frame)) + }) { return false; } @@ -105,6 +141,55 @@ pub(crate) fn geometry_is_valid(edges: &[SceneEdge], nodes: &[SceneNode], bounds }) } +pub(crate) fn alternative_routes( + edge: &Edge, + 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); + } + } + } + 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); + 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, + }) + .collect()) +} + fn node_rect(nodes: &[SceneNode], identifier: &str) -> Option { nodes .iter() @@ -141,6 +226,56 @@ fn ports(rect: Rect) -> [Point; 4] { ] } +fn terminal_stubs(rect: Rect) -> [(Point, Point, usize); 4] { + let ports = ports(rect); + [ + ( + ports[0], + Point { + x: ports[0].x + ROUTE_MARGIN, + y: ports[0].y, + }, + 1, + ), + ( + ports[1], + Point { + x: ports[1].x, + y: ports[1].y + ROUTE_MARGIN, + }, + 2, + ), + ( + ports[2], + Point { + x: ports[2].x - ROUTE_MARGIN, + y: ports[2].y, + }, + 1, + ), + ( + ports[3], + Point { + x: ports[3].x, + y: ports[3].y - ROUTE_MARGIN, + }, + 2, + ), + ] +} + +fn departs_normally(terminal: Point, other: Point, rect: Rect) -> bool { + if terminal.y == other.y && between(terminal.y, rect.y, rect.y + rect.height) { + (terminal.x == rect.x && other.x < terminal.x) + || (terminal.x == rect.x + rect.width && other.x > terminal.x) + } else if terminal.x == other.x && between(terminal.x, rect.x, rect.x + rect.width) { + (terminal.y == rect.y && other.y < terminal.y) + || (terminal.y == rect.y + rect.height && other.y > terminal.y) + } else { + false + } +} + fn path_midpoint(path: &[Point]) -> Point { let total = path .windows(2) @@ -213,6 +348,61 @@ fn segment_crosses_rect_interior(start: Point, end: Point, rect: Rect) -> bool { } } +fn segment_hits_rect(start: Point, end: Point, rect: Rect) -> bool { + if start.y == end.y { + between(start.y, rect.y, rect.y + rect.height) + && start.x.min(end.x) < rect.x + rect.width + && start.x.max(end.x) > rect.x + } else if start.x == end.x { + between(start.x, rect.x, rect.x + rect.width) + && start.y.min(end.y) < rect.y + rect.height + && start.y.max(end.y) > rect.y + } else { + true + } +} + +fn expanded(rect: Rect) -> Rect { + Rect { + x: rect.x - ROUTE_MARGIN, + y: rect.y - ROUTE_MARGIN, + width: rect.width + 2 * ROUTE_MARGIN, + height: rect.height + 2 * ROUTE_MARGIN, + } +} + +fn frame_segment_is_clear(start: Point, end: Point, frame: Rect) -> bool { + // Only the finite, parallel sides constrain a segment. Extending their + // projection by the same margin also protects perpendicular corner grazes. + if start.y == end.y { + let near_side = (start.y - frame.y).abs() < FRAME_MARGIN + || (start.y - frame.y - frame.height).abs() < FRAME_MARGIN; + !near_side + || start.x.max(end.x) <= frame.x - FRAME_MARGIN + || start.x.min(end.x) >= frame.x + frame.width + FRAME_MARGIN + } else if start.x == end.x { + let near_side = (start.x - frame.x).abs() < FRAME_MARGIN + || (start.x - frame.x - frame.width).abs() < FRAME_MARGIN; + !near_side + || start.y.max(end.y) <= frame.y - FRAME_MARGIN + || start.y.min(end.y) >= frame.y + frame.height + FRAME_MARGIN + } else { + false + } +} + +fn frame_bend_is_clear(point: Point, frame: Rect) -> bool { + let near_horizontal = ((point.y - frame.y).abs() < FRAME_MARGIN + || (point.y - frame.y - frame.height).abs() < FRAME_MARGIN) + && point.x > frame.x - FRAME_MARGIN + && point.x < frame.x + frame.width + FRAME_MARGIN; + let near_vertical = ((point.x - frame.x).abs() < FRAME_MARGIN + || (point.x - frame.x - frame.width).abs() < FRAME_MARGIN) + && point.y > frame.y - FRAME_MARGIN + && point.y < frame.y + frame.height + FRAME_MARGIN; + !near_horizontal && !near_vertical +} + impl Rect { fn contains_point(self, point: Point) -> bool { point.x >= self.x @@ -240,14 +430,25 @@ impl Rect { #[derive(Debug)] struct GridRouter<'a> { nodes: &'a [SceneNode], + fixed_obstacles: &'a [Rect], + frames: &'a [Rect], bounds: Rect, xs: Vec, ys: Vec, valid: Vec, + bend_allowed: Vec, + links: Vec<[Option; 4]>, + shared: Vec<[u32; 2]>, + occupied: Vec<[u32; 2]>, } impl<'a> GridRouter<'a> { - fn new(nodes: &'a [SceneNode], bounds: Rect) -> Self { + fn new( + nodes: &'a [SceneNode], + bounds: Rect, + fixed_obstacles: &'a [Rect], + frames: &'a [Rect], + ) -> Self { let mut xs = vec![ bounds.x + ROUTE_MARGIN, bounds.x + bounds.width - ROUTE_MARGIN, @@ -260,78 +461,209 @@ impl<'a> GridRouter<'a> { let rect = node.rect; xs.extend([ rect.x - ROUTE_MARGIN, - rect.x, rect.x + rect.width / 2, - rect.x + rect.width, rect.x + rect.width + ROUTE_MARGIN, ]); ys.extend([ rect.y - ROUTE_MARGIN, - rect.y, rect.y + rect.height / 2, - rect.y + rect.height, rect.y + rect.height + ROUTE_MARGIN, ]); } + for rect in fixed_obstacles { + xs.extend([rect.x - ROUTE_MARGIN, rect.x + rect.width + ROUTE_MARGIN]); + ys.extend([rect.y - ROUTE_MARGIN, rect.y + rect.height + ROUTE_MARGIN]); + } + for frame in frames { + for side in [frame.x, frame.x + frame.width] { + xs.extend([side - FRAME_MARGIN, side + FRAME_MARGIN]); + } + for side in [frame.y, frame.y + frame.height] { + ys.extend([side - FRAME_MARGIN, side + FRAME_MARGIN]); + } + } 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(); ys.sort_unstable(); xs.dedup(); ys.dedup(); - let valid = ys + let obstacles = nodes + .iter() + .map(|node| expanded(node.rect)) + .chain(fixed_obstacles.iter().copied().map(expanded)) + .collect::>(); + let valid: Vec = ys .iter() .flat_map(|y| { + let obstacles = &obstacles; xs.iter().map(move |x| { let point = Point { x: *x, y: *y }; - nodes + obstacles .iter() - .all(|node| !node.rect.contains_point_interior(point)) + .all(|rect| !rect.contains_point_interior(point)) }) }) .collect(); - Self { + let bend_allowed = (0..valid.len()) + .map(|vertex| { + let point = Point { + x: xs[vertex % xs.len()], + y: ys[vertex / xs.len()], + }; + frames + .iter() + .all(|frame| frame_bend_is_clear(point, *frame)) + }) + .collect(); + let mut router = Self { nodes, + fixed_obstacles, + frames, bounds, xs, ys, + links: vec![[None; 4]; valid.len()], + shared: vec![[0; 2]; valid.len()], + occupied: vec![[0; 2]; valid.len()], valid, + bend_allowed, + }; + // The clearance grid is fixed for the entire scene. Search only reads + // these visibility links, rather than rechecking every obstacle. + for vertex in 0..router.valid.len() { + if !router.valid[vertex] { + continue; + } + let x = vertex % router.xs.len(); + let y = vertex / router.xs.len(); + let candidates = [ + x.checked_sub(1).map(|x| y * router.xs.len() + x), + (x + 1 < router.xs.len()).then_some(vertex + 1), + y.checked_sub(1).map(|y| y * router.xs.len() + x), + (y + 1 < router.ys.len()).then_some(vertex + router.xs.len()), + ]; + for (slot, candidate) in candidates.into_iter().enumerate() { + if let Some(next) = candidate { + if router.valid[next] + && obstacles.iter().all(|rect| { + !segment_crosses_rect_interior( + router.point(vertex), + router.point(next), + *rect, + ) + }) + && frames.iter().all(|frame| { + frame_segment_is_clear(router.point(vertex), router.point(next), *frame) + }) + { + router.links[vertex][slot] = Some(next); + } + } + } } + router + } + + fn route(&mut self, source: Rect, target: Rect) -> Option> { + self.route_between(source, target, None) } - fn route(&self, source: Rect, target: Rect) -> Option> { + fn route_between( + &mut self, + source: Rect, + target: Rect, + port_pair: Option<(usize, usize)>, + ) -> Option> { 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(); - for port in ports(source) { - let vertex = self.vertex(port)?; - let state = vertex * 3; - distances[state] = 0; - pending.push(Reverse((0_i64, state))); + let mut starts = Vec::new(); + for (index, (port, stub, axis)) in terminal_stubs(source).into_iter().enumerate() { + if port_pair.is_some_and(|(source_port, _)| source_port != index) { + continue; + } + let Some(vertex) = self.vertex(stub) else { + continue; + }; + if !self.stub_is_clear(port, stub, source) { + continue; + } + let state = vertex * 3 + axis; + distances[state] = ROUTE_MARGIN; + pending.push(Reverse((ROUTE_MARGIN, state))); + starts.push((state, port)); + if source == target { + break; + } } - let target_vertices = ports(target) + let targets = terminal_stubs(target) .into_iter() - .map(|port| self.vertex(port)) - .collect::>>()?; + .enumerate() + .filter_map(|(index, (port, stub, axis))| { + if port_pair.is_some_and(|(_, target_port)| target_port != 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)) + }) + .collect::>(); + if starts.is_empty() || targets.is_empty() { + return None; + } + let mut best: Option<(i64, usize, Point)> = None; while let Some(Reverse((cost, state))) = pending.pop() { if distances[state] != cost { continue; } + if best.is_some_and(|(best_cost, _, _)| cost > best_cost) { + break; + } let vertex = state / 3; let incoming_axis = state % 3; - if incoming_axis != 0 && target_vertices.contains(&vertex) { - return Some(self.reconstruct(state, &parents)); + for &(target_vertex, port, axis) in &targets { + if target_vertex == vertex && (incoming_axis == axis || self.bend_allowed[vertex]) { + let candidate = ( + cost + ROUTE_MARGIN + + if incoming_axis == axis { + 0 + } else { + BEND_PENALTY + }, + state, + port, + ); + if best.is_none_or(|best| candidate < best) { + best = Some(candidate); + } + } } - for (next_vertex, next_axis, length) in self.neighbors(vertex) { - let bend = if incoming_axis != 0 && incoming_axis != next_axis { + for (slot, next_vertex) in self.links[vertex].iter().enumerate() { + let Some(next_vertex) = *next_vertex else { + continue; + }; + let next_axis = if slot < 2 { 1 } else { 2 }; + if incoming_axis != next_axis && !self.bend_allowed[vertex] { + continue; + } + let length = manhattan(self.point(vertex), self.point(next_vertex)); + let bend = if incoming_axis != next_axis { BEND_PENALTY } else { 0 }; + let shared = i64::from(self.shared[vertex.min(next_vertex)][next_axis - 1]); + let crossing = i64::from(self.occupied[next_vertex][2 - next_axis]); let next_state = next_vertex * 3 + next_axis; - let next_cost = cost + length + bend; + let next_cost = cost + + length + + bend + + shared * length * SHARED_LENGTH_PENALTY + + crossing * CROSSING_PENALTY; if next_cost < distances[next_state] { distances[next_state] = next_cost; parents[next_state] = Some(state); @@ -339,7 +671,76 @@ impl<'a> GridRouter<'a> { } } } - None + let (_, 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); + for point in std::iter::once(source_port) + .chain(middle) + .chain(std::iter::once(target_port)) + { + push_point(&mut path, point); + } + if port_pair.is_none() { + self.reserve_path(&path); + } + Some(path) + } + + fn stub_is_clear(&self, port: Point, stub: Point, terminal: Rect) -> bool { + self.bounds.contains_point(port) + && self.bounds.contains_point(stub) + && self.nodes.iter().all(|node| { + node.rect == terminal + || !segment_crosses_rect_interior(port, stub, expanded(node.rect)) + }) + && self + .fixed_obstacles + .iter() + .all(|rect| !segment_crosses_rect_interior(port, stub, expanded(*rect))) + && self + .frames + .iter() + .all(|frame| frame_segment_is_clear(port, stub, *frame)) + } + + fn reserve_path(&mut self, path: &[Point]) { + for segment in path.windows(2) { + let horizontal = segment[0].y == segment[1].y; + let (coordinates, fixed, start, end, axis) = if horizontal { + ( + &self.xs, + self.ys.binary_search(&segment[0].y), + segment[0].x, + segment[1].x, + 0, + ) + } else { + ( + &self.ys, + self.xs.binary_search(&segment[0].x), + segment[0].y, + segment[1].y, + 1, + ) + }; + let Ok(fixed) = fixed else { continue }; + let first = coordinates.partition_point(|coordinate| *coordinate < start.min(end)); + let last = coordinates.partition_point(|coordinate| *coordinate <= start.max(end)); + for index in first..last { + let vertex = if horizontal { + fixed * self.xs.len() + index + } else { + index * self.xs.len() + fixed + }; + if self.valid[vertex] { + self.occupied[vertex][axis] += 1; + if index + 1 < last && self.links[vertex][axis * 2 + 1].is_some() { + self.shared[vertex][axis] += 1; + } + } + } + } } fn vertex(&self, point: Point) -> Option { @@ -356,53 +757,7 @@ impl<'a> GridRouter<'a> { } } - fn neighbors(&self, vertex: usize) -> Vec<(usize, usize, i64)> { - let x = vertex % self.xs.len(); - let y = vertex / self.xs.len(); - let mut neighbors = Vec::with_capacity(4); - self.scan_neighbor(x, y, -1, 0, 1, &mut neighbors); - self.scan_neighbor(x, y, 1, 0, 1, &mut neighbors); - self.scan_neighbor(x, y, 0, -1, 2, &mut neighbors); - self.scan_neighbor(x, y, 0, 1, 2, &mut neighbors); - neighbors - } - - fn scan_neighbor( - &self, - x: usize, - y: usize, - x_step: isize, - y_step: isize, - axis: usize, - neighbors: &mut Vec<(usize, usize, i64)>, - ) { - let mut candidate_x = x as isize + x_step; - let mut candidate_y = y as isize + y_step; - while candidate_x >= 0 - && candidate_y >= 0 - && candidate_x < self.xs.len() as isize - && candidate_y < self.ys.len() as isize - { - let candidate = candidate_y as usize * self.xs.len() + candidate_x as usize; - if self.valid[candidate] { - let start = self.point(y * self.xs.len() + x); - let end = self.point(candidate); - if self.bounds.contains_point(end) - && self - .nodes - .iter() - .all(|node| !segment_crosses_rect_interior(start, end, node.rect)) - { - neighbors.push((candidate, axis, manhattan(start, end))); - } - break; - } - candidate_x += x_step; - candidate_y += y_step; - } - } - - fn reconstruct(&self, state: usize, parents: &[Option]) -> Vec { + fn reconstruct(&self, state: usize, parents: &[Option]) -> (Vec, usize) { let mut states = Vec::new(); let mut cursor = Some(state); while let Some(current) = cursor { @@ -412,33 +767,285 @@ impl<'a> GridRouter<'a> { states.reverse(); let mut path = Vec::new(); + let root = states[0]; for state in states { - let point = self.point(state / 3); - if path.last() == Some(&point) { - continue; - } - if path.len() >= 2 { - let previous: Point = path[path.len() - 2]; - let last: Point = path[path.len() - 1]; - if (previous.x == last.x && last.x == point.x) - || (previous.y == last.y && last.y == point.y) - { - path.pop(); - } - } - path.push(point); + push_point(&mut path, self.point(state / 3)); } - path + (path, root) } } +fn push_point(path: &mut Vec, point: Point) { + if path.last() == Some(&point) { + return; + } + if path.len() >= 2 { + let previous = path[path.len() - 2]; + let last = path[path.len() - 1]; + if (previous.x == last.x && last.x == point.x) + || (previous.y == last.y && last.y == point.y) + { + path.pop(); + } + } + path.push(point); +} + #[cfg(test)] mod tests { use std::error::Error; use stack_compiler::ir::{EdgeDirection, EdgeKind}; - use super::Marker; + use super::{Marker, Point, SceneEdge}; + use crate::scene::{Rect, SceneNode}; + + fn test_node(id: &str, x: i64, y: i64) -> SceneNode { + SceneNode { + id: id.to_owned(), + parent_group_id: None, + rect: Rect { + x, + y, + width: 100_000, + height: 100_000, + }, + } + } + + fn test_edge(path: &[(i64, i64)]) -> SceneEdge { + SceneEdge { + from: "source".to_owned(), + to: "target".to_owned(), + direction: EdgeDirection::Forward, + kind: EdgeKind::Flow, + label: None, + path: path.iter().map(|&(x, y)| Point { x, y }).collect(), + start_marker: Marker::None, + end_marker: Marker::Arrow, + label_anchor: None, + label_rect: None, + } + } + + fn test_bounds() -> Rect { + Rect { + x: 0, + y: 0, + width: 500_000, + height: 400_000, + } + } + + fn test_frame() -> Rect { + Rect { + x: 100_000, + y: 100_000, + width: 200_000, + height: 200_000, + } + } + + #[test] + fn frame_rejects_parallel_border_travel() { + assert!(!super::frame_segment_is_clear( + Point { + x: 120_000, + y: 100_000 + }, + Point { + x: 280_000, + y: 100_000 + }, + test_frame(), + )); + } + + #[test] + fn frame_rejects_parallel_routes_inside_the_painted_clearance() { + for y in [83_000, 117_000, 283_000, 317_000] { + assert!(!super::frame_segment_is_clear( + Point { x: 120_000, y }, + Point { x: 280_000, y }, + test_frame(), + )); + } + for x in [83_000, 117_000, 283_000, 317_000] { + assert!(!super::frame_segment_is_clear( + Point { x, y: 120_000 }, + Point { x, y: 280_000 }, + test_frame(), + )); + } + } + + #[test] + fn frame_rejects_corner_crossings_and_bends_on_the_border() { + assert!(!super::frame_segment_is_clear( + Point { + x: 110_000, + y: 60_000 + }, + Point { + x: 110_000, + y: 160_000 + }, + test_frame(), + )); + assert!(!super::frame_bend_is_clear( + Point { + x: 100_000, + y: 200_000 + }, + test_frame(), + )); + assert!(!super::frame_bend_is_clear( + Point { + x: 90_000, + y: 90_000 + }, + test_frame(), + )); + } + + #[test] + fn frame_allows_normal_crossings_and_clear_parallel_lanes() { + for (start, end) in [ + ((200_000, 60_000), (200_000, 160_000)), + ((60_000, 200_000), (160_000, 200_000)), + ((120_000, 82_000), (280_000, 82_000)), + ((120_000, 118_000), (280_000, 118_000)), + ((10_000, 100_000), (50_000, 100_000)), + ] { + assert!(super::frame_segment_is_clear( + Point { + x: start.0, + y: start.1 + }, + Point { x: end.0, y: end.1 }, + test_frame(), + )); + } + } + + #[test] + fn frame_routes_leave_a_clear_parallel_lane() -> Result<(), Box> { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let frames = [Rect { + x: 150_000, + y: 160_000, + width: 200_000, + height: 160_000, + }]; + let mut edge = test_edge(&[(120_000, 150_000), (320_000, 150_000)]); + assert!(super::geometry_is_valid( + &[edge.clone()], + &nodes, + test_bounds(), + &[], + )); + assert!(!super::geometry_is_valid( + &[edge.clone()], + &nodes, + test_bounds(), + &frames, + )); + let mut router = super::GridRouter::new(&nodes, test_bounds(), &[], &frames); + edge.path = router + .route(nodes[0].rect, nodes[1].rect) + .ok_or("no clear route beside the frame")?; + assert!(super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &frames, + )); + Ok(()) + } + + #[test] + fn frame_routes_cross_straight_into_the_group() -> Result<(), Box> { + let nodes = [ + test_node("source", 20_000, 160_000), + test_node("target", 220_000, 160_000), + ]; + let frames = [Rect { + x: 150_000, + y: 110_000, + width: 200_000, + height: 200_000, + }]; + let mut router = super::GridRouter::new(&nodes, test_bounds(), &[], &frames); + let path = router + .route(nodes[0].rect, nodes[1].rect) + .ok_or("normal frame crossing is missing")?; + let edge = test_edge(&[(120_000, 210_000), (220_000, 210_000)]); + assert_eq!(path, edge.path); + assert!(super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &frames, + )); + Ok(()) + } + + #[test] + fn rejects_tangential_terminal_departure() { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let edge = test_edge(&[(70_000, 100_000), (370_000, 100_000)]); + assert!(!super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); + } + + #[test] + fn rejects_intervening_node_boundary_travel() { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("blocker", 170_000, 50_000), + test_node("target", 320_000, 100_000), + ]; + let edge = test_edge(&[(120_000, 150_000), (320_000, 150_000)]); + assert!(!super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); + } + + #[test] + fn rejects_later_contact_with_the_source_boundary() { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let edge = test_edge(&[ + (120_000, 150_000), + (150_000, 150_000), + (150_000, 100_000), + (70_000, 100_000), + (70_000, 50_000), + (370_000, 50_000), + (370_000, 100_000), + ]); + assert!(!super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); + } fn scene_from(source: &[u8]) -> Result> { let compiled = stack_compiler::compile_bytes(source); @@ -476,11 +1083,103 @@ mod tests { #[test] fn routes_around_an_intervening_node() -> Result<(), Box> { - let scene = scene_from( - b"stack 1.0 diagram \"Obstacle\" { layout { direction right } node left \"Left\" node blocker \"Blocker\" node right \"Right\" edge left -> right }", - )?; - assert!(scene.edges[0].path.len() >= 4); - assert!(scene.geometry_is_valid()); + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("blocker", 170_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let mut router = super::GridRouter::new(&nodes, test_bounds(), &[], &[]); + let path = router + .route(nodes[0].rect, nodes[2].rect) + .ok_or("no route around the intervening node")?; + assert!(path.len() >= 4); + let mut edge = test_edge(&[]); + edge.path = path; + assert!(super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); + Ok(()) + } + + #[test] + fn routes_around_reserved_title_boxes() -> Result<(), Box> { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let title = Rect { + x: 180_000, + y: 125_000, + width: 70_000, + height: 50_000, + }; + let fixed = [title]; + let mut router = super::GridRouter::new(&nodes, test_bounds(), &fixed, &[]); + let path = router + .route(nodes[0].rect, nodes[1].rect) + .ok_or("no route around the reserved title")?; + assert!( + path.windows(2) + .all(|segment| { !super::segment_hits_rect(segment[0], segment[1], title) }) + ); + let mut edge = test_edge(&[]); + edge.path = path; + assert!(super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); + Ok(()) + } + + #[test] + fn separates_repeated_routes_when_another_clear_lane_exists() -> Result<(), Box> { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let mut router = super::GridRouter::new(&nodes, test_bounds(), &[], &[]); + let first = router + .route(nodes[0].rect, nodes[1].rect) + .ok_or("first route is missing")?; + let second = router + .route(nodes[0].rect, nodes[1].rect) + .ok_or("second route is missing")?; + assert_ne!(first, second); + for path in [first, second] { + let mut edge = test_edge(&[]); + edge.path = path; + assert!(super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); + } + Ok(()) + } + + #[test] + fn self_edges_leave_and_return_through_different_normal_ports() -> Result<(), Box> { + let nodes = [test_node("source", 100_000, 100_000)]; + let mut router = super::GridRouter::new(&nodes, test_bounds(), &[], &[]); + let path = router + .route(nodes[0].rect, nodes[0].rect) + .ok_or("self route is missing")?; + assert_ne!(path.first(), path.last()); + let mut edge = test_edge(&[]); + edge.to = "source".to_owned(); + edge.path = path; + assert!(super::geometry_is_valid( + &[edge], + &nodes, + test_bounds(), + &[] + )); Ok(()) } @@ -537,4 +1236,82 @@ mod tests { assert!(!invalid.geometry_is_valid()); Ok(()) } + + #[test] + fn missing_endpoints_and_enclosed_terminals_fail_without_partial_routes() { + let nodes = [ + test_node("source", 20_000, 100_000), + test_node("target", 320_000, 100_000), + ]; + let edge = stack_compiler::ir::Edge { + from: "source".into(), + to: "target".into(), + direction: EdgeDirection::Forward, + kind: EdgeKind::Flow, + label: None, + }; + assert!(super::route(std::slice::from_ref(&edge), &nodes, test_bounds(), &[], &[]).is_ok()); + for missing in [0, 1] { + let mut corrupted = edge.clone(); + if missing == 0 { + corrupted.from = "absent".into(); + } else { + corrupted.to = "absent".into(); + } + assert!( + super::route( + std::slice::from_ref(&corrupted), + &nodes, + test_bounds(), + &[], + &[] + ) + .is_err() + ); + assert!( + super::alternative_routes(&corrupted, &nodes, test_bounds(), &[], &[]).is_err() + ); + } + let covering_obstacle = [test_bounds()]; + assert!( + super::route( + std::slice::from_ref(&edge), + &nodes, + test_bounds(), + &covering_obstacle, + &[] + ) + .is_err() + ); + assert_eq!( + super::alternative_routes(&edge, &nodes, test_bounds(), &covering_obstacle, &[]), + Ok(Vec::new()) + ); + let mut invalid = test_edge(&[(120_000, 150_000), (320_000, 150_000)]); + invalid.to = "absent".into(); + assert!(!super::geometry_is_valid( + &[invalid], + &nodes, + test_bounds(), + &[] + )); + } + + #[test] + fn unsupported_diagonal_segments_fail_closed_in_routing_predicates() { + let start = Point { x: 0, y: 0 }; + let end = Point { + x: 400_000, + y: 400_000, + }; + assert!(!super::segment_is_axis_aligned(start, end)); + assert!(!super::point_is_on_segment(start, start, end)); + assert!(super::segment_hits_rect(start, end, test_frame())); + assert!(super::segment_crosses_rect_interior( + start, + end, + test_frame() + )); + assert!(!super::frame_segment_is_clear(start, end, test_frame())); + } } diff --git a/crates/stack-engine/src/scene.rs b/crates/stack-engine/src/scene.rs index ea01c06..bb47944 100644 --- a/crates/stack-engine/src/scene.rs +++ b/crates/stack-engine/src/scene.rs @@ -18,7 +18,7 @@ const NODE_ICON_SIZE: i64 = 24_000; const NODE_ICON_GAP: i64 = 12_000; const NODE_DETAIL_GAP: i64 = 4_000; const ITEM_GAP: i64 = 24_000; -const GROUP_PADDING: i64 = 24_000; +pub(crate) const GROUP_PADDING: i64 = 40_000; const GROUP_LABEL_GAP: i64 = 12_000; const DIAGRAM_PADDING: i64 = 32_000; const DIAGRAM_TITLE_GAP: i64 = 20_000; @@ -78,6 +78,8 @@ pub(crate) struct Scene { pub(crate) groups: Vec, pub(crate) edges: Vec, pub(crate) unsatisfied_orders: Vec, + text_obstacles: Vec, + connector_width: i64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -159,11 +161,22 @@ impl Scene { } } - if !routing::geometry_is_valid(&self.edges, &self.nodes, self.bounds) { + let frames = self + .groups + .iter() + .map(|group| group.rect) + .collect::>(); + if !routing::geometry_is_valid(&self.edges, &self.nodes, self.bounds, &frames) { return false; } - true + crate::labels::geometry_is_valid( + &self.edges, + &self.nodes, + self.bounds, + &label_obstacles(&self.text_obstacles, &frames), + self.connector_width, + ) } fn parent_content_rect(&self, parent_group_id: Option<&str>) -> Option { @@ -226,6 +239,13 @@ struct Arrangement { items: Vec, } +struct LayoutContext<'a> { + diagram: &'a Diagram, + theme: &'a Theme, + metrics: &'a FontMetrics, + gap_scale: i64, +} + pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result { let theme = selected_theme(diagram, catalog)?; let metrics = catalog @@ -233,20 +253,42 @@ pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result continue, + result => return result, + } + } + Err(SceneError::EdgeRoutingFailed) +} + +fn layout_attempt(context: &LayoutContext<'_>) -> Result { + let LayoutContext { + diagram, + theme, + metrics, + .. + } = *context; let mut sizes = BTreeMap::new(); for node in &diagram.nodes { sizes.insert(node.id.clone(), node_size(node, theme, metrics)); } for group in diagram.groups.iter().rev() { - let children = arrange(&group.children, group.layout.as_ref(), &sizes)?; + let children = arrange(&group.children, group.layout.as_ref(), &sizes, context)?; sizes.insert( group.id.clone(), group_size(group, children.size, &theme.typography, metrics), ); } - let root = arrange(&diagram.children, diagram.layout.as_ref(), &sizes)?; + let root = arrange(&diagram.children, diagram.layout.as_ref(), &sizes, context)?; let title_height = line_height( theme.typography.group_label_size_milli_px, &theme.typography, @@ -256,15 +298,34 @@ pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result Result Result, SceneError>>()?; - let edges = routing::route(&diagram.edges, &nodes, bounds) + let mut text_obstacles = vec![Rect { + x: horizontal_padding, + y: root_origin.y + - DIAGRAM_TITLE_GAP + - i64::from(theme.typography.group_label_size_milli_px), + width: title_width, + height: title_height, + }]; + for group in &groups { + let authored = diagram + .groups + .iter() + .find(|entry| entry.id == group.id) + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + text_obstacles.push(Rect { + x: group.rect.x + GROUP_PADDING, + y: group.rect.y + GROUP_PADDING, + width: text_width( + &authored.label, + theme.typography.group_label_size_milli_px, + metrics, + ), + height: title_height, + }); + } + let frames = groups.iter().map(|group| group.rect).collect::>(); + let mut edges = routing::route(&diagram.edges, &nodes, bounds, &text_obstacles, &frames) .map_err(|_| SceneError::EdgeRoutingFailed)?; - let unsatisfied_orders = unsatisfied_orders(diagram, &nodes, &groups)?; + let label_obstacles = label_obstacles(&text_obstacles, &frames); + if crate::labels::place(&mut edges, &nodes, bounds, &label_obstacles, theme, metrics).is_err() { + edges = route_with_reserved_labels(context, &nodes, bounds, &text_obstacles, &frames)?; + } + let unsatisfied_orders = unsatisfied_orders(diagram, &nodes, &groups, root.direction)?; Ok(Scene { bounds, @@ -331,19 +422,122 @@ pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result Vec { + // Label backgrounds must not mask group borders. Nine pixels conservatively + // reserve an 8px painted gap plus the current 1px frame stroke. These finite + // perimeter strips are label-only: routes may still cross frames normally. + const GAP: i64 = 9_000; + let mut obstacles = fixed_text.to_vec(); + for frame in frames { + for y in [frame.y, frame.y + frame.height] { + obstacles.push(Rect { + x: frame.x - GAP, + y: y - GAP, + width: frame.width + 2 * GAP, + height: 2 * GAP, + }); + } + for x in [frame.x, frame.x + frame.width] { + obstacles.push(Rect { + x: x - GAP, + y: frame.y - GAP, + width: 2 * GAP, + height: frame.height + 2 * GAP, + }); + } + } + obstacles +} + +fn route_with_reserved_labels( + context: &LayoutContext<'_>, + nodes: &[SceneNode], + bounds: Rect, + fixed_text: &[Rect], + frames: &[Rect], +) -> Result, SceneError> { + let label_obstacles = label_obstacles(fixed_text, frames); + let mut order = (0..context.diagram.edges.len()).collect::>(); + order.sort_by_key(|index| { + let width = context.diagram.edges[*index] + .label + .as_ref() + .map_or(0, |label| { + crate::labels::dimensions(label, context.theme, context.metrics).width + }); + (std::cmp::Reverse(width), *index) + }); + let mut previous: Vec = Vec::with_capacity(order.len()); + 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], + nodes, + bounds, + &obstacles, + frames, + ) + .map_err(|_| SceneError::EdgeRoutingFailed)? + .pop() + .ok_or(SceneError::EdgeRoutingFailed)?; + if crate::labels::place_next( + &mut edge, + &previous, + nodes, + bounds, + &label_obstacles, + context.theme, + context.metrics, + ) + .is_err() + { + edge = routing::alternative_routes( + &context.diagram.edges[index], + nodes, + bounds, + &obstacles, + frames, + ) + .map_err(|_| SceneError::EdgeRoutingFailed)? + .into_iter() + .find_map(|mut alternative| { + crate::labels::place_next( + &mut alternative, + &previous, + nodes, + bounds, + &label_obstacles, + context.theme, + context.metrics, + ) + .is_ok() + .then_some(alternative) + }) + .ok_or(SceneError::EdgeRoutingFailed)?; + } + previous.push(edge); + } + let mut indexed = order.into_iter().zip(previous).collect::>(); + indexed.sort_by_key(|(index, _)| *index); + Ok(indexed.into_iter().map(|(_, edge)| edge).collect()) +} + fn unsatisfied_orders( diagram: &Diagram, nodes: &[SceneNode], groups: &[SceneGroup], + root_direction: SceneDirection, ) -> Result, SceneError> { let mut unsatisfied = Vec::new(); if let Some(layout) = &diagram.layout { if let Some(order) = &layout.order { - let direction = resolve_direction(diagram.children.len(), layout.direction); - if !order_is_satisfied(order, direction, nodes, groups) { + if !order_is_satisfied(order, root_direction, nodes, groups) { unsatisfied.push(SceneScope::Diagram); } } @@ -470,9 +664,8 @@ fn group_size( } struct Placer<'a> { - diagram: &'a Diagram, + context: &'a LayoutContext<'a>, sizes: &'a BTreeMap, - theme: &'a Theme, node_rects: BTreeMap, group_rects: BTreeMap, group_content_rects: BTreeMap, @@ -480,11 +673,10 @@ struct Placer<'a> { } impl<'a> Placer<'a> { - fn new(diagram: &'a Diagram, sizes: &'a BTreeMap, theme: &'a Theme) -> Self { + fn new(context: &'a LayoutContext<'a>, sizes: &'a BTreeMap) -> Self { Self { - diagram, + context, sizes, - theme, node_rects: BTreeMap::new(), group_rects: BTreeMap::new(), group_content_rects: BTreeMap::new(), @@ -498,7 +690,7 @@ impl<'a> Placer<'a> { layout: Option<&Layout>, origin: Point, ) -> Result<(), SceneError> { - let arrangement = arrange(children, layout, self.sizes)?; + let arrangement = arrange(children, layout, self.sizes, self.context)?; for placed in arrangement.items { let child = children .get(placed.index) @@ -511,23 +703,34 @@ impl<'a> Placer<'a> { }; match child { ElementId::Node(identifier) => { - if self.diagram.nodes.iter().all(|node| node.id != *identifier) { + if self + .context + .diagram + .nodes + .iter() + .all(|node| node.id != *identifier) + { return Err(SceneError::InvalidIntermediateRepresentation); } self.node_rects.insert(identifier.clone(), rect); } ElementId::Group(identifier) => { let group = self + .context .diagram .groups .iter() .find(|group| group.id == *identifier) .ok_or(SceneError::InvalidIntermediateRepresentation)?; - let child_arrangement = - arrange(&group.children, group.layout.as_ref(), self.sizes)?; + let child_arrangement = arrange( + &group.children, + group.layout.as_ref(), + self.sizes, + self.context, + )?; let label_height = line_height( - self.theme.typography.group_label_size_milli_px, - &self.theme.typography, + self.context.theme.typography.group_label_size_milli_px, + &self.context.theme.typography, ); let content_origin = Point { x: rect.x + GROUP_PADDING, @@ -557,20 +760,118 @@ fn arrange( children: &[ElementId], layout: Option<&Layout>, sizes: &BTreeMap, + context: &LayoutContext<'_>, ) -> Result { if children.is_empty() { return Err(SceneError::InvalidIntermediateRepresentation); } - let direction = resolve_direction(children.len(), layout.and_then(|layout| layout.direction)); - let ranks = ranks(children, layout); + let ranks = graph_ranks(children, layout, context.diagram); + let authored = layout.and_then(|layout| layout.direction); + let preferred = resolve_direction(children.len(), authored); + let first = arrange_direction(children, sizes, context, &ranks, preferred)?; + let incident = context.diagram.edges.iter().any(|edge| { + scope_owner(children, &edge.from, context.diagram).is_some() + || scope_owner(children, &edge.to, context.diagram).is_some() + }); + let simple_unlabeled_pair = children.len() <= 2 + && context + .diagram + .edges + .iter() + .all(|edge| edge.label.is_none()); + if authored.is_some() || !incident || simple_unlabeled_pair { + return Ok(first); + } + let alternative = match preferred { + SceneDirection::Right => SceneDirection::Down, + SceneDirection::Down => SceneDirection::Right, + }; + let second = arrange_direction(children, sizes, context, &ranks, alternative)?; + let cost = |arrangement: &Arrangement| { + ( + arrangement.size.width.max(arrangement.size.height), + i128::from(arrangement.size.width) * i128::from(arrangement.size.height), + ) + }; + Ok(if cost(&second) < cost(&first) { + second + } else { + first + }) +} + +fn arrange_direction( + children: &[ElementId], + sizes: &BTreeMap, + context: &LayoutContext<'_>, + ranks: &[Vec], + direction: SceneDirection, +) -> Result { + let label_size = context + .diagram + .edges + .iter() + .filter_map(|edge| { + let from = scope_owner(children, &edge.from, context.diagram)?; + let to = scope_owner(children, &edge.to, context.diagram)?; + if from == to { + return None; + } + edge.label + .as_ref() + .map(|label| crate::labels::dimensions(label, context.theme, context.metrics)) + }) + .fold( + Size { + width: 0, + height: 0, + }, + |size, label| Size { + width: size.width.max(label.width), + height: size.height.max(label.height), + }, + ); + let primary_label = match direction { + SceneDirection::Right => label_size.width, + SceneDirection::Down => label_size.height, + }; + let cross_label = match direction { + SceneDirection::Right => label_size.height, + SceneDirection::Down => label_size.width, + }; + let primary_gap = ITEM_GAP.max(primary_label + if primary_label > 0 { 32_000 } else { 0 }) + * context.gap_scale; + let cross_gap = + ITEM_GAP.max(cross_label + if cross_label > 0 { 16_000 } else { 0 }) * context.gap_scale; let mut items = Vec::with_capacity(children.len()); let mut primary_cursor = 0; - let mut cross_extent = 0; + let cross_extent = ranks + .iter() + .map(|rank| { + rank.iter() + .map(|index| { + sizes + .get(children[*index].as_str()) + .map(|size| match direction { + SceneDirection::Right => size.height, + SceneDirection::Down => size.width, + }) + .ok_or(SceneError::InvalidIntermediateRepresentation) + }) + .collect::, _>>() + .map(|sizes| { + sizes.into_iter().sum::() + cross_gap * rank.len().saturating_sub(1) as i64 + }) + }) + .collect::, _>>()? + .into_iter() + .max() + .unwrap_or(0); for rank in ranks { let mut primary_extent = 0; let mut rank_cross_cursor = 0; - for index in &rank { + for index in rank { let size = sizes .get(children[*index].as_str()) .copied() @@ -584,10 +885,10 @@ fn arrange( SceneDirection::Down => size.width, }; } - rank_cross_cursor += ITEM_GAP * (rank.len().saturating_sub(1) as i64); + rank_cross_cursor += cross_gap * (rank.len().saturating_sub(1) as i64); - let mut cross_cursor = 0; - for index in rank { + let mut cross_cursor = (cross_extent - rank_cross_cursor) / 2; + for &index in rank { let size = sizes .get(children[index].as_str()) .copied() @@ -607,16 +908,22 @@ fn arrange( }, }; cross_cursor += match direction { - SceneDirection::Right => size.height + ITEM_GAP, - SceneDirection::Down => size.width + ITEM_GAP, + SceneDirection::Right => size.height + cross_gap, + SceneDirection::Down => size.width + cross_gap, }; items.push(PlacedItem { index, rect }); } - cross_extent = cross_extent.max(rank_cross_cursor); - primary_cursor += primary_extent + ITEM_GAP; + primary_cursor += primary_extent + primary_gap; } - primary_cursor -= ITEM_GAP; + primary_cursor -= primary_gap; + // Keep the node spine centered within its ranks while reserving space to + // its right for vertical edge labels, before computing parent bounds. + let cross_extent = if direction == SceneDirection::Down && label_size.width > 0 { + cross_extent.max(cross_extent / 2 + label_size.width + 16_000) + } else { + cross_extent + }; let size = match direction { SceneDirection::Right => Size { width: primary_cursor, @@ -634,6 +941,125 @@ fn arrange( }) } +fn scope_owner(children: &[ElementId], node_id: &str, diagram: &Diagram) -> Option { + let mut identifier = node_id; + for _ in 0..=diagram.groups.len() + 1 { + if let Some(index) = children + .iter() + .position(|child| child.as_str() == identifier) + { + return Some(index); + } + identifier = diagram + .nodes + .iter() + .find(|node| node.id == identifier) + .and_then(|node| node.parent_group_id.as_deref()) + .or_else(|| { + diagram + .groups + .iter() + .find(|group| group.id == identifier) + .and_then(|group| group.parent_group_id.as_deref()) + })?; + } + None +} + +fn graph_ranks( + children: &[ElementId], + layout: Option<&Layout>, + diagram: &Diagram, +) -> Vec> { + let blocks = ranks(children, layout); + let count = blocks.len(); + let mut reachable = vec![vec![false; count]; count]; + let mut has_connection = false; + for edge in &diagram.edges { + let (Some(from), Some(to)) = ( + scope_owner(children, &edge.from, diagram), + scope_owner(children, &edge.to, diagram), + ) else { + continue; + }; + if from == to { + continue; + } + let source = blocks + .iter() + .position(|block| block.contains(&from)) + .unwrap_or(0); + let target = blocks + .iter() + .position(|block| block.contains(&to)) + .unwrap_or(0); + if source != target { + reachable[source][target] = true; + has_connection = true; + } + } + if !has_connection { + return blocks; + } + + // Collapse strongly connected components before layering. This also makes + // cycles introduced by authored same-rank constraints deterministic. + let adjacency = reachable.clone(); + for via in 0..count { + for from in 0..count { + for to in 0..count { + reachable[from][to] |= reachable[from][via] && reachable[via][to]; + } + } + } + let component = (0..count) + .map(|index| { + (0..index) + .find(|other| reachable[index][*other] && reachable[*other][index]) + .unwrap_or(index) + }) + .collect::>(); + let mut depth = vec![0; count]; + for _ in 0..count { + let mut changed = false; + for from in 0..count { + for to in 0..count { + let (source, target) = (component[from], component[to]); + if adjacency[from][to] && source != target && depth[target] <= depth[source] { + depth[target] = depth[source] + 1; + changed = true; + } + } + } + if !changed { + break; + } + } + let mut layers = BTreeMap::>::new(); + for (index, block) in blocks.into_iter().enumerate() { + layers + .entry(depth[component[index]]) + .or_default() + .extend(block); + } + let order = layout.and_then(|layout| layout.order.as_deref()); + layers + .into_values() + .map(|mut layer| { + layer.sort_by_key(|member| { + order + .and_then(|order| { + order + .iter() + .position(|entry| entry == children[*member].as_str()) + }) + .map_or((1, *member), |position| (0, position)) + }); + layer + }) + .collect() +} + fn ranks(children: &[ElementId], layout: Option<&Layout>) -> Vec> { let same_ranks = layout.map_or(&[][..], |layout| layout.same_ranks.as_slice()); let order = layout.and_then(|layout| layout.order.as_deref()); diff --git a/crates/stack-engine/src/svg.rs b/crates/stack-engine/src/svg.rs index 7182fd4..13b6f81 100644 --- a/crates/stack-engine/src/svg.rs +++ b/crates/stack-engine/src/svg.rs @@ -5,16 +5,13 @@ use stack_theme::{NodeShape, PaletteToken, Theme}; use crate::EngineMetadata; use crate::resources::{ResolvedNode, Resources}; -use crate::routing::{Marker, Point, SceneEdge}; -use crate::scene::{self, Rect, Scene, SceneGroup, SceneNode}; +use crate::routing::{Marker, SceneEdge}; +use crate::scene::{GROUP_PADDING, Rect, Scene, SceneGroup, SceneNode}; const GROUP_CORNER_RADIUS: i64 = 12_000; const ICON_SIZE: i64 = 24_000; const NODE_HORIZONTAL_PADDING: i64 = 20_000; const ICON_TEXT_GAP: i64 = 12_000; -const EDGE_LABEL_HORIZONTAL_PADDING: i64 = 6_000; -const EDGE_LABEL_VERTICAL_PADDING: i64 = 4_000; -const EDGE_LABEL_CLEARANCE: i64 = 8_000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct SvgError { @@ -40,10 +37,10 @@ pub(crate) fn render( "\n", pixel_dimension(scene.bounds.width), pixel_dimension(scene.bounds.height), - scene.bounds.x, - scene.bounds.y, - scene.bounds.width, - scene.bounds.height, + pixel_dimension(scene.bounds.x), + pixel_dimension(scene.bounds.y), + pixel_dimension(scene.bounds.width), + pixel_dimension(scene.bounds.height), escape_attribute(&metadata.engine_version), escape_attribute(&theme.id), escape_attribute(&metadata.theme_catalog_version), @@ -93,10 +90,10 @@ pub(crate) fn render( )); output.push_str(&format!( " \n", - scene.bounds.x, - scene.bounds.y, - scene.bounds.width, - scene.bounds.height, + pixel_dimension(scene.bounds.x), + pixel_dimension(scene.bounds.y), + pixel_dimension(scene.bounds.width), + pixel_dimension(scene.bounds.height), escape_attribute(&theme.palette.canvas) )); render_definitions(&mut output, theme); @@ -146,8 +143,8 @@ fn render_definitions(output: &mut String, theme: &Theme) { output.push_str(" \n"); output.push_str(&format!( " \n", - theme.connector.arrow_size_milli_px, - theme.connector.arrow_size_milli_px + pixel_dimension(i64::from(theme.connector.arrow_size_milli_px)), + pixel_dimension(i64::from(theme.connector.arrow_size_milli_px)) )); output.push_str(&format!( " \n", @@ -166,11 +163,11 @@ fn render_diagram_title( let baseline = scene.content_rect.y - 20_000; output.push_str(&format!( " {}\n", - scene.content_rect.x, - baseline, + pixel_dimension(scene.content_rect.x), + pixel_dimension(baseline), escape_attribute(&resources.theme.palette.text), escape_attribute(&resources.metrics.family), - typography.group_label_size_milli_px, + pixel_dimension(i64::from(typography.group_label_size_milli_px)), typography.label_weight, escape_text(&diagram.title) )); @@ -185,22 +182,22 @@ fn render_group(output: &mut String, group: &SceneGroup, label: &str, resources: )); output.push_str(&format!(" {}\n", escape_text(label))); output.push_str(&format!( - " \n", - group.rect.x, - group.rect.y, - group.rect.width, - group.rect.height, - GROUP_CORNER_RADIUS, + " \n", + pixel_dimension(group.rect.x), + pixel_dimension(group.rect.y), + pixel_dimension(group.rect.width), + pixel_dimension(group.rect.height), + pixel_dimension(GROUP_CORNER_RADIUS), escape_attribute(&theme.palette.surface_muted), escape_attribute(&theme.palette.border) )); output.push_str(&format!( " {}\n", - group.rect.x + 24_000, - group.rect.y + 24_000 + i64::from(theme.typography.group_label_size_milli_px), + pixel_dimension(group.rect.x + GROUP_PADDING), + pixel_dimension(group.rect.y + GROUP_PADDING + i64::from(theme.typography.group_label_size_milli_px)), escape_attribute(&theme.palette.text), escape_attribute(&resources.metrics.family), - theme.typography.group_label_size_milli_px, + pixel_dimension(i64::from(theme.typography.group_label_size_milli_px)), theme.typography.label_weight, escape_text(label) )); @@ -249,7 +246,7 @@ fn render_edge( let points = edge .path .iter() - .map(|point| format!("{},{}", point.x, point.y)) + .map(|point| format!("{},{}", pixel_dimension(point.x), pixel_dimension(point.y))) .collect::>() .join(" "); let marker_start = marker_attribute("marker-start", edge.start_marker); @@ -259,7 +256,7 @@ fn render_edge( " \n", points, escape_attribute(palette_color(resources.theme, resources.theme.connector.stroke)), - resources.theme.connector.width_milli_px, + pixel_dimension(i64::from(resources.theme.connector.width_milli_px)), marker_start, marker_end, dash @@ -286,7 +283,7 @@ fn dash_attribute(edge: &SceneEdge, theme: &Theme) -> String { " stroke-dasharray=\"{}\"", values .iter() - .map(u32::to_string) + .map(|value| pixel_dimension(i64::from(*value))) .collect::>() .join(" ") ) @@ -294,126 +291,24 @@ fn dash_attribute(edge: &SceneEdge, theme: &Theme) -> String { } fn render_edge_labels(output: &mut String, scene: &Scene, resources: &Resources<'_>) { - let mut occupied = scene.nodes.iter().map(|node| node.rect).collect::>(); output.push_str(" \n"); for edge in &scene.edges { - if let (Some(label), Some(anchor)) = (&edge.label, edge.label_anchor) { - let dimensions = edge_label_dimensions(label, resources); - let anchor = choose_label_anchor( - anchor, - dimensions.width, - dimensions.height, - scene.bounds, - &occupied, - ); - render_edge_label(output, label, anchor, dimensions, resources); - occupied.push(centered_rect(anchor, dimensions.width, dimensions.height)); + if let (Some(label), Some(rect)) = (&edge.label, edge.label_rect) { + render_edge_label(output, label, rect, resources); } } output.push_str(" \n"); } -fn edge_label_dimensions(label: &str, resources: &Resources<'_>) -> Rect { - let typography = &resources.theme.typography; - let text_width = scene::text_width( - label, - typography.edge_label_size_milli_px, - resources.metrics, - ); - let line_height = scene::line_height(typography.edge_label_size_milli_px, typography); - Rect { - x: 0, - y: 0, - width: text_width + 2 * EDGE_LABEL_HORIZONTAL_PADDING, - height: line_height + 2 * EDGE_LABEL_VERTICAL_PADDING, - } -} - -fn choose_label_anchor( - preferred: Point, - width: i64, - height: i64, - bounds: Rect, - occupied: &[Rect], -) -> Point { - let mut candidates = vec![preferred]; - for rect in occupied { - candidates.extend([ - Point { - x: preferred.x, - y: rect.y - height / 2 - EDGE_LABEL_CLEARANCE, - }, - Point { - x: preferred.x, - y: rect.y + rect.height + height / 2 + EDGE_LABEL_CLEARANCE, - }, - Point { - x: rect.x - width / 2 - EDGE_LABEL_CLEARANCE, - y: preferred.y, - }, - Point { - x: rect.x + rect.width + width / 2 + EDGE_LABEL_CLEARANCE, - y: preferred.y, - }, - ]); - } - candidates - .into_iter() - .enumerate() - .filter(|(_, candidate)| { - let candidate = centered_rect(*candidate, width, height); - rect_contains(bounds, candidate) - && occupied - .iter() - .all(|occupied| !rects_overlap(candidate, *occupied)) - }) - .min_by_key(|(index, candidate)| { - ( - (candidate.x - preferred.x).abs() + (candidate.y - preferred.y).abs(), - *index, - ) - }) - .map_or(preferred, |(_, candidate)| candidate) -} - -fn centered_rect(center: Point, width: i64, height: i64) -> Rect { - Rect { - x: center.x - width / 2, - y: center.y - height / 2, - width, - height, - } -} - -fn rect_contains(outer: Rect, inner: Rect) -> bool { - inner.x >= outer.x - && inner.y >= outer.y - && inner.x + inner.width <= outer.x + outer.width - && inner.y + inner.height <= outer.y + outer.height -} - -fn rects_overlap(left: Rect, right: Rect) -> bool { - left.x < right.x + right.width - && right.x < left.x + left.width - && left.y < right.y + right.height - && right.y < left.y + left.height -} - -fn render_edge_label( - output: &mut String, - label: &str, - anchor: Point, - dimensions: Rect, - resources: &Resources<'_>, -) { +fn render_edge_label(output: &mut String, label: &str, rect: Rect, resources: &Resources<'_>) { let typography = &resources.theme.typography; output.push_str(&format!( - " \n \n", + " \n \n", escape_attribute(label), - anchor.x - dimensions.width / 2, - anchor.y - dimensions.height / 2, - dimensions.width, - dimensions.height, + pixel_dimension(rect.x), + pixel_dimension(rect.y), + pixel_dimension(rect.width), + pixel_dimension(rect.height), escape_attribute(palette_color( resources.theme, resources.theme.connector.label_background @@ -421,14 +316,14 @@ fn render_edge_label( )); output.push_str(&format!( " {}\n \n", - anchor.x, - anchor.y, + pixel_dimension(rect.x + rect.width / 2), + pixel_dimension(rect.y + rect.height / 2), escape_attribute(palette_color( resources.theme, resources.theme.connector.text )), escape_attribute(&resources.metrics.family), - typography.edge_label_size_milli_px, + pixel_dimension(i64::from(typography.edge_label_size_milli_px)), escape_text(label) )); } @@ -466,69 +361,69 @@ fn render_node_shape(output: &mut String, rect: Rect, resolved: &ResolvedNode<'_ let stroke = escape_attribute(palette_color(theme, resolved.visual.stroke)); match resolved.visual.shape { NodeShape::RoundedRectangle | NodeShape::Capsule => output.push_str(&format!( - " \n", - rect.x, - rect.y, - rect.width, - rect.height, - resolved.visual.corner_radius_milli_px, + " \n", + pixel_dimension(rect.x), + pixel_dimension(rect.y), + pixel_dimension(rect.width), + pixel_dimension(rect.height), + pixel_dimension(i64::from(resolved.visual.corner_radius_milli_px)), fill, stroke )), NodeShape::Circle => output.push_str(&format!( - " \n", - rect.x + rect.width / 2, - rect.y + rect.height / 2, - rect.width.min(rect.height) / 2, + " \n", + pixel_dimension(rect.x + rect.width / 2), + pixel_dimension(rect.y + rect.height / 2), + pixel_dimension(rect.width.min(rect.height) / 2), fill, stroke )), NodeShape::Cylinder => { let radius_y = 10_000; output.push_str(&format!( - " \n", - rect.x, - rect.y + radius_y, - rect.y + rect.height - radius_y, - rect.x, - rect.y + rect.height, - rect.x + rect.width, - rect.y + rect.height, - rect.x + rect.width, - rect.y + rect.height - radius_y, - rect.y + radius_y, - rect.x + rect.width, - rect.y, - rect.x, - rect.y, - rect.x, - rect.y + radius_y, + " \n", + pixel_dimension(rect.x), + pixel_dimension(rect.y + radius_y), + pixel_dimension(rect.y + rect.height - radius_y), + pixel_dimension(rect.x), + pixel_dimension(rect.y + rect.height), + pixel_dimension(rect.x + rect.width), + pixel_dimension(rect.y + rect.height), + pixel_dimension(rect.x + rect.width), + pixel_dimension(rect.y + rect.height - radius_y), + pixel_dimension(rect.y + radius_y), + pixel_dimension(rect.x + rect.width), + pixel_dimension(rect.y), + pixel_dimension(rect.x), + pixel_dimension(rect.y), + pixel_dimension(rect.x), + pixel_dimension(rect.y + radius_y), fill, stroke )); output.push_str(&format!( - " \n", - rect.x + rect.width / 2, - rect.y + radius_y, - rect.width / 2, - radius_y, + " \n", + pixel_dimension(rect.x + rect.width / 2), + pixel_dimension(rect.y + radius_y), + pixel_dimension(rect.width / 2), + pixel_dimension(radius_y), stroke )); } NodeShape::Hexagon => output.push_str(&format!( - " \n", - rect.x + 16_000, - rect.y, - rect.x + rect.width - 16_000, - rect.y, - rect.x + rect.width, - rect.y + rect.height / 2, - rect.x + rect.width - 16_000, - rect.y + rect.height, - rect.x + 16_000, - rect.y + rect.height, - rect.x, - rect.y + rect.height / 2, + " \n", + pixel_dimension(rect.x + 16_000), + pixel_dimension(rect.y), + pixel_dimension(rect.x + rect.width - 16_000), + pixel_dimension(rect.y), + pixel_dimension(rect.x + rect.width), + pixel_dimension(rect.y + rect.height / 2), + pixel_dimension(rect.x + rect.width - 16_000), + pixel_dimension(rect.y + rect.height), + pixel_dimension(rect.x + 16_000), + pixel_dimension(rect.y + rect.height), + pixel_dimension(rect.x), + pixel_dimension(rect.y + rect.height / 2), fill, stroke )), @@ -547,10 +442,10 @@ fn render_icon( let view_box = resolved.icon_view_box; output.push_str(&format!( " {} \n", - rect.x + NODE_HORIZONTAL_PADDING, - rect.y + (rect.height - ICON_SIZE) / 2, - ICON_SIZE, - ICON_SIZE, + pixel_dimension(rect.x + NODE_HORIZONTAL_PADDING), + pixel_dimension(rect.y + (rect.height - ICON_SIZE) / 2), + pixel_dimension(ICON_SIZE), + pixel_dimension(ICON_SIZE), view_box[0], view_box[1], view_box[2], @@ -584,22 +479,22 @@ fn render_node_text( }; output.push_str(&format!( " {}\n", - text_x, - label_y, + pixel_dimension(text_x), + pixel_dimension(label_y), escape_attribute(palette_color(resources.theme, resolved.visual.text)), escape_attribute(&resources.metrics.family), - typography.node_label_size_milli_px, + pixel_dimension(i64::from(typography.node_label_size_milli_px)), typography.label_weight, escape_text(&node.label) )); if let Some(detail) = &node.detail { output.push_str(&format!( " {}\n", - text_x, - rect.y + rect.height / 2 + 10_000, + pixel_dimension(text_x), + pixel_dimension(rect.y + rect.height / 2 + 10_000), escape_attribute(&resources.theme.palette.text_muted), escape_attribute(&resources.metrics.family), - typography.node_detail_size_milli_px, + pixel_dimension(i64::from(typography.node_detail_size_milli_px)), typography.detail_weight, escape_text(detail) )); @@ -662,12 +557,15 @@ fn node_kind_name(kind: NodeKind) -> &'static str { } fn pixel_dimension(milli_pixels: i64) -> String { - let whole = milli_pixels / 1000; - let remainder = milli_pixels % 1000; + // Serialize only at the SVG boundary; scene geometry stays in integer milli-pixels. + let magnitude = milli_pixels.unsigned_abs(); + let sign = if milli_pixels < 0 { "-" } else { "" }; + let whole = magnitude / 1000; + let remainder = magnitude % 1000; if remainder == 0 { - whole.to_string() + format!("{sign}{whole}") } else { - format!("{whole}.{remainder:03}") + format!("{sign}{whole}.{remainder:03}") .trim_end_matches('0') .to_owned() } @@ -692,11 +590,11 @@ fn escape_attribute(value: &str) -> String { #[cfg(test)] mod tests { + use super::{escape_attribute, escape_text, pixel_dimension}; + use crate::resources::Resources; use crate::routing::Point; use crate::scene::Rect; - use super::{choose_label_anchor, escape_attribute, escape_text, pixel_dimension}; - #[test] fn escapes_untrusted_text_and_attributes() { assert_eq!(escape_text("<&>"), "<&>"); @@ -711,34 +609,247 @@ mod tests { } #[test] - fn moves_edge_labels_to_the_nearest_clear_position() { - let bounds = Rect { - x: 0, - y: 0, - width: 500_000, - height: 500_000, - }; - let node = Rect { - x: 80_000, - y: 100_000, - width: 160_000, + fn formats_signed_milli_pixels_including_integer_limits() { + for (input, expected) in [ + (0, "0"), + (1, "0.001"), + (-1, "-0.001"), + (-10, "-0.01"), + (-100, "-0.1"), + (-1000, "-1"), + (-1001, "-1.001"), + (i64::MIN, "-9223372036854775.808"), + (i64::MAX, "9223372036854775.807"), + ] { + assert_eq!(pixel_dimension(input), expected); + } + } + + #[test] + fn serializes_the_outer_view_box_and_all_text_sizes_in_css_pixels() + -> Result<(), Box> { + let source = b"stack 1.0 diagram \"Units\" { group g \"Group\" { node a \"A\" { detail \"Detail\" } node b \"B\" } edge a -> b \"Label\" }"; + let diagram = stack_compiler::compile_bytes(source) + .diagram + .ok_or("valid diagram")?; + let scene = crate::scene::layout(&diagram, stack_theme::catalog())?; + let output = crate::Engine::bundled() + .render(source)? + .svg + .ok_or("SVG document")?; + assert!(output.contains(&format!( + "width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\"", + pixel_dimension(scene.bounds.width), + pixel_dimension(scene.bounds.height), + pixel_dimension(scene.bounds.width), + pixel_dimension(scene.bounds.height), + ))); + let theme = &stack_theme::catalog().themes[0]; + for size in [ + theme.typography.node_label_size_milli_px, + theme.typography.node_detail_size_milli_px, + theme.typography.edge_label_size_milli_px, + theme.typography.group_label_size_milli_px, + ] { + assert!(output.contains(&format!( + "font-size=\"{}\"", + pixel_dimension(i64::from(size)) + ))); + assert!(!output.contains(&format!("font-size=\"{size}\""))); + } + assert!(output.contains("font-weight=\"600\"")); + assert!(output.contains("rx=\"12\"")); + assert!(output.contains("fill-opacity=\"0.55\"")); + assert!(output.contains("stroke-width=\"1\"")); + Ok(()) + } + + #[test] + fn renderer_rejects_missing_semantic_and_resource_records() + -> Result<(), Box> { + let source = b"stack 1.0 diagram \"Integrity\" { group g \"Group\" { node a \"A\" node b \"B\" } edge a -> b }"; + let diagram = stack_compiler::compile_bytes(source) + .diagram + .ok_or("missing diagram")?; + let scene = crate::scene::layout(&diagram, stack_theme::catalog())?; + let metadata = crate::Engine::bundled().check(source)?.metadata; + for (case, expected) in [ + (0, "scene group has no normalized IR record"), + (1, "edge source has no normalized node"), + (2, "edge target has no normalized node"), + (3, "scene node has no normalized IR record"), + (4, "scene node has no resolved theme record"), + ] { + let mut invalid = diagram.clone(); + let mut scene = scene.clone(); + let mut resources = Resources::resolve(&diagram, stack_theme::catalog(), &[]) + .map_err(|error| error.reason())?; + match case { + 0 => invalid.groups.clear(), + 1 => { + invalid.nodes.remove(0); + } + 2 => { + invalid.nodes.remove(1); + } + 3 => { + scene.edges.clear(); + invalid.nodes.clear(); + } + _ => resources.nodes.clear(), + } + assert_eq!( + super::render(&invalid, &scene, &resources, &metadata) + .map_err(|error| error.reason()), + Err(expected) + ); + } + Ok(()) + } + + #[test] + fn serializes_every_node_shape_in_css_pixels() -> Result<(), Box> { + let rect = Rect { + x: -1250, + y: 267_800, + width: 208_300, height: 72_000, }; - assert_eq!( - choose_label_anchor( - Point { - x: 80_000, - y: 136_000, - }, - 32_000, - 24_000, - bounds, - &[node], + for (kind, expected) in [ + ( + "service", + " Result<(), Box> { + let source = b"stack 1.0 diagram \"Routes\" { node a \"A\" node b \"B\" edge a -> b }"; + let diagram = stack_compiler::compile_bytes(source) + .diagram + .ok_or("valid diagram")?; + let resources = Resources::resolve(&diagram, stack_theme::catalog(), &[]) + .map_err(|error| error.reason())?; + let mut scene = crate::scene::layout(&diagram, stack_theme::catalog())?; + let edge = scene.edges.first_mut().ok_or("scene edge")?; + edge.path = vec![Point { x: -1, y: 1250 }, Point { x: 20_000, y: 1250 }]; + let mut output = String::new(); + super::render_edge(&mut output, edge, &diagram, &resources) + .map_err(|error| error.reason())?; + assert!(output.contains("points=\"-0.001,1.25 20,1.25\"")); + assert!(output.contains(&format!( + "stroke-width=\"{}\"", + pixel_dimension(i64::from(resources.theme.connector.width_milli_px)) + ))); + let mut theme = resources.theme.clone(); + theme.connector.arrow_size_milli_px = 10_125; + theme.connector.dash_milli_px = Some(vec![1250, 2001]); + assert_eq!( + super::dash_attribute(edge, &theme), + " stroke-dasharray=\"1.25 2.001\"" + ); + edge.direction = stack_compiler::ir::EdgeDirection::Association; + assert_eq!( + super::dash_attribute(edge, &theme), + " stroke-dasharray=\"8 6\"" ); + super::render_definitions(&mut output, &theme); + assert!(output.contains("markerWidth=\"10.125\" markerHeight=\"10.125\"")); + assert!(output.contains("refX=\"9000\" refY=\"5000\" viewBox=\"0 0 10000 10000\"")); + assert!(output.contains("d=\"M 0 0 L 10000 5000 L 0 10000 z\"")); + Ok(()) + } + + #[test] + fn scales_the_icon_viewport_without_rewriting_its_local_geometry() + -> Result<(), Box> { + let diagram = stack_compiler::compile_bytes( + b"stack 1.0 diagram \"Icon\" { node a \"A\" { kind database } }", + ) + .diagram + .ok_or("valid diagram")?; + let resources = Resources::resolve(&diagram, stack_theme::catalog(), &[]) + .map_err(|error| error.reason())?; + let resolved = resources.node("a").ok_or("resolved node")?; + let mut output = String::new(); + super::render_icon( + &mut output, + Rect { + x: -1250, + y: 267_800, + width: 208_300, + height: 72_000, + }, + resolved, + resources.theme, + ) + .map_err(|error| error.reason())?; + assert!(output.contains( + " Result<(), Box> { + let engine = crate::Engine::bundled(); + let source = + b"stack 1.0 diagram \"Labels\" { node a \"A\" node b \"B\" edge a -> b \"request\" }"; + let compiled = stack_compiler::compile_bytes(source); + let scene = crate::scene::layout( + &compiled.diagram.ok_or("valid diagram")?, + stack_theme::catalog(), + )?; + let rect = scene.edges[0].label_rect.ok_or("scene label geometry")?; + let output = engine.render(source)?.svg.ok_or("SVG document")?; + assert!(output.contains(&format!( + " n2 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-01", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n0 -> n1 \"Connection 0\" edge n1 -> n3 \"Connection 1\" edge n2 -> n3 \"Connection 2\" }" + } + }, + { + "name": "dag-02", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n2 -> n3 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n1 -> n4 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" }" + } + }, + { + "name": "dag-03", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n0 -> n5 \"Connection 0\" edge n1 -> n4 \"Connection 1\" edge n3 -> n5 \"Connection 2\" edge n2 -> n3 \"Connection 3\" edge n1 -> n2 \"Connection 4\" }" + } + }, + { + "name": "dag-04", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n2 -> n5 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n3 -> n5 \"retry request after timeout\" edge n0 -> n5 \"retry request after timeout\" edge n0 -> n6 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-05", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n2 -> n5 \"Connection 0\" edge n1 -> n3 \"Connection 1\" edge n0 -> n1 \"Connection 2\" edge n0 -> n2 \"Connection 3\" edge n0 -> n3 \"Connection 4\" edge n4 -> n6 \"Connection 5\" edge n2 -> n4 \"Connection 6\" }" + } + }, + { + "name": "dag-06", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-07", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n0 -> n2 \"Connection 0\" edge n0 -> n1 \"Connection 1\" edge n2 -> n3 \"Connection 2\" edge n0 -> n3 \"Connection 3\" edge n1 -> n3 \"Connection 4\" edge n1 -> n2 \"Connection 5\" }" + } + }, + { + "name": "dag-08", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n1 -> n4 \"retry request after timeout\" edge n0 -> n4 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" }" + } + }, + { + "name": "dag-09", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n3 -> n5 \"Connection 0\" edge n0 -> n1 \"Connection 1\" edge n0 -> n4 \"Connection 2\" edge n2 -> n5 \"Connection 3\" edge n0 -> n3 \"Connection 4\" edge n1 -> n4 \"Connection 5\" edge n1 -> n3 \"Connection 6\" edge n1 -> n5 \"Connection 7\" edge n4 -> n5 \"Connection 8\" edge n0 -> n2 \"Connection 9\" edge n0 -> n5 \"Connection 10\" }" + } + }, + { + "name": "dag-10", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n0 -> n4 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n4 -> n6 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n3 -> n6 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n4 -> n5 \"retry request after timeout\" edge n5 -> n6 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n3 -> n5 \"retry request after timeout\" edge n1 -> n5 \"retry request after timeout\" }" + } + }, + { + "name": "dag-11", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n2 -> n5 \"Connection 0\" edge n0 -> n3 \"Connection 1\" edge n6 -> n7 \"Connection 2\" edge n5 -> n6 \"Connection 3\" edge n3 -> n6 \"Connection 4\" edge n1 -> n2 \"Connection 5\" edge n1 -> n6 \"Connection 6\" edge n4 -> n7 \"Connection 7\" edge n2 -> n3 \"Connection 8\" edge n5 -> n7 \"Connection 9\" edge n0 -> n6 \"Connection 10\" edge n3 -> n4 \"Connection 11\" edge n0 -> n7 \"Connection 12\" }" + } + }, + { + "name": "dag-12", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" }" + } + }, + { + "name": "dag-13", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n0 -> n2 \"Connection 0\" edge n2 -> n3 \"Connection 1\" edge n0 -> n1 \"Connection 2\" }" + } + }, + { + "name": "dag-14", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n1 -> n4 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" }" + } + }, + { + "name": "dag-15", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n1 -> n4 \"Connection 0\" edge n0 -> n1 \"Connection 1\" edge n3 -> n5 \"Connection 2\" edge n4 -> n5 \"Connection 3\" edge n0 -> n2 \"Connection 4\" }" + } + }, + { + "name": "dag-16", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n2 -> n5 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n4 -> n6 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n6 \"retry request after timeout\" }" + } + }, + { + "name": "dag-17", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n0 -> n7 \"Connection 0\" edge n4 -> n5 \"Connection 1\" edge n6 -> n7 \"Connection 2\" edge n2 -> n4 \"Connection 3\" edge n1 -> n3 \"Connection 4\" edge n3 -> n5 \"Connection 5\" edge n0 -> n1 \"Connection 6\" }" + } + }, + { + "name": "dag-18", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" }" + } + }, + { + "name": "dag-19", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n0 -> n3 \"Connection 0\" edge n2 -> n3 \"Connection 1\" edge n1 -> n2 \"Connection 2\" edge n0 -> n2 \"Connection 3\" edge n0 -> n1 \"Connection 4\" edge n1 -> n3 \"Connection 5\" }" + } + }, + { + "name": "dag-20", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n2 -> n3 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n4 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n4 \"retry request after timeout\" }" + } + }, + { + "name": "dag-21", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n3 -> n4 \"Connection 0\" edge n3 -> n5 \"Connection 1\" edge n1 -> n3 \"Connection 2\" edge n0 -> n4 \"Connection 3\" edge n0 -> n1 \"Connection 4\" edge n0 -> n2 \"Connection 5\" edge n1 -> n5 \"Connection 6\" edge n0 -> n5 \"Connection 7\" edge n0 -> n3 \"Connection 8\" edge n2 -> n5 \"Connection 9\" edge n1 -> n4 \"Connection 10\" }" + } + }, + { + "name": "dag-22", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n3 -> n6 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n5 -> n6 \"retry request after timeout\" edge n0 -> n5 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n6 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n0 -> n4 \"retry request after timeout\" }" + } + }, + { + "name": "dag-23", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n1 -> n7 \"Connection 0\" edge n4 -> n6 \"Connection 1\" edge n3 -> n7 \"Connection 2\" edge n2 -> n5 \"Connection 3\" edge n1 -> n4 \"Connection 4\" edge n1 -> n2 \"Connection 5\" edge n1 -> n6 \"Connection 6\" edge n0 -> n7 \"Connection 7\" edge n2 -> n7 \"Connection 8\" edge n1 -> n5 \"Connection 9\" edge n3 -> n6 \"Connection 10\" edge n4 -> n5 \"Connection 11\" edge n1 -> n3 \"Connection 12\" }" + } + }, + { + "name": "dag-24", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-25", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n1 -> n2 \"Connection 0\" edge n0 -> n1 \"Connection 1\" edge n1 -> n3 \"Connection 2\" }" + } + }, + { + "name": "dag-26", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n0 -> n3 \"retry request after timeout\" edge n1 -> n4 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" }" + } + }, + { + "name": "dag-27", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n1 -> n2 \"Connection 0\" edge n2 -> n3 \"Connection 1\" edge n1 -> n4 \"Connection 2\" edge n0 -> n3 \"Connection 3\" edge n4 -> n5 \"Connection 4\" }" + } + }, + { + "name": "dag-28", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n0 -> n1 \"retry request after timeout\" edge n2 -> n6 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n4 -> n6 \"retry request after timeout\" }" + } + }, + { + "name": "dag-29", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n2 -> n5 \"Connection 0\" edge n1 -> n6 \"Connection 1\" edge n3 -> n7 \"Connection 2\" edge n2 -> n3 \"Connection 3\" edge n6 -> n7 \"Connection 4\" edge n0 -> n1 \"Connection 5\" edge n3 -> n5 \"Connection 6\" }" + } + }, + { + "name": "dag-30", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n0 -> n1 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-31", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n1 -> n3 \"Connection 0\" edge n2 -> n3 \"Connection 1\" edge n0 -> n1 \"Connection 2\" edge n0 -> n3 \"Connection 3\" edge n1 -> n2 \"Connection 4\" edge n0 -> n2 \"Connection 5\" }" + } + }, + { + "name": "dag-32", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n1 -> n4 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n4 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" }" + } + }, + { + "name": "dag-33", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n2 -> n4 \"Connection 0\" edge n0 -> n3 \"Connection 1\" edge n3 -> n5 \"Connection 2\" edge n3 -> n4 \"Connection 3\" edge n0 -> n2 \"Connection 4\" edge n2 -> n5 \"Connection 5\" edge n1 -> n5 \"Connection 6\" edge n2 -> n3 \"Connection 7\" edge n1 -> n2 \"Connection 8\" edge n0 -> n1 \"Connection 9\" edge n0 -> n4 \"Connection 10\" }" + } + }, + { + "name": "dag-34", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n2 -> n6 \"retry request after timeout\" edge n0 -> n6 \"retry request after timeout\" edge n2 -> n5 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n3 -> n5 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n4 -> n6 \"retry request after timeout\" edge n5 -> n6 \"retry request after timeout\" edge n3 -> n6 \"retry request after timeout\" edge n4 -> n5 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-35", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n0 -> n1 \"Connection 0\" edge n0 -> n4 \"Connection 1\" edge n2 -> n6 \"Connection 2\" edge n1 -> n5 \"Connection 3\" edge n1 -> n6 \"Connection 4\" edge n0 -> n2 \"Connection 5\" edge n2 -> n7 \"Connection 6\" edge n1 -> n4 \"Connection 7\" edge n2 -> n5 \"Connection 8\" edge n1 -> n2 \"Connection 9\" edge n3 -> n7 \"Connection 10\" edge n2 -> n4 \"Connection 11\" edge n3 -> n5 \"Connection 12\" }" + } + }, + { + "name": "dag-36", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-37", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n1 -> n3 \"Connection 0\" edge n1 -> n2 \"Connection 1\" edge n0 -> n3 \"Connection 2\" }" + } + }, + { + "name": "dag-38", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n1 -> n3 \"retry request after timeout\" edge n1 -> n4 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" }" + } + }, + { + "name": "dag-39", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n1 -> n3 \"Connection 0\" edge n0 -> n5 \"Connection 1\" edge n2 -> n4 \"Connection 2\" edge n2 -> n3 \"Connection 3\" edge n2 -> n5 \"Connection 4\" }" + } + }, + { + "name": "dag-40", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n1 -> n3 \"retry request after timeout\" edge n5 -> n6 \"retry request after timeout\" edge n3 -> n6 \"retry request after timeout\" edge n0 -> n6 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n0 -> n5 \"retry request after timeout\" }" + } + }, + { + "name": "dag-41", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n0 -> n6 \"Connection 0\" edge n0 -> n4 \"Connection 1\" edge n2 -> n5 \"Connection 2\" edge n4 -> n6 \"Connection 3\" edge n0 -> n7 \"Connection 4\" edge n5 -> n7 \"Connection 5\" edge n1 -> n4 \"Connection 6\" }" + } + }, + { + "name": "dag-42", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n0 -> n1 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-43", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n0 -> n3 \"Connection 0\" edge n0 -> n2 \"Connection 1\" edge n1 -> n2 \"Connection 2\" edge n2 -> n3 \"Connection 3\" edge n0 -> n1 \"Connection 4\" edge n1 -> n3 \"Connection 5\" }" + } + }, + { + "name": "dag-44", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n4 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n1 -> n4 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" }" + } + }, + { + "name": "dag-45", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n0 -> n1 \"Connection 0\" edge n4 -> n5 \"Connection 1\" edge n1 -> n4 \"Connection 2\" edge n0 -> n5 \"Connection 3\" edge n0 -> n4 \"Connection 4\" edge n3 -> n5 \"Connection 5\" edge n0 -> n2 \"Connection 6\" edge n2 -> n4 \"Connection 7\" edge n2 -> n3 \"Connection 8\" edge n0 -> n3 \"Connection 9\" edge n2 -> n5 \"Connection 10\" }" + } + }, + { + "name": "dag-46", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n0 -> n1 \"retry request after timeout\" edge n4 -> n5 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n4 -> n6 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n1 -> n6 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n3 -> n5 \"retry request after timeout\" edge n2 -> n5 \"retry request after timeout\" }" + } + }, + { + "name": "dag-47", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n3 -> n7 \"Connection 0\" edge n2 -> n4 \"Connection 1\" edge n2 -> n5 \"Connection 2\" edge n0 -> n6 \"Connection 3\" edge n5 -> n6 \"Connection 4\" edge n1 -> n6 \"Connection 5\" edge n2 -> n7 \"Connection 6\" edge n6 -> n7 \"Connection 7\" edge n1 -> n4 \"Connection 8\" edge n0 -> n1 \"Connection 9\" edge n4 -> n5 \"Connection 10\" edge n0 -> n3 \"Connection 11\" edge n1 -> n3 \"Connection 12\" }" + } + }, + { + "name": "dag-48", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n0 -> n1 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-49", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n0 -> n3 \"Connection 0\" edge n0 -> n2 \"Connection 1\" edge n0 -> n1 \"Connection 2\" }" + } + }, + { + "name": "dag-50", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n0 -> n4 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" }" + } + }, + { + "name": "dag-51", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n1 -> n5 \"Connection 0\" edge n0 -> n4 \"Connection 1\" edge n2 -> n3 \"Connection 2\" edge n0 -> n3 \"Connection 3\" edge n0 -> n1 \"Connection 4\" }" + } + }, + { + "name": "dag-52", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n0 -> n4 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n0 -> n5 \"retry request after timeout\" edge n5 -> n6 \"retry request after timeout\" }" + } + }, + { + "name": "dag-53", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n5 -> n7 \"Connection 0\" edge n0 -> n3 \"Connection 1\" edge n1 -> n2 \"Connection 2\" edge n3 -> n7 \"Connection 3\" edge n3 -> n5 \"Connection 4\" edge n5 -> n6 \"Connection 5\" edge n1 -> n7 \"Connection 6\" }" + } + }, + { + "name": "dag-54", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" edge n1 -> n2 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" }" + } + }, + { + "name": "dag-55", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" edge n2 -> n3 \"Connection 0\" edge n0 -> n1 \"Connection 1\" edge n1 -> n3 \"Connection 2\" edge n0 -> n2 \"Connection 3\" edge n0 -> n3 \"Connection 4\" edge n1 -> n2 \"Connection 5\" }" + } + }, + { + "name": "dag-56", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" edge n0 -> n1 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n3 \"retry request after timeout\" edge n0 -> n4 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n1 -> n4 \"retry request after timeout\" }" + } + }, + { + "name": "dag-57", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction right } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" edge n2 -> n3 \"Connection 0\" edge n3 -> n5 \"Connection 1\" edge n1 -> n3 \"Connection 2\" edge n1 -> n2 \"Connection 3\" edge n1 -> n5 \"Connection 4\" edge n3 -> n4 \"Connection 5\" edge n2 -> n5 \"Connection 6\" edge n2 -> n4 \"Connection 7\" edge n0 -> n3 \"Connection 8\" edge n0 -> n5 \"Connection 9\" edge n1 -> n4 \"Connection 10\" }" + } + }, + { + "name": "dag-58", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { layout { direction down } node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" edge n2 -> n4 \"retry request after timeout\" edge n0 -> n6 \"retry request after timeout\" edge n0 -> n5 \"retry request after timeout\" edge n0 -> n1 \"retry request after timeout\" edge n0 -> n2 \"retry request after timeout\" edge n3 -> n4 \"retry request after timeout\" edge n4 -> n5 \"retry request after timeout\" edge n1 -> n5 \"retry request after timeout\" edge n1 -> n2 \"retry request after timeout\" edge n2 -> n3 \"retry request after timeout\" edge n1 -> n3 \"retry request after timeout\" edge n3 -> n5 \"retry request after timeout\" }" + } + }, + { + "name": "dag-59", + "input": { + "kind": "string", + "value": "stack 1.0 diagram \"Probe\" { node n0 \"Node 0\" node n1 \"Node 1\" node n2 \"Node 2\" node n3 \"Node 3\" node n4 \"Node 4\" node n5 \"Node 5\" node n6 \"Node 6\" node n7 \"Node 7\" edge n2 -> n5 \"Connection 0\" edge n3 -> n6 \"Connection 1\" edge n1 -> n2 \"Connection 2\" edge n1 -> n5 \"Connection 3\" edge n4 -> n6 \"Connection 4\" edge n2 -> n6 \"Connection 5\" edge n1 -> n6 \"Connection 6\" edge n2 -> n4 \"Connection 7\" edge n0 -> n4 \"Connection 8\" edge n0 -> n6 \"Connection 9\" edge n0 -> n1 \"Connection 10\" edge n3 -> n7 \"Connection 11\" edge n1 -> n7 \"Connection 12\" }" + } + } +] diff --git a/crates/stack-engine/tests/layout_corpus.rs b/crates/stack-engine/tests/layout_corpus.rs index 9406681..9b7a36a 100644 --- a/crates/stack-engine/tests/layout_corpus.rs +++ b/crates/stack-engine/tests/layout_corpus.rs @@ -79,6 +79,28 @@ struct ProviderAssetInput { svg: String, } +#[test] +fn svg_viewport_and_geometry_use_the_same_pixel_units() -> Result<(), Box> { + let root = repository_root(); + let catalog = load_catalog(&root)?; + let case = &catalog.cases[0]; + let source = fs::read(root.join("layout-corpus").join(&case.source))?; + let svg = Engine::bundled() + .render(&source)? + .svg + .ok_or("missing SVG")?; + let document = roxmltree::Document::parse(&svg)?; + let element = document.root_element(); + let width = element.attribute("width").ok_or("missing width")?; + let height = element.attribute("height").ok_or("missing height")?; + assert_eq!( + element.attribute("viewBox"), + Some(format!("0 0 {width} {height}").as_str()) + ); + validate_svg(case, &svg)?; + Ok(()) +} + #[test] fn layout_corpus_matches_approved_snapshots() -> Result<(), Box> { let root = repository_root(); @@ -327,7 +349,7 @@ fn validate_svg(case: &LayoutCase, svg: &str) -> Result<(), Box> { .attribute("viewBox") .ok_or_else(|| format!("{} has no viewBox", case.id))? .split_ascii_whitespace() - .map(str::parse::) + .map(svg_dimension_milli_px) .collect::, _>>()?; if view_box.len() != 4 || view_box[0] != 0 @@ -403,6 +425,46 @@ fn validate_svg(case: &LayoutCase, svg: &str) -> Result<(), Box> { Ok(()) } +fn svg_dimension_milli_px(value: &str) -> Result> { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.len() > 3 + || !fraction.bytes().all(|byte| byte.is_ascii_digit()) + || (value.contains('.') && fraction.is_empty()) + { + return Err("unsupported SVG pixel dimension".into()); + } + let whole: i64 = whole.parse()?; + let fraction: i64 = format!("{fraction:0<3}").parse()?; + whole + .checked_mul(1000) + .and_then(|whole| whole.checked_add(fraction)) + .ok_or_else(|| "SVG pixel dimension overflow".into()) +} + +#[test] +fn svg_pixel_dimensions_preserve_exact_millipixels() -> Result<(), Box> { + for (value, expected) in [("0", 0), ("733.7", 733_700), ("1.001", 1001)] { + assert_eq!(svg_dimension_milli_px(value)?, expected); + } + for value in [ + "", + ".1", + "1.", + "1.0001", + "NaN", + "inf", + "1e3", + "-1", + "1.2.3", + "9223372036854776", + ] { + assert!(svg_dimension_milli_px(value).is_err(), "{value}"); + } + Ok(()) +} + fn assert_inventory(root: &Path, catalog: &LayoutCatalog) -> Result<(), Box> { let expected_sources = catalog .cases diff --git a/crates/stack-engine/tests/render_snapshots.rs b/crates/stack-engine/tests/render_snapshots.rs index 8e5a776..78b1e4f 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.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - + stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + - + - Complete semantics + Complete semantics System - - System + + System Compute - - Compute + + Compute State - - State + + State User flows to Web app: HTTPS - + Web app is connected bidirectionally with API: WebSocket - + API flows to Function: Invoke - + Function flows to Database: SQL - + Function flows to Queue: Job - + Worker is associated with Storage: Archive - + API flows to Cache: Read - + API flows to External provider: API - + User: Purchaser - - Web app - - - Web app + + + Web app API - - Function - - - Function + + + Function Worker - - Database - - - Cache - - - - Cache + + + + Cache Queue - - Storage - - External provider - - - External provider + + + External provider diff --git a/crates/stack-engine/tests/snapshots/render/default-normalization.svg b/crates/stack-engine/tests/snapshots/render/default-normalization.svg index 232f79a..005656c 100644 --- a/crates/stack-engine/tests/snapshots/render/default-normalization.svg +++ b/crates/stack-engine/tests/snapshots/render/default-normalization.svg @@ -1,39 +1,39 @@ - + Default normalization Architecture diagram with 2 nodes, 0 groups, and 1 relationship. - stack-engine 0.7.0; language 1.0; theme 0.5.0 at sha256:3bfd66e1a96628b29b95b7273b54373bcce952f7285aefa506b4255a629eaf53 - + stack-engine 0.8.0; language 1.0; theme 0.6.0 at sha256:4d4e9dcda36bf2a5187a233c0be74c9e9302f41d5021e1fa2a73712837ff55c1 + - + - Default normalization + Default normalization Web app flows to API - + Web app - - API - -