From fc7eb2c85b37e05decb7ca8ed3c6f0429024c3e6 Mon Sep 17 00:00:00 2001 From: konojunya Date: Thu, 3 Sep 2026 15:28:48 +0900 Subject: [PATCH] Add deterministic scene layout --- .github/workflows/ci.yaml | 23 + README.md | 10 +- crates/stack-engine/Cargo.toml | 3 + crates/stack-engine/src/lib.rs | 35 + crates/stack-engine/src/scene.rs | 929 ++++++++++++++++++ .../snapshots/complete-semantics.scene.txt | 14 + .../0003-use-integer-ranked-scene-layout.md | 31 + docs/dependency-audit.md | 5 +- 8 files changed, 1046 insertions(+), 4 deletions(-) create mode 100644 crates/stack-engine/src/scene.rs create mode 100644 crates/stack-engine/tests/snapshots/complete-semantics.scene.txt create mode 100644 docs/decisions/0003-use-integer-ranked-scene-layout.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8e1c8c0..480d4da 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,6 +45,27 @@ 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 + env: + STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification + run: cargo +stable test -p stack-engine --features conformance canonical_complete_semantics_matches_snapshot --locked + + - name: Build pure engine for WebAssembly + run: | + rustup target add wasm32-unknown-unknown wasm32-wasip1 --toolchain stable + cargo +stable build -p stack-engine --target wasm32-unknown-unknown --locked + + - name: Install WebAssembly test runtime + uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4 + with: + tool: wasmtime@48.0.1 + fallback: none + + - 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 + - name: Run Clippy run: cargo +stable clippy --workspace --all-targets --all-features --locked -- -D warnings @@ -60,6 +81,8 @@ jobs: fallback: none - name: Enforce unit-test coverage + env: + STACK_SPECIFICATION_DIR: ${{ github.workspace }}/.stack-specification run: cargo +stable llvm-cov --lib --workspace --all-features --locked --fail-under-lines 95 --fail-under-functions 95 --fail-under-regions 95 msrv: diff --git a/README.md b/README.md index 7c6b8b1..8ae10b0 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 and the pure `stack-engine` operation facade. Layout, theme resolution, SVG rendering, and WebAssembly adapters remain planned work. +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. ## Planned workspace -- `stack-engine`: implemented operation/output boundary plus planned theme resolution, deterministic layout, validation beyond the compiler stage, and standalone SVG rendering; +- `stack-engine`: implemented operation/output boundary, theme resolution, deterministic scene layout, 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,6 +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 +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 fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo doc --workspace --no-deps @@ -32,12 +35,13 @@ 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 and unavailable pipeline stages use a separate operational-error channel. Compiler-valid rendering remains unavailable until layout and SVG integration land. +`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. ## 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/dependency-audit.md`](./docs/dependency-audit.md) ## Licensing diff --git a/crates/stack-engine/Cargo.toml b/crates/stack-engine/Cargo.toml index 0d77642..310d28a 100644 --- a/crates/stack-engine/Cargo.toml +++ b/crates/stack-engine/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true repository.workspace = true description = "Pure execution facade for Stack diagram operations" +[features] +conformance = [] + [dependencies] stack-compiler.workspace = true stack-formatter.workspace = true diff --git a/crates/stack-engine/src/lib.rs b/crates/stack-engine/src/lib.rs index eb517b0..bbf84bd 100644 --- a/crates/stack-engine/src/lib.rs +++ b/crates/stack-engine/src/lib.rs @@ -24,6 +24,8 @@ use std::fmt; use stack_compiler::diagnostic as compiler_diagnostic; +mod scene; + /// Version of the Rust engine facade. pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -109,6 +111,9 @@ impl<'catalog> Engine<'catalog> { /// Runs the currently available compiler stages without producing SVG. pub fn check(&self, source: &[u8]) -> OperationResult { let compiled = stack_compiler::compile_bytes(source); + if let Some(diagram) = &compiled.diagram { + self.validate_scene(diagram)?; + } Ok(CheckOutput { diagnostics: portable_diagnostics(compiled.diagnostics), metadata: self.metadata(declared_language_version(source)), @@ -133,6 +138,10 @@ impl<'catalog> Engine<'catalog> { }); } + if let Some(diagram) = &compiled.diagram { + self.validate_scene(diagram)?; + } + Err(OperationalError::PipelineUnavailable { operation: Operation::Render, }) @@ -146,6 +155,20 @@ impl<'catalog> Engine<'catalog> { theme_catalog_revision: self.catalog_revision.to_owned(), } } + + fn validate_scene(&self, diagram: &stack_compiler::ir::Diagram) -> OperationResult<()> { + let scene = scene::layout(diagram, self.catalog).map_err(|error| { + OperationalError::InvalidIntermediateRepresentation { + reason: error.reason(), + } + })?; + if !scene.geometry_is_valid() { + return Err(OperationalError::InvalidIntermediateRepresentation { + reason: "layout produced invalid containment or overlap geometry", + }); + } + Ok(()) + } } /// Operation whose execution may produce an operational error. @@ -178,6 +201,11 @@ pub enum OperationalError { /// Stable explanation of the violated catalog invariant. reason: &'static str, }, + /// Compiler or layout data violates an invariant required by pure execution. + InvalidIntermediateRepresentation { + /// Stable explanation of the violated invariant. + reason: &'static str, + }, /// The requested pure stage has not landed in this engine revision. PipelineUnavailable { /// Operation whose downstream stages are unavailable. @@ -189,6 +217,9 @@ impl fmt::Display for OperationalError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidCatalog { reason } => write!(formatter, "invalid theme catalog: {reason}"), + Self::InvalidIntermediateRepresentation { reason } => { + write!(formatter, "invalid intermediate representation: {reason}") + } Self::PipelineUnavailable { operation } => { write!(formatter, "{operation} pipeline is unavailable") } @@ -548,5 +579,9 @@ mod tests { .to_string(), "render pipeline is unavailable" ); + assert_eq!( + OperationalError::InvalidIntermediateRepresentation { reason: "reason" }.to_string(), + "invalid intermediate representation: reason" + ); } } diff --git a/crates/stack-engine/src/scene.rs b/crates/stack-engine/src/scene.rs new file mode 100644 index 0000000..df8a4de --- /dev/null +++ b/crates/stack-engine/src/scene.rs @@ -0,0 +1,929 @@ +//! SVG-independent deterministic scene geometry. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; + +use stack_compiler::ir::{Diagram, Direction, ElementId, Group, Layout, Node}; +use stack_theme::{Catalog, FontMetrics, Theme, Typography}; + +const NODE_MIN_WIDTH: i64 = 160_000; +const NODE_MIN_HEIGHT: i64 = 72_000; +const NODE_HORIZONTAL_PADDING: i64 = 20_000; +const NODE_VERTICAL_PADDING: i64 = 16_000; +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; +const GROUP_LABEL_GAP: i64 = 12_000; +const DIAGRAM_PADDING: i64 = 32_000; +const DIAGRAM_TITLE_GAP: i64 = 20_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SceneDirection { + Right, + Down, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Rect { + pub(crate) x: i64, + pub(crate) y: i64, + pub(crate) width: i64, + pub(crate) height: i64, +} + +impl Rect { + fn contains(self, other: Self) -> bool { + other.x >= self.x + && other.y >= self.y + && other.x + other.width <= self.x + self.width + && other.y + other.height <= self.y + self.height + } + + fn overlaps(self, other: Self) -> bool { + self.x < other.x + other.width + && other.x < self.x + self.width + && self.y < other.y + other.height + && other.y < self.y + self.height + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SceneNode { + pub(crate) id: String, + pub(crate) parent_group_id: Option, + pub(crate) rect: Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SceneGroup { + pub(crate) id: String, + pub(crate) parent_group_id: Option, + pub(crate) rect: Rect, + pub(crate) content_rect: Rect, + pub(crate) direction: SceneDirection, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Scene { + pub(crate) bounds: Rect, + pub(crate) content_rect: Rect, + pub(crate) direction: SceneDirection, + pub(crate) nodes: Vec, + pub(crate) groups: Vec, +} + +impl Scene { + pub(crate) fn geometry_is_valid(&self) -> bool { + if self.bounds.width <= 0 + || self.bounds.height <= 0 + || self.content_rect.width <= 0 + || self.content_rect.height <= 0 + || !self.bounds.contains(self.content_rect) + { + return false; + } + let group_ids = self + .groups + .iter() + .map(|group| group.id.as_str()) + .collect::>(); + let node_ids = self + .nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + if group_ids.len() != self.groups.len() || node_ids.len() != self.nodes.len() { + return false; + } + + for node in &self.nodes { + let Some(parent) = self.parent_content_rect(node.parent_group_id.as_deref()) else { + return false; + }; + if node.rect.width <= 0 || node.rect.height <= 0 || !parent.contains(node.rect) { + return false; + } + } + for group in &self.groups { + let Some(parent) = self.parent_content_rect(group.parent_group_id.as_deref()) else { + return false; + }; + if group.rect.width <= 0 + || group.rect.height <= 0 + || group.content_rect.width <= 0 + || group.content_rect.height <= 0 + || !parent.contains(group.rect) + || !group.rect.contains(group.content_rect) + { + return false; + } + } + + for left in 0..self.nodes.len() { + for right in (left + 1)..self.nodes.len() { + if self.nodes[left].parent_group_id == self.nodes[right].parent_group_id + && self.nodes[left].rect.overlaps(self.nodes[right].rect) + { + return false; + } + } + } + for left in 0..self.groups.len() { + for right in (left + 1)..self.groups.len() { + if self.groups[left].parent_group_id == self.groups[right].parent_group_id + && self.groups[left].rect.overlaps(self.groups[right].rect) + { + return false; + } + } + } + for node in &self.nodes { + for group in &self.groups { + if node.parent_group_id == group.parent_group_id && node.rect.overlaps(group.rect) { + return false; + } + } + } + + true + } + + fn parent_content_rect(&self, parent_group_id: Option<&str>) -> Option { + match parent_group_id { + Some(parent_group_id) => self + .groups + .iter() + .find(|group| group.id == parent_group_id) + .map(|group| group.content_rect), + None => Some(self.content_rect), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SceneError { + MissingTheme, + MissingFontMetrics, + InvalidIntermediateRepresentation, +} + +impl SceneError { + pub(crate) const fn reason(self) -> &'static str { + match self { + Self::MissingTheme => "no requested or fallback theme is available", + Self::MissingFontMetrics => "theme references unavailable font metrics", + Self::InvalidIntermediateRepresentation => { + "normalized containment references are inconsistent" + } + } + } +} + +impl fmt::Display for SceneError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.reason()) + } +} + +impl Error for SceneError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Size { + width: i64, + height: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PlacedItem { + index: usize, + rect: Rect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Arrangement { + size: Size, + direction: SceneDirection, + items: Vec, +} + +pub(crate) fn layout(diagram: &Diagram, catalog: &Catalog) -> Result { + let theme = selected_theme(diagram, catalog)?; + let metrics = catalog + .font_metrics + .iter() + .find(|metrics| metrics.id == theme.typography.font_metrics_id) + .ok_or(SceneError::MissingFontMetrics)?; + + 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)?; + sizes.insert( + group.id.clone(), + group_size(group, children.size, &theme.typography, metrics), + ); + } + + let root = arrange(&diagram.children, diagram.layout.as_ref(), &sizes)?; + let title_height = line_height( + theme.typography.group_label_size_milli_px, + &theme.typography, + ); + let title_width = text_width( + &diagram.title, + theme.typography.group_label_size_milli_px, + metrics, + ); + let bounds = Rect { + x: 0, + y: 0, + width: (root.size.width + 2 * DIAGRAM_PADDING).max(title_width + 2 * DIAGRAM_PADDING), + height: root.size.height + 2 * DIAGRAM_PADDING + title_height + DIAGRAM_TITLE_GAP, + }; + let root_origin = Point { + x: DIAGRAM_PADDING, + y: DIAGRAM_PADDING + title_height + DIAGRAM_TITLE_GAP, + }; + let content_rect = Rect { + x: root_origin.x, + y: root_origin.y, + width: root.size.width, + height: root.size.height, + }; + let mut placer = Placer::new(diagram, &sizes, theme); + placer.place_scope(&diagram.children, diagram.layout.as_ref(), root_origin)?; + + let nodes = diagram + .nodes + .iter() + .map(|node| { + placer + .node_rects + .get(&node.id) + .copied() + .map(|rect| SceneNode { + id: node.id.clone(), + parent_group_id: node.parent_group_id.clone(), + rect, + }) + .ok_or(SceneError::InvalidIntermediateRepresentation) + }) + .collect::, _>>()?; + let groups = diagram + .groups + .iter() + .map(|group| { + let rect = placer + .group_rects + .get(&group.id) + .copied() + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + let content_rect = placer + .group_content_rects + .get(&group.id) + .copied() + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + let direction = placer + .group_directions + .get(&group.id) + .copied() + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + Ok(SceneGroup { + id: group.id.clone(), + parent_group_id: group.parent_group_id.clone(), + rect, + content_rect, + direction, + }) + }) + .collect::, SceneError>>()?; + + Ok(Scene { + bounds, + content_rect, + direction: root.direction, + nodes, + groups, + }) +} + +fn selected_theme<'a>(diagram: &Diagram, catalog: &'a Catalog) -> Result<&'a Theme, SceneError> { + catalog + .themes + .iter() + .find(|theme| theme.id == diagram.theme_id) + .or_else(|| { + catalog + .themes + .iter() + .find(|theme| theme.id == catalog.fallbacks.missing_theme_id) + }) + .ok_or(SceneError::MissingTheme) +} + +fn node_size(node: &Node, theme: &Theme, metrics: &FontMetrics) -> Size { + let label_width = text_width( + &node.label, + theme.typography.node_label_size_milli_px, + metrics, + ); + let detail_width = node.detail.as_deref().map_or(0, |detail| { + text_width(detail, theme.typography.node_detail_size_milli_px, metrics) + }); + let content_width = label_width.max(detail_width) + NODE_ICON_SIZE + NODE_ICON_GAP; + let label_height = line_height(theme.typography.node_label_size_milli_px, &theme.typography); + let detail_height = node.detail.as_ref().map_or(0, |_| { + NODE_DETAIL_GAP + + line_height( + theme.typography.node_detail_size_milli_px, + &theme.typography, + ) + }); + + Size { + width: NODE_MIN_WIDTH.max(content_width + 2 * NODE_HORIZONTAL_PADDING), + height: NODE_MIN_HEIGHT.max(label_height + detail_height + 2 * NODE_VERTICAL_PADDING), + } +} + +fn group_size( + group: &Group, + content: Size, + typography: &Typography, + metrics: &FontMetrics, +) -> Size { + let label_width = text_width(&group.label, typography.group_label_size_milli_px, metrics); + let label_height = line_height(typography.group_label_size_milli_px, typography); + Size { + width: (content.width + 2 * GROUP_PADDING).max(label_width + 2 * GROUP_PADDING), + height: content.height + 2 * GROUP_PADDING + label_height + GROUP_LABEL_GAP, + } +} + +#[derive(Debug, Clone, Copy)] +struct Point { + x: i64, + y: i64, +} + +struct Placer<'a> { + diagram: &'a Diagram, + sizes: &'a BTreeMap, + theme: &'a Theme, + node_rects: BTreeMap, + group_rects: BTreeMap, + group_content_rects: BTreeMap, + group_directions: BTreeMap, +} + +impl<'a> Placer<'a> { + fn new(diagram: &'a Diagram, sizes: &'a BTreeMap, theme: &'a Theme) -> Self { + Self { + diagram, + sizes, + theme, + node_rects: BTreeMap::new(), + group_rects: BTreeMap::new(), + group_content_rects: BTreeMap::new(), + group_directions: BTreeMap::new(), + } + } + + fn place_scope( + &mut self, + children: &[ElementId], + layout: Option<&Layout>, + origin: Point, + ) -> Result<(), SceneError> { + let arrangement = arrange(children, layout, self.sizes)?; + for placed in arrangement.items { + let child = children + .get(placed.index) + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + let rect = Rect { + x: origin.x + placed.rect.x, + y: origin.y + placed.rect.y, + width: placed.rect.width, + height: placed.rect.height, + }; + match child { + ElementId::Node(identifier) => { + if self.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 + .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 label_height = line_height( + self.theme.typography.group_label_size_milli_px, + &self.theme.typography, + ); + let content_origin = Point { + x: rect.x + GROUP_PADDING, + y: rect.y + GROUP_PADDING + label_height + GROUP_LABEL_GAP, + }; + self.group_rects.insert(identifier.clone(), rect); + self.group_content_rects.insert( + identifier.clone(), + Rect { + x: content_origin.x, + y: content_origin.y, + width: child_arrangement.size.width, + height: child_arrangement.size.height, + }, + ); + self.group_directions + .insert(identifier.clone(), child_arrangement.direction); + self.place_scope(&group.children, group.layout.as_ref(), content_origin)?; + } + } + } + Ok(()) + } +} + +fn arrange( + children: &[ElementId], + layout: Option<&Layout>, + sizes: &BTreeMap, +) -> 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 mut items = Vec::with_capacity(children.len()); + let mut primary_cursor = 0; + let mut cross_extent = 0; + + for rank in ranks { + let mut primary_extent = 0; + let mut rank_cross_cursor = 0; + for index in &rank { + let size = sizes + .get(children[*index].as_str()) + .copied() + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + primary_extent = primary_extent.max(match direction { + SceneDirection::Right => size.width, + SceneDirection::Down => size.height, + }); + rank_cross_cursor += match direction { + SceneDirection::Right => size.height, + SceneDirection::Down => size.width, + }; + } + rank_cross_cursor += ITEM_GAP * (rank.len().saturating_sub(1) as i64); + + let mut cross_cursor = 0; + for index in rank { + let size = sizes + .get(children[index].as_str()) + .copied() + .ok_or(SceneError::InvalidIntermediateRepresentation)?; + let rect = match direction { + SceneDirection::Right => Rect { + x: primary_cursor, + y: cross_cursor, + width: size.width, + height: size.height, + }, + SceneDirection::Down => Rect { + x: cross_cursor, + y: primary_cursor, + width: size.width, + height: size.height, + }, + }; + cross_cursor += match direction { + SceneDirection::Right => size.height + ITEM_GAP, + SceneDirection::Down => size.width + ITEM_GAP, + }; + items.push(PlacedItem { index, rect }); + } + cross_extent = cross_extent.max(rank_cross_cursor); + primary_cursor += primary_extent + ITEM_GAP; + } + + primary_cursor -= ITEM_GAP; + let size = match direction { + SceneDirection::Right => Size { + width: primary_cursor, + height: cross_extent, + }, + SceneDirection::Down => Size { + width: cross_extent, + height: primary_cursor, + }, + }; + Ok(Arrangement { + size, + direction, + items, + }) +} + +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()); + let mut assigned = vec![false; children.len()]; + let mut ranks = Vec::new(); + + for index in 0..children.len() { + if assigned[index] { + continue; + } + let identifier = children[index].as_str(); + let mut rank = same_ranks + .iter() + .find(|rank| rank.iter().any(|entry| entry == identifier)) + .map_or_else( + || vec![index], + |same_rank| { + children + .iter() + .enumerate() + .filter_map(|(candidate, child)| { + same_rank + .iter() + .any(|entry| entry == child.as_str()) + .then_some(candidate) + }) + .collect() + }, + ); + for member in &rank { + assigned[*member] = true; + } + if let Some(order) = order { + rank.sort_by_key(|member| { + order + .iter() + .position(|entry| entry == children[*member].as_str()) + .map_or((1, *member), |position| (0, position)) + }); + } + ranks.push(rank); + } + ranks +} + +fn resolve_direction(child_count: usize, authored: Option) -> SceneDirection { + match authored { + Some(Direction::Right) => SceneDirection::Right, + Some(Direction::Down) => SceneDirection::Down, + None if child_count <= 3 => SceneDirection::Right, + None => SceneDirection::Down, + } +} + +fn text_width(text: &str, size_milli_px: u32, metrics: &FontMetrics) -> i64 { + let advance = text + .chars() + .map(|character| glyph_advance(character, metrics) as i64) + .sum::(); + let units_per_em = i64::from(metrics.units_per_em); + (advance * i64::from(size_milli_px) + units_per_em - 1) / units_per_em +} + +fn glyph_advance(character: char, metrics: &FontMetrics) -> u32 { + let scalar = character as u32; + let key = format!("U+{scalar:04X}"); + if let Some(advance) = metrics.glyph_advances.get(&key) { + return *advance; + } + if metrics.wide_ranges.iter().any(|range| { + let start = unicode_scalar(&range.start); + let end = unicode_scalar(&range.end); + start.is_some_and(|start| scalar >= start) && end.is_some_and(|end| scalar <= end) + }) { + metrics.wide_advance + } else { + metrics.default_advance + } +} + +fn unicode_scalar(label: &str) -> Option { + label + .strip_prefix("U+") + .and_then(|digits| u32::from_str_radix(digits, 16).ok()) +} + +fn line_height(size_milli_px: u32, typography: &Typography) -> i64 { + (i64::from(size_milli_px) * i64::from(typography.line_height_permille) + 999) / 1000 +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use stack_compiler::ir::ElementId; + + use super::{Scene, SceneDirection, SceneError, glyph_advance, layout, selected_theme}; + + 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(layout(&diagram, stack_theme::catalog())?) + } + + fn node<'a>(scene: &'a Scene, identifier: &str) -> Option<&'a super::SceneNode> { + scene.nodes.iter().find(|node| node.id == identifier) + } + + #[test] + fn right_direction_satisfies_same_rank_and_cross_axis_order() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Right\" { layout { direction right rank same [a, b] order [b, a] } node a \"A\" node b \"B\" node c \"C\" }", + )?; + let a = node(&scene, "a").ok_or("missing a")?; + let b = node(&scene, "b").ok_or("missing b")?; + let c = node(&scene, "c").ok_or("missing c")?; + + assert_eq!(scene.direction, SceneDirection::Right); + assert_eq!(a.rect.x, b.rect.x); + assert!(b.rect.y < a.rect.y); + assert!(c.rect.x >= a.rect.x + a.rect.width); + assert!(scene.geometry_is_valid()); + Ok(()) + } + + #[test] + fn down_direction_satisfies_same_rank_and_cross_axis_order() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Down\" { layout { direction down rank same [a, b] order [b, a] } node a \"A\" node b \"B\" node c \"C\" }", + )?; + let a = node(&scene, "a").ok_or("missing a")?; + let b = node(&scene, "b").ok_or("missing b")?; + let c = node(&scene, "c").ok_or("missing c")?; + + assert_eq!(scene.direction, SceneDirection::Down); + assert_eq!(a.rect.y, b.rect.y); + assert!(b.rect.x < a.rect.x); + assert!(c.rect.y >= a.rect.y + a.rect.height); + assert!(scene.geometry_is_valid()); + Ok(()) + } + + #[test] + fn automatic_layout_is_repeatable_and_does_not_inherit() -> Result<(), Box> { + let source = b"stack 1.0 diagram \"Auto\" { group pair \"Pair\" { node a \"A\" node b \"B\" } node c \"C\" node d \"D\" node e \"E\" }"; + let first = scene_from(source)?; + let second = scene_from(source)?; + assert_eq!(first, second); + assert_eq!(first.direction, SceneDirection::Down); + assert_eq!(first.groups[0].direction, SceneDirection::Right); + assert!(first.geometry_is_valid()); + Ok(()) + } + + #[test] + fn nested_groups_and_nodes_stay_inside_non_overlapping_parents() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Nested\" { layout { direction right } node user \"User\" group system \"System\" { layout { direction down } node web \"Web\" group data \"Data\" { node db \"Database\" { kind database detail \"Primary\" } node cache \"Cache\" { kind cache } } node vendor \"Vendor\" { kind external } } }", + )?; + assert_eq!(scene.nodes.len(), 5); + assert_eq!(scene.groups.len(), 2); + for group in &scene.groups { + assert_eq!(group.content_rect.x - group.rect.x, super::GROUP_PADDING); + assert!(group.rect.contains(group.content_rect)); + } + for node in &scene.nodes { + if let Some(parent_group_id) = node.parent_group_id.as_deref() { + let parent = scene + .groups + .iter() + .find(|group| group.id == parent_group_id) + .ok_or("missing parent group")?; + assert!(parent.content_rect.contains(node.rect)); + } + } + assert!(scene.geometry_is_valid()); + Ok(()) + } + + #[test] + fn geometry_coordinates_have_target_independent_width() -> Result<(), Box> { + let scene = scene_from(b"stack 1.0 diagram \"Width\" { node a \"A\" }")?; + assert_eq!(std::mem::size_of_val(&scene.bounds.x), 8); + assert_eq!(std::mem::size_of_val(&scene.nodes[0].rect.width), 8); + Ok(()) + } + + #[test] + fn geometry_matches_cross_target_numeric_fixture() -> Result<(), Box> { + let scene = + scene_from(b"stack 1.0 diagram \"Parity\" { node first \"A\" node second \"BB\" }")?; + assert_eq!( + scene.bounds, + super::Rect { + x: 0, + y: 0, + width: 408_000, + height: 174_200, + } + ); + assert_eq!( + scene.content_rect, + super::Rect { + x: 32_000, + y: 70_200, + width: 344_000, + height: 72_000, + } + ); + assert_eq!( + scene.nodes.iter().map(|node| node.rect).collect::>(), + vec![ + super::Rect { + x: 32_000, + y: 70_200, + width: 160_000, + height: 72_000, + }, + super::Rect { + x: 216_000, + y: 70_200, + width: 160_000, + height: 72_000, + }, + ] + ); + Ok(()) + } + + #[test] + fn geometry_validation_rejects_corrupted_scene_data() -> Result<(), Box> { + let scene = scene_from( + b"stack 1.0 diagram \"Validate\" { node a \"A\" node b \"B\" group pair \"Pair\" { node c \"C\" } }", + )?; + + let mut invalid = scene.clone(); + invalid.bounds.width = 0; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.content_rect.x = invalid.bounds.width; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.nodes.push(invalid.nodes[0].clone()); + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.nodes[0].parent_group_id = Some("missing".to_owned()); + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.nodes[0].rect.width = 0; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.nodes[1].rect = invalid.nodes[0].rect; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.groups[0].parent_group_id = Some("missing".to_owned()); + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene.clone(); + invalid.groups[0].content_rect.width = 0; + assert!(!invalid.geometry_is_valid()); + + let mut invalid = scene; + invalid.nodes[0].rect = invalid.groups[0].rect; + assert!(!invalid.geometry_is_valid()); + Ok(()) + } + + #[test] + fn versioned_metrics_make_wide_scalar_measurement_explicit() -> Result<(), Box> { + let compiled = + stack_compiler::compile_bytes(b"stack 1.0 diagram \"Metrics\" { node a \"A\" }"); + let diagram = compiled.diagram.ok_or("missing diagram")?; + let catalog = stack_theme::catalog(); + let theme = selected_theme(&diagram, catalog)?; + let metrics = catalog + .font_metrics + .iter() + .find(|metrics| metrics.id == theme.typography.font_metrics_id) + .ok_or("missing metrics")?; + + assert_eq!(glyph_advance('A', metrics), 600); + assert_eq!(glyph_advance('日', metrics), 1000); + assert_eq!(glyph_advance('🚀', metrics), 1000); + assert_eq!(glyph_advance('i', metrics), 250); + Ok(()) + } + + #[test] + fn malformed_catalog_and_ir_return_scene_errors() -> Result<(), Box> { + let compiled = + stack_compiler::compile_bytes(b"stack 1.0 diagram \"Errors\" { node a \"A\" }"); + let mut diagram = compiled.diagram.ok_or("missing diagram")?; + let mut catalog = stack_theme::catalog().clone(); + catalog.themes.clear(); + assert_eq!(layout(&diagram, &catalog), Err(SceneError::MissingTheme)); + + let mut catalog = stack_theme::catalog().clone(); + catalog.font_metrics.clear(); + assert_eq!( + layout(&diagram, &catalog), + Err(SceneError::MissingFontMetrics) + ); + + diagram.children.push(ElementId::Node("missing".to_owned())); + assert_eq!( + layout(&diagram, stack_theme::catalog()), + Err(SceneError::InvalidIntermediateRepresentation) + ); + assert_eq!( + SceneError::MissingTheme.to_string(), + "no requested or fallback theme is available" + ); + assert_eq!( + SceneError::MissingFontMetrics.to_string(), + "theme references unavailable font metrics" + ); + assert_eq!( + SceneError::InvalidIntermediateRepresentation.to_string(), + "normalized containment references are inconsistent" + ); + Ok(()) + } + + #[cfg(feature = "conformance")] + #[test] + fn canonical_complete_semantics_matches_snapshot() -> Result<(), Box> { + let specification = std::env::var("STACK_SPECIFICATION_DIR")?; + let source = std::fs::read( + std::path::Path::new(&specification) + .join("conformance/valid/complete-semantics/source.stack"), + )?; + let scene = scene_from(&source)?; + let actual = scene_snapshot(&scene); + let expected = include_str!("../tests/snapshots/complete-semantics.scene.txt"); + assert_eq!(actual, expected); + Ok(()) + } + + #[cfg(feature = "conformance")] + fn scene_snapshot(scene: &Scene) -> String { + let mut output = format!( + "scene|bounds={},{},{},{}|content={},{},{},{}|direction={:?}\n", + scene.bounds.x, + scene.bounds.y, + scene.bounds.width, + scene.bounds.height, + scene.content_rect.x, + scene.content_rect.y, + scene.content_rect.width, + scene.content_rect.height, + scene.direction + ); + for group in &scene.groups { + output.push_str(&format!( + "group|{}|parent={}|rect={},{},{},{}|content={},{},{},{}|direction={:?}\n", + group.id, + group.parent_group_id.as_deref().unwrap_or("-"), + group.rect.x, + group.rect.y, + group.rect.width, + group.rect.height, + group.content_rect.x, + group.content_rect.y, + group.content_rect.width, + group.content_rect.height, + group.direction + )); + } + for node in &scene.nodes { + output.push_str(&format!( + "node|{}|parent={}|rect={},{},{},{}\n", + node.id, + node.parent_group_id.as_deref().unwrap_or("-"), + node.rect.x, + node.rect.y, + node.rect.width, + node.rect.height + )); + } + output + } +} diff --git a/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt b/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt new file mode 100644 index 0000000..65751bb --- /dev/null +++ b/crates/stack-engine/tests/snapshots/complete-semantics.scene.txt @@ -0,0 +1,14 @@ +scene|bounds=0,0,504000,1081200|content=32000,70200,440000,979000|direction=Right +group|system|parent=-|rect=32000,166600,440000,882600|content=56000,220800,392000,804400|direction=Down +group|compute|parent=system|rect=56000,316800,392000,150200|content=80000,371000,344000,72000|direction=Right +group|state|parent=system|rect=56000,491000,208000,438200|content=80000,545200,160000,360000|direction=Down +node|user|parent=-|rect=32000,70200,160000,72400 +node|web|parent=system|rect=56000,220800,160000,72000 +node|api|parent=system|rect=240000,220800,160000,72000 +node|function|parent=compute|rect=80000,371000,160000,72000 +node|worker|parent=compute|rect=264000,371000,160000,72000 +node|database|parent=state|rect=80000,545200,160000,72000 +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 diff --git a/docs/decisions/0003-use-integer-ranked-scene-layout.md b/docs/decisions/0003-use-integer-ranked-scene-layout.md new file mode 100644 index 0000000..59287eb --- /dev/null +++ b/docs/decisions/0003-use-integer-ranked-scene-layout.md @@ -0,0 +1,31 @@ +# ADR-0003: Use integer ranked scene layout + +## Status + +Accepted + +## Date + +2026-09-03 + +## Context + +The engine must turn normalized Stack IR into geometry that is identical in native and WebAssembly builds. The language defines `right`, `down`, automatic direction, same-rank constraints, cross-axis order, and independently configured nested groups. The theme catalog supplies versioned font advances, so layout must not depend on platform fonts, browser measurement, floating-point rounding, locale, or host state. + +## Decision + +Build an SVG-independent scene inside `stack-engine`. Every coordinate and extent is a signed 64-bit integer measured in one-thousandth of a CSS pixel. Text widths use only the selected theme's explicit glyph advances, wide Unicode ranges, default advance, units per em, and line-height ratio. Arithmetic rounds upward with integers. + +Layout proceeds in two deterministic passes. A bottom-up pass calculates node and group sizes. A top-down pass places root elements and recursively places group children inside explicit content rectangles. Nodes and nested groups are siblings for collision checks. Parent content rectangles exclude title and padding areas, so geometry validation verifies padding containment instead of checking only outer bounds. + +Authored `right` and `down` directions are exact. In a right-directed scope, same-rank elements share the x coordinate and order controls top-to-bottom placement. In a down-directed scope, they share the y coordinate and order controls left-to-right placement. Automatic direction selects right for one to three direct children and down for four or more. Each nested scope resolves its own direction; it never inherits from its parent. + +The implementation has no layout dependency and no host access. The canonical complete-semantics fixture has a checked-in exact integer scene snapshot. Unit tests cover rank alignment, cross-axis order, nesting, padding, non-overlap, automatic direction, wide-character metrics, malformed normalized input, and fixed-width coordinates. CI compiles the browser target and executes the same exact numeric fixture in native and `wasm32-wasip1` test binaries. Adapter-level native/WASM output parity remains part of the later WebAssembly integration. + +## Consequences + +- Repeated layout for the same normalized IR, catalog revision, and options produces the same scene. +- Native and WebAssembly builds share integer geometry code and do not require platform font APIs. +- The automatic policy is intentionally simple and stable; changing it is an observable scene-layout change and requires snapshot review. +- SVG serialization can consume scene rectangles without owning ranking, measurement, or containment semantics. +- More advanced routing and constraint optimization can be added behind the scene boundary without changing compiler IR. diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md index afc6e6d..c6b7cb4 100644 --- a/docs/dependency-audit.md +++ b/docs/dependency-audit.md @@ -10,7 +10,7 @@ Audit date: 2026-09-03 - the workspace-local `stack-formatter` for canonical source output; - `stack-theme` at `ed6c500762fc9ccffc8777172ac672a716dcd916` for the embedded core catalog, SVG bytes, deterministic font metrics, catalog version, and catalog revision. -The resolved normal dependency graph adds only the `serde` and `serde_json` graph required by `stack-theme`. Exact versions and licenses are recorded in [`THIRD_PARTY_LICENSES.md`](../THIRD_PARTY_LICENSES.md) and pinned in `Cargo.lock`. No layout, SVG, filesystem, network, asynchronous runtime, random, clock, locale, DOM, or platform-font dependency is present. +The resolved normal dependency graph adds only the `serde` and `serde_json` graph required by `stack-theme`. Exact versions and licenses are recorded in [`THIRD_PARTY_LICENSES.md`](../THIRD_PARTY_LICENSES.md) and pinned in `Cargo.lock`. Scene layout is implemented locally with fixed-width integer arithmetic and versioned catalog metrics. No layout, SVG, filesystem, network, asynchronous runtime, random, clock, locale, DOM, or platform-font dependency is present. ## Runtime access boundary @@ -27,5 +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 +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 clippy --workspace --all-targets --all-features --locked -- -D warnings ```