UNPKG

saxi

Version:

Drive the AxiDraw pen plotter

424 lines 15.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Plan = exports.XYMotion = exports.PenMotion = exports.Block = exports.AxidrawBrushlessFast = exports.AxidrawFast = exports.Device = exports.defaultPlanOptions = void 0; exports.plan = plan; const paper_size_1 = require("./paper-size"); const vec_1 = require("./vec"); const epsilon = 1e-9; exports.defaultPlanOptions = { penUpHeight: 50, penDownHeight: 60, pointJoinRadius: 0, pathJoinRadius: 0.5, paperSize: paper_size_1.PaperSize.standard.ArchA.landscape, marginMm: 20, selectedGroupLayers: new Set(), selectedStrokeLayers: new Set(), layerMode: 'stroke', penDownAcceleration: 200, penDownMaxVelocity: 50, penDownCorneringFactor: 0.127, penUpAcceleration: 400, penUpMaxVelocity: 200, penDropDuration: 0.12, penLiftDuration: 0.12, sortPaths: true, rotateDrawing: 0, fitPage: true, cropToMargins: true, minimumPathLength: 0, hardware: 'v3' }; const Device = (hardware = 'v3') => { if (hardware === 'brushless') return AxidrawBrushless; return Axidraw; }; exports.Device = Device; // Defaults: penup at 12000 (1ms), pendown at 16000 (1.33ms). const Axidraw = { stepsPerMm: 5, penServoMin: 7500, // pen down penServoMax: 28000, // pen up penPctToPos(pct) { const t = pct / 100.0; return Math.round(this.penServoMin * t + this.penServoMax * (1 - t)); } }; // brushless servo (https://shop.evilmadscientist.com/productsmenu/else?id=56) const AxidrawBrushless = { stepsPerMm: 5, penServoMin: 5400, // pen down penServoMax: 12600, // pen up penPctToPos(pct) { const t = pct / 100.0; return Math.round(this.penServoMin * t + this.penServoMax * (1 - t)); } }; exports.AxidrawFast = { penDownProfile: { acceleration: 200 * Axidraw.stepsPerMm, maximumVelocity: 50 * Axidraw.stepsPerMm, corneringFactor: 0.127 * Axidraw.stepsPerMm }, penUpProfile: { acceleration: 400 * Axidraw.stepsPerMm, maximumVelocity: 200 * Axidraw.stepsPerMm, corneringFactor: 0 }, penUpPos: Axidraw.penPctToPos(50), penDownPos: Axidraw.penPctToPos(60), penDropDuration: 0.12, penLiftDuration: 0.12 }; exports.AxidrawBrushlessFast = { penDownProfile: { acceleration: 200 * AxidrawBrushless.stepsPerMm, maximumVelocity: 50 * AxidrawBrushless.stepsPerMm, corneringFactor: 0.127 * AxidrawBrushless.stepsPerMm }, penUpProfile: { acceleration: 400 * AxidrawBrushless.stepsPerMm, maximumVelocity: 200 * AxidrawBrushless.stepsPerMm, corneringFactor: 0 }, penUpPos: AxidrawBrushless.penPctToPos(50), penDownPos: AxidrawBrushless.penPctToPos(60), penDropDuration: 0.08, penLiftDuration: 0.08 }; class Block { static deserialize(o) { return new Block(o.accel, o.duration, o.vInitial, o.p1, o.p2); } constructor(accel, duration, vInitial, p1, p2) { if (!(vInitial >= 0)) { throw new Error(`vInitial must be >= 0, but was ${vInitial}`); } if (!(vInitial + accel * duration >= -epsilon)) { throw new Error(`vFinal must be >= 0, but vInitial=${vInitial}, duration=${duration}, accel=${accel}`); } this.accel = accel; this.duration = duration; this.vInitial = vInitial; this.p1 = p1; this.p2 = p2; this.distance = (0, vec_1.vlen)((0, vec_1.vsub)(p1, p2)); } get vFinal() { return Math.max(0, this.vInitial + this.accel * this.duration); } instant(tU, dt = 0, ds = 0) { const t = Math.max(0, Math.min(this.duration, tU)); const a = this.accel; const v = this.vInitial + this.accel * t; const s = Math.max(0, Math.min(this.distance, this.vInitial * t + a * t * t / 2)); const p = (0, vec_1.vadd)(this.p1, (0, vec_1.vmul)((0, vec_1.vnorm)((0, vec_1.vsub)(this.p2, this.p1)), s)); return { t: t + dt, p, s: s + ds, v, a }; } serialize() { return { accel: this.accel, duration: this.duration, vInitial: this.vInitial, p1: this.p1, p2: this.p2, }; } } exports.Block = Block; class PenMotion { static deserialize(o) { return new PenMotion(o.initialPos, o.finalPos, o.duration); } constructor(initialPos, finalPos, duration) { this.initialPos = initialPos; this.finalPos = finalPos; this.pDuration = duration; } duration() { return this.pDuration; } serialize() { return { t: "PenMotion", initialPos: this.initialPos, finalPos: this.finalPos, duration: this.pDuration, }; } } exports.PenMotion = PenMotion; function scanLeft(a, z, op) { const b = []; let acc = z; b.push(acc); for (const x of a) { acc = op(acc, x); b.push(acc); } return b; } function sortedIndex(array, obj) { let low = 0; let high = array.length; while (low < high) { const mid = Math.floor((low + high) / 2); if (array[mid] < obj) { low = mid + 1; } else { high = mid; } } return low; } class XYMotion { static deserialize(o) { return new XYMotion(o.blocks.map(Block.deserialize)); } constructor(blocks) { this.blocks = blocks; this.ts = scanLeft(blocks.map((b) => b.duration), 0, (a, b) => a + b).slice(0, -1); this.ss = scanLeft(blocks.map((b) => b.distance), 0, (a, b) => a + b).slice(0, -1); } get p1() { return this.blocks[0].p1; } get p2() { return this.blocks[this.blocks.length - 1].p2; } duration() { return this.blocks.map((b) => b.duration).reduce((a, b) => a + b, 0); } instant(t) { const idx = sortedIndex(this.ts, t); const blockIdx = this.ts[idx] === t ? idx : idx - 1; const block = this.blocks[blockIdx]; return block.instant(t - this.ts[blockIdx], this.ts[blockIdx], this.ss[blockIdx]); } serialize() { return { t: "XYMotion", blocks: this.blocks.map((b) => b.serialize()) }; } } exports.XYMotion = XYMotion; class Plan { static deserialize(o) { return new Plan(o.motions.map((m) => { switch (m.t) { case "XYMotion": return XYMotion.deserialize(m); case "PenMotion": return PenMotion.deserialize(m); } })); } constructor(motions) { this.motions = motions; } duration(start = 0) { return this.motions.slice(start).map((m) => m.duration()).reduce((a, b) => a + b, 0); } motion(i) { return this.motions[i]; } withPenHeights(penUpHeight, penDownHeight) { let penMotionIndex = 0; return new Plan(this.motions.map((motion, j) => { if (motion instanceof XYMotion) { return motion; } else if (motion instanceof PenMotion) { // TODO: Remove this hack by storing the pen-up/pen-down heights // in a single place, and reference them from the PenMotions. if (j === this.motions.length - 1) { return new PenMotion(penDownHeight, penUpHeight, motion.duration()); } return (penMotionIndex++ % 2 === 0 ? new PenMotion(penUpHeight, penDownHeight, motion.duration()) : new PenMotion(penDownHeight, penUpHeight, motion.duration())); } })); } serialize() { return { motions: this.motions.map((m) => m.serialize()) }; } } exports.Plan = Plan; class Segment { constructor(p1, p2) { this.maxEntryVelocity = 0; this.entryVelocity = 0; this.p1 = p1; this.p2 = p2; this.blocks = []; } length() { return (0, vec_1.vlen)((0, vec_1.vsub)(this.p2, this.p1)); } direction() { return (0, vec_1.vnorm)((0, vec_1.vsub)(this.p2, this.p1)); } } function cornerVelocity(seg1, seg2, vMax, accel, cornerFactor) { // https://onehossshay.wordpress.com/2011/09/24/improving_grbl_cornering_algorithm/ const cosine = -(0, vec_1.vdot)(seg1.direction(), seg2.direction()); // assert(!cosine.isNaN, s"cosine was NaN: $seg1, $seg2, ${seg1.direction}, ${seg2.direction}") if (Math.abs(cosine - 1) < epsilon) { return 0; } const sine = Math.sqrt((1 - cosine) / 2); if (Math.abs(sine - 1) < epsilon) { return vMax; } const v = Math.sqrt((accel * cornerFactor * sine) / (1 - sine)); // assert(!v.isNaN, s"v was NaN: $accel, $cornerFactor, $sine") return Math.min(v, vMax); } /** Compute a triangular velocity profile with piecewise constant acceleration. * * The maximum velocity is derived from the acceleration and the distance to be travelled. * * @param distance Distance to travel (equal to |p3-p1|). * @param initialVel Starting velocity, unit length per unit time. * @param finalVel Final velocity, unit length per unit time. * @param accel Magnitude of acceleration, unit length per unit time per unit time. * @param p1 Starting point. * @param p3 Ending point. * @return */ function computeTriangle(distance, initialVel, finalVel, accel, p1, p3) { const acceleratingDistance = (2 * accel * distance + finalVel * finalVel - initialVel * initialVel) / (4 * accel); const deceleratingDistance = distance - acceleratingDistance; const vMax = Math.sqrt(initialVel * initialVel + 2 * accel * acceleratingDistance); const t1 = (vMax - initialVel) / accel; const t2 = (finalVel - vMax) / -accel; const p2 = (0, vec_1.vadd)(p1, (0, vec_1.vmul)((0, vec_1.vnorm)((0, vec_1.vsub)(p3, p1)), acceleratingDistance)); return { s1: acceleratingDistance, s2: deceleratingDistance, t1, t2, vMax, p1, p2, p3 }; } function computeTrapezoid(distance, initialVel, maxVel, finalVel, accel, p1, p4) { const t1 = (maxVel - initialVel) / accel; const s1 = (maxVel + initialVel) / 2 * t1; const t3 = (finalVel - maxVel) / -accel; const s3 = (finalVel + maxVel) / 2 * t3; const s2 = distance - s1 - s3; const t2 = s2 / maxVel; const dir = (0, vec_1.vnorm)((0, vec_1.vsub)(p4, p1)); const p2 = (0, vec_1.vadd)(p1, (0, vec_1.vmul)(dir, s1)); const p3 = (0, vec_1.vadd)(p1, (0, vec_1.vmul)(dir, (distance - s3))); return { s1, s2, s3, t1, t2, t3, p1, p2, p3, p4 }; } function dedupPoints(points, epsilon) { if (epsilon === 0) { return points; } const dedupedPoints = []; dedupedPoints.push(points[0]); for (const p of points.slice(1)) { if ((0, vec_1.vlen)((0, vec_1.vsub)(p, dedupedPoints[dedupedPoints.length - 1])) > epsilon) { dedupedPoints.push(p); } } return dedupedPoints; } /** * Plan a path, using a constant acceleration profile. * This function plans only a single x/y motion of the tool, * i.e. between a single pen-down/pen-up pair. * * @param points Sequence of points to pass through * @param profile Tooling profile to use * @return A plan of action */ function constantAccelerationPlan(points, profile) { const dedupedPoints = dedupPoints(points, epsilon); if (dedupedPoints.length === 1) { return new XYMotion([new Block(0, 0, 0, dedupedPoints[0], dedupedPoints[0])]); } const segments = dedupedPoints.slice(1).map((a, i) => new Segment(dedupedPoints[i], a)); const accel = profile.acceleration; const vMax = profile.maximumVelocity; const cornerFactor = profile.corneringFactor; // Calculate the maximum entry velocity for each segment based on the angle between it // and the previous segment. segments.slice(1).forEach((seg2, i) => { const seg1 = segments[i]; seg2.maxEntryVelocity = cornerVelocity(seg1, seg2, vMax, accel, cornerFactor); }); // This is to force the velocity to zero at the end of the path. const lastPoint = dedupedPoints[dedupedPoints.length - 1]; segments.push(new Segment(lastPoint, lastPoint)); let i = 0; while (i < segments.length - 1) { const segment = segments[i]; const nextSegment = segments[i + 1]; const distance = segment.length(); const vInitial = segment.entryVelocity; const vExit = nextSegment.maxEntryVelocity; const p1 = segment.p1; const p2 = segment.p2; const m = computeTriangle(distance, vInitial, vExit, accel, p1, p2); if (m.s1 < -epsilon) { // We'd have to start decelerating _before we started on this segment_. backtrack. // In order enter this segment slow enough to be leaving it at vExit, we need to // compute a maximum entry velocity s.t. we can slow down in the distance we have. // TODO: verify this equation. segment.maxEntryVelocity = Math.sqrt(vExit * vExit + 2 * accel * distance); i -= 1; } else if (m.s2 <= 0) { // No deceleration. // TODO: shouldn't we check vMax here and maybe do trapezoid? should the next case below come first? const vFinal = Math.sqrt(vInitial * vInitial + 2 * accel * distance); const t = (vFinal - vInitial) / accel; segment.blocks = [ new Block(accel, t, vInitial, p1, p2) ]; nextSegment.entryVelocity = vFinal; i += 1; } else if (m.vMax > vMax) { // Triangle profile would exceed maximum velocity, so top out at vMax. const z = computeTrapezoid(distance, vInitial, vMax, vExit, accel, p1, p2); segment.blocks = [ new Block(accel, z.t1, vInitial, z.p1, z.p2), new Block(0, z.t2, vMax, z.p2, z.p3), new Block(-accel, z.t3, vMax, z.p3, z.p4) ]; nextSegment.entryVelocity = vExit; i += 1; } else { // Accelerate, then decelerate. segment.blocks = [ new Block(accel, m.t1, vInitial, m.p1, m.p2), new Block(-accel, m.t2, m.vMax, m.p2, m.p3) ]; nextSegment.entryVelocity = vExit; i += 1; } } const blocks = []; segments.forEach((s) => { s.blocks.forEach((b) => { if (b.duration > epsilon) { blocks.push(b); } }); }); return new XYMotion(blocks); } function plan(paths, profile) { const motions = []; let curPos = { x: 0, y: 0 }; const penMotions = { up: new PenMotion(profile.penDownPos, profile.penUpPos, profile.penLiftDuration), down: new PenMotion(profile.penUpPos, profile.penDownPos, profile.penDropDuration) }; // For each path - move to the initial position, put the pen down, draw the path, bring pen up paths.forEach(path => { const motion = constantAccelerationPlan(path, profile.penDownProfile); const position = constantAccelerationPlan([curPos, motion.p1], profile.penUpProfile); motions.push(position, penMotions.down, motion, penMotions.up); curPos = motion.p2; }); // Move to {x: 0, y: 0} motions.push(constantAccelerationPlan([curPos, { x: 0, y: 0 }], profile.penUpProfile)); return new Plan(motions); } //# sourceMappingURL=planning.js.map