diff --git a/code/__defines/misc.dm b/code/__defines/misc.dm index 59adc8c8f35..61a27d63390 100644 --- a/code/__defines/misc.dm +++ b/code/__defines/misc.dm @@ -529,6 +529,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 DEPARTMENT_OFFDUTY "Off-Duty" diff --git a/code/game/machinery/painter_vr.dm b/code/game/machinery/painter_vr.dm index 5894a8d41e3..28b91fbf888 100644 --- a/code/game/machinery/painter_vr.dm +++ b/code/game/machinery/painter_vr.dm @@ -217,7 +217,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]), @@ -248,7 +248,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]), diff --git a/code/modules/tgui_input/matrix.dm b/code/modules/tgui_input/matrix.dm new file mode 100644 index 00000000000..f5def9d0764 --- /dev/null +++ b/code/modules/tgui_input/matrix.dm @@ -0,0 +1,338 @@ +/** + * Creates a TGUI window with a matrix input. Returns the user's response as list | null. + * + * This proc should be used to create windows for matrix entry that the caller will wait for a response from. + * If tgui fancy chat is turned off: Will return a normal input. If a max or min value is specified, will + * validate the input inside the UI and ui_act. + * + * Arguments: + * * user - The user to show the matrix input to. + * * message - The content of the matrix input, shown in the body of the TGUI window. + * * title - The title of the matrix input modal, shown on the top of the TGUI window. + * * target - The target where the matrix will be applied to. + * * matrix_only - uses a static mode and allows fallback to non tgui. Only use this if you only care about the return value. + * * 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 = 30 MINUTES, ui_state = GLOB.tgui_always_state) + if (!user) + user = usr + if (!istype(user)) + if (istype(user, /client)) + var/client/client = user + user = client.mob + else + return null + + if (isnull(user.client)) + return null + + if(!islist(default) || !length(default)) + default = DEFAULT_COLORMATRIX + + if(length(default) < 12) + default.len = 12 + + // Client does NOT have tgui_input on and we only want a matrix, or we haven't passed a preview path or object: Returns regular input + if(!user.read_preference(/datum/preference/toggle/tgui_input_mode) && matrix_only || (!ispath(target) && !isatom(target))) + return color_matrix_picker(user, message, title, "Ok", "Erase", "Cancel", TRUE, timeout, default) + var/was_path = ispath(target) + var/atom/movable/real_target = was_path ? new target : target + var/datum/tgui_input_colormatrix/matrix_input = new(user, message, title, real_target, default, matrix_only, timeout, ui_state, was_path) + matrix_input.tgui_interact(user) + matrix_input.wait() + // We only created it for the preview + if(was_path) + qdel(real_target) + if (matrix_input) + . = matrix_input.entry + qdel(matrix_input) + +/** + * # tgui_input_colormatrix + * + * Datum used for instantiating and using a TGUI-controlled color matrix input that prompts the user with + * a message and has an input for color matrix entry. + */ +/datum/tgui_input_colormatrix + /// Boolean field describing if the tgui_input_colormatrix was closed by the user. + var/closed + /// The entry that the user has return_typed in. + var/entry + /// The prompt's body, if any, of the TGUI window. + var/message + /// The target for our display + var/atom/movable/target + /// The base color matrix + var/list/default + /// static mode users can't change + var/matrix_only + /// our default list should not be edited as it might be a reference + var/list/color_matrix_last + /// The time at which the number input was created, for displaying timeout progress. + var/start_time + /// The lifespan of the color matrix input, after which the window will close and delete itself. + var/timeout + /// The title of the TGUI window + var/title + /// The TGUI UI state that will be returned in ui_state(). Default: always_state + var/datum/tgui_state/state + /// Internal var to remember if we only passed a path before + var/was_path + + var/activecolor = "#FFFFFF" + var/active_mode = COLORMATE_HSV + + var/build_hue = 0 + var/build_sat = 1 + var/build_val = 1 + + /// Minimum lightness for normal mode + var/minimum_normal_lightness = 50 + /// Minimum lightness for matrix mode, tested using 4 test colors of full red, green, blue, white. + var/minimum_matrix_lightness = 75 + /// Minimum matrix tests that must pass for something to be considered a valid color (see above) + var/minimum_matrix_tests = 2 + /// Temporary messages + var/temp + +/datum/tgui_input_colormatrix/New(mob/user, message, title, atom/movable/target, list/default, matrix_only, timeout, ui_state, was_path) + src.default = default + src.message = message + src.target = target + src.title = title + src.state = ui_state + src.was_path = was_path + src.matrix_only = matrix_only + if(matrix_only) + active_mode = COLORMATE_MATRIX + if (timeout) + src.timeout = timeout + start_time = world.time + QDEL_IN(src, timeout) + color_matrix_last = default.Copy() + +/datum/tgui_input_colormatrix/Destroy(force) + SStgui.close_uis(src) + state = null + target = null + return ..() + +/** + * Waits for a user's response to the tgui_input_colormatrix's prompt before returning. Returns early if + * the window was closed by the user. + */ +/datum/tgui_input_colormatrix/proc/wait() + while (!entry && !closed && !QDELETED(src)) + stoplag(1) + +/datum/tgui_input_colormatrix/tgui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "ColorMate") + ui.set_autoupdate(FALSE) //This might be a bit intensive, better to not update it every few ticks + ui.open() + +/datum/tgui_input_colormatrix/tgui_close(mob/user) + . = ..() + closed = TRUE + +/datum/tgui_input_colormatrix/tgui_state(mob/user) + return state + +/datum/tgui_input_colormatrix/tgui_static_data(mob/user) + var/list/data = list() + data["message"] = message + data["title"] = title + data["item_name"] = target.name + data["item_sprite"] = icon2base64(get_flat_icon(target,dir=SOUTH,no_anim=TRUE)) + data["matrix_only"] = matrix_only + return data + +/datum/tgui_input_colormatrix/tgui_data(mob/user) + var/list/data = list() + data["activemode"] = active_mode + data["matrixcolors"] = list( + "rr" = color_matrix_last[1], + "rg" = color_matrix_last[2], + "rb" = color_matrix_last[3], + "gr" = color_matrix_last[4], + "gg" = color_matrix_last[5], + "gb" = color_matrix_last[6], + "br" = color_matrix_last[7], + "bg" = color_matrix_last[8], + "bb" = color_matrix_last[9], + "cr" = color_matrix_last[10], + "cg" = color_matrix_last[11], + "cb" = color_matrix_last[12], + ) + data["buildhue"] = build_hue + data["buildsat"] = build_sat + data["buildval"] = build_val + data["item_preview"] = icon2base64(build_preview(user)) + if(temp) + data["temp"] = temp + if(timeout) + data["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS)) + return data + +/datum/tgui_input_colormatrix/tgui_act(action, list/params, datum/tgui/ui) + . = ..() + if (.) + return + switch(action) + if("switch_modes") + if(matrix_only && active_mode < 3) + return FALSE + active_mode = text2num(params["mode"]) + return TRUE + if("choose_color") + var/chosen_color = tgui_color_picker(ui.user, "Choose a color: ", "[title] colour picking", activecolor) + if(chosen_color) + activecolor = chosen_color + return TRUE + if("paint") + if(!do_paint(ui.user, !was_path)) + return TRUE + set_entry(color_matrix_last) + temp = "Painted Successfully!" + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("drop") + temp = "" + closed = TRUE + SStgui.close_uis(src) + return TRUE + if("clear") + target.remove_atom_colour(FIXED_COLOUR_PRIORITY) + playsound(src, 'sound/effects/spray3.ogg', 50, 1) + temp = "Cleared Successfully!" + color_matrix_last = DEFAULT_COLORMATRIX + return TRUE + if("set_matrix_color") + color_matrix_last[params["color"]] = params["value"] + return TRUE + if("set_matrix_string") + if(params["value"]) + var/list/colours = splittext(params["value"], ",") + if(length(colours) > 12) + colours.Cut(13) + for(var/i = 1, i <= length(colours), i++) + var/number = text2num(colours[i]) + if(isnum(number)) + color_matrix_last[i] = clamp(number, -10, 10) + return TRUE + if("set_hue") + build_hue = clamp(text2num(params["buildhue"]), 0, 360) + return TRUE + if("set_sat") + build_sat = clamp(text2num(params["buildsat"]), -10, 10) + return TRUE + if("set_val") + build_val = clamp(text2num(params["buildval"]), -10, 10) + return TRUE + +/datum/tgui_input_colormatrix/proc/set_entry(entry) + src.entry = entry + +/datum/tgui_input_colormatrix/proc/do_paint(mob/user, apply) + var/color_to_use + switch(active_mode) + if(COLORMATE_TINT) + color_to_use = activecolor + if(COLORMATE_MATRIX, COLORMATE_MATRIX_AUTO) + color_to_use = rgb_construct_color_matrix( + text2num(color_matrix_last[1]), + text2num(color_matrix_last[2]), + text2num(color_matrix_last[3]), + text2num(color_matrix_last[4]), + text2num(color_matrix_last[5]), + text2num(color_matrix_last[6]), + text2num(color_matrix_last[7]), + text2num(color_matrix_last[8]), + text2num(color_matrix_last[9]), + text2num(color_matrix_last[10]), + text2num(color_matrix_last[11]), + text2num(color_matrix_last[12]), + ) + if(COLORMATE_HSV) + color_to_use = color_matrix_hsv(build_hue, build_sat, build_val) + color_matrix_last = color_to_use + if(!color_to_use || !check_valid_color(color_to_use, user)) + temp = "Invalid color!" + return FALSE + if(apply) + target.add_atom_colour(color_to_use, FIXED_COLOUR_PRIORITY) + playsound(src, 'sound/effects/spray3.ogg', 50, 1) + if(isanimal(target)) + var/mob/living/simple_mob/M = target + M.has_recoloured = TRUE + if(isrobot(target)) + var/mob/living/silicon/robot/R = target + R.has_recoloured = TRUE + return TRUE + +/// Produces the preview image of the item, used in the UI, the way the color is not stacking is a sin. +/datum/tgui_input_colormatrix/proc/build_preview(mob/user) + if(target) //sanity + var/list/cm + switch(active_mode) + if(COLORMATE_MATRIX, COLORMATE_MATRIX_AUTO) + cm = rgb_construct_color_matrix( + text2num(color_matrix_last[1]), + text2num(color_matrix_last[2]), + text2num(color_matrix_last[3]), + text2num(color_matrix_last[4]), + text2num(color_matrix_last[5]), + text2num(color_matrix_last[6]), + text2num(color_matrix_last[7]), + text2num(color_matrix_last[8]), + text2num(color_matrix_last[9]), + text2num(color_matrix_last[10]), + text2num(color_matrix_last[11]), + text2num(color_matrix_last[12]), + ) + if(!check_valid_color(cm, user)) + return get_flat_icon(target, dir=SOUTH, no_anim=TRUE) + + if(COLORMATE_TINT) + if(!check_valid_color(activecolor, user)) + return get_flat_icon(target, dir=SOUTH, no_anim=TRUE) + + if(COLORMATE_HSV) + cm = color_matrix_hsv(build_hue, build_sat, build_val) + color_matrix_last = cm + if(!check_valid_color(cm, user)) + return get_flat_icon(target, dir=SOUTH, no_anim=TRUE) + + var/cur_color = target.color + target.color = null + target.color = (active_mode == COLORMATE_TINT ? activecolor : cm) + var/icon/preview = get_flat_icon(target, dir=SOUTH, no_anim=TRUE) + target.color = cur_color + temp = "" + + . = preview + +/datum/tgui_input_colormatrix/proc/check_valid_color(list/cm, mob/user) + if(!islist(cm)) // normal + var/list/HSV = ReadHSV(RGBtoHSV(cm)) + if(HSV[3] < minimum_normal_lightness) + temp = "[cm] is too dark (Minimum lightness: [minimum_normal_lightness])" + return FALSE + return TRUE + else // matrix + // We test using full red, green, blue, and white + // A predefined number of them must pass to be considered valid + var/passed = 0 +#define COLORTEST(thestring, thematrix) passed += (ReadHSV(RGBtoHSV(RGBMatrixTransform(thestring, thematrix)))[3] >= minimum_matrix_lightness) + COLORTEST("FF0000", cm) + COLORTEST("00FF00", cm) + COLORTEST("0000FF", cm) + COLORTEST("FFFFFF", cm) +#undef COLORTEST + if(passed < minimum_matrix_tests) + temp = "Matrix is too dark. (passed [passed] out of [minimum_matrix_tests] required tests. Minimum lightness: [minimum_matrix_lightness])." + return FALSE + return TRUE diff --git a/tgui/packages/tgui/interfaces/ColorMate/Helpers/ConfigField.tsx b/tgui/packages/tgui/interfaces/ColorMate/Helpers/ConfigField.tsx new file mode 100644 index 00000000000..da732f34d46 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/Helpers/ConfigField.tsx @@ -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(); + const { matrixcolors } = data; + + return ( + + + act('set_matrix_string', { value })} + /> + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/ColorMate/Helpers/ImageCanvas.tsx b/tgui/packages/tgui/interfaces/ColorMate/Helpers/ImageCanvas.tsx new file mode 100644 index 00000000000..8f744db897e --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/Helpers/ImageCanvas.tsx @@ -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(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) => { + 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 ( + + ); +} diff --git a/tgui/packages/tgui/interfaces/ColorMate/Helpers/MatrixColorBox.tsx b/tgui/packages/tgui/interfaces/ColorMate/Helpers/MatrixColorBox.tsx new file mode 100644 index 00000000000..a75145df8a3 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/Helpers/MatrixColorBox.tsx @@ -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( + undefined, + ); + const [currentColor, setCurrentColor] = useState( + 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 ( + + } + > + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMateColor.tsx b/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMateColor.tsx new file mode 100644 index 00000000000..ee954764d1d --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMateColor.tsx @@ -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 ( + + ); +}; + +export const ColorMateHSV = (props) => { + const { act, data } = useBackend(); + + const { buildhue, buildsat, buildval } = data; + return ( + + + + + Hue: + + + value.toFixed()} + onChange={(e, value: number) => + act('set_hue', { + buildhue: value, + }) + } + /> + + + + + + + Saturation: + + + value.toFixed(2)} + onChange={(e, value: number) => + act('set_sat', { + buildsat: value, + }) + } + /> + + + + + + + Value: + + + value.toFixed(2)} + onChange={(e, value: number) => + act('set_val', { + buildval: value, + }) + } + /> + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMateMatrix.tsx b/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMateMatrix.tsx new file mode 100644 index 00000000000..f86af78dfe9 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMateMatrix.tsx @@ -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(); + const { matrixcolors } = data; + + return ( + + + + {MATRIX_COLUMS.map((column, colIndex) => ( + + + {column.map(({ label, key, color }) => ( + + + {label}: + + value.toFixed(2)} + onChange={(value: number) => + act('set_matrix_color', { color: color, value }) + } + /> + + + + ))} + + + ))} + + + + RG means red will + become this much green. + + + CR means this much + red will be added. + + + + + + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMatrixSolver.tsx b/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMatrixSolver.tsx new file mode 100644 index 00000000000..abf9f832847 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/MatrixTabs/ColorMatrixSolver.tsx @@ -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>; + colorPairs: ColorPair[]; + onColorPairs: React.Dispatch>; + onPick: ColorUpdate; +}) => { + const { act } = useBackend(); + 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 ( +
+ + {colorPairs.map((colorPair, index) => ( + + + + {`${index + 1}: `} + Source + + + + updateColor(value, 'input', index) + } + /> + + + + + {colorPairs.length < 20 && ( + + + + )} + + )} + {index > 0 && ( + + removePair(index)} + /> + + )} + + + ))} + +
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/ColorMate/constants.ts b/tgui/packages/tgui/interfaces/ColorMate/constants.ts new file mode 100644 index 00000000000..498c7874679 --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/constants.ts @@ -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 }, + ], +]; diff --git a/tgui/packages/tgui/interfaces/ColorMate/functions.ts b/tgui/packages/tgui/interfaces/ColorMate/functions.ts new file mode 100644 index 00000000000..e6fb81f53ff --- /dev/null +++ b/tgui/packages/tgui/interfaces/ColorMate/functions.ts @@ -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]); +} diff --git a/tgui/packages/tgui/interfaces/ColorMate/index.tsx b/tgui/packages/tgui/interfaces/ColorMate/index.tsx index c6deda3be08..c70ad84c7b0 100644 --- a/tgui/packages/tgui/interfaces/ColorMate/index.tsx +++ b/tgui/packages/tgui/interfaces/ColorMate/index.tsx @@ -1,33 +1,98 @@ +import { useState } from 'react'; import { useBackend } from 'tgui/backend'; import { Window } from 'tgui/layouts'; import { Box, Button, - Image, NoticeBox, Section, 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(); +<<<<<<< HEAD const { activemode, temp, item } = data; +======= + const [colorPairs, setColorPairs] = useState([ + { input: '#ffffff', output: '#000000' }, + ]); + + const [activeID, setActiveId] = useState({ id: null, type: null }); + + const { + activemode, + temp, + item_name, + item_sprite, + item_preview, + title, + message, + matrix_only, + } = data; +>>>>>>> 9709af12a9 ([MIRROR] colorsolver (#11773)) const tab: React.JSX.Element[] = []; tab[1] = ; tab[2] = ; tab[3] = ; + tab[4] = ( + + ); + +<<<<<<< HEAD +======= + 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; + }); + } +>>>>>>> 9709af12a9 ([MIRROR] colorsolver (#11773)) return ( +<<<<<<< HEAD
{temp ? {temp} : null} {item && Object.keys(item).length ? ( @@ -114,6 +179,160 @@ export const ColorMate = (props) => { ) : (
No item inserted.
)} +======= +
+ + {!!temp && ( + + {temp} + + )} + + {message} + + + {item_name ? ( + + + + +
+ + Item: + + + + +
+
+ +
+ + Preview: + + + + +
+
+
+
+ + + {!matrix_only && ( + <> + + act('switch_modes', { + mode: 1, + }) + } + > + Tint coloring (Simple) + + + act('switch_modes', { + mode: 2, + }) + } + > + HSV coloring (Normal) + + + )} + + act('switch_modes', { + mode: 3, + }) + } + > + Matrix coloring (Advanced) + + + act('switch_modes', { + mode: 4, + }) + } + > + Matrix coloring (Automatic) + + + + Coloring: {item_name} + + + + + + act('paint')} + > + Paint + + + + act('clear')} + > + Clear + + + + act('drop')} + > + Eject + + + + + + {tab[activemode] || Error} + + + +
+ ) : ( +
No item inserted.
+ )} +
+
+>>>>>>> 9709af12a9 ([MIRROR] colorsolver (#11773))
diff --git a/tgui/packages/tgui/interfaces/ColorMate/types.ts b/tgui/packages/tgui/interfaces/ColorMate/types.ts index a56bab9c7b1..7d40fb242dc 100644 --- a/tgui/packages/tgui/interfaces/ColorMate/types.ts +++ b/tgui/packages/tgui/interfaces/ColorMate/types.ts @@ -1,22 +1,29 @@ 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; temp: string | null; item: { name: string; sprite: string; preview: string } | null; }; + +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; diff --git a/tgui/packages/tgui/interfaces/ColorPickerModal.tsx b/tgui/packages/tgui/interfaces/ColorPickerModal.tsx index a42495a80c3..04185ef3844 100644 --- a/tgui/packages/tgui/interfaces/ColorPickerModal.tsx +++ b/tgui/packages/tgui/interfaces/ColorPickerModal.tsx @@ -44,7 +44,7 @@ interface ColorPickerData { timeout: number; title: string; default_color: string; - presets: string; + presets?: string; } interface ColorPickerModalProps {} @@ -56,7 +56,7 @@ export const ColorPickerModal: React.FC = () => { message, autofocus, default_color = '#000000', - presets = '', + presets, } = data; let { title } = data; @@ -91,23 +91,26 @@ export const ColorPickerModal: React.FC = () => { 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 ( = () => { interface ColorPresetsProps { setColor: (color: HsvaColor) => void; setShowPresets: (show: boolean) => void; - presetList: string[][]; + presetList?: string[][]; selectedPreset: number | undefined; onSelectedPreset: React.Dispatch>; - allowEditing: boolean; - onAllowEditing: React.Dispatch>; + allowEditing?: boolean; + onAllowEditing?: React.Dispatch>; } const ColorPresets: React.FC = React.memo( @@ -207,7 +210,7 @@ const ColorPresets: React.FC = React.memo( ))} - {presetList.map((row, index) => ( + {presetList?.map((row, index) => ( {row.map((entry, i) => ( @@ -237,14 +240,16 @@ const ColorPresets: React.FC = React.memo( ))} -