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