add bare-bones geo slicer and wire into stub for mesh rebuilder

This commit is contained in:
Stewart Allen 2022-01-26 21:06:30 -05:00
commit 5f11231e9e
7 changed files with 768 additions and 2 deletions

View file

@ -2,6 +2,7 @@
"use strict";
// dep: geo.base
(function() {
if (self.base.Bounds) return;

View file

@ -2,6 +2,7 @@
"use strict";
// dep: geo.bounds
(function() {
if (self.base.Polygon) return;

730
src/geo/slicer.js Normal file
View file

@ -0,0 +1,730 @@
/** Copyright Stewart Allen <sa@grid.space> -- 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<j; ) {
array.push(newPoint(points[i++], points[i++], points[i++]));
}
points = array;
}
// gather z-index stats
for (i = 0; i < points.length;) {
p1 = points[i++].round(3);
p2 = points[i++].round(3);
p3 = points[i++].round(3);
// used to calculate buckets
zSum += (Math.abs(p1.z - p2.z) + Math.abs(p2.z - p3.z) + Math.abs(p3.z - p1.z));
// use co-flat and co-line detection to adjust slice Z
if (p1.z === p2.z && p2.z === p3.z && p1.z >= 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<connect.length; i++) {
if (!bridge) {
emit(newPolygon().addPoints(root).setOpen());
continue;
}
// rollup root with arrays after until no more ends match
inner: while (true) {
let root = connect[i];
if (root.delete) break;
let rfirst = root[0],
rlast = root.peek(),
dist = rfirst.distToSq2D(rlast),
closest = { dist };
for (let j=i+1; j<connect.length; j++) {
let next = connect[j];
if (next.delete) continue;
let nfirst = next[0];
let nlast = next.peek();
// test last to next first
dist = rlast.distToSq2D(nfirst);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next }
}
// test last to next last
dist = rlast.distToSq2D(nlast);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next, reverse: next };
}
// test first to next first
dist = rfirst.distToSq2D(nfirst);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next, reverse: root };
}
// test last to next last
dist = rfirst.distToSq2D(nlast);
mingap = Math.min(mingap, dist);
if (dist < closest.dist && dist <= bridge) {
closest = { dist, next, swap: j };
}
}
let { next, reverse, swap } = closest;
if (next && closest.dist < bridge) {
if (debug) console.log({
rollup: root.slice(),
next: next.slice(),
reverse,
swap
});
if (reverse) {
reverse.reverse();
}
if (swap) {
next.appendAll(root);
connect[i] = next;
connect[swap] = root;
root.delete = true;
next.merged = true;
} else {
root.appendAll(next);
next.delete = true;
root.merged = true;
}
} else {
break inner;
}
}
}
bridge = mingap < Infinity ? Math.max(mingap + 0.01, bridge + 0.1) : bridge + 0.1;
if (debug) console.log({iter, bridge, mingap, bridgeMax});
} while (iter-- > 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;
})();

View file

@ -430,12 +430,16 @@ let tool = {
});
},
clean(models = selection.models()) {
clean(models) {
models = fallback(models);
tool.heal(models, { merge: false }).then(() => {
api.log.emit('cleaning complete').unpin();
api.selection.update();
});
},
rebuild(models) {
Promise.all(fallback(models).map(m => m.rebuild()));
}
};

View file

@ -297,8 +297,9 @@ function ui_build() {
h.div([
h.div({ _: "fix", class: "head" }),
// h.div({ class: "vsep" }),
h.button({ _: 'automatic', onclick: tool.repair }),
h.button({ _: 'analyze', onclick: tool.analyze }),
h.button({ _: 'repair', onclick: tool.repair }),
h.button({ _: 'rebuild', onclick: tool.rebuild }),
h.button({ _: 'clean', onclick: tool.clean }),
]),
]);

View file

@ -153,6 +153,15 @@ mesh.model = class MeshModel extends mesh.object {
});
}
rebuild() {
worker.model_rebuild({
matrix: this.matrix,
id: this.id
}).then(data => {
console.log({rebuild: this.od, data});
});
}
load(vertices, indices, normals) {
let geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(vertices, 3));

View file

@ -9,6 +9,14 @@ moto.client.start(`/code/mesh_pool?${gapp.version}`, moto.client.max() * 0);
// dep: ext.three
// dep: ext.three-bgu
// dep: geo.base
// dep: geo.line
// dep: geo.point
// dep: geo.polygon
// dep: geo.polygons
// dep: geo.bounds
// dep: geo.slicer
// dep: ext.clip2
gapp.finalize("mesh.work", [
"moto.license", // dep: moto.license
"moto.client", // dep: moto.client
@ -281,6 +289,18 @@ let model = {
faces.push(Math.min(a,b,c) / 3);
}
return { faces, edges, verts, point };
},
rebuild(data) {
let { id, matrix } = data;
log(`${id} | rebuilding...`);
let points = translate_encode(id, matrix);
log(`${id} | ${points.length} points`);
base.slice(points, { flat: true, autoDim: true }).then(sliced => {
console.log({sliced});
log(`${id} | ${sliced.length} slices`);
});
return [];
}
};