editor-core: a staff_pitch_at click→pitch vertical inverse

The vertical half of click-to-insert: resolve a world point to the staff
under the cursor and the natural diatonic pitch at that height under the
staff's clef.

Staff selection is manifestation-aware. Each rendered bottom staff line
carries its staff's manifestation id as the stroke's stable_id, so a click
maps back to a concrete (region, staff_instance) by matching that id, then
chooses the nearest staff by 2D proximity — horizontal span first (which
region, since one staff tiles across regions that can share a y band), then
the vertical band. The earlier StaffId-collapsing helpers are gone.

The clef is resolved by time, not vector order: render_score builds a
per-staff start-clef table from the logical layout via active_clef (now
exported from epiphany-layout-ir) and threads it through open/commit, so a
mid-vector [bass@1, treble@0] still spells the staff start as treble.

A non-finite click is rejected up front — NaN would slip through
dist_to_band's comparisons as distance 0 and saturate the round() as i32
step into a bogus pitch.

Tests cover finite resolution, x-aware manifestation selection across staves
sharing a y band, non-finite rejection, and time-ordered clef resolution.
The GUI surfaces the resolved pitch on an empty-staff click.

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 18:56:34 -04:00
parent e5efe22d5b
commit 8bffd2f2fd
4 changed files with 299 additions and 18 deletions

View File

@ -39,18 +39,20 @@
//! produces a [`RenderIR`]; turning that into pixels is the renderer's job.
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
use epiphany_core::{
AcousticRealization, CmnNominal, Event, EventDuration, EventId, EventPosition, IdentifiedPitch,
MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpacePosition,
PitchSpelling, PitchedEvent, RegionTimeModel, ReplicaId, Score, SpellingDirective,
SpellingNominal, SpellingScope, SpellingSourceKind, StaffInstanceId, StemConfiguration,
TransactionId, TypedObjectId, VoiceId, WallClockTime,
AcousticRealization, Clef, CmnNominal, Event, EventDuration, EventId, EventPosition,
IdentifiedPitch, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
PitchSpacePosition, PitchSpelling, PitchedEvent, RegionId, RegionTimeModel, ReplicaId, Score,
SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind, StaffId, StaffInstance,
StaffInstanceId, StemConfiguration, TransactionId, TypedObjectId, VoiceId, WallClockTime,
};
use epiphany_layout_ir::{
to_constrained, to_logical, to_render, ConstraintSolver, HitTestMap, LayoutObjectId, Point,
RenderIR, ResolvedLayoutIR, SolverConfig,
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
ConstraintSolver, HitTestMap, LayoutContent, LayoutObjectId, LogicalLayoutIR, Point, RenderIR,
ResolvedLayoutIR, SolverConfig, TimePoint,
};
use epiphany_ops::{
AcceptOutcome, AuthorId, CausalContext, DeleteEventOp, DeleteIdentifiedPitchOp,
@ -70,6 +72,20 @@ pub struct Selection {
pub layout_object: LayoutObjectId,
}
/// What a click at a world point resolves to **vertically**: the staff under the
/// cursor and the natural diatonic pitch at that height under the staff's clef. The
/// vertical half of click-to-insert (the horizontal half — the musical position — is
/// a separate query). The accidental is left natural; a caller respells if needed.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct StaffPitch {
/// The staff instance the click is over (nearest staff).
pub staff_instance: StaffInstanceId,
/// The diatonic nominal at the clicked height.
pub nominal: CmnNominal,
/// The octave (scientific-pitch) at the clicked height.
pub octave: i8,
}
/// What an [`EditorSession::apply`] did.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct EditOutcome {
@ -171,6 +187,11 @@ pub struct EditorSession {
resolved: ResolvedLayoutIR,
render: RenderIR,
map: HitTestMap,
// The clef in force at the start of each manifested staff, resolved by time from
// the logical layout's `PlacedClef`s (not vector order). Keyed by the manifesting
// `(region, staff)`, since one staff can be tiled into several regions. The
// vertical half of click-to-insert reads this to spell the clicked height.
start_clefs: BTreeMap<(RegionId, StaffId), Clef>,
selection: Option<Selection>,
// Operation-minting identity. A real client supplies its own replica/author.
// Minted operations form this replica's contiguous, zero-based local history;
@ -187,7 +208,7 @@ impl EditorSession {
/// Opens a session on `score` with `solver`, rendering immediately. Errors with
/// [`EditorError::NotRenderable`] if the initial layout is diagnostic-only.
pub fn open(score: Score, solver: Box<dyn ConstraintSolver>) -> Result<Self, EditorError> {
let (resolved, render, map) =
let (start_clefs, resolved, render, map) =
render_score(&score, solver.as_ref()).ok_or(EditorError::NotRenderable)?;
Ok(EditorSession {
base: score.clone(),
@ -196,6 +217,7 @@ impl EditorSession {
resolved,
render,
map,
start_clefs,
selection: None,
replica: ReplicaId(1),
author: AuthorId(0),
@ -264,6 +286,74 @@ impl EditorSession {
self.applied.last()
}
/// The staff and diatonic pitch at a world `point` — the **vertical half** of a
/// click-to-insert. Finds the staff the click is over (the nearest staff by its
/// rendered line band) and the natural pitch at that height under the staff's
/// clef. `None` if there is no staff, the staff has no diatonic clef (percussion),
/// or the height is out of representable range. The horizontal half (the musical
/// position to insert at) is a separate query.
pub fn staff_pitch_at(&self, point: Point) -> Option<StaffPitch> {
// Reject a non-finite click up front: `dist_to_band`'s `<`/`>` would let a
// NaN fall through as distance 0 (matching every staff), and `round() as i32`
// saturates a non-finite height into a bogus staff step. A malformed view
// transform yields no pitch rather than an arbitrary one.
if !point.x.0.is_finite() || !point.y.0.is_finite() {
return None;
}
// A 5-line staff spans four staff spaces above its bottom line.
const STAFF_SPAN: f32 = 4.0;
// Pick the manifested staff the click is nearest, by 2D proximity: horizontal
// span first (which region — one staff tiles across regions that can share a y
// band), then the vertical band (which staff within it). The bottom staff line
// carries the staff's manifestation id as its stroke `stable_id`, which is how
// a rendered line maps back to its `(region, staff_instance)`.
let mut best: Option<(RegionId, &StaffInstance, f32)> = None;
let mut best_dist = (f32::INFINITY, f32::INFINITY);
for (region, si) in self.score.staff_instances() {
let manifest = manifestation_layout_id(&TypedObjectId::Staff(si.staff), region);
let Some(bottom) = self
.resolved
.strokes
.iter()
.find(|s| s.provenance.stable_id == manifest)
else {
continue;
};
// The bottom line is horizontal; its `y` is the step origin and its
// endpoints bound the staff's horizontal extent.
let origin = bottom.from.y.0;
let span = (
bottom.from.x.0.min(bottom.to.x.0),
bottom.from.x.0.max(bottom.to.x.0),
);
let dist = (
dist_to_band(point.x.0, span),
dist_to_band(point.y.0, (origin, origin + STAFF_SPAN)),
);
if dist < best_dist {
best_dist = dist;
best = Some((region, si, origin));
}
}
let (region, si, origin) = best?;
// The clef in force at the staff start, resolved by time when the layout was
// built (a mid-staff clef change is a later refinement); default treble when
// none is declared.
let clef = self
.start_clefs
.get(&(region, si.staff))
.copied()
.unwrap_or_default();
// Each step is half a staff space, +y up; round to the nearest line/space.
let step = ((point.y.0 - origin) * 2.0).round() as i32;
let (nominal, octave) = staff_step_pitch(step, &clef)?;
Some(StaffPitch {
staff_instance: si.id,
nominal,
octave,
})
}
/// Resolves a click at a world `point`: selects the **topmost** hit there (what
/// a GUI selects), or clears the selection if the point hits nothing. Returns
/// the new selection.
@ -378,7 +468,7 @@ impl EditorSession {
let graph_changed = edited != self.score;
// Refuse a diagnostic-only layout, still before committing anything.
let (resolved, render, map) =
let (start_clefs, resolved, render, map) =
render_score(&edited, self.solver.as_ref()).ok_or(EditorError::NotRenderable)?;
// Commit (the only mutation point — so an error above leaves all state,
@ -387,6 +477,7 @@ impl EditorSession {
self.resolved = resolved;
self.render = render;
self.map = map;
self.start_clefs = start_clefs;
self.applied.extend(new);
let selection_preserved = self.reresolve_selection();
@ -922,6 +1013,18 @@ fn staff_step(pitch: &Pitch, steps: i32) -> Option<Pitch> {
Some(moved)
}
/// The distance from height `y` to a staff's line band `(bottom, top)`: zero inside
/// the band, else the gap to the nearer edge. Used to pick the staff a click is over.
fn dist_to_band(y: f32, (bottom, top): (f32, f32)) -> f32 {
if y < bottom {
bottom - y
} else if y > top {
y - top
} else {
0.0
}
}
/// The diatonic staff index of a CMN pitch — `octave * 7 + nominal`, so two pitches
/// compare by staff position and a step is `± 1`. `None` for a non-CMN position.
fn diatonic_index(pitch: &Pitch) -> Option<i64> {
@ -1060,20 +1163,46 @@ fn staff_step_spelling(spelling: &PitchSpelling, steps: i32) -> Option<PitchSpel
/// Renders a score with `solver` to its `RenderIR` + hit-test map, or `None` if the
/// solver's report is diagnostic-only (not renderable).
type StartClefs = BTreeMap<(RegionId, StaffId), Clef>;
fn render_score(
score: &Score,
solver: &dyn ConstraintSolver,
) -> Option<(ResolvedLayoutIR, RenderIR, HitTestMap)> {
let report = solver.solve(
&to_constrained(&to_logical(score)),
&SolverConfig::default(),
);
) -> Option<(StartClefs, ResolvedLayoutIR, RenderIR, HitTestMap)> {
// Build the start-clef table from the logical layout, where anchor resolution has
// already placed each `PlacedClef` at a concrete time — the editor's vertical
// inverse spells the clicked height against this, not against vector order.
let logical = to_logical(score);
let start_clefs = staff_start_clefs(&logical);
let report = solver.solve(&to_constrained(&logical), &SolverConfig::default());
if !report.status.is_renderable() {
return None;
}
let render = to_render(&report.layout);
let map = render.hit_test_map();
Some((report.layout, render, map))
Some((start_clefs, report.layout, render, map))
}
/// The clef in force at each manifested staff's start, resolved by time from the
/// logical layout's placed clefs (see [`active_clef`]). Keyed by the manifesting
/// `(region, staff)` so tiled copies of one staff each get their own entry — the
/// vertical half of click-to-insert looks a staff up by the region its rendered
/// lines were traced to.
fn staff_start_clefs(logical: &LogicalLayoutIR) -> StartClefs {
let start = TimePoint::Musical(MusicalPosition::origin());
let mut clefs = StartClefs::new();
for region in &logical.regions {
let TypedObjectId::Region(region_id) = region.provenance.source else {
continue;
};
for object in &region.objects {
if let (Some(staff), LayoutContent::Staff(content)) = (object.staff(), object.content())
{
clefs.insert((region_id, staff), active_clef(&content.clefs, &start));
}
}
}
clefs
}
#[cfg(test)]
@ -1164,6 +1293,152 @@ mod tests {
assert_eq!(session.selection(), None);
}
#[test]
fn staff_pitch_at_reads_the_clicked_height() {
let session = open_rich(0x5EED);
// The lowest staff-line stroke is the bottom staff's step origin.
let origin = session
.resolved()
.strokes
.iter()
.filter(|s| matches!(s.provenance.source, TypedObjectId::Staff(_)))
.map(|s| s.from.y.0)
.fold(f32::INFINITY, f32::min);
assert!(origin.is_finite(), "the score renders staff lines");
let diatonic = |p: StaffPitch| p.octave as i32 * 7 + p.nominal as i32;
let at = |dy: f32| session.staff_pitch_at(Point::new(5.0, origin + dy));
let bottom = at(0.0).expect("a staff under the click");
// Two staff spaces up is four diatonic steps (a fifth) higher, on the same
// staff — so y maps to staff steps at two steps per space.
let up_a_fifth = at(2.0).expect("still over the staff");
assert_eq!(up_a_fifth.staff_instance, bottom.staff_instance);
assert_eq!(diatonic(up_a_fifth) - diatonic(bottom), 4);
// A half-space below the bottom line is one diatonic step down.
let below = at(-0.5).expect("just below the staff still resolves");
assert_eq!(diatonic(bottom) - diatonic(below), 1);
}
#[test]
fn staff_pitch_at_picks_the_staff_under_the_x() {
let session = open_rich(0x5EED);
// Each staff manifestation's bottom line: its step-origin y and its x span.
let mut bands: Vec<(StaffInstanceId, f32, f32, f32)> = session
.score()
.staff_instances()
.filter_map(|(region, si)| {
let m = manifestation_layout_id(&TypedObjectId::Staff(si.staff), region);
session
.resolved()
.strokes
.iter()
.find(|s| s.provenance.stable_id == m)
.map(|s| {
(
si.id,
s.from.x.0.min(s.to.x.0),
s.from.x.0.max(s.to.x.0),
s.from.y.0,
)
})
})
.collect();
bands.sort_by(|a, b| a.1.total_cmp(&b.1));
// The fixture tiles its staves left-to-right sharing one y band — so height
// alone cannot tell them apart; only x identifies the clicked staff.
assert!(bands.len() >= 2, "the fixture manifests multiple staves");
let origin = bands[0].3;
assert!(
bands.iter().all(|b| (b.3 - origin).abs() < 1e-3),
"the staves share a y band, so the click is ambiguous by height alone"
);
let mut resolved = std::collections::BTreeSet::new();
for &(expected, lo, hi, _) in &bands {
// Click the centre of this staff's x span, at the shared band height.
let pitch = session
.staff_pitch_at(Point::new((lo + hi) / 2.0, origin + 2.0))
.expect("a staff under the click");
assert_eq!(
pitch.staff_instance, expected,
"the click resolves to the staff occupying that x, not a height-only pick"
);
resolved.insert(pitch.staff_instance);
}
assert_eq!(
resolved.len(),
bands.len(),
"distinct x positions resolved to distinct staves (height alone could not)"
);
}
#[test]
fn staff_pitch_at_rejects_non_finite_clicks() {
let session = open_rich(0x5EED);
// A finite click resolves, so the fixture has a staff to hit.
assert!(session.staff_pitch_at(Point::new(5.0, 0.0)).is_some());
// A malformed view transform can hand a NaN/inf coordinate; it must yield no
// pitch, not a bogus one (NaN would slip through `dist_to_band` as distance 0
// and saturate the `round() as i32` step).
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert_eq!(session.staff_pitch_at(Point::new(bad, 0.0)), None);
assert_eq!(session.staff_pitch_at(Point::new(5.0, bad)), None);
}
}
#[test]
fn staff_start_clefs_resolve_by_time_not_vector_order() {
use epiphany_core::{MusicalPosition, RationalTime};
use epiphany_layout_ir::{LayoutObject, PlacedClef};
let at = |n, d| TimePoint::Musical(MusicalPosition(RationalTime::new(n, d).unwrap()));
let mut logical = to_logical(&valid_score_rich(0x5EED));
// Author one staff's clefs out of order: bass at time 1, treble at the origin.
// The clef in force at the staff start is treble (the latest change at or
// before time 0), even though bass is first in the vector.
let mut patched = None;
'outer: for region in &mut logical.regions {
let TypedObjectId::Region(rid) = region.provenance.source else {
continue;
};
for object in &mut region.objects {
// Match on content, not variant: the staff-content object projects from
// a `StaffInstance` source, so it is not the `Staff` layout variant.
let LayoutContent::Staff(content) = object.content() else {
continue;
};
let Some(staff) = object.staff() else {
continue;
};
let mut content = content.clone();
content.clefs = vec![
PlacedClef {
time: at(1, 1),
clef: Clef::bass(),
},
PlacedClef {
time: at(0, 1),
clef: Clef::treble(),
},
];
*object = LayoutObject::from_projection_with_content(
object.provenance().clone(),
Some(staff),
LayoutContent::Staff(content),
);
patched = Some((rid, staff));
break 'outer;
}
}
let key = patched.expect("the fixture has a staff layout object");
let clefs = staff_start_clefs(&logical);
assert_eq!(
clefs.get(&key).copied(),
Some(Clef::treble()),
"the start clef resolves by time (treble@0), not vector order (bass first)"
);
}
#[test]
fn the_full_editing_loop_runs_through_the_session() {
let mut session = open_rich(0x5EED);

View File

@ -338,9 +338,15 @@ impl EditorApp {
if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let world = ViewMap::new(self.view_box, rect).screen_to_world(pos);
// The pitch the clicked height resolves to (the vertical half of
// click-to-insert) — shown when the click lands on empty staff.
let pitch = self.session.staff_pitch_at(world);
self.status = match self.session.click(world) {
Some(_) => "selected".to_string(),
None => "cleared selection".to_string(),
None => match pitch {
Some(p) => format!("empty — would insert {:?}{}", p.nominal, p.octave),
None => "cleared selection".to_string(),
},
};
}
}

View File

@ -1526,7 +1526,7 @@ fn shift_time(base: &TimePoint, offset: &MusicalDuration) -> TimePoint {
/// 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 {
pub fn active_clef(clefs: &[PlacedClef], at: &TimePoint) -> Clef {
clefs
.iter()
.filter(|placed| {

View File

@ -90,7 +90,7 @@ pub use cache::{
ResolvedSystemCache, SystemId,
};
pub use constrained::{
is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakKind,
active_clef, is_rigid_width_stroke, to_constrained, try_to_constrained, Axis, BreakKind,
ConstrainedLayoutIR, ConstrainedLayoutRegion, ConstrainedValidationError, ConstraintParameters,
ConstraintRegistryId, GlyphObject, GlyphObjectId, GlyphStyle, LayoutConstraint,
LayoutTransformError, SpringSlot, Stroke,