/** Copyright 2014-2019 Stewart Allen -- All Rights Reserved */ "use strict"; let gs_kiri_slicer = exports; (function() { if (!self.kiri) self.kiri = {}; if (self.kiri.slicer) return; let slicer = self.kiri.slicer = { slice: slice, sliceWidget: sliceWidget, connectLines: connectLines }; let KIRI = self.kiri, BASE = self.base, CONF = BASE.config, UTIL = BASE.util, POLY = BASE.polygons, time = UTIL.time, newSlice = KIRI.newSlice, newOrderedLine = BASE.newOrderedLine; /** * Convenience method. Gets a Widget's points and calls slice() * * @param {Widget} widget * @param {Object} options * @param {Function} ondone callback when slicing complete * @param {Function} onupdate callback on incremental updates */ function sliceWidget(widget, options, ondone, onupdate) { slice(widget.getPoints(), widget.getBoundingBox(), options, ondone, onupdate); } /** * Given an array of points as triples, a bounding box and a set of * slicing controls, emit an array of Slice objects to the ondone() * function. onupdate() will be called with two parameters (% completion * and an optional message) so that the UI can report progress to the user. * * @param {Array} points vertex array * @param {Bounds} bounds bounding box for points * @param {Object} options slicing parameters * @param {Function} ondone callback when slicing done * @param {Function} onupdate callback to report slicing progress */ function slice(points, bounds, options, ondone, onupdate) { let topoMode = options.topo, simpleMode = options.simple, swap = options.swapX || options.swapY, isCam = options.cam, ox = 0, oy = 0; // handle rotating meshes for CAM finishing. // slicer expects things just so, so we alter // geometry to satisfy if (swap) { points = points.slice(); let btmp = new THREE.Box3(), pref = {}, cached; btmp.setFromPoints(points); if (options.swapX) ox = -btmp.max.x; if (options.swapY) oy = -btmp.max.y; // array re-uses points so we need // to be careful not to alter a point // more than once for (let p, index=0; index bounds.min.z) { // detect zFlat faces to avoid slicing directly on them let zkey = p1.z.toFixed(5), area = Math.abs(UTIL.area2(p1,p2,p3)) / 2; if (!zFlat[zkey]) { zFlat[zkey] = area; } else { zFlat[zkey] += area; } } else if (true || options.trace) { // detect zLines (curved region tops/bottoms) // mark these layers for ball and v mill tracing if (p1.z === p2.z && p1.z > bounds.min.z) { let zkey = p1.z.toFixed(5); let zval = zLines[zkey]; zLines[zkey] = (zval || 0) + 1; } if (p2.z === p3.z && p2.z > bounds.min.z) { let zkey = p2.z.toFixed(5); let zval = zLines[zkey]; zLines[zkey] = (zval || 0) + 1; } if (p3.z === p1.z && p3.z > bounds.min.z) { let zkey = p3.z.toFixed(5); let zval = zLines[zkey]; zLines[zkey] = (zval || 0) + 1; } } } // CAM: find slice candidates to trace for ballmills and tapermills if (options.trace) { let zl = {}; let le; let zs = Object.entries(zLines).map(oe => { return oe.map(v => parseFloat(v)); }).sort((a,b) => { return b[0] - a[0]; }).forEach((e,i) => { if (i > 0) { let zd = le[0]-e[0]; if (zd > 0.1 && e[1] > 100) { // zl.push(e) zl[e[0].toFixed(5)] = e[1]; zFlat[e[0].toFixed(5)] = e[1]; } } if (e[1] > 10) { le = e; } }); zLines = zl; } /** * bucket polygons into z-bounded groups (inside or crossing) * to reduce the search space in complex models */ let bucketCount = Math.max(1, Math.ceil(zMax / (zSum / points.length)) - 1); zScale = 1 / (zMax / bucketCount); if (bucketCount > 1) { // create empty buckets for (i = 0; i < bucketCount + 1; i++) buckets.push([]); // copy triples into all matching z-buckets for (i = 0; i < points.length;) { p1 = points[i++]; p2 = points[i++]; p3 = points[i++]; let zm = Math.min(p1.z, p2.z, p3.z), zM = Math.max(p1.z, p2.z, p3.z), bm = Math.floor(zm * zScale), bM = Math.ceil(zM * zScale); for (j = bm; j < bM; j++) { buckets[j].push(p1); buckets[j].push(p2); buckets[j].push(p3); } } } // we need Z ordered list for laser auto or adaptive fdm slicing if (zInc === 0 || zIncMin) { for (let key in zList) { if (!zList.hasOwnProperty(key)) continue; zOrdered.push(parseFloat(key)); } zOrdered.sort(function(a,b) { return a - b}); } if (options.single) { // usually for laser single slice zIndexes.push(zMin + zInc); } else if (zInc === 0) { // use Z indices in auto slice mode for laser // find unique z-index offsets for slicing let zl = zOrdered for (i = 0; i < zl.length - 1; i++) { zIndexes.push((zl[i] + zl[i+1]) / 2); } } else if (isCam) { // re-divide slice height so that top and // bottom slices fall exactly on those faces zInc = (zMax - zMin) / (Math.floor(zMax / zInc) + 1); for (i = zMin; i < zMax; i += zInc) { zIndexes.push(i); } for (let key in zFlat) { // todo make threshold for flat detection configurable if (!zFlat.hasOwnProperty(key) || zFlat[key] < 10){ continue; } key = parseFloat(key); if (!zIndexes.contains(key) && key >= zMin) { zIndexes.push(key); } } // sort top down zIndexes.sort(function(a,b) { return b-a; }); // copy to store original z zHeights = zIndexes.slice(); } else if (zIncMin) { // FDM adaptive slicing let zPos = zMin + zIncFirst, zOI = 0, zDelta, zDivMin, zDivMax, zStep, nextZ, lzp = zPos; // first slice is fixed zHeights.push(zIncFirst); zIndexes.push(zIncFirst / 2); while (zPos < zMax && zOI < zOrdered.length) { nextZ = zOrdered[zOI++]; if (zPos >= nextZ) continue; zDelta = nextZ - zPos; if (zDelta < zIncMin) continue; zDivMin = Math.floor(zDelta / zIncMin); zDivMax = Math.floor(zDelta / zInc); if (zDivMax && zDivMax <= zDivMin) { if (zDelta % zInc > 0.01) zDivMax++; zStep = zDelta / zDivMax; // console.log(`--- zDivMax <= zDivMin ---`, zStep, zDelta % zInc) } else { zStep = zDelta; } // console.log({nextZ, zPos, zDelta, zStep, zDivMin, zDivMax}) while (zPos < nextZ) { zHeights.push(zStep); zIndexes.push(zPos + zStep / 2); zPos += zStep; // console.log({D: zPos - lzp, zPos}) // lzp = zPos; } } } else { // FDM fixed slicing if (options.firstHeight) { zIndexes.push(options.firstHeight / 2); zHeights.push(options.firstHeight); zMin = options.firstHeight; } for (i = zMin + zOff; i < zMax; i += zInc) { zIndexes.push(i); zHeights.push(zInc); } } // create a Slice for each z offset in the zIndexes array for (let i = 0; i < zIndexes.length; i++) { let ik = zIndexes[i].toFixed(5), onFlat = false, onLine = false; // ensure no slice through horizontal lines or planes if (zFlat[ik]) onFlat = true; if (zLines[ik]) onLine = true; if (onFlat || onLine) { zIndexes[i] += isCam ? 0.001 : -0.001; } // slice next layer and add to slices[] array let slice = sliceZ(zIndexes[i], zHeights[i], onFlat, onLine); // override z in cam in case it was moved for a flat or line if (isCam && slice) { slice.z = zHeights[i]; } onupdate(i / zIndexes.length); } // for cam, bottom as mandatory (hasFlats) if (isCam && slices.length > 0) { // slices[0].hasFlats = true; slices[slices.length-1].hasFlats = true; } // connect slices into linked list for island/bridge projections for (i=1; i { poly.swap(options.swapX, options.swapY); poly.move(move); poly.inner = null; }); } } slices.push(slice); return slice; } } /** * Given an array of input lines (line soup), find the path through * joining line ends that encompasses the greatest area without self * interesection. Eliminate used points and repeat. Unjoined lines * are permitted and handled after all other cases are handled. * * @param {Line[]} input * @param {number} [index] * @returns {Array} */ function connectLines(input) { // map points to all other points they're connected to let DBUG = BASE.debug, CONF = BASE.config, pmap = {}, points = [], output = [], connect = [], search = 1, nextMod = 1, bridge = CONF.bridgeLineGapDistance, p1, p2; function cachedPoint(p) { let cp = pmap[p.key]; if (cp) return cp; points.push(p); pmap[p.key] = p; p.mod = nextMod++; // unique seq ID for points p.toString = function() { return this.mod }; // point array concat return p; } function addConnected(p1, p2) { if (!p1.group) p1.group = [ p2 ]; else p1.group.push(p2); } function sliceAtTerm(path, term) { let idx, len = path.length; for (idx = 0; idx < len-1; idx++) { if (path[idx] === term) { return path.slice(idx); } } return path; } /** * using minimal recursion, follow points through connected lines * to form candidate output paths. */ function findPathsMinRecurse(point, path, paths, from) { let stack = [ ]; if (paths.length > 10000) { DBUG.log("excessive path options @ "+paths.length+" #"+input.length); return; } for (;;) { stack.push(point); let last = point, links = point.group; path.push(point); // use del to mark traversed path point.del = true; // set so point isn't used in another polygon search point.pos = search++; // seed path with two points to prevent redundant opposing seeks if (path.length === 1) { from = point; point = links[0]; continue; } if (links.length > 2) { // TODO optimize when > 2 and limit to left-most and right-most branches // for now, pursue all possible branches links.forEach(function(nextp) { // do not backtrack if (nextp === from) { return; } if (nextp.del) { paths.push(sliceAtTerm(path,nextp)); } else { findPathsMinRecurse(nextp, path.slice(), paths, point); } }); break; } else { point = links[0] === from ? links[1] : links[0]; from = last; // hit an open end if (!point) { path.open = true; paths.push(path); break; } // hit a point previously in the path (or start) if (point.del) { paths.push(sliceAtTerm(path,point)); break; } } } for (let i=0; i 2) output.push(poly.clean()); } // given an array of paths, emit longest to shortest // eliminating points from the paths as they are emitted // shorter paths any point eliminated are eliminated as candidates. function emitLongestAsPolygon(paths) { let longest = null, emitted = 0, closed = 0, open = 0; paths.forEach(function(path) { // use longest perimeter vs longest path? if (!longest || path.length > longest.length) longest = path; if (!path.open) closed++; else open++; }); // it gets more complicated with multiple possible output paths if (closed > 1 && open === 0) { // add polygon to path (for area sorting) paths.forEach(function(path) { path.poly = BASE.newPolygon().addPoints(path) }); // sort descending by area VS (length below -- better in most cases) // paths.sort(function(a,b) { return b.poly.area() - a.poly.area() }); // sort descending by length paths.sort(function(a,b) { return b.poly.length - a.poly.length }); // emit polygons largest to smallest // omit polygon if it intersects previously emitted (has del points) paths.forEach(function(path) { if (path.length < 3) return; let len = path.length, i; for (i = 0; i < len; i++) if (path[i].del) return; for (i = 0; i < len; i++) path[i].del = true; emit(path.poly); emitted++; }); } else { if (longest.open) { connect.push(longest); } else { emit(BASE.newPolygon().addPoints(longest)); } } } // create point map, unique point list and point group arrays input.forEach(function(line) { p1 = cachedPoint(line.p1.round(7)); p2 = cachedPoint(line.p2.round(7)); addConnected(p1,p2); addConnected(p2,p1); }); // first trace paths starting at dangling endpoinds (bad polygon soup) points.forEach(function(point) { // must not have been used and be a dangling end if (point.pos === 0 && point.group.length === 1) { let path = [], paths = []; findPathsMinRecurse(point, path, paths); if (paths.length > 0) emitLongestAsPolygon(paths); } }); // for each point, find longest path back to self points.forEach(function(point) { // must not have been used or be at a split if (point.pos === 0 && point.group.length === 2) { let path = [], paths = []; findPathsMinRecurse(point, path, paths); if (paths.length > 0) emitLongestAsPolygon(paths); } }); // return true if points are deemed "close enough" close a polygon function close(p1,p2) { return p1.distToSq2D(p2) <= 0.01; } // reconnect dangling/open polygons to closest endpoint for (let i=0; i