5170 lines
229 KiB
Rust
5170 lines
229 KiB
Rust
//! 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 ®ion.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 ®ion.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 ®ion.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(¬e.components).enumerate() {
|
||
let time = shift_time(¬e.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 ¬e.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((®ion.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(®ion_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 ®ion.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·(1−t)`; a column at parameter `t` needing `d` more
|
||
/// clearance therefore forces an apex of at least `d / (4·t·(1−t))`. 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·(1−t)`.
|
||
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 (0–9) 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());
|
||
}
|
||
}
|