@hatiolab/figure-model 0.1.37 → 0.1.38

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.
@@ -22,6 +22,7 @@
22
22
  * parent carries the child with it. A part that is fastened to nothing poses straight into the asset frame.
23
23
  */
24
24
  import { compileV3Asset } from "./v3-asset.js";
25
+ import { compileV3Graph } from "./v3-graph.js";
25
26
  import { V3ContractError } from "./v3-contract.js";
26
27
  import { AXES } from "./types.js";
27
28
  const fail = (code, path, message) => {
@@ -54,15 +55,22 @@ function sizeRefsOf(m, id, path) {
54
55
  fail('EDIT_TARGET', path, `${id} is a ${shape.op}; a face of it is not an axis-aligned plane this command can measure`);
55
56
  return { x: shape.args[0], y: shape.args[1], z: shape.args[2] };
56
57
  }
58
+ /** The angle a pose argument holds, whether it is a constant or a design input, or null when it is neither. */
59
+ function angleOf(m, asset, ref) {
60
+ const constant = m.constants.find((c) => c.id === ref);
61
+ if (constant)
62
+ return constant.value;
63
+ const input = m.inputs.find((i) => i.id === ref);
64
+ if (input && input.role !== 'state')
65
+ return asset.designInputs[ref] ?? null;
66
+ return null;
67
+ }
57
68
  /** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
58
69
  function requireUnturned(m, asset, id, path) {
59
70
  const { pose } = partOf(m, id, path);
60
- for (const ref of pose.args.slice(3, 6)) {
61
- const constant = m.constants.find((c) => c.id === ref);
62
- const value = constant ? constant.value : asset.designInputs[ref];
63
- if (value === undefined || value !== 0)
71
+ for (const ref of pose.args.slice(3, 6))
72
+ if (angleOf(m, asset, ref) !== 0)
64
73
  fail('EDIT_TARGET', path, `${id} is turned; fastening a turned part by its faces is not covered by this command`);
65
- }
66
74
  }
67
75
  /** Fresh node ids under a part, so two commands never claim one id. */
68
76
  function namer(m, prefix) {
@@ -100,6 +108,37 @@ function scaled(m, name, ref, times, plus, hint) {
100
108
  }
101
109
  return out;
102
110
  }
111
+ /** A measure as one graph reference: a constant, or the source it follows times a multiple plus a margin. */
112
+ function measureRef(m, asset, name, measure, hint, path) {
113
+ if (measure === undefined)
114
+ return constantOf(m, 'mm', 0, 'd');
115
+ if (typeof measure === 'number') {
116
+ if (!Number.isFinite(measure))
117
+ fail('SCHEMA', path, 'a finite length is required');
118
+ return constantOf(m, 'mm', measure, 'd');
119
+ }
120
+ if (!measure || typeof measure !== 'object' || !measure.from)
121
+ fail('SCHEMA', path, 'a length is a number of millimetres, or something to follow');
122
+ return scaled(m, name, sourceRef(m, asset, measure.from, path), measure.times ?? 1, measure.plus ?? 0, hint);
123
+ }
124
+ /** Σ k·ref, written as nodes, with nothing emitted for a weight of zero and no node for a single term. */
125
+ function sumOf(m, name, terms, hint) {
126
+ const live = terms.filter(t => t.k !== 0);
127
+ if (!live.length)
128
+ return constantOf(m, 'mm', 0, 'd');
129
+ let out = '';
130
+ for (const [i, t] of live.entries()) {
131
+ const piece = scaled(m, name, t.ref, t.k, 0, `${hint}.${i}`);
132
+ if (!out)
133
+ out = piece;
134
+ else {
135
+ const id = name(`${hint}.${i}.sum`);
136
+ m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
137
+ out = `${id}.value`;
138
+ }
139
+ }
140
+ return out;
141
+ }
103
142
  function sourceRef(m, asset, source, path) {
104
143
  if (!source || typeof source !== 'object')
105
144
  fail('SCHEMA', path, 'a dimension source is required');
@@ -118,56 +157,91 @@ function sourceRef(m, asset, source, path) {
118
157
  return source.input;
119
158
  }
120
159
  if (source.of === 'part')
121
- return sizeRefsOf(m, source.part, path)[source.axis];
160
+ return stableDimension(m, source.part, source.axis, path);
122
161
  return fail('SCHEMA', path, `${String(source.of)} is not a dimension source`);
123
162
  }
124
163
  /**
125
- * Whether this side of the part already follows something. A part created by the authoring API reads its own
126
- * `<id>.size.<axis>` input and nothing else; anything else in that argument is a link, whether it is a chain of
127
- * nodes or the followed value used directly.
164
+ * The name of a part's dimension, which stays the same name however that dimension is later worked out.
165
+ *
166
+ * "B is as wide as A" has to keep meaning that when A is told to follow something else. Reading A's shape
167
+ * argument at the moment of linking gave B whatever A happened to read then — A's own control — so relinking A
168
+ * left B on the old value (V3 designer's counterexample 2026-09-23: A went to 300 and B stayed at 100). Every
169
+ * dimension a command touches gets a node of its own, `<part>.dimension.<axis>`, and that is what the shape
170
+ * reads and what anything following it reads. Relinking rewrites what that node computes, and everyone pointing
171
+ * at it comes along — fastenings included, since they measure the same references.
128
172
  */
129
- const followsAlready = (part, axis, ref) => ref !== `${part}.size.${axis}`;
173
+ function stableDimension(m, part, axis, path) {
174
+ const shape = partOf(m, part, path).shape;
175
+ const i = AXES.indexOf(axis);
176
+ const id = `${part}.dimension.${axis}`;
177
+ const ref = `${id}.value`;
178
+ if (nodeById(m, id))
179
+ return ref;
180
+ const one = constantOf(m, 'ratio', 1, 'k');
181
+ m.nodes.push({ id, op: 'mul@1', args: [shape.args[i], one], outputs: { value: ref } });
182
+ shape.args = shape.args.map((r, k) => (k === i ? ref : r));
183
+ return ref;
184
+ }
185
+ /** What a part's dimension is worked out from right now: the first argument of its own dimension node. */
186
+ const dimensionSource = (m, part, axis) => nodeById(m, `${part}.dimension.${axis}`)?.args?.[0];
187
+ /** Whether this side of the part already follows something, rather than reading its own control. */
188
+ const followsAlready = (m, part, axis) => {
189
+ const from = dimensionSource(m, part, axis);
190
+ return from !== undefined && from !== `${part}.size.${axis}`;
191
+ };
130
192
  function linkDimension(asset, a) {
131
193
  const m = asset.document.model;
132
194
  const path = `${a.part}.${a.axis}`;
133
- const sizes = sizeRefsOf(m, a.part, path);
134
- const current = sizes[a.axis];
135
- if (followsAlready(a.part, a.axis, current) && !a.replace)
195
+ const mine = stableDimension(m, a.part, a.axis, path);
196
+ if (followsAlready(m, a.part, a.axis) && !a.replace)
136
197
  fail('DIMENSION_LINKED', path, `${a.part}'s ${a.axis} already follows something; pass replace to change what it follows, or unlink it first`);
137
198
  const times = a.times ?? 1;
138
199
  const plus = a.plus ?? 0;
139
200
  if (!Number.isFinite(times) || !Number.isFinite(plus))
140
201
  fail('SCHEMA', path, 'finite times and plus required');
202
+ if (a.source.of === 'part' && a.source.part === a.part && a.source.axis === a.axis)
203
+ fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
141
204
  const from = sourceRef(m, asset, a.source, path);
142
- if (from === current)
205
+ if (from === mine)
143
206
  fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
207
+ if (reaches(m, from, mine))
208
+ fail('DIMENSION_CYCLE', path, `${String(a.source.of === 'part' ? a.source.part : a.source.of)} already follows ${a.part}'s ${a.axis}`);
144
209
  const name = namer(m, `${a.part}.${a.axis}`);
145
210
  const value = scaled(m, name, from, times, plus, 'follows');
146
- if (value === from && a.source.of === 'part' && a.source.part === a.part)
147
- fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
148
- const shape = partOf(m, a.part, path).shape;
149
- shape.args = shape.args.map((ref) => (ref === current ? value : ref));
150
- // The part's own input is no longer read by anything; leaving it would show a control that changes nothing.
151
- dropIfUnused(asset, current, `${a.part}.size.${a.axis}`);
211
+ const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
212
+ const was = node.args[0];
213
+ node.args = [value, node.args[1]];
214
+ // The part's own control is no longer read by anything; leaving it would show a dial that changes nothing.
215
+ dropIfUnused(asset, was, `${a.part}.size.${a.axis}`);
152
216
  return asset;
153
217
  }
218
+ /** Whether one value is worked out from another, so a link cannot be made to close a loop. */
219
+ function reaches(m, from, target, seen = new Set()) {
220
+ if (from === target)
221
+ return true;
222
+ if (seen.has(from))
223
+ return false;
224
+ seen.add(from);
225
+ const writer = writerOf(m, from);
226
+ return !!writer && writer.args.some((ref) => reaches(m, ref, target, seen));
227
+ }
154
228
  function unlinkDimension(asset, a) {
155
229
  const m = asset.document.model;
156
230
  const path = `${a.part}.${a.axis}`;
157
231
  if (!(Number.isFinite(a.mm) && a.mm > 0))
158
232
  fail('GEOMETRY_DOMAIN', path, 'a positive length is required');
159
- const sizes = sizeRefsOf(m, a.part, path);
160
- const current = sizes[a.axis];
161
- if (!followsAlready(a.part, a.axis, current))
233
+ stableDimension(m, a.part, a.axis, path);
234
+ if (!followsAlready(m, a.part, a.axis))
162
235
  fail('EDIT_TARGET', path, `${a.part}'s ${a.axis} does not follow anything`);
163
236
  const id = `${a.part}.size.${a.axis}`;
164
237
  if (m.inputs.some((i) => i.id === id))
165
238
  fail('DUPLICATE_WRITER', path, `${id} already exists`);
166
239
  m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
167
240
  asset.designInputs[id] = a.mm;
168
- const shape = partOf(m, a.part, path).shape;
169
- shape.args = shape.args.map((ref) => (ref === current ? id : ref));
170
- dropIfUnused(asset, current);
241
+ const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
242
+ const was = node.args[0];
243
+ node.args = [id, node.args[1]];
244
+ dropIfUnused(asset, was);
171
245
  return asset;
172
246
  }
173
247
  /**
@@ -207,75 +281,100 @@ function parentOf(m, id) {
207
281
  }
208
282
  function attach(asset, a) {
209
283
  const m = asset.document.model;
210
- const path = `${a.part} to ${a.to?.part}`;
211
- if (!a.to || typeof a.to !== 'object')
212
- fail('SCHEMA', path, 'a target part and face are required');
213
- if (a.part === a.to.part)
284
+ const to = a.to;
285
+ const path = `${a.part} to ${to?.plane ?? to?.part}`;
286
+ if (!to || typeof to !== 'object')
287
+ fail('SCHEMA', path, 'a target face, or the plane the figure is mounted on, is required');
288
+ if (to.plane !== undefined)
289
+ return standOnPlane(asset, a, path);
290
+ if (a.part === to.part)
214
291
  fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
215
- for (const face of [a.face, a.to.face])
292
+ for (const face of [a.face, to.face])
216
293
  if (!Object.hasOwn(V3_FACES, String(face)))
217
294
  fail('SCHEMA', path, `${String(face)} is not a face`);
218
295
  const facing = a.facing ?? 'meet';
219
296
  if (facing !== 'meet' && facing !== 'flush')
220
297
  fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
221
- const gap = a.gap ?? 0;
222
- if (!Number.isFinite(gap))
223
- fail('SCHEMA', path, 'a finite gap is required');
224
- const axis = V3_FACES[a.to.face];
298
+ const axis = V3_FACES[to.face];
225
299
  if (V3_FACES[a.face] !== axis)
226
- fail('ATTACH_FACE', path, `${a.face} faces along ${V3_FACES[a.face]} and ${a.to.face} along ${axis}; fastening them would need a turn, which this command does not do`);
227
- if (facing === 'meet' && a.face !== OPPOSITE[a.to.face])
228
- fail('ATTACH_FACE', path, `to meet ${a.to.face}, ${a.part} offers its ${OPPOSITE[a.to.face]}; pass flush to lay ${a.face} in the same plane instead`);
229
- if (facing === 'flush' && a.face !== a.to.face)
230
- fail('ATTACH_FACE', path, `to lie flush with ${a.to.face}, ${a.part} offers its ${a.to.face}`);
300
+ fail('ATTACH_FACE', path, `${a.face} faces along ${V3_FACES[a.face]} and ${to.face} along ${axis}; fastening them would need a turn, which this command does not do`);
301
+ if (facing === 'meet' && a.face !== OPPOSITE[to.face])
302
+ fail('ATTACH_FACE', path, `to meet ${to.face}, ${a.part} offers its ${OPPOSITE[to.face]}; pass flush to lay ${a.face} in the same plane instead`);
303
+ if (facing === 'flush' && a.face !== to.face)
304
+ fail('ATTACH_FACE', path, `to lie flush with ${to.face}, ${a.part} offers its ${to.face}`);
231
305
  if (parentOf(m, a.part) && !a.replace)
232
306
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
233
307
  // A part cannot end up its own ancestor.
234
- for (let up = a.to.part; up; up = parentOf(m, up))
308
+ for (let up = to.part; up; up = parentOf(m, up))
235
309
  if (up === a.part)
236
- fail('ATTACH_CYCLE', path, `${a.to.part} already hangs from ${a.part}`);
310
+ fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
237
311
  const mine = sizeRefsOf(m, a.part, path);
238
- const theirs = sizeRefsOf(m, a.to.part, path);
312
+ const theirs = sizeRefsOf(m, to.part, path);
239
313
  requireUnturned(m, asset, a.part, path);
240
- requireUnturned(m, asset, a.to.part, path);
241
- const name = namer(m, `${a.part}.on.${a.to.part}`);
242
- const half = (ref, hint) => scaled(m, name, ref, 0.5, 0, hint);
243
- const sign = SIGN[a.to.face];
314
+ requireUnturned(m, asset, to.part, path);
315
+ const name = namer(m, `${a.part}.on.${to.part}`);
316
+ const sign = SIGN[to.face];
244
317
  /*
245
318
  Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
246
319
  gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
247
320
  */
248
- const reach = facing === 'meet' ? sign * (gap + 0) : -sign * 0;
249
- void reach;
250
- const ownHalf = half(mine[axis], `half.${axis}`);
251
- const theirHalf = half(theirs[axis], `their.half.${axis}`);
252
321
  const outward = facing === 'meet' ? sign : -sign;
253
- const alongId = name(`along.${axis}`);
254
- m.nodes.push({ id: alongId, op: 'add@1', args: [scaled(m, name, theirHalf, sign, 0, `face.${axis}`), scaled(m, name, ownHalf, outward, gap * outward, `stand.${axis}`)], outputs: { value: `${alongId}.value` } });
322
+ const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
255
323
  const translation = { x: '', y: '', z: '' };
256
- translation[axis] = `${alongId}.value`;
324
+ translation[axis] = sumOf(m, name, [
325
+ { ref: theirs[axis], k: sign * 0.5 },
326
+ { ref: mine[axis], k: outward * 0.5 },
327
+ { ref: gap, k: outward }
328
+ ], `along.${axis}`);
257
329
  // Across the face. Centred by default; min and max keep the two parts' edges level as either is resized.
258
330
  for (const other of AXES.filter(x => x !== axis)) {
259
331
  const how = a.align?.[other] ?? 'centre';
260
332
  if (how === 'centre')
261
333
  translation[other] = constantOf(m, 'mm', 0, 'd');
262
- else if (typeof how === 'object' && how && Number.isFinite(how.mm))
263
- translation[other] = constantOf(m, 'mm', how.mm, 'd');
334
+ else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
335
+ translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
264
336
  else if (how === 'min' || how === 'max') {
265
337
  const s = how === 'min' ? -1 : 1;
266
- const id = name(`align.${other}`);
267
- m.nodes.push({
268
- id,
269
- op: 'add@1',
270
- args: [scaled(m, name, half(theirs[other], `their.half.${other}`), s, 0, `edge.${other}`), scaled(m, name, half(mine[other], `half.${other}`), -s, 0, `inset.${other}`)],
271
- outputs: { value: `${id}.value` }
272
- });
273
- translation[other] = `${id}.value`;
338
+ translation[other] = sumOf(m, name, [{ ref: theirs[other], k: s * 0.5 }, { ref: mine[other], k: -s * 0.5 }], `align.${other}`);
274
339
  }
275
340
  else
276
341
  fail('SCHEMA', path, `${String(how)} is not an alignment`);
277
342
  }
278
- reseat(asset, a.part, `${a.to.part}.local`, [translation.x, translation.y, translation.z]);
343
+ reseat(asset, a.part, `${to.part}.local`, [translation.x, translation.y, translation.z]);
344
+ return asset;
345
+ }
346
+ /**
347
+ * Standing a part on the plane the figure is mounted on. The plane has no width and no depth, so there is
348
+ * nothing to line the part's edges up with: across the plane, only the centre or a measured offset.
349
+ */
350
+ function standOnPlane(asset, a, path) {
351
+ const m = asset.document.model;
352
+ const to = a.to;
353
+ if (to.plane !== 'mounting-plane')
354
+ fail('SCHEMA', path, `${String(to.plane)} is not a plane a part can stand on`);
355
+ if (a.facing !== undefined && a.facing !== 'meet')
356
+ fail('SCHEMA', path, 'a part standing on the plane meets it; flush says nothing here');
357
+ if (a.face !== 'bottom' && a.face !== 'top')
358
+ fail('ATTACH_FACE', path, `${String(a.face)} faces along ${V3_FACES[a.face] ?? '?'}; the plane faces along y, so a part stands on it by its bottom or hangs from it by its top`);
359
+ if (parentOf(m, a.part) && !a.replace)
360
+ fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
361
+ const mine = sizeRefsOf(m, a.part, path);
362
+ requireUnturned(m, asset, a.part, path);
363
+ const name = namer(m, `${a.part}.on.plane`);
364
+ const outward = a.face === 'bottom' ? 1 : -1;
365
+ const gap = measureRef(m, asset, name, a.gap, 'gap.y', path);
366
+ const translation = { x: '', y: '', z: '' };
367
+ translation.y = sumOf(m, name, [{ ref: mine.y, k: outward * 0.5 }, { ref: gap, k: outward }], 'stand.y');
368
+ for (const other of ['x', 'z']) {
369
+ const how = a.align?.[other] ?? 'centre';
370
+ if (how === 'centre')
371
+ translation[other] = constantOf(m, 'mm', 0, 'd');
372
+ else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
373
+ translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
374
+ else
375
+ fail('SCHEMA', path, `the plane has no edges to line ${a.part} up with on ${other}; give the centre or a measured offset`);
376
+ }
377
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, [translation.x, translation.y, translation.z]);
279
378
  return asset;
280
379
  }
281
380
  function detach(asset, a) {
@@ -284,13 +383,36 @@ function detach(asset, a) {
284
383
  const parent = parentOf(m, a.part);
285
384
  if (!parent)
286
385
  fail('EDIT_TARGET', path, `${a.part} is not fastened to anything`);
287
- // Where it stands now, kept as plain numbers in the asset frame, so detaching does not move it.
386
+ /*
387
+ Detaching keeps the part where it stands, and keeps it moving the way it moved. Taking the world pose and
388
+ writing it in as the part's own pose did neither: the part's motion was already in that pose and was then
389
+ applied a second time, so a part with 20 mm of travel jumped to 40 (V3 designer's counterexample
390
+ 2026-09-23).
391
+
392
+ What the part's pose has to gain is exactly what the parent's chain was contributing — and that is a plain
393
+ translation only while nothing above the part is turned. Where something is, the two cannot be separated
394
+ into a translation the pose can absorb, and this refuses rather than moving the part.
395
+ */
396
+ for (let up = parent; up; up = parentOf(m, up))
397
+ requireUnturned(m, asset, up, path);
288
398
  const evaluated = compileV3Asset(asset).evaluate();
289
- const here = evaluated.geometry.find((g) => g.id === a.part);
290
- if (!here)
291
- fail('TARGET_ABSENT', path, `${a.part} is not drawn`);
292
- const at = AXES.map((_axis, i) => constantOf(m, 'mm', round6(here.pose.t[i]), 'd'));
293
- reseat(asset, a.part, asset.document.capabilities.assetFrame, at);
399
+ const above = evaluated.values?.[nodeById(m, `${parent}.world`)?.outputs?.pose ?? nodeById(m, `${parent}.chain`)?.outputs?.pose ?? nodeById(m, `${parent}.pose`).outputs.pose];
400
+ if (!above || !above.t)
401
+ fail('TARGET_ABSENT', path, `${parent} has no pose to read`);
402
+ const turned = above.r.some((row, i) => row.some((v, j) => Math.abs(v - (i === j ? 1 : 0)) > 1e-9));
403
+ if (turned)
404
+ fail('EDIT_TARGET', path, `${parent} is turned; taking ${a.part} off it would move it, and this command does not move a part`);
405
+ const { pose } = partOf(m, a.part, path);
406
+ const own = evaluated.values?.[pose.outputs.pose];
407
+ if (!own || !own.t)
408
+ fail('TARGET_ABSENT', path, `${a.part} has no pose to read`);
409
+ /*
410
+ Where it stands, as plain numbers: what the parent was contributing plus what the part's own pose was. Its
411
+ motion is left out, because the motion node stays and would otherwise be counted twice. Numbers rather than
412
+ references, because a part that is no longer fastened to the plate should not still grow with it.
413
+ */
414
+ const translation = AXES.map((_axis, i) => constantOf(m, 'mm', round6(above.t[i] + own.t[i]), 'd'));
415
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, translation);
294
416
  return asset;
295
417
  }
296
418
  const round6 = (v) => Math.round(v * 1e6) / 1e6;
@@ -301,8 +423,9 @@ const round6 = (v) => Math.round(v * 1e6) / 1e6;
301
423
  function reseat(asset, id, to, translation) {
302
424
  const m = asset.document.model;
303
425
  const { place, pose } = partOf(m, id, id);
304
- const old = pose.args.slice(0, 3);
305
- pose.args = [...translation, ...pose.args.slice(3)];
426
+ const old = translation ? pose.args.slice(0, 3) : [];
427
+ if (translation)
428
+ pose.args = [...translation, ...pose.args.slice(3)];
306
429
  pose.params = { ...pose.params, to: `${id}.seat` };
307
430
  const motion = nodeById(m, `${id}.motion`);
308
431
  if (motion)
@@ -366,6 +489,21 @@ export function applyV3Authoring(source, action) {
366
489
  case 'detach':
367
490
  detach(asset, action);
368
491
  break;
492
+ case 'add-motion':
493
+ addMotion(asset, action);
494
+ break;
495
+ case 'remove-motion':
496
+ removeMotion(asset, action);
497
+ break;
498
+ case 'declare-occupancy':
499
+ declareOccupancy(asset, action);
500
+ break;
501
+ case 'set-mounting-face':
502
+ setMountingFace(asset, action);
503
+ break;
504
+ case 'clear-mounting-face':
505
+ clearMountingFace(asset);
506
+ break;
369
507
  default:
370
508
  fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
371
509
  }
@@ -381,4 +519,294 @@ export function v3AttachmentsOf(asset) {
381
519
  out[n.id] = parentOf(m, n.id);
382
520
  return out;
383
521
  }
522
+ /** The frame a part is fastened into: its parent's, or the asset's. */
523
+ const attachmentFrameOf = (asset, id) => {
524
+ const parent = parentOf(asset.document.model, id);
525
+ return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
526
+ };
527
+ function addMotion(asset, a) {
528
+ const m = asset.document.model;
529
+ const path = a.part;
530
+ partOf(m, a.part, path);
531
+ if (!a.motion || (a.motion.kind !== 'slide' && a.motion.kind !== 'turn'))
532
+ fail('SCHEMA', path, 'a slide or a turn is required');
533
+ if (!AXES.includes(a.motion.axis))
534
+ fail('SCHEMA', path, `${String(a.motion.axis)} is not an axis`);
535
+ if (nodeById(m, `${a.part}.motion`) && !a.replace)
536
+ fail('MOTION_REPLACED', path, `${a.part} already moves; pass replace to change how, or remove the motion first`);
537
+ if (nodeById(m, `${a.part}.motion`))
538
+ removeMotion(asset, { kind: 'remove-motion', part: a.part });
539
+ const frame = a.motion.frame ?? 'attachment';
540
+ const seatedIn = attachmentFrameOf(asset, a.part);
541
+ if (frame === 'asset' && seatedIn !== asset.document.capabilities.assetFrame)
542
+ fail('MOTION_FRAME', path, `${a.part} is fastened into ${seatedIn}; an axis in the asset frame would have to be re-expressed there, which this command does not do`);
543
+ else if (frame !== 'attachment' && frame !== 'asset')
544
+ fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
545
+ const st = a.state;
546
+ if (!st || typeof st.id !== 'string' || !st.id.trim())
547
+ fail('SCHEMA', path, 'a state input id is required');
548
+ if (m.inputs.some((i) => i.id === st.id))
549
+ fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
550
+ const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
551
+ if (!wanted.includes(st.unit))
552
+ fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
553
+ if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
554
+ fail('SCHEMA', path, 'a range with max above min is required');
555
+ const start = st.start ?? st.min;
556
+ if (!(start >= st.min && start <= st.max))
557
+ fail('SCHEMA', path, 'the starting value is outside the range');
558
+ m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
559
+ asset.stateDefaults[st.id] = start;
560
+ if (st.label !== undefined || st.sweep !== undefined) {
561
+ asset.stateInputs = { ...(asset.stateInputs ?? {}) };
562
+ asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
563
+ }
564
+ const name = namer(m, `${a.part}.motion`);
565
+ let quantity = st.id;
566
+ if (a.motion.kind === 'slide' && st.unit === 'ratio') {
567
+ if (!a.travel)
568
+ fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
569
+ const travel = a.travel;
570
+ const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
571
+ const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
572
+ const id = name('distance');
573
+ m.nodes.push({ id, op: 'mul@1', args: [st.id, length], outputs: { value: `${id}.value` } });
574
+ quantity = `${id}.value`;
575
+ }
576
+ else if (a.motion.kind === 'slide' && a.travel)
577
+ fail('SCHEMA', path, 'a mm control is the distance; travel would say it twice');
578
+ const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? 1 : 0, 'axis'));
579
+ m.nodes.push({
580
+ id: `${a.part}.motion`,
581
+ op: a.motion.kind === 'slide' ? 'axis-slide@1' : 'axis-turn@1',
582
+ args: [...unit, quantity],
583
+ outputs: { pose: `${a.part}.motion.value` },
584
+ params: { from: `${a.part}.seat`, to: seatedIn }
585
+ });
586
+ reseat(asset, a.part, seatedIn);
587
+ if (a.clip) {
588
+ const c = a.clip;
589
+ if (typeof c.name !== 'string' || !c.name.trim())
590
+ fail('SCHEMA', path, 'a clip is named');
591
+ if (!(Number.isFinite(c.duration) && c.duration > 0))
592
+ fail('SCHEMA', path, 'a clip runs for a positive number of seconds');
593
+ asset.drivers = [...(asset.drivers ?? []), { id: `${a.part}/${c.name}`, clip: c.name, state: st.id, time: { unit: 's', duration: c.duration }, keys: structuredClone(c.keys), interpolation: 'linear', loop: c.loop ?? 'wrap', accumulates: c.accumulates ?? false }];
594
+ }
595
+ return asset;
596
+ }
597
+ /** "This part does not move any more." The control it created goes with it, and so does its clip. */
598
+ function removeMotion(asset, a) {
599
+ const m = asset.document.model;
600
+ const motion = nodeById(m, `${a.part}.motion`);
601
+ if (!motion)
602
+ fail('EDIT_TARGET', a.part, `${a.part} does not move`);
603
+ const quantity = motion.args[3];
604
+ const to = motion.params.to;
605
+ // Which control this motion introduced, read before the nodes that name it are taken away.
606
+ const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
607
+ ?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
608
+ m.nodes = m.nodes.filter((n) => n !== motion);
609
+ reseat(asset, a.part, to);
610
+ dropIfUnused(asset, quantity, quantity);
611
+ if (state && !m.nodes.some((n) => n.args.includes(state.id))) {
612
+ m.inputs = m.inputs.filter((i) => i !== state);
613
+ delete asset.stateDefaults[state.id];
614
+ if (asset.stateInputs)
615
+ delete asset.stateInputs[state.id];
616
+ if (asset.drivers)
617
+ asset.drivers = asset.drivers.filter((d) => d.state !== state.id);
618
+ if (asset.drivers && !asset.drivers.length)
619
+ delete asset.drivers;
620
+ if (asset.stateInputs && !Object.keys(asset.stateInputs).length)
621
+ delete asset.stateInputs;
622
+ }
623
+ return asset;
624
+ }
625
+ /** A figure as one value, for saying "this is the figure I read". The declaration itself is left out of it. */
626
+ function fingerprintOf(asset) {
627
+ const text = JSON.stringify({ ...asset, occupancy: asset.occupancy ? { ...asset.occupancy, bounds: null } : undefined });
628
+ let h1 = 0x811c9dc5;
629
+ let h2 = 0x01000193;
630
+ for (let i = 0; i < text.length; i++) {
631
+ h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
632
+ h2 = Math.imul(h2 + text.charCodeAt(i), 0x85ebca6b) >>> 0;
633
+ }
634
+ return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
635
+ }
636
+ const linAdd = (a, b, k = 1) => ({ terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))], c: a.c + b.c * k });
637
+ const linOf = (m, ref) => {
638
+ const constant = m.constants.find((c) => c.id === ref);
639
+ return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
640
+ };
641
+ /** How far a slide has gone when its control is at one end of its range. */
642
+ function travelAt(m, asset, motion, bound) {
643
+ const quantity = motion.args[3];
644
+ const direct = m.inputs.find((i) => i.id === quantity && i.role === 'state');
645
+ const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
646
+ if (direct)
647
+ return { terms: [], c: pick(direct) };
648
+ const writer = writerOf(m, quantity);
649
+ if (writer?.op === 'mul@1' && writer.args.length === 2) {
650
+ const state = m.inputs.find((i) => i.id === writer.args[0] && i.role === 'state');
651
+ if (state)
652
+ return { terms: [{ ref: writer.args[1], k: pick(state) }], c: 0 };
653
+ }
654
+ return null;
655
+ }
656
+ /** Where a part's centre is on one axis, as a sum, following whatever it is fastened to. */
657
+ function centreLin(m, asset, id, axis, bound) {
658
+ const i = AXES.indexOf(axis);
659
+ let out = { terms: [], c: 0 };
660
+ for (let part = id; part; part = parentOf(m, part)) {
661
+ const pose = nodeById(m, `${part}.pose`);
662
+ if (!pose)
663
+ return `${part} has no pose this command can read`;
664
+ for (const ref of pose.args.slice(3, 6))
665
+ if (angleOf(m, asset, ref) !== 0)
666
+ return `${part} is turned, so its reach is not a box this command can add up`;
667
+ out = linAdd(out, linOf(m, pose.args[i]));
668
+ const motion = nodeById(m, `${part}.motion`);
669
+ if (motion) {
670
+ if (motion.op !== 'axis-slide@1')
671
+ return `${part} turns, so its reach is not a box this command can add up`;
672
+ const unit = motion.args.slice(0, 3).map((ref) => m.constants.find((k) => k.id === ref)?.value ?? null);
673
+ if (unit.some((v) => v === null))
674
+ return `${part}'s slide axis is not a constant`;
675
+ const along = unit[i];
676
+ if (along !== 0) {
677
+ const travel = travelAt(m, asset, motion, bound);
678
+ if (!travel)
679
+ return `${part}'s travel is not a distance this command can read`;
680
+ out = linAdd(out, travel, along);
681
+ }
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+ /** The bounds the parts come to, as sums, with what could not be read named. */
687
+ function occupancyLins(asset, over) {
688
+ const m = asset.document.model;
689
+ const parts = [];
690
+ const skipped = [];
691
+ const low = { x: [], y: [], z: [] };
692
+ const high = { x: [], y: [], z: [] };
693
+ for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
694
+ let sizes;
695
+ try {
696
+ sizes = sizeRefsOf(m, place.id, place.id);
697
+ }
698
+ catch {
699
+ skipped.push({ part: place.id, reason: 'not a box-shaped part; this command measures boxes' });
700
+ continue;
701
+ }
702
+ /*
703
+ Both ends of every control are candidates, and which one is the low end is not decided here: a travel of
704
+ −40 mm puts the low end at the control's maximum (V3 designer's counterexample 2026-09-23, where the
705
+ proposal read −50..10 for a part that needs −90..50). Both candidates go into the same min and the same
706
+ max, so the answer holds whichever way the travel runs, and holds at every design size, because the
707
+ choice is made when the graph is evaluated rather than when the proposal is written.
708
+ */
709
+ const ends = over === 'rest' ? ['start'] : ['min', 'max'];
710
+ const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
711
+ const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
712
+ if (bad) {
713
+ skipped.push({ part: place.id, reason: String(bad) });
714
+ continue;
715
+ }
716
+ parts.push(place.id);
717
+ for (const r of readings) {
718
+ const half = { terms: [{ ref: sizes[r.axis], k: 0.5 }], c: 0 };
719
+ const seen = new Set();
720
+ for (const centre of r.at) {
721
+ const key = JSON.stringify([centre.terms.map(t => [t.ref, t.k]).sort(), centre.c]);
722
+ if (seen.has(key))
723
+ continue; // a part that does not move gives the same candidate twice
724
+ seen.add(key);
725
+ low[r.axis].push(linAdd(centre, half, -1));
726
+ high[r.axis].push(linAdd(centre, half, 1));
727
+ }
728
+ }
729
+ }
730
+ return { parts, skipped, low, high };
731
+ }
732
+ const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
733
+ /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
734
+ export function proposeV3Occupancy(asset, options = {}) {
735
+ const over = options.over ?? 'range';
736
+ if (over !== 'rest' && over !== 'range')
737
+ fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
738
+ const source = structuredClone(asset);
739
+ const { parts, skipped, low, high } = occupancyLins(source, over);
740
+ if (!parts.length)
741
+ fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
742
+ const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
743
+ const at = Object.fromEntries(AXES.map(axis => [axis, { min: round6(Math.min(...low[axis].map(l => valueOfLin(values, l)))), max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l)))) }]));
744
+ return { over, at, parts, skipped, basis: fingerprintOf(source) };
745
+ }
746
+ function declareOccupancy(asset, a) {
747
+ const m = asset.document.model;
748
+ if (!a.proposal || (a.proposal.over !== 'rest' && a.proposal.over !== 'range'))
749
+ fail('SCHEMA', 'occupancy', 'a proposal is required; propose first, then confirm');
750
+ if (!['floor', 'ceiling', 'center'].includes(a.placement))
751
+ fail('SCHEMA', 'occupancy', `${String(a.placement)} is not a placement`);
752
+ const now = fingerprintOf(asset);
753
+ if (a.proposal.basis !== now)
754
+ 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');
755
+ const { parts, low, high } = occupancyLins(asset, a.proposal.over);
756
+ if (parts.join('|') !== a.proposal.parts.join('|'))
757
+ 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`);
758
+ const name = namer(m, 'occupancy');
759
+ const emit = (l, hint) => {
760
+ if (!l.terms.length)
761
+ return constantOf(m, 'mm', round6(l.c), 'd');
762
+ let out = '';
763
+ for (const t of l.terms) {
764
+ const piece = scaled(m, name, t.ref, t.k, 0, hint);
765
+ if (!out)
766
+ out = piece;
767
+ else {
768
+ const id = name(`${hint}.sum`);
769
+ m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
770
+ out = `${id}.value`;
771
+ }
772
+ }
773
+ if (l.c !== 0) {
774
+ const id = name(`${hint}.offset`);
775
+ m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
776
+ out = `${id}.value`;
777
+ }
778
+ return out;
779
+ };
780
+ const pick = (ls, op, hint) => {
781
+ const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
782
+ if (refs.length === 1)
783
+ return refs[0];
784
+ const id = name(hint);
785
+ m.nodes.push({ id, op, args: refs, outputs: { value: `${id}.value` } });
786
+ return `${id}.value`;
787
+ };
788
+ const bounds = Object.fromEntries(AXES.map(axis => [axis, { min: pick(low[axis], 'min@1', `${axis}.min`), max: pick(high[axis], 'max@1', `${axis}.max`) }]));
789
+ asset.occupancy = { ...(asset.occupancy?.contact ? { contact: asset.occupancy.contact } : {}), bounds, placement: a.placement };
790
+ return asset;
791
+ }
792
+ /** "This face is what the figure is mounted on." Kept apart from how much room the figure takes up. */
793
+ function setMountingFace(asset, a) {
794
+ const m = asset.document.model;
795
+ if (!asset.occupancy)
796
+ fail('OCCUPANCY_UNDECLARED', 'occupancy', 'declare the room the figure takes up before naming the face it stands on');
797
+ if (!Object.hasOwn(V3_FACES, String(a.face)))
798
+ fail('SCHEMA', a.part, `${String(a.face)} is not a face`);
799
+ const { place } = partOf(m, a.part, a.part);
800
+ const id = `${a.part}.mount.${a.face}`;
801
+ if (!nodeById(m, id))
802
+ m.nodes.push({ id, op: 'feature@1', args: [place.args[0]], outputs: { pose: `${id}.value` }, params: { frame: `${id}.frame`, name: a.face } });
803
+ asset.occupancy.contact = { plane: 'mounting-plane', surface: { placement: place.outputs.placed, feature: `${id}.value` } };
804
+ return asset;
805
+ }
806
+ function clearMountingFace(asset) {
807
+ if (!asset.occupancy?.contact)
808
+ fail('EDIT_TARGET', 'occupancy', 'this figure names no mounting face');
809
+ delete asset.occupancy.contact;
810
+ return asset;
811
+ }
384
812
  //# sourceMappingURL=v3-authoring-actions.js.map