@hatiolab/figure-model 0.1.56 → 0.1.57

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.
@@ -31,6 +31,12 @@ const fail = (code, path, message) => {
31
31
  };
32
32
  /** The six faces of a box-shaped part, and the axis each one faces along. */
33
33
  export const V3_FACES = { left: 'x', right: 'x', bottom: 'y', top: 'y', back: 'z', front: 'z' };
34
+ /**
35
+ * UsdPhysics's joint types (ADR-0093, amended 2026-09-25: the joint follows USD). A `fixed` joint is a fastening; a
36
+ * `revolute` joint turns -- with no limits it turns without end, its value a phase (-180..180), as USD's revolute
37
+ * joint without limits does; a `prismatic` joint slides.
38
+ */
39
+ export const V3_JOINT_TYPES = ['fixed', 'revolute', 'prismatic'];
34
40
  const SIGN = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 };
35
41
  const OPPOSITE = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' };
36
42
  const clone = (asset) => structuredClone(asset);
@@ -74,12 +80,48 @@ function angleOf(m, asset, ref) {
74
80
  return asset.designInputs[ref] ?? null;
75
81
  return null;
76
82
  }
83
+ /** The turn in a part's pose, in degrees about X, Y and Z, or null for an axis whose turn is neither a number nor a design value. */
84
+ function turnOf(m, asset, id, path) {
85
+ return partOf(m, id, path).pose.args.slice(3, 6).map((ref) => angleOf(m, asset, ref));
86
+ }
87
+ const saidTurn = (turn) => AXES.map((a, i) => `${a.toUpperCase()} ${turn[i] ?? '?'}°`).join(', ');
77
88
  /** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
78
89
  function requireUnturned(m, asset, id, path) {
79
- const { pose } = partOf(m, id, path);
80
- for (const ref of pose.args.slice(3, 6))
81
- if (angleOf(m, asset, ref) !== 0)
82
- fail('EDIT_TARGET', path, `${id} is turned; fastening a turned part by its faces is not covered by this command`);
90
+ const turn = turnOf(m, asset, id, path);
91
+ if (turn.some(t => t !== 0))
92
+ fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is not covered by this command`);
93
+ }
94
+ /**
95
+ * The part's size along each axis as it stands in its seat -- after its own turn. A turn by a whole number of
96
+ * quarter turns only swaps the axes (a cylinder laid on its side is 2r tall and its length deep), so its faces are
97
+ * still axis-aligned planes; any other turn is refused, and says the angle. The turn is then fixed as it is: a part
98
+ * fastened by its faces keeps the turn its faces were measured in (chief architect's ruling 2026-09-25).
99
+ */
100
+ function standingRefsOf(m, asset, id, path, freeze = true) {
101
+ const own = sizeRefsOf(m, id, path);
102
+ const turn = turnOf(m, asset, id, path);
103
+ if (turn.every(t => t === 0))
104
+ return own;
105
+ if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
106
+ fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); only quarter turns keep its faces level with the axes`);
107
+ const [rx, ry, rz] = turn.map(t => (t * Math.PI) / 180);
108
+ const cos = (a) => Math.round(Math.cos(a)), sin = (a) => Math.round(Math.sin(a));
109
+ const X = [[1, 0, 0], [0, cos(rx), -sin(rx)], [0, sin(rx), cos(rx)]];
110
+ const Y = [[cos(ry), 0, sin(ry)], [0, 1, 0], [-sin(ry), 0, cos(ry)]];
111
+ const Z = [[cos(rz), -sin(rz), 0], [sin(rz), cos(rz), 0], [0, 0, 1]];
112
+ const mul = (a, b) => a.map(row => b[0].map((_, j) => row.reduce((sum, v, k) => sum + v * b[k][j], 0)));
113
+ const r = mul(mul(X, Y), Z); // the order `rigid@1` turns in
114
+ if (freeze) {
115
+ const pose = partOf(m, id, path).pose;
116
+ const old = pose.args.slice(3, 6);
117
+ pose.args = [...pose.args.slice(0, 3), ...turn.map(t => constantOf(m, 'deg', t, 'a'))];
118
+ // Its turn fields go with it: typing into them would change nothing.
119
+ for (const ref of old)
120
+ dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
121
+ }
122
+ const standing = {};
123
+ AXES.forEach((axis, i) => (standing[axis] = own[AXES[r[i].findIndex(v => v !== 0)]]));
124
+ return standing;
83
125
  }
84
126
  /** Fresh node ids under a part, so two commands never claim one id. */
85
127
  function namer(m, prefix) {
@@ -228,6 +270,9 @@ function linkDimension(asset, a) {
228
270
  const was = node.args[0];
229
271
  const owned = ownInput(m, a.part, was) ? was : `${a.part}.size.${a.axis}`;
230
272
  node.args = [value, node.args[1]];
273
+ // Joints read this part's size; made again from their records, they read the size as it now is.
274
+ if (asset.joints?.length)
275
+ rewriteJoints(asset);
231
276
  // The part's own control is no longer read by anything; leaving it would show a dial that changes nothing.
232
277
  dropIfUnused(asset, was, owned);
233
278
  return asset;
@@ -258,6 +303,8 @@ function unlinkDimension(asset, a) {
258
303
  const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
259
304
  const was = node.args[0];
260
305
  node.args = [id, node.args[1]];
306
+ if (asset.joints?.length)
307
+ rewriteJoints(asset);
261
308
  dropIfUnused(asset, was);
262
309
  return asset;
263
310
  }
@@ -293,8 +340,8 @@ function dropIfUnused(asset, ref, ownedInput) {
293
340
  function parentOf(m, id) {
294
341
  if (!nodeById(m, `${id}.chain`))
295
342
  return null;
296
- // The seat's own frame leads either to a motion node or, when the part does not move, to an identity rigid.
297
- const above = nodeById(m, `${id}.motion`) ?? nodeById(m, `${id}.seat.rigid`);
343
+ // The seat rigid carries the part into the frame it is fastened to, moving or not (an old asset may have only a motion).
344
+ const above = nodeById(m, `${id}.seat.rigid`) ?? nodeById(m, `${id}.motion`);
298
345
  const match = /^(.*)\.local$/.exec(String(above?.params?.to));
299
346
  return match && match[1] !== id ? match[1] : null;
300
347
  }
@@ -327,10 +374,9 @@ function attach(asset, a) {
327
374
  for (let up = to.part; up; up = parentOf(m, up))
328
375
  if (up === a.part)
329
376
  fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
330
- const mine = sizeRefsOf(m, a.part, path);
331
377
  const theirs = sizeRefsOf(m, to.part, path);
332
- requireUnturned(m, asset, a.part, path);
333
378
  requireUnturned(m, asset, to.part, path);
379
+ const mine = standingRefsOf(m, asset, a.part, path);
334
380
  const name = namer(m, `${a.part}.on.${to.part}`);
335
381
  const sign = SIGN[to.face];
336
382
  /*
@@ -339,13 +385,21 @@ function attach(asset, a) {
339
385
  */
340
386
  const outward = facing === 'meet' ? sign : -sign;
341
387
  const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
388
+ /*
389
+ Split at the point fastened at: the seat is on the target's face (its centre, or level with an edge), the part's
390
+ centre is half its own depth and the gap past it. A turn added later turns about that point.
391
+ */
392
+ const zero = constantOf(m, 'mm', 0, 'd');
342
393
  const translation = { x: '', y: '', z: '' };
343
- translation[axis] = sumOf(m, name, [
344
- { ref: theirs[axis].ref, k: sign * 0.5 * theirs[axis].k },
345
- { ref: mine[axis].ref, k: outward * 0.5 * mine[axis].k },
346
- { ref: gap, k: outward }
347
- ], `along.${axis}`);
348
- // Across the face. Centred by default; min and max keep the two parts' edges level as either is resized.
394
+ const offset = { x: zero, y: zero, z: zero };
395
+ translation[axis] = sumOf(m, name, [{ ref: theirs[axis].ref, k: sign * 0.5 * theirs[axis].k }], `seat.${axis}`);
396
+ offset[axis] = sumOf(m, name, [{ ref: mine[axis].ref, k: outward * 0.5 * mine[axis].k }, { ref: gap, k: outward }], `along.${axis}`);
397
+ /*
398
+ Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
399
+ point fastened at is where those edges meet: the seat on the target's edge, the part's own edge on it. A door
400
+ fastened level with the frame's min edge turns about that edge, as a hinge does (2026-09-25); centred, the point
401
+ is the middle of the face.
402
+ */
349
403
  for (const other of AXES.filter(x => x !== axis)) {
350
404
  const how = a.align?.[other] ?? 'centre';
351
405
  if (how === 'centre')
@@ -354,12 +408,13 @@ function attach(asset, a) {
354
408
  translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
355
409
  else if (how === 'min' || how === 'max') {
356
410
  const s = how === 'min' ? -1 : 1;
357
- translation[other] = sumOf(m, name, [{ ref: theirs[other].ref, k: s * 0.5 * theirs[other].k }, { ref: mine[other].ref, k: -s * 0.5 * mine[other].k }], `align.${other}`);
411
+ translation[other] = sumOf(m, name, [{ ref: theirs[other].ref, k: s * 0.5 * theirs[other].k }], `edge.${other}`);
412
+ offset[other] = sumOf(m, name, [{ ref: mine[other].ref, k: -s * 0.5 * mine[other].k }], `own-edge.${other}`);
358
413
  }
359
414
  else
360
415
  fail('SCHEMA', path, `${String(how)} is not an alignment`);
361
416
  }
362
- reseat(asset, a.part, `${to.part}.local`, [translation.x, translation.y, translation.z]);
417
+ reseat(asset, a.part, `${to.part}.local`, { seat: [translation.x, translation.y, translation.z], pose: [offset.x, offset.y, offset.z] });
363
418
  return asset;
364
419
  }
365
420
  /**
@@ -377,8 +432,7 @@ function standOnPlane(asset, a, path) {
377
432
  fail('ATTACH_FACE', path, `${String(a.face)} faces along ${V3_FACES[a.face] ?? '?'}; the plane faces along y, so a part stands on it by its bottom or hangs from it by its top`);
378
433
  if (parentOf(m, a.part) && !a.replace)
379
434
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
380
- const mine = sizeRefsOf(m, a.part, path);
381
- requireUnturned(m, asset, a.part, path);
435
+ const mine = standingRefsOf(m, asset, a.part, path);
382
436
  const name = namer(m, `${a.part}.on.plane`);
383
437
  const outward = a.face === 'bottom' ? 1 : -1;
384
438
  const gap = measureRef(m, asset, name, a.gap, 'gap.y', path);
@@ -393,7 +447,9 @@ function standOnPlane(asset, a, path) {
393
447
  else
394
448
  fail('SCHEMA', path, `the plane has no edges to line ${a.part} up with on ${other}; give the centre or a measured offset`);
395
449
  }
396
- reseat(asset, a.part, asset.document.capabilities.assetFrame, [translation.x, translation.y, translation.z]);
450
+ // The point it stands on is on the plane, under its centre: a turn about Y turns it where it stands.
451
+ const zero = constantOf(m, 'mm', 0, 'd');
452
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat: [translation.x, zero, translation.z], pose: [zero, translation.y, zero] });
397
453
  return asset;
398
454
  }
399
455
  function detach(asset, a) {
@@ -434,8 +490,15 @@ function detach(asset, a) {
434
490
  The part gets its own position numbers back, as a part made in the modeller has them, so it can be moved again
435
491
  by hand or by the gizmo. Where one of those names is taken, that axis stays a plain number.
436
492
  */
493
+ /*
494
+ A part that moves keeps turning about the point it was fastened at: that point becomes its seat in the asset, its
495
+ own numbers the offset from it. A part that does not move gets its place back as plain position numbers.
496
+ */
497
+ const seatAt = evaluated.values?.[`${a.part}.seat.value`]?.t ?? [0, 0, 0];
498
+ const moving = !!nodeById(m, `${a.part}.motion`);
499
+ const pivot = AXES.map((_, i) => round6(above.t[i] + seatAt[i]));
437
500
  const translation = AXES.map((axis, i) => {
438
- const value = round6(above.t[i] + own.t[i]);
501
+ const value = moving ? round6(own.t[i]) : round6(pivot[i] + own.t[i]);
439
502
  const id = `${a.part}.position.${axis}`;
440
503
  if (m.inputs.some((input) => input.id === id) || m.nodes.some((n) => Object.values(n.outputs).includes(id)))
441
504
  return constantOf(m, 'mm', value, 'd');
@@ -443,7 +506,8 @@ function detach(asset, a) {
443
506
  asset.designInputs[id] = value;
444
507
  return id;
445
508
  });
446
- reseat(asset, a.part, asset.document.capabilities.assetFrame, translation);
509
+ const seat = AXES.map((_, i) => constantOf(m, 'mm', moving ? pivot[i] : 0, 'd'));
510
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat, pose: translation });
447
511
  return asset;
448
512
  }
449
513
  const round6 = (v) => Math.round(v * 1e6) / 1e6;
@@ -451,36 +515,55 @@ const round6 = (v) => Math.round(v * 1e6) / 1e6;
451
515
  * Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
452
516
  * The part's own id, its shape and its `placed` reference all stay as they were.
453
517
  */
454
- function reseat(asset, id, to, translation) {
518
+ /**
519
+ * Where a part is fastened, in two translations that are never merged: `seat` is the point it is fastened at, in the
520
+ * frame it is fastened to -- a point on the target's face; `pose` is the part's centre from that point, in its own
521
+ * seat frame. A motion sits between the two, so a turn turns about the point the part is fastened at: a hinge, a
522
+ * joint, a lid (chief architect's ruling 2026-09-25; before this the seat was the target's centre and a fastened
523
+ * part swung about the middle of what it was fastened to).
524
+ *
525
+ * <id>.local --pose--> <id>.seat [--motion--> <id>.joint] --seat.rigid--> <to>
526
+ *
527
+ * Without `place` the part keeps both translations and only its frames are rewired (a motion added or taken away).
528
+ */
529
+ function reseat(asset, id, to, place) {
455
530
  const m = asset.document.model;
456
- const { place, pose } = partOf(m, id, id);
457
- const old = translation ? pose.args.slice(0, 3) : [];
458
- if (translation)
459
- pose.args = [...translation, ...pose.args.slice(3)];
531
+ const { place: placed, pose } = partOf(m, id, id);
532
+ const old = place ? pose.args.slice(0, 3) : [];
533
+ if (place)
534
+ pose.args = [...place.pose, ...pose.args.slice(3)];
460
535
  pose.params = { ...pose.params, to: `${id}.seat` };
461
536
  const motion = nodeById(m, `${id}.motion`);
462
- if (motion)
463
- motion.params = { ...motion.params, from: `${id}.seat`, to };
464
- // seat → the frame it is fastened to. Without a motion node the seat is that frame, through an identity rigid.
465
- let chain = nodeById(m, `${id}.chain`);
466
- if (!chain) {
467
- chain = { id: `${id}.chain`, op: 'compose@1', args: ['', pose.outputs.pose], outputs: { pose: `${id}.chain.value` } };
468
- m.nodes.push(chain);
469
- }
537
+ const zero = constantOf(m, 'mm', 0, 'd');
538
+ const noTurn = constantOf(m, 'deg', 0, 'a');
470
539
  let seat = nodeById(m, `${id}.seat.rigid`);
471
- if (!motion) {
472
- const zero = constantOf(m, 'mm', 0, 'd');
473
- const noTurn = constantOf(m, 'deg', 0, 'a');
474
- if (!seat) {
475
- seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: { from: `${id}.seat`, to } };
476
- m.nodes.push(seat);
540
+ if (!seat) {
541
+ seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: {} };
542
+ m.nodes.push(seat);
543
+ }
544
+ const oldSeat = place ? seat.args.slice(0, 3) : [];
545
+ if (place)
546
+ seat.args = [...place.seat, ...seat.args.slice(3)];
547
+ seat.params = { from: motion ? `${id}.joint` : `${id}.seat`, to };
548
+ // The motion turns the seat frame about its origin -- the point fastened at -- into the joint frame.
549
+ let turned = nodeById(m, `${id}.turned`);
550
+ if (motion) {
551
+ motion.params = { ...motion.params, from: `${id}.seat`, to: `${id}.joint` };
552
+ if (!turned) {
553
+ turned = { id: `${id}.turned`, op: 'compose@1', args: [motion.outputs.pose, pose.outputs.pose], outputs: { pose: `${id}.turned.value` } };
554
+ m.nodes.push(turned);
477
555
  }
478
556
  else
479
- seat.params = { ...seat.params, to };
480
- chain.args = [seat.outputs.pose, pose.outputs.pose];
557
+ turned.args = [motion.outputs.pose, pose.outputs.pose];
481
558
  }
482
- else
483
- chain.args = [motion.outputs.pose, pose.outputs.pose];
559
+ else if (turned)
560
+ m.nodes = m.nodes.filter((n) => n !== turned);
561
+ let chain = nodeById(m, `${id}.chain`);
562
+ if (!chain) {
563
+ chain = { id: `${id}.chain`, op: 'compose@1', args: ['', ''], outputs: { pose: `${id}.chain.value` } };
564
+ m.nodes.push(chain);
565
+ }
566
+ chain.args = [seat.outputs.pose, motion ? turned.outputs.pose : pose.outputs.pose];
484
567
  const parent = /^(.*)\.local$/.exec(to);
485
568
  let world = nodeById(m, `${id}.world`);
486
569
  if (parent && parent[1] !== id) {
@@ -491,18 +574,18 @@ function reseat(asset, id, to, translation) {
491
574
  }
492
575
  else
493
576
  world.args = [above, chain.outputs.pose];
494
- place.args = [place.args[0], world.outputs.pose];
577
+ placed.args = [placed.args[0], world.outputs.pose];
495
578
  }
496
579
  else {
497
580
  if (world)
498
581
  m.nodes = m.nodes.filter((n) => n !== world);
499
- place.args = [place.args[0], chain.outputs.pose];
582
+ placed.args = [placed.args[0], chain.outputs.pose];
500
583
  }
501
584
  /*
502
585
  The part's own position numbers, once nothing reads them, go with the old pose: a fastened part left its
503
586
  position fields in the editor, and typing into them changed nothing (seen on :3300, 2026-09-24).
504
587
  */
505
- for (const ref of old)
588
+ for (const ref of [...old, ...oldSeat])
506
589
  dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
507
590
  }
508
591
  /** Apply one authoring command. Throws on any refusal, leaving the asset it was given untouched. */
@@ -519,16 +602,20 @@ export function applyV3Authoring(source, action) {
519
602
  unlinkDimension(asset, action);
520
603
  break;
521
604
  case 'attach':
522
- attach(asset, action);
605
+ fastenAsJoint(asset, action);
523
606
  break;
524
- case 'detach':
525
- detach(asset, action);
607
+ case 'detach': {
608
+ const joint = jointAbove(asset, action.part);
609
+ if (!joint)
610
+ fail('EDIT_TARGET', action.part, `${action.part} is not fastened to anything`);
611
+ removeJoint(asset, joint.id);
526
612
  break;
527
- case 'add-motion':
528
- addMotion(asset, action);
613
+ }
614
+ case 'set-joint':
615
+ setJoint(asset, action.joint);
529
616
  break;
530
- case 'remove-motion':
531
- removeMotion(asset, action);
617
+ case 'remove-joint':
618
+ removeJoint(asset, action.id);
532
619
  break;
533
620
  case 'declare-occupancy':
534
621
  declareOccupancy(asset, action);
@@ -581,6 +668,9 @@ export function applyV3Authoring(source, action) {
581
668
  default:
582
669
  fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
583
670
  }
671
+ /* The joint records are the canon: whatever the command reshaped, their nodes are made again from them. */
672
+ if (asset.joints?.length && action.kind !== 'set-joint' && action.kind !== 'remove-joint')
673
+ rewriteJoints(asset);
584
674
  compileV3Asset(asset);
585
675
  holdsOverDeclaredSize(asset, source);
586
676
  return asset;
@@ -683,12 +773,258 @@ export function v3AttachmentsOf(asset) {
683
773
  out[n.id] = parentOf(m, n.id);
684
774
  return out;
685
775
  }
776
+ /* ------------------------------------------------------------------ joints */
777
+ /** The joint whose child is this part, if any. */
778
+ function jointAbove(asset, part) {
779
+ return asset.joints?.find(j => j.body1.part === part);
780
+ }
781
+ const stateIdOf = (j) => j.state?.id ?? j.id;
782
+ /** A record that is well formed on its own, and fits the tree the others make. */
783
+ function checkJoint(asset, j) {
784
+ const m = asset.document.model;
785
+ const path = `joints.${String(j?.id)}`;
786
+ if (!j || typeof j.id !== 'string' || !j.id.trim())
787
+ fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
788
+ if (!V3_JOINT_TYPES.includes(j.type))
789
+ fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${V3_JOINT_TYPES.join(', ')}`);
790
+ if (!j.body1 || typeof j.body1.part !== 'string')
791
+ fail('JOINT_SCHEMA', path, 'a joint carries a part (body1)');
792
+ partOf(m, j.body1.part, path);
793
+ if (!j.body0 || (!('plane' in j.body0) && typeof j.body0.part !== 'string'))
794
+ fail('JOINT_SCHEMA', path, 'a joint hangs from a part or the mounting plane (body0)');
795
+ if ('part' in j.body0)
796
+ partOf(m, j.body0.part, path);
797
+ const others = (asset.joints ?? []).filter(o => o.id !== j.id);
798
+ const above = others.find(o => o.body1.part === j.body1.part);
799
+ if (above)
800
+ fail('JOINT_TREE', path, `${j.body1.part} already hangs from ${'part' in above.body0 ? above.body0.part : 'the mounting plane'} by ${above.id}; a part has one joint above it`);
801
+ if ('part' in j.body0) {
802
+ const parentOfPart = (part) => others.find(o => o.body1.part === part)?.body0;
803
+ for (let up = j.body0.part; up;) {
804
+ if (up === j.body1.part)
805
+ fail('ATTACH_CYCLE', path, `${j.body0.part} already hangs from ${j.body1.part}; ${j.body1.part} would hang from itself`);
806
+ const next = parentOfPart(up);
807
+ up = next && 'part' in next ? next.part : undefined;
808
+ }
809
+ }
810
+ const moving = j.type !== 'fixed';
811
+ const limited = j.lowerLimit !== undefined || j.upperLimit !== undefined;
812
+ if (moving && !['X', 'Y', 'Z'].includes(j.axis))
813
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z`);
814
+ if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
815
+ fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
816
+ if (j.travel !== undefined && j.type !== 'prismatic')
817
+ fail('JOINT_SCHEMA', path, 'only a prismatic joint travels a length');
818
+ if (j.type === 'prismatic' && j.travel && !limited) {
819
+ j.lowerLimit = 0;
820
+ j.upperLimit = 1;
821
+ }
822
+ /* A revolute joint without limits turns without end (USD); with them, both are given. A slide always has them. */
823
+ if (moving && !j.mimic && (limited || j.type === 'prismatic')) {
824
+ const [lo, hi] = [j.lowerLimit, j.upperLimit];
825
+ if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
826
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
827
+ if (!(lo <= 0 && 0 <= hi))
828
+ fail('JOINT_SCHEMA', path, 'the rest pose is the joint at 0, so its limits hold 0');
829
+ }
830
+ if (j.mimic) {
831
+ const leader = others.find(o => o.id === j.mimic.joint);
832
+ if (!leader || leader.type === 'fixed')
833
+ fail('JOINT_SCHEMA', path, `${String(j.mimic.joint)} is not a moving joint to follow`);
834
+ if (leader.mimic)
835
+ fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
836
+ if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
837
+ fail('JOINT_SCHEMA', path, 'a joint follows one that moves the same way: a slide a slide, a turn a turn');
838
+ if (!Number.isFinite(j.mimic.multiplier))
839
+ fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
840
+ }
841
+ }
842
+ /**
843
+ * Write a joint's nodes from its record: the child's quarter turn, the fastening (the seat on the parent's face and
844
+ * the child's offset from it), and for a moving joint the turn or slide at the seat. The node ids are the child's and
845
+ * are chosen the same way each time, so writing a record again gives the same nodes.
846
+ */
847
+ function writeJoint(asset, j) {
848
+ const m = asset.document.model;
849
+ const child = j.body1.part;
850
+ const { pose } = partOf(m, child, child);
851
+ const old = pose.args.slice(3, 6);
852
+ pose.args = [...pose.args.slice(0, 3), ...AXES.map(a => constantOf(m, 'deg', j.origin?.turn?.[a] ?? 0, 'a'))];
853
+ for (const ref of old)
854
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
855
+ attach(asset, {
856
+ kind: 'attach',
857
+ part: child,
858
+ face: j.body1.face,
859
+ to: 'plane' in j.body0 ? { plane: 'mounting-plane' } : { part: j.body0.part, face: j.body0.face },
860
+ ...(j.origin?.facing ? { facing: j.origin.facing } : {}),
861
+ ...(j.origin?.gap !== undefined ? { gap: j.origin.gap } : {}),
862
+ ...(j.origin?.align ? { align: j.origin.align } : {}),
863
+ replace: true
864
+ });
865
+ if (j.type === 'fixed')
866
+ return;
867
+ const kind = j.type === 'prismatic' ? 'slide' : 'turn';
868
+ const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
869
+ 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 };
870
+ const leader = j.mimic && (asset.joints ?? []).find(o => o.id === j.mimic.joint);
871
+ addMotion(asset, {
872
+ kind: 'add-motion',
873
+ part: child,
874
+ motion: { kind, axis: j.axis.toLowerCase() },
875
+ 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 } : {}) },
876
+ ...(j.travel ? { travel: j.travel } : {}),
877
+ replace: true
878
+ }, leader ? { of: stateIdOf(leader), k: j.mimic.multiplier, c: j.mimic.offset ?? 0 } : undefined);
879
+ }
880
+ /** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
881
+ function stripJoint(asset, j) {
882
+ const m = asset.document.model;
883
+ const child = j.body1.part;
884
+ if (nodeById(m, `${child}.motion`))
885
+ removeMotion(asset, { kind: 'remove-motion', part: child });
886
+ const { pose, place } = partOf(m, child, child);
887
+ const owned = new Set([`${child}.seat.rigid`, `${child}.chain`, `${child}.world`, `${child}.turned`]);
888
+ m.nodes = m.nodes.filter((n) => !owned.has(n.id) && !n.id.startsWith(`${child}.on.`));
889
+ const zero = constantOf(m, 'mm', 0, 'd');
890
+ const old = pose.args.slice(0, 3);
891
+ pose.args = [zero, zero, zero, ...pose.args.slice(3)];
892
+ pose.params = { ...pose.params, to: asset.document.capabilities.assetFrame };
893
+ // Its own position numbers, if it still had them, place nothing now: the joint places it.
894
+ for (const ref of old)
895
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
896
+ place.args = [place.args[0], pose.outputs.pose];
897
+ }
898
+ /** Joints in an order each can be written in: its parent's joint and the joint it mimics first. */
899
+ function writingOrder(joints) {
900
+ const done = new Set();
901
+ const out = [];
902
+ const byChild = new Map(joints.map(j => [j.body1.part, j]));
903
+ while (out.length < joints.length) {
904
+ const ready = joints.filter(j => {
905
+ if (done.has(j.id))
906
+ return false;
907
+ const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
908
+ return (!up || done.has(up.id)) && (!j.mimic || done.has(j.mimic.joint));
909
+ });
910
+ if (!ready.length)
911
+ fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
912
+ for (const j of ready) {
913
+ done.add(j.id);
914
+ out.push(j);
915
+ }
916
+ }
917
+ return out;
918
+ }
919
+ /** Every joint's nodes again, from the records: the ones below first away, then all written parents first. */
920
+ function rewriteJoints(asset) {
921
+ const joints = (asset.joints ?? []);
922
+ const order = writingOrder(joints);
923
+ const keep = { ...asset.stateDefaults };
924
+ for (const j of [...order].reverse())
925
+ stripJoint(asset, j);
926
+ for (const j of order)
927
+ writeJoint(asset, j);
928
+ // A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
929
+ for (const j of order)
930
+ if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
931
+ asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
932
+ }
933
+ function setJoint(asset, joint) {
934
+ // As JSON: a field left undefined is a field not there, which is how the asset stores it.
935
+ const j = JSON.parse(JSON.stringify(joint ?? null));
936
+ checkJoint(asset, j);
937
+ /* The child's own quarter turn becomes the record's, the first time it is fastened. */
938
+ if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
939
+ const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
940
+ if (turn.some(t => t !== 0))
941
+ j.origin = { ...(j.origin ?? {}), turn: Object.fromEntries(AXES.map((a, i) => [a, turn[i]])) };
942
+ }
943
+ const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
944
+ const followers = joints.filter(o => o.mimic?.joint === j.id);
945
+ if (followers.length && j.type === 'fixed')
946
+ fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
947
+ asset.joints = [...joints, j];
948
+ rewriteJoints(asset);
949
+ }
950
+ /** The joint goes; its child keeps where it stands now, as plain position numbers. */
951
+ function removeJoint(asset, id) {
952
+ const joints = (asset.joints ?? []);
953
+ const j = joints.find(o => o.id === id);
954
+ if (!j)
955
+ fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
956
+ const followers = joints.filter(o => o.mimic?.joint === id);
957
+ if (followers.length)
958
+ fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
959
+ const m = asset.document.model;
960
+ const child = j.body1.part;
961
+ const evaluated = compileV3Asset(asset).evaluate();
962
+ const placed = nodeById(m, child);
963
+ const at = evaluated.values?.[placed.args[1]];
964
+ if (!at?.t)
965
+ fail('TARGET_ABSENT', child, `${child} has no pose to read`);
966
+ const own = m.nodes.find((n) => n.id === `${child}.pose`);
967
+ const turned = (r, want) => r.some((row, i) => row.some((v, k) => Math.abs(v - want[i][k]) > 1e-9));
968
+ const ownTurn = evaluated.values?.[own.outputs.pose]?.r;
969
+ if (ownTurn && turned(at.r, ownTurn))
970
+ fail('EDIT_TARGET', child, `${child} stands turned by what it hangs from; taking the joint away would turn it`);
971
+ asset.joints = joints.filter(o => o.id !== id);
972
+ stripJoint(asset, j);
973
+ const translation = AXES.map((axis, i) => {
974
+ const input = `${child}.position.${axis}`;
975
+ const value = round6(at.t[i]);
976
+ if (!m.inputs.some((n) => n.id === input))
977
+ m.inputs.push({ id: input, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
978
+ asset.designInputs[input] = value;
979
+ return input;
980
+ });
981
+ own.args = [...translation, ...own.args.slice(3)];
982
+ // Parts hanging from it are written again: they hang from where it stands now.
983
+ rewriteJoints(asset);
984
+ }
985
+ /** `attach` is the modeller's word for a fixed joint: the record says it, whatever joint was there keeps its id and motion. */
986
+ function fastenAsJoint(asset, a) {
987
+ const was = jointAbove(asset, a.part);
988
+ if (was && !a.replace)
989
+ fail('ATTACH_REPLACED', a.part, `${a.part} is already fastened by ${was.id}; pass replace to move it, or detach it first`);
990
+ const to = a.to;
991
+ setJoint(asset, {
992
+ ...(was ?? { id: a.part, type: 'fixed' }),
993
+ body0: to?.plane !== undefined ? { plane: to.plane } : { part: to?.part, face: to?.face },
994
+ body1: { part: a.part, face: a.face },
995
+ origin: {
996
+ ...(was?.origin?.turn ? { turn: was.origin.turn } : {}),
997
+ ...(a.facing ? { facing: a.facing } : {}),
998
+ ...(a.gap !== undefined ? { gap: a.gap } : {}),
999
+ ...(a.align ? { align: a.align } : {})
1000
+ }
1001
+ });
1002
+ }
1003
+ /**
1004
+ * The records are the canon: made again from them, the joints' nodes must be the nodes the asset holds. An asset
1005
+ * whose nodes or records were edited apart is refused, not quietly rebuilt (ADR-0093, chief architect's condition).
1006
+ */
1007
+ export function checkV3Joints(asset) {
1008
+ const joints = asset.joints;
1009
+ if (!joints?.length)
1010
+ return;
1011
+ const again = structuredClone(asset);
1012
+ for (const j of joints)
1013
+ checkJoint({ ...again, joints: joints.filter(o => o.id !== j.id).concat(j) }, j);
1014
+ rewriteJoints(again);
1015
+ const canon = (model) => JSON.stringify({
1016
+ nodes: [...model.nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
1017
+ inputs: [...model.inputs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
1018
+ });
1019
+ if (canon(again.document.model) !== canon(asset.document.model))
1020
+ fail('JOINT_DRIFT', 'joints', 'the joints\' nodes are not what their records make; the asset was edited apart from its joints');
1021
+ }
686
1022
  /** The frame a part is fastened into: its parent's, or the asset's. */
687
1023
  const attachmentFrameOf = (asset, id) => {
688
1024
  const parent = parentOf(asset.document.model, id);
689
1025
  return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
690
1026
  };
691
- function addMotion(asset, a) {
1027
+ function addMotion(asset, a, follow) {
692
1028
  const m = asset.document.model;
693
1029
  const path = a.part;
694
1030
  partOf(m, a.part, path);
@@ -707,26 +1043,40 @@ function addMotion(asset, a) {
707
1043
  else if (frame !== 'attachment' && frame !== 'asset')
708
1044
  fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
709
1045
  const st = a.state;
710
- if (!st || typeof st.id !== 'string' || !st.id.trim())
711
- fail('SCHEMA', path, 'a state input id is required');
712
- if (m.inputs.some((i) => i.id === st.id))
713
- fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
714
- const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
715
- if (!wanted.includes(st.unit))
716
- fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
717
- if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
718
- fail('SCHEMA', path, 'a range with max above min is required');
719
- const start = st.start ?? st.min;
720
- if (!(start >= st.min && start <= st.max))
721
- fail('SCHEMA', path, 'the starting value is outside the range');
722
- m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
723
- asset.stateDefaults[st.id] = start;
724
- if (st.label !== undefined || st.sweep !== undefined) {
725
- asset.stateInputs = { ...(asset.stateInputs ?? {}) };
726
- asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
727
- }
728
1046
  const name = namer(m, `${a.part}.motion`);
729
- let quantity = st.id;
1047
+ let quantity = st?.id;
1048
+ if (follow) {
1049
+ /* A mimic: the leader's value times k plus c, and no control of its own. */
1050
+ const scaledId = name('mimic');
1051
+ m.nodes.push({ id: scaledId, op: 'mul@1', args: [follow.of, constantOf(m, 'ratio', follow.k, 'k')], outputs: { value: `${scaledId}.value` } });
1052
+ quantity = `${scaledId}.value`;
1053
+ if (follow.c) {
1054
+ const plus = name('mimic.offset');
1055
+ const unitOf = m.inputs.find((i) => i.id === follow.of)?.unit ?? 'deg';
1056
+ m.nodes.push({ id: plus, op: 'add@1', args: [quantity, constantOf(m, unitOf, follow.c, 'd')], outputs: { value: `${plus}.value` } });
1057
+ quantity = `${plus}.value`;
1058
+ }
1059
+ }
1060
+ else {
1061
+ if (!st || typeof st.id !== 'string' || !st.id.trim())
1062
+ fail('SCHEMA', path, 'a state input id is required');
1063
+ if (m.inputs.some((i) => i.id === st.id))
1064
+ fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
1065
+ const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
1066
+ if (!wanted.includes(st.unit))
1067
+ fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
1068
+ if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
1069
+ fail('SCHEMA', path, 'a range with max above min is required');
1070
+ const start = st.start ?? st.min;
1071
+ if (!(start >= st.min && start <= st.max))
1072
+ fail('SCHEMA', path, 'the starting value is outside the range');
1073
+ m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
1074
+ asset.stateDefaults[st.id] = start;
1075
+ if (st.label !== undefined || st.sweep !== undefined) {
1076
+ asset.stateInputs = { ...(asset.stateInputs ?? {}) };
1077
+ asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
1078
+ }
1079
+ }
730
1080
  if (a.motion.kind === 'slide' && st.unit === 'ratio') {
731
1081
  if (!a.travel)
732
1082
  fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
@@ -734,7 +1084,7 @@ function addMotion(asset, a) {
734
1084
  const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
735
1085
  const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
736
1086
  const id = name('distance');
737
- m.nodes.push({ id, op: 'mul@1', args: [st.id, length], outputs: { value: `${id}.value` } });
1087
+ m.nodes.push({ id, op: 'mul@1', args: [quantity, length], outputs: { value: `${id}.value` } });
738
1088
  quantity = `${id}.value`;
739
1089
  }
740
1090
  else if (a.motion.kind === 'slide' && a.travel)
@@ -765,7 +1115,7 @@ function removeMotion(asset, a) {
765
1115
  if (!motion)
766
1116
  fail('EDIT_TARGET', a.part, `${a.part} does not move`);
767
1117
  const quantity = motion.args[3];
768
- const to = motion.params.to;
1118
+ const to = nodeById(m, `${a.part}.seat.rigid`)?.params?.to ?? motion.params.to;
769
1119
  // Which control this motion introduced, read before the nodes that name it are taken away.
770
1120
  const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
771
1121
  ?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
@@ -798,22 +1148,53 @@ function fingerprintOf(asset) {
798
1148
  return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
799
1149
  }
800
1150
  const linAdd = (a, b, k = 1) => ({ terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))], c: a.c + b.c * k });
1151
+ /** The same sum with each reference once. */
1152
+ const linNorm = (a) => {
1153
+ const by = new Map();
1154
+ for (const t of a.terms)
1155
+ by.set(t.ref, (by.get(t.ref) ?? 0) + t.k);
1156
+ return { terms: [...by].filter(([, k]) => k !== 0).map(([ref, k]) => ({ ref, k })), c: a.c };
1157
+ };
1158
+ /** A sum at least as large as either, where every reference is a size (never below zero): term by term, the larger. */
1159
+ const linAtLeast = (a, b) => {
1160
+ const by = new Map();
1161
+ for (const t of [...linNorm(a).terms, ...linNorm(b).terms])
1162
+ by.set(t.ref, Math.max(by.get(t.ref) ?? 0, t.k));
1163
+ return { terms: [...by].map(([ref, k]) => ({ ref, k })), c: Math.max(a.c, b.c) };
1164
+ };
801
1165
  const linOf = (m, ref) => {
802
1166
  const constant = m.constants.find((c) => c.id === ref);
803
1167
  return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
804
1168
  };
805
1169
  /** How far a slide has gone when its control is at one end of its range. */
806
1170
  function travelAt(m, asset, motion, bound) {
807
- const quantity = motion.args[3];
808
- const direct = m.inputs.find((i) => i.id === quantity && i.role === 'state');
809
1171
  const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
810
- if (direct)
811
- return { terms: [], c: pick(direct) };
1172
+ /* A control's value, or a mimic's: the leader's value times a constant, plus a constant. */
1173
+ const valueOf = (ref) => {
1174
+ const state = m.inputs.find((i) => i.id === ref && i.role === 'state');
1175
+ if (state)
1176
+ return pick(state);
1177
+ const w = writerOf(m, ref);
1178
+ const constant = (r) => m.constants.find((c) => c.id === r)?.value;
1179
+ if (w?.op === 'mul@1' && w.args.length === 2 && constant(w.args[1]) !== undefined) {
1180
+ const v = valueOf(w.args[0]);
1181
+ return v === null ? null : v * constant(w.args[1]);
1182
+ }
1183
+ if (w?.op === 'add@1' && w.args.length === 2 && constant(w.args[1]) !== undefined) {
1184
+ const v = valueOf(w.args[0]);
1185
+ return v === null ? null : v + constant(w.args[1]);
1186
+ }
1187
+ return null;
1188
+ };
1189
+ const quantity = motion.args[3];
1190
+ const direct = valueOf(quantity);
1191
+ if (direct !== null)
1192
+ return { terms: [], c: direct };
812
1193
  const writer = writerOf(m, quantity);
813
1194
  if (writer?.op === 'mul@1' && writer.args.length === 2) {
814
- const state = m.inputs.find((i) => i.id === writer.args[0] && i.role === 'state');
815
- if (state)
816
- return { terms: [{ ref: writer.args[1], k: pick(state) }], c: 0 };
1195
+ const v = valueOf(writer.args[0]);
1196
+ if (v !== null)
1197
+ return { terms: [{ ref: writer.args[1], k: v }], c: 0 };
817
1198
  }
818
1199
  return null;
819
1200
  }
@@ -825,10 +1206,15 @@ function centreLin(m, asset, id, axis, bound) {
825
1206
  const pose = nodeById(m, `${part}.pose`);
826
1207
  if (!pose)
827
1208
  return `${part} has no pose this command can read`;
828
- for (const ref of pose.args.slice(3, 6))
829
- if (angleOf(m, asset, ref) !== 0)
830
- return `${part} is turned, so its reach is not a box this command can add up`;
1209
+ /* A part's own turn turns it about its centre; a turn above it would turn everything below. */
1210
+ if (part !== id)
1211
+ for (const ref of pose.args.slice(3, 6))
1212
+ if (angleOf(m, asset, ref) !== 0)
1213
+ return `${part} is turned, so its reach is not a box this command can add up`;
831
1214
  out = linAdd(out, linOf(m, pose.args[i]));
1215
+ const seat = nodeById(m, `${part}.seat.rigid`);
1216
+ if (seat)
1217
+ out = linAdd(out, linOf(m, seat.args[i]));
832
1218
  const motion = nodeById(m, `${part}.motion`);
833
1219
  if (motion) {
834
1220
  if (motion.op !== 'axis-slide@1')
@@ -847,6 +1233,161 @@ function centreLin(m, asset, id, axis, bound) {
847
1233
  }
848
1234
  return out;
849
1235
  }
1236
+ /** A value written as a sum of design inputs times constants, or null where it is anything else. */
1237
+ function affineOf(m, ref, seen = 0) {
1238
+ if (seen > 64)
1239
+ return null;
1240
+ const constant = m.constants.find((c) => c.id === ref);
1241
+ if (constant)
1242
+ return { terms: [], c: constant.value };
1243
+ if (m.inputs.some((i) => i.id === ref))
1244
+ return { terms: [{ ref, k: 1 }], c: 0 };
1245
+ const writer = writerOf(m, ref);
1246
+ if (writer?.op === 'add@1') {
1247
+ let out = { terms: [], c: 0 };
1248
+ for (const arg of writer.args) {
1249
+ const part = affineOf(m, arg, seen + 1);
1250
+ if (!part)
1251
+ return null;
1252
+ out = linAdd(out, part);
1253
+ }
1254
+ return out;
1255
+ }
1256
+ if (writer?.op === 'mul@1' && writer.args.length === 2) {
1257
+ const [a, b] = writer.args.map((arg) => affineOf(m, arg, seen + 1));
1258
+ if (!a || !b)
1259
+ return null;
1260
+ if (!a.terms.length)
1261
+ return linAdd({ terms: [], c: 0 }, b, a.c);
1262
+ if (!b.terms.length)
1263
+ return linAdd({ terms: [], c: 0 }, a, b.c);
1264
+ }
1265
+ return null;
1266
+ }
1267
+ /**
1268
+ * How long a value can be, at most, as a sum that holds at every design size: each input's coefficient taken
1269
+ * positive. Sound only over inputs that are never below zero (sizes), so any other input makes it unreadable.
1270
+ */
1271
+ function magnitudeOf(m, lin) {
1272
+ let out = { terms: [], c: 0 };
1273
+ for (const t of lin.terms) {
1274
+ const a = affineOf(m, t.ref);
1275
+ if (!a)
1276
+ return null;
1277
+ for (const u of a.terms) {
1278
+ const input = m.inputs.find((i) => i.id === u.ref);
1279
+ if (!input || input.role === 'state' || !(input.min >= 0))
1280
+ return null;
1281
+ out = linAdd(out, { terms: [{ ref: u.ref, k: Math.abs(u.k * t.k) }], c: 0 });
1282
+ }
1283
+ out.c += Math.abs(a.c * t.k);
1284
+ }
1285
+ out.c += Math.abs(lin.c);
1286
+ return out;
1287
+ }
1288
+ /**
1289
+ * Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
1290
+ * every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
1291
+ * inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
1292
+ * ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
1293
+ * the parts against it.
1294
+ */
1295
+ function reachOf(m, asset, id, freezeless = true) {
1296
+ const chain = [];
1297
+ for (let part = id; part; part = parentOf(m, part))
1298
+ chain.push(part);
1299
+ const top = chain.reduce((found, part, i) => (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1' ? i : found), -1);
1300
+ if (top < 0)
1301
+ return `${id} is carried by no turn`;
1302
+ let radius = { terms: [], c: 0 };
1303
+ const grow = (lin, what) => {
1304
+ const size = lin && magnitudeOf(m, lin);
1305
+ if (!size)
1306
+ return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
1307
+ radius = linAdd(radius, size);
1308
+ return null;
1309
+ };
1310
+ const sizes = standingRefsOf(m, asset, id, id, !freezeless);
1311
+ for (const axis of AXES) {
1312
+ const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
1313
+ if (bad)
1314
+ return bad;
1315
+ }
1316
+ for (let i = 0; i <= top; i++) {
1317
+ const part = chain[i];
1318
+ const pose = nodeById(m, `${part}.pose`);
1319
+ if (!pose)
1320
+ return `${part} has no pose this command can read`;
1321
+ for (const ref of pose.args.slice(0, 3)) {
1322
+ const bad = grow(linOf(m, ref), `${part}'s place`);
1323
+ if (bad)
1324
+ return bad;
1325
+ }
1326
+ if (i < top) {
1327
+ const seat = nodeById(m, `${part}.seat.rigid`);
1328
+ for (const ref of seat?.args.slice(0, 3) ?? []) {
1329
+ const bad = grow(linOf(m, ref), `where ${part} is fastened`);
1330
+ if (bad)
1331
+ return bad;
1332
+ }
1333
+ const motion = nodeById(m, `${part}.motion`);
1334
+ if (motion?.op === 'axis-slide@1')
1335
+ for (const end of ['min', 'max']) {
1336
+ const bad = grow(travelAt(m, asset, motion, end), `${part}'s travel`);
1337
+ if (bad)
1338
+ return bad;
1339
+ }
1340
+ }
1341
+ }
1342
+ // The pivot: where the topmost turn is fastened, and everything above it, which neither turns nor slides.
1343
+ const pivotPart = chain[top];
1344
+ const centre = {};
1345
+ for (const axis of AXES) {
1346
+ const k = AXES.indexOf(axis);
1347
+ let at = { terms: [], c: 0 };
1348
+ const seat = nodeById(m, `${pivotPart}.seat.rigid`);
1349
+ if (seat)
1350
+ at = linAdd(at, linOf(m, seat.args[k]));
1351
+ for (let part = parentOf(m, pivotPart); part; part = parentOf(m, part)) {
1352
+ const pose = nodeById(m, `${part}.pose`);
1353
+ if (!pose)
1354
+ return `${part} has no pose this command can read`;
1355
+ for (const ref of pose.args.slice(3, 6))
1356
+ if (angleOf(m, asset, ref) !== 0)
1357
+ return `${part} is turned, so where ${pivotPart} turns is not a point this command can add up`;
1358
+ if (nodeById(m, `${part}.motion`))
1359
+ return `${part} moves, and so does the point ${pivotPart} turns about`;
1360
+ at = linAdd(at, linOf(m, pose.args[k]));
1361
+ const above = nodeById(m, `${part}.seat.rigid`);
1362
+ if (above)
1363
+ at = linAdd(at, linOf(m, above.args[k]));
1364
+ }
1365
+ centre[axis] = at;
1366
+ }
1367
+ return { centre, radius };
1368
+ }
1369
+ /** A sum written as graph nodes: each term scaled, then added up, then the constant. */
1370
+ function emitLin(m, name, l, hint) {
1371
+ if (!l.terms.length)
1372
+ return constantOf(m, 'mm', round6(l.c), 'd');
1373
+ let out = '';
1374
+ for (const t of l.terms) {
1375
+ const piece = scaled(m, name, t.ref, t.k, 0, hint);
1376
+ if (!out)
1377
+ out = piece;
1378
+ else {
1379
+ const id = name(`${hint}.sum`);
1380
+ m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
1381
+ out = `${id}.value`;
1382
+ }
1383
+ }
1384
+ if (l.c !== 0) {
1385
+ const id = name(`${hint}.offset`);
1386
+ m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
1387
+ out = `${id}.value`;
1388
+ }
1389
+ return out;
1390
+ }
850
1391
  /** The bounds the parts come to, as sums, with what could not be read named. */
851
1392
  function occupancyLins(asset, over) {
852
1393
  const m = asset.document.model;
@@ -854,13 +1395,34 @@ function occupancyLins(asset, over) {
854
1395
  const skipped = [];
855
1396
  const low = { x: [], y: [], z: [] };
856
1397
  const high = { x: [], y: [], z: [] };
1398
+ /* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
1399
+ const balls = new Map();
857
1400
  for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
858
1401
  let sizes;
859
1402
  try {
860
- sizes = sizeRefsOf(m, place.id, place.id);
1403
+ sizes = standingRefsOf(m, asset, place.id, place.id, false);
861
1404
  }
862
- catch {
863
- skipped.push({ part: place.id, reason: 'not a box-shaped part; this command measures boxes' });
1405
+ catch (e) {
1406
+ skipped.push({ part: place.id, reason: e instanceof V3ContractError ? e.message : 'not a part whose size this command can read' });
1407
+ continue;
1408
+ }
1409
+ const turns = (() => {
1410
+ for (let part = place.id; part; part = parentOf(m, part))
1411
+ if (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1')
1412
+ return true;
1413
+ return false;
1414
+ })();
1415
+ if (turns) {
1416
+ const reach = reachOf(m, asset, place.id);
1417
+ if (typeof reach === 'string') {
1418
+ skipped.push({ part: place.id, reason: reach });
1419
+ continue;
1420
+ }
1421
+ parts.push(place.id);
1422
+ const centre = Object.fromEntries(AXES.map(a => [a, linNorm(reach.centre[a])]));
1423
+ const key = JSON.stringify(centre);
1424
+ const ball = balls.get(key);
1425
+ balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
864
1426
  continue;
865
1427
  }
866
1428
  /*
@@ -891,7 +1453,7 @@ function occupancyLins(asset, over) {
891
1453
  }
892
1454
  }
893
1455
  }
894
- return { parts, skipped, low, high };
1456
+ return { parts, skipped, low, high, balls: [...balls.values()] };
895
1457
  }
896
1458
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
897
1459
  /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
@@ -900,7 +1462,12 @@ export function proposeV3Occupancy(asset, options = {}) {
900
1462
  if (over !== 'rest' && over !== 'range')
901
1463
  fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
902
1464
  const source = structuredClone(asset);
903
- const { parts, skipped, low, high } = occupancyLins(source, over);
1465
+ const { parts, skipped, low, high, balls } = occupancyLins(source, over);
1466
+ for (const ball of balls)
1467
+ for (const axis of AXES) {
1468
+ low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
1469
+ high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
1470
+ }
904
1471
  if (!parts.length)
905
1472
  fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
906
1473
  const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
@@ -923,7 +1490,15 @@ function declareOccupancy(asset, a) {
923
1490
  fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
924
1491
  padding[axis] = pad;
925
1492
  }
926
- const { parts, low, high } = occupancyLins(asset, a.proposal.over);
1493
+ const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
1494
+ const name0 = namer(m, 'occupancy.reach');
1495
+ balls.forEach((ball, b) => {
1496
+ const radius = emitLin(m, name0, ball.radius, `${b}`);
1497
+ for (const axis of AXES) {
1498
+ low[axis].push(linAdd(ball.centre[axis], { terms: [{ ref: radius, k: 1 }], c: 0 }, -1));
1499
+ high[axis].push(linAdd(ball.centre[axis], { terms: [{ ref: radius, k: 1 }], c: 0 }, 1));
1500
+ }
1501
+ });
927
1502
  for (const axis of AXES) {
928
1503
  if (!padding[axis])
929
1504
  continue;
@@ -933,27 +1508,7 @@ function declareOccupancy(asset, a) {
933
1508
  if (parts.join('|') !== a.proposal.parts.join('|'))
934
1509
  fail('OCCUPANCY_STALE', 'occupancy', `the figure changed since the proposal was made (${a.proposal.parts.join(', ')} then, ${parts.join(', ')} now); propose again and confirm that`);
935
1510
  const name = namer(m, 'occupancy');
936
- const emit = (l, hint) => {
937
- if (!l.terms.length)
938
- return constantOf(m, 'mm', round6(l.c), 'd');
939
- let out = '';
940
- for (const t of l.terms) {
941
- const piece = scaled(m, name, t.ref, t.k, 0, hint);
942
- if (!out)
943
- out = piece;
944
- else {
945
- const id = name(`${hint}.sum`);
946
- m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
947
- out = `${id}.value`;
948
- }
949
- }
950
- if (l.c !== 0) {
951
- const id = name(`${hint}.offset`);
952
- m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
953
- out = `${id}.value`;
954
- }
955
- return out;
956
- };
1511
+ const emit = (l, hint) => emitLin(m, name, linNorm(l), hint);
957
1512
  const pick = (ls, op, hint) => {
958
1513
  const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
959
1514
  if (refs.length === 1)