pixel-data-js 0.18.0 → 0.19.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 (77) hide show
  1. package/README.md +6 -1
  2. package/dist/index.dev.cjs +2723 -1487
  3. package/dist/index.dev.cjs.map +1 -1
  4. package/dist/index.dev.js +2690 -1481
  5. package/dist/index.dev.js.map +1 -1
  6. package/dist/index.prod.cjs +2723 -1487
  7. package/dist/index.prod.cjs.map +1 -1
  8. package/dist/index.prod.d.ts +400 -246
  9. package/dist/index.prod.js +2690 -1481
  10. package/dist/index.prod.js.map +1 -1
  11. package/package.json +21 -6
  12. package/src/Algorithm/forEachLinePoint.ts +36 -0
  13. package/src/BlendModes/BlendModeRegistry.ts +2 -0
  14. package/src/BlendModes/blend-modes-fast.ts +2 -2
  15. package/src/BlendModes/blend-modes-perfect.ts +5 -4
  16. package/src/BlendModes/toBlendModeIndexAndName.ts +41 -0
  17. package/src/History/PixelAccumulator.ts +2 -2
  18. package/src/History/PixelMutator/mutatorApplyAlphaMask.ts +30 -0
  19. package/src/History/PixelMutator/mutatorApplyBinaryMask.ts +30 -0
  20. package/src/History/PixelMutator/mutatorApplyCircleBrush.ts +23 -9
  21. package/src/History/PixelMutator/mutatorApplyCircleBrushStroke.ts +138 -0
  22. package/src/History/PixelMutator/mutatorApplyCirclePencil.ts +59 -0
  23. package/src/History/PixelMutator/mutatorApplyCirclePencilStroke.ts +131 -0
  24. package/src/History/PixelMutator/mutatorApplyRectBrush.ts +20 -7
  25. package/src/History/PixelMutator/mutatorApplyRectBrushStroke.ts +169 -0
  26. package/src/History/PixelMutator/mutatorApplyRectPencil.ts +62 -0
  27. package/src/History/PixelMutator/mutatorApplyRectPencilStroke.ts +149 -0
  28. package/src/History/PixelMutator/mutatorBlendColor.ts +9 -4
  29. package/src/History/PixelMutator/mutatorBlendPixelData.ts +10 -5
  30. package/src/History/PixelMutator/mutatorClear.ts +27 -0
  31. package/src/History/PixelMutator/{mutatorFillPixelData.ts → mutatorFill.ts} +9 -3
  32. package/src/History/PixelMutator/mutatorInvert.ts +10 -3
  33. package/src/History/PixelMutator.ts +23 -3
  34. package/src/History/PixelPatchTiles.ts +2 -2
  35. package/src/History/PixelWriter.ts +3 -3
  36. package/src/ImageData/ImageDataLike.ts +13 -0
  37. package/src/ImageData/extractImageDataBuffer.ts +22 -15
  38. package/src/ImageData/serialization.ts +4 -4
  39. package/src/ImageData/uInt32ArrayToImageData.ts +29 -0
  40. package/src/ImageData/writeImageData.ts +26 -18
  41. package/src/ImageData/writeImageDataBuffer.ts +30 -18
  42. package/src/IndexedImage/indexedImageToAverageColor.ts +1 -1
  43. package/src/Internal/resolveClipping.ts +140 -0
  44. package/src/Mask/applyBinaryMaskToAlphaMask.ts +89 -0
  45. package/src/Mask/copyMask.ts +1 -3
  46. package/src/Mask/mergeAlphaMasks.ts +81 -0
  47. package/src/Mask/mergeBinaryMasks.ts +89 -0
  48. package/src/PixelData/PixelBuffer32.ts +28 -0
  49. package/src/PixelData/PixelData.ts +38 -33
  50. package/src/PixelData/applyAlphaMaskToPixelData.ts +119 -0
  51. package/src/PixelData/applyBinaryMaskToPixelData.ts +111 -0
  52. package/src/PixelData/applyCircleBrushToPixelData.ts +31 -56
  53. package/src/PixelData/applyRectBrushToPixelData.ts +39 -71
  54. package/src/PixelData/blendColorPixelData.ts +18 -111
  55. package/src/PixelData/blendColorPixelDataAlphaMask.ts +111 -0
  56. package/src/PixelData/blendColorPixelDataBinaryMask.ts +89 -0
  57. package/src/PixelData/blendPixelData.ts +19 -107
  58. package/src/PixelData/blendPixelDataAlphaMask.ts +149 -0
  59. package/src/PixelData/blendPixelDataBinaryMask.ts +133 -0
  60. package/src/PixelData/clearPixelData.ts +2 -3
  61. package/src/PixelData/extractPixelData.ts +4 -4
  62. package/src/PixelData/extractPixelDataBuffer.ts +38 -26
  63. package/src/PixelData/fillPixelData.ts +18 -20
  64. package/src/PixelData/invertPixelData.ts +13 -21
  65. package/src/PixelData/pixelDataToAlphaMask.ts +2 -3
  66. package/src/PixelData/reflectPixelData.ts +3 -3
  67. package/src/PixelData/resamplePixelData.ts +2 -6
  68. package/src/PixelData/writePixelDataBuffer.ts +34 -20
  69. package/src/Rect/getCircleBrushOrPencilBounds.ts +43 -0
  70. package/src/Rect/getCircleBrushOrPencilStrokeBounds.ts +24 -0
  71. package/src/Rect/getRectBrushOrPencilBounds.ts +38 -0
  72. package/src/Rect/getRectBrushOrPencilStrokeBounds.ts +26 -0
  73. package/src/_types.ts +49 -33
  74. package/src/index.ts +47 -11
  75. package/src/History/PixelMutator/mutatorApplyMask.ts +0 -20
  76. package/src/Mask/mergeMasks.ts +0 -100
  77. package/src/PixelData/applyMaskToPixelData.ts +0 -129
@@ -1,44 +1,49 @@
1
+ import type { ImageDataLike, ImageDataLikeConstructor, IPixelData } from '../_types'
1
2
  import { imageDataToUInt32Array } from '../ImageData/imageDataToUInt32Array'
2
3
 
3
- export class PixelData {
4
- public data32: Uint32Array
5
- public imageData!: ImageData
4
+ export class PixelData<T extends ImageDataLike = ImageData> implements IPixelData {
5
+ readonly data32: Uint32Array
6
+ readonly imageData: T
7
+ readonly width: number
8
+ readonly height: number
6
9
 
7
- get width(): number {
8
- return this.imageData.width
9
- }
10
-
11
- get height(): number {
12
- return this.imageData.height
13
- }
14
-
15
- constructor(imageData: ImageData) {
10
+ constructor(imageData: T) {
16
11
  this.data32 = imageDataToUInt32Array(imageData)
17
12
  this.imageData = imageData
13
+ this.width = imageData.width
14
+ this.height = imageData.height
18
15
  }
19
16
 
20
- set(imageData: ImageData): void {
21
- this.imageData = imageData
22
- this.data32 = imageDataToUInt32Array(imageData)
17
+ set(imageData: T): void {
18
+ ;(this as any).imageData = imageData
19
+ ;(this as any).data32 = imageDataToUInt32Array(imageData)
20
+ ;(this as any).width = imageData.width
21
+ ;(this as any).height = imageData.height
23
22
  }
24
23
 
25
- /**
26
- * Creates a deep copy of the PixelData using the environment's ImageData constructor.
27
- */
28
- copy(): PixelData {
29
- const buffer = new Uint8ClampedArray(this.imageData.data)
30
-
31
- // Fallback to the object's own constructor if the global ImageData is missing (Node tests)
32
- const ImageConstructor = (typeof ImageData !== 'undefined'
33
- ? ImageData
34
- : (this.imageData.constructor as typeof ImageData))
35
-
36
- const newImageData = new ImageConstructor(
37
- buffer,
38
- this.width,
39
- this.height,
40
- )
41
-
42
- return new PixelData(newImageData)
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)
43
48
  }
44
49
  }
@@ -0,0 +1,119 @@
1
+ import { type AlphaMask, type ApplyMaskToPixelDataOptions, type IPixelData } from '../_types'
2
+
3
+ export function applyAlphaMaskToPixelData(
4
+ dst: IPixelData,
5
+ mask: AlphaMask,
6
+ opts: ApplyMaskToPixelDataOptions = {},
7
+ ): void {
8
+ const {
9
+ x: targetX = 0,
10
+ y: targetY = 0,
11
+ w: width = dst.width,
12
+ h: height = dst.height,
13
+ alpha: globalAlpha = 255,
14
+ mw,
15
+ mx = 0,
16
+ my = 0,
17
+ invertMask = false,
18
+ } = opts
19
+
20
+ if (globalAlpha === 0) return
21
+
22
+ // 1. Initial Destination Clipping
23
+ let x = targetX
24
+ let y = targetY
25
+ let w = width
26
+ let h = height
27
+
28
+ if (x < 0) {
29
+ w += x
30
+ x = 0
31
+ }
32
+
33
+ if (y < 0) {
34
+ h += y
35
+ y = 0
36
+ }
37
+
38
+ w = Math.min(w, dst.width - x)
39
+ h = Math.min(h, dst.height - y)
40
+
41
+ if (w <= 0) return
42
+ if (h <= 0) return
43
+
44
+ // 2. Determine Source Dimensions
45
+ const mPitch = mw ?? width
46
+ if (mPitch <= 0) return
47
+ const maskHeight = (mask.length / mPitch) | 0
48
+
49
+ // 3. Source Bounds Clipping
50
+ // Calculate where we would start reading in the mask
51
+ const startX = mx + (x - targetX)
52
+ const startY = my + (y - targetY)
53
+
54
+ // Find the safe overlap between the requested region and the mask bounds
55
+ const sX0 = Math.max(0, startX)
56
+ const sY0 = Math.max(0, startY)
57
+ const sX1 = Math.min(mPitch, startX + w)
58
+ const sY1 = Math.min(maskHeight, startY + h)
59
+
60
+ const finalW = sX1 - sX0
61
+ const finalH = sY1 - sY0
62
+
63
+ // This is where your failing tests are now caught
64
+ if (finalW <= 0) return
65
+ if (finalH <= 0) return
66
+
67
+ // 4. Align Destination with Source Clipping
68
+ // If the source was clipped on the top/left, we must shift the destination start
69
+ const xShift = sX0 - startX
70
+ const yShift = sY0 - startY
71
+
72
+ const dst32 = dst.data32
73
+ const dw = dst.width
74
+ const dStride = dw - finalW
75
+ const mStride = mPitch - finalW
76
+
77
+ let dIdx = (y + yShift) * dw + (x + xShift)
78
+ let mIdx = sY0 * mPitch + sX0
79
+
80
+ for (let iy = 0; iy < h; iy++) {
81
+ for (let ix = 0; ix < w; ix++) {
82
+ const mVal = mask[mIdx]
83
+ // Unified logic branch inside the hot path
84
+ const effectiveM = invertMask ? 255 - mVal : mVal
85
+
86
+ let weight = 0
87
+
88
+ if (effectiveM === 0) {
89
+ weight = 0
90
+ } else if (effectiveM === 255) {
91
+ weight = globalAlpha
92
+ } else if (globalAlpha === 255) {
93
+ weight = effectiveM
94
+ } else {
95
+ weight = (effectiveM * globalAlpha + 128) >> 8
96
+ }
97
+
98
+ if (weight === 0) {
99
+ // Clear alpha channel
100
+ dst32[dIdx] = (dst32[dIdx] & 0x00ffffff) >>> 0
101
+ } else if (weight !== 255) {
102
+ // Merge alpha channel
103
+ const d = dst32[dIdx]
104
+ const da = d >>> 24
105
+
106
+ if (da !== 0) {
107
+ const finalAlpha = da === 255 ? weight : (da * weight + 128) >> 8
108
+ dst32[dIdx] = ((d & 0x00ffffff) | (finalAlpha << 24)) >>> 0
109
+ }
110
+ }
111
+
112
+ dIdx++
113
+ mIdx++
114
+ }
115
+
116
+ dIdx += dStride
117
+ mIdx += mStride
118
+ }
119
+ }
@@ -0,0 +1,111 @@
1
+ import { type ApplyMaskToPixelDataOptions, type BinaryMask, type IPixelData } from '../_types'
2
+
3
+ /**
4
+ * Directly applies a mask to a region of PixelData,
5
+ * modifying the destination's alpha channel in-place.
6
+ */
7
+ export function applyBinaryMaskToPixelData(
8
+ dst: IPixelData,
9
+ mask: BinaryMask,
10
+ opts: ApplyMaskToPixelDataOptions = {},
11
+ ): void {
12
+ const {
13
+ x: targetX = 0,
14
+ y: targetY = 0,
15
+ w: width = dst.width,
16
+ h: height = dst.height,
17
+ alpha = 255,
18
+ mw,
19
+ mx = 0,
20
+ my = 0,
21
+ invertMask = false,
22
+ } = opts
23
+
24
+ if (alpha === 0) return
25
+
26
+ // 1. Initial Destination Clipping
27
+ let x = targetX
28
+ let y = targetY
29
+ let w = width
30
+ let h = height
31
+
32
+ if (x < 0) {
33
+ w += x
34
+ x = 0
35
+ }
36
+
37
+ if (y < 0) {
38
+ h += y
39
+ y = 0
40
+ }
41
+
42
+ w = Math.min(w, dst.width - x)
43
+ h = Math.min(h, dst.height - y)
44
+
45
+ if (w <= 0) return
46
+ if (h <= 0) return
47
+
48
+ // 2. Determine Source Dimensions
49
+ const mPitch = mw ?? width
50
+ if (mPitch <= 0) return
51
+ const maskHeight = (mask.length / mPitch) | 0
52
+
53
+ // 3. Source Bounds Clipping
54
+ // Calculate where we would start reading in the mask
55
+ const startX = mx + (x - targetX)
56
+ const startY = my + (y - targetY)
57
+
58
+ // Find the safe overlap between the requested region and the mask bounds
59
+ const sX0 = Math.max(0, startX)
60
+ const sY0 = Math.max(0, startY)
61
+ const sX1 = Math.min(mPitch, startX + w)
62
+ const sY1 = Math.min(maskHeight, startY + h)
63
+
64
+ const finalW = sX1 - sX0
65
+ const finalH = sY1 - sY0
66
+
67
+ // This is where your failing tests are now caught
68
+ if (finalW <= 0) return
69
+ if (finalH <= 0) return
70
+
71
+ // 4. Align Destination with Source Clipping
72
+ // If the source was clipped on the top/left, we must shift the destination start
73
+ const xShift = sX0 - startX
74
+ const yShift = sY0 - startY
75
+
76
+ const dst32 = dst.data32
77
+ const dw = dst.width
78
+ const dStride = dw - finalW
79
+ const mStride = mPitch - finalW
80
+
81
+ let dIdx = (y + yShift) * dw + (x + xShift)
82
+ let mIdx = sY0 * mPitch + sX0
83
+
84
+ for (let iy = 0; iy < h; iy++) {
85
+ for (let ix = 0; ix < w; ix++) {
86
+ const mVal = mask[mIdx]
87
+ // Consistently determines if this pixel should be "masked out" (cleared)
88
+ const isMaskedOut = invertMask ? mVal !== 0 : mVal === 0
89
+
90
+ if (isMaskedOut) {
91
+ // Clear alpha channel only (keep RGB)
92
+ dst32[dIdx] = (dst32[dIdx] & 0x00ffffff) >>> 0
93
+ } else if (alpha !== 255) {
94
+ const d = dst32[dIdx]
95
+ const da = d >>> 24
96
+
97
+ // If pixel isn't already fully transparent, apply global alpha
98
+ if (da !== 0) {
99
+ const finalAlpha = da === 255 ? alpha : (da * alpha + 128) >> 8
100
+ dst32[dIdx] = ((d & 0x00ffffff) | (finalAlpha << 24)) >>> 0
101
+ }
102
+ }
103
+
104
+ dIdx++
105
+ mIdx++
106
+ }
107
+
108
+ dIdx += dStride
109
+ mIdx += mStride
110
+ }
111
+ }
@@ -1,6 +1,6 @@
1
- import type { BlendColor32, Color32, Rect } from '../_types'
1
+ import type { BlendColor32, Color32, IPixelData, Rect } from '../_types'
2
2
  import { sourceOverPerfect } from '../BlendModes/blend-modes-perfect'
3
- import type { PixelData } from './PixelData'
3
+ import { getCircleBrushOrPencilBounds } from '../Rect/getCircleBrushOrPencilBounds'
4
4
 
5
5
  /**
6
6
  * Applies a circular brush to pixel data, blending a color with optional falloff.
@@ -14,17 +14,17 @@ import type { PixelData } from './PixelData'
14
14
  * @default 255
15
15
  * @param fallOff A function that returns an alpha multiplier (0-1) based on the normalized distance (0-1) from the circle's center.
16
16
  * @param blendFn
17
- * @param bounds precalculated result from {@link getCircleBrushBounds}
17
+ * @param bounds precalculated result from {@link getCircleBrushOrPencilBounds}
18
18
  * @default sourceOverPerfect
19
19
  */
20
20
  export function applyCircleBrushToPixelData(
21
- target: PixelData,
21
+ target: IPixelData,
22
22
  color: Color32,
23
23
  centerX: number,
24
24
  centerY: number,
25
25
  brushSize: number,
26
26
  alpha = 255,
27
- fallOff?: (dist: number) => number,
27
+ fallOff: (dist: number) => number,
28
28
  blendFn: BlendColor32 = sourceOverPerfect,
29
29
  bounds?: Rect,
30
30
  ): void {
@@ -32,12 +32,12 @@ export function applyCircleBrushToPixelData(
32
32
  const targetHeight = target.height
33
33
 
34
34
  // Use provided bounds OR calculate them once
35
- const b = bounds ?? getCircleBrushBounds(
35
+ const b = bounds ?? getCircleBrushOrPencilBounds(
36
36
  centerX,
37
37
  centerY,
38
38
  brushSize,
39
39
  targetWidth,
40
- targetHeight
40
+ targetHeight,
41
41
  )
42
42
 
43
43
  if (b.w <= 0 || b.h <= 0) return
@@ -48,8 +48,6 @@ export function applyCircleBrushToPixelData(
48
48
  const invR = 1 / r
49
49
 
50
50
  const centerOffset = (brushSize % 2 === 0) ? 0.5 : 0
51
- const baseColor = color & 0x00ffffff
52
- const constantSrc = ((alpha << 24) | baseColor) >>> 0 as Color32
53
51
 
54
52
  const endX = b.x + b.w
55
53
  const endY = b.y + b.h
@@ -57,6 +55,10 @@ export function applyCircleBrushToPixelData(
57
55
  // Anchor the math to the floor of the center for exact pixel art parity
58
56
  const fCenterX = Math.floor(centerX)
59
57
  const fCenterY = Math.floor(centerY)
58
+ const baseSrcAlpha = (color >>> 24)
59
+ const colorRGB = color & 0x00ffffff
60
+ const isOpaque = alpha === 255
61
+ const isOverwrite = (blendFn as any).isOverwrite
60
62
 
61
63
  for (let cy = b.y; cy < endY; cy++) {
62
64
  const relY = (cy - fCenterY) + centerOffset
@@ -69,56 +71,29 @@ export function applyCircleBrushToPixelData(
69
71
 
70
72
  if (dSqr <= rSqr) {
71
73
  const idx = rowOffset + cx
74
+ let weight = alpha
72
75
 
73
- if (fallOff) {
74
- const strength = fallOff(Math.sqrt(dSqr) * invR)
75
- const fAlpha = (alpha * strength) & 0xFF
76
- const src = ((fAlpha << 24) | baseColor) >>> 0 as Color32
77
- data32[idx] = blendFn(src, data32[idx] as Color32)
78
- } else {
79
- data32[idx] = blendFn(constantSrc, data32[idx] as Color32)
80
- }
81
- }
82
- }
83
- }
84
- }
76
+ const strength = fallOff(1 - (Math.sqrt(dSqr) * invR))
77
+ const maskVal = (strength * 255) | 0
78
+ if (maskVal === 0) continue
85
79
 
86
- export function getCircleBrushBounds(
87
- centerX: number,
88
- centerY: number,
89
- brushSize: number,
90
- targetWidth?: number,
91
- targetHeight?: number,
92
- out?: Rect,
93
- ): Rect {
94
- const r = brushSize / 2
95
-
96
- // These offsets match your getPerfectCircleCoords exactly
97
- const minOffset = -Math.ceil(r - 0.5)
98
- const maxOffset = Math.floor(r - 0.5)
80
+ // Match Blitter's weight calculation exactly
81
+ if (isOpaque) {
82
+ weight = maskVal
83
+ } else if (maskVal !== 255) {
84
+ weight = (maskVal * alpha + 128) >> 8
85
+ }
99
86
 
100
- // start is inclusive, end is exclusive
101
- const startX = Math.floor(centerX + minOffset)
102
- const startY = Math.floor(centerY + minOffset)
103
- const endX = Math.floor(centerX + maxOffset) + 1
104
- const endY = Math.floor(centerY + maxOffset) + 1
87
+ // Match Blitter's final color calculation exactly
88
+ let finalCol = color
89
+ if (weight < 255) {
90
+ const a = (baseSrcAlpha * weight + 128) >> 8
91
+ if (a === 0 && !isOverwrite) continue
92
+ finalCol = (colorRGB | (a << 24)) >>> 0 as Color32
93
+ }
105
94
 
106
- const res = out ?? {
107
- x: 0,
108
- y: 0,
109
- w: 0,
110
- h: 0,
95
+ data32[idx] = blendFn(finalCol, data32[idx] as Color32)
96
+ }
97
+ }
111
98
  }
112
-
113
- const cStartX = targetWidth !== undefined ? Math.max(0, startX) : startX
114
- const cStartY = targetHeight !== undefined ? Math.max(0, startY) : startY
115
- const cEndX = targetWidth !== undefined ? Math.min(targetWidth, endX) : endX
116
- const cEndY = targetHeight !== undefined ? Math.min(targetHeight, endY) : endY
117
-
118
- res.x = cStartX
119
- res.y = cStartY
120
- res.w = Math.max(0, cEndX - cStartX)
121
- res.h = Math.max(0, cEndY - cStartY)
122
-
123
- return res
124
99
  }
@@ -1,117 +1,85 @@
1
- import type { BlendColor32, Color32, Rect } from '../_types'
1
+ import type { BlendColor32, Color32, IPixelData, Rect } from '../_types'
2
2
  import { sourceOverPerfect } from '../BlendModes/blend-modes-perfect'
3
- import { PixelData } from './PixelData'
4
-
5
- /**
6
- * Applies a rectangular brush to pixel data, blending a color with optional falloff.
7
- *
8
- * @param target The PixelData to modify.
9
- * @param color The brush color.
10
- * @param centerX The center x-coordinate of the brush.
11
- * @param centerY The center y-coordinate of the brush.
12
- * @param brushWidth
13
- * @param brushHeight
14
- * @param alpha The overall opacity of the brush (0-255).
15
- * @default 255
16
- * @param fallOff A function that returns an alpha multiplier (0-1) based on the normalized distance (0-1) from the circle's center.
17
- * @param blendFn
18
- * @param bounds precalculated result from {@link getRectBrushBounds}
19
- * @default sourceOverPerfect
20
- */
3
+ import { getRectBrushOrPencilBounds } from '../Rect/getRectBrushOrPencilBounds'
4
+
21
5
  export function applyRectBrushToPixelData(
22
- target: PixelData,
6
+ target: IPixelData,
23
7
  color: Color32,
24
8
  centerX: number,
25
9
  centerY: number,
26
10
  brushWidth: number,
27
11
  brushHeight: number,
28
12
  alpha = 255,
29
- fallOff?: (dist: number) => number,
13
+ fallOff: (dist: number) => number,
30
14
  blendFn: BlendColor32 = sourceOverPerfect,
31
15
  bounds?: Rect,
32
16
  ): void {
33
17
  const targetWidth = target.width
34
18
  const targetHeight = target.height
35
19
 
36
- // Use provided bounds or compute once
37
- const b = bounds ?? getRectBrushBounds(
20
+ const b = bounds ?? getRectBrushOrPencilBounds(
38
21
  centerX,
39
22
  centerY,
40
23
  brushWidth,
41
24
  brushHeight,
42
25
  targetWidth,
43
- targetHeight
26
+ targetHeight,
44
27
  )
45
28
 
46
29
  if (b.w <= 0 || b.h <= 0) return
47
30
 
48
31
  const data32 = target.data32
49
32
  const baseColor = color & 0x00ffffff
50
- const constantSrc = ((alpha << 24) | baseColor) >>> 0 as Color32
33
+ const baseSrcAlpha = color >>> 24
34
+ const isOpaque = alpha === 255
51
35
 
52
36
  const invHalfW = 1 / (brushWidth / 2)
53
37
  const invHalfH = 1 / (brushHeight / 2)
38
+
39
+ // Restore the pixel-art centering logic
40
+ const centerOffsetX = (brushWidth % 2 === 0) ? 0.5 : 0
41
+ const centerOffsetY = (brushHeight % 2 === 0) ? 0.5 : 0
42
+ const fCenterX = Math.floor(centerX)
43
+ const fCenterY = Math.floor(centerY)
44
+
54
45
  const endX = b.x + b.w
55
46
  const endY = b.y + b.h
47
+ const isOverwrite = (blendFn as any).isOverwrite
56
48
 
57
49
  for (let py = b.y; py < endY; py++) {
58
50
  const rowOffset = py * targetWidth
59
-
60
- // Y-distance check for falloff (center of pixel to center of brush)
61
- const dy = fallOff ? Math.abs(py + 0.5 - centerY) * invHalfH : 0
51
+ const dy = Math.abs((py - fCenterY) + centerOffsetY) * invHalfH
62
52
 
63
53
  for (let px = b.x; px < endX; px++) {
64
54
  const idx = rowOffset + px
65
55
 
66
- if (fallOff) {
67
- const dx = Math.abs(px + 0.5 - centerX) * invHalfW
68
- const dist = dx > dy ? dx : dy
56
+ const dx = Math.abs((px - fCenterX) + centerOffsetX) * invHalfW
57
+ const dist = dx > dy ? dx : dy
58
+
59
+ const strength = fallOff(dist)
60
+ const maskVal = (strength * 255) | 0
69
61
 
70
- const strength = fallOff(dist)
71
- const fAlpha = (alpha * strength) | 0
72
- const src = ((fAlpha << 24) | baseColor) >>> 0 as Color32
62
+ if (maskVal <= 0) continue
73
63
 
74
- data32[idx] = blendFn(src, data32[idx] as Color32)
75
- } else {
76
- data32[idx] = blendFn(constantSrc, data32[idx] as Color32)
64
+ let weight = alpha
65
+
66
+ if (isOpaque) {
67
+ weight = maskVal
68
+ } else if (maskVal !== 255) {
69
+ weight = (maskVal * alpha + 128) >> 8
77
70
  }
78
- }
79
- }
80
- }
81
71
 
82
- export function getRectBrushBounds(
83
- centerX: number,
84
- centerY: number,
85
- brushWidth: number,
86
- brushHeight: number,
87
- targetWidth?: number,
88
- targetHeight?: number,
89
- out?: Rect,
90
- ): Rect {
91
- const startX = Math.floor(centerX - brushWidth / 2)
92
- const startY = Math.floor(centerY - brushHeight / 2)
93
- const endX = startX + brushWidth
94
- const endY = startY + brushHeight
95
-
96
- const res = out ?? {
97
- x: 0,
98
- y: 0,
99
- w: 0,
100
- h: 0,
101
- }
72
+ let finalCol = color
102
73
 
103
- const cStartX = targetWidth !== undefined ? Math.max(0, startX) : startX
104
- const cStartY = targetHeight !== undefined ? Math.max(0, startY) : startY
105
- const cEndX = targetWidth !== undefined ? Math.min(targetWidth, endX) : endX
106
- const cEndY = targetHeight !== undefined ? Math.min(targetHeight, endY) : endY
74
+ if (weight < 255) {
75
+ const a = (baseSrcAlpha * weight + 128) >> 8
107
76
 
108
- const w = cEndX - cStartX
109
- const h = cEndY - cStartY
77
+ if (a === 0 && !isOverwrite) continue
110
78
 
111
- res.x = cStartX
112
- res.y = cStartY
113
- res.w = w < 0 ? 0 : w
114
- res.h = h < 0 ? 0 : h
79
+ finalCol = ((a << 24) | baseColor) >>> 0 as Color32
80
+ }
115
81
 
116
- return res
82
+ data32[idx] = blendFn(finalCol, data32[idx] as Color32)
83
+ }
84
+ }
117
85
  }