openpencil/tests/engine/app/vector-edit-transforms.test.ts
Danila Poyarkov cb2c4aa3d3 fix(vector): edit vectors from opened files
- Resolve the active graph at action time and map edit geometry through full world transforms
- Regenerate live path fills and discard stale imported stroke outlines after edits
- Preserve fill and stroke geometry through resize previews, undo, and redo
- Cover graph replacement, nested transforms, edit history, resizing, and CanvasKit output

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
2026-07-28 12:30:21 +03:00

321 lines
11 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { createEditor, type Editor } from '@open-pencil/core/editor'
import { regenerateFillGeometry } from '@open-pencil/core/vector'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode, VectorNetwork } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import { createVectorEditHistoryActions } from '@/app/editor/vector-edit/history'
import { createVectorEditLifecycle } from '@/app/editor/vector-edit/lifecycle'
import type { VectorEditState } from '@/app/editor/vector-edit/types'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
const NETWORK: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 50 },
{ x: 0, y: 50 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 20, y: 10 }, tangentEnd: { x: -20, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
}
function setup(frameRotation: number, vectorRotation: number) {
const graph = new SceneGraph()
const pageId = expectDefined(graph.getPages()[0]).id
const frame = graph.createNode('FRAME', pageId, {
x: 300,
y: 200,
width: 400,
height: 300,
rotation: frameRotation
})
const vector = graph.createNode('VECTOR', frame.id, {
x: 40,
y: 60,
width: 100,
height: 50,
rotation: vectorRotation,
vectorNetwork: {
vertices: NETWORK.vertices.map((v) => ({ ...v })),
segments: NETWORK.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
}
})
const undoLabels: string[] = []
const editor = {
get graph() {
return graph
},
select: () => undefined,
requestRender: () => undefined,
updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
undoLabels.push(label)
graph.updateNode(id, changes)
}
} as Editor
const state: VectorEditState = { nodeEditState: null } as VectorEditState
const lifecycle = createVectorEditLifecycle(editor, state)
return { graph, vector, state, lifecycle, undoLabels }
}
/** World-space positions of a node's network vertices via its world matrix. */
function worldVertices(graph: SceneGraph, nodeId: string) {
const node = getNodeOrThrow(graph, nodeId)
const network = expectDefined(node.vectorNetwork, 'vectorNetwork')
const world = getWorldMatrix(node, graph)
return network.vertices.map((v) => Matrix.mapPoint(world, v))
}
describe('vector edit graph ownership', () => {
test('enters edit mode after the editor graph is replaced', () => {
const initial = new SceneGraph()
const replacement = new SceneGraph()
const vector = replacement.createNode('VECTOR', expectDefined(replacement.getPages()[0]).id, {
width: 100,
height: 50,
vectorNetwork: NETWORK
})
const editor = createEditor({ graph: initial })
const state = { nodeEditState: null } as VectorEditState
const lifecycle = createVectorEditLifecycle(editor, state)
editor.replaceGraph(replacement)
lifecycle.enterNodeEditMode(vector.id)
expect(state.nodeEditState?.nodeId).toBe(vector.id)
})
test('commits network and fill geometry as one document undo step', () => {
const editor = createEditor()
const page = expectDefined(editor.graph.getPages()[0])
const fillGeometry = regenerateFillGeometry(NETWORK, [
{
windingRule: 'NONZERO',
commandsBlob: new Uint8Array(),
fills: [
{
type: 'SOLID',
color: { r: 1, g: 0.4, b: 0, a: 1 },
visible: true,
opacity: 1,
blendMode: 'NORMAL'
}
]
}
])
const vector = editor.graph.createNode('VECTOR', page.id, {
width: 100,
height: 50,
vectorNetwork: NETWORK,
fillGeometry
})
const state = { nodeEditState: null } as VectorEditState
const lifecycle = createVectorEditLifecycle(editor, state)
const originalBlob = expectDefined(vector.fillGeometry[0]).commandsBlob.slice()
lifecycle.enterNodeEditMode(vector.id)
const editState = expectDefined(state.nodeEditState)
expectDefined(editState.vertices[1]).x += 20
lifecycle.exitNodeEditMode(true)
const edited = getNodeOrThrow(editor.graph, vector.id)
const editedBlob = expectDefined(edited.fillGeometry[0]).commandsBlob.slice()
expect(editedBlob).not.toEqual(originalBlob)
expect(editor.undo.canUndo).toBe(true)
editor.undo.undo()
expect(
expectDefined(getNodeOrThrow(editor.graph, vector.id).fillGeometry[0]).commandsBlob
).toEqual(originalBlob)
expect(editor.undo.canUndo).toBe(false)
editor.undo.redo()
expect(
expectDefined(getNodeOrThrow(editor.graph, vector.id).fillGeometry[0]).commandsBlob
).toEqual(editedBlob)
})
})
describe('vector edit with rotated ancestors', () => {
test('enter maps vertices through the full world matrix', () => {
const { graph, vector, state, lifecycle } = setup(30, 20)
lifecycle.enterNodeEditMode(vector.id)
const expected = worldVertices(graph, vector.id)
const es = expectDefined(state.nodeEditState, 'nodeEditState')
for (let i = 0; i < expected.length; i++) {
const point = expectDefined(expected[i])
const vertex = expectDefined(es.vertices[i])
expect(vertex.x).toBeCloseTo(point.x, 6)
expect(vertex.y).toBeCloseTo(point.y, 6)
}
})
test('enter + commit without edits is a no-op', () => {
const { graph, vector, lifecycle, undoLabels } = setup(30, 20)
const before = worldVertices(graph, vector.id)
lifecycle.enterNodeEditMode(vector.id)
lifecycle.exitNodeEditMode(true)
const node = getNodeOrThrow(graph, vector.id)
expect(node.x).toBe(40)
expect(node.y).toBe(60)
expect(node.width).toBe(100)
expect(node.height).toBe(50)
const after = worldVertices(graph, vector.id)
for (let i = 0; i < before.length; i++) {
const b = expectDefined(before[i])
const a = expectDefined(after[i])
expect(a.x).toBeCloseTo(b.x, 6)
expect(a.y).toBeCloseTo(b.y, 6)
}
// no geometry change → no document undo entry
expect(undoLabels).toEqual([])
})
test('dragging a vertex commits without moving the rest of the shape', () => {
const { graph, vector, state, lifecycle, undoLabels } = setup(30, 20)
const before = worldVertices(graph, vector.id)
lifecycle.enterNodeEditMode(vector.id)
const es = expectDefined(state.nodeEditState, 'nodeEditState')
const dragged = expectDefined(es.vertices[1])
dragged.x += 25
dragged.y -= 15
const movedWorld = { x: dragged.x, y: dragged.y }
lifecycle.exitNodeEditMode(true)
const after = worldVertices(graph, vector.id)
expect(expectDefined(after[1]).x).toBeCloseTo(movedWorld.x, 5)
expect(expectDefined(after[1]).y).toBeCloseTo(movedWorld.y, 5)
for (const i of [0, 2, 3]) {
const b = expectDefined(before[i])
const a = expectDefined(after[i])
expect(a.x).toBeCloseTo(b.x, 5)
expect(a.y).toBeCloseTo(b.y, 5)
}
// rotation is preserved, geometry is re-normalized to the new bounds
const node = getNodeOrThrow(graph, vector.id)
const network = expectDefined(node.vectorNetwork, 'vectorNetwork')
expect(node.rotation).toBe(20)
expect(Math.min(...network.vertices.map((v) => v.x))).toBeCloseTo(0, 5)
expect(Math.min(...network.vertices.map((v) => v.y))).toBeCloseTo(0, 5)
// committing goes through the undo-aware update so Cmd+Z works
expect(undoLabels).toEqual(['Edit vector'])
})
test('tangents rotate with the node on enter', () => {
const { state, lifecycle, vector } = setup(0, 90)
lifecycle.enterNodeEditMode(vector.id)
const es = expectDefined(state.nodeEditState, 'nodeEditState')
const t = expectDefined(es.segments[0]).tangentStart
// local tangent (20, 10) rotated 90° → (-10, 20)
expect(t.x).toBeCloseTo(-10, 6)
expect(t.y).toBeCloseTo(20, 6)
})
})
describe('vector edit session undo/redo', () => {
function editSession() {
const { graph, vector, state, lifecycle } = setup(0, 0)
const editor = {
get graph() {
return graph
},
requestRender: () => undefined
} as Editor
const history = createVectorEditHistoryActions(editor, state)
lifecycle.enterNodeEditMode(vector.id)
return { state, history, es: expectDefined(state.nodeEditState, 'nodeEditState') }
}
test('undo restores geometry from before the mutation', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditPushHistory() // drag start
es.vertices[0] = { ...orig, x: orig.x + 50, y: orig.y - 20 }
history.nodeEditUndo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
expect(expectDefined(es.vertices[0]).y).toBeCloseTo(orig.y, 6)
})
test('redo reapplies the undone mutation', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditPushHistory()
es.vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditUndo()
history.nodeEditRedo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x + 50, 6)
})
test('no-op drag snapshots are skipped', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditPushHistory()
es.vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditPushHistory() // click without move → identical snapshot
history.nodeEditPushHistory()
history.nodeEditUndo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
})
test('undo with no history is a no-op', () => {
const { es, history } = editSession()
const orig = { ...expectDefined(es.vertices[0]) }
history.nodeEditUndo()
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
})
test('clicking without dragging after undo keeps redo', () => {
const { state, history } = editSession()
const es = () => expectDefined(state.nodeEditState, 'nodeEditState')
const orig = { ...expectDefined(es().vertices[0]) }
history.nodeEditPushHistory()
es().vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditUndo()
history.nodeEditPushHistory() // click a vertex without moving it
history.nodeEditRedo()
expect(expectDefined(es().vertices[0]).x).toBeCloseTo(orig.x + 50, 6)
})
test('a real change after undo clears redo', () => {
const { state, history } = editSession()
const es = () => expectDefined(state.nodeEditState, 'nodeEditState')
const orig = { ...expectDefined(es().vertices[0]) }
history.nodeEditPushHistory()
es().vertices[0] = { ...orig, x: orig.x + 50 }
history.nodeEditUndo()
history.nodeEditPushHistory()
es().vertices[0] = { ...orig, x: orig.x - 30 } // new timeline
history.nodeEditRedo() // stale redo must not restore +50
expect(expectDefined(es().vertices[0]).x).toBeCloseTo(orig.x - 30, 6)
})
})