@hatiolab/figure-model 0.9.5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/asset-types.d.ts +24 -56
  3. package/dist/asset-types.d.ts.map +1 -1
  4. package/dist/asset-types.js.map +1 -1
  5. package/dist/asset.d.ts +0 -7
  6. package/dist/asset.d.ts.map +1 -1
  7. package/dist/asset.js +11 -45
  8. package/dist/asset.js.map +1 -1
  9. package/dist/authoring-actions.d.ts.map +1 -1
  10. package/dist/authoring-actions.js +18 -47
  11. package/dist/authoring-actions.js.map +1 -1
  12. package/dist/authoring-bindings.js +1 -1
  13. package/dist/authoring-bindings.js.map +1 -1
  14. package/dist/authoring-dimensions.js +3 -3
  15. package/dist/authoring-geometry.d.ts +7 -0
  16. package/dist/authoring-geometry.d.ts.map +1 -1
  17. package/dist/authoring-geometry.js +25 -2
  18. package/dist/authoring-geometry.js.map +1 -1
  19. package/dist/authoring-nodes.d.ts +4 -4
  20. package/dist/authoring-nodes.d.ts.map +1 -1
  21. package/dist/authoring-nodes.js +7 -7
  22. package/dist/authoring-nodes.js.map +1 -1
  23. package/dist/authoring-placement.d.ts +40 -0
  24. package/dist/authoring-placement.d.ts.map +1 -0
  25. package/dist/{authoring-occupancy.js → authoring-placement.js} +325 -223
  26. package/dist/authoring-placement.js.map +1 -0
  27. package/dist/authoring-proportion.d.ts.map +1 -1
  28. package/dist/authoring-proportion.js +3 -4
  29. package/dist/authoring-proportion.js.map +1 -1
  30. package/dist/authoring-size.d.ts +5 -10
  31. package/dist/authoring-size.d.ts.map +1 -1
  32. package/dist/authoring-size.js +29 -99
  33. package/dist/authoring-size.js.map +1 -1
  34. package/dist/authoring-types.d.ts +10 -79
  35. package/dist/authoring-types.d.ts.map +1 -1
  36. package/dist/body.d.ts +19 -0
  37. package/dist/body.d.ts.map +1 -0
  38. package/dist/body.js +37 -0
  39. package/dist/body.js.map +1 -0
  40. package/dist/command-preview.js +4 -4
  41. package/dist/command-preview.js.map +1 -1
  42. package/dist/contract.d.ts +4 -19
  43. package/dist/contract.d.ts.map +1 -1
  44. package/dist/contract.js.map +1 -1
  45. package/dist/create.d.ts +5 -3
  46. package/dist/create.d.ts.map +1 -1
  47. package/dist/create.js +3 -7
  48. package/dist/create.js.map +1 -1
  49. package/dist/examples.d.ts.map +1 -1
  50. package/dist/examples.js +1 -2
  51. package/dist/examples.js.map +1 -1
  52. package/dist/face-overhang.js +2 -2
  53. package/dist/face-overhang.js.map +1 -1
  54. package/dist/fasten-place.js +7 -7
  55. package/dist/fasten-place.js.map +1 -1
  56. package/dist/gate.d.ts +15 -32
  57. package/dist/gate.d.ts.map +1 -1
  58. package/dist/gate.js +60 -107
  59. package/dist/gate.js.map +1 -1
  60. package/dist/graph-types.d.ts +17 -4
  61. package/dist/graph-types.d.ts.map +1 -1
  62. package/dist/graph.d.ts.map +1 -1
  63. package/dist/graph.js +21 -73
  64. package/dist/graph.js.map +1 -1
  65. package/dist/index.d.ts +8 -8
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +6 -6
  68. package/dist/index.js.map +1 -1
  69. package/dist/instance-size.d.ts +58 -32
  70. package/dist/instance-size.d.ts.map +1 -1
  71. package/dist/instance-size.js +155 -41
  72. package/dist/instance-size.js.map +1 -1
  73. package/dist/joint-list.d.ts.map +1 -1
  74. package/dist/joint-list.js +3 -3
  75. package/dist/joint-list.js.map +1 -1
  76. package/dist/joint-part-edits.d.ts +1 -10
  77. package/dist/joint-part-edits.d.ts.map +1 -1
  78. package/dist/joint-part-edits.js +6 -64
  79. package/dist/joint-part-edits.js.map +1 -1
  80. package/dist/joint-writing.d.ts.map +1 -1
  81. package/dist/joint-writing.js +9 -4
  82. package/dist/joint-writing.js.map +1 -1
  83. package/dist/kernel-version.d.ts +1 -1
  84. package/dist/kernel-version.d.ts.map +1 -1
  85. package/dist/kernel-version.js +1 -1
  86. package/dist/kernel-version.js.map +1 -1
  87. package/dist/part-edit.d.ts +1 -8
  88. package/dist/part-edit.d.ts.map +1 -1
  89. package/dist/part-edit.js +17 -34
  90. package/dist/part-edit.js.map +1 -1
  91. package/dist/proposal.d.ts +4 -11
  92. package/dist/proposal.d.ts.map +1 -1
  93. package/dist/proposal.js +10 -17
  94. package/dist/proposal.js.map +1 -1
  95. package/dist/recipe-from-parts.js +5 -5
  96. package/dist/recipe-from-parts.js.map +1 -1
  97. package/dist/recipe.js +1 -1
  98. package/dist/recipe.js.map +1 -1
  99. package/dist/release-contract.d.ts +1 -1
  100. package/dist/release-contract.d.ts.map +1 -1
  101. package/dist/release-contract.js +39 -31
  102. package/dist/release-contract.js.map +1 -1
  103. package/dist/release-metrics.d.ts +6 -14
  104. package/dist/release-metrics.d.ts.map +1 -1
  105. package/dist/release-metrics.js +14 -31
  106. package/dist/release-metrics.js.map +1 -1
  107. package/dist/resize-plan.d.ts +0 -5
  108. package/dist/resize-plan.d.ts.map +1 -1
  109. package/dist/resize-plan.js +8 -38
  110. package/dist/resize-plan.js.map +1 -1
  111. package/dist/robots.d.ts.map +1 -1
  112. package/dist/robots.js +1 -2
  113. package/dist/robots.js.map +1 -1
  114. package/dist/shape-provider.d.ts.map +1 -1
  115. package/dist/shape-provider.js +4 -3
  116. package/dist/shape-provider.js.map +1 -1
  117. package/dist/size-edit.js +3 -3
  118. package/dist/size-edit.js.map +1 -1
  119. package/dist/surface.js +2 -2
  120. package/docs/asset-persistence.md +1 -1
  121. package/docs/document-contract.md +0 -1
  122. package/docs/operator-contracts.md +3 -18
  123. package/docs/shape-dimension-contract.md +27 -25
  124. package/package.json +1 -1
  125. package/dist/authoring-occupancy.d.ts +0 -28
  126. package/dist/authoring-occupancy.d.ts.map +0 -1
  127. package/dist/authoring-occupancy.js.map +0 -1
  128. package/dist/room.d.ts +0 -53
  129. package/dist/room.d.ts.map +0 -1
  130. package/dist/room.js +0 -92
  131. package/dist/room.js.map +0 -1
@@ -2,41 +2,18 @@
2
2
  * Copyright © HatioLab Inc. All rights reserved.
3
3
  */
4
4
  /*
5
- * The room the figure takes up: proposed from its parts, declared, and the face it is mounted by.
5
+ * The room a figure sweeps as it moves (ADR-0145 2.3), worked out from its parts and joints; where it is placed from, and
6
+ * the face it is mounted by.
6
7
  */
7
- import { upgradeFigurePlacement, figurePlacementOf } from "./asset.js";
8
+ import { figurePlacementOf } from "./asset.js";
8
9
  import { compileFigureGraph } from "./graph.js";
10
+ import { figureShapeDescriptionOf } from "./shape-ops.js";
9
11
  import { FigureContractError } from "./contract.js";
10
12
  import { AXES } from "./words.js";
11
13
  import { FIGURE_PLACEMENTS } from "./asset-types.js";
12
14
  import { FIGURE_FACES } from "./authoring-types.js";
13
15
  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
- /** A figure as one value, for saying "this is the figure I read". The declaration itself is left out of it. */
16
- function fingerprintOf(asset) {
17
- /*
18
- The room and the placement are left out: neither changes what the room would measure (ADR-0097). Nor do the clips,
19
- their groups and the data connections -- the room is measured over the joints' ranges, not what plays them -- and a
20
- command is applied with the groups and their clips held aside (clip-keeping.ts): counted, a room proposed on the
21
- figure as it is was stale against the same figure without them, and a figure with a motion group could not declare
22
- its room again (the robot dog, 2026-10-19).
23
- */
24
- const text = JSON.stringify({
25
- ...asset,
26
- placement: undefined,
27
- occupancy: asset.occupancy ? { ...asset.occupancy, bounds: null } : undefined,
28
- drivers: undefined,
29
- motionGroups: undefined,
30
- dataInterface: undefined
31
- });
32
- let h1 = 0x811c9dc5;
33
- let h2 = 0x01000193;
34
- for (let i = 0; i < text.length; i++) {
35
- h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
36
- h2 = Math.imul(h2 + text.charCodeAt(i), 0x85ebca6b) >>> 0;
37
- }
38
- return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
39
- }
16
+ import { isReach, sizeRefsOf, occupiedExtentsOf, angleOf, turnMatrix, mul3, standingRefsOf, half, axisTurn, offCentreEndsOf, IDENTITY } from "./authoring-geometry.js";
40
17
  const linAdd = (a, b, k = 1) => ({
41
18
  terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))],
42
19
  c: a.c + b.c * k
@@ -63,7 +40,7 @@ const linOf = (m, ref) => {
63
40
  * 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
64
41
  * figure's sizes make of the rest. A mimic of a leader whose stroke follows a length is the leader's ratio times that
65
42
  * length times its multiplier -- read through, not refused: the telescopic conveyor's second and third stages were
66
- * left out of its declared room, 2600 and 5200 mm past it (2026-09-25).
43
+ * left out of the room it was given, 2600 and 5200 mm past it (2026-09-25).
67
44
  */
68
45
  function travelAt(m, asset, motion, bound) {
69
46
  const pick = (i) => bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max;
@@ -133,7 +110,7 @@ function poseTurnOf(m, asset, part) {
133
110
  const seatTurned = (m, asset, part) => (nodeById(m, `${part}.seat.rigid`)?.args.slice(3, 6) ?? []).some((ref) => angleOf(m, asset, ref) !== 0);
134
111
  /**
135
112
  * 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`).
113
+ * turned: a box cannot follow it, its reach about the point it turns at can (`reachOf`).
137
114
  */
138
115
  function carriedByATurn(m, asset, id) {
139
116
  for (let part = id; part; part = parentOf(m, part)) {
@@ -258,8 +235,9 @@ function sizeValue(m, ref, seen = 0) {
258
235
  if (seen > 64)
259
236
  return false;
260
237
  const input = m.inputs.find((i) => i.id === ref);
238
+ /* The instance size is never below zero, its smallest end given or not (ADR-0087 decision 6). */
261
239
  if (input)
262
- return input.role !== 'state' && input.min >= 0;
240
+ return input.role !== 'state' && (/^size\.[xyz]$/.test(input.id) || input.min >= 0);
263
241
  const w = writerOf(m, ref);
264
242
  const positive = (r) => (m.constants.find((c) => c.id === r)?.value ?? -1) > 0;
265
243
  if (w?.op === 'geomean@1')
@@ -381,31 +359,6 @@ function boundedMagnitudeOf(m, ref, inside = false) {
381
359
  return linAdd({ terms: [], c: 0 }, linAdd(each[0], each[1]), 0.5);
382
360
  return each.reduce((a, b) => linAtLeast(a, b));
383
361
  }
384
- /** The values held by bounds a part's place reads, up what carries it: its room may be wider than it there. */
385
- function boundsUnder(m, id) {
386
- const found = new Set();
387
- for (let part = id; part; part = parentOf(m, part))
388
- for (const node of [nodeById(m, `${part}.pose`), nodeById(m, `${part}.seat.rigid`)])
389
- for (const ref of (node?.args ?? []).slice(0, 3))
390
- for (const t of throughSums(m, linOf(m, ref)).terms)
391
- if (isBounded(m, t.ref))
392
- found.add(t.ref);
393
- return [...found];
394
- }
395
- /**
396
- * How much wider than a bounded value its room may be, at the figure's values, in mm: the greatest of its bound over
397
- * 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).
398
- */
399
- function boundedWidthOf(m, values, refs) {
400
- let most = 0;
401
- for (const ref of refs) {
402
- const kind = boundedKind(m, ref);
403
- const v = Math.abs(values[ref] ?? 0);
404
- const wide = 'root' in kind ? (values[kind.root[0]] + values[kind.root[1]]) / 2 - v : Math.abs(values[kind.point.radius]) - v;
405
- most = Math.max(most, wide);
406
- }
407
- return Math.round(most * 10) / 10;
408
- }
409
362
  /** Every low candidate bounded from below and every high one from above, so the room holds whatever they come to. */
410
363
  function boundedSides(m, low, high) {
411
364
  for (const axis of AXES) {
@@ -413,14 +366,115 @@ function boundedSides(m, low, high) {
413
366
  high[axis] = high[axis].flatMap(l => boundsOf(m, l, true));
414
367
  }
415
368
  }
369
+ const transposedTimes = (r, d) => [0, 1, 2].map(j => r[0][j] * d[0] + r[1][j] * d[1] + r[2][j] * d[2]);
370
+ /** A turn as `rigid@1` writes it, in degrees about X, Y and Z: Rx·Ry·Rz, at any angle (`turnMatrix` takes quarter turns). */
371
+ const rigidTurnOf = ([x, y, z]) => mul3(mul3(axisTurn('x', x), axisTurn('y', y)), axisTurn('z', z));
372
+ /** Each component's largest size, which a unit direction never passes. */
373
+ const largestOf = (d) => d.mid.map((v, i) => Math.min(1, Math.abs(v) + d.spread[i]));
374
+ /** The same directions, each component held to [−1, 1]. */
375
+ function heldToUnit(mid, spread) {
376
+ const lo = mid.map((v, i) => Math.max(-1, v - spread[i]));
377
+ const hi = mid.map((v, i) => Math.min(1, v + spread[i]));
378
+ return { mid: lo.map((l, i) => (l + hi[i]) / 2), spread: lo.map((l, i) => (hi[i] - l) / 2) };
379
+ }
380
+ /** The directions seen from a frame turned by `r` from this one (a point there is r·x here). */
381
+ function turnedBy(d, r) {
382
+ return heldToUnit(transposedTimes(r, d.mid), [0, 1, 2].map(j => d.spread.reduce((sum, v, i) => sum + Math.abs(r[i][j]) * v, 0)));
383
+ }
384
+ /** The least and the greatest of a + b cos t + c sin t for t from lo to hi, in radians; all the way round without a range. */
385
+ function rangeOver(a, b, c, range) {
386
+ if (!range)
387
+ return [a - Math.hypot(b, c), a + Math.hypot(b, c)];
388
+ const [lo, hi] = range;
389
+ const at = (t) => a + b * Math.cos(t) + c * Math.sin(t);
390
+ let least = Math.min(at(lo), at(hi)), most = Math.max(at(lo), at(hi));
391
+ /* Where its slope is zero: t = atan2(c, b) and half a turn from it, every whole turn over. */
392
+ const turn = 2 * Math.PI;
393
+ for (const t0 of [Math.atan2(c, b), Math.atan2(c, b) + Math.PI])
394
+ for (let t = t0 + Math.ceil((lo - t0) / turn) * turn; t <= hi; t += turn) {
395
+ least = Math.min(least, at(t));
396
+ most = Math.max(most, at(t));
397
+ }
398
+ return [least, most];
399
+ }
416
400
  /**
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.
401
+ * The directions seen from a frame turned about the unit axis `u` by any angle in `range` (radians; null all the way
402
+ * round): R(t) = u uᵀ + (I − u uᵀ) cos t + [u]× sin t, each entry a + b cos t + c sin t. The middle is turned through
403
+ * the range exactly; the spread about it by each entry's largest.
422
404
  */
423
- function reachOf(m, asset, id, ends, freezeless = true) {
405
+ function sweptBy(d, u, range) {
406
+ const K = [
407
+ [0, -u[2], u[1]],
408
+ [u[2], 0, -u[0]],
409
+ [-u[1], u[0], 0]
410
+ ];
411
+ const A = (i, j) => u[i] * u[j];
412
+ const B = (i, j) => (i === j ? 1 : 0) - u[i] * u[j];
413
+ const C = (i, j) => K[i][j];
414
+ if (range && Math.abs(range[1] - range[0]) < 1e-12) {
415
+ const t = range[0];
416
+ 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))));
417
+ }
418
+ const sum = (f, j) => [0, 1, 2].reduce((s, i) => s + f(i, j) * d.mid[i], 0);
419
+ const ends = [0, 1, 2].map(j => rangeOver(sum(A, j), sum(B, j), sum(C, j), range));
420
+ const spread = [0, 1, 2].map(j => d.spread.reduce((s, v, i) => {
421
+ const [l, h] = rangeOver(A(i, j), B(i, j), C(i, j), range);
422
+ return s + Math.max(Math.abs(l), Math.abs(h)) * v;
423
+ }, 0));
424
+ return heldToUnit(ends.map(([l, h]) => (l + h) / 2), ends.map(([l, h], j) => (h - l) / 2 + spread[j]));
425
+ }
426
+ /**
427
+ * The angles a turn's control can give it, in degrees: its limits, scaled and moved as the angle is written from it.
428
+ * Null -- all the way round -- for a control without limits or that turns a whole turn, and for an angle written any
429
+ * other way.
430
+ */
431
+ function angleRangeOf(m, ref, depth = 0) {
432
+ if (depth > 64)
433
+ return null;
434
+ const constant = m.constants.find((c) => c.id === ref);
435
+ if (constant)
436
+ return constant.unit === 'deg' ? [constant.value, constant.value] : null;
437
+ const input = m.inputs.find((i) => i.id === ref);
438
+ if (input) {
439
+ if (input.role !== 'state' || input.unit !== 'deg')
440
+ return null;
441
+ const { min, max } = input;
442
+ return Number.isFinite(min) && Number.isFinite(max) && max - min < 360 ? [min, max] : null;
443
+ }
444
+ const w = writerOf(m, ref);
445
+ if (w?.op === 'add@1') {
446
+ let lo = 0, hi = 0;
447
+ for (const arg of w.args) {
448
+ const r = angleRangeOf(m, arg, depth + 1);
449
+ if (!r)
450
+ return null;
451
+ lo += r[0];
452
+ hi += r[1];
453
+ }
454
+ return hi - lo < 360 ? [lo, hi] : null;
455
+ }
456
+ if (w?.op === 'mul@1' && w.args.length === 2) {
457
+ const factor = (arg) => m.constants.find((c) => c.id === arg && c.unit === 'ratio')?.value;
458
+ const [a, b] = w.args;
459
+ const k = factor(a) ?? factor(b);
460
+ const r = factor(a) !== undefined ? angleRangeOf(m, b, depth + 1) : factor(b) !== undefined ? angleRangeOf(m, a, depth + 1) : null;
461
+ if (k === undefined || !r)
462
+ return null;
463
+ const ends = [r[0] * k, r[1] * k].sort((x, y) => x - y);
464
+ return ends[1] - ends[0] < 360 ? ends : null;
465
+ }
466
+ return null;
467
+ }
468
+ /**
469
+ * Where a part carried by a turn can reach, on each side of each axis, from the point the first turn above it turns
470
+ * about (ADR-0087, amendments 2026-10-21). Down the chain from there each step adds what it can add that way: the
471
+ * middle of the directions times the step, sign included, and their spread -- none until a joint has turned them
472
+ * through its range -- times its size; no more than its length, when its coordinates are numbers. A slide from nothing
473
+ * to a sum of sizes adds only toward the way it goes. The part adds its own extent the same way. Each step's largest taken on its own (the triangle inequality), it holds at
474
+ * every angle the joints can reach and every design size: a bound, not a sample. Read by the proposal; the release
475
+ * gate still checks the parts against it.
476
+ */
477
+ function reachOf(m, asset, id, ends) {
424
478
  const chain = [];
425
479
  for (let part = id; part; part = parentOf(m, part))
426
480
  chain.push(part);
@@ -432,56 +486,170 @@ function reachOf(m, asset, id, ends, freezeless = true) {
432
486
  : found, -1);
433
487
  if (top < 0)
434
488
  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. */
489
+ const pivotPart = chain[top];
490
+ const parent = parentOf(m, pivotPart);
491
+ /* Above the pivot every part stands in quarter turns: the frame it turns in is a signed axis permutation. */
492
+ const frame = parent ? worldTurnOf(m, asset, parent) : IDENTITY;
493
+ if (!frame)
494
+ return `${pivotPart} is carried by a part turned by other than quarter turns`;
444
495
  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;
496
+ const { shape } = partOf(m, id, id);
497
+ const faces = figureShapeDescriptionOf(shape)?.faces;
498
+ const offCentre = Object.fromEntries(AXES.map(axis => [axis, offCentreEndsOf(shape, faces, axis)]));
499
+ const constants = (refs) => refs.map(r => m.constants.find((c) => c.id === r)?.value);
500
+ const angles = (refs) => refs.map(r => angleOf(m, asset, r));
501
+ const reachToward = (e) => {
502
+ let d = { mid: transposedTimes(frame, e), spread: [0, 0, 0] };
503
+ let out = { terms: [], c: 0 };
504
+ const tiny = (v) => Math.abs(v) < 1e-12;
505
+ /* A step of three coordinates, in the frame the directions are seen from: the middle times it, the spread times its size. */
506
+ const step = (refs, what) => {
507
+ const numbers = constants(refs);
508
+ if (numbers.every(n => n !== undefined)) {
509
+ const across = numbers.reduce((s, n, i) => s + d.mid[i] * n + d.spread[i] * Math.abs(n), 0);
510
+ /* No unit direction takes a step further than its length. */
511
+ const w = largestOf(d);
512
+ const length = Math.hypot(...numbers.map((n, i) => (w[i] > 0 ? n : 0)));
513
+ out = linAdd(out, { terms: [], c: Math.min(across, length) });
514
+ return null;
467
515
  }
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`);
516
+ for (const [i, ref] of refs.entries()) {
517
+ if (!tiny(d.mid[i]))
518
+ out = linAdd(out, linOf(m, ref), d.mid[i]);
519
+ if (tiny(d.spread[i]))
520
+ continue;
521
+ const size = magnitudeOf(m, linOf(m, ref));
522
+ if (!size)
523
+ return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
524
+ out = linAdd(out, size, d.spread[i]);
525
+ }
526
+ return null;
527
+ };
528
+ const turnTo = (refs) => {
529
+ const a = angles(refs);
530
+ if (a.every(t => t === 0))
531
+ return;
532
+ if (a.every(t => t !== null && Number.isFinite(t)))
533
+ d = turnedBy(d, rigidTurnOf(a));
534
+ else {
535
+ /* A turn this command cannot read: any direction no longer than the largest it was. */
536
+ const reach = Math.min(1, largestOf(d).reduce((s, v) => s + v, 0));
537
+ d = { mid: [0, 0, 0], spread: [reach, reach, reach] };
538
+ }
539
+ };
540
+ for (let i = top; i >= 0; i--) {
541
+ const part = chain[i];
542
+ const seat = nodeById(m, `${part}.seat.rigid`);
543
+ if (seat) {
544
+ /* The pivot's own seat is where it turns: it is the centre, not a step. */
545
+ if (i < top) {
546
+ const bad = step(seat.args.slice(0, 3), `where ${part} is fastened`);
472
547
  if (bad)
473
548
  return bad;
474
549
  }
550
+ turnTo(seat.args.slice(3, 6));
551
+ }
552
+ const motion = nodeById(m, `${part}.motion`);
553
+ if (motion) {
554
+ const unit = constants(motion.args.slice(0, 3));
555
+ if (unit.some(u => u === undefined))
556
+ return `${part}'s ${motion.op === 'axis-turn@1' ? 'turn' : 'slide'} axis is not a constant`;
557
+ const n = Math.hypot(...unit);
558
+ const u = unit.map(v => v / n);
559
+ if (motion.op === 'axis-turn@1') {
560
+ const range = angleRangeOf(m, motion.args[3]);
561
+ d = sweptBy(d, u, range && [(range[0] * Math.PI) / 180, (range[1] * Math.PI) / 180]);
562
+ }
563
+ else if (motion.op === 'axis-slide@1') {
564
+ /*
565
+ Its travel lies between the two ends of its control. One end at nothing and the other a sum of sizes: it
566
+ goes only that way, so the middle adds it only toward that side. Otherwise the further end, either way.
567
+ */
568
+ const mid = d.mid.reduce((s, v, k) => s + v * u[k], 0);
569
+ const spread = d.spread.reduce((s, v, k) => s + v * Math.abs(u[k]), 0);
570
+ const [lo, hi] = ['min', 'max'].map(end => travelAt(m, asset, motion, end));
571
+ if (!lo || !hi)
572
+ return `${part}'s travel is not a sum of sizes, so how far it reaches cannot be bounded`;
573
+ const nothing = (l) => !linNorm(l).terms.length && Math.abs(l.c) < 1e-12;
574
+ const sizes = (l) => l.c >= 0 && linNorm(l).terms.every(t => t.k > 0 && sizeValue(m, t.ref));
575
+ const other = nothing(lo) ? hi : nothing(hi) ? lo : null;
576
+ const way = other && (sizes(other) ? 1 : sizes(linAdd({ terms: [], c: 0 }, other, -1)) ? -1 : 0);
577
+ if (other && way)
578
+ out = linAdd(out, other, way * (Math.max(way * mid, 0) + spread));
579
+ else {
580
+ const further = [lo, hi].reduce((sum, l) => {
581
+ const size = sum && magnitudeOf(m, l);
582
+ return size && sum && linAtLeast(sum, size);
583
+ }, { terms: [], c: 0 });
584
+ if (!further)
585
+ return `${part}'s travel is not a sum of sizes, so how far it reaches cannot be bounded`;
586
+ out = linAdd(out, further, Math.abs(mid) + spread);
587
+ }
588
+ }
589
+ else
590
+ return `${part} moves by ${motion.op}, which this command does not add up`;
591
+ }
592
+ const pose = nodeById(m, `${part}.pose`);
593
+ if (!pose)
594
+ return `${part} has no pose this command can read`;
595
+ const bad = step(pose.args.slice(0, 3), `${part}'s place`);
596
+ if (bad)
597
+ return bad;
598
+ turnTo(pose.args.slice(3, 6));
475
599
  }
600
+ /*
601
+ Its own extent on every axis, as far as the directions reach across it: half its size either way about its
602
+ origin, or from face to face where they do not sit about it (a portal stands on 0, `offCentreEndsOf`) -- the
603
+ middle at the face it points to, the spread times the further face.
604
+ */
605
+ for (const [i, axis] of AXES.entries()) {
606
+ const mid = d.mid[i], spread = d.spread[i];
607
+ if (tiny(mid) && tiny(spread))
608
+ continue;
609
+ const ends = offCentre[axis];
610
+ if (ends) {
611
+ const e = mid > 0 ? ends[1] : ends[0];
612
+ if (!tiny(mid))
613
+ out = linAdd(out, { terms: [{ ref: e.ref, k: e.k * mid }], c: 0 });
614
+ if (tiny(spread))
615
+ continue;
616
+ const further = ends.reduce((sum, x) => {
617
+ const size = sum && magnitudeOf(m, { terms: [{ ref: x.ref, k: x.k }], c: 0 });
618
+ return size && sum && linAtLeast(sum, size);
619
+ }, { terms: [], c: 0 });
620
+ if (!further)
621
+ return `${id}'s size is not a sum of sizes, so how far it reaches cannot be bounded`;
622
+ out = linAdd(out, further, spread);
623
+ continue;
624
+ }
625
+ const half = magnitudeOf(m, { terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 });
626
+ if (!half)
627
+ return `${id}'s size is not a sum of sizes, so how far it reaches cannot be bounded`;
628
+ out = linAdd(out, half, Math.min(1, Math.abs(mid) + spread));
629
+ }
630
+ return linNorm(out);
631
+ };
632
+ const up = {};
633
+ const down = {};
634
+ for (const [i, axis] of AXES.entries()) {
635
+ const e = [0, 0, 0];
636
+ e[i] = 1;
637
+ const high = reachToward(e);
638
+ if (typeof high === 'string')
639
+ return high;
640
+ e[i] = -1;
641
+ const low = reachToward(e);
642
+ if (typeof low === 'string')
643
+ return low;
644
+ up[axis] = high;
645
+ down[axis] = low;
476
646
  }
477
647
  /*
478
648
  The pivot: where the topmost turn is fastened, and everything above it. Above it nothing turns, but a slide may
479
649
  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).
650
+ end of the controls, a reach from each: a slide is a straight line, so the reaches from its two ends bound it all
651
+ the way (it had been refused, and the nose left out of the room).
482
652
  */
483
- const pivotPart = chain[top];
484
- const parent = parentOf(m, pivotPart);
485
653
  const seat = nodeById(m, `${pivotPart}.seat.rigid`);
486
654
  const seatAt = Object.fromEntries(AXES.map((a, i) => [a, seat ? linOf(m, seat.args[i]) : { terms: [], c: 0 }]));
487
655
  const centres = [];
@@ -492,21 +660,40 @@ function reachOf(m, asset, id, ends, freezeless = true) {
492
660
  return at;
493
661
  centres.push(at);
494
662
  }
495
- return { centres, radius };
663
+ return { centres, up, down };
496
664
  }
497
665
  /** The bounds the parts come to, as sums, with what could not be read named. */
498
- function occupancyLins(asset, over) {
666
+ function reachLins(asset) {
499
667
  const m = asset.document.model;
500
668
  const parts = [];
501
669
  const skipped = [];
502
- const bounded = [];
503
670
  const low = { x: [], y: [], z: [] };
504
671
  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();
672
+ /*
673
+ Parts carried by the same turn each bound their own reach about its pivot, and the room takes the largest when the
674
+ graph is evaluated (ADR-0087, 2026-10-21). One ball per pivot, its radius the larger of theirs term by term, was
675
+ every branch added up: each part's sizes and places are values of its own, and a humanoid's torso, head and two arms
676
+ 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
677
+ number and sizes, never below zero -- is left out.
678
+ */
679
+ const reaches = {
680
+ low: { x: [], y: [], z: [] },
681
+ high: { x: [], y: [], z: [] }
682
+ };
683
+ const atLeast = (a, b) => {
684
+ const gap = linNorm(linAdd(a, b, -1));
685
+ return gap.c >= -1e-9 && gap.terms.every(t => t.k > 0 && sizeValue(m, t.ref));
686
+ };
687
+ const addReach = (side, axis, lin) => {
688
+ const list = reaches[side][axis];
689
+ const further = (a, b) => (side === 'high' ? atLeast(a, b) : atLeast(b, a));
690
+ if (list.some(o => further(o, lin)))
691
+ return;
692
+ reaches[side][axis] = [...list.filter(o => !further(lin, o)), lin];
693
+ };
507
694
  /* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
508
695
  const rowOf = (id) => nodeById(m, `${id}.repeat`);
509
- const ends = over === 'rest' ? ['start'] : ['min', 'max'];
696
+ const ends = ['min', 'max'];
510
697
  /* A part's size along the figure's axes, whichever quarter turns it and what carries it stand in. */
511
698
  const sizesOf = (id, turns) => {
512
699
  /* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
@@ -630,14 +817,14 @@ function occupancyLins(asset, over) {
630
817
  */
631
818
  for (const { run } of rows) {
632
819
  for (const l of run.lows)
633
- low[run.axis].push(linAdd(l, reach.radius, -1));
820
+ low[run.axis].push(linAdd(l, reach.down[run.axis], -1));
634
821
  for (const h of run.highs)
635
- high[run.axis].push(linAdd(h, reach.radius, 1));
822
+ high[run.axis].push(linAdd(h, reach.up[run.axis], 1));
636
823
  }
637
824
  for (const at of reach.centres)
638
825
  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);
826
+ const lo = linAdd(at[axis], reach.down[axis], -1);
827
+ const hi = linAdd(at[axis], reach.up[axis], 1);
641
828
  low[axis].push(lo);
642
829
  high[axis].push(hi);
643
830
  climbed(lo, -1, low, axis);
@@ -645,12 +832,11 @@ function occupancyLins(asset, over) {
645
832
  }
646
833
  continue;
647
834
  }
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
- }
835
+ for (const at of reach.centres)
836
+ for (const axis of AXES) {
837
+ addReach('low', axis, linNorm(linAdd(at[axis], reach.down[axis], -1)));
838
+ addReach('high', axis, linNorm(linAdd(at[axis], reach.up[axis], 1)));
839
+ }
654
840
  continue;
655
841
  }
656
842
  /*
@@ -740,40 +926,34 @@ function occupancyLins(asset, over) {
740
926
  }
741
927
  }
742
928
  }
743
- for (const part of parts)
744
- if (boundsUnder(m, part).length)
745
- bounded.push(part);
746
- return { parts, skipped, bounded, low, high, balls: [...balls.values()] };
929
+ return { parts, skipped, low, high, reaches };
747
930
  }
748
931
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
749
- /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
750
- export function proposeFigureOccupancy(asset, options = {}) {
751
- asset = upgradeFigurePlacement(asset);
752
- const over = options.over ?? 'range';
753
- if (over !== 'rest' && over !== 'range')
754
- fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
932
+ /**
933
+ * The room a figure sweeps (ADR-0145 2.3): every part over every joint's range, at the authored size or at `sizes`
934
+ * (design inputs, by id). Computed, never declared -- it holds the parts by what it is. Undefined for a figure with no
935
+ * part to measure. Reads the asset; changes nothing.
936
+ */
937
+ export function figureMotionRangeOf(asset, sizes = {}) {
755
938
  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
- }
939
+ const { parts, skipped, low, high, reaches } = reachLins(source);
940
+ for (const axis of AXES) {
941
+ low[axis].push(...reaches.low[axis]);
942
+ high[axis].push(...reaches.high[axis]);
943
+ }
762
944
  boundedSides(source.document.model, low, high);
763
945
  if (!parts.length)
764
- fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
765
- const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults })
946
+ return undefined;
947
+ const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults, ...sizes })
766
948
  .values;
767
- const at = Object.fromEntries(AXES.map(axis => [
949
+ const bounds = Object.fromEntries(AXES.map(axis => [
768
950
  axis,
769
951
  {
770
952
  min: round6(Math.min(...low[axis].map(l => valueOfLin(values, l)))),
771
953
  max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l))))
772
954
  }
773
955
  ]));
774
- const m = source.document.model;
775
- const held = bounded.map(part => ({ part, mm: boundedWidthOf(m, values, boundsUnder(m, part)) }));
776
- return { over, at, parts, skipped, ...(held.length ? { bounded: held } : {}), basis: fingerprintOf(source) };
956
+ return { bounds, parts, skipped };
777
957
  }
778
958
  /**
779
959
  * The placement says which plane the mounting plane is. A part fastened to it in a way the new plane does not take --
@@ -797,87 +977,9 @@ export function setPlacement(asset, a) {
797
977
  asset.placement = a.placement;
798
978
  return asset;
799
979
  }
800
- export function declareOccupancy(asset, a) {
801
- const m = asset.document.model;
802
- if (!a.proposal || (a.proposal.over !== 'rest' && a.proposal.over !== 'range'))
803
- fail('SCHEMA', 'occupancy', 'a proposal is required; propose first, then confirm');
804
- const now = fingerprintOf(asset);
805
- if (a.proposal.basis !== now)
806
- fail('OCCUPANCY_STALE', 'occupancy', 'the figure changed since this was proposed, so the volume it would declare is not the one that was looked at; propose again and confirm that');
807
- /* Declared with a placement, the placement is set by the same command as on its own: one value, one check. */
808
- if (a.placement !== undefined)
809
- setPlacement(asset, { kind: 'set-placement', placement: a.placement });
810
- const padding = {};
811
- for (const axis of AXES) {
812
- const pad = a.padding?.[axis] ?? 0;
813
- if (!(Number.isFinite(pad) && pad >= 0))
814
- fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
815
- padding[axis] = pad;
816
- }
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
- }
824
- boundedSides(m, low, high);
825
- for (const axis of AXES) {
826
- if (!padding[axis])
827
- continue;
828
- low[axis] = low[axis].map((l) => ({ ...l, c: l.c - padding[axis] }));
829
- high[axis] = high[axis].map((l) => ({ ...l, c: l.c + padding[axis] }));
830
- }
831
- if (parts.join('|') !== a.proposal.parts.join('|'))
832
- 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`);
833
- /* One node for the six bounds (affine-bounds@1): each candidate its constant, then a value and a ratio per term. */
834
- const args = [];
835
- const layout = AXES.flatMap(axis => [low[axis], high[axis]]).map(side => side
836
- .map((l) => {
837
- const n = throughSums(m, l);
838
- args.push(constantOf(m, 'mm', round6(n.c), 'd'));
839
- for (const t of n.terms)
840
- args.push(t.ref, constantOf(m, 'ratio', t.k, 'k'));
841
- return n.terms.length;
842
- })
843
- .join('_'));
844
- const id = namer(m, 'occupancy')('bounds');
845
- const port = (p) => `${id}.${p}`;
846
- m.nodes.push({
847
- id,
848
- op: 'affine-bounds@1',
849
- args,
850
- params: { layout: `L${layout.join('.')}` },
851
- outputs: Object.fromEntries(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax'].map(p => [p, port(p)]))
852
- });
853
- const bounds = Object.fromEntries(AXES.map(axis => [axis, { min: port(`${axis}min`), max: port(`${axis}max`) }]));
854
- const was = stringsIn(asset.occupancy?.bounds);
855
- asset.occupancy = { ...(asset.occupancy?.contact ? { contact: asset.occupancy.contact } : {}), bounds };
856
- /*
857
- The room declared before goes with what fed only it. Left in the graph, it kept reading where parts had been
858
- fastened, and the next fastening was refused over a room no one read (the gantry, 2026-09-25).
859
- */
860
- for (const ref of was)
861
- dropIfUnused(asset, ref);
862
- /*
863
- A part's own measure the old room alone still read -- the part follows something else now -- goes too: kept, it
864
- was a dial that changed nothing (the turntable after every part followed the one ratio, 2026-09-25). So does its
865
- own position or turn, once a fastening places it: a floor part's height position stayed as a field that moved
866
- nothing (browser check, ADR-0097, 2026-09-27). The room's refit names them before it is confirmed (figureRoomRefitOf).
867
- */
868
- const placed = new Set(m.nodes.filter((n) => n.op === 'place@1' || n.op === 'member@1').map((n) => n.id));
869
- for (const input of [...m.inputs])
870
- if (input.role === 'design' &&
871
- placed.has(String(input.id).split('.')[0]) &&
872
- /\.(dimension|size|position|rotation)\.[a-z]+$/.test(input.id))
873
- dropIfUnused(asset, input.id, input.id);
874
- return asset;
875
- }
876
- /** "This face is what the figure is mounted on." Kept apart from how much room the figure takes up. */
980
+ /** "This face is what the figure is mounted on." */
877
981
  export function setMountingFace(asset, a) {
878
982
  const m = asset.document.model;
879
- if (!asset.occupancy)
880
- fail('OCCUPANCY_UNDECLARED', 'occupancy', 'declare the room the figure takes up before naming the face it stands on');
881
983
  if (!Object.hasOwn(FIGURE_FACES, String(a.face)))
882
984
  fail('SCHEMA', a.part, `${String(a.face)} is not a face`);
883
985
  const { place } = partOf(m, a.part, a.part);
@@ -890,16 +992,16 @@ export function setMountingFace(asset, a) {
890
992
  outputs: { pose: `${id}.value` },
891
993
  params: { frame: `${id}.frame`, name: a.face }
892
994
  });
893
- asset.occupancy.contact = {
995
+ asset.mountingFace = {
894
996
  plane: 'mounting-plane',
895
997
  surface: { placement: place.outputs.placed, feature: `${id}.value` }
896
998
  };
897
999
  return asset;
898
1000
  }
899
1001
  export function clearMountingFace(asset) {
900
- if (!asset.occupancy?.contact)
901
- fail('EDIT_TARGET', 'occupancy', 'this figure names no mounting face');
902
- delete asset.occupancy.contact;
1002
+ if (!asset.mountingFace)
1003
+ fail('EDIT_TARGET', 'mountingFace', 'this figure names no mounting face');
1004
+ delete asset.mountingFace;
903
1005
  return asset;
904
1006
  }
905
- //# sourceMappingURL=authoring-occupancy.js.map
1007
+ //# sourceMappingURL=authoring-placement.js.map