@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +40 -0
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +383 -47
- package/dist/v3-authoring-actions.js.map +1 -1
- package/dist/v3-graph.d.ts +15 -0
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +40 -0
- 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-part-edit.d.ts.map +1 -1
- package/dist/v3-part-edit.js +9 -3
- package/dist/v3-part-edit.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The part's own turn as a signed axis permutation, when it is a whole number of quarter turns; any other turn is
|
|
104
|
+
* refused and says the angle. With `freeze` the turn is fixed as it is: a part fastened by its faces keeps the turn
|
|
105
|
+
* its faces were measured in (chief architect's ruling 2026-09-25).
|
|
106
|
+
*/
|
|
107
|
+
function quarterTurnOf(m, asset, id, path, freeze) {
|
|
102
108
|
const turn = turnOf(m, asset, id, path);
|
|
103
109
|
if (turn.every(t => t === 0))
|
|
104
|
-
return
|
|
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
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 =
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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,
|
|
396
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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 =
|
|
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, [
|
|
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
|
-
|
|
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), ...
|
|
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:
|
|
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 ?
|
|
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`);
|