diff --git a/src/kiri/app/inputs.js b/src/kiri/app/inputs.js index a2204af7..d87dd2f0 100644 --- a/src/kiri/app/inputs.js +++ b/src/kiri/app/inputs.js @@ -794,6 +794,9 @@ function newInput(label, opt = {}) { if (opt.trigger) { refresh(opt.trigger === 1 || opt.trigger === true); } + if (opt.post) { + opt.post(); + } }); if (opt.units) { addUnits(ip, opt.round || 3); diff --git a/src/kiri/mode/cam/cl-ops.js b/src/kiri/mode/cam/cl-ops.js index 7849a713..199533b8 100644 --- a/src/kiri/mode/cam/cl-ops.js +++ b/src/kiri/mode/cam/cl-ops.js @@ -661,6 +661,14 @@ export function createPopOps() { createPopOp('|', {}).inputs = {}; + createPopOp('loop', { + repeat: 2, + count: 1 + }).inputs = { + repeat: UC.newInput('repeat', { title: "repeat count", convert: toInt, bound: UC.bound(1, 100), post: opRender }), + count: UC.newInput('operations', { title: "num operations", convert: toInt, bound: UC.bound(1, 20), post: opRender }) + }; + function isClear() { return env.poppedRec.mode === 'clear'; } diff --git a/src/kiri/mode/cam/init-ui.js b/src/kiri/mode/cam/init-ui.js index 81a2ed94..19d1d7b4 100644 --- a/src/kiri/mode/cam/init-ui.js +++ b/src/kiri/mode/cam/init-ui.js @@ -165,6 +165,10 @@ const opAddLevel = () => { opAdd(env.popOp.level.new()); }; +const opAddLoop = () => { + opAdd(env.popOp.loop.new()); +}; + const opAddRough = () => { opAdd(env.popOp.rough.new()); }; @@ -287,8 +291,33 @@ export function opRender() { oplist.forEach((rec, i) => { let title = ''; let clock = rec.type === '|'; + let isLoop = rec.type === 'loop'; let label = clock ? `` : (rec.mode ? rec.type + ' ' + rec.mode : rec.type); let clazz = notime ? ["draggable", "notime"] : ["draggable"]; + + // Loop operation label and styling + if (isLoop) { + label = `loop ${rec.repeat || 2}x`; + clazz.push('loop'); + title = ` title="loop: repeat next ${rec.count || 1} operation(s) ${rec.repeat || 2} time(s)"`; + } + + // Check if this operation is within N operations after a loop + let inLoopRange = false; + for (let j = i - 1; j >= 0; j--) { + if (oplist[j].type === 'loop') { + let loopCount = oplist[j].count || 1; + let distanceFromLoop = i - j; + if (distanceFromLoop <= loopCount && distanceFromLoop > 0) { + inLoopRange = true; + break; + } + } + } + if (inLoopRange) { + clazz.push('in-loop'); + } + let notable = rec.note ? rec.note.split(' ').filter(v => v.charAt(0) === '#') : undefined; if (clock) { clazz.push('clock'); title = ` title="end of ops chain\ndrag/drop like an op\nops after this are disabled"` } if (notable?.length) { @@ -755,6 +784,7 @@ export function init() { case "laser off": return opAddLaserOff(); case "gcode": return opAddGCode(); case "level": return opAddLevel(); + case "loop": return opAddLoop(); case "rough": return opAddRough(); case "outline": return opAddOutline(); case "contour": diff --git a/src/kiri/mode/cam/op-loop.js b/src/kiri/mode/cam/op-loop.js new file mode 100644 index 00000000..06e16fe9 --- /dev/null +++ b/src/kiri/mode/cam/op-loop.js @@ -0,0 +1,17 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { CamOp } from './op.js'; + +export class OpLoop extends CamOp { + weight() { + return 0; // No weight, acts as marker + } + + async slice() { + // No-op: expansion happens in slice.js + } + + prepare() { + // No-op: ignored during g-code generation + } +} diff --git a/src/kiri/mode/cam/ops.js b/src/kiri/mode/cam/ops.js index 79e4c7f0..3685fd1e 100644 --- a/src/kiri/mode/cam/ops.js +++ b/src/kiri/mode/cam/ops.js @@ -11,6 +11,7 @@ import { OpLaserOff } from './op-laser-off.js'; import { OpLaserOn } from './op-laser-on.js'; import { OpLathe } from './op-lathe.js'; import { OpLevel } from './op-level.js'; +import { OpLoop } from './op-loop.js'; import { OpOutline } from './op-outline.js'; import { OpPocket } from './op-pocket.js'; import { OpRegister } from './op-register.js'; @@ -31,6 +32,7 @@ export const ops = { "laser on": OpLaserOn, "lathe": OpLathe, "level": OpLevel, + "loop": OpLoop, "outline": OpOutline, "pocket": OpPocket, "register": OpRegister, diff --git a/src/kiri/mode/cam/prepare.js b/src/kiri/mode/cam/prepare.js index 4973c239..a1d645c7 100644 --- a/src/kiri/mode/cam/prepare.js +++ b/src/kiri/mode/cam/prepare.js @@ -990,6 +990,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // pre-flight check of ops for (let op of widget.camops) { + // Skip loop operations (they're expanded during slice) + if (op.op.type === 'loop') { + continue; + } opTot += op.weight(); // ensure tool related parameters are available // for the first index call when no tool is specified @@ -999,6 +1003,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } for (let op of widget.camops) { + // Skip loop operations (they're expanded during slice) + if (op.op.type === 'loop') { + continue; + } contouring = false; lasering = false; let cop = currentOp = op.op; diff --git a/src/kiri/mode/cam/slice.js b/src/kiri/mode/cam/slice.js index 9cd12de4..2fcf67ad 100644 --- a/src/kiri/mode/cam/slice.js +++ b/src/kiri/mode/cam/slice.js @@ -324,6 +324,52 @@ export async function cam_slice(settings, widget, onupdate, ondone) { opTot += opList.peek().weight(); } + // LOOP EXPANSION - duplicate next N operations M times + let expandedOps = []; + let i = 0; + + while (i < activeOps.length) { + let op = activeOps[i]; + + if (op.type === 'loop') { + let repeatCount = op.repeat || 2; // M times + let opCount = op.count || 1; // N operations + + // Validate we have enough operations + if (i + opCount >= activeOps.length) { + return error(`Loop operation requires ${opCount} following operation(s), but only ${activeOps.length - i - 1} available`); + } + + // Extract next N operations + let loopOps = activeOps.slice(i + 1, i + 1 + opCount); + + // Check for nested loops + for (let loopOp of loopOps) { + if (loopOp.type === 'loop') { + return error("Nested loops are not supported"); + } + } + + // Duplicate operations M times + for (let r = 0; r < repeatCount; r++) { + for (let loopOp of loopOps) { + let clonedOp = Object.clone(loopOp); + expandedOps.push(clonedOp); + } + } + + // Skip the loop operation itself and the N operations + i += 1 + opCount; + } else if (op.type === '|') { + break; // Clock operation + } else { + expandedOps.push(op); + i++; + } + } + + activeOps = expandedOps; + // determine # of steps and step weighting for progress bar for (let op of activeOps) { if (op.type === '|') { diff --git a/web/kiri/index.css b/web/kiri/index.css index 60b2a680..d3520e7a 100644 --- a/web/kiri/index.css +++ b/web/kiri/index.css @@ -1448,6 +1448,21 @@ details[open] summary::after { #oplist .clock:hover { background-color: var(--blue-2); } +#oplist .loop:hover { + background-color: rgba(100, 150, 255, 0.35); +} +#oplist .in-loop { + margin-left: 10px; +} +#oplist .in-loop::before { + content: ""; + border: 1px solid var(--blue-2); + border-radius: 3px; + width: 2px; + height: 80%; + position: absolute; + left: -10px; +} #oplist > div { padding: 4px 20px 4px 7px !important; } diff --git a/web/kiri/index.html b/web/kiri/index.html index c0890b03..795e25da 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -471,6 +471,7 @@
rough
outline
area
+
loop