epiphany/crates/epiphany-engrave/src/lib.rs

2814 lines
126 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![forbid(unsafe_code)]
//! # epiphany-engrave
//!
//! Agent I's **engraving constraint solver** (spec **Chapter 9**, "Constraint-
//! Solver Interface"): it turns a [`ConstrainedLayoutIR`] into a
//! [`ResolvedLayoutIR`] with real geometry. It is the production-side replacement
//! for `epiphany-layout-ir`'s interface-only `StubSolver` — the QUICKSTART puts
//! the *interface* (`layout-ir`) and the *algorithm* (`engrave`) in separate
//! crates so the core/product boundary stays sharp (`spec/PHASE2_QUICKSTART.md`,
//! crate topology).
//!
//! ## Phase status — `Minimal` tier, with casting-off
//!
//! [`Engraver`] runs a genuine deterministic **horizontal spacing pass** (the
//! private `spacing` module) — placing each glyph-bearing slot left-to-right by
//! a collision-aware advance (its preferred width floored by the real glyph
//! bearings) — then a **casting-off pass** (the [`casting`] module; Chapter 9
//! §"The Constraint-Solving Stage": the solver "resolve\[s\] page and system
//! breaks"): greedy first-fit system breaking at measure boundaries against a
//! [`PageGeometry`], a widow-rebalance phase that evens a region's system widths
//! so the final system is not left a stub, vertical system stacking at the
//! vertical-band model's inter-system gap, page assignment by content height,
//! and a real populated page/system tree (Chapter 7 §"ResolvedLayoutIR"). Every
//! chosen break is
//! recorded as an [`epiphany_layout_ir::EngravingDecision`] whose target is a
//! `MUSCLOID` id synthesized under
//! [`epiphany_layout_ir::SynthesisKind::EngravedBreak`], attributed to the user
//! override that asked for it when one did
//! ([`epiphany_layout_ir::DecisionSource::UserOverride`]).
//!
//! The declared constraints are **evaluated**, routed by
//! [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"). Geometric
//! constraints (no-collision, alignment, position-within) are evaluated in the
//! **pre-casting spaced frame** — they are region-frame obligations, and
//! casting-off relocates whole systems by rigid motions that cannot un-satisfy
//! them within a system (see `DECISIONS.md`). Break constraints are evaluated
//! against the **final break structure**: a `SystemBreakAt`/`PageBreakAt` is
//! satisfied iff the cast layout breaks (starts a system/page) at that slot. A
//! violated `Required` constraint makes the solve
//! [`SolveStatus::Unsatisfiable`]; a violated `Preferred` one (a soft break
//! skipped on the documented pathological path) surfaces as a
//! [`SolverWarningKind::LargeSoftConstraintViolation`] warning under
//! [`SolveStatus::SolvedWithWarnings`] plus an `IrOverride`-sourced decision,
//! never a failure. A solve is [`SolveStatus::Solved`] only when every declared
//! constraint holds.
//!
//! Having earned it, [`Engraver::tier`] reports [`SolverTier::Minimal`] — which
//! (Chapter 9 §"Conformance Tiers" / QUICKSTART) means *hard constraints
//! satisfied, no claim about optimality* — greedy first-fit casting-off is
//! legitimate at this tier. The solve reports a **real quality-metric vector**:
//! the private `quality` module computes all nine normative axes per the
//! ratified *Quality Metric Catalog* companion (collision census, spacing
//! regularity, break/page/casting-off distribution, vertical gap deviation;
//! `slur_shape` is now **measured** — each drawn slur's arc ratio against the
//! shallow-arc band — while `beam_slope` stays vacuous-`0.0` because no drawn
//! beam geometry exists yet), normalized through the catalog's pinned anchors
//! ([`epiphany_layout_ir::quality`]), with
//! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the
//! threshold column the config's profile selects. The all-worst
//! [`QualityMetricVector::unmeasured`] placeholder remains only for malformed
//! inputs the solver cannot measure. Still deferred to a later tier: the
//! **vertical spring pass** (glyph `y` within a system is the constrained
//! natural staff layout, preserved verbatim; systems stack by real content
//! extents), per-system justification/stretch, and optimal break search.
//!
//! ## Architecture decision (see `DECISIONS.md`)
//!
//! The solver is a **two-pass spring layout** (horizontal then vertical), with
//! the constraint graph derived from the existing [`ConstrainedLayoutIR`]
//! (QUICKSTART decision 2). A global optimization solver is rejected: the spec's
//! deterministic-output requirement makes it expensive to validate.
//!
//! [`epiphany-render-svg`]: ../epiphany_render_svg/index.html
pub mod casting;
mod quality;
mod spacing;
use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::TypedObjectId;
use epiphany_layout_ir::{
all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId,
ConstraintSolver, ConstraintStrength, Curve, GlyphCatalog, GlyphObject, GlyphObjectId,
InvalidationSet, LayoutConstraint, Point, PrimitiveIndices, QualityMetricVector, Rect,
ResolvedGlyph, ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig,
SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke,
};
pub use casting::{PageGeometry, INTER_PAGE_GAP, SYSTEM_CONTINUATION_SYNTHESIS};
/// The glyph a fixed-width stroke (a ledger line) belongs to: the same-source glyph
/// whose baseline falls within the stroke's horizontal span (its accidentals sit
/// outside the span, to the left). The stroke is anchored to this glyph's column so
/// it translates with it — found by source, never inferred from the stroke's own
/// midpoint, which for a wide head can fall nearer a neighbouring column.
pub(crate) fn owning_glyph<'a>(
stroke: &Stroke,
glyphs: &'a [GlyphObject],
) -> Option<&'a GlyphObject> {
let lo = stroke.from.x.0.min(stroke.to.x.0);
let hi = stroke.from.x.0.max(stroke.to.x.0);
glyphs.iter().find(|g| {
g.provenance.source == stroke.provenance.source
&& g.baseline.x.0 >= lo
&& g.baseline.x.0 <= hi
})
}
/// The glyph a per-event COMPONENT stroke belongs to — the notehead that shares
/// its `Event` source — found by source ALONE, so it also catches a **stem**,
/// which sits offset from its column (at `notehead_x + stem_offset`) and whose
/// x therefore contains no glyph baseline (`owning_glyph`'s x-span test misses
/// it). Such a stroke tracks its notehead's slot rigidly, so re-spacing and
/// justification move it *with* its head instead of stretching its offset. A
/// stroke whose source is not an `Event` — a staff line (`Staff`), a volta
/// bracket (a `RepeatStructure`, a source its ending-number glyphs also carry) —
/// has no same-slot owner here and stretches with its system instead. (Every
/// event-sourced stroke today is a single-slot component: stem, ledger, or a
/// zero-extent anchor. A future event-spanning stroke — a beam — would need a
/// span-aware guard added here.)
pub(crate) fn component_glyph<'a>(
stroke: &Stroke,
glyphs: &'a [GlyphObject],
) -> Option<&'a GlyphObject> {
// Spanning strokes stretch with their system, so they own no single slot: a
// staff line (`Staff` source), a volta bracket (`RepeatStructure` source —
// which its ending-number glyphs also carry, so a plain source match would
// wrongly anchor the whole bracket to one number's slot).
if matches!(
stroke.provenance.source,
TypedObjectId::Staff(_) | TypedObjectId::RepeatStructure(_)
) {
return None;
}
// A ledger shares its notehead's `Pitch` source and overlaps it in x, so
// `owning_glyph` finds it directly.
if let Some(g) = owning_glyph(stroke, glyphs) {
return Some(g);
}
// A stem is `Event`-sourced with NO same-source glyph (noteheads are
// `Pitch`-sourced) and sits offset from its column (`stem_x = notehead_x +
// 1.15`, inside the 1.6 column step), so its x contains no glyph baseline.
// It belongs to the slot just to its LEFT — the glyph with the greatest
// baseline ≤ its x, which is a notehead or dot in the stem's OWN column (all
// of that column's glyphs share the one slot, so the pick's slot is exact).
let x = stroke.from.x.0.max(stroke.to.x.0);
glyphs
.iter()
.filter(|g| g.baseline.x.0 <= x + f32::EPSILON)
.max_by(|a, b| a.baseline.x.0.total_cmp(&b.baseline.x.0))
}
/// The Epiphany engraving solver (Chapter 9). A `Minimal`-tier solver: it spaces
/// glyphs horizontally, casts the result off into systems and pages against its
/// [`PageGeometry`], and satisfies the IR's declared hard constraints — break
/// constraints included. See the crate docs for what each tier claims and what
/// remains deferred.
#[derive(Copy, Clone, Debug, Default)]
pub struct Engraver {
geometry: PageGeometry,
}
/// The implementation version of this solver (Chapter 9: within a fixed version,
/// identical input produces identical output). Distinct from the stub's `0`;
/// bumped to `2` when the casting-off pass landed (the resolved geometry of a
/// wrapping score differs from version `1`'s single endless system), to `3`
/// when casting-off gained its widow-rebalance phase (a wrapping score's system
/// breaks — and so its baked geometry — differ again from version `2`'s pure
/// greedy first-fit), to `4` when repeat barlines and volta brackets landed
/// and the horizontal remap became **slot-relative** (a repeat-bearing score's
/// baked geometry differs from version `3`'s invisible traced anchors, and a
/// same-slot companion glyph — a time-signature digit, key-signature
/// accidental, or spelling accidental — now rides its slot's rigid delta
/// instead of drifting by interpolation; scores without such companions are
/// unchanged), and to `5` when slur curves landed (a slur-bearing score gains
/// a drawn cubic-bézier curve where version `4` had only a traced anchor; the
/// resolved output now carries a third primitive kind, so its canonical bytes
/// differ; slur-free scores draw the same ink as before), and to `6` when slur
/// curves gained a line pattern (a dashed or dotted slur renders its authored
/// `SpanStyle.line` faithfully; a curve's canonical bytes now include its line
/// style; solid slurs and slur-free scores are unchanged), and to `7` when a
/// slur spanning a system break began splitting into per-system sub-curves (de
/// Casteljau) instead of drawing whole in its start system; a slur that fits in
/// one system is unchanged, and to `8` when **per-system justification** landed
/// (every non-final system of a multi-system region stretches its horizontal
/// slack so its ink fills the content width, instead of sitting at its natural
/// left-aligned width) **together with correct component-stroke slot-anchoring**
/// (a stem — an `Event`-sourced stroke offset from its column, which the old
/// ledger-only rigid-width test missed — now rides its notehead's slot in both
/// the spacing pass and casting, instead of being stretched by the interpolation
/// / justification map and drifting off its head; any wrapping score's baked
/// geometry differs, and any score with drawn stems shifts them onto their
/// heads), and to `9` when **vertical justification** landed (the systems of a
/// non-final page spread so the last one's bottom reaches the content bottom,
/// filling the page height; a multi-page score's baked geometry differs, while
/// a single-page score — whose only page is ragged-bottom by convention — is
/// unchanged), and to `10` when casting-off replaced greedy first-fit + widow
/// rebalance with an **optimal (badness-minimizing) break search** (a wrapping
/// score's measures partition into more balanced systems — e.g. a 5/4 split
/// where greedy left a fuller-then-stub one — so its system breaks, and every
/// geometry that flows from them, differ; a score that does not wrap is
/// unchanged), and to `11` when the **inter-staff vertical solve** landed (the
/// gaps between a system's staves are renegotiated so tightly ledgered or
/// slurred adjacent staves separate to the band model's preferred gap; a
/// multi-staff score whose staves press together shifts them apart, while a
/// single-staff score — with no inter-staff pair — is unchanged), and to `12`
/// when that solve became **two-sided** (it now closes a slack pair as well as
/// opening a crowded one, realizing the `InterStaffGap` band's declared ink
/// clearance exactly; the constrained stage's fixed `SYSTEM_STAFF_PITCH` is an
/// initial arrangement, not a floor. Every multi-staff score's staff distances
/// move — plain content settles near a pitch of 10.6 staff spaces — while a
/// single-staff score is again unchanged. The same version repairs a cascade
/// defect: a pair below the first was over-separated by exactly the shift above
/// it, so 3+-staff scores tighten further).
pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(12);
impl Engraver {
/// An engraver casting off against the given page geometry.
/// [`Engraver::default`] uses [`PageGeometry::default`] (A4 portrait at an
/// 8 mm staff — see its docs for the arithmetic).
pub fn with_geometry(geometry: PageGeometry) -> Self {
Engraver { geometry }
}
/// The page geometry this engraver casts off against.
pub fn geometry(&self) -> PageGeometry {
self.geometry
}
/// Resolves geometry: a deterministic horizontal spacing pass over the spring
/// slots (each glyph to its slot's `x`, baseline `y` preserved), then the
/// casting-off pass (system breaking, vertical stacking, page assignment —
/// see [`casting`]), then evaluation of the declared constraints by
/// strength, then the **quality-metric census** (the private `quality`
/// module): all nine normative axes of the Quality Metric Catalog computed
/// over the cast geometry, with `QualityFloorApproached` warnings against
/// the threshold column the config's profile selects (diagnostic — per the
/// catalog they never change the status). A malformed input — an unknown
/// glyph, a forged catalog identity, or invalid structure — yields
/// [`SolveStatus::InternalError`] with the all-worst unmeasured vector
/// (nothing trustworthy to measure); a valid problem whose `Required`
/// constraints cannot all be satisfied yields
/// [`SolveStatus::Unsatisfiable`] (naming the unsatisfied constraints), its
/// real geometry measured honestly. Neither panics. Violated `Preferred`
/// constraints yield soft-violation warnings under
/// [`SolveStatus::SolvedWithWarnings`] — a valid, renderable layout.
fn resolve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport {
let structural_valid = input.validate().is_ok();
// Short-circuit before catalog construction so an unknown glyph yields a
// diagnostic, not a panic in the metrics hash (mirrors the stub).
let names: Vec<&str> = input.glyphs.iter().map(|g| g.glyph.as_str()).collect();
let metrics_available = all_available(names.iter().copied());
let catalog_valid = metrics_available && input.catalog == BravuraCatalog.identity(&names);
// The horizontal spacing pass re-places each glyph by its spring slot.
// The strokes that track those glyphs (stems, staff lines, barlines) must
// ride the *same* horizontal map, or a re-spaced notehead would leave its
// stem behind. Both gate on structural validity: a malformed input must
// not leak geometry into the diagnostic layout (which reaches
// canonical_bytes / the renderer).
let (spaced_glyphs, spaced_strokes, spaced_curves): (
Vec<ResolvedGlyph>,
Vec<Stroke>,
Vec<Curve>,
) = if structural_valid {
let remap = HorizontalRemap::build(input);
(
remap.glyphs(input),
remap.strokes(input),
remap.curves(input),
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
// Casting-off: break the spaced line into systems, stack them, assign
// pages, and bake every position into the single world frame. Pure
// geometry, so it runs whenever the structure is trustworthy (the
// catalog gate below only guards constraint *evaluation*).
let cast = if structural_valid {
Some(casting::cast_off(
input,
&spaced_glyphs,
&spaced_strokes,
&spaced_curves,
&self.geometry,
))
} else {
None
};
let resolved_glyphs = spaced_glyphs.len();
// Evaluate every declared constraint, routed by its strength (Chapter 9
// §"Strength Levels"): geometric constraints against the *pre-casting*
// spaced geometry (their frame of expression — casting-off relocates
// whole systems rigidly), break constraints against the final break
// structure. A violated `Required` constraint is unsatisfied (the solve
// is `Unsatisfiable`); a violated `Preferred` one is a soft-violation
// *warning*, never a failure. A structurally invalid or bad-catalog
// input is not evaluated — there is no trustworthy geometry — so it
// reports no evaluation work.
let (evaluation, constraints_evaluated) = if structural_valid && catalog_valid {
let cast = cast
.as_ref()
.expect("casting ran on structurally valid input");
(
evaluate_constraints(
&input.constraints,
&spaced_glyphs,
&BreakOutcome {
system_starts: &cast.system_start_slots,
page_starts: &cast.page_start_slots,
},
),
input.constraints.len() as u64,
)
} else {
(ConstraintEvaluation::not_evaluated(), 0)
};
let ConstraintEvaluation {
required_satisfied,
unsatisfied: unsatisfied_constraints,
soft_violations,
} = evaluation;
let well_formed = structural_valid && catalog_valid && required_satisfied;
// Distinguish a *malformed/unusable* input (InternalError — a structural or
// catalog defect the solver cannot proceed past) from a *valid problem
// whose declared hard constraints cannot all be satisfied* (Unsatisfiable),
// per the solver-report contract (Chapter 9 §"The Solver Report"). Hard
// constraints all satisfied but soft ones violated is a valid layout
// worth flagging: SolvedWithWarnings.
let status = if !structural_valid || !catalog_valid {
SolveStatus::InternalError
} else if !required_satisfied {
SolveStatus::Unsatisfiable
} else if !soft_violations.is_empty() {
SolveStatus::SolvedWithWarnings
} else {
SolveStatus::Solved
};
let mut warnings = soft_violations;
if structural_valid && catalog_valid && !unsatisfied_constraints.is_empty() {
warnings.push(SolverWarning {
kind: SolverWarningKind::UnusualLayoutDecision(
"one or more declared hard constraints are not satisfied by this \
Minimal solve (e.g. an unverifiable extension constraint, or \
colliding geometry the spacing pass cannot separate); see \
unsatisfied_constraints"
.to_owned(),
),
affected_objects: Vec::new(),
message: "declared hard constraints unsatisfied".to_owned(),
});
}
// The quality-metric census (Quality Metric Catalog): measured whenever
// the geometry is trustworthy — structure valid (the cast ran) and the
// catalog identity genuine (the glyph boxes the census sweeps are the
// real bundled metrics). A malformed input keeps the all-worst
// unmeasured placeholder: there is nothing honest to measure. The
// floor warnings reference the threshold column the config's profile
// selects (Draft -> Minimal, Standard/Publication -> Standard); per the
// catalog they are diagnostic and never change `status`, which was
// fixed above.
let metric_vector = match (&cast, catalog_valid) {
(Some(cast), true) => {
let vector = quality::measure(input, cast, &spaced_curves, &self.geometry);
warnings.extend(quality::floor_warnings(
&vector,
profile_thresholds(config.profile),
));
vector
}
_ => QualityMetricVector::unmeasured(),
};
// The final layout is the cast world frame: real pages and systems,
// glyph/stroke positions baked, the engraver's break decisions appended
// to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including
// any the solver itself made").
let (glyphs, strokes, curves, pages, engraving_decisions, unowned) = match cast {
Some(cast) => {
let mut decisions = input.engraving_decisions.clone();
decisions.extend(cast.decisions);
(
cast.glyphs,
cast.strokes,
cast.curves,
cast.pages,
decisions,
cast.unowned,
)
}
None => (
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
input.engraving_decisions.clone(),
PrimitiveIndices::default(),
),
};
SolveReport {
status,
satisfied_hard_constraints: well_formed,
layout: ResolvedLayoutIR {
source: input.source,
pages,
glyphs,
strokes,
curves,
engraving_decisions,
catalog: input.catalog.clone(),
unowned,
},
unsatisfied_constraints,
warnings,
// The real nine-axis census computed above (or the honest all-worst
// placeholder for a malformed input the solver could not measure).
metric_vector,
budget_used: SolverBudgetUsed {
// The horizontal pass and the casting-off walk each touch every
// slot once; report the spacing pass's touch honestly.
iterations: input.horizontal_slots.len() as u64,
nodes: resolved_glyphs as u64,
constraint_evaluations: constraints_evaluated,
wall_time_ms: 0,
},
state: SolverState {
solver_version: Some(self.version()),
resolved_glyphs,
},
}
}
}
/// A monotonic piecewise-linear map from a constrained x to its spaced x. Each
/// column's *source* x (the baseline its member glyphs share) maps to the
/// *target* x the spacing pass assigns its slot; intermediate and outlying
/// coordinates interpolate/extrapolate linearly. Applied to glyph baselines
/// **and** stroke endpoints alike, so a stroke at (or near) a glyph's column
/// moves with it instead of detaching — the fix for strokes being left at their
/// constrained coordinates while glyphs re-space.
struct HorizontalRemap {
/// `(source_x, target_x)` control points, sorted by source, sources distinct.
points: Vec<(f32, f32)>,
/// Each glyph-bearing slot's rigid translation (`target source`). A
/// glyph moves by **its own slot's** delta — never by interpolation, which
/// would drag a same-slot companion (a time-signature digit after its
/// barline, a key-signature accidental after the clef) by a neighbouring
/// interval whenever its absolute x crosses the next slot's source. The
/// spacing pass already reserved the companion's extent in the slot's
/// advance; the rigid delta is what honors that reservation.
slot_delta: BTreeMap<SpringSlotId, f32>,
}
impl HorizontalRemap {
fn build(input: &ConstrainedLayoutIR) -> Self {
// The control points are computed collision-aware (per-slot bearings) by
// the spacing pass; sources are globally monotonic because regions tile
// left-to-right.
let spaced = spacing::space_slots(input);
HorizontalRemap {
points: spaced.points,
slot_delta: spaced
.by_slot
.into_iter()
.map(|(id, (source, target))| (id, target - source))
.collect(),
}
}
/// Maps a constrained x to its spaced x.
fn map(&self, x: f32) -> f32 {
let p = &self.points;
match p.len() {
0 => x,
// One column: a pure translation keeps relative offsets.
1 => x + (p[0].1 - p[0].0),
n => {
if x <= p[0].0 {
interp(p[0], p[1], x)
} else if x >= p[n - 1].0 {
interp(p[n - 2], p[n - 1], x)
} else {
p.windows(2)
.find(|w| x >= w[0].0 && x <= w[1].0)
.map(|w| interp(w[0], w[1], x))
.unwrap_or(x)
}
}
}
}
/// Re-places each glyph by **its slot's rigid delta** (intra-slot offsets
/// preserved verbatim — see [`HorizontalRemap::slot_delta`]), baseline `y`
/// preserved; provenance, glyph identity, bounds, style, and layer carried
/// through. A glyph whose slot the spacing pass never placed
/// (out-of-pipeline input) falls back to the interpolated map.
fn glyphs(&self, input: &ConstrainedLayoutIR) -> Vec<ResolvedGlyph> {
input
.glyphs
.iter()
.map(|g| {
let x = match self.slot_delta.get(&g.horizontal_slot) {
Some(delta) => g.baseline.x.0 + delta,
None => self.map(g.baseline.x.0),
};
ResolvedGlyph {
provenance: g.provenance.clone(),
glyph: g.glyph.clone(),
position: Point::new(x, g.baseline.y.0),
transform: None,
bounding_box: g.bounding_box,
style: g.style,
layer: g.layer,
}
})
.collect()
}
/// Re-maps each stroke's endpoints so it tracks the glyphs it spans. A
/// system-spanning stroke (staff line, barline, …) maps both endpoints, so it
/// stretches with the spacing; a fixed-width stroke (a ledger line on one
/// notehead) is translated rigidly by its owning column's delta
/// ([`epiphany_layout_ir::is_rigid_width_stroke`]) — preserving both its length
/// and its offset from its glyph, which maps by that same delta at its column.
fn strokes(&self, input: &ConstrainedLayoutIR) -> Vec<Stroke> {
input
.strokes
.iter()
.map(|s| {
let (from_x, to_x) = if let Some(g) = component_glyph(s, &input.glyphs) {
// A per-event component stroke (a stem, a ledger) translates
// rigidly by its *owning glyph's* slot delta — found by
// source, not the stroke's own x (a stem sits offset from its
// column, so its midpoint could pick a neighbouring slot). The
// slot delta is the exact column translation the glyph itself
// moves by, so the stroke keeps its offset from its head and
// its length.
let delta = self
.slot_delta
.get(&g.horizontal_slot)
.copied()
.unwrap_or_else(|| self.map(g.baseline.x.0) - g.baseline.x.0);
(s.from.x.0 + delta, s.to.x.0 + delta)
} else {
(self.map(s.from.x.0), self.map(s.to.x.0))
};
Stroke {
provenance: s.provenance.clone(),
from: Point::new(from_x, s.from.y.0),
to: Point::new(to_x, s.to.y.0),
thickness: s.thickness,
layer: s.layer,
style: s.style,
vertical_band: s.vertical_band,
}
})
.collect()
}
/// Re-maps each curve's four control-point x's through the same coordinate
/// map as a spanning stroke's endpoints (a slur is never rigid-width), so
/// the arc stretches with the spacing between its endpoint columns. Each
/// control point's y is preserved verbatim.
fn curves(&self, input: &ConstrainedLayoutIR) -> Vec<Curve> {
input
.curves
.iter()
.map(|c| {
let map_x = |point: Point| Point::new(self.map(point.x.0), point.y.0);
Curve {
provenance: c.provenance.clone(),
p0: map_x(c.p0),
p1: map_x(c.p1),
p2: map_x(c.p2),
p3: map_x(c.p3),
thickness: c.thickness,
layer: c.layer,
style: c.style,
line: c.line,
vertical_band: c.vertical_band,
}
})
.collect()
}
}
/// Linear interpolation/extrapolation through two control points.
fn interp((s0, t0): (f32, f32), (s1, t1): (f32, f32), x: f32) -> f32 {
if (s1 - s0).abs() < f32::EPSILON {
t0
} else {
t0 + (x - s0) * (t1 - t0) / (s1 - s0)
}
}
/// What evaluating the declared constraints found, routed by strength: whether
/// every `Required` constraint held, the ids of those that did not, and a
/// soft-violation warning per unhonoured `Preferred` constraint.
struct ConstraintEvaluation {
required_satisfied: bool,
unsatisfied: Vec<ConstraintId>,
soft_violations: Vec<SolverWarning>,
}
impl ConstraintEvaluation {
/// The result for an input that was never evaluated (malformed structure or
/// catalog): nothing is claimed satisfied, nothing is named unsatisfied.
fn not_evaluated() -> Self {
ConstraintEvaluation {
required_satisfied: false,
unsatisfied: Vec::new(),
soft_violations: Vec::new(),
}
}
}
/// The break structure the casting-off pass produced, for constraint
/// evaluation: the slots at which the final layout starts a system, and the
/// subset at which it starts a page.
struct BreakOutcome<'a> {
system_starts: &'a BTreeSet<SpringSlotId>,
page_starts: &'a BTreeSet<SpringSlotId>,
}
/// Evaluates the IR's declared constraints — a constraint's id is its index in
/// the IR's constraint list — routing each violation by
/// [`LayoutConstraint::strength`] (Chapter 9 §"Strength Levels"): a violated
/// `Required` constraint is reported unsatisfied, a violated `Preferred` one
/// becomes a [`SolverWarningKind::LargeSoftConstraintViolation`] warning and
/// never fails the solve.
///
/// Geometric constraints (no-collision, alignment, position-within) are checked
/// against the **pre-casting spaced** glyph boxes — the frame the constraints
/// are expressed in; casting-off then relocates whole systems by rigid motions
/// (see `DECISIONS.md`, frame of evaluation). Break constraints are checked
/// against the **cast break structure**: `SystemBreakAt`/`PageBreakAt` is
/// satisfied iff the final layout starts a system/page at that slot — so a hard
/// break is `Unsatisfiable` only if casting-off failed to honour it (which
/// cannot happen for a feasible, structurally valid input), and a soft break is
/// a warning exactly when it was skipped on the documented pathological path.
/// An extension `Registered` constraint this solver cannot interpret is not
/// claimed satisfied (Chapter 7 §"Behavior Under Unknown Extensions":
/// conservative).
fn evaluate_constraints(
constraints: &[LayoutConstraint],
glyphs: &[ResolvedGlyph],
breaks: &BreakOutcome,
) -> ConstraintEvaluation {
let by_id: BTreeMap<GlyphObjectId, &ResolvedGlyph> = glyphs
.iter()
.map(|g| (GlyphObjectId(g.provenance.stable_id.0), g))
.collect();
let mut unsatisfied = Vec::new();
let mut soft_violations = Vec::new();
for (index, constraint) in constraints.iter().enumerate() {
let holds = match constraint {
LayoutConstraint::NoCollision { a, b } => match (by_id.get(a), by_id.get(b)) {
(Some(a), Some(b)) => !overlaps(a, b),
// A referenced glyph was dropped (a diagnostic layout): not claimed.
_ => false,
},
LayoutConstraint::Align { a, b, axis } => match (by_id.get(a), by_id.get(b)) {
(Some(a), Some(b)) => aligned(a, b, *axis),
_ => false,
},
LayoutConstraint::PositionWithin { glyph, region } => match by_id.get(glyph) {
Some(g) => within(g, region),
None => false,
},
LayoutConstraint::SystemBreakAt { slot, .. } => breaks.system_starts.contains(slot),
LayoutConstraint::PageBreakAt { slot, .. } => breaks.page_starts.contains(slot),
LayoutConstraint::Registered(_, _) => false,
};
if holds {
continue;
}
let id = ConstraintId(index as u128);
match constraint.strength() {
ConstraintStrength::Required => unsatisfied.push(id),
ConstraintStrength::Preferred { .. } => soft_violations.push(SolverWarning {
kind: SolverWarningKind::LargeSoftConstraintViolation {
constraint: id,
// A break preference is binary — honoured or not — so an
// unhonoured one is a full (1.0) violation.
magnitude: 1.0,
},
affected_objects: Vec::new(),
message: "a preferred (soft) break is not honoured by this solve \
(skipped on the pathological-system path; an IrOverride \
decision records it)"
.to_owned(),
}),
}
}
ConstraintEvaluation {
required_satisfied: unsatisfied.is_empty(),
unsatisfied,
soft_violations,
}
}
/// A resolved glyph's absolute bounding box `[left, bottom, right, top]`.
fn abs_box(g: &ResolvedGlyph) -> [f32; 4] {
[
g.position.x.0 + g.bounding_box.left.0,
g.position.y.0 + g.bounding_box.bottom.0,
g.position.x.0 + g.bounding_box.right.0,
g.position.y.0 + g.bounding_box.top.0,
]
}
/// Whether two glyphs' boxes overlap (touching edges do not count).
fn overlaps(a: &ResolvedGlyph, b: &ResolvedGlyph) -> bool {
let [al, ab, ar, at] = abs_box(a);
let [bl, bb, br, bt] = abs_box(b);
ar > bl && br > al && at > bb && bt > ab
}
/// Whether two glyphs are aligned along `axis`: a common horizontal line (equal
/// baseline y) for `Horizontal`, a common vertical line (equal x) for `Vertical`.
/// (The spec leaves the axis sense to the solver; this is the chosen convention.)
fn aligned(a: &ResolvedGlyph, b: &ResolvedGlyph, axis: Axis) -> bool {
const EPS: f32 = 1e-3;
match axis {
Axis::Horizontal => (a.position.y.0 - b.position.y.0).abs() < EPS,
Axis::Vertical => (a.position.x.0 - b.position.x.0).abs() < EPS,
}
}
/// Whether a glyph's box lies within a region rectangle (inclusive, with a small
/// tolerance for quantization).
fn within(g: &ResolvedGlyph, region: &Rect) -> bool {
const EPS: f32 = 1e-3;
let [l, b, r, t] = abs_box(g);
let rl = region.origin.x.0;
let rb = region.origin.y.0;
let rr = rl + region.size.width.0;
let rt = rb + region.size.height.0;
l >= rl - EPS && b >= rb - EPS && r <= rr + EPS && t <= rt + EPS
}
impl ConstraintSolver for Engraver {
fn tier(&self) -> SolverTier {
// Minimal (Chapter 9): it evaluates and satisfies the IR's declared hard
// constraints, reporting honestly which (if any) it cannot, and computes
// real quality-metric vectors per the Quality Metric Catalog — accurate
// reports being part of the Minimal claim. `Minimal` still makes no
// optimality claim (greedy first-fit casting-off is legitimate here);
// the Standard tier's tighter thresholds are not claimed.
SolverTier::Minimal
}
fn version(&self) -> SolverVersion {
ENGRAVER_VERSION
}
fn solve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport {
self.resolve(input, config)
}
fn solve_incremental(
&self,
input: &ConstrainedLayoutIR,
_prior: &SolverState,
_invalidations: &InvalidationSet,
config: &SolverConfig,
) -> SolveReport {
// The scaffold recomputes spacing from scratch, which is trivially
// observationally equivalent to a scoped incremental solve (Chapter 9
// §"Observational Equivalence"). Real incremental scoping is Minimal-tier
// work.
self.resolve(input, config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::generators::valid_score_rich;
use epiphany_layout_ir::{is_rigid_width_stroke, to_constrained, to_logical, StubSolver};
fn fixture() -> ConstrainedLayoutIR {
to_constrained(&to_logical(&valid_score_rich(11)))
}
#[test]
fn reports_the_minimal_tier_it_has_earned() {
use epiphany_layout_ir::{MINIMAL_THRESHOLDS, QUALITY_METRIC_KINDS};
// It evaluates the declared hard constraints, so it reports Minimal — above
// the interface-only stub, below the tighter-threshold Standard tier.
assert_eq!(Engraver::default().tier(), SolverTier::Minimal);
assert!(Engraver::default().tier() > StubSolver.tier());
assert!(Engraver::default().tier() < SolverTier::Standard);
// Accurate metric vectors are part of the Minimal claim (Chapter 9;
// Quality Metric Catalog): the vector is *real* — never the all-worst
// unmeasured placeholder — collision-free on this clean fixture, and
// every axis is a valid NormalizedMetric within the catalog's Minimal
// threshold column (the fixture's three regions each cast onto a
// single system, so the break-family axes degenerate to exactly 0.0
// under the vacuous-geometry rule).
let report = Engraver::default().solve(&fixture(), &SolverConfig::default());
assert_ne!(report.metric_vector, QualityMetricVector::unmeasured());
assert_eq!(report.metric_vector.collision_penalty.0, 0.0);
for kind in QUALITY_METRIC_KINDS {
let value = report.metric_vector.axis(kind).0;
assert!(
value.is_finite() && (0.0..=1.0).contains(&value),
"{kind:?}"
);
assert!(
value <= MINIMAL_THRESHOLDS.axis(kind),
"{kind:?} = {value} exceeds its Minimal threshold"
);
}
assert_eq!(Engraver::default().version(), ENGRAVER_VERSION);
assert_ne!(Engraver::default().version(), StubSolver.version());
}
/// Builds a tiny valid constrained IR — a clef and a single note — and lets
/// the caller add constraints over its glyphs.
fn with_constraints(
constraints: impl FnOnce(&ConstrainedLayoutIR) -> Vec<epiphany_layout_ir::LayoutConstraint>,
) -> ConstrainedLayoutIR {
use epiphany_core::{
CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, RegionId, StaffId,
StaffInstanceId, TypedObjectId,
};
use epiphany_layout_ir::{
LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR,
MetricTimeAxis, NoteContent, NotePitch, Provenance, ScoreVersion, StaffContent,
TimeAxisModel, TimePoint, VerticalExtent,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch = PitchId::from_raw(100);
let manifested = |src, content| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: VerticalExtent {
staves: vec![staff],
},
objects: vec![
manifested(
TypedObjectId::StaffInstance(StaffInstanceId::from_raw(1)),
LayoutContent::Staff(StaffContent {
default_clef: epiphany_core::Clef::default(),
clefs: vec![],
keys: vec![],
}),
),
manifested(
TypedObjectId::Event(EventId::from_raw(1)),
LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![NotePitch {
pitch,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 5)),
}],
}),
),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let mut c = to_constrained(&logical);
c.constraints = constraints(&c);
c
}
#[test]
fn the_pipelines_emitted_constraints_are_satisfied() {
// The spacing stage now emits real constraints (no-collision chains,
// per-glyph containment) — this is *not* a vacuous empty-set solve. The
// collision-aware spacing satisfies every one of them, and the solve
// honestly reports the evaluation work it did.
let input = fixture();
assert!(
!input.constraints.is_empty(),
"the pipeline declares real constraints"
);
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert_eq!(
report.budget_used.constraint_evaluations,
input.constraints.len() as u64
);
}
#[test]
fn a_satisfied_no_collision_constraint_solves() {
use epiphany_layout_ir::LayoutConstraint;
// The clef and the notehead are in different columns, so they do not
// collide; a NoCollision over them is satisfied.
let input = with_constraints(|c| {
let clef = c
.glyphs
.iter()
.find(|g| g.glyph.as_str() == "gClef")
.unwrap()
.id();
let head = c
.glyphs
.iter()
.find(|g| g.glyph.as_str().starts_with("notehead"))
.unwrap()
.id();
vec![LayoutConstraint::NoCollision { a: clef, b: head }]
});
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert_eq!(report.budget_used.constraint_evaluations, 1);
}
#[test]
fn a_violated_no_collision_is_reported_not_falsely_solved() {
use epiphany_layout_ir::LayoutConstraint;
// A glyph trivially collides with itself; NoCollision(g, g) is unsatisfiable
// and must be reported, not silently accepted.
let input = with_constraints(|c| {
let g = c
.glyphs
.iter()
.find(|g| g.glyph.as_str().starts_with("notehead"))
.unwrap()
.id();
vec![LayoutConstraint::NoCollision { a: g, b: g }]
});
let report = Engraver::default().solve(&input, &SolverConfig::default());
// A valid problem whose hard constraint cannot be met is Unsatisfiable,
// not an InternalError (which is reserved for solver/structure failures).
assert_eq!(report.status, SolveStatus::Unsatisfiable);
assert!(!report.satisfied_hard_constraints);
assert_eq!(report.unsatisfied_constraints.len(), 1);
assert_eq!(report.budget_used.constraint_evaluations, 1);
}
/// Total systems across all pages of a resolved layout.
fn system_count(layout: &ResolvedLayoutIR) -> usize {
layout.pages.iter().map(|p| p.systems.len()).sum()
}
#[test]
fn a_hard_break_is_honoured_by_casting_off() {
use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind};
// Inverse of the pre-casting-off pin (`a_hard_break_cannot_be_honoured_
// by_single_system_minimal`): a hard break maps to
// ConstraintStrength::Required, and the casting-off pass ALWAYS breaks
// at it — even though that leaves a clef-only first system — so the
// solve is Solved and the system count increases.
let baseline =
Engraver::default().solve(&with_constraints(|_| vec![]), &SolverConfig::default());
assert_eq!(baseline.status, SolveStatus::Solved);
assert_eq!(system_count(&baseline.layout), 1);
let input = with_constraints(|c| {
// Slot 0 is the clef lead (trivially at a boundary); the note
// column is the non-trivial break target.
let slot = c.horizontal_slots[1].id;
vec![LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Hard,
}]
});
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert_eq!(
system_count(&report.layout),
2,
"the hard break splits the line into two systems"
);
// The chosen break is recorded as an engraved decision; no user
// override projected this constraint, so it is attributed Automatic.
assert!(report
.layout
.engraving_decisions
.iter()
.any(|d| d.kind == EngravingDecisionKind::SystemBreak
&& d.source == DecisionSource::Automatic));
}
#[test]
fn a_soft_break_with_content_before_it_is_honoured() {
use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind};
// A soft break whose closing system carries musical content is simply
// honoured: a clean Solved two-system layout, no soft-violation
// warning, and an Automatic engraved decision.
let mut input = two_off_staff_whole_notes();
let slot = input.horizontal_slots[2].id; // the second note column
input.constraints.push(LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Soft,
});
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
// The honoured break is never reported as a soft violation. (The
// report legitimately carries QualityFloorApproached diagnostics: this
// two-note micro-score casts off into wildly uneven system widths,
// which the casting-off axis honestly measures — quality warnings are
// diagnostic and, per the catalog, never change the status.)
assert!(
!report.warnings.iter().any(|w| matches!(
w.kind,
SolverWarningKind::LargeSoftConstraintViolation { .. }
)),
"an honoured break must not surface as a soft violation: {:?}",
report.warnings
);
assert!(report.satisfied_hard_constraints);
assert_eq!(system_count(&report.layout), 2);
assert!(report
.layout
.engraving_decisions
.iter()
.any(|d| d.kind == EngravingDecisionKind::SystemBreak
&& d.source == DecisionSource::Automatic));
}
#[test]
fn a_pathological_soft_break_is_skipped_and_recorded_as_ir_override() {
use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind};
// A soft break at the first note column would close a system containing
// only the clef — no musical content. The documented exceptional path
// skips it: still renderable (a Preferred violation is a warning, never
// a failure), the constraint is reported as a soft violation, and the
// unhonoured preference is recorded as an IrOverride-sourced decision
// (the spec's override-resolution rule: record, don't drop).
let input = with_constraints(|c| {
let slot = c.horizontal_slots[1].id;
vec![LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Soft,
}]
});
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::SolvedWithWarnings);
assert!(report.status.is_renderable());
assert!(
report.satisfied_hard_constraints,
"a soft-break violation must not flip hard-constraint satisfaction"
);
assert!(report.unsatisfied_constraints.is_empty());
assert!(report.warnings.iter().any(|w| matches!(
w.kind,
SolverWarningKind::LargeSoftConstraintViolation {
constraint: ConstraintId(0),
magnitude,
} if magnitude == 1.0
)));
assert_eq!(
system_count(&report.layout),
1,
"the pathological break was skipped, not honoured"
);
assert!(report
.layout
.engraving_decisions
.iter()
.any(|d| d.kind == EngravingDecisionKind::SystemBreak
&& d.source == DecisionSource::IrOverride));
}
#[test]
fn a_users_break_is_honoured_and_recorded_with_its_override() {
// Inverse of the pre-casting-off pin (`a_users_break_flows_to_a_soft_
// violation_not_a_failure`). End to end: a user system break on the
// score graph projects through the logical stage's break override into
// a Soft break constraint, which casting-off HONOURS — the anchored
// column starts a new system at the left margin, the solve is clean
// (Solved, no warnings), and the engraved decision cites the user's
// override id (DecisionSource::UserOverride).
use epiphany_core::generators::valid_score;
use epiphany_core::{AnchorOffset, Event, EventPosition, TimeAnchor};
use epiphany_layout_ir::{DecisionSource, EngravingDecisionKind};
let mut score = valid_score(3);
// The latest pitched onset: a mid-region break target, so the closing
// system carries musical content (the honoured, non-pathological path).
let event = score.canvas.regions[0]
.staff_instances()
.iter()
.flat_map(|si| si.voices.iter())
.flat_map(|voice| voice.events.iter().copied())
.filter(|eid| {
matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty())
})
.max_by_key(|eid| match score.events.get(*eid).map(|e| e.position()) {
Some(EventPosition::Musical(p)) => Some(p.clone()),
_ => None,
})
.expect("valid_score has a pitched event");
score.canvas.regions[0]
.content
.staff_based_mut()
.expect("valid_score is staff based")
.user_system_breaks
.push(TimeAnchor::Event {
id: event,
offset: AnchorOffset::Zero,
});
let constrained = to_constrained(&to_logical(&score));
let break_slot = constrained
.constraints
.iter()
.find_map(|c| match c {
LayoutConstraint::SystemBreakAt { slot, .. } => Some(*slot),
_ => None,
})
.expect("the user break projects into a break constraint");
let origin = constrained
.break_origins
.iter()
.find(|o| o.slot == break_slot)
.expect("the projection records the override attribution");
let engraver = Engraver::default();
let report = engraver.solve(&constrained, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
// An honoured break never warns *about the break* (no soft violation).
// The report may carry QualityFloorApproached diagnostics — this
// few-note score's user break honestly leaves a stub last system,
// which the casting-off axis measures; quality warnings never change
// the status per the catalog.
assert!(
!report.warnings.iter().any(|w| matches!(
w.kind,
SolverWarningKind::LargeSoftConstraintViolation { .. }
)),
"an honoured break never surfaces as a soft violation: {:?}",
report.warnings
);
assert!(report.satisfied_hard_constraints);
assert!(report.unsatisfied_constraints.is_empty());
assert!(
system_count(&report.layout) >= 2,
"the honoured break increases the system count"
);
// The break lands at the anchor's column: the anchored slot's glyphs
// now start their system at the page's left content edge (up to the
// ledger-line extension, 0.3 staff spaces, which also participates in
// the system's extent and may sit left of the notehead box).
let left_edge = report
.layout
.glyphs
.iter()
.zip(&constrained.glyphs)
.filter(|(_, c)| c.horizontal_slot == break_slot)
.map(|(r, c)| r.position.x.0 + c.bounding_box.left.0)
.fold(f32::INFINITY, f32::min);
let margin = engraver.geometry().margins.left.0;
assert!(
left_edge >= margin - 1e-3 && left_edge <= margin + 0.5,
"the anchored column starts its system at the left margin \
(edge {left_edge}, margin {margin})"
);
// The decision record cites the user's override.
assert!(report
.layout
.engraving_decisions
.iter()
.any(|d| d.kind == EngravingDecisionKind::SystemBreak
&& d.source == DecisionSource::UserOverride(origin.override_id)));
}
#[test]
fn ledger_lines_keep_their_width_through_the_spacing_pass() {
// A corpus score with off-staff notes yields ledger strokes; the horizontal
// spacing pass must translate them (preserving length), not re-map both
// endpoints — which would scale a fixed-width mark with the local spacing.
let mut checked = 0;
for seed in 0..16 {
let input = to_constrained(&to_logical(&valid_score_rich(seed)));
let widths: std::collections::HashMap<u128, f32> = input
.strokes
.iter()
.filter(|s| is_rigid_width_stroke(s))
.map(|s| (s.provenance.stable_id.0, s.to.x.0 - s.from.x.0))
.collect();
if widths.is_empty() {
continue;
}
let report = Engraver::default().solve(&input, &SolverConfig::default());
for s in report
.layout
.strokes
.iter()
.filter(|s| is_rigid_width_stroke(s))
{
if let Some(&w_in) = widths.get(&s.provenance.stable_id.0) {
let w_out = s.to.x.0 - s.from.x.0;
assert!(
(w_out - w_in).abs() < 1e-4,
"ledger width changed through spacing: {w_in} -> {w_out}"
);
checked += 1;
}
}
}
assert!(checked > 0, "no ledger strokes exercised across 16 seeds");
}
/// Two whole notes (wide heads) a step above the staff, in adjacent time
/// columns — the case where a ledger's own midpoint can fall nearer the next
/// column than its notehead's.
fn two_off_staff_whole_notes() -> ConstrainedLayoutIR {
use epiphany_core::{
CmnNominal, EventId, MusicalDuration, MusicalPosition, NotatedComponent, NoteValue,
PitchId, PitchSpelling, RationalTime, RegionId, StaffId, StaffInstanceId,
TypedObjectId,
};
use epiphany_layout_ir::{
LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR,
MetricTimeAxis, NoteContent, NotePitch, PlacedComponent, Provenance, ScoreVersion,
StaffContent, TimeAxisModel, TimePoint, VerticalExtent,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let manifested = |src, content| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
let whole = || {
vec![PlacedComponent {
offset: MusicalDuration::zero(),
component: NotatedComponent {
base_value: NoteValue::Whole,
dots: 0,
tuplet: None,
tied_to_next: false,
},
tuplet: None,
}]
};
let note = |eid: u128, pid: u128, pos: MusicalPosition| {
let pitch = PitchId::from_raw(pid);
[
manifested(
TypedObjectId::Event(EventId::from_raw(eid)),
LayoutContent::Note(NoteContent {
position: TimePoint::Musical(pos),
components: whole(),
// C6 is a step above the treble staff, so each head earns
// ledger lines.
pitches: vec![NotePitch {
pitch,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 6)),
}],
}),
),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
]
};
let mut objects = vec![manifested(
TypedObjectId::StaffInstance(StaffInstanceId::from_raw(1)),
LayoutContent::Staff(StaffContent {
default_clef: epiphany_core::Clef::default(),
clefs: vec![],
keys: vec![],
}),
)];
objects.extend(note(1, 101, MusicalPosition::origin()));
objects.extend(note(
2,
102,
MusicalPosition(RationalTime::new(1, 1).unwrap()),
));
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: VerticalExtent {
staves: vec![staff],
},
objects,
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
to_constrained(&logical)
}
#[test]
fn an_off_staff_whole_note_ledger_does_not_drift() {
let input = two_off_staff_whole_notes();
assert!(
input
.glyphs
.iter()
.any(|g| g.glyph.as_str() == "noteheadWhole"),
"the fixture engraves whole notes"
);
let ledger_count = input
.strokes
.iter()
.filter(|s| is_rigid_width_stroke(s))
.count();
assert!(
ledger_count >= 2,
"off-staff whole notes earn ledger strokes"
);
let report = Engraver::default().solve(&input, &SolverConfig::default());
// The wide whole-note columns re-space (their deltas differ), so a midpoint-
// anchored ledger would translate by a neighbouring column's delta and drift.
// The owning-glyph anchor keeps every ledger at its notehead's offset.
for s_in in input.strokes.iter().filter(|s| is_rigid_width_stroke(s)) {
let g_in = owning_glyph(s_in, &input.glyphs).expect("owning notehead");
let s_out = report
.layout
.strokes
.iter()
.find(|s| s.provenance.stable_id == s_in.provenance.stable_id)
.expect("stroke survives");
let g_out = report
.layout
.glyphs
.iter()
.find(|g| g.provenance.stable_id == g_in.provenance.stable_id)
.expect("glyph survives");
let offset_in = s_in.from.x.0 - g_in.baseline.x.0;
let offset_out = s_out.from.x.0 - g_out.position.x.0;
assert!(
(offset_out - offset_in).abs() < 1e-4,
"whole-note ledger drifted {offset_in} -> {offset_out}"
);
}
}
#[test]
fn ledger_offsets_from_the_notehead_survive_the_engraver() {
// The column-delta translation keeps a ledger at exactly the same offset from
// its notehead through the spacing pass; interpolating its midpoint (the bug
// this replaced) would shift it under a non-unit local slope.
let mut checked = 0;
for seed in 0..16 {
let input = to_constrained(&to_logical(&valid_score_rich(seed)));
let report = Engraver::default().solve(&input, &SolverConfig::default());
for s_in in input.strokes.iter().filter(|s| is_rigid_width_stroke(s)) {
let lo = s_in.from.x.0.min(s_in.to.x.0);
let hi = s_in.from.x.0.max(s_in.to.x.0);
let Some(g_in) = input.glyphs.iter().find(|g| {
g.provenance.source == s_in.provenance.source
&& g.baseline.x.0 >= lo
&& g.baseline.x.0 <= hi
}) else {
continue;
};
let Some(s_out) = report
.layout
.strokes
.iter()
.find(|s| s.provenance.stable_id == s_in.provenance.stable_id)
else {
continue;
};
let Some(g_out) = report
.layout
.glyphs
.iter()
.find(|g| g.provenance.stable_id == g_in.provenance.stable_id)
else {
continue;
};
let offset_in = s_in.from.x.0 - g_in.baseline.x.0;
let offset_out = s_out.from.x.0 - g_out.position.x.0;
assert!(
(offset_out - offset_in).abs() < 1e-4,
"seed {seed}: ledger offset drifted {offset_in} -> {offset_out}"
);
checked += 1;
}
}
assert!(checked > 0, "no ledger/notehead pairs exercised");
}
#[test]
fn stem_offsets_from_the_notehead_survive_justification() {
// A stem is `Event`-sourced with no same-source glyph, so it tracks its
// column via `component_glyph`'s nearest-left notehead. Its offset from
// that column must survive the FULL solve — spacing AND per-system
// justification — so a stem in a stretched non-final system stays
// attached to its head rather than being dragged into the gap (the
// review's severe finding). Checked across seeds that wrap (justify).
let mut checked = 0;
for seed in 0..16 {
let input = to_constrained(&to_logical(&valid_score_rich(seed)));
let report = Engraver::default().solve(&input, &SolverConfig::default());
for s_in in &input.strokes {
// Vertical, non-zero-length strokes are drawn stems.
if (s_in.from.x.0 - s_in.to.x.0).abs() > 1e-4
|| (s_in.from.y.0 - s_in.to.y.0).abs() < 1e-3
{
continue;
}
let Some(owner_in) = component_glyph(s_in, &input.glyphs) else {
continue;
};
// A stem's owner is found by nearest-left, NOT source match
// (that path is the ledger case); skip anything same-source.
if owner_in.provenance.source == s_in.provenance.source {
continue;
}
let (Some(s_out), Some(g_out)) = (
report
.layout
.strokes
.iter()
.find(|s| s.provenance.stable_id == s_in.provenance.stable_id),
report
.layout
.glyphs
.iter()
.find(|g| g.provenance.stable_id == owner_in.provenance.stable_id),
) else {
continue;
};
let offset_in = s_in.from.x.0 - owner_in.baseline.x.0;
let offset_out = s_out.from.x.0 - g_out.position.x.0;
assert!(
(offset_out - offset_in).abs() < 1e-3,
"seed {seed}: stem offset drifted {offset_in} -> {offset_out} \
(justification scaled it off its notehead)"
);
checked += 1;
}
}
assert!(checked > 0, "no stem/notehead pairs exercised");
}
#[test]
fn adjacent_ledger_lines_are_spaced_not_overlapping() {
use std::collections::HashMap;
// The spacing pass reserves room for ledger overhang, so two off-staff notes
// that share a ledger height (same step) and sit in neighbouring columns get
// ledger strokes that do not overlap.
let mut ledgers = 0;
let mut pairs = 0;
for seed in 0..32 {
let report = Engraver::default().solve(
&to_constrained(&to_logical(&valid_score_rich(seed))),
&SolverConfig::default(),
);
let mut by_height: HashMap<i64, Vec<(f32, f32, _)>> = HashMap::new();
for s in report
.layout
.strokes
.iter()
.filter(|s| is_rigid_width_stroke(s))
{
ledgers += 1;
let y = (s.from.y.0 * 1024.0).round() as i64;
let (lo, hi) = (s.from.x.0.min(s.to.x.0), s.from.x.0.max(s.to.x.0));
by_height
.entry(y)
.or_default()
.push((lo, hi, s.provenance.source));
}
for group in by_height.values_mut() {
group.sort_by(|a, b| a.0.total_cmp(&b.0));
for w in group.windows(2) {
// Distinct notes' ledgers at the same height must not overlap.
if w[0].2 != w[1].2 {
pairs += 1;
assert!(
w[0].1 <= w[1].0 + 1e-3,
"seed {seed}: ledger lines overlap ({:?} vs {:?})",
w[0],
w[1]
);
}
}
}
}
assert!(ledgers > 0, "the corpus exercised no ledger lines");
assert!(pairs > 0, "no adjacent same-height ledger pairs to check");
}
#[test]
fn solves_the_stub_pipeline_and_preserves_provenance() {
let input = fixture();
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved);
assert!(report.satisfied_hard_constraints);
assert_eq!(report.layout.glyphs.len(), input.glyphs.len());
// Every input glyph's provenance survives, one-for-one.
for (resolved, original) in report.layout.glyphs.iter().zip(&input.glyphs) {
assert_eq!(resolved.provenance, original.provenance);
assert_eq!(resolved.glyph, original.glyph);
}
// The metric vector is real — computed per the Quality Metric Catalog,
// never the all-worst placeholder — and this clean pipeline fixture is
// collision-free under the full same-system census.
assert_ne!(report.metric_vector, QualityMetricVector::unmeasured());
assert_eq!(report.metric_vector.collision_penalty.0, 0.0);
}
#[test]
fn a_structurally_invalid_input_emits_no_strokes() {
// Strokes are gated on the same structural validity as glyphs: an input
// whose validation fails (here, an out-of-range stroke thickness) yields a
// diagnostic layout with no glyphs *and* no strokes — the malformed stroke
// must not leak into canonical_bytes or the renderer.
let mut input = fixture();
let provenance = input.glyphs[0].provenance.clone();
let band = input.glyphs[0].vertical_band;
input.strokes.push(epiphany_layout_ir::Stroke {
provenance,
vertical_band: band,
from: epiphany_layout_ir::Point::new(0.0, 0.0),
to: epiphany_layout_ir::Point::new(1.0, 0.0),
thickness: epiphany_layout_ir::StaffSpace(f32::MAX),
layer: 0,
style: epiphany_layout_ir::GlyphStyle::default(),
});
// Declare a constraint too: a malformed input is *not* evaluated, so the
// report must claim zero constraint evaluations (not work it never did).
let g = input.glyphs[0].id();
input
.constraints
.push(epiphany_layout_ir::LayoutConstraint::NoCollision { a: g, b: g });
assert!(
input.validate().is_err(),
"the out-of-range stroke is invalid"
);
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::InternalError);
assert!(report.layout.glyphs.is_empty());
assert!(
report.layout.strokes.is_empty(),
"a structurally invalid input emits no strokes (gated like glyphs)"
);
assert_eq!(
report.budget_used.constraint_evaluations, 0,
"no constraints were evaluated on a malformed input"
);
}
#[test]
fn horizontal_spacing_differs_from_the_verbatim_stub() {
// The whole point of the scaffold: it re-spaces horizontally rather than
// echoing the input columns. With multiple glyphs the positions differ
// from the stub's verbatim baselines.
let input = fixture();
assert!(input.glyphs.len() >= 2);
let engraved = Engraver::default()
.solve(&input, &SolverConfig::default())
.layout;
let stub = StubSolver.solve(&input, &SolverConfig::default()).layout;
assert_ne!(
engraved
.glyphs
.iter()
.map(|g| g.position.x.0)
.collect::<Vec<_>>(),
stub.glyphs
.iter()
.map(|g| g.position.x.0)
.collect::<Vec<_>>(),
"the engrave pass must re-space, not echo the stub's columns"
);
// ...but it preserves the same glyph set and order.
assert_eq!(engraved.glyphs.len(), stub.glyphs.len());
}
#[test]
fn strokes_ride_the_same_coordinate_map_as_glyphs() {
// The spacing pass re-places glyphs; the strokes that track them must move
// by the same horizontal map, not stay at their constrained coordinates.
let input = fixture();
let engraved = Engraver::default()
.solve(&input, &SolverConfig::default())
.layout;
// Constrained x -> engraved x, per glyph.
let glyph_map: Vec<(f32, f32)> = input
.glyphs
.iter()
.zip(&engraved.glyphs)
.map(|(c, r)| (c.baseline.x.0, r.position.x.0))
.collect();
// Every stroke endpoint coincident with a glyph's column lands at that
// glyph's engraved x — they ride one map, so they stay attached.
let mut checked = 0;
for (c, r) in input.strokes.iter().zip(&engraved.strokes) {
for (gx, ex) in &glyph_map {
if (c.from.x.0 - gx).abs() < 1e-6 {
assert!(
(r.from.x.0 - ex).abs() < 1e-3,
"a stroke at a glyph's column detached from it after spacing"
);
checked += 1;
}
}
}
assert!(
checked > 0,
"expected strokes coincident with glyph columns"
);
// …and the strokes actually moved (the pass re-spaces, it does not echo).
let moved = input.strokes.iter().zip(&engraved.strokes).any(|(c, r)| {
(c.from.x.0 - r.from.x.0).abs() > 1e-4 || (c.to.x.0 - r.to.x.0).abs() > 1e-4
});
assert!(
moved,
"strokes must be re-spaced with the glyphs, not left behind"
);
}
#[test]
fn solve_is_deterministic_and_quantizable() {
let input = fixture();
let a = Engraver::default()
.solve(&input, &SolverConfig::default())
.layout;
let b = Engraver::default()
.solve(&input, &SolverConfig::default())
.layout;
// Byte-identical canonical output across solves (Chapter 9 determinism).
assert_eq!(a.canonical_bytes(), b.canonical_bytes());
}
#[test]
fn engraver_reserves_an_accidental_against_the_previous_note() {
// A note's accidental overhangs *left* of its notehead, into the previous
// note's column. The spacing pass must reserve that overhang (against the
// previous slot's advance), or the accidental overlaps the prior notehead.
use epiphany_core::{
AccidentalId, CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling,
RationalTime, RegionId, StaffId, TypedObjectId,
};
use epiphany_layout_ir::{
LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR,
MetricTimeAxis, NoteContent, NotePitch, Provenance, ScoreVersion, TimeAxisModel,
TimePoint, VerticalExtent,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let plain = PitchId::from_raw(100);
let sharped = PitchId::from_raw(101);
let manifested = |src, content| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
let at = |n, d| TimePoint::Musical(MusicalPosition(RationalTime::new(n, d).unwrap()));
let note = |pid: PitchId, time: TimePoint, accidental: bool| {
let mut spelling = PitchSpelling::cmn(CmnNominal::C, 5);
if accidental {
spelling.accidentals.push(AccidentalId::new("sharp"));
}
LayoutContent::Note(NoteContent {
position: time,
components: vec![],
pitches: vec![NotePitch {
pitch: pid,
spelling: Some(spelling),
}],
})
};
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: VerticalExtent {
staves: vec![staff],
},
objects: vec![
// A plain note, then a note with a sharp a quarter later.
manifested(
TypedObjectId::Event(EventId::from_raw(1)),
note(plain, at(0, 1), false),
),
manifested(TypedObjectId::Pitch(plain), LayoutContent::Structural),
manifested(
TypedObjectId::Event(EventId::from_raw(2)),
note(sharped, at(1, 4), true),
),
manifested(TypedObjectId::Pitch(sharped), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let constrained = to_constrained(&logical);
let engraved = Engraver::default()
.solve(&constrained, &SolverConfig::default())
.layout;
let mut noteheads: Vec<_> = engraved
.glyphs
.iter()
.filter(|g| g.glyph.as_str() == "noteheadBlack")
.collect();
noteheads.sort_by(|a, b| a.position.x.0.partial_cmp(&b.position.x.0).unwrap());
assert_eq!(noteheads.len(), 2, "two noteheads");
let first_right = noteheads[0].position.x.0 + noteheads[0].bounding_box.right.0;
let sharp = engraved
.glyphs
.iter()
.find(|g| g.glyph.as_str() == "accidentalSharp")
.expect("a sharp is drawn");
let sharp_left = sharp.position.x.0 + sharp.bounding_box.left.0;
assert!(
sharp_left >= first_right,
"the accidental ({sharp_left}) overlaps the previous notehead's right edge ({first_right})"
);
}
#[test]
fn engraver_preserves_key_signature_lead_spacing() {
// The lead area (clef + key signature) is fixed-width content. The spacing
// pass must reserve it via the lead slot's preferred width, or it compresses
// the key signature back onto the clef. This drives the *real* engraver, not
// just the verbatim stub.
use epiphany_core::{
CmnNominal, EventId, KeySignature, MusicalPosition, PitchId, PitchSpelling, RegionId,
StaffId, StaffInstanceId, TypedObjectId,
};
use epiphany_layout_ir::{
LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR,
MetricTimeAxis, NoteContent, NotePitch, PlacedKeySignature, Provenance, ScoreVersion,
StaffContent, TimeAxisModel, TimePoint, VerticalExtent,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch = PitchId::from_raw(100);
let manifested = |src, content| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: VerticalExtent {
staves: vec![staff],
},
objects: vec![
// A 3-sharp (A major) key signature, then a note.
manifested(
TypedObjectId::StaffInstance(StaffInstanceId::from_raw(1)),
LayoutContent::Staff(StaffContent {
default_clef: epiphany_core::Clef::default(),
clefs: vec![],
keys: vec![PlacedKeySignature {
time: TimePoint::Musical(MusicalPosition::origin()),
key: KeySignature::new(3).expect("three sharps"),
}],
}),
),
manifested(
TypedObjectId::Event(EventId::from_raw(1)),
LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![NotePitch {
pitch,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 5)),
}],
}),
),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let constrained = to_constrained(&logical);
let engraved = Engraver::default()
.solve(&constrained, &SolverConfig::default())
.layout;
let x_of = |name: &str| {
engraved
.glyphs
.iter()
.find(|g| g.glyph.as_str() == name)
.map(|g| g.position.x.0)
};
let clef_x = x_of("gClef").expect("clef engraved");
let note_x = x_of("noteheadBlack").expect("notehead engraved");
let sharps: Vec<f32> = engraved
.glyphs
.iter()
.filter(|g| g.glyph.as_str() == "accidentalSharp")
.map(|g| g.position.x.0)
.collect();
assert_eq!(sharps.len(), 3, "a three-sharp signature");
// Not compressed into the clef: the lead clearly exceeds one note slot.
assert!(
note_x - clef_x > 3.0,
"key signature compressed into the clef (lead width {})",
note_x - clef_x
);
// The accidentals sit in the lead (clef..note), spread to distinct x.
assert!(
sharps.iter().all(|&x| x > clef_x && x < note_x),
"accidentals must lie between the clef and the first note"
);
let mut sorted = sharps;
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!(
sorted[0] < sorted[1] && sorted[1] < sorted[2],
"accidentals are spread out, not stacked at one x"
);
}
#[test]
fn incremental_is_observationally_equivalent_to_full() {
let input = fixture();
let full = Engraver::default().solve(&input, &SolverConfig::default());
let inc = Engraver::default().solve_incremental(
&input,
&full.state,
&InvalidationSet {
scope: epiphany_layout_ir::InvalidationScope::WholeScore,
slots: vec![],
bands: vec![],
constraints: vec![],
glyphs: vec![],
},
&SolverConfig::default(),
);
assert_eq!(full.layout, inc.layout);
}
/// Acceptance criterion 6 (the Chapter 7 layout round-trip) against the **real**
/// Engraver, not the verbatim stub. `round_trip_with` drives graph -> logical ->
/// constrained -> *engraved* -> render and asserts the whole provenance contract
/// internally: every laid-out object is covered, the complete `Provenance`
/// (source, synthesis, dependencies, stable id) survives the engrave pass and the
/// render unchanged, the recovered source set is exactly the set laid out, and no
/// two objects share a stable id. The point the stub can never make: provenance is
/// preserved *through a real geometry change* — the Engraver re-spaces every glyph
/// and the strokes that track them, yet not one back-reference is lost.
#[test]
fn criterion_six_round_trips_through_the_engravers_respacing() {
use epiphany_core::generators::valid_score;
use epiphany_layout_ir::{round_trip_with, SolveStatus};
use epiphany_testkit::fixtures::{ten_measure_single_staff, ten_measure_with_repeats};
for seed in 0..32u64 {
// Mirror the criterion-6 hand-off gate's own fixtures — the 10-measure
// single staff (measures + barlines), its repeat-bearing sibling
// (morphed/standalone repeat signs, dot pair, volta brackets — all
// re-spaced and cast off), and the rich score (cross-cutting
// tuplet/tie/spanner/marker) — and keep `valid_score` for breadth.
let scores = [
ten_measure_single_staff(seed),
ten_measure_with_repeats(seed),
valid_score(seed),
valid_score_rich(seed),
];
for score in scores {
// round_trip_with asserts the full provenance contract; a Solved
// status also confirms the Engraver satisfied the pipeline's hard
// constraints (the stub pipeline declares none, so vacuously).
let report = round_trip_with(&score, &Engraver::default());
assert_eq!(report.status, SolveStatus::Solved);
}
}
// Non-vacuity: the contract above held *through* a genuine re-spacing — the
// Engraver's geometry differs from the stub's verbatim columns, so provenance
// survived a real geometry change rather than a pass-through.
let constrained = to_constrained(&to_logical(&valid_score_rich(11)));
assert!(constrained.glyphs.len() >= 2);
let engraved = Engraver::default()
.solve(&constrained, &SolverConfig::default())
.layout;
let stub = StubSolver
.solve(&constrained, &SolverConfig::default())
.layout;
assert_ne!(
engraved
.glyphs
.iter()
.map(|g| g.position.x.0)
.collect::<Vec<_>>(),
stub.glyphs
.iter()
.map(|g| g.position.x.0)
.collect::<Vec<_>>(),
"the Engraver must re-space, not echo the stub's verbatim columns"
);
}
/// A repeat-morphed barline whose measure introduces a time signature: the
/// digits sit in the barline's **slot** but right of the *following note
/// column's source* (`TIME_SIG_X` plus the sign's right extension exceeds
/// the constrained column step), so a per-glyph interpolated remap would
/// drag them by the wrong interval and collapse them into the following
/// note. The slot-relative remap keeps them with their barline: resolved,
/// every digit clears both the repeat sign and every notehead, and every
/// same-slot pair keeps its exact constrained offset.
#[test]
fn time_signature_digits_ride_their_barline_slot_past_a_repeat_sign() {
use epiphany_core::{
BeatGroup, MusicalDuration, PowerOfTwo, RationalTime, TimeSignature,
TimeSignatureDisplay, TimeSignatureId, TypedObjectId,
};
use epiphany_layout_ir::{Margins, Size2D};
let mut score = epiphany_testkit::fixtures::ten_measure_with_repeats(0x000A_11CE);
let ts_id: TimeSignatureId = score.identity.mint();
let beat = || BeatGroup {
duration: MusicalDuration(RationalTime::new(1, 4).expect("nonzero")),
subdivision: None,
accent: 1,
};
score.time_signatures.push(
TimeSignature::new(
ts_id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: PowerOfTwo::new(4).expect("4 is a power of two"),
},
MusicalDuration(RationalTime::new(1, 1).expect("nonzero")),
vec![beat(), beat(), beat(), beat()],
)
.expect("4/4 beat groups sum to a whole note"),
);
// Measure 2 is the fixture's repeatLeft morph (the first repeat's start).
score.canvas.regions[0]
.content
.staff_instances_mut()
.expect("the fixture is staff-based")[0]
.measures[1]
.time_signature = Some(ts_id);
let constrained = to_constrained(&to_logical(&score));
// An unbounded page keeps everything on one endless system, so
// x-disjointness below compares glyphs of the same line (casting
// restarts x per system, making cross-system x-overlap legitimate).
let layout = Engraver::with_geometry(PageGeometry {
size: Size2D::default(),
margins: Margins::default(),
})
.solve(&constrained, &SolverConfig::default())
.layout;
let interval = |position: f32, bounding_box: &epiphany_layout_ir::BoundingBox| {
(
position + bounding_box.left.0,
position + bounding_box.right.0,
)
};
let digits: Vec<_> = layout
.glyphs
.iter()
.filter(|g| {
g.glyph.as_str().starts_with("timeSig")
&& matches!(g.provenance.source, TypedObjectId::Measure(_))
})
.collect();
assert!(!digits.is_empty(), "the 4/4 draws digit glyphs");
let sign = layout
.glyphs
.iter()
.find(|g| {
g.glyph.as_str() == "repeatLeft"
&& matches!(g.provenance.source, TypedObjectId::Measure(_))
})
.expect("the morphed start sign is engraved");
let (_, sign_right) = interval(sign.position.x.0, &sign.bounding_box);
for digit in &digits {
let (digit_left, digit_right) = interval(digit.position.x.0, &digit.bounding_box);
assert!(
digit_left >= sign_right,
"a digit must clear the repeat sign's ink"
);
for head in layout
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("notehead"))
{
let (head_left, head_right) = interval(head.position.x.0, &head.bounding_box);
assert!(
digit_right <= head_left || head_right <= digit_left,
"a digit must not cross a notehead's ink"
);
}
}
// The invariant behind the fix: same-slot companions keep their exact
// constrained offsets through the re-spacing.
let resolved: Vec<_> = constrained.glyphs.iter().zip(&layout.glyphs).collect();
for (a, ra) in &resolved {
for (b, rb) in &resolved {
if a.horizontal_slot == b.horizontal_slot {
let before = b.baseline.x.0 - a.baseline.x.0;
let after = rb.position.x.0 - ra.position.x.0;
assert!(
(before - after).abs() < 1e-4,
"intra-slot offsets must survive the re-spacing"
);
}
}
}
}
/// The editing-loop vertical slice (testkit's `run_edit_loop_with`) driven
/// through the **real Engraver**: click a notehead, sharpen its pitch, re-space
/// with the Engraver, and confirm the selection survives. The selection is the
/// `MUSCLOID` layout id (content-independent), so it holds even though the
/// Engraver re-spaces every glyph — proving the slice's "real Engraver too"
/// claim, not just the stub's.
#[test]
fn the_editing_loop_holds_through_the_real_engraver() {
use epiphany_core::generators::valid_score_rich;
for seed in 0..16u64 {
let report = epiphany_testkit::editloop::run_edit_loop_with(
&valid_score_rich(seed),
&Engraver::default(),
)
.unwrap_or_else(|| panic!("seed {seed}: no clickable notehead to drive the loop"));
assert!(report.graph_changed, "seed {seed}: graph unchanged");
assert!(
report.selection_preserved,
"seed {seed}: selection lost across the Engraver's relayout"
);
assert!(report.render_changed, "seed {seed}: edit not visible");
}
}
// ---- Casting-off (system breaking, stacking, page assignment) ----------
/// The QUICKSTART ten-measure hand-off fixture through the default page
/// geometry — the honest multi-system case the goldens lock.
fn ten_measure_constrained() -> ConstrainedLayoutIR {
to_constrained(&to_logical(
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
))
}
#[test]
fn greedy_wrap_breaks_at_measure_boundaries() {
let input = ten_measure_constrained();
let engraver = Engraver::default();
let report = engraver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
let layout = &report.layout;
assert_eq!(layout.pages.len(), 1, "two short systems fit one page");
let systems = &layout.pages[0].systems;
assert!(
systems.len() >= 2,
"the ten-measure fixture (≈99 staff spaces) wraps under the \
default 90-staff-space content width; got {} system(s)",
systems.len()
);
// Every wrapped system fits the content width (no measure in this
// fixture is wider than a page), and every system starts at the left
// content margin.
let geometry = engraver.geometry();
for system in systems {
assert!(
system.bounding_box.size.width.0 <= geometry.content_width() + 1e-3,
"an automatically wrapped system must fit the content width"
);
assert!(
(system.bounding_box.origin.x.0 - geometry.margins.left.0).abs() < 1e-3,
"every system starts at the left content margin"
);
}
// The greedy pass breaks at measure boundaries only: each wrapped
// system after the first begins with a barline column.
for system in &systems[1..] {
let top = system.bounding_box.origin.y.0 + system.bounding_box.size.height.0;
let bottom = system.bounding_box.origin.y.0;
let first_glyph = layout
.glyphs
.iter()
.filter(|g| g.position.y.0 >= bottom - 1e-3 && g.position.y.0 <= top + 1e-3)
.min_by(|a, b| a.position.x.0.total_cmp(&b.position.x.0))
.expect("a wrapped system has glyphs");
assert!(
first_glyph.glyph.as_str().starts_with("barline"),
"a greedy system boundary sits at a measure boundary, got {}",
first_glyph.glyph.as_str()
);
}
// One Automatic engraved decision per chosen boundary, *appended* to
// the pipeline's own decisions (which are carried through unchanged).
let appended = layout
.engraving_decisions
.iter()
.filter(|d| !input.engraving_decisions.contains(d))
.collect::<Vec<_>>();
assert_eq!(appended.len(), systems.len() - 1);
assert!(appended.iter().all(|d| {
d.kind == epiphany_layout_ir::EngravingDecisionKind::SystemBreak
&& d.source == epiphany_layout_ir::DecisionSource::Automatic
}));
}
#[test]
fn a_hard_page_break_starts_a_new_page() {
use epiphany_layout_ir::{BreakKind, DecisionSource, EngravingDecisionKind};
let mut input = two_off_staff_whole_notes();
let slot = input.horizontal_slots[2].id; // the second note column
input.constraints.push(LayoutConstraint::PageBreakAt {
slot,
kind: BreakKind::Hard,
});
let engraver = Engraver::default();
let report = engraver.solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
assert!(report.satisfied_hard_constraints);
assert_eq!(
report.layout.pages.len(),
2,
"the hard page break paginates"
);
assert_eq!(report.layout.pages[0].number, 1);
assert_eq!(report.layout.pages[1].number, 2);
assert_eq!(report.layout.pages[0].systems.len(), 1);
assert_eq!(report.layout.pages[1].systems.len(), 1);
// Page 2's system sits inside page 2's world frame (a full page height
// plus the inter-page gap below page 1's frame).
let geometry = engraver.geometry();
let page2_content_top =
-(geometry.size.height.0 + crate::INTER_PAGE_GAP) - geometry.margins.top.0;
let system2 = &report.layout.pages[1].systems[0];
let system2_top = system2.bounding_box.origin.y.0 + system2.bounding_box.size.height.0;
assert!(
(system2_top - page2_content_top).abs() < 1e-3,
"page 2's first system starts at page 2's content top \
({system2_top} vs {page2_content_top})"
);
assert!(report
.layout
.engraving_decisions
.iter()
.any(|d| d.kind == EngravingDecisionKind::PageBreak
&& d.source == DecisionSource::Automatic));
}
#[test]
fn vertical_stacking_respects_the_inter_system_gap() {
use epiphany_layout_ir::{VerticalBand, VerticalBandId};
let report =
Engraver::default().solve(&ten_measure_constrained(), &SolverConfig::default());
let systems = &report.layout.pages[0].systems;
assert!(systems.len() >= 2);
// The gap between consecutive systems' real extents is exactly the
// vertical-band model's preferred inter-system gap.
let preferred = VerticalBand::inter_system_gap(VerticalBandId(0))
.preferred_height
.0;
for pair in systems.windows(2) {
let upper_bottom = pair[0].bounding_box.origin.y.0;
let lower_top = pair[1].bounding_box.origin.y.0 + pair[1].bounding_box.size.height.0;
let gap = upper_bottom - lower_top;
assert!(
(gap - preferred).abs() < 1e-3,
"inter-system gap {gap} != preferred {preferred}"
);
}
}
#[test]
fn page_overflow_starts_a_second_page() {
use epiphany_layout_ir::{Margins, Size2D, StaffSpace};
// A deliberately small page: 50×10 staff spaces of content, so the
// ten-measure fixture wraps into systems (≈7 staff spaces tall) of
// which only one fits a page — the multi-page path.
let geometry = PageGeometry {
size: Size2D {
width: StaffSpace(60.0),
height: StaffSpace(20.0),
},
margins: Margins {
top: StaffSpace(5.0),
right: StaffSpace(5.0),
bottom: StaffSpace(5.0),
left: StaffSpace(5.0),
},
};
let engraver = Engraver::with_geometry(geometry);
let report = engraver.solve(&ten_measure_constrained(), &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
let pages = &report.layout.pages;
assert!(pages.len() >= 2, "a 10-staff-space content page overflows");
for (index, page) in pages.iter().enumerate() {
assert_eq!(page.number, index as u32 + 1, "page numbers are 1-based");
assert!(!page.systems.is_empty(), "no page is emitted empty");
assert_eq!(page.size, geometry.size);
assert_eq!(page.margins, geometry.margins);
// Every system lies within its page's content frame.
let page_top = -(index as f32) * (geometry.size.height.0 + crate::INTER_PAGE_GAP);
let content_top = page_top - geometry.margins.top.0;
let content_bottom = content_top - geometry.content_height();
for system in &page.systems {
let top = system.bounding_box.origin.y.0 + system.bounding_box.size.height.0;
let bottom = system.bounding_box.origin.y.0;
assert!(
top <= content_top + 1e-3 && bottom >= content_bottom - 1e-3,
"system [{bottom}, {top}] escapes page {} content \
[{content_bottom}, {content_top}]",
page.number
);
}
}
}
#[test]
fn a_slur_travels_with_its_own_staff() {
use epiphany_layout_ir::{to_constrained, to_logical};
// Review-found bug — the TWIN of the stem tear-off, and a worse one: a
// slur's endpoints are LIFTED clear of their own staff by construction
// (an above-slur sits a staff-height plus a gap over the top line), so
// they land in the inter-staff zone where the notehead nearest `p0`
// belongs to the ADJACENT staff. The slur was attributed to the top
// staff, kept its shift of 0, and tore off its own notes — its baked
// path was byte-identical to the pre-solve golden. No distance metric
// can recover the staff (the lift is by design), so attribution now
// keys on the arc's direction against the staff-line bands.
let report = Engraver::default().solve(
&to_constrained(&to_logical(
&epiphany_testkit::fixtures::two_staff_close_content(1),
)),
&SolverConfig::default(),
);
let sys = &report.layout.pages[0].systems[0];
assert_eq!(report.layout.curves.len(), 1, "the fixture draws one slur");
let p0y = report.layout.curves[0].p0.y.0;
let band_gap = |b: &epiphany_layout_ir::Rect| {
let (lo, hi) = (b.origin.y.0, b.origin.y.0 + b.size.height.0);
if p0y < lo {
lo - p0y
} else if p0y > hi {
p0y - hi
} else {
0.0
}
};
// Staff records are top-first; the slur belongs to the BOTTOM staff.
let (d_top, d_bottom) = (
band_gap(&sys.staves[0].bounding_box),
band_gap(&sys.staves[1].bounding_box),
);
assert!(
d_bottom < d_top,
"the slur rides its OWN (bottom) staff: d_bottom={d_bottom} d_top={d_top}"
);
assert!(
d_bottom < 2.0,
"and sits just outside that staff's lines: {d_bottom}"
);
}
#[test]
fn multi_staff_stems_stay_on_their_own_staff() {
use epiphany_layout_ir::{to_constrained, to_logical};
// Review-found bug: the inter-staff attribution reused `component_glyph`,
// whose fallback picks the nearest glyph by X ALONE — correct for a slot
// (both staves of a system share their x columns, hence their slots) but
// it handed a stem to the ADJACENT staff, so the stem kept that staff's
// vertical shift and tore off its own notehead. Attribution is now
// y-aware. Every stem's base must still sit at its notehead, offset only
// by the stem's x inset — on the single- AND multi-staff fixtures.
let worst = |score: epiphany_core::Score| -> f32 {
let r = Engraver::default().solve(
&to_constrained(&to_logical(&score)),
&SolverConfig::default(),
);
let heads: Vec<(f32, f32)> = r
.layout
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("notehead"))
.map(|g| (g.position.x.0, g.position.y.0))
.collect();
r.layout
.strokes
.iter()
.filter(|st| {
(st.from.x.0 - st.to.x.0).abs() < 1e-4 && (st.from.y.0 - st.to.y.0).abs() > 1e-3
})
.map(|st| {
heads
.iter()
.map(|(hx, hy)| {
((hx - st.from.x.0).powi(2) + (hy - st.from.y.0).powi(2)).sqrt()
})
.fold(f32::INFINITY, f32::min)
})
.fold(0.0_f32, f32::max)
};
let single = worst(epiphany_testkit::fixtures::ten_measure_single_staff(1));
let multi = worst(epiphany_testkit::fixtures::two_staff_close_content(1));
assert!(
single < 1.5,
"single-staff stems stay on their heads: {single}"
);
assert!(
multi < 1.5,
"multi-staff stems stay on their OWN staff's heads: {multi}"
);
}
/// A staff band that owns no GLYPHS is still a staff of its region: the
/// percussion placeholder's clef has no bundled glyph (a traced anchor
/// stroke) and it carries no notes, so its band's glyph `members` list is
/// empty while the band owns its five staff-line strokes. The solve must
/// still separate it. (That the *metric* also counts its gap is locked by
/// `quality::tests::a_glyphless_staff_band_still_contributes_an_inter_staff_unit`.)
#[test]
fn a_staff_band_owning_no_glyphs_is_still_laid_out_as_a_staff() {
use epiphany_layout_ir::{to_constrained, to_logical};
let report = Engraver::default().solve(
&to_constrained(&to_logical(
&epiphany_testkit::fixtures::percussion_placeholder_staff(1),
)),
&SolverConfig::default(),
);
assert_eq!(report.status, SolveStatus::Solved);
for page in &report.layout.pages {
for sys in &page.systems {
assert_eq!(sys.staves.len(), 2, "the placeholder is a staff");
}
}
assert_eq!(report.metric_vector.collision_penalty.0, 0.0);
}
/// The solve sizes each system's gaps from that system's own content, so one
/// region's systems end up at genuinely different staff distances: the system
/// carrying the colliding first measure opens wide, the slack one after it is
/// pulled CLOSER than the constrained stage's fixed pitch. Two-sided
/// renegotiation is what makes the second half of that sentence true.
#[test]
fn each_system_solves_its_own_staff_distance() {
use epiphany_layout_ir::{to_constrained, to_logical};
let report = Engraver::default().solve(
&to_constrained(&to_logical(
&epiphany_testkit::fixtures::two_staff_wrapping_pressure(1),
)),
&SolverConfig::default(),
);
let systems: Vec<_> = report
.layout
.pages
.iter()
.flat_map(|page| &page.systems)
.collect();
assert_eq!(systems.len(), 2, "the region wraps into two systems");
let pitch = |sys: &epiphany_layout_ir::ResolvedSystem| {
assert_eq!(sys.staves.len(), 2, "both staves ride every system");
sys.staves[0].bounding_box.origin.y.0 - sys.staves[1].bounding_box.origin.y.0
};
let (first, second) = (pitch(systems[0]), pitch(systems[1]));
assert!(
first > second + 8.0,
"the pressured system opens far wider: {first} vs {second}"
);
// The constrained stage stacks at SYSTEM_STAFF_PITCH = 12; the slack
// system is COMPRESSED below it, which an expand-only solve cannot do.
assert!(
second < 12.0,
"the slack system is pulled tighter than the fixed pitch: {second}"
);
assert_eq!(report.metric_vector.collision_penalty.0, 0.0);
}
#[test]
fn inter_staff_solve_separates_colliding_staves() {
use epiphany_layout_ir::{to_constrained, to_logical};
// The two-staff fixture's staves collide under the fixed staff pitch:
// low ledger notes on the top staff, high ledger notes plus a slur on
// the bottom. The inter-staff solve pushes them apart until the content
// clears the band model's preferred gap.
let report = Engraver::default().solve(
&to_constrained(&to_logical(
&epiphany_testkit::fixtures::two_staff_close_content(1),
)),
&SolverConfig::default(),
);
let sys = &report.layout.pages[0].systems[0];
assert_eq!(sys.staves.len(), 2, "two staves in the system");
// Staff records are top-first; the gap between the two staves' staff-line
// boxes now exceeds the fixed inter-staff pitch (12 staff spaces), where
// under fixed stacking it is ~8 — the solve opened room for the colliding
// ledgers and slur (their content clears with margin to spare).
let (top, bottom) = (&sys.staves[0].bounding_box, &sys.staves[1].bounding_box);
let gap = top.origin.y.0 - (bottom.origin.y.0 + bottom.size.height.0);
assert!(
gap > 12.0,
"the colliding staves separate: staff-line gap {gap}"
);
assert_eq!(
report.metric_vector.collision_penalty.0, 0.0,
"the separated staves collide nowhere"
);
// A correctly separated system scores ~0 on the deviation-from-preferred
// axis: the solve expands each gap until the staves' CONTENT clears the
// declared `InterStaffGap` band's preferred height, and the metric reads
// that same declared band against the same content. It used to saturate
// at 1.0 here -- the metric measured the gap between the staves' *glyph*
// ink while the solve targeted their full content, so the ledger and slur
// ink the solve had correctly accounted for was rescored as crowding, and
// a Standard-tier floor warning fired on a correct layout.
let density = report.metric_vector.vertical_density_penalty.0;
assert!(
density < 1e-4,
"a correctly separated system deviates from its declared gap by ~0: {density}"
);
assert!(
!report.warnings.iter().any(|w| matches!(
w.kind,
epiphany_layout_ir::SolverWarningKind::QualityFloorApproached {
metric: epiphany_layout_ir::QualityMetricKind::VerticalDensity
}
)),
"and earns no vertical-density floor warning"
);
// A no-pressure single-staff score has no inter-staff pair, so it is
// untouched — its goldens stay byte-stable (see the render-svg suite).
let single =
Engraver::default().solve(&ten_measure_constrained(), &SolverConfig::default());
assert!(
single
.layout
.pages
.iter()
.flat_map(|p| &p.systems)
.all(|s| s.staves.len() <= 1),
"single-staff systems have no inter-staff pair to renegotiate"
);
}
/// Three staves in one system, with deliberately ASYMMETRIC pressure: the
/// upper pair collides hard (C1 against C7), the lower pair only gently. A
/// staff's shift accumulates the corrections of every pair above it — the
/// bottom staff must clear the middle staff where the middle staff now sits,
/// not where it started — so `shift_lower = shift_upper + target - gap_raw`,
/// with `gap_raw` the pair's UNSHIFTED clearance.
///
/// A solve sizing each pair independently gets this wrong the obvious way:
/// it hands the bottom staff only its own small correction and leaves it
/// above the middle staff's new position. Verified by mutation: with the
/// cascade removed this test fails on `s2 > s1`.
///
/// It is wrong the *subtle* way too, by subtracting `shift_upper` from the
/// measured gap and then adding it back through the accumulator — which
/// over-separates every pair below the first by exactly the shift above it.
/// That defect shipped in `ENGRAVER_VERSION` 11 and this test could not see
/// it (`s2 > s1` holds either way; two-staff fixtures have `shift_upper = 0`).
/// It is caught by `quality::tests::each_system_realizing_a_gap_band_
/// contributes_its_own_unit`, which asserts every pair realizes its declared
/// clearance — the over-separated lower pair reported 21.06 against a
/// declared 4.0.
#[test]
fn inter_staff_shifts_cascade_down_three_staves() {
use epiphany_layout_ir::{to_constrained, to_logical};
let report = Engraver::default().solve(
&to_constrained(&to_logical(
&epiphany_testkit::fixtures::three_staff_close_content(1),
)),
&SolverConfig::default(),
);
let sys = &report.layout.pages[0].systems[0];
assert_eq!(sys.staves.len(), 3, "three staves in ONE system");
// Staff records are top-first. Recover each staff's solved shift: under
// fixed stacking staff `i` sits `i * SYSTEM_STAFF_PITCH` below the top,
// so whatever separation exceeds that is the shift the solve added.
let top_y = sys.staves[0].bounding_box.origin.y.0;
let shift = |i: usize| (top_y - sys.staves[i].bounding_box.origin.y.0) - 12.0 * i as f32;
let (s0, s1, s2) = (shift(0), shift(1), shift(2));
assert!(s0.abs() < 1e-3, "the top staff anchors the system: {s0}");
assert!(s1 > 0.0, "the hard-colliding upper pair separates: {s1}");
// The cascade. The bottom staff strictly outruns the middle staff: its
// correction is measured against the middle staff's SHIFTED bottom, so
// it inherits that descent and adds its own clearance on top. Sizing the
// lower pair independently yields only its own small raw correction —
// less than `s1` for this fixture, which the strict inequality rejects.
assert!(
s2 > s1,
"the bottom staff inherits the shift above it: s1={s1} s2={s2}"
);
// Every adjacent pair ends up clear of the fixed pitch, and nothing
// collides — the invariant a broken cascade destroys for the lower pair.
for i in 0..2 {
let (upper, lower) = (&sys.staves[i].bounding_box, &sys.staves[i + 1].bounding_box);
let gap = upper.origin.y.0 - (lower.origin.y.0 + lower.size.height.0);
assert!(
gap > 12.0,
"staves {i}/{} separate: staff-line gap {gap}",
i + 1
);
}
assert_eq!(
report.metric_vector.collision_penalty.0, 0.0,
"the cascaded staves collide nowhere"
);
// The slur belongs to the BOTTOM staff, the one carrying the largest
// cascaded shift — so curve attribution has to survive a THREE-band
// choice, not just pick the nearer of two. Misattributing it to the
// middle staff would leave it ~`s2 - s1` above its own notes.
assert_eq!(report.layout.curves.len(), 1, "the fixture draws one slur");
let p0y = report.layout.curves[0].p0.y.0;
let band_gap = |b: &epiphany_layout_ir::Rect| {
let (lo, hi) = (b.origin.y.0, b.origin.y.0 + b.size.height.0);
if p0y < lo {
lo - p0y
} else if p0y > hi {
p0y - hi
} else {
0.0
}
};
let gaps: Vec<f32> = (0..3)
.map(|i| band_gap(&sys.staves[i].bounding_box))
.collect();
assert!(
gaps[2] < gaps[1] && gaps[2] < gaps[0],
"the slur rides its OWN (bottom) staff: {gaps:?}"
);
// Its endpoints now hug the C6 noteheads, three ledger lines above that
// staff's top line, so the gap to the staff-line box is a few spaces —
// not the fixed 0.7 of a staff-relative endpoint. What matters is that it
// is an order nearer its own staff than the one above.
// The middle staff is more than a staff height (4 spaces) further away.
assert!(
gaps[1] > gaps[2] + 4.0,
"and by a wide margin over the middle staff: {gaps:?}"
);
}
#[test]
fn vertical_justification_fills_non_final_pages() {
use epiphany_layout_ir::{Margins, Size2D, StaffSpace};
// Narrow and short, so the ten-measure fixture wraps into several
// systems with two-plus fitting a page — a non-final page to justify.
let geometry = PageGeometry {
size: Size2D {
width: StaffSpace(40.0),
height: StaffSpace(30.0),
},
margins: Margins {
top: StaffSpace(5.0),
right: StaffSpace(5.0),
bottom: StaffSpace(5.0),
left: StaffSpace(5.0),
},
};
let engraver = Engraver::with_geometry(geometry);
let report = engraver.solve(&ten_measure_constrained(), &SolverConfig::default());
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
let pages = &report.layout.pages;
assert!(
pages.len() >= 2,
"the fixture spans pages; got {}",
pages.len()
);
let content_bottom_of = |index: usize| {
let page_top = -(index as f32) * (geometry.size.height.0 + crate::INTER_PAGE_GAP)
- geometry.margins.top.0;
page_top - geometry.content_height()
};
// Every non-final page with ≥2 systems fills: its last system's bottom
// lands on the content bottom.
let mut justified = 0;
for (index, page) in pages.iter().enumerate() {
if index + 1 == pages.len() || page.systems.len() < 2 {
continue;
}
let last_bottom = page.systems.last().unwrap().bounding_box.origin.y.0;
assert!(
(last_bottom - content_bottom_of(index)).abs() < 1e-2,
"page {} last-system bottom {last_bottom} should reach content \
bottom {}",
page.number,
content_bottom_of(index)
);
justified += 1;
}
assert!(
justified > 0,
"no non-final page carried ≥2 systems to justify"
);
// The LAST page stays ragged-bottom — its last system's bottom sits
// above the content bottom, not force-filled.
let last_index = pages.len() - 1;
let last_bottom = pages[last_index]
.systems
.last()
.unwrap()
.bounding_box
.origin
.y
.0;
assert!(
last_bottom > content_bottom_of(last_index) + 1.0,
"the last page is ragged-bottom: {last_bottom} vs {}",
content_bottom_of(last_index)
);
}
#[test]
fn the_resolved_page_tree_is_populated() {
use std::collections::BTreeSet;
let report =
Engraver::default().solve(&ten_measure_constrained(), &SolverConfig::default());
let page = &report.layout.pages[0];
assert_eq!(page.number, 1);
assert!(page.size.width.0 > 0.0 && page.size.height.0 > 0.0);
assert!(page.free_objects.is_empty());
let mut system_ids = BTreeSet::new();
let mut measure_ids = BTreeSet::new();
let mut measure_records = 0usize;
let mut previous_top = f32::INFINITY;
for system in &page.systems {
// Real, ordered bounding boxes: non-default, stacked top to bottom.
assert!(system.bounding_box.size.width.0 > 0.0);
assert!(system.bounding_box.size.height.0 > 0.0);
let top = system.bounding_box.origin.y.0 + system.bounding_box.size.height.0;
assert!(top < previous_top, "systems are ordered top to bottom");
previous_top = top;
assert!(
system_ids.insert(system.provenance.stable_id),
"each system has a distinct stable id"
);
// One staff record (the fixture is single-staff), spanning the
// system and standing four staff spaces tall (plus line thickness).
assert_eq!(system.staves.len(), 1);
let staff = &system.staves[0];
let staff_height = staff.bounding_box.size.height.0;
assert!(
(4.0..4.5).contains(&staff_height),
"a five-line staff spans four staff spaces, got {staff_height}"
);
assert!(staff.bounding_box.size.width.0 > 0.0);
// Measure records: within the system box, ordered by x, distinct.
let mut previous_x = f32::NEG_INFINITY;
for measure in &system.measures {
measure_records += 1;
assert!(measure_ids.insert(measure.measure), "measures are distinct");
let x = measure.bounding_box.origin.x.0;
assert!(x > previous_x, "measures are ordered by x");
previous_x = x;
assert!(measure.bounding_box.size.width.0 > 0.0);
assert!(
x >= system.bounding_box.origin.x.0 - 1e-3
&& x + measure.bounding_box.size.width.0
<= system.bounding_box.origin.x.0
+ system.bounding_box.size.width.0
+ 1e-3,
"a measure record lies within its system"
);
}
}
// Nine of the fixture's ten measures are marked by a start barline
// column (the final-barline measure's start is not marked by any
// column in this projection, so its record is honestly omitted).
assert_eq!(measure_records, 9);
}
#[test]
fn casting_off_is_deterministic_byte_for_byte() {
// Chapter 9 determinism over the full multi-system output: two solves
// of the wrapping fixture produce byte-identical canonical layouts.
let input = ten_measure_constrained();
let a = Engraver::default()
.solve(&input, &SolverConfig::default())
.layout;
let b = Engraver::default()
.solve(&input, &SolverConfig::default())
.layout;
assert_eq!(a.canonical_bytes(), b.canonical_bytes());
}
#[test]
fn staff_lines_are_split_per_system_with_synthesized_continuations() {
use epiphany_layout_ir::SynthesisKind;
let input = ten_measure_constrained();
let report = Engraver::default().solve(&input, &SolverConfig::default());
let systems = &report.layout.pages[0].systems;
assert!(systems.len() >= 2);
// Five lines of one staff, one segment per system: the first segment of
// each keeps the original stroke's provenance; each later one is
// synthesized under the continuation registry kind.
let continuations = report
.layout
.strokes
.iter()
.filter(|s| {
s.provenance.synthesis
== Some(SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS))
})
.count();
assert_eq!(
continuations,
5 * (systems.len() - 1),
"one synthesized continuation per staff line per later system"
);
// Every input stroke's provenance survives (the first segments).
for stroke in &input.strokes {
assert!(
report
.layout
.strokes
.iter()
.any(|s| s.provenance == stroke.provenance),
"an input stroke's provenance was lost in the split"
);
}
// Each system's staff-line segments stay within their system's box.
for system in systems {
let staff = &system.staves[0];
let box_left = system.bounding_box.origin.x.0;
let box_right = box_left + system.bounding_box.size.width.0;
assert!(staff.bounding_box.origin.x.0 >= box_left - 1e-3);
assert!(
staff.bounding_box.origin.x.0 + staff.bounding_box.size.width.0 <= box_right + 1e-3
);
}
}
#[test]
fn hit_testing_resolves_a_glyph_in_the_second_system() {
use epiphany_layout_ir::{to_render, HitShape, Point, PrimitiveRef};
let input = ten_measure_constrained();
let report = Engraver::default().solve(&input, &SolverConfig::default());
let first_system_bottom = report.layout.pages[0].systems[0].bounding_box.origin.y.0;
// A real notehead that wrapped into a later system (below the first).
let (index, glyph) = report
.layout
.glyphs
.iter()
.enumerate()
.filter(|(_, g)| {
g.glyph.as_str().starts_with("notehead") && g.provenance.synthesis.is_none()
})
.min_by(|a, b| a.1.position.y.0.total_cmp(&b.1.position.y.0))
.expect("the fixture has noteheads");
assert!(
glyph.position.y.0 < first_system_bottom,
"the lowest notehead sits below the first system (it wrapped)"
);
// The baked world frame is the hit-test frame: clicking its box centre
// resolves to the same glyph and its score-graph source.
let render = to_render(&report.layout);
let map = render.hit_test_map();
let region = map
.regions
.iter()
.find(|r| r.primitive == PrimitiveRef::Glyph(index))
.expect("every glyph has a hit region");
let HitShape::Box(bounds) = region.shape else {
panic!("a glyph hit region is a box");
};
let click = Point::new(
(bounds.left.0 + bounds.right.0) / 2.0,
(bounds.bottom.0 + bounds.top.0) / 2.0,
);
let top = map.hit(click).into_iter().next().expect("the click hits");
assert_eq!(top.layout_object, glyph.provenance.stable_id);
assert_eq!(top.source, glyph.provenance.source);
}
}