diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index 706f9a8..43faf85 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -403,3 +403,28 @@ schema-major-1 track's Phase F (2026-07-06; core spec schema-major-1 tranche). Satisfaction is a predicate on the output layout, not the solver's spring state; casting-off evaluates the declared hard break constraints as part of its tier claim. + +## ENGRAVER_VERSION 3 → 4: repeat barlines + volta brackets (E1, 2026-07-07) + +The layout pipeline now draws repeat signs and volta brackets (layout-ir E1 +decision), so a repeat-bearing score's baked geometry differs from version 3's +invisible traced anchors — a version bump per the constant's own rule. +Repeat-free scores are byte-identical (the existing `ten_measure` / +`valid_score_rich` render goldens passed unchanged; only the new +`ten_measure_with_repeats` goldens were added). Casting-off changes: + +- Barline-column classification routes through layout-ir's + `is_barline_glyph` instead of the `"barline"` name prefix, **and now + requires a directly-manifested `Measure` source** (no synthesis): a morphed + repeat sign remains a break candidate and its measure keeps its record, + while a repeat-synthesized standalone sign (a mid-measure boundary, a + region edge without a final barline) and the `repeatDots` pair classify + nothing — the casting contract breaks systems at measure boundaries, and a + phantom candidate could tear off a degenerate lone-sign trailing system or + split a measure. Locked by + `repeat_signs_keep_measure_records_honest_and_raise_their_system`, and the + criterion-6 round-trip now runs the repeat fixture through the real + Engraver. +- Volta bracket strokes raise their system's extent, so vertical stacking + and page overflow account for them with no engraver change (system height + is computed from every member box and stroke). diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs index afdd4b6..367ef47 100644 --- a/crates/epiphany-engrave/src/casting.rs +++ b/crates/epiphany-engrave/src/casting.rs @@ -79,11 +79,11 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{StaffId, TypedObjectId}; use epiphany_layout_ir::{ - continuation_instance_key, is_rigid_width_stroke, synthesized_layout_id, BreakClass, BreakKind, - ConstrainedLayoutIR, DecisionSource, EngravingDecision, EngravingDecisionKind, - EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId, Margins, Point, - Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, ResolvedSystem, - Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, + continuation_instance_key, is_barline_glyph, is_rigid_width_stroke, synthesized_layout_id, + BreakClass, BreakKind, ConstrainedLayoutIR, DecisionSource, EngravingDecision, + EngravingDecisionKind, EngravingOverrideId, GlyphObjectId, LayoutConstraint, LayoutObjectId, + Margins, Point, Provenance, Rect, ResolvedGlyph, ResolvedMeasure, ResolvedPage, ResolvedStaff, + ResolvedSystem, Size2D, SpringSlotId, StaffSpace, Stroke, SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId, VerticalBand, VerticalBandId, }; @@ -365,14 +365,22 @@ pub(crate) fn cast_off( entry.lo = entry.lo.min(lo); entry.hi = entry.hi.max(hi); entry.members.push(i); - if name.starts_with("barline") { + // Barline classification by the engraver's own name vocabulary (which + // includes the composite repeat signs a repeat boundary morphs a + // measure barline into) — but only for a **directly-manifested measure + // barline**: the casting contract breaks systems at measure + // boundaries, so a repeat-synthesized standalone sign (a mid-measure + // boundary, a region edge without a final barline) must not become a + // phantom break candidate that could tear off a degenerate lone-sign + // trailing system or split a measure. + if is_barline_glyph(name) + && glyph.provenance.synthesis.is_none() + && matches!(glyph.provenance.source, TypedObjectId::Measure(_)) + { entry.barline = true; if name == "barlineFinal" { entry.final_barline = true; - } else if entry.measure_barline.is_none() - && glyph.provenance.synthesis.is_none() - && matches!(glyph.provenance.source, TypedObjectId::Measure(_)) - { + } else if entry.measure_barline.is_none() { entry.measure_barline = Some(i); } } @@ -1448,6 +1456,54 @@ mod tests { ); } + #[test] + fn repeat_signs_keep_measure_records_honest_and_raise_their_system() { + use crate::Engraver; + use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig}; + // The repeat fixture draws morphed repeat barlines, a standalone sign, + // the final-barline dot pair, and volta brackets. None of that may + // mint a phantom measure record (a standalone sign and the dot pair + // are repeat-synthesized, not measure barlines) or lose one (a morphed + // barline still marks its measure): both fixtures cast off to the same + // nine records — one per measure-*start* barline column; the final + // measure's barline closes the region and yields none, by convention. + let solve = |score| { + Engraver::default().solve( + &to_constrained(&to_logical(&score)), + &SolverConfig::default(), + ) + }; + let plain = solve(epiphany_testkit::fixtures::ten_measure_single_staff( + 0x000A_11CE, + )); + let repeats = solve(epiphany_testkit::fixtures::ten_measure_with_repeats( + 0x000A_11CE, + )); + let measure_count = |report: &crate::SolveReport| -> usize { + report + .layout + .pages + .iter() + .flat_map(|page| &page.systems) + .map(|system| system.measures.len()) + .sum() + }; + assert_eq!(measure_count(&plain), 9); + assert_eq!(measure_count(&repeats), 9); + // The volta brackets sit above the staff, so the system carrying them + // is taller than any repeat-free system. + let max_height = |report: &crate::SolveReport| -> f32 { + report + .layout + .pages + .iter() + .flat_map(|page| &page.systems) + .map(|system| system.bounding_box.size.height.0) + .fold(0.0, f32::max) + }; + assert!(max_height(&repeats) > max_height(&plain)); + } + #[test] fn the_widow_rebalance_evens_the_final_system() { use crate::Engraver; diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 74bb6aa..f644975 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -118,11 +118,13 @@ pub struct Engraver { /// The implementation version of this solver (Chapter 9: within a fixed version, /// identical input produces identical output). Distinct from the stub's `0`; /// bumped to `2` when the casting-off pass landed (the resolved geometry of a -/// wrapping score differs from version `1`'s single endless system), and to `3` +/// wrapping score differs from version `1`'s single endless system), to `3` /// when casting-off gained its widow-rebalance phase (a wrapping score's system /// breaks — and so its baked geometry — differ again from version `2`'s pure -/// greedy first-fit). -pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(3); +/// greedy first-fit), and to `4` when repeat barlines and volta brackets landed +/// (a repeat-bearing score's baked geometry differs from version `3`'s +/// invisible traced anchors; repeat-free scores are unchanged). +pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(4); impl Engraver { /// An engraver casting off against the given page geometry. @@ -1662,14 +1664,17 @@ mod tests { fn criterion_six_round_trips_through_the_engravers_respacing() { use epiphany_core::generators::valid_score; use epiphany_layout_ir::{round_trip_with, SolveStatus}; - use epiphany_testkit::fixtures::ten_measure_single_staff; + use epiphany_testkit::fixtures::{ten_measure_single_staff, ten_measure_with_repeats}; for seed in 0..32u64 { // Mirror the criterion-6 hand-off gate's own fixtures — the 10-measure - // single staff (measures + barlines) and the rich score (cross-cutting - // tuplet/tie/spanner/marker) — and keep `valid_score` for added breadth. + // single staff (measures + barlines), its repeat-bearing sibling + // (morphed/standalone repeat signs, dot pair, volta brackets — all + // re-spaced and cast off), and the rich score (cross-cutting + // tuplet/tie/spanner/marker) — and keep `valid_score` for breadth. let scores = [ ten_measure_single_staff(seed), + ten_measure_with_repeats(seed), valid_score(seed), valid_score_rich(seed), ]; diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index debb403..2b4558a 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -480,3 +480,79 @@ sanctioned (`req:solver:subconformant-report`). **I6** the implemented emission set (successive-notehead no-collision chains + per-glyph containment + user-break constraints) is the normative Minimal-tier floor (`req:layoutir:constraint-floor`). + +## Repeat barlines and volta brackets (schema major 2, E1, 2026-07-07) + +The first repeat-structure ink (Chapter 5 `RepeatStructure` / `RepeatKind` / +`Volta`, ratified by the major-2 Phase A). Rendering is spec-unconstrained +(Ch7's `BarLine` payload is undefined and voltas have no layout variant), so +these are E1 implementation decisions for the Phase-F ratification pass: + +- **Kind → ink mapping.** `SimpleRepeat` and `Volta` draw repeat barlines at + their boundaries; `DaCapo`/`DalSegno` draw **no Minimal-tier ink** (segno / + coda / instruction marks need a text primitive — a later tranche) but keep + their traced anchors. Volta brackets draw for the `voltas` list of **any** + kind. +- **Morph, standalone, or dots.** A boundary whose column carries a measure's + own barline **morphs** that barline into the precomposed SMuFL sign + (`repeatLeft` / `repeatRight` / `repeatRightLeft` when an end meets a + start) — a *name* change only: the measure's exact provenance is preserved + verbatim because the round-trip provenance floor compares it exactly; + repeat-edit invalidation is carried by the `ScoreVersion` (v0 relayouts + wholesale), and an incremental tranche would add the dependency at the + logical stage where dependencies are established. A boundary with no + coinciding measure barline stands alone as a repeat-synthesized sign at its + own barline-role column (`REPEAT_BARLINE_SYNTHESIS`; one per (column, + staff); coinciding structures merge into one sign whose synthesis owner is + the smallest `(structure id, boundary site)` — a **semantic** instance key, + `(site << 32) | staff index`, stable under unrelated edits where a + positional column rank would re-derive). The **region-closing column**: an + end repeat there adds the `repeatDots` pair beside a staff's final barline + (the final barline never morphs, keeping the casting-off solver's + final-barline classification truthful), or draws the full end sign on a + staff whose run continues (no final barline there); a *start* repeat at the + region close draws nothing on any staff — a sign after the close would + misstate the structure. +- **Source-geometry clearance.** An end-facing sign's ink reaches ~1.1–1.3 + staff spaces left of its column (its heavy line right-aligns to the plain + barline's span), so the mark's column reserves that reach through the + accidental-overhang mechanism and the source layout stays collision-free. + A morphed measure's time-signature digits shift right by the sign's right + extension (`repeatLeft`/`repeatRightLeft` are wider than the barline they + replace); both adjustments are zero for the plain barlines, so repeat-free + geometry is untouched. +- **Honest placement.** Repeat boundaries resolve via `RepeatPlacement` at + projection time (`to_constrained` has no `Score`): `At(time)`, + `RegionEnd` (zero-offset anchors to an existing region's end edge or to the + end of an instance's last measure — the *column* is knowable where the + *time* is not; zero-ness is judged **by value**, so a `Musical(0)` offset + earns the same verdict as the `Zero` variant), or `Unresolved`, which draws + **no ink** — unlike `resolve_time_anchor`'s origin fallback, a repeat sign + at a false position would misstate the musical structure. A bare + **wall-clock boundary is `Unresolved`**: it references no graph object, so + nothing pins it to the region it would draw in — the sign would land + wherever its time happens to *sort* among that region's columns (repeat ink + for wall-clock-synchronized material is a later tranche; wall-clock + `TimePoint`s reached *through* an object anchor place normally). + Cross-region repeats keep the traced anchor only (content is dropped on the + cross-region path — a documented Minimal boundary until repeat ink learns + to split). Repeat dependencies now come from + `RepeatStructure::anchor_sites()` (THE single site-set walk), so volta + spans and jump targets are real invalidation and region-membership + evidence. +- **Volta brackets.** Three strokes above the *top* staff (line at + `VOLTA_Y = 6.5` staff spaces, two descending hooks) plus the ending numbers + as `timeSig0..9` digit glyphs (the Minimal tier has no text primitive), + all synthesized under `VOLTA_SYNTHESIS`, endings drawn verbatim in authored + order. A reversed / zero-width / unresolvable span draws no bracket + (advisory volta well-formedness is the authoring layer's jurisdiction). + Bracket strokes are ordinary re-spaceable strokes, so the engraver's + system-splitting (`StrokeFate::Split`) applies unchanged. +- **Glyphs.** The precomposed Bravura signs over hand-compositing + heavy/thin/dot primitives; metrics extracted from the same SHA-pinned + `bravura-1.392` as the rest of the table. The heavy line is aligned to the + plain barline's span by a box approximation (`repeat_sign_x`: start signs + left-aligned, end signs right-aligned, the combined sign centred). + `is_barline_glyph` is exported so the casting-off solver classifies + measure-boundary columns from this crate's name vocabulary instead of a + string prefix. diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 5f27c7d..baec42e 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -14,7 +14,8 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ Clef, EventId, KeySignature, MeasureId, MeasurePosition, MusicalDuration, NoteValue, PitchId, - PitchSpelling, SpellingNominal, StaffId, TimeAnchor, TypedObjectId, WallClockTime, + PitchSpelling, RepeatStructureId, SpellingNominal, StaffId, TimeAnchor, TypedObjectId, + WallClockTime, }; use epiphany_determinism::{DomainTag, Preimage}; @@ -26,7 +27,7 @@ use crate::engraving::{EngravingDecision, OverrideKind, OverridePriority, Overri use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference}; use crate::logical::{ apply_offset, BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature, - ScoreVersion, StaffContent, + RepeatContent, RepeatPlacement, ScoreVersion, StaffContent, }; use crate::provenance::{ manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind, @@ -524,6 +525,15 @@ const KEY_SIG_START: f32 = 2.7; // x where a key signature begins (just after th 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 /// The horizontal half-reach of an emitted `PositionWithin` region, in staff /// spaces. The constrained stage performs no casting-off, so a region imposes @@ -576,6 +586,26 @@ const KEY_SIG_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x4B45_5953_4 /// 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 @@ -600,6 +630,14 @@ const TIME_SIG_SYNTHESIS: SynthesisRegistryId = SynthesisRegistryId(0x54_494D_45 /// model does not contain. 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") } @@ -698,6 +736,12 @@ pub fn try_to_constrained( // 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(); @@ -792,13 +836,32 @@ pub fn try_to_constrained( event_rests.insert(eid, segs); } (TypedObjectId::Measure(_), LayoutContent::Measure(measure)) => { - keys.insert(measure_column(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 @@ -821,6 +884,58 @@ pub fn try_to_constrained( } } + // 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 @@ -1142,17 +1257,30 @@ pub fn try_to_constrained( 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 { - "barlineSingle" + 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(info.x, yo); + 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 @@ -1161,7 +1289,9 @@ pub fn try_to_constrained( // representative subset). if let Some(LayoutContent::Measure(measure)) = content { if let Some(time_signature) = measure.time_signature { - let center_x = info.x + TIME_SIG_X; + // 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). @@ -1170,7 +1300,7 @@ pub fn try_to_constrained( (1u8, time_signature.denominator, yo + 1.0), ]; for (role, value, baseline_y) in lines { - let digits = digits_of(value); + let digits = digits_of(u32::from(value)); let count = digits.len() as f32; for (i, digit) in digits.iter().enumerate() { let x = @@ -1194,13 +1324,152 @@ pub fn try_to_constrained( } } } - // Region, Voice, GraphicObject, and every cross-cutting structure - // (ties, slurs, beams, tuplets, spanners, markers, …) have no - // Minimal-tier glyph; a zero-extent traced anchor keeps them. + 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))); + 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, + )); + 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, + )); + } + 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; + } + } + } + // Region, Voice, GraphicObject, and every other cross-cutting + // structure (ties, slurs, 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))), } } + // 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, @@ -1696,6 +1965,93 @@ fn measure_column(measure: &crate::logical::MeasureContent) -> ColumnKey { } } +/// 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 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 @@ -1723,8 +2079,9 @@ fn key_accidentals_for(content: &StaffContent) -> Vec { } } -/// The decimal digits of a time-signature number, most significant first. -fn digits_of(value: u16) -> Vec { +/// 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]; } @@ -3312,4 +3669,404 @@ mod tests { 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" + ); + } + } } diff --git a/crates/epiphany-layout-ir/src/glyph.rs b/crates/epiphany-layout-ir/src/glyph.rs index 41ee3c9..0f4b75a 100644 --- a/crates/epiphany-layout-ir/src/glyph.rs +++ b/crates/epiphany-layout-ir/src/glyph.rs @@ -247,6 +247,14 @@ pub const BRAVURA_METRICS: &[GlyphMetric] = &[ // spaces) *up* from it, spanning the staff when anchored at the bottom line. GlyphMetric::new("barlineSingle", 147, [0, 0, 148, 4096]), GlyphMetric::new("barlineFinal", 938, [0, 0, 934, 4096]), + // Repeat signs: composite barline glyphs (heavy/thin lines plus dots), + // staff-spanning like the barlines above; `repeatDots` is the bare dot + // pair drawn beside a barline (its box sits in the middle two staff + // spaces when anchored at the bottom line). + GlyphMetric::new("repeatLeft", 1507, [0, 0, 1500, 4096]), + GlyphMetric::new("repeatRight", 1503, [4, 0, 1504, 4096]), + GlyphMetric::new("repeatRightLeft", 2490, [4, 0, 2491, 4096]), + GlyphMetric::new("repeatDots", 410, [0, 1302, 410, 2745]), GlyphMetric::new("dynamicForte", 1491, [-578, -623, 1491, 1819]), GlyphMetric::new("dynamicPiano", 1495, [-365, -582, 1500, 1123]), ]; @@ -261,6 +269,16 @@ pub fn all_available<'a>(names: impl IntoIterator) -> bool { names.into_iter().all(|n| metrics(n).is_some()) } +/// Whether a glyph name draws a **measure-boundary barline**: the plain and +/// final barlines plus the composite repeat signs (which replace a plain +/// barline at a repeat boundary). The casting-off solver classifies +/// measure-boundary columns with this instead of hard-coding the engraver's +/// name choices. `repeatDots` is decoration drawn *beside* a barline, not a +/// barline itself, so it is excluded. +pub fn is_barline_glyph(name: &str) -> bool { + name.starts_with("barline") || matches!(name, "repeatLeft" | "repeatRight" | "repeatRightLeft") +} + /// A glyph's rendering data (Chapter 7 §"Glyph Catalog Interface": /// `GlyphRenderData`). The full outline/bitmap vocabulary (`PathCommand`, /// `GlyphBitmap`) belongs to the out-of-core renderer; v0 carries an opaque @@ -469,6 +487,25 @@ mod tests { assert_eq!(catalog.identity(&["runtimeGlyph"]).metrics_hash, [7; 32]); } + #[test] + fn barline_classification_covers_the_repeat_signs_but_not_the_dots() { + for name in [ + "barlineSingle", + "barlineFinal", + "repeatLeft", + "repeatRight", + "repeatRightLeft", + ] { + assert!( + is_barline_glyph(name), + "{name} is a measure-boundary barline" + ); + } + for name in ["repeatDots", "noteheadBlack", "timeSig2", "gClef"] { + assert!(!is_barline_glyph(name), "{name} is not a barline"); + } + } + #[test] fn every_bundled_name_is_unique_and_resolvable() { let mut seen = BTreeSet::new(); diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index 6e6e047..ebd6138 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -108,9 +108,10 @@ pub use engraving::{ OverrideKind, OverrideOrigin, OverridePriority, OverrideTarget, PluginId, Timestamp, }; pub use glyph::{ - all_available, bravura_catalog_identity, metrics, metrics_hash_for, BravuraCatalog, FontId, - GlyphAnchor, GlyphBitmap, GlyphCatalog, GlyphCatalogIdentity, GlyphMetric, GlyphReference, - GlyphRenderData, PathCommand, SemVer, SmuflVersion, BRAVURA_METRICS, BRAVURA_VERSION, + all_available, bravura_catalog_identity, is_barline_glyph, metrics, metrics_hash_for, + BravuraCatalog, FontId, GlyphAnchor, GlyphBitmap, GlyphCatalog, GlyphCatalogIdentity, + GlyphMetric, GlyphReference, GlyphRenderData, PathCommand, SemVer, SmuflVersion, + BRAVURA_METRICS, BRAVURA_VERSION, }; pub use hittest::{HitRegion, HitShape, HitTestMap, PrimitiveRef}; pub use logical::{ @@ -118,10 +119,10 @@ pub use logical::{ CompositeLayoutObject, CrossRegionObject, CueLayout, GraphicLayout, GroupLayout, KeySignatureLayout, LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR, MarkerLayout, MeasureContent, MultimeasureRestLayout, NoteContent, NoteLayout, - NotePitch, PlacedClef, PlacedComponent, PlacedKeySignature, RestContent, RestLayout, - ScoreVersion, SlurLayout, SpannerLayout, StaffContent, StaffLayout, TextLayout, TieLayout, - TimeSignatureContent, TimeSignatureDisplayLayout, TrajectoryLayout, TupletDisplayLayout, - VerticalExtent, + NotePitch, PlacedClef, PlacedComponent, PlacedKeySignature, RepeatContent, RepeatPlacement, + RestContent, RestLayout, ScoreVersion, SlurLayout, SpannerLayout, StaffContent, StaffLayout, + TextLayout, TieLayout, TimeSignatureContent, TimeSignatureDisplayLayout, TrajectoryLayout, + TupletDisplayLayout, VerticalExtent, VoltaContent, }; pub use provenance::{ continuation_instance_key, manifestation_layout_id, stable_layout_id, synthesized_layout_id, diff --git a/crates/epiphany-layout-ir/src/logical.rs b/crates/epiphany-layout-ir/src/logical.rs index d95db76..573c750 100644 --- a/crates/epiphany-layout-ir/src/logical.rs +++ b/crates/epiphany-layout-ir/src/logical.rs @@ -33,12 +33,14 @@ use crate::spatial::Transform2D; use crate::time_axis::{time_axis_of, TimeAxisModel, TimePoint}; /// The engraving content of a layout object beyond its provenance and staff — -/// the note value, spelled pitches, clef, key, or measure data the constrained -/// pass needs to choose glyphs and compute staff positions (Chapter 7 §"Engraving -/// Decisions": the decisions are recorded in the IR). Structural objects (staves, -/// voices, the per-pitch back-references, cross-cutting structures) carry -/// [`LayoutContent::Structural`]. This payload is *authoritative* for engraving; -/// the [`LayoutObject`] variant remains the structural classification. +/// the note value, spelled pitches, clef, key, measure, or repeat data the +/// constrained pass needs to choose glyphs and compute staff positions +/// (Chapter 7 §"Engraving Decisions": the decisions are recorded in the IR). +/// Structural objects (staves, voices, the per-pitch back-references, and the +/// cross-cutting structures other than repeats) carry +/// [`LayoutContent::Structural`]. This payload is *authoritative* for +/// engraving; the [`LayoutObject`] variant remains the structural +/// classification. #[derive(Clone, PartialEq, Eq, Debug, Default)] pub enum LayoutContent { /// No engraving content beyond provenance/staff. @@ -55,6 +57,10 @@ pub enum LayoutContent { /// A measure: whether it ends the staff (a final barline) and the time /// signature in force, when this measure introduces one. Measure(MeasureContent), + /// A repeat structure: whether its kind draws repeat barlines, where its + /// boundaries land, and its volta brackets — resolved to layout placements + /// at projection time (the constrained pass has no score access). + Repeat(RepeatContent), } /// The clef and key-signature sequences in force across a staff instance, @@ -153,6 +159,48 @@ pub struct TimeSignatureContent { pub denominator: u16, } +/// A repeat structure's engraving content (Chapter 5 §"Cross-Cutting +/// Structures": `RepeatStructure`), with every boundary resolved to a +/// [`RepeatPlacement`] so the constrained pass can place ink without going +/// back to the score graph. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RepeatContent { + /// Whether the structure's kind draws repeat barlines at its boundaries + /// (`SimpleRepeat` and `Volta`). The jump kinds (`DaCapo`/`DalSegno`) draw + /// no Minimal-tier ink — their marks (segno, coda, instruction text) need + /// a text primitive and belong to a later tranche. + pub barlines: bool, + pub start: RepeatPlacement, + pub end: RepeatPlacement, + /// The structure's volta brackets, in authored order. + pub voltas: Vec, +} + +/// One volta bracket: its pass numbers and its resolved span. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct VoltaContent { + /// The pass numbers this ending plays on (1-based; authored order kept). + pub endings: Vec, + pub start: RepeatPlacement, + pub end: RepeatPlacement, +} + +/// Where a repeat boundary lands on its region's spacing axis. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum RepeatPlacement { + /// At the spacing column of this resolved time. + At(TimePoint), + /// At the region's closing column (the final-barline position) — a + /// zero-offset anchor to the region's end edge or to the end of its last + /// measure, whose *time* the Minimal slice cannot resolve but whose + /// *column* is exactly the region-closing one. + RegionEnd, + /// Not resolvable in the Minimal slice (dangling target, unknown metric + /// region end, clock-mismatched offset). The boundary draws no ink; the + /// structure keeps its traced anchor. + Unresolved, +} + /// A structural layout object before spacing (Chapter 7 §"Layout Objects"). It /// carries its [`Provenance`], the staff it belongs to (used to route it to the /// correct vertical band), and its [`LayoutContent`] (the engraving payload the @@ -596,6 +644,19 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { if !seen.insert(provenance.stable_id) { continue; } + // A repeat structure carries its resolved engraving content (barline + // verdict + placements). Every other cross-cutting object is + // structural in this tier. + let content = match src { + TypedObjectId::RepeatStructure(id) => score + .cross_cutting + .repeats + .iter() + .find(|rp| rp.id == id) + .map(|rp| repeat_content(score, rp)) + .unwrap_or_default(), + _ => LayoutContent::Structural, + }; let mut anchored_regions = Vec::new(); let mut anchored_staves = BTreeSet::new(); for region in ®ions { @@ -628,7 +689,9 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { .expect("anchored region was collected from this vector"); region .objects - .push(LayoutObject::from_projection(provenance, staff)); + .push(LayoutObject::from_projection_with_content( + provenance, staff, content, + )); } [] => { // Wall-clock-only annotations have no graph anchor from which @@ -636,9 +699,15 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { if let Some(first) = regions.first_mut() { first .objects - .push(LayoutObject::from_projection(provenance, staff)); + .push(LayoutObject::from_projection_with_content( + provenance, staff, content, + )); } } + // A multi-region spanning object keeps no engraving content: the + // cross-region path places a single traced anchor at its first + // region (a Minimal-slice boundary — repeat ink across region + // boundaries belongs to a later tranche). _ => cross_region.push(CrossRegionObject { provenance, regions: anchored_regions, @@ -934,6 +1003,95 @@ fn origin_time() -> TimePoint { TimePoint::Musical(MusicalPosition::origin()) } +/// A repeat structure's engraving content: its kind's barline verdict plus +/// every boundary (structure and volta) resolved to a [`RepeatPlacement`]. +fn repeat_content(score: &Score, rp: &epiphany_core::RepeatStructure) -> LayoutContent { + use epiphany_core::RepeatKind; + LayoutContent::Repeat(RepeatContent { + barlines: matches!(rp.kind, RepeatKind::SimpleRepeat { .. } | RepeatKind::Volta), + start: repeat_placement(score, &rp.start), + end: repeat_placement(score, &rp.end), + voltas: rp + .voltas + .iter() + .map(|volta| VoltaContent { + endings: volta.endings.clone(), + start: repeat_placement(score, &volta.start), + end: repeat_placement(score, &volta.end), + }) + .collect(), + }) +} + +/// Resolves a repeat boundary anchor to a [`RepeatPlacement`]. Unlike +/// [`resolve_time_anchor`], failure is **honest** (`Unresolved` draws no ink) +/// rather than falling back to the origin — a repeat sign at a false position +/// would misstate the musical structure. Two region-closing shapes resolve to +/// [`RepeatPlacement::RegionEnd`] by *column* even though their *time* is +/// unknowable in a metric region: a zero-offset anchor to an existing region's +/// end edge, and a zero-offset anchor to the end of a staff instance's last +/// measure. Offsets are tested for zero-ness **by value** (`Zero`, +/// `Musical(0)`, `WallClock(0)` all qualify — value-equal decoded anchors must +/// not render differently). +/// +/// A bare **wall-clock** boundary is `Unresolved`: it references no graph +/// object, so nothing ties it to the region it would draw in — the sign would +/// land wherever the wall-clock time happens to *sort* among that region's +/// columns (after every musical column, in a metric region), a false position. +/// Repeat ink for wall-clock-synchronized material is a later tranche. +/// (Wall-clock `TimePoint`s resolved *through* an event/measure/region anchor +/// are fine — the referenced object pins the region and its clock.) +fn repeat_placement(score: &Score, anchor: &TimeAnchor) -> RepeatPlacement { + const DEPTH: u8 = 16; + match anchor { + TimeAnchor::Region { + id, + edge: RegionEdge::End, + offset, + } if offset_is_zero(offset) + && score.canvas.regions.iter().any(|region| region.id == *id) => + { + RepeatPlacement::RegionEnd + } + TimeAnchor::Measure { + id, + position: MeasurePosition::End, + offset, + } if offset_is_zero(offset) && is_last_measure(score, *id) => RepeatPlacement::RegionEnd, + TimeAnchor::WallClock { .. } => RepeatPlacement::Unresolved, + _ => match resolve_time_anchor_inner(score, anchor, DEPTH) { + Some(time) => RepeatPlacement::At(time), + None => RepeatPlacement::Unresolved, + }, + } +} + +/// Whether an anchor offset is zero **by value**: the `Zero` variant or a +/// zero-magnitude duration of either clock (both are representable and decode +/// verbatim; the placement verdict must not depend on the representation). +fn offset_is_zero(offset: &AnchorOffset) -> bool { + match offset { + AnchorOffset::Zero => true, + AnchorOffset::Musical(duration) => *duration == MusicalDuration::zero(), + AnchorOffset::WallClock(duration) => duration.0 == 0, + } +} + +/// Whether `id` names the **last** measure of the staff instance that owns it +/// (first-match walk, the same discipline as [`measure_anchor_time`]). +fn is_last_measure(score: &Score, id: epiphany_core::MeasureId) -> bool { + for (_, instance) in score.staff_instances() { + if let Some(index) = instance + .measures + .iter() + .position(|measure| measure.id == id) + { + return index + 1 == instance.measures.len(); + } + } + false +} + /// The full notated decomposition of an event (base values, dots, tuplets, ties) /// from the pre-pass; empty when the event has no decomposition (non-metric or /// ineligible) — the constrained pass surfaces that rather than inventing a value. @@ -1086,9 +1244,13 @@ pub(crate) fn cross_cutting_objects(score: &Score) -> Vec<(TypedObjectId, Vec "flag", GlyphClass::TimeSignature => "timeSig", GlyphClass::Barline => "barline", + GlyphClass::Repeat => "repeat", GlyphClass::Dynamic => "dynamic", GlyphClass::AugmentationDot => "augmentationDot", GlyphClass::Other => "other", diff --git a/crates/epiphany-render-svg/tests/acceptance.rs b/crates/epiphany-render-svg/tests/acceptance.rs index b5b54f9..4e916f4 100644 --- a/crates/epiphany-render-svg/tests/acceptance.rs +++ b/crates/epiphany-render-svg/tests/acceptance.rs @@ -37,6 +37,10 @@ fn fixtures() -> Vec<(&'static str, Score)> { "valid_score_rich", epiphany_core::generators::valid_score_rich(0x5EED), ), + ( + "ten_measure_with_repeats", + epiphany_testkit::fixtures::ten_measure_with_repeats(0x000A_11CE), + ), ] } diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt new file mode 100644 index 0000000..fa36f7e --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.snapshot.txt @@ -0,0 +1,16 @@ +fixture=ten_measure_with_repeats solver=engrave +glyph_count=56 +path_count=56 +fallback_rect_count=0 +stroke_count=105 +provenance_count=161 +layer_count=1 +hard_constraint_count=95 +xml_well_formed=true +view_box=[5.4999995 -28.60539 67.03889 23.104813] +class_counts: + barline=7 + clef=1 + notehead=40 + repeat=5 + timeSig=3 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg new file mode 100644 index 0000000..721ed7e --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt new file mode 100644 index 0000000..4408d4b --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.snapshot.txt @@ -0,0 +1,16 @@ +fixture=ten_measure_with_repeats solver=stub +glyph_count=56 +path_count=56 +fallback_rect_count=0 +stroke_count=100 +provenance_count=156 +layer_count=1 +hard_constraint_count=95 +xml_well_formed=true +view_box=[-3.065 -3.632 92.937454 12.212] +class_counts: + barline=7 + clef=1 + notehead=40 + repeat=5 + timeSig=3 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg new file mode 100644 index 0000000..0c9cbbe --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tools/extract_bravura_outlines.py b/crates/epiphany-render-svg/tools/extract_bravura_outlines.py index 7f36635..7ea1b75 100644 --- a/crates/epiphany-render-svg/tools/extract_bravura_outlines.py +++ b/crates/epiphany-render-svg/tools/extract_bravura_outlines.py @@ -37,7 +37,8 @@ NAMES = ["noteheadBlack","noteheadHalf","noteheadWhole","noteheadDoubleWhole", "flag8thUp","flag8thDown","augmentationDot", "timeSig0","timeSig1","timeSig2","timeSig3","timeSig4","timeSig5","timeSig6", "timeSig7","timeSig8","timeSig9","timeSigCommon", -"barlineSingle","barlineFinal","dynamicForte","dynamicPiano"] +"barlineSingle","barlineFinal","dynamicForte","dynamicPiano", +"repeatLeft","repeatRight","repeatRightLeft","repeatDots"] def verify(data, expected, what): actual = hashlib.sha256(data).hexdigest() diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index 4570c42..40ac8e9 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -12,15 +12,15 @@ use epiphany_core::{ AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal, CrossCuttingRegistry, Event, EventArena, EventDuration, EventPosition, IdentifiedPitch, - IdentityContext, Marker, Measure, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, - PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, RegionEdge, RegionTimeModel, - ScalePosition, Score, Spanner, Staff, StaffBasedContent, StaffExtent, StaffInstance, - StaffLineConfiguration, StemConfiguration, Tie, TieClass, TimeAnchor, TimeExtent, - TuningReference, Voice, WallClockTime, + IdentityContext, Marker, Measure, MeasurePosition, MetricTimeModel, MusicalDuration, + MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, + RegionEdge, RegionTimeModel, RepeatKind, RepeatStructure, ScalePosition, Score, Spanner, Staff, + StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, + TieClass, TimeAnchor, TimeExtent, TuningReference, Voice, Volta, WallClockTime, }; use epiphany_core::{ - ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId, ReplicaId, - SlurId, SpannerId, StaffId, StaffInstanceId, TieId, VoiceId, + ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId, + RepeatStructureId, ReplicaId, SlurId, SpannerId, StaffId, StaffInstanceId, TieId, VoiceId, }; use epiphany_determinism::fuzz::SplitMix64; @@ -196,6 +196,67 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { score } +/// [`ten_measure_single_staff`] plus three repeat structures — the +/// repeat-rendering acceptance fixture (schema-major-2 E1). Measure-anchored so +/// every boundary coincides with a barline column, it exercises each visual +/// form at once: a simple repeat over measures 2–4 whose end meets the volta +/// repeat's start (the combined `repeatRightLeft` sign), a `Volta`-kind repeat +/// over measures 5–7 with a first ending over measure 7 and a "2 3" ending +/// over measure 8 (brackets + ending numerals), and a simple repeat of the +/// last measure closing on the region end (the dot pair beside the final +/// barline). Invariant-clean. +pub fn ten_measure_with_repeats(seed: u64) -> Score { + let mut score = ten_measure_single_staff(seed); + let measures: Vec = score.canvas.regions[0].staff_instances()[0] + .measures + .iter() + .map(|measure| measure.id) + .collect(); + let at_start = |index: usize| TimeAnchor::Measure { + id: measures[index], + position: MeasurePosition::Start, + offset: AnchorOffset::Zero, + }; + + score.cross_cutting.repeats.push(RepeatStructure { + id: score.identity.mint::(), + start: at_start(1), + end: at_start(4), + kind: RepeatKind::SimpleRepeat { count: 2 }, + voltas: Vec::new(), + }); + score.cross_cutting.repeats.push(RepeatStructure { + id: score.identity.mint::(), + start: at_start(4), + end: at_start(7), + kind: RepeatKind::Volta, + voltas: vec![ + Volta { + endings: vec![1], + start: at_start(6), + end: at_start(7), + }, + Volta { + endings: vec![2, 3], + start: at_start(7), + end: at_start(8), + }, + ], + }); + score.cross_cutting.repeats.push(RepeatStructure { + id: score.identity.mint::(), + start: at_start(9), + end: TimeAnchor::Measure { + id: measures[9], + position: MeasurePosition::End, + offset: AnchorOffset::Zero, + }, + kind: RepeatKind::SimpleRepeat { count: 2 }, + voltas: Vec::new(), + }); + score +} + #[cfg(test)] mod tests { use super::*; @@ -215,6 +276,23 @@ mod tests { assert_eq!(s.cross_cutting.chord_symbols.len(), 1); } + #[test] + fn repeat_fixture_is_invariant_clean_and_carries_three_repeats() { + let s = ten_measure_with_repeats(1); + let v = check_invariants(&s); + assert!(v.is_empty(), "repeat fixture has violations: {v:?}"); + assert_eq!(s.cross_cutting.repeats.len(), 3); + // One volta structure with two brackets; the rest simple repeats. + assert_eq!( + s.cross_cutting + .repeats + .iter() + .map(|rp| rp.voltas.len()) + .sum::(), + 2 + ); + } + /// A measure that references a time signature lists it (and its start /// anchor's target) among its invalidation dependencies, so a time-signature /// display change with an unchanged id invalidates the measure and its diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 3d705e0..4267f41 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -208,7 +208,14 @@ pub fn gen_layout_content(rng: &mut Rng) -> LayoutContent { _ => BarlineKind::Final, } } - match rng.below(5) { + fn placement(rng: &mut Rng) -> RepeatPlacement { + match rng.below(3) { + 0 => RepeatPlacement::At(time(rng)), + 1 => RepeatPlacement::RegionEnd, + _ => RepeatPlacement::Unresolved, + } + } + match rng.below(6) { 0 => LayoutContent::Structural, 1 => LayoutContent::Staff(StaffContent { clefs: clefs(rng), @@ -229,7 +236,7 @@ pub fn gen_layout_content(rng: &mut Rng) -> LayoutContent { .boolean() .then(|| epiphany_core::StaffPosition(rng.range(0, 9) as i16)), }), - _ => LayoutContent::Measure(MeasureContent { + 4 => LayoutContent::Measure(MeasureContent { start: time(rng), barline: barline(rng), time_signature: rng.boolean().then(|| TimeSignatureContent { @@ -237,6 +244,18 @@ pub fn gen_layout_content(rng: &mut Rng) -> LayoutContent { denominator: *rng.choose(&[2, 4, 8, 16]), }), }), + _ => LayoutContent::Repeat(RepeatContent { + barlines: rng.boolean(), + start: placement(rng), + end: placement(rng), + voltas: (0..rng.below(3)) + .map(|index| VoltaContent { + endings: vec![index as u32 + 1], + start: placement(rng), + end: placement(rng), + }) + .collect(), + }), } }