Skip to content

Fix/rotation recalc compound transforms#1083

Merged
jfhenon merged 6 commits into
SVG-Edit:masterfrom
geoffyoungs:fix/rotation-recalc-compound-transforms
Mar 15, 2026
Merged

Fix/rotation recalc compound transforms#1083
jfhenon merged 6 commits into
SVG-Edit:masterfrom
geoffyoungs:fix/rotation-recalc-compound-transforms

Conversation

@geoffyoungs
Copy link
Copy Markdown
Contributor

@geoffyoungs geoffyoungs commented Mar 15, 2026

PR description

Fix rotation centre recalculation corrupting compound transforms

When changing attributes on rotated elements, the rotation center recalculation had two bugs:

  1. It fired for all attribute changes (stroke-width, fill, opacity, etc.), not just geometry-affecting ones (x, y, width, height, etc.). Non-geometric changes don't move the bbox centre, so the recalculation was unnecessary and destructive.
  2. The new rotation center was computed through all remaining transforms in the list, including pre-rotation ones like translate. This caused the translate to leak into the centre, producing incorrect rotation.

Both bugs existed in undo.js (changeSelectedAttributeNoUndoMethod) and history.js (ChangeElementCommand.apply/unapply). The history.js version was worse — it replaced the entire transform attribute with just rotate(...), destroying any other transforms.

To replicate the bug:

Use this sample SVG: compound-transform-bug

This branch is deployed to: https://geoffyoungs.github.io/svgedit/
Compare with the upstream demo at https://svgedit.netlify.app/editor/index.html

To test the bug:

  1. Open both editors
  2. Import the pinwheel SVG (or draw shapes with compound transforms)
  3. Select the group and change stroke-width
  4. On the upstream demo, the shapes jump — on the fixed version, they stay in place

Checklist

Note that we require UI tests to ensure that the added feature will not be
nixed by some future fix and that there is at least some test-as-documentation
to indicate how the fix or enhancement is expected to behave.

  • - Added Cypress UI tests
  • - Ran npm test, ensuring linting passes and that Cypress UI tests keep
    coverage to at least the same percent (reflected in the coverage badge
    that should be updated after the tests run)
  • - Added any user documentation. Though not required, this can be a big
    help both for future users and for the PR reviewer.

NB: Claude Code was used to identify and fix this issue.

Summary by Sourcery

Fix rotation center recalculation so that transforms on rotated elements are preserved and compound transforms remain stable when attributes change or history operations are applied.

Bug Fixes:

  • Restrict rotation center recalculation to geometry-affecting attributes and compute the new center using only post-rotation transforms, preventing corrupt rotation on elements with compound transforms.
  • Preserve existing transform lists when updating rotation centers during undo/redo instead of replacing the entire transform with a simple rotate(), avoiding loss of translate/scale transforms.

Tests:

  • Add visual fixtures and a Playwright-based screenshot script to document and manually verify the rotation recalculation behaviour on elements with compound transforms.

geoffyoungs and others added 4 commits March 15, 2026 11:26
Two bugs in the rotation center recalculation triggered by attribute
changes on rotated elements:

1. The recalculation fires for ALL attribute changes (stroke-width,
   fill, opacity, etc.) even though only geometric attributes (x, y,
   width, height, d, points, etc.) can change the bbox center. This
   causes unnecessary and incorrect rotation center updates.

2. In undo.js, the center is computed through ALL remaining transforms
   via transformListToTransform(tlist).matrix, but for compound
   transform lists like [translate(tx,ty), rotate(angle)], the
   pre-rotation translate leaks into the center calculation, producing
   rotate(angle, bcx+tx, bcy+ty) instead of the correct
   rotate(angle, bcx, bcy).

3. In history.js (undo/redo), the code replaces the ENTIRE transform
   attribute with just rotate(...), completely destroying any other
   transforms (translate, scale, etc.) in the list.

Fixes:
- Guard recalculation with a BBOX_AFFECTING_ATTRS set so non-geometric
  attribute changes skip the rotation block entirely.
- In undo.js, use transformListToTransform(tlist, n, max) to compute
  the center through only post-rotation transforms.
- In history.js, replace string-based transform replacement with
  transform list API that finds and updates only the rotation entry,
  preserving all other transforms. Extract shared logic into
  relocateRotationCenter() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests demonstrate and verify fixes for three bugs:

1. Non-geometric attributes (stroke-width, fill, opacity) no longer
   trigger rotation center recalculation — the transform list is
   left untouched.

2. Geometric attribute changes on elements with compound transforms
   (e.g. translate + rotate) compute the rotation center using only
   post-rotation transforms, preventing the translate from leaking
   into the center calculation.

3. ChangeElementCommand.apply/unapply preserve compound transform
   structure instead of replacing the entire transform attribute
   with just rotate(...).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Includes an HTML demo showing both bugs side-by-side:
- Bug 1: Non-geometric attribute changes (stroke-width) on compound
  transforms corrupt character positions (e.g., word art on curves)
- Bug 2: Rotation center computed through ALL transforms instead of
  only post-rotation transforms, causing translate to leak into center

Each panel shows Original / Buggy / Fixed comparisons with the actual
SVG transform math applied. A Playwright script generates screenshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A pinwheel of 6 colored rectangles using translate(200,200) rotate(N).
Loading this into SVG-Edit and changing stroke-width on the group
triggers the rotation recalculation bug on unfixed builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Mar 15, 2026

Reviewer's Guide

Refactors rotation-center recalculation so it only runs for geometry-affecting attribute changes and correctly preserves compound transform lists, and adds visual/demo assets and supporting test scaffolding for the bug.

Sequence diagram for attribute change with safe rotation-center recalculation

sequenceDiagram
  actor User
  participant EditorUI
  participant SvgCanvas
  participant ChangeElementCommand
  participant relocateRotationCenter
  participant SVGElement as SvgElement

  User->>EditorUI: Change attribute (e.g. stroke-width, x, width)
  EditorUI->>SvgCanvas: changeSelectedAttributeNoUndoMethod(attr, newValue, elems)
  SvgCanvas->>SvgElement: Update attribute
  SvgCanvas->>SvgElement: getRotationAngle(elem)
  alt angle is zero or attr is not bbox-affecting
    SvgCanvas-->>EditorUI: No rotation recalc
  else angle nonzero and attr bbox-affecting
    SvgCanvas->>SvgElement: getTransformList(elem)
    SvgCanvas->>SvgElement: Remove existing rotate transform
    SvgCanvas->>SvgElement: utilsGetBBox(elem)
    SvgCanvas->>SvgElement: transformListToTransform(tlist, n, end)
    SvgCanvas->>SvgElement: transformPoint(bboxCenter, centerMatrix)
    SvgCanvas->>SvgElement: createSVGTransform().setRotate(angle, center.x, center.y)
    SvgCanvas->>SvgElement: insertItemBefore(newRotate, n)
  end

  User->>EditorUI: Perform undo or redo
  EditorUI->>ChangeElementCommand: apply() or unapply()
  ChangeElementCommand->>SvgElement: Apply stored attribute values
  ChangeElementCommand->>relocateRotationCenter: relocateRotationCenter(elem, changedAttrNames)
  relocateRotationCenter->>SvgElement: getRotationAngle(elem)
  alt no bbox-affecting attrs or angle is zero
    relocateRotationCenter-->>ChangeElementCommand: Return without changes
  else
    relocateRotationCenter->>SvgElement: getTransformList(elem)
    relocateRotationCenter->>SvgElement: Remove rotate transform at index n
    relocateRotationCenter->>SvgElement: getBBox(elem)
    relocateRotationCenter->>SvgElement: transformListToTransform(tlist, n, end)
    relocateRotationCenter->>SvgElement: transformPoint(bboxCenter, centerMatrix)
    relocateRotationCenter->>SvgElement: createSVGTransform().setRotate(angle, center.x, center.y)
    relocateRotationCenter->>SvgElement: insertItemBefore(newRotate, n)
  end
Loading

Class diagram for ChangeElementCommand and rotation recalc helper

classDiagram
  class ChangeElementCommand {
    - elem : Element
    - oldValues : Object
    - newValues : Object
    + constructor(elem, attrs, values)
    + apply()
    + unapply()
  }

  class relocateRotationCenterHelper {
    + BBOX_AFFECTING_ATTRS : Set~string~
    + relocateRotationCenter(elem, changedAttrs) void
  }

  class SvgUtilities {
    + getRotationAngle(elem) number
    + getBBox(elem) DOMRect
  }

  class SvgMath {
    + getTransformList(elem) SVGTransformList
    + transformListToTransform(tlist, fromIndex, toIndex) SVGTransform
    + transformPoint(x, y, matrix) SVGPoint
  }

  ChangeElementCommand --> relocateRotationCenterHelper : uses
  relocateRotationCenterHelper --> SvgUtilities : uses
  relocateRotationCenterHelper --> SvgMath : uses
Loading

File-Level Changes

Change Details Files
Add shared rotation-center relocation helper that updates only the rotate transform while preserving the rest of the transform list and using only post-rotation transforms to compute the new center.
  • Introduce BBOX_AFFECTING_ATTRS set and relocateRotationCenter(elem, changedAttrs) in history.js using math.js helpers (getTransformList, transformListToTransform, transformPoint, getBBox).
  • In ChangeElementCommand.apply and .unapply, replace manual bbox/rotate recomputation and full transform overwrite with relocateRotationCenter using newValues/oldValues attribute keys.
  • Ensure rotation center calculation transforms the bbox center only through transforms that follow the removed rotation in the list, falling back to identity if none remain.
packages/svgcanvas/core/history.js
Restrict rotation-center recomputation on live attribute changes to bbox-affecting attributes and fix center calculation to ignore pre-rotation transforms.
  • Define local BBOX_AFFECTING_ATTRS in changeSelectedAttributeNoUndoMethod and gate rotation-center updates on angle != 0, attr !== 'transform', and attr being bbox-affecting.
  • After removing the existing rotate transform, compute the bbox via utilsGetBBox and build a center transform matrix only from transforms that come after the rotation, defaulting to identity when none exist.
  • Use transformPoint with the new center matrix to derive the updated rotation center and reinsert a rotate(angle, cx, cy) transform at the original index.
packages/svgcanvas/core/undo.js
Add visual documentation and tooling to demonstrate and capture the rotation-recalculation bug and its fix.
  • Create rotation-recalc-demo.html visual page explaining the two rotation bugs (history.js full-transform replacement and undo.js pre-rotation leakage) with annotated SVG examples.
  • Add a Playwright-based screenshot.mjs script to capture a PNG screenshot of the rotation-recalc demo HTML for documentation/CI use.
  • Introduce additional visual and unit test artifacts (compound-transform-bug.svg and rotation-recalc.test.js stub) as anchors for future automated tests of this behavior.
tests/visual/rotation-recalc-demo.html
tests/visual/screenshot.mjs
tests/visual/compound-transform-bug.svg
tests/unit/rotation-recalc.test.js

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • The BBOX_AFFECTING_ATTRS set is now defined twice (in history.js and inside changeSelectedAttributeNoUndoMethod); consider centralizing this in a shared module/constant so the attribute list stays consistent and isn’t duplicated.
  • In relocateRotationCenter, tlist.removeItem(n) is called before getBBox(elem) and an early return on a falsy bbox would leave the rotation permanently removed; it would be safer to compute/validate the bbox before mutating the transform list or otherwise ensure the rotation is restored on failure paths.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `BBOX_AFFECTING_ATTRS` set is now defined twice (in `history.js` and inside `changeSelectedAttributeNoUndoMethod`); consider centralizing this in a shared module/constant so the attribute list stays consistent and isn’t duplicated.
- In `relocateRotationCenter`, `tlist.removeItem(n)` is called before `getBBox(elem)` and an early `return` on a falsy bbox would leave the rotation permanently removed; it would be safer to compute/validate the bbox before mutating the transform list or otherwise ensure the rotation is restored on failure paths.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@jfhenon
Copy link
Copy Markdown
Collaborator

jfhenon commented Mar 15, 2026

plz fix the linter errors so I can merge your (very useful) PR

geoffyoungs and others added 2 commits March 15, 2026 19:41
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address two code review comments:

1. Export BBOX_AFFECTING_ATTRS from history.js and import it in undo.js
   so the attribute list is defined in one place.

2. Compute getBBox() before calling tlist.removeItem() so that a falsy
   bbox causes an early return/break without having already destroyed
   the rotation transform.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@geoffyoungs
Copy link
Copy Markdown
Contributor Author

Apologies - I should have checked that before submitting. SVG-Edit is an excellent and mature tool - I was both surprised and excited to find a legitimate bug!

@jfhenon jfhenon merged commit 173dd26 into SVG-Edit:master Mar 15, 2026
8 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.

2 participants