epiphany/crates/epiphany-layout-ir/src/constrained.rs

5170 lines
229 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.

//! Stage 2 — `ConstrainedLayoutIR` (Chapter 7 §"ConstrainedLayoutIR").
//!
//! The output of the spacing pass: the logical IR with composite objects
//! flattened to individual glyphs, each glyph carrying the anchor geometry that
//! is the constraint solver's input. v0 lays glyphs out left-to-right on the
//! canonical `1/1024` grid, assigns each region's glyphs to a vertical band
//! (Chapter 7 §"Vertical Bands"), carries the engraving decisions forward, and
//! stamps the catalog with a metrics hash over exactly the glyphs it references
//! (Chapter 7 §7.3.2), and emits the spring-slot and constraint interfaces the
//! solver consumes. The geometry here is what the stub solver returns verbatim.
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::{
Clef, EventId, KeySignature, LineStyle, MeasureId, MeasurePosition, MusicalDuration, NoteValue,
PitchId, PitchSpelling, RepeatStructureId, SpellingNominal, StaffId, TimeAnchor, TypedObjectId,
WallClockTime,
};
use epiphany_determinism::{DomainTag, Preimage};
use crate::engrave_theory::{
accidental_glyph, clef_glyph, has_stem, key_signature, notehead_glyph, rest_glyph,
staff_position, KeyAccidental, StaffStep,
};
use crate::engraving::{EngravingDecision, OverrideKind, OverridePriority, OverrideTarget};
use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference};
use crate::logical::{
apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature,
RepeatContent, RepeatPlacement, ScoreVersion, SlurContent, SlurDirection, SlurEndpoint,
StaffContent,
};
use crate::provenance::{
manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind,
SynthesisRegistryId,
};
use crate::solver::{ConstraintStrength, SpringSlotId};
use crate::spatial::{BoundingBox, Point, Rect, Size2D, StaffSpace};
use crate::time_axis::{time_cmp, SlotPlacement, TimeAxisModel, TimePoint};
use crate::vertical_band::{inter_staff_gap_id, VerticalBand, VerticalBandId};
/// A stable identifier for a glyph-level object (Chapter 7: `GlyphObjectId`).
/// Shares the glyph's provenance `stable_id`, so it is stable across relayouts.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct GlyphObjectId(pub u128);
/// A glyph with a baseline anchor, the input to the solver (Chapter 7
/// §"Glyph-Level Objects"). v0 references the glyph by SMuFL name and queries
/// its metrics from the in-tree catalog ([`crate::glyph`]); the `baseline` is
/// the staff-space geometry the stub solver returns verbatim.
#[derive(Clone, PartialEq, Debug)]
pub struct GlyphObject {
pub provenance: Provenance,
/// The SMuFL glyph whose metrics the solver consults.
pub glyph: GlyphReference,
/// Horizontal spring slot containing this glyph.
pub horizontal_slot: SpringSlotId,
pub baseline: Point,
/// The vertical band this glyph belongs to (Chapter 7 §"Glyph-Level
/// Objects": every glyph names exactly one `vertical_band`).
pub vertical_band: VerticalBandId,
pub bounding_box: BoundingBox,
pub anchor: Point,
pub layer: i32,
pub style: GlyphStyle,
}
impl GlyphObject {
/// This glyph's stable id (Chapter 7: `GlyphObjectId`).
pub fn id(&self) -> GlyphObjectId {
GlyphObjectId(self.provenance.stable_id.0)
}
}
/// A straight stroke (a line/rule) the renderer draws directly — the notation
/// primitives that are *not* SMuFL glyphs: staff lines, stems, barlines, ledger
/// lines, and beams. Endpoints are in staff-space and `thickness` is the line
/// width in staff spaces; the stroke carries its own [`Provenance`] so it traces
/// like a glyph. It flows through the solver and is positioned by the engraver,
/// not invented by the renderer (Chapter 7 §"Non-overreach").
#[derive(Clone, PartialEq, Debug)]
pub struct Stroke {
pub provenance: Provenance,
pub from: Point,
pub to: Point,
pub thickness: StaffSpace,
pub layer: i32,
pub style: GlyphStyle,
/// The vertical band this stroke belongs to, declared by the projection that
/// emitted it — the same band its owning object's glyphs declare. A vertical
/// solver reads *this*, never the stroke's geometry: a stem, a ledger line,
/// and a staff line all name their staff outright, so no consumer has to
/// guess an owner from proximity. Unlike a glyph, a stroke is not listed in
/// [`VerticalBand::members`] (band membership drives the spring solve over
/// glyphs); this is a one-way reference, validated only to name a real band.
///
/// Content owned by no staff — a page-margin annotation, a repeat structure
/// spanning several staves — names the region's margin band.
pub vertical_band: VerticalBandId,
}
impl Stroke {
/// This stroke's stable id (derived from its provenance, as a glyph's is).
pub fn id(&self) -> GlyphObjectId {
GlyphObjectId(self.provenance.stable_id.0)
}
}
/// A cubic-bézier curve primitive — the third pipeline primitive kind, drawn as
/// a stroked (unfilled) path (Chapter 7 §"Non-overreach"). Slurs engrave to
/// one of these; ties and other span curves will follow. Like [`Stroke`] it
/// holds no spring slot — the solver re-spaces its control points by the
/// horizontal coordinate map, exactly as it does a spanning stroke's endpoints —
/// but it does declare its [`vertical_band`](Curve::vertical_band). The four
/// control points are world-space staff-space coordinates, `p0`→`p3` the drawing
/// order.
#[derive(Clone, PartialEq, Debug)]
pub struct Curve {
pub provenance: Provenance,
pub p0: Point,
pub p1: Point,
pub p2: Point,
pub p3: Point,
pub thickness: StaffSpace,
pub layer: i32,
pub style: GlyphStyle,
/// The line pattern the renderer strokes the path with (a slur's authored
/// `SpanStyle.line`). Solid, dashed, or dotted.
pub line: LineStyle,
/// The vertical band this curve belongs to — see [`Stroke::vertical_band`].
///
/// This matters more for a curve than for any other primitive. A slur's
/// endpoints are *lifted clear* of its 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 nearest notehead can belong to the
/// ADJACENT staff. No geometric rule recovers the owner. The projection
/// knows it — a slur's staff is the staff of its notes — so it declares it.
/// A slur whose notes span two staves is owned by neither and names the
/// margin band.
pub vertical_band: VerticalBandId,
}
impl Curve {
/// This curve's stable id (derived from its provenance, as a glyph's is).
pub fn id(&self) -> GlyphObjectId {
GlyphObjectId(self.provenance.stable_id.0)
}
/// The four control points in drawing order — the shared iteration order
/// for remapping, bounding, and flattening.
pub fn control_points(&self) -> [Point; 4] {
[self.p0, self.p1, self.p2, self.p3]
}
}
/// The constrained IR: composite objects flattened to glyphs and strokes, with
/// the vertical bands and engraving decisions that the solver consumes alongside
/// them (Chapter 7 §"Constraints").
#[derive(Clone, PartialEq, Debug)]
pub struct ConstrainedLayoutIR {
pub source: ScoreVersion,
pub regions: Vec<ConstrainedLayoutRegion>,
pub horizontal_slots: Vec<SpringSlot>,
pub glyphs: Vec<GlyphObject>,
/// Non-glyph line primitives (staff lines, stems, barlines, …).
pub strokes: Vec<Stroke>,
/// Cubic-bézier curve primitives (slurs, …).
pub curves: Vec<Curve>,
pub vertical_bands: Vec<VerticalBand>,
pub constraints: Vec<LayoutConstraint>,
/// The user-override attributions behind the projected break constraints in
/// `constraints` (one entry per break constraint that originated in a user
/// break override), so a casting-off solver can cite the override id in the
/// decision it records. Engraver-independent constraints (tests, tools) have
/// no entry here and are attributed `DecisionSource::Automatic`.
pub break_origins: Vec<BreakOrigin>,
pub engraving_decisions: Vec<EngravingDecision>,
/// Engraving-coverage gaps surfaced rather than hidden: a pitch with no
/// resolved spelling, a glyph the bundled metrics do not carry. Not a hard
/// error — the object is still placed (a fallback notehead, a traced anchor)
/// — but the gap is recorded so it is visible, not silently papered over.
pub diagnostics: Vec<LayoutDiagnostic>,
pub catalog: GlyphCatalogIdentity,
}
/// An engraving-coverage gap the constrained pass surfaced (Chapter 7
/// §"Non-overreach": a missing decision is reported, not invented).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct LayoutDiagnostic {
/// The score-graph object the gap concerns.
pub source: TypedObjectId,
pub kind: LayoutDiagnosticKind,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum LayoutDiagnosticKind {
/// A pitch reached the constrained pass with no resolved (or non-CMN)
/// spelling; its notehead is placed on the clef reference line as a
/// fallback, but its true staff position is unknown.
MissingSpelling,
/// A glyph the bundled metrics do not carry (a percussion clef, a
/// sixteenth-or-shorter rest); the object is carried as a traced anchor
/// rather than drawn at a guessed shape.
UnbundledGlyph(GlyphReference),
}
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub struct GlyphStyle {
/// RGBA color in `0xRRGGBBAA` form.
pub rgba: u32,
}
#[derive(Clone, PartialEq, Debug)]
pub struct ConstrainedLayoutRegion {
pub provenance: Provenance,
pub glyphs: Vec<GlyphObjectId>,
/// The region's time axis, populated with the time→slot placements of this
/// region's spring slots (Chapter 7 §"The Time Axis"): `time_axis.project`
/// maps a musical/wall-clock time to the slot covering it.
pub time_axis: TimeAxisModel,
}
#[derive(Clone, PartialEq, Debug)]
pub struct SpringSlot {
pub id: SpringSlotId,
pub time: TimePoint,
pub min_width: StaffSpace,
pub preferred_width: StaffSpace,
pub max_width: Option<StaffSpace>,
pub stretch_factor: f32,
pub compress_factor: f32,
pub members: Vec<GlyphObjectId>,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Axis {
Horizontal,
Vertical,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum BreakKind {
Hard,
Soft,
}
/// Which break-constraint family a [`BreakOrigin`] attributes: a
/// [`LayoutConstraint::SystemBreakAt`] or a [`LayoutConstraint::PageBreakAt`].
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum BreakClass {
System,
Page,
}
/// The user-override origin of a projected break constraint (Chapter 7
/// §"Engraving Overrides"): the spring slot the override's anchor realized to,
/// which break family it projected into, and the override id. The
/// [`LayoutConstraint`] enum is the spec's normative shape and carries no
/// origin, so the projection records the attribution alongside the constraint
/// list; a casting-off solver that honours the break cites this id in its
/// engraving-decision record (`DecisionSource::UserOverride`, Chapter 7
/// §"Note Layout"). Non-canonical, like every constrained-stage value.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct BreakOrigin {
pub slot: SpringSlotId,
pub class: BreakClass,
pub override_id: crate::engraving::EngravingOverrideId,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct ConstraintRegistryId(pub u128);
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct ConstraintParameters(pub Vec<u8>);
#[derive(Clone, PartialEq, Debug)]
pub enum LayoutConstraint {
NoCollision {
a: GlyphObjectId,
b: GlyphObjectId,
},
Align {
a: GlyphObjectId,
b: GlyphObjectId,
axis: Axis,
},
PositionWithin {
glyph: GlyphObjectId,
region: Rect,
},
SystemBreakAt {
slot: SpringSlotId,
kind: BreakKind,
},
PageBreakAt {
slot: SpringSlotId,
kind: BreakKind,
},
Registered(ConstraintRegistryId, ConstraintParameters),
}
impl LayoutConstraint {
/// The strength this constraint binds the solver with (Chapter 9 §"Strength
/// Levels": [`ConstraintStrength`]).
///
/// The spec's `LayoutConstraint` enum carries no strength field, and the
/// "normalized form" Chapter 9 says the solver consumes does not specify how
/// strength attaches to a constraint instance (a genuine spec gap — see
/// DECISIONS.md), so v0 attaches strength **by rule** rather than widening
/// the IR shape: a break constraint's own [`BreakKind`] is its strength
/// (`Hard` → `Required`, `Soft` → `Preferred` at the default weight), the
/// geometric constraints (no-collision, alignment, containment) are hard
/// engraving obligations (`Required`), and a `Registered` extension
/// constraint is conservatively `Required` — an obligation a solver cannot
/// verify must not be silently demoted (Chapter 9: a solver MUST NOT treat
/// `Required` as `Preferred`).
pub fn strength(&self) -> ConstraintStrength {
match self {
LayoutConstraint::SystemBreakAt {
kind: BreakKind::Soft,
..
}
| LayoutConstraint::PageBreakAt {
kind: BreakKind::Soft,
..
} => ConstraintStrength::Preferred { weight: 1.0 },
LayoutConstraint::NoCollision { .. }
| LayoutConstraint::Align { .. }
| LayoutConstraint::PositionWithin { .. }
| LayoutConstraint::SystemBreakAt {
kind: BreakKind::Hard,
..
}
| LayoutConstraint::PageBreakAt {
kind: BreakKind::Hard,
..
}
| LayoutConstraint::Registered(_, _) => ConstraintStrength::Required,
}
}
}
/// A structural defect in [`ConstrainedLayoutIR`] that prevents a solver from
/// treating the input as a valid constraint problem.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum ConstrainedValidationError {
DuplicateGlyphId(GlyphObjectId),
DuplicateBandId(VerticalBandId),
UnknownBand(VerticalBandId),
UnknownBandMember(GlyphObjectId),
DuplicateBandMember(GlyphObjectId),
BandMismatch(GlyphObjectId),
InvalidGeometry(GlyphObjectId),
InvalidBandGeometry(VerticalBandId),
DuplicateSlotId(SpringSlotId),
UnknownSlot(SpringSlotId),
UnknownSlotMember(GlyphObjectId),
DuplicateSlotMember(GlyphObjectId),
SlotMismatch(GlyphObjectId),
InvalidSlotGeometry(SpringSlotId),
/// A spring slot has no member glyph; the spacing solver derives a slot's
/// source x from a member, so an empty slot has a target it cannot map.
EmptySlot(SpringSlotId),
InvalidGlyphBounds(GlyphObjectId),
/// A constraint references a glyph that is not in the glyph set.
UnknownConstraintGlyph(GlyphObjectId),
/// A break constraint references a spring slot that does not exist.
UnknownConstraintSlot(SpringSlotId),
/// A `PositionWithin` constraint carries a non-finite or inverted region.
InvalidConstraintRegion(GlyphObjectId),
/// A stroke has a non-finite endpoint or a non-finite/negative thickness.
InvalidStrokeGeometry(GlyphObjectId),
/// A curve has a non-finite control point or a non-finite/negative thickness.
InvalidCurveGeometry(GlyphObjectId),
}
/// A malformed logical-stage value that cannot be transformed without losing
/// content.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum LayoutTransformError {
RegionSourceIsNotRegion(LayoutObjectId),
CrossRegionObjectHasNoRegion(LayoutObjectId),
}
impl ConstrainedLayoutIR {
/// Validates the cross-reference and finite-geometry invariants consumed by
/// every constraint solver. Invalid public values are rejected before a
/// solver can report `Solved` or emit non-canonical geometry.
pub fn validate(&self) -> Result<(), ConstrainedValidationError> {
let mut glyphs_by_id = BTreeMap::new();
for glyph in &self.glyphs {
let id = glyph.id();
if glyphs_by_id.insert(id, glyph).is_some() {
return Err(ConstrainedValidationError::DuplicateGlyphId(id));
}
let bounds = glyph.bounding_box;
let valid_bounds = [bounds.left.0, bounds.bottom.0, bounds.right.0, bounds.top.0]
.iter()
.all(|value| value.is_finite())
&& bounds.left.0 <= bounds.right.0
&& bounds.bottom.0 <= bounds.top.0;
if !valid_bounds {
return Err(ConstrainedValidationError::InvalidGlyphBounds(id));
}
if glyph.baseline.quantize().is_none() || glyph.anchor.quantize().is_none() {
return Err(ConstrainedValidationError::InvalidGeometry(id));
}
}
let mut slot_ids = BTreeSet::new();
let mut slot_memberships = BTreeMap::new();
for slot in &self.horizontal_slots {
if !slot_ids.insert(slot.id) {
return Err(ConstrainedValidationError::DuplicateSlotId(slot.id));
}
let min = slot.min_width.0;
let preferred = slot.preferred_width.0;
let max_valid = match slot.max_width {
Some(maximum) => maximum.0.is_finite() && maximum.0 >= preferred,
None => true,
};
if !min.is_finite()
|| !preferred.is_finite()
|| min < 0.0
|| preferred < min
|| !max_valid
|| !slot.stretch_factor.is_finite()
|| !slot.compress_factor.is_finite()
|| slot.stretch_factor < 0.0
|| slot.compress_factor < 0.0
{
return Err(ConstrainedValidationError::InvalidSlotGeometry(slot.id));
}
// A spring slot must contain at least one glyph: the spacing solver
// derives each slot's source x from a member glyph, so an empty slot
// is a slot whose target the engraver could not map back. A
// stroke-only column carries no slot at all (Chapter 7 §"Constraints").
if slot.members.is_empty() {
return Err(ConstrainedValidationError::EmptySlot(slot.id));
}
for member in &slot.members {
let Some(glyph) = glyphs_by_id.get(member) else {
return Err(ConstrainedValidationError::UnknownSlotMember(*member));
};
if slot_memberships.insert(*member, slot.id).is_some() {
return Err(ConstrainedValidationError::DuplicateSlotMember(*member));
}
if glyph.horizontal_slot != slot.id {
return Err(ConstrainedValidationError::SlotMismatch(*member));
}
}
}
let mut band_ids = BTreeSet::new();
let mut memberships = BTreeMap::new();
for band in &self.vertical_bands {
if !band_ids.insert(band.id) {
return Err(ConstrainedValidationError::DuplicateBandId(band.id));
}
let min = band.min_height.0;
let preferred = band.preferred_height.0;
let max = band.max_height.map(|height| height.0);
let valid_heights = min.is_finite()
&& preferred.is_finite()
&& min >= 0.0
&& preferred >= min
&& match max {
Some(maximum) => maximum.is_finite() && maximum >= preferred,
None => true,
};
if !valid_heights
|| !band.stretch_factor.is_finite()
|| !band.compress_factor.is_finite()
|| band.stretch_factor < 0.0
|| band.compress_factor < 0.0
{
return Err(ConstrainedValidationError::InvalidBandGeometry(band.id));
}
for member in &band.members {
let Some(glyph) = glyphs_by_id.get(member) else {
return Err(ConstrainedValidationError::UnknownBandMember(*member));
};
if memberships.insert(*member, band.id).is_some() {
return Err(ConstrainedValidationError::DuplicateBandMember(*member));
}
if glyph.vertical_band != band.id {
return Err(ConstrainedValidationError::BandMismatch(*member));
}
}
}
for glyph in &self.glyphs {
if !slot_ids.contains(&glyph.horizontal_slot) {
return Err(ConstrainedValidationError::UnknownSlot(
glyph.horizontal_slot,
));
}
if slot_memberships.get(&glyph.id()) != Some(&glyph.horizontal_slot) {
return Err(ConstrainedValidationError::SlotMismatch(glyph.id()));
}
if !band_ids.contains(&glyph.vertical_band) {
return Err(ConstrainedValidationError::UnknownBand(glyph.vertical_band));
}
if memberships.get(&glyph.id()) != Some(&glyph.vertical_band) {
return Err(ConstrainedValidationError::BandMismatch(glyph.id()));
}
}
// Constraints must reference objects that exist: a dangling glyph or
// slot reference is a malformed problem, not a silently-accepted one.
let glyph_exists = |id: GlyphObjectId| -> bool { glyphs_by_id.contains_key(&id) };
for constraint in &self.constraints {
match constraint {
LayoutConstraint::NoCollision { a, b } | LayoutConstraint::Align { a, b, .. } => {
if !glyph_exists(*a) {
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*a));
}
if !glyph_exists(*b) {
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*b));
}
}
LayoutConstraint::PositionWithin { glyph, region } => {
if !glyph_exists(*glyph) {
return Err(ConstrainedValidationError::UnknownConstraintGlyph(*glyph));
}
let r = [
region.origin.x.0,
region.origin.y.0,
region.size.width.0,
region.size.height.0,
];
let region_ok = r.iter().all(|v| v.is_finite())
&& region.size.width.0 >= 0.0
&& region.size.height.0 >= 0.0;
if !region_ok {
return Err(ConstrainedValidationError::InvalidConstraintRegion(*glyph));
}
}
LayoutConstraint::SystemBreakAt { slot, .. }
| LayoutConstraint::PageBreakAt { slot, .. } => {
if !slot_ids.contains(slot) {
return Err(ConstrainedValidationError::UnknownConstraintSlot(*slot));
}
}
// A Registered (extension) constraint is opaque; treated
// conservatively (not rejected) per "Behavior Under Unknown
// Extensions".
LayoutConstraint::Registered(_, _) => {}
}
}
for stroke in &self.strokes {
// Endpoints and thickness must all quantize (finite *and* in canonical
// range) — a finite-but-out-of-range value would validate yet panic in
// `canonical_bytes`. Thickness must additionally be non-negative.
let geometry_quantizes = stroke.from.quantize().is_some()
&& stroke.to.quantize().is_some()
&& stroke.thickness.quantize().is_some();
if !geometry_quantizes || stroke.thickness.0 < 0.0 {
return Err(ConstrainedValidationError::InvalidStrokeGeometry(
stroke.id(),
));
}
// A stroke's band is a one-way reference (it is not in `members`),
// so the only thing to enforce is that it names a band that exists —
// a dangling one would silently drop the stroke out of the vertical
// solve's attribution.
if !band_ids.contains(&stroke.vertical_band) {
return Err(ConstrainedValidationError::UnknownBand(
stroke.vertical_band,
));
}
}
for curve in &self.curves {
// Every control point and the thickness must quantize; thickness
// non-negative — the same discipline as a stroke's geometry.
let geometry_quantizes = curve
.control_points()
.iter()
.all(|point| point.quantize().is_some())
&& curve.thickness.quantize().is_some();
if !geometry_quantizes || curve.thickness.0 < 0.0 {
return Err(ConstrainedValidationError::InvalidCurveGeometry(curve.id()));
}
if !band_ids.contains(&curve.vertical_band) {
return Err(ConstrainedValidationError::UnknownBand(curve.vertical_band));
}
}
Ok(())
}
}
// Minimal-tier engraving geometry, in staff spaces (Chapter 7 §7.2). These are
// fixed defaults, not yet solver-negotiated: the stub solver returns this
// geometry verbatim, so it is what the renderer draws.
const STAFF_LINE_THICKNESS: f32 = 0.13;
const STEM_THICKNESS: f32 = 0.12;
// One octave from the outer notehead — but a stem on a note beyond the staff is
// drawn out to the middle line instead, so it never dangles in the ledger field.
const STEM_LENGTH: f32 = 3.5;
const STAFF_HEIGHT: f32 = 4.0; // 4 spaces between the outer lines of a 5-line staff
const SYSTEM_STAFF_PITCH: f32 = 12.0; // vertical distance between stacked staves
const CLEF_X: f32 = 0.0;
const FIRST_COLUMN_X: f32 = 3.0; // x of the first time column (right of the clef)
const COLUMN_X_STEP: f32 = 1.6; // x advance per distinct musical time column
const COLUMN_PREFERRED_WIDTH: f32 = 1.5; // a column's spring preferred width
const STAFF_LEFT_MARGIN: f32 = 1.0; // staff line extends this far left of the clef
const STAFF_RIGHT_MARGIN: f32 = 2.0; // …and this far right of the last column
const REGION_GAP: f32 = 4.0; // horizontal gap between regions (no page layout in v0)
// Fallback stem attachment when a notehead's metrics are absent; a bundled head
// uses its own bounding box (right edge for an up-stem, left for a down-stem —
// SMuFL's `stemUpSE` / `stemDownNW`, which for `noteheadBlack` are x = 1.18 / 0).
const NOTEHEAD_STEM_X: f32 = 1.15;
const ACCIDENTAL_X: f32 = 1.1; // the innermost accidental sits this far left of its notehead
const ACC_STACK_X: f32 = 0.9; // each further-out stacked accidental steps left by this
const KEY_SIG_START: f32 = 2.7; // x where a key signature begins (just after the clef)
const KEY_ACC_X: f32 = 0.9; // x advance per key-signature accidental
const TIME_SIG_X: f32 = 0.5; // a time signature sits this far right of its barline
const TIME_DIGIT_X: f32 = 0.8; // x advance per time-signature digit
// Repeat/volta engraving defaults (Minimal tier; SMuFL engraving-default
// neighborhood, not solver-negotiated).
const REPEAT_DOTS_SEPARATION: f32 = 0.16; // gap between the dot pair and the barline it decorates
const VOLTA_Y: f32 = 6.5; // the bracket line, above the top staff's bottom line
const VOLTA_HOOK: f32 = 1.4; // the descending hook at each bracket end
const VOLTA_LINE_THICKNESS: f32 = 0.16; // SMuFL repeatEndingLineThickness default
const VOLTA_TEXT_X: f32 = 0.4; // the first ending digit sits this far right of the bracket start
const VOLTA_TEXT_DROP: f32 = 1.3; // ending-digit baseline, below the bracket line
const VOLTA_ENDING_GAP: f32 = 0.5; // extra gap between successive ending numbers
// Slur engraving defaults (Minimal tier; a symmetric cubic arc — Push 3 refines
// with collision-aware shaping).
// A slur's endpoints and its arc clear the notes by this much, on the arc's side.
const SLUR_ENDPOINT_GAP: f32 = 0.7;
const SLUR_HEIGHT_FACTOR: f32 = 0.16; // auto arc apex height as a fraction of span width
const SLUR_MIN_HEIGHT: f32 = 0.8; // …clamped to at least this many staff spaces
const SLUR_MAX_HEIGHT: f32 = 3.0; // …and at most this many
const SLUR_THICKNESS: f32 = 0.12; // default line thickness when the style declares none
/// The horizontal half-reach of an emitted `PositionWithin` region, in staff
/// spaces. The constrained stage performs no casting-off, so a region imposes
/// no *horizontal* bound on its glyphs — a conformant solver may re-space
/// columns freely along the open canvas. The containment obligation this stage
/// can honestly state is the **vertical** envelope (which the spacing pass
/// computes from the very glyph geometry it emits), so the emitted rect pins
/// that envelope and leaves the horizontal span at canvas scale: wide enough
/// for any plausible re-spacing, finite because the validator rejects
/// non-finite constraint regions. Geometric constraints are expressed — and
/// evaluated — in *this stage's frame*: a casting-off solver that relocates
/// whole systems (a per-system rigid motion) evaluates them against its
/// pre-casting spaced geometry, where the obligation is meaningful (see
/// `epiphany-engrave`).
const POSITION_WITHIN_X_REACH: f32 = 1.0e6;
/// The registry id for the engraver's **structural-line synthesis** (staff
/// lines). The normative [`SynthesisKind`] set names *musical* synthesized
/// objects (cancellation accidentals, generated rests, …) but no purely visual
/// rule like a staff line; the codebase-wide convention is that a kind the core
/// vocabulary does not close is carried as a `Registered(...Id)` extension
/// (Chapter 7 §"Behavior Under Unknown Extensions"; see DECISIONS.md). A staff's
/// five lines share its source, so four of them must be synthesized to earn
/// distinct stable ids; this is the kind they declare.
const STAFF_LINE_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x5354_4146_465F_4C4E); // "STAFFLN"
const LEDGER_LINE_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x4C45_4447_4552_4C4E); // "LEDGERLN"
const LEDGER_LINE_EXTENSION: f32 = 0.3; // a ledger line reaches this far past the notehead, each side
/// The registry id for **notated-component synthesis**: a note/rest notated as a
/// tied decomposition (e.g. a quarter tied to an eighth across a barline) draws
/// one notehead/stem/rest *per component*, but the pitch and event each have only
/// one source. The first component carries that exact source; later components
/// are synthesized from it, again via the `Registered` hatch for a kind the
/// normative set does not name.
const COMPONENT_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x434F_4D50_4F4E_4E54); // "COMPONNT"
/// The registry id for **accidental synthesis**: a pitch's spelling accidental
/// (sharp, flat, natural, …) is a second glyph for the same pitch — the notehead
/// carries the pitch's exact provenance, so the accidental, needing a distinct
/// stable id, is synthesized from it via the same `Registered` hatch.
const ACCIDENTAL_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x4143_4349_4445_4E54); // "ACCIDENT"
/// The registry id for **key-signature synthesis**: the staff instance carries
/// the key, but its accidental glyphs (the sharp/flat zigzag) each need a
/// distinct stable id, so they are synthesized from the staff instance.
const KEY_SIG_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x4B45_5953_4947_4E5F); // "KEYSIGN_"
/// The registry id for **time-signature synthesis**: the measure introduces the
/// meter, but its numerator/denominator digit glyphs each need a distinct stable
/// id, so they are synthesized from the measure.
const TIME_SIG_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x54_494D_4553_4947); // "TIMESIG"
/// The registry id for **repeat-barline synthesis**: a repeat sign drawn where
/// no measure barline stands (a mid-measure boundary, a region edge without a
/// final barline) or the dot pair beside a final barline. The repeat
/// structure's own exact provenance stays on its traced anchor, so every ink
/// primitive it owns is synthesized from it. The instance key is
/// `(boundary site << 32) | staff index` — a **semantic** identity (site 0 =
/// the owner's start boundary, 1 = its end; a structure has one of each, and
/// each lands on one column), so the id survives unrelated edits where a
/// positional column rank would re-derive.
const REPEAT_BARLINE_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x5245_5045_4154_424C); // "REPEATBL"
/// The registry id for **volta-bracket synthesis**: each bracket's three
/// strokes and its ending-number digit glyphs, synthesized from the owning
/// repeat structure. The instance key is `(volta index << 64) | element`,
/// elements `0..=2` the strokes (line, start hook, end hook) and `3 +` the
/// digits in drawing order — the element field is 64 bits wide so an
/// adversarially long endings list cannot bleed into the volta-index bits
/// (the same non-overlap discipline as [`ledger_line_key`]).
const VOLTA_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x564F_4C54_4142_524B); // "VOLTABRK"
/// Flattens [`LogicalLayoutIR`] into [`ConstrainedLayoutIR`], engraving each
/// layout object into the notation primitive that represents it: a **glyph** for
/// the SMuFL objects (a pitch's notehead at its clef-relative staff position, a
/// staff instance's clef, a rest, a measure's barline) and a **stroke** for the
/// line primitives (staff lines, stems). Every logical object is covered by
/// exactly one primitive carrying *its* provenance, so the round-trip's
/// source-set surjection holds; derived primitives a single object owns more than
/// one of (the four upper staff lines, a tied note's later components) are
/// [`Provenance::synthesized`] from it, earning distinct stable ids without
/// inventing a spurious source.
///
/// **Spacing** is column-based: the region's distinct musical times become
/// spring slots (one per column, a barline column sorting before the notes at the
/// same onset, the clef in a lead column), so chord/simultaneous glyphs share a
/// slot and the time axis maps musical time to its column. Regions are laid out
/// left-to-right (no page casting-off in v0), so every coordinate is globally
/// monotonic — which is what lets a real solver re-space glyphs *and* the strokes
/// that track them by a single coordinate map.
///
/// Every primitive — glyph, stroke, curve — is routed to the band of its own
/// staff (Chapter 7 §"Vertical Bands"), so a vertical solver reads a primitive's
/// owner rather than inferring it from geometry. Only glyphs become band
/// *members* (membership realizes the spring solve); a stroke's or curve's band
/// is a one-way declaration. Structural objects with no Minimal-tier glyph
/// (regions, voices, ties, slurs, beams, …) are carried as zero-extent traced
/// anchors so provenance survives, pending their engraving in a higher tier.
///
/// **Repeat structures** draw real ink: a boundary of a barline-drawing kind
/// morphs the coinciding measure barline into the composite SMuFL repeat sign
/// (or stands alone at its own column when no measure barline coincides; an
/// end repeat closing on the final barline adds the dot pair beside it), and
/// each volta draws a bracket above the top staff with its ending numbers as
/// digit glyphs. The structure's exact provenance stays on its traced anchor;
/// all of its ink is synthesized from it.
pub fn to_constrained(logical: &LogicalLayoutIR) -> ConstrainedLayoutIR {
try_to_constrained(logical).expect("LogicalLayoutIR is malformed")
}
/// Fallible form of [`to_constrained`] for callers accepting externally built
/// logical IR. It rejects malformed provenance rather than silently dropping a
/// region or spanning object.
pub fn try_to_constrained(
logical: &LogicalLayoutIR,
) -> Result<ConstrainedLayoutIR, LayoutTransformError> {
let mut glyphs = Vec::new();
let mut strokes = Vec::new();
let mut curves = Vec::new();
let mut diagnostics = Vec::new();
let mut vertical_bands = Vec::new();
let mut horizontal_slots = Vec::new();
let mut constraints = Vec::new();
let mut break_origins = Vec::new();
let mut constrained_regions = Vec::new();
// Regions tile left-to-right; this advances by each region's width so all
// coordinates stay globally monotonic (the solver's coordinate remap relies
// on it). v0 has no page casting-off, so this replaces region overlap.
let mut region_x: f32 = 0.0;
for region in &logical.regions {
let region_id = match region.provenance.source {
TypedObjectId::Region(id) => id,
_ => {
return Err(LayoutTransformError::RegionSourceIsNotRegion(
region.provenance.stable_id,
))
}
};
let region_layout_id = region.provenance.stable_id;
let band_of = |staff: Option<StaffId>| -> VerticalBandId {
match staff {
Some(s) => {
VerticalBandId(manifestation_layout_id(&TypedObjectId::Staff(s), region_id).0)
}
None => VerticalBandId(region_layout_id.0),
}
};
// Vertical layout: stack the region's staves top-to-bottom, the first at
// y = 0 and each later one `SYSTEM_STAFF_PITCH` below. A staff's bottom
// line sits at its origin; a `StaffStep` is half a staff space above it.
let mut staff_order: Vec<StaffId> = region.vertical_extent.staves.clone();
for object in &region.objects {
if let Some(staff) = object.staff() {
if !staff_order.contains(&staff) {
staff_order.push(staff);
}
}
}
let y_origin = |staff: StaffId| -> f32 {
-(staff_order.iter().position(|s| *s == staff).unwrap_or(0) as f32) * SYSTEM_STAFF_PITCH
};
// The clef *sequence* in force on each staff (a staff instance carries it).
// The active clef at a given position is the latest change at or before it,
// so a mid-staff clef change moves later pitches without affecting earlier
// ones. An empty sequence falls back to the staff's own `default_clef`.
let mut clef_seq_of: BTreeMap<StaffId, Vec<PlacedClef>> = BTreeMap::new();
let mut clef_default_of: BTreeMap<StaffId, Clef> = BTreeMap::new();
for object in &region.objects {
if let (Some(staff), LayoutContent::Staff(content)) = (object.staff(), object.content())
{
clef_seq_of
.entry(staff)
.or_insert_with(|| content.clefs.clone());
clef_default_of.entry(staff).or_insert(content.default_clef);
}
}
let clef_seq = |staff: Option<StaffId>| -> &[PlacedClef] {
staff
.and_then(|s| clef_seq_of.get(&s))
.map(Vec::as_slice)
.unwrap_or(&[])
};
// A staff's own default clef, in force before its first `ClefChange`.
let clef_default = |staff: Option<StaffId>| -> Clef {
staff
.and_then(|s| clef_default_of.get(&s))
.copied()
.unwrap_or_default()
};
// Pass 1 — compute every glyph's notation, keyed for emission in pass 2,
// and collect the distinct columns it occupies. A note/rest notated as a
// multi-component (tied) decomposition yields one notehead/stem/rest per
// component, each at `position + component.offset`.
let mut pitch_heads: BTreeMap<PitchId, Vec<Head>> = BTreeMap::new();
let mut event_stems: BTreeMap<EventId, Vec<StemSeg>> = BTreeMap::new();
// Per staff, the drawn extent of each note column — the obstacle field a
// slur must arc clear of, and the stem direction it takes its side from.
// Columns are shared between the staves of a system (they share an x), so
// this is keyed by staff as well as column.
let mut column_ink: BTreeMap<(StaffId, ColumnKey), ColumnInk> = BTreeMap::new();
let mut event_rests: BTreeMap<EventId, Vec<RestSeg>> = BTreeMap::new();
// Every column that needs an x. A column earns a spring slot only if a
// glyph actually lands in it (decided after emission, by occupancy), so a
// stroke-only column — e.g. an unbundled rest, or a pitch-less note — gets
// an x but never an empty slot the solver would have to position.
let mut keys: BTreeSet<ColumnKey> = BTreeSet::new();
// How far a column's content overhangs *left* of its noteheads (the
// accidental zone). The source layout separates this column from the
// previous one by this much extra, so a note's accidental does not overlap
// the previous note (the engraver's monotonic remap cannot un-overlap it).
let mut column_overhang: BTreeMap<ColumnKey, f32> = BTreeMap::new();
// The widest key signature in the region (in accidentals): the lead area
// between clef and first note must fit it, so the first note column shifts
// right by it (zero when no staff declares a key — layout unchanged).
let mut key_sig_accs = 0usize;
// This region's repeat structures (engraving content projected by the
// logical stage), and every (column, staff) a measure's own barline
// occupies — repeat signs replace a coinciding measure barline (pass 2
// morphs its glyph) and stand alone elsewhere.
let mut repeats: Vec<(RepeatStructureId, &RepeatContent)> = Vec::new();
let mut measure_cols: BTreeSet<(ColumnKey, StaffId)> = BTreeSet::new();
for object in &region.objects {
let staff = object.staff();
let yo = staff.map(&y_origin).unwrap_or(0.0);
match (object.provenance().source, object.content()) {
(TypedObjectId::Event(eid), LayoutContent::Note(note)) => {
let mut stems = Vec::new();
for (comp, (offset, value)) in components_of(&note.components).enumerate() {
let time = shift_time(&note.position, &offset);
let key = ColumnKey::Timed(time.clone(), ColumnRole::Note);
keys.insert(key.clone());
let clef = active_clef_or(clef_seq(staff), &time, clef_default(staff));
let name = notehead_glyph(value);
let mut ys = Vec::new();
for pitch in &note.pitches {
let (step, missing) = spelling_step(&pitch.spelling, &clef);
if missing {
diagnostics.push(LayoutDiagnostic {
source: TypedObjectId::Pitch(pitch.pitch),
kind: LayoutDiagnosticKind::MissingSpelling,
});
}
// The spelling's accidentals draw on the first component
// only; an unbundled (microtonal) one is surfaced, not
// guessed.
let accidentals = if comp == 0 {
pitch_accidentals(&pitch.spelling, pitch.pitch, &mut diagnostics)
} else {
Vec::new()
};
if !accidentals.is_empty() {
// The leftmost accidental's left edge, measured from
// the notehead (innermost at ACCIDENTAL_X, each
// further-out one ACC_STACK_X beyond).
let overhang =
ACCIDENTAL_X + (accidentals.len() - 1) as f32 * ACC_STACK_X;
let entry = column_overhang.entry(key.clone()).or_insert(0.0);
*entry = entry.max(overhang);
}
let y = step_to_y(yo, step);
ys.push(y);
pitch_heads.entry(pitch.pitch).or_default().push(Head {
name,
key: key.clone(),
y,
step,
comp,
accidentals,
});
}
let drawn = has_stem(value) && !ys.is_empty();
let fallback = step_to_y(yo, reference_step(&clef));
let bottom = ys.iter().copied().fold(f32::INFINITY, f32::min);
let bottom = if ys.is_empty() { fallback } else { bottom };
let top = ys
.iter()
.copied()
.fold(f32::NEG_INFINITY, f32::max)
.max(bottom);
// Direction: the head furthest from the middle line decides,
// and a tie goes DOWN (the engraving convention for a note
// *on* the middle line, and for a chord straddling it
// evenly). So a single head below the middle line stems up.
let middle = yo + STAFF_HEIGHT * 0.5;
let up = (top - middle) < (middle - bottom);
// Attachment: an up-stem rides the right edge of the lowest
// head, a down-stem the left edge of the highest.
let head_box = metrics(name).map(|m| m.bounding_box());
let x_off = if up {
head_box.map_or(NOTEHEAD_STEM_X, |b| b.right.0)
} else {
head_box.map_or(0.0, |b| b.left.0)
};
// Length: an octave from the outer head, but a stem on a
// note beyond the staff is drawn out to the middle line, so
// it never dangles in the ledger field (`max`/`min` only
// ever lengthen).
let tip = if up {
(top + STEM_LENGTH).max(middle)
} else {
(bottom - STEM_LENGTH).min(middle)
};
if let Some(staff) = staff {
let head_top = head_box.map_or(0.5, |b| b.top.0);
let head_bottom = head_box.map_or(-0.5, |b| b.bottom.0);
let centre = head_box
.map_or(NOTEHEAD_STEM_X * 0.5, |b| (b.left.0 + b.right.0) * 0.5);
let mut ink = ColumnInk {
top: top + head_top,
bottom: bottom + head_bottom,
stem_up: drawn.then_some(up),
centre,
};
if drawn {
if up {
ink.top = ink.top.max(tip);
} else {
ink.bottom = ink.bottom.min(tip);
}
}
let entry = column_ink.entry((staff, key.clone())).or_insert(ink);
entry.top = entry.top.max(ink.top);
entry.bottom = entry.bottom.min(ink.bottom);
}
stems.push(StemSeg {
key,
lo: bottom,
hi: top,
drawn,
comp,
up,
x_off,
tip,
});
}
event_stems.insert(eid, stems);
}
(TypedObjectId::Event(eid), LayoutContent::Rest(rest)) => {
let mut segs = Vec::new();
for (comp, (offset, value)) in components_of(&rest.components).enumerate() {
let time = shift_time(&rest.position, &offset);
// Every rest component occupies its musical onset column,
// whether or not a glyph is bundled for its value — an
// unbundled (short) rest is a traced anchor *there*, not at
// a default x, and later components do not vanish.
let key = ColumnKey::Timed(time, ColumnRole::Note);
keys.insert(key.clone());
// A bundled rest draws a glyph into the column; an unbundled
// one is a stroke-only anchor at the same column (so the
// column earns no slot — decided by occupancy below).
segs.push(RestSeg {
name: rest_glyph(value),
key,
y: yo + STAFF_HEIGHT / 2.0,
comp,
});
}
event_rests.insert(eid, segs);
}
(TypedObjectId::Measure(_), LayoutContent::Measure(measure)) => {
let key = measure_column(measure);
keys.insert(key.clone());
if let Some(s) = staff {
measure_cols.insert((key, s));
}
}
(TypedObjectId::Measure(_), _) => {
// Pass 2 renders malformed/missing measure content as a final
// barline, so collect that fallback column here instead of
// letting the fallible conversion panic.
keys.insert(ColumnKey::End);
if let Some(s) = staff {
measure_cols.insert((ColumnKey::End, s));
}
}
(TypedObjectId::RepeatStructure(id), LayoutContent::Repeat(content)) => {
// A barline-drawing repeat's boundaries are real spacing
// columns (a mid-measure boundary mints one of its own).
if content.barlines {
for placement in [&content.start, &content.end] {
if let Some(key) = placement_column_key(placement) {
keys.insert(key);
}
}
}
repeats.push((id, content));
}
(TypedObjectId::StaffInstance(_), LayoutContent::Staff(content)) => {
// The staff instance's clef glyph occupies the lead column. The
// *displayed* clef is the one in force at the staff start, by
// time — consistent with how notes resolve their active clef.
let clef = active_clef_or(&content.clefs, &origin(), content.default_clef);
if clef_glyph(clef.shape).is_some() {
keys.insert(ColumnKey::Lead);
// The key signature shares the lead column; reserve its width.
key_sig_accs = key_sig_accs.max(key_accidentals_for(content).len());
}
}
(TypedObjectId::StaffInstance(_), _) => {
// Pass 2 falls back to a default treble clef for malformed or
// absent staff-instance content; collect the lead column it
// will use.
keys.insert(ColumnKey::Lead);
}
_ => {}
}
}
// The repeat-barline marks: which columns carry a repeat boundary,
// facing which way, owned by which structures. Marks from distinct
// repeats merge (an end meeting a start draws the combined sign).
let mut marks: BTreeMap<ColumnKey, RepeatMark> = BTreeMap::new();
for (id, content) in &repeats {
if !content.barlines {
continue;
}
for (placement, is_start) in [(&content.start, true), (&content.end, false)] {
let Some(key) = placement_column_key(placement) else {
continue;
};
let mark = marks.entry(key).or_default();
let site = if is_start {
mark.start = true;
0u8
} else {
mark.end = true;
1u8
};
if !mark.sources.contains(id) {
mark.sources.push(*id);
}
let candidate = (*id, site);
mark.owner = Some(match mark.owner {
None => candidate,
Some(current) => current.min(candidate),
});
}
}
// An end-facing sign's ink reaches well left of its column (the plain
// barline sits at the column; `repeat_sign_x` right-aligns the sign's
// heavy line to it), so the column must clear the previous one by that
// reach — the same separation mechanism accidentals use. Without it
// the sign overlaps the preceding note column in the source geometry.
for (key, mark) in &marks {
if !mark.end || !matches!(key, ColumnKey::Timed(..)) {
continue;
}
let name = repeat_sign_name(mark.start, mark.end);
let left_reach = -(repeat_sign_x(name, 0.0)
+ metrics(name)
.expect("repeat sign metrics are bundled")
.bounding_box()
.left
.0);
if left_reach > 0.0 {
let entry = column_overhang.entry(key.clone()).or_insert(0.0);
*entry = entry.max(left_reach);
}
}
// Pass 1b — turn the collected column keys into a table: each gets an x
// (the lead at the clef, timed columns spread by rank, the final-barline
// column at the right) and a spring slot. The table is sorted by
// `ColumnKey`'s exact order.
let timed_count = keys
.iter()
.filter(|k| matches!(k, ColumnKey::Timed(..)))
.count();
// The first note column clears the clef *and* the key signature; each
// timed column additionally clears the previous one by its accidental
// overhang, so the source layout is collision-free.
let first_col = FIRST_COLUMN_X + key_sig_accs as f32 * KEY_ACC_X;
let total_overhang: f32 = column_overhang.values().sum();
let local_right =
first_col + total_overhang + timed_count as f32 * COLUMN_X_STEP + STAFF_RIGHT_MARGIN;
let staff_left = region_x + CLEF_X - STAFF_LEFT_MARGIN;
let staff_right = region_x + local_right;
let mut columns: BTreeMap<ColumnKey, ColumnInfo> = BTreeMap::new();
let mut timed_x = first_col;
for (rank, key) in keys.iter().enumerate() {
let x = match key {
ColumnKey::Lead => region_x + CLEF_X,
ColumnKey::Timed(..) => {
// Push right of the previous column by this column's overhang.
timed_x += column_overhang.get(key).copied().unwrap_or(0.0);
let x = region_x + timed_x;
timed_x += COLUMN_X_STEP;
x
}
ColumnKey::End => staff_right - 0.5,
};
let time = match key {
ColumnKey::Timed(t, _) => t.clone(),
_ => TimePoint::WallClock(WallClockTime(rank as i64)),
};
columns.insert(
key.clone(),
ColumnInfo {
x,
// Every column has a candidate slot id; the slot is only
// *realized* (pushed to the IR) if a glyph lands in it.
slot: column_slot_id(region_layout_id, rank),
time,
note_column: matches!(key, ColumnKey::Timed(_, ColumnRole::Note)),
},
);
}
let column = |key: &ColumnKey| -> &ColumnInfo {
columns
.get(key)
.expect("every emitted column was collected in pass 1")
};
let default_x = region_x + CLEF_X;
// (provenance, owning staff, engraving content) for the region object,
// then its contents, then this region's spanning cross-region objects.
let specs: Vec<(&Provenance, Option<StaffId>, Option<&LayoutContent>)> =
std::iter::once((&region.provenance, None, None))
.chain(
region
.objects
.iter()
.map(|o| (o.provenance(), o.staff(), Some(o.content()))),
)
.chain(
logical
.cross_region
.iter()
.filter(|object| object.regions.first() == Some(&region_id))
.map(|object| (&object.provenance, object.staff, None)),
)
.collect();
// Where this region's glyphs begin in the global vector, so constraint
// emission below can see exactly the glyphs pass 2 produced for it.
let region_glyph_start = glyphs.len();
let mut emit = Emit {
glyphs: &mut glyphs,
strokes: &mut strokes,
curves: &mut curves,
diagnostics: &mut diagnostics,
column_members: BTreeMap::new(),
region_glyphs: Vec::new(),
staff_members: BTreeMap::new(),
margin_members: Vec::new(),
staves_in_order: Vec::new(),
};
// Regroup the note-column ink by staff: a slur's obstacle field is its own
// staff's columns, never the sibling staff that shares their x.
let mut staff_notes: BTreeMap<StaffId, BTreeMap<ColumnKey, ColumnInk>> = BTreeMap::new();
for ((staff, key), column) in column_ink {
staff_notes.entry(staff).or_default().insert(key, column);
}
let no_notes: BTreeMap<ColumnKey, ColumnInk> = BTreeMap::new();
// Pass 2 — emit. Each logical object's exact provenance lands on exactly
// one primitive; the extras a multi-component object owns are synthesized.
for (provenance, staff, content) in specs {
let yo = staff.map(&y_origin).unwrap_or(0.0);
match provenance.source {
TypedObjectId::Staff(s) => {
// Five staff lines: the bottom line is the staff's own anchor;
// the four above are synthesized from it (distinct stable ids
// keyed on the manifestation and line index).
let manifestation =
manifestation_layout_id(&TypedObjectId::Staff(s), region_id);
for line in 0..5u32 {
let y = yo + line as f32;
let provenance = if line == 0 {
provenance.clone()
} else {
Provenance::synthesized(
TypedObjectId::Staff(s),
SynthesisKind::Registered(STAFF_LINE_SYNTHESIS),
staff_line_key(manifestation, line),
Vec::new(),
)
};
emit.stroke(line_stroke(
provenance,
Point::new(staff_left, y),
Point::new(staff_right, y),
STAFF_LINE_THICKNESS,
band_of(staff),
));
}
}
TypedObjectId::StaffInstance(_) => {
// The displayed clef is the one in force at the staff start, by
// time — the same query the notes use, so they always agree.
let clef = match content {
Some(LayoutContent::Staff(c)) => {
active_clef_or(&c.clefs, &origin(), c.default_clef)
}
_ => Clef::default(),
};
match clef_glyph(clef.shape) {
Some(name) => {
let info = column(&ColumnKey::Lead);
let baseline = Point::new(info.x, yo + (clef.line as f32 - 1.0));
emit.glyph(
provenance,
name,
baseline,
band_of(staff),
staff,
info.slot,
);
// The key signature's sharp/flat zigzag: each accidental
// a synthesized glyph in the lead area after the clef,
// at its clef-relative staff position, sharing the lead
// column slot.
if let Some(LayoutContent::Staff(c)) = content {
for (i, accidental) in key_accidentals_for(c).iter().enumerate() {
let key_provenance = Provenance::synthesized(
provenance.source,
SynthesisKind::Registered(KEY_SIG_SYNTHESIS),
SynthesisInstanceKey(i as u128),
provenance.dependencies.clone(),
);
emit.glyph(
&key_provenance,
accidental.glyph,
Point::new(
region_x + KEY_SIG_START + i as f32 * KEY_ACC_X,
step_to_y(yo, accidental.position),
),
band_of(staff),
staff,
info.slot,
);
}
}
}
None => {
emit.diag(provenance.source, unbundled(clef_label(clef.shape)));
emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
));
}
}
}
TypedObjectId::Event(eid) => match content {
Some(LayoutContent::Note(_)) => {
let segs = event_stems.get(&eid).map(Vec::as_slice).unwrap_or(&[]);
if segs.is_empty() {
// A pitch-less, component-less note still needs its anchor.
emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
));
}
for seg in segs {
let info = column(&seg.key);
let stem_x = info.x + seg.x_off;
let (from, to) = if seg.drawn {
// The stem runs from the head it attaches to — the
// lowest for an up-stem, the highest for a down one —
// out to its tip.
let base = if seg.up { seg.lo } else { seg.hi };
(Point::new(stem_x, base), Point::new(stem_x, seg.tip))
} else {
// A stemless value (whole note): a zero-length stem.
(Point::new(info.x, seg.lo), Point::new(info.x, seg.lo))
};
let prov = component_provenance(provenance, seg.comp);
emit.stroke(Stroke {
provenance: prov,
from,
to,
thickness: StaffSpace(STEM_THICKNESS),
layer: 0,
style: ink(),
vertical_band: band_of(staff),
});
}
}
Some(LayoutContent::Rest(_)) => {
let segs = event_rests.get(&eid).map(Vec::as_slice).unwrap_or(&[]);
if segs.is_empty() {
emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
));
}
for seg in segs {
let info = column(&seg.key);
let owned;
let prov_ref = if seg.comp == 0 {
provenance
} else {
owned = component_provenance(provenance, seg.comp);
&owned
};
match seg.name {
Some(name) => emit.glyph(
prov_ref,
name,
Point::new(info.x, seg.y),
band_of(staff),
staff,
info.slot,
),
// No bundled glyph for this (short) value: a traced
// anchor at the rest's *own onset column* (not a
// default x), with the gap surfaced. The component
// keeps its place; later components do not vanish.
None => {
emit.diag(prov_ref.source, unbundled(rest_label()));
emit.stroke(anchor(
prov_ref,
Point::new(info.x, seg.y),
band_of(staff),
));
}
}
}
}
// A non-pitched, non-rest event (unpitched / trajectory / cue):
// not engraved in this tier; a traced anchor keeps it.
_ => emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
)),
},
TypedObjectId::Pitch(pid) => match pitch_heads.get(&pid) {
Some(heads) => {
for head in heads {
let info = column(&head.key);
let owned;
let prov_ref = if head.comp == 0 {
provenance
} else {
owned = component_provenance(provenance, head.comp);
&owned
};
emit.glyph(
prov_ref,
head.name,
Point::new(info.x, head.y),
band_of(staff),
staff,
info.slot,
);
// Ledger lines: short strokes continuing the staff to a
// notehead above or below it, one per whole step between
// the staff and the note, reaching `LEDGER_LINE_EXTENSION`
// past each side of *this notehead's* drawn box — so a
// wider head (a whole note) gets a wider ledger. Synthesized
// from the pitch; render-svg draws strokes under glyphs at a
// layer, so the notehead sits over them.
let head_box = metrics(head.name).map(|m| m.bounding_box());
let head_left = head_box.map_or(0.0, |b| b.left.0);
let head_right = head_box.map_or(NOTEHEAD_STEM_X, |b| b.right.0);
for ledger_step in ledger_steps(head.step) {
let y = step_to_y(yo, ledger_step);
let ledger_provenance = Provenance::synthesized(
provenance.source,
SynthesisKind::Registered(LEDGER_LINE_SYNTHESIS),
ledger_line_key(head.comp, ledger_step),
provenance.dependencies.clone(),
);
emit.stroke(line_stroke(
ledger_provenance,
Point::new(info.x + head_left - LEDGER_LINE_EXTENSION, y),
Point::new(info.x + head_right + LEDGER_LINE_EXTENSION, y),
STAFF_LINE_THICKNESS,
band_of(staff),
));
}
// The spelling's accidental stack: synthesized glyphs
// left of the notehead (innermost nearest it), at its
// staff position, sharing the notehead's column slot.
// Emitted *after* the notehead so the slot's source x
// stays the notehead's.
for (stack, accidental) in head.accidentals.iter().enumerate() {
let acc_provenance = Provenance::synthesized(
provenance.source,
SynthesisKind::Registered(ACCIDENTAL_SYNTHESIS),
SynthesisInstanceKey((head.comp as u128) << 8 | stack as u128),
provenance.dependencies.clone(),
);
let x = info.x - ACCIDENTAL_X - stack as f32 * ACC_STACK_X;
emit.glyph(
&acc_provenance,
accidental,
Point::new(x, head.y),
band_of(staff),
staff,
info.slot,
);
}
}
}
None => {
// An unmatched pitch (no event content reached it): a black
// notehead on the clef reference line, with the gap surfaced.
emit.diag(provenance.source, LayoutDiagnosticKind::MissingSpelling);
let clef = active_clef_or(clef_seq(staff), &origin(), clef_default(staff));
emit.stroke(anchor(
provenance,
Point::new(default_x, step_to_y(yo, reference_step(&clef))),
band_of(staff),
));
}
},
TypedObjectId::Measure(_) => {
let key = match content {
Some(LayoutContent::Measure(measure)) => measure_column(measure),
_ => ColumnKey::End,
};
// A repeat boundary on this measure's own barline column
// morphs the barline into the composite repeat sign — the
// sign *replaces* the plain barline, keeping the measure's
// exact provenance verbatim (the round-trip provenance
// floor compares it exactly; repeat-edit invalidation is
// carried by the score version, which any edit changes).
// The final barline never morphs — an end repeat there
// draws its dot pair beside it instead (emitted with the
// standalone signs below), so the casting-off solver's
// final-barline classification stays truthful.
let name = if key == ColumnKey::End {
"barlineFinal"
} else {
match marks.get(&key) {
Some(mark) => repeat_sign_name(mark.start, mark.end),
None => "barlineSingle",
}
};
let info = column(&key);
// The barline glyph's origin is its lower end — Bravura barlines
// run 0..4 staff spaces *up* from the origin — so anchoring it at
// the staff bottom (`yo`) makes it connect the bottom and top
// staff lines rather than float above the midline.
let baseline = Point::new(repeat_sign_x(name, info.x), yo);
emit.glyph(provenance, name, baseline, band_of(staff), staff, info.slot);
// The time signature this measure introduces: numerator over
// denominator, just right of the barline, each digit a
// synthesized glyph sharing the barline's column slot. An
// unbundled digit is surfaced (the bundled metrics carry only a
// representative subset).
if let Some(LayoutContent::Measure(measure)) = content {
if let Some(time_signature) = measure.time_signature {
// A morphed repeat sign's ink extends right of the
// plain barline span; the time signature clears it.
let center_x = info.x + TIME_SIG_X + repeat_sign_right_extension(name);
// The digit glyphs are centred on their baseline, so the
// numerator's baseline sits on the upper half of the
// staff (≈ y 3) and the denominator's on the lower (≈ y 1).
let lines = [
(0u8, time_signature.numerator, yo + 3.0),
(1u8, time_signature.denominator, yo + 1.0),
];
for (role, value, baseline_y) in lines {
let digits = digits_of(u32::from(value));
let count = digits.len() as f32;
for (i, digit) in digits.iter().enumerate() {
let x =
center_x + (i as f32 - (count - 1.0) / 2.0) * TIME_DIGIT_X;
let digit_provenance = Provenance::synthesized(
provenance.source,
SynthesisKind::Registered(TIME_SIG_SYNTHESIS),
SynthesisInstanceKey((role as u128) << 8 | i as u128),
provenance.dependencies.clone(),
);
emit.glyph_if_bundled(
&digit_provenance,
time_digit(*digit),
Point::new(x, baseline_y),
band_of(staff),
staff,
info.slot,
);
}
}
}
}
}
TypedObjectId::RepeatStructure(_) => {
// The structure's exact provenance rides its traced anchor
// (uniform with every other cross-cutting structure); all
// of its ink — the standalone signs below and the volta
// brackets here — is synthesized from it.
emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
));
let Some(LayoutContent::Repeat(repeat)) = content else {
continue;
};
// Volta brackets sit above the region's top staff: a
// horizontal line with a descending hook at each end and
// the ending numbers (time-signature digit glyphs — the
// Minimal tier has no text primitive) under its left end.
let top_staff = staff_order.first().copied();
let yo_top = top_staff.map(&y_origin).unwrap_or(0.0);
for (index, volta) in repeat.voltas.iter().enumerate() {
let Some(start) = placement_column(&volta.start, &columns) else {
continue;
};
let Some(end) = placement_column(&volta.end, &columns) else {
continue;
};
if end.x <= start.x {
// A reversed or zero-width span draws no bracket
// (advisory volta well-formedness is the authoring
// layer's jurisdiction, not the engraver's).
continue;
}
let y = yo_top + VOLTA_Y;
let volta_provenance = |element: u128| {
Provenance::synthesized(
provenance.source,
SynthesisKind::Registered(VOLTA_SYNTHESIS),
SynthesisInstanceKey(((index as u128) << 64) | element),
provenance.dependencies.clone(),
)
};
emit.stroke(line_stroke(
volta_provenance(0),
Point::new(start.x, y),
Point::new(end.x, y),
VOLTA_LINE_THICKNESS,
band_of(staff),
));
for (element, x) in [(1u128, start.x), (2u128, end.x)] {
emit.stroke(line_stroke(
volta_provenance(element),
Point::new(x, y),
Point::new(x, y - VOLTA_HOOK),
VOLTA_LINE_THICKNESS,
band_of(staff),
));
}
let mut cursor = start.x + VOLTA_TEXT_X;
let mut element = 3u128;
for ending in &volta.endings {
for digit in digits_of(*ending) {
emit.glyph(
&volta_provenance(element),
time_digit(digit),
Point::new(cursor, y - VOLTA_TEXT_DROP),
band_of(top_staff),
top_staff,
start.slot,
);
cursor += TIME_DIGIT_X;
element += 1;
}
cursor += VOLTA_ENDING_GAP;
}
}
}
TypedObjectId::Slur(_) => {
// A slur engraves to a cubic-bézier curve arcing between its
// two endpoint columns. No curve is honest — the traced
// anchor keeps provenance instead — when: the slur resolved
// to no single staff (endpoints on different staves; the
// arc would float at `yo = 0` detached from a note on
// another staff — a Minimal boundary, cross-staff slurs
// defer to a later tranche), either endpoint is unresolved
// (dangling event, or an endpoint in another region), or the
// span is not left-to-right in this region.
let curve = match content {
Some(LayoutContent::Slur(slur)) if staff.is_some() => slur_curve(
provenance,
slur,
yo,
&columns,
band_of(staff),
staff
.and_then(|st| staff_notes.get(&st))
.unwrap_or(&no_notes),
),
_ => None,
};
match curve {
Some(curve) => emit.curve(curve),
None => emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
)),
}
}
// Region, Voice, GraphicObject, and every other cross-cutting
// structure (ties, beams, tuplets, spanners, markers, …) have no
// Minimal-tier glyph; a zero-extent traced anchor keeps them.
_ => emit.stroke(anchor(
provenance,
Point::new(default_x, yo),
band_of(staff),
)),
}
}
// The repeat signs the measures could not carry: a composite sign at a
// column with no measure barline on that staff (a mid-measure boundary,
// a region edge without a final barline), and the dot pair beside a
// final barline an end repeat closes on. One sign per (column, staff),
// synthesized from the mark's owner under its semantic
// `(boundary site, staff)` instance key, depending on every structure
// that shares the mark.
for (key, info) in columns.iter() {
let Some(mark) = marks.get(key) else {
continue;
};
// At the region-closing column only an END repeat has ink — the
// dot pair beside a final barline, or the full sign where no
// final barline stands on that staff. A START boundary there
// draws nothing on any staff: a sign after the region close
// would misstate the structure.
if *key == ColumnKey::End && !mark.end {
continue;
}
let (owner, site) = mark
.owner
.expect("a repeat mark records at least one owning boundary");
let deps: Vec<TypedObjectId> = mark
.sources
.iter()
.map(|id| TypedObjectId::RepeatStructure(*id))
.collect();
for (staff_index, staff) in staff_order.iter().enumerate() {
let covered = measure_cols.contains(&(key.clone(), *staff));
let (name, x) = if *key == ColumnKey::End {
if covered {
let dots_width = metrics("repeatDots")
.expect("repeatDots metrics are bundled")
.bounding_box()
.right
.0;
("repeatDots", info.x - REPEAT_DOTS_SEPARATION - dots_width)
} else {
// No final barline on this staff (its run continues in
// a later region): the full end sign, never a
// start-facing one.
("repeatRight", repeat_sign_x("repeatRight", info.x))
}
} else {
if covered {
// The measure's own barline morphed into the sign.
continue;
}
let name = repeat_sign_name(mark.start, mark.end);
(name, repeat_sign_x(name, info.x))
};
let provenance = Provenance::synthesized(
TypedObjectId::RepeatStructure(owner),
SynthesisKind::Registered(REPEAT_BARLINE_SYNTHESIS),
SynthesisInstanceKey(((site as u128) << 32) | staff_index as u128),
deps.clone(),
);
emit.glyph(
&provenance,
name,
Point::new(x, y_origin(*staff)),
band_of(Some(*staff)),
Some(*staff),
info.slot,
);
}
}
let Emit {
column_members,
region_glyphs,
mut staff_members,
margin_members,
staves_in_order,
..
} = emit;
// One spring slot per glyph-bearing column, in column order (so the solver
// accumulates a monotonic x), members = the column's glyphs. Stroke-only
// columns have no slot. The time axis maps each musical *note* column with
// a slot to it (barline/lead/end columns are visual, not musical query
// points, so they are omitted from it).
let mut region_placements = Vec::new();
for info in columns.values() {
let members = column_members.get(&info.slot).cloned().unwrap_or_default();
// Realize a slot only if a glyph occupies the column — never an empty
// slot (which would have a spacing target but no glyph the engraver
// could derive a source x from).
if members.is_empty() {
continue;
}
// The spring slot's natural width is uniform; the engraver computes the
// collision-aware advance (per-slot bearings) when it re-spaces, and
// the *source* geometry below already separates columns enough that
// accidentals do not overlap the previous note.
horizontal_slots.push(SpringSlot {
id: info.slot,
time: info.time.clone(),
min_width: StaffSpace(1.0),
preferred_width: StaffSpace(COLUMN_PREFERRED_WIDTH),
max_width: None,
stretch_factor: 1.0,
compress_factor: 1.0,
members,
});
if info.note_column {
region_placements.push(SlotPlacement {
time: info.time.clone(),
slot: info.slot,
});
}
}
// --- Constraint emission (Chapter 7 §"Pipeline Overview": the spacing
// pass "build[s] collision constraints"). Everything emitted here is
// satisfiable on well-formed input by construction — the source layout
// separates columns collision-free and a conformant re-spacing keeps
// them so — and the order is deterministic: per region, no-collision
// pairs (staff emission order, then column x / glyph id), containment
// (glyph stable-id order), then projected breaks (override order).
let region_glyph_objects = &glyphs[region_glyph_start..];
let glyph_by_id: BTreeMap<GlyphObjectId, &GlyphObject> = region_glyph_objects
.iter()
.map(|glyph| (glyph.id(), glyph))
.collect();
// NoCollision between *successive notehead columns* within each staff:
// adjacent pairs in (column x, id) order, one linear chain per staff,
// not O(n²). Chord members share a column slot — a second or unison may
// genuinely overlap by design — so only cross-column neighbours carry
// the obligation.
for staff in &staves_in_order {
let mut heads: Vec<&GlyphObject> = staff_members
.get(staff)
.into_iter()
.flatten()
.filter_map(|id| glyph_by_id.get(id).copied())
.filter(|glyph| glyph.glyph.as_str().starts_with("notehead"))
.collect();
heads.sort_by(|a, b| {
a.baseline
.x
.0
.total_cmp(&b.baseline.x.0)
.then_with(|| a.id().cmp(&b.id()))
});
for pair in heads.windows(2) {
if pair[0].horizontal_slot != pair[1].horizontal_slot {
constraints.push(LayoutConstraint::NoCollision {
a: pair[0].id(),
b: pair[1].id(),
});
}
}
}
// PositionWithin: every glyph must stay inside its owning region's
// envelope. The vertical extent is the exact envelope of the region's
// own glyph boxes (both v0 solvers preserve glyph `y` verbatim, so this
// is a real obligation a vertical pass must renegotiate); the
// horizontal span is the open v0 canvas (see
// [`POSITION_WITHIN_X_REACH`]).
if !region_glyph_objects.is_empty() {
let mut bottom = f32::INFINITY;
let mut top = f32::NEG_INFINITY;
for glyph in region_glyph_objects {
bottom = bottom.min(glyph.baseline.y.0 + glyph.bounding_box.bottom.0);
top = top.max(glyph.baseline.y.0 + glyph.bounding_box.top.0);
}
let envelope = Rect {
origin: Point::new(-POSITION_WITHIN_X_REACH, bottom),
size: Size2D {
width: StaffSpace(2.0 * POSITION_WITHIN_X_REACH),
height: StaffSpace(top - bottom),
},
};
let mut ids: Vec<GlyphObjectId> = glyph_by_id.keys().copied().collect();
ids.sort();
for glyph in ids {
constraints.push(LayoutConstraint::PositionWithin {
glyph,
region: envelope,
});
}
}
// Projected break overrides (the logical stage's `SystemBreak` /
// `PageBreak` engraving overrides, Chapter 7 §"Engraving Overrides")
// become break constraints on the spring slot that carries the break
// anchor's onset — the barline column at that time when one exists
// (a break belongs at the boundary), else the note column. An anchor
// no realized column represents — an event or measure outside this
// region, a measure *end* (Minimal resolves measure starts only), a
// region edge, or a column no glyph landed in — is skipped silently:
// there is no slot for a solver to break at.
let mut event_onsets: BTreeMap<EventId, TimePoint> = BTreeMap::new();
let mut measure_starts: BTreeMap<MeasureId, TimePoint> = BTreeMap::new();
for object in &region.objects {
match (object.provenance().source, object.content()) {
(TypedObjectId::Event(eid), LayoutContent::Note(note)) => {
event_onsets.insert(eid, note.position.clone());
}
(TypedObjectId::Event(eid), LayoutContent::Rest(rest)) => {
event_onsets.insert(eid, rest.position.clone());
}
(TypedObjectId::Measure(mid), LayoutContent::Measure(measure)) => {
measure_starts.insert(mid, measure.start.clone());
}
_ => {}
}
}
for override_record in &logical.overrides {
if override_record.target
!= OverrideTarget::ScoreGraph(TypedObjectId::Region(region_id))
{
continue;
}
let (anchor, system) = match &override_record.kind {
OverrideKind::SystemBreak { anchor } => (anchor, true),
OverrideKind::PageBreak { anchor } => (anchor, false),
_ => continue,
};
let Some(time) = break_anchor_time(anchor, &event_onsets, &measure_starts) else {
continue;
};
let slot = [ColumnRole::Barline, ColumnRole::Note]
.iter()
.find_map(|role| {
let info = columns.get(&ColumnKey::Timed(time.clone(), *role))?;
column_members
.get(&info.slot)
.filter(|members| !members.is_empty())
.map(|_| info.slot)
});
let Some(slot) = slot else {
continue;
};
// The override's binding strength is the break's kind: a `Hard`
// override MUST be honored or error, a `Soft` one is a preference
// (Chapter 7 §"Override Resolution"; the projection emits `Soft`).
let kind = match override_record.priority {
OverridePriority::Hard => BreakKind::Hard,
OverridePriority::Soft => BreakKind::Soft,
};
constraints.push(if system {
LayoutConstraint::SystemBreakAt { slot, kind }
} else {
LayoutConstraint::PageBreakAt { slot, kind }
});
// Record the attribution so the casting-off solver's decision can
// cite the user override that asked for this break.
break_origins.push(BreakOrigin {
slot,
class: if system {
BreakClass::System
} else {
BreakClass::Page
},
override_id: override_record.id,
});
}
// A staff band per staff of the region, in the region's own staff order —
// the order `y_origin` stacks by, and exactly the set `band_of` can name.
// (Driving this off the staves that emitted *glyphs* would leave a
// stroke-only staff — one whose clef glyph is unbundled, so it engraves
// to an anchor stroke — naming a band that does not exist.) An (empty)
// inter-staff gap band sits between adjacent staves.
//
// The margin band is emitted unconditionally: a region's own traced
// anchor is a *stroke*, and it names the margin band whether or not any
// region-level glyph does. Both bands may carry no members; so may a gap
// band. Membership drives the spring solve over glyphs, not existence.
for staff in &staff_order {
let layout_id = manifestation_layout_id(&TypedObjectId::Staff(*staff), region_id);
let members = staff_members.remove(staff).unwrap_or_default();
vertical_bands.push(VerticalBand::staff_manifestation(
layout_id, *staff, members,
));
}
for gap in 1..staff_order.len() {
let gap_id = inter_staff_gap_id(region_layout_id, gap);
vertical_bands.push(VerticalBand::inter_staff_gap(gap_id));
}
vertical_bands.push(VerticalBand::margin(region_layout_id, margin_members));
constrained_regions.push(ConstrainedLayoutRegion {
provenance: region.provenance.clone(),
glyphs: region_glyphs,
// The region's kind-only logical axis, now populated with the real
// time→slot placements resolved during spacing.
time_axis: region.time_axis.clone().with_placements(region_placements),
});
region_x = staff_right + REGION_GAP;
}
let names: Vec<&str> = glyphs.iter().map(|glyph| glyph.glyph.as_str()).collect();
let catalog = BravuraCatalog.identity(&names);
if let Some(object) = logical
.cross_region
.iter()
.find(|object| object.regions.is_empty())
{
return Err(LayoutTransformError::CrossRegionObjectHasNoRegion(
object.provenance.stable_id,
));
}
Ok(ConstrainedLayoutIR {
source: logical.source,
regions: constrained_regions,
horizontal_slots,
glyphs,
strokes,
curves,
vertical_bands,
constraints,
break_origins,
engraving_decisions: logical.engraving_decisions.clone(),
diagnostics,
catalog,
})
}
/// A notehead the constrained pass will emit for a pitch: its glyph, the column
/// it sits in, its `y`, and which component of the note it belongs to.
struct Head {
name: &'static str,
key: ColumnKey,
y: f32,
/// The note's diatonic staff position, for ledger-line emission.
step: StaffStep,
comp: usize,
/// The spelling's accidental stack (innermost — nearest the notehead — first),
/// each drawn left of the notehead. Present only on the first component (a tie
/// carries it; later components do not repeat it).
accidentals: Vec<&'static str>,
}
/// One component's stem geometry, computed before column x is known (carried as
/// the column key plus the staff-space `y` extent).
struct StemSeg {
key: ColumnKey,
/// The lowest and highest notehead centre of the component's chord.
lo: f32,
hi: f32,
drawn: bool,
comp: usize,
/// Stem direction: up (right of the heads) or down (left of them).
up: bool,
/// Where the stem attaches, as an x offset from the column's notehead x.
x_off: f32,
/// The free end of the stem, in staff spaces.
tip: f32,
}
/// The drawn extent of one staff's note column: what a slur arcing over or under
/// it must clear, and which way its stem points.
#[derive(Clone, Copy)]
struct ColumnInk {
/// Highest and lowest ink, noteheads and stem alike, in staff spaces.
top: f32,
bottom: f32,
/// `Some(true)` when the column's stem is drawn and points up; `None` when
/// the column draws no stem (a whole note).
stem_up: Option<bool>,
/// The notehead's horizontal centre, as an offset from the column's x.
centre: f32,
}
/// One component's rest glyph (absent when the value has no bundled rest glyph).
struct RestSeg {
name: Option<&'static str>,
key: ColumnKey,
y: f32,
comp: usize,
}
/// A horizontal column the spacing pass tiles left-to-right. The clef sits in the
/// `Lead` column; notes and barlines occupy `Timed` columns (a barline before the
/// notes at the same onset); the final barline closes the region in `End`.
#[derive(Clone, PartialEq, Eq)]
enum ColumnKey {
Lead,
Timed(TimePoint, ColumnRole),
End,
}
/// Within one musical time, a barline column precedes the note column.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum ColumnRole {
Barline,
Note,
}
impl Ord for ColumnKey {
fn cmp(&self, other: &Self) -> Ordering {
use ColumnKey::*;
match (self, other) {
(Lead, Lead) | (End, End) => Ordering::Equal,
(Lead, _) => Ordering::Less,
(_, Lead) => Ordering::Greater,
(End, _) => Ordering::Greater,
(_, End) => Ordering::Less,
(Timed(ta, ra), Timed(tb, rb)) => time_total(ta, tb).then(ra.cmp(rb)),
}
}
}
impl PartialOrd for ColumnKey {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// A resolved column: its x, its candidate spring slot (realized only if a glyph
/// occupies the column), the time it represents, and whether it is a musical note
/// column (the only kind the time axis indexes).
struct ColumnInfo {
x: f32,
slot: SpringSlotId,
time: TimePoint,
note_column: bool,
}
/// The accumulators a region's engraving emits into. Every primitive declares its
/// vertical band; glyphs alone carry band *membership* and a spring slot.
struct Emit<'a> {
glyphs: &'a mut Vec<GlyphObject>,
strokes: &'a mut Vec<Stroke>,
curves: &'a mut Vec<Curve>,
diagnostics: &'a mut Vec<LayoutDiagnostic>,
column_members: BTreeMap<SpringSlotId, Vec<GlyphObjectId>>,
region_glyphs: Vec<GlyphObjectId>,
staff_members: BTreeMap<StaffId, Vec<GlyphObjectId>>,
margin_members: Vec<GlyphObjectId>,
staves_in_order: Vec<StaffId>,
}
impl Emit<'_> {
/// Emits one glyph at `baseline`, in `band` and the column's spring `slot`,
/// recording its band and slot membership. Chord/simultaneous glyphs sharing
/// a column share one slot. Recording membership is what *realizes* the slot:
/// a column no glyph reaches stays slot-less.
fn glyph(
&mut self,
provenance: &Provenance,
name: &'static str,
baseline: Point,
band: VerticalBandId,
staff: Option<StaffId>,
slot: SpringSlotId,
) {
let bounding_box = metrics(name)
.expect("engraved glyph names are bundled")
.bounding_box();
let glyph = GlyphObject {
bounding_box,
glyph: GlyphReference::borrowed(name),
horizontal_slot: slot,
baseline,
vertical_band: band,
anchor: Point::ORIGIN,
layer: 0,
style: ink(),
provenance: provenance.clone(),
};
let gid = glyph.id();
self.column_members.entry(slot).or_default().push(gid);
self.region_glyphs.push(gid);
match staff {
Some(s) => {
if !self.staves_in_order.contains(&s) {
self.staves_in_order.push(s);
}
self.staff_members.entry(s).or_default().push(gid);
}
None => self.margin_members.push(gid),
}
self.glyphs.push(glyph);
}
fn stroke(&mut self, stroke: Stroke) {
self.strokes.push(stroke);
}
fn curve(&mut self, curve: Curve) {
self.curves.push(curve);
}
fn diag(&mut self, source: TypedObjectId, kind: LayoutDiagnosticKind) {
self.diagnostics.push(LayoutDiagnostic { source, kind });
}
/// Emits a glyph if its metrics are bundled, else surfaces the gap as an
/// `UnbundledGlyph` diagnostic (the bundled metrics carry a representative
/// subset — e.g. not every time-signature digit).
fn glyph_if_bundled(
&mut self,
provenance: &Provenance,
name: &'static str,
baseline: Point,
band: VerticalBandId,
staff: Option<StaffId>,
slot: SpringSlotId,
) {
if metrics(name).is_some() {
self.glyph(provenance, name, baseline, band, staff, slot);
} else {
self.diag(
provenance.source,
LayoutDiagnosticKind::UnbundledGlyph(GlyphReference::borrowed(name)),
);
}
}
}
/// Solid black, the default ink for engraved primitives.
fn ink() -> GlyphStyle {
GlyphStyle { rgba: 0x0000_00ff }
}
/// A solid black line stroke between two points.
fn line_stroke(
provenance: Provenance,
from: Point,
to: Point,
thickness: f32,
band: VerticalBandId,
) -> Stroke {
Stroke {
provenance,
from,
to,
thickness: StaffSpace(thickness),
layer: 0,
style: ink(),
vertical_band: band,
}
}
/// A zero-extent, zero-width stroke at `at`: an invisible traced anchor that
/// keeps a structural object (with no Minimal-tier glyph) provenance-tracked.
fn anchor(provenance: &Provenance, at: Point, band: VerticalBandId) -> Stroke {
Stroke {
provenance: provenance.clone(),
from: at,
to: at,
thickness: StaffSpace(0.0),
layer: 0,
style: ink(),
vertical_band: band,
}
}
/// The provenance for a notated component: the object's own (exact) for the first
/// component, a synthesis from it for each later one.
fn component_provenance(base: &Provenance, comp: usize) -> Provenance {
if comp == 0 {
base.clone()
} else {
Provenance::synthesized(
base.source,
SynthesisKind::Registered(COMPONENT_SYNTHESIS),
SynthesisInstanceKey(comp as u128),
base.dependencies.clone(),
)
}
}
/// Resolves a projected break override's [`TimeAnchor`] to the region-local
/// [`TimePoint`] whose spacing column carries it, using the onsets this
/// region's own objects resolved to. Returns `None` — the break is skipped
/// silently — when the anchor addresses something no spacing column
/// represents: an event or measure outside this region, a measure *end* (the
/// Minimal slice resolves measure starts only), a region edge, or an offset
/// whose clock does not match its base.
fn break_anchor_time(
anchor: &TimeAnchor,
event_onsets: &BTreeMap<EventId, TimePoint>,
measure_starts: &BTreeMap<MeasureId, TimePoint>,
) -> Option<TimePoint> {
match anchor {
TimeAnchor::WallClock { time } => Some(TimePoint::WallClock(*time)),
TimeAnchor::Event { id, offset } => apply_offset(event_onsets.get(id)?.clone(), offset),
TimeAnchor::Measure {
id,
position: MeasurePosition::Start,
offset,
} => apply_offset(measure_starts.get(id)?.clone(), offset),
TimeAnchor::Measure { .. } | TimeAnchor::Region { .. } => None,
}
}
/// The column a measure's barline occupies: the final barline closes the region
/// at the right; an interior/region-end barline sits before its measure's notes.
fn measure_column(measure: &crate::logical::MeasureContent) -> ColumnKey {
if measure.barline == BarlineKind::Final {
ColumnKey::End
} else {
ColumnKey::Timed(measure.start.clone(), ColumnRole::Barline)
}
}
/// A repeat boundary landing on one spacing column: which way its sign faces
/// (a coinciding end+start faces both) and the structures that own it, in
/// region emission order.
#[derive(Default)]
struct RepeatMark {
start: bool,
end: bool,
sources: Vec<RepeatStructureId>,
/// The synthesis owner: the smallest `(structure id, boundary site)` that
/// landed on this column, `site` 0 for the structure's start boundary and
/// 1 for its end. A structure has one boundary of each site, so the pair
/// is a **semantic** instance identity — stable under unrelated edits,
/// where a positional (column-rank) key would re-derive whenever any
/// earlier column appeared or disappeared.
owner: Option<(RepeatStructureId, u8)>,
}
/// The spacing column a repeat boundary's *sign* occupies, if placeable: the
/// barline-role column at its resolved time (minting one is pass 1's job), or
/// the region-closing column.
fn placement_column_key(placement: &RepeatPlacement) -> Option<ColumnKey> {
match placement {
RepeatPlacement::At(time) => Some(ColumnKey::Timed(time.clone(), ColumnRole::Barline)),
RepeatPlacement::RegionEnd => Some(ColumnKey::End),
RepeatPlacement::Unresolved => None,
}
}
/// The realized column a volta boundary aligns with: the barline column at its
/// resolved time when one exists, else the note column there, else the
/// region-closing column for a region-end placement. `None` — the bracket
/// draws no ink — when nothing at that time was laid out.
fn placement_column<'a>(
placement: &RepeatPlacement,
columns: &'a BTreeMap<ColumnKey, ColumnInfo>,
) -> Option<&'a ColumnInfo> {
match placement {
RepeatPlacement::At(time) => [ColumnRole::Barline, ColumnRole::Note]
.iter()
.find_map(|role| columns.get(&ColumnKey::Timed(time.clone(), *role))),
RepeatPlacement::RegionEnd => columns.get(&ColumnKey::End),
RepeatPlacement::Unresolved => None,
}
}
/// The composite SMuFL sign for a repeat boundary: a start opens the passage to
/// its right (`repeatLeft`), an end closes the passage to its left
/// (`repeatRight`), and a coinciding end+start draws the combined sign. The
/// no-facing case is unreachable (a [`RepeatMark`] records at least one), but
/// falls back to the plain barline rather than panicking on malformed input.
fn repeat_sign_name(start: bool, end: bool) -> &'static str {
match (start, end) {
(true, false) => "repeatLeft",
(false, true) => "repeatRight",
(true, true) => "repeatRightLeft",
(false, false) => "barlineSingle",
}
}
/// The baseline x aligning a repeat sign's **heavy line** with the boundary the
/// plain barline marks (a Minimal approximation from the glyph boxes): a start
/// sign's heavy line is its left edge, so it draws from the column; an end
/// sign's is its right edge, so it right-aligns to the plain barline's span;
/// the combined sign centers its shared heavy line on it.
fn repeat_sign_x(name: &str, column_x: f32) -> f32 {
let right = |name: &str| metrics(name).map_or(0.0, |m| m.bounding_box().right.0);
match name {
"repeatRight" => column_x + right("barlineSingle") - right("repeatRight"),
"repeatRightLeft" => column_x + (right("barlineSingle") - right("repeatRightLeft")) / 2.0,
_ => column_x,
}
}
/// How far a repeat sign's ink extends **right** of the plain-barline span it
/// replaced, in staff spaces — zero for the plain barlines themselves, so
/// repeat-free geometry is untouched. The morphed measure's time-signature
/// digits shift right by this so they clear the sign.
fn repeat_sign_right_extension(name: &str) -> f32 {
match name {
"repeatLeft" | "repeatRight" | "repeatRightLeft" => {
let right = |name: &str| metrics(name).map_or(0.0, |m| m.bounding_box().right.0);
(repeat_sign_x(name, 0.0) + right(name) - right("barlineSingle")).max(0.0)
}
_ => 0.0,
}
}
/// The cubic-bézier curve for a slur, or `None` when it cannot be honestly
/// drawn in this region: either endpoint unresolved (dangling event, or an
/// endpoint whose column was laid out in another region), or a span that is not
/// left-to-right. `yo` is the slur's staff origin (its bottom line); `notes` is
/// that staff's note columns, the obstacle field the arc must clear.
///
/// **Side.** An authored direction wins. `Auto` places the slur *opposite* the
/// stems, the single-voice engraving rule — all stems up puts it below the
/// noteheads, all down puts it above. A span with stems both ways has no
/// notehead side, and goes above.
///
/// **Endpoints.** They sit a gap outside the endpoint column's ink on the chosen
/// side, horizontally at the notehead's centre. On the notehead side that is
/// just clear of the head; where the stem points the same way as the slur (a
/// mixed-stem span), the column's ink includes the stem, so the endpoint clears
/// the stem tip instead — which is what an engraver draws.
///
/// **Apex.** Span-proportional by default, then *raised until the arc clears
/// every column between the endpoints*. Because the control points sit on the
/// chord at thirds, `x` is linear in `t` and the arc's departure from the chord
/// is exactly `3·lift·t·(1t)`; a column at parameter `t` needing `d` more
/// clearance therefore forces an apex of at least `d / (4·t·(1t))`. An authored
/// height is a floor, never a ceiling: clearance may raise it, so honouring the
/// author cannot draw a slur through a note.
fn slur_curve(
provenance: &Provenance,
slur: &SlurContent,
yo: f32,
columns: &BTreeMap<ColumnKey, ColumnInfo>,
band: VerticalBandId,
notes: &BTreeMap<ColumnKey, ColumnInk>,
) -> Option<Curve> {
let start_key = slur_endpoint_key(&slur.start)?;
let end_key = slur_endpoint_key(&slur.end)?;
let start_x = columns.get(&start_key)?.x;
let end_x = columns.get(&end_key)?.x;
// The staff's columns in the span, by x, so a shared-x sibling staff's notes
// never enter this slur's obstacle field.
// Each column is placed at its NOTEHEAD CENTRE, the same x the endpoints use,
// so a column's curve parameter `t` is the one the arc actually passes it at.
let spanned: Vec<(f32, ColumnInk)> = notes
.iter()
.filter_map(|(key, column)| columns.get(key).map(|info| (info.x, *column)))
.filter(|(x, _)| *x >= start_x && *x <= end_x)
.map(|(x, column)| (x + column.centre, column))
.collect();
let default_centre = NOTEHEAD_STEM_X * 0.5;
let head = |key: &ColumnKey| notes.get(key).copied();
let (p0x, p3x) = (
start_x + head(&start_key).map_or(default_centre, |i| i.centre),
end_x + head(&end_key).map_or(default_centre, |i| i.centre),
);
if p3x <= p0x {
return None;
}
// Side: an authored direction wins; `Auto` goes opposite the stems.
let above = match slur.direction {
SlurDirection::Below => false,
SlurDirection::Above => true,
SlurDirection::Auto => {
let ups = spanned
.iter()
.filter(|(_, i)| i.stem_up == Some(true))
.count();
let downs = spanned
.iter()
.filter(|(_, i)| i.stem_up == Some(false))
.count();
// All stems up ⇒ the noteheads are below ⇒ the slur is too. Anything
// else (all down, mixed, or stemless) goes above.
!(ups > 0 && downs == 0)
}
};
// Endpoint y: a gap outside the endpoint column's ink, on the arc's side.
// Absent ink (a column that drew no notehead) falls back to the staff edge.
let endpoint_y = |key: &ColumnKey| -> f32 {
match (head(key), above) {
(Some(i), true) => i.top + SLUR_ENDPOINT_GAP,
(Some(i), false) => i.bottom - SLUR_ENDPOINT_GAP,
(None, true) => yo + STAFF_HEIGHT + SLUR_ENDPOINT_GAP,
(None, false) => yo - SLUR_ENDPOINT_GAP,
}
};
let (p0y, p3y) = (endpoint_y(&start_key), endpoint_y(&end_key));
let span = p3x - p0x;
let chord = |t: f32| p0y + t * (p3y - p0y);
// Apex: an authored *positive* height, else span-proportional and clamped. A
// non-positive authored height is out of range — it would flip or collapse
// the arc — so it falls back to the default rather than producing a downward
// "above" slur (authoring-validation may flag it separately).
let default_height = (span * SLUR_HEIGHT_FACTOR).clamp(SLUR_MIN_HEIGHT, SLUR_MAX_HEIGHT);
let mut height = slur
.height
.map(|h| h.0.get() as f32)
.filter(|h| *h > 0.0)
.unwrap_or(default_height);
// Raise the apex until every column between the endpoints clears. `x` is
// linear in `t` (the control points sit on the chord at thirds), and the
// arc's departure from the chord at `t` is `4·height·t·(1t)`.
for (x, column) in &spanned {
let t = (x - p0x) / span;
if !(1e-3..=1.0 - 1e-3).contains(&t) {
continue;
}
let needed = if above {
column.top + SLUR_ENDPOINT_GAP - chord(t)
} else {
chord(t) - (column.bottom - SLUR_ENDPOINT_GAP)
};
if needed > 0.0 {
height = height.max(needed / (4.0 * t * (1.0 - t)));
}
}
// Lift the two control points so the cubic's apex (t = 0.5) sits `height`
// from the chord: B(0.5) lifts the control y by 0.75, so the lift is
// 4/3 · height (negated below the staff).
let lift = if above { height } else { -height } * 4.0 / 3.0;
// Thickness: an authored *positive* value, else the default. A
// non-positive one is skipped — a zero would draw an invisible,
// unhittable slur, and a negative one would fail geometry validation and
// blank the whole layout; neither may reach the primitive.
let thickness = slur
.thickness
.map(|t| t.0.get() as f32)
.filter(|t| *t > 0.0)
.unwrap_or(SLUR_THICKNESS);
Some(Curve {
provenance: provenance.clone(),
p0: Point::new(p0x, p0y),
p1: Point::new(p0x + span / 3.0, chord(1.0 / 3.0) + lift),
p2: Point::new(p3x - span / 3.0, chord(2.0 / 3.0) + lift),
p3: Point::new(p3x, p3y),
thickness: StaffSpace(thickness),
layer: 0,
style: ink(),
// The authored line pattern is rendered faithfully (dashed/dotted),
// not deferred — the renderer strokes the path with it.
line: slur.line,
// The slur's OWN staff band — its notes' band, not whichever staff its
// lifted endpoints happen to land nearest.
vertical_band: band,
})
}
/// A slur endpoint's resolved x: the note column at its resolved onset, or
/// `None` when the endpoint is unresolved or its column was not laid out in
/// this region.
fn slur_endpoint_key(endpoint: &SlurEndpoint) -> Option<ColumnKey> {
match endpoint {
SlurEndpoint::At(time) => Some(ColumnKey::Timed(time.clone(), ColumnRole::Note)),
SlurEndpoint::Unresolved => None,
}
}
/// The key signature in force at a staff's start, by resolved time (the same
/// rule as the active clef), or `None` when the staff declares no key. Absence
/// means no signature drawn — distinct from a declared C-major (which also draws
/// nothing, via an empty accidental set).
fn active_key(keys: &[PlacedKeySignature]) -> Option<KeySignature> {
keys.iter()
.filter(|placed| {
matches!(
time_cmp(&placed.time, &origin()),
Some(Ordering::Less | Ordering::Equal)
)
})
.max_by(|a, b| time_total(&a.time, &b.time))
.or_else(|| keys.iter().min_by(|a, b| time_total(&a.time, &b.time)))
.map(|placed| placed.key)
}
/// The key signature's accidentals (the clef-relative zigzag) at a staff's start:
/// the active key resolved under the active clef. Empty when no key is declared,
/// the key is C major, or the clef has no diatonic positions (percussion).
fn key_accidentals_for(content: &StaffContent) -> Vec<KeyAccidental> {
match active_key(&content.keys) {
Some(key) => key_signature(
key,
&active_clef_or(&content.clefs, &origin(), content.default_clef),
),
None => Vec::new(),
}
}
/// The decimal digits of a displayed number (time-signature numerals, volta
/// ending numbers), most significant first.
fn digits_of(value: u32) -> Vec<u8> {
if value == 0 {
return vec![0];
}
let mut digits = Vec::new();
let mut remaining = value;
while remaining > 0 {
digits.push((remaining % 10) as u8);
remaining /= 10;
}
digits.reverse();
digits
}
/// The SMuFL time-signature glyph for a decimal digit.
fn time_digit(digit: u8) -> &'static str {
match digit {
0 => "timeSig0",
1 => "timeSig1",
2 => "timeSig2",
3 => "timeSig3",
4 => "timeSig4",
5 => "timeSig5",
6 => "timeSig6",
7 => "timeSig7",
8 => "timeSig8",
_ => "timeSig9",
}
}
/// The `(offset, base value)` of each notated component, or a single implicit
/// quarter at offset zero when the event carries no decomposition.
fn components_of(
components: &[crate::logical::PlacedComponent],
) -> impl Iterator<Item = (MusicalDuration, NoteValue)> + '_ {
let implicit = components.is_empty();
let mapped = components
.iter()
.map(|c| (c.offset.clone(), c.component.base_value));
let fallback = std::iter::once((MusicalDuration::zero(), NoteValue::Quarter));
mapped
.chain(fallback.filter(move |_| implicit))
.take(if implicit { 1 } else { usize::MAX })
}
/// A musical time shifted by a component offset (a wall-clock base has no musical
/// offset, so it is unchanged).
fn shift_time(base: &TimePoint, offset: &MusicalDuration) -> TimePoint {
match base {
TimePoint::Musical(position) => TimePoint::Musical(position.clone() + offset.clone()),
TimePoint::WallClock(time) => TimePoint::WallClock(*time),
}
}
/// The clef in force at `at`, by **resolved time, not vector order**.
///
/// **Model (Minimal tier):** a staff's *initial* clef — the earliest-timed change
/// — applies from the staff start, even at positions before its own anchor; a
/// later change takes effect from its anchor onward. So the clef at `at` is the
/// change with the greatest time at or before `at`, else the earliest-timed
/// change (the initial clef), else treble when none is declared. This treats the
/// declared clefs as the staff's clef *plan* rather than "treble until the first
/// anchor"; in practice a score's first clef is anchored at the start, so the two
/// readings coincide, and the lead clef glyph uses this same query so it always
/// agrees with the notes. The sequence is not assumed sorted: `[bass@1, treble@0]`
/// resolves a note after time 1 to bass and one before to treble.
pub fn active_clef(clefs: &[PlacedClef], at: &TimePoint) -> Clef {
active_clef_or(clefs, at, Clef::default())
}
/// The clef in force at `at`, falling back to `default` — the staff's own
/// `Staff::default_clef` — when the sequence names none. A staff that declares
/// its clef only on the `Staff` (no `ClefChange` at all) engraves in that clef;
/// `active_clef` is this with the treble default, kept for callers that have no
/// staff to hand.
pub fn active_clef_or(clefs: &[PlacedClef], at: &TimePoint, default: Clef) -> Clef {
clefs
.iter()
.filter(|placed| {
matches!(
time_cmp(&placed.time, at),
Some(Ordering::Less | Ordering::Equal)
)
})
.max_by(|a, b| time_total(&a.time, &b.time))
.or_else(|| clefs.iter().min_by(|a, b| time_total(&a.time, &b.time)))
.map(|p| p.clef)
.unwrap_or(default)
}
/// The musical origin (the active-clef query for an unanchored pitch).
fn origin() -> TimePoint {
TimePoint::Musical(epiphany_core::MusicalPosition::origin())
}
/// A total order over column times: exact within a kind, musical before
/// wall-clock across kinds (a region is single-kind in practice).
fn time_total(a: &TimePoint, b: &TimePoint) -> Ordering {
time_cmp(a, b).unwrap_or(match (a, b) {
(TimePoint::Musical(_), TimePoint::WallClock(_)) => Ordering::Less,
(TimePoint::WallClock(_), TimePoint::Musical(_)) => Ordering::Greater,
_ => Ordering::Equal,
})
}
/// The visual `y` (staff spaces) of a [`StaffStep`] above a staff whose bottom
/// line is at `y_origin`: each step is half a staff space, `+y` up.
fn step_to_y(y_origin: f32, step: StaffStep) -> f32 {
y_origin + step as f32 * 0.5
}
/// The staff steps at which a note at `step` needs ledger lines: the even steps
/// strictly outside the five-line staff (whose lines are the even steps `0..=8`),
/// from the staff out to the note. Empty when the note is on or within the staff,
/// or one space just outside it (an odd step at `±1` from the nearest line). At
/// most one of the two loops runs, since a step cannot be both above and below.
fn ledger_steps(step: StaffStep) -> Vec<StaffStep> {
let mut steps = Vec::new();
let mut above = 10;
while above <= step {
steps.push(above);
above += 2;
}
let mut below = -2;
while below >= step {
steps.push(below);
below -= 2;
}
steps
}
/// A distinct synthesis key for a ledger line on component `comp` at diatonic
/// `step`. The component occupies the high 64 bits and the signed step the low 64,
/// so the two fields never overlap — including a step below `-128`, whose
/// two's-complement low bits would otherwise reach into the component field and let
/// two components of a very low pitch mint colliding stable ids.
fn ledger_line_key(comp: usize, step: StaffStep) -> SynthesisInstanceKey {
SynthesisInstanceKey(((comp as u128) << 64) | (step as i64 as u64 as u128))
}
/// Whether a stroke must keep a **fixed width** when a solver resolves horizontal
/// spacing: its length is a glyph-relative constant, not a span across the columns
/// the spacing pass stretches. A solver should translate such a stroke (preserving
/// its length) rather than re-map both endpoints, which would scale it. Ledger lines
/// are the case today — a fixed-width mark centered on one notehead, unlike a staff
/// line or barline that genuinely spans the system. Public so the constraint solver
/// can honor it without hard-coding the ledger synthesis identity.
pub fn is_rigid_width_stroke(stroke: &Stroke) -> bool {
matches!(
stroke.provenance.synthesis,
Some(SynthesisKind::Registered(k)) if k == LEDGER_LINE_SYNTHESIS
)
}
/// The staff step of a clef's reference line — a neutral fallback position for a
/// pitch whose spelling does not resolve to a CMN nominal.
fn reference_step(clef: &Clef) -> StaffStep {
(clef.line as i32 - 1) * 2
}
/// The staff step of a spelled pitch under `clef`, and whether it is a fallback:
/// the clef reference line when the spelling is absent or non-CMN (its diatonic
/// position is unknown), which the caller surfaces as a diagnostic.
fn spelling_step(spelling: &Option<PitchSpelling>, clef: &Clef) -> (StaffStep, bool) {
match spelling {
Some(s) => match s.nominal {
SpellingNominal::Cmn(nominal) => (staff_position(nominal, s.octave, clef), false),
_ => (reference_step(clef), true),
},
None => (reference_step(clef), true),
}
}
/// The bundled glyphs for a spelling's full accidental stack (innermost first),
/// in stack order. An accidental the bundled metrics do not carry (a microtonal
/// one) is surfaced as a diagnostic rather than drawn at a guessed shape and
/// omitted from the result; the notehead is still drawn at its
/// (accidental-independent) staff position. v0 draws exactly what the spelling
/// carries — it does not yet apply CMN accidental-state suppression (a repeated
/// sharp in a bar is shown again).
fn pitch_accidentals(
spelling: &Option<PitchSpelling>,
pitch: PitchId,
diagnostics: &mut Vec<LayoutDiagnostic>,
) -> Vec<&'static str> {
let Some(spelling) = spelling else {
return Vec::new();
};
let mut glyphs = Vec::new();
for accidental in &spelling.accidentals {
match accidental_glyph(accidental) {
Some(name) => glyphs.push(name),
None => {
diagnostics.push(LayoutDiagnostic {
source: TypedObjectId::Pitch(pitch),
kind: LayoutDiagnosticKind::UnbundledGlyph(GlyphReference::owned(
accidental.as_str(),
)),
});
}
}
}
glyphs
}
/// A label for an unbundled clef shape, for its diagnostic.
fn clef_label(shape: epiphany_core::ClefShape) -> &'static str {
match shape {
epiphany_core::ClefShape::Percussion => "percussionClef",
epiphany_core::ClefShape::G => "gClef",
epiphany_core::ClefShape::F => "fClef",
epiphany_core::ClefShape::C => "cClef",
}
}
fn rest_label() -> &'static str {
"rest (unbundled value)"
}
/// An `UnbundledGlyph` diagnostic kind for a glyph name.
fn unbundled(name: &'static str) -> LayoutDiagnosticKind {
LayoutDiagnosticKind::UnbundledGlyph(GlyphReference::borrowed(name))
}
/// The synthesis instance key for an upper staff line: distinct per manifestation
/// (so a staff manifested in two regions does not collide) and per line index.
fn staff_line_key(manifestation: LayoutObjectId, line: u32) -> SynthesisInstanceKey {
SynthesisInstanceKey((manifestation.0 << 3) | line as u128)
}
/// A deterministic spring-slot id for a region's column (by rank), distinct
/// across regions.
fn column_slot_id(region: LayoutObjectId, rank: usize) -> SpringSlotId {
let mut preimage = Preimage::new(DomainTag::CONFLICT);
preimage.push_bytes(b"layout/column-slot");
preimage.push_u64_le((region.0 >> 64) as u64);
preimage.push_u64_le(region.0 as u64);
preimage.push_u64_le(rank as u64);
SpringSlotId(preimage.finish_trunc128())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logical::to_logical;
use crate::time_axis::TimeAxis;
use epiphany_core::generators::valid_score_rich;
use std::collections::BTreeSet;
#[test]
fn constraint_strength_attaches_by_rule() {
// The spec's constraint enum carries no strength field, so strength is
// a rule over the constraint's own shape (Chapter 9 §"Strength Levels").
let glyph = GlyphObjectId(1);
let slot = SpringSlotId(2);
let required = [
LayoutConstraint::NoCollision { a: glyph, b: glyph },
LayoutConstraint::Align {
a: glyph,
b: glyph,
axis: Axis::Vertical,
},
LayoutConstraint::PositionWithin {
glyph,
region: Rect {
origin: Point::ORIGIN,
size: Size2D::default(),
},
},
LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Hard,
},
LayoutConstraint::PageBreakAt {
slot,
kind: BreakKind::Hard,
},
// Conservative: an unverifiable extension obligation is never demoted.
LayoutConstraint::Registered(ConstraintRegistryId(3), ConstraintParameters::default()),
];
for constraint in required {
assert_eq!(constraint.strength(), ConstraintStrength::Required);
}
// A soft break is a preference at the default weight.
for soft in [
LayoutConstraint::SystemBreakAt {
slot,
kind: BreakKind::Soft,
},
LayoutConstraint::PageBreakAt {
slot,
kind: BreakKind::Soft,
},
] {
assert_eq!(
soft.strength(),
ConstraintStrength::Preferred { weight: 1.0 }
);
}
}
#[test]
fn constraint_emission_is_deterministic_and_principled() {
let logical = to_logical(&valid_score_rich(11));
let a = try_to_constrained(&logical).expect("well-formed logical IR");
let b = try_to_constrained(&logical).expect("well-formed logical IR");
assert_eq!(
a.constraints, b.constraints,
"two runs emit identical constraint vectors"
);
assert!(!a.constraints.is_empty(), "the pipeline emits constraints");
// Every constraint references real glyphs/slots and finite geometry.
assert!(a.validate().is_ok());
// Containment: one PositionWithin per glyph, against its region envelope.
let contained = a
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::PositionWithin { .. }))
.count();
assert_eq!(contained, a.glyphs.len());
// No-collision: a linear chain over successive notehead columns, never
// the O(n²) all-pairs closure.
let pairs = a
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::NoCollision { .. }))
.count();
let noteheads = a
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("notehead"))
.count();
assert!(pairs > 0, "successive noteheads earn no-collision pairs");
assert!(pairs < noteheads, "the chain is linear in the noteheads");
// Every no-collision endpoint is a notehead in a distinct column slot.
let by_id: BTreeMap<GlyphObjectId, &GlyphObject> =
a.glyphs.iter().map(|g| (g.id(), g)).collect();
for constraint in &a.constraints {
if let LayoutConstraint::NoCollision {
a: first,
b: second,
} = constraint
{
let (first, second) = (by_id[first], by_id[second]);
assert!(first.glyph.as_str().starts_with("notehead"));
assert!(second.glyph.as_str().starts_with("notehead"));
assert_ne!(first.horizontal_slot, second.horizontal_slot);
}
}
// No break constraints without projected break overrides.
assert!(!a.constraints.iter().any(|c| matches!(
c,
LayoutConstraint::SystemBreakAt { .. } | LayoutConstraint::PageBreakAt { .. }
)));
}
#[test]
fn user_break_overrides_become_soft_break_constraints() {
use epiphany_core::generators::valid_score;
use epiphany_core::{AnchorOffset, Event, RegionEdge, TimeAnchor};
let mut score = valid_score(3);
let region_id = score.canvas.regions[0].id;
// A pitched event in region 0 whose onset column is realized (it draws
// noteheads), so the break has a spring slot to land on.
let event = score.canvas.regions[0]
.staff_instances()
.iter()
.flat_map(|si| si.voices.iter())
.flat_map(|voice| voice.events.iter().copied())
.find(|eid| {
matches!(score.events.get(*eid), Some(Event::Pitched(p)) if !p.pitches.is_empty())
})
.expect("valid_score has a pitched event");
let anchor = TimeAnchor::Event {
id: event,
offset: AnchorOffset::Zero,
};
let content = score.canvas.regions[0]
.content
.staff_based_mut()
.expect("valid_score is staff based");
content.user_system_breaks.push(anchor.clone());
content.user_page_breaks.push(anchor);
// An anchor no spacing column represents — a region edge — is skipped
// silently rather than mis-assigned to some column.
content.user_system_breaks.push(TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
});
let constrained = to_constrained(&to_logical(&score));
assert!(constrained.validate().is_ok());
let system_breaks: Vec<&LayoutConstraint> = constrained
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::SystemBreakAt { .. }))
.collect();
let page_breaks: Vec<&LayoutConstraint> = constrained
.constraints
.iter()
.filter(|c| matches!(c, LayoutConstraint::PageBreakAt { .. }))
.collect();
assert_eq!(
system_breaks.len(),
1,
"the event-anchored break lands; the region-edge one is skipped"
);
assert_eq!(page_breaks.len(), 1);
for projected in system_breaks.iter().chain(&page_breaks) {
let (LayoutConstraint::SystemBreakAt { slot, kind }
| LayoutConstraint::PageBreakAt { slot, kind }) = projected
else {
unreachable!("filtered to break constraints");
};
// A Soft override projects a Soft break — a Preferred obligation.
assert_eq!(*kind, BreakKind::Soft);
assert_eq!(
projected.strength(),
ConstraintStrength::Preferred { weight: 1.0 }
);
// The slot is the event's own (realized) onset column.
let slot = constrained
.horizontal_slots
.iter()
.find(|s| s.id == *slot)
.expect("break constraints name realized slots");
assert!(!slot.members.is_empty());
}
}
#[test]
fn ledger_steps_cover_only_lines_outside_the_staff() {
// Within the five-line staff (steps 0..=8) and one space just outside: none.
for step in [-1, 0, 4, 8, 9] {
assert!(ledger_steps(step).is_empty(), "no ledger at step {step}");
}
// First ledger above (step 10) and below (-2): exactly one line, on the note.
assert_eq!(ledger_steps(10), vec![10]);
assert_eq!(ledger_steps(-2), vec![-2]);
// A note in the space above the first ledger still needs just that line.
assert_eq!(ledger_steps(11), vec![10]);
// Two lines above / below, in order from the staff outward.
assert_eq!(ledger_steps(12), vec![10, 12]);
assert_eq!(ledger_steps(-4), vec![-2, -4]);
}
#[test]
fn ledger_line_keys_are_distinct_across_components_and_low_steps() {
// The high/low 64-bit split keeps the component and step fields disjoint —
// including a step below -128, whose two's-complement low bits are large and,
// under a naive `(comp << small) | (step + bias)`, would reach into the
// component field and collide with another component's key.
let mut seen = std::collections::HashSet::new();
for comp in 0..3usize {
for step in [-300, -200, -130, -128, -2, 10, 200] {
assert!(
seen.insert(ledger_line_key(comp, step).0),
"ledger key collision at comp={comp} step={step}"
);
}
}
}
#[test]
fn a_ledger_line_spans_its_notehead() {
// The ledger reaches past the notehead on both sides, for any notehead width
// — a whole note's head is wider than a black head, and the ledger must use
// the real bounding box, not a fixed notehead width.
let mut checked = 0;
for seed in 0..32 {
let c = to_constrained(&to_logical(&valid_score_rich(seed)));
for s in c.strokes.iter().filter(|s| is_rigid_width_stroke(s)) {
let lo = s.from.x.0.min(s.to.x.0);
let hi = s.from.x.0.max(s.to.x.0);
// The owning notehead: same source, baseline within the stroke span.
if let Some(g) = c.glyphs.iter().find(|g| {
g.provenance.source == s.provenance.source
&& g.baseline.x.0 >= lo
&& g.baseline.x.0 <= hi
}) {
let head_left = g.baseline.x.0 + g.bounding_box.left.0;
let head_right = g.baseline.x.0 + g.bounding_box.right.0;
assert!(
lo <= head_left + 1e-4 && hi >= head_right - 1e-4,
"seed {seed}: ledger [{lo}, {hi}] does not span notehead [{head_left}, {head_right}]"
);
checked += 1;
}
}
}
assert!(checked > 0, "no ledger/notehead pairs to check");
}
#[test]
fn a_note_far_above_the_staff_gets_ledger_strokes() {
// A constrained layout of the rich corpus has noteheads across the range; at
// least one sits far enough off the staff to earn a ledger line, emitted as a
// synthesized stroke sourced from its pitch.
let mut any_ledger = false;
for seed in 0..16 {
let c = to_constrained(&to_logical(&valid_score_rich(seed)));
if c.strokes.iter().any(|s| {
matches!(
s.provenance.synthesis,
Some(SynthesisKind::Registered(k)) if k == LEDGER_LINE_SYNTHESIS
)
}) {
any_ledger = true;
break;
}
}
assert!(
any_ledger,
"no ledger-line strokes across 16 rich-corpus seeds"
);
}
/// Every stroke and curve names a band that exists. A vertical solver reads
/// that band to find a primitive's owning staff, so a dangling reference
/// would silently drop the primitive out of the solve — it would keep its
/// source y while its staff moved, tearing off its notes.
///
/// Two bands exist precisely so nothing dangles. A staff band is emitted for
/// every staff of the region, not only those that emitted a glyph (a staff
/// whose clef is unbundled engraves to an anchor *stroke* and no glyph). The
/// margin band is emitted unconditionally, because a region's own traced
/// anchor is a stroke that names it whether or not a margin glyph does.
#[test]
fn every_stroke_and_curve_names_a_band_that_exists() {
use epiphany_core::generators::valid_score;
for (label, score) in [
("valid_score_rich", valid_score_rich(11)),
("valid_score", valid_score(4)),
] {
let c = to_constrained(&to_logical(&score));
let bands: BTreeSet<VerticalBandId> = c.vertical_bands.iter().map(|b| b.id).collect();
assert!(!bands.is_empty(), "{label}: the projection emits bands");
for stroke in &c.strokes {
assert!(
bands.contains(&stroke.vertical_band),
"{label}: stroke {:?} names a band that does not exist",
stroke.id()
);
}
for curve in &c.curves {
assert!(
bands.contains(&curve.vertical_band),
"{label}: curve {:?} names a band that does not exist",
curve.id()
);
}
// The region's own anchor stroke is staff-less, so a margin band must
// exist even when no region-level *glyph* put a member in it.
assert!(
c.vertical_bands
.iter()
.any(|b| b.kind == crate::VerticalBandKind::MarginBand),
"{label}: a margin band exists for the region-level anchor"
);
assert!(c.validate().is_ok(), "{label}: the projection validates");
}
}
#[test]
fn out_of_range_finite_stroke_thickness_is_rejected() {
let mut c = to_constrained(&to_logical(&valid_score_rich(11)));
let provenance = c.glyphs[0].provenance.clone();
let band = c.glyphs[0].vertical_band;
c.strokes.push(Stroke {
provenance,
vertical_band: band,
from: Point::new(0.0, 0.0),
to: Point::new(1.0, 0.0),
// Finite but far outside the canonical 1/1024 grid range: it passes a
// bare finite/non-negative check yet would panic in `canonical_bytes`.
thickness: StaffSpace(f32::MAX),
layer: 0,
style: GlyphStyle::default(),
});
assert!(
matches!(
c.validate(),
Err(ConstrainedValidationError::InvalidStrokeGeometry(_))
),
"a finite-but-out-of-range stroke thickness is rejected"
);
}
/// The contract the engraver's coordinate remap relies on: every spring slot
/// has a member glyph (a source x). An externally-built IR with an empty slot
/// — valid in every other respect — is rejected, not silently accepted to be
/// hit later as a "target with no source" remap hole.
#[test]
fn an_empty_spring_slot_is_rejected() {
let mut c = to_constrained(&to_logical(&valid_score_rich(11)));
assert!(c.validate().is_ok());
c.horizontal_slots.push(SpringSlot {
id: SpringSlotId(0xDEAD_BEEF),
time: TimePoint::WallClock(WallClockTime(0)),
min_width: StaffSpace(1.0),
preferred_width: StaffSpace(1.5),
max_width: None,
stretch_factor: 1.0,
compress_factor: 1.0,
members: vec![],
});
assert!(
matches!(c.validate(), Err(ConstrainedValidationError::EmptySlot(_))),
"an empty spring slot is rejected"
);
}
/// The direct logical-IR way to reach the empty-slot shape: a pitched event
/// with no pitches marks a column but emits only a stem (no notehead). The
/// column must stay slot-less, so `to_constrained`'s own output validates.
#[test]
fn pitchless_note_produces_no_empty_slot() {
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR, NoteContent};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{EventId, MusicalPosition, RegionId, StaffId};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let note = LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![],
});
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![LayoutObject::from_projection_with_content(
Provenance::manifested(
TypedObjectId::Event(EventId::from_raw(1)),
region,
vec![],
),
Some(staff),
note,
)],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
assert!(
c.validate().is_ok(),
"to_constrained must not produce an empty slot from a pitchless note"
);
assert!(c.horizontal_slots.iter().all(|s| !s.members.is_empty()));
// The event is still covered — by its (zero-length) stem anchor.
assert!(c
.strokes
.iter()
.any(|s| s.provenance.source == TypedObjectId::Event(EventId::from_raw(1))));
}
/// The fallible public conversion must not panic when externally built
/// logical IR pairs a source kind with non-matching content. Pass 2 has
/// explicit fallbacks for these cases (default treble clef / final barline),
/// so pass 1 must collect the columns those fallbacks use.
#[test]
fn source_content_mismatches_use_fallback_columns() {
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{MeasureId, RegionId, StaffId, StaffInstanceId};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let staff_instance = StaffInstanceId::from_raw(20);
let measure = MeasureId::from_raw(30);
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![
LayoutObject::from_projection_with_content(
Provenance::manifested(
TypedObjectId::StaffInstance(staff_instance),
region,
vec![],
),
Some(staff),
LayoutContent::Structural,
),
LayoutObject::from_projection_with_content(
Provenance::manifested(TypedObjectId::Measure(measure), region, vec![]),
Some(staff),
LayoutContent::Structural,
),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = try_to_constrained(&logical)
.expect("mismatched public logical IR should use fallback columns");
assert!(c.validate().is_ok());
assert!(c.glyphs.iter().any(|g| {
g.provenance.source == TypedObjectId::StaffInstance(staff_instance)
&& g.glyph.as_str() == "gClef"
}));
assert!(c.glyphs.iter().any(|g| {
g.provenance.source == TypedObjectId::Measure(measure)
&& g.glyph.as_str() == "barlineFinal"
}));
}
/// A spelling's full accidental *stack* draws — every element, innermost
/// nearest the notehead — not just the first, with distinct synthesized ids.
#[test]
fn a_stacked_accidental_draws_every_element() {
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR, NoteContent, NotePitch};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{
AccidentalId, CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, RegionId,
StaffId,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch = PitchId::from_raw(100);
let mut spelling = PitchSpelling::cmn(CmnNominal::C, 5);
// Innermost (nearest the notehead) first, then an outer element.
spelling.accidentals.push(AccidentalId::new("sharp"));
spelling.accidentals.push(AccidentalId::new("flat"));
let note = LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![NotePitch {
pitch,
spelling: Some(spelling),
}],
});
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: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![
manifested(TypedObjectId::Event(EventId::from_raw(1)), note),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let accidentals: Vec<_> = c
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("accidental"))
.collect();
assert_eq!(accidentals.len(), 2, "both stack elements are drawn");
let sharp = accidentals
.iter()
.find(|g| g.glyph.as_str() == "accidentalSharp")
.expect("innermost sharp drawn");
let flat = accidentals
.iter()
.find(|g| g.glyph.as_str() == "accidentalFlat")
.expect("outer flat drawn");
// The innermost (sharp, stack index 0) sits nearer the notehead.
assert!(
sharp.baseline.x.0 > flat.baseline.x.0,
"the innermost accidental is nearer the notehead than the outer"
);
assert_ne!(sharp.provenance.stable_id, flat.provenance.stable_id);
assert!(accidentals
.iter()
.all(|g| g.provenance.source == TypedObjectId::Pitch(pitch)));
assert!(c.validate().is_ok());
}
/// A pitch whose spelling carries an accidental draws it as a synthesized
/// glyph just left of the notehead, at the same staff position, sharing the
/// notehead's column slot — the notehead keeps the pitch's exact provenance.
#[test]
fn a_spelled_accidental_draws_left_of_its_notehead() {
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR, NoteContent, NotePitch};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{
AccidentalId, CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, RegionId,
StaffId,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch = PitchId::from_raw(100);
let mut spelling = PitchSpelling::cmn(CmnNominal::C, 5);
spelling.accidentals.push(AccidentalId::new("sharp"));
let note = LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![NotePitch {
pitch,
spelling: Some(spelling),
}],
});
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: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![
manifested(TypedObjectId::Event(EventId::from_raw(1)), note),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let notehead = c
.glyphs
.iter()
.find(|g| g.glyph.as_str().starts_with("notehead"))
.expect("a notehead is drawn");
let accidental = c
.glyphs
.iter()
.find(|g| g.glyph.as_str() == "accidentalSharp")
.expect("the sharp accidental is drawn");
// The notehead carries the pitch's exact provenance; the accidental is a
// distinct, synthesized glyph from the same source.
assert!(notehead.provenance.synthesis.is_none());
assert_eq!(notehead.provenance.source, TypedObjectId::Pitch(pitch));
assert!(accidental.provenance.synthesis.is_some());
assert_eq!(accidental.provenance.source, TypedObjectId::Pitch(pitch));
assert_ne!(
accidental.provenance.stable_id,
notehead.provenance.stable_id
);
// Left of the notehead, same staff position, same column slot.
assert!(accidental.baseline.x.0 < notehead.baseline.x.0);
assert_eq!(accidental.baseline.y, notehead.baseline.y);
assert_eq!(accidental.horizontal_slot, notehead.horizontal_slot);
// The accidental is a proper slot/band member — the IR validates.
assert!(c.validate().is_ok());
}
/// A key signature draws its sharp/flat zigzag in the lead area after the
/// clef, each accidental a synthesized glyph at its clef-relative staff
/// position, sharing the clef's column slot.
#[test]
fn a_key_signature_draws_its_accidentals_in_the_lead() {
use crate::logical::{
LayoutObject, LayoutRegion, LogicalLayoutIR, PlacedKeySignature, StaffContent,
};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{KeySignature, MusicalPosition, RegionId, StaffId, StaffInstanceId};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let instance = StaffInstanceId::from_raw(1);
// D major (two sharps), default treble clef.
let content = LayoutContent::Staff(StaffContent {
default_clef: Clef::default(),
clefs: vec![],
keys: vec![PlacedKeySignature {
time: TimePoint::Musical(MusicalPosition::origin()),
key: KeySignature::new(2).expect("two sharps is a valid key"),
}],
});
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![LayoutObject::from_projection_with_content(
Provenance::manifested(TypedObjectId::StaffInstance(instance), region, vec![]),
Some(staff),
content,
)],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let sharps: Vec<_> = c
.glyphs
.iter()
.filter(|g| g.glyph.as_str() == "accidentalSharp")
.collect();
assert_eq!(
sharps.len(),
2,
"a two-sharp key signature draws two sharps"
);
// Synthesized from the staff instance, distinct ids.
assert!(sharps.iter().all(|g| g.provenance.synthesis.is_some()));
assert!(sharps
.iter()
.all(|g| g.provenance.source == TypedObjectId::StaffInstance(instance)));
assert_ne!(
sharps[0].provenance.stable_id,
sharps[1].provenance.stable_id
);
// In the lead, right of the clef, left-to-right, sharing the clef's slot.
let clef = c
.glyphs
.iter()
.find(|g| g.glyph.as_str() == "gClef")
.expect("clef");
assert!(sharps.iter().all(|g| g.baseline.x.0 > clef.baseline.x.0));
assert!(sharps[0].baseline.x.0 < sharps[1].baseline.x.0);
assert!(sharps
.iter()
.all(|g| g.horizontal_slot == clef.horizontal_slot));
// The conventional treble placement: F♯ on the top line (step 8 → y 4),
// C♯ in the third space (step 5 → y 2.5).
assert_eq!(sharps[0].baseline.y.0, 4.0);
assert_eq!(sharps[1].baseline.y.0, 2.5);
assert!(c.validate().is_ok());
}
/// A measure that introduces a time signature draws a numerator-over-
/// denominator digit pair right of its barline, each digit synthesized from
/// the measure and sharing the barline's column slot. An unbundled digit is
/// surfaced as a diagnostic, not drawn at a guessed shape.
#[test]
fn a_time_signature_draws_a_digit_pair_after_the_barline() {
use crate::logical::{
BarlineKind, LayoutObject, LayoutRegion, LogicalLayoutIR, MeasureContent,
TimeSignatureContent,
};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{MeasureId, MusicalPosition, RegionId, StaffId};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let measure = MeasureId::from_raw(7);
let build = |numerator: u16, denominator: u16| {
let content = LayoutContent::Measure(MeasureContent {
start: TimePoint::Musical(MusicalPosition::origin()),
barline: BarlineKind::Interior,
time_signature: Some(TimeSignatureContent {
numerator,
denominator,
}),
});
LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![LayoutObject::from_projection_with_content(
Provenance::manifested(TypedObjectId::Measure(measure), region, vec![]),
Some(staff),
content,
)],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
}
};
// 4/4: both digits are bundled, so two '4' glyphs are drawn.
let c = to_constrained(&build(4, 4));
let fours: Vec<_> = c
.glyphs
.iter()
.filter(|g| g.glyph.as_str() == "timeSig4")
.collect();
assert_eq!(fours.len(), 2, "4/4 draws two '4' digits");
assert!(fours.iter().all(|g| g.provenance.synthesis.is_some()));
assert!(fours
.iter()
.all(|g| g.provenance.source == TypedObjectId::Measure(measure)));
assert_ne!(fours[0].provenance.stable_id, fours[1].provenance.stable_id);
let barline = c
.glyphs
.iter()
.find(|g| g.glyph.as_str() == "barlineSingle")
.expect("a barline is drawn");
assert!(fours.iter().all(|g| g.baseline.x.0 > barline.baseline.x.0));
assert!(fours
.iter()
.all(|g| g.horizontal_slot == barline.horizontal_slot));
// Numerator above the denominator (distinct vertical positions).
let upper = fours
.iter()
.map(|g| g.baseline.y.0)
.fold(f32::MIN, f32::max);
let lower = fours
.iter()
.map(|g| g.baseline.y.0)
.fold(f32::MAX, f32::min);
assert!(upper > lower, "numerator sits above the denominator");
assert!(c.validate().is_ok());
assert!(c.diagnostics.is_empty(), "4/4 digits are all bundled");
// 3/4: every digit (09) is now bundled, so both draw with no diagnostic.
let c3 = to_constrained(&build(3, 4));
assert!(c3.glyphs.iter().any(|g| g.glyph.as_str() == "timeSig3"));
assert!(c3.glyphs.iter().any(|g| g.glyph.as_str() == "timeSig4"));
assert!(
c3.diagnostics.is_empty(),
"all single-digit time-signature values are bundled"
);
// A two-digit number lays its digits out side by side (e.g. 12/8).
let c12 = to_constrained(&build(12, 8));
let ones = c12
.glyphs
.iter()
.filter(|g| g.glyph.as_str() == "timeSig1")
.count();
assert_eq!(ones, 1, "the '1' of 12 is drawn");
assert!(c12.glyphs.iter().any(|g| g.glyph.as_str() == "timeSig2"));
assert!(c12.glyphs.iter().any(|g| g.glyph.as_str() == "timeSig8"));
}
/// A note notated as a multi-component (tied) decomposition draws one
/// notehead per component at its own offset — not a single notehead at the
/// event start. The first component carries the pitch's exact provenance, the
/// rest are synthesized from it.
#[test]
fn tied_decomposition_draws_a_notehead_per_component() {
use crate::logical::{
LayoutObject, LayoutRegion, LogicalLayoutIR, NoteContent, NotePitch, PlacedComponent,
};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{
CmnNominal, EventId, MusicalPosition, NotatedComponent, PitchId, PitchSpelling,
RationalTime, RegionId, StaffId,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch = PitchId::from_raw(100);
let component = |base, num, den, tied| PlacedComponent {
offset: MusicalDuration(RationalTime::new(num, den).unwrap()),
component: NotatedComponent {
base_value: base,
dots: 0,
tuplet: None,
tied_to_next: tied,
},
tuplet: None,
};
// A quarter tied to an eighth: two components at offsets 0 and 1/4.
let note = LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![
component(NoteValue::Quarter, 0, 1, true),
component(NoteValue::Eighth, 1, 4, false),
],
pitches: vec![NotePitch {
pitch,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 4)),
}],
});
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: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![
manifested(TypedObjectId::Event(EventId::from_raw(1)), note),
manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let heads: Vec<_> = c
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("notehead"))
.collect();
assert_eq!(
heads.len(),
2,
"two components → two noteheads (not collapsed)"
);
assert_ne!(
heads[0].baseline.x, heads[1].baseline.x,
"the second component sits at a later column (its offset is honored)"
);
// The pitch's exact source is on one notehead; the other is synthesized
// from it — so the round-trip recovers the pitch once, no duplicate id.
let exact = heads
.iter()
.filter(|g| g.provenance.synthesis.is_none())
.count();
let synth = heads
.iter()
.filter(|g| g.provenance.synthesis.is_some())
.count();
assert_eq!((exact, synth), (1, 1));
assert!(heads
.iter()
.all(|g| g.provenance.source == TypedObjectId::Pitch(pitch)));
// Two stems too — one per component (the event's, plus a synthesized one).
let stems = c
.strokes
.iter()
.filter(|s| s.provenance.source == TypedObjectId::Event(EventId::from_raw(1)))
.count();
assert_eq!(stems, 2, "one stem per component");
}
/// `active_clef` resolves by time, not vector order: an unsorted clef
/// sequence still yields the latest change at or before the query.
#[test]
fn active_clef_resolves_by_time_not_vector_order() {
use crate::logical::PlacedClef;
use epiphany_core::{MusicalPosition, RationalTime};
let at = |n, d| TimePoint::Musical(MusicalPosition(RationalTime::new(n, d).unwrap()));
// Authored out of order: bass at time 1, treble at time 0.
let clefs = vec![
PlacedClef {
time: at(1, 1),
clef: Clef::bass(),
},
PlacedClef {
time: at(0, 1),
clef: Clef::treble(),
},
];
// After time 1 → bass (latest change ≤ query), not treble (last in vector).
assert_eq!(active_clef(&clefs, &at(2, 1)), Clef::bass());
// At time 1/2 → treble (the change at time 0).
assert_eq!(active_clef(&clefs, &at(1, 2)), Clef::treble());
// Before any change → the earliest-timed clef (treble@0).
assert_eq!(active_clef(&clefs, &at(-1, 1)), Clef::treble());
// Empty → default treble.
assert_eq!(active_clef(&[], &at(0, 1)), Clef::default());
}
/// The displayed lead clef agrees with the notes' active clef: an unsorted
/// `[bass@1, treble@0]` sequence draws a treble clef at the start (the clef in
/// force at the staff start, by time), not bass (the vector-first entry).
#[test]
fn lead_clef_glyph_uses_time_order_not_vector_order() {
use crate::logical::{
LayoutObject, LayoutRegion, LogicalLayoutIR, PlacedClef, StaffContent,
};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{MusicalPosition, RationalTime, RegionId, StaffId, StaffInstanceId};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let at = |n, d| TimePoint::Musical(MusicalPosition(RationalTime::new(n, d).unwrap()));
let content = LayoutContent::Staff(StaffContent {
default_clef: Clef::default(),
// Authored out of order: bass at time 1, treble at time 0.
clefs: vec![
PlacedClef {
time: at(1, 1),
clef: Clef::bass(),
},
PlacedClef {
time: at(0, 1),
clef: Clef::treble(),
},
],
keys: vec![],
});
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![LayoutObject::from_projection_with_content(
Provenance::manifested(
TypedObjectId::StaffInstance(StaffInstanceId::from_raw(1)),
region,
vec![],
),
Some(staff),
content,
)],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let clef = c
.glyphs
.iter()
.find(|g| g.glyph.as_str().ends_with("Clef"))
.expect("a clef glyph is drawn");
assert_eq!(
clef.glyph.as_str(),
"gClef",
"the lead clef is the treble in force at the start, not the vector-first bass"
);
}
/// A rest with no bundled glyph (a sixteenth) is a traced anchor at its *own
/// onset column*, not a default x, and every component is kept (later ones do
/// not vanish), with each unbundled value surfaced as a diagnostic.
#[test]
fn unbundled_rest_components_anchor_at_their_onset() {
use crate::logical::{
LayoutObject, LayoutRegion, LogicalLayoutIR, PlacedComponent, RestContent,
};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{
EventId, MusicalPosition, NotatedComponent, RationalTime, RegionId, StaffId,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let eid = EventId::from_raw(1);
let component = |num, den| PlacedComponent {
offset: MusicalDuration(RationalTime::new(num, den).unwrap()),
component: NotatedComponent {
base_value: NoteValue::Sixteenth, // no bundled rest glyph
dots: 0,
tuplet: None,
tied_to_next: false,
},
tuplet: None,
};
let rest = LayoutContent::Rest(RestContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![component(0, 1), component(1, 16)],
staff_position: None,
});
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![LayoutObject::from_projection_with_content(
Provenance::manifested(TypedObjectId::Event(eid), region, vec![]),
Some(staff),
rest,
)],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
// No rest glyph (the value is unbundled).
assert!(c
.glyphs
.iter()
.all(|g| !g.glyph.as_str().starts_with("rest")));
// Both unbundled components are surfaced.
let unbundled = c
.diagnostics
.iter()
.filter(|d| matches!(d.kind, LayoutDiagnosticKind::UnbundledGlyph(_)))
.count();
assert_eq!(unbundled, 2, "each unbundled rest component is diagnosed");
// Both components are kept, anchored at distinct onset columns — not piled
// at a default x.
let anchors: Vec<_> = c
.strokes
.iter()
.filter(|s| s.provenance.source == TypedObjectId::Event(eid))
.collect();
assert_eq!(anchors.len(), 2, "no component vanishes");
assert_ne!(
anchors[0].from.x, anchors[1].from.x,
"components anchor at their distinct onset columns"
);
assert!(
anchors.iter().all(|a| a.from.x.0 >= FIRST_COLUMN_X),
"an unbundled rest anchors at its onset column, not the default x"
);
// Stroke-only columns earn no spring slot: this region has no glyphs, so
// no slots — the engraver's remap never faces an empty slot with no
// source→target point.
assert!(
c.horizontal_slots.is_empty(),
"a stroke-only (unbundled-rest) column creates no spring slot"
);
}
/// No spring slot is ever empty: a slot exists only for a glyph-bearing
/// column, so the engraver's coordinate remap always has a source point for
/// every slot.
#[test]
fn no_spring_slot_is_empty() {
for seed in 0..32u64 {
let c = to_constrained(&to_logical(&valid_score_rich(seed)));
for slot in &c.horizontal_slots {
assert!(
!slot.members.is_empty(),
"a spring slot has no glyph members (would break the remap)"
);
}
}
}
#[test]
fn spacing_populates_a_consumable_time_axis_per_region() {
let c = to_constrained(&to_logical(&valid_score_rich(11)));
assert!(!c.regions.is_empty());
let slot_ids: BTreeSet<_> = c.horizontal_slots.iter().map(|s| s.id).collect();
// Every glyph names one of the IR's real spring slots (its column).
for glyph in &c.glyphs {
assert!(slot_ids.contains(&glyph.horizontal_slot));
}
for region in &c.regions {
// The axis indexes musical *note* columns; each placement's time
// projects back to that column's slot (not a constant).
for placement in region.time_axis.placements() {
assert!(slot_ids.contains(&placement.slot));
assert_eq!(
region.time_axis.project(placement.time.clone()),
placement.slot
);
}
// Distinct note columns have distinct times (project is a real
// function of the query, not "always the first slot").
if region.time_axis.placements().len() >= 2 {
let p = region.time_axis.placements();
assert_ne!(
region.time_axis.project(p[0].time.clone()),
region.time_axis.project(p[1].time.clone())
);
}
}
}
/// Chord/simultaneous glyphs share one column slot — the per-musical-column
/// contract — rather than each getting its own.
#[test]
fn simultaneous_glyphs_share_one_column_slot() {
use crate::logical::{LayoutObject, LayoutRegion, LogicalLayoutIR, NoteContent, NotePitch};
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use epiphany_core::{
CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, RegionId, StaffId,
};
let region = RegionId::from_raw(1);
let staff = StaffId::from_raw(10);
let pitch_a = PitchId::from_raw(100);
let pitch_b = PitchId::from_raw(101);
let manifested = |src, content| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
// One event, two pitches at the same onset (a chord).
let note = LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![
NotePitch {
pitch: pitch_a,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 4)),
},
NotePitch {
pitch: pitch_b,
spelling: Some(PitchSpelling::cmn(CmnNominal::E, 4)),
},
],
});
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff],
},
objects: vec![
manifested(TypedObjectId::Event(EventId::from_raw(1)), note),
manifested(TypedObjectId::Pitch(pitch_a), LayoutContent::Structural),
manifested(TypedObjectId::Pitch(pitch_b), LayoutContent::Structural),
],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let heads: Vec<_> = c
.glyphs
.iter()
.filter(|g| g.glyph.as_str().starts_with("notehead"))
.collect();
assert_eq!(heads.len(), 2, "both chord pitches draw a notehead");
assert_eq!(
heads[0].horizontal_slot, heads[1].horizontal_slot,
"chord noteheads share one column slot"
);
assert_eq!(
heads[0].baseline.x, heads[1].baseline.x,
"…and therefore share an x"
);
assert_ne!(
heads[0].baseline.y, heads[1].baseline.y,
"but sit at distinct staff positions"
);
}
/// Band membership is a correct partition: every glyph names an existing
/// band, no glyph is a member of two bands, and a glyph's `vertical_band`
/// equals the band that lists it — so a glyph is never placed in another
/// staff's band.
#[test]
fn glyphs_are_routed_to_exactly_their_band() {
for seed in 0..48u64 {
let c = to_constrained(&to_logical(&valid_score_rich(seed)));
let band_ids: BTreeSet<_> = c.vertical_bands.iter().map(|b| b.id).collect();
let mut member_band: BTreeMap<GlyphObjectId, VerticalBandId> = BTreeMap::new();
for b in &c.vertical_bands {
for m in &b.members {
assert!(
member_band.insert(*m, b.id).is_none(),
"a glyph is a member of two bands"
);
}
}
for g in &c.glyphs {
assert!(
band_ids.contains(&g.vertical_band),
"glyph names an unknown band"
);
assert_eq!(
member_band.get(&g.id()),
Some(&g.vertical_band),
"glyph is not a member of the band it names"
);
}
}
}
/// A two-staff region yields a staff band per staff (no cross-staff
/// contamination) plus an inter-staff gap band; each staff's glyphs land in
/// that staff's band only. The glyph-bearing objects are a staff instance
/// (its clef) and a pitched event's pitch (its notehead) per staff — staff
/// objects and stems engrave to free stroke lines, not band members.
#[test]
fn multi_staff_region_routes_per_staff_with_a_gap_band() {
use crate::logical::{
LayoutObject, LayoutRegion, LogicalLayoutIR, NoteContent, NotePitch, StaffContent,
};
use crate::provenance::Provenance;
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use crate::vertical_band::VerticalBandKind;
use epiphany_core::{
CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, RegionId, StaffId,
StaffInstanceId,
};
let region = RegionId::from_raw(1);
let region_src = TypedObjectId::Region(region);
let staff_a = StaffId::from_raw(10);
let staff_b = StaffId::from_raw(20);
let with_content = |src: TypedObjectId, staff: StaffId, content: LayoutContent| {
LayoutObject::from_projection_with_content(
Provenance::manifested(src, region, vec![]),
Some(staff),
content,
)
};
// Per staff: a staff instance (its clef glyph) and a single-pitch note
// whose pitch draws a notehead — two band glyphs each.
let staff_objects = |staff: StaffId, si: u128, eid: u128, pid: u128| {
let pitch = PitchId::from_raw(pid);
vec![
with_content(
TypedObjectId::StaffInstance(StaffInstanceId::from_raw(si)),
staff,
LayoutContent::Staff(StaffContent {
default_clef: Clef::default(),
clefs: vec![],
keys: vec![],
}),
),
with_content(
TypedObjectId::Event(EventId::from_raw(eid)),
staff,
LayoutContent::Note(NoteContent {
position: TimePoint::Musical(MusicalPosition::origin()),
components: vec![],
pitches: vec![NotePitch {
pitch,
spelling: Some(PitchSpelling::cmn(CmnNominal::C, 4)),
}],
}),
),
with_content(
TypedObjectId::Pitch(pitch),
staff,
LayoutContent::Structural,
),
]
};
let mut objects = staff_objects(staff_a, 1, 1, 100);
objects.extend(staff_objects(staff_b, 2, 2, 200));
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(region_src, vec![]),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent {
staves: vec![staff_a, staff_b],
},
objects,
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
let c = to_constrained(&logical);
let staff_bands: Vec<_> = c
.vertical_bands
.iter()
.filter(|b| matches!(b.kind, VerticalBandKind::Staff(_)))
.collect();
let gap_bands = c
.vertical_bands
.iter()
.filter(|b| matches!(b.kind, VerticalBandKind::InterStaffGap))
.count();
assert_eq!(staff_bands.len(), 2, "one staff band per staff");
assert_eq!(gap_bands, 1, "one inter-staff gap band between two staves");
// Staff A's two glyphs (clef + notehead) are in A's band only.
let band_a = staff_bands
.iter()
.find(|b| b.kind == VerticalBandKind::Staff(staff_a))
.unwrap();
let band_b = staff_bands
.iter()
.find(|b| b.kind == VerticalBandKind::Staff(staff_b))
.unwrap();
assert_eq!(band_a.members.len(), 2);
assert_eq!(band_b.members.len(), 2);
let a_set: BTreeSet<_> = band_a.members.iter().collect();
assert!(
band_b.members.iter().all(|m| !a_set.contains(m)),
"no glyph is in both staves' bands"
);
}
#[test]
fn malformed_region_provenance_is_rejected_not_dropped() {
use crate::time_axis::{MetricTimeAxis, TimeAxisModel};
use crate::LayoutRegion;
use epiphany_core::EventId;
let logical = LogicalLayoutIR {
source: ScoreVersion::default(),
regions: vec![LayoutRegion {
provenance: Provenance::projected(
TypedObjectId::Event(EventId::from_raw(9)),
vec![],
),
coordinate_system: crate::LocalCoordinateSystem::default(),
time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()),
vertical_extent: crate::VerticalExtent::default(),
objects: vec![],
}],
engraving_decisions: vec![],
overrides: vec![],
cross_region: vec![],
};
assert!(matches!(
try_to_constrained(&logical),
Err(LayoutTransformError::RegionSourceIsNotRegion(_))
));
}
// --- Repeat barlines and volta brackets (schema-major-2 E1) ------------
use epiphany_core::{
AnchorOffset, BeatGroup, MusicalDuration, PowerOfTwo, RationalTime, RegionEdge, RepeatKind,
RepeatStructure, RepeatStructureId, Score, TimeSignature, TimeSignatureDisplay,
TimeSignatureId, Volta,
};
/// `valid_score_rich` with four extra measures appended to region A's first
/// staff instance (region-anchored at whole-note offsets 1..=4), so repeat
/// boundaries have real barline columns to land on. Returns the score and
/// the five measure ids in order; the last measure's barline is the
/// region-final one (region A's staff manifests nowhere else).
fn repeat_ready_score(seed: u64) -> (Score, Vec<MeasureId>) {
let mut score = valid_score_rich(seed);
let region_id = score.canvas.regions[0].id;
let extra: Vec<MeasureId> = (0..4).map(|_| score.identity.mint()).collect();
let instance = &mut score.canvas.regions[0]
.content
.staff_instances_mut()
.expect("region A is staff-based")[0];
let mut ids = vec![instance.measures[0].id];
for (index, id) in extra.iter().enumerate() {
instance.measures.push(epiphany_core::Measure {
id: *id,
start: TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(
RationalTime::new(index as i64 + 1, 1).expect("nonzero"),
)),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
});
}
ids.extend(extra);
(score, ids)
}
fn measure_start(id: MeasureId) -> TimeAnchor {
TimeAnchor::Measure {
id,
position: MeasurePosition::Start,
offset: AnchorOffset::Zero,
}
}
fn named<'a>(constrained: &'a ConstrainedLayoutIR, name: &str) -> Vec<&'a GlyphObject> {
constrained
.glyphs
.iter()
.filter(|glyph| glyph.glyph.as_str() == name)
.collect()
}
#[test]
fn repeat_boundaries_morph_their_measure_barlines_and_voltas_draw_brackets() {
let (mut score, m) = repeat_ready_score(21);
// The measure gaining the start sign also introduces a 4/4, so the
// test covers the time signature clearing the wider sign's ink.
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"),
);
score.canvas.regions[0]
.content
.staff_instances_mut()
.expect("region A is staff-based")[0]
.measures
.iter_mut()
.find(|measure| measure.id == m[1])
.expect("m1 exists")
.time_signature = Some(ts_id);
let a: RepeatStructureId = score.identity.mint();
let b: RepeatStructureId = score.identity.mint();
score.cross_cutting.repeats.push(RepeatStructure {
id: a,
start: measure_start(m[1]),
end: measure_start(m[2]),
kind: RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
score.cross_cutting.repeats.push(RepeatStructure {
id: b,
start: measure_start(m[2]),
end: measure_start(m[3]),
kind: RepeatKind::Volta,
voltas: vec![Volta {
endings: vec![2, 3],
start: measure_start(m[2]),
end: measure_start(m[3]),
}],
});
let constrained = to_constrained(&to_logical(&score));
// The three boundary columns morph their measure barlines: a start
// sign, the combined sign where A's end meets B's start, and an end
// sign — each keeping the measure's own exact provenance (a morph is a
// name change, not a new primitive; the round-trip provenance floor
// depends on that).
for name in ["repeatLeft", "repeatRightLeft", "repeatRight"] {
let signs = named(&constrained, name);
assert_eq!(signs.len(), 1, "exactly one {name} sign");
let sign = signs[0];
assert!(
matches!(sign.provenance.source, TypedObjectId::Measure(_)),
"{name} is the measure's own (morphed) barline"
);
assert!(sign.provenance.synthesis.is_none());
}
// Exactly three plain barlines morphed away: five measures draw
// m0..=m3 at their start columns and m4 at the region end.
assert_eq!(named(&constrained, "barlineSingle").len(), 1);
assert_eq!(named(&constrained, "barlineFinal").len(), 1);
// The volta bracket: three strokes (line + two hooks) above the staff,
// synthesized from the repeat, spanning m2's column to m3's.
let bracket: Vec<&Stroke> = constrained
.strokes
.iter()
.filter(|stroke| {
matches!(
stroke.provenance.synthesis,
Some(SynthesisKind::Registered(k)) if k == VOLTA_SYNTHESIS
) && matches!(stroke.provenance.source, TypedObjectId::RepeatStructure(id) if id == b)
})
.collect();
assert_eq!(bracket.len(), 3, "bracket line plus two hooks");
let line = bracket
.iter()
.find(|stroke| stroke.from.y == stroke.to.y)
.expect("the bracket has a horizontal line");
assert!(
line.from.y.0 > STAFF_HEIGHT,
"the bracket sits above the staff"
);
assert!(
line.to.x.0 > line.from.x.0,
"the bracket spans left to right"
);
for hook in bracket.iter().filter(|stroke| stroke.from.y != stroke.to.y) {
assert_eq!(hook.from.x, hook.to.x, "hooks are vertical");
assert!(hook.to.y.0 < hook.from.y.0, "hooks descend from the line");
}
// The ending numbers "2 3" draw as digit glyphs synthesized from the
// repeat, under the bracket line.
for digit in ["timeSig2", "timeSig3"] {
let glyphs = named(&constrained, digit);
let volta_digit = glyphs
.iter()
.find(|glyph| {
matches!(glyph.provenance.source, TypedObjectId::RepeatStructure(id) if id == b)
})
.unwrap_or_else(|| panic!("volta ending digit {digit} is drawn"));
assert!(volta_digit.baseline.y.0 > STAFF_HEIGHT);
assert!(volta_digit.baseline.y.0 < line.from.y.0);
}
// The 4/4 the morphed measure introduces clears the sign's ink: every
// measure-owned digit starts right of the repeatLeft's right edge.
let start_sign = named(&constrained, "repeatLeft")[0];
let sign_right = start_sign.baseline.x.0 + start_sign.bounding_box.right.0;
let measure_digits: Vec<&GlyphObject> = constrained
.glyphs
.iter()
.filter(|glyph| {
glyph.glyph.as_str().starts_with("timeSig")
&& matches!(glyph.provenance.source, TypedObjectId::Measure(_))
})
.collect();
assert!(!measure_digits.is_empty(), "the 4/4 draws digit glyphs");
for digit in measure_digits {
assert!(
digit.baseline.x.0 + digit.bounding_box.left.0 >= sign_right,
"time-signature digits clear the repeat sign's ink"
);
}
// The full pipeline round-trips: every source recovered exactly once,
// every synthesized primitive with a distinct stable id.
crate::roundtrip::round_trip(&score);
}
#[test]
fn off_grid_and_region_end_boundaries_stand_alone() {
let (mut score, m) = repeat_ready_score(22);
// Region A's triplet events sit at 0, 1/12, 2/12 — the second event is
// mid-measure, off every barline column.
let mid_measure_event = score.canvas.regions[0].staff_instances()[0].voices[0].events[1];
let c: RepeatStructureId = score.identity.mint();
score.cross_cutting.repeats.push(RepeatStructure {
id: c,
start: TimeAnchor::Event {
id: mid_measure_event,
offset: AnchorOffset::Zero,
},
end: TimeAnchor::Measure {
id: m[4],
position: MeasurePosition::End,
offset: AnchorOffset::Zero,
},
kind: RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
let constrained = to_constrained(&to_logical(&score));
// The mid-measure start mints its own column and stands alone,
// synthesized from the repeat (no measure barline morphs).
let left = named(&constrained, "repeatLeft");
assert_eq!(left.len(), 1);
assert!(matches!(left[0].provenance.source, TypedObjectId::RepeatStructure(id) if id == c));
assert!(matches!(
left[0].provenance.synthesis,
Some(SynthesisKind::Registered(k)) if k == REPEAT_BARLINE_SYNTHESIS
));
// The region-end close keeps the final barline and adds the dot pair
// beside it (never a morph, never a second barline).
let finals = named(&constrained, "barlineFinal");
assert_eq!(finals.len(), 1, "the final barline stands");
let dots = named(&constrained, "repeatDots");
assert_eq!(dots.len(), 1, "one dot pair beside it");
assert!(named(&constrained, "repeatRight").is_empty());
assert!(
dots[0].baseline.x.0 < finals[0].baseline.x.0,
"the dots face the repeated passage, left of the final barline"
);
assert_eq!(
dots[0].horizontal_slot, finals[0].horizontal_slot,
"the dots share the region-closing column"
);
crate::roundtrip::round_trip(&score);
}
#[test]
fn two_repeats_merge_one_standalone_sign_that_clears_the_notes() {
let (mut score, m) = repeat_ready_score(24);
// Region A's triplet events sit at 0, 1/12, 2/12; the second event is
// an off-grid boundary two structures share: one ends there, the
// other starts there.
let mid_measure_event = score.canvas.regions[0].staff_instances()[0].voices[0].events[1];
let event_anchor = TimeAnchor::Event {
id: mid_measure_event,
offset: AnchorOffset::Zero,
};
let g1: RepeatStructureId = score.identity.mint();
let g2: RepeatStructureId = score.identity.mint();
score.cross_cutting.repeats.push(RepeatStructure {
id: g1,
start: measure_start(m[1]),
end: event_anchor.clone(),
kind: RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
score.cross_cutting.repeats.push(RepeatStructure {
id: g2,
start: event_anchor,
end: measure_start(m[2]),
kind: RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
let constrained = to_constrained(&to_logical(&score));
// One merged combined sign, owned by the smallest structure id,
// depending on both structures.
let combined = named(&constrained, "repeatRightLeft");
assert_eq!(combined.len(), 1, "the shared boundary draws one sign");
let sign = combined[0];
assert!(matches!(
sign.provenance.synthesis,
Some(SynthesisKind::Registered(k)) if k == REPEAT_BARLINE_SYNTHESIS
));
assert!(
matches!(sign.provenance.source, TypedObjectId::RepeatStructure(id) if id == g1.min(g2))
);
for id in [g1, g2] {
assert!(sign
.provenance
.dependencies
.contains(&TypedObjectId::RepeatStructure(id)));
}
// The end-facing sign's leftward ink reserved its reach (the
// accidental-overhang mechanism), so it crosses no notehead's ink.
let sign_left = sign.baseline.x.0 + sign.bounding_box.left.0;
let sign_right = sign.baseline.x.0 + sign.bounding_box.right.0;
for head in constrained
.glyphs
.iter()
.filter(|glyph| glyph.glyph.as_str().starts_with("notehead"))
{
let head_left = head.baseline.x.0 + head.bounding_box.left.0;
let head_right = head.baseline.x.0 + head.bounding_box.right.0;
assert!(
sign_right <= head_left || head_right <= sign_left,
"the repeat sign's ink must not cross a notehead's"
);
}
crate::roundtrip::round_trip(&score);
}
#[test]
fn jump_kinds_and_unresolved_boundaries_draw_no_ink() {
let (mut score, m) = repeat_ready_score(23);
let replica = score.identity.replica_id;
// A DalSegno repeat: barlines are a jump-mark tranche, not E1 — only
// the traced anchor is emitted. Its volta bracket still draws: the
// voltas list is kind-independent.
let d: RepeatStructureId = score.identity.mint();
score.cross_cutting.repeats.push(RepeatStructure {
id: d,
start: measure_start(m[1]),
end: measure_start(m[3]),
kind: RepeatKind::DalSegno {
segno: measure_start(m[2]),
end_target: measure_start(m[1]),
},
voltas: vec![Volta {
endings: vec![1],
start: measure_start(m[2]),
end: measure_start(m[3]),
}],
});
// A simple repeat whose start dangles (a decoded score may hold one);
// the resolved end still morphs, the dangling side draws nothing.
let e: RepeatStructureId = score.identity.mint();
score.cross_cutting.repeats.push(RepeatStructure {
id: e,
start: TimeAnchor::Measure {
id: MeasureId::new(replica, 9_999_999),
position: MeasurePosition::Start,
offset: AnchorOffset::Zero,
},
end: measure_start(m[2]),
kind: RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
// A bare wall-clock repeat: nothing pins it to a region, so it draws
// no ink anywhere (its placements are Unresolved by rule).
let f: RepeatStructureId = score.identity.mint();
score.cross_cutting.repeats.push(RepeatStructure {
id: f,
start: TimeAnchor::WallClock {
time: WallClockTime(5),
},
end: TimeAnchor::WallClock {
time: WallClockTime(10),
},
kind: RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
let constrained = to_constrained(&to_logical(&score));
assert!(named(&constrained, "repeatLeft").is_empty());
assert!(named(&constrained, "repeatRightLeft").is_empty());
assert!(named(&constrained, "repeatDots").is_empty());
let right = named(&constrained, "repeatRight");
assert_eq!(right.len(), 1, "the resolved end still closes");
// The jump kind's volta bracket draws even though its barlines do not.
let bracket_strokes = constrained
.strokes
.iter()
.filter(|stroke| {
matches!(
stroke.provenance.synthesis,
Some(SynthesisKind::Registered(k)) if k == VOLTA_SYNTHESIS
)
})
.count();
assert_eq!(bracket_strokes, 3, "the DalSegno's volta bracket draws");
// All three structures keep their traced anchors.
for id in [d, e, f] {
assert!(
constrained.strokes.iter().any(|stroke| {
stroke.from == stroke.to
&& matches!(stroke.provenance.source,
TypedObjectId::RepeatStructure(s) if s == id)
}),
"repeat {id:?} keeps its zero-extent traced anchor"
);
}
}
// --- Slur curves (schema-major-2 E2) -----------------------------------
use epiphany_core::{
CurvatureOverride, CurveDirection, EventId, Slur, SlurId, SpaceUnit, SpanStyle,
};
/// The events of region A's first voice (all on its one staff), for slur
/// endpoints that resolve to note columns.
fn region_a_events(score: &Score) -> Vec<EventId> {
score.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone()
}
/// Re-pitches every event in reading order, cycling `octaves` (all C naturals).
/// The corpus generator writes only low notes, so a test that needs a
/// down-stem — or a note tall enough to obstruct a slur — must say so.
fn repitch(score: &mut Score, octaves: &[i8]) {
use epiphany_core::{CmnNominal, Event, PitchSpacePosition};
let ids: Vec<_> = score.events.iter().map(|e| e.id()).collect();
for (index, id) in ids.iter().enumerate() {
if let Some(Event::Pitched(note)) = score.events.get_mut(*id) {
for pitch in &mut note.pitches {
if let PitchSpacePosition::Cmn {
nominal, octave, ..
} = &mut pitch.pitch.scale_position.position
{
*nominal = CmnNominal::C;
*octave = octaves[index % octaves.len()];
}
}
}
}
}
fn slur(id: SlurId, start: EventId, end: EventId, over: Option<CurvatureOverride>) -> Slur {
Slur {
id,
start_event: start,
end_event: end,
kind: epiphany_core::SlurKind::Legato,
curvature_override: over,
style: SpanStyle::default(),
}
}
fn slur_curve_of(constrained: &ConstrainedLayoutIR, id: SlurId) -> Option<&Curve> {
constrained
.curves
.iter()
.find(|curve| curve.provenance.source == TypedObjectId::Slur(id))
}
/// An `Auto` slur is placed OPPOSITE the stems — the single-voice engraving
/// rule. All stems up (low notes) puts it under the noteheads; all stems down
/// (high notes) puts it over them. It used to arc above unconditionally,
/// which drew it straight through the stems of every stem-up passage.
#[test]
fn a_default_slur_takes_the_side_opposite_the_stems() {
for (octave, expect_above) in [(4i8, false), (6, true)] {
let (mut score, _) = repeat_ready_score(41);
repitch(&mut score, &[octave]);
let events = region_a_events(&score);
let id: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(id, events[0], events[2], None));
let constrained = to_constrained(&to_logical(&score));
let curve = slur_curve_of(&constrained, id).expect("the slur draws a curve");
// Its exact provenance rides the curve — one primitive per slur, no
// synthesis (a slur owns a single curve).
assert!(curve.provenance.synthesis.is_none());
assert!(curve.p3.x.0 > curve.p0.x.0, "left to right");
assert_eq!(curve.p0.y, curve.p3.y, "equal pitches share a baseline");
let arcs_up = curve.p1.y.0 > curve.p0.y.0 && curve.p2.y.0 > curve.p0.y.0;
assert_eq!(
arcs_up,
expect_above,
"C{octave}: stems point {}, so the slur goes {}",
if expect_above { "down" } else { "up" },
if expect_above { "above" } else { "below" }
);
// The endpoints hug the ENDPOINT NOTE, not the staff. A C6's head
// centre sits at y = 6 and its ink tops out half a space above; a C4's
// centre at 1, its ink half a space below. Endpoints a fixed gap from
// the staff edge instead (y = 4.7 / 0.7) would leave an above-slur
// hanging *under* its own ledger notes — the original defect.
let head_centre = if expect_above { 6.0 } else { -1.0 };
let want = if expect_above {
head_centre + 0.5 + SLUR_ENDPOINT_GAP
} else {
head_centre - 0.5 - SLUR_ENDPOINT_GAP
};
assert!(
(curve.p0.y.0 - want).abs() < 1e-3,
"C{octave}: the endpoint hugs its note at {want}, not the staff: {}",
curve.p0.y.0
);
// No traced anchor for a drawn slur (the curve carries the provenance).
assert!(!constrained
.strokes
.iter()
.any(|s| s.provenance.source == TypedObjectId::Slur(id)));
crate::roundtrip::round_trip(&score);
}
}
/// The arc is raised until it clears every column between its endpoints. Two
/// C4s (stems up) around a C6 (stem down) is a mixed-stem span, so the slur
/// goes above — and the C6 stands three ledger lines inside it.
#[test]
fn a_slur_arcs_clear_of_a_note_between_its_endpoints() {
let (mut score, _) = repeat_ready_score(45);
repitch(&mut score, &[4, 6, 4]);
let events = region_a_events(&score);
let id: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(id, events[0], events[2], None));
let constrained = to_constrained(&to_logical(&score));
let curve = slur_curve_of(&constrained, id).expect("the slur draws a curve");
assert!(
curve.p1.y.0 > curve.p0.y.0,
"a mixed-stem span has no notehead side, so it arcs above"
);
// The C6 notehead's top: three ledgers above a staff whose top line is 4.
let c6_top = curve
.control_points()
.iter()
.map(|p| p.y.0)
.fold(f32::NEG_INFINITY, f32::max);
assert!(c6_top > 6.0, "the arc reaches over the C6: {c6_top}");
// Sample the cubic at its apex (t = 0.5) and check it clears the note.
let at = |t: f32| {
let (u, cp) = (1.0 - t, curve.control_points());
u * u * u * cp[0].y.0
+ 3.0 * u * u * t * cp[1].y.0
+ 3.0 * u * t * t * cp[2].y.0
+ t * t * t * cp[3].y.0
};
// The C6's head centre sits at y = 6 (step 12); its ink tops out at ~6.5.
// The arc clears it by exactly the endpoint gap and no more: the lift is
// the least that suffices. Evaluating the obstacle at its column x rather
// than its notehead CENTRE skews `t` and silently over-lifts, which this
// upper bound catches.
let required = 6.5 + SLUR_ENDPOINT_GAP;
assert!(
at(0.5) >= required - 1e-3,
"the apex clears the intervening notehead: {} < {required}",
at(0.5)
);
assert!(
at(0.5) <= required + 0.05,
"and does not overshoot it: {} > {required}",
at(0.5)
);
}
/// An authored direction overrides the stem rule, and an authored height is
/// honoured — as a FLOOR. Clearance may raise it (a slur must never be drawn
/// through a note to obey an author), but nothing lowers it.
#[test]
fn an_authored_direction_and_height_override_the_defaults() {
let (mut score, _) = repeat_ready_score(42);
repitch(&mut score, &[4]); // all C4: stems up, so Auto would go below
let events = region_a_events(&score);
let forced_above: SlurId = score.identity.mint();
let forced_below: SlurId = score.identity.mint();
let height = SpaceUnit(epiphany_determinism::CanonicalF64::new(2.0).expect("finite"));
score.cross_cutting.slurs.push(slur(
forced_above,
events[0],
events[2],
Some(CurvatureOverride {
direction: Some(CurveDirection::Above),
height: None,
}),
));
score.cross_cutting.slurs.push(slur(
forced_below,
events[0],
events[2],
Some(CurvatureOverride {
direction: Some(CurveDirection::Below),
height: Some(height),
}),
));
let constrained = to_constrained(&to_logical(&score));
let up = slur_curve_of(&constrained, forced_above).expect("above slur draws");
let down = slur_curve_of(&constrained, forced_below).expect("below slur draws");
// The authored `Above` wins over the stem rule, which wanted below.
assert!(
up.p1.y.0 > up.p0.y.0 && up.p2.y.0 > up.p0.y.0,
"an authored Above arcs upward even over stem-up notes"
);
assert!(down.p1.y.0 < down.p0.y.0 && down.p2.y.0 < down.p0.y.0);
assert!(down.p0.y.0 < 0.0, "a below slur sits under the staff");
// The authored apex height is 2.0: the control lift is 4/3 · height, so
// the apex sits 0.75 · lift from the chord. Equal pitches make the chord
// flat and the intervening column no obstacle, so nothing raises it.
let lift = down.p1.y.0 - down.p0.y.0;
assert!(
(0.75 * -lift - 2.0).abs() < 1e-4,
"authored apex height honoured (2.0 staff spaces): lift {lift}"
);
}
/// An authored height that clearance must overrule: a below-slur whose span
/// dips over a C2. The author asked for a shallow 0.5, but honouring it would
/// draw the arc straight through the low note, so the apex is raised.
#[test]
fn clearance_raises_an_authored_height_it_would_otherwise_violate() {
let (mut score, _) = repeat_ready_score(46);
repitch(&mut score, &[4, 2, 4]); // stems all up ⇒ Auto below; C2 dips low
let events = region_a_events(&score);
let id: SlurId = score.identity.mint();
let shallow = SpaceUnit(epiphany_determinism::CanonicalF64::new(0.5).expect("finite"));
score.cross_cutting.slurs.push(slur(
id,
events[0],
events[2],
Some(CurvatureOverride {
direction: None,
height: Some(shallow),
}),
));
let constrained = to_constrained(&to_logical(&score));
let curve = slur_curve_of(&constrained, id).expect("the slur draws");
let lift = curve.p1.y.0 - curve.p0.y.0;
let apex = 0.75 * -lift;
assert!(
apex > 0.5 + 1e-3,
"the shallow authored height was raised to clear the C2: {apex}"
);
// And it really does clear: sample the cubic at the obstructing column.
let at = |t: f32| {
let (u, cp) = (1.0 - t, curve.control_points());
u * u * u * cp[0].y.0
+ 3.0 * u * u * t * cp[1].y.0
+ 3.0 * u * t * t * cp[2].y.0
+ t * t * t * cp[3].y.0
};
// C2's head centre is at y = -8 (step -16); its ink bottoms out at ~-8.5.
assert!(
at(0.5) <= -8.5 - SLUR_ENDPOINT_GAP + 1e-3,
"the apex passes under the C2: {}",
at(0.5)
);
}
/// A staff that declares its clef ONLY on the `Staff` — no `ClefChange` at
/// all — engraves in that clef. `Staff::default_clef` used to be decorative:
/// the projection took the active clef from the staff instance's sequence and
/// fell back to `Clef::default()`, so a bass staff drew a treble clef and put
/// every note two steps wrong (P13-I2).
#[test]
fn a_staff_declaring_only_a_default_clef_engraves_in_it() {
let (mut score, _) = repeat_ready_score(48);
let staff_id = score.canvas.regions[0].staff_instances()[0].staff;
for staff in &mut score.staves {
if staff.id == staff_id {
staff.default_clef = Clef::bass();
}
}
assert!(
score.canvas.regions[0].staff_instances()[0]
.clef_sequence
.is_empty(),
"the staff declares no ClefChange — only its default"
);
let instance_id = score.canvas.regions[0].staff_instances()[0].id;
let c = to_constrained(&to_logical(&score));
// THIS staff's clef glyph is the bass clef, not the treble default. The
// score's other staves keep their own defaults, so scope by provenance.
let drawn: Vec<&str> = c
.glyphs
.iter()
.filter(|g| g.provenance.source == TypedObjectId::StaffInstance(instance_id))
.map(|g| g.glyph.as_str())
.collect();
assert_eq!(
drawn,
vec!["fClef"],
"a staff whose only clef is its default draws it"
);
// And its notes are placed against that clef: `active_clef_or` is what the
// staff-position computation reads, so the two can never disagree.
let at = TimePoint::Musical(epiphany_core::MusicalPosition::origin());
assert_eq!(active_clef_or(&[], &at, Clef::bass()), Clef::bass());
assert_eq!(
active_clef(&[], &at),
Clef::default(),
"the old API is intact"
);
}
/// `req:layoutir:coverage-diagnostics`: an object the projection cannot
/// engrave faithfully is **recorded and still placed** — never guessed at,
/// never dropped. A percussion clef has no bundled glyph, so the staff
/// instance engraves to a zero-extent traced anchor that keeps its
/// provenance (a hit-test can still find it) while an `UnbundledGlyph`
/// diagnostic names the gap.
#[test]
fn an_unengravable_object_is_recorded_and_still_placed() {
let (mut score, _) = repeat_ready_score(47);
let instance_id = score.canvas.regions[0].staff_instances()[0].id;
score.canvas.regions[0]
.content
.staff_instances_mut()
.expect("staff-based")[0]
.clef_sequence
.push(epiphany_core::ClefChange {
anchor: TimeAnchor::WallClock {
time: epiphany_core::WallClockTime(0),
},
clef: epiphany_core::Clef {
shape: epiphany_core::ClefShape::Percussion,
line: 3,
octave_shift: 0,
},
});
let c = to_constrained(&to_logical(&score));
let source = TypedObjectId::StaffInstance(instance_id);
// Recorded: the gap names the object and the glyph it wanted.
let diagnostic = c
.diagnostics
.iter()
.find(|d| d.source == source)
.expect("the unbundled clef is surfaced, not hidden");
assert!(
matches!(diagnostic.kind, LayoutDiagnosticKind::UnbundledGlyph(_)),
"and says why: {:?}",
diagnostic.kind
);
// Not guessed: no glyph stands in for the clef.
assert!(
!c.glyphs.iter().any(|g| g.provenance.source == source),
"no plausible substitute is drawn"
);
// Not dropped: a traced anchor keeps its provenance addressable.
let anchor = c
.strokes
.iter()
.find(|st| st.provenance.source == source)
.expect("the object is still placed, as a traced anchor");
assert_eq!(anchor.from, anchor.to, "a zero-extent anchor draws no ink");
assert_eq!(anchor.thickness.0, 0.0);
}
/// A stem points AWAY from the middle line — up for a head below it, down
/// for a head above it or on it — and attaches on the side it points: an
/// up-stem at the head's right edge, a down-stem at its left. A stem on a
/// note beyond an octave from the middle line is drawn out TO that line,
/// rather than dangling a fixed octave into the ledger field.
///
/// Every stem in the engine used to point up, on the right, at a constant
/// octave — an upward stem on a C6 three ledgers above the staff. It is also
/// why an `Auto` slur, which is placed *opposite* the stems, could not be
/// given a correct side until this landed.
#[test]
fn a_stem_points_away_from_the_middle_line_and_reaches_it() {
use epiphany_core::{CmnNominal, Event, PitchSpacePosition};
// The corpus generator writes only low notes, so spread the octaves to
// straddle the middle line and reach well past a stem's length.
let mut score = valid_score_rich(11);
let ids: Vec<_> = score.events.iter().map(|e| e.id()).collect();
for (index, id) in ids.iter().enumerate() {
if let Some(Event::Pitched(note)) = score.events.get_mut(*id) {
for pitch in &mut note.pitches {
if let PitchSpacePosition::Cmn {
nominal, octave, ..
} = &mut pitch.pitch.scale_position.position
{
*nominal = CmnNominal::C;
*octave = [3i8, 4, 5, 6][index % 4];
}
}
}
}
let c = to_constrained(&to_logical(&score));
// Each staff's middle line, from that staff's own staff-line strokes.
let mut lines: BTreeMap<VerticalBandId, f32> = BTreeMap::new();
for stroke in &c.strokes {
if matches!(stroke.provenance.source, TypedObjectId::Staff(_)) {
let entry = lines.entry(stroke.vertical_band).or_insert(f32::INFINITY);
*entry = entry.min(stroke.from.y.0);
}
}
let stems: Vec<&Stroke> = c
.strokes
.iter()
.filter(|s| (s.thickness.0 - STEM_THICKNESS).abs() < 1e-6)
.filter(|s| (s.to.y.0 - s.from.y.0).abs() > 1e-6)
.collect();
assert!(!stems.is_empty(), "the fixture draws stems");
let (mut saw_up, mut saw_down, mut saw_extended) = (false, false, false);
for stem in stems {
let middle = lines[&stem.vertical_band] + STAFF_HEIGHT * 0.5;
let (base, tip) = (stem.from.y.0, stem.to.y.0);
let up = tip > base;
assert_eq!(
up,
base < middle,
"a head below the middle line stems up, else down: base {base} middle {middle}"
);
if up {
assert!(tip >= middle - 1e-4, "an up-stem reaches the middle line");
saw_up = true;
} else {
assert!(tip <= middle + 1e-4, "a down-stem reaches the middle line");
saw_down = true;
}
// Beyond an octave from the middle line, the stem stops AT that line.
if (base - middle).abs() > STEM_LENGTH {
assert!(
(tip - middle).abs() < 1e-4,
"a far-out note's stem stops at the middle line: {tip} vs {middle}"
);
saw_extended = true;
}
}
assert!(saw_up && saw_down, "the fixture exercises both directions");
assert!(
saw_extended,
"and at least one stem drawn out to the middle line"
);
}
#[test]
fn a_slur_with_an_unresolved_or_reversed_endpoint_keeps_its_anchor() {
let (mut score, _) = repeat_ready_score(43);
let events = region_a_events(&score);
let replica = score.identity.replica_id;
// A dangling end event: nothing to arc to.
let dangling: SlurId = score.identity.mint();
score.cross_cutting.slurs.push(slur(
dangling,
events[0],
EventId::new(replica, 9_999_999),
None,
));
// A zero-span slur (both endpoints the same event): no left-to-right arc.
let degenerate: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(degenerate, events[0], events[0], None));
let constrained = to_constrained(&to_logical(&score));
for id in [dangling, degenerate] {
assert!(
slur_curve_of(&constrained, id).is_none(),
"an unresolved/degenerate slur draws no curve"
);
assert!(
constrained
.strokes
.iter()
.any(|s| { s.from == s.to && s.provenance.source == TypedObjectId::Slur(id) }),
"…it keeps its zero-extent traced anchor"
);
}
}
#[test]
fn a_cross_staff_slur_keeps_its_anchor_rather_than_floating() {
use epiphany_core::generators::valid_score;
// A `valid_score` seed with two staff instances in its single region.
let mut score = (0..64u64)
.map(valid_score)
.find(|s| s.canvas.regions[0].staff_instances().len() == 2)
.expect("some seed yields a two-staff region");
let instances = score.canvas.regions[0].staff_instances();
// One endpoint on each staff — the slur resolves to no single staff.
let top = instances[0].voices[0].events[0];
let bottom = instances[1].voices[0].events[0];
let id: SlurId = score.identity.mint();
score.cross_cutting.slurs.push(slur(id, top, bottom, None));
let constrained = to_constrained(&to_logical(&score));
// No curve floating at yo = 0 detached from a note on the other staff;
// the traced anchor keeps provenance (a Minimal boundary).
assert!(
slur_curve_of(&constrained, id).is_none(),
"a cross-staff slur draws no curve"
);
assert!(constrained
.strokes
.iter()
.any(|s| s.from == s.to && s.provenance.source == TypedObjectId::Slur(id)));
crate::roundtrip::round_trip(&score);
}
#[test]
fn an_authored_dashed_slur_renders_a_dashed_curve() {
use epiphany_core::{LineStyle, SpanStyle};
let (mut score, _) = repeat_ready_score(45);
let events = region_a_events(&score);
let solid_id: SlurId = score.identity.mint();
let dashed_id: SlurId = score.identity.mint();
score
.cross_cutting
.slurs
.push(slur(solid_id, events[0], events[2], None));
let mut dashed = slur(dashed_id, events[0], events[2], None);
dashed.style = SpanStyle {
line: LineStyle::Dashed,
thickness: None,
};
score.cross_cutting.slurs.push(dashed);
let constrained = to_constrained(&to_logical(&score));
// The authored line pattern is carried on the drawn curve — rendered
// faithfully, not deferred to a diagnostic.
assert_eq!(
slur_curve_of(&constrained, dashed_id)
.expect("dashed slur draws")
.line,
LineStyle::Dashed
);
assert_eq!(
slur_curve_of(&constrained, solid_id)
.expect("solid slur draws")
.line,
LineStyle::Solid
);
// No line-style diagnostic remains — the style is rendered, not surfaced.
assert!(constrained
.diagnostics
.iter()
.all(|d| d.source != TypedObjectId::Slur(dashed_id)));
}
#[test]
fn out_of_range_authored_slur_dimensions_fall_back_to_defaults() {
let (mut score, _) = repeat_ready_score(44);
// High notes: stems down, so an Auto slur takes the side above them.
repitch(&mut score, &[6]);
let events = region_a_events(&score);
let neg: epiphany_determinism::CanonicalF64 =
epiphany_determinism::CanonicalF64::new(-1.0).expect("finite");
let zero: epiphany_determinism::CanonicalF64 =
epiphany_determinism::CanonicalF64::new(0.0).expect("finite");
// A slur authoring a negative height and a zero thickness — pathological
// out-of-range values that must NOT flip the arc, draw an invisible
// curve, or (for a negative thickness) fail geometry validation and
// blank the layout.
let id: SlurId = score.identity.mint();
let mut bad = slur(
id,
events[0],
events[2],
Some(CurvatureOverride {
direction: None, // Auto: opposite the (down) stems ⇒ above
height: Some(SpaceUnit(neg)),
}),
);
bad.style.thickness = Some(SpaceUnit(zero));
score.cross_cutting.slurs.push(bad);
let constrained = to_constrained(&to_logical(&score));
let curve = slur_curve_of(&constrained, id).expect("the slur still draws");
// The negative height fell back to the positive default: the arc keeps
// its side and its curvature.
assert!(
curve.p1.y.0 > curve.p0.y.0,
"a non-positive authored height falls back, arc stays upward"
);
// The zero thickness fell back to the visible, hittable default.
assert!(
curve.thickness.0 > 0.0,
"thickness falls back to a positive default"
);
// Geometry validates — the layout is not blanked.
assert!(constrained.validate().is_ok());
}
}