diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf5e34b9..727b8af82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/canvas/labels/draw.ts b/packages/core/src/canvas/labels/draw.ts index 728f5db90..e5aba9bd4 100644 --- a/packages/core/src/canvas/labels/draw.ts +++ b/packages/core/src/canvas/labels/draw.ts @@ -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() } diff --git a/packages/core/src/canvas/labels/hit-test.ts b/packages/core/src/canvas/labels/hit-test.ts index 8beb2691e..3b1d8a775 100644 --- a/packages/core/src/canvas/labels/hit-test.ts +++ b/packages/core/src/canvas/labels/hit-test.ts @@ -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 diff --git a/packages/core/src/canvas/labels/layout.ts b/packages/core/src/canvas/labels/layout.ts index 2063304d5..39f5c93e5 100644 --- a/packages/core/src/canvas/labels/layout.ts +++ b/packages/core/src/canvas/labels/layout.ts @@ -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 } } diff --git a/packages/core/src/canvas/labels/paragraph-cache.ts b/packages/core/src/canvas/labels/paragraph-cache.ts index 6c965e5ed..33d6263d2 100644 --- a/packages/core/src/canvas/labels/paragraph-cache.ts +++ b/packages/core/src/canvas/labels/paragraph-cache.ts @@ -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 { - 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) diff --git a/packages/core/src/canvas/labels/selection.ts b/packages/core/src/canvas/labels/selection.ts index e9719ffcc..01a198268 100644 --- a/packages/core/src/canvas/labels/selection.ts +++ b/packages/core/src/canvas/labels/selection.ts @@ -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. diff --git a/packages/core/src/canvas/labels/style.ts b/packages/core/src/canvas/labels/style.ts index 3a895a9ee..2a973044a 100644 --- a/packages/core/src/canvas/labels/style.ts +++ b/packages/core/src/canvas/labels/style.ts @@ -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 ) } diff --git a/packages/core/src/canvas/labels/transform.ts b/packages/core/src/canvas/labels/transform.ts index 93cb595ca..73d7facca 100644 --- a/packages/core/src/canvas/labels/transform.ts +++ b/packages/core/src/canvas/labels/transform.ts @@ -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 function labelWorldMatrix(transform: LabelTransform, zoom: number) { diff --git a/packages/core/src/canvas/overlays/selection.ts b/packages/core/src/canvas/overlays/selection.ts index ef52aaa9e..d3cdc79a1 100644 --- a/packages/core/src/canvas/overlays/selection.ts +++ b/packages/core/src/canvas/overlays/selection.ts @@ -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() diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index d231b147c..5edc8891f 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 diff --git a/tests/e2e/canvas/frame-overlays.spec.ts-snapshots/nested-frame-rotation-preview-openpencil-darwin.png b/tests/e2e/canvas/frame-overlays.spec.ts-snapshots/nested-frame-rotation-preview-openpencil-darwin.png index e80f78ef1..43ef39ad6 100644 Binary files a/tests/e2e/canvas/frame-overlays.spec.ts-snapshots/nested-frame-rotation-preview-openpencil-darwin.png and b/tests/e2e/canvas/frame-overlays.spec.ts-snapshots/nested-frame-rotation-preview-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts b/tests/e2e/canvas/label-anatomy.spec.ts new file mode 100644 index 000000000..42a0bebcf --- /dev/null +++ b/tests/e2e/canvas/label-anatomy.spec.ts @@ -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 + 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() + }) +} diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame--145-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame--145-openpencil-darwin.png new file mode 100644 index 000000000..1c79c353b Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame--145-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame--40-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame--40-openpencil-darwin.png new file mode 100644 index 000000000..91e2605f8 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame--40-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame-50-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame-50-openpencil-darwin.png new file mode 100644 index 000000000..108346810 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/readable-frame-50-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-0-5-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-0-5-openpencil-darwin.png new file mode 100644 index 000000000..76ea2eb4f Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-0-5-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-1-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-1-openpencil-darwin.png new file mode 100644 index 000000000..57f1f4a6f Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-1-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-2-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-2-openpencil-darwin.png new file mode 100644 index 000000000..2624bd885 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-2-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-0-5-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-0-5-openpencil-darwin.png new file mode 100644 index 000000000..a7c344977 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-0-5-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-1-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-1-openpencil-darwin.png new file mode 100644 index 000000000..559761204 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-1-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-2-openpencil-darwin.png b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-2-openpencil-darwin.png new file mode 100644 index 000000000..9d36395f5 Binary files /dev/null and b/tests/e2e/canvas/label-anatomy.spec.ts-snapshots/section-pills-hover-2-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-false-held-openpencil-darwin.png b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-false-held-openpencil-darwin.png index 46ed484e6..7a39b3126 100644 Binary files a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-false-held-openpencil-darwin.png and b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-false-held-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-true-held-openpencil-darwin.png b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-true-held-openpencil-darwin.png index da60b4b0f..31ce013bf 100644 Binary files a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-true-held-openpencil-darwin.png and b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-false-true-held-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-false-held-openpencil-darwin.png b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-false-held-openpencil-darwin.png index e7dbba35b..000241915 100644 Binary files a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-false-held-openpencil-darwin.png and b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-false-held-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-true-held-openpencil-darwin.png b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-true-held-openpencil-darwin.png index a36d38af4..bdd6ae276 100644 Binary files a/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-true-held-openpencil-darwin.png and b/tests/e2e/canvas/nested-transform-interaction.spec.ts-snapshots/nested-transform-true-true-held-openpencil-darwin.png differ diff --git a/tests/engine/render/canvas/labels/layout.test.ts b/tests/engine/render/canvas/labels/layout.test.ts index 0f90e6cba..ef9b33378 100644 --- a/tests/engine/render/canvas/labels/layout.test.ts +++ b/tests/engine/render/canvas/labels/layout.test.ts @@ -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) diff --git a/tests/engine/render/canvas/labels/paragraph-cache.test.ts b/tests/engine/render/canvas/labels/paragraph-cache.test.ts index d508eea19..ef722daee 100644 --- a/tests/engine/render/canvas/labels/paragraph-cache.test.ts +++ b/tests/engine/render/canvas/labels/paragraph-cache.test.ts @@ -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 { diff --git a/tests/engine/render/canvas/labels/transform.test.ts b/tests/engine/render/canvas/labels/transform.test.ts index 62d75f6fb..a81c62d23 100644 --- a/tests/engine/render/canvas/labels/transform.test.ts +++ b/tests/engine/render/canvas/labels/transform.test.ts @@ -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() })