/** Copyright Stewart Allen -- All Rights Reserved */ "use strict"; /** * Slicing engine used by FDM, Laser, and SLA */ (function() { if (self.kiri.slicer) return; self.kiri.slicer = { slice, sliceZ, sliceWidget, connectLines, createSlice }; let KIRI = self.kiri, BASE = self.base, CONF = BASE.config, UTIL = BASE.util, POLY = BASE.polygons, time = UTIL.time, tracker = UTIL.pwait, 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 useFlats = options.flats, debug = options.debug, xray = options.xray, ox = 0, oy = 0; let zMin = options.zmin || options.firstHeight || Math.floor(bounds.min.z), zMax = options.zmax || Math.ceil(bounds.max.z), zInc = options.height, zIncMin = options.minHeight, zIncFirst = options.firstHeight || zInc, zOff = true ? zInc / 2 : 0, zHeights = [], // heights for zIndexes in adaptive mode zIndexes = [], // auto-detected z slicing offsets (laser/cam) zOrdered = [], // ordered list of Z indexes zThick = [], // ordered list of Z slice thickness (laser) zList = {}, // map count of z index points for adaptive slicing zFlat = {}, // map area of z index flat areas (cam) zLines = {}, // map count of z index lines zScale, // bucket span in z units timeStart = time(), zSum = 0.0, buckets = [], i, j = 0, k, p1, p2, p3, px, CPRO = KIRI.driver.CAM.process, concurrent = options.concurrent ? KIRI.minions.concurrent : 0; if (options.add) { zMax += zInc; } function countZ(z) { z = UTIL.round(z,5); zList[z] = (zList[z] || 0) + 1; } // gather z-index stats // these are used for auto-slicing in laser // and to flats detection in CAM mode for (i = 0; i < points.length;) { p1 = points[i++]; p2 = points[i++]; p3 = points[i++]; zSum += (Math.abs(p1.z - p2.z) + Math.abs(p2.z - p3.z) + Math.abs(p3.z - p1.z)); // laser auto-detect z slice points if (zInc === 0 || zIncMin) { countZ(p1.z); countZ(p2.z); countZ(p3.z); } // use co-flat and co-line detection to adjust slice Z if (p1.z === p2.z && p2.z === p3.z && p1.z > 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; } } } /** * 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); if (concurrent > 1) { if (bucketCount < concurrent) { bucketCount = concurrent; } else if (bucketCount > 100) { bucketCount = 100; } } zScale = 1 / (zMax / bucketCount); if (bucketCount > 1) { // create empty buckets for (i = 0; i < bucketCount + 1; i++) { buckets.push({ points: [], slices: [] }); } // 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); if (bm < 0) bm = 0; for (j = bm; j < bM; j++) { buckets[j].points.push(p1); buckets[j].points.push(p2); buckets[j].points.push(p3); } } } else { buckets.push({ points, slices: [] }); } // 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.indices) { zIndexes = options.indices; zHeights = zIndexes.map(v => options.height); } else if (useFlats) { zIndexes.appendAll(zOrdered); } else 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 // if zIncMin also present, then merge adjacent // slices less than that value if (zIncMin) { let last = undefined; zl = zl.filter(v => { if (last !== undefined && v - last < zIncMin) { return false; } last = v; return true; }); } for (i = 0; i < zl.length - 1; i++) { zIndexes.push((zl[i] + zl[i+1]) / 2); zThick.push(zl[i+1] - zl[i]); } } else if (zIncMin) { // console.log('adaptive slicing', zIncMin, ':', zInc, 'from', zMin, 'to', zMax); // FDM adaptive slicing let zPos = zIncFirst, zOI = 0, zDelta, zDivMin, zDivMax, zStep, nextZ, lzp = zPos; // first slice is fixed zHeights.push(zIncFirst); zIndexes.push(zIncFirst / 2); // console.log({zIncFirst, zOrdered}) while (zPos < zMax && zOI < zOrdered.length) { nextZ = zOrdered[zOI++]; if (zPos >= nextZ) { // console.log('skip',{zPos},'>=',{nextZ}); continue; } zDelta = nextZ - zPos; if (zDelta < zIncMin) { // console.log('skip',{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 { // console.log('fixed slicing', zInc, 'from', zMin, 'to', zMax); // 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 buckets data structure 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 (!useFlats && (onFlat || onLine)) { zIndexes[i] -= -0.001; } bucketZ(i, zIndexes[i], zHeights[i], onFlat, onLine, zThick[i]); onupdate((i / zIndexes.length) * 0.1); } // create slices from each bucketed region sliceBuckets().then(slices => { slices = slices.sort((a,b) => a.index - b.index); // connect slices into linked list for island/bridge projections for (i=1; i KIRI.minions.sliceBucket(bucket, options, output) ); await tracker(promises, (i,t,d) => { onupdate(0.1 + (i / t) * 0.9); }); } else { let count = 0; for (let bucket of buckets) { for (let params of bucket.slices) { output.push(createSlice( params, sliceZ(params.z, bucket.points, options, params), options )); onupdate(0.1 + (count++ / zIndexes.length) * 0.9); } } } return output; } } function createSlice(params, data, options = {}) { let { index, z, height, thick } = params; let { lines, groups, tops } = data; let slice = newSlice(z).addTops(tops); slice.height = height; slice.index = index; slice.thick = thick; // debugging (non-threaded mode only) let { debug, xray, view } = options; if (view && (debug || xray)) { if ((debug && lines.excessive) || xray) { const dash = xray || 3; lines.forEach((line, i) => { const group = i % dash; const color = [ 0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff ][group]; slice.output().setLayer(`xl-${group}`, color).addLine(line.p1, line.p2); }); } POLY.nest(groups).forEach((poly, i) => { slice.addTop(poly); if (xray) { const group = i % (xray || 3); const color = [ 0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff ][group]; slice.output().setLayer(`xg-${group}`, color).addPoly(poly); } }); } return slice; } /** ***** SLICING FUNCTIONS ***** */ /** * given a point, append to the correct * 'where' objec tarray (on, over or under) * * @param {Point} p * @param {number} z offset * @param {Obejct} where */ function checkUnderOverOn(p, z, where) { let delta = p.z - z; if (Math.abs(delta) < CONF.precision_slice_z) { // on where.on.push(p); } else if (delta < 0) { // under where.under.push(p); } else { // over where.over.push(p); } } /** * Given a point over and under a z offset, calculate * and return the intersection point on that z plane * * @param {Point} over * @param {Point} under * @param {number} z offset * @returns {Point} intersection point */ function intersectPoints(over, under, z) { let ip = []; for (let i = 0; i < over.length; i++) { for (let j = 0; j < under.length; j++) { ip.push(over[i].intersectZ(under[j], z)); } } return ip; } /** * Ensure points are unique with a cache/key algorithm */ function getCachedPoint(phash, p) { let cached = phash[p.key]; if (!cached) { phash[p.key] = p; return p; } return cached; } /** * Given two points and hints about their edges, * return a new Line object with points sorted * lexicographically by key. This allows for future * line de-duplication and joins. * * @param {Object} phash * @param {Point} p1 * @param {Point} p2 * @param {boolean} [coplanar] * @param {boolean} [edge] * @returns {Line} */ function makeZLine(phash, p1, p2, coplanar, edge) { p1 = getCachedPoint(phash, p1); p2 = getCachedPoint(phash, p2); let line = newOrderedLine(p1,p2); line.coplanar = coplanar || false; line.edge = edge || false; return line; } /** * process a single z-slice on a single mesh and * add to slices array * * @param {number} z * @param {number} [height] optional real height (fdm) */ function sliceZ(z, points, options = {}, params = {}) { let phash = {}, lines = [], p1, p2, p3; // iterate over matching buckets for this z offset for (let i = 0; i < points.length; ) { p1 = points[i++]; p2 = points[i++]; p3 = points[i++]; let where = {under: [], over: [], on: []}; checkUnderOverOn(p1, z, where); checkUnderOverOn(p2, z, where); checkUnderOverOn(p3, z, where); if (where.under.length === 3 || where.over.length === 3) { // does not intersect (all 3 above or below) } else if (where.on.length === 2) { // one side of triangle is on the Z plane and 3rd is below // drop lines with 3rd above because that leads to ambiguities // with complex nested polygons on flat surface if (where.under.length === 1) { lines.push(makeZLine(phash, where.on[0], where.on[1], false, true)); } } else if (where.on.length === 3) { // triangle is coplanar with Z // we drop these because this face is attached to 3 others // that will satisfy the if above (line) with 2 points } else if (where.under.length === 0 || where.over.length === 0) { // does not intersect but one point is on the slice Z plane } else { // compute two point intersections and construct line let line = intersectPoints(where.over, where.under, z); if (line.length < 2 && where.on.length === 1) { line.push(where.on[0]); } if (line.length === 2) { lines.push(makeZLine(phash, line[0], line[1])); } else { console.log({msg: "invalid ips", line: line, where: where}); } } } if (lines.length == 0 && options.noEmpty) { return; } // de-dup and group lines lines = removeDuplicateLines(lines); let groups = connectLines(lines, z); // simplistic healing of bad meshes if (options.union) { groups = POLY.flatten(POLY.union(POLY.nest(groups), null, true), null, true); } let tops = POLY.nest(groups); let data = { lines, groups, tops }; // look for driver-specific slice post-processor if (options.mode) { let fn = KIRI.driver[options.mode].slicePost; if (fn) { fn(data, options, params); } } return data; } /** * 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, z) { // 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(`indeterminate path @ ${z} from paths=${paths.length} input=${input.length}`); input.excessive = paths.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; } } } // undo temp del/used marks for (let i=0; i 2) output.push(poly); } // 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(4)); p2 = cachedPoint(line.p2.round(4)); 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