E2 follow-up: surface slur style deferral, conservative curve hit capsule

Three source-audit findings on 81b7f42, fixed:

1. Slur kind and line style were dropped at projection and every slur drew
   solid with no signal. SlurContent now carries `kind` (SlurKind) and `line`
   (LineStyle) through the projection, and a non-Solid authored line style
   emits a `LayoutDiagnosticKind::SlurLineStyleNotRendered` — the curve still
   draws (solid, ink + provenance kept), but the ignored dash/dotted intent is
   surfaced, not papered over. The Minimal tier still draws one canonical arc
   per kind (kind-aware rendering is higher-tier); the kind is now preserved
   for it. The slur fixture's editorial slur is authored Dashed to exercise it.

2. Curve hit-testing flattened to a fixed 16 chords and tested capsules at
   exactly half_width, so a thin/high-curvature slur's true ink between samples
   could miss a click. contains/intersects_rect now inflate the capsule by a
   proven flattening-error bound (h²/8·max‖B''‖ = (3/4N²)·max second-difference)
   — provably conservative, so a click on the drawn arc never misses. The AABB
   (control hull ± half_width) already bounds the true ink, unchanged.

3. The engrave crate-level doc still said "no drawn slur geometry exists yet";
   updated to slur_shape 0.0 by construction (Minimal draws the ideal arc),
   beam_slope still vacuous.

931 tests, docs clean, conformance 8/8. Zero golden churn (dashed slur draws
solid; layout diagnostics don't reach the render output).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-08 16:07:13 -04:00
parent 81b7f42f0a
commit 28f210e16f
6 changed files with 162 additions and 20 deletions

View File

@ -51,8 +51,10 @@
//! the private `quality` module computes all nine normative axes per the
//! ratified *Quality Metric Catalog* companion (collision census, spacing
//! regularity, break/page/casting-off distribution, vertical gap deviation;
//! slur/beam shape are vacuous-`0.0` because no drawn slur/beam geometry exists
//! yet), normalized through the catalog's pinned anchors
//! `slur_shape` is `0.0` **by construction** now that slurs draw — the Minimal
//! tier emits the ideal arc, so a drawn slur has zero shape deviation — while
//! `beam_slope` stays vacuous-`0.0` because no drawn beam geometry exists yet),
//! normalized through the catalog's pinned anchors
//! ([`epiphany_layout_ir::quality`]), with
//! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the
//! threshold column the config's profile selects. The all-worst

View File

@ -581,9 +581,17 @@ out of scope), so these are E2 decisions for the Phase-F ratification pass:
- **curvature_override honored structurally.** `direction` (Above/Below;
default Auto = above) flips the arc; `height` (a `SpaceUnit`) sets the apex,
else a span-proportional default clamped to `[SLUR_MIN_HEIGHT,
SLUR_MAX_HEIGHT]`. `style.line` (dashed/dotted) is a Push-3 refinement — the
Minimal tier draws every slur solid (the curve carries an `ink` style like a
stroke); nothing authored is lost, only its dash rendering deferred.
SLUR_MAX_HEIGHT]`.
- **Kind and line style: carried, and the deferral is surfaced (review fix).**
`SlurContent` carries `kind` (`SlurKind`) and `line` (`LineStyle`) through the
projection — nothing is dropped — but the Minimal tier draws one canonical
solid arc for *every* kind (a phrase mark's longer curve, an editorial
slur's distinct line are kind-aware higher-tier work). `style.line` (dashed/
dotted) is likewise a Push-3 refinement; rather than silently rendering an
authored dashed slur solid, a non-`Solid` line style emits a
`LayoutDiagnosticKind::SlurLineStyleNotRendered` — the curve still draws
(solid, ink and provenance preserved), but the ignored intent is surfaced,
not papered over (the crate's non-overreach discipline).
- **Honest non-drawing.** No curve is drawn — the traced anchor keeps
provenance, the same discipline as an unresolvable repeat boundary — when:
an endpoint is unresolved (dangling event, or a column in another region);

View File

@ -13,8 +13,8 @@ use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::{
Clef, EventId, KeySignature, MeasureId, MeasurePosition, MusicalDuration, NoteValue, PitchId,
PitchSpelling, RepeatStructureId, SpellingNominal, StaffId, TimeAnchor, TypedObjectId,
Clef, EventId, KeySignature, LineStyle, MeasureId, MeasurePosition, MusicalDuration, NoteValue,
PitchId, PitchSpelling, RepeatStructureId, SpellingNominal, StaffId, TimeAnchor, TypedObjectId,
WallClockTime,
};
use epiphany_determinism::{DomainTag, Preimage};
@ -176,6 +176,11 @@ pub enum LayoutDiagnosticKind {
/// sixteenth-or-shorter rest); the object is carried as a traced anchor
/// rather than drawn at a guessed shape.
UnbundledGlyph(GlyphReference),
/// A slur carries an authored non-`Solid` line style (dashed/dotted) that
/// the Minimal tier does not render — the curve is drawn solid, and the
/// unrendered style is surfaced here rather than silently dropped (its dash
/// pattern is a higher-tier refinement).
SlurLineStyleNotRendered,
}
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
@ -1471,7 +1476,20 @@ pub fn try_to_constrained(
_ => None,
};
match curve {
Some(curve) => emit.curve(curve),
Some(curve) => {
// A drawn slur whose authored line style is not solid
// renders solid at this tier; surface the gap so the
// dash/dotted intent is not silently lost.
if let Some(LayoutContent::Slur(slur)) = content {
if slur.line != LineStyle::Solid {
emit.diag(
provenance.source,
LayoutDiagnosticKind::SlurLineStyleNotRendered,
);
}
}
emit.curve(curve);
}
None => emit.stroke(anchor(provenance, Point::new(default_x, yo))),
}
}
@ -4417,6 +4435,32 @@ mod tests {
crate::roundtrip::round_trip(&score);
}
#[test]
fn an_authored_dashed_slur_draws_solid_but_surfaces_a_diagnostic() {
use epiphany_core::{LineStyle, SpanStyle};
let (mut score, _) = repeat_ready_score(45);
let events = region_a_events(&score);
let id: SlurId = score.identity.mint();
let mut dashed = slur(id, events[0], events[2], None);
dashed.style = SpanStyle {
line: LineStyle::Dashed,
thickness: None,
};
score.cross_cutting.slurs.push(dashed);
let constrained = to_constrained(&to_logical(&score));
// The curve still draws (solid) — ink and provenance preserved.
assert!(slur_curve_of(&constrained, id).is_some());
// …and the unrendered dash style is surfaced, not silently dropped.
assert!(
constrained.diagnostics.iter().any(|d| {
d.source == TypedObjectId::Slur(id)
&& d.kind == LayoutDiagnosticKind::SlurLineStyleNotRendered
}),
"a dashed slur surfaces a SlurLineStyleNotRendered diagnostic"
);
}
#[test]
fn out_of_range_authored_slur_dimensions_fall_back_to_defaults() {
let (mut score, _) = repeat_ready_score(44);

View File

@ -57,8 +57,10 @@ pub enum HitShape {
},
/// A curve (slur, …): its four cubic-bézier control points and a half-width.
/// Its geometry tests flatten the cubic into [`CURVE_FLATTEN_SEGMENTS`]
/// straight capsule segments (a polyline), so a click near the drawn arc
/// selects it — one region per curve, unlike per-segment hit fragments.
/// straight capsule segments (a polyline), inflated by a conservative
/// flattening-error bound so a click on the true drawn arc — which can bow
/// outside the sampled chords for a thin, high-curvature slur — never
/// misses. One region per curve, unlike per-segment hit fragments.
Curve {
p0: Point,
p1: Point,
@ -94,6 +96,26 @@ fn flatten_cubic(p0: Point, p1: Point, p2: Point, p3: Point) -> Vec<Point> {
.collect()
}
/// A **conservative** upper bound on the maximum distance between the true cubic
/// and its [`flatten_cubic`] polyline. The chord error over a parameter
/// interval of length `h` is at most `h²/8 · max‖B''‖`; a cubic's second
/// derivative is linear, so `max‖B''‖ = 6 · max(‖p02p1+p2‖, ‖p12p2+p3‖)`
/// (attained at an endpoint), and `h = 1/N`. The [`HitShape::Curve`] tests
/// inflate the capsule half-width by this bound so a click on the *drawn* arc —
/// which can bow outside the flattened chords for a thin, high-curvature slur —
/// never misses (the true curve lies within this distance of the polyline).
fn flatten_error_bound(p0: Point, p1: Point, p2: Point, p3: Point) -> f32 {
let second_diff = |a: Point, b: Point, c: Point| {
let dx = a.x.0 - 2.0 * b.x.0 + c.x.0;
let dy = a.y.0 - 2.0 * b.y.0 + c.y.0;
(dx * dx + dy * dy).sqrt()
};
let d = second_diff(p0, p1, p2).max(second_diff(p1, p2, p3));
let n = CURVE_FLATTEN_SEGMENTS as f32;
// h²/8 · 6·d = (3/(4N²))·d.
(3.0 * d) / (4.0 * n * n)
}
impl HitShape {
/// Whether a world `point` lies within this shape — the click-selection test.
/// A box is closed (edges included); a segment is within `half_width` of the
@ -112,9 +134,14 @@ impl HitShape {
p2,
p3,
half_width,
} => flatten_cubic(*p0, *p1, *p2, *p3)
.windows(2)
.any(|seg| distance_point_segment(point, seg[0], seg[1]) <= *half_width),
} => {
// Inflate by the flattening-error bound so ink that bows outside
// the sampled chords is still hit (see `flatten_error_bound`).
let reach = *half_width + flatten_error_bound(*p0, *p1, *p2, *p3);
flatten_cubic(*p0, *p1, *p2, *p3)
.windows(2)
.any(|seg| distance_point_segment(point, seg[0], seg[1]) <= reach)
}
}
}
@ -141,9 +168,12 @@ impl HitShape {
p2,
p3,
half_width,
} => flatten_cubic(*p0, *p1, *p2, *p3)
.windows(2)
.any(|seg| segment_intersects_rect(seg[0], seg[1], *half_width, &rect)),
} => {
let reach = *half_width + flatten_error_bound(*p0, *p1, *p2, *p3);
flatten_cubic(*p0, *p1, *p2, *p3)
.windows(2)
.any(|seg| segment_intersects_rect(seg[0], seg[1], reach, &rect))
}
}
}
@ -555,6 +585,46 @@ mod tests {
assert!(s.intersects_rect(BoundingBox::new(1.5, 1.3, 2.5, 1.7)));
}
#[test]
fn a_thin_high_curvature_curve_is_hit_on_its_true_arc_between_samples() {
// A near-degenerate-thin curve (half_width 0.001) with strong curvature:
// a point ON the true cubic strictly between two flatten samples bows
// outside the chord capsule at that half-width, but the error-bound
// inflation still hits it.
let (p0, p1, p2, p3) = (
Point::new(0.0, 0.0),
Point::new(0.0, 6.0),
Point::new(6.0, 6.0),
Point::new(6.0, 0.0),
);
let s = HitShape::Curve {
p0,
p1,
p2,
p3,
half_width: 0.001,
};
// A true-cubic point at a parameter offset from the 1/16 grid (t = 1/32
// falls between samples 0 and 1).
let on_arc = cubic_point(p0, p1, p2, p3, 1.0 / 32.0);
assert!(
s.contains(on_arc),
"a point on the drawn arc between flatten samples must be hit"
);
// The error bound is positive for a genuinely curved shape and zero for
// collinear control points (a straight "curve").
assert!(flatten_error_bound(p0, p1, p2, p3) > 0.0);
assert_eq!(
flatten_error_bound(
Point::new(0.0, 0.0),
Point::new(1.0, 0.0),
Point::new(2.0, 0.0),
Point::new(3.0, 0.0),
),
0.0
);
}
#[test]
fn a_curve_becomes_one_hit_region_tracing_its_source() {
use epiphany_core::{SlurId, TypedObjectId};

View File

@ -18,9 +18,9 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::prepass::{derive_annotations, DerivedAnnotations, PrePassProfile};
use epiphany_core::{
AleatoricAnchoringDiscipline, AnchorOffset, AnnotationAnchor, CanonicalValue, Clef,
CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, MeasurePosition,
CoordinateDiscipline, Event, EventId, EventPosition, KeySignature, LineStyle, MeasurePosition,
MusicalDuration, MusicalPosition, NotatedComponent, PitchId, PitchSpelling, Region, RegionEdge,
RegionId, RegionTimeModel, Score, SpaceUnit, StaffId, StaffPosition, TimeAnchor,
RegionId, RegionTimeModel, Score, SlurKind, SpaceUnit, StaffId, StaffPosition, TimeAnchor,
TimeSignatureDisplay, TupletId, TupletRatio, TypedObjectId, WallClockTime,
};
use epiphany_determinism::{DomainTag, Preimage};
@ -220,6 +220,15 @@ pub struct SlurContent {
/// Authored line thickness (`style.thickness`); `None` = the engraver's
/// default.
pub thickness: Option<SpaceUnit>,
/// The slur's kind (Chapter 5 `SlurKind`). Carried through the projection —
/// the Minimal tier draws one canonical arc for every kind, but the kind is
/// preserved for a kind-aware higher tier (a phrase mark's longer curve, an
/// editorial slur's distinct line).
pub kind: SlurKind,
/// The authored line style (`style.line`). The Minimal tier draws every
/// slur solid; a non-`Solid` style is surfaced as a diagnostic rather than
/// silently rendered solid (its dash pattern is a higher-tier refinement).
pub line: LineStyle,
}
/// A slur endpoint on the region's spacing axis.
@ -1087,6 +1096,8 @@ fn slur_content(score: &Score, slur: &epiphany_core::Slur) -> LayoutContent {
direction,
height: slur.curvature_override.as_ref().and_then(|o| o.height),
thickness: slur.style.thickness,
kind: slur.kind,
line: slur.style.line,
})
}

View File

@ -12,7 +12,7 @@
use epiphany_core::{
AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal,
CrossCuttingRegistry, CurvatureOverride, CurveDirection, Event, EventArena, EventDuration,
EventPosition, IdentifiedPitch, IdentityContext, Marker, Measure, MeasurePosition,
EventPosition, IdentifiedPitch, IdentityContext, LineStyle, Marker, Measure, MeasurePosition,
MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition,
RationalTime, RegionContent, RegionEdge, RegionTimeModel, RepeatKind, RepeatStructure,
ScalePosition, Score, Slur, SlurKind, SpaceUnit, SpanStyle, Spanner, Staff, StaffBasedContent,
@ -296,7 +296,14 @@ pub fn ten_measure_with_slurs(seed: u64) -> Score {
end_event: events[12],
kind: SlurKind::Editorial,
curvature_override: None,
style: SpanStyle::default(),
// An authored dashed line — the Minimal tier draws it solid and
// surfaces a `SlurLineStyleNotRendered` layout diagnostic (its dash
// pattern is a higher-tier refinement), so the fixture exercises that
// honest-deferral path.
style: SpanStyle {
line: LineStyle::Dashed,
thickness: None,
},
});
score
}