@hatiolab/figure-model 0.1.85 → 0.1.87
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.
- package/dist/v3-authoring-actions.d.ts +10 -3
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +292 -98
- package/dist/v3-authoring-actions.js.map +1 -1
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +17 -6
- package/dist/v3-graph.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +1 -1
- package/dist/v3-kernel-version.js +1 -1
- package/dist/v3-proposal.js +3 -3
- package/dist/v3-proposal.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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] ?? '
|
|
614
|
-
: `${String(a.face)} faces along ${V3_FACES[a.face] ?? '
|
|
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
|
-
|
|
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);
|
|
@@ -1128,20 +1166,36 @@ function checkJoint(asset, j) {
|
|
|
1128
1166
|
fail('JOINT_SCHEMA', `${path}.origin.twist`, 'a twist is a number of degrees');
|
|
1129
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))
|
|
1130
1168
|
fail('JOINT_SCHEMA', `${path}.origin.turn`, 'a turn is in quarter turns; an angle about the face\'s normal is its twist');
|
|
1131
|
-
/*
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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 is a grid; it runs along another axis than the rows around it.
|
|
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 below = (part) => all.filter(o => 'part' in o.body0 && o.body0.part === part).flatMap(o => [o.body1.part, ...below(o.body1.part)]);
|
|
1183
|
+
const around = [];
|
|
1184
|
+
for (let up = ('part' in j.body0 ? rowAbove(j.body0.part) : undefined); up; up = ('part' in up.body0 ? rowAbove(up.body0.part) : undefined))
|
|
1185
|
+
around.push(up);
|
|
1186
|
+
const inside = below(j.body1.part).map(p => all.find(o => o.body1.part === p)).filter(o => o.repeat);
|
|
1187
|
+
const same = [...around, ...inside].find(o => o.repeat.axis === j.repeat.axis);
|
|
1188
|
+
if (same)
|
|
1189
|
+
fail('JOINT_SCHEMA', `${path}.repeat`, `${same.body1.part}'s row runs along ${same.repeat.axis} too; a row inside a row runs along another axis`);
|
|
1190
|
+
}
|
|
1135
1191
|
if (j.repeat) {
|
|
1136
1192
|
const r = j.repeat;
|
|
1137
1193
|
const row = `${path}.repeat`;
|
|
1138
|
-
if (j.type !== 'fixed')
|
|
1139
|
-
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');
|
|
1140
1194
|
if (!('part' in j.body0))
|
|
1141
1195
|
fail('JOINT_SCHEMA', row, 'a row fills the part it is fastened to; the mounting plane has no length to fill');
|
|
1142
1196
|
if (!['X', 'Y', 'Z'].includes(r.axis))
|
|
1143
1197
|
fail('JOINT_SCHEMA', row, `a row runs along X, Y or Z, not ${String(r.axis)}`);
|
|
1144
|
-
if (r.axis.toLowerCase() === V3_FACES[
|
|
1198
|
+
if (r.axis.toLowerCase() === V3_FACES[childFaceOf(asset.document.model, j)])
|
|
1145
1199
|
fail('JOINT_SCHEMA', row, `a row runs across the face it is fastened to; ${r.axis} is out of that face`);
|
|
1146
1200
|
if (typeof r.pitch === 'number' ? !(Number.isFinite(r.pitch) && r.pitch > 0) : !(r.pitch && typeof r.pitch === 'object' && r.pitch.from))
|
|
1147
1201
|
fail('GEOMETRY_DOMAIN', row, 'a row has a positive pitch, or one that follows a length');
|
|
@@ -1153,11 +1207,6 @@ function checkJoint(asset, j) {
|
|
|
1153
1207
|
if (!(Number.isFinite(r.rise.mm) && r.rise.mm !== 0))
|
|
1154
1208
|
fail('GEOMETRY_DOMAIN', `${row}.rise`, 'a rise is a length in mm, not zero');
|
|
1155
1209
|
}
|
|
1156
|
-
const held = others.filter(o => 'part' in o.body0 && o.body0.part === j.body1.part).map(o => o.body1.part);
|
|
1157
|
-
if (held.length)
|
|
1158
|
-
fail('JOINT_SCHEMA', row, `${j.body1.part} holds ${held.join(', ')}; a part in a row holds nothing`);
|
|
1159
|
-
if (others.some(o => o.mimic?.joint === j.id))
|
|
1160
|
-
fail('JOINT_SCHEMA', row, `a joint follows ${j.id}; a part in a row does not move`);
|
|
1161
1210
|
}
|
|
1162
1211
|
}
|
|
1163
1212
|
/**
|
|
@@ -1180,11 +1229,12 @@ function writeJoint(asset, j) {
|
|
|
1180
1229
|
const turned = !isIdentity(back);
|
|
1181
1230
|
const own = AXES.map(a => j.origin?.turn?.[a] ?? 0);
|
|
1182
1231
|
/* The child as it stands: its quarter turn, then its twist about the normal of the face it is fastened by. */
|
|
1183
|
-
const
|
|
1232
|
+
const childFace = childFaceOf(m, j);
|
|
1233
|
+
const normal = V3_FACES[childFace];
|
|
1184
1234
|
const standing = j.origin?.twist ? mul3(axisTurn(normal, j.origin.twist), turnMatrix(own)) : turnMatrix(own);
|
|
1185
1235
|
/* In the parent's frame: its twist about the face's normal there, and the quarter turn that is left. */
|
|
1186
1236
|
const inParent = mul3(back, standing);
|
|
1187
|
-
const localNormal = axisOf(mv3(back, FACE_VECTOR[
|
|
1237
|
+
const localNormal = axisOf(mv3(back, FACE_VECTOR[childFace]), `${j.body1.part}'s ${childFace}`, parent ?? '', back, path);
|
|
1188
1238
|
/* The record's twist, about the normal as it lies in the parent's frame; what is left is a quarter turn. */
|
|
1189
1239
|
/*
|
|
1190
1240
|
In the parent's frame the child stands twisted about the face's normal -- by its own twist, and back by its
|
|
@@ -1219,13 +1269,14 @@ function writeJoint(asset, j) {
|
|
|
1219
1269
|
for (const ref of now)
|
|
1220
1270
|
dropIfUnused(asset, ref);
|
|
1221
1271
|
}
|
|
1222
|
-
if (j.repeat)
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1272
|
+
if (j.repeat && turned)
|
|
1273
|
+
fail('JOINT_SCHEMA', `joints.${j.id}.repeat`, `${parent} stands turned; a row across a turned part is not covered yet`);
|
|
1274
|
+
/* A row is laid out last, once its part's motion is written: every copy moves on it (a roller row spinning). */
|
|
1275
|
+
if (j.type === 'fixed') {
|
|
1276
|
+
if (j.repeat)
|
|
1277
|
+
repeatPart(asset, j);
|
|
1228
1278
|
return;
|
|
1279
|
+
}
|
|
1229
1280
|
const kind = j.type === 'prismatic' ? 'slide' : 'turn';
|
|
1230
1281
|
const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
|
|
1231
1282
|
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 };
|
|
@@ -1249,6 +1300,8 @@ function writeJoint(asset, j) {
|
|
|
1249
1300
|
(the telescopic conveyor, 2026-09-25). For a leader whose control is its distance the two are one value.
|
|
1250
1301
|
*/
|
|
1251
1302
|
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);
|
|
1303
|
+
if (j.repeat)
|
|
1304
|
+
repeatPart(asset, j);
|
|
1252
1305
|
}
|
|
1253
1306
|
const IDENTITY = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
1254
1307
|
const isIdentity = (r) => r.every((row, i) => row.every((v, k) => Math.abs(v - (i === k ? 1 : 0)) < 1e-12));
|
|
@@ -1268,7 +1321,7 @@ function standingTurnOf(asset, part, path) {
|
|
|
1268
1321
|
fail('EDIT_TARGET', path, `${part} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is covered only for quarter turns`);
|
|
1269
1322
|
const quarter = turn.every(t => t === 0) ? IDENTITY : turnMatrix(turn);
|
|
1270
1323
|
/* A twisted part stands twisted about the normal of the face it is fastened by. */
|
|
1271
|
-
return record?.origin?.twist ? mul3(axisTurn(V3_FACES[
|
|
1324
|
+
return record?.origin?.twist ? mul3(axisTurn(V3_FACES[childFaceOf(asset.document.model, record)], record.origin.twist), quarter) : quarter;
|
|
1272
1325
|
}
|
|
1273
1326
|
/** How a part stands before its twist: its quarter turn only. */
|
|
1274
1327
|
function standingQuarterOf(asset, part, path) {
|
|
@@ -1396,7 +1449,9 @@ function repeatPart(asset, j) {
|
|
|
1396
1449
|
if (!theirs)
|
|
1397
1450
|
fail('EDIT_TARGET', path, `${parent} has no two faces along ${axis}; there is no length to fill`);
|
|
1398
1451
|
const mine = standingSpansOf(m, asset, part, path)[axis];
|
|
1399
|
-
|
|
1452
|
+
/* A frustum has no faces across its axis; it is as wide there as its wider end (G6). */
|
|
1453
|
+
const widest = mine ? undefined : frustumWidthAlong(m, asset, part, axis, path);
|
|
1454
|
+
if (!mine && !widest)
|
|
1400
1455
|
fail('EDIT_TARGET', path, `${part} has no two faces along ${axis}; its copies cannot be spaced`);
|
|
1401
1456
|
const { place, pose } = partOf(m, part, path);
|
|
1402
1457
|
const seat = nodeById(m, `${part}.seat.rigid`);
|
|
@@ -1404,22 +1459,25 @@ function repeatPart(asset, j) {
|
|
|
1404
1459
|
const zero = constantOf(m, 'mm', 0, 'd');
|
|
1405
1460
|
/* The seat moves to the row's start; each copy's centre is where the layout puts it from there. */
|
|
1406
1461
|
seat.args = seat.args.map((ref, i) => (i === k ? sumOf(m, name, theirs.lo, 'start') : ref));
|
|
1407
|
-
const own = mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
|
|
1462
|
+
const own = widest || mine.centred ? zero : sumOf(m, name, neg([...half(mine.lo), ...half(mine.hi)]), 'own-centre');
|
|
1408
1463
|
pose.args = pose.args.map((ref, i) => (i === k ? own : ref));
|
|
1409
1464
|
const length = sumOf(m, name, [...theirs.hi, ...neg(theirs.lo)], 'length');
|
|
1410
|
-
const extent = sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
|
|
1465
|
+
const extent = widest ?? sumOf(m, name, [...mine.hi, ...neg(mine.lo)], 'extent');
|
|
1411
1466
|
const centred = constantOf(m, 'ratio', 0.5, 'k');
|
|
1412
1467
|
const limit = constantOf(m, 'count', REPEAT_LIMIT, 'limit');
|
|
1413
1468
|
const count = j.repeat.count;
|
|
1414
1469
|
m.nodes.push(count === undefined
|
|
1415
1470
|
? { 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` } }
|
|
1416
1471
|
: { id: `${part}.layout`, op: 'fixed-count@1', args: [constantOf(m, 'count', count, 'count'), length, zero, zero, extent, centred, limit], outputs: { layout: `${part}.layout.value` } });
|
|
1417
|
-
/* The part is the row's template now: a member, drawn only through the row. */
|
|
1472
|
+
/* The part is the row's template now: a member, drawn only through the row -- turned by its motion, if it moves. */
|
|
1418
1473
|
place.op = 'member@1';
|
|
1419
|
-
place.args = [place.args[0], pose.outputs.pose];
|
|
1474
|
+
place.args = [place.args[0], nodeById(m, `${part}.turned`)?.outputs?.pose ?? pose.outputs.pose];
|
|
1420
1475
|
m.nodes.push({ id: `${part}.assembly`, op: 'assembly@1', args: [place.outputs.placed], outputs: { assembly: `${part}.assembly.value` } });
|
|
1421
1476
|
/* Where the parent stands, as `attach` finds it: fastened (its world or chain), or placed by its own numbers. */
|
|
1422
|
-
const above = nodeById(m, `${parent}.world`)?.outputs?.pose ??
|
|
1477
|
+
const above = nodeById(m, `${parent}.world`)?.outputs?.pose ??
|
|
1478
|
+
nodeById(m, `${parent}.chain`)?.outputs?.pose ??
|
|
1479
|
+
nodeById(m, `${parent}.turned`)?.outputs?.pose ??
|
|
1480
|
+
nodeById(m, `${parent}.pose`)?.outputs?.pose;
|
|
1423
1481
|
let root = seat.outputs.pose;
|
|
1424
1482
|
if (above) {
|
|
1425
1483
|
m.nodes.push({ id: `${part}.row`, op: 'compose@1', args: [above, seat.outputs.pose], outputs: { pose: `${part}.row.value` } });
|
|
@@ -1435,6 +1493,61 @@ function repeatPart(asset, j) {
|
|
|
1435
1493
|
params: { axis, ...(rise ? { riseAxis: rise.axis.toLowerCase() } : {}) }
|
|
1436
1494
|
});
|
|
1437
1495
|
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Everything fastened to a row's part, however far down, goes into the row's assembly: each copy carries it (ADR-0093,
|
|
1498
|
+
* 2026-09-26). Its placement already reads its copy's frame -- it hangs from the part's pose, not from a chain.
|
|
1499
|
+
*/
|
|
1500
|
+
function gatherRows(asset, joints) {
|
|
1501
|
+
const m = asset.document.model;
|
|
1502
|
+
for (const row of joints.filter(j => j.repeat)) {
|
|
1503
|
+
const assembly = nodeById(m, `${row.body1.part}.assembly`);
|
|
1504
|
+
/* Down to the next row: a row inside this one joins as its copies, and what hangs from its part is its own. */
|
|
1505
|
+
const gather = (part) => {
|
|
1506
|
+
for (const o of joints.filter(o => 'part' in o.body0 && o.body0.part === part)) {
|
|
1507
|
+
if (o.repeat) {
|
|
1508
|
+
const inner = nodeById(m, `${o.body1.part}.repeat`);
|
|
1509
|
+
inner.params = { ...inner.params, within: `${row.body1.part}.repeat` };
|
|
1510
|
+
assembly.args.push(inner.outputs.collection);
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
const place = nodeById(m, o.body1.part);
|
|
1514
|
+
place.op = 'member@1';
|
|
1515
|
+
assembly.args.push(place.outputs.placed);
|
|
1516
|
+
gather(o.body1.part);
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
gather(row.body1.part);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* How wide a frustum is across its own axis, where the row runs: the larger of its two diameters, as the bounds of two
|
|
1524
|
+
* candidates (\`affine-bounds@1\`) -- the room's own way to take the larger of sums, so the gate still reads it as a sum
|
|
1525
|
+
* (chief architect, 2026-09-26). Undefined for any other shape, or a row along the frustum's own axis.
|
|
1526
|
+
*/
|
|
1527
|
+
function frustumWidthAlong(m, asset, part, axis, path) {
|
|
1528
|
+
const { shape } = partOf(m, part, path);
|
|
1529
|
+
if (shape.op !== 'frustum-shape@1')
|
|
1530
|
+
return undefined;
|
|
1531
|
+
const r = quarterTurnOf(m, asset, part, path, true);
|
|
1532
|
+
const own = AXES[r[AXES.indexOf(axis)].findIndex(v => v !== 0)];
|
|
1533
|
+
if (own === 'y')
|
|
1534
|
+
return undefined;
|
|
1535
|
+
const id = `${part}.width.${own}`;
|
|
1536
|
+
if (!nodeById(m, id)) {
|
|
1537
|
+
const zero = constantOf(m, 'mm', 0, 'd');
|
|
1538
|
+
const two = constantOf(m, 'ratio', 2, 'k');
|
|
1539
|
+
const [top, bottom] = shape.args;
|
|
1540
|
+
/* xmin, xmax, ymin, ymax, zmin, zmax: only the one maximum is read; the others hold a zero each. */
|
|
1541
|
+
m.nodes.push({
|
|
1542
|
+
id,
|
|
1543
|
+
op: 'affine-bounds@1',
|
|
1544
|
+
args: [zero, zero, top, two, zero, bottom, two, zero, zero, zero, zero],
|
|
1545
|
+
outputs: { xmin: `${id}.unused.xmin`, xmax: `${id}.value`, ymin: `${id}.unused.ymin`, ymax: `${id}.unused.ymax`, zmin: `${id}.unused.zmin`, zmax: `${id}.unused.zmax` },
|
|
1546
|
+
params: { layout: 'L0.1_1.0.0.0.0' }
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
return `${id}.value`;
|
|
1550
|
+
}
|
|
1438
1551
|
/** A row made back into its one part, so its fastening can be taken away or written again. */
|
|
1439
1552
|
function unrepeatPart(asset, part) {
|
|
1440
1553
|
const m = asset.document.model;
|
|
@@ -1504,10 +1617,15 @@ function rewriteJoints(asset) {
|
|
|
1504
1617
|
stableDimension(asset.document.model, j.travel.source.part, j.travel.source.axis, `joints.${j.id}.travel`);
|
|
1505
1618
|
const order = writingOrder(joints);
|
|
1506
1619
|
const keep = { ...asset.stateDefaults };
|
|
1620
|
+
/* 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. */
|
|
1621
|
+
for (const n of asset.document.model.nodes)
|
|
1622
|
+
if (n.op === 'member@1' && !nodeById(asset.document.model, `${n.id}.repeat`))
|
|
1623
|
+
n.op = 'place@1';
|
|
1507
1624
|
for (const j of [...order].reverse())
|
|
1508
1625
|
stripJoint(asset, j);
|
|
1509
1626
|
for (const j of order)
|
|
1510
1627
|
writeJoint(asset, j);
|
|
1628
|
+
gatherRows(asset, joints);
|
|
1511
1629
|
// A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
|
|
1512
1630
|
for (const j of order)
|
|
1513
1631
|
if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
|
|
@@ -1653,7 +1771,7 @@ function setJoint(asset, joint) {
|
|
|
1653
1771
|
if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
|
|
1654
1772
|
const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
|
|
1655
1773
|
/* A part turned by any angle about the normal of the face it is fastened by keeps that angle as its twist. */
|
|
1656
|
-
const n = AXES.indexOf(V3_FACES[
|
|
1774
|
+
const n = AXES.indexOf(V3_FACES[childFaceOf(asset.document.model, j)]);
|
|
1657
1775
|
const quarter = (t) => t !== null && Number.isFinite(t) && Math.abs(t / 90 - Math.round(t / 90)) < 1e-9;
|
|
1658
1776
|
const twist = !quarter(turn[n]) && turn.every((t, i) => i === n || t === 0) ? turn[n] : undefined;
|
|
1659
1777
|
if (twist !== undefined && !j.origin?.twist)
|
|
@@ -2279,34 +2397,122 @@ function occupancyLins(asset, over) {
|
|
|
2279
2397
|
const balls = new Map();
|
|
2280
2398
|
/* A row's template stands for its copies (`repeatPart`): leaving it out left a conveyor's rollers out of its room. */
|
|
2281
2399
|
const rowOf = (id) => nodeById(m, `${id}.repeat`);
|
|
2282
|
-
|
|
2400
|
+
const ends = over === 'rest' ? ['start'] : ['min', 'max'];
|
|
2401
|
+
/* A part's size along the figure's axes, whichever quarter turns it and what carries it stand in. */
|
|
2402
|
+
const sizesOf = (id, turns) => {
|
|
2403
|
+
/* A turned chain's reach is read from one size per axis (reachOf), so a part there must have one. */
|
|
2404
|
+
const own = occupiedExtentsOf(m, id, id, turns);
|
|
2405
|
+
const c = turns ? IDENTITY : worldTurnOf(m, asset, id);
|
|
2406
|
+
if (!c)
|
|
2407
|
+
throw new V3ContractError('EDIT_TARGET', id, `${id} is turned by other than quarter turns`);
|
|
2408
|
+
return Object.fromEntries(AXES.map((axis, i) => [axis, own[AXES[c[i].findIndex(v => v !== 0)]]]));
|
|
2409
|
+
};
|
|
2410
|
+
/*
|
|
2411
|
+
A row runs from its parent's one edge to the other along its axis, every copy inside (fit-pitch and fixed-count lay
|
|
2412
|
+
them out within that length). What it spans there, how long its part is along it, and how far a rising row climbs.
|
|
2413
|
+
*/
|
|
2414
|
+
const runOf = (part) => {
|
|
2415
|
+
const row = rowOf(part);
|
|
2416
|
+
const axis = String(row.params?.axis ?? '').toLowerCase();
|
|
2417
|
+
const parent = parentOf(m, part);
|
|
2418
|
+
const straight = parent && worldTurnOf(m, asset, parent);
|
|
2419
|
+
if (!parent || !AXES.includes(axis) || !straight || AXES.some((_, i) => straight[i][i] !== 1))
|
|
2420
|
+
return `${part} is a row whose run this command cannot read`;
|
|
2421
|
+
const theirs = standingRefsOf(m, asset, parent, parent, false)[axis];
|
|
2422
|
+
const half = { terms: [{ ref: theirs.ref, k: 0.5 * theirs.k }], c: 0 };
|
|
2423
|
+
const centres = ends.map(end => centreLin(m, asset, parent, axis, end));
|
|
2424
|
+
const bad = centres.find(c => typeof c === 'string');
|
|
2425
|
+
if (bad)
|
|
2426
|
+
return String(bad);
|
|
2427
|
+
const mine = sizesOf(part, false)[axis][0];
|
|
2428
|
+
const extent = { terms: [{ ref: mine.ref, k: mine.k }], c: 0 };
|
|
2429
|
+
const record = (asset.joints ?? []).find(o => o.body1.part === part && o.repeat)?.repeat;
|
|
2430
|
+
let climb;
|
|
2431
|
+
if (record?.rise) {
|
|
2432
|
+
/*
|
|
2433
|
+
The last copy is (count − 1) rises past the first. A row that fills its length has at most
|
|
2434
|
+
(length − one copy) / pitch copies past the first, so that many rises bound it and follow the parent's size;
|
|
2435
|
+
a counted row has exactly count − 1.
|
|
2436
|
+
*/
|
|
2437
|
+
const per = record.rise.mm;
|
|
2438
|
+
let reach;
|
|
2439
|
+
if (record.count !== undefined)
|
|
2440
|
+
reach = { terms: [], c: (record.count - 1) * per };
|
|
2441
|
+
else if (typeof record.pitch === 'number')
|
|
2442
|
+
reach = { terms: [{ ref: theirs.ref, k: (theirs.k * per) / record.pitch }, { ref: mine.ref, k: (-mine.k * per) / record.pitch }], c: 0 };
|
|
2443
|
+
else
|
|
2444
|
+
return `${part} is a rising row whose pitch follows a length; how far it rises is not added up`;
|
|
2445
|
+
climb = { axis: record.rise.axis.toLowerCase(), up: per > 0, reach };
|
|
2446
|
+
}
|
|
2447
|
+
return { axis, lows: centres.map(c => linAdd(c, half, -1)), highs: centres.map(c => linAdd(c, half, 1)), extent, climb };
|
|
2448
|
+
};
|
|
2449
|
+
/* A part in a row, or fastened to one: each copy carries it (ADR-0093, 2026-09-26). */
|
|
2450
|
+
for (const place of m.nodes.filter((n) => n.op === 'place@1' || (n.op === 'member@1' && (rowOf(n.id) || rowHolding(m, n.id))))) {
|
|
2283
2451
|
const turns = carriedByATurn(m, asset, place.id);
|
|
2284
2452
|
let sizes = {};
|
|
2285
2453
|
try {
|
|
2286
|
-
|
|
2287
|
-
const own = turns ? occupiedExtentsOf(m, place.id, place.id, true) : occupiedExtentsOf(m, place.id, place.id);
|
|
2288
|
-
/* Its size along the figure's axes, whichever quarter turns it and what carries it stand in. */
|
|
2289
|
-
const c = turns ? IDENTITY : worldTurnOf(m, asset, place.id);
|
|
2290
|
-
if (!c)
|
|
2291
|
-
throw new V3ContractError('EDIT_TARGET', place.id, `${place.id} is turned by other than quarter turns`);
|
|
2292
|
-
AXES.forEach((axis, i) => (sizes[axis] = own[AXES[c[i].findIndex(v => v !== 0)]]));
|
|
2454
|
+
sizes = sizesOf(place.id, turns);
|
|
2293
2455
|
}
|
|
2294
2456
|
catch (e) {
|
|
2295
2457
|
skipped.push({ part: place.id, reason: e instanceof V3ContractError ? e.message : 'not a part whose size this command can read' });
|
|
2296
2458
|
continue;
|
|
2297
2459
|
}
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2460
|
+
/*
|
|
2461
|
+
The rows around this part, nearest first: its own if it is a row's part, then the row of each part it hangs from
|
|
2462
|
+
(a grid is a row inside a row, each along its own axis).
|
|
2463
|
+
*/
|
|
2464
|
+
const rows = [];
|
|
2465
|
+
let unread;
|
|
2466
|
+
for (let up = place.op === 'member@1' ? place.id : null; up; up = parentOf(m, up)) {
|
|
2467
|
+
if (!rowOf(up))
|
|
2468
|
+
continue;
|
|
2469
|
+
const run = runOf(up);
|
|
2470
|
+
if (typeof run === 'string') {
|
|
2471
|
+
unread = run;
|
|
2472
|
+
break;
|
|
2473
|
+
}
|
|
2474
|
+
rows.push({ part: up, run });
|
|
2475
|
+
}
|
|
2476
|
+
if (unread) {
|
|
2477
|
+
skipped.push({ part: place.id, reason: unread });
|
|
2301
2478
|
continue;
|
|
2302
2479
|
}
|
|
2480
|
+
const rowAxes = new Set(rows.map(r => r.run.axis));
|
|
2481
|
+
/* How far the rows around it climb, along an axis and a way, added up: each copy of each row is further up. */
|
|
2482
|
+
const climbed = (at, side, onto, axis) => {
|
|
2483
|
+
const climbs = rows.map(r => r.run.climb).filter(c => c && c.axis === axis && (c.up ? 1 : -1) === side);
|
|
2484
|
+
if (climbs.length)
|
|
2485
|
+
onto[axis].push(climbs.reduce((sum, c) => linAdd(sum, c.reach), at));
|
|
2486
|
+
};
|
|
2303
2487
|
if (turns) {
|
|
2304
|
-
const reach = reachOf(m, asset, place.id,
|
|
2488
|
+
const reach = reachOf(m, asset, place.id, ends);
|
|
2305
2489
|
if (typeof reach === 'string') {
|
|
2306
2490
|
skipped.push({ part: place.id, reason: reach });
|
|
2307
2491
|
continue;
|
|
2308
2492
|
}
|
|
2309
2493
|
parts.push(place.id);
|
|
2494
|
+
if (rows.length) {
|
|
2495
|
+
/*
|
|
2496
|
+
Each copy turns about its own pivot, which lies on its rows' runs: along a row the copies reach the run's
|
|
2497
|
+
ends and the turn's reach past them; across them, the turn's reach about the pivot.
|
|
2498
|
+
*/
|
|
2499
|
+
for (const { run } of rows) {
|
|
2500
|
+
for (const l of run.lows)
|
|
2501
|
+
low[run.axis].push(linAdd(l, reach.radius, -1));
|
|
2502
|
+
for (const h of run.highs)
|
|
2503
|
+
high[run.axis].push(linAdd(h, reach.radius, 1));
|
|
2504
|
+
}
|
|
2505
|
+
for (const at of reach.centres)
|
|
2506
|
+
for (const axis of AXES.filter(a => !rowAxes.has(a))) {
|
|
2507
|
+
const lo = linAdd(at[axis], reach.radius, -1);
|
|
2508
|
+
const hi = linAdd(at[axis], reach.radius, 1);
|
|
2509
|
+
low[axis].push(lo);
|
|
2510
|
+
high[axis].push(hi);
|
|
2511
|
+
climbed(lo, -1, low, axis);
|
|
2512
|
+
climbed(hi, 1, high, axis);
|
|
2513
|
+
}
|
|
2514
|
+
continue;
|
|
2515
|
+
}
|
|
2310
2516
|
for (const at of reach.centres) {
|
|
2311
2517
|
const centre = Object.fromEntries(AXES.map(a => [a, linNorm(at[a])]));
|
|
2312
2518
|
const key = JSON.stringify(centre);
|
|
@@ -2322,64 +2528,50 @@ function occupancyLins(asset, over) {
|
|
|
2322
2528
|
max, so the answer holds whichever way the travel runs, and holds at every design size, because the
|
|
2323
2529
|
choice is made when the graph is evaluated rather than when the proposal is written.
|
|
2324
2530
|
*/
|
|
2325
|
-
const ends = over === 'rest' ? ['start'] : ['min', 'max'];
|
|
2326
2531
|
const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
|
|
2327
2532
|
const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
|
|
2328
2533
|
if (bad) {
|
|
2329
2534
|
skipped.push({ part: place.id, reason: String(bad) });
|
|
2330
2535
|
continue;
|
|
2331
2536
|
}
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
let climb;
|
|
2339
|
-
if (row) {
|
|
2340
|
-
rowAxis = String(row.params?.axis ?? '').toLowerCase();
|
|
2341
|
-
const parent = parentOf(m, place.id);
|
|
2342
|
-
const straight = parent && worldTurnOf(m, asset, parent);
|
|
2343
|
-
if (!parent || !AXES.includes(rowAxis) || !straight || AXES.some((_, i) => straight[i][i] !== 1)) {
|
|
2344
|
-
skipped.push({ part: place.id, reason: `${place.id} is a row whose run this command cannot read` });
|
|
2537
|
+
let offRun;
|
|
2538
|
+
for (const { part: template, run } of rows) {
|
|
2539
|
+
if (template === place.id) {
|
|
2540
|
+
/* The row's own part: along the row it takes the run. */
|
|
2541
|
+
low[run.axis].push(...run.lows);
|
|
2542
|
+
high[run.axis].push(...run.highs);
|
|
2345
2543
|
continue;
|
|
2346
2544
|
}
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2545
|
+
/*
|
|
2546
|
+
Carried by a row's part: its copies stand where that part's copies do, offset as it is from it. That part's
|
|
2547
|
+
centres lie half its length in from the run's ends.
|
|
2548
|
+
*/
|
|
2549
|
+
const theirs = ends.map(end => centreLin(m, asset, template, run.axis, end));
|
|
2550
|
+
const bad = theirs.find(c => typeof c === 'string');
|
|
2551
|
+
if (bad) {
|
|
2552
|
+
offRun = String(bad);
|
|
2553
|
+
break;
|
|
2353
2554
|
}
|
|
2354
|
-
const
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
reach = { terms: [], c: (record.count - 1) * per };
|
|
2365
|
-
else if (typeof record.pitch === 'number') {
|
|
2366
|
-
const mine = sizes[rowAxis][0];
|
|
2367
|
-
reach = { terms: [{ ref: theirs.ref, k: (theirs.k * per) / record.pitch }, { ref: mine.ref, k: (-mine.k * per) / record.pitch }], c: 0 };
|
|
2368
|
-
}
|
|
2369
|
-
else {
|
|
2370
|
-
skipped.push({ part: place.id, reason: `${place.id} is a rising row whose pitch follows a length; how far it rises is not added up` });
|
|
2371
|
-
continue;
|
|
2555
|
+
const along = readings.find(r => r.axis === run.axis).at;
|
|
2556
|
+
const inset = linAdd({ terms: [], c: 0 }, run.extent, 0.5);
|
|
2557
|
+
for (const [i, mine] of along.entries()) {
|
|
2558
|
+
const offset = linAdd(mine, theirs[i], -1);
|
|
2559
|
+
for (const e of sizes[run.axis]) {
|
|
2560
|
+
const half = { terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 };
|
|
2561
|
+
for (const l of run.lows)
|
|
2562
|
+
low[run.axis].push(linAdd(linAdd(linAdd(l, inset), offset), half, -1));
|
|
2563
|
+
for (const h of run.highs)
|
|
2564
|
+
high[run.axis].push(linAdd(linAdd(linAdd(h, inset, -1), offset), half));
|
|
2372
2565
|
}
|
|
2373
|
-
climb = { axis: record.rise.axis.toLowerCase(), up: per > 0, reach };
|
|
2374
|
-
}
|
|
2375
|
-
for (const centre of centres) {
|
|
2376
|
-
low[rowAxis].push(linAdd(centre, half, -1));
|
|
2377
|
-
high[rowAxis].push(linAdd(centre, half, 1));
|
|
2378
2566
|
}
|
|
2379
2567
|
}
|
|
2568
|
+
if (offRun) {
|
|
2569
|
+
skipped.push({ part: place.id, reason: offRun });
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2380
2572
|
parts.push(place.id);
|
|
2381
2573
|
for (const r of readings) {
|
|
2382
|
-
if (r.axis
|
|
2574
|
+
if (rowAxes.has(r.axis))
|
|
2383
2575
|
continue;
|
|
2384
2576
|
const halves = sizes[r.axis].map(e => ({ terms: [{ ref: e.ref, k: 0.5 * e.k }], c: 0 }));
|
|
2385
2577
|
const seen = new Set();
|
|
@@ -2389,10 +2581,12 @@ function occupancyLins(asset, over) {
|
|
|
2389
2581
|
continue; // a part that does not move gives the same candidate twice
|
|
2390
2582
|
seen.add(key);
|
|
2391
2583
|
for (const half of halves) {
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2584
|
+
const lo = linAdd(centre, half, -1);
|
|
2585
|
+
const hi = linAdd(centre, half, 1);
|
|
2586
|
+
low[r.axis].push(lo);
|
|
2587
|
+
high[r.axis].push(hi);
|
|
2588
|
+
climbed(lo, -1, low, r.axis);
|
|
2589
|
+
climbed(hi, 1, high, r.axis);
|
|
2396
2590
|
}
|
|
2397
2591
|
}
|
|
2398
2592
|
}
|
|
@@ -2846,7 +3040,7 @@ function proportionPlan(asset) {
|
|
|
2846
3040
|
plan.skipped.push({ what: `${part}.place`, reason: 'stands by no fastening; fasten it for its place to follow' });
|
|
2847
3041
|
}
|
|
2848
3042
|
for (const j of (asset.joints ?? [])) {
|
|
2849
|
-
const faceAxis = V3_FACES[j
|
|
3043
|
+
const faceAxis = V3_FACES[childFaceOf(m, j)];
|
|
2850
3044
|
const gap = j.origin?.gap;
|
|
2851
3045
|
if (typeof gap === 'number' && gap !== 0)
|
|
2852
3046
|
plan.lengths.push({ joint: j.id, at: 'gap', axis: faceAxis, times: share(gap, faceAxis) });
|