diff --git a/backend/requirements.txt b/backend/requirements.txt index 386f674..8553437 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,8 +14,9 @@ pydantic-settings==2.1.0 email-validator==2.1.0 opencv-python-headless==4.9.0.80 scikit-image==0.22.0 +# rembg for background removal - use onnxruntime 1.15.1 to avoid executable stack issue +onnxruntime==1.15.1 rembg==2.0.50 -onnxruntime==1.16.3 # SAM (Segment Anything) for smart object selection - runs locally, no API needed torch==2.1.2 torchvision==0.16.2 diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css index 5060016..dedbe30 100644 --- a/frontend/src/css/layout.css +++ b/frontend/src/css/layout.css @@ -518,6 +518,51 @@ IMPORTANT: any new icon should also must be added on /service-worker.js + its ve bottom:0; width: 20px; } +/* Layer context menu */ +.layer_context_menu{ + display: none; + position: fixed; + z-index: 1000; + background: var(--section-background-color); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + min-width: 150px; +} +.layer_context_menu ul{ + list-style: none; + margin: 0; + padding: 5px 0; +} +.layer_context_menu li{ + padding: 6px 15px; + cursor: pointer; + color: var(--text-color); + font-size: 13px; +} +.layer_context_menu li:hover{ + background: var(--background-color-active); + color: var(--text-color-active); +} +.layer_context_menu li.separator{ + height: 1px; + background: var(--border-color); + margin: 5px 10px; + padding: 0; + cursor: default; +} +.layer_context_menu li.separator:hover{ + background: var(--border-color); +} +.layer_scale{ + display:inline-block; + padding:1px 8px; + margin-right: 5px; + border:1px solid #444; + border-color: var(--border-color); + color: var(--text-color); + cursor:pointer; +} .sidebar_right .label{ display: inline-block; } diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js index eb76ec6..4ede307 100644 --- a/frontend/src/js/config-menu.js +++ b/frontend/src/js/config-menu.js @@ -326,6 +326,11 @@ const menuDefinition = [ name: 'Convert to Raster', target: 'layer/raster.raster' }, + { + name: 'Scale Layer', + ellipsis: true, + target: 'layer/scale.scale' + }, { divider: true }, @@ -480,6 +485,11 @@ const menuDefinition = [ ellipsis: true, target: 'effects/black_and_white.black_and_white' }, + { + name: 'Greyscale', + ellipsis: true, + target: 'effects/greyscale.greyscale' + }, { name: 'Borders', ellipsis: true, diff --git a/frontend/src/js/core/gui/gui-layers.js b/frontend/src/js/core/gui/gui-layers.js index 87fb052..b00cdf2 100644 --- a/frontend/src/js/core/gui/gui-layers.js +++ b/frontend/src/js/core/gui/gui-layers.js @@ -11,17 +11,22 @@ import Layer_rename_class from './../../modules/layer/rename.js'; import Effects_browser_class from './../../modules/effects/browser.js'; import Layer_duplicate_class from './../../modules/layer/duplicate.js'; import Layer_raster_class from './../../modules/layer/raster.js'; +import Layer_scale_class from './../../modules/layer/scale.js'; +import Layer_merge_class from './../../modules/layer/merge.js'; +import Layer_flatten_class from './../../modules/layer/flatten.js'; import Tools_translate_class from './../../modules/tools/translate.js'; var template = ` +
+
`; /** @@ -36,7 +41,11 @@ class GUI_layers_class { this.Effects_browser = new Effects_browser_class(); this.Layer_duplicate = new Layer_duplicate_class(); this.Layer_raster = new Layer_raster_class(); + this.Layer_scale = new Layer_scale_class(); + this.Layer_merge = new Layer_merge_class(); + this.Layer_flatten = new Layer_flatten_class(); this.Tools_translate = new Tools_translate_class(); + this.contextMenuLayerId = null; } render_main_layers() { @@ -67,6 +76,10 @@ class GUI_layers_class { //raster _this.Layer_raster.raster(); } + else if (target.id == 'layer_scale') { + //scale + _this.Layer_scale.scale(); + } else if (target.id == 'layer_up') { //move layer up app.State.do_action( @@ -127,6 +140,134 @@ class GUI_layers_class { } }); + // Right-click context menu for layers + document.getElementById('layers_base').addEventListener('contextmenu', function (event) { + var target = event.target; + + // Check if right-clicked on a layer item + if (target.id == 'layer_name' || target.closest('.item')) { + event.preventDefault(); + + var layerId = target.dataset.id || target.closest('.item').querySelector('[data-id]').dataset.id; + _this.showContextMenu(event.clientX, event.clientY, layerId); + } + }); + + // Hide context menu when clicking elsewhere + document.addEventListener('click', function (event) { + _this.hideContextMenu(); + }); + + } + + /** + * Show context menu for layer + */ + showContextMenu(x, y, layerId) { + var _this = this; + this.contextMenuLayerId = layerId; + + // Select the layer first + if (layerId != config.layer.id) { + app.State.do_action( + new app.Actions.Select_layer_action(layerId) + ); + } + + var menuItems = [ + { label: 'Rename', action: 'rename' }, + { label: 'Duplicate', action: 'duplicate' }, + { label: 'Delete', action: 'delete' }, + { label: '---' }, + { label: 'Move Up', action: 'move_up' }, + { label: 'Move Down', action: 'move_down' }, + { label: '---' }, + { label: 'Scale Layer...', action: 'scale' }, + { label: 'Convert to Raster', action: 'raster' }, + { label: '---' }, + { label: 'Merge Down', action: 'merge' }, + { label: 'Flatten All', action: 'flatten' }, + ]; + + var menu = document.getElementById('layer_context_menu'); + var html = ''; + menu.innerHTML = html; + menu.style.display = 'block'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + // Add click handlers to menu items + menu.querySelectorAll('li[data-action]').forEach(function(item) { + item.addEventListener('click', function(e) { + e.stopPropagation(); + _this.handleContextMenuAction(this.dataset.action); + _this.hideContextMenu(); + }); + }); + } + + /** + * Hide context menu + */ + hideContextMenu() { + var menu = document.getElementById('layer_context_menu'); + if (menu) { + menu.style.display = 'none'; + } + } + + /** + * Handle context menu action + */ + handleContextMenuAction(action) { + var layerId = this.contextMenuLayerId; + + switch (action) { + case 'rename': + this.Layer_rename.rename(layerId); + break; + case 'duplicate': + this.Layer_duplicate.duplicate(); + break; + case 'delete': + app.State.do_action( + new app.Actions.Delete_layer_action(layerId) + ); + break; + case 'move_up': + app.State.do_action( + new app.Actions.Reorder_layer_action(layerId, 1) + ); + break; + case 'move_down': + app.State.do_action( + new app.Actions.Reorder_layer_action(layerId, -1) + ); + break; + case 'scale': + this.Layer_scale.scale(); + break; + case 'raster': + this.Layer_raster.raster(); + break; + case 'merge': + this.Layer_merge.merge(); + break; + case 'flatten': + this.Layer_flatten.flatten(); + break; + } } /** diff --git a/frontend/src/js/modules/effects/greyscale.js b/frontend/src/js/modules/effects/greyscale.js new file mode 100644 index 0000000..56ac996 --- /dev/null +++ b/frontend/src/js/modules/effects/greyscale.js @@ -0,0 +1,157 @@ +/** + * Greyscale Effect - Convert layer to greyscale (desaturate) + * Useful for CNC carving, depth maps, etc. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Effects_greyscale_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + greyscale() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster first.'); + return; + } + + var settings = { + title: 'Convert to Greyscale', + preview: true, + effects: true, + params: [ + { + name: "method", + title: "Method:", + values: ["Luminosity (Rec. 709)", "Average", "Lightness", "Red Channel", "Green Channel", "Blue Channel"], + value: "Luminosity (Rec. 709)" + }, + {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]}, + {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]}, + {name: "invert", title: "Invert:", value: false}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_greyscale(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.apply_greyscale(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + apply_greyscale(imageData, params) { + var data = imageData.data; + var method = params.method; + var contrast = (params.contrast || 0) / 100; + var brightness = (params.brightness || 0) * 2.55; // Convert to 0-255 range + var invert = params.invert || false; + + // Contrast factor + var factor = (1 + contrast); + + for (var i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) continue; // Skip transparent pixels + + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + var grey; + + // Calculate greyscale value based on method + switch (method) { + case "Luminosity (Rec. 709)": + // Standard HDTV (Rec. 709) - most accurate perceptual + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + break; + case "Average": + grey = (r + g + b) / 3; + break; + case "Lightness": + // HSL lightness + grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + break; + case "Red Channel": + grey = r; + break; + case "Green Channel": + grey = g; + break; + case "Blue Channel": + grey = b; + break; + default: + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + // Apply brightness + grey += brightness; + + // Apply contrast (around middle grey) + grey = ((grey - 128) * factor) + 128; + + // Invert if requested + if (invert) { + grey = 255 - grey; + } + + // Clamp to valid range + grey = Math.max(0, Math.min(255, Math.round(grey))); + + data[i] = grey; + data[i + 1] = grey; + data[i + 2] = grey; + } + + return imageData; + } + + demo(canvas_id, canvas_thumb) { + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + method: "Luminosity (Rec. 709)", + contrast: 0, + brightness: 0, + invert: false + }; + var data = this.apply_greyscale(img, params); + ctx.putImageData(data, 0, 0); + } +} + +export default Effects_greyscale_class; diff --git a/frontend/src/js/modules/layer/scale.js b/frontend/src/js/modules/layer/scale.js new file mode 100644 index 0000000..34a8b50 --- /dev/null +++ b/frontend/src/js/modules/layer/scale.js @@ -0,0 +1,174 @@ +/** + * Layer Scale module - Scale individual layers (like GIMP's Scale Layer) + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Pica from './../../../../node_modules/pica/dist/pica.js'; + +var instance = null; + +class Layer_scale_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + this.pica = Pica(); + } + + scale() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('Please convert layer to raster first (Layer > Raster)'); + return; + } + + var currentWidth = config.layer.width; + var currentHeight = config.layer.height; + var aspectRatio = currentWidth / currentHeight; + + var settings = { + title: 'Scale Layer', + params: [ + {name: "width", title: "Width:", value: currentWidth, placeholder: currentWidth}, + {name: "height", title: "Height:", value: currentHeight, placeholder: currentHeight}, + {name: "width_percent", title: "Width %:", value: 100, placeholder: 100}, + {name: "height_percent", title: "Height %:", value: 100, placeholder: 100}, + {name: "maintain_aspect", title: "Maintain Aspect Ratio:", value: true}, + {name: "interpolation", title: "Interpolation:", values: ["Lanczos (Best)", "Bilinear", "Nearest"]}, + ], + on_change: function(params) { + // Auto-adjust to maintain aspect ratio if enabled + if (params.maintain_aspect) { + var widthInput = document.getElementById("pop_data_width"); + var heightInput = document.getElementById("pop_data_height"); + var widthPercentInput = document.getElementById("pop_data_width_percent"); + var heightPercentInput = document.getElementById("pop_data_height_percent"); + + // This is simplified - in practice you'd track which field changed + } + }, + on_finish: function (params) { + _this.do_scale(params); + }, + }; + this.POP.show(settings); + } + + async do_scale(params) { + var layer = config.layer; + + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var currentWidth = layer.width; + var currentHeight = layer.height; + + // Calculate new dimensions + var newWidth, newHeight; + + if (params.width && params.width != currentWidth) { + newWidth = parseInt(params.width); + if (params.maintain_aspect) { + newHeight = Math.round(newWidth / (currentWidth / currentHeight)); + } else { + newHeight = params.height ? parseInt(params.height) : currentHeight; + } + } else if (params.height && params.height != currentHeight) { + newHeight = parseInt(params.height); + if (params.maintain_aspect) { + newWidth = Math.round(newHeight * (currentWidth / currentHeight)); + } else { + newWidth = params.width ? parseInt(params.width) : currentWidth; + } + } else if (params.width_percent && params.width_percent != 100) { + newWidth = Math.round(currentWidth * params.width_percent / 100); + if (params.maintain_aspect) { + newHeight = Math.round(currentHeight * params.width_percent / 100); + } else { + newHeight = Math.round(currentHeight * (params.height_percent || 100) / 100); + } + } else if (params.height_percent && params.height_percent != 100) { + newHeight = Math.round(currentHeight * params.height_percent / 100); + if (params.maintain_aspect) { + newWidth = Math.round(currentWidth * params.height_percent / 100); + } else { + newWidth = Math.round(currentWidth * (params.width_percent || 100) / 100); + } + } else { + newWidth = parseInt(params.width) || currentWidth; + newHeight = parseInt(params.height) || currentHeight; + } + + if (newWidth <= 0 || newHeight <= 0) { + alertify.error('Invalid dimensions'); + return; + } + + if (newWidth === currentWidth && newHeight === currentHeight) { + alertify.warning('No change in size'); + return; + } + + // Get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false); + var ctx = canvas.getContext("2d"); + + // Create destination canvas + var destCanvas = document.createElement('canvas'); + destCanvas.width = newWidth; + destCanvas.height = newHeight; + var destCtx = destCanvas.getContext('2d'); + + // Perform resize based on interpolation method + if (params.interpolation === "Lanczos (Best)") { + await this.pica.resize(canvas, destCanvas, { alpha: true }); + } else if (params.interpolation === "Bilinear") { + destCtx.imageSmoothingEnabled = true; + destCtx.imageSmoothingQuality = 'high'; + destCtx.drawImage(canvas, 0, 0, newWidth, newHeight); + } else { + // Nearest neighbor + destCtx.imageSmoothingEnabled = false; + destCtx.drawImage(canvas, 0, 0, newWidth, newHeight); + } + + // Calculate new position (keep center in same place) + var centerX = layer.x + layer.width / 2; + var centerY = layer.y + layer.height / 2; + var newX = Math.round(centerX - newWidth / 2); + var newY = Math.round(centerY - newHeight / 2); + + // Apply the changes + app.State.do_action( + new app.Actions.Bundle_action('scale_layer', 'Scale Layer', [ + new app.Actions.Update_layer_image_action(destCanvas, layer.id), + new app.Actions.Update_layer_action(layer.id, { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + width_original: newWidth, + height_original: newHeight + }) + ]) + ); + + alertify.success('Layer scaled to ' + newWidth + 'x' + newHeight); + } +} + +export default Layer_scale_class;