@hatiolab/figure-model 0.5.0 → 0.5.2

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 (136) hide show
  1. package/dist/asset-types.d.ts +1 -1
  2. package/dist/asset-types.d.ts.map +1 -1
  3. package/dist/asset.d.ts.map +1 -1
  4. package/dist/asset.js +6 -5
  5. package/dist/asset.js.map +1 -1
  6. package/dist/authoring-actions.d.ts +2 -795
  7. package/dist/authoring-actions.d.ts.map +1 -1
  8. package/dist/authoring-actions.js +66 -3777
  9. package/dist/authoring-actions.js.map +1 -1
  10. package/dist/authoring-bindings.d.ts +35 -0
  11. package/dist/authoring-bindings.d.ts.map +1 -0
  12. package/dist/authoring-bindings.js +241 -0
  13. package/dist/authoring-bindings.js.map +1 -0
  14. package/dist/authoring-dimensions.d.ts +10 -0
  15. package/dist/authoring-dimensions.d.ts.map +1 -0
  16. package/dist/authoring-dimensions.js +88 -0
  17. package/dist/authoring-dimensions.js.map +1 -0
  18. package/dist/authoring-fastening.d.ts +24 -0
  19. package/dist/authoring-fastening.d.ts.map +1 -0
  20. package/dist/authoring-fastening.js +562 -0
  21. package/dist/authoring-fastening.js.map +1 -0
  22. package/dist/authoring-geometry.d.ts +119 -0
  23. package/dist/authoring-geometry.d.ts.map +1 -0
  24. package/dist/authoring-geometry.js +419 -0
  25. package/dist/authoring-geometry.js.map +1 -0
  26. package/dist/authoring-nodes.d.ts +77 -0
  27. package/dist/authoring-nodes.d.ts.map +1 -0
  28. package/dist/authoring-nodes.js +310 -0
  29. package/dist/authoring-nodes.js.map +1 -0
  30. package/dist/authoring-occupancy.d.ts +28 -0
  31. package/dist/authoring-occupancy.d.ts.map +1 -0
  32. package/dist/authoring-occupancy.js +784 -0
  33. package/dist/authoring-occupancy.js.map +1 -0
  34. package/dist/authoring-proportion.d.ts +27 -0
  35. package/dist/authoring-proportion.d.ts.map +1 -0
  36. package/dist/authoring-proportion.js +378 -0
  37. package/dist/authoring-proportion.js.map +1 -0
  38. package/dist/authoring-size.d.ts +17 -0
  39. package/dist/authoring-size.d.ts.map +1 -0
  40. package/dist/authoring-size.js +140 -0
  41. package/dist/authoring-size.js.map +1 -0
  42. package/dist/authoring-types.d.ts +753 -0
  43. package/dist/authoring-types.d.ts.map +1 -0
  44. package/dist/authoring-types.js +42 -0
  45. package/dist/authoring-types.js.map +1 -0
  46. package/dist/command-preview.d.ts +1 -1
  47. package/dist/command-preview.d.ts.map +1 -1
  48. package/dist/command-preview.js +1 -0
  49. package/dist/command-preview.js.map +1 -1
  50. package/dist/contract.d.ts +142 -3
  51. package/dist/contract.d.ts.map +1 -1
  52. package/dist/contract.js +1 -1
  53. package/dist/contract.js.map +1 -1
  54. package/dist/cost.d.ts.map +1 -1
  55. package/dist/cost.js +3 -0
  56. package/dist/cost.js.map +1 -1
  57. package/dist/create.d.ts +36 -0
  58. package/dist/create.d.ts.map +1 -1
  59. package/dist/create.js +60 -0
  60. package/dist/create.js.map +1 -1
  61. package/dist/document.d.ts.map +1 -1
  62. package/dist/document.js +4 -3
  63. package/dist/document.js.map +1 -1
  64. package/dist/editor-workspace.d.ts +3 -3
  65. package/dist/editor-workspace.d.ts.map +1 -1
  66. package/dist/examples.d.ts.map +1 -1
  67. package/dist/examples.js +3 -1
  68. package/dist/examples.js.map +1 -1
  69. package/dist/gate.d.ts.map +1 -1
  70. package/dist/gate.js +2 -0
  71. package/dist/gate.js.map +1 -1
  72. package/dist/graph-types.d.ts +3 -2
  73. package/dist/graph-types.d.ts.map +1 -1
  74. package/dist/graph.d.ts.map +1 -1
  75. package/dist/graph.js +13 -7
  76. package/dist/graph.js.map +1 -1
  77. package/dist/index.d.ts +10 -4
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +8 -2
  80. package/dist/index.js.map +1 -1
  81. package/dist/joint-list.js +1 -1
  82. package/dist/joint-list.js.map +1 -1
  83. package/dist/joint-part-edits.d.ts +40 -0
  84. package/dist/joint-part-edits.d.ts.map +1 -0
  85. package/dist/joint-part-edits.js +488 -0
  86. package/dist/joint-part-edits.js.map +1 -0
  87. package/dist/joint-writing.d.ts +50 -0
  88. package/dist/joint-writing.d.ts.map +1 -0
  89. package/dist/joint-writing.js +847 -0
  90. package/dist/joint-writing.js.map +1 -0
  91. package/dist/json.d.ts.map +1 -1
  92. package/dist/json.js +11 -8
  93. package/dist/json.js.map +1 -1
  94. package/dist/kernel-version.d.ts +1 -1
  95. package/dist/kernel-version.js +1 -1
  96. package/dist/package.d.ts +15 -3
  97. package/dist/package.d.ts.map +1 -1
  98. package/dist/package.js +58 -0
  99. package/dist/package.js.map +1 -1
  100. package/dist/part-edit.d.ts +1 -1
  101. package/dist/part-edit.d.ts.map +1 -1
  102. package/dist/part-edit.js +7 -4
  103. package/dist/part-edit.js.map +1 -1
  104. package/dist/proposal.d.ts +1 -1
  105. package/dist/proposal.d.ts.map +1 -1
  106. package/dist/proposal.js +5 -2
  107. package/dist/proposal.js.map +1 -1
  108. package/dist/render-batches.d.ts +10 -2
  109. package/dist/render-batches.d.ts.map +1 -1
  110. package/dist/render-batches.js +19 -5
  111. package/dist/render-batches.js.map +1 -1
  112. package/dist/resize-plan.d.ts +1 -1
  113. package/dist/resize-plan.d.ts.map +1 -1
  114. package/dist/robots.d.ts.map +1 -1
  115. package/dist/robots.js +3 -1
  116. package/dist/robots.js.map +1 -1
  117. package/dist/room.d.ts +1 -1
  118. package/dist/room.d.ts.map +1 -1
  119. package/dist/room.js +2 -1
  120. package/dist/room.js.map +1 -1
  121. package/dist/shape-edit.js +4 -4
  122. package/dist/shape-edit.js.map +1 -1
  123. package/dist/shape-ops.d.ts +62 -7
  124. package/dist/shape-ops.d.ts.map +1 -1
  125. package/dist/shape-ops.js +117 -8
  126. package/dist/shape-ops.js.map +1 -1
  127. package/dist/slot-layout-presets.d.ts +102 -0
  128. package/dist/slot-layout-presets.d.ts.map +1 -0
  129. package/dist/slot-layout-presets.js +185 -0
  130. package/dist/slot-layout-presets.js.map +1 -0
  131. package/dist/slot-layout.d.ts +16 -1
  132. package/dist/slot-layout.d.ts.map +1 -1
  133. package/dist/slot-layout.js.map +1 -1
  134. package/docs/modeller-interaction-performance-2026-10-07.md +40 -8
  135. package/docs/shape-contract.md +46 -0
  136. package/package.json +1 -1
@@ -0,0 +1,784 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+ /*
5
+ * The room the figure takes up: proposed from its parts, declared, and the face it is mounted by.
6
+ */
7
+ import { upgradeFigurePlacement } from "./asset.js";
8
+ import { compileFigureGraph } from "./graph.js";
9
+ import { FigureContractError } from "./contract.js";
10
+ import { AXES } from "./words.js";
11
+ import { FIGURE_PLACEMENTS } from "./asset-types.js";
12
+ 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, planeFacesOf, 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
+ /* The room and the placement are left out: neither changes what the room would measure (ADR-0097). */
18
+ const text = JSON.stringify({
19
+ ...asset,
20
+ placement: undefined,
21
+ occupancy: asset.occupancy ? { ...asset.occupancy, bounds: null } : undefined
22
+ });
23
+ let h1 = 0x811c9dc5;
24
+ let h2 = 0x01000193;
25
+ for (let i = 0; i < text.length; i++) {
26
+ h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
27
+ h2 = Math.imul(h2 + text.charCodeAt(i), 0x85ebca6b) >>> 0;
28
+ }
29
+ return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
30
+ }
31
+ const linAdd = (a, b, k = 1) => ({
32
+ terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))],
33
+ c: a.c + b.c * k
34
+ });
35
+ /** The same sum with each reference once. */
36
+ const linNorm = (a) => {
37
+ const by = new Map();
38
+ for (const t of a.terms)
39
+ by.set(t.ref, (by.get(t.ref) ?? 0) + t.k);
40
+ return { terms: [...by].filter(([, k]) => k !== 0).map(([ref, k]) => ({ ref, k })), c: a.c };
41
+ };
42
+ /** A sum at least as large as either, where every reference is a size (never below zero): term by term, the larger. */
43
+ const linAtLeast = (a, b) => {
44
+ const by = new Map();
45
+ for (const t of [...linNorm(a).terms, ...linNorm(b).terms])
46
+ by.set(t.ref, Math.max(by.get(t.ref) ?? 0, t.k));
47
+ return { terms: [...by].map(([ref, k]) => ({ ref, k })), c: Math.max(a.c, b.c) };
48
+ };
49
+ const linOf = (m, ref) => {
50
+ const constant = m.constants.find((c) => c.id === ref);
51
+ return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
52
+ };
53
+ /**
54
+ * The same sum read back through the sums and scalings that make it up, down to what no command rewrites: the inputs,
55
+ * 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,
56
+ * a share of the figure's depth -- was broken by the next fastening, which writes that joint's nodes afresh: the gantry
57
+ * could not be re-fastened once its room was declared (UNKNOWN_REFERENCE, 2026-09-25).
58
+ */
59
+ function linThroughSums(m, lin) {
60
+ const constant = (ref) => m.constants.find((c) => c.id === ref)?.value;
61
+ const ratio = (ref) => m.constants.find((c) => c.id === ref && c.unit === 'ratio')?.value;
62
+ const expand = (ref, k, depth) => {
63
+ const c = constant(ref);
64
+ if (c !== undefined)
65
+ return { terms: [], c: c * k };
66
+ const w = depth < 256 ? writerOf(m, ref) : undefined;
67
+ if (w?.op === 'add@1')
68
+ return w.args.reduce((sum, arg) => linAdd(sum, expand(arg, k, depth + 1)), { terms: [], c: 0 });
69
+ /* Only a length times a ratio: what is left is then a length, as each term of the room must be. */
70
+ if (w?.op === 'mul@1') {
71
+ const [a, b] = w.args;
72
+ if (ratio(b) !== undefined)
73
+ return expand(a, k * ratio(b), depth + 1);
74
+ if (ratio(a) !== undefined)
75
+ return expand(b, k * ratio(a), depth + 1);
76
+ }
77
+ return { terms: [{ ref, k }], c: 0 };
78
+ };
79
+ return linNorm(lin.terms.reduce((sum, t) => linAdd(sum, expand(t.ref, t.k, 0)), { terms: [], c: lin.c }));
80
+ }
81
+ /**
82
+ * 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
83
+ * figure's sizes make of the rest. A mimic of a leader whose stroke follows a length is the leader's ratio times that
84
+ * length times its multiplier -- read through, not refused: the telescopic conveyor's second and third stages were
85
+ * left out of its declared room, 2600 and 5200 mm past it (2026-09-25).
86
+ */
87
+ function travelAt(m, asset, motion, bound) {
88
+ const pick = (i) => bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max;
89
+ const constant = (r) => m.constants.find((c) => c.id === r)?.value;
90
+ const stateOf = (r) => m.inputs.find((i) => i.id === r && i.role === 'state');
91
+ /* A value no control reaches: it is the same at either end, so it stays a term of the sum. */
92
+ const still = (ref, depth = 0) => {
93
+ if (stateOf(ref))
94
+ return false;
95
+ const w = depth < 256 ? writerOf(m, ref) : undefined;
96
+ return !w || w.args.every((a) => typeof a !== 'string' || still(a, depth + 1));
97
+ };
98
+ const read = (ref, depth = 0) => {
99
+ const c = constant(ref);
100
+ if (c !== undefined)
101
+ return { terms: [], c };
102
+ const state = stateOf(ref);
103
+ if (state)
104
+ return { terms: [], c: pick(state) };
105
+ const w = depth < 256 ? writerOf(m, ref) : undefined;
106
+ if (w?.op === 'add@1') {
107
+ let out = { terms: [], c: 0 };
108
+ for (const arg of w.args) {
109
+ const l = read(arg, depth + 1);
110
+ if (!l)
111
+ return null;
112
+ out = linAdd(out, l);
113
+ }
114
+ return out;
115
+ }
116
+ if (w?.op === 'mul@1' && w.args.length === 2) {
117
+ /* A ratio times a length (the one ratio's size, u × x₀) is kept whole: the sum's terms stay lengths. */
118
+ const known = (arg) => m.constants.find((c) => c.id === arg);
119
+ if (!w.args.every(known) &&
120
+ w.args.some(arg => known(arg) && known(arg).unit !== 'ratio'))
121
+ return still(ref) ? { terms: [{ ref, k: 1 }], c: 0 } : null;
122
+ const [a, b] = w.args.map(arg => read(arg, depth + 1));
123
+ if (!a || !b)
124
+ return null;
125
+ // One side a number: a product of two sizes is not a length.
126
+ if (!a.terms.length)
127
+ return linAdd({ terms: [], c: 0 }, b, a.c);
128
+ if (!b.terms.length)
129
+ return linAdd({ terms: [], c: 0 }, a, b.c);
130
+ return null;
131
+ }
132
+ return still(ref) ? { terms: [{ ref, k: 1 }], c: 0 } : null;
133
+ };
134
+ return read(motion.args[3]);
135
+ }
136
+ const zeroVec = () => ({ x: { terms: [], c: 0 }, y: { terms: [], c: 0 }, z: { terms: [], c: 0 } });
137
+ /** A vector of sums turned by a quarter turn (a signed axis permutation). */
138
+ const turnVec = (r, v) => Object.fromEntries(AXES.map((a, i) => [
139
+ a,
140
+ AXES.reduce((sum, b, j) => (r[i][j] ? linAdd(sum, v[b], r[i][j]) : sum), { terms: [], c: 0 })
141
+ ]));
142
+ const isQuarter = (t) => t !== null && Number.isFinite(t) && Math.abs(t / 90 - Math.round(t / 90)) < 1e-9;
143
+ /** A part's own turn: none, a quarter turn, or one this command cannot add up (any other angle, or not a number). */
144
+ function poseTurnOf(m, asset, part) {
145
+ const pose = nodeById(m, `${part}.pose`);
146
+ const turn = (pose?.args.slice(3, 6) ?? []).map((ref) => angleOf(m, asset, ref));
147
+ if (turn.every((t) => t === 0))
148
+ return null;
149
+ return turn.every(isQuarter) ? turnMatrix(turn) : 'other';
150
+ }
151
+ /** Whether a part, or anything carrying it, turns on a joint or stands turned by other than quarter turns. */
152
+ function carriedByATurn(m, asset, id) {
153
+ for (let part = id; part; part = parentOf(m, part)) {
154
+ if (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1')
155
+ return true;
156
+ if (poseTurnOf(m, asset, part) === 'other')
157
+ return true;
158
+ }
159
+ return false;
160
+ }
161
+ /** How a part's own axes lie in the figure's: the quarter turns of it and everything carrying it, composed. */
162
+ function worldTurnOf(m, asset, id) {
163
+ let c = IDENTITY;
164
+ for (let part = id; part; part = parentOf(m, part)) {
165
+ const r = poseTurnOf(m, asset, part);
166
+ if (r === 'other')
167
+ return null;
168
+ if (r)
169
+ c = mul3(r, c);
170
+ }
171
+ return c;
172
+ }
173
+ /**
174
+ * Where a point of a part lies in the figure, as sums, following whatever it is fastened to: its centre, or `from` in
175
+ * its own frame. A part standing a quarter turned turns what it carries -- a valve body on a lying pipe, a hub on its
176
+ * post -- and was refused, those figures left without a room (2026-09-25); another angle still is.
177
+ */
178
+ function centreVec(m, asset, id, bound, from) {
179
+ let v = from ?? zeroVec();
180
+ for (let part = id; part; part = parentOf(m, part)) {
181
+ const pose = nodeById(m, `${part}.pose`);
182
+ if (!pose)
183
+ return `${part} has no pose this command can read`;
184
+ /* A part's own turn turns it about its centre: its centre does not move by it. */
185
+ if (part !== id || from) {
186
+ const r = poseTurnOf(m, asset, part);
187
+ if (r === 'other')
188
+ return `${part} is turned by other than quarter turns, so what it carries is not a box this command can add up`;
189
+ if (r)
190
+ v = turnVec(r, v);
191
+ }
192
+ for (const [i, a] of AXES.entries())
193
+ v[a] = linAdd(v[a], linOf(m, pose.args[i]));
194
+ const motion = nodeById(m, `${part}.motion`);
195
+ if (motion) {
196
+ if (motion.op !== 'axis-slide@1')
197
+ return `${part} turns, so its reach is not a box this command can add up`;
198
+ const unit = motion.args
199
+ .slice(0, 3)
200
+ .map((ref) => m.constants.find((k) => k.id === ref)?.value ?? null);
201
+ if (unit.some((u) => u === null))
202
+ return `${part}'s slide axis is not a constant`;
203
+ if (unit.some((u) => u !== 0)) {
204
+ const travel = travelAt(m, asset, motion, bound);
205
+ if (!travel)
206
+ return `${part}'s travel is not a distance this command can read`;
207
+ for (const [i, a] of AXES.entries())
208
+ if (unit[i])
209
+ v[a] = linAdd(v[a], travel, unit[i]);
210
+ }
211
+ }
212
+ const seat = nodeById(m, `${part}.seat.rigid`);
213
+ if (seat) {
214
+ if (seat.args.slice(3, 6).some((ref) => angleOf(m, asset, ref) !== 0))
215
+ return `${part} is seated turned, which this command does not add up`;
216
+ for (const [i, a] of AXES.entries())
217
+ v[a] = linAdd(v[a], linOf(m, seat.args[i]));
218
+ }
219
+ }
220
+ return v;
221
+ }
222
+ /** Where a part's centre is on one axis, as a sum, following whatever it is fastened to. */
223
+ function centreLin(m, asset, id, axis, bound) {
224
+ const v = centreVec(m, asset, id, bound);
225
+ return typeof v === 'string' ? v : v[axis];
226
+ }
227
+ /** A value written as a sum of design inputs times constants, or null where it is anything else. */
228
+ function affineOf(m, ref, seen = 0) {
229
+ if (seen > 64)
230
+ return null;
231
+ const constant = m.constants.find((c) => c.id === ref);
232
+ if (constant)
233
+ return { terms: [], c: constant.value };
234
+ if (m.inputs.some((i) => i.id === ref))
235
+ return { terms: [{ ref, k: 1 }], c: 0 };
236
+ const writer = writerOf(m, ref);
237
+ if (writer?.op === 'add@1') {
238
+ let out = { terms: [], c: 0 };
239
+ for (const arg of writer.args) {
240
+ const part = affineOf(m, arg, seen + 1);
241
+ if (!part)
242
+ return null;
243
+ out = linAdd(out, part);
244
+ }
245
+ return out;
246
+ }
247
+ if (writer?.op === 'mul@1' && writer.args.length === 2) {
248
+ /* A ratio times a length is kept whole: its terms must stay lengths (the one ratio's size, u × x₀). */
249
+ const constant = (arg) => m.constants.find((c) => c.id === arg);
250
+ const ratioTimesLength = !writer.args.every(constant) && writer.args.some((arg) => constant(arg) && constant(arg).unit !== 'ratio');
251
+ if (ratioTimesLength)
252
+ return sizeValue(m, ref) ? { terms: [{ ref, k: 1 }], c: 0 } : null;
253
+ const [a, b] = writer.args.map((arg) => affineOf(m, arg, seen + 1));
254
+ if (!a || !b)
255
+ return null;
256
+ if (!a.terms.length)
257
+ return linAdd({ terms: [], c: 0 }, b, a.c);
258
+ if (!b.terms.length)
259
+ return linAdd({ terms: [], c: 0 }, a, b.c);
260
+ }
261
+ /* Not a sum, but a size all the same: the one ratio a figure grows by, read as it is. */
262
+ if (sizeValue(m, ref))
263
+ return { terms: [{ ref, k: 1 }], c: 0 };
264
+ return null;
265
+ }
266
+ /**
267
+ * A value never below zero that is not a sum of inputs: the mean of sizes, or it over a positive number -- the ratio
268
+ * a figure that grows by one ratio grows by (`scaleUniformly`). A turntable made to follow it had its platter and
269
+ * clamps left out of the room, their reach no longer a sum of inputs (2026-09-25).
270
+ */
271
+ function sizeValue(m, ref, seen = 0) {
272
+ if (seen > 64)
273
+ return false;
274
+ const input = m.inputs.find((i) => i.id === ref);
275
+ if (input)
276
+ return input.role !== 'state' && input.min >= 0;
277
+ const w = writerOf(m, ref);
278
+ const positive = (r) => (m.constants.find((c) => c.id === r)?.value ?? -1) > 0;
279
+ if (w?.op === 'geomean@1')
280
+ return w.args.every((a) => sizeValue(m, a, seen + 1));
281
+ if (w?.op === 'div@1')
282
+ return sizeValue(m, w.args[0], seen + 1) && positive(w.args[1]);
283
+ if (w?.op === 'mul@1')
284
+ return (w.args.length === 2 &&
285
+ ((sizeValue(m, w.args[0], seen + 1) && positive(w.args[1])) ||
286
+ (positive(w.args[0]) && sizeValue(m, w.args[1], seen + 1))));
287
+ return false;
288
+ }
289
+ /**
290
+ * How long a value can be, at most, as a sum that holds at every design size: each input's coefficient taken
291
+ * positive. Sound only over inputs that are never below zero (sizes), so any other input makes it unreadable.
292
+ */
293
+ function magnitudeOf(m, lin) {
294
+ let out = { terms: [], c: 0 };
295
+ for (const t of lin.terms) {
296
+ const a = affineOf(m, t.ref);
297
+ if (!a)
298
+ return null;
299
+ for (const u of a.terms) {
300
+ if (!sizeValue(m, u.ref))
301
+ return null;
302
+ out = linAdd(out, { terms: [{ ref: u.ref, k: Math.abs(u.k * t.k) }], c: 0 });
303
+ }
304
+ out.c += Math.abs(a.c * t.k);
305
+ }
306
+ out.c += Math.abs(lin.c);
307
+ return out;
308
+ }
309
+ /**
310
+ * Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
311
+ * every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
312
+ * inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
313
+ * ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
314
+ * the parts against it.
315
+ */
316
+ function reachOf(m, asset, id, ends, freezeless = true) {
317
+ const chain = [];
318
+ for (let part = id; part; part = parentOf(m, part))
319
+ chain.push(part);
320
+ /* The topmost part that turns on a joint, or stands turned by an angle a box cannot follow (a fan's blade at 120°). */
321
+ const top = chain.reduce((found, part, i) => nodeById(m, `${part}.motion`)?.op === 'axis-turn@1' || poseTurnOf(m, asset, part) === 'other' ? i : found, -1);
322
+ if (top < 0)
323
+ return `${id} is carried by no turn`;
324
+ let radius = { terms: [], c: 0 };
325
+ const grow = (lin, what) => {
326
+ const size = lin && magnitudeOf(m, lin);
327
+ if (!size)
328
+ return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
329
+ radius = linAdd(radius, size);
330
+ return null;
331
+ };
332
+ /* Its half size on every axis of its own, whichever way it stands: the ball holds it at any angle. */
333
+ const sizes = sizeRefsOf(m, id, id);
334
+ void freezeless;
335
+ for (const axis of AXES) {
336
+ const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
337
+ if (bad)
338
+ return bad;
339
+ }
340
+ for (let i = 0; i <= top; i++) {
341
+ const part = chain[i];
342
+ const pose = nodeById(m, `${part}.pose`);
343
+ if (!pose)
344
+ return `${part} has no pose this command can read`;
345
+ for (const ref of pose.args.slice(0, 3)) {
346
+ const bad = grow(linOf(m, ref), `${part}'s place`);
347
+ if (bad)
348
+ return bad;
349
+ }
350
+ if (i < top) {
351
+ const seat = nodeById(m, `${part}.seat.rigid`);
352
+ for (const ref of seat?.args.slice(0, 3) ?? []) {
353
+ const bad = grow(linOf(m, ref), `where ${part} is fastened`);
354
+ if (bad)
355
+ return bad;
356
+ }
357
+ const motion = nodeById(m, `${part}.motion`);
358
+ if (motion?.op === 'axis-slide@1')
359
+ for (const end of ['min', 'max']) {
360
+ const bad = grow(travelAt(m, asset, motion, end), `${part}'s travel`);
361
+ if (bad)
362
+ return bad;
363
+ }
364
+ }
365
+ }
366
+ /*
367
+ The pivot: where the topmost turn is fastened, and everything above it. Above it nothing turns, but a slide may
368
+ carry it -- the telescopic conveyor's nose turns at the end of its third stage -- so the pivot is read at each
369
+ 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
370
+ way (it had been refused, and the nose left out of the room).
371
+ */
372
+ const pivotPart = chain[top];
373
+ const parent = parentOf(m, pivotPart);
374
+ const seat = nodeById(m, `${pivotPart}.seat.rigid`);
375
+ const seatAt = Object.fromEntries(AXES.map((a, i) => [a, seat ? linOf(m, seat.args[i]) : { terms: [], c: 0 }]));
376
+ const centres = [];
377
+ for (const end of ends) {
378
+ /* The point it turns about, in the frame of what carries it, carried there -- turned with it if that stands turned. */
379
+ const at = parent ? centreVec(m, asset, parent, end, structuredClone(seatAt)) : seatAt;
380
+ if (typeof at === 'string')
381
+ return at;
382
+ centres.push(at);
383
+ }
384
+ return { centres, radius };
385
+ }
386
+ /** The bounds the parts come to, as sums, with what could not be read named. */
387
+ function occupancyLins(asset, over) {
388
+ const m = asset.document.model;
389
+ const parts = [];
390
+ const skipped = [];
391
+ const low = { x: [], y: [], z: [] };
392
+ const high = { x: [], y: [], z: [] };
393
+ /* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
394
+ const balls = new Map();
395
+ /* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
396
+ const rowOf = (id) => nodeById(m, `${id}.repeat`);
397
+ const ends = over === 'rest' ? ['start'] : ['min', 'max'];
398
+ /* A part's size along the figure's axes, whichever quarter turns it and what carries it stand in. */
399
+ const sizesOf = (id, turns) => {
400
+ /* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
401
+ const own = occupiedExtentsOf(m, id, id, turns);
402
+ const c = turns ? IDENTITY : worldTurnOf(m, asset, id);
403
+ if (!c)
404
+ throw new FigureContractError('EDIT_TARGET', id, `${id} is turned by other than quarter turns`);
405
+ return Object.fromEntries(AXES.map((axis, i) => {
406
+ const j = c[i].findIndex(v => v !== 0);
407
+ const e = own[AXES[j]];
408
+ /* A size is the same either way round; a reach turned half about flips: its offsets change sign. */
409
+ return [axis, isReach(e) && c[i][j] < 0 ? { offsets: e.offsets.map(o => ({ ...o, k: -o.k })) } : e];
410
+ }));
411
+ };
412
+ /*
413
+ A row runs from its parent's one edge to the other along its axis, every copy inside (fit-pitch and fixed-count lay
414
+ them out within that length). What it spans there, how long its part is along it, and how far a rising row climbs.
415
+ */
416
+ const runOf = (part) => {
417
+ const row = rowOf(part);
418
+ const axis = String(row.params?.axis ?? '').toLowerCase();
419
+ const parent = parentOf(m, part);
420
+ const straight = parent && worldTurnOf(m, asset, parent);
421
+ if (!parent || !AXES.includes(axis) || !straight || AXES.some((_, i) => straight[i][i] !== 1))
422
+ return `${part} is a row whose run this command cannot read`;
423
+ const theirs = standingRefsOf(m, asset, parent, parent, false)[axis];
424
+ const half = { terms: [{ ref: theirs.ref, k: 0.5 * theirs.k }], c: 0 };
425
+ const centres = ends.map(end => centreLin(m, asset, parent, axis, end));
426
+ const bad = centres.find(c => typeof c === 'string');
427
+ if (bad)
428
+ return String(bad);
429
+ const along = sizesOf(part, false)[axis];
430
+ if (isReach(along))
431
+ return `${part} is a row of a drawn section along its section; how far each copy reaches is not added up`;
432
+ const mine = along[0];
433
+ const extent = { terms: [{ ref: mine.ref, k: mine.k }], c: 0 };
434
+ const record = (asset.joints ?? []).find(o => o.body1.part === part && o.repeat)?.repeat;
435
+ let climb;
436
+ if (record?.rise) {
437
+ /*
438
+ The last copy is (count − 1) rises past the first. A row that fills its length has at most
439
+ (length − one copy) / pitch copies past the first, so that many rises bound it and follow the parent's size;
440
+ a counted row has exactly count − 1.
441
+ */
442
+ const per = record.rise.mm;
443
+ let reach;
444
+ if (record.count !== undefined)
445
+ reach = { terms: [], c: (record.count - 1) * per };
446
+ else if (typeof record.pitch === 'number')
447
+ reach = {
448
+ terms: [
449
+ { ref: theirs.ref, k: (theirs.k * per) / record.pitch },
450
+ { ref: mine.ref, k: (-mine.k * per) / record.pitch }
451
+ ],
452
+ c: 0
453
+ };
454
+ else
455
+ return `${part} is a rising row whose pitch follows a length; how far it rises is not added up`;
456
+ climb = { axis: record.rise.axis.toLowerCase(), up: per > 0, reach };
457
+ }
458
+ return {
459
+ axis,
460
+ lows: centres.map(c => linAdd(c, half, -1)),
461
+ highs: centres.map(c => linAdd(c, half, 1)),
462
+ extent,
463
+ climb
464
+ };
465
+ };
466
+ /* A part in a row, or fastened to one: each copy carries it (ADR-0093, 2026-09-26). */
467
+ for (const place of m.nodes.filter((n) => n.op === 'place@1' || (n.op === 'member@1' && (rowOf(n.id) || rowHolding(m, n.id))))) {
468
+ const turns = carriedByATurn(m, asset, place.id);
469
+ let sizes = {};
470
+ try {
471
+ sizes = sizesOf(place.id, turns);
472
+ }
473
+ catch (e) {
474
+ skipped.push({
475
+ part: place.id,
476
+ reason: e instanceof FigureContractError ? e.message : 'not a part whose size this command can read'
477
+ });
478
+ continue;
479
+ }
480
+ /*
481
+ The rows around this part, nearest first: its own if it is a row's part, then the row of each part it hangs from
482
+ (a grid is a row inside a row, each along its own axis).
483
+ */
484
+ const rows = [];
485
+ let unread;
486
+ for (let up = place.op === 'member@1' ? place.id : null; up; up = parentOf(m, up)) {
487
+ if (!rowOf(up))
488
+ continue;
489
+ const run = runOf(up);
490
+ if (typeof run === 'string') {
491
+ unread = run;
492
+ break;
493
+ }
494
+ rows.push({ part: up, run });
495
+ }
496
+ if (unread) {
497
+ skipped.push({ part: place.id, reason: unread });
498
+ continue;
499
+ }
500
+ const rowAxes = new Set(rows.map(r => r.run.axis));
501
+ /* How far the rows around it climb, along an axis and a way, added up: each copy of each row is further up. */
502
+ const climbed = (at, side, onto, axis) => {
503
+ const climbs = rows.map(r => r.run.climb).filter(c => c && c.axis === axis && (c.up ? 1 : -1) === side);
504
+ if (climbs.length)
505
+ onto[axis].push(climbs.reduce((sum, c) => linAdd(sum, c.reach), at));
506
+ };
507
+ if (turns) {
508
+ const reach = reachOf(m, asset, place.id, ends);
509
+ if (typeof reach === 'string') {
510
+ skipped.push({ part: place.id, reason: reach });
511
+ continue;
512
+ }
513
+ parts.push(place.id);
514
+ if (rows.length) {
515
+ /*
516
+ Each copy turns about its own pivot, which lies on its rows' runs: along a row the copies reach the run's
517
+ ends and the turn's reach past them; across them, the turn's reach about the pivot.
518
+ */
519
+ for (const { run } of rows) {
520
+ for (const l of run.lows)
521
+ low[run.axis].push(linAdd(l, reach.radius, -1));
522
+ for (const h of run.highs)
523
+ high[run.axis].push(linAdd(h, reach.radius, 1));
524
+ }
525
+ for (const at of reach.centres)
526
+ for (const axis of AXES.filter(a => !rowAxes.has(a))) {
527
+ const lo = linAdd(at[axis], reach.radius, -1);
528
+ const hi = linAdd(at[axis], reach.radius, 1);
529
+ low[axis].push(lo);
530
+ high[axis].push(hi);
531
+ climbed(lo, -1, low, axis);
532
+ climbed(hi, 1, high, axis);
533
+ }
534
+ continue;
535
+ }
536
+ for (const at of reach.centres) {
537
+ const centre = Object.fromEntries(AXES.map(a => [a, linNorm(at[a])]));
538
+ const key = JSON.stringify(centre);
539
+ const ball = balls.get(key);
540
+ balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
541
+ }
542
+ continue;
543
+ }
544
+ /*
545
+ Both ends of every control are candidates, and which one is the low end is not decided here: a travel of
546
+ −40 mm puts the low end at the control's maximum (designer's counterexample 2026-09-23, where the
547
+ proposal read −50..10 for a part that needs −90..50). Both candidates go into the same min and the same
548
+ max, so the answer holds whichever way the travel runs, and holds at every design size, because the
549
+ choice is made when the graph is evaluated rather than when the proposal is written.
550
+ */
551
+ const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
552
+ const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
553
+ if (bad) {
554
+ skipped.push({ part: place.id, reason: String(bad) });
555
+ continue;
556
+ }
557
+ let offRun;
558
+ for (const { part: template, run } of rows) {
559
+ if (template === place.id) {
560
+ /* The row's own part: along the row it takes the run. */
561
+ low[run.axis].push(...run.lows);
562
+ high[run.axis].push(...run.highs);
563
+ continue;
564
+ }
565
+ /*
566
+ Carried by a row's part: its copies stand where that part's copies do, offset as it is from it. That part's
567
+ centres lie half its length in from the run's ends.
568
+ */
569
+ const theirs = ends.map(end => centreLin(m, asset, template, run.axis, end));
570
+ const bad = theirs.find(c => typeof c === 'string');
571
+ if (bad) {
572
+ offRun = String(bad);
573
+ break;
574
+ }
575
+ const along = readings.find(r => r.axis === run.axis).at;
576
+ const inset = linAdd({ terms: [], c: 0 }, run.extent, 0.5);
577
+ const across = sizes[run.axis];
578
+ if (isReach(across)) {
579
+ offRun = `${place.id} is a drawn section carried along a row; how far each copy reaches is not added up`;
580
+ break;
581
+ }
582
+ for (const [i, mine] of along.entries()) {
583
+ const offset = linAdd(mine, theirs[i], -1);
584
+ for (const e of across) {
585
+ const half = { terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 };
586
+ for (const l of run.lows)
587
+ low[run.axis].push(linAdd(linAdd(linAdd(l, inset), offset), half, -1));
588
+ for (const h of run.highs)
589
+ high[run.axis].push(linAdd(linAdd(linAdd(h, inset, -1), offset), half));
590
+ }
591
+ }
592
+ }
593
+ if (offRun) {
594
+ skipped.push({ part: place.id, reason: offRun });
595
+ continue;
596
+ }
597
+ parts.push(place.id);
598
+ for (const r of readings) {
599
+ if (rowAxes.has(r.axis))
600
+ continue;
601
+ const extent = sizes[r.axis];
602
+ const seen = new Set();
603
+ for (const centre of r.at) {
604
+ const key = JSON.stringify([centre.terms.map(t => [t.ref, t.k]).sort(), centre.c]);
605
+ if (seen.has(key))
606
+ continue; // a part that does not move gives the same candidate twice
607
+ seen.add(key);
608
+ if (isReach(extent)) {
609
+ /* Every point is a candidate for both ends: the least is the low end, the greatest the high one. */
610
+ for (const o of extent.offsets) {
611
+ const at = linAdd(centre, { terms: [{ ref: o.ref, k: o.k }], c: 0 });
612
+ low[r.axis].push(at);
613
+ high[r.axis].push(at);
614
+ climbed(at, -1, low, r.axis);
615
+ climbed(at, 1, high, r.axis);
616
+ }
617
+ continue;
618
+ }
619
+ const halves = extent.map(e => ({ terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 }));
620
+ for (const half of halves) {
621
+ const lo = linAdd(centre, half, -1);
622
+ const hi = linAdd(centre, half, 1);
623
+ low[r.axis].push(lo);
624
+ high[r.axis].push(hi);
625
+ climbed(lo, -1, low, r.axis);
626
+ climbed(hi, 1, high, r.axis);
627
+ }
628
+ }
629
+ }
630
+ }
631
+ return { parts, skipped, low, high, balls: [...balls.values()] };
632
+ }
633
+ const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
634
+ /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
635
+ export function proposeFigureOccupancy(asset, options = {}) {
636
+ asset = upgradeFigurePlacement(asset);
637
+ const over = options.over ?? 'range';
638
+ if (over !== 'rest' && over !== 'range')
639
+ fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
640
+ const source = structuredClone(asset);
641
+ const { parts, skipped, low, high, balls } = occupancyLins(source, over);
642
+ for (const ball of balls)
643
+ for (const axis of AXES) {
644
+ low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
645
+ high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
646
+ }
647
+ if (!parts.length)
648
+ fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
649
+ const values = compileFigureGraph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults })
650
+ .values;
651
+ const at = Object.fromEntries(AXES.map(axis => [
652
+ axis,
653
+ {
654
+ min: round6(Math.min(...low[axis].map(l => valueOfLin(values, l)))),
655
+ max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l))))
656
+ }
657
+ ]));
658
+ return { over, at, parts, skipped, basis: fingerprintOf(source) };
659
+ }
660
+ /**
661
+ * The placement says which plane the mounting plane is. A part already fastened to it by a face the new plane does not
662
+ * take -- a bottom on the floor, when the figure is to hang on a wall -- is named, not seated again without a word
663
+ * (chief architect's condition, 2026-09-25): fasten it again by the face the new plane takes.
664
+ */
665
+ export function setPlacement(asset, a) {
666
+ if (!FIGURE_PLACEMENTS.includes(a.placement))
667
+ fail('SCHEMA', 'placement', `${String(a.placement)} is not a placement`);
668
+ const takes = planeFacesOf(a.placement);
669
+ const misfit = (asset.joints ?? []).filter(j => 'plane' in j.body0 && !takes.includes(j.body1.face));
670
+ if (misfit.length)
671
+ fail('PLACEMENT_PLANE', 'placement', `${misfit.map(j => `${j.body1.part} (${j.body1.face})`).join(', ')} ${misfit.length > 1 ? 'are' : 'is'} fastened to the mounting plane by a face a ${a.placement} does not take (${takes.join(' or ')}); detach ${misfit.length > 1 ? 'them' : 'it'} or fasten ${misfit.length > 1 ? 'them' : 'it'} by ${takes.join(' or ')} first`, {
672
+ reason: 'placement-face',
673
+ placement: a.placement,
674
+ parts: misfit.map(j => ({ part: j.body1.part, face: String(j.body1.face) })),
675
+ takes: [...takes]
676
+ });
677
+ asset.placement = a.placement;
678
+ return asset;
679
+ }
680
+ export function declareOccupancy(asset, a) {
681
+ const m = asset.document.model;
682
+ if (!a.proposal || (a.proposal.over !== 'rest' && a.proposal.over !== 'range'))
683
+ fail('SCHEMA', 'occupancy', 'a proposal is required; propose first, then confirm');
684
+ const now = fingerprintOf(asset);
685
+ if (a.proposal.basis !== now)
686
+ 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');
687
+ /* Declared with a placement, the placement is set by the same command as on its own: one value, one check. */
688
+ if (a.placement !== undefined)
689
+ setPlacement(asset, { kind: 'set-placement', placement: a.placement });
690
+ const padding = {};
691
+ for (const axis of AXES) {
692
+ const pad = a.padding?.[axis] ?? 0;
693
+ if (!(Number.isFinite(pad) && pad >= 0))
694
+ fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
695
+ padding[axis] = pad;
696
+ }
697
+ const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
698
+ /* A turning chain's ball, its reach either way about its pivot on each axis. */
699
+ for (const ball of balls)
700
+ for (const axis of AXES) {
701
+ low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
702
+ high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
703
+ }
704
+ for (const axis of AXES) {
705
+ if (!padding[axis])
706
+ continue;
707
+ low[axis] = low[axis].map((l) => ({ ...l, c: l.c - padding[axis] }));
708
+ high[axis] = high[axis].map((l) => ({ ...l, c: l.c + padding[axis] }));
709
+ }
710
+ if (parts.join('|') !== a.proposal.parts.join('|'))
711
+ 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`);
712
+ /* One node for the six bounds (affine-bounds@1): each candidate its constant, then a value and a ratio per term. */
713
+ const args = [];
714
+ const layout = AXES.flatMap(axis => [low[axis], high[axis]]).map(side => side
715
+ .map((l) => {
716
+ const n = linThroughSums(m, l);
717
+ args.push(constantOf(m, 'mm', round6(n.c), 'd'));
718
+ for (const t of n.terms)
719
+ args.push(t.ref, constantOf(m, 'ratio', t.k, 'k'));
720
+ return n.terms.length;
721
+ })
722
+ .join('_'));
723
+ const id = namer(m, 'occupancy')('bounds');
724
+ const port = (p) => `${id}.${p}`;
725
+ m.nodes.push({
726
+ id,
727
+ op: 'affine-bounds@1',
728
+ args,
729
+ params: { layout: `L${layout.join('.')}` },
730
+ outputs: Object.fromEntries(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax'].map(p => [p, port(p)]))
731
+ });
732
+ const bounds = Object.fromEntries(AXES.map(axis => [axis, { min: port(`${axis}min`), max: port(`${axis}max`) }]));
733
+ const was = stringsIn(asset.occupancy?.bounds);
734
+ asset.occupancy = { ...(asset.occupancy?.contact ? { contact: asset.occupancy.contact } : {}), bounds };
735
+ /*
736
+ The room declared before goes with what fed only it. Left in the graph, it kept reading where parts had been
737
+ fastened, and the next fastening was refused over a room no one read (the gantry, 2026-09-25).
738
+ */
739
+ for (const ref of was)
740
+ dropIfUnused(asset, ref);
741
+ /*
742
+ A part's own measure the old room alone still read -- the part follows something else now -- goes too: kept, it
743
+ was a dial that changed nothing (the turntable after every part followed the one ratio, 2026-09-25). So does its
744
+ own position or turn, once a fastening places it: a floor part's height position stayed as a field that moved
745
+ nothing (browser check, ADR-0097, 2026-09-27). The room's refit names them before it is confirmed (figureRoomRefitOf).
746
+ */
747
+ const placed = new Set(m.nodes.filter((n) => n.op === 'place@1' || n.op === 'member@1').map((n) => n.id));
748
+ for (const input of [...m.inputs])
749
+ if (input.role === 'design' &&
750
+ placed.has(String(input.id).split('.')[0]) &&
751
+ /\.(dimension|size|position|rotation)\.[a-z]+$/.test(input.id))
752
+ dropIfUnused(asset, input.id, input.id);
753
+ return asset;
754
+ }
755
+ /** "This face is what the figure is mounted on." Kept apart from how much room the figure takes up. */
756
+ export function setMountingFace(asset, a) {
757
+ const m = asset.document.model;
758
+ if (!asset.occupancy)
759
+ fail('OCCUPANCY_UNDECLARED', 'occupancy', 'declare the room the figure takes up before naming the face it stands on');
760
+ if (!Object.hasOwn(FIGURE_FACES, String(a.face)))
761
+ fail('SCHEMA', a.part, `${String(a.face)} is not a face`);
762
+ const { place } = partOf(m, a.part, a.part);
763
+ const id = `${a.part}.mount.${a.face}`;
764
+ if (!nodeById(m, id))
765
+ m.nodes.push({
766
+ id,
767
+ op: 'feature@1',
768
+ args: [place.args[0]],
769
+ outputs: { pose: `${id}.value` },
770
+ params: { frame: `${id}.frame`, name: a.face }
771
+ });
772
+ asset.occupancy.contact = {
773
+ plane: 'mounting-plane',
774
+ surface: { placement: place.outputs.placed, feature: `${id}.value` }
775
+ };
776
+ return asset;
777
+ }
778
+ export function clearMountingFace(asset) {
779
+ if (!asset.occupancy?.contact)
780
+ fail('EDIT_TARGET', 'occupancy', 'this figure names no mounting face');
781
+ delete asset.occupancy.contact;
782
+ return asset;
783
+ }
784
+ //# sourceMappingURL=authoring-occupancy.js.map