+ {list.map(([image, caption], index) => (
+
{caption}
diff --git a/src/geo/polygon.js b/src/geo/polygon.js
index 634b29846..435cd1515 100644
--- a/src/geo/polygon.js
+++ b/src/geo/polygon.js
@@ -1414,6 +1414,152 @@ export class Polygon {
};
}
+ /**
+ * find the closest intersection on the boundary along the horizontal line Y = pt.y
+ *
+ * @param {Point} target
+ * @return {Point} closestPoint
+ */
+ snapToIntersectionX(target) {
+ let points = this.points;
+ let len = points.length;
+ if (len === 0) return null;
+ if (len === 1) return points[0];
+ let targetY = target.y;
+ let targetX = target.x;
+ let minD = Infinity;
+ let closest = null;
+ for (let i = 0; i < len; i++) {
+ let p1 = points[i];
+ let p2 = points[this.open ? i + 1 : (i + 1) % len];
+ if (!p2) continue;
+
+ // Check if segment p1-p2 spans targetY
+ let miny = Math.min(p1.y, p2.y);
+ let maxy = Math.max(p1.y, p2.y);
+ if (targetY >= miny && targetY <= maxy) {
+ let x;
+ if (p1.y === p2.y) {
+ // Segment is horizontal and on targetY
+ x = Math.max(Math.min(p1.x, p2.x), Math.min(Math.max(p1.x, p2.x), targetX));
+ } else {
+ x = p1.x + (targetY - p1.y) * (p2.x - p1.x) / (p2.y - p1.y);
+ }
+ let d = Math.abs(targetX - x);
+ if (d < minD) {
+ minD = d;
+ closest = newPoint(x, targetY, target.z);
+ }
+ }
+ }
+ return closest;
+ }
+
+ /**
+ * find the closest intersection on the boundary along the vertical line X = pt.x
+ *
+ * @param {Point} target
+ * @return {Point} closestPoint
+ */
+ snapToIntersectionY(target) {
+ let points = this.points;
+ let len = points.length;
+ if (len === 0) return null;
+ if (len === 1) return points[0];
+ let targetY = target.y;
+ let targetX = target.x;
+ let minD = Infinity;
+ let closest = null;
+ for (let i = 0; i < len; i++) {
+ let p1 = points[i];
+ let p2 = points[this.open ? i + 1 : (i + 1) % len];
+ if (!p2) continue;
+
+ // Check if segment p1-p2 spans targetX
+ let minx = Math.min(p1.x, p2.x);
+ let maxx = Math.max(p1.x, p2.x);
+ if (targetX >= minx && targetX <= maxx) {
+ let y;
+ if (p1.x === p2.x) {
+ // Segment is vertical and on targetX
+ y = Math.max(Math.min(p1.y, p2.y), Math.min(Math.max(p1.y, p2.y), targetY));
+ } else {
+ y = p1.y + (targetX - p1.x) * (p2.y - p1.y) / (p2.x - p1.x);
+ }
+ let d = Math.abs(targetY - y);
+ if (d < minD) {
+ minD = d;
+ closest = newPoint(targetX, y, target.z);
+ }
+ }
+ }
+ return closest;
+ }
+
+ snapToIntersectionAngle(target, angle) {
+ let dx_line = Math.cos(angle);
+ let dy_line = Math.sin(angle);
+ let points = this.points;
+ let len = points.length;
+ if (len === 0) return null;
+ if (len === 1) return points[0];
+
+ let closestPt = null;
+ let minDist = Infinity;
+
+ for (let i = 0; i < len; i++) {
+ let A = points[i];
+ let B = points[this.open ? i + 1 : (i + 1) % len];
+ if (!B) continue;
+
+ let dx_seg = B.x - A.x;
+ let dy_seg = B.y - A.y;
+
+ let det = dy_line * dx_seg - dx_line * dy_seg;
+ if (Math.abs(det) < 1e-9) continue; // parallel
+
+ let s = (dx_line * (A.y - target.y) - dy_line * (A.x - target.x)) / det;
+ if (s >= 0 && s <= 1) {
+ let ix = A.x + s * dx_seg;
+ let iy = A.y + s * dy_seg;
+ let ipt = newPoint(ix, iy, target.z);
+ let dist = target.distTo2D(ipt);
+ if (dist < minDist) {
+ minDist = dist;
+ closestPt = ipt;
+ }
+ }
+ }
+ return closestPt;
+ }
+
+ /**
+ * find the closest point on the polygon perimeter/boundary to target
+ *
+ * @param {Point} target
+ * @return {Point} closestPoint
+ */
+ findClosestPointOnPerimeter(target) {
+ let points = this.points;
+ let len = points.length;
+ if (len === 0) return null;
+ if (len === 1) return points[0];
+ let minD = Infinity;
+ let closest = null;
+ for (let i = 0; i < len; i++) {
+ let p1 = points[i];
+ let p2 = points[this.open ? i + 1 : (i + 1) % len];
+ if (!p2) continue;
+ let cp = closestPointOnSegment(target, p1, p2);
+ let d = target.distTo2D(cp);
+ if (d < minD) {
+ minD = d;
+ closest = cp;
+ }
+ }
+ return closest;
+ }
+
/**
* @param {Polygon[]} out
* @param {[]} deep recurse and track recursion
@@ -1754,3 +1900,15 @@ export function fromClipperPath(path, z) {
export function newPolygon(points) {
return new Polygon(points);
}
+
+function closestPointOnSegment(p, a, b) {
+ let abx = b.x - a.x;
+ let aby = b.y - a.y;
+ let apx = p.x - a.x;
+ let apy = p.y - a.y;
+ let ab2 = abx * abx + aby * aby;
+ if (ab2 === 0) return a;
+ let t = (apx * abx + apy * aby) / ab2;
+ t = Math.max(0, Math.min(1, t));
+ return newPoint(a.x + t * abx, a.y + t * aby);
+}
diff --git a/src/geo/polygons.js b/src/geo/polygons.js
index 649813eb7..423dc919f 100644
--- a/src/geo/polygons.js
+++ b/src/geo/polygons.js
@@ -74,7 +74,8 @@ const POLYS = {
union,
unionFaces,
xor,
- verify
+ verify,
+ spiralize
};
export { POLYS };
@@ -1306,4 +1307,195 @@ export function reconnect(polys, sameZ = true) {
return polys;
}
+export function spiralize(loops, climb) {
+ if (!loops || loops.length === 0) return [];
+
+ // Filter out empty loops first to avoid undefined point access during resampling
+ loops = loops.filter(l => l && l.points && l.points.length > 0);
+ if (loops.length === 0) return [];
+
+ // Ensure winding is set if climb is specified
+ if (climb !== undefined && climb !== null) {
+ setWinding(loops.filter(p => p.isClosed()), climb);
+ }
+
+ let n = loops.length;
+ let parents = new Array(n).fill(-1);
+ let isLeaf = new Array(n).fill(true);
+ let depths = new Array(n).fill(0);
+ let containedBy = Array.from({ length: n }, () => []);
+
+ for (let i = 0; i < n; i++) {
+ let pi = loops[i].points[0];
+ if (!pi) continue;
+ for (let j = 0; j < n; j++) {
+ if (i !== j) {
+ if (pi.isInPolygon(loops[j])) {
+ containedBy[i].push(j);
+ }
+ }
+ }
+ }
+
+ for (let i = 0; i < n; i++) {
+ let containers = containedBy[i];
+ if (containers.length > 0) {
+ let bestParent = containers[0];
+ for (let c of containers) {
+ if (containedBy[c].length > containedBy[bestParent].length) {
+ bestParent = c;
+ }
+ }
+ parents[i] = bestParent;
+ isLeaf[bestParent] = false;
+ depths[i] = containers.length;
+ }
+ }
+
+ let childCount = new Array(n).fill(0);
+ for (let i = 0; i < n; i++) {
+ if (parents[i] !== -1) {
+ childCount[parents[i]]++;
+ }
+ }
+
+ let assigned = new Array(n).fill(false);
+ let chains = [];
+
+ let leafIndices = [];
+ for (let i = 0; i < n; i++) {
+ if (isLeaf[i]) {
+ leafIndices.push(i);
+ }
+ }
+ leafIndices.sort((a, b) => depths[b] - depths[a]);
+
+ for (let leafIdx of leafIndices) {
+ let chain = [];
+ let curr = leafIdx;
+ while (curr !== -1 && !assigned[curr]) {
+ chain.push(loops[curr]);
+ assigned[curr] = true;
+ let next = parents[curr];
+ if (next !== -1 && (childCount[next] > 1 || assigned[next])) {
+ break;
+ }
+ curr = next;
+ }
+ if (chain.length > 0) {
+ chains.push(chain);
+ }
+ }
+
+ for (let i = 0; i < n; i++) {
+ if (!assigned[i]) {
+ chains.push([loops[i]]);
+ }
+ }
+
+ let spiralPolys = [];
+
+ for (let chain of chains) {
+ if (chain.length === 1) {
+ let single = chain[0].clone();
+ single.push(single.first());
+ spiralPolys.push(single);
+ continue;
+ }
+
+ // Determine N for resampling
+ let N = Math.max(...chain.map(l => l.points.length), 100);
+
+ // Resample all loops in the chain
+ let resampledLoops = chain.map(loop => {
+ let points = loop.points;
+ if (points.length === 0) return [];
+
+ let cumulative = [0];
+ let totalDist = 0;
+ for (let i = 0; i < points.length; i++) {
+ let p1 = points[i];
+ let p2 = points[(i + 1) % points.length];
+ totalDist += p1.distTo2D(p2);
+ cumulative.push(totalDist);
+ }
+
+ if (totalDist === 0) {
+ return Array.from({ length: N }, () => points[0].clone());
+ }
+
+ let resampled = [];
+ for (let i = 0; i < N; i++) {
+ let targetDist = (i / N) * totalDist;
+ let idx = 0;
+ while (idx < points.length && cumulative[idx + 1] < targetDist) {
+ idx++;
+ }
+ let p1 = points[idx];
+ let p2 = points[(idx + 1) % points.length];
+ let segLen = cumulative[idx + 1] - cumulative[idx];
+ let t = segLen > 0 ? (targetDist - cumulative[idx]) / segLen : 0;
+
+ let dx = p2.x - p1.x;
+ let dy = p2.y - p1.y;
+ resampled.push(newPoint(p1.x + dx * t, p1.y + dy * t, p1.z));
+ }
+ return resampled;
+ });
+
+ // Align start points
+ for (let i = 1; i < resampledLoops.length; i++) {
+ let prevStart = resampledLoops[i - 1][0];
+ let currPoints = resampledLoops[i];
+ let minDist = Infinity;
+ let bestIdx = 0;
+ for (let j = 0; j < currPoints.length; j++) {
+ let dist = prevStart.distTo2D(currPoints[j]);
+ if (dist < minDist) {
+ minDist = dist;
+ bestIdx = j;
+ }
+ }
+ if (bestIdx > 0) {
+ resampledLoops[i] = currPoints.slice(bestIdx).concat(currPoints.slice(0, bestIdx));
+ }
+ }
+
+ // Interpolate spiral
+ let spiralPoints = [];
+
+ // Trace the first (innermost) loop in its entirety to clean the inner wall
+ let L_first = resampledLoops[0];
+ for (let j = 0; j < N; j++) {
+ spiralPoints.push(L_first[j].clone());
+ }
+ spiralPoints.push(L_first[0].clone());
+
+ for (let i = 0; i < resampledLoops.length - 1; i++) {
+ let L_curr = resampledLoops[i];
+ let L_next = resampledLoops[i + 1];
+ for (let j = 0; j < N; j++) {
+ let t = j / N;
+ let x = L_curr[j].x * (1 - t) + L_next[j].x * t;
+ let y = L_curr[j].y * (1 - t) + L_next[j].y * t;
+ spiralPoints.push(newPoint(x, y, L_curr[j].z));
+ }
+ }
+
+ // Append the final loop to clean the outer wall
+ let L_last = resampledLoops[resampledLoops.length - 1];
+ for (let j = 0; j < N; j++) {
+ spiralPoints.push(L_last[j].clone());
+ }
+ spiralPoints.push(L_last[0].clone());
+
+ let spiralPoly = newPolygon(spiralPoints);
+ spiralPoly.setOpen();
+ spiralPoly.resampleN = N;
+ spiralPolys.push(spiralPoly);
+ }
+
+ return spiralPolys;
+}
+
export const polygons = POLYS;
diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js
index 1efecc0b1..28d0d1abe 100644
--- a/src/kiri/app/conf/defaults.js
+++ b/src/kiri/app/conf/defaults.js
@@ -433,7 +433,7 @@ export const conf = {
camAreaTool: 1000,
camAreaMode: "clear",
camAreaTrace: "none",
- camAreaSurface: "linear",
+ camAreaSurface: "concentric",
camAreaEdgeOnly: false,
camAreaAngle: 0,
camAreaOver: 0.4,
@@ -448,16 +448,20 @@ export const conf = {
camAreaDogbones: false,
camAreaRevbones: false,
camAreaOutline: false,
+ camAreaOmitThru: false,
camAreaWalls: false,
camAreaZigZag: true,
camContourAngle: 85,
camContourBottom: false,
camContourBridge: 0,
camContourCurves: false,
+ camContourCurveDist: 0.5,
camContourIn: false,
camContourLeave: 0,
+ camContourOmitThru: false,
camContourOver: 0.5,
camContourReduce: 2,
+ camContourShape: "Concentric",
camContourSpeed: 1000,
camContourSpindle: 1000,
camContourTool: 1000,
@@ -536,6 +540,7 @@ export const conf = {
camLevelStepZ: 0,
camLevelStock: true,
camLevelTool: 1000,
+ camLevelType: "linear",
camMillDirection: "climb",
camOriginCenter: false,
camOriginOffX: 0,
@@ -557,10 +562,12 @@ export const conf = {
camOutlineTool: 1000,
camOutlineWide: false,
camPocketContour: false,
+ camPocketType: "concentric",
camPocketDown: 1,
camPocketExpand: 0,
camPocketFollow: 5,
camPocketOutline: false,
+ camPocketOmitThru: false,
camPocketOver: 0.25,
camPocketPlunge: 200,
camPocketRefine: 20,
@@ -587,6 +594,7 @@ export const conf = {
camRoughStock: 0,
camRoughStockZ: 0,
camRoughTool: 1000,
+ camRoughType: "concentric",
camRoughTop: true,
camRoundCorners: true,
camStockClipTo: false,
diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js
index ac59923b2..328855ad6 100644
--- a/src/kiri/app/consts.js
+++ b/src/kiri/app/consts.js
@@ -94,7 +94,12 @@ const LISTS = {
],
xyaxis: [
{ name: "X" },
- { name: "Y" }
+ { name: "Y" },
+ { name: "Radial" }
+ ],
+ crshape: [
+ { name: "Concentric" },
+ { name: "Spiral" }
],
regaxis: [
{ name: "X" },
@@ -120,7 +125,12 @@ const LISTS = {
],
surftyp: [
{ name: "linear" },
- { name: "offset" },
+ { name: "concentric" },
+ { name: "spiral" }
+ ],
+ roughtyp: [
+ { name: "concentric" },
+ { name: "spiral" }
],
direction: [
{ name: "climb" },
diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js
index 4dd6ca0f5..6c4924dc5 100644
--- a/src/kiri/mode/cam/app/cl-ops.js
+++ b/src/kiri/mode/cam/app/cl-ops.js
@@ -256,7 +256,7 @@ export function createPopOps() {
}
function isSurfaceLinear() {
- return env.poppedRec.mode === 'surface' && env.poppedRec.sr_type === 'linear';
+ return env.poppedRec.mode === 'surface' && env.poppedRec.sr_type_surf === 'linear';
}
function canDogBones() {
@@ -295,7 +295,8 @@ export function createPopOps() {
rate: 'camLevelSpeed',
down: 'camLevelDown',
inset: 'camLevelInset',
- stock: 'camLevelStock'
+ stock: 'camLevelStock',
+ sr_type: 'camLevelType'
}).inputs = {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
sep: UC.newBlank({ class: "pop-sep" }),
@@ -305,6 +306,7 @@ export function createPopOps() {
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
down: UC.newInput(LANG.cc_loff_s, { title: LANG.cc_loff_l, convert: toFloat, units }),
inset: UC.newInput(LANG.cc_lxyo_s, { title: LANG.cc_lxyo_l, convert: toFloat, units, show: () => !env.popOp.level.rec.stock }),
+ sr_type: UC.newSelect("pattern", { title: "pattern" }, "surftyp"),
sep: UC.newBlank({ class: "pop-sep" }),
stock: UC.newBoolean(LANG.cc_lsto_s, undefined, { title: LANG.cc_lsto_l }),
};
@@ -323,11 +325,13 @@ export function createPopOps() {
flats: 'camRoughFlat',
inside: 'camRoughIn',
omitthru: 'camRoughOmitThru',
+ sr_type: 'camRoughType',
ov_topz: 0,
ov_botz: 0,
}).inputs = {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
direction: UC.newSelect(LANG.ou_dire_s, { title: LANG.ou_dire_l }, "direction"),
+ sr_type: UC.newSelect("pattern", { title: "pattern" }, "roughtyp"),
sep: UC.newBlank({ class: "pop-sep" }),
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0) }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }),
@@ -411,13 +415,17 @@ export function createPopOps() {
bridging: 'camContourBridge',
bottom: 'camContourBottom',
curves: 'camContourCurves',
+ curvesDist: 'camContourCurveDist',
inside: 'camContourIn',
clipto: 'camStockClipTo',
+ omitthru: 'camContourOmitThru',
filter: 'camContourFilter',
- axis: 'X'
+ axis: 'X',
+ shape: 'camContourShape'
}).inputs = {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
- axis: UC.newSelect(LANG.cd_axis, {}, "xyaxis"),
+ axis: UC.newSelect(LANG.cd_axis, { trigger: true }, "xyaxis"),
+ shape: UC.newSelect(LANG.cf_shpe_s, { title: LANG.cf_shpe_l, show: () => env.poppedRec.axis === 'Radial' }, "crshape"),
sep: UC.newBlank({ class: "pop-sep" }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
@@ -432,9 +440,11 @@ export function createPopOps() {
// bridging: UC.newInput(LANG.ou_brdg_s, {title:LANG.ou_brdg_l, convert:toFloat, bound:UC.bound(0,1000.0), units:true, round:4, show:(op) => op.inputs.curves.checked}),
sep: UC.newBlank({ class: "pop-sep" }),
curves: UC.newBoolean(LANG.cf_curv_s, undefined, { title: LANG.cf_curv_l }),
+ curvesDist: UC.newInput(LANG.cf_cdst_s, { title: LANG.cf_cdst_l, convert: toFloat, bound: UC.bound(0, 100), show: (op) => op.inputs.curves.checked }),
inside: UC.newBoolean(LANG.cf_olin_s, undefined, { title: LANG.cf_olin_l }),
bottom: UC.newBoolean(LANG.cf_botm_s, undefined, { title: LANG.cf_botm_l, show: (op, conf) => conf ? conf.process.camZBottom : 0 }),
clipto: UC.newBoolean(LANG.cf_clip_s, undefined, { title:LANG.cf_clip_l, show: () => !isWebGPU() }),
+ omitthru: UC.newBoolean(LANG.co_omit_s, undefined, { title: LANG.co_omit_l }),
filter: UC.newRow([UC.newButton(LANG.filter, contourFilter)], { class: "ext-buttons f-row" })
};
@@ -540,7 +550,9 @@ export function createPopOps() {
refine: 'camPocketRefine',
follow: 'camPocketFollow',
contour: 'camPocketContour',
+ sr_type: 'camPocketType',
outline: 'camPocketOutline',
+ omitthru: 'camPocketOmitThru',
ov_topz: 0,
ov_botz: 0,
ov_conv: '~camConventional',
@@ -559,7 +571,9 @@ export function createPopOps() {
follow: UC.newInput(LANG.cp_foll_s, { title: LANG.cp_foll_l, convert: toFloat }),
sep: UC.newBlank({ class: "pop-sep" }),
contour: UC.newBoolean(LANG.cp_cont_s, undefined, { title: LANG.cp_cont_s }),
+ sr_type: UC.newSelect("pattern", { title: "pattern", show: () => env.poppedRec.contour }, "surftyp"),
outline: UC.newBoolean(LANG.cp_outl_s, undefined, { title: LANG.cp_outl_l }),
+ omitthru: UC.newBoolean(LANG.co_omit_s, undefined, { title: LANG.co_omit_l, show: () => env.poppedRec.outline }),
exp: UC.newExpand("feeds & speeds", { }),
spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }),
rate: UC.newInput(LANG.cc_feed_s, { title: LANG.cc_feed_l, convert: toInt, units }),
@@ -759,7 +773,8 @@ export function createPopOps() {
mode: 'camAreaMode',
direction: 'camMillDirection',
tr_type: 'camAreaTrace',
- sr_type: 'camAreaSurface',
+ sr_type_clear: 'camAreaSurface',
+ sr_type_surf: 'camAreaSurface',
sr_angle: 'camAreaAngle',
sr_alter: 'camAreaZigZag',
over: 'camAreaOver',
@@ -772,6 +787,7 @@ export function createPopOps() {
follow: 'camAreaFollow',
refine: 'camAreaRefine',
outline: 'camAreaOutline',
+ omitthru: 'camAreaOmitThru',
shadow: 'camAreaShadow',
tolerance: 'camTolerance',
dogbones: 'camAreaDogbones',
@@ -782,7 +798,8 @@ export function createPopOps() {
}).inputs = {
mode: UC.newSelect(LANG.mo_menu, { post: opRender }, "opmode"),
tr_type: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: isTrace }, "traceoff"),
- sr_type: UC.newSelect("pattern", { title: "pattern", show: isSurface }, "surftyp"),
+ sr_type_clear: UC.newSelect("pattern", { title: "pattern", show: isClear }, "roughtyp"),
+ sr_type_surf: UC.newSelect("pattern", { title: "pattern", show: isSurface }, "surftyp"),
sep: UC.newBlank({ class: "pop-sep" }),
exp: UC.newExpand("area selection", { open }),
menu: UC.newRow([
@@ -791,6 +808,7 @@ export function createPopOps() {
], { class: "ext-buttons f-row", show: () => !isShadow() }),
shadow: UC.newBoolean(LANG.cp_shad_s, undefined, { title: LANG.cp_shad_l }),
outline: UC.newBoolean(LANG.cp_outl_s, undefined, { title: LANG.cp_outl_l }),
+ omitthru: UC.newBoolean(LANG.co_omit_s, undefined, { title: LANG.co_omit_l, show: () => env.poppedRec.outline }),
exp_end: UC.endExpand(),
sep: UC.newBlank({ class: "pop-sep" }),
exp: UC.newExpand("area modifiers", { }),
diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js
index 7a0598135..475688333 100644
--- a/src/kiri/mode/cam/work/op-area.js
+++ b/src/kiri/mode/cam/work/op-area.js
@@ -27,8 +27,9 @@ class OpArea extends CamOp {
async slice(progress) {
let { op, state } = this;
- let { direction, down, expand, flats, flatOff, follow } = op;
+ let { direction, down, expand, flats, flatOff, follow, omitthru } = op;
let { mode, outline, over, rename, smooth, tool } = op;
+ let sr_type = (mode === 'clear' ? op.sr_type_clear : op.sr_type_surf) || op.sr_type || 'concentric';
let { addSlices, axisIndex, color, cutTabs, settings } = state;
let { shadowAt, setToolDiam, tabs, widget, workarea } = state;
@@ -210,6 +211,9 @@ class OpArea extends CamOp {
POLY.offset(clip, offsets, {
count: op.walls ? 1 : (op.steps ?? 999), outs, flat: true, z: z - zMov, ...offopt
});
+ if (sr_type === 'spiral' || sr_type === 'concentric spiral') {
+ outs = POLY.spiralize(outs);
+ }
// if we see no offsets, re-check the mesh bottom Z then exit
if (outs.length === 0) {
if (bounds && lzo > bounds.min.z) {
@@ -348,7 +352,7 @@ class OpArea extends CamOp {
}
} else
if (mode === 'surface') {
- let { sr_type, sr_angle, sr_alter, tolerance } = op;
+ let { sr_angle, sr_alter, tolerance } = op;
let resolution = tolerance || 0.05;
let raster = await self.get_raster_gpu({ mode: "tracing", resolution });
@@ -357,8 +361,9 @@ class OpArea extends CamOp {
// prepare paths
if (sr_type === 'linear') {
+ let angle = (sr_angle || 0) * DEG2RAD;
// scan the area bounding box with rays at defined angle
- let scan = scanBoxAtAngle(bounds, sr_angle * DEG2RAD, toolOver);
+ let scan = scanBoxAtAngle(bounds, angle, toolOver);
let lines = scan.map(line => {
let { a, b } = line;
return [ newPoint(a.x, a.y, 0).toClipper(), newPoint(b.x, b.y, 0).toClipper() ]
@@ -378,20 +383,29 @@ class OpArea extends CamOp {
paths.forEach(path => path.reverse());
}
// optional alternating paths
- if (paths.length && sr_alter) {
+ if (paths.length && sr_alter !== false) {
paths = tip2tipJoin(paths, paths[0].first(), toolOver * 10);
}
} else
- if (sr_type === 'offset') {
+ // check 'offset' for backward compatibility with older save files (renamed to 'concentric')
+ if (sr_type === 'concentric' || sr_type === 'offset') {
// progressive inset from perimeter
POLY.offset([ area ], [ -toolDiam / 2, -toolOver ], {
count: 999, outs: paths, flat: true, z: 0, minArea: 0
});
paths.forEach(poly => poly.isClosed() && poly.push(poly.first()));
POLY.setWinding(paths.filter(p => p.isClosed()), direction === 'climb');
+ } else
+ if (sr_type === 'spiral' || sr_type === 'concentric spiral') {
+ let loops = [];
+ POLY.offset([ area ], [ -toolDiam / 2, -toolOver ], {
+ count: 999, outs: loops, flat: true, z: 0, minArea: 0
+ });
+ paths.push(...POLY.spiralize(loops, direction === 'climb'));
}
// convert resulting poly lines to raster float32 array groups
+ let resampleNs = paths.map(poly => poly.resampleN);
paths = paths.map(poly => poly.points.map(p => [ p.x, p.y ]).flat().toFloat32());
// prepare tool mesh points
@@ -429,15 +443,48 @@ class OpArea extends CamOp {
// convert terrain raster output back to open polylines
// todo: add leave_z support
+ let pathIdx = 0;
for (let path of output.paths) {
- path = newPolygon().fromArray([1, ...path]);
- if (op.refine) path.refine(op.refine);
- surface.push(path);
- let slice = newLayer();
- slice.camLines = [ path ];
- slice.output()
- .setLayer(rename ?? "linear", { line: color }, false)
- .addPolys([ path ]);
+ let rN = resampleNs[pathIdx++];
+ let splitPaths = [];
+ if (rN) {
+ let ptsCount = path.length / 3;
+ for (let i = 0; i < ptsCount; i += rN) {
+ let start = Math.max(0, i - 1);
+ let end = Math.min(ptsCount, i + rN);
+ if (end - start < 2) continue;
+ splitPaths.push(path.subarray(start * 3, end * 3));
+ }
+ } else {
+ splitPaths.push(path);
+ }
+
+ // Push the original continuous path to the surface array so that
+ // G-code generates a single continuous toolpath without travel lifts/moves.
+ let origPathPoly = newPolygon().fromArray([1, ...path]);
+ if (op.refine) origPathPoly.refine(op.refine);
+ if (omitthru) {
+ origPathPoly = prunePointsInHoles(origPathPoly, thruHoles);
+ }
+ if (origPathPoly.points.length > 1) {
+ surface.push(origPathPoly);
+ }
+
+ // Add split segments to separate layers for step-by-step preview visualization
+ for (let sp of splitPaths) {
+ let polyPath = newPolygon().fromArray([1, ...sp]);
+ if (op.refine) polyPath.refine(op.refine);
+ if (omitthru) {
+ polyPath = prunePointsInHoles(polyPath, thruHoles);
+ }
+ if (polyPath.points.length > 1) {
+ let slice = newLayer();
+ slice.camLines = [ polyPath ];
+ slice.output()
+ .setLayer(rename ?? "linear", { line: color }, false)
+ .addPolys([ polyPath ]);
+ }
+ }
}
// output this surface
@@ -477,6 +524,9 @@ class OpArea extends CamOp {
return;
}
+ let sr_type = (op.mode === 'clear' ? op.sr_type_clear : op.sr_type_surf) || op.sr_type || 'concentric';
+ let spiral = sr_type === 'spiral' || sr_type === 'concentric spiral';
+
// process areas as pockets
while (areas?.length) {
let min = {
@@ -511,7 +561,8 @@ class OpArea extends CamOp {
easeDown: op.down && process.easeDown ? op.down : 0,
outline: op.drape || op.mode === 'trace',
progress: (n,m) => progress(n/m, "area"),
- slices: min.area.filter(slice => slice.camLines)
+ slices: min.area.filter(slice => slice.camLines),
+ spiral
});
} else {
break;
@@ -539,10 +590,20 @@ function omitMatching(target, matches) {
target = target.clone(true);
for (let poly of target.filter(p => p.inner)) {
poly.inner = poly.inner.filter(inner => {
+ let innerCenter = inner.bounds.center();
for (let ho of matches) {
- if (inner.isEquivalent(ho)) {
+ if (inner.isEquivalent(ho, false, 0.2)) {
return false;
}
+ // Fallback check: if the center of the sliced hole is inside the matching hole,
+ // and their areas are within a 20% tolerance threshold.
+ let hoArea = Math.abs(ho.area());
+ let innerArea = Math.abs(inner.area());
+ if (hoArea > 0.001 && Math.abs(hoArea - innerArea) / hoArea < 0.2) {
+ if (innerCenter.isInPolygon(ho)) {
+ return false;
+ }
+ }
}
return true;
});
@@ -614,4 +675,36 @@ function scanBoxAtAngle(box2, angle, step) {
return rays;
}
+function prunePointsInHoles(poly, holes) {
+ if (!holes || !holes.length) return poly;
+ let holeBoxes = [];
+ for (let hole of holes) {
+ let bounds = hole.bounds;
+ holeBoxes.push({
+ min_x: bounds.minx,
+ max_x: bounds.maxx,
+ min_y: bounds.miny,
+ max_y: bounds.maxy,
+ hole
+ });
+ }
+ let newPoints = [];
+ for (let pt of poly.points) {
+ let inHole = false;
+ for (let hb of holeBoxes) {
+ if (pt.x >= hb.min_x && pt.x <= hb.max_x && pt.y >= hb.min_y && pt.y <= hb.max_y) {
+ if (pt.isInPolygon(hb.hole)) {
+ inHole = true;
+ break;
+ }
+ }
+ }
+ if (!inHole) {
+ newPoints.push(pt);
+ }
+ }
+ poly.points = newPoints;
+ return poly;
+}
+
export { OpArea };
diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js
index 7bcf7c8fe..36a865f22 100644
--- a/src/kiri/mode/cam/work/op-contour.js
+++ b/src/kiri/mode/cam/work/op-contour.js
@@ -43,7 +43,7 @@ function createFilter(op, origin, axis) {
);
}
}
- } else {
+ } else if (axis === 'y') {
let sx = slice.z - origin.x;
if (sx >= x[0] && sx <= x[1]) {
ok = true;
@@ -54,6 +54,15 @@ function createFilter(op, origin, axis) {
);
}
}
+ } else if (axis === 'radial') {
+ ok = true;
+ for (let p of slice.camLines) {
+ p.points = p.points.filter(p =>
+ p.x - origin.x >= x[0] && p.x - origin.x <= x[1] &&
+ p.y - origin.y >= y[0] && p.y - origin.y <= y[1] &&
+ p.z - origin.z >= z[0] && p.z - origin.z <= z[1]
+ );
+ }
}
if (ok) {
slice.camLines = slice.camLines.map(p => {
@@ -100,7 +109,12 @@ class OpContour extends CamOp {
onupdate: (index, total, msg) => {
progress(index / total, msg);
},
- ondone: (slices) => {
+ ondone: (slices, topo) => {
+ // If 'Omit Through' is enabled, post-process the generated toolpath slices
+ // to cleanly snap coordinates crossing any through-hole onto the hole perimeter.
+ if (op.omitthru && state.shadow && state.shadow.holes && state.shadow.holes.length) {
+ slices = cleanupContourSlices(slices, state.shadow.holes, topo, op, toolDiam);
+ }
slices = filter(slices);
this.sliceOut = slices;
addSlices(slices);
@@ -131,7 +145,7 @@ class OpContour extends CamOp {
let { settings } = state;
let { process } = settings;
- let { polyEmit, setContouring, setTolerance, setTool } = ops;
+ let { polyEmit, setContouring, setTolerance, setTool, setTravelBoundary } = ops;
let { widget, newLayer, zmax } = ops;
let bounds = widget.getBoundingBox();
@@ -139,32 +153,234 @@ class OpContour extends CamOp {
setTool(op.tool, op.rate, process.camFastFeedZ);
setContouring(true, toolStep * 1.5, topo.coastline);
+ if (state.shadow && state.shadow.base) {
+ setTravelBoundary(state.shadow.base);
+ }
setTolerance(this.tolerance);
let printPoint = newPoint(bounds.min.x, bounds.min.y, zmax);
- for (let slice of sliceOut) {
- // ignore debug slices
- if (!slice.camLines) {
- continue;
- }
- let polys = [], poly;
+ // Helper to convert slice camLines to formatted polygons array for tip2tipEmit
+ const sliceToPolys = (slice) => {
+ let polys = [];
slice.camLines.forEach((poly) => {
poly = poly.clone(true).annotate({ slice: slice.index + 1 });
polys.push({ first: poly.first(), last: poly.last(), poly: poly });
});
- depthData.appendAll(polys);
- }
+ return polys;
+ };
- tip2tipEmit(depthData, printPoint, (el, point) => {
+ // Shared callback function to emit toolpaths
+ const emitSegment = (el, point) => {
let poly = el.poly;
- if (poly.last() === point) {
- poly.reverse();
+ if (poly.isClosed()) {
+ // Closed concentric loops are set to CounterClockwise for standard milling direction
+ poly.setCounterClockwise();
+ polyEmit(poly, -999);
+ } else {
+ // Open concentric arcs: reverse traversal direction if the end point is closer
+ if (poly.last() === point) {
+ poly.reverse();
+ }
+ polyEmit(poly);
}
- polyEmit(poly);
newLayer();
+ };
+
+ const isRadial = op.axis.toLowerCase() === 'radial';
+ const lshape = (op.shape || '').toLowerCase();
+ const isSpiral = lshape === 'spiral' || lshape === 'concentric spiral' || lshape === 'contour spiral';
+
+ if (isRadial) {
+ // RADIAL FINISHING TOOLPATH EMISSION:
+ // Radial axis mode finishes the surface concentric-loop by concentric-loop or turns of a spiral.
+ // Unlike linear X/Y parallel finishing passes where all segments are accumulated together
+ // and ordered globally, in Radial Concentric mode we emit loops slice-by-slice (from innermost
+ // out) and run tip-to-tip path ordering per loop to minimize travel and prevent collisions.
+ if (isSpiral) {
+ // Group all chunk polygons by their spiralId (or group index)
+ let groups = new Map();
+ for (let slice of sliceOut) {
+ if (!slice.camLines) continue;
+ for (let poly of slice.camLines) {
+ let id = poly.spiralId || 0;
+ if (!groups.has(id)) {
+ groups.set(id, []);
+ }
+ groups.get(id).push(poly);
+ }
+ }
+
+ // For each group, merge them into a single continuous polygon
+ let mergedPolys = [];
+ for (let [id, polys] of groups.entries()) {
+ if (polys.length === 0) continue;
+ let mergedPoints = [];
+ for (let poly of polys) {
+ for (let pt of poly.points) {
+ if (mergedPoints.length > 0) {
+ let lastPt = mergedPoints[mergedPoints.length - 1];
+ if (lastPt.distTo2D(pt) < 0.0001) {
+ // Skip duplicate overlap point
+ continue;
+ }
+ }
+ mergedPoints.push(pt);
+ }
+ }
+ if (mergedPoints.length > 1) {
+ let mergedPoly = newPolygon(mergedPoints);
+ mergedPoly.setOpen();
+ mergedPoly.spiralId = id;
+ mergedPolys.push(mergedPoly);
+ }
+ }
+
+ // Now emit these merged polygons as a single tip2tipEmit call to preserve continuous milling
+ let depthData = mergedPolys.map(poly => {
+ return { first: poly.first(), last: poly.last(), poly: poly };
+ });
+ printPoint = tip2tipEmit(depthData, printPoint, emitSegment);
+ } else {
+ for (let slice of sliceOut) {
+ if (!slice.camLines) {
+ continue;
+ }
+ let polys = sliceToPolys(slice);
+ // Find optimized path routing (tip2tip) on the loops within this slice
+ printPoint = tip2tipEmit(polys, printPoint, emitSegment);
+ }
+ }
+ } else {
+ // STANDARD LINEAR X/Y FINISHING EMISSION:
+ // Accumulate all segments across all slices first, then run a single global tip-to-tip path optimizer.
+ for (let slice of sliceOut) {
+ if (!slice.camLines) {
+ continue;
+ }
+ depthData.appendAll(sliceToPolys(slice));
+ }
+
+ tip2tipEmit(depthData, printPoint, emitSegment);
+ }
+ }
+}
+
+// SLICE POST-PROCESSING HOLE PRUNING & RETRACT OPTIMIZATION (OMIT THROUGH option):
+// Post-processes contour slice lines when 'Omit Through' is active so that toolpaths do not run inside the holes.
+// For segments crossing through a hole, we check if the straight line shortcut crosses solid material (outside the hole
+// or within the tool radius of the hole boundaries). If it does, we retract/split the toolpath. Otherwise, we keep the
+// toolpath continuous so the tool feeds directly across the empty hole space, minimizing retracts.
+function cleanupContourSlices(slices, holes, topo, op, toolDiam) {
+ let holeBoxes = [];
+ // Compute bounding boxes for each through-hole to accelerate polygon containment tests
+ for (let hole of holes) {
+ let bounds = hole.bounds;
+ holeBoxes.push({
+ min_x: bounds.minx,
+ max_x: bounds.maxx,
+ min_y: bounds.miny,
+ max_y: bounds.maxy,
+ hole
});
}
+
+ const toolRadius = (toolDiam || 0) / 2;
+
+ for (let slice of slices) {
+ if (!slice.camLines) continue;
+ let newPolys = [];
+ for (let poly of slice.camLines) {
+ let points = poly.points;
+ let len = points.length;
+ if (len < 2) {
+ if (len > 0) newPolys.push(poly);
+ continue;
+ }
+
+ let splitPolysPoints = [];
+ let currentPoints = [];
+ let lastSolidPt = null;
+ let hasSkipped = false;
+
+ for (let i = 0; i < len; i++) {
+ let pt = points[i];
+ let ptInHole = false;
+ for (let hb of holeBoxes) {
+ if (pt.x >= hb.min_x && pt.x <= hb.max_x && pt.y >= hb.min_y && pt.y <= hb.max_y) {
+ if (pt.isInPolygon(hb.hole)) {
+ ptInHole = true;
+ break;
+ }
+ }
+ }
+
+ if (!ptInHole) {
+ if (hasSkipped && lastSolidPt) {
+ let crosses = segmentCrossesSolid(lastSolidPt, pt, holeBoxes, toolRadius);
+ if (crosses) {
+ if (currentPoints.length > 1) {
+ splitPolysPoints.push(currentPoints);
+ }
+ currentPoints = [];
+ }
+ }
+ currentPoints.push(pt);
+ lastSolidPt = pt;
+ hasSkipped = false;
+ } else {
+ hasSkipped = true;
+ }
+ }
+
+ if (currentPoints.length > 1) {
+ splitPolysPoints.push(currentPoints);
+ }
+
+ for (let pts of splitPolysPoints) {
+ let newPoly = newPolygon(pts).setOpen();
+ if (poly.spiralId !== undefined) newPoly.spiralId = poly.spiralId;
+ newPolys.push(newPoly);
+ }
+ }
+ slice.camLines = newPolys;
+ }
+ return slices;
+}
+
+function segmentCrossesSolid(p1, p2, holeBoxes, toolRadius) {
+ if (!p1 || !p2) return false;
+ // Sample 25%, 50%, and 75% along the shortcut segment
+ for (let pct of [0.25, 0.50, 0.75]) {
+ let testPt = newPoint(
+ p1.x + (p2.x - p1.x) * pct,
+ p1.y + (p2.y - p1.y) * pct,
+ p1.z + (p2.z - p1.z) * pct
+ );
+ let inAnyHole = false;
+ for (let hb of holeBoxes) {
+ if (testPt.x >= hb.min_x && testPt.x <= hb.max_x && testPt.y >= hb.min_y && testPt.y <= hb.max_y) {
+ if (testPt.isInPolygon(hb.hole)) {
+ // Check if the tool center is at least one tool radius away from the hole perimeter
+ // to prevent the side of the tool from clipping/gouging the hole walls.
+ let edgePt = hb.hole.findClosestPointOnPerimeter(testPt);
+ if (edgePt) {
+ let dist = testPt.distTo2D(edgePt);
+ if (dist >= toolRadius) {
+ inAnyHole = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+ // If the sample point is not inside any hole, or is too close to a hole wall,
+ // we treat it as crossing/gouging solid material.
+ if (!inAnyHole) {
+ return true;
+ }
+ }
+ return false;
}
export { OpContour };
\ No newline at end of file
diff --git a/src/kiri/mode/cam/work/op-level.js b/src/kiri/mode/cam/work/op-level.js
index ab1651d59..eb6686714 100644
--- a/src/kiri/mode/cam/work/op-level.js
+++ b/src/kiri/mode/cam/work/op-level.js
@@ -16,7 +16,7 @@ class OpLevel extends CamOp {
let { op, state } = this;
let { addSlices, color, settings, shadow } = state;
let { share, updateToolDiams, zMax, ztOff } = state;
- let { down, tool, step, stepz, inset } = op;
+ let { down, tool, step, stepz, inset, sr_type } = op;
let { stock } = settings;
let { center } = stock;
@@ -40,7 +40,6 @@ class OpLevel extends CamOp {
updateToolDiams(toolDiam);
- let points = [];
let clear = op.stock ?
[ newPolygon().centerRectangle({
x: -wpos.x + center.x,
@@ -49,45 +48,72 @@ class OpLevel extends CamOp {
}, stock.x + toolDiam/2, stock.y) ] :
POLY.outer(POLY.offset(shadow.base, toolDiam * (inset || 0)));
- POLY.fillArea(clear, 1090, stepOver, points);
+ let level_polys = [];
+ // check 'offset' for backward compatibility with older save files (renamed to 'concentric')
+ if (sr_type === 'concentric' || sr_type === 'offset') {
+ POLY.offset(clear, -stepOver, { count: 999, outs: level_polys, flat: true, z: 0, minArea: 0.01 });
+ level_polys.push(...clear.map(p => p.clone(true)));
+ } else if (sr_type === 'spiral' || sr_type === 'concentric spiral') {
+ let loops = [];
+ POLY.offset(clear, -stepOver, { count: 999, outs: loops, flat: true, z: 0, minArea: 0.01 });
+ loops.push(...clear.map(p => p.clone(true)));
+ level_polys = POLY.spiralize(loops);
+ } else {
+ let points = [];
+ POLY.fillArea(clear, 1090, stepOver, points);
+ for (let i = 0; i < points.length; i += 2) {
+ level_polys.push(newPolygon().setOpen().addPoints([ points[i], points[i+1] ]));
+ }
+ }
let layers = this.layers = [];
for (let z of zList) {
let lines = [];
layers.push(lines);
- for (let i=0; i
{ return { first: p.first(), last: p.last(), poly: p } });
+ if (spiral && layer_index > 0) {
+ camOut(printPoint.clone().setZ(zSafe), 0);
+ newLayer();
+ }
printPoint = tip2tipEmit(lines, printPoint, (el, point, count) => {
let poly = el.poly;
if (poly.last() === point) {
poly.reverse();
}
poly.forEachPoint((point, pidx) => {
- camOut(point.clone(), true, stepOver);
+ camOut(point.clone(), pidx === 0 ? 0 : true, stepOver);
}, false);
});
newLayer();
+ layer_index++;
}
}
}
diff --git a/src/kiri/mode/cam/work/op-pocket.js b/src/kiri/mode/cam/work/op-pocket.js
index f225ea724..ad654dce4 100644
--- a/src/kiri/mode/cam/work/op-pocket.js
+++ b/src/kiri/mode/cam/work/op-pocket.js
@@ -10,8 +10,8 @@ class OpPocket extends CamOp {
async slice(progress) {
let { op, state } = this;
- let { contour, direction, down, expand, follow, outline, ov_botz, ov_topz } = op;
- let { plunge, rate, refine, smooth, spindle, surfaces, tolerance, tool } = op;
+ let { contour, direction, down, expand, follow, outline, omitthru, ov_botz, ov_topz } = op;
+ let { plunge, rate, refine, smooth, spindle, surfaces, tolerance, tool, sr_type } = op;
let pocket = {
areas: {},
direction,
@@ -20,6 +20,7 @@ class OpPocket extends CamOp {
follow,
mode: contour ? 'surface' : 'clear',
outline,
+ omitthru,
ov_botz,
ov_topz,
over: op.step,
@@ -29,7 +30,7 @@ class OpPocket extends CamOp {
rename: op.rename ?? "pocket",
smooth,
spindle,
- sr_type: 'offset',
+ sr_type: sr_type || 'concentric',
surfaces,
tolerance,
tool,
diff --git a/src/kiri/mode/cam/work/op-rough.js b/src/kiri/mode/cam/work/op-rough.js
index c1df6cbc1..8de311190 100644
--- a/src/kiri/mode/cam/work/op-rough.js
+++ b/src/kiri/mode/cam/work/op-rough.js
@@ -19,6 +19,10 @@ class OpRough extends CamOp {
let cutOutside = !op.inside;
let shadowBase = shadow.base;
+ if (op.omitthru && state.shadow && state.shadow.holes && state.shadow.holes.length) {
+ shadowBase = omitMatching(shadowBase, state.shadow.holes);
+ }
+
if (op.down <= 0) {
throw `invalid step down "${op.down}"`;
}
@@ -44,6 +48,7 @@ class OpRough extends CamOp {
smooth: 0,
outline: true,
omitthru: op.omitthru,
+ sr_type: op.sr_type || 'concentric',
leave_xy: op.leave,
leave_z: op.leavez,
ov_botz: op.ov_botz,
@@ -69,6 +74,7 @@ class OpRough extends CamOp {
smooth: 0,
outline: true,
omitthru: op.omitthru,
+ sr_type: op.sr_type || 'concentric',
leave_xy: op.leave,
leave_z: op.leavez,
ov_botz: op.ov_botz,
@@ -132,4 +138,27 @@ class OpRough extends CamOp {
}
}
+function omitMatching(target, matches) {
+ target = target.clone(true);
+ for (let poly of target.filter(p => p.inner)) {
+ poly.inner = poly.inner.filter(inner => {
+ let innerCenter = inner.bounds.center();
+ for (let ho of matches) {
+ if (inner.isEquivalent(ho, false, 0.2)) {
+ return false;
+ }
+ let hoArea = Math.abs(ho.area());
+ let innerArea = Math.abs(inner.area());
+ if (hoArea > 0.001 && Math.abs(hoArea - innerArea) / hoArea < 0.2) {
+ if (innerCenter.isInPolygon(ho)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ });
+ }
+ return target;
+}
+
export { OpRough };
diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js
index 288d3b9a6..eba381df9 100644
--- a/src/kiri/mode/cam/work/prepare.js
+++ b/src/kiri/mode/cam/work/prepare.js
@@ -190,7 +190,8 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
function setContouring(bool, step, coast) {
coastline = coast;
contouring = bool;
- toolDiamMove = step ?? tool.getStepSize(currentOp.step) * 2;
+ let baseStep = step ?? tool.getStepSize(currentOp.step) * 2;
+ toolDiamMove = Math.max(baseStep, tool.fluteDiameter());
if (bool) setTravelBoundary();
}
@@ -554,7 +555,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
layerPush(printPoint.clone().move({ z: 0.1 }), 0, 0, tool);
layerPush(point.clone().move({ z: 0.1 }), 0, 0, tool);
}
- } else
+ }
// when rapid pluge could cut thru stock:
// * rapid to just above stock
// * continue plunge as cut
@@ -675,7 +676,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
* @param {boolean} cutdir true=CW false=CCW
* @param {boolean} depthFirst prioritize cut depth in pockets by nesting
*/
- function pocket({ slices, cutdir, depthFirst, outline, progress }) {
+ function pocket({ slices, cutdir, depthFirst, outline, progress, spiral }) {
let total = 0;
let depthData = [];
@@ -707,6 +708,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
} else {
// if not depth first, output the polys in slice order
setTravelBoundary(slice.tool_shadow.clone(true));
+ if (spiral && total > 0) {
+ layerPush(printPoint.clone().setZ(zSafe), 0, 0, tool);
+ newLayer();
+ }
poly2polyEmit(polys, printPoint, polyEmit, { swapdir: false });
newLayer();
}
@@ -723,42 +728,46 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
descend(depthData.slice(i), undefined, outline);
}
}
- }
- function descend(stack, inside, outline) {
- if (stack.length === 0) return;
- let tops = stack[0];
- let flat = (outline ? POLY.flatten(tops) : tops).filter(poly => !poly.marked);
- if (flat.length === 0) return;
- if (inside) {
- flat = flat.filter(p => p.isInside(inside));
- }
+ function descend(stack, inside, outline) {
+ if (stack.length === 0) return;
+ let tops = stack[0];
+ let flat = (outline ? POLY.flatten(tops) : tops).filter(poly => !poly.marked);
+ if (flat.length === 0) return;
+ if (inside) {
+ flat = flat.filter(p => p.isInside(inside));
+ }
- for (;;) {
- let wpp = getWidgetPrintPoint();
- let poly = flat.filter(poly => !poly.marked)
- .map(p => p.findClosestPointTo(wpp))
- .sort((a,b) => a.distance - b.distance)
- .map(rec => rec.poly)[0];
-
- if (poly) {
- let output = [];
- setTravelBoundary(tops.tool_shadow);
- emit_flat([ poly ], output);
- let engage = true;
- for (let poly of output) {
- polyEmit(poly, CLOSEST_TO_PP, engage);
- engage = false;
- }
- if (outline) {
- output.forEach(poly => {
+ for (;;) {
+ let wpp = getWidgetPrintPoint();
+ let poly = flat.filter(poly => !poly.marked)
+ .map(p => p.findClosestPointTo(wpp))
+ .sort((a,b) => a.distance - b.distance)
+ .map(rec => rec.poly)[0];
+
+ if (poly) {
+ let output = [];
+ setTravelBoundary(tops.tool_shadow);
+ emit_flat([ poly ], output);
+ let engage = true;
+ if (spiral && inside) {
+ layerPush(printPoint.clone().setZ(zSafe), 0, 0, tool);
+ newLayer();
+ }
+ for (let poly of output) {
+ polyEmit(poly, CLOSEST_TO_PP, engage);
+ engage = false;
+ }
+ if (outline) {
+ output.forEach(poly => {
+ descend(stack.slice(1), poly, outline);
+ });
+ } else {
descend(stack.slice(1), poly, outline);
- });
+ }
} else {
- descend(stack.slice(1), poly, outline);
+ return;
}
- } else {
- return;
}
}
}
@@ -816,7 +825,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
}
}
- if (!contouring && poly.isClosed()) {
+ if (poly.isClosed()) {
points.push(points[0].clone());
}
diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js
index bae903127..335f3d84b 100644
--- a/src/kiri/mode/cam/work/topo3.js
+++ b/src/kiri/mode/cam/work/topo3.js
@@ -25,6 +25,7 @@ export class Topo {
axis = contour.axis.toLowerCase(),
contourX = axis === "x",
contourY = axis === "y",
+ contourR = axis === "radial",
bounds = widget.getBoundingBox().clone(),
tolerance = contour.tolerance,
flatness = contour.flatness || (tolerance / 100),
@@ -46,6 +47,8 @@ export class Topo {
leave = contour.leave || 0,
maxangle = contour.angle,
curvesOnly = contour.curves,
+ curvesDistFraction = (contour.curvesDist !== undefined) ? contour.curvesDist : 0.5,
+ curvesDist = curvesDistFraction * toolDiameter,
bridge = contour.bridging || 0,
stepsX = Math.ceil(boundsX / resolution),
stepsY = Math.ceil(boundsY / resolution),
@@ -74,15 +77,22 @@ export class Topo {
tabsOn = tabs,
tabHeight = Math.max(process.camTabsHeight + zBottom, tabsMax),
clipTab = tabsOn ? [] : null,
- clipTo = inside ? shadow.base : POLY.expand(shadow.base, toolDiameter / 2 + resolution * 3),
+ shadowBase = (contour.omitthru && shadow.holes) ? omitMatching(shadow.base, shadow.holes) : shadow.base,
+ clipTo = inside ? shadowBase : POLY.expand(shadowBase, toolDiameter / 2 + (contourR ? toolStep : 0) + resolution * 3),
partOff = inside ? 0 : toolDiameter / 2 + resolution,
gridDelta = Math.floor(partOff / resolution),
debug_clips = true;
+ let clipStock = undefined;
if (contour.clipto) {
let { stock } = settings;
let { center, x, y } = stock;
- clipTo.push(newPolygon().centerRectangle(center, x, y));
+ let stockPoly = newPolygon().centerRectangle(center, x, y);
+ if (webGPU && !contour.nogpu && !contourR) {
+ clipTo.push(stockPoly);
+ } else {
+ clipStock = [ stockPoly ];
+ }
}
if (tolerance === 0 && !topoCache) {
@@ -107,6 +117,7 @@ export class Topo {
const output = debug.output();
if (clipTab) output.setLayer("clip.tab", { line: 0xff0000 }).addPolys(clipTab);
if (clipTo) output.setLayer("clip.to", { line: 0x00dd00 }).addPolys(clipTo);
+ if (clipStock) output.setLayer("clip.stock", { line: 0xdd00dd }).addPolys(clipStock);
newslices.push(debug);
}
@@ -146,7 +157,7 @@ export class Topo {
let trace = contour.trace;
let gpu = await self.get_raster_gpu({
- mode: trace ? "tracing" : "planar",
+ mode: contourR ? "tracing" : (trace ? "tracing" : "planar"),
resolution
});
let xStep = density;
@@ -162,13 +173,349 @@ export class Topo {
boundsOverride: wbounds
});
let { gridWidth, positions } = terrain;
- // generate all scanline points passing tool over terrain
- let output = await gpu.generateToolpaths({
- xStep,
- yStep,
- zFloor: zBottom - 1,
- onProgress(pct) { console.log({ pct }); onupdate(pct/100, 100) }
+
+ // Map GPU row-major positions to CPU column-major data
+ const rx = stepsX / boundsX;
+ const ry = stepsY / boundsY;
+ const grx = 1 / resolution;
+ const gridHeight = Math.ceil((wbounds.max.y - wbounds.min.y) / resolution) + 1;
+ for (let ix = 0; ix < stepsX; ix++) {
+ for (let iy = 0; iy < stepsY; iy++) {
+ const px = minX + ix / rx;
+ const py = minY + iy / ry;
+ const gix = Math.round((px - wbounds.min.x) * grx);
+ const giy = Math.round((py - wbounds.min.y) * grx);
+ if (gix >= 0 && gix < gridWidth && giy >= 0 && giy < gridHeight) {
+ const val = positions[giy * gridWidth + gix];
+ data[ix * stepsY + iy] = (val === undefined || val <= -1e9) ? zMin : val;
+ } else {
+ data[ix * stepsY + iy] = zMin;
+ }
+ }
+ }
+
+ // Run through-hole capping on CPU data if omitthru is enabled
+ if (contour.omitthru && shadow.holes && shadow.holes.length) {
+ const rx_cap = stepsX / boundsX;
+ for (let hole of shadow.holes) {
+ const expHole = POLY.expand([hole], resolution * 1.5)[0];
+ if (!expHole) continue;
+ const hbounds = expHole.bounds;
+ const min_ix = Math.max(0, Math.floor(rx_cap * (hbounds.minx - minX)));
+ const max_ix = Math.min(stepsX - 1, Math.ceil(rx_cap * (hbounds.maxx - minX)));
+ const min_iy = Math.max(0, Math.floor(rx_cap * (hbounds.miny - minY)));
+ const max_iy = Math.min(stepsY - 1, Math.ceil(rx_cap * (hbounds.maxy - minY)));
+
+ for (let ix = min_ix; ix <= max_ix; ix++) {
+ for (let iy = min_iy; iy <= max_iy; iy++) {
+ const idx = ix * stepsY + iy;
+ if (data[idx] < zMin + 0.1) {
+ const px = minX + ix / rx_cap;
+ const py = minY + iy / rx_cap;
+ const pt = newPoint(px, py);
+ if (pt.isInPolygon(expHole)) {
+ let edgePt = null;
+ edgePt = hole.findClosestPointOnPerimeter(pt);
+ let outsidePt = edgePt;
+ const d = pt.distTo2D(edgePt);
+ if (d > 0.00001) {
+ const dx = (edgePt.x - pt.x) / d;
+ const dy = (edgePt.y - pt.y) / d;
+ outsidePt = newPoint(edgePt.x + dx * (resolution * 0.5), edgePt.y + dy * (resolution * 0.5));
+ }
+ let edge_ix = Math.max(0, Math.min(stepsX - 1, Math.round(rx_cap * (outsidePt.x - minX))));
+ let edge_iy = Math.max(0, Math.min(stepsY - 1, Math.round(rx_cap * (outsidePt.y - minY))));
+
+ if (edge_ix === ix && edge_iy === iy) {
+ const step_x = Math.sign(outsidePt.x - pt.x) || 0;
+ const step_y = Math.sign(outsidePt.y - pt.y) || 0;
+ let nx = Math.max(0, Math.min(stepsX - 1, ix + step_x));
+ let ny = Math.max(0, Math.min(stepsY - 1, iy + step_y));
+ if (nx !== ix || ny !== iy) {
+ edge_ix = nx;
+ edge_iy = ny;
+ }
+ }
+ data[idx] = data[edge_ix * stepsY + edge_iy];
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Initialize probe on Topo instance for CPU-side trace verification/fallbacks
+ const probe = this.probe = new Probe({
+ profile: toolOffset,
+ data,
+ stepsX,
+ stepsY,
+ boundsX,
+ boundsY,
+ minX,
+ minY,
+ zMin
});
+
+ this.toolAtZ = probe.toolAtZ;
+ this.toolAtXY = probe.toolAtXY;
+ this.zAtXY = probe.zAtXY;
+
+ // Generate 2D radial paths on the CPU if in Radial mode
+ let radial2DPaths = [];
+ let radialStep = resolution * density;
+ if (contourR) {
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+ const partOff = inside ? 0 : toolDiameter / 2 + resolution;
+ const dx = maxX - centerX + partOff;
+ const dy = maxY - centerY + partOff;
+ const maxR = Math.sqrt(dx * dx + dy * dy);
+ const shape = (contour.shape || 'Concentric').toLowerCase();
+ const isConcentricLike = shape === 'concentric' || shape === 'spiral' || shape === 'concentric spiral' || shape === 'contour spiral';
+ const isSpiralLike = shape === 'spiral' || shape === 'concentric spiral' || shape === 'contour spiral';
+
+ if (isConcentricLike) {
+ if (clipTo && clipTo.length) {
+ let outs = [];
+ POLY.offset(clipTo, -toolStep, { count: 999, outs: outs, flat: true, z: 0, minArea: 0.01 });
+
+ let loops = [];
+ for (let i = outs.length - 1; i >= 0; i--) {
+ loops.push(outs[i].clone(true));
+ }
+ for (let poly of clipTo) {
+ loops.push(poly.clone(true));
+ }
+ loops = POLY.flatten(loops, [], true);
+
+ if (isSpiralLike) {
+ loops = POLY.spiralize(loops);
+ }
+
+ for (let poly of loops) {
+ const points = poly.points;
+ const numPoints = points.length;
+ if (numPoints < 2) continue;
+
+ let subPoints = [];
+ const limit = poly.open ? numPoints - 1 : numPoints;
+ for (let i = 0; i < limit; i++) {
+ const p1 = points[i];
+ const p2 = points[(i + 1) % numPoints];
+ const len = p1.distTo2D(p2);
+
+ if (len > radialStep) {
+ const divisions = Math.ceil(len / radialStep);
+ for (let j = 0; j < divisions; j++) {
+ const pct = j / divisions;
+ subPoints.push(p1.x + (p2.x - p1.x) * pct, p1.y + (p2.y - p1.y) * pct);
+ }
+ } else {
+ subPoints.push(p1.x, p1.y);
+ }
+ }
+ if (poly.open && numPoints > 0) {
+ let lastP = points[numPoints - 1];
+ subPoints.push(lastP.x, lastP.y);
+ }
+ radial2DPaths.push(new Float32Array(subPoints));
+ }
+ }
+ }
+ }
+
+ let output;
+ if (contourR) {
+ if (radial2DPaths.length === 0) {
+ gpu.terminate();
+ ondone([], this);
+ return this;
+ }
+ output = await gpu.generateToolpaths({
+ paths: radial2DPaths,
+ step: radialStep,
+ zFloor: zBottom - 1,
+ onProgress(pct) { onupdate(pct/100, 100) }
+ });
+ } else {
+ output = await gpu.generateToolpaths({
+ xStep,
+ yStep,
+ zFloor: zBottom - 1,
+ onProgress(pct) { console.log({ pct }); onupdate(pct/100, 100) }
+ });
+ }
+
+ if (contourR) {
+ // Post-process the 3D paths on CPU
+ gpu.terminate();
+ let slices = [];
+ let checkr = newPoint(0, 0);
+
+ this.trace = new Trace(this.probe, {
+ curvesOnly,
+ curvesDist,
+ maxangle,
+ flatness,
+ bridge,
+ contourX,
+ contourR,
+ leave,
+ resolution,
+ holes: (contour.omitthru && shadow.holes && shadow.holes.length) ? shadow.holes : null
+ });
+
+ this.trace.init({
+ box: wbounds.clone(),
+ leave,
+ clipTo,
+ clipStock,
+ clipTab,
+ clipTabZ: clipTab ? clipTab.map(t => t.z) : undefined,
+ tabHeight,
+ resolution,
+ concurrent: false,
+ density
+ });
+
+ this.trace.newslice();
+
+ const shape = (contour.shape || 'Concentric').toLowerCase();
+ let loopIdx = 0;
+
+ for (let pathXYZ of output.paths) {
+ let points = [];
+ for (let i = 0; i < pathXYZ.length; i += 3) {
+ points.push({ x: pathXYZ[i], y: pathXYZ[i+1], z: pathXYZ[i+2] });
+ }
+
+ if (shape === 'concentric') {
+ let evaluated = [];
+ let hasOut = false;
+ for (let pt of points) {
+ checkr.x = pt.x;
+ checkr.y = pt.y;
+
+ const inStock = !clipStock || this.trace.inClip(clipStock, undefined, checkr);
+ const inShadow = !clipTo || this.trace.inClip(clipTo, undefined, checkr);
+ const inClipPos = inStock && inShadow;
+
+ if (!inClipPos) {
+ hasOut = true;
+ evaluated.push({ x: pt.x, y: pt.y, z: 0, inClip: false });
+ } else {
+ let tv = Math.max(pt.z, this.probe.zAtXY(pt.x, pt.y));
+ if (clipTab && clipTab.length && tv < tabHeight && this.trace.inClip(clipTab, tv, checkr)) {
+ tv = this.trace.tabZ;
+ }
+ evaluated.push({ x: pt.x, y: pt.y, z: tv, inClip: true });
+ }
+ }
+
+ if (hasOut) {
+ let firstOutIdx = evaluated.findIndex(p => !p.inClip);
+ let rotated = [...evaluated.slice(firstOutIdx), ...evaluated.slice(0, firstOutIdx)];
+
+ let tracing = false;
+ for (let pt of rotated) {
+ if (pt.inClip) {
+ if (!tracing) {
+ this.trace.newtrace();
+ tracing = true;
+ this.trace.setLoopIndex(loopIdx);
+ }
+ this.trace.push_point(pt.x, pt.y, pt.z + leave);
+ } else {
+ if (tracing) {
+ this.trace.end_poly();
+ tracing = false;
+ }
+ }
+ }
+ if (tracing) {
+ this.trace.end_poly();
+ }
+ } else {
+ this.trace.newtrace();
+ this.trace.setClosed();
+ this.trace.setLoopIndex(loopIdx);
+
+ const lastPt = evaluated[evaluated.length - 1];
+ if (lastPt) {
+ this.trace.setLastPoint(newPoint(lastPt.x, lastPt.y, lastPt.z + leave));
+ }
+ for (let pt of evaluated) {
+ this.trace.push_point(pt.x, pt.y, pt.z + leave);
+ }
+ this.trace.end_poly();
+ }
+ } else {
+ let tracing = false;
+ this.trace.newtrace();
+
+ for (let pt of points) {
+ checkr.x = pt.x;
+ checkr.y = pt.y;
+
+ const inStock = !clipStock || this.trace.inClip(clipStock, undefined, checkr);
+ const inShadow = !clipTo || this.trace.inClip(clipTo, undefined, checkr);
+ const inClipPos = inStock && inShadow;
+
+ if (!inClipPos) {
+ if (tracing) {
+ this.trace.end_poly();
+ tracing = false;
+ }
+ } else {
+ if (!tracing) {
+ this.trace.newtrace();
+ tracing = true;
+ }
+ let tv = Math.max(pt.z, this.probe.zAtXY(pt.x, pt.y));
+ if (clipTab && clipTab.length && tv < tabHeight && this.trace.inClip(clipTab, tv, checkr)) {
+ tv = this.trace.tabZ;
+ }
+ this.trace.push_point(pt.x, pt.y, tv + leave);
+ }
+ }
+ if (tracing) {
+ this.trace.end_poly();
+ }
+ }
+ loopIdx++;
+ }
+
+ let segments = this.trace.slice;
+ if (segments.length > 0) {
+ if (shape === 'concentric') {
+ let grouped = [];
+ for (let seg of segments) {
+ let lidx = seg.loopIndex ?? 0;
+ if (!grouped[lidx]) {
+ grouped[lidx] = [];
+ }
+ grouped[lidx].push(seg);
+ }
+ let sliceIdx = 0;
+ for (let g of grouped) {
+ if (g && g.length > 0) {
+ let slice = newSlice(sliceIdx++);
+ slice.camLines = g;
+ slices.push(slice);
+ }
+ }
+ } else {
+ let slice = newSlice(0);
+ slice.camLines = segments;
+ slices.push(slice);
+ }
+ }
+
+ ondone(slices, this);
+ return this;
+ }
+
gpu.mode = 'tracing';
// create coastline path around part for tip-to-tip travels
// convert shadow/clip poly lines to raster float32 array groups
@@ -329,7 +676,7 @@ export class Topo {
onupdate(i, numScanlines);
}
}
- ondone(slices);
+ ondone(slices, this);
return this;
}
@@ -353,10 +700,15 @@ export class Topo {
const trace = this.trace = new Trace(probe, {
curvesOnly,
+ curvesDist,
maxangle,
flatness,
bridge,
- contourX
+ contourX,
+ contourR,
+ resolution,
+ leave,
+ holes: (contour.omitthru && shadow.holes && shadow.holes.length) ? shadow.holes : null
});
if (topo.raster) {
@@ -381,6 +733,80 @@ export class Topo {
topo.raster = false;
}
+ // THROUGH-HOLE CAPPING LOGIC (OMIT THROUGH option):
+ // If the user wants to omit milling through-holes, we find all grid cells that fall inside
+ // any through-hole polygon. Since a through-hole has a depth of 'zMin' (air/empty space), we
+ // "cap" the grid cell by copying the height of the nearest solid wall/boundary. This fools
+ // the z-height probe into believing the hole is filled at solid part height, preventing
+ // the tool from plunging down or generating toolpaths inside the hole.
+ if (contour.omitthru && shadow.holes && shadow.holes.length) {
+ let cappedCount = 0;
+ const rx = stepsX / boundsX; // Coordinate scaling factor
+ for (let hole of shadow.holes) {
+ // Expand the boundary check slightly (by 1.5 * resolution) to capture boundary cells
+ // that may be slightly on the edge of the polygon due to grid discretization.
+ const expHole = POLY.expand([hole], resolution * 1.5)[0];
+ if (!expHole) continue;
+ const hbounds = expHole.bounds;
+ // Crop search range to the hole's bounding box to keep loop iterations fast
+ const min_ix = Math.max(0, Math.floor(rx * (hbounds.minx - minX)));
+ const max_ix = Math.min(stepsX - 1, Math.ceil(rx * (hbounds.maxx - minX)));
+ const min_iy = Math.max(0, Math.floor(rx * (hbounds.miny - minY)));
+ const max_iy = Math.min(stepsY - 1, Math.ceil(rx * (hbounds.maxy - minY)));
+
+ for (let ix = min_ix; ix <= max_ix; ix++) {
+ for (let iy = min_iy; iy <= max_iy; iy++) {
+ const idx = ix * stepsY + iy;
+ // Only cap empty cells (having a height near zMin) to avoid overwriting solid geometry
+ if (data[idx] < zMin + 0.1) {
+ const px = minX + ix / rx;
+ const py = minY + iy / rx;
+ const pt = newPoint(px, py);
+ if (pt.isInPolygon(expHole)) {
+ // Find the closest boundary point on the original unexpanded hole perimeter
+ let edgePt = null;
+ if (axis === 'x') {
+ edgePt = hole.snapToIntersectionX(pt);
+ } else if (axis === 'y') {
+ edgePt = hole.snapToIntersectionY(pt);
+ }
+ if (!edgePt) {
+ edgePt = hole.findClosestPointOnPerimeter(pt);
+ }
+ let outsidePt = edgePt;
+ const d = pt.distTo2D(edgePt);
+ if (d > 0.00001) {
+ // Project the coordinate slightly outward (by half a grid step) into the solid part
+ // to ensure we sample a clean height from the solid part instead of a transitional edge.
+ const dx = (edgePt.x - pt.x) / d;
+ const dy = (edgePt.y - pt.y) / d;
+ outsidePt = newPoint(edgePt.x + dx * (resolution * 0.5), edgePt.y + dy * (resolution * 0.5));
+ }
+ let edge_ix = Math.max(0, Math.min(stepsX - 1, Math.round(rx * (outsidePt.x - minX))));
+ let edge_iy = Math.max(0, Math.min(stepsY - 1, Math.round(rx * (outsidePt.y - minY))));
+
+ // Fallback: if the outward projection still maps to the same grid cell ix/iy,
+ // step one grid cell away in the direction of the boundary to guarantee we fetch solid height.
+ if (edge_ix === ix && edge_iy === iy) {
+ const step_x = Math.sign(outsidePt.x - pt.x) || 0;
+ const step_y = Math.sign(outsidePt.y - pt.y) || 0;
+ let nx = Math.max(0, Math.min(stepsX - 1, ix + step_x));
+ let ny = Math.max(0, Math.min(stepsY - 1, iy + step_y));
+ if (nx !== ix || ny !== iy) {
+ edge_ix = nx;
+ edge_iy = ny;
+ }
+ }
+ // Copy the height from the solid part edge cell onto the hole cell
+ data[idx] = data[edge_ix * stepsY + edge_iy];
+ cappedCount++;
+ }
+ }
+ }
+ }
+ }
+ }
+
await this.contour({
box: topo.box,
minX,
@@ -397,18 +823,21 @@ export class Topo {
toolStep,
contourX,
contourY,
+ contourR,
density,
clipTo,
+ clipStock,
clipTab,
clipTabZ: clipTab ? clipTab.map(t => t.z) : undefined,
tabHeight,
newslices,
- leave
+ leave,
+ shape: contour.shape
}, (i, l, p) => {
onupdate(l / 2 + i / 2, l, p);
});
- ondone(newslices);
+ ondone(newslices, this);
return this;
}
@@ -529,8 +958,8 @@ export class Topo {
const concurrent = self.kiri_worker.minions.running;
const { minX, maxX, minY, maxY, boundsX, boundsY, stepsX, stepsY } = params;
- const { gridDelta, resolution, density, partOff, toolStep, contourX, contourY } = params;
- const { clipTo, clipTab, clipTabZ, tabHeight, newslices, leave } = params;
+ const { gridDelta, resolution, density, partOff, toolStep, contourX, contourY, contourR } = params;
+ const { clipTo, clipStock, clipTab, clipTabZ, tabHeight, newslices, leave, shape } = params;
let stepsTaken = 0,
stepsTotal = 0;
@@ -543,6 +972,17 @@ export class Topo {
stepsTotal += ((maxX - minX + partOff * 2) / toolStep) | 0;
}
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+ const dx = maxX - centerX + partOff;
+ const dy = maxY - centerY + partOff;
+ const maxR = Math.sqrt(dx * dx + dy * dy);
+ const totalTurns = maxR / toolStep;
+
+ if (contourR) {
+ stepsTotal += Math.ceil(totalTurns);
+ }
+
if (stepsTotal === 0) {
return;
}
@@ -555,11 +995,12 @@ export class Topo {
box,
leave,
clipTo,
+ clipStock,
clipTab,
clipTabZ,
tabHeight,
resolution,
- concurrent,
+ concurrent: contourR ? false : concurrent,
density
});
@@ -567,13 +1008,16 @@ export class Topo {
let pcount = 0;
let slicesY = [];
let slicesX = [];
+ let slicesR = [];
let promise = new Promise(resolve => {
resolver = () => {
// sort output slices (required for async)
slicesY.sort((a, b) => a.z - b.z);
slicesX.sort((a, b) => a.z - b.z);
+ slicesR.sort((a, b) => a.z - b.z);
newslices.appendAll(slicesY);
newslices.appendAll(slicesX);
+ newslices.appendAll(slicesR);
resolve();
}
});
@@ -632,6 +1076,82 @@ export class Topo {
}
}
+ if (contourR) {
+ onupdate(0, stepsTotal, "contour radial");
+ inc();
+ trace.crossRadial({
+ centerX,
+ centerY,
+ maxR,
+ toolStep,
+ shape: (shape || 'Concentric').toLowerCase()
+ }, segments => {
+ if (segments.length > 0) {
+ const lshape = (shape || 'Concentric').toLowerCase();
+ if (lshape === 'concentric') {
+ // Export each loop as a separate slice
+ let grouped = [];
+ for (let seg of segments) {
+ let lidx = seg.loopIndex ?? 0;
+ if (!grouped[lidx]) {
+ grouped[lidx] = [];
+ }
+ grouped[lidx].push(seg);
+ }
+ let sliceIdx = 0;
+ for (let g of grouped) {
+ if (g && g.length > 0) {
+ let slice = newSlice(sliceIdx++);
+ slice.camLines = g;
+ slicesR.push(slice);
+ }
+ }
+ } else if (lshape === 'spiral' || lshape === 'concentric spiral' || lshape === 'contour spiral') {
+ // Contour/Concentric Spiral mode: split into separate slices (revolutions)
+ let sliceIdx = 0;
+ for (let segIdx = 0; segIdx < segments.length; segIdx++) {
+ let seg = segments[segIdx];
+ let rN = seg.resampleN || 100;
+ let points = seg.points;
+ let ptsCount = points.length;
+ for (let i = 0; i < ptsCount; i += rN) {
+ let start = Math.max(0, i - 1);
+ let end = Math.min(ptsCount, i + rN);
+ if (end - start < 2) continue;
+
+ let slice = newSlice(sliceIdx++);
+ let chunkPoly = newPolygon(points.slice(start, end));
+ chunkPoly.setOpen();
+ chunkPoly.spiralId = segIdx;
+ slice.camLines = [ chunkPoly ];
+ slicesR.push(slice);
+ }
+ }
+ } else {
+ // Fallback to Concentric slice building if shape is unrecognized
+ let grouped = [];
+ for (let seg of segments) {
+ let lidx = seg.loopIndex ?? 0;
+ if (!grouped[lidx]) {
+ grouped[lidx] = [];
+ }
+ grouped[lidx].push(seg);
+ }
+ let sliceIdx = 0;
+ for (let g of grouped) {
+ if (g && g.length > 0) {
+ let slice = newSlice(sliceIdx++);
+ slice.camLines = g;
+ slicesR.push(slice);
+ }
+ }
+ }
+ }
+ onupdate(stepsTotal, stepsTotal, "contour radial");
+ dec();
+ });
+ }
+
if (!concurrent) resolver();
await promise;
@@ -650,7 +1170,7 @@ export class Probe {
constructor(params) {
const { data, profile } = params;
- const { stepsX, stepsY, boundsX, zMin, minX, minY } = params;
+ const { stepsX, stepsY, boundsX, boundsY, zMin, minX, minY } = params;
this.params = params;
@@ -684,7 +1204,7 @@ export class Probe {
// export z probe function
const rx = stepsX / boundsX;
- const ry = stepsX / boundsX;
+ const ry = stepsY / boundsY;
const toolAtXY = this.toolAtXY = function (px, py) {
px = Math.round(rx * (px - minX));
py = Math.round(ry * (py - minY));
@@ -704,26 +1224,85 @@ export class Trace {
constructor(probe, params) {
- const { curvesOnly, maxangle, flatness, bridge, contourX, leave } = params;
+ const { curvesOnly, curvesDist, maxangle, flatness, bridge, contourX, contourR, leave, resolution } = params;
this.params = params;
this.probe = probe;
+ // Structured cloning to parallel workers strips getters/prototypes from Polygon objects.
+ // We guarantee that all through-hole boundary polygons have their bounds defined with a
+ // containsXY(x, y) check so that subsequent slope-masking tests on the worker don't crash.
+ if (params.holes) {
+ for (let hole of params.holes) {
+ if (!hole.bounds) {
+ let minx = Infinity, maxx = -Infinity, miny = Infinity, maxy = -Infinity;
+ for (let p of hole.points) {
+ if (p.x < minx) minx = p.x;
+ if (p.x > maxx) maxx = p.x;
+ if (p.y < miny) miny = p.y;
+ if (p.y > maxy) maxy = p.y;
+ }
+ const hb = {
+ minx, maxx, miny, maxy,
+ containsXY(x, y) {
+ return x >= this.minx && x <= this.maxx && y >= this.miny && y <= this.maxy;
+ }
+ };
+ Object.defineProperty(hole, 'bounds', {
+ value: hb,
+ writable: true,
+ configurable: true
+ });
+ }
+ }
+ }
+
let trace,
slice,
latent,
lastPP,
- lastSlope;
+ lastSlope,
+ flatBuffer = [],
+ flatDist = 0,
+ splitDone = false;
const newslice = this.newslice = () => {
this.slice = slice = [];
}
+ // Expose helper methods on the Trace class instance to cleanly forward parameters
+ // to the active polygon being generated, or to set initial/previous tracing state.
+ const setClosed = this.setClosed = function () {
+ if (trace) trace.open = false;
+ };
+
+ const setLoopIndex = this.setLoopIndex = function (idx) {
+ if (trace) trace.loopIndex = idx;
+ };
+
+ const setResampleN = this.setResampleN = function (n) {
+ if (trace) trace.resampleN = n;
+ };
+
+ const setLastPoint = this.setLastPoint = function (point) {
+ lastPP = point;
+ };
+
const newtrace = this.newtrace = function () {
- trace = newPolygon().setOpen();
+ trace = object.trace = newPolygon().setOpen();
}
const end_poly = this.end_poly = function (point) {
+ if (flatBuffer.length > 0) {
+ if (!splitDone) {
+ for (let p of flatBuffer) {
+ trace.push(p);
+ }
+ }
+ flatBuffer = [];
+ flatDist = 0;
+ splitDone = false;
+ }
if (latent) {
trace.push(latent);
}
@@ -732,7 +1311,11 @@ export class Trace {
if (trace.length > 1) {
slice.push(trace);
}
+ const oldIdx = trace.loopIndex;
+ const oldN = trace.resampleN;
newtrace();
+ trace.loopIndex = oldIdx;
+ trace.resampleN = oldN;
}
lastPP = undefined;
latent = undefined;
@@ -758,28 +1341,154 @@ export class Trace {
const lastP = lastPP;
if (lastP) {
+ // If "Curves Only" is active, check if the point is inside a through-hole.
+ // If inside a hole, we split the toolpath immediately at the boundary and skip the point.
+ let inHole = false;
+ if (curvesOnly && params.holes) {
+ for (let hole of params.holes) {
+ const hb = hole.bounds;
+ if (newP.x >= hb.minx && newP.x <= hb.maxx && newP.y >= hb.miny && newP.y <= hb.maxy) {
+ if (newP.isInPolygon(hole)) {
+ inHole = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if (inHole) {
+ if (!splitDone) {
+ trace.setOpen();
+ flatBuffer = [];
+ end_poly();
+ splitDone = true;
+ }
+ flatBuffer = [];
+ flatDist = 0;
+ lastPP = newP;
+ return;
+ }
+
const dl = (x - lastP.x) || (y - lastP.y);
const dz = z - lastP.z;
- const slope = Math.atan2(dz, dl);
- if (curvesOnly && Math.abs(dz) < flatness) {
- end_poly(newP);
- } else if (lastSlope !== undefined && Math.abs(lastSlope - slope) < flatness) {
- latent = newP;
- } else {
- if (latent) {
- trace.push(latent);
- latent = undefined;
+
+ let isFlat = false;
+ if (curvesOnly) {
+ if (contourR) {
+ // RADIAL LOCAL SURFACE SLOPE DETECTION (Curves Only mode):
+ // Radial/Concentric toolpaths move along a curved path. We cannot check flatness
+ // purely by comparing adjacent toolpath points (Math.abs(dz)) because height changes
+ // along concentric arcs on sloped/spherical profiles can be tiny.
+ // Instead, we probe the terrain height in orthogonal directions (+/- delta) around (x, y).
+ const delta = Math.max(resolution * 2, 0.05);
+ const z0 = probe.zAtXY(x, y);
+
+ // Mask through-holes: if a probed coordinates falls inside a through-hole, we return
+ // the height of the center point (z0). This prevents cliff-edges around through-holes
+ // from registering as "sloped" and generating stray finishing toolpaths near hole boundaries.
+ const getSlopeZ = (px, py) => {
+ if (params.holes) {
+ for (let hole of params.holes) {
+ const hb = hole.bounds;
+ if (px >= hb.minx && px <= hb.maxx && py >= hb.miny && py <= hb.maxy) {
+ if (newPoint(px, py).isInPolygon(hole)) {
+ return z0;
+ }
+ }
+ }
+ }
+ return probe.zAtXY(px, py);
+ };
+ const zX1 = getSlopeZ(x + delta, y);
+ const zX2 = getSlopeZ(x - delta, y);
+ const zY1 = getSlopeZ(x, y + delta);
+ const zY2 = getSlopeZ(x, y - delta);
+
+ // Scale slopeFlatness with delta to maintain a consistent angle threshold (~3 degrees)
+ const slopeFlatness = Math.max(delta * 0.05, 0.002);
+ const isSurfaceSloped =
+ Math.abs(zX1 - z0) >= slopeFlatness ||
+ Math.abs(zX2 - z0) >= slopeFlatness ||
+ Math.abs(zY1 - z0) >= slopeFlatness ||
+ Math.abs(zY2 - z0) >= slopeFlatness;
+
+ // The point is flat if the toolpath height change is minimal AND the surrounding surface has no slope
+ isFlat = Math.abs(dz) < slopeFlatness && !isSurfaceSloped;
+ } else {
+ isFlat = Math.abs(dz) < flatness;
+ }
+ }
+
+ if (isFlat) {
+ if (flatBuffer.length === 0) {
+ flatBuffer.push(newP);
+ flatDist = lastP.distTo2D(newP);
+ splitDone = false;
+ } else {
+ flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP);
+ flatBuffer.push(newP);
+ }
+
+ if (flatDist > curvesDist) {
+ if (!splitDone) {
+ trace.setOpen();
+ // Empty flatBuffer before calling end_poly to ensure we discard the flat segment
+ // we are splitting at, rather than flushing the flat points into the ended segment.
+ flatBuffer = [];
+ end_poly();
+ splitDone = true;
+ }
+ flatBuffer = [newP];
+ }
+ lastPP = newP;
+ return;
+ }
+
+ // If we were in a flat region, flush it now before handling the sloped/steep point
+ if (flatBuffer.length > 0) {
+ if (splitDone) {
+ trace.push(flatBuffer[flatBuffer.length - 1]);
+ } else {
+ for (let p of flatBuffer) {
+ trace.push(p);
+ }
}
+ flatBuffer = [];
+ flatDist = 0;
+ splitDone = false;
+ }
+
+ if (contourR) {
if (curvesOnly) {
- const dv = contourX ? Math.abs(lastP.x - x) : Math.abs(lastP.y - y);
+ const dv = lastP.distTo2D(newP);
const angle = Math.atan2(Math.abs(dz), dv) * RAD2DEG;
if (angle > maxangle) {
+ trace.setOpen();
end_poly();
}
}
trace.push(newP);
+ } else {
+ const slope = Math.atan2(dz, dl);
+ if (lastSlope !== undefined && Math.abs(lastSlope - slope) < flatness) {
+ latent = newP;
+ } else {
+ if (latent) {
+ trace.push(latent);
+ latent = undefined;
+ }
+ if (curvesOnly) {
+ const dv = contourX ? Math.abs(lastP.x - x) : Math.abs(lastP.y - y);
+ const angle = Math.atan2(Math.abs(dz), dv) * RAD2DEG;
+ if (angle > maxangle) {
+ trace.setOpen();
+ end_poly();
+ }
+ }
+ trace.push(newP);
+ }
+ lastSlope = slope;
}
- lastSlope = slope;
} else {
trace.push(newP);
}
@@ -866,7 +1575,7 @@ export class Trace {
crossY_sync(params, then) {
const { push_point, end_poly, newtrace, newslice, inClip } = this.object;
- const { clipTab, tabHeight, clipTo, box, resolution, density, leave } = this.cross;
+ const { clipTab, tabHeight, clipTo, clipStock, box, resolution, density, leave } = this.cross;
const { toolAtZ } = this.probe;
let { from, to, x, gridx, gridy } = params;
@@ -889,9 +1598,10 @@ export class Trace {
if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) {
tv = this.tabZ;
}
- // if the value is on the floor and inside the clip
- // poly (usually shadow), end the segment
- if (clipTo && !inClip(clipTo, undefined, checkr)) {
+ // clip to stock AND shadow (intersection)
+ const inStock = !clipStock || inClip(clipStock, undefined, checkr);
+ const inShadow = !clipTo || inClip(clipTo, undefined, checkr);
+ if (!inStock || !inShadow) {
end_poly();
gridy += density;
continue;
@@ -905,7 +1615,7 @@ export class Trace {
crossX_sync(params, then) {
const { push_point, end_poly, newtrace, newslice, inClip } = this.object;
- const { clipTab, tabHeight, clipTo, box, resolution, density, leave } = this.cross;
+ const { clipTab, tabHeight, clipTo, clipStock, box, resolution, density, leave } = this.cross;
const { toolAtZ } = this.probe;
let { from, to, y, gridx, gridy } = params;
@@ -927,9 +1637,10 @@ export class Trace {
if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) {
tv = this.tabZ;
}
- // if the value is on the floor and inside the clip
- // poly (usually shadow), end the segment
- if (clipTo && !inClip(clipTo, undefined, checkr)) {
+ // clip to stock AND shadow (intersection)
+ const inStock = !clipStock || inClip(clipStock, undefined, checkr);
+ const inShadow = !clipTo || inClip(clipTo, undefined, checkr);
+ if (!inStock || !inShadow) {
end_poly();
gridx += density;
continue;
@@ -940,6 +1651,317 @@ export class Trace {
end_poly();
then(this.slice);
}
+
+ crossRadial(params, then) {
+ const { minions } = self.kiri_worker || {};
+ const { clipTo, toolStep, resolution, density } = this.cross;
+ const shape = (params.shape || 'Concentric').toLowerCase();
+
+ if (minions && minions.running > 1 && this.cross.concurrent) {
+ if (shape === 'concentric') {
+ if (clipTo && clipTo.length) {
+ let outs = [];
+ POLY.offset(clipTo, -toolStep, { count: 999, outs: outs, flat: true, z: 0, minArea: 0.01 });
+
+ let loops = [];
+ for (let i = outs.length - 1; i >= 0; i--) {
+ loops.push(outs[i].clone(true));
+ }
+ for (let poly of clipTo) {
+ loops.push(poly.clone(true));
+ }
+ loops = POLY.flatten(loops, [], true);
+
+ let promises = [];
+ let loopIdx = 0;
+ for (let poly of loops) {
+ const lidx = loopIdx;
+ promises.push(new Promise(resolve => {
+ minions.queue({
+ cmd: "trace_radial",
+ params: {
+ ...params,
+ loop: poly.toObject(),
+ loopIdx: lidx
+ }
+ }, data => {
+ resolve(codec.decode(data.slice));
+ });
+ }));
+ loopIdx++;
+ }
+ Promise.all(promises).then(slices => {
+ let merged = [];
+ for (let slice of slices) {
+ if (slice) {
+ merged.push(...slice);
+ }
+ }
+ then(merged);
+ });
+ } else {
+ then([]);
+ }
+ } else if (shape === 'spiral' || shape === 'concentric spiral' || shape === 'contour spiral') {
+ this.crossRadial_sync(params, then);
+ }
+ } else {
+ this.crossRadial_sync(params, then);
+ }
+ }
+
+ crossRadial_sync(params, then) {
+ const { push_point, end_poly, newtrace, newslice, inClip } = this.object;
+ const { clipTab, tabHeight, clipTo, clipStock, box, resolution, density, leave } = this.cross;
+ const { toolAtXY } = this.probe;
+
+ let { centerX, centerY, maxR, toolStep, shape } = params;
+
+ // Step resolution along the curve/polygon
+ const step = resolution * density;
+ const checkr = newPoint(0, 0);
+
+ newslice();
+
+ const lshape = (shape || 'Concentric').toLowerCase();
+ const isConcentricLike = lshape === 'concentric' || lshape === 'spiral' || lshape === 'concentric spiral' || lshape === 'contour spiral';
+ const isSpiralLike = lshape === 'spiral' || lshape === 'concentric spiral' || lshape === 'contour spiral';
+
+ if (isConcentricLike) {
+ // CONCENTRIC SHAPE GENERATION:
+ // Generates closed concentric loop paths from the innermost region to the outer perimeter.
+ if (params.loop) {
+ let poly = newPolygon().fromObject(params.loop);
+ let loopIdx = params.loopIdx;
+ const self_trace = this;
+
+ const points = poly.points;
+ const numPoints = points.length;
+ if (numPoints >= 2) {
+ // 1. Subdivide loop segments:
+ let subPoints = [];
+ for (let i = 0; i < numPoints; i++) {
+ const p1 = points[i];
+ const p2 = points[(i + 1) % numPoints];
+ const len = p1.distTo2D(p2);
+
+ if (len > step) {
+ const divisions = Math.ceil(len / step);
+ for (let j = 0; j < divisions; j++) {
+ const pct = j / divisions;
+ const x = p1.x + (p2.x - p1.x) * pct;
+ const y = p1.y + (p2.y - p1.y) * pct;
+ subPoints.push({ x, y });
+ }
+ } else {
+ subPoints.push({ x: p1.x, y: p1.y });
+ }
+ }
+
+ // 2. Evaluate clipping and probe Z height for each point:
+ let evaluated = [];
+ let hasOut = false;
+
+ for (let pt of subPoints) {
+ checkr.x = pt.x;
+ checkr.y = pt.y;
+
+ const inStock = !clipStock || inClip(clipStock, undefined, checkr);
+ const inShadow = !clipTo || inClip(clipTo, undefined, checkr);
+ const inClipPos = inStock && inShadow;
+
+ if (!inClipPos) {
+ hasOut = true;
+ evaluated.push({ x: pt.x, y: pt.y, z: 0, inClip: false });
+ } else {
+ let tv = toolAtXY(pt.x, pt.y);
+ if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) {
+ tv = this.tabZ;
+ }
+ evaluated.push({ x: pt.x, y: pt.y, z: tv, inClip: true });
+ }
+ }
+
+ // 3. Emit points using state machine:
+ if (hasOut) {
+ let firstOutIdx = evaluated.findIndex(p => !p.inClip);
+ let rotated = [...evaluated.slice(firstOutIdx), ...evaluated.slice(0, firstOutIdx)];
+
+ let tracing = false;
+ for (let pt of rotated) {
+ if (pt.inClip) {
+ if (!tracing) {
+ newtrace();
+ tracing = true;
+ self_trace.setLoopIndex(loopIdx);
+ }
+ push_point(pt.x, pt.y, pt.z + leave);
+ } else {
+ if (tracing) {
+ end_poly();
+ tracing = false;
+ }
+ }
+ }
+ if (tracing) {
+ end_poly();
+ }
+ } else {
+ newtrace();
+ self_trace.setClosed();
+ self_trace.setLoopIndex(loopIdx);
+
+ const lastPt = evaluated[evaluated.length - 1];
+ if (lastPt) {
+ self_trace.setLastPoint(newPoint(lastPt.x, lastPt.y, lastPt.z + leave));
+ }
+ for (let pt of evaluated) {
+ push_point(pt.x, pt.y, pt.z + leave);
+ }
+ end_poly();
+ }
+ }
+ } else if (clipTo && clipTo.length) {
+ let outs = [];
+ // Use POLY.offset to generate concentric toolpath offsets (step-over) from the boundary.
+ // -toolStep is used to offset inwards. We offset on the 2D plane (z: 0) and then probe Z height.
+ POLY.offset(clipTo, -toolStep, { count: 999, outs: outs, flat: true, z: 0, minArea: 0.01 });
+
+ // We want to cut from the inside out to minimize tool deflection and vibration.
+ // POLY.offset generates paths from outside-in: [first offset, second offset, ..., innermost]
+ // We reverse the array to cut from [innermost, ..., second offset, first offset].
+ let loops = [];
+ for (let i = outs.length - 1; i >= 0; i--) {
+ loops.push(outs[i].clone(true));
+ }
+ // Append the original boundary (clipTo) at the end so we perform a final perimeter pass.
+ for (let poly of clipTo) {
+ loops.push(poly.clone(true));
+ }
+ loops = POLY.flatten(loops, [], true);
+
+ if (isSpiralLike) {
+ loops = POLY.spiralize(loops);
+ }
+
+ const self_trace = this;
+
+ let loopIdx = 0;
+ for (let poly of loops) {
+ if (isSpiralLike) {
+ self_trace.setResampleN(poly.resampleN);
+ }
+ const points = poly.points;
+ const numPoints = points.length;
+ if (numPoints < 2) continue;
+
+ // 1. Subdivide loop segments:
+ // Subdivides long segments into smaller points spaced by 'step'. This guarantees
+ // we have enough point density to accurately sample the 3D surface heights.
+ let subPoints = [];
+ const limit = poly.open ? numPoints - 1 : numPoints;
+ for (let i = 0; i < limit; i++) {
+ const p1 = points[i];
+ const p2 = points[(i + 1) % numPoints];
+ const len = p1.distTo2D(p2);
+
+ if (len > step) {
+ const divisions = Math.ceil(len / step);
+ for (let j = 0; j < divisions; j++) {
+ const pct = j / divisions;
+ const x = p1.x + (p2.x - p1.x) * pct;
+ const y = p1.y + (p2.y - p1.y) * pct;
+ subPoints.push({ x, y });
+ }
+ } else {
+ subPoints.push({ x: p1.x, y: p1.y });
+ }
+ }
+ if (poly.open && numPoints > 0) {
+ let lastP = points[numPoints - 1];
+ subPoints.push({ x: lastP.x, y: lastP.y });
+ }
+
+ // 2. Evaluate clipping and probe Z height for each point:
+ // Checks if each point is inside the stock and shadow bounds, then probes the topography.
+ let evaluated = [];
+ let hasOut = false;
+
+ for (let pt of subPoints) {
+ checkr.x = pt.x;
+ checkr.y = pt.y;
+
+ const inStock = !clipStock || inClip(clipStock, undefined, checkr);
+ const inShadow = !clipTo || inClip(clipTo, undefined, checkr);
+ const inClipPos = inStock && inShadow;
+
+ if (!inClipPos) {
+ hasOut = true;
+ evaluated.push({ x: pt.x, y: pt.y, z: 0, inClip: false });
+ } else {
+ let tv = toolAtXY(pt.x, pt.y);
+ if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) {
+ tv = this.tabZ;
+ }
+ evaluated.push({ x: pt.x, y: pt.y, z: tv, inClip: true });
+ }
+ }
+
+ // 3. Emit points using state machine:
+ if (hasOut || poly.open) {
+ // PARTIAL CLIPPING: If the loop intersects the boundaries (i.e. goes out of stock),
+ // we must split it into open segments. We find the first out-of-clip point and rotate the array
+ // so it starts outside. For open paths, we do not rotate.
+ let rotated = evaluated;
+ if (hasOut && !poly.open) {
+ let firstOutIdx = evaluated.findIndex(p => !p.inClip);
+ rotated = [...evaluated.slice(firstOutIdx), ...evaluated.slice(0, firstOutIdx)];
+ }
+
+ let tracing = false;
+ for (let pt of rotated) {
+ if (pt.inClip) {
+ if (!tracing) {
+ newtrace();
+ tracing = true;
+ self_trace.setLoopIndex(loopIdx);
+ }
+ push_point(pt.x, pt.y, pt.z + leave);
+ } else {
+ if (tracing) {
+ end_poly();
+ tracing = false;
+ }
+ }
+ }
+ if (tracing) {
+ end_poly();
+ }
+ } else {
+ // NO CLIPPING: If the loop is fully within stock and boundaries, emit as a single closed loop.
+ newtrace();
+ self_trace.setClosed();
+ self_trace.setLoopIndex(loopIdx);
+
+ // Seed the starting lastPP with the final point of the loop.
+ // This maintains circular continuity, so the first point is checked for flatness
+ // against the last point of the loop, preventing CW vs. CCW starting point asymmetry.
+ const lastPt = evaluated[evaluated.length - 1];
+ if (lastPt) {
+ self_trace.setLastPoint(newPoint(lastPt.x, lastPt.y, lastPt.z + leave));
+ }
+ for (let pt of evaluated) {
+ push_point(pt.x, pt.y, pt.z + leave);
+ }
+ end_poly();
+ }
+ loopIdx++;
+ }
+ }
+ }
+
+ then(this.slice);
+ }
}
export function raster_slice(inputs) {
@@ -1033,6 +2055,31 @@ export function raster_slice(inputs) {
return points;
};
+function omitMatching(target, matches) {
+ target = target.clone(true);
+ for (let poly of target.filter(p => p.inner)) {
+ poly.inner = poly.inner.filter(inner => {
+ let innerCenter = inner.bounds.center();
+ for (let ho of matches) {
+ if (inner.isEquivalent(ho, false, 0.2)) {
+ return false;
+ }
+ // Fallback check: if the center of the sliced hole is inside the matching hole,
+ // and their areas are within a 20% tolerance threshold.
+ let hoArea = Math.abs(ho.area());
+ let innerArea = Math.abs(inner.area());
+ if (hoArea > 0.001 && Math.abs(hoArea - innerArea) / hoArea < 0.2) {
+ if (innerCenter.isInPolygon(ho)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ });
+ }
+ return target;
+}
+
export async function generate(opt) {
return new Topo().generate(opt);
}
diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js
index 22cf938bd..7812a922a 100644
--- a/src/kiri/run/minion.js
+++ b/src/kiri/run/minion.js
@@ -261,6 +261,8 @@ const funcs = self.minion = {
trace_init(data) {
data.cross.clipTo = codec.decode(data.cross.clipTo);
data.cross.clipTab = codec.decode(data.cross.clipTab);
+ data.cross.clipStock = codec.decode(data.cross.clipStock);
+ data.trace = codec.decode(data.trace);
const probe = new Probe(data.probe);
const trace = new Trace(probe, data.trace);
cache.trace = {
@@ -288,6 +290,14 @@ const funcs = self.minion = {
});
},
+ trace_radial(data, seq) {
+ const { trace } = cache.trace;
+ trace.crossRadial_sync(data.params, slice => {
+ slice = codec.encode(slice);
+ reply({ seq, slice });
+ });
+ },
+
trace_cleanup() {
delete cache.trace;
},
diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js
index a6bbf3fc0..b987098e6 100644
--- a/web/kiri/lang/en.js
+++ b/web/kiri/lang/en.js
@@ -592,6 +592,8 @@ self.lang['en-us'] = {
cf_botm_l: ["obey z bottom limit"],
cf_curv_s: "curves only",
cf_curv_l: ["limit linear cleanup","to curved surfaces"],
+ cf_cdst_s: "curve join dist",
+ cf_cdst_l: ["don't elide flat regions between curves","if they are shorter than this multiple of tool diameter"],
cf_olin_s: "inside only",
cf_olin_l: ["limit cutting to","inside part boundaries"],
cf_linx_s: "enable y pass",
@@ -600,6 +602,8 @@ self.lang['en-us'] = {
cf_liny_l: "linear x-axis finishing",
cf_clip_s: "clip to stock",
cf_clip_l: ["contour op only","clip cutting paths","to defined stock"],
+ cf_shpe_s: "shape",
+ cf_shpe_l: "contour shape mode",
// CNC TRACE
cu_menu: "trace",