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:
parent
9539b1652b
commit
e5efe22d5b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue