Push 3 Standard-tier: vertical justification (fill non-final pages)
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) <noreply@anthropic.com>
This commit is contained in:
parent
fea446a182
commit
69e0430062
|
|
@ -26,8 +26,10 @@ greedy system breaking at measure boundaries, vertical system stacking, page
|
||||||
assignment, a populated `ResolvedPage`/`ResolvedSystem` tree, and full break-
|
assignment, a populated `ResolvedPage`/`ResolvedSystem` tree, and full break-
|
||||||
constraint evaluation. **Push 3's Standard-tier track then adds per-system
|
constraint evaluation. **Push 3's Standard-tier track then adds per-system
|
||||||
JUSTIFICATION** (see "Per-system justification (Push 3)" below): every non-final
|
JUSTIFICATION** (see "Per-system justification (Push 3)" below): every non-final
|
||||||
system stretches to fill the content width. Still deferred: the vertical
|
system stretches to fill the content width; and **vertical justification** (same
|
||||||
soft-spring solve within a system, and optimal (lookahead) break search.
|
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
|
### 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
|
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
|
gap in the same family as the spaced-vs-constrained one, a follow-up if slur
|
||||||
fidelity warrants measuring the post-justification whole curve.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -733,6 +733,36 @@ pub(crate) fn cast_off(
|
||||||
cursor -= height + gap;
|
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 -------------------------------------
|
// ---- Break structure and decisions -------------------------------------
|
||||||
let mut system_start_slots = BTreeSet::new();
|
let mut system_start_slots = BTreeSet::new();
|
||||||
for plan in &systems {
|
for plan in &systems {
|
||||||
|
|
|
||||||
|
|
@ -191,8 +191,12 @@ pub struct Engraver {
|
||||||
/// the spacing pass and casting, instead of being stretched by the interpolation
|
/// the spacing pass and casting, instead of being stretched by the interpolation
|
||||||
/// / justification map and drifting off its head; any wrapping score's baked
|
/// / justification map and drifting off its head; any wrapping score's baked
|
||||||
/// geometry differs, and any score with drawn stems shifts them onto their
|
/// geometry differs, and any score with drawn stems shifts them onto their
|
||||||
/// heads).
|
/// heads), and to `9` when **vertical justification** landed (the systems of a
|
||||||
pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(8);
|
/// 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 {
|
impl Engraver {
|
||||||
/// An engraver casting off against the given page geometry.
|
/// 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]
|
#[test]
|
||||||
fn the_resolved_page_tree_is_populated() {
|
fn the_resolved_page_tree_is_populated() {
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue