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 000000000..4c6b8a024 Binary files /dev/null and b/tests/e2e/canvas/vector/editing.spec.ts-snapshots/vector-edit-mode-overlay-openpencil-darwin.png differ 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) + }) +})