pixel-data-js 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/index.dev.cjs +1024 -596
  2. package/dist/index.dev.cjs.map +1 -1
  3. package/dist/index.dev.js +1010 -592
  4. package/dist/index.dev.js.map +1 -1
  5. package/dist/index.prod.cjs +1024 -596
  6. package/dist/index.prod.cjs.map +1 -1
  7. package/dist/index.prod.d.ts +280 -165
  8. package/dist/index.prod.js +1010 -592
  9. package/dist/index.prod.js.map +1 -1
  10. package/package.json +3 -2
  11. package/src/Canvas/CanvasFrameRenderer.ts +57 -0
  12. package/src/Canvas/ReusableCanvas.ts +60 -11
  13. package/src/History/HistoryAction.ts +38 -0
  14. package/src/History/HistoryManager.ts +4 -8
  15. package/src/History/PixelAccumulator.ts +95 -80
  16. package/src/History/PixelEngineConfig.ts +18 -6
  17. package/src/History/PixelMutator/mutatorApplyAlphaMask.ts +6 -6
  18. package/src/History/PixelMutator/mutatorApplyBinaryMask.ts +6 -6
  19. package/src/History/PixelMutator/mutatorApplyCircleBrushStroke.ts +6 -5
  20. package/src/History/PixelMutator/mutatorApplyCirclePencil.ts +22 -22
  21. package/src/History/PixelMutator/mutatorApplyCirclePencilStroke.ts +6 -5
  22. package/src/History/PixelMutator/mutatorApplyRectBrush.ts +19 -19
  23. package/src/History/PixelMutator/mutatorApplyRectBrushStroke.ts +6 -4
  24. package/src/History/PixelMutator/mutatorApplyRectPencil.ts +20 -20
  25. package/src/History/PixelMutator/mutatorApplyRectPencilStroke.ts +6 -4
  26. package/src/History/PixelMutator/mutatorBlendColor.ts +8 -5
  27. package/src/History/PixelMutator/mutatorBlendColorCircleMask.ts +71 -0
  28. package/src/History/PixelMutator/mutatorBlendPixel.ts +22 -26
  29. package/src/History/PixelMutator/mutatorBlendPixelData.ts +5 -3
  30. package/src/History/PixelMutator/mutatorBlendPixelDataAlphaMask.ts +5 -3
  31. package/src/History/PixelMutator/mutatorBlendPixelDataBinaryMask.ts +5 -3
  32. package/src/History/PixelMutator/mutatorClear.ts +7 -6
  33. package/src/History/PixelMutator/mutatorFill.ts +34 -9
  34. package/src/History/PixelMutator/mutatorFillBinaryMask.ts +4 -2
  35. package/src/History/PixelMutator/mutatorInvert.ts +8 -4
  36. package/src/History/PixelMutator.ts +4 -3
  37. package/src/History/PixelPatchTiles.ts +3 -15
  38. package/src/History/PixelWriter.ts +29 -33
  39. package/src/ImageData/ReusableImageData.ts +3 -5
  40. package/src/Mask/{CircleBrushAlphaMask.ts → CircleAlphaMask.ts} +2 -2
  41. package/src/Mask/{CircleBrushBinaryMask.ts → CircleBinaryMask.ts} +2 -2
  42. package/src/PixelData/PixelData.ts +1 -27
  43. package/src/PixelData/applyAlphaMaskToPixelData.ts +19 -9
  44. package/src/PixelData/applyBinaryMaskToPixelData.ts +24 -17
  45. package/src/PixelData/applyRectBrushToPixelData.ts +18 -5
  46. package/src/PixelData/blendColorPixelData.ts +31 -7
  47. package/src/PixelData/blendColorPixelDataAlphaMask.ts +16 -6
  48. package/src/PixelData/blendColorPixelDataBinaryMask.ts +16 -7
  49. package/src/PixelData/{applyCircleBrushToPixelData.ts → blendColorPixelDataCircleMask.ts} +11 -10
  50. package/src/PixelData/blendPixel.ts +47 -0
  51. package/src/PixelData/blendPixelData.ts +14 -4
  52. package/src/PixelData/blendPixelDataAlphaMask.ts +12 -4
  53. package/src/PixelData/blendPixelDataBinaryMask.ts +13 -4
  54. package/src/PixelData/blendPixelDataPaintBuffer.ts +37 -0
  55. package/src/PixelData/clearPixelData.ts +2 -2
  56. package/src/PixelData/fillPixelData.ts +26 -16
  57. package/src/PixelData/fillPixelDataBinaryMask.ts +12 -4
  58. package/src/PixelData/fillPixelDataFast.ts +94 -0
  59. package/src/PixelData/invertPixelData.ts +4 -2
  60. package/src/PixelTile/PaintBuffer.ts +122 -0
  61. package/src/PixelTile/PaintBufferRenderer.ts +40 -0
  62. package/src/PixelTile/PixelTile.ts +21 -0
  63. package/src/PixelTile/PixelTilePool.ts +63 -0
  64. package/src/_types.ts +9 -9
  65. package/src/index.ts +16 -6
  66. package/src/History/PixelMutator/mutatorApplyCircleBrush.ts +0 -78
@@ -1,7 +1,7 @@
1
1
  import { mutatorApplyAlphaMask } from './PixelMutator/mutatorApplyAlphaMask'
2
2
  import { mutatorApplyBinaryMask } from './PixelMutator/mutatorApplyBinaryMask'
3
- import { mutatorApplyCircleBrush } from './PixelMutator/mutatorApplyCircleBrush'
4
3
  import { mutatorApplyCircleBrushStroke } from './PixelMutator/mutatorApplyCircleBrushStroke'
4
+ import { mutatorBlendColorCircleMask } from './PixelMutator/mutatorBlendColorCircleMask'
5
5
  import { mutatorApplyCirclePencil } from './PixelMutator/mutatorApplyCirclePencil'
6
6
  import { mutatorApplyCirclePencilStroke } from './PixelMutator/mutatorApplyCirclePencilStroke'
7
7
  import { mutatorApplyRectBrush } from './PixelMutator/mutatorApplyRectBrush'
@@ -14,7 +14,7 @@ import { mutatorBlendPixelData } from './PixelMutator/mutatorBlendPixelData'
14
14
  import { mutatorBlendPixelDataAlphaMask } from './PixelMutator/mutatorBlendPixelDataAlphaMask'
15
15
  import { mutatorBlendPixelDataBinaryMask } from './PixelMutator/mutatorBlendPixelDataBinaryMask'
16
16
  import { mutatorClear } from './PixelMutator/mutatorClear'
17
- import { mutatorFill } from './PixelMutator/mutatorFill'
17
+ import { mutatorFill, mutatorFillRect } from './PixelMutator/mutatorFill'
18
18
  import { mutatorFillBinaryMask } from './PixelMutator/mutatorFillBinaryMask'
19
19
  import { mutatorInvert } from './PixelMutator/mutatorInvert'
20
20
  import type { PixelWriter } from './PixelWriter'
@@ -24,7 +24,6 @@ export function makeFullPixelMutator(writer: PixelWriter<any>) {
24
24
  // @sort
25
25
  ...mutatorApplyAlphaMask(writer),
26
26
  ...mutatorApplyBinaryMask(writer),
27
- ...mutatorApplyCircleBrush(writer),
28
27
  ...mutatorApplyCircleBrushStroke(writer),
29
28
  ...mutatorApplyCirclePencil(writer),
30
29
  ...mutatorApplyCirclePencilStroke(writer),
@@ -33,6 +32,7 @@ export function makeFullPixelMutator(writer: PixelWriter<any>) {
33
32
  ...mutatorApplyRectPencil(writer),
34
33
  ...mutatorApplyRectPencilStroke(writer),
35
34
  ...mutatorBlendColor(writer),
35
+ ...mutatorBlendColorCircleMask(writer),
36
36
  ...mutatorBlendPixel(writer),
37
37
  ...mutatorBlendPixelData(writer),
38
38
  ...mutatorBlendPixelDataAlphaMask(writer),
@@ -40,6 +40,7 @@ export function makeFullPixelMutator(writer: PixelWriter<any>) {
40
40
  ...mutatorClear(writer),
41
41
  ...mutatorFill(writer),
42
42
  ...mutatorFillBinaryMask(writer),
43
+ ...mutatorFillRect(writer),
43
44
  ...mutatorInvert(writer),
44
45
  }
45
46
  }
@@ -1,24 +1,12 @@
1
1
  import type { IPixelData } from '../_types'
2
+ import { PixelTile } from '../PixelTile/PixelTile'
2
3
 
3
4
  export type PixelPatchTiles = {
4
5
  beforeTiles: PixelTile[]
5
6
  afterTiles: PixelTile[]
6
7
  }
7
8
 
8
- export class PixelTile {
9
- public data32: Uint32Array
10
-
11
- constructor(
12
- public id: number,
13
- public tx: number,
14
- public ty: number,
15
- tileArea: number,
16
- ) {
17
- this.data32 = new Uint32Array(tileArea)
18
- }
19
- }
20
-
21
- export function applyPatchTiles(target: IPixelData, tiles: PixelTile[], tileSize: number = 256) {
9
+ export function applyPatchTiles(target: IPixelData, tiles: PixelTile[], tileSize: number) {
22
10
  for (let i = 0; i < tiles.length; i++) {
23
11
  const tile = tiles[i]
24
12
 
@@ -34,7 +22,7 @@ export function applyPatchTiles(target: IPixelData, tiles: PixelTile[], tileSize
34
22
  // Calculate clamping to prevent wrapping artifacts on image edges
35
23
  const copyWidth = Math.max(0, Math.min(tileSize, dstWidth - startX))
36
24
 
37
- if (copyWidth <= 0) return
25
+ if (copyWidth <= 0) continue
38
26
 
39
27
  for (let ly = 0; ly < tileSize; ly++) {
40
28
  const globalY = startY + ly
@@ -1,13 +1,16 @@
1
1
  import type { IPixelData } from '../_types'
2
- import { type HistoryAction, HistoryManager } from './HistoryManager'
2
+ import { type HistoryActionFactory, makeHistoryAction } from './HistoryAction'
3
+ import { HistoryManager } from './HistoryManager'
3
4
  import { PixelAccumulator } from './PixelAccumulator'
4
5
  import { PixelEngineConfig } from './PixelEngineConfig'
5
- import { applyPatchTiles, type PixelPatchTiles } from './PixelPatchTiles'
6
+ import { PixelTilePool } from '../PixelTile/PixelTilePool'
6
7
 
7
8
  export interface PixelWriterOptions {
8
9
  maxHistorySteps?: number
9
10
  tileSize?: number
10
11
  historyManager?: HistoryManager
12
+ historyActionFactory?: HistoryActionFactory
13
+ pixelTilePool?: PixelTilePool,
11
14
  }
12
15
 
13
16
  /**
@@ -32,52 +35,45 @@ export interface PixelWriterOptions {
32
35
  * })
33
36
  */
34
37
  export class PixelWriter<M> {
35
- public target: IPixelData
36
- public historyManager: HistoryManager
37
- public accumulator: PixelAccumulator
38
- protected config: PixelEngineConfig
38
+ readonly historyManager: HistoryManager
39
+ readonly accumulator: PixelAccumulator
40
+ readonly historyActionFactory: HistoryActionFactory
41
+ readonly config: PixelEngineConfig
39
42
  readonly mutator: M
40
43
 
41
44
  constructor(target: IPixelData, mutatorFactory: (writer: PixelWriter<any>) => M, {
42
45
  tileSize = 256,
43
46
  maxHistorySteps = 50,
44
47
  historyManager = new HistoryManager(maxHistorySteps),
48
+ historyActionFactory = makeHistoryAction,
49
+ pixelTilePool,
45
50
  }: PixelWriterOptions = {}) {
46
- this.target = target
47
- this.config = new PixelEngineConfig(tileSize)
51
+ this.config = new PixelEngineConfig(tileSize, target)
48
52
  this.historyManager = historyManager
49
- this.accumulator = new PixelAccumulator(target, this.config)
53
+ pixelTilePool ??= new PixelTilePool(this.config)
54
+ this.accumulator = new PixelAccumulator(this.config, pixelTilePool)
55
+ this.historyActionFactory = historyActionFactory
50
56
  this.mutator = mutatorFactory(this)
51
57
  }
52
58
 
53
- withHistory(cb: (mutator: M) => void) {
54
- cb(this.mutator)
55
-
56
- this.captureHistory()
57
- }
58
-
59
- captureHistory() {
60
- const beforeTiles = this.accumulator.beforeTiles
61
- if (beforeTiles.length === 0) return
62
-
63
- const afterTiles = this.accumulator.extractAfterTiles()
64
-
65
- const patch: PixelPatchTiles = {
66
- beforeTiles: beforeTiles,
67
- afterTiles: afterTiles,
59
+ withHistory(
60
+ cb: (mutator: M) => void,
61
+ after?: () => void,
62
+ afterUndo?: () => void,
63
+ afterRedo?: () => void,
64
+ ) {
65
+ try {
66
+ cb(this.mutator)
67
+ } catch (e) {
68
+ this.accumulator.rollback()
69
+ throw e
68
70
  }
69
71
 
70
- const target = this.target
71
- const tileSize = this.config.tileSize
72
- const accumulator = this.accumulator
72
+ if (this.accumulator.beforeTiles.length === 0) return
73
73
 
74
- const action: HistoryAction = {
75
- undo: () => applyPatchTiles(target, patch.beforeTiles, tileSize),
76
- redo: () => applyPatchTiles(target, patch.afterTiles, tileSize),
77
- dispose: () => accumulator.recyclePatch(patch),
78
- }
74
+ const patch = this.accumulator.extractPatch()
75
+ const action = this.historyActionFactory(this, patch, after, afterUndo, afterRedo)
79
76
 
80
77
  this.historyManager.commit(action)
81
- this.accumulator.reset()
82
78
  }
83
79
  }
@@ -17,12 +17,10 @@ export function makeReusableImageData() {
17
17
  * @returns The cached or newly allocated ImageData object.
18
18
  */
19
19
  return function getReusableImageData(width: number, height: number) {
20
- const hasInstance = !!imageData
21
- const widthMatches = hasInstance && imageData!.width === width
22
- const heightMatches = hasInstance && imageData!.height === height
23
-
24
- if (!widthMatches || !heightMatches) {
20
+ if (imageData === null || imageData.width !== width || imageData.height !== height) {
25
21
  imageData = new ImageData(width, height)
22
+ } else {
23
+ imageData.data.fill(0)
26
24
  }
27
25
 
28
26
  return imageData!
@@ -1,6 +1,6 @@
1
- import { type CircleBrushAlphaMask, MaskType } from '../_types'
1
+ import { type CircleAlphaMask, MaskType } from '../_types'
2
2
 
3
- export function makeCircleBrushAlphaMask(size: number, fallOff: (d: number) => number = () => 1): CircleBrushAlphaMask {
3
+ export function makeCircleAlphaMask(size: number, fallOff: (d: number) => number = () => 1): CircleAlphaMask {
4
4
  const area = size * size
5
5
  const data = new Uint8Array(area)
6
6
  const radius = size / 2
@@ -1,6 +1,6 @@
1
- import { type CircleBrushBinaryMask, MaskType } from '../_types'
1
+ import { type CircleBinaryMask, MaskType } from '../_types'
2
2
 
3
- export function makeCircleBrushBinaryMask(size: number): CircleBrushBinaryMask {
3
+ export function makeCircleBinaryMask(size: number): CircleBinaryMask {
4
4
  const area = size * size
5
5
  const data = new Uint8Array(area)
6
6
  const radius = size / 2
@@ -1,4 +1,4 @@
1
- import type { ImageDataLike, ImageDataLikeConstructor, IPixelData } from '../_types'
1
+ import type { ImageDataLike, IPixelData } from '../_types'
2
2
  import { imageDataToUInt32Array } from '../ImageData/imageDataToUInt32Array'
3
3
 
4
4
  export class PixelData<T extends ImageDataLike = ImageData> implements IPixelData {
@@ -20,30 +20,4 @@ export class PixelData<T extends ImageDataLike = ImageData> implements IPixelDat
20
20
  ;(this as any).width = imageData.width
21
21
  ;(this as any).height = imageData.height
22
22
  }
23
-
24
- // should only be used for debug and testing
25
- copy(): PixelData<T> {
26
- const data = this.imageData.data
27
- const buffer = new Uint8ClampedArray(data)
28
- const Ctor = this.imageData.constructor
29
- const isCtorValid = typeof Ctor === 'function'
30
-
31
- let newImageData: T
32
- if (isCtorValid && Ctor !== Object) {
33
- const ImageConstructor = Ctor as ImageDataLikeConstructor<T>
34
- newImageData = new ImageConstructor(
35
- buffer,
36
- this.width,
37
- this.height,
38
- )
39
- } else {
40
- newImageData = {
41
- width: this.width,
42
- height: this.height,
43
- data: buffer,
44
- } as unknown as T
45
- }
46
-
47
- return new PixelData<T>(newImageData)
48
- }
49
23
  }
@@ -1,14 +1,15 @@
1
- import { type AlphaMask, type ApplyMaskToPixelDataOptions, type IPixelData } from '../_types'
1
+ import { type AlphaMask, type ApplyMaskToPixelDataOptions, type Color32, type IPixelData } from '../_types'
2
2
 
3
3
  /**
4
4
  * Directly applies a mask to a region of PixelData,
5
5
  * modifying the destination's alpha channel in-place.
6
+ * @returns true if any pixels were actually modified.
6
7
  */
7
8
  export function applyAlphaMaskToPixelData(
8
9
  dst: IPixelData,
9
10
  mask: AlphaMask,
10
11
  opts: ApplyMaskToPixelDataOptions = {},
11
- ): void {
12
+ ): boolean {
12
13
  const {
13
14
  x: targetX = 0,
14
15
  y: targetY = 0,
@@ -20,7 +21,7 @@ export function applyAlphaMaskToPixelData(
20
21
  invertMask = false,
21
22
  } = opts
22
23
 
23
- if (globalAlpha === 0) return
24
+ if (globalAlpha === 0) return false
24
25
 
25
26
  // 1. Initial Destination Clipping
26
27
  let x = targetX
@@ -41,12 +42,12 @@ export function applyAlphaMaskToPixelData(
41
42
  w = Math.min(w, dst.width - x)
42
43
  h = Math.min(h, dst.height - y)
43
44
 
44
- if (w <= 0) return
45
- if (h <= 0) return
45
+ if (w <= 0) return false
46
+ if (h <= 0) return false
46
47
 
47
48
  // 2. Determine Source Dimensions
48
49
  const mPitch = mask.w
49
- if (mPitch <= 0) return
50
+ if (mPitch <= 0) return false
50
51
 
51
52
  // 3. Source Bounds Clipping
52
53
  // Calculate where we would start reading in the mask
@@ -63,8 +64,8 @@ export function applyAlphaMaskToPixelData(
63
64
  const finalH = sY1 - sY0
64
65
 
65
66
  // This is where your failing tests are now caught
66
- if (finalW <= 0) return
67
- if (finalH <= 0) return
67
+ if (finalW <= 0) return false
68
+ if (finalH <= 0) return false
68
69
 
69
70
  // 4. Align Destination with Source Clipping
70
71
  // If the source was clipped on the top/left, we must shift the destination start
@@ -80,6 +81,7 @@ export function applyAlphaMaskToPixelData(
80
81
  let dIdx = (y + yShift) * dw + (x + xShift)
81
82
  let mIdx = sY0 * mPitch + sX0
82
83
 
84
+ let didChange = false
83
85
  for (let iy = 0; iy < h; iy++) {
84
86
  for (let ix = 0; ix < w; ix++) {
85
87
  const mVal = maskData[mIdx]
@@ -101,6 +103,7 @@ export function applyAlphaMaskToPixelData(
101
103
  if (weight === 0) {
102
104
  // Clear alpha channel
103
105
  dst32[dIdx] = (dst32[dIdx] & 0x00ffffff) >>> 0
106
+ didChange = true
104
107
  } else if (weight !== 255) {
105
108
  // Merge alpha channel
106
109
  const d = dst32[dIdx]
@@ -108,7 +111,13 @@ export function applyAlphaMaskToPixelData(
108
111
 
109
112
  if (da !== 0) {
110
113
  const finalAlpha = da === 255 ? weight : (da * weight + 128) >> 8
111
- dst32[dIdx] = ((d & 0x00ffffff) | (finalAlpha << 24)) >>> 0
114
+
115
+ const current = dst32[dIdx] as Color32
116
+ const next = ((d & 0x00ffffff) | (finalAlpha << 24)) >>> 0
117
+ if (current !== next) {
118
+ dst32[dIdx] = next
119
+ didChange = true
120
+ }
112
121
  }
113
122
  }
114
123
 
@@ -119,4 +128,5 @@ export function applyAlphaMaskToPixelData(
119
128
  dIdx += dStride
120
129
  mIdx += mStride
121
130
  }
131
+ return didChange
122
132
  }
@@ -3,12 +3,13 @@ import { type ApplyMaskToPixelDataOptions, type BinaryMask, type IPixelData } fr
3
3
  /**
4
4
  * Directly applies a mask to a region of PixelData,
5
5
  * modifying the destination's alpha channel in-place.
6
+ * @returns true if any pixels were actually modified.
6
7
  */
7
8
  export function applyBinaryMaskToPixelData(
8
9
  dst: IPixelData,
9
10
  mask: BinaryMask,
10
11
  opts: ApplyMaskToPixelDataOptions = {},
11
- ): void {
12
+ ): boolean {
12
13
  const {
13
14
  x: targetX = 0,
14
15
  y: targetY = 0,
@@ -20,9 +21,8 @@ export function applyBinaryMaskToPixelData(
20
21
  invertMask = false,
21
22
  } = opts
22
23
 
23
- if (globalAlpha === 0) return
24
+ if (globalAlpha === 0) return false
24
25
 
25
- // 1. Initial Destination Clipping
26
26
  let x = targetX
27
27
  let y = targetY
28
28
  let w = width
@@ -41,12 +41,10 @@ export function applyBinaryMaskToPixelData(
41
41
  w = Math.min(w, dst.width - x)
42
42
  h = Math.min(h, dst.height - y)
43
43
 
44
- if (w <= 0) return
45
- if (h <= 0) return
44
+ if (w <= 0 || h <= 0) return false
46
45
 
47
- // 2. Determine Source Dimensions
48
46
  const mPitch = mask.w
49
- if (mPitch <= 0) return
47
+ if (mPitch <= 0) return false
50
48
 
51
49
  // 3. Source Bounds Clipping
52
50
  // Calculate where we would start reading in the mask
@@ -62,9 +60,9 @@ export function applyBinaryMaskToPixelData(
62
60
  const finalW = sX1 - sX0
63
61
  const finalH = sY1 - sY0
64
62
 
65
- // This is where your failing tests are now caught
66
- if (finalW <= 0) return
67
- if (finalH <= 0) return
63
+ if (finalW <= 0 || finalH <= 0) {
64
+ return false
65
+ }
68
66
 
69
67
  // 4. Align Destination with Source Clipping
70
68
  // If the source was clipped on the top/left, we must shift the destination start
@@ -79,24 +77,31 @@ export function applyBinaryMaskToPixelData(
79
77
 
80
78
  let dIdx = (y + yShift) * dw + (x + xShift)
81
79
  let mIdx = sY0 * mPitch + sX0
80
+ let didChange = false
82
81
 
83
- for (let iy = 0; iy < h; iy++) {
84
- for (let ix = 0; ix < w; ix++) {
82
+ for (let iy = 0; iy < finalH; iy++) {
83
+ for (let ix = 0; ix < finalW; ix++) {
85
84
  const mVal = maskData[mIdx]
86
- // Consistently determines if this pixel should be "masked out" (cleared)
87
85
  const isMaskedOut = invertMask ? mVal !== 0 : mVal === 0
88
86
 
89
87
  if (isMaskedOut) {
90
- // Clear alpha channel only (keep RGB)
91
- dst32[dIdx] = (dst32[dIdx] & 0x00ffffff) >>> 0
88
+ const current = dst32[dIdx]
89
+ const next = (current & 0x00ffffff) >>> 0
90
+ if (current !== next) {
91
+ dst32[dIdx] = next
92
+ didChange = true
93
+ }
92
94
  } else if (globalAlpha !== 255) {
93
95
  const d = dst32[dIdx]
94
96
  const da = d >>> 24
95
97
 
96
- // If pixel isn't already fully transparent, apply global alpha
97
98
  if (da !== 0) {
98
99
  const finalAlpha = da === 255 ? globalAlpha : (da * globalAlpha + 128) >> 8
99
- dst32[dIdx] = ((d & 0x00ffffff) | (finalAlpha << 24)) >>> 0
100
+ const next = ((d & 0x00ffffff) | (finalAlpha << 24)) >>> 0
101
+ if (d !== next) {
102
+ dst32[dIdx] = next
103
+ didChange = true
104
+ }
100
105
  }
101
106
  }
102
107
 
@@ -107,4 +112,6 @@ export function applyBinaryMaskToPixelData(
107
112
  dIdx += dStride
108
113
  mIdx += mStride
109
114
  }
115
+
116
+ return didChange
110
117
  }
@@ -2,6 +2,10 @@ import type { BlendColor32, Color32, IPixelData, Rect } from '../_types'
2
2
  import { sourceOverPerfect } from '../BlendModes/blend-modes-perfect'
3
3
  import { getRectBrushOrPencilBounds } from '../Rect/getRectBrushOrPencilBounds'
4
4
 
5
+ /**
6
+ * Applies a rectangular brush to the pixel data.
7
+ * @returns true if any pixels were actually modified.
8
+ */
5
9
  export function applyRectBrushToPixelData(
6
10
  target: IPixelData,
7
11
  color: Color32,
@@ -13,7 +17,7 @@ export function applyRectBrushToPixelData(
13
17
  fallOff: (dist: number) => number,
14
18
  blendFn: BlendColor32 = sourceOverPerfect,
15
19
  bounds?: Rect,
16
- ): void {
20
+ ): boolean {
17
21
  const targetWidth = target.width
18
22
  const targetHeight = target.height
19
23
 
@@ -26,7 +30,9 @@ export function applyRectBrushToPixelData(
26
30
  targetHeight,
27
31
  )
28
32
 
29
- if (b.w <= 0 || b.h <= 0) return
33
+ if (b.w <= 0 || b.h <= 0) {
34
+ return false
35
+ }
30
36
 
31
37
  const data32 = target.data32
32
38
  const baseColor = color & 0x00ffffff
@@ -36,7 +42,6 @@ export function applyRectBrushToPixelData(
36
42
  const invHalfW = 1 / (brushWidth / 2)
37
43
  const invHalfH = 1 / (brushHeight / 2)
38
44
 
39
- // Restore the pixel-art centering logic
40
45
  const centerOffsetX = (brushWidth % 2 === 0) ? 0.5 : 0
41
46
  const centerOffsetY = (brushHeight % 2 === 0) ? 0.5 : 0
42
47
  const fCenterX = Math.floor(centerX)
@@ -45,6 +50,7 @@ export function applyRectBrushToPixelData(
45
50
  const endX = b.x + b.w
46
51
  const endY = b.y + b.h
47
52
  const isOverwrite = (blendFn as any).isOverwrite
53
+ let didChange = false
48
54
 
49
55
  for (let py = b.y; py < endY; py++) {
50
56
  const rowOffset = py * targetWidth
@@ -52,7 +58,6 @@ export function applyRectBrushToPixelData(
52
58
 
53
59
  for (let px = b.x; px < endX; px++) {
54
60
  const idx = rowOffset + px
55
-
56
61
  const dx = Math.abs((px - fCenterX) + centerOffsetX) * invHalfW
57
62
  const dist = dx > dy ? dx : dy
58
63
 
@@ -79,7 +84,15 @@ export function applyRectBrushToPixelData(
79
84
  finalCol = ((a << 24) | baseColor) >>> 0 as Color32
80
85
  }
81
86
 
82
- data32[idx] = blendFn(finalCol, data32[idx] as Color32)
87
+ const current = data32[idx] as Color32
88
+ const next = blendFn(finalCol, current)
89
+
90
+ if (current !== next) {
91
+ data32[idx] = next
92
+ didChange = true
93
+ }
83
94
  }
84
95
  }
96
+
97
+ return didChange
85
98
  }
@@ -1,11 +1,15 @@
1
1
  import { type Color32, type ColorBlendOptions, type IPixelData } from '../_types'
2
2
  import { sourceOverPerfect } from '../BlendModes/blend-modes-perfect'
3
3
 
4
+ /**
5
+ * Blends a solid color into a target pixel buffer.
6
+ * @returns true if any pixels were actually modified.
7
+ */
4
8
  export function blendColorPixelData(
5
9
  dst: IPixelData,
6
10
  color: Color32,
7
11
  opts: ColorBlendOptions = {},
8
- ) {
12
+ ): boolean {
9
13
  const {
10
14
  x: targetX = 0,
11
15
  y: targetY = 0,
@@ -15,30 +19,40 @@ export function blendColorPixelData(
15
19
  blendFn = sourceOverPerfect,
16
20
  } = opts
17
21
 
18
- if (globalAlpha === 0) return
22
+ if (globalAlpha === 0) return false
23
+
19
24
  const baseSrcAlpha = (color >>> 24)
20
25
  const isOverwrite = (blendFn as any).isOverwrite || false
21
- if (baseSrcAlpha === 0 && !isOverwrite) return
26
+
27
+ if (baseSrcAlpha === 0 && !isOverwrite) return false
22
28
 
23
29
  // Clipping
24
- let x = targetX, y = targetY, w = width, h = height
30
+ let x = targetX
31
+ let y = targetY
32
+ let w = width
33
+ let h = height
34
+
25
35
  if (x < 0) {
26
36
  w += x
27
37
  x = 0
28
38
  }
39
+
29
40
  if (y < 0) {
30
41
  h += y
31
42
  y = 0
32
43
  }
44
+
33
45
  const actualW = Math.min(w, dst.width - x)
34
46
  const actualH = Math.min(h, dst.height - y)
35
- if (actualW <= 0 || actualH <= 0) return
47
+
48
+ if (actualW <= 0 || actualH <= 0) return false
36
49
 
37
50
  // Single-color fills can pre-calculate the source color once
38
51
  let finalSrcColor = color
52
+
39
53
  if (globalAlpha < 255) {
40
54
  const a = (baseSrcAlpha * globalAlpha + 128) >> 8
41
- if (a === 0 && !isOverwrite) return
55
+ if (a === 0 && !isOverwrite) return false
42
56
  finalSrcColor = ((color & 0x00ffffff) | (a << 24)) >>> 0 as Color32
43
57
  }
44
58
 
@@ -46,12 +60,22 @@ export function blendColorPixelData(
46
60
  const dw = dst.width
47
61
  let dIdx = (y * dw + x) | 0
48
62
  const dStride = (dw - actualW) | 0
63
+ let didChange = false
49
64
 
50
65
  for (let iy = 0; iy < actualH; iy++) {
51
66
  for (let ix = 0; ix < actualW; ix++) {
52
- dst32[dIdx] = blendFn(finalSrcColor, dst32[dIdx] as Color32)
67
+ const current = dst32[dIdx] as Color32
68
+ const next = blendFn(finalSrcColor, current)
69
+
70
+ if (current !== next) {
71
+ dst32[dIdx] = next
72
+ didChange = true
73
+ }
74
+
53
75
  dIdx++
54
76
  }
55
77
  dIdx += dStride
56
78
  }
79
+
80
+ return didChange
57
81
  }
@@ -12,13 +12,14 @@ import { sourceOverPerfect } from '../BlendModes/blend-modes-perfect'
12
12
  * @param color - The solid color to apply.
13
13
  * @param mask - The mask defining the per-pixel opacity of the target area.
14
14
  * @param opts - Configuration options including placement coordinates, bounds, global alpha, and mask offsets.
15
+ * @returns true if any pixels were actually modified.
15
16
  */
16
17
  export function blendColorPixelDataAlphaMask(
17
18
  dst: IPixelData,
18
19
  color: Color32,
19
20
  mask: AlphaMask,
20
21
  opts: ColorBlendMaskOptions = {},
21
- ): void {
22
+ ): boolean {
22
23
  const targetX = opts.x ?? 0
23
24
  const targetY = opts.y ?? 0
24
25
  const w = opts.w ?? mask.w
@@ -29,12 +30,12 @@ export function blendColorPixelDataAlphaMask(
29
30
  const my = opts.my ?? 0
30
31
  const invertMask = opts.invertMask ?? false
31
32
 
32
- if (globalAlpha === 0) return
33
+ if (globalAlpha === 0) return false
33
34
 
34
35
  const baseSrcAlpha = (color >>> 24)
35
36
  const isOverwrite = (blendFn as any).isOverwrite || false
36
37
 
37
- if (baseSrcAlpha === 0 && !isOverwrite) return
38
+ if (baseSrcAlpha === 0 && !isOverwrite) return false
38
39
 
39
40
  let x = targetX
40
41
  let y = targetY
@@ -54,7 +55,7 @@ export function blendColorPixelDataAlphaMask(
54
55
  actualW = Math.min(actualW, dst.width - x)
55
56
  actualH = Math.min(actualH, dst.height - y)
56
57
 
57
- if (actualW <= 0 || actualH <= 0) return
58
+ if (actualW <= 0 || actualH <= 0) return false
58
59
 
59
60
  const dx = (x - targetX) | 0
60
61
  const dy = (y - targetY) | 0
@@ -68,9 +69,10 @@ export function blendColorPixelDataAlphaMask(
68
69
  let mIdx = ((my + dy) * mPitch + (mx + dx)) | 0
69
70
 
70
71
  const dStride = (dw - actualW) | 0
71
- let mStride = (mPitch - actualW) | 0
72
+ const mStride = (mPitch - actualW) | 0
72
73
  const isOpaque = globalAlpha === 255
73
74
  const colorRGB = color & 0x00ffffff
75
+ let didChange = false
74
76
 
75
77
  for (let iy = 0; iy < actualH; iy++) {
76
78
  for (let ix = 0; ix < actualW; ix++) {
@@ -109,7 +111,13 @@ export function blendColorPixelDataAlphaMask(
109
111
  finalCol = ((colorRGB | (a << 24)) >>> 0) as Color32
110
112
  }
111
113
 
112
- dst32[dIdx] = blendFn(finalCol, dst32[dIdx] as Color32)
114
+ const current = dst32[dIdx] as Color32
115
+ const next = blendFn(finalCol, current)
116
+
117
+ if (current !== next) {
118
+ dst32[dIdx] = next
119
+ didChange = true
120
+ }
113
121
 
114
122
  dIdx++
115
123
  mIdx++
@@ -118,4 +126,6 @@ export function blendColorPixelDataAlphaMask(
118
126
  dIdx += dStride
119
127
  mIdx += mStride
120
128
  }
129
+
130
+ return didChange
121
131
  }