epiphany/crates/epiphany-engrave
Levi Neuwirth 24b6a34db9 Slurs: side from the stems, endpoints on the notes, apex clear of both
The rendered slurs were wrong in three independent ways, all visible in the
two-staff and three-staff goldens.

  1. Side. SlurDirection::Auto always arced above. The single-voice rule is
     OPPOSITE the stems -- all stems up puts the slur under the noteheads, all
     down puts it over them, and a mixed-stem span (which has no notehead side)
     goes above. Every Auto slur over a stem-up passage was drawn through its own
     stems. This is why stem direction had to land first: with every stem pointing
     up, "opposite the stems" means nothing.

  2. Endpoints. They sat at staff_top + gap -- a constant offset from the STAFF,
     not from the notes -- so a slur between two C6s hung below its own noteheads
     and crossed their ledger lines. They now sit a gap outside the endpoint
     column's ink, at the notehead's centre. Where the stem points the same way as
     the slur, that ink includes the stem, so the endpoint clears the stem tip.

  3. Clearance. The apex was span-proportional and blind, so a note between the
     endpoints poked straight through the arc. ColumnInk -- per staff, per column:
     top, bottom, stem direction, notehead centre -- is the obstacle field. The
     control points sit on the chord at thirds, so x is exactly linear in t and
     the arc's departure from the chord is 3*lift*t*(1-t); a column at t needing d
     more clearance forces an apex of at least d/(4*t*(1-t)).

An authored height is a floor, not a ceiling: clearance may raise it, so obeying
an author cannot draw a slur through a note. An authored direction still wins.

Obstacles are measured at the notehead CENTRE, the same x the endpoints use. The
first cut used the raw column x, which skews t and silently over-lifts: the
two-staff slur cleared its C6 by 4.05 spaces where 3.5 was needed. The clearance
test now asserts an upper bound as well as a lower one.

SLUR_INSET is gone. Endpoints at the notehead centres are what its 0.6-space
"tuck" approximated for the start point -- and got wrong for the end, where it
tucked a full notehead width to the LEFT of the final note.

Four mutations verified: always-above, staff-relative endpoints, no clearance
pass, and obstacles at the column x. The staff-relative-endpoint mutation PASSED
at first -- the tests asserted only "above the staff" / "below the staff", which a
staff-relative endpoint satisfies by construction. The exact-endpoint assertion
exists because that mutation survived.

Projection change, so no version moves; goldens churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:58:56 -04:00
..
src Slurs: side from the stems, endpoints on the notes, apex clear of both 2026-07-09 13:58:56 -04:00
Cargo.toml Phase 3 tranche 1: casting-off, K1 schema-fill, value-restoring undo 2026-07-02 21:55:26 -04:00
DECISIONS.md Two-sided inter-staff renegotiation, and the cascade defect it uncovered 2026-07-09 13:32:37 -04:00
README.md Phase 3 tranche 1: casting-off, K1 schema-fill, value-restoring undo 2026-07-02 21:55:26 -04:00

README.md

epiphany-engrave

Agent I's engraving constraint solver (spec Chapter 9): turns a ConstrainedLayoutIR into a ResolvedLayoutIR with real geometry. It is the production-side replacement for epiphany-layout-ir's interface-only StubSolver; the two live in separate crates so the spec's core/product boundary stays sharp.

Status: Minimal tier, with casting-off

  • Engraver runs a deterministic horizontal spacing pass (the first axis of the two-pass spring layout): each spring slot is placed left-to-right by a collision-aware advance derived from real glyph bearings.
  • A casting-off pass (Phase 3's layout track) then breaks the spaced line into systems at measure boundaries (greedy first-fit against a PageGeometry — default A4 portrait at an 8 mm staff), stacks systems vertically at the vertical-band model's inter-system gap, assigns pages by content height, and populates the real ResolvedPage/ResolvedSystem tree. Every position is baked into a single y-up world frame (pages stacked vertically), so the SVG renderer and hit-testing consume the flat glyph/stroke lists unchanged.
  • The IR's declared constraints are evaluated — geometric families against the pre-casting spaced frame, break constraints against the final break structure (hard breaks are always honoured; a pathological soft break is skipped and recorded as an IrOverride decision). Chosen breaks are recorded as EngravingDecisions with SynthesisKind::EngravedBreak targets, attributed to the user override that requested them when one did.
  • It reports SolverTier::Minimal: hard constraints (break family included) satisfied, no optimality claim — the quality-metric vector stays the honest all-worst placeholder until the Quality Metric Catalog lands.

Deferred: the vertical soft-spring solve, per-system justification/stretch, optimal break search, widow/orphan control, and casting-off caching. See DECISIONS.md.

use epiphany_engrave::Engraver;
use epiphany_layout_ir::{ConstraintSolver, SolverConfig};

let report = Engraver::default().solve(&constrained_ir, &SolverConfig::default());
assert!(report.satisfied_hard_constraints);
let resolved = report.layout; // real pages/systems; hand to epiphany-render-svg