diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md
index d21cb61..4a503bf 100644
--- a/crates/epiphany-engrave/DECISIONS.md
+++ b/crates/epiphany-engrave/DECISIONS.md
@@ -709,13 +709,29 @@ single-staff score, with no inter-staff pair, is byte-identical).
**Attribution (`vertical_band` + owning-glyph).** Every resolved primitive is
attributed to its owning staff: a glyph via its `vertical_band`
-(`VerticalBandKind::Staff` → `StaffId`); a stem or ledger via its notehead
-(`component_glyph` → that glyph's band); a staff line via its `Staff`
-provenance source; a slur via the notehead nearest its start endpoint. Spacing
-is horizontal-only, so a primitive's y is unchanged from the source frame the
-attribution reads, and the resolved and source indices line up. (The geometric
-`round(-y / pitch)` alternative was rejected: an extreme ledgered note on the
-top or bottom staff rounds to a non-existent neighbour.)
+(`VerticalBandKind::Staff` → `StaffId`); a ledger via its notehead
+(`owning_glyph`, a shared `Pitch` source); a **stem** — which has no same-source
+glyph — via the glyph nearest its BASE point *in two dimensions*; a staff line
+via its `Staff` provenance source; a slur via the notehead nearest its start
+endpoint. Spacing is horizontal-only, so a primitive's y is unchanged from the
+source frame the attribution reads, and the resolved and source indices line up.
+(The geometric `round(-y / pitch)` alternative was rejected: an extreme ledgered
+note on the top or bottom staff rounds to a non-existent neighbour.)
+
+**Why staff attribution must be y-aware (review fix).** The first cut reused
+`component_glyph`, whose fallback picks the nearest glyph **by x alone**. That is
+right for a *slot* — both staves of a system share their x columns, hence their
+spring slots, so the horizontal delta is the same either way — but it is wrong
+for a *staff*: it handed a lower-staff stem to the UPPER staff's notehead, so the
+stem kept the wrong vertical shift and tore off its own head by several staff
+spaces (and polluted the upper staff's content extent, inflating the computed
+gap). The staff attribution now uses a 2-D nearest for that fallback.
+`component_glyph` is unchanged and still serves the horizontal path. Locked by
+`multi_staff_stems_stay_on_their_own_staff` (a stem's base sits at its notehead
+on both the single- and multi-staff fixtures). A related correction: staff-
+attributed primitives contribute their y ONLY through the shifted path
+(`Extent::add_x` for x, `add_y` for the shifted staff extent), so a lower staff's
+unshifted content can no longer inflate a system's `max_y`.
**The solve.** Per system, per staff, the real content y-extent is collected
(glyphs, strokes, curves — ledgers and slurs included, not just noteheads). The
diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs
index d4d5fe6..4f3a616 100644
--- a/crates/epiphany-engrave/src/casting.rs
+++ b/crates/epiphany-engrave/src/casting.rs
@@ -81,7 +81,7 @@ use epiphany_layout_ir::{
SynthesisKind, SynthesisRegistryId, VerticalBand, VerticalBandId, VerticalBandKind,
};
-use crate::{component_glyph, owning_glyph};
+use crate::owning_glyph;
/// The registry id for the engraver's **system-continuation synthesis**: the
/// segment of a region-spanning stroke (a staff line) that casting-off places
@@ -353,22 +353,23 @@ impl Extent {
}
}
- fn add(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
- if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) {
- self.min_x = self.min_x.min(x0.min(x1));
- self.max_x = self.max_x.max(x0.max(x1));
- self.min_y = self.min_y.min(y0.min(y1));
- self.max_y = self.max_y.max(y0.max(y1));
- self.any = true;
- }
- }
-
/// Extend only the vertical extent (the inter-staff solve grows a system's
/// height by shifting staves apart, without touching its x-span).
fn add_y(&mut self, y0: f32, y1: f32) {
if y0.is_finite() && y1.is_finite() {
self.min_y = self.min_y.min(y0.min(y1));
self.max_y = self.max_y.max(y0.max(y1));
+ self.any = true;
+ }
+ }
+
+ /// Extend only the horizontal extent. Staff-attributed content contributes
+ /// its y through the inter-staff solve (SHIFTED), never here.
+ fn add_x(&mut self, x0: f32, x1: f32) {
+ if x0.is_finite() && x1.is_finite() {
+ self.min_x = self.min_x.min(x0.min(x1));
+ self.max_x = self.max_x.max(x0.max(x1));
+ self.any = true;
}
}
@@ -651,12 +652,28 @@ pub(crate) fn cast_off(
.collect();
let glyph_band_staff = |g: &GlyphObject| band_to_staff.get(&g.vertical_band).copied();
let glyph_staff_of: Vec