CI Log in

zach/dotter

src/motion.js 373 lines · 13.0 KB · javascript
1
// Trajectory planning (port of nextdrawcore/motion.py).
2
//
3
// Moves are returned as lists of commands for the dripfeed:
4
//   ['lower'] / ['raise']
5
//   ['T3', [time, v1, a1, j1, v2, a2, j2], [inches, xyzPosAfter]]
6
//   ['TD', [time, v1a, v1b, a1, j1, v2a, v2b, a2, j2], [inches, xyzPosAfter]]
7
//
8
// Native motor axes on the NextDraw are mixed:
9
//   motor1 = x + y, motor2 = x - y.
10
11
import { checkLimitsTol, distance, dotProductXY, isClose, moveDistT3, pyRound, rateT3 } from './calc.js';
12
import * as plan from './plan.js';
13
14
// Rounded-up maximum distance covered by one step (low-res / hi-res).
15
const MAX_STEP_DIST_LR = 0.000696;
16
const MAX_STEP_DIST_HR = 0.000348;
17
18
// The Python planner stores these values in array('f'), i.e. float32.
19
const f32 = Math.fround;
20
21
export function copyPos(pos) {
22
  return { ...pos };
23
}
24
25
/** Plan a full pen-down path: lower pen, follow vertices, raise pen. */
26
export function trajectory(nd, vertexList, xyzPos = null) {
27
  if (xyzPos == null) xyzPos = copyPos(nd.pen.phys);
28
  xyzPos.zUp = false;
29
  const middle = planTrajectory(nd, vertexList, xyzPos);
30
  if (middle == null) return null;
31
  return [['lower'], ...middle, ['raise']];
32
}
33
34
/** Plan motion through a vertex list with junction-speed look-ahead. */
35
export function planTrajectory(nd, vertexList, xyzPos = null) {
36
  let trajLength = vertexList.length;
37
  if (trajLength < 2) return null;
38
  if (nd.pen.phys.xpos == null) return null;
39
  if (xyzPos == null) xyzPos = copyPos(nd.pen.phys);
40
41
  const penUp = xyzPos.zUp;
42
43
  if (trajLength < 3) {
44
    const [moves] = computeSegment(nd, [vertexList[1][0], vertexList[1][1], 0, 0, false], xyzPos);
45
    return moves ?? [];
46
  }
47
48
  const speedLimit = penUp ? nd.speedPenup : nd.speedPendown;
49
50
  const trajDists = [f32(0.0)];
51
  const trajVels = [f32(0.0)];
52
  const trajVectors = [];
53
  const trimmedPath = [];
54
55
  const minDist = nd.params.resolution === 1 ? MAX_STEP_DIST_HR : MAX_STEP_DIST_LR;
56
57
  let lastIndex = 0;
58
  for (let i = 1; i < trajLength; i++) {
59
    // Skip segments likely to be shorter than one motor step.
60
    const dx = vertexList[i][0] - vertexList[lastIndex][0];
61
    const dy = vertexList[i][1] - vertexList[lastIndex][1];
62
    const d = distance(dx, dy);
63
    if (d >= minDist) {
64
      trajDists.push(f32(d));
65
      trajVectors.push([dx / d, dy / d]);
66
      trimmedPath.push([vertexList[i][0], vertexList[i][1]]);
67
      lastIndex = i;
68
    }
69
  }
70
71
  trajLength = trajDists.length;
72
  if (trajLength < 2) return null;
73
74
  if (trajLength < 3) {
75
    // Only one usable segment remains. (The Python driver returns the raw
76
    // (move_list, data_list) tuple here, which the dripfeed then silently
77
    // ignores, so the line is never drawn. We draw it.)
78
    const [moves] = computeSegment(nd, [trimmedPath[0][0], trimmedPath[0][1], 0, 0, false], xyzPos);
79
    return moves ?? [];
80
  }
81
82
  const [jerkUp, jerkDown] = plan.calcJerk(nd);
83
  const jerkRate = penUp ? jerkUp : jerkDown;
84
85
  // Forward pass: speed limits at each vertex from jerk and cornering.
86
  for (let i = 1; i < trajLength - 1; i++) {
87
    const segLength = trajDists[i];
88
    const vPrevExit = trajVels[i - 1];
89
90
    let vMax = plan.scurvePlan(vPrevExit, speedLimit, jerkRate, segLength, 0.007);
91
    if (vMax == null) continue;
92
93
    let vec = trajVectors[i - 1];
94
    let len = distance(vec[0], vec[1]);
95
    const v1 = [vec[0] / len, vec[1] / len];
96
    vec = trajVectors[i];
97
    len = distance(vec[0], vec[1]);
98
    const v2 = [vec[0] / len, vec[1] / len];
99
100
    let cosine = dotProductXY(v1, v2);
101
    if (cosine < 0) cosine = 0; // Corner sharper than 90 degrees
102
103
    if (vMax > speedLimit * 0.5) vMax = Math.min(vMax, vMax * cosine ** 4);
104
    else if (vMax > speedLimit * 0.25) vMax = Math.min(vMax, vMax * cosine);
105
    vMax = Math.min(vMax, speedLimit * 0.8);
106
107
    trajVels.push(f32(vMax));
108
  }
109
  trajVels.push(f32(0.0)); // Come to rest at the final vertex.
110
111
  // Reverse pass: make sure every deceleration fits in its segment.
112
  for (let j = 1; j < trajLength; j++) {
113
    const i = trajLength - j;
114
    const vFinal = trajVels[i];
115
    const vInitial = trajVels[i - 1];
116
    const segLength = trajDists[i];
117
    if (segLength <= 0 || vInitial < vFinal || isClose(vInitial, vFinal, 1e-9)) continue;
118
    const vInitMax = plan.scurvePlan(vFinal, speedLimit, jerkRate, segLength, 0.007);
119
    trajVels[i - 1] = f32(Math.min(vInitial, vInitMax));
120
  }
121
122
  const moveList = [];
123
  for (let i = 0; i < trajLength - 1; i++) {
124
    const [moves, data] = computeSegment(
125
      nd, [trimmedPath[i][0], trimmedPath[i][1], trajVels[i], trajVels[i + 1], false], xyzPos);
126
    if (moves != null) {
127
      xyzPos = data[0];
128
      moveList.push(...moves);
129
    }
130
  }
131
  return moveList;
132
}
133
134
/**
135
 * Plan one straight segment with given initial/final speeds.
136
 * data = [xDest, yDest, vInitial, vFinal, ignoreLimits].
137
 * Returns [moveList, [xyzPosAfter]] or [null, null] if there is nothing to do.
138
 */
139
export function computeSegment(nd, data, xyzPos = null) {
140
  let [xDest, yDest, vi, vf, ignoreLimits] = data;
141
  if (xyzPos == null) xyzPos = copyPos(nd.pen.phys);
142
143
  const fCurrentX = xyzPos.xpos;
144
  if (fCurrentX == null) return [null, null];
145
  const fCurrentY = xyzPos.ypos;
146
  const penUp = xyzPos.zUp;
147
148
  const constantVelMode = nd.params.constSpeed && !penUp;
149
150
  if (!ignoreLimits) {
151
    const tol = nd.params.boundsTolerance;
152
    let xBounded, yBounded;
153
    [xDest, xBounded] = checkLimitsTol(xDest, nd.bounds[0][0], nd.bounds[1][0], tol);
154
    [yDest, yBounded] = checkLimitsTol(yDest, nd.bounds[0][1], nd.bounds[1][1], tol);
155
    if (xBounded || yBounded) nd.warn?.('bounds');
156
  }
157
158
  const dxIn = xDest - fCurrentX;
159
  const dyIn = yDest - fCurrentY;
160
161
  const motorSteps1 = pyRound(nd.stepScale * (dxIn + dyIn));
162
  const motorSteps2 = pyRound(nd.stepScale * (dxIn - dyIn));
163
164
  const m1Rounded = motorSteps1 / (2.0 * nd.stepScale);
165
  const m2Rounded = motorSteps2 / (2.0 * nd.stepScale);
166
  const dxRounded = m1Rounded + m2Rounded;
167
  const dyRounded = m1Rounded - m2Rounded;
168
169
  if (Math.abs(motorSteps1) < 1 && Math.abs(motorSteps2) < 1) return [null, null];
170
171
  const distInch = distance(dxRounded, dyRounded);
172
  const [jerkUp, jerkDown] = plan.calcJerk(nd);
173
  const speedLimit = penUp ? nd.speedPenup : nd.speedPendown;
174
  const jerkRate = penUp ? jerkUp : jerkDown;
175
176
  if (constantVelMode) {
177
    vi = speedLimit;
178
    vf = speedLimit;
179
  } else {
180
    vi = Math.min(vi, speedLimit);
181
    vf = Math.min(vf, speedLimit);
182
  }
183
184
  // Sub-segment codes: 1 S-curve accel, 2 S-curve decel, 3 constant velocity.
185
  const subsegs = [];
186
  const dists = [];
187
  const vels = [];
188
  const jerks = [];
189
190
  let accelDist = 0;
191
  let decelDist = 0;
192
  let kase = 0;
193
  const constSpeedTime = isClose(vi, 0) ? 100 : distInch / vi;
194
195
  if (constantVelMode || (isClose(vi, speedLimit) && isClose(vf, speedLimit))) {
196
    kase = 3;
197
  } else if (isClose(vi, vf, 1e-2) && constSpeedTime < 0.013) {
198
    kase = 3; // Very short; keep it a single constant-speed command.
199
  } else if (isClose(vi, vf, 1e-2) && constSpeedTime < 0.030 && vi > speedLimit / 2) {
200
    kase = 3; // Smooth motion at higher speeds.
201
  } else {
202
    accelDist = plan.scurvePlan(vi, speedLimit, jerkRate, null);
203
    decelDist = plan.scurvePlan(vf, speedLimit, jerkRate, null);
204
    const distSvm = accelDist + decelDist;
205
    const distSse = vi <= vf
206
      ? plan.scurvePlan(vi, vf, jerkRate, null)
207
      : plan.scurvePlan(vf, vi, jerkRate, null);
208
209
    if (distInch >= distSvm) {
210
      if (isClose(vi, speedLimit)) kase = isClose(distInch, distSvm) ? 2 : 4;
211
      else if (isClose(vf, speedLimit)) kase = isClose(distInch, distSvm) ? 1 : 5;
212
      else kase = 6;
213
    } else if (isClose(vi, vf, 1e-3) && isClose(vi, 0, 1e-3)) {
214
      kase = 7; // Starts and stops at rest: always a triangle.
215
    } else if (distInch > distSse) {
216
      kase = 7;
217
    } else if (isClose(distInch, distSse, 1e-3)) {
218
      kase = vi < vf ? 1 : 2;
219
    }
220
  }
221
222
  jerks.push(f32(jerkRate));
223
224
  if (kase === 1) {
225
    subsegs.push(1);
226
  } else if (kase === 2) {
227
    subsegs.push(2);
228
  } else if (kase === 3) {
229
    subsegs.push(3);
230
  } else if (kase === 4) {
231
    subsegs.push(3);
232
    dists.push(f32(distInch - decelDist));
233
    vels.push(f32(speedLimit));
234
    subsegs.push(2);
235
    jerks.push(f32(jerkRate));
236
  } else if (kase === 5) {
237
    subsegs.push(1);
238
    dists.push(f32(accelDist));
239
    vels.push(f32(speedLimit));
240
    subsegs.push(3);
241
    jerks.push(f32(jerkRate));
242
  } else if (kase === 6) {
243
    subsegs.push(1);
244
    dists.push(f32(accelDist));
245
    vels.push(f32(speedLimit));
246
    subsegs.push(3);
247
    dists.push(f32(distInch - decelDist));
248
    vels.push(f32(speedLimit));
249
    subsegs.push(2);
250
    jerks.push(f32(jerkRate));
251
    jerks.push(f32(jerkRate));
252
  } else if (kase === 7) {
253
    const vMid = plan.striangle(vi, vf, speedLimit, jerkRate, distInch);
254
    const time1 = plan.scurveTime(vi, vMid, jerkRate);
255
    const time2 = plan.scurveTime(vMid, vf, jerkRate);
256
    let time3 = 0;
257
    const jTemp = plan.scurveJerk(vi, vf, distInch, jerkRate);
258
    if (jTemp != null) time3 = plan.scurveTime(vi, vf, jTemp);
259
    if (time3 !== 0 && (time1 + time2 > time3 || time1 + time2 < 0.012)) kase = 0;
260
    if (kase === 7) {
261
      subsegs.push(1);
262
      dists.push(f32(plan.scurvePlan(vi, vMid, jerkRate, null)));
263
      vels.push(f32(vMid));
264
      subsegs.push(2);
265
      jerks.push(f32(jerkRate));
266
    }
267
  }
268
269
  if (kase === 0) {
270
    // Reduced-jerk single S-curve for moves too short for the full plan.
271
    let jTemp = plan.scurveJerk(vi, vf, distInch, jerkRate);
272
    if (jTemp == null) jTemp = plan.scurveJerk2(vi, vf, distInch, jerkRate);
273
    if (jTemp == null) return [null, null];
274
    jerks[jerks.length - 1] = f32(jTemp);
275
    subsegs.push(vi < vf ? 1 : 2);
276
  }
277
278
  return computeSubsegmentCmds(
279
    [vi, xyzPos, motorSteps1, motorSteps2, nd.stepScale, vf, distInch], subsegs, dists, vels, jerks);
280
}
281
282
/** Turn a planned segment into T3/TD commands (motion.compute_subsegment_cmds). */
283
export function computeSubsegmentCmds(params, subsegs, dists, vels, jerks) {
284
  const [vi, xyzPos, motorSteps1, motorSteps2, stepScale, vf, segmentLength] = params;
285
286
  const velocityScale = stepScale * (2147483648 / 25000);
287
  const jerkScale = stepScale * 2147483648 / (25000 ** 3);
288
  const isrToSteps = 1.0 / 2147483648;
289
290
  const motorStepDist = distance(motorSteps1, motorSteps2);
291
  // Direction of travel, scaled by sqrt(2) for the motor-axis conversion.
292
  const vNorm1 = Math.sqrt(2) * motorSteps1 / motorStepDist;
293
  const vNorm2 = Math.sqrt(2) * motorSteps2 / motorStepDist;
294
295
  let prevMotor1 = 0;
296
  let prevMotor2 = 0;
297
  let prevVel1 = pyRound((vi * velocityScale) * vNorm1);
298
  let prevVel2 = pyRound((vi * velocityScale) * vNorm2);
299
300
  const moveList = [];
301
  const count = subsegs.length;
302
303
  for (let index = 0; index < count; index++) {
304
    let dest1, dest2, subVf;
305
    if (index === count - 1) {
306
      // Last sub-segment always ends at the exact final position and speed.
307
      dest1 = motorSteps1;
308
      dest2 = motorSteps2;
309
      subVf = vf;
310
    } else {
311
      subVf = vels[index];
312
      dest1 = pyRound(motorSteps1 * dists[index] / segmentLength);
313
      dest2 = pyRound(motorSteps2 * dists[index] / segmentLength);
314
    }
315
    const stepsSub1 = dest1 - prevMotor1;
316
    const stepsSub2 = dest2 - prevMotor2;
317
318
    const vel1 = pyRound((subVf * velocityScale) * vNorm1);
319
    const vel2 = pyRound((subVf * velocityScale) * vNorm2);
320
321
    const jerkRate = jerks[index];
322
    const jerk1 = jerkRate * jerkScale * vNorm1;
323
    const jerk2 = jerkRate * jerkScale * vNorm2;
324
325
    let t1 = 0;
326
    let t2 = 0;
327
    const type = subsegs[index];
328
329
    if (type === 1 || type === 2) {
330
      // S-curve: TD = two T3 halves, jerk then counter-jerk.
331
      const sign = type === 1 ? 1 : -1;
332
      const j1 = sign * pyRound(jerk1);
333
      const j2 = sign * pyRound(jerk2);
334
      if (j1 !== 0) t1 = pyRound(Math.sqrt(Math.abs((vel1 - prevVel1) / j1)));
335
      if (j2 !== 0) t2 = pyRound(Math.sqrt(Math.abs((vel2 - prevVel2) / j2)));
336
337
      const test1 = moveDistT3(t1, prevVel1, 0, j1)[0];
338
      const test2 = moveDistT3(t2, prevVel2, 0, j2)[0];
339
      const moveTime = Math.abs(test1) > Math.abs(test2) ? t1 : t2;
340
      if (moveTime === 0) continue;
341
342
      const a1 = pyRound(sign * jerk1 * moveTime);
343
      const a2 = pyRound(sign * jerk2 * moveTime);
344
      const r1 = rateT3(moveTime, prevVel1, 0, j1);
345
      const r2 = rateT3(moveTime, prevVel2, 0, j2);
346
347
      const td = [moveTime, prevVel1, r1 + a1, a1, j1, prevVel2, r2 + a2, a2, j2];
348
      const [s1, s2, inches, nv1, nv2] = plan.tdSegData(td, xyzPos, stepScale);
349
      prevVel1 = nv1;
350
      prevVel2 = nv2;
351
      moveList.push(['TD', td, [inches, copyPos(xyzPos)]]);
352
      prevMotor1 += s1;
353
      prevMotor2 += s2;
354
    } else if (type === 3) {
355
      if (vel1 !== 0) t1 = Math.ceil(Math.abs(stepsSub1 / (vel1 * isrToSteps)));
356
      if (vel2 !== 0) t2 = Math.ceil(Math.abs(stepsSub2 / (vel2 * isrToSteps)));
357
358
      const test1 = moveDistT3(t1, vel1, 0, 0)[0];
359
      const test2 = moveDistT3(t2, vel2, 0, 0)[0];
360
      const moveTime = Math.abs(test1) > Math.abs(test2) ? t1 : t2;
361
      if (moveTime === 0) continue;
362
363
      const t3 = [moveTime, vel1, 0, 0, vel2, 0, 0];
364
      const [s1, s2, inches, nv1, nv2] = plan.t3SegData(t3, xyzPos, stepScale);
365
      prevVel1 = nv1;
366
      prevVel2 = nv2;
367
      moveList.push(['T3', t3, [inches, copyPos(xyzPos)]]);
368
      prevMotor1 += s1;
369
      prevMotor2 += s2;
370
    }
371
  }
372
  return [moveList, [xyzPos]];
373
}