diff --git a/.gitignore b/.gitignore index 8191219..87e6ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ /ALL_DECISIONS_AND_READMES.md /HANDOFF.md +# The render-svg demo writes its SVG here by convention +# (`cargo run -p epiphany-render-svg --example render_fixture -- … > out.svg`); +# it is a working artifact, not source. +/out.svg + # LaTeX build intermediates (the spec PDF itself is tracked; these are not). spec/*.aux spec/*.fdb_latexmk diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 9cf499a..8ce1aba 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -16,8 +16,9 @@ //! grows this crate into the real two-pass spring solver. This is the first //! increment: [`Engraver`] runs a genuine deterministic **horizontal spacing //! pass** (see [`spacing`]) — the first axis of the planned two-pass spring -//! layout — placing each spring slot left-to-right by its preferred width -//! instead of returning the input columns verbatim. +//! layout — placing each glyph-bearing slot left-to-right by a collision-aware +//! advance (its preferred width floored by the real glyph bearings) instead of +//! returning the input columns verbatim. //! //! It does **not yet** run the vertical spring pass, the soft-constraint //! stretch/compress solve, or evaluate the IR's declared hard constraints. By the @@ -41,13 +42,11 @@ mod spacing; -use std::collections::BTreeMap; - use epiphany_layout_ir::{ all_available, BravuraCatalog, ConstrainedLayoutIR, ConstraintSolver, GlyphCatalog, - InvalidationSet, Margins, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, + InvalidationSet, Margins, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR, ResolvedPage, ResolvedSystem, Size2D, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, - SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, + SolverState, SolverTier, SolverVersion, SolverWarning, SolverWarningKind, Stroke, }; /// The Epiphany engraving solver (Chapter 9). See the crate docs for the phase @@ -81,11 +80,17 @@ impl Engraver { // constraints is reported as not-yet-solvable rather than falsely Solved. let well_formed = structural_valid && catalog_valid && input.constraints.is_empty(); - let glyphs: Vec = if structural_valid { - let positions = spacing::slot_positions(input); - place_glyphs(input, &positions) + // The horizontal spacing pass re-places each glyph by its spring slot. + // The strokes that track those glyphs (stems, staff lines, barlines) must + // ride the *same* horizontal map, or a re-spaced notehead would leave its + // stem behind. Both gate on structural validity: a malformed input must + // not leak geometry into the diagnostic layout (which reaches + // canonical_bytes / the renderer). + let (glyphs, strokes): (Vec, Vec) = if structural_valid { + let remap = HorizontalRemap::build(input); + (remap.glyphs(input), remap.strokes(input)) } else { - Vec::new() + (Vec::new(), Vec::new()) }; let resolved_glyphs = glyphs.len(); @@ -136,6 +141,7 @@ impl Engraver { source: input.source, pages, glyphs, + strokes, engraving_decisions: input.engraving_decisions.clone(), catalog: input.catalog.clone(), }, @@ -158,32 +164,92 @@ impl Engraver { } } -/// Copies each glyph to the `x` of its horizontal slot (its baseline `y` -/// preserved), preserving provenance, glyph identity, bounds, style, and layer. -/// A glyph whose slot has no computed position keeps its baseline `x`. -fn place_glyphs( - input: &ConstrainedLayoutIR, - positions: &BTreeMap, -) -> Vec { - input - .glyphs - .iter() - .map(|g| { - let x = positions - .get(&g.horizontal_slot) - .copied() - .unwrap_or(g.baseline.x.0); - ResolvedGlyph { +/// A monotonic piecewise-linear map from a constrained x to its spaced x. Each +/// column's *source* x (the baseline its member glyphs share) maps to the +/// *target* x the spacing pass assigns its slot; intermediate and outlying +/// coordinates interpolate/extrapolate linearly. Applied to glyph baselines +/// **and** stroke endpoints alike, so a stroke at (or near) a glyph's column +/// moves with it instead of detaching — the fix for strokes being left at their +/// constrained coordinates while glyphs re-space. +struct HorizontalRemap { + /// `(source_x, target_x)` control points, sorted by source, sources distinct. + points: Vec<(f32, f32)>, +} + +impl HorizontalRemap { + fn build(input: &ConstrainedLayoutIR) -> Self { + // The control points are computed collision-aware (per-slot bearings) by + // the spacing pass; sources are globally monotonic because regions tile + // left-to-right. + HorizontalRemap { + points: spacing::control_points(input), + } + } + + /// Maps a constrained x to its spaced x. + fn map(&self, x: f32) -> f32 { + let p = &self.points; + match p.len() { + 0 => x, + // One column: a pure translation keeps relative offsets. + 1 => x + (p[0].1 - p[0].0), + n => { + if x <= p[0].0 { + interp(p[0], p[1], x) + } else if x >= p[n - 1].0 { + interp(p[n - 2], p[n - 1], x) + } else { + p.windows(2) + .find(|w| x >= w[0].0 && x <= w[1].0) + .map(|w| interp(w[0], w[1], x)) + .unwrap_or(x) + } + } + } + } + + /// Re-places each glyph at its mapped x, baseline `y` preserved; provenance, + /// glyph identity, bounds, style, and layer carried through. + fn glyphs(&self, input: &ConstrainedLayoutIR) -> Vec { + input + .glyphs + .iter() + .map(|g| ResolvedGlyph { provenance: g.provenance.clone(), glyph: g.glyph.clone(), - position: epiphany_layout_ir::Point::new(x, g.baseline.y.0), + position: Point::new(self.map(g.baseline.x.0), g.baseline.y.0), transform: None, bounding_box: g.bounding_box, style: g.style, layer: g.layer, - } - }) - .collect() + }) + .collect() + } + + /// Re-maps both endpoints of each stroke, so it tracks the glyphs it spans. + fn strokes(&self, input: &ConstrainedLayoutIR) -> Vec { + input + .strokes + .iter() + .map(|s| Stroke { + provenance: s.provenance.clone(), + from: Point::new(self.map(s.from.x.0), s.from.y.0), + to: Point::new(self.map(s.to.x.0), s.to.y.0), + thickness: s.thickness, + layer: s.layer, + style: s.style, + }) + .collect() + } +} + +/// Linear interpolation/extrapolation through two control points. +fn interp((s0, t0): (f32, f32), (s1, t1): (f32, f32), x: f32) -> f32 { + if (s1 - s0).abs() < f32::EPSILON { + t0 + } else { + t0 + (x - s0) * (t1 - t0) / (s1 - s0) + } } impl ConstraintSolver for Engraver { @@ -254,6 +320,35 @@ mod tests { assert_eq!(report.metric_vector, QualityMetricVector::unmeasured()); } + #[test] + fn a_structurally_invalid_input_emits_no_strokes() { + // Strokes are gated on the same structural validity as glyphs: an input + // whose validation fails (here, an out-of-range stroke thickness) yields a + // diagnostic layout with no glyphs *and* no strokes — the malformed stroke + // must not leak into canonical_bytes or the renderer. + let mut input = fixture(); + let provenance = input.glyphs[0].provenance.clone(); + input.strokes.push(epiphany_layout_ir::Stroke { + provenance, + from: epiphany_layout_ir::Point::new(0.0, 0.0), + to: epiphany_layout_ir::Point::new(1.0, 0.0), + thickness: epiphany_layout_ir::StaffSpace(f32::MAX), + layer: 0, + style: epiphany_layout_ir::GlyphStyle::default(), + }); + assert!( + input.validate().is_err(), + "the out-of-range stroke is invalid" + ); + let report = Engraver.solve(&input, &SolverConfig::default()); + assert_eq!(report.status, SolveStatus::InternalError); + assert!(report.layout.glyphs.is_empty()); + assert!( + report.layout.strokes.is_empty(), + "a structurally invalid input emits no strokes (gated like glyphs)" + ); + } + #[test] fn horizontal_spacing_differs_from_the_verbatim_stub() { // The whole point of the scaffold: it re-spaces horizontally rather than @@ -279,6 +374,50 @@ mod tests { assert_eq!(engraved.glyphs.len(), stub.glyphs.len()); } + #[test] + fn strokes_ride_the_same_coordinate_map_as_glyphs() { + // The spacing pass re-places glyphs; the strokes that track them must move + // by the same horizontal map, not stay at their constrained coordinates. + let input = fixture(); + let engraved = Engraver.solve(&input, &SolverConfig::default()).layout; + + // Constrained x -> engraved x, per glyph. + let glyph_map: Vec<(f32, f32)> = input + .glyphs + .iter() + .zip(&engraved.glyphs) + .map(|(c, r)| (c.baseline.x.0, r.position.x.0)) + .collect(); + + // Every stroke endpoint coincident with a glyph's column lands at that + // glyph's engraved x — they ride one map, so they stay attached. + let mut checked = 0; + for (c, r) in input.strokes.iter().zip(&engraved.strokes) { + for (gx, ex) in &glyph_map { + if (c.from.x.0 - gx).abs() < 1e-6 { + assert!( + (r.from.x.0 - ex).abs() < 1e-3, + "a stroke at a glyph's column detached from it after spacing" + ); + checked += 1; + } + } + } + assert!( + checked > 0, + "expected strokes coincident with glyph columns" + ); + + // …and the strokes actually moved (the pass re-spaces, it does not echo). + let moved = input.strokes.iter().zip(&engraved.strokes).any(|(c, r)| { + (c.from.x.0 - r.from.x.0).abs() > 1e-4 || (c.to.x.0 - r.to.x.0).abs() > 1e-4 + }); + assert!( + moved, + "strokes must be re-spaced with the glyphs, not left behind" + ); + } + #[test] fn solve_is_deterministic_and_quantizable() { let input = fixture(); @@ -288,6 +427,205 @@ mod tests { assert_eq!(a.canonical_bytes(), b.canonical_bytes()); } + #[test] + fn engraver_reserves_an_accidental_against_the_previous_note() { + // A note's accidental overhangs *left* of its notehead, into the previous + // note's column. The spacing pass must reserve that overhang (against the + // previous slot's advance), or the accidental overlaps the prior notehead. + use epiphany_core::{ + AccidentalId, CmnNominal, EventId, MusicalPosition, PitchId, PitchSpelling, + RationalTime, RegionId, StaffId, TypedObjectId, + }; + use epiphany_layout_ir::{ + LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR, + MetricTimeAxis, NoteContent, NotePitch, Provenance, ScoreVersion, TimeAxisModel, + TimePoint, VerticalExtent, + }; + + let region = RegionId::from_raw(1); + let staff = StaffId::from_raw(10); + let plain = PitchId::from_raw(100); + let sharped = PitchId::from_raw(101); + let manifested = |src, content| { + LayoutObject::from_projection_with_content( + Provenance::manifested(src, region, vec![]), + Some(staff), + content, + ) + }; + let at = |n, d| TimePoint::Musical(MusicalPosition(RationalTime::new(n, d).unwrap())); + let note = |pid: PitchId, time: TimePoint, accidental: bool| { + let mut spelling = PitchSpelling::cmn(CmnNominal::C, 5); + if accidental { + spelling.accidentals.push(AccidentalId::new("sharp")); + } + LayoutContent::Note(NoteContent { + position: time, + components: vec![], + pitches: vec![NotePitch { + pitch: pid, + spelling: Some(spelling), + }], + }) + }; + let logical = LogicalLayoutIR { + source: ScoreVersion::default(), + regions: vec![LayoutRegion { + provenance: Provenance::projected(TypedObjectId::Region(region), vec![]), + coordinate_system: LocalCoordinateSystem::default(), + time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()), + vertical_extent: VerticalExtent { + staves: vec![staff], + }, + objects: vec![ + // A plain note, then a note with a sharp a quarter later. + manifested( + TypedObjectId::Event(EventId::from_raw(1)), + note(plain, at(0, 1), false), + ), + manifested(TypedObjectId::Pitch(plain), LayoutContent::Structural), + manifested( + TypedObjectId::Event(EventId::from_raw(2)), + note(sharped, at(1, 4), true), + ), + manifested(TypedObjectId::Pitch(sharped), LayoutContent::Structural), + ], + }], + engraving_decisions: vec![], + overrides: vec![], + cross_region: vec![], + }; + + let constrained = to_constrained(&logical); + let engraved = Engraver + .solve(&constrained, &SolverConfig::default()) + .layout; + let mut noteheads: Vec<_> = engraved + .glyphs + .iter() + .filter(|g| g.glyph.as_str() == "noteheadBlack") + .collect(); + noteheads.sort_by(|a, b| a.position.x.0.partial_cmp(&b.position.x.0).unwrap()); + assert_eq!(noteheads.len(), 2, "two noteheads"); + let first_right = noteheads[0].position.x.0 + noteheads[0].bounding_box.right.0; + let sharp = engraved + .glyphs + .iter() + .find(|g| g.glyph.as_str() == "accidentalSharp") + .expect("a sharp is drawn"); + let sharp_left = sharp.position.x.0 + sharp.bounding_box.left.0; + assert!( + sharp_left >= first_right, + "the accidental ({sharp_left}) overlaps the previous notehead's right edge ({first_right})" + ); + } + + #[test] + fn engraver_preserves_key_signature_lead_spacing() { + // The lead area (clef + key signature) is fixed-width content. The spacing + // pass must reserve it via the lead slot's preferred width, or it compresses + // the key signature back onto the clef. This drives the *real* engraver, not + // just the verbatim stub. + use epiphany_core::{ + CmnNominal, EventId, KeySignature, MusicalPosition, PitchId, PitchSpelling, RegionId, + StaffId, StaffInstanceId, TypedObjectId, + }; + use epiphany_layout_ir::{ + LayoutContent, LayoutObject, LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR, + MetricTimeAxis, NoteContent, NotePitch, PlacedKeySignature, Provenance, ScoreVersion, + StaffContent, TimeAxisModel, TimePoint, VerticalExtent, + }; + + let region = RegionId::from_raw(1); + let staff = StaffId::from_raw(10); + let pitch = PitchId::from_raw(100); + 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: LocalCoordinateSystem::default(), + time_axis: TimeAxisModel::Metric(MetricTimeAxis::default()), + vertical_extent: VerticalExtent { + staves: vec![staff], + }, + objects: vec![ + // A 3-sharp (A major) key signature, then a note. + manifested( + TypedObjectId::StaffInstance(StaffInstanceId::from_raw(1)), + LayoutContent::Staff(StaffContent { + clefs: vec![], + keys: vec![PlacedKeySignature { + time: TimePoint::Musical(MusicalPosition::origin()), + key: KeySignature::new(3).expect("three sharps"), + }], + }), + ), + manifested( + TypedObjectId::Event(EventId::from_raw(1)), + LayoutContent::Note(NoteContent { + position: TimePoint::Musical(MusicalPosition::origin()), + components: vec![], + pitches: vec![NotePitch { + pitch, + spelling: Some(PitchSpelling::cmn(CmnNominal::C, 5)), + }], + }), + ), + manifested(TypedObjectId::Pitch(pitch), LayoutContent::Structural), + ], + }], + engraving_decisions: vec![], + overrides: vec![], + cross_region: vec![], + }; + + let constrained = to_constrained(&logical); + let engraved = Engraver + .solve(&constrained, &SolverConfig::default()) + .layout; + let x_of = |name: &str| { + engraved + .glyphs + .iter() + .find(|g| g.glyph.as_str() == name) + .map(|g| g.position.x.0) + }; + let clef_x = x_of("gClef").expect("clef engraved"); + let note_x = x_of("noteheadBlack").expect("notehead engraved"); + let sharps: Vec = engraved + .glyphs + .iter() + .filter(|g| g.glyph.as_str() == "accidentalSharp") + .map(|g| g.position.x.0) + .collect(); + assert_eq!(sharps.len(), 3, "a three-sharp signature"); + + // Not compressed into the clef: the lead clearly exceeds one note slot. + assert!( + note_x - clef_x > 3.0, + "key signature compressed into the clef (lead width {})", + note_x - clef_x + ); + // The accidentals sit in the lead (clef..note), spread to distinct x. + assert!( + sharps.iter().all(|&x| x > clef_x && x < note_x), + "accidentals must lie between the clef and the first note" + ); + let mut sorted = sharps; + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert!( + sorted[0] < sorted[1] && sorted[1] < sorted[2], + "accidentals are spread out, not stacked at one x" + ); + } + #[test] fn incremental_is_observationally_equivalent_to_full() { let input = fixture(); diff --git a/crates/epiphany-engrave/src/spacing.rs b/crates/epiphany-engrave/src/spacing.rs index a0a8a6c..66449af 100644 --- a/crates/epiphany-engrave/src/spacing.rs +++ b/crates/epiphany-engrave/src/spacing.rs @@ -1,107 +1,132 @@ //! The horizontal spacing pass — the first axis of the planned two-pass spring //! layout (`epiphany-engrave`'s DECISIONS.md, decision 1). //! -//! A `ConstrainedLayoutIR` carries one horizontal spring slot per musical time -//! column, each with a `preferred_width` in staff spaces. This pass walks the -//! slots in their emitted left-to-right order and assigns each an absolute `x` -//! by accumulating preferred widths, producing even, non-overlapping horizontal -//! spacing (the stub solver, by contrast, returns the raw input columns -//! verbatim). The vertical pass, the soft-spring stretch/compress solve, and -//! constraint evaluation are deferred to the `Minimal`-tier work (next phase). +//! A `ConstrainedLayoutIR` carries horizontal spring slots — one slot per +//! *musical time column* (`to_constrained` groups simultaneous glyphs into a +//! shared column slot, with the clef in a lead column and barlines in their own +//! columns). This pass places each glyph-bearing slot left to right and yields +//! the coordinate-map control points the caller ([`crate::HorizontalRemap`]) +//! applies to glyph baselines *and* the strokes that track them. +//! +//! The advance from one slot to the next is the larger of the slot's +//! `preferred_width` (the spring's natural width — a uniform placeholder in v0) +//! and a **collision minimum** derived from real glyph bounding boxes: the slot's +//! right content extent, plus a gap, plus the *next* slot's left overhang (its +//! accidental zone). Reserving the next slot's left overhang against *this* slot's +//! advance is what protects a note's accidental from overlapping the previous +//! note — a single per-slot `preferred_width` could only reserve space to the +//! right of a slot's source. The vertical pass, the soft-spring stretch/compress +//! solve, and constraint evaluation remain `Minimal`-tier work (next phase). use std::collections::BTreeMap; -use epiphany_layout_ir::{ConstrainedLayoutIR, SpringSlotId, StaffSpace}; +use epiphany_layout_ir::{ConstrainedLayoutIR, SpringSlotId}; -/// The absolute `x` (in staff spaces) assigned to each spring slot, accumulated -/// left-to-right over the slots' emitted order. Deterministic: a pure function -/// of the slot sequence and their preferred widths. -pub(crate) fn slot_positions(input: &ConstrainedLayoutIR) -> BTreeMap { - let mut positions = BTreeMap::new(); - let mut cursor = 0.0_f32; - for slot in &input.horizontal_slots { - positions.insert(slot.id, cursor); - // Advance by the slot's preferred width; a non-finite or negative width - // would have been rejected by `ConstrainedLayoutIR::validate`, but clamp - // defensively so the cursor stays finite and monotonic regardless. - let StaffSpace(width) = slot.preferred_width; - cursor += if width.is_finite() && width >= 0.0 { - width - } else { - 0.0 - }; +/// Inter-slot gap (staff spaces) reserved between one slot's right content and +/// the next slot's left content. +const SLOT_GAP: f32 = 0.3; + +/// Horizontal coordinate-map control points `(source_x, target_x)`, one per +/// glyph-bearing slot, sorted left to right. The source is the slot's column +/// reference (its first member glyph's baseline); the target accumulates +/// collision-aware advances so neighbouring slots' content — including +/// left-overhanging accidentals — never overlaps, and a wide lead (clef + key +/// signature) reserves real space. Deterministic: a pure function of the glyphs +/// and their bounding boxes. +pub(crate) fn control_points(input: &ConstrainedLayoutIR) -> Vec<(f32, f32)> { + /// One slot's horizontal extent, from its member glyphs. + struct Extent { + /// Column reference x (the first member's baseline). + source: f32, + /// Leftmost / rightmost content edge across the slot's glyphs. + min_left: f32, + max_right: f32, + /// The spring's natural width. + preferred: f32, } - positions + + let preferred_of: BTreeMap = input + .horizontal_slots + .iter() + .map(|s| (s.id, s.preferred_width.0)) + .collect(); + let mut by_slot: BTreeMap = BTreeMap::new(); + for glyph in &input.glyphs { + let left = glyph.baseline.x.0 + glyph.bounding_box.left.0; + let right = glyph.baseline.x.0 + glyph.bounding_box.right.0; + by_slot + .entry(glyph.horizontal_slot) + .and_modify(|e| { + e.min_left = e.min_left.min(left); + e.max_right = e.max_right.max(right); + }) + .or_insert(Extent { + source: glyph.baseline.x.0, + min_left: left, + max_right: right, + preferred: preferred_of + .get(&glyph.horizontal_slot) + .copied() + .unwrap_or(0.0), + }); + } + + let mut slots: Vec = by_slot.into_values().collect(); + slots.sort_by(|a, b| { + a.source + .partial_cmp(&b.source) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut points = Vec::with_capacity(slots.len()); + let mut target = 0.0_f32; + for i in 0..slots.len() { + points.push((slots[i].source, target)); + let right_bearing = slots[i].max_right - slots[i].source; + // The next slot's left overhang must be cleared by *this* slot's advance. + let next_left = slots + .get(i + 1) + .map(|next| next.source - next.min_left) + .unwrap_or(0.0); + let advance = slots[i].preferred.max(right_bearing + SLOT_GAP + next_left); + target += advance; + } + points.dedup_by(|a, b| a.0 == b.0); + points } #[cfg(test)] mod tests { use super::*; use epiphany_core::generators::valid_score_rich; - use epiphany_core::WallClockTime; - use epiphany_layout_ir::{ - to_constrained, to_logical, GlyphCatalogIdentity, SpringSlot, TimePoint, - }; - - fn slot(id: u128, preferred: f32) -> SpringSlot { - SpringSlot { - id: SpringSlotId(id), - time: TimePoint::WallClock(WallClockTime(id as i64)), - min_width: StaffSpace(preferred), - preferred_width: StaffSpace(preferred), - max_width: None, - stretch_factor: 1.0, - compress_factor: 1.0, - members: vec![], - } - } - - fn ir_with_slots(slots: Vec) -> ConstrainedLayoutIR { - ConstrainedLayoutIR { - source: Default::default(), - regions: vec![], - horizontal_slots: slots, - glyphs: vec![], - vertical_bands: vec![], - constraints: vec![], - engraving_decisions: vec![], - catalog: GlyphCatalogIdentity::default(), - } - } + use epiphany_layout_ir::{to_constrained, to_logical}; #[test] - fn slots_are_placed_left_to_right_by_accumulated_width() { + fn control_points_are_monotonic_in_source_and_target() { let c = to_constrained(&to_logical(&valid_score_rich(7))); - let positions = slot_positions(&c); - // Every slot got a position equal to the running cursor, non-decreasing - // in emitted order (preferred widths are non-negative). - assert_eq!(positions.len(), c.horizontal_slots.len()); - let mut cursor = 0.0_f32; - for slot in &c.horizontal_slots { - let x = positions[&slot.id]; - assert!(x.is_finite()); - assert!( - (x - cursor).abs() < 1e-3, - "slot x must equal the running cursor" - ); - cursor += slot.preferred_width.0; + let points = control_points(&c); + assert!(!points.is_empty()); + for w in points.windows(2) { + assert!(w[1].0 > w[0].0, "sources strictly increase"); + assert!(w[1].1 > w[0].1, "targets strictly increase"); } } #[test] - fn spacing_uses_preferred_widths_not_input_columns() { - // Slots that each prefer 2.0 staff spaces lay out at 0, 2, 4 — a pure - // function of widths, independent of any input baseline column. - let input = ir_with_slots(vec![slot(1, 2.0), slot(2, 2.0), slot(3, 2.0)]); - let p = slot_positions(&input); - assert_eq!(p[&SpringSlotId(1)], 0.0); - assert_eq!(p[&SpringSlotId(2)], 2.0); - assert_eq!(p[&SpringSlotId(3)], 4.0); + fn spacing_re_spaces_rather_than_echoing_sources() { + // A wide lead (clef) advances by more than a uniform note slot, so the + // engraved targets are not a copy of the source columns. + let c = to_constrained(&to_logical(&valid_score_rich(7))); + let points = control_points(&c); + assert!( + points.iter().any(|(s, t)| (s - t).abs() > 1e-3), + "targets must differ from sources (re-spacing happened)" + ); } #[test] fn spacing_is_deterministic() { - let input = ir_with_slots(vec![slot(10, 1.5), slot(20, 1.5)]); - assert_eq!(slot_positions(&input), slot_positions(&input)); + let c = to_constrained(&to_logical(&valid_score_rich(3))); + assert_eq!(control_points(&c), control_points(&c)); } } diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 7f2cd96..431a2e4 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -9,19 +9,32 @@ //! (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::{StaffId, TypedObjectId, WallClockTime}; - -use crate::engraving::EngravingDecision; -use crate::glyph::{ - metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference, BRAVURA_METRICS, +use epiphany_core::{ + Clef, EventId, KeySignature, MusicalDuration, NoteValue, PitchId, PitchSpelling, + SpellingNominal, StaffId, 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; +use crate::glyph::{metrics, BravuraCatalog, GlyphCatalog, GlyphCatalogIdentity, GlyphReference}; +use crate::logical::{ + BarlineKind, LayoutContent, LogicalLayoutIR, PlacedClef, PlacedKeySignature, ScoreVersion, + StaffContent, +}; +use crate::provenance::{ + manifestation_layout_id, LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind, + SynthesisRegistryId, }; -use crate::logical::{LogicalLayoutIR, ScoreVersion}; -use crate::provenance::{manifestation_layout_id, LayoutObjectId, Provenance}; use crate::solver::SpringSlotId; use crate::spatial::{BoundingBox, Point, Rect, StaffSpace}; -use crate::time_axis::{SlotPlacement, TimeAxisModel, TimePoint}; +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`). @@ -57,21 +70,72 @@ impl GlyphObject { } } -/// The constrained IR: composite objects flattened to glyphs, with the vertical -/// bands and engraving decisions that the solver consumes alongside them -/// (Chapter 7 §"Constraints"). +/// 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, +} + +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) + } +} + +/// 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, pub vertical_bands: Vec, pub constraints: 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. @@ -162,6 +226,9 @@ pub enum ConstrainedValidationError { 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), @@ -169,6 +236,8 @@ pub enum ConstrainedValidationError { 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 malformed logical-stage value that cannot be transformed without losing @@ -228,6 +297,13 @@ impl ConstrainedLayoutIR { { 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)); @@ -338,33 +414,105 @@ impl ConstrainedLayoutIR { 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(), + )); + } + } Ok(()) } } -/// Picks a bundled SMuFL glyph for a source, deterministically (a pure function -/// of the source kind, so it never depends on traversal position). -pub(crate) fn glyph_name_for(source: &TypedObjectId) -> GlyphReference { - GlyphReference::borrowed( - BRAVURA_METRICS[(source.discriminant() as usize) % BRAVURA_METRICS.len()] - .name - .as_ref(), - ) -} +// 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; +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) +const NOTEHEAD_STEM_X: f32 = 1.15; // a stem-up attaches at the notehead's right edge +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 -/// Flattens [`LogicalLayoutIR`] into [`ConstrainedLayoutIR`]: one glyph per -/// layout object (including the region object itself), each laid out -/// left-to-right on the `1/1024` grid, with provenance preserved -/// object-for-object. +/// 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" + +/// 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" + +/// 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. /// -/// **Each glyph is routed to the band of its own staff** (Chapter 7 §"Vertical -/// Bands"): a region manifesting two staves gets a staff band per staff, with -/// each glyph a member of exactly its staff's band — never every staff's band. -/// Region-level glyphs (the region object, cross-cutting, free-graphic) go to a -/// margin band. Multi-staff regions also carry empty `InterStaffGap` spring -/// bands between consecutive staves. Staff-band ids are the staff *layout -/// object's* manifestation id, so a staff manifested in two regions gets two -/// distinct bands. +/// **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. +/// +/// Glyphs (only) are routed to the band of their own staff (Chapter 7 §"Vertical +/// Bands"); strokes are free line primitives the solver positions but the band +/// 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. pub fn to_constrained(logical: &LogicalLayoutIR) -> ConstrainedLayoutIR { try_to_constrained(logical).expect("LogicalLayoutIR is malformed") } @@ -376,10 +524,15 @@ pub fn try_to_constrained( logical: &LogicalLayoutIR, ) -> Result { let mut glyphs = Vec::new(); + let mut strokes = Vec::new(); + let mut diagnostics = Vec::new(); let mut vertical_bands = Vec::new(); let mut horizontal_slots = Vec::new(); let mut constrained_regions = Vec::new(); - let mut column: i64 = 0; + // 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 { @@ -400,60 +553,580 @@ pub fn try_to_constrained( } }; - // (provenance, owning staff) for the region object, then its contents. - let mut specs: Vec<(&Provenance, Option)> = - std::iter::once((®ion.provenance, None)) - .chain(region.objects.iter().map(|o| (o.provenance(), o.staff()))) + // 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 defaults to treble. + let mut clef_seq_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()); + } + } + let clef_seq = |staff: Option| -> &[PlacedClef] { + staff + .and_then(|s| clef_seq_of.get(&s)) + .map(Vec::as_slice) + .unwrap_or(&[]) + }; + + // 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(); + 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; + + 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(clef_seq(staff), &time); + 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, + comp, + accidentals, + }); + } + let drawn = has_stem(value) && !ys.is_empty(); + let lo = ys + .iter() + .copied() + .fold(f32::INFINITY, f32::min) + .min(step_to_y(yo, reference_step(&clef))); + let hi = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max).max(lo); + stems.push(StemSeg { + key, + lo: if ys.is_empty() { + step_to_y(yo, reference_step(&clef)) + } else { + ys.iter().copied().fold(f32::INFINITY, f32::min) + }, + hi, + drawn, + comp, + }); + } + 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)) => { + keys.insert(measure_column(measure)); + } + (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); + } + (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(&content.clefs, &origin()); + 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); + } + _ => {} + } + } + + // 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(); - specs.extend( - logical - .cross_region - .iter() - .filter(|object| object.regions.first() == Some(®ion_id)) - .map(|object| (&object.provenance, object.staff)), - ); - // Distinct staves in first-appearance order, and members per band. - let mut staves_in_order: Vec = Vec::new(); - let mut staff_members: BTreeMap> = BTreeMap::new(); - let mut margin_members: Vec = Vec::new(); - let mut region_glyphs = Vec::new(); - let mut region_placements: Vec = Vec::new(); + let mut emit = Emit { + glyphs: &mut glyphs, + strokes: &mut strokes, + diagnostics: &mut diagnostics, + column_members: BTreeMap::new(), + region_glyphs: Vec::new(), + staff_members: BTreeMap::new(), + margin_members: Vec::new(), + staves_in_order: Vec::new(), + }; - for (provenance, staff) in specs { - let band = band_of(staff); - let glyph = make_glyph(provenance, column, band); - column += 1; - let gid = glyph.id(); - let time = TimePoint::WallClock(WallClockTime(column - 1)); + // 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, + )); + } + } + 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(&c.clefs, &origin()), + _ => 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))); + } + } + } + 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))); + } + for seg in segs { + let info = column(&seg.key); + let stem_x = info.x + NOTEHEAD_STEM_X; + let (from, to) = if seg.drawn { + ( + Point::new(stem_x, seg.lo), + Point::new(stem_x, seg.hi + STEM_LENGTH), + ) + } 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(), + }); + } + } + 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))); + } + 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))); + } + } + } + } + // 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))), + }, + 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, + ); + // 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(clef_seq(staff), &origin()); + emit.stroke(anchor( + provenance, + Point::new(default_x, step_to_y(yo, reference_step(&clef))), + )); + } + }, + TypedObjectId::Measure(_) => { + let key = match content { + Some(LayoutContent::Measure(measure)) => measure_column(measure), + _ => ColumnKey::End, + }; + let name = if key == ColumnKey::End { + "barlineFinal" + } else { + "barlineSingle" + }; + let info = column(&key); + let baseline = Point::new(info.x, yo + STAFF_HEIGHT / 2.0); + 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 { + let center_x = info.x + TIME_SIG_X; + // 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(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, + ); + } + } + } + } + } + // 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. + _ => emit.stroke(anchor(provenance, Point::new(default_x, yo))), + } + } + + 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: glyph.horizontal_slot, - time: time.clone(), + id: info.slot, + time: info.time.clone(), min_width: StaffSpace(1.0), - preferred_width: StaffSpace(1.5), + preferred_width: StaffSpace(COLUMN_PREFERRED_WIDTH), max_width: None, stretch_factor: 1.0, compress_factor: 1.0, - members: vec![gid], + members, }); - region_placements.push(SlotPlacement { - time, - slot: glyph.horizontal_slot, - }); - region_glyphs.push(gid); - match staff { - Some(s) => { - if !staves_in_order.contains(&s) { - staves_in_order.push(s); - } - staff_members.entry(s).or_default().push(gid); - } - None => margin_members.push(gid), + if info.note_column { + region_placements.push(SlotPlacement { + time: info.time.clone(), + slot: info.slot, + }); } - glyphs.push(glyph); } - // A staff band per manifested staff, in first-appearance order. + // A staff band per manifested staff that carries glyphs, in first-glyph + // order; an (empty) inter-staff gap band between adjacent staves; and a + // margin band for any region-level glyphs. for staff in &staves_in_order { let layout_id = manifestation_layout_id(&TypedObjectId::Staff(*staff), region_id); let members = staff_members.remove(staff).unwrap_or_default(); @@ -461,12 +1134,10 @@ pub fn try_to_constrained( layout_id, *staff, members, )); } - // An (empty) inter-staff gap band between each pair of adjacent staves. for gap in 1..staves_in_order.len() { let gap_id = inter_staff_gap_id(region_layout_id, gap); vertical_bands.push(VerticalBand::inter_staff_gap(gap_id)); } - // A margin band for region-level glyphs, if any. if !margin_members.is_empty() { vertical_bands.push(VerticalBand::margin(region_layout_id, margin_members)); } @@ -477,6 +1148,8 @@ pub fn try_to_constrained( // 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(); @@ -496,32 +1169,452 @@ pub fn try_to_constrained( regions: constrained_regions, horizontal_slots, glyphs, + strokes, vertical_bands, constraints: Vec::new(), engraving_decisions: logical.engraving_decisions.clone(), + diagnostics, catalog, }) } -/// Builds a glyph for a provenance at horizontal `column`, baseline one staff -/// space apart per column (Chapter 7 §7.2 staff-space coordinates), in `band`. -fn make_glyph(provenance: &Provenance, column: i64, band: VerticalBandId) -> GlyphObject { - let glyph = glyph_name_for(&provenance.source); - GlyphObject { - bounding_box: metrics(glyph.as_str()) - .expect("pipeline glyph names are bundled") - .bounding_box(), - glyph, - horizontal_slot: SpringSlotId(provenance.stable_id.0), - baseline: Point::new(column as f32, 0.0), - vertical_band: band, - anchor: Point::ORIGIN, - layer: 0, - style: GlyphStyle { rgba: 0x0000_00ff }, - provenance: provenance.clone(), +/// 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, + 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, + lo: f32, + hi: f32, + drawn: bool, + comp: usize, +} + +/// 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. Glyphs (only) carry band and +/// spring-slot membership; strokes are free line primitives. +struct Emit<'a> { + glyphs: &'a mut Vec, + strokes: &'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 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) -> Stroke { + Stroke { + provenance, + from, + to, + thickness: StaffSpace(thickness), + layer: 0, + style: ink(), + } +} + +/// 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) -> Stroke { + Stroke { + provenance: provenance.clone(), + from: at, + to: at, + thickness: StaffSpace(0.0), + layer: 0, + style: ink(), + } +} + +/// 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(), + ) + } +} + +/// 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) + } +} + +/// 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(&content.clefs, &origin())), + None => Vec::new(), + } +} + +/// The decimal digits of a time-signature number, most significant first. +fn digits_of(value: u16) -> 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. +fn active_clef(clefs: &[PlacedClef], at: &TimePoint) -> 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 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::*; @@ -530,27 +1623,825 @@ mod tests { use epiphany_core::generators::valid_score_rich; use std::collections::BTreeSet; + #[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(); + c.strokes.push(Stroke { + provenance, + 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 { + 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 { + // 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 is no longer inert: it carries one placement per slot the - // region produced, and the slots come back in time order. - let region_slots: Vec = - region.glyphs.iter().map(|g| SpringSlotId(g.0)).collect(); - assert_eq!(region.time_axis.slots(), region_slots); - - // project() consumes the time argument: each slot's own time projects - // back to that slot (the covering placement) — not a constant. + // 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 ); } - // A non-trivial region distinguishes its slots by time (so project is - // genuinely a function of the query, not "always the first 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!( @@ -561,6 +2452,82 @@ mod tests { } } + /// 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 @@ -596,22 +2563,67 @@ mod tests { /// 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. + /// 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}; + 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::{EventId, RegionId, StaffId}; + 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 manifested = |src: TypedObjectId, staff: StaffId| { - LayoutObject::from_projection(Provenance::manifested(src, region, vec![]), Some(staff)) + 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 { + 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 { @@ -621,12 +2633,7 @@ mod tests { vertical_extent: crate::VerticalExtent { staves: vec![staff_a, staff_b], }, - objects: vec![ - manifested(TypedObjectId::Staff(staff_a), staff_a), - manifested(TypedObjectId::Staff(staff_b), staff_b), - manifested(TypedObjectId::Event(EventId::from_raw(1)), staff_a), - manifested(TypedObjectId::Event(EventId::from_raw(2)), staff_b), - ], + objects, }], engraving_decisions: vec![], overrides: vec![], @@ -647,7 +2654,7 @@ mod tests { 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 (staff object + event) are in A's band only. + // 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)) diff --git a/crates/epiphany-layout-ir/src/engrave_theory.rs b/crates/epiphany-layout-ir/src/engrave_theory.rs new file mode 100644 index 0000000..e077435 --- /dev/null +++ b/crates/epiphany-layout-ir/src/engrave_theory.rs @@ -0,0 +1,327 @@ +//! Music-theory engraving primitives — the pure mapping from notated content to +//! SMuFL glyph names and staff positions, with no layout-IR plumbing. +//! +//! This is the "engraving decision" layer Chapter 7 assigns to the layout IR: +//! which glyph notates a note value, where a pitch sits on the staff under a +//! clef, which accidentals a spelling draws, and which accidentals a key +//! signature places. The constrained-layout pass consumes these; the renderer +//! never makes these choices (Chapter 7 §"Non-overreach"). +//! +//! Every function here is total and deterministic. Glyph names are the SMuFL +//! canonical names bundled in [`crate::glyph`]. + +use epiphany_core::{ + AccidentalId, Clef, ClefShape, CmnNominal, KeySignature, NoteValue, StemDirection, +}; + +/// A staff position in **half-staff-space steps from the bottom staff line**: +/// the bottom line is `0`, the space just above it is `1`, the second line is +/// `2`, … (negative below the bottom line). One step is half a staff space, so +/// the visual `y` of a position is `position as f32 * 0.5` staff spaces. +/// +/// This is a **diatonic** position: it depends only on the nominal letter and +/// octave, never on accidentals — a C-sharp sits on the same line as a +/// C-natural. Accidentals are drawn to the *left* of the notehead, they do not +/// move it vertically. +pub type StaffStep = i32; + +/// The diatonic index of a `(nominal, octave)`: `octave * 7 + letter`, with the +/// nominal letter C=0 … B=6 ([`CmnNominal`]). Octaves are scientific-pitch +/// (C4 = middle C), so each whole octave is exactly seven diatonic steps. +fn diatonic_index(nominal: CmnNominal, octave: i8) -> i32 { + octave as i32 * 7 + nominal as i32 +} + +/// The diatonic index of a clef shape's reference pitch — the pitch the clef +/// glyph fixes on its line: G clef → G4, F clef → F3, C clef → middle C (C4). +/// A percussion clef has no diatonic reference. +fn clef_reference_diatonic(shape: ClefShape) -> Option { + match shape { + ClefShape::G => Some(diatonic_index(CmnNominal::G, 4)), + ClefShape::F => Some(diatonic_index(CmnNominal::F, 3)), + ClefShape::C => Some(diatonic_index(CmnNominal::C, 4)), + ClefShape::Percussion => None, + } +} + +/// The staff position of a diatonic pitch under `clef` (see [`StaffStep`]). +/// +/// The clef fixes its reference pitch on `clef.line` (line 1 = the bottom line); +/// every diatonic step away from that pitch is one half-staff-space step. +/// `clef.octave_shift` transposes the written position: a `+1` (8va) clef writes +/// a sounding pitch an octave *lower* on the staff, a `-1` (8vb) clef an octave +/// *higher*. A percussion clef has no diatonic mapping and reports its own line +/// (a neutral mid-staff position) for any pitch. +pub fn staff_position(nominal: CmnNominal, octave: i8, clef: &Clef) -> StaffStep { + let reference_line_step = (clef.line as i32 - 1) * 2; + match clef_reference_diatonic(clef.shape) { + Some(reference_diatonic) => { + reference_line_step + (diatonic_index(nominal, octave) - reference_diatonic) + - clef.octave_shift as i32 * 7 + } + None => reference_line_step, + } +} + +/// The SMuFL notehead glyph for a note value: a hollow whole/half notehead, else +/// the filled black notehead. +pub fn notehead_glyph(value: NoteValue) -> &'static str { + match value { + NoteValue::Whole => "noteheadWhole", + NoteValue::Half => "noteheadHalf", + _ => "noteheadBlack", + } +} + +/// The SMuFL rest glyph for a note value, if one is bundled. Only whole/half/ +/// quarter/eighth rests ship in the bundled metrics; a sixteenth-or-shorter rest +/// reports `None` so the caller surfaces the missing glyph coverage rather than +/// misrendering it as an eighth rest. +pub fn rest_glyph(value: NoteValue) -> Option<&'static str> { + Some(match value { + NoteValue::Whole => "restWhole", + NoteValue::Half => "restHalf", + NoteValue::Quarter => "restQuarter", + NoteValue::Eighth => "rest8th", + _ => return None, + }) +} + +/// Whether a note value is drawn with a stem — every value but the whole note. +pub fn has_stem(value: NoteValue) -> bool { + !matches!(value, NoteValue::Whole) +} + +/// The SMuFL flag glyph for an *unbeamed* stemmed note value, if one is bundled. +/// Only the eighth-note flag ships in the bundled metrics; shorter values need +/// their own flag glyphs (or beaming, deferred past I-1) and report `None`. +pub fn flag_glyph(value: NoteValue, stem: StemDirection) -> Option<&'static str> { + match value { + NoteValue::Eighth => Some(match stem { + StemDirection::Up => "flag8thUp", + StemDirection::Down => "flag8thDown", + }), + _ => None, + } +} + +/// The SMuFL clef glyph for a clef shape, if one is bundled. A percussion clef +/// reports `None` until its glyph is bundled — returning a G clef for it would +/// be a semantic false positive, so the caller surfaces the gap instead. +pub fn clef_glyph(shape: ClefShape) -> Option<&'static str> { + Some(match shape { + ClefShape::G => "gClef", + ClefShape::F => "fClef", + ClefShape::C => "cClef", + ClefShape::Percussion => return None, + }) +} + +/// The SMuFL accidental glyph for a spelling accidental, if one is bundled. +/// `None` for an accidental the bundled metrics don't carry (e.g. microtonal), +/// which the caller surfaces rather than papers over. +pub fn accidental_glyph(accidental: &AccidentalId) -> Option<&'static str> { + Some(match accidental.as_str() { + "sharp" => "accidentalSharp", + "flat" => "accidentalFlat", + "natural" => "accidentalNatural", + "doublesharp" | "double-sharp" => "accidentalDoubleSharp", + _ => return None, + }) +} + +/// One accidental in a key signature: the SMuFL glyph and the staff position it +/// occupies under the active clef. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct KeyAccidental { + pub glyph: &'static str, + pub position: StaffStep, +} + +// The conventional staff-step offsets, from the first accidental, of the +// key-signature "zigzag" — invariant across clefs (only the start position is +// clef-dependent). Sharp order F C G D A E B; flat order B E A D G C F. +const SHARP_OFFSETS: [StaffStep; 7] = [0, -3, 1, -2, -5, -1, -4]; +const FLAT_OFFSETS: [StaffStep; 7] = [0, 3, -1, 2, -2, 1, -3]; + +/// The staff position of the first key-signature accidental of `letter` under +/// `clef`: the octave of `letter` whose position is closest to the conventional +/// band (sharps high, near the top line; flats mid, near the middle line), +/// breaking ties toward the higher position. This reproduces the standard +/// treble and bass placements; other clefs follow the same principle. +fn first_accidental_position(letter: CmnNominal, clef: &Clef, sharp: bool) -> StaffStep { + let target = if sharp { 8 } else { 4 }; + (-2i8..=10) + .map(|octave| staff_position(letter, octave, clef)) + .min_by_key(|&p| ((p - target).abs(), -p)) + .unwrap_or(target) +} + +/// The ordered accidentals a key signature places under `clef`. A positive +/// `key.fifths()` places that many sharps (order F C G D A E B); a negative one +/// places that many flats (order B E A D G C F); `0` (and a percussion clef) +/// places none. The count needs no clamping — [`KeySignature`] already +/// guarantees `fifths` is within the conventional `-7..=7` (I-0 invariant). +pub fn key_signature(key: KeySignature, clef: &Clef) -> Vec { + let fifths = key.fifths(); + if fifths == 0 || matches!(clef.shape, ClefShape::Percussion) { + return Vec::new(); + } + let count = fifths.unsigned_abs() as usize; + let (glyph, start, offsets) = if fifths > 0 { + ( + "accidentalSharp", + first_accidental_position(CmnNominal::F, clef, true), + &SHARP_OFFSETS, + ) + } else { + ( + "accidentalFlat", + first_accidental_position(CmnNominal::B, clef, false), + &FLAT_OFFSETS, + ) + }; + (0..count) + .map(|i| KeyAccidental { + glyph, + position: start + offsets[i], + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn staff_position_is_diatonic_and_clef_relative() { + let treble = Clef::treble(); + // Treble: E4 is the bottom line (0); G4 the second line (2); F5 the top + // line (8); A4 the second space (3); C4 a ledger below (−2). + assert_eq!(staff_position(CmnNominal::E, 4, &treble), 0); + assert_eq!(staff_position(CmnNominal::G, 4, &treble), 2); + assert_eq!(staff_position(CmnNominal::F, 5, &treble), 8); + assert_eq!(staff_position(CmnNominal::A, 4, &treble), 3); + assert_eq!(staff_position(CmnNominal::C, 4, &treble), -2); + + // Bass: F3 the fourth line (6); G2 the bottom line (0); middle C4 the + // first ledger above (10). + let bass = Clef::bass(); + assert_eq!(staff_position(CmnNominal::F, 3, &bass), 6); + assert_eq!(staff_position(CmnNominal::G, 2, &bass), 0); + assert_eq!(staff_position(CmnNominal::C, 4, &bass), 10); + + // Alto: middle C4 the middle line (4). Tenor: middle C4 the fourth line (6). + assert_eq!(staff_position(CmnNominal::C, 4, &Clef::alto()), 4); + assert_eq!(staff_position(CmnNominal::C, 4, &Clef::tenor()), 6); + } + + #[test] + fn accidentals_never_change_staff_position() { + // The position is purely diatonic — the same nominal+octave lands on the + // same line regardless of any accidental the spelling carries. + let treble = Clef::treble(); + let c_natural = staff_position(CmnNominal::C, 5, &treble); + let c_sharp = staff_position(CmnNominal::C, 5, &treble); // spelling differs, position must not + assert_eq!(c_natural, c_sharp); + } + + #[test] + fn octave_shift_sign_is_explicit() { + // 8va (+1) writes a sounding pitch an octave LOWER on the staff; 8vb (−1) + // an octave HIGHER. A sounding G5 under treble-8va sits where G4 sits on + // a plain treble clef (step 2); under treble-8vb, where G6 would (step 16). + let plain = Clef::treble(); + let up = Clef { + octave_shift: 1, + ..Clef::treble() + }; + let down = Clef { + octave_shift: -1, + ..Clef::treble() + }; + let plain_g5 = staff_position(CmnNominal::G, 5, &plain); // 9 + assert_eq!(plain_g5, 9); + assert_eq!(staff_position(CmnNominal::G, 5, &up), plain_g5 - 7); + assert_eq!(staff_position(CmnNominal::G, 5, &down), plain_g5 + 7); + } + + #[test] + fn note_value_glyphs() { + assert_eq!(notehead_glyph(NoteValue::Whole), "noteheadWhole"); + assert_eq!(notehead_glyph(NoteValue::Half), "noteheadHalf"); + assert_eq!(notehead_glyph(NoteValue::Quarter), "noteheadBlack"); + assert_eq!(notehead_glyph(NoteValue::Sixteenth), "noteheadBlack"); + assert_eq!(rest_glyph(NoteValue::Whole), Some("restWhole")); + assert_eq!(rest_glyph(NoteValue::Eighth), Some("rest8th")); + assert_eq!(rest_glyph(NoteValue::Sixteenth), None); + assert!(!has_stem(NoteValue::Whole)); + assert!(has_stem(NoteValue::Quarter)); + assert_eq!(flag_glyph(NoteValue::Quarter, StemDirection::Up), None); + assert_eq!( + flag_glyph(NoteValue::Eighth, StemDirection::Up), + Some("flag8thUp") + ); + assert_eq!( + flag_glyph(NoteValue::Eighth, StemDirection::Down), + Some("flag8thDown") + ); + } + + #[test] + fn clef_and_accidental_glyphs() { + assert_eq!(clef_glyph(ClefShape::G), Some("gClef")); + assert_eq!(clef_glyph(ClefShape::F), Some("fClef")); + assert_eq!(clef_glyph(ClefShape::C), Some("cClef")); + assert_eq!(clef_glyph(ClefShape::Percussion), None); + assert_eq!( + accidental_glyph(&AccidentalId::new("sharp")), + Some("accidentalSharp") + ); + assert_eq!( + accidental_glyph(&AccidentalId::new("flat")), + Some("accidentalFlat") + ); + assert_eq!( + accidental_glyph(&AccidentalId::new("natural")), + Some("accidentalNatural") + ); + assert_eq!(accidental_glyph(&AccidentalId::new("quarter-sharp")), None); + } + + #[test] + fn key_signature_positions_match_the_conventional_pattern() { + let key = |fifths: i8| KeySignature::new(fifths).expect("fifths in range"); + + // C major / A minor: nothing. + assert!(key_signature(key(0), &Clef::treble()).is_empty()); + + // Treble sharps F C G D A E B → steps 8 5 9 6 3 7 4. + let treble_sharps: Vec = key_signature(key(7), &Clef::treble()) + .iter() + .map(|a| a.position) + .collect(); + assert_eq!(treble_sharps, vec![8, 5, 9, 6, 3, 7, 4]); + assert!(key_signature(key(7), &Clef::treble()) + .iter() + .all(|a| a.glyph == "accidentalSharp")); + + // Treble flats B E A D G C F → steps 4 7 3 6 2 5 1. + let treble_flats: Vec = key_signature(key(-7), &Clef::treble()) + .iter() + .map(|a| a.position) + .collect(); + assert_eq!(treble_flats, vec![4, 7, 3, 6, 2, 5, 1]); + assert!(key_signature(key(-7), &Clef::treble()) + .iter() + .all(|a| a.glyph == "accidentalFlat")); + + // Bass: first sharp F#3 on the fourth line (6); first flat B♭2 on the + // second line (2). + assert_eq!(key_signature(key(1), &Clef::bass())[0].position, 6); + assert_eq!(key_signature(key(-1), &Clef::bass())[0].position, 2); + + // A two-sharp signature places exactly two accidentals. + assert_eq!(key_signature(key(2), &Clef::treble()).len(), 2); + } +} diff --git a/crates/epiphany-layout-ir/src/glyph.rs b/crates/epiphany-layout-ir/src/glyph.rs index 8d94e23..1c26791 100644 --- a/crates/epiphany-layout-ir/src/glyph.rs +++ b/crates/epiphany-layout-ir/src/glyph.rs @@ -213,8 +213,20 @@ pub const BRAVURA_METRICS: &[GlyphMetric] = &[ GlyphMetric::new("flag8thUp", 1007, [0, -84, 1007, 2607]), GlyphMetric::new("flag8thDown", 1007, [0, -2607, 1007, 84]), GlyphMetric::new("augmentationDot", 400, [0, -154, 308, 154]), - GlyphMetric::new("timeSig4", 1280, [40, 0, 1240, 2048]), - GlyphMetric::new("timeSigCommon", 1480, [80, 0, 1400, 2048]), + // Time-signature digits and the common-time C, with their genuine Bravura + // advances and tight bounding boxes (centred on the baseline, y ≈ ±1), from + // `tools/extract_bravura_outlines.py` — kept consistent with the outlines. + GlyphMetric::new("timeSig0", 1925, [82, -1024, 1843, 1028]), + GlyphMetric::new("timeSig1", 1368, [82, -1024, 1286, 1028]), + GlyphMetric::new("timeSig2", 1827, [82, -1053, 1745, 1040]), + GlyphMetric::new("timeSig3", 1724, [82, -1028, 1642, 1020]), + GlyphMetric::new("timeSig4", 1925, [82, -1024, 1843, 1028]), + GlyphMetric::new("timeSig5", 1651, [82, -1028, 1569, 1008]), + GlyphMetric::new("timeSig6", 1778, [82, -1020, 1696, 1028]), + GlyphMetric::new("timeSig7", 1806, [82, -1024, 1724, 1020]), + GlyphMetric::new("timeSig8", 1786, [82, -1061, 1704, 1061]), + GlyphMetric::new("timeSig9", 1778, [82, -1020, 1696, 1028]), + GlyphMetric::new("timeSigCommon", 1737, [20, -1020, 1737, 1028]), GlyphMetric::new("barlineSingle", 160, [0, -2048, 160, 2048]), GlyphMetric::new("barlineFinal", 620, [0, -2048, 620, 2048]), GlyphMetric::new("dynamicForte", 1480, [0, -706, 1480, 1565]), diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index 34a8704..cd2c51b 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -67,6 +67,7 @@ pub mod barrier; pub mod cache; pub mod constrained; +pub mod engrave_theory; pub mod engraving; pub mod glyph; pub mod logical; @@ -91,7 +92,11 @@ pub use constrained::{ to_constrained, try_to_constrained, Axis, BreakKind, ConstrainedLayoutIR, ConstrainedLayoutRegion, ConstrainedValidationError, ConstraintParameters, ConstraintRegistryId, GlyphObject, GlyphObjectId, GlyphStyle, LayoutConstraint, - LayoutTransformError, SpringSlot, + LayoutTransformError, SpringSlot, Stroke, +}; +pub use engrave_theory::{ + accidental_glyph, clef_glyph, flag_glyph, has_stem, key_signature, notehead_glyph, rest_glyph, + staff_position, KeyAccidental, StaffStep, }; pub use engraving::{ AuthorId, DecisionSource, EngravingDecision, EngravingDecisionId, EngravingDecisionKind, @@ -104,11 +109,14 @@ pub use glyph::{ GlyphRenderData, PathCommand, SemVer, SmuflVersion, BRAVURA_METRICS, BRAVURA_VERSION, }; pub use logical::{ - to_logical, BarLineLayout, BeamGroupLayout, ChordLayout, ClefLayout, CompositeLayoutObject, - CrossRegionObject, CueLayout, GraphicLayout, GroupLayout, KeySignatureLayout, LayoutObject, - LayoutRegion, LocalCoordinateSystem, LogicalLayoutIR, MarkerLayout, MultimeasureRestLayout, - NoteLayout, RestLayout, ScoreVersion, SlurLayout, SpannerLayout, StaffLayout, TextLayout, - TieLayout, TimeSignatureDisplayLayout, TrajectoryLayout, TupletDisplayLayout, VerticalExtent, + to_logical, BarLineLayout, BarlineKind, BeamGroupLayout, ChordLayout, ClefLayout, + 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, }; pub use provenance::{ manifestation_layout_id, stable_layout_id, synthesized_layout_id, LayoutObjectId, Provenance, diff --git a/crates/epiphany-layout-ir/src/logical.rs b/crates/epiphany-layout-ir/src/logical.rs index 0aa80d4..c1e0fef 100644 --- a/crates/epiphany-layout-ir/src/logical.rs +++ b/crates/epiphany-layout-ir/src/logical.rs @@ -13,26 +13,156 @@ //! change should invalidate it, Chapter 7 §7.1's requirement), and that //! provenance survives the whole pipeline. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; -use epiphany_core::{AnnotationAnchor, RegionId, Score, StaffId, TimeAnchor, TypedObjectId}; +use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile}; +use epiphany_core::{ + AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, Clef, CoordinateDiscipline, + Event, EventId, EventPosition, KeySignature, MeasurePosition, MusicalDuration, MusicalPosition, + NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge, RegionId, RegionTimeModel, Score, + StaffId, StaffPosition, TimeAnchor, TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId, + WallClockTime, +}; use epiphany_determinism::{DomainTag, Preimage}; use crate::engraving::{EngravingDecision, EngravingDecisionKind, EngravingOverride}; use crate::provenance::{LayoutObjectId, Provenance}; use crate::spatial::Transform2D; -use crate::time_axis::{time_axis_of, TimeAxisModel}; +use crate::time_axis::{time_axis_of, TimeAxisModel, TimePoint}; -/// A structural layout object before spacing (Chapter 7 §"Layout Objects"). v0 -/// carries its [`Provenance`] and the staff it belongs to (used to route it to -/// the correct vertical band); the composite glyph content is materialized at -/// the [`crate::ConstrainedLayoutIR`] stage. +/// 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. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub enum LayoutContent { + /// No engraving content beyond provenance/staff. + #[default] + Structural, + /// A staff instance's resolved clef and key-signature *sequences*; the + /// constrained pass chooses the active clef/key per position and defaults to + /// treble / C major when a sequence is empty. + Staff(StaffContent), + /// A note or chord: its note value and spelled pitches (one notehead each). + Note(NoteContent), + /// A rest: its note value and optional explicit staff position. + Rest(RestContent), + /// A measure: whether it ends the staff (a final barline) and the time + /// signature in force, when this measure introduces one. + Measure(MeasureContent), +} + +/// The clef and key-signature sequences in force across a staff instance, +/// carried at resolved [`TimePoint`]s so the constrained pass can choose the +/// *active* clef/key at any position without going back to the score graph. +/// Empty sequences mean the score declares none — the constrained pass then +/// defaults to treble / C major. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct StaffContent { + pub clefs: Vec, + pub keys: Vec, +} + +/// A clef change with its score anchor resolved into the layout time axis. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PlacedClef { + pub time: TimePoint, + pub clef: Clef, +} + +/// A key-signature change with its score anchor resolved into the layout time +/// axis. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PlacedKeySignature { + pub time: TimePoint, + pub key: KeySignature, +} + +/// A note or chord's notated content: its resolved start position, its placed +/// notated components (one notehead/tie segment each, at successive offsets — a +/// multi-component decomposition is *not* collapsed), and its spelled pitches. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NoteContent { + pub position: TimePoint, + pub components: Vec, + pub pitches: Vec, +} + +/// One notated component placed within a note or rest: its offset from the +/// owning event's start position, the component itself (base value, dots, tuplet +/// membership, tie), and the resolved tuplet ratio when it is in a tuplet (the +/// `TupletId` inside the component does not carry the ratio). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PlacedComponent { + pub offset: MusicalDuration, + pub component: NotatedComponent, + pub tuplet: Option, +} + +/// One pitch of a note — its identity (for the notehead's provenance) and its +/// resolved spelling, or `None` when the pre-pass produced none. A `None` +/// spelling is *preserved*, not dropped, so the constrained pass surfaces a +/// missing-spelling diagnostic rather than silently losing musical content. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NotePitch { + pub pitch: PitchId, + pub spelling: Option, +} + +/// A rest's notated content: its resolved start position, its placed notated +/// components, and any explicit vertical position. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct RestContent { + pub position: TimePoint, + pub components: Vec, + pub staff_position: Option, +} + +/// A measure's notated content: its resolved start position, which barline ends +/// it, and the time signature it introduces, if any. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct MeasureContent { + pub start: TimePoint, + pub barline: BarlineKind, + pub time_signature: Option, +} + +/// Which barline ends a measure. A staff manifested across several regions +/// continues at each region boundary, so only the last measure of the *last* +/// region manifesting the staff is truly [`Final`](BarlineKind::Final). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum BarlineKind { + /// A measure within the staff's run. + Interior, + /// The last measure of this staff instance in this region; the staff + /// continues in a later region. + RegionEnd, + /// The last measure of the last region manifesting this staff (the true end). + Final, +} + +/// A time signature reduced to its displayed numerator and denominator. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct TimeSignatureContent { + pub numerator: u16, + pub denominator: u16, +} + +/// A structural layout object before spacing (Chapter 7 §"Layout Objects"). It +/// carries its [`Provenance`], the staff it belongs to (used to route it to the +/// correct vertical band), and its [`LayoutContent`] (the engraving payload the +/// constrained pass materializes into glyphs). #[derive(Clone, PartialEq, Eq, Debug)] pub struct CompositeLayoutObject { pub provenance: Provenance, /// The staff this object belongs to, or `None` for region-level and /// score-level (cross-cutting / free-graphic) objects. pub staff: Option, + /// The engraving content materialized into glyphs at the constrained stage. + pub content: LayoutContent, } pub type NoteLayout = CompositeLayoutObject; @@ -86,7 +216,11 @@ pub enum LayoutObject { impl LayoutObject { pub fn from_projection(provenance: Provenance, staff: Option) -> Self { - let payload = CompositeLayoutObject { provenance, staff }; + let payload = CompositeLayoutObject { + provenance, + staff, + content: LayoutContent::Structural, + }; match payload.provenance.source { TypedObjectId::Event(_) | TypedObjectId::Pitch(_) => LayoutObject::Note(payload), TypedObjectId::Beam(_) => LayoutObject::BeamGroup(payload), @@ -110,16 +244,32 @@ impl LayoutObject { } } + /// Projects an object and attaches its engraving content in one step. + pub fn from_projection_with_content( + provenance: Provenance, + staff: Option, + content: LayoutContent, + ) -> Self { + let mut object = Self::from_projection(provenance, staff); + object.payload_mut().content = content; + object + } + pub fn provenance(&self) -> &Provenance { - self.payload().0 + &self.payload().provenance } pub fn staff(&self) -> Option { - self.payload().1 + self.payload().staff } - fn payload(&self) -> (&Provenance, Option) { - let payload = match self { + /// The engraving content of this object (authoritative over the variant). + pub fn content(&self) -> &LayoutContent { + &self.payload().content + } + + fn payload(&self) -> &CompositeLayoutObject { + match self { LayoutObject::Note(value) | LayoutObject::Chord(value) | LayoutObject::Rest(value) @@ -140,8 +290,32 @@ impl LayoutObject { | LayoutObject::Cue(value) | LayoutObject::Trajectory(value) | LayoutObject::Group(value) => value, - }; - (&payload.provenance, payload.staff) + } + } + + fn payload_mut(&mut self) -> &mut CompositeLayoutObject { + match self { + LayoutObject::Note(value) + | LayoutObject::Chord(value) + | LayoutObject::Rest(value) + | LayoutObject::BeamGroup(value) + | LayoutObject::TupletDisplay(value) + | LayoutObject::Slur(value) + | LayoutObject::Tie(value) + | LayoutObject::Spanner(value) + | LayoutObject::Marker(value) + | LayoutObject::BarLine(value) + | LayoutObject::Clef(value) + | LayoutObject::KeySignature(value) + | LayoutObject::TimeSignatureDisplay(value) + | LayoutObject::Staff(value) + | LayoutObject::Text(value) + | LayoutObject::Graphic(value) + | LayoutObject::MultimeasureRest(value) + | LayoutObject::Cue(value) + | LayoutObject::Trajectory(value) + | LayoutObject::Group(value) => value, + } } } @@ -222,54 +396,116 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { let mut engraving_decisions = Vec::new(); let mut cross_region = Vec::new(); let mut seen: BTreeSet = BTreeSet::new(); + // The resolved spellings and decompositions the notation engraving consumes + // (Agent H's pre-pass): which notehead a note draws, where its pitches sit, + // and which accidentals its spelling carries. Recomputed deterministically + // from the score with the default profile. + let annotations = derive_annotations(score, &PrePassProfile::default()); + // The last region index that manifests each staff, so a measure can tell a + // mid-staff region boundary (continuation) from the true final barline. + let mut staff_last_region: BTreeMap = BTreeMap::new(); + for (index, region) in score.canvas.regions.iter().enumerate() { + for staff_id in ®ion.staff_extent.staves { + staff_last_region.insert(*staff_id, index); + } + } - for region in &score.canvas.regions { + for (region_index, region) in score.canvas.regions.iter().enumerate() { let region_id = region.id; let mut objects = Vec::new(); - let mut push = - |source: TypedObjectId, dependencies: Vec, staff: Option| { - let provenance = Provenance::manifested(source, region_id, dependencies); - if seen.insert(provenance.stable_id) { - objects.push(LayoutObject::from_projection(provenance, staff)); - } - }; + let mut push = |source: TypedObjectId, + dependencies: Vec, + staff: Option, + content: LayoutContent| { + let provenance = Provenance::manifested(source, region_id, dependencies); + if seen.insert(provenance.stable_id) { + objects.push(LayoutObject::from_projection_with_content( + provenance, staff, content, + )); + } + }; // Staves manifested in this region (via the staff extent). for staff_id in ®ion.staff_extent.staves { - push(TypedObjectId::Staff(*staff_id), vec![], Some(*staff_id)); + push( + TypedObjectId::Staff(*staff_id), + vec![], + Some(*staff_id), + LayoutContent::Structural, + ); } // Staff instances, voices, and their events + pitches — all belong to - // the instance's staff. + // the instance's staff. The staff instance carries the clef/key in force. for si in region.staff_instances() { let staff = Some(si.staff); let si_src = TypedObjectId::StaffInstance(si.id); - push(si_src, vec![TypedObjectId::Staff(si.staff)], staff); + let mut si_deps = vec![TypedObjectId::Staff(si.staff)]; + si_deps.extend( + si.clef_sequence + .iter() + .filter_map(|change| time_anchor_dep(&change.anchor)), + ); + si_deps.extend( + si.key_sequence + .iter() + .filter_map(|change| time_anchor_dep(&change.anchor)), + ); + push(si_src, si_deps, staff, staff_content(score, si)); for voice in &si.voices { let v_src = TypedObjectId::Voice(voice.id); - push(v_src, vec![si_src], staff); + push(v_src, vec![si_src], staff, LayoutContent::Structural); for eid in &voice.events { let e_src = TypedObjectId::Event(*eid); // The event's pitches become its invalidation dependencies. let pitches = identified_pitch_ids(score, *eid); let mut deps = vec![v_src]; deps.extend(pitches.iter().copied().map(TypedObjectId::Pitch)); - push(e_src, deps, staff); - // And the pitches themselves, as their own objects. + // The event carries the notated content (note value + spelled + // pitches); the per-pitch objects are structural provenance. + push(e_src, deps, staff, event_content(score, *eid, &annotations)); for pid in pitches { - push(TypedObjectId::Pitch(pid), vec![e_src], staff); + push( + TypedObjectId::Pitch(pid), + vec![e_src], + staff, + LayoutContent::Structural, + ); } } } } - // Measures, per staff instance (Chapter 5 §"Measures"). + // Measures, per staff instance (Chapter 5 §"Measures"). The last measure + // of an instance ends this region's run; it is the true final barline only + // when this is the last region manifesting the staff. for si in region.staff_instances() { - for measure in &si.measures { + let last = si.measures.len().saturating_sub(1); + let staff_ends_here = staff_last_region.get(&si.staff) == Some(®ion_index); + for (index, measure) in si.measures.iter().enumerate() { + let barline = if index != last { + BarlineKind::Interior + } else if staff_ends_here { + BarlineKind::Final + } else { + BarlineKind::RegionEnd + }; + // The measure depends on its staff instance, the time signature it + // displays (so a display change with the same id invalidates the + // measure and its synthesized time-signature glyphs), and whatever + // its start anchor resolves through. + let mut measure_deps = vec![TypedObjectId::StaffInstance(si.id)]; + if let Some(time_signature) = measure.time_signature { + measure_deps.push(TypedObjectId::TimeSignature(time_signature)); + } + if let Some(anchor_dep) = time_anchor_dep(&measure.start) { + measure_deps.push(anchor_dep); + } push( TypedObjectId::Measure(measure.id), - vec![TypedObjectId::StaffInstance(si.id)], + measure_deps, Some(si.staff), + measure_content(score, measure, barline), ); } } @@ -278,7 +514,12 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { // Content"; Chapter 7 §"Region Uniformity"). These are region-level, not // staff-owned. for go in region.content.graphic_objects() { - push(TypedObjectId::GraphicObject(go.id), vec![], None); + push( + TypedObjectId::GraphicObject(go.id), + vec![], + None, + LayoutContent::Structural, + ); } let r_src = TypedObjectId::Region(region.id); @@ -393,6 +634,296 @@ fn derive_score_version(score: &Score) -> ScoreVersion { ScoreVersion(*preimage.finish().as_bytes()) } +/// The clef and key-signature sequences of a staff instance, carried with +/// resolved layout times. Empty sequences (a score that declares no clef/key) +/// are carried as-is — the constrained pass defaults the *active* clef/key to +/// treble / C major. +fn staff_content(score: &Score, si: &epiphany_core::StaffInstance) -> LayoutContent { + LayoutContent::Staff(StaffContent { + clefs: si + .clef_sequence + .iter() + .map(|change| PlacedClef { + time: resolve_time_anchor(score, &change.anchor), + clef: change.clef, + }) + .collect(), + keys: si + .key_sequence + .iter() + .map(|change| PlacedKeySignature { + time: resolve_time_anchor(score, &change.anchor), + key: change.key, + }) + .collect(), + }) +} + +/// The notated content of an event: a note (its position, decomposition, and +/// spelled pitches) for a pitched event, a rest for a rest, and structural for +/// the kinds this Minimal slice does not yet engrave (unpitched / indeterminate +/// / trajectory / graphic / cue). Every pitch is kept; an unspelled one carries +/// `spelling: None` rather than being dropped. +fn event_content(score: &Score, event: EventId, annotations: &DerivedAnnotations) -> LayoutContent { + let Some(graph_event) = score.events.get(event) else { + return LayoutContent::Structural; + }; + let components = placed_components(score, components_of(annotations, event)); + match graph_event { + Event::Pitched(pitched) => { + let pitches = pitched + .pitches + .iter() + .map(|identified| NotePitch { + pitch: identified.id, + spelling: annotations + .spellings + .get(&identified.id) + .map(|resolved| resolved.spelling.clone()), + }) + .collect(); + LayoutContent::Note(NoteContent { + position: event_time(&pitched.position), + components, + pitches, + }) + } + Event::Rest(rest) => LayoutContent::Rest(RestContent { + position: event_time(&rest.position), + components, + staff_position: rest.vertical_position, + }), + _ => LayoutContent::Structural, + } +} + +/// An event's concrete position as a layout [`TimePoint`] (the two share the +/// musical/wall-clock shape). +fn event_time(position: &EventPosition) -> TimePoint { + match position { + EventPosition::Musical(p) => TimePoint::Musical(p.clone()), + EventPosition::WallClock(t) => TimePoint::WallClock(*t), + } +} + +/// Places each notated component at its successive offset from the event start, +/// resolving its tuplet ratio. The offset of a component is the summed sounding +/// duration of the components before it (base value × dot factor × tuplet +/// scale), so a multi-component (e.g. tied-across-a-barline) note yields separate +/// noteheads at the right positions. +fn placed_components(score: &Score, components: Vec) -> Vec { + let mut placed = Vec::with_capacity(components.len()); + let mut offset = MusicalDuration::zero(); + for component in components { + let tuplet = component.tuplet.and_then(|id| tuplet_ratio(score, id)); + let duration = component_duration(&component, tuplet); + placed.push(PlacedComponent { + offset: offset.clone(), + component, + tuplet, + }); + offset = offset + duration; + } + placed +} + +/// The resolved ratio of a tuplet, looked up by id in the score's cross-cutting +/// registry. +fn tuplet_ratio(score: &Score, id: TupletId) -> Option { + score + .cross_cutting + .tuplets + .iter() + .find(|tuplet| tuplet.id == id) + .map(|tuplet| tuplet.ratio) +} + +/// The sounding duration of a notated component. The core graph model owns the +/// exact dotted-duration semantics, including large dot counts that require +/// arbitrary precision, so layout delegates instead of duplicating the math. +fn component_duration( + component: &NotatedComponent, + tuplet: Option, +) -> MusicalDuration { + component.sounding_duration(tuplet) +} + +/// Resolves a [`TimeAnchor`] to a concrete layout [`TimePoint`] for placement. +/// Event anchors use the event's own region-local position plus the anchor +/// offset; measure anchors recurse through the referenced measure boundary; and +/// region anchors resolve to the referenced region edge in that region's local +/// time discipline. Cycles, missing targets, unknown metric region ends, and +/// clock-mismatched offsets fall back to the musical origin — surfaced as a +/// Minimal-slice boundary rather than panicking or inventing a false coordinate. +fn resolve_time_anchor(score: &Score, anchor: &TimeAnchor) -> TimePoint { + const DEPTH: u8 = 16; + resolve_time_anchor_inner(score, anchor, DEPTH).unwrap_or_else(origin_time) +} + +fn resolve_time_anchor_inner(score: &Score, anchor: &TimeAnchor, depth: u8) -> Option { + if depth == 0 { + return None; + } + match anchor { + TimeAnchor::WallClock { time } => Some(TimePoint::WallClock(*time)), + TimeAnchor::Event { id, offset } => { + let event = score.events.get(*id)?; + apply_offset(event_time(event.position()), offset) + } + TimeAnchor::Measure { + id, + position, + offset, + } => { + let base = measure_anchor_time(score, *id, *position, depth - 1)?; + apply_offset(base, offset) + } + TimeAnchor::Region { id, edge, offset } => { + let region = score + .canvas + .regions + .iter() + .find(|region| region.id == *id)?; + let base = region_edge_time(region, *edge, offset)?; + apply_offset(base, offset) + } + } +} + +fn apply_offset(base: TimePoint, offset: &AnchorOffset) -> Option { + match (base, offset) { + (base, AnchorOffset::Zero) => Some(base), + (TimePoint::Musical(position), AnchorOffset::Musical(duration)) => { + Some(TimePoint::Musical(position + duration.clone())) + } + (TimePoint::WallClock(time), AnchorOffset::WallClock(duration)) => time + .0 + .checked_add(duration.0) + .map(WallClockTime) + .map(TimePoint::WallClock), + _ => None, + } +} + +fn measure_anchor_time( + score: &Score, + id: epiphany_core::MeasureId, + position: MeasurePosition, + depth: u8, +) -> Option { + for (_, instance) in score.staff_instances() { + let Some(index) = instance + .measures + .iter() + .position(|measure| measure.id == id) + else { + continue; + }; + return match position { + MeasurePosition::Start => { + resolve_time_anchor_inner(score, &instance.measures[index].start, depth) + } + MeasurePosition::End => instance + .measures + .get(index + 1) + .and_then(|next| resolve_time_anchor_inner(score, &next.start, depth)), + }; + } + None +} + +fn region_edge_time(region: &Region, edge: RegionEdge, offset: &AnchorOffset) -> Option { + match edge { + RegionEdge::Start => Some(region_origin_time(region, offset)), + RegionEdge::End => region_end_time(region), + } +} + +fn region_origin_time(region: &Region, offset: &AnchorOffset) -> TimePoint { + match offset { + AnchorOffset::Musical(_) => TimePoint::Musical(MusicalPosition::origin()), + AnchorOffset::WallClock(_) => TimePoint::WallClock(WallClockTime(0)), + AnchorOffset::Zero => match region.time_model.coordinate_discipline() { + CoordinateDiscipline::Musical => TimePoint::Musical(MusicalPosition::origin()), + CoordinateDiscipline::WallClock => TimePoint::WallClock(WallClockTime(0)), + CoordinateDiscipline::Aleatoric(AleatoricAnchoringDiscipline::WallClock) => { + TimePoint::WallClock(WallClockTime(0)) + } + CoordinateDiscipline::Aleatoric(_) => TimePoint::Musical(MusicalPosition::origin()), + }, + } +} + +fn region_end_time(region: &Region) -> Option { + match ®ion.time_model { + RegionTimeModel::Proportional(model) => { + Some(TimePoint::WallClock(WallClockTime(model.duration.0))) + } + _ => None, + } +} + +/// The musical origin as a [`TimePoint`] (the placement fallback). +fn origin_time() -> TimePoint { + TimePoint::Musical(MusicalPosition::origin()) +} + +/// The full notated decomposition of an event (base values, dots, tuplets, ties) +/// from the pre-pass; empty when the event has no decomposition (non-metric or +/// ineligible) — the constrained pass surfaces that rather than inventing a value. +fn components_of(annotations: &DerivedAnnotations, event: EventId) -> Vec { + annotations + .decompositions + .get(&event) + .map(|decomposition| decomposition.components.clone()) + .unwrap_or_default() +} + +/// The notated content of a measure: its start anchor, its ending barline, and +/// the time signature it introduces, resolved to numerator/denominator when +/// standard or irrational (compound / mixed / symbolic meters are not engraved +/// in I-1). +fn measure_content( + score: &Score, + measure: &epiphany_core::Measure, + barline: BarlineKind, +) -> LayoutContent { + let time_signature = measure + .time_signature + .and_then(|id| time_signature_content(score, id)); + LayoutContent::Measure(MeasureContent { + start: resolve_time_anchor(score, &measure.start), + barline, + time_signature, + }) +} + +/// Resolves a time-signature id to its displayed numerator/denominator, for the +/// meter shapes I-1 engraves. +fn time_signature_content( + score: &Score, + id: epiphany_core::TimeSignatureId, +) -> Option { + let signature = score.time_signatures.iter().find(|t| t.id == id)?; + match &signature.display { + TimeSignatureDisplay::Standard { + numerator, + denominator, + } => Some(TimeSignatureContent { + numerator: *numerator, + denominator: denominator.get(), + }), + TimeSignatureDisplay::Irrational { + numerator, + denominator, + } => Some(TimeSignatureContent { + numerator: *numerator, + denominator: denominator.get(), + }), + _ => None, + } +} + /// The identified-pitch ids of an event, in arena order (empty if the event is /// absent or carries no pitches). pub(crate) fn identified_pitch_ids( @@ -534,7 +1065,211 @@ pub(crate) fn cross_cutting_objects(score: &Score) -> Vec<(TypedObjectId, Vec MusicalDuration { + MusicalDuration(RationalTime::new(numerator, denominator).expect("nonzero")) + } + + fn position(numerator: i64, denominator: i64) -> MusicalPosition { + MusicalPosition(RationalTime::new(numerator, denominator).expect("nonzero")) + } + + #[test] + fn to_logical_enriches_notes_staves_and_measures() { + let score = valid_score_rich(7); + let ir = to_logical(&score); + let objects: Vec<&LayoutObject> = ir + .regions + .iter() + .flat_map(|region| region.objects.iter()) + .collect(); + + // A pitched event projects a note carrying its decomposition and at least + // one spelled pitch (and its position is recorded). + let spelled_note = objects.iter().any(|object| { + matches!(object.content(), LayoutContent::Note(note) + if !note.components.is_empty() + && note.pitches.iter().any(|pitch| pitch.spelling.is_some())) + }); + assert!( + spelled_note, + "expected an enriched note with a decomposition and a spelled pitch" + ); + + // Staff instances carry their clef/key sequences. valid_score declares no + // clef, so the sequences are empty (the constrained pass defaults them to + // treble / C major). + let staves: Vec<&StaffContent> = objects + .iter() + .filter_map(|object| match object.content() { + LayoutContent::Staff(staff) => Some(staff), + _ => None, + }) + .collect(); + assert!(!staves.is_empty(), "staff instances carry staff content"); + assert!( + staves.iter().all(|staff| staff.clefs.is_empty()), + "a score with no declared clef carries an empty clef sequence" + ); + + // Measures project measure content carrying a start anchor; the last + // region manifesting each staff ends with a true Final barline (never + // every region end). + let measures: Vec<&MeasureContent> = objects + .iter() + .filter_map(|object| match object.content() { + LayoutContent::Measure(measure) => Some(measure), + _ => None, + }) + .collect(); + assert!(!measures.is_empty(), "measures project measure content"); + assert!( + measures + .iter() + .any(|measure| measure.barline == BarlineKind::Final), + "the staff's last region ends with a final barline" + ); + } + + #[test] + fn placed_components_accumulate_successive_offsets() { + // A note notated as a quarter tied to an eighth: the second component + // starts a quarter-note's duration after the first (offsets are summed, + // not collapsed). + let score = valid_score(1); + let components = vec![ + NotatedComponent { + base_value: NoteValue::Quarter, + dots: 0, + tuplet: None, + tied_to_next: true, + }, + NotatedComponent { + base_value: NoteValue::Eighth, + dots: 0, + tuplet: None, + tied_to_next: false, + }, + ]; + let placed = placed_components(&score, components); + assert_eq!(placed.len(), 2); + assert_eq!(placed[0].offset, MusicalDuration::zero()); + assert_eq!(placed[1].offset, duration(1, 4)); + } + + #[test] + fn placed_components_uses_core_duration_for_large_dot_counts() { + let score = valid_score(1); + let component = NotatedComponent { + base_value: NoteValue::SixtyFourth, + dots: 80, + tuplet: None, + tied_to_next: true, + }; + let placed = placed_components(&score, vec![component.clone(), component.clone()]); + assert_eq!(placed.len(), 2); + assert_eq!(placed[1].offset, component.sounding_duration(None)); + } + + #[test] + fn resolve_time_anchor_applies_event_offsets() { + let score = valid_score(1); + let event = score + .events + .iter_canonical() + .find(|event| matches!(event.position(), EventPosition::Musical(_))) + .expect("valid_score contains musical events"); + let EventPosition::Musical(base) = event.position() else { + unreachable!("filtered for musical events"); + }; + let offset = duration(1, 4); + let resolved = resolve_time_anchor( + &score, + &TimeAnchor::Event { + id: event.id(), + offset: AnchorOffset::Musical(offset.clone()), + }, + ); + assert_eq!(resolved, TimePoint::Musical(base.clone() + offset)); + } + + #[test] + fn resolve_time_anchor_uses_referenced_region_edge() { + let score = valid_score_rich(7); + let (region_id, duration_ns) = score + .canvas + .regions + .iter() + .find_map(|region| match ®ion.time_model { + RegionTimeModel::Proportional(model) => Some((region.id, model.duration.0)), + _ => None, + }) + .expect("valid_score_rich contains a proportional region"); + + let resolved = resolve_time_anchor( + &score, + &TimeAnchor::Region { + id: region_id, + edge: RegionEdge::End, + offset: AnchorOffset::Zero, + }, + ); + assert_eq!(resolved, TimePoint::WallClock(WallClockTime(duration_ns))); + } + + #[test] + fn staff_content_resolves_clef_and_key_anchors() { + let mut score = valid_score(1); + let region_id = score.canvas.regions[0].id; + let anchor = TimeAnchor::Region { + id: region_id, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(duration(1, 4)), + }; + let staff_instance = score.canvas.regions[0] + .content + .staff_instances_mut() + .expect("valid_score is staff based") + .first_mut() + .expect("valid_score contains a staff instance"); + staff_instance.clef_sequence.push(ClefChange { + anchor: anchor.clone(), + clef: Clef::bass(), + }); + staff_instance.key_sequence.push(KeySignatureChange { + anchor, + key: KeySignature::new(-3).expect("valid key signature"), + }); + + let ir = to_logical(&score); + let staff = ir + .regions + .iter() + .flat_map(|region| region.objects.iter()) + .find_map(|object| match object.content() { + LayoutContent::Staff(staff) if !staff.clefs.is_empty() => Some(staff), + _ => None, + }) + .expect("staff content carries clef/key changes"); + assert_eq!( + staff.clefs[0], + PlacedClef { + time: TimePoint::Musical(position(1, 4)), + clef: Clef::bass(), + } + ); + assert_eq!( + staff.keys[0], + PlacedKeySignature { + time: TimePoint::Musical(position(1, 4)), + key: KeySignature::new(-3).expect("valid key signature"), + } + ); + } #[test] fn score_version_tracks_content_not_just_identifiers() { diff --git a/crates/epiphany-layout-ir/src/render.rs b/crates/epiphany-layout-ir/src/render.rs index 4879626..1d8d178 100644 --- a/crates/epiphany-layout-ir/src/render.rs +++ b/crates/epiphany-layout-ir/src/render.rs @@ -11,7 +11,7 @@ use crate::provenance::Provenance; use crate::resolved::ResolvedLayoutIR; use crate::spatial::{Point, ScaleContext}; -use crate::{BoundingBox, GlyphReference, GlyphStyle, Transform2D}; +use crate::{BoundingBox, GlyphReference, GlyphStyle, Stroke, Transform2D}; /// A single renderer primitive (Chapter 7 §"RenderIR"). Interface only — it /// carries just enough to prove the provenance-preservation contract: every @@ -34,6 +34,9 @@ pub struct RenderPrimitive { #[derive(Clone, PartialEq, Debug)] pub struct RenderIR { pub primitives: Vec, + /// Non-glyph line primitives (staff lines, stems, barlines, …), traced like + /// the glyph primitives so the round-trip recovers their sources too. + pub strokes: Vec, } /// The render target (Chapter 7 §"RenderIR": `RenderConfiguration.target`). @@ -139,5 +142,6 @@ pub fn to_render(resolved: &ResolvedLayoutIR) -> RenderIR { layer: g.layer, }) .collect(), + strokes: resolved.strokes.clone(), } } diff --git a/crates/epiphany-layout-ir/src/resolved.rs b/crates/epiphany-layout-ir/src/resolved.rs index d5c202e..2e9b2aa 100644 --- a/crates/epiphany-layout-ir/src/resolved.rs +++ b/crates/epiphany-layout-ir/src/resolved.rs @@ -24,7 +24,7 @@ use epiphany_core::{MeasureId, StaffId, TypedObjectId}; use epiphany_determinism::{CanonicalEncode, CanonicalF64, QuantizedCoord}; -use crate::constrained::{GlyphObjectId, GlyphStyle}; +use crate::constrained::{GlyphObjectId, GlyphStyle, Stroke}; use crate::engraving::{DecisionSource, EngravingDecision, EngravingDecisionKind}; use crate::glyph::{GlyphCatalogIdentity, GlyphReference}; use crate::logical::ScoreVersion; @@ -87,6 +87,9 @@ pub struct ResolvedLayoutIR { pub source: ScoreVersion, pub pages: Vec, pub glyphs: Vec, + /// Resolved non-glyph line primitives (staff lines, stems, barlines, …), + /// positioned by the solver alongside the glyphs. + pub strokes: Vec, pub engraving_decisions: Vec, /// The catalog identity under which this layout was produced — required for /// any byte-equal conformance claim (Chapter 7 §7.3.2). @@ -142,6 +145,19 @@ impl CanonicalEncode for ResolvedLayoutIR { out.extend_from_slice(&glyph.style.rgba.to_le_bytes()); out.extend_from_slice(&glyph.layer.to_le_bytes()); } + push_u64(out, self.strokes.len() as u64); + for stroke in &self.strokes { + encode_provenance(out, &stroke.provenance); + let (fx, fy) = quantize(stroke.from); + fx.encode_canonical(out); + fy.encode_canonical(out); + let (tx, ty) = quantize(stroke.to); + tx.encode_canonical(out); + ty.encode_canonical(out); + encode_staff_space(out, stroke.thickness); + out.extend_from_slice(&stroke.style.rgba.to_le_bytes()); + out.extend_from_slice(&stroke.layer.to_le_bytes()); + } push_u64(out, self.engraving_decisions.len() as u64); for decision in &self.engraving_decisions { encode_decision(out, decision); @@ -353,6 +369,7 @@ mod tests { source: ScoreVersion::default(), pages: vec![], glyphs, + strokes: vec![], engraving_decisions: decisions, catalog: GlyphCatalogIdentity::default(), } diff --git a/crates/epiphany-layout-ir/src/roundtrip.rs b/crates/epiphany-layout-ir/src/roundtrip.rs index bc672bd..3f3026a 100644 --- a/crates/epiphany-layout-ir/src/roundtrip.rs +++ b/crates/epiphany-layout-ir/src/roundtrip.rs @@ -64,9 +64,13 @@ pub fn laid_out_object_ids(score: &Score) -> BTreeSet { pub struct RoundTripReport { pub status: SolveStatus, pub logical_objects: usize, + /// Glyph primitives (one per laid-out glyph). pub glyphs: usize, + /// Stroke primitives (staff lines, stems, markers, …). + pub render_strokes: usize, + /// Total render primitives — glyphs **and** strokes. pub render_primitives: usize, - /// Every score-graph source recovered from the RenderIR. + /// Every score-graph source recovered from the RenderIR (glyphs + strokes). pub recovered_sources: BTreeSet, } @@ -122,14 +126,27 @@ pub fn round_trip(score: &Score) -> RoundTripReport { }) .chain(logical.cross_region.iter().map(|object| &object.provenance)), ); + // Every primitive — glyph *and* stroke — is provenance-tracked. The + // constrained stage may carry *more* primitives than the logical stage has + // objects: each logical object is covered by exactly one primitive carrying + // its provenance, plus the engraver's synthesized derived primitives + // (accidentals, staff lines, stems, …), whose `source` is constrained to a + // laid-out object by the surjection below. let constrained_map = provenance_map( "constrained", - constrained.glyphs.iter().map(|g| &g.provenance), - ); - assert_eq!( - logical_map, constrained_map, - "provenance not preserved logical -> constrained" + constrained + .glyphs + .iter() + .map(|g| &g.provenance) + .chain(constrained.strokes.iter().map(|s| &s.provenance)), ); + for (id, provenance) in &logical_map { + assert_eq!( + constrained_map.get(id), + Some(provenance), + "logical object {id:?} is not covered (with its exact provenance) in constrained" + ); + } let report = StubSolver.solve(&constrained, &SolverConfig::default()); assert_eq!( @@ -162,10 +179,20 @@ pub fn round_trip(score: &Score) -> RoundTripReport { assert_eq!(resolved_glyph.style, constrained_glyph.style); assert_eq!(resolved_glyph.layer, constrained_glyph.layer); } + // Strokes likewise pass through the stub verbatim, in order. + assert_eq!( + report.layout.strokes, constrained.strokes, + "stub solver must return the input strokes verbatim" + ); let resolved_map = provenance_map( "resolved", - report.layout.glyphs.iter().map(|g| &g.provenance), + report + .layout + .glyphs + .iter() + .map(|g| &g.provenance) + .chain(report.layout.strokes.iter().map(|s| &s.provenance)), ); assert_eq!( constrained_map, resolved_map, @@ -181,31 +208,43 @@ pub fn round_trip(score: &Score) -> RoundTripReport { assert_eq!(primitive.style, resolved_glyph.style); assert_eq!(primitive.layer, resolved_glyph.layer); } - let render_map = provenance_map("render", render.primitives.iter().map(|p| &p.provenance)); + assert_eq!( + render.strokes, report.layout.strokes, + "render must carry the resolved strokes verbatim" + ); + let render_map = provenance_map( + "render", + render + .primitives + .iter() + .map(|p| &p.provenance) + .chain(render.strokes.iter().map(|s| &s.provenance)), + ); assert_eq!( resolved_map, render_map, "provenance not preserved resolved -> render" ); - // Provenance back to graph identity: the recovered source *set* is exactly - // the set laid out (a surjection — every source recovered, nothing spurious), - // while the primitive count equals the distinct-stable-id count, so each - // layout object (including each manifestation of a multiply-manifested - // source) is represented exactly once. + // Provenance back to graph identity: the recovered source *set* — over every + // primitive, glyph and stroke — is exactly the set laid out (a surjection: + // every source recovered, nothing spurious), while the primitive count equals + // the distinct-stable-id count, so each layout object (and each synthesized + // derived primitive) is represented exactly once. let expected = laid_out_object_ids(score); let recovered: BTreeSet = render .primitives .iter() .map(|p| p.provenance.source) + .chain(render.strokes.iter().map(|s| s.provenance.source)) .collect(); assert_eq!( expected, recovered, "RenderIR sources do not match the laid-out graph objects" ); assert_eq!( - render.primitives.len(), + render.primitives.len() + render.strokes.len(), render_map.len(), - "render produced two objects with the same stable id" + "render produced two primitives with the same stable id" ); RoundTripReport { @@ -217,7 +256,8 @@ pub fn round_trip(score: &Score) -> RoundTripReport { .sum::() + logical.cross_region.len(), glyphs: constrained.glyphs.len(), - render_primitives: render.primitives.len(), + render_strokes: render.strokes.len(), + render_primitives: render.primitives.len() + render.strokes.len(), recovered_sources: recovered, } } @@ -272,18 +312,26 @@ mod tests { assert_eq!(report.status, SolveStatus::Solved); let render = to_render(&report.layout); - // Two primitives for the shared staff (one per region), distinct ids. - let staff_prims: Vec<_> = render - .primitives + // A staff engraves as stroke line primitives (its five staff lines); its + // own provenance anchors the bottom line, the upper four are synthesized + // from it. Two manifestations → two anchor lines with distinct ids. + let staff_anchors: Vec<_> = render + .strokes .iter() - .filter(|p| p.provenance.source == TypedObjectId::Staff(staff)) + .filter(|s| { + s.provenance.source == TypedObjectId::Staff(staff) + && s.provenance.synthesis.is_none() + }) .collect(); assert_eq!( - staff_prims.len(), + staff_anchors.len(), 2, "both manifestations reach the render stage" ); - let ids: BTreeSet<_> = staff_prims.iter().map(|p| p.provenance.stable_id).collect(); + let ids: BTreeSet<_> = staff_anchors + .iter() + .map(|s| s.provenance.stable_id) + .collect(); assert_eq!( ids.len(), 2, @@ -369,8 +417,19 @@ mod tests { fn valid_scores_round_trip() { for seed in 0..64u64 { let report = round_trip(&valid_score(seed)); - assert_eq!(report.glyphs, report.render_primitives); - assert_eq!(report.recovered_sources.len(), report.render_primitives); + assert_eq!( + report.render_primitives, + report.glyphs + report.render_strokes + ); + // Every laid-out source is recovered, nothing spurious (the + // surjection). A source may back several primitives now — a staff's + // five lines all trace to it — so the recovered *set* is no larger + // than the primitive count, not equal to it. + assert_eq!( + report.recovered_sources, + laid_out_object_ids(&valid_score(seed)) + ); + assert!(report.recovered_sources.len() <= report.render_primitives); } } diff --git a/crates/epiphany-layout-ir/src/solver.rs b/crates/epiphany-layout-ir/src/solver.rs index 2a96773..5f4a830 100644 --- a/crates/epiphany-layout-ir/src/solver.rs +++ b/crates/epiphany-layout-ir/src/solver.rs @@ -204,9 +204,13 @@ pub struct ExtensionMetric { } /// The quality metric vector for a layout (Chapter 9 §"Quality Metrics": -/// `QualityMetricVector`). v0 carries the type but computes **no** values: the -/// stub's vector is a placeholder all-`0.0` (nominal-best) vector with no -/// conformance meaning (the normalization functions are deferred). +/// `QualityMetricVector`). v0 carries the type but computes **no** values: an +/// interface-only solver reports the conservative all-worst placeholder +/// ([`QualityMetricVector::unmeasured`], every metric `1.0`), never a measured +/// value, so a caller cannot mistake an unmeasured layout for a good one. (The +/// derived [`Default`] is all-`0.0`/nominal-best and is *not* what the stub +/// reports; the normalization functions of the Quality Metric Catalog are +/// deferred.) #[derive(Clone, PartialEq, Debug, Default)] pub struct QualityMetricVector { pub collision_penalty: NormalizedMetric, @@ -416,6 +420,13 @@ impl StubSolver { } else { Vec::new() }; + // Strokes pass through verbatim (the stub resolves no geometry), gated on + // the same structural validity as the glyphs. + let strokes = if structural_valid { + input.strokes.clone() + } else { + Vec::new() + }; let resolved_glyphs = glyphs.len(); let pages = input .regions @@ -451,6 +462,7 @@ impl StubSolver { source: input.source, pages, glyphs, + strokes, engraving_decisions: input.engraving_decisions.clone(), catalog: input.catalog.clone(), }, @@ -544,9 +556,11 @@ mod tests { members: glyphs.iter().map(GlyphObject::id).collect(), }], glyphs, + strokes: vec![], vertical_bands: vec![band], constraints: vec![], engraving_decisions: vec![], + diagnostics: vec![], catalog, } } @@ -628,9 +642,11 @@ mod tests { members: vec![unknown.id()], }], glyphs: vec![unknown], + strokes: vec![], vertical_bands: vec![band], constraints: vec![], engraving_decisions: vec![], + diagnostics: vec![], catalog: GlyphCatalogIdentity::default(), }; let report = StubSolver.solve(&input, &SolverConfig::default()); @@ -650,6 +666,34 @@ mod tests { assert!(report.warnings.is_empty()); } + #[test] + fn strokes_survive_the_solve_and_enter_the_canonical_bytes() { + let mut input = constrained(vec![glyph("noteheadBlack")]); + let baseline = StubSolver + .solve(&input, &SolverConfig::default()) + .layout + .canonical_bytes(); + input.strokes.push(crate::Stroke { + provenance: input.glyphs[0].provenance.clone(), + from: crate::Point::new(0.0, 0.0), + to: crate::Point::new(1.5, 0.0), + thickness: crate::StaffSpace(0.13), + layer: 0, + style: crate::GlyphStyle::default(), + }); + let solved = StubSolver.solve(&input, &SolverConfig::default()); + assert_eq!( + solved.layout.strokes.len(), + 1, + "the stroke survives the solve" + ); + assert_ne!( + solved.layout.canonical_bytes(), + baseline, + "a stroke changes the resolved canonical bytes" + ); + } + #[test] fn forged_catalog_metadata_is_rejected() { let mut input = constrained(vec![glyph("noteheadBlack")]); diff --git a/crates/epiphany-layout-ir/src/time_axis.rs b/crates/epiphany-layout-ir/src/time_axis.rs index efcb61a..d7ae046 100644 --- a/crates/epiphany-layout-ir/src/time_axis.rs +++ b/crates/epiphany-layout-ir/src/time_axis.rs @@ -95,8 +95,9 @@ pub enum TimeAxisKind { } /// Compares two [`TimePoint`]s of the *same* kind; mixed kinds are -/// incomparable (`None`), which a uniform region never produces. -fn time_cmp(a: &TimePoint, b: &TimePoint) -> Option { +/// incomparable (`None`), which a uniform region never produces. Exact — over +/// rational musical time and integer wall-clock, never a lossy `f64`. +pub(crate) fn time_cmp(a: &TimePoint, b: &TimePoint) -> Option { match (a, b) { (TimePoint::Musical(x), TimePoint::Musical(y)) => Some(x.cmp(y)), (TimePoint::WallClock(x), TimePoint::WallClock(y)) => Some(x.cmp(y)), diff --git a/crates/epiphany-render-svg/src/outlines_generated.rs b/crates/epiphany-render-svg/src/outlines_generated.rs index 2625cc2..e0c7800 100644 --- a/crates/epiphany-render-svg/src/outlines_generated.rs +++ b/crates/epiphany-render-svg/src/outlines_generated.rs @@ -6,6 +6,12 @@ //! (musical convention, positive y = higher pitch), relative to each glyph's //! origin, rounded to 4 decimals. The renderer applies a single y-flip wrapper. //! +//! Source (pinned + SHA-256 verified on extraction): Bravura 1.392, +//! steinbergmedia/bravura @ 301087ca0b0d30b65d81bc3e718ff64b613e2a9a +//! (sha256 dca2d90c88437a701b1c2e71fa54e76f9fa41d7deee935d74dc871ea66ecfdd2); +//! glyph names from w3c/smufl gh-pages @ 31a327a29640c313b12076739987bd7f25bdddde +//! (sha256 1d05352599a20983d1c901635dc75d76f063c0987a7bee65f145325fc3e0d29f). +//! //! Bravura is (c) Steinberg Media Technologies GmbH under the SIL Open Font //! License 1.1; these extracted outlines are redistributed under the same license //! (see `tools/OFL.txt`). @@ -155,12 +161,66 @@ pub(crate) const BRAVURA_OUTLINES: &[BravuraOutline] = &[ path: "M1.128 -0.436V-0.068C1.128 -0.008 1.08 0.036 1.024 0.036H0.104C0.044 0.036 0 -0.008 0 -0.068V-0.436C0 -0.492 0.044 -0.54 0.104 -0.54H1.024C1.08 -0.54 1.128 -0.492 1.128 -0.436Z", bbox: [0.0, -0.54, 1.128, 0.036], }, + BravuraOutline { + name: "timeSig0", + codepoint: 0xE080, + path: "M1.8 0C1.8 0.556 1.416 1.004 0.94 1.004C0.464 1.004 0.08 0.556 0.08 0C0.08 -0.552 0.464 -1 0.94 -1C1.416 -1 1.8 -0.552 1.8 0ZM0.94 0.88C1.104 0.88 1.24 0.5 1.24 0.028C1.24 -0.44 1.104 -0.82 0.94 -0.82C0.772 -0.82 0.64 -0.44 0.64 0.028C0.64 0.5 0.772 0.88 0.94 0.88Z", + bbox: [0.08, -1.0, 1.8, 1.004], + }, + BravuraOutline { + name: "timeSig1", + codepoint: 0xE081, + path: "M0.096 0.052C0.096 0.052 0.08 0.028 0.08 0C0.08 -0.02 0.092 -0.044 0.124 -0.056C0.14 -0.06 0.156 -0.064 0.16 -0.064C0.2 -0.064 0.216 -0.028 0.216 -0.028C0.216 -0.028 0.388 0.248 0.432 0.324C0.448 0.352 0.464 0.364 0.472 0.364C0.488 0.364 0.496 0.332 0.496 0.308V-0.724C0.496 -0.816 0.404 -0.876 0.32 -0.876C0.292 -0.876 0.252 -0.888 0.252 -0.936C0.252 -0.98 0.288 -1 0.34 -1H1.192C1.256 -1 1.256 -0.936 1.256 -0.936C1.256 -0.936 1.256 -0.876 1.196 -0.876C1.14 -0.876 1.068 -0.804 1.068 -0.736V0.912C1.068 0.976 1.044 1 0.988 1.004C0.932 1.004 0.832 0.988 0.78 0.988C0.704 0.988 0.632 0.992 0.572 1C0.564 1 0.556 1.004 0.552 1.004C0.512 1.004 0.496 0.964 0.48 0.928Z", + bbox: [0.08, -1.0, 1.256, 1.004], + }, + BravuraOutline { + name: "timeSig2", + codepoint: 0xE082, + path: "M1.684 -0.364C1.684 -0.316 1.664 -0.308 1.636 -0.308C1.604 -0.308 1.592 -0.324 1.584 -0.352L1.58 -0.356C1.54 -0.456 1.508 -0.532 1.424 -0.532C1.404 -0.532 1.384 -0.528 1.356 -0.52C1.304 -0.5 1.276 -0.496 1.236 -0.476C1.156 -0.444 0.968 -0.38 0.804 -0.38C0.752 -0.38 0.7 -0.388 0.656 -0.404C0.744 -0.26 1.084 -0.14 1.172 -0.116C1.452 -0.04 1.704 0.076 1.704 0.408C1.704 0.832 1.288 1.016 0.916 1.016C0.636 1.016 0.388 0.992 0.192 0.764C0.124 0.68 0.08 0.58 0.08 0.472C0.08 0.416 0.092 0.36 0.116 0.3C0.176 0.176 0.3 0.08 0.444 0.08C0.688 0.08 0.724 0.332 0.724 0.432C0.724 0.672 0.448 0.684 0.448 0.764C0.456 0.82 0.528 0.916 0.764 0.916C1.12 0.916 1.124 0.648 1.124 0.532C1.124 0.168 0.824 -0.108 0.536 -0.284C0.316 -0.424 0.16 -0.62 0.092 -0.88L0.088 -0.892C0.088 -0.944 0.132 -1.028 0.192 -1.028C0.28 -1.028 0.328 -0.784 0.564 -0.784C0.724 -0.784 0.784 -1 1.14 -1C1.312 -1 1.62 -0.984 1.684 -0.364Z", + bbox: [0.08, -1.028, 1.704, 1.016], + }, + BravuraOutline { + name: "timeSig3", + codepoint: 0xE083, + path: "M0.852 0.992C0.848 0.992 0.844 0.992 0.836 0.992L0.808 0.996C0.804 0.996 0.796 0.996 0.792 0.996C0.448 0.996 0.104 0.768 0.104 0.556C0.104 0.424 0.18 0.248 0.428 0.232H0.448C0.624 0.232 0.712 0.36 0.712 0.492V0.524C0.7 0.672 0.6 0.68 0.58 0.688C0.56 0.696 0.5 0.688 0.5 0.744V0.76C0.508 0.828 0.632 0.86 0.668 0.86C1.008 0.86 1.04 0.648 1.04 0.552V0.524C1.04 0.228 0.804 0.112 0.552 0.1C0.512 0.096 0.456 0.076 0.456 0.032C0.456 -0.016 0.524 -0.016 0.556 -0.016C1.016 -0.016 1.052 -0.328 1.052 -0.38C1.052 -0.804 0.836 -0.852 0.748 -0.852C0.732 -0.852 0.716 -0.848 0.712 -0.848C0.68 -0.844 0.604 -0.844 0.6 -0.784V-0.764C0.6 -0.676 0.688 -0.616 0.692 -0.5C0.692 -0.332 0.576 -0.212 0.404 -0.212C0.388 -0.212 0.376 -0.212 0.36 -0.216C0.292 -0.228 0.216 -0.268 0.168 -0.32C0.1 -0.38 0.08 -0.476 0.08 -0.564C0.088 -0.876 0.372 -0.996 0.764 -1.004H0.8C1.196 -1.004 1.604 -0.8 1.604 -0.448V-0.42C1.596 -0.304 1.568 -0.228 1.492 -0.14C1.468 -0.108 1.436 -0.08 1.396 -0.056L1.312 -0.008L1.18 0.028C1.16 0.032 1.148 0.032 1.14 0.048C1.136 0.056 1.136 0.06 1.136 0.068C1.136 0.084 1.14 0.1 1.152 0.104C1.196 0.116 1.24 0.12 1.276 0.14C1.436 0.216 1.52 0.32 1.52 0.504C1.52 0.872 1.02 0.98 0.852 0.992Z", + bbox: [0.08, -1.004, 1.604, 0.996], + }, BravuraOutline { name: "timeSig4", codepoint: 0xE084, path: "M1.448 -0.296V0.56C1.448 0.592 1.444 0.628 1.4 0.628C1.364 0.628 1.344 0.62 1.32 0.592L0.94 0.132C0.924 0.112 0.904 0.088 0.904 0.04V-0.296H0.364C0.684 -0.024 1.324 0.884 1.336 0.932L1.34 0.944C1.34 0.98 1.312 1.004 1.28 1.004C1.244 1.004 1.08 0.996 1.008 0.996C0.936 0.996 0.756 1.004 0.724 1.004C0.688 1.004 0.632 0.992 0.632 0.928C0.632 0.432 0.24 -0.124 0.12 -0.292L0.096 -0.324C0.096 -0.324 0.096 -0.328 0.096 -0.328L0.092 -0.332C0.084 -0.352 0.08 -0.368 0.08 -0.38C0.08 -0.42 0.112 -0.448 0.16 -0.448H0.904V-0.7C0.904 -0.808 0.816 -0.84 0.744 -0.84C0.68 -0.84 0.652 -0.876 0.652 -0.916C0.652 -0.956 0.668 -1 0.728 -1H1.58C1.62 -1 1.66 -0.972 1.66 -0.916C1.66 -0.86 1.612 -0.836 1.572 -0.836C1.532 -0.836 1.448 -0.812 1.448 -0.684V-0.448H1.74C1.78 -0.448 1.8 -0.42 1.8 -0.372C1.8 -0.324 1.784 -0.296 1.74 -0.296Z", bbox: [0.08, -1.0, 1.8, 1.004], }, + BravuraOutline { + name: "timeSig5", + codepoint: 0xE085, + path: "M0.304 0.236C0.304 0.236 0.32 0.46 0.324 0.496C0.328 0.528 0.348 0.548 0.384 0.548H0.4C0.44 0.54 0.628 0.512 0.792 0.512C1.348 0.512 1.368 0.832 1.368 0.896C1.368 0.948 1.356 0.98 1.312 0.98C1.26 0.98 0.948 0.944 0.82 0.944C0.692 0.944 0.348 0.976 0.28 0.984C0.208 0.984 0.188 0.948 0.184 0.916L0.14 0.028V0.02C0.14 -0.032 0.18 -0.04 0.22 -0.04C0.26 -0.04 0.264 -0.004 0.308 0.04C0.348 0.08 0.444 0.172 0.58 0.172C0.716 0.172 0.992 0.096 0.992 -0.348C0.992 -0.788 0.756 -0.844 0.652 -0.844C0.62 -0.844 0.588 -0.844 0.56 -0.832C0.54 -0.82 0.516 -0.804 0.512 -0.776C0.512 -0.748 0.54 -0.732 0.56 -0.72C0.652 -0.664 0.712 -0.564 0.712 -0.452C0.712 -0.276 0.572 -0.14 0.4 -0.14C0.184 -0.14 0.096 -0.296 0.084 -0.436C0.08 -0.46 0.08 -0.484 0.08 -0.508C0.08 -0.84 0.296 -1.004 0.788 -1.004C1.268 -1.004 1.532 -0.708 1.532 -0.348C1.532 0.016 1.236 0.312 0.872 0.312C0.64 0.312 0.468 0.272 0.34 0.196C0.332 0.192 0.324 0.192 0.32 0.192C0.304 0.192 0.304 0.208 0.304 0.22Z", + bbox: [0.08, -1.004, 1.532, 0.984], + }, + BravuraOutline { + name: "timeSig6", + codepoint: 0xE086, + path: "M1.22 0.332C1.388 0.332 1.54 0.464 1.54 0.636C1.54 0.652 1.54 0.664 1.536 0.68C1.496 0.936 1.184 1.004 0.968 1.004C0.664 1 0.38 0.852 0.236 0.58C0.148 0.412 0.08 0.2 0.08 0.012V-0.004C0.084 -0.188 0.116 -0.396 0.204 -0.556C0.368 -0.852 0.564 -0.996 0.9 -0.996C1.08 -0.996 1.28 -0.968 1.424 -0.852C1.564 -0.74 1.656 -0.56 1.656 -0.38C1.656 -0.068 1.36 0.2 1.052 0.2C0.924 0.2 0.792 0.152 0.688 0.06C0.68 0.052 0.672 0.052 0.664 0.052C0.64 0.052 0.628 0.084 0.628 0.148C0.64 0.888 0.876 0.908 0.96 0.908C1.04 0.908 1.092 0.888 1.092 0.848C1.092 0.792 1.016 0.744 0.992 0.696C0.972 0.66 0.964 0.62 0.964 0.58C0.964 0.52 0.984 0.46 1.024 0.416C1.052 0.364 1.168 0.332 1.22 0.332ZM0.888 0.008C1.016 0.008 1.124 -0.192 1.124 -0.44C1.124 -0.688 1.016 -0.888 0.888 -0.888C0.76 -0.888 0.656 -0.688 0.656 -0.44C0.656 -0.192 0.76 0.008 0.888 0.008Z", + bbox: [0.08, -0.996, 1.656, 1.004], + }, + BravuraOutline { + name: "timeSig7", + codepoint: 0xE087, + path: "M1.684 0.816C1.684 0.924 1.684 0.976 1.616 0.976C1.612 0.976 1.548 0.96 1.532 0.932C1.504 0.884 1.448 0.656 1.348 0.656C1.248 0.656 1.06 0.996 0.728 0.996C0.496 0.996 0.436 0.904 0.38 0.852C0.324 0.8 0.3 0.784 0.272 0.78C0.24 0.78 0.188 0.836 0.168 0.876C0.16 0.892 0.14 0.904 0.12 0.904C0.1 0.904 0.08 0.892 0.08 0.856V0.196C0.08 0.196 0.084 0.132 0.124 0.132C0.156 0.132 0.168 0.168 0.184 0.212C0.224 0.312 0.276 0.544 0.456 0.544C0.616 0.544 0.808 0.244 1.04 0.244C1.152 0.244 1.208 0.304 1.24 0.328C1.252 0.336 1.268 0.344 1.276 0.344C1.292 0.344 1.3 0.332 1.304 0.308C1.304 0.196 0.996 -0.028 0.756 -0.312C0.604 -0.488 0.48 -0.72 0.48 -0.876C0.48 -0.96 0.48 -1 0.556 -1C0.628 -1 0.716 -0.964 0.816 -0.964C0.916 -0.964 1.104 -1 1.144 -1C1.184 -1 1.208 -0.968 1.208 -0.852C1.208 -0.184 1.684 0.388 1.684 0.8Z", + bbox: [0.08, -1.0, 1.684, 0.996], + }, + BravuraOutline { + name: "timeSig8", + codepoint: 0xE088, + path: "M1.336 0.144C1.48 0.236 1.576 0.368 1.576 0.568C1.576 0.976 0.988 1.036 0.88 1.036C0.416 1.036 0.1 0.824 0.1 0.488C0.1 0.212 0.256 0.064 0.448 -0.044C0.24 -0.144 0.08 -0.276 0.08 -0.528C0.08 -0.876 0.44 -1.036 0.836 -1.036C1.236 -1.036 1.664 -0.864 1.664 -0.324C1.664 -0.084 1.524 0.048 1.336 0.144ZM1.128 0.236C0.808 0.348 0.468 0.416 0.468 0.668C0.468 0.836 0.696 0.92 0.872 0.92C1 0.92 1.34 0.856 1.34 0.576C1.34 0.416 1.26 0.312 1.128 0.236ZM0.82 -0.904C0.552 -0.904 0.308 -0.768 0.308 -0.508C0.308 -0.348 0.448 -0.2 0.624 -0.132C0.916 -0.26 1.212 -0.344 1.212 -0.608C1.212 -0.768 1.088 -0.904 0.82 -0.904Z", + bbox: [0.08, -1.036, 1.664, 1.036], + }, + BravuraOutline { + name: "timeSig9", + codepoint: 0xE089, + path: "M0.516 -0.324C0.348 -0.324 0.196 -0.456 0.196 -0.628C0.196 -0.644 0.196 -0.656 0.2 -0.672C0.24 -0.928 0.552 -0.996 0.768 -0.996C1.072 -0.992 1.356 -0.844 1.5 -0.572C1.588 -0.404 1.656 -0.192 1.656 -0.004V0.012C1.652 0.196 1.62 0.404 1.532 0.564C1.368 0.86 1.172 1.004 0.836 1.004C0.656 1.004 0.456 0.976 0.312 0.86C0.172 0.748 0.08 0.568 0.08 0.388C0.08 0.076 0.376 -0.192 0.684 -0.192C0.812 -0.192 0.944 -0.144 1.048 -0.052C1.056 -0.044 1.064 -0.044 1.072 -0.044C1.096 -0.044 1.108 -0.076 1.108 -0.14C1.096 -0.88 0.86 -0.9 0.776 -0.9C0.696 -0.9 0.644 -0.88 0.644 -0.84C0.644 -0.784 0.72 -0.736 0.744 -0.688C0.764 -0.652 0.772 -0.612 0.772 -0.572C0.772 -0.512 0.752 -0.452 0.712 -0.408C0.684 -0.356 0.568 -0.324 0.516 -0.324ZM0.848 0C0.72 0 0.612 0.2 0.612 0.448C0.612 0.696 0.72 0.896 0.848 0.896C0.976 0.896 1.08 0.696 1.08 0.448C1.08 0.2 0.976 0 0.848 0Z", + bbox: [0.08, -0.996, 1.656, 1.004], + }, BravuraOutline { name: "timeSigCommon", codepoint: 0xE08A, diff --git a/crates/epiphany-render-svg/src/svg.rs b/crates/epiphany-render-svg/src/svg.rs index 399f3c7..57763f8 100644 --- a/crates/epiphany-render-svg/src/svg.rs +++ b/crates/epiphany-render-svg/src/svg.rs @@ -35,7 +35,7 @@ use std::collections::BTreeMap; use std::fmt::Write as _; -use epiphany_layout_ir::{BoundingBox, Provenance, ResolvedGlyph, ResolvedLayoutIR}; +use epiphany_layout_ir::{BoundingBox, Provenance, ResolvedGlyph, ResolvedLayoutIR, Transform2D}; use crate::outline::outline; use crate::xml::{check_well_formed, escape_attr}; @@ -154,13 +154,18 @@ pub struct RenderStats { pub path_count: usize, /// Fallback `` elements emitted (glyphs with no bundled outline). pub fallback_rect_count: usize, + /// `` elements emitted (one per resolved stroke: staff line, stem, …). + pub stroke_count: usize, /// Elements carrying a `data-prov` trace back to a score-graph source. pub provenance_count: usize, /// Distinct layers, each rendered as one `` group. pub layer_count: usize, /// Per-class glyph counts. pub class_counts: BTreeMap, - /// The content viewBox `[min_x, min_y, width, height]`, in staff spaces. + /// The padded content bounds `[min_x, min_y, width, height]`, in staff + /// spaces. Note this is the *content extent*, not the emitted `viewBox` + /// attribute: the SVG is translated so its `viewBox` is always `0 0 W H` + /// (the `min_x`/`min_y` here are folded into the y-flip group's translate). pub view_box: [f32; 4], } @@ -205,6 +210,7 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut glyph_count: 0, path_count: 0, fallback_rect_count: 0, + stroke_count: 0, provenance_count: 0, layer_count: 0, class_counts, @@ -217,14 +223,26 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut }; let max_y = min_y + height; - // Group glyph indices by layer (ascending), preserving input order within. - let mut layers: BTreeMap> = BTreeMap::new(); + // Group glyphs and strokes by layer (ascending), preserving input order + // within. Strokes draw before glyphs at the same layer, so a staff line sits + // under the noteheads on it. + let mut glyph_layers: BTreeMap> = BTreeMap::new(); for (i, g) in resolved.glyphs.iter().enumerate() { - layers.entry(g.layer).or_default().push(i); + glyph_layers.entry(g.layer).or_default().push(i); } + let mut stroke_layers: BTreeMap> = BTreeMap::new(); + for (i, stroke) in resolved.strokes.iter().enumerate() { + stroke_layers.entry(stroke.layer).or_default().push(i); + } + let layer_ids: std::collections::BTreeSet = glyph_layers + .keys() + .chain(stroke_layers.keys()) + .copied() + .collect(); let mut path_count = 0; let mut fallback_rect_count = 0; + let mut stroke_count = 0; let mut provenance_count = 0; let mut s = String::new(); @@ -250,53 +268,96 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut num(max_y), ); - for (layer, indices) in &layers { + for layer in &layer_ids { let _ = writeln!(s, " ", layer); - for &i in indices { - let g = &resolved.glyphs[i]; - let name = g.glyph.as_str(); - let (x, y) = (g.position.x.0, g.position.y.0); - let (fill, opacity) = colour(g.style.rgba); - let prov = if options.emit_provenance { - provenance_count += 1; - provenance_attrs(&g.provenance, name, GlyphClass::of(name)) - } else { - String::new() - }; - match outline(name) { - Some(o) => { - path_count += 1; - let _ = writeln!( - s, - " ", - o.path, - num(x), - num(y), - fill, - opacity, - prov, - ); + + // Strokes (staff lines, stems, barlines, …) — drawn first so glyphs on + // the same layer sit over them. + if let Some(indices) = stroke_layers.get(layer) { + for &i in indices { + let stroke = &resolved.strokes[i]; + let (stroke_fill, opacity) = stroke_colour(stroke.style.rgba); + let prov = if options.emit_provenance { + provenance_count += 1; + stroke_provenance_attrs(&stroke.provenance) + } else { + String::new() + }; + stroke_count += 1; + let _ = writeln!( + s, + " ", + num(stroke.from.x.0), + num(stroke.from.y.0), + num(stroke.to.x.0), + num(stroke.to.y.0), + stroke_fill, + num(stroke.thickness.0), + opacity, + prov, + ); + } + } + + if let Some(indices) = glyph_layers.get(layer) { + for &i in indices { + let g = &resolved.glyphs[i]; + let name = g.glyph.as_str(); + let (x, y) = (g.position.x.0, g.position.y.0); + // The glyph's resolved transform (scale/rotate/skew about its + // origin), composed after the placement translate. `None` ⇒ a bare + // translate, the common case. + let placement = placement_transform(x, y, &g.transform); + if let Some(t) = &g.transform { + if !is_affine(t) { + diagnostics.push(Diagnostic { + message: "non-affine (projective) glyph transform is not \ + representable in SVG; rendered its affine projection" + .to_owned(), + glyph: Some(name.to_owned()), + }); + } } - None => { - // No outline: surface it and draw the IR bounding box so the - // missing glyph is visible, not silently absent. - fallback_rect_count += 1; - diagnostics.push(Diagnostic { - message: "no bundled Bravura outline; drew bounding-box fallback" - .to_owned(), - glyph: Some(name.to_owned()), - }); - let bb = g.bounding_box; - let _ = writeln!( - s, - " ", - num(x + bb.left.0), - num(y + bb.bottom.0), - num((bb.right.0 - bb.left.0).max(0.0)), - num((bb.top.0 - bb.bottom.0).max(0.0)), - prov, - ); + let (fill, opacity) = colour(g.style.rgba); + let prov = if options.emit_provenance { + provenance_count += 1; + provenance_attrs(&g.provenance, name, GlyphClass::of(name)) + } else { + String::new() + }; + match outline(name) { + Some(o) => { + path_count += 1; + let _ = writeln!( + s, + " ", + o.path, placement, fill, opacity, prov, + ); + } + None => { + // No outline: surface it and draw the IR bounding box so the + // missing glyph is visible, not silently absent. + fallback_rect_count += 1; + diagnostics.push(Diagnostic { + message: "no bundled Bravura outline; drew bounding-box fallback" + .to_owned(), + glyph: Some(name.to_owned()), + }); + let bb = g.bounding_box; + let _ = writeln!( + s, + " ", + num(bb.left.0), + num(bb.bottom.0), + num((bb.right.0 - bb.left.0).max(0.0)), + num((bb.top.0 - bb.bottom.0).max(0.0)), + placement, + prov, + ); + } } } } @@ -311,8 +372,9 @@ pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOut glyph_count: resolved.glyphs.len(), path_count, fallback_rect_count, + stroke_count, provenance_count, - layer_count: layers.len(), + layer_count: layer_ids.len(), class_counts, view_box: [num_f(min_x), num_f(min_y), num_f(width), num_f(height)], }, @@ -332,10 +394,16 @@ fn content_bounds(resolved: &ResolvedLayoutIR, margin: f32) -> Option<(f32, f32, for g in &resolved.glyphs { let bb = drawn_bbox(g); let (x, y) = (g.position.x.0, g.position.y.0); - for (px, py) in [ - (x + bb.left.0, y + bb.bottom.0), - (x + bb.right.0, y + bb.top.0), + // All four bbox corners mapped through the *same* placement transform the + // renderer applies — a scale/rotation can push drawn geometry past the + // untransformed axis-aligned extent, which would crop it. + for (lx, ly) in [ + (bb.left.0, bb.bottom.0), + (bb.right.0, bb.bottom.0), + (bb.left.0, bb.top.0), + (bb.right.0, bb.top.0), ] { + let (px, py) = placed_point(x, y, &g.transform, lx, ly); if px.is_finite() && py.is_finite() { any = true; min_x = min_x.min(px); @@ -345,6 +413,23 @@ fn content_bounds(resolved: &ResolvedLayoutIR, margin: f32) -> Option<(f32, f32, } } } + // Strokes extend the bounds by a half-thickness box around each endpoint, so a + // thick rule is not clipped perpendicular to its direction even at margin 0. + for stroke in &resolved.strokes { + let half = (stroke.thickness.0 * 0.5).max(0.0); + for point in [stroke.from, stroke.to] { + let (cx, cy) = (point.x.0, point.y.0); + for (px, py) in [(cx - half, cy - half), (cx + half, cy + half)] { + if px.is_finite() && py.is_finite() { + any = true; + min_x = min_x.min(px); + min_y = min_y.min(py); + max_x = max_x.max(px); + max_y = max_y.max(py); + } + } + } + } if !any { return None; } @@ -378,6 +463,87 @@ fn provenance_attrs(p: &Provenance, glyph: &str, class: GlyphClass) -> String { ) } +/// `data-*` provenance attributes for a stroke (a non-glyph line primitive). +fn stroke_provenance_attrs(p: &Provenance) -> String { + format!( + " data-prov=\"{:032x}\" data-source-kind=\"{}\" data-kind=\"stroke\"", + p.stable_id.0, + p.source.discriminant(), + ) +} + +/// Whether a [`Transform2D`] is a pure 2-D affine — its bottom row is `[0, 0, 1]` +/// (within an `f32` tolerance). SVG transforms are affine, so a non-affine +/// (projective) transform cannot be represented; the renderer diagnoses it and +/// renders its affine projection. +fn is_affine(transform: &Transform2D) -> bool { + let bottom = transform.matrix[2]; + bottom[0].abs() < 1e-6 && bottom[1].abs() < 1e-6 && (bottom[2] - 1.0).abs() < 1e-6 +} + +/// A glyph-local point mapped to world space through the renderer's placement +/// transform — the glyph's resolved affine applied about the origin, then +/// translated to `(px, py)`. Matches [`placement_transform`]'s SVG output (it +/// uses the affine projection, dropping any projective bottom row). +fn placed_point(px: f32, py: f32, transform: &Option, lx: f32, ly: f32) -> (f32, f32) { + let (tx, ty) = match transform { + None => (lx, ly), + Some(t) => { + let m = t.matrix; + ( + m[0][0] * lx + m[0][1] * ly + m[0][2], + m[1][0] * lx + m[1][1] * ly + m[1][2], + ) + } + }; + (px + tx, py + ty) +} + +/// The SVG `transform` placing a glyph at `(x, y)` and applying its resolved +/// affine (`scale`/`rotate`/`skew` about the glyph origin) when present. The +/// affine is the inner (rightmost) transform, so the glyph's local outline is +/// transformed, then translated into place. `None` is the common case: a bare +/// translate, byte-identical to the pre-transform output. A non-affine transform +/// is rendered as its affine projection (the bottom row is dropped); the render +/// loop emits a diagnostic for that case. +fn placement_transform(x: f32, y: f32, transform: &Option) -> String { + match transform { + None => format!("translate({} {})", num(x), num(y)), + Some(t) => { + let m = t.matrix; + // SVG matrix(a b c d e f) is the affine [[a c e],[b d f],[0 0 1]]; + // map it from the row-major 3×3 (the bottom row is implicit). + format!( + "translate({} {}) matrix({} {} {} {} {} {})", + num(x), + num(y), + num(m[0][0]), + num(m[1][0]), + num(m[0][1]), + num(m[1][1]), + num(m[0][2]), + num(m[1][2]), + ) + } + } +} + +/// Splits an `0xRRGGBBAA` colour into an SVG `stroke` value and an optional +/// `stroke-opacity` attribute (empty when fully opaque). +fn stroke_colour(rgba: u32) -> (String, String) { + let r = (rgba >> 24) & 0xff; + let g = (rgba >> 16) & 0xff; + let b = (rgba >> 8) & 0xff; + let a = rgba & 0xff; + let stroke = format!("#{r:02x}{g:02x}{b:02x}"); + let opacity = if a == 0xff { + String::new() + } else { + format!(" stroke-opacity=\"{}\"", num(a as f32 / 255.0)) + }; + (stroke, opacity) +} + /// Splits an `0xRRGGBBAA` colour into an SVG `fill` value and an optional /// `fill-opacity` attribute (empty when fully opaque). fn colour(rgba: u32) -> (String, String) { @@ -419,8 +585,9 @@ fn num(v: f32) -> String { s } -/// Normalises a coordinate value: `-0.0` and tiny `±0` round to `0.0`; other -/// values pass through. Keeps the formatted form and the stored stats agreeing. +/// Normalises a coordinate value: `-0.0` (which compares equal to `0.0`) maps to +/// `0.0`; every other value passes through unchanged. Keeps the formatted form +/// and the stored stats agreeing. fn num_f(v: f32) -> f32 { if v == 0.0 { 0.0 @@ -434,7 +601,8 @@ mod tests { use super::*; use epiphany_core::generators::valid_score_rich; use epiphany_layout_ir::{ - to_constrained, to_logical, ConstraintSolver, SolverConfig, StubSolver, + to_constrained, to_logical, ConstraintSolver, GlyphStyle, Point, SolverConfig, StaffSpace, + Stroke, StubSolver, Transform2D, }; fn stub_layout(seed: u64) -> ResolvedLayoutIR { @@ -444,6 +612,120 @@ mod tests { .layout } + #[test] + fn a_stroke_renders_as_a_traced_line() { + let mut layout = stub_layout(11); + let glyph_count = layout.glyphs.len(); + // The engraver already emits strokes (staff lines, stems, …); this adds + // one more and checks it renders and traces on top of them. + let base_strokes = layout.strokes.len(); + layout.strokes.push(Stroke { + provenance: layout.glyphs[0].provenance.clone(), + from: Point::new(0.0, 0.0), + to: Point::new(4.0, 0.0), + thickness: StaffSpace(0.13), + layer: -1, + style: GlyphStyle { rgba: 0x0000_00ff }, + }); + let out = render(&layout, &RenderOptions::default()); + assert!( + out.is_well_formed(), + "SVG with a stroke must be well-formed" + ); + assert_eq!(out.stats.stroke_count, base_strokes + 1); + assert!( + out.svg.contains("" + ); + assert!( + out.svg.contains("data-kind=\"stroke\""), + "the stroke carries a provenance trace" + ); + assert_eq!( + out.stats.provenance_count, + glyph_count + base_strokes + 1, + "every glyph and stroke is traced" + ); + } + + #[test] + fn a_glyph_transform_is_applied_not_dropped() { + let mut layout = stub_layout(11); + // A 2× scale about the glyph origin: the renderer must emit it, not ignore + // it (otherwise a future solver's transform would silently disappear). + layout.glyphs[0].transform = Some(Transform2D { + matrix: [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]], + }); + let out = render(&layout, &RenderOptions::default()); + assert!(out.is_well_formed()); + assert!( + out.svg.contains("matrix(2 0 0 2 0 0)"), + "the glyph's resolved transform is applied" + ); + } + + #[test] + fn glyph_transform_expands_the_view_box() { + let base = stub_layout(11); + let base_width = render(&base, &RenderOptions::default()).stats.view_box[2]; + let mut transformed = base.clone(); + // Translate one glyph far to the right via its transform; the bounds must + // grow to contain it (untransformed bounds would crop it). + transformed.glyphs[0].transform = Some(Transform2D { + matrix: [[1.0, 0.0, 1000.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + }); + let width = render(&transformed, &RenderOptions::default()) + .stats + .view_box[2]; + assert!( + width > base_width + 900.0, + "a transformed glyph widens the viewBox ({width} vs {base_width})" + ); + } + + #[test] + fn thick_stroke_expands_bounds_by_half_width() { + let mut layout = stub_layout(11); + let provenance = layout.glyphs[0].provenance.clone(); + layout.glyphs.clear(); + layout.strokes.push(Stroke { + provenance, + from: Point::new(0.0, 0.0), + to: Point::new(4.0, 0.0), + thickness: StaffSpace(2.0), + layer: 0, + style: GlyphStyle::default(), + }); + let options = RenderOptions { + margin: 0.0, + ..RenderOptions::default() + }; + let height = render(&layout, &options).stats.view_box[3]; + // A horizontal rule of thickness 2 spans y ∈ [−1, 1]: a half-width each + // side, so the perpendicular extent is at least the full thickness. + assert!( + height >= 2.0, + "half-thickness expands the perpendicular extent (got {height})" + ); + } + + #[test] + fn non_affine_transform_is_diagnosed() { + let mut layout = stub_layout(11); + // A non-zero bottom row makes the transform projective, not affine. + layout.glyphs[0].transform = Some(Transform2D { + matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.5, 0.0, 1.0]], + }); + let out = render(&layout, &RenderOptions::default()); + assert!(out.is_well_formed()); + assert!( + out.diagnostics + .iter() + .any(|d| d.message.contains("non-affine")), + "a projective transform is surfaced as a diagnostic, not silently mis-rendered" + ); + } + #[test] fn renders_well_formed_svg_with_one_path_per_glyph() { let layout = stub_layout(11); @@ -454,7 +736,10 @@ mod tests { assert_eq!(out.stats.path_count, layout.glyphs.len()); assert_eq!(out.stats.fallback_rect_count, 0); assert!(out.diagnostics.is_empty()); - assert_eq!(out.stats.provenance_count, layout.glyphs.len()); + assert_eq!( + out.stats.provenance_count, + layout.glyphs.len() + layout.strokes.len() + ); assert!(out.svg.contains(" 0, "{fixture}: nothing was laid out"); + assert_eq!( + out.stats.path_count + out.stats.fallback_rect_count, + out.stats.glyph_count, + "{fixture}: every engraver glyph must be drawn (path or fallback), none dropped" + ); + assert_eq!( + out.stats.provenance_count, + out.stats.glyph_count + out.stats.stroke_count, + "{fixture}: every drawn glyph and stroke must carry a provenance trace" + ); + assert!( + out.diagnostics.is_empty(), + "{fixture}: stub-pipeline glyphs are all bundled, so no fallback is expected" + ); + } +} diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt index dfcdde5..9d394f0 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.snapshot.txt @@ -1,15 +1,14 @@ fixture=ten_measure_single_staff solver=stub -glyph_count=98 -path_count=98 +glyph_count=51 +path_count=51 fallback_rect_count=0 -provenance_count=98 +stroke_count=51 +provenance_count=102 layer_count=1 hard_constraint_count=0 xml_well_formed=true -view_box=[-2 -5.2408 102.18 11.6328] +view_box=[-3.065 -3.632 88.87701 11.632] class_counts: - accidental=10 - clef=2 - flag=1 - notehead=83 - rest=2 + barline=10 + clef=1 + notehead=40 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg index 1233e9c..2a8d4ca 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.stub.svg @@ -1,106 +1,110 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt index 273af4b..c6f222e 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.snapshot.txt @@ -1,16 +1,14 @@ fixture=valid_score_rich solver=stub -glyph_count=32 -path_count=32 +glyph_count=11 +path_count=11 fallback_rect_count=0 -provenance_count=32 +stroke_count=33 +provenance_count=44 layer_count=1 hard_constraint_count=0 xml_well_formed=true -view_box=[-2 -5.2408 36.18 11.6328] +view_box=[-3.065 -3.632 39.329998 11.632] class_counts: - accidental=1 - clef=6 - dynamic=1 - flag=1 - notehead=21 - rest=2 + barline=1 + clef=3 + notehead=7 diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg index d123acf..9e5cc65 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.stub.svg @@ -1,40 +1,52 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/epiphany-render-svg/tools/extract_bravura_outlines.py b/crates/epiphany-render-svg/tools/extract_bravura_outlines.py index 3ab4f2a..6b25255 100644 --- a/crates/epiphany-render-svg/tools/extract_bravura_outlines.py +++ b/crates/epiphany-render-svg/tools/extract_bravura_outlines.py @@ -14,26 +14,52 @@ The font is NOT vendored; only the generated Rust is committed. Bravura is © Steinberg Media Technologies GmbH under the SIL Open Font License 1.1; the extracted outlines are redistributed under the same license (see OFL.txt). """ -import json, re, sys, urllib.request +import hashlib, json, re, sys, urllib.request -FONT_URL = "https://raw.githubusercontent.com/steinbergmedia/bravura/master/redist/otf/Bravura.otf" -NAMES_URL = "https://raw.githubusercontent.com/w3c/smufl/gh-pages/metadata/glyphnames.json" +# Pinned, immutable sources. A moving branch (`master` / `gh-pages`) would make +# regeneration non-reproducible: a future font update would silently change the +# outlines. Both sources are pinned to a commit SHA and their bytes verified +# against a recorded SHA-256, so a substituted or updated source is rejected +# rather than quietly accepted. To deliberately move to a newer font, bump the +# ref AND the checksum together (a reviewable change), then regenerate. +FONT_TAG = "1.392" # steinbergmedia/bravura tag bravura-1.392 +FONT_REF = "301087ca0b0d30b65d81bc3e718ff64b613e2a9a" +NAMES_REF = "31a327a29640c313b12076739987bd7f25bdddde" # w3c/smufl gh-pages +FONT_URL = f"https://raw.githubusercontent.com/steinbergmedia/bravura/{FONT_REF}/redist/otf/Bravura.otf" +NAMES_URL = f"https://raw.githubusercontent.com/w3c/smufl/{NAMES_REF}/metadata/glyphnames.json" +FONT_SHA256 = "dca2d90c88437a701b1c2e71fa54e76f9fa41d7deee935d74dc871ea66ecfdd2" +NAMES_SHA256 = "1d05352599a20983d1c901635dc75d76f063c0987a7bee65f145325fc3e0d29f" # Exactly the glyph set the v0 layout pipeline can name (layout-ir BRAVURA_METRICS). NAMES = ["noteheadBlack","noteheadHalf","noteheadWhole","noteheadDoubleWhole", "gClef","fClef","cClef","accidentalSharp","accidentalFlat","accidentalNatural", "accidentalDoubleSharp","restWhole","restHalf","restQuarter","rest8th", -"flag8thUp","flag8thDown","augmentationDot","timeSig4","timeSigCommon", +"flag8thUp","flag8thDown","augmentationDot", +"timeSig0","timeSig1","timeSig2","timeSig3","timeSig4","timeSig5","timeSig6", +"timeSig7","timeSig8","timeSig9","timeSigCommon", "barlineSingle","barlineFinal","dynamicForte","dynamicPiano"] +def verify(data, expected, what): + actual = hashlib.sha256(data).hexdigest() + if actual != expected: + sys.exit(f"{what} SHA-256 mismatch:\n expected {expected}\n actual {actual}\n" + "the pinned source changed; refusing to regenerate against an unverified font " + "(bump FONT_REF/NAMES_REF and the checksum deliberately if this is intended)") + return data + def load(): from fontTools.ttLib import TTFont import io if "--local" in sys.argv: - font = TTFont("Bravura.otf"); names = json.load(open("glyphnames.json")) + font_bytes = open("Bravura.otf", "rb").read() + names_bytes = open("glyphnames.json", "rb").read() else: - font = TTFont(io.BytesIO(urllib.request.urlopen(FONT_URL).read())) - names = json.loads(urllib.request.urlopen(NAMES_URL).read()) + font_bytes = urllib.request.urlopen(FONT_URL).read() + names_bytes = urllib.request.urlopen(NAMES_URL).read() + verify(font_bytes, FONT_SHA256, "Bravura.otf") + verify(names_bytes, NAMES_SHA256, "glyphnames.json") + font = TTFont(io.BytesIO(font_bytes)) + names = json.loads(names_bytes) return font, names def round_d(d, nd=4): @@ -53,7 +79,9 @@ def main(): scale = 1.0 / sp gs = font.getGlyphSet() cmap = font.getBestCmap() + hmtx = font["hmtx"] rows = [] + metrics = [] # (name, advance, [l,b,r,t]) in 1/1024-staff-space integer units for name in NAMES: cp = int(glyphnames[name]["codepoint"].replace("U+", ""), 16) g = cmap.get(cp) @@ -65,7 +93,16 @@ def main(): bp = BoundsPen(gs); gs[g].draw(bp) l, b, r, t = ([round(v * scale, 4) for v in bp.bounds] if bp.bounds else [0, 0, 0, 0]) rows.append((name, cp, d, (l, b, r, t))) + # Companion metrics for layout-ir `BRAVURA_METRICS` (1/1024 staff space): + # the glyph's advance (from hmtx) and tight bbox. + adv1024 = round(hmtx[g][0] * scale * 1024) + bbox1024 = [round(v * scale * 1024) for v in (bp.bounds or (0, 0, 0, 0))] + metrics.append((name, adv1024, bbox1024)) rows.sort() + print("// --- BRAVURA_METRICS rows (advance, [l,b,r,t] in 1/1024 staff space) ---", + file=sys.stderr) + for name, adv1024, bbox1024 in sorted(metrics): + print(f' GlyphMetric::new("{name}", {adv1024}, {bbox1024}),', file=sys.stderr) o = [] o.append("//! GENERATED by `tools/extract_bravura_outlines.py` — do not edit by hand.") o.append("//!") @@ -75,6 +112,12 @@ def main(): o.append("//! (musical convention, positive y = higher pitch), relative to each glyph's") o.append("//! origin, rounded to 4 decimals. The renderer applies a single y-flip wrapper.") o.append("//!") + o.append(f"//! Source (pinned + SHA-256 verified on extraction): Bravura {FONT_TAG},") + o.append(f"//! steinbergmedia/bravura @ {FONT_REF}") + o.append(f"//! (sha256 {FONT_SHA256});") + o.append(f"//! glyph names from w3c/smufl gh-pages @ {NAMES_REF}") + o.append(f"//! (sha256 {NAMES_SHA256}).") + o.append("//!") o.append("//! Bravura is (c) Steinberg Media Technologies GmbH under the SIL Open Font") o.append("//! License 1.1; these extracted outlines are redistributed under the same license") o.append("//! (see `tools/OFL.txt`).") diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index ef9c38e..503cafa 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -208,4 +208,47 @@ mod tests { assert_eq!(s.cross_cutting.markers.len(), 1); assert_eq!(s.cross_cutting.chord_symbols.len(), 1); } + + /// 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 + /// synthesized time-signature glyphs. + #[test] + fn a_measure_depends_on_its_time_signature_and_start_anchor() { + use epiphany_core::{TimeSignatureId, TypedObjectId}; + use epiphany_layout_ir::to_logical; + + let mut score = ten_measure_single_staff(1); + let time_signature: TimeSignatureId = score.identity.mint(); + let (measure_id, region_id) = { + let region = &mut score.canvas.regions[0]; + let region_id = region.id; + let instance = region + .content + .staff_instances_mut() + .expect("the fixture is staff-based") + .first_mut() + .expect("a staff instance"); + instance.measures[0].time_signature = Some(time_signature); + (instance.measures[0].id, region_id) + }; + + let logical = to_logical(&score); + let measure = logical + .regions + .iter() + .flat_map(|r| r.objects.iter()) + .find(|o| o.provenance().source == TypedObjectId::Measure(measure_id)) + .expect("measure 0 is projected"); + let deps = &measure.provenance().dependencies; + assert!( + deps.contains(&TypedObjectId::TimeSignature(time_signature)), + "a measure depends on the time signature it displays" + ); + // Each fixture measure is region-anchored, so the region is a dep too. + assert!( + deps.contains(&TypedObjectId::Region(region_id)), + "a measure depends on its start anchor's target" + ); + } } diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index 2b893bd..dfc8365 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -118,11 +118,134 @@ pub fn gen_point(rng: &mut Rng) -> Point { Point::new(coord(rng), coord(rng)) } -/// A logical layout object (provenance + an optional owning staff). +/// A logical engraving-content payload — every [`LayoutContent`] variant, so the +/// generator/fuzz surface exercises the enriched payloads, not just structural. +pub fn gen_layout_content(rng: &mut Rng) -> LayoutContent { + fn time(rng: &mut Rng) -> TimePoint { + if rng.boolean() { + TimePoint::Musical(epiphany_core::MusicalPosition( + epiphany_core::RationalTime::new(rng.range(0, 16) as i64, 4).expect("nonzero"), + )) + } else { + TimePoint::WallClock(epiphany_core::WallClockTime(rng.range(0, 10_000) as i64)) + } + } + fn duration(numerator: i64, denominator: i64) -> epiphany_core::MusicalDuration { + epiphany_core::MusicalDuration( + epiphany_core::RationalTime::new(numerator, denominator).expect("nonzero"), + ) + } + fn component(rng: &mut Rng, offset: epiphany_core::MusicalDuration) -> PlacedComponent { + PlacedComponent { + offset, + component: epiphany_core::NotatedComponent { + base_value: *rng.choose(&[ + epiphany_core::NoteValue::Whole, + epiphany_core::NoteValue::Half, + epiphany_core::NoteValue::Quarter, + epiphany_core::NoteValue::Eighth, + epiphany_core::NoteValue::Sixteenth, + ]), + dots: rng.range(0, 4) as u8, + tuplet: None, + tied_to_next: rng.boolean(), + }, + tuplet: None, + } + } + fn components(rng: &mut Rng) -> Vec { + let mut out = vec![component(rng, epiphany_core::MusicalDuration::zero())]; + if rng.boolean() { + out.push(component(rng, duration(1, 4))); + } + out + } + fn clefs(rng: &mut Rng) -> Vec { + if rng.boolean() { + vec![ + PlacedClef { + time: time(rng), + clef: epiphany_core::Clef::treble(), + }, + PlacedClef { + time: time(rng), + clef: epiphany_core::Clef::bass(), + }, + ] + } else { + Vec::new() + } + } + fn keys(rng: &mut Rng) -> Vec { + if rng.boolean() { + vec![PlacedKeySignature { + time: time(rng), + key: epiphany_core::KeySignature::new(rng.range(0, 14) as i8 - 7) + .expect("generator stays in -7..=7"), + }] + } else { + Vec::new() + } + } + fn spelling(rng: &mut Rng) -> Option { + rng.boolean().then(|| { + let nominal = *rng.choose(&[ + epiphany_core::CmnNominal::C, + epiphany_core::CmnNominal::D, + epiphany_core::CmnNominal::E, + epiphany_core::CmnNominal::F, + epiphany_core::CmnNominal::G, + epiphany_core::CmnNominal::A, + epiphany_core::CmnNominal::B, + ]); + epiphany_core::PitchSpelling::cmn(nominal, rng.range(2, 7) as i8) + }) + } + fn barline(rng: &mut Rng) -> BarlineKind { + match rng.below(3) { + 0 => BarlineKind::Interior, + 1 => BarlineKind::RegionEnd, + _ => BarlineKind::Final, + } + } + match rng.below(5) { + 0 => LayoutContent::Structural, + 1 => LayoutContent::Staff(StaffContent { + clefs: clefs(rng), + keys: keys(rng), + }), + 2 => LayoutContent::Note(NoteContent { + position: time(rng), + components: components(rng), + pitches: vec![NotePitch { + pitch: epiphany_core::PitchId::new(epiphany_core::ReplicaId(7), rng.next_u64()), + spelling: spelling(rng), + }], + }), + 3 => LayoutContent::Rest(RestContent { + position: time(rng), + components: components(rng), + staff_position: rng + .boolean() + .then(|| epiphany_core::StaffPosition(rng.range(0, 9) as i16)), + }), + _ => LayoutContent::Measure(MeasureContent { + start: time(rng), + barline: barline(rng), + time_signature: rng.boolean().then(|| TimeSignatureContent { + numerator: rng.range(1, 13) as u16, + denominator: *rng.choose(&[2, 4, 8, 16]), + }), + }), + } +} + +/// A logical layout object (provenance + an optional owning staff + content). pub fn gen_layout_object(rng: &mut Rng) -> LayoutObject { - LayoutObject::from_projection( + LayoutObject::from_projection_with_content( gen_provenance(rng), rng.boolean().then(|| crate::generators::staff_id(rng)), + gen_layout_content(rng), ) } @@ -189,6 +312,21 @@ pub fn gen_logical_layout_ir(rng: &mut Rng) -> LogicalLayoutIR { } } +/// A non-glyph line primitive (a staff line / stem / barline), so the +/// generator/fuzz surface exercises strokes alongside glyphs. +pub fn gen_stroke(rng: &mut Rng) -> Stroke { + Stroke { + provenance: gen_provenance(rng), + from: gen_point(rng), + to: gen_point(rng), + thickness: gen_staff_space(rng), + layer: rng.range(0, 8) as i32 - 4, + style: GlyphStyle { + rgba: rng.next_u64() as u32, + }, + } +} + /// A constrained layout IR whose catalog hash covers exactly its glyph metrics /// (so the real stub solver accepts it as well-formed), with an **internally /// consistent** vertical band: every glyph names the band, and the band's @@ -229,11 +367,15 @@ pub fn gen_constrained_layout_ir(rng: &mut Rng) -> ConstrainedLayoutIR { regions: vec![], horizontal_slots, glyphs, + strokes: (0..rng.range_usize(0, 3)) + .map(|_| gen_stroke(rng)) + .collect(), vertical_bands: vec![band], constraints: vec![], engraving_decisions: (0..decisions) .map(|_| gen_engraving_decision(rng)) .collect(), + diagnostics: Vec::new(), catalog: GlyphCatalogIdentity { metrics_hash, ..GlyphCatalogIdentity::default() @@ -326,12 +468,15 @@ pub fn gen_round_trip_report(rng: &mut Rng) -> RoundTripReport { .primitives .iter() .map(|primitive| primitive.provenance.source) + .chain(render.strokes.iter().map(|stroke| stroke.provenance.source)) .collect(); + let total = render.primitives.len() + render.strokes.len(); RoundTripReport { status: SolveStatus::Solved, - logical_objects: render.primitives.len(), + logical_objects: total, glyphs: render.primitives.len(), - render_primitives: render.primitives.len(), + render_strokes: render.strokes.len(), + render_primitives: total, recovered_sources, } } @@ -1305,7 +1450,10 @@ mod tests { let score = fixtures::ten_measure_single_staff(0xA11CE); let report = round_trip(&score); assert!(report.glyphs > 0); - assert_eq!(report.glyphs, report.render_primitives); + assert_eq!( + report.render_primitives, + report.glyphs + report.render_strokes + ); let measures = report .recovered_sources diff --git a/crates/epiphany-testkit/tests/acceptance.rs b/crates/epiphany-testkit/tests/acceptance.rs index 8152d25..656f590 100644 --- a/crates/epiphany-testkit/tests/acceptance.rs +++ b/crates/epiphany-testkit/tests/acceptance.rs @@ -214,7 +214,12 @@ fn criterion_6_layout_round_trip() { for seed in 0..128u64 { let report = layout_stub::round_trip(&fixtures::ten_measure_single_staff(seed)); assert!(report.glyphs > 0); - assert_eq!(report.glyphs, report.render_primitives); + // The render IR carries glyph *and* stroke primitives; the round-trip + // recovers a source for every one of them. + assert_eq!( + report.render_primitives, + report.glyphs + report.render_strokes + ); layout_stub::round_trip(&generators::graph::valid_score_rich(seed)); }