From 8bffd2f2fdc81f24e76bc228ce4e205afcd0c133 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 29 Jun 2026 18:56:34 -0400 Subject: [PATCH] =?UTF-8?q?editor-core:=20a=20staff=5Fpitch=5Fat=20click?= =?UTF-8?q?=E2=86=92pitch=20vertical=20inverse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS --- crates/epiphany-editor-core/src/lib.rs | 305 ++++++++++++++++++- crates/epiphany-editor-gui/src/main.rs | 8 +- crates/epiphany-layout-ir/src/constrained.rs | 2 +- crates/epiphany-layout-ir/src/lib.rs | 2 +- 4 files changed, 299 insertions(+), 18 deletions(-) diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index f2278eb..db71f98 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -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, // 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) -> Result { - 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 { + // 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 { 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 { @@ -1060,20 +1163,46 @@ fn staff_step_spelling(spelling: &PitchSpelling, steps: i32) -> Option; + 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 ®ion.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); diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index 93a452d..3343aab 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -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(), + }, }; } } diff --git a/crates/epiphany-layout-ir/src/constrained.rs b/crates/epiphany-layout-ir/src/constrained.rs index 9ab0648..95adbe7 100644 --- a/crates/epiphany-layout-ir/src/constrained.rs +++ b/crates/epiphany-layout-ir/src/constrained.rs @@ -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| { diff --git a/crates/epiphany-layout-ir/src/lib.rs b/crates/epiphany-layout-ir/src/lib.rs index 5366d4a..364b37b 100644 --- a/crates/epiphany-layout-ir/src/lib.rs +++ b/crates/epiphany-layout-ir/src/lib.rs @@ -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,