Skip to content

fix(core): return CSS pixels from the CPU worldToCanvas/canvasToWorld - #2850

Merged
wayfarer3130 merged 2 commits into
mainfrom
fix/browser-zoom-cpu
Aug 11, 2026
Merged

fix(core): return CSS pixels from the CPU worldToCanvas/canvasToWorld#2850
wayfarer3130 merged 2 commits into
mainfrom
fix/browser-zoom-cpu

Conversation

@wayfarer3130

@wayfarer3130 wayfarer3130 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

The CPU transform built by calculateTransform is anchored on the canvas backing store, which getOrCreateCanvas sizes in device pixels, so pixelToCanvas and canvasToPixel speak device pixels. StackViewport passed their results straight through worldToCanvasCPU and canvasToWorldCPU, making the CPU path the only viewport path whose public coordinates are not CSS pixels: the GPU implementations divide by devicePixelRatio before returning, and the tools event layer derives its canvas points from getBoundingClientRect, which has no DPR term.

The two agree only at devicePixelRatio 1, which is why this went unnoticed. At any other ratio - HiDPI display, zoomed browser, mobile device - interaction is mis-scaled by exactly that factor, most visibly as panning that moves the image devicePixelRatio times further than the pointer. The offset is permanent rather than transient, zero at the canvas centre and growing with distance from it.

Convert at the public boundary only. The conversion deliberately does not live inside pixelToCanvas/canvasToPixel: internal rendering callers pass device pixels to those on purpose, and setToPixelCoordinateSystem applies the same transform to a 2D context where device pixels are the correct unit. The three internal round trips through canvasToPixel are bridged so their results are byte-for-byte unchanged.

resetCameraCPU already passed element.clientWidth/2 - CSS pixels - into canvasToWorldCPU, so it was silently wrong at DPR != 1 and is corrected by this change.

Reproduce before the fix: open a stack example with ?cpu=1, set browser zoom to 150%, and pan.

Fixes #2849

Note that testing this requires the touchAllTools PR to be merged first.

Context

Changes & Results

Testing

Checklist

PR

  • [] My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • [] My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • [] The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • [] "OS:
  • [] "Node version:
  • [] "Browser:

fix(core): return CSS pixels from the CPU worldToCanvas/canvasToWorld

The CPU transform built by calculateTransform is anchored on the canvas
backing store, which getOrCreateCanvas sizes in device pixels, so
pixelToCanvas and canvasToPixel speak device pixels. StackViewport
passed their results straight through worldToCanvasCPU and
canvasToWorldCPU, making the CPU path the only viewport path whose
public coordinates are not CSS pixels: the GPU implementations divide by
devicePixelRatio before returning, and the tools event layer derives its
canvas points from getBoundingClientRect, which has no DPR term.

The two agree only at devicePixelRatio 1, which is why this went
unnoticed. At any other ratio - HiDPI display, zoomed browser, mobile
device - interaction is mis-scaled by exactly that factor, most visibly
as panning that moves the image devicePixelRatio times further than the
pointer. The offset is permanent rather than transient, zero at the
canvas centre and growing with distance from it.

Convert at the public boundary only. The conversion deliberately does
not live inside pixelToCanvas/canvasToPixel: internal rendering callers
pass device pixels to those on purpose, and setToPixelCoordinateSystem
applies the same transform to a 2D context where device pixels are the
correct unit. The three internal round trips through canvasToPixel are
bridged so their results are byte-for-byte unchanged.

resetCameraCPU already passed element.clientWidth/2 - CSS pixels - into
canvasToWorldCPU, so it was silently wrong at DPR != 1 and is corrected
by this change.

Reproduce before the fix: open a stack example with ?cpu=1, set browser
zoom to 150%, and pan.

Fixes #2849

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
@wayfarer3130
wayfarer3130 requested a review from jbocce August 7, 2026 16:58
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The CPU fallback now converts public CSS-pixel coordinates to device pixels for internal transforms and converts results back to CSS pixels. New helpers handle device-pixel ratios, and tests cover scaling, fallback behavior, and round trips.

Changes

CPU CSS Pixel Conversion

Layer / File(s) Summary
Conversion helpers and validation
packages/core/src/RenderingEngine/helpers/cpuFallback/rendering/cssPixelConversion.ts, packages/core/test/cssPixelConversion.jest.js
Added bidirectional CSS/device-pixel conversion using window.devicePixelRatio, with a fallback to 1. Tests cover integer and fractional ratios, missing ratios, identity conversions, and round trips.
StackViewport CPU coordinate boundaries
packages/core/src/RenderingEngine/StackViewport.ts
Applied conversions at CPU world/index, focal-point, canvas/world, and world/canvas boundaries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #2849 by converting CPU viewport coordinates at the public boundary while preserving device-pixel internal rendering.
Out of Scope Changes check ✅ Passed The conversion helpers, StackViewport changes, and tests are directly related to the linked CPU pixel-coordinate issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly describes the CPU coordinate conversion fix and follows the required semantic-release format.
Description check ✅ Passed The description explains the issue, implementation, impact, reproduction steps, and linked issue, but leaves the checklist and tested environment incomplete.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/browser-zoom-cpu

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@wayfarer3130

Copy link
Copy Markdown
Collaborator Author

@jbocce - I've tested this on a tablet device (amazon fire) and it resolves hte scaling transform issue.

const prevFocalPointPixel = canvasToPixel(
this._cpuFallbackEnabledElement,
prevFocalPointCanvas
cssToDevicePixels(prevFocalPointCanvas)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does the if(parallelScale) branch need to consider DPR as well?

@jbocce jbocce left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One small comment/question in the code changed, but I am also wondering why aren't other viewports affected by DPR or is it already handled?

@wayfarer3130
wayfarer3130 merged commit bdd1dd3 into main Aug 11, 2026
17 checks passed
@wayfarer3130
wayfarer3130 deleted the fix/browser-zoom-cpu branch August 11, 2026 18:44
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.

CPU rendering path uses device pixels in worldToCanvas/canvasToWorld, breaking pan and zoom when devicePixelRatio !== 1

2 participants