base/Path.js

/*
 * Copyright 2020 WICKLETS LLC
 *
 * This file is part of Wick Engine.
 *
 * Wick Engine 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.
 *
 * Wick Engine 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 Wick Engine.  If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * Represents a Wick Path.
 */
Wick.Path = class extends Wick.Base {
    /**
     * Create a Wick Path.
     * @param {array} json - Path data exported from paper.js using exportJSON({asString:false}).
     * @param {Paper.Path} path - A Paper.js Path object to use as this Path's svg data. Optional if json was not passed in.
     */
    constructor(args) {
        if (!args) args = {};
        super(args);

        this._fontStyle = 'normal';
        this._fontWeight = 400;
        this._isPlaceholder = args.isPlaceholder;
        this._originalStyle = null;

        if(args.path) {
            this.json = args.path.exportJSON({asString:false});
        } else if(args.json) {
            this.json = args.json;
        } else {
            this.json = new paper.Path({ insert: false }).exportJSON({ asString: false });
        }

        this.needReimport = true;
    }

    /**
     * Create a path containing an image from an ImageAsset.
     * @param {Wick.ImageAsset} asset - The asset from which the image src will be loaded from
     * @param {Function} callback - A function that will be called when the image is done loading.
     */
    static createImagePath(asset, callback) {
        var img = new Image();
        img.src = asset.src;
        img.onload = () => {
            var raster = new paper.Raster(img);
            raster.remove();
            var path = new Wick.Path({
                json: Wick.View.Path.exportJSON(raster),
                project: asset.project,
            });
            callback(path);
        }
    }

    /**
     * Create a path (synchronously) containing an image from an ImageAsset.
     * @param {Wick.ImageAsset} asset - The asset from which the image src will be loaded from
     */
    static createImagePathSync(asset) {
        var raster = new paper.Raster(asset.src);
        raster.remove();
        var path = new Wick.Path({
            json: Wick.View.Path.exportJSON(raster),
            project: asset.project,
        });
        return path;
    }

    get classname() {
        return 'Path';
    }

    _serialize(args) {
        var data = super._serialize(args);

        data.json = this.json;
        delete data.json[1].data;

        // optimization: replace dataurls with asset uuids
        if (data.json[0] === 'Raster' && data.json[1].source.startsWith('data:')) {
            if (!this.project) {
                console.warn('Could not replace raster image source with asset UUID, path does not belong to a project.');
            } else {
                this.project.getAssets('Image').forEach(imageAsset => {
                    if (imageAsset.src === data.json[1].source) {
                        data.json[1].source = 'asset:' + imageAsset.uuid;
                    }
                })
            }
        }

        data.fontStyle = this._fontStyle;
        data.fontWeight = this._fontWeight;
        data.isPlaceholder = this._isPlaceholder;

        return data;
    }

    _deserialize(data) {
        super._deserialize(data);
        this.json = data.json;
        this._fontStyle = data.fontStyle || 'normal';
        this._fontWeight = data.fontWeight || 400;
        this._isPlaceholder = data.isPlaceholder;
    }

    /**
     * Determines if this Path is visible in the project.
     */
    get onScreen() {
        return this.parent.onScreen;
    }

    /**
     * The type of path that this path is. Can be 'path', 'text', or 'image'
     * @returns {string}
     */
    get pathType() {
        if (this.view.item instanceof paper.TextItem) {
            return 'text';
        } else if (this.view.item instanceof paper.Raster) {
            return 'image';
        } else {
            return 'path';
        }
    }

    /**
     * Path data exported from paper.js using exportJSON({asString:false}).
     * @type {object}
     */
    get json() {
        return this._json;
    }

    set json(json) {
        this._json = json;
        this.needReimport = true;
        this.view.render();
    }

    /**
     * The bounding box of the path.
     * @type {object}
     */
    get bounds() {
        var paperBounds = this.view.item.bounds;
        return {
            top: paperBounds.top,
            bottom: paperBounds.bottom,
            left: paperBounds.left,
            right: paperBounds.right,
            width: paperBounds.width,
            height: paperBounds.height,
        };
    }

    /**
     * The position of the path.
     * @type {number}
     */
    get x() {
        return this.view.item.position.x;
    }

    set x(x) {
        this.view.item.position.x = x;
        this.updateJSON();
    }

    /**
     * The position of the path.
     * @type {number}
     */
    get y() {
        return this.view.item.position.y;
    }

    set y(y) {
        this.view.item.position.y = y;
        this.updateJSON();
    }

    /**
     * The fill color of the path.
     * @type {paper.Color}
     */
    get fillColor() {
        return this.view.item.fillColor || new paper.Color();
    }

    set fillColor(fillColor) {
        this.view.item.fillColor = fillColor;
        this.updateJSON();
    }

    /**
     * The stroke color of the path.
     * @type {paper.Color}
     */
    get strokeColor() {
        return this.view.item.strokeColor || new paper.Color();
    }

    set strokeColor(strokeColor) {
        this.view.item.strokeColor = strokeColor;
        this.updateJSON();
    }

    /**
     * The stroke width of the path.
     * @type {number}
     */
    get strokeWidth() {
        return this.view.item.strokeWidth;
    }

    set strokeWidth(strokeWidth) {
        this.view.item.strokeWidth = strokeWidth;
        this.updateJSON();
    }

    /**
     * The opacity of the path.
     * @type {number}
     */
    get opacity() {
        if (this.view.item.opacity === undefined || this.view.item.opacity === null) {
            return 1.0;
        }
        return this.view.item.opacity;
    }

    set opacity(opacity) {
        this.view.item.opacity = opacity;
        this.updateJSON();
    }

    /**
     * The font family of the path.
     * @type {string}
     */
    get fontFamily() {
        return this.view.item.fontFamily
    }

    set fontFamily(fontFamily) {
        this.view.item.fontFamily = fontFamily;
        this.fontWeight = 400;
        this.fontStyle = 'normal';
        this.updateJSON();
    }

    /**
     * The font size of the path.
     * @type {number}
     */
    get fontSize() {
        return this.view.item.fontSize;
    }

    set fontSize(fontSize) {
        this.view.item.fontSize = fontSize;
        this.view.item.leading = fontSize * 1.2;
        this.updateJSON();
    }

    /**
     * The font weight of the path.
     * @type {number}
     */
    get fontWeight() {
        return this._fontWeight;
    }

    set fontWeight(fontWeight) {
        if (typeof fontWeight === 'string') {
            console.error('fontWeight must be a number.');
            return;
        }
        this._fontWeight = fontWeight
        this.updateJSON();
    }

    /**
     * The font style of the path ('italic' or 'oblique').
     * @type {string}
     */
    get fontStyle() {
        return this._fontStyle;
    }

    set fontStyle(fontStyle) {
        this._fontStyle = fontStyle;
        this.updateJSON();
    }

    /**
     * The original style of the path (used to recover the path's style if it was changed by a custom onion skin style)
     * @type {object}
     */
    get originalStyle () {
        return this._originalStyle;
    }

    set originalStyle (originalStyle) {
        this._originalStyle = originalStyle;
    }

    /**
     * The content of the text.
     * @type {string}
     */
    get textContent() {
        return this.view.item.content;
    }

    set textContent(textContent) {
        this.view.item.content = textContent;
    }

    /**
     * Update the JSON of the path based on the path on the view.
     */
    updateJSON() {
        this.json = this.view.exportJSON();
    }

    /**
     * API function to change the textContent of dynamic text paths.
     */
    setText(newTextContent) {
        this.textContent = newTextContent;
    }

    /**
     * Check if this path is a dynamic text object.
     * @type {boolean}
     */
    get isDynamicText() {
        return this.pathType === 'text' &&
            this.identifier !== null;
    }

    /**
     * The image asset that this path uses, if this path is a Raster path.
     * @returns {Wick.Asset[]}
     */
    //should this also return the SVGAsset if the path is loaded from an SVGAsset
    getLinkedAssets() {
        var linkedAssets = [];

        var data = this.serialize(); // just need the asset uuid...
        if (data.json[0] === 'Raster') {
            var uuid = data.json[1].source.split(':')[1];
            linkedAssets.push(this.project.getAssetByUUID(uuid));
        }

        return linkedAssets;
    }

    /**
     * Removes this path from its parent frame.
     */
    remove() {
        this.parentFrame.removePath(this);
    }

    /**
     * Creates a new path using boolean unite on multiple paths. The resulting path will use the fillColor, strokeWidth, and strokeColor of the first path in the array.
     * @param {Wick.Path[]} paths - an array containing the paths to process.
     * @returns {Wick.Path} The path resulting from the boolean unite.
     */
    static unite(paths) {
        return Wick.Path.booleanOp(paths, 'unite');
    }

    /**
     * Creates a new path using boolean subtration on multiple paths. The resulting path will use the fillColor, strokeWidth, and strokeColor of the first path in the array.
     * @param {Wick.Path[]} paths - an array containing the paths to process.
     * @returns {Wick.Path} The path resulting from the boolean subtraction.
     */
    static subtract(paths) {
        return Wick.Path.booleanOp(paths, 'subtract');
    }

    /**
     * Creates a new path using boolean intersection on multiple paths. The resulting path will use the fillColor, strokeWidth, and strokeColor of the first path in the array.
     * @param {Wick.Path[]} paths - an array containing the paths to process.
     * @returns {Wick.Path} The path resulting from the boolean intersection.
     */
    static intersect(paths) {
        return Wick.Path.booleanOp(paths, 'intersect');
    }

    /**
     * Perform a paper.js boolean operation on a list of paths.
     * @param {Wick.Path[]} paths - a list of paths to perform the boolean operation on.
     * @param {string} booleanOpName - the name of the boolean operation to perform. Currently supports "unite", "subtract", and "intersect"
     */
    static booleanOp(paths, booleanOpName) {
        if (!booleanOpName) {
            console.error('Wick.Path.booleanOp: booleanOpName is required');
        }
        if (booleanOpName !== 'unite' && booleanOpName !== 'subtract' && booleanOpName !== 'intersect') {
            console.error('Wick.Path.booleanOp: unsupported booleanOpName: ' + booleanOpName);
        }

        if (!paths || paths.length === 0) {
            console.error('Wick.Path.booleanOp: a non-empty list of paths is required');
        }

        // Single path? Nothing to do.
        if (paths.length === 1) {
            return paths[0];
        }

        // Get paper.js path objects
        paths = paths.map(path => {
            return path.view.item;
        });

        var result = paths[0].clone({ insert: false });
        paths.forEach(path => {
            if (path === paths[0]) return;

            result = result[booleanOpName](path);
            result.remove();
        });

        var resultWickPath = new Wick.Path({
            json: result.exportJSON({ asString: false }),
        });
        return resultWickPath;
    }

    /**
     * Converts a stroke into fill. Only works with paths that have a strokeWidth and strokeColor, and have no fillColor. Does nothing otherwise.
     * @returns {Wick.Path} A flattened version of this path. Can be null if the path cannot be flattened.
     */
    flatten() {
        if (this.fillColor || !this.strokeColor || !this.strokeWidth) {
            return null;
        }

        if (!(this instanceof paper.Path)) {
            return null;
        }

        var flatPath = new Wick.Path({
            json: this.view.item.flatten().exportJSON({ asString: false }),
        });

        flatPath.fillColor = this.strokeColor;

        return flatPath;
    }

    /**
     * Is this path used as a placeholder for preventing empty clips?
     * @type {bool}
     */
    set isPlaceholder (isPlaceholder) {
        this._isPlaceholder = isPlaceholder;
    }

    get isPlaceholder () {
        return this._isPlaceholder;
    }
}