Skip to content

Commit 4d6f950

Browse files
committed
Grace note engraving: scaled stems/flags/beams, acciaccatura slash, stems forced up
Stem class gains _graceScale (60% thinner lines) and _slash (acciaccatura diagonal through stem). handleNote/handleChord/drawBeamGroup in beams.js now detect grace notes: force stems up, shorten to ~5 half-spaces, scale flag glyphs, and tag beams with _graceScale for thinner beam lines. Beam class scales thickness proportionally. Added 'Grace Note Variants' visual test (accidentals, high position, beamed 16th grace pair).
1 parent 0865877 commit 4d6f950

File tree

4 files changed

+88
-27
lines changed

4 files changed

+88
-27
lines changed

โ€Žplans/features-todo.mdโ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Planned features and improvements, roughly prioritized.
5454
- [ ] Multiple lyric verses (currently only verse 1 rendered; NWC supports up to 8)
5555
- [ ] Grand staff brace rendering (`braceWithNext` parsed but not drawn)
5656
- [ ] Proportional / spring-and-rod spacing (currently fixed-width-per-duration)
57-
- [x] Grace notes โ€” drawn at 60% scale with reduced spacing
57+
- [x] Grace notes โ€” 60% scale noteheads/accidentals, stems always up, shorter stems (~5 half-spaces), thinner stem/beam lines, scaled flag glyphs, acciaccatura slash through stem. Beamed grace groups get scaled beams. Reduced horizontal spacing (40% spring, 50% padding).
5858
- [x] Ties and slurs โ€” direction follows stem (upโ†’below, downโ†’above); anchored at notehead pitch (offsetY); per-child-note chord ties; cross-system tie splitting preserves direction; slurs use outer chord note as anchor
5959
- [x] **Professional tie/slur engraving** โ€” Cubic bezier curves with proportional arc height (short ties = round, long ties = flat); separate Tie/Slur classes (ties thicker+rounder, slurs thinner+more open); edge-anchored ties (gap from notehead edges, "never touch"); chord inner/outer direction (top curves above, bottom below, inner follows nearest); mixed-stem slurs default above; staff-line avoidance (peak nudged into spaces); accidental collision clearance; engraving constants in `src/engraving-rules.js`
6060
- [x] Triplet/tuplet brackets โ€” numeral on stem/beam side; fully-beamed triplets get numeral only (no bracket); unbeamed/mixed get bracket+numeral; vocal staves place numeral above to clear lyrics
@@ -66,7 +66,7 @@ Planned features and improvements, roughly prioritized.
6666
- [ ] **Staves need sufficient vertical space** โ€” when notes extend far above/below the staff (ledger lines, high beams, triplet brackets, slur arcs), adjacent staves can overlap. The vertical gap between staves should be computed dynamically based on the actual content extent (highest/lowest drawn element) rather than using a fixed inter-staff gap. This affects both scroll and wrap modes.
6767
- [ ] **Hairpins collide with adjacent dynamics** โ€” a crescendo/decrescendo wedge that leads into a dynamic marking (e.g. cresc โ†’ ff) can overlap the dynamic glyph. The hairpin end-point should stop short to leave clearance, or the dynamic should be nudged right.
6868
- [ ] **Hairpins and dynamics vertical alignment** โ€” hairpin wedges and dynamic markings on the same staff should share a consistent baseline Y position so they read as a continuous expression lane, rather than each sitting at its own independent vertical offset.
69-
- [ ] **Grace notes: stem/flag not scaled** โ€” grace note noteheads and accidentals render at 60% scale, but stems are full length/thickness and flags are full-sized. `beams.js` has zero grace-note awareness. Need: shorter stems (~4 half-spaces), 60%-scaled flag glyphs, thinner stem lines, and optionally an acciaccatura slash through the stem.
69+
- [x] **Grace notes: stem/flag not scaled** โ€” Fixed: stems shortened to ~5 half-spaces with 60% thickness, flag glyphs scaled, acciaccatura slash drawn, stems forced up. Beam groups also scaled (thinner beams, shorter stems).
7070
- [ ] **Stave brackets misaligned** โ€” bracket/brace rendering for staff groups (`bracketWithNext`, `braceWithNext`) is visually incorrect or mispositioned.
7171
- [ ] **Chord tie orientation wrong** โ€” ties on chord notes don't always follow the inner/outer rule correctly in all cases. The top note should curve above, bottom note below, inner notes follow nearest outer.
7272
- [ ] **Slur direction with mixed beams** โ€” when a slur spans notes that belong to different beam groups or a mix of beamed and unbeamed notes, the slur direction heuristic can pick the wrong side. Should consider the overall phrase contour and stem directions of all spanned notes, not just the start/end.

โ€Žsrc/drawing.jsโ€Ž

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,26 @@ class Stem extends Draw {
561561
}
562562

563563
draw(ctx) {
564+
var scale = this._graceScale || 1
564565
ctx.beginPath()
565-
ctx.lineWidth = getFontSize() / 30 // 1.2
566+
ctx.lineWidth = (getFontSize() / 30) * scale
566567
ctx.moveTo(0, 0)
567568
ctx.lineTo(0, this.unitsToY(this.len))
568569
ctx.stroke()
570+
571+
// Acciaccatura slash: diagonal line through the stem
572+
if (this._slash) {
573+
var fs = getFontSize()
574+
var slashLen = fs * 0.25 * scale
575+
// Position the slash roughly 1/3 up the stem
576+
var stemPixels = this.unitsToY(this.len)
577+
var slashY = stemPixels * 0.35
578+
ctx.beginPath()
579+
ctx.lineWidth = (fs / 24) * scale
580+
ctx.moveTo(-slashLen * 0.6, slashY - slashLen * 0.5)
581+
ctx.lineTo(slashLen * 0.6, slashY + slashLen * 0.5)
582+
ctx.stroke()
583+
}
569584
}
570585
}
571586

@@ -968,7 +983,8 @@ class Beam extends Draw {
968983
}
969984

970985
draw(ctx) {
971-
const beamThickness = getFontSize() / 10
986+
var scale = this._graceScale || 1
987+
const beamThickness = (getFontSize() / 10) * scale
972988
const beamSpacing = beamThickness * 1.0
973989
// Stems-up: additional beams stack downward (toward noteheads) โ†’ positive offset.
974990
// Stems-down: additional beams stack upward (toward noteheads) โ†’ negative offset.

โ€Žsrc/layout/beams.jsโ€Ž

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,19 @@ function computeStemLength(position, stemUp, chordSpan, beamCount) {
166166
function drawBeamGroup(group) {
167167
if (group.length < 2) return
168168

169+
// Detect grace note beam group (all notes in group are grace)
170+
const isGraceGroup = group.every(t => t.grace)
171+
const graceScale = isGraceGroup ? 0.6 : 1.0
172+
169173
// Use the stored stem direction from the NWC file if available.
170174
// stem: 1 = up, 2 = down. Fall back to average-position heuristic.
171-
const firstStemDir = group[0].stem
175+
// Grace notes: stems almost always point up.
172176
let stemUp
173-
if (firstStemDir === 1) {
177+
if (isGraceGroup) {
178+
stemUp = true
179+
} else if (group[0].stem === 1) {
174180
stemUp = true
175-
} else if (firstStemDir === 2) {
181+
} else if (group[0].stem === 2) {
176182
stemUp = false
177183
} else {
178184
const avgPosition = group.reduce((sum, token) => {
@@ -281,15 +287,17 @@ function drawBeamGroup(group) {
281287
beamBase = -Infinity
282288
for (const nd of noteData) {
283289
const t = (nd.x - firstX) / xSpan
284-
const minStem = computeStemLength(nd.position, stemUp, nd.chordSpan, totalBeamCount)
290+
var minStem = computeStemLength(nd.position, stemUp, nd.chordSpan, totalBeamCount)
291+
if (isGraceGroup) minStem = Math.min(minStem, 5)
285292
const needed = nd.position + minStem - slope * t
286293
if (needed > beamBase) beamBase = needed
287294
}
288295
} else {
289296
beamBase = Infinity
290297
for (const nd of noteData) {
291298
const t = (nd.x - firstX) / xSpan
292-
const minStem = computeStemLength(nd.position, stemUp, nd.chordSpan, totalBeamCount)
299+
var minStem = computeStemLength(nd.position, stemUp, nd.chordSpan, totalBeamCount)
300+
if (isGraceGroup) minStem = Math.min(minStem, 5)
293301
const needed = nd.position - minStem - slope * t
294302
if (needed < beamBase) beamBase = needed
295303
}
@@ -320,6 +328,7 @@ function drawBeamGroup(group) {
320328
// Stem-down: start at (relativePos - stemLen), draws up by stemLen โ†’ reaches notehead
321329
const stemY = stemUp ? data.relativePos : data.relativePos - data.stemLen
322330
const stem = new Stem(stemY, data.stemLen)
331+
if (isGraceGroup) stem._graceScale = graceScale
323332
stem.moveTo(data.x, data.y)
324333
drawing.add(stem)
325334
})
@@ -333,6 +342,7 @@ function drawBeamGroup(group) {
333342

334343
const primaryBeam = new Beam(beamStartRelative, beamEndRelative, 0, lastStem.x - firstStem.x, primaryBeamCount)
335344
primaryBeam.stemUp = stemUp
345+
if (isGraceGroup) primaryBeam._graceScale = graceScale
336346
primaryBeam.moveTo(firstStem.x, firstStem.y)
337347
drawing.add(primaryBeam)
338348

@@ -362,6 +372,7 @@ function drawBeamGroup(group) {
362372

363373
const subBeam = new Beam(segStartY, segEndY, segStartX, segEndX, 1)
364374
subBeam.stemUp = stemUp
375+
if (isGraceGroup) subBeam._graceScale = graceScale
365376
subBeam._beamOffset = seg.level
366377
subBeam.moveTo(firstStem.x, firstStem.y)
367378
drawing.add(subBeam)
@@ -384,8 +395,12 @@ function handleChord(token) {
384395
const topNote = notes.reduce((a, b) => a.position > b.position ? a : b)
385396
const bottomNote = notes.reduce((a, b) => a.position < b.position ? a : b)
386397

387-
const stemUp =
388-
token.Stem === 'Up' || token.stem === 1
398+
const isGrace = !!token.grace
399+
const graceScale = isGrace ? 0.6 : 1.0
400+
401+
// Grace notes: stems almost always point up.
402+
const stemUp = isGrace ? true
403+
: token.Stem === 'Up' || token.stem === 1
389404
? true
390405
: token.Stem === 'Down' || token.stem === 2
391406
? false
@@ -397,27 +412,31 @@ function handleChord(token) {
397412

398413
const relativePos = anchorNote.position + 4
399414
const chordSpan = topNote.position - bottomNote.position
400-
// Standalone chords: beamCount=0 (extra beam length only applies in beam groups)
401-
const stemLen = computeStemLength(anchorNote.position, stemUp, chordSpan, 0)
415+
const stemLen = isGrace ? 5 + chordSpan
416+
: computeStemLength(anchorNote.position, stemUp, chordSpan, 0)
402417
const requireFlag = duration >= 8
403418

404419
if (!stemUp) {
405420
const stem = new Stem(relativePos - stemLen, stemLen)
421+
if (isGrace) { stem._graceScale = graceScale; stem._slash = true }
406422
stem.moveTo(notehead.x, notehead.y)
407423
drawing.add(stem)
408424

409425
if (requireFlag) {
410426
var flag = new Glyph(`flag${duration}thDown`, relativePos - stemLen - 0.5)
427+
if (isGrace) flag._graceScale = graceScale
411428
flag.moveTo(notehead.x, notehead.y)
412429
drawing.add(flag)
413430
}
414431
} else {
415432
const stem = new Stem(relativePos, stemLen)
433+
if (isGrace) { stem._graceScale = graceScale; stem._slash = true }
416434
stem.moveTo(notehead.x + notehead.width, notehead.y)
417435
drawing.add(stem)
418436

419437
if (requireFlag) {
420438
var flag = new Glyph(`flag${duration}thUp`, relativePos + stemLen)
439+
if (isGrace) flag._graceScale = graceScale
421440
flag.moveTo(notehead.x + notehead.width, notehead.y)
422441
drawing.add(flag)
423442
}
@@ -431,35 +450,44 @@ function handleNote(token) {
431450
const notehead = token.drawingNoteHead
432451
if (!notehead) return
433452

434-
const stemUp =
435-
token.Stem === 'Up' || token.stem === 1
453+
const isGrace = !!token.grace
454+
const graceScale = isGrace ? 0.6 : 1.0
455+
456+
// Grace notes: stems almost always point up (standard engraving).
457+
const stemUp = isGrace ? true
458+
: token.Stem === 'Up' || token.stem === 1
436459
? true
437460
: token.Stem === 'Down' || token.stem === 2
438461
? false
439462
: token.position < 0
440463

441464
const relativePos = token.position + 4
442-
// Standalone notes: beamCount=0 (extra beam length only applies in beam groups)
443-
const stemLen = computeStemLength(token.position, stemUp, 0, 0)
465+
// Grace notes: ~5 half-spaces stem (shorter than the standard 7).
466+
const stemLen = isGrace ? 5
467+
: computeStemLength(token.position, stemUp, 0, 0)
444468
const requireFlag = duration >= 8
445469

446470
if (!stemUp) {
447471
const stem = new Stem(relativePos - stemLen, stemLen)
472+
if (isGrace) { stem._graceScale = graceScale; stem._slash = true }
448473
stem.moveTo(notehead.x, notehead.y)
449474
drawing.add(stem)
450475

451476
if (requireFlag) {
452477
var flag = new Glyph(`flag${duration}thDown`, relativePos - stemLen - 0.5)
478+
if (isGrace) flag._graceScale = graceScale
453479
flag.moveTo(notehead.x, notehead.y)
454480
drawing.add(flag)
455481
}
456482
} else {
457483
const stem = new Stem(relativePos, stemLen)
484+
if (isGrace) { stem._graceScale = graceScale; stem._slash = true }
458485
stem.moveTo(notehead.x + notehead.width, notehead.y)
459486
drawing.add(stem)
460487

461488
if (requireFlag) {
462489
var flag = new Glyph(`flag${duration}thUp`, relativePos + stemLen)
490+
if (isGrace) flag._graceScale = graceScale
463491
flag.moveTo(notehead.x + notehead.width, notehead.y)
464492
drawing.add(flag)
465493
}

โ€Žtest/visual/fixtures.jsโ€Ž

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -723,16 +723,33 @@ const SYNTHETIC_FIXTURES = [
723723
]]),
724724
},
725725
{
726-
label: 'Grace Notes',
727-
desc: 'Grace note before regular note โ€” 60% scale',
728-
data: () => makeScore('Grace Notes', [[
729-
clef(), keySig(), timeSig(),
730-
note(2, 8, { grace: 1 }), note(0, 4),
731-
note(4, 8, { grace: 1 }), note(2, 4),
732-
note(-2, 8, { grace: 1 }), note(0, 2),
733-
bar(3),
734-
]]),
735-
},
726+
label: 'Grace Notes',
727+
desc: 'Acciaccatura (slashed stem), scaled flag, stems up',
728+
data: () => makeScore('Grace Notes', [[
729+
clef(), keySig(), timeSig(),
730+
// Single grace notes before different durations
731+
note(2, 8, { grace: 1 }), note(0, 4),
732+
note(4, 8, { grace: 1 }), note(2, 4),
733+
note(-2, 8, { grace: 1 }), note(0, 2),
734+
bar(3),
735+
]]),
736+
},
737+
{
738+
label: 'Grace Note Variants',
739+
desc: 'High/low positions, with accidentals, beamed grace group',
740+
data: () => makeScore('Grace Note Variants', [[
741+
clef(), keySig(), timeSig(),
742+
// Grace note with accidental
743+
note(0, 8, { grace: 1, accidental: '#' }), note(0, 4),
744+
// Grace note high on staff
745+
note(6, 8, { grace: 1 }), note(4, 4),
746+
// Beamed grace note pair (16ths)
747+
note(0, 16, { grace: 1, beam: 1 }),
748+
note(2, 16, { grace: 1, beam: 3 }),
749+
note(0, 2),
750+
bar(3),
751+
]]),
752+
},
736753
],
737754
},
738755
// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

0 commit comments

Comments
ย (0)