Skip to content

fix(core): keep the globalZIndex clone aligned with its source graphic - #2110

Merged
xuefei1313 merged 3 commits into
VisActor:developfrom
g1f9:fix/interactive-graphic-bounds
Aug 2, 2026
Merged

fix(core): keep the globalZIndex clone aligned with its source graphic#2110
xuefei1313 merged 3 commits into
VisActor:developfrom
g1f9:fix/interactive-graphic-bounds

Conversation

@g1f9

@g1f9 g1f9 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🤔 This is a ...

  • Bug fix

🔗 Related issue link

fix #2109

💡 Background and solution

The clone that InteractiveDrawItemInterceptorContribution.beforeSetInteractive hoists into _builtin_interactive only carried globalZIndex / zIndex, so it lost the transform its source inherits from its ancestors.

Drawing was unaffected, because beforeDrawInteractive renders the baseGraphic and re-applies the matrix by hand:

context.setTransformFromMatrix(baseGraphic.parent.globalTransMatrix, true);

But the clone's own geometry was never corrected, so its globalAABBBounds — and therefore the bounds of the shadow root, of _interactive_group, and of the whole interactive layer — ended up short by exactly that transform.

Picking reads those bounds. DefaultPickService.pickGroup gates traversal on them:

const insideGroup = group.AABBBounds.containsPoint(newPoint);
if (!insideGroup && !group.stage.camera) return result;

So whenever the real pointer position fell outside the shifted box, the entire interactive layer was skipped and the hoisted graphic was unreachable even though it was painted on top. Graphics that happened to fall inside the shifted box still picked correctly, which made it look intermittent — in a chart, data points near one edge of the plot area stopped responding to hover while the rest worked.

The fix gives the clone a postMatrix built from its source's parent global matrix. postMatrix composes outside the local transform (Graphic.doUpdateLocalMatrix), so it reproduces exactly what the ancestor chain contributes, and rendering is untouched since the draw path still goes through baseGraphic.

There was already a // const m = graphic.globalTransMatrix; left commented out at that spot, so this looks like it was on someone's radar.

Verified against the reproduction attached to the issue (a graphic with globalZIndex inside a translated group, occluded by a sibling group): stage.pick goes from returning the covering rect to returning the circle, and the clone's bounds become identical to the source's.

📝 Changelog

Language Changelog
🇺🇸 English Fix graphics hoisted with globalZIndex becoming unpickable when an ancestor carries a transform.
🇨🇳 Chinese 修复祖先带变换时,配置了 globalZIndex 的图元无法被拾取的问题。

☑️ Self-Check before Merge

  • Doc is updated/provided or not needed
  • Demo is updated/provided or not needed
  • TypeScript definition is updated/provided or not needed
  • Changelog is provided or not needed

Added packages/vrender-core/__tests__/unit/render/interactive-graphic-bounds.test.ts, which fails on develop (clone lands at e = 150 instead of 250) and passes with the fix. tsc --noEmit and eslint are clean for vrender-core.

The clone that beforeSetInteractive hoists into the interactive layer only
carried globalZIndex/zIndex, so it lost the transform its source inherits from
its ancestors. Drawing was unaffected because beforeDrawInteractive renders the
baseGraphic and re-applies the matrix by hand, but the clone's own bounds - and
therefore the bounds of the shadow root, _interactive_group and the whole
_builtin_interactive layer - ended up short by that transform.

pickGroup gates traversal on those bounds, so a pointer position outside the
shifted box skipped the entire interactive layer and the hoisted graphic became
unpickable even though it was painted on top.

Give the clone a postMatrix built from its source's parent global matrix, which
composes outside the local transform and reproduces what the ancestor chain
contributes.

@xuefei1313 xuefei1313 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses translated ancestors, but the clone still diverges from the source for supported transform cases. Please address the inline blocker and extend the regression coverage.

Comment thread packages/vrender-core/src/render/contributions/render/draw-interceptor.ts Outdated
chendaxin.tk added 2 commits August 1, 2026 22:43
…e transform

Addresses review feedback on the first pass:

- graphic.clone() already copies the source postMatrix, and overwriting it with
  the parent global matrix dropped it. The ancestor transform is now composed
  with the source postMatrix instead of replacing it.
- doUpdateGlobalMatrix applies the parent scrollX/scrollY after the local
  matrix. Since that translation sits to the right of the local matrix it
  cannot be pre-multiplied directly, so it is conjugated through the source
  transform (a pure translation stays a translation) and folded into the
  ancestor part, giving clonePost = P x T(own . scroll) x ownPost.
- The matrix is cached on the clone and rewritten in place, so an elevated
  graphic no longer allocates a Matrix on every render pass.

Covered by four added cases: source postMatrix, parent scroll, the two
combined with a scaled ancestor and a rotated source, and matrix reuse.
…e source matrix

Measured what the two quantities actually do under a scrolling parent, with
parent {x:100, y:80, scrollX:30, scrollY:20} and a circle at {x:50, y:50}:

  ordinary graphic   globalTransMatrix.e/f = (180, 250)
                     globalAABBBounds centre = (150, 230)
                     painted pixels = (179.5, 249.4)

globalAABBBounds does not carry the parent scroll, while the matrix and the
painted position do. Picking pre-filters on AABBBounds, so matching the matrix
is the wrong target: it moved the clone's bounds away from the source's by
exactly the scroll.

Same setup, clone bounds centre against a source bounds centre of (150, 130):

  unpatched                    (50, 50)
  ancestor transform only     (150, 130)   <- matches
  ancestor transform + scroll (180, 150)   <- off by the scroll

So the scroll term is dropped and the test now pins that behaviour instead.
Source postMatrix preservation and the cached matrix are unchanged.
@g1f9

g1f9 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment — I had asserted from reading the code that the paint path drops the parent scroll. I could not actually reproduce that (the elevated graphic never painted in the harness I used, so the measurement was inconclusive), so please disregard that part. Measuring instead surfaced something more relevant, and it changes what this PR should do. Pushed as b442500.

What I measured. Parent {x: 100, y: 80, scrollX: 30, scrollY: 20}, circle at {x: 50, y: 50}, on released 1.1.5. For an ordinary graphic — nothing to do with the interactive layer:

value
globalTransMatrix.e/f (180, 250)
globalAABBBounds centre (150, 230)
painted pixels (centroid) (179.5, 249.4)

globalAABBBounds does not carry the parent scroll, while the matrix and the painted position do.

Why that matters here. Picking pre-filters on AABBBounds, not on the matrix — pickGroup does group.AABBBounds.containsPoint(newPoint). So matrix parity is the wrong target whenever scroll is involved. Same setup, comparing the clone's bounds centre against the source's (150, 130):

clone postMatrix clone bounds centre
unpatched (50, 50)
ancestor transform only (150, 130) — matches
ancestor transform + scroll (my last commit) (180, 150) — off by the scroll

So the scroll conjugation I added made the clone's bounds worse than leaving it out: it fixed matrix parity while breaking the parity that picking actually consumes. b442500 drops the scroll term, and the test now pins that deliberately, with the reasoning in the comment so nobody re-adds it.

Your first point still stands and is unchanged — clone() copies the source postMatrix and it must not be overwritten. The cached matrix for the per-render allocation is also unchanged. Net effect of this commit is -1 behaviour and a clearer test.

The mismatch between globalAABBBounds and globalTransMatrix under a scrolling parent looks like a separate pre-existing issue — happy to open one if you agree it is worth tracking, but it felt out of scope to change bounds semantics inside this fix.

@g1f9

g1f9 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up, and I need to un-retract part of what I said two comments ago. My "the paint path drops the parent scroll" claim was right; the measurement I used to walk it back was broken — the interactive layer renders into its own canvas, and my pixel scan only looked at the first one, which is why the elevated graphic came back as "not painted". Scanning every canvas gives a complete picture.

Same setup as before — parent {x: 100, y: 80, scrollX: 30, scrollY: 20}, released 1.1.5, one elevated circle and one ordinary circle in the same parent:

globalTransMatrix.e/f globalAABBBounds centre painted centroid
elevated (globalZIndex) (180, 150) (150, 130) (149.5, 129.5)
ordinary (180, 250) (150, 230) (179.5, 249.4)

Two separate things fall out:

1. For an elevated graphic, painting matches the bounds, not the matrix. beforeDrawInteractive sets the context to baseGraphic.parent.globalTransMatrix and BaseRender multiplies the graphic's own matrix onto it, so it lands at P × ownTransform — the parent scroll never enters. That is exactly what the ancestor-only postMatrix reproduces. So with the current commit the clone's bounds agree with the source's bounds and with where the graphic is actually drawn. Adding the scroll term put the bounds at (180, 150), disagreeing with both.

So the scroll case in your review does hold as an observation — the matrices genuinely differ by 30 — but the conclusion that the interactive-layer bounds stay wrong and picking can still be skipped is not what happens: the bounds line up. I think the mismatch is a red herring for this particular fix, since pickGroup gates on AABBBounds.

2. There is a real scroll inconsistency, but it is in the ordinary path. An ordinary graphic is painted at (179.5, 249.4) while its globalAABBBounds centre is (150, 230) — picking and pixels are a scroll apart for any graphic under a scrolling parent, elevated or not. That predates this PR and I do not think it should be fixed by changing bounds semantics here. Happy to open a separate issue with this reproduction if you want it tracked.

Reproduction for both tables is the same self-contained page as the issue, plus a per-canvas pixel scan; I can attach it to an issue if useful. Sorry for the noise of the retraction-and-reinstatement — the underlying claim held up, my first attempt to verify it did not.

@xuefei1313 xuefei1313 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed b442500. The source postMatrix is now preserved, the cached Matrix is reused, and the scroll behavior matches the bounds and paint semantics consumed by interactive-layer picking. Targeted tests, additional parent/source transform update cases, vrender-core TypeScript compilation, and changed-file lint all pass.

@xuefei1313
xuefei1313 merged commit 48db8f4 into VisActor:develop Aug 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] globalZIndex clone in the interactive layer loses its parent transform, so its bounds break picking

2 participants