fix(renderer): preserve proportions of FIT image fills
This commit is contained in:
parent
fbacd1a34a
commit
de6c0efc00
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 37 KiB |
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue