<!DOCTYPE html>
<html lang="en">
    <head>
        <title>$pagetitle</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
        <meta name="generator" content="FreeCAD $version">
        <style>
            * {
                margin: 0;
                padding: 0;
            }
            body {
                background: #ffffff; /* Old browsers */
                background: -moz-linear-gradient(top, #e3e9fc 0%, #ffffff 70%, #e2dab3 100%); /* FF3.6-15 */
                background: -webkit-linear-gradient(top, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* Chrome10-25, Safari5.1-6 */
                background: linear-gradient(to bottom, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
                width: 100vw;
                height: 100vh;
            }
            canvas { display: block; }
            #mainCanvas {
                width: 100%;
                height: 100%;
            }
            #arrowCanvas  {
                position: absolute;
                left: 0px;
                bottom: 0px;
                width: 150px;
                height: 150px;
                z-index: 100;
            }
            select { width: 170px; }
        </style>
    </head>
    <script type="importmap">
        {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@$threejs_version/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@$threejs_version/examples/jsm/"
            }
        }
    </script>
    <body>
        <canvas id="mainCanvas"></canvas>
        <canvas id="arrowCanvas"></canvas>
        <script type="module">
            // Direct from mrdoob: https://www.jsdelivr.com/package/npm/three
            import * as THREE from 'three'
            import { OrbitControls } from     'three/addons/controls/OrbitControls.js';
            import { GUI } from               'three/addons/libs/lil-gui.module.min.js';
            import { Line2 } from             'three/addons/lines/Line2.js';
            import { LineMaterial } from      'three/addons/lines/LineMaterial.js';
            import { LineGeometry } from      'three/addons/lines/LineGeometry.js';
            import { EdgeSplitModifier } from 'three/addons/modifiers/EdgeSplitModifier.js';

            const data = $data;

            // Z is up for FreeCAD
            THREE.Object3D.DEFAULT_UP = new THREE.Vector3(0, 0, 1);

            const defaultWireColor = new THREE.Color('rgb(0,0,0)');
            const defaultWireLineWidth = 2; // in pixels

            const raycasterObj = []; // list of obj that can mouseover highlight

            const canvas = document.querySelector('#mainCanvas');

            const scene = new THREE.Scene();

            const renderer = new THREE.WebGLRenderer({
                alpha: true,
                antialias: true,
                canvas: canvas
            }); // Clear bg so we can set it with css
            renderer.setClearColor(0x000000, 0);

            let renderRequested = false;

            // HemisphereLight gives different colors of light from the top
            // and bottom simulating reflected light from the 'ground' and
            // 'sky'
            scene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 0.4));

            const dLight1 = new THREE.DirectionalLight(0xffffff, 0.4);
            dLight1.position.set(5, -2, 3);
            scene.add(dLight1);
            const dLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
            dLight2.position.set(-5, 2, 3);
            scene.add(dLight2);

            if (data.compressed) {
                const base = data.base;
                const baseFloat = data.baseFloat;

                function baseDecode(input) {
                    const baseCt = base.length;
                    const output = [];
                    const len = parseInt(input[0]); // num chars of each element
                    for (let i = 1; i < input.length; i += len) {
                        const str = input.substring(i, i + len).trim();
                        let val = 0;
                        for (let s = 0; s < str.length; s++) {
                            const ind = base.indexOf(str[s]);
                            val += ind * Math.pow(baseCt, s);
                        }
                        output.push(val);
                    }
                    return output;
                }

                function floatDecode(input) {
                    const baseCt = base.length;
                    const baseFloatCt = baseFloat.length;
                    let numString = '';
                    for (let i = 0; i < input.length; i += 4) {
                        const b90chunk = input.substring(i, i + 4).trim();
                        let quotient = 0;
                        for (let s = 0; s < b90chunk.length; s++) {
                            const ind = base.indexOf(b90chunk[s]);
                            quotient += ind * Math.pow(baseCt, s);
                        }
                        let buffer = '';
                        for (let s = 0; s < 7; s++) {
                            buffer = baseFloat[quotient % baseFloatCt] + buffer;
                            quotient = parseInt(quotient / baseFloatCt);
                        }
                        numString += buffer;
                    }
                    let trailingCommas = 0;
                    for (let s = 1; s < 7; s++) {
                        if (numString[numString.length - s] == baseFloat[0]) {
                            trailingCommas++;
                        }
                    }
                    numString = numString.substring(0, numString.length - trailingCommas);
                    return numString;
                }

                // Decode from base90 and distribute the floats
                for (const obj of data.objects) {
                    obj.floats = JSON.parse('[' + floatDecode(obj.floats) + ']');
                    obj.verts = baseDecode(obj.verts).map(x => obj.floats[x]);
                    obj.facets = baseDecode(obj.facets);
                    obj.wires = obj.wires.map(w => baseDecode(w).map(x => obj.floats[x]));
                    obj.facesToFacets = obj.facesToFacets.map(x => baseDecode(x));
                }
            }
            else {
                for (const obj of data.objects) {
                    obj.verts = obj.verts.map(x => parseFloat(x));
                    obj.wires = obj.wires.map(w => w.map(x => parseFloat(x)))
                    obj.facesToFacets = obj.facesToFacets.map(w => w.map(x => parseFloat(x)));
                }
            }
            // Get bounds for global clipping
            const globalMaxMin = [{min: null, max: null},
                                    {min: null, max: null},
                                    {min: null, max: null}];
            for (const obj of data.objects) {
                for (let v = 0; v < obj.verts.length; v++) {
                    if (globalMaxMin[v % 3] === null
                        || obj.verts[v] < globalMaxMin[v % 3].min) {
                        globalMaxMin[v % 3].min = obj.verts[v];
                    }
                    if (globalMaxMin[v % 3] === null
                        || obj.verts[v] > globalMaxMin[v % 3].max) {
                        globalMaxMin[v % 3].max = obj.verts[v];
                    }
                }
            }
            let bigrange = 0;
            // add a little extra
            for (const i of globalMaxMin) {
                const range = i.max - i.min;
                if (range > bigrange) {
                    bigrange = range;
                }
                i.min -= range * 0.01;
                i.max += range * 0.01;
            }

            const camCenter = new THREE.Vector3(
                0.5 * (globalMaxMin[0].max - globalMaxMin[0].min) + globalMaxMin[0].min,
                0.5 * (globalMaxMin[1].max - globalMaxMin[1].min) + globalMaxMin[1].min,
                0.5 * (globalMaxMin[2].max - globalMaxMin[2].min) + globalMaxMin[2].min );
            const viewSize = 1.5 * bigrange; // make the view area a little bigger than the object
            const aspectRatio = canvas.clientWidth / canvas.clientHeight;
            const originalAspect = aspectRatio;

            function initCam(camera) {
                // XXX this needs to treat the perspective and orthographic
                // cameras differently
                camera.position.set(
                    data.camera.position_x,
                    data.camera.position_y,
                    data.camera.position_z);
                camera.lookAt(camCenter);
                camera.updateMatrixWorld();
            }

            let cameraType = data.camera.type;
            const persCamera = new THREE.PerspectiveCamera(
                50, aspectRatio, 1, 100000);
            initCam(persCamera);
            const orthCamera = new THREE.OrthographicCamera(
                -aspectRatio * viewSize / 2, aspectRatio * viewSize / 2,
                viewSize / 2, -viewSize / 2, -100000, 100000);
            initCam(orthCamera);

            function assignMesh(positions, color, opacity, faces) {
                const baseGeometry = new THREE.BufferGeometry();
                baseGeometry.setAttribute('position', new THREE.BufferAttribute(
                    positions, 3));

                // EdgeSplitModifier is used to combine verts so that smoothing normals can be generated WITHOUT removing the hard edges of the design
                // REF: https://threejs.org/examples/?q=edge#webgl_modifier_edgesplit - https://github.com/mrdoob/three.js/pull/20535
                const edgeSplit = new EdgeSplitModifier();
                const cutOffAngle = 20;
                const geometry = edgeSplit.modify(
                    baseGeometry, cutOffAngle * Math.PI / 180);
                geometry.computeVertexNormals();
                geometry.computeBoundingSphere();

                const material = new THREE.MeshLambertMaterial({
                    color: color,
                    side: THREE.DoubleSide,
                    vertexColors: false,
                    opacity: opacity,
                    transparent: opacity != 1.0,
                    fog: false
                });

                const meshobj = new THREE.Mesh(geometry, material);
                meshobj.name = meshobj.uuid;
                faces.push(meshobj.uuid);
                scene.add(meshobj);
                raycasterObj.push(meshobj);
            }

            const objects = [];
            for (const obj of data.objects) {
                // Each face gets its own material because they each can
                // have different colors
                const faces = [];
                if (obj.facesToFacets.length > 0) {
                    for (let f=0; f < obj.facesToFacets.length; f++) {
                        const facecolor = obj.faceColors.length > 0 ? obj.faceColors[f] : obj.color;
                        const positions = new Float32Array(obj.facesToFacets[f].length * 9);
                        for (let a=0; a < obj.facesToFacets[f].length; a++) {
                            for (let b=0; b < 3; b++) {
                                for (let c=0; c < 3; c++) {
                                    positions[9 * a + 3 * b + c] = obj.verts[3 * obj.facets[3 * obj.facesToFacets[f][a] + b ] + c ];
                                }
                            }
                        }
                        assignMesh(positions, facecolor, obj.opacity, faces);
                    }
                } else {
                    // No facesToFacets means that there was a tessellate()
                    // mismatch inside FreeCAD. Use all facets in object to
                    // create this mesh
                    const positions = new Float32Array(obj.facets.length * 3);
                    for (let a=0; a < obj.facets.length; a++) {
                        for (let b=0; b < 3; b++) {
                            positions[3 * a + b] = obj.verts[3 * obj.facets[a] + b];
                        }
                    }
                    assignMesh(positions, obj.color, obj.opacity, faces);
                }

                // Wires
                // cannot have lines in WebGL that are wider than 1px due to browser limitations so Line2 workaround lib is used
                // REF: https://threejs.org/examples/?q=fat#webgl_lines_fat - https://jsfiddle.net/brLk6aud/1/
                // This material is shared by all wires in this object
                const wirematerial = new LineMaterial( {
                    color: defaultWireColor,
                    linewidth: defaultWireLineWidth,
                    dashed: false, dashSize: 1, gapSize: 1, dashScale: 3
                } );
                wirematerial.resolution.set(
                    canvas.clientWidth * window.devicePixelRatio,
                    canvas.clientHeight * window.devicePixelRatio);

                const wires = [];
                for (const w of obj.wires) {
                    const wiregeometry = new LineGeometry();
                    wiregeometry.setPositions(w);
                    const wire = new Line2(wiregeometry, wirematerial);
                    wire.computeLineDistances();
                    wire.scale.set(1, 1, 1);
                    wire.name = wire.uuid;
                    scene.add(wire);
                    wires.push(wire.name);
                }
                objects.push({
                    data: obj,
                    faces: faces,
                    wires: wires,
                    wirematerial: wirematerial,
                    gui_link: null
                });
            }

            // ---- GUI Init ----
            const gui = new GUI({ width: 300, closeFolders: true });
            const addFolder = GUI.prototype.addFolder;
            GUI.prototype.addFolder = function(...args) {
                return addFolder.call(this, ...args).close();
            }
            const guiparams = {
                wiretype: 'Normal',
                wirewidth: defaultWireLineWidth,
                wirecolor: '#' + defaultWireColor.getHexString(),
                clippingx: 100,
                clippingy: 100,
                clippingz: 100,
                cameraType: cameraType,
                navright: function() { navChange([1,  0, 0]); },
                navtop:   function() { navChange([0,  0, 1]); },
                navfront: function() { navChange([0, -1, 0]); }
            };

            // ---- Wires ----
            const wiretypes = { Normal: 'Normal', Dashed: 'Dashed', None: 'None' };

            const wireFolder = gui.addFolder('Wire');
            wireFolder.add(guiparams, 'wiretype', wiretypes).name('Wire Display').onChange(wireChange);
            wireFolder.add(guiparams, 'wirewidth').min(1).max(5).step(1).name('Wire Width').onChange(wireChange);
            wireFolder.addColor(guiparams, 'wirecolor').name('Wire Color').onChange(wireChange);

            function wireChange() {
                for (const obj of objects) {
                    const m = obj.wirematerial;
                    if (m.dashed) {
                        if (guiparams.wiretype != 'Dashed') {
                            m.dashed = false;
                            delete m.defines.USE_DASH;
                        }
                    } else {
                        if (guiparams.wiretype == 'Dashed') {
                            m.dashed = true;
                            // Dashed lines require this as of r122. delete if not dashed
                            m.defines.USE_DASH = ""; // https://discourse.threejs.org/t/dashed-line2-material/10825
                        }
                    }
                    if (guiparams.wiretype == 'None') {
                        m.visible = false;
                    } else {
                        if ((obj.faces.length == 0) | scene.getObjectByName(obj.faces[0]).material.visible){
                            m.visible = true;
                        }
                    }
                    m.linewidth = guiparams.wirewidth;
                    m.color = new THREE.Color(guiparams.wirecolor);
                    m.needsUpdate = true;
                }
                requestRender();
            }
            wireChange();

            // ---- Clipping ----
            const clippingFolder = gui.addFolder('Clipping');
            clippingFolder.add(guiparams, 'clippingx').min(0).max(100).step(1).name('X-Axis Clipping').onChange(clippingChange);
            clippingFolder.add(guiparams, 'clippingy').min(0).max(100).step(1).name('Y-Axis Clipping').onChange(clippingChange);
            clippingFolder.add(guiparams, 'clippingz').min(0).max(100).step(1).name('Z-Axis Clipping').onChange(clippingChange);

            const clipPlaneX = new THREE.Plane(new THREE.Vector3( -1, 0, 0 ), 0);
            const clipPlaneY = new THREE.Plane(new THREE.Vector3( 0, -1, 0 ), 0);
            const clipPlaneZ = new THREE.Plane(new THREE.Vector3( 0, 0, -1 ), 0);

            function clippingChange() {
                if (guiparams.clippingx < 100 || guiparams.clippingy < 100 || guiparams.clippingz < 100) {
                    if (renderer.clippingPlanes.length == 0) {
                        renderer.clippingPlanes.push(clipPlaneX, clipPlaneY, clipPlaneZ);
                    }
                }
                clipPlaneX.constant = (globalMaxMin[0].max - globalMaxMin[0].min) * guiparams.clippingx / 100.0 + globalMaxMin[0].min;
                clipPlaneY.constant = (globalMaxMin[1].max - globalMaxMin[1].min) * guiparams.clippingy / 100.0 + globalMaxMin[1].min;
                clipPlaneZ.constant = (globalMaxMin[2].max - globalMaxMin[2].min) * guiparams.clippingz / 100.0 + globalMaxMin[2].min;
                requestRender();
            }

            // ---- Camera & Navigation ----
            const camFolder = gui.addFolder('Camera');
            const cameraTypes = { Perspective: 'Perspective', Orthographic: 'Orthographic' };
            camFolder.add(guiparams, 'cameraType', cameraTypes).name('Camera type').onChange(cameraChange);
            camFolder.add(guiparams, 'navright').name('View Right');
            camFolder.add(guiparams, 'navtop').name('View Top');
            camFolder.add(guiparams, 'navfront').name('View Front');

            function navChange(v) {
                const t = new THREE.Vector3();
                new THREE.Box3().setFromObject(scene).getSize(t);
                persControls.object.position.set(
                    v[0] * t.x * 2 + camCenter.x,
                    v[1] * t.y * 2 + camCenter.y,
                    v[2] * t.z * 2 + camCenter.z);
                persControls.target = camCenter;
                persControls.update();
                orthControls.object.position.set(
                    v[0] * t.x + camCenter.x,
                    v[1] * t.y + camCenter.y,
                    v[2] * t.z + camCenter.z);
                orthControls.target = camCenter;
                orthControls.update();
                // controls.update() implicitly calls requestRender()
            }

            function cameraChange(v) {
                cameraType = v;
                requestRender();
            }

            const guiObjects = gui.addFolder('Objects');
            for (const obj of objects) {
                // Ignore objects with no vertices
                if (obj.data.verts.length > 0) {
                    const guiObjData = {
                        obj: obj, color: obj.data.color, opacity: obj.data.opacity, show: true };
                    const guiObject = guiObjects.addFolder(obj.data.name);
                    guiObject.addColor(guiObjData, 'color').name('Color').onChange(GUIObjectChange);
                    guiObject.add(guiObjData, 'opacity').min(0.0).max(1.0).step(0.05).name('Opacity').onChange(GUIObjectChange);
                    guiObject.add(guiObjData, 'show').onChange(GUIObjectChange).listen();
                    obj.gui_link = guiObjData
                }
            }

            function GUIObjectChange(v) {
                for (const f of this.object.obj.faces) {
                    const m = scene.getObjectByName(f).material;
                    if (this.property == 'color') {
                        m.color.setStyle(v);
                    }
                    if (this.property == 'opacity') {
                        m.opacity = v;
                        m.transparent = (v != 1.0);
                    }
                    if (this.property == 'show') {
                        m.visible = v
                    }
                }
                if (this.property == 'opacity') {
                    const m = this.object.obj.wirematerial;
                    m.opacity = v;
                    m.transparent = (v != 1.0);
                }
                if (this.property == 'show') {
                    const m = this.object.obj.wirematerial;
                    m.visible = v
                }
                requestRender();
            }


            // Make simple orientation arrows and box - REF: http://jsfiddle.net/b97zd1a3/16/
            const arrowCanvas = document.querySelector('#arrowCanvas');
            const arrowRenderer = new THREE.WebGLRenderer({
                alpha: true,
                canvas: arrowCanvas
            }); // clear
            arrowRenderer.setClearColor(0x000000, 0);
            arrowRenderer.setSize(arrowCanvas.clientWidth * window.devicePixelRatio,
                                    arrowCanvas.clientHeight * window.devicePixelRatio,
                                    false);

            const arrowScene = new THREE.Scene();

            const arrowCamera = new THREE.PerspectiveCamera(
                50, arrowCanvas.clientWidth / arrowCanvas.clientHeight, 1, 500 );
            arrowCamera.up = persCamera.up; // important!

            const arrowPos = new THREE.Vector3(0, 0, 0);
            arrowScene.add(new THREE.ArrowHelper(
                new THREE.Vector3(1, 0, 0), arrowPos, 60, 0x7F2020, 20, 10));
            arrowScene.add(new THREE.ArrowHelper(
                new THREE.Vector3(0, 1, 0), arrowPos, 60, 0x207F20, 20, 10));
            arrowScene.add(new THREE.ArrowHelper(
                new THREE.Vector3(0, 0, 1), arrowPos, 60, 0x20207F, 20, 10));
            arrowScene.add(new THREE.Mesh(
                new THREE.BoxGeometry(40, 40, 40),
                new THREE.MeshLambertMaterial({ color: 0xaaaaaa })
            ));
            arrowScene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 1.2));

            // Controls
            const persControls = new OrbitControls(persCamera, renderer.domElement);
            persControls.target = camCenter; // rotate around center of parts
            // persControls.enablePan = false;
            // persControls.enableDamping = true;
            persControls.update();
            const orthControls = new OrbitControls(orthCamera, renderer.domElement);
            orthControls.target = camCenter; // rotate around center of parts
            // orthControls.enablePan = false;
            // orthControls.enableDamping = true;
            orthControls.update();

            function render() {
                renderRequested = false;
                persControls.update();
                if (cameraType == 'Perspective') {
                    arrowCamera.position.copy(persCamera.position);
                    arrowCamera.position.sub(persControls.target);
                }
                orthControls.update();
                if (cameraType == 'Orthographic') {
                    arrowCamera.position.copy(orthCamera.position);
                    arrowCamera.position.sub(orthControls.target);
                }
                arrowCamera.lookAt(arrowScene.position);
                arrowCamera.position.setLength(200);

                if (cameraType == 'Perspective') {
                    renderer.render(scene, persCamera);
                }
                if (cameraType == 'Orthographic') {
                    renderer.render(scene, orthCamera);
                }
                arrowRenderer.render(arrowScene, arrowCamera);
            };

            function requestRender() {
                if (!renderRequested) {
                    renderRequested = true;
                    requestAnimationFrame(render);
                }
            }

            persControls.addEventListener('change', requestRender);
            orthControls.addEventListener('change', requestRender);
            renderer.domElement.addEventListener('mousemove', onMouseMove);
            renderer.domElement.addEventListener('dblclick', onMouseDblClick);
            window.addEventListener('resize', onMainCanvasResize, false);

            onMainCanvasResize();
            requestRender();

            function onMainCanvasResize() {
                const pixelRatio = window.devicePixelRatio;
                const width = canvas.clientWidth * pixelRatio | 0;
                const height = canvas.clientHeight * pixelRatio | 0;
                const needResize = canvas.width !== width || canvas.height !== height;
                const aspect = canvas.clientWidth / canvas.clientHeight;
                if (needResize) {
                    renderer.setSize(width, height, false);

                    // See https://stackoverflow.com/questions/39373113/three-js-resize-window-not-scaling-properly
                    const change = originalAspect / aspect;
                    const newSize = viewSize * change;
                    orthCamera.left = -aspect * newSize / 2;
                    orthCamera.right = aspect * newSize  / 2;
                    orthCamera.top = newSize / 2;
                    orthCamera.bottom = -newSize / 2;
                    orthCamera.updateProjectionMatrix();

                    persCamera.aspect = canvas.clientWidth / canvas.clientHeight;
                    persCamera.updateProjectionMatrix();
                }

                for (const obj of objects) {
                    obj.wirematerial.resolution.set(width, height);
                }
                requestRender();
            }

            // Use mouse double click to toggle the gui for the selected object
            function onMouseDblClick(e){
                let c = false;
                if (cameraType == 'Orthographic') {
                    c = orthCamera;
                }
                if (cameraType == 'Perspective') {
                    c = persCamera;
                }
                if (!c) {
                    return;
                }

                const raycaster = new THREE.Raycaster();
                raycaster.setFromCamera(new THREE.Vector2(
                    (e.clientX / canvas.clientWidth) * 2 - 1,
                    -(e.clientY / canvas.clientHeight) * 2 + 1),
                                        c);
                const intersects = raycaster.intersectObjects(raycasterObj);

                for (const i of intersects) {
                    const m = i.object;
                    if (!m.material.visible){continue};
                    for (const obj of objects) {
                        for (const face_uuid of obj.faces) {
                            if (face_uuid == m.uuid) {
                                obj.gui_link.show = false
                                obj.wirematerial.visible = false
                                for (const face of obj.faces) {
                                    scene.getObjectByName(face).material.visible = false
                                }
                                requestRender();
                                return
                            }
                        }
                    }
                }
            }


            function onMouseMove(e)  {
                let c = false;
                if (cameraType == 'Orthographic') {
                    c = orthCamera;
                }
                if (cameraType == 'Perspective') {
                    c = persCamera;
                }
                if (!c) {
                    return;
                }

                const raycaster = new THREE.Raycaster();
                raycaster.setFromCamera(new THREE.Vector2(
                    (e.clientX / canvas.clientWidth) * 2 - 1,
                    -(e.clientY / canvas.clientHeight) * 2 + 1),
                                        c);
                const intersects = raycaster.intersectObjects(raycasterObj);

                let chosen = '';
                for (const i of intersects) {
                    const m = i.object.material;
                    if ((m.opacity > 0) & m.visible) {
                        if (m.emissive.getHex() == 0x000000) {
                            m.emissive.setHex( 0x777777 );
                            m.needsUpdate = true;
                            requestRender();
                        }
                        chosen = i.object.name;
                        break;
                    }
                }
                for (const r of raycasterObj) {
                    if (r.name == chosen) {
                        continue;
                    }
                    if (r.material.emissive.getHex() != 0x000000) {
                        r.material.emissive.setHex(0x000000);
                        r.material.needsUpdate = true;
                        requestRender();
                    }
                }
            }
        </script>
    </body>
</html>
