Implement some texture adjustment tools

This commit is contained in:
JannisX11 2022-01-30 00:23:44 +01:00
parent ea5b30183d
commit 6cc59dd058
7 changed files with 594 additions and 46 deletions

View File

@ -4,9 +4,13 @@
position: absolute;
width: 100%;
height: 100%;
z-index: 19;
background-color: transparent;
opacity: 0;
}
#blackout.darken {
background-color: var(--color-dark);
opacity: 0.6;
z-index: 19;
}
.dialog_handle {
position: relative;
@ -1326,42 +1330,135 @@
}
/* Edit History */
#edit_history_list ul {
margin-left: 14px;
}
#edit_history_list ul li {
height: 30px;
padding: 2px 6px;
cursor: pointer;
border: 2px solid transparent;
display: flex;
justify-content: space-between;
}
#edit_history_list ul li.current {
border-color: var(--color-accent);
}
#edit_history_list ul li:hover {
color: var(--color-light);
}
#edit_history_list ul li.selected {
background-color: var(--color-accent);
color: var(--color-accent_text);
position: relative;
}
#edit_history_list ul li.selected:not(:first-of-type)::before {
content: "\f04b";
font-family: 'Font Awesome 5 Free';
font-weight: 900;
font-size: 14px;
color: var(--color-accent);
display: block;
position: absolute;
top: -11px;
left: -16px;
}
#edit_history_list .edit_history_time {
color: var(--color-subtle_text);
}
#edit_history_list ul li.selected .edit_history_time {
color: inherit;
}
#edit_history_list ul {
margin-left: 14px;
}
#edit_history_list ul li {
height: 30px;
padding: 2px 6px;
cursor: pointer;
border: 2px solid transparent;
display: flex;
justify-content: space-between;
}
#edit_history_list ul li.current {
border-color: var(--color-accent);
}
#edit_history_list ul li:hover {
color: var(--color-light);
}
#edit_history_list ul li.selected {
background-color: var(--color-accent);
color: var(--color-accent_text);
position: relative;
}
#edit_history_list ul li.selected:not(:first-of-type)::before {
content: "\f04b";
font-family: 'Font Awesome 5 Free';
font-weight: 900;
font-size: 14px;
color: var(--color-accent);
display: block;
position: absolute;
top: -11px;
left: -16px;
}
#edit_history_list .edit_history_time {
color: var(--color-subtle_text);
}
#edit_history_list ul li.selected .edit_history_time {
color: inherit;
}
/* Texture Edit */
div.texture_adjust_previews {
overflow: auto;
max-height: 416px;
display: flex;
margin-bottom: 10px;
width: calc(100% - 38px);
float: left;
}
div.texture_adjust_previews.folded {
max-height: 30px;
overflow: hidden;
}
div.texture_adjust_previews img {
max-height: 400px;
width: 400px;
margin: auto;
}
dialog .slider_input_combo {
clear: both;
}
dialog#adjust_curves .dialog_content {
margin-top: 6px;
margin-bottom: 10px;
}
#contrast_graph {
background-color: var(--color-back);
border: 1px solid var(--color-border);
height: 412px;
width: 412px;
position: relative;
overflow: hidden;
cursor: crosshair;
}
.contrast_graph_selector {
clear: both;
display: flex;
}
.contrast_graph_selector div {
flex-basis: 0;
flex-grow: 1;
padding-top: 2px;
text-align: center;
cursor: pointer;
}
.contrast_graph_selector div:hover {
color: var(--color-light);
}
.contrast_graph_selector div.selected {
background-color: var(--color-accent);
color: var(--color-accent_text);
}
#contrast_graph svg {
height: 100%;
width: 100%;
pointer-events: none;
}
#contrast_graph svg path {
fill: none;
stroke-width: 2px;
stroke: var(--color-accent);
opacity: 0.3;
}
#contrast_graph svg path.active {
opacity: 1.0;
}
#contrast_graph svg polygon {
fill: var(--color-selected);
stroke: none;
stroke-width: 0;
pointer-events: none;
}
.contrast_graph_point {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--color-accent);
margin: -1px;
}
.contrast_graph_point:hover {
background-color: var(--color-light);
}
.contrast_graph_point::before {
content: "";
position: absolute;
width: 24px;
height: 24px;
left: -6px;
top: -6px;
cursor: move;
}

View File

@ -122,6 +122,7 @@
<script src="js/texturing/painter.js"></script>
<script src="js/texturing/texture_generator.js"></script>
<script src="js/texturing/color.js"></script>
<script src="js/texturing/edit_texture.js"></script>
<script src="js/display_mode.js"></script>
<script src="js/animations/animation.js"></script>
<script src="js/animations/timeline_animators.js"></script>
@ -362,7 +363,7 @@
<div id="page_wrapper" class="invisible start_screen">
<div id="blackout"></div>
<div id="blackout" class="darken"></div>
<div id="tab_bar" :class="{drag_mode: drag_target_index !== null}">
<div id="tab_bar_list">

View File

@ -419,6 +419,7 @@ window.Dialog = class Dialog {
this.width = options.width
this.draggable = options.draggable
this.darken = options.darken !== false
this.singleButton = options.singleButton
this.buttons = options.buttons instanceof Array ? options.buttons : (options.singleButton ? ['dialog.close'] : ['dialog.confirm', 'dialog.cancel'])
this.form_first = options.form_first;
@ -677,7 +678,8 @@ window.Dialog = class Dialog {
let jq_dialog = $(this.object);
$('#dialog_wrapper').append(jq_dialog);
$('#blackout').show();
$('#blackout').show().toggleClass('darken', this.darken);
jq_dialog.show().css('display', 'flex');
jq_dialog.css('top', limitNumber(window.innerHeight/2-jq_dialog.height()/2, 0, 100)+'px');
if (this.width) {
@ -704,13 +706,13 @@ window.Dialog = class Dialog {
return this;
}
hide() {
$('#blackout').hide();
$('#blackout').hide().toggleClass('darken', true);
$(this.object).hide();
open_dialog = false;
open_interface = false;
Dialog.open = null;
Prop.active_panel = undefined;
$(this.object).detach()
$(this.object).detach();
return this;
}
delete() {

View File

@ -692,6 +692,27 @@ const MenuBar = {
], {
condition: {modes: ['edit']}
})
new BarMenu('texture', [
'adjust_brightness',
'adjust_contrast',
'invert_colors',
'adjust_curves',
'_',
'flip_texture_x',
'flip_texture_y'
/*
Hue, Saturation, Contrast, Brightness, Temperature
Color Curve
Flip, Rotate
Resize
Animation frame manipulation
Replace individual colors
Text?
Merge
*/
], {
condition: {modes: ['paint']}
})
new BarMenu('display', [
'copy',

View File

@ -0,0 +1,426 @@
BARS.defineActions(function() {
function getTextures() {
if (Texture.selected) {
return [Texture.selected];
} else {
return Texture.all;
}
}
new Action('invert_colors', {
icon: 'invert_colors',
category: 'textures',
condition: {modes: ['paint'], method: () => Texture.all.length},
click() {
let textures = getTextures();
Undo.initEdit({textures, bitmap: true});
textures.forEach(texture => {
texture.edit((canvas) => {
let ctx = canvas.getContext('2d');
ctx.filter = 'invert(1)';
ctx.drawImage(canvas, 0, 0);
}, {no_undo: true});
})
Undo.finishEdit('Invert colors')
}
})
new Action('adjust_brightness', {
icon: 'brightness_high',
category: 'textures',
condition: {modes: ['paint'], method: () => Texture.all.length},
click() {
let textures = getTextures();
let original_imgs = textures.map(tex => {
return tex.img.cloneNode();
})
Undo.initEdit({textures, bitmap: true});
new Dialog({
id: 'adjust_brightness',
name: 'action.adjust_brightness',
darken: false,
component: {
data() {return {
show_preview: true,
brightness: 100,
textures
}},
methods: {
change() {
textures.forEach((texture, i) => {
texture.edit((canvas) => {
let ctx = canvas.getContext('2d');
ctx.filter = `brightness(${this.brightness / 100})`;
ctx.drawImage(original_imgs[i], 0, 0);
}, {no_undo: true, use_cache: true});
})
}
},
template: `
<div>
<div class="texture_adjust_previews checkerboard" :class="{folded: !show_preview}">
<img v-for="texture in textures" :src="texture.source" />
</div>
<div class="tool texture_adjust_preview_toggle" @click="show_preview = !show_preview"><i class="material-icons">{{ show_preview ? 'expand_more' : 'expand_less' }}</i></div>
<div class="bar slider_input_combo">
<input type="range" class="tool" min="0" max="200" step="1" v-model="brightness" @input="change()">
<input lang="en" type="number" class="tool" min="0" max="200" step="1" v-model.number="brightness" @input="change()">
</div>
</div>
`
},
onConfirm() {
Undo.finishEdit('Invert colors');
},
onCancel() {
Undo.cancelEdit();
}
}).show();
}
})
new Action('adjust_contrast', {
icon: 'brightness_6',
category: 'textures',
condition: {modes: ['paint'], method: () => Texture.all.length},
click() {
let textures = getTextures();
let original_imgs = textures.map(tex => {
return tex.img.cloneNode();
})
Undo.initEdit({textures, bitmap: true});
new Dialog({
id: 'adjust_contrast',
name: 'action.adjust_contrast',
darken: false,
component: {
data() {return {
show_preview: true,
contrast: 100,
textures
}},
methods: {
change() {
textures.forEach((texture, i) => {
texture.edit((canvas) => {
let ctx = canvas.getContext('2d');
ctx.filter = `contrast(${this.contrast / 100})`;
ctx.drawImage(original_imgs[i], 0, 0);
}, {no_undo: true, use_cache: true});
})
}
},
template: `
<div>
<div class="texture_adjust_previews checkerboard" :class="{folded: !show_preview}">
<img v-for="texture in textures" :src="texture.source" />
</div>
<div class="tool texture_adjust_preview_toggle" @click="show_preview = !show_preview"><i class="material-icons">{{ show_preview ? 'expand_more' : 'expand_less' }}</i></div>
<div class="bar slider_input_combo">
<input type="range" class="tool" min="0" max="200" step="1" v-model="contrast" @input="change()">
<input lang="en" type="number" class="tool" min="0" max="200" step="1" v-model.number="contrast" @input="change()">
</div>
</div>
`
},
onConfirm() {
Undo.finishEdit('Invert colors');
},
onCancel() {
Undo.cancelEdit();
}
}).show();
}
})
new Action('adjust_curves', {
icon: 'fa-chart-line',
category: 'textures',
condition: {modes: ['paint'], method: () => Texture.all.length},
click() {
let textures = getTextures();
let original_image_data = textures.map(tex => {
let canvas = Painter.getCanvas(tex);
let image_data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
image_data.original_data = image_data.data.slice();
return image_data;
})
Undo.initEdit({textures, bitmap: true});
let image_data = original_image_data[0];
let light_points = {};
let highest_light_point = 0;
for (let i = 0; i < 256; i++) {
light_points[i] = 0;
}
for (let i = 0; i < image_data.data.length; i += 4) {
let R = image_data.data[i+0];
let G = image_data.data[i+1];
let B = image_data.data[i+2];
let A = image_data.data[i+3];
if (A == 0) continue;
let brightness = (0.2126*R + 0.7152*G + 0.0722*B);
light_points[Math.round(brightness)] += 1;
highest_light_point = Math.max(highest_light_point, light_points[Math.round(brightness)]);
}
let curves = {
rgb: null,
r: null,
g: null,
b: null,
a: null,
};
function toCatmullRomBezier( points, tension = 0.5, closing = false) {
if (points.length == 2) {
return `M${points[0][0]},${points[0][1]} L${points[1][0]},${points[1][1]}`
}
let tens = (tension !== 0) ? tension * 12 : 0.5 * 12
let PointList = (closing) ? [...copyFirstPointToLast(points)] : [...points]
let floats = PointList.map(x => x.map(x => parseFloat(x)))
let firstMoveto = ['M' + floats[0][0] + ' ' + floats[0][1] + ' ']
let matrixPoints = floats.map((point, i, arr) => {
if (i == 0) {
return getMatrix([arr[i],arr[i],arr[i+1],arr[i+2]])
} else if (i == arr.length - 2) {
return getMatrix([arr[i-1],arr[i],arr[i+1],arr[i+1]])
} else {
return getMatrix([arr[i-1],arr[i],arr[i+1],arr[i+2]])
}
}).filter(mx => mx[3] !== undefined)
let matrixMathToBezier = matrixPoints.map(p => {
return [
{ x: p[1].x, y: p[1].y },
{ x: ((-p[0].x + tens * p[1].x + p[2].x) / tens), y: ((-p[0].y + tens * p[1].y + p[2].y) / tens)},
{ x: ((p[1].x + tens * p[2].x - p[3].x) / tens), y: ((p[1].y + tens * p[2].y - p[3].y) / tens) },
{ x: p[2].x, y: p[2].y }
]
})
let toSVGNotation = matrixMathToBezier.map(bp => {
return "C" + bp[1].x + "," + bp[1].y + " " + bp[2].x + "," + bp[2].y + " " + bp[3].x + "," + bp[3].y + " "
})
return firstMoveto.concat(toSVGNotation).join(' ')
function getMatrix(arr) {
return arr.map(p => {
if (p !== undefined) { return { x : p[0], y : p[1] }}
})
}
// Duplicate first element to the end
function copyFirstPointToLast(arr) {
return (arr[0] !== arr[arr.length - 1]) ? [...arr, arr[0]] : arr
}
}
new Dialog({
id: 'adjust_curves',
name: 'action.adjust_curves',
darken: false,
width: 460,
component: {
data() {return {
light_data: '',
graph: 'rgb',
graphs: {
rgb: {data: '', points: [[0, 0], [1, 1]]},
r: {data: '', points: [[0, 0], [1, 1]]},
g: {data: '', points: [[0, 0], [1, 1]]},
b: {data: '', points: [[0, 0], [1, 1]]},
a: {data: '', points: [[0, 0], [1, 1]]},
},
height: 400,
width: 400,
textures
}},
methods: {
change() {
for (let key in this.graphs) {
var vectors = [];
this.graphs[key].points.forEach(point => {
vectors.push(new THREE.Vector2(point[0], point[1]));
})
curves[key] = new THREE.SplineCurve(vectors);
}
textures.forEach((texture, i) => {
texture.edit((canvas) => {
let ctx = canvas.getContext('2d');
let image_data = original_image_data[i];//ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < image_data.data.length; i += 4) {
let R = image_data.original_data[i+0]
let G = image_data.original_data[i+1]
let B = image_data.original_data[i+2]
let A = image_data.original_data[i+3]
let brightness = (0.2126*R + 0.7152*G + 0.0722*B);
let rgb = curves.rgb.getPoint(brightness / 255).y;
let r = curves.r.getPoint(brightness / 255).y;
let g = curves.g.getPoint(brightness / 255).y;
let b = curves.b.getPoint(brightness / 255).y;
let a = curves.a.getPoint(brightness / 255).y;
image_data.data[i+0] = R * ((r * 255) / brightness) * ((rgb * 255) / brightness);
image_data.data[i+1] = G * ((g * 255) / brightness) * ((rgb * 255) / brightness);
image_data.data[i+2] = B * ((b * 255) / brightness) * ((rgb * 255) / brightness);
image_data.data[i+3] = A * ((a * 255) / brightness);
}
ctx.putImageData(image_data, 0, 0);
}, {no_undo: true, use_cache: true});
})
},
setGraph(graph) {
this.graph = graph;
this.updateGraph();
},
dragPoint(point, e1) {
let scope = this;
let original_point = point.slice();
function drag(e2) {
point[0] = Math.clamp(original_point[0] + (e2.clientX - e1.clientX) / scope.width, 0, 1);
point[1] = Math.clamp(original_point[1] - (e2.clientY - e1.clientY) / scope.height, 0, 1);
scope.updateGraph();
scope.change();
}
function stop() {
removeEventListeners(document, 'mousemove touchmove', drag);
removeEventListeners(document, 'mouseup touchend', stop);
}
addEventListeners(document, 'mousemove touchmove', drag);
addEventListeners(document, 'mouseup touchend', stop);
},
createNewPoint(event) {
if (event.target.id !== 'contrast_graph' || event.which == 3) return;
let point = [
(event.offsetX - 5) / this.width,
1 - ((event.offsetY - 5) / this.width),
]
let {points} = this.graphs[this.graph];
points.push(point);
this.updateGraph();
this.change();
this.dragPoint(point, event);
},
updateGraph(id = this.graph) {
let offset = 5;
let {points} = this.graphs[this.graph];
points.sort((a, b) => a[0] - b[0]);
this.graphs[id].data = toCatmullRomBezier(points.map(p => {
return [p[0] * this.width + offset, (1-p[1]) * this.height + offset];
}));
},
contextMenu(point, event) {
new Menu([{
id: 'remove',
name: 'Remove',
icon: 'clear',
click: () => {
let {points} = this.graphs[this.graph];
points.remove(point);
this.updateGraph();
this.change();
}
}]).open(event.target);
}
},
template: `
<div>
<div class="bar contrast_graph_selector">
<div @click="setGraph('rgb')" :class="{selected: graph == 'rgb'}">RGB</div>
<div @click="setGraph('r')" :class="{selected: graph == 'r'}">R</div>
<div @click="setGraph('g')" :class="{selected: graph == 'g'}">G</div>
<div @click="setGraph('b')" :class="{selected: graph == 'b'}">B</div>
<div @click="setGraph('a')" :class="{selected: graph == 'a'}">A</div>
</div>
<div id="contrast_graph" @mousedown="createNewPoint($event)" @touchstart="createNewPoint($event)">
<svg>
<path :class="{active: graph == 'r'}" :d="graphs.r.data" style="stroke: #ff0000;"></path>
<path :class="{active: graph == 'g'}" :d="graphs.g.data" style="stroke: #00ff00;"></path>
<path :class="{active: graph == 'b'}" :d="graphs.b.data" style="stroke: #3b3bff;"></path>
<path :class="{active: graph == 'a'}" :d="graphs.a.data" style="stroke: var(--color-text);"></path>
<path :class="{active: graph == 'rgb'}" :d="graphs.rgb.data"></path>
<polygon :points="light_data" />
</svg>
<div class="contrast_graph_point"
v-for="point in graphs[graph].points"
:style="{left: point[0] * width + 'px', top: (1-point[1]) * height + 'px'}"
@mousedown="dragPoint(point, $event)" @touchstart="dragPoint(point, $event)"
@contextmenu="contextMenu(point, $event)"
></div>
</div>
</div>
`,
mounted() {
for (let key in this.graphs) {
this.updateGraph(key);
}
this.light_data = `${5},${this.height + 5}`;
for (let key in light_points) {
this.light_data += ` ${Math.round((key / 255) * this.width) + 5},${Math.round((1 - light_points[key] / highest_light_point) * this.height) + 5}`;
}
this.light_data += ` ${this.width + 5},${this.height + 5}`;
}
},
onConfirm() {
Undo.finishEdit('Invert colors');
},
onCancel() {
Undo.cancelEdit();
}
}).show();
}
})
new Action('flip_texture_x', {
icon: 'icon-mirror_x',
category: 'textures',
condition: {modes: ['paint'], method: () => Texture.all.length},
click() {
let textures = getTextures();
Undo.initEdit({textures, bitmap: true});
textures.forEach(texture => {
texture.edit((canvas) => {
let ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.scale(-1, 1);
ctx.drawImage(texture.img, -canvas.width, 0);
}, {no_undo: true});
})
Undo.finishEdit('Flip texture X')
}
})
new Action('flip_texture_y', {
icon: 'icon-mirror_y',
category: 'textures',
condition: {modes: ['paint'], method: () => Texture.all.length},
click() {
let textures = getTextures();
Undo.initEdit({textures, bitmap: true});
textures.forEach(texture => {
texture.edit((canvas) => {
let ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.scale(1, -1);
ctx.drawImage(texture.img, 0, -canvas.height);
}, {no_undo: true});
})
Undo.finishEdit('Flip texture Y')
}
})
})

View File

@ -629,8 +629,8 @@ Object.defineProperty(String.prototype, 'hashCode', {
//Color
tinycolor.prototype.toInt = function() {
var rgba = this.toRgb()
return Jimp.rgbaToInt(rgba.r, rgba.g, rgba.b, rgba.a)
let {r, g, b, a} = this.toRgb();
return r * Math.pow(256, 3) + g * Math.pow(256, 2) + b * Math.pow(256, 1) + a * Math.pow(256, 0);
}
function getAverageRGB(imgEl, blockSize) {

View File

@ -1323,6 +1323,7 @@
"menu.file": "File",
"menu.edit": "Edit",
"menu.transform": "Transform",
"menu.texture": "Texture",
"menu.tools": "Tools",
"menu.display": "Display",
"menu.animation": "Animation",