2083 lines
92 KiB
Rust
2083 lines
92 KiB
Rust
#![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/beam shape are vacuous-`0.0` because no drawn slur/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_layout_ir::{
|
||
all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId,
|
||
ConstraintSolver, ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId,
|
||
InvalidationSet, LayoutConstraint, Point, 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 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), and to `4` when repeat barlines and volta brackets landed
|
||
/// (a repeat-bearing score's baked geometry differs from version `3`'s
|
||
/// invisible traced anchors; repeat-free scores are unchanged).
|
||
pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(4);
|
||
|
||
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): (Vec<ResolvedGlyph>, Vec<Stroke>) = if structural_valid
|
||
{
|
||
let remap = HorizontalRemap::build(input);
|
||
(remap.glyphs(input), remap.strokes(input))
|
||
} else {
|
||
(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,
|
||
&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, &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, pages, engraving_decisions) = match cast {
|
||
Some(cast) => {
|
||
let mut decisions = input.engraving_decisions.clone();
|
||
decisions.extend(cast.decisions);
|
||
(cast.glyphs, cast.strokes, cast.pages, decisions)
|
||
}
|
||
None => (
|
||
Vec::new(),
|
||
Vec::new(),
|
||
Vec::new(),
|
||
input.engraving_decisions.clone(),
|
||
),
|
||
};
|
||
|
||
SolveReport {
|
||
status,
|
||
satisfied_hard_constraints: well_formed,
|
||
layout: ResolvedLayoutIR {
|
||
source: input.source,
|
||
pages,
|
||
glyphs,
|
||
strokes,
|
||
engraving_decisions,
|
||
catalog: input.catalog.clone(),
|
||
},
|
||
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)>,
|
||
}
|
||
|
||
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.
|
||
HorizontalRemap {
|
||
points: spacing::control_points(input),
|
||
}
|
||
}
|
||
|
||
/// 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 at its mapped x, baseline `y` preserved; provenance,
|
||
/// glyph identity, bounds, style, and layer carried through.
|
||
fn glyphs(&self, input: &ConstrainedLayoutIR) -> Vec<ResolvedGlyph> {
|
||
input
|
||
.glyphs
|
||
.iter()
|
||
.map(|g| ResolvedGlyph {
|
||
provenance: g.provenance.clone(),
|
||
glyph: g.glyph.clone(),
|
||
position: Point::new(self.map(g.baseline.x.0), 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 epiphany_layout_ir::is_rigid_width_stroke(s) {
|
||
// Translate rigidly by the *owning glyph's* column delta — found by
|
||
// source, not the stroke's midpoint, which for a wide head could
|
||
// pick a neighbouring column and reintroduce drift. The glyph
|
||
// baseline is a control point, so `map(baseline) − baseline` is its
|
||
// exact column delta; applying it keeps the stroke's offset from the
|
||
// glyph and its length.
|
||
let delta = owning_glyph(s, &input.glyphs)
|
||
.map(|g| self.map(g.baseline.x.0) - g.baseline.x.0)
|
||
.unwrap_or(0.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,
|
||
}
|
||
})
|
||
.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 {
|
||
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 {
|
||
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 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();
|
||
input.strokes.push(epiphany_layout_ir::Stroke {
|
||
provenance,
|
||
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 {
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// 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 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);
|
||
}
|
||
}
|