layout-ir: staff_step_pitch, the clef-relative position inverse

The inverse of staff_position: a staff step (a clicked staff height) back to the
diatonic pitch (nominal, octave) written there under a clef. This is the vertical
half of the click-to-insert inverse the GUI will use — turning where you click on
a staff into the pitch to place.

It mirrors the forward map's clef and octave-shift convention exactly, computes
the diatonic index in i64 so an extreme step cannot overflow the intermediate,
and returns None for a percussion clef (no diatonic mapping) or a position whose
octave falls outside i8 (rather than wrapping to a wrong-looking octave). A
round-trip test covers treble/bass/alto/tenor and octave-shifted (8va/8vb)
trebles across octaves and nominals, plus the out-of-range refusals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
Levi Neuwirth 2026-06-29 17:28:23 -04:00
parent 9539b1652b
commit e5efe22d5b
2 changed files with 92 additions and 1 deletions

View File

@ -63,6 +63,38 @@ pub fn staff_position(nominal: CmnNominal, octave: i8, clef: &Clef) -> StaffStep
}
}
/// The [`CmnNominal`] for a diatonic letter index `0..=6` (`C..=B`); callers pass
/// `rem_euclid(7)`, so out-of-range values are unreachable and fold to `B`.
fn cmn_nominal(letter: i32) -> CmnNominal {
match letter {
0 => CmnNominal::C,
1 => CmnNominal::D,
2 => CmnNominal::E,
3 => CmnNominal::F,
4 => CmnNominal::G,
5 => CmnNominal::A,
_ => CmnNominal::B,
}
}
/// The diatonic pitch `(nominal, octave)` written at staff position `step` under
/// `clef` — the inverse of [`staff_position`], used to turn a clicked staff height
/// into the pitch to insert. `None` for a percussion clef (no diatonic mapping — it
/// collapses every pitch onto its reference line), and `None` when the position is
/// so far off the staff that its octave falls outside the representable `i8` range
/// (rather than silently wrapping to a wrong-looking octave). Inverse only of the
/// *diatonic* position; the accidental is a separate, caller-chosen concern.
pub fn staff_step_pitch(step: StaffStep, clef: &Clef) -> Option<(CmnNominal, i8)> {
let reference_line_step = (clef.line as i64 - 1) * 2;
let reference_diatonic = clef_reference_diatonic(clef.shape)? as i64;
// i64 so an extreme `step` cannot overflow the intermediate before the octave
// range-check rejects it.
let diatonic =
step as i64 - reference_line_step + reference_diatonic + clef.octave_shift as i64 * 7;
let octave = i8::try_from(diatonic.div_euclid(7)).ok()?;
Some((cmn_nominal(diatonic.rem_euclid(7) as i32), octave))
}
/// 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 {
@ -216,6 +248,65 @@ mod tests {
assert_eq!(staff_position(CmnNominal::C, 4, &Clef::tenor()), 6);
}
#[test]
fn staff_step_pitch_inverts_staff_position() {
let nominals = [
CmnNominal::C,
CmnNominal::D,
CmnNominal::E,
CmnNominal::F,
CmnNominal::G,
CmnNominal::A,
CmnNominal::B,
];
// Plain clefs and octave-shifted (8va/8vb) trebles — the editor inverse
// depends on the same octave_shift sign convention as the forward map.
let octave_treble = |shift: i8| Clef {
shape: ClefShape::G,
line: 2,
octave_shift: shift,
};
for clef in [
Clef::treble(),
Clef::bass(),
Clef::alto(),
Clef::tenor(),
octave_treble(1),
octave_treble(-1),
] {
for octave in 1..8i8 {
for nominal in nominals {
let step = staff_position(nominal, octave, &clef);
assert_eq!(
staff_step_pitch(step, &clef),
Some((nominal, octave)),
"round trip failed for {nominal:?}{octave} under {clef:?}"
);
}
}
}
// A percussion clef has no diatonic mapping, so no inverse.
let percussion = Clef {
shape: ClefShape::Percussion,
line: 3,
octave_shift: 0,
};
assert_eq!(staff_step_pitch(0, &percussion), None);
}
#[test]
fn staff_step_pitch_refuses_an_unrepresentable_octave() {
// A click so far off the staff that its octave overflows i8 is refused, not
// wrapped to a plausible-but-wrong octave.
let treble = Clef::treble();
assert_eq!(staff_step_pitch(i32::MAX, &treble), None);
assert_eq!(staff_step_pitch(i32::MIN, &treble), None);
assert_eq!(staff_step_pitch(10_000, &treble), None);
assert_eq!(staff_step_pitch(-10_000, &treble), None);
// …but a position just inside the i8 octave range still resolves.
assert!(staff_step_pitch(staff_position(CmnNominal::C, 9, &treble), &treble).is_some());
}
#[test]
fn accidentals_never_change_staff_position() {
// The position is purely diatonic — the same nominal+octave lands on the

View File

@ -97,7 +97,7 @@ pub use constrained::{
};
pub use engrave_theory::{
accidental_glyph, clef_glyph, flag_glyph, has_stem, key_signature, notehead_glyph, rest_glyph,
staff_position, KeyAccidental, StaffStep,
staff_position, staff_step_pitch, KeyAccidental, StaffStep,
};
pub use engraving::{
AuthorId, DecisionSource, EngravingDecision, EngravingDecisionId, EngravingDecisionKind,