mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: add background and convolution core tools
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
backgroundMaskPreview,
|
||||
backgroundRemovalMask,
|
||||
removeBackground
|
||||
} from './background';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const GREEN = [0, 255, 0, 255];
|
||||
const RED = [255, 0, 0, 255];
|
||||
|
||||
describe('backgroundRemovalMask', () => {
|
||||
it('глобальный режим удаляет все совпадающие пиксели', () => {
|
||||
const mask = backgroundRemovalMask(
|
||||
makeImage(2, 1, [GREEN, RED]),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect([...mask]).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
describe('режим внешних областей', () => {
|
||||
const ringRedCenterGreen = [
|
||||
RED, RED, RED,
|
||||
RED, GREEN, RED,
|
||||
RED, RED, RED
|
||||
];
|
||||
|
||||
it('заливка от краёв не достаёт до изолированного совпадающего острова', () => {
|
||||
const mask = backgroundRemovalMask(
|
||||
makeImage(3, 3, ringRedCenterGreen),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: true, smoothPasses: 0 }
|
||||
);
|
||||
expect(mask[4]).toBe(0);
|
||||
});
|
||||
|
||||
it('глобальный режим удаляет и изолированный остров', () => {
|
||||
const mask = backgroundRemovalMask(
|
||||
makeImage(3, 3, ringRedCenterGreen),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect(mask[4]).toBe(1);
|
||||
expect(mask[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('допуск расширяет захват по цветовому расстоянию', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 10, 10, 255],
|
||||
[128, 128, 128, 255]
|
||||
]);
|
||||
const tight = backgroundRemovalMask(img, {
|
||||
color: '#000000', tolerancePercent: 40, outerOnly: false, smoothPasses: 0
|
||||
});
|
||||
const wide = backgroundRemovalMask(img, {
|
||||
color: '#000000', tolerancePercent: 60, outerOnly: false, smoothPasses: 0
|
||||
});
|
||||
expect(tight[1]).toBe(0);
|
||||
expect(wide[1]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smoothMask-поведение через backgroundRemovalMask', () => {
|
||||
const ringRedCenterGreen = [
|
||||
RED, RED, RED,
|
||||
RED, GREEN, RED,
|
||||
RED, RED, RED
|
||||
];
|
||||
const opts = { color: '#00ff00', tolerancePercent: 0, outerOnly: false };
|
||||
|
||||
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
|
||||
|
||||
it('без сглаживания центр удалён', () => {
|
||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 0 });
|
||||
expect(alphaAtCenter(img)).toBe(0);
|
||||
});
|
||||
|
||||
it('два прохода мажоритарного фильтра возвращают изолированный пиксель', () => {
|
||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 2 });
|
||||
expect(alphaAtCenter(img)).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBackground', () => {
|
||||
it('обнуляет альфу удалённых, сохраняет RGB остальных', () => {
|
||||
const out = removeBackground(
|
||||
makeImage(2, 1, [GREEN, [5, 6, 7, 200]]),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([5, 6, 7, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backgroundMaskPreview', () => {
|
||||
it('белое там, где удаление, чёрное — где остаёмся, всё непрозрачно', () => {
|
||||
const preview = backgroundMaskPreview(
|
||||
makeImage(2, 1, [
|
||||
GREEN,
|
||||
[9, 9, 9, 60]
|
||||
]),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect([...preview.data]).toEqual([
|
||||
255, 255, 255, 255,
|
||||
0, 0, 0, 255
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { createPixelImage, type PixelImage } from './types';
|
||||
|
||||
export type BackgroundOptions = {
|
||||
color: string;
|
||||
tolerancePercent: number;
|
||||
outerOnly: boolean;
|
||||
smoothPasses: number;
|
||||
};
|
||||
|
||||
function buildRawMask(
|
||||
img: PixelImage,
|
||||
targetR: number,
|
||||
targetG: number,
|
||||
targetB: number,
|
||||
tolerancePercent: number
|
||||
): Uint8Array {
|
||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * Math.sqrt(3 * 255 * 255);
|
||||
const thresholdSq = tolerance * tolerance;
|
||||
const mask = new Uint8Array(img.width * img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const dr = img.data[i * 4] - targetR;
|
||||
const dg = img.data[i * 4 + 1] - targetG;
|
||||
const db = img.data[i * 4 + 2] - targetB;
|
||||
mask[i] = dr * dr + dg * dg + db * db <= thresholdSq ? 1 : 0;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function floodFromBorders(mask: Uint8Array, w: number, h: number): void {
|
||||
const queue: number[] = [];
|
||||
const push = (index: number) => {
|
||||
if (mask[index] === 1) {
|
||||
mask[index] = 2;
|
||||
queue.push(index);
|
||||
}
|
||||
};
|
||||
for (let x = 0; x < w; x++) {
|
||||
push(x);
|
||||
push((h - 1) * w + x);
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
push(y * w);
|
||||
push(y * w + w - 1);
|
||||
}
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const index = queue[head++];
|
||||
const x = index % w;
|
||||
if (x > 0) push(index - 1);
|
||||
if (x < w - 1) push(index + 1);
|
||||
if (index >= w) push(index - w);
|
||||
if (index < (h - 1) * w) push(index + w);
|
||||
}
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
mask[i] = mask[i] === 2 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function smoothMask(
|
||||
mask: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
passes: number
|
||||
): Uint8Array {
|
||||
let current = mask;
|
||||
const count = clamp(Math.trunc(passes), 0, 8);
|
||||
for (let pass = 0; pass < count; pass++) {
|
||||
const next = new Uint8Array(current.length);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
let removed = 0;
|
||||
let total = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const sy = clamp(y + dy, 0, h - 1);
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const sx = clamp(x + dx, 0, w - 1);
|
||||
removed += current[sy * w + sx];
|
||||
total++;
|
||||
}
|
||||
}
|
||||
next[y * w + x] = removed * 2 >= total ? 1 : 0;
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function backgroundRemovalMask(
|
||||
img: PixelImage,
|
||||
options: BackgroundOptions
|
||||
): Uint8Array {
|
||||
const [tr, tg, tb] = parseHex(options.color);
|
||||
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
|
||||
if (options.outerOnly) {
|
||||
floodFromBorders(mask, img.width, img.height);
|
||||
}
|
||||
return smoothMask(mask, img.width, img.height, options.smoothPasses);
|
||||
}
|
||||
|
||||
export function removeBackground(img: PixelImage, options: BackgroundOptions): PixelImage {
|
||||
const mask = backgroundRemovalMask(img, options);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const di = i * 4;
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
out.data[di + 3] = mask[i] === 1 ? 0 : img.data[di + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function backgroundMaskPreview(img: PixelImage, options: BackgroundOptions): PixelImage {
|
||||
const mask = backgroundRemovalMask(img, options);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const v = mask[i] === 1 ? 255 : 0;
|
||||
const di = i * 4;
|
||||
out.data[di] = v;
|
||||
out.data[di + 1] = v;
|
||||
out.data[di + 2] = v;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { convolve, gaussianBlur, sharpen } from './convolution';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
||||
|
||||
describe('convolve', () => {
|
||||
it('крестовое ядро резкости на полоске из трёх пикселей', () => {
|
||||
const out = convolve(
|
||||
makeImage(3, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[100, 100, 100, 255],
|
||||
[0, 0, 0, 255]
|
||||
]),
|
||||
SHARPEN_KERNEL,
|
||||
3
|
||||
);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
|
||||
expect([...out.data.slice(8, 12)]).toEqual([0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, 3],
|
||||
[3.5, 3],
|
||||
[0, 3]
|
||||
])('бросает ошибку на некорректном размере ядра %i', (size) => {
|
||||
expect(() => convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharpen', () => {
|
||||
it('сила 0 возвращает копию', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[10, 20, 30, 255],
|
||||
[40, 50, 60, 128],
|
||||
[70, 80, 90, 255],
|
||||
[100, 110, 120, 200]
|
||||
]);
|
||||
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it('сила 100 применяет чистое ядро резкости', () => {
|
||||
const out = sharpen(
|
||||
makeImage(3, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[100, 100, 100, 255],
|
||||
[0, 0, 0, 255]
|
||||
]),
|
||||
100
|
||||
);
|
||||
expect(out.data[4]).toBe(255);
|
||||
expect(out.data[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gaussianBlur', () => {
|
||||
it('постоянное изображение не меняется ни в RGB, ни в альфе', () => {
|
||||
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
|
||||
const out = gaussianBlur(img, 16);
|
||||
for (let i = 0; i < out.data.length; i++) {
|
||||
expect(out.data[i]).toBe(img.data[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('далёкие углы остаются прозрачными, цвет центра не искажается', () => {
|
||||
const size = 61;
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
pixels.push(
|
||||
x >= 26 && x <= 34 && y >= 26 && y <= 34 ? [200, 50, 25, 255] : [0, 0, 0, 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
const out = gaussianBlur(makeImage(size, size, pixels), 8);
|
||||
const corner = 0;
|
||||
expect(out.data[corner]).toBe(0);
|
||||
expect(out.data[corner + 1]).toBe(0);
|
||||
expect(out.data[corner + 2]).toBe(0);
|
||||
expect(out.data[corner + 3]).toBe(0);
|
||||
const center = (30 * size + 30) * 4;
|
||||
expect(out.data[center]).toBe(200);
|
||||
expect(out.data[center + 1]).toBe(50);
|
||||
expect(out.data[center + 2]).toBe(25);
|
||||
});
|
||||
|
||||
it('симметричный вход даёт симметричный результат', () => {
|
||||
const leftByRow = [
|
||||
[
|
||||
[255, 0, 0, 255],
|
||||
[10, 20, 30, 255],
|
||||
[64, 64, 64, 64],
|
||||
[5, 5, 5, 200]
|
||||
],
|
||||
[
|
||||
[10, 20, 30, 255],
|
||||
[200, 100, 50, 255],
|
||||
[1, 2, 3, 4],
|
||||
[90, 90, 90, 250]
|
||||
],
|
||||
[
|
||||
[64, 64, 64, 64],
|
||||
[1, 2, 3, 4],
|
||||
[128, 128, 128, 128],
|
||||
[40, 40, 40, 240]
|
||||
],
|
||||
[
|
||||
[200, 100, 50, 255],
|
||||
[90, 90, 90, 250],
|
||||
[40, 40, 40, 240],
|
||||
[7, 7, 7, 255]
|
||||
],
|
||||
[
|
||||
[10, 20, 30, 255],
|
||||
[1, 2, 3, 4],
|
||||
[64, 64, 64, 64],
|
||||
[90, 90, 90, 250]
|
||||
],
|
||||
[
|
||||
[64, 64, 64, 64],
|
||||
[200, 100, 50, 255],
|
||||
[5, 5, 5, 200],
|
||||
[1, 2, 3, 4]
|
||||
],
|
||||
[
|
||||
[5, 5, 5, 200],
|
||||
[40, 40, 40, 240],
|
||||
[90, 90, 90, 250],
|
||||
[128, 128, 128, 128]
|
||||
]
|
||||
];
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
pixels.push(leftByRow[y][Math.min(x, 6 - x)]);
|
||||
}
|
||||
}
|
||||
const img = makeImage(7, 7, pixels);
|
||||
const blurred = gaussianBlur(img, 3);
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 3; x++) {
|
||||
const li = (y * 7 + x) * 4;
|
||||
const ri = (y * 7 + (6 - x)) * 4;
|
||||
expect([...blurred.data.slice(li, li + 4)]).toEqual([
|
||||
blurred.data[ri],
|
||||
blurred.data[ri + 1],
|
||||
blurred.data[ri + 2],
|
||||
blurred.data[ri + 3]
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
type Plane = Float64Array;
|
||||
|
||||
export function convolve(
|
||||
img: PixelImage,
|
||||
kernel: readonly number[],
|
||||
size: number
|
||||
): PixelImage {
|
||||
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
||||
throw new Error('Размер ядра должен быть нечётным положительным числом');
|
||||
}
|
||||
if (kernel.length !== size * size) {
|
||||
throw new Error('Длина ядра не совпадает с его размером');
|
||||
}
|
||||
const half = Math.floor(size / 2);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
let acc = 0;
|
||||
for (let ky = 0; ky < size; ky++) {
|
||||
const sy = clampInt(y + ky - half, 0, img.height - 1);
|
||||
for (let kx = 0; kx < size; kx++) {
|
||||
const sx = clampInt(x + kx - half, 0, img.width - 1);
|
||||
acc += img.data[(sy * img.width + sx) * 4 + ch] * kernel[ky * size + kx];
|
||||
}
|
||||
}
|
||||
out.data[(y * out.width + x) * 4 + ch] = acc;
|
||||
}
|
||||
out.data[(y * out.width + x) * 4 + 3] = img.data[(y * img.width + x) * 4 + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
||||
|
||||
export function sharpen(img: PixelImage, strengthPercent: number): PixelImage {
|
||||
const strength = clamp(strengthPercent, 0, 100) / 100;
|
||||
if (strength === 0) return clonePixelImage(img);
|
||||
const sharp = convolve(img, SHARPEN_KERNEL, 3);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
out.data[i + ch] =
|
||||
img.data[i + ch] * (1 - strength) + sharp.data[i + ch] * strength;
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function gaussianBlur(img: PixelImage, radiusPx: number): PixelImage {
|
||||
const radius = clamp(radiusPx, 0, 512);
|
||||
if (radius < 1) return clonePixelImage(img);
|
||||
const sigma = Math.max(0.25, radius / 2);
|
||||
const boxes = boxesForGauss(sigma, 3);
|
||||
|
||||
const w = img.width;
|
||||
const h = img.height;
|
||||
const pixels = w * h;
|
||||
|
||||
const red = new Float64Array(pixels);
|
||||
const green = new Float64Array(pixels);
|
||||
const blue = new Float64Array(pixels);
|
||||
const alpha = new Float64Array(pixels);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const a = img.data[i * 4 + 3] / 255;
|
||||
red[i] = (img.data[i * 4] / 255) * a;
|
||||
green[i] = (img.data[i * 4 + 1] / 255) * a;
|
||||
blue[i] = (img.data[i * 4 + 2] / 255) * a;
|
||||
alpha[i] = a;
|
||||
}
|
||||
|
||||
const tmp = new Float64Array(pixels);
|
||||
for (const box of boxes) {
|
||||
const r = Math.max(0, (box - 1) / 2);
|
||||
if (r < 1) continue;
|
||||
blurPlanePass(red, tmp, w, h, r);
|
||||
blurPlanePass(green, tmp, w, h, r);
|
||||
blurPlanePass(blue, tmp, w, h, r);
|
||||
blurPlanePass(alpha, tmp, w, h, r);
|
||||
}
|
||||
|
||||
const out = createPixelImage(w, h);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const a = alpha[i];
|
||||
const di = i * 4;
|
||||
if (a < 1 / 255) {
|
||||
out.data[di] = 0;
|
||||
out.data[di + 1] = 0;
|
||||
out.data[di + 2] = 0;
|
||||
out.data[di + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
out.data[di] = (red[i] / a) * 255;
|
||||
out.data[di + 1] = (green[i] / a) * 255;
|
||||
out.data[di + 2] = (blue[i] / a) * 255;
|
||||
out.data[di + 3] = a * 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blurPlanePass(plane: Plane, tmp: Plane, w: number, h: number, r: number): void {
|
||||
blurPlaneHorizontal(plane, tmp, w, h, r);
|
||||
blurPlaneVertical(tmp, plane, w, h, r);
|
||||
}
|
||||
|
||||
function blurPlaneHorizontal(
|
||||
src: Plane,
|
||||
dst: Plane,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
): void {
|
||||
const div = 2 * r + 1;
|
||||
const inv = 1 / div;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
let sum = src[row] * (r + 1);
|
||||
for (let k = 1; k <= r; k++) {
|
||||
sum += src[row + clampInt(k, 0, w - 1)];
|
||||
}
|
||||
for (let x = 0; x < w; x++) {
|
||||
dst[row + x] = sum * inv;
|
||||
const addIndex = clampInt(x + r + 1, 0, w - 1);
|
||||
const removeIndex = clampInt(x - r, 0, w - 1);
|
||||
sum += src[row + addIndex] - src[row + removeIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blurPlaneVertical(
|
||||
src: Plane,
|
||||
dst: Plane,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
): void {
|
||||
const div = 2 * r + 1;
|
||||
const inv = 1 / div;
|
||||
for (let x = 0; x < w; x++) {
|
||||
let sum = src[x] * (r + 1);
|
||||
for (let k = 1; k <= r; k++) {
|
||||
sum += src[clampInt(k, 0, h - 1) * w + x];
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
dst[y * w + x] = sum * inv;
|
||||
const addIndex = clampInt(y + r + 1, 0, h - 1);
|
||||
const removeIndex = clampInt(y - r, 0, h - 1);
|
||||
sum += src[addIndex * w + x] - src[removeIndex * w + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boxesForGauss(sigma: number, boxes: number): number[] {
|
||||
const wIdeal = Math.sqrt((12 * sigma * sigma) / boxes + 1);
|
||||
let wl = Math.floor(wIdeal);
|
||||
if (wl % 2 === 0) wl--;
|
||||
const wu = wl + 2;
|
||||
const mIdeal = (12 * sigma * sigma - boxes * wl * wl - boxes * wl - boxes) / (4 * wl + 4);
|
||||
const m = Math.round(mIdeal);
|
||||
const sizes: number[] = [];
|
||||
for (let i = 0; i < boxes; i++) {
|
||||
sizes.push(i < m ? wl : wu);
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
Reference in New Issue
Block a user