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