diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index 319f10e6857..ff3d12d8e4f 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -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', diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js index d503903f090..87d68adb0bd 100644 --- a/addons/web_editor/static/src/js/editor/image_processing.js +++ b/addons/web_editor/static/src/js/editor/image_processing.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) { diff --git a/addons/web_editor/static/src/js/editor/perspective_utils.js b/addons/web_editor/static/src/js/editor/perspective_utils.js new file mode 100644 index 00000000000..5a59b223e0b --- /dev/null +++ b/addons/web_editor/static/src/js/editor/perspective_utils.js @@ -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]]), + ); +} diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index c3817f2c409..f9529c6c498 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -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); }, diff --git a/addons/web_editor/static/tests/perspective_tests.js b/addons/web_editor/static/tests/perspective_tests.js new file mode 100644 index 00000000000..10b995b1ef6 --- /dev/null +++ b/addons/web_editor/static/tests/perspective_tests.js @@ -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])); + }); +});