diff --git a/crates/epiphany-layout-ir/DECISIONS.md b/crates/epiphany-layout-ir/DECISIONS.md index 778ae5a..975bc0e 100644 --- a/crates/epiphany-layout-ir/DECISIONS.md +++ b/crates/epiphany-layout-ir/DECISIONS.md @@ -717,3 +717,59 @@ ignored). **Both of the above are parked, not open.** The Pass-13 batch is closed and the house rule opens a pass at ≥3 candidates; these are two. They join a future batch rather than reopening one. + +## Stem direction and length (2026-07-09) + +Until now every stem in the engine pointed **up**, on the notehead's right, at a +constant octave — so a C6 sitting three ledger lines above the staff grew an +upward stem shooting past everything, and a slur placed *opposite the stems* +(the `Auto` rule) could never be given a correct side. + +- **Direction: away from the middle line.** The head furthest from it decides, + and a tie goes **down** — the convention for a note *on* the middle line and + for a chord straddling it evenly. A single head below the line stems up. +- **Attachment: the side it points.** An up-stem rides the lowest head's right + edge, a down-stem the highest head's left. Taken from the head's own bounding + box, not the rounded `NOTEHEAD_STEM_X` constant: for `noteheadBlack` those are + x = 1.1807 and 0, and 1.1807 is Bravura's real `stemUpSE` (1.18). The old 1.15 + was a rounding, which is the whole of `ten_measure`'s golden churn (every stem + moved right by 0.031 and not one moved otherwise). +- **Length: an octave, but at least to the middle line.** A note beyond an octave + from that line has its stem drawn out *to* it (`max`/`min` only ever lengthen), + so no stem dangles in the ledger field. + +No version moves: this is the **projection** changing, not a solver. `to_constrained` +produces different geometry from the same graph, so `ENGRAVER_VERSION`'s promise +(same input ⇒ same output) is untouched. Every golden churns, stub and engrave +alike, because stems are constrained-stage geometry. + +Locked by `a_stem_points_away_from_the_middle_line_and_reaches_it`, which +re-pitches a generated score across four octaves (the corpus generator writes only +low notes, so it never exercised a down-stem). Mutation-verified: restoring +always-up + fixed-octave fails it. + +**Deferred:** the y half of the attachment. SMuFL puts `stemUpSE` at y = +0.168 +and `stemDownNW` at −0.168, so a stem should meet the head slightly off its +centre; we attach at the centre. Cosmetic at this tier. + +## Parked: the notehead stem anchors are unusable as written (2026-07-09) + +`BRAVURA_METRICS`' `NOTEHEAD_ANCHORS` declares `stemUpNW` at x = 0 and +`stemDownSE` at x = 1180 (i.e. 1.152 staff spaces). Two problems, found while +implementing stem direction: + +- **The names are the wrong corners.** An up-stem attaches on the *right* of a + notehead, so the anchor there is SMuFL's `stemUpSE`, not `stemUpNW`; the left + one is `stemDownNW`. Bravura's `noteheadBlack` has exactly those two and does + not define the pair named here. +- **The x looks unit-confused.** Bravura's `stemUpSE` is at 1.18 staff spaces; + in this table's `1/1024` units that is 1208, not 1180. `1180` reads like 1.18 + written in thousandths. (`NOTEHEAD_STEM_X = 1.15` matches the same slip.) + +Nothing consumes the anchors — they enter only `metrics_hash`, so correcting them +moves the `GlyphCatalogIdentity` every conformance claim declares. Hence parked +rather than fixed in passing. The stem work sidesteps them by reading the head's +bounding box, whose right edge (1.1807) *is* the correct attachment. + +**Three parked candidates now stand** (this, `Staff::default_clef`, and the +`ConstrainedLayoutIR` listing gap). The house rule opens a batch pass at ≥3. diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 214080c..7810558 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -596,6 +596,8 @@ impl ConstrainedLayoutIR { // geometry verbatim, so it is what the renderer draws. const STAFF_LINE_THICKNESS: f32 = 0.13; const STEM_THICKNESS: f32 = 0.12; +// One octave from the outer notehead — but a stem on a note beyond the staff is +// drawn out to the middle line instead, so it never dangles in the ledger field. const STEM_LENGTH: f32 = 3.5; const STAFF_HEIGHT: f32 = 4.0; // 4 spaces between the outer lines of a 5-line staff const SYSTEM_STAFF_PITCH: f32 = 12.0; // vertical distance between stacked staves @@ -606,7 +608,10 @@ 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 + // Fallback stem attachment when a notehead's metrics are absent; a bundled head + // uses its own bounding box (right edge for an up-stem, left for a down-stem — + // SMuFL's `stemUpSE` / `stemDownNW`, which for `noteheadBlack` are x = 1.18 / 0). +const NOTEHEAD_STEM_X: f32 = 1.15; const ACCIDENTAL_X: f32 = 1.1; // the innermost accidental sits this far left of its notehead const ACC_STACK_X: f32 = 0.9; // each further-out stacked accidental steps left by this const KEY_SIG_START: f32 = 2.7; // x where a key signature begins (just after the clef) @@ -892,22 +897,46 @@ pub fn try_to_constrained( }); } let drawn = has_stem(value) && !ys.is_empty(); - let lo = ys + let fallback = step_to_y(yo, reference_step(&clef)); + let bottom = ys.iter().copied().fold(f32::INFINITY, f32::min); + let bottom = if ys.is_empty() { fallback } else { bottom }; + let top = ys .iter() .copied() - .fold(f32::INFINITY, f32::min) - .min(step_to_y(yo, reference_step(&clef))); - let hi = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max).max(lo); + .fold(f32::NEG_INFINITY, f32::max) + .max(bottom); + // Direction: the head furthest from the middle line decides, + // and a tie goes DOWN (the engraving convention for a note + // *on* the middle line, and for a chord straddling it + // evenly). So a single head below the middle line stems up. + let middle = yo + STAFF_HEIGHT * 0.5; + let up = (top - middle) < (middle - bottom); + // Attachment: an up-stem rides the right edge of the lowest + // head, a down-stem the left edge of the highest. + let head_box = metrics(name).map(|m| m.bounding_box()); + let x_off = if up { + head_box.map_or(NOTEHEAD_STEM_X, |b| b.right.0) + } else { + head_box.map_or(0.0, |b| b.left.0) + }; + // Length: an octave from the outer head, but a stem on a + // note beyond the staff is drawn out to the middle line, so + // it never dangles in the ledger field (`max`/`min` only + // ever lengthen). + let tip = if up { + (top + STEM_LENGTH).max(middle) + } else { + (bottom - STEM_LENGTH).min(middle) + }; 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, + lo: bottom, + hi: top, drawn, comp, + up, + x_off, + tip, }); } event_stems.insert(eid, stems); @@ -1223,12 +1252,13 @@ pub fn try_to_constrained( } for seg in segs { let info = column(&seg.key); - let stem_x = info.x + NOTEHEAD_STEM_X; + let stem_x = info.x + seg.x_off; let (from, to) = if seg.drawn { - ( - Point::new(stem_x, seg.lo), - Point::new(stem_x, seg.hi + STEM_LENGTH), - ) + // The stem runs from the head it attaches to — the + // lowest for an up-stem, the highest for a down one — + // out to its tip. + let base = if seg.up { seg.lo } else { seg.hi }; + (Point::new(stem_x, base), Point::new(stem_x, seg.tip)) } else { // A stemless value (whole note): a zero-length stem. (Point::new(info.x, seg.lo), Point::new(info.x, seg.lo)) @@ -1903,10 +1933,17 @@ struct Head { /// the column key plus the staff-space `y` extent). struct StemSeg { key: ColumnKey, + /// The lowest and highest notehead centre of the component's chord. lo: f32, hi: f32, drawn: bool, comp: usize, + /// Stem direction: up (right of the heads) or down (left of them). + up: bool, + /// Where the stem attaches, as an x offset from the column's notehead x. + x_off: f32, + /// The free end of the stem, in staff spaces. + tip: f32, } /// One component's rest glyph (absent when the value has no bundled rest glyph). @@ -4499,6 +4536,88 @@ mod tests { assert!(up.p1.y.0 > up.p0.y.0); } + /// A stem points AWAY from the middle line — up for a head below it, down + /// for a head above it or on it — and attaches on the side it points: an + /// up-stem at the head's right edge, a down-stem at its left. A stem on a + /// note beyond an octave from the middle line is drawn out TO that line, + /// rather than dangling a fixed octave into the ledger field. + /// + /// Every stem in the engine used to point up, on the right, at a constant + /// octave — an upward stem on a C6 three ledgers above the staff. It is also + /// why an `Auto` slur, which is placed *opposite* the stems, could not be + /// given a correct side until this landed. + #[test] + fn a_stem_points_away_from_the_middle_line_and_reaches_it() { + use epiphany_core::{CmnNominal, Event, PitchSpacePosition}; + // The corpus generator writes only low notes, so spread the octaves to + // straddle the middle line and reach well past a stem's length. + let mut score = valid_score_rich(11); + let ids: Vec<_> = score.events.iter().map(|e| e.id()).collect(); + for (index, id) in ids.iter().enumerate() { + if let Some(Event::Pitched(note)) = score.events.get_mut(*id) { + for pitch in &mut note.pitches { + if let PitchSpacePosition::Cmn { + nominal, octave, .. + } = &mut pitch.pitch.scale_position.position + { + *nominal = CmnNominal::C; + *octave = [3i8, 4, 5, 6][index % 4]; + } + } + } + } + let c = to_constrained(&to_logical(&score)); + + // Each staff's middle line, from that staff's own staff-line strokes. + let mut lines: BTreeMap = BTreeMap::new(); + for stroke in &c.strokes { + if matches!(stroke.provenance.source, TypedObjectId::Staff(_)) { + let entry = lines.entry(stroke.vertical_band).or_insert(f32::INFINITY); + *entry = entry.min(stroke.from.y.0); + } + } + + let stems: Vec<&Stroke> = c + .strokes + .iter() + .filter(|s| (s.thickness.0 - STEM_THICKNESS).abs() < 1e-6) + .filter(|s| (s.to.y.0 - s.from.y.0).abs() > 1e-6) + .collect(); + assert!(!stems.is_empty(), "the fixture draws stems"); + + let (mut saw_up, mut saw_down, mut saw_extended) = (false, false, false); + for stem in stems { + let middle = lines[&stem.vertical_band] + STAFF_HEIGHT * 0.5; + let (base, tip) = (stem.from.y.0, stem.to.y.0); + let up = tip > base; + assert_eq!( + up, + base < middle, + "a head below the middle line stems up, else down: base {base} middle {middle}" + ); + if up { + assert!(tip >= middle - 1e-4, "an up-stem reaches the middle line"); + saw_up = true; + } else { + assert!(tip <= middle + 1e-4, "a down-stem reaches the middle line"); + saw_down = true; + } + // Beyond an octave from the middle line, the stem stops AT that line. + if (base - middle).abs() > STEM_LENGTH { + assert!( + (tip - middle).abs() < 1e-4, + "a far-out note's stem stops at the middle line: {tip} vs {middle}" + ); + saw_extended = true; + } + } + assert!(saw_up && saw_down, "the fixture exercises both directions"); + assert!( + saw_extended, + "and at least one stem drawn out to the middle line" + ); + } + #[test] fn a_slur_with_an_unresolved_or_reversed_endpoint_keeps_its_anchor() { let (mut score, _) = repeat_ready_score(43); diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg index c2c0d9b..1f4931a 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg @@ -10,85 +10,85 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + 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 3634f29..bc77c31 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 @@ -10,85 +10,85 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg index 195ead1..2066dde 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.engrave.svg @@ -10,85 +10,85 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg index 115f34c..2f4220f 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_repeats.stub.svg @@ -10,85 +10,85 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg index 798e013..cfa7819 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.engrave.svg @@ -10,85 +10,85 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg index c93373c..4b9d00d 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_with_slurs.stub.svg @@ -10,85 +10,85 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.snapshot.txt index 033883c..a57f8ad 100644 --- a/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.snapshot.txt @@ -8,7 +8,7 @@ provenance_count=96 layer_count=1 hard_constraint_count=27 xml_well_formed=true -view_box=[5.5 -66.14458 17.379084 60.643997] +view_box=[5.5 -60.02458 17.379084 54.524002] class_counts: barline=3 clef=3 diff --git a/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.svg b/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.svg index 73888e3..5e8c52e 100644 --- a/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/three_staff_close_content.engrave.svg @@ -1,5 +1,5 @@ - + @@ -9,25 +9,25 @@ - - - - - - - - - - + + + + + + + + + + - + - + - + @@ -36,7 +36,7 @@ - + @@ -48,57 +48,57 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + - - + + diff --git a/crates/epiphany-render-svg/tests/golden/three_staff_close_content.stub.svg b/crates/epiphany-render-svg/tests/golden/three_staff_close_content.stub.svg index 6f9f516..a23de33 100644 --- a/crates/epiphany-render-svg/tests/golden/three_staff_close_content.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/three_staff_close_content.stub.svg @@ -20,14 +20,14 @@ - + - + - + @@ -36,7 +36,7 @@ - + @@ -49,37 +49,37 @@ - + - + - + - + - + - - + + - + diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt index 77b17c8..e9bf583 100644 --- a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.snapshot.txt @@ -8,7 +8,7 @@ provenance_count=54 layer_count=1 hard_constraint_count=18 xml_well_formed=true -view_box=[5.5 -39.584576 17.379084 34.084] +view_box=[5.5 -36.52458 17.379084 31.024] class_counts: barline=2 clef=2 diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg index c6a915c..4e3a967 100644 --- a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.engrave.svg @@ -1,5 +1,5 @@ - + @@ -9,20 +9,20 @@ - - - - - + + + + + - + - + - + @@ -31,32 +31,32 @@ - + - - - - - - - - - + + + + + + + + + - - - - - + + + + + - + diff --git a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg index e034b2d..3f0e4ff 100644 --- a/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg +++ b/crates/epiphany-render-svg/tests/golden/two_staff_close_content.stub.svg @@ -15,14 +15,14 @@ - + - + - + @@ -31,19 +31,19 @@ - + - + - - + + - + diff --git a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg index c8d654c..03005e9 100644 --- a/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/valid_score_rich.engrave.svg @@ -10,11 +10,11 @@ - + - + - + @@ -28,9 +28,9 @@ - + - + @@ -38,9 +38,9 @@ - + - + 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 425141c..233007c 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 @@ -10,11 +10,11 @@ - + - + - + @@ -28,9 +28,9 @@ - + - + @@ -38,9 +38,9 @@ - + - +