@hatiolab/figure-model 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +41 -5
  2. package/dist/asset-types.d.ts +19 -0
  3. package/dist/asset-types.d.ts.map +1 -1
  4. package/dist/asset-types.js +2 -0
  5. package/dist/asset-types.js.map +1 -1
  6. package/dist/asset.d.ts.map +1 -1
  7. package/dist/asset.js +5 -3
  8. package/dist/asset.js.map +1 -1
  9. package/dist/authoring-actions.js +8 -0
  10. package/dist/authoring-actions.js.map +1 -1
  11. package/dist/authoring-fastening.d.ts +16 -2
  12. package/dist/authoring-fastening.d.ts.map +1 -1
  13. package/dist/authoring-fastening.js +106 -47
  14. package/dist/authoring-fastening.js.map +1 -1
  15. package/dist/authoring-placement.d.ts.map +1 -1
  16. package/dist/authoring-placement.js +116 -22
  17. package/dist/authoring-placement.js.map +1 -1
  18. package/dist/authoring-proportion.d.ts.map +1 -1
  19. package/dist/authoring-proportion.js +25 -8
  20. package/dist/authoring-proportion.js.map +1 -1
  21. package/dist/authoring-types.d.ts +62 -34
  22. package/dist/authoring-types.d.ts.map +1 -1
  23. package/dist/authoring-types.js +8 -8
  24. package/dist/authoring-types.js.map +1 -1
  25. package/dist/clip-keeping.d.ts.map +1 -1
  26. package/dist/clip-keeping.js +7 -5
  27. package/dist/clip-keeping.js.map +1 -1
  28. package/dist/contract.d.ts +11 -1
  29. package/dist/contract.d.ts.map +1 -1
  30. package/dist/contract.js.map +1 -1
  31. package/dist/data-interface.d.ts +1 -1
  32. package/dist/data-interface.d.ts.map +1 -1
  33. package/dist/data-interface.js +43 -5
  34. package/dist/data-interface.js.map +1 -1
  35. package/dist/editor-workspace.js +1 -1
  36. package/dist/editor-workspace.js.map +1 -1
  37. package/dist/examples.js +6 -6
  38. package/dist/examples.js.map +1 -1
  39. package/dist/fasten-place.d.ts +18 -0
  40. package/dist/fasten-place.d.ts.map +1 -1
  41. package/dist/fasten-place.js +36 -0
  42. package/dist/fasten-place.js.map +1 -1
  43. package/dist/index.d.ts +6 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -3
  46. package/dist/index.js.map +1 -1
  47. package/dist/item-notes.d.ts +17 -0
  48. package/dist/item-notes.d.ts.map +1 -0
  49. package/dist/item-notes.js +28 -0
  50. package/dist/item-notes.js.map +1 -0
  51. package/dist/joint-fields.d.ts +6 -8
  52. package/dist/joint-fields.d.ts.map +1 -1
  53. package/dist/joint-fields.js +6 -6
  54. package/dist/joint-fields.js.map +1 -1
  55. package/dist/joint-list.d.ts +11 -9
  56. package/dist/joint-list.d.ts.map +1 -1
  57. package/dist/joint-list.js +75 -53
  58. package/dist/joint-list.js.map +1 -1
  59. package/dist/joint-motions.d.ts +30 -0
  60. package/dist/joint-motions.d.ts.map +1 -0
  61. package/dist/joint-motions.js +28 -0
  62. package/dist/joint-motions.js.map +1 -0
  63. package/dist/joint-part-edits.d.ts.map +1 -1
  64. package/dist/joint-part-edits.js +95 -34
  65. package/dist/joint-part-edits.js.map +1 -1
  66. package/dist/joint-writing.d.ts +3 -4
  67. package/dist/joint-writing.d.ts.map +1 -1
  68. package/dist/joint-writing.js +229 -170
  69. package/dist/joint-writing.js.map +1 -1
  70. package/dist/kernel-version.d.ts +1 -1
  71. package/dist/kernel-version.js +1 -1
  72. package/dist/part-edit.d.ts.map +1 -1
  73. package/dist/part-edit.js +4 -2
  74. package/dist/part-edit.js.map +1 -1
  75. package/dist/proposal.d.ts +1 -1
  76. package/dist/proposal.d.ts.map +1 -1
  77. package/dist/proposal.js +5 -3
  78. package/dist/proposal.js.map +1 -1
  79. package/dist/recipe-from-parts.d.ts.map +1 -1
  80. package/dist/recipe-from-parts.js +12 -4
  81. package/dist/recipe-from-parts.js.map +1 -1
  82. package/dist/recipe.d.ts.map +1 -1
  83. package/dist/recipe.js +10 -8
  84. package/dist/recipe.js.map +1 -1
  85. package/dist/release-contract.d.ts +1 -1
  86. package/dist/release-contract.d.ts.map +1 -1
  87. package/dist/release-contract.js +56 -4
  88. package/dist/release-contract.js.map +1 -1
  89. package/dist/release-metrics.d.ts +8 -0
  90. package/dist/release-metrics.d.ts.map +1 -1
  91. package/dist/release-metrics.js +7 -2
  92. package/dist/release-metrics.js.map +1 -1
  93. package/dist/robots.d.ts.map +1 -1
  94. package/dist/robots.js +43 -46
  95. package/dist/robots.js.map +1 -1
  96. package/docs/attachment-response-spec-review-2026-10-24.md +56 -0
  97. package/docs/editor-integration.md +2 -0
  98. package/docs/modeller-interaction-performance-2026-10-07.md +1 -1
  99. package/docs/performance-followup-2026-10-05.md +1 -1
  100. package/docs/review-2026-09-22.md +2 -2
  101. package/docs/status.md +12 -2
  102. package/package.json +1 -1
@@ -9,14 +9,16 @@ import { compileFigureGraph, figureShapeFaces } from "./graph.js";
9
9
  import { remappedFaces } from "./shape-edit.js";
10
10
  import { REPEAT_LIMIT, AXES } from "./words.js";
11
11
  import { FIGURE_INSTANCE_SIZE_INPUTS } from "./instance-size.js";
12
- import { FIGURE_FACES, FIGURE_JOINT_TYPES, FIGURE_JOINT_PIVOTS, figureMimicLeadersOf, followsSomething, followedTermsOf } from "./authoring-types.js";
12
+ import { FIGURE_FACES, FIGURE_MOTION_TYPES, FIGURE_JOINT_PIVOTS, figureMimicLeadersOf, followsSomething, followedTermsOf } from "./authoring-types.js";
13
13
  import { fail, nodeById, partOf, namer, constantOf, measureRef, sumOf, stableDimension, ownInput, holdingDeclared, dropIfUnused, parentOf, round6 } from "./authoring-nodes.js";
14
14
  import { mul3, neg, half, ownSpansOf, IDENTITY, transpose3, mv3, eulerOfAny, rpyMatrix, isQuarterMatrix, receivingAxisOf, rpyOf } from "./authoring-geometry.js";
15
15
  import { placeOnFace, addMotion, removeMotion, reseat, turnSeat } from "./authoring-fastening.js";
16
16
  import { figureShapeExtremeAlong, lin, plus, times, writeFigureReach } from "./shape-extreme.js";
17
+ import { figureAxesOf, figureMotionsOf, isMovingJoint, motionNodeIdOf, motionStateIdOf } from "./joint-motions.js";
17
18
  const everyKey = () => (...keys) => keys;
18
19
  const JOINT_FIELDS = {
19
- joint: everyKey()('id', 'type', 'body0', 'body1', 'origin', 'axis', 'lowerLimit', 'upperLimit', 'travel', 'state', 'mimic', 'repeat'),
20
+ joint: everyKey()('id', 'body0', 'body1', 'origin', 'motion', 'repeat'),
21
+ motion: everyKey()('id', 'type', 'axis', 'through', 'lowerLimit', 'upperLimit', 'travel', 'state', 'mimic'),
20
22
  body0: everyKey()('part', 'face', 'surface', 'plane', 'at'),
21
23
  surface: everyKey()('toward', 'around', 'along'),
22
24
  body1: everyKey()('part', 'at'),
@@ -28,8 +30,8 @@ const JOINT_FIELDS = {
28
30
  source: everyKey()('of', 'axis', 'input', 'part'),
29
31
  travel: everyKey()('source', 'times', 'plus', 'sum'),
30
32
  state: everyKey()('id', 'label', 'start', 'sweep'),
31
- mimic: everyKey()('joint', 'multiplier', 'offset', 'sum'),
32
- leader: everyKey()('joint', 'multiplier'),
33
+ mimic: everyKey()('motion', 'multiplier', 'offset', 'sum'),
34
+ leader: everyKey()('motion', 'multiplier'),
33
35
  repeat: everyKey()('axis', 'pitch', 'count', 'rise'),
34
36
  rise: everyKey()('axis', 'mm')
35
37
  };
@@ -79,18 +81,29 @@ function knownJointFields(j, path) {
79
81
  measure(a?.mm, `${path}.origin.align.${axis}.mm`);
80
82
  }
81
83
  }
82
- only(j.axis, JOINT_FIELDS.axes, `${path}.axis`);
83
- if (j.travel && typeof j.travel === 'object') {
84
- only(j.travel, JOINT_FIELDS.travel, `${path}.travel`);
85
- source(j.travel.source, `${path}.travel.source`);
86
- if (Array.isArray(j.travel.sum))
87
- measure({ sum: j.travel.sum }, `${path}.travel`);
84
+ if (j.motion !== undefined && !Array.isArray(j.motion))
85
+ fail('JOINT_SCHEMA', `${path}.motion`, 'a motion is a list of axes');
86
+ for (const [k, a] of (Array.isArray(j.motion) ? j.motion : []).entries()) {
87
+ const at = `${path}.motion.${k}`;
88
+ only(a, JOINT_FIELDS.motion, at);
89
+ if (!a || typeof a !== 'object')
90
+ continue;
91
+ only(a.axis, JOINT_FIELDS.axes, `${at}.axis`);
92
+ only(a.through, JOINT_FIELDS.axes, `${at}.through`);
93
+ for (const [axis, d] of Object.entries(a.through && typeof a.through === 'object' ? a.through : {}))
94
+ measure(d, `${at}.through.${axis}`);
95
+ if (a.travel && typeof a.travel === 'object') {
96
+ only(a.travel, JOINT_FIELDS.travel, `${at}.travel`);
97
+ source(a.travel.source, `${at}.travel.source`);
98
+ if (Array.isArray(a.travel.sum))
99
+ measure({ sum: a.travel.sum }, `${at}.travel`);
100
+ }
101
+ only(a.state, JOINT_FIELDS.state, `${at}.state`);
102
+ only(a.mimic, JOINT_FIELDS.mimic, `${at}.mimic`);
103
+ if (Array.isArray(a.mimic?.sum))
104
+ for (const [i, t] of a.mimic.sum.entries())
105
+ only(t, JOINT_FIELDS.leader, `${at}.mimic.sum.${i}`);
88
106
  }
89
- only(j.state, JOINT_FIELDS.state, `${path}.state`);
90
- only(j.mimic, JOINT_FIELDS.mimic, `${path}.mimic`);
91
- if (Array.isArray(j.mimic?.sum))
92
- for (const [i, t] of j.mimic.sum.entries())
93
- only(t, JOINT_FIELDS.leader, `${path}.mimic.sum.${i}`);
94
107
  only(j.repeat, JOINT_FIELDS.repeat, `${path}.repeat`);
95
108
  if (j.repeat && typeof j.repeat === 'object') {
96
109
  measure(j.repeat.pitch, `${path}.repeat.pitch`);
@@ -110,7 +123,6 @@ export function figureAttachmentsOf(asset) {
110
123
  export function jointAbove(asset, part) {
111
124
  return asset.joints?.find(j => j.body1.part === part);
112
125
  }
113
- export const stateIdOf = (j) => j.state?.id ?? j.id;
114
126
  /** Whether a joint is placed at its parent's origin rather than on a face (ADR-0107 decision 4). */
115
127
  export const isOriginPlaced = (j) => !!j.body0 && 'at' in j.body0;
116
128
  /** A record's axis as a unit vector when it is written as one; undefined for a token. Zero or not finite is refused. */
@@ -204,6 +216,83 @@ function checkJointFrames(j, path) {
204
216
  if (o.offset !== undefined && (!o.offset || typeof o.offset !== 'object'))
205
217
  fail('JOINT_SCHEMA', `${path}.origin.offset`, 'an offset is a length along x, y and z');
206
218
  }
219
+ /**
220
+ * A joint's axes (ADR-0159): each turns or slides about an axis, has its limits -- a revolute one may have none and
221
+ * turns without end, a slide always has them -- or follows other axes. Its id is unique among every joint's axes and
222
+ * every other joint's id, so a follower, a control and the joint list name one thing. Two axes in a row on one line
223
+ * are two: each its own control, range and followers, though the pose they make is one turn (review 2026-10-24).
224
+ */
225
+ function checkMotion(others, j, path) {
226
+ const motions = figureMotionsOf(j);
227
+ const theirs = figureAxesOf(others).map(a => a.motion);
228
+ const ids = new Set([...theirs.map(a => a.id), ...others.map(o => o.id)]);
229
+ for (const [k, a] of motions.entries()) {
230
+ const at = `${path}.motion.${k}`;
231
+ const record = j.motion[k];
232
+ if (!record || typeof record !== 'object')
233
+ fail('JOINT_SCHEMA', at, 'an axis is { type, axis }');
234
+ if (!FIGURE_MOTION_TYPES.includes(a.type))
235
+ fail('JOINT_SCHEMA', at, `${String(a.type)} is not a motion; ${FIGURE_MOTION_TYPES.join(', ')}`);
236
+ if (record.id !== undefined && (typeof record.id !== 'string' || !record.id.trim()))
237
+ fail('JOINT_SCHEMA', `${at}.id`, 'an axis id is a name');
238
+ if (ids.has(a.id))
239
+ fail('JOINT_SCHEMA', `${at}.id`, `${a.id} names another axis already; an axis id is unique among every joint's axes`);
240
+ ids.add(a.id);
241
+ if (!['X', 'Y', 'Z'].includes(a.axis) && !jointVectorOf(a.axis, at))
242
+ fail('JOINT_SCHEMA', at, `a ${a.type} axis is X, Y or Z, or a vector { x, y, z }`);
243
+ for (const [x, d] of Object.entries(a.through ?? {}))
244
+ if (d !== undefined && (typeof d === 'number' ? !Number.isFinite(d) : !followsSomething(d)))
245
+ fail('JOINT_SCHEMA', `${at}.through.${x}`, 'the point an axis passes is millimetres, or a length that follows something');
246
+ if (a.travel !== undefined && a.type !== 'prismatic')
247
+ fail('JOINT_SCHEMA', at, 'only a prismatic axis travels a length');
248
+ const limited = a.lowerLimit !== undefined || a.upperLimit !== undefined;
249
+ if (a.type === 'prismatic' && a.travel && !limited) {
250
+ record.lowerLimit = 0;
251
+ record.upperLimit = 1;
252
+ }
253
+ /* A revolute axis without limits turns without end (USD); with them, both are given. A slide always has them. */
254
+ if (!a.mimic && (limited || a.type === 'prismatic')) {
255
+ const [lo, hi] = [record.lowerLimit, record.upperLimit];
256
+ if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
257
+ fail('JOINT_SCHEMA', at, `a ${a.type} axis has both limits, lower below upper`);
258
+ /*
259
+ The rest pose is every axis at 0 -- the kinematic reference it is read at (joint-list.ts) -- and 0 need not be a
260
+ value it may take: an axis limited to 10..80 still has its reference at 0 (ADR-0107 J4). Its starting value, the
261
+ stored state default, is held to its limits as every control's is (addMotion).
262
+ */
263
+ }
264
+ }
265
+ for (const [k, a] of motions.entries()) {
266
+ if (!a.mimic)
267
+ continue;
268
+ const at = `${path}.motion.${k}`;
269
+ /*
270
+ One leader or a sum of them (ADR-0107 decision 2), each held to the same rules: an axis that leads itself -- so a
271
+ follower is never a leader and no loop can close -- that moves the way this one does (degrees are not added to
272
+ millimetres), named once, with a finite multiplier.
273
+ */
274
+ const sum = Object.hasOwn(a.mimic, 'sum');
275
+ if (sum && !(Array.isArray(a.mimic.sum) && a.mimic.sum.length))
276
+ fail('JOINT_SCHEMA', at, 'a mimic sum follows one axis or more');
277
+ const seen = new Set();
278
+ for (const t of figureMimicLeadersOf(a)) {
279
+ const leader = [...theirs, ...motions].find(o => o.id === t?.motion && o.id !== a.id);
280
+ if (!leader)
281
+ fail('JOINT_SCHEMA', at, `${String(t?.motion)} is not a moving axis to follow`);
282
+ if (seen.has(leader.id))
283
+ fail('JOINT_SCHEMA', at, `${leader.id} is followed twice; a sum names each axis once`);
284
+ seen.add(leader.id);
285
+ if (leader.mimic)
286
+ fail('JOINT_SCHEMA', at, `${leader.id} follows another axis; follow the one it follows`);
287
+ if ((leader.type === 'prismatic') !== (a.type === 'prismatic'))
288
+ fail('JOINT_SCHEMA', at, `${leader.id} ${leader.type === 'prismatic' ? 'slides' : 'turns'}; an axis follows ones that move the same way: a slide a slide, a turn a turn`);
289
+ if (!Number.isFinite(t.multiplier))
290
+ fail('JOINT_SCHEMA', at, 'a mimic has a multiplier');
291
+ }
292
+ if (a.mimic.offset !== undefined && !Number.isFinite(a.mimic.offset))
293
+ fail('JOINT_SCHEMA', at, "a mimic's offset is a number in the axis's unit");
294
+ }
295
+ }
207
296
  /** A record that is well formed on its own, and fits the tree the others make. */
208
297
  function checkJoint(asset, j) {
209
298
  const m = asset.document.model;
@@ -211,8 +300,6 @@ function checkJoint(asset, j) {
211
300
  if (!j || typeof j.id !== 'string' || !j.id.trim())
212
301
  fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
213
302
  knownJointFields(j, path);
214
- if (!FIGURE_JOINT_TYPES.includes(j.type))
215
- fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${FIGURE_JOINT_TYPES.join(', ')}`);
216
303
  if (!j.body1 || typeof j.body1.part !== 'string')
217
304
  fail('JOINT_SCHEMA', path, 'a joint carries a part (body1)');
218
305
  partOf(m, j.body1.part, path);
@@ -234,56 +321,7 @@ function checkJoint(asset, j) {
234
321
  up = next && 'part' in next ? next.part : undefined;
235
322
  }
236
323
  }
237
- const moving = j.type !== 'fixed';
238
- const limited = j.lowerLimit !== undefined || j.upperLimit !== undefined;
239
- if (moving && !['X', 'Y', 'Z'].includes(j.axis) && !jointVectorOf(j.axis, path))
240
- fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z, or a vector { x, y, z }`);
241
- if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
242
- fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
243
- if (j.travel !== undefined && j.type !== 'prismatic')
244
- fail('JOINT_SCHEMA', path, 'only a prismatic joint travels a length');
245
- if (j.type === 'prismatic' && j.travel && !limited) {
246
- j.lowerLimit = 0;
247
- j.upperLimit = 1;
248
- }
249
- /* A revolute joint without limits turns without end (USD); with them, both are given. A slide always has them. */
250
- if (moving && !j.mimic && (limited || j.type === 'prismatic')) {
251
- const [lo, hi] = [j.lowerLimit, j.upperLimit];
252
- if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
253
- fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
254
- /*
255
- The rest pose is every joint at 0 -- the kinematic reference its axis is read at (joint-list.ts) -- and 0 need not
256
- be a value the joint may take: a joint limited to 10..80 still has its reference at 0 (ADR-0107 J4). Its starting
257
- value, the stored state default, is held to its limits as every control's is (addMotion).
258
- */
259
- }
260
- if (j.mimic) {
261
- /*
262
- One leader or a sum of them (ADR-0107 decision 2), each held to the same rules: a moving joint that leads itself
263
- -- so a follower is never a leader and no loop can close -- that moves the way this one does (degrees are not
264
- added to millimetres), named once, with a finite multiplier.
265
- */
266
- const sum = Object.hasOwn(j.mimic, 'sum');
267
- if (sum && !(Array.isArray(j.mimic.sum) && j.mimic.sum.length))
268
- fail('JOINT_SCHEMA', path, 'a mimic sum follows one joint or more');
269
- const seen = new Set();
270
- for (const t of figureMimicLeadersOf(j)) {
271
- const leader = others.find(o => o.id === t?.joint);
272
- if (!leader || leader.type === 'fixed')
273
- fail('JOINT_SCHEMA', path, `${String(t?.joint)} is not a moving joint to follow`);
274
- if (seen.has(leader.id))
275
- fail('JOINT_SCHEMA', path, `${leader.id} is followed twice; a sum names each joint once`);
276
- seen.add(leader.id);
277
- if (leader.mimic)
278
- fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
279
- if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
280
- fail('JOINT_SCHEMA', path, `${leader.id} ${leader.type === 'prismatic' ? 'slides' : 'turns'}; a joint follows ones that move the same way: a slide a slide, a turn a turn`);
281
- if (!Number.isFinite(t.multiplier))
282
- fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
283
- }
284
- if (j.mimic.offset !== undefined && !Number.isFinite(j.mimic.offset))
285
- fail('JOINT_SCHEMA', path, "a mimic's offset is a number in the joint's unit");
286
- }
324
+ checkMotion(others, j, path);
287
325
  if (j.origin?.childRotate !== undefined &&
288
326
  (!j.origin.childRotate || typeof j.origin.childRotate !== 'object' || Object.values(j.origin.childRotate).some(t => !Number.isFinite(t))))
289
327
  fail('JOINT_SCHEMA', `${path}.origin.childRotate`, 'a pose is degrees about x, y and z');
@@ -392,78 +430,84 @@ function placeAtOrigin(asset, j) {
392
430
  turnSeat(asset, child, rpyMatrix(j.origin.parentRotate), j.origin.parentRotate, IDENTITY);
393
431
  }
394
432
  /**
395
- * A moving joint's motion, at the joint frame, about or along its axis turned into the parent's frame by `back`: a token
396
- * is one of the parent's axes, either way along it (a parent standing twisted refuses one that is not); a vector goes
397
- * as it is, as its unit vector (URDF's axis). A row is laid out last, once its part's motion is written.
433
+ * A moving joint's axes, in order (ADR-0159), each at the joint frame about or along its axis turned into the parent's
434
+ * frame by `back`: a token is one of the parent's axes, either way along it (a parent standing twisted refuses one that
435
+ * is not); a vector goes as it is, as its unit vector (URDF's axis). Each passes its point (`through`) and is carried by
436
+ * the ones before it. A row is laid out last, once its part's motion is written.
398
437
  */
399
438
  function moveJoint(asset, j, back) {
400
439
  const m = asset.document.model;
401
440
  const child = j.body1.part;
402
441
  const parent = 'part' in j.body0 ? j.body0.part : undefined;
403
442
  const path = `joints.${j.id}`;
404
- /* A row is laid out last, once its part's motion is written: every copy moves on it (a roller row spinning). */
405
- if (j.type === 'fixed') {
406
- if (j.repeat)
407
- repeatPart(asset, j);
408
- return;
409
- }
410
- const kind = j.type === 'prismatic' ? 'slide' : 'turn';
411
- const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
412
- const range = j.lowerLimit !== undefined && j.upperLimit !== undefined
413
- ? { min: j.lowerLimit, max: j.upperLimit }
414
- : j.type === 'revolute'
415
- ? { min: -180, max: 180 }
416
- : { min: 0, max: 1 };
417
- const leaders = figureMimicLeadersOf(j).map(t => ({
418
- leader: (asset.joints ?? []).find(o => o.id === t.joint),
419
- k: t.multiplier
420
- }));
421
- /* The axis turned into the parent's frame too: one of its axes, either way along it. */
422
- const vector = jointVectorOf(j.axis, path);
423
- const along = mv3(back, vector ?? AXES.map(a => (a === j.axis.toLowerCase() ? 1 : 0)));
424
- const axisIndex = vector ? 0 : along.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
425
- if (axisIndex < 0)
426
- fail('JOINT_SCHEMA', path, `${parent} stands twisted; the axis ${j.axis} is not level with an axis in it`);
427
- /* Twelve places, as a twist's factors: the same record writes the same constants however the vector was turned. */
428
- const direction = vector
429
- ? along.map(v => (Math.abs(v) < 1e-12 ? 0 : Math.round(v * 1e12) / 1e12))
430
- : along[axisIndex] > 0
431
- ? 1
432
- : -1;
433
- addMotion(asset, {
434
- kind: 'add-motion',
435
- part: child,
436
- motion: { kind, axis: AXES[axisIndex] },
437
- state: {
438
- id: stateIdOf(j),
439
- unit,
440
- min: range.min,
441
- max: range.max,
442
- start: j.state?.start ?? Math.min(range.max, Math.max(range.min, 0)),
443
- ...(j.state?.label !== undefined ? { label: j.state.label } : {}),
444
- ...(j.state?.sweep !== undefined ? { sweep: j.state.sweep } : {})
445
- },
446
- ...(j.travel ? { travel: j.travel } : {}),
447
- replace: true
448
- },
449
443
  /*
450
- A follower follows how far its leader has gone -- its distance or angle -- not the leader's control: a leader whose
451
- stroke is a share of a length has a control of 0..1, and multiplying that gave a follower a number with no length
452
- (the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
444
+ What an axis's value is worked out from comes first, the order axes are carried in after: each keeps its place
445
+ (`<part>.motion.<k>`), but an axis that follows another of its joint is written once its leader's distance is there
446
+ -- else it read the leader's control, a share, as millimetres (review 2026-10-24, F3). A leader leads itself, so
447
+ the axes that lead themselves are written first.
453
448
  */
454
- j.mimic
455
- ? {
456
- terms: leaders.map(({ leader, k }) => ({
457
- of: nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader),
458
- k
459
- })),
460
- c: j.mimic.offset ?? 0,
461
- sum: Object.hasOwn(j.mimic, 'sum')
462
- }
463
- : undefined, direction);
449
+ const order = figureMotionsOf(j)
450
+ .map((a, k) => ({ a, k }))
451
+ .sort((x, y) => Number(!!x.a.mimic) - Number(!!y.a.mimic) || x.k - y.k);
452
+ for (const { a, k } of order) {
453
+ const at = `${path}.motion.${k}`;
454
+ const kind = a.type === 'prismatic' ? 'slide' : 'turn';
455
+ const unit = a.type === 'prismatic' ? (a.travel ? 'ratio' : 'mm') : 'deg';
456
+ const range = a.lowerLimit !== undefined && a.upperLimit !== undefined
457
+ ? { min: a.lowerLimit, max: a.upperLimit }
458
+ : a.type === 'revolute'
459
+ ? { min: -180, max: 180 }
460
+ : { min: 0, max: 1 };
461
+ const leaders = figureMimicLeadersOf(a).map(t => ({ leader: figureAxisOf(asset, t.motion), k: t.multiplier }));
462
+ /* The axis turned into the parent's frame too: one of its axes, either way along it. */
463
+ const vector = jointVectorOf(a.axis, at);
464
+ const along = mv3(back, vector ?? AXES.map(x => (x === a.axis.toLowerCase() ? 1 : 0)));
465
+ const axisIndex = vector ? 0 : along.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
466
+ if (axisIndex < 0)
467
+ fail('JOINT_SCHEMA', at, `${parent} stands twisted; the axis ${a.axis} is not level with an axis in it`);
468
+ /* Twelve places, as a twist's factors: the same record writes the same constants however the vector was turned. */
469
+ const direction = vector
470
+ ? along.map(v => (Math.abs(v) < 1e-12 ? 0 : Math.round(v * 1e12) / 1e12))
471
+ : along[axisIndex] > 0
472
+ ? 1
473
+ : -1;
474
+ addMotion(asset, {
475
+ kind: 'add-motion',
476
+ part: child,
477
+ motion: { kind, axis: AXES[axisIndex] },
478
+ state: {
479
+ id: motionStateIdOf(a),
480
+ unit,
481
+ min: range.min,
482
+ max: range.max,
483
+ start: a.state?.start ?? Math.min(range.max, Math.max(range.min, 0)),
484
+ ...(a.state?.label !== undefined ? { label: a.state.label } : {}),
485
+ ...(a.state?.sweep !== undefined ? { sweep: a.state.sweep } : {})
486
+ },
487
+ ...(a.travel ? { travel: a.travel } : {}),
488
+ replace: true
489
+ },
490
+ /*
491
+ A follower follows how far its leader has gone -- its distance or angle -- not the leader's control: a leader whose
492
+ stroke is a share of a length has a control of 0..1, and multiplying that gave a follower a number with no length
493
+ (the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
494
+ */
495
+ a.mimic
496
+ ? {
497
+ terms: leaders.map(({ leader, k: times }) => ({
498
+ of: nodeById(m, motionNodeIdOf(leader.joint.body1.part, leader.k))?.args[3] ?? motionStateIdOf(leader.motion),
499
+ k: times
500
+ })),
501
+ c: a.mimic.offset ?? 0,
502
+ sum: Object.hasOwn(a.mimic, 'sum')
503
+ }
504
+ : undefined, direction, { k, through: a.through, path: at });
505
+ }
464
506
  if (j.repeat)
465
507
  repeatPart(asset, j);
466
508
  }
509
+ /** The axis that goes by this id, among the asset's joints. */
510
+ const figureAxisOf = (asset, id) => figureAxesOf(asset.joints).find(a => a.motion.id === id);
467
511
  /**
468
512
  * A fastened part made a row (r3 §8): its placement becomes the template of a `repeat@1`. The row starts at the
469
513
  * parent's lower edge along the axis and runs to its upper edge, so its length is the parent's and follows it; each
@@ -618,9 +662,9 @@ export function stripJoint(asset, j) {
618
662
  }
619
663
  /**
620
664
  * Every length a joint record holds that may follow something, read as a measure, with a way to write it back: the
621
- * gap, a row's pitch, a slide's travel (its one-term form names the source `source`, and without one is `plus` mm),
622
- * each offset across the face and each of the joint frame's offset (ADR-0107). Renaming a part and taking one away
623
- * read and write them all alike.
665
+ * gap, a row's pitch, each slide's travel (its one-term form names the source `source`, and without one is `plus` mm)
666
+ * and each axis's point (ADR-0159), each offset across the face and each of the joint frame's offset (ADR-0107).
667
+ * Renaming a part and taking one away read and write them all alike.
624
668
  */
625
669
  export function jointMeasures(j) {
626
670
  const out = [];
@@ -628,32 +672,37 @@ export function jointMeasures(j) {
628
672
  out.push({ at: 'gap', get: () => j.origin.gap, set: v => (j.origin.gap = v) });
629
673
  if (j.repeat)
630
674
  out.push({ at: 'pitch', get: () => j.repeat.pitch, set: v => (j.repeat.pitch = v) });
631
- if (j.travel) {
632
- const t = j.travel;
633
- out.push({
634
- at: 'travel',
635
- get: () => Object.hasOwn(t, 'sum')
636
- ? { sum: t.sum, ...(t.plus !== undefined ? { plus: t.plus } : {}) }
637
- : t.source
638
- ? {
639
- from: t.source,
640
- ...(t.times !== undefined ? { times: t.times } : {}),
641
- ...(t.plus !== undefined ? { plus: t.plus } : {})
642
- }
643
- : (t.plus ?? 0),
644
- set: v => {
645
- const f = followedTermsOf(v);
646
- j.travel = !f
647
- ? { plus: v }
648
- : f.sum
649
- ? { sum: f.terms, ...(f.plus ? { plus: f.plus } : {}) }
650
- : {
651
- source: f.terms[0].from,
652
- ...(f.terms[0].times !== undefined ? { times: f.terms[0].times } : {}),
653
- ...(v.plus !== undefined ? { plus: v.plus } : {})
654
- };
655
- }
656
- });
675
+ for (const [k, a] of (j.motion ?? []).entries()) {
676
+ if (a.travel) {
677
+ const t = a.travel;
678
+ out.push({
679
+ at: `motion.${k}.travel`,
680
+ get: () => Object.hasOwn(t, 'sum')
681
+ ? { sum: t.sum, ...(t.plus !== undefined ? { plus: t.plus } : {}) }
682
+ : t.source
683
+ ? {
684
+ from: t.source,
685
+ ...(t.times !== undefined ? { times: t.times } : {}),
686
+ ...(t.plus !== undefined ? { plus: t.plus } : {})
687
+ }
688
+ : (t.plus ?? 0),
689
+ set: v => {
690
+ const f = followedTermsOf(v);
691
+ a.travel = !f
692
+ ? { plus: v }
693
+ : f.sum
694
+ ? { sum: f.terms, ...(f.plus ? { plus: f.plus } : {}) }
695
+ : {
696
+ source: f.terms[0].from,
697
+ ...(f.terms[0].times !== undefined ? { times: f.terms[0].times } : {}),
698
+ ...(v.plus !== undefined ? { plus: v.plus } : {})
699
+ };
700
+ }
701
+ });
702
+ }
703
+ for (const axis of Object.keys(a.through ?? {}))
704
+ if (a.through[axis] !== undefined)
705
+ out.push({ at: `motion.${k}.through.${axis}`, get: () => a.through[axis], set: v => (a.through[axis] = v) });
657
706
  }
658
707
  for (const axis of Object.keys(j.origin?.align ?? {})) {
659
708
  const how = j.origin.align[axis];
@@ -678,17 +727,19 @@ export function mapMeasureSources(measure, to) {
678
727
  return { ...measure, sum: f.terms.map(t => ({ ...t, from: to(t.from) })) };
679
728
  return { ...measure, from: to(f.terms[0].from) };
680
729
  }
681
- /** Joints in an order each can be written in: its parent's joint and the joints it mimics first. */
730
+ /** Joints in an order each can be written in: its parent's joint and the joints whose axes it mimics first. */
682
731
  function writingOrder(joints) {
683
732
  const done = new Set();
684
733
  const out = [];
685
734
  const byChild = new Map(joints.map(j => [j.body1.part, j]));
735
+ const jointOf = new Map(figureAxesOf(joints).map(a => [a.motion.id, a.joint.id]));
686
736
  while (out.length < joints.length) {
687
737
  const ready = joints.filter(j => {
688
738
  if (done.has(j.id))
689
739
  return false;
690
740
  const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
691
- return (!up || done.has(up.id)) && figureMimicLeadersOf(j).every(t => done.has(t.joint));
741
+ const leaders = figureMotionsOf(j).flatMap(a => figureMimicLeadersOf(a).map(t => jointOf.get(t.motion)));
742
+ return (!up || done.has(up.id)) && leaders.every(id => id === j.id || done.has(id));
692
743
  });
693
744
  if (!ready.length)
694
745
  fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
@@ -737,10 +788,10 @@ export function rewriteJoints(asset) {
737
788
  if (owner && ownInput(m, owner, ref))
738
789
  dropIfUnused(asset, ref, ref);
739
790
  }
740
- // A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
741
- for (const j of order)
742
- if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
743
- asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
791
+ // An axis's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
792
+ for (const { motion } of figureAxesOf(order))
793
+ if (!motion.mimic && keep[motionStateIdOf(motion)] !== undefined)
794
+ asset.stateDefaults[motionStateIdOf(motion)] = keep[motionStateIdOf(motion)];
744
795
  });
745
796
  }
746
797
  export function setJoint(asset, joint) {
@@ -757,9 +808,13 @@ export function setJoint(asset, joint) {
757
808
  j.origin = { ...(j.origin ?? {}), childRotate: pose };
758
809
  }
759
810
  const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
760
- const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === j.id));
761
- if (followers.length && j.type === 'fixed')
762
- fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
811
+ /* An axis something follows stays: the joint written again without it would leave its followers following nothing. */
812
+ const was = (asset.joints ?? []).find(o => o.id === j.id);
813
+ const staying = new Set(figureMotionsOf(j).map(a => a.id));
814
+ const going = (was ? figureMotionsOf(was) : []).map(a => a.id).filter(id => !staying.has(id));
815
+ const followers = followersOf(joints, going);
816
+ if (followers.length)
817
+ fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${going.join(', ')}; ${j.id} cannot stop moving that way`);
763
818
  asset.joints = [...joints, j];
764
819
  rewriteJoints(asset);
765
820
  }
@@ -823,12 +878,14 @@ export function applyFaceRemap(asset, part, remap) {
823
878
  }
824
879
  remappedFaces.set(asset, faces);
825
880
  }
881
+ /** The joints with an axis that follows one of these axes. */
882
+ const followersOf = (joints, axes) => joints.filter(o => figureMotionsOf(o).some(a => figureMimicLeadersOf(a).some(t => axes.includes(t.motion))));
826
883
  export function removeJoint(asset, id) {
827
884
  const joints = (asset.joints ?? []);
828
885
  const j = joints.find(o => o.id === id);
829
886
  if (!j)
830
887
  fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
831
- const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === id));
888
+ const followers = followersOf(joints.filter(o => o.id !== id), figureMotionsOf(j).map(a => a.id));
832
889
  if (followers.length)
833
890
  fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
834
891
  const m = asset.document.model;
@@ -863,7 +920,9 @@ export function removeJoint(asset, id) {
863
920
  * square to it. Read from the figure as drawn at rest: every joint at 0, its reference pose.
864
921
  */
865
922
  function childRotateOf(asset, part, body0, parentRotate) {
866
- const rest = Object.fromEntries((asset.joints ?? []).filter(j => j.type !== 'fixed' && !j.mimic).map(j => [stateIdOf(j), 0]));
923
+ const rest = Object.fromEntries(figureAxesOf(asset.joints)
924
+ .filter(a => !a.motion.mimic)
925
+ .map(a => [motionStateIdOf(a.motion), 0]));
867
926
  const compiled = compileFigureAssetUnjointed(asset);
868
927
  /* A control whose range does not hold 0 is read where it stands: its reference is still 0, but it cannot be drawn there. */
869
928
  const geometry = (() => {
@@ -929,7 +988,7 @@ export function fastenAsJoint(asset, a, standing = asset) {
929
988
  /* Its pose now, in the new parent's frame (ADR-0140 3.6): it is fastened as it stands. */
930
989
  const pose = childRotateOf(standing, a.part, body0, was?.origin?.parentRotate);
931
990
  setJoint(asset, {
932
- ...(was ?? { id, type: 'fixed' }),
991
+ ...(was ?? { id }),
933
992
  body0,
934
993
  body1: { part: a.part },
935
994
  origin: {