diff --git a/CHANGELOG.md b/CHANGELOG.md index c6661169f..09dd50f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ ### Fixed +- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges. - Preserve edited instance text, including cleared labels, when saving and reopening `.fig` files. - Honor `.pen` frame layout defaults and sizing and padding shorthands so imported auto-layout frames keep their computed dimensions and child positions. (#564) - Avoid macOS Keychain prompts during credential status checks and pause repeated credential access after failures until explicitly retried from Settings. diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index 0fff4de8f..7e27ea938 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -405,21 +405,20 @@ export function makeImageFillLocalMatrix( } } - let sx: number, sy: number, sw: number, sh: number if (scaleMode === 'FIT') { const scale = Math.min(node.width / imgW, node.height / imgH) - sw = imgW - sh = imgH - sx = -(node.width / scale - imgW) / 2 - sy = -(node.height / scale - imgH) / 2 - } else { - const scale = Math.max(node.width / imgW, node.height / imgH) - sw = node.width / scale - sh = node.height / scale - sx = (imgW - sw) / 2 - sy = (imgH - sh) / 2 + return r.ck.Matrix.multiply( + r.ck.Matrix.translated((node.width - imgW * scale) / 2, (node.height - imgH * scale) / 2), + r.ck.Matrix.scaled(scale, scale) + ) } + const scale = Math.max(node.width / imgW, node.height / imgH) + const sw = node.width / scale + const sh = node.height / scale + const sx = (imgW - sw) / 2 + const sy = (imgH - sh) / 2 + return r.ck.Matrix.multiply( r.ck.Matrix.scaled(node.width / sw, node.height / sh), r.ck.Matrix.translated(-sx, -sy) @@ -463,9 +462,10 @@ export function applyImageFill( return true } + const tileMode = scaleMode === 'FIT' ? r.ck.TileMode.Decal : r.ck.TileMode.Clamp const shader = img.makeShaderOptions( - r.ck.TileMode.Clamp, - r.ck.TileMode.Clamp, + tileMode, + tileMode, r.ck.FilterMode.Linear, r.ck.MipmapMode.Linear, localMatrix diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts b/tests/e2e/canvas/renderer-visuals.spec.ts index 4aeb4646b..c451b8eb5 100644 --- a/tests/e2e/canvas/renderer-visuals.spec.ts +++ b/tests/e2e/canvas/renderer-visuals.spec.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises' + import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures' const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers') @@ -64,6 +66,55 @@ test('boolean operations', async () => { await expectCanvas('boolean-operations') }) +test('FIT avatar preserves the full clipboard fixture image', async () => { + const bytes = await readFile('tests/fixtures/vectorize/pilot_avatar.png') + await editor.page.evaluate( + (data) => { + const store = window.openPencil?.getStore?.() + if (!store) throw new Error('OpenPencil store not initialized') + const hash = store.storeImage(new Uint8Array(data)) + store.graph.createNode('RECTANGLE', store.state.currentPageId, { + name: 'Bitmap background', + x: 80, + y: 80, + width: 320, + height: 222, + cornerRadius: 16, + fills: [ + { + type: 'SOLID', + color: { r: 241 / 255, g: 245 / 255, b: 249 / 255, a: 1 }, + visible: true, + opacity: 1 + } + ] + }) + store.graph.createNode('RECTANGLE', store.state.currentPageId, { + name: 'Pilot avatar · image fill FIT', + x: 138, + y: 90, + width: 204, + height: 202, + fills: [ + { + type: 'IMAGE', + imageHash: hash, + imageScaleMode: 'FIT', + color: { r: 0, g: 0, b: 0, a: 1 }, + visible: true, + opacity: 1 + } + ] + }) + store.clearSelection() + store.requestRender() + }, + [...bytes] + ) + await editor.canvas.waitForRender() + await expectCanvas('fit-avatar-clipboard-fixture') +}) + test('gradients and image fill modes', async () => { await editor.page.evaluate(async () => { const store = window.openPencil?.getStore?.() @@ -134,6 +185,24 @@ test('gradients and image fill modes', async () => { }) } + store.graph.createNode('RECTANGLE', pageId, { + name: 'FIT image with transparent margins', + x: 568, + y: 84, + width: 140, + height: 172, + fills: [ + { + type: 'IMAGE', + color: { r: 0, g: 0, b: 0, a: 1 }, + visible: true, + opacity: 1, + imageHash, + imageScaleMode: 'FIT' + } + ] + }) + const scaleModes = ['FILL', 'FIT', 'TILE', 'CROP'] as const for (const [index, imageScaleMode] of scaleModes.entries()) { store.graph.createNode('RECTANGLE', pageId, { diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/fit-avatar-clipboard-fixture-openpencil-darwin.png b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/fit-avatar-clipboard-fixture-openpencil-darwin.png new file mode 100644 index 000000000..7bb87930b Binary files /dev/null and b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/fit-avatar-clipboard-fixture-openpencil-darwin.png differ diff --git a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/gradients-and-image-fill-modes-openpencil-darwin.png b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/gradients-and-image-fill-modes-openpencil-darwin.png index d97005c95..b0aaee097 100644 Binary files a/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/gradients-and-image-fill-modes-openpencil-darwin.png and b/tests/e2e/canvas/renderer-visuals.spec.ts-snapshots/gradients-and-image-fill-modes-openpencil-darwin.png differ diff --git a/tests/engine/render/canvas/image-fill.test.ts b/tests/engine/render/canvas/image-fill.test.ts index ddf574f28..007f358ff 100644 --- a/tests/engine/render/canvas/image-fill.test.ts +++ b/tests/engine/render/canvas/image-fill.test.ts @@ -68,6 +68,23 @@ describe('canvas pattern fills', () => { }) describe('canvas image fills', () => { + test.each([ + { width: 100, height: 100, scale: 0.8, x: 20, y: 0 }, + { width: 240, height: 80, scale: 0.5, x: 0, y: 20 }, + { width: 60, height: 40, scale: 2, x: 0, y: 0 } + ])( + 'centers FIT images without stretching ($width × $height)', + ({ width, height, scale, x, y }) => { + const renderer = createRenderer() + const fill = { type: 'IMAGE', imageScaleMode: 'FIT' } as Fill + expect(makeImageFillLocalMatrix(renderer, fill, node, width, height)).toEqual([ + 'multiply', + ['translated', x, y], + ['scaled', scale, scale] + ]) + } + ) + test('keeps untransformed tile fills in image pixel space', () => { const renderer = createRenderer() const fill = { type: 'IMAGE', imageScaleMode: 'TILE' } as Fill