@hatiolab/figure-model 0.1.56 → 0.1.58

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,15 +22,21 @@
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
+ import { compileV3Graph, v3ShapeFaces } from "./v3-graph.js";
26
26
  import { V3ContractError } from "./v3-contract.js";
27
- import { AXES } from "./types.js";
27
+ import { AXES, REPEAT_LIMIT } from "./types.js";
28
28
  import { foldInstanceSizeRange, v3InstanceSizeLimitsOf, V3_INSTANCE_SIZE_DEFAULT, V3_INSTANCE_SIZE_INPUTS } from "./v3-instance-size.js";
29
29
  const fail = (code, path, message) => {
30
30
  throw new V3ContractError(code, path, message);
31
31
  };
32
32
  /** The six faces of a box-shaped part, and the axis each one faces along. */
33
33
  export const V3_FACES = { left: 'x', right: 'x', bottom: 'y', top: 'y', back: 'z', front: 'z' };
34
+ /**
35
+ * UsdPhysics's joint types (ADR-0093, amended 2026-09-25: the joint follows USD). A `fixed` joint is a fastening; a
36
+ * `revolute` joint turns -- with no limits it turns without end, its value a phase (-180..180), as USD's revolute
37
+ * joint without limits does; a `prismatic` joint slides.
38
+ */
39
+ export const V3_JOINT_TYPES = ['fixed', 'revolute', 'prismatic'];
34
40
  const SIGN = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 };
35
41
  const OPPOSITE = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' };
36
42
  const clone = (asset) => structuredClone(asset);
@@ -39,7 +45,9 @@ const writerOf = (m, ref) => m.nodes.find((n) => Object.values(n.outputs).includ
39
45
  /** A part an authoring command can work with, and the pieces of it those commands need. */
40
46
  function partOf(m, id, path) {
41
47
  const place = nodeById(m, id);
42
- if (!place || place.op !== 'place@1')
48
+ /* A row's template is the part too: it is placed through its row (`repeatPart`). */
49
+ const rowTemplate = place?.op === 'member@1' && !!nodeById(m, `${id}.repeat`);
50
+ if (!place || (place.op !== 'place@1' && !rowTemplate))
43
51
  fail('TARGET_ABSENT', path, `${id} is not a placed part`);
44
52
  const shape = writerOf(m, place.args[0]);
45
53
  if (!shape)
@@ -74,12 +82,103 @@ function angleOf(m, asset, ref) {
74
82
  return asset.designInputs[ref] ?? null;
75
83
  return null;
76
84
  }
85
+ /** The turn in a part's pose, in degrees about X, Y and Z, or null for an axis whose turn is neither a number nor a design value. */
86
+ function turnOf(m, asset, id, path) {
87
+ return partOf(m, id, path).pose.args.slice(3, 6).map((ref) => angleOf(m, asset, ref));
88
+ }
89
+ const saidTurn = (turn) => AXES.map((a, i) => `${a.toUpperCase()} ${turn[i] ?? '?'}°`).join(', ');
77
90
  /** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
78
91
  function requireUnturned(m, asset, id, path) {
79
- const { pose } = partOf(m, id, path);
80
- for (const ref of pose.args.slice(3, 6))
81
- if (angleOf(m, asset, ref) !== 0)
82
- fail('EDIT_TARGET', path, `${id} is turned; fastening a turned part by its faces is not covered by this command`);
92
+ const turn = turnOf(m, asset, id, path);
93
+ if (turn.some(t => t !== 0))
94
+ fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is not covered by this command`);
95
+ }
96
+ /**
97
+ * The part's size along each axis as it stands in its seat -- after its own turn. A turn by a whole number of
98
+ * quarter turns only swaps the axes (a cylinder laid on its side is 2r tall and its length deep), so its faces are
99
+ * still axis-aligned planes; any other turn is refused, and says the angle. The turn is then fixed as it is: a part
100
+ * fastened by its faces keeps the turn its faces were measured in (chief architect's ruling 2026-09-25).
101
+ */
102
+ /**
103
+ * The part's own turn as a signed axis permutation, when it is a whole number of quarter turns; any other turn is
104
+ * refused and says the angle. With `freeze` the turn is fixed as it is: a part fastened by its faces keeps the turn
105
+ * its faces were measured in (chief architect's ruling 2026-09-25).
106
+ */
107
+ function quarterTurnOf(m, asset, id, path, freeze) {
108
+ const turn = turnOf(m, asset, id, path);
109
+ if (turn.every(t => t === 0))
110
+ return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
111
+ if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
112
+ fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); only quarter turns keep its faces level with the axes`);
113
+ const r = turnMatrix(turn);
114
+ if (freeze) {
115
+ const pose = partOf(m, id, path).pose;
116
+ const old = pose.args.slice(3, 6);
117
+ pose.args = [...pose.args.slice(0, 3), ...turn.map(t => constantOf(m, 'deg', t, 'a'))];
118
+ // Its turn fields go with it: typing into them would change nothing.
119
+ for (const ref of old)
120
+ dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
121
+ }
122
+ return r;
123
+ }
124
+ /** A quarter turn in degrees about X, Y and Z as `rigid@1` turns (X, then Y, then Z), as a signed axis permutation. */
125
+ function turnMatrix(turn) {
126
+ const [rx, ry, rz] = turn.map(t => (t * Math.PI) / 180);
127
+ const cos = (a) => Math.round(Math.cos(a)), sin = (a) => Math.round(Math.sin(a));
128
+ const X = [[1, 0, 0], [0, cos(rx), -sin(rx)], [0, sin(rx), cos(rx)]];
129
+ const Y = [[cos(ry), 0, sin(ry)], [0, 1, 0], [-sin(ry), 0, cos(ry)]];
130
+ const Z = [[cos(rz), -sin(rz), 0], [sin(rz), cos(rz), 0], [0, 0, 1]];
131
+ return mul3(mul3(X, Y), Z);
132
+ }
133
+ const mul3 = (a, b) => a.map(row => b[0].map((_, j) => row.reduce((sum, v, k) => sum + v * b[k][j], 0)));
134
+ function standingRefsOf(m, asset, id, path, freeze = true) {
135
+ const own = sizeRefsOf(m, id, path);
136
+ const r = quarterTurnOf(m, asset, id, path, freeze);
137
+ const standing = {};
138
+ AXES.forEach((axis, i) => (standing[axis] = own[AXES[r[i].findIndex(v => v !== 0)]]));
139
+ return standing;
140
+ }
141
+ const neg = (lin) => lin.map(t => ({ ref: t.ref, k: -t.k }));
142
+ const half = (lin) => lin.map(t => ({ ref: t.ref, k: t.k / 2 }));
143
+ const OUTER = { x: ['left', 'right'], y: ['bottom', 'top'], z: ['back', 'front'] };
144
+ /** A part's declared faces, from its shape's own definition, or a refusal saying the shape has none. */
145
+ function facesOfPart(m, id, path) {
146
+ const { shape } = partOf(m, id, path);
147
+ const faces = v3ShapeFaces(shape.op);
148
+ if (!faces)
149
+ fail('EDIT_TARGET', path, `${id} is a ${shape.op}; it has no flat face to fasten by`);
150
+ return { shape, faces: faces };
151
+ }
152
+ const planeOf = (shape, face) => face.plane.map(p => ({ ref: shape.args[p.arg], k: p.k }));
153
+ /** The part's outer faces on each axis in its own frame; an axis without a pair of flat faces has none. */
154
+ function ownSpansOf(m, id, path) {
155
+ const { shape, faces } = facesOfPart(m, id, path);
156
+ const spans = {};
157
+ for (const axis of AXES) {
158
+ const [lo, hi] = OUTER[axis].map(n => faces[n]);
159
+ if (!lo || !hi)
160
+ continue;
161
+ const l = planeOf(shape, lo), h = planeOf(shape, hi);
162
+ spans[axis] = { lo: l, hi: h, centred: JSON.stringify(l) === JSON.stringify(neg(h)) };
163
+ }
164
+ return spans;
165
+ }
166
+ /**
167
+ * The part's spans as it stands in its seat, after its own quarter turn (the turn is fixed as `standingRefsOf` fixes
168
+ * it). A turn that flips an axis swaps its two faces: the part's own right face becomes the standing left.
169
+ */
170
+ function standingSpansOf(m, asset, id, path) {
171
+ const own = ownSpansOf(m, id, path);
172
+ const r = quarterTurnOf(m, asset, id, path, true);
173
+ const standing = {};
174
+ AXES.forEach((axis, i) => {
175
+ const j = r[i].findIndex(v => v !== 0);
176
+ const span = own[AXES[j]];
177
+ if (!span)
178
+ return;
179
+ standing[axis] = r[i][j] > 0 ? span : { lo: neg(span.hi), hi: neg(span.lo), centred: span.centred };
180
+ });
181
+ return standing;
83
182
  }
84
183
  /** Fresh node ids under a part, so two commands never claim one id. */
85
184
  function namer(m, prefix) {
@@ -228,6 +327,9 @@ function linkDimension(asset, a) {
228
327
  const was = node.args[0];
229
328
  const owned = ownInput(m, a.part, was) ? was : `${a.part}.size.${a.axis}`;
230
329
  node.args = [value, node.args[1]];
330
+ // Joints read this part's size; made again from their records, they read the size as it now is.
331
+ if (asset.joints?.length)
332
+ rewriteJoints(asset);
231
333
  // The part's own control is no longer read by anything; leaving it would show a dial that changes nothing.
232
334
  dropIfUnused(asset, was, owned);
233
335
  return asset;
@@ -258,6 +360,8 @@ function unlinkDimension(asset, a) {
258
360
  const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
259
361
  const was = node.args[0];
260
362
  node.args = [id, node.args[1]];
363
+ if (asset.joints?.length)
364
+ rewriteJoints(asset);
261
365
  dropIfUnused(asset, was);
262
366
  return asset;
263
367
  }
@@ -293,8 +397,8 @@ function dropIfUnused(asset, ref, ownedInput) {
293
397
  function parentOf(m, id) {
294
398
  if (!nodeById(m, `${id}.chain`))
295
399
  return null;
296
- // The seat's own frame leads either to a motion node or, when the part does not move, to an identity rigid.
297
- const above = nodeById(m, `${id}.motion`) ?? nodeById(m, `${id}.seat.rigid`);
400
+ // The seat rigid carries the part into the frame it is fastened to, moving or not (an old asset may have only a motion).
401
+ const above = nodeById(m, `${id}.seat.rigid`) ?? nodeById(m, `${id}.motion`);
298
402
  const match = /^(.*)\.local$/.exec(String(above?.params?.to));
299
403
  return match && match[1] !== id ? match[1] : null;
300
404
  }
@@ -308,58 +412,92 @@ function attach(asset, a) {
308
412
  return standOnPlane(asset, a, path);
309
413
  if (a.part === to.part)
310
414
  fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
311
- for (const face of [a.face, to.face])
312
- if (!Object.hasOwn(V3_FACES, String(face)))
313
- fail('SCHEMA', path, `${String(face)} is not a face`);
415
+ if (!Object.hasOwn(V3_FACES, String(a.face)))
416
+ fail('SCHEMA', path, `${String(a.face)} is not a face`);
417
+ /* The target's faces are the ones its shape declares: a portal's opening as well as its outer faces. */
418
+ const target = facesOfPart(m, to.part, path);
419
+ const theirFace = Object.hasOwn(target.faces, String(to.face)) ? target.faces[to.face] : undefined;
420
+ if (!theirFace)
421
+ fail('SCHEMA', path, `${to.part} has no face ${String(to.face)}; it has ${Object.keys(target.faces).join(', ')}`);
314
422
  const facing = a.facing ?? 'meet';
315
423
  if (facing !== 'meet' && facing !== 'flush')
316
424
  fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
317
- const axis = V3_FACES[to.face];
425
+ const axis = theirFace.axis;
426
+ const sign = theirFace.normal;
318
427
  if (V3_FACES[a.face] !== axis)
319
428
  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`);
320
- if (facing === 'meet' && a.face !== OPPOSITE[to.face])
321
- 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`);
322
- if (facing === 'flush' && a.face !== to.face)
323
- fail('ATTACH_FACE', path, `to lie flush with ${to.face}, ${a.part} offers its ${to.face}`);
429
+ const wanted = Object.keys(V3_FACES).find(f => V3_FACES[f] === axis && SIGN[f] === (facing === 'meet' ? -sign : sign));
430
+ if (facing === 'meet' && a.face !== wanted)
431
+ fail('ATTACH_FACE', path, `to meet ${to.face}, ${a.part} offers its ${wanted}; pass flush to lay ${a.face} in the same plane instead`);
432
+ if (facing === 'flush' && a.face !== wanted)
433
+ fail('ATTACH_FACE', path, `to lie flush with ${to.face}, ${a.part} offers its ${wanted}`);
324
434
  if (parentOf(m, a.part) && !a.replace)
325
435
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
326
436
  // A part cannot end up its own ancestor.
327
437
  for (let up = to.part; up; up = parentOf(m, up))
328
438
  if (up === a.part)
329
439
  fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
330
- const mine = sizeRefsOf(m, a.part, path);
331
- const theirs = sizeRefsOf(m, to.part, path);
332
- requireUnturned(m, asset, a.part, path);
333
- requireUnturned(m, asset, to.part, path);
440
+ /*
441
+ Measured in the target's own frame, where its faces are the planes its shape declares: the seat is written into
442
+ that frame. A target standing turned is met there too -- the joint record's words are turned into that frame
443
+ before this runs (`writeJoint`).
444
+ */
445
+ const theirs = ownSpansOf(m, to.part, path);
446
+ const mine = standingSpansOf(m, asset, a.part, path);
447
+ const mineAlong = mine[axis];
448
+ if (!mineAlong)
449
+ fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
334
450
  const name = namer(m, `${a.part}.on.${to.part}`);
335
- const sign = SIGN[to.face];
336
451
  /*
337
452
  Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
338
453
  gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
339
454
  */
340
455
  const outward = facing === 'meet' ? sign : -sign;
341
456
  const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
457
+ /*
458
+ Split at the point fastened at: the seat is on the target's face (its centre, or level with an edge), the part's
459
+ centre is half its own depth and the gap past it. A turn added later turns about that point.
460
+ */
461
+ const zero = constantOf(m, 'mm', 0, 'd');
342
462
  const translation = { x: '', y: '', z: '' };
343
- translation[axis] = sumOf(m, name, [
344
- { ref: theirs[axis].ref, k: sign * 0.5 * theirs[axis].k },
345
- { ref: mine[axis].ref, k: outward * 0.5 * mine[axis].k },
346
- { ref: gap, k: outward }
347
- ], `along.${axis}`);
348
- // Across the face. Centred by default; min and max keep the two parts' edges level as either is resized.
463
+ const offset = { x: zero, y: zero, z: zero };
464
+ translation[axis] = sumOf(m, name, planeOf(target.shape, theirFace), `seat.${axis}`);
465
+ const ownFace = SIGN[a.face] > 0 ? mineAlong.hi : mineAlong.lo;
466
+ offset[axis] = sumOf(m, name, [...neg(ownFace), { ref: gap, k: outward }], `along.${axis}`);
467
+ /*
468
+ Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
469
+ point fastened at is where those edges meet: the seat on the target's edge, the part's own edge on it. A door
470
+ fastened level with the frame's min edge turns about that edge, as a hinge does (2026-09-25); centred, the point
471
+ is the middle of the face. A shape not centred on its origin (a portal stands on its bottom) is centred by its
472
+ faces, not by its origin.
473
+ */
349
474
  for (const other of AXES.filter(x => x !== axis)) {
350
475
  const how = a.align?.[other] ?? 'centre';
351
- if (how === 'centre')
352
- translation[other] = constantOf(m, 'mm', 0, 'd');
353
- else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
354
- translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
476
+ const t = theirs[other], o = mine[other];
477
+ const middle = (span) => (span && !span.centred ? [...half(span.lo), ...half(span.hi)] : []);
478
+ const centreOf = (span, hint) => (middle(span).length ? sumOf(m, name, middle(span), hint) : constantOf(m, 'mm', 0, 'd'));
479
+ const ownCentre = () => (middle(o).length ? sumOf(m, name, neg(middle(o)), `own-centre.${other}`) : zero);
480
+ if (how === 'centre') {
481
+ translation[other] = centreOf(t, `centre.${other}`);
482
+ offset[other] = ownCentre();
483
+ }
484
+ else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm')) {
485
+ const mm = measureRef(m, asset, name, how.mm, `align.${other}`, path);
486
+ translation[other] = middle(t).length ? sumOf(m, name, [...middle(t), { ref: mm, k: 1 }], `align.${other}.from-centre`) : mm;
487
+ offset[other] = ownCentre();
488
+ }
355
489
  else if (how === 'min' || how === 'max') {
356
- const s = how === 'min' ? -1 : 1;
357
- translation[other] = sumOf(m, name, [{ ref: theirs[other].ref, k: s * 0.5 * theirs[other].k }, { ref: mine[other].ref, k: -s * 0.5 * mine[other].k }], `align.${other}`);
490
+ if (!t)
491
+ fail('ATTACH_FACE', path, `${to.part} has no edges along ${other} to line ${a.part} up with; give the centre or a measured offset`);
492
+ if (!o)
493
+ fail('ATTACH_FACE', path, `${a.part} has no edges along ${other} to line up; give the centre or a measured offset`);
494
+ translation[other] = sumOf(m, name, how === 'min' ? t.lo : t.hi, `edge.${other}`);
495
+ offset[other] = sumOf(m, name, neg(how === 'min' ? o.lo : o.hi), `own-edge.${other}`);
358
496
  }
359
497
  else
360
498
  fail('SCHEMA', path, `${String(how)} is not an alignment`);
361
499
  }
362
- reseat(asset, a.part, `${to.part}.local`, [translation.x, translation.y, translation.z]);
500
+ reseat(asset, a.part, `${to.part}.local`, { seat: [translation.x, translation.y, translation.z], pose: [offset.x, offset.y, offset.z] });
363
501
  return asset;
364
502
  }
365
503
  /**
@@ -377,13 +515,16 @@ function standOnPlane(asset, a, path) {
377
515
  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`);
378
516
  if (parentOf(m, a.part) && !a.replace)
379
517
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
380
- const mine = sizeRefsOf(m, a.part, path);
381
- requireUnturned(m, asset, a.part, path);
518
+ const mine = standingSpansOf(m, asset, a.part, path);
519
+ if (!mine.y)
520
+ fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
382
521
  const name = namer(m, `${a.part}.on.plane`);
383
522
  const outward = a.face === 'bottom' ? 1 : -1;
384
523
  const gap = measureRef(m, asset, name, a.gap, 'gap.y', path);
385
524
  const translation = { x: '', y: '', z: '' };
386
- translation.y = sumOf(m, name, [{ ref: mine.y.ref, k: outward * 0.5 * mine.y.k }, { ref: gap, k: outward }], 'stand.y');
525
+ translation.y = sumOf(m, name, [...neg(a.face === 'bottom' ? mine.y.lo : mine.y.hi), { ref: gap, k: outward }], 'stand.y');
526
+ const zero = constantOf(m, 'mm', 0, 'd');
527
+ const pose = { x: zero, y: translation.y, z: zero };
387
528
  for (const other of ['x', 'z']) {
388
529
  const how = a.align?.[other] ?? 'centre';
389
530
  if (how === 'centre')
@@ -392,8 +533,13 @@ function standOnPlane(asset, a, path) {
392
533
  translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
393
534
  else
394
535
  fail('SCHEMA', path, `the plane has no edges to line ${a.part} up with on ${other}; give the centre or a measured offset`);
536
+ /* A shape not centred on its origin stands centred by its faces. */
537
+ const span = mine[other];
538
+ if (span && !span.centred)
539
+ pose[other] = sumOf(m, name, neg([...half(span.lo), ...half(span.hi)]), `own-centre.${other}`);
395
540
  }
396
- reseat(asset, a.part, asset.document.capabilities.assetFrame, [translation.x, translation.y, translation.z]);
541
+ // The point it stands on is on the plane, under its centre: a turn about Y turns it where it stands.
542
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat: [translation.x, zero, translation.z], pose: [pose.x, pose.y, pose.z] });
397
543
  return asset;
398
544
  }
399
545
  function detach(asset, a) {
@@ -434,8 +580,15 @@ function detach(asset, a) {
434
580
  The part gets its own position numbers back, as a part made in the modeller has them, so it can be moved again
435
581
  by hand or by the gizmo. Where one of those names is taken, that axis stays a plain number.
436
582
  */
583
+ /*
584
+ A part that moves keeps turning about the point it was fastened at: that point becomes its seat in the asset, its
585
+ own numbers the offset from it. A part that does not move gets its place back as plain position numbers.
586
+ */
587
+ const seatAt = evaluated.values?.[`${a.part}.seat.value`]?.t ?? [0, 0, 0];
588
+ const moving = !!nodeById(m, `${a.part}.motion`);
589
+ const pivot = AXES.map((_, i) => round6(above.t[i] + seatAt[i]));
437
590
  const translation = AXES.map((axis, i) => {
438
- const value = round6(above.t[i] + own.t[i]);
591
+ const value = moving ? round6(own.t[i]) : round6(pivot[i] + own.t[i]);
439
592
  const id = `${a.part}.position.${axis}`;
440
593
  if (m.inputs.some((input) => input.id === id) || m.nodes.some((n) => Object.values(n.outputs).includes(id)))
441
594
  return constantOf(m, 'mm', value, 'd');
@@ -443,7 +596,8 @@ function detach(asset, a) {
443
596
  asset.designInputs[id] = value;
444
597
  return id;
445
598
  });
446
- reseat(asset, a.part, asset.document.capabilities.assetFrame, translation);
599
+ const seat = AXES.map((_, i) => constantOf(m, 'mm', moving ? pivot[i] : 0, 'd'));
600
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat, pose: translation });
447
601
  return asset;
448
602
  }
449
603
  const round6 = (v) => Math.round(v * 1e6) / 1e6;
@@ -451,36 +605,55 @@ const round6 = (v) => Math.round(v * 1e6) / 1e6;
451
605
  * Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
452
606
  * The part's own id, its shape and its `placed` reference all stay as they were.
453
607
  */
454
- function reseat(asset, id, to, translation) {
608
+ /**
609
+ * Where a part is fastened, in two translations that are never merged: `seat` is the point it is fastened at, in the
610
+ * frame it is fastened to -- a point on the target's face; `pose` is the part's centre from that point, in its own
611
+ * seat frame. A motion sits between the two, so a turn turns about the point the part is fastened at: a hinge, a
612
+ * joint, a lid (chief architect's ruling 2026-09-25; before this the seat was the target's centre and a fastened
613
+ * part swung about the middle of what it was fastened to).
614
+ *
615
+ * <id>.local --pose--> <id>.seat [--motion--> <id>.joint] --seat.rigid--> <to>
616
+ *
617
+ * Without `place` the part keeps both translations and only its frames are rewired (a motion added or taken away).
618
+ */
619
+ function reseat(asset, id, to, place) {
455
620
  const m = asset.document.model;
456
- const { place, pose } = partOf(m, id, id);
457
- const old = translation ? pose.args.slice(0, 3) : [];
458
- if (translation)
459
- pose.args = [...translation, ...pose.args.slice(3)];
621
+ const { place: placed, pose } = partOf(m, id, id);
622
+ const old = place ? pose.args.slice(0, 3) : [];
623
+ if (place)
624
+ pose.args = [...place.pose, ...pose.args.slice(3)];
460
625
  pose.params = { ...pose.params, to: `${id}.seat` };
461
626
  const motion = nodeById(m, `${id}.motion`);
462
- if (motion)
463
- motion.params = { ...motion.params, from: `${id}.seat`, to };
464
- // seat → the frame it is fastened to. Without a motion node the seat is that frame, through an identity rigid.
465
- let chain = nodeById(m, `${id}.chain`);
466
- if (!chain) {
467
- chain = { id: `${id}.chain`, op: 'compose@1', args: ['', pose.outputs.pose], outputs: { pose: `${id}.chain.value` } };
468
- m.nodes.push(chain);
469
- }
627
+ const zero = constantOf(m, 'mm', 0, 'd');
628
+ const noTurn = constantOf(m, 'deg', 0, 'a');
470
629
  let seat = nodeById(m, `${id}.seat.rigid`);
471
- if (!motion) {
472
- const zero = constantOf(m, 'mm', 0, 'd');
473
- const noTurn = constantOf(m, 'deg', 0, 'a');
474
- if (!seat) {
475
- seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: { from: `${id}.seat`, to } };
476
- m.nodes.push(seat);
630
+ if (!seat) {
631
+ seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: {} };
632
+ m.nodes.push(seat);
633
+ }
634
+ const oldSeat = place ? seat.args.slice(0, 3) : [];
635
+ if (place)
636
+ seat.args = [...place.seat, ...seat.args.slice(3)];
637
+ seat.params = { from: motion ? `${id}.joint` : `${id}.seat`, to };
638
+ // The motion turns the seat frame about its origin -- the point fastened at -- into the joint frame.
639
+ let turned = nodeById(m, `${id}.turned`);
640
+ if (motion) {
641
+ motion.params = { ...motion.params, from: `${id}.seat`, to: `${id}.joint` };
642
+ if (!turned) {
643
+ turned = { id: `${id}.turned`, op: 'compose@1', args: [motion.outputs.pose, pose.outputs.pose], outputs: { pose: `${id}.turned.value` } };
644
+ m.nodes.push(turned);
477
645
  }
478
646
  else
479
- seat.params = { ...seat.params, to };
480
- chain.args = [seat.outputs.pose, pose.outputs.pose];
647
+ turned.args = [motion.outputs.pose, pose.outputs.pose];
481
648
  }
482
- else
483
- chain.args = [motion.outputs.pose, pose.outputs.pose];
649
+ else if (turned)
650
+ m.nodes = m.nodes.filter((n) => n !== turned);
651
+ let chain = nodeById(m, `${id}.chain`);
652
+ if (!chain) {
653
+ chain = { id: `${id}.chain`, op: 'compose@1', args: ['', ''], outputs: { pose: `${id}.chain.value` } };
654
+ m.nodes.push(chain);
655
+ }
656
+ chain.args = [seat.outputs.pose, motion ? turned.outputs.pose : pose.outputs.pose];
484
657
  const parent = /^(.*)\.local$/.exec(to);
485
658
  let world = nodeById(m, `${id}.world`);
486
659
  if (parent && parent[1] !== id) {
@@ -491,18 +664,18 @@ function reseat(asset, id, to, translation) {
491
664
  }
492
665
  else
493
666
  world.args = [above, chain.outputs.pose];
494
- place.args = [place.args[0], world.outputs.pose];
667
+ placed.args = [placed.args[0], world.outputs.pose];
495
668
  }
496
669
  else {
497
670
  if (world)
498
671
  m.nodes = m.nodes.filter((n) => n !== world);
499
- place.args = [place.args[0], chain.outputs.pose];
672
+ placed.args = [placed.args[0], chain.outputs.pose];
500
673
  }
501
674
  /*
502
675
  The part's own position numbers, once nothing reads them, go with the old pose: a fastened part left its
503
676
  position fields in the editor, and typing into them changed nothing (seen on :3300, 2026-09-24).
504
677
  */
505
- for (const ref of old)
678
+ for (const ref of [...old, ...oldSeat])
506
679
  dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
507
680
  }
508
681
  /** Apply one authoring command. Throws on any refusal, leaving the asset it was given untouched. */
@@ -519,16 +692,20 @@ export function applyV3Authoring(source, action) {
519
692
  unlinkDimension(asset, action);
520
693
  break;
521
694
  case 'attach':
522
- attach(asset, action);
695
+ fastenAsJoint(asset, action);
523
696
  break;
524
- case 'detach':
525
- detach(asset, action);
697
+ case 'detach': {
698
+ const joint = jointAbove(asset, action.part);
699
+ if (!joint)
700
+ fail('EDIT_TARGET', action.part, `${action.part} is not fastened to anything`);
701
+ removeJoint(asset, joint.id);
526
702
  break;
527
- case 'add-motion':
528
- addMotion(asset, action);
703
+ }
704
+ case 'set-joint':
705
+ setJoint(asset, action.joint);
529
706
  break;
530
- case 'remove-motion':
531
- removeMotion(asset, action);
707
+ case 'remove-joint':
708
+ removeJoint(asset, action.id);
532
709
  break;
533
710
  case 'declare-occupancy':
534
711
  declareOccupancy(asset, action);
@@ -559,6 +736,22 @@ export function applyV3Authoring(source, action) {
559
736
  case 'set-repeat':
560
737
  setRepeat(asset, action);
561
738
  break;
739
+ case 'make-repeat': {
740
+ const j = (asset.joints ?? []).find(o => o.body1.part === action.part);
741
+ if (!j)
742
+ fail('EDIT_TARGET', `${action.part}.repeat`, `${action.part} is not fastened to anything; fasten it to the part it repeats across first`);
743
+ const count = action.count === undefined || action.count === 'fills' ? undefined : action.count;
744
+ setJoint(asset, { ...j, repeat: { axis: String(action.axis).toUpperCase(), pitch: action.pitch, ...(count !== undefined ? { count } : {}) } });
745
+ break;
746
+ }
747
+ case 'remove-repeat': {
748
+ const j = (asset.joints ?? []).find(o => o.body1.part === action.part);
749
+ if (!j?.repeat)
750
+ fail('EDIT_TARGET', `${action.part}.repeat`, `${action.part} is not repeated`);
751
+ const { repeat: _gone, ...once } = j;
752
+ setJoint(asset, once);
753
+ break;
754
+ }
562
755
  case 'nudge-part':
563
756
  nudgePart(asset, action);
564
757
  break;
@@ -581,6 +774,9 @@ export function applyV3Authoring(source, action) {
581
774
  default:
582
775
  fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
583
776
  }
777
+ /* The joint records are the canon: whatever the command reshaped, their nodes are made again from them. */
778
+ if (asset.joints?.length && action.kind !== 'set-joint' && action.kind !== 'remove-joint')
779
+ rewriteJoints(asset);
584
780
  compileV3Asset(asset);
585
781
  holdsOverDeclaredSize(asset, source);
586
782
  return asset;
@@ -683,12 +879,457 @@ export function v3AttachmentsOf(asset) {
683
879
  out[n.id] = parentOf(m, n.id);
684
880
  return out;
685
881
  }
882
+ /* ------------------------------------------------------------------ joints */
883
+ /** The joint whose child is this part, if any. */
884
+ function jointAbove(asset, part) {
885
+ return asset.joints?.find(j => j.body1.part === part);
886
+ }
887
+ const stateIdOf = (j) => j.state?.id ?? j.id;
888
+ /** A record that is well formed on its own, and fits the tree the others make. */
889
+ function checkJoint(asset, j) {
890
+ const m = asset.document.model;
891
+ const path = `joints.${String(j?.id)}`;
892
+ if (!j || typeof j.id !== 'string' || !j.id.trim())
893
+ fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
894
+ if (!V3_JOINT_TYPES.includes(j.type))
895
+ fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${V3_JOINT_TYPES.join(', ')}`);
896
+ if (!j.body1 || typeof j.body1.part !== 'string')
897
+ fail('JOINT_SCHEMA', path, 'a joint carries a part (body1)');
898
+ partOf(m, j.body1.part, path);
899
+ if (!j.body0 || (!('plane' in j.body0) && typeof j.body0.part !== 'string'))
900
+ fail('JOINT_SCHEMA', path, 'a joint hangs from a part or the mounting plane (body0)');
901
+ if ('part' in j.body0)
902
+ partOf(m, j.body0.part, path);
903
+ const others = (asset.joints ?? []).filter(o => o.id !== j.id);
904
+ const above = others.find(o => o.body1.part === j.body1.part);
905
+ if (above)
906
+ fail('JOINT_TREE', path, `${j.body1.part} already hangs from ${'part' in above.body0 ? above.body0.part : 'the mounting plane'} by ${above.id}; a part has one joint above it`);
907
+ if ('part' in j.body0) {
908
+ const parentOfPart = (part) => others.find(o => o.body1.part === part)?.body0;
909
+ for (let up = j.body0.part; up;) {
910
+ if (up === j.body1.part)
911
+ fail('ATTACH_CYCLE', path, `${j.body0.part} already hangs from ${j.body1.part}; ${j.body1.part} would hang from itself`);
912
+ const next = parentOfPart(up);
913
+ up = next && 'part' in next ? next.part : undefined;
914
+ }
915
+ }
916
+ const moving = j.type !== 'fixed';
917
+ const limited = j.lowerLimit !== undefined || j.upperLimit !== undefined;
918
+ if (moving && !['X', 'Y', 'Z'].includes(j.axis))
919
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z`);
920
+ if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
921
+ fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
922
+ if (j.travel !== undefined && j.type !== 'prismatic')
923
+ fail('JOINT_SCHEMA', path, 'only a prismatic joint travels a length');
924
+ if (j.type === 'prismatic' && j.travel && !limited) {
925
+ j.lowerLimit = 0;
926
+ j.upperLimit = 1;
927
+ }
928
+ /* A revolute joint without limits turns without end (USD); with them, both are given. A slide always has them. */
929
+ if (moving && !j.mimic && (limited || j.type === 'prismatic')) {
930
+ const [lo, hi] = [j.lowerLimit, j.upperLimit];
931
+ if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
932
+ fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
933
+ if (!(lo <= 0 && 0 <= hi))
934
+ fail('JOINT_SCHEMA', path, 'the rest pose is the joint at 0, so its limits hold 0');
935
+ }
936
+ if (j.mimic) {
937
+ const leader = others.find(o => o.id === j.mimic.joint);
938
+ if (!leader || leader.type === 'fixed')
939
+ fail('JOINT_SCHEMA', path, `${String(j.mimic.joint)} is not a moving joint to follow`);
940
+ if (leader.mimic)
941
+ fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
942
+ if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
943
+ fail('JOINT_SCHEMA', path, 'a joint follows one that moves the same way: a slide a slide, a turn a turn');
944
+ if (!Number.isFinite(j.mimic.multiplier))
945
+ fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
946
+ }
947
+ /* A part in a row is its copies: nothing can hang from one place of it. */
948
+ const repeatedParent = 'part' in j.body0 ? others.find(o => o.repeat && o.body1.part === j.body0.part) : undefined;
949
+ if (repeatedParent)
950
+ fail('JOINT_SCHEMA', path, `${repeatedParent.body1.part} is repeated; its copies have no one place for ${j.body1.part} to hang from`);
951
+ if (j.repeat) {
952
+ const r = j.repeat;
953
+ const row = `${path}.repeat`;
954
+ if (j.type !== 'fixed')
955
+ fail('JOINT_SCHEMA', row, 'a part in a row does not move on a joint of its own (ADR-0093); fasten it fixed to repeat it');
956
+ if (!('part' in j.body0))
957
+ fail('JOINT_SCHEMA', row, 'a row fills the part it is fastened to; the mounting plane has no length to fill');
958
+ if (!['X', 'Y', 'Z'].includes(r.axis))
959
+ fail('JOINT_SCHEMA', row, `a row runs along X, Y or Z, not ${String(r.axis)}`);
960
+ if (r.axis.toLowerCase() === V3_FACES[j.body1.face])
961
+ fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
962
+ if (!(Number.isFinite(r.pitch) && r.pitch > 0))
963
+ fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch');
964
+ if (r.count !== undefined && !(Number.isSafeInteger(r.count) && r.count >= 1 && r.count <= REPEAT_LIMIT))
965
+ fail('GEOMETRY_DOMAIN', row, `a row has 1 to ${REPEAT_LIMIT} copies`);
966
+ const held = others.filter(o => 'part' in o.body0 && o.body0.part === j.body1.part).map(o => o.body1.part);
967
+ if (held.length)
968
+ fail('JOINT_SCHEMA', row, `${j.body1.part} holds ${held.join(', ')}; a part in a row holds nothing`);
969
+ if (others.some(o => o.mimic?.joint === j.id))
970
+ fail('JOINT_SCHEMA', row, `a joint follows ${j.id}; a part in a row does not move`);
971
+ }
972
+ }
973
+ /**
974
+ * Write a joint's nodes from its record: the child's quarter turn, the fastening (the seat on the parent's face and
975
+ * the child's offset from it), and for a moving joint the turn or slide at the seat. The node ids are the child's and
976
+ * are chosen the same way each time, so writing a record again gives the same nodes.
977
+ */
978
+ function writeJoint(asset, j) {
979
+ const m = asset.document.model;
980
+ const child = j.body1.part;
981
+ const { pose } = partOf(m, child, child);
982
+ /*
983
+ The record speaks as the parts stand: `top` is up, the axis is the figure's. The seat is written in the parent's
984
+ own frame, so under a parent standing a quarter turn the record's words are turned into that frame, and the child
985
+ is given the turn back so it keeps its own (빈틈 A, 2026-09-25: a body on a pipe laid on its side).
986
+ */
987
+ const parent = 'part' in j.body0 ? j.body0.part : undefined;
988
+ const back = parent ? transpose3(standingTurnOf(asset, parent, `joints.${j.id}`)) : IDENTITY;
989
+ const turned = !isIdentity(back);
990
+ const own = AXES.map(a => j.origin?.turn?.[a] ?? 0);
991
+ const turn = turned ? eulerOf(mul3(back, turnMatrix(own))) : own;
992
+ const old = pose.args.slice(3, 6);
993
+ pose.args = [...pose.args.slice(0, 3), ...turn.map(t => constantOf(m, 'deg', t, 'a'))];
994
+ for (const ref of old)
995
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
996
+ const face = (name) => (turned && Object.hasOwn(FACE_VECTOR, name) ? faceAlong(mv3(back, FACE_VECTOR[name])) : name);
997
+ attach(asset, {
998
+ kind: 'attach',
999
+ part: child,
1000
+ face: face(j.body1.face),
1001
+ to: 'plane' in j.body0 ? { plane: 'mounting-plane' } : { part: j.body0.part, face: face(j.body0.face) },
1002
+ ...(j.origin?.facing ? { facing: j.origin.facing } : {}),
1003
+ ...(j.origin?.gap !== undefined ? { gap: j.origin.gap } : {}),
1004
+ ...(j.origin?.align ? { align: turned ? alignInto(back, j.origin.align, `joints.${j.id}`) : j.origin.align } : {}),
1005
+ replace: true
1006
+ });
1007
+ if (j.repeat) {
1008
+ if (turned)
1009
+ fail('JOINT_SCHEMA', `joints.${j.id}.repeat`, `${parent} stands turned; a row across a turned part is not covered yet`);
1010
+ return repeatPart(asset, j);
1011
+ }
1012
+ if (j.type === 'fixed')
1013
+ return;
1014
+ const kind = j.type === 'prismatic' ? 'slide' : 'turn';
1015
+ const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
1016
+ const range = j.lowerLimit !== undefined && j.upperLimit !== undefined ? { min: j.lowerLimit, max: j.upperLimit } : j.type === 'revolute' ? { min: -180, max: 180 } : { min: 0, max: 1 };
1017
+ const leader = j.mimic && (asset.joints ?? []).find(o => o.id === j.mimic.joint);
1018
+ /* The axis turned into the parent's frame too: one of its axes, either way along it. */
1019
+ const along = mv3(back, AXES.map(a => (a === j.axis.toLowerCase() ? 1 : 0)));
1020
+ const axisIndex = along.findIndex(v => v !== 0);
1021
+ addMotion(asset, {
1022
+ kind: 'add-motion',
1023
+ part: child,
1024
+ motion: { kind, axis: AXES[axisIndex] },
1025
+ state: { id: stateIdOf(j), unit, min: range.min, max: range.max, start: j.state?.start ?? 0, ...(j.state?.label !== undefined ? { label: j.state.label } : {}), ...(j.state?.sweep !== undefined ? { sweep: j.state.sweep } : {}) },
1026
+ ...(j.travel ? { travel: j.travel } : {}),
1027
+ replace: true
1028
+ }, leader ? { of: stateIdOf(leader), k: j.mimic.multiplier, c: j.mimic.offset ?? 0 } : undefined, along[axisIndex] > 0 ? 1 : -1);
1029
+ }
1030
+ const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
1031
+ const isIdentity = (r) => r.every((row, i) => row.every((v, k) => v === (i === k ? 1 : 0)));
1032
+ const transpose3 = (r) => r[0].map((_, i) => r.map(row => row[i]));
1033
+ const mv3 = (r, v) => r.map(row => row.reduce((sum, x, k) => sum + x * v[k], 0));
1034
+ const FACE_VECTOR = { left: [-1, 0, 0], right: [1, 0, 0], bottom: [0, -1, 0], top: [0, 1, 0], back: [0, 0, -1], front: [0, 0, 1] };
1035
+ const faceAlong = (v) => Object.keys(FACE_VECTOR).find(f => FACE_VECTOR[f].every((x, i) => x === v[i]));
1036
+ /**
1037
+ * How a part stands, as a signed axis permutation: its own quarter turn, which its joint record keeps (the pose may
1038
+ * carry the turn back given under a turned parent). A part with no joint stands as its pose turns it. Any turn but
1039
+ * quarter turns is refused and says the angle: nothing can be fastened level with its faces.
1040
+ */
1041
+ function standingTurnOf(asset, part, path) {
1042
+ const record = (asset.joints ?? []).find(o => o.body1.part === part);
1043
+ const turn = record ? AXES.map(a => record.origin?.turn?.[a] ?? 0) : turnOf(asset.document.model, asset, part, path);
1044
+ if (turn.every(t => t === 0))
1045
+ return IDENTITY;
1046
+ if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
1047
+ fail('EDIT_TARGET', path, `${part} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is covered only for quarter turns`);
1048
+ return turnMatrix(turn);
1049
+ }
1050
+ /** A quarter-turn matrix back as degrees about X, Y and Z in the order `rigid@1` turns. */
1051
+ function eulerOf(r) {
1052
+ const deg = (a) => {
1053
+ const d = Math.round((a * 180) / Math.PI / 90) * 90;
1054
+ return d === 0 ? 0 : d;
1055
+ };
1056
+ const out = Math.abs(r[0][2]) < 1
1057
+ ? [deg(Math.atan2(-r[1][2], r[2][2])), deg(Math.asin(r[0][2])), deg(Math.atan2(-r[0][1], r[0][0]))]
1058
+ : [deg(Math.atan2(r[1][0], r[1][1])), deg(Math.asin(r[0][2])), 0];
1059
+ if (!turnMatrix(out).every((row, i) => row.every((v, k) => v === r[i][k])))
1060
+ throw new Error(`no turn gives ${JSON.stringify(r)}`);
1061
+ return out;
1062
+ }
1063
+ /** Where a part sits across a face, said on the standing axes, turned onto the parent's own: an edge flips where an axis does. */
1064
+ function alignInto(back, align, path) {
1065
+ const out = {};
1066
+ for (const [a, how] of Object.entries(align)) {
1067
+ const v = mv3(back, AXES.map(x => (x === a ? 1 : 0)));
1068
+ const k = v.findIndex(x => x !== 0);
1069
+ const s = v[k];
1070
+ const to = AXES[k];
1071
+ if (how === 'centre' || s > 0)
1072
+ out[to] = how;
1073
+ else if (how === 'min' || how === 'max')
1074
+ out[to] = how === 'min' ? 'max' : 'min';
1075
+ else if (typeof how.mm === 'number')
1076
+ out[to] = { mm: -how.mm };
1077
+ else
1078
+ fail('SCHEMA', path, `an offset that follows another value cannot be turned over onto ${to}; give it as a number`);
1079
+ }
1080
+ return out;
1081
+ }
1082
+ /**
1083
+ * A fastened part made a row (r3 §8): its placement becomes the template of a `repeat@1`. The row starts at the
1084
+ * parent's lower edge along the axis and runs to its upper edge, so its length is the parent's and follows it; each
1085
+ * copy stands as the fastening put the part, moved along the axis. The part keeps its id, as the row's template.
1086
+ */
1087
+ function repeatPart(asset, j) {
1088
+ const m = asset.document.model;
1089
+ const part = j.body1.part;
1090
+ const parent = j.body0.part;
1091
+ const path = `joints.${j.id}.repeat`;
1092
+ const axis = j.repeat.axis.toLowerCase();
1093
+ const k = AXES.indexOf(axis);
1094
+ const theirs = ownSpansOf(m, parent, path)[axis];
1095
+ if (!theirs)
1096
+ fail('EDIT_TARGET', path, `${parent} has no two faces along ${axis}; there is no length to fill`);
1097
+ const mine = standingSpansOf(m, asset, part, path)[axis];
1098
+ if (!mine)
1099
+ fail('EDIT_TARGET', path, `${part} has no two faces along ${axis}; its copies cannot be spaced`);
1100
+ const { place, pose } = partOf(m, part, path);
1101
+ const seat = nodeById(m, `${part}.seat.rigid`);
1102
+ const name = namer(m, `${part}.row`);
1103
+ const zero = constantOf(m, 'mm', 0, 'd');
1104
+ /* The seat moves to the row's start; each copy's centre is where the layout puts it from there. */
1105
+ seat.args = seat.args.map((ref, i) => (i === k ? sumOf(m, name, theirs.lo, 'start') : ref));
1106
+ const own = mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
1107
+ pose.args = pose.args.map((ref, i) => (i === k ? own : ref));
1108
+ const length = sumOf(m, name, [...theirs.hi, ...neg(theirs.lo)], 'length');
1109
+ const extent = sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
1110
+ const centred = constantOf(m, 'ratio', 0.5, 'k');
1111
+ const limit = constantOf(m, 'count', REPEAT_LIMIT, 'limit');
1112
+ const count = j.repeat.count;
1113
+ m.nodes.push(count === undefined
1114
+ ? { id: `${part}.layout`, op: 'fit-pitch@1', args: [length, zero, zero, extent, constantOf(m, 'mm', j.repeat.pitch, 'pitch'), centred, limit], outputs: { layout: `${part}.layout.value` } }
1115
+ : { id: `${part}.layout`, op: 'fixed-count@1', args: [constantOf(m, 'count', count, 'count'), length, zero, zero, extent, centred, limit], outputs: { layout: `${part}.layout.value` } });
1116
+ /* The part is the row's template now: a member, drawn only through the row. */
1117
+ place.op = 'member@1';
1118
+ place.args = [place.args[0], pose.outputs.pose];
1119
+ m.nodes.push({ id: `${part}.assembly`, op: 'assembly@1', args: [place.outputs.placed], outputs: { assembly: `${part}.assembly.value` } });
1120
+ const above = nodeById(m, `${parent}.world`)?.outputs?.pose ?? nodeById(m, `${parent}.chain`)?.outputs?.pose;
1121
+ let root = seat.outputs.pose;
1122
+ if (above) {
1123
+ m.nodes.push({ id: `${part}.row`, op: 'compose@1', args: [above, seat.outputs.pose], outputs: { pose: `${part}.row.value` } });
1124
+ root = `${part}.row.value`;
1125
+ }
1126
+ m.nodes = m.nodes.filter((n) => n.id !== `${part}.chain` && n.id !== `${part}.world`);
1127
+ m.nodes.push({ id: `${part}.repeat`, op: 'repeat@1', args: [`${part}.layout.value`, `${part}.assembly.value`, root], outputs: { collection: `${part}.repeat.value` }, params: { axis } });
1128
+ }
1129
+ /** A row made back into its one part, so its fastening can be taken away or written again. */
1130
+ function unrepeatPart(asset, part) {
1131
+ const m = asset.document.model;
1132
+ const template = nodeById(m, part);
1133
+ if (template?.op !== 'member@1' || !nodeById(m, `${part}.repeat`))
1134
+ return;
1135
+ const layout = nodeById(m, `${part}.layout`);
1136
+ const row = new Set([`${part}.layout`, `${part}.assembly`, `${part}.repeat`, `${part}.row`]);
1137
+ const gone = m.nodes.filter((n) => row.has(n.id) || n.id.startsWith(`${part}.row.`));
1138
+ m.nodes = m.nodes.filter((n) => !gone.includes(n));
1139
+ template.op = 'place@1';
1140
+ for (const ref of layout?.args ?? [])
1141
+ dropIfUnused(asset, ref);
1142
+ }
1143
+ /** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
1144
+ function stripJoint(asset, j) {
1145
+ const m = asset.document.model;
1146
+ const child = j.body1.part;
1147
+ unrepeatPart(asset, child);
1148
+ if (nodeById(m, `${child}.motion`))
1149
+ removeMotion(asset, { kind: 'remove-motion', part: child });
1150
+ const { pose, place } = partOf(m, child, child);
1151
+ const owned = new Set([`${child}.seat.rigid`, `${child}.chain`, `${child}.world`, `${child}.turned`]);
1152
+ m.nodes = m.nodes.filter((n) => !owned.has(n.id) && !n.id.startsWith(`${child}.on.`));
1153
+ const zero = constantOf(m, 'mm', 0, 'd');
1154
+ const old = pose.args.slice(0, 3);
1155
+ pose.args = [zero, zero, zero, ...pose.args.slice(3)];
1156
+ pose.params = { ...pose.params, to: asset.document.capabilities.assetFrame };
1157
+ // Its own position numbers, if it still had them, place nothing now: the joint places it.
1158
+ for (const ref of old)
1159
+ dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
1160
+ place.args = [place.args[0], pose.outputs.pose];
1161
+ }
1162
+ /** Joints in an order each can be written in: its parent's joint and the joint it mimics first. */
1163
+ function writingOrder(joints) {
1164
+ const done = new Set();
1165
+ const out = [];
1166
+ const byChild = new Map(joints.map(j => [j.body1.part, j]));
1167
+ while (out.length < joints.length) {
1168
+ const ready = joints.filter(j => {
1169
+ if (done.has(j.id))
1170
+ return false;
1171
+ const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
1172
+ return (!up || done.has(up.id)) && (!j.mimic || done.has(j.mimic.joint));
1173
+ });
1174
+ if (!ready.length)
1175
+ fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
1176
+ for (const j of ready) {
1177
+ done.add(j.id);
1178
+ out.push(j);
1179
+ }
1180
+ }
1181
+ return out;
1182
+ }
1183
+ /** Every joint's nodes again, from the records: the ones below first away, then all written parents first. */
1184
+ function rewriteJoints(asset) {
1185
+ const joints = (asset.joints ?? []);
1186
+ const order = writingOrder(joints);
1187
+ const keep = { ...asset.stateDefaults };
1188
+ for (const j of [...order].reverse())
1189
+ stripJoint(asset, j);
1190
+ for (const j of order)
1191
+ writeJoint(asset, j);
1192
+ // A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
1193
+ for (const j of order)
1194
+ if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
1195
+ asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
1196
+ }
1197
+ /*
1198
+ * A part edit that renames or removes a part reads the joint records first: the records are the canon and the joint
1199
+ * nodes are made from them (ADR-0093). Renaming or deleting a part in the graph alone left records naming a part that
1200
+ * was no longer there, and the author could not save (TARGET_ABSENT on joints.*, 2026-09-25).
1201
+ */
1202
+ /** The records follow a part's new name -- as a joint's parent, as its child, and as the joint a follower follows -- and the nodes are made again from them. */
1203
+ export function renameV3PartInJoints(asset, from, to) {
1204
+ const joints = asset.joints;
1205
+ if (!joints?.length)
1206
+ return;
1207
+ const name = (id) => (id === from ? to : id);
1208
+ asset.joints = joints.map(j => ({
1209
+ ...j,
1210
+ id: name(j.id),
1211
+ body0: 'part' in j.body0 ? { ...j.body0, part: name(j.body0.part) } : j.body0,
1212
+ body1: { ...j.body1, part: name(j.body1.part) },
1213
+ ...(j.mimic ? { mimic: { ...j.mimic, joint: name(j.mimic.joint) } } : {}),
1214
+ ...(j.state?.id !== undefined ? { state: { ...j.state, id: name(j.state.id) } } : {})
1215
+ }));
1216
+ rewriteJoints(asset);
1217
+ }
1218
+ /**
1219
+ * Before a part is taken away. What hangs from it, or follows its joint, is named and the removal refused -- a
1220
+ * silent cascade would take parts the author did not pick. A part nothing hangs from goes with its own joint.
1221
+ */
1222
+ export function releaseV3PartJoints(asset, part) {
1223
+ const joints = (asset.joints ?? []);
1224
+ const hanging = joints.filter(j => 'part' in j.body0 && j.body0.part === part).map(j => j.body1.part);
1225
+ if (hanging.length)
1226
+ fail('PART_REFERENCED', part, `${part} holds ${hanging.join(', ')}; take ${hanging.length > 1 ? 'them' : 'it'} off first`);
1227
+ const own = joints.find(j => j.body1.part === part);
1228
+ if (!own)
1229
+ return;
1230
+ const followers = joints.filter(o => o.mimic?.joint === own.id).map(o => o.body1.part);
1231
+ if (followers.length)
1232
+ fail('PART_REFERENCED', part, `${followers.join(', ')} ${followers.length > 1 ? 'move' : 'moves'} with ${part}; make ${followers.length > 1 ? 'them' : 'it'} move on ${followers.length > 1 ? 'their' : 'its'} own first`);
1233
+ asset.joints = joints.filter(j => j.id !== own.id);
1234
+ if (!asset.joints.length)
1235
+ delete asset.joints;
1236
+ stripJoint(asset, own);
1237
+ }
1238
+ function setJoint(asset, joint) {
1239
+ // As JSON: a field left undefined is a field not there, which is how the asset stores it.
1240
+ const j = JSON.parse(JSON.stringify(joint ?? null));
1241
+ checkJoint(asset, j);
1242
+ /* The child's own quarter turn becomes the record's, the first time it is fastened. */
1243
+ if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
1244
+ const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
1245
+ if (turn.some(t => t !== 0))
1246
+ j.origin = { ...(j.origin ?? {}), turn: Object.fromEntries(AXES.map((a, i) => [a, turn[i]])) };
1247
+ }
1248
+ const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
1249
+ const followers = joints.filter(o => o.mimic?.joint === j.id);
1250
+ if (followers.length && j.type === 'fixed')
1251
+ fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
1252
+ asset.joints = [...joints, j];
1253
+ rewriteJoints(asset);
1254
+ }
1255
+ /** The joint goes; its child keeps where it stands now, as plain position numbers. */
1256
+ function removeJoint(asset, id) {
1257
+ const joints = (asset.joints ?? []);
1258
+ const j = joints.find(o => o.id === id);
1259
+ if (!j)
1260
+ fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
1261
+ const followers = joints.filter(o => o.mimic?.joint === id);
1262
+ if (followers.length)
1263
+ fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
1264
+ const m = asset.document.model;
1265
+ const child = j.body1.part;
1266
+ const evaluated = compileV3Asset(asset).evaluate();
1267
+ const placed = nodeById(m, child);
1268
+ const at = evaluated.values?.[placed.args[1]];
1269
+ if (!at?.t)
1270
+ fail('TARGET_ABSENT', child, `${child} has no pose to read`);
1271
+ const own = m.nodes.find((n) => n.id === `${child}.pose`);
1272
+ const turned = (r, want) => r.some((row, i) => row.some((v, k) => Math.abs(v - want[i][k]) > 1e-9));
1273
+ const ownTurn = evaluated.values?.[own.outputs.pose]?.r;
1274
+ if (ownTurn && turned(at.r, ownTurn))
1275
+ fail('EDIT_TARGET', child, `${child} stands turned by what it hangs from; taking the joint away would turn it`);
1276
+ asset.joints = joints.filter(o => o.id !== id);
1277
+ stripJoint(asset, j);
1278
+ const translation = AXES.map((axis, i) => {
1279
+ const input = `${child}.position.${axis}`;
1280
+ const value = round6(at.t[i]);
1281
+ if (!m.inputs.some((n) => n.id === input))
1282
+ m.inputs.push({ id: input, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
1283
+ asset.designInputs[input] = value;
1284
+ return input;
1285
+ });
1286
+ own.args = [...translation, ...own.args.slice(3)];
1287
+ // Parts hanging from it are written again: they hang from where it stands now.
1288
+ rewriteJoints(asset);
1289
+ }
1290
+ /** `attach` is the modeller's word for a fixed joint: the record says it, whatever joint was there keeps its id and motion. */
1291
+ function fastenAsJoint(asset, a) {
1292
+ const was = jointAbove(asset, a.part);
1293
+ if (was && !a.replace)
1294
+ fail('ATTACH_REPLACED', a.part, `${a.part} is already fastened by ${was.id}; pass replace to move it, or detach it first`);
1295
+ const to = a.to;
1296
+ setJoint(asset, {
1297
+ ...(was ?? { id: a.part, type: 'fixed' }),
1298
+ body0: to?.plane !== undefined ? { plane: to.plane } : { part: to?.part, face: to?.face },
1299
+ body1: { part: a.part, face: a.face },
1300
+ origin: {
1301
+ ...(was?.origin?.turn ? { turn: was.origin.turn } : {}),
1302
+ ...(a.facing ? { facing: a.facing } : {}),
1303
+ ...(a.gap !== undefined ? { gap: a.gap } : {}),
1304
+ ...(a.align ? { align: a.align } : {})
1305
+ }
1306
+ });
1307
+ }
1308
+ /**
1309
+ * The records are the canon: made again from them, the joints' nodes must be the nodes the asset holds. An asset
1310
+ * whose nodes or records were edited apart is refused, not quietly rebuilt (ADR-0093, chief architect's condition).
1311
+ */
1312
+ export function checkV3Joints(asset) {
1313
+ const joints = asset.joints;
1314
+ if (!joints?.length)
1315
+ return;
1316
+ const again = structuredClone(asset);
1317
+ for (const j of joints)
1318
+ checkJoint({ ...again, joints: joints.filter(o => o.id !== j.id).concat(j) }, j);
1319
+ rewriteJoints(again);
1320
+ const canon = (model) => JSON.stringify({
1321
+ nodes: [...model.nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
1322
+ inputs: [...model.inputs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
1323
+ });
1324
+ if (canon(again.document.model) !== canon(asset.document.model))
1325
+ fail('JOINT_DRIFT', 'joints', 'the joints\' nodes are not what their records make; the asset was edited apart from its joints');
1326
+ }
686
1327
  /** The frame a part is fastened into: its parent's, or the asset's. */
687
1328
  const attachmentFrameOf = (asset, id) => {
688
1329
  const parent = parentOf(asset.document.model, id);
689
1330
  return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
690
1331
  };
691
- function addMotion(asset, a) {
1332
+ function addMotion(asset, a, follow, sign = 1) {
692
1333
  const m = asset.document.model;
693
1334
  const path = a.part;
694
1335
  partOf(m, a.part, path);
@@ -707,26 +1348,40 @@ function addMotion(asset, a) {
707
1348
  else if (frame !== 'attachment' && frame !== 'asset')
708
1349
  fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
709
1350
  const st = a.state;
710
- if (!st || typeof st.id !== 'string' || !st.id.trim())
711
- fail('SCHEMA', path, 'a state input id is required');
712
- if (m.inputs.some((i) => i.id === st.id))
713
- fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
714
- const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
715
- if (!wanted.includes(st.unit))
716
- fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
717
- if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
718
- fail('SCHEMA', path, 'a range with max above min is required');
719
- const start = st.start ?? st.min;
720
- if (!(start >= st.min && start <= st.max))
721
- fail('SCHEMA', path, 'the starting value is outside the range');
722
- m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
723
- asset.stateDefaults[st.id] = start;
724
- if (st.label !== undefined || st.sweep !== undefined) {
725
- asset.stateInputs = { ...(asset.stateInputs ?? {}) };
726
- asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
727
- }
728
1351
  const name = namer(m, `${a.part}.motion`);
729
- let quantity = st.id;
1352
+ let quantity = st?.id;
1353
+ if (follow) {
1354
+ /* A mimic: the leader's value times k plus c, and no control of its own. */
1355
+ const scaledId = name('mimic');
1356
+ m.nodes.push({ id: scaledId, op: 'mul@1', args: [follow.of, constantOf(m, 'ratio', follow.k, 'k')], outputs: { value: `${scaledId}.value` } });
1357
+ quantity = `${scaledId}.value`;
1358
+ if (follow.c) {
1359
+ const plus = name('mimic.offset');
1360
+ const unitOf = m.inputs.find((i) => i.id === follow.of)?.unit ?? 'deg';
1361
+ m.nodes.push({ id: plus, op: 'add@1', args: [quantity, constantOf(m, unitOf, follow.c, 'd')], outputs: { value: `${plus}.value` } });
1362
+ quantity = `${plus}.value`;
1363
+ }
1364
+ }
1365
+ else {
1366
+ if (!st || typeof st.id !== 'string' || !st.id.trim())
1367
+ fail('SCHEMA', path, 'a state input id is required');
1368
+ if (m.inputs.some((i) => i.id === st.id))
1369
+ fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
1370
+ const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
1371
+ if (!wanted.includes(st.unit))
1372
+ fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
1373
+ if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
1374
+ fail('SCHEMA', path, 'a range with max above min is required');
1375
+ const start = st.start ?? st.min;
1376
+ if (!(start >= st.min && start <= st.max))
1377
+ fail('SCHEMA', path, 'the starting value is outside the range');
1378
+ m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
1379
+ asset.stateDefaults[st.id] = start;
1380
+ if (st.label !== undefined || st.sweep !== undefined) {
1381
+ asset.stateInputs = { ...(asset.stateInputs ?? {}) };
1382
+ asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
1383
+ }
1384
+ }
730
1385
  if (a.motion.kind === 'slide' && st.unit === 'ratio') {
731
1386
  if (!a.travel)
732
1387
  fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
@@ -734,12 +1389,12 @@ function addMotion(asset, a) {
734
1389
  const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
735
1390
  const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
736
1391
  const id = name('distance');
737
- m.nodes.push({ id, op: 'mul@1', args: [st.id, length], outputs: { value: `${id}.value` } });
1392
+ m.nodes.push({ id, op: 'mul@1', args: [quantity, length], outputs: { value: `${id}.value` } });
738
1393
  quantity = `${id}.value`;
739
1394
  }
740
1395
  else if (a.motion.kind === 'slide' && a.travel)
741
1396
  fail('SCHEMA', path, 'a mm control is the distance; travel would say it twice');
742
- const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? 1 : 0, 'axis'));
1397
+ const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? sign : 0, 'axis'));
743
1398
  m.nodes.push({
744
1399
  id: `${a.part}.motion`,
745
1400
  op: a.motion.kind === 'slide' ? 'axis-slide@1' : 'axis-turn@1',
@@ -765,7 +1420,7 @@ function removeMotion(asset, a) {
765
1420
  if (!motion)
766
1421
  fail('EDIT_TARGET', a.part, `${a.part} does not move`);
767
1422
  const quantity = motion.args[3];
768
- const to = motion.params.to;
1423
+ const to = nodeById(m, `${a.part}.seat.rigid`)?.params?.to ?? motion.params.to;
769
1424
  // Which control this motion introduced, read before the nodes that name it are taken away.
770
1425
  const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
771
1426
  ?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
@@ -798,22 +1453,53 @@ function fingerprintOf(asset) {
798
1453
  return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
799
1454
  }
800
1455
  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 });
1456
+ /** The same sum with each reference once. */
1457
+ const linNorm = (a) => {
1458
+ const by = new Map();
1459
+ for (const t of a.terms)
1460
+ by.set(t.ref, (by.get(t.ref) ?? 0) + t.k);
1461
+ return { terms: [...by].filter(([, k]) => k !== 0).map(([ref, k]) => ({ ref, k })), c: a.c };
1462
+ };
1463
+ /** A sum at least as large as either, where every reference is a size (never below zero): term by term, the larger. */
1464
+ const linAtLeast = (a, b) => {
1465
+ const by = new Map();
1466
+ for (const t of [...linNorm(a).terms, ...linNorm(b).terms])
1467
+ by.set(t.ref, Math.max(by.get(t.ref) ?? 0, t.k));
1468
+ return { terms: [...by].map(([ref, k]) => ({ ref, k })), c: Math.max(a.c, b.c) };
1469
+ };
801
1470
  const linOf = (m, ref) => {
802
1471
  const constant = m.constants.find((c) => c.id === ref);
803
1472
  return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
804
1473
  };
805
1474
  /** How far a slide has gone when its control is at one end of its range. */
806
1475
  function travelAt(m, asset, motion, bound) {
807
- const quantity = motion.args[3];
808
- const direct = m.inputs.find((i) => i.id === quantity && i.role === 'state');
809
1476
  const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
810
- if (direct)
811
- return { terms: [], c: pick(direct) };
1477
+ /* A control's value, or a mimic's: the leader's value times a constant, plus a constant. */
1478
+ const valueOf = (ref) => {
1479
+ const state = m.inputs.find((i) => i.id === ref && i.role === 'state');
1480
+ if (state)
1481
+ return pick(state);
1482
+ const w = writerOf(m, ref);
1483
+ const constant = (r) => m.constants.find((c) => c.id === r)?.value;
1484
+ if (w?.op === 'mul@1' && w.args.length === 2 && constant(w.args[1]) !== undefined) {
1485
+ const v = valueOf(w.args[0]);
1486
+ return v === null ? null : v * constant(w.args[1]);
1487
+ }
1488
+ if (w?.op === 'add@1' && w.args.length === 2 && constant(w.args[1]) !== undefined) {
1489
+ const v = valueOf(w.args[0]);
1490
+ return v === null ? null : v + constant(w.args[1]);
1491
+ }
1492
+ return null;
1493
+ };
1494
+ const quantity = motion.args[3];
1495
+ const direct = valueOf(quantity);
1496
+ if (direct !== null)
1497
+ return { terms: [], c: direct };
812
1498
  const writer = writerOf(m, quantity);
813
1499
  if (writer?.op === 'mul@1' && writer.args.length === 2) {
814
- const state = m.inputs.find((i) => i.id === writer.args[0] && i.role === 'state');
815
- if (state)
816
- return { terms: [{ ref: writer.args[1], k: pick(state) }], c: 0 };
1500
+ const v = valueOf(writer.args[0]);
1501
+ if (v !== null)
1502
+ return { terms: [{ ref: writer.args[1], k: v }], c: 0 };
817
1503
  }
818
1504
  return null;
819
1505
  }
@@ -825,10 +1511,15 @@ function centreLin(m, asset, id, axis, bound) {
825
1511
  const pose = nodeById(m, `${part}.pose`);
826
1512
  if (!pose)
827
1513
  return `${part} has no pose this command can read`;
828
- for (const ref of pose.args.slice(3, 6))
829
- if (angleOf(m, asset, ref) !== 0)
830
- return `${part} is turned, so its reach is not a box this command can add up`;
1514
+ /* A part's own turn turns it about its centre; a turn above it would turn everything below. */
1515
+ if (part !== id)
1516
+ for (const ref of pose.args.slice(3, 6))
1517
+ if (angleOf(m, asset, ref) !== 0)
1518
+ return `${part} is turned, so its reach is not a box this command can add up`;
831
1519
  out = linAdd(out, linOf(m, pose.args[i]));
1520
+ const seat = nodeById(m, `${part}.seat.rigid`);
1521
+ if (seat)
1522
+ out = linAdd(out, linOf(m, seat.args[i]));
832
1523
  const motion = nodeById(m, `${part}.motion`);
833
1524
  if (motion) {
834
1525
  if (motion.op !== 'axis-slide@1')
@@ -847,6 +1538,161 @@ function centreLin(m, asset, id, axis, bound) {
847
1538
  }
848
1539
  return out;
849
1540
  }
1541
+ /** A value written as a sum of design inputs times constants, or null where it is anything else. */
1542
+ function affineOf(m, ref, seen = 0) {
1543
+ if (seen > 64)
1544
+ return null;
1545
+ const constant = m.constants.find((c) => c.id === ref);
1546
+ if (constant)
1547
+ return { terms: [], c: constant.value };
1548
+ if (m.inputs.some((i) => i.id === ref))
1549
+ return { terms: [{ ref, k: 1 }], c: 0 };
1550
+ const writer = writerOf(m, ref);
1551
+ if (writer?.op === 'add@1') {
1552
+ let out = { terms: [], c: 0 };
1553
+ for (const arg of writer.args) {
1554
+ const part = affineOf(m, arg, seen + 1);
1555
+ if (!part)
1556
+ return null;
1557
+ out = linAdd(out, part);
1558
+ }
1559
+ return out;
1560
+ }
1561
+ if (writer?.op === 'mul@1' && writer.args.length === 2) {
1562
+ const [a, b] = writer.args.map((arg) => affineOf(m, arg, seen + 1));
1563
+ if (!a || !b)
1564
+ return null;
1565
+ if (!a.terms.length)
1566
+ return linAdd({ terms: [], c: 0 }, b, a.c);
1567
+ if (!b.terms.length)
1568
+ return linAdd({ terms: [], c: 0 }, a, b.c);
1569
+ }
1570
+ return null;
1571
+ }
1572
+ /**
1573
+ * How long a value can be, at most, as a sum that holds at every design size: each input's coefficient taken
1574
+ * positive. Sound only over inputs that are never below zero (sizes), so any other input makes it unreadable.
1575
+ */
1576
+ function magnitudeOf(m, lin) {
1577
+ let out = { terms: [], c: 0 };
1578
+ for (const t of lin.terms) {
1579
+ const a = affineOf(m, t.ref);
1580
+ if (!a)
1581
+ return null;
1582
+ for (const u of a.terms) {
1583
+ const input = m.inputs.find((i) => i.id === u.ref);
1584
+ if (!input || input.role === 'state' || !(input.min >= 0))
1585
+ return null;
1586
+ out = linAdd(out, { terms: [{ ref: u.ref, k: Math.abs(u.k * t.k) }], c: 0 });
1587
+ }
1588
+ out.c += Math.abs(a.c * t.k);
1589
+ }
1590
+ out.c += Math.abs(lin.c);
1591
+ return out;
1592
+ }
1593
+ /**
1594
+ * Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
1595
+ * every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
1596
+ * inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
1597
+ * ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
1598
+ * the parts against it.
1599
+ */
1600
+ function reachOf(m, asset, id, freezeless = true) {
1601
+ const chain = [];
1602
+ for (let part = id; part; part = parentOf(m, part))
1603
+ chain.push(part);
1604
+ const top = chain.reduce((found, part, i) => (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1' ? i : found), -1);
1605
+ if (top < 0)
1606
+ return `${id} is carried by no turn`;
1607
+ let radius = { terms: [], c: 0 };
1608
+ const grow = (lin, what) => {
1609
+ const size = lin && magnitudeOf(m, lin);
1610
+ if (!size)
1611
+ return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
1612
+ radius = linAdd(radius, size);
1613
+ return null;
1614
+ };
1615
+ const sizes = standingRefsOf(m, asset, id, id, !freezeless);
1616
+ for (const axis of AXES) {
1617
+ const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
1618
+ if (bad)
1619
+ return bad;
1620
+ }
1621
+ for (let i = 0; i <= top; i++) {
1622
+ const part = chain[i];
1623
+ const pose = nodeById(m, `${part}.pose`);
1624
+ if (!pose)
1625
+ return `${part} has no pose this command can read`;
1626
+ for (const ref of pose.args.slice(0, 3)) {
1627
+ const bad = grow(linOf(m, ref), `${part}'s place`);
1628
+ if (bad)
1629
+ return bad;
1630
+ }
1631
+ if (i < top) {
1632
+ const seat = nodeById(m, `${part}.seat.rigid`);
1633
+ for (const ref of seat?.args.slice(0, 3) ?? []) {
1634
+ const bad = grow(linOf(m, ref), `where ${part} is fastened`);
1635
+ if (bad)
1636
+ return bad;
1637
+ }
1638
+ const motion = nodeById(m, `${part}.motion`);
1639
+ if (motion?.op === 'axis-slide@1')
1640
+ for (const end of ['min', 'max']) {
1641
+ const bad = grow(travelAt(m, asset, motion, end), `${part}'s travel`);
1642
+ if (bad)
1643
+ return bad;
1644
+ }
1645
+ }
1646
+ }
1647
+ // The pivot: where the topmost turn is fastened, and everything above it, which neither turns nor slides.
1648
+ const pivotPart = chain[top];
1649
+ const centre = {};
1650
+ for (const axis of AXES) {
1651
+ const k = AXES.indexOf(axis);
1652
+ let at = { terms: [], c: 0 };
1653
+ const seat = nodeById(m, `${pivotPart}.seat.rigid`);
1654
+ if (seat)
1655
+ at = linAdd(at, linOf(m, seat.args[k]));
1656
+ for (let part = parentOf(m, pivotPart); part; part = parentOf(m, part)) {
1657
+ const pose = nodeById(m, `${part}.pose`);
1658
+ if (!pose)
1659
+ return `${part} has no pose this command can read`;
1660
+ for (const ref of pose.args.slice(3, 6))
1661
+ if (angleOf(m, asset, ref) !== 0)
1662
+ return `${part} is turned, so where ${pivotPart} turns is not a point this command can add up`;
1663
+ if (nodeById(m, `${part}.motion`))
1664
+ return `${part} moves, and so does the point ${pivotPart} turns about`;
1665
+ at = linAdd(at, linOf(m, pose.args[k]));
1666
+ const above = nodeById(m, `${part}.seat.rigid`);
1667
+ if (above)
1668
+ at = linAdd(at, linOf(m, above.args[k]));
1669
+ }
1670
+ centre[axis] = at;
1671
+ }
1672
+ return { centre, radius };
1673
+ }
1674
+ /** A sum written as graph nodes: each term scaled, then added up, then the constant. */
1675
+ function emitLin(m, name, l, hint) {
1676
+ if (!l.terms.length)
1677
+ return constantOf(m, 'mm', round6(l.c), 'd');
1678
+ let out = '';
1679
+ for (const t of l.terms) {
1680
+ const piece = scaled(m, name, t.ref, t.k, 0, hint);
1681
+ if (!out)
1682
+ out = piece;
1683
+ else {
1684
+ const id = name(`${hint}.sum`);
1685
+ m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
1686
+ out = `${id}.value`;
1687
+ }
1688
+ }
1689
+ if (l.c !== 0) {
1690
+ const id = name(`${hint}.offset`);
1691
+ m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
1692
+ out = `${id}.value`;
1693
+ }
1694
+ return out;
1695
+ }
850
1696
  /** The bounds the parts come to, as sums, with what could not be read named. */
851
1697
  function occupancyLins(asset, over) {
852
1698
  const m = asset.document.model;
@@ -854,13 +1700,34 @@ function occupancyLins(asset, over) {
854
1700
  const skipped = [];
855
1701
  const low = { x: [], y: [], z: [] };
856
1702
  const high = { x: [], y: [], z: [] };
1703
+ /* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
1704
+ const balls = new Map();
857
1705
  for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
858
1706
  let sizes;
859
1707
  try {
860
- sizes = sizeRefsOf(m, place.id, place.id);
1708
+ sizes = standingRefsOf(m, asset, place.id, place.id, false);
861
1709
  }
862
- catch {
863
- skipped.push({ part: place.id, reason: 'not a box-shaped part; this command measures boxes' });
1710
+ catch (e) {
1711
+ skipped.push({ part: place.id, reason: e instanceof V3ContractError ? e.message : 'not a part whose size this command can read' });
1712
+ continue;
1713
+ }
1714
+ const turns = (() => {
1715
+ for (let part = place.id; part; part = parentOf(m, part))
1716
+ if (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1')
1717
+ return true;
1718
+ return false;
1719
+ })();
1720
+ if (turns) {
1721
+ const reach = reachOf(m, asset, place.id);
1722
+ if (typeof reach === 'string') {
1723
+ skipped.push({ part: place.id, reason: reach });
1724
+ continue;
1725
+ }
1726
+ parts.push(place.id);
1727
+ const centre = Object.fromEntries(AXES.map(a => [a, linNorm(reach.centre[a])]));
1728
+ const key = JSON.stringify(centre);
1729
+ const ball = balls.get(key);
1730
+ balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
864
1731
  continue;
865
1732
  }
866
1733
  /*
@@ -891,7 +1758,7 @@ function occupancyLins(asset, over) {
891
1758
  }
892
1759
  }
893
1760
  }
894
- return { parts, skipped, low, high };
1761
+ return { parts, skipped, low, high, balls: [...balls.values()] };
895
1762
  }
896
1763
  const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
897
1764
  /** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
@@ -900,7 +1767,12 @@ export function proposeV3Occupancy(asset, options = {}) {
900
1767
  if (over !== 'rest' && over !== 'range')
901
1768
  fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
902
1769
  const source = structuredClone(asset);
903
- const { parts, skipped, low, high } = occupancyLins(source, over);
1770
+ const { parts, skipped, low, high, balls } = occupancyLins(source, over);
1771
+ for (const ball of balls)
1772
+ for (const axis of AXES) {
1773
+ low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
1774
+ high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
1775
+ }
904
1776
  if (!parts.length)
905
1777
  fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
906
1778
  const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
@@ -923,7 +1795,15 @@ function declareOccupancy(asset, a) {
923
1795
  fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
924
1796
  padding[axis] = pad;
925
1797
  }
926
- const { parts, low, high } = occupancyLins(asset, a.proposal.over);
1798
+ const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
1799
+ const name0 = namer(m, 'occupancy.reach');
1800
+ balls.forEach((ball, b) => {
1801
+ const radius = emitLin(m, name0, ball.radius, `${b}`);
1802
+ for (const axis of AXES) {
1803
+ low[axis].push(linAdd(ball.centre[axis], { terms: [{ ref: radius, k: 1 }], c: 0 }, -1));
1804
+ high[axis].push(linAdd(ball.centre[axis], { terms: [{ ref: radius, k: 1 }], c: 0 }, 1));
1805
+ }
1806
+ });
927
1807
  for (const axis of AXES) {
928
1808
  if (!padding[axis])
929
1809
  continue;
@@ -933,27 +1813,7 @@ function declareOccupancy(asset, a) {
933
1813
  if (parts.join('|') !== a.proposal.parts.join('|'))
934
1814
  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`);
935
1815
  const name = namer(m, 'occupancy');
936
- const emit = (l, hint) => {
937
- if (!l.terms.length)
938
- return constantOf(m, 'mm', round6(l.c), 'd');
939
- let out = '';
940
- for (const t of l.terms) {
941
- const piece = scaled(m, name, t.ref, t.k, 0, hint);
942
- if (!out)
943
- out = piece;
944
- else {
945
- const id = name(`${hint}.sum`);
946
- m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
947
- out = `${id}.value`;
948
- }
949
- }
950
- if (l.c !== 0) {
951
- const id = name(`${hint}.offset`);
952
- m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
953
- out = `${id}.value`;
954
- }
955
- return out;
956
- };
1816
+ const emit = (l, hint) => emitLin(m, name, linNorm(l), hint);
957
1817
  const pick = (ls, op, hint) => {
958
1818
  const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
959
1819
  if (refs.length === 1)
@@ -1175,6 +2035,12 @@ const mostChanged = (a, b) => {
1175
2035
  function setRepeat(asset, a) {
1176
2036
  const m = asset.document.model;
1177
2037
  const path = `${a.part}.repeat`;
2038
+ /* A row made in the modeller lives in its fastening's record: the pitch changes there, and the row is made again. */
2039
+ const record = (asset.joints ?? []).find(o => o.body1.part === a.part && o.repeat);
2040
+ if (record) {
2041
+ setJoint(asset, { ...record, repeat: { ...record.repeat, pitch: a.pitch } });
2042
+ return asset;
2043
+ }
1178
2044
  const layout = nodeById(m, `${a.part}.layout`);
1179
2045
  if (!layout || !nodeById(m, `${a.part}.repeat`))
1180
2046
  fail('EDIT_TARGET', path, `${a.part} is not a repeated part`);