6 Nov 2020

Implementing Custom Markups

The Autodesk.Viewing.MarkupsCore extension provides a limited support for implementing your own kinds of markups. Let's create a "smiley face" markup!

What is and isn't possible

Before we start coding, let's summarize what is and isn't possible in the markup customization (at least as of viewer version 7.30):

  • You can implement custom markup types that will be created and manipulated by the markup extension
  • The extension can serialize the custom markup into SVG, but...
  • The extension cannot currently deserialize custom markups from an SVG; you would have to parse the markup manually

Implementation

Custom markup type is implemented by subclassing several classes from the Autodesk.Viewing.Extensions.Markups.Core namespace, namely the following:

  • Markup class represents individual instances of the custom markup type
  • EditAction class represents (undoable) actions such as creation or deletion of a specific markup instance
  • EditMode class represents the "controller" responsible for creating, updating, and deleting instances of a specific markup type, using instances of EditAction

Note that these namespaces and classes are only available after the Autodesk.Viewing.MarkupsCore extension has been loaded. You will therefore need to make sure that the code with your derived classes is loaded after the extension.

Let's start by subclassing the Markup class. Our new class should define at least the following methods:

  • getEditMode() returning this markup's corresponding EditMode
  • set(position, size) updating the markup's position and size
  • updateStyle() updating the markup's SVG attributes based on the markup's state
  • setMetadata() storing the markup's state into its SVG element

Some common parts of the markup logic is encapsulated in various utility methods under the Autodesk.Viewing.Extensions.Markups.Core.Utils namespace, and we will make use of those as well.

const avemc = Autodesk.Viewing.Extensions.Markups.Core;
const avemcu = Autodesk.Viewing.Extensions.Markups.Core.Utils;
 
class MarkupSmiley extends avemc.Markup {
    constructor(id, editor) {
        super(id, editor, ['stroke-width', 'stroke-color', 'stroke-opacity', 'fill-color', 'fill-opacity']);
        this.type = 'smiley';
        this.addMarkupMetadata = avemcu.addMarkupMetadata.bind(this);
        this.shape = avemcu.createMarkupPathSvg();
        this.bindDomEvents();
    }
 
    // Get a new edit mode object for this markup type.
    getEditMode() {
        return new EditModeSmiley(this.editor);
    }
 
    // Compute SVG path based on the markup's parameters.
    getPath() {
        const { size } = this;
        if (size.x === 1 || size.y === 1) {
            return [''];
        }
 
        const strokeWidth = this.style['stroke-width'];
        const width = size.x - strokeWidth;
        const height = size.y - strokeWidth;
        const radius = 0.5 * Math.min(width, height);
        const path = [
            // Head
            'M', -radius, 0,
            'A', radius, radius, 0, 0, 1, radius, 0,
            'A', radius, radius, 0, 0, 1, -radius, 0,
 
            // Mouth
            'M', -0.5 * radius, -0.5 * radius,
            'A', radius, radius, 0, 0, 1, 0.5 * radius, -0.5 * radius,
 
            // Left eye (closed)
            'M', -0.5 * radius, 0.5 * radius,
            'A', radius, radius, 0, 0, 1, -0.1 * radius, 0.5 * radius,
 
            // Right eye (closed)
            'M', 0.1 * radius, 0.5 * radius,
            'A', radius, radius, 0, 0, 1, 0.5 * radius, 0.5 * radius,
        ];
        return path;
    }
 
    // Update the markup's transform properties.
    set(position, size) {
        this.setSize(position, size.x, size.y);
    }
 
    // Update the markup's SVG shape based on its style and transform properties.
    updateStyle() {
        const { style, shape } = this;
        const path = this.getPath().join(' ');
 
        const strokeWidth = this.style['stroke-width'];
        const strokeColor = this.highlighted ? this.highlightColor : avemcu.composeRGBAString(style['stroke-color'], style['stroke-opacity']);
        const fillColor = avemcu.composeRGBAString(style['fill-color'], style['fill-opacity']);
        const transform = this.getTransform();
 
        avemcu.setAttributeToMarkupSvg(shape, 'd', path);
        avemcu.setAttributeToMarkupSvg(shape, 'stroke-width', strokeWidth);
        avemcu.setAttributeToMarkupSvg(shape, 'stroke', strokeColor);
        avemcu.setAttributeToMarkupSvg(shape, 'fill', fillColor);
        avemcu.setAttributeToMarkupSvg(shape, 'transform', transform);
        avemcu.updateMarkupPathSvgHitarea(shape, this.editor);
    }
 
    // Store the markup's type, transforms, and styles in its SVG shape.
    setMetadata() {
        const metadata = avemcu.cloneStyle(this.style);
        metadata.type = this.type;
        metadata.position = [this.position.x, this.position.y].join(' ');
        metadata.size = [this.size.x, this.size.y].join(' ');
        metadata.rotation = String(this.rotation);
        return this.addMarkupMetadata(this.shape, metadata);
    }
}

Most of the code in this class is just a boiler plate that will be the same for any custom markup type. The one method that is actually custom here is getPath where we build up an SVG path for our smiley face.

Next, let's define 3 classes derived from EditAction, one for creating a new smiley markup, one for updating it, and one for removing it. Even here, most of the implementation is a boiler plate code that's just handling undo/redo operations.

class SmileyCreateAction extends avemc.EditAction {
    constructor(editor, id, position, size, rotation, style) {
        super(editor, 'CREATE-SMILEY', id);
        this.selectOnExecution = false;
        this.position = { x: position.x, y: position.y };
        this.size = { x: size.x, y: size.y };
        this.rotation = rotation;
        this.style = avemcu.cloneStyle(style);
    }
 
    redo() {
        const editor = this.editor;
        const smiley = new MarkupSmiley(this.targetId, editor);
        editor.addMarkup(smiley);
        smiley.setSize(this.position, this.size.x, this.size.y);
        smiley.setRotation(this.rotation);
        smiley.setStyle(this.style);
    }
 
    undo() {
        const markup = this.editor.getMarkup(this.targetId);
        markup && this.editor.removeMarkup(markup);
    }
}
 
class SmileyUpdateAction extends avemc.EditAction {
    constructor(editor, smiley, position, size) {
        super(editor, 'UPDATE-SMILEY', smiley.id);
        this.newPosition = { x: position.x, y: position.y };
        this.newSize = { x: size.x, y: size.y };
        this.oldPosition = { x: smiley.position.x, y: smiley.position.y };
        this.oldSize = { x: smiley.size.x, y: smiley.size.y };
    }
 
    redo() {
        this.applyState(this.targetId, this.newPosition, this.newSize);
    }
 
    undo() {
        this.applyState(this.targetId, this.oldPosition, this.oldSize);
    }
 
    merge(action) {
        if (this.targetId === action.targetId && this.type === action.type) {
            this.newPosition = action.newPosition;
            this.newSize = action.newSize;
            return true;
        }
        return false;
    }
 
    applyState(targetId, position, size) {
        const smiley = this.editor.getMarkup(targetId);
        if(!smiley) {
            return;
        }
 
        // Different stroke widths make positions differ at sub-pixel level.
        const epsilon = 0.0001;
        if (Math.abs(smiley.position.x - position.x) > epsilon || Math.abs(smiley.size.y - size.y) > epsilon ||
            Math.abs(smiley.position.y - position.y) > epsilon || Math.abs(smiley.size.y - size.y) > epsilon) {
            smiley.set(position, size);
        }
    }
 
    isIdentity() {
        return (
            this.newPosition.x === this.oldPosition.x &&
            this.newPosition.y === this.oldPosition.y &&
            this.newSize.x === this.oldSize.x &&
            this.newSize.y === this.oldSize.y
        );
    }
}
 
class SmileyDeleteAction extends avemc.EditAction {
    constructor(editor, smiley) {
        super(editor, 'DELETE-SMILEY', smiley.id);
        this.createSmiley = new SmileyCreateAction(
            editor,
            smiley.id,
            smiley.position,
            smiley.size,
            smiley.rotation,
            smiley.getStyle()
        );
    }
 
    redo() {
        this.createSmiley.undo();
    }
 
    undo() {
        this.createSmiley.redo();
    }
}

Finally, let's create a new controller (EditMode) for our smiley face markup. In its basic form, the class just has to define a couple of methods for handling the user input (onMouseDownonMouseMove, and deleteMarkup), and execute corresponding actions.

class EditModeSmiley extends avemc.EditMode {
    constructor(editor) {
        super(editor, 'smiley', ['stroke-width', 'stroke-color', 'stroke-opacity', 'fill-color', 'fill-opacity']);
    }
 
    deleteMarkup(markup, cantUndo) {
        markup = markup || this.selectedMarkup;
        if (markup && markup.type == this.type) {
            const action = new SmileyDeleteAction(this.editor, markup);
            action.addToHistory = !cantUndo;
            action.execute();
            return true;
        }
        return false;
    }
 
    onMouseMove(event) {
        super.onMouseMove(event);
 
        const { selectedMarkup, editor } = this;
        if (!selectedMarkup || !this.creating) {
            return;
        }
 
        let final = this.getFinalMouseDraggingPosition();
        final = editor.clientToMarkups(final.x, final.y);
        let position = {
            x: (this.firstPosition.x + final.x) * 0.5,
            y: (this.firstPosition.y + final.y) * 0.5
        };
        let size = this.size = {
            x:  Math.abs(this.firstPosition.x - final.x),
            y: Math.abs(this.firstPosition.y - final.y)
        };
        const action = new SmileyUpdateAction(editor, selectedMarkup, position, size);
        action.execute();
    }
 
    onMouseDown() {
        super.onMouseDown();
        const { selectedMarkup, editor } = this;
        if (selectedMarkup) {
            return;
        }
 
        // Calculate center and size.
        let mousePosition = editor.getMousePosition();
        this.initialX = mousePosition.x;
        this.initialY = mousePosition.y;
        let position = this.firstPosition = editor.clientToMarkups(this.initialX, this.initialY);
        let size = this.size = editor.sizeFromClientToMarkups(1, 1);
 
        editor.beginActionGroup();
        const markupId = editor.getId();
        const action = new SmileyCreateAction(editor, markupId, position, size, 0, this.style);
        action.execute();
 
        this.selectedMarkup = editor.getMarkup(markupId);
        this.creationBegin();
    }
}

And that's it for our custom markup definition!

Now, how would we go about incorporating this markup type into our web application with Forge Viewer? As we mentioned in the disclaimer earlier in this post, we cannot simply load our JavaScript code by adding a <script> tag to our HTML because the Autodesk.Viewing.Extensions.Markups.Core namespace and everything under it will only become available after we load the Autodesk.Viewing.MarkupsCore extension. One way to go around that is loading our JavaScript code dynamically, for example, like so:

class SmileyExtension extends Autodesk.Viewing.Extension {
    async load() {
        await this.viewer.loadExtension('Autodesk.Viewing.MarkupsCore');
        await this.loadScript('/smiley-markup.js');
        return true;
    }
 
    unload() {
        return true;
    }
 
    loadScript(url) {
        return new Promise(function (resolve, reject) {
            const script = document.createElement('script');
            script.setAttribute('src', url);
            script.onload = resolve;
            script.onerror = reject;
            document.body.appendChild(script);
        });
    }
 
    startDrawing() {
        const markupExt = this.viewer.getExtension('Autodesk.Viewing.MarkupsCore');
        markupExt.show();
        markupExt.enterEditMode();
        markupExt.changeEditMode(new EditModeSmiley(markupExt));
    }
 
    stopDrawing() {
        const markupExt = this.viewer.getExtension('Autodesk.Viewing.MarkupsCore');
        markupExt.leaveEditMode();
    }
}
 
Autodesk.Viewing.theExtensionManager.registerExtension('SmileyExtension', SmileyExtension);

If you load this extension when instantiating the viewer, you can then simply enter the drawing mode like so:

const ext = viewer.getExtension('SmileyExtension');
ext.startDrawing();

And we're done! If you'd like to see this custom markup implemented in context of a basic Forge application, take a look at https://github.com/petrbroz/forge-basic-app/tree/sample/custom-markup. The classes implementing the custom markup are defined in https://github.com/petrbroz/forge-basic-app/blob/sample/custom-markup/public/smiley-markup.js.

Cheers!

Related Article