Home Reference Source

viewer/svgoverlay.js

import * as mat4 from "./glmatrix/mat4.js";
import * as vec3 from "./glmatrix/vec3.js";
import * as vec2 from "./glmatrix/vec2.js"
import * as mat2 from "./glmatrix/mat2.js"

class SvgOverlayNode {
    constructor(overlay, svgElem) {
        this.overlay = overlay;
        this.svgElem = svgElem;
        this.additionalElems = [];
        this._lastVisibilityState = null;
    }

    process() {
        let v = this.isVisible();
        if (v !== this._lastVisibilityState) {
            this.svgElem.setAttribute("visibility", v ? "visible" : "hidden");
        }
        if (this.beforeUpdate) {
            this.beforeUpdate();
        }
        if (this._lastVisibilityState = v) {
            this.doUpdate();
        }
    }

    destroy() {
        this.overlay.nodes.splice(this.overlay.nodes.indexOf(this), 1);
        let deleteSvgElem = (el) => {
            el.parentElement.removeChild(el);
        };
        deleteSvgElem(this.svgElem);
        this.additionalElems.forEach(deleteSvgElem);
    }
}

class OrbitCenterOverlayNode extends SvgOverlayNode {
    constructor(overlay, svgElem, camera) {
        super(overlay, svgElem);
        this.camera = camera;
    }

    isVisible() {
        return this.camera.orbitting;
    }

    doUpdate() {
        let xy = this.overlay.transformPoint(this.camera.center);
        this.svgElem.setAttribute("cx", xy[0]);
        this.svgElem.setAttribute("cy", xy[1]);
    }
}

class PathOverlayNode extends SvgOverlayNode {
    constructor(overlay, points) {
        super(overlay, null);
        this._points = points;
        this.svgElem = overlay.create("path", {
            fill: "lightblue",
            stroke: "lightblue",
            "fill-opacity": 0.4,
            d: this.createPathAttribute()
        });        
    }

    createPathAttribute() {
        return "M" + this._points.map((p) => this.overlay.toString(this.overlay.transformPoint(p))).join(" L");
    }

    isVisible() {
        return true;
    }

    get points() {
        return this._points;
    }

    set points(p) {
        this._points = p;
        this.doUpdate();
    } 

    doUpdate() {
        this.svgElem.setAttribute("d", this.createPathAttribute());
    }
}


const tmp_mat_1 = mat2.create();

// Calculates the intersection between two (infinite) lines by
// means of determinants, as described on:
// https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection
function intersectLine(R, p00, p01, p10, p11) {
    mat2.set(tmp_mat_1, p00[0], p01[0], p00[1], p01[1]);
    const d00 = mat2.determinant(tmp_mat_1);
    mat2.set(tmp_mat_1, p10[0], p11[0], p10[1], p11[1]);
    const d01 = mat2.determinant(tmp_mat_1);
    const d10 = p00[0] - p01[0];
    const d11 = p10[0] - p11[0];
    mat2.set(tmp_mat_1, d00, d01, d10, d11);
    const x0 = mat2.determinant(tmp_mat_1);
    const d30 = p00[1] - p01[1];
    const d31 = p10[1] - p11[1];
    mat2.set(tmp_mat_1, d00, d01, d30, d31);
    const y0 = mat2.determinant(tmp_mat_1);
    mat2.set(tmp_mat_1, d10, d11, d30, d31);
    const D  = mat2.determinant(tmp_mat_1);
    vec2.set(R, x0 / D, y0 / D);
}

const tmp_measurement_ = vec3.create();
const tmp_measurement_2 = [vec2.create(), vec2.create(), vec2.create(), vec2.create()];

class InfiniteLine extends SvgOverlayNode {
    constructor(overlay, measurement, point, normal) {
        super(overlay, null);

        this.measurement = measurement;
        
        this.points = [
            vec3.clone(point), vec3.add(vec3.create(), point, normal)
        ];

        this.svgElem = overlay.create("path", {
            stroke: "black",
            d: this.createPathAttribute()
        }, {
            strokeDasharray: 4,
            opacity: 0.5
        });

        this.process();
    }

    isVisible() {
        return this.measurement.constrain && !this.measurement.fixed;
    }

    createPathAttribute() {
        let a = vec3.copy(tmp_measurement_, this.overlay.transformPoint(this.points[0]));
        let b = this.overlay.transformPoint(this.points[1]);

        for (var i = 0; i < 4; ++i) {
            intersectLine(
                tmp_measurement_2[i],
                a,
                b,
                this.overlay.boundaryPoints[i],
                this.overlay.boundaryPoints[(i+1)%4]
            );
        }

        const dists = tmp_measurement_2.map((v, i) => [vec2.dist(v, this.overlay.centerPoint), i]);
        dists.sort((a, b) => a[0] - b[0]);
        
        return "M" + [
            tmp_measurement_2[dists[0][1]],
            tmp_measurement_2[dists[1][1]]
        ].map(this.overlay.toString).join(" L");
    }

    doUpdate() {
        this.svgElem.setAttribute("d", this.createPathAttribute());
    }
}

class MeasurementNode extends SvgOverlayNode {
    constructor(overlay, point, normal, constrain) {
        super(overlay, null);
        
        this._points = [new Float32Array(point), new Float32Array(point)];
        this._points_constrained = [new Float32Array(point), new Float32Array(point)];
        this._constrain = !!constrain;
        this.fixed = false;
        this.normal = normal;

        this.line = new InfiniteLine(overlay, this, point, normal);
        overlay.nodes.push(this.line);
        
        this.svgElem = overlay.create("path", {
            stroke: "black",
            fill: "none",
            d: this.createPathAttribute()
        }, {
            markerStart: "url(#m1)",
            markerEnd: "url(#m0)"
        });

        this.label = overlay.create("text", {
        }, {
            textAnchor: "middle",
            fontFamilt: "verdana",
            fontSize: "12pt",
            fill: "#fff",
            stroke: "#000",
            strokeWidth: 2,
            paintOrder: "stroke",
            alignmentBaseline: "middle"        
        });
        this.label.appendChild(document.createTextNode(""));

        this.additionalElems.push(this.label);
    }

    createPathAttribute() {
        // Actually it's nicer to just show something to indicate to the user
        // something is happening.
        /*if (this.points.length < 2 || this.length <= 1) {
            return "";
        }*/
        return "M" + this.points.map((p) => this.overlay.toString(this.overlay.transformPoint(p))).join(" L");
    }

    isVisible() {
        return true;
    }

    get num_points() {
        let n = this._points.length;
        if (!this.fixed) {
            // last point is still in progress
            n --;
        }
        return n;
    }

    get constrain() {
        return this._constrain;
    }

    set constrain(b) {
        this._constrain = b;
        this.doUpdate();
    }

    get length() {
        if (this.points.length < 2) {
            return 0;
        }
        let L = 0;
        for (let i = 0; i < this.points.length - 1; ++i) {
            vec3.subtract(tmp_measurement_, this.points[i], this.points[i + 1]);    
            L += vec3.length(tmp_measurement_);
        }
        return L;
    }

    get midpoint() {
        if (this.points.length < 2) {
            return null;
        } else if (this.points.length == 2) {
            vec3.copy(tmp_measurement_, this.overlay.transformPoint(this.points[0]));
            let t = this.overlay.transformPoint(this.points[1]);
            vec2.add(tmp_measurement_, tmp_measurement_, t);
            vec2.scale(tmp_measurement_, tmp_measurement_, 0.5);
        } else {
            let M = this.length / 2.;
            let accum = 0.;
            for (let i = 0; i < this.points.length - 1; ++i) {
                vec3.subtract(tmp_measurement_, this.points[i], this.points[i + 1]);    
                let segmentLength = vec3.length(tmp_measurement_);
                if (M - accum < segmentLength) {
                    vec3.copy(tmp_measurement_, this.overlay.transformPoint(this.points[i]));
                    let t = this.overlay.transformPoint(this.points[i + 1]);
                    vec2.lerp(tmp_measurement_, tmp_measurement_, t, (M - accum) / segmentLength);
                    break;
                }
                accum += segmentLength;
            }
        }
        return tmp_measurement_;
    }

    get angle() {
        if (this.points.length < 2) {
            return null;
        } else if (this.points.length == 2) {
            vec3.copy(tmp_measurement_, this.overlay.transformPoint(this.points[0]));
            let t = this.overlay.transformPoint(this.points[1]);
            vec2.subtract(tmp_measurement_, t, tmp_measurement_);
            return Math.atan2(tmp_measurement_[1], tmp_measurement_[0]) * 180. / Math.PI;
        } else {
            let M = this.length / 2.;
            let accum = 0.;
            for (let i = 0; i < this.points.length - 1; ++i) {
                vec3.subtract(tmp_measurement_, this.points[i], this.points[i + 1]);    
                let segmentLength = vec3.length(tmp_measurement_);
                if (M - accum < segmentLength) {
                    vec3.copy(tmp_measurement_, this.overlay.transformPoint(this.points[i]));
                    let t = this.overlay.transformPoint(this.points[i + 1]);
                    vec2.subtract(tmp_measurement_, t, tmp_measurement_);
                    return Math.atan2(tmp_measurement_[1], tmp_measurement_[0]) * 180. / Math.PI;
                }
                accum += segmentLength;
            }
        }
    }

    get points() {
        if (this.constrain) {
            return this._points_constrained;
        } else {
            return this._points;
        }
    }

    updatePoint(p) {
        this.points[this.points.length - 1] = new Float32Array(p);
        if (this.constrain) {
            let p = this.points;
            vec3.subtract(tmp_measurement_, p[1], p[0]);
            let l = vec3.dot(tmp_measurement_, this.normal);
            vec3.scale(tmp_measurement_, this.normal, l);
            vec3.add(tmp_measurement_, tmp_measurement_, p[0]);
            this._points_constrained = [p[0], new Float32Array(tmp_measurement_)];
        }
        this.doUpdate();
    }

    fixPoint(p) {
        this.points.push(new Float32Array(this.points[this.points.length - 1]));
    }

    popLastPoint() {
        this.points.pop();
        this.doUpdate();
    }

    doUpdate() {
        this.svgElem.setAttribute("d", this.createPathAttribute());
        let mp = this.midpoint;
        if (mp === null) return;
        let x = mp[0];
        let y = mp[1];
        this.label.setAttribute("x", x);
        this.label.setAttribute("y", y);
        let l = this.length;
        this.label.childNodes[0].textContent = l > 1 ? (l / 1000).toFixed(2) : "";
        let a = this.angle;
        if (a < -90 || a > 90) {
            a += 180;
        }
        this.label.setAttribute("transform", `rotate(${a} ${x} ${y}) translate(0 -7)`);
    }
}

/**
 * A SVG overlay that is synced with the WebGL viewport for efficiently rendering
 * two-dimensional elements such as text, that are not easily rendered using WebGL.
 *
 * @class SvgOverlay
 */
export class SvgOverlay {
	constructor(domNode, camera) {
        this.track = domNode;
        this.camera = camera;

        this.tmp = vec3.create();

        let svg = this.svg = this.create("svg", {id:"viewerOverlay"}, {
            padding: 0,
            margin: 0,
            position: "absolute",
            zIndex: 10000,
            display: "block",
        	"pointer-events": "none"
        });
        
        document.body.appendChild(svg);

        let defs = this.create("defs", null, null);

        for (let i = 0; i < 2; ++i) {
            let marker = this.create("marker", {
                id: `m${i}`,
                markerWidth: 13,
                markerHeight: 13,
                refX: [10, 2][i],
                refY: 6,
                orient: "auto"
            }, null, defs);

            this.create("path", {
                d : ["M2,2 L2,11 L10,6 L2,2", "M10,2 L10,11 L2,6 L10,2"][i],
            }, {
                fill: "#000000"
            }, marker);

            this.create("path", {
                d : ["M10,2 L10,11", "M2,2 L2,11"][i],
            }, {
                stroke: "#000000",
                strokeWidth: 1
            }, marker);
        }

        // Initialize by resize()
        this.boundaryPoints = [
            vec2.create(),
            vec2.create(),
            vec2.create(),
            vec2.create()
        ];
        this.centerPoint = vec2.create();

        this.resize();
        
        this.camera.listeners.push(this.update.bind(this));
        this._orbitCenter = this.create("circle", {
            visibility: "hidden",
            r: 6,
            fill: "white",
        	stroke: "black",
        	"fill-opacity": 0.4
        });

        // This is an array of elements that have methods to query their visibility
        // and update their SVG positioning
        this.nodes = [new OrbitCenterOverlayNode(this, this._orbitCenter, this.camera)];

        window.addEventListener("resize", this.resize.bind(this), false);
    }

    transformPoint(p) {
        let t = this.tmp;
        vec3.transformMat4(t, p, this.camera.viewProjMatrix);
        t[1] *= -1;
        vec2.multiply(t, t, this.wh);
        vec2.add(t, t, this.wh);
        return t;
    }

    toString(t) {
        return t[0] + "," + t[1];
    }

    update() {
        this.nodes.forEach((n) => {
            n.process();
        });
    }

    create(tag, attrs, style, parent) {
        let elem = document.createElementNS("http://www.w3.org/2000/svg", tag);
        for (let [k, v] of Object.entries(attrs || {})) {
            elem.setAttribute(k, v);
        }
        let s = elem.style;
        for (let [k, v] of Object.entries(style || {})) {
            s[k] = v;
        }
        if (typeof(parent) === 'undefined') {
            parent = this.svg;
        }
        if (parent) {
            parent.appendChild(elem);
        }
        return elem;
    }

    createWorldSpacePolyline(points) {
        let node = new PathOverlayNode(this, points);
        this.nodes.push(node);
        return node;
    }

    addMeasurement(point, normal, constrain) {
        let node = new MeasurementNode(this, point, normal, constrain);
        this.nodes.push(node);
        return node;
    }

    resize() {
        function getElementXY(e) {
            var x = 0, y = 0;
            while (e) {
                x += (e.offsetLeft-e.scrollLeft);
                y += (e.offsetTop-e.scrollTop);
                e = e.offsetParent;
            }

            var bodyRect = document.body.getBoundingClientRect();
            return {
                x: (x - bodyRect.left),
                y: (y - bodyRect.top)
            };
        }
        
        let svgStyle = this.svg.style;
        var xy = getElementXY(this.track);
        svgStyle.left = xy.x + "px";
        svgStyle.top = xy.y + "px";
        svgStyle.width = (this.w = this.track.clientWidth) + "px";
        svgStyle.height = (this.h = this.track.clientHeight) + "px";
        this.svg.setAttribute("width", this.w);
        this.svg.setAttribute("height", this.h);
        this.svg.setAttribute("viewBox", "0 0 " + this.w + " " + this.h);
        this.w /= 2.;
        this.h /= 2.;
        this.wh = vec2.fromValues(this.w, this.h);
        
        // (0,0)
        // (w,0)
        // (w,h)
        // (0,h)
        this.boundaryPoints[1][0] = this.w * 2;
        this.boundaryPoints[2][0] = this.w * 2;
        this.boundaryPoints[2][1] = this.h * 2;
        this.boundaryPoints[3][1] = this.h * 2;

        this.centerPoint[0] = this.w;
        this.centerPoint[1] = this.h;
        
        this.aspect = this.w / this.h;
    }
}