460 lines
13 KiB
TypeScript
460 lines
13 KiB
TypeScript
import { PEN_HANDLE_RADIUS, PEN_VERTEX_RADIUS } from '../constants'
|
|
import { vectorNetworkToPath } from '../vector'
|
|
import { computeAccurateBounds } from '../vector/bezier'
|
|
|
|
import type { VectorVertex, VectorSegment, VectorRegion, SceneGraph } from '../scene-graph'
|
|
import type { Vector } from '../types'
|
|
import type { SkiaRenderer, RenderOverlays } from './renderer'
|
|
import type { Canvas, Paint } from 'canvaskit-wasm'
|
|
|
|
type ToScreenFn = (x: number, y: number) => Vector
|
|
|
|
type HandleInfo =
|
|
| { segmentIndex: number; tangentField: 'tangentStart' | 'tangentEnd' }
|
|
| null
|
|
| undefined
|
|
|
|
export interface NodeEditOverlayState {
|
|
nodeId: string
|
|
vertices: VectorVertex[]
|
|
segments: VectorSegment[]
|
|
regions: VectorRegion[]
|
|
selectedVertexIndices: Set<number>
|
|
selectedHandles?: Set<string>
|
|
hoveredHandleInfo?: HandleInfo
|
|
}
|
|
|
|
// Blue = rgb(59,130,246) ≈ 0.23, 0.51, 0.96
|
|
// Light blue (hover) = rgb(96,165,250) ≈ 0.376, 0.647, 0.98
|
|
const BLUE = [0.23, 0.51, 0.96] as const
|
|
const LIGHT_BLUE = [0.376, 0.647, 0.98] as const
|
|
|
|
/** Compute set of vertices whose handles should be visible: selected vertices + vertices with selected handles + their direct neighbors */
|
|
function computeHandleVisibleVertices(
|
|
selectedVertexIndices: Set<number>,
|
|
selectedHandles: Set<string>,
|
|
segments: VectorSegment[]
|
|
): Set<number> {
|
|
// Seed: selected vertices + vertices that own a selected handle
|
|
const seed = new Set(selectedVertexIndices)
|
|
for (const key of selectedHandles) {
|
|
const [siStr, tf] = key.split(':')
|
|
const seg = segments[Number(siStr)]
|
|
seed.add(tf === 'tangentStart' ? seg.start : seg.end)
|
|
}
|
|
// Expand: add only direct neighbors of seed vertices (no cascading)
|
|
const visible = new Set(seed)
|
|
for (const seg of segments) {
|
|
if (seed.has(seg.start)) visible.add(seg.end)
|
|
if (seed.has(seg.end)) visible.add(seg.start)
|
|
}
|
|
return visible
|
|
}
|
|
|
|
export function drawNodeEditOverlay(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
graph: SceneGraph,
|
|
editState: RenderOverlays['nodeEditState']
|
|
): void {
|
|
if (!editState) return
|
|
const { segments, selectedVertexIndices } = editState
|
|
const vertices = editState.vertices
|
|
const regions = editState.regions
|
|
|
|
if (vertices.length === 0) return
|
|
|
|
ensureNodeEditPaints(r)
|
|
|
|
const toScreen: ToScreenFn = (x, y) => ({
|
|
x: x * r.zoom + r.panX,
|
|
y: y * r.zoom + r.panY
|
|
})
|
|
|
|
const selectedHandles = editState.selectedHandles ?? new Set<string>()
|
|
const hovered = editState.hoveredHandleInfo ?? null
|
|
const handleVisibleVertices = computeHandleVisibleVertices(
|
|
selectedVertexIndices,
|
|
selectedHandles,
|
|
segments
|
|
)
|
|
|
|
// Draw the node shape with its original fills/strokes/effects using live vertices
|
|
drawLiveShape(r, canvas, graph, editState.nodeId, vertices, segments, regions)
|
|
|
|
// Draw technical stroke outline over the shape
|
|
drawTechStroke(r, canvas, vertices, segments, regions)
|
|
|
|
// Draw tangent handle lines + diamonds for vertices with visible handles
|
|
drawEditHandles(
|
|
r,
|
|
canvas,
|
|
vertices,
|
|
segments,
|
|
handleVisibleVertices,
|
|
toScreen,
|
|
selectedHandles,
|
|
hovered
|
|
)
|
|
|
|
// Draw vertex circles
|
|
drawEditVertices(r, canvas, vertices, selectedVertexIndices, toScreen)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live shape rendering with full styles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function drawLiveShape(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
graph: SceneGraph,
|
|
nodeId: string,
|
|
vertices: VectorVertex[],
|
|
segments: VectorSegment[],
|
|
regions: VectorRegion[]
|
|
): void {
|
|
const node = graph.getNode(nodeId)
|
|
if (!node) return
|
|
|
|
// Compute bounds from live (absolute) vertices
|
|
const liveNetwork = { vertices, segments, regions }
|
|
const bounds = computeAccurateBounds(liveNetwork)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Temporarily patch the node so renderShapeUncached uses our live network
|
|
const origNetwork = node.vectorNetwork
|
|
const origX = node.x
|
|
const origY = node.y
|
|
const origW = node.width
|
|
const origH = node.height
|
|
|
|
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.
|
|
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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lazy paint cache
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const paintCache = new WeakMap<
|
|
SkiaRenderer,
|
|
{
|
|
handleLinePaint: Paint
|
|
handleLineSelectedPaint: Paint
|
|
handleLineHoverPaint: Paint
|
|
vertexStroke1px: Paint
|
|
vertexSelectedFill: Paint
|
|
handleSelectedFill: Paint
|
|
handleSelectedStroke: Paint
|
|
handleHoverFill: Paint
|
|
handleHoverStroke: Paint
|
|
techStrokePaint: Paint
|
|
}
|
|
>()
|
|
|
|
function ensureNodeEditPaints(r: SkiaRenderer) {
|
|
if (paintCache.has(r)) return
|
|
const ck = r.ck
|
|
|
|
// Default handle line — gray
|
|
const handleLinePaint = new ck.Paint()
|
|
handleLinePaint.setStyle(ck.PaintStyle.Stroke)
|
|
handleLinePaint.setStrokeWidth(1)
|
|
handleLinePaint.setColor(ck.Color4f(0.6, 0.6, 0.6, 1))
|
|
handleLinePaint.setAntiAlias(true)
|
|
|
|
// Selected handle line — blue
|
|
const handleLineSelectedPaint = new ck.Paint()
|
|
handleLineSelectedPaint.setStyle(ck.PaintStyle.Stroke)
|
|
handleLineSelectedPaint.setStrokeWidth(1)
|
|
handleLineSelectedPaint.setColor(ck.Color4f(BLUE[0], BLUE[1], BLUE[2], 1))
|
|
handleLineSelectedPaint.setAntiAlias(true)
|
|
|
|
// Hover handle line — light blue
|
|
const handleLineHoverPaint = new ck.Paint()
|
|
handleLineHoverPaint.setStyle(ck.PaintStyle.Stroke)
|
|
handleLineHoverPaint.setStrokeWidth(1)
|
|
handleLineHoverPaint.setColor(ck.Color4f(LIGHT_BLUE[0], LIGHT_BLUE[1], LIGHT_BLUE[2], 1))
|
|
handleLineHoverPaint.setAntiAlias(true)
|
|
|
|
// Vertex stroke — blue 1px
|
|
const vertexStroke1px = new ck.Paint()
|
|
vertexStroke1px.setStyle(ck.PaintStyle.Stroke)
|
|
vertexStroke1px.setStrokeWidth(1)
|
|
vertexStroke1px.setColor(ck.Color4f(BLUE[0], BLUE[1], BLUE[2], 1))
|
|
vertexStroke1px.setAntiAlias(true)
|
|
|
|
// Vertex selected fill — blue
|
|
const vertexSelectedFill = new ck.Paint()
|
|
vertexSelectedFill.setStyle(ck.PaintStyle.Fill)
|
|
vertexSelectedFill.setColor(ck.Color4f(BLUE[0], BLUE[1], BLUE[2], 1))
|
|
vertexSelectedFill.setAntiAlias(true)
|
|
|
|
// Selected handle diamond — blue fill
|
|
const handleSelectedFill = new ck.Paint()
|
|
handleSelectedFill.setStyle(ck.PaintStyle.Fill)
|
|
handleSelectedFill.setColor(ck.Color4f(BLUE[0], BLUE[1], BLUE[2], 1))
|
|
handleSelectedFill.setAntiAlias(true)
|
|
|
|
// Selected handle diamond — white outset stroke 3px
|
|
const handleSelectedStroke = new ck.Paint()
|
|
handleSelectedStroke.setStyle(ck.PaintStyle.Stroke)
|
|
handleSelectedStroke.setStrokeWidth(3)
|
|
handleSelectedStroke.setColor(ck.Color4f(1, 1, 1, 1))
|
|
handleSelectedStroke.setAntiAlias(true)
|
|
|
|
// Hover handle diamond — light blue fill
|
|
const handleHoverFill = new ck.Paint()
|
|
handleHoverFill.setStyle(ck.PaintStyle.Fill)
|
|
handleHoverFill.setColor(ck.Color4f(LIGHT_BLUE[0], LIGHT_BLUE[1], LIGHT_BLUE[2], 1))
|
|
handleHoverFill.setAntiAlias(true)
|
|
|
|
// Hover handle diamond — white outset stroke 3px
|
|
const handleHoverStroke = new ck.Paint()
|
|
handleHoverStroke.setStyle(ck.PaintStyle.Stroke)
|
|
handleHoverStroke.setStrokeWidth(3)
|
|
handleHoverStroke.setColor(ck.Color4f(1, 1, 1, 1))
|
|
handleHoverStroke.setAntiAlias(true)
|
|
|
|
// Technical stroke: rgb(178,178,178) = 0.698
|
|
const techStrokePaint = new ck.Paint()
|
|
techStrokePaint.setStyle(ck.PaintStyle.Stroke)
|
|
techStrokePaint.setStrokeWidth(1)
|
|
techStrokePaint.setColor(ck.Color4f(0.698, 0.698, 0.698, 1))
|
|
techStrokePaint.setAntiAlias(true)
|
|
|
|
paintCache.set(r, {
|
|
handleLinePaint,
|
|
handleLineSelectedPaint,
|
|
handleLineHoverPaint,
|
|
vertexStroke1px,
|
|
vertexSelectedFill,
|
|
handleSelectedFill,
|
|
handleSelectedStroke,
|
|
handleHoverFill,
|
|
handleHoverStroke,
|
|
techStrokePaint
|
|
})
|
|
}
|
|
|
|
function getNodeEditPaints(r: SkiaRenderer) {
|
|
const paints = paintCache.get(r)
|
|
if (!paints) {
|
|
throw new Error('Node edit paints are not initialized')
|
|
}
|
|
return paints
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Technical stroke outline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function drawTechStroke(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
vertices: VectorVertex[],
|
|
segments: VectorSegment[],
|
|
regions: VectorRegion[]
|
|
): void {
|
|
const { techStrokePaint } = getNodeEditPaints(r)
|
|
const network = { vertices, segments, regions }
|
|
const paths = vectorNetworkToPath(r.ck, network)
|
|
|
|
// Set stroke width to compensate for canvas scale so it's always 1px on screen
|
|
techStrokePaint.setStrokeWidth(1 / r.zoom)
|
|
|
|
canvas.save()
|
|
canvas.translate(r.panX, r.panY)
|
|
canvas.scale(r.zoom, r.zoom)
|
|
|
|
for (const p of paths) {
|
|
canvas.drawPath(p, techStrokePaint)
|
|
p.delete()
|
|
}
|
|
|
|
canvas.restore()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handle lines + diamonds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function drawEditHandles(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
vertices: VectorVertex[],
|
|
segments: VectorSegment[],
|
|
handleVisibleVertices: Set<number>,
|
|
toScreen: ToScreenFn,
|
|
selectedHandles: Set<string>,
|
|
hovered: HandleInfo
|
|
): void {
|
|
const paints = getNodeEditPaints(r)
|
|
for (let si = 0; si < segments.length; si++) {
|
|
const seg = segments[si]
|
|
drawSegmentHandle(
|
|
r,
|
|
canvas,
|
|
vertices,
|
|
seg,
|
|
si,
|
|
'tangentStart',
|
|
handleVisibleVertices,
|
|
toScreen,
|
|
selectedHandles,
|
|
hovered,
|
|
paints
|
|
)
|
|
drawSegmentHandle(
|
|
r,
|
|
canvas,
|
|
vertices,
|
|
seg,
|
|
si,
|
|
'tangentEnd',
|
|
handleVisibleVertices,
|
|
toScreen,
|
|
selectedHandles,
|
|
hovered,
|
|
paints
|
|
)
|
|
}
|
|
}
|
|
|
|
function drawSegmentHandle(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
vertices: VectorVertex[],
|
|
seg: VectorSegment,
|
|
segmentIndex: number,
|
|
tangentField: 'tangentStart' | 'tangentEnd',
|
|
handleVisibleVertices: Set<number>,
|
|
toScreen: ToScreenFn,
|
|
selectedHandles: Set<string>,
|
|
hovered: HandleInfo,
|
|
paints: ReturnType<typeof getNodeEditPaints>
|
|
): void {
|
|
const vertexIndex = tangentField === 'tangentStart' ? seg.start : seg.end
|
|
if (!handleVisibleVertices.has(vertexIndex)) return
|
|
|
|
const tangent = seg[tangentField]
|
|
if (tangent.x === 0 && tangent.y === 0) return
|
|
|
|
const key = `${segmentIndex}:${tangentField}`
|
|
const isSel = selectedHandles.has(key)
|
|
const isHov =
|
|
!isSel &&
|
|
!!hovered &&
|
|
hovered.segmentIndex === segmentIndex &&
|
|
hovered.tangentField === tangentField
|
|
|
|
let linePaint = paints.handleLinePaint
|
|
if (isSel) linePaint = paints.handleLineSelectedPaint
|
|
else if (isHov) linePaint = paints.handleLineHoverPaint
|
|
|
|
const anchor = toScreen(vertices[vertexIndex].x, vertices[vertexIndex].y)
|
|
const cp = toScreen(vertices[vertexIndex].x + tangent.x, vertices[vertexIndex].y + tangent.y)
|
|
canvas.drawLine(anchor.x, anchor.y, cp.x, cp.y, linePaint)
|
|
|
|
if (isSel) {
|
|
drawHandleDiamond(r, canvas, cp.x, cp.y, paints.handleSelectedFill, paints.handleSelectedStroke)
|
|
return
|
|
}
|
|
if (isHov) {
|
|
drawHandleDiamond(r, canvas, cp.x, cp.y, paints.handleHoverFill, paints.handleHoverStroke)
|
|
return
|
|
}
|
|
drawHandleDiamond(r, canvas, cp.x, cp.y, r.penVertexFill, paints.vertexStroke1px)
|
|
}
|
|
|
|
function drawHandleDiamond(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
x: number,
|
|
y: number,
|
|
fillPaint: Paint,
|
|
strokePaint: Paint
|
|
): void {
|
|
const s = PEN_HANDLE_RADIUS
|
|
const path = new r.ck.Path()
|
|
path.moveTo(x, y - s)
|
|
path.lineTo(x + s, y)
|
|
path.lineTo(x, y + s)
|
|
path.lineTo(x - s, y)
|
|
path.close()
|
|
|
|
// Draw stroke first (outset) then fill on top
|
|
canvas.drawPath(path, strokePaint)
|
|
canvas.drawPath(path, fillPaint)
|
|
path.delete()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vertex circles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function drawEditVertices(
|
|
r: SkiaRenderer,
|
|
canvas: Canvas,
|
|
vertices: VectorVertex[],
|
|
selectedVertexIndices: Set<number>,
|
|
toScreen: ToScreenFn
|
|
): void {
|
|
const vertexFill = r.penVertexFill
|
|
const { vertexStroke1px, vertexSelectedFill } = getNodeEditPaints(r)
|
|
|
|
for (let i = 0; i < vertices.length; i++) {
|
|
const v = toScreen(vertices[i].x, vertices[i].y)
|
|
const radius = PEN_VERTEX_RADIUS
|
|
|
|
if (selectedVertexIndices.has(i)) {
|
|
canvas.drawCircle(v.x, v.y, radius, vertexSelectedFill)
|
|
canvas.drawCircle(v.x, v.y, radius, vertexStroke1px)
|
|
} else {
|
|
canvas.drawCircle(v.x, v.y, radius, vertexFill)
|
|
canvas.drawCircle(v.x, v.y, radius, vertexStroke1px)
|
|
}
|
|
}
|
|
}
|