From 69e04300625e1d6d2d04fc0f10a9249d058784fe Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 20:36:18 -0400 Subject: [PATCH] Push 3 Standard-tier: vertical justification (fill non-final pages) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vertical analog of per-system justification, and the first piece of the deferred vertical spring solve: the systems of every non-final page spread so the last system's bottom reaches the content bottom, filling the page height. A second pass after the top-down stacking loop, once page membership is known: for each non-final page with >=2 systems it computes the vertical slack (the last system's natural bottom above the content bottom) and distributes it evenly across the inter-system gaps — system i (0-based on the page) sinks by i/(n-1) of the slack, so the first stays at the content top and the last lands on the content bottom. Only Placement::dy changes, so it composes cleanly with horizontal justification (independent axes). ENGRAVER_VERSION 8 -> 9. The last page stays ragged-bottom (top-aligned, engraving convention), so a single-page score is unchanged — every existing single-page golden is byte-identical (zero golden churn). A single-system or already-full page has no slack. Drives page_fill_efficiency to ~0 on justified pages. Regression: vertical_justification_fills_non_final_pages (a small custom PageGeometry forces the multi-page path; the non-final page fills, the last stays ragged; verified to fail without the pass). Inter-staff band-height renegotiation within a multi-staff system remains the deferred rest of the vertical spring solve. 942 tests, clippy 0, docs -D warnings, conformance 8/8. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-engrave/DECISIONS.md | 35 +++++++++++- crates/epiphany-engrave/src/casting.rs | 30 ++++++++++ crates/epiphany-engrave/src/lib.rs | 79 +++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index 521b7a4..3fb40de 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -26,8 +26,10 @@ greedy system breaking at measure boundaries, vertical system stacking, page assignment, a populated `ResolvedPage`/`ResolvedSystem` tree, and full break- constraint evaluation. **Push 3's Standard-tier track then adds per-system JUSTIFICATION** (see "Per-system justification (Push 3)" below): every non-final -system stretches to fill the content width. Still deferred: the vertical -soft-spring solve within a system, and optimal (lookahead) break search. +system stretches to fill the content width; and **vertical justification** (same +section): the systems of a non-final page spread to fill the page height. Still +deferred: the inter-staff soft-spring solve *within* a system (band-height +renegotiation), and optimal (lookahead) break search. ### Honest tier @@ -597,3 +599,32 @@ a metric-semantics change needing catalog alignment. **Also noted:** justification's horizontal stretch of a slur is not reflected — a second-order gap in the same family as the spaced-vs-constrained one, a follow-up if slur fidelity warrants measuring the post-justification whole curve. + +## Vertical justification (Push 3, 2026-07-08) + +The vertical analog of per-system justification, and the first piece of the +deferred vertical spring solve: the systems of every **non-final page** spread +so the last system's bottom reaches the content bottom, filling the page height. +A second pass after the top-down stacking loop, once page membership is known: +for each non-final page with ≥2 systems it computes the vertical slack (the last +system's natural bottom above the content bottom) and distributes it evenly +across the inter-system gaps — system `i` (0-based on the page) sinks by +`i/(n−1)` of the slack, so the first stays at the content top and the last lands +on the content bottom. Only `Placement::dy` changes, so it composes cleanly with +horizontal justification (independent axes). `ENGRAVER_VERSION` 8 → 9. + +**Which pages.** The last page stays ragged-bottom (top-aligned), as engraving +convention wants — a single-page score is therefore unchanged (its only page is +the last), so every existing single-page golden is byte-identical. A page with +one system has no inter-system gap to grow; an already-full or overfull page has +no positive slack. Drives `page_fill_efficiency` to ~0 on justified pages (a +non-final page now fills the height). Locked by +`vertical_justification_fills_non_final_pages` (a small custom `PageGeometry` +forces the multi-page path; the non-final page fills, the last stays ragged). + +**Still deferred (the rest of the vertical spring solve).** Inter-staff +band-height renegotiation *within* a multi-staff system (today the constrained +stage's staff stacking is preserved verbatim; `vertical_density_penalty` +measures it but nothing renegotiates it). That needs vertical pressure — a +collision or a target — to be meaningful, and multi-staff systems to exercise; +a later tranche. diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs index fc00bfe..e5ef229 100644 --- a/crates/epiphany-engrave/src/casting.rs +++ b/crates/epiphany-engrave/src/casting.rs @@ -733,6 +733,36 @@ pub(crate) fn cast_off( cursor -= height + gap; } + // ---- Vertical justification ------------------------------------------- + // Spread the systems of every NON-FINAL page so the last system's bottom + // reaches the content bottom, filling the page height — the vertical analog + // of per-system horizontal justification, distributing the slack evenly + // across the inter-system gaps. The last page stays ragged-bottom + // (top-aligned), as engraving convention wants; a page with a single system + // has no gap to grow, and an already-full (or overfull) page is left alone. + if bounded { + let last_page = page_systems.len().saturating_sub(1); + for (p, page) in page_systems.iter().enumerate() { + if p == last_page || page.len() < 2 { + continue; + } + let content_bottom = page_top_content(p, geometry) - content_height; + let last = *page.last().expect("a page carries at least one system"); + let natural_bottom = placements[last].dy + extents[last].min_y; + let slack = natural_bottom - content_bottom; + if slack <= 0.0 { + continue; + } + // System i (0-based on the page) sinks by i/(n-1) of the slack, so + // the first stays at the content top and the last lands on the + // content bottom (y-down is decreasing y in this world frame). + let step = slack / (page.len() - 1) as f32; + for (i, &s) in page.iter().enumerate() { + placements[s].dy -= i as f32 * step; + } + } + } + // ---- Break structure and decisions ------------------------------------- let mut system_start_slots = BTreeSet::new(); for plan in &systems { diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 4a6a646..c1eaeb7 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -191,8 +191,12 @@ pub struct Engraver { /// the spacing pass and casting, instead of being stretched by the interpolation /// / justification map and drifting off its head; any wrapping score's baked /// geometry differs, and any score with drawn stems shifts them onto their -/// heads). -pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(8); +/// heads), and to `9` when **vertical justification** landed (the systems of a +/// non-final page spread so the last one's bottom reaches the content bottom, +/// filling the page height; a multi-page score's baked geometry differs, while +/// a single-page score — whose only page is ragged-bottom by convention — is +/// unchanged). +pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(9); impl Engraver { /// An engraver casting off against the given page geometry. @@ -2220,6 +2224,77 @@ mod tests { } } + #[test] + fn vertical_justification_fills_non_final_pages() { + use epiphany_layout_ir::{Margins, Size2D, StaffSpace}; + // Narrow and short, so the ten-measure fixture wraps into several + // systems with two-plus fitting a page — a non-final page to justify. + let geometry = PageGeometry { + size: Size2D { + width: StaffSpace(40.0), + height: StaffSpace(30.0), + }, + margins: Margins { + top: StaffSpace(5.0), + right: StaffSpace(5.0), + bottom: StaffSpace(5.0), + left: StaffSpace(5.0), + }, + }; + let engraver = Engraver::with_geometry(geometry); + let report = engraver.solve(&ten_measure_constrained(), &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings); + let pages = &report.layout.pages; + assert!( + pages.len() >= 2, + "the fixture spans pages; got {}", + pages.len() + ); + + let content_bottom_of = |index: usize| { + let page_top = -(index as f32) * (geometry.size.height.0 + crate::INTER_PAGE_GAP) + - geometry.margins.top.0; + page_top - geometry.content_height() + }; + // Every non-final page with ≥2 systems fills: its last system's bottom + // lands on the content bottom. + let mut justified = 0; + for (index, page) in pages.iter().enumerate() { + if index + 1 == pages.len() || page.systems.len() < 2 { + continue; + } + let last_bottom = page.systems.last().unwrap().bounding_box.origin.y.0; + assert!( + (last_bottom - content_bottom_of(index)).abs() < 1e-2, + "page {} last-system bottom {last_bottom} should reach content \ + bottom {}", + page.number, + content_bottom_of(index) + ); + justified += 1; + } + assert!( + justified > 0, + "no non-final page carried ≥2 systems to justify" + ); + // The LAST page stays ragged-bottom — its last system's bottom sits + // above the content bottom, not force-filled. + let last_index = pages.len() - 1; + let last_bottom = pages[last_index] + .systems + .last() + .unwrap() + .bounding_box + .origin + .y + .0; + assert!( + last_bottom > content_bottom_of(last_index) + 1.0, + "the last page is ragged-bottom: {last_bottom} vs {}", + content_bottom_of(last_index) + ); + } + #[test] fn the_resolved_page_tree_is_populated() { use std::collections::BTreeSet;