@hatiolab/figure-model 0.2.15 → 0.3.0

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 (43) hide show
  1. package/dist/authoring-actions.d.ts +68 -25
  2. package/dist/authoring-actions.d.ts.map +1 -1
  3. package/dist/authoring-actions.js +331 -71
  4. package/dist/authoring-actions.js.map +1 -1
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/joint-list.d.ts +76 -0
  10. package/dist/joint-list.d.ts.map +1 -0
  11. package/dist/joint-list.js +259 -0
  12. package/dist/joint-list.js.map +1 -0
  13. package/dist/kernel-version.d.ts +1 -1
  14. package/dist/kernel-version.d.ts.map +1 -1
  15. package/dist/kernel-version.js +1 -1
  16. package/dist/kernel-version.js.map +1 -1
  17. package/dist/proposal.d.ts.map +1 -1
  18. package/dist/proposal.js +5 -4
  19. package/dist/proposal.js.map +1 -1
  20. package/dist/recipe.d.ts +11 -2
  21. package/dist/recipe.d.ts.map +1 -1
  22. package/dist/recipe.js +3 -1
  23. package/dist/recipe.js.map +1 -1
  24. package/dist/release-contract.d.ts +1 -1
  25. package/dist/release-contract.d.ts.map +1 -1
  26. package/dist/release-contract.js +83 -0
  27. package/dist/release-contract.js.map +1 -1
  28. package/dist/release-metrics.d.ts +3 -0
  29. package/dist/release-metrics.d.ts.map +1 -1
  30. package/dist/release-metrics.js +13 -2
  31. package/dist/release-metrics.js.map +1 -1
  32. package/dist/render-batches.d.ts +20 -3
  33. package/dist/render-batches.d.ts.map +1 -1
  34. package/dist/render-batches.js +75 -6
  35. package/dist/render-batches.js.map +1 -1
  36. package/dist/robots.d.ts +23 -26
  37. package/dist/robots.d.ts.map +1 -1
  38. package/dist/robots.js +52 -20
  39. package/dist/robots.js.map +1 -1
  40. package/dist/score.d.ts.map +1 -1
  41. package/dist/score.js +4 -1
  42. package/dist/score.js.map +1 -1
  43. package/package.json +2 -1
@@ -47,6 +47,101 @@ export const FIGURE_FACES = { left: 'x', right: 'x', bottom: 'y', top: 'y', back
47
47
  export const FIGURE_JOINT_TYPES = ['fixed', 'revolute', 'prismatic'];
48
48
  const SIGN = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 };
49
49
  const OPPOSITE = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' };
50
+ /** The joints a follower follows, each with its multiplier: one for a one-leader mimic, none for a joint that leads itself. */
51
+ export function figureMimicLeadersOf(j) {
52
+ const mimic = j.mimic;
53
+ if (!mimic || typeof mimic !== 'object')
54
+ return [];
55
+ if (Object.hasOwn(mimic, 'sum'))
56
+ return Array.isArray(mimic.sum) ? mimic.sum : [];
57
+ return [{ joint: mimic.joint, multiplier: mimic.multiplier }];
58
+ }
59
+ /** Whether a length is written as something to follow: one source, or a sum of one term or more, each with a source. */
60
+ const followsSomething = (measure) => {
61
+ const terms = followedTermsOf(measure)?.terms;
62
+ return Array.isArray(terms) && terms.length > 0 && terms.every(t => !!t?.from);
63
+ };
64
+ /** A length that follows something, as its terms and its margin; undefined for a number (or nothing). */
65
+ function followedTermsOf(measure) {
66
+ if (!measure || typeof measure !== 'object')
67
+ return undefined;
68
+ if (Object.hasOwn(measure, 'sum')) {
69
+ const m = measure;
70
+ return { terms: m.sum, plus: m.plus ?? 0, sum: true };
71
+ }
72
+ const m = measure;
73
+ return { terms: [{ from: m.from, ...(m.times !== undefined ? { times: m.times } : {}) }], plus: m.plus ?? 0, sum: false };
74
+ }
75
+ const everyKey = () => (...keys) => keys;
76
+ const JOINT_FIELDS = {
77
+ joint: everyKey()('id', 'type', 'body0', 'body1', 'origin', 'axis', 'lowerLimit', 'upperLimit', 'travel', 'state', 'mimic', 'repeat'),
78
+ body0: everyKey()('part', 'face', 'plane'),
79
+ body1: everyKey()('part', 'face'),
80
+ origin: everyKey()('facing', 'gap', 'align', 'turn', 'twist'),
81
+ axes: everyKey()('x', 'y', 'z'),
82
+ alignment: everyKey()('mm'),
83
+ measure: everyKey()('from', 'times', 'plus', 'sum'),
84
+ term: everyKey()('from', 'times'),
85
+ source: everyKey()('of', 'axis', 'input', 'part'),
86
+ travel: everyKey()('source', 'times', 'plus', 'sum'),
87
+ state: everyKey()('id', 'label', 'start', 'sweep'),
88
+ mimic: everyKey()('joint', 'multiplier', 'offset', 'sum'),
89
+ leader: everyKey()('joint', 'multiplier'),
90
+ repeat: everyKey()('axis', 'pitch', 'count', 'rise'),
91
+ rise: everyKey()('axis', 'mm')
92
+ };
93
+ /** Refuses, by name, a key of a joint record or of an object in it that this kernel does not know. A key left undefined is no field. */
94
+ function knownJointFields(j, path) {
95
+ const only = (o, allowed, at) => {
96
+ if (!o || typeof o !== 'object' || Array.isArray(o))
97
+ return;
98
+ for (const [k, v] of Object.entries(o))
99
+ if (v !== undefined && !allowed.includes(k))
100
+ fail('JOINT_SCHEMA', at, `${k} is a field this kernel does not know`);
101
+ };
102
+ const source = (s, at) => only(s, JOINT_FIELDS.source, at);
103
+ const measure = (m, at) => {
104
+ if (!m || typeof m !== 'object')
105
+ return;
106
+ only(m, JOINT_FIELDS.measure, at);
107
+ source(m.from, `${at}.from`);
108
+ if (Array.isArray(m.sum))
109
+ for (const [i, t] of m.sum.entries()) {
110
+ only(t, JOINT_FIELDS.term, `${at}.sum.${i}`);
111
+ source(t?.from, `${at}.sum.${i}.from`);
112
+ }
113
+ };
114
+ only(j, JOINT_FIELDS.joint, path);
115
+ only(j.body0, JOINT_FIELDS.body0, `${path}.body0`);
116
+ only(j.body1, JOINT_FIELDS.body1, `${path}.body1`);
117
+ const o = j.origin;
118
+ only(o, JOINT_FIELDS.origin, `${path}.origin`);
119
+ if (o && typeof o === 'object') {
120
+ measure(o.gap, `${path}.origin.gap`);
121
+ only(o.turn, JOINT_FIELDS.axes, `${path}.origin.turn`);
122
+ only(o.align, JOINT_FIELDS.axes, `${path}.origin.align`);
123
+ for (const [axis, a] of Object.entries(o.align && typeof o.align === 'object' ? o.align : {})) {
124
+ only(a, JOINT_FIELDS.alignment, `${path}.origin.align.${axis}`);
125
+ measure(a?.mm, `${path}.origin.align.${axis}.mm`);
126
+ }
127
+ }
128
+ if (j.travel && typeof j.travel === 'object') {
129
+ only(j.travel, JOINT_FIELDS.travel, `${path}.travel`);
130
+ source(j.travel.source, `${path}.travel.source`);
131
+ if (Array.isArray(j.travel.sum))
132
+ measure({ sum: j.travel.sum }, `${path}.travel`);
133
+ }
134
+ only(j.state, JOINT_FIELDS.state, `${path}.state`);
135
+ only(j.mimic, JOINT_FIELDS.mimic, `${path}.mimic`);
136
+ if (Array.isArray(j.mimic?.sum))
137
+ for (const [i, t] of j.mimic.sum.entries())
138
+ only(t, JOINT_FIELDS.leader, `${path}.mimic.sum.${i}`);
139
+ only(j.repeat, JOINT_FIELDS.repeat, `${path}.repeat`);
140
+ if (j.repeat && typeof j.repeat === 'object') {
141
+ measure(j.repeat.pitch, `${path}.repeat.pitch`);
142
+ only(j.repeat.rise, JOINT_FIELDS.rise, `${path}.repeat.rise`);
143
+ }
144
+ }
50
145
  const clone = (asset) => structuredClone(asset);
51
146
  const nodeById = (m, id) => m.nodes.find((n) => n.id === id);
52
147
  const writerOf = (m, ref) => m.nodes.find((n) => Object.values(n.outputs).includes(ref));
@@ -300,9 +395,29 @@ function measureRef(m, asset, name, measure, hint, path) {
300
395
  fail('SCHEMA', path, 'a finite length is required');
301
396
  return constantOf(m, 'mm', measure, 'd');
302
397
  }
303
- if (!measure || typeof measure !== 'object' || !measure.from)
398
+ if (!measure || typeof measure !== 'object' || !(Object.hasOwn(measure, 'sum') || measure.from))
304
399
  fail('SCHEMA', path, 'a length is a number of millimetres, or something to follow');
305
- return scaled(m, name, sourceRef(m, asset, measure.from, path), measure.times ?? 1, measure.plus ?? 0, hint);
400
+ const { terms, plus, sum } = followedTermsOf(measure);
401
+ const refs = termRefs(m, asset, terms, plus, path);
402
+ /* One term is written as it always was; a sum adds its terms, then the margin (the same add@1 and mul@1 nodes). */
403
+ if (!sum)
404
+ return scaled(m, name, refs[0].ref, refs[0].k, plus, hint);
405
+ return scaled(m, name, sumOf(m, name, refs, `${hint}.sum`), 1, plus, hint);
406
+ }
407
+ /** Each term of a followed length as a graph reference and its multiple, every one checked as a one-term length is. */
408
+ function termRefs(m, asset, terms, plus, path) {
409
+ if (!Array.isArray(terms) || !terms.length)
410
+ fail('SCHEMA', path, 'a sum has one term or more');
411
+ if (!Number.isFinite(plus))
412
+ fail('SCHEMA', path, 'finite times and plus required');
413
+ return terms.map((t, i) => {
414
+ const at = terms.length > 1 ? `${path}.sum.${i}` : path;
415
+ if (!t || typeof t !== 'object' || !t.from)
416
+ fail('SCHEMA', at, 'a length is a number of millimetres, or something to follow');
417
+ if (!Number.isFinite(t.times ?? 1))
418
+ fail('SCHEMA', at, 'finite times and plus required');
419
+ return { ref: sourceRef(m, asset, t.from, at), k: t.times ?? 1 };
420
+ });
306
421
  }
307
422
  /** Σ k·ref, written as nodes, with nothing emitted for a weight of zero and no node for a single term. */
308
423
  function sumOf(m, name, terms, hint) {
@@ -407,19 +522,23 @@ function linkDimension(asset, a) {
407
522
  const mine = stableDimension(m, a.part, a.axis, path);
408
523
  if (followsAlready(m, a.part, a.axis) && !a.replace)
409
524
  fail('DIMENSION_LINKED', path, `${a.part}'s ${a.axis} already follows something; pass replace to change what it follows, or unlink it first`);
410
- const times = a.times ?? 1;
525
+ const sum = Object.hasOwn(a, 'sum');
526
+ const terms = sum ? a.sum : [{ from: a.source, ...(a.times !== undefined ? { times: a.times } : {}) }];
411
527
  const plus = a.plus ?? 0;
412
- if (!Number.isFinite(times) || !Number.isFinite(plus))
413
- fail('SCHEMA', path, 'finite times and plus required');
414
- if (a.source.of === 'part' && a.source.part === a.part && a.source.axis === a.axis)
415
- fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
416
- const from = sourceRef(m, asset, a.source, path);
417
- if (from === mine)
418
- fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
419
- if (reaches(m, from, mine))
420
- fail('DIMENSION_CYCLE', path, `${String(a.source.of === 'part' ? a.source.part : a.source.of)} already follows ${a.part}'s ${a.axis}`);
528
+ /* Each term is checked for a loop as a one-term link is: none may read the measure it would set. */
529
+ for (const t of Array.isArray(terms) ? terms : [])
530
+ if (t?.from?.of === 'part' && t.from.part === a.part && t.from.axis === a.axis)
531
+ fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
532
+ const refs = termRefs(m, asset, terms, plus, path);
533
+ for (const [i, { ref }] of refs.entries()) {
534
+ const source = terms[i].from;
535
+ if (ref === mine)
536
+ fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
537
+ if (reaches(m, ref, mine))
538
+ fail('DIMENSION_CYCLE', path, `${String(source.of === 'part' ? source.part : source.of)} already follows ${a.part}'s ${a.axis}`);
539
+ }
421
540
  const name = namer(m, `${a.part}.${a.axis}`);
422
- const value = scaled(m, name, from, times, plus, 'follows');
541
+ const value = sum ? scaled(m, name, sumOf(m, name, refs, 'follows.sum'), 1, plus, 'follows') : scaled(m, name, refs[0].ref, refs[0].k, plus, 'follows');
423
542
  const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
424
543
  const was = node.args[0];
425
544
  const owned = ownInput(m, a.part, was) ? was : `${a.part}.size.${a.axis}`;
@@ -1236,6 +1355,7 @@ function checkJoint(asset, j) {
1236
1355
  const path = `joints.${String(j?.id)}`;
1237
1356
  if (!j || typeof j.id !== 'string' || !j.id.trim())
1238
1357
  fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
1358
+ knownJointFields(j, path);
1239
1359
  if (!FIGURE_JOINT_TYPES.includes(j.type))
1240
1360
  fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${FIGURE_JOINT_TYPES.join(', ')}`);
1241
1361
  if (!j.body1 || typeof j.body1.part !== 'string')
@@ -1275,19 +1395,38 @@ function checkJoint(asset, j) {
1275
1395
  const [lo, hi] = [j.lowerLimit, j.upperLimit];
1276
1396
  if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
1277
1397
  fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
1278
- if (!(lo <= 0 && 0 <= hi))
1279
- fail('JOINT_SCHEMA', path, 'the rest pose is the joint at 0, so its limits hold 0');
1398
+ /*
1399
+ The rest pose is every joint at 0 -- the kinematic reference its axis is read at (joint-list.ts) -- and 0 need not
1400
+ be a value the joint may take: a joint limited to 10..80 still has its reference at 0 (ADR-0107 J4). Its starting
1401
+ value, the stored state default, is held to its limits as every control's is (addMotion).
1402
+ */
1280
1403
  }
1281
1404
  if (j.mimic) {
1282
- const leader = others.find(o => o.id === j.mimic.joint);
1283
- if (!leader || leader.type === 'fixed')
1284
- fail('JOINT_SCHEMA', path, `${String(j.mimic.joint)} is not a moving joint to follow`);
1285
- if (leader.mimic)
1286
- fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
1287
- if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
1288
- fail('JOINT_SCHEMA', path, 'a joint follows one that moves the same way: a slide a slide, a turn a turn');
1289
- if (!Number.isFinite(j.mimic.multiplier))
1290
- fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
1405
+ /*
1406
+ One leader or a sum of them (ADR-0107 decision 2), each held to the same rules: a moving joint that leads itself
1407
+ -- so a follower is never a leader and no loop can close -- that moves the way this one does (degrees are not
1408
+ added to millimetres), named once, with a finite multiplier.
1409
+ */
1410
+ const sum = Object.hasOwn(j.mimic, 'sum');
1411
+ if (sum && !(Array.isArray(j.mimic.sum) && j.mimic.sum.length))
1412
+ fail('JOINT_SCHEMA', path, 'a mimic sum follows one joint or more');
1413
+ const seen = new Set();
1414
+ for (const t of figureMimicLeadersOf(j)) {
1415
+ const leader = others.find(o => o.id === t?.joint);
1416
+ if (!leader || leader.type === 'fixed')
1417
+ fail('JOINT_SCHEMA', path, `${String(t?.joint)} is not a moving joint to follow`);
1418
+ if (seen.has(leader.id))
1419
+ fail('JOINT_SCHEMA', path, `${leader.id} is followed twice; a sum names each joint once`);
1420
+ seen.add(leader.id);
1421
+ if (leader.mimic)
1422
+ fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
1423
+ if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
1424
+ 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`);
1425
+ if (!Number.isFinite(t.multiplier))
1426
+ fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
1427
+ }
1428
+ if (j.mimic.offset !== undefined && !Number.isFinite(j.mimic.offset))
1429
+ fail('JOINT_SCHEMA', path, "a mimic's offset is a number in the joint's unit");
1291
1430
  }
1292
1431
  if (j.origin?.twist !== undefined && !Number.isFinite(j.origin.twist))
1293
1432
  fail('JOINT_SCHEMA', `${path}.origin.twist`, 'a twist is a number of degrees');
@@ -1324,7 +1463,7 @@ function checkJoint(asset, j) {
1324
1463
  fail('JOINT_SCHEMA', row, `a row runs along X, Y or Z, not ${String(r.axis)}`);
1325
1464
  if (r.axis.toLowerCase() === FIGURE_FACES[childFaceOf(asset.document.model, j)])
1326
1465
  fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
1327
- if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !(r.pitch && typeof r.pitch === 'object' && r.pitch.from))
1466
+ if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !followsSomething(r.pitch))
1328
1467
  fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch, or one that follows a length');
1329
1468
  if (r.count !== undefined && !(Number.isSafeInteger(r.count) && r.count >= 1 && r.count <= REPEAT_LIMIT))
1330
1469
  fail('GEOMETRY_DOMAIN', row, `a row has 1 to ${REPEAT_LIMIT} copies`);
@@ -1407,7 +1546,7 @@ function writeJoint(asset, j) {
1407
1546
  const kind = j.type === 'prismatic' ? 'slide' : 'turn';
1408
1547
  const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
1409
1548
  const range = j.lowerLimit !== undefined && j.upperLimit !== undefined ? { min: j.lowerLimit, max: j.upperLimit } : j.type === 'revolute' ? { min: -180, max: 180 } : { min: 0, max: 1 };
1410
- const leader = j.mimic && (asset.joints ?? []).find(o => o.id === j.mimic.joint);
1549
+ const leaders = figureMimicLeadersOf(j).map(t => ({ leader: (asset.joints ?? []).find(o => o.id === t.joint), k: t.multiplier }));
1411
1550
  /* The axis turned into the parent's frame too: one of its axes, either way along it. */
1412
1551
  const along = mv3(back, AXES.map(a => (a === j.axis.toLowerCase() ? 1 : 0)));
1413
1552
  const axisIndex = along.findIndex(v => Math.abs(Math.abs(v) - 1) < 1e-9);
@@ -1417,7 +1556,7 @@ function writeJoint(asset, j) {
1417
1556
  kind: 'add-motion',
1418
1557
  part: child,
1419
1558
  motion: { kind, axis: AXES[axisIndex] },
1420
- state: { id: stateIdOf(j), unit, min: range.min, max: range.max, start: j.state?.start ?? 0, ...(j.state?.label !== undefined ? { label: j.state.label } : {}), ...(j.state?.sweep !== undefined ? { sweep: j.state.sweep } : {}) },
1559
+ state: { id: stateIdOf(j), unit, min: range.min, max: range.max, start: j.state?.start ?? Math.min(range.max, Math.max(range.min, 0)), ...(j.state?.label !== undefined ? { label: j.state.label } : {}), ...(j.state?.sweep !== undefined ? { sweep: j.state.sweep } : {}) },
1421
1560
  ...(j.travel ? { travel: j.travel } : {}),
1422
1561
  replace: true
1423
1562
  },
@@ -1426,7 +1565,13 @@ function writeJoint(asset, j) {
1426
1565
  stroke is a share of a length has a control of 0..1, and multiplying that gave a follower a number with no length
1427
1566
  (the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
1428
1567
  */
1429
- leader ? { of: nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader), k: j.mimic.multiplier, c: j.mimic.offset ?? 0 } : undefined, along[axisIndex] > 0 ? 1 : -1);
1568
+ j.mimic
1569
+ ? {
1570
+ terms: leaders.map(({ leader, k }) => ({ of: nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader), k })),
1571
+ c: j.mimic.offset ?? 0,
1572
+ sum: Object.hasOwn(j.mimic, 'sum')
1573
+ }
1574
+ : undefined, along[axisIndex] > 0 ? 1 : -1);
1430
1575
  if (j.repeat)
1431
1576
  repeatPart(asset, j);
1432
1577
  }
@@ -1711,7 +1856,45 @@ function stripJoint(asset, j) {
1711
1856
  dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
1712
1857
  place.args = [place.args[0], pose.outputs.pose];
1713
1858
  }
1714
- /** Joints in an order each can be written in: its parent's joint and the joint it mimics first. */
1859
+ /**
1860
+ * Every length a joint record holds that may follow something, read as a measure, with a way to write it back: the
1861
+ * gap, a row's pitch, a slide's travel (its one-term form names the source `source`, and without one is `plus` mm)
1862
+ * and each offset across the face. Renaming a part and taking one away read and write them all alike.
1863
+ */
1864
+ function jointMeasures(j) {
1865
+ const out = [];
1866
+ if (j.origin?.gap !== undefined)
1867
+ out.push({ at: 'gap', get: () => j.origin.gap, set: v => (j.origin.gap = v) });
1868
+ if (j.repeat)
1869
+ out.push({ at: 'pitch', get: () => j.repeat.pitch, set: v => (j.repeat.pitch = v) });
1870
+ if (j.travel) {
1871
+ const t = j.travel;
1872
+ out.push({
1873
+ at: 'travel',
1874
+ get: () => (Object.hasOwn(t, 'sum') ? { sum: t.sum, ...(t.plus !== undefined ? { plus: t.plus } : {}) } : t.source ? { from: t.source, ...(t.times !== undefined ? { times: t.times } : {}), ...(t.plus !== undefined ? { plus: t.plus } : {}) } : (t.plus ?? 0)),
1875
+ set: v => {
1876
+ const f = followedTermsOf(v);
1877
+ j.travel = !f ? { plus: v } : f.sum ? { sum: f.terms, ...(f.plus ? { plus: f.plus } : {}) } : { source: f.terms[0].from, ...(f.terms[0].times !== undefined ? { times: f.terms[0].times } : {}), ...(v.plus !== undefined ? { plus: v.plus } : {}) };
1878
+ }
1879
+ });
1880
+ }
1881
+ for (const axis of Object.keys(j.origin?.align ?? {})) {
1882
+ const how = j.origin.align[axis];
1883
+ if (how && typeof how === 'object' && Object.hasOwn(how, 'mm'))
1884
+ out.push({ at: `align.${axis}`, get: () => how.mm, set: v => (how.mm = v) });
1885
+ }
1886
+ return out;
1887
+ }
1888
+ /** The same measure with each term's source put through `to`, in the form it was written. */
1889
+ function mapMeasureSources(measure, to) {
1890
+ const f = followedTermsOf(measure);
1891
+ if (!f)
1892
+ return measure;
1893
+ if (f.sum)
1894
+ return { ...measure, sum: f.terms.map(t => ({ ...t, from: to(t.from) })) };
1895
+ return { ...measure, from: to(f.terms[0].from) };
1896
+ }
1897
+ /** Joints in an order each can be written in: its parent's joint and the joints it mimics first. */
1715
1898
  function writingOrder(joints) {
1716
1899
  const done = new Set();
1717
1900
  const out = [];
@@ -1721,7 +1904,7 @@ function writingOrder(joints) {
1721
1904
  if (done.has(j.id))
1722
1905
  return false;
1723
1906
  const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
1724
- return (!up || done.has(up.id)) && (!j.mimic || done.has(j.mimic.joint));
1907
+ return (!up || done.has(up.id)) && figureMimicLeadersOf(j).every(t => done.has(t.joint));
1725
1908
  });
1726
1909
  if (!ready.length)
1727
1910
  fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
@@ -1742,8 +1925,10 @@ function rewriteJoints(asset) {
1742
1925
  2026-09-25). The names are made first, so every joint measures the same references.
1743
1926
  */
1744
1927
  for (const j of joints)
1745
- if (j.travel?.source && j.travel.source.of === 'part')
1746
- stableDimension(asset.document.model, j.travel.source.part, j.travel.source.axis, `joints.${j.id}.travel`);
1928
+ for (const { at, get } of jointMeasures(j))
1929
+ for (const t of followedTermsOf(get())?.terms ?? [])
1930
+ if (t?.from?.of === 'part')
1931
+ stableDimension(asset.document.model, t.from.part, t.from.axis, `joints.${j.id}.${at}`);
1747
1932
  const order = writingOrder(joints);
1748
1933
  const keep = { ...asset.stateDefaults };
1749
1934
  /* What hangs from a row's part is drawn through the row; it is a part of its own again while the joints are written. */
@@ -1765,7 +1950,11 @@ function rewriteJoints(asset) {
1765
1950
  * nodes are made from them (ADR-0093). Renaming or deleting a part in the graph alone left records naming a part that
1766
1951
  * was no longer there, and the author could not save (TARGET_ABSENT on joints.*, 2026-09-25).
1767
1952
  */
1768
- /** The records follow a part's new name -- as a joint's parent, as its child, and as the joint a follower follows -- and the nodes are made again from them. */
1953
+ /**
1954
+ * The records follow a part's new name -- as a joint's parent, as its child, and as the joint a follower follows -- and
1955
+ * the nodes are made again from them. Nothing is frozen: a length that followed the part still follows it, and the
1956
+ * stored design inputs keep their values under the carried names, so the figure stands as it did at every size.
1957
+ */
1769
1958
  export function renameFigurePartInJoints(asset, from, to) {
1770
1959
  const joints = asset.joints;
1771
1960
  if (!joints?.length)
@@ -1790,14 +1979,23 @@ export function renameFigurePartInJoints(asset, from, to) {
1790
1979
  if (out.occupancy)
1791
1980
  out.occupancy = carried(out.occupancy);
1792
1981
  out.designInputs = Object.fromEntries(Object.entries(out.designInputs ?? {}).map(([k, v]) => [carried(k), v]));
1793
- asset.joints = joints.map(j => ({
1794
- ...j,
1795
- id: name(j.id),
1796
- body0: 'part' in j.body0 ? { ...j.body0, part: name(j.body0.part) } : j.body0,
1797
- body1: { ...j.body1, part: name(j.body1.part) },
1798
- ...(j.mimic ? { mimic: { ...j.mimic, joint: name(j.mimic.joint) } } : {}),
1799
- ...(j.state?.id !== undefined ? { state: { ...j.state, id: name(j.state.id) } } : {})
1800
- }));
1982
+ /* A length that follows the part's measure follows it under its new name, in each term of a sum too. */
1983
+ const source = (s) => (s?.of === 'part' ? { ...s, part: name(s.part) } : s);
1984
+ asset.joints = joints.map(was => {
1985
+ const j = structuredClone(was);
1986
+ for (const { get, set } of jointMeasures(j))
1987
+ set(mapMeasureSources(get(), source));
1988
+ return {
1989
+ ...j,
1990
+ id: name(j.id),
1991
+ body0: 'part' in j.body0 ? { ...j.body0, part: name(j.body0.part) } : j.body0,
1992
+ body1: { ...j.body1, part: name(j.body1.part) },
1993
+ ...(j.mimic
1994
+ ? { mimic: Object.hasOwn(j.mimic, 'sum') ? { ...j.mimic, sum: figureMimicLeadersOf(j).map(t => ({ ...t, joint: name(t.joint) })) } : { ...j.mimic, joint: name(j.mimic.joint) } }
1995
+ : {}),
1996
+ ...(j.state?.id !== undefined ? { state: { ...j.state, id: name(j.state.id) } } : {})
1997
+ };
1998
+ });
1801
1999
  rewriteJoints(asset);
1802
2000
  }
1803
2001
  /**
@@ -1889,6 +2087,11 @@ export function releaseFigurePartJoints(asset, part, options = {}) {
1889
2087
  const children = joints.filter(j => 'part' in j.body0 && j.body0.part === part);
1890
2088
  if (own?.repeat && children.length)
1891
2089
  fail('PART_ROW', part, `${children.map(j => j.body1.part).join(', ')} ${children.length > 1 ? 'hang' : 'hangs'} in the row ${part}, on every copy; take ${children.length > 1 ? 'them' : 'it'} off first`);
2090
+ /*
2091
+ Every number frozen below -- a follower's range and start, a measure that followed the part, a value read from it --
2092
+ is evaluated at the asset's stored design inputs and state defaults: the figure as it is authored, so its placement
2093
+ there is what it was. At another instance size the frozen numbers no longer follow.
2094
+ */
1892
2095
  const inputs = (over = {}) => ({ ...a.designInputs, ...a.stateDefaults, ...over });
1893
2096
  const controls = m.inputs.filter((i) => i.role === 'state');
1894
2097
  const rest = Object.fromEntries(controls.map((i) => [i.id, Math.min(i.max, Math.max(i.min, 0))]));
@@ -1899,47 +2102,78 @@ export function releaseFigurePartJoints(asset, part, options = {}) {
1899
2102
  const going = new Set([...(own ? [own.id] : []), ...children.map(j => j.id)]);
1900
2103
  /* A follower has no control of its own, so its own record's nominal id is not taken. */
1901
2104
  const taken = new Set([...m.inputs.map((i) => i.id), ...joints.filter(j => !j.mimic).map(stateIdOf)]);
1902
- for (const f of joints.filter(o => o.mimic && going.has(o.mimic.joint) && !going.has(o.id))) {
1903
- const leader = joints.find(o => o.id === f.mimic.joint);
1904
- const leaderPart = leader.body1.part;
1905
- const quantity = nodeById(m, `${leaderPart}.motion`)?.args[3] ?? stateIdOf(leader);
1906
- const state = m.inputs.find((i) => i.id === stateIdOf(leader));
1907
- const distance = (value) => Number(graph.evaluate(inputs({ [stateIdOf(leader)]: value })).values[quantity]);
2105
+ for (const f of joints.filter(o => figureMimicLeadersOf(o).some(t => going.has(t.joint)) && !going.has(o.id))) {
2106
+ /*
2107
+ A sum whose leaders all go is a control of its own over what the sum came to, as one leader's follower is. One
2108
+ whose other leaders stay would follow them and stand still by the rest -- what it should then be is not said, so
2109
+ it is refused by name rather than made up.
2110
+ */
2111
+ const staying = figureMimicLeadersOf(f).filter(t => !going.has(t.joint));
2112
+ if (staying.length)
2113
+ fail('PART_FOLLOWED', part, `${f.id} follows ${staying.map(t => t.joint).join(', ')} as well as a joint that goes with ${part}; make ${f.id} follow only the joints that stay first`);
2114
+ const terms = figureMimicLeadersOf(f).map(t => {
2115
+ const leader = joints.find(o => o.id === t.joint);
2116
+ const quantity = nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader);
2117
+ const state = m.inputs.find((i) => i.id === stateIdOf(leader));
2118
+ const distance = (value) => Number(graph.evaluate(inputs({ [stateIdOf(leader)]: value })).values[quantity]);
2119
+ const [a, b] = [distance(state?.min ?? 0) * t.multiplier, distance(state?.max ?? 1) * t.multiplier];
2120
+ return { lo: Math.min(a, b), hi: Math.max(a, b), now: Number(now[quantity]) * t.multiplier };
2121
+ });
1908
2122
  const turn = f.type === 'revolute';
1909
2123
  const toUnit = (base) => round6(turn ? (base * 180) / Math.PI : base);
1910
2124
  const offset = (f.mimic.offset ?? 0) * (turn ? Math.PI / 180 : 1);
1911
- const follow = (base) => toUnit(base * f.mimic.multiplier + offset);
1912
- const [lo, hi] = [follow(distance(state?.min ?? 0)), follow(distance(state?.max ?? 1))].sort((x, y) => x - y);
2125
+ /*
2126
+ Each leader moves on its own control, so the sum's least and greatest are each term's, added (a negative multiplier
2127
+ swaps a term's ends, above). When leaders are driven by one upstream input -- not the case today, a leader leads
2128
+ itself -- the summed range is conservative: it holds every value the sum can take, not the exact reach.
2129
+ */
2130
+ const total = (pick) => toUnit(terms.reduce((sum, t, i) => (i ? sum + pick(t) : pick(t)), 0) + offset);
2131
+ const [lo, hi] = [total(t => t.lo), total(t => t.hi)];
1913
2132
  let id = f.body1.part;
1914
2133
  for (let n = 2; taken.has(id); n++)
1915
2134
  id = `${f.body1.part}-${n}`;
1916
2135
  taken.add(id);
1917
- const start = Math.min(hi, Math.max(lo, follow(Number(now[quantity]))));
2136
+ const start = Math.min(hi, Math.max(lo, total(t => t.now)));
1918
2137
  const { mimic: _led, travel: _share, ...alone } = f;
1919
2138
  joints = joints.map(o => o.id === f.id ? { ...alone, lowerLimit: lo, upperLimit: hi, state: { ...(f.state ?? {}), id, start } } : o);
1920
2139
  notice.controls.push({ part: f.body1.part, control: id, min: lo, max: hi });
1921
2140
  }
1922
- /* A value in a record that followed the part: the number it is now. */
2141
+ /*
2142
+ A value in a record that followed the part: the number it is now -- at the stored design inputs (above). In a sum, each term that reads the part becomes
2143
+ its number, added to the margin, and the terms that read something else still follow; no term left, the number.
2144
+ */
1923
2145
  const freezeMeasure = (measure, where) => {
1924
- if (!measure || typeof measure !== 'object' || measure.from.of !== 'part' || measure.from.part !== part)
2146
+ const f = followedTermsOf(measure);
2147
+ if (!f || !f.terms.some(t => t.from?.of === 'part' && t.from.part === part))
1925
2148
  return measure;
1926
2149
  const probe = structuredClone(m);
1927
- const ref = stableDimension(probe, part, measure.from.axis, where);
1928
- const value = round6(Number(compileFigureGraph(probe).evaluate(inputs()).values[ref]) * (measure.times ?? 1) + (measure.plus ?? 0));
1929
- notice.fixed.push({ part: where, value, unit: 'mm' });
1930
- return value;
2150
+ let frozen = 0;
2151
+ const rest = [];
2152
+ for (const t of f.terms) {
2153
+ if (!(t.from.of === 'part' && t.from.part === part)) {
2154
+ rest.push(t);
2155
+ continue;
2156
+ }
2157
+ const ref = stableDimension(probe, part, t.from.axis, where);
2158
+ frozen += Number(compileFigureGraph(probe).evaluate(inputs()).values[ref]) * (t.times ?? 1);
2159
+ }
2160
+ if (!rest.length) {
2161
+ const value = round6(frozen + f.plus);
2162
+ notice.fixed.push({ part: where, value, unit: 'mm' });
2163
+ return value;
2164
+ }
2165
+ notice.fixed.push({ part: where, value: round6(frozen), unit: 'mm' });
2166
+ return { sum: rest, ...(round6(frozen + f.plus) ? { plus: round6(frozen + f.plus) } : {}) };
1931
2167
  };
1932
2168
  joints = joints.map(j => {
1933
2169
  if (j.id === own?.id || children.includes(j))
1934
2170
  return j;
1935
2171
  const out = structuredClone(j);
1936
- if (out.origin?.gap !== undefined)
1937
- out.origin.gap = freezeMeasure(out.origin.gap, j.body1.part);
1938
- if (out.repeat)
1939
- out.repeat.pitch = freezeMeasure(out.repeat.pitch, j.body1.part);
1940
- if (out.travel?.source?.of === 'part' && out.travel.source.part === part) {
1941
- const plus = freezeMeasure({ from: out.travel.source, times: out.travel.times, plus: out.travel.plus }, j.body1.part);
1942
- out.travel = { plus };
2172
+ for (const { get, set } of jointMeasures(out)) {
2173
+ const was = get();
2174
+ const fixed = freezeMeasure(was, j.body1.part);
2175
+ if (fixed !== was)
2176
+ set(fixed);
1943
2177
  }
1944
2178
  return out;
1945
2179
  });
@@ -2152,7 +2386,7 @@ function setJoint(asset, joint) {
2152
2386
  j.origin = { ...(j.origin ?? {}), turn: Object.fromEntries(AXES.map((a, i) => [a, turn[i]])) };
2153
2387
  }
2154
2388
  const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
2155
- const followers = joints.filter(o => o.mimic?.joint === j.id);
2389
+ const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === j.id));
2156
2390
  if (followers.length && j.type === 'fixed')
2157
2391
  fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
2158
2392
  asset.joints = [...joints, j];
@@ -2213,7 +2447,7 @@ function removeJoint(asset, id) {
2213
2447
  const j = joints.find(o => o.id === id);
2214
2448
  if (!j)
2215
2449
  fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
2216
- const followers = joints.filter(o => o.mimic?.joint === id);
2450
+ const followers = joints.filter(o => figureMimicLeadersOf(o).some(t => t.joint === id));
2217
2451
  if (followers.length)
2218
2452
  fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
2219
2453
  const m = asset.document.model;
@@ -2315,10 +2549,28 @@ function addMotion(asset, a, follow, sign = 1) {
2315
2549
  const name = namer(m, `${a.part}.motion`);
2316
2550
  let quantity = st?.id;
2317
2551
  if (follow) {
2318
- /* A mimic: the leader's value times k plus c, and no control of its own. */
2319
- const scaledId = name('mimic');
2320
- m.nodes.push({ id: scaledId, op: 'mul@1', args: [follow.of, constantOf(m, 'ratio', follow.k, 'k')], outputs: { value: `${scaledId}.value` } });
2321
- quantity = `${scaledId}.value`;
2552
+ /*
2553
+ A mimic: the leader's value times k plus c, and no control of its own. A sum is each leader's value times its own
2554
+ k, added up, then c -- written with the same mul@1 and add@1 a one-leader mimic is, so a pose step runs it as it
2555
+ runs that (ADR-0108), and the gate reads it by the same operators.
2556
+ */
2557
+ if (!follow.sum) {
2558
+ const scaledId = name('mimic');
2559
+ m.nodes.push({ id: scaledId, op: 'mul@1', args: [follow.terms[0].of, constantOf(m, 'ratio', follow.terms[0].k, 'k')], outputs: { value: `${scaledId}.value` } });
2560
+ quantity = `${scaledId}.value`;
2561
+ }
2562
+ else
2563
+ for (const [i, t] of follow.terms.entries()) {
2564
+ const scaledId = name(`mimic.${i}`);
2565
+ m.nodes.push({ id: scaledId, op: 'mul@1', args: [t.of, constantOf(m, 'ratio', t.k, 'k')], outputs: { value: `${scaledId}.value` } });
2566
+ if (!i)
2567
+ quantity = `${scaledId}.value`;
2568
+ else {
2569
+ const added = name(`mimic.${i}.sum`);
2570
+ m.nodes.push({ id: added, op: 'add@1', args: [quantity, `${scaledId}.value`], outputs: { value: `${added}.value` } });
2571
+ quantity = `${added}.value`;
2572
+ }
2573
+ }
2322
2574
  if (follow.c) {
2323
2575
  const plus = name('mimic.offset');
2324
2576
  /* The offset is in the follower's own unit: millimetres for a slide, degrees for a turn. */
@@ -2375,8 +2627,16 @@ function addMotion(asset, a, follow, sign = 1) {
2375
2627
  if (!a.travel)
2376
2628
  fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
2377
2629
  const travel = a.travel;
2378
- const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
2379
- const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
2630
+ let length;
2631
+ if (Object.hasOwn(travel, 'sum')) {
2632
+ /* A stroke that is a sum of lengths: the terms added, then the margin, as any followed length (ADR-0107). */
2633
+ const plus = travel.plus ?? 0;
2634
+ length = scaled(m, name, sumOf(m, name, termRefs(m, asset, travel.sum, plus, `${path}.travel`), 'travel.sum'), 1, plus, 'travel');
2635
+ }
2636
+ else {
2637
+ const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
2638
+ length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
2639
+ }
2380
2640
  const id = name('distance');
2381
2641
  m.nodes.push({ id, op: 'mul@1', args: [quantity, length], outputs: { value: `${id}.value` } });
2382
2642
  quantity = `${id}.value`;