diff --git a/src/constants/Categories.ts b/src/constants/Categories.ts index 88230a1..021944a 100644 --- a/src/constants/Categories.ts +++ b/src/constants/Categories.ts @@ -33,6 +33,7 @@ export const CATEGORIES = [ 'Glare Hover', 'Magnet Lines', 'Count Up', + 'Metallic Paint', 'Click Spark', 'Magnet', 'Cubes' diff --git a/src/constants/Components.ts b/src/constants/Components.ts index 6c1a5f2..6f5a3cf 100644 --- a/src/constants/Components.ts +++ b/src/constants/Components.ts @@ -5,6 +5,7 @@ const animations = { 'glare-hover': () => import('../demo/Animations/GlareHoverDemo.vue'), 'magnet-lines': () => import('../demo/Animations/MagnetLinesDemo.vue'), 'click-spark': () => import('../demo/Animations/ClickSparkDemo.vue'), + 'metallic-paint': () => import('../demo/Animations/MetallicPaintDemo.vue'), 'magnet': () => import('../demo/Animations/MagnetDemo.vue'), 'cubes': () => import('../demo/Animations/CubesDemo.vue'), 'count-up': () => import('../demo/Animations/CountUpDemo.vue') diff --git a/src/constants/code/Animations/metallicPaintCode.ts b/src/constants/code/Animations/metallicPaintCode.ts new file mode 100644 index 0000000..de663c3 --- /dev/null +++ b/src/constants/code/Animations/metallicPaintCode.ts @@ -0,0 +1,47 @@ +import code from '@content/Animations/MetallicPaint/MetallicPaint.vue?raw' +import utility from '@content/Animations/MetallicPaint/parseImage.ts?raw' +import type { CodeObject } from '../../../types/code' + +export const metallicPaint: CodeObject = { + cli: `npx jsrepo add https://vue-bits.dev/ui/Animations/MetallicPaint`, + usage: ` + +`, + code, + utility +} diff --git a/src/content/Animations/MetallicPaint/MetallicPaint.vue b/src/content/Animations/MetallicPaint/MetallicPaint.vue new file mode 100644 index 0000000..47a8afa --- /dev/null +++ b/src/content/Animations/MetallicPaint/MetallicPaint.vue @@ -0,0 +1,386 @@ + + + diff --git a/src/content/Animations/MetallicPaint/parseImage.ts b/src/content/Animations/MetallicPaint/parseImage.ts new file mode 100644 index 0000000..4627bee --- /dev/null +++ b/src/content/Animations/MetallicPaint/parseImage.ts @@ -0,0 +1,180 @@ +export function parseImage(file: File): Promise<{ imageData: ImageData; pngBlob: Blob }> { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + return new Promise((resolve, reject) => { + if (!file || !ctx) { + reject(new Error('Invalid file or context')) + return + } + + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = function () { + if (file.type === 'image/svg+xml') { + img.width = 1000 + img.height = 1000 + } + + const MAX_SIZE = 1000 + const MIN_SIZE = 500 + let width = img.naturalWidth + let height = img.naturalHeight + + if (width > MAX_SIZE || height > MAX_SIZE || width < MIN_SIZE || height < MIN_SIZE) { + if (width > height) { + if (width > MAX_SIZE) { + height = Math.round((height * MAX_SIZE) / width) + width = MAX_SIZE + } else if (width < MIN_SIZE) { + height = Math.round((height * MIN_SIZE) / width) + width = MIN_SIZE + } + } else { + if (height > MAX_SIZE) { + width = Math.round((width * MAX_SIZE) / height) + height = MAX_SIZE + } else if (height < MIN_SIZE) { + width = Math.round((width * MIN_SIZE) / height) + height = MIN_SIZE + } + } + } + + canvas.width = width + canvas.height = height + + const shapeCanvas = document.createElement('canvas') + shapeCanvas.width = width + shapeCanvas.height = height + const shapeCtx = shapeCanvas.getContext('2d')! + shapeCtx.drawImage(img, 0, 0, width, height) + + const shapeImageData = shapeCtx.getImageData(0, 0, width, height) + const data = shapeImageData.data + const shapeMask = new Array(width * height).fill(false) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx4 = (y * width + x) * 4 + const r = data[idx4] + const g = data[idx4 + 1] + const b = data[idx4 + 2] + const a = data[idx4 + 3] + shapeMask[y * width + x] = !((r === 255 && g === 255 && b === 255 && a === 255) || a === 0) + } + } + + function inside(x: number, y: number) { + if (x < 0 || x >= width || y < 0 || y >= height) return false + return shapeMask[y * width + x] + } + + const boundaryMask = new Array(width * height).fill(false) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x + if (!shapeMask[idx]) continue + let isBoundary = false + for (let ny = y - 1; ny <= y + 1 && !isBoundary; ny++) { + for (let nx = x - 1; nx <= x + 1 && !isBoundary; nx++) { + if (!inside(nx, ny)) { + isBoundary = true + } + } + } + if (isBoundary) { + boundaryMask[idx] = true + } + } + } + + const interiorMask = new Array(width * height).fill(false) + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = y * width + x + if ( + shapeMask[idx] && + shapeMask[idx - 1] && + shapeMask[idx + 1] && + shapeMask[idx - width] && + shapeMask[idx + width] + ) { + interiorMask[idx] = true + } + } + } + + const u = new Float32Array(width * height).fill(0) + const newU = new Float32Array(width * height).fill(0) + const C = 0.01 + const ITERATIONS = 300 + + function getU(x: number, y: number, arr: Float32Array) { + if (x < 0 || x >= width || y < 0 || y >= height) return 0 + if (!shapeMask[y * width + x]) return 0 + return arr[y * width + x] + } + + for (let iter = 0; iter < ITERATIONS; iter++) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x + if (!shapeMask[idx] || boundaryMask[idx]) { + newU[idx] = 0 + continue + } + const sumN = getU(x + 1, y, u) + getU(x - 1, y, u) + getU(x, y + 1, u) + getU(x, y - 1, u) + newU[idx] = (C + sumN) / 4 + } + } + u.set(newU) + } + + let maxVal = 0 + for (let i = 0; i < width * height; i++) { + if (u[i] > maxVal) maxVal = u[i] + } + const alpha = 2.0 + const outImg = ctx.createImageData(width, height) + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x + const px = idx * 4 + if (!shapeMask[idx]) { + outImg.data[px] = 255 + outImg.data[px + 1] = 255 + outImg.data[px + 2] = 255 + outImg.data[px + 3] = 255 + } else { + const raw = u[idx] / maxVal + const remapped = Math.pow(raw, alpha) + const gray = 255 * (1 - remapped) + outImg.data[px] = gray + outImg.data[px + 1] = gray + outImg.data[px + 2] = gray + outImg.data[px + 3] = 255 + } + } + } + ctx.putImageData(outImg, 0, 0) + + canvas.toBlob( + blob => { + if (!blob) { + reject(new Error('Failed to create PNG blob')) + return + } + resolve({ + imageData: outImg, + pngBlob: blob + }) + }, + 'image/png' + ) + } + + img.onerror = () => reject(new Error('Failed to load image')) + img.src = URL.createObjectURL(file) + }) +} diff --git a/src/demo/Animations/MetallicPaintDemo.vue b/src/demo/Animations/MetallicPaintDemo.vue new file mode 100644 index 0000000..438b53d --- /dev/null +++ b/src/demo/Animations/MetallicPaintDemo.vue @@ -0,0 +1,141 @@ + + +