diff --git a/crates/epiphany-render-svg/tests/acceptance.rs b/crates/epiphany-render-svg/tests/acceptance.rs index 9139953..8b387b3 100644 --- a/crates/epiphany-render-svg/tests/acceptance.rs +++ b/crates/epiphany-render-svg/tests/acceptance.rs @@ -45,6 +45,10 @@ fn fixtures() -> Vec<(&'static str, Score)> { "ten_measure_with_slurs", epiphany_testkit::fixtures::ten_measure_with_slurs(0x000A_11CE), ), + ( + "two_staff_close_content", + epiphany_testkit::fixtures::two_staff_close_content(0x000A_11CE), + ), ] } diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt new file mode 100644 index 0000000..e174ca4 --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt @@ -0,0 +1,15 @@ +fixture=two_staff_close_content solver=engrave +glyph_count=12 +path_count=12 +fallback_rect_count=0 +stroke_count=41 +curve_count=1 +provenance_count=54 +layer_count=1 +hard_constraint_count=18 +xml_well_formed=true +view_box=[5.5 -28.524578 17.379084 23.024] +class_counts: + barline=2 + clef=2 + notehead=8 diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg new file mode 100644 index 0000000..fc8d893 --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.snapshot.txt new file mode 100644 index 0000000..8ee2c48 --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.snapshot.txt @@ -0,0 +1,15 @@ +fixture=two_staff_close_content solver=stub +glyph_count=12 +path_count=12 +fallback_rect_count=0 +stroke_count=41 +curve_count=1 +provenance_count=54 +layer_count=1 +hard_constraint_count=18 +xml_well_formed=true +view_box=[-3.065 -15.632 16.876999 23.024] +class_counts: + barline=2 + clef=2 + notehead=8 diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg new file mode 100644 index 0000000..e034b2d --- /dev/null +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index ac55988..f05858e 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -60,6 +60,157 @@ fn quarter(eid: EventId, voice: VoiceId, pid: PitchId, index: i64) -> Event { }) } +/// A C pitched quarter-note at `octave`, at musical position `index/4`. +fn c_at(eid: EventId, voice: VoiceId, pid: PitchId, index: i64, octave: i8) -> Event { + Event::Pitched(epiphany_core::PitchedEvent { + id: eid, + voice, + position: EventPosition::Musical(MusicalPosition(RationalTime::new(index, 4).unwrap())), + duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())), + pitches: vec![IdentifiedPitch { + id: pid, + pitch: Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + }, + }], + articulations: vec![], + dynamic: None, + ornaments: vec![], + stem: StemConfiguration, + grace: None, + }) +} + +/// A one-measure, TWO-staff (treble/treble) metric score whose staves carry +/// vertically extreme content: the top staff dips to low ledger notes (down to +/// C2) while the bottom staff climbs to high ledger notes (up to C6) under a +/// slur that arcs further above them. At the engraver's default *fixed* staff +/// pitch the two staves' content nearly collides — the inter-staff pressure case +/// the vertical spring solve must separate. Invariant-clean. +pub fn two_staff_close_content(seed: u64) -> Score { + let mut rng = SplitMix64::new(seed ^ 0x0002_57AF_F123); + let replica = + ReplicaId::from_entropy(rng.next_u64().to_le_bytes()).unwrap_or(ReplicaId(0x2ADF)); + let mut idc = IdentityContext::new(replica); + + let top_staff: StaffId = idc.mint(); + let bottom_staff: StaffId = idc.mint(); + let instrument: InstrumentId = idc.mint(); + let region_id: RegionId = idc.mint(); + let top_instance: StaffInstanceId = idc.mint(); + let bottom_instance: StaffInstanceId = idc.mint(); + let top_voice: VoiceId = idc.mint(); + let bottom_voice: VoiceId = idc.mint(); + + let mut arena = EventArena::new(); + // Top staff descends into low ledgers; bottom staff climbs into high ones. + let mut top_v = Voice::user(top_voice); + let mut bot_v = Voice::user(bottom_voice); + let mut bot_events: Vec = Vec::new(); + for (i, oct) in [4i8, 3, 2, 3].into_iter().enumerate() { + let (eid, pid): (EventId, PitchId) = (idc.mint(), idc.mint()); + arena + .insert(c_at(eid, top_voice, pid, i as i64, oct)) + .unwrap(); + top_v.events.push(eid); + } + for (i, oct) in [4i8, 5, 6, 5].into_iter().enumerate() { + let (eid, pid): (EventId, PitchId) = (idc.mint(), idc.mint()); + arena + .insert(c_at(eid, bottom_voice, pid, i as i64, oct)) + .unwrap(); + bot_v.events.push(eid); + bot_events.push(eid); + } + + let measure = |id: MeasureId| Measure { + id, + start: TimeAnchor::Region { + id: region_id, + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }, + time_signature: None, + explicit_number: Some(1), + number_visibility: Default::default(), + }; + let mut top_inst = StaffInstance::new(top_instance, top_staff); + top_inst.voices.push(top_v); + top_inst.measures.push(measure(idc.mint())); + let mut bot_inst = StaffInstance::new(bottom_instance, bottom_staff); + bot_inst.voices.push(bot_v); + bot_inst.measures.push(measure(idc.mint())); + + // A slur over the bottom staff's high notes, arcing further up — extra + // upward extent that sharpens the collision with the top staff. + let mut cross_cutting = CrossCuttingRegistry::default(); + cross_cutting.slurs.push(Slur { + id: idc.mint::(), + start_event: bot_events[1], + end_event: bot_events[3], + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }); + + let region = epiphany_core::Region { + id: region_id, + time_model: RegionTimeModel::Metric(MetricTimeModel::default()), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: vec![top_inst, bot_inst], + ..Default::default() + }), + time_extent: TimeExtent { + start: TimeAnchor::WallClock { + time: WallClockTime(0), + }, + end: TimeAnchor::WallClock { + time: WallClockTime(10_000_000), + }, + }, + staff_extent: StaffExtent { + staves: vec![top_staff, bottom_staff], + }, + local_tempo_map: None, + permits_spanning_slurs: false, + }; + + let staff = |id: StaffId, name: &str| Staff { + id, + name: String::from(name), + abbreviation: None, + instrument, + default_staff_lines: StaffLineConfiguration::default(), + group: None, + default_clef: epiphany_core::Clef::treble(), + }; + let mut score = Score::empty(idc.clone()); + score.identity = idc; + score.instruments = vec![epiphany_core::Instrument::new( + instrument, + String::from("Keyboard"), + )]; + score.staves = vec![staff(top_staff, "Right"), staff(bottom_staff, "Left")]; + score.events = arena; + score.cross_cutting = cross_cutting; + score.canvas = Canvas { + regions: vec![region], + ..Default::default() + }; + score +} + /// A 10-measure, single-staff, single-voice metric score with 40 quarter notes /// (four per measure), plus a tie, a spanner, a marker, and a chord symbol. The /// QUICKSTART layout hand-off case. Invariant-clean (the returned graph passes @@ -325,6 +476,21 @@ mod tests { assert_eq!(s.cross_cutting.chord_symbols.len(), 1); } + #[test] + fn two_staff_close_content_is_invariant_clean_and_two_staves() { + let s = two_staff_close_content(1); + let v = check_invariants(&s); + assert!(v.is_empty(), "two-staff fixture has violations: {v:?}"); + assert_eq!(s.staves.len(), 2, "two staves"); + assert_eq!( + s.canvas.regions[0].staff_instances().len(), + 2, + "two staff instances in the region" + ); + assert_eq!(s.events.len(), 8, "four notes per staff"); + assert_eq!(s.cross_cutting.slurs.len(), 1, "a slur over the high notes"); + } + #[test] fn repeat_fixture_is_invariant_clean_and_carries_three_repeats() { let s = ten_measure_with_repeats(1);