@hatiolab/figure-model 0.9.6 → 0.11.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 (143) hide show
  1. package/CHANGELOG.md +41 -5
  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 +5 -27
  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-fastening.d.ts +16 -2
  16. package/dist/authoring-fastening.d.ts.map +1 -1
  17. package/dist/authoring-fastening.js +106 -47
  18. package/dist/authoring-fastening.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} +225 -238
  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 +28 -12
  29. package/dist/authoring-proportion.js.map +1 -1
  30. package/dist/authoring-size.js +1 -1
  31. package/dist/authoring-types.d.ts +57 -91
  32. package/dist/authoring-types.d.ts.map +1 -1
  33. package/dist/authoring-types.js +8 -8
  34. package/dist/authoring-types.js.map +1 -1
  35. package/dist/body.d.ts +19 -0
  36. package/dist/body.d.ts.map +1 -0
  37. package/dist/body.js +37 -0
  38. package/dist/body.js.map +1 -0
  39. package/dist/clip-keeping.d.ts.map +1 -1
  40. package/dist/clip-keeping.js +7 -5
  41. package/dist/clip-keeping.js.map +1 -1
  42. package/dist/command-preview.js +4 -4
  43. package/dist/command-preview.js.map +1 -1
  44. package/dist/contract.d.ts +0 -3
  45. package/dist/contract.d.ts.map +1 -1
  46. package/dist/contract.js.map +1 -1
  47. package/dist/editor-workspace.js +1 -1
  48. package/dist/editor-workspace.js.map +1 -1
  49. package/dist/examples.d.ts.map +1 -1
  50. package/dist/examples.js +7 -8
  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.d.ts +18 -0
  55. package/dist/fasten-place.d.ts.map +1 -1
  56. package/dist/fasten-place.js +43 -7
  57. package/dist/fasten-place.js.map +1 -1
  58. package/dist/gate.d.ts +15 -32
  59. package/dist/gate.d.ts.map +1 -1
  60. package/dist/gate.js +34 -101
  61. package/dist/gate.js.map +1 -1
  62. package/dist/graph-types.d.ts +2 -2
  63. package/dist/graph.d.ts.map +1 -1
  64. package/dist/graph.js +6 -67
  65. package/dist/graph.js.map +1 -1
  66. package/dist/index.d.ts +9 -8
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +8 -7
  69. package/dist/index.js.map +1 -1
  70. package/dist/joint-fields.d.ts +6 -8
  71. package/dist/joint-fields.d.ts.map +1 -1
  72. package/dist/joint-fields.js +6 -6
  73. package/dist/joint-fields.js.map +1 -1
  74. package/dist/joint-list.d.ts +11 -9
  75. package/dist/joint-list.d.ts.map +1 -1
  76. package/dist/joint-list.js +75 -53
  77. package/dist/joint-list.js.map +1 -1
  78. package/dist/joint-motions.d.ts +30 -0
  79. package/dist/joint-motions.d.ts.map +1 -0
  80. package/dist/joint-motions.js +28 -0
  81. package/dist/joint-motions.js.map +1 -0
  82. package/dist/joint-part-edits.d.ts +1 -10
  83. package/dist/joint-part-edits.d.ts.map +1 -1
  84. package/dist/joint-part-edits.js +101 -98
  85. package/dist/joint-part-edits.js.map +1 -1
  86. package/dist/joint-writing.d.ts +3 -4
  87. package/dist/joint-writing.d.ts.map +1 -1
  88. package/dist/joint-writing.js +229 -170
  89. package/dist/joint-writing.js.map +1 -1
  90. package/dist/kernel-version.d.ts +1 -1
  91. package/dist/kernel-version.d.ts.map +1 -1
  92. package/dist/kernel-version.js +1 -1
  93. package/dist/kernel-version.js.map +1 -1
  94. package/dist/part-edit.d.ts +1 -8
  95. package/dist/part-edit.d.ts.map +1 -1
  96. package/dist/part-edit.js +19 -34
  97. package/dist/part-edit.js.map +1 -1
  98. package/dist/proposal.d.ts +4 -11
  99. package/dist/proposal.d.ts.map +1 -1
  100. package/dist/proposal.js +6 -13
  101. package/dist/proposal.js.map +1 -1
  102. package/dist/recipe-from-parts.d.ts.map +1 -1
  103. package/dist/recipe-from-parts.js +12 -4
  104. package/dist/recipe-from-parts.js.map +1 -1
  105. package/dist/recipe.d.ts.map +1 -1
  106. package/dist/recipe.js +11 -9
  107. package/dist/recipe.js.map +1 -1
  108. package/dist/release-contract.d.ts +1 -1
  109. package/dist/release-contract.d.ts.map +1 -1
  110. package/dist/release-contract.js +33 -30
  111. package/dist/release-contract.js.map +1 -1
  112. package/dist/release-metrics.d.ts +14 -14
  113. package/dist/release-metrics.d.ts.map +1 -1
  114. package/dist/release-metrics.js +13 -29
  115. package/dist/release-metrics.js.map +1 -1
  116. package/dist/resize-plan.d.ts +0 -5
  117. package/dist/resize-plan.d.ts.map +1 -1
  118. package/dist/resize-plan.js +4 -29
  119. package/dist/resize-plan.js.map +1 -1
  120. package/dist/robots.d.ts.map +1 -1
  121. package/dist/robots.js +44 -48
  122. package/dist/robots.js.map +1 -1
  123. package/dist/size-edit.js +3 -3
  124. package/dist/size-edit.js.map +1 -1
  125. package/dist/surface.js +2 -2
  126. package/docs/asset-persistence.md +1 -1
  127. package/docs/attachment-response-spec-review-2026-10-24.md +56 -0
  128. package/docs/document-contract.md +0 -1
  129. package/docs/editor-integration.md +2 -0
  130. package/docs/modeller-interaction-performance-2026-10-07.md +1 -1
  131. package/docs/operator-contracts.md +3 -18
  132. package/docs/performance-followup-2026-10-05.md +1 -1
  133. package/docs/review-2026-09-22.md +2 -2
  134. package/docs/shape-dimension-contract.md +27 -25
  135. package/docs/status.md +12 -2
  136. package/package.json +1 -1
  137. package/dist/authoring-occupancy.d.ts +0 -28
  138. package/dist/authoring-occupancy.d.ts.map +0 -1
  139. package/dist/authoring-occupancy.js.map +0 -1
  140. package/dist/room.d.ts +0 -53
  141. package/dist/room.d.ts.map +0 -1
  142. package/dist/room.js +0 -101
  143. package/dist/room.js.map +0 -1
@@ -2,9 +2,10 @@
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";
9
10
  import { figureShapeDescriptionOf } from "./shape-ops.js";
10
11
  import { FigureContractError } from "./contract.js";
@@ -13,31 +14,7 @@ import { FIGURE_PLACEMENTS } from "./asset-types.js";
13
14
  import { FIGURE_FACES } from "./authoring-types.js";
14
15
  import { fail, nodeById, writerOf, throughSums, rowHolding, partOf, namer, constantOf, dropIfUnused, parentOf, round6, stringsIn } from "./authoring-nodes.js";
15
16
  import { isReach, sizeRefsOf, occupiedExtentsOf, angleOf, turnMatrix, mul3, standingRefsOf, half, axisTurn, offCentreEndsOf, IDENTITY } from "./authoring-geometry.js";
16
- /** A figure as one value, for saying "this is the figure I read". The declaration itself is left out of it. */
17
- function fingerprintOf(asset) {
18
- /*
19
- The room and the placement are left out: neither changes what the room would measure (ADR-0097). Nor do the clips,
20
- their groups and the data connections -- the room is measured over the joints' ranges, not what plays them -- and a
21
- command is applied with the groups and their clips held aside (clip-keeping.ts): counted, a room proposed on the
22
- figure as it is was stale against the same figure without them, and a figure with a motion group could not declare
23
- its room again (the robot dog, 2026-10-19).
24
- */
25
- const text = JSON.stringify({
26
- ...asset,
27
- placement: undefined,
28
- occupancy: asset.occupancy ? { ...asset.occupancy, bounds: null } : undefined,
29
- drivers: undefined,
30
- motionGroups: undefined,
31
- dataInterface: undefined
32
- });
33
- let h1 = 0x811c9dc5;
34
- let h2 = 0x01000193;
35
- for (let i = 0; i < text.length; i++) {
36
- h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
37
- h2 = Math.imul(h2 + text.charCodeAt(i), 0x85ebca6b) >>> 0;
38
- }
39
- return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
40
- }
17
+ import { motionStagesOf } from "./authoring-fastening.js";
41
18
  const linAdd = (a, b, k = 1) => ({
42
19
  terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))],
43
20
  c: a.c + b.c * k
@@ -60,14 +37,74 @@ const linOf = (m, ref) => {
60
37
  const constant = m.constants.find((c) => c.id === ref);
61
38
  return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
62
39
  };
40
+ /** Most controls a part's slides are read over corner by corner: 2^n corners, so a bound is kept on n. */
41
+ const CORNER_CONTROLS = 10;
42
+ /** The state inputs a value reads, through whatever writes it. */
43
+ function statesReaching(m, ref, out = new Set(), depth = 0) {
44
+ if (m.inputs.some((i) => i.id === ref && i.role === 'state'))
45
+ out.add(ref);
46
+ const w = depth < 256 ? writerOf(m, ref) : undefined;
47
+ for (const a of w?.args ?? [])
48
+ if (typeof a === 'string')
49
+ statesReaching(m, a, out, depth + 1);
50
+ return out;
51
+ }
52
+ /**
53
+ * Every corner of the controls that slide a part or what carries it: each control at its least or its greatest, apart
54
+ * from the others. Two slides that pull one way and the other reach furthest with one at each end, which all-least
55
+ * and all-greatest miss (review 2026-10-24, F1: a block 70 mm out of the room it was given). A control two slides read
56
+ * takes one end for both. More controls than `CORNER_CONTROLS` are too many corners to read one by one: they are
57
+ * given back to be bounded apart (`wideReadingOf`).
58
+ */
59
+ function cornersOf(m, id) {
60
+ const states = new Set();
61
+ for (let part = id; part; part = parentOf(m, part))
62
+ for (const { motion } of motionStagesOf(m, part))
63
+ if (motion.op === 'axis-slide@1')
64
+ statesReaching(m, motion.args[3], states);
65
+ const list = [...states].sort();
66
+ if (list.length > CORNER_CONTROLS)
67
+ return { many: list };
68
+ if (list.length <= 1)
69
+ return ['min', 'max'];
70
+ return Array.from({ length: 2 ** list.length }, (_, n) => Object.fromEntries(list.map((s, i) => [s, (n >> i) & 1 ? 'max' : 'min'])));
71
+ }
72
+ /** Said where too many controls slide a part for this reading to go corner by corner. */
73
+ const tooManyCorners = (id, many) => `${id} is slid by ${many.length} controls; this reading goes over at most ${CORNER_CONTROLS} of them corner by corner`;
74
+ /**
75
+ * Where a part slid by more controls than its corners can be read over is on one axis: a bound, never a sample (review
76
+ * 2026-10-24, F5). Its centre is linear in each control apart -- a slide's travel is its control times a length, and a
77
+ * follower's a sum of its leaders' -- so from every control at its least, each control taken to its greatest moves it
78
+ * by a sum d; wherever the controls stand, it is within the sum of every |d| either way of that. |d| is read as sizes,
79
+ * which are never below zero; a d that is not a sum of sizes cannot be bounded and is said.
80
+ */
81
+ function wideReadingOf(m, asset, id, axis, states) {
82
+ const least = Object.fromEntries(states.map(s => [s, 'min']));
83
+ const from = centreLin(m, asset, id, axis, least);
84
+ if (typeof from === 'string')
85
+ return from;
86
+ let spread = { terms: [], c: 0 };
87
+ for (const s of states) {
88
+ const moved = centreLin(m, asset, id, axis, { ...least, [s]: 'max' });
89
+ if (typeof moved === 'string')
90
+ return moved;
91
+ const size = magnitudeOf(m, linAdd(moved, from, -1));
92
+ if (!size)
93
+ return `${id}'s travel is not a sum of sizes, so how far it reaches cannot be bounded`;
94
+ spread = linAdd(spread, size);
95
+ }
96
+ return [linAdd(from, spread, -1), linAdd(from, spread)];
97
+ }
63
98
  /**
64
99
  * 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
65
100
  * figure's sizes make of the rest. A mimic of a leader whose stroke follows a length is the leader's ratio times that
66
101
  * length times its multiplier -- read through, not refused: the telescopic conveyor's second and third stages were
67
- * left out of its declared room, 2600 and 5200 mm past it (2026-09-25).
102
+ * left out of the room it was given, 2600 and 5200 mm past it (2026-09-25).
68
103
  */
69
104
  function travelAt(m, asset, motion, bound) {
70
- const pick = (i) => bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max;
105
+ const pick = (i) => typeof bound === 'object'
106
+ ? (bound[i.id] === 'min' ? i.min : i.max)
107
+ : bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max;
71
108
  const constant = (r) => m.constants.find((c) => c.id === r)?.value;
72
109
  const stateOf = (r) => m.inputs.find((i) => i.id === r && i.role === 'state');
73
110
  /* A value no control reaches: it is the same at either end, so it stays a term of the sum. */
@@ -130,6 +167,8 @@ function poseTurnOf(m, asset, part) {
130
167
  return null;
131
168
  return turn.every(isQuarter) ? turnMatrix(turn) : 'other';
132
169
  }
170
+ /** Whether any axis a part moves on turns (ADR-0159: a part may move on several). */
171
+ const turnsOnAJoint = (m, part) => motionStagesOf(m, part).some(s => s.motion.op === 'axis-turn@1');
133
172
  /** Whether a part's seat is turned: its joint frame turned by `origin.parentRotate` (ADR-0107). */
134
173
  const seatTurned = (m, asset, part) => (nodeById(m, `${part}.seat.rigid`)?.args.slice(3, 6) ?? []).some((ref) => angleOf(m, asset, ref) !== 0);
135
174
  /**
@@ -138,7 +177,7 @@ const seatTurned = (m, asset, part) => (nodeById(m, `${part}.seat.rigid`)?.args.
138
177
  */
139
178
  function carriedByATurn(m, asset, id) {
140
179
  for (let part = id; part; part = parentOf(m, part)) {
141
- if (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1')
180
+ if (turnsOnAJoint(m, part))
142
181
  return true;
143
182
  if (poseTurnOf(m, asset, part) === 'other' || seatTurned(m, asset, part))
144
183
  return true;
@@ -178,8 +217,8 @@ function centreVec(m, asset, id, bound, from) {
178
217
  }
179
218
  for (const [i, a] of AXES.entries())
180
219
  v[a] = linAdd(v[a], linOf(m, pose.args[i]));
181
- const motion = nodeById(m, `${part}.motion`);
182
- if (motion) {
220
+ /* Slides add up in any order, and a slide along a line through a point is the slide: the point is not read. */
221
+ for (const { motion } of motionStagesOf(m, part)) {
183
222
  if (motion.op !== 'axis-slide@1')
184
223
  return `${part} turns, so its reach is not a box this command can add up`;
185
224
  const unit = motion.args
@@ -383,31 +422,6 @@ function boundedMagnitudeOf(m, ref, inside = false) {
383
422
  return linAdd({ terms: [], c: 0 }, linAdd(each[0], each[1]), 0.5);
384
423
  return each.reduce((a, b) => linAtLeast(a, b));
385
424
  }
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
425
  /** Every low candidate bounded from below and every high one from above, so the room holds whatever they come to. */
412
426
  function boundedSides(m, low, high) {
413
427
  for (const axis of AXES) {
@@ -418,29 +432,38 @@ function boundedSides(m, low, high) {
418
432
  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
433
  /** 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
434
  const rigidTurnOf = ([x, y, z]) => mul3(mul3(axisTurn('x', x), axisTurn('y', y)), axisTurn('z', z));
435
+ /** Each component's largest size, which a unit direction never passes. */
436
+ const largestOf = (d) => d.mid.map((v, i) => Math.min(1, Math.abs(v) + d.spread[i]));
437
+ /** The same directions, each component held to [−1, 1]. */
438
+ function heldToUnit(mid, spread) {
439
+ const lo = mid.map((v, i) => Math.max(-1, v - spread[i]));
440
+ const hi = mid.map((v, i) => Math.min(1, v + spread[i]));
441
+ return { mid: lo.map((l, i) => (l + hi[i]) / 2), spread: lo.map((l, i) => (hi[i] - l) / 2) };
442
+ }
421
443
  /** The directions seen from a frame turned by `r` from this one (a point there is r·x here). */
422
444
  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))) };
445
+ 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)));
426
446
  }
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) {
447
+ /** 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. */
448
+ function rangeOver(a, b, c, range) {
429
449
  if (!range)
430
- return Math.abs(a) + Math.hypot(b, c);
450
+ return [a - Math.hypot(b, c), a + Math.hypot(b, c)];
431
451
  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));
452
+ const at = (t) => a + b * Math.cos(t) + c * Math.sin(t);
453
+ let least = Math.min(at(lo), at(hi)), most = Math.max(at(lo), at(hi));
434
454
  /* Where its slope is zero: t = atan2(c, b) and half a turn from it, every whole turn over. */
435
455
  const turn = 2 * Math.PI;
436
456
  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)
457
+ for (let t = t0 + Math.ceil((lo - t0) / turn) * turn; t <= hi; t += turn) {
458
+ least = Math.min(least, at(t));
438
459
  most = Math.max(most, at(t));
439
- return most;
460
+ }
461
+ return [least, most];
440
462
  }
441
463
  /**
442
464
  * 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.
465
+ * 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
466
+ * the range exactly; the spread about it by each entry's largest.
444
467
  */
445
468
  function sweptBy(d, u, range) {
446
469
  const K = [
@@ -455,18 +478,13 @@ function sweptBy(d, u, range) {
455
478
  const t = range[0];
456
479
  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
480
  }
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
- };
481
+ const sum = (f, j) => [0, 1, 2].reduce((s, i) => s + f(i, j) * d.mid[i], 0);
482
+ const ends = [0, 1, 2].map(j => rangeOver(sum(A, j), sum(B, j), sum(C, j), range));
483
+ const spread = [0, 1, 2].map(j => d.spread.reduce((s, v, i) => {
484
+ const [l, h] = rangeOver(A(i, j), B(i, j), C(i, j), range);
485
+ return s + Math.max(Math.abs(l), Math.abs(h)) * v;
486
+ }, 0));
487
+ return heldToUnit(ends.map(([l, h]) => (l + h) / 2), ends.map(([l, h], j) => (h - l) / 2 + spread[j]));
470
488
  }
471
489
  /**
472
490
  * The angles a turn's control can give it, in degrees: its limits, scaled and moved as the angle is written from it.
@@ -512,19 +530,19 @@ function angleRangeOf(m, ref, depth = 0) {
512
530
  }
513
531
  /**
514
532
  * 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
533
+ * about (ADR-0087, amendments 2026-10-21). Down the chain from there each step adds what it can add that way: the
534
+ * middle of the directions times the step, sign included, and their spread -- none until a joint has turned them
535
+ * through its range -- times its size; no more than its length, when its coordinates are numbers. A slide from nothing
536
+ * 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
519
537
  * every angle the joints can reach and every design size: a bound, not a sample. Read by the proposal; the release
520
538
  * gate still checks the parts against it.
521
539
  */
522
- function reachOf(m, asset, id, ends) {
540
+ function reachOf(m, asset, id) {
523
541
  const chain = [];
524
542
  for (let part = id; part; part = parentOf(m, part))
525
543
  chain.push(part);
526
544
  /* The topmost part that turns on a joint, or stands turned by an angle a box cannot follow (a fan's blade at 120°). */
527
- const top = chain.reduce((found, part, i) => nodeById(m, `${part}.motion`)?.op === 'axis-turn@1' ||
545
+ const top = chain.reduce((found, part, i) => turnsOnAJoint(m, part) ||
528
546
  poseTurnOf(m, asset, part) === 'other' ||
529
547
  seatTurned(m, asset, part)
530
548
  ? i
@@ -544,34 +562,29 @@ function reachOf(m, asset, id, ends) {
544
562
  const constants = (refs) => refs.map(r => m.constants.find((c) => c.id === r)?.value);
545
563
  const angles = (refs) => refs.map(r => angleOf(m, asset, r));
546
564
  const reachToward = (e) => {
547
- let d = { one: transposedTimes(frame, e) };
565
+ let d = { mid: transposedTimes(frame, e), spread: [0, 0, 0] };
548
566
  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. */
567
+ const tiny = (v) => Math.abs(v) < 1e-12;
568
+ /* A step of three coordinates, in the frame the directions are seen from: the middle times it, the spread times its size. */
551
569
  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;
559
- }
560
- const w = d.most;
561
570
  const numbers = constants(refs);
562
571
  if (numbers.every(n => n !== undefined)) {
563
- const across = numbers.reduce((s, n, i) => s + w[i] * Math.abs(n), 0);
572
+ const across = numbers.reduce((s, n, i) => s + d.mid[i] * n + d.spread[i] * Math.abs(n), 0);
573
+ /* No unit direction takes a step further than its length. */
574
+ const w = largestOf(d);
564
575
  const length = Math.hypot(...numbers.map((n, i) => (w[i] > 0 ? n : 0)));
565
576
  out = linAdd(out, { terms: [], c: Math.min(across, length) });
566
577
  return null;
567
578
  }
568
579
  for (const [i, ref] of refs.entries()) {
569
- if (!w[i])
580
+ if (!tiny(d.mid[i]))
581
+ out = linAdd(out, linOf(m, ref), d.mid[i]);
582
+ if (tiny(d.spread[i]))
570
583
  continue;
571
584
  const size = magnitudeOf(m, linOf(m, ref));
572
585
  if (!size)
573
586
  return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
574
- out = linAdd(out, size, w[i]);
587
+ out = linAdd(out, size, d.spread[i]);
575
588
  }
576
589
  return null;
577
590
  };
@@ -579,9 +592,13 @@ function reachOf(m, asset, id, ends) {
579
592
  const a = angles(refs);
580
593
  if (a.every(t => t === 0))
581
594
  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))) };
595
+ if (a.every(t => t !== null && Number.isFinite(t)))
596
+ d = turnedBy(d, rigidTurnOf(a));
597
+ else {
598
+ /* A turn this command cannot read: any direction no longer than the largest it was. */
599
+ const reach = Math.min(1, largestOf(d).reduce((s, v) => s + v, 0));
600
+ d = { mid: [0, 0, 0], spread: [reach, reach, reach] };
601
+ }
585
602
  };
586
603
  for (let i = top; i >= 0; i--) {
587
604
  const part = chain[i];
@@ -595,8 +612,13 @@ function reachOf(m, asset, id, ends) {
595
612
  }
596
613
  turnTo(seat.args.slice(3, 6));
597
614
  }
598
- const motion = nodeById(m, `${part}.motion`);
599
- if (motion) {
615
+ /* Its axes in order, each about the point it passes: there, turned or slid, and back (ADR-0159). */
616
+ for (const { motion, through, back } of motionStagesOf(m, part)) {
617
+ if (through) {
618
+ const bad = step(through.args.slice(0, 3), `the point ${part} turns about`);
619
+ if (bad)
620
+ return bad;
621
+ }
600
622
  const unit = constants(motion.args.slice(0, 3));
601
623
  if (unit.some(u => u === undefined))
602
624
  return `${part}'s ${motion.op === 'axis-turn@1' ? 'turn' : 'slide'} axis is not a constant`;
@@ -607,17 +629,44 @@ function reachOf(m, asset, id, ends) {
607
629
  d = sweptBy(d, u, range && [(range[0] * Math.PI) / 180, (range[1] * Math.PI) / 180]);
608
630
  }
609
631
  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)
632
+ /*
633
+ Its travel lies between the two ends of its control. One end at nothing and the other a sum of sizes: it
634
+ goes only that way, so the middle adds it only toward that side. Otherwise the further end, either way.
635
+ */
636
+ const mid = d.mid.reduce((s, v, k) => s + v * u[k], 0);
637
+ const spread = d.spread.reduce((s, v, k) => s + v * Math.abs(u[k]), 0);
638
+ /* Every corner of the controls it reads: a sum of leaders pulling apart goes furthest with them at either end. */
639
+ const states = [...statesReaching(m, motion.args[3])].sort();
640
+ if (states.length > CORNER_CONTROLS)
641
+ return `${part}'s slide reads ${states.length} controls; how far it reaches is read over at most ${CORNER_CONTROLS}`;
642
+ const corners = states.length <= 1 ? ['min', 'max'] : Array.from({ length: 2 ** states.length }, (_, n) => Object.fromEntries(states.map((st, i) => [st, (n >> i) & 1 ? 'max' : 'min'])));
643
+ const travels = corners.map(corner => travelAt(m, asset, motion, corner));
644
+ if (travels.some(t => !t))
645
+ return `${part}'s travel is not a sum of sizes, so how far it reaches cannot be bounded`;
646
+ const [lo, hi] = travels.length === 2 ? travels : [null, null];
647
+ const nothing = (l) => !linNorm(l).terms.length && Math.abs(l.c) < 1e-12;
648
+ const sizes = (l) => l.c >= 0 && linNorm(l).terms.every(t => t.k > 0 && sizeValue(m, t.ref));
649
+ const other = lo && hi ? (nothing(lo) ? hi : nothing(hi) ? lo : null) : null;
650
+ const way = other && (sizes(other) ? 1 : sizes(linAdd({ terms: [], c: 0 }, other, -1)) ? -1 : 0);
651
+ if (other && way)
652
+ out = linAdd(out, other, way * (Math.max(way * mid, 0) + spread));
653
+ else {
654
+ const further = travels.reduce((sum, l) => {
655
+ const size = sum && magnitudeOf(m, l);
656
+ return size && sum && linAtLeast(sum, size);
657
+ }, { terms: [], c: 0 });
658
+ if (!further)
615
659
  return `${part}'s travel is not a sum of sizes, so how far it reaches cannot be bounded`;
616
- out = linAdd(out, size, along);
660
+ out = linAdd(out, further, Math.abs(mid) + spread);
617
661
  }
618
662
  }
619
663
  else
620
664
  return `${part} moves by ${motion.op}, which this command does not add up`;
665
+ if (back) {
666
+ const bad = step(back.args.slice(0, 3), `the point ${part} turns about`);
667
+ if (bad)
668
+ return bad;
669
+ }
621
670
  }
622
671
  const pose = nodeById(m, `${part}.pose`);
623
672
  if (!pose)
@@ -629,27 +678,33 @@ function reachOf(m, asset, id, ends) {
629
678
  }
630
679
  /*
631
680
  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`).
681
+ origin, or from face to face where they do not sit about it (a portal stands on 0, `offCentreEndsOf`) -- the
682
+ middle at the face it points to, the spread times the further face.
633
683
  */
634
- const w = most(d);
635
684
  for (const [i, axis] of AXES.entries()) {
636
- if (!w[i])
685
+ const mid = d.mid[i], spread = d.spread[i];
686
+ if (tiny(mid) && tiny(spread))
637
687
  continue;
638
688
  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 });
689
+ if (ends) {
690
+ const e = mid > 0 ? ends[1] : ends[0];
691
+ if (!tiny(mid))
692
+ out = linAdd(out, { terms: [{ ref: e.ref, k: e.k * mid }], c: 0 });
693
+ if (tiny(spread))
694
+ continue;
695
+ const further = ends.reduce((sum, x) => {
696
+ const size = sum && magnitudeOf(m, { terms: [{ ref: x.ref, k: x.k }], c: 0 });
697
+ return size && sum && linAtLeast(sum, size);
698
+ }, { terms: [], c: 0 });
699
+ if (!further)
700
+ return `${id}'s size is not a sum of sizes, so how far it reaches cannot be bounded`;
701
+ out = linAdd(out, further, spread);
642
702
  continue;
643
703
  }
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)
704
+ const half = magnitudeOf(m, { terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 });
705
+ if (!half)
651
706
  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]);
707
+ out = linAdd(out, half, Math.min(1, Math.abs(mid) + spread));
653
708
  }
654
709
  return linNorm(out);
655
710
  };
@@ -671,15 +726,18 @@ function reachOf(m, asset, id, ends) {
671
726
  /*
672
727
  The pivot: where the topmost turn is fastened, and everything above it. Above it nothing turns, but a slide may
673
728
  carry it -- the telescopic conveyor's nose turns at the end of its third stage -- so the pivot is read at each
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).
729
+ corner of the controls (`cornersOf`), a reach from each: a slide is a straight line, so the reaches from its ends
730
+ bound it all the way (it had been refused, and the nose left out of the room).
676
731
  */
677
732
  const seat = nodeById(m, `${pivotPart}.seat.rigid`);
678
733
  const seatAt = Object.fromEntries(AXES.map((a, i) => [a, seat ? linOf(m, seat.args[i]) : { terms: [], c: 0 }]));
679
734
  const centres = [];
680
- for (const end of ends) {
735
+ const corners = parent ? cornersOf(m, parent) : ['min'];
736
+ if (!Array.isArray(corners))
737
+ return tooManyCorners(parent, corners.many);
738
+ for (const corner of corners) {
681
739
  /* The point it turns about, in the frame of what carries it, carried there -- turned with it if that stands turned. */
682
- const at = parent ? centreVec(m, asset, parent, end, structuredClone(seatAt)) : seatAt;
740
+ const at = parent ? centreVec(m, asset, parent, corner, structuredClone(seatAt)) : seatAt;
683
741
  if (typeof at === 'string')
684
742
  return at;
685
743
  centres.push(at);
@@ -687,11 +745,10 @@ function reachOf(m, asset, id, ends) {
687
745
  return { centres, up, down };
688
746
  }
689
747
  /** The bounds the parts come to, as sums, with what could not be read named. */
690
- function occupancyLins(asset, over) {
748
+ function reachLins(asset) {
691
749
  const m = asset.document.model;
692
750
  const parts = [];
693
751
  const skipped = [];
694
- const bounded = [];
695
752
  const low = { x: [], y: [], z: [] };
696
753
  const high = { x: [], y: [], z: [] };
697
754
  /*
@@ -718,7 +775,6 @@ function occupancyLins(asset, over) {
718
775
  };
719
776
  /* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
720
777
  const rowOf = (id) => nodeById(m, `${id}.repeat`);
721
- const ends = over === 'rest' ? ['start'] : ['min', 'max'];
722
778
  /* A part's size along the figure's axes, whichever quarter turns it and what carries it stand in. */
723
779
  const sizesOf = (id, turns) => {
724
780
  /* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
@@ -746,7 +802,10 @@ function occupancyLins(asset, over) {
746
802
  return `${part} is a row whose run this command cannot read`;
747
803
  const theirs = standingRefsOf(m, asset, parent, parent, false)[axis];
748
804
  const half = { terms: [{ ref: theirs.ref, k: 0.5 * theirs.k }], c: 0 };
749
- const centres = ends.map(end => centreLin(m, asset, parent, axis, end));
805
+ const corners = cornersOf(m, parent);
806
+ if (!Array.isArray(corners))
807
+ return tooManyCorners(parent, corners.many);
808
+ const centres = corners.map(corner => centreLin(m, asset, parent, axis, corner));
750
809
  const bad = centres.find(c => typeof c === 'string');
751
810
  if (bad)
752
811
  return String(bad);
@@ -829,7 +888,7 @@ function occupancyLins(asset, over) {
829
888
  onto[axis].push(climbs.reduce((sum, c) => linAdd(sum, c.reach), at));
830
889
  };
831
890
  if (turns) {
832
- const reach = reachOf(m, asset, place.id, ends);
891
+ const reach = reachOf(m, asset, place.id);
833
892
  if (typeof reach === 'string') {
834
893
  skipped.push({ part: place.id, reason: reach });
835
894
  continue;
@@ -865,13 +924,22 @@ function occupancyLins(asset, over) {
865
924
  continue;
866
925
  }
867
926
  /*
868
- Both ends of every control are candidates, and which one is the low end is not decided here: a travel of
927
+ Every corner of the controls is a candidate (`cornersOf`), and which one is the low end is not decided here: a travel of
869
928
  −40 mm puts the low end at the control's maximum (designer's counterexample 2026-09-23, where the
870
929
  proposal read −50..10 for a part that needs −90..50). Both candidates go into the same min and the same
871
930
  max, so the answer holds whichever way the travel runs, and holds at every design size, because the
872
931
  choice is made when the graph is evaluated rather than when the proposal is written.
873
932
  */
874
- const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
933
+ const corners = cornersOf(m, place.id);
934
+ /* A row's copies are read corner by corner against its part's: too many controls for that is said, not bounded. */
935
+ if (!Array.isArray(corners) && rows.some(r => r.part !== place.id)) {
936
+ skipped.push({ part: place.id, reason: tooManyCorners(place.id, corners.many) });
937
+ continue;
938
+ }
939
+ const readings = AXES.map(axis => ({
940
+ axis,
941
+ at: Array.isArray(corners) ? corners.map(corner => centreLin(m, asset, place.id, axis, corner)) : wideReadingOf(m, asset, place.id, axis, corners.many)
942
+ })).map(r => ({ axis: r.axis, at: typeof r.at === 'string' ? [r.at] : r.at }));
875
943
  const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
876
944
  if (bad) {
877
945
  skipped.push({ part: place.id, reason: String(bad) });
@@ -889,7 +957,8 @@ function occupancyLins(asset, over) {
889
957
  Carried by a row's part: its copies stand where that part's copies do, offset as it is from it. That part's
890
958
  centres lie half its length in from the run's ends.
891
959
  */
892
- const theirs = ends.map(end => centreLin(m, asset, template, run.axis, end));
960
+ /* Read corner by corner: a part slid by too many controls for that was said above. */
961
+ const theirs = corners.map(corner => centreLin(m, asset, template, run.axis, corner));
893
962
  const bad = theirs.find(c => typeof c === 'string');
894
963
  if (bad) {
895
964
  offRun = String(bad);
@@ -951,39 +1020,34 @@ function occupancyLins(asset, over) {
951
1020
  }
952
1021
  }
953
1022
  }
954
- for (const part of parts)
955
- if (boundsUnder(m, part).length)
956
- bounded.push(part);
957
- return { parts, skipped, bounded, low, high, reaches };
1023
+ return { parts, skipped, low, high, reaches };
958
1024
  }
959
1025
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
960
- /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
961
- export function proposeFigureOccupancy(asset, options = {}) {
962
- asset = upgradeFigurePlacement(asset);
963
- const over = options.over ?? 'range';
964
- if (over !== 'rest' && over !== 'range')
965
- fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
1026
+ /**
1027
+ * The room a figure sweeps (ADR-0145 2.3): every part over every joint's range, at the authored size or at `sizes`
1028
+ * (design inputs, by id). Computed, never declared -- it holds the parts by what it is. Undefined for a figure with no
1029
+ * part to measure. Reads the asset; changes nothing.
1030
+ */
1031
+ export function figureMotionRangeOf(asset, sizes = {}) {
966
1032
  const source = structuredClone(asset);
967
- const { parts, skipped, bounded, low, high, reaches } = occupancyLins(source, over);
1033
+ const { parts, skipped, low, high, reaches } = reachLins(source);
968
1034
  for (const axis of AXES) {
969
1035
  low[axis].push(...reaches.low[axis]);
970
1036
  high[axis].push(...reaches.high[axis]);
971
1037
  }
972
1038
  boundedSides(source.document.model, low, high);
973
1039
  if (!parts.length)
974
- fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
975
- const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults })
1040
+ return undefined;
1041
+ const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults, ...sizes })
976
1042
  .values;
977
- const at = Object.fromEntries(AXES.map(axis => [
1043
+ const bounds = Object.fromEntries(AXES.map(axis => [
978
1044
  axis,
979
1045
  {
980
1046
  min: round6(Math.min(...low[axis].map(l => valueOfLin(values, l)))),
981
1047
  max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l))))
982
1048
  }
983
1049
  ]));
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) };
1050
+ return { bounds, parts, skipped };
987
1051
  }
988
1052
  /**
989
1053
  * The placement says which plane the mounting plane is. A part fastened to it in a way the new plane does not take --
@@ -1007,86 +1071,9 @@ export function setPlacement(asset, a) {
1007
1071
  asset.placement = a.placement;
1008
1072
  return asset;
1009
1073
  }
1010
- export function declareOccupancy(asset, a) {
1011
- const m = asset.document.model;
1012
- if (!a.proposal || (a.proposal.over !== 'rest' && a.proposal.over !== 'range'))
1013
- fail('SCHEMA', 'occupancy', 'a proposal is required; propose first, then confirm');
1014
- const now = fingerprintOf(asset);
1015
- if (a.proposal.basis !== now)
1016
- 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');
1017
- /* Declared with a placement, the placement is set by the same command as on its own: one value, one check. */
1018
- if (a.placement !== undefined)
1019
- setPlacement(asset, { kind: 'set-placement', placement: a.placement });
1020
- const padding = {};
1021
- for (const axis of AXES) {
1022
- const pad = a.padding?.[axis] ?? 0;
1023
- if (!(Number.isFinite(pad) && pad >= 0))
1024
- fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
1025
- padding[axis] = pad;
1026
- }
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);
1034
- for (const axis of AXES) {
1035
- if (!padding[axis])
1036
- continue;
1037
- low[axis] = low[axis].map((l) => ({ ...l, c: l.c - padding[axis] }));
1038
- high[axis] = high[axis].map((l) => ({ ...l, c: l.c + padding[axis] }));
1039
- }
1040
- if (parts.join('|') !== a.proposal.parts.join('|'))
1041
- 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`);
1042
- /* One node for the six bounds (affine-bounds@1): each candidate its constant, then a value and a ratio per term. */
1043
- const args = [];
1044
- const layout = AXES.flatMap(axis => [low[axis], high[axis]]).map(side => side
1045
- .map((l) => {
1046
- const n = throughSums(m, l);
1047
- args.push(constantOf(m, 'mm', round6(n.c), 'd'));
1048
- for (const t of n.terms)
1049
- args.push(t.ref, constantOf(m, 'ratio', t.k, 'k'));
1050
- return n.terms.length;
1051
- })
1052
- .join('_'));
1053
- const id = namer(m, 'occupancy')('bounds');
1054
- const port = (p) => `${id}.${p}`;
1055
- m.nodes.push({
1056
- id,
1057
- op: 'affine-bounds@1',
1058
- args,
1059
- params: { layout: `L${layout.join('.')}` },
1060
- outputs: Object.fromEntries(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax'].map(p => [p, port(p)]))
1061
- });
1062
- const bounds = Object.fromEntries(AXES.map(axis => [axis, { min: port(`${axis}min`), max: port(`${axis}max`) }]));
1063
- const was = stringsIn(asset.occupancy?.bounds);
1064
- asset.occupancy = { ...(asset.occupancy?.contact ? { contact: asset.occupancy.contact } : {}), bounds };
1065
- /*
1066
- The room declared before goes with what fed only it. Left in the graph, it kept reading where parts had been
1067
- fastened, and the next fastening was refused over a room no one read (the gantry, 2026-09-25).
1068
- */
1069
- for (const ref of was)
1070
- dropIfUnused(asset, ref);
1071
- /*
1072
- A part's own measure the old room alone still read -- the part follows something else now -- goes too: kept, it
1073
- was a dial that changed nothing (the turntable after every part followed the one ratio, 2026-09-25). So does its
1074
- own position or turn, once a fastening places it: a floor part's height position stayed as a field that moved
1075
- nothing (browser check, ADR-0097, 2026-09-27). The room's refit names them before it is confirmed (figureRoomRefitOf).
1076
- */
1077
- const placed = new Set(m.nodes.filter((n) => n.op === 'place@1' || n.op === 'member@1').map((n) => n.id));
1078
- for (const input of [...m.inputs])
1079
- if (input.role === 'design' &&
1080
- placed.has(String(input.id).split('.')[0]) &&
1081
- /\.(dimension|size|position|rotation)\.[a-z]+$/.test(input.id))
1082
- dropIfUnused(asset, input.id, input.id);
1083
- return asset;
1084
- }
1085
- /** "This face is what the figure is mounted on." Kept apart from how much room the figure takes up. */
1074
+ /** "This face is what the figure is mounted on." */
1086
1075
  export function setMountingFace(asset, a) {
1087
1076
  const m = asset.document.model;
1088
- if (!asset.occupancy)
1089
- fail('OCCUPANCY_UNDECLARED', 'occupancy', 'declare the room the figure takes up before naming the face it stands on');
1090
1077
  if (!Object.hasOwn(FIGURE_FACES, String(a.face)))
1091
1078
  fail('SCHEMA', a.part, `${String(a.face)} is not a face`);
1092
1079
  const { place } = partOf(m, a.part, a.part);
@@ -1099,16 +1086,16 @@ export function setMountingFace(asset, a) {
1099
1086
  outputs: { pose: `${id}.value` },
1100
1087
  params: { frame: `${id}.frame`, name: a.face }
1101
1088
  });
1102
- asset.occupancy.contact = {
1089
+ asset.mountingFace = {
1103
1090
  plane: 'mounting-plane',
1104
1091
  surface: { placement: place.outputs.placed, feature: `${id}.value` }
1105
1092
  };
1106
1093
  return asset;
1107
1094
  }
1108
1095
  export function clearMountingFace(asset) {
1109
- if (!asset.occupancy?.contact)
1110
- fail('EDIT_TARGET', 'occupancy', 'this figure names no mounting face');
1111
- delete asset.occupancy.contact;
1096
+ if (!asset.mountingFace)
1097
+ fail('EDIT_TARGET', 'mountingFace', 'this figure names no mounting face');
1098
+ delete asset.mountingFace;
1112
1099
  return asset;
1113
1100
  }
1114
- //# sourceMappingURL=authoring-occupancy.js.map
1101
+ //# sourceMappingURL=authoring-placement.js.map