extract poly utils into their own static class

This commit is contained in:
Stewart Allen 2025-12-23 20:12:47 -05:00
commit deb3e0faa9
5 changed files with 1280 additions and 942 deletions

325
src/geo/poly/arcs.js Normal file
View file

@ -0,0 +1,325 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { base, util } from '../base.js';
import { newPoint } from '../point.js';
/**
* Arc detection utility functions for polygons.
* These utilities detect and annotate circular arc sequences in polygon points.
*/
/**
* Calculate the center point of a circle defined by three points.
* Using perimeter length, finds 3 equally spaced points to compute circle center.
*
* @param {Polygon} poly - polygon assumed to be roughly circular
* @returns {Point} center point of the circle
*/
export function calcCircleCenter(poly) {
let pe = poly.perimeter(),
pe1 = pe / 3, // point 2
pe2 = pe1 * 2, // point 3
p = poly.points,
l = p.length,
i = 1,
td = 0,
lp = p[0], // first point
ap = [ lp ]; // array of 3 points
while (i < l) {
let np = p[i++];
let d = lp.distTo2D(np);
td += d;
if (ap.length === 1 && td >= pe1) {
ap.push(np);
} else if (ap.length === 2 && (td >= pe2 || i >= l)) {
ap.push(np);
break;
}
lp = np;
}
let center = util.center2d(...ap);
return newPoint(center.x, center.y, p[0].z, null);
}
/**
* Iterate over polygon points and find sequences of 5 or more points
* with a common center point and collect as midpoint/radius.
*
* @param {Polygon} poly - polygon to analyze
* @param {Object} opt - options
* @param {number} opt.tolerance - center matching tolerance (default 1e-2)
* @param {boolean} opt.inside - require centers be inside polygon (default true)
* @returns {Array} array of arc center records
*/
export function findArcCenters(poly, opt = {}) {
if (poly.length < 6) return [];
let tolerance = opt.tolerance || 1e-2,
inside = opt.inside ?? true,
seq = poly.points.slice(),
util = base.util;
if (poly.isClosed()) seq.appendAll(seq.slice(0,5));
let recs = [], // accumulated arc points
cand = []; // candidate center array
for (let pos=3; pos<seq.length; pos++) {
let next = util.circleCenter(...seq.slice(pos-3, pos));
let prev = cand.peek();
if (inside && !newPoint(next.x,next.y).inPolygon(poly)) {
// require point be inside current polygon
next = null;
}
if (next && !prev) {
// seed candidate list
cand.push(next);
continue;
}
if (!next) {
// next is a bust, reset candidate list
cand.length = 0;
continue;
}
if (util.dist2D(next,prev) < tolerance) {
// add new candidate
cand.push(next);
continue;
} else if (cand.length >= 3) {
// emit record on 5 points
recs.push(cand.peek());
}
// reset candidate array
cand = [ next ];
}
if (cand.length >= 3) {
recs.push(cand.peek());
}
// filter dups
recs.sort((a,b) => {
return util.dist2D(a,b);
});
recs = recs.filter((r,i) => {
if (i > 0) {
return util.dist2D(r, recs[i-1]) < tolerance*10 ? null : r;
} else {
return r;
}
});
return recs;
}
/**
* Detect and annotate arc sequences in polygon points.
* When an arc is detected, the first point is annotated with arc metadata
* and intermediate points remain in the array but should be skipped during emission.
*
* @param {Polygon} poly - polygon to analyze (will be mutated with arc annotations)
* @param {Object} opts - detection options
* @param {number} opts.tolerance - arc detection tolerance (default 0.1)
* @param {number} opts.arcRes - arc resolution in radians (default 0.1)
* @param {number} opts.minPoints - minimum points to consider an arc (default 4)
* @returns {Polygon} this polygon with arc annotations
*/
export function detectArcs(poly, opts = {}) {
const {
tolerance = 0.1,
arcRes = 0.1,
minPoints = 4
} = opts;
const points = poly.points;
const length = points.length;
if (length < minPoints) {
return poly;
}
// Clear any existing arc annotations
for (let p of points) {
delete p.arc;
}
let i = 0;
// Process all points except we can't start an arc at the last point
// because we need at least minPoints to form an arc
while (i < length - minPoints + 1) {
// Try to detect arc starting at position i
const arcData = detectArcAt(poly, i, points, length, tolerance, arcRes, minPoints);
if (arcData) {
// Annotate the starting point
points[i].arc = arcData;
// Skip past the arc points
i += arcData.skip + 1; // +1 to move to point after arc end
} else {
i++;
}
}
return poly;
}
/**
* Try to detect an arc starting at the given index.
* Uses a greedy approach: grow the arc as long as possible, then validate.
*
* @private
* @param {Polygon} poly - polygon being analyzed
* @param {number} startIdx - starting index
* @param {Point[]} points - polygon points
* @param {number} length - points length
* @param {number} tolerance - detection tolerance
* @param {number} arcRes - arc resolution
* @param {number} minPoints - minimum points for arc
* @returns {Object|null} arc data or null if no arc detected
*/
export function detectArcAt(poly, startIdx, points, length, tolerance, arcRes, minPoints) {
// Greedy approach: collect as many points as possible that might form an arc
let candidates = [];
for (let idx = startIdx; idx < length; idx++) {
const p1 = points[idx];
// Last point - add and done
if (idx >= length - 1) {
candidates.push(p1);
break;
}
const p2 = points[idx + 1];
// Skip duplicate points
if (p1.distTo2D(p2) < 0.001) {
break;
}
candidates.push(p1);
// Stop if we've collected enough to test
if (candidates.length >= minPoints) {
// Try to validate the arc with current candidates
const arcData = validateArc(poly, candidates, tolerance, arcRes, minPoints);
if (!arcData) {
// Current set doesn't form valid arc, back up one point
candidates.pop();
break;
}
}
}
// Final validation with all collected candidates
return validateArc(poly, candidates, tolerance, arcRes, minPoints);
}
/**
* Validate if a set of points forms a valid arc.
*
* @private
* @param {Polygon} poly - polygon being analyzed
* @param {Point[]} arcPoints - candidate arc points
* @param {number} tolerance - detection tolerance
* @param {number} arcRes - arc resolution
* @param {number} minPoints - minimum points for arc
* @returns {Object|null} arc data or null if invalid
*/
export function validateArc(poly, arcPoints, tolerance, arcRes, minPoints) {
if (arcPoints.length < minPoints) {
return null;
}
// Calculate center using well-distributed points
const center = findBestCenter(arcPoints, tolerance);
if (!center) {
return null;
}
// Check if all points lie on the circle within tolerance
let maxRadiusError = 0;
let sumRadiusError = 0;
for (let i = 0; i < arcPoints.length; i++) {
const p = arcPoints[i];
const radius = Math.hypot(p.x - center.x, p.y - center.y);
const radiusError = Math.abs(radius - center.r);
maxRadiusError = Math.max(maxRadiusError, radiusError);
sumRadiusError += radiusError;
// Hard limit on any single point
if (radiusError > tolerance * 2) {
return null;
}
}
// Check average error is reasonable
const avgRadiusError = sumRadiusError / arcPoints.length;
if (avgRadiusError > tolerance) {
return null;
}
// Check angular resolution (don't want points too far apart)
for (let i = 0; i < arcPoints.length - 1; i++) {
const curr = arcPoints[i];
const next = arcPoints[i + 1];
const dist = curr.distTo2D(next);
const radius = Math.hypot(curr.x - center.x, curr.y - center.y);
if (radius > 0) {
const angle = 2 * Math.asin(Math.min(1, dist / (2 * radius)));
if (Math.abs(angle) > arcRes) {
return null;
}
}
}
// Determine arc direction
const p0 = arcPoints[0];
const p1 = arcPoints[Math.min(1, arcPoints.length - 1)];
const vec1 = { x: p1.x - p0.x, y: p1.y - p0.y };
const vec2 = { x: center.x - p0.x, y: center.y - p0.y };
const cross = vec1.x * vec2.y - vec1.y * vec2.x;
const clockwise = cross < 0;
return {
center: newPoint(center.x, center.y, p0.z),
clockwise,
skip: arcPoints.length - 1
};
}
/**
* Find the best-fit center for a set of arc points.
*
* @private
* @param {Point[]} arcPoints - candidate arc points
* @param {number} tolerance - detection tolerance
* @returns {Object|null} center with x, y, r properties or null
*/
export function findBestCenter(arcPoints, tolerance) {
const len = arcPoints.length;
// Use 3 well-spaced points for initial center calculation
let idx1 = 0;
let idx2 = Math.floor(len / 2);
let idx3 = len - 1;
// If first and last are very close (near-circle), use different points
if (arcPoints[idx1].distTo2D(arcPoints[idx3]) < tolerance * 2) {
idx1 = Math.floor(len * 0.25);
idx2 = Math.floor(len * 0.5);
idx3 = Math.floor(len * 0.75);
}
const center = util.center2d(
arcPoints[idx1],
arcPoints[idx2],
arcPoints[idx3],
1
);
if (!center || center.hasNaN?.() || !isFinite(center.r)) {
return null;
}
return center;
}

280
src/geo/poly/geo.js Normal file
View file

@ -0,0 +1,280 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { base, config, util } from '../base.js';
import { calc_normal, calc_vertex } from '../paths.js';
import { newPolygon, slopeDiff } from '../polygon.js';
/**
* Geometric utility functions for polygons.
* These are extracted from the Polygon class to improve modularity.
* All functions take a polygon as the first parameter.
*/
/**
* Remove points that are closer than `dist` to the previous point.
* Returns a new polygon with filtered points, or null if result would have < 2 points.
*
* @param {Polygon} poly - source polygon
* @param {number} dist - minimum distance between points (defaults to precision_merge)
* @returns {Polygon|null} new filtered polygon or null
*/
export function debur(poly, dist) {
if (poly.length < 2) {
return null;
}
const pa = poly.points,
pln = pa.length,
open = poly.open,
newp = newPolygon().copyZ(poly.getZ()),
min = dist || base.config.precision_merge;
let lo;
newp.push(lo = pa[0]);
for (let i = 1; i < pln; i++) {
if (lo.distTo2D(pa[i]) >= min) {
newp.push(lo = pa[i]);
}
}
newp.open = open;
newp.parent = poly.parent;
if (newp.length < 2) {
return null;
}
return newp;
}
/**
* Calculate miter joins for polygon points where angle exceeds 90 degrees.
* Returns either the original polygon or a new one with miter adjustments.
*
* @param {Polygon} poly - source polygon
* @param {boolean} debug - debug flag
* @returns {Polygon} polygon with miters applied
*/
export function miter(poly, debug) {
if (poly.length < 3) return poly;
const slo = [],
pa = poly.points,
pln = pa.length,
open = poly.open;
let last;
for (let i = 1; i < pln; i++) {
slo.push(pa[i - 1].slopeTo(last = pa[i]));
}
if (!open) {
slo.push(last.slopeTo(pa[0]));
}
const ang = new Array(pln).fill(0);
let redo = false;
const aln = open ? pln - 1 : pln;
for (let i = 1; i < aln; i++) {
ang[i] = slopeDiff(slo[i - 1], slo[i]);
redo |= ang[i] > 90;
}
if (!open) {
ang[0] = slopeDiff(slo[pln - 1], slo[0]);
redo |= ang[pln - 1] > 90;
redo |= ang[0] > 90;
}
if (redo) {
const newp = newPolygon().copyZ(poly.z);
newp.open = open;
for (let i = 0; i < pln; i++) {
const p = pa[(i + pln) % pln];
const d = ang[(i + pln) % pln];
if (d > 179) {
const s = slo[(i + pln) % pln];
const pp = pa[(i + pln - 1) % pln];
const ps = slo[(i + pln - 1) % pln];
newp.push(p.follow(p.slopeTo(pp).normal(), 0.001));
newp.push(p.follow(s.clone().normal().invert(), 0.001));
} else if (d > 90) {
const s = slo[(i + pln) % pln];
const pp = pa[(i + pln - 1) % pln];
const ps = slo[(i + pln - 1) % pln];
newp.push(p.follow(p.slopeTo(pp), 0.001));
newp.push(p.follow(s, 0.001));
} else {
p.parent = newp;
newp.push(p);
}
}
return newp;
}
return poly;
}
/**
* Create convex hull from points and add to polygon (mutates polygon).
* Only used in fdm/slice.js for enclosing supports.
*
* @param {Polygon} poly - target polygon (will be mutated)
* @param {Point[]} points - points to form convex hull
* @returns {Polygon} the mutated polygon
*/
export function createConvexHull(poly, points) {
function removeMiddle(a, b, c) {
let cross = (a.x - b.x) * (c.y - b.y) - (a.y - b.y) * (c.x - b.x);
let dot = (a.x - b.x) * (c.x - b.x) + (a.y - b.y) * (c.y - b.y);
return cross < 0 || cross == 0 && dot <= 0;
}
points.sort(function(a, b) {
return a.x != b.x ? a.x - b.x : a.y - b.y;
});
let n = points.length;
let hull = [];
for (let i = 0; i < 2 * n; i++) {
let j = i < n ? i : 2 * n - 1 - i;
while (hull.length >= 2 && removeMiddle(hull[hull.length - 2], hull[hull.length - 1], points[j]))
hull.pop();
hull.push(points[j]);
}
hull.pop();
poly.addPoints(hull);
return poly;
}
/**
* Find all intersections of a line segment with polygon edges.
* Results are sorted by distance from lp1.
*
* @param {Polygon} poly - polygon to test
* @param {Point} lp1 - line segment start
* @param {Point} lp2 - line segment end
* @param {boolean} deep - recurse into inner polygons
* @returns {Point[]} array of intersection points
*/
export function intersections(poly, lp1, lp2, deep) {
let list = [];
poly.forEachSegment(function(pp1, pp2, ip1, ip2) {
let int = util.intersect(lp1, lp2, pp1, pp2, base.key.SEGINT, false);
if (int) {
list.push(int);
pp1.pos = ip1;
pp2.pos = ip2;
}
});
if (deep && poly.inner) {
poly.inner.forEach(p => {
let ints = intersections(p, lp1, lp2);
if (ints) list.appendAll(ints);
});
}
list.sort(function(p1, p2) {
return util.distSq(lp1, p1) - util.distSq(lp1, p2);
});
return list;
}
/**
* Walk points noting z deltas and smoothing z sawtooth patterns.
* Used to smooth low-rez surface contouring (mutates polygon).
*
* @param {Polygon} poly - polygon to smooth (mutated)
* @param {number} passes - number of smoothing passes
* @returns {Polygon} the mutated polygon
*/
export function refine(poly, passes = 0) {
for (let j = 0; j < passes; j++) {
let points = poly.points,
length = points.length,
sn = [], // segment normals
vn = []; // vertex normals
for (let i = 0; i < length; i++) {
let p1 = points[i];
let p2 = points[(i + 1) % length];
sn.push(calc_normal(p1, p2));
}
for (let i = 0; i < length; i++) {
let n1 = sn[(i + length - 1) % length];
let n2 = sn[i];
let vi = calc_vertex(n1, n2, 1);
vn.push(vi);
let vl = Math.abs(1 - vi.vl).round(2);
// vl should be close to zero on smooth / continuous curves
// factoring out hard turns, we smooth the z using the weighted
// z values of the points before and after the current point
if (vl === 0) {
let p0 = points[(i + length - 1) % length];
let p1 = points[i];
let p2 = points[(i + 1) % length];
p1.z = (p0.z + p2.z + p1.z) / 3;
}
}
}
return poly;
}
/**
* Test if two polygons are essentially equivalent in shape and position.
* Checks area, bounds, circularity, and point-to-segment distances.
*
* @param {Polygon} poly1 - first polygon
* @param {Polygon} poly2 - second polygon
* @param {boolean} recurse - also check inner polygons
* @param {number} precision - tolerance for comparisons
* @returns {boolean} true if polygons are equivalent
*/
export function isEquivalent(poly1, poly2, recurse, precision) {
let area1 = Math.abs(poly1.area());
let area2 = Math.abs(poly2.area());
if (util.isCloseTo(area1, area2, precision || config.precision_poly_area) &&
poly1.bounds.equals(poly2.bounds, precision || config.precision_poly_bounds)) {
// use circularity near 1 to eliminate the extensive check below
let c1 = poly1.circularity(),
c2 = poly2.circularity();
if (Math.abs(c1 - c2) < config.precision_circularity && ((1 - c1) < config.precision_circularity)) {
return true;
}
if (recurse) {
let i, ai = poly1.inner,
bi = poly2.inner;
if (ai !== bi) {
if (ai === null || bi === null || ai.length != bi.length) {
return false;
}
for (i = 0; i < ai.length; i++) {
if (!isEquivalent(ai[i], bi[i])) {
return false;
}
}
}
}
let exit = true,
pointok,
dist,
min;
poly1.forEachPoint(i2p => {
pointok = false;
poly2.forEachSegment((i1p1, i1p2) => {
// if point is close to poly, terminate search, go to next point
if ((dist = i2p.distToLine(i1p1, i1p2)) < config.precision_poly_merge) {
return pointok = true;
}
// otherwise track min and keep searching
min = Math.min(min, dist);
});
// fail poly if one point is bad
if (!pointok) {
exit = false;
// terminate search
return true;
}
});
return exit;
}
return false;
}

276
src/geo/poly/mesh.js Normal file
View file

@ -0,0 +1,276 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { earcut as earcutLib } from '../base.js';
import { newPolygon } from '../polygon.js';
import { polygons as POLY } from '../polygons.js';
/**
* Mesh generation utility functions for polygons.
* These utilities convert 2D polygons into 3D meshes and geometry.
*/
/**
* Triangulate polygon using earcut library.
* Returns array of triangular polygons with preserved alignment/swap info.
*
* @param {Polygon} poly - polygon to triangulate
* @returns {Polygon[]} array of triangular polygons
*/
export function earcut(poly) {
// gather all points into a single array including inner polys
// keeping track of array offset indices for inners
let out = [];
let holes = [];
// flatten points into array for earcut()
poly.points.forEach(p => {
out.push(p.x, p.y, p.z);
});
// add hole offsets for inner polygons
if (poly.inner) {
poly.inner.forEach(p => {
holes.push(out.length / 3);
p.points.forEach(p => {
out.push(p.x, p.y, p.z);
})
});
}
// perform earcut()
let cut = earcutLib(out, holes, 3);
let ret = [];
// preserve swaps in new polys
for (let i = 0; i < cut.length; i += 3) {
let p = new (poly.constructor)();
p._aligned = poly._aligned;
p._swapped = poly._swapped;
for (let j = 0; j < 3; j++) {
let n = cut[i + j] * 3;
p.add(out[n], out[n + 1], out[n + 2]);
}
ret.push(p);
}
return ret;
}
/**
* Turn 2D polygon into a 2.5D ribbon extruded in Z.
* Returns flat array of vertices for mesh rendering.
*
* @param {Polygon} poly - polygon to extrude as ribbon
* @param {number} z - Z height (default 1)
* @param {number} zadd - Z offset (default 0)
* @param {boolean} rev - reverse winding (default false)
* @returns {number[]} flat array of triangle vertices
*/
export function ribbonZ(poly, z = 1, zadd = 0, rev) {
let clone = poly.clone().setClockwise();
let faces = [];
let points = clone.points;
let length = points.length;
if (rev) {
points = points.slice().reverse();
}
for (let i=0; i<length; i++) {
let p0 = points[i];
let p1 = points[(i + 1) % length];
faces.push(p0.x, p0.y, p0.z + zadd);
faces.push(p1.x, p1.y, p1.z + z + zadd);
faces.push(p1.x, p1.y, p0.z + zadd);
faces.push(p0.x, p0.y, p0.z + zadd);
faces.push(p0.x, p0.y, p0.z + z + zadd);
faces.push(p1.x, p1.y, p1.z + z + zadd);
}
return faces;
}
/**
* For turning a poly with an inner offset into a 3D mesh.
* Requires inner has same circularity and <= num points.
* Primarily used to make chamfers in mesh:tool.
*
* @param {Polygon} poly - polygon with exactly one inner
* @param {boolean} swap - swap winding for normals
* @returns {number[]|undefined} flat array of triangle vertices or undefined if invalid
*/
export function ribbonMesh(poly, swap) {
if (!(poly.inner && poly.inner.length === 1)) {
return undefined;
}
let outer = poly.clone().setClockwise();
let inner = poly.inner[0].clone().setClockwise();
let c0 = outer.circularity();
let c1 = inner.circularity();
let n0 = outer.points.length;
let n1 = inner.points.length;
let p0 = outer.points.slice();
let p1 = inner.points.slice();
let min = { d: Infinity, i:0, j:0 };
// find the closests two points inner/outer
for (let i=0; i<p0.length; i++) {
for (let j=0; j<p1.length; j++) {
let d = p0[i].distTo2D(p1[j]);
if (d < min.d) {
min = { d, i, j };
}
}
}
// slide the arrays until the closest points are aligned at index = 0
p0 = p0.slice(min.i).concat(p0.slice(0, min.i)); p0.push(p0[0]);
p1 = p1.slice(min.j).concat(p1.slice(0, min.j)); p1.push(p1[0]);
// walk both arrays moving to the next poly + point that forms
// the shortest line segment between the two points (inner / outer)
let faces = [];
let pi0 = 0;
let pi1 = 0;
let pp0 = p0[pi0];
let pp1 = p1[pi1];
for (;;) {
let pn0 = p0[pi0 + 1];
let pn1 = p1[pi1 + 1];
if ((!pn0 && pn1) || (pn1 && pp0.distTo2D(pn1) < pp1.distTo2D(pn0))) {
// emit and increment bottom
faces.push(pp0.x, pp0.y, pp0.z);
if (swap) {
faces.push(pp1.x, pp1.y, pp1.z);
faces.push(pn1.x, pn1.y, pn1.z);
} else {
faces.push(pn1.x, pn1.y, pn1.z);
faces.push(pp1.x, pp1.y, pp1.z);
}
pi1++;
pp1 = p1[pi1];
} else if (pn0) {
// emit and increment top
faces.push(pp0.x, pp0.y, pp0.z);
if (swap) {
faces.push(pp1.x, pp1.y, pp1.z);
faces.push(pn0.x, pn0.y, pn0.z);
} else {
faces.push(pn0.x, pn0.y, pn0.z);
faces.push(pp1.x, pp1.y, pp1.z);
}
pi0++;
pp0 = p0[pi0];
} else {
break;
}
}
return faces;
}
/**
* Extrude polygon (with inner voids) into 3D mesh.
* Supports chamfers on top and/or bottom edges.
*
* @param {Polygon} poly - polygon to extrude
* @param {number} z - Z height (default 1)
* @param {Object} opt - options
* @param {number} opt.zadd - Z offset for bottom (default 0)
* @param {number} opt.chamfer - chamfer size for both top and bottom (default 0)
* @param {number} opt.chamfer_top - chamfer size for top (overrides chamfer)
* @param {number} opt.chamfer_bottom - chamfer size for bottom (overrides chamfer)
* @returns {number[]} flat array of triangle vertices
*/
export function extrude(poly, z = 1, opt = {}) {
let earcutFaces = earcut(poly); // array of 3-point polygons
// return just the 2D face when no Z depth specified
// used primarily by mesh.sketch render()
if (z === 0) {
return earcutFaces.map(face => face.points.map(p => [ p.x, p.y, p.z ])).flat().flat();
}
let inv = z < 0;
if (inv) {
z = -z;
}
let chamfer = opt.chamfer || 0;
let chamfer_top = opt.chamfer_top || chamfer;
let chamfer_bottom = opt.chamfer_bottom || chamfer;
if (inv) {
let tmp = chamfer_top;
chamfer_top = chamfer_bottom;
chamfer_bottom = tmp;
}
let zadd = (typeof opt === 'number' ? opt : opt.zadd || 0); // z bottom
let obj = []; // flat output vertex array (float-x,float-y,float-z,...)
let top_face = earcutFaces;
let bottom_face = earcutFaces;
let z_top = z + zadd;
let z_bottom = zadd;
let z_side_top = z;
let z_side_bottom = z_bottom;
// chamfer bottom only on negative chamfer
if (chamfer < 0) {
chamfer_top = 0;
chamfer_bottom = -chamfer;
}
// create chamfers (when defined)
if (chamfer_top) {
let inset = poly.offset(chamfer_top);
if (inset.length === 1) {
inset[0].setZ(z_top);
top_face = earcut(inset[0]);
z_side_top -= chamfer_top;
let renest = POLY.renest([poly.clone(true).setZ(z_side_top), inset[0]]);
for (let rnpoly of renest) {
obj.appendAll(ribbonMesh(rnpoly, true));
}
}
}
if (chamfer_bottom) {
let inset = poly.offset(chamfer_bottom);
if (inset.length === 1) {
inset[0].setZ(0);
bottom_face = earcut(inset[0]);
z_side_top -= chamfer_top || chamfer_bottom;
z_side_bottom += chamfer_bottom;
let renest = POLY.renest([poly.clone(true).setZ(z_side_bottom), inset[0]]);
for (let rnpoly of renest) {
obj.appendAll(ribbonMesh(rnpoly, false));
}
}
}
for (let p of top_face) {
for (let point of p.points) {
obj.push(point.x, point.y, z_top);
}
}
// bottom face (reversed to reverse normals)
for (let p of bottom_face) {
for (let point of p.points.reverse()) {
obj.push(point.x, point.y, z_bottom);
}
}
// outside wall
let rib = z - chamfer_top - chamfer_bottom;
obj.appendAll(ribbonZ(poly, rib, z_side_bottom));
for (let inner of poly.inner || []) {
// inside wall(s)
obj.appendAll(ribbonZ(inner, rib, z_side_bottom, true));
}
if (inv) {
for (let i=2; i<obj.length; i+=3) {
obj[i] -= z;
}
}
return obj;
}

374
src/geo/poly/path.js Normal file
View file

@ -0,0 +1,374 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
"use strict";
import { util } from '../base.js';
import { newPoint } from '../point.js';
import { newPolygon } from '../polygon.js';
import { newSlopeFromAngle } from '../slope.js';
import { polygons as POLY } from '../polygons.js';
/**
* Toolpath and CNC utility functions for polygons.
* These utilities handle path generation, segmentation, and CNC-specific operations.
*/
/**
* Generate a trace path around the inside of a polygon including inner polys.
* Returns the noodle and the remainder of the polygon with the noodle removed.
* TODO: relocate this code to fdm/post.js
*
* @param {Polygon} poly - polygon to trace
* @param {number} width - trace width
* @returns {Object} { noodle: Polygon[], remain: Polygon[] }
*/
export function noodle(poly, width) {
let clone = poly.clone(true);
let ins = clone.offset(width) ?? [];
let remain = ins.clone(true);
let nood = POLY.nest(POLY.flatten([ clone, ...ins ], [], true));
return { noodle: nood, remain };
}
/**
* Generate center crossing point cloud for thin wall filling.
* Only used for fdm thin-wall type 1 (fdm/post.js).
* TODO: relocate this code to fdm/post.js
*
* @param {Polygon} poly - polygon to analyze
* @param {number} step - step size for sampling
* @param {number} z - Z height
* @param {number} min - minimum distance threshold
* @param {number} max - maximum distance threshold
* @param {Object} opt - options
* @param {boolean} opt.lines - return lines instead of points (default false)
* @param {number} opt.mindist - minimum distance for point joining (default step * 1.5)
* @returns {Polygon[]|Point[]} array of polygons or points
*/
export function centers(poly, step, z, min, max, opt = {}) {
let cloud = [],
bounds = poly.bounds,
lines = opt.lines || false,
stepoff = step / 2,
set = [poly.points];
if (poly.inner) {
for (let inner of poly.inner) {
set.push(inner.points);
}
}
for (let y of util.lerp(bounds.miny + stepoff, bounds.maxy - stepoff, step, true)) {
let ints = [];
for (let points of set) {
let length = points.length;
for (let i = 0; i < length; i++) {
let p1 = points[i % length];
let p2 = points[(i + 1) % length];
if (
(p1.y <= y && p2.y > y) ||
(p1.y > y && p2.y <= y)
) ints.push([p1, p2]);
}
}
let cntr = [];
if (ints.length && ints.length % 2 === 0) {
for (let int of ints) {
let [p1, p2] = int;
if (p2.y < p1.y) {
let tp = p1;
p1 = p2;
p2 = tp;
}
let minx = Math.min(p1.x, p2.x);
let maxx = Math.max(p1.x, p2.x);
let miny = Math.min(p1.y, p2.y);
let maxy = Math.max(p1.y, p2.y);
let dx = p2.x - p1.x;
let dy = maxy - miny;
let pct = (y - miny) / dy;
let xpo = p1.x + pct * dx;
cntr.push(xpo);
}
}
cntr.sort((a, b) => {
return b - a;
});
let lp, eo = 0;
for (let x of cntr) {
let p = newPoint(x, y, z);
if (eo++ % 2) {
let d = lp.distTo2D(p);
if (d >= min && d <= max) {
if (lines) {
cloud.push(lp);
cloud.push(p);
} else {
cloud.push(newPoint(
(lp.x + p.x) / 2, y, z
));
}
}
} else {
lp = p;
}
}
}
for (let x of util.lerp(bounds.minx + stepoff, bounds.maxx - stepoff, step, true)) {
let ints = [];
for (let points of set) {
let length = points.length;
for (let i = 0; i < length; i++) {
let p1 = points[i % length];
let p2 = points[(i + 1) % length];
if (
(p1.x <= x && p2.x > x) ||
(p1.x > x && p2.x <= x)
) ints.push([p1, p2]);
}
}
let cntr = [];
if (ints.length && ints.length % 2 === 0) {
for (let int of ints) {
let [p1, p2] = int;
if (p2.x < p1.x) {
let tp = p1;
p1 = p2;
p2 = tp;
}
let minx = Math.min(p1.x, p2.x);
let maxx = Math.max(p1.x, p2.x);
let miny = Math.min(p1.y, p2.y);
let maxy = Math.max(p1.y, p2.y);
let dx = maxx - minx;
let dy = p2.y - p1.y;
let pct = (x - minx) / dx;
let ypo = p1.y + pct * dy;
cntr.push(ypo);
}
}
cntr.sort((a, b) => {
return b - a;
});
let lp, eo = 0;
for (let y of cntr) {
let p = newPoint(x, y, z);
if (eo++ % 2) {
let d = lp.distTo2D(p);
if (d >= min && d <= max) {
if (lines) {
cloud.push(lp);
cloud.push(p);
} else {
cloud.push(newPoint(
x, (lp.y + p.y) / 2, z
));
}
}
} else {
lp = p;
}
}
}
if (lines) {
return cloud;
}
let mindist = opt.mindist || step * 1.5;
function build(polyPoints) {
let lastp = polyPoints.last();
let minp;
let mind = Infinity;
for (let point of cloud) {
let dist = point.distTo2D(lastp);
if (dist < mindist && dist < mind) {
mind = dist;
minp = point;
}
}
if (minp) {
cloud = cloud.filter(p => p !== minp);
polyPoints.push(minp);
return true;
}
return false;
}
// join points into polys
let polys = [];
let polyPoints = [];
while (cloud.length) {
if (polyPoints.length === 0) {
polyPoints = [cloud.shift()];
polys.push(polyPoints);
continue;
}
if (build(polyPoints)) {
continue;
}
if (!polyPoints.flip) {
polyPoints.reverse();
polyPoints.flip = true;
continue;
}
if (polyPoints.length) {
polyPoints = [];
} else {
throw "whoop there it is";
}
}
return polys
.filter(polyPoints => polyPoints.length > 1)
.map(polyPoints => {
let np = newPolygon().setOpen();
for (let p of polyPoints) {
np.push(p);
}
if (np.last().distTo2D(np.first()) <= max) {
np.setClosed();
}
np = np.clean();
return np;
});
}
/**
* Split long straight lines into segments no longer than `max`.
* Returns a new polygon. Optionally `mark` points with their derived
* segment start point (hinting for thin walls), `wrap` the poly first
* point by appending to the end (adaptive walls), or stop segmentation at `maxoff`.
*
* @param {Polygon} poly - polygon to segment
* @param {number} max - maximum segment length (default 1)
* @param {boolean} mark - mark points with segment start (default false)
* @param {boolean} wrap - wrap first point to end (default false)
* @param {number} maxoff - maximum offset for segmentation (default Infinity)
* @returns {Polygon} new segmented polygon
*/
export function segment(poly, max = 1, mark = false, wrap = false, maxoff = Infinity) {
const newp = [];
const points = poly.points;
const length = points.length;
const l0 = poly.open ? length - 1 : length;
for (let i=0, p=points, l1=length, l2=l1+1; i<l0; i++) {
const p1 = p[i];
const p2 = p[(i + 1) % l1];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dl = Math.sqrt(dx * dx + dy * dy);
newp.push(p1);
if (mark) p1.segment = p1;
// if segment shorter than max (delta) or
// maxoff is exhausted (only segmenting up to some length)
// then skip sub-segmenting the current segment
if (dl < max || maxoff < 0) {
continue;
}
maxoff -= dl;
const div = dl / max;
const fit = div | 0;
const add = fit - 1;
const ix = dx / fit;
const iy = dy / fit;
let ox = p1.x + ix;
let oy = p1.y + iy;
for (let i=0; i<add; i++) {
newp.push(newPoint(ox, oy, (p1.z + p2.z) / 2));
ox += ix;
oy += iy;
// mark new point with first point of originating segment
if (mark) newp.peek().segment = p1;
}
}
if (newp.length > length) {
if (wrap) {
newp.push(newp[0]);
}
return newPolygon().addPoints(newp.map(p => p.clone(['segment']))).setOpenValue(poly.open);
}
return poly;
}
/**
* For any poly points closer than `dist`, replace them with their midpoint.
* Only used by cam/op-pocket.js.
*
* @param {Polygon} poly - polygon to process
* @param {number} dist - distance threshold (default 0.01)
* @returns {Polygon} new polygon with midpoints or original if no changes
*/
export function midpoints(poly, dist = 0.01) {
const newp = [];
const points = poly.points;
const length = points.length;
const l0 = poly.open ? length - 1 : length;
let mod = 0;
for (let i=0, p=points; i<l0; i++) {
const p1 = p[i];
const p2 = p[(i + 1) % length];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const ln = Math.sqrt(dx * dx + dy * dy);
if (ln < dist) {
newp.push(p1.midPointTo(p2));
mod++;
} else {
newp.push(p1);
}
}
if (mod) {
return newPolygon().addPoints(newp.map(p => p.clone())).setOpenValue(poly.open);
}
return poly;
}
/**
* Add dogbone joints for CNC operations at sharp interior corners.
* Mutates the polygon in place. Recursively processes inner polygons.
*
* @param {Polygon} poly - polygon to modify (mutated)
* @param {number} dist - dogbone distance/size
* @param {boolean} reverse - reverse winding direction
* @returns {Polygon} the mutated polygon
*/
export function addDogbones(poly, dist, reverse) {
let open = poly.open;
let isCW = poly.isClockwise();
if (reverse || poly.parent) isCW = !isCW;
let oldpts = poly.points.slice();
let lastpt = oldpts[oldpts.length - 1];
let lastsl = lastpt.slopeTo(oldpts[0]).toUnit();
let length = oldpts.length + (open ? 0 : 1);
let newpts = [];
for (let i = 0; i < length; i++) {
let nextpt = oldpts[i % oldpts.length];
let nextsl = lastpt.slopeTo(nextpt).toUnit();
let adiff = lastsl.angleDiff(nextsl, true);
let bdiff = ((adiff < 0 ? (180 - adiff) : (180 + adiff)) / 2) + 180;
if (!open || (i > 1 && i < length)) {
if (isCW && adiff > 45) {
let newa = newSlopeFromAngle(lastsl.angle + bdiff);
newpts.push(lastpt.projectOnSlope(newa, dist));
newpts.push(lastpt.clone());
} else if (!isCW && adiff < -45) {
let newa = newSlopeFromAngle(lastsl.angle - bdiff);
newpts.push(lastpt.projectOnSlope(newa, dist));
newpts.push(lastpt.clone());
}
}
lastsl = nextsl;
lastpt = nextpt;
if (i < oldpts.length) {
newpts.push(nextpt);
}
}
poly.points = newpts;
if (poly.inner) {
poly.inner.forEach(inner => addDogbones(inner, dist, true));
}
return poly;
}

File diff suppressed because it is too large Load diff