diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 10ed530..72d09aa 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -1871,6 +1871,116 @@ } }; + // src/uv_fill.ts + function setupUVFill() { + const fillModeSelect = BarItems.fill_mode; + fillModeSelect.options["uv"] = { name: "UV" }; + const originalUseFilltool = Painter.useFilltool; + Painter.useFilltool = function(texture, ctx, x, y, area) { + if (fillModeSelect.get() !== "uv") { + return originalUseFilltool.call(Painter, texture, ctx, x, y, area); + } + uvRegionFill(texture, ctx, x, y, area); + }; + track({ + delete() { + Painter.useFilltool = originalUseFilltool; + delete fillModeSelect.options["uv"]; + } + }); + } + function uvRegionFill(texture, ctx, x, y, area) { + const region = findFaceRegion(texture, x, y, area.uvFactorX, area.uvFactorY); + if (!region) return; + const clickedAlpha = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1).data[3]; + if (clickedAlpha === 0) { + fillTransparent(ctx, region); + } else if (isFlatColor(ctx, region)) { + fillRegion(ctx, region); + } + } + function findFaceRegion(texture, x, y, uvFactorX, uvFactorY) { + const animOffset = texture.display_height * texture.currentFrame; + for (const cube of Cube.all) { + for (const faceKey in cube.faces) { + const face = cube.faces[faceKey]; + const faceTexture = face.getTexture(); + if (!faceTexture || Painter.getTextureToEdit(faceTexture) !== texture) continue; + const uv = face.uv; + if (!uv) continue; + const minX = Math.floor(Math.min(uv[0], uv[2]) * uvFactorX); + const maxX = Math.ceil(Math.max(uv[0], uv[2]) * uvFactorX); + const minY = Math.floor(Math.min(uv[1], uv[3]) * uvFactorY) + animOffset; + const maxY = Math.ceil(Math.max(uv[1], uv[3]) * uvFactorY) + animOffset; + if (x >= minX && x < maxX && y >= minY && y < maxY) { + return { minX, minY, maxX, maxY }; + } + } + } + for (const mesh of Mesh.all) { + for (const faceKey in mesh.faces) { + const face = mesh.faces[faceKey]; + const faceTexture = face.getTexture(); + if (!faceTexture || Painter.getTextureToEdit(faceTexture) !== texture) continue; + if (face.vertices.length < 3) continue; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const vkey in face.uv) { + const uv = face.uv[vkey]; + minX = Math.min(minX, uv[0] * uvFactorX); + maxX = Math.max(maxX, uv[0] * uvFactorX); + minY = Math.min(minY, uv[1] * uvFactorY); + maxY = Math.max(maxY, uv[1] * uvFactorY); + } + minX = Math.floor(minX); + minY = Math.floor(minY) + animOffset; + maxX = Math.ceil(maxX); + maxY = Math.ceil(maxY) + animOffset; + if (x >= minX && x < maxX && y >= minY && y < maxY) { + return { minX, minY, maxX, maxY }; + } + } + } + return null; + } + function isFlatColor(ctx, region) { + const width = region.maxX - region.minX; + const height = region.maxY - region.minY; + if (width <= 0 || height <= 0) return false; + const data = ctx.getImageData(region.minX, region.minY, width, height).data; + const [r, g, b, a] = [data[0], data[1], data[2], data[3]]; + for (let i = 4; i < data.length; i += 4) { + if (data[i] !== r || data[i + 1] !== g || data[i + 2] !== b || data[i + 3] !== a) { + return false; + } + } + return true; + } + function fillRegion(ctx, region) { + const opacity = BarItems.slider_brush_opacity.get() / 255; + ctx.save(); + ctx.fillStyle = tinycolor(ColorPanel.get()).setAlpha(opacity).toRgbString(); + ctx.fillRect(region.minX, region.minY, region.maxX - region.minX, region.maxY - region.minY); + ctx.restore(); + } + function fillTransparent(ctx, region) { + const width = region.maxX - region.minX; + const height = region.maxY - region.minY; + if (width <= 0 || height <= 0) return; + const imageData = ctx.getImageData(region.minX, region.minY, width, height); + const data = imageData.data; + const color = tinycolor(ColorPanel.get()).toRgb(); + const alpha = Math.round(BarItems.slider_brush_opacity.get() / 255 * 255); + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) { + data[i] = color.r; + data[i + 1] = color.g; + data[i + 2] = color.b; + data[i + 3] = alpha; + } + } + ctx.putImageData(imageData, region.minX, region.minY); + } + // src/plugin.ts BBPlugin.register("hytale_plugin", { title: "Hytale Models", @@ -1895,6 +2005,7 @@ setupPhotoshopTools(); setupUVCycling(); setupTextureHandling(); + setupUVFill(); let pivot_marker = new CustomPivotMarker(); track(pivot_marker); }, diff --git a/src/plugin.ts b/src/plugin.ts index 26a5c67..e692655 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -11,6 +11,7 @@ import { setupFormats } from "./formats"; import { setupPhotoshopTools } from "./photoshop_copy_paste"; import { CustomPivotMarker } from "./pivot_marker" import { setupTextureHandling } from "./texture"; +import { setupUVFill } from "./uv_fill"; BBPlugin.register('hytale_plugin', { title: 'Hytale Models', @@ -36,6 +37,7 @@ BBPlugin.register('hytale_plugin', { setupPhotoshopTools(); setupUVCycling(); setupTextureHandling(); + setupUVFill(); let pivot_marker = new CustomPivotMarker(); track(pivot_marker) diff --git a/src/uv_fill.ts b/src/uv_fill.ts new file mode 100644 index 0000000..74ed25a --- /dev/null +++ b/src/uv_fill.ts @@ -0,0 +1,153 @@ +import { track } from "./cleanup"; + +interface UVRegion { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +interface FillArea { + rect: number[]; + uvFactorX: number; + uvFactorY: number; + w: number; + h: number; +} + +// Adds "UV" fill mode: fills transparent areas or replaces flat-colored faces +export function setupUVFill() { + const fillModeSelect = BarItems.fill_mode as BarSelect; + fillModeSelect.options['uv'] = { name: 'UV' }; + + const originalUseFilltool = Painter.useFilltool; + + Painter.useFilltool = function(texture: Texture, ctx: CanvasRenderingContext2D, x: number, y: number, area: FillArea) { + if (fillModeSelect.get() !== 'uv') { + return originalUseFilltool.call(Painter, texture, ctx, x, y, area); + } + uvRegionFill(texture, ctx, x, y, area); + }; + + track({ + delete() { + Painter.useFilltool = originalUseFilltool; + delete fillModeSelect.options['uv']; + } + }); +} + +// Main fill logic: transparent click fills transparent pixels, flat color click replaces entire face +function uvRegionFill(texture: Texture, ctx: CanvasRenderingContext2D, x: number, y: number, area: FillArea) { + const region = findFaceRegion(texture, x, y, area.uvFactorX, area.uvFactorY); + if (!region) return; + + const clickedAlpha = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1).data[3]; + + if (clickedAlpha === 0) { + fillTransparent(ctx, region); + } else if (isFlatColor(ctx, region)) { + fillRegion(ctx, region); + } +} + +// Returns UV bounds of the face containing the point, or null if none found +function findFaceRegion(texture: Texture, x: number, y: number, uvFactorX: number, uvFactorY: number): UVRegion | null { + const animOffset = texture.display_height * texture.currentFrame; + + for (const cube of Cube.all) { + for (const faceKey in cube.faces) { + const face = cube.faces[faceKey as CubeFaceDirection]; + const faceTexture = face.getTexture(); + if (!faceTexture || (Painter.getTextureToEdit(faceTexture) as Texture) !== texture) continue; + + const uv = face.uv; + if (!uv) continue; + + const minX = Math.floor(Math.min(uv[0], uv[2]) * uvFactorX); + const maxX = Math.ceil(Math.max(uv[0], uv[2]) * uvFactorX); + const minY = Math.floor(Math.min(uv[1], uv[3]) * uvFactorY) + animOffset; + const maxY = Math.ceil(Math.max(uv[1], uv[3]) * uvFactorY) + animOffset; + + if (x >= minX && x < maxX && y >= minY && y < maxY) { + return { minX, minY, maxX, maxY }; + } + } + } + + for (const mesh of Mesh.all) { + for (const faceKey in mesh.faces) { + const face = mesh.faces[faceKey]; + const faceTexture = face.getTexture(); + if (!faceTexture || (Painter.getTextureToEdit(faceTexture) as Texture) !== texture) continue; + if (face.vertices.length < 3) continue; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const vkey in face.uv) { + const uv = face.uv[vkey]; + minX = Math.min(minX, uv[0] * uvFactorX); + maxX = Math.max(maxX, uv[0] * uvFactorX); + minY = Math.min(minY, uv[1] * uvFactorY); + maxY = Math.max(maxY, uv[1] * uvFactorY); + } + + minX = Math.floor(minX); + minY = Math.floor(minY) + animOffset; + maxX = Math.ceil(maxX); + maxY = Math.ceil(maxY) + animOffset; + + if (x >= minX && x < maxX && y >= minY && y < maxY) { + return { minX, minY, maxX, maxY }; + } + } + } + + return null; +} + +function isFlatColor(ctx: CanvasRenderingContext2D, region: UVRegion): boolean { + const width = region.maxX - region.minX; + const height = region.maxY - region.minY; + if (width <= 0 || height <= 0) return false; + + const data = ctx.getImageData(region.minX, region.minY, width, height).data; + const [r, g, b, a] = [data[0], data[1], data[2], data[3]]; + + for (let i = 4; i < data.length; i += 4) { + if (data[i] !== r || data[i + 1] !== g || data[i + 2] !== b || data[i + 3] !== a) { + return false; + } + } + return true; +} + +function fillRegion(ctx: CanvasRenderingContext2D, region: UVRegion) { + const opacity = (BarItems.slider_brush_opacity as BarSlider).get() / 255; + + ctx.save(); + ctx.fillStyle = tinycolor(ColorPanel.get()).setAlpha(opacity).toRgbString(); + ctx.fillRect(region.minX, region.minY, region.maxX - region.minX, region.maxY - region.minY); + ctx.restore(); +} + +function fillTransparent(ctx: CanvasRenderingContext2D, region: UVRegion) { + const width = region.maxX - region.minX; + const height = region.maxY - region.minY; + if (width <= 0 || height <= 0) return; + + const imageData = ctx.getImageData(region.minX, region.minY, width, height); + const data = imageData.data; + const color = tinycolor(ColorPanel.get()).toRgb(); + const alpha = Math.round((BarItems.slider_brush_opacity as BarSlider).get() / 255 * 255); + + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) { + data[i] = color.r; + data[i + 1] = color.g; + data[i + 2] = color.b; + data[i + 3] = alpha; + } + } + + ctx.putImageData(imageData, region.minX, region.minY); +}