From fb5b3760598fdacfcdeef07e3583f42438b8c597 Mon Sep 17 00:00:00 2001 From: konojunya Date: Thu, 3 Sep 2026 15:44:07 +0900 Subject: [PATCH] Add deterministic edge routing --- .github/workflows/ci.yaml | 6 +- README.md | 11 +- crates/stack-engine/src/lib.rs | 138 ++++- crates/stack-engine/src/routing.rs | 540 ++++++++++++++++++ crates/stack-engine/src/scene.rs | 152 ++++- .../snapshots/complete-semantics.scene.txt | 8 + ...e-orthogonal-edges-on-a-visibility-grid.md | 29 + docs/dependency-audit.md | 4 +- 8 files changed, 863 insertions(+), 25 deletions(-) create mode 100644 crates/stack-engine/src/routing.rs create mode 100644 docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 480d4da..46c4413 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,10 +45,10 @@ jobs: STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification run: cargo +stable test -p stack-formatter --features conformance --test conformance --locked - - name: Run canonical layout snapshot + - name: Run canonical scene suite env: STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification - run: cargo +stable test -p stack-engine --features conformance canonical_complete_semantics_matches_snapshot --locked + run: cargo +stable test -p stack-engine --features conformance --locked - name: Build pure engine for WebAssembly run: | @@ -64,7 +64,7 @@ jobs: - name: Verify native and WebAssembly geometry parity env: CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime - run: cargo +stable test -p stack-engine --lib --target wasm32-wasip1 geometry_matches_cross_target_numeric_fixture --locked + run: cargo +stable test -p stack-engine --lib --target wasm32-wasip1 cross_target_numeric_fixture --locked - name: Run Clippy run: cargo +stable clippy --workspace --all-targets --all-features --locked -- -D warnings diff --git a/README.md b/README.md index 8ae10b0..3d45c21 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ `stack-sh/engine` is the pure Rust execution engine for Stack architecture diagrams. -The workspace now provides canonical Stack source formatting, the pure `stack-engine` operation facade, and deterministic theme-aware scene layout. SVG rendering and WebAssembly adapters remain planned work. +The workspace now provides canonical Stack source formatting, the pure `stack-engine` operation facade, deterministic theme-aware scene layout, and orthogonal edge routing. SVG rendering and WebAssembly adapters remain planned work. ## Planned workspace -- `stack-engine`: implemented operation/output boundary, theme resolution, deterministic scene layout, and validation beyond the compiler stage, plus planned standalone SVG rendering; +- `stack-engine`: implemented operation/output boundary, theme resolution, deterministic scene layout, edge routing, and validation beyond the compiler stage, plus planned standalone SVG rendering; - `stack-formatter`: comment-preserving canonical formatting for Stack source files (implemented); - a WebAssembly adapter exposing the same pure operations to browser consumers. @@ -25,9 +25,9 @@ The workspace uses Rust 2024 with Rust 1.85 as its minimum supported version. Ru ```sh cargo test --workspace STACK_SPECIFICATION_DIR=../specification cargo test -p stack-formatter --features conformance --test conformance -STACK_SPECIFICATION_DIR=../specification cargo test -p stack-engine --features conformance canonical_complete_semantics_matches_snapshot +STACK_SPECIFICATION_DIR=../specification cargo test -p stack-engine --features conformance cargo build -p stack-engine --target wasm32-unknown-unknown -CARGO_TARGET_WASM32_WASIP1_RUNNER=wasmtime cargo test -p stack-engine --lib --target wasm32-wasip1 geometry_matches_cross_target_numeric_fixture +CARGO_TARGET_WASM32_WASIP1_RUNNER=wasmtime cargo test -p stack-engine --lib --target wasm32-wasip1 cross_target_numeric_fixture cargo fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo doc --workspace --no-deps @@ -35,13 +35,14 @@ cargo doc --workspace --no-deps `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 reserved `render` methods through an engine bound to the embedded or a caller-provided validated catalog. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs, invalid normalized containment, and unavailable pipeline stages use a separate operational-error channel. Checks and compiler-valid render attempts now resolve the requested theme and validate a deterministic integer scene; rendering remains unavailable until SVG integration lands. CI executes one exact numeric geometry fixture in both the native suite and a WASI build. +`stack-engine` exposes byte-oriented `format`, `check`, and reserved `render` methods through an engine bound to the embedded or a caller-provided validated catalog. Every normal output carries engine, authored language, theme catalog version, and theme catalog revision metadata. User-source failures stay in ordered portable diagnostics. Invalid provided catalogs, invalid normalized containment, routing failure, and unavailable pipeline stages use a separate operational-error channel. Checks and compiler-valid render attempts resolve the requested theme, validate deterministic integer geometry, and route ordered edges outside node interiors. An unsatisfied authored order hint produces `STK4001` at its source-map range; a satisfied hint does not. Rendering remains unavailable until SVG integration lands. CI executes one exact numeric geometry fixture in both the native suite and a WASI build. ## Architecture - [`docs/decisions/0001-build-the-formatter-from-compiler-models.md`](./docs/decisions/0001-build-the-formatter-from-compiler-models.md) - [`docs/decisions/0002-use-a-pure-versioned-engine-facade.md`](./docs/decisions/0002-use-a-pure-versioned-engine-facade.md) - [`docs/decisions/0003-use-integer-ranked-scene-layout.md`](./docs/decisions/0003-use-integer-ranked-scene-layout.md) +- [`docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md`](./docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md) - [`docs/dependency-audit.md`](./docs/dependency-audit.md) ## Licensing diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index bbf84bd..2ca0347 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -24,6 +24,7 @@ use std::fmt; use stack_compiler::diagnostic as compiler_diagnostic; +mod routing; mod scene; /// Version of the Rust engine facade. @@ -108,14 +109,20 @@ impl<'catalog> Engine<'catalog> { }) } - /// Runs the currently available compiler stages without producing SVG. + /// Runs compiler, theme, layout, and routing validation without producing SVG. pub fn check(&self, source: &[u8]) -> OperationResult { - let compiled = stack_compiler::compile_bytes(source); + let compiled = stack_compiler::compile_bytes_with_source_map(source); + let mut diagnostics = portable_diagnostics(compiled.diagnostics); if let Some(diagram) = &compiled.diagram { - self.validate_scene(diagram)?; + let source_map = compiled.source_map.as_ref().ok_or( + OperationalError::InvalidIntermediateRepresentation { + reason: "compiler omitted the source map for normalized IR", + }, + )?; + diagnostics.extend(self.validate_scene(diagram, source_map)?); } Ok(CheckOutput { - diagnostics: portable_diagnostics(compiled.diagnostics), + diagnostics, metadata: self.metadata(declared_language_version(source)), }) } @@ -128,7 +135,7 @@ impl<'catalog> Engine<'catalog> { /// The latter branch is replaced by deterministic SVG output when the render /// pipeline lands. pub fn render(&self, source: &[u8]) -> OperationResult { - let compiled = stack_compiler::compile_bytes(source); + let compiled = stack_compiler::compile_bytes_with_source_map(source); let metadata = self.metadata(declared_language_version(source)); if compiled.diagram.is_none() { return Ok(RenderOutput { @@ -139,7 +146,12 @@ impl<'catalog> Engine<'catalog> { } if let Some(diagram) = &compiled.diagram { - self.validate_scene(diagram)?; + let source_map = compiled.source_map.as_ref().ok_or( + OperationalError::InvalidIntermediateRepresentation { + reason: "compiler omitted the source map for normalized IR", + }, + )?; + self.validate_scene(diagram, source_map)?; } Err(OperationalError::PipelineUnavailable { @@ -156,7 +168,11 @@ impl<'catalog> Engine<'catalog> { } } - fn validate_scene(&self, diagram: &stack_compiler::ir::Diagram) -> OperationResult<()> { + fn validate_scene( + &self, + diagram: &stack_compiler::ir::Diagram, + source_map: &stack_compiler::source_map::SourceMap, + ) -> OperationResult> { let scene = scene::layout(diagram, self.catalog).map_err(|error| { OperationalError::InvalidIntermediateRepresentation { reason: error.reason(), @@ -167,10 +183,41 @@ impl<'catalog> Engine<'catalog> { reason: "layout produced invalid containment or overlap geometry", }); } - Ok(()) + scene + .unsatisfied_orders + .iter() + .map(|scope| order_diagnostic(scope, source_map)) + .collect() } } +fn order_diagnostic( + scope: &scene::SceneScope, + source_map: &stack_compiler::source_map::SourceMap, +) -> OperationResult { + let origin = match scope { + scene::SceneScope::Diagram => source_map.diagram_order(), + scene::SceneScope::Group(identifier) => source_map.group_order(identifier).ok_or( + OperationalError::InvalidIntermediateRepresentation { + reason: "source map omitted a normalized group", + }, + )?, + }; + let span = origin + .span() + .ok_or(OperationalError::InvalidIntermediateRepresentation { + reason: "source map omitted an authored order hint", + })?; + Ok(Diagnostic { + code: "STK4001".to_owned(), + severity: Severity::Warning, + message: "order hint could not be satisfied by deterministic layout".to_owned(), + range: SourceRange::from(span), + help: Some("Adjust the order hint or same-rank constraints.".to_owned()), + related: Vec::new(), + }) +} + /// Operation whose execution may produce an operational error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Operation { @@ -265,7 +312,7 @@ pub struct FormatOutput { /// Result of the check operation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CheckOutput { - /// Compiler and, in later stages, theme and layout diagnostics in deterministic order. + /// Compiler, theme, and layout diagnostics in deterministic order. pub diagnostics: Vec, /// Versions that identify the exact operation implementation and inputs. pub metadata: EngineMetadata, @@ -403,6 +450,8 @@ impl From for SourcePosition { #[cfg(test)] mod tests { + use std::error::Error; + use stack_compiler::diagnostic as compiler_diagnostic; use super::{ @@ -482,6 +531,77 @@ mod tests { } } + #[test] + fn check_emits_order_warning_at_the_authored_statement() -> Result<(), Box> { + let source = "stack 1.0 diagram \"Order\" { layout { direction right order [b, a] } node a \"A\" node b \"B\" }"; + let output = Engine::bundled().check(source.as_bytes())?; + assert_eq!(output.diagnostics.len(), 1); + let diagnostic = &output.diagnostics[0]; + assert_eq!(diagnostic.code, "STK4001"); + assert_eq!(diagnostic.severity, Severity::Warning); + let start = source + .find("order [b, a]") + .ok_or("missing order statement")?; + let end = start + "order [b, a]".len(); + assert_eq!(diagnostic.range.start.byte_offset, start as u64); + assert_eq!(diagnostic.range.end.byte_offset, end as u64); + assert_eq!(diagnostic.range.start.line, 1); + assert_eq!(diagnostic.range.start.column, start as u64 + 1); + assert_eq!(diagnostic.range.end.column, end as u64 + 1); + Ok(()) + } + + #[test] + fn check_omits_order_warning_when_rank_placement_satisfies_it() -> Result<(), Box> { + let source = b"stack 1.0 diagram \"Order\" { layout { direction right rank same [a, b] order [b, a] } node a \"A\" node b \"B\" }"; + let output = Engine::bundled().check(source)?; + assert!(output.diagnostics.is_empty()); + Ok(()) + } + + #[test] + fn group_order_warning_uses_the_group_source_map_entry() -> Result<(), Box> { + let source = "stack 1.0 diagram \"Group order\" { group pair \"Pair\" { layout { direction down order [b, a] } node a \"A\" node b \"B\" } }"; + let output = Engine::bundled().check(source.as_bytes())?; + assert_eq!(output, Engine::bundled().check(source.as_bytes())?); + assert_eq!( + output + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect::>(), + vec!["STK4001"] + ); + let start = source + .find("order [b, a]") + .ok_or("missing order statement")?; + assert_eq!(output.diagnostics[0].range.start.byte_offset, start as u64); + Ok(()) + } + + #[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\" ", + ); + for index in 0..13 { + source.push_str(&format!( + "node n{index} \"N {index}\" edge hub -> n{index} " + )); + } + source.push('}'); + let output = Engine::bundled().check(source.as_bytes())?; + assert_eq!( + output + .diagnostics + .iter() + .map(|diagnostic| diagnostic.code.as_str()) + .collect::>(), + vec!["STK4002", "STK4001"] + ); + Ok(()) + } + #[test] fn invalid_render_input_is_not_an_operational_error() { let engine = Engine::bundled(); diff --git a/crates/stack-engine/src/routing.rs b/crates/stack-engine/src/routing.rs new file mode 100644 index 0000000..31d43c9 --- /dev/null +++ b/crates/stack-engine/src/routing.rs @@ -0,0 +1,540 @@ +//! Deterministic orthogonal edge routing for the internal scene. + +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +use stack_compiler::ir::{Edge, EdgeDirection, EdgeKind}; + +use crate::scene::{Rect, SceneNode}; + +const ROUTE_MARGIN: i64 = 8_000; +const BEND_PENALTY: i64 = 32_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Point { + pub(crate) x: i64, + pub(crate) y: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Marker { + None, + Arrow, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SceneEdge { + pub(crate) from: String, + pub(crate) to: String, + pub(crate) direction: EdgeDirection, + pub(crate) kind: EdgeKind, + pub(crate) label: Option, + pub(crate) path: Vec, + pub(crate) start_marker: Marker, + pub(crate) end_marker: Marker, + pub(crate) label_anchor: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RoutingError; + +pub(crate) fn route( + edges: &[Edge], + nodes: &[SceneNode], + bounds: Rect, +) -> Result, RoutingError> { + let router = GridRouter::new(nodes, bounds); + edges + .iter() + .map(|edge| { + let source = node_rect(nodes, &edge.from).ok_or(RoutingError)?; + let target = node_rect(nodes, &edge.to).ok_or(RoutingError)?; + let path = router.route(source, target).ok_or(RoutingError)?; + let (start_marker, end_marker) = markers(edge.direction); + let label_anchor = edge.label.as_ref().map(|_| path_midpoint(&path)); + Ok(SceneEdge { + from: edge.from.clone(), + to: edge.to.clone(), + direction: edge.direction, + kind: edge.kind, + label: edge.label.clone(), + path, + start_marker, + end_marker, + label_anchor, + }) + }) + .collect() +} + +pub(crate) fn geometry_is_valid(edges: &[SceneEdge], nodes: &[SceneNode], bounds: Rect) -> bool { + edges.iter().all(|edge| { + let Some(source) = node_rect(nodes, &edge.from) else { + return false; + }; + let Some(target) = node_rect(nodes, &edge.to) else { + return false; + }; + if edge.path.len() < 2 + || !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| { + segment[0] == segment[1] + || !segment_is_axis_aligned(segment[0], segment[1]) + || nodes.iter().any(|node| { + segment_crosses_rect_interior(segment[0], segment[1], node.rect) + }) + }) + { + return false; + } + + let (start_marker, end_marker) = markers(edge.direction); + if edge.start_marker != start_marker || edge.end_marker != end_marker { + return false; + } + match (edge.label.as_ref(), edge.label_anchor) { + (Some(_), Some(anchor)) => edge + .path + .windows(2) + .any(|segment| point_is_on_segment(anchor, segment[0], segment[1])), + (None, None) => true, + (Some(_), None) | (None, Some(_)) => false, + } + }) +} + +fn node_rect(nodes: &[SceneNode], identifier: &str) -> Option { + nodes + .iter() + .find(|node| node.id == identifier) + .map(|node| node.rect) +} + +fn markers(direction: EdgeDirection) -> (Marker, Marker) { + match direction { + EdgeDirection::Forward => (Marker::None, Marker::Arrow), + EdgeDirection::Bidirectional => (Marker::Arrow, Marker::Arrow), + EdgeDirection::Association => (Marker::None, Marker::None), + } +} + +fn ports(rect: Rect) -> [Point; 4] { + [ + Point { + x: rect.x + rect.width, + y: rect.y + rect.height / 2, + }, + Point { + x: rect.x + rect.width / 2, + y: rect.y + rect.height, + }, + Point { + x: rect.x, + y: rect.y + rect.height / 2, + }, + Point { + x: rect.x + rect.width / 2, + y: rect.y, + }, + ] +} + +fn path_midpoint(path: &[Point]) -> Point { + let total = path + .windows(2) + .map(|segment| manhattan(segment[0], segment[1])) + .sum::(); + let mut remaining = total / 2; + for segment in path.windows(2) { + let length = manhattan(segment[0], segment[1]); + if remaining <= length { + return if segment[0].x == segment[1].x { + Point { + x: segment[0].x, + y: move_toward(segment[0].y, segment[1].y, remaining), + } + } else { + Point { + x: move_toward(segment[0].x, segment[1].x, remaining), + y: segment[0].y, + } + }; + } + remaining -= length; + } + path[path.len() - 1] +} + +fn move_toward(start: i64, end: i64, distance: i64) -> i64 { + if start <= end { + start + distance + } else { + start - distance + } +} + +fn manhattan(left: Point, right: Point) -> i64 { + (left.x - right.x).abs() + (left.y - right.y).abs() +} + +fn point_is_on_segment(point: Point, start: Point, end: Point) -> bool { + if start.x == end.x { + point.x == start.x && between(point.y, start.y, end.y) + } else if start.y == end.y { + point.y == start.y && between(point.x, start.x, end.x) + } else { + false + } +} + +fn between(value: i64, left: i64, right: i64) -> bool { + value >= left.min(right) && value <= left.max(right) +} + +fn segment_is_axis_aligned(start: Point, end: Point) -> bool { + start.x == end.x || start.y == end.y +} + +fn segment_crosses_rect_interior(start: Point, end: Point, rect: Rect) -> bool { + if start.y == end.y { + start.y > rect.y + && start.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 { + start.x > rect.x + && start.x < rect.x + rect.width + && start.y.min(end.y) < rect.y + rect.height + && start.y.max(end.y) > rect.y + } else { + true + } +} + +impl Rect { + fn contains_point(self, point: Point) -> bool { + point.x >= self.x + && point.x <= self.x + self.width + && point.y >= self.y + && point.y <= self.y + self.height + } + + fn contains_point_interior(self, point: Point) -> bool { + point.x > self.x + && point.x < self.x + self.width + && point.y > self.y + && point.y < self.y + self.height + } + + fn has_boundary_point(self, point: Point) -> bool { + self.contains_point(point) + && (point.x == self.x + || point.x == self.x + self.width + || point.y == self.y + || point.y == self.y + self.height) + } +} + +#[derive(Debug)] +struct GridRouter<'a> { + nodes: &'a [SceneNode], + bounds: Rect, + xs: Vec, + ys: Vec, + valid: Vec, +} + +impl<'a> GridRouter<'a> { + fn new(nodes: &'a [SceneNode], bounds: Rect) -> Self { + let mut xs = vec![ + bounds.x + ROUTE_MARGIN, + bounds.x + bounds.width - ROUTE_MARGIN, + ]; + let mut ys = vec![ + bounds.y + ROUTE_MARGIN, + bounds.y + bounds.height - ROUTE_MARGIN, + ]; + for node in nodes { + 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, + ]); + } + 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 + .iter() + .flat_map(|y| { + xs.iter().map(move |x| { + let point = Point { x: *x, y: *y }; + nodes + .iter() + .all(|node| !node.rect.contains_point_interior(point)) + }) + }) + .collect(); + Self { + nodes, + bounds, + xs, + ys, + valid, + } + } + + fn route(&self, source: Rect, target: Rect) -> 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 target_vertices = ports(target) + .into_iter() + .map(|port| self.vertex(port)) + .collect::>>()?; + + while let Some(Reverse((cost, state))) = pending.pop() { + if distances[state] != cost { + continue; + } + let vertex = state / 3; + let incoming_axis = state % 3; + if incoming_axis != 0 && target_vertices.contains(&vertex) { + return Some(self.reconstruct(state, &parents)); + } + for (next_vertex, next_axis, length) in self.neighbors(vertex) { + let bend = if incoming_axis != 0 && incoming_axis != next_axis { + BEND_PENALTY + } else { + 0 + }; + let next_state = next_vertex * 3 + next_axis; + let next_cost = cost + length + bend; + if next_cost < distances[next_state] { + distances[next_state] = next_cost; + parents[next_state] = Some(state); + pending.push(Reverse((next_cost, next_state))); + } + } + } + None + } + + fn vertex(&self, point: Point) -> Option { + let x = self.xs.binary_search(&point.x).ok()?; + let y = self.ys.binary_search(&point.y).ok()?; + let vertex = y * self.xs.len() + x; + self.valid[vertex].then_some(vertex) + } + + fn point(&self, vertex: usize) -> Point { + Point { + x: self.xs[vertex % self.xs.len()], + y: self.ys[vertex / self.xs.len()], + } + } + + 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 { + let mut states = Vec::new(); + let mut cursor = Some(state); + while let Some(current) = cursor { + states.push(current); + cursor = parents[current]; + } + states.reverse(); + + let mut path = Vec::new(); + 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); + } + path + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use stack_compiler::ir::{EdgeDirection, EdgeKind}; + + use super::Marker; + + fn scene_from(source: &[u8]) -> Result> { + let compiled = stack_compiler::compile_bytes(source); + if !compiled.diagnostics.is_empty() { + return Err("fixture produced compiler diagnostics".into()); + } + let diagram = compiled.diagram.ok_or("fixture produced no diagram")?; + Ok(crate::scene::layout(&diagram, stack_theme::catalog())?) + } + + #[test] + fn preserves_edge_order_semantics_labels_and_markers() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Edges\" { node a \"A\" node b \"B\" node c \"C\" edge a -> b \"Request\" { kind request } edge b <-> c \"Flow\" edge c -- a { kind dependency } }", + )?; + assert_eq!(scene.edges.len(), 3); + assert_eq!(scene.edges[0].from, "a"); + assert_eq!(scene.edges[0].to, "b"); + assert_eq!(scene.edges[0].kind, EdgeKind::Request); + assert_eq!(scene.edges[0].label.as_deref(), Some("Request")); + assert_eq!(scene.edges[0].start_marker, Marker::None); + assert_eq!(scene.edges[0].end_marker, Marker::Arrow); + assert_eq!(scene.edges[1].direction, EdgeDirection::Bidirectional); + assert_eq!(scene.edges[1].start_marker, Marker::Arrow); + assert_eq!(scene.edges[1].end_marker, Marker::Arrow); + assert_eq!(scene.edges[2].direction, EdgeDirection::Association); + assert_eq!(scene.edges[2].kind, EdgeKind::Dependency); + assert_eq!(scene.edges[2].start_marker, Marker::None); + assert_eq!(scene.edges[2].end_marker, Marker::None); + assert!(scene.edges[0].label_anchor.is_some()); + assert!(scene.edges[2].label_anchor.is_none()); + assert!(scene.geometry_is_valid()); + Ok(()) + } + + #[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()); + Ok(()) + } + + #[test] + fn routing_matches_cross_target_numeric_fixture() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Route parity\" { node a \"A\" node b \"B\" edge a -> b }", + )?; + assert_eq!( + scene.edges[0].path, + vec![ + super::Point { + x: 192_000, + y: 106_200, + }, + super::Point { + x: 216_000, + y: 106_200, + }, + ] + ); + assert!(scene.geometry_is_valid()); + Ok(()) + } + + #[test] + fn rejects_corrupted_edge_geometry() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Validate edge\" { node a \"A\" node b \"B\" edge a -> b \"Call\" }", + )?; + + let mut invalid = scene.clone(); + invalid.edges[0].path.truncate(1); + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.edges[0].path[0].x += 1; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.edges[0].path[1].y += 1; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.edges[0].end_marker = Marker::None; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.edges[0].label_anchor = None; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene; + invalid.edges[0].from = "missing".to_owned(); + assert!(!invalid.geometry_is_valid()); + Ok(()) + } +} diff --git a/crates/stack-engine/src/scene.rs b/crates/stack-engine/src/scene.rs index df8a4de..85deb6e 100644 --- a/crates/stack-engine/src/scene.rs +++ b/crates/stack-engine/src/scene.rs @@ -7,6 +7,8 @@ use std::fmt; use stack_compiler::ir::{Diagram, Direction, ElementId, Group, Layout, Node}; use stack_theme::{Catalog, FontMetrics, Theme, Typography}; +use crate::routing::{self, Point, SceneEdge}; + const NODE_MIN_WIDTH: i64 = 160_000; const NODE_MIN_HEIGHT: i64 = 72_000; const NODE_HORIZONTAL_PADDING: i64 = 20_000; @@ -73,6 +75,14 @@ pub(crate) struct Scene { pub(crate) direction: SceneDirection, pub(crate) nodes: Vec, pub(crate) groups: Vec, + pub(crate) edges: Vec, + pub(crate) unsatisfied_orders: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SceneScope { + Diagram, + Group(String), } impl Scene { @@ -148,6 +158,10 @@ impl Scene { } } + if !routing::geometry_is_valid(&self.edges, &self.nodes, self.bounds) { + return false; + } + true } @@ -168,6 +182,7 @@ pub(crate) enum SceneError { MissingTheme, MissingFontMetrics, InvalidIntermediateRepresentation, + EdgeRoutingFailed, } impl SceneError { @@ -178,6 +193,7 @@ impl SceneError { Self::InvalidIntermediateRepresentation => { "normalized containment references are inconsistent" } + Self::EdgeRoutingFailed => "an edge could not be routed outside node interiors", } } } @@ -302,6 +318,9 @@ pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result, SceneError>>()?; + let edges = routing::route(&diagram.edges, &nodes, bounds) + .map_err(|_| SceneError::EdgeRoutingFailed)?; + let unsatisfied_orders = unsatisfied_orders(diagram, &nodes, &groups)?; Ok(Scene { bounds, @@ -309,9 +328,84 @@ pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result 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) { + unsatisfied.push(SceneScope::Diagram); + } + } + } + + for group in &diagram.groups { + let Some(layout) = &group.layout else { + continue; + }; + let Some(order) = &layout.order else { + continue; + }; + let direction = groups + .iter() + .find(|scene_group| scene_group.id == group.id) + .map(|scene_group| scene_group.direction) + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + if !order_is_satisfied(order, direction, nodes, groups) { + unsatisfied.push(SceneScope::Group(group.id.clone())); + } + } + Ok(unsatisfied) +} + +fn order_is_satisfied( + order: &[String], + direction: SceneDirection, + nodes: &[SceneNode], + groups: &[SceneGroup], +) -> bool { + order.windows(2).all(|pair| { + match ( + element_rect(&pair[0], nodes, groups), + element_rect(&pair[1], nodes, groups), + ) { + (Some(left), Some(right)) => { + cross_axis_position(left, direction) < cross_axis_position(right, direction) + } + _ => false, + } }) } +fn element_rect(identifier: &str, nodes: &[SceneNode], groups: &[SceneGroup]) -> Option { + nodes + .iter() + .find(|node| node.id == identifier) + .map(|node| node.rect) + .or_else(|| { + groups + .iter() + .find(|group| group.id == identifier) + .map(|group| group.rect) + }) +} + +fn cross_axis_position(rect: Rect, direction: SceneDirection) -> i64 { + match direction { + SceneDirection::Right => 2 * rect.y + rect.height, + SceneDirection::Down => 2 * rect.x + rect.width, + } +} + fn selected_theme<'a>(diagram: &Diagram, catalog: &'a Catalog) -> Result<&'a Theme, SceneError> { catalog .themes @@ -365,12 +459,6 @@ fn group_size( } } -#[derive(Debug, Clone, Copy)] -struct Point { - x: i64, - y: i64, -} - struct Placer<'a> { diagram: &'a Diagram, sizes: &'a BTreeMap, @@ -865,6 +953,10 @@ mod tests { SceneError::InvalidIntermediateRepresentation.to_string(), "normalized containment references are inconsistent" ); + assert_eq!( + SceneError::EdgeRoutingFailed.to_string(), + "an edge could not be routed outside node interiors" + ); Ok(()) } @@ -883,6 +975,31 @@ mod tests { Ok(()) } + #[cfg(feature = "conformance")] + #[test] + fn canonical_examples_produce_valid_routed_scenes() -> Result<(), Box> { + let specification = std::env::var("STACK_SPECIFICATION_DIR")?; + let examples_root = std::path::Path::new(&specification).join("examples"); + let mut examples = std::fs::read_dir(&examples_root)?.collect::, _>>()?; + examples.sort_by_key(|entry| entry.file_name()); + if examples.is_empty() { + return Err(format!("no examples found in {}", examples_root.display()).into()); + } + + for entry in examples { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("stack") { + continue; + } + let source = std::fs::read(&path)?; + let scene = scene_from(&source)?; + if !scene.geometry_is_valid() { + return Err(format!("{} produced invalid scene geometry", path.display()).into()); + } + } + Ok(()) + } + #[cfg(feature = "conformance")] fn scene_snapshot(scene: &Scene) -> String { let mut output = format!( @@ -924,6 +1041,29 @@ mod tests { node.rect.height )); } + for edge in &scene.edges { + let path = edge + .path + .iter() + .map(|point| format!("{},{}", point.x, point.y)) + .collect::>() + .join(";"); + output.push_str(&format!( + "edge|{}|{}|direction={:?}|kind={:?}|label={}|markers={:?},{:?}|anchor={}|path={}\n", + edge.from, + edge.to, + edge.direction, + edge.kind, + edge.label.as_deref().unwrap_or("-"), + edge.start_marker, + edge.end_marker, + edge.label_anchor.map_or_else( + || "-".to_owned(), + |point| format!("{},{}", point.x, point.y) + ), + path + )); + } output } } diff --git a/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt b/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt index 65751bb..9bba2d2 100644 --- a/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt +++ b/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt @@ -12,3 +12,11 @@ node|cache|parent=state|rect=80000,641200,160000,72000 node|queue|parent=state|rect=80000,737200,160000,72000 node|storage|parent=state|rect=80000,833200,160000,72000 node|vendor|parent=system|rect=56000,953200,192200,72000 +edge|user|web|direction=Forward|kind=Request|label=HTTPS|markers=None,Arrow|anchor=112000,193700|path=112000,142600;112000,220800;136000,220800 +edge|web|api|direction=Bidirectional|kind=Flow|label=WebSocket|markers=Arrow,Arrow|anchor=228000,256800|path=216000,256800;240000,256800 +edge|api|function|direction=Forward|kind=Dependency|label=Invoke|markers=None,Arrow|anchor=240000,331900|path=240000,256800;240000,407000 +edge|function|database|direction=Forward|kind=Data|label=SQL|markers=None,Arrow|anchor=160000,494100|path=160000,443000;160000,545200 +edge|function|queue|direction=Forward|kind=Event|label=Job|markers=None,Arrow|anchor=80000,590100|path=80000,407000;80000,773200 +edge|worker|storage|direction=Association|kind=Data|label=Archive|markers=None,None|anchor=264000,650100|path=264000,407000;264000,869200;240000,869200 +edge|api|cache|direction=Forward|kind=Data|label=Read|markers=None,Arrow|anchor=240000,467000|path=240000,256800;240000,677200 +edge|api|vendor|direction=Forward|kind=Request|label=API|markers=None,Arrow|anchor=248200,605100|path=320000,292800;248200,292800;248200,989200 diff --git a/docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md b/docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md new file mode 100644 index 0000000..d01f563 --- /dev/null +++ b/docs/decisions/0004-route-orthogonal-edges-on-a-visibility-grid.md @@ -0,0 +1,29 @@ +# ADR-0004: Route orthogonal edges on a visibility grid + +## Status + +Accepted + +## Date + +2026-09-03 + +## Context + +The internal scene must preserve every normalized edge in declaration order, distinguish forward, bidirectional, and association semantics, place optional labels, and keep paths out of node interiors. Routing must remain deterministic and host-independent for native and WebAssembly builds. The engine must also report `STK4001` only when its concrete placement does not satisfy an authored order hint, using the compiler-owned source-map range rather than reconstructing source locations. + +## Decision + +Represent each scene edge with authored endpoints, normalized kind and direction, optional label, an ordered orthogonal point sequence, explicit start and end markers, and an optional label anchor. Forward edges have an arrow only at the target, bidirectional edges have arrows at both endpoints, and associations have no arrows. A label anchor is the integer half-length point along the routed path. + +Build one rectilinear visibility grid per scene from canvas margins plus every node boundary, midpoint, and clearance line. Grid points strictly inside nodes are unavailable. Adjacent visible points form possible horizontal or vertical path segments. For each edge, a deterministic shortest-path search starts from all four source boundary midpoints and accepts all four target boundary midpoints. Cost combines Manhattan distance with a fixed bend penalty; stable grid and state order resolves ties. Geometry validation independently verifies boundary endpoints, axis-aligned nonzero segments, canvas containment, marker semantics, label-anchor membership, and absence of node-interior intersections. + +After placement, evaluate each authored order list using doubled cross-axis center coordinates. Every consecutive entry must increase strictly along the resolved scope cross-axis. Satisfied hints produce no diagnostic. Unsatisfied hints produce warning `STK4001` at the complete authored order-statement span supplied by `stack-compiler::source_map::SourceMap`. Compiler diagnostics remain first; layout diagnostics follow in diagram-then-depth-first-group order. + +## Consequences + +- Routing depends only on normalized IR and existing integer scene geometry. +- Paths may touch or follow a node boundary but never enter a node interior. +- Edge declaration order, kind, label, direction, path, markers, and anchors are snapshot-testable before SVG serialization exists. +- The bend penalty favors readable routes without making crossing minimization a semantic guarantee. +- Layout diagnostics reuse the public compiler sidecar and do not add source spans to portable IR. diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md index c6b7cb4..480b2cb 100644 --- a/docs/dependency-audit.md +++ b/docs/dependency-audit.md @@ -27,8 +27,8 @@ Tests and CI may read the pinned specification checkout and invoke toolchains. T cargo tree -p stack-engine --edges normal --locked cargo metadata --format-version 1 --locked cargo test --workspace --locked -STACK_SPECIFICATION_DIR=../specification cargo test -p stack-engine --features conformance canonical_complete_semantics_matches_snapshot --locked +STACK_SPECIFICATION_DIR=../specification cargo test -p stack-engine --features conformance --locked cargo build -p stack-engine --target wasm32-unknown-unknown --locked -CARGO_TARGET_WASM32_WASIP1_RUNNER=wasmtime cargo test -p stack-engine --lib --target wasm32-wasip1 geometry_matches_cross_target_numeric_fixture --locked +CARGO_TARGET_WASM32_WASIP1_RUNNER=wasmtime cargo test -p stack-engine --lib --target wasm32-wasip1 cross_target_numeric_fixture --locked cargo clippy --workspace --all-targets --all-features --locked -- -D warnings ```