[MIRROR] colorsolver (#11773)

Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
Co-authored-by: Selis <12716288+ItsSelis@users.noreply.github.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2025-10-03 05:32:17 -07:00
committed by GitHub
parent 32b62f335b
commit 9709af12a9
18 changed files with 930 additions and 479 deletions

View File

@@ -532,6 +532,7 @@ GLOBAL_LIST_INIT(all_volume_channels, list(
#define COLORMATE_TINT 1
#define COLORMATE_HSV 2
#define COLORMATE_MATRIX 3
#define COLORMATE_MATRIX_AUTO 4
#define DEFAULT_COLORMATRIX list(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)

View File

@@ -213,7 +213,7 @@
switch(active_mode)
if(COLORMATE_TINT)
color_to_use = activecolor
if(COLORMATE_MATRIX)
if(COLORMATE_MATRIX, COLORMATE_MATRIX_AUTO)
color_to_use = rgb_construct_color_matrix(
text2num(color_matrix_last[1]),
text2num(color_matrix_last[2]),
@@ -244,7 +244,7 @@
if(inserted) //sanity
var/list/cm
switch(active_mode)
if(COLORMATE_MATRIX)
if(COLORMATE_MATRIX, COLORMATE_MATRIX_AUTO)
cm = rgb_construct_color_matrix(
text2num(color_matrix_last[1]),
text2num(color_matrix_last[2]),

View File

@@ -14,7 +14,7 @@
* * default - The default (or current) value, shown as a placeholder. Users can press refresh with this.
* * timeout - The timeout of the matrix input, after which the modal will close and qdel itself. Set to zero for no timeout.
*/
/proc/tgui_input_colormatrix(mob/user, message, title = "Matrix Recolor", atom/movable/target, list/default = DEFAULT_COLORMATRIX, matrix_only = FALSE, timeout = 10 MINUTES, ui_state = GLOB.tgui_always_state)
/proc/tgui_input_colormatrix(mob/user, message, title = "Matrix Recolor", atom/movable/target, list/default = DEFAULT_COLORMATRIX, matrix_only = FALSE, timeout = 30 MINUTES, ui_state = GLOB.tgui_always_state)
if (!user)
user = usr
if (!istype(user))
@@ -182,6 +182,8 @@
return
switch(action)
if("switch_modes")
if(matrix_only && active_mode < 3)
return FALSE
active_mode = text2num(params["mode"])
return TRUE
if("choose_color")
@@ -239,7 +241,7 @@
switch(active_mode)
if(COLORMATE_TINT)
color_to_use = activecolor
if(COLORMATE_MATRIX)
if(COLORMATE_MATRIX, COLORMATE_MATRIX_AUTO)
color_to_use = rgb_construct_color_matrix(
text2num(color_matrix_last[1]),
text2num(color_matrix_last[2]),
@@ -276,7 +278,7 @@
if(target) //sanity
var/list/cm
switch(active_mode)
if(COLORMATE_MATRIX)
if(COLORMATE_MATRIX, COLORMATE_MATRIX_AUTO)
cm = rgb_construct_color_matrix(
text2num(color_matrix_last[1]),
text2num(color_matrix_last[2]),

View File

@@ -1,78 +0,0 @@
import { useBackend } from 'tgui/backend';
import { Button, Slider, Table } from 'tgui-core/components';
import type { Data } from './types';
export const ColorMateTint = (props) => {
const { act } = useBackend();
return (
<Button fluid icon="paint-brush" onClick={() => act('choose_color')}>
Select new color
</Button>
);
};
export const ColorMateHSV = (props) => {
const { act, data } = useBackend<Data>();
const { buildhue, buildsat, buildval } = data;
return (
<Table>
<Table.Row>
<center>Hue:</center>
<Table.Cell width="85%">
<Slider
tickWhileDragging
minValue={0}
maxValue={360}
step={1}
value={buildhue}
format={(value: number) => value.toFixed()}
onChange={(e, value: number) =>
act('set_hue', {
buildhue: value,
})
}
/>
</Table.Cell>
</Table.Row>
<Table.Row>
<center>Saturation:</center>
<Table.Cell>
<Slider
tickWhileDragging
minValue={-10}
maxValue={10}
step={0.01}
value={buildsat}
format={(value: number) => value.toFixed(2)}
onChange={(e, value: number) =>
act('set_sat', {
buildsat: value,
})
}
/>
</Table.Cell>
</Table.Row>
<Table.Row>
<center>Value:</center>
<Table.Cell>
<Slider
tickWhileDragging
minValue={-10}
maxValue={10}
step={0.01}
value={buildval}
format={(value: number) => value.toFixed(2)}
onChange={(e, value: number) =>
act('set_val', {
buildval: value,
})
}
/>
</Table.Cell>
</Table.Row>
</Table>
);
};

View File

@@ -1,254 +0,0 @@
import { useBackend } from 'tgui/backend';
import {
Box,
Icon,
Input,
LabeledList,
NumberInput,
Table,
} from 'tgui-core/components';
import type { Data } from './types';
export const ColorMateMatrix = (props) => {
const { act, data } = useBackend<Data>();
const { matrixcolors } = data;
return (
<>
<Table>
<Table.Cell>
<Table.Row>
RR:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.rr}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 1,
value,
})
}
/>
</Table.Row>
<Table.Row>
GR:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.gr}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 4,
value,
})
}
/>
</Table.Row>
<Table.Row>
BR:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.br}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 7,
value,
})
}
/>
</Table.Row>
</Table.Cell>
<Table.Cell>
<Table.Row>
RG:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.rg}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 2,
value,
})
}
/>
</Table.Row>
<Table.Row>
GG:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.gg}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 5,
value,
})
}
/>
</Table.Row>
<Table.Row>
BG:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.bg}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 8,
value,
})
}
/>
</Table.Row>
</Table.Cell>
<Table.Cell>
<Table.Row>
RB:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.rb}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 3,
value,
})
}
/>
</Table.Row>
<Table.Row>
GB:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.gb}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 6,
value,
})
}
/>
</Table.Row>
<Table.Row>
BB:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.bb}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 9,
value,
})
}
/>
</Table.Row>
</Table.Cell>
<Table.Cell>
<Table.Row>
CR:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.cr}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 10,
value,
})
}
/>
</Table.Row>
<Table.Row>
CG:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.cg}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 11,
value,
})
}
/>
</Table.Row>
<Table.Row>
CB:
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors.cb}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', {
color: 12,
value,
})
}
/>
</Table.Row>
</Table.Cell>
<Table.Cell width="40%">
<Icon name="question-circle" color="blue" /> RG means red will become
this much green.
<br />
<Icon name="question-circle" color="blue" /> CR means this much red
will be added.
</Table.Cell>
</Table>
<Box mt={3}>
<LabeledList>
<LabeledList.Item label="Config">
<Input
fluid
value={Object.values(matrixcolors).toString()}
onBlur={(value: string) => act('set_matrix_string', { value })}
/>
</LabeledList.Item>
</LabeledList>
</Box>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { useBackend } from 'tgui/backend';
import { Input, LabeledList } from 'tgui-core/components';
import type { Data } from '../types';
export const ConfigField = (props) => {
const { act, data } = useBackend<Data>();
const { matrixcolors } = data;
return (
<LabeledList>
<LabeledList.Item label="Config">
<Input
fluid
value={Object.values(matrixcolors).toString()}
onBlur={(value: string) => act('set_matrix_string', { value })}
/>
</LabeledList.Item>
</LabeledList>
);
};

View File

@@ -0,0 +1,90 @@
import type React from 'react';
import { useEffect, useRef } from 'react';
import type { ColorUpdate } from '../types';
export function ColorPickerCanvas(props: {
imageData: string | null;
onPick: ColorUpdate;
isMatrix: boolean;
}) {
const { imageData, onPick, isMatrix } = props;
const canvasRef = useRef<HTMLCanvasElement>(null);
const CANVAS_WIDTH = 475;
const CANVAS_HEIGHT = 475;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !imageData) return;
const ctx = canvas.getContext('2d');
const img = new Image();
img.src = `data:image/jpeg;base64,${imageData}`;
img.onload = () => {
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
ctx?.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
const imgAspect = img.width / img.height;
const canvasAspect = CANVAS_WIDTH / CANVAS_HEIGHT;
let drawWidth = CANVAS_WIDTH;
let drawHeight = CANVAS_HEIGHT;
if (imgAspect > canvasAspect) {
drawWidth = CANVAS_WIDTH;
drawHeight = CANVAS_WIDTH / imgAspect;
} else {
drawHeight = CANVAS_HEIGHT;
drawWidth = CANVAS_HEIGHT * imgAspect;
}
const offsetX = (CANVAS_WIDTH - drawWidth) / 2;
const offsetY = (CANVAS_HEIGHT - drawHeight) / 2;
if (ctx) {
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);
}
};
}, [imageData]);
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isMatrix) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.floor((e.clientX - rect.left) * scaleX);
const y = Math.floor((e.clientY - rect.top) * scaleY);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const pixel = ctx.getImageData(x, y, 1, 1).data;
const hex = `#${[pixel[0], pixel[1], pixel[2]]
.map((c) => c.toString(16).padStart(2, '0'))
.join('')}`;
onPick(hex);
};
return (
<canvas
ref={canvasRef}
onClick={handleClick}
style={{
cursor: isMatrix ? 'crosshair' : 'default',
imageRendering: 'pixelated',
display: 'block',
margin: '0 auto',
}}
width={CANVAS_WIDTH}
height={CANVAS_HEIGHT}
/>
);
}

View File

@@ -0,0 +1,71 @@
import { type HsvaColor, hexToHsva, hsvaToHex } from 'common/colorpicker';
import { useEffect, useState } from 'react';
import { Box, Floating } from 'tgui-core/components';
import { ColorSelector } from '../../ColorPickerModal';
export const ColorMatrixColorBox = (props: {
selectedColor: string;
onSelectedColor: (value: string) => void;
}) => {
const { selectedColor, onSelectedColor } = props;
const [selectedPreset, setSelectedPreset] = useState<number | undefined>(
undefined,
);
const [currentColor, setCurrentColor] = useState<HsvaColor>(
hexToHsva(selectedColor),
);
const [initialColor, setInitialColor] = useState(selectedColor);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (!isOpen) {
setInitialColor(selectedColor);
setCurrentColor(hexToHsva(selectedColor));
}
}, [isOpen, selectedColor]);
const handleSetColor = (
value: HsvaColor | ((prev: HsvaColor) => HsvaColor),
) => {
const newColor = typeof value === 'function' ? value(currentColor) : value;
setCurrentColor(newColor);
onSelectedColor(hsvaToHex(newColor));
};
const pixelSize = 20;
const parentSize = `${pixelSize}px`;
const childSize = `${pixelSize - 4}px`;
return (
<Floating
onOpenChange={setIsOpen}
placement="bottom-end"
contentClasses="MatrixEditor__Floating"
content={
<ColorSelector
color={currentColor}
setColor={handleSetColor}
defaultColor={initialColor}
selectedPreset={selectedPreset}
onSelectedPreset={setSelectedPreset}
/>
}
>
<Box
style={{
border: '2px solid white',
cursor: 'pointer',
}}
width={parentSize}
height={parentSize}
>
<Box
backgroundColor={selectedColor}
width={childSize}
height={childSize}
/>
</Box>
</Floating>
);
};

View File

@@ -0,0 +1,89 @@
import { useBackend } from 'tgui/backend';
import { Button, Slider, Stack } from 'tgui-core/components';
import type { Data } from '../types';
export const ColorMateTint = (props) => {
const { act } = useBackend();
return (
<Button fluid icon="paint-brush" onClick={() => act('choose_color')}>
Select new color
</Button>
);
};
export const ColorMateHSV = (props) => {
const { act, data } = useBackend<Data>();
const { buildhue, buildsat, buildval } = data;
return (
<Stack vertical>
<Stack.Item>
<Stack align="center">
<Stack.Item textAlign="center" basis="15%">
Hue:
</Stack.Item>
<Stack.Item grow>
<Slider
tickWhileDragging
minValue={0}
maxValue={360}
step={1}
value={buildhue}
format={(value: number) => value.toFixed()}
onChange={(e, value: number) =>
act('set_hue', {
buildhue: value,
})
}
/>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item>
<Stack>
<Stack.Item textAlign="center" basis="15%">
Saturation:
</Stack.Item>
<Stack.Item grow>
<Slider
tickWhileDragging
minValue={-10}
maxValue={10}
step={0.01}
value={buildsat}
format={(value: number) => value.toFixed(2)}
onChange={(e, value: number) =>
act('set_sat', {
buildsat: value,
})
}
/>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item>
<Stack>
<Stack.Item textAlign="center" basis="15%">
Value:
</Stack.Item>
<Stack.Item grow>
<Slider
tickWhileDragging
minValue={-10}
maxValue={10}
step={0.01}
value={buildval}
format={(value: number) => value.toFixed(2)}
onChange={(e, value: number) =>
act('set_val', {
buildval: value,
})
}
/>
</Stack.Item>
</Stack>
</Stack.Item>
</Stack>
);
};

View File

@@ -0,0 +1,60 @@
import { useBackend } from 'tgui/backend';
import { Icon, NumberInput, Stack } from 'tgui-core/components';
import { MATRIX_COLUMS } from '../constants';
import { ConfigField } from '../Helpers/ConfigField';
import type { Data } from '../types';
export const ColorMateMatrix = (props) => {
const { act, data } = useBackend<Data>();
const { matrixcolors } = data;
return (
<Stack fill vertical>
<Stack.Item grow>
<Stack>
{MATRIX_COLUMS.map((column, colIndex) => (
<Stack.Item key={`col-${colIndex}`}>
<Stack vertical>
{column.map(({ label, key, color }) => (
<Stack.Item ml="20px" key={label}>
<Stack align="center">
<Stack.Item>{label}:</Stack.Item>
<Stack.Item>
<NumberInput
width="50px"
minValue={-10}
maxValue={10}
step={0.01}
value={matrixcolors[key]}
format={(value: number) => value.toFixed(2)}
onChange={(value: number) =>
act('set_matrix_color', { color: color, value })
}
/>
</Stack.Item>
</Stack>
</Stack.Item>
))}
</Stack>
</Stack.Item>
))}
<Stack.Item ml="20px" width="40%">
<Stack vertical>
<Stack.Item>
<Icon name="question-circle" color="blue" /> RG means red will
become this much green.
</Stack.Item>
<Stack.Item>
<Icon name="question-circle" color="blue" /> CR means this much
red will be added.
</Stack.Item>
</Stack>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item>
<ConfigField />
</Stack.Item>
</Stack>
);
};

View File

@@ -0,0 +1,192 @@
import { useBackend } from 'tgui/backend';
import { Box, Button, Input, Section, Stack } from 'tgui-core/components';
import { computeMatrixFromPairs, isValidHex } from '../functions';
import { ColorMatrixColorBox } from '../Helpers/MatrixColorBox';
import type {
ColorPair,
ColorUpdate,
Data,
MatrixColors,
SelectedId,
} from '../types';
export const ColorMateMatrixSolver = (props: {
activeID: SelectedId;
onActiveId: React.Dispatch<React.SetStateAction<SelectedId>>;
colorPairs: ColorPair[];
onColorPairs: React.Dispatch<React.SetStateAction<ColorPair[]>>;
onPick: ColorUpdate;
}) => {
const { act } = useBackend<Data>();
const { activeID, onActiveId, colorPairs, onColorPairs, onPick } = props;
function handleColorUpdate(
newColor: string,
type: string,
index: number,
): void {
if (!isValidHex(newColor)) {
return;
}
onPick(newColor, type, index);
}
function toggleDripper(index: number, type: 'input' | 'output') {
if (activeID.id === index && activeID.type === type) {
onActiveId({ id: null, type: null });
} else {
onActiveId({ id: index, type: type });
}
}
function removePair(index: number) {
const newPairs = [...colorPairs];
newPairs.splice(index, 1);
onColorPairs(newPairs);
if (activeID.id !== index) return;
onActiveId({ id: null, type: null });
}
function calculateColor() {
try {
const matrix = computeMatrixFromPairs(colorPairs);
const newMatrixcolors: MatrixColors = {
rr: matrix[0][0],
rg: matrix[0][1],
rb: matrix[0][2],
gr: matrix[1][0],
gg: matrix[1][1],
gb: matrix[1][2],
br: matrix[2][0],
bg: matrix[2][1],
bb: matrix[2][2],
cr: matrix[0][3],
cg: matrix[1][3],
cb: matrix[2][3],
};
const ourMatrix = Object.values(newMatrixcolors)
.map((v) => v.toFixed(2))
.toString();
act('set_matrix_string', { value: ourMatrix });
} catch (err) {
console.log(`Matrix computation failed: ${err.message}`);
}
}
function updateColor(color: string, type: string, index: number) {
handleColorUpdate(color, type, index);
}
return (
<Section fill scrollable>
<Stack vertical>
{colorPairs.map((colorPair, index) => (
<Stack.Item key={index}>
<Stack align="center">
<Stack.Item>
<Box
color="label"
inline
preserveWhitespace
>{`${index + 1}: `}</Box>
<Box inline>Source</Box>
</Stack.Item>
<Stack.Item>
<ColorMatrixColorBox
selectedColor={colorPair.input}
onSelectedColor={(value) =>
updateColor(value, 'input', index)
}
/>
</Stack.Item>
<Stack.Item>
<Button
selected={activeID.id === index && activeID.type === 'input'}
onClick={() => toggleDripper(index, 'input')}
tooltip="Pick color from image"
icon="eye-dropper"
/>
</Stack.Item>
<Stack.Item>
<Input
fluid
value={colorPair.input}
onChange={(value) => handleColorUpdate(value, 'input', index)}
width="80px"
/>
</Stack.Item>
<Stack.Item>
<Box color="label">{`==>`}</Box>
</Stack.Item>
<Stack.Item>
<Box>Target</Box>
</Stack.Item>
<Stack.Item>
<ColorMatrixColorBox
selectedColor={colorPair.output}
onSelectedColor={(value) =>
updateColor(value, 'output', index)
}
/>
</Stack.Item>
<Stack.Item>
<Button
selected={activeID.id === index && activeID.type === 'output'}
onClick={() => toggleDripper(index, 'output')}
tooltip="Pick color from image"
icon="eye-dropper"
/>
</Stack.Item>
<Stack.Item>
<Input
fluid
value={colorPair.output}
onChange={(value) =>
handleColorUpdate(value, 'output', index)
}
width="80px"
/>
</Stack.Item>
{index === 0 && (
<>
<Stack.Item>
<Button onClick={() => calculateColor()}>Calculate</Button>
</Stack.Item>
{colorPairs.length < 20 && (
<Stack.Item>
<Button
onClick={() =>
onColorPairs([
...colorPairs,
{ input: '#ffffff', output: '#000000' },
])
}
>
Add Pair
</Button>
</Stack.Item>
)}
</>
)}
{index > 0 && (
<Stack.Item>
<Button.Confirm
icon="trash"
color="red"
onClick={() => removePair(index)}
/>
</Stack.Item>
)}
</Stack>
</Stack.Item>
))}
</Stack>
</Section>
);
};

View File

@@ -0,0 +1,22 @@
export const MATRIX_COLUMS = [
[
{ label: 'RR', key: 'rr', color: 1 },
{ label: 'GR', key: 'gr', color: 4 },
{ label: 'BR', key: 'br', color: 7 },
],
[
{ label: 'RG', key: 'rg', color: 2 },
{ label: 'GG', key: 'gg', color: 5 },
{ label: 'BG', key: 'bg', color: 8 },
],
[
{ label: 'RB', key: 'rb', color: 3 },
{ label: 'GB', key: 'gb', color: 6 },
{ label: 'BB', key: 'bb', color: 9 },
],
[
{ label: 'CR', key: 'cr', color: 10 },
{ label: 'CG', key: 'cg', color: 11 },
{ label: 'CB', key: 'cb', color: 12 },
],
];

View File

@@ -0,0 +1,128 @@
export function isValidHex(hex: string): boolean {
return /^#[0-9A-Fa-f]{6}$/.test(hex);
}
export function computeMatrixFromPairs(
pairs: { input: string; output: string }[],
): number[][] {
const identityColors = ['#ff0000', '#00ff00', '#0000ff', '#ffffff'];
const workingPairs = [...pairs];
for (const idColor of identityColors) {
const alreadyUsed = workingPairs.some(
(p) => p.input.toLowerCase() === idColor,
);
if (!alreadyUsed) {
workingPairs.push({
input: idColor,
output: idColor,
});
}
}
const rgbIn = workingPairs.map((p) => hexToRgb(p.input));
const rgbOut = workingPairs.map((p) => hexToRgb(p.output));
const matrix: number[][] = [];
for (let channel = 0; channel < 3; channel++) {
let attempts = 0;
let weights: number[] = [];
while (attempts < 5) {
try {
const a = rgbIn.map((rgb) => [rgb[0], rgb[1], rgb[2], 1]);
const b = rgbOut.map((rgb) => rgb[channel]);
weights = leastSquares(a, b);
if (!weights.every((w) => w >= -10 && w <= 10)) {
throw new Error('Computed weights out of range');
}
break;
} catch (e) {
const idx = rgbOut.length - 1;
rgbOut[idx] = rgbOut[idx].map(
(val) => val + Math.random() * 0.01 - 0.005,
);
attempts++;
}
}
if (weights.length !== 4) {
throw new Error(
`Matrix computation failed for channel ${channel} after ${attempts} attempts`,
);
}
matrix.push(weights);
}
return matrix;
}
function hexToRgb(hex: string): number[] {
const clean = hex.replace('#', '').padEnd(6, '0');
const r = parseInt(clean.slice(0, 2), 16) / 255;
const g = parseInt(clean.slice(2, 4), 16) / 255;
const b = parseInt(clean.slice(4, 6), 16) / 255;
return [r, g, b];
}
function transpose(matrix: number[][]): number[][] {
return matrix[0].map((_, i) => matrix.map((row) => row[i]));
}
function multiply(a: number[][], b: number[][]): number[][] {
const result: number[][] = Array(a.length)
.fill(0)
.map(() => Array(b[0].length).fill(0));
for (let i = 0; i < a.length; i++) {
for (let j = 0; j < b[0].length; j++) {
for (let k = 0; k < b.length; k++) {
result[i][j] += a[i][k] * b[k][j];
}
}
}
return result;
}
function inverse(matrix: number[][]): number[][] {
const size = matrix.length;
const augmented = matrix.map((row, i) => [
...row,
...Array(size)
.fill(0)
.map((_, j) => (i === j ? 1 : 0)),
]);
for (let i = 0; i < size; i++) {
const diag = augmented[i][i];
if (diag === 0) {
throw new Error('Singular matrix');
}
for (let j = 0; j < size * 2; j++) augmented[i][j] /= diag;
for (let k = 0; k < size; k++) {
if (k === i) continue;
const factor = augmented[k][i];
for (let j = 0; j < size * 2; j++) {
augmented[k][j] -= factor * augmented[i][j];
}
}
}
return augmented.map((row) => row.slice(size));
}
function leastSquares(a: number[][], b: number[]): number[] {
const AT = transpose(a);
const ATa = multiply(AT, a);
const ATb = multiply(
AT,
b.map((v) => [v]),
);
const ATainv = inverse(ATa);
const result = multiply(ATainv, ATb);
return result.map((r) => r[0]);
}

View File

@@ -1,23 +1,30 @@
import { useState } from 'react';
import { useBackend } from 'tgui/backend';
import { Window } from 'tgui/layouts';
import {
Box,
Button,
Image,
NoticeBox,
Section,
Stack,
Table,
Tabs,
} from 'tgui-core/components';
import { ColorMateHSV, ColorMateTint } from './ColorMateColor';
import { ColorMateMatrix } from './ColorMateMatrix';
import { ColorPickerCanvas } from './Helpers/ImageCanvas';
import { ColorMateHSV, ColorMateTint } from './MatrixTabs/ColorMateColor';
import { ColorMateMatrix } from './MatrixTabs/ColorMateMatrix';
import { ColorMateMatrixSolver } from './MatrixTabs/ColorMatrixSolver';
import type { Data } from './types';
export const ColorMate = (props) => {
const { act, data } = useBackend<Data>();
const [colorPairs, setColorPairs] = useState([
{ input: '#ffffff', output: '#000000' },
]);
const [activeID, setActiveId] = useState({ id: null, type: null });
const {
activemode,
temp,
@@ -34,14 +41,53 @@ export const ColorMate = (props) => {
tab[1] = <ColorMateTint />;
tab[2] = <ColorMateHSV />;
tab[3] = <ColorMateMatrix />;
tab[4] = (
<ColorMateMatrixSolver
activeID={activeID}
onActiveId={setActiveId}
colorPairs={colorPairs}
onColorPairs={setColorPairs}
onPick={handleColorUpdate}
/>
);
const height = 720 + (matrix_only ? -20 : 0) + (message ? 20 : 0);
const height =
750 + (matrix_only ? -20 : 0) + (message ? 20 : 0) + (temp ? 20 : 0);
function handleColorUpdate(
hexCol: string,
mode?: string,
index?: number,
): void {
if (activemode !== 4) return;
const usedIndex = index ?? activeID.id;
if (usedIndex === null || usedIndex >= colorPairs.length) return;
const usedMode = mode ?? activeID.type;
if (!usedMode) return;
if(!mode && !index) {
setActiveId({ id: null, type: null });
}
setColorPairs((prev) => {
const newPairs = [...prev];
newPairs[usedIndex] = {
...newPairs[usedIndex],
[usedMode]: hexCol,
};
return newPairs;
});
}
return (
<Window title={title} width={980} height={height}>
<Window.Content overflow="auto">
<Section fill>
<Stack vertical>
<Stack fill vertical>
{!!temp && (
<Stack.Item>
<NoticeBox>{temp}</NoticeBox>
@@ -50,59 +96,73 @@ export const ColorMate = (props) => {
<Stack.Item>
<Box>{message}</Box>
</Stack.Item>
<Stack.Item>
<Stack.Item grow>
{item_name ? (
<>
<Table>
<Table.Cell width="50%">
<Section>
<center>Item:</center>
<Image
src={`data:image/jpeg;base64,${item_sprite}`}
style={{
width: '100%',
height: '100%',
}}
/>
</Section>
</Table.Cell>
<Table.Cell>
<Section>
<center>Preview:</center>
<Image
src={`data:image/jpeg;base64,${item_preview}`}
style={{
width: '100%',
height: '100%',
}}
/>
</Section>
</Table.Cell>
</Table>
{!matrix_only && (
<Stack vertical fill>
<Stack.Item>
<Table>
<Table.Cell width="50%">
<Section fill>
<Stack fill vertical>
<Stack.Item align="center">Item:</Stack.Item>
<Stack.Item>
<ColorPickerCanvas
imageData={item_sprite}
onPick={handleColorUpdate}
isMatrix={
activemode === 4 && activeID.id !== null
}
/>
</Stack.Item>
</Stack>
</Section>
</Table.Cell>
<Table.Cell>
<Section fill>
<Stack fill vertical>
<Stack.Item align="center">Preview:</Stack.Item>
<Stack.Item>
<ColorPickerCanvas
imageData={item_preview}
onPick={handleColorUpdate}
isMatrix={
activemode === 4 && activeID.id !== null
}
/>
</Stack.Item>
</Stack>
</Section>
</Table.Cell>
</Table>
</Stack.Item>
<Stack.Item>
<Tabs fluid>
<Tabs.Tab
key="1"
selected={activemode === 1}
onClick={() =>
act('switch_modes', {
mode: 1,
})
}
>
Tint coloring (Simple)
</Tabs.Tab>
<Tabs.Tab
key="2"
selected={activemode === 2}
onClick={() =>
act('switch_modes', {
mode: 2,
})
}
>
HSV coloring (Normal)
</Tabs.Tab>
{!matrix_only && (
<>
<Tabs.Tab
key="1"
selected={activemode === 1}
onClick={() =>
act('switch_modes', {
mode: 1,
})
}
>
Tint coloring (Simple)
</Tabs.Tab>
<Tabs.Tab
key="2"
selected={activemode === 2}
onClick={() =>
act('switch_modes', {
mode: 2,
})
}
>
HSV coloring (Normal)
</Tabs.Tab>
</>
)}
<Tabs.Tab
key="3"
selected={activemode === 3}
@@ -114,44 +174,65 @@ export const ColorMate = (props) => {
>
Matrix coloring (Advanced)
</Tabs.Tab>
<Tabs.Tab
key="4"
selected={activemode === 4}
onClick={() =>
act('switch_modes', {
mode: 4,
})
}
>
Matrix coloring (Automatic)
</Tabs.Tab>
</Tabs>
)}
<center>Coloring: {item_name}</center>
<Table mt={1}>
<Table.Cell width="33%">
<Button.Confirm
fluid
icon="fill"
confirmIcon="fill"
confirmContent="Confirm Paint?"
onClick={() => act('paint')}
>
Paint
</Button.Confirm>
<Button.Confirm
fluid
icon="eraser"
confirmIcon="eraser"
confirmContent="Confirm Clear?"
onClick={() => act('clear')}
>
Clear
</Button.Confirm>
<Button.Confirm
fluid
icon="eject"
confirmIcon="eject"
confirmContent="Confirm Eject?"
onClick={() => act('drop')}
>
Eject
</Button.Confirm>
</Table.Cell>
<Table.Cell width="66%">
{tab[activemode] || <Box textColor="red">Error</Box>}
</Table.Cell>
</Table>
</>
</Stack.Item>
<Stack.Item align="center">Coloring: {item_name}</Stack.Item>
<Stack.Item grow>
<Stack fill mt={1}>
<Stack.Item width="33%">
<Stack vertical>
<Stack.Item>
<Button.Confirm
fluid
icon="fill"
confirmIcon="fill"
confirmContent="Confirm Paint?"
onClick={() => act('paint')}
>
Paint
</Button.Confirm>
</Stack.Item>
<Stack.Item>
<Button.Confirm
fluid
icon="eraser"
confirmIcon="eraser"
confirmContent="Confirm Clear?"
onClick={() => act('clear')}
>
Clear
</Button.Confirm>
</Stack.Item>
<Stack.Item>
<Button.Confirm
fluid
icon="eject"
confirmIcon="eject"
confirmContent="Confirm Eject?"
onClick={() => act('drop')}
>
Eject
</Button.Confirm>
</Stack.Item>
</Stack>
</Stack.Item>
<Stack.Item width="66%">
{tab[activemode] || <Box textColor="red">Error</Box>}
</Stack.Item>
</Stack>
</Stack.Item>
</Stack>
) : (
<center>No item inserted.</center>
)}

View File

@@ -1,19 +1,6 @@
export type Data = {
activemode: number;
matrixcolors: {
rr: number;
rg: number;
rb: number;
gr: number;
gg: number;
gb: number;
br: number;
bg: number;
bb: number;
cr: number;
cg: number;
cb: number;
};
matrixcolors: MatrixColors;
buildhue: number;
buildsat: number;
buildval: number;
@@ -25,3 +12,23 @@ export type Data = {
title?: string;
matrix_only?: number;
};
export type MatrixColors = {
rr: number;
rg: number;
rb: number;
gr: number;
gg: number;
gb: number;
br: number;
bg: number;
bb: number;
cr: number;
cg: number;
cb: number;
};
export type ColorPair = { input: string; output: string };
export type SelectedId = { id: number | null; type: 'input' | 'output' | null };
export type ColorUpdate = (hex: string, mode?: string, index?: number) => void;

View File

@@ -49,7 +49,7 @@ interface ColorPickerData {
timeout: number;
title: string;
default_color: string;
presets: string;
presets?: string;
}
type ColorPickerModalProps = Record<never, never>;
@@ -61,7 +61,7 @@ export const ColorPickerModal: React.FC<ColorPickerModalProps> = () => {
message,
autofocus,
default_color = '#000000',
presets = '',
presets,
} = data;
let { title } = data;
@@ -105,23 +105,26 @@ export const ColorPickerModal: React.FC<ColorPickerModalProps> = () => {
undefined,
);
const ourPresets = presets
.replaceAll('#', '')
.replace(/(^;)|(;$)/g, '')
.split(';');
while (ourPresets.length < 20) {
ourPresets.push('FFFFFF');
let presetList;
if (presets) {
const ourPresets = presets
.replaceAll('#', '')
.replace(/(^;)|(;$)/g, '')
.split(';');
while (ourPresets.length < 20) {
ourPresets.push('FFFFFF');
}
presetList = ourPresets.reduce(
(input, entry, index) => {
if (index < 10) {
return [[...input[0], entry], input[1]];
} else {
return [input[0], [...input[1], entry]];
}
},
[[], []],
);
}
const presetList = ourPresets.reduce(
(input, entry, index) => {
if (index < 10) {
return [[...input[0], entry], input[1]];
} else {
return [input[0], [...input[1], entry]];
}
},
[[], []],
);
return (
<Window
height={message ? 460 : 420}
@@ -168,11 +171,11 @@ export const ColorPickerModal: React.FC<ColorPickerModalProps> = () => {
interface ColorPresetsProps {
setColor: (color: HsvaColor) => void;
setShowPresets: (show: boolean) => void;
presetList: string[][];
presetList?: string[][];
selectedPreset: number | undefined;
onSelectedPreset: React.Dispatch<React.SetStateAction<number | undefined>>;
allowEditing: boolean;
onAllowEditing: React.Dispatch<React.SetStateAction<boolean>>;
allowEditing?: boolean;
onAllowEditing?: React.Dispatch<React.SetStateAction<boolean>>;
}
const ColorPresets: React.FC<ColorPresetsProps> = React.memo(
@@ -221,7 +224,7 @@ const ColorPresets: React.FC<ColorPresetsProps> = React.memo(
))}
</Stack.Item>
<Stack.Item mt={0.5}>
{presetList.map((row, index) => (
{presetList?.map((row, index) => (
<Stack.Item key={index} grow>
<Stack justify="center" g={0}>
{row.map((entry, i) => (
@@ -251,14 +254,16 @@ const ColorPresets: React.FC<ColorPresetsProps> = React.memo(
))}
</Stack.Item>
</Stack>
<Button
color={allowEditing ? 'green' : 'red'}
position="absolute"
right="4px"
bottom="4px"
icon="lock"
onClick={() => onAllowEditing(!allowEditing)}
/>
{!!onAllowEditing && (
<Button
color={allowEditing ? 'green' : 'red'}
position="absolute"
right="4px"
bottom="4px"
icon="lock"
onClick={() => onAllowEditing(!allowEditing)}
/>
)}
</>
);
},
@@ -268,14 +273,14 @@ interface ColorSelectorProps {
color: HsvaColor;
setColor: React.Dispatch<React.SetStateAction<HsvaColor>>;
defaultColor: string;
presetList: string[][];
presetList?: string[][];
selectedPreset: number | undefined;
onSelectedPreset: React.Dispatch<React.SetStateAction<number | undefined>>;
allowEditing: boolean;
onAllowEditing: React.Dispatch<React.SetStateAction<boolean>>;
allowEditing?: boolean;
onAllowEditing?: React.Dispatch<React.SetStateAction<boolean>>;
}
const ColorSelector: React.FC<ColorSelectorProps> = React.memo(
export const ColorSelector: React.FC<ColorSelectorProps> = React.memo(
({
color,
setColor,

View File

@@ -0,0 +1,14 @@
.MatrixEditor__Floating {
height: 290px;
width: 580px;
background-color: var(--color-section);
backdrop-filter: var(--blur-medium);
border: var(--border-thickness-tiny) solid var(--color-border);
border-radius: var(--border-radius-medium);
box-shadow: var(--shadow-glow-medium) hsla(0, 0%, 0%, 0.5);
margin: var(--space-m);
color: var(--color-text);
overflow-y: scroll;
overflow-x: hidden;
padding: var(--space-m);
}

View File

@@ -57,6 +57,7 @@
@include meta.load-css('./interfaces/VorePanel.scss');
@include meta.load-css('./interfaces/Wires.scss');
@include meta.load-css('./interfaces/PlushEditor.scss');
@include meta.load-css('./interfaces/ColorMatrixEditor.scss');
// Layouts
@include meta.load-css('./layouts/Layout.scss');