From 5efeedbad99f4b961669d0a77445f967e185ee28 Mon Sep 17 00:00:00 2001 From: Y0SH1M4S73R Date: Sun, 1 Feb 2026 14:51:58 -0500 Subject: [PATCH] [TM First] Paint Canvas Refactor (powered by the Universal Icon Editing Framework) (#94797) ## About The Pull Request This PR adds a universal framework for editing sprites, and adds an example implementation by way of porting paint canvases to it. This should have no impact on serializing paintings in the database. As part of this canvas refactor, zooming in and out of a canvas is now handled entirely on the client side. The paint palette component has also been refactored to allow a variable number of colors up to the specified maximum, instead of populating all color slots to start out with. Some features of the sprite editing framework are unused in this PR, as they were not necessary for feature parity with the current implementation of paintings. However, they remain present for use in future PRs, such as a PDA painting app that was separated out of this branch for atomicity. These features include: - Eraser tool - Undo history - Layers - Multi-dir icon support Support for animated icons is not present, but planned to be added when a new feature needs it. ### New Painting UI Screenshot: NewCanvasUiDemo ## Why It's Good For The Game This framework opens the possibility for a wide variety of new features, such as Goofball's planned tailoring mechanic. Additionally, the consolidation of all the controls into a single window should make painting more user-friendly. ## Changelog :cl: refactor: Painting has been significantly refactored with a new UI that should provide every painting control you need without having to unfocus the UI window. Please report any issues. refactor: Items that can store paint palettes now start with no colors. Colors can be added to and removed from the item's palette, up to the number of colors they previously stored. /:cl: --------- Co-authored-by: Jordan Dominion --- code/__DEFINES/colors.dm | 2 + code/__DEFINES/dcs/signals/signals_datum.dm | 8 +- code/__DEFINES/sprite_editor.dm | 17 + code/__HELPERS/colors.dm | 23 + code/__HELPERS/icons.dm | 47 ++ code/_globalvars/lists/mapping.dm | 10 + code/datums/components/palette.dm | 65 +- code/game/objects/items/crayons.dm | 2 + .../engineering/painter/decal_painter.dm | 2 +- code/modules/art/paintings.dm | 403 +++------ code/modules/sprite_editing/flood_fill.dm | 53 ++ code/modules/sprite_editing/workspace.dm | 302 +++++++ code/modules/tgui/tgui.dm | 1 + tgstation.dme | 3 + tgui/bun.lock | 3 + .../tgui/assets/transparency_checkerboard.svg | 10 + tgui/packages/tgui/interfaces/Canvas.tsx | 773 ++++++++---------- .../Components/AdvancedCanvas.tsx | 191 +++++ .../SpriteEditor/Components/ColorPicker.tsx | 575 +++++++++++++ .../SpriteEditor/Components/LayerManager.tsx | 224 +++++ .../SpriteEditor/Components/Palette.tsx | 83 ++ .../common/SpriteEditor/Types/Tool.ts | 26 + .../common/SpriteEditor/Types/Tools/Bucket.ts | 35 + .../common/SpriteEditor/Types/Tools/Eraser.ts | 125 +++ .../SpriteEditor/Types/Tools/Eyedropper.ts | 28 + .../common/SpriteEditor/Types/Tools/Pencil.ts | 126 +++ .../common/SpriteEditor/Types/Transaction.ts | 8 + .../common/SpriteEditor/Types/types.ts | 131 +++ .../interfaces/common/SpriteEditor/atoms.ts | 40 + .../common/SpriteEditor/colorSpaces.ts | 131 +++ .../interfaces/common/SpriteEditor/helpers.ts | 157 ++++ .../interfaces/common/SpriteEditor/index.tsx | 362 ++++++++ tgui/packages/tgui/package.json | 1 + 33 files changed, 3206 insertions(+), 761 deletions(-) create mode 100644 code/__DEFINES/sprite_editor.dm create mode 100644 code/modules/sprite_editing/flood_fill.dm create mode 100644 code/modules/sprite_editing/workspace.dm create mode 100644 tgui/packages/tgui/assets/transparency_checkerboard.svg create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Components/AdvancedCanvas.tsx create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Components/ColorPicker.tsx create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Components/LayerManager.tsx create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Components/Palette.tsx create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tool.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Bucket.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eraser.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Eyedropper.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Tools/Pencil.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/Transaction.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/Types/types.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/atoms.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/colorSpaces.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/helpers.ts create mode 100644 tgui/packages/tgui/interfaces/common/SpriteEditor/index.tsx diff --git a/code/__DEFINES/colors.dm b/code/__DEFINES/colors.dm index e0cfe5c44dd..59f6ce1471d 100644 --- a/code/__DEFINES/colors.dm +++ b/code/__DEFINES/colors.dm @@ -462,6 +462,8 @@ #define COLOR_AMMO_ARMORPIERCE "#d9d9d9" #define COLOR_AMMO_HOLLOWPOINT "#ff9900" +#define COLOR_DMI_MASK "#a0a0a000" + GLOBAL_LIST_INIT(cable_colors, list( CABLE_COLOR_BLUE = CABLE_HEX_COLOR_BLUE, CABLE_COLOR_CYAN = CABLE_HEX_COLOR_CYAN, diff --git a/code/__DEFINES/dcs/signals/signals_datum.dm b/code/__DEFINES/dcs/signals/signals_datum.dm index 19e28e35984..3668d202a7f 100644 --- a/code/__DEFINES/dcs/signals/signals_datum.dm +++ b/code/__DEFINES/dcs/signals/signals_datum.dm @@ -22,7 +22,9 @@ #define COMSIG_VV_TOPIC "vv_topic" #define COMPONENT_VV_HANDLED (1<<0) /// from datum ui_act (usr, action) -#define COMSIG_UI_ACT "COMSIG_UI_ACT" +#define COMSIG_UI_ACT "ui_act" +/// from datum/tgui/get_payload(user, list/data) +#define COMSIG_UI_DATA "ui_data" /// fires on the target datum when an element is attached to it (/datum/element) #define COMSIG_ELEMENT_ATTACH "element_attach" @@ -53,3 +55,7 @@ ///from /datum/component/bubble_icon_override/get_bubble_icon(): (list/holder) #define COMSIG_GET_BUBBLE_ICON "get_bubble_icon" + +///from /datum/sprite_editor_workspace/is_valid_color(): (color) +#define COMSIG_SPRITE_EDITOR_VALIDATE_COLOR "sprite_editor_validate_color" + #define COLOR_IS_INVALID (1<<0) diff --git a/code/__DEFINES/sprite_editor.dm b/code/__DEFINES/sprite_editor.dm new file mode 100644 index 00000000000..30076b7e2cf --- /dev/null +++ b/code/__DEFINES/sprite_editor.dm @@ -0,0 +1,17 @@ +// Color modes +/// Full RGBA color picker +#define SPRITE_EDITOR_COLOR_MODE_RGBA "rgba" +/// RGB color picker without alpha +#define SPRITE_EDITOR_COLOR_MODE_RGB "rgb" +/// Greyscale color picker with a single value +#define SPRITE_EDITOR_COLOR_MODE_GREYSCALE "greyscale" + +// Config flags +#define SPRITE_EDITOR_ALLOW_LAYERS (1<<0) +#define SPRITE_EDITOR_ALLOW_UNDO (1<<1) + +// Tool flags +#define SPRITE_EDITOR_TOOL_PENCIL (1<<0) +#define SPRITE_EDITOR_TOOL_ERASER (1<<1) +#define SPRITE_EDITOR_TOOL_DROPPER (1<<2) +#define SPRITE_EDITOR_TOOL_BUCKET (1<<3) diff --git a/code/__HELPERS/colors.dm b/code/__HELPERS/colors.dm index 6a5d6025fd4..d9da27f62c5 100644 --- a/code/__HELPERS/colors.dm +++ b/code/__HELPERS/colors.dm @@ -264,3 +264,26 @@ modify.underlays[underlay_index] = filter_appearance_recursive(modify.underlays[underlay_index], filter_to_apply) return modify + +#define ALPHA_COMPOSE(src_a, comp_a, back_ch, src_ch) ((1 - src_a / comp_a) * back_ch + (src_a / comp_a) * src_ch) + +/// Blend two colors using the normal blend mode of the CSS compositing algorithm +/proc/blend_color(backdrop = "#00000000", source) + var/list/rgb_source = split_color(source) + var/source_alpha = rgb_source[4] + if(source_alpha == 0) + return backdrop + if(source_alpha == 255) + return source + var/list/rgb_backdrop = split_color(backdrop) + var/backdrop_alpha = rgb_backdrop[4] / 255 + source_alpha /= 255 + var/output_alpha = source_alpha + backdrop_alpha - source_alpha * backdrop_alpha + return rgb( + ALPHA_COMPOSE(source_alpha, output_alpha, rgb_backdrop[1], rgb_source[1]), + ALPHA_COMPOSE(source_alpha, output_alpha, rgb_backdrop[2], rgb_source[2]), + ALPHA_COMPOSE(source_alpha, output_alpha, rgb_backdrop[3], rgb_source[3]), + output_alpha * 255 + ) + +#undef ALPHA_COMPOSE diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm index ac67848447a..4f23df1d0aa 100644 --- a/code/__HELPERS/icons.dm +++ b/code/__HELPERS/icons.dm @@ -1393,3 +1393,50 @@ GLOBAL_LIST_EMPTY(transformation_animation_objects) icon_cache[job_type] = sechud_icon return icon(icon_cache[job_type]) + +/** + * Copies the pixel colors from the passed in icon `I` to the 2d list `grid` + */ +/proc/fill_grid_from_icon(list/grid, icon/I) + var/width = I.Width() + var/height = I.Height() + for(var/x in 1 to width) + for(var/y in 1 to height) + grid[y][x] = I.GetPixel(x,height+1-y) + +// Given a number of frames for an icon state, and the dimensions of the icon, returns the ideal dimensions for a DMI file +/proc/calculate_optimal_icon_grid_dimensions(width, height, count) + var/grid_width = 1 + var/grid_height = 1 + while(grid_width * grid_height < count) + if(height*grid_height < width*grid_width) + grid_height++ + else + grid_width++ + return list(grid_height, grid_width) + +// Reorder the 2d pixel data of the passed in frames into a data string that can be passed to rustg_dmi_create_png +/proc/reorder_pixels(icon_width, icon_height, grid_width, grid_height, list/frames) + var/file_height = icon_height * grid_height + + // This little trick right here reduces the total iteration of repeat_string from the product of the arguments to their sum. + // Can't be applied to the general case without a complex partitioning algorithm, + // since the count could either be a large prime or have large primes as factors + var/linear_pixels = COLOR_DMI_MASK + for(var/count in list(icon_width, icon_height, grid_width, grid_height)) + if(count == 1) + continue + linear_pixels = repeat_string(count, linear_pixels) + + for(var/i in 1 to length(frames)) + var/list/frame = frames[i] + var/row_index = floor((i-1)/grid_width) + var/column = (i-1)%grid_width + for(var/y in 1 to length(frame)) + var/list/row = jointext(frame[y], "") + var/splice_start = (row_index+y-1)*file_height + column*icon_width + 1 + linear_pixels = splicetext(splice_start*9, (splice_start+icon_width)*9, row) + var/zero_alpha_regex = regex(@@#(?:(?!a0a0a0)([0-9]|[a-f]){6}00)@, "gi") + linear_pixels = replacetext(linear_pixels, zero_alpha_regex, COLOR_DMI_MASK) + return linear_pixels + diff --git a/code/_globalvars/lists/mapping.dm b/code/_globalvars/lists/mapping.dm index 116fb3adbea..0f1eebb3347 100644 --- a/code/_globalvars/lists/mapping.dm +++ b/code/_globalvars/lists/mapping.dm @@ -92,6 +92,16 @@ GLOBAL_LIST_INIT(alldirs, list( SOUTHEAST, SOUTHWEST, )) +GLOBAL_LIST_INIT(alldirs_dmi_order, list( + SOUTH, + NORTH, + EAST, + WEST, + SOUTHEAST, + SOUTHWEST, + NORTHEAST, + NORTHWEST, +)) GLOBAL_LIST_INIT(cardinal_angles, list( "[NORTH]" = 0, diff --git a/code/datums/components/palette.dm b/code/datums/components/palette.dm index 0fb937a3b11..a3551b0b59f 100644 --- a/code/datums/components/palette.dm +++ b/code/datums/components/palette.dm @@ -5,9 +5,10 @@ * and call set_painting_tool_color() on the parent for more specific object behavior. */ /datum/component/palette + /// The maximum number of colors this palette can have. + var/max_colors /* * A list that stores a selection of colors. - * The number of available spaces is defined by the available_space arg of Initialize() */ var/list/colors = list() /* @@ -20,25 +21,20 @@ /// The radial menu choice datums are stored here as a microop to avoid generating new ones every time the menu is opened or updated. var/list/datum/radial_menu_choice/menu_choices -/datum/component/palette/Initialize(available_space, selected_color) +/datum/component/palette/Initialize(max_colors, selected_color) if(!isitem(parent)) return COMPONENT_INCOMPATIBLE - if(!isnum(available_space) || available_space < 1) /// This component means nothing if there's no space for colors - stack_trace("palette component initialized without a proper value for the available_space arg") + if(!isnum(max_colors) || max_colors < 1) /// This component means nothing if there's no space for colors + stack_trace("palette component initialized without a proper value for the max_colors arg") return COMPONENT_INCOMPATIBLE - for(var/index in 1 to available_space) - colors += "#ffffff" - - src.colors = colors + src.max_colors = max_colors src.selected_color = selected_color || "#ffffff" RegisterSignal(parent, COMSIG_ITEM_ATTACK_SELF_SECONDARY, PROC_REF(on_attack_self_secondary)) RegisterSignal(parent, COMSIG_ATOM_EXAMINE, PROC_REF(on_examine)) RegisterSignal(parent, COMSIG_PAINTING_TOOL_SET_COLOR, PROC_REF(on_painting_tool_set_color)) - RegisterSignal(parent, COMSIG_PAINTING_TOOL_GET_ADDITIONAL_DATA, PROC_REF(get_palette_data)) - RegisterSignal(parent, COMSIG_PAINTING_TOOL_PALETTE_COLOR_CHANGED, PROC_REF(palette_color_changed)) /datum/component/palette/Destroy() QDEL_NULL(color_picker_menu) @@ -51,7 +47,7 @@ SIGNAL_HANDLER examine_list += span_notice("Right-Click this item while it's in your active hand to open/close its color picker menu.") - examine_list += span_notice("In the color picker, Left-Click a color button to pick it or Right-Click to edit it.") + examine_list += span_notice("In the color picker, Left-Click a color button to pick it or Right-Click to remove it.") /datum/component/palette/proc/on_attack_self_secondary(datum/source, mob/user) SIGNAL_HANDLER @@ -72,12 +68,27 @@ /datum/component/palette/proc/build_radial_list() var/radial_list = list() - LAZYSETLEN(menu_choices, length(colors)) + var/color_count = length(colors) + LAZYSETLEN(menu_choices, max(color_count+1)) + if(color_count < max_colors && !(selected_color in colors)) + var/datum/radial_menu_choice/add_option = peek(menu_choices) + if(!add_option) + add_option = new + menu_choices[color_count+1] = add_option + var/image/element = image(icon = 'icons/hud/radial.dmi', icon_state = "palette_element") + element.color = selected_color + var/image/plus = image(icon = 'icons/hud/radial.dmi', icon_state = "palette_add") + plus.appearance_flags = /image::appearance_flags | RESET_COLOR + element.add_overlay(plus) + add_option.image = element + add_option.name = "Add Color ([selected_color])" + radial_list["add"] = add_option for(var/index in 1 to length(colors)) var/hexcolor = colors[index] var/datum/radial_menu_choice/option = menu_choices[index] if(!option) option = new + menu_choices[index] = option var/icon_state_to_use = hexcolor == selected_color ? "palette_selected" : "palette_element" var/image/element = image(icon = 'icons/hud/radial.dmi', icon_state = icon_state_to_use) element.color = hexcolor @@ -104,11 +115,14 @@ close_radial_menu() return var/is_right_clicking = LAZYACCESS(params2list(params), RIGHT_CLICK) + if(choice == "add") + if(length(colors) < max_colors) + colors += selected_color + update_radial_list() + return var/index = text2num(choice) if(is_right_clicking) - var/chosen_color = tgui_color_picker(user, "Pick new color", "[parent]", colors[index]) - if(chosen_color && !QDELETED(src) && !IS_DEAD_OR_INCAP(user) && user.is_holding(parent)) - colors[index] = chosen_color + colors.Cut(index, index+1) update_radial_list() else var/obj/item/parent_item = parent @@ -119,24 +133,3 @@ selected_color = chosen_color update_radial_list() - -/datum/component/palette/proc/get_palette_data(datum/source, data) - SIGNAL_HANDLER - var/list/painting_data = list() - for(var/hexcolor in colors) - painting_data += list(list( - "color" = hexcolor, - "is_selected" = hexcolor == selected_color - )) - data["paint_tool_palette"] = painting_data - -/datum/component/palette/proc/palette_color_changed(datum/source, chosen_color, index) - SIGNAL_HANDLER - - var/was_selected_color = selected_color == colors[index] - colors[index] = chosen_color - if(was_selected_color) - var/obj/item/parent_item = parent - parent_item.set_painting_tool_color(chosen_color) - else - update_radial_list() diff --git a/code/game/objects/items/crayons.dm b/code/game/objects/items/crayons.dm index f055aa108bd..9737e7ba1f5 100644 --- a/code/game/objects/items/crayons.dm +++ b/code/game/objects/items/crayons.dm @@ -238,6 +238,8 @@ /// Sets painting color and updates appearance. /obj/item/toy/crayon/set_painting_tool_color(chosen_color) . = ..() + if(!can_change_colour) + return paint_color = chosen_color update_appearance() diff --git a/code/game/objects/items/tools/engineering/painter/decal_painter.dm b/code/game/objects/items/tools/engineering/painter/decal_painter.dm index 35abfb90b94..cfd9a34cc2f 100644 --- a/code/game/objects/items/tools/engineering/painter/decal_painter.dm +++ b/code/game/objects/items/tools/engineering/painter/decal_painter.dm @@ -14,7 +14,7 @@ /// The current base icon state of the decal being printed. VAR_PRIVATE/selected_decal_icon_state = "warningline" /// Current custom color - VAR_PRIVATE/selected_custom_color + var/selected_custom_color /// Current active decal category. Reference to a global singleton VAR_PRIVATE/datum/paintable_decal_category/current_category diff --git a/code/modules/art/paintings.dm b/code/modules/art/paintings.dm index 1c62bc96cb2..eec2f93e5fe 100644 --- a/code/modules/art/paintings.dm +++ b/code/modules/art/paintings.dm @@ -1,4 +1,11 @@ -#define MAX_PAINTING_ZOOM_OUT 3 +GLOBAL_LIST_INIT(canvas_dimensions, init_canvas_dimensions()) + +/proc/init_canvas_dimensions() + . = list() + for(var/obj/item/canvas/canvas_type in typesof(/obj/item/canvas)) + var/width = canvas_type::width + var/height = canvas_type::height + .["[width]x[height]"] = list(width, height) /////////// // EASEL // @@ -16,15 +23,14 @@ var/obj/item/canvas/painting = null //Adding canvases -/obj/structure/easel/attackby(obj/item/I, mob/user, list/modifiers, list/attack_modifiers) - if(istype(I, /obj/item/canvas)) - var/obj/item/canvas/canvas = I +/obj/structure/easel/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(istype(tool, /obj/item/canvas)) + var/obj/item/canvas/canvas = tool user.transfer_item_to_turf(canvas, get_turf(src), silent = FALSE) painting = canvas canvas.layer = layer+0.1 user.visible_message(span_notice("[user] puts \the [canvas] on \the [src]."),span_notice("You place \the [canvas] on \the [src].")) - else - return ..() + return ITEM_INTERACT_SUCCESS //Stick to the easel like glue @@ -46,14 +52,13 @@ interaction_flags_atom = parent_type::interaction_flags_atom | INTERACT_ATOM_ALLOW_USER_LOCATION var/width = 11 var/height = 11 - var/list/grid /// empty canvas color var/canvas_color = "#ffffff" + /// The sprite editor workspace that carries the data for this canvas + var/datum/sprite_editor_workspace/workspace /// Is it clean canvas or was there something painted on it at some point, used to decide when to show wip splotch overlay var/used = FALSE var/finalized = FALSE //Blocks edits - /// Whether a grid should be shown in the UI if the canvas is editable and the viewer is holding a painting tool. - var/show_grid = TRUE var/icon_generated = FALSE var/icon/generated_icon ///boolean that blocks persistence from saving it. enabled from printing copies, because we do not want to save copies. @@ -74,17 +79,20 @@ */ var/pixels_per_unit = 9 - ///A list that keeps track of the current zoom value for each current viewer. - var/list/zoom_by_observer - SET_BASE_PIXEL(11, 10) custom_price = PAYCHECK_CREW /obj/item/canvas/Initialize(mapload) . = ..() - reset_grid() - + workspace = new(width, + height, + color_mode = SPRITE_EDITOR_COLOR_MODE_RGB, + config_flags = NONE, + tool_flags = SPRITE_EDITOR_TOOL_PENCIL | SPRITE_EDITOR_TOOL_BUCKET, + initial_layer_color = "[canvas_color]ff" // To avoid needing to handle strings of mixed lengths, sprite editor workspaces always use the alpha channel + ) + RegisterSignal(workspace, COMSIG_SPRITE_EDITOR_VALIDATE_COLOR, PROC_REF(validate_color)) painting_metadata = new painting_metadata.title = "Untitled Artwork" painting_metadata.creation_round_id = GLOB.round_id @@ -100,12 +108,6 @@ painting_metadata = null return ..() -/obj/item/canvas/proc/reset_grid() - grid = new/list(width,height) - for(var/x in 1 to width) - for(var/y in 1 to height) - grid[x][y] = canvas_color - /obj/item/canvas/attack_self(mob/user) . = ..() ui_interact(user) @@ -116,56 +118,55 @@ return ..() /obj/item/canvas/ui_state(mob/user) - if(isobserver(user)) - return GLOB.observer_state if(finalized) return GLOB.hold_or_view_state return GLOB.default_state -/obj/item/canvas/ui_status(mob/user, datum/ui_state/state) - if(state == GLOB.default_state || !state) - return ..() - //Skip the can_interact() check from atom/ui_status() and let them zoom in/out! - var/src_object = ui_host(user) - return state.can_use_topic(src_object, user) - /obj/item/canvas/ui_interact(mob/user, datum/tgui/ui) ui = SStgui.try_update_ui(user, src, ui) if(!ui) ui = new(user, src, "Canvas", name) ui.open() -/obj/item/canvas/attackby(obj/item/I, mob/living/user, list/modifiers, list/attack_modifiers) - if(!user.combat_mode) - ui_interact(user) - else - return ..() - -/obj/item/canvas/ui_static_data(mob/user) - . = ..() - .["px_per_unit"] = pixels_per_unit - .["max_zoom"] = MAX_PAINTING_ZOOM_OUT +/obj/item/canvas/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + ui_interact(user) + return ITEM_INTERACT_SUCCESS /obj/item/canvas/ui_data(mob/user) . = ..() - .["grid"] = grid - .["zoom"] = LAZYACCESS(zoom_by_observer, user.key) || (finalized ? 1 : MAX_PAINTING_ZOOM_OUT) - .["name"] = painting_metadata.title - .["author"] = painting_metadata.creator_name - .["patron"] = painting_metadata.patron_name - .["medium"] = painting_metadata.medium - .["date"] = painting_metadata.creation_date - .["finalized"] = finalized - .["editable"] = !finalized //Ideally you should be able to draw moustaches on existing paintings in the gallery but that's not implemented yet - .["show_plaque"] = istype(loc,/obj/structure/sign/painting) - .["show_grid"] = show_grid - .["paint_tool_palette"] = null - var/obj/item/painting_implement = user.get_active_held_item() - if(!painting_implement) - .["paint_tool_color"] = null - return - .["paint_tool_color"] = get_paint_tool_color(painting_implement) - SEND_SIGNAL(painting_implement, COMSIG_PAINTING_TOOL_GET_ADDITIONAL_DATA, .) + var/list/metadata = list( + "title" = painting_metadata.title, + "author" = painting_metadata.creator_name, + "patron" = painting_metadata.patron_name, + "medium" = painting_metadata.medium, + "date" = painting_metadata.creation_date, + ) + var/list/editor_data = workspace.sprite_editor_ui_data() + var/can_edit = TRUE + + var/obj/item/implement = user.get_active_held_item() + var/implement_color = get_paint_tool_color(implement) + var/can_change_implement_color = can_change_paint_tool_color(implement) + if(implement_color) + editor_data["serverSelectedColor"] = implement_color + editor_data["serverPalette"] = get_paint_tool_palette(implement) + editor_data["maxServerColors"] = get_paint_tool_palette_capacity(implement) + editor_data["onSelectServerColor"] = "onSelectColor" + editor_data["onAddServerColor"] = "onAddPaletteColor" + editor_data["onRemoveServerColor"] = "onRemovePaletteColor" + if(can_change_implement_color) + editor_data["toolFlags"] |= SPRITE_EDITOR_TOOL_DROPPER + else + can_edit = FALSE + return list( + "metadata" = metadata, + "editorData" = editor_data, + "pixelsPerUnit" = pixels_per_unit, + "finalized" = finalized, + "allowColorPicker" = can_change_implement_color, + "editable" = can_edit && !finalized, //Ideally you should be able to draw moustaches on existing paintings in the gallery but that's not implemented yet + "showPlaque" = istype(loc, /obj/structure/sign/painting) + ) /obj/item/canvas/examine(mob/user) . = ..() @@ -176,87 +177,56 @@ if(.) return var/mob/user = usr - //this is here to allow observers and viewers to zoom in and out regardless of adjacency. - //observers need this special check because we allow them to operate the UI in ui_state - if((action != "zoom_in" && action != "zoom_out") && (isobserver(user) || !can_interact(user))) - return + var/obj/item/implement = user.get_active_held_item() + var/datum/component/palette/palette_comp = implement?.GetComponent(/datum/component/palette) switch(action) - if("paint", "fill") + if("spriteEditorCommand") + . = TRUE if(finalized) - return TRUE - var/obj/item/I = user.get_active_held_item() - var/tool_color = get_paint_tool_color(I) - if(!tool_color) - return FALSE - if(action == "fill") - var/x = params["x"] - var/y = params["y"] - if(!canvas_fill(x, y, tool_color)) - return FALSE - else - var/list/data = params["data"] - for(var/point in data) - var/x = text2num(point["x"]) - var/y = text2num(point["y"]) - grid[x][y] = tool_color - var/medium = get_paint_tool_medium(I) + return + var/command = params["command"] + if(command != "transaction") // Painting only allows transactions, no undo/redo or layer visibility toggling + return + if(!workspace.new_transaction(params["transaction"])) + return + var/medium = get_paint_tool_medium(implement) if(medium && painting_metadata.medium && painting_metadata.medium != medium) painting_metadata.medium = "Mixed medium" else painting_metadata.medium = medium used = TRUE update_appearance() + if("onSelectColor") . = TRUE - if("select_color") - var/obj/item/painting_implement = user.get_active_held_item() - painting_implement?.set_painting_tool_color(params["selected_color"]) + var/paint_color = copytext(params["color"], 1, 8) + implement?.set_painting_tool_color(paint_color) + if("onAddPaletteColor") . = TRUE - if("select_color_from_coords") - var/obj/item/painting_implement = user.get_active_held_item() - if(!painting_implement) - return FALSE - var/x = text2num(params["x"]) - var/y = text2num(params["y"]) - painting_implement.set_painting_tool_color(grid[x][y]) + if(!palette_comp) + return + if(length(palette_comp.colors) >= palette_comp.max_colors) + return + var/paint_color = copytext(params["color"], 1, 8) + palette_comp.colors += paint_color + if("onRemovePaletteColor") . = TRUE - if("change_palette") - var/obj/item/painting_implement = user.get_active_held_item() - if(!painting_implement) - return FALSE - //I'd have this done inside the signal, but that'd have to be asynced, - //while we want the UI to be updated after the color is chosen, not before. - var/chosen_color = tgui_color_picker(user, "Pick new color", painting_implement, params["old_color"]) - if(!chosen_color || IS_DEAD_OR_INCAP(user) || !user.is_holding(painting_implement)) - return FALSE - SEND_SIGNAL(painting_implement, COMSIG_PAINTING_TOOL_PALETTE_COLOR_CHANGED, chosen_color, params["color_index"]) - . = TRUE - if("toggle_grid") - . = TRUE - show_grid = !show_grid + if(!palette_comp) + return + var/color_index = params["index"] + palette_comp.colors.Cut(color_index, color_index+1) if("finalize") . = TRUE finalize(user) if("patronage") . = TRUE patron(user) - if("zoom_in") - . = TRUE - LAZYINITLIST(zoom_by_observer) - if(!zoom_by_observer[user.key]) - zoom_by_observer[user.key] = 2 - else - zoom_by_observer[user.key] = min(zoom_by_observer[user.key] + 1, MAX_PAINTING_ZOOM_OUT) - if("zoom_out") - . = TRUE - LAZYINITLIST(zoom_by_observer) - if(!zoom_by_observer[user.key]) - zoom_by_observer[user.key] = MAX_PAINTING_ZOOM_OUT - 1 - else - zoom_by_observer[user.key] = max(zoom_by_observer[user.key] - 1, 1) -/obj/item/canvas/ui_close(mob/user) - . = ..() - LAZYREMOVE(zoom_by_observer, user.key) +/obj/item/canvas/proc/validate_color(_source, paint_color) + SIGNAL_HANDLER + paint_color = copytext(paint_color, 1, 8) + var/obj/item/implement = usr.get_active_held_item() + if(!implement || !((get_paint_tool_color(implement) == paint_color) || (paint_color in get_paint_tool_palette(implement)))) + return COLOR_IS_INVALID /obj/item/canvas/proc/finalize(mob/user) if(finalized || painting_metadata.loaded_from_json) @@ -401,9 +371,10 @@ /obj/item/canvas/proc/get_data_string() var/list/data = list() + var/list/grid = workspace.layers[1]["data"]["[SOUTH]"] for(var/y in 1 to height) for(var/x in 1 to width) - data += grid[x][y] + data += grid[y][x] return data.Join("") //Todo make this element ? @@ -419,9 +390,38 @@ else if(istype(painting_implement, /obj/item/pen)) var/obj/item/pen/pen = painting_implement return pen.colour + else if (istype(painting_implement, /obj/item/airlock_painter/decal)) + var/obj/item/airlock_painter/decal/painter = painting_implement + return painter.selected_custom_color else if(istype(painting_implement, /obj/item/soap) || istype(painting_implement, /obj/item/rag)) return canvas_color +/obj/item/canvas/proc/get_paint_tool_palette(obj/item/painting_implement) + if(!painting_implement) + return list() + var/datum/component/palette/palette_comp = painting_implement.GetComponent(/datum/component/palette) + if(!palette_comp) + var/implement_color = get_paint_tool_color(painting_implement) + return implement_color ? list(implement_color) : list() + return palette_comp.colors + +/obj/item/canvas/proc/get_paint_tool_palette_capacity(obj/item/painting_implement) + if(!painting_implement) + return + var/datum/component/palette/palette_comp = painting_implement.GetComponent(/datum/component/palette) + if(!palette_comp) + return get_paint_tool_color(painting_implement) ? 1 : 0 + return palette_comp.max_colors + +/obj/item/canvas/proc/can_change_paint_tool_color(obj/item/painting_implement) + if(!painting_implement) + return + if(istype(painting_metadata, /obj/item/paint_palette) || istype(painting_implement, /obj/item/airlock_painter/decal)) + return TRUE + if(istype(painting_implement, /obj/item/toy/crayon)) + var/obj/item/toy/crayon/crayon = painting_implement + return crayon.can_change_colour + /// Generates medium description /obj/item/canvas/proc/get_paint_tool_medium(obj/item/painting_implement) if(!painting_implement) @@ -432,7 +432,7 @@ return "Spraycan on canvas" else if(istype(painting_implement, /obj/item/toy/crayon)) return "Crayon on canvas" - else if(istype(painting_implement, /obj/item/pen)) + else if(istype(painting_implement, /obj/item/pen) || istype(painting_implement, /obj/item/airlock_painter/decal)) return "Ink on canvas" else if(istype(painting_implement, /obj/item/soap) || istype(painting_implement, /obj/item/rag)) return //These are just for cleaning, ignore them @@ -457,147 +457,6 @@ return FALSE -///The pixel to the right matches the previous color we're flooding over -#define CANVAS_FILL_R_MATCH (1<<0) -///The pixel to the left matches the previous color we're flooding over -#define CANVAS_FILL_L_MATCH (1<<1) - -//a macro for the stringized key for coordinates to check later -#define CANVAS_COORD(x, y) "[x]-[y]" -///queues a coordinate on the canvas for future cycles. -#define QUEUE_CANVAS_COORD(x, y, queue) \ - if(y && !queue[CANVAS_COORD(x, y)]) {\ - queue[CANVAS_COORD(x, y)] = list(x, y);\ - } - -/** - * A proc that adopts a span-based, 4-dir (correct me if I'm wrong) flood fill algorithm used - * by the bucked tool in the UI, to facilitate coloring larger portions of the canvas. - * If you have never used the bucket/flood tool on an image editor, I suggest you do it - * now so you know what I'm basically talking about. - * - * @ param x The point on the x axys where we start flooding our canvas. The arg is later used to store the current x - * @ param y The point on the y axys where we start flooding the canvas. The arg is later used to store the current y - * @ param new_color The new color that floods over the old one - */ -/obj/item/canvas/proc/canvas_fill(x, y, new_color) - var/prev_color = grid[x][y] - //If the colors are the same, don't do anything. - if(prev_color == new_color) - return FALSE - - //The queue for coordinates to the right of the current line - var/list/queue_right = list() - //Inversely for those to our left - var/list/queue_left = list() - //Whether we're currently checking the right or left queue. - var/go_right = TRUE - - //The current coordinates. The only reason this is outside the loop - //is because we first go up, then reset our vertical position to just below - //the starting position and go down from there. - var/list/coords = list(x, y) - - //Basically, the way it works is that each cycle we first go up, then down until we - //either reach the vertical borders of the raster or find a pixel that is not of the color we want - //to flood. As we do this, we try to queue a minimum of coordinates to our - //left and right to use for future cycles, moving horizontally in one direction until there are no - //more queued coordinates for that dir. Then we turn around and repeat - //until both left and right queues are completely empty. - while(coords) - //The current vertical line, the right and the left ones. - var/list/curr_line = grid[x] - var/list/right_line = x < width ? grid[x+1] : null - var/list/left_line = x > 1 ? grid[x-1] : null - //the queue we're on, depending on direction - var/list/curr_queue = go_right ? queue_right : queue_left - //Instead of queueing every point to our left and right that shares our prevous color, - //Causing a lot of empty cycles, we only queue an extremity of a vertical segment - //delimited by pixels of other colors or the y boundaries of the raster. To do this, - //we need to track where the segment (called line for simplicity) starts (or ends). - var/r_line_start - var/l_line_start - - //go up first (y = 1 is the upper border is) - while(y >= 1 && curr_line[y] == prev_color) - var/return_flags = canvas_scan_step(x, y, queue_left, queue_right, left_line, right_line, l_line_start, r_line_start, prev_color) - if(return_flags & CANVAS_FILL_R_MATCH) - r_line_start = y - else - r_line_start = null - if(return_flags & CANVAS_FILL_L_MATCH) - l_line_start = y - else - l_line_start = null - curr_line[y] = new_color - curr_queue -= CANVAS_COORD(x, y) //remove it from the queue if possible. - y-- - - //Any unqueued coordinate is queued and cleared before the next half of the cycle - QUEUE_CANVAS_COORD(x + 1, r_line_start, queue_right) - QUEUE_CANVAS_COORD(x - 1, l_line_start, queue_left) - r_line_start = l_line_start = null - - //set y to the pixel immediately below the starting y - y = coords[2] + 1 - - //then go down (y = height is the bottom border) - while(y <= height && curr_line[y] == prev_color) - var/return_flags = canvas_scan_step(x, y, queue_left, queue_right, left_line, right_line, l_line_start, r_line_start, prev_color) - if(!(return_flags & CANVAS_FILL_R_MATCH)) - r_line_start = null - else if(!r_line_start) - r_line_start = y - if(!(return_flags & CANVAS_FILL_L_MATCH)) - l_line_start = null - else if(!l_line_start) - l_line_start = y - curr_line[y] = new_color - curr_queue -= CANVAS_COORD(x, y) - y++ - - QUEUE_CANVAS_COORD(x + 1, r_line_start, queue_right) - QUEUE_CANVAS_COORD(x - 1, l_line_start, queue_left) - - //Pick the next set of coords from the queue (and change direction if necessary) - if(!length(curr_queue)) - var/list/other_queue = go_right ? queue_left : queue_right - coords = other_queue[other_queue[1]] - other_queue.Cut(1, 2) - go_right = !go_right - else - coords = curr_queue[curr_queue[1]] - curr_queue.Cut(1, 2) - - x = coords?[1] - y = coords?[2] - - return TRUE - -/** - * The step of canvas_fill() that scans the pixels to the immediate right and left of our coord and see if they need to be queue'd or not. - * Kept as a separate proc to reduce copypasted code. - */ -/proc/canvas_scan_step(x, y, list/queue_left, list/queue_right, list/left_line, list/right_line, left_pos, right_pos, prev_color) - if(left_line) - if(left_line[y] == prev_color) - . += CANVAS_FILL_L_MATCH - else - QUEUE_CANVAS_COORD(x - 1, left_pos, queue_left) - - if(!right_line) - return - - if(right_line[y] == prev_color) - . += CANVAS_FILL_R_MATCH - else - QUEUE_CANVAS_COORD(x + 1, right_pos, queue_right) - -#undef CANVAS_FILL_R_MATCH -#undef CANVAS_FILL_L_MATCH -#undef CANVAS_COORD -#undef QUEUE_CANVAS_COORD - /obj/item/canvas/nineteen_nineteen name = "canvas (19x19)" icon_state = "19x19" @@ -725,14 +584,14 @@ . = ..() SSpersistent_paintings.painting_frames -= src -/obj/structure/sign/painting/attackby(obj/item/I, mob/user, list/modifiers, list/attack_modifiers) - if(!current_canvas && istype(I, /obj/item/canvas)) - frame_canvas(user,I) - else if(current_canvas && current_canvas.painting_metadata.title == initial(current_canvas.painting_metadata.title) && istype(I,/obj/item/pen)) +/obj/structure/sign/painting/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!current_canvas && istype(tool, /obj/item/canvas)) + frame_canvas(user, tool) + return ITEM_INTERACT_SUCCESS + if(current_canvas && current_canvas.painting_metadata.title == initial(current_canvas.painting_metadata.title) && istype(tool, /obj/item/pen)) if(try_rename(user)) SStgui.update_uis(src) - else - return ..() + return ITEM_INTERACT_SUCCESS /obj/structure/sign/painting/atom_deconstruct(disassembled) var/turf/drop_turf = drop_location() @@ -849,7 +708,7 @@ if(!istype(new_canvas)) CRASH("Found painting size with no matching canvas type") new_canvas.painting_metadata = painting - new_canvas.fill_grid_from_icon(I) + fill_grid_from_icon(new_canvas.workspace.get_first_layer_pixel_data(), I) new_canvas.generated_icon = I new_canvas.icon_generated = TRUE new_canvas.finalized = TRUE @@ -907,10 +766,11 @@ SSpersistent_paintings.paintings += current_canvas.painting_metadata /obj/item/canvas/proc/fill_grid_from_icon(icon/I) + var/list/grid = workspace.layers[1]["data"]["[SOUTH]"] var/h = I.Height() + 1 for(var/x in 1 to width) for(var/y in 1 to height) - grid[x][y] = I.GetPixel(x,h-y) + grid[y][x] = I.GetPixel(x,h-y) /obj/item/wallframe/painting/large name = "large painting frame" @@ -1073,4 +933,3 @@ current_color = chosen_color #undef AVAILABLE_PALETTE_SPACE -#undef MAX_PAINTING_ZOOM_OUT diff --git a/code/modules/sprite_editing/flood_fill.dm b/code/modules/sprite_editing/flood_fill.dm new file mode 100644 index 00000000000..5e1b295dd32 --- /dev/null +++ b/code/modules/sprite_editing/flood_fill.dm @@ -0,0 +1,53 @@ +//a macro for the stringized key for coordinates to check later +#define CANVAS_COORD(x, y) "[x]:[y]" +#define IS_IN_BOUNDS(x, y) (x > 0 && x <= width && y > 0 && y <= height) +#define COLORS_ARE_EQUAL(a, b) ((a == b) || (endswith(a, "00") && endswith(b, "00"))) + +#define SHOULD_ADD_POINT(x, y) (!coord_cache[CANVAS_COORD(x, y)] && IS_IN_BOUNDS(x, y) && COLORS_ARE_EQUAL(grid[y][x], target_color)) + +#define ADD_POINT(x, y) \ + points += list(list((x) - 1, (y) - 1, target_color));\ + coord_cache[CANVAS_COORD(x, y)] = TRUE + +/proc/flood_fill(list/grid, x, y, width, height) + var/target_color = grid[y][x] + var/list/coord_cache = list() + var/list/points = list() + var/list/coord_queue = list(x, x, y, 1, x, x, y-1, -1) + var/span_start + var/column + var/span_end + var/row + var/row_shift + while(length(coord_queue)) + span_start = coord_queue[1] + column = span_start + span_end = coord_queue[2] + row = coord_queue[3] + row_shift = coord_queue[4] + coord_queue.Cut(1, 5) + if(SHOULD_ADD_POINT(column, row)) + while(SHOULD_ADD_POINT(column - 1, row)) + ADD_POINT(column - 1, row) + column-- + if(column < span_start) + coord_queue += list(column, span_start - 1, row - row_shift, -row_shift) + while(span_start <= span_end) + while(SHOULD_ADD_POINT(span_start, row)) + ADD_POINT(span_start, row) + span_start++ + if(span_start > column) + coord_queue += list(column, span_start - 1, row + row_shift, row_shift) + if(span_start - 1 > span_end) + coord_queue += list(span_end + 1, span_start - 1, row - row_shift, -row_shift) + span_start++ + while(span_start < span_end && !SHOULD_ADD_POINT(span_start, row)) + span_start++ + column = span_start + return points + +#undef ADD_POINT +#undef SHOULD_ADD_POINT +#undef COLORS_ARE_EQUAL +#undef IS_IN_BOUNDS +#undef CANVAS_COORD diff --git a/code/modules/sprite_editing/workspace.dm b/code/modules/sprite_editing/workspace.dm new file mode 100644 index 00000000000..9871c342fd1 --- /dev/null +++ b/code/modules/sprite_editing/workspace.dm @@ -0,0 +1,302 @@ +/datum/sprite_editor_workspace + var/width + var/height + var/dirs + var/backdrop + + var/color_mode = SPRITE_EDITOR_COLOR_MODE_RGBA + /// A bitfield specifying whether certain functions of the sprite editor should be performed if the corresponding ui actions are received - used to prevent href exploitation + var/config_flags = ALL + /// A bitfield specifying what tools we are allowed to use in the sprite editor + var/tool_flags = ALL + + var/list/layers + var/list/undo_stack = list() + var/list/undo_names = list() + var/list/redo_stack = list() + var/list/redo_names = list() + +/datum/sprite_editor_workspace/New( + width = 32, + height = 32, + dirs = 1, + backdrop = null, + color_mode = SPRITE_EDITOR_COLOR_MODE_RGBA, + config_flags = ALL, + tool_flags = ALL, + initial_layer_color = null) + . = ..() + src.width = width + src.height = height + src.dirs = dirs + src.color_mode = color_mode + src.config_flags = config_flags + src.tool_flags = tool_flags + src.backdrop = backdrop + layers = list(list("name" = "Background", visible = TRUE, "data" = create_layer_data(initial_layer_color))) + +/datum/sprite_editor_workspace/proc/copy(preserve_history = FALSE) + var/datum/sprite_editor_workspace/new_workspace = new(width, height, dirs, color_mode, config_flags, tool_flags) + new_workspace.layers = deep_copy_list_alt(layers) + if(preserve_history) + new_workspace.undo_names = undo_names.Copy() + new_workspace.undo_stack = deep_copy_list_alt(undo_stack) + new_workspace.redo_names = redo_names.Copy() + new_workspace.redo_stack = deep_copy_list_alt(redo_stack) + return new_workspace + +/datum/sprite_editor_workspace/proc/create_layer_data(color = "#00000000") + var/list/out = list() + for(var/i in 1 to dirs) + var/list/layer = list() + for(var/y in 1 to height) + var/list/row = list() + for(var/x in 1 to width) + row += color + layer += list(row) + out["[GLOB.alldirs_dmi_order[i]]"] = layer + return out + +/** + * Take a new transaction, perform it, and optionally add it to the undo history. + * Returns TRUE if the transaction was valid. + */ +/datum/sprite_editor_workspace/proc/new_transaction(transaction) + if(!can_transact(transaction)) + return + preprocess_new_transaction(transaction) + transact(transaction) + if(!(config_flags & SPRITE_EDITOR_ALLOW_UNDO)) + return TRUE + redo_stack.Cut() + redo_names.Cut() + undo_stack += list(transaction) + undo_names += transaction["name"] + return TRUE + +/datum/sprite_editor_workspace/proc/undo() + if(!(config_flags & SPRITE_EDITOR_ALLOW_UNDO)) + return + if(length(undo_stack)) + pop(undo_names) + var/transaction = pop(undo_stack) + reverse_transact(transaction) + redo_stack += list(transaction) + redo_names += transaction["name"] + +/datum/sprite_editor_workspace/proc/redo() + if(!(config_flags & SPRITE_EDITOR_ALLOW_UNDO)) + return + if(length(redo_stack)) + pop(redo_names) + var/transaction = pop(redo_stack) + transact(transaction) + undo_stack += list(transaction) + undo_names += transaction["name"] + +/datum/sprite_editor_workspace/proc/toggle_layer_visible(layer) + if(!(config_flags & SPRITE_EDITOR_ALLOW_LAYERS)) + return + if(!isnum(layer)) + return + if(layer < 1 || layer > length(layers)) + return + layers[layer]["visible"] = !layers[layer]["visible"] + +/datum/sprite_editor_workspace/proc/is_valid_color(color) + if(SEND_SIGNAL(src, COMSIG_SPRITE_EDITOR_VALIDATE_COLOR, color)) + return FALSE + var/list/rgb_color = split_color(color) + switch(color_mode) + if(SPRITE_EDITOR_COLOR_MODE_RGBA) + return TRUE + if(SPRITE_EDITOR_COLOR_MODE_RGB) + return rgb_color[4] == 255 + if(SPRITE_EDITOR_COLOR_MODE_GREYSCALE) + return rgb_color[1] == rgb_color[2] && rgb_color[2] == rgb_color[3] + else + return TRUE + +/datum/sprite_editor_workspace/proc/can_transact(list/transaction) + switch(transaction["type"]) + if("pencil") + return tool_flags & SPRITE_EDITOR_TOOL_PENCIL && is_valid_color(transaction["color"]) + if("eraser") + return tool_flags & SPRITE_EDITOR_TOOL_ERASER + if("bucket") + return tool_flags & SPRITE_EDITOR_TOOL_BUCKET && is_valid_color(transaction["color"]) + if("renameLayer", "moveLayerUp", "moveLayerDown", "flattenLayer", "addLayer", "deleteLayer") + return config_flags & SPRITE_EDITOR_ALLOW_LAYERS + else // Invalid transaction type, probably from href exploitation + return FALSE + +/datum/sprite_editor_workspace/proc/preprocess_new_transaction(list/transaction) + switch(transaction["type"]) + if("pencil", "eraser") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + point += affected_frame[y][x] + if("bucket") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/affected_frame = layers[layer]["data"][dir] + var/list/point = transaction["point"] + var/x = point[1]+1 + var/y = point[2]+1 + transaction["points"] = flood_fill(affected_frame, x, y, width, height) + transaction -= "point" + if("flattenLayer") + var/layer = transaction["layer"] + var/list/top_layer = layers[layer] + var/list/bottom_layer = layers[layer-1] + transaction["oldTop"] = top_layer + transaction["oldBottom"] = deep_copy_list(bottom_layer) + if("deleteLayer") + var/layer = transaction["layer"] + var/list/old_layer = layers[layer] + transaction["oldLayer"] = old_layer + +/datum/sprite_editor_workspace/proc/transact(list/transaction) + switch(transaction["type"]) + if("pencil", "bucket") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/color = transaction["color"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/list/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + affected_frame[y][x] = blend_color(affected_frame[y][x], color) + if("eraser") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/list/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + affected_frame[y][x] = "#00000000" + if("renameLayer") + var/layer = transaction["layer"] + var/new_name = transaction["newName"] + layers[layer]["name"] = new_name + if("moveLayerUp") + var/layer = transaction["layer"] + layers.Swap(layer, layer+1) + if("moveLayerDown") + var/layer = transaction["layer"] + layers.Swap(layer, layer-1) + if("flattenLayer") + var/layer = transaction["layer"] + var/list/top_layer = layers[layer] + var/list/bottom_layer = layers[layer-1] + for(var/dir in 1 to dirs) + for(var/y in 1 to height) + for(var/x in 1 to width) + bottom_layer["[dir]"][y][x] = blend_color(bottom_layer["[dir]"][y][x], top_layer["[dir]"][y][x]) + layers.Cut(layer, layer+1) + if("addLayer") + layers += list(list("name" = "New Layer", "visible" = TRUE, "data" = create_layer_data())) + if("deleteLayer") + var/layer = transaction["layer"] + layers.Cut(layer, layer+1) + +/datum/sprite_editor_workspace/proc/reverse_transact(list/transaction) + switch(transaction["type"]) + if("pencil", "eraser", "bucket") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/list/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + affected_frame[y][x] = point[3] + if("renameLayer") + var/layer = transaction["layer"] + var/old_name = transaction["oldName"] + layers[layer]["name"] = old_name + if("moveLayerUp") + var/layer = transaction["layer"] + layers.Swap(layer, layer+1) + if("moveLayerDown") + var/layer = transaction["layer"] + layers.Swap(layer, layer-1) + if("flattenLayer") + var/layer = transaction["layer"] + var/top_layer = transaction["oldTop"] + var/bottom_layer = transaction["oldBottom"] + var/bottom_layer_index = layer-1 + var/old_visibility = layers[bottom_layer_index]["visible"] + layers[bottom_layer_index] = bottom_layer + layers[bottom_layer_index]["visible"] = old_visibility + layers.Insert(layer, top_layer) + if("addLayer") + pop(layers) + if("deleteLayer") + var/layer = transaction["layer"] + var/old_layer = transaction["oldLayer"] + layers.Insert(layer, old_layer) + +/datum/sprite_editor_workspace/proc/sprite_editor_ui_data() + return list( + "colorMode" = color_mode, + "toolFlags" = tool_flags, + "undoStack" = undo_names, + "redoStack" = redo_names, + "sprite" = list( + "width" = width, + "height" = height, + "dirs" = dirs, + "backdrop" = backdrop, + "layers" = layers, + ) + ) + +/// Get a reference to the pixel data for the first layer of the given dir +/datum/sprite_editor_workspace/proc/get_first_layer_pixel_data(dir = SOUTH) + return layers[1]["data"]["[dir]"] + +/datum/sprite_editor_workspace/proc/to_icon() + var/metadata = json_encode(list( + "width" = width, + "height" = height, + "states" = list(list( + "name" = "", + "dirs" = dirs, + )), + )) + var/list/ideal_dims = calculate_optimal_icon_grid_dimensions(width, height, dirs) + var/grid_width = ideal_dims[1] + var/grid_height = ideal_dims[2] + var/file_width = width * grid_width + var/file_height = height * grid_height + var/layer_count = length(layers) + var/temp_file_prefix = copytext(REF(src), 2, -1) + for(var/i in 1 to layer_count) + var/list/layer_frames = list() + for(var/dir_index in 1 to dirs) + layer_frames += list(layers[i]["data"]["[GLOB.alldirs_dmi_order[dir_index]]"]) + var/pixels = reorder_pixels(width, height, grid_width, grid_height, layer_frames) + var/temp_path = "tmp/[temp_file_prefix]_layer[i].dmi" + var/result = rustg_dmi_create_png(temp_path, "[file_width]", "[file_height]", pixels) + if(result) + stack_trace(result) + return TRUE + result = rustg_dmi_inject_metadata(temp_path, metadata) + if(result) + stack_trace(result) + return TRUE + var/datum/universal_icon/out_icon = uni_icon("tmp/[temp_file_prefix]_layer1.dmi", "") + for(var/i in 2 to layer_count) + out_icon.blend_icon(uni_icon("tmp/[temp_file_prefix]_layer[i].dmi", ""), ICON_OVERLAY) + var/icon/final_icon = out_icon.to_icon() + for(var/i in 1 to layer_count) + fdel("tmp/[temp_file_prefix]_layer[i].dmi") + return final_icon diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm index 89d25e98516..60b45bf0eb4 100644 --- a/code/modules/tgui/tgui.dm +++ b/code/modules/tgui/tgui.dm @@ -287,6 +287,7 @@ ), ) var/data = custom_data || with_data && src_object.ui_data(user) + SEND_SIGNAL(src_object, COMSIG_UI_DATA, user, data) if(data) json_data["data"] = data var/static_data = with_static_data && src_object.ui_static_data(user) diff --git a/tgstation.dme b/tgstation.dme index 88350149c0e..6114fda8931 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -238,6 +238,7 @@ #include "code\__DEFINES\species_clothing_paths.dm" #include "code\__DEFINES\speech_channels.dm" #include "code\__DEFINES\sprite_accessories.dm" +#include "code\__DEFINES\sprite_editor.dm" #include "code\__DEFINES\stack.dm" #include "code\__DEFINES\stack_trace.dm" #include "code\__DEFINES\stat.dm" @@ -6389,6 +6390,8 @@ #include "code\modules\spells\spell_types\touch\flesh_to_stone.dm" #include "code\modules\spells\spell_types\touch\scream_for_me.dm" #include "code\modules\spells\spell_types\touch\smite.dm" +#include "code\modules\sprite_editing\flood_fill.dm" +#include "code\modules\sprite_editing\workspace.dm" #include "code\modules\station_goals\bsa.dm" #include "code\modules\station_goals\dna_vault.dm" #include "code\modules\station_goals\generate_goals.dm" diff --git a/tgui/bun.lock b/tgui/bun.lock index 7308d97a26b..6dd4971b730 100644 --- a/tgui/bun.lock +++ b/tgui/bun.lock @@ -40,6 +40,7 @@ "name": "tgui", "version": "6.0.0", "dependencies": { + "color-blend": "4.0.0", "common": "workspace:*", "dateformat": "^5.0.3", "dompurify": "^3.2.5", @@ -413,6 +414,8 @@ "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color-blend": ["color-blend@4.0.0", "", {}, "sha512-fYODTHhI/NG+B5GnzvuL3kiFrK/UnkUezWFTgEPBTY5V+kpyfAn95Vn9sJeeCX6omrCOdxnqCL3CvH+6sXtIbw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/tgui/packages/tgui/assets/transparency_checkerboard.svg b/tgui/packages/tgui/assets/transparency_checkerboard.svg new file mode 100644 index 00000000000..ecfd3a467d5 --- /dev/null +++ b/tgui/packages/tgui/assets/transparency_checkerboard.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tgui/packages/tgui/interfaces/Canvas.tsx b/tgui/packages/tgui/interfaces/Canvas.tsx index dadd0cc202a..5ce942e8163 100644 --- a/tgui/packages/tgui/interfaces/Canvas.tsx +++ b/tgui/packages/tgui/interfaces/Canvas.tsx @@ -1,471 +1,342 @@ -import { Component, createRef, type RefObject, useState } from 'react'; -import { Color } from 'tgui-core/color'; -import { - Box, - Button, - Flex, - Icon, - KeyListener, - Tooltip, -} from 'tgui-core/components'; -import { KEY_F, KEY_G } from 'tgui-core/keycodes'; +import { type PropsWithChildren, type ReactNode, useState } from 'react'; +import { Box, Button, Stack } from 'tgui-core/components'; +import { clamp } from 'tgui-core/math'; +import type { BooleanLike } from 'tgui-core/react'; import { decodeHtmlEntities } from 'tgui-core/string'; import { useBackend } from '../backend'; import { Window } from '../layouts'; +import { SpriteEditor } from './common/SpriteEditor'; +import { + AdvancedCanvas, + type AdvancedCanvasPropsBase, +} from './common/SpriteEditor/Components/AdvancedCanvas'; +import { hasServerColorData } from './common/SpriteEditor/helpers'; +import { Dir, type SpriteEditorData } from './common/SpriteEditor/Types/types'; -const LEFT_CLICK = 0; - -type PaintCanvasProps = Partial<{ - onCanvasModifiedHandler: (data: PointData[]) => void; - onCanvasDropperHandler: (x: number, y: number) => void; - onCanvasFillHandler: (x: number, y: number) => void; - value: string[][]; - width: number; - height: number; - imageWidth: number; - imageHeight: number; - editable: boolean; - drawing_color: string | null; - has_palette: boolean; - show_grid: boolean; - zoom: number; - fillmode: boolean; -}>; - -type PointData = { - x: number; - y: number; -}; - -const fromDM = (data: string[][]) => { - return data.map((inner) => inner.map((v) => Color.fromHex(v))); -}; - -const toMassPaintFormat = (data: PointData[]) => { - return data.map((p) => ({ x: p.x + 1, y: p.y + 1 })); // 1-based index dm side -}; - -const checkPointCoords = (x: number, y: number, p: PointData) => { - return p.x === x && p.y === y; -}; - -class PaintCanvas extends Component { - canvasRef: RefObject; - baseImageData: Color[][]; - is_grid_shown: boolean; - modifiedElements: PointData[]; - onCanvasModified: (data: PointData[]) => void; - onCanvasDropper: (x: number, y: number) => void; - onCanvasFill: (x: number, y: number) => void; - drawing: boolean; - drawing_color: string; - zoom: number; - - constructor(props) { - super(props); - this.canvasRef = createRef(); - this.modifiedElements = []; - this.is_grid_shown = false; - this.drawing = false; - this.zoom = props.zoom; - - this.onCanvasModified = props.onCanvasModifiedHandler; - this.onCanvasDropper = props.onCanvasDropperHandler; - this.onCanvasFill = props.onCanvasFillHandler; - - this.handleStartDrawing = this.handleStartDrawing.bind(this); - this.handleDrawing = this.handleDrawing.bind(this); - this.handleEndDrawing = this.handleEndDrawing.bind(this); - this.handleDropper = this.handleDropper.bind(this); - } - - componentDidMount() { - this.prepareCanvas(); - this.syncCanvas(); - } - - componentDidUpdate() { - if (this.zoom !== this.props.zoom) { - this.prepareCanvas(); - this.syncCanvas(); - } else if ( - (this.props.value !== undefined && - JSON.stringify(this.baseImageData) !== - JSON.stringify(fromDM(this.props.value))) || - this.is_grid_shown !== this.props.show_grid - ) { - this.syncCanvas(); - } - } - - prepareCanvas() { - this.zoom = this.props.zoom as number; - const canvas = this.canvasRef.current!; - const ctx = canvas.getContext('2d'); - const width = this.props.width || canvas.width || 360; - const height = this.props.height || canvas.height || 360; - const x_resolution = this.props.imageWidth || 36; - const y_resolution = this.props.imageHeight || 36; - const x_scale = Math.round(width / x_resolution); - const y_scale = Math.round(height / y_resolution); - ctx?.setTransform(1, 0, 0, 1, 0, 0); - ctx?.scale(x_scale, y_scale); // This clears the canvas. - } - - syncCanvas() { - if (this.props.value === undefined) { - return; - } - this.baseImageData = fromDM(this.props.value); - this.is_grid_shown = !!this.props.show_grid; - this.modifiedElements = []; - - const canvas = this.canvasRef.current!; - const ctx = canvas.getContext('2d')!; - for (let x = 0; x < this.baseImageData.length; x++) { - const element = this.baseImageData[x]; - for (let y = 0; y < element.length; y++) { - const color = element[y]; - ctx.fillStyle = color.toString(); - ctx.fillRect(x, y, 1, 1); - if (this.is_grid_shown) { - ctx.strokeStyle = '#888888'; - ctx.lineWidth = 0.05; - ctx.strokeRect(x, y, 1, 1); - } - } - } - } - - eventToCoords(event: MouseEvent) { - const canvas = this.canvasRef.current!; - const width = this.props.width || canvas.width || 360; - const height = this.props.height || canvas.height || 360; - const x_resolution = this.props.imageWidth || 36; - const y_resolution = this.props.imageHeight || 36; - const x_scale = Math.round(width / x_resolution); - const y_scale = Math.round(height / y_resolution); - - const rect = canvas.getBoundingClientRect(); - const x = Math.floor((event.clientX - rect.left) / x_scale); - const y = Math.floor((event.clientY - rect.top) / y_scale); - return { x, y }; - } - - handleStartDrawing(event: MouseEvent) { - if ( - !this.props.editable || - this.props.drawing_color === undefined || - this.props.drawing_color === null || - event.button !== LEFT_CLICK - ) { - return; - } - const coords = this.eventToCoords(event); - if (this.props.fillmode) { - this.onCanvasFill(coords.x + 1, coords.y + 1); // 1-based index dm side - return; - } - this.modifiedElements = []; - this.drawing = true; - this.drawing_color = this.props.drawing_color; - this.drawPoint(coords.x, coords.y, this.drawing_color); - } - - drawPoint(x: number, y: number, color: any) { - // check if modifiedElements already contains a point with same x and y - if (this.modifiedElements.some(checkPointCoords.bind(null, x, y))) { - return; - } - const p: PointData = { x, y }; - this.modifiedElements.push(p); - const canvas = this.canvasRef.current!; - const ctx = canvas.getContext('2d')!; - ctx.fillStyle = color; - ctx.fillRect(x, y, 1, 1); - if (this.is_grid_shown) { - ctx.strokeStyle = '#888888'; - ctx.lineWidth = 0.05; - ctx.strokeRect(x, y, 1, 1); - } - } - - handleDrawing(event: MouseEvent) { - if (!this.drawing) { - return; - } - const coords = this.eventToCoords(event); - this.drawPoint(coords.x, coords.y, this.drawing_color); - } - - handleEndDrawing(event: MouseEvent) { - if (!this.drawing) { - return; - } - this.drawing = false; - if (this.onCanvasModified !== undefined) { - this.onCanvasModified(this.modifiedElements); - } - } - - handleDropper(event: MouseEvent) { - event.preventDefault(); - if (!this.props.has_palette) { - return; - } - const coords = this.eventToCoords(event); - this.onCanvasDropper(coords.x + 1, coords.y + 1); // 1-based index dm side - } - - render() { - const { - value, - width = 300, - height = 300, - imageWidth = 36, - imageHeight = 36, - ...rest - } = this.props; - return ( - - Canvas failed to render. - - ); - } -} - -const getImageSize = (value) => { - const width = value.length; - const height = width !== 0 ? value[0].length : 0; - return [width, height]; -}; - -type PaletteColor = { - color: string; - is_selected: boolean; +type CanvasMetadata = { + title: string; + author: string; + patron?: string; + medium: string; + date?: string; }; type CanvasData = { - grid: string[][]; - px_per_unit: number; - finalized: boolean; - name: string; - editable: boolean; - paint_tool_color: string | null; - paint_tool_palette: PaletteColor[] | null; - author: string | null; - medium: string | null; - patron: string | null; - date: string | null; - show_plaque: boolean; - show_grid: boolean; - zoom: number; - max_zoom: number; + metadata: CanvasMetadata; + editorData: SpriteEditorData; + pixelsPerUnit: number; + finalized: BooleanLike; + editable: BooleanLike; + allowColorPicker: BooleanLike; + showPlaque: BooleanLike; }; -export const Canvas = (props) => { - const { act, data } = useBackend(); - const [width, height] = getImageSize(data.grid); - const scaled_width = width * data.px_per_unit * data.zoom; - const scaled_height = height * data.px_per_unit * data.zoom; - const average_plaque_height = 90; - const palette_height = 38; - const griddy = !!data.show_grid && !!data.editable && !!data.paint_tool_color; - const [fillmode, setFillMode] = useState(false); +type ZoomProps = { + zoom: number; + setZoom: React.Dispatch>; + pixelsPerUnit: number; +}; + +type CanvasCommonProps = ZoomProps & { + width: number; + height: number; +}; + +const ZoomButtons = ({ zoom, setZoom, pixelsPerUnit }: ZoomProps) => ( + + + + + ))} + + + } + > +