"use strict"; const orbit = require("./orbit"); const cookie = require("./cookie")("bbctrl-"); const font = require("./helvetiker_regular.typeface.json"); module.exports = { template: "#path-viewer-template", props: [ "toolpath" ], data: function() { return { enabled: false, loading: false, dirty: true, snapView: cookie.get("snap-view", "isometric"), small: cookie.get_bool("small-path-view", true), surfaceMode: "cut", showPath: cookie.get_bool("show-path", true), showTool: cookie.get_bool("show-tool", true), showBBox: cookie.get_bool("show-bbox", true), showAxes: cookie.get_bool("show-axes", true), showIntensity: cookie.get_bool("show-intensity", false) }; }, computed: { target: function() { return this.$el.querySelector(".path-viewer-content"); }, webglAvailable: function() { // Create canvas element. The canvas is not added to the // document itself, so it is never displayed in the // browser window. const canvas = document.createElement("canvas"); // Get WebGLRenderingContext from canvas element. const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); // Report the result. return gl && gl instanceof WebGLRenderingContext; } }, watch: { toolpath: function() { Vue.nextTick(this.update); }, surfaceMode: function(mode) { this.update_surface_mode(mode); }, small: function(enable) { cookie.set_bool("small-path-view", enable); Vue.nextTick(this.update_view); }, showPath: function(enable) { cookie.set_bool("show-path", enable); this.set_visible(this.pathView, enable); }, showTool: function(enable) { cookie.set_bool("show-tool", enable); this.set_visible(this.toolView, enable); }, showAxes: function(enable) { cookie.set_bool("show-axes", enable); this.set_visible(this.axesView, enable); }, showIntensity: function(enable) { cookie.set_bool("show-intensity", enable); Vue.nextTick(this.update); }, showBBox: function(enable) { cookie.set_bool("show-bbox", enable); this.set_visible(this.bboxView, enable); this.set_visible(this.envelopeView, enable); }, x: function() { this.axis_changed(); }, y: function() { this.axis_changed(); }, z: function() { this.axis_changed(); } }, ready: function() { this.graphics(); Vue.nextTick(this.update); }, beforeDestroy: function() { if (this._sizeWatcher) { this._sizeWatcher.disconnect(); this._sizeWatcher = null; } }, methods: { update: async function() { if (!this.webglAvailable) { return; } if (!this.state.selected) { this.dirty = true; this.scene = new THREE.Scene(); } else if (!this.toolpath.filename && !this.loading) { this.loading = true; this.dirty = true; this.draw_loading(); } if (!this.enabled || !this.toolpath.filename) { return; } async function get(url) { const response = await fetch(`${url}`, { cache: "no-cache" }); const arrayBuffer = await response.arrayBuffer(); return new Float32Array(arrayBuffer); } const [ positions, speeds ] = await Promise.all([ get(`/api/path/${this.toolpath.filename}/positions`), get(`/api/path/${this.toolpath.filename}/speeds`) ]); this.positions = positions; this.speeds = speeds; this.loading = false; // Update scene this.scene = new THREE.Scene(); this.draw(this.scene); this.snap(this.snapView); this.update_view(); }, update_surface_mode: function(mode) { if (!this.enabled) { return; } if (typeof this.surfaceMaterial != "undefined") { this.surfaceMaterial.wireframe = mode == "wire"; this.surfaceMaterial.needsUpdate = true; } this.set_visible(this.surfaceMesh, mode == "cut" || mode == "wire"); this.set_visible(this.workpieceMesh, mode == "solid"); }, load_surface: function(surface) { if (typeof surface == "undefined") { this.vertices = undefined; this.normals = undefined; return; } this.vertices = surface.vertices; // Expand normals this.normals = []; for (let i = 0; i < surface.normals.length / 3; i++) { for (let j = 0; j < 3; j++) { for (let k = 0; k < 3; k++) { this.normals.push(surface.normals[i * 3 + k]); } } } }, set_visible: function(target, visible) { if (typeof target != "undefined") { target.visible = visible; } this.dirty = true; }, get_dims: function() { const computedStyle = window.getComputedStyle(this.target); return { width: parseInt(computedStyle.width), height: parseInt(computedStyle.height) }; }, update_view: function() { if (!this.enabled) { return; } const dims = this.get_dims(); // Skip layouts where the target has no measurable size. // The render loop guard below will not draw frames until // a real size has been observed at least once. if (!(dims.width > 0 && dims.height > 0)) { return; } this.camera.aspect = dims.width / dims.height; this.camera.updateProjectionMatrix(); this.renderer.setSize(dims.width, dims.height); if (this.loading) { this.controls.reset(); this.camera.position.copy(new THREE.Vector3(0, 0, 600)); this.camera.lookAt(new THREE.Vector3(0, 0, 0)); } this.dirty = true; }, update_tool: function(tool) { if (!this.enabled) { return; } if (typeof tool == "undefined") { tool = this.toolView; } if (typeof tool == "undefined") { return; } tool.position.x = this.x.pos; tool.position.y = this.y.pos; tool.position.z = this.z.pos; }, update_envelope: function(envelope) { if (!this.enabled || !this.axes.homed) { return; } if (typeof envelope == "undefined") { envelope = this.envelopeView; } if (typeof envelope == "undefined") { return; } const min = new THREE.Vector3(); const max = new THREE.Vector3(); for (const axis of "xyz") { min[axis] = this[axis].min - this[axis].off; max[axis] = this[axis].max - this[axis].off; } const bounds = new THREE.Box3(min, max); if (bounds.isEmpty()) { envelope.geometry = this.create_empty_geom(); } else { envelope.geometry = this.create_bbox_geom(bounds); } }, axis_changed: function() { this.update_tool(); this.update_envelope(); this.dirty = true; }, graphics: function() { if (!this.webglAvailable) { return; } try { // Renderer. Use an opaque canvas with a clear color // that matches the page-side gradient so the moment // the canvas is appended (and before the first 3D // frame is drawn) the user does not see a flash from // the page background through transparency. this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, }); this.renderer.setPixelRatio(window.devicePixelRatio); this.renderer.setClearColor(0x222222, 1); // Same color on the DOM element itself so the very // first paint (before the WebGL context has cleared) // is dark too. this.renderer.domElement.style.background = "#222222"; this.renderer.domElement.style.display = "block"; this.target.appendChild(this.renderer.domElement); } catch (e) { console.log("WebGL not supported: ", e); return; } this.enabled = true; // Camera this.camera = new THREE.PerspectiveCamera(45, 4 / 3, 1, 10000); // Lighting this.ambient = new THREE.AmbientLight(0xffffff, 0.5); const keyLight = new THREE.DirectionalLight(new THREE.Color("hsl(30, 100%, 75%)"), 0.75); keyLight.position.set(-100, 0, 100); const fillLight = new THREE.DirectionalLight(new THREE.Color("hsl(240, 100%, 75%)"), 0.25); fillLight.position.set(100, 0, 100); const backLight = new THREE.DirectionalLight(0xffffff, 0.5); backLight.position.set(100, 0, -100).normalize(); this.lights = new THREE.Group(); this.lights.add(keyLight); this.lights.add(fillLight); this.lights.add(backLight); // Surface material this.surfaceMaterial = this.create_surface_material(); // Controls this.controls = new orbit(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.2; this.controls.rotateSpeed = 0.25; this.controls.enableZoom = true; // Move lights with scene this.controls.addEventListener("change", function(scope) { return function() { keyLight.position.copy(scope.camera.position); fillLight.position.copy(scope.camera.position); backLight.position.copy(scope.camera.position); keyLight.lookAt(scope.controls.target); fillLight.lookAt(scope.controls.target); backLight.lookAt(scope.controls.target); scope.dirty = true; }; }(this)); // Events window.addEventListener("resize", this.update_view, false); // Start the render loop only after the target has a real, // stable size. Without this, the first frame paints into // a 0×0 / collapsed-flex canvas and a second frame paints // again at the right size — visible as a flash on the // very first mount of the Program tab. const startRendering = () => { if (this._rendering) return; this._rendering = true; this.update_view(); this.render(); }; const dims = this.get_dims(); if (dims.width > 0 && dims.height > 0) { startRendering(); } else if (typeof ResizeObserver !== "undefined") { this._sizeWatcher = new ResizeObserver(entries => { for (const entry of entries) { const r = entry.contentRect; if (r.width > 0 && r.height > 0) { this._sizeWatcher.disconnect(); this._sizeWatcher = null; startRendering(); return; } } }); this._sizeWatcher.observe(this.target); } else { // Old browser fallback: poll for a non-zero size. const tick = () => { const d = this.get_dims(); if (d.width > 0 && d.height > 0) { startRendering(); } else { requestAnimationFrame(tick); } }; requestAnimationFrame(tick); } }, create_surface_material: function() { return new THREE.MeshPhongMaterial({ specular: 0x111111, shininess: 10, side: THREE.FrontSide, color: 0x0c2d53 }); }, draw_loading: function() { this.scene = new THREE.Scene(); const geometry = new THREE.TextGeometry("Loading 3D View...", { font: new THREE.Font(font), size: 40, height: 5, curveSegments: 12, bevelEnabled: true, bevelThickness: 10, bevelSize: 8, bevelSegments: 5 }); geometry.computeBoundingBox(); const mesh = new THREE.Mesh(geometry, this.surfaceMaterial); this.scene.add(mesh); this.scene.add(this.ambient); this.scene.add(this.lights); this.update_view(); }, draw_workpiece: function(scene, material) { if (typeof this.workpiece == "undefined") { return; } let min = this.workpiece.min; let max = this.workpiece.max; min = new THREE.Vector3(min[0], min[1], min[2]); max = new THREE.Vector3(max[0], max[1], max[2]); const dims = max.clone().sub(min); const geometry = new THREE.BoxGeometry(dims.x, dims.y, dims.z); const mesh = new THREE.Mesh(geometry, material); const offset = dims.clone(); offset.divideScalar(2); offset.add(min); mesh.position.add(offset); geometry.computeBoundingBox(); scene.add(mesh); return mesh; }, draw_surface: function(scene, material) { if (typeof this.vertices == "undefined") { return; } const geometry = new THREE.BufferGeometry(); geometry.addAttribute("position", new THREE.Float32BufferAttribute(this.vertices, 3)); geometry.addAttribute("normal", new THREE.Float32BufferAttribute(this.normals, 3)); geometry.computeBoundingSphere(); geometry.computeBoundingBox(); return new THREE.Mesh(geometry, material); }, draw_tool: function(scene, bbox) { // Tool size is relative to bounds const size = bbox.getSize(new THREE.Vector3()); let length = (size.x + size.y + size.z) / 24; if (length < 1) { length = 1; } const material = new THREE.MeshPhongMaterial({ transparent: true, opacity: 0.75, specular: 0x161616, shininess: 10, color: 0xffa500 // Orange }); const geometry = new THREE.CylinderGeometry(length / 2, 0, length, 128); geometry.translate(0, length / 2, 0); geometry.rotateX(0.5 * Math.PI); const mesh = new THREE.Mesh(geometry, material); this.update_tool(mesh); mesh.visible = this.showTool; scene.add(mesh); return mesh; }, draw_axis: function(axis, up, length, radius) { let color; if (axis == 0) { color = 0xff0000; } else if (axis == 1) { color = 0x00ff00; } else if (axis == 2) { color = 0x0000ff; } const group = new THREE.Group(); const material = new THREE.MeshPhongMaterial({ specular: 0x161616, shininess: 10, color: color }); let geometry = new THREE.CylinderGeometry(radius, radius, length, 128); geometry.translate(0, -length / 2, 0); group.add(new THREE.Mesh(geometry, material)); geometry = new THREE.CylinderGeometry(1.5 * radius, 0, 2 * radius, 128); geometry.translate(0, -length - radius, 0); group.add(new THREE.Mesh(geometry, material)); if (axis == 0) { group.rotateZ((up ? 0.5 : 1.5) * Math.PI); } else if (axis == 1) { group.rotateX((up ? 0 : 1 ) * Math.PI); } else if (axis == 2) { group.rotateX((up ? 1.5 : 0.5) * Math.PI); } return group; }, draw_axes: function(scene, bbox) { const size = bbox.getSize(new THREE.Vector3()); let length = (size.x + size.y + size.z) / 3; length /= 10; if (length < 1) { length = 1; } const radius = length / 20; const group = new THREE.Group(); for (let axis = 0; axis < 3; axis++) { for (let up = 0; up < 2; up++) { group.add(this.draw_axis(axis, up, length, radius)); } } group.visible = this.showAxes; scene.add(group); return group; }, get_color: function(speed) { if (isNaN(speed)) { return [ 255, 0, 0 ]; } // Rapid let intensity = speed / this.toolpath.maxSpeed; if (typeof speed == "undefined" || !this.showIntensity) { intensity = 1; } return [ 0, 255 * intensity, 127 * (1 - intensity) ]; }, draw_path: function(scene) { const geometry = new THREE.BufferGeometry(); const material = new THREE.LineBasicMaterial({ vertexColors: THREE.VertexColors, linewidth: 1.5 }); const positions = new THREE.Float32BufferAttribute(this.positions, 3); geometry.addAttribute("position", positions); let colors = []; for (let i = 0; i < this.speeds.length; i++) { const color = this.get_color(this.speeds[i]); Array.prototype.push.apply(colors, color); } colors = new THREE.Uint8BufferAttribute(colors, 3, true); geometry.addAttribute("color", colors); geometry.computeBoundingSphere(); geometry.computeBoundingBox(); const line = new THREE.Line(geometry, material); line.visible = this.showPath; scene.add(line); return line; }, create_empty_geom: function() { const geometry = new THREE.BufferGeometry(); geometry.addAttribute("position", new THREE.Float32BufferAttribute([], 3)); return geometry; }, create_bbox_geom: function(bbox) { const vertices = []; if (!bbox.isEmpty()) { // Top vertices.push(bbox.min.x, bbox.min.y, bbox.min.z); vertices.push(bbox.max.x, bbox.min.y, bbox.min.z); vertices.push(bbox.max.x, bbox.min.y, bbox.min.z); vertices.push(bbox.max.x, bbox.min.y, bbox.max.z); vertices.push(bbox.max.x, bbox.min.y, bbox.max.z); vertices.push(bbox.min.x, bbox.min.y, bbox.max.z); vertices.push(bbox.min.x, bbox.min.y, bbox.max.z); vertices.push(bbox.min.x, bbox.min.y, bbox.min.z); // Bottom vertices.push(bbox.min.x, bbox.max.y, bbox.min.z); vertices.push(bbox.max.x, bbox.max.y, bbox.min.z); vertices.push(bbox.max.x, bbox.max.y, bbox.min.z); vertices.push(bbox.max.x, bbox.max.y, bbox.max.z); vertices.push(bbox.max.x, bbox.max.y, bbox.max.z); vertices.push(bbox.min.x, bbox.max.y, bbox.max.z); vertices.push(bbox.min.x, bbox.max.y, bbox.max.z); vertices.push(bbox.min.x, bbox.max.y, bbox.min.z); // Sides vertices.push(bbox.min.x, bbox.min.y, bbox.min.z); vertices.push(bbox.min.x, bbox.max.y, bbox.min.z); vertices.push(bbox.max.x, bbox.min.y, bbox.min.z); vertices.push(bbox.max.x, bbox.max.y, bbox.min.z); vertices.push(bbox.max.x, bbox.min.y, bbox.max.z); vertices.push(bbox.max.x, bbox.max.y, bbox.max.z); vertices.push(bbox.min.x, bbox.min.y, bbox.max.z); vertices.push(bbox.min.x, bbox.max.y, bbox.max.z); } const geometry = new THREE.BufferGeometry(); geometry.addAttribute("position", new THREE.Float32BufferAttribute(vertices, 3)); return geometry; }, draw_bbox: function(scene, bbox) { const geometry = this.create_bbox_geom(bbox); const material = new THREE.LineBasicMaterial({ color: 0xffffff }); const line = new THREE.LineSegments(geometry, material); line.visible = this.showBBox; scene.add(line); return line; }, draw_envelope: function(scene) { const geometry = this.create_empty_geom(); const material = new THREE.LineBasicMaterial({ color: 0x00f7ff }); const line = new THREE.LineSegments(geometry, material); line.visible = this.showBBox; scene.add(line); this.update_envelope(line); return line; }, draw: function(scene) { // Lights scene.add(this.ambient); scene.add(this.lights); // Model this.pathView = this.draw_path(scene); this.surfaceMesh = this.draw_surface(scene, this.surfaceMaterial); this.workpieceMesh = this.draw_workpiece(scene, this.surfaceMaterial); this.update_surface_mode(this.surfaceMode); // Compute bounding box const bbox = this.get_model_bounds(); // Tool, axes & bounds this.toolView = this.draw_tool(scene, bbox); this.axesView = this.draw_axes(scene, bbox); this.bboxView = this.draw_bbox(scene, bbox); this.envelopeView = this.draw_envelope(scene); }, render: function() { window.requestAnimationFrame(this.render); if (typeof this.scene == "undefined") { return; } // Don't paint frames while the target has no size; this // prevents an initial single-frame clear from painting // before the layout has settled (visible as a dark flash). const dims = this.get_dims(); if (!(dims.width > 0 && dims.height > 0)) { return; } if (this.controls.update() || this.dirty) { this.dirty = false; this.renderer.render(this.scene, this.camera); } }, get_model_bounds: function() { const bbox = new THREE.Box3(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0.00001, 0.00001, 0.00001)); function add(o) { if (typeof o != "undefined") { const oBBox = new THREE.Box3(); oBBox.setFromObject(o); bbox.union(oBBox); } } add(this.pathView); add(this.surfaceMesh); add(this.workpieceMesh); return bbox; }, snap: function(view) { if (this.loading) { return; } if (view != this.snapView) { this.snapView = view; cookie.set("snap-view", view); } const bbox = this.get_model_bounds(); this.controls.reset(); bbox.getCenter(this.controls.target); this.update_view(); // Compute new camera position const center = bbox.getCenter(new THREE.Vector3()); const offset = new THREE.Vector3(); switch (view) { case "isometric": offset.y -= 1; offset.z += 1; break; case "front": offset.y -= 1; break; case "back": offset.y += 1; break; case "left": offset.x -= 1; break; case "right": offset.x += 1; break; case "top": offset.z += 1; break; case "bottom": offset.z -= 1; break; } offset.normalize(); // Initial camera position const position = new THREE.Vector3().copy(center).add(offset); this.camera.position.copy(position); this.camera.lookAt(center); // Get correct camera orientation const theta = this.camera.fov / 180 * Math.PI; // View angle const cameraLine = new THREE.Line3(center, position); const cameraUp = new THREE.Vector3() .copy(this.camera.up) .applyQuaternion(this.camera.quaternion); const cameraLeft = new THREE.Vector3() .copy(offset) .cross(cameraUp) .normalize(); const corners = [ new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.min.z), new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.max.z), new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.min.z), new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.max.z), new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.min.z), new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.max.z), new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.min.z), new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.max.z), ]; let dist = this.camera.near; // Min camera dist for (let i = 0; i < corners.length; i++) { // Project on to camera line const p1 = cameraLine.closestPointToPoint(corners[i], false, new THREE.Vector3()); // Compute distance from projection to center let d = p1.distanceTo(center); if (cameraLine.closestPointToPointParameter(p1, false) < 0) { d = -d; } // Compute up line const up = new THREE.Line3(p1, new THREE.Vector3().copy(p1).add(cameraUp)); // Project on to up line const p2 = up.closestPointToPoint(corners[i], false, new THREE.Vector3()); // Compute length let l = p1.distanceTo(p2); // Update min camera distance dist = Math.max(dist, d + l / Math.tan(theta / 2)); // Compute left line const left = new THREE.Line3(p1, new THREE.Vector3().copy(p1).add(cameraLeft)); // Project on to left line const p3 = left.closestPointToPoint(corners[i], false, new THREE.Vector3()); // Compute length l = p1.distanceTo(p3); // Update min camera distance dist = Math.max(dist, d + l / Math.tan(theta / 2) / this.camera.aspect); } this.camera.position.copy(offset.multiplyScalar(dist * 1.2).add(center)); } }, mixins: [ require("./axis-vars") ] };