//! 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, pub horizontal_slots: Vec, pub glyphs: Vec, /// Non-glyph line primitives (staff lines, stems, barlines, …). pub strokes: Vec, /// Cubic-bézier curve primitives (slurs, …). pub curves: Vec, pub vertical_bands: Vec, pub constraints: Vec, /// 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, pub engraving_decisions: Vec, /// 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, 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, /// 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, pub stretch_factor: f32, pub compress_factor: f32, pub members: Vec, } #[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); #[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 { 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| -> 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 = 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> = BTreeMap::new(); let mut clef_default_of: BTreeMap = 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| -> &[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| -> 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> = BTreeMap::new(); let mut event_stems: BTreeMap> = 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> = 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 = 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 = 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 = 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 = 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, 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> = BTreeMap::new(); for ((staff, key), column) in column_ink { staff_notes.entry(staff).or_default().insert(key, column); } let no_notes: BTreeMap = 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 = 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 = 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 = 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 = BTreeMap::new(); let mut measure_starts: BTreeMap = 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, /// 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 { 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, strokes: &'a mut Vec, curves: &'a mut Vec, diagnostics: &'a mut Vec, column_members: BTreeMap>, region_glyphs: Vec, staff_members: BTreeMap>, margin_members: Vec, staves_in_order: Vec, } 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, 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, 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, measure_starts: &BTreeMap, ) -> Option { 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, /// 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 { 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, ) -> 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, band: VerticalBandId, notes: &BTreeMap, ) -> Option { 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 { 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 { 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 { 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 { 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 + '_ { 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 { 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, 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, pitch: PitchId, diagnostics: &mut Vec, ) -> 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 = 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 = 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 = 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) { let mut score = valid_score_rich(seed); let region_id = score.canvas.regions[0].id; let extra: Vec = (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 { 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) -> 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 = 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()); } }