@hatiolab/figure-model 0.1.57 → 0.1.59

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,9 +22,9 @@
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);
@@ -45,7 +45,9 @@ const writerOf = (m, ref) => m.nodes.find((n) => Object.values(n.outputs).includ
45
45
  /** A part an authoring command can work with, and the pieces of it those commands need. */
46
46
  function partOf(m, id, path) {
47
47
  const place = nodeById(m, id);
48
- 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))
49
51
  fail('TARGET_ABSENT', path, `${id} is not a placed part`);
50
52
  const shape = writerOf(m, place.args[0]);
51
53
  if (!shape)
@@ -97,20 +99,18 @@ function requireUnturned(m, asset, id, path) {
97
99
  * still axis-aligned planes; any other turn is refused, and says the angle. The turn is then fixed as it is: a part
98
100
  * fastened by its faces keeps the turn its faces were measured in (chief architect's ruling 2026-09-25).
99
101
  */
100
- function standingRefsOf(m, asset, id, path, freeze = true) {
101
- const own = sizeRefsOf(m, id, path);
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) {
102
108
  const turn = turnOf(m, asset, id, path);
103
109
  if (turn.every(t => t === 0))
104
- return own;
110
+ return [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
105
111
  if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
106
112
  fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); only quarter turns keep its faces level with the axes`);
107
- const [rx, ry, rz] = turn.map(t => (t * Math.PI) / 180);
108
- const cos = (a) => Math.round(Math.cos(a)), sin = (a) => Math.round(Math.sin(a));
109
- const X = [[1, 0, 0], [0, cos(rx), -sin(rx)], [0, sin(rx), cos(rx)]];
110
- const Y = [[cos(ry), 0, sin(ry)], [0, 1, 0], [-sin(ry), 0, cos(ry)]];
111
- const Z = [[cos(rz), -sin(rz), 0], [sin(rz), cos(rz), 0], [0, 0, 1]];
112
- const mul = (a, b) => a.map(row => b[0].map((_, j) => row.reduce((sum, v, k) => sum + v * b[k][j], 0)));
113
- const r = mul(mul(X, Y), Z); // the order `rigid@1` turns in
113
+ const r = turnMatrix(turn);
114
114
  if (freeze) {
115
115
  const pose = partOf(m, id, path).pose;
116
116
  const old = pose.args.slice(3, 6);
@@ -119,10 +119,67 @@ function standingRefsOf(m, asset, id, path, freeze = true) {
119
119
  for (const ref of old)
120
120
  dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
121
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);
122
137
  const standing = {};
123
138
  AXES.forEach((axis, i) => (standing[axis] = own[AXES[r[i].findIndex(v => v !== 0)]]));
124
139
  return standing;
125
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;
182
+ }
126
183
  /** Fresh node ids under a part, so two commands never claim one id. */
127
184
  function namer(m, prefix) {
128
185
  const taken = new Set([...m.nodes.map((n) => n.id), ...m.nodes.flatMap((n) => Object.values(n.outputs)), ...m.constants.map((c) => c.id), ...m.inputs.map((i) => i.id)]);
@@ -355,30 +412,42 @@ function attach(asset, a) {
355
412
  return standOnPlane(asset, a, path);
356
413
  if (a.part === to.part)
357
414
  fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
358
- for (const face of [a.face, to.face])
359
- if (!Object.hasOwn(V3_FACES, String(face)))
360
- 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(', ')}`);
361
422
  const facing = a.facing ?? 'meet';
362
423
  if (facing !== 'meet' && facing !== 'flush')
363
424
  fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
364
- const axis = V3_FACES[to.face];
425
+ const axis = theirFace.axis;
426
+ const sign = theirFace.normal;
365
427
  if (V3_FACES[a.face] !== axis)
366
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`);
367
- if (facing === 'meet' && a.face !== OPPOSITE[to.face])
368
- 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`);
369
- if (facing === 'flush' && a.face !== to.face)
370
- 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}`);
371
434
  if (parentOf(m, a.part) && !a.replace)
372
435
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
373
436
  // A part cannot end up its own ancestor.
374
437
  for (let up = to.part; up; up = parentOf(m, up))
375
438
  if (up === a.part)
376
439
  fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
377
- const theirs = sizeRefsOf(m, to.part, path);
378
- requireUnturned(m, asset, to.part, path);
379
- const mine = standingRefsOf(m, asset, a.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`);
380
450
  const name = namer(m, `${a.part}.on.${to.part}`);
381
- const sign = SIGN[to.face];
382
451
  /*
383
452
  Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
384
453
  gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
@@ -392,24 +461,38 @@ function attach(asset, a) {
392
461
  const zero = constantOf(m, 'mm', 0, 'd');
393
462
  const translation = { x: '', y: '', z: '' };
394
463
  const offset = { x: zero, y: zero, z: zero };
395
- translation[axis] = sumOf(m, name, [{ ref: theirs[axis].ref, k: sign * 0.5 * theirs[axis].k }], `seat.${axis}`);
396
- offset[axis] = sumOf(m, name, [{ ref: mine[axis].ref, k: outward * 0.5 * mine[axis].k }, { ref: gap, k: outward }], `along.${axis}`);
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}`);
397
467
  /*
398
468
  Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
399
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
400
470
  fastened level with the frame's min edge turns about that edge, as a hinge does (2026-09-25); centred, the point
401
- is the middle of the face.
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.
402
473
  */
403
474
  for (const other of AXES.filter(x => x !== axis)) {
404
475
  const how = a.align?.[other] ?? 'centre';
405
- if (how === 'centre')
406
- translation[other] = constantOf(m, 'mm', 0, 'd');
407
- else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
408
- 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
+ }
409
489
  else if (how === 'min' || how === 'max') {
410
- const s = how === 'min' ? -1 : 1;
411
- translation[other] = sumOf(m, name, [{ ref: theirs[other].ref, k: s * 0.5 * theirs[other].k }], `edge.${other}`);
412
- offset[other] = sumOf(m, name, [{ ref: mine[other].ref, k: -s * 0.5 * mine[other].k }], `own-edge.${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}`);
413
496
  }
414
497
  else
415
498
  fail('SCHEMA', path, `${String(how)} is not an alignment`);
@@ -432,12 +515,16 @@ function standOnPlane(asset, a, path) {
432
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`);
433
516
  if (parentOf(m, a.part) && !a.replace)
434
517
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
435
- const mine = standingRefsOf(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`);
436
521
  const name = namer(m, `${a.part}.on.plane`);
437
522
  const outward = a.face === 'bottom' ? 1 : -1;
438
523
  const gap = measureRef(m, asset, name, a.gap, 'gap.y', path);
439
524
  const translation = { x: '', y: '', z: '' };
440
- 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 };
441
528
  for (const other of ['x', 'z']) {
442
529
  const how = a.align?.[other] ?? 'centre';
443
530
  if (how === 'centre')
@@ -446,10 +533,13 @@ function standOnPlane(asset, a, path) {
446
533
  translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
447
534
  else
448
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}`);
449
540
  }
450
541
  // The point it stands on is on the plane, under its centre: a turn about Y turns it where it stands.
451
- const zero = constantOf(m, 'mm', 0, 'd');
452
- reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat: [translation.x, zero, translation.z], pose: [zero, translation.y, zero] });
542
+ reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat: [translation.x, zero, translation.z], pose: [pose.x, pose.y, pose.z] });
453
543
  return asset;
454
544
  }
455
545
  function detach(asset, a) {
@@ -646,6 +736,22 @@ export function applyV3Authoring(source, action) {
646
736
  case 'set-repeat':
647
737
  setRepeat(asset, action);
648
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
+ }
649
755
  case 'nudge-part':
650
756
  nudgePart(asset, action);
651
757
  break;
@@ -838,6 +944,31 @@ function checkJoint(asset, j) {
838
944
  if (!Number.isFinite(j.mimic.multiplier))
839
945
  fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
840
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
+ }
841
972
  }
842
973
  /**
843
974
  * Write a joint's nodes from its record: the child's quarter turn, the fastening (the seat on the parent's face and
@@ -848,39 +979,172 @@ function writeJoint(asset, j) {
848
979
  const m = asset.document.model;
849
980
  const child = j.body1.part;
850
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;
851
992
  const old = pose.args.slice(3, 6);
852
- pose.args = [...pose.args.slice(0, 3), ...AXES.map(a => constantOf(m, 'deg', j.origin?.turn?.[a] ?? 0, 'a'))];
993
+ pose.args = [...pose.args.slice(0, 3), ...turn.map(t => constantOf(m, 'deg', t, 'a'))];
853
994
  for (const ref of old)
854
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);
855
997
  attach(asset, {
856
998
  kind: 'attach',
857
999
  part: child,
858
- face: j.body1.face,
859
- to: 'plane' in j.body0 ? { plane: 'mounting-plane' } : { part: j.body0.part, face: j.body0.face },
1000
+ face: face(j.body1.face),
1001
+ to: 'plane' in j.body0 ? { plane: 'mounting-plane' } : { part: j.body0.part, face: face(j.body0.face) },
860
1002
  ...(j.origin?.facing ? { facing: j.origin.facing } : {}),
861
1003
  ...(j.origin?.gap !== undefined ? { gap: j.origin.gap } : {}),
862
- ...(j.origin?.align ? { align: j.origin.align } : {}),
1004
+ ...(j.origin?.align ? { align: turned ? alignInto(back, j.origin.align, `joints.${j.id}`) : j.origin.align } : {}),
863
1005
  replace: true
864
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
+ }
865
1012
  if (j.type === 'fixed')
866
1013
  return;
867
1014
  const kind = j.type === 'prismatic' ? 'slide' : 'turn';
868
1015
  const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
869
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 };
870
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);
871
1021
  addMotion(asset, {
872
1022
  kind: 'add-motion',
873
1023
  part: child,
874
- motion: { kind, axis: j.axis.toLowerCase() },
1024
+ motion: { kind, axis: AXES[axisIndex] },
875
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 } : {}) },
876
1026
  ...(j.travel ? { travel: j.travel } : {}),
877
1027
  replace: true
878
- }, leader ? { of: stateIdOf(leader), k: j.mimic.multiplier, c: j.mimic.offset ?? 0 } : undefined);
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);
879
1142
  }
880
1143
  /** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
881
1144
  function stripJoint(asset, j) {
882
1145
  const m = asset.document.model;
883
1146
  const child = j.body1.part;
1147
+ unrepeatPart(asset, child);
884
1148
  if (nodeById(m, `${child}.motion`))
885
1149
  removeMotion(asset, { kind: 'remove-motion', part: child });
886
1150
  const { pose, place } = partOf(m, child, child);
@@ -930,6 +1194,72 @@ function rewriteJoints(asset) {
930
1194
  if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
931
1195
  asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
932
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
+ * A copied part is fastened as the original is: its own record, the same faces and place, so it is not held by nodes
1220
+ * no record makes. A moving joint gets its own control -- a copy is a second thing, not the original's shadow.
1221
+ */
1222
+ export function copyV3PartJoint(asset, from, to) {
1223
+ const joints = (asset.joints ?? []);
1224
+ const j = joints.find(o => o.body1.part === from);
1225
+ if (!j)
1226
+ return;
1227
+ const m = asset.document.model;
1228
+ const ids = new Set(joints.map(o => o.id));
1229
+ const inputs = new Set(m.inputs.map((i) => i.id));
1230
+ const fresh = (base, taken) => {
1231
+ let id = base;
1232
+ for (let n = 2; taken.has(id); n++)
1233
+ id = `${base}-${n}`;
1234
+ return id;
1235
+ };
1236
+ const id = fresh(j.id === from ? to : `${j.id}-copy`, ids);
1237
+ const copy = { ...structuredClone(j), id, body1: { ...j.body1, part: to } };
1238
+ if (j.type !== 'fixed' && !j.mimic)
1239
+ copy.state = { ...(j.state ?? {}), id: fresh(j.state?.id ? `${j.state.id}-copy` : id, inputs) };
1240
+ asset.joints = [...joints, copy];
1241
+ rewriteJoints(asset);
1242
+ }
1243
+ /**
1244
+ * Before a part is taken away. What hangs from it, or follows its joint, is named and the removal refused -- a
1245
+ * silent cascade would take parts the author did not pick. A part nothing hangs from goes with its own joint.
1246
+ */
1247
+ export function releaseV3PartJoints(asset, part) {
1248
+ const joints = (asset.joints ?? []);
1249
+ const hanging = joints.filter(j => 'part' in j.body0 && j.body0.part === part).map(j => j.body1.part);
1250
+ if (hanging.length)
1251
+ fail('PART_REFERENCED', part, `${part} holds ${hanging.join(', ')}; take ${hanging.length > 1 ? 'them' : 'it'} off first`);
1252
+ const own = joints.find(j => j.body1.part === part);
1253
+ if (!own)
1254
+ return;
1255
+ const followers = joints.filter(o => o.mimic?.joint === own.id).map(o => o.body1.part);
1256
+ if (followers.length)
1257
+ 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`);
1258
+ asset.joints = joints.filter(j => j.id !== own.id);
1259
+ if (!asset.joints.length)
1260
+ delete asset.joints;
1261
+ stripJoint(asset, own);
1262
+ }
933
1263
  function setJoint(asset, joint) {
934
1264
  // As JSON: a field left undefined is a field not there, which is how the asset stores it.
935
1265
  const j = JSON.parse(JSON.stringify(joint ?? null));
@@ -1024,7 +1354,7 @@ const attachmentFrameOf = (asset, id) => {
1024
1354
  const parent = parentOf(asset.document.model, id);
1025
1355
  return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
1026
1356
  };
1027
- function addMotion(asset, a, follow) {
1357
+ function addMotion(asset, a, follow, sign = 1) {
1028
1358
  const m = asset.document.model;
1029
1359
  const path = a.part;
1030
1360
  partOf(m, a.part, path);
@@ -1089,7 +1419,7 @@ function addMotion(asset, a, follow) {
1089
1419
  }
1090
1420
  else if (a.motion.kind === 'slide' && a.travel)
1091
1421
  fail('SCHEMA', path, 'a mm control is the distance; travel would say it twice');
1092
- const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? 1 : 0, 'axis'));
1422
+ const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? sign : 0, 'axis'));
1093
1423
  m.nodes.push({
1094
1424
  id: `${a.part}.motion`,
1095
1425
  op: a.motion.kind === 'slide' ? 'axis-slide@1' : 'axis-turn@1',
@@ -1730,6 +2060,12 @@ const mostChanged = (a, b) => {
1730
2060
  function setRepeat(asset, a) {
1731
2061
  const m = asset.document.model;
1732
2062
  const path = `${a.part}.repeat`;
2063
+ /* A row made in the modeller lives in its fastening's record: the pitch changes there, and the row is made again. */
2064
+ const record = (asset.joints ?? []).find(o => o.body1.part === a.part && o.repeat);
2065
+ if (record) {
2066
+ setJoint(asset, { ...record, repeat: { ...record.repeat, pitch: a.pitch } });
2067
+ return asset;
2068
+ }
1733
2069
  const layout = nodeById(m, `${a.part}.layout`);
1734
2070
  if (!layout || !nodeById(m, `${a.part}.repeat`))
1735
2071
  fail('EDIT_TARGET', path, `${a.part} is not a repeated part`);