diff --git a/elements.js b/elements.js new file mode 100644 index 0000000..9e531d2 --- /dev/null +++ b/elements.js @@ -0,0 +1,754 @@ +/* jslint esversion: 6 */ + +/* + * Copyright 2019 Abakkk + * + * This file is part of DrawOnYourScreen, a drawing extension for GNOME Shell. + * https://framagit.org/abakkk/DrawOnYourScreen + * + * 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 2 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 . + */ + +const Cairo = imports.cairo; +const Clutter = imports.gi.Clutter; +const Lang = imports.lang; +const Pango = imports.gi.Pango; +const PangoCairo = imports.gi.PangoCairo; + +const ExtensionUtils = imports.misc.extensionUtils; +const Me = ExtensionUtils.getCurrentExtension(); +const _ = imports.gettext.domain(Me.metadata['gettext-domain']).gettext; + +const SVG_DEBUG_SUPERPOSES_CAIRO = false; + +const reverseEnumeration = function(obj) { + let reversed = {}; + Object.keys(obj).forEach(key => { + reversed[obj[key]] = key.slice(0,1) + key.slice(1).toLowerCase().replace('_', '-'); + }); + return reversed; +}; + +const Shapes = { NONE: 0, LINE: 1, ELLIPSE: 2, RECTANGLE: 3, TEXT: 4, POLYGON: 5, POLYLINE: 6 }; +const Manipulations = { MOVE: 100, RESIZE: 101, MIRROR: 102 }; +const Transformations = { TRANSLATION: 0, ROTATION: 1, SCALE_PRESERVE: 2, STRETCH: 3, REFLECTION: 4, INVERSION: 5 }; +var Tools = Object.assign({}, Shapes, Manipulations); +var ToolNames = { 0: "Free drawing", 1: "Line", 2: "Ellipse", 3: "Rectangle", 4: "Text", 5: "Polygon", 6: "Polyline", 100: "Move", 101: "Resize", 102: "Mirror" }; +var LineCapNames = Object.assign(reverseEnumeration(Cairo.LineCap), { 2: 'Square' }); +var LineJoinNames = reverseEnumeration(Cairo.LineJoin); +var FillRuleNames = { 0: 'Nonzero', 1: 'Evenodd' }; +var FontGenericNames = { 0: 'Theme', 1: 'Sans-Serif', 2: 'Serif', 3: 'Monospace', 4: 'Cursive', 5: 'Fantasy' }; +var FontWeightNames = Object.assign(reverseEnumeration(Pango.Weight), { 200: "Ultra-light", 350: "Semi-light", 600: "Semi-bold", 800: "Ultra-bold" }); +delete FontWeightNames[Pango.Weight.ULTRAHEAVY]; +var FontStyleNames = reverseEnumeration(Pango.Style); +var FontStretchNames = reverseEnumeration(Pango.Stretch); +var FontVariantNames = reverseEnumeration(Pango.Variant); + +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 + +// 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 = new Lang.Class({ + Name: 'DrawOnYourScreenDrawingElement', + + _init: function(params) { + for (let key in params) + this[key] = params[key]; + + // compatibility with json generated by old extension versions + + if (params.fillRule === undefined) + this.fillRule = Cairo.FillRule.WINDING; + if (params.transformations === undefined) + this.transformations = []; + if (params.shape == Shapes.TEXT) { + if (params.font && params.font.weight === 0) + this.font.weight = 400; + if (params.font && params.font.weight === 1) + this.font.weight = 700; + } + + if (params.transform && params.transform.center) { + let angle = (params.transform.angle || 0) + (params.transform.startAngle || 0); + if (angle) + this.transformations.push({ type: Transformations.ROTATION, angle: angle }); + } + if (params.shape == Shapes.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; + }, + + // toJSON is called by JSON.stringify + toJSON: function() { + 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, + text: this.text, + lineIndex: this.lineIndex !== undefined ? this.lineIndex : undefined, + textRightAligned: this.textRightAligned, + font: this.font, + points: this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]) + }; + }, + + buildCairo: function(cr, params) { + let [success, color] = Clutter.Color.from_string(this.color); + if (success) + Clutter.cairo_set_source_color(cr, color); + + if (this.showSymmetryElement) { + let transformation = this.lastTransformation; + setDummyStroke(cr); + if (transformation.type == Transformations.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(); + } + + 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) { + Clutter.cairo_set_source_color(cr, Clutter.Color.new(255, 0, 0, 255)); + cr.setLineWidth(this.line.lineWidth / 2 || 1); + } + + this.transformations.slice(0).reverse().forEach(transformation => { + if (transformation.type == Transformations.TRANSLATION) { + cr.translate(transformation.slideX, transformation.slideY); + } else if (transformation.type == Transformations.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 == Transformations.SCALE_PRESERVE || transformation.type == Transformations.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 == Transformations.REFLECTION || transformation.type == Transformations.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); + } + }); + + let [points, shape] = [this.points, this.shape]; + + if (shape == Shapes.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 == Shapes.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 == Shapes.NONE || shape == Shapes.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 == Shapes.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 == Shapes.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 == Shapes.POLYGON || shape == Shapes.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 == Shapes.POLYGON) + cr.closePath(); + + } else if (shape == Shapes.TEXT && points.length == 2) { + let layout = PangoCairo.create_layout(cr); + let fontSize = Math.abs(points[1][1] - points[0][1]) * Pango.SCALE; + let fontDescription = new Pango.FontDescription(); + fontDescription.set_absolute_size(fontSize); + ['family', 'weight', 'style', 'stretch', 'variant'].forEach(attribute => { + if (this.font[attribute] !== undefined) + try { + fontDescription[`set_${attribute}`](this.font[attribute]); + } catch(e) {} + }); + layout.set_font_description(fontDescription); + layout.set_text(this.text, -1); + this.textWidth = layout.get_pixel_size()[0]; + cr.moveTo(points[1][0] - (this.textRightAligned ? this.textWidth : 0), Math.max(points[0][1],points[1][1]) - layout.get_baseline() / Pango.SCALE); + layout.set_text(this.text, -1); + PangoCairo.show_layout(cr, layout); + + if (params.showTextCursor) { + let cursorPosition = this.cursorPosition == -1 ? this.text.length : this.cursorPosition; + layout.set_text(this.text.slice(0, cursorPosition), -1); + let width = layout.get_pixel_size()[0]; + cr.rectangle(points[1][0] - (this.textRightAligned ? this.textWidth : 0) + width, Math.max(points[0][1],points[1][1]), + Math.abs(points[1][1] - points[0][1]) / 25, - Math.abs(points[1][1] - points[0][1])); + cr.fill(); + } + + if (params.showTextRectangle || params.drawTextRectangle) { + cr.rectangle(points[1][0] - (this.textRightAligned ? this.textWidth : 0), Math.max(points[0][1], points[1][1]), + this.textWidth, - Math.abs(points[1][1] - points[0][1])); + if (params.showTextRectangle) + setDummyStroke(cr); + else + // Only draw the rectangle to find the element, not to show it. + cr.setLineWidth(0); + } + } + + cr.identityMatrix(); + }, + + getContainsPoint: function(cr, x, y) { + if (this.shape == Shapes.TEXT) + return cr.inFill(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: function(bgColor) { + let row = "\n "; + let points = this.points.map((point) => [Math.round(point[0]*100)/100, Math.round(point[1]*100)/100]); + let color = this.eraser ? bgColor : this.color; + let fill = this.fill && !this.isStraightLine; + let attributes = ''; + + if (fill) { + attributes = `fill="${color}"`; + if (this.fillRule) + attributes += ` fill-rule="${FillRuleNames[this.fillRule].toLowerCase()}"`; + } 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="${LineCapNames[this.line.lineCap].toLowerCase()}"`; + if (this.line.lineJoin && !this.isStraightLine) + attributes += ` stroke-linejoin="${LineJoinNames[this.line.lineJoin].toLowerCase()}"`; + 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}"`; + } else { + attributes += ` stroke="none"`; + } + + let transAttribute = ''; + this.transformations.slice(0).reverse().forEach(transformation => { + transAttribute += transAttribute ? ' ' : ' transform="'; + let center = this._getTransformedCenter(transformation); + + if (transformation.type == Transformations.TRANSLATION) { + transAttribute += `translate(${transformation.slideX},${transformation.slideY})`; + } else if (transformation.type == Transformations.ROTATION) { + transAttribute += `translate(${center[0]},${center[1]}) `; + transAttribute += `rotate(${transformation.angle * RADIAN}) `; + transAttribute += `translate(${-center[0]},${-center[1]})`; + } else if (transformation.type == Transformations.SCALE_PRESERVE || transformation.type == Transformations.STRETCH) { + transAttribute += `translate(${center[0]},${center[1]}) `; + transAttribute += `rotate(${transformation.angle * RADIAN}) `; + transAttribute += `scale(${transformation.scaleX},${transformation.scaleY}) `; + transAttribute += `rotate(${-transformation.angle * RADIAN}) `; + transAttribute += `translate(${-center[0]},${-center[1]})`; + } else if (transformation.type == Transformations.REFLECTION || transformation.type == Transformations.INVERSION) { + transAttribute += `translate(${transformation.slideX}, ${transformation.slideY}) `; + transAttribute += `rotate(${transformation.angle * RADIAN}) `; + transAttribute += `scale(${transformation.scaleX}, ${transformation.scaleY}) `; + transAttribute += `rotate(${-transformation.angle * RADIAN}) `; + transAttribute += `translate(${-transformation.slideX}, ${-transformation.slideY})`; + } + }); + transAttribute += transAttribute ? '"' : ''; + + if (this.shape == Shapes.LINE && points.length == 4) { + row += ``; + + } else if (this.shape == Shapes.LINE && points.length == 3) { + row += ``; + + } else if (this.shape == Shapes.LINE) { + row += ``; + + } else if (this.shape == Shapes.NONE) { + row += ``; + + } else if (this.shape == Shapes.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 += ``; + + } else if (this.shape == Shapes.ELLIPSE && points.length == 2) { + let r = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1]); + row += ``; + + } else if (this.shape == Shapes.RECTANGLE && points.length == 2) { + row += ``; + + } else if (this.shape == Shapes.POLYGON && points.length >= 3) { + row += ``; + + } else if (this.shape == Shapes.POLYLINE && points.length >= 2) { + row += ``; + + } else if (this.shape == Shapes.TEXT && points.length == 2) { + attributes = `fill="${color}" ` + + `stroke="transparent" ` + + `stroke-opacity="0" ` + + `font-size="${Math.abs(points[1][1] - points[0][1])}"`; + + if (this.font.family) + attributes += ` font-family="${this.font.family}"`; + if (this.font.weight && this.font.weight != Pango.Weight.NORMAL) + attributes += ` font-weight="${this.font.weight}"`; + if (this.font.style && FontStyleNames[this.font.style]) + attributes += ` font-style="${FontStyleNames[this.font.style].toLowerCase()}"`; + if (FontStretchNames[this.font.stretch] && this.font.stretch != Pango.Stretch.NORMAL) + attributes += ` font-stretch="${FontStretchNames[this.font.stretch].toLowerCase()}"`; + if (this.font.variant && FontVariantNames[this.font.variant]) + attributes += ` font-variant="${FontVariantNames[this.font.variant].toLowerCase()}"`; + + // this.textWidth is computed during Cairo building. + row += `${this.text}`; + } + + return row; + }, + + get lastTransformation() { + if (!this.transformations.length) + return null; + + return this.transformations[this.transformations.length - 1]; + }, + + get isStraightLine() { + return this.shape == Shapes.LINE && this.points.length == 2; + }, + + smoothAll: function() { + for (let i = 0; i < this.points.length; i++) { + this._smooth(i); + } + }, + + addPoint: function() { + if (this.shape == Shapes.POLYGON || this.shape == Shapes.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 == Shapes.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]; + } + } + }, + + startDrawing: function(startX, startY) { + this.points.push([startX, startY]); + + if (this.shape == Shapes.POLYGON || this.shape == Shapes.POLYLINE) + this.points.push([startX, startY]); + }, + + updateDrawing: function(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 == Shapes.NONE) { + points.push([x, y]); + if (transform) + this._smooth(points.length - 1); + + } else if ((this.shape == Shapes.RECTANGLE || this.shape == Shapes.POLYGON || this.shape == Shapes.POLYLINE) && transform) { + if (points.length < 2) + return; + + let center = this._getOriginalCenter(); + this.transformations[0] = { type: Transformations.ROTATION, + angle: getAngle(center[0], center[1], points[points.length - 1][0], points[points.length - 1][1], x, y) }; + + } else if (this.shape == Shapes.ELLIPSE && transform) { + if (points.length < 2) + return; + + points[2] = [x, y]; + let center = this._getOriginalCenter(); + this.transformations[0] = { type: Transformations.ROTATION, + angle: getAngle(center[0], center[1], center[0] + 1, center[1], x, y) }; + + } else if (this.shape == Shapes.POLYGON || this.shape == Shapes.POLYLINE) { + points[points.length - 1] = [x, y]; + + } else if (this.shape == Shapes.TEXT && 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]; + + } + }, + + stopDrawing: function() { + // skip when the size is too small to be visible (3px) (except for free drawing) + if (this.shape != Shapes.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 == Transformations.ROTATION && + Math.abs(this.transformations[0].angle) < MIN_ROTATION_ANGLE) + this.transformations.shift(); + }, + + startTransformation: function(startX, startY, type) { + if (type == Transformations.TRANSLATION) + this.transformations.push({ startX: startX, startY: startY, type: type, slideX: 0, slideY: 0 }); + else if (type == Transformations.ROTATION) + this.transformations.push({ startX: startX, startY: startY, type: type, angle: 0 }); + else if (type == Transformations.SCALE_PRESERVE || type == Transformations.STRETCH) + this.transformations.push({ startX: startX, startY: startY, type: type, scaleX: 1, scaleY: 1, angle: 0 }); + else if (type == Transformations.REFLECTION) + this.transformations.push({ startX: startX, startY: startY, endX: startX, endY: startY, type: type, + scaleX: 1, scaleY: 1, slideX: 0, slideY: 0, angle: 0 }); + else if (type == Transformations.INVERSION) + this.transformations.push({ startX: startX, startY: startY, endX: startX, endY: startY, type: type, + scaleX: -1, scaleY: -1, slideX: startX, slideY: startY, + angle: Math.PI + Math.atan(startY / (startX || 1)) }); + + if (type == Transformations.REFLECTION || type == Transformations.INVERSION) + this.showSymmetryElement = true; + }, + + updateTransformation: function(x, y) { + let transformation = this.lastTransformation; + + if (transformation.type == Transformations.TRANSLATION) { + transformation.slideX = x - transformation.startX; + transformation.slideY = y - transformation.startY; + } else if (transformation.type == Transformations.ROTATION) { + let center = this._getTransformedCenter(transformation); + transformation.angle = getAngle(center[0], center[1], transformation.startX, transformation.startY, x, y); + } else if (transformation.type == Transformations.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 == Transformations.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 == Transformations.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) { + [transformation.scaleX, transformation.scaleY] = [1, -1]; + [transformation.slideX, transformation.slideY] = [0, transformation.startY]; + transformation.angle = Math.PI; + } else if (Math.abs(x - transformation.startX) <= REFLECTION_TOLERANCE && Math.abs(y - transformation.startY) > REFLECTION_TOLERANCE) { + [transformation.scaleX, transformation.scaleY] = [-1, 1]; + [transformation.slideX, transformation.slideY] = [transformation.startX, 0]; + transformation.angle = Math.PI; + } else if (x != transformation.startX) { + let tan = (y - transformation.startY) / (x - transformation.startX); + [transformation.scaleX, transformation.scaleY] = [1, -1]; + [transformation.slideX, transformation.slideY] = [0, transformation.startY - transformation.startX * tan]; + transformation.angle = Math.PI + Math.atan(tan); + } else if (y != transformation.startY) { + let tan = (x - transformation.startX) / (y - transformation.startY); + [transformation.scaleX, transformation.scaleY] = [-1, 1]; + [transformation.slideX, transformation.slideY] = [transformation.startX - transformation.startY * tan, 0]; + transformation.angle = Math.PI - Math.atan(tan); + } + } else if (transformation.type == Transformations.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: function() { + // Clean transformations + let transformation = this.lastTransformation; + + if (transformation.type == Transformations.REFLECTION || transformation.type == Transformations.INVERSION) + this.showSymmetryElement = false; + + if (transformation.type == Transformations.REFLECTION && + getNearness([transformation.startX, transformation.startY], [transformation.endX, transformation.endY], MIN_REFLECTION_LINE_LENGTH) || + transformation.type == Transformations.TRANSLATION && Math.hypot(transformation.slideX, transformation.slideY) < MIN_TRANSLATION_DISTANCE || + transformation.type == Transformations.ROTATION && Math.abs(transformation.angle) < MIN_ROTATION_ANGLE) { + + this.transformations.pop(); + } else { + delete transformation.startX; + delete transformation.startY; + delete transformation.endX; + delete transformation.endY; + } + }, + + // When rotating grouped lines, lineOffset is used to retrieve the rotation center of the first line. + _getLineOffset: function() { + return (this.lineIndex || 0) * Math.abs(this.points[1][1] - this.points[0][1]); + }, + + // The figure rotation center before transformations (original). + // this.textWidth is computed during Cairo building. + _getOriginalCenter: function() { + if (!this._originalCenter) { + let points = this.points; + this._originalCenter = this.shape == Shapes.ELLIPSE ? [points[0][0], points[0][1]] : + this.shape == Shapes.LINE && points.length == 4 ? getCurveCenter(points[0], points[1], points[2], points[3]) : + this.shape == Shapes.LINE && points.length == 3 ? getCurveCenter(points[0], points[0], points[1], points[2]) : + this.shape == Shapes.TEXT && this.textWidth ? [points[1][0], Math.max(points[0][1], points[1][1]) - this._getLineOffset()] : + 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: function(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 == Transformations.TRANSLATION) { + matrix.translate(transformation.slideX, transformation.slideY); + } else if (transformation.type == Transformations.ROTATION) { + // nothing, the center position is preserved. + } else if (transformation.type == Transformations.SCALE_PRESERVE || transformation.type == Transformations.STRETCH) { + // nothing, the center position is preserved. + } else if (transformation.type == Transformations.REFLECTION || transformation.type == Transformations.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: function(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 setDummyStroke = function(cr) { + cr.setLineWidth(2); + cr.setLineCap(0); + cr.setLineJoin(0); + cr.setDash([1, 2], 0); +}; + +/* + Some geometric utils +*/ + +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; +}; +