[IMP] web_editor: add image shapes with perspective 3D
This commit adds the possibility of applying a "perspective" transformation on image with some image shapes (e.g. devices). For technical reasons, the only way to apply this 3D transformation is to modify the image in a canva. Since the 2D context of the canvas doesn't support projective transformations, we approximate the result using multiple affine transformations on smaller parts of the image. task-2666049 Part-of: odoo/odoo#79068 Co-authored-by: Tom De Caluwé <tdc@odoo.com>
This commit is contained in:
committed by
qsm-odoo
co-authored by
Tom De Caluwé
parent
63cf8a693a
commit
dc1ca34448
@@ -71,6 +71,7 @@ Odoo Web Editor widget.
|
||||
|
||||
'web_editor/static/src/js/wysiwyg/fonts.js',
|
||||
'web_editor/static/src/js/base.js',
|
||||
'web_editor/static/src/js/editor/perspective_utils.js',
|
||||
'web_editor/static/src/js/editor/image_processing.js',
|
||||
'web_editor/static/src/js/editor/custom_colors.js',
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
odoo.define('web_editor.image_processing', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {getAffineApproximation, getProjective} = require('@web_editor/js/editor/perspective_utils');
|
||||
|
||||
// Fields returned by cropperjs 'getData' method, also need to be passed when
|
||||
// initializing the cropper to reuse the previous crop.
|
||||
const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
|
||||
@@ -216,6 +218,9 @@ async function applyModifications(img, dataOptions = {}) {
|
||||
glFilter,
|
||||
filterOptions,
|
||||
forceModification,
|
||||
perspective,
|
||||
svgAspectRatio,
|
||||
imgAspectRatio,
|
||||
} = data;
|
||||
[width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s));
|
||||
quality = parseInt(quality);
|
||||
@@ -230,20 +235,87 @@ async function applyModifications(img, dataOptions = {}) {
|
||||
const original = await loadImage(originalSrc);
|
||||
container.appendChild(original);
|
||||
await activateCropper(original, 0, data);
|
||||
const croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
|
||||
let croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
|
||||
$(original).cropper('destroy');
|
||||
|
||||
// Aspect Ratio
|
||||
if (imgAspectRatio) {
|
||||
document.createElement('div').appendChild(croppedImg);
|
||||
imgAspectRatio = imgAspectRatio.split(':');
|
||||
imgAspectRatio = parseFloat(imgAspectRatio[0]) / parseFloat(imgAspectRatio[1]);
|
||||
await activateCropper(croppedImg, imgAspectRatio, {y: 0});
|
||||
croppedImg = $(croppedImg).cropper('getCroppedCanvas');
|
||||
$(croppedImg).cropper('destroy');
|
||||
}
|
||||
|
||||
// Width
|
||||
const result = document.createElement('canvas');
|
||||
result.width = resizeWidth || croppedImg.width;
|
||||
result.height = croppedImg.height * result.width / croppedImg.width;
|
||||
result.height = perspective ? result.width / svgAspectRatio : croppedImg.height * result.width / croppedImg.width;
|
||||
const ctx = result.getContext('2d');
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.mozImageSmoothingEnabled = true;
|
||||
ctx.webkitImageSmoothingEnabled = true;
|
||||
ctx.msImageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
|
||||
|
||||
// Perspective 3D
|
||||
if (perspective) {
|
||||
// x, y coordinates of the corners of the image as a percentage
|
||||
// (relative to the width or height of the image) needed to apply
|
||||
// the 3D effect.
|
||||
const points = JSON.parse(perspective);
|
||||
const divisions = 10;
|
||||
const w = croppedImg.width, h = croppedImg.height;
|
||||
|
||||
const project = getProjective(w, h, [
|
||||
[(result.width / 100) * points[0][0], (result.height / 100) * points[0][1]], // Top-left [x, y]
|
||||
[(result.width / 100) * points[1][0], (result.height / 100) * points[1][1]], // Top-right [x, y]
|
||||
[(result.width / 100) * points[2][0], (result.height / 100) * points[2][1]], // bottom-right [x, y]
|
||||
[(result.width / 100) * points[3][0], (result.height / 100) * points[3][1]], // bottom-left [x, y]
|
||||
]);
|
||||
|
||||
for (let i = 0; i < divisions; i++) {
|
||||
for (let j = 0; j < divisions; j++) {
|
||||
const [dx, dy] = [w / divisions, h / divisions];
|
||||
|
||||
const upper = {origin: [i * dx, j * dy], sides: [dx, dy], flange: 0.1, overlap: 0};
|
||||
const lower = {origin: [i * dx + dx, j * dy + dy], sides: [-dx, -dy], flange: 0, overlap: 0.1};
|
||||
|
||||
for (let {origin, sides, flange, overlap} of [upper, lower]) {
|
||||
const [[a, c, e], [b, d, f]] = getAffineApproximation(project, [
|
||||
origin, [origin[0] + sides[0], origin[1]], [origin[0], origin[1] + sides[1]]
|
||||
]);
|
||||
|
||||
const ox = (i !== divisions ? overlap * sides[0] : 0) + flange * sides[0];
|
||||
const oy = (j !== divisions ? overlap * sides[1] : 0) + flange * sides[1];
|
||||
|
||||
origin[0] += flange * sides[0];
|
||||
origin[1] += flange * sides[1];
|
||||
|
||||
sides[0] -= flange * sides[0];
|
||||
sides[1] -= flange * sides[1];
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(a, b, c, d, e, f);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(origin[0] - ox, origin[1] - oy);
|
||||
ctx.lineTo(origin[0] + sides[0], origin[1] - oy);
|
||||
ctx.lineTo(origin[0] + sides[0], origin[1]);
|
||||
ctx.lineTo(origin[0], origin[1] + sides[1]);
|
||||
ctx.lineTo(origin[0] - ox, origin[1] + sides[1]);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
ctx.drawImage(croppedImg, 0, 0);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
|
||||
}
|
||||
|
||||
// GL filter
|
||||
if (glFilter) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Transform a 2D point using a projective transformation matrix. Note that
|
||||
* this method is only well behaved for points that don't map to infinity!
|
||||
*
|
||||
* @param {number[][]} matrix - A projective transformation matrix
|
||||
* @param {number[]} point - A 2D point
|
||||
* @returns The transformed 2D point
|
||||
*/
|
||||
export function transform([[a, b, c], [d, e, f], [g, h, i]], [x, y]) {
|
||||
let z = g * x + h * y + i;
|
||||
return [(a * x + b * y + c) / z, (d * x + e * y + f) / z];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the inverse of a 3x3 matrix assuming it is invertible.
|
||||
*
|
||||
* @param {number[][]} matrix - A 3x3 matrix
|
||||
* @returns The resulting 3x3 matrix
|
||||
*/
|
||||
function invert([[a, b, c], [d, e, f], [g, h, i]]) {
|
||||
const determinant = a * e * i - a * f * h - b * d * i + b * f * g + c * d * h - c * e * g;
|
||||
return [
|
||||
[(e * i - h * f) / determinant, (h * c - b * i) / determinant, (b * f - e * c) / determinant],
|
||||
[(g * f - d * i) / determinant, (a * i - g * c) / determinant, (d * c - a * f) / determinant],
|
||||
[(d * h - g * e) / determinant, (g * b - a * h) / determinant, (a * e - d * b) / determinant],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply two 3x3 matrices.
|
||||
*
|
||||
* @param {number[][]} a - A 3x3 matrix
|
||||
* @param {number[][]} b - A 3x3 matrix
|
||||
* @returns The resulting 3x3 matrix
|
||||
*/
|
||||
function multiply(a, b) {
|
||||
const [[a0, a1, a2], [a3, a4, a5], [a6, a7, a8]] = a;
|
||||
const [[b0, b1, b2], [b3, b4, b5], [b6, b7, b8]] = b;
|
||||
return [
|
||||
[a0 * b0 + a1 * b3 + a2 * b6, a0 * b1 + a1 * b4 + a2 * b7, a0 * b2 + a1 * b5 + a2 * b8],
|
||||
[a3 * b0 + a4 * b3 + a5 * b6, a3 * b1 + a4 * b4 + a5 * b7, a3 * b2 + a4 * b5 + a5 * b8],
|
||||
[a6 * b0 + a7 * b3 + a8 * b6, a6 * b1 + a7 * b4 + a8 * b7, a6 * b2 + a7 * b5 + a8 * b8],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a projective transformation mapping a rectangular area at origin (0,0)
|
||||
* with a given width and height to a certain quadrilateral.
|
||||
*
|
||||
* @param {number} width - The width of the rectangular area
|
||||
* @param {number} height - The height of the rectangular area
|
||||
* @param {number[][]} quadrilateral - The vertices of the quadrilateral
|
||||
* @returns A projective transformation matrix
|
||||
*/
|
||||
export function getProjective(width, height, [[x0, y0], [x1, y1], [x2, y2], [x3, y3]]) {
|
||||
// Calculate a set of homogeneous coordinates a, b, c of the first
|
||||
// point using the other three points as basis vectors in the
|
||||
// underlying vector space.
|
||||
const denominator = x3 * (y1 - y2) + x1 * (y2 - y3) + x2 * (y3 - y1);
|
||||
const a = (x0 * (y2 - y3) + x2 * (y3 - y0) + x3 * (y0 - y2)) / denominator;
|
||||
const b = (x0 * (y3 - y1) + x3 * (y1 - y0) + x1 * (y0 - y3)) / denominator;
|
||||
const c = (x0 * (y1 - y2) + x1 * (y2 - y0) + x2 * (y0 - y1)) / denominator;
|
||||
|
||||
// The reverse transformation maps the homogeneous coordinates of
|
||||
// the last three corners of the original image onto the basis vectors
|
||||
// while mapping the first corner onto (1, 1, 1). The forward
|
||||
// transformation maps those basis vectors in addition to (1, 1, 1)
|
||||
// onto homogeneous coordinates of the corresponding corners of the
|
||||
// projective image. Combining these together yields the projective
|
||||
// transformation we are looking for.
|
||||
const reverse = invert([[width, -width, 0], [0, -height, height], [1, -1, 1]]);
|
||||
const forward = [[a * x1, b * x2, c * x3], [a * y1, b * y2, c * y3], [a, b, c]];
|
||||
|
||||
return multiply(forward, reverse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an affine transformation matrix that exactly maps the vertices of a
|
||||
* triangle to their corresponding images of a projective transformation. The
|
||||
* resulting transformation will be an approximation of the projective
|
||||
* transformation for the area inside the triangle.
|
||||
*
|
||||
* @param {number[][]} projective - A projective transformation matrix
|
||||
* @param {number[][]} triangle - The vertices of a triangle
|
||||
* @returns - An affine transformation matrix
|
||||
*/
|
||||
export function getAffineApproximation(projective, [[x0, y0], [x1, y1], [x2, y2]]) {
|
||||
const a = transform(projective, [x0, y0]);
|
||||
const b = transform(projective, [x1, y1]);
|
||||
const c = transform(projective, [x2, y2]);
|
||||
|
||||
return multiply(
|
||||
[[a[0], b[0], c[0]], [a[1], b[1], c[1]], [1, 1, 1]],
|
||||
invert([[x0, x1, x2], [y0, y1, y2], [1, 1, 1]]),
|
||||
);
|
||||
}
|
||||
@@ -5450,10 +5450,17 @@ registry.ImageTools = ImageHandlerOption.extend({
|
||||
const initialImageWidth = img.naturalWidth;
|
||||
|
||||
const svg = new DOMParser().parseFromString(svgText, 'image/svg+xml').documentElement;
|
||||
const svgAspectRatio = parseInt(svg.getAttribute('width')) / parseInt(svg.getAttribute('height'));
|
||||
// We will store the image in base64 inside the SVG.
|
||||
// applyModifications will return a dataURL with the current filters
|
||||
// and size options.
|
||||
const imgDataURL = await applyModifications(img, {mimetype: this._getImageMimetype(img)});
|
||||
const options = {
|
||||
mimetype: this._getImageMimetype(img),
|
||||
perspective: svg.dataset.imgPerspective || null,
|
||||
imgAspectRatio: svg.dataset.imgAspectRatio || null,
|
||||
svgAspectRatio: svgAspectRatio,
|
||||
};
|
||||
const imgDataURL = await applyModifications(img, options);
|
||||
svg.removeChild(svg.querySelector('#preview'));
|
||||
svg.querySelector('image').setAttribute('xlink:href', imgDataURL);
|
||||
// Force natural width & height (note: loading the original image is
|
||||
@@ -5467,7 +5474,6 @@ registry.ImageTools = ImageHandlerOption.extend({
|
||||
svg.setAttribute('height', originalImage.naturalHeight);
|
||||
} else {
|
||||
const imageWidth = Math.trunc(img.dataset.resizeWidth || img.dataset.width || initialImageWidth);
|
||||
const svgAspectRatio = parseInt(svg.getAttribute('width')) / parseInt(svg.getAttribute('height'));
|
||||
const newHeight = imageWidth / svgAspectRatio;
|
||||
svg.setAttribute('width', imageWidth);
|
||||
svg.setAttribute('height', newHeight);
|
||||
@@ -5635,9 +5641,17 @@ registry.ImageTools = ImageHandlerOption.extend({
|
||||
*/
|
||||
async _initializeImage() {
|
||||
const img = this._getImg();
|
||||
const match = img.src.match(/\/web_editor\/image_shape\/(\w+\.\w+)/);
|
||||
let match = img.src.match(/\/web_editor\/image_shape\/(\w+\.\w+)/);
|
||||
if (img.dataset.shape && match) {
|
||||
return this._loadImageInfo(`/web/image/${match[1]}`);
|
||||
match = match[1];
|
||||
if (match.endsWith("_perspective")) {
|
||||
// As an image might already have been modified with a
|
||||
// perspective for some customized snippets in themes. We need
|
||||
// to find the original image to set the 'data-original-src'
|
||||
// attribute.
|
||||
match = match.slice(0, -12);
|
||||
}
|
||||
return this._loadImageInfo(`/web/image/${match}`);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import {getAffineApproximation, getProjective, transform} from '@web_editor/js/editor/perspective_utils';
|
||||
|
||||
const epsilon = 100 * Number.EPSILON;
|
||||
|
||||
function midpoint([x0, y0], [x1, y1], weight = 0.5) {
|
||||
return [weight * (x0 + y0), (1.0 - weight) * (x1 + y1)];
|
||||
}
|
||||
|
||||
function pointEqual(assert, a, b) {
|
||||
assert.pushResult({
|
||||
result: Math.abs(a[0] - b[0]) < epsilon && Math.abs(a[1] - b[1]) < epsilon,
|
||||
expected: `(${a[0]}, ${a[1]})`,
|
||||
actual: `(${b[0]}, ${b[1]})`,
|
||||
});
|
||||
}
|
||||
|
||||
function notPointEqual(assert, a, b) {
|
||||
assert.pushResult({
|
||||
result: Math.abs(a[0] - b[0]) > epsilon || Math.abs(a[1] - b[1]) > epsilon,
|
||||
expected: `(${a[0]}, ${a[1]})`,
|
||||
actual: `different from (${b[0]}, ${b[1]})`,
|
||||
});
|
||||
}
|
||||
|
||||
QUnit.module('Perspective Utils', {
|
||||
|
||||
}, function () {
|
||||
QUnit.test("Should correctly transform 2D points using a projective transformation", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const translation = [[0, 0, 3], [0, 0, 5], [0, 0, 1]];
|
||||
pointEqual(assert, transform(translation, [0, 0]), [3, 5]);
|
||||
|
||||
const scale = [[2, 0, 0], [0, 0.5, 0], [0, 0, 1]];
|
||||
pointEqual(assert, transform(scale, [1, 1]), [2, 0.5]);
|
||||
|
||||
const perspective = [[1, 0, 0], [0, 1, 0], [1, 1, 1]];
|
||||
pointEqual(assert, transform(perspective, [4, 5]), [0.4, 0.5]);
|
||||
});
|
||||
QUnit.test("Should find an affine approximation of a projective transformation", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const a = [0, 0];
|
||||
const b = [1, 0];
|
||||
const c = [0, 1];
|
||||
|
||||
const projective = [[1, 2, 3], [2, 4, 5], [2, 2, 1]];
|
||||
const affine = getAffineApproximation(projective, [a, b, c]);
|
||||
|
||||
pointEqual(assert, transform(projective, a), transform(affine, a));
|
||||
pointEqual(assert, transform(projective, b), transform(affine, b));
|
||||
pointEqual(assert, transform(projective, c), transform(affine, c));
|
||||
|
||||
notPointEqual(assert, (projective, [1, 1]), transform(affine, [1, 1]));
|
||||
});
|
||||
QUnit.test("Should identically transform common edge points of two affine approximations", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const a = [0, 0];
|
||||
const b = [1, 0];
|
||||
const c = [1, 1];
|
||||
const d = [0, 1];
|
||||
|
||||
const projective = [[1, 2, 3], [2, 4, 5], [2, 2, 1]];
|
||||
const affine1 = getAffineApproximation(projective, [a, b, d]);
|
||||
const affine2 = getAffineApproximation(projective, [b, c, d]);
|
||||
|
||||
pointEqual(assert, transform(affine1, midpoint(b, d, 0.2)), transform(affine2, midpoint(b, d, 0.2)));
|
||||
pointEqual(assert, transform(affine1, midpoint(b, d, 0.5)), transform(affine2, midpoint(b, d, 0.5)));
|
||||
pointEqual(assert, transform(affine1, midpoint(b, d, 0.8)), transform(affine2, midpoint(b, d, 0.8)));
|
||||
});
|
||||
QUnit.test("Should find a projective transformation for a given quadrilateral", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const width = 2;
|
||||
const height = 3;
|
||||
|
||||
const a = [0.1, 0.3];
|
||||
const b = [1.9, 0.1];
|
||||
const c = [1.7, 2.9];
|
||||
const d = [0.1, 2.8];
|
||||
|
||||
const projective = getProjective(width, height, [a, b, c, d]);
|
||||
|
||||
pointEqual(assert, a, transform(projective, [0, 0]));
|
||||
pointEqual(assert, b, transform(projective, [width, 0]));
|
||||
pointEqual(assert, c, transform(projective, [width, height]));
|
||||
pointEqual(assert, d, transform(projective, [0, height]));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user