@hatiolab/figure-model 0.1.55 → 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.
@@ -25,12 +25,18 @@ import { compileV3Asset } from "./v3-asset.js";
25
25
  import { compileV3Graph } from "./v3-graph.js";
26
26
  import { V3ContractError } from "./v3-contract.js";
27
27
  import { AXES } from "./types.js";
28
- import { foldInstanceSizeRange, v3InstanceSizeLimitsOf, V3_INSTANCE_SIZE_INPUTS } from "./v3-instance-size.js";
28
+ import { foldInstanceSizeRange, v3InstanceSizeLimitsOf, V3_INSTANCE_SIZE_DEFAULT, V3_INSTANCE_SIZE_INPUTS } from "./v3-instance-size.js";
29
29
  const fail = (code, path, message) => {
30
30
  throw new V3ContractError(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);
@@ -559,6 +646,9 @@ export function applyV3Authoring(source, action) {
559
646
  case 'set-repeat':
560
647
  setRepeat(asset, action);
561
648
  break;
649
+ case 'nudge-part':
650
+ nudgePart(asset, action);
651
+ break;
562
652
  case 'clear-mounting-face':
563
653
  clearMountingFace(asset);
564
654
  break;
@@ -578,8 +668,11 @@ export function applyV3Authoring(source, action) {
578
668
  default:
579
669
  fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
580
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);
581
674
  compileV3Asset(asset);
582
- holdsOverDeclaredSize(asset);
675
+ holdsOverDeclaredSize(asset, source);
583
676
  return asset;
584
677
  }
585
678
  /* ----------------------------------------------------------- instance size */
@@ -630,21 +723,45 @@ function declareInstanceSize(asset, a) {
630
723
  * makes a part 200 mm shorter than the instance is caught when the instance may be 1 mm wide — the kernel alone
631
724
  * accepts it, and the board then fails to draw the figure at small sizes.
632
725
  */
633
- function holdsOverDeclaredSize(asset) {
726
+ /** Where the declared size range reaches past what the figure holds: per axis, the bound it would need, and who sets it. */
727
+ function sizeRangeGaps(asset) {
634
728
  const m = asset.document.model;
635
729
  if (!AXES.every(axis => sizeInputOf(m, axis)))
636
- return;
730
+ return [];
637
731
  const range = Object.fromEntries(AXES.map(axis => [axis, { min: sizeInputOf(m, axis).min, max: sizeInputOf(m, axis).max }]));
638
732
  const limits = v3InstanceSizeLimitsOf(asset, range);
639
733
  const { range: holds } = foldInstanceSizeRange(range, limits);
734
+ const gaps = [];
640
735
  for (const axis of AXES) {
641
- const path = V3_INSTANCE_SIZE_INPUTS[axis];
642
736
  const who = limits.find(l => l.axis === axis)?.part;
643
- const by = who ? ` (${who})` : '';
644
737
  if (holds[axis].min > range[axis].min)
645
- fail('INSTANCE_SIZE_RANGE', path, `below ${round6(holds[axis].min)} mm a length goes to zero${by}; declare the smallest size at ${round6(holds[axis].min)} mm or more`);
738
+ gaps.push({ axis, bound: 'min', need: holds[axis].min, declared: range[axis].min, who });
646
739
  if (holds[axis].max < range[axis].max)
647
- fail('INSTANCE_SIZE_RANGE', path, `above ${round6(holds[axis].max)} mm the figure cannot be drawn${by}; declare the largest size at ${round6(holds[axis].max)} mm or less`);
740
+ gaps.push({ axis, bound: 'max', need: holds[axis].max, declared: range[axis].max, who });
741
+ }
742
+ return gaps;
743
+ }
744
+ /**
745
+ * After a command, the declared size range must still hold at its ends. A gap the figure already had before the
746
+ * command -- a range the conversion declared too wide (FORMATION_TRAY: X from 1 mm, its cells gone below 8.8 mm) -- is
747
+ * not the command's doing: it is refused only if it made a gap or widened one. Refusing every command for it had
748
+ * blocked even choosing a detail level (user, 2026-09-24).
749
+ */
750
+ function holdsOverDeclaredSize(asset, before) {
751
+ const now = sizeRangeGaps(asset);
752
+ if (!now.length)
753
+ return;
754
+ const was = before ? sizeRangeGaps(before) : [];
755
+ for (const gap of now) {
756
+ const prior = was.find(g => g.axis === gap.axis && g.bound === gap.bound);
757
+ const worse = !prior || (gap.bound === 'min' ? gap.need > prior.need + 1e-9 || gap.declared < prior.declared : gap.need < prior.need - 1e-9 || gap.declared > prior.declared);
758
+ if (!worse)
759
+ continue;
760
+ const path = V3_INSTANCE_SIZE_INPUTS[gap.axis];
761
+ const by = gap.who ? ` (${gap.who})` : '';
762
+ if (gap.bound === 'min')
763
+ fail('INSTANCE_SIZE_RANGE', path, `below ${round6(gap.need)} mm a length goes to zero${by}; declare the smallest size at ${round6(gap.need)} mm or more`);
764
+ fail('INSTANCE_SIZE_RANGE', path, `above ${round6(gap.need)} mm the figure cannot be drawn${by}; declare the largest size at ${round6(gap.need)} mm or less`);
648
765
  }
649
766
  }
650
767
  /** What each part is fastened to, for a reader that wants the structure rather than the graph. */
@@ -656,12 +773,258 @@ export function v3AttachmentsOf(asset) {
656
773
  out[n.id] = parentOf(m, n.id);
657
774
  return out;
658
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
+ }
659
1022
  /** The frame a part is fastened into: its parent's, or the asset's. */
660
1023
  const attachmentFrameOf = (asset, id) => {
661
1024
  const parent = parentOf(asset.document.model, id);
662
1025
  return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
663
1026
  };
664
- function addMotion(asset, a) {
1027
+ function addMotion(asset, a, follow) {
665
1028
  const m = asset.document.model;
666
1029
  const path = a.part;
667
1030
  partOf(m, a.part, path);
@@ -680,26 +1043,40 @@ function addMotion(asset, a) {
680
1043
  else if (frame !== 'attachment' && frame !== 'asset')
681
1044
  fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
682
1045
  const st = a.state;
683
- if (!st || typeof st.id !== 'string' || !st.id.trim())
684
- fail('SCHEMA', path, 'a state input id is required');
685
- if (m.inputs.some((i) => i.id === st.id))
686
- fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
687
- const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
688
- if (!wanted.includes(st.unit))
689
- fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
690
- if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
691
- fail('SCHEMA', path, 'a range with max above min is required');
692
- const start = st.start ?? st.min;
693
- if (!(start >= st.min && start <= st.max))
694
- fail('SCHEMA', path, 'the starting value is outside the range');
695
- m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
696
- asset.stateDefaults[st.id] = start;
697
- if (st.label !== undefined || st.sweep !== undefined) {
698
- asset.stateInputs = { ...(asset.stateInputs ?? {}) };
699
- asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
700
- }
701
1046
  const name = namer(m, `${a.part}.motion`);
702
- 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
+ }
703
1080
  if (a.motion.kind === 'slide' && st.unit === 'ratio') {
704
1081
  if (!a.travel)
705
1082
  fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
@@ -707,7 +1084,7 @@ function addMotion(asset, a) {
707
1084
  const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
708
1085
  const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
709
1086
  const id = name('distance');
710
- 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` } });
711
1088
  quantity = `${id}.value`;
712
1089
  }
713
1090
  else if (a.motion.kind === 'slide' && a.travel)
@@ -738,7 +1115,7 @@ function removeMotion(asset, a) {
738
1115
  if (!motion)
739
1116
  fail('EDIT_TARGET', a.part, `${a.part} does not move`);
740
1117
  const quantity = motion.args[3];
741
- const to = motion.params.to;
1118
+ const to = nodeById(m, `${a.part}.seat.rigid`)?.params?.to ?? motion.params.to;
742
1119
  // Which control this motion introduced, read before the nodes that name it are taken away.
743
1120
  const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
744
1121
  ?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
@@ -771,22 +1148,53 @@ function fingerprintOf(asset) {
771
1148
  return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
772
1149
  }
773
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
+ };
774
1165
  const linOf = (m, ref) => {
775
1166
  const constant = m.constants.find((c) => c.id === ref);
776
1167
  return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
777
1168
  };
778
1169
  /** How far a slide has gone when its control is at one end of its range. */
779
1170
  function travelAt(m, asset, motion, bound) {
780
- const quantity = motion.args[3];
781
- const direct = m.inputs.find((i) => i.id === quantity && i.role === 'state');
782
1171
  const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
783
- if (direct)
784
- 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 };
785
1193
  const writer = writerOf(m, quantity);
786
1194
  if (writer?.op === 'mul@1' && writer.args.length === 2) {
787
- const state = m.inputs.find((i) => i.id === writer.args[0] && i.role === 'state');
788
- if (state)
789
- 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 };
790
1198
  }
791
1199
  return null;
792
1200
  }
@@ -798,10 +1206,15 @@ function centreLin(m, asset, id, axis, bound) {
798
1206
  const pose = nodeById(m, `${part}.pose`);
799
1207
  if (!pose)
800
1208
  return `${part} has no pose this command can read`;
801
- for (const ref of pose.args.slice(3, 6))
802
- if (angleOf(m, asset, ref) !== 0)
803
- 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`;
804
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]));
805
1218
  const motion = nodeById(m, `${part}.motion`);
806
1219
  if (motion) {
807
1220
  if (motion.op !== 'axis-slide@1')
@@ -820,6 +1233,161 @@ function centreLin(m, asset, id, axis, bound) {
820
1233
  }
821
1234
  return out;
822
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
+ }
823
1391
  /** The bounds the parts come to, as sums, with what could not be read named. */
824
1392
  function occupancyLins(asset, over) {
825
1393
  const m = asset.document.model;
@@ -827,13 +1395,34 @@ function occupancyLins(asset, over) {
827
1395
  const skipped = [];
828
1396
  const low = { x: [], y: [], z: [] };
829
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();
830
1400
  for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
831
1401
  let sizes;
832
1402
  try {
833
- sizes = sizeRefsOf(m, place.id, place.id);
1403
+ sizes = standingRefsOf(m, asset, place.id, place.id, false);
834
1404
  }
835
- catch {
836
- 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) });
837
1426
  continue;
838
1427
  }
839
1428
  /*
@@ -864,7 +1453,7 @@ function occupancyLins(asset, over) {
864
1453
  }
865
1454
  }
866
1455
  }
867
- return { parts, skipped, low, high };
1456
+ return { parts, skipped, low, high, balls: [...balls.values()] };
868
1457
  }
869
1458
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
870
1459
  /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
@@ -873,7 +1462,12 @@ export function proposeV3Occupancy(asset, options = {}) {
873
1462
  if (over !== 'rest' && over !== 'range')
874
1463
  fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
875
1464
  const source = structuredClone(asset);
876
- 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
+ }
877
1471
  if (!parts.length)
878
1472
  fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
879
1473
  const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
@@ -896,7 +1490,15 @@ function declareOccupancy(asset, a) {
896
1490
  fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
897
1491
  padding[axis] = pad;
898
1492
  }
899
- 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
+ });
900
1502
  for (const axis of AXES) {
901
1503
  if (!padding[axis])
902
1504
  continue;
@@ -906,27 +1508,7 @@ function declareOccupancy(asset, a) {
906
1508
  if (parts.join('|') !== a.proposal.parts.join('|'))
907
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`);
908
1510
  const name = namer(m, 'occupancy');
909
- const emit = (l, hint) => {
910
- if (!l.terms.length)
911
- return constantOf(m, 'mm', round6(l.c), 'd');
912
- let out = '';
913
- for (const t of l.terms) {
914
- const piece = scaled(m, name, t.ref, t.k, 0, hint);
915
- if (!out)
916
- out = piece;
917
- else {
918
- const id = name(`${hint}.sum`);
919
- m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
920
- out = `${id}.value`;
921
- }
922
- }
923
- if (l.c !== 0) {
924
- const id = name(`${hint}.offset`);
925
- m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
926
- out = `${id}.value`;
927
- }
928
- return out;
929
- };
1511
+ const emit = (l, hint) => emitLin(m, name, linNorm(l), hint);
930
1512
  const pick = (ls, op, hint) => {
931
1513
  const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
932
1514
  if (refs.length === 1)
@@ -1068,6 +1650,83 @@ function clearMountingFace(asset) {
1068
1650
  delete asset.occupancy.contact;
1069
1651
  return asset;
1070
1652
  }
1653
+ function nudgePart(asset, a) {
1654
+ const m = asset.document.model;
1655
+ const path = a.part;
1656
+ const { pose } = partOf(m, a.part, path);
1657
+ if (pose.op !== 'rigid@1')
1658
+ fail('EDIT_TARGET', path, `${a.part} is not posed by a rigid@1 this command can move`);
1659
+ if (pose.params?.to !== 'asset')
1660
+ fail('EDIT_TARGET', path, `${a.part} is carried by a joint or a seat, which places it`);
1661
+ if (parentOf(m, a.part))
1662
+ fail('EDIT_TARGET', path, `${a.part} is fastened to ${parentOf(m, a.part)}, which places it; detach it first`);
1663
+ const shift = (slot, by, unit, kind, axis) => {
1664
+ if (!by)
1665
+ return;
1666
+ if (!Number.isFinite(by))
1667
+ fail('SCHEMA', `${path}.${kind}.${axis}`, 'a finite amount required');
1668
+ const was = pose.args[slot];
1669
+ const own = `${a.part}.${kind}.${axis}`;
1670
+ /* Already the part's own number: add to it. */
1671
+ if (was === own && m.inputs.some((i) => i.id === own && i.role === 'design')) {
1672
+ asset.designInputs[own] = round6(asset.designInputs[own] + by);
1673
+ return;
1674
+ }
1675
+ const constant = m.constants.find((c) => c.id === was);
1676
+ const free = (id) => !m.inputs.some((i) => i.id === id) && !m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id));
1677
+ const input = (id, value) => {
1678
+ m.inputs.push({ id, unit, min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
1679
+ asset.designInputs[id] = round6(value);
1680
+ };
1681
+ /* A plain number becomes the part's own, moved. */
1682
+ if (constant && free(own)) {
1683
+ input(own, constant.value + by);
1684
+ pose.args = pose.args.map((r, k) => (k === slot ? own : r));
1685
+ return;
1686
+ }
1687
+ /* A worked-out place keeps its rule and takes the drag beside it. */
1688
+ const nudge = `${a.part}.${kind}.nudge.${axis}`;
1689
+ if (m.inputs.some((i) => i.id === nudge)) {
1690
+ asset.designInputs[nudge] = round6(asset.designInputs[nudge] + by);
1691
+ return;
1692
+ }
1693
+ input(nudge, by);
1694
+ const id = namer(m, `${a.part}.${kind}`)(`nudged.${axis}`);
1695
+ m.nodes.push({ id, op: 'add@1', args: [was, nudge], outputs: { value: `${id}.value` } });
1696
+ pose.args = pose.args.map((r, k) => (k === slot ? `${id}.value` : r));
1697
+ };
1698
+ AXES.forEach((axis, i) => shift(i, a.by?.[axis] ?? 0, 'mm', 'position', axis));
1699
+ AXES.forEach((axis, i) => shift(3 + i, a.turn?.[axis] ?? 0, 'deg', 'rotation', axis));
1700
+ /* Sizes: the measure's own node carries a factor beside its rule (`stableDimension` routes every measure through one). */
1701
+ const shape = partOf(m, a.part, path).shape;
1702
+ const measures = shape.op === 'cylinder-shape@1' ? ['x', 'y'] : AXES.filter((_, i) => shape.args[i] !== undefined);
1703
+ for (const axis of measures) {
1704
+ /* A cylinder's radius answers to the cross axis the drag moved more. */
1705
+ const factor = shape.op === 'cylinder-shape@1' && axis === 'x' ? mostChanged(a.grow?.x, a.grow?.z) : a.grow?.[axis];
1706
+ if (factor === undefined || factor === 1)
1707
+ continue;
1708
+ if (!(Number.isFinite(factor) && factor > 0))
1709
+ fail('GEOMETRY_DOMAIN', `${path}.dimension.${axis}`, 'a positive factor required');
1710
+ stableDimension(m, a.part, axis, `${path}.dimension.${axis}`);
1711
+ const node = nodeById(m, `${a.part}.dimension.${axis}`);
1712
+ const id = `${a.part}.dimension.scale.${axis}`;
1713
+ if (node.args[1] === id)
1714
+ asset.designInputs[id] = round6(asset.designInputs[id] * factor);
1715
+ else {
1716
+ if (m.inputs.some((i) => i.id === id))
1717
+ fail('DUPLICATE_WRITER', path, `${id} already exists`);
1718
+ m.inputs.push({ id, unit: 'ratio', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
1719
+ const was = m.constants.find((c) => c.id === node.args[1])?.value ?? 1;
1720
+ asset.designInputs[id] = round6(was * factor);
1721
+ node.args = [node.args[0], id];
1722
+ }
1723
+ }
1724
+ return asset;
1725
+ }
1726
+ const mostChanged = (a, b) => {
1727
+ const pick = [a, b].filter((v) => v !== undefined);
1728
+ return pick.length ? pick.reduce((x, y) => (Math.abs(y - 1) > Math.abs(x - 1) ? y : x), 1) : undefined;
1729
+ };
1071
1730
  function setRepeat(asset, a) {
1072
1731
  const m = asset.document.model;
1073
1732
  const path = `${a.part}.repeat`;
@@ -1080,27 +1739,38 @@ function setRepeat(asset, a) {
1080
1739
  fail('EDIT_TARGET', path, `${a.part} lays its copies out by ${layout.op}, which has no pitch`);
1081
1740
  if (!(Number.isFinite(a.pitch) && a.pitch > 0))
1082
1741
  fail('GEOMETRY_DOMAIN', path, 'a positive pitch is required');
1742
+ /*
1743
+ One number, the pitch: copies = floor(row length / pitch), at most `limit` of them (the kernel's copy cap, V2's).
1744
+ So the longest row the figure may be placed at is limit x pitch -- the converter set the length's upper bound that
1745
+ way from the V2 pitch. A new pitch moves that bound with it; the pitch is refused only when the length the figure
1746
+ is authored at would already hold more copies than the cap.
1747
+ */
1748
+ const values = compileV3Graph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
1749
+ const lengthRef = layout.args[0];
1750
+ const authored = values[lengthRef];
1751
+ const limit = (values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value);
1752
+ const smallest = Math.ceil((authored / limit) * 1000) / 1000;
1753
+ if (a.pitch < smallest)
1754
+ fail('INPUT_RANGE', path, `at the length it is authored (${round6(authored)} mm) a pitch under ${smallest} mm makes more than ${limit} copies`);
1083
1755
  const id = `${a.part}.repeat.pitch`;
1084
- let input = m.inputs.find((i) => i.id === id);
1085
- if (!input) {
1756
+ if (!m.inputs.some((i) => i.id === id)) {
1086
1757
  if (m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id)))
1087
1758
  fail('DUPLICATE_WRITER', path, `${id} already exists`);
1088
- /* The lowest pitch keeps the count within the limit when the row is as long as it gets. */
1089
- const values = compileV3Graph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
1090
- const lengthRef = layout.args[0];
1091
- const longest = m.inputs.find((i) => i.id === lengthRef)?.max ?? values[lengthRef];
1092
- const limit = values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value;
1093
- const lowest = Number.isFinite(longest) && limit > 0 ? Math.ceil((longest / limit) * 1000) / 1000 : 1;
1094
- input = { id, unit: 'mm', min: Math.max(lowest, 0.001), max: Number.isFinite(longest) ? longest : 1e6, role: 'design' };
1095
- m.inputs.push(input);
1759
+ m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
1096
1760
  const was = [layout.args[3], layout.args[4]];
1097
1761
  layout.args = layout.args.map((r, k) => (k === 3 || k === 4 ? id : r));
1098
1762
  for (const ref of new Set(was))
1099
1763
  dropIfUnused(asset, ref);
1100
1764
  }
1101
- if (a.pitch < input.min)
1102
- fail('INPUT_RANGE', path, `a pitch under ${input.min} mm puts more copies in the row than the kernel allows at its longest`);
1103
1765
  asset.designInputs[id] = a.pitch;
1766
+ /* The longest length follows the pitch, within the default range (ten times the authored length). */
1767
+ const length = m.inputs.find((i) => i.id === lengthRef && i.role === 'design');
1768
+ if (length) {
1769
+ const longest = Math.min(round6(limit * a.pitch), round6(authored * V3_INSTANCE_SIZE_DEFAULT.times));
1770
+ length.max = Math.max(longest, authored);
1771
+ if (length.min > length.max)
1772
+ length.min = Math.min(length.min, authored);
1773
+ }
1104
1774
  return asset;
1105
1775
  }
1106
1776
  function scaleUniformly(asset) {