diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dab280a5..77d3317bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ### Fixed +- Keep a layer where it is drawn when it moves into or out of a rotated or flipped parent, instead of shifting it and leaving it at its old angle (#737). - Size auto-width text from `.pen` files to its content in CLI exports, instead of a 10000px placeholder that stretched hugging frames in HTML and Storybook output, and keep narrow widths a `.pen` file sets explicitly instead of widening multi-character text. - Keep grid layouts, rotation, inner shadows, every shadow of a layer, layer and background blur, flex grow, right-to-left direction, and sections in HTML export, which previously turned grids into columns and dropped the rest. - Show what to update instead of a blank window when the browser or system WebView is too old, naming the detected macOS, Safari, Chrome, Edge, Firefox, WebKitGTK, or WebView2 version and linking a prefilled bug report, and explain a failed start the same way (#744). diff --git a/packages/scene-graph/src/coordinate.ts b/packages/scene-graph/src/coordinate.ts index c52611d61..c76d1c252 100644 --- a/packages/scene-graph/src/coordinate.ts +++ b/packages/scene-graph/src/coordinate.ts @@ -224,3 +224,31 @@ export function getWorldHandles( w: { x: pts[14], y: pts[15] } } } + +type LocalTransform = Pick + +/** + * Local transform that draws `node` with the given world matrix once it sits under a parent + * whose world matrix is `parentWorld`. Keeps the node's own `flipX` when the matrix allows it. + */ +export function localTransformFromWorld( + node: SceneNode, + world: Mat3, + parentWorld: Mat3 +): LocalTransform | null { + const parentInverse = Matrix.invert(parentWorld) + if (!parentInverse) return null + const local = Matrix.multiply(parentInverse, world) + const [a, b, , c, d] = local + const sx = node.flipX ? -1 : 1 + const sy = a * d - b * c < 0 ? -sx : sx + let rotation = (Math.atan2(sy * c, sx * a) * 180) / Math.PI + if (Math.abs(rotation) < 1e-9) rotation = 0 + const transform = { rotation, flipX: sx < 0, flipY: sy < 0 } + const origin = getNodeLocalMatrix({ ...node, ...transform }, { x: 0, y: 0 }) + return { ...transform, x: local[2] - origin[2], y: local[5] - origin[5] } +} + +export function isTranslationOnly(matrix: Mat3): boolean { + return matrix[0] === 1 && matrix[1] === 0 && matrix[3] === 0 && matrix[4] === 1 +} diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 6aee0c87f..91edde7a2 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -41,6 +41,7 @@ import { cloneNodeProps } from './copy' import { bindNodeEvents } from './events' import * as HitTest from './hit-test' import * as Instances from './instances' +import Matrix, { type Mat3 } from './matrix' import { CONTAINER_TYPES, createDefaultNode } from './node-defaults' import { updateNodePreview, type NodePreviewObserver } from './preview' import { styleDetachmentChanges } from './shared-styles' @@ -54,7 +55,13 @@ export * from './types' import type { Emitter } from 'nanoevents' -import { getAbsolutePosition } from './coordinate' +import { + getAbsolutePosition, + getNodeLocalMatrix, + getWorldMatrix, + isTranslationOnly, + localTransformFromWorld +} from './coordinate' import type { Color, Rect, Vector } from './primitives' import type { DocumentColorSpace, @@ -507,12 +514,8 @@ export class SceneGraph { const oldParentId = node.parentId this.absPosCache.clear() - const absPos = this.getAbsolutePosition(nodeId) - const newParentNode = this.nodes.get(newParentId) - const newParentAbs = - newParentId === this.rootId || newParentNode?.type === 'CANVAS' - ? { x: 0, y: 0 } - : this.getAbsolutePosition(newParentId) + const oldParentWorld = this.parentWorldMatrix(oldParent) + const newParentWorld = this.parentWorldMatrix(newParent) if (oldParent) { oldParent.childIds = oldParent.childIds.filter((cid) => cid !== nodeId) @@ -521,12 +524,23 @@ export class SceneGraph { node.parentId = newParentId newParent.childIds.push(nodeId) - node.x = absPos.x - newParentAbs.x - node.y = absPos.y - newParentAbs.y + if (isTranslationOnly(oldParentWorld) && isTranslationOnly(newParentWorld)) { + node.x += oldParentWorld[2] - newParentWorld[2] + node.y += oldParentWorld[5] - newParentWorld[5] + } else { + const world = Matrix.multiply(oldParentWorld, getNodeLocalMatrix(node)) + const local = localTransformFromWorld(node, world, newParentWorld) + if (local) Object.assign(node, local) + } this.emitter.emit('node:reparented', nodeId, oldParentId, newParentId) } + private parentWorldMatrix(parent: SceneNode | undefined): Mat3 { + if (!parent || parent.id === this.rootId) return Matrix.identity() + return getWorldMatrix(parent, this) + } + reorderChild(nodeId: string, parentId: string, insertIndex: number): void { const node = this.nodes.get(nodeId) if (!node) return diff --git a/tests/engine/scene-graph/reparent-transform.test.ts b/tests/engine/scene-graph/reparent-transform.test.ts new file mode 100644 index 000000000..e4c59ff32 --- /dev/null +++ b/tests/engine/scene-graph/reparent-transform.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test' + +import { getWorldMatrix, SceneGraph, TransformMatrix } from '@open-pencil/scene-graph' + +function setup() { + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + return { graph, pageId } +} + +function worldCorners(graph: SceneGraph, id: string) { + const node = graph.getNode(id) + if (!node) throw new Error(`missing node ${id}`) + const w = node.width + const h = node.height + return TransformMatrix.mapPoints(getWorldMatrix(node, graph), [0, 0, w, 0, w, h, 0, h]) +} + +function expectSameCorners(actual: number[], expected: number[]) { + expect(actual).toHaveLength(expected.length) + actual.forEach((value, index) => expect(value).toBeCloseTo(expected[index], 6)) +} + +describe('reparentNode keeps the node where it is drawn', () => { + test('moving into a rotated frame keeps the corners and counter-rotates the node', () => { + const { graph, pageId } = setup() + const frame = graph.createNode('FRAME', pageId, { + x: 100, + y: 50, + width: 240, + height: 240, + rotation: 30 + }) + const rect = graph.createNode('RECTANGLE', pageId, { x: 500, y: 400, width: 40, height: 20 }) + const before = worldCorners(graph, rect.id) + + graph.reparentNode(rect.id, frame.id) + + expectSameCorners(worldCorners(graph, rect.id), before) + expect(graph.getNode(rect.id)?.rotation).toBeCloseTo(-30, 6) + }) + + test('moving into a rotated frame and back out restores the original transform', () => { + const { graph, pageId } = setup() + const frame = graph.createNode('FRAME', pageId, { + x: 100, + y: 50, + width: 240, + height: 240, + rotation: 30 + }) + const rect = graph.createNode('RECTANGLE', pageId, { x: 500, y: 400, width: 40, height: 20 }) + + graph.reparentNode(rect.id, frame.id) + graph.reparentNode(rect.id, pageId) + + const node = graph.getNode(rect.id) + expect(node?.x).toBeCloseTo(500, 6) + expect(node?.y).toBeCloseTo(400, 6) + expect(node?.rotation).toBeCloseTo(0, 6) + }) + + test('moving into a flipped frame keeps the corners', () => { + const { graph, pageId } = setup() + const frame = graph.createNode('FRAME', pageId, { + x: 100, + y: 100, + width: 300, + height: 200, + flipX: true + }) + const rect = graph.createNode('RECTANGLE', pageId, { + x: 150, + y: 120, + width: 40, + height: 20, + rotation: 15 + }) + const before = worldCorners(graph, rect.id) + + graph.reparentNode(rect.id, frame.id) + + expectSameCorners(worldCorners(graph, rect.id), before) + const node = graph.getNode(rect.id) + expect(node?.flipX).not.toBe(node?.flipY) + }) + + test('a rotated node moved between unrotated frames keeps its corners and rotation', () => { + const { graph, pageId } = setup() + const left = graph.createNode('FRAME', pageId, { x: 0, y: 0, width: 200, height: 200 }) + const right = graph.createNode('FRAME', pageId, { x: 300, y: 40, width: 200, height: 200 }) + const rect = graph.createNode('RECTANGLE', left.id, { + x: 20, + y: 30, + width: 60, + height: 20, + rotation: 45 + }) + const before = worldCorners(graph, rect.id) + + graph.reparentNode(rect.id, right.id) + + expectSameCorners(worldCorners(graph, rect.id), before) + const node = graph.getNode(rect.id) + expect(node?.x).toBe(-280) + expect(node?.y).toBe(-10) + expect(node?.rotation).toBe(45) + }) +})