@hatiolab/figure-model 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/dist/authoring-actions.d.ts.map +1 -1
  3. package/dist/authoring-actions.js +13 -20
  4. package/dist/authoring-actions.js.map +1 -1
  5. package/dist/authoring-geometry.d.ts +7 -0
  6. package/dist/authoring-geometry.d.ts.map +1 -1
  7. package/dist/authoring-geometry.js +25 -2
  8. package/dist/authoring-geometry.js.map +1 -1
  9. package/dist/authoring-occupancy.d.ts.map +1 -1
  10. package/dist/authoring-occupancy.js +284 -75
  11. package/dist/authoring-occupancy.js.map +1 -1
  12. package/dist/authoring-size.d.ts +5 -10
  13. package/dist/authoring-size.d.ts.map +1 -1
  14. package/dist/authoring-size.js +28 -98
  15. package/dist/authoring-size.js.map +1 -1
  16. package/dist/authoring-types.d.ts +10 -22
  17. package/dist/authoring-types.d.ts.map +1 -1
  18. package/dist/contract.d.ts +4 -16
  19. package/dist/contract.d.ts.map +1 -1
  20. package/dist/contract.js.map +1 -1
  21. package/dist/create.d.ts +5 -3
  22. package/dist/create.d.ts.map +1 -1
  23. package/dist/create.js +3 -7
  24. package/dist/create.js.map +1 -1
  25. package/dist/gate.d.ts.map +1 -1
  26. package/dist/gate.js +28 -8
  27. package/dist/gate.js.map +1 -1
  28. package/dist/graph-types.d.ts +15 -2
  29. package/dist/graph-types.d.ts.map +1 -1
  30. package/dist/graph.js +15 -6
  31. package/dist/graph.js.map +1 -1
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/instance-size.d.ts +58 -32
  37. package/dist/instance-size.d.ts.map +1 -1
  38. package/dist/instance-size.js +155 -41
  39. package/dist/instance-size.js.map +1 -1
  40. package/dist/joint-list.d.ts.map +1 -1
  41. package/dist/joint-list.js +3 -3
  42. package/dist/joint-list.js.map +1 -1
  43. package/dist/joint-writing.d.ts.map +1 -1
  44. package/dist/joint-writing.js +9 -4
  45. package/dist/joint-writing.js.map +1 -1
  46. package/dist/kernel-version.d.ts +1 -1
  47. package/dist/kernel-version.js +1 -1
  48. package/dist/part-edit.d.ts.map +1 -1
  49. package/dist/part-edit.js +2 -2
  50. package/dist/part-edit.js.map +1 -1
  51. package/dist/proposal.js +7 -7
  52. package/dist/proposal.js.map +1 -1
  53. package/dist/recipe-from-parts.js +5 -5
  54. package/dist/recipe-from-parts.js.map +1 -1
  55. package/dist/release-contract.d.ts.map +1 -1
  56. package/dist/release-contract.js +7 -2
  57. package/dist/release-contract.js.map +1 -1
  58. package/dist/release-metrics.d.ts.map +1 -1
  59. package/dist/release-metrics.js +6 -2
  60. package/dist/release-metrics.js.map +1 -1
  61. package/dist/resize-plan.d.ts.map +1 -1
  62. package/dist/resize-plan.js +4 -9
  63. package/dist/resize-plan.js.map +1 -1
  64. package/dist/room.d.ts.map +1 -1
  65. package/dist/room.js +13 -4
  66. package/dist/room.js.map +1 -1
  67. package/dist/shape-provider.d.ts.map +1 -1
  68. package/dist/shape-provider.js +4 -3
  69. package/dist/shape-provider.js.map +1 -1
  70. package/package.json +1 -1
@@ -6,12 +6,13 @@
6
6
  */
7
7
  import { upgradeFigurePlacement, figurePlacementOf } from "./asset.js";
8
8
  import { compileFigureGraph } from "./graph.js";
9
+ import { figureShapeDescriptionOf } from "./shape-ops.js";
9
10
  import { FigureContractError } from "./contract.js";
10
11
  import { AXES } from "./words.js";
11
12
  import { FIGURE_PLACEMENTS } from "./asset-types.js";
12
13
  import { FIGURE_FACES } from "./authoring-types.js";
13
14
  import { fail, nodeById, writerOf, throughSums, rowHolding, partOf, namer, constantOf, dropIfUnused, parentOf, round6, stringsIn } from "./authoring-nodes.js";
14
- import { isReach, sizeRefsOf, occupiedExtentsOf, angleOf, turnMatrix, mul3, standingRefsOf, half, IDENTITY } from "./authoring-geometry.js";
15
+ import { isReach, sizeRefsOf, occupiedExtentsOf, angleOf, turnMatrix, mul3, standingRefsOf, half, axisTurn, offCentreEndsOf, IDENTITY } from "./authoring-geometry.js";
15
16
  /** A figure as one value, for saying "this is the figure I read". The declaration itself is left out of it. */
16
17
  function fingerprintOf(asset) {
17
18
  /*
@@ -133,7 +134,7 @@ function poseTurnOf(m, asset, part) {
133
134
  const seatTurned = (m, asset, part) => (nodeById(m, `${part}.seat.rigid`)?.args.slice(3, 6) ?? []).some((ref) => angleOf(m, asset, ref) !== 0);
134
135
  /**
135
136
  * Whether a part, or anything carrying it, turns on a joint, stands turned by other than quarter turns, or is seated
136
- * turned: a box cannot follow it, a ball about the point it turns at can (`reachOf`).
137
+ * turned: a box cannot follow it, its reach about the point it turns at can (`reachOf`).
137
138
  */
138
139
  function carriedByATurn(m, asset, id) {
139
140
  for (let part = id; part; part = parentOf(m, part)) {
@@ -258,8 +259,9 @@ function sizeValue(m, ref, seen = 0) {
258
259
  if (seen > 64)
259
260
  return false;
260
261
  const input = m.inputs.find((i) => i.id === ref);
262
+ /* The instance size is never below zero, its smallest end given or not (ADR-0087 decision 6). */
261
263
  if (input)
262
- return input.role !== 'state' && input.min >= 0;
264
+ return input.role !== 'state' && (/^size\.[xyz]$/.test(input.id) || input.min >= 0);
263
265
  const w = writerOf(m, ref);
264
266
  const positive = (r) => (m.constants.find((c) => c.id === r)?.value ?? -1) > 0;
265
267
  if (w?.op === 'geomean@1')
@@ -413,14 +415,111 @@ function boundedSides(m, low, high) {
413
415
  high[axis] = high[axis].flatMap(l => boundsOf(m, l, true));
414
416
  }
415
417
  }
418
+ const transposedTimes = (r, d) => [0, 1, 2].map(j => r[0][j] * d[0] + r[1][j] * d[1] + r[2][j] * d[2]);
419
+ /** A turn as `rigid@1` writes it, in degrees about X, Y and Z: Rx·Ry·Rz, at any angle (`turnMatrix` takes quarter turns). */
420
+ const rigidTurnOf = ([x, y, z]) => mul3(mul3(axisTurn('x', x), axisTurn('y', y)), axisTurn('z', z));
421
+ /** The directions seen from a frame turned by `r` from this one (a point there is r·x here). */
422
+ function turnedBy(d, r) {
423
+ if ('one' in d)
424
+ return { one: transposedTimes(r, d.one) };
425
+ return { most: [0, 1, 2].map(j => Math.min(1, d.most.reduce((sum, v, i) => sum + Math.abs(r[i][j]) * v, 0))) };
426
+ }
427
+ /** The largest |a + b cos t + c sin t| for t from lo to hi, in radians; all the way round without a range. */
428
+ function largestOver(a, b, c, range) {
429
+ if (!range)
430
+ return Math.abs(a) + Math.hypot(b, c);
431
+ const [lo, hi] = range;
432
+ const at = (t) => Math.abs(a + b * Math.cos(t) + c * Math.sin(t));
433
+ let most = Math.max(at(lo), at(hi));
434
+ /* Where its slope is zero: t = atan2(c, b) and half a turn from it, every whole turn over. */
435
+ const turn = 2 * Math.PI;
436
+ for (const t0 of [Math.atan2(c, b), Math.atan2(c, b) + Math.PI])
437
+ for (let t = t0 + Math.ceil((lo - t0) / turn) * turn; t <= hi; t += turn)
438
+ most = Math.max(most, at(t));
439
+ return most;
440
+ }
441
+ /**
442
+ * The directions seen from a frame turned about the unit axis `u` by any angle in `range` (radians; null all the way
443
+ * round): R(t) = u uᵀ + (I − u uᵀ) cos t + [u]× sin t, each entry a + b cos t + c sin t.
444
+ */
445
+ function sweptBy(d, u, range) {
446
+ const K = [
447
+ [0, -u[2], u[1]],
448
+ [u[2], 0, -u[0]],
449
+ [-u[1], u[0], 0]
450
+ ];
451
+ const A = (i, j) => u[i] * u[j];
452
+ const B = (i, j) => (i === j ? 1 : 0) - u[i] * u[j];
453
+ const C = (i, j) => K[i][j];
454
+ if (range && Math.abs(range[1] - range[0]) < 1e-12) {
455
+ const t = range[0];
456
+ return turnedBy(d, [0, 1, 2].map(i => [0, 1, 2].map(j => A(i, j) + B(i, j) * Math.cos(t) + C(i, j) * Math.sin(t))));
457
+ }
458
+ if ('one' in d) {
459
+ const v = d.one;
460
+ const sum = (f, j) => [0, 1, 2].reduce((s, i) => s + f(i, j) * v[i], 0);
461
+ const parts = [0, 1, 2].map(j => [sum(A, j), sum(B, j), sum(C, j)]);
462
+ /* Along the axis it is turned about, a direction does not move. */
463
+ if (parts.every(([, b, c]) => Math.abs(b) < 1e-12 && Math.abs(c) < 1e-12))
464
+ return d;
465
+ return { most: parts.map(([a, b, c]) => Math.min(1, largestOver(a, b, c, range))) };
466
+ }
467
+ return {
468
+ most: [0, 1, 2].map(j => Math.min(1, d.most.reduce((s, v, i) => s + largestOver(A(i, j), B(i, j), C(i, j), range) * v, 0)))
469
+ };
470
+ }
471
+ /**
472
+ * The angles a turn's control can give it, in degrees: its limits, scaled and moved as the angle is written from it.
473
+ * Null -- all the way round -- for a control without limits or that turns a whole turn, and for an angle written any
474
+ * other way.
475
+ */
476
+ function angleRangeOf(m, ref, depth = 0) {
477
+ if (depth > 64)
478
+ return null;
479
+ const constant = m.constants.find((c) => c.id === ref);
480
+ if (constant)
481
+ return constant.unit === 'deg' ? [constant.value, constant.value] : null;
482
+ const input = m.inputs.find((i) => i.id === ref);
483
+ if (input) {
484
+ if (input.role !== 'state' || input.unit !== 'deg')
485
+ return null;
486
+ const { min, max } = input;
487
+ return Number.isFinite(min) && Number.isFinite(max) && max - min < 360 ? [min, max] : null;
488
+ }
489
+ const w = writerOf(m, ref);
490
+ if (w?.op === 'add@1') {
491
+ let lo = 0, hi = 0;
492
+ for (const arg of w.args) {
493
+ const r = angleRangeOf(m, arg, depth + 1);
494
+ if (!r)
495
+ return null;
496
+ lo += r[0];
497
+ hi += r[1];
498
+ }
499
+ return hi - lo < 360 ? [lo, hi] : null;
500
+ }
501
+ if (w?.op === 'mul@1' && w.args.length === 2) {
502
+ const factor = (arg) => m.constants.find((c) => c.id === arg && c.unit === 'ratio')?.value;
503
+ const [a, b] = w.args;
504
+ const k = factor(a) ?? factor(b);
505
+ const r = factor(a) !== undefined ? angleRangeOf(m, b, depth + 1) : factor(b) !== undefined ? angleRangeOf(m, a, depth + 1) : null;
506
+ if (k === undefined || !r)
507
+ return null;
508
+ const ends = [r[0] * k, r[1] * k].sort((x, y) => x - y);
509
+ return ends[1] - ends[0] < 360 ? ends : null;
510
+ }
511
+ return null;
512
+ }
416
513
  /**
417
- * Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
418
- * every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
419
- * inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
420
- * ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
421
- * the parts against it.
514
+ * Where a part carried by a turn can reach, on each side of each axis, from the point the first turn above it turns
515
+ * about (ADR-0087, amendments 2026-10-21). Down the chain from there each step adds what it can add that way: while
516
+ * the directions are one, what it adds, sign included; once a joint has turned them through its range, each axis's
517
+ * largest times the size of that coordinate -- no more than its length, when its coordinates are numbers. The part
518
+ * adds its own half size the same way. Each step's largest taken on its own (the triangle inequality), it holds at
519
+ * every angle the joints can reach and every design size: a bound, not a sample. Read by the proposal; the release
520
+ * gate still checks the parts against it.
422
521
  */
423
- function reachOf(m, asset, id, ends, freezeless = true) {
522
+ function reachOf(m, asset, id, ends) {
424
523
  const chain = [];
425
524
  for (let part = id; part; part = parentOf(m, part))
426
525
  chain.push(part);
@@ -432,56 +531,149 @@ function reachOf(m, asset, id, ends, freezeless = true) {
432
531
  : found, -1);
433
532
  if (top < 0)
434
533
  return `${id} is carried by no turn`;
435
- let radius = { terms: [], c: 0 };
436
- const grow = (lin, what) => {
437
- const size = lin && magnitudeOf(m, lin);
438
- if (!size)
439
- return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
440
- radius = linAdd(radius, size);
441
- return null;
442
- };
443
- /* Its half size on every axis of its own, whichever way it stands: the ball holds it at any angle. */
534
+ const pivotPart = chain[top];
535
+ const parent = parentOf(m, pivotPart);
536
+ /* Above the pivot every part stands in quarter turns: the frame it turns in is a signed axis permutation. */
537
+ const frame = parent ? worldTurnOf(m, asset, parent) : IDENTITY;
538
+ if (!frame)
539
+ return `${pivotPart} is carried by a part turned by other than quarter turns`;
444
540
  const sizes = sizeRefsOf(m, id, id);
445
- void freezeless;
446
- for (const axis of AXES) {
447
- const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
448
- if (bad)
449
- return bad;
450
- }
451
- for (let i = 0; i <= top; i++) {
452
- const part = chain[i];
453
- const pose = nodeById(m, `${part}.pose`);
454
- if (!pose)
455
- return `${part} has no pose this command can read`;
456
- for (const ref of pose.args.slice(0, 3)) {
457
- const bad = grow(linOf(m, ref), `${part}'s place`);
458
- if (bad)
459
- return bad;
460
- }
461
- if (i < top) {
462
- const seat = nodeById(m, `${part}.seat.rigid`);
463
- for (const ref of seat?.args.slice(0, 3) ?? []) {
464
- const bad = grow(linOf(m, ref), `where ${part} is fastened`);
465
- if (bad)
466
- return bad;
541
+ const { shape } = partOf(m, id, id);
542
+ const faces = figureShapeDescriptionOf(shape)?.faces;
543
+ const offCentre = Object.fromEntries(AXES.map(axis => [axis, offCentreEndsOf(shape, faces, axis)]));
544
+ const constants = (refs) => refs.map(r => m.constants.find((c) => c.id === r)?.value);
545
+ const angles = (refs) => refs.map(r => angleOf(m, asset, r));
546
+ const reachToward = (e) => {
547
+ let d = { one: transposedTimes(frame, e) };
548
+ let out = { terms: [], c: 0 };
549
+ const most = (dir) => ('one' in dir ? dir.one.map(Math.abs) : dir.most);
550
+ /* A step of three coordinates, in the frame the directions are seen from. */
551
+ const step = (refs, what) => {
552
+ if ('one' in d) {
553
+ const v = d.one;
554
+ refs.forEach((ref, i) => {
555
+ if (Math.abs(v[i]) > 1e-12)
556
+ out = linAdd(out, linOf(m, ref), v[i]);
557
+ });
558
+ return null;
467
559
  }
468
- const motion = nodeById(m, `${part}.motion`);
469
- if (motion?.op === 'axis-slide@1')
470
- for (const end of ['min', 'max']) {
471
- const bad = grow(travelAt(m, asset, motion, end), `${part}'s travel`);
560
+ const w = d.most;
561
+ const numbers = constants(refs);
562
+ if (numbers.every(n => n !== undefined)) {
563
+ const across = numbers.reduce((s, n, i) => s + w[i] * Math.abs(n), 0);
564
+ const length = Math.hypot(...numbers.map((n, i) => (w[i] > 0 ? n : 0)));
565
+ out = linAdd(out, { terms: [], c: Math.min(across, length) });
566
+ return null;
567
+ }
568
+ for (const [i, ref] of refs.entries()) {
569
+ if (!w[i])
570
+ continue;
571
+ const size = magnitudeOf(m, linOf(m, ref));
572
+ if (!size)
573
+ return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
574
+ out = linAdd(out, size, w[i]);
575
+ }
576
+ return null;
577
+ };
578
+ const turnTo = (refs) => {
579
+ const a = angles(refs);
580
+ if (a.every(t => t === 0))
581
+ return;
582
+ d = a.every(t => t !== null && Number.isFinite(t))
583
+ ? turnedBy(d, rigidTurnOf(a))
584
+ : { most: [0, 1, 2].map(() => Math.min(1, most(d).reduce((s, v) => s + v, 0))) };
585
+ };
586
+ for (let i = top; i >= 0; i--) {
587
+ const part = chain[i];
588
+ const seat = nodeById(m, `${part}.seat.rigid`);
589
+ if (seat) {
590
+ /* The pivot's own seat is where it turns: it is the centre, not a step. */
591
+ if (i < top) {
592
+ const bad = step(seat.args.slice(0, 3), `where ${part} is fastened`);
472
593
  if (bad)
473
594
  return bad;
474
595
  }
596
+ turnTo(seat.args.slice(3, 6));
597
+ }
598
+ const motion = nodeById(m, `${part}.motion`);
599
+ if (motion) {
600
+ const unit = constants(motion.args.slice(0, 3));
601
+ if (unit.some(u => u === undefined))
602
+ return `${part}'s ${motion.op === 'axis-turn@1' ? 'turn' : 'slide'} axis is not a constant`;
603
+ const n = Math.hypot(...unit);
604
+ const u = unit.map(v => v / n);
605
+ if (motion.op === 'axis-turn@1') {
606
+ const range = angleRangeOf(m, motion.args[3]);
607
+ d = sweptBy(d, u, range && [(range[0] * Math.PI) / 180, (range[1] * Math.PI) / 180]);
608
+ }
609
+ else if (motion.op === 'axis-slide@1') {
610
+ const along = most(d).reduce((s, v, k) => s + v * Math.abs(u[k]), 0);
611
+ for (const end of ['min', 'max']) {
612
+ const travel = travelAt(m, asset, motion, end);
613
+ const size = travel && magnitudeOf(m, travel);
614
+ if (!size)
615
+ return `${part}'s travel is not a sum of sizes, so how far it reaches cannot be bounded`;
616
+ out = linAdd(out, size, along);
617
+ }
618
+ }
619
+ else
620
+ return `${part} moves by ${motion.op}, which this command does not add up`;
621
+ }
622
+ const pose = nodeById(m, `${part}.pose`);
623
+ if (!pose)
624
+ return `${part} has no pose this command can read`;
625
+ const bad = step(pose.args.slice(0, 3), `${part}'s place`);
626
+ if (bad)
627
+ return bad;
628
+ turnTo(pose.args.slice(3, 6));
475
629
  }
630
+ /*
631
+ Its own extent on every axis, as far as the directions reach across it: half its size either way about its
632
+ origin, or from face to face where they do not sit about it (a portal stands on 0, `offCentreEndsOf`).
633
+ */
634
+ const w = most(d);
635
+ for (const [i, axis] of AXES.entries()) {
636
+ if (!w[i])
637
+ continue;
638
+ const ends = offCentre[axis];
639
+ if (ends && 'one' in d) {
640
+ const e = d.one[i] > 0 ? ends[1] : ends[0];
641
+ out = linAdd(out, { terms: [{ ref: e.ref, k: e.k * d.one[i] }], c: 0 });
642
+ continue;
643
+ }
644
+ const reach = ends
645
+ ? ends.reduce((sum, e) => {
646
+ const size = sum && magnitudeOf(m, { terms: [{ ref: e.ref, k: e.k }], c: 0 });
647
+ return size && sum && linAtLeast(sum, size);
648
+ }, { terms: [], c: 0 })
649
+ : magnitudeOf(m, { terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 });
650
+ if (!reach)
651
+ return `${id}'s size is not a sum of sizes, so how far it reaches cannot be bounded`;
652
+ out = linAdd(out, reach, w[i]);
653
+ }
654
+ return linNorm(out);
655
+ };
656
+ const up = {};
657
+ const down = {};
658
+ for (const [i, axis] of AXES.entries()) {
659
+ const e = [0, 0, 0];
660
+ e[i] = 1;
661
+ const high = reachToward(e);
662
+ if (typeof high === 'string')
663
+ return high;
664
+ e[i] = -1;
665
+ const low = reachToward(e);
666
+ if (typeof low === 'string')
667
+ return low;
668
+ up[axis] = high;
669
+ down[axis] = low;
476
670
  }
477
671
  /*
478
672
  The pivot: where the topmost turn is fastened, and everything above it. Above it nothing turns, but a slide may
479
673
  carry it -- the telescopic conveyor's nose turns at the end of its third stage -- so the pivot is read at each
480
- end of the controls, a ball at each: a slide is a straight line, so the balls at its two ends bound it all the
481
- way (it had been refused, and the nose left out of the room).
674
+ end of the controls, a reach from each: a slide is a straight line, so the reaches from its two ends bound it all
675
+ the way (it had been refused, and the nose left out of the room).
482
676
  */
483
- const pivotPart = chain[top];
484
- const parent = parentOf(m, pivotPart);
485
677
  const seat = nodeById(m, `${pivotPart}.seat.rigid`);
486
678
  const seatAt = Object.fromEntries(AXES.map((a, i) => [a, seat ? linOf(m, seat.args[i]) : { terms: [], c: 0 }]));
487
679
  const centres = [];
@@ -492,7 +684,7 @@ function reachOf(m, asset, id, ends, freezeless = true) {
492
684
  return at;
493
685
  centres.push(at);
494
686
  }
495
- return { centres, radius };
687
+ return { centres, up, down };
496
688
  }
497
689
  /** The bounds the parts come to, as sums, with what could not be read named. */
498
690
  function occupancyLins(asset, over) {
@@ -502,8 +694,28 @@ function occupancyLins(asset, over) {
502
694
  const bounded = [];
503
695
  const low = { x: [], y: [], z: [] };
504
696
  const high = { x: [], y: [], z: [] };
505
- /* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
506
- const balls = new Map();
697
+ /*
698
+ Parts carried by the same turn each bound their own reach about its pivot, and the room takes the largest when the
699
+ graph is evaluated (ADR-0087, 2026-10-21). One ball per pivot, its radius the larger of theirs term by term, was
700
+ every branch added up: each part's sizes and places are values of its own, and a humanoid's torso, head and two arms
701
+ under its waist came to 8210 mm across for a body 300 mm wide. A reach that another one passes at every size -- by a
702
+ number and sizes, never below zero -- is left out.
703
+ */
704
+ const reaches = {
705
+ low: { x: [], y: [], z: [] },
706
+ high: { x: [], y: [], z: [] }
707
+ };
708
+ const atLeast = (a, b) => {
709
+ const gap = linNorm(linAdd(a, b, -1));
710
+ return gap.c >= -1e-9 && gap.terms.every(t => t.k > 0 && sizeValue(m, t.ref));
711
+ };
712
+ const addReach = (side, axis, lin) => {
713
+ const list = reaches[side][axis];
714
+ const further = (a, b) => (side === 'high' ? atLeast(a, b) : atLeast(b, a));
715
+ if (list.some(o => further(o, lin)))
716
+ return;
717
+ reaches[side][axis] = [...list.filter(o => !further(lin, o)), lin];
718
+ };
507
719
  /* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
508
720
  const rowOf = (id) => nodeById(m, `${id}.repeat`);
509
721
  const ends = over === 'rest' ? ['start'] : ['min', 'max'];
@@ -630,14 +842,14 @@ function occupancyLins(asset, over) {
630
842
  */
631
843
  for (const { run } of rows) {
632
844
  for (const l of run.lows)
633
- low[run.axis].push(linAdd(l, reach.radius, -1));
845
+ low[run.axis].push(linAdd(l, reach.down[run.axis], -1));
634
846
  for (const h of run.highs)
635
- high[run.axis].push(linAdd(h, reach.radius, 1));
847
+ high[run.axis].push(linAdd(h, reach.up[run.axis], 1));
636
848
  }
637
849
  for (const at of reach.centres)
638
850
  for (const axis of AXES.filter(a => !rowAxes.has(a))) {
639
- const lo = linAdd(at[axis], reach.radius, -1);
640
- const hi = linAdd(at[axis], reach.radius, 1);
851
+ const lo = linAdd(at[axis], reach.down[axis], -1);
852
+ const hi = linAdd(at[axis], reach.up[axis], 1);
641
853
  low[axis].push(lo);
642
854
  high[axis].push(hi);
643
855
  climbed(lo, -1, low, axis);
@@ -645,12 +857,11 @@ function occupancyLins(asset, over) {
645
857
  }
646
858
  continue;
647
859
  }
648
- for (const at of reach.centres) {
649
- const centre = Object.fromEntries(AXES.map(a => [a, linNorm(at[a])]));
650
- const key = JSON.stringify(centre);
651
- const ball = balls.get(key);
652
- balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
653
- }
860
+ for (const at of reach.centres)
861
+ for (const axis of AXES) {
862
+ addReach('low', axis, linNorm(linAdd(at[axis], reach.down[axis], -1)));
863
+ addReach('high', axis, linNorm(linAdd(at[axis], reach.up[axis], 1)));
864
+ }
654
865
  continue;
655
866
  }
656
867
  /*
@@ -743,7 +954,7 @@ function occupancyLins(asset, over) {
743
954
  for (const part of parts)
744
955
  if (boundsUnder(m, part).length)
745
956
  bounded.push(part);
746
- return { parts, skipped, bounded, low, high, balls: [...balls.values()] };
957
+ return { parts, skipped, bounded, low, high, reaches };
747
958
  }
748
959
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
749
960
  /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
@@ -753,12 +964,11 @@ export function proposeFigureOccupancy(asset, options = {}) {
753
964
  if (over !== 'rest' && over !== 'range')
754
965
  fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
755
966
  const source = structuredClone(asset);
756
- const { parts, skipped, bounded, low, high, balls } = occupancyLins(source, over);
757
- for (const ball of balls)
758
- for (const axis of AXES) {
759
- low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
760
- high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
761
- }
967
+ const { parts, skipped, bounded, low, high, reaches } = occupancyLins(source, over);
968
+ for (const axis of AXES) {
969
+ low[axis].push(...reaches.low[axis]);
970
+ high[axis].push(...reaches.high[axis]);
971
+ }
762
972
  boundedSides(source.document.model, low, high);
763
973
  if (!parts.length)
764
974
  fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
@@ -814,13 +1024,12 @@ export function declareOccupancy(asset, a) {
814
1024
  fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
815
1025
  padding[axis] = pad;
816
1026
  }
817
- const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
818
- /* A turning chain's ball, its reach either way about its pivot on each axis. */
819
- for (const ball of balls)
820
- for (const axis of AXES) {
821
- low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
822
- high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
823
- }
1027
+ const { parts, low, high, reaches } = occupancyLins(asset, a.proposal.over);
1028
+ /* What the parts carried by a turn reach either way about their pivots, on each axis. */
1029
+ for (const axis of AXES) {
1030
+ low[axis].push(...reaches.low[axis]);
1031
+ high[axis].push(...reaches.high[axis]);
1032
+ }
824
1033
  boundedSides(m, low, high);
825
1034
  for (const axis of AXES) {
826
1035
  if (!padding[axis])