1933 lines
77 KiB
Rust
1933 lines
77 KiB
Rust
//! Stage 1 — `LogicalLayoutIR` (Chapter 7 §"LogicalLayoutIR").
|
||
//!
|
||
//! The structural projection of the score graph into layout objects, with
|
||
//! engraving decisions notionally made but spatial positions unresolved. It is
|
||
//! the output of the engraving pass and the input to the spacing pass.
|
||
//!
|
||
//! v0 projects every score-graph object that participates in the round-trip into
|
||
//! a thin [`LayoutObject`] carrying its [`Provenance`]; the full composite-object
|
||
//! taxonomy of Chapter 7 §"Layout Objects" (`NoteLayout`, `ChordLayout`, …) is a
|
||
//! layered engraving concern past v0. What v0 *does* guarantee is the contract
|
||
//! that matters for incremental layout: every object carries a complete
|
||
//! provenance back-reference (its `source` plus every score-graph object whose
|
||
//! change should invalidate it, Chapter 7 §7.1's requirement), and that
|
||
//! provenance survives the whole pipeline.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile};
|
||
use epiphany_core::{
|
||
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef,
|
||
CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, LineStyle, MeasurePosition,
|
||
MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge,
|
||
RegionId, RegionTimeModel, Score, SlurKind, SpaceUnit, StaffId, StaffPosition, TimeAnchor,
|
||
TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId, WallClockTime,
|
||
};
|
||
use epiphany_determinism::{DomainTag, Preimage};
|
||
|
||
use crate::engraving::{
|
||
DecisionSource, EngravingDecision, EngravingDecisionKind, EngravingOverride, OverrideKind,
|
||
};
|
||
use crate::provenance::{LayoutObjectId, Provenance};
|
||
use crate::spatial::Transform2D;
|
||
use crate::time_axis::{time_axis_of, TimeAxisModel, TimePoint};
|
||
|
||
/// The engraving content of a layout object beyond its provenance and staff —
|
||
/// the note value, spelled pitches, clef, key, measure, or repeat data the
|
||
/// constrained pass needs to choose glyphs and compute staff positions
|
||
/// (Chapter 7 §"Engraving Decisions": the decisions are recorded in the IR).
|
||
/// Structural objects (staves, voices, the per-pitch back-references, and the
|
||
/// cross-cutting structures other than repeats) carry
|
||
/// [`LayoutContent::Structural`]. This payload is *authoritative* for
|
||
/// engraving; the [`LayoutObject`] variant remains the structural
|
||
/// classification.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub enum LayoutContent {
|
||
/// No engraving content beyond provenance/staff.
|
||
#[default]
|
||
Structural,
|
||
/// A staff instance's resolved clef and key-signature *sequences*; the
|
||
/// constrained pass chooses the active clef/key per position and defaults to
|
||
/// treble / C major when a sequence is empty.
|
||
Staff(StaffContent),
|
||
/// A note or chord: its note value and spelled pitches (one notehead each).
|
||
Note(NoteContent),
|
||
/// A rest: its note value and optional explicit staff position.
|
||
Rest(RestContent),
|
||
/// A measure: whether it ends the staff (a final barline) and the time
|
||
/// signature in force, when this measure introduces one.
|
||
Measure(MeasureContent),
|
||
/// A repeat structure: whether its kind draws repeat barlines, where its
|
||
/// boundaries land, and its volta brackets — resolved to layout placements
|
||
/// at projection time (the constrained pass has no score access).
|
||
Repeat(RepeatContent),
|
||
/// A slur: its two endpoint onsets (resolved to columns in the constrained
|
||
/// pass), its arc direction, and any authored curvature/style overrides.
|
||
Slur(SlurContent),
|
||
}
|
||
|
||
/// The clef and key-signature sequences in force across a staff instance,
|
||
/// carried at resolved [`TimePoint`]s so the constrained pass can choose the
|
||
/// *active* clef/key at any position without going back to the score graph.
|
||
/// Empty sequences mean the score declares none — the constrained pass then
|
||
/// defaults to treble / C major.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct StaffContent {
|
||
pub clefs: Vec<PlacedClef>,
|
||
pub keys: Vec<PlacedKeySignature>,
|
||
/// The staff's own `default_clef` — the clef in force before the first
|
||
/// `ClefChange`, and throughout a staff that declares none. Carried here
|
||
/// because the clef belongs to the `Staff` while the sequence belongs to the
|
||
/// `StaffInstance`, and every consumer resolving "the clef at time t" needs
|
||
/// both (`active_clef_or`).
|
||
pub default_clef: Clef,
|
||
}
|
||
|
||
/// A clef change with its score anchor resolved into the layout time axis.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct PlacedClef {
|
||
pub time: TimePoint,
|
||
pub clef: Clef,
|
||
}
|
||
|
||
/// A key-signature change with its score anchor resolved into the layout time
|
||
/// axis.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct PlacedKeySignature {
|
||
pub time: TimePoint,
|
||
pub key: KeySignature,
|
||
}
|
||
|
||
/// A note or chord's notated content: its resolved start position, its placed
|
||
/// notated components (one notehead/tie segment each, at successive offsets — a
|
||
/// multi-component decomposition is *not* collapsed), and its spelled pitches.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct NoteContent {
|
||
pub position: TimePoint,
|
||
pub components: Vec<PlacedComponent>,
|
||
pub pitches: Vec<NotePitch>,
|
||
}
|
||
|
||
/// One notated component placed within a note or rest: its offset from the
|
||
/// owning event's start position, the component itself (base value, dots, tuplet
|
||
/// membership, tie), and the resolved tuplet ratio when it is in a tuplet (the
|
||
/// `TupletId` inside the component does not carry the ratio).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct PlacedComponent {
|
||
pub offset: MusicalDuration,
|
||
pub component: NotatedComponent,
|
||
pub tuplet: Option<TupletRatio>,
|
||
}
|
||
|
||
/// One pitch of a note — its identity (for the notehead's provenance) and its
|
||
/// resolved spelling, or `None` when the pre-pass produced none. A `None`
|
||
/// spelling is *preserved*, not dropped, so the constrained pass surfaces a
|
||
/// missing-spelling diagnostic rather than silently losing musical content.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct NotePitch {
|
||
pub pitch: PitchId,
|
||
pub spelling: Option<PitchSpelling>,
|
||
}
|
||
|
||
/// A rest's notated content: its resolved start position, its placed notated
|
||
/// components, and any explicit vertical position.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct RestContent {
|
||
pub position: TimePoint,
|
||
pub components: Vec<PlacedComponent>,
|
||
pub staff_position: Option<StaffPosition>,
|
||
}
|
||
|
||
/// A measure's notated content: its resolved start position, which barline ends
|
||
/// it, and the time signature it introduces, if any.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct MeasureContent {
|
||
pub start: TimePoint,
|
||
pub barline: BarlineKind,
|
||
pub time_signature: Option<TimeSignatureContent>,
|
||
}
|
||
|
||
/// Which barline ends a measure. A staff manifested across several regions
|
||
/// continues at each region boundary, so only the last measure of the *last*
|
||
/// region manifesting the staff is truly [`Final`](BarlineKind::Final).
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub enum BarlineKind {
|
||
/// A measure within the staff's run.
|
||
Interior,
|
||
/// The last measure of this staff instance in this region; the staff
|
||
/// continues in a later region.
|
||
RegionEnd,
|
||
/// The last measure of the last region manifesting this staff (the true end).
|
||
Final,
|
||
}
|
||
|
||
/// A time signature reduced to its displayed numerator and denominator.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub struct TimeSignatureContent {
|
||
pub numerator: u16,
|
||
pub denominator: u16,
|
||
}
|
||
|
||
/// A repeat structure's engraving content (Chapter 5 §"Cross-Cutting
|
||
/// Structures": `RepeatStructure`), with every boundary resolved to a
|
||
/// [`RepeatPlacement`] so the constrained pass can place ink without going
|
||
/// back to the score graph.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct RepeatContent {
|
||
/// Whether the structure's kind draws repeat barlines at its boundaries
|
||
/// (`SimpleRepeat` and `Volta`). The jump kinds (`DaCapo`/`DalSegno`) draw
|
||
/// no Minimal-tier ink — their marks (segno, coda, instruction text) need
|
||
/// a text primitive and belong to a later tranche.
|
||
pub barlines: bool,
|
||
pub start: RepeatPlacement,
|
||
pub end: RepeatPlacement,
|
||
/// The structure's volta brackets, in authored order.
|
||
pub voltas: Vec<VoltaContent>,
|
||
}
|
||
|
||
/// One volta bracket: its pass numbers and its resolved span.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct VoltaContent {
|
||
/// The pass numbers this ending plays on (1-based; authored order kept).
|
||
pub endings: Vec<u32>,
|
||
pub start: RepeatPlacement,
|
||
pub end: RepeatPlacement,
|
||
}
|
||
|
||
/// Where a repeat boundary lands on its region's spacing axis.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum RepeatPlacement {
|
||
/// At the spacing column of this resolved time.
|
||
At(TimePoint),
|
||
/// At the region's closing column (the final-barline position) — a
|
||
/// zero-offset anchor to the region's end edge or to the end of its last
|
||
/// measure, whose *time* the Minimal slice cannot resolve but whose
|
||
/// *column* is exactly the region-closing one.
|
||
RegionEnd,
|
||
/// Not resolvable in the Minimal slice (dangling target, unknown metric
|
||
/// region end, clock-mismatched offset). The boundary draws no ink; the
|
||
/// structure keeps its traced anchor.
|
||
Unresolved,
|
||
}
|
||
|
||
/// A slur's engraving content (Chapter 5 §"Slurs": `Slur`), with each endpoint
|
||
/// event resolved to its onset [`TimePoint`] and the authored overrides
|
||
/// distilled so the constrained pass can draw the arc without score access.
|
||
/// Dimensions are carried as [`SpaceUnit`] (staff spaces) so the payload stays
|
||
/// `Eq` with the rest of [`LayoutContent`].
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct SlurContent {
|
||
pub start: SlurEndpoint,
|
||
pub end: SlurEndpoint,
|
||
pub direction: SlurDirection,
|
||
/// Authored arc apex height (`curvature_override.height`); `None` = the
|
||
/// engraver's span-proportional default.
|
||
pub height: Option<SpaceUnit>,
|
||
/// Authored line thickness (`style.thickness`); `None` = the engraver's
|
||
/// default.
|
||
pub thickness: Option<SpaceUnit>,
|
||
/// The slur's kind (Chapter 5 `SlurKind`). Carried through the projection —
|
||
/// the Minimal tier draws one canonical arc for every kind, but the kind is
|
||
/// preserved for a kind-aware higher tier (a phrase mark's longer curve, an
|
||
/// editorial slur's distinct line).
|
||
pub kind: SlurKind,
|
||
/// The authored line style (`style.line`). Rendered faithfully: the style
|
||
/// rides the emitted `Curve`, so a non-`Solid` slur draws dashed or dotted
|
||
/// (`stroke-dasharray`) rather than silently solid.
|
||
pub line: LineStyle,
|
||
}
|
||
|
||
/// A slur endpoint on the region's spacing axis.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum SlurEndpoint {
|
||
/// At the note column of this resolved event onset.
|
||
At(TimePoint),
|
||
/// Not resolvable in this region (missing event, or an endpoint whose
|
||
/// column is laid out in another region). The slur draws no curve.
|
||
Unresolved,
|
||
}
|
||
|
||
/// A slur's arc direction. `Auto` lets the engraver choose (Minimal: above the
|
||
/// staff); `Above`/`Below` are authored via `curvature_override.direction`.
|
||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||
pub enum SlurDirection {
|
||
Auto,
|
||
Above,
|
||
Below,
|
||
}
|
||
|
||
/// A structural layout object before spacing (Chapter 7 §"Layout Objects"). It
|
||
/// carries its [`Provenance`], the staff it belongs to (used to route it to the
|
||
/// correct vertical band), and its [`LayoutContent`] (the engraving payload the
|
||
/// constrained pass materializes into glyphs).
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct CompositeLayoutObject {
|
||
pub provenance: Provenance,
|
||
/// The staff this object belongs to, or `None` for region-level and
|
||
/// score-level (cross-cutting / free-graphic) objects.
|
||
pub staff: Option<StaffId>,
|
||
/// The engraving content materialized into glyphs at the constrained stage.
|
||
pub content: LayoutContent,
|
||
}
|
||
|
||
pub type NoteLayout = CompositeLayoutObject;
|
||
pub type ChordLayout = CompositeLayoutObject;
|
||
pub type RestLayout = CompositeLayoutObject;
|
||
pub type BeamGroupLayout = CompositeLayoutObject;
|
||
pub type TupletDisplayLayout = CompositeLayoutObject;
|
||
pub type SlurLayout = CompositeLayoutObject;
|
||
pub type TieLayout = CompositeLayoutObject;
|
||
pub type SpannerLayout = CompositeLayoutObject;
|
||
pub type MarkerLayout = CompositeLayoutObject;
|
||
pub type BarLineLayout = CompositeLayoutObject;
|
||
pub type ClefLayout = CompositeLayoutObject;
|
||
pub type KeySignatureLayout = CompositeLayoutObject;
|
||
pub type TimeSignatureDisplayLayout = CompositeLayoutObject;
|
||
pub type StaffLayout = CompositeLayoutObject;
|
||
pub type TextLayout = CompositeLayoutObject;
|
||
pub type GraphicLayout = CompositeLayoutObject;
|
||
pub type MultimeasureRestLayout = CompositeLayoutObject;
|
||
pub type CueLayout = CompositeLayoutObject;
|
||
pub type TrajectoryLayout = CompositeLayoutObject;
|
||
pub type GroupLayout = CompositeLayoutObject;
|
||
|
||
/// The complete Chapter 7 logical composite-object taxonomy. The prototype
|
||
/// payload shared by each variant is provenance/staff ownership; companion
|
||
/// engraving algorithms can refine the aliased payloads without changing the
|
||
/// stage container or variant vocabulary.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub enum LayoutObject {
|
||
Note(NoteLayout),
|
||
Chord(ChordLayout),
|
||
Rest(RestLayout),
|
||
BeamGroup(BeamGroupLayout),
|
||
TupletDisplay(TupletDisplayLayout),
|
||
Slur(SlurLayout),
|
||
Tie(TieLayout),
|
||
Spanner(SpannerLayout),
|
||
Marker(MarkerLayout),
|
||
BarLine(BarLineLayout),
|
||
Clef(ClefLayout),
|
||
KeySignature(KeySignatureLayout),
|
||
TimeSignatureDisplay(TimeSignatureDisplayLayout),
|
||
Staff(StaffLayout),
|
||
Text(TextLayout),
|
||
Graphic(GraphicLayout),
|
||
MultimeasureRest(MultimeasureRestLayout),
|
||
Cue(CueLayout),
|
||
Trajectory(TrajectoryLayout),
|
||
Group(GroupLayout),
|
||
}
|
||
|
||
impl LayoutObject {
|
||
pub fn from_projection(provenance: Provenance, staff: Option<StaffId>) -> Self {
|
||
let payload = CompositeLayoutObject {
|
||
provenance,
|
||
staff,
|
||
content: LayoutContent::Structural,
|
||
};
|
||
match payload.provenance.source {
|
||
TypedObjectId::Event(_) | TypedObjectId::Pitch(_) => LayoutObject::Note(payload),
|
||
TypedObjectId::Beam(_) => LayoutObject::BeamGroup(payload),
|
||
TypedObjectId::Tuplet(_) => LayoutObject::TupletDisplay(payload),
|
||
TypedObjectId::Slur(_) => LayoutObject::Slur(payload),
|
||
TypedObjectId::Tie(_) => LayoutObject::Tie(payload),
|
||
TypedObjectId::Spanner(_) => LayoutObject::Spanner(payload),
|
||
TypedObjectId::Marker(_) | TypedObjectId::RepeatStructure(_) => {
|
||
LayoutObject::Marker(payload)
|
||
}
|
||
TypedObjectId::Measure(_) => LayoutObject::BarLine(payload),
|
||
TypedObjectId::Staff(_) => LayoutObject::Staff(payload),
|
||
TypedObjectId::GraphicObject(_) | TypedObjectId::GraphicGesture(_) => {
|
||
LayoutObject::Graphic(payload)
|
||
}
|
||
TypedObjectId::LyricLine(_)
|
||
| TypedObjectId::ChordSymbol(_)
|
||
| TypedObjectId::Comment(_)
|
||
| TypedObjectId::AnalyticalAnnotation(_) => LayoutObject::Text(payload),
|
||
_ => LayoutObject::Group(payload),
|
||
}
|
||
}
|
||
|
||
/// Projects an object and attaches its engraving content in one step.
|
||
pub fn from_projection_with_content(
|
||
provenance: Provenance,
|
||
staff: Option<StaffId>,
|
||
content: LayoutContent,
|
||
) -> Self {
|
||
let mut object = Self::from_projection(provenance, staff);
|
||
object.payload_mut().content = content;
|
||
object
|
||
}
|
||
|
||
pub fn provenance(&self) -> &Provenance {
|
||
&self.payload().provenance
|
||
}
|
||
|
||
pub fn staff(&self) -> Option<StaffId> {
|
||
self.payload().staff
|
||
}
|
||
|
||
/// The engraving content of this object (authoritative over the variant).
|
||
pub fn content(&self) -> &LayoutContent {
|
||
&self.payload().content
|
||
}
|
||
|
||
fn payload(&self) -> &CompositeLayoutObject {
|
||
match self {
|
||
LayoutObject::Note(value)
|
||
| LayoutObject::Chord(value)
|
||
| LayoutObject::Rest(value)
|
||
| LayoutObject::BeamGroup(value)
|
||
| LayoutObject::TupletDisplay(value)
|
||
| LayoutObject::Slur(value)
|
||
| LayoutObject::Tie(value)
|
||
| LayoutObject::Spanner(value)
|
||
| LayoutObject::Marker(value)
|
||
| LayoutObject::BarLine(value)
|
||
| LayoutObject::Clef(value)
|
||
| LayoutObject::KeySignature(value)
|
||
| LayoutObject::TimeSignatureDisplay(value)
|
||
| LayoutObject::Staff(value)
|
||
| LayoutObject::Text(value)
|
||
| LayoutObject::Graphic(value)
|
||
| LayoutObject::MultimeasureRest(value)
|
||
| LayoutObject::Cue(value)
|
||
| LayoutObject::Trajectory(value)
|
||
| LayoutObject::Group(value) => value,
|
||
}
|
||
}
|
||
|
||
fn payload_mut(&mut self) -> &mut CompositeLayoutObject {
|
||
match self {
|
||
LayoutObject::Note(value)
|
||
| LayoutObject::Chord(value)
|
||
| LayoutObject::Rest(value)
|
||
| LayoutObject::BeamGroup(value)
|
||
| LayoutObject::TupletDisplay(value)
|
||
| LayoutObject::Slur(value)
|
||
| LayoutObject::Tie(value)
|
||
| LayoutObject::Spanner(value)
|
||
| LayoutObject::Marker(value)
|
||
| LayoutObject::BarLine(value)
|
||
| LayoutObject::Clef(value)
|
||
| LayoutObject::KeySignature(value)
|
||
| LayoutObject::TimeSignatureDisplay(value)
|
||
| LayoutObject::Staff(value)
|
||
| LayoutObject::Text(value)
|
||
| LayoutObject::Graphic(value)
|
||
| LayoutObject::MultimeasureRest(value)
|
||
| LayoutObject::Cue(value)
|
||
| LayoutObject::Trajectory(value)
|
||
| LayoutObject::Group(value) => value,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Opaque identity of the score version projected into a layout pipeline.
|
||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
|
||
pub struct ScoreVersion(pub [u8; 32]);
|
||
|
||
/// Region-local coordinate system and its canvas transform.
|
||
#[derive(Copy, Clone, PartialEq, Debug, Default)]
|
||
pub struct LocalCoordinateSystem {
|
||
pub transform: Transform2D,
|
||
}
|
||
|
||
/// The globally identified staff bands occupied by a logical region.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct VerticalExtent {
|
||
pub staves: Vec<StaffId>,
|
||
}
|
||
|
||
/// A region projected into layout space, carrying its time axis (Chapter 7
|
||
/// §"Layout Regions"). All region kinds use this one container type
|
||
/// (Chapter 7 §"Region Uniformity").
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct LayoutRegion {
|
||
pub provenance: Provenance,
|
||
pub coordinate_system: LocalCoordinateSystem,
|
||
pub time_axis: TimeAxisModel,
|
||
pub vertical_extent: VerticalExtent,
|
||
pub objects: Vec<LayoutObject>,
|
||
}
|
||
|
||
/// A spanning object whose dependencies occupy more than one score region.
|
||
/// `regions` is in score-canvas order and identifies the complete span; the
|
||
/// spacing pass places its prototype glyph at the first anchored region while
|
||
/// preserving all regions in provenance dependencies.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct CrossRegionObject {
|
||
pub provenance: Provenance,
|
||
pub regions: Vec<RegionId>,
|
||
pub staff: Option<StaffId>,
|
||
}
|
||
|
||
/// The logical IR: the structural projection of the score graph (Chapter 7
|
||
/// §"LogicalLayoutIR"), plus the engraving decisions made during this pass.
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct LogicalLayoutIR {
|
||
pub source: ScoreVersion,
|
||
pub regions: Vec<LayoutRegion>,
|
||
/// Engraving decisions made during the engraving pass (Chapter 7
|
||
/// §"Engraving Decisions"), carried forward through the pipeline.
|
||
pub engraving_decisions: Vec<EngravingDecision>,
|
||
/// User engraving overrides projected from the score graph: each region's
|
||
/// authoritative `user_system_breaks` / `user_page_breaks` lists (Chapter 5
|
||
/// §"Staff-Based Content") become Soft, `Internal`-origin break overrides
|
||
/// targeting the owning region, ordered by (region id, kind, anchor
|
||
/// canonical bytes). Each carries a paired [`EngravingDecision`] with
|
||
/// [`DecisionSource::UserOverride`] in `engraving_decisions`.
|
||
pub overrides: Vec<EngravingOverride>,
|
||
/// Objects spanning two or more layout regions.
|
||
pub cross_region: Vec<CrossRegionObject>,
|
||
}
|
||
|
||
/// Projects a score graph into [`LogicalLayoutIR`].
|
||
///
|
||
/// Every layout object carries a [`Provenance`] whose `source` is the
|
||
/// score-graph object it represents, with dependency back-references for
|
||
/// incremental layout. One [`LayoutRegion`] per score region carries that
|
||
/// region's [`TimeAxisModel`]. The set of projected sources is exactly
|
||
/// [`crate::laid_out_object_ids`] — the two are kept in lockstep so the
|
||
/// round-trip's source-set surjection (each source recovered; manifestation
|
||
/// multiplicity carried by distinct stable ids) holds.
|
||
///
|
||
/// A score-graph object manifested within a region is laid out **per
|
||
/// manifestation**: its stable id derives from `(source, region)`
|
||
/// ([`Provenance::manifested`]), so a staff manifested in two time-disjoint
|
||
/// regions (Chapter 5 §"Region Overlap and Concurrency") yields *two* distinct
|
||
/// layout objects — both visual staves are preserved, neither is dropped. A
|
||
/// stable-id collision (the same `(source, region)` reached twice, e.g. a staff
|
||
/// listed twice in one staff extent) is de-duplicated.
|
||
pub fn to_logical(score: &Score) -> LogicalLayoutIR {
|
||
let mut regions = Vec::new();
|
||
let mut engraving_decisions = Vec::new();
|
||
// The projected break overrides, keyed by owning region for the final
|
||
// deterministic ordering (canvas order need not be region-id order).
|
||
let mut projected_breaks: Vec<(RegionId, EngravingOverride)> = Vec::new();
|
||
let mut cross_region = Vec::new();
|
||
let mut seen: BTreeSet<LayoutObjectId> = BTreeSet::new();
|
||
// The resolved spellings and decompositions the notation engraving consumes
|
||
// (Agent H's pre-pass): which notehead a note draws, where its pitches sit,
|
||
// and which accidentals its spelling carries. Recomputed deterministically
|
||
// from the score with the default profile.
|
||
let annotations = derive_annotations(score, &PrePassProfile::default())
|
||
.expect("the default pre-pass algorithms are supported");
|
||
// The last region index that manifests each staff, so a measure can tell a
|
||
// mid-staff region boundary (continuation) from the true final barline.
|
||
let mut staff_last_region: BTreeMap<StaffId, usize> = BTreeMap::new();
|
||
for (index, region) in score.canvas.regions.iter().enumerate() {
|
||
for staff_id in ®ion.staff_extent.staves {
|
||
staff_last_region.insert(*staff_id, index);
|
||
}
|
||
}
|
||
|
||
for (region_index, region) in score.canvas.regions.iter().enumerate() {
|
||
let region_id = region.id;
|
||
let mut objects = Vec::new();
|
||
let mut push = |source: TypedObjectId,
|
||
dependencies: Vec<TypedObjectId>,
|
||
staff: Option<StaffId>,
|
||
content: LayoutContent| {
|
||
let provenance = Provenance::manifested(source, region_id, dependencies);
|
||
if seen.insert(provenance.stable_id) {
|
||
objects.push(LayoutObject::from_projection_with_content(
|
||
provenance, staff, content,
|
||
));
|
||
}
|
||
};
|
||
|
||
// Staves manifested in this region (via the staff extent).
|
||
for staff_id in ®ion.staff_extent.staves {
|
||
push(
|
||
TypedObjectId::Staff(*staff_id),
|
||
vec![],
|
||
Some(*staff_id),
|
||
LayoutContent::Structural,
|
||
);
|
||
}
|
||
|
||
// Staff instances, voices, and their events + pitches — all belong to
|
||
// the instance's staff. The staff instance carries the clef/key in force.
|
||
for si in region.staff_instances() {
|
||
let staff = Some(si.staff);
|
||
let si_src = TypedObjectId::StaffInstance(si.id);
|
||
let mut si_deps = vec![TypedObjectId::Staff(si.staff)];
|
||
si_deps.extend(
|
||
si.clef_sequence
|
||
.iter()
|
||
.filter_map(|change| time_anchor_dep(&change.anchor)),
|
||
);
|
||
si_deps.extend(
|
||
si.key_sequence
|
||
.iter()
|
||
.filter_map(|change| time_anchor_dep(&change.anchor)),
|
||
);
|
||
push(si_src, si_deps, staff, staff_content(score, si));
|
||
for voice in &si.voices {
|
||
let v_src = TypedObjectId::Voice(voice.id);
|
||
push(v_src, vec![si_src], staff, LayoutContent::Structural);
|
||
for eid in &voice.events {
|
||
let e_src = TypedObjectId::Event(*eid);
|
||
// The event's pitches become its invalidation dependencies.
|
||
let pitches = identified_pitch_ids(score, *eid);
|
||
let mut deps = vec![v_src];
|
||
deps.extend(pitches.iter().copied().map(TypedObjectId::Pitch));
|
||
// The event carries the notated content (note value + spelled
|
||
// pitches); the per-pitch objects are structural provenance.
|
||
push(e_src, deps, staff, event_content(score, *eid, &annotations));
|
||
for pid in pitches {
|
||
push(
|
||
TypedObjectId::Pitch(pid),
|
||
vec![e_src],
|
||
staff,
|
||
LayoutContent::Structural,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Measures, per staff instance (Chapter 5 §"Measures"). The last measure
|
||
// of an instance ends this region's run; it is the true final barline only
|
||
// when this is the last region manifesting the staff.
|
||
for si in region.staff_instances() {
|
||
let last = si.measures.len().saturating_sub(1);
|
||
let staff_ends_here = staff_last_region.get(&si.staff) == Some(®ion_index);
|
||
for (index, measure) in si.measures.iter().enumerate() {
|
||
let barline = if index != last {
|
||
BarlineKind::Interior
|
||
} else if staff_ends_here {
|
||
BarlineKind::Final
|
||
} else {
|
||
BarlineKind::RegionEnd
|
||
};
|
||
// The measure depends on its staff instance, the time signature it
|
||
// displays (so a display change with the same id invalidates the
|
||
// measure and its synthesized time-signature glyphs), and whatever
|
||
// its start anchor resolves through.
|
||
let mut measure_deps = vec![TypedObjectId::StaffInstance(si.id)];
|
||
if let Some(time_signature) = measure.time_signature {
|
||
measure_deps.push(TypedObjectId::TimeSignature(time_signature));
|
||
}
|
||
if let Some(anchor_dep) = time_anchor_dep(&measure.start) {
|
||
measure_deps.push(anchor_dep);
|
||
}
|
||
push(
|
||
TypedObjectId::Measure(measure.id),
|
||
measure_deps,
|
||
Some(si.staff),
|
||
measure_content(score, measure, barline),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Free-graphic and hybrid-overlay graphic objects (Chapter 5 §"Graphic
|
||
// Content"; Chapter 7 §"Region Uniformity"). These are region-level, not
|
||
// staff-owned.
|
||
for go in region.content.graphic_objects() {
|
||
push(
|
||
TypedObjectId::GraphicObject(go.id),
|
||
vec![],
|
||
None,
|
||
LayoutContent::Structural,
|
||
);
|
||
}
|
||
|
||
let r_src = TypedObjectId::Region(region.id);
|
||
let region_provenance = Provenance::projected(
|
||
r_src,
|
||
region
|
||
.staff_extent
|
||
.staves
|
||
.iter()
|
||
.map(|s| TypedObjectId::Staff(*s))
|
||
.collect(),
|
||
);
|
||
// Each region notionally begins a system: record that decision against
|
||
// the region's stable layout id (Chapter 7 §"Engraving Decisions").
|
||
engraving_decisions.push(EngravingDecision::automatic(
|
||
region_provenance.stable_id,
|
||
EngravingDecisionKind::SystemBreak,
|
||
));
|
||
// The region's authoritative user break lists project as engraving
|
||
// overrides (Chapter 7 §"Engraving Overrides": a break override
|
||
// addresses a *position* — its kind carries the break's `TimeAnchor`,
|
||
// its `ScoreGraph` target names the owning region). Each applied
|
||
// override records a paired decision with
|
||
// `DecisionSource::UserOverride(id)` (Chapter 7 §"Override
|
||
// Resolution") against the region's stable layout id.
|
||
if let Some(content) = region.content.staff_based() {
|
||
for anchor in &content.user_system_breaks {
|
||
let projected =
|
||
EngravingOverride::projected_system_break(region_id, anchor.clone());
|
||
engraving_decisions.push(EngravingDecision::with_source(
|
||
region_provenance.stable_id,
|
||
EngravingDecisionKind::SystemBreak,
|
||
DecisionSource::UserOverride(projected.id),
|
||
));
|
||
projected_breaks.push((region_id, projected));
|
||
}
|
||
for anchor in &content.user_page_breaks {
|
||
let projected = EngravingOverride::projected_page_break(region_id, anchor.clone());
|
||
engraving_decisions.push(EngravingDecision::with_source(
|
||
region_provenance.stable_id,
|
||
EngravingDecisionKind::PageBreak,
|
||
DecisionSource::UserOverride(projected.id),
|
||
));
|
||
projected_breaks.push((region_id, projected));
|
||
}
|
||
}
|
||
regions.push(LayoutRegion {
|
||
provenance: region_provenance,
|
||
coordinate_system: LocalCoordinateSystem::default(),
|
||
time_axis: time_axis_of(region),
|
||
vertical_extent: VerticalExtent {
|
||
staves: region.staff_extent.staves.clone(),
|
||
},
|
||
objects,
|
||
});
|
||
}
|
||
|
||
// Place spanning structures according to the locations of their real
|
||
// dependencies. A single-region object joins that region and, when all
|
||
// located dependencies agree, that staff. A multi-region object uses the
|
||
// dedicated cross-region collection instead of being misfiled in region 0.
|
||
for (src, deps) in cross_cutting_objects(score) {
|
||
let provenance = Provenance::projected(src, deps.clone());
|
||
if !seen.insert(provenance.stable_id) {
|
||
continue;
|
||
}
|
||
// A repeat structure or slur carries its resolved engraving content
|
||
// (barline placements / endpoint onsets). Every other cross-cutting
|
||
// object is structural in this tier.
|
||
let content = match src {
|
||
TypedObjectId::RepeatStructure(id) => score
|
||
.cross_cutting
|
||
.repeats
|
||
.iter()
|
||
.find(|rp| rp.id == id)
|
||
.map(|rp| repeat_content(score, rp))
|
||
.unwrap_or_default(),
|
||
TypedObjectId::Slur(id) => score
|
||
.cross_cutting
|
||
.slurs
|
||
.iter()
|
||
.find(|slur| slur.id == id)
|
||
.map(|slur| slur_content(score, slur))
|
||
.unwrap_or_default(),
|
||
_ => LayoutContent::Structural,
|
||
};
|
||
let mut anchored_regions = Vec::new();
|
||
let mut anchored_staves = BTreeSet::new();
|
||
for region in ®ions {
|
||
let TypedObjectId::Region(region_id) = region.provenance.source else {
|
||
continue;
|
||
};
|
||
let mut touches_region = deps.contains(®ion.provenance.source);
|
||
for object in ®ion.objects {
|
||
if deps.contains(&object.provenance().source) {
|
||
touches_region = true;
|
||
if let Some(staff) = object.staff() {
|
||
anchored_staves.insert(staff);
|
||
}
|
||
}
|
||
}
|
||
if touches_region {
|
||
anchored_regions.push(region_id);
|
||
}
|
||
}
|
||
let staff = if anchored_staves.len() == 1 {
|
||
anchored_staves.iter().next().copied()
|
||
} else {
|
||
None
|
||
};
|
||
match anchored_regions.as_slice() {
|
||
[region_id] => {
|
||
let region = regions
|
||
.iter_mut()
|
||
.find(|region| region.provenance.source == TypedObjectId::Region(*region_id))
|
||
.expect("anchored region was collected from this vector");
|
||
region
|
||
.objects
|
||
.push(LayoutObject::from_projection_with_content(
|
||
provenance, staff, content,
|
||
));
|
||
}
|
||
[] => {
|
||
// Wall-clock-only annotations have no graph anchor from which
|
||
// to infer a region; retain deterministic fallback placement.
|
||
if let Some(first) = regions.first_mut() {
|
||
first
|
||
.objects
|
||
.push(LayoutObject::from_projection_with_content(
|
||
provenance, staff, content,
|
||
));
|
||
}
|
||
}
|
||
// A multi-region spanning object keeps no engraving content: the
|
||
// cross-region path places a single traced anchor at its first
|
||
// region (a Minimal-slice boundary — repeat ink across region
|
||
// boundaries belongs to a later tranche).
|
||
_ => cross_region.push(CrossRegionObject {
|
||
provenance,
|
||
regions: anchored_regions,
|
||
staff,
|
||
}),
|
||
}
|
||
}
|
||
|
||
// Deterministic override order: by (region id, kind discriminant, anchor
|
||
// canonical bytes) — independent of canvas order and of the break lists'
|
||
// internal order.
|
||
projected_breaks.sort_by(|(region_a, a), (region_b, b)| {
|
||
(region_a, a.kind.discriminant(), break_anchor_bytes(a)).cmp(&(
|
||
region_b,
|
||
b.kind.discriminant(),
|
||
break_anchor_bytes(b),
|
||
))
|
||
});
|
||
|
||
let source = derive_score_version(score);
|
||
LogicalLayoutIR {
|
||
source,
|
||
regions,
|
||
engraving_decisions,
|
||
overrides: projected_breaks
|
||
.into_iter()
|
||
.map(|(_, projected)| projected)
|
||
.collect(),
|
||
cross_region,
|
||
}
|
||
}
|
||
|
||
/// The canonical bytes of a projected break override's anchor (its ordering
|
||
/// key alongside the owning region and kind).
|
||
fn break_anchor_bytes(projected: &EngravingOverride) -> Vec<u8> {
|
||
match &projected.kind {
|
||
OverrideKind::SystemBreak { anchor } | OverrideKind::PageBreak { anchor } => {
|
||
anchor.canonical_bytes()
|
||
}
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Derives the [`ScoreVersion`] from the **whole score's canonical content**
|
||
/// (Agent B's whole-score codec), not merely the layout projection's object
|
||
/// identities. Any score edit — including one that changes an event's content
|
||
/// without changing any identifier (e.g. a respelling or a duration change) —
|
||
/// therefore yields a different version, which is what incremental-layout cache
|
||
/// invalidation depends on (Chapter 7 §"Incremental Layout"). The former
|
||
/// derivation keyed on layout-object `stable_id`s alone, so a pure content edit
|
||
/// left the version unchanged.
|
||
fn derive_score_version(score: &Score) -> ScoreVersion {
|
||
let mut preimage = Preimage::new(DomainTag::CONFLICT);
|
||
preimage.push_bytes(b"layout-score-version");
|
||
preimage.push_bytes(&score.canonical_bytes());
|
||
ScoreVersion(*preimage.finish().as_bytes())
|
||
}
|
||
|
||
/// The clef and key-signature sequences of a staff instance, carried with
|
||
/// resolved layout times. Empty sequences (a score that declares no clef/key)
|
||
/// are carried as-is — the constrained pass defaults the *active* clef/key to
|
||
/// treble / C major.
|
||
fn staff_content(score: &Score, si: &epiphany_core::StaffInstance) -> LayoutContent {
|
||
let default_clef = score
|
||
.staves
|
||
.iter()
|
||
.find(|staff| staff.id == si.staff)
|
||
.map(|staff| staff.default_clef)
|
||
.unwrap_or_default();
|
||
LayoutContent::Staff(StaffContent {
|
||
default_clef,
|
||
clefs: si
|
||
.clef_sequence
|
||
.iter()
|
||
.map(|change| PlacedClef {
|
||
time: resolve_time_anchor(score, &change.anchor),
|
||
clef: change.clef,
|
||
})
|
||
.collect(),
|
||
keys: si
|
||
.key_sequence
|
||
.iter()
|
||
.map(|change| PlacedKeySignature {
|
||
time: resolve_time_anchor(score, &change.anchor),
|
||
key: change.key,
|
||
})
|
||
.collect(),
|
||
})
|
||
}
|
||
|
||
/// The notated content of an event: a note (its position, decomposition, and
|
||
/// spelled pitches) for a pitched event, a rest for a rest, and structural for
|
||
/// the kinds this Minimal slice does not yet engrave (unpitched / indeterminate
|
||
/// / trajectory / graphic / cue). Every pitch is kept; an unspelled one carries
|
||
/// `spelling: None` rather than being dropped.
|
||
fn event_content(score: &Score, event: EventId, annotations: &DerivedAnnotations) -> LayoutContent {
|
||
let Some(graph_event) = score.events.get(event) else {
|
||
return LayoutContent::Structural;
|
||
};
|
||
let components = placed_components(score, components_of(annotations, event));
|
||
match graph_event {
|
||
Event::Pitched(pitched) => {
|
||
let pitches = pitched
|
||
.pitches
|
||
.iter()
|
||
.map(|identified| NotePitch {
|
||
pitch: identified.id,
|
||
spelling: annotations
|
||
.spellings
|
||
.get(&identified.id)
|
||
.map(|resolved| resolved.spelling.clone()),
|
||
})
|
||
.collect();
|
||
LayoutContent::Note(NoteContent {
|
||
position: event_time(&pitched.position),
|
||
components,
|
||
pitches,
|
||
})
|
||
}
|
||
Event::Rest(rest) => LayoutContent::Rest(RestContent {
|
||
position: event_time(&rest.position),
|
||
components,
|
||
staff_position: rest.vertical_position,
|
||
}),
|
||
_ => LayoutContent::Structural,
|
||
}
|
||
}
|
||
|
||
/// An event's concrete position as a layout [`TimePoint`] (the two share the
|
||
/// musical/wall-clock shape).
|
||
fn event_time(position: &EventPosition) -> TimePoint {
|
||
match position {
|
||
EventPosition::Musical(p) => TimePoint::Musical(p.clone()),
|
||
EventPosition::WallClock(t) => TimePoint::WallClock(*t),
|
||
}
|
||
}
|
||
|
||
/// Places each notated component at its successive offset from the event start,
|
||
/// resolving its tuplet ratio. The offset of a component is the summed sounding
|
||
/// duration of the components before it (base value × dot factor × tuplet
|
||
/// scale), so a multi-component (e.g. tied-across-a-barline) note yields separate
|
||
/// noteheads at the right positions.
|
||
fn placed_components(score: &Score, components: Vec<NotatedComponent>) -> Vec<PlacedComponent> {
|
||
let mut placed = Vec::with_capacity(components.len());
|
||
let mut offset = MusicalDuration::zero();
|
||
for component in components {
|
||
let tuplet = component.tuplet.and_then(|id| tuplet_ratio(score, id));
|
||
let duration = component_duration(&component, tuplet);
|
||
placed.push(PlacedComponent {
|
||
offset: offset.clone(),
|
||
component,
|
||
tuplet,
|
||
});
|
||
offset = offset + duration;
|
||
}
|
||
placed
|
||
}
|
||
|
||
/// The resolved ratio of a tuplet, looked up by id in the score's cross-cutting
|
||
/// registry.
|
||
fn tuplet_ratio(score: &Score, id: TupletId) -> Option<TupletRatio> {
|
||
score
|
||
.cross_cutting
|
||
.tuplets
|
||
.iter()
|
||
.find(|tuplet| tuplet.id == id)
|
||
.map(|tuplet| tuplet.ratio)
|
||
}
|
||
|
||
/// The sounding duration of a notated component. The core graph model owns the
|
||
/// exact dotted-duration semantics, including large dot counts that require
|
||
/// arbitrary precision, so layout delegates instead of duplicating the math.
|
||
fn component_duration(
|
||
component: &NotatedComponent,
|
||
tuplet: Option<TupletRatio>,
|
||
) -> MusicalDuration {
|
||
component.sounding_duration(tuplet)
|
||
}
|
||
|
||
/// Resolves a [`TimeAnchor`] to a concrete layout [`TimePoint`] for placement.
|
||
/// Event anchors use the event's own region-local position plus the anchor
|
||
/// offset; measure anchors recurse through the referenced measure boundary; and
|
||
/// region anchors resolve to the referenced region edge in that region's local
|
||
/// time discipline. Cycles, missing targets, unknown metric region ends, and
|
||
/// clock-mismatched offsets fall back to the musical origin — surfaced as a
|
||
/// Minimal-slice boundary rather than panicking or inventing a false coordinate.
|
||
fn resolve_time_anchor(score: &Score, anchor: &TimeAnchor) -> TimePoint {
|
||
const DEPTH: u8 = 16;
|
||
resolve_time_anchor_inner(score, anchor, DEPTH).unwrap_or_else(origin_time)
|
||
}
|
||
|
||
fn resolve_time_anchor_inner(score: &Score, anchor: &TimeAnchor, depth: u8) -> Option<TimePoint> {
|
||
if depth == 0 {
|
||
return None;
|
||
}
|
||
match anchor {
|
||
TimeAnchor::WallClock { time } => Some(TimePoint::WallClock(*time)),
|
||
TimeAnchor::Event { id, offset } => {
|
||
let event = score.events.get(*id)?;
|
||
apply_offset(event_time(event.position()), offset)
|
||
}
|
||
TimeAnchor::Measure {
|
||
id,
|
||
position,
|
||
offset,
|
||
} => {
|
||
let base = measure_anchor_time(score, *id, *position, depth - 1)?;
|
||
apply_offset(base, offset)
|
||
}
|
||
TimeAnchor::Region { id, edge, offset } => {
|
||
let region = score
|
||
.canvas
|
||
.regions
|
||
.iter()
|
||
.find(|region| region.id == *id)?;
|
||
let base = region_edge_time(region, *edge, offset)?;
|
||
apply_offset(base, offset)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Applies an [`AnchorOffset`] to a resolved base time; `None` when the
|
||
/// offset's clock does not match the base. Shared with the constrained stage's
|
||
/// break-anchor resolution.
|
||
pub(crate) fn apply_offset(base: TimePoint, offset: &AnchorOffset) -> Option<TimePoint> {
|
||
match (base, offset) {
|
||
(base, AnchorOffset::Zero) => Some(base),
|
||
(TimePoint::Musical(position), AnchorOffset::Musical(duration)) => {
|
||
Some(TimePoint::Musical(position + duration.clone()))
|
||
}
|
||
(TimePoint::WallClock(time), AnchorOffset::WallClock(duration)) => time
|
||
.0
|
||
.checked_add(duration.0)
|
||
.map(WallClockTime)
|
||
.map(TimePoint::WallClock),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn measure_anchor_time(
|
||
score: &Score,
|
||
id: epiphany_core::MeasureId,
|
||
position: MeasurePosition,
|
||
depth: u8,
|
||
) -> Option<TimePoint> {
|
||
for (_, instance) in score.staff_instances() {
|
||
let Some(index) = instance
|
||
.measures
|
||
.iter()
|
||
.position(|measure| measure.id == id)
|
||
else {
|
||
continue;
|
||
};
|
||
return match position {
|
||
MeasurePosition::Start => {
|
||
resolve_time_anchor_inner(score, &instance.measures[index].start, depth)
|
||
}
|
||
MeasurePosition::End => instance
|
||
.measures
|
||
.get(index + 1)
|
||
.and_then(|next| resolve_time_anchor_inner(score, &next.start, depth)),
|
||
};
|
||
}
|
||
None
|
||
}
|
||
|
||
fn region_edge_time(region: &Region, edge: RegionEdge, offset: &AnchorOffset) -> Option<TimePoint> {
|
||
match edge {
|
||
RegionEdge::Start => Some(region_origin_time(region, offset)),
|
||
RegionEdge::End => region_end_time(region),
|
||
}
|
||
}
|
||
|
||
fn region_origin_time(region: &Region, offset: &AnchorOffset) -> TimePoint {
|
||
match offset {
|
||
AnchorOffset::Musical(_) => TimePoint::Musical(MusicalPosition::origin()),
|
||
AnchorOffset::WallClock(_) => TimePoint::WallClock(WallClockTime(0)),
|
||
AnchorOffset::Zero => match region.time_model.coordinate_discipline() {
|
||
CoordinateDiscipline::Musical => TimePoint::Musical(MusicalPosition::origin()),
|
||
CoordinateDiscipline::WallClock => TimePoint::WallClock(WallClockTime(0)),
|
||
CoordinateDiscipline::Aleatoric(AleatoricAnchoringDiscipline::WallClock) => {
|
||
TimePoint::WallClock(WallClockTime(0))
|
||
}
|
||
CoordinateDiscipline::Aleatoric(_) => TimePoint::Musical(MusicalPosition::origin()),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn region_end_time(region: &Region) -> Option<TimePoint> {
|
||
match ®ion.time_model {
|
||
RegionTimeModel::Proportional(model) => {
|
||
Some(TimePoint::WallClock(WallClockTime(model.duration.0)))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The musical origin as a [`TimePoint`] (the placement fallback).
|
||
fn origin_time() -> TimePoint {
|
||
TimePoint::Musical(MusicalPosition::origin())
|
||
}
|
||
|
||
/// A repeat structure's engraving content: its kind's barline verdict plus
|
||
/// every boundary (structure and volta) resolved to a [`RepeatPlacement`].
|
||
fn repeat_content(score: &Score, rp: &epiphany_core::RepeatStructure) -> LayoutContent {
|
||
use epiphany_core::RepeatKind;
|
||
LayoutContent::Repeat(RepeatContent {
|
||
barlines: matches!(rp.kind, RepeatKind::SimpleRepeat { .. } | RepeatKind::Volta),
|
||
start: repeat_placement(score, &rp.start),
|
||
end: repeat_placement(score, &rp.end),
|
||
voltas: rp
|
||
.voltas
|
||
.iter()
|
||
.map(|volta| VoltaContent {
|
||
endings: volta.endings.clone(),
|
||
start: repeat_placement(score, &volta.start),
|
||
end: repeat_placement(score, &volta.end),
|
||
})
|
||
.collect(),
|
||
})
|
||
}
|
||
|
||
/// A slur's engraving content: each endpoint event resolved to its onset (or
|
||
/// [`SlurEndpoint::Unresolved`] when the event is missing), plus the authored
|
||
/// curvature/style overrides. Direction defaults to [`SlurDirection::Auto`]
|
||
/// when the override leaves it unset.
|
||
fn slur_content(score: &Score, slur: &epiphany_core::Slur) -> LayoutContent {
|
||
use epiphany_core::CurveDirection;
|
||
let direction = match slur.curvature_override.as_ref().and_then(|o| o.direction) {
|
||
Some(CurveDirection::Above) => SlurDirection::Above,
|
||
Some(CurveDirection::Below) => SlurDirection::Below,
|
||
None => SlurDirection::Auto,
|
||
};
|
||
LayoutContent::Slur(SlurContent {
|
||
start: slur_endpoint(score, slur.start_event),
|
||
end: slur_endpoint(score, slur.end_event),
|
||
direction,
|
||
height: slur.curvature_override.as_ref().and_then(|o| o.height),
|
||
thickness: slur.style.thickness,
|
||
kind: slur.kind,
|
||
line: slur.style.line,
|
||
})
|
||
}
|
||
|
||
/// A slur endpoint: the event's onset as a [`TimePoint`], or
|
||
/// [`SlurEndpoint::Unresolved`] when the event is absent from the score.
|
||
fn slur_endpoint(score: &Score, event: EventId) -> SlurEndpoint {
|
||
match score.events.get(event) {
|
||
Some(graph_event) => SlurEndpoint::At(event_time(graph_event.position())),
|
||
None => SlurEndpoint::Unresolved,
|
||
}
|
||
}
|
||
|
||
/// Resolves a repeat boundary anchor to a [`RepeatPlacement`]. Unlike
|
||
/// [`resolve_time_anchor`], failure is **honest** (`Unresolved` draws no ink)
|
||
/// rather than falling back to the origin — a repeat sign at a false position
|
||
/// would misstate the musical structure. Two region-closing shapes resolve to
|
||
/// [`RepeatPlacement::RegionEnd`] by *column* even though their *time* is
|
||
/// unknowable in a metric region: a zero-offset anchor to an existing region's
|
||
/// end edge, and a zero-offset anchor to the end of a staff instance's last
|
||
/// measure. Offsets are tested for zero-ness **by value** (`Zero`,
|
||
/// `Musical(0)`, `WallClock(0)` all qualify — value-equal decoded anchors must
|
||
/// not render differently).
|
||
///
|
||
/// A bare **wall-clock** boundary is `Unresolved`: it references no graph
|
||
/// object, so nothing ties it to the region it would draw in — the sign would
|
||
/// land wherever the wall-clock time happens to *sort* among that region's
|
||
/// columns (after every musical column, in a metric region), a false position.
|
||
/// Repeat ink for wall-clock-synchronized material is a later tranche.
|
||
/// (Wall-clock `TimePoint`s resolved *through* an event/measure/region anchor
|
||
/// are fine — the referenced object pins the region and its clock.)
|
||
fn repeat_placement(score: &Score, anchor: &TimeAnchor) -> RepeatPlacement {
|
||
const DEPTH: u8 = 16;
|
||
match anchor {
|
||
TimeAnchor::Region {
|
||
id,
|
||
edge: RegionEdge::End,
|
||
offset,
|
||
} if offset_is_zero(offset)
|
||
&& score.canvas.regions.iter().any(|region| region.id == *id) =>
|
||
{
|
||
RepeatPlacement::RegionEnd
|
||
}
|
||
TimeAnchor::Measure {
|
||
id,
|
||
position: MeasurePosition::End,
|
||
offset,
|
||
} if offset_is_zero(offset) && is_last_measure(score, *id) => RepeatPlacement::RegionEnd,
|
||
TimeAnchor::WallClock { .. } => RepeatPlacement::Unresolved,
|
||
_ => match resolve_time_anchor_inner(score, anchor, DEPTH) {
|
||
Some(time) => RepeatPlacement::At(time),
|
||
None => RepeatPlacement::Unresolved,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Whether an anchor offset is zero **by value**: the `Zero` variant or a
|
||
/// zero-magnitude duration of either clock (both are representable and decode
|
||
/// verbatim; the placement verdict must not depend on the representation).
|
||
fn offset_is_zero(offset: &AnchorOffset) -> bool {
|
||
match offset {
|
||
AnchorOffset::Zero => true,
|
||
AnchorOffset::Musical(duration) => *duration == MusicalDuration::zero(),
|
||
AnchorOffset::WallClock(duration) => duration.0 == 0,
|
||
}
|
||
}
|
||
|
||
/// Whether `id` names the **last** measure of the staff instance that owns it
|
||
/// (first-match walk, the same discipline as [`measure_anchor_time`]).
|
||
fn is_last_measure(score: &Score, id: epiphany_core::MeasureId) -> bool {
|
||
for (_, instance) in score.staff_instances() {
|
||
if let Some(index) = instance
|
||
.measures
|
||
.iter()
|
||
.position(|measure| measure.id == id)
|
||
{
|
||
return index + 1 == instance.measures.len();
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// The full notated decomposition of an event (base values, dots, tuplets, ties)
|
||
/// from the pre-pass; empty when the event has no decomposition (non-metric or
|
||
/// ineligible) — the constrained pass surfaces that rather than inventing a value.
|
||
fn components_of(annotations: &DerivedAnnotations, event: EventId) -> Vec<NotatedComponent> {
|
||
annotations
|
||
.decompositions
|
||
.get(&event)
|
||
.map(|decomposition| decomposition.components.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// The notated content of a measure: its start anchor, its ending barline, and
|
||
/// the time signature it introduces, resolved to numerator/denominator when
|
||
/// standard or irrational (compound / mixed / symbolic meters are not engraved
|
||
/// in I-1).
|
||
fn measure_content(
|
||
score: &Score,
|
||
measure: &epiphany_core::Measure,
|
||
barline: BarlineKind,
|
||
) -> LayoutContent {
|
||
let time_signature = measure
|
||
.time_signature
|
||
.and_then(|id| time_signature_content(score, id));
|
||
LayoutContent::Measure(MeasureContent {
|
||
start: resolve_time_anchor(score, &measure.start),
|
||
barline,
|
||
time_signature,
|
||
})
|
||
}
|
||
|
||
/// Resolves a time-signature id to its displayed numerator/denominator, for the
|
||
/// meter shapes I-1 engraves.
|
||
fn time_signature_content(
|
||
score: &Score,
|
||
id: epiphany_core::TimeSignatureId,
|
||
) -> Option<TimeSignatureContent> {
|
||
let signature = score.time_signatures.iter().find(|t| t.id == id)?;
|
||
match &signature.display {
|
||
TimeSignatureDisplay::Standard {
|
||
numerator,
|
||
denominator,
|
||
} => Some(TimeSignatureContent {
|
||
numerator: *numerator,
|
||
denominator: denominator.get(),
|
||
}),
|
||
TimeSignatureDisplay::Irrational {
|
||
numerator,
|
||
denominator,
|
||
} => Some(TimeSignatureContent {
|
||
numerator: *numerator,
|
||
denominator: denominator.get(),
|
||
}),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The identified-pitch ids of an event, in arena order (empty if the event is
|
||
/// absent or carries no pitches).
|
||
pub(crate) fn identified_pitch_ids(
|
||
score: &Score,
|
||
event: epiphany_core::EventId,
|
||
) -> Vec<epiphany_core::PitchId> {
|
||
let mut ids = Vec::new();
|
||
if let Some(event) = score.events.get(event) {
|
||
let mut buf = Vec::new();
|
||
event.collect_identified_pitches(&mut buf);
|
||
ids.extend(buf.iter().map(|p| p.id));
|
||
}
|
||
ids
|
||
}
|
||
|
||
/// The score-graph object a [`TimeAnchor`] depends on, if any (a wall-clock
|
||
/// anchor depends on no object). Anchors are real invalidation dependencies: if
|
||
/// the anchored event/measure/region changes, the spanning object must relayout.
|
||
fn time_anchor_dep(anchor: &TimeAnchor) -> Option<TypedObjectId> {
|
||
match anchor {
|
||
TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)),
|
||
TimeAnchor::Measure { id, .. } => Some(TypedObjectId::Measure(*id)),
|
||
TimeAnchor::Region { id, .. } => Some(TypedObjectId::Region(*id)),
|
||
TimeAnchor::WallClock { .. } => None,
|
||
}
|
||
}
|
||
|
||
/// The score-graph objects an [`AnnotationAnchor`] depends on.
|
||
fn annotation_anchor_deps(anchor: &AnnotationAnchor) -> Vec<TypedObjectId> {
|
||
match anchor {
|
||
AnnotationAnchor::Event(id) => vec![TypedObjectId::Event(*id)],
|
||
AnnotationAnchor::Range { start, end } => [start, end]
|
||
.iter()
|
||
.filter_map(|a| time_anchor_dep(a))
|
||
.collect(),
|
||
AnnotationAnchor::Region(id) => vec![TypedObjectId::Region(*id)],
|
||
}
|
||
}
|
||
|
||
/// The score's cross-cutting objects as `(source, dependencies)` pairs, in the
|
||
/// canonical order the projection emits them. Every cross-cutting registry
|
||
/// (Chapter 5 §"Cross-Cutting Structures") is projected, and each object's
|
||
/// dependencies are its real references — member events, anchored objects, and
|
||
/// attached staves — so an edit to any of them invalidates the spanning layout
|
||
/// object (Chapter 7 §"Invalidation Rules").
|
||
pub(crate) fn cross_cutting_objects(score: &Score) -> Vec<(TypedObjectId, Vec<TypedObjectId>)> {
|
||
let cc = &score.cross_cutting;
|
||
let mut out: Vec<(TypedObjectId, Vec<TypedObjectId>)> = Vec::new();
|
||
for t in &cc.ties {
|
||
out.push((
|
||
TypedObjectId::Tie(t.id),
|
||
vec![
|
||
TypedObjectId::Event(t.start_event),
|
||
TypedObjectId::Event(t.end_event),
|
||
],
|
||
));
|
||
}
|
||
for s in &cc.slurs {
|
||
out.push((
|
||
TypedObjectId::Slur(s.id),
|
||
vec![
|
||
TypedObjectId::Event(s.start_event),
|
||
TypedObjectId::Event(s.end_event),
|
||
],
|
||
));
|
||
}
|
||
for b in &cc.beams {
|
||
out.push((
|
||
TypedObjectId::Beam(b.id),
|
||
b.events.iter().map(|e| TypedObjectId::Event(*e)).collect(),
|
||
));
|
||
}
|
||
for tu in &cc.tuplets {
|
||
out.push((
|
||
TypedObjectId::Tuplet(tu.id),
|
||
tu.members
|
||
.iter()
|
||
.map(|e| TypedObjectId::Event(*e))
|
||
.collect(),
|
||
));
|
||
}
|
||
for sp in &cc.spanners {
|
||
let mut deps: Vec<TypedObjectId> = [&sp.start, &sp.end]
|
||
.iter()
|
||
.filter_map(|a| time_anchor_dep(a))
|
||
.collect();
|
||
deps.extend(sp.staves.iter().map(|s| TypedObjectId::Staff(*s)));
|
||
out.push((TypedObjectId::Spanner(sp.id), deps));
|
||
}
|
||
for mk in &cc.markers {
|
||
out.push((
|
||
TypedObjectId::Marker(mk.id),
|
||
time_anchor_dep(&mk.anchor).into_iter().collect(),
|
||
));
|
||
}
|
||
for rp in &cc.repeats {
|
||
// THE single site-set walk (core `RepeatStructure::anchor_sites`):
|
||
// volta spans and jump targets are real invalidation dependencies and
|
||
// real region-membership evidence, exactly like start/end.
|
||
let deps = rp
|
||
.anchor_sites()
|
||
.into_iter()
|
||
.filter_map(time_anchor_dep)
|
||
.collect();
|
||
out.push((TypedObjectId::RepeatStructure(rp.id), deps));
|
||
}
|
||
for an in &cc.analytical {
|
||
let mut deps = annotation_anchor_deps(&an.anchor);
|
||
deps.extend(an.layer.map(TypedObjectId::AnalysisLayer));
|
||
out.push((TypedObjectId::AnalyticalAnnotation(an.id), deps));
|
||
}
|
||
for cm in &cc.comments {
|
||
out.push((
|
||
TypedObjectId::Comment(cm.id),
|
||
annotation_anchor_deps(&cm.anchor),
|
||
));
|
||
}
|
||
for gg in &cc.graphic_gestures {
|
||
out.push((
|
||
TypedObjectId::GraphicGesture(gg.id),
|
||
gg.objects
|
||
.iter()
|
||
.map(|o| TypedObjectId::GraphicObject(*o))
|
||
.collect(),
|
||
));
|
||
}
|
||
for ly in &cc.lyrics {
|
||
out.push((
|
||
TypedObjectId::LyricLine(ly.id),
|
||
ly.events.iter().map(|e| TypedObjectId::Event(*e)).collect(),
|
||
));
|
||
}
|
||
for ch in &cc.chord_symbols {
|
||
out.push((
|
||
TypedObjectId::ChordSymbol(ch.id),
|
||
time_anchor_dep(&ch.anchor).into_iter().collect(),
|
||
));
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use epiphany_core::generators::{valid_score, valid_score_rich};
|
||
use epiphany_core::{
|
||
AnchorOffset, Clef, ClefChange, KeySignature, KeySignatureChange, NoteValue, RationalTime,
|
||
RegionEdge, Spanner, SpannerId, TimeAnchor, WallClockTime,
|
||
};
|
||
|
||
fn duration(numerator: i64, denominator: i64) -> MusicalDuration {
|
||
MusicalDuration(RationalTime::new(numerator, denominator).expect("nonzero"))
|
||
}
|
||
|
||
fn position(numerator: i64, denominator: i64) -> MusicalPosition {
|
||
MusicalPosition(RationalTime::new(numerator, denominator).expect("nonzero"))
|
||
}
|
||
|
||
#[test]
|
||
fn to_logical_enriches_notes_staves_and_measures() {
|
||
let score = valid_score_rich(7);
|
||
let ir = to_logical(&score);
|
||
let objects: Vec<&LayoutObject> = ir
|
||
.regions
|
||
.iter()
|
||
.flat_map(|region| region.objects.iter())
|
||
.collect();
|
||
|
||
// A pitched event projects a note carrying its decomposition and at least
|
||
// one spelled pitch (and its position is recorded).
|
||
let spelled_note = objects.iter().any(|object| {
|
||
matches!(object.content(), LayoutContent::Note(note)
|
||
if !note.components.is_empty()
|
||
&& note.pitches.iter().any(|pitch| pitch.spelling.is_some()))
|
||
});
|
||
assert!(
|
||
spelled_note,
|
||
"expected an enriched note with a decomposition and a spelled pitch"
|
||
);
|
||
|
||
// Staff instances carry their clef/key sequences. valid_score declares no
|
||
// clef, so the sequences are empty (the constrained pass defaults them to
|
||
// treble / C major).
|
||
let staves: Vec<&StaffContent> = objects
|
||
.iter()
|
||
.filter_map(|object| match object.content() {
|
||
LayoutContent::Staff(staff) => Some(staff),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(!staves.is_empty(), "staff instances carry staff content");
|
||
assert!(
|
||
staves.iter().all(|staff| staff.clefs.is_empty()),
|
||
"a score with no declared clef carries an empty clef sequence"
|
||
);
|
||
|
||
// Measures project measure content carrying a start anchor; the last
|
||
// region manifesting each staff ends with a true Final barline (never
|
||
// every region end).
|
||
let measures: Vec<&MeasureContent> = objects
|
||
.iter()
|
||
.filter_map(|object| match object.content() {
|
||
LayoutContent::Measure(measure) => Some(measure),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(!measures.is_empty(), "measures project measure content");
|
||
assert!(
|
||
measures
|
||
.iter()
|
||
.any(|measure| measure.barline == BarlineKind::Final),
|
||
"the staff's last region ends with a final barline"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn placed_components_accumulate_successive_offsets() {
|
||
// A note notated as a quarter tied to an eighth: the second component
|
||
// starts a quarter-note's duration after the first (offsets are summed,
|
||
// not collapsed).
|
||
let score = valid_score(1);
|
||
let components = vec![
|
||
NotatedComponent {
|
||
base_value: NoteValue::Quarter,
|
||
dots: 0,
|
||
tuplet: None,
|
||
tied_to_next: true,
|
||
},
|
||
NotatedComponent {
|
||
base_value: NoteValue::Eighth,
|
||
dots: 0,
|
||
tuplet: None,
|
||
tied_to_next: false,
|
||
},
|
||
];
|
||
let placed = placed_components(&score, components);
|
||
assert_eq!(placed.len(), 2);
|
||
assert_eq!(placed[0].offset, MusicalDuration::zero());
|
||
assert_eq!(placed[1].offset, duration(1, 4));
|
||
}
|
||
|
||
#[test]
|
||
fn placed_components_uses_core_duration_for_large_dot_counts() {
|
||
let score = valid_score(1);
|
||
let component = NotatedComponent {
|
||
base_value: NoteValue::SixtyFourth,
|
||
dots: 80,
|
||
tuplet: None,
|
||
tied_to_next: true,
|
||
};
|
||
let placed = placed_components(&score, vec![component.clone(), component.clone()]);
|
||
assert_eq!(placed.len(), 2);
|
||
assert_eq!(placed[1].offset, component.sounding_duration(None));
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_time_anchor_applies_event_offsets() {
|
||
let score = valid_score(1);
|
||
let event = score
|
||
.events
|
||
.iter_canonical()
|
||
.find(|event| matches!(event.position(), EventPosition::Musical(_)))
|
||
.expect("valid_score contains musical events");
|
||
let EventPosition::Musical(base) = event.position() else {
|
||
unreachable!("filtered for musical events");
|
||
};
|
||
let offset = duration(1, 4);
|
||
let resolved = resolve_time_anchor(
|
||
&score,
|
||
&TimeAnchor::Event {
|
||
id: event.id(),
|
||
offset: AnchorOffset::Musical(offset.clone()),
|
||
},
|
||
);
|
||
assert_eq!(resolved, TimePoint::Musical(base.clone() + offset));
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_time_anchor_uses_referenced_region_edge() {
|
||
let score = valid_score_rich(7);
|
||
let (region_id, duration_ns) = score
|
||
.canvas
|
||
.regions
|
||
.iter()
|
||
.find_map(|region| match ®ion.time_model {
|
||
RegionTimeModel::Proportional(model) => Some((region.id, model.duration.0)),
|
||
_ => None,
|
||
})
|
||
.expect("valid_score_rich contains a proportional region");
|
||
|
||
let resolved = resolve_time_anchor(
|
||
&score,
|
||
&TimeAnchor::Region {
|
||
id: region_id,
|
||
edge: RegionEdge::End,
|
||
offset: AnchorOffset::Zero,
|
||
},
|
||
);
|
||
assert_eq!(resolved, TimePoint::WallClock(WallClockTime(duration_ns)));
|
||
}
|
||
|
||
#[test]
|
||
fn staff_content_resolves_clef_and_key_anchors() {
|
||
let mut score = valid_score(1);
|
||
let region_id = score.canvas.regions[0].id;
|
||
let anchor = TimeAnchor::Region {
|
||
id: region_id,
|
||
edge: RegionEdge::Start,
|
||
offset: AnchorOffset::Musical(duration(1, 4)),
|
||
};
|
||
let staff_instance = score.canvas.regions[0]
|
||
.content
|
||
.staff_instances_mut()
|
||
.expect("valid_score is staff based")
|
||
.first_mut()
|
||
.expect("valid_score contains a staff instance");
|
||
staff_instance.clef_sequence.push(ClefChange {
|
||
anchor: anchor.clone(),
|
||
clef: Clef::bass(),
|
||
});
|
||
staff_instance.key_sequence.push(KeySignatureChange {
|
||
anchor,
|
||
key: KeySignature::new(-3).expect("valid key signature"),
|
||
});
|
||
|
||
let ir = to_logical(&score);
|
||
let staff = ir
|
||
.regions
|
||
.iter()
|
||
.flat_map(|region| region.objects.iter())
|
||
.find_map(|object| match object.content() {
|
||
LayoutContent::Staff(staff) if !staff.clefs.is_empty() => Some(staff),
|
||
_ => None,
|
||
})
|
||
.expect("staff content carries clef/key changes");
|
||
assert_eq!(
|
||
staff.clefs[0],
|
||
PlacedClef {
|
||
time: TimePoint::Musical(position(1, 4)),
|
||
clef: Clef::bass(),
|
||
}
|
||
);
|
||
assert_eq!(
|
||
staff.keys[0],
|
||
PlacedKeySignature {
|
||
time: TimePoint::Musical(position(1, 4)),
|
||
key: KeySignature::new(-3).expect("valid key signature"),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn score_version_tracks_content_not_just_identifiers() {
|
||
let score = valid_score(7);
|
||
// Deterministic: the same score yields the same version.
|
||
assert_eq!(to_logical(&score).source, to_logical(&score).source);
|
||
// Distinct scores yield distinct versions.
|
||
assert_ne!(
|
||
to_logical(&valid_score(7)).source,
|
||
to_logical(&valid_score(8)).source
|
||
);
|
||
// A pure content edit that changes NO identifier still changes the
|
||
// version (the old identity-only derivation missed this).
|
||
let mut edited = score.clone();
|
||
edited.metadata.title = Some("a different title".to_owned());
|
||
assert_ne!(
|
||
to_logical(&score).source,
|
||
to_logical(&edited).source,
|
||
"a content edit with unchanged ids must change the score version"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn user_breaks_project_as_overrides_with_paired_decisions() {
|
||
use crate::engraving::{
|
||
DecisionSource, EngravingDecisionKind, OverrideKind, OverrideOrigin, OverridePriority,
|
||
OverrideTarget,
|
||
};
|
||
let mut score = valid_score(5);
|
||
let region_id = score.canvas.regions[0].id;
|
||
let anchor = TimeAnchor::WallClock {
|
||
time: WallClockTime(42),
|
||
};
|
||
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.clone());
|
||
|
||
// Deterministic across two runs.
|
||
let ir = to_logical(&score);
|
||
assert_eq!(ir.overrides, to_logical(&score).overrides);
|
||
|
||
// One override per break, in the pinned projected shape: the kind
|
||
// carries the anchor, the ScoreGraph target names the owning region,
|
||
// Soft binding, Internal origin.
|
||
assert_eq!(ir.overrides.len(), 2);
|
||
for projected in &ir.overrides {
|
||
assert_eq!(
|
||
projected.target,
|
||
OverrideTarget::ScoreGraph(TypedObjectId::Region(region_id))
|
||
);
|
||
assert_eq!(projected.priority, OverridePriority::Soft);
|
||
assert_eq!(projected.origin, OverrideOrigin::Internal);
|
||
}
|
||
assert!(ir.overrides.iter().any(|o| matches!(
|
||
&o.kind,
|
||
OverrideKind::SystemBreak { anchor: got } if *got == anchor
|
||
)));
|
||
assert!(ir.overrides.iter().any(|o| matches!(
|
||
&o.kind,
|
||
OverrideKind::PageBreak { anchor: got } if *got == anchor
|
||
)));
|
||
assert_ne!(ir.overrides[0].id, ir.overrides[1].id);
|
||
|
||
// Each applied override records a paired decision sourced to it
|
||
// (Chapter 7 §"Override Resolution").
|
||
for projected in &ir.overrides {
|
||
let kind = match &projected.kind {
|
||
OverrideKind::SystemBreak { .. } => EngravingDecisionKind::SystemBreak,
|
||
OverrideKind::PageBreak { .. } => EngravingDecisionKind::PageBreak,
|
||
other => panic!("unexpected projected override kind {other:?}"),
|
||
};
|
||
assert!(
|
||
ir.engraving_decisions.iter().any(|decision| decision.source
|
||
== DecisionSource::UserOverride(projected.id)
|
||
&& decision.kind == kind),
|
||
"no paired UserOverride decision for {projected:?}"
|
||
);
|
||
}
|
||
// The automatic per-region system-break decision is still present.
|
||
assert!(ir
|
||
.engraving_decisions
|
||
.iter()
|
||
.any(|decision| decision.source == DecisionSource::Automatic
|
||
&& decision.kind == EngravingDecisionKind::SystemBreak));
|
||
}
|
||
|
||
#[test]
|
||
fn spanning_object_uses_cross_region_collection() {
|
||
let mut score = valid_score_rich(5);
|
||
let first = score.canvas.regions[0].id;
|
||
let second = score.canvas.regions[1].id;
|
||
let first_staff = score.canvas.regions[0].staff_extent.staves[0];
|
||
let second_staff = score.canvas.regions[1].staff_extent.staves[0];
|
||
let id: SpannerId = score.identity.mint();
|
||
score.cross_cutting.spanners.push(Spanner {
|
||
id,
|
||
start: TimeAnchor::Region {
|
||
id: first,
|
||
edge: RegionEdge::Start,
|
||
offset: AnchorOffset::Zero,
|
||
},
|
||
end: TimeAnchor::Region {
|
||
id: second,
|
||
edge: RegionEdge::End,
|
||
offset: AnchorOffset::Zero,
|
||
},
|
||
staves: vec![first_staff, second_staff],
|
||
kind: Default::default(),
|
||
style: Default::default(),
|
||
});
|
||
|
||
let logical = to_logical(&score);
|
||
let spanning = logical
|
||
.cross_region
|
||
.iter()
|
||
.find(|object| object.provenance.source == TypedObjectId::Spanner(id))
|
||
.expect("cross-region spanner must not be assigned to region zero");
|
||
assert_eq!(spanning.regions, vec![first, second]);
|
||
assert_eq!(spanning.staff, None);
|
||
}
|
||
|
||
#[test]
|
||
fn same_region_tie_is_attached_to_its_real_staff() {
|
||
let score = valid_score_rich(6);
|
||
let tie = score.cross_cutting.ties[0].id;
|
||
let expected_staff = score.canvas.regions[0].staff_extent.staves[0];
|
||
let logical = to_logical(&score);
|
||
let object = logical.regions[0]
|
||
.objects
|
||
.iter()
|
||
.find(|object| object.provenance().source == TypedObjectId::Tie(tie))
|
||
.expect("tie must be in its events' region");
|
||
assert_eq!(object.staff(), Some(expected_staff));
|
||
}
|
||
|
||
// --- Repeat projection (schema-major-2 E1) ------------------------------
|
||
|
||
#[test]
|
||
fn repeat_placements_resolve_honestly() {
|
||
use epiphany_core::{EventId, Measure, MeasureId, MeasurePosition, RegionId};
|
||
|
||
let mut score = valid_score_rich(31);
|
||
let region_a = score.canvas.regions[0].id;
|
||
let replica = score.identity.replica_id;
|
||
// Append a second measure so the generator's measure is not the last.
|
||
let m1: MeasureId = score.identity.mint();
|
||
let instance = &mut score.canvas.regions[0]
|
||
.content
|
||
.staff_instances_mut()
|
||
.expect("region A is staff-based")[0];
|
||
let m0 = instance.measures[0].id;
|
||
instance.measures.push(Measure {
|
||
id: m1,
|
||
start: TimeAnchor::Region {
|
||
id: region_a,
|
||
edge: RegionEdge::Start,
|
||
offset: AnchorOffset::Musical(duration(1, 1)),
|
||
},
|
||
time_signature: None,
|
||
explicit_number: None,
|
||
number_visibility: Default::default(),
|
||
});
|
||
|
||
// A zero-offset end-edge anchor on an existing region closes on the
|
||
// region-final column, whose *time* a metric region cannot state.
|
||
assert_eq!(
|
||
repeat_placement(
|
||
&score,
|
||
&TimeAnchor::Region {
|
||
id: region_a,
|
||
edge: RegionEdge::End,
|
||
offset: AnchorOffset::Zero,
|
||
}
|
||
),
|
||
RepeatPlacement::RegionEnd
|
||
);
|
||
// Zero-ness is a *value* judgement: a Musical(0) offset is the same
|
||
// anchor and must earn the same verdict as the Zero variant.
|
||
assert_eq!(
|
||
repeat_placement(
|
||
&score,
|
||
&TimeAnchor::Region {
|
||
id: region_a,
|
||
edge: RegionEdge::End,
|
||
offset: AnchorOffset::Musical(MusicalDuration::zero()),
|
||
}
|
||
),
|
||
RepeatPlacement::RegionEnd
|
||
);
|
||
// A dangling region target never binds some other region's close.
|
||
assert_eq!(
|
||
repeat_placement(
|
||
&score,
|
||
&TimeAnchor::Region {
|
||
id: RegionId::new(replica, 4_242_424),
|
||
edge: RegionEdge::End,
|
||
offset: AnchorOffset::Zero,
|
||
}
|
||
),
|
||
RepeatPlacement::Unresolved
|
||
);
|
||
// The last measure's end closes on the region; an earlier measure's
|
||
// end resolves to the next measure's start time.
|
||
let measure_end = |id: MeasureId| TimeAnchor::Measure {
|
||
id,
|
||
position: MeasurePosition::End,
|
||
offset: AnchorOffset::Zero,
|
||
};
|
||
assert_eq!(
|
||
repeat_placement(&score, &measure_end(m1)),
|
||
RepeatPlacement::RegionEnd
|
||
);
|
||
assert_eq!(
|
||
repeat_placement(
|
||
&score,
|
||
&TimeAnchor::Measure {
|
||
id: m1,
|
||
position: MeasurePosition::End,
|
||
offset: AnchorOffset::Musical(MusicalDuration::zero()),
|
||
}
|
||
),
|
||
RepeatPlacement::RegionEnd
|
||
);
|
||
assert_eq!(
|
||
repeat_placement(&score, &measure_end(m0)),
|
||
RepeatPlacement::At(TimePoint::Musical(position(1, 1)))
|
||
);
|
||
// A dangling event target is honestly unresolved — never the origin
|
||
// fallback, which would draw a repeat sign at a false position.
|
||
assert_eq!(
|
||
repeat_placement(
|
||
&score,
|
||
&TimeAnchor::Event {
|
||
id: EventId::new(replica, 9_999_999),
|
||
offset: AnchorOffset::Zero,
|
||
}
|
||
),
|
||
RepeatPlacement::Unresolved
|
||
);
|
||
// A bare wall-clock boundary references no graph object, so nothing
|
||
// pins it to a region — it draws no ink rather than landing wherever
|
||
// the time happens to sort among a metric region's columns.
|
||
assert_eq!(
|
||
repeat_placement(
|
||
&score,
|
||
&TimeAnchor::WallClock {
|
||
time: WallClockTime(7),
|
||
}
|
||
),
|
||
RepeatPlacement::Unresolved
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn repeats_project_content_and_volta_anchors_decide_region_membership() {
|
||
use epiphany_core::{RepeatKind, RepeatStructure, RepeatStructureId, Volta};
|
||
|
||
let mut score = valid_score_rich(32);
|
||
let region_a = score.canvas.regions[0].id;
|
||
let region_b = score.canvas.regions[1].id;
|
||
let edge = |id, edge| TimeAnchor::Region {
|
||
id,
|
||
edge,
|
||
offset: AnchorOffset::Zero,
|
||
};
|
||
// r1: a volta repeat anchored wholly inside region A — it lands there
|
||
// carrying resolved content.
|
||
let r1: RepeatStructureId = score.identity.mint();
|
||
score.cross_cutting.repeats.push(RepeatStructure {
|
||
id: r1,
|
||
start: edge(region_a, RegionEdge::Start),
|
||
end: edge(region_a, RegionEdge::End),
|
||
kind: RepeatKind::Volta,
|
||
voltas: vec![Volta {
|
||
endings: vec![1, 2],
|
||
start: edge(region_a, RegionEdge::Start),
|
||
end: edge(region_a, RegionEdge::End),
|
||
}],
|
||
});
|
||
// r2: a DalSegno whose segno points into region B — the jump target is
|
||
// real membership evidence, so the repeat spans regions (and the
|
||
// cross-region path carries no engraving content).
|
||
let r2: RepeatStructureId = score.identity.mint();
|
||
score.cross_cutting.repeats.push(RepeatStructure {
|
||
id: r2,
|
||
start: edge(region_a, RegionEdge::Start),
|
||
end: edge(region_a, RegionEdge::End),
|
||
kind: RepeatKind::DalSegno {
|
||
segno: edge(region_b, RegionEdge::Start),
|
||
end_target: edge(region_a, RegionEdge::Start),
|
||
},
|
||
voltas: Vec::new(),
|
||
});
|
||
|
||
let ir = to_logical(&score);
|
||
let object = ir.regions[0]
|
||
.objects
|
||
.iter()
|
||
.find(|object| object.provenance().source == TypedObjectId::RepeatStructure(r1))
|
||
.expect("the in-region repeat lands in region A");
|
||
let LayoutContent::Repeat(content) = object.content() else {
|
||
panic!("the repeat carries resolved engraving content");
|
||
};
|
||
assert!(content.barlines, "a Volta-kind repeat draws barlines");
|
||
assert_eq!(
|
||
content.start,
|
||
RepeatPlacement::At(TimePoint::Musical(MusicalPosition::origin()))
|
||
);
|
||
assert_eq!(content.end, RepeatPlacement::RegionEnd);
|
||
assert_eq!(content.voltas.len(), 1);
|
||
assert_eq!(content.voltas[0].endings, vec![1, 2]);
|
||
|
||
let spanning = ir
|
||
.cross_region
|
||
.iter()
|
||
.find(|object| object.provenance.source == TypedObjectId::RepeatStructure(r2))
|
||
.expect("the segno target pulls the repeat across regions");
|
||
assert_eq!(spanning.regions, vec![region_a, region_b]);
|
||
assert!(
|
||
ir.regions.iter().all(|region| {
|
||
region
|
||
.objects
|
||
.iter()
|
||
.all(|o| o.provenance().source != TypedObjectId::RepeatStructure(r2))
|
||
}),
|
||
"a cross-region repeat is not also placed in a region"
|
||
);
|
||
}
|
||
}
|