Unify preview-aware transforms across scene drawing, labels, selection and input. Give live property and creation edits explicit preview ownership, preserve cancellation and one-step undo, and reuse bounded text preparation resources. Include retained device-grid handling, worktree HMR coverage, and nested/reflected interaction regressions in this cohesive progress checkpoint. Validation: full check passes; 734 scoped unit tests and 53 targeted browser tests pass. This is NOT merge-ready: the unchanged exact nested filled-section held/released regression still fails with 63 differing pixels (maximum channel delta 5/255). The browser pass count excludes that separately run failure and the previously classified paint-field-width baseline. Raster-origin investigation and broader acceptance remain outstanding.
171 lines
4.9 KiB
TypeScript
171 lines
4.9 KiB
TypeScript
import { describe, expect, mock, test } from 'bun:test'
|
|
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
import Matrix, { type Mat3 } from '@open-pencil/scene-graph/matrix'
|
|
|
|
import type { SkiaRenderer } from '#core/canvas/renderer'
|
|
import { renderNode } from '#core/canvas/scene'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
function pageId(graph: SceneGraph) {
|
|
return graph.getPages()[0].id
|
|
}
|
|
|
|
function createCanvas() {
|
|
return {
|
|
save: mock(() => undefined),
|
|
restore: mock(() => undefined),
|
|
translate: mock(() => undefined),
|
|
concat: mock((_matrix: Mat3) => undefined),
|
|
rotate: mock(() => undefined),
|
|
scale: mock(() => undefined),
|
|
saveLayer: mock(() => undefined),
|
|
clipRect: mock(() => undefined),
|
|
clipRRect: mock(() => undefined)
|
|
}
|
|
}
|
|
|
|
function createRenderer() {
|
|
const rendered: string[] = []
|
|
const renderer = {
|
|
_nodeCount: 0,
|
|
_culledCount: 0,
|
|
worldViewport: { x: 900, y: 900, w: 300, h: 300 },
|
|
opacityPaint: { setAlphaf: mock(() => undefined) },
|
|
effectLayerPaint: {
|
|
setImageFilter: mock(() => undefined),
|
|
setColorFilter: mock(() => undefined),
|
|
setBlendMode: mock(() => undefined)
|
|
},
|
|
ck: {
|
|
BlendMode: { SrcOver: 'SrcOver' },
|
|
LTRBRect: mock((left: number, top: number, right: number, bottom: number) => [
|
|
left,
|
|
top,
|
|
right,
|
|
bottom
|
|
]),
|
|
ClipOp: { Intersect: 'Intersect' }
|
|
},
|
|
getCachedBlur: mock(() => null),
|
|
renderShape: mock((_canvas, node) => {
|
|
rendered.push(node.id)
|
|
}),
|
|
renderSection: mock((_canvas, node) => {
|
|
rendered.push(node.id)
|
|
}),
|
|
renderComponentSet: mock((_canvas, node) => {
|
|
rendered.push(node.id)
|
|
}),
|
|
renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY, hasTransformedAncestor) {
|
|
renderNode(
|
|
this as SkiaRenderer,
|
|
canvas,
|
|
graph,
|
|
nodeId,
|
|
overlays,
|
|
parentAbsX,
|
|
parentAbsY,
|
|
hasTransformedAncestor
|
|
)
|
|
}
|
|
}
|
|
return { renderer: renderer as SkiaRenderer, rendered }
|
|
}
|
|
|
|
describe('canvas culling', () => {
|
|
test('uses accumulated absolute position for nested children', () => {
|
|
const graph = new SceneGraph()
|
|
const frame = graph.createNode('FRAME', pageId(graph), {
|
|
x: 1000,
|
|
y: 1000,
|
|
width: 200,
|
|
height: 200
|
|
})
|
|
const text = graph.createNode('TEXT', frame.id, {
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 24,
|
|
text: 'Visible nested text'
|
|
})
|
|
const { renderer, rendered } = createRenderer()
|
|
|
|
renderNode(renderer, createCanvas(), graph, frame.id, {})
|
|
|
|
expect(rendered).toContain(frame.id)
|
|
expect(rendered).toContain(text.id)
|
|
expect(renderer._culledCount).toBe(0)
|
|
})
|
|
|
|
test('uses world bounds for children of transformed instances', () => {
|
|
const graph = new SceneGraph()
|
|
const instance = graph.createNode('INSTANCE', pageId(graph), {
|
|
x: 1000,
|
|
y: 1000,
|
|
width: 100,
|
|
height: 800,
|
|
rotation: 90
|
|
})
|
|
const connector = graph.createNode('VECTOR', instance.id, {
|
|
x: 0,
|
|
y: 700,
|
|
width: 100,
|
|
height: 20
|
|
})
|
|
const { renderer, rendered } = createRenderer()
|
|
renderer.worldViewport = { x: 700, y: 1300, w: 300, h: 300 }
|
|
|
|
renderNode(renderer, createCanvas(), graph, instance.id, {})
|
|
|
|
expect(rendered).toContain(connector.id)
|
|
expect(renderer._culledCount).toBe(0)
|
|
})
|
|
|
|
test('culling follows an ancestor rotation preview before the graph commits', () => {
|
|
const graph = new SceneGraph()
|
|
const instance = graph.createNode('INSTANCE', pageId(graph), {
|
|
x: 1000,
|
|
y: 1000,
|
|
width: 100,
|
|
height: 800
|
|
})
|
|
const connector = graph.createNode('VECTOR', instance.id, {
|
|
x: 0,
|
|
y: 700,
|
|
width: 100,
|
|
height: 20
|
|
})
|
|
const { renderer, rendered } = createRenderer()
|
|
renderer.worldViewport = { x: 700, y: 1300, w: 300, h: 300 }
|
|
renderNode(renderer, createCanvas(), graph, instance.id, {
|
|
rotationPreview: { nodeId: instance.id, angle: 90 }
|
|
})
|
|
expect(rendered).toContain(connector.id)
|
|
expect(instance.rotation).toBe(0)
|
|
})
|
|
|
|
test('applies reflection before rotation like the scene transform matrix', () => {
|
|
const graph = new SceneGraph()
|
|
const vector = graph.createNode('VECTOR', pageId(graph), {
|
|
width: 100,
|
|
height: 50,
|
|
rotation: 90,
|
|
flipX: true
|
|
})
|
|
const { renderer } = createRenderer()
|
|
renderer.worldViewport = { x: -100, y: -100, w: 300, h: 300 }
|
|
const canvas = createCanvas()
|
|
renderNode(renderer, canvas, graph, vector.id, {})
|
|
|
|
const matrix = expectDefined(canvas.concat.mock.calls[0]?.[0], 'drawing transform')
|
|
const start = Matrix.mapPoint(matrix, { x: 0, y: 0 })
|
|
const end = Matrix.mapPoint(matrix, { x: 100, y: 50 })
|
|
expect(start.x).toBeCloseTo(25, 9)
|
|
expect(start.y).toBeCloseTo(-25, 9)
|
|
expect(end.x).toBeCloseTo(75, 9)
|
|
expect(end.y).toBeCloseTo(75, 9)
|
|
})
|
|
})
|