From cb2c4aa3d3b025a6826d3c85afcaa6990cfaae0a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 28 Jul 2026 12:30:21 +0300 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + packages/core/src/canvas/node-edit-overlay.ts | 91 +++-- packages/core/src/editor/undo.ts | 22 +- packages/core/src/index.ts | 3 +- packages/core/src/vector/fill-geometry.ts | 90 +++++ packages/core/src/vector/index.ts | 42 +-- packages/core/src/vector/path-helpers.ts | 51 ++- .../fig/src/instance-overrides/constraints.ts | 3 +- .../derived-symbol-data/geometry.ts | 2 +- .../src/instance-overrides/geometry-paths.ts | 37 -- packages/fig/tests/vector-style-fills.test.ts | 2 +- packages/scene-graph/src/copy.ts | 32 ++ packages/scene-graph/src/index.ts | 8 +- packages/scene-graph/src/resize.ts | 26 +- packages/scene-graph/src/vector-network.ts | 40 +++ packages/vue/src/editor/commands/edit.ts | 24 +- .../src/editor/selection-capabilities/use.ts | 6 +- .../vue/src/shared/input/node-edit/index.ts | 4 + packages/vue/src/shared/input/resize.ts | 26 +- packages/vue/src/shared/input/resize/start.ts | 3 + packages/vue/src/shared/input/types.ts | 4 +- src/app/editor/pen/create.ts | 36 +- src/app/editor/pen/resume.ts | 16 - src/app/editor/session/create.ts | 2 +- src/app/editor/session/modules.ts | 6 +- src/app/editor/vector-edit/create.ts | 15 +- src/app/editor/vector-edit/handle-actions.ts | 2 + src/app/editor/vector-edit/history.ts | 72 ++++ src/app/editor/vector-edit/lifecycle.ts | 104 +++--- src/app/editor/vector-edit/network.ts | 4 + src/app/editor/vector-edit/selection.ts | 5 + src/app/editor/vector-edit/types.ts | 7 + tests/e2e/canvas/vector/editing.spec.ts | 102 ++++++ ...or-edit-mode-overlay-openpencil-darwin.png | Bin 0 -> 45028 bytes .../style-fills.spec.ts} | 0 ...tor-path-style-fills-openpencil-darwin.png | Bin .../winding.spec.ts} | 0 ...ven-odd-vector-holes-openpencil-darwin.png | Bin .../engine/app/vector-edit-transforms.test.ts | 320 ++++++++++++++++++ tests/engine/editor/undo/group-resize.test.ts | 4 +- .../engine/vector/fill-geometry-regen.test.ts | 115 +++++++ tests/engine/vue/controls/constraints.test.ts | 12 +- .../engine/vue/input/resize-geometry.test.ts | 139 ++++++++ 43 files changed, 1245 insertions(+), 233 deletions(-) create mode 100644 packages/core/src/vector/fill-geometry.ts delete mode 100644 packages/fig/src/instance-overrides/geometry-paths.ts create mode 100644 src/app/editor/vector-edit/history.ts create mode 100644 tests/e2e/canvas/vector/editing.spec.ts create mode 100644 tests/e2e/canvas/vector/editing.spec.ts-snapshots/vector-edit-mode-overlay-openpencil-darwin.png rename tests/e2e/canvas/{vector-style-fills-visual.spec.ts => vector/style-fills.spec.ts} (100%) rename tests/e2e/canvas/{vector-style-fills-visual.spec.ts-snapshots => vector/style-fills.spec.ts-snapshots}/vector-path-style-fills-openpencil-darwin.png (100%) rename tests/e2e/canvas/{vector-winding-visual.spec.ts => vector/winding.spec.ts} (100%) rename tests/e2e/canvas/{vector-winding-visual.spec.ts-snapshots => vector/winding.spec.ts-snapshots}/even-odd-vector-holes-openpencil-darwin.png (100%) create mode 100644 tests/engine/app/vector-edit-transforms.test.ts create mode 100644 tests/engine/vector/fill-geometry-regen.test.ts create mode 100644 tests/engine/vue/input/resize-geometry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb9eaf5e..1b61a6155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Match regional browser languages to supported locales without selecting a secondary language. (#417) - Save auto-layout frames that stretch their children to `.fig` without failing. (#427) - Preserve multiple colors in imported vector artwork such as multi-color logos. (#386) +- Edit vectors in opened documents at the correct position, with live fills and undo/redo. (#390) - Reduce large `.fig` page-switch work to the active page, reuse fixed-point propagation indexes, and coalesce Layers tree rebuilds. (#420) - Center text glyphs within explicit line-height leading in CanvasKit paragraph rendering. diff --git a/packages/core/src/canvas/node-edit-overlay.ts b/packages/core/src/canvas/node-edit-overlay.ts index c03c63d00..740c70efd 100644 --- a/packages/core/src/canvas/node-edit-overlay.ts +++ b/packages/core/src/canvas/node-edit-overlay.ts @@ -1,16 +1,18 @@ import type { Canvas, Paint } from 'canvaskit-wasm' +import { transformVectorNetwork } from '@open-pencil/scene-graph' import type { VectorVertex, VectorSegment, VectorRegion, SceneGraph } from '@open-pencil/scene-graph' +import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import Matrix from '@open-pencil/scene-graph/matrix' import type { Vector } from '@open-pencil/scene-graph/primitives' import { PEN_HANDLE_RADIUS, PEN_VERTEX_RADIUS } from '#core/constants' -import { vectorNetworkToPath } from '#core/vector' -import { computeAccurateBounds } from '#core/vector/bezier' +import { regenerateFillGeometry, vectorNetworkToPath } from '#core/vector' import type { SkiaRenderer, RenderOverlays } from './renderer' @@ -124,60 +126,49 @@ function drawLiveShape( const node = graph.getNode(nodeId) if (!node) return - // Compute bounds from live (absolute) vertices - const liveNetwork = { vertices, segments, regions } - const bounds = computeAccurateBounds(liveNetwork) + // The edit state is page-absolute; map it back into the node's local frame + // so static fillGeometry (multi-color vectors) stays aligned, and render + // through the node's full world matrix (nesting, rotation, flips). + const world = getWorldMatrix(node, graph) + const inverse = Matrix.invert(world) + if (!inverse) return + const localNetwork = transformVectorNetwork(inverse, { vertices, segments, regions }) - // Build a normalized VectorNetwork (relative to bounds origin) - const normalizedNetwork = { - vertices: vertices.map((v) => ({ - ...v, - x: v.x - bounds.x, - y: v.y - bounds.y - })), - segments, - regions + const invalidatePathCaches = () => { + r.vectorPathCache.delete(nodeId) + r.fillGeometryCache.delete(nodeId) + r.strokeGeometryCache.delete(nodeId) } - // Temporarily patch the node so renderShapeUncached uses our live network + // Temporarily patch the node so renderShapeUncached uses our live network; + // fills draw from fillGeometry blobs, so rebuild those from the live network. + // Clear strokeGeometry so strokes use the live vector network instead of + // stale imported stroke blobs. const origNetwork = node.vectorNetwork - const origX = node.x - const origY = node.y - const origW = node.width - const origH = node.height + const origFillGeometry = node.fillGeometry + const origStrokeGeometry = node.strokeGeometry - node.vectorNetwork = normalizedNetwork - node.x = bounds.x - node.y = bounds.y - node.width = bounds.width - node.height = bounds.height - - // Invalidate cached paths so they're rebuilt from our live network - r.vectorPathCache.delete(nodeId) - r.fillGeometryCache.delete(nodeId) - r.strokeGeometryCache.delete(nodeId) - - // The overlay canvas is in screen space after panX/panY + zoom scaling. - // renderShapeUncached expects a canvas translated to the node's local origin. + // The overlay canvas is in screen space; renderShapeUncached expects the + // node's local frame, so apply viewport then the node's world matrix. canvas.save() - canvas.translate(bounds.x * r.zoom + r.panX, bounds.y * r.zoom + r.panY) - canvas.scale(r.zoom, r.zoom) - - r.renderShapeUncached(canvas, node, graph) - - canvas.restore() - - // Restore the original node properties - node.vectorNetwork = origNetwork - node.x = origX - node.y = origY - node.width = origW - node.height = origH - - // Invalidate caches again so the original renders correctly after exit - r.vectorPathCache.delete(nodeId) - r.fillGeometryCache.delete(nodeId) - r.strokeGeometryCache.delete(nodeId) + try { + node.vectorNetwork = localNetwork + node.fillGeometry = regenerateFillGeometry(localNetwork, origFillGeometry) + node.strokeGeometry = [] + invalidatePathCaches() + canvas.translate(r.panX, r.panY) + canvas.scale(r.zoom, r.zoom) + canvas.concat(world) + r.renderShapeUncached(canvas, node, graph) + } finally { + canvas.restore() + // Restore the original node properties and invalidate again so the + // original renders correctly after exit — even if regeneration or rendering throws. + node.vectorNetwork = origNetwork + node.fillGeometry = origFillGeometry + node.strokeGeometry = origStrokeGeometry + invalidatePathCaches() + } } // --------------------------------------------------------------------------- diff --git a/packages/core/src/editor/undo.ts b/packages/core/src/editor/undo.ts index 1f080214f..3729464e8 100644 --- a/packages/core/src/editor/undo.ts +++ b/packages/core/src/editor/undo.ts @@ -1,6 +1,7 @@ import { pick } from 'es-toolkit/object' import { cloneVectorNetwork, type SceneNode } from '@open-pencil/scene-graph' +import { copyGeometryPaths } from '@open-pencil/scene-graph/copy' import type { Rect, Vector } from '@open-pencil/scene-graph/primitives' import type { UndoEntry } from '@open-pencil/scene-graph/undo' @@ -14,8 +15,12 @@ import { import { textAutoResizeChanges } from './text/auto-resize' import type { EditorContext } from './types' -type ResizeSnapshot = Pick -type ResizeOriginal = Rect & { vectorNetwork?: SceneNode['vectorNetwork'] } +type ResizeSnapshot = Pick< + SceneNode, + 'x' | 'y' | 'width' | 'height' | 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry' +> +type ResizeOriginal = Rect & + Partial> function createResizeSnapshot(node: SceneNode): ResizeSnapshot { return { @@ -23,7 +28,9 @@ function createResizeSnapshot(node: SceneNode): ResizeSnapshot { y: node.y, width: node.width, height: node.height, - vectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null + vectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null, + fillGeometry: copyGeometryPaths(node.fillGeometry), + strokeGeometry: copyGeometryPaths(node.strokeGeometry) } } @@ -94,10 +101,11 @@ export function createUndoActions(ctx: EditorContext) { function commitResize(nodeId: string, original: ResizeOriginal) { const node = ctx.graph.getNode(nodeId) if (!node) return - const final: ResizeOriginal = - 'vectorNetwork' in original - ? createResizeSnapshot(node) - : { x: node.x, y: node.y, width: node.width, height: node.height } + const includesGeometry = + 'vectorNetwork' in original || 'fillGeometry' in original || 'strokeGeometry' in original + const final: ResizeOriginal = includesGeometry + ? createResizeSnapshot(node) + : { x: node.x, y: node.y, width: node.width, height: node.height } ctx.undo.push({ label: 'Resize', forward: () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5257b92d3..45ffcbd70 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -219,7 +219,8 @@ export { geometryBlobToPath, decodeVectorNetworkBlob, encodeVectorNetworkBlob, - buildStyleOverrideTable + buildStyleOverrideTable, + regenerateFillGeometry } from './vector' export { evalCubic, diff --git a/packages/core/src/vector/fill-geometry.ts b/packages/core/src/vector/fill-geometry.ts new file mode 100644 index 000000000..9ff6a82ab --- /dev/null +++ b/packages/core/src/vector/fill-geometry.ts @@ -0,0 +1,90 @@ +import type { GeometryPath, VectorNetwork } from '@open-pencil/scene-graph' + +import { addLoopToPath, addOpenSegmentsToPath } from './path-helpers' +import type { PathSink } from './path-helpers' + +const CMD_CLOSE = 0 +const CMD_MOVE_TO = 1 +const CMD_LINE_TO = 2 +const CMD_CUBIC_TO = 4 + +/** PathSink that encodes commands into the .fig geometry commandsBlob format. */ +class GeometryBlobBuilder implements PathSink { + private cmds: { code: number; args: number[] }[] = [] + + moveTo(x: number, y: number) { + this.cmds.push({ code: CMD_MOVE_TO, args: [x, y] }) + } + lineTo(x: number, y: number) { + this.cmds.push({ code: CMD_LINE_TO, args: [x, y] }) + } + cubicTo(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number) { + this.cmds.push({ code: CMD_CUBIC_TO, args: [x1, y1, x2, y2, x3, y3] }) + } + close() { + this.cmds.push({ code: CMD_CLOSE, args: [] }) + } + + toBlob(): Uint8Array { + const size = this.cmds.reduce((n, c) => n + 1 + c.args.length * 4, 0) + const out = new Uint8Array(size) + const view = new DataView(out.buffer) + let o = 0 + for (const c of this.cmds) { + out[o++] = c.code + for (const v of c.args) { + view.setFloat32(o, v, true) + o += 4 + } + } + return out + } +} + +/** + * Rebuild fillGeometry command blobs from a (possibly edited) VectorNetwork so + * fills follow network edits. Imported .fig vectors pair fillGeometry entries + * with network regions positionally (fillGeometry[i] ↔ regions[i]); per-path + * fills are preserved. Networks without regions (open chains) map to a + * single fillGeometry entry. + */ +export function regenerateFillGeometry( + network: VectorNetwork, + existing: GeometryPath[] +): GeometryPath[] { + if (existing.length === 0) return existing + const { vertices, segments, regions } = network + + if (regions.length === existing.length) { + return existing.map((g, i) => { + const region = regions[i] + const builder = new GeometryBlobBuilder() + for (const loop of region.loops) addLoopToPath(builder, loop, segments, vertices) + return { ...g, windingRule: region.windingRule, commandsBlob: builder.toBlob() } + }) + } + + if (regions.length === 0 && existing.length === 1) { + const builder = new GeometryBlobBuilder() + addOpenSegmentsToPath(builder, segments, vertices) + return [{ ...existing[0], commandsBlob: builder.toBlob() }] + } + + // Region count no longer matches the imported fillGeometry (e.g. a region was + // deleted mid-edit) — the style mapping is unknown. Rebuild from the live + // network so committed fillGeometry stays coherent with vectorNetwork; styles + // fall back to node-level fills. + if (regions.length > 0) { + return regions.map((region) => { + const builder = new GeometryBlobBuilder() + for (const loop of region.loops) addLoopToPath(builder, loop, segments, vertices) + return { windingRule: region.windingRule, commandsBlob: builder.toBlob() } + }) + } + if (segments.length > 0) { + const builder = new GeometryBlobBuilder() + addOpenSegmentsToPath(builder, segments, vertices) + return [{ windingRule: 'NONZERO' as const, commandsBlob: builder.toBlob() }] + } + return [] +} diff --git a/packages/core/src/vector/index.ts b/packages/core/src/vector/index.ts index 0f12b3add..366440fe2 100644 --- a/packages/core/src/vector/index.ts +++ b/packages/core/src/vector/index.ts @@ -19,10 +19,11 @@ export { decodeVectorNetworkBlob, encodeVectorNetworkBlob } from '@open-pencil/fig/node-change' -import type { VectorNetwork, VectorSegment, VectorVertex } from '@open-pencil/scene-graph' +import type { VectorNetwork, WindingRule } from '@open-pencil/scene-graph' -import { addOpenSegmentsToPath, addSegmentDirected } from './path-helpers' +import { addLoopToPath, addOpenSegmentsToPath } from './path-helpers' export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline' +export { regenerateFillGeometry } from './fill-geometry' export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path[] { const { vertices, segments, regions } = network @@ -47,43 +48,6 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path return [path] } -function addLoopToPath( - path: Path, - loop: number[], - segments: VectorSegment[], - vertices: VectorVertex[] -): void { - if (loop.length === 0) return - - const firstSeg = segments[loop[0]] - - // Determine the starting vertex — if the loop has multiple segments, - // the first segment's direction is determined by which vertex connects - // to the second segment. - let current: number - if (loop.length === 1) { - current = firstSeg.start - } else { - const secondSeg = segments[loop[1]] - if (firstSeg.end === secondSeg.start || firstSeg.end === secondSeg.end) { - current = firstSeg.start - } else { - current = firstSeg.end - } - } - - path.moveTo(vertices[current].x, vertices[current].y) - - for (const segIdx of loop) { - const seg = segments[segIdx] - const forward = seg.start === current - addSegmentDirected(path, seg, vertices, forward) - current = forward ? seg.end : seg.start - } - - path.close() -} - const CMD_CLOSE = 0 const CMD_MOVE_TO = 1 const CMD_LINE_TO = 2 diff --git a/packages/core/src/vector/path-helpers.ts b/packages/core/src/vector/path-helpers.ts index c84978b89..c24e91680 100644 --- a/packages/core/src/vector/path-helpers.ts +++ b/packages/core/src/vector/path-helpers.ts @@ -1,9 +1,15 @@ -import type { Path } from 'canvaskit-wasm' - import type { VectorSegment, VectorVertex } from '@open-pencil/scene-graph' +/** Anything path commands can be emitted into: a canvaskit Path or a blob encoder. */ +export interface PathSink { + moveTo(x: number, y: number): unknown + lineTo(x: number, y: number): unknown + cubicTo(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number): unknown + close(): unknown +} + export function addSegmentDirected( - path: Path, + path: PathSink, seg: VectorSegment, vertices: VectorVertex[], forward: boolean @@ -32,8 +38,45 @@ export function findChainStart(chain: number[], segments: VectorSegment[]): numb return first.start } +export function addLoopToPath( + path: PathSink, + loop: number[], + segments: VectorSegment[], + vertices: VectorVertex[] +): void { + if (loop.length === 0) return + + const firstSeg = segments[loop[0]] + + // Determine the starting vertex — if the loop has multiple segments, + // the first segment's direction is determined by which vertex connects + // to the second segment. + let current: number + if (loop.length === 1) { + current = firstSeg.start + } else { + const secondSeg = segments[loop[1]] + if (firstSeg.end === secondSeg.start || firstSeg.end === secondSeg.end) { + current = firstSeg.start + } else { + current = firstSeg.end + } + } + + path.moveTo(vertices[current].x, vertices[current].y) + + for (const segIdx of loop) { + const seg = segments[segIdx] + const forward = seg.start === current + addSegmentDirected(path, seg, vertices, forward) + current = forward ? seg.end : seg.start + } + + path.close() +} + export function addOpenSegmentsToPath( - path: Path, + path: PathSink, segments: VectorSegment[], vertices: VectorVertex[] ): void { diff --git a/packages/fig/src/instance-overrides/constraints.ts b/packages/fig/src/instance-overrides/constraints.ts index 7fba7d8a8..b6e90aeb1 100644 --- a/packages/fig/src/instance-overrides/constraints.ts +++ b/packages/fig/src/instance-overrides/constraints.ts @@ -1,7 +1,6 @@ import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-graph' -import { copyGeometryPaths } from '@open-pencil/scene-graph/copy' +import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy' -import { scaleGeometryPaths } from './geometry-paths' import { buildClonesMap } from './sync' import type { OverrideContext } from './types' import { overrideCandidates } from './utils' diff --git a/packages/fig/src/instance-overrides/derived-symbol-data/geometry.ts b/packages/fig/src/instance-overrides/derived-symbol-data/geometry.ts index 978376ee4..21aea6392 100644 --- a/packages/fig/src/instance-overrides/derived-symbol-data/geometry.ts +++ b/packages/fig/src/instance-overrides/derived-symbol-data/geometry.ts @@ -4,8 +4,8 @@ import { resolveVectorStyleOverrideFills } from '@open-pencil/fig/node-change' import type { SceneNode } from '@open-pencil/scene-graph' +import { scaleGeometryPaths } from '@open-pencil/scene-graph/copy' -import { scaleGeometryPaths } from '../geometry-paths' import type { DerivedSymbolOverride } from '../types' export function resolveDsdGeometry( diff --git a/packages/fig/src/instance-overrides/geometry-paths.ts b/packages/fig/src/instance-overrides/geometry-paths.ts deleted file mode 100644 index a93a6dc0c..000000000 --- a/packages/fig/src/instance-overrides/geometry-paths.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { GeometryPath } from '@open-pencil/scene-graph' -import { copyGeometryPaths } from '@open-pencil/scene-graph/copy' - -export function scaleGeometryPaths( - geometry: GeometryPath[], - scaleX: number, - scaleY: number -): GeometryPath[] { - const copies = copyGeometryPaths(geometry) - if (scaleX === 1 && scaleY === 1) return copies - - for (const path of copies) { - const view = new DataView( - path.commandsBlob.buffer, - path.commandsBlob.byteOffset, - path.commandsBlob.byteLength - ) - let offset = 0 - while (offset < path.commandsBlob.length) { - const command = path.commandsBlob[offset] - offset += 1 - if (command === 0) continue - - let coordinateCount = 0 - if (command === 1 || command === 2) coordinateCount = 1 - else if (command === 3) coordinateCount = 2 - else if (command === 4) coordinateCount = 3 - if (coordinateCount === 0 || offset + coordinateCount * 8 > path.commandsBlob.length) break - for (let index = 0; index < coordinateCount; index++) { - view.setFloat32(offset, view.getFloat32(offset, true) * scaleX, true) - view.setFloat32(offset + 4, view.getFloat32(offset + 4, true) * scaleY, true) - offset += 8 - } - } - } - return copies -} diff --git a/packages/fig/tests/vector-style-fills.test.ts b/packages/fig/tests/vector-style-fills.test.ts index e93fd391c..52588f81a 100644 --- a/packages/fig/tests/vector-style-fills.test.ts +++ b/packages/fig/tests/vector-style-fills.test.ts @@ -3,9 +3,9 @@ import { describe, expect, test } from 'bun:test' import type { Paint } from '@open-pencil/kiwi/fig/codec' import type { Fill } from '@open-pencil/scene-graph' import { SceneGraph } from '@open-pencil/scene-graph' +import { scaleGeometryPaths } from '@open-pencil/scene-graph/copy' import { resolveDsdGeometry } from '../src/instance-overrides/derived-symbol-data/geometry' -import { scaleGeometryPaths } from '../src/instance-overrides/geometry-paths' import { resolveGeometryPaths, resolveStyleOverrideFills, diff --git a/packages/scene-graph/src/copy.ts b/packages/scene-graph/src/copy.ts index a4554f9c2..c031213bc 100644 --- a/packages/scene-graph/src/copy.ts +++ b/packages/scene-graph/src/copy.ts @@ -113,6 +113,38 @@ export function copyGeometryPaths(paths: GeometryPath[]): GeometryPath[] { })) } +/** Scale geometry path coordinates while preserving independent path fills. */ +export function scaleGeometryPaths(paths: GeometryPath[], scaleX: number, scaleY: number) { + const copies = copyGeometryPaths(paths) + if (scaleX === 1 && scaleY === 1) return copies + + for (const path of copies) { + const view = new DataView( + path.commandsBlob.buffer, + path.commandsBlob.byteOffset, + path.commandsBlob.byteLength + ) + let offset = 0 + while (offset < path.commandsBlob.length) { + const command = path.commandsBlob[offset] + offset += 1 + if (command === 0) continue + + let coordinateCount = 0 + if (command === 1 || command === 2) coordinateCount = 1 + else if (command === 3) coordinateCount = 2 + else if (command === 4) coordinateCount = 3 + if (coordinateCount === 0 || offset + coordinateCount * 8 > path.commandsBlob.length) break + for (let index = 0; index < coordinateCount; index++) { + view.setFloat32(offset, view.getFloat32(offset, true) * scaleX, true) + view.setFloat32(offset + 4, view.getFloat32(offset + 4, true) * scaleY, true) + offset += 8 + } + } + } + return copies +} + // --- Internal helpers --- /** Copy an optional array: non-empty → mapped, empty → [], undefined → undefined. */ diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 03241b2ac..812b7137d 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -48,7 +48,13 @@ import type { VariableValue } from './types' -export { cloneVectorNetwork, normalizeVectorNetwork, validateVectorNetwork } from './vector-network' +export { + cloneVectorNetwork, + normalizeVectorNetwork, + transformVectorNetwork, + validateVectorNetwork, + vectorNetworksEqual +} from './vector-network' let nextLocalID = 1 diff --git a/packages/scene-graph/src/resize.ts b/packages/scene-graph/src/resize.ts index 1763a4eec..c404bbec8 100644 --- a/packages/scene-graph/src/resize.ts +++ b/packages/scene-graph/src/resize.ts @@ -1,8 +1,12 @@ +import { copyGeometryPaths, scaleGeometryPaths } from './copy' import type { Rect } from './primitives' import type { ConstraintType, SceneNode, VectorNetwork } from './types' import { cloneVectorNetwork } from './vector-network' -export type ResizeSnapshot = Pick +export type ResizeSnapshot = Pick< + SceneNode, + 'x' | 'y' | 'width' | 'height' | 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry' +> interface ResizeGraph { getNode(id: string): SceneNode | undefined @@ -119,7 +123,9 @@ export function collectResizeDescendants( y: child.y, width: child.width, height: child.height, - vectorNetwork: child.vectorNetwork ? cloneVectorNetwork(child.vectorNetwork) : null + vectorNetwork: child.vectorNetwork ? cloneVectorNetwork(child.vectorNetwork) : null, + fillGeometry: copyGeometryPaths(child.fillGeometry), + strokeGeometry: copyGeometryPaths(child.strokeGeometry) }) collect(childId) } @@ -173,6 +179,22 @@ export function computeConstrainedResizeChanges( rect.height ) if (vectorNetwork) childChanges.vectorNetwork = vectorNetwork + if (original.width > 0 && original.height > 0) { + const scaleX = rect.width / original.width + const scaleY = rect.height / original.height + if (scaleX !== 1 || scaleY !== 1) { + if (original.fillGeometry.length > 0) { + childChanges.fillGeometry = scaleGeometryPaths(original.fillGeometry, scaleX, scaleY) + } + if (original.strokeGeometry.length > 0) { + childChanges.strokeGeometry = scaleGeometryPaths( + original.strokeGeometry, + scaleX, + scaleY + ) + } + } + } changes.set(childId, childChanges) // The final pass sees layout containers after Yoga has resolved HUG/FILL sizing. compute(childId, original, child.layoutMode === 'NONE' ? rect : child) diff --git a/packages/scene-graph/src/vector-network.ts b/packages/scene-graph/src/vector-network.ts index f2ca9046e..47fc43937 100644 --- a/packages/scene-graph/src/vector-network.ts +++ b/packages/scene-graph/src/vector-network.ts @@ -1,6 +1,46 @@ +import { isEqual } from 'es-toolkit/predicate' + +import type { Mat3 } from './matrix' import type { Vector } from './primitives' import type { VectorNetwork, VectorSegment } from './types' +/** + * Map a VectorNetwork through an affine matrix: vertices as points, + * tangents as direction vectors (linear part only, no translation). + * Returns a deep copy; the input is not mutated. + */ +export function transformVectorNetwork(m: Mat3, vn: VectorNetwork): VectorNetwork { + const mapVector = (v: Vector): Vector => ({ + x: m[0] * v.x + m[1] * v.y, + y: m[3] * v.x + m[4] * v.y + }) + return { + vertices: vn.vertices.map((v) => ({ + ...v, + x: m[0] * v.x + m[1] * v.y + m[2], + y: m[3] * v.x + m[4] * v.y + m[5] + })), + segments: vn.segments.map((s) => ({ + ...s, + tangentStart: mapVector(s.tangentStart), + tangentEnd: mapVector(s.tangentEnd) + })), + regions: vn.regions.map((r) => ({ + windingRule: r.windingRule, + loops: r.loops.map((l) => [...l]) + })) + } +} + +/** Structural equality of two VectorNetworks (order-sensitive, exact values). */ +export function vectorNetworksEqual(a: VectorNetwork, b: VectorNetwork): boolean { + return ( + isEqual(a.vertices, b.vertices) && + isEqual(a.segments, b.segments) && + isEqual(a.regions, b.regions) + ) +} + /** Deep-copy a VectorNetwork, stripping any Vue Proxy wrappers. */ export function cloneVectorNetwork(vn: VectorNetwork): VectorNetwork { return { diff --git a/packages/vue/src/editor/commands/edit.ts b/packages/vue/src/editor/commands/edit.ts index 00010a259..2a35107f6 100644 --- a/packages/vue/src/editor/commands/edit.ts +++ b/packages/vue/src/editor/commands/edit.ts @@ -3,11 +3,19 @@ import type { EditorCommand } from './types' type EditCommandId = 'edit.undo' | 'edit.redo' +// Vector edit mode keeps a session-local history; undo/redo route there +// while it is active instead of the document undo stack. +type NodeEditHistoryEditor = Partial<{ + nodeEditUndo: () => void + nodeEditRedo: () => void +}> + export function createEditCommands({ editor, capabilities, messages: t }: EditorCommandMapOptions): Record { + const nodeEditEditor = editor as typeof editor & NodeEditHistoryEditor return { 'edit.undo': { id: 'edit.undo', @@ -15,7 +23,13 @@ export function createEditCommands({ return t.value.undo }, enabled: capabilities.canUndo, - run: () => editor.undoAction() + run: () => { + if (editor.state.nodeEditState) { + nodeEditEditor.nodeEditUndo?.() + return + } + editor.undoAction() + } }, 'edit.redo': { id: 'edit.redo', @@ -23,7 +37,13 @@ export function createEditCommands({ return t.value.redo }, enabled: capabilities.canRedo, - run: () => editor.redoAction() + run: () => { + if (editor.state.nodeEditState) { + nodeEditEditor.nodeEditRedo?.() + return + } + editor.redoAction() + } } } } diff --git a/packages/vue/src/editor/selection-capabilities/use.ts b/packages/vue/src/editor/selection-capabilities/use.ts index a77edd80f..155007579 100644 --- a/packages/vue/src/editor/selection-capabilities/use.ts +++ b/packages/vue/src/editor/selection-capabilities/use.ts @@ -70,8 +70,10 @@ export function useSelectionCapabilities() { canSelectAll: useSceneComputed( () => editor.graph.getChildren(editor.state.currentPageId).length > 0 ), - canUndo: useSceneComputed(() => editor.undo.canUndo), - canRedo: useSceneComputed(() => editor.undo.canRedo), + // In vector edit mode, undo/redo route to the session-local history — + // keep the commands enabled so the shortcut reaches them. + canUndo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canUndo), + canRedo: useSceneComputed(() => editor.state.nodeEditState != null || editor.undo.canRedo), canZoomToSelection: computed(() => hasSelection.value) } } diff --git a/packages/vue/src/shared/input/node-edit/index.ts b/packages/vue/src/shared/input/node-edit/index.ts index 41483cadf..64f3aa04d 100644 --- a/packages/vue/src/shared/input/node-edit/index.ts +++ b/packages/vue/src/shared/input/node-edit/index.ts @@ -21,6 +21,7 @@ type NodeEditEditor = Partial<{ nodeEditRemoveVertex: (vertexIndex: number) => void penResumeFromEndpoint: (nodeId: string, endpointVertexIndex: number) => void nodeEditAddVertex: (cx: number, cy: number) => void + nodeEditPushHistory: () => void nodeEditSetHandle: ( segmentIndex: number, tangentField: 'tangentStart' | 'tangentEnd', @@ -56,6 +57,7 @@ export function handleNodeEditDown( es.selectedVertexIndices = new Set() es.selectedHandles = new Set([key]) } + nodeEditEditor.nodeEditPushHistory?.() setDrag({ type: 'edit-handle', segmentIndex: handleHit.segmentIndex, @@ -79,6 +81,7 @@ export function handleNodeEditDown( if (e.metaKey || e.ctrlKey) { nodeEditEditor.nodeEditSelectVertex?.(vi, false) + nodeEditEditor.nodeEditPushHistory?.() setDrag({ type: 'bend-handle', vertexIndex: vi, @@ -104,6 +107,7 @@ export function handleNodeEditDown( origPositions.set(vi, { x: es.vertices[vi].x, y: es.vertices[vi].y }) } + nodeEditEditor.nodeEditPushHistory?.() setDrag({ type: 'edit-node', startX: cx, diff --git a/packages/vue/src/shared/input/resize.ts b/packages/vue/src/shared/input/resize.ts index f4bf0d3de..0046ead6c 100644 --- a/packages/vue/src/shared/input/resize.ts +++ b/packages/vue/src/shared/input/resize.ts @@ -2,7 +2,9 @@ export { constrainToAspectRatio } from '#vue/shared/input/resize/rect' export { tryStartResize } from '#vue/shared/input/resize/start' import type { Editor } from '@open-pencil/core/editor' import { computeAllLayouts } from '@open-pencil/core/layout' +import { cloneVectorNetwork } from '@open-pencil/scene-graph' import type { SceneNode } from '@open-pencil/scene-graph' +import { copyGeometryPaths, scaleGeometryPaths } from '@open-pencil/scene-graph/copy' import { computeConstrainedResizeChanges, scaleVectorNetworkForResize @@ -25,6 +27,18 @@ function resizeChanges(d: DragResize, cx: number, cy: number, constrain: boolean newRect.height ) if (resizedVectorNetwork) changes.vectorNetwork = resizedVectorNetwork + if (origRect.width > 0 && origRect.height > 0) { + const scaleX = newRect.width / origRect.width + const scaleY = newRect.height / origRect.height + if (scaleX !== 1 || scaleY !== 1) { + if (d.origFillGeometry.length > 0) { + changes.fillGeometry = scaleGeometryPaths(d.origFillGeometry, scaleX, scaleY) + } + if (d.origStrokeGeometry.length > 0) { + changes.strokeGeometry = scaleGeometryPaths(d.origStrokeGeometry, scaleX, scaleY) + } + } + } return { changes, newRect } } @@ -72,7 +86,9 @@ export function commitResizePreview(d: DragResize, editor: Editor) { width: node.width, height: node.height } - if (node.vectorNetwork) finalChanges.vectorNetwork = node.vectorNetwork + if (node.vectorNetwork) finalChanges.vectorNetwork = cloneVectorNetwork(node.vectorNetwork) + finalChanges.fillGeometry = copyGeometryPaths(node.fillGeometry) + finalChanges.strokeGeometry = copyGeometryPaths(node.strokeGeometry) if (d.origChildren) { const finalChildren = new Map>() @@ -85,7 +101,9 @@ export function commitResizePreview(d: DragResize, editor: Editor) { width: child.width, height: child.height } - if (child.vectorNetwork) final.vectorNetwork = child.vectorNetwork + if (child.vectorNetwork) final.vectorNetwork = cloneVectorNetwork(child.vectorNetwork) + final.fillGeometry = copyGeometryPaths(child.fillGeometry) + final.strokeGeometry = copyGeometryPaths(child.strokeGeometry) finalChildren.set(childId, final) } editor.graph.updateNodePreview(d.nodeId, d.origRect) @@ -103,7 +121,9 @@ export function commitResizePreview(d: DragResize, editor: Editor) { editor.updateNode(d.nodeId, finalChanges) editor.commitResize(d.nodeId, { ...d.origRect, - ...(d.origVectorNetwork || node.vectorNetwork ? { vectorNetwork: d.origVectorNetwork } : {}) + vectorNetwork: d.origVectorNetwork, + fillGeometry: d.origFillGeometry, + strokeGeometry: d.origStrokeGeometry }) } } diff --git a/packages/vue/src/shared/input/resize/start.ts b/packages/vue/src/shared/input/resize/start.ts index 90cc3c0d2..d61553d4e 100644 --- a/packages/vue/src/shared/input/resize/start.ts +++ b/packages/vue/src/shared/input/resize/start.ts @@ -1,5 +1,6 @@ import type { Editor } from '@open-pencil/core/editor' import { cloneVectorNetwork } from '@open-pencil/scene-graph' +import { copyGeometryPaths } from '@open-pencil/scene-graph/copy' import { collectResizeDescendants } from '@open-pencil/scene-graph/resize' import { getHitHandleByMatrix } from '#vue/shared/input/geometry' @@ -19,6 +20,8 @@ export function tryStartResize(cx: number, cy: number, editor: Editor): DragResi origRect: { x: node.x, y: node.y, width: node.width, height: node.height }, nodeId: id, origVectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null, + origFillGeometry: copyGeometryPaths(node.fillGeometry), + origStrokeGeometry: copyGeometryPaths(node.strokeGeometry), origChildren: collectResizeDescendants(editor.graph, id) } } diff --git a/packages/vue/src/shared/input/types.ts b/packages/vue/src/shared/input/types.ts index 000d52ecc..1a9e790e5 100644 --- a/packages/vue/src/shared/input/types.ts +++ b/packages/vue/src/shared/input/types.ts @@ -1,5 +1,5 @@ import type { Tool } from '@open-pencil/core/editor' -import type { NodeType, VectorNetwork } from '@open-pencil/scene-graph' +import type { GeometryPath, NodeType, VectorNetwork } from '@open-pencil/scene-graph' import type { Rect, Vector } from '@open-pencil/scene-graph/primitives' import type { ResizeSnapshot } from '@open-pencil/scene-graph/resize' @@ -46,6 +46,8 @@ export interface DragResize { origRect: Rect nodeId: string origVectorNetwork: VectorNetwork | null + origFillGeometry: GeometryPath[] + origStrokeGeometry: GeometryPath[] origChildren: Map | null } diff --git a/src/app/editor/pen/create.ts b/src/app/editor/pen/create.ts index 9acf24a1e..cd07aeb16 100644 --- a/src/app/editor/pen/create.ts +++ b/src/app/editor/pen/create.ts @@ -1,16 +1,20 @@ import type { Editor, Tool } from '@open-pencil/core/editor' -import type { SceneGraph } from '@open-pencil/scene-graph' +import { transformVectorNetwork } from '@open-pencil/scene-graph' +import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate' import { - absoluteVertices, - cloneSegments, createResumedPenState, walkChainOrdered, walkChainToEnd, type PenState } from '@/app/editor/pen/resume' -export function createPenActions(editor: Editor, graph: SceneGraph, state: PenState) { +// Always read editor.graph at call time: opening a file swaps the graph +// instance (editor.replaceGraph), so a captured reference goes stale. +// +// Pen state is page-absolute; resumed geometry maps through the node's world +// matrix so nested, rotated, and flipped vectors resume in place. +export function createPenActions(editor: Editor, state: PenState) { function setTool(tool: Tool) { if (state.penState && tool !== 'PEN' && tool !== 'HAND') { editor.penCommit(false) @@ -19,27 +23,31 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt } function penResumeOnPath(nodeId: string) { - const node = graph.getNode(nodeId) + const node = editor.graph.getNode(nodeId) if (node?.type !== 'VECTOR' || !node.vectorNetwork) return - state.penState = createResumedPenState( - node, - absoluteVertices(node, node.vectorNetwork.vertices), - cloneSegments(node.vectorNetwork.segments) + const absNetwork = transformVectorNetwork( + getWorldMatrix(node, editor.graph), + node.vectorNetwork ) + state.penState = createResumedPenState(node, absNetwork.vertices, absNetwork.segments) - graph.deleteNode(nodeId) + editor.graph.deleteNode(nodeId) editor.clearSelection() editor.setTool('PEN') editor.requestRender() } function penResumeFromEndpoint(nodeId: string, endpointVertexIndex: number) { - const node = graph.getNode(nodeId) + const node = editor.graph.getNode(nodeId) if (node?.type !== 'VECTOR' || !node.vectorNetwork) return - const absVertices = absoluteVertices(node, node.vectorNetwork.vertices) - const absSegments = cloneSegments(node.vectorNetwork.segments) + const absNetwork = transformVectorNetwork( + getWorldMatrix(node, editor.graph), + node.vectorNetwork + ) + const absVertices = absNetwork.vertices + const absSegments = absNetwork.segments const otherEnd = walkChainToEnd(absSegments, endpointVertexIndex) const { orderedVertices, orderedSegments } = walkChainOrdered( absVertices, @@ -48,7 +56,7 @@ export function createPenActions(editor: Editor, graph: SceneGraph, state: PenSt ) state.penState = createResumedPenState(node, orderedVertices, orderedSegments) - graph.deleteNode(nodeId) + editor.graph.deleteNode(nodeId) editor.clearSelection() editor.setTool('PEN') editor.requestRender() diff --git a/src/app/editor/pen/resume.ts b/src/app/editor/pen/resume.ts index 41b248d8b..6fe8296d4 100644 --- a/src/app/editor/pen/resume.ts +++ b/src/app/editor/pen/resume.ts @@ -4,22 +4,6 @@ import type { SceneNode, VectorSegment, VectorVertex } from '@open-pencil/scene- export type PenState = EditorState type PenStateInit = NonNullable -export function absoluteVertices(node: SceneNode, vertices: VectorVertex[]): VectorVertex[] { - return vertices.map((v) => ({ - ...v, - x: v.x + node.x, - y: v.y + node.y - })) -} - -export function cloneSegments(segments: VectorSegment[]): VectorSegment[] { - return segments.map((s) => ({ - ...s, - tangentStart: { ...s.tangentStart }, - tangentEnd: { ...s.tangentEnd } - })) -} - export function createResumedPenState( node: SceneNode, vertices: VectorVertex[], diff --git a/src/app/editor/session/create.ts b/src/app/editor/session/create.ts index 73d1a3bc8..1fc675dca 100644 --- a/src/app/editor/session/create.ts +++ b/src/app/editor/session/create.ts @@ -49,7 +49,7 @@ export function createEditorStore(initialGraph?: SceneGraph) { const { selectedNodes, selectedNode, layerTree } = createEditorComputedRefs(editor, state) - const modules = createEditorStoreModules(editor, graph, state, io, viewportSize) + const modules = createEditorStoreModules(editor, state, io, viewportSize) // ─── Public API ─────────────────────────────────────────────── // Spread all core Editor methods, then override getters and add app-specific. diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index 7f67b1d33..e1bde7300 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -2,7 +2,6 @@ import { computed } from 'vue' import type { Editor } from '@open-pencil/core/editor' import type { IORegistry } from '@open-pencil/core/io' -import type { SceneGraph } from '@open-pencil/scene-graph' import { createDocumentExportActions } from '@/app/document/export' import { createDocumentIOActions } from '@/app/document/io' @@ -51,14 +50,13 @@ export function createEditorComputedRefs(editor: Editor, state: AppEditorState) export function createEditorStoreModules( editor: Editor, - graph: SceneGraph, state: AppEditorState, io: IORegistry, viewportSize: ViewportSize ) { const flash = createFlashActions(editor, state) - const pen = createPenActions(editor, graph, state) - const vectorEdit = createVectorEditActions(editor, graph, state) + const pen = createPenActions(editor, state) + const vectorEdit = createVectorEditActions(editor, state) const documentIO = createDocumentIOActions(editor, state, viewportSize) const documentExport = createDocumentExportActions(editor, state, io, documentIO.downloadBlob) const mobileClipboard = createMobileClipboardActions(editor, state) diff --git a/src/app/editor/vector-edit/create.ts b/src/app/editor/vector-edit/create.ts index 32b68f4a5..5ad1a2fc7 100644 --- a/src/app/editor/vector-edit/create.ts +++ b/src/app/editor/vector-edit/create.ts @@ -1,15 +1,15 @@ import type { Editor } from '@open-pencil/core/editor' -import type { SceneGraph } from '@open-pencil/scene-graph' import { createVectorEditHandleActions } from './handle-actions' +import { createVectorEditHistoryActions } from './history' import { createVectorEditLifecycle } from './lifecycle' import { createVectorEditNetworkActions, getLiveNetwork, setNodeEditNetwork } from './network' import { createVectorEditSelectionActions } from './selection' import type { VectorEditState } from './types' -export function createVectorEditActions(editor: Editor, graph: SceneGraph, state: VectorEditState) { +export function createVectorEditActions(editor: Editor, state: VectorEditState) { const { getNodeEditState, applyNodeEditToNode, enterNodeEditMode, exitNodeEditMode } = - createVectorEditLifecycle(editor, graph, state) + createVectorEditLifecycle(editor, state) const { nodeEditSelectVertex, nodeEditAlignVertices, @@ -19,6 +19,10 @@ export function createVectorEditActions(editor: Editor, graph: SceneGraph, state const { nodeEditSetHandle, nodeEditBendHandle, nodeEditZeroVertexHandles } = createVectorEditHandleActions(editor, getNodeEditState) + const { nodeEditPushHistory, nodeEditUndo, nodeEditRedo } = createVectorEditHistoryActions( + editor, + state + ) const { nodeEditConnectEndpoints, nodeEditAddVertex, nodeEditRemoveVertex } = createVectorEditNetworkActions(editor, state, getNodeEditState) @@ -38,6 +42,9 @@ export function createVectorEditActions(editor: Editor, graph: SceneGraph, state nodeEditRemoveVertex, nodeEditAlignVertices, nodeEditDeleteSelected, - nodeEditBreakAtVertex + nodeEditBreakAtVertex, + nodeEditPushHistory, + nodeEditUndo, + nodeEditRedo } } diff --git a/src/app/editor/vector-edit/handle-actions.ts b/src/app/editor/vector-edit/handle-actions.ts index a83ca190c..ba5c63a20 100644 --- a/src/app/editor/vector-edit/handle-actions.ts +++ b/src/app/editor/vector-edit/handle-actions.ts @@ -3,6 +3,7 @@ import { findAllHandles, findOppositeHandle, mirrorHandle } from '@open-pencil/c import type { Vector } from '@open-pencil/scene-graph/primitives' import { constrainContinuousTangent } from './handles' +import { pushNodeEditHistory } from './history' import { getLiveNetwork } from './network' import type { NodeEditState } from './types' @@ -127,6 +128,7 @@ export function createVectorEditHandleActions(editor: Editor, getNodeEditState: function nodeEditZeroVertexHandles(vertexIndex: number) { const es = getNodeEditState() if (!es) return + pushNodeEditHistory(es) const live = getLiveNetwork(es) const handles = findAllHandles(live, vertexIndex) for (const h of handles) { diff --git a/src/app/editor/vector-edit/history.ts b/src/app/editor/vector-edit/history.ts new file mode 100644 index 000000000..f3779f876 --- /dev/null +++ b/src/app/editor/vector-edit/history.ts @@ -0,0 +1,72 @@ +import type { Editor } from '@open-pencil/core/editor' +import { cloneVectorNetwork, vectorNetworksEqual } from '@open-pencil/scene-graph' +import type { VectorNetwork } from '@open-pencil/scene-graph' + +import type { NodeEditState, VectorEditState } from './types' + +function snapshot(es: NodeEditState): VectorNetwork { + return cloneVectorNetwork({ vertices: es.vertices, segments: es.segments, regions: es.regions }) +} + +/** Snapshot the current geometry onto the session undo stack (call BEFORE mutating). + * Redo entries are NOT cleared here — pointer-down alone is not a change; stale + * redo entries are dropped lazily once a real geometry change is observed. */ +export function pushNodeEditHistory(es: NodeEditState) { + es.history.push(snapshot(es)) +} + +/** Drop redo entries when the geometry diverged from the last undo/redo restore + * point — i.e. a real change happened, which invalidates the redo timeline. */ +function invalidateStaleFuture(es: NodeEditState, current: VectorNetwork) { + if (!es.futureBaseline || es.future.length === 0) return + if (!vectorNetworksEqual(es.futureBaseline, current)) { + es.future = [] + es.futureBaseline = null + } +} + +export function createVectorEditHistoryActions(editor: Editor, state: VectorEditState) { + function nodeEditPushHistory() { + const es = state.nodeEditState + if (es) pushNodeEditHistory(es) + } + + function restore(es: NodeEditState, network: VectorNetwork) { + const clone = cloneVectorNetwork(network) + es.vertices = clone.vertices + es.segments = clone.segments + es.regions = clone.regions + es.futureBaseline = cloneVectorNetwork(network) + // indices may no longer exist in the restored geometry + es.selectedVertexIndices = new Set() + es.selectedHandles = new Set() + es.hoveredHandleInfo = null + editor.requestRender() + } + + function nodeEditUndo() { + const es = state.nodeEditState + if (!es) return + const current = snapshot(es) + invalidateStaleFuture(es, current) + // Drag-start snapshots can be no-ops (click without move) — skip those + let entry = es.history.pop() + while (entry && vectorNetworksEqual(entry, current)) entry = es.history.pop() + if (!entry) return + es.future.push(current) + restore(es, entry) + } + + function nodeEditRedo() { + const es = state.nodeEditState + if (!es) return + const current = snapshot(es) + invalidateStaleFuture(es, current) + const next = es.future.pop() + if (!next) return + es.history.push(current) + restore(es, next) + } + + return { nodeEditPushHistory, nodeEditUndo, nodeEditRedo } +} diff --git a/src/app/editor/vector-edit/lifecycle.ts b/src/app/editor/vector-edit/lifecycle.ts index 56012f656..bdf758d83 100644 --- a/src/app/editor/vector-edit/lifecycle.ts +++ b/src/app/editor/vector-edit/lifecycle.ts @@ -1,70 +1,98 @@ import type { Editor } from '@open-pencil/core/editor' -import { computeAccurateBounds } from '@open-pencil/core/vector' -import { cloneVectorNetwork } from '@open-pencil/scene-graph' -import type { SceneGraph, VectorNetwork } from '@open-pencil/scene-graph' +import { computeAccurateBounds, regenerateFillGeometry } from '@open-pencil/core/vector' +import { + cloneVectorNetwork, + transformVectorNetwork, + vectorNetworksEqual +} from '@open-pencil/scene-graph' +import type { VectorNetwork } from '@open-pencil/scene-graph' +import { getNodeLocalMatrix, getWorldMatrix } from '@open-pencil/scene-graph/coordinate' +import Matrix from '@open-pencil/scene-graph/matrix' import { getLiveNetwork } from './network' import type { VectorEditState } from './types' -export function createVectorEditLifecycle( - editor: Editor, - graph: SceneGraph, - state: VectorEditState -) { +// Always read editor.graph at call time: opening a file swaps the graph +// instance (editor.replaceGraph), so a captured reference goes stale. +// +// Edit-state geometry lives in page-absolute space, mapped through the node's +// full world matrix so nested, rotated, and flipped nodes edit correctly. +export function createVectorEditLifecycle(editor: Editor, state: VectorEditState) { function getNodeEditState() { return state.nodeEditState } function applyNodeEditToNode(es: NonNullable) { - const node = graph.getNode(es.nodeId) + const node = editor.graph.getNode(es.nodeId) if (node?.type !== 'VECTOR') return - const live = getLiveNetwork(es) - const bounds = computeAccurateBounds(live) + // Session geometry only changes through explicit edits, so an exact + // comparison detects a no-op session — skip it to keep undo clean. + if (vectorNetworksEqual(getLiveNetwork(es), es.origAbsNetwork)) return + + const world = getWorldMatrix(node, editor.graph) + const inverse = Matrix.invert(world) + if (!inverse) return + + // Map the edited page-absolute network back into the node's local frame + const localNetwork = transformVectorNetwork(inverse, getLiveNetwork(es)) + const bounds = computeAccurateBounds(localNetwork) const relativeNetwork: VectorNetwork = { - vertices: live.vertices.map((v) => ({ + vertices: localNetwork.vertices.map((v) => ({ ...v, x: v.x - bounds.x, y: v.y - bounds.y })), - segments: live.segments, - regions: live.regions + segments: localNetwork.segments, + regions: localNetwork.regions } - graph.updateNode(node.id, { - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - vectorNetwork: relativeNetwork + // The node rotates/flips about its center, so with rotation preserved the + // new x/y follow from where the new geometry's center lands in the parent + // frame (via the OLD local matrix — the geometry itself has not moved). + const localMatrix = getNodeLocalMatrix(node) + const center = Matrix.mapPoint(localMatrix, { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2 }) + + editor.updateNodeWithUndo( + node.id, + { + x: center.x - bounds.width / 2, + y: center.y - bounds.height / 2, + width: bounds.width, + height: bounds.height, + vectorNetwork: relativeNetwork, + // Fills render from fillGeometry blobs when present — rebuild them from + // the edited network so fills follow the edit. + fillGeometry: regenerateFillGeometry(relativeNetwork, node.fillGeometry), + // Drop stale imported stroke outline blobs; post-edit strokes come from + // the live vector network path. + strokeGeometry: [] + }, + 'Edit vector' + ) editor.requestRender() } function enterNodeEditMode(nodeId: string) { - const node = graph.getNode(nodeId) + const node = editor.graph.getNode(nodeId) if (node?.type !== 'VECTOR' || !node.vectorNetwork) return - const absVertices = node.vectorNetwork.vertices.map((v) => ({ - ...v, - x: v.x + node.x, - y: v.y + node.y - })) + const world = getWorldMatrix(node, editor.graph) + const absNetwork = transformVectorNetwork(world, node.vectorNetwork) state.nodeEditState = { nodeId, origNetwork: cloneVectorNetwork(node.vectorNetwork), origBounds: { x: node.x, y: node.y, width: node.width, height: node.height }, - vertices: absVertices, - segments: node.vectorNetwork.segments.map((s) => ({ - ...s, - tangentStart: { ...s.tangentStart }, - tangentEnd: { ...s.tangentEnd } - })), - regions: node.vectorNetwork.regions.map((r) => ({ - windingRule: r.windingRule, - loops: r.loops.map((l) => [...l]) - })), + origAbsNetwork: cloneVectorNetwork(absNetwork), + vertices: absNetwork.vertices, + segments: absNetwork.segments, + regions: absNetwork.regions, + history: [], + future: [], selectedVertexIndices: new Set(), draggedHandleInfo: null, selectedHandles: new Set(), @@ -79,7 +107,7 @@ export function createVectorEditLifecycle( const es = getNodeEditState() if (!es) return - const node = graph.getNode(es.nodeId) + const node = editor.graph.getNode(es.nodeId) if (node?.type !== 'VECTOR') { state.nodeEditState = null editor.requestRender() @@ -89,7 +117,7 @@ export function createVectorEditLifecycle( if (commit) { applyNodeEditToNode(es) } else { - graph.updateNode(es.nodeId, { + editor.graph.updateNode(es.nodeId, { x: es.origBounds.x, y: es.origBounds.y, width: es.origBounds.width, diff --git a/src/app/editor/vector-edit/network.ts b/src/app/editor/vector-edit/network.ts index 392ed6f6e..902b27b25 100644 --- a/src/app/editor/vector-edit/network.ts +++ b/src/app/editor/vector-edit/network.ts @@ -2,6 +2,7 @@ import type { Editor } from '@open-pencil/core/editor' import { nearestPointOnNetwork, removeVertex, splitSegmentAt } from '@open-pencil/core/vector' import type { VectorNetwork } from '@open-pencil/scene-graph' +import { pushNodeEditHistory } from './history' import type { NodeEditState, VectorEditState } from './types' export function setNodeEditNetwork(es: NodeEditState, network: VectorNetwork) { @@ -43,6 +44,7 @@ export function createVectorEditNetworkActions( const es = getNodeEditState() if (!es || a === b) return if (a < 0 || b < 0 || a >= es.vertices.length || b >= es.vertices.length) return + pushNodeEditHistory(es) const removeIndex = a const keepIndex = b @@ -74,6 +76,7 @@ export function createVectorEditNetworkActions( const live = getLiveNetwork(es) const nearest = nearestPointOnNetwork(cx, cy, live, nodeEditHitThreshold / state.zoom) if (!nearest) return + pushNodeEditHistory(es) const split = splitSegmentAt(live, nearest.segmentIndex, nearest.t) setNodeEditNetwork(es, split.network) es.selectedVertexIndices = new Set([split.newVertexIndex]) @@ -87,6 +90,7 @@ export function createVectorEditNetworkActions( const live = getLiveNetwork(es) const next = removeVertex(live, vertexIndex) if (!next) return + pushNodeEditHistory(es) setNodeEditNetwork(es, next) es.selectedVertexIndices = new Set() es.selectedHandles = new Set() diff --git a/src/app/editor/vector-edit/selection.ts b/src/app/editor/vector-edit/selection.ts index 53a6bc94c..24db52c31 100644 --- a/src/app/editor/vector-edit/selection.ts +++ b/src/app/editor/vector-edit/selection.ts @@ -1,6 +1,7 @@ import type { Editor } from '@open-pencil/core/editor' import { breakAtVertex, deleteVertex } from '@open-pencil/core/vector' +import { pushNodeEditHistory } from './history' import { getLiveNetwork, setNodeEditNetwork } from './network' import type { VectorEditState } from './types' @@ -26,6 +27,7 @@ export function createVectorEditSelectionActions(editor: Editor, state: VectorEd function nodeEditAlignVertices(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max') { const es = getNodeEditState() if (!es || es.selectedVertexIndices.size < 2) return + pushNodeEditHistory(es) const indices = [...es.selectedVertexIndices] const prop = axis === 'horizontal' ? 'x' : 'y' @@ -50,6 +52,8 @@ export function createVectorEditSelectionActions(editor: Editor, state: VectorEd function nodeEditDeleteSelected() { const es = getNodeEditState() if (!es) return + if (es.selectedHandles.size === 0 && es.selectedVertexIndices.size === 0) return + pushNodeEditHistory(es) let live = getLiveNetwork(es) for (const key of es.selectedHandles) { @@ -76,6 +80,7 @@ export function createVectorEditSelectionActions(editor: Editor, state: VectorEd function nodeEditBreakAtVertex() { const es = getNodeEditState() if (!es || es.selectedVertexIndices.size === 0) return + pushNodeEditHistory(es) const [vertexIndex] = es.selectedVertexIndices const live = getLiveNetwork(es) const next = breakAtVertex(live, vertexIndex) diff --git a/src/app/editor/vector-edit/types.ts b/src/app/editor/vector-edit/types.ts index 81462e301..3df603c3f 100644 --- a/src/app/editor/vector-edit/types.ts +++ b/src/app/editor/vector-edit/types.ts @@ -11,9 +11,16 @@ export type NodeEditState = { nodeId: string origNetwork: VectorNetwork origBounds: Rect + /** Session geometry as entered (page-absolute) — commit is a no-op when unchanged */ + origAbsNetwork: VectorNetwork vertices: VectorVertex[] segments: VectorSegment[] regions: VectorRegion[] + /** Session-local undo/redo stacks of geometry snapshots (Cmd+Z inside edit mode) */ + history: VectorNetwork[] + future: VectorNetwork[] + /** Geometry at the last undo/redo restore; divergence invalidates `future` */ + futureBaseline?: VectorNetwork | null selectedVertexIndices: Set draggedHandleInfo: { vertexIndex: number diff --git a/tests/e2e/canvas/vector/editing.spec.ts b/tests/e2e/canvas/vector/editing.spec.ts new file mode 100644 index 000000000..232a8e5a5 --- /dev/null +++ b/tests/e2e/canvas/vector/editing.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' + +const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') + +test('vector edit overlay follows nested transforms and live path fills', async () => { + await editor.page.evaluate(() => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const pageId = store.state.currentPageId + const rectBlob = (x: number, y: number, width: number, height: number) => { + const blob = new Uint8Array(38) + const view = new DataView(blob.buffer) + const points = [ + { command: 1, x, y }, + { command: 2, x: x + width, y }, + { command: 2, x: x + width, y: y + height }, + { command: 2, x, y: y + height } + ] + let offset = 0 + for (const point of points) { + blob[offset] = point.command + view.setFloat32(offset + 1, point.x, true) + view.setFloat32(offset + 5, point.y, true) + offset += 9 + } + blob[offset] = 0 + return blob + } + + const blue = { + type: 'SOLID' as const, + color: { r: 0.23, g: 0.51, b: 0.96, a: 1 }, + visible: true, + opacity: 1 + } + const orange = { + type: 'SOLID' as const, + color: { r: 1, g: 0.4, b: 0, a: 1 }, + visible: true, + opacity: 1 + } + const frame = store.graph.createNode('FRAME', pageId, { + name: 'Edit host', + x: 220, + y: 120, + width: 400, + height: 300, + rotation: 30, + fills: [ + { type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97, a: 1 }, visible: true, opacity: 1 } + ] + }) + const vector = store.graph.createNode('VECTOR', frame.id, { + name: 'Edited vector', + x: 60, + y: 80, + width: 200, + height: 100, + rotation: 20, + fills: [blue], + fillGeometry: [ + { windingRule: 'NONZERO', commandsBlob: rectBlob(0, 0, 100, 100) }, + { windingRule: 'NONZERO', commandsBlob: rectBlob(100, 0, 100, 100), fills: [orange] } + ], + vectorNetwork: { + vertices: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + { x: 100, y: 0 }, + { x: 200, y: 0 }, + { x: 200, y: 100 }, + { x: 100, y: 100 } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 20, y: 20 }, 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 } }, + { start: 4, end: 5, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 5, end: 6, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 6, end: 7, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }, + { start: 7, end: 4, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } } + ], + regions: [ + { windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }, + { windingRule: 'NONZERO', loops: [[4, 5, 6, 7]] } + ] + } + }) + + store.enterNodeEditMode(vector.id) + const editState = store.getNodeEditState() + if (editState) editState.vertices[6].x += 30 + store.requestRender() + }) + await editor.canvas.waitForRender() + editor.canvas.assertNoErrors() + const buffer = await editor.canvas.canvas.screenshot() + expect(buffer).toMatchSnapshot('vector-edit-mode-overlay.png') +}) diff --git a/tests/e2e/canvas/vector/editing.spec.ts-snapshots/vector-edit-mode-overlay-openpencil-darwin.png b/tests/e2e/canvas/vector/editing.spec.ts-snapshots/vector-edit-mode-overlay-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..4c6b8a024ce91f093a4ee6d8ba47ac76e4f4e009 GIT binary patch literal 45028 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
8#tk1&GCLuKec_h=HVevzWNA}- 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 J<0$#B{|5!z#pVD2 literal 0 HcmV?d00001 diff --git a/tests/e2e/canvas/vector-style-fills-visual.spec.ts b/tests/e2e/canvas/vector/style-fills.spec.ts similarity index 100% rename from tests/e2e/canvas/vector-style-fills-visual.spec.ts rename to tests/e2e/canvas/vector/style-fills.spec.ts diff --git a/tests/e2e/canvas/vector-style-fills-visual.spec.ts-snapshots/vector-path-style-fills-openpencil-darwin.png b/tests/e2e/canvas/vector/style-fills.spec.ts-snapshots/vector-path-style-fills-openpencil-darwin.png similarity index 100% rename from tests/e2e/canvas/vector-style-fills-visual.spec.ts-snapshots/vector-path-style-fills-openpencil-darwin.png rename to tests/e2e/canvas/vector/style-fills.spec.ts-snapshots/vector-path-style-fills-openpencil-darwin.png diff --git a/tests/e2e/canvas/vector-winding-visual.spec.ts b/tests/e2e/canvas/vector/winding.spec.ts similarity index 100% rename from tests/e2e/canvas/vector-winding-visual.spec.ts rename to tests/e2e/canvas/vector/winding.spec.ts diff --git a/tests/e2e/canvas/vector-winding-visual.spec.ts-snapshots/even-odd-vector-holes-openpencil-darwin.png b/tests/e2e/canvas/vector/winding.spec.ts-snapshots/even-odd-vector-holes-openpencil-darwin.png similarity index 100% rename from tests/e2e/canvas/vector-winding-visual.spec.ts-snapshots/even-odd-vector-holes-openpencil-darwin.png rename to tests/e2e/canvas/vector/winding.spec.ts-snapshots/even-odd-vector-holes-openpencil-darwin.png diff --git a/tests/engine/app/vector-edit-transforms.test.ts b/tests/engine/app/vector-edit-transforms.test.ts new file mode 100644 index 000000000..7fbe85bd2 --- /dev/null +++ b/tests/engine/app/vector-edit-transforms.test.ts @@ -0,0 +1,320 @@ +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, 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) + }) +}) diff --git a/tests/engine/editor/undo/group-resize.test.ts b/tests/engine/editor/undo/group-resize.test.ts index 5cf5c4ef3..db1a5ead5 100644 --- a/tests/engine/editor/undo/group-resize.test.ts +++ b/tests/engine/editor/undo/group-resize.test.ts @@ -101,7 +101,9 @@ describe('resize undo', () => { y: 0, width: 10, height: 10, - vectorNetwork: originalVectorNetwork + vectorNetwork: originalVectorNetwork, + fillGeometry: [], + strokeGeometry: [] } ] ]) diff --git a/tests/engine/vector/fill-geometry-regen.test.ts b/tests/engine/vector/fill-geometry-regen.test.ts new file mode 100644 index 000000000..4ea0917f4 --- /dev/null +++ b/tests/engine/vector/fill-geometry-regen.test.ts @@ -0,0 +1,115 @@ +import { describe, test, expect } from 'bun:test' + +import { regenerateFillGeometry } from '@open-pencil/core/vector' +import type { GeometryPath, VectorNetwork } from '@open-pencil/scene-graph' + +function square(): VectorNetwork { + return { + vertices: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, 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: 'EVENODD', loops: [[0, 1, 2, 3]] }] + } +} + +/** Decode a commandsBlob into a readable command list for assertions. */ +function decode(blob: Uint8Array): string[] { + const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength) + const out: string[] = [] + let o = 0 + while (o < blob.length) { + const cmd = blob[o] + o += 1 + if (cmd === 0) { + out.push('Z') + } else if (cmd === 1 || cmd === 2) { + out.push( + `${cmd === 1 ? 'M' : 'L'} ${view.getFloat32(o, true)},${view.getFloat32(o + 4, true)}` + ) + o += 8 + } else if (cmd === 4) { + out.push(`C ${view.getFloat32(o + 16, true)},${view.getFloat32(o + 20, true)}`) + o += 24 + } else { + throw new Error(`unknown cmd ${cmd}`) + } + } + return out +} + +const ENTRY: GeometryPath = { + windingRule: 'NONZERO', + commandsBlob: new Uint8Array([9, 9, 9]), + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + blendMode: 'NORMAL' + } + ] +} + +describe('regenerateFillGeometry', () => { + test('rebuilds region blobs positionally, preserving fills', () => { + const network = square() + const [g] = regenerateFillGeometry(network, [ENTRY]) + expect(g.fills).toEqual(ENTRY.fills) + expect(g.windingRule).toBe('EVENODD') // taken from the region + expect(decode(g.commandsBlob)).toEqual(['M 0,0', 'L 10,0', 'L 10,10', 'L 0,10', 'L 0,0', 'Z']) + }) + + test('edited vertices land in the regenerated blob', () => { + const network = square() + network.vertices[2] = { x: 14, y: 12 } + const [g] = regenerateFillGeometry(network, [ENTRY]) + expect(decode(g.commandsBlob)).toContain('L 14,12') + }) + + test('curved segments emit cubics', () => { + const network = square() + network.segments[0].tangentStart = { x: 3, y: 4 } + const [g] = regenerateFillGeometry(network, [ENTRY]) + expect(decode(g.commandsBlob)[1]).toBe('C 10,0') + }) + + test('region/entry count mismatch rebuilds without stale styles', () => { + const network = square() + // Two style entries but only one region — mapping is unknown, so rebuild + // one path per region and drop the mismatched style metadata. + const out = regenerateFillGeometry(network, [ENTRY, { ...ENTRY }]) + expect(out).toHaveLength(1) + const path = out[0] + expect(path).toBeDefined() + if (!path) return + expect(path.fills).toBeUndefined() + expect(path.commandsBlob).not.toBe(ENTRY.commandsBlob) + expect(decode(path.commandsBlob)).toEqual([ + 'M 0,0', + 'L 10,0', + 'L 10,10', + 'L 0,10', + 'L 0,0', + 'Z' + ]) + }) + + test('regionless networks rebuild a single entry from segment chains', () => { + const network = square() + network.regions = [] + const [g] = regenerateFillGeometry(network, [ENTRY]) + const cmds = decode(g.commandsBlob) + expect(cmds[0]).toBe('M 0,0') + expect(cmds).toHaveLength(5) // M + 4 line segments around the square + }) +}) diff --git a/tests/engine/vue/controls/constraints.test.ts b/tests/engine/vue/controls/constraints.test.ts index 4ecdf5a9d..31f504a42 100644 --- a/tests/engine/vue/controls/constraints.test.ts +++ b/tests/engine/vue/controls/constraints.test.ts @@ -26,7 +26,9 @@ function original(node: { y: node.y, width: node.width, height: node.height, - vectorNetwork: node.vectorNetwork + vectorNetwork: node.vectorNetwork, + fillGeometry: [], + strokeGeometry: [] } } @@ -63,7 +65,9 @@ describe('constraint resize geometry', () => { y: 30, width: 40, height: 20, - vectorNetwork: null + vectorNetwork: null, + fillGeometry: [], + strokeGeometry: [] } const expectedX: Record = { MIN: [20, 40], @@ -114,6 +118,8 @@ describe('constraint resize geometry', () => { origRect: { x: root.x, y: root.y, width: root.width, height: root.height }, nodeId: root.id, origVectorNetwork: null, + origFillGeometry: [], + origStrokeGeometry: [], origChildren: new Map([ [nested.id, original(nested)], [grandchild.id, original(grandchild)] @@ -157,6 +163,8 @@ describe('constraint resize geometry', () => { origRect: { x: root.x, y: root.y, width: root.width, height: root.height }, nodeId: root.id, origVectorNetwork: null, + origFillGeometry: [], + origStrokeGeometry: [], origChildren: collectResizeDescendants(graph, root.id) } diff --git a/tests/engine/vue/input/resize-geometry.test.ts b/tests/engine/vue/input/resize-geometry.test.ts new file mode 100644 index 000000000..e5ee7954b --- /dev/null +++ b/tests/engine/vue/input/resize-geometry.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test' + +import { createEditor } from '@open-pencil/core/editor' +import type { Fill, GeometryPath, SceneNode, VectorNetwork } from '@open-pencil/scene-graph' +import { cloneVectorNetwork } from '@open-pencil/scene-graph' +import { copyGeometryPaths } from '@open-pencil/scene-graph/copy' +import { collectResizeDescendants } from '@open-pencil/scene-graph/resize' + +import { applyResize, commitResizePreview } from '#vue/shared/input/resize' +import type { DragResize } from '#vue/shared/input/types' + +import { getNodeOrThrow } from '#tests/helpers/assert' + +const RED: Fill = { + type: 'SOLID', + color: { r: 1, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + blendMode: 'NORMAL' +} + +const NETWORK: VectorNetwork = { + vertices: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 } + ], + segments: [ + { start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, 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 movePath(x: number, y: number, fills?: Fill[]): GeometryPath { + const commandsBlob = new Uint8Array(9) + const view = new DataView(commandsBlob.buffer) + commandsBlob[0] = 1 + view.setFloat32(1, x, true) + view.setFloat32(5, y, true) + return { windingRule: 'NONZERO', commandsBlob, fills } +} + +function pathPoint(path: GeometryPath | undefined) { + if (!path) return null + const view = new DataView( + path.commandsBlob.buffer, + path.commandsBlob.byteOffset, + path.commandsBlob.byteLength + ) + return [view.getFloat32(1, true), view.getFloat32(5, true)] +} + +function vectorGeometry(node: SceneNode) { + return { + network: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null, + fills: copyGeometryPaths(node.fillGeometry), + strokes: copyGeometryPaths(node.strokeGeometry) + } +} + +function dragFor(node: SceneNode, origChildren: DragResize['origChildren'] = null): DragResize { + const geometry = vectorGeometry(node) + return { + type: 'resize', + handle: 'se', + startX: 10, + startY: 10, + origRect: { x: node.x, y: node.y, width: node.width, height: node.height }, + nodeId: node.id, + origVectorNetwork: geometry.network, + origFillGeometry: geometry.fills, + origStrokeGeometry: geometry.strokes, + origChildren + } +} + +function expectGeometry(node: SceneNode, size: number) { + expect(node.width).toBe(size) + expect(node.height).toBe(size) + expect(node.vectorNetwork?.vertices[2]).toMatchObject({ x: size, y: size }) + expect(pathPoint(node.fillGeometry[0])).toEqual([size, size]) + expect(pathPoint(node.strokeGeometry[0])).toEqual([size / 2, size / 2]) + expect(node.fillGeometry[0]?.fills?.[0]?.color.r).toBe(1) +} + +describe('vector resize geometry', () => { + test('keeps fill and stroke paths aligned through resize, undo, and redo', () => { + const editor = createEditor() + const page = editor.graph.getPages()[0] + const vector = editor.graph.createNode('VECTOR', page.id, { + width: 10, + height: 10, + vectorNetwork: cloneVectorNetwork(NETWORK), + fillGeometry: [movePath(10, 10, [RED])], + strokeGeometry: [movePath(5, 5)] + }) + const drag = dragFor(vector) + + applyResize(drag, 20, 20, false, editor) + commitResizePreview(drag, editor) + expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20) + + editor.undo.undo() + expectGeometry(getNodeOrThrow(editor.graph, vector.id), 10) + + editor.undo.redo() + expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20) + }) + + test('keeps descendant geometry aligned through group resize history', () => { + const editor = createEditor() + const page = editor.graph.getPages()[0] + const group = editor.graph.createNode('GROUP', page.id, { width: 10, height: 10 }) + const vector = editor.graph.createNode('VECTOR', group.id, { + width: 10, + height: 10, + vectorNetwork: cloneVectorNetwork(NETWORK), + fillGeometry: [movePath(10, 10, [RED])], + strokeGeometry: [movePath(5, 5)] + }) + const descendants = collectResizeDescendants(editor.graph, group.id) + expect(descendants).not.toBeNull() + const drag = dragFor(group, descendants) + + applyResize(drag, 20, 20, false, editor) + commitResizePreview(drag, editor) + expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20) + + editor.undo.undo() + expectGeometry(getNodeOrThrow(editor.graph, vector.id), 10) + + editor.undo.redo() + expectGeometry(getNodeOrThrow(editor.graph, vector.id), 20) + }) +})