fix(canvas): keep labels readable and refine section badges

Choose readable opposite edges for rotated frame titles and size badges, sharing placement with hit testing. Render section titles as compact inset badges with contrast-aware borders and hover feedback.
This commit is contained in:
Danila Poyarkov 2026-09-15 19:54:04 +03:00
parent 79552129b5
commit 1a2107b0e1
28 changed files with 303 additions and 64 deletions

View file

@ -60,7 +60,8 @@
### Fixed
- Keep newly created and edited objects visible during zoom instead of replaying outdated scene content.
- Keep property fields and paint previews live during editing, and keep rotated selection labels aligned and object edges stable when previews settle.
- Keep property fields and paint previews live during editing, rotated selection labels readable and aligned, and object edges stable when previews settle.
- Show compact bordered section labels with inset nested titles and clearer hover feedback.
- Keep Undo and Redo commands available as edit history changes, without requiring another scene edit.
- Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic.
- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges.

View file

@ -37,7 +37,7 @@ function drawSectionTitle(
): void {
let layout = labelLayout('section', node.width * r.zoom, nested)
if (!layout) return
const { background, foreground } = sectionLabelColors(r, graph, node)
const { background, foreground, border, hover } = sectionLabelColors(r, graph, node)
const metrics = r.labelParagraphCache.measure(
r.ck,
provider,
@ -45,7 +45,8 @@ function drawSectionTitle(
layout.fontSize,
layout.maxTextWidth,
foreground,
r.fontGeneration
r.fontGeneration,
layout.fontWeight
)
layout = labelLayout('section', node.width * r.zoom, nested, metrics)
if (!layout) return
@ -53,14 +54,20 @@ function drawSectionTitle(
canvas.concat(labelScreenMatrix(labelTransform(node, graph, overlays?.rotationPreview), r))
r.auxFill.setColor(r.ck.Color4f(background.r, background.g, background.b, background.a))
const { x, y, width, height } = layout.bounds
canvas.drawRRect(
r.ck.RRectXY(
r.ck.LTRBRect(x, y, x + width, y + height),
SECTION_TITLE_RADIUS,
SECTION_TITLE_RADIUS
),
r.auxFill
const bounds = r.ck.RRectXY(
r.ck.LTRBRect(x, y, x + width, y + height),
SECTION_TITLE_RADIUS,
SECTION_TITLE_RADIUS
)
canvas.drawRRect(bounds, r.auxFill)
if (overlays?.hoveredNodeId === node.id) {
r.auxFill.setColor(hover)
canvas.drawRRect(bounds, r.auxFill)
}
r.auxStroke.setColor(border)
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setPathEffect(null)
canvas.drawRRect(bounds, r.auxStroke)
r.auxFill.setColor(foreground)
r.labelParagraphCache.draw(
r.ck,
@ -72,7 +79,8 @@ function drawSectionTitle(
foreground,
r.fontGeneration,
layout.text.x,
layout.text.y
layout.text.y,
layout.fontWeight
)
canvas.restore()
}
@ -151,7 +159,8 @@ export function drawComponentLabels(
compColor,
r.fontGeneration,
layout.text.x,
layout.text.y
layout.text.y,
layout.fontWeight
)
canvas.restore()
}

View file

@ -13,7 +13,7 @@ import {
type LabelTextMetrics
} from './layout'
import { measureGlyphWidth } from './paragraph-cache'
import { labelLocalPoint, labelTransform } from './transform'
import { frameLabelPlacement, labelLocalPoint, labelTransform } from './transform'
export interface LabelHitOptions {
preview?: RotationPreview | null
@ -32,19 +32,24 @@ function hitLabel(
font: Font,
options: LabelHitOptions
): SceneNode | null {
let layout = labelLayout(kind, node.width * zoom, inside)
const placement =
kind === 'frame'
? frameLabelPlacement(node, graph, options.preview)
: { ...labelTransform(node, graph, options.preview), width: node.width }
let layout = labelLayout(kind, placement.width * zoom, inside)
if (!layout) return null
const point = labelLocalPoint(labelTransform(node, graph, options.preview), zoom, {
const point = labelLocalPoint(placement, zoom, {
x: canvasX,
y: canvasY
})
if (!point || point.x < 0 || point.x > node.width * zoom || point.y < layout.bounds.y) return null
if (!point || point.x < 0 || point.x > placement.width * zoom || point.y < layout.bounds.y)
return null
if (kind === 'section' && point.y > layout.bounds.y + layout.bounds.height) return null
const metrics = options.measure
? options.measure(node, layout)
: { width: measureGlyphWidth(font, node.name), height: layout.fontSize }
if (!metrics) return null
layout = labelLayout(kind, node.width * zoom, inside, metrics)
layout = labelLayout(kind, placement.width * zoom, inside, metrics)
if (!layout) return null
const { x, y, width, height } = layout.bounds
return point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height ? node : null

View file

@ -24,6 +24,7 @@ export interface LabelLayout {
text: Vector
icon: Rect | null
fontSize: number
fontWeight: number
maxTextWidth: number
}
@ -31,6 +32,34 @@ export function hasFrameTitle(node: SceneNode, parent?: SceneNode | null): boole
return node.type === 'FRAME' && (!parent || parent.type === 'CANVAS' || parent.type === 'SECTION')
}
function sectionLabelLayout(
screenWidth: number,
inside: boolean,
metrics?: LabelTextMetrics
): LabelLayout | null {
const x = inside ? SECTION_TITLE_GAP : 0
if (screenWidth <= x) return null
const y = inside ? SECTION_TITLE_GAP : -SECTION_TITLE_HEIGHT - SECTION_TITLE_GAP
const textWidth = metrics?.width ?? screenWidth
return {
kind: 'section',
bounds: {
x,
y,
width: Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, screenWidth - x),
height: SECTION_TITLE_HEIGHT
},
text: {
x: x + SECTION_TITLE_PADDING_X,
y: y + (SECTION_TITLE_HEIGHT - (metrics?.height ?? SECTION_TITLE_FONT_SIZE)) / 2
},
icon: null,
fontSize: SECTION_TITLE_FONT_SIZE,
fontWeight: 600,
maxTextWidth: Math.max(1, screenWidth - x - SECTION_TITLE_PADDING_X * 2)
}
}
/** Pixel-local geometry shared by drawing and hit-testing, including ellipsis and icon bounds. */
export function labelLayout(
kind: LabelKind,
@ -39,26 +68,8 @@ export function labelLayout(
metrics?: LabelTextMetrics
): LabelLayout | null {
if (screenWidth <= 0) return null
if (kind === 'section') return sectionLabelLayout(screenWidth, inside, metrics)
const textWidth = metrics?.width ?? screenWidth
if (kind === 'section') {
const y = inside ? SECTION_TITLE_GAP : -SECTION_TITLE_HEIGHT - SECTION_TITLE_GAP
return {
kind,
bounds: {
x: 0,
y,
width: Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, screenWidth),
height: SECTION_TITLE_HEIGHT
},
text: {
x: SECTION_TITLE_PADDING_X,
y: y + (SECTION_TITLE_HEIGHT - (metrics?.height ?? SECTION_TITLE_FONT_SIZE)) / 2
},
icon: null,
fontSize: SECTION_TITLE_FONT_SIZE,
maxTextWidth: Math.max(1, screenWidth - SECTION_TITLE_PADDING_X * 2)
}
}
const component = kind === 'component'
const fontSize = component ? COMPONENT_LABEL_FONT_SIZE : LABEL_FONT_SIZE
const textX = component ? COMPONENT_LABEL_ICON_SIZE + COMPONENT_LABEL_ICON_GAP : 0
@ -85,6 +96,7 @@ export function labelLayout(
text: { x: textX, y: textY },
icon,
fontSize,
fontWeight: 400,
maxTextWidth
}
}

View file

@ -1,4 +1,11 @@
import type { Canvas, CanvasKit, Font, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm'
import type {
Canvas,
CanvasKit,
Font,
FontWeight,
Paragraph,
TypefaceFontProvider
} from 'canvaskit-wasm'
interface LabelParagraphEntry {
paragraph: Paragraph
@ -25,9 +32,10 @@ export class LabelParagraphCache {
fontSize: number,
maxWidth: number,
color: Float32Array,
generation: number
generation: number,
fontWeight = 400
): Pick<LabelParagraphEntry, 'width' | 'height'> {
return this.entry(ck, provider, text, fontSize, maxWidth, color, generation)
return this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight)
}
draw(
@ -40,9 +48,10 @@ export class LabelParagraphCache {
color: Float32Array,
generation: number,
x: number,
y: number
y: number,
fontWeight = 400
): number {
const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation)
const entry = this.entry(ck, provider, text, fontSize, maxWidth, color, generation, fontWeight)
canvas.drawParagraph(entry.paragraph, x, y)
return entry.width
}
@ -63,20 +72,26 @@ export class LabelParagraphCache {
fontSize: number,
maxWidth: number,
color: Float32Array,
generation: number
generation: number,
fontWeight: number
): LabelParagraphEntry {
if (generation !== this.fontGeneration) {
this.clear()
this.fontGeneration = generation
}
const boundedWidth = Math.max(1, maxWidth)
const key = `${fontSize}\0${boundedWidth}\0${Array.from(color).join(',')}\0${text}`
const key = `${fontSize}\0${fontWeight}\0${boundedWidth}\0${Array.from(color).join(',')}\0${text}`
let entry = this.entries.get(key)
if (!entry) {
const style = new ck.ParagraphStyle({
maxLines: 1,
ellipsis: '…',
textStyle: { color, fontFamilies: ['Inter'], fontSize }
textStyle: {
color,
fontFamilies: ['Inter'],
fontSize,
fontStyle: { weight: { value: fontWeight } as FontWeight }
}
})
const builder = ck.ParagraphBuilder.MakeFromFontProvider(style, provider)
builder.addText(text)

View file

@ -15,7 +15,7 @@ import { createSceneGeometry } from '#core/geometry'
import { hasFrameTitle, labelLayout } from './layout'
import { measureGlyphWidth } from './paragraph-cache'
import { labelScreenMatrix, labelTransform } from './transform'
import { frameLabelPlacement, labelScreenMatrix } from './transform'
function accumulateSelectionBounds(
graph: SceneGraph,
@ -47,11 +47,11 @@ function drawSingleFrameTitle(
const provider = r.fontProvider
if (!hasFrameTitle(node, parentNode) || !provider) return
const transform = labelTransform(node, graph, overlays.rotationPreview)
const transform = frameLabelPlacement(node, graph, overlays.rotationPreview)
r.auxFill.setColor(r.selColor())
const layout = labelLayout('frame', node.width * r.zoom)
const layout = labelLayout('frame', transform.width * r.zoom)
if (!layout) return
canvas.save()
@ -67,7 +67,8 @@ function drawSingleFrameTitle(
r.selColor(),
r.fontGeneration,
layout.text.x,
layout.text.y
layout.text.y,
layout.fontWeight
)
canvas.restore()
}
@ -112,9 +113,9 @@ export function drawSingleSelectionSize(
): void {
const sizeText = `${Math.round(node.width)} × ${Math.round(node.height)}`
const pillColor = r.isComponentType(node.type) ? r.compColor() : r.selColor()
const transform = labelTransform(node, graph, overlays.rotationPreview, {
x: node.width / 2,
y: node.height
const transform = frameLabelPlacement(node, graph, overlays.rotationPreview, {
x: 0.5,
y: 1
})
// Keep the label's typography and gap in screen pixels rather than scaling them.

View file

@ -16,6 +16,8 @@ export function sectionLabelColors(r: SkiaRenderer, graph: SceneGraph, node: Sce
const foreground = canvasLabelForeground(background, r.pageColor)
return {
background,
border: r.ck.Color4f(foreground.r, foreground.g, foreground.b, 0.22),
hover: r.ck.Color4f(foreground.r, foreground.g, foreground.b, 0.08),
foreground: r.ck.Color4f(foreground.r, foreground.g, foreground.b, foreground.a)
}
}
@ -39,7 +41,8 @@ export function measureLabel(
layout.fontSize,
layout.maxTextWidth,
color,
r.fontGeneration
r.fontGeneration,
layout.fontWeight
)
}

View file

@ -22,6 +22,34 @@ export function labelTransform(
}
}
/**
* Choose the most horizontal pair of edges, with left-to-right screen text.
* The anchor uses normalized coordinates within that readable rectangle.
*/
export function frameLabelPlacement(
node: SceneNode,
graph: SceneGraph,
preview: RotationPreview | null | undefined,
anchor: Vector = { x: 0, y: 0 }
) {
const world = createSceneGeometry(graph, preview).unreflectedWorldMatrix(node)
const orientation = (Math.atan2(world[3], world[0]) * 180) / Math.PI
const turns = Math.floor((orientation + 45) / 90)
const corner = ((turns % 4) + 4) % 4
const origin = Matrix.mapPoint(world, {
x: corner >= 2 ? node.width : 0,
y: corner === 1 || corner === 2 ? node.height : 0
})
const width = corner % 2 === 0 ? node.width : node.height
const height = corner % 2 === 0 ? node.height : node.width
const rotation = orientation - turns * 90
const offset = Matrix.mapPoint(Matrix.rotated((rotation * Math.PI) / 180), {
x: anchor.x * width,
y: anchor.y * height
})
return { x: origin.x + offset.x, y: origin.y + offset.y, rotation, width, height }
}
type LabelTransform = ReturnType<typeof labelTransform>
function labelWorldMatrix(transform: LabelTransform, zoom: number) {

View file

@ -6,7 +6,12 @@ import Matrix from '@open-pencil/scene-graph/matrix'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import type { RenderOverlays, SkiaRenderer } from '#core/canvas/renderer'
import { HANDLE_HALF_SIZE, ROTATION_HANDLE_DISTANCE, SELECTION_DASH_ALPHA } from '#core/constants'
import {
HANDLE_HALF_SIZE,
ROTATION_HANDLE_DISTANCE,
SELECTION_DASH_ALPHA,
SECTION_HOVER_STROKE_WIDTH
} from '#core/constants'
import {
createSceneGeometry,
viewportMatrix,
@ -26,7 +31,7 @@ export function drawHoverHighlight(
): void {
const node = hoveredNodeId ? graph.getNode(hoveredNodeId) : undefined
if (!node) return
r.auxStroke.setStrokeWidth(1 / r.zoom)
r.auxStroke.setStrokeWidth((node.type === 'SECTION' ? SECTION_HOVER_STROKE_WIDTH : 1) / r.zoom)
r.auxStroke.setColor(r.isComponentType(node.type) ? r.compColor() : r.selColor())
r.auxStroke.setPathEffect(null)
canvas.save()

View file

@ -109,10 +109,11 @@ export const AUTO_LAYOUT_HOVER_MAGENTA_FILL = { r: 1, g: 0.32, b: 0.68, a: 0.1 }
export const SECTION_CORNER_RADIUS = 5
export const SECTION_TITLE_HEIGHT = 24
export const SECTION_TITLE_PADDING_X = 8
export const SECTION_TITLE_PADDING_X = 6
export const SECTION_TITLE_RADIUS = 5
export const SECTION_TITLE_FONT_SIZE = 12
export const SECTION_TITLE_FONT_SIZE = 11
export const SECTION_TITLE_GAP = 6
export const SECTION_HOVER_STROKE_WIDTH = 2
export const COMPONENT_SET_DASH = 6
export const COMPONENT_SET_DASH_GAP = 4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -0,0 +1,103 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { expect, test } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
test.use({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 2 })
for (const rotation of [-145, -40, 50]) {
test(`frame titles and dimensions remain readable at ${rotation} degrees`, async ({ page }) => {
await page.goto('/?test&no-rulers')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
await page.evaluate((angle) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
const id = store.createShape('FRAME', 250, 250, 300, 180)
store.updateNode(id, { name: 'Frame', rotation: angle })
store.select([id])
}, rotation)
const box = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds')
await expect(page).toHaveScreenshot(`readable-frame-${rotation}.png`, {
clip: { x: box.x + 150, y: box.y + 100, width: 500, height: 500 },
maxDiffPixels: 0,
threshold: 0,
scale: 'device'
})
canvas.assertNoErrors()
})
}
for (const zoom of [0.5, 1, 2]) {
test(`section pills retain their size and hover target at ${zoom} zoom`, async ({ page }) => {
await page.goto('/?test&no-rulers')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
const lightId = await page.evaluate((scale) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('Editor unavailable')
const sectionPaint = {
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
strokes: [
{
color: { r: 0.7, g: 0.7, b: 0.7, a: 1 },
weight: 1,
opacity: 1,
visible: true,
align: 'INSIDE'
}
]
} satisfies Pick<SceneNode, 'fills' | 'strokes'>
const color = store.graph.createNode('SECTION', store.state.currentPageId, {
name: 'Color',
x: 60,
y: 100,
width: 280,
height: 150,
...structuredClone(sectionPaint)
})
const light = store.graph.createNode('SECTION', color.id, {
name: 'Light',
x: 16,
y: 30,
width: 248,
height: 100,
...structuredClone(sectionPaint)
})
store.graph.createNode('RECTANGLE', light.id, {
x: 20,
y: 60,
width: 60,
height: 24,
fills: [
{ type: 'SOLID', color: { r: 0.05, g: 0.05, b: 0.05, a: 1 }, opacity: 1, visible: true }
]
})
store.clearSelection()
store.setZoomAroundPoint(scale, 0, 0)
store.requestRender()
return light.id
}, zoom)
const box = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds')
const clip = { x: box.x + 10, y: box.y + 10, width: 700, height: 560 }
await expect(page).toHaveScreenshot(`section-pills-${zoom}.png`, {
clip,
maxDiffPixels: 0,
threshold: 0,
scale: 'device'
})
// Nested titles have a fixed six-pixel inset and a screen-sized pill.
await page.mouse.move(box.x + 76 * zoom + 18, box.y + 130 * zoom + 18)
await expect
.poll(() => page.evaluate(() => window.openPencil?.getStore?.().state.hoveredNodeId))
.toBe(lightId)
await expect(page).toHaveScreenshot(`section-pills-hover-${zoom}.png`, {
clip,
maxDiffPixels: 0,
threshold: 0,
scale: 'device'
})
canvas.assertNoErrors()
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 92 KiB

View file

@ -7,7 +7,11 @@ import Matrix from '@open-pencil/scene-graph/matrix'
import { hitTestComponentLabel, hitTestFrameTitle } from '#core/canvas/labels/hit-test'
import { labelLayout } from '#core/canvas/labels/layout'
import { labelLocalPoint, labelScreenMatrix, labelTransform } from '#core/canvas/labels/transform'
import {
frameLabelPlacement,
labelLocalPoint,
labelScreenMatrix
} from '#core/canvas/labels/transform'
import { createSceneGeometry } from '#core/geometry'
import { expectDefined } from '#tests/helpers/assert'
@ -28,8 +32,16 @@ test('layout bounds include clipped text, padding and the entire component icon'
'section layout'
)
expect(section.bounds).toEqual({ x: 0, y: -30, width: 60, height: 24 })
expect(section.text).toEqual({ x: 8, y: -25 })
expect(section.maxTextWidth).toBe(44)
expect(section.text).toEqual({ x: 6, y: -25 })
expect(section.maxTextWidth).toBe(48)
const nested = expectDefined(
labelLayout('section', 60, true, { width: 100, height: 14 }),
'nested section layout'
)
expect(nested.bounds).toEqual({ x: 6, y: 6, width: 54, height: 24 })
expect(nested.text).toEqual({ x: 12, y: 11 })
expect(nested.maxTextWidth).toBe(42)
expect(nested.fontWeight).toBe(600)
const component = expectDefined(
labelLayout('component', 80, false, { width: 100, height: 11 }),
'component layout'
@ -47,7 +59,7 @@ for (const flipX of [false, true]) {
const page = expectDefined(graph.getPages()[0], 'page')
const { section, frame } = nestedGeometryFixture(graph, page.id, flipX, flipY)
const preview = { nodeId: section.id, angle: 70 }
const transform = labelTransform(frame, graph, preview)
const transform = frameLabelPlacement(frame, graph, preview)
const screen = Matrix.mapPoint(labelScreenMatrix(transform, { panX: 0, panY: 0, zoom: 2 }), {
x: 4,
y: -10
@ -60,7 +72,14 @@ for (const flipX of [false, true]) {
createSceneGeometry(graph, preview).toLocal(frame, world),
'node-local point'
)
expect(nodeLocal.y < 0 || nodeLocal.y > frame.height).toBe(true)
expect(
nodeLocal.x < 0 ||
nodeLocal.x > frame.width ||
nodeLocal.y < 0 ||
nodeLocal.y > frame.height
).toBe(true)
expect(transform.rotation).toBeGreaterThanOrEqual(-45)
expect(transform.rotation).toBeLessThan(45)
expect(
hitTestFrameTitle(graph, world.x, world.y, 2, new Set([frame.id]), font, { preview })?.id
).toBe(frame.id)

View file

@ -28,6 +28,10 @@ describe('label paragraph cache', () => {
cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1)
expect(cache.size()).toBe(1)
const semibold = cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1, 600)
expect(semibold).not.toBe(metrics)
expect(cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 1, 600)).toBe(semibold)
expect(cache.size()).toBe(2)
cache.measure(ck, provider, 'Primitives', 12, 200, ck.BLACK, 2)
expect(cache.size()).toBe(1)
} finally {

View file

@ -6,7 +6,7 @@ import { SceneGraph } from '@open-pencil/scene-graph'
import { LabelCache } from '#core/canvas/labels/cache'
import { hitTestSectionTitle, hitTestFrameTitle } from '#core/canvas/labels/hit-test'
import { labelTransform } from '#core/canvas/labels/transform'
import { frameLabelPlacement, labelTransform } from '#core/canvas/labels/transform'
import { createSceneGeometry } from '#core/geometry'
import { expectDefined } from '#tests/helpers/assert'
@ -58,6 +58,38 @@ test('label origins and edge anchors include a live ancestor rotation without mu
expect(createSceneGeometry(graph, null).bounds(frame)).toEqual(bounds)
})
for (const [angle, rotation, width, height] of [
[40, 40, 80, 40],
[50, -40, 40, 80],
[140, -40, 80, 40],
[-145, 35, 80, 40],
[180, 0, 80, 40]
] as const) {
test(`frame labels choose readable opposite edges at ${angle} degrees`, () => {
const { graph, frame } = fixture()
graph.updateNode(frame.id, { rotation: angle })
const title = frameLabelPlacement(frame, graph, null)
const size = frameLabelPlacement(frame, graph, null, { x: 0.5, y: 1 })
expect(title.rotation).toBeCloseTo(rotation, 9)
expect(title.width).toBe(width)
expect(title.height).toBe(height)
const radians = (rotation * Math.PI) / 180
const dx = size.x - title.x
const dy = size.y - title.y
expect(dx * Math.cos(radians) + dy * Math.sin(radians)).toBeCloseTo(width / 2, 9)
expect(-dx * Math.sin(radians) + dy * Math.cos(radians)).toBeCloseTo(height, 9)
})
}
test('readability thresholds use identical edges during preview and after commit', () => {
for (const angle of [-135, -45, 45, 135]) {
const { graph, frame } = fixture()
const preview = frameLabelPlacement(frame, graph, { nodeId: frame.id, angle })
graph.updateNode(frame.id, { rotation: angle })
expect(frameLabelPlacement(frame, graph, null)).toEqual(preview)
}
})
test('section culling uses preview world bounds rather than cached unrotated coordinates', () => {
const { graph, page, section } = fixture()
const cache = new LabelCache()
@ -78,6 +110,7 @@ test('section and nested frame hit targets follow their rendered title transform
for (const catalog of [undefined, cache]) {
expect(hitTestSectionTitle(graph, 260, 54, 1, page.id, font, catalog)?.id).toBe(section.id)
}
// Nested frame title local point (4, -10), through (220, 70) at 90 degrees.
expect(hitTestFrameTitle(graph, 230, 74, 1, new Set([frame.id]), font)?.id).toBe(frame.id)
// The readable frame title moves to the horizontal edge at (180, 70).
expect(hitTestFrameTitle(graph, 184, 60, 1, new Set([frame.id]), font)?.id).toBe(frame.id)
expect(hitTestFrameTitle(graph, 230, 74, 1, new Set([frame.id]), font)).toBeNull()
})