2576 lines
106 KiB
Rust
2576 lines
106 KiB
Rust
//! The **casting-off pass** — Minimal-tier system breaking, vertical stacking,
|
||
//! and page assignment (Chapter 9 §"The Constraint-Solving Stage": the solver
|
||
//! "resolve\[s\] page and system breaks"; Chapter 7 §"ResolvedLayoutIR" defines
|
||
//! the page/system tree this pass populates).
|
||
//!
|
||
//! ## The algorithm (optimal break search)
|
||
//!
|
||
//! [`SolverTier::Minimal`](epiphany_layout_ir::SolverTier) requires the break
|
||
//! constraint family to be supported and every hard constraint satisfied (or an
|
||
//! honest `Unsatisfiable`); it makes **no optimality claim**. Casting-off uses a
|
||
//! deterministic **badness-minimizing break search** (`optimal_breaks`, a
|
||
//! Knuth–Plass-style dynamic program) — an honest improvement on the earlier
|
||
//! greedy-first-fit-plus-widow-rebalance, not a formal optimality guarantee.
|
||
//!
|
||
//! 1. **System breaking.** Per region, partition the measures into systems to
|
||
//! minimize the total squared normalized underfill over ALL systems — which
|
||
//! evens them (no lopsided split) and fills them (no needless breaks), the
|
||
//! additive analog of the Quality Metric Catalog's break/imbalance
|
||
//! distribution cost; including the final system in the sum is what removes
|
||
//! the old separate widow rebalance. Breaks fall only at **measure
|
||
//! boundaries** — the barline columns (`to_constrained` draws each measure's
|
||
//! barline at its start column; the region-final barline closes the region
|
||
//! and is never a break candidate). A **hard** `SystemBreakAt`/`PageBreakAt`
|
||
//! is *always* honoured at its slot (and bounds the search's segments); a
|
||
//! **soft** one is honoured unless doing so would close a system with no
|
||
//! musical content (no notehead/rest column) — the documented exceptional
|
||
//! path, recorded as an [`EngravingDecision`] with
|
||
//! [`DecisionSource::IrOverride`] per the spec's override-resolution rule (an
|
||
//! unhonoured override is recorded, not silently dropped). A region with no
|
||
//! measures has no break candidates: it stays one (possibly overfull) system
|
||
//! unless breaks force otherwise. A single measure wider than the page yields
|
||
//! an overfull system — Minimal does not break mid-measure on its own.
|
||
//! 2. **Vertical stacking.** Each system's height is its real content extent
|
||
//! (glyph boxes plus stroke extents — the vertical spring solve that would
|
||
//! renegotiate band heights is deferred, so the constrained `y` geometry is
|
||
//! authoritative); consecutive systems are separated by the vertical-band
|
||
//! model's **inter-system gap** ([`VerticalBand::inter_system_gap`], the
|
||
//! preferred height — genuinely read from the band constructor so the two
|
||
//! cannot drift). Systems that no longer fit the page content height start
|
||
//! the next page.
|
||
//! 3. **Page assignment and the world frame.** Pages stack **vertically in one
|
||
//! world**: page *n*'s top edge sits [`INTER_PAGE_GAP`] staff spaces below
|
||
//! page *n−1*'s bottom edge, page 1's top-left corner at the origin (world
|
||
//! is y-up, so pages grow downward in −y). Every glyph and stroke position
|
||
//! is **baked** into this single world frame (each system is translated
|
||
//! rigidly: x back to the left margin, y to its stacked position), so the
|
||
//! flat glyph/stroke lists remain the renderer's and hit-tester's single
|
||
//! coordinate space — no per-page transform exists anywhere downstream.
|
||
//!
|
||
//! ## Region-spanning strokes
|
||
//!
|
||
//! A stroke confined to one system (a stem, a ledger, a barline-anchored mark)
|
||
//! translates rigidly with it. A stroke spanning several systems — in practice
|
||
//! the five staff lines, which `to_constrained` draws across the whole region —
|
||
//! is **split** at the system boundaries: the first segment keeps the original
|
||
//! stroke's exact provenance (so the round-trip's preservation contract holds),
|
||
//! and each later segment is engraver-**synthesized** from the same source
|
||
//! ([`SynthesisKind::Registered`] under [`SYSTEM_CONTINUATION_SYNTHESIS`], the
|
||
//! codebase's convention for a synthesis kind the normative vocabulary does not
|
||
//! name), keyed by [`continuation_instance_key`] so segments of different lines
|
||
//! can never collide.
|
||
//!
|
||
//! ## Default page geometry
|
||
//!
|
||
//! The spec names `Canvas.layout_defaults` ("paper size, margins") but does not
|
||
//! define its type, and the core graph deliberately does not carry it yet (the
|
||
//! graph home is staged to the data-model schema major — see `DECISIONS.md`),
|
||
//! so page geometry is an **engraver-side parameter** ([`PageGeometry`], a
|
||
//! constructor argument of [`crate::Engraver`]) with a documented default; see
|
||
//! [`PageGeometry::default`] for the arithmetic.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use epiphany_core::{StaffId, TypedObjectId};
|
||
use epiphany_layout_ir::{
|
||
continuation_instance_key, inter_staff_gap_id, is_barline_glyph, is_rigid_width_stroke,
|
||
synthesized_layout_id, BreakClass, BreakKind, ConstrainedLayoutIR, Curve, DecisionSource,
|
||
EngravingDecision, EngravingDecisionKind, EngravingOverrideId, GlyphObject, GlyphObjectId,
|
||
LayoutConstraint, LayoutObjectId, Margins, Point, PrimitiveIndices, Provenance, Rect,
|
||
ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem, Size2D,
|
||
SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId,
|
||
VerticalBand, VerticalBandId, VerticalBandKind,
|
||
};
|
||
|
||
use crate::owning_glyph;
|
||
|
||
/// The registry id for the engraver's **system-continuation synthesis**: the
|
||
/// segment of a region-spanning stroke (a staff line) that casting-off places
|
||
/// in a system after the stroke's first. The normative [`SynthesisKind`] set
|
||
/// names no purely visual continuation rule, so — like the constrained stage's
|
||
/// staff-line/ledger/accidental syntheses — it is carried as a `Registered`
|
||
/// extension kind (Chapter 7 §"Behavior Under Unknown Extensions").
|
||
pub const SYSTEM_CONTINUATION_SYNTHESIS: SynthesisRegistryId =
|
||
SynthesisRegistryId(0x5359_5354_4D53_4547); // "SYSTMSEG"
|
||
|
||
/// The vertical gap between consecutive **pages** in the single world frame, in
|
||
/// staff spaces. Pages are separate physical sheets; this gap exists only in
|
||
/// the continuous scroll-like world the renderer and hit-tester share, so it is
|
||
/// a presentation constant, not engraving geometry.
|
||
pub const INTER_PAGE_GAP: f32 = 8.0;
|
||
|
||
/// Namespace bit for a synthesized *system* provenance instance key (a region's
|
||
/// second and later systems), disjoint from the page namespace below and — by
|
||
/// 128-bit-hash construction — from the slot-identity keys of break decisions.
|
||
const KEY_NS_SYSTEM: u128 = 1;
|
||
/// Namespace bit for a synthesized *page* provenance instance key.
|
||
const KEY_NS_PAGE: u128 = 2;
|
||
|
||
/// Page geometry the engraver casts off against: the page size and margins, in
|
||
/// staff spaces (Chapter 7 §7.2: IR coordinates are staff spaces). A parameter
|
||
/// of [`crate::Engraver`] because the score graph has no home for it yet — the
|
||
/// spec's `Canvas.layout_defaults` is named but never defined, and adding a
|
||
/// graph field is a data-model schema-major change (see `DECISIONS.md`).
|
||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||
pub struct PageGeometry {
|
||
/// Full page size, in staff spaces.
|
||
pub size: Size2D,
|
||
/// Page margins, in staff spaces.
|
||
pub margins: Margins,
|
||
}
|
||
|
||
impl PageGeometry {
|
||
/// The horizontal content extent a system may fill: page width minus the
|
||
/// left and right margins. Non-positive geometry disables automatic
|
||
/// wrapping (treated as unbounded) rather than failing the solve.
|
||
pub fn content_width(&self) -> f32 {
|
||
self.size.width.0 - self.margins.left.0 - self.margins.right.0
|
||
}
|
||
|
||
/// The vertical content extent a page may fill: page height minus the top
|
||
/// and bottom margins. Non-positive geometry disables page overflow
|
||
/// (treated as unbounded) rather than failing the solve.
|
||
pub fn content_height(&self) -> f32 {
|
||
self.size.height.0 - self.margins.top.0 - self.margins.bottom.0
|
||
}
|
||
}
|
||
|
||
impl Default for PageGeometry {
|
||
/// **A4 portrait at an 8 mm staff height** (rastral ≈ size 1, a common
|
||
/// full-size instrumental-part raster), 15 mm margins. The arithmetic, with
|
||
/// 1 staff space = staff height / 4 = 2.0 mm:
|
||
///
|
||
/// * page: 210 mm × 297 mm → **105 × 148.5** staff spaces;
|
||
/// * margins: 15 mm each → **7.5** staff spaces;
|
||
/// * content area: 180 mm × 267 mm → **90 × 133.5** staff spaces.
|
||
///
|
||
/// 90 staff spaces of content width wraps the QUICKSTART's ten-measure
|
||
/// hand-off fixture (whose spaced width is ≈ 99 staff spaces) into two
|
||
/// systems — an honest multi-system default rather than one that only ever
|
||
/// produces the degenerate single line.
|
||
fn default() -> Self {
|
||
PageGeometry {
|
||
size: Size2D {
|
||
width: StaffSpace(105.0),
|
||
height: StaffSpace(148.5),
|
||
},
|
||
margins: Margins {
|
||
top: StaffSpace(7.5),
|
||
right: StaffSpace(7.5),
|
||
bottom: StaffSpace(7.5),
|
||
left: StaffSpace(7.5),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What the casting-off pass produced: the final world-frame geometry, the
|
||
/// populated page/system tree, the engraver's appended break decisions, and the
|
||
/// break structure the constraint evaluation consults.
|
||
pub(crate) struct CastLayout {
|
||
/// Final glyphs, in input order, positions baked into the world frame.
|
||
pub glyphs: Vec<ResolvedGlyph>,
|
||
/// Final strokes: the input strokes in order (each translated with its
|
||
/// system; a system-spanning stroke replaced by its first segment), then
|
||
/// the synthesized continuation segments.
|
||
pub strokes: Vec<Stroke>,
|
||
/// Final curves, in input order, each translated with its system. A curve
|
||
/// spanning a system break is split into per-system sub-curves by de
|
||
/// Casteljau subdivision (the first keeps the source's provenance, the rest
|
||
/// are synthesized continuations, like system-spanning strokes).
|
||
pub curves: Vec<Curve>,
|
||
/// The populated page tree (empty when the input declares no regions).
|
||
pub pages: Vec<ResolvedPage>,
|
||
/// Break decisions this pass made (chosen breaks in reading order, then
|
||
/// the skipped-soft `IrOverride` records in walk order).
|
||
pub decisions: Vec<EngravingDecision>,
|
||
/// Slots at which the final layout breaks: the first slot of every system.
|
||
pub system_start_slots: BTreeSet<SpringSlotId>,
|
||
/// Slots at which a page begins: the first slot of each page's first system.
|
||
pub page_start_slots: BTreeSet<SpringSlotId>,
|
||
/// The system each baked glyph landed in, parallel to `glyphs` — derived
|
||
/// once, inside the casting pass, from the slot→system assignment that
|
||
/// pass computes for its own use. **This is the glyph→system attribution,
|
||
/// full stop** (W1 pin 7): the raw slot map is deliberately *not*
|
||
/// published, so no consumer can grow a second copy of the rule that then
|
||
/// drifts. `None`: the glyph's slot was claimed by no region, so it
|
||
/// belongs to no per-system aggregate.
|
||
pub glyph_system: Vec<Option<usize>>,
|
||
/// The system each baked stroke landed in, parallel to `strokes` (including
|
||
/// the appended continuation segments). A stroke carries no spring slot, so
|
||
/// the slot map cannot answer for it; the casting pass records what it
|
||
/// already knew. `None`: claimed by no region.
|
||
pub stroke_system: Vec<Option<usize>>,
|
||
/// The system each baked curve landed in, parallel to `curves`.
|
||
pub curve_system: Vec<Option<usize>>,
|
||
/// The region each system slices, indexed by global system index (the
|
||
/// per-region grouping the casting-off quality metrics aggregate by).
|
||
pub region_of_system: Vec<usize>,
|
||
/// The primitives no system claims — `glyph_system`/`stroke_system`/
|
||
/// `curve_system` entries of `None`, gathered into the same shape
|
||
/// [`ResolvedSystem::primitives`] uses (W1 pin 3: unowned is a first-class
|
||
/// bucket, never coerced onto a system).
|
||
pub unowned: PrimitiveIndices,
|
||
}
|
||
|
||
/// One realized spring slot in spaced (pre-casting) coordinates, with the
|
||
/// classification the greedy walk needs.
|
||
struct SlotInfo {
|
||
id: SpringSlotId,
|
||
/// Reference x: the first member glyph's spaced baseline.
|
||
x: f32,
|
||
/// Leftmost content edge (member glyph boxes plus their rigid strokes).
|
||
lo: f32,
|
||
/// Rightmost content edge.
|
||
hi: f32,
|
||
/// Member glyph indices into the (parallel) input/spaced glyph vectors.
|
||
members: Vec<usize>,
|
||
/// The column carries a barline glyph — a measure boundary.
|
||
barline: bool,
|
||
/// The column carries the region-final barline (never a break candidate).
|
||
final_barline: bool,
|
||
/// The column carries musical content (a notehead or a rest).
|
||
note: bool,
|
||
/// The directly-manifested barline glyph of a measure *start* (glyph
|
||
/// index), for the per-system measure records. `None` at the final
|
||
/// barline: that measure's start is not marked by any column in this
|
||
/// projection, so its record is omitted rather than fabricated.
|
||
measure_barline: Option<usize>,
|
||
}
|
||
|
||
/// A break requirement a constraint declares at a slot.
|
||
#[derive(Copy, Clone)]
|
||
struct BreakReq {
|
||
page: bool,
|
||
hard: bool,
|
||
}
|
||
|
||
/// The boundary decision that opened a system (absent at a region's first).
|
||
#[derive(Copy, Clone)]
|
||
struct Boundary {
|
||
slot: SpringSlotId,
|
||
source: DecisionSource,
|
||
}
|
||
|
||
/// One cast-off system: which region it slices and which of that region's
|
||
/// slots it carries.
|
||
struct SystemPlan {
|
||
region: usize,
|
||
/// Region-local ordinal (0-based).
|
||
local: usize,
|
||
/// Indices into the region's ordered slot vector.
|
||
slots: Vec<usize>,
|
||
boundary: Option<Boundary>,
|
||
/// A page must start at this system (a page-break request sits here).
|
||
page_forced: bool,
|
||
/// Attribution for a forced page start (the page-break decision's source).
|
||
page_source: DecisionSource,
|
||
}
|
||
|
||
/// A stroke's casting fate: ride one system rigidly, or split at system
|
||
/// boundaries.
|
||
enum StrokeFate {
|
||
/// Translate the whole stroke with this system (`None`: not covered by any
|
||
/// region — left untransformed in the spaced frame, on no page).
|
||
Rigid(Option<usize>),
|
||
/// Per-system segments, ascending system order: `(system, from, to)` in
|
||
/// spaced coordinates.
|
||
Split(Vec<(usize, Point, Point)>),
|
||
}
|
||
|
||
/// A curve's casting fate: ride one system rigidly, or split at system
|
||
/// boundaries into per-system sub-cubics (de Casteljau).
|
||
enum CurveFate {
|
||
/// Translate the whole curve with this system (`None`: not covered by any
|
||
/// region).
|
||
Rigid(Option<usize>),
|
||
/// Per-system sub-cubics, ascending system order: `(system, control points)`
|
||
/// in spaced coordinates.
|
||
Split(Vec<(usize, [Point; 4])>),
|
||
}
|
||
|
||
/// A system's world-frame placement: a vertical shift `dy` plus a horizontal
|
||
/// affine map `world_x = a·x + b`.
|
||
///
|
||
/// A rigid (unjustified) system has `a = 1`, `b = dx` — a pure translation. A
|
||
/// **justified** system has `a > 1`: the horizontal slack (content width minus
|
||
/// natural ink width) is spread linearly across the line so its ink fills the
|
||
/// content width. The map is applied SLOT-RELATIVELY to glyphs — each slot's
|
||
/// members translate by the map evaluated at the slot's source, so intra-slot
|
||
/// offsets (a time signature after its barline, an accidental left of its
|
||
/// notehead) survive verbatim — directly to spanning-stroke and curve
|
||
/// endpoints, and via the owning slot for a rigid-width stroke (a stem or ledger
|
||
/// that must stay attached to its notehead, not stretch).
|
||
#[derive(Copy, Clone)]
|
||
struct Placement {
|
||
a: f32,
|
||
b: f32,
|
||
dy: f32,
|
||
/// The system's slot-source range `[x0, x1]`. The affine stretch acts only
|
||
/// WITHIN it; beyond it (a glyph's bearing overhang, a staff line drawn to
|
||
/// the ink edge) the map is rigid slope-1, so the mapped ink extremes agree
|
||
/// exactly with the per-slot deltas at the first/last slots.
|
||
x0: f32,
|
||
x1: f32,
|
||
}
|
||
|
||
impl Placement {
|
||
/// A pure translation (an unjustified system, or the identity fallback for
|
||
/// content no system claims). `a = 1`, so the clamp range is irrelevant.
|
||
fn rigid(dx: f32, dy: f32) -> Self {
|
||
Placement {
|
||
a: 1.0,
|
||
b: dx,
|
||
dy,
|
||
x0: 0.0,
|
||
x1: 0.0,
|
||
}
|
||
}
|
||
/// The world x of a spaced x: affine within the slot-source range, rigid
|
||
/// (slope 1) beyond it.
|
||
fn x(&self, x: f32) -> f32 {
|
||
let c = x.clamp(self.x0, self.x1);
|
||
self.a * c + self.b + (x - c)
|
||
}
|
||
/// The rigid delta every glyph in a slot whose source is `slot_x`
|
||
/// translates by — constant per slot, so intra-slot offsets are preserved.
|
||
/// Slot sources lie in `[x0, x1]`, so no clamp is needed.
|
||
fn slot_dx(&self, slot_x: f32) -> f32 {
|
||
(self.a - 1.0) * slot_x + self.b
|
||
}
|
||
/// The same placement sunk downward by `shift` — the inter-staff solve
|
||
/// pushes a staff's content down within its system (y-down is decreasing y).
|
||
fn sunk(&self, shift: f32) -> Self {
|
||
Placement {
|
||
dy: self.dy - shift,
|
||
..*self
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The content extent of a system in spaced (pre-casting) coordinates.
|
||
#[derive(Copy, Clone)]
|
||
struct Extent {
|
||
min_x: f32,
|
||
min_y: f32,
|
||
max_x: f32,
|
||
max_y: f32,
|
||
any: bool,
|
||
}
|
||
|
||
impl Extent {
|
||
fn empty() -> Self {
|
||
Extent {
|
||
min_x: f32::INFINITY,
|
||
min_y: f32::INFINITY,
|
||
max_x: f32::NEG_INFINITY,
|
||
max_y: f32::NEG_INFINITY,
|
||
any: false,
|
||
}
|
||
}
|
||
|
||
/// Extend only the vertical extent (the inter-staff solve grows a system's
|
||
/// height by shifting staves apart, without touching its x-span).
|
||
fn add_y(&mut self, y0: f32, y1: f32) {
|
||
if y0.is_finite() && y1.is_finite() {
|
||
self.min_y = self.min_y.min(y0.min(y1));
|
||
self.max_y = self.max_y.max(y0.max(y1));
|
||
self.any = true;
|
||
}
|
||
}
|
||
|
||
/// Extend only the horizontal extent. Staff-attributed content contributes
|
||
/// its y through the inter-staff solve (SHIFTED), never here.
|
||
fn add_x(&mut self, x0: f32, x1: f32) {
|
||
if x0.is_finite() && x1.is_finite() {
|
||
self.min_x = self.min_x.min(x0.min(x1));
|
||
self.max_x = self.max_x.max(x0.max(x1));
|
||
self.any = true;
|
||
}
|
||
}
|
||
|
||
/// Normalized: a content-less system is a zero box at the origin.
|
||
fn normalized(self) -> Self {
|
||
if self.any {
|
||
self
|
||
} else {
|
||
Extent {
|
||
min_x: 0.0,
|
||
min_y: 0.0,
|
||
max_x: 0.0,
|
||
max_y: 0.0,
|
||
any: false,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The MUSCLOID target of an engraved break decision: synthesized from the
|
||
/// owning region's source under [`SynthesisKind::EngravedBreak`], keyed by the
|
||
/// breaking slot's identity (the slot id is itself content-derived from the
|
||
/// region and its column, so the key is the column's semantic identity, never a
|
||
/// layout-position ordinal).
|
||
fn break_target(region_source: TypedObjectId, slot: SpringSlotId) -> LayoutObjectId {
|
||
synthesized_layout_id(
|
||
®ion_source,
|
||
SynthesisKind::EngravedBreak,
|
||
SynthesisInstanceKey(slot.0),
|
||
)
|
||
}
|
||
|
||
/// The decision source for a break honoured at `slot`: the user override that
|
||
/// asked for it when the projection recorded one, else `Automatic`.
|
||
fn origin_source(
|
||
origins: &BTreeMap<(u128, bool), EngravingOverrideId>,
|
||
slot: SpringSlotId,
|
||
page: bool,
|
||
) -> DecisionSource {
|
||
match origins.get(&(slot.0, page)) {
|
||
Some(id) => DecisionSource::UserOverride(*id),
|
||
None => DecisionSource::Automatic,
|
||
}
|
||
}
|
||
|
||
/// Casts the spaced layout off into systems and pages. Pure and deterministic:
|
||
/// a function of the input IR, the spaced geometry, and the page geometry.
|
||
pub(crate) fn cast_off(
|
||
input: &ConstrainedLayoutIR,
|
||
spaced_glyphs: &[ResolvedGlyph],
|
||
spaced_strokes: &[Stroke],
|
||
spaced_curves: &[Curve],
|
||
geometry: &PageGeometry,
|
||
) -> CastLayout {
|
||
// ---- Slot table (spaced coordinates) --------------------------------
|
||
let mut slots: BTreeMap<SpringSlotId, SlotInfo> = BTreeMap::new();
|
||
for (i, (glyph, spaced)) in input.glyphs.iter().zip(spaced_glyphs).enumerate() {
|
||
let name = glyph.glyph.as_str();
|
||
let x = spaced.position.x.0;
|
||
let lo = x + glyph.bounding_box.left.0;
|
||
let hi = x + glyph.bounding_box.right.0;
|
||
let entry = slots.entry(glyph.horizontal_slot).or_insert(SlotInfo {
|
||
id: glyph.horizontal_slot,
|
||
x,
|
||
lo,
|
||
hi,
|
||
members: Vec::new(),
|
||
barline: false,
|
||
final_barline: false,
|
||
note: false,
|
||
measure_barline: None,
|
||
});
|
||
entry.lo = entry.lo.min(lo);
|
||
entry.hi = entry.hi.max(hi);
|
||
entry.members.push(i);
|
||
// Barline classification by the engraver's own name vocabulary (which
|
||
// includes the composite repeat signs a repeat boundary morphs a
|
||
// measure barline into) — but only for a **directly-manifested measure
|
||
// barline**: the casting contract breaks systems at measure
|
||
// boundaries, so a repeat-synthesized standalone sign (a mid-measure
|
||
// boundary, a region edge without a final barline) must not become a
|
||
// phantom break candidate that could tear off a degenerate lone-sign
|
||
// trailing system or split a measure.
|
||
if is_barline_glyph(name)
|
||
&& glyph.provenance.synthesis.is_none()
|
||
&& matches!(glyph.provenance.source, TypedObjectId::Measure(_))
|
||
{
|
||
entry.barline = true;
|
||
if name == "barlineFinal" {
|
||
entry.final_barline = true;
|
||
} else if entry.measure_barline.is_none() {
|
||
entry.measure_barline = Some(i);
|
||
}
|
||
}
|
||
if name.starts_with("notehead") || name.starts_with("rest") {
|
||
entry.note = true;
|
||
}
|
||
}
|
||
// Fold each rigid stroke (a ledger line) into its owning slot's extent, so
|
||
// an overhanging ledger widens the measure it belongs to (mirrors the
|
||
// spacing pass's extent rule).
|
||
for (stroke, spaced) in input.strokes.iter().zip(spaced_strokes) {
|
||
if !is_rigid_width_stroke(stroke) {
|
||
continue;
|
||
}
|
||
if let Some(glyph) = owning_glyph(stroke, &input.glyphs) {
|
||
if let Some(entry) = slots.get_mut(&glyph.horizontal_slot) {
|
||
entry.lo = entry.lo.min(spaced.from.x.0.min(spaced.to.x.0));
|
||
entry.hi = entry.hi.max(spaced.from.x.0.max(spaced.to.x.0));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Region partition ------------------------------------------------
|
||
let mut region_of_glyph: BTreeMap<GlyphObjectId, usize> = BTreeMap::new();
|
||
for (r, region) in input.regions.iter().enumerate() {
|
||
for id in ®ion.glyphs {
|
||
region_of_glyph.entry(*id).or_insert(r);
|
||
}
|
||
}
|
||
let mut region_slots: Vec<Vec<SlotInfo>> =
|
||
(0..input.regions.len()).map(|_| Vec::new()).collect();
|
||
for (_, info) in slots {
|
||
let region = info
|
||
.members
|
||
.first()
|
||
.and_then(|&i| region_of_glyph.get(&input.glyphs[i].id()))
|
||
.copied();
|
||
// A slot no region claims (out-of-pipeline input) is left out: its
|
||
// glyphs stay in the spaced frame, on no page.
|
||
if let Some(r) = region {
|
||
region_slots[r].push(info);
|
||
}
|
||
}
|
||
for infos in &mut region_slots {
|
||
infos.sort_by(|a, b| a.x.total_cmp(&b.x).then_with(|| a.id.cmp(&b.id)));
|
||
}
|
||
// Each slot's spaced reference x, for the slot-relative justification delta.
|
||
let slot_source_x: BTreeMap<SpringSlotId, f32> = region_slots
|
||
.iter()
|
||
.flatten()
|
||
.map(|info| (info.id, info.x))
|
||
.collect();
|
||
|
||
// ---- Break requirements ----------------------------------------------
|
||
let mut reqs: BTreeMap<SpringSlotId, Vec<BreakReq>> = BTreeMap::new();
|
||
for constraint in &input.constraints {
|
||
let (slot, page, kind) = match constraint {
|
||
LayoutConstraint::SystemBreakAt { slot, kind } => (*slot, false, *kind),
|
||
LayoutConstraint::PageBreakAt { slot, kind } => (*slot, true, *kind),
|
||
_ => continue,
|
||
};
|
||
reqs.entry(slot).or_default().push(BreakReq {
|
||
page,
|
||
hard: kind == BreakKind::Hard,
|
||
});
|
||
}
|
||
let mut origins: BTreeMap<(u128, bool), EngravingOverrideId> = BTreeMap::new();
|
||
for origin in &input.break_origins {
|
||
origins
|
||
.entry((origin.slot.0, origin.class == BreakClass::Page))
|
||
.or_insert(origin.override_id);
|
||
}
|
||
|
||
// ---- System breaking (greedy first-fit per region) --------------------
|
||
let width_limit = {
|
||
let w = geometry.content_width();
|
||
if w > 0.0 {
|
||
w
|
||
} else {
|
||
f32::INFINITY
|
||
}
|
||
};
|
||
let mut systems: Vec<SystemPlan> = Vec::new();
|
||
let mut skipped: Vec<EngravingDecision> = Vec::new();
|
||
for (r, infos) in region_slots.iter().enumerate() {
|
||
let region_source = input.regions[r].provenance.source;
|
||
walk_region(
|
||
r,
|
||
infos,
|
||
&reqs,
|
||
&origins,
|
||
region_source,
|
||
width_limit,
|
||
&mut systems,
|
||
&mut skipped,
|
||
);
|
||
}
|
||
|
||
// (The old greedy pass needed a second widow-rebalance phase here; the
|
||
// optimal break search evens the final system directly — see
|
||
// `optimal_breaks`.)
|
||
|
||
// ---- Stroke fates ------------------------------------------------------
|
||
// Which system each slot landed in, and each region's slot span / per-system
|
||
// clip intervals (the interior cut points for system-spanning strokes).
|
||
let mut system_of_slot: BTreeMap<SpringSlotId, usize> = BTreeMap::new();
|
||
for (s, plan) in systems.iter().enumerate() {
|
||
for &i in &plan.slots {
|
||
system_of_slot.insert(region_slots[plan.region][i].id, s);
|
||
}
|
||
}
|
||
let region_spans: Vec<Option<(f32, f32)>> = region_slots
|
||
.iter()
|
||
.map(|infos| {
|
||
infos
|
||
.iter()
|
||
.map(|s| (s.lo, s.hi))
|
||
.reduce(|a, b| (a.0.min(b.0), a.1.max(b.1)))
|
||
})
|
||
.collect();
|
||
let mut region_systems: Vec<Vec<usize>> = vec![Vec::new(); input.regions.len()];
|
||
for (s, plan) in systems.iter().enumerate() {
|
||
region_systems[plan.region].push(s);
|
||
}
|
||
let mut clips: Vec<(f32, f32)> = vec![(f32::NEG_INFINITY, f32::INFINITY); systems.len()];
|
||
for (r, sys_of_region) in region_systems.iter().enumerate() {
|
||
let last = sys_of_region.len().saturating_sub(1);
|
||
for (local, &s) in sys_of_region.iter().enumerate() {
|
||
let lo = if local == 0 {
|
||
f32::NEG_INFINITY
|
||
} else {
|
||
systems[s]
|
||
.slots
|
||
.iter()
|
||
.map(|&i| region_slots[r][i].lo)
|
||
.fold(f32::INFINITY, f32::min)
|
||
};
|
||
let hi = if local == last {
|
||
f32::INFINITY
|
||
} else {
|
||
systems[s]
|
||
.slots
|
||
.iter()
|
||
.map(|&i| region_slots[r][i].hi)
|
||
.fold(f32::NEG_INFINITY, f32::max)
|
||
};
|
||
clips[s] = (lo, hi);
|
||
}
|
||
}
|
||
let fates: Vec<StrokeFate> = input
|
||
.strokes
|
||
.iter()
|
||
.zip(spaced_strokes)
|
||
.map(|(stroke, spaced)| {
|
||
stroke_fate(
|
||
stroke,
|
||
spaced,
|
||
input,
|
||
&system_of_slot,
|
||
®ion_spans,
|
||
®ion_systems,
|
||
&clips,
|
||
)
|
||
})
|
||
.collect();
|
||
// A curve rides one system whole when it fits within one, or splits into
|
||
// per-system sub-cubics (de Casteljau) when it spans a break — the same
|
||
// nearest-region / clip-overlap logic strokes use.
|
||
let curve_fates: Vec<CurveFate> = spaced_curves
|
||
.iter()
|
||
.map(|curve| curve_fate(curve, ®ion_spans, ®ion_systems, &clips))
|
||
.collect();
|
||
|
||
// ---- Inter-staff vertical solve + system extents -----------------------
|
||
// Attribute every primitive to its owning staff so the gaps BETWEEN a
|
||
// system's staves can be renegotiated: the constrained stage stacks staves
|
||
// at a fixed pitch, so tightly ledgered or slurred adjacent staves collide.
|
||
//
|
||
// Attribution is a BAND LOOKUP, not a geometric guess. Every primitive —
|
||
// glyph, stroke, curve — declares the vertical band it belongs to, and the
|
||
// projection that emitted it knew the answer: a stem's band is its note's, a
|
||
// slur's is its notes'. Content owned by no staff (a page-margin annotation,
|
||
// a repeat structure spanning several staves) names a non-`Staff` band and
|
||
// is attributed to `None` — it takes no staff shift.
|
||
//
|
||
// Inferring the owner from proximity instead is a trap this code fell into
|
||
// twice. A stem sits under its notehead but shares x columns with the staff
|
||
// above; a slur's endpoints are lifted clear of its own staff by design, so
|
||
// the nearest notehead is routinely on the ADJACENT staff. Neither is
|
||
// recoverable from geometry, and both silently tore primitives off their
|
||
// notes. See DECISIONS.md, "Why attribution is declared, not inferred".
|
||
let band_to_staff: BTreeMap<VerticalBandId, StaffId> = input
|
||
.vertical_bands
|
||
.iter()
|
||
.filter_map(|b| match b.kind {
|
||
VerticalBandKind::Staff(s) => Some((b.id, s)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let staff_of = |band: VerticalBandId| band_to_staff.get(&band).copied();
|
||
let glyph_staff_of: Vec<Option<StaffId>> = input
|
||
.glyphs
|
||
.iter()
|
||
.map(|g| staff_of(g.vertical_band))
|
||
.collect();
|
||
let stroke_staff_of: Vec<Option<StaffId>> = input
|
||
.strokes
|
||
.iter()
|
||
.map(|s| staff_of(s.vertical_band))
|
||
.collect();
|
||
let curve_staff_of: Vec<Option<StaffId>> = input
|
||
.curves
|
||
.iter()
|
||
.map(|c| staff_of(c.vertical_band))
|
||
.collect();
|
||
|
||
// Pass A: system extents (unshifted), and per (system, staff) content
|
||
// y-extents plus the staff-line reference y (for ordering).
|
||
let mut extents: Vec<Extent> = vec![Extent::empty(); systems.len()];
|
||
let mut staff_ext: BTreeMap<(usize, StaffId), (f32, f32)> = BTreeMap::new();
|
||
let mut staff_ref: BTreeMap<(usize, StaffId), f32> = BTreeMap::new();
|
||
let into_staff = |m: &mut BTreeMap<(usize, StaffId), (f32, f32)>,
|
||
s: usize,
|
||
staff: Option<StaffId>,
|
||
lo_y: f32,
|
||
hi_y: f32| {
|
||
if let Some(st) = staff {
|
||
m.entry((s, st))
|
||
.and_modify(|e| {
|
||
e.0 = e.0.min(lo_y);
|
||
e.1 = e.1.max(hi_y);
|
||
})
|
||
.or_insert((lo_y, hi_y));
|
||
}
|
||
};
|
||
for (s, plan) in systems.iter().enumerate() {
|
||
for &i in &plan.slots {
|
||
for &g in ®ion_slots[plan.region][i].members {
|
||
let glyph = &spaced_glyphs[g];
|
||
let (x, y) = (glyph.position.x.0, glyph.position.y.0);
|
||
let (lo_y, hi_y) = (
|
||
y + glyph.bounding_box.bottom.0,
|
||
y + glyph.bounding_box.top.0,
|
||
);
|
||
extents[s].add_x(
|
||
x + glyph.bounding_box.left.0,
|
||
x + glyph.bounding_box.right.0,
|
||
);
|
||
match glyph_staff_of[g] {
|
||
Some(_) => into_staff(&mut staff_ext, s, glyph_staff_of[g], lo_y, hi_y),
|
||
None => extents[s].add_y(lo_y, hi_y),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (si, (fate, spaced)) in fates.iter().zip(spaced_strokes).enumerate() {
|
||
let half = (spaced.thickness.0 * 0.5).max(0.0);
|
||
let staff = stroke_staff_of[si];
|
||
let is_staff_line = matches!(spaced.provenance.source, TypedObjectId::Staff(_));
|
||
let segs: Vec<(usize, Point, Point)> = match fate {
|
||
StrokeFate::Rigid(Some(s)) => vec![(*s, spaced.from, spaced.to)],
|
||
StrokeFate::Rigid(None) => vec![],
|
||
StrokeFate::Split(segments) => segments.clone(),
|
||
};
|
||
for (s, from, to) in segs {
|
||
let (lo_y, hi_y) = (from.y.0.min(to.y.0) - half, from.y.0.max(to.y.0) + half);
|
||
extents[s].add_x(from.x.0 - half, to.x.0 + half);
|
||
match staff {
|
||
Some(_) => into_staff(&mut staff_ext, s, staff, lo_y, hi_y),
|
||
None => extents[s].add_y(lo_y, hi_y),
|
||
}
|
||
if is_staff_line {
|
||
if let Some(st) = staff {
|
||
staff_ref
|
||
.entry((s, st))
|
||
.and_modify(|r| *r = r.max(hi_y))
|
||
.or_insert(hi_y);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (ci, (fate, curve)) in curve_fates.iter().zip(spaced_curves).enumerate() {
|
||
let half = (curve.thickness.0 * 0.5).max(0.0);
|
||
let staff = curve_staff_of[ci];
|
||
let segs: Vec<(usize, [Point; 4])> = match fate {
|
||
CurveFate::Rigid(Some(s)) => vec![(*s, curve.control_points())],
|
||
CurveFate::Rigid(None) => vec![],
|
||
CurveFate::Split(segments) => segments.clone(),
|
||
};
|
||
for (s, cp) in segs {
|
||
for p in cp {
|
||
extents[s].add_x(p.x.0 - half, p.x.0 + half);
|
||
match staff {
|
||
Some(_) => into_staff(&mut staff_ext, s, staff, p.y.0 - half, p.y.0 + half),
|
||
None => extents[s].add_y(p.y.0 - half, p.y.0 + half),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Solve each system's inter-staff gaps: order the staves top-to-bottom by
|
||
// their reference y (staff line, else content mid), keep that order fixed,
|
||
// and shift each staff so its INK CLEARANCE to the one above realizes the
|
||
// gap band's declared height. `staff_shift[(system, staff)]` is the downward
|
||
// shift (subtracted from y); the top staff's is 0.
|
||
//
|
||
// The renegotiation is TWO-SIDED. A pair whose content collides is pushed
|
||
// apart; a pair the constrained stage left slack is pulled together. The
|
||
// fixed `SYSTEM_STAFF_PITCH` that stage stacks by is therefore an initial
|
||
// arrangement, not a floor: the band model is the height model, and the solve
|
||
// realizes it. (Expanding only was the earlier behaviour, and it was
|
||
// measurably wrong — `vertical_density_penalty` scored honest sprawl on every
|
||
// relaxed multi-staff system, because a gap wider than preferred is sprawl
|
||
// exactly as a narrower one is crowding.)
|
||
//
|
||
// The target is the gap band's `preferred_height`, held at or above its
|
||
// `min_height` — the hardest squeeze permitted. Validation already brackets
|
||
// preferred by min and max, so the clamp is belt-and-braces rather than a
|
||
// second policy. The band is the one the REGION DECLARED, not the
|
||
// constructor's default, so the solve and `vertical_density_penalty` — which
|
||
// scores the realized clearance against that same band — read one number.
|
||
//
|
||
// Gap `g` separates the region's staves `g-1` and `g` (see `to_constrained`).
|
||
// Every staff of a region carries content in every system of that region —
|
||
// its staff lines are per-staff strokes, split into each system — so the
|
||
// staves present here are the region's full staff order and the window index
|
||
// is the gap index. A band that somehow does not exist falls back to the
|
||
// constructor's default rather than silently skipping the pair.
|
||
let fallback = VerticalBand::inter_staff_gap(VerticalBandId(0));
|
||
let mut staff_shift: BTreeMap<(usize, StaffId), f32> = BTreeMap::new();
|
||
for (s, plan) in systems.iter().enumerate() {
|
||
let region_layout_id = input.regions[plan.region].provenance.stable_id;
|
||
let target_gap = |gap_index: usize| -> f32 {
|
||
let id = inter_staff_gap_id(region_layout_id, gap_index);
|
||
let band = input
|
||
.vertical_bands
|
||
.iter()
|
||
.find(|band| band.id == id)
|
||
.unwrap_or(&fallback);
|
||
band.preferred_height.0.max(band.min_height.0)
|
||
};
|
||
let mut staves: Vec<(StaffId, (f32, f32))> = staff_ext
|
||
.iter()
|
||
.filter(|((sys, _), _)| *sys == s)
|
||
.map(|((_, st), ext)| (*st, *ext))
|
||
.collect();
|
||
// Top first: larger reference y is higher on the page.
|
||
staves.sort_by(|a, b| {
|
||
let key = |st: StaffId, ext: (f32, f32)| {
|
||
staff_ref
|
||
.get(&(s, st))
|
||
.copied()
|
||
.unwrap_or((ext.0 + ext.1) * 0.5)
|
||
};
|
||
key(b.0, b.1).total_cmp(&key(a.0, a.1)).then(a.0.cmp(&b.0))
|
||
});
|
||
let mut shift = 0.0_f32;
|
||
for (g, w) in staves.windows(2).enumerate() {
|
||
let (upper, (upper_lo, _)) = w[0];
|
||
let (lower, (_, lower_hi)) = w[1];
|
||
staff_shift.insert((s, upper), shift);
|
||
// Both staves move, so solve the recurrence rather than guessing it.
|
||
// With `shift` the upper staff's cumulative shift, the realized
|
||
// clearance is `(upper_lo - shift_upper) - (lower_hi - shift_lower)`,
|
||
// and setting that equal to the target gives
|
||
//
|
||
// shift_lower = shift_upper + target - (upper_lo - lower_hi)
|
||
//
|
||
// — the UNSHIFTED gap. Subtracting `shift_upper` from the gap here
|
||
// and adding it back through `shift +=` would count it twice, which
|
||
// over-separated every pair below the first by exactly the shift
|
||
// above it (invisible on two staves, where that shift is 0). The
|
||
// correction is signed: positive opens a crowded pair, negative
|
||
// closes a slack one, and it accumulates down the stack.
|
||
let gap = upper_lo - lower_hi;
|
||
shift += target_gap(g + 1) - gap;
|
||
staff_shift.insert((s, lower), shift);
|
||
}
|
||
if staves.len() == 1 {
|
||
staff_shift.insert((s, staves[0].0), 0.0);
|
||
}
|
||
}
|
||
|
||
// Fold each staff's SHIFTED content y-extent into its system extent, so the
|
||
// stacking/justification below sees the taller, separated system.
|
||
for ((s, st), (lo, hi)) in &staff_ext {
|
||
let sh = staff_shift.get(&(*s, *st)).copied().unwrap_or(0.0);
|
||
extents[*s].add_y(lo - sh, hi - sh);
|
||
}
|
||
let extents: Vec<Extent> = extents.into_iter().map(Extent::normalized).collect();
|
||
|
||
// ---- Vertical stacking and page assignment ----------------------------
|
||
// The inter-system spacing comes from the vertical-band model's own
|
||
// constructor, so the casting-off gap and the band spring cannot drift.
|
||
let gap = VerticalBand::inter_system_gap(VerticalBandId(0))
|
||
.preferred_height
|
||
.0;
|
||
let content_height = geometry.content_height();
|
||
let bounded = content_height > 0.0;
|
||
let mut placements: Vec<Placement> = Vec::with_capacity(systems.len());
|
||
let mut page_systems: Vec<Vec<usize>> = Vec::new();
|
||
let mut cursor = 0.0_f32;
|
||
let mut page_floor = 0.0_f32;
|
||
for (s, plan) in systems.iter().enumerate() {
|
||
let ext = &extents[s];
|
||
let height = ext.max_y - ext.min_y;
|
||
// Every opened page immediately receives a system, so an overflow test
|
||
// against a non-empty page list never opens an empty page — a system
|
||
// taller than a whole page stays (overfull) on the page it opens.
|
||
let overflow = bounded && !page_systems.is_empty() && cursor - height < page_floor;
|
||
if page_systems.is_empty() || plan.page_forced || overflow {
|
||
let p = page_systems.len();
|
||
cursor = page_top_content(p, geometry);
|
||
page_floor = cursor - content_height.max(0.0);
|
||
page_systems.push(Vec::new());
|
||
}
|
||
let base_dx = geometry.margins.left.0 - ext.min_x;
|
||
let dy = cursor - ext.max_y;
|
||
placements.push(justify_system(
|
||
plan,
|
||
ext,
|
||
base_dx,
|
||
dy,
|
||
®ion_slots,
|
||
®ion_systems,
|
||
width_limit,
|
||
));
|
||
page_systems
|
||
.last_mut()
|
||
.expect("a page was opened above")
|
||
.push(s);
|
||
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 {
|
||
if let Some(&i) = plan.slots.first() {
|
||
system_start_slots.insert(region_slots[plan.region][i].id);
|
||
}
|
||
}
|
||
let mut page_start_slots = BTreeSet::new();
|
||
let mut decisions = Vec::new();
|
||
for (p, on_page) in page_systems.iter().enumerate() {
|
||
for (j, &s) in on_page.iter().enumerate() {
|
||
let plan = &systems[s];
|
||
let starts_page = j == 0;
|
||
if starts_page {
|
||
if let Some(&i) = plan.slots.first() {
|
||
page_start_slots.insert(region_slots[plan.region][i].id);
|
||
}
|
||
}
|
||
let region_source = input.regions[plan.region].provenance.source;
|
||
if let Some(boundary) = plan.boundary {
|
||
// A chosen intra-region break: a page decision when the system
|
||
// actually opens a page, a system decision otherwise.
|
||
decisions.push(EngravingDecision::with_source(
|
||
break_target(region_source, boundary.slot),
|
||
if starts_page {
|
||
EngravingDecisionKind::PageBreak
|
||
} else {
|
||
EngravingDecisionKind::SystemBreak
|
||
},
|
||
boundary.source,
|
||
));
|
||
} else if starts_page && p > 0 {
|
||
// A later page opening at a region's first system: the page
|
||
// start is itself an engraved decision (forced or overflow).
|
||
if let Some(&i) = plan.slots.first() {
|
||
decisions.push(EngravingDecision::with_source(
|
||
break_target(region_source, region_slots[plan.region][i].id),
|
||
EngravingDecisionKind::PageBreak,
|
||
plan.page_source,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
decisions.extend(skipped);
|
||
|
||
// ---- Bake the world frame ----------------------------------------------
|
||
// A primitive's additional downward shift from the inter-staff solve.
|
||
let staff_dy = |s: usize, staff: Option<StaffId>| -> f32 {
|
||
staff
|
||
.and_then(|st| staff_shift.get(&(s, st)))
|
||
.copied()
|
||
.unwrap_or(0.0)
|
||
};
|
||
// Computed once, alongside the positioning it also drives (W1 pin 7): the
|
||
// quality-metric census consumes this published vector rather than
|
||
// re-deriving the same attribution from `system_of_slot` itself.
|
||
let (glyphs, glyph_system): (Vec<ResolvedGlyph>, Vec<Option<usize>>) = spaced_glyphs
|
||
.iter()
|
||
.zip(&input.glyphs)
|
||
.enumerate()
|
||
.map(|(gi, (spaced, glyph))| {
|
||
let system = system_of_slot.get(&glyph.horizontal_slot).copied();
|
||
let (dx, dy) = match system {
|
||
Some(s) => {
|
||
// Slot-relative: every member of a slot translates by the
|
||
// map at the slot's source, so intra-slot offsets survive.
|
||
let sx = slot_source_x
|
||
.get(&glyph.horizontal_slot)
|
||
.copied()
|
||
.unwrap_or(spaced.position.x.0);
|
||
(
|
||
placements[s].slot_dx(sx),
|
||
placements[s].dy - staff_dy(s, glyph_staff_of[gi]),
|
||
)
|
||
}
|
||
None => (0.0, 0.0),
|
||
};
|
||
let resolved = ResolvedGlyph {
|
||
position: Point::new(spaced.position.x.0 + dx, spaced.position.y.0 + dy),
|
||
..spaced.clone()
|
||
};
|
||
(resolved, system)
|
||
})
|
||
.unzip();
|
||
|
||
// Per-system staff-line marks, for the resolved staff records below.
|
||
let mut staff_marks: BTreeMap<(usize, StaffId), StaffAgg> = BTreeMap::new();
|
||
let mut strokes: Vec<Stroke> = Vec::with_capacity(spaced_strokes.len());
|
||
let mut continuations: Vec<Stroke> = Vec::new();
|
||
// The system each baked stroke landed in, parallel to `strokes` (a quality
|
||
// metric measures a system's realized per-staff content extents, and a
|
||
// stroke carries no spring slot to look one up with).
|
||
let mut stroke_system: Vec<Option<usize>> = Vec::with_capacity(spaced_strokes.len());
|
||
let mut continuation_system: Vec<Option<usize>> = Vec::new();
|
||
for (si, ((source, spaced), fate)) in input
|
||
.strokes
|
||
.iter()
|
||
.zip(spaced_strokes)
|
||
.zip(&fates)
|
||
.enumerate()
|
||
{
|
||
match fate {
|
||
StrokeFate::Rigid(sys) => {
|
||
let stroke = match sys {
|
||
Some(s) => place_stroke(
|
||
source,
|
||
spaced,
|
||
placements[*s].sunk(staff_dy(*s, stroke_staff_of[si])),
|
||
&slot_source_x,
|
||
&input.glyphs,
|
||
),
|
||
None => spaced.clone(),
|
||
};
|
||
if let (Some(s), TypedObjectId::Staff(staff)) = (sys, spaced.provenance.source) {
|
||
mark_staff(&mut staff_marks, *s, staff, &stroke);
|
||
}
|
||
strokes.push(stroke);
|
||
stroke_system.push(*sys);
|
||
}
|
||
StrokeFate::Split(segments) => {
|
||
for (k, (s, from, to)) in segments.iter().enumerate() {
|
||
// A split stroke spans systems — a staff line or volta
|
||
// bracket — so each segment stretches with its system.
|
||
let p = placements[*s].sunk(staff_dy(*s, stroke_staff_of[si]));
|
||
let provenance = if k == 0 {
|
||
// The first segment carries the original stroke's exact
|
||
// provenance: the object survives, re-shaped.
|
||
spaced.provenance.clone()
|
||
} else {
|
||
Provenance::synthesized(
|
||
spaced.provenance.source,
|
||
SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS),
|
||
continuation_instance_key(spaced.provenance.stable_id, k as u32),
|
||
spaced.provenance.dependencies.clone(),
|
||
)
|
||
};
|
||
let stroke = Stroke {
|
||
provenance,
|
||
from: Point::new(p.x(from.x.0), from.y.0 + p.dy),
|
||
to: Point::new(p.x(to.x.0), to.y.0 + p.dy),
|
||
thickness: spaced.thickness,
|
||
layer: spaced.layer,
|
||
style: spaced.style,
|
||
vertical_band: spaced.vertical_band,
|
||
};
|
||
if let TypedObjectId::Staff(staff) = spaced.provenance.source {
|
||
mark_staff(&mut staff_marks, *s, staff, &stroke);
|
||
}
|
||
if k == 0 {
|
||
strokes.push(stroke);
|
||
stroke_system.push(Some(*s));
|
||
} else {
|
||
continuations.push(stroke);
|
||
continuation_system.push(Some(*s));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
strokes.extend(continuations);
|
||
stroke_system.extend(continuation_system);
|
||
|
||
// Curves: a curve that fits in one system is translated whole by that
|
||
// system's placement (or left in the spaced frame if no region claimed it).
|
||
// A curve that spans a system break is split into per-system sub-cubics: the
|
||
// first segment carries the slur's exact provenance (the object survives,
|
||
// re-shaped — the round-trip source surjection recovers it), later segments
|
||
// are synthesized continuations under `SYSTEM_CONTINUATION_SYNTHESIS`, as a
|
||
// split stroke's are.
|
||
let mut curves: Vec<Curve> = Vec::with_capacity(spaced_curves.len());
|
||
let mut curve_continuations: Vec<Curve> = Vec::new();
|
||
let mut curve_system: Vec<Option<usize>> = Vec::with_capacity(spaced_curves.len());
|
||
let mut curve_continuation_system: Vec<Option<usize>> = Vec::new();
|
||
for (ci, (curve, fate)) in spaced_curves.iter().zip(&curve_fates).enumerate() {
|
||
let curve_staff = curve_staff_of[ci];
|
||
// A slur has no intra-slot structure, so its control points map straight
|
||
// through the affine: the endpoints follow their anchor notes (which sit
|
||
// at slot sources) and the arc stretches horizontally with the span.
|
||
let shift =
|
||
|cp: [Point; 4], p: Placement| cp.map(|pt| Point::new(p.x(pt.x.0), pt.y.0 + p.dy));
|
||
match fate {
|
||
CurveFate::Rigid(system) => {
|
||
let p = system
|
||
.map(|s| placements[s].sunk(staff_dy(s, curve_staff)))
|
||
.unwrap_or(Placement::rigid(0.0, 0.0));
|
||
let [p0, p1, p2, p3] = shift(curve.control_points(), p);
|
||
curves.push(Curve {
|
||
p0,
|
||
p1,
|
||
p2,
|
||
p3,
|
||
..curve.clone()
|
||
});
|
||
curve_system.push(*system);
|
||
}
|
||
CurveFate::Split(segments) => {
|
||
for (k, (s, cp)) in segments.iter().enumerate() {
|
||
let [p0, p1, p2, p3] =
|
||
shift(*cp, placements[*s].sunk(staff_dy(*s, curve_staff)));
|
||
let provenance = if k == 0 {
|
||
curve.provenance.clone()
|
||
} else {
|
||
Provenance::synthesized(
|
||
curve.provenance.source,
|
||
SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS),
|
||
continuation_instance_key(curve.provenance.stable_id, k as u32),
|
||
curve.provenance.dependencies.clone(),
|
||
)
|
||
};
|
||
let segment = Curve {
|
||
provenance,
|
||
p0,
|
||
p1,
|
||
p2,
|
||
p3,
|
||
thickness: curve.thickness,
|
||
layer: curve.layer,
|
||
style: curve.style,
|
||
vertical_band: curve.vertical_band,
|
||
line: curve.line,
|
||
};
|
||
if k == 0 {
|
||
curves.push(segment);
|
||
curve_system.push(Some(*s));
|
||
} else {
|
||
curve_continuations.push(segment);
|
||
curve_continuation_system.push(Some(*s));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
curves.extend(curve_continuations);
|
||
curve_system.extend(curve_continuation_system);
|
||
|
||
// ---- Per-system primitive ownership (W1) -------------------------------
|
||
// The partition already exists in `glyph_system`/`stroke_system`/
|
||
// `curve_system` above; this just stops discarding it. For each flat
|
||
// array, every index lands in exactly one system's list or in `unowned`
|
||
// (pin 4: a total, disjoint partition, tested in this module below).
|
||
let mut owned: Vec<PrimitiveIndices> = (0..systems.len())
|
||
.map(|_| PrimitiveIndices::default())
|
||
.collect();
|
||
let mut unowned = PrimitiveIndices::default();
|
||
for (i, system) in glyph_system.iter().enumerate() {
|
||
match system {
|
||
Some(s) => owned[*s].glyphs.push(i as u32),
|
||
None => unowned.glyphs.push(i as u32),
|
||
}
|
||
}
|
||
for (i, system) in stroke_system.iter().enumerate() {
|
||
match system {
|
||
Some(s) => owned[*s].strokes.push(i as u32),
|
||
None => unowned.strokes.push(i as u32),
|
||
}
|
||
}
|
||
for (i, system) in curve_system.iter().enumerate() {
|
||
match system {
|
||
Some(s) => owned[*s].curves.push(i as u32),
|
||
None => unowned.curves.push(i as u32),
|
||
}
|
||
}
|
||
|
||
// ---- The resolved page tree ---------------------------------------------
|
||
let resolved_systems: Vec<ResolvedSystem> = systems
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(s, plan)| {
|
||
build_system(
|
||
s,
|
||
plan,
|
||
input,
|
||
®ion_slots,
|
||
&extents,
|
||
&placements,
|
||
&staff_marks,
|
||
owned[s].clone(),
|
||
)
|
||
})
|
||
.collect();
|
||
let mut resolved_systems: Vec<Option<ResolvedSystem>> =
|
||
resolved_systems.into_iter().map(Some).collect();
|
||
let pages: Vec<ResolvedPage> = page_systems
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(p, on_page)| {
|
||
let first_region = systems[on_page[0]].region;
|
||
let region_provenance = &input.regions[first_region].provenance;
|
||
let provenance = if p == 0 {
|
||
// Page 1 carries the first region's own provenance, as the
|
||
// degenerate single-page output always did.
|
||
input.regions[0].provenance.clone()
|
||
} else {
|
||
Provenance::synthesized(
|
||
region_provenance.source,
|
||
SynthesisKind::EngravedBreak,
|
||
SynthesisInstanceKey((KEY_NS_PAGE << 64) | (p as u128 + 1)),
|
||
region_provenance.dependencies.clone(),
|
||
)
|
||
};
|
||
ResolvedPage {
|
||
provenance,
|
||
number: p as u32 + 1,
|
||
size: geometry.size,
|
||
margins: geometry.margins,
|
||
systems: on_page
|
||
.iter()
|
||
.map(|&s| resolved_systems[s].take().expect("each system on one page"))
|
||
.collect(),
|
||
// Nothing in the Minimal pipeline is a page-level free object
|
||
// (region content is all system-bound); left empty rather than
|
||
// fabricated.
|
||
free_objects: Vec::new(),
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
CastLayout {
|
||
glyphs,
|
||
strokes,
|
||
curves,
|
||
pages,
|
||
decisions,
|
||
system_start_slots,
|
||
page_start_slots,
|
||
glyph_system,
|
||
stroke_system,
|
||
curve_system,
|
||
region_of_system: systems.iter().map(|plan| plan.region).collect(),
|
||
unowned,
|
||
}
|
||
}
|
||
|
||
/// The world-frame y of page `p`'s content top: pages stack downward from the
|
||
/// origin, each a full page height plus [`INTER_PAGE_GAP`] below the previous.
|
||
fn page_top_content(p: usize, geometry: &PageGeometry) -> f32 {
|
||
-(p as f32) * (geometry.size.height.0 + INTER_PAGE_GAP) - geometry.margins.top.0
|
||
}
|
||
|
||
/// Optimal automatic system breaks for one region: a badness-minimizing
|
||
/// (Knuth–Plass-style) partition of the region's measures into systems,
|
||
/// replacing greedy first-fit. Returns the slot ids at which an AUTOMATIC break
|
||
/// opens a system — the break REQUIREMENTS (hard / soft / page, which bound the
|
||
/// DP's segments) are honoured by [`walk_region`] itself, and never appear here.
|
||
///
|
||
/// **Objective.** Minimize the sum over ALL systems of the squared normalized
|
||
/// underfill `((width_limit − w) / width_limit)²`. Squaring evens the systems
|
||
/// (a lopsided split costs more than a balanced one), and including the *final*
|
||
/// system in the sum is what subsumes the old tail-only widow rebalance — the
|
||
/// optimizer will not leave a narrow final stub if a more even partition is
|
||
/// cheaper. It is the additive, DP-tractable analog of the catalog's
|
||
/// break/imbalance distribution cost (`distribution_cost`, now retired): both
|
||
/// reward filled, even systems. A system may not exceed the content width unless
|
||
/// it is a **single unsplittable measure** (an overfull lone measure, which the
|
||
/// greedy pass also emitted). `Minimal` still makes no optimality *claim*; this
|
||
/// is a deterministic global heuristic, an honest improvement on first-fit.
|
||
///
|
||
/// **Determinism.** A pure function of the slot extents and requirements; the
|
||
/// DP minimizes the lexicographic `(cost, system_count)` (fewer systems breaks
|
||
/// ties, so ties favour fewer pages), and among equal `(cost, count)` the
|
||
/// earliest-considered predecessor (the largest final system) wins.
|
||
fn optimal_breaks(
|
||
slots: &[SlotInfo],
|
||
reqs: &BTreeMap<SpringSlotId, Vec<BreakReq>>,
|
||
width_limit: f32,
|
||
) -> BTreeSet<SpringSlotId> {
|
||
let mut automatic = BTreeSet::new();
|
||
if !width_limit.is_finite() || width_limit <= 0.0 || slots.is_empty() {
|
||
return automatic; // unbounded width: nothing wraps
|
||
}
|
||
let breakable = |slot: &SlotInfo| slot.barline && !slot.final_barline;
|
||
// Measure-boundary positions in slot-index space: region start, each
|
||
// breakable barline, region end. `forced[k]` marks a boundary carrying a
|
||
// break requirement (the DP may not span it). The region end is a boundary.
|
||
let mut pts: Vec<usize> = vec![0];
|
||
let mut forced: Vec<bool> = vec![false];
|
||
for (i, slot) in slots.iter().enumerate() {
|
||
if i > 0 && breakable(slot) {
|
||
pts.push(i);
|
||
forced.push(reqs.contains_key(&slot.id));
|
||
}
|
||
}
|
||
pts.push(slots.len());
|
||
forced.push(true);
|
||
let n = pts.len(); // n - 1 measures between the n boundaries
|
||
|
||
// A system spanning boundaries [a, b): its ink extent over slots
|
||
// `[pts[a] .. pts[b])`.
|
||
let width = |a: usize, b: usize| -> f32 {
|
||
let range = &slots[pts[a]..pts[b]];
|
||
let lo = range.iter().map(|s| s.lo).fold(f32::INFINITY, f32::min);
|
||
let hi = range.iter().map(|s| s.hi).fold(f32::NEG_INFINITY, f32::max);
|
||
(hi - lo).max(0.0)
|
||
};
|
||
|
||
// dp[b] = the min `(cost, system_count)` to partition measures [0, b).
|
||
let mut dp: Vec<(f64, usize)> = vec![(f64::INFINITY, usize::MAX); n];
|
||
let mut from: Vec<usize> = vec![0; n];
|
||
dp[0] = (0.0, 0);
|
||
for b in 1..n {
|
||
for a in 0..b {
|
||
// A system may not skip a forced break at an interior boundary.
|
||
if (a + 1..b).any(|k| forced[k]) {
|
||
continue;
|
||
}
|
||
let (prev_cost, prev_count) = dp[a];
|
||
if !prev_cost.is_finite() {
|
||
continue;
|
||
}
|
||
let w = width(a, b);
|
||
let bad = if w <= width_limit {
|
||
let u = f64::from((width_limit - w) / width_limit);
|
||
u * u
|
||
} else if b - a == 1 {
|
||
0.0 // a lone measure wider than the page: unavoidable, not charged
|
||
} else {
|
||
continue; // overfull and splittable: not a valid system
|
||
};
|
||
let cand = (prev_cost + bad, prev_count + 1);
|
||
if cand < dp[b] {
|
||
dp[b] = cand;
|
||
from[b] = a;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reconstruct the partition; its non-forced boundaries are the automatic
|
||
// breaks `walk_region` adds to its requirement-driven ones.
|
||
if dp[n - 1].0.is_finite() {
|
||
let mut b = n - 1;
|
||
while b > 0 {
|
||
let a = from[b];
|
||
if a > 0 && !forced[a] {
|
||
automatic.insert(slots[pts[a]].id);
|
||
}
|
||
b = a;
|
||
}
|
||
}
|
||
automatic
|
||
}
|
||
|
||
/// Walks one region's slots, opening a system at each break requirement and at
|
||
/// each optimal automatic break (`optimal_breaks`).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn walk_region(
|
||
region: usize,
|
||
slots: &[SlotInfo],
|
||
reqs: &BTreeMap<SpringSlotId, Vec<BreakReq>>,
|
||
origins: &BTreeMap<(u128, bool), EngravingOverrideId>,
|
||
region_source: TypedObjectId,
|
||
width_limit: f32,
|
||
systems: &mut Vec<SystemPlan>,
|
||
skipped: &mut Vec<EngravingDecision>,
|
||
) {
|
||
// The optimal automatic breaks (a global badness-minimizing partition,
|
||
// bounded by the break requirements); the walk opens a system at each.
|
||
let automatic = optimal_breaks(slots, reqs, width_limit);
|
||
|
||
// Overflow safety net. A lead-only (note-less) run can defer a *planned*
|
||
// break past its barline — the DP treats a requirement, or its own chosen
|
||
// automatic break, as a real system start, but the walk skips it when the
|
||
// closing system carries no musical content (the soft-break exception, and
|
||
// the `has_note` guard on the automatic break below). The DP optimizes each
|
||
// requirement-bounded segment independently and cannot foresee that skip, so
|
||
// without a net the following DP-filled system would absorb the furniture
|
||
// measures and overflow. `chunk_hi[i]` — the rightmost content edge of the
|
||
// measure beginning at slot `i` — lets the walk still break before a measure
|
||
// that would overflow the content width, exactly as first-fit did. In the
|
||
// common (content-full) case the DP's break fires first, so the net never
|
||
// triggers and the geometry is the optimizer's.
|
||
let breakable = |slot: &SlotInfo| slot.barline && !slot.final_barline;
|
||
let mut chunk_hi = vec![f32::NEG_INFINITY; slots.len()];
|
||
for i in (0..slots.len()).rev() {
|
||
let next = if i + 1 < slots.len() && !breakable(&slots[i + 1]) {
|
||
chunk_hi[i + 1]
|
||
} else {
|
||
f32::NEG_INFINITY
|
||
};
|
||
chunk_hi[i] = slots[i].hi.max(next);
|
||
}
|
||
|
||
let mut local = 0usize;
|
||
let mut current: Vec<usize> = Vec::new();
|
||
let mut has_note = false;
|
||
let mut current_lo = f32::INFINITY;
|
||
let mut open_boundary: Option<Boundary> = None;
|
||
let mut open_page_forced = false;
|
||
let mut open_page_source = DecisionSource::Automatic;
|
||
|
||
for (i, slot) in slots.iter().enumerate() {
|
||
let slot_reqs = reqs.get(&slot.id).map(Vec::as_slice).unwrap_or(&[]);
|
||
if current.is_empty() {
|
||
// The region's first slot is already at a system boundary, so a
|
||
// system break here is trivially honoured; a page break still
|
||
// forces this (first) system onto a fresh page.
|
||
for req in slot_reqs {
|
||
if req.page {
|
||
open_page_forced = true;
|
||
if open_page_source == DecisionSource::Automatic {
|
||
open_page_source = origin_source(origins, slot.id, true);
|
||
}
|
||
}
|
||
}
|
||
current.push(i);
|
||
has_note = slot.note;
|
||
current_lo = slot.lo;
|
||
continue;
|
||
}
|
||
let mut break_here = false;
|
||
let mut page_here = false;
|
||
let mut source = DecisionSource::Automatic;
|
||
for req in slot_reqs {
|
||
if !req.hard && !has_note {
|
||
// The documented exceptional path: honouring this *soft* break
|
||
// would close a system with no musical content (e.g. a bare
|
||
// clef/barline line). It is skipped, and the unhonoured
|
||
// override is recorded as an IR-stage-overridden decision
|
||
// (never silently dropped).
|
||
skipped.push(EngravingDecision::with_source(
|
||
break_target(region_source, slot.id),
|
||
if req.page {
|
||
EngravingDecisionKind::PageBreak
|
||
} else {
|
||
EngravingDecisionKind::SystemBreak
|
||
},
|
||
DecisionSource::IrOverride,
|
||
));
|
||
continue;
|
||
}
|
||
break_here = true;
|
||
page_here |= req.page;
|
||
if !matches!(source, DecisionSource::UserOverride(_)) {
|
||
source = origin_source(origins, slot.id, req.page);
|
||
}
|
||
}
|
||
// Optimal casting-off: open a system at a chosen automatic break — or,
|
||
// as the overflow net, before a measure that would overflow the content
|
||
// width — as long as the closing system carries musical content (a
|
||
// lead-only system is never torn off, matching the requirement rule).
|
||
if !break_here
|
||
&& has_note
|
||
&& (automatic.contains(&slot.id)
|
||
|| (breakable(slot) && chunk_hi[i] - current_lo > width_limit))
|
||
{
|
||
break_here = true;
|
||
}
|
||
if break_here {
|
||
systems.push(SystemPlan {
|
||
region,
|
||
local,
|
||
slots: std::mem::take(&mut current),
|
||
boundary: open_boundary.take(),
|
||
page_forced: open_page_forced,
|
||
page_source: open_page_source,
|
||
});
|
||
local += 1;
|
||
open_boundary = Some(Boundary {
|
||
slot: slot.id,
|
||
source,
|
||
});
|
||
open_page_forced = page_here;
|
||
open_page_source = if page_here {
|
||
source
|
||
} else {
|
||
DecisionSource::Automatic
|
||
};
|
||
current.push(i);
|
||
has_note = slot.note;
|
||
current_lo = slot.lo;
|
||
} else {
|
||
current.push(i);
|
||
has_note |= slot.note;
|
||
current_lo = current_lo.min(slot.lo);
|
||
}
|
||
}
|
||
// The region's last system — or, for a region with no slots at all, its
|
||
// single (empty) system, preserving one-system-per-region as the minimum.
|
||
systems.push(SystemPlan {
|
||
region,
|
||
local,
|
||
slots: current,
|
||
boundary: open_boundary,
|
||
page_forced: open_page_forced,
|
||
page_source: open_page_source,
|
||
});
|
||
}
|
||
|
||
/// Decides how a stroke rides the cast systems (see [`StrokeFate`]).
|
||
fn stroke_fate(
|
||
stroke: &Stroke,
|
||
spaced: &Stroke,
|
||
input: &ConstrainedLayoutIR,
|
||
system_of_slot: &BTreeMap<SpringSlotId, usize>,
|
||
region_spans: &[Option<(f32, f32)>],
|
||
region_systems: &[Vec<usize>],
|
||
clips: &[(f32, f32)],
|
||
) -> StrokeFate {
|
||
// A rigid-width stroke (a ledger line) rides its owning glyph's system, so
|
||
// it translates by exactly the same delta as its notehead.
|
||
if is_rigid_width_stroke(stroke) {
|
||
if let Some(glyph) = owning_glyph(stroke, &input.glyphs) {
|
||
return StrokeFate::Rigid(system_of_slot.get(&glyph.horizontal_slot).copied());
|
||
}
|
||
}
|
||
let lo = spaced.from.x.0.min(spaced.to.x.0);
|
||
let hi = spaced.from.x.0.max(spaced.to.x.0);
|
||
// The owning region: the one whose slot span is nearest (ties to the first).
|
||
let mut best: Option<(usize, f32)> = None;
|
||
for (r, span) in region_spans.iter().enumerate() {
|
||
let Some((rlo, rhi)) = span else { continue };
|
||
let distance = if hi < *rlo {
|
||
rlo - hi
|
||
} else if lo > *rhi {
|
||
lo - rhi
|
||
} else {
|
||
0.0
|
||
};
|
||
if best.is_none_or(|(_, d)| distance < d) {
|
||
best = Some((r, distance));
|
||
}
|
||
}
|
||
let Some((region, _)) = best else {
|
||
return StrokeFate::Rigid(None);
|
||
};
|
||
// The systems of that region the stroke's span overlaps.
|
||
let overlapped: Vec<usize> = region_systems[region]
|
||
.iter()
|
||
.copied()
|
||
.filter(|&s| lo <= clips[s].1 && hi >= clips[s].0)
|
||
.collect();
|
||
match overlapped.len() {
|
||
0 => {
|
||
// In the sliver between two systems' content: nearest system.
|
||
let nearest = region_systems[region]
|
||
.iter()
|
||
.copied()
|
||
.min_by(|&a, &b| {
|
||
let da = interval_distance(lo, hi, clips[a]);
|
||
let db = interval_distance(lo, hi, clips[b]);
|
||
da.total_cmp(&db).then(a.cmp(&b))
|
||
})
|
||
.expect("every region has at least one system");
|
||
StrokeFate::Rigid(Some(nearest))
|
||
}
|
||
1 => StrokeFate::Rigid(Some(overlapped[0])),
|
||
_ => {
|
||
// A system-spanning stroke (a staff line): one segment per system,
|
||
// cut at the systems' content edges, y interpolated along the
|
||
// stroke so a (hypothetical) sloped spanner splits consistently.
|
||
let (x0, y0) = (spaced.from.x.0, spaced.from.y.0);
|
||
let (x1, y1) = (spaced.to.x.0, spaced.to.y.0);
|
||
let point_at = |x: f32| -> Point {
|
||
if (x1 - x0).abs() < f32::EPSILON {
|
||
Point::new(x, y0)
|
||
} else {
|
||
let t = (x - x0) / (x1 - x0);
|
||
Point::new(x, y0 + t * (y1 - y0))
|
||
}
|
||
};
|
||
let segments = overlapped
|
||
.into_iter()
|
||
.map(|s| {
|
||
let a = lo.max(clips[s].0);
|
||
let b = hi.min(clips[s].1);
|
||
(s, point_at(a), point_at(b))
|
||
})
|
||
.collect();
|
||
StrokeFate::Split(segments)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A curve's casting fate. A curve overlapping ONE system rides it whole
|
||
/// (`Rigid(Some(s))`) — the nearest region's system whose clip interval
|
||
/// contains the curve's **start** control point, else that region's nearest
|
||
/// system; a curve no region claims is `Rigid(None)` (left in the spaced frame,
|
||
/// on no page). A curve spanning MULTIPLE systems is `Split` into per-system
|
||
/// sub-curves by de Casteljau subdivision at the parameters where its
|
||
/// x-monotonic path crosses each system's content-clip edges (a non-monotonic
|
||
/// curve — not produced by the engraver — cannot be honestly split and rides
|
||
/// its start system whole).
|
||
fn curve_fate(
|
||
curve: &Curve,
|
||
region_spans: &[Option<(f32, f32)>],
|
||
region_systems: &[Vec<usize>],
|
||
clips: &[(f32, f32)],
|
||
) -> CurveFate {
|
||
let cp = curve.control_points();
|
||
let xs = cp.map(|p| p.x.0);
|
||
let lo = xs.iter().copied().fold(f32::INFINITY, f32::min);
|
||
let hi = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||
// The owning region: the one whose slot span is nearest (ties to the first).
|
||
let mut best: Option<(usize, f32)> = None;
|
||
for (r, span) in region_spans.iter().enumerate() {
|
||
let Some((rlo, rhi)) = span else { continue };
|
||
let distance = interval_distance(lo, hi, (*rlo, *rhi));
|
||
if best.is_none_or(|(_, d)| distance < d) {
|
||
best = Some((r, distance));
|
||
}
|
||
}
|
||
let Some((region, _)) = best else {
|
||
return CurveFate::Rigid(None);
|
||
};
|
||
// The systems of that region the curve's x-span overlaps.
|
||
let overlapped: Vec<usize> = region_systems[region]
|
||
.iter()
|
||
.copied()
|
||
.filter(|&s| lo <= clips[s].1 && hi >= clips[s].0)
|
||
.collect();
|
||
// The start control point pins which single system the curve rides when it
|
||
// does not span a break.
|
||
let start_system = || {
|
||
region_systems[region].iter().copied().min_by(|&a, &b| {
|
||
let da = interval_distance(cp[0].x.0, cp[0].x.0, clips[a]);
|
||
let db = interval_distance(cp[0].x.0, cp[0].x.0, clips[b]);
|
||
da.total_cmp(&db).then(a.cmp(&b))
|
||
})
|
||
};
|
||
match overlapped.len() {
|
||
0 => CurveFate::Rigid(start_system()),
|
||
1 => CurveFate::Rigid(Some(overlapped[0])),
|
||
_ => {
|
||
// A curve spanning a system break is split into per-system
|
||
// sub-curves by de Casteljau subdivision at the parameters where it
|
||
// crosses each system's content clip edges. This needs an
|
||
// x-monotonic curve to invert `x -> t`; a slur is (its control
|
||
// points are x-ascending by construction). A non-monotonic curve
|
||
// (not produced by the engraver) cannot be honestly split, so it
|
||
// rides its start system whole.
|
||
if !is_x_monotonic(cp) {
|
||
return CurveFate::Rigid(start_system());
|
||
}
|
||
let segments = overlapped
|
||
.into_iter()
|
||
.map(|s| {
|
||
let (clo, chi) = clips[s];
|
||
let x0 = clo.max(cp[0].x.0);
|
||
let x1 = chi.min(cp[3].x.0);
|
||
let t0 = param_at_x(cp, x0);
|
||
let t1 = param_at_x(cp, x1);
|
||
(s, sub_cubic(cp, t0, t1))
|
||
})
|
||
.collect();
|
||
CurveFate::Split(segments)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether a cubic's control points ascend in x (so `x -> t` is invertible by
|
||
/// bisection), with a non-trivial x-span.
|
||
fn is_x_monotonic(cp: [Point; 4]) -> bool {
|
||
cp[0].x.0 <= cp[1].x.0
|
||
&& cp[1].x.0 <= cp[2].x.0
|
||
&& cp[2].x.0 <= cp[3].x.0
|
||
&& cp[3].x.0 > cp[0].x.0
|
||
}
|
||
|
||
/// The parameter `t` at which an x-monotonic cubic's x-coordinate equals `x`
|
||
/// (bisection; `x` is clamped to the curve's x-range by the caller).
|
||
fn param_at_x(cp: [Point; 4], x: f32) -> f32 {
|
||
let cubic_x = |t: f32| {
|
||
let u = 1.0 - t;
|
||
u * u * u * cp[0].x.0
|
||
+ 3.0 * u * u * t * cp[1].x.0
|
||
+ 3.0 * u * t * t * cp[2].x.0
|
||
+ t * t * t * cp[3].x.0
|
||
};
|
||
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
|
||
for _ in 0..40 {
|
||
let mid = 0.5 * (lo + hi);
|
||
if cubic_x(mid) < x {
|
||
lo = mid;
|
||
} else {
|
||
hi = mid;
|
||
}
|
||
}
|
||
0.5 * (lo + hi)
|
||
}
|
||
|
||
/// Linear interpolation between two points.
|
||
fn lerp_point(a: Point, b: Point, t: f32) -> Point {
|
||
Point::new(a.x.0 + (b.x.0 - a.x.0) * t, a.y.0 + (b.y.0 - a.y.0) * t)
|
||
}
|
||
|
||
/// de Casteljau split of a cubic at `t`: `(left [0, t], right [t, 1])`.
|
||
fn split_cubic(cp: [Point; 4], t: f32) -> ([Point; 4], [Point; 4]) {
|
||
let a = lerp_point(cp[0], cp[1], t);
|
||
let b = lerp_point(cp[1], cp[2], t);
|
||
let c = lerp_point(cp[2], cp[3], t);
|
||
let d = lerp_point(a, b, t);
|
||
let e = lerp_point(b, c, t);
|
||
let f = lerp_point(d, e, t);
|
||
([cp[0], a, d, f], [f, e, c, cp[3]])
|
||
}
|
||
|
||
/// The sub-cubic of `cp` over the parameter range `[t0, t1]` (two de Casteljau
|
||
/// splits: take `[0, t1]`, then within it the `[t0/t1, 1]` tail).
|
||
fn sub_cubic(cp: [Point; 4], t0: f32, t1: f32) -> [Point; 4] {
|
||
let (left, _) = split_cubic(cp, t1);
|
||
let tt = if t1 > f32::EPSILON {
|
||
(t0 / t1).clamp(0.0, 1.0)
|
||
} else {
|
||
0.0
|
||
};
|
||
let (_, right) = split_cubic(left, tt);
|
||
right
|
||
}
|
||
|
||
/// Distance from the span `[lo, hi]` to a clip interval (0 when they overlap).
|
||
fn interval_distance(lo: f32, hi: f32, clip: (f32, f32)) -> f32 {
|
||
if hi < clip.0 {
|
||
clip.0 - hi
|
||
} else if lo > clip.1 {
|
||
lo - clip.1
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
|
||
/// A stroke translated rigidly by `(dx, dy)`.
|
||
fn translated(stroke: &Stroke, dx: f32, dy: f32) -> Stroke {
|
||
Stroke {
|
||
provenance: stroke.provenance.clone(),
|
||
from: Point::new(stroke.from.x.0 + dx, stroke.from.y.0 + dy),
|
||
to: Point::new(stroke.to.x.0 + dx, stroke.to.y.0 + dy),
|
||
thickness: stroke.thickness,
|
||
layer: stroke.layer,
|
||
style: stroke.style,
|
||
vertical_band: stroke.vertical_band,
|
||
}
|
||
}
|
||
|
||
/// A system's placement: rigid (translated to the left margin) unless the
|
||
/// system JUSTIFIES — a non-final system of its region, narrower than the
|
||
/// content width, with a positive slot span — in which case the horizontal
|
||
/// slack is spread linearly so the system's ink fills the content width (its
|
||
/// leftmost ink at the left margin, its rightmost at the right margin). A
|
||
/// region's last system stays ragged-right, as engraving convention wants; a
|
||
/// system already at or over width is not compressed into overlap.
|
||
fn justify_system(
|
||
plan: &SystemPlan,
|
||
ext: &Extent,
|
||
base_dx: f32,
|
||
dy: f32,
|
||
region_slots: &[Vec<SlotInfo>],
|
||
region_systems: &[Vec<usize>],
|
||
width_limit: f32,
|
||
) -> Placement {
|
||
let is_last = plan.local + 1 >= region_systems[plan.region].len();
|
||
if is_last || !width_limit.is_finite() {
|
||
return Placement::rigid(base_dx, dy);
|
||
}
|
||
let slots = ®ion_slots[plan.region];
|
||
let (Some(&first), Some(&last)) = (plan.slots.first(), plan.slots.last()) else {
|
||
return Placement::rigid(base_dx, dy);
|
||
};
|
||
let x0 = slots[first].x;
|
||
let x1 = slots[last].x;
|
||
let span = x1 - x0;
|
||
let extra = width_limit - (ext.max_x - ext.min_x);
|
||
if span <= f32::EPSILON || extra <= f32::EPSILON {
|
||
return Placement::rigid(base_dx, dy);
|
||
}
|
||
// Within [x0, x1]: world_x(x) = x + base_dx + extra·(x − x0)/span, i.e.
|
||
// a·x + b. Beyond it, `Placement::x` falls back to rigid slope 1.
|
||
Placement {
|
||
a: 1.0 + extra / span,
|
||
b: base_dx - extra * x0 / span,
|
||
dy,
|
||
x0,
|
||
x1,
|
||
}
|
||
}
|
||
|
||
/// Places a whole stroke under a system's justification. A per-event component
|
||
/// stroke (a stem or ledger) tracks its notehead: both endpoints translate by
|
||
/// the owning slot's delta, so it stays attached without stretching its offset.
|
||
/// A spanning stroke (a staff line, a volta bracket) stretches with the system:
|
||
/// each endpoint maps through the affine.
|
||
fn place_stroke(
|
||
source: &Stroke,
|
||
spaced: &Stroke,
|
||
p: Placement,
|
||
slot_source_x: &BTreeMap<SpringSlotId, f32>,
|
||
glyphs: &[GlyphObject],
|
||
) -> Stroke {
|
||
if let Some(dx) = crate::component_glyph(source, glyphs)
|
||
.and_then(|g| slot_source_x.get(&g.horizontal_slot))
|
||
.map(|&sx| p.slot_dx(sx))
|
||
{
|
||
return translated(spaced, dx, p.dy);
|
||
}
|
||
Stroke {
|
||
provenance: spaced.provenance.clone(),
|
||
from: Point::new(p.x(spaced.from.x.0), spaced.from.y.0 + p.dy),
|
||
to: Point::new(p.x(spaced.to.x.0), spaced.to.y.0 + p.dy),
|
||
thickness: spaced.thickness,
|
||
layer: spaced.layer,
|
||
style: spaced.style,
|
||
vertical_band: spaced.vertical_band,
|
||
}
|
||
}
|
||
|
||
/// Accumulated staff-line geometry within one system, for the resolved staff
|
||
/// record: the extent of the staff's line segments and the provenance of its
|
||
/// bottom line (the segment that anchors the staff in this system).
|
||
struct StaffAgg {
|
||
min_x: f32,
|
||
max_x: f32,
|
||
min_y: f32,
|
||
max_y: f32,
|
||
bottom: (f32, Provenance),
|
||
}
|
||
|
||
/// Folds a world-frame staff-line stroke into its `(system, staff)` aggregate.
|
||
fn mark_staff(
|
||
marks: &mut BTreeMap<(usize, StaffId), StaffAgg>,
|
||
system: usize,
|
||
staff: StaffId,
|
||
stroke: &Stroke,
|
||
) {
|
||
let half = (stroke.thickness.0 * 0.5).max(0.0);
|
||
let (lo_x, hi_x) = (
|
||
stroke.from.x.0.min(stroke.to.x.0),
|
||
stroke.from.x.0.max(stroke.to.x.0),
|
||
);
|
||
let (lo_y, hi_y) = (
|
||
stroke.from.y.0.min(stroke.to.y.0) - half,
|
||
stroke.from.y.0.max(stroke.to.y.0) + half,
|
||
);
|
||
marks
|
||
.entry((system, staff))
|
||
.and_modify(|agg| {
|
||
agg.min_x = agg.min_x.min(lo_x);
|
||
agg.max_x = agg.max_x.max(hi_x);
|
||
agg.min_y = agg.min_y.min(lo_y);
|
||
agg.max_y = agg.max_y.max(hi_y);
|
||
if lo_y < agg.bottom.0 {
|
||
agg.bottom = (lo_y, stroke.provenance.clone());
|
||
}
|
||
})
|
||
.or_insert_with(|| StaffAgg {
|
||
min_x: lo_x,
|
||
max_x: hi_x,
|
||
min_y: lo_y,
|
||
max_y: hi_y,
|
||
bottom: (lo_y, stroke.provenance.clone()),
|
||
});
|
||
}
|
||
|
||
/// Builds one populated [`ResolvedSystem`]: a real world-frame bounding box, a
|
||
/// staff record per staff whose lines reach this system (top staff first), and
|
||
/// a measure record per measure-start barline column the system carries. What
|
||
/// the pipeline does not know is left empty, never fabricated: a staff with no
|
||
/// engraved lines yields no staff record, and the final-barline measure (whose
|
||
/// start no column marks) yields no measure record.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn build_system(
|
||
system: usize,
|
||
plan: &SystemPlan,
|
||
input: &ConstrainedLayoutIR,
|
||
region_slots: &[Vec<SlotInfo>],
|
||
extents: &[Extent],
|
||
placements: &[Placement],
|
||
staff_marks: &BTreeMap<(usize, StaffId), StaffAgg>,
|
||
primitives: PrimitiveIndices,
|
||
) -> ResolvedSystem {
|
||
let region = &input.regions[plan.region];
|
||
let p = placements[system];
|
||
let ext = &extents[system];
|
||
let provenance = if plan.local == 0 {
|
||
region.provenance.clone()
|
||
} else {
|
||
// A region's second and later systems are engraver-created objects:
|
||
// synthesized from the region under `EngravedBreak`, keyed by the
|
||
// region-local system ordinal in its own key namespace.
|
||
Provenance::synthesized(
|
||
region.provenance.source,
|
||
SynthesisKind::EngravedBreak,
|
||
SynthesisInstanceKey((KEY_NS_SYSTEM << 64) | plan.local as u128),
|
||
region.provenance.dependencies.clone(),
|
||
)
|
||
};
|
||
let bounding_box = Rect {
|
||
// Justification stretches the horizontal extent: the box spans the
|
||
// system's world-frame ink, which for a justified system is the content
|
||
// width.
|
||
origin: Point::new(p.x(ext.min_x), ext.min_y + p.dy),
|
||
size: Size2D {
|
||
width: StaffSpace(p.x(ext.max_x) - p.x(ext.min_x)),
|
||
height: StaffSpace(ext.max_y - ext.min_y),
|
||
},
|
||
};
|
||
|
||
let mut staves: Vec<ResolvedStaff> = staff_marks
|
||
.range((system, StaffId::from_raw(0))..=(system, StaffId::from_raw(u128::MAX)))
|
||
.map(|(&(_, staff), agg)| ResolvedStaff {
|
||
provenance: agg.bottom.1.clone(),
|
||
staff,
|
||
bounding_box: Rect {
|
||
origin: Point::new(agg.min_x, agg.min_y),
|
||
size: Size2D {
|
||
width: StaffSpace(agg.max_x - agg.min_x),
|
||
height: StaffSpace(agg.max_y - agg.min_y),
|
||
},
|
||
},
|
||
})
|
||
.collect();
|
||
// Top staff first — the reading order of the system.
|
||
staves.sort_by(|a, b| {
|
||
let top_a = a.bounding_box.origin.y.0 + a.bounding_box.size.height.0;
|
||
let top_b = b.bounding_box.origin.y.0 + b.bounding_box.size.height.0;
|
||
top_b.total_cmp(&top_a)
|
||
});
|
||
|
||
// Measures: each measure-start barline column opens a span that runs to the
|
||
// next such column in this system, or to the system's content edge.
|
||
let slots = ®ion_slots[plan.region];
|
||
let marks: Vec<(usize, usize)> = plan
|
||
.slots
|
||
.iter()
|
||
.filter_map(|&i| slots[i].measure_barline.map(|g| (i, g)))
|
||
.collect();
|
||
let measures: Vec<ResolvedMeasure> = marks
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(k, &(i, g))| {
|
||
let glyph = &input.glyphs[g];
|
||
let TypedObjectId::Measure(measure) = glyph.provenance.source else {
|
||
return None;
|
||
};
|
||
let start = slots[i].lo;
|
||
let end = marks
|
||
.get(k + 1)
|
||
.map(|&(next, _)| slots[next].lo)
|
||
.unwrap_or(ext.max_x);
|
||
Some(ResolvedMeasure {
|
||
provenance: glyph.provenance.clone(),
|
||
measure,
|
||
bounding_box: Rect {
|
||
origin: Point::new(p.x(start), ext.min_y + p.dy),
|
||
size: Size2D {
|
||
width: StaffSpace(p.x(end) - p.x(start)),
|
||
height: StaffSpace(ext.max_y - ext.min_y),
|
||
},
|
||
},
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
ResolvedSystem {
|
||
provenance,
|
||
bounding_box,
|
||
staves,
|
||
measures,
|
||
primitives,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn default_geometry_matches_the_documented_arithmetic() {
|
||
// A4 at an 8 mm staff: 1 staff space = 2 mm.
|
||
let geometry = PageGeometry::default();
|
||
assert_eq!(geometry.size.width.0, 210.0 / 2.0);
|
||
assert_eq!(geometry.size.height.0, 297.0 / 2.0);
|
||
for margin in [
|
||
geometry.margins.top,
|
||
geometry.margins.right,
|
||
geometry.margins.bottom,
|
||
geometry.margins.left,
|
||
] {
|
||
assert_eq!(margin.0, 15.0 / 2.0);
|
||
}
|
||
assert_eq!(geometry.content_width(), 90.0);
|
||
assert_eq!(geometry.content_height(), 133.5);
|
||
}
|
||
|
||
#[test]
|
||
fn pages_stack_downward_with_the_inter_page_gap() {
|
||
let geometry = PageGeometry::default();
|
||
assert_eq!(page_top_content(0, &geometry), -7.5);
|
||
assert_eq!(
|
||
page_top_content(1, &geometry),
|
||
-(148.5 + INTER_PAGE_GAP) - 7.5
|
||
);
|
||
}
|
||
|
||
/// A uniform test measure: one break-candidate barline slot per measure,
|
||
/// spanning `[i·10, i·10 + 9]` (each measure ~9 wide, step 10).
|
||
fn measure_slot(i: usize) -> SlotInfo {
|
||
SlotInfo {
|
||
id: SpringSlotId(i as u128 + 1),
|
||
x: i as f32 * 10.0,
|
||
lo: i as f32 * 10.0,
|
||
hi: i as f32 * 10.0 + 9.0,
|
||
members: Vec::new(),
|
||
barline: true,
|
||
final_barline: false,
|
||
note: true,
|
||
measure_barline: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn optimal_breaks_balances_systems_and_avoids_a_final_widow() {
|
||
// Six uniform measures; the content width fits four (4 measures span 39,
|
||
// 5 span 49). Greedy first-fit packs [4, 2] — a short final system;
|
||
// the optimal search balances to [3, 3] (lower total squared underfill),
|
||
// subsuming the old widow rebalance. One automatic break, before the
|
||
// fourth measure.
|
||
let slots: Vec<SlotInfo> = (0..6).map(measure_slot).collect();
|
||
let breaks = optimal_breaks(&slots, &BTreeMap::new(), 42.0);
|
||
assert_eq!(
|
||
breaks.len(),
|
||
1,
|
||
"one automatic break → two systems: {breaks:?}"
|
||
);
|
||
assert!(
|
||
breaks.contains(&slots[3].id),
|
||
"the break is before the 4th measure (a 3/3 split): {breaks:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn optimal_breaks_never_spans_a_forced_break() {
|
||
// A break requirement at the 2nd measure partitions the DP: the first
|
||
// segment is a lone measure [0,1); the optimizer works only within
|
||
// [1,6). So measure 0 stands alone even though it would pack with more,
|
||
// and no automatic break coincides with the forced one.
|
||
let slots: Vec<SlotInfo> = (0..6).map(measure_slot).collect();
|
||
let mut reqs: BTreeMap<SpringSlotId, Vec<BreakReq>> = BTreeMap::new();
|
||
reqs.insert(
|
||
slots[1].id,
|
||
vec![BreakReq {
|
||
page: false,
|
||
hard: true,
|
||
}],
|
||
);
|
||
let breaks = optimal_breaks(&slots, &reqs, 42.0);
|
||
assert!(
|
||
!breaks.contains(&slots[1].id),
|
||
"the forced break is walk_region's, never reported here: {breaks:?}"
|
||
);
|
||
// The remaining measures [1..6) (5 of them, width 49 > 42) split
|
||
// optimally within their segment — every reported break is inside it.
|
||
for id in &breaks {
|
||
assert!(
|
||
slots[2..].iter().any(|s| s.id == *id),
|
||
"an automatic break stays inside the post-requirement segment: {id:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn optimal_breaks_is_deterministic_and_empty_when_unbounded() {
|
||
let slots: Vec<SlotInfo> = (0..6).map(measure_slot).collect();
|
||
let a = optimal_breaks(&slots, &BTreeMap::new(), 42.0);
|
||
let b = optimal_breaks(&slots, &BTreeMap::new(), 42.0);
|
||
assert_eq!(a, b, "a pure function of the inputs");
|
||
assert!(
|
||
optimal_breaks(&slots, &BTreeMap::new(), f32::INFINITY).is_empty(),
|
||
"an unbounded width wraps nothing"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_content_less_measure_before_a_soft_break_never_overflows() {
|
||
// Review Finding 1: a note-less leading measure (M0, clef/key/time only)
|
||
// whose barline carries a SOFT break. `walk_region` skips the break (the
|
||
// closing system has no content) and the DP, which treated that barline
|
||
// as a forced segment boundary, cannot foresee the skip. Without the
|
||
// overflow net the optimizer-filled measures after it would absorb M0
|
||
// into a MULTI-measure overfull system; the net breaks before the measure
|
||
// that would overflow instead. Verify no non-final system is both
|
||
// multi-measure and wider than the content width.
|
||
use epiphany_core::{RegionId, ReplicaId};
|
||
let mk = |i: usize, lo: f32, hi: f32, note: bool| SlotInfo {
|
||
id: SpringSlotId(i as u128 + 1),
|
||
x: lo,
|
||
lo,
|
||
hi,
|
||
members: Vec::new(),
|
||
barline: true,
|
||
final_barline: false,
|
||
note,
|
||
measure_barline: None,
|
||
};
|
||
// A wide note-less M0; then three narrow measures and two wide ones, so
|
||
// the optimizer groups [M1,M2,M3,M4] (its first system, ~39 ≤ 42) and
|
||
// [M5] — which, with M0 prepended by the skipped break, would span
|
||
// M0..M4 ≈ 60 ≫ 42 without the net.
|
||
let slots = vec![
|
||
mk(0, 0.0, 20.0, false),
|
||
mk(1, 21.0, 26.0, true),
|
||
mk(2, 27.0, 32.0, true),
|
||
mk(3, 33.0, 38.0, true),
|
||
mk(4, 39.0, 60.0, true),
|
||
mk(5, 61.0, 82.0, true),
|
||
];
|
||
let mut reqs: BTreeMap<SpringSlotId, Vec<BreakReq>> = BTreeMap::new();
|
||
reqs.insert(
|
||
slots[1].id,
|
||
vec![BreakReq {
|
||
page: false,
|
||
hard: false,
|
||
}],
|
||
); // SOFT
|
||
let width_limit = 42.0;
|
||
let mut systems = Vec::new();
|
||
let mut skipped = Vec::new();
|
||
walk_region(
|
||
0,
|
||
&slots,
|
||
&reqs,
|
||
&BTreeMap::new(),
|
||
TypedObjectId::Region(RegionId::new(ReplicaId(1), 1)),
|
||
width_limit,
|
||
&mut systems,
|
||
&mut skipped,
|
||
);
|
||
for (s, plan) in systems.iter().enumerate() {
|
||
let lo = plan
|
||
.slots
|
||
.iter()
|
||
.map(|&k| slots[k].lo)
|
||
.fold(f32::INFINITY, f32::min);
|
||
let hi = plan
|
||
.slots
|
||
.iter()
|
||
.map(|&k| slots[k].hi)
|
||
.fold(f32::NEG_INFINITY, f32::max);
|
||
assert!(
|
||
hi - lo <= width_limit + 1e-3 || plan.slots.len() <= 1,
|
||
"system {s} spans {} measures at width {} > {width_limit}",
|
||
plan.slots.len(),
|
||
hi - lo
|
||
);
|
||
}
|
||
assert!(!skipped.is_empty(), "the skipped soft break is recorded");
|
||
}
|
||
|
||
#[test]
|
||
fn repeat_signs_keep_measure_records_honest_and_raise_their_system() {
|
||
use crate::Engraver;
|
||
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||
// The repeat fixture draws morphed repeat barlines, a standalone sign,
|
||
// the final-barline dot pair, and volta brackets. None of that may
|
||
// mint a phantom measure record (a standalone sign and the dot pair
|
||
// are repeat-synthesized, not measure barlines) or lose one (a morphed
|
||
// barline still marks its measure): both fixtures cast off to the same
|
||
// nine records — one per measure-*start* barline column; the final
|
||
// measure's barline closes the region and yields none, by convention.
|
||
let solve = |score| {
|
||
Engraver::default().solve(
|
||
&to_constrained(&to_logical(&score)),
|
||
&SolverConfig::default(),
|
||
)
|
||
};
|
||
let plain = solve(epiphany_testkit::fixtures::ten_measure_single_staff(
|
||
0x000A_11CE,
|
||
));
|
||
let repeats = solve(epiphany_testkit::fixtures::ten_measure_with_repeats(
|
||
0x000A_11CE,
|
||
));
|
||
let measure_count = |report: &crate::SolveReport| -> usize {
|
||
report
|
||
.layout
|
||
.pages
|
||
.iter()
|
||
.flat_map(|page| &page.systems)
|
||
.map(|system| system.measures.len())
|
||
.sum()
|
||
};
|
||
assert_eq!(measure_count(&plain), 9);
|
||
assert_eq!(measure_count(&repeats), 9);
|
||
// The volta brackets sit above the staff, so the system carrying them
|
||
// is taller than any repeat-free system.
|
||
let max_height = |report: &crate::SolveReport| -> f32 {
|
||
report
|
||
.layout
|
||
.pages
|
||
.iter()
|
||
.flat_map(|page| &page.systems)
|
||
.map(|system| system.bounding_box.size.height.0)
|
||
.fold(0.0, f32::max)
|
||
};
|
||
assert!(max_height(&repeats) > max_height(&plain));
|
||
}
|
||
|
||
#[test]
|
||
fn the_widow_rebalance_evens_the_final_system() {
|
||
use crate::Engraver;
|
||
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||
// The ten-measure fixture wraps into two systems under the default A4
|
||
// geometry. Greedy first-fit alone leaves a two-measure stub final
|
||
// system; the widow rebalance evens the split so the final system
|
||
// carries a substantial share of the measures — while the system
|
||
// *count* is unchanged. (Justification now stretches every non-final
|
||
// system to the full content width, so the rebalance's effect shows in
|
||
// the MEASURE distribution, not the baked widths — the non-final system
|
||
// fills the width regardless.)
|
||
let input = to_constrained(&to_logical(
|
||
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
|
||
));
|
||
let report = Engraver::default().solve(&input, &SolverConfig::default());
|
||
let page = &report.layout.pages[0];
|
||
assert_eq!(page.systems.len(), 2, "the fixture wraps into two systems");
|
||
let first = page.systems[0].measures.len();
|
||
let last = page.systems[1].measures.len();
|
||
assert!(
|
||
last * 2 >= first,
|
||
"the rebalanced final system carries a substantial share of the \
|
||
measures, not a stub: {last} vs {first}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn sub_cubic_reproduces_the_original_curve_on_its_sub_range() {
|
||
// de Casteljau correctness: the sub-cubic over [t0, t1], evaluated at
|
||
// its own parameter u in [0, 1], equals the original evaluated at
|
||
// t0 + u·(t1 - t0). A slur-shaped x-ascending cubic.
|
||
let cp = [
|
||
Point::new(0.0, 0.0),
|
||
Point::new(2.0, 3.0),
|
||
Point::new(6.0, 3.0),
|
||
Point::new(8.0, 0.0),
|
||
];
|
||
let eval = |p: [Point; 4], t: f32| -> Point {
|
||
let u = 1.0 - t;
|
||
Point::new(
|
||
u * u * u * p[0].x.0
|
||
+ 3.0 * u * u * t * p[1].x.0
|
||
+ 3.0 * u * t * t * p[2].x.0
|
||
+ t * t * t * p[3].x.0,
|
||
u * u * u * p[0].y.0
|
||
+ 3.0 * u * u * t * p[1].y.0
|
||
+ 3.0 * u * t * t * p[2].y.0
|
||
+ t * t * t * p[3].y.0,
|
||
)
|
||
};
|
||
let (t0, t1) = (0.3_f32, 0.75_f32);
|
||
let sub = sub_cubic(cp, t0, t1);
|
||
for i in 0..=10 {
|
||
let u = i as f32 / 10.0;
|
||
let on_sub = eval(sub, u);
|
||
let on_orig = eval(cp, t0 + u * (t1 - t0));
|
||
assert!(
|
||
(on_sub.x.0 - on_orig.x.0).abs() < 1e-4 && (on_sub.y.0 - on_orig.y.0).abs() < 1e-4,
|
||
"sub-cubic diverges from the original at u={u}: {on_sub:?} vs {on_orig:?}"
|
||
);
|
||
}
|
||
// And `param_at_x` inverts the x-monotonic curve: the point at the found
|
||
// parameter has the requested x.
|
||
assert!(is_x_monotonic(cp));
|
||
let t = param_at_x(cp, 5.0);
|
||
assert!((eval(cp, t).x.0 - 5.0).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn a_slur_spanning_a_system_break_splits_into_per_system_sub_curves() {
|
||
use crate::Engraver;
|
||
use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle, TypedObjectId};
|
||
use epiphany_layout_ir::{
|
||
to_constrained, to_logical, ConstraintSolver, SolverConfig, SynthesisKind,
|
||
};
|
||
// A slur over the whole ten-measure score — its endpoints cast into
|
||
// different systems (the fixture wraps into two), so the curve spans the
|
||
// break.
|
||
let mut score = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
|
||
let events: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0]
|
||
.events
|
||
.clone();
|
||
let slur_id: SlurId = score.identity.mint();
|
||
score.cross_cutting.slurs.push(Slur {
|
||
id: slur_id,
|
||
start_event: events[0],
|
||
end_event: events[events.len() - 1],
|
||
kind: SlurKind::Legato,
|
||
curvature_override: None,
|
||
style: SpanStyle::default(),
|
||
});
|
||
let report = Engraver::default().solve(
|
||
&to_constrained(&to_logical(&score)),
|
||
&SolverConfig::default(),
|
||
);
|
||
assert_eq!(report.layout.pages[0].systems.len(), 2, "two systems");
|
||
|
||
let slur_curves: Vec<_> = report
|
||
.layout
|
||
.curves
|
||
.iter()
|
||
.filter(|c| c.provenance.source == TypedObjectId::Slur(slur_id))
|
||
.collect();
|
||
// The slur split into ≥2 sub-cubics (one per spanned system).
|
||
assert!(
|
||
slur_curves.len() >= 2,
|
||
"a break-spanning slur splits, got {} segment(s)",
|
||
slur_curves.len()
|
||
);
|
||
// Exactly one segment carries the slur's exact provenance (the surjection
|
||
// recovers the source once); the rest are synthesized continuations.
|
||
let originals = slur_curves
|
||
.iter()
|
||
.filter(|c| c.provenance.synthesis.is_none())
|
||
.count();
|
||
assert_eq!(
|
||
originals, 1,
|
||
"one segment keeps the slur's exact provenance"
|
||
);
|
||
assert!(slur_curves
|
||
.iter()
|
||
.filter(|c| c.provenance.synthesis.is_some())
|
||
.all(|c| matches!(c.provenance.synthesis, Some(SynthesisKind::Registered(_)))));
|
||
// The segments sit in different systems, which casting stacks
|
||
// vertically (each system is translated down and restarts x at the left
|
||
// margin), so a real split separates them in Y — one curve overhanging
|
||
// into the next system would keep a single y-band.
|
||
let y_centroids: Vec<f32> = slur_curves
|
||
.iter()
|
||
.map(|c| (c.p0.y.0 + c.p1.y.0 + c.p2.y.0 + c.p3.y.0) / 4.0)
|
||
.collect();
|
||
let (lo, hi) = (
|
||
y_centroids.iter().copied().fold(f32::INFINITY, f32::min),
|
||
y_centroids
|
||
.iter()
|
||
.copied()
|
||
.fold(f32::NEG_INFINITY, f32::max),
|
||
);
|
||
assert!(
|
||
hi - lo > 1.0,
|
||
"the segments span distinct system y-bands (a real split), spread {}",
|
||
hi - lo
|
||
);
|
||
}
|
||
|
||
/// (m1) For each of the layout's three flat arrays, every system's owned
|
||
/// index list plus the layout's `unowned` bucket covers `0..len` exactly
|
||
/// once — pin 4's total, disjoint partition. The load-bearing invariant,
|
||
/// checked directly rather than assumed from construction.
|
||
fn assert_total_disjoint_partition(layout: &epiphany_layout_ir::ResolvedLayoutIR) {
|
||
let check = |label: &str, len: usize, owned: Vec<&Vec<u32>>, unowned: &[u32]| {
|
||
let mut seen = vec![0u8; len];
|
||
for &i in owned.iter().flat_map(|v| v.iter()).chain(unowned.iter()) {
|
||
let idx = i as usize;
|
||
assert!(idx < len, "{label}: index {i} out of range (len {len})");
|
||
seen[idx] += 1;
|
||
}
|
||
for (i, &count) in seen.iter().enumerate() {
|
||
assert_eq!(
|
||
count, 1,
|
||
"{label}: index {i} covered {count} times (want exactly 1)"
|
||
);
|
||
}
|
||
};
|
||
let glyph_lists: Vec<&Vec<u32>> = layout.systems().map(|s| &s.primitives.glyphs).collect();
|
||
check(
|
||
"glyphs",
|
||
layout.glyphs.len(),
|
||
glyph_lists,
|
||
&layout.unowned.glyphs,
|
||
);
|
||
let stroke_lists: Vec<&Vec<u32>> =
|
||
layout.systems().map(|s| &s.primitives.strokes).collect();
|
||
check(
|
||
"strokes",
|
||
layout.strokes.len(),
|
||
stroke_lists,
|
||
&layout.unowned.strokes,
|
||
);
|
||
let curve_lists: Vec<&Vec<u32>> = layout.systems().map(|s| &s.primitives.curves).collect();
|
||
check(
|
||
"curves",
|
||
layout.curves.len(),
|
||
curve_lists,
|
||
&layout.unowned.curves,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn primitive_ownership_partitions_every_flat_array_totally_and_disjointly() {
|
||
use crate::Engraver;
|
||
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||
|
||
// The wrapping ten-measure fixture: real glyphs and strokes, no curves.
|
||
let wrapping = Engraver::default().solve(
|
||
&to_constrained(&to_logical(
|
||
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
|
||
)),
|
||
&SolverConfig::default(),
|
||
);
|
||
assert_eq!(
|
||
wrapping.layout.pages[0].systems.len(),
|
||
2,
|
||
"the fixture wraps into two systems"
|
||
);
|
||
assert!(!wrapping.layout.glyphs.is_empty());
|
||
assert!(!wrapping.layout.strokes.is_empty());
|
||
assert_total_disjoint_partition(&wrapping.layout);
|
||
|
||
// The slurred fixture: also exercises curves, including a
|
||
// system-spanning split (G4's own construction).
|
||
let slurred = Engraver::default().solve(
|
||
&to_constrained(&to_logical(
|
||
&epiphany_testkit::fixtures::ten_measure_with_slurs(0),
|
||
)),
|
||
&SolverConfig::default(),
|
||
);
|
||
let slurred_systems: usize = slurred.layout.pages.iter().map(|p| p.systems.len()).sum();
|
||
assert!(
|
||
slurred_systems > 1,
|
||
"casting-off wraps the slurred fixture too"
|
||
);
|
||
assert!(
|
||
!slurred.layout.curves.is_empty(),
|
||
"the slur produces real curves"
|
||
);
|
||
assert_total_disjoint_partition(&slurred.layout);
|
||
}
|
||
|
||
#[test]
|
||
fn attribution_correctness_matches_the_real_per_system_counts() {
|
||
// (m3) The *actual* per-system glyph/stroke counts of the two-system
|
||
// fixture — real numbers, not `> 0` — value-asserted directly against
|
||
// what casting-off computed.
|
||
use crate::Engraver;
|
||
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||
|
||
let report = Engraver::default().solve(
|
||
&to_constrained(&to_logical(
|
||
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
|
||
)),
|
||
&SolverConfig::default(),
|
||
);
|
||
let systems: Vec<_> = report.layout.systems().collect();
|
||
assert_eq!(systems.len(), 2, "two systems");
|
||
let glyph_counts: Vec<usize> = systems.iter().map(|s| s.primitives.glyphs.len()).collect();
|
||
let stroke_counts: Vec<usize> =
|
||
systems.iter().map(|s| s.primitives.strokes.len()).collect();
|
||
assert_eq!(
|
||
glyph_counts,
|
||
vec![26, 25],
|
||
"the six/four widow-rebalanced measure split's real per-system glyph counts"
|
||
);
|
||
assert_eq!(
|
||
stroke_counts,
|
||
vec![51, 45],
|
||
"the six/four widow-rebalanced measure split's real per-system stroke counts"
|
||
);
|
||
assert_eq!(
|
||
glyph_counts[0] + glyph_counts[1],
|
||
report.layout.glyphs.len()
|
||
);
|
||
assert_eq!(
|
||
stroke_counts[0] + stroke_counts[1],
|
||
report.layout.strokes.len()
|
||
);
|
||
assert!(
|
||
report.layout.unowned.glyphs.is_empty(),
|
||
"the whole score is inside the one region this fixture declares"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn continuation_segments_are_owned_by_the_system_they_split_into() {
|
||
// (m5) A slur crossing a system break: its synthesized continuation
|
||
// segment is owned by the system it was split INTO, not the source
|
||
// segment's system.
|
||
use crate::Engraver;
|
||
use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle, TypedObjectId};
|
||
use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig};
|
||
|
||
let mut score = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE);
|
||
let events: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0]
|
||
.events
|
||
.clone();
|
||
let slur_id: SlurId = score.identity.mint();
|
||
score.cross_cutting.slurs.push(Slur {
|
||
id: slur_id,
|
||
start_event: events[0],
|
||
end_event: events[events.len() - 1],
|
||
kind: SlurKind::Legato,
|
||
curvature_override: None,
|
||
style: SpanStyle::default(),
|
||
});
|
||
let report = Engraver::default().solve(
|
||
&to_constrained(&to_logical(&score)),
|
||
&SolverConfig::default(),
|
||
);
|
||
assert_eq!(report.layout.pages[0].systems.len(), 2, "two systems");
|
||
|
||
let original_index = report
|
||
.layout
|
||
.curves
|
||
.iter()
|
||
.position(|c| {
|
||
c.provenance.source == TypedObjectId::Slur(slur_id)
|
||
&& c.provenance.synthesis.is_none()
|
||
})
|
||
.expect("one segment keeps the slur's exact provenance");
|
||
let continuation_index = report
|
||
.layout
|
||
.curves
|
||
.iter()
|
||
.position(|c| {
|
||
c.provenance.source == TypedObjectId::Slur(slur_id)
|
||
&& c.provenance.synthesis.is_some()
|
||
})
|
||
.expect("the break-spanning slur splits and synthesizes a continuation");
|
||
|
||
let owner_of = |index: usize| -> Option<usize> {
|
||
report
|
||
.layout
|
||
.systems()
|
||
.position(|s| s.primitives.curves.contains(&(index as u32)))
|
||
};
|
||
let owner_first = owner_of(original_index).expect("the original segment is owned");
|
||
let owner_continuation = owner_of(continuation_index).expect("the continuation is owned");
|
||
assert_eq!(
|
||
owner_first, 0,
|
||
"the original segment starts in the first system"
|
||
);
|
||
assert_eq!(
|
||
owner_continuation, 1,
|
||
"the continuation is owned by the system it was split INTO, not the source's"
|
||
);
|
||
}
|
||
}
|