From 5bec373b81aaff5256dfafb280859e1106f93ead Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Tue, 16 Dec 2025 18:56:14 +0100 Subject: [PATCH 1/2] UV fill mode for bucket --- dist/hytale_plugin.js | 88 ++++++++++++++++++++++++++++ src/plugin.ts | 2 + src/uv_fill.ts | 130 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 src/uv_fill.ts diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 10ed530..2a66931 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -1871,6 +1871,93 @@ } }; + // 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, clickX, clickY, area) { + const region = findFaceAtPoint(texture, clickX, clickY, area.uvFactorX, area.uvFactorY); + if (region) { + fillRegion(ctx, region); + } + } + function findFaceAtPoint(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 uvCoord = face.uv[vkey]; + minX = Math.min(minX, uvCoord[0] * uvFactorX); + maxX = Math.max(maxX, uvCoord[0] * uvFactorX); + minY = Math.min(minY, uvCoord[1] * uvFactorY); + maxY = Math.max(maxY, uvCoord[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 fillRegion(ctx, region) { + const opacity = BarItems.slider_brush_opacity.get() / 255; + const eraseMode = Painter.erase_mode; + const lockAlpha = Painter.lock_alpha; + ctx.save(); + if (eraseMode) { + ctx.globalAlpha = opacity; + ctx.fillStyle = "white"; + ctx.globalCompositeOperation = "destination-out"; + } else { + ctx.fillStyle = tinycolor(ColorPanel.get()).setAlpha(opacity).toRgbString(); + ctx.globalCompositeOperation = Painter.getBlendModeCompositeOperation(); + if (lockAlpha) { + ctx.globalCompositeOperation = "source-atop"; + } + } + ctx.fillRect(region.minX, region.minY, region.maxX - region.minX, region.maxY - region.minY); + ctx.restore(); + } + // src/plugin.ts BBPlugin.register("hytale_plugin", { title: "Hytale Models", @@ -1895,6 +1982,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..0734af5 --- /dev/null +++ b/src/uv_fill.ts @@ -0,0 +1,130 @@ +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 a "UV" fill mode that fills the UV region of any face clicked on the texture +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']; + } + }); +} + +// Finds the face UV region at click point and fills it with solid color +function uvRegionFill(texture: Texture, ctx: CanvasRenderingContext2D, clickX: number, clickY: number, area: FillArea) { + const region = findFaceAtPoint(texture, clickX, clickY, area.uvFactorX, area.uvFactorY); + if (region) { + fillRegion(ctx, region); + } +} + +// Returns the UV region of the face containing the given point +function findFaceAtPoint(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 uvCoord = face.uv[vkey]; + minX = Math.min(minX, uvCoord[0] * uvFactorX); + maxX = Math.max(maxX, uvCoord[0] * uvFactorX); + minY = Math.min(minY, uvCoord[1] * uvFactorY); + maxY = Math.max(maxY, uvCoord[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; +} + +// Fills a rectangular region with the current paint color +function fillRegion(ctx: CanvasRenderingContext2D, region: UVRegion) { + const opacity = (BarItems.slider_brush_opacity as BarSlider).get() / 255; + const eraseMode = (Painter as any).erase_mode as boolean; + const lockAlpha = (Painter as any).lock_alpha as boolean; + + ctx.save(); + + if (eraseMode) { + ctx.globalAlpha = opacity; + ctx.fillStyle = 'white'; + ctx.globalCompositeOperation = 'destination-out'; + } else { + ctx.fillStyle = tinycolor(ColorPanel.get()).setAlpha(opacity).toRgbString(); + ctx.globalCompositeOperation = (Painter as any).getBlendModeCompositeOperation() as GlobalCompositeOperation; + if (lockAlpha) { + ctx.globalCompositeOperation = 'source-atop'; + } + } + + ctx.fillRect(region.minX, region.minY, region.maxX - region.minX, region.maxY - region.minY); + ctx.restore(); +} From 1a90a1ea2a1e32e7414d6b8403a6bd154d5826fc Mon Sep 17 00:00:00 2001 From: Matteo Simonetti Date: Tue, 16 Dec 2025 19:07:26 +0100 Subject: [PATCH 2/2] Filling logic change and clean up --- dist/hytale_plugin.js | 67 +++++++++++++++++++++----------- src/uv_fill.ts | 89 +++++++++++++++++++++++++++---------------- 2 files changed, 101 insertions(+), 55 deletions(-) diff --git a/dist/hytale_plugin.js b/dist/hytale_plugin.js index 2a66931..72d09aa 100644 --- a/dist/hytale_plugin.js +++ b/dist/hytale_plugin.js @@ -1889,13 +1889,17 @@ } }); } - function uvRegionFill(texture, ctx, clickX, clickY, area) { - const region = findFaceAtPoint(texture, clickX, clickY, area.uvFactorX, area.uvFactorY); - if (region) { + 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 findFaceAtPoint(texture, x, y, uvFactorX, uvFactorY) { + 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) { @@ -1921,11 +1925,11 @@ if (face.vertices.length < 3) continue; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const vkey in face.uv) { - const uvCoord = face.uv[vkey]; - minX = Math.min(minX, uvCoord[0] * uvFactorX); - maxX = Math.max(maxX, uvCoord[0] * uvFactorX); - minY = Math.min(minY, uvCoord[1] * uvFactorY); - maxY = Math.max(maxY, uvCoord[1] * uvFactorY); + 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; @@ -1938,25 +1942,44 @@ } 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; - const eraseMode = Painter.erase_mode; - const lockAlpha = Painter.lock_alpha; ctx.save(); - if (eraseMode) { - ctx.globalAlpha = opacity; - ctx.fillStyle = "white"; - ctx.globalCompositeOperation = "destination-out"; - } else { - ctx.fillStyle = tinycolor(ColorPanel.get()).setAlpha(opacity).toRgbString(); - ctx.globalCompositeOperation = Painter.getBlendModeCompositeOperation(); - if (lockAlpha) { - ctx.globalCompositeOperation = "source-atop"; - } - } + 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", { diff --git a/src/uv_fill.ts b/src/uv_fill.ts index 0734af5..74ed25a 100644 --- a/src/uv_fill.ts +++ b/src/uv_fill.ts @@ -15,20 +15,14 @@ interface FillArea { h: number; } -// Adds a "UV" fill mode that fills the UV region of any face clicked on the texture +// 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 - ) { + 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); } @@ -43,16 +37,22 @@ export function setupUVFill() { }); } -// Finds the face UV region at click point and fills it with solid color -function uvRegionFill(texture: Texture, ctx: CanvasRenderingContext2D, clickX: number, clickY: number, area: FillArea) { - const region = findFaceAtPoint(texture, clickX, clickY, area.uvFactorX, area.uvFactorY); - if (region) { +// 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 the UV region of the face containing the given point -function findFaceAtPoint(texture: Texture, x: number, y: number, uvFactorX: number, uvFactorY: number): UVRegion | null { +// 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) { @@ -84,11 +84,11 @@ function findFaceAtPoint(texture: Texture, x: number, y: number, uvFactorX: numb let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const vkey in face.uv) { - const uvCoord = face.uv[vkey]; - minX = Math.min(minX, uvCoord[0] * uvFactorX); - maxX = Math.max(maxX, uvCoord[0] * uvFactorX); - minY = Math.min(minY, uvCoord[1] * uvFactorY); - maxY = Math.max(maxY, uvCoord[1] * uvFactorY); + 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); @@ -105,26 +105,49 @@ function findFaceAtPoint(texture: Texture, x: number, y: number, uvFactorX: numb return null; } -// Fills a rectangular region with the current paint color +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; - const eraseMode = (Painter as any).erase_mode as boolean; - const lockAlpha = (Painter as any).lock_alpha as boolean; 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(); +} - if (eraseMode) { - ctx.globalAlpha = opacity; - ctx.fillStyle = 'white'; - ctx.globalCompositeOperation = 'destination-out'; - } else { - ctx.fillStyle = tinycolor(ColorPanel.get()).setAlpha(opacity).toRgbString(); - ctx.globalCompositeOperation = (Painter as any).getBlendModeCompositeOperation() as GlobalCompositeOperation; - if (lockAlpha) { - ctx.globalCompositeOperation = 'source-atop'; +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.fillRect(region.minX, region.minY, region.maxX - region.minX, region.maxY - region.minY); - ctx.restore(); + ctx.putImageData(imageData, region.minX, region.minY); }