feat(vector): add anchor point alignment for selected vertices

Align selected anchor points relative to each other in vector edit mode. The standard alignment buttons in the position panel now operate on selected vertices when 2 or more are selected, enabling precise vector path editing workflows.
This commit is contained in:
Ilya Nikitin 2026-03-26 11:55:56 +05:00
parent 6ad25297df
commit 2b92916881
10 changed files with 123 additions and 26 deletions

View file

@ -14,6 +14,7 @@
- Added a vector curve editor and improved drawing experience with the pen tool
- Resume pen drawing from existing open path endpoints — click an endpoint to continue the curve
- Close open paths by dragging one endpoint to the other
- Align selected anchor points relative to each other in vector edit mode — the standard alignment buttons in the position panel now operate on selected vertices when 2 or more are selected
### Fixes

View file

@ -126,6 +126,48 @@ With Pen active inside curve edit mode, contour insertion, endpoint resume, and
- **WHEN** Pen is active and user `Alt/Option`-clicks an anchor in curve edit mode
- **THEN** anchor is removed and neighboring segments are reconnected if topology allows
### Requirement: Align selected anchors relative to each other
When two or more anchors are selected in curve edit mode, alignment buttons in the position panel SHALL reposition those anchors relative to each other instead of operating on the parent node.
#### Scenario: Align left (min X)
- **WHEN** 2+ anchors are selected and user clicks Align Left
- **THEN** all selected anchors move to the X coordinate of the leftmost anchor
- **AND** unselected anchors and tangents are unaffected
#### Scenario: Align right (max X)
- **WHEN** 2+ anchors are selected and user clicks Align Right
- **THEN** all selected anchors move to the X coordinate of the rightmost anchor
- **AND** unselected anchors and tangents are unaffected
#### Scenario: Align center horizontally
- **WHEN** 2+ anchors are selected and user clicks Align Center Horizontally
- **THEN** all selected anchors move to the midpoint X between leftmost and rightmost selected anchor
- **AND** unselected anchors and tangents are unaffected
#### Scenario: Align top (min Y)
- **WHEN** 2+ anchors are selected and user clicks Align Top
- **THEN** all selected anchors move to the Y coordinate of the topmost anchor
- **AND** unselected anchors and tangents are unaffected
#### Scenario: Align bottom (max Y)
- **WHEN** 2+ anchors are selected and user clicks Align Bottom
- **THEN** all selected anchors move to the Y coordinate of the bottommost anchor
- **AND** unselected anchors and tangents are unaffected
#### Scenario: Align center vertically
- **WHEN** 2+ anchors are selected and user clicks Align Center Vertically
- **THEN** all selected anchors move to the midpoint Y between topmost and bottommost selected anchor
- **AND** unselected anchors and tangents are unaffected
#### Scenario: Single anchor selected — no vertex alignment
- **WHEN** exactly 1 anchor is selected and user clicks any alignment button
- **THEN** alignment operates on the parent node as in normal selection mode
- **AND** no vertex repositioning occurs
#### Scenario: No anchors selected — no vertex alignment
- **WHEN** no anchors are selected and user clicks any alignment button
- **THEN** alignment operates on the parent node as in normal selection mode
### Requirement: Visual preview correctness during curve edit
Interactive preview in curve edit mode SHALL render active tangent feedback from anchor to active tangent endpoint.

View file

@ -223,8 +223,12 @@ export function createShapeActions(ctx: EditorContext) {
: []
const network: VectorNetwork = {
vertices: ps.vertices,
segments: ps.segments,
vertices: ps.vertices.map((v) => ({ ...v })),
segments: ps.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions
}

View file

@ -25,6 +25,7 @@ export type {
export {
SceneGraph,
generateId,
cloneVectorNetwork,
type SceneNode,
type NodeType,
type Fill,

View file

@ -58,6 +58,22 @@ export interface VectorNetwork {
regions: VectorRegion[]
}
/** Deep-copy a VectorNetwork, stripping any Vue Proxy wrappers. */
export function cloneVectorNetwork(vn: VectorNetwork): VectorNetwork {
return {
vertices: vn.vertices.map((v) => ({ ...v })),
segments: vn.segments.map((s) => ({
...s,
tangentStart: { ...s.tangentStart },
tangentEnd: { ...s.tangentEnd }
})),
regions: vn.regions.map((r) => ({
windingRule: r.windingRule,
loops: r.loops.map((l) => [...l])
}))
}
}
export interface GeometryPath {
windingRule: WindingRule
commandsBlob: Uint8Array

View file

@ -1,6 +1,7 @@
import { defineTool, nodeSummary } from './schema'
import type { FigmaAPI } from '../figma-api'
import { cloneVectorNetwork } from '../scene-graph'
import type { SceneNode, VectorNetwork } from '../scene-graph'
function getVectorNode(
@ -10,7 +11,7 @@ function getVectorNode(
const node = figma.graph.getNode(id)
if (!node) return { error: `Node "${id}" not found` }
if (!node.vectorNetwork) return { error: `Node "${id}" has no vector data` }
return { node, vn: structuredClone(node.vectorNetwork) }
return { node, vn: cloneVectorNetwork(node.vectorNetwork) }
}
const CHUNK_SIZE = 0x8000

View file

@ -28,22 +28,14 @@ export function duplicateAndDrag(
for (const id of editor.state.selectedIds) {
const src = editor.graph.getNode(id)
if (!src) continue
const newId = editor.createShape(src.type, src.x, src.y, src.width, src.height)
editor.graph.updateNode(newId, {
name: src.name + ' copy',
fills: [...src.fills],
strokes: [...src.strokes],
effects: [...src.effects],
cornerRadius: src.cornerRadius,
opacity: src.opacity,
rotation: src.rotation
})
newIds.push(newId)
const newNode = editor.graph.getNode(newId)
newOriginals.set(newId, {
const parentId = src.parentId ?? editor.state.currentPageId
const clone = editor.graph.cloneTree(id, parentId, { name: src.name + ' copy' })
if (!clone) continue
newIds.push(clone.id)
newOriginals.set(clone.id, {
x: src.x,
y: src.y,
parentId: newNode?.parentId ?? editor.state.currentPageId
parentId
})
}
editor.select(newIds)

View file

@ -1,6 +1,7 @@
import { hitTestHandle } from './geometry'
import type { DragResize, HandlePosition } from './types'
import { cloneVectorNetwork } from '@open-pencil/core'
import type { Rect, SceneNode } from '@open-pencil/core'
import type { Editor } from '@open-pencil/core/editor'
@ -140,7 +141,7 @@ export function tryStartResize(
startY: cy,
origRect: { x: node.x, y: node.y, width: node.width, height: node.height },
nodeId: id,
origVectorNetwork: node.vectorNetwork ? structuredClone(node.vectorNetwork) : null
origVectorNetwork: node.vectorNetwork ? cloneVectorNetwork(node.vectorNetwork) : null
}
}
}

View file

@ -3,9 +3,24 @@ import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { iconButton } from '@/components/ui/icon-button'
import { sectionWrapper } from '@/components/ui/section'
import { useEditorStore } from '@/stores/editor'
import { PositionControlsRoot, useI18n } from '@open-pencil/vue'
const { panels } = useI18n()
const store = useEditorStore()
function handleAlign(
nodeAlign: (axis: 'horizontal' | 'vertical', pos: 'min' | 'center' | 'max') => void,
axis: 'horizontal' | 'vertical',
pos: 'min' | 'center' | 'max'
) {
const es = store.state.nodeEditState
if (es && es.selectedVertexIndices.size >= 2) {
store.nodeEditAlignVertices(axis, pos)
} else {
nodeAlign(axis, pos)
}
}
</script>
<template>
@ -34,7 +49,7 @@ const { panels } = useI18n()
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-left"
@click="align('horizontal', 'min')"
@click="handleAlign(align, 'horizontal', 'min')"
>
<icon-lucide-align-horizontal-justify-start class="size-3.5" />
</button>
@ -43,7 +58,7 @@ const { panels } = useI18n()
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-center-h"
@click="align('horizontal', 'center')"
@click="handleAlign(align, 'horizontal', 'center')"
>
<icon-lucide-align-horizontal-justify-center class="size-3.5" />
</button>
@ -52,7 +67,7 @@ const { panels } = useI18n()
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-right"
@click="align('horizontal', 'max')"
@click="handleAlign(align, 'horizontal', 'max')"
>
<icon-lucide-align-horizontal-justify-end class="size-3.5" />
</button>
@ -63,7 +78,7 @@ const { panels } = useI18n()
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-top"
@click="align('vertical', 'min')"
@click="handleAlign(align, 'vertical', 'min')"
>
<icon-lucide-align-vertical-justify-start class="size-3.5" />
</button>
@ -72,7 +87,7 @@ const { panels } = useI18n()
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-center-v"
@click="align('vertical', 'center')"
@click="handleAlign(align, 'vertical', 'center')"
>
<icon-lucide-align-vertical-justify-center class="size-3.5" />
</button>
@ -81,7 +96,7 @@ const { panels } = useI18n()
<button
:class="iconButton({ size: 'md' })"
data-test-id="position-align-bottom"
@click="align('vertical', 'max')"
@click="handleAlign(align, 'vertical', 'max')"
>
<icon-lucide-align-vertical-justify-end class="size-3.5" />
</button>

View file

@ -6,6 +6,7 @@ import { loadFont } from '@/engine/fonts'
import { toast } from '@/utils/toast'
import {
breakAtVertex,
cloneVectorNetwork,
computeAccurateBounds,
createDefaultEditorState,
createEditor,
@ -454,7 +455,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
state.nodeEditState = {
nodeId,
origNetwork: structuredClone(node.vectorNetwork),
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) => ({
@ -495,7 +496,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
y: es.origBounds.y,
width: es.origBounds.width,
height: es.origBounds.height,
vectorNetwork: structuredClone(es.origNetwork)
vectorNetwork: cloneVectorNetwork(es.origNetwork)
})
editor.requestRender()
}
@ -773,6 +774,28 @@ export function createEditorStore(initialGraph?: SceneGraph) {
editor.requestRender()
}
function nodeEditAlignVertices(axis: 'horizontal' | 'vertical', align: 'min' | 'center' | 'max') {
const es = getNodeEditState()
if (!es || es.selectedVertexIndices.size < 2) return
const indices = [...es.selectedVertexIndices]
const prop = axis === 'horizontal' ? 'x' : 'y'
let lo = Infinity
let hi = -Infinity
for (const i of indices) {
const v = es.vertices[i][prop]
if (v < lo) lo = v
if (v > hi) hi = v
}
const target = align === 'min' ? lo : (align === 'max' ? hi : (lo + hi) / 2)
for (const i of indices) {
es.vertices[i] = { ...es.vertices[i], [prop]: target }
}
editor.requestRepaint()
}
function nodeEditDeleteSelected() {
const es = getNodeEditState()
if (!es) return
@ -1174,6 +1197,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
nodeEditConnectEndpoints,
nodeEditAddVertex,
nodeEditRemoveVertex,
nodeEditAlignVertices,
nodeEditDeleteSelected,
nodeEditBreakAtVertex,
openFigFile,