/*
 * Copyright 2019 Abakkk
 * Copyright 2024 Dave Prowse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * SPDX-FileCopyrightText: 2019 Abakkk
 * SPDX-License-Identifier: GPL-3.0-or-later
 * SPDX-FileContributor: Modified by Dave Prowse
 */

/* jslint esversion: 6 (2019) */
/* eslint version: 9.16 (2024) */
/* exported Shape, TextAlignment, Transformation, getAllFontFamilies, DrawingElement */

import Cairo from 'cairo';

import Clutter from 'gi://Clutter';
import Cogl from 'gi://Cogl';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import PangoCairo from 'gi://PangoCairo';

import { CURATED_UUID as UUID } from './utils.js';

// replacing this nullish with a ternay: const Color = Clutter.Color ?? Cogl.Color;
const Color = Clutter.Color ? Clutter.Color : Cogl.Color;

export const StaticColor = {
    WHITE: Color.from_string('#ffffff')[1],
    BLUE: Color.from_string('#0000ff')[1],
    TRANSPARENT: Color.from_string('#00000000')[1],
    BLACK: Color.from_string('#000000')[1],
    GRAY: Color.from_string('#a0a0a4')[1],
    RED: Color.from_string('#ff0000')[1]
}

export const Shape = { NONE: 0, LINE: 1, ELLIPSE: 2, RECTANGLE: 3, TEXT: 4, POLYGON: 5, POLYLINE: 6, IMAGE: 7, ARROW: 8, LASER: 9, HIGHLIGHTER: 10 };
export const TextAlignment = { LEFT: 0, CENTER: 1, RIGHT: 2 };
export const Transformation = { TRANSLATION: 0, ROTATION: 1, SCALE_PRESERVE: 2, STRETCH: 3, REFLECTION: 4, INVERSION: 5, SMOOTH: 100 };

export const getAllFontFamilies = function() {
    return PangoCairo.font_map_get_default().list_families().map(fontFamily => fontFamily.get_name()).sort((a,b) => a.localeCompare(b));
};

const getFillRuleSvgName = function(fillRule) {
    return fillRule == Cairo.FillRule.EVEN_ODD ? 'evenodd' : 'nonzero';
};

const getLineCapSvgName = function(lineCap) {
    return lineCap == Cairo.LineCap.BUTT ? 'butt' :
           lineCap == Cairo.LineCap.SQUASH ? 'square' :
           'round';
};

const getLineJoinSvgName = function(lineJoin) {
    return lineJoin == Cairo.LineJoin.MITER ? 'miter' :
           lineJoin == Cairo.LineJoin.BEVEL ? 'bevel' :
           'round';
};

const SVG_DEBUG_SUPERPOSES_CAIRO = false;
const RADIAN = 180 / Math.PI;               // degree
const INVERSION_CIRCLE_RADIUS = 12;         // px
const REFLECTION_TOLERANCE = 5;             // px,  to select vertical and horizontal directions
const STRETCH_TOLERANCE = Math.PI / 8;      // rad, to select vertical and horizontal directions
const MIN_REFLECTION_LINE_LENGTH = 10;      // px
const MIN_TRANSLATION_DISTANCE = 1;         // px
const MIN_ROTATION_ANGLE = Math.PI / 1000;  // rad
const MIN_DRAWING_SIZE = 3;                 // px
const MIN_INTERMEDIATE_POINT_DISTANCE = 1;  // px, the higher it is, the fewer points there will be
const MARK_COLOR = StaticColor.BLUE

export const DrawingElement = function(params) {
    return params.shape == Shape.TEXT ? new TextElement(params) :
           params.shape == Shape.IMAGE ? new ImageElement(params) :
           new _DrawingElement(params);
};

// DrawingElement represents a "brushstroke".
// It can be converted into a cairo path as well as a svg element.
// See DrawingArea._startDrawing() to know its params.
const _DrawingElement = GObject.registerClass({
    GTypeName: `${UUID}-DrawingElement`,
}, class _DrawingElement extends GObject.Object{ 
    _init(params) {
        for (let key in params)
            this[key] = params[key];
        
        // compatibility with json generated by old extension versions
        
        if (params.transformations === undefined)
            this.transformations = [];
        
        if (params.font && !(params.font instanceof Pango.FontDescription)) {
            // compatibility with v6.2-
            if (params.font.weight === 0)
                this.font.weight = 400;
            else if (params.font.weight === 1)
                this.font.weight = 700;
            this.font = new Pango.FontDescription();
            ['family', 'weight', 'style', 'stretch', 'variant'].forEach(attribute => {
                if (params.font[attribute] !== undefined)
                    try {
                        this.font[`set_${attribute}`](params.font[attribute]);
                    } catch(e) {}
            });
        }
        
        if (params.transform && params.transform.center) {
            let angle = (params.transform.angle || 0) + (params.transform.startAngle || 0);
            if (angle)
                this.transformations.push({ type: Transformation.ROTATION, angle: angle });
        }
        if (params.shape == Shape.ELLIPSE && params.transform && params.transform.ratio && params.transform.ratio != 1 && params.points.length >= 2) {
            let [ratio, p0, p1] = [params.transform.ratio, params.points[0], params.points[1]];
            // Add a fake point that will give the right ellipse ratio when building the element.
            this.points.push([ratio * (p1[0] - p0[0]) + p0[0], ratio * (p1[1] - p0[1]) + p0[1]]);
        }
        delete this.transform;
        
        // v10-
        if (this.textRightAligned)
            this.textAlignment = TextAlignment.RIGHT;
        delete this.textRightAligned;
    }
    
    // toJSON is called by JSON.stringify
    toJSON() {
        return {
            shape: this.shape,
            color: this.color,
            line: this.line,
            dash: this.dash,
            fill: this.fill,
            fillRule: this.fillRule,
            eraser: this.eraser,
            transformations: this.transformations.filter(transformation => transformation.type != Transformation.SMOOTH)
                                                 .map(transformation => Object.assign({}, transformation, { undoable: undefined })),
            points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100])
        };
    }
    
    buildCairo(cr, params) {
        if (this.color)
            cr.setSourceColor(this.color);

        if (this.line) {
            cr.setLineCap(this.line.lineCap);
            cr.setLineJoin(this.line.lineJoin);
            cr.setLineWidth(this.line.lineWidth);
        }
        
        if (this.fillRule)
            cr.setFillRule(this.fillRule);
        
        if (this.dash && this.dash.active && this.dash.array && this.dash.array[0] && this.dash.array[1])
            cr.setDash(this.dash.array, this.dash.offset);
        
        if (this.eraser)
            cr.setOperator(Cairo.Operator.CLEAR);
        else
            cr.setOperator(Cairo.Operator.OVER);
        
        if (params.dummyStroke)
            setDummyStroke(cr);
        
        if (SVG_DEBUG_SUPERPOSES_CAIRO) {
            cr.setSourceColor(StaticColor.RED);
            cr.setLineWidth(this.line.lineWidth / 2 || 1);
        }
        
        this.transformations.slice(0).reverse().forEach(transformation => {
            if (transformation.type == Transformation.TRANSLATION) {
                cr.translate(transformation.slideX, transformation.slideY);
            } else if (transformation.type == Transformation.ROTATION) {
                let center = this._getTransformedCenter(transformation);
                cr.translate(center[0], center[1]);
                cr.rotate(transformation.angle);
                cr.translate(-center[0], -center[1]);
            } else if (transformation.type == Transformation.SCALE_PRESERVE || transformation.type == Transformation.STRETCH) {
                let center = this._getTransformedCenter(transformation);
                cr.translate(center[0], center[1]);
                cr.rotate(transformation.angle);
                cr.scale(transformation.scaleX, transformation.scaleY);
                cr.rotate(-transformation.angle);
                cr.translate(-center[0], -center[1]);
            } else if (transformation.type == Transformation.REFLECTION || transformation.type == Transformation.INVERSION) {
                cr.translate(transformation.slideX, transformation.slideY);
                cr.rotate(transformation.angle);
                cr.scale(transformation.scaleX, transformation.scaleY);
                cr.rotate(-transformation.angle);
                cr.translate(-transformation.slideX, -transformation.slideY);
            }
        });
        
        this._drawCairo(cr, params);
        
        cr.identityMatrix();
    }
    
    _addMarks(cr) {
        if (this.showSymmetryElement) {
            setDummyStroke(cr);
            cr.setSourceColor(MARK_COLOR);
            
            let transformation = this.lastTransformation;
            if (transformation.type == Transformation.REFLECTION) {
                cr.moveTo(transformation.startX, transformation.startY);
                cr.lineTo(transformation.endX, transformation.endY);
            } else {
                cr.arc(transformation.endX, transformation.endY, INVERSION_CIRCLE_RADIUS, 0, 2 * Math.PI);
            }
            cr.stroke();
        }
        
        if (this.showRotationCenter) {
            setDummyStroke(cr);
            cr.setSourceColor(MARK_COLOR);

            let center = this._getTransformedCenter(this.lastTransformation);
            cr.arc(center[0], center[1], INVERSION_CIRCLE_RADIUS, 0, 2 * Math.PI);
            cr.stroke();
        }
        
        if (this.showStretchAxes) {
            setDummyStroke(cr);
            cr.setSourceColor(MARK_COLOR);

            let center = this._getTransformedCenter(this.lastTransformation);
            for (let i = 0; i <=1; i++) {
                cr.moveTo(center[0] - 1000 * Math.cos(i * Math.PI / 2), center[1] - 1000 * Math.sin(i * Math.PI / 2));
                cr.lineTo(center[0] + 1000 * Math.cos(i * Math.PI / 2), center[1] + 1000 * Math.sin(i * Math.PI / 2));
            }
            cr.stroke();
        }
    }
    
    _drawCairo(cr, params) {
        let [points, shape] = [this.points, this.shape];
        
        if (shape == Shape.LINE && points.length == 3) {
            cr.moveTo(points[0][0], points[0][1]);
            cr.curveTo(points[0][0], points[0][1], points[1][0], points[1][1], points[2][0], points[2][1]);
            
        } else if (shape == Shape.LINE && points.length == 4) {
            cr.moveTo(points[0][0], points[0][1]);
            cr.curveTo(points[1][0], points[1][1], points[2][0], points[2][1], points[3][0], points[3][1]);
            
        } else if (shape == Shape.NONE || shape == Shape.LINE) {
            cr.moveTo(points[0][0], points[0][1]);
            for (let j = 1; j < points.length; j++) {
                cr.lineTo(points[j][0], points[j][1]);
            }
            
        } else if (shape == Shape.ELLIPSE && points.length >= 2) {
            let radius = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]);
            let ratio = 1;
            
            if (points[2]) {
                ratio = Math.hypot(points[2][0] - points[0][0], points[2][1] - points[0][1]) / radius;
                cr.translate(points[0][0], points[0][1]);
                cr.scale(ratio, 1);
                cr.translate(-points[0][0], -points[0][1]);
                cr.arc(points[0][0], points[0][1], radius, 0, 2 * Math.PI);
                cr.translate(points[0][0], points[0][1]);
                cr.scale(1 / ratio, 1);
                cr.translate(-points[0][0], -points[0][1]);
            } else
                cr.arc(points[0][0], points[0][1], radius, 0, 2 * Math.PI);
            
        } else if (shape == Shape.RECTANGLE && points.length == 2) {
            cr.rectangle(points[0][0], points[0][1], points[1][0] - points[0][0], points[1][1] - points[0][1]);
        
        } else if ((shape == Shape.POLYGON || shape == Shape.POLYLINE) && points.length >= 2) {
            cr.moveTo(points[0][0], points[0][1]);
            for (let j = 1; j < points.length; j++) {
                cr.lineTo(points[j][0], points[j][1]);
            }
            if (shape == Shape.POLYGON)
                cr.closePath();
        
        } else if (shape == Shape.ARROW && points.length >= 2) {
            // Draw the main line
            cr.moveTo(points[0][0], points[0][1]);
            cr.lineTo(points[1][0], points[1][1]);

            // Draw arrowhead
            let angle = Math.atan2(points[1][1] - points[0][1], points[1][0] - points[0][0]);
            let arrowSize = this.line.lineWidth * 5; // Adjust this multiplier to change arrow size

            cr.moveTo(points[1][0], points[1][1]);
            cr.lineTo(points[1][0] - arrowSize * Math.cos(angle - Math.PI/6),
                    points[1][1] - arrowSize * Math.sin(angle - Math.PI/6));
            cr.moveTo(points[1][0], points[1][1]);
            cr.lineTo(points[1][0] - arrowSize * Math.cos(angle + Math.PI/6),
                    points[1][1] - arrowSize * Math.sin(angle + Math.PI/6));    
        
        // Laser Method
        } else if (shape == Shape.LASER && points.length >= 2) {
        // LASER BEAM - Like a bass line with vibrato! 🎸
        const startX = points[0][0];
        const startY = points[0][1];
        const endX = points[1][0];
        const endY = points[1][1];
        
        const distance = Math.hypot(endX - startX, endY - startY);
        const angle = Math.atan2(endY - startY, endX - startX);
        const segments = Math.floor(distance / 8);
        
        if (segments > 1) {
            cr.moveTo(startX, startY);
            
            // Create the wavy laser effect - like sound waves! 🎵
            for (let i = 1; i <= segments; i++) {
                const progress = i / segments;
                const x = startX + Math.cos(angle) * distance * progress;
                const y = startY + Math.sin(angle) * distance * progress;
                const waveAmplitude = Math.sin(progress * Math.PI) * 3; // Wave intensity
                const perpOffset = Math.sin(i * Math.PI / 3) * waveAmplitude;
                const perpX = x + Math.cos(angle + Math.PI/2) * perpOffset;
                const perpY = y + Math.sin(angle + Math.PI/2) * perpOffset;
                cr.lineTo(perpX, perpY);
            }
            
            cr.lineTo(endX, endY);
        } else {
            // Fallback for very short lasers
            cr.moveTo(startX, startY);
            cr.lineTo(endX, endY);
        }
    
        // End Laser Portion

        // Start Highlighter Portion

        } else if (shape == Shape.HIGHLIGHTER && points.length >= 2) {
                // HIGHLIGHTER - Semi-transparent yellow brush
                cr.setOperator(Cairo.Operator.OVER);
                cr.moveTo(points[0][0], points[0][1]);
                for (let j = 1; j < points.length; j++) {
                    cr.lineTo(points[j][0], points[j][1]);
                }
            }   
            // End Highlighter Portion
            
        }
    
    getContainsPoint(cr, x, y) {
        cr.save();
        cr.setLineWidth(Math.max(this.line.lineWidth, 25));
        cr.setDash([], 0);
        
        // Check whether the point is inside/on/near the element.
        let inElement = cr.inStroke(x, y) || this.fill && cr.inFill(x, y);
        cr.restore();
        return inElement;
    }
    
    buildSVG(bgcolorString) {
        let transforms = [];
        this.transformations.slice(0).reverse().forEach(transformation => {
            let center = this._getTransformedCenter(transformation);
            
            if (transformation.type == Transformation.TRANSLATION) {
                transforms.push(['translate', transformation.slideX, transformation.slideY]);
            } else if (transformation.type == Transformation.ROTATION) {
                transforms.push(['translate', center[0], center[1]]);
                transforms.push(['rotate', transformation.angle * RADIAN]);
                transforms.push(['translate', -center[0], -center[1]]);
            } else if (transformation.type == Transformation.SCALE_PRESERVE || transformation.type == Transformation.STRETCH) {
                transforms.push(['translate', center[0], center[1]]);
                transforms.push(['rotate', transformation.angle * RADIAN]);
                transforms.push(['scale', transformation.scaleX, transformation.scaleY]);
                transforms.push(['rotate', -transformation.angle * RADIAN]);
                transforms.push(['translate', -center[0], -center[1]]);
            } else if (transformation.type == Transformation.REFLECTION || transformation.type == Transformation.INVERSION) {
                transforms.push(['translate', transformation.slideX, transformation.slideY]);
                transforms.push(['rotate', transformation.angle * RADIAN]);
                transforms.push(['scale', transformation.scaleX, transformation.scaleY]);
                transforms.push(['rotate', -transformation.angle * RADIAN]);
                transforms.push(['translate', -transformation.slideX, -transformation.slideY]);
            }
        });
            
        let grouped = [];
        transforms.forEach((transform, index) => {
            let [type, ...values] = transform;
            
            if (grouped.length && grouped[grouped.length - 1][0] == type)
                values.forEach((value, valueIndex) => grouped[grouped.length - 1][valueIndex + 1] += value);
            else
                grouped.push(transform);
        });
        
        let filtered = grouped.filter(transform => {
            let [type, ...values] = transform;
            
            if (type == 'scale')
                return values.some(value => value != 1);
            else
                return values.some(value => value != 0);
        });
        
        let transAttribute = '';
        if (filtered.length) {
            transAttribute = ' transform="';
            filtered.forEach((transform, index) => {
                let [type, ...values] = transform;
                transAttribute += `${index == 0 ? '' : ' '}${type}(${values.map(value => Number(value).toFixed(2))})`;
            });
            
            transAttribute += '"';
        }
        
        return this._drawSvg(transAttribute, bgcolorString);
    }
    
    _drawSvg(transAttribute, bgcolorString) {
        let row = "\n  ";
        let points = this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]);
        
        // Ensure color has toJSON method (may be lost during copy/mirror operations)
        let color;
        if (this.eraser) {
            color = bgcolorString;
        } else {
            if (this.color.toJSON && typeof this.color.toJSON === 'function') {
                color = this.color.toJSON();
            } else {
                // Fallback: reconstruct color string from Color object
                if (this.color.to_string && typeof this.color.to_string === 'function') {
                    color = this.color.to_string().slice(0, -2); // Remove alpha suffix
                } else {
                    color = `rgb(${this.color.red},${this.color.green},${this.color.blue})`;
                }
            }
        }
        
        let fill = this.fill && !this.isStraightLine;
        let attributes = this.eraser ? `class="eraser" ` : '';
        
        if (fill) {
            attributes += `fill="${color}"`;
            if (this.fillRule)
                attributes += ` fill-rule="${getFillRuleSvgName(this.fillRule)}"`;
        } else {
            attributes += `fill="none"`;
        }
        
        if (this.line && this.line.lineWidth) {
            attributes += ` stroke="${color}"` +
                          ` stroke-width="${this.line.lineWidth}"`;
            if (this.line.lineCap)
                attributes += ` stroke-linecap="${getLineCapSvgName(this.line.lineCap)}"`;
            if (this.line.lineJoin && !this.isStraightLine)
                attributes += ` stroke-linejoin="${getLineJoinSvgName(this.line.lineJoin)}"`;
            if (this.dash && this.dash.active && this.dash.array && this.dash.array[0] && this.dash.array[1])
                attributes += ` stroke-dasharray="${this.dash.array[0]} ${this.dash.array[1]}" stroke-dashoffset="${this.dash.offset}"`;
        }
        
        if (this.shape == Shape.LINE && points.length == 4) {
            row += `<path ${attributes} d="M${points[0][0]} ${points[0][1]}`;
            row += ` C ${points[1][0]} ${points[1][1]}, ${points[2][0]} ${points[2][1]}, ${points[3][0]} ${points[3][1]}`;
            row += `${fill ? 'z' : ''}"${transAttribute}/>`;
            
        } else if (this.shape == Shape.LINE && points.length == 3) {
            row += `<path ${attributes} d="M${points[0][0]} ${points[0][1]}`;
            row += ` C ${points[0][0]} ${points[0][1]}, ${points[1][0]} ${points[1][1]}, ${points[2][0]} ${points[2][1]}`;
            row += `${fill ? 'z' : ''}"${transAttribute}/>`;
            
        } else if (this.shape == Shape.LINE) {
            row += `<line ${attributes} x1="${points[0][0]}" y1="${points[0][1]}" x2="${points[1][0]}" y2="${points[1][1]}"${transAttribute}/>`;
        
        } else if (this.shape == Shape.NONE) {
            row += `<path ${attributes} d="M${points[0][0]} ${points[0][1]}`;
            for (let i = 1; i < points.length; i++)
                row += ` L ${points[i][0]} ${points[i][1]}`;
            row += `${fill ? 'z' : ''}"${transAttribute}/>`;
            
        } else if (this.shape == Shape.ELLIPSE && points.length == 3) {
            let ry = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]);
            let rx = Math.hypot(points[2][0] - points[0][0], points[2][1] - points[0][1]);
            row += `<ellipse ${attributes} cx="${points[0][0]}" cy="${points[0][1]}" rx="${rx}" ry="${ry}"${transAttribute}/>`;
            
        } else if (this.shape == Shape.ELLIPSE && points.length == 2) {
            let r = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]);
            row += `<circle ${attributes} cx="${points[0][0]}" cy="${points[0][1]}" r="${r}"${transAttribute}/>`;
            
        } else if (this.shape == Shape.RECTANGLE && points.length == 2) {
            row += `<rect ${attributes} x="${Math.min(points[0][0], points[1][0])}" y="${Math.min(points[0][1], points[1][1])}" ` +
                   `width="${Math.abs(points[1][0] - points[0][0])}" height="${Math.abs(points[1][1] - points[0][1])}"${transAttribute}/>`;
                   
        } else if (this.shape == Shape.POLYGON && points.length >= 3) {
            row += `<polygon ${attributes} points="`;
            for (let i = 0; i < points.length; i++)
                row += ` ${points[i][0]},${points[i][1]}`;
            row += `"${transAttribute}/>`;
        
        } else if (this.shape == Shape.POLYLINE && points.length >= 2) {
            row += `<polyline ${attributes} points="`;
            for (let i = 0; i < points.length; i++)
                row += ` ${points[i][0]},${points[i][1]}`;
            row += `"${transAttribute}/>`;
        
        } else if (this.shape == Shape.ARROW && points.length >= 2) {
            let angle = Math.atan2(points[1][1] - points[0][1], points[1][0] - points[0][0]);
            let arrowSize = this.line.lineWidth * 5; // Should match the Cairo version

            row += `<path ${attributes} d="M${points[0][0]},${points[0][1]} `;
            row += `L${points[1][0]},${points[1][1]} `;
            row += `M${points[1][0]},${points[1][1]} `;
            row += `L${points[1][0] - arrowSize * Math.cos(angle - Math.PI/6)},${points[1][1] - arrowSize * Math.sin(angle - Math.PI/6)} `;
            row += `M${points[1][0]},${points[1][1]} `;
            row += `L${points[1][0] - arrowSize * Math.cos(angle + Math.PI/6)},${points[1][1] - arrowSize * Math.sin(angle + Math.PI/6)}"`;
            row += `${transAttribute}/>`;
        
        // Laser portion
        } else if (this.shape == Shape.LASER && points.length >= 2) {
        // Export LASER as wavy path
            const startX = points[0][0];
            const startY = points[0][1];
            const endX = points[1][0];
            const endY = points[1][1];
            const distance = Math.hypot(endX - startX, endY - startY);
            const angle = Math.atan2(endY - startY, endX - startX);
            const segments = Math.floor(distance / 8);
            
            row += `<path ${attributes} d="M${startX},${startY}`;
            
            if (segments > 1) {
                for (let i = 1; i <= segments; i++) {
                    const progress = i / segments;
                    const x = startX + Math.cos(angle) * distance * progress;
                    const y = startY + Math.sin(angle) * distance * progress;
                    const waveAmplitude = Math.sin(progress * Math.PI) * 3;
                    const perpOffset = Math.sin(i * Math.PI / 3) * waveAmplitude;
                    const perpX = x + Math.cos(angle + Math.PI/2) * perpOffset;
                    const perpY = y + Math.sin(angle + Math.PI/2) * perpOffset;
                    row += ` L${perpX},${perpY}`;
                }
            }
            
            row += ` L${endX},${endY}"${transAttribute}/>`;
        
        // End laser portion

        // Start Highlighter Portion
        } else if (this.shape == Shape.HIGHLIGHTER && points.length >= 2) {
            // Export HIGHLIGHTER as semi-transparent path
            row += `<path ${attributes} opacity="0.5" d="M${points[0][0]},${points[0][1]}`;
            for (let i = 1; i < points.length; i++) {
                row += ` L${points[i][0]},${points[i][1]}`;
            }
            row += `"${transAttribute}/>`;
        }
        // End Highlighter Portion
        
        return row;
    }
    
    get lastTransformation() {
        if (!this.transformations.length)
            return null;
        
        return this.transformations[this.transformations.length - 1];
    }
    
    get isStraightLine() {
        return this.shape == Shape.LINE && this.points.length == 2;
    }
    
    smoothAll() {
        let oldPoints = this.points.slice();
        
        for (let i = 0; i < this.points.length; i++)
            this._smooth(i);
        
        let newPoints = this.points.slice();
        
        this.transformations.push({ type: Transformation.SMOOTH, undoable: true,
                                    undo: () => this.points = oldPoints,
                                    redo: () => this.points = newPoints });
        
        if (this._undoneTransformations)
            this._undoneTransformations = this._undoneTransformations.filter(transformation => transformation.type != Transformation.SMOOTH);
    }
    
    addPoint() {
        if (this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) {
            // copy last point
            let [lastPoint, secondToLastPoint] = [this.points[this.points.length - 1], this.points[this.points.length - 2]];
            if (!getNearness(secondToLastPoint, lastPoint, MIN_DRAWING_SIZE))
                this.points.push([lastPoint[0], lastPoint[1]]);
        } else if (this.shape == Shape.LINE) {
            if (this.points.length == 2) {
                this.points[2] = this.points[1];
            } else if (this.points.length == 3) {
                this.points[3] = this.points[2];
                this.points[2] = this.points[1];
            }
        }
    }
    
    // For free drawing only.
    addIntermediatePoint(x, y, transform) {
        let points = this.points;
        if (getNearness(points[points.length - 1], [x, y], MIN_INTERMEDIATE_POINT_DISTANCE))
            return;
        
        points.push([x, y]);
        if (transform)
            this._smooth(points.length - 1);
    }
    
    startDrawing(startX, startY) {
        this.points.push([startX, startY]);
        
        if (this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE)
            this.points.push([startX, startY]);
    }
    
    updateDrawing(x, y, transform) {
        let points = this.points;
        if (x == points[points.length - 1][0] && y == points[points.length - 1][1])
            return;
        
        transform = transform || this.transformations.length >= 1;
        
        if (this.shape == Shape.NONE) {
            points.push([x, y]);
            if (transform)
                this._smooth(points.length - 1);
            
        } else if ((this.shape == Shape.RECTANGLE || this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) && transform) {
            if (points.length < 2)
                return;
                
            let center = this._getOriginalCenter();
            this.transformations[0] = { type: Transformation.ROTATION,
                                        angle: getAngle(center[0], center[1], points[points.length - 1][0], points[points.length - 1][1], x, y) };
            
        } else if (this.shape == Shape.ELLIPSE && transform) {
            if (points.length < 2)
                return;
            
            points[2] = [x, y];
            let center = this._getOriginalCenter();
            this.transformations[0] = { type: Transformation.ROTATION,
                                        angle: getAngle(center[0], center[1], center[0] + 1, center[1], x, y) };
            
        } else if (this.shape == Shape.POLYGON || this.shape == Shape.POLYLINE) {
            points[points.length - 1] = [x, y];
            
        } else {
            points[1] = [x, y];
            
        }
    }
    
    stopDrawing() {
        // skip when the size is too small to be visible (3px) (except for free drawing)
        if (this.shape != Shape.NONE && this.points.length >= 2) {
            let lastPoint = this.points[this.points.length - 1];
            let secondToLastPoint = this.points[this.points.length - 2];
            if (getNearness(secondToLastPoint, lastPoint, MIN_DRAWING_SIZE))
                this.points.pop();
        }
        
        if (this.transformations[0] && this.transformations[0].type == Transformation.ROTATION &&
                Math.abs(this.transformations[0].angle) < MIN_ROTATION_ANGLE)
            this.transformations.shift();
    }
    
    startTransformation(startX, startY, type, undoable) {
        if (type == Transformation.TRANSLATION)
            this.transformations.push({ startX, startY, type, undoable, slideX: 0, slideY: 0 });
        else if (type == Transformation.ROTATION)
            this.transformations.push({ startX, startY, type, undoable, angle: 0 });
        else if (type == Transformation.SCALE_PRESERVE || type == Transformation.STRETCH)
            this.transformations.push({ startX, startY, type, undoable, scaleX: 1, scaleY: 1, angle: 0 });
        else if (type == Transformation.REFLECTION)
            this.transformations.push({ startX, startY, endX: startX, endY: startY, type, undoable,
                                        scaleX:  1, scaleY:  1, slideX: 0, slideY: 0, angle: 0 });
        else if (type == Transformation.INVERSION)
            this.transformations.push({ startX, startY, endX: startX, endY: startY, type, undoable,
                                        scaleX: -1, scaleY: -1, slideX: startX, slideY: startY,
                                        angle: Math.PI + Math.atan(startY / (startX || 1)) });
        
        if (type == Transformation.REFLECTION || type == Transformation.INVERSION)
            this.showSymmetryElement = true;
        else if (type == Transformation.ROTATION)
            this.showRotationCenter = true;
        else if (type == Transformation.STRETCH)
            this.showStretchAxes = true;
    }
    
    updateTransformation(x, y) {
        let transformation = this.lastTransformation;
        
        if (transformation.type == Transformation.TRANSLATION) {
            transformation.slideX = x - transformation.startX;
            transformation.slideY = y - transformation.startY;
        } else if (transformation.type == Transformation.ROTATION) {
            let center = this._getTransformedCenter(transformation);
            transformation.angle = getAngle(center[0], center[1], transformation.startX, transformation.startY, x, y);
        } else if (transformation.type == Transformation.SCALE_PRESERVE) {
            let center = this._getTransformedCenter(transformation);
            let scale = Math.hypot(x - center[0], y - center[1]) / Math.hypot(transformation.startX - center[0], transformation.startY - center[1]) || 1;
            [transformation.scaleX, transformation.scaleY] = [scale, scale];
        } else if (transformation.type == Transformation.STRETCH) {
            let center = this._getTransformedCenter(transformation);
            let startAngle = getAngle(center[0], center[1], center[0] + 1, center[1], transformation.startX, transformation.startY);
            let vertical = Math.abs(Math.sin(startAngle)) >= Math.sin(Math.PI / 2 - STRETCH_TOLERANCE);
            let horizontal = Math.abs(Math.cos(startAngle)) >= Math.cos(STRETCH_TOLERANCE);
            let scale = Math.hypot(x - center[0], y - center[1]) / Math.hypot(transformation.startX - center[0], transformation.startY - center[1]) || 1;
            transformation.scaleX = vertical ? 1 : scale;
            transformation.scaleY = !vertical ? 1 : scale;
            transformation.angle = vertical || horizontal ? 0 : getAngle(center[0], center[1], center[0] + 1, center[1], x, y);
        } else if (transformation.type == Transformation.REFLECTION) {
            [transformation.endX, transformation.endY] = [x, y];
            
            if (getNearness([transformation.startX, transformation.startY], [x, y], MIN_REFLECTION_LINE_LENGTH)) {
                // do nothing to avoid jumps (no transformation at starting and locked transformation after)
            } else if (Math.abs(y - transformation.startY) <= REFLECTION_TOLERANCE && Math.abs(x - transformation.startX) > REFLECTION_TOLERANCE) {
                // HORIZONTAL DRAG: Mirror across horizontal axis (flip top/bottom)
                [transformation.scaleX, transformation.scaleY] = [1, -1];
                [transformation.slideX, transformation.slideY] = [0, transformation.startY];
                transformation.angle = 0;  // ✅ FIXED: No rotation needed
                
            } else if (Math.abs(x - transformation.startX) <= REFLECTION_TOLERANCE && Math.abs(y - transformation.startY) > REFLECTION_TOLERANCE) {
                // VERTICAL DRAG: Mirror across vertical axis (flip left/right)
                [transformation.scaleX, transformation.scaleY] = [-1, 1];
                [transformation.slideX, transformation.slideY] = [transformation.startX, 0];
                transformation.angle = 0;  // ✅ FIXED: No rotation needed
                
            } else if (x != transformation.startX) {
                // DIAGONAL DRAG: Mirror across diagonal line
                let tan = (y - transformation.startY) / (x - transformation.startX);
                let reflectionAngle = Math.atan(tan);
                
                [transformation.scaleX, transformation.scaleY] = [1, -1];
                [transformation.slideX, transformation.slideY] = [0, transformation.startY - transformation.startX * tan];
                transformation.angle = reflectionAngle;  // ✅ FIXED: Use actual angle only
                
            } else if (y != transformation.startY) {
                // DIAGONAL DRAG: Mirror across diagonal line (vertical case)
                let tan = (x - transformation.startX) / (y - transformation.startY);
                let reflectionAngle = Math.atan(tan);
                
                [transformation.scaleX, transformation.scaleY] = [-1, 1];
                [transformation.slideX, transformation.slideY] = [transformation.startX - transformation.startY * tan, 0];
                transformation.angle = -reflectionAngle;  // ✅ FIXED: Use actual angle only
            }

        } else if (transformation.type == Transformation.INVERSION) {
            [transformation.endX, transformation.endY] = [x, y];
            [transformation.scaleX, transformation.scaleY] = [-1, -1];
            [transformation.slideX, transformation.slideY] = [x, y];
            transformation.angle = Math.PI + Math.atan(y / (x || 1));
        }
    }
    
    stopTransformation() {
        this.showSymmetryElement = false;
        this.showRotationCenter = false;
        this.showStretchAxes = false;
        
        // Clean transformations
        let transformation = this.lastTransformation;
        if (!transformation)
            return;
        
        if (transformation.type == Transformation.REFLECTION &&
                getNearness([transformation.startX, transformation.startY], [transformation.endX, transformation.endY], MIN_REFLECTION_LINE_LENGTH) ||
            transformation.type == Transformation.TRANSLATION && Math.hypot(transformation.slideX, transformation.slideY) < MIN_TRANSLATION_DISTANCE ||
            transformation.type == Transformation.ROTATION && Math.abs(transformation.angle) < MIN_ROTATION_ANGLE) {
            
            this.transformations.pop();
        } else {
            delete transformation.startX;
            delete transformation.startY;
            delete transformation.endX;
            delete transformation.endY;
        }
    }
    
    undoTransformation() {
        if (this.transformations && this.transformations.length) {
            // Do not undo initial transformations (transformations made during the drawing step).
            if (!this.lastTransformation.undoable)
                return false;
            
            if (!this._undoneTransformations)
                this._undoneTransformations = [];
            
            let transformation = this.transformations.pop();
            if (transformation.type == Transformation.SMOOTH)
                transformation.undo();
            
            this._undoneTransformations.push(transformation);
            
            return true;
        }
        
        return false;
    }
    
    redoTransformation() {
        if (this._undoneTransformations && this._undoneTransformations.length) {
            if (!this.transformations)
                this.transformations = [];
            
            let transformation = this._undoneTransformations.pop();
            if (transformation.type == Transformation.SMOOTH)
                transformation.redo();
            
            this.transformations.push(transformation);
            
            return true;
        }
        
        return false;
    }
    
    resetUndoneTransformations() {
        delete this._undoneTransformations;
    }
    
    get canUndo() {
        return this._undoneTransformations && this._undoneTransformations.length ? true : false;
    }
    
    // The figure rotation center before transformations (original).
    _getOriginalCenter() {
        if (!this._originalCenter) {
            let points = this.points;
            this._originalCenter = this.shape == Shape.ELLIPSE ? [points[0][0], points[0][1]] :
                                   this.shape == Shape.LINE && points.length == 4 ? getCurveCenter(points[0], points[1], points[2], points[3]) :
                                   this.shape == Shape.LINE && points.length == 3 ? getCurveCenter(points[0], points[0], points[1], points[2]) :
                                   points.length >= 3 ? getCentroid(points) :
                                   getNaiveCenter(points);
        }
        
        return this._originalCenter;
    }
    
    // The figure rotation center, whose position is affected by all transformations done before 'transformation'.
    _getTransformedCenter(transformation) {
        if (!transformation.elementTransformedCenter) {
            let matrix = new Pango.Matrix({ xx: 1, xy: 0, yx: 0, yy: 1, x0: 0, y0: 0 });
            
            // Apply transformations to the matrice in reverse order
            // because Pango multiply matrices by the left when applying a transformation
            this.transformations.slice(0, this.transformations.indexOf(transformation)).reverse().forEach(transformation => {
                if (transformation.type == Transformation.TRANSLATION) {
                    matrix.translate(transformation.slideX, transformation.slideY);
                } else if (transformation.type == Transformation.ROTATION) {
                    // nothing, the center position is preserved.
                } else if (transformation.type == Transformation.SCALE_PRESERVE || transformation.type == Transformation.STRETCH) {
                    // nothing, the center position is preserved.
                } else if (transformation.type == Transformation.REFLECTION || transformation.type == Transformation.INVERSION) {
                    matrix.translate(transformation.slideX, transformation.slideY);
                    matrix.rotate(-transformation.angle * RADIAN);
                    matrix.scale(transformation.scaleX, transformation.scaleY);
                    matrix.rotate(transformation.angle * RADIAN);
                    matrix.translate(-transformation.slideX, -transformation.slideY);
                }
            });
            
            let originalCenter = this._getOriginalCenter();
            transformation.elementTransformedCenter = matrix.transform_point(originalCenter[0], originalCenter[1]);
        }
        
        return transformation.elementTransformedCenter;
    }
    
    _smooth(i) {
        if (i < 2)
            return;
        this.points[i-1] = [(this.points[i-2][0] + this.points[i][0]) / 2, (this.points[i-2][1] + this.points[i][1]) / 2];
    }
});

const TextElement = GObject.registerClass({
    GTypeName: `${UUID}-TextElement`,
}, class TextElement extends _DrawingElement{
    
    toJSON() {
        // The font size is useless because it is always computed from the points during cairo/svg building.
        this.font.unset_fields(Pango.FontMask.SIZE);
        
        return {
            shape: this.shape,
            color: this.color,
            eraser: this.eraser,
            transformations: this.transformations,
            text: this.text,
            textAlignment: this.textAlignment,
            font: this.font.to_string(),
            points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100])
        };
    }
    
    get x() {
        // this.textWidth is computed during Cairo building.
        let offset = this.textAlignment == TextAlignment.RIGHT ? this.textWidth :
                     this.textAlignment == TextAlignment.CENTER ? this.textWidth / 2 :
                     0;
        
        return this.points[1][0] - offset;
    }
    
    get y() {
        return Math.max(this.points[0][1], this.points[1][1]);
    }
    
    get height() {
        return Math.abs(this.points[1][1] - this.points[0][1]);
    }
    
    // this.lineWidths is computed during Cairo building.
    _getLineX(index) {
        let offset = this.textAlignment == TextAlignment.RIGHT && this.lineWidths && this.lineWidths[index] ? this.lineWidths[index] :
                     this.textAlignment == TextAlignment.CENTER && this.lineWidths && this.lineWidths[index] ? this.lineWidths[index] / 2 :
                     0;
        
        return this.points[1][0] - offset;
    }
    
    _drawCairo(cr, params) {
        if (this.points.length != 2)
            return;
        
        let layout = PangoCairo.create_layout(cr);
        let fontSize = this.height * Pango.SCALE;
        this.font.set_absolute_size(fontSize);
        layout.set_font_description(this.font);
        layout.set_text(this.text, -1);
        this.textWidth = layout.get_pixel_size()[0];
        this.lineWidths = layout.get_lines_readonly().map(layoutLine => layoutLine.get_pixel_extents()[1].width);
        
        layout.get_lines_readonly().forEach((layoutLine, index) => {
            cr.moveTo(this._getLineX(index), this.y + this.height * index);
            PangoCairo.show_layout_line(cr, layoutLine);
        });
        
        // Cannot use 'layout.index_to_line_x(cursorPosition, 0)' because character position != byte index
        if (params.showTextCursor) {
            let layoutCopy = layout.copy();
            if (this.cursorPosition != -1)
                layoutCopy.set_text(this.text.slice(0, this.cursorPosition), -1);
            
            let cursorLineIndex = layoutCopy.get_line_count() - 1;
            let cursorX = this._getLineX(cursorLineIndex) + layoutCopy.get_line_readonly(cursorLineIndex).get_pixel_extents()[1].width;
            let cursorY = this.y + this.height * cursorLineIndex;
            cr.rectangle(cursorX, cursorY, this.height / 25, - this.height);
            cr.fill();
        }
        
        if (params.showElementBounds) {
            cr.rectangle(this.x, this.y - this.height,
                         this.textWidth, this.height * layout.get_line_count());
            setDummyStroke(cr);
        } else if (params.drawElementBounds) {
            cr.rectangle(this.x, this.y - this.height,
                         this.textWidth, this.height * layout.get_line_count());
            // Only draw the rectangle to find the element, not to show it.
            cr.setLineWidth(0);
        }
    }
    
    getContainsPoint(cr, x, y) {
        return cr.inFill(x, y);
    }
    
    _drawSvg(transAttribute, bgcolorString) {
        if (this.points.length != 2)
            return "";
        
        let row = "";
        let height = Math.round(this.height * 100) / 100;
        let color = this.eraser ? bgcolorString : this.color.toJSON();
        let attributes = this.eraser ? `class="eraser" ` : '';
        attributes += `fill="${color}" ` +
                      `font-size="${height}" ` +
                      `font-family="${this.font.get_family()}"`;
        
        // this.font.to_string() is not valid to fill the svg 'font' shorthand property.
        // Each property must be filled separately.
        ['Stretch', 'Style', 'Variant'].forEach(attribute => {
            let lower = attribute.toLowerCase();
            if (this.font[`get_${lower}`]() != Pango[attribute].NORMAL) {
                let font = new Pango.FontDescription();
                font[`set_${lower}`](this.font[`get_${lower}`]());
                attributes += ` font-${lower}="${font.to_string()}"`;
            }
        });
        if (this.font.get_weight() != Pango.Weight.NORMAL)
            attributes += ` font-weight="${this.font.get_weight()}"`;
        
        // It is a fallback for thumbnails. The following layout is not the same than the Cairo one and
        // layoutLine.get_pixel_extents does not return the correct value with non-latin fonts.
        // An alternative would be to store this.lineWidths in the json.
        if (this.textAlignment != TextAlignment.LEFT && !this.lineWidths) {
            let clutterText = new Clutter.Text({ text: this.text });
            let layout = clutterText.get_layout();
            let fontSize = height * Pango.SCALE;
            this.font.set_absolute_size(fontSize);
            layout.set_font_description(this.font);
            this.lineWidths = layout.get_lines_readonly().map(layoutLine => layoutLine.get_pixel_extents()[1].width);
        }
        
        this.text.split(/\r\n|\r|\n/).forEach((text, index) => {
            let x = Math.round(this._getLineX(index) * 100) / 100;
            let y = Math.round((this.y + this.height * index) * 100) / 100;
            row += `\n <text ${attributes} x="${x}" y="${y}"${transAttribute}>${text}</text>`;
        });
        
        return row;
    }
    
    updateDrawing(x, y, transform) {
        let points = this.points;
        if (x == points[points.length - 1][0] && y == points[points.length - 1][1])
            return;
        
        transform = transform || this.transformations.length >= 1;
        
        if (transform) {
           if (points.length < 2)
                return;
        
            let [slideX, slideY] = [x - points[1][0], y - points[1][1]];
            points[0] = [points[0][0] + slideX, points[0][1] + slideY];
            points[1] = [x, y];
        
        } else {
            points[1] = [x, y];
            
        }
    }
    
    _getOriginalCenter() {
        if (!this._originalCenter) {
            let points = this.points;
            this._originalCenter = points.length == 2 ? [points[1][0], Math.max(points[0][1], points[1][1])] :
                                   points.length >= 3 ? getCentroid(points) :
                                   getNaiveCenter(points);
        }
        
        return this._originalCenter;
    }
});

const ImageElement = GObject.registerClass({
    GTypeName: `${UUID}-ImageElement`,
},  class ImageElement extends _DrawingElement{
    
    toJSON() {
        return {
            shape: this.shape,
            color: this.color,
            colored: this.colored,
            transformations: this.transformations,
            image: this.image,
            preserveAspectRatio: this.preserveAspectRatio,
            points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100])
        };
    }
    
    _drawCairo(cr, params) {
        if (this.points.length < 2)
            return;
        
        let points = this.points;
        let [x, y] = [Math.min(points[0][0], points[1][0]), Math.min(points[0][1], points[1][1])];
        let [width, height] = [Math.abs(points[1][0] - points[0][0]), Math.abs(points[1][1] - points[0][1])];
        
        if (width < 1 || height < 1)
            return;
        
        cr.save();
        this.image.setCairoSource(cr, x, y, width, height, this.preserveAspectRatio, this.colored ? this.color.toJSON() : null);
        // Image is already painted by setCairoSource - no need to fill
        cr.restore();
        
        if (params.showElementBounds) {
            cr.rectangle(x, y, width, height);
            setDummyStroke(cr);
        } else if (params.drawElementBounds) {
            cr.rectangle(x, y, width, height);
            // Only draw the rectangle to find the element, not to show it.
            cr.setLineWidth(0);
        }
    }
    
    getContainsPoint(cr, x, y) {
        return cr.inFill(x, y);
    }
    
    _drawSvg(transAttribute) {
        let points = this.points;
        let row = "\n  ";
        let attributes = '';
        
        // GNOME 48.5 fix: Check if image exists before accessing
        if (!this.image) {            
            return row; // Return empty row
        }
        
        if (points.length == 2) {
            attributes += `fill="none"`;
            let base64 = this.image.getBase64ForColor(this.colored ? this.color.toJSON() : null);
            row += `<image ${attributes} x="${Math.min(points[0][0], points[1][0])}" y="${Math.min(points[0][1], points[1][1])}" ` +
                `width="${Math.abs(points[1][0] - points[0][0])}" height="${Math.abs(points[1][1] - points[0][1])}"${transAttribute} ` +
                `preserveAspectRatio="${this.preserveAspectRatio ? 'xMinYMin' : 'none'}" ` +
                `id="${this.image.displayName}" xlink:href="data:${this.image.contentType};base64,${base64}"/>`;
        }
        
        return row;
    }
    
    updateDrawing(x, y, transform) {
        let points = this.points;
        if (x == points[0][0] || y == points[0][1])
            return;
        
        points[1] = [x, y];
        this.preserveAspectRatio = !transform;
    }
});

const setDummyStroke = function(cr) {
    cr.setLineWidth(2);
    cr.setLineCap(0);
    cr.setLineJoin(0);
    cr.setDash([1, 2], 0);
};

const getNearness = function(pointA, pointB, distance) {
    return Math.hypot(pointB[0] - pointA[0], pointB[1] - pointA[1]) < distance;
};

// mean of the vertices, ok for regular polygons
const getNaiveCenter = function(points) {
    return points.reduce((accumulator, point) => accumulator = [accumulator[0] + point[0], accumulator[1] + point[1]])
                 .map(coord => coord / points.length);
};

// https://en.wikipedia.org/wiki/Centroid#Of_a_polygon
const getCentroid = function(points) {
    let n = points.length;
    points.push(points[0]);
    
    let [sA, sX, sY] = [0, 0, 0];
    for (let i = 0; i <= n-1; i++) {
        let a = points[i][0]*points[i+1][1] - points[i+1][0]*points[i][1];
        sA += a;
        sX += (points[i][0] + points[i+1][0]) * a;
        sY += (points[i][1] + points[i+1][1]) * a;
    }
    
    points.pop();
    if (sA == 0)
        return getNaiveCenter(points);
    return [sX / (3 * sA), sY / (3 * sA)];
};

/*
Cubic Bézier:
[0, 1] -> ℝ², P(t) = (1-t)³P₀ + 3t(1-t)²P₁ + 3t²(1-t)P₂ + t³P₃

general case:

const cubicBezierCoord = function(x0, x1, x2, x3, t) {
    return (1-t)**3*x0 + 3*t*(1-t)**2*x1 + 3*t**2*(1-t)*x2 + t**3*x3;
}

const cubicBezierPoint = function(p0, p1, p2, p3, t) {
    return [cubicBezier(p0[0], p1[0], p2[0], p3[0], t), cubicBezier(p0[1], p1[1], p2[1], p3[1], t)];
}

Approximatively: 
control point: p0 ----  p1  ----  p2  ----  p3  (p2 is not on the curve)
            t: 0  ---- 1/3  ---- 2/3  ----  1
*/

// If the curve has a symmetry axis, it is truly a center (the intersection of the curve and the axis).
// In other cases, it is not a notable point, just a visual approximation.
const getCurveCenter = function(p0, p1, p2, p3) {
    if (p0[0] == p1[0] && p0[1] == p1[1])
        // p0 = p1, t = 2/3
        return [(p1[0] + 6*p1[0] + 12*p2[0] + 8*p3[0]) / 27, (p1[1] + 6*p1[1] + 12*p2[1] + 8*p3[1]) / 27];
    else
        // t = 1/2
        return [(p0[0] + 3*p1[0] + 3*p2[0] + p3[0]) / 8, (p0[1] + 3*p1[1] + 3*p2[1] + p3[1]) / 8];
};

const getAngle = function(xO, yO, xA, yA, xB, yB) {
    // calculate angle of rotation in absolute value
    // cos(AOB) = (OA.OB)/(||OA||*||OB||) where OA.OB = (xA-xO)*(xB-xO) + (yA-yO)*(yB-yO)
    let cos = ((xA - xO)*(xB - xO) + (yA - yO)*(yB - yO)) / (Math.hypot(xA - xO, yA - yO) * Math.hypot(xB - xO, yB - yO));
    
    // acos is defined on [-1, 1] but
    // with A == B and imperfect computer calculations, cos may be equal to 1.00000001.
    cos = Math.min(Math.max(-1, cos), 1);
    let angle = Math.acos( cos );
    
    // determine the sign of the angle
    if (xA == xO) {
        if (xB > xO)
            angle = -angle;
    } else {
        // equation of OA: y = ax + b
        let a = (yA - yO) / (xA - xO);
        let b = yA - a*xA;
        if (yB < a*xB + b)
            angle = - angle;
        if (xA < xO)
            angle = - angle;
    }
    
    return angle;
};

