Merge pull request #401 from GridSpace/helical-op

Helical op
This commit is contained in:
Josiah Hamm 2025-07-20 16:45:57 -06:00 • committed by GitHub
commit daebb07ee1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 792 additions and 44 deletions

View file

@ -54,6 +54,21 @@
"camDrillLift": 2,
"camDrillingOn": false,
"camRegisterSpeed": 1000,
"camHelicalTool": 1000,
"camHelicalSpindle": 1000,
"camHelicalDownSpeed": 250,
"camHelicalSpeed": 1000,
"camHelicalDown": 5,
"camHelicalBottomFinish":true,
"camHelicalThru": 0,
"camHelicalOffset": "auto",
"camHelicalForceStartAngle": false,
"camHelicalStartAngle": 0,
"camHelicalOffsetOverride": 0,
"camHelicalEntry": false,
"camHelicalEntryOffset":0,
"camHelicalReverse": false,
"camHelicalClockwise": true,
"camFlipAxis": "X",
"camFlipOther": "",
"camTabsWidth": 20,

View file

@ -538,6 +538,10 @@ export function arcToPath(start, end, arcdivs = 24, opts) {
return;
}
if(arcdivs <= 2){
return [start.clone(), end.clone()];
}
if (center) {
// center = center.add(start);
center.r = center.distTo2D(start);
@ -569,27 +573,17 @@ export function arcToPath(start, end, arcdivs = 24, opts) {
let a2 = Math.atan2(center.y - end.y, center.x - end.x) + Math.PI;
let ad = base.util.thetaDiff(a1, a2, clockwise); // angle difference in radians
let samePoint = Math.abs(ad) < 0.001
let ofFull = Math.abs(ad) / (2 * Math.PI);
let steps = samePoint ? arcdivs : Math.max(Math.floor(arcdivs * ofFull), 4);
let step = (samePoint ? (Math.PI * 2) : ad) / steps;
let numPoints = steps / step;
let ofFull = Math.abs(ad)/(2*Math.PI);
let steps = samePoint? arcdivs : Math.max(Math.floor( arcdivs * ofFull),4);
let step = (samePoint? (Math.PI*2)*(clockwise? -1 : 1) : ad) / steps;
let zStart = start.z;
let zStep = dz / numPoints;
let rot = a1 + step;
let zStep = -dz / steps;
let rot = a1;
//unused deltas
let da = Math.abs(a1 - a2);
let dd = Math.sqrt(dx * dx + dy * dy);
// LOG({index, da, dd, first: pos, last: rec, center, a1, a2, ad, step, steps, rot, line});
// G0G1(false, [`X${center.x}`, `Y${center.y}`, `E1`]);
// under 1 degree arc and 5mm, convert to straight line
// if (da < 0.005 && dd < 5) {
// G0G1(false, [`X${end.x}`, `Y${end.y}`, `E1`]);
// return ;
// }
let arr = [] // point accumulator
for (let i = 0; i <= steps - 2; i++) {
if (isNaN(center.r) || isNaN(center.x) || isNaN(center.y)) {

View file

@ -518,6 +518,21 @@ export const conf = {
camDrillingOn: false,
camRegisterSpeed: 1000,
camRegisterThru: 5,
camHelicalTool: 1000,
camHelicalSpindle: 1000,
camHelicalDownSpeed: 250,
camHelicalSpeed: 1000,
camHelicalDown: 5,
camHelicalBottomFinish: true,
camHelicalThru: 0,
camHelicalOffset: "auto",
camHelicalForceStartAngle: false,
camHelicalStartAngle: 0,
camHelicalOffsetOverride: 0,
camHelicalEntry: false,
camHelicalEntryOffset:0,
camHelicalReverse: false,
camHelicalClockwise: true,
camFlipAxis: "X",
camFlipOther: "",
camLaserEnable: ["M321"],

View file

@ -90,6 +90,11 @@ const LISTS = {
{ name: "inside" },
{ name: "outside" }
],
helicaloff:[
{ name: "auto" },
{ name: "inside" },
{ name: "outside" },
],
zanchor: [
{ name: "top" },
{ name: "middle" },

View file

@ -38,6 +38,7 @@ export const UI = {
bound,
toInt,
toFloat,
toDegsFloat,
isSticky,
setSticky,
newBoolean,
@ -334,6 +335,18 @@ function toFloat() {
return nv;
}
function toDegsFloat(){
let nv = this.value !== '' ? parseFloat(this.value) : null;
if (nv !== null && this.bound) nv = this.bound(nv);
nv = (nv+360)% 360; // bound the val to 0-359.99
if (this.setv) {
return this.setv(nv);
} else {
this.value = nv;
}
return nv;
}
function raw() {
return this.value !== '' ? this.value : null;
}

View file

@ -0,0 +1,63 @@
import { api } from "../../core/api.js";
import { CAM } from "./driver-fe.js";
import { clearPops, env } from "./client.js";
let alert, lastWidget;
export let helicalOn = false;
export function selectHelical() {
if (helicalOn) {
return helicalDone();
}
clearPops();
alert = api.show.alert("analyzing surfaces...", 1000);
let cylinders = env.poppedRec.cylinders;
CAM.surface_prep(false, () => {
api.hide.alert(alert);
alert = api.show.alert("[esc] cancels surface selection");
for (let [wid, arr] of Object.entries(cylinders)) {
let widget = api.widgets.forid(wid);
if ( widget && arr?.length )
for (let faceid of arr) {
CAM.cylinderToggle(widget, faceid, (faceids) => {
// cylinders[widget.id] = faceids;
});
}
}
});
helicalOn = env.hoveredOp;
helicalOn.classList.add("editing");
api.feature.on_mouse_up = (obj, ev) => {
let { face } = obj;
let min = Math.min(face.a, face.b, face.c);
let faceid = min / 3;
let widget = (lastWidget = obj.object.widget);
CAM.cylinderToggle(widget, faceid, ({ faces, error }) => {
if ( error ) {
api.show.alert(error, 3000);
console.log( error );
return;
}
cylinders[widget.id] = faces;
});
};
};
export function helicalDone(){
if (!( helicalOn && env.poppedRec && env.poppedRec.cylinders )) {
return;
}
let { cylinders } = env.poppedRec;
for (let wid of Object.keys(cylinders)) {
let widget = api.widgets.forid(wid);
if (widget) {
CAM.cylinderClear(widget);
} else {
delete cylinders[wid];
}
}
api.hide.alert(alert);
api.feature.on_mouse_up = undefined;
helicalOn.classList.remove("editing");
helicalOn = false;
};

View file

@ -140,17 +140,7 @@ export async function selectHoles(individual) {
export function selectHolesHover(data) {
//not used right now. may be useful in the future
if (lastTrace) {
let { color, colorSave } = lastTrace.material[0] || lastTrace.material;
color.r = colorSave.r;
color.g = colorSave.g;
color.b = colorSave.b;
lastTrace.position.z -= 0.01;
}
if (data.type === 'platform') {
setLastTrace();
return;
}
}
export function selectHolesHoverUp(int, ev) {

View file

@ -6,6 +6,7 @@ import { env } from './client.js';
import { Tool } from './tool.js';
import { opFlip } from './cl-flip.js';
import { selectHoles } from './cl-hole.js';
import { selectHelical } from './cl-helical.js';
import { surfaceAdd } from './cl-surface.js';
import { traceAdd } from './cl-trace.js';
@ -524,6 +525,49 @@ export function createPopOps() {
thru: UC.newInput(LANG.cd_thru_s, { title: LANG.cd_thru_l, convert: UC.toFloat, units: true }),
};
createPopOp('helical', {
tool: 'camHelicalTool',
offset: 'camHelicalOffset',
spindle: 'camHelicalSpindle',
rate: 'camHelicalDownSpeed',
feed: 'camHelicalSpeed',
down: 'camHelicalDown',
finish: 'camHelicalBottomFinish',
startAng:'camHelicalStartAngle',
offOver: 'camHelicalOffsetOverride',
entry: 'camHelicalEntry',
entryOffset: 'camHelicalEntryOffset',
reverse: 'camHelicalReverse',
clockwise:'camHelicalClockwise',
thru: 'camHelicalThru',
forceStartAng:'camHelicalForceStartAngle',
fromTop: 'camHelicalFromStockTop',
}).inputs = {
tool: UC.newSelect(LANG.cc_tool, {}, "tools"),
offset: UC.newSelect(LANG.cc_offs_s, {title: LANG.cc_offs_l,}, "helicaloff"),
sep: UC.newBlank({class:"pop-sep"}),
spindle: UC.newInput(LANG.cc_spnd_s, {title:LANG.cc_spnd_l, convert:UC.toInt, show:hasSpindle}),
rate: UC.newInput(LANG.cc_plng_s, {title:LANG.cc_plng_l, convert:UC.toFloat, units:true}),
feed: UC.newInput(LANG.cc_feed_s, {title:LANG.cc_feed_l, convert:UC.toFloat, units:true}),
down: UC.newInput(LANG.ch_sdwn_s, {title:LANG.ch_sdwn_l, convert:UC.toFloat, units:true}),
startAng: UC.newInput(LANG.ch_stra_s, {title:LANG.ch_stra_l, convert:UC.toDegsFloat, bound:UC.bound(-360,360),show:() => env.poppedRec.forceStartAng}),
offOver: UC.newInput(LANG.cc_offd_s, {title:LANG.cc_offd_l, convert:UC.toFloat, units:true, bound:UC.bound(0,Infinity)}),
sep: UC.newBlank({class:"pop-sep"}),
entry: UC.newBoolean(LANG.ch_entr_s,undefined, {title:LANG.ch_entr_l}),
entryOffset: UC.newInput(LANG.ch_ento_s, {title:LANG.ch_ento_l, convert:UC.toFloat, units:true, show:() => env.poppedRec.entry}),
reverse: UC.newBoolean(LANG.ch_rvrs_s,undefined, {title:LANG.ch_rvrs_l}),
clockwise:UC.newBoolean(LANG.ch_clkw_s,undefined, {title:LANG.ch_clkw_l}),
sep: UC.newBlank({class:"pop-sep"}),
finish: UC.newBoolean(LANG.ch_fini_s,undefined, { title:LANG.ch_fini_l ,show: ()=>!env.poppedRec.reverse}),
forceStartAng: UC.newBoolean(LANG.ch_fsta_s, undefined, {title:LANG.ch_fsta_l }),
fromTop: UC.newBoolean(LANG.cd_ftop_s,undefined, {title:LANG.cd_ftop_l}),
sep: UC.newBlank({class:"pop-sep"}),
thru: UC.newInput(LANG.cd_thru_s, {title:LANG.cd_thru_l, convert:UC.toFloat, units:true}),
actions: UC.newRow([
UC.newButton(LANG.select, selectHelical, {title:LANG.cd_seli_l}),
], {class:"ext-buttons f-col"})
};
createPopOp('flip', {
axis: 'camFlipAxis',
invert: 'camFlipInvert'

View file

@ -14,6 +14,7 @@ import { tabAdd, tabDone, tabClear, restoreTabs, rotateTabs, updateTabs, clearTa
import { traceOn, traceDone, unselectTraces } from './cl-trace.js';
import { holeSelOn, selectHolesDone, clearHolesRec } from './cl-hole.js';
import { surfaceOn, surfaceDone } from './cl-surface.js';
import { helicalOn, helicalDone } from './cl-helical.js';
import { originSelectDone } from './cl-origin.js';
const DEG2RAD = Math.PI / 180;
@ -60,6 +61,8 @@ export function clearPops() {
surfaceDone();
tabDone();
traceDone();
selectHolesDone();
helicalDone();
}
function animFn() {
@ -172,6 +175,14 @@ const opAddPocket = () => {
opAdd(rec);
};
const opAddHelical = () => {
traceDone();
surfaceDone();
let rec = env.popOp.helical.new();
rec.cylinders = { /* widget.id: [ faces... ] */ };
opAdd(rec);
};
const opAddContour = (axis) => {
let rec = env.popOp.contour.new();
rec.axis = axis.toUpperCase();
@ -728,6 +739,7 @@ export function init() {
case "drill": return opAddDrill();
case "trace": return opAddTrace();
case "pocket": return opAddPocket();
case "helical": return opAddHelical();
case "flip":
// only one flip op permitted
for (let op of env.current.process.ops) {
@ -793,6 +805,9 @@ export function init() {
if (traceOn) {
traceDone();
}
if(helicalOn){
helicalDone();
}
unselectTraces(widget);
if (env.flipping) {
return;
@ -816,6 +831,9 @@ export function init() {
if (holeSelOn) {
selectHolesDone();
}
if(helicalOn){
helicalDone();
}
clearHolesRec(widget)
unselectTraces(widget);
if (env.flipping) {
@ -836,6 +854,9 @@ export function init() {
if (holeSelOn) {
selectHolesDone();
}
if(helicalOn){
helicalDone();
}
if (env.flipping) {
return;
}

View file

@ -5,6 +5,7 @@ import { cam_slice, holes, traces } from './slice.js';
import { cam_prepare } from './prepare.js';
import { cam_export } from './export.js';
import { tool as mesh_tool } from '../../../mesh/tool.js';
import { cylinder_poly_find } from './slice.js';
import { init as init_2d } from './anim-2d-be.js';
import { init as init_3d } from './anim-3d-be.js';
@ -97,6 +98,16 @@ function init(worker) {
shadowed: widget.shadowedDrills
} } )));
}
dispatch.cam_cylinder_find = async function(data,send){
const { id, face, settings } = data;
const widget = worker.cache[id];
try{
send.done(cylinder_poly_find(widget, face));
}catch(error){
send.done({error});
}
}
}
export const CAM = {

View file

@ -15,6 +15,10 @@ function surface_show(widget) {
widget.selectFaces(Object.values(widget._surfaces).flat());
};
function cylinder_show(widget) {
widget.selectFaces(Object.values(widget._cylinders).flat());
};
function surface_toggle(widget, face, radians, ondone) {
let surfaces = widget._surfaces = widget._surfaces || {};
for (let [root, faces] of Object.entries(surfaces)) {
@ -78,9 +82,44 @@ function holes(indiv, rec, onProgress, onDone) {
})
};
function cylinderShow(onProgress,onDone){
kiri.client.sync();
const settings = api.conf.get();
}
function cylinderToggle(widget, face, onDone){
let cyls = widget._cylinders = widget._cylinders || {};
for (let [root, faces] of Object.entries(cyls)) {
if (faces.contains(face)) { // if a face group contains the selected face
// delete this face group
delete cyls[root];
CAM.cylinder_show(widget);
onDone(Object.keys(cyls).map(v => parseInt(v)));
return;
}
}
//send
api.client.send("cam_cylinder_find", { id: widget.id, face }, ({faces,error}) => {
if (faces?.length) {
cyls[face] = faces;
CAM.cylinder_show(widget);
}
onDone({faces:Object.keys(cyls).map(v => parseInt(v)),error});
});
}
function cylinderClear(widget){
widget.selectFaces([]);
widget._cylinders = {};
}
export const CAM = {
surface_prep,
surface_show,
cylinder_show,
cylinderToggle,
cylinderClear,
surface_toggle,
surface_clear,
traces,

View file

@ -0,0 +1,278 @@
import { arcToPath } from "../../../geo/paths.js";
import { newPolygon } from "../../../geo/polygon.js";
import { CamOp } from "./op.js";
import { Tool } from "./tool.js";
import { cylinder_poly_find } from "./slice.js";
import { newPoint } from "../../../geo/point.js";
import { newSlice } from "../../core/slice.js";
export class OpHelical extends CamOp {
constructor(state, op) {
super(state, op);
}
async slice(progress) {
let { op, state } = this;
let { settings, addSlices, widget, updateToolDiams, slicer } = state;
let { color } = state;
let {
cylinders,
forceStartAng,
startAng,
fromTop,
offOver,
offset,
down,
thru,
finish,
clockwise,
reverse,
entry,
entryOffset,
} = op;
let {stock} = settings
let tool = new Tool(settings, op.tool),
toolDiam = tool.fluteDiameter(),
sliceOut = (this.sliceOut = []),
startAngle = forceStartAng ? startAng : 0;
updateToolDiams(toolDiam);
// console.log({cylinders})
let faces = cylinders[widget.id] ?? [];
if (faces.faces) faces = faces.faces;
let polys = [];
//iterate over selected faces
for (let [i,face] of faces.entries()) {
//get poly info for the cylinder
let res
try{
res = cylinder_poly_find(
widget,
face
);
}catch(error){
// don't quit if a cylinder errors out, just skip
console.error(error)
continue
}
let { zmin, zmax, diam, center, interior, faces } = res;
// console.log({ zmin, zmax, diam, center, interior });
let radius = diam >> 1;
zmax = fromTop ? stock.z: zmax;
let radAdd,
zBottom = zmin - thru,
numSegs = faces.length / 2,
poly = newPolygon().setOpen();
if (offOver) {
//apply offset override
if (offset == "auto") {
radAdd = interior ? -offOver : offOver;
} else if (offset == "inside") {
radAdd = -offOver;
} else if (offset == "outside") {
radAdd = offOver;
}
} else {
//otherwise, calculate based on offset type
if (offset == "auto") {
radAdd = (interior ? -toolDiam : toolDiam) / 2;
} else if (offset == "inside") {
radAdd = -toolDiam / 2;
} else if (offset == "outside") {
radAdd = toolDiam / 2;
}
}
radius += radAdd;
if( radius <= 0 ){
//if negative radius, skip this cylinder
console.log('negative radius', radius);
continue
}
let startPoint = center
.clone()
.setZ(zmax)
.add(
newPoint(
Math.cos(startAngle) * radius,
Math.sin(startAngle) * radius,
0
)
);
let currentZ = zmax;
for (;;) {
let bottom = currentZ - down;
if ( bottom > zBottom ) {
//if not at the bottom, do a whole helix
let start = startPoint.setZ(currentZ),
end = startPoint.clone().setZ(bottom);
// console.log(currentZ, bottom, [start.z, end.z]);
let path = arcToPath(start, end, numSegs, {
clockwise,
radius,
center,
});
// console.log({ path, start, end, radius, center });
poly.addPoints(path);
} else {
//if at the bottom, do a partial helix
let toBottom = currentZ - zBottom,
ofFull = toBottom / down,
ofCircle = ofFull * (2 * Math.PI),
cwMultiplier = clockwise ? -1 : 1,
finalAngle = startAngle + (ofCircle*cwMultiplier),
finalEnd = center
.clone()
.setZ(zBottom)
.add(
newPoint(
Math.cos(finalAngle) * radius,
Math.sin(finalAngle) * radius,
0
)
);
// console.log({down,toBottom,ofFull,ofCircle,startAngle,finalAngle,finalEnd});
//circle down to final point
poly.addPoints(
arcToPath(startPoint.setZ(currentZ), finalEnd, numSegs , {
clockwise,
radius,
center,
})
);
//if starting at the bottom, reverse poly
if ( reverse ){
poly.reverse();
}
if ( entry ){
//get first z of poly to use for entry
let firstZ = poly.first().z;
//create new pointArray
let entry =[newPoint(0,0,0)];
//add center point
let entryCenter = center.clone().setZ(firstZ)
//if doing a curved enter, add more
if ( entryOffset > 0 ){
if ( entryOffset > radius ){
console.error("entryOffset must be less than radius of helix");
}else{
//calculate entry center
let lineOut = radius - entryOffset;
let centerOffset = (entryOffset**2 + 2*entryOffset*lineOut) / (2*(entryOffset + lineOut));
//add entry points
entry.push(...
newPoint(0,-lineOut,0),
arcToPath(
newPoint(0,-lineOut,0),
newPoint(radius,0,0),
128,
{
clockwise:false,
radius:lineOut,
center:newPoint(centerOffset,0,0)
}
),
newPoint(radius,0,0),
)
}
}
//for each point
entry = entry.map(p => {
//rotate by start angle
// add to hole center
// TODO: there's totally a way to do this with a matrix
p.y *= clockwise ? 1 : -1
p.rotate(startAngle)
return p.add(entryCenter)
})
//append to start of poly
poly = newPolygon().setOpen().addPoints(entry).addPoints(poly.points);
}
//if doing a finish pass, do a full circle at the bottom
if ( finish && !reverse ){
poly.addPoints(
arcToPath(finalEnd, finalEnd, numSegs, {
clockwise,
radius,
center,
}),
);
poly.append(
finalEnd.clone().setZ(zBottom),
)
}
break;
}
currentZ = bottom;
}
progress(i/faces.length, "Helical intrerpolation");
polys.push(poly);
}
let slice = newSlice(0);
slice.camTrace = { tool: op.tool, rate: op.feed, plunge: op.rate };
slice.camLines = polys;
slice
.output()
.setLayer("Helical", { face: color, line: color })
.addPolys(slice.camLines);
addSlices(slice);
sliceOut.push(slice);
}
async prepare(ops, progress) {
let { op, state } = this;
let { settings, widget, addSlices, updateToolDiams } = state;
let {
setTool,
setSpindle,
polyEmit,
camOut,
setPrintPoint,
getPrintPoint,
} = ops;
let { tool, spindle, rate, feed } = op;
setTool(tool, feed, rate);
setSpindle(spindle);
let [polys] = this.sliceOut.map((s) => s.camLines);
let pp = getPrintPoint();
polys = polys.slice();
for (;;) {
let closestDist = Infinity,
closestI,
closest = null,
dist,
poly;
for (let i = 0; i < polys.length; i++) {
if (!polys[i]) continue;
if ((dist = polys[i].first().distTo2D(pp)) < closestDist) {
closestDist = dist;
closest = polys[i];
closestI = i;
}
}
if (!closest) break;
poly = polys[closestI]
polys[closestI] = null;
//emit
pp = polyEmit(poly,0,1,poly.points[0])
}
setPrintPoint(pp);
}
}

View file

@ -16,6 +16,7 @@ import { OpLaserOff } from './op-laser-off.js';
import { OpGCode } from './op-gcode.js';
import { OpIndex } from './op-index.js';
import { CamOp } from './op.js';
import { OpHelical } from './op-helical.js';
export const ops = {
"xray": OpXRay,
@ -29,6 +30,7 @@ export const ops = {
"trace": OpTrace,
"drill": OpDrill,
"register": OpRegister,
"helical": OpHelical,
"laser on": OpLaserOn,
"laser off": OpLaserOff,
"gcode": OpGCode,

View file

@ -332,7 +332,7 @@ export function prepEach(widget, settings, print, firstPoint, update) {
* @param {number} opts.factor speed scale factor
*/
function camOut(point, emit, opts) {
// console.log({point, emit, opts})
// console.trace({point, emit, opts})
let {
center = {},
clockwise = true,
@ -742,7 +742,8 @@ export function prepEach(widget, settings, print, firstPoint, update) {
function polyEmit(poly, index, count, fromPoint) {
let arcQ = [],
arcMax = Infinity, // no max arc radius
lineTolerance = 0.001; // do not consider points under 0.001mm for arcs
lineTolerance = 0.001, // do not consider points under 0.001mm for arcs
zTolerance = 0.1; // allow zs of Points to be off by 0.001mm per radian of rotation
fromPoint = fromPoint || printPoint;
arcQ.angle = []
@ -818,6 +819,18 @@ export function prepEach(widget, settings, print, firstPoint, update) {
radFault = true;
}
let endDelta = e1.distTo2D(e3)
if(endDelta < 0.01){
console.log("circleCompleteFault")
let last = arcQ.peek()
drainQ(true)
arcQ.push(last)
return e3
}
let ddz = Math.abs((e3.z - e4.z) - (e4.z - e5.z)) // take second derivitive of z
let zFault = Math.abs(ddz) > zTolerance
if (cc) {
if ([cc.x, cc.y, cc.z, cc.r].hasNaN()) {
// console.log({cc, e1, e2, e3});
@ -829,15 +842,15 @@ export function prepEach(widget, settings, print, firstPoint, update) {
arcQ.rSum = cc.r;
// check if first angles should have caused radFault
let a = toRadians(arcQ[0].slopeTo(cc).angle)
let b = toRadians(arcQ[1].slopeTo(cc).angle)
let angle = Math.abs(b - a)
if (Math.abs(angle) > arcRes) {
let angle = toRadians(arcQ[0].slopeTo(cc).angleDiff(arcQ[1].slopeTo(cc)).angle)
radFault = Math.abs(angle) > arcRes
if (radFault) {
// if so, remove first point
console.log("secondary radfault,",structuredClone(arcQ),{angle,arcRes,a,b})
camOut(arcQ.shift(), 1)
radFault = true
}
} else {
// check center point delta
arcQ.xSum = arcQ.center.reduce(function (t, v) { return t + v.x }, 0);
@ -848,10 +861,10 @@ export function prepEach(widget, settings, print, firstPoint, update) {
dc = Math.hypot(dx, dy); // delta center distance
}
// if new point is off the arc
// if (deem || depm || desp || dc > arcTolerance || cc.r < arcMin || cc.r > arcMax || dist > cc.r) {
if (dc * arcQ.center.length / arcQ.rSum > arcTolerance || dist > cc.r || cc.r > arcMax || radFault) {
// let debug = [deem, depm, desp, dc * arcQ.center.length / arcQ.rSum > arcTolerance, dist > cc.r, cc.r > arcMax, radFault];
// console.log("point off the arc,",structuredClone(arcQ),);
// if point is off-center, or too far from center, or too large of a radius
if (dc * arcQ.center.length / arcQ.rSum > arcTolerance || dist > cc.r || cc.r > arcMax || radFault || zFault) {
// let debug = [ dc * arcQ.center.length / arcQ.rSum > arcTolerance, dist > cc.r, cc.r > arcMax, radFault];
// console.log("point off the arc,",structuredClone(arcQ),radFault,zFault,[dc * arcQ.center.length / arcQ.rSum > arcTolerance , dist > cc.r , cc.r > arcMax]);
if (arcQ.length === 4) {
// not enough points for an arc, drop first point and recalc center
camOut(arcQ.shift(), 1);
@ -899,7 +912,12 @@ export function prepEach(widget, settings, print, firstPoint, update) {
return point;
}
function drainQ() {
/**
* Emits arcs and/or lines from the arcQ to the current point set.
* @param {boolean} forceCircle emits a single circle iven if poly is not closed.
*/
function drainQ(forceCircle = false) {
// console.trace("draining")
let arcPreviewRes = 64
if (!arcTolerance) {
@ -915,6 +933,7 @@ export function prepEach(widget, settings, print, firstPoint, update) {
let from = arcQ[0];
let to = arcQ.peek();
let delta = from.distTo2D(to)
let closed = poly.isClosed()
arcQ.xSum = arcQ.center.reduce((t, v) => t + v.x, 0);
arcQ.ySum = arcQ.center.reduce((t, v) => t + v.y, 0);
arcQ.rSum = arcQ.center.reduce((t, v) => t + v.r, 0);
@ -925,15 +944,20 @@ export function prepEach(widget, settings, print, firstPoint, update) {
)
// console.log("draining")
if (arcQ.length == poly.points.length) {
if (closed && arcQ.length == poly.points.length || forceCircle ) {
//if is a circle
// generate circle
// console.log("circle",{from, to,center});
let arcPoints = arcToPath(from, from, arcPreviewRes, { clockwise, center })
.map(applyWidgetMovement);
to = forceCircle? from.clone().setZ(to.z) : from
let arcPoints = arcToPath(from, to, arcPreviewRes, { clockwise, center })
.map(applyWidgetMovement);
// console.log({arcPoints})
camOut(from, 1);
camOut(from, gc, { center: center.sub(from), clockwise, arcPoints });
// lastPoint = to.clone();
camOut(to, gc, { center: center.sub(from), clockwise, arcPoints });
} else {
//if a non-circle arc
let arcPoints = arcToPath(from, to, arcPreviewRes, { clockwise, center })

View file

@ -9,6 +9,7 @@ import { setSliceTracker } from '../../core/slice.js';
import { ops as OPS } from './ops.js';
import { Tool } from './tool.js';
import { Slicer as cam_slicer } from './slicer.js';
import { CAM } from './driver-be.js';
/**
* DRIVER SLICE CONTRACT
@ -508,6 +509,123 @@ export async function traces(settings, widget) {
return true;
};
/**
* Returns an array of arrays of perpindicular triangles in the mesh.
* Each sub-array is a list of triangle data that are part of the same
* cylinder.
*
* @param {object} settings - settings object
* @param {object} widget - widget object
* @param {object} opts - options object
* @return {array} - array of arrays of triangle data
*/
export async function cylinders(settings, widget, opts){
let {} = opts ?? {}
let {array:verts} = widget.mesh.geometry.attributes.position
let perpTriangles = []
//iterate over all triangles
for(let i = 0; i < verts.length; i+=9){
let a = [verts[i], verts[i+1], verts[i+2]],
b = [verts[i+3], verts[i+4], verts[i+5]],
c = [verts[i+6], verts[i+7], verts[i+8]];
//calculate normal
let normal = new THREE.Vector3(a[0]-b[0], a[1]-b[1], a[2]-b[2])
.cross(new THREE.Vector3(b[0]-c[0], b[1]-c[1], b[2]-c[2]))
.normalize()
// if perpindicular normal, and at least 2 Zs are the same
if(normal.z.round(5) == 0 && ! (a[2] != b[2] && b[2] != c[2])){
let minZ = Math.min(a[2], b[2], c[2]),
maxZ = Math.max(a[2], b[2], c[2]);
if(minZ == maxZ){ // all Zs are the same indicated malformed geometry
continue;
}
perpTriangles.push({...[a,b,c], normal,minZ,maxZ,i:index})
}
}
//map where zmax, zmin -> triangleData
let cylinderTriangles = new Map()
for(let t of perpTriangles){
let hash = `${t.minZ.round(5)},${t.maxZ.round(5)}`
if(!cylinderTriangles.has(hash)){
cylinderTriangles.set(hash,[])
}
cylinderTriangles.get(hash).push(t)
}
return Array.from(cylinderTriangles.values())
}
/**
* Find all triangles that are part of the same cylinder as the given triangle
* @param {Widget} widget - widget object
* @param {number} face - index of face in triangle vertex data
* @return {array} - array of all triangle data that belong to the same cylinder
* @requires surface_prep must be called first
*/
export function cylinder_find (widget, face){
CAM.surface_prep(widget,false);
return widget.tool.findCylinderSurface(face);
}
export function cylinder_poly_find(widget, face){
let faces = cylinder_find(widget, face);
let vert = widget.getGeoVertices({ unroll: true, translate: true }).map(v => v.round(4));
let slicer = new cam_slicer(widget,{})
let opts = {
dedup:false,
edges: false,
over:true
}
let firstOffset = faces[0]*9
let [x1,y1,z1,x2,y2,z2,x3,y3,z3]= Array.from(vert.subarray(firstOffset,firstOffset+9))
let zs = [z1,z2,z3].map(z => z.round(5))
let zmin = Math.min(...zs);
let zmax = Math.max(...zs);
let zmid = (zmin+zmax)/2
let cylVerts = new Float32Array(faces.length*9)
let i = 0
//for each cylinder face index
for(let index of faces){
let off = index*9
//copy the 9 triangle vertices floats in
for(let j = 0;j < 9;j++){
cylVerts[i] = vert[off+j]
i++
}
}
//slice the poly at the midpoint
let poly = slicer.sliceZ(zmid, cylVerts,opts)?.polys?.at(0)
if(!poly) throw "slicing returned no poly"
let circular = poly.circularity() > 0.98;
//throw error if not circular
if(!circular) throw "faces must be circular"
//calculate circle properties
let area = poly.area(),
diam = Math.sqrt(area / Math.PI)* 2,
center = poly.calcCircleCenter();
//find direction using normal to center point
let point = newPoint(x1, y1, z1),
delta = point.setZ(center.z).sub(center),
normal = new THREE.Vector3(x1-x2, y1-y2, z1-z2)
.cross(new THREE.Vector3(x2-x3, y2-y3, z2-z3)),
dotProd = normal.dot(delta),
interior = dotProd < 0
return {faces, zmin, zmax, poly, circular, area, diam, center,interior}
}
/**
* Generate a list of holes in the model based on the given diameter.
*

View file

@ -233,11 +233,20 @@ class MeshTool {
fn++;
}
}
//faces are the normals in groups of 6 [nx,]
this.indexed = {
faces, sides, sideExt
};
}
/**
* Retrieves the adjacent faces for a given face index.
*
* @param {number} face - The index of the face for which adjacent faces are to be found.
* @returns {number[]} An array of face indices that are adjacent to the given face.
*/
getAdjacentFaces(face) {
const { faces, sides } = this.getIndex();
const foff = face * 6;
@ -294,6 +303,88 @@ class MeshTool {
return faces;
}
/**
* Finds all faces in a cylinder surface connected to the given face.
*
* @param {number} face - the face index to start from
* @return {number[]} face indices in the cylinder surface
*
* @throws {string} if the face has only one Z value
* @throws {string} if the face normal is not perpendicular to Z axis
*
* The algorithm works by:
* 1. Finding the two distinct Z values of the given face
* 2. Filtering adjacent faces to those with the same Z values
* 3. Filtering faces with normals perpendicular to Z axis
* 4. Recursively adding adjacent faces with the same Z values and normal perpendicular to Z axis
*/
findCylinderSurface(face){
face = Number(face)
const radianTolerance = 8 * (Math.PI/180);
const vertexTolerance = 1e-4;
//helper function
const dissimilar = (a,b,c) => Math.abs(a-b) > vertexTolerance &&
Math.abs(b-c) > vertexTolerance &&
Math.abs(a-c) > vertexTolerance;
let found = {},
out = [face],
{vertices} = this,
{faces} = this.getIndex(),
faceOffset = face*9,
zs = [2,5,8].map(i => vertices[faceOffset+i].round(5)), //get zs from face vertices
lowZ = Math.min(zs),
highZ = Math.max(zs),
checked = {},
check = [face];
//check for errors, and throw if found
if(dissimilar(...zs)){
throw `face must have only 2 Z values. found: ${zs.join(", ")}`
}else if(Math.abs( faces[face * 6 + 2] > 1e-6 )){
throw "face's normal must be perpendicular to Z axis"
}
found[face] = 1;
while (check.length) {
const face = check.shift();
const froot = face * 6;
const vroot = face * 9
// filter faces to those perpendicular to +z axis
if ( Math.abs( faces[froot + 2] > 1e-6 )) continue;
//filter faces with verts don't share the z values of the first face
if(
!(vertices[vroot + 2] !== lowZ || vertices[vroot + 2] !== highZ) &&
!(vertices[vroot + 5] !== lowZ || vertices[vroot + 5] !== highZ) &&
!(vertices[vroot + 8] !== lowZ || vertices[vroot + 8] !== highZ)
) continue;
const fadj = this.getAdjacentFaces(face);
for (let f of fadj) {
if (found[f] || checked[f]) {
continue;
}
const aroot = f * 6;
let sum = 0;
for (let i=0; i<3; i++) {
sum += Math.pow(faces[froot + i] - faces[aroot + i], 2);
}
const fn = Math.sqrt(sum);
if (fn <= radianTolerance) {
out.push(f);
check.push(f)
checked[f] = 1;
found[f] = 1;
}
}
}
return out;
}
// given a list of faces (indices), return an array of closed
// polylines that represent the outlines of each discrete island
generateOutlines(list) {
const { faces, sides } = this.getIndex();
const prec = this.precision;

View file

@ -437,6 +437,7 @@
<div title="clean up after roughing&#13;or perform a part cutout">outline</div>
<div title="2.5D tracing along X or Y axis&#13;used for complex part features">contour</div>
<div title="clear areas above&#13;selected surfaces">pocket</div>
<div title="follow a helix&#13;around circular paths">helical</div>
</div>
</div>
</div>

View file

@ -608,6 +608,26 @@ self.lang['en-us'] = {
cd_sela_s: "Select Matching Diameter",
cd_sela_l: ["select all holes that", "match selected tool diameter"],
// CNC HELICAL tolpath
ch_menu: "helical",
ch_sdwn_s: "step down (pitch)",
ch_sdwn_l: ["z-distance","per revolution","in workspace units","(inverse of threads per unit)", "0 to disable"],
ch_fsta_s: "force start angle",
ch_fsta_l: ["override start angle","defaults to closest point to tool"],
ch_stra_s: "start angle",
ch_stra_l: ["start angle of helix","in degrees ccw of +x axis"],
ch_selc_l: ["select a cylinder face", "inner holes that are too small","will be ignored."],
ch_fini_s: "bottom finish pass",
ch_fini_l: ["trace a circle at bottom of helix","used for hole clearing"],
ch_entr_s: "enter from center",
ch_entr_l: ["start cut from center of cylinder","gently arcing in horisontaly", "instead of plunging"],
ch_ento_s: "center entry offset",
ch_ento_l: ["offset from path for curved entry","usually thread depth if threadmilling","in workspace units","0 for a linear entry"],
ch_rvrs_s: "reverse",
ch_rvrs_l: ["reverse helical z-axis direction","low-Z to high-Z when checked","useful for thread milling","not recomended for other operations"],
ch_clkw_s: "clockwise",
ch_clkw_l: ["reverse helical rotation around cylinder","clockwise when checked"],
// CNC FLIP
cf_menu: "flip",
cf_nvrt_s: "z bottom",
@ -643,6 +663,10 @@ self.lang['en-us'] = {
cl_maxz_s: "max z height",
cl_maxz_l: ["highest Z band position. when set to 0, uses the highest Z point of the workspace"],
// CNC CUTOUT TABS
ct_menu: "tabs",
ct_angl_s: "angle",