From 7aa0b938e222f798cce2ddbf97d83bee87c91b0e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 9 Jul 2026 13:47:47 -0400 Subject: [PATCH] Stems point away from the middle line, and reach it Every stem in the engine pointed up, on the notehead's right, at a constant octave. A C6 three ledger lines above the staff grew an upward stem shooting past everything -- visible in the two_staff and three_staff renders. It is also why the slurs are wrong: an Auto slur is placed OPPOSITE the stems, so no correct side could be chosen while every stem pointed the same way. Stems are therefore a prerequisite for the slur fix, not merely adjacent to it. - Direction: away from the middle line, decided by the head furthest from it, ties going down (the convention for a note on the line, and for a chord that straddles it evenly). - Attachment: the side it points -- an up-stem at the lowest head's right edge, a down-stem at the highest head's left. Read from the head's own bounding box rather than the rounded NOTEHEAD_STEM_X: for noteheadBlack those are 1.1807 and 0, and 1.1807 is Bravura's real stemUpSE (1.18). The old 1.15 was a rounding -- it accounts for the whole of ten_measure's churn, where every stem moved right by 0.031 and nothing else moved at all. - Length: an octave from the outer head, but drawn out to the middle line when the note lies beyond it, so no stem dangles in the ledger field. No version moves. This is the PROJECTION changing, not a solver: to_constrained emits 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 nothing in the suite had ever exercised a down-stem. Mutation-verified: restoring always-up plus fixed-octave fails it. Parked, found on the way: BRAVURA_METRICS' notehead anchors are unusable as written. They name stemUpNW/stemDownSE -- the corners an up- and down-stem never attach to -- and their x of 1180 reads like 1.18 staff spaces written in thousandths rather than the table's 1/1024 units (1.18 sp = 1208). Nothing consumes them except metrics_hash, so correcting them would move the GlyphCatalogIdentity every conformance claim declares. That makes three parked candidates; the house rule opens a batch pass at three. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-layout-ir/DECISIONS.md | 56 +++++++ crates/epiphany-layout-ir/src/constrained.rs | 151 ++++++++++++++++-- .../ten_measure_single_staff.engrave.svg | 80 +++++----- .../golden/ten_measure_single_staff.stub.svg | 80 +++++----- .../ten_measure_with_repeats.engrave.svg | 80 +++++----- .../golden/ten_measure_with_repeats.stub.svg | 80 +++++----- .../golden/ten_measure_with_slurs.engrave.svg | 80 +++++----- .../golden/ten_measure_with_slurs.stub.svg | 80 +++++----- ...e_staff_close_content.engrave.snapshot.txt | 2 +- .../three_staff_close_content.engrave.svg | 120 +++++++------- .../golden/three_staff_close_content.stub.svg | 24 +-- ...o_staff_close_content.engrave.snapshot.txt | 2 +- .../two_staff_close_content.engrave.svg | 50 +++--- .../golden/two_staff_close_content.stub.svg | 16 +- .../tests/golden/valid_score_rich.engrave.svg | 14 +- .../tests/golden/valid_score_rich.stub.svg | 14 +- 16 files changed, 552 insertions(+), 377 deletions(-) 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 @@ - + - +