@hatiolab/figure-model 0.1.84 → 0.1.86

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.
@@ -45,10 +45,18 @@ const clone = (asset) => structuredClone(asset);
45
45
  const nodeById = (m, id) => m.nodes.find((n) => n.id === id);
46
46
  const writerOf = (m, ref) => m.nodes.find((n) => Object.values(n.outputs).includes(ref));
47
47
  /** A part an authoring command can work with, and the pieces of it those commands need. */
48
+ /** The part whose row carries this one -- fastened to it, however far down -- or undefined. */
49
+ function rowHolding(m, id) {
50
+ const place = nodeById(m, id);
51
+ if (place?.op !== 'member@1' || nodeById(m, `${id}.repeat`))
52
+ return undefined;
53
+ const assembly = m.nodes.find((n) => n.op === 'assembly@1' && n.args.includes(place.outputs.placed));
54
+ return assembly ? String(assembly.id).replace(/\.assembly$/, '') : undefined;
55
+ }
48
56
  function partOf(m, id, path) {
49
57
  const place = nodeById(m, id);
50
58
  /* A row's template is the part too: it is placed through its row (`repeatPart`). */
51
- const rowTemplate = place?.op === 'member@1' && !!nodeById(m, `${id}.repeat`);
59
+ const rowTemplate = place?.op === 'member@1' && (!!nodeById(m, `${id}.repeat`) || rowHolding(m, id) !== undefined);
52
60
  if (!place || (place.op !== 'place@1' && !rowTemplate))
53
61
  fail('TARGET_ABSENT', path, `${id} is not a placed part`);
54
62
  const shape = writerOf(m, place.args[0]);
@@ -168,6 +176,25 @@ function facesOfPart(m, id, path) {
168
176
  fail('EDIT_TARGET', path, `${id} is a ${shape.op}; it has no flat face to fasten by`);
169
177
  return { shape, faces: faces };
170
178
  }
179
+ /**
180
+ * The face a joint's child is measured by: its own, or for `centre` the face that would meet the parent's -- the part is
181
+ * measured and turned as that face, and only where its middle sits differs (\`attach\`).
182
+ */
183
+ function childFaceOf(m, j) {
184
+ if (j.body1.face !== 'centre')
185
+ return j.body1.face;
186
+ const path = `joints.${j.id}.body1.face`;
187
+ if (!('part' in j.body0))
188
+ fail('JOINT_SCHEMA', path, 'a part stands on the mounting plane by a face of its own, not by its centre');
189
+ /* One of the six, in the record's words: the face across from it. An opening's face, read from the parent's shape. */
190
+ const named = j.body0.face;
191
+ if (Object.hasOwn(V3_FACES, named))
192
+ return Object.keys(V3_FACES).find(f => V3_FACES[f] === V3_FACES[named] && SIGN[f] === -SIGN[named]);
193
+ const theirs = facesOfPart(m, j.body0.part, path).faces[j.body0.face];
194
+ if (!theirs)
195
+ fail('JOINT_SCHEMA', path, `${j.body0.part} has no face ${j.body0.face}`);
196
+ return Object.keys(V3_FACES).find(f => V3_FACES[f] === theirs.axis && SIGN[f] === -theirs.normal);
197
+ }
171
198
  const planeOf = (shape, face) => face.plane.map(p => ({ ref: shape.args[p.arg], k: p.k }));
172
199
  /** The part's outer faces on each axis in its own frame; an axis without a pair of flat faces has none. */
173
200
  function ownSpansOf(m, id, path) {
@@ -447,7 +474,8 @@ function attach(asset, a, twist) {
447
474
  return standOnPlane(asset, a, path);
448
475
  if (a.part === to.part)
449
476
  fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
450
- if (!Object.hasOwn(V3_FACES, String(a.face)))
477
+ const byCentre = a.face === 'centre';
478
+ if (!byCentre && !Object.hasOwn(V3_FACES, String(a.face)))
451
479
  fail('SCHEMA', path, `${String(a.face)} is not a face`);
452
480
  /* The target's faces are the ones its shape declares: a portal's opening as well as its outer faces. */
453
481
  const target = facesOfPart(m, to.part, path);
@@ -459,6 +487,11 @@ function attach(asset, a, twist) {
459
487
  fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
460
488
  const axis = theirFace.axis;
461
489
  const sign = theirFace.normal;
490
+ if (byCentre && facing !== 'meet')
491
+ fail('ATTACH_FACE', path, `${a.part} fastened by its centre sits on ${to.face}; flush has no meaning for a centre`);
492
+ /* By its centre, the part is measured as the face that would meet the target's; only its offset differs. */
493
+ if (byCentre)
494
+ a = { ...a, face: Object.keys(V3_FACES).find(f => V3_FACES[f] === axis && SIGN[f] === -sign) };
462
495
  if (V3_FACES[a.face] !== axis)
463
496
  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`);
464
497
  const wanted = Object.keys(V3_FACES).find(f => V3_FACES[f] === axis && SIGN[f] === (facing === 'meet' ? -sign : sign));
@@ -480,7 +513,7 @@ function attach(asset, a, twist) {
480
513
  const theirs = ownSpansOf(m, to.part, path);
481
514
  const mine = standingSpansOf(m, asset, a.part, path);
482
515
  const mineAlong = mine[axis];
483
- if (!mineAlong)
516
+ if (!mineAlong && !byCentre)
484
517
  fail('EDIT_TARGET', path, `${a.part} has no flat ${a.face} face`);
485
518
  const name = namer(m, `${a.part}.on.${to.part}`);
486
519
  /*
@@ -496,9 +529,10 @@ function attach(asset, a, twist) {
496
529
  const zero = constantOf(m, 'mm', 0, 'd');
497
530
  const translation = { x: '', y: '', z: '' };
498
531
  const offset = { x: zero, y: zero, z: zero };
499
- translation[axis] = sumOf(m, name, planeOf(target.shape, theirFace), `seat.${axis}`);
500
- const ownFace = SIGN[a.face] > 0 ? mineAlong.hi : mineAlong.lo;
501
- offset[axis] = sumOf(m, name, [...neg(ownFace), { ref: gap, k: outward }], `along.${axis}`);
532
+ /* By its centre, the gap goes into the seat: the point fastened at -- and turned about -- is the part's middle. */
533
+ translation[axis] = sumOf(m, name, [...planeOf(target.shape, theirFace), ...(byCentre ? [{ ref: gap, k: outward }] : [])], `seat.${axis}`);
534
+ const ownFace = byCentre ? (mineAlong && !mineAlong.centred ? [...half(mineAlong.lo), ...half(mineAlong.hi)] : []) : SIGN[a.face] > 0 ? mineAlong.hi : mineAlong.lo;
535
+ offset[axis] = byCentre ? (ownFace.length ? sumOf(m, name, neg(ownFace), `along.${axis}`) : zero) : sumOf(m, name, [...neg(ownFace), { ref: gap, k: outward }], `along.${axis}`);
502
536
  /*
503
537
  Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
504
538
  point fastened at is where those edges meet: the seat on the target's edge, the part's own edge on it. A door
@@ -610,8 +644,8 @@ function standOnPlane(asset, a, path) {
610
644
  const normal = placement === 'wall' ? 'z' : 'y';
611
645
  if (!takes.includes(a.face))
612
646
  fail('ATTACH_FACE', path, placement === 'wall'
613
- ? `${String(a.face)} faces along ${V3_FACES[a.face] ?? '?'}; the figure hangs on a wall, the plane Z = 0, so a part is fastened to it by its back`
614
- : `${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`);
647
+ ? `${String(a.face)} faces along ${V3_FACES[a.face] ?? 'no axis'}; the figure hangs on a wall, the plane Z = 0, so a part is fastened to it by its back`
648
+ : `${String(a.face)} faces along ${V3_FACES[a.face] ?? 'no axis'}; the plane faces along y, so a part stands on it by its bottom or hangs from it by its top`);
615
649
  if (parentOf(m, a.part) && !a.replace)
616
650
  fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
617
651
  const mine = standingSpansOf(m, asset, a.part, path);
@@ -772,7 +806,11 @@ function reseat(asset, id, to, place) {
772
806
  const parent = /^(.*)\.local$/.exec(to);
773
807
  let world = nodeById(m, `${id}.world`);
774
808
  if (parent && parent[1] !== id) {
775
- const above = nodeById(m, `${parent[1]}.world`)?.outputs?.pose ?? nodeById(m, `${parent[1]}.chain`)?.outputs?.pose ?? nodeById(m, `${parent[1]}.pose`).outputs.pose;
809
+ /* A row's template has no chain of its own: what hangs from it hangs in its copy's frame, turned as it turns. */
810
+ const above = nodeById(m, `${parent[1]}.world`)?.outputs?.pose ??
811
+ nodeById(m, `${parent[1]}.chain`)?.outputs?.pose ??
812
+ nodeById(m, `${parent[1]}.turned`)?.outputs?.pose ??
813
+ nodeById(m, `${parent[1]}.pose`).outputs.pose;
776
814
  if (!world) {
777
815
  world = { id: `${id}.world`, op: 'compose@1', args: [above, chain.outputs.pose], outputs: { pose: `${id}.world.value` } };
778
816
  m.nodes.push(world);
@@ -862,7 +900,8 @@ export function applyV3Authoring(source, action) {
862
900
  if (!j)
863
901
  fail('EDIT_TARGET', `${action.part}.repeat`, `${action.part} is not fastened to anything; fasten it to the part it repeats across first`);
864
902
  const count = action.count === undefined || action.count === 'fills' ? undefined : action.count;
865
- setJoint(asset, { ...j, repeat: { axis: String(action.axis).toUpperCase(), pitch: action.pitch, ...(count !== undefined ? { count } : {}) } });
903
+ const rise = action.rise ? { rise: { axis: String(action.rise.axis).toUpperCase(), mm: action.rise.mm } } : {};
904
+ setJoint(asset, { ...j, repeat: { axis: String(action.axis).toUpperCase(), pitch: action.pitch, ...(count !== undefined ? { count } : {}), ...rise } });
866
905
  break;
867
906
  }
868
907
  case 'remove-repeat': {
@@ -1127,30 +1166,44 @@ function checkJoint(asset, j) {
1127
1166
  fail('JOINT_SCHEMA', `${path}.origin.twist`, 'a twist is a number of degrees');
1128
1167
  if (j.origin?.turn && Object.values(j.origin.turn).some(t => !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
1129
1168
  fail('JOINT_SCHEMA', `${path}.origin.turn`, 'a turn is in quarter turns; an angle about the face\'s normal is its twist');
1130
- /* A part in a row is its copies: nothing can hang from one place of it. */
1131
- const repeatedParent = 'part' in j.body0 ? others.find(o => o.repeat && o.body1.part === j.body0.part) : undefined;
1132
- if (repeatedParent)
1133
- fail('JOINT_SCHEMA', path, `${repeatedParent.body1.part} is repeated; its copies have no one place for ${j.body1.part} to hang from`);
1169
+ /*
1170
+ A row repeats its part and everything fastened to it (ADR-0093, 2026-09-26): a part fastened to a part in a row is
1171
+ on every copy. A row inside a row -- a grid -- is not covered yet.
1172
+ */
1173
+ const all = [...others, j];
1174
+ const upOf = (part) => all.find(o => o.body1.part === part && 'part' in o.body0);
1175
+ const rowAbove = (part) => {
1176
+ for (let up = upOf(part); up; up = upOf(up.body0.part))
1177
+ if (up.repeat)
1178
+ return up;
1179
+ return undefined;
1180
+ };
1181
+ if (j.repeat) {
1182
+ const outer = 'part' in j.body0 ? rowAbove(j.body0.part) : undefined;
1183
+ const below = (part) => all.filter(o => 'part' in o.body0 && o.body0.part === part).flatMap(o => [o.body1.part, ...below(o.body1.part)]);
1184
+ const inner = below(j.body1.part).map(p => all.find(o => o.body1.part === p)).find(o => o.repeat);
1185
+ if (outer || inner)
1186
+ fail('JOINT_SCHEMA', `${path}.repeat`, `${outer ? `${j.body1.part} is on ${outer.body1.part}'s row` : `${inner.body1.part} on ${j.body1.part} is a row`}; a row inside a row is not covered yet`);
1187
+ }
1134
1188
  if (j.repeat) {
1135
1189
  const r = j.repeat;
1136
1190
  const row = `${path}.repeat`;
1137
- if (j.type !== 'fixed')
1138
- 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');
1139
1191
  if (!('part' in j.body0))
1140
1192
  fail('JOINT_SCHEMA', row, 'a row fills the part it is fastened to; the mounting plane has no length to fill');
1141
1193
  if (!['X', 'Y', 'Z'].includes(r.axis))
1142
1194
  fail('JOINT_SCHEMA', row, `a row runs along X, Y or Z, not ${String(r.axis)}`);
1143
- if (r.axis.toLowerCase() === V3_FACES[j.body1.face])
1195
+ if (r.axis.toLowerCase() === V3_FACES[childFaceOf(asset.document.model, j)])
1144
1196
  fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
1145
1197
  if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !(r.pitch && typeof r.pitch === 'object' && r.pitch.from))
1146
1198
  fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch, or one that follows a length');
1147
1199
  if (r.count !== undefined && !(Number.isSafeInteger(r.count) && r.count >= 1 && r.count <= REPEAT_LIMIT))
1148
1200
  fail('GEOMETRY_DOMAIN', row, `a row has 1 to ${REPEAT_LIMIT} copies`);
1149
- const held = others.filter(o => 'part' in o.body0 && o.body0.part === j.body1.part).map(o => o.body1.part);
1150
- if (held.length)
1151
- fail('JOINT_SCHEMA', row, `${j.body1.part} holds ${held.join(', ')}; a part in a row holds nothing`);
1152
- if (others.some(o => o.mimic?.joint === j.id))
1153
- fail('JOINT_SCHEMA', row, `a joint follows ${j.id}; a part in a row does not move`);
1201
+ if (r.rise !== undefined) {
1202
+ if (!r.rise || !['X', 'Y', 'Z'].includes(r.rise.axis) || r.rise.axis === r.axis)
1203
+ fail('JOINT_SCHEMA', `${row}.rise`, `a row rises along X, Y or Z other than the one it runs along (${r.axis})`);
1204
+ if (!(Number.isFinite(r.rise.mm) && r.rise.mm !== 0))
1205
+ fail('GEOMETRY_DOMAIN', `${row}.rise`, 'a rise is a length in mm, not zero');
1206
+ }
1154
1207
  }
1155
1208
  }
1156
1209
  /**
@@ -1173,11 +1226,12 @@ function writeJoint(asset, j) {
1173
1226
  const turned = !isIdentity(back);
1174
1227
  const own = AXES.map(a => j.origin?.turn?.[a] ?? 0);
1175
1228
  /* The child as it stands: its quarter turn, then its twist about the normal of the face it is fastened by. */
1176
- const normal = V3_FACES[j.body1.face];
1229
+ const childFace = childFaceOf(m, j);
1230
+ const normal = V3_FACES[childFace];
1177
1231
  const standing = j.origin?.twist ? mul3(axisTurn(normal, j.origin.twist), turnMatrix(own)) : turnMatrix(own);
1178
1232
  /* In the parent's frame: its twist about the face's normal there, and the quarter turn that is left. */
1179
1233
  const inParent = mul3(back, standing);
1180
- const localNormal = axisOf(mv3(back, FACE_VECTOR[j.body1.face]), `${j.body1.part}'s ${j.body1.face}`, parent ?? '', back, path);
1234
+ const localNormal = axisOf(mv3(back, FACE_VECTOR[childFace]), `${j.body1.part}'s ${childFace}`, parent ?? '', back, path);
1181
1235
  /* The record's twist, about the normal as it lies in the parent's frame; what is left is a quarter turn. */
1182
1236
  /*
1183
1237
  In the parent's frame the child stands twisted about the face's normal -- by its own twist, and back by its
@@ -1212,13 +1266,14 @@ function writeJoint(asset, j) {
1212
1266
  for (const ref of now)
1213
1267
  dropIfUnused(asset, ref);
1214
1268
  }
1215
- if (j.repeat) {
1216
- if (turned)
1217
- fail('JOINT_SCHEMA', `joints.${j.id}.repeat`, `${parent} stands turned; a row across a turned part is not covered yet`);
1218
- return repeatPart(asset, j);
1219
- }
1220
- if (j.type === 'fixed')
1269
+ if (j.repeat && turned)
1270
+ fail('JOINT_SCHEMA', `joints.${j.id}.repeat`, `${parent} stands turned; a row across a turned part is not covered yet`);
1271
+ /* A row is laid out last, once its part's motion is written: every copy moves on it (a roller row spinning). */
1272
+ if (j.type === 'fixed') {
1273
+ if (j.repeat)
1274
+ repeatPart(asset, j);
1221
1275
  return;
1276
+ }
1222
1277
  const kind = j.type === 'prismatic' ? 'slide' : 'turn';
1223
1278
  const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
1224
1279
  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 };
@@ -1242,6 +1297,8 @@ function writeJoint(asset, j) {
1242
1297
  (the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
1243
1298
  */
1244
1299
  leader ? { of: nodeById(m, `${leader.body1.part}.motion`)?.args[3] ?? stateIdOf(leader), k: j.mimic.multiplier, c: j.mimic.offset ?? 0 } : undefined, along[axisIndex] > 0 ? 1 : -1);
1300
+ if (j.repeat)
1301
+ repeatPart(asset, j);
1245
1302
  }
1246
1303
  const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
1247
1304
  const isIdentity = (r) => r.every((row, i) => row.every((v, k) => Math.abs(v - (i === k ? 1 : 0)) < 1e-12));
@@ -1261,7 +1318,7 @@ function standingTurnOf(asset, part, path) {
1261
1318
  fail('EDIT_TARGET', path, `${part} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is covered only for quarter turns`);
1262
1319
  const quarter = turn.every(t => t === 0) ? IDENTITY : turnMatrix(turn);
1263
1320
  /* A twisted part stands twisted about the normal of the face it is fastened by. */
1264
- return record?.origin?.twist ? mul3(axisTurn(V3_FACES[record.body1.face], record.origin.twist), quarter) : quarter;
1321
+ return record?.origin?.twist ? mul3(axisTurn(V3_FACES[childFaceOf(asset.document.model, record)], record.origin.twist), quarter) : quarter;
1265
1322
  }
1266
1323
  /** How a part stands before its twist: its quarter turn only. */
1267
1324
  function standingQuarterOf(asset, part, path) {
@@ -1389,7 +1446,9 @@ function repeatPart(asset, j) {
1389
1446
  if (!theirs)
1390
1447
  fail('EDIT_TARGET', path, `${parent} has no two faces along ${axis}; there is no length to fill`);
1391
1448
  const mine = standingSpansOf(m, asset, part, path)[axis];
1392
- if (!mine)
1449
+ /* A frustum has no faces across its axis; it is as wide there as its wider end (G6). */
1450
+ const widest = mine ? undefined : frustumWidthAlong(m, asset, part, axis, path);
1451
+ if (!mine && !widest)
1393
1452
  fail('EDIT_TARGET', path, `${part} has no two faces along ${axis}; its copies cannot be spaced`);
1394
1453
  const { place, pose } = partOf(m, part, path);
1395
1454
  const seat = nodeById(m, `${part}.seat.rigid`);
@@ -1397,19 +1456,19 @@ function repeatPart(asset, j) {
1397
1456
  const zero = constantOf(m, 'mm', 0, 'd');
1398
1457
  /* The seat moves to the row's start; each copy's centre is where the layout puts it from there. */
1399
1458
  seat.args = seat.args.map((ref, i) => (i === k ? sumOf(m, name, theirs.lo, 'start') : ref));
1400
- const own = mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
1459
+ const own = widest || mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
1401
1460
  pose.args = pose.args.map((ref, i) => (i === k ? own : ref));
1402
1461
  const length = sumOf(m, name, [...theirs.hi, ...neg(theirs.lo)], 'length');
1403
- const extent = sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
1462
+ const extent = widest ?? sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
1404
1463
  const centred = constantOf(m, 'ratio', 0.5, 'k');
1405
1464
  const limit = constantOf(m, 'count', REPEAT_LIMIT, 'limit');
1406
1465
  const count = j.repeat.count;
1407
1466
  m.nodes.push(count === undefined
1408
1467
  ? { id: `${part}.layout`, op: 'fit-pitch@1', args: [length, zero, zero, extent, measureRef(m, asset, name, j.repeat.pitch, 'pitch', path), centred, limit], outputs: { layout: `${part}.layout.value` } }
1409
1468
  : { id: `${part}.layout`, op: 'fixed-count@1', args: [constantOf(m, 'count', count, 'count'), length, zero, zero, extent, centred, limit], outputs: { layout: `${part}.layout.value` } });
1410
- /* The part is the row's template now: a member, drawn only through the row. */
1469
+ /* The part is the row's template now: a member, drawn only through the row -- turned by its motion, if it moves. */
1411
1470
  place.op = 'member@1';
1412
- place.args = [place.args[0], pose.outputs.pose];
1471
+ place.args = [place.args[0], nodeById(m, `${part}.turned`)?.outputs?.pose ?? pose.outputs.pose];
1413
1472
  m.nodes.push({ id: `${part}.assembly`, op: 'assembly@1', args: [place.outputs.placed], outputs: { assembly: `${part}.assembly.value` } });
1414
1473
  /* Where the parent stands, as `attach` finds it: fastened (its world or chain), or placed by its own numbers. */
1415
1474
  const above = nodeById(m, `${parent}.world`)?.outputs?.pose ?? nodeById(m, `${parent}.chain`)?.outputs?.pose ?? nodeById(m, `${parent}.pose`)?.outputs?.pose;
@@ -1419,7 +1478,59 @@ function repeatPart(asset, j) {
1419
1478
  root = `${part}.row.value`;
1420
1479
  }
1421
1480
  m.nodes = m.nodes.filter((n) => n.id !== `${part}.chain` && n.id !== `${part}.world`);
1422
- m.nodes.push({ id: `${part}.repeat`, op: 'repeat@1', args: [`${part}.layout.value`, `${part}.assembly.value`, root], outputs: { collection: `${part}.repeat.value` }, params: { axis } });
1481
+ const rise = j.repeat.rise;
1482
+ m.nodes.push({
1483
+ id: `${part}.repeat`,
1484
+ op: 'repeat@1',
1485
+ args: [`${part}.layout.value`, `${part}.assembly.value`, root, ...(rise ? [constantOf(m, 'mm', rise.mm, 'rise')] : [])],
1486
+ outputs: { collection: `${part}.repeat.value` },
1487
+ params: { axis, ...(rise ? { riseAxis: rise.axis.toLowerCase() } : {}) }
1488
+ });
1489
+ }
1490
+ /**
1491
+ * Everything fastened to a row's part, however far down, goes into the row's assembly: each copy carries it (ADR-0093,
1492
+ * 2026-09-26). Its placement already reads its copy's frame -- it hangs from the part's pose, not from a chain.
1493
+ */
1494
+ function gatherRows(asset, joints) {
1495
+ const m = asset.document.model;
1496
+ for (const row of joints.filter(j => j.repeat)) {
1497
+ const assembly = nodeById(m, `${row.body1.part}.assembly`);
1498
+ const below = (part) => joints.filter(o => 'part' in o.body0 && o.body0.part === part).flatMap(o => [o.body1.part, ...below(o.body1.part)]);
1499
+ for (const part of below(row.body1.part)) {
1500
+ const place = nodeById(m, part);
1501
+ place.op = 'member@1';
1502
+ assembly.args.push(place.outputs.placed);
1503
+ }
1504
+ }
1505
+ }
1506
+ /**
1507
+ * How wide a frustum is across its own axis, where the row runs: the larger of its two diameters, as the bounds of two
1508
+ * candidates (\`affine-bounds@1\`) -- the room's own way to take the larger of sums, so the gate still reads it as a sum
1509
+ * (chief architect, 2026-09-26). Undefined for any other shape, or a row along the frustum's own axis.
1510
+ */
1511
+ function frustumWidthAlong(m, asset, part, axis, path) {
1512
+ const { shape } = partOf(m, part, path);
1513
+ if (shape.op !== 'frustum-shape@1')
1514
+ return undefined;
1515
+ const r = quarterTurnOf(m, asset, part, path, true);
1516
+ const own = AXES[r[AXES.indexOf(axis)].findIndex(v => v !== 0)];
1517
+ if (own === 'y')
1518
+ return undefined;
1519
+ const id = `${part}.width.${own}`;
1520
+ if (!nodeById(m, id)) {
1521
+ const zero = constantOf(m, 'mm', 0, 'd');
1522
+ const two = constantOf(m, 'ratio', 2, 'k');
1523
+ const [top, bottom] = shape.args;
1524
+ /* xmin, xmax, ymin, ymax, zmin, zmax: only the one maximum is read; the others hold a zero each. */
1525
+ m.nodes.push({
1526
+ id,
1527
+ op: 'affine-bounds@1',
1528
+ args: [zero, zero, top, two, zero, bottom, two, zero, zero, zero, zero],
1529
+ outputs: { xmin: `${id}.unused.xmin`, xmax: `${id}.value`, ymin: `${id}.unused.ymin`, ymax: `${id}.unused.ymax`, zmin: `${id}.unused.zmin`, zmax: `${id}.unused.zmax` },
1530
+ params: { layout: 'L0.1_1.0.0.0.0' }
1531
+ });
1532
+ }
1533
+ return `${id}.value`;
1423
1534
  }
1424
1535
  /** A row made back into its one part, so its fastening can be taken away or written again. */
1425
1536
  function unrepeatPart(asset, part) {
@@ -1428,11 +1539,12 @@ function unrepeatPart(asset, part) {
1428
1539
  if (template?.op !== 'member@1' || !nodeById(m, `${part}.repeat`))
1429
1540
  return;
1430
1541
  const layout = nodeById(m, `${part}.layout`);
1542
+ const rise = nodeById(m, `${part}.repeat`)?.args[3];
1431
1543
  const row = new Set([`${part}.layout`, `${part}.assembly`, `${part}.repeat`, `${part}.row`]);
1432
1544
  const gone = m.nodes.filter((n) => row.has(n.id) || n.id.startsWith(`${part}.row.`));
1433
1545
  m.nodes = m.nodes.filter((n) => !gone.includes(n));
1434
1546
  template.op = 'place@1';
1435
- for (const ref of layout?.args ?? [])
1547
+ for (const ref of [...(layout?.args ?? []), ...(rise ? [rise] : [])])
1436
1548
  dropIfUnused(asset, ref);
1437
1549
  }
1438
1550
  /** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
@@ -1489,10 +1601,15 @@ function rewriteJoints(asset) {
1489
1601
  stableDimension(asset.document.model, j.travel.source.part, j.travel.source.axis, `joints.${j.id}.travel`);
1490
1602
  const order = writingOrder(joints);
1491
1603
  const keep = { ...asset.stateDefaults };
1604
+ /* What hangs from a row's part is drawn through the row; it is a part of its own again while the joints are written. */
1605
+ for (const n of asset.document.model.nodes)
1606
+ if (n.op === 'member@1' && !nodeById(asset.document.model, `${n.id}.repeat`))
1607
+ n.op = 'place@1';
1492
1608
  for (const j of [...order].reverse())
1493
1609
  stripJoint(asset, j);
1494
1610
  for (const j of order)
1495
1611
  writeJoint(asset, j);
1612
+ gatherRows(asset, joints);
1496
1613
  // A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
1497
1614
  for (const j of order)
1498
1615
  if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
@@ -1638,7 +1755,7 @@ function setJoint(asset, joint) {
1638
1755
  if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
1639
1756
  const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
1640
1757
  /* A part turned by any angle about the normal of the face it is fastened by keeps that angle as its twist. */
1641
- const n = AXES.indexOf(V3_FACES[j.body1.face]);
1758
+ const n = AXES.indexOf(V3_FACES[childFaceOf(asset.document.model, j)]);
1642
1759
  const quarter = (t) => t !== null && Number.isFinite(t) && Math.abs(t / 90 - Math.round(t / 90)) < 1e-9;
1643
1760
  const twist = !quarter(turn[n]) && turn.every((t, i) => i === n || t === 0) ? turn[n] : undefined;
1644
1761
  if (twist !== undefined && !j.origin?.twist)
@@ -2264,34 +2381,104 @@ function occupancyLins(asset, over) {
2264
2381
  const balls = new Map();
2265
2382
  /* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
2266
2383
  const rowOf = (id) => nodeById(m, `${id}.repeat`);
2267
- for (const place of m.nodes.filter((n) => n.op === 'place@1' || (n.op === 'member@1' && rowOf(n.id)))) {
2384
+ const ends = over === 'rest' ? ['start'] : ['min', 'max'];
2385
+ /* A part's size along the figure's axes, whichever quarter turns it and what carries it stand in. */
2386
+ const sizesOf = (id, turns) => {
2387
+ /* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
2388
+ const own = occupiedExtentsOf(m, id, id, turns);
2389
+ const c = turns ? IDENTITY : worldTurnOf(m, asset, id);
2390
+ if (!c)
2391
+ throw new V3ContractError('EDIT_TARGET', id, `${id} is turned by other than quarter turns`);
2392
+ return Object.fromEntries(AXES.map((axis, i) => [axis, own[AXES[c[i].findIndex(v => v !== 0)]]]));
2393
+ };
2394
+ /*
2395
+ A row runs from its parent's one edge to the other along its axis, every copy inside (fit-pitch and fixed-count lay
2396
+ them out within that length). What it spans there, how long its part is along it, and how far a rising row climbs.
2397
+ */
2398
+ const runOf = (part) => {
2399
+ const row = rowOf(part);
2400
+ const axis = String(row.params?.axis ?? '').toLowerCase();
2401
+ const parent = parentOf(m, part);
2402
+ const straight = parent && worldTurnOf(m, asset, parent);
2403
+ if (!parent || !AXES.includes(axis) || !straight || AXES.some((_, i) => straight[i][i] !== 1))
2404
+ return `${part} is a row whose run this command cannot read`;
2405
+ const theirs = standingRefsOf(m, asset, parent, parent, false)[axis];
2406
+ const half = { terms: [{ ref: theirs.ref, k: 0.5 * theirs.k }], c: 0 };
2407
+ const centres = ends.map(end => centreLin(m, asset, parent, axis, end));
2408
+ const bad = centres.find(c => typeof c === 'string');
2409
+ if (bad)
2410
+ return String(bad);
2411
+ const mine = sizesOf(part, false)[axis][0];
2412
+ const extent = { terms: [{ ref: mine.ref, k: mine.k }], c: 0 };
2413
+ const record = (asset.joints ?? []).find(o => o.body1.part === part && o.repeat)?.repeat;
2414
+ let climb;
2415
+ if (record?.rise) {
2416
+ /*
2417
+ The last copy is (count − 1) rises past the first. A row that fills its length has at most
2418
+ (length − one copy) / pitch copies past the first, so that many rises bound it and follow the parent's size;
2419
+ a counted row has exactly count − 1.
2420
+ */
2421
+ const per = record.rise.mm;
2422
+ let reach;
2423
+ if (record.count !== undefined)
2424
+ reach = { terms: [], c: (record.count - 1) * per };
2425
+ else if (typeof record.pitch === 'number')
2426
+ reach = { terms: [{ ref: theirs.ref, k: (theirs.k * per) / record.pitch }, { ref: mine.ref, k: (-mine.k * per) / record.pitch }], c: 0 };
2427
+ else
2428
+ return `${part} is a rising row whose pitch follows a length; how far it rises is not added up`;
2429
+ climb = { axis: record.rise.axis.toLowerCase(), up: per > 0, reach };
2430
+ }
2431
+ return { axis, lows: centres.map(c => linAdd(c, half, -1)), highs: centres.map(c => linAdd(c, half, 1)), extent, climb };
2432
+ };
2433
+ /* A part in a row, or fastened to one: each copy carries it (ADR-0093, 2026-09-26). */
2434
+ for (const place of m.nodes.filter((n) => n.op === 'place@1' || (n.op === 'member@1' && (rowOf(n.id) || rowHolding(m, n.id))))) {
2268
2435
  const turns = carriedByATurn(m, asset, place.id);
2269
2436
  let sizes = {};
2270
2437
  try {
2271
- /* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
2272
- const own = turns ? occupiedExtentsOf(m, place.id, place.id, true) : occupiedExtentsOf(m, place.id, place.id);
2273
- /* Its size along the figure's axes, whichever quarter turns it and what carries it stand in. */
2274
- const c = turns ? IDENTITY : worldTurnOf(m, asset, place.id);
2275
- if (!c)
2276
- throw new V3ContractError('EDIT_TARGET', place.id, `${place.id} is turned by other than quarter turns`);
2277
- AXES.forEach((axis, i) => (sizes[axis] = own[AXES[c[i].findIndex(v => v !== 0)]]));
2438
+ sizes = sizesOf(place.id, turns);
2278
2439
  }
2279
2440
  catch (e) {
2280
2441
  skipped.push({ part: place.id, reason: e instanceof V3ContractError ? e.message : 'not a part whose size this command can read' });
2281
2442
  continue;
2282
2443
  }
2283
- const row = place.op === 'member@1' ? rowOf(place.id) : undefined;
2284
- if (turns && row) {
2285
- skipped.push({ part: place.id, reason: `${place.id} is a row carried by a turn; where its copies reach is not added up` });
2444
+ /* The part whose row carries this one: itself, or the part it hangs from in a row. */
2445
+ const rowPart = place.op === 'member@1' ? (rowOf(place.id) ? place.id : rowHolding(m, place.id)) : undefined;
2446
+ const run = rowPart ? runOf(rowPart) : undefined;
2447
+ if (typeof run === 'string') {
2448
+ skipped.push({ part: place.id, reason: run });
2286
2449
  continue;
2287
2450
  }
2451
+ const climbed = (at, side, onto, axis) => {
2452
+ if (run?.climb?.axis === axis && (run.climb.up ? 1 : -1) === side)
2453
+ onto[axis].push(linAdd(at, run.climb.reach));
2454
+ };
2288
2455
  if (turns) {
2289
- const reach = reachOf(m, asset, place.id, over === 'rest' ? ['start'] : ['min', 'max']);
2456
+ const reach = reachOf(m, asset, place.id, ends);
2290
2457
  if (typeof reach === 'string') {
2291
2458
  skipped.push({ part: place.id, reason: reach });
2292
2459
  continue;
2293
2460
  }
2294
2461
  parts.push(place.id);
2462
+ if (run) {
2463
+ /*
2464
+ Each copy turns about its own pivot, which lies on its row's run: along the row the copies reach the run's ends
2465
+ and the turn's reach past them; across it, the turn's reach about the pivot.
2466
+ */
2467
+ for (const l of run.lows)
2468
+ low[run.axis].push(linAdd(l, reach.radius, -1));
2469
+ for (const h of run.highs)
2470
+ high[run.axis].push(linAdd(h, reach.radius, 1));
2471
+ for (const at of reach.centres)
2472
+ for (const axis of AXES.filter(a => a !== run.axis)) {
2473
+ const lo = linAdd(at[axis], reach.radius, -1);
2474
+ const hi = linAdd(at[axis], reach.radius, 1);
2475
+ low[axis].push(lo);
2476
+ high[axis].push(hi);
2477
+ climbed(lo, -1, low, axis);
2478
+ climbed(hi, 1, high, axis);
2479
+ }
2480
+ continue;
2481
+ }
2295
2482
  for (const at of reach.centres) {
2296
2483
  const centre = Object.fromEntries(AXES.map(a => [a, linNorm(at[a])]));
2297
2484
  const key = JSON.stringify(centre);
@@ -2307,41 +2494,44 @@ function occupancyLins(asset, over) {
2307
2494
  max, so the answer holds whichever way the travel runs, and holds at every design size, because the
2308
2495
  choice is made when the graph is evaluated rather than when the proposal is written.
2309
2496
  */
2310
- const ends = over === 'rest' ? ['start'] : ['min', 'max'];
2311
2497
  const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
2312
2498
  const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
2313
2499
  if (bad) {
2314
2500
  skipped.push({ part: place.id, reason: String(bad) });
2315
2501
  continue;
2316
2502
  }
2317
- /*
2318
- A row runs from its parent's one edge to the other along its axis, every copy inside (fit-pitch and fixed-count
2319
- lay them out within that length): along it the row takes the parent's room, across it the template's.
2320
- */
2321
- let rowAxis;
2322
- if (row) {
2323
- rowAxis = String(row.params?.axis ?? '').toLowerCase();
2324
- const parent = parentOf(m, place.id);
2325
- const straight = parent && worldTurnOf(m, asset, parent);
2326
- if (!parent || !AXES.includes(rowAxis) || !straight || AXES.some((_, i) => straight[i][i] !== 1)) {
2327
- skipped.push({ part: place.id, reason: `${place.id} is a row whose run this command cannot read` });
2328
- continue;
2329
- }
2330
- const theirs = standingRefsOf(m, asset, parent, parent, false)[rowAxis];
2331
- const half = { terms: [{ ref: theirs.ref, k: 0.5 * theirs.k }], c: 0 };
2332
- const centres = ends.map(end => centreLin(m, asset, parent, rowAxis, end));
2333
- if (centres.some(c => typeof c === 'string')) {
2334
- skipped.push({ part: place.id, reason: String(centres.find(c => typeof c === 'string')) });
2503
+ if (run && rowPart === place.id) {
2504
+ /* The row's part: along the row it takes the run. */
2505
+ low[run.axis].push(...run.lows);
2506
+ high[run.axis].push(...run.highs);
2507
+ }
2508
+ else if (run) {
2509
+ /*
2510
+ Fastened to a row's part: its copies stand where that part's copies do, offset as it is from it. That part's
2511
+ centres lie half its length in from the run's ends.
2512
+ */
2513
+ const theirs = ends.map(end => centreLin(m, asset, rowPart, run.axis, end));
2514
+ const bad = theirs.find(c => typeof c === 'string');
2515
+ if (bad) {
2516
+ skipped.push({ part: place.id, reason: String(bad) });
2335
2517
  continue;
2336
2518
  }
2337
- for (const centre of centres) {
2338
- low[rowAxis].push(linAdd(centre, half, -1));
2339
- high[rowAxis].push(linAdd(centre, half, 1));
2519
+ const along = readings.find(r => r.axis === run.axis).at;
2520
+ const inset = linAdd({ terms: [], c: 0 }, run.extent, 0.5);
2521
+ for (const [i, mine] of along.entries()) {
2522
+ const offset = linAdd(mine, theirs[i], -1);
2523
+ for (const e of sizes[run.axis]) {
2524
+ const half = { terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 };
2525
+ for (const l of run.lows)
2526
+ low[run.axis].push(linAdd(linAdd(linAdd(l, inset), offset), half, -1));
2527
+ for (const h of run.highs)
2528
+ high[run.axis].push(linAdd(linAdd(linAdd(h, inset, -1), offset), half));
2529
+ }
2340
2530
  }
2341
2531
  }
2342
2532
  parts.push(place.id);
2343
2533
  for (const r of readings) {
2344
- if (r.axis === rowAxis)
2534
+ if (r.axis === run?.axis)
2345
2535
  continue;
2346
2536
  const halves = sizes[r.axis].map(e => ({ terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 }));
2347
2537
  const seen = new Set();
@@ -2351,8 +2541,12 @@ function occupancyLins(asset, over) {
2351
2541
  continue; // a part that does not move gives the same candidate twice
2352
2542
  seen.add(key);
2353
2543
  for (const half of halves) {
2354
- low[r.axis].push(linAdd(centre, half, -1));
2355
- high[r.axis].push(linAdd(centre, half, 1));
2544
+ const lo = linAdd(centre, half, -1);
2545
+ const hi = linAdd(centre, half, 1);
2546
+ low[r.axis].push(lo);
2547
+ high[r.axis].push(hi);
2548
+ climbed(lo, -1, low, r.axis);
2549
+ climbed(hi, 1, high, r.axis);
2356
2550
  }
2357
2551
  }
2358
2552
  }
@@ -2806,7 +3000,7 @@ function proportionPlan(asset) {
2806
3000
  plan.skipped.push({ what: `${part}.place`, reason: 'stands by no fastening; fasten it for its place to follow' });
2807
3001
  }
2808
3002
  for (const j of (asset.joints ?? [])) {
2809
- const faceAxis = V3_FACES[j.body1.face];
3003
+ const faceAxis = V3_FACES[childFaceOf(m, j)];
2810
3004
  const gap = j.origin?.gap;
2811
3005
  if (typeof gap === 'number' && gap !== 0)
2812
3006
  plan.lengths.push({ joint: j.id, at: 'gap', axis: faceAxis, times: share(gap, faceAxis) });