openpencil/tests/engine/io/fig/export/rotation-transform.test.ts
2026-08-17 12:00:01 +03:00

114 lines
4.5 KiB
TypeScript

/**
* Rotated nodes must survive an export → reimport with their world position
* intact. computeExportTransform (serialize.ts) encodes the .fig transform and
* must be the exact inverse of the import decode (convert.ts), which treats an
* unflipped rotation as about the node CENTER. A prior AABB-min-corner encode
* only matched the rotation==0 / flipped branch, so every rotated node drifted
* on reimport (and a rotated text-on-path sticker slid out of its frame clip).
*/
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { SceneNode } from '@open-pencil/scene-graph'
import { getWorldMatrix } from '@open-pencil/scene-graph/coordinate'
import Matrix from '@open-pencil/scene-graph/matrix'
import { exportFigFile } from '#core/io/formats/fig/export'
import { parseFigFile } from '#core/io/formats/fig/read'
import { loadFigFixture, readFigFixture } from '#tests/helpers/fig-fixtures'
function worldCorners(graph: SceneGraph, name: string) {
const node = [...graph.getAllNodes()].find((n) => n.name === name)
if (!node) throw new Error(`node ${name} not found`)
const m = getWorldMatrix(node, graph)
return Matrix.mapPoints(m, [0, 0, node.width, 0, node.width, node.height, 0, node.height])
}
async function roundTrip(graph: SceneGraph) {
const out = await exportFigFile(graph)
const ab = out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength) as ArrayBuffer
return parseFigFile(ab)
}
describe('rotated node transform round-trip', () => {
test.each([0, 30, -45, 90, 137])('rotation %i° keeps world corners', async (rotation) => {
const graph = new SceneGraph()
graph.createNode('RECTANGLE', graph.getPages()[0].id, {
name: 'R',
x: 100,
y: 200,
width: 300,
height: 150,
rotation
})
const before = worldCorners(graph, 'R')
const after = worldCorners(await roundTrip(graph), 'R')
for (let i = 0; i < before.length; i++) {
expect(after[i]).toBeCloseTo(before[i], 2)
}
})
test('rotated + off-origin node round-trips', async () => {
const graph = new SceneGraph()
graph.createNode('RECTANGLE', graph.getPages()[0].id, {
name: 'R',
x: -37.5,
y: 412.9,
width: 88,
height: 220,
rotation: 62
})
const before = worldCorners(graph, 'R')
const after = worldCorners(await roundTrip(graph), 'R')
for (let i = 0; i < before.length; i++) {
expect(after[i]).toBeCloseTo(before[i], 2)
}
})
})
// Real imported text-on-path (with stroke silhouettes + an expanded layout box)
// exercises all three coupled fixes: the transform pivot, size/transform pairing
// (exportNodeSize), and the edited-path-text strip (raw glyphs/silhouettes are
// rebuilt from the shifted graph glyphs). Editing one — rotate OR move — used to
// slide it out of its frame clip on reimport.
const CIRCLE_TEXT = 'tests/fixtures/circle-text.fig'
// Skip *visibly* when the LFS fixture isn't fetched (contributor checkout). A
// helper early-return would let Bun report these as passed with 0 assertions.
const describeWithFixture = readFigFixture(CIRCLE_TEXT) ? describe : describe.skip
function glyphWorld(graph: SceneGraph) {
const node = [...graph.getAllNodes()].find((n) => n.name === 'ArnoCoenen.art')
if (!node?.derivedTextGlyphs) throw new Error('path-text node not found')
const m = getWorldMatrix(node, graph)
return node.derivedTextGlyphs.map((g) => Matrix.mapPoints(m, [g.x, g.y]))
}
async function assertEditKeepsGlyphs(edit: (node: SceneNode) => Partial<SceneNode>) {
const graph = await loadFigFixture(CIRCLE_TEXT)
if (!graph) throw new Error('circle-text.fig fixture unavailable')
const node = [...graph.getAllNodes()].find((n) => n.name === 'ArnoCoenen.art')
if (!node) throw new Error('node missing')
graph.updateNode(node.id, edit(node))
const before = glyphWorld(graph)
const after = glyphWorld(await roundTrip(graph))
let maxDrift = 0
for (let i = 0; i < before.length; i++) {
maxDrift = Math.max(
maxDrift,
Math.hypot(after[i][0] - before[i][0], after[i][1] - before[i][1])
)
}
expect(maxDrift).toBeLessThan(0.05)
}
describeWithFixture('edited imported text-on-path round-trip', () => {
test.each([0, 30, -55, 90])('rotation %i° keeps glyph world positions', async (rotation) => {
await assertEditKeepsGlyphs(() => ({ rotation }))
})
test('move (rotation 0) keeps glyph world positions', async () => {
await assertEditKeepsGlyphs((node) => ({ x: node.x + 120, y: node.y - 45 }))
})
})