@hatiolab/figure-model 0.9.4 → 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 (95) hide show
  1. package/README.md +1 -1
  2. package/dist/authoring-actions.d.ts.map +1 -1
  3. package/dist/authoring-actions.js +16 -23
  4. package/dist/authoring-actions.js.map +1 -1
  5. package/dist/authoring-fastening.d.ts.map +1 -1
  6. package/dist/authoring-fastening.js +182 -13
  7. package/dist/authoring-fastening.js.map +1 -1
  8. package/dist/authoring-geometry.d.ts +17 -1
  9. package/dist/authoring-geometry.d.ts.map +1 -1
  10. package/dist/authoring-geometry.js +66 -5
  11. package/dist/authoring-geometry.js.map +1 -1
  12. package/dist/authoring-nodes.d.ts +20 -0
  13. package/dist/authoring-nodes.d.ts.map +1 -1
  14. package/dist/authoring-nodes.js +34 -0
  15. package/dist/authoring-nodes.js.map +1 -1
  16. package/dist/authoring-occupancy.d.ts.map +1 -1
  17. package/dist/authoring-occupancy.js +419 -109
  18. package/dist/authoring-occupancy.js.map +1 -1
  19. package/dist/authoring-size.d.ts +5 -10
  20. package/dist/authoring-size.d.ts.map +1 -1
  21. package/dist/authoring-size.js +28 -98
  22. package/dist/authoring-size.js.map +1 -1
  23. package/dist/authoring-types.d.ts +43 -23
  24. package/dist/authoring-types.d.ts.map +1 -1
  25. package/dist/contract.d.ts +4 -16
  26. package/dist/contract.d.ts.map +1 -1
  27. package/dist/contract.js.map +1 -1
  28. package/dist/create.d.ts +5 -3
  29. package/dist/create.d.ts.map +1 -1
  30. package/dist/create.js +3 -7
  31. package/dist/create.js.map +1 -1
  32. package/dist/face-overhang.d.ts +22 -2
  33. package/dist/face-overhang.d.ts.map +1 -1
  34. package/dist/face-overhang.js +139 -1
  35. package/dist/face-overhang.js.map +1 -1
  36. package/dist/fasten-place.d.ts +77 -0
  37. package/dist/fasten-place.d.ts.map +1 -1
  38. package/dist/fasten-place.js +214 -2
  39. package/dist/fasten-place.js.map +1 -1
  40. package/dist/gate.d.ts.map +1 -1
  41. package/dist/gate.js +28 -8
  42. package/dist/gate.js.map +1 -1
  43. package/dist/graph-types.d.ts +15 -2
  44. package/dist/graph-types.d.ts.map +1 -1
  45. package/dist/graph.js +15 -6
  46. package/dist/graph.js.map +1 -1
  47. package/dist/index.d.ts +6 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/instance-size.d.ts +58 -32
  52. package/dist/instance-size.d.ts.map +1 -1
  53. package/dist/instance-size.js +155 -41
  54. package/dist/instance-size.js.map +1 -1
  55. package/dist/joint-list.d.ts.map +1 -1
  56. package/dist/joint-list.js +3 -3
  57. package/dist/joint-list.js.map +1 -1
  58. package/dist/joint-writing.d.ts.map +1 -1
  59. package/dist/joint-writing.js +69 -8
  60. package/dist/joint-writing.js.map +1 -1
  61. package/dist/kernel-version.d.ts +1 -1
  62. package/dist/kernel-version.js +1 -1
  63. package/dist/part-edit.d.ts.map +1 -1
  64. package/dist/part-edit.js +2 -2
  65. package/dist/part-edit.js.map +1 -1
  66. package/dist/proposal.js +9 -9
  67. package/dist/proposal.js.map +1 -1
  68. package/dist/recipe-from-parts.js +5 -5
  69. package/dist/recipe-from-parts.js.map +1 -1
  70. package/dist/release-contract.d.ts.map +1 -1
  71. package/dist/release-contract.js +7 -2
  72. package/dist/release-contract.js.map +1 -1
  73. package/dist/release-metrics.d.ts.map +1 -1
  74. package/dist/release-metrics.js +6 -2
  75. package/dist/release-metrics.js.map +1 -1
  76. package/dist/resize-plan.d.ts.map +1 -1
  77. package/dist/resize-plan.js +4 -9
  78. package/dist/resize-plan.js.map +1 -1
  79. package/dist/room.d.ts.map +1 -1
  80. package/dist/room.js +13 -4
  81. package/dist/room.js.map +1 -1
  82. package/dist/shape-edit.d.ts +1 -1
  83. package/dist/shape-edit.js +1 -1
  84. package/dist/shape-extreme.d.ts +55 -4
  85. package/dist/shape-extreme.d.ts.map +1 -1
  86. package/dist/shape-extreme.js +118 -4
  87. package/dist/shape-extreme.js.map +1 -1
  88. package/dist/shape-provider.d.ts.map +1 -1
  89. package/dist/shape-provider.js +4 -3
  90. package/dist/shape-provider.js.map +1 -1
  91. package/dist/size-edit.d.ts +32 -8
  92. package/dist/size-edit.d.ts.map +1 -1
  93. package/dist/size-edit.js +68 -125
  94. package/dist/size-edit.js.map +1 -1
  95. 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
- import { fail, nodeById, writerOf, 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";
14
+ import { fail, nodeById, writerOf, throughSums, rowHolding, partOf, namer, constantOf, dropIfUnused, parentOf, round6, stringsIn } from "./authoring-nodes.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
  /*
@@ -59,34 +60,6 @@ const linOf = (m, ref) => {
59
60
  const constant = m.constants.find((c) => c.id === ref);
60
61
  return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
61
62
  };
62
- /**
63
- * The same sum read back through the sums and scalings that make it up, down to what no command rewrites: the inputs,
64
- * and values that are not a sum (a row's count). A declared room that read a node a joint writes -- where a leg stands,
65
- * a share of the figure's depth -- was broken by the next fastening, which writes that joint's nodes afresh: the gantry
66
- * could not be re-fastened once its room was declared (UNKNOWN_REFERENCE, 2026-09-25).
67
- */
68
- function linThroughSums(m, lin) {
69
- const constant = (ref) => m.constants.find((c) => c.id === ref)?.value;
70
- const ratio = (ref) => m.constants.find((c) => c.id === ref && c.unit === 'ratio')?.value;
71
- const expand = (ref, k, depth) => {
72
- const c = constant(ref);
73
- if (c !== undefined)
74
- return { terms: [], c: c * k };
75
- const w = depth < 256 ? writerOf(m, ref) : undefined;
76
- if (w?.op === 'add@1')
77
- return w.args.reduce((sum, arg) => linAdd(sum, expand(arg, k, depth + 1)), { terms: [], c: 0 });
78
- /* Only a length times a ratio: what is left is then a length, as each term of the room must be. */
79
- if (w?.op === 'mul@1') {
80
- const [a, b] = w.args;
81
- if (ratio(b) !== undefined)
82
- return expand(a, k * ratio(b), depth + 1);
83
- if (ratio(a) !== undefined)
84
- return expand(b, k * ratio(a), depth + 1);
85
- }
86
- return { terms: [{ ref, k }], c: 0 };
87
- };
88
- return linNorm(lin.terms.reduce((sum, t) => linAdd(sum, expand(t.ref, t.k, 0)), { terms: [], c: lin.c }));
89
- }
90
63
  /**
91
64
  * How far a slide has gone when its control is at one end of its range, as a sum: each control at that end, what the
92
65
  * figure's sizes make of the rest. A mimic of a leader whose stroke follows a length is the leader's ratio times that
@@ -161,7 +134,7 @@ function poseTurnOf(m, asset, part) {
161
134
  const seatTurned = (m, asset, part) => (nodeById(m, `${part}.seat.rigid`)?.args.slice(3, 6) ?? []).some((ref) => angleOf(m, asset, ref) !== 0);
162
135
  /**
163
136
  * Whether a part, or anything carrying it, turns on a joint, stands turned by other than quarter turns, or is seated
164
- * 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`).
165
138
  */
166
139
  function carriedByATurn(m, asset, id) {
167
140
  for (let part = id; part; part = parentOf(m, part)) {
@@ -286,8 +259,9 @@ function sizeValue(m, ref, seen = 0) {
286
259
  if (seen > 64)
287
260
  return false;
288
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). */
289
263
  if (input)
290
- return input.role !== 'state' && input.min >= 0;
264
+ return input.role !== 'state' && (/^size\.[xyz]$/.test(input.id) || input.min >= 0);
291
265
  const w = writerOf(m, ref);
292
266
  const positive = (r) => (m.constants.find((c) => c.id === r)?.value ?? -1) > 0;
293
267
  if (w?.op === 'geomean@1')
@@ -304,12 +278,20 @@ function sizeValue(m, ref, seen = 0) {
304
278
  * How long a value can be, at most, as a sum that holds at every design size: each input's coefficient taken
305
279
  * positive. Sound only over inputs that are never below zero (sizes), so any other input makes it unreadable.
306
280
  */
307
- function magnitudeOf(m, lin) {
281
+ function magnitudeOf(m, sum) {
308
282
  let out = { terms: [], c: 0 };
283
+ /* Read through its sums first: what cancels there -- a rim's place taken back out of a pose -- is not counted. */
284
+ const lin = throughSums(m, sum);
309
285
  for (const t of lin.terms) {
310
286
  const a = affineOf(m, t.ref);
311
- if (!a)
312
- return null;
287
+ if (!a) {
288
+ /* A value held by bounds (`boundsOf`): no larger than its bound -- √(x·y) ≤ (|x| + |y|) / 2, a largest or least ≤ the larger. */
289
+ const bound = boundedMagnitudeOf(m, t.ref);
290
+ if (!bound)
291
+ return null;
292
+ out = linAdd(out, bound, Math.abs(t.k));
293
+ continue;
294
+ }
313
295
  for (const u of a.terms) {
314
296
  if (!sizeValue(m, u.ref))
315
297
  return null;
@@ -321,13 +303,223 @@ function magnitudeOf(m, lin) {
321
303
  return out;
322
304
  }
323
305
  /**
324
- * Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
325
- * every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
326
- * inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
327
- * ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
328
- * the parts against it.
306
+ * Places a fastening writes that are not sums (ADR-0140 3.9) are held by bounds, each a few sums (chief architect
307
+ * 2026-10-20): the room takes them as candidates, the least of the lows and the greatest of the highs, so it holds at
308
+ * every design size though it may be wider than the parts by a little -- and says by how much (`bounded`).
309
+ * - where a part lined up with a disc's rim stands, √((R − E)(R + E));
310
+ * - a point of an ellipsoid of two radii, r_i² · n_i / hypot(r·n), written as (r_i · r_i · n_i) / hypot: it lies on the
311
+ * ellipsoid, so between 0 and r_i on the side n_i points to.
312
+ * A root of sizes alone -- the one ratio a figure grows by -- is a size, read as it is (`sizeValue`).
313
+ */
314
+ function boundedKind(m, ref) {
315
+ const w = writerOf(m, ref);
316
+ if (w?.op === 'geomean@1' && !sizeValue(m, ref))
317
+ return { root: [w.args[0], w.args[1]] };
318
+ if (w?.op !== 'div@1')
319
+ return undefined;
320
+ const top = writerOf(m, w.args[0]);
321
+ const k = top?.op === 'mul@1' ? m.constants.find((c) => c.id === top.args[1] && c.unit === 'ratio')?.value : undefined;
322
+ const square = k ? writerOf(m, top.args[0]) : undefined;
323
+ if (square?.op !== 'mul@1' || square.args[0] !== square.args[1])
324
+ return undefined;
325
+ return { point: { radius: square.args[0], sign: k > 0 ? 1 : -1 } };
326
+ }
327
+ const isBounded = (m, ref) => boundedKind(m, ref) !== undefined;
328
+ /**
329
+ * Sums that bound a value from above (`up`: its greatest is at least the value) or below (its least is at most it):
330
+ * √(x·y) ∈ [min(x, y), (x + y) / 2] a point of an ellipsoid ∈ [0, r_i] on the side n_i points to
331
+ * read through the sums and ratios that make it up; a term with a negative factor bounded the other way. What is
332
+ * inside the root -- the largest of R − E and 0 -- is read as it is, as the room reads any largest.
329
333
  */
330
- function reachOf(m, asset, id, ends, freezeless = true) {
334
+ function boundsOf(m, lin, up, seen = 0) {
335
+ const flat = throughSums(m, lin);
336
+ let out = [{ terms: [], c: flat.c }];
337
+ for (const t of flat.terms) {
338
+ let candidates = [{ terms: [t], c: 0 }];
339
+ const kind = seen < 64 ? boundedKind(m, t.ref) : undefined;
340
+ if (kind) {
341
+ const way = t.k > 0 ? up : !up;
342
+ let raw;
343
+ if ('root' in kind) {
344
+ const each = kind.root.map(ref => boundsOf(m, { terms: [{ ref, k: 1 }], c: 0 }, way, seen + 1));
345
+ raw = way ? each[0].flatMap(x => each[1].map(y => linAdd({ terms: [], c: 0 }, linAdd(x, y), 0.5))) : each.flat();
346
+ }
347
+ else {
348
+ /* Toward its side the radius, away from it the middle. */
349
+ const far = kind.point.sign > 0 === way;
350
+ raw = far ? boundsOf(m, { terms: [{ ref: kind.point.radius, k: kind.point.sign }], c: 0 }, way, seen + 1) : [{ terms: [], c: 0 }];
351
+ }
352
+ candidates = raw.map(c => linAdd({ terms: [], c: 0 }, c, t.k));
353
+ }
354
+ out = out.flatMap(o => candidates.map(c => linAdd(o, c)));
355
+ }
356
+ return out.map(linNorm);
357
+ }
358
+ /**
359
+ * How large a bounded root can be, as one sum of sizes: √(x·y) no more than (|x| + |y|) / 2; the largest or least of
360
+ * values inside it no more than the larger of them.
361
+ */
362
+ function boundedMagnitudeOf(m, ref, inside = false) {
363
+ const w = writerOf(m, ref);
364
+ const kind = boundedKind(m, ref);
365
+ if (kind && 'point' in kind)
366
+ return magnitudeOf(m, { terms: [{ ref: kind.point.radius, k: 1 }], c: 0 });
367
+ if (!(kind || (inside && (w?.op === 'max@1' || w?.op === 'min@1'))))
368
+ return null;
369
+ const each = w.args.map(a => {
370
+ const lin = throughSums(m, { terms: [{ ref: a, k: 1 }], c: 0 });
371
+ let out = { terms: [], c: Math.abs(lin.c) };
372
+ for (const t of lin.terms) {
373
+ const size = affineOf(m, t.ref) ? magnitudeOf(m, { terms: [t], c: 0 }) : boundedMagnitudeOf(m, t.ref, true);
374
+ if (!size)
375
+ return null;
376
+ out = linAdd(out, size, Math.abs(t.k));
377
+ }
378
+ return out;
379
+ });
380
+ if (each.some(e => !e))
381
+ return null;
382
+ if (w.op === 'geomean@1')
383
+ return linAdd({ terms: [], c: 0 }, linAdd(each[0], each[1]), 0.5);
384
+ return each.reduce((a, b) => linAtLeast(a, b));
385
+ }
386
+ /** The values held by bounds a part's place reads, up what carries it: its room may be wider than it there. */
387
+ function boundsUnder(m, id) {
388
+ const found = new Set();
389
+ for (let part = id; part; part = parentOf(m, part))
390
+ for (const node of [nodeById(m, `${part}.pose`), nodeById(m, `${part}.seat.rigid`)])
391
+ for (const ref of (node?.args ?? []).slice(0, 3))
392
+ for (const t of throughSums(m, linOf(m, ref)).terms)
393
+ if (isBounded(m, t.ref))
394
+ found.add(t.ref);
395
+ return [...found];
396
+ }
397
+ /**
398
+ * How much wider than a bounded value its room may be, at the figure's values, in mm: the greatest of its bound over
399
+ * the value -- (x + y) / 2 − √(x·y) for a root, r_i − |p_i| for a point of an ellipsoid (chief architect 2026-10-20).
400
+ */
401
+ function boundedWidthOf(m, values, refs) {
402
+ let most = 0;
403
+ for (const ref of refs) {
404
+ const kind = boundedKind(m, ref);
405
+ const v = Math.abs(values[ref] ?? 0);
406
+ const wide = 'root' in kind ? (values[kind.root[0]] + values[kind.root[1]]) / 2 - v : Math.abs(values[kind.point.radius]) - v;
407
+ most = Math.max(most, wide);
408
+ }
409
+ return Math.round(most * 10) / 10;
410
+ }
411
+ /** Every low candidate bounded from below and every high one from above, so the room holds whatever they come to. */
412
+ function boundedSides(m, low, high) {
413
+ for (const axis of AXES) {
414
+ low[axis] = low[axis].flatMap(l => boundsOf(m, l, false));
415
+ high[axis] = high[axis].flatMap(l => boundsOf(m, l, true));
416
+ }
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
+ }
513
+ /**
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.
521
+ */
522
+ function reachOf(m, asset, id, ends) {
331
523
  const chain = [];
332
524
  for (let part = id; part; part = parentOf(m, part))
333
525
  chain.push(part);
@@ -339,56 +531,149 @@ function reachOf(m, asset, id, ends, freezeless = true) {
339
531
  : found, -1);
340
532
  if (top < 0)
341
533
  return `${id} is carried by no turn`;
342
- let radius = { terms: [], c: 0 };
343
- const grow = (lin, what) => {
344
- const size = lin && magnitudeOf(m, lin);
345
- if (!size)
346
- return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
347
- radius = linAdd(radius, size);
348
- return null;
349
- };
350
- /* 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`;
351
540
  const sizes = sizeRefsOf(m, id, id);
352
- void freezeless;
353
- for (const axis of AXES) {
354
- const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
355
- if (bad)
356
- return bad;
357
- }
358
- for (let i = 0; i <= top; i++) {
359
- const part = chain[i];
360
- const pose = nodeById(m, `${part}.pose`);
361
- if (!pose)
362
- return `${part} has no pose this command can read`;
363
- for (const ref of pose.args.slice(0, 3)) {
364
- const bad = grow(linOf(m, ref), `${part}'s place`);
365
- if (bad)
366
- return bad;
367
- }
368
- if (i < top) {
369
- const seat = nodeById(m, `${part}.seat.rigid`);
370
- for (const ref of seat?.args.slice(0, 3) ?? []) {
371
- const bad = grow(linOf(m, ref), `where ${part} is fastened`);
372
- if (bad)
373
- 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;
374
559
  }
375
- const motion = nodeById(m, `${part}.motion`);
376
- if (motion?.op === 'axis-slide@1')
377
- for (const end of ['min', 'max']) {
378
- 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`);
379
593
  if (bad)
380
594
  return bad;
381
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));
382
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;
383
670
  }
384
671
  /*
385
672
  The pivot: where the topmost turn is fastened, and everything above it. Above it nothing turns, but a slide may
386
673
  carry it -- the telescopic conveyor's nose turns at the end of its third stage -- so the pivot is read at each
387
- 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
388
- 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).
389
676
  */
390
- const pivotPart = chain[top];
391
- const parent = parentOf(m, pivotPart);
392
677
  const seat = nodeById(m, `${pivotPart}.seat.rigid`);
393
678
  const seatAt = Object.fromEntries(AXES.map((a, i) => [a, seat ? linOf(m, seat.args[i]) : { terms: [], c: 0 }]));
394
679
  const centres = [];
@@ -399,17 +684,38 @@ function reachOf(m, asset, id, ends, freezeless = true) {
399
684
  return at;
400
685
  centres.push(at);
401
686
  }
402
- return { centres, radius };
687
+ return { centres, up, down };
403
688
  }
404
689
  /** The bounds the parts come to, as sums, with what could not be read named. */
405
690
  function occupancyLins(asset, over) {
406
691
  const m = asset.document.model;
407
692
  const parts = [];
408
693
  const skipped = [];
694
+ const bounded = [];
409
695
  const low = { x: [], y: [], z: [] };
410
696
  const high = { x: [], y: [], z: [] };
411
- /* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
412
- 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
+ };
413
719
  /* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
414
720
  const rowOf = (id) => nodeById(m, `${id}.repeat`);
415
721
  const ends = over === 'rest' ? ['start'] : ['min', 'max'];
@@ -536,14 +842,14 @@ function occupancyLins(asset, over) {
536
842
  */
537
843
  for (const { run } of rows) {
538
844
  for (const l of run.lows)
539
- low[run.axis].push(linAdd(l, reach.radius, -1));
845
+ low[run.axis].push(linAdd(l, reach.down[run.axis], -1));
540
846
  for (const h of run.highs)
541
- high[run.axis].push(linAdd(h, reach.radius, 1));
847
+ high[run.axis].push(linAdd(h, reach.up[run.axis], 1));
542
848
  }
543
849
  for (const at of reach.centres)
544
850
  for (const axis of AXES.filter(a => !rowAxes.has(a))) {
545
- const lo = linAdd(at[axis], reach.radius, -1);
546
- 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);
547
853
  low[axis].push(lo);
548
854
  high[axis].push(hi);
549
855
  climbed(lo, -1, low, axis);
@@ -551,12 +857,11 @@ function occupancyLins(asset, over) {
551
857
  }
552
858
  continue;
553
859
  }
554
- for (const at of reach.centres) {
555
- const centre = Object.fromEntries(AXES.map(a => [a, linNorm(at[a])]));
556
- const key = JSON.stringify(centre);
557
- const ball = balls.get(key);
558
- balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
559
- }
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
+ }
560
865
  continue;
561
866
  }
562
867
  /*
@@ -646,7 +951,10 @@ function occupancyLins(asset, over) {
646
951
  }
647
952
  }
648
953
  }
649
- return { parts, skipped, low, high, balls: [...balls.values()] };
954
+ for (const part of parts)
955
+ if (boundsUnder(m, part).length)
956
+ bounded.push(part);
957
+ return { parts, skipped, bounded, low, high, reaches };
650
958
  }
651
959
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
652
960
  /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
@@ -656,12 +964,12 @@ export function proposeFigureOccupancy(asset, options = {}) {
656
964
  if (over !== 'rest' && over !== 'range')
657
965
  fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
658
966
  const source = structuredClone(asset);
659
- const { parts, skipped, low, high, balls } = occupancyLins(source, over);
660
- for (const ball of balls)
661
- for (const axis of AXES) {
662
- low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
663
- high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
664
- }
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
+ }
972
+ boundedSides(source.document.model, low, high);
665
973
  if (!parts.length)
666
974
  fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
667
975
  const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults })
@@ -673,7 +981,9 @@ export function proposeFigureOccupancy(asset, options = {}) {
673
981
  max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l))))
674
982
  }
675
983
  ]));
676
- return { over, at, parts, skipped, basis: fingerprintOf(source) };
984
+ const m = source.document.model;
985
+ const held = bounded.map(part => ({ part, mm: boundedWidthOf(m, values, boundsUnder(m, part)) }));
986
+ return { over, at, parts, skipped, ...(held.length ? { bounded: held } : {}), basis: fingerprintOf(source) };
677
987
  }
678
988
  /**
679
989
  * The placement says which plane the mounting plane is. A part fastened to it in a way the new plane does not take --
@@ -714,13 +1024,13 @@ export function declareOccupancy(asset, a) {
714
1024
  fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
715
1025
  padding[axis] = pad;
716
1026
  }
717
- const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
718
- /* A turning chain's ball, its reach either way about its pivot on each axis. */
719
- for (const ball of balls)
720
- for (const axis of AXES) {
721
- low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
722
- high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
723
- }
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
+ }
1033
+ boundedSides(m, low, high);
724
1034
  for (const axis of AXES) {
725
1035
  if (!padding[axis])
726
1036
  continue;
@@ -733,7 +1043,7 @@ export function declareOccupancy(asset, a) {
733
1043
  const args = [];
734
1044
  const layout = AXES.flatMap(axis => [low[axis], high[axis]]).map(side => side
735
1045
  .map((l) => {
736
- const n = linThroughSums(m, l);
1046
+ const n = throughSums(m, l);
737
1047
  args.push(constantOf(m, 'mm', round6(n.c), 'd'));
738
1048
  for (const t of n.terms)
739
1049
  args.push(t.ref, constantOf(m, 'ratio', t.k, 'k'));