openpencil/tests/engine/geometry/arrow-caps.test.ts
Ed-Key 9b48e47329 feat(canvas): render arrow stroke caps on lines and open vectors
ARROW_LINES and ARROW_EQUILATERAL stroke caps were accepted by the schema
and round-tripped from .fig files, but the renderer mapped every cap to
Butt, so imported arrows lost their heads and arrows could not be drawn
locally.

Add a pure arrow-cap geometry module to scene-graph (open-endpoint
collection with per-vertex cap overrides, weight-scaled head geometry)
and draw heads in the stroke color for LINE nodes and open VECTOR
centerline strokes, including the dashed-centerline path. Fold the head
reach into strokeOverflow and the node picture margin so heads are not
cropped by visual bounds, caches, or culling. Round-trip per-vertex
stroke caps through the .fig vector style override table so one-ended
Figma arrows import correctly. Expose both caps in the stroke inspector
cap picker with localized labels, and cover the renderer with an
arrow-stroke-caps Playwright canvas snapshot.

Claude-Session: https://claude.ai/code/session_012zjEbyLSBYyPpJP4fu1XNB
2026-08-31 21:25:38 +03:00

290 lines
9.8 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import type { Stroke, VectorNetwork } from '@open-pencil/scene-graph'
import type { ArrowEndpoint } from '@open-pencil/scene-graph/arrow-caps'
import {
arrowCapOverflow,
arrowLinesSegments,
collectArrowEndpoints,
equilateralArrowPoints,
isArrowCap,
lineArrowEndpoints
} from '@open-pencil/scene-graph/arrow-caps'
import { strokeOverflow } from '@open-pencil/scene-graph/geometry'
const SQRT3 = Math.sqrt(3)
function endpointAt(endpoints: ArrowEndpoint[], x: number): ArrowEndpoint {
const match = endpoints.find((endpoint) => endpoint.x === x)
if (!match) throw new Error(`no endpoint at x=${x}`)
return match
}
function straightOpenLine(): VectorNetwork {
return {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
}
}
describe('isArrowCap', () => {
test('recognizes both arrow cap kinds and nothing else', () => {
expect(isArrowCap('ARROW_LINES')).toBe(true)
expect(isArrowCap('ARROW_EQUILATERAL')).toBe(true)
expect(isArrowCap('ROUND')).toBe(false)
expect(isArrowCap('SQUARE')).toBe(false)
expect(isArrowCap('NONE')).toBe(false)
expect(isArrowCap(undefined)).toBe(false)
})
})
describe('collectArrowEndpoints', () => {
test('returns both open ends of a straight segment pointing outward', () => {
const endpoints = collectArrowEndpoints(straightOpenLine(), 'ARROW_EQUILATERAL')
expect(endpoints).toHaveLength(2)
const start = endpointAt(endpoints, 0)
const end = endpointAt(endpoints, 100)
expect(start.y).toBe(0)
expect(Math.abs(start.angle)).toBeCloseTo(Math.PI, 5)
expect(start.cap).toBe('ARROW_EQUILATERAL')
expect(end.y).toBe(0)
expect(end.angle).toBeCloseTo(0, 5)
expect(end.cap).toBe('ARROW_EQUILATERAL')
})
test('returns nothing when the effective cap is not an arrow', () => {
expect(collectArrowEndpoints(straightOpenLine(), 'ROUND')).toHaveLength(0)
expect(collectArrowEndpoints(straightOpenLine(), 'NONE')).toHaveLength(0)
})
test('per-vertex caps override the fallback cap', () => {
const network = straightOpenLine()
network.vertices[0] = { x: 0, y: 0, strokeCap: 'ROUND' }
const endpoints = collectArrowEndpoints(network, 'ARROW_LINES')
expect(endpoints).toHaveLength(1)
expect(endpoints[0].x).toBe(100)
expect(endpoints[0].cap).toBe('ARROW_LINES')
})
test('a per-vertex arrow cap applies even when the fallback is NONE', () => {
const network = straightOpenLine()
network.vertices[1] = { x: 100, y: 0, strokeCap: 'ARROW_EQUILATERAL' }
const endpoints = collectArrowEndpoints(network, 'NONE')
expect(endpoints).toHaveLength(1)
expect(endpoints[0].x).toBe(100)
expect(endpoints[0].cap).toBe('ARROW_EQUILATERAL')
})
test('closed loops have no arrow endpoints', () => {
const closedTriangle: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 50, y: 80 }
],
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: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: []
}
expect(collectArrowEndpoints(closedTriangle, 'ARROW_EQUILATERAL')).toHaveLength(0)
})
test('interior polyline vertices are not endpoints', () => {
const polyline: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 50, y: 0 },
{ x: 50, y: 40 }
],
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 } }
],
regions: []
}
const endpoints = collectArrowEndpoints(polyline, 'ARROW_LINES')
expect(endpoints).toHaveLength(2)
const coordinates = endpoints.map((endpoint) => [endpoint.x, endpoint.y]).sort()
expect(coordinates).toEqual([
[0, 0],
[50, 40]
])
})
test('curved terminal segments take their angle from the bezier tangent', () => {
const curve: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 20 }, tangentEnd: { x: 0, y: -20 } }],
regions: []
}
const endpoints = collectArrowEndpoints(curve, 'ARROW_EQUILATERAL')
const start = endpointAt(endpoints, 0)
const end = endpointAt(endpoints, 100)
expect(start.angle).toBeCloseTo(-Math.PI / 2, 5)
expect(end.angle).toBeCloseTo(Math.PI / 2, 5)
})
})
describe('equilateralArrowPoints', () => {
test('builds a weight-scaled equilateral head with the tip at the endpoint', () => {
const [tip, left, right] = equilateralArrowPoints(10, 5, 0, 1)
expect(tip.x).toBeCloseTo(10, 5)
expect(tip.y).toBeCloseTo(5, 5)
expect(left.x).toBeCloseTo(10 - 2 * SQRT3, 5)
expect(left.y).toBeCloseTo(5 - 2, 5)
expect(right.x).toBeCloseTo(10 - 2 * SQRT3, 5)
expect(right.y).toBeCloseTo(5 + 2, 5)
})
test('rotates with the endpoint angle', () => {
const [tip, left, right] = equilateralArrowPoints(0, 0, Math.PI / 2, 1)
expect(tip.x).toBeCloseTo(0, 5)
expect(tip.y).toBeCloseTo(0, 5)
expect(left.y).toBeCloseTo(-2 * SQRT3, 5)
expect(right.y).toBeCloseTo(-2 * SQRT3, 5)
expect([left.x, right.x].sort((a, b) => a - b)[0]).toBeCloseTo(-2, 5)
expect([left.x, right.x].sort((a, b) => a - b)[1]).toBeCloseTo(2, 5)
})
test('scales linearly with stroke weight', () => {
const [, left] = equilateralArrowPoints(0, 0, 0, 3)
expect(left.x).toBeCloseTo(-6 * SQRT3, 5)
expect(left.y).toBeCloseTo(-6, 5)
})
})
describe('arrowLinesSegments', () => {
test('builds two wings sweeping back from the tip', () => {
const [first, second] = arrowLinesSegments(10, 5, 0, 1)
expect(first.from.x).toBeCloseTo(10, 5)
expect(first.from.y).toBeCloseTo(5, 5)
expect(second.from.x).toBeCloseTo(10, 5)
expect(second.from.y).toBeCloseTo(5, 5)
const tips = [first.to, second.to].sort((a, b) => a.y - b.y)
expect(tips[0].x).toBeCloseTo(10 - 2 * SQRT3, 5)
expect(tips[0].y).toBeCloseTo(5 - 2, 5)
expect(tips[1].x).toBeCloseTo(10 - 2 * SQRT3, 5)
expect(tips[1].y).toBeCloseTo(5 + 2, 5)
})
test('scales linearly with stroke weight', () => {
const [first] = arrowLinesSegments(0, 0, 0, 2)
const length = Math.hypot(first.to.x - first.from.x, first.to.y - first.from.y)
expect(length).toBeCloseTo(8, 5)
})
})
describe('lineArrowEndpoints', () => {
test('a horizontal line gets outward endpoints at both ends', () => {
const endpoints = lineArrowEndpoints(100, 0, 'ARROW_EQUILATERAL')
expect(endpoints).toHaveLength(2)
const start = endpointAt(endpoints, 0)
const end = endpointAt(endpoints, 100)
expect(Math.abs(start.angle)).toBeCloseTo(Math.PI, 5)
expect(end.angle).toBeCloseTo(0, 5)
})
test('a diagonal line points its endpoints along the line direction', () => {
const endpoints = lineArrowEndpoints(30, 40, 'ARROW_LINES')
const end = endpointAt(endpoints, 30)
expect(end.y).toBe(40)
expect(end.angle).toBeCloseTo(Math.atan2(40, 30), 5)
})
test('returns nothing for non-arrow caps', () => {
expect(lineArrowEndpoints(100, 0, 'ROUND')).toHaveLength(0)
expect(lineArrowEndpoints(100, 0, 'NONE')).toHaveLength(0)
})
})
function stroke(overrides: Partial<Stroke> = {}): Stroke {
return {
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 4,
opacity: 1,
visible: true,
align: 'CENTER',
...overrides
}
}
describe('arrowCapOverflow', () => {
test('an arrow cap reserves the full head reach plus wing stroke width', () => {
expect(arrowCapOverflow([stroke({ cap: 'ARROW_EQUILATERAL', weight: 3 })])).toBe(13.5)
expect(arrowCapOverflow([stroke({ cap: 'ARROW_LINES', weight: 2 })])).toBe(9)
})
test('the fallback cap applies when the stroke has none', () => {
expect(arrowCapOverflow([stroke({ weight: 2 })], 'ARROW_LINES')).toBe(9)
})
test('a vertex-only arrow cap still reserves overflow', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0, strokeCap: 'ARROW_EQUILATERAL' }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
}
expect(arrowCapOverflow([stroke({ weight: 2 })], 'NONE', network)).toBe(9)
expect(arrowCapOverflow([stroke({ weight: 2 })], 'NONE')).toBe(0)
})
test('an explicit non-arrow cap beats an arrow fallback', () => {
expect(arrowCapOverflow([stroke({ cap: 'ROUND' })], 'ARROW_EQUILATERAL')).toBe(0)
})
test('invisible and capless strokes contribute nothing', () => {
expect(arrowCapOverflow([stroke({ cap: 'ARROW_LINES', visible: false })])).toBe(0)
expect(arrowCapOverflow([stroke()])).toBe(0)
expect(arrowCapOverflow(undefined)).toBe(0)
})
})
describe('strokeOverflow with arrow caps', () => {
test('arrow heads widen the stroke overflow past the weight pad', () => {
expect(strokeOverflow([stroke({ cap: 'ARROW_EQUILATERAL' })])).toBe(18)
expect(strokeOverflow([stroke({ weight: 4 })], 'ARROW_LINES')).toBe(18)
})
test('a vertex-only arrow cap widens overflow through the network parameter', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0, strokeCap: 'ARROW_LINES' },
{ x: 100, y: 0 }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
}
expect(strokeOverflow([stroke({ weight: 4 })], 'NONE', network)).toBe(18)
})
test('non-arrow strokes keep the align-based overflow', () => {
expect(strokeOverflow([stroke()])).toBe(2)
})
})