/** Copyright Stewart Allen -- All Rights Reserved */ "use strict"; /** * Basic slice and line connection */ (function() { let base = self.base; if (base.slice) return; let { config, util, polygons } = base let { newOrderedLine, newPolygon, newPoint } = base; /** * 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 {Point[]} points vertex array * @param {Object} options slicing parameters */ async function slice(points, options = {}) { let zMin = options.zmin || 0, zMax = options.zmax || 0, zInc = options.zinc || 0, zIndexes = options.indices || [], zFlat = {}, // map area of z index flat areas zList = {}, // fast map of z indexes zScale, // bucket span in z units zSum = 0.0, // sanity check that points enclose non-zere volume buckets = [], // banded/grouped faces to speed up slice/search onupdate = options.onupdate || function() {}, i, j, p1, p2, p3; if (!(points && points.length)) { throw "missing points array"; } // convert threejs position array into points array if (options.flat) { let array = []; for (i=0, j=points.length; i= zMin) { // detect faces co-planar with Z and sum the enclosed area 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; } } if (options.autoDim) { zMin = Math.min(zMin, p1.z, p2.z, p3.z); zMax = Math.max(zMax, p1.z, p2.z, p3.z); } zList[p1.z] = p1.z; zList[p2.z] = p2.z; zList[p3.z] = p3.z; } if (zInc) { for (i = zMin; i < zMax; i += zInc) { zIndexes.push(i); } } else { zIndexes = Object.values(zList).sort((a,b) => a - b); } /** short-circuit for microscopic and invalid objects */ if (zMax == 0 || zSum == 0 || points.length == 0) { return []; } /** * bucket polygons into z-bounded groups (inside or crossing) * to reduce the search space in complex models */ let bucketCount = options.bucket !== false ? Math.max(1, Math.ceil(zMax / (zSum / points.length)) - 1) : 1; zScale = 1 / (zMax / bucketCount); console.log({zMin, zMax, zIndexes, points, bucketCount, zScale, zSum}); 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: [] }); } // create buckets data structure for (let i = 0; i < zIndexes.length; i++) { let ik = zIndexes[i].toFixed(5); // ensure no slice through horizontal lines or planes if (zFlat[ik]) { zIndexes[i] -= -0.001; } bucketZ(i, zIndexes[i]); onupdate((i / zIndexes.length) * 0.1); } function bucketZ(index, z) { buckets[Math.floor(z * zScale)].slices.push({ index, z, total: zIndexes.length }); } async function sliceBuckets() { let output = []; let count = 0; for (let bucket of buckets) { for (let params of bucket.slices) { output.push({ slice: sliceZ(params.z, bucket.points, options, params), params, options }); onupdate(0.1 + (count++ / zIndexes.length) * 0.9); } } return output; } // create slices from each bucketed region let slices = await sliceBuckets(); return slices.sort((a,b) => a.index - b.index); } /** * 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) < config.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 */ function sliceZ(z, points, options = {}) { 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, { debug: options.debug, }); let tops = polygons.nest(groups); let data = { lines, groups, tops }; 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, opt = {}) { let { debug } = opt; // map points to all other points they're connected to let pmap = {}, points = [], output = [], connect = [], emitted = 0, forks = false, frays = false, bridge = config.bridgeLineGapDistance, bridgeMax = config.bridgeLineGapDistanceMax, p1, p2, gl; function cachedPoint(p) { let cp = pmap[p.key]; if (cp) return cp; points.push(p); pmap[p.key] = p; return p; } function addConnected(p1, p2) { if (!p1.group) p1.group = [ p2 ]; else p1.group.push(p2); } function perimeter(array) { if (!array.perimeter) { array.perimeter = newPolygon().addPoints(array).perimeter(); } return array.perimeter; } /** * follow points through connected lines to form candidate output paths */ function findNextPath(point, current, branches, depth = 1) { let path = []; if (current) { current.push(path); } for (;;) { // prevent point re-use point.del = true; // add point to path path.push(point); let links = point.group.filter(p => !p.del); // no need to recurse at the start if (links.length === 2 && depth === 1) { point = links[0]; // if (debug) console.log({start_mid: point, depth}); continue; } // if fork in the road, follow all paths to their end // and find the longest path let root = !current, nc; if (links.length > 1) { // if (debug) console.log('fork!', {links: links.length, depth, root}); if (root) { current = [ path ]; branches = [ ]; } for (let p of links) { branches.push(nc = current.slice()); let rpath = findNextPath(p, nc, branches, depth + 1); // allow point re-use in other path searches for (let p of rpath) p.del = false; } // flatten and sort in ascending perimeter let flat = branches.map(b => b.flat()).sort((a,b) => { return perimeter(b) - perimeter(a); }); let npath = flat[0]; if (debug) console.log({ root, branches: branches.slice(), flat, path, npath }); if (root) { for (let p of npath) p.del = true; return npath; } else { return path; } // return root ? npath : path; } else { // choose next (unused) point point = links[0]; } // hit an open end or branch if (!point || point.del) { return path; } } throw "invalid state"; } // emit a polygon if it can be cleaned and still have 2 or more points function emit(poly) { emitted += poly.length; poly = poly.clean(); if (poly.length > 2 || true) output.push(poly); if (debug) console.log('xray',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 emitPath(path) { let closed = path[0].group.indexOf(path.peek()) >= 0; if (closed && path.length > 2) { if (debug) console.log({ closed: path.length, path }); emit(newPolygon().addPoints(path)); } else if (path.length > 1) { let gap = path[0].distTo2D(path.peek()).round(4); if (debug) console.log({ open: path.length, gap, path }); connect.push(path); } } // create point map, unique point list and point group arrays input.forEach(function(line) { p1 = cachedPoint(line.p1); p2 = cachedPoint(line.p2); addConnected(p1,p2); addConnected(p2,p1); }); // console.log({points, forks: points.filter(p => p.group.length !== 2)}); // for each unused point, find the longest non-intersecting path for (let point of points) { gl = point.group.length; forks = forks || gl > 2; frays = frays || gl < 2; } if (debug && (forks || frays)) console.log({forks, frays}); // process paths starting with forks if (forks) { if (debug) console.log('process forks'); for (let point of points) { // must not have been used and be a dangling end if (!point.del && point.group.length > 2) { let path = findNextPath(point); if (path) emitPath(path); } } } // process paths with dangling endpoints if (frays) { if (debug) console.log('process frays'); for (let point of points) { // must not have been used and be a dangling end if (!point.del && point.group.length === 1) { let path = findNextPath(point); if (path) emitPath(path); } } } // process normal paths if (debug) console.log('process mids'); for (let point of points) { // must not have been used and be a dangling end if (!point.del) { let path = findNextPath(point); if (path) emitPath(path); } } if (debug) console.log({ points, emitted, used: points.filter(p => p.del), free: points.filter(p => !p.del), }); if (debug && connect.length) console.log({connect}); if (debug) connect = connect.map(a => a.slice()); // progressively connect open polygons within a bridge distance let iter = 1000; let mingap; if (true) do { mingap = Infinity; outer: for (let i=0; i 0 && bridge && bridge < bridgeMax && mingap < bridgeMax); if (debug) console.log({ remain: connect.filter(c => !c.delete) }); for (let array of connect) { if (array.delete) continue; let first = array[0]; let last = array.peek(); let dist = first.distToSq2D(last); if (debug) console.log({ dist: dist.round(4), merged: array.merged || false, array }); if (dist <= bridge) { // tail meets head (close) emit(newPolygon().addPoints(array)); } else { // tail meets head (far) emit(newPolygon().addPoints(array) // .setOpen() ); } } if (debug) console.log({ emitted }); if (debug && emitted < points.length) console.log({ leftovers:points.length - emitted }); return output; } /** * eliminate duplicate lines and interior-only lines (coplanar) * * lines are sorted using lexicographic point keys such that * they are comparable even if their points are reversed. hinting * for deletion, co-planar and suspect shared edge is detectable at * this time. * * @param {Line[]} lines * @returns {Line[]} */ function removeDuplicateLines(lines) { let output = [], tmplines = [], points = [], pmap = {}; function cachePoint(p) { let cp = pmap[p.key]; if (cp) return cp; points.push(p); pmap[p.key] = p; return p; } function addLinesToPoint(point, line) { cachePoint(point); if (!point.group) point.group = [ line ]; else point.group.push(line); } // mark duplicates for deletion preserving edges lines.sort(function (l1, l2) { if (l1.key === l2.key) { l1.del = !l1.edge; l2.del = !l2.edge; return 0; } return l1.key < l2.key ? -1 : 1; }); // associate points with their lines, cull deleted for (let line of lines) { if (!line.del) { tmplines.push(line); addLinesToPoint(line.p1, line); addLinesToPoint(line.p2, line); } } // merge collinear lines for (let point of points) { // only merge when point connects to exactly one other point if (point.group.length != 2) { continue; } let l1 = point.group[0], l2 = point.group[1]; if (l1.isCollinear(l2)) { l1.del = true; l2.del = true; // find new endpoints that are not shared point let p1 = l1.p1 != point ? l1.p1 : l1.p2, p2 = l2.p1 != point ? l2.p1 : l2.p2, newline = newOrderedLine(p1,p2); // remove deleted lines from associated points p1.group.remove(l1); p1.group.remove(l2); p2.group.remove(l1); p2.group.remove(l2); // associate new line with points p1.group.push(newline); p2.group.push(newline); // add new line to lines array newline.edge = l1.edge || l2.edge; tmplines.push(newline); } } // mark duplicates for deletion // but preserve one if it's an edge tmplines.sort(function (l1, l2) { if (l1.key === l2.key) { l1.del = true; l2.del = !l2.edge; return 0; } return l1.key < l2.key ? -1 : 1; }); // create new line array culling deleted for (let line of tmplines) { if (!line.del) { output.push(line); line.p1.group = null; line.p2.group = null; } } return output; } base.slice = slice; })();