From 38bd49058bd16c2545c5478b19450f8ac6ad4e32 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 4 Sep 2026 16:41:17 +0200 Subject: [PATCH 1/2] Fix slur loss when Explode moves a grace-noted note to another staff Explode/Implode clone voices between staves via CloneVoice::cloneVoice() with unlinked clones. The slur-reconnection code in doCloneVoice() looked up the new start/end ChordRest through the old element's links() list, which is only populated for linked clones (the "exchange voice" case), so it silently failed to reconnect slurs for Explode/Implode. Plain notes were rescued by a later tick/track-based fallback in Spanner::findStartCR()/findEndCR(), but that fallback can only resolve to the ChordRest actually stored in a Segment's track slot - never a grace chord, which shares its parent's tick. So a slur anchored on a grace note had no way to be recovered and was dropped. Add a ChordRest -> clone map (reusing the existing ElementMap type used by TieMap/TupletMap) populated for both main chords and grace chords while cloning, and consult it first when reconnecting slurs/hairpins/ hammer-on-pull-offs, falling back to the old links()-based lookup for any endpoint outside the cloned range. Resolves #34809 --- src/engraving/editing/clonevoice.cpp | 50 +++++++++++++++------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/src/engraving/editing/clonevoice.cpp b/src/engraving/editing/clonevoice.cpp index 91139833ac769..b94513bd441d1 100644 --- a/src/engraving/editing/clonevoice.cpp +++ b/src/engraving/editing/clonevoice.cpp @@ -34,6 +34,7 @@ #include "../dom/segment.h" #include "../dom/spanner.h" #include "../dom/staff.h" +#include "../dom/elementmap.h" #include "../dom/tiemap.h" #include "../dom/tremolotwochord.h" #include "../dom/tupletmap.h" @@ -49,6 +50,7 @@ static void doCloneVoice(Score* destScore, track_idx_t srcTrack, track_idx_t dst Fraction start = sourceSeg->tick(); TieMap tieMap; TupletMap tupletMap; // tuplets cannot cross measure boundaries + ElementMap crMap; // old ChordRest/grace chord -> its clone, for spanner reconnection TremoloTwoChord* tremolo = nullptr; auto maybeLinkedClone = [link](EngravingItem* item) -> EngravingItem* { @@ -72,6 +74,7 @@ static void doCloneVoice(Score* destScore, track_idx_t srcTrack, track_idx_t dst ChordRest* ncr = toChordRest(maybeLinkedClone(ocr)); ncr->setScore(destScore); ncr->setTrack(dstTrack); + crMap.add(ocr, ncr); //Don't clone gaps to a first voice if (!(ncr->track() % VOICES) && ncr->isRest()) { @@ -205,6 +208,7 @@ static void doCloneVoice(Score* destScore, track_idx_t srcTrack, track_idx_t dst Chord* ogc = toChord(ocr)->graceNotes().at(i); Chord* ngc = toChord(ncr)->graceNotes().at(i); cloneChord(ogc, ngc); + crMap.add(ogc, ngc); } } @@ -285,32 +289,32 @@ static void doCloneVoice(Score* destScore, track_idx_t srcTrack, track_idx_t dst ChordRest* cr1 = sp->startCR(); ChordRest* cr2 = sp->endCR(); - ns->setStartElement(0); - ns->setEndElement(0); - if (cr1 && cr1->links()) { - for (EngravingObject* e : *cr1->links()) { - ChordRest* cr = toChordRest(e); - if (cr == cr1) { - continue; - } - if ((cr->score() == destScore) && (cr->tick() == ns->tick()) && cr->track() == dstTrack) { - ns->setStartElement(cr); - break; - } + // Prefer the direct old-CR -> new-CR mapping built while cloning above: it also covers + // grace chords, which share their parent's tick and so can't be told apart by tick/track + // alone (the fallback below, kept for cross-staff endpoints not cloned here, cannot resolve them). + auto findNewCR = [&](ChordRest* cr, const Fraction& tick) -> ChordRest* { + if (!cr) { + return nullptr; } - } - if (cr2 && cr2->links()) { - for (EngravingObject* e : *cr2->links()) { - ChordRest* cr = toChordRest(e); - if (cr == cr2) { - continue; - } - if ((cr->score() == destScore) && (cr->tick() == ns->tick2()) && cr->track() == dstTrack) { - ns->setEndElement(cr); - break; + if (EngravingItem* mapped = crMap.findNew(cr)) { + return toChordRest(mapped); + } + if (cr->links()) { + for (EngravingObject* e : *cr->links()) { + ChordRest* linkedCr = toChordRest(e); + if (linkedCr == cr) { + continue; + } + if ((linkedCr->score() == destScore) && (linkedCr->tick() == tick) && linkedCr->track() == dstTrack) { + return linkedCr; + } } } - } + return nullptr; + }; + + ns->setStartElement(findNewCR(cr1, ns->tick())); + ns->setEndElement(findNewCR(cr2, ns->tick2())); if (link) { destScore->doUndoAddElement(ns); } else { From 4d38fed3b73b038a6ab10ad3d82dddaf4946b8de Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 4 Sep 2026 16:51:08 +0200 Subject: [PATCH 2/2] Don't map skipped full-measure rests in the clone-voice spanner map crMap.add(ocr, ncr) ran before the full-measure-rest early-exit for non-first voices, so a rest that got skipped (never added to the destination score) was still registered as the clone of ocr. A hairpin anchored on that rest would then resolve its endpoint to this orphaned, unattached clone instead of falling back to the tick/track lookup. Move the crMap.add() call after the skip check, so only ChordRests that actually survive into the destination score are ever returned as a spanner endpoint. Addresses CodeRabbit review comment on #34810, resolves #34809 --- src/engraving/editing/clonevoice.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/engraving/editing/clonevoice.cpp b/src/engraving/editing/clonevoice.cpp index b94513bd441d1..130bea68a8428 100644 --- a/src/engraving/editing/clonevoice.cpp +++ b/src/engraving/editing/clonevoice.cpp @@ -74,7 +74,6 @@ static void doCloneVoice(Score* destScore, track_idx_t srcTrack, track_idx_t dst ChordRest* ncr = toChordRest(maybeLinkedClone(ocr)); ncr->setScore(destScore); ncr->setTrack(dstTrack); - crMap.add(ocr, ncr); //Don't clone gaps to a first voice if (!(ncr->track() % VOICES) && ncr->isRest()) { @@ -130,6 +129,10 @@ static void doCloneVoice(Score* destScore, track_idx_t srcTrack, track_idx_t dst } } + // Only map ChordRests that actually survive into the destination score - a skipped full-measure + // rest above must never be found as a spanner endpoint, since it is never added anywhere. + crMap.add(ocr, ncr); + auto cloneChord = [&](Chord* oldChord, Chord* newChord) { size_t n = oldChord->notes().size(); for (size_t i = 0; i < n; ++i) {