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>
This commit is contained in:
parent
a7d7b71995
commit
cb2c4aa3d3
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<SceneNode, 'x' | 'y' | 'width' | 'height' | 'vectorNetwork'>
|
||||
type ResizeOriginal = Rect & { vectorNetwork?: SceneNode['vectorNetwork'] }
|
||||
type ResizeSnapshot = Pick<
|
||||
SceneNode,
|
||||
'x' | 'y' | 'width' | 'height' | 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry'
|
||||
>
|
||||
type ResizeOriginal = Rect &
|
||||
Partial<Pick<SceneNode, 'vectorNetwork' | 'fillGeometry' | 'strokeGeometry'>>
|
||||
|
||||
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: () => {
|
||||
|
|
|
|||
|
|
@ -219,7 +219,8 @@ export {
|
|||
geometryBlobToPath,
|
||||
decodeVectorNetworkBlob,
|
||||
encodeVectorNetworkBlob,
|
||||
buildStyleOverrideTable
|
||||
buildStyleOverrideTable,
|
||||
regenerateFillGeometry
|
||||
} from './vector'
|
||||
export {
|
||||
evalCubic,
|
||||
|
|
|
|||
90
packages/core/src/vector/fill-geometry.ts
Normal file
90
packages/core/src/vector/fill-geometry.ts
Normal file
|
|
@ -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 []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SceneNode, 'x' | 'y' | 'width' | 'height' | 'vectorNetwork'>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<EditCommandId, EditorCommand> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, Partial<SceneNode>>()
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, ResizeSnapshot> | null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -4,22 +4,6 @@ import type { SceneNode, VectorSegment, VectorVertex } from '@open-pencil/scene-
|
|||
export type PenState = EditorState
|
||||
type PenStateInit = NonNullable<EditorState['penState']>
|
||||
|
||||
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[],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
72
src/app/editor/vector-edit/history.ts
Normal file
72
src/app/editor/vector-edit/history.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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<typeof state.nodeEditState>) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<number>
|
||||
draggedHandleInfo: {
|
||||
vertexIndex: number
|
||||
|
|
|
|||
102
tests/e2e/canvas/vector/editing.spec.ts
Normal file
102
tests/e2e/canvas/vector/editing.spec.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
320
tests/engine/app/vector-edit-transforms.test.ts
Normal file
320
tests/engine/app/vector-edit-transforms.test.ts
Normal file
|
|
@ -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<SceneNode>, label = 'Update') {
|
||||
undoLabels.push(label)
|
||||
graph.updateNode(id, changes)
|
||||
}
|
||||
} as Editor
|
||||
const state: VectorEditState = { nodeEditState: null } as VectorEditState
|
||||
const lifecycle = createVectorEditLifecycle(editor, state)
|
||||
return { graph, vector, state, lifecycle, undoLabels }
|
||||
}
|
||||
|
||||
/** World-space positions of a node's network vertices via its world matrix. */
|
||||
function worldVertices(graph: SceneGraph, nodeId: string) {
|
||||
const node = getNodeOrThrow(graph, nodeId)
|
||||
const network = expectDefined(node.vectorNetwork, 'vectorNetwork')
|
||||
const world = getWorldMatrix(node, graph)
|
||||
return network.vertices.map((v) => Matrix.mapPoint(world, v))
|
||||
}
|
||||
|
||||
describe('vector edit graph ownership', () => {
|
||||
test('enters edit mode after the editor graph is replaced', () => {
|
||||
const initial = new SceneGraph()
|
||||
const replacement = new SceneGraph()
|
||||
const vector = replacement.createNode('VECTOR', expectDefined(replacement.getPages()[0]).id, {
|
||||
width: 100,
|
||||
height: 50,
|
||||
vectorNetwork: NETWORK
|
||||
})
|
||||
const editor = createEditor({ graph: initial })
|
||||
const state = { nodeEditState: null } as VectorEditState
|
||||
const lifecycle = createVectorEditLifecycle(editor, state)
|
||||
|
||||
editor.replaceGraph(replacement)
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
|
||||
expect(state.nodeEditState?.nodeId).toBe(vector.id)
|
||||
})
|
||||
|
||||
test('commits network and fill geometry as one document undo step', () => {
|
||||
const editor = createEditor()
|
||||
const page = expectDefined(editor.graph.getPages()[0])
|
||||
const fillGeometry = regenerateFillGeometry(NETWORK, [
|
||||
{
|
||||
windingRule: 'NONZERO',
|
||||
commandsBlob: new Uint8Array(),
|
||||
fills: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 0.4, b: 0, a: 1 },
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
blendMode: 'NORMAL'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
const vector = editor.graph.createNode('VECTOR', page.id, {
|
||||
width: 100,
|
||||
height: 50,
|
||||
vectorNetwork: NETWORK,
|
||||
fillGeometry
|
||||
})
|
||||
const state = { nodeEditState: null } as VectorEditState
|
||||
const lifecycle = createVectorEditLifecycle(editor, state)
|
||||
const originalBlob = expectDefined(vector.fillGeometry[0]).commandsBlob.slice()
|
||||
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
const editState = expectDefined(state.nodeEditState)
|
||||
expectDefined(editState.vertices[1]).x += 20
|
||||
lifecycle.exitNodeEditMode(true)
|
||||
|
||||
const edited = getNodeOrThrow(editor.graph, vector.id)
|
||||
const editedBlob = expectDefined(edited.fillGeometry[0]).commandsBlob.slice()
|
||||
expect(editedBlob).not.toEqual(originalBlob)
|
||||
expect(editor.undo.canUndo).toBe(true)
|
||||
|
||||
editor.undo.undo()
|
||||
expect(
|
||||
expectDefined(getNodeOrThrow(editor.graph, vector.id).fillGeometry[0]).commandsBlob
|
||||
).toEqual(originalBlob)
|
||||
expect(editor.undo.canUndo).toBe(false)
|
||||
|
||||
editor.undo.redo()
|
||||
expect(
|
||||
expectDefined(getNodeOrThrow(editor.graph, vector.id).fillGeometry[0]).commandsBlob
|
||||
).toEqual(editedBlob)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vector edit with rotated ancestors', () => {
|
||||
test('enter maps vertices through the full world matrix', () => {
|
||||
const { graph, vector, state, lifecycle } = setup(30, 20)
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
|
||||
const expected = worldVertices(graph, vector.id)
|
||||
const es = expectDefined(state.nodeEditState, 'nodeEditState')
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
const point = expectDefined(expected[i])
|
||||
const vertex = expectDefined(es.vertices[i])
|
||||
expect(vertex.x).toBeCloseTo(point.x, 6)
|
||||
expect(vertex.y).toBeCloseTo(point.y, 6)
|
||||
}
|
||||
})
|
||||
|
||||
test('enter + commit without edits is a no-op', () => {
|
||||
const { graph, vector, lifecycle, undoLabels } = setup(30, 20)
|
||||
const before = worldVertices(graph, vector.id)
|
||||
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
lifecycle.exitNodeEditMode(true)
|
||||
|
||||
const node = getNodeOrThrow(graph, vector.id)
|
||||
expect(node.x).toBe(40)
|
||||
expect(node.y).toBe(60)
|
||||
expect(node.width).toBe(100)
|
||||
expect(node.height).toBe(50)
|
||||
const after = worldVertices(graph, vector.id)
|
||||
for (let i = 0; i < before.length; i++) {
|
||||
const b = expectDefined(before[i])
|
||||
const a = expectDefined(after[i])
|
||||
expect(a.x).toBeCloseTo(b.x, 6)
|
||||
expect(a.y).toBeCloseTo(b.y, 6)
|
||||
}
|
||||
// no geometry change → no document undo entry
|
||||
expect(undoLabels).toEqual([])
|
||||
})
|
||||
|
||||
test('dragging a vertex commits without moving the rest of the shape', () => {
|
||||
const { graph, vector, state, lifecycle, undoLabels } = setup(30, 20)
|
||||
const before = worldVertices(graph, vector.id)
|
||||
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
const es = expectDefined(state.nodeEditState, 'nodeEditState')
|
||||
const dragged = expectDefined(es.vertices[1])
|
||||
dragged.x += 25
|
||||
dragged.y -= 15
|
||||
const movedWorld = { x: dragged.x, y: dragged.y }
|
||||
lifecycle.exitNodeEditMode(true)
|
||||
|
||||
const after = worldVertices(graph, vector.id)
|
||||
expect(expectDefined(after[1]).x).toBeCloseTo(movedWorld.x, 5)
|
||||
expect(expectDefined(after[1]).y).toBeCloseTo(movedWorld.y, 5)
|
||||
for (const i of [0, 2, 3]) {
|
||||
const b = expectDefined(before[i])
|
||||
const a = expectDefined(after[i])
|
||||
expect(a.x).toBeCloseTo(b.x, 5)
|
||||
expect(a.y).toBeCloseTo(b.y, 5)
|
||||
}
|
||||
// rotation is preserved, geometry is re-normalized to the new bounds
|
||||
const node = getNodeOrThrow(graph, vector.id)
|
||||
const network = expectDefined(node.vectorNetwork, 'vectorNetwork')
|
||||
expect(node.rotation).toBe(20)
|
||||
expect(Math.min(...network.vertices.map((v) => v.x))).toBeCloseTo(0, 5)
|
||||
expect(Math.min(...network.vertices.map((v) => v.y))).toBeCloseTo(0, 5)
|
||||
// committing goes through the undo-aware update so Cmd+Z works
|
||||
expect(undoLabels).toEqual(['Edit vector'])
|
||||
})
|
||||
|
||||
test('tangents rotate with the node on enter', () => {
|
||||
const { state, lifecycle, vector } = setup(0, 90)
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
const es = expectDefined(state.nodeEditState, 'nodeEditState')
|
||||
const t = expectDefined(es.segments[0]).tangentStart
|
||||
// local tangent (20, 10) rotated 90° → (-10, 20)
|
||||
expect(t.x).toBeCloseTo(-10, 6)
|
||||
expect(t.y).toBeCloseTo(20, 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vector edit session undo/redo', () => {
|
||||
function editSession() {
|
||||
const { graph, vector, state, lifecycle } = setup(0, 0)
|
||||
const editor = {
|
||||
get graph() {
|
||||
return graph
|
||||
},
|
||||
requestRender: () => undefined
|
||||
} as Editor
|
||||
const history = createVectorEditHistoryActions(editor, state)
|
||||
lifecycle.enterNodeEditMode(vector.id)
|
||||
return { state, history, es: expectDefined(state.nodeEditState, 'nodeEditState') }
|
||||
}
|
||||
|
||||
test('undo restores geometry from before the mutation', () => {
|
||||
const { es, history } = editSession()
|
||||
const orig = { ...expectDefined(es.vertices[0]) }
|
||||
|
||||
history.nodeEditPushHistory() // drag start
|
||||
es.vertices[0] = { ...orig, x: orig.x + 50, y: orig.y - 20 }
|
||||
history.nodeEditUndo()
|
||||
|
||||
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
|
||||
expect(expectDefined(es.vertices[0]).y).toBeCloseTo(orig.y, 6)
|
||||
})
|
||||
|
||||
test('redo reapplies the undone mutation', () => {
|
||||
const { es, history } = editSession()
|
||||
const orig = { ...expectDefined(es.vertices[0]) }
|
||||
|
||||
history.nodeEditPushHistory()
|
||||
es.vertices[0] = { ...orig, x: orig.x + 50 }
|
||||
history.nodeEditUndo()
|
||||
history.nodeEditRedo()
|
||||
|
||||
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x + 50, 6)
|
||||
})
|
||||
|
||||
test('no-op drag snapshots are skipped', () => {
|
||||
const { es, history } = editSession()
|
||||
const orig = { ...expectDefined(es.vertices[0]) }
|
||||
|
||||
history.nodeEditPushHistory()
|
||||
es.vertices[0] = { ...orig, x: orig.x + 50 }
|
||||
history.nodeEditPushHistory() // click without move → identical snapshot
|
||||
history.nodeEditPushHistory()
|
||||
history.nodeEditUndo()
|
||||
|
||||
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
|
||||
})
|
||||
|
||||
test('undo with no history is a no-op', () => {
|
||||
const { es, history } = editSession()
|
||||
const orig = { ...expectDefined(es.vertices[0]) }
|
||||
history.nodeEditUndo()
|
||||
expect(expectDefined(es.vertices[0]).x).toBeCloseTo(orig.x, 6)
|
||||
})
|
||||
|
||||
test('clicking without dragging after undo keeps redo', () => {
|
||||
const { state, history } = editSession()
|
||||
const es = () => expectDefined(state.nodeEditState, 'nodeEditState')
|
||||
const orig = { ...expectDefined(es().vertices[0]) }
|
||||
|
||||
history.nodeEditPushHistory()
|
||||
es().vertices[0] = { ...orig, x: orig.x + 50 }
|
||||
history.nodeEditUndo()
|
||||
history.nodeEditPushHistory() // click a vertex without moving it
|
||||
history.nodeEditRedo()
|
||||
|
||||
expect(expectDefined(es().vertices[0]).x).toBeCloseTo(orig.x + 50, 6)
|
||||
})
|
||||
|
||||
test('a real change after undo clears redo', () => {
|
||||
const { state, history } = editSession()
|
||||
const es = () => expectDefined(state.nodeEditState, 'nodeEditState')
|
||||
const orig = { ...expectDefined(es().vertices[0]) }
|
||||
|
||||
history.nodeEditPushHistory()
|
||||
es().vertices[0] = { ...orig, x: orig.x + 50 }
|
||||
history.nodeEditUndo()
|
||||
history.nodeEditPushHistory()
|
||||
es().vertices[0] = { ...orig, x: orig.x - 30 } // new timeline
|
||||
history.nodeEditRedo() // stale redo must not restore +50
|
||||
|
||||
expect(expectDefined(es().vertices[0]).x).toBeCloseTo(orig.x - 30, 6)
|
||||
})
|
||||
})
|
||||
|
|
@ -101,7 +101,9 @@ describe('resize undo', () => {
|
|||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
vectorNetwork: originalVectorNetwork
|
||||
vectorNetwork: originalVectorNetwork,
|
||||
fillGeometry: [],
|
||||
strokeGeometry: []
|
||||
}
|
||||
]
|
||||
])
|
||||
|
|
|
|||
115
tests/engine/vector/fill-geometry-regen.test.ts
Normal file
115
tests/engine/vector/fill-geometry-regen.test.ts
Normal file
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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<ConstraintType, [number, number]> = {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
139
tests/engine/vue/input/resize-geometry.test.ts
Normal file
139
tests/engine/vue/input/resize-geometry.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue