From 1ab3e5f2986d0c7df3ce9311f7877d76c94e3566 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 29 Jun 2026 21:46:11 -0400 Subject: [PATCH] =?UTF-8?q?editor-core:=20a=20position=5Fat=20click?= =?UTF-8?q?=E2=86=92beat-grid=20horizontal=20inverse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The horizontal half of click-to-insert: resolve a world point to the metric region under the cursor and the grid-snapped musical position to insert at. Together with staff_pitch_at it gives the (pitch, position) an insert needs. The region and staff are picked through a shared nearest_manifestation helper (extracted from staff_pitch_at, the non-finite guard moved into it), so the vertical and horizontal halves agree on which staff the click is over. The x→time inverse is solver-agnostic: position_anchors reads resolved glyph x-positions and maps each to its event's onset through the glyph's Pitch/Event provenance, so the samples come from the actual rendered layout. Only directly -manifested onset glyphs anchor — synthesized glyphs (accidentals sit left of the notehead, ledgers, …) are skipped so they cannot pull the time column off the notehead. invert_x is piecewise-linear through those anchors, extrapolating the end segment past the last note — the make-room case of clicking the empty staff after the last note. snap_to_grid rounds to the nearest multiple of the grid step, rebuilt by exact rational arithmetic so it lands on the grid. GridResolution is the caller-supplied beat grid (a musical-time step, also the default duration of a note entered there); a meter-derived default is deferred. position_at refuses a non-positive grid, a non-metric region (no musical onset), and a region with fewer than two rendered events (no scale to extrapolate from). The GUI shows the resolved pitch and snapped position on an empty-staff click, on a default quarter-note grid. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS --- crates/epiphany-editor-core/src/lib.rs | 469 +++++++++++++++++++++++-- crates/epiphany-editor-gui/src/main.rs | 22 +- 2 files changed, 456 insertions(+), 35 deletions(-) diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index db71f98..a0f4d0b 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -39,15 +39,16 @@ //! produces a [`RenderIR`]; turning that into pixels is the renderer's job. use std::cmp::Reverse; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; use epiphany_core::{ 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, + PitchSpacePosition, PitchSpelling, PitchedEvent, RationalTime, RegionId, RegionTimeModel, + ReplicaId, Score, SpellingDirective, SpellingNominal, SpellingScope, SpellingSourceKind, + StaffId, StaffInstance, StaffInstanceId, StemConfiguration, TransactionId, TypedObjectId, + VoiceId, WallClockTime, }; use epiphany_layout_ir::{ active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render, @@ -86,6 +87,38 @@ pub struct StaffPitch { pub octave: i8, } +/// The beat grid a click-to-insert snaps to: the musical-time `step` between insert +/// positions (and the natural default written duration of a note entered there) — +/// a DAW's "grid" setting. The caller chooses the resolution; deriving a default +/// from the region's meter is a later refinement, so this carries no meter logic. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GridResolution { + /// The grid step from the region origin; insert positions are its multiples. + pub step: MusicalDuration, +} + +impl GridResolution { + /// A quarter-note grid — a sensible default for a client that has not chosen a + /// resolution (a meter-derived default is a later refinement). + pub fn quarter() -> Self { + // `1/4` is always representable (a non-zero denominator). + GridResolution { + step: MusicalDuration(RationalTime::new(1, 4).expect("1/4 is a valid duration")), + } + } +} + +/// What a click at a world point resolves to **horizontally**: the metric region +/// under the cursor and the grid-snapped musical position to insert at. The vertical +/// half (the pitch) is a [`StaffPitch`]; a click-to-insert combines the two. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GridPosition { + /// The metric region the position belongs to. + pub region: RegionId, + /// The grid-snapped onset to insert at. + pub position: MusicalPosition, +} + /// What an [`EditorSession::apply`] did. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct EditOutcome { @@ -293,20 +326,43 @@ impl EditorSession { /// 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 { + let (region, si, origin) = self.nearest_manifestation(point)?; + // 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, + }) + } + + /// The manifested staff a world `point` is nearest, by 2D proximity — its + /// `(region, staff instance)` and the staff's step-origin `y`. 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 `(region, staff_instance)`. Both halves of click-to-insert — + /// [`Self::staff_pitch_at`] (pitch) and [`Self::position_at`] (position) — select + /// the staff/region through this. `None` on a non-finite point or no staff line. + fn nearest_manifestation(&self, point: Point) -> Option<(RegionId, &StaffInstance, f32)> { // 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. + // NaN fall through as distance 0 (matching every staff), and downstream a + // `round() as i32` would saturate a non-finite height into a bogus step. A + // malformed view transform yields no manifestation 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() { @@ -335,23 +391,94 @@ impl EditorSession { 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, - }) + best + } + + /// The musical position a world `point` snaps to on the beat grid — the + /// **horizontal half** of a click-to-insert. Finds the metric region under the + /// cursor, inverts the click's `x` to a raw musical position (piecewise-linear + /// through the region's rendered event anchors), then snaps it to `grid`. `None` + /// if the click is off any staff, the region is non-metric (a proportional or + /// aleatoric region has no musical position to land on), `grid` is non-positive, + /// or the region has fewer than two rendered metric events to fix a scale from. + /// The vertical half (the pitch) is [`Self::staff_pitch_at`]. + pub fn position_at(&self, point: Point, grid: &GridResolution) -> Option { + if !grid.step.is_positive() { + return None; + } + let (region, _, _) = self.nearest_manifestation(point)?; + // Only a metric region has a musical position to land on (a proportional or + // aleatoric region is measured in wall-clock / DAG order, not metric onsets). + if !self.region_is_metric(region) { + return None; + } + // Two anchors fix the x→time scale; with fewer, the spacing density is + // unknown, so there is nothing to extrapolate an empty-space position from. + let anchors = self.position_anchors(region); + if anchors.len() < 2 { + return None; + } + let raw = invert_x(&anchors, point.x.0); + let position = snap_to_grid(raw, &grid.step); + Some(GridPosition { region, position }) + } + + /// Whether `region` uses metric time (positions are musical onsets). + fn region_is_metric(&self, region: RegionId) -> bool { + self.score + .canvas + .regions + .iter() + .find(|r| r.id == region) + .is_some_and(|r| matches!(r.time_model, RegionTimeModel::Metric(_))) + } + + /// The `(musical onset, leftmost rendered x)` anchors of `region`'s metric events, + /// in ascending time order — the samples the horizontal inverse interpolates. A + /// glyph maps to its onset through its `Pitch`/`Event` provenance source; the + /// leftmost glyph at an onset (the notehead/stem column) fixes that onset's x. + fn position_anchors(&self, region: RegionId) -> Vec<(MusicalPosition, f32)> { + // Source id (event or one of its pitches) → the event's metric onset. + let mut onset: HashMap = HashMap::new(); + let mut pitches: Vec<&IdentifiedPitch> = Vec::new(); + for (rid, _, voice) in self.score.voices() { + if rid != region { + continue; + } + for event_id in &voice.events { + let Some(event) = self.score.events.get(*event_id) else { + continue; + }; + let EventPosition::Musical(at) = event.position() else { + continue; + }; + onset.insert(TypedObjectId::Event(*event_id), at.clone()); + pitches.clear(); + event.collect_identified_pitches(&mut pitches); + for ip in &pitches { + onset.insert(TypedObjectId::Pitch(ip.id), at.clone()); + } + } + } + // Leftmost glyph x per onset, gathered in time order (BTreeMap key = onset). + let mut by_onset: BTreeMap = BTreeMap::new(); + for glyph in &self.resolved.glyphs { + // Only the directly-manifested onset glyph (the notehead/rest) fixes the + // onset's x. Skip synthesized glyphs: an accidental shares its note's + // `Pitch` source but is placed *left* of the notehead, so taking it as the + // anchor would pull the onset's x off the time column. + if glyph.provenance.synthesis.is_some() { + continue; + } + if let Some(at) = onset.get(&glyph.provenance.source) { + let x = glyph.position.x.0; + by_onset + .entry(at.clone()) + .and_modify(|cur| *cur = cur.min(x)) + .or_insert(x); + } + } + by_onset.into_iter().collect() } /// Resolves a click at a world `point`: selects the **topmost** hit there (what @@ -1025,6 +1152,51 @@ fn dist_to_band(y: f32, (bottom, top): (f32, f32)) -> f32 { } } +/// Inverts an `x` coordinate to a raw musical position through `(onset, x)` anchors +/// in ascending order (`>= 2`, leftmost first) — the horizontal inverse before grid +/// snapping. Within the anchored span it interpolates the bracketing segment; outside +/// it, it extrapolates the nearest end segment's slope (the common case — a click in +/// the empty staff after the last note). `f64` because this is geometry, not exact +/// musical time; the result is snapped to an exact grid position by [`snap_to_grid`]. +fn invert_x(anchors: &[(MusicalPosition, f32)], x: f32) -> f64 { + let n = anchors.len(); + debug_assert!(n >= 2, "invert_x needs at least two anchors for a scale"); + let pos = |i: usize| anchors[i].0 .0.to_f64(); + let ax = |i: usize| anchors[i].1 as f64; + // The segment to (inter/extra)polate on: the one bracketing `x`, clamped to the + // first/last segment when `x` is left of / right of every anchor. + let seg = if x <= anchors[0].1 { + 0 + } else if x >= anchors[n - 1].1 { + n - 2 + } else { + (0..n - 1) + .find(|&i| x >= anchors[i].1 && x <= anchors[i + 1].1) + .unwrap_or(n - 2) + }; + let (x0, x1) = (ax(seg), ax(seg + 1)); + let (p0, p1) = (pos(seg), pos(seg + 1)); + let span = x1 - x0; + if span.abs() < f64::EPSILON { + return p0; + } + p0 + (x as f64 - x0) / span * (p1 - p0) +} + +/// Snaps a raw musical position to the nearest multiple of `step` from the origin, +/// clamped to be non-negative (no position precedes the region start). The multiple +/// is taken in `f64`, then the position is rebuilt by exact rational arithmetic +/// (`step * k`), so the result lands exactly on the grid, not on a rounded float. +fn snap_to_grid(raw: f64, step: &MusicalDuration) -> MusicalPosition { + let step_f = step.0.to_f64(); + let k = if step_f > 0.0 { + (raw / step_f).round().clamp(0.0, i32::MAX as f64) as i32 + } else { + 0 + }; + MusicalPosition(step.0.mul(&RationalTime::from_int(k))) +} + /// 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 { @@ -1439,6 +1611,245 @@ mod tests { ); } + /// The first region whose events have `time_model`-matching positions, picked by + /// the `metric` flag (a `Musical` vs `WallClock` onset). + fn a_region_with(session: &EditorSession, metric: bool) -> RegionId { + session + .score() + .voices() + .find_map(|(rid, _, v)| { + v.events.iter().find_map(|eid| { + let ev = session.score().events.get(*eid)?; + (matches!(ev.position(), EventPosition::Musical(_)) == metric).then_some(rid) + }) + }) + .expect("a region with the requested time model") + } + + /// `region`'s pitched metric events as `(onset, first pitch id)`, in onset order. + fn region_pitched_events( + session: &EditorSession, + region: RegionId, + ) -> Vec<(MusicalPosition, PitchId)> { + let mut out = Vec::new(); + let mut buf: Vec<&IdentifiedPitch> = Vec::new(); + for (rid, _, v) in session.score().voices() { + if rid != region { + continue; + } + for eid in &v.events { + let Some(ev) = session.score().events.get(*eid) else { + continue; + }; + let EventPosition::Musical(at) = ev.position() else { + continue; + }; + buf.clear(); + ev.collect_identified_pitches(&mut buf); + if let Some(ip) = buf.first() { + out.push((at.clone(), ip.id)); + } + } + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + + /// `region`'s rendered bottom staff line as `(left_x, right_x, origin_y)`. + fn region_staff_line(session: &EditorSession, region: RegionId) -> (f32, f32, f32) { + let (_, si) = session + .score() + .staff_instances() + .find(|(r, _)| *r == region) + .expect("the region has a staff instance"); + let m = manifestation_layout_id(&TypedObjectId::Staff(si.staff), region); + let line = session + .resolved() + .strokes + .iter() + .find(|s| s.provenance.stable_id == m) + .expect("the staff renders its bottom line"); + ( + line.from.x.0.min(line.to.x.0), + line.from.x.0.max(line.to.x.0), + line.from.y.0, + ) + } + + /// A world point on `region`'s staff: the midpoint of its bottom line, one staff + /// space up (inside the staff, so the click resolves to this region). + fn point_on_region_staff(session: &EditorSession, region: RegionId) -> Point { + let (left, right, origin_y) = region_staff_line(session, region); + Point::new((left + right) / 2.0, origin_y + 1.0) + } + + #[test] + fn position_at_snaps_a_click_to_the_beat_grid() { + let session = open_rich(0x5EED); + let region = a_region_with(&session, true); + let anchors = session.position_anchors(region); + assert!( + anchors.len() >= 2, + "the metric region renders multiple notes" + ); + let (_, right, origin_y) = region_staff_line(&session, region); + let y = origin_y + 1.0; + + // Grid = the first inter-onset gap, so every rendered onset is a grid multiple. + let step = MusicalDuration(anchors[1].0 .0.sub(&anchors[0].0 .0)); + assert!(step.is_positive()); + let grid = GridResolution { step }; + + // Clicking each notehead snaps to that note's onset. + for (onset, x) in &anchors { + let gp = session + .position_at(Point::new(*x, y), &grid) + .expect("a metric region under the click"); + assert_eq!(gp.region, region); + assert_eq!(&gp.position, onset, "the click snaps to the note's onset"); + } + + // Past the last note but still over this staff (between the last notehead and + // the staff's right edge — the empty space a make-room insert targets), the + // inverse extrapolates to a later grid slot, strictly after the last onset. + let (last_onset, last_x) = anchors.last().cloned().unwrap(); + let reach = (last_x + right) / 2.0; + assert!(reach > last_x, "the staff extends past the last note"); + let far = session + .position_at(Point::new(reach, y), &grid) + .expect("empty space past the notes still resolves"); + assert!( + far.position > last_onset, + "a click past the last note lands later on the grid" + ); + + // Left of the first note clamps to the region origin (no negative musical time). + let before = session + .position_at(Point::new(anchors[0].1 - 1000.0, y), &grid) + .expect("left of the first note still resolves"); + assert_eq!(before.position, MusicalPosition::origin()); + } + + #[test] + fn position_at_anchors_on_the_notehead_not_the_accidental() { + // An accidental shares its note's `Pitch` source but is drawn left of the + // notehead. Sharpen a note so it grows an accidental, then verify a click on + // its notehead still snaps to the note's onset — the accidental's leftward x + // must not become the time anchor. + let base = open_rich(0x5EED); + let region = a_region_with(&base, true); + let events = region_pitched_events(&base, region); + assert!( + events.len() >= 2, + "the metric region renders multiple notes" + ); + // Grid = the first inter-onset gap, so every onset is a grid multiple. + let grid = GridResolution { + step: MusicalDuration(events[1].0 .0.sub(&events[0].0 .0)), + }; + let (_, _, origin_y) = region_staff_line(&base, region); + + // Use the first note past the origin whose sharpen renders an accidental + // (a semitone up is a natural for some spellings, e.g. E→F — skip those). + let mut tested = false; + for (onset, pid) in events.iter().skip(1) { + let mut session = open_rich(0x5EED); + select_pitch(&mut session, *pid); + if session.transpose_selection(1).is_err() { + continue; + } + let glyph_x = |synthesized: bool| { + session + .resolved() + .glyphs + .iter() + .filter(|g| { + g.provenance.source == TypedObjectId::Pitch(*pid) + && g.provenance.synthesis.is_some() == synthesized + }) + .map(|g| g.position.x.0) + .next() + }; + let Some(notehead_x) = glyph_x(false) else { + continue; + }; + let Some(accidental_x) = glyph_x(true) else { + continue; // the sharpen produced a natural; not the case we want + }; + assert!( + accidental_x < notehead_x, + "the accidental sits left of the notehead" + ); + // The onset's anchor must be the notehead x, not the (leftmost) accidental + // — the exact check, independent of how coarse the grid is. + let anchor_x = session + .position_anchors(region) + .into_iter() + .find(|(o, _)| o == onset) + .map(|(_, x)| x) + .expect("the sharped note has a rendered anchor"); + assert_eq!( + anchor_x, notehead_x, + "the onset anchors on the notehead, not the accidental left of it" + ); + // And end-to-end: clicking the notehead snaps to the note's onset. + let gp = session + .position_at(Point::new(notehead_x, origin_y + 1.0), &grid) + .expect("a metric region under the click"); + assert_eq!(gp.position, *onset, "the click snaps to the note's onset"); + tested = true; + break; + } + assert!( + tested, + "a sharpened note rendered an accidental to test the anchor against" + ); + } + + #[test] + fn position_at_rejects_a_non_metric_region() { + let session = open_rich(0x5EED); + // The fixture's middle region is proportional (wall-clock events) — it has no + // musical onset to land on, so a click over it yields no grid position. + let region = a_region_with(&session, false); + assert!(!session.region_is_metric(region)); + let grid = GridResolution { + step: MusicalDuration(RationalTime::new(1, 4).unwrap()), + }; + assert_eq!( + session.position_at(point_on_region_staff(&session, region), &grid), + None + ); + } + + #[test] + fn position_at_rejects_a_non_positive_grid() { + let session = open_rich(0x5EED); + let region = a_region_with(&session, true); + let at = point_on_region_staff(&session, region); + // A positive grid resolves; a zero/negative one cannot snap, so it is refused. + let ok = GridResolution { + step: MusicalDuration(RationalTime::new(1, 12).unwrap()), + }; + assert!(session.position_at(at, &ok).is_some()); + let zero = GridResolution { + step: MusicalDuration(RationalTime::zero()), + }; + assert_eq!(session.position_at(at, &zero), None); + } + + #[test] + fn position_at_rejects_non_finite_clicks() { + let session = open_rich(0x5EED); + let grid = GridResolution { + step: MusicalDuration(RationalTime::new(1, 12).unwrap()), + }; + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(session.position_at(Point::new(bad, 1.0), &grid), None); + assert_eq!(session.position_at(Point::new(5.0, bad), &grid), None); + } + } + #[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 3343aab..ffe2e41 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -18,7 +18,7 @@ use eframe::egui; -use epiphany_editor_core::{EditOutcome, EditorError, EditorSession}; +use epiphany_editor_core::{EditOutcome, EditorError, EditorSession, GridResolution}; use epiphany_engrave::Engraver; use epiphany_layout_ir::{BoundingBox, HitShape, Point}; use epiphany_ops::{OperationKind, OperationPayload}; @@ -338,14 +338,24 @@ 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. + // What a click-to-insert would resolve to on empty staff: the pitch + // (vertical half) and the grid-snapped position (horizontal half), on + // a default quarter-note grid. let pitch = self.session.staff_pitch_at(world); + let position = self.session.position_at(world, &GridResolution::quarter()); self.status = match self.session.click(world) { Some(_) => "selected".to_string(), - None => match pitch { - Some(p) => format!("empty — would insert {:?}{}", p.nominal, p.octave), - None => "cleared selection".to_string(), + None => match (pitch, position) { + (Some(p), Some(gp)) => format!( + "empty — would insert {:?}{} at beat {:.3}", + p.nominal, + p.octave, + gp.position.0.to_f64() + ), + (Some(p), None) => { + format!("empty — would insert {:?}{}", p.nominal, p.octave) + } + _ => "cleared selection".to_string(), }, }; }