From bea77ec0ebc9a404b810ce874fa14b0b57789592 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 07:35:44 -0400 Subject: [PATCH 01/24] CAM: Implement radial/spiral contour operation --- src/kiri/app/consts.js | 3 +- src/kiri/mode/cam/work/op-contour.js | 11 ++- src/kiri/mode/cam/work/topo3.js | 103 +++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index ac59923b2..00c4e1a9c 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -94,7 +94,8 @@ const LISTS = { ], xyaxis: [ { name: "X" }, - { name: "Y" } + { name: "Y" }, + { name: "Radial" } ], regaxis: [ { name: "X" }, diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index 7bcf7c8fe..1cc0ebb08 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 => { diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index bae903127..ce0d555c9 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), @@ -110,7 +111,7 @@ export class Topo { newslices.push(debug); } - if (webGPU && !contour.nogpu) { + if (webGPU && !contour.nogpu && !contourR) { // invert tool Z offset for gpu code let toolBounds = new THREE.Box3() .expandByPoint({ x: -toolDiameter/2, y: -toolDiameter/2, z: 0 }) @@ -356,7 +357,8 @@ export class Topo { maxangle, flatness, bridge, - contourX + contourX, + contourR }); if (topo.raster) { @@ -397,6 +399,7 @@ export class Topo { toolStep, contourX, contourY, + contourR, density, clipTo, clipTab, @@ -529,7 +532,7 @@ 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 { gridDelta, resolution, density, partOff, toolStep, contourX, contourY, contourR } = params; const { clipTo, clipTab, clipTabZ, tabHeight, newslices, leave } = params; let stepsTaken = 0, @@ -543,6 +546,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; } @@ -559,7 +573,7 @@ export class Topo { clipTabZ, tabHeight, resolution, - concurrent, + concurrent: contourR ? false : concurrent, density }); @@ -567,13 +581,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 +649,25 @@ export class Topo { } } + if (contourR) { + onupdate(0, stepsTotal, "contour radial"); + inc(); + trace.crossRadial({ + centerX, + centerY, + maxR, + toolStep + }, segments => { + if (segments.length > 0) { + let slice = newSlice(0); + slice.camLines = segments; + slicesR.push(slice); + } + onupdate(stepsTotal, stepsTotal, "contour radial"); + dec(); + }); + } + if (!concurrent) resolver(); await promise; @@ -704,7 +740,7 @@ export class Trace { constructor(probe, params) { - const { curvesOnly, maxangle, flatness, bridge, contourX, leave } = params; + const { curvesOnly, maxangle, flatness, bridge, contourX, contourR, leave } = params; this.params = params; this.probe = probe; @@ -757,6 +793,12 @@ export class Trace { const newP = newPoint(x, y, z); const lastP = lastPP; + if (contourR) { + trace.push(newP); + lastPP = newP; + return; + } + if (lastP) { const dl = (x - lastP.x) || (y - lastP.y); const dz = z - lastP.z; @@ -940,6 +982,57 @@ export class Trace { end_poly(); then(this.slice); } + + crossRadial(params, then) { + this.crossRadial_sync(params, then); + } + + crossRadial_sync(params, then) { + const { push_point, end_poly, newtrace, newslice, inClip } = this.object; + const { clipTab, tabHeight, clipTo, box, resolution, density, leave } = this.cross; + const { toolAtXY } = this.probe; + + let { centerX, centerY, maxR, toolStep } = params; + + const step = resolution * density; + const b = toolStep / (2 * Math.PI); + const checkr = newPoint(0, 0); + + newslice(); + newtrace(); + + let theta = 0; + let r = 0; + + while (r <= maxR) { + const x = centerX + r * Math.cos(theta); + const y = centerY + r * Math.sin(theta); + + if (x >= box.min.x && x <= box.max.x && y >= box.min.y && y <= box.max.y) { + let tv = toolAtXY(x, y); + checkr.x = x; + checkr.y = y; + + if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) { + tv = this.tabZ; + } + + if (clipTo && !inClip(clipTo, undefined, checkr)) { + end_poly(); + } else { + push_point(x, y, tv + leave); + } + } else { + end_poly(); + } + + const dtheta = step / Math.sqrt(b * b + r * r); + theta += dtheta; + r = b * theta; + } + end_poly(); + then(this.slice); + } } export function raster_slice(inputs) { From 697b249522a557d4b774e2397a8a9f5efeaaced1 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 07:40:33 -0400 Subject: [PATCH 02/24] CAM: Fix perimeter clipping bug in radial contouring --- src/kiri/mode/cam/work/topo3.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index ce0d555c9..953de9d30 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -1007,23 +1007,19 @@ export class Trace { while (r <= maxR) { const x = centerX + r * Math.cos(theta); const y = centerY + r * Math.sin(theta); + checkr.x = x; + checkr.y = y; - if (x >= box.min.x && x <= box.max.x && y >= box.min.y && y <= box.max.y) { + if (clipTo && !inClip(clipTo, undefined, checkr)) { + end_poly(); + } else { let tv = toolAtXY(x, y); - checkr.x = x; - checkr.y = y; if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) { tv = this.tabZ; } - if (clipTo && !inClip(clipTo, undefined, checkr)) { - end_poly(); - } else { - push_point(x, y, tv + leave); - } - } else { - end_poly(); + push_point(x, y, tv + leave); } const dtheta = step / Math.sqrt(b * b + r * r); From a2098049ffa907d0458aeb50b1938a93936533c0 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 07:48:21 -0400 Subject: [PATCH 03/24] CAM: Add stock clipping intersection and perimeter expansion to radial contouring --- src/kiri/mode/cam/work/topo3.js | 52 ++++++++++++++++++++++++--------- src/kiri/run/minion.js | 1 + 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 953de9d30..4bb6f0fa1 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -75,15 +75,21 @@ 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), + clipTo = inside ? shadow.base : POLY.expand(shadow.base, 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) { @@ -108,6 +114,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); } @@ -402,6 +409,7 @@ export class Topo { contourR, density, clipTo, + clipStock, clipTab, clipTabZ: clipTab ? clipTab.map(t => t.z) : undefined, tabHeight, @@ -533,7 +541,7 @@ export class Topo { const { minX, maxX, minY, maxY, boundsX, boundsY, stepsX, stepsY } = params; const { gridDelta, resolution, density, partOff, toolStep, contourX, contourY, contourR } = params; - const { clipTo, clipTab, clipTabZ, tabHeight, newslices, leave } = params; + const { clipTo, clipStock, clipTab, clipTabZ, tabHeight, newslices, leave } = params; let stepsTaken = 0, stepsTotal = 0; @@ -569,6 +577,7 @@ export class Topo { box, leave, clipTo, + clipStock, clipTab, clipTabZ, tabHeight, @@ -793,6 +802,9 @@ export class Trace { const newP = newPoint(x, y, z); const lastP = lastPP; + // Bypass linear slope-based point simplification in radial mode. + // On flat horizontal faces (dz = 0), a constant slope calculation of 0 + // would otherwise collapse the spiral path into a single straight line. if (contourR) { trace.push(newP); lastPP = newP; @@ -908,7 +920,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; @@ -931,9 +943,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; @@ -947,7 +960,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; @@ -969,9 +982,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; @@ -989,12 +1003,14 @@ export class Trace { crossRadial_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 { toolAtXY } = this.probe; let { centerX, centerY, maxR, toolStep } = params; + // Step resolution along the spiral curve const step = resolution * density; + // Growth coefficient for Archimedean spiral (expansion of toolStep per 2pi radians) const b = toolStep / (2 * Math.PI); const checkr = newPoint(0, 0); @@ -1005,16 +1021,24 @@ export class Trace { let r = 0; while (r <= maxR) { + // Convert polar coordinates to Cartesian workspace coordinates const x = centerX + r * Math.cos(theta); const y = centerY + r * Math.sin(theta); checkr.x = x; checkr.y = y; - if (clipTo && !inClip(clipTo, undefined, checkr)) { + // Restrict toolpath within stock AND expanded part silhouette boundaries (intersection check) + const inStock = !clipStock || inClip(clipStock, undefined, checkr); + const inShadow = !clipTo || inClip(clipTo, undefined, checkr); + + if (!inStock || !inShadow) { + // Terminate path if we exit the allowed stock or shadow regions end_poly(); } else { + // Probe topography height map at (x, y) coordinates let tv = toolAtXY(x, y); + // Override height if tool is within tab boundary to prevent milling tabs if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) { tv = this.tabZ; } @@ -1022,6 +1046,8 @@ export class Trace { push_point(x, y, tv + leave); } + // Stepping formula: dtheta = ds / sqrt(b^2 + r^2) to maintain a constant + // linear feed resolution (step size) as the spiral radius expands. const dtheta = step / Math.sqrt(b * b + r * r); theta += dtheta; r = b * theta; diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js index 22cf938bd..0514804f1 100644 --- a/src/kiri/run/minion.js +++ b/src/kiri/run/minion.js @@ -261,6 +261,7 @@ 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); const probe = new Probe(data.probe); const trace = new Trace(probe, data.trace); cache.trace = { From 92d4bf3516d2dec6fc3aabb2d438d9cf6e7fd3d3 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 08:05:30 -0400 Subject: [PATCH 04/24] Implement Perimeter concentric loops option for CAM Radial contouring mode --- src/kiri/app/conf/defaults.js | 1 + src/kiri/app/consts.js | 4 + src/kiri/mode/cam/app/cl-ops.js | 6 +- src/kiri/mode/cam/work/prepare.js | 2 +- src/kiri/mode/cam/work/topo3.js | 188 ++++++++++++++++++++++++------ web/kiri/lang/en.js | 2 + 6 files changed, 163 insertions(+), 40 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 1efecc0b1..bc30da27a 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -458,6 +458,7 @@ export const conf = { camContourLeave: 0, camContourOver: 0.5, camContourReduce: 2, + camContourShape: "Spiral", camContourSpeed: 1000, camContourSpindle: 1000, camContourTool: 1000, diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index 00c4e1a9c..ff13b1652 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -97,6 +97,10 @@ const LISTS = { { name: "Y" }, { name: "Radial" } ], + crshape: [ + { name: "Spiral" }, + { name: "Perimeter" } + ], regaxis: [ { name: "X" }, { name: "Y" }, diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 4dd6ca0f5..03391a384 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -414,10 +414,12 @@ export function createPopOps() { inside: 'camContourIn', clipto: 'camStockClipTo', 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 }), diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 288d3b9a6..a749cc57e 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -816,7 +816,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 4bb6f0fa1..978601d84 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -414,7 +414,8 @@ export class Topo { 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); }); @@ -541,7 +542,7 @@ export class Topo { const { minX, maxX, minY, maxY, boundsX, boundsY, stepsX, stepsY } = params; const { gridDelta, resolution, density, partOff, toolStep, contourX, contourY, contourR } = params; - const { clipTo, clipStock, clipTab, clipTabZ, tabHeight, newslices, leave } = params; + const { clipTo, clipStock, clipTab, clipTabZ, tabHeight, newslices, leave, shape } = params; let stepsTaken = 0, stepsTotal = 0; @@ -665,7 +666,8 @@ export class Topo { centerX, centerY, maxR, - toolStep + toolStep, + shape: (shape || 'Spiral').toLowerCase() }, segments => { if (segments.length > 0) { let slice = newSlice(0); @@ -765,7 +767,7 @@ export class Trace { } const newtrace = this.newtrace = function () { - trace = newPolygon().setOpen(); + trace = object.trace = newPolygon().setOpen(); } const end_poly = this.end_poly = function (point) { @@ -1006,53 +1008,165 @@ export class Trace { const { clipTab, tabHeight, clipTo, clipStock, box, resolution, density, leave } = this.cross; const { toolAtXY } = this.probe; - let { centerX, centerY, maxR, toolStep } = params; + let { centerX, centerY, maxR, toolStep, shape } = params; - // Step resolution along the spiral curve + // Step resolution along the curve/polygon const step = resolution * density; - // Growth coefficient for Archimedean spiral (expansion of toolStep per 2pi radians) - const b = toolStep / (2 * Math.PI); const checkr = newPoint(0, 0); newslice(); - newtrace(); - let theta = 0; - let r = 0; + if (shape === 'perimeter') { + if (clipTo && clipTo.length) { + let outs = []; + // Generate concentric loop offsets using POLY.offset. + // -toolStep is used to offset inwards. + // z: 0 is used because we offset on the 2D plane and then probe 3D topography. + POLY.offset(clipTo, -toolStep, { count: 999, outs: outs, flat: true, z: 0, minArea: 0.01 }); + + // We want to cut from the inside out. + // outs is generated from outside-in: [first offset, second offset, ..., innermost] + // Reversing outs gives: [innermost, ..., second offset, first offset] + let loops = []; + for (let i = outs.length - 1; i >= 0; i--) { + loops.push(outs[i]); + } + // Finally, append the original boundary (clipTo) at the end so we do a final pass around the perimeter + for (let poly of clipTo) { + loops.push(poly); + } - while (r <= maxR) { - // Convert polar coordinates to Cartesian workspace coordinates - const x = centerX + r * Math.cos(theta); - const y = centerY + r * Math.sin(theta); - checkr.x = x; - checkr.y = y; + const self_trace = this; + + for (let poly of loops) { + const points = poly.points; + const numPoints = points.length; + if (numPoints < 2) continue; + + // 1. Generate subdivided points along the loop to preserve original vertices + 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 }); + } + } - // Restrict toolpath within stock AND expanded part silhouette boundaries (intersection check) - const inStock = !clipStock || inClip(clipStock, undefined, checkr); - const inShadow = !clipTo || inClip(clipTo, undefined, checkr); + // 2. Evaluate clipping and probe Z height for each point + let evaluated = []; + let hasOut = false; - if (!inStock || !inShadow) { - // Terminate path if we exit the allowed stock or shadow regions - end_poly(); - } else { - // Probe topography height map at (x, y) coordinates - let tv = toolAtXY(x, y); + for (let pt of subPoints) { + checkr.x = pt.x; + checkr.y = pt.y; - // Override height if tool is within tab boundary to prevent milling tabs - if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) { - tv = this.tabZ; - } + const inStock = !clipStock || inClip(clipStock, undefined, checkr); + const inShadow = !clipTo || inClip(clipTo, undefined, checkr); + const inClipPos = inStock && inShadow; - push_point(x, y, tv + leave); + 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) { + // Find first out-of-clip point to start rotation + let firstOutIdx = evaluated.findIndex(p => !p.inClip); + // Rotate array so it starts with an out-of-clip point + 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; + } + push_point(pt.x, pt.y, pt.z + leave); + } else { + if (tracing) { + end_poly(); + tracing = false; + } + } + } + if (tracing) { + end_poly(); + } + } else { + // No clipping at all: emit as a single closed polygon + newtrace(); + self_trace.trace.open = false; + for (let pt of evaluated) { + push_point(pt.x, pt.y, pt.z + leave); + } + end_poly(); + } + } } + } else { + // Default Spiral Mode (Archimedean spiral from center outwards) + newtrace(); + + // Growth coefficient for Archimedean spiral (expansion of toolStep per 2pi radians) + const b = toolStep / (2 * Math.PI); + let theta = 0; + let r = 0; + + while (r <= maxR) { + // Convert polar coordinates to Cartesian workspace coordinates + const x = centerX + r * Math.cos(theta); + const y = centerY + r * Math.sin(theta); + checkr.x = x; + checkr.y = y; + + // Restrict toolpath within stock AND expanded part silhouette boundaries (intersection check) + const inStock = !clipStock || inClip(clipStock, undefined, checkr); + const inShadow = !clipTo || inClip(clipTo, undefined, checkr); + + if (!inStock || !inShadow) { + // Terminate path if we exit the allowed stock or shadow regions + end_poly(); + } else { + // Probe topography height map at (x, y) coordinates + let tv = toolAtXY(x, y); - // Stepping formula: dtheta = ds / sqrt(b^2 + r^2) to maintain a constant - // linear feed resolution (step size) as the spiral radius expands. - const dtheta = step / Math.sqrt(b * b + r * r); - theta += dtheta; - r = b * theta; + // Override height if tool is within tab boundary to prevent milling tabs + if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) { + tv = this.tabZ; + } + + push_point(x, y, tv + leave); + } + + // Stepping formula: dtheta = ds / sqrt(b^2 + r^2) to maintain a constant + // linear feed resolution (step size) as the spiral radius expands. + const dtheta = step / Math.sqrt(b * b + r * r); + theta += dtheta; + r = b * theta; + } + end_poly(); } - end_poly(); + then(this.slice); } } diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index a6bbf3fc0..7067c1997 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -600,6 +600,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", From 7e78416ac0bfb05a65cac441bcef5cda9aa36f3c Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 13:35:19 -0400 Subject: [PATCH 05/24] feat(cam): add 'ignore through' option and point pruning to contour operation --- src/kiri/app/conf/defaults.js | 1 + src/kiri/mode/cam/app/cl-ops.js | 2 + src/kiri/mode/cam/work/op-contour.js | 178 ++++++++++++++++++++++++--- src/kiri/mode/cam/work/topo3.js | 58 +++++++-- 4 files changed, 214 insertions(+), 25 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index bc30da27a..7e846a73c 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -456,6 +456,7 @@ export const conf = { camContourCurves: false, camContourIn: false, camContourLeave: 0, + camContourOmitThru: false, camContourOver: 0.5, camContourReduce: 2, camContourShape: "Spiral", diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 03391a384..4e867b98f 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -413,6 +413,7 @@ export function createPopOps() { curves: 'camContourCurves', inside: 'camContourIn', clipto: 'camStockClipTo', + omitthru: 'camContourOmitThru', filter: 'camContourFilter', axis: 'X', shape: 'camContourShape' @@ -437,6 +438,7 @@ export function createPopOps() { 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" }) }; diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index 1cc0ebb08..f317fe129 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -109,7 +109,10 @@ class OpContour extends CamOp { onupdate: (index, total, msg) => { progress(index / total, msg); }, - ondone: (slices) => { + ondone: (slices, topo) => { + if (op.omitthru && state.shadow && state.shadow.holes && state.shadow.holes.length) { + slices = cleanupContourSlices(slices, state.shadow.holes, topo, op); + } slices = filter(slices); this.sliceOut = slices; addSlices(slices); @@ -152,28 +155,169 @@ class OpContour extends CamOp { let printPoint = newPoint(bounds.min.x, bounds.min.y, zmax); - for (let slice of sliceOut) { - // ignore debug slices - if (!slice.camLines) { - continue; + const isRadial = op.axis.toLowerCase() === 'radial'; + + if (isRadial) { + for (let slice of sliceOut) { + // ignore debug slices + if (!slice.camLines) { + continue; + } + 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 }); + }); + printPoint = tip2tipEmit(polys, printPoint, (el, point) => { + let poly = el.poly; + if (poly.isClosed()) { + poly.setCounterClockwise(); + polyEmit(poly, -999); + } else { + if (poly.last() === point) { + poly.reverse(); + } + polyEmit(poly); + } + newLayer(); + }); } - let polys = [], poly; - slice.camLines.forEach((poly) => { - poly = poly.clone(true).annotate({ slice: slice.index + 1 }); - polys.push({ first: poly.first(), last: poly.last(), poly: poly }); + } else { + for (let slice of sliceOut) { + // ignore debug slices + if (!slice.camLines) { + continue; + } + let polys = [], poly; + 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); + } + + tip2tipEmit(depthData, printPoint, (el, point) => { + let poly = el.poly; + if (poly.isClosed()) { + poly.setCounterClockwise(); + polyEmit(poly, -999); + } else { + if (poly.last() === point) { + poly.reverse(); + } + polyEmit(poly); + } + newLayer(); }); - depthData.appendAll(polys); } + } +} + +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); +} + +function closestPointOnPolygon(pt, poly) { + let points = poly.points; + let len = points.length; + let minD = Infinity; + let closest = null; + for (let i = 0; i < len; i++) { + let p1 = points[i]; + let p2 = points[(i + 1) % len]; + let cp = closestPointOnSegment(pt, p1, p2); + let d = pt.distTo2D(cp); + if (d < minD) { + minD = d; + closest = cp; + } + } + return closest; +} + +function cleanupContourSlices(slices, holes, topo, op) { + let holeBoxes = []; + for (let hole of holes) { + let min_x = Infinity, max_x = -Infinity, min_y = Infinity, max_y = -Infinity; + for (let p of hole.points) { + if (p.x < min_x) min_x = p.x; + if (p.x > max_x) max_x = p.x; + if (p.y < min_y) min_y = p.y; + if (p.y > max_y) max_y = p.y; + } + holeBoxes.push({ min_x, max_x, min_y, max_y, hole }); + } + + for (let slice of slices) { + if (!slice.camLines) continue; + let newPolys = []; + for (let poly of slice.camLines) { + let newPoints = []; + let inHolePolygon = null; + let inHoleStart = null; + let inHoleLast = null; + + const emitHole = () => { + if (inHolePolygon) { + if (inHoleStart) { + newPoints.push(inHoleStart); + } + if (inHoleLast && inHoleLast !== inHoleStart) { + newPoints.push(inHoleLast); + } + inHolePolygon = null; + inHoleStart = null; + inHoleLast = null; + } + }; - tip2tipEmit(depthData, printPoint, (el, point) => { - let poly = el.poly; - if (poly.last() === point) { - poly.reverse(); + for (let pt of poly.points) { + let currentHole = null; + 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)) { + currentHole = hb.hole; + break; + } + } + } + + if (currentHole) { + let edgePt = closestPointOnPolygon(pt, currentHole); + let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); + let projectedPt = newPoint(edgePt.x, edgePt.y, z); + + if (inHolePolygon === currentHole) { + inHoleLast = projectedPt; + } else { + emitHole(); + inHolePolygon = currentHole; + inHoleStart = projectedPt; + inHoleLast = projectedPt; + } + } else { + emitHole(); + newPoints.push(pt); + } } - polyEmit(poly); - newLayer(); - }); + emitHole(); + + if (newPoints.length > 1) { + poly.points = newPoints; + newPolys.push(poly); + } + } + slice.camLines = newPolys; } + return slices; } export { OpContour }; \ No newline at end of file diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 978601d84..0e2b7032c 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -75,7 +75,8 @@ 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 + (contourR ? toolStep : 0) + 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; @@ -337,7 +338,7 @@ export class Topo { onupdate(i, numScanlines); } } - ondone(slices); + ondone(slices, this); return this; } @@ -420,7 +421,7 @@ export class Topo { onupdate(l / 2 + i / 2, l, p); }); - ondone(newslices); + ondone(newslices, this); return this; } @@ -670,9 +671,30 @@ export class Topo { shape: (shape || 'Spiral').toLowerCase() }, segments => { if (segments.length > 0) { - let slice = newSlice(0); - slice.camLines = segments; - slicesR.push(slice); + if ((shape || 'Spiral').toLowerCase() === 'perimeter') { + // 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 { + // Spiral mode: single slice + let slice = newSlice(0); + slice.camLines = segments; + slicesR.push(slice); + } } onupdate(stepsTotal, stepsTotal, "contour radial"); dec(); @@ -1029,15 +1051,17 @@ export class Trace { // Reversing outs gives: [innermost, ..., second offset, first offset] let loops = []; for (let i = outs.length - 1; i >= 0; i--) { - loops.push(outs[i]); + loops.push(outs[i].clone(true)); } // Finally, append the original boundary (clipTo) at the end so we do a final pass around the perimeter for (let poly of clipTo) { - loops.push(poly); + loops.push(poly.clone(true)); } + loops = POLY.flatten(loops, [], true); const self_trace = this; + let loopIdx = 0; for (let poly of loops) { const points = poly.points; const numPoints = points.length; @@ -1100,6 +1124,7 @@ export class Trace { if (!tracing) { newtrace(); tracing = true; + self_trace.trace.loopIndex = loopIdx; } push_point(pt.x, pt.y, pt.z + leave); } else { @@ -1116,11 +1141,13 @@ export class Trace { // No clipping at all: emit as a single closed polygon newtrace(); self_trace.trace.open = false; + self_trace.trace.loopIndex = loopIdx; for (let pt of evaluated) { push_point(pt.x, pt.y, pt.z + leave); } end_poly(); } + loopIdx++; } } } else { @@ -1262,6 +1289,21 @@ 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 => { + for (let ho of matches) { + if (inner.isEquivalent(ho)) { + return false; + } + } + return true; + }); + } + return target; +} + export async function generate(opt) { return new Topo().generate(opt); } From cd0948a89188e4725ea482de92300c3229b6a439 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 13:56:43 -0400 Subject: [PATCH 06/24] refactor(cam): rename perimeter shape mode to concentric for radial contouring --- src/kiri/app/consts.js | 2 +- src/kiri/mode/cam/work/topo3.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index ff13b1652..f7b8dc40e 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -99,7 +99,7 @@ const LISTS = { ], crshape: [ { name: "Spiral" }, - { name: "Perimeter" } + { name: "Concentric" } ], regaxis: [ { name: "X" }, diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 0e2b7032c..93e029b89 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -671,7 +671,7 @@ export class Topo { shape: (shape || 'Spiral').toLowerCase() }, segments => { if (segments.length > 0) { - if ((shape || 'Spiral').toLowerCase() === 'perimeter') { + if ((shape || 'Spiral').toLowerCase() === 'concentric') { // Export each loop as a separate slice let grouped = []; for (let seg of segments) { @@ -1038,7 +1038,7 @@ export class Trace { newslice(); - if (shape === 'perimeter') { + if (shape === 'concentric') { if (clipTo && clipTo.length) { let outs = []; // Generate concentric loop offsets using POLY.offset. From 4a92e379d31d789fc586bba8ef8248fba62ef70a Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 19:13:43 -0400 Subject: [PATCH 07/24] fix bug where open contours were being marked as closed, thus cutting through the part. --- src/kiri/mode/cam/work/prepare.js | 7 +++-- src/kiri/mode/cam/work/topo3.js | 43 ++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index a749cc57e..dbd39949d 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -542,7 +542,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // contouring logic if (isMove && contouring) { - if (coastline && deltaXY < 5 && coastlineMove(point)) { + const isRadial = currentOp && (currentOp.axis || '').toLowerCase() === 'radial'; + if (isRadial) { + upAndOver = true; + } else if (coastline && deltaXY < 5 && coastlineMove(point)) { // console.log('coastline move'); } else if (deltaXY > toolDiamMove) { upAndOver = true; @@ -554,7 +557,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 diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 93e029b89..310c28032 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -366,7 +366,9 @@ export class Topo { flatness, bridge, contourX, - contourR + contourR, + resolution, + leave }); if (topo.raster) { @@ -773,7 +775,7 @@ export class Trace { constructor(probe, params) { - const { curvesOnly, maxangle, flatness, bridge, contourX, contourR, leave } = params; + const { curvesOnly, maxangle, flatness, bridge, contourX, contourR, leave, resolution } = params; this.params = params; this.probe = probe; @@ -801,7 +803,9 @@ export class Trace { if (trace.length > 1) { slice.push(trace); } + const oldIdx = trace.loopIndex; newtrace(); + trace.loopIndex = oldIdx; } lastPP = undefined; latent = undefined; @@ -830,7 +834,40 @@ export class Trace { // On flat horizontal faces (dz = 0), a constant slope calculation of 0 // would otherwise collapse the spiral path into a single straight line. if (contourR) { - trace.push(newP); + if (lastP) { + const dl = (x - lastP.x) || (y - lastP.y); + const dz = z - lastP.z; + let isSurfaceSloped = false; + if (curvesOnly) { + const delta = Math.max(resolution * 3, 0.5); + const z0 = z - leave; + const zX1 = probe.toolAtXY(x + delta, y); + const zX2 = probe.toolAtXY(x - delta, y); + const zY1 = probe.toolAtXY(x, y + delta); + const zY2 = probe.toolAtXY(x, y - delta); + isSurfaceSloped = + Math.abs(zX1 - z0) >= flatness || + Math.abs(zX2 - z0) >= flatness || + Math.abs(zY1 - z0) >= flatness || + Math.abs(zY2 - z0) >= flatness; + } + if (curvesOnly && Math.abs(dz) < flatness && !isSurfaceSloped) { + trace.setOpen(); + end_poly(newP); + } else { + if (curvesOnly) { + 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 { + trace.push(newP); + } lastPP = newP; return; } From 9828bfc8330cd3c6e53c815c827d2f92c92de861 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 19:43:15 -0400 Subject: [PATCH 08/24] fix unnecessary retractions --- src/kiri/app/conf/defaults.js | 1 + src/kiri/mode/cam/app/cl-ops.js | 2 + src/kiri/mode/cam/work/op-contour.js | 5 +- src/kiri/mode/cam/work/prepare.js | 8 +- src/kiri/mode/cam/work/topo3.js | 125 ++++++++++++++++++--------- web/kiri/lang/en.js | 2 + 6 files changed, 95 insertions(+), 48 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 7e846a73c..0b9456ef2 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -454,6 +454,7 @@ export const conf = { camContourBottom: false, camContourBridge: 0, camContourCurves: false, + camContourCurveDist: 3, camContourIn: false, camContourLeave: 0, camContourOmitThru: false, diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 4e867b98f..17a51be9f 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -411,6 +411,7 @@ export function createPopOps() { bridging: 'camContourBridge', bottom: 'camContourBottom', curves: 'camContourCurves', + curvesDist: 'camContourCurveDist', inside: 'camContourIn', clipto: 'camStockClipTo', omitthru: 'camContourOmitThru', @@ -435,6 +436,7 @@ 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() }), diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index f317fe129..51aa4e49d 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -143,7 +143,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(); @@ -151,6 +151,9 @@ 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); diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index dbd39949d..3edda13c3 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(); } @@ -542,10 +543,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // contouring logic if (isMove && contouring) { - const isRadial = currentOp && (currentOp.axis || '').toLowerCase() === 'radial'; - if (isRadial) { - upAndOver = true; - } else if (coastline && deltaXY < 5 && coastlineMove(point)) { + if (coastline && deltaXY < 5 && coastlineMove(point)) { // console.log('coastline move'); } else if (deltaXY > toolDiamMove) { upAndOver = true; diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 310c28032..66ed47723 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -47,6 +47,7 @@ export class Topo { leave = contour.leave || 0, maxangle = contour.angle, curvesOnly = contour.curves, + curvesDist = (contour.curvesDist !== undefined) ? contour.curvesDist : 3.0, bridge = contour.bridging || 0, stepsX = Math.ceil(boundsX / resolution), stepsY = Math.ceil(boundsY / resolution), @@ -362,6 +363,7 @@ export class Topo { const trace = this.trace = new Trace(probe, { curvesOnly, + curvesDist, maxangle, flatness, bridge, @@ -775,7 +777,7 @@ export class Trace { constructor(probe, params) { - const { curvesOnly, maxangle, flatness, bridge, contourX, contourR, leave, resolution } = params; + const { curvesOnly, curvesDist, maxangle, flatness, bridge, contourX, contourR, leave, resolution } = params; this.params = params; this.probe = probe; @@ -784,7 +786,10 @@ export class Trace { slice, latent, lastPP, - lastSlope; + lastSlope, + flatBuffer = [], + flatDist = 0, + splitDone = false; const newslice = this.newslice = () => { this.slice = slice = []; @@ -795,6 +800,16 @@ export class Trace { } 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); } @@ -830,71 +845,97 @@ export class Trace { const newP = newPoint(x, y, z); const lastP = lastPP; - // Bypass linear slope-based point simplification in radial mode. - // On flat horizontal faces (dz = 0), a constant slope calculation of 0 - // would otherwise collapse the spiral path into a single straight line. - if (contourR) { - if (lastP) { - const dl = (x - lastP.x) || (y - lastP.y); - const dz = z - lastP.z; - let isSurfaceSloped = false; - if (curvesOnly) { + if (lastP) { + const dl = (x - lastP.x) || (y - lastP.y); + const dz = z - lastP.z; + + let isFlat = false; + if (curvesOnly) { + if (contourR) { const delta = Math.max(resolution * 3, 0.5); const z0 = z - leave; const zX1 = probe.toolAtXY(x + delta, y); const zX2 = probe.toolAtXY(x - delta, y); const zY1 = probe.toolAtXY(x, y + delta); const zY2 = probe.toolAtXY(x, y - delta); - isSurfaceSloped = + const isSurfaceSloped = Math.abs(zX1 - z0) >= flatness || Math.abs(zX2 - z0) >= flatness || Math.abs(zY1 - z0) >= flatness || Math.abs(zY2 - z0) >= flatness; + isFlat = Math.abs(dz) < flatness && !isSurfaceSloped; + } else { + isFlat = Math.abs(dz) < flatness; } - if (curvesOnly && Math.abs(dz) < flatness && !isSurfaceSloped) { - trace.setOpen(); - end_poly(newP); + } + + if (isFlat) { + if (flatBuffer.length === 0) { + flatBuffer.push(newP); + flatDist = lastP.distTo2D(newP); + splitDone = false; } else { - if (curvesOnly) { - const dv = lastP.distTo2D(newP); - const angle = Math.atan2(Math.abs(dz), dv) * RAD2DEG; - if (angle > maxangle) { - trace.setOpen(); - end_poly(); - } + flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP); + flatBuffer.push(newP); + } + + if (flatDist > curvesDist) { + if (!splitDone) { + trace.setOpen(); + end_poly(); + splitDone = true; } - trace.push(newP); + flatBuffer = [newP]; } - } else { - trace.push(newP); + lastPP = newP; + return; } - lastPP = newP; - return; - } - if (lastP) { - 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; + // 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); } diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index 7067c1997..c9b6f93a1 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 distance (mm)"], cf_olin_s: "inside only", cf_olin_l: ["limit cutting to","inside part boundaries"], cf_linx_s: "enable y pass", From b9ee3dca0dbeaa83c38757503f3569ccfa092948 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 20:27:02 -0400 Subject: [PATCH 09/24] fixed remaining bugs --- src/kiri/app/conf/defaults.js | 2 +- src/kiri/mode/cam/work/topo3.js | 192 +++++++++++++++++++++++++++++--- web/kiri/lang/en.js | 2 +- 3 files changed, 176 insertions(+), 20 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 0b9456ef2..e2830c9e2 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -454,7 +454,7 @@ export const conf = { camContourBottom: false, camContourBridge: 0, camContourCurves: false, - camContourCurveDist: 3, + camContourCurveDist: 0.5, camContourIn: false, camContourLeave: 0, camContourOmitThru: false, diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 66ed47723..4e21e4b7a 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -47,7 +47,8 @@ export class Topo { leave = contour.leave || 0, maxangle = contour.angle, curvesOnly = contour.curves, - curvesDist = (contour.curvesDist !== undefined) ? contour.curvesDist : 3.0, + 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), @@ -370,7 +371,8 @@ export class Topo { contourX, contourR, resolution, - leave + leave, + holes: (contour.omitthru && shadow.holes && shadow.holes.length) ? shadow.holes : null }); if (topo.raster) { @@ -395,6 +397,57 @@ export class Topo { topo.raster = false; } + if (contour.omitthru && shadow.holes && shadow.holes.length) { + console.warn("[CAM DEBUGLOG] capping through holes, count:", shadow.holes.length); + let cappedCount = 0; + const rx = 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 * (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; + 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)) { + const edgePt = closestPointOnPolygon(pt, hole); + 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 * (outsidePt.x - minX)))); + let edge_iy = Math.max(0, Math.min(stepsY - 1, Math.round(rx * (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]; + cappedCount++; + } + } + } + } + } + console.warn("[CAM DEBUGLOG] total capped grid cells:", cappedCount); + } + await this.contour({ box: topo.box, minX, @@ -782,6 +835,31 @@ export class Trace { this.params = params; this.probe = probe; + 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, @@ -795,6 +873,18 @@ export class Trace { this.slice = slice = []; } + const setClosed = this.setClosed = function () { + if (trace) trace.open = false; + }; + + const setLoopIndex = this.setLoopIndex = function (idx) { + if (trace) trace.loopIndex = idx; + }; + + const setLastPoint = this.setLastPoint = function (point) { + lastPP = point; + }; + const newtrace = this.newtrace = function () { trace = object.trace = newPolygon().setOpen(); } @@ -852,36 +942,68 @@ export class Trace { let isFlat = false; if (curvesOnly) { if (contourR) { - const delta = Math.max(resolution * 3, 0.5); - const z0 = z - leave; - const zX1 = probe.toolAtXY(x + delta, y); - const zX2 = probe.toolAtXY(x - delta, y); - const zY1 = probe.toolAtXY(x, y + delta); - const zY2 = probe.toolAtXY(x, y - delta); + const delta = Math.max(resolution * 2, 0.05); + const z0 = probe.zAtXY(x, y); + 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); + const slopeFlatness = Math.max(delta * 0.05, 0.002); const isSurfaceSloped = - Math.abs(zX1 - z0) >= flatness || - Math.abs(zX2 - z0) >= flatness || - Math.abs(zY1 - z0) >= flatness || - Math.abs(zY2 - z0) >= flatness; - isFlat = Math.abs(dz) < flatness && !isSurfaceSloped; + Math.abs(zX1 - z0) >= slopeFlatness || + Math.abs(zX2 - z0) >= slopeFlatness || + Math.abs(zY1 - z0) >= slopeFlatness || + Math.abs(zY2 - z0) >= slopeFlatness; + isFlat = Math.abs(dz) < slopeFlatness && !isSurfaceSloped; } else { isFlat = Math.abs(dz) < flatness; } } if (isFlat) { + let inHole = false; + if (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 (flatBuffer.length === 0) { flatBuffer.push(newP); - flatDist = lastP.distTo2D(newP); + flatDist = inHole ? 0 : lastP.distTo2D(newP); splitDone = false; } else { - flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP); + if (!inHole) { + flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP); + } else { + console.warn("[CAM DEBUGLOG] point in hole, ignoring from flatDist:", newP, "z0:", (z - leave).round(5)); + } flatBuffer.push(newP); } if (flatDist > curvesDist) { if (!splitDone) { trace.setOpen(); + flatBuffer = []; end_poly(); splitDone = true; } @@ -1202,7 +1324,7 @@ export class Trace { if (!tracing) { newtrace(); tracing = true; - self_trace.trace.loopIndex = loopIdx; + self_trace.setLoopIndex(loopIdx); } push_point(pt.x, pt.y, pt.z + leave); } else { @@ -1218,8 +1340,12 @@ export class Trace { } else { // No clipping at all: emit as a single closed polygon newtrace(); - self_trace.trace.open = false; - self_trace.trace.loopIndex = loopIdx; + 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); } @@ -1382,6 +1508,36 @@ function omitMatching(target, matches) { return target; } +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); +} + +function closestPointOnPolygon(pt, poly) { + let points = poly.points; + let len = points.length; + let minD = Infinity; + let closest = null; + for (let i = 0; i < len; i++) { + let p1 = points[i]; + let p2 = points[(i + 1) % len]; + let cp = closestPointOnSegment(pt, p1, p2); + let d = pt.distTo2D(cp); + if (d < minD) { + minD = d; + closest = cp; + } + } + return closest; +} + export async function generate(opt) { return new Topo().generate(opt); } diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index c9b6f93a1..b987098e6 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -593,7 +593,7 @@ self.lang['en-us'] = { 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 distance (mm)"], + 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", From 23f3e7b39cf584a90d3fe9fccaf6e73b0525097e Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Fri, 29 May 2026 20:30:47 -0400 Subject: [PATCH 10/24] added docs --- docs/kiri-moto/CAM/ops.mdx | 17 ++++++++++++++--- src/kiri/mode/cam/work/topo3.js | 11 +++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/kiri-moto/CAM/ops.mdx b/docs/kiri-moto/CAM/ops.mdx index ed7c2cd0f..4f3ea314c 100644 --- a/docs/kiri-moto/CAM/ops.mdx +++ b/docs/kiri-moto/CAM/ops.mdx @@ -74,13 +74,24 @@ The Rough op removes large volumes of material quickly using a stepped-down tool -The Contour op generates detailed toolpaths for complex organic surface geometries. -It can trace along the X or Y axis and has configurable precision. It is useful for: +The Contour op generates detailed finishing toolpaths for complex organic surface geometries. +It can trace along the X or Y axes, or in **Radial** mode, with configurable precision. It is useful for: -- Carving 3D shapes with organic or curved profiles +- Carving 3D shapes with organic, curved, or spherical profiles - Finishing complex wall geometries - Cleaning up a part after [roughing](#rough) +#### Axis Modes +- **X / Y Axis**: Generates linear parallel scanning toolpaths along the selected horizontal axis. +- **Radial**: Generates toolpaths radiating from or circling around the center of the part. This has two shape submodes: + - **Spiral**: Emits a continuous spiral toolpath expanding outwards from the center of the part. + - **Concentric**: Generates concentric circular loops from the innermost boundary to the perimeter. + +#### Key Options +- **Curves Only**: Restricts linear cleanup to sloped/curved surfaces, automatically skipping flat horizontal sections. +- **Curve Join Dist**: A multiplier of the tool diameter (defaults to 0.5) defining the maximum length of flat regions between curved profiles that should be bridged and kept continuous to prevent unnecessary tool retractions. +- **Omit Through**: Bypasses machining slots, holes, and other features that go completely through the part. + ### Register diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 4e21e4b7a..07bc76fc9 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -835,6 +835,9 @@ export class Trace { 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) { @@ -873,6 +876,8 @@ export class Trace { 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; }; @@ -1003,6 +1008,8 @@ export class Trace { 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; @@ -1342,6 +1349,10 @@ export class Trace { 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)); From 1353908a24eec8285714184fddbcc67520a0cd39 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Sat, 30 May 2026 00:17:42 -0400 Subject: [PATCH 11/24] add docs --- docs/kiri-moto/CAM/ops.mdx | 32 +++++----- src/kiri/mode/cam/work/op-contour.js | 25 ++++++++ src/kiri/mode/cam/work/topo3.js | 91 +++++++++++++++++++--------- 3 files changed, 105 insertions(+), 43 deletions(-) diff --git a/docs/kiri-moto/CAM/ops.mdx b/docs/kiri-moto/CAM/ops.mdx index 4f3ea314c..7a754334c 100644 --- a/docs/kiri-moto/CAM/ops.mdx +++ b/docs/kiri-moto/CAM/ops.mdx @@ -11,7 +11,7 @@ Ops—not to be confused with [opps](https://knowyourmeme.com/memes/opp-opps)— applied to a model to remove material. The Operations tab also contains meta-operations that represent parts of the manufacturing process but do not necessarily generate toolpaths. -Each operation has parameters which can be hovered over to reveal their description. +Each operation has parameters which can be hovered over to reveal their description. ![](/img/CAM/paramDetails.png) When an operation's settings are changed, they update the defaults for that operation @@ -74,7 +74,7 @@ The Rough op removes large volumes of material quickly using a stepped-down tool -The Contour op generates detailed finishing toolpaths for complex organic surface geometries. +The Contour op generates detailed finishing toolpaths for complex organic surface geometries. It can trace along the X or Y axes, or in **Radial** mode, with configurable precision. It is useful for: - Carving 3D shapes with organic, curved, or spherical profiles @@ -96,21 +96,21 @@ It can trace along the X or Y axes, or in **Radial** mode, with configurable pre -The Register operation drills holes in the sides of a part, -helping keep the part in the same place when it is flipped onto its opposite face. +The Register operation drills holes in the sides of a part, +helping keep the part in the same place when it is flipped onto its opposite face. The operation has options to drill on different sides and can even generate a puzzle-piece-like pattern for registration. ## Specific Operations -Specific operations require selection of individual part features to apply operations to. +Specific operations require selection of individual part features to apply operations to. These may overlap with Global operations but are generally more configurable. ### Drill -The Drill op creates pecking toolpaths for a drill tool to follow. -It can also drill holes of other diameters and even mark holes instead of drilling. +The Drill op creates pecking toolpaths for a drill tool to follow. +It can also drill holes of other diameters and even mark holes instead of drilling. It is useful for: - Generating a toolpath for a drill tool @@ -121,10 +121,10 @@ It is useful for: -The Trace operation is likely the most configurable, allowing for generation of toolpaths following loops or lines. -It can trace on, inside, or outside a line and can take multiple passes to step down to the desired position. -When the type parameter is set to "clear," it can even act like a [Pocket](#pocket), -clearing the area above the shape created by the selected lines or loop. +The Trace operation is likely the most configurable, allowing for generation of toolpaths following loops or lines. +It can trace on, inside, or outside a line and can take multiple passes to step down to the desired position. +When the type parameter is set to "clear," it can even act like a [Pocket](#pocket), +clearing the area above the shape created by the selected lines or loop. While the Trace op can do a lot, some specific use cases include: - Engraving lettering or other text @@ -136,8 +136,8 @@ While the Trace op can do a lot, some specific use cases include: -The Pocket operation takes a selection of polygon faces and generates a pocket toolpath that cuts down to them. -The operation has options to expand and smooth the pocket selection, and the contour option even allows +The Pocket operation takes a selection of polygon faces and generates a pocket toolpath that cuts down to them. +The operation has options to expand and smooth the pocket selection, and the contour option even allows for an approximation of a v-bit carve. Some use cases specific to the Pocket op include: - Creating one pocket in a part with multiple @@ -148,9 +148,9 @@ for an approximation of a v-bit carve. Some use cases specific to the Pocket op -The Gcode operation does not generate a toolpath but instead adds a line or lines of G-code to the output -of the file. The Gcode operation is different from [Gcode Macros](/kiri-moto/gcode-macros), -as it is not tied to any event +The Gcode operation does not generate a toolpath but instead adds a line or lines of G-code to the output +of the file. The Gcode operation is different from [Gcode Macros](/kiri-moto/gcode-macros), +as it is not tied to any event but is output in the order it is included in the operations array. ## Laser Operations diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index 51aa4e49d..f52d7458d 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -110,6 +110,8 @@ class OpContour extends CamOp { progress(index / total, msg); }, 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); } @@ -161,6 +163,11 @@ class OpContour extends CamOp { const isRadial = op.axis.toLowerCase() === 'radial'; 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. for (let slice of sliceOut) { // ignore debug slices if (!slice.camLines) { @@ -171,12 +178,15 @@ class OpContour extends CamOp { poly = poly.clone(true).annotate({ slice: slice.index + 1 }); polys.push({ first: poly.first(), last: poly.last(), poly: poly }); }); + // Find optimized path routing (tip2tip) on the loops within this slice printPoint = tip2tipEmit(polys, printPoint, (el, point) => { let poly = el.poly; 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(); } @@ -186,6 +196,8 @@ class OpContour extends CamOp { }); } } 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) { // ignore debug slices if (!slice.camLines) { @@ -216,6 +228,7 @@ class OpContour extends CamOp { } } +// Finds the closest point on segment AB to point P. Used to project points onto boundaries. function closestPointOnSegment(p, a, b) { let abx = b.x - a.x; let aby = b.y - a.y; @@ -228,6 +241,7 @@ function closestPointOnSegment(p, a, b) { return newPoint(a.x + t * abx, a.y + t * aby); } +// Finds the closest point on the entire polygon perimeter to pt. function closestPointOnPolygon(pt, poly) { let points = poly.points; let len = points.length; @@ -246,8 +260,15 @@ function closestPointOnPolygon(pt, poly) { return closest; } +// SLICE POST-PROCESSING SNAP-TO-HOLE (OMIT THROUGH option): +// Post-processes contour slice lines to snap segments that cross through-holes onto the hole boundary. +// When 'Omit Through' is active, the toolpath should not run inside the holes. For segments crossing +// the holes, we project points inside the hole onto the nearest point on the hole's perimeter, +// and sample the correct Z height at that edge. This creates clean, continuous contours wrapping +// around the through-holes rather than leaving jagged/broken segments. function cleanupContourSlices(slices, holes, topo, op) { let holeBoxes = []; + // Compute bounding boxes for each through-hole to accelerate polygon containment tests for (let hole of holes) { let min_x = Infinity, max_x = -Infinity, min_y = Infinity, max_y = -Infinity; for (let p of hole.points) { @@ -268,6 +289,7 @@ function cleanupContourSlices(slices, holes, topo, op) { let inHoleStart = null; let inHoleLast = null; + // Helper to emit the snapped boundary segment once we exit the through-hole area const emitHole = () => { if (inHolePolygon) { if (inHoleStart) { @@ -294,7 +316,9 @@ function cleanupContourSlices(slices, holes, topo, op) { } if (currentHole) { + // Point is inside a through-hole: snap it to the closest point on the hole perimeter let edgePt = closestPointOnPolygon(pt, currentHole); + // Sample Z height at the perimeter edge so the tool remains at the correct part height let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); let projectedPt = newPoint(edgePt.x, edgePt.y, z); @@ -307,6 +331,7 @@ function cleanupContourSlices(slices, holes, topo, op) { inHoleLast = projectedPt; } } else { + // Point is on solid part: emit normally emitHole(); newPoints.push(pt); } diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 07bc76fc9..9296627e0 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -397,14 +397,23 @@ 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) { console.warn("[CAM DEBUGLOG] capping through holes, count:", shadow.holes.length); let cappedCount = 0; - const rx = stepsX / boundsX; + 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))); @@ -413,21 +422,28 @@ export class Topo { 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 const edgePt = closestPointOnPolygon(pt, hole); 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; @@ -438,6 +454,7 @@ export class Topo { 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++; } @@ -876,7 +893,7 @@ export class Trace { this.slice = slice = []; } - // Expose helper methods on the Trace class instance to cleanly forward parameters + // 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; @@ -947,8 +964,17 @@ export class Trace { 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) { @@ -966,12 +992,16 @@ export class Trace { 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 || + 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; @@ -1008,7 +1038,7 @@ export class Trace { if (flatDist > curvesDist) { if (!splitDone) { trace.setOpen(); - // Empty flatBuffer before calling end_poly to ensure we discard the flat segment + // 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(); @@ -1246,21 +1276,22 @@ export class Trace { newslice(); if (shape === 'concentric') { + // CONCENTRIC SHAPE GENERATION: + // Generates closed concentric loop paths from the innermost region to the outer perimeter. if (clipTo && clipTo.length) { let outs = []; - // Generate concentric loop offsets using POLY.offset. - // -toolStep is used to offset inwards. - // z: 0 is used because we offset on the 2D plane and then probe 3D topography. + // 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. - // outs is generated from outside-in: [first offset, second offset, ..., innermost] - // Reversing outs gives: [innermost, ..., second offset, first offset] + // 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)); } - // Finally, append the original boundary (clipTo) at the end so we do a final pass around the perimeter + // 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)); } @@ -1274,7 +1305,9 @@ export class Trace { const numPoints = points.length; if (numPoints < 2) continue; - // 1. Generate subdivided points along the loop to preserve original vertices + // 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 = []; for (let i = 0; i < numPoints; i++) { const p1 = points[i]; @@ -1294,7 +1327,8 @@ export class Trace { } } - // 2. Evaluate clipping and probe Z height for each point + // 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; @@ -1318,11 +1352,12 @@ export class Trace { } } - // 3. Emit points using state machine + // 3. Emit points using state machine: if (hasOut) { - // Find first out-of-clip point to start rotation + // PARTIAL CLIPPING: If the concentric 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. This allows us to cleanly start/stop tracing when entering/exiting bounds. let firstOutIdx = evaluated.findIndex(p => !p.inClip); - // Rotate array so it starts with an out-of-clip point let rotated = [...evaluated.slice(firstOutIdx), ...evaluated.slice(0, firstOutIdx)]; let tracing = false; @@ -1345,11 +1380,11 @@ export class Trace { end_poly(); } } else { - // No clipping at all: emit as a single closed polygon + // 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. @@ -1366,7 +1401,8 @@ export class Trace { } } } else { - // Default Spiral Mode (Archimedean spiral from center outwards) + // DEFAULT SPIRAL MODE: + // Generates a continuous Archimedean spiral from the center outwards. newtrace(); // Growth coefficient for Archimedean spiral (expansion of toolStep per 2pi radians) @@ -1375,18 +1411,18 @@ export class Trace { let r = 0; while (r <= maxR) { - // Convert polar coordinates to Cartesian workspace coordinates + // Convert polar coordinates (r, theta) to Cartesian workspace coordinates (x, y) const x = centerX + r * Math.cos(theta); const y = centerY + r * Math.sin(theta); checkr.x = x; checkr.y = y; - // Restrict toolpath within stock AND expanded part silhouette boundaries (intersection check) + // Restrict toolpath within stock AND expanded part boundaries (intersection check) const inStock = !clipStock || inClip(clipStock, undefined, checkr); const inShadow = !clipTo || inClip(clipTo, undefined, checkr); if (!inStock || !inShadow) { - // Terminate path if we exit the allowed stock or shadow regions + // Terminate path segment if we exit allowed regions end_poly(); } else { // Probe topography height map at (x, y) coordinates @@ -1400,8 +1436,9 @@ export class Trace { push_point(x, y, tv + leave); } - // Stepping formula: dtheta = ds / sqrt(b^2 + r^2) to maintain a constant - // linear feed resolution (step size) as the spiral radius expands. + // STEPPING FORMULA: + // We use dtheta = ds / sqrt(b^2 + r^2) to maintain a constant linear feed resolution + // (linear step size 'step') as the spiral radius expands outwards. const dtheta = step / Math.sqrt(b * b + r * r); theta += dtheta; r = b * theta; From 6ff78b36feb3625cbe5851230cfe6869859b4768 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Sat, 30 May 2026 14:09:02 -0400 Subject: [PATCH 12/24] more cleanup --- src/geo/polygon.js | 39 ++++++++++ src/kiri/mode/cam/work/op-contour.js | 106 ++++++++------------------- src/kiri/mode/cam/work/topo3.js | 36 +-------- 3 files changed, 72 insertions(+), 109 deletions(-) diff --git a/src/geo/polygon.js b/src/geo/polygon.js index 634b29846..99e69c22d 100644 --- a/src/geo/polygon.js +++ b/src/geo/polygon.js @@ -1414,6 +1414,33 @@ export class Polygon { }; } + /** + * 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 +1781,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/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index f52d7458d..dad16b82b 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -160,6 +160,33 @@ class OpContour extends CamOp { let printPoint = newPoint(bounds.min.x, bounds.min.y, zmax); + // 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 }); + }); + return polys; + }; + + // Shared callback function to emit toolpaths + const emitSegment = (el, point) => { + let poly = el.poly; + 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); + } + newLayer(); + }; + const isRadial = op.axis.toLowerCase() === 'radial'; if (isRadial) { @@ -169,95 +196,26 @@ class OpContour extends CamOp { // 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. for (let slice of sliceOut) { - // ignore debug slices if (!slice.camLines) { continue; } - 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 }); - }); + let polys = sliceToPolys(slice); // Find optimized path routing (tip2tip) on the loops within this slice - printPoint = tip2tipEmit(polys, printPoint, (el, point) => { - let poly = el.poly; - 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); - } - newLayer(); - }); + 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) { - // ignore debug slices if (!slice.camLines) { continue; } - let polys = [], poly; - 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); + depthData.appendAll(sliceToPolys(slice)); } - tip2tipEmit(depthData, printPoint, (el, point) => { - let poly = el.poly; - if (poly.isClosed()) { - poly.setCounterClockwise(); - polyEmit(poly, -999); - } else { - if (poly.last() === point) { - poly.reverse(); - } - polyEmit(poly); - } - newLayer(); - }); - } - } -} - -// Finds the closest point on segment AB to point P. Used to project points onto boundaries. -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); -} - -// Finds the closest point on the entire polygon perimeter to pt. -function closestPointOnPolygon(pt, poly) { - let points = poly.points; - let len = points.length; - let minD = Infinity; - let closest = null; - for (let i = 0; i < len; i++) { - let p1 = points[i]; - let p2 = points[(i + 1) % len]; - let cp = closestPointOnSegment(pt, p1, p2); - let d = pt.distTo2D(cp); - if (d < minD) { - minD = d; - closest = cp; + tip2tipEmit(depthData, printPoint, emitSegment); } } - return closest; } // SLICE POST-PROCESSING SNAP-TO-HOLE (OMIT THROUGH option): @@ -317,7 +275,7 @@ function cleanupContourSlices(slices, holes, topo, op) { if (currentHole) { // Point is inside a through-hole: snap it to the closest point on the hole perimeter - let edgePt = closestPointOnPolygon(pt, currentHole); + let edgePt = currentHole.findClosestPointOnPerimeter(pt); // Sample Z height at the perimeter edge so the tool remains at the correct part height let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); let projectedPt = newPoint(edgePt.x, edgePt.y, z); diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 9296627e0..31d58e9cd 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -404,7 +404,6 @@ export class Topo { // 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) { - console.warn("[CAM DEBUGLOG] capping through holes, count:", shadow.holes.length); let cappedCount = 0; const rx = stepsX / boundsX; // Coordinate scaling factor for (let hole of shadow.holes) { @@ -429,7 +428,7 @@ export class Topo { const pt = newPoint(px, py); if (pt.isInPolygon(expHole)) { // Find the closest boundary point on the original unexpanded hole perimeter - const edgePt = closestPointOnPolygon(pt, hole); + const edgePt = hole.findClosestPointOnPerimeter(pt); let outsidePt = edgePt; const d = pt.distTo2D(edgePt); if (d > 0.00001) { @@ -462,7 +461,6 @@ export class Topo { } } } - console.warn("[CAM DEBUGLOG] total capped grid cells:", cappedCount); } await this.contour({ @@ -1029,8 +1027,6 @@ export class Trace { } else { if (!inHole) { flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP); - } else { - console.warn("[CAM DEBUGLOG] point in hole, ignoring from flatDist:", newP, "z0:", (z - leave).round(5)); } flatBuffer.push(newP); } @@ -1556,36 +1552,6 @@ function omitMatching(target, matches) { return target; } -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); -} - -function closestPointOnPolygon(pt, poly) { - let points = poly.points; - let len = points.length; - let minD = Infinity; - let closest = null; - for (let i = 0; i < len; i++) { - let p1 = points[i]; - let p2 = points[(i + 1) % len]; - let cp = closestPointOnSegment(pt, p1, p2); - let d = pt.distTo2D(cp); - if (d < minD) { - minD = d; - closest = cp; - } - } - return closest; -} - export async function generate(opt) { return new Topo().generate(opt); } From 82cf7b41cc9dfbe641753d5673269a7f0e224ee3 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Sat, 30 May 2026 14:27:20 -0400 Subject: [PATCH 13/24] fix hole-skipping bug --- src/geo/polygon.js | 82 ++++++++++++++++++++++++++++ src/kiri/mode/cam/work/op-contour.js | 11 +++- src/kiri/mode/cam/work/topo3.js | 57 +++++++++++++------ src/kiri/run/minion.js | 1 + 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/src/geo/polygon.js b/src/geo/polygon.js index 99e69c22d..6196a2ab3 100644 --- a/src/geo/polygon.js +++ b/src/geo/polygon.js @@ -1414,6 +1414,88 @@ 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; + } + /** * find the closest point on the polygon perimeter/boundary to target * diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index dad16b82b..d7ee70f41 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -275,7 +275,16 @@ function cleanupContourSlices(slices, holes, topo, op) { if (currentHole) { // Point is inside a through-hole: snap it to the closest point on the hole perimeter - let edgePt = currentHole.findClosestPointOnPerimeter(pt); + let edgePt = null; + let axis = op.axis ? op.axis.toLowerCase() : ''; + if (axis === 'x') { + edgePt = currentHole.snapToIntersectionX(pt); + } else if (axis === 'y') { + edgePt = currentHole.snapToIntersectionY(pt); + } + if (!edgePt) { + edgePt = currentHole.findClosestPointOnPerimeter(pt); + } // Sample Z height at the perimeter edge so the tool remains at the correct part height let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); let projectedPt = newPoint(edgePt.x, edgePt.y, z); diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 31d58e9cd..0123f32a2 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -428,7 +428,15 @@ export class Topo { const pt = newPoint(px, py); if (pt.isInPolygon(expHole)) { // Find the closest boundary point on the original unexpanded hole perimeter - const edgePt = hole.findClosestPointOnPerimeter(pt); + 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) { @@ -956,6 +964,34 @@ 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; @@ -1007,27 +1043,12 @@ export class Trace { } if (isFlat) { - let inHole = false; - if (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 (flatBuffer.length === 0) { flatBuffer.push(newP); - flatDist = inHole ? 0 : lastP.distTo2D(newP); + flatDist = lastP.distTo2D(newP); splitDone = false; } else { - if (!inHole) { - flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP); - } + flatDist += flatBuffer[flatBuffer.length - 1].distTo2D(newP); flatBuffer.push(newP); } diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js index 0514804f1..324f1952c 100644 --- a/src/kiri/run/minion.js +++ b/src/kiri/run/minion.js @@ -262,6 +262,7 @@ const funcs = self.minion = { 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 = { From 2edd771e5a15c0f9e3d8d36deea48d1c7096393b Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Sat, 30 May 2026 14:45:34 -0400 Subject: [PATCH 14/24] add initial GPU support for radial toolpaths --- src/kiri/mode/cam/work/topo3.js | 580 ++++++++++++++++++++++++++++++-- src/kiri/run/minion.js | 8 + 2 files changed, 568 insertions(+), 20 deletions(-) diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 0123f32a2..6ed6197e5 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -121,7 +121,7 @@ export class Topo { newslices.push(debug); } - if (webGPU && !contour.nogpu && !contourR) { + if (webGPU && !contour.nogpu) { // invert tool Z offset for gpu code let toolBounds = new THREE.Box3() .expandByPoint({ x: -toolDiameter/2, y: -toolDiameter/2, z: 0 }) @@ -157,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; @@ -173,13 +173,355 @@ 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 || 'Spiral').toLowerCase(); + + 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); + + for (let poly of loops) { + const points = poly.points; + const numPoints = points.length; + if (numPoints < 2) continue; + + 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 > 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); + } + } + radial2DPaths.push(new Float32Array(subPoints)); + } + } + } else { + // Spiral mode + let subPoints = []; + const b = toolStep / (2 * Math.PI); + let theta = 0; + let r = 0; + + while (r <= maxR) { + const x = centerX + r * Math.cos(theta); + const y = centerY + r * Math.sin(theta); + subPoints.push(x, y); + + const dtheta = radialStep / Math.sqrt(b * b + r * r); + theta += dtheta; + r = b * theta; + } + 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 || 'Spiral').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 @@ -799,7 +1141,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; @@ -833,7 +1175,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)); @@ -1276,7 +1618,105 @@ export class Trace { } crossRadial(params, then) { - this.crossRadial_sync(params, then); + const { minions } = self.kiri_worker || {}; + const { clipTo, toolStep, resolution, density } = this.cross; + const shape = (params.shape || 'Spiral').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 { + // Spiral shape parallelization + const b = toolStep / (2 * Math.PI); + const bounds = this.probe.params.boundsOverride || this.cross.box; + const minX = bounds.min.x; + const maxX = bounds.max.x; + const minY = bounds.min.y; + const maxY = bounds.max.y; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const partOff = this.cross.partOff || 0; + const dx = maxX - centerX + partOff; + const dy = maxY - centerY + partOff; + const maxR = Math.sqrt(dx * dx + dy * dy); + const maxTheta = maxR / b; + + const numMinions = minions.running; + const chunkSize = maxTheta / numMinions; + let promises = []; + + for (let i = 0; i < numMinions; i++) { + const thetaStart = i * chunkSize; + const thetaEnd = (i + 1) * chunkSize; + promises.push(new Promise(resolve => { + minions.queue({ + cmd: "trace_radial", + params: { + ...params, + thetaStart, + thetaEnd + } + }, data => { + resolve(codec.decode(data.slice)); + }); + })); + } + + Promise.all(promises).then(slices => { + let merged = []; + for (let slice of slices) { + if (slice) { + merged.push(...slice); + } + } + then(merged); + }); + } + } else { + this.crossRadial_sync(params, then); + } } crossRadial_sync(params, then) { @@ -1295,7 +1735,98 @@ export class Trace { if (shape === 'concentric') { // CONCENTRIC SHAPE GENERATION: // Generates closed concentric loop paths from the innermost region to the outer perimeter. - if (clipTo && clipTo.length) { + 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. @@ -1420,14 +1951,14 @@ export class Trace { } else { // DEFAULT SPIRAL MODE: // Generates a continuous Archimedean spiral from the center outwards. - newtrace(); - - // Growth coefficient for Archimedean spiral (expansion of toolStep per 2pi radians) const b = toolStep / (2 * Math.PI); - let theta = 0; - let r = 0; + let theta = params.thetaStart !== undefined ? params.thetaStart : 0; + let r = b * theta; + const thetaEnd = params.thetaEnd; - while (r <= maxR) { + let tracing = false; + + while (r <= maxR && (thetaEnd === undefined || theta <= thetaEnd)) { // Convert polar coordinates (r, theta) to Cartesian workspace coordinates (x, y) const x = centerX + r * Math.cos(theta); const y = centerY + r * Math.sin(theta); @@ -1440,8 +1971,15 @@ export class Trace { if (!inStock || !inShadow) { // Terminate path segment if we exit allowed regions - end_poly(); + if (tracing) { + end_poly(); + tracing = false; + } } else { + if (!tracing) { + newtrace(); + tracing = true; + } // Probe topography height map at (x, y) coordinates let tv = toolAtXY(x, y); @@ -1460,7 +1998,9 @@ export class Trace { theta += dtheta; r = b * theta; } - end_poly(); + if (tracing) { + end_poly(); + } } then(this.slice); diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js index 324f1952c..7812a922a 100644 --- a/src/kiri/run/minion.js +++ b/src/kiri/run/minion.js @@ -290,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; }, From df26e9d3edd558377ccb3e98d6f5bc38dc1a9301 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Mon, 1 Jun 2026 12:22:43 -0400 Subject: [PATCH 15/24] optimize hole skipping logic --- src/geo/polygon.js | 37 +++++++++++++ src/kiri/app/conf/defaults.js | 2 +- src/kiri/app/consts.js | 4 +- src/kiri/mode/cam/work/op-contour.js | 83 ++++++++++++++++++++++------ src/kiri/mode/cam/work/topo3.js | 10 ++-- 5 files changed, 111 insertions(+), 25 deletions(-) diff --git a/src/geo/polygon.js b/src/geo/polygon.js index 6196a2ab3..435cd1515 100644 --- a/src/geo/polygon.js +++ b/src/geo/polygon.js @@ -1496,6 +1496,43 @@ export class Polygon { 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 * diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index e2830c9e2..6dfca9fbe 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -460,7 +460,7 @@ export const conf = { camContourOmitThru: false, camContourOver: 0.5, camContourReduce: 2, - camContourShape: "Spiral", + camContourShape: "Concentric", camContourSpeed: 1000, camContourSpindle: 1000, camContourTool: 1000, diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index f7b8dc40e..baeba1cc3 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -98,8 +98,8 @@ const LISTS = { { name: "Radial" } ], crshape: [ - { name: "Spiral" }, - { name: "Concentric" } + { name: "Concentric" }, + { name: "Spiral" } ], regaxis: [ { name: "X" }, diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index d7ee70f41..02f7c4015 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -262,7 +262,10 @@ function cleanupContourSlices(slices, holes, topo, op) { } }; - for (let pt of poly.points) { + let points = poly.points; + let len = points.length; + for (let i = 0; i < len; i++) { + let pt = points[i]; let currentHole = null; 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) { @@ -274,31 +277,77 @@ function cleanupContourSlices(slices, holes, topo, op) { } if (currentHole) { - // Point is inside a through-hole: snap it to the closest point on the hole perimeter - let edgePt = null; - let axis = op.axis ? op.axis.toLowerCase() : ''; - if (axis === 'x') { - edgePt = currentHole.snapToIntersectionX(pt); - } else if (axis === 'y') { - edgePt = currentHole.snapToIntersectionY(pt); + if (newPoints.length === 0) { + continue; } - if (!edgePt) { - edgePt = currentHole.findClosestPointOnPerimeter(pt); - } - // Sample Z height at the perimeter edge so the tool remains at the correct part height - let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); - let projectedPt = newPoint(edgePt.x, edgePt.y, z); - if (inHolePolygon === currentHole) { - inHoleLast = projectedPt; + // Already in this hole: only calculate snap if this is the exit point + let isExit = (i === len - 1); + if (!isExit) { + let nextPt = points[i + 1]; + let nextHole = null; + for (let hb of holeBoxes) { + if (nextPt.x >= hb.min_x && nextPt.x <= hb.max_x && nextPt.y >= hb.min_y && nextPt.y <= hb.max_y) { + if (nextPt.isInPolygon(hb.hole)) { + nextHole = hb.hole; + break; + } + } + } + if (nextHole !== currentHole) { + isExit = true; + } + } + + if (isExit) { + let edgePt = null; + let axis = op.axis ? op.axis.toLowerCase() : ''; + if (axis === 'x') { + edgePt = currentHole.snapToIntersectionX(pt); + } else if (axis === 'y') { + edgePt = currentHole.snapToIntersectionY(pt); + } else if (axis === 'radial') { + let bounds = topo && topo.widget ? topo.widget.getBoundingBox() : null; + let centerX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0; + let centerY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0; + let theta = Math.atan2(pt.y - centerY, pt.x - centerX); + let tangentAngle = theta + Math.PI / 2; + edgePt = currentHole.snapToIntersectionAngle(pt, tangentAngle); + } + if (!edgePt) { + edgePt = currentHole.findClosestPointOnPerimeter(pt); + } + let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); + inHoleLast = newPoint(edgePt.x, edgePt.y, z); + } } else { + // Entry point (first point inside the hole): compute and snap entry emitHole(); inHolePolygon = currentHole; + + let edgePt = null; + let axis = op.axis ? op.axis.toLowerCase() : ''; + if (axis === 'x') { + edgePt = currentHole.snapToIntersectionX(pt); + } else if (axis === 'y') { + edgePt = currentHole.snapToIntersectionY(pt); + } else if (axis === 'radial') { + let bounds = topo && topo.widget ? topo.widget.getBoundingBox() : null; + let centerX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0; + let centerY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0; + let theta = Math.atan2(pt.y - centerY, pt.x - centerX); + let tangentAngle = theta + Math.PI / 2; + edgePt = currentHole.snapToIntersectionAngle(pt, tangentAngle); + } + if (!edgePt) { + edgePt = currentHole.findClosestPointOnPerimeter(pt); + } + let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); + let projectedPt = newPoint(edgePt.x, edgePt.y, z); inHoleStart = projectedPt; inHoleLast = projectedPt; } } else { - // Point is on solid part: emit normally emitHole(); newPoints.push(pt); } diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 6ed6197e5..cec6fd87e 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -271,7 +271,7 @@ export class Topo { const dx = maxX - centerX + partOff; const dy = maxY - centerY + partOff; const maxR = Math.sqrt(dx * dx + dy * dy); - const shape = (contour.shape || 'Spiral').toLowerCase(); + const shape = (contour.shape || 'Concentric').toLowerCase(); if (shape === 'concentric') { if (clipTo && clipTo.length) { @@ -387,7 +387,7 @@ export class Topo { this.trace.newslice(); - const shape = (contour.shape || 'Spiral').toLowerCase(); + const shape = (contour.shape || 'Concentric').toLowerCase(); let loopIdx = 0; for (let pathXYZ of output.paths) { @@ -1090,10 +1090,10 @@ export class Topo { centerY, maxR, toolStep, - shape: (shape || 'Spiral').toLowerCase() + shape: (shape || 'Concentric').toLowerCase() }, segments => { if (segments.length > 0) { - if ((shape || 'Spiral').toLowerCase() === 'concentric') { + if ((shape || 'Concentric').toLowerCase() === 'concentric') { // Export each loop as a separate slice let grouped = []; for (let seg of segments) { @@ -1620,7 +1620,7 @@ export class Trace { crossRadial(params, then) { const { minions } = self.kiri_worker || {}; const { clipTo, toolStep, resolution, density } = this.cross; - const shape = (params.shape || 'Spiral').toLowerCase(); + const shape = (params.shape || 'Concentric').toLowerCase(); if (minions && minions.running > 1 && this.cross.concurrent) { if (shape === 'concentric') { From b020090ad81e251faa276bc033b9c31c39b721ca Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 08:56:31 -0400 Subject: [PATCH 16/24] checkpoint --- src/geo/polygons.js | 182 +++++++++++++++++++++++++++- src/kiri/app/conf/defaults.js | 1 + src/kiri/app/consts.js | 4 +- src/kiri/mode/cam/app/cl-ops.js | 2 + src/kiri/mode/cam/work/op-area.js | 47 +++++-- src/kiri/mode/cam/work/op-pocket.js | 4 +- src/kiri/mode/cam/work/topo3.js | 78 ++++++++++-- 7 files changed, 296 insertions(+), 22 deletions(-) diff --git a/src/geo/polygons.js b/src/geo/polygons.js index 649813eb7..0a06f038e 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,183 @@ export function reconnect(polys, sameZ = true) { return polys; } +export function spiralize(loops, climb) { + if (!loops || 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 = []; + 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 6dfca9fbe..8f0a076e3 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -560,6 +560,7 @@ export const conf = { camOutlineTool: 1000, camOutlineWide: false, camPocketContour: false, + camPocketType: "offset", camPocketDown: 1, camPocketExpand: 0, camPocketFollow: 5, diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index baeba1cc3..6e4087cc6 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -99,7 +99,8 @@ const LISTS = { ], crshape: [ { name: "Concentric" }, - { name: "Spiral" } + { name: "Spiral" }, + { name: "Concentric Spiral" } ], regaxis: [ { name: "X" }, @@ -126,6 +127,7 @@ const LISTS = { surftyp: [ { name: "linear" }, { name: "offset" }, + { name: "concentric 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 17a51be9f..cfa9b2705 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -546,6 +546,7 @@ export function createPopOps() { refine: 'camPocketRefine', follow: 'camPocketFollow', contour: 'camPocketContour', + sr_type: 'camPocketType', outline: 'camPocketOutline', ov_topz: 0, ov_botz: 0, @@ -565,6 +566,7 @@ 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 }), exp: UC.newExpand("feeds & speeds", { }), spindle: UC.newInput(LANG.cc_spnd_s, { title: LANG.cc_spnd_l, convert: toInt, show: hasSpindle }), diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index 7a0598135..54cf910a7 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -389,9 +389,17 @@ class OpArea extends CamOp { }); 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 +437,38 @@ 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); + 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); + let slice = newLayer(); + slice.camLines = [ polyPath ]; + slice.output() + .setLayer(rename ?? "linear", { line: color }, false) + .addPolys([ polyPath ]); + } } // output this surface diff --git a/src/kiri/mode/cam/work/op-pocket.js b/src/kiri/mode/cam/work/op-pocket.js index f225ea724..700bca3b3 100644 --- a/src/kiri/mode/cam/work/op-pocket.js +++ b/src/kiri/mode/cam/work/op-pocket.js @@ -11,7 +11,7 @@ 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 { plunge, rate, refine, smooth, spindle, surfaces, tolerance, tool, sr_type } = op; let pocket = { areas: {}, direction, @@ -29,7 +29,7 @@ class OpPocket extends CamOp { rename: op.rename ?? "pocket", smooth, spindle, - sr_type: 'offset', + sr_type: sr_type || 'offset', surfaces, tolerance, tool, diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index cec6fd87e..371e0a1a6 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -272,8 +272,10 @@ export class Topo { const dy = maxY - centerY + partOff; const maxR = Math.sqrt(dx * dx + dy * dy); const shape = (contour.shape || 'Concentric').toLowerCase(); + const isConcentricLike = shape === 'concentric' || shape === 'concentric spiral' || shape === 'contour spiral'; + const isSpiralLike = shape === 'concentric spiral' || shape === 'contour spiral'; - if (shape === 'concentric') { + if (isConcentricLike) { if (clipTo && clipTo.length) { let outs = []; POLY.offset(clipTo, -toolStep, { count: 999, outs: outs, flat: true, z: 0, minArea: 0.01 }); @@ -287,13 +289,18 @@ export class Topo { } 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 = []; - for (let i = 0; i < numPoints; i++) { + 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); @@ -308,6 +315,10 @@ export class Topo { 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)); } } @@ -1093,7 +1104,8 @@ export class Topo { shape: (shape || 'Concentric').toLowerCase() }, segments => { if (segments.length > 0) { - if ((shape || 'Concentric').toLowerCase() === 'concentric') { + const lshape = (shape || 'Concentric').toLowerCase(); + if (lshape === 'concentric') { // Export each loop as a separate slice let grouped = []; for (let seg of segments) { @@ -1111,6 +1123,25 @@ export class Topo { slicesR.push(slice); } } + } else if (lshape === 'concentric spiral' || lshape === 'contour spiral') { + // Contour/Concentric Spiral mode: split into separate slices (revolutions) + let sliceIdx = 0; + for (let seg of segments) { + 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(); + slice.camLines = [ chunkPoly ]; + slicesR.push(slice); + } + } } else { // Spiral mode: single slice let slice = newSlice(0); @@ -1251,6 +1282,10 @@ export class Trace { if (trace) trace.loopIndex = idx; }; + const setResampleN = this.setResampleN = function (n) { + if (trace) trace.resampleN = n; + }; + const setLastPoint = this.setLastPoint = function (point) { lastPP = point; }; @@ -1279,8 +1314,10 @@ export class Trace { slice.push(trace); } const oldIdx = trace.loopIndex; + const oldN = trace.resampleN; newtrace(); trace.loopIndex = oldIdx; + trace.resampleN = oldN; } lastPP = undefined; latent = undefined; @@ -1667,6 +1704,8 @@ export class Trace { } else { then([]); } + } else if (shape === 'concentric spiral' || shape === 'contour spiral') { + this.crossRadial_sync(params, then); } else { // Spiral shape parallelization const b = toolStep / (2 * Math.PI); @@ -1732,7 +1771,11 @@ export class Trace { newslice(); - if (shape === 'concentric') { + const lshape = (shape || 'Concentric').toLowerCase(); + const isConcentricLike = lshape === 'concentric' || lshape === 'concentric spiral' || lshape === 'contour spiral'; + const isSpiralLike = 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) { @@ -1845,10 +1888,17 @@ export class Trace { } 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; @@ -1857,7 +1907,8 @@ export class Trace { // 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 = []; - for (let i = 0; i < numPoints; i++) { + 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); @@ -1874,6 +1925,10 @@ export class Trace { 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. @@ -1901,12 +1956,15 @@ export class Trace { } // 3. Emit points using state machine: - if (hasOut) { - // PARTIAL CLIPPING: If the concentric loop intersects the boundaries (i.e. goes out of stock), + 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. This allows us to cleanly start/stop tracing when entering/exiting bounds. - let firstOutIdx = evaluated.findIndex(p => !p.inClip); - let rotated = [...evaluated.slice(firstOutIdx), ...evaluated.slice(0, firstOutIdx)]; + // 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) { From f6ede736375703cd2a6c5c52ddcf7a90024ddc1f Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 09:28:40 -0400 Subject: [PATCH 17/24] siplify hole-skipping logic --- src/kiri/mode/cam/work/op-contour.js | 159 ++++++++++----------------- src/kiri/mode/cam/work/topo3.js | 4 +- 2 files changed, 60 insertions(+), 103 deletions(-) diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index 02f7c4015..78ca75b45 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -188,6 +188,8 @@ class OpContour extends CamOp { }; const isRadial = op.axis.toLowerCase() === 'radial'; + const lshape = (op.shape || '').toLowerCase(); + const isSpiral = lshape === 'concentric spiral' || lshape === 'contour spiral'; if (isRadial) { // RADIAL FINISHING TOOLPATH EMISSION: @@ -195,13 +197,59 @@ class OpContour extends CamOp { // 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. - for (let slice of sliceOut) { - if (!slice.camLines) { - continue; + 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); } - 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: @@ -243,116 +291,23 @@ function cleanupContourSlices(slices, holes, topo, op) { let newPolys = []; for (let poly of slice.camLines) { let newPoints = []; - let inHolePolygon = null; - let inHoleStart = null; - let inHoleLast = null; - - // Helper to emit the snapped boundary segment once we exit the through-hole area - const emitHole = () => { - if (inHolePolygon) { - if (inHoleStart) { - newPoints.push(inHoleStart); - } - if (inHoleLast && inHoleLast !== inHoleStart) { - newPoints.push(inHoleLast); - } - inHolePolygon = null; - inHoleStart = null; - inHoleLast = null; - } - }; - let points = poly.points; let len = points.length; for (let i = 0; i < len; i++) { let pt = points[i]; - let currentHole = null; + 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)) { - currentHole = hb.hole; + inHole = true; break; } } } - - if (currentHole) { - if (newPoints.length === 0) { - continue; - } - if (inHolePolygon === currentHole) { - // Already in this hole: only calculate snap if this is the exit point - let isExit = (i === len - 1); - if (!isExit) { - let nextPt = points[i + 1]; - let nextHole = null; - for (let hb of holeBoxes) { - if (nextPt.x >= hb.min_x && nextPt.x <= hb.max_x && nextPt.y >= hb.min_y && nextPt.y <= hb.max_y) { - if (nextPt.isInPolygon(hb.hole)) { - nextHole = hb.hole; - break; - } - } - } - if (nextHole !== currentHole) { - isExit = true; - } - } - - if (isExit) { - let edgePt = null; - let axis = op.axis ? op.axis.toLowerCase() : ''; - if (axis === 'x') { - edgePt = currentHole.snapToIntersectionX(pt); - } else if (axis === 'y') { - edgePt = currentHole.snapToIntersectionY(pt); - } else if (axis === 'radial') { - let bounds = topo && topo.widget ? topo.widget.getBoundingBox() : null; - let centerX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0; - let centerY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0; - let theta = Math.atan2(pt.y - centerY, pt.x - centerX); - let tangentAngle = theta + Math.PI / 2; - edgePt = currentHole.snapToIntersectionAngle(pt, tangentAngle); - } - if (!edgePt) { - edgePt = currentHole.findClosestPointOnPerimeter(pt); - } - let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); - inHoleLast = newPoint(edgePt.x, edgePt.y, z); - } - } else { - // Entry point (first point inside the hole): compute and snap entry - emitHole(); - inHolePolygon = currentHole; - - let edgePt = null; - let axis = op.axis ? op.axis.toLowerCase() : ''; - if (axis === 'x') { - edgePt = currentHole.snapToIntersectionX(pt); - } else if (axis === 'y') { - edgePt = currentHole.snapToIntersectionY(pt); - } else if (axis === 'radial') { - let bounds = topo && topo.widget ? topo.widget.getBoundingBox() : null; - let centerX = bounds ? (bounds.min.x + bounds.max.x) / 2 : 0; - let centerY = bounds ? (bounds.min.y + bounds.max.y) / 2 : 0; - let theta = Math.atan2(pt.y - centerY, pt.x - centerX); - let tangentAngle = theta + Math.PI / 2; - edgePt = currentHole.snapToIntersectionAngle(pt, tangentAngle); - } - if (!edgePt) { - edgePt = currentHole.findClosestPointOnPerimeter(pt); - } - let z = (topo && topo.toolAtXY ? topo.toolAtXY(edgePt.x, edgePt.y) : pt.z) + (op.leave || 0); - let projectedPt = newPoint(edgePt.x, edgePt.y, z); - inHoleStart = projectedPt; - inHoleLast = projectedPt; - } - } else { - emitHole(); + if (!inHole) { newPoints.push(pt); } } - emitHole(); if (newPoints.length > 1) { poly.points = newPoints; diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 371e0a1a6..b1d79b740 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -1126,7 +1126,8 @@ export class Topo { } else if (lshape === 'concentric spiral' || lshape === 'contour spiral') { // Contour/Concentric Spiral mode: split into separate slices (revolutions) let sliceIdx = 0; - for (let seg of segments) { + 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; @@ -1138,6 +1139,7 @@ export class Topo { let slice = newSlice(sliceIdx++); let chunkPoly = newPolygon(points.slice(start, end)); chunkPoly.setOpen(); + chunkPoly.spiralId = segIdx; slice.camLines = [ chunkPoly ]; slicesR.push(slice); } From 1e5005943d61e644b23a2c8f1445333adc4b5972 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 09:39:41 -0400 Subject: [PATCH 18/24] remove archemedian spiral --- src/kiri/app/consts.js | 5 +- src/kiri/mode/cam/work/op-contour.js | 2 +- src/kiri/mode/cam/work/topo3.js | 149 +++++---------------------- 3 files changed, 26 insertions(+), 130 deletions(-) diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index 6e4087cc6..565db4f28 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -99,8 +99,7 @@ const LISTS = { ], crshape: [ { name: "Concentric" }, - { name: "Spiral" }, - { name: "Concentric Spiral" } + { name: "Spiral" } ], regaxis: [ { name: "X" }, @@ -127,7 +126,7 @@ const LISTS = { surftyp: [ { name: "linear" }, { name: "offset" }, - { name: "concentric spiral" } + { name: "spiral" } ], direction: [ { name: "climb" }, diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index 78ca75b45..4065be80e 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -189,7 +189,7 @@ class OpContour extends CamOp { const isRadial = op.axis.toLowerCase() === 'radial'; const lshape = (op.shape || '').toLowerCase(); - const isSpiral = lshape === 'concentric spiral' || lshape === 'contour spiral'; + const isSpiral = lshape === 'spiral' || lshape === 'concentric spiral' || lshape === 'contour spiral'; if (isRadial) { // RADIAL FINISHING TOOLPATH EMISSION: diff --git a/src/kiri/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index b1d79b740..90da169d4 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -272,8 +272,8 @@ export class Topo { const dy = maxY - centerY + partOff; const maxR = Math.sqrt(dx * dx + dy * dy); const shape = (contour.shape || 'Concentric').toLowerCase(); - const isConcentricLike = shape === 'concentric' || shape === 'concentric spiral' || shape === 'contour spiral'; - const isSpiralLike = shape === 'concentric spiral' || shape === 'contour spiral'; + 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) { @@ -322,23 +322,6 @@ export class Topo { radial2DPaths.push(new Float32Array(subPoints)); } } - } else { - // Spiral mode - let subPoints = []; - const b = toolStep / (2 * Math.PI); - let theta = 0; - let r = 0; - - while (r <= maxR) { - const x = centerX + r * Math.cos(theta); - const y = centerY + r * Math.sin(theta); - subPoints.push(x, y); - - const dtheta = radialStep / Math.sqrt(b * b + r * r); - theta += dtheta; - r = b * theta; - } - radial2DPaths.push(new Float32Array(subPoints)); } } @@ -1123,7 +1106,7 @@ export class Topo { slicesR.push(slice); } } - } else if (lshape === 'concentric spiral' || lshape === 'contour spiral') { + } 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++) { @@ -1145,10 +1128,23 @@ export class Topo { } } } else { - // Spiral mode: single slice - let slice = newSlice(0); - slice.camLines = segments; - slicesR.push(slice); + // 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"); @@ -1706,54 +1702,8 @@ export class Trace { } else { then([]); } - } else if (shape === 'concentric spiral' || shape === 'contour spiral') { + } else if (shape === 'spiral' || shape === 'concentric spiral' || shape === 'contour spiral') { this.crossRadial_sync(params, then); - } else { - // Spiral shape parallelization - const b = toolStep / (2 * Math.PI); - const bounds = this.probe.params.boundsOverride || this.cross.box; - const minX = bounds.min.x; - const maxX = bounds.max.x; - const minY = bounds.min.y; - const maxY = bounds.max.y; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - const partOff = this.cross.partOff || 0; - const dx = maxX - centerX + partOff; - const dy = maxY - centerY + partOff; - const maxR = Math.sqrt(dx * dx + dy * dy); - const maxTheta = maxR / b; - - const numMinions = minions.running; - const chunkSize = maxTheta / numMinions; - let promises = []; - - for (let i = 0; i < numMinions; i++) { - const thetaStart = i * chunkSize; - const thetaEnd = (i + 1) * chunkSize; - promises.push(new Promise(resolve => { - minions.queue({ - cmd: "trace_radial", - params: { - ...params, - thetaStart, - thetaEnd - } - }, data => { - resolve(codec.decode(data.slice)); - }); - })); - } - - Promise.all(promises).then(slices => { - let merged = []; - for (let slice of slices) { - if (slice) { - merged.push(...slice); - } - } - then(merged); - }); } } else { this.crossRadial_sync(params, then); @@ -1774,8 +1724,8 @@ export class Trace { newslice(); const lshape = (shape || 'Concentric').toLowerCase(); - const isConcentricLike = lshape === 'concentric' || lshape === 'concentric spiral' || lshape === 'contour spiral'; - const isSpiralLike = lshape === 'concentric spiral' || lshape === 'contour spiral'; + 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: @@ -2008,59 +1958,6 @@ export class Trace { loopIdx++; } } - } else { - // DEFAULT SPIRAL MODE: - // Generates a continuous Archimedean spiral from the center outwards. - const b = toolStep / (2 * Math.PI); - let theta = params.thetaStart !== undefined ? params.thetaStart : 0; - let r = b * theta; - const thetaEnd = params.thetaEnd; - - let tracing = false; - - while (r <= maxR && (thetaEnd === undefined || theta <= thetaEnd)) { - // Convert polar coordinates (r, theta) to Cartesian workspace coordinates (x, y) - const x = centerX + r * Math.cos(theta); - const y = centerY + r * Math.sin(theta); - checkr.x = x; - checkr.y = y; - - // Restrict toolpath within stock AND expanded part boundaries (intersection check) - const inStock = !clipStock || inClip(clipStock, undefined, checkr); - const inShadow = !clipTo || inClip(clipTo, undefined, checkr); - - if (!inStock || !inShadow) { - // Terminate path segment if we exit allowed regions - if (tracing) { - end_poly(); - tracing = false; - } - } else { - if (!tracing) { - newtrace(); - tracing = true; - } - // Probe topography height map at (x, y) coordinates - let tv = toolAtXY(x, y); - - // Override height if tool is within tab boundary to prevent milling tabs - if (clipTab && clipTab.length && tv < tabHeight && inClip(clipTab, tv, checkr)) { - tv = this.tabZ; - } - - push_point(x, y, tv + leave); - } - - // STEPPING FORMULA: - // We use dtheta = ds / sqrt(b^2 + r^2) to maintain a constant linear feed resolution - // (linear step size 'step') as the spiral radius expands outwards. - const dtheta = step / Math.sqrt(b * b + r * r); - theta += dtheta; - r = b * theta; - } - if (tracing) { - end_poly(); - } } then(this.slice); From 4e0530e12b4d7723aae28480cb160e85cea86cf5 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 11:04:25 -0400 Subject: [PATCH 19/24] standardize on "concentric" ("offset" was used in some places to mean the same thing) --- src/kiri/app/conf/defaults.js | 5 ++- src/kiri/app/consts.js | 2 +- src/kiri/mode/cam/app/cl-ops.js | 10 ++++- src/kiri/mode/cam/work/op-area.js | 67 ++++++++++++++++++++++++----- src/kiri/mode/cam/work/op-level.js | 39 ++++++++++++----- src/kiri/mode/cam/work/op-pocket.js | 5 ++- 6 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 8f0a076e3..3f027b6e8 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -448,6 +448,7 @@ export const conf = { camAreaDogbones: false, camAreaRevbones: false, camAreaOutline: false, + camAreaOmitThru: false, camAreaWalls: false, camAreaZigZag: true, camContourAngle: 85, @@ -539,6 +540,7 @@ export const conf = { camLevelStepZ: 0, camLevelStock: true, camLevelTool: 1000, + camLevelType: "linear", camMillDirection: "climb", camOriginCenter: false, camOriginOffX: 0, @@ -560,11 +562,12 @@ export const conf = { camOutlineTool: 1000, camOutlineWide: false, camPocketContour: false, - camPocketType: "offset", + camPocketType: "concentric", camPocketDown: 1, camPocketExpand: 0, camPocketFollow: 5, camPocketOutline: false, + camPocketOmitThru: false, camPocketOver: 0.25, camPocketPlunge: 200, camPocketRefine: 20, diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index 565db4f28..99eb285d9 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -125,7 +125,7 @@ const LISTS = { ], surftyp: [ { name: "linear" }, - { name: "offset" }, + { name: "concentric" }, { name: "spiral" } ], direction: [ diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index cfa9b2705..d59993a20 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -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 }), }; @@ -548,6 +550,7 @@ export function createPopOps() { contour: 'camPocketContour', sr_type: 'camPocketType', outline: 'camPocketOutline', + omitthru: 'camPocketOmitThru', ov_topz: 0, ov_botz: 0, ov_conv: '~camConventional', @@ -568,6 +571,7 @@ export function createPopOps() { 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 }), @@ -780,6 +784,7 @@ export function createPopOps() { follow: 'camAreaFollow', refine: 'camAreaRefine', outline: 'camAreaOutline', + omitthru: 'camAreaOmitThru', shadow: 'camAreaShadow', tolerance: 'camTolerance', dogbones: 'camAreaDogbones', @@ -790,7 +795,7 @@ 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: UC.newSelect("pattern", { title: "pattern", show: () => isClear() || isSurface() }, "surftyp"), sep: UC.newBlank({ class: "pop-sep" }), exp: UC.newExpand("area selection", { open }), menu: UC.newRow([ @@ -799,6 +804,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 54cf910a7..c74c1e4e6 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -27,7 +27,7 @@ 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, sr_type, omitthru } = op; let { mode, outline, over, rename, smooth, tool } = op; let { addSlices, axisIndex, color, cutTabs, settings } = state; let { shadowAt, setToolDiam, tabs, widget, workarea } = state; @@ -210,6 +210,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) { @@ -357,8 +360,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,11 +382,12 @@ 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 @@ -457,17 +462,27 @@ class OpArea extends CamOp { // G-code generates a single continuous toolpath without travel lifts/moves. let origPathPoly = newPolygon().fromArray([1, ...path]); if (op.refine) origPathPoly.refine(op.refine); - surface.push(origPathPoly); + 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); - let slice = newLayer(); - slice.camLines = [ polyPath ]; - slice.output() - .setLayer(rename ?? "linear", { line: color }, false) - .addPolys([ polyPath ]); + 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 ]); + } } } @@ -645,4 +660,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 min_x = Infinity, max_x = -Infinity, min_y = Infinity, max_y = -Infinity; + for (let p of hole.points) { + if (p.x < min_x) min_x = p.x; + if (p.x > max_x) max_x = p.x; + if (p.y < min_y) min_y = p.y; + if (p.y > max_y) max_y = p.y; + } + holeBoxes.push({ min_x, max_x, min_y, max_y, 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-level.js b/src/kiri/mode/cam/work/op-level.js index ab1651d59..235b81099 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,21 +48,41 @@ 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 Date: Wed, 3 Jun 2026 11:49:21 -0400 Subject: [PATCH 20/24] Update documentation for new spiralizing ops --- docs/kiri-moto/CAM/ops.mdx | 42 ++++++++++++++++++++++++++++++ docs/src/components/carousel.js | 8 ++++-- src/geo/polygons.js | 8 ++++++ src/kiri/app/conf/defaults.js | 1 + src/kiri/mode/cam/app/cl-ops.js | 2 ++ src/kiri/mode/cam/work/op-rough.js | 2 ++ 6 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/kiri-moto/CAM/ops.mdx b/docs/kiri-moto/CAM/ops.mdx index 7a754334c..43bf31c30 100644 --- a/docs/kiri-moto/CAM/ops.mdx +++ b/docs/kiri-moto/CAM/ops.mdx @@ -30,6 +30,7 @@ These operations include: - [Drill](#drill) - [Trace](#trace) - [Pocket](#pocket) + - [Area](#area) - [Gcode](#gcode) - Laser Operations - Indexed Operations @@ -58,6 +59,12 @@ The Level op creates a flat-surface clearing toolpath across a selected area. It - Creating uniform faces on complex objects - Clearing large areas with consistent depth quickly +#### Key Options +- **Pattern**: The toolpath pattern used to surface the area: + - **linear**: Parallel zig-zag raster paths. + - **concentric**: Progressive concentric loops offsetting inwards from the perimeter. + - **spiral**: Continuously spiralized concentric offset paths. + ### Rough @@ -70,6 +77,11 @@ The Rough op removes large volumes of material quickly using a stepped-down tool - Preparing a part for finish machining - Preserving tool life by reducing cutting load in later passes +#### Key Options +- **Pattern**: The clearing pattern type: + - **concentric**: Progressive concentric loops offsetting inwards from the boundary. + - **spiral**: Continuously spiralized concentric offset paths. + ### Contour @@ -144,6 +156,36 @@ for an approximation of a v-bit carve. Some use cases specific to the Pocket op - Attempting a v-carve - Clearing a specific area of a part +#### Key Options +- **Contour / Surfacing Pattern**: When **contour** is enabled, chooses the surfacing pattern type: + - **linear**: Parallel zig-zag toolpaths. + - **concentric**: Progressive concentric loops offsetting inwards. + - **spiral**: Continuously spiralized concentric offset paths from the center outwards, with the innermost loop fully traced in its entirety to ensure no center stock is left uncut. +- **Outline Only**: Restricts pocket contouring to only trace the outer perimeter and hole outlines. +- **Omit Through**: When **Outline Only** is checked, this bypasses machining boundaries for holes and features that go completely through the part. + +### Area + + + +The Area operation allows you to select specific boundary loops or surfaces on a part to clear, trace, or surface. It provides precise control over localized features. It is useful for: + +- Clearing flat pockets or horizontal areas. +- Tracing specific boundary contours. +- Surfacing localized regions of a part. + +#### Key Options +- **Mode**: Chooses the operation style: + - **clear**: Clears the material inside the selected boundaries. + - **trace**: Traces the selected boundary lines directly. + - **surface**: Surfaces the area using a designated pattern. +- **Pattern**: When in **clear** or **surface** mode, defines the toolpath pattern: + - **linear**: Parallel scanlines/zig-zag paths. + - **concentric**: Progressive concentric loops offsetting inwards from the boundary. + - **spiral**: Continuously spiralized concentric offset paths. +- **Outline Only**: When in **clear** or **surface** mode, restricts paths to trace the outer perimeter and hole outlines. +- **Omit Through**: When **Outline Only** is checked, this prunes toolpath points inside through-holes to skip them cleanly. + ### Gcode diff --git a/docs/src/components/carousel.js b/docs/src/components/carousel.js index 0174fc2d0..d6186f9d0 100644 --- a/docs/src/components/carousel.js +++ b/docs/src/components/carousel.js @@ -77,11 +77,15 @@ const imageDescripts = { * @returns {ReactElement} - A carousel of the given images. */ export function ImageCarousel({ base, images, sml }) { + const list = imageDescripts[images]; + if (!list || list.length === 0) { + return null; + } return (
- {imageDescripts[images].map(([image, caption], index) => ( -
+ {list.map(([image, caption], index) => ( +

{caption}

diff --git a/src/geo/polygons.js b/src/geo/polygons.js index 0a06f038e..b22417d60 100644 --- a/src/geo/polygons.js +++ b/src/geo/polygons.js @@ -1459,6 +1459,14 @@ export function spiralize(loops, climb) { // 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]; diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 3f027b6e8..6de202f6b 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -594,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/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index d59993a20..07757b075 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -325,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" }, "surftyp"), 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 }), diff --git a/src/kiri/mode/cam/work/op-rough.js b/src/kiri/mode/cam/work/op-rough.js index c1df6cbc1..85954fa88 100644 --- a/src/kiri/mode/cam/work/op-rough.js +++ b/src/kiri/mode/cam/work/op-rough.js @@ -44,6 +44,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 +70,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, From 22b0a8ed5e8570e31fe2972615eaf037f8c6d968 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 14:34:46 -0400 Subject: [PATCH 21/24] update docs and fix a bug introduced in roughing --- docs/kiri-moto/CAM/ops.mdx | 6 ++ src/kiri/app/consts.js | 4 + src/kiri/mode/cam/app/cl-ops.js | 2 +- src/kiri/mode/cam/work/op-area.js | 12 ++- src/kiri/mode/cam/work/op-contour.js | 112 +++++++++++++++++++++------ src/kiri/mode/cam/work/op-rough.js | 27 +++++++ src/kiri/mode/cam/work/topo3.js | 12 ++- 7 files changed, 148 insertions(+), 27 deletions(-) diff --git a/docs/kiri-moto/CAM/ops.mdx b/docs/kiri-moto/CAM/ops.mdx index 43bf31c30..3e487f235 100644 --- a/docs/kiri-moto/CAM/ops.mdx +++ b/docs/kiri-moto/CAM/ops.mdx @@ -60,6 +60,7 @@ The Level op creates a flat-surface clearing toolpath across a selected area. It - Clearing large areas with consistent depth quickly #### Key Options + - **Pattern**: The toolpath pattern used to surface the area: - **linear**: Parallel zig-zag raster paths. - **concentric**: Progressive concentric loops offsetting inwards from the perimeter. @@ -78,6 +79,7 @@ The Rough op removes large volumes of material quickly using a stepped-down tool - Preserving tool life by reducing cutting load in later passes #### Key Options + - **Pattern**: The clearing pattern type: - **concentric**: Progressive concentric loops offsetting inwards from the boundary. - **spiral**: Continuously spiralized concentric offset paths. @@ -94,12 +96,14 @@ It can trace along the X or Y axes, or in **Radial** mode, with configurable pre - Cleaning up a part after [roughing](#rough) #### Axis Modes + - **X / Y Axis**: Generates linear parallel scanning toolpaths along the selected horizontal axis. - **Radial**: Generates toolpaths radiating from or circling around the center of the part. This has two shape submodes: - **Spiral**: Emits a continuous spiral toolpath expanding outwards from the center of the part. - **Concentric**: Generates concentric circular loops from the innermost boundary to the perimeter. #### Key Options + - **Curves Only**: Restricts linear cleanup to sloped/curved surfaces, automatically skipping flat horizontal sections. - **Curve Join Dist**: A multiplier of the tool diameter (defaults to 0.5) defining the maximum length of flat regions between curved profiles that should be bridged and kept continuous to prevent unnecessary tool retractions. - **Omit Through**: Bypasses machining slots, holes, and other features that go completely through the part. @@ -157,6 +161,7 @@ for an approximation of a v-bit carve. Some use cases specific to the Pocket op - Clearing a specific area of a part #### Key Options + - **Contour / Surfacing Pattern**: When **contour** is enabled, chooses the surfacing pattern type: - **linear**: Parallel zig-zag toolpaths. - **concentric**: Progressive concentric loops offsetting inwards. @@ -175,6 +180,7 @@ The Area operation allows you to select specific boundary loops or surfaces on a - Surfacing localized regions of a part. #### Key Options + - **Mode**: Chooses the operation style: - **clear**: Clears the material inside the selected boundaries. - **trace**: Traces the selected boundary lines directly. diff --git a/src/kiri/app/consts.js b/src/kiri/app/consts.js index 99eb285d9..328855ad6 100644 --- a/src/kiri/app/consts.js +++ b/src/kiri/app/consts.js @@ -128,6 +128,10 @@ const LISTS = { { name: "concentric" }, { name: "spiral" } ], + roughtyp: [ + { name: "concentric" }, + { name: "spiral" } + ], direction: [ { name: "climb" }, { name: "conventional" }, diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 07757b075..2a78ca18d 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -331,7 +331,7 @@ export function createPopOps() { }).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" }, "surftyp"), + 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 }), diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index c74c1e4e6..db6c54144 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -585,10 +585,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; }); diff --git a/src/kiri/mode/cam/work/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index 4065be80e..36a865f22 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -113,7 +113,7 @@ class OpContour extends CamOp { // 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); + slices = cleanupContourSlices(slices, state.shadow.holes, topo, op, toolDiam); } slices = filter(slices); this.sliceOut = slices; @@ -266,52 +266,81 @@ class OpContour extends CamOp { } } -// SLICE POST-PROCESSING SNAP-TO-HOLE (OMIT THROUGH option): -// Post-processes contour slice lines to snap segments that cross through-holes onto the hole boundary. -// When 'Omit Through' is active, the toolpath should not run inside the holes. For segments crossing -// the holes, we project points inside the hole onto the nearest point on the hole's perimeter, -// and sample the correct Z height at that edge. This creates clean, continuous contours wrapping -// around the through-holes rather than leaving jagged/broken segments. -function cleanupContourSlices(slices, holes, topo, op) { +// 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 min_x = Infinity, max_x = -Infinity, min_y = Infinity, max_y = -Infinity; - for (let p of hole.points) { - if (p.x < min_x) min_x = p.x; - if (p.x > max_x) max_x = p.x; - if (p.y < min_y) min_y = p.y; - if (p.y > max_y) max_y = p.y; - } - holeBoxes.push({ min_x, max_x, min_y, max_y, hole }); + 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 newPoints = []; 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 inHole = false; + 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)) { - inHole = true; + ptInHole = true; break; } } } - if (!inHole) { - newPoints.push(pt); + + 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 (newPoints.length > 1) { - poly.points = newPoints; - newPolys.push(poly); + 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; @@ -319,4 +348,39 @@ function cleanupContourSlices(slices, holes, topo, op) { 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-rough.js b/src/kiri/mode/cam/work/op-rough.js index 85954fa88..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}"`; } @@ -134,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/topo3.js b/src/kiri/mode/cam/work/topo3.js index 90da169d4..335f3d84b 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -2059,10 +2059,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; }); From 09857727ef72aec4fdb6ab28e04a4f358de45f0f Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 14:46:29 -0400 Subject: [PATCH 22/24] final cleanups --- src/geo/polygons.js | 4 ++++ src/kiri/app/conf/defaults.js | 2 +- src/kiri/mode/cam/app/cl-ops.js | 8 +++++--- src/kiri/mode/cam/work/op-area.js | 21 +++++++++++---------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/geo/polygons.js b/src/geo/polygons.js index b22417d60..423dc919f 100644 --- a/src/geo/polygons.js +++ b/src/geo/polygons.js @@ -1310,6 +1310,10 @@ export function reconnect(polys, sameZ = true) { 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); diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 6de202f6b..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, diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 2a78ca18d..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() { @@ -773,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', @@ -797,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: () => isClear() || 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([ diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index db6c54144..b2c8550d1 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, sr_type, omitthru } = 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; @@ -351,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 }); @@ -674,14 +675,14 @@ function prunePointsInHoles(poly, holes) { if (!holes || !holes.length) return poly; let holeBoxes = []; for (let hole of holes) { - let min_x = Infinity, max_x = -Infinity, min_y = Infinity, max_y = -Infinity; - for (let p of hole.points) { - if (p.x < min_x) min_x = p.x; - if (p.x > max_x) max_x = p.x; - if (p.y < min_y) min_y = p.y; - if (p.y > max_y) max_y = p.y; - } - holeBoxes.push({ min_x, max_x, min_y, max_y, hole }); + 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) { From e21aacf31714fc67b09fac4fee0e55fa5bfcbe56 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Wed, 3 Jun 2026 14:49:15 -0400 Subject: [PATCH 23/24] final docs updates --- docs/kiri-moto/CAM/ops.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/kiri-moto/CAM/ops.mdx b/docs/kiri-moto/CAM/ops.mdx index 3e487f235..50b6e4856 100644 --- a/docs/kiri-moto/CAM/ops.mdx +++ b/docs/kiri-moto/CAM/ops.mdx @@ -185,8 +185,8 @@ The Area operation allows you to select specific boundary loops or surfaces on a - **clear**: Clears the material inside the selected boundaries. - **trace**: Traces the selected boundary lines directly. - **surface**: Surfaces the area using a designated pattern. -- **Pattern**: When in **clear** or **surface** mode, defines the toolpath pattern: - - **linear**: Parallel scanlines/zig-zag paths. +- **Pattern**: Defines the toolpath pattern: + - **linear**: Parallel scanlines/zig-zag paths (available in **surface** mode only). - **concentric**: Progressive concentric loops offsetting inwards from the boundary. - **spiral**: Continuously spiralized concentric offset paths. - **Outline Only**: When in **clear** or **surface** mode, restricts paths to trace the outer perimeter and hole outlines. From add93c1ff3e97fa2bf7768092765c45cd4137274 Mon Sep 17 00:00:00 2001 From: Jeff Cooper Date: Thu, 11 Jun 2026 22:49:10 -0400 Subject: [PATCH 24/24] fix a bug where multi-pass spiral contours (roughing, area, level, and pocket) wouldn't retract between passes. --- src/kiri/mode/cam/work/op-area.js | 6 ++- src/kiri/mode/cam/work/op-level.js | 11 ++++- src/kiri/mode/cam/work/prepare.js | 72 +++++++++++++++++------------- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index b2c8550d1..475688333 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -524,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 = { @@ -558,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; diff --git a/src/kiri/mode/cam/work/op-level.js b/src/kiri/mode/cam/work/op-level.js index 235b81099..eb6686714 100644 --- a/src/kiri/mode/cam/work/op-level.js +++ b/src/kiri/mode/cam/work/op-level.js @@ -89,24 +89,31 @@ class OpLevel extends CamOp { prepare(ops, progress) { let { layers, skip, stepOver } = this; let { printPoint } = ops; - let { newLayer, tip2tipEmit, camOut } = ops; + let { newLayer, tip2tipEmit, camOut, zSafe, tool } = ops; if (skip) { return; } + let spiral = this.op.sr_type === 'spiral' || this.op.sr_type === 'concentric spiral'; + let layer_index = 0; for (let lines of layers) { lines = lines.map(p => { 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/prepare.js b/src/kiri/mode/cam/work/prepare.js index 3edda13c3..eba381df9 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -676,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 = []; @@ -708,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(); } @@ -724,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; } } }