@hatiolab/figure-model 0.1.56 → 0.1.57
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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +85 -5
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +668 -113
- 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-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 +23 -0
- 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
|
@@ -31,6 +31,12 @@ const fail = (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);
|
|
@@ -74,12 +80,48 @@ function angleOf(m, asset, ref) {
|
|
|
74
80
|
return asset.designInputs[ref] ?? null;
|
|
75
81
|
return null;
|
|
76
82
|
}
|
|
83
|
+
/** 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. */
|
|
84
|
+
function turnOf(m, asset, id, path) {
|
|
85
|
+
return partOf(m, id, path).pose.args.slice(3, 6).map((ref) => angleOf(m, asset, ref));
|
|
86
|
+
}
|
|
87
|
+
const saidTurn = (turn) => AXES.map((a, i) => `${a.toUpperCase()} ${turn[i] ?? '?'}°`).join(', ');
|
|
77
88
|
/** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
|
|
78
89
|
function requireUnturned(m, asset, id, path) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
const turn = turnOf(m, asset, id, path);
|
|
91
|
+
if (turn.some(t => t !== 0))
|
|
92
|
+
fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); fastening to a turned part by its faces is not covered by this command`);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The part's size along each axis as it stands in its seat -- after its own turn. A turn by a whole number of
|
|
96
|
+
* quarter turns only swaps the axes (a cylinder laid on its side is 2r tall and its length deep), so its faces are
|
|
97
|
+
* still axis-aligned planes; any other turn is refused, and says the angle. The turn is then fixed as it is: a part
|
|
98
|
+
* fastened by its faces keeps the turn its faces were measured in (chief architect's ruling 2026-09-25).
|
|
99
|
+
*/
|
|
100
|
+
function standingRefsOf(m, asset, id, path, freeze = true) {
|
|
101
|
+
const own = sizeRefsOf(m, id, path);
|
|
102
|
+
const turn = turnOf(m, asset, id, path);
|
|
103
|
+
if (turn.every(t => t === 0))
|
|
104
|
+
return own;
|
|
105
|
+
if (turn.some(t => t === null || !Number.isFinite(t) || Math.abs(t / 90 - Math.round(t / 90)) > 1e-9))
|
|
106
|
+
fail('EDIT_TARGET', path, `${id} is turned (${saidTurn(turn)}); only quarter turns keep its faces level with the axes`);
|
|
107
|
+
const [rx, ry, rz] = turn.map(t => (t * Math.PI) / 180);
|
|
108
|
+
const cos = (a) => Math.round(Math.cos(a)), sin = (a) => Math.round(Math.sin(a));
|
|
109
|
+
const X = [[1, 0, 0], [0, cos(rx), -sin(rx)], [0, sin(rx), cos(rx)]];
|
|
110
|
+
const Y = [[cos(ry), 0, sin(ry)], [0, 1, 0], [-sin(ry), 0, cos(ry)]];
|
|
111
|
+
const Z = [[cos(rz), -sin(rz), 0], [sin(rz), cos(rz), 0], [0, 0, 1]];
|
|
112
|
+
const mul = (a, b) => a.map(row => b[0].map((_, j) => row.reduce((sum, v, k) => sum + v * b[k][j], 0)));
|
|
113
|
+
const r = mul(mul(X, Y), Z); // the order `rigid@1` turns in
|
|
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
|
+
const standing = {};
|
|
123
|
+
AXES.forEach((axis, i) => (standing[axis] = own[AXES[r[i].findIndex(v => v !== 0)]]));
|
|
124
|
+
return standing;
|
|
83
125
|
}
|
|
84
126
|
/** Fresh node ids under a part, so two commands never claim one id. */
|
|
85
127
|
function namer(m, prefix) {
|
|
@@ -228,6 +270,9 @@ function linkDimension(asset, a) {
|
|
|
228
270
|
const was = node.args[0];
|
|
229
271
|
const owned = ownInput(m, a.part, was) ? was : `${a.part}.size.${a.axis}`;
|
|
230
272
|
node.args = [value, node.args[1]];
|
|
273
|
+
// Joints read this part's size; made again from their records, they read the size as it now is.
|
|
274
|
+
if (asset.joints?.length)
|
|
275
|
+
rewriteJoints(asset);
|
|
231
276
|
// The part's own control is no longer read by anything; leaving it would show a dial that changes nothing.
|
|
232
277
|
dropIfUnused(asset, was, owned);
|
|
233
278
|
return asset;
|
|
@@ -258,6 +303,8 @@ function unlinkDimension(asset, a) {
|
|
|
258
303
|
const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
|
|
259
304
|
const was = node.args[0];
|
|
260
305
|
node.args = [id, node.args[1]];
|
|
306
|
+
if (asset.joints?.length)
|
|
307
|
+
rewriteJoints(asset);
|
|
261
308
|
dropIfUnused(asset, was);
|
|
262
309
|
return asset;
|
|
263
310
|
}
|
|
@@ -293,8 +340,8 @@ function dropIfUnused(asset, ref, ownedInput) {
|
|
|
293
340
|
function parentOf(m, id) {
|
|
294
341
|
if (!nodeById(m, `${id}.chain`))
|
|
295
342
|
return null;
|
|
296
|
-
// The seat
|
|
297
|
-
const above = nodeById(m, `${id}.
|
|
343
|
+
// The seat rigid carries the part into the frame it is fastened to, moving or not (an old asset may have only a motion).
|
|
344
|
+
const above = nodeById(m, `${id}.seat.rigid`) ?? nodeById(m, `${id}.motion`);
|
|
298
345
|
const match = /^(.*)\.local$/.exec(String(above?.params?.to));
|
|
299
346
|
return match && match[1] !== id ? match[1] : null;
|
|
300
347
|
}
|
|
@@ -327,10 +374,9 @@ function attach(asset, a) {
|
|
|
327
374
|
for (let up = to.part; up; up = parentOf(m, up))
|
|
328
375
|
if (up === a.part)
|
|
329
376
|
fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
|
|
330
|
-
const mine = sizeRefsOf(m, a.part, path);
|
|
331
377
|
const theirs = sizeRefsOf(m, to.part, path);
|
|
332
|
-
requireUnturned(m, asset, a.part, path);
|
|
333
378
|
requireUnturned(m, asset, to.part, path);
|
|
379
|
+
const mine = standingRefsOf(m, asset, a.part, path);
|
|
334
380
|
const name = namer(m, `${a.part}.on.${to.part}`);
|
|
335
381
|
const sign = SIGN[to.face];
|
|
336
382
|
/*
|
|
@@ -339,13 +385,21 @@ function attach(asset, a) {
|
|
|
339
385
|
*/
|
|
340
386
|
const outward = facing === 'meet' ? sign : -sign;
|
|
341
387
|
const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
|
|
388
|
+
/*
|
|
389
|
+
Split at the point fastened at: the seat is on the target's face (its centre, or level with an edge), the part's
|
|
390
|
+
centre is half its own depth and the gap past it. A turn added later turns about that point.
|
|
391
|
+
*/
|
|
392
|
+
const zero = constantOf(m, 'mm', 0, 'd');
|
|
342
393
|
const translation = { x: '', y: '', z: '' };
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
394
|
+
const offset = { x: zero, y: zero, z: zero };
|
|
395
|
+
translation[axis] = sumOf(m, name, [{ ref: theirs[axis].ref, k: sign * 0.5 * theirs[axis].k }], `seat.${axis}`);
|
|
396
|
+
offset[axis] = sumOf(m, name, [{ ref: mine[axis].ref, k: outward * 0.5 * mine[axis].k }, { ref: gap, k: outward }], `along.${axis}`);
|
|
397
|
+
/*
|
|
398
|
+
Across the face. Centred by default; min and max keep the two parts' edges level as either is resized -- and the
|
|
399
|
+
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
|
+
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.
|
|
402
|
+
*/
|
|
349
403
|
for (const other of AXES.filter(x => x !== axis)) {
|
|
350
404
|
const how = a.align?.[other] ?? 'centre';
|
|
351
405
|
if (how === 'centre')
|
|
@@ -354,12 +408,13 @@ function attach(asset, a) {
|
|
|
354
408
|
translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
|
|
355
409
|
else if (how === 'min' || how === 'max') {
|
|
356
410
|
const s = how === 'min' ? -1 : 1;
|
|
357
|
-
translation[other] = sumOf(m, name, [{ ref: theirs[other].ref, k: s * 0.5 * theirs[other].k }
|
|
411
|
+
translation[other] = sumOf(m, name, [{ ref: theirs[other].ref, k: s * 0.5 * theirs[other].k }], `edge.${other}`);
|
|
412
|
+
offset[other] = sumOf(m, name, [{ ref: mine[other].ref, k: -s * 0.5 * mine[other].k }], `own-edge.${other}`);
|
|
358
413
|
}
|
|
359
414
|
else
|
|
360
415
|
fail('SCHEMA', path, `${String(how)} is not an alignment`);
|
|
361
416
|
}
|
|
362
|
-
reseat(asset, a.part, `${to.part}.local`, [translation.x, translation.y, translation.z]);
|
|
417
|
+
reseat(asset, a.part, `${to.part}.local`, { seat: [translation.x, translation.y, translation.z], pose: [offset.x, offset.y, offset.z] });
|
|
363
418
|
return asset;
|
|
364
419
|
}
|
|
365
420
|
/**
|
|
@@ -377,8 +432,7 @@ function standOnPlane(asset, a, path) {
|
|
|
377
432
|
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
433
|
if (parentOf(m, a.part) && !a.replace)
|
|
379
434
|
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
|
-
requireUnturned(m, asset, a.part, path);
|
|
435
|
+
const mine = standingRefsOf(m, asset, a.part, path);
|
|
382
436
|
const name = namer(m, `${a.part}.on.plane`);
|
|
383
437
|
const outward = a.face === 'bottom' ? 1 : -1;
|
|
384
438
|
const gap = measureRef(m, asset, name, a.gap, 'gap.y', path);
|
|
@@ -393,7 +447,9 @@ function standOnPlane(asset, a, path) {
|
|
|
393
447
|
else
|
|
394
448
|
fail('SCHEMA', path, `the plane has no edges to line ${a.part} up with on ${other}; give the centre or a measured offset`);
|
|
395
449
|
}
|
|
396
|
-
|
|
450
|
+
// The point it stands on is on the plane, under its centre: a turn about Y turns it where it stands.
|
|
451
|
+
const zero = constantOf(m, 'mm', 0, 'd');
|
|
452
|
+
reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat: [translation.x, zero, translation.z], pose: [zero, translation.y, zero] });
|
|
397
453
|
return asset;
|
|
398
454
|
}
|
|
399
455
|
function detach(asset, a) {
|
|
@@ -434,8 +490,15 @@ function detach(asset, a) {
|
|
|
434
490
|
The part gets its own position numbers back, as a part made in the modeller has them, so it can be moved again
|
|
435
491
|
by hand or by the gizmo. Where one of those names is taken, that axis stays a plain number.
|
|
436
492
|
*/
|
|
493
|
+
/*
|
|
494
|
+
A part that moves keeps turning about the point it was fastened at: that point becomes its seat in the asset, its
|
|
495
|
+
own numbers the offset from it. A part that does not move gets its place back as plain position numbers.
|
|
496
|
+
*/
|
|
497
|
+
const seatAt = evaluated.values?.[`${a.part}.seat.value`]?.t ?? [0, 0, 0];
|
|
498
|
+
const moving = !!nodeById(m, `${a.part}.motion`);
|
|
499
|
+
const pivot = AXES.map((_, i) => round6(above.t[i] + seatAt[i]));
|
|
437
500
|
const translation = AXES.map((axis, i) => {
|
|
438
|
-
const value = round6(
|
|
501
|
+
const value = moving ? round6(own.t[i]) : round6(pivot[i] + own.t[i]);
|
|
439
502
|
const id = `${a.part}.position.${axis}`;
|
|
440
503
|
if (m.inputs.some((input) => input.id === id) || m.nodes.some((n) => Object.values(n.outputs).includes(id)))
|
|
441
504
|
return constantOf(m, 'mm', value, 'd');
|
|
@@ -443,7 +506,8 @@ function detach(asset, a) {
|
|
|
443
506
|
asset.designInputs[id] = value;
|
|
444
507
|
return id;
|
|
445
508
|
});
|
|
446
|
-
|
|
509
|
+
const seat = AXES.map((_, i) => constantOf(m, 'mm', moving ? pivot[i] : 0, 'd'));
|
|
510
|
+
reseat(asset, a.part, asset.document.capabilities.assetFrame, { seat, pose: translation });
|
|
447
511
|
return asset;
|
|
448
512
|
}
|
|
449
513
|
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
@@ -451,36 +515,55 @@ const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
|
451
515
|
* Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
|
|
452
516
|
* The part's own id, its shape and its `placed` reference all stay as they were.
|
|
453
517
|
*/
|
|
454
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Where a part is fastened, in two translations that are never merged: `seat` is the point it is fastened at, in the
|
|
520
|
+
* frame it is fastened to -- a point on the target's face; `pose` is the part's centre from that point, in its own
|
|
521
|
+
* seat frame. A motion sits between the two, so a turn turns about the point the part is fastened at: a hinge, a
|
|
522
|
+
* joint, a lid (chief architect's ruling 2026-09-25; before this the seat was the target's centre and a fastened
|
|
523
|
+
* part swung about the middle of what it was fastened to).
|
|
524
|
+
*
|
|
525
|
+
* <id>.local --pose--> <id>.seat [--motion--> <id>.joint] --seat.rigid--> <to>
|
|
526
|
+
*
|
|
527
|
+
* Without `place` the part keeps both translations and only its frames are rewired (a motion added or taken away).
|
|
528
|
+
*/
|
|
529
|
+
function reseat(asset, id, to, place) {
|
|
455
530
|
const m = asset.document.model;
|
|
456
|
-
const { place, pose } = partOf(m, id, id);
|
|
457
|
-
const old =
|
|
458
|
-
if (
|
|
459
|
-
pose.args = [...
|
|
531
|
+
const { place: placed, pose } = partOf(m, id, id);
|
|
532
|
+
const old = place ? pose.args.slice(0, 3) : [];
|
|
533
|
+
if (place)
|
|
534
|
+
pose.args = [...place.pose, ...pose.args.slice(3)];
|
|
460
535
|
pose.params = { ...pose.params, to: `${id}.seat` };
|
|
461
536
|
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
|
-
}
|
|
537
|
+
const zero = constantOf(m, 'mm', 0, 'd');
|
|
538
|
+
const noTurn = constantOf(m, 'deg', 0, 'a');
|
|
470
539
|
let seat = nodeById(m, `${id}.seat.rigid`);
|
|
471
|
-
if (!
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
540
|
+
if (!seat) {
|
|
541
|
+
seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: {} };
|
|
542
|
+
m.nodes.push(seat);
|
|
543
|
+
}
|
|
544
|
+
const oldSeat = place ? seat.args.slice(0, 3) : [];
|
|
545
|
+
if (place)
|
|
546
|
+
seat.args = [...place.seat, ...seat.args.slice(3)];
|
|
547
|
+
seat.params = { from: motion ? `${id}.joint` : `${id}.seat`, to };
|
|
548
|
+
// The motion turns the seat frame about its origin -- the point fastened at -- into the joint frame.
|
|
549
|
+
let turned = nodeById(m, `${id}.turned`);
|
|
550
|
+
if (motion) {
|
|
551
|
+
motion.params = { ...motion.params, from: `${id}.seat`, to: `${id}.joint` };
|
|
552
|
+
if (!turned) {
|
|
553
|
+
turned = { id: `${id}.turned`, op: 'compose@1', args: [motion.outputs.pose, pose.outputs.pose], outputs: { pose: `${id}.turned.value` } };
|
|
554
|
+
m.nodes.push(turned);
|
|
477
555
|
}
|
|
478
556
|
else
|
|
479
|
-
|
|
480
|
-
chain.args = [seat.outputs.pose, pose.outputs.pose];
|
|
557
|
+
turned.args = [motion.outputs.pose, pose.outputs.pose];
|
|
481
558
|
}
|
|
482
|
-
else
|
|
483
|
-
|
|
559
|
+
else if (turned)
|
|
560
|
+
m.nodes = m.nodes.filter((n) => n !== turned);
|
|
561
|
+
let chain = nodeById(m, `${id}.chain`);
|
|
562
|
+
if (!chain) {
|
|
563
|
+
chain = { id: `${id}.chain`, op: 'compose@1', args: ['', ''], outputs: { pose: `${id}.chain.value` } };
|
|
564
|
+
m.nodes.push(chain);
|
|
565
|
+
}
|
|
566
|
+
chain.args = [seat.outputs.pose, motion ? turned.outputs.pose : pose.outputs.pose];
|
|
484
567
|
const parent = /^(.*)\.local$/.exec(to);
|
|
485
568
|
let world = nodeById(m, `${id}.world`);
|
|
486
569
|
if (parent && parent[1] !== id) {
|
|
@@ -491,18 +574,18 @@ function reseat(asset, id, to, translation) {
|
|
|
491
574
|
}
|
|
492
575
|
else
|
|
493
576
|
world.args = [above, chain.outputs.pose];
|
|
494
|
-
|
|
577
|
+
placed.args = [placed.args[0], world.outputs.pose];
|
|
495
578
|
}
|
|
496
579
|
else {
|
|
497
580
|
if (world)
|
|
498
581
|
m.nodes = m.nodes.filter((n) => n !== world);
|
|
499
|
-
|
|
582
|
+
placed.args = [placed.args[0], chain.outputs.pose];
|
|
500
583
|
}
|
|
501
584
|
/*
|
|
502
585
|
The part's own position numbers, once nothing reads them, go with the old pose: a fastened part left its
|
|
503
586
|
position fields in the editor, and typing into them changed nothing (seen on :3300, 2026-09-24).
|
|
504
587
|
*/
|
|
505
|
-
for (const ref of old)
|
|
588
|
+
for (const ref of [...old, ...oldSeat])
|
|
506
589
|
dropIfUnused(asset, ref, ownInput(m, id, ref) ? ref : undefined);
|
|
507
590
|
}
|
|
508
591
|
/** Apply one authoring command. Throws on any refusal, leaving the asset it was given untouched. */
|
|
@@ -519,16 +602,20 @@ export function applyV3Authoring(source, action) {
|
|
|
519
602
|
unlinkDimension(asset, action);
|
|
520
603
|
break;
|
|
521
604
|
case 'attach':
|
|
522
|
-
|
|
605
|
+
fastenAsJoint(asset, action);
|
|
523
606
|
break;
|
|
524
|
-
case 'detach':
|
|
525
|
-
|
|
607
|
+
case 'detach': {
|
|
608
|
+
const joint = jointAbove(asset, action.part);
|
|
609
|
+
if (!joint)
|
|
610
|
+
fail('EDIT_TARGET', action.part, `${action.part} is not fastened to anything`);
|
|
611
|
+
removeJoint(asset, joint.id);
|
|
526
612
|
break;
|
|
527
|
-
|
|
528
|
-
|
|
613
|
+
}
|
|
614
|
+
case 'set-joint':
|
|
615
|
+
setJoint(asset, action.joint);
|
|
529
616
|
break;
|
|
530
|
-
case 'remove-
|
|
531
|
-
|
|
617
|
+
case 'remove-joint':
|
|
618
|
+
removeJoint(asset, action.id);
|
|
532
619
|
break;
|
|
533
620
|
case 'declare-occupancy':
|
|
534
621
|
declareOccupancy(asset, action);
|
|
@@ -581,6 +668,9 @@ export function applyV3Authoring(source, action) {
|
|
|
581
668
|
default:
|
|
582
669
|
fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
|
|
583
670
|
}
|
|
671
|
+
/* The joint records are the canon: whatever the command reshaped, their nodes are made again from them. */
|
|
672
|
+
if (asset.joints?.length && action.kind !== 'set-joint' && action.kind !== 'remove-joint')
|
|
673
|
+
rewriteJoints(asset);
|
|
584
674
|
compileV3Asset(asset);
|
|
585
675
|
holdsOverDeclaredSize(asset, source);
|
|
586
676
|
return asset;
|
|
@@ -683,12 +773,258 @@ export function v3AttachmentsOf(asset) {
|
|
|
683
773
|
out[n.id] = parentOf(m, n.id);
|
|
684
774
|
return out;
|
|
685
775
|
}
|
|
776
|
+
/* ------------------------------------------------------------------ joints */
|
|
777
|
+
/** The joint whose child is this part, if any. */
|
|
778
|
+
function jointAbove(asset, part) {
|
|
779
|
+
return asset.joints?.find(j => j.body1.part === part);
|
|
780
|
+
}
|
|
781
|
+
const stateIdOf = (j) => j.state?.id ?? j.id;
|
|
782
|
+
/** A record that is well formed on its own, and fits the tree the others make. */
|
|
783
|
+
function checkJoint(asset, j) {
|
|
784
|
+
const m = asset.document.model;
|
|
785
|
+
const path = `joints.${String(j?.id)}`;
|
|
786
|
+
if (!j || typeof j.id !== 'string' || !j.id.trim())
|
|
787
|
+
fail('JOINT_SCHEMA', 'joints', 'a joint has an id');
|
|
788
|
+
if (!V3_JOINT_TYPES.includes(j.type))
|
|
789
|
+
fail('JOINT_SCHEMA', path, `${String(j.type)} is not a joint type; ${V3_JOINT_TYPES.join(', ')}`);
|
|
790
|
+
if (!j.body1 || typeof j.body1.part !== 'string')
|
|
791
|
+
fail('JOINT_SCHEMA', path, 'a joint carries a part (body1)');
|
|
792
|
+
partOf(m, j.body1.part, path);
|
|
793
|
+
if (!j.body0 || (!('plane' in j.body0) && typeof j.body0.part !== 'string'))
|
|
794
|
+
fail('JOINT_SCHEMA', path, 'a joint hangs from a part or the mounting plane (body0)');
|
|
795
|
+
if ('part' in j.body0)
|
|
796
|
+
partOf(m, j.body0.part, path);
|
|
797
|
+
const others = (asset.joints ?? []).filter(o => o.id !== j.id);
|
|
798
|
+
const above = others.find(o => o.body1.part === j.body1.part);
|
|
799
|
+
if (above)
|
|
800
|
+
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`);
|
|
801
|
+
if ('part' in j.body0) {
|
|
802
|
+
const parentOfPart = (part) => others.find(o => o.body1.part === part)?.body0;
|
|
803
|
+
for (let up = j.body0.part; up;) {
|
|
804
|
+
if (up === j.body1.part)
|
|
805
|
+
fail('ATTACH_CYCLE', path, `${j.body0.part} already hangs from ${j.body1.part}; ${j.body1.part} would hang from itself`);
|
|
806
|
+
const next = parentOfPart(up);
|
|
807
|
+
up = next && 'part' in next ? next.part : undefined;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const moving = j.type !== 'fixed';
|
|
811
|
+
const limited = j.lowerLimit !== undefined || j.upperLimit !== undefined;
|
|
812
|
+
if (moving && !['X', 'Y', 'Z'].includes(j.axis))
|
|
813
|
+
fail('JOINT_SCHEMA', path, `a ${j.type} joint has an axis, X, Y or Z`);
|
|
814
|
+
if (!moving && (j.axis !== undefined || limited || j.mimic !== undefined || j.travel !== undefined))
|
|
815
|
+
fail('JOINT_SCHEMA', path, 'a fixed joint has no axis, limits, mimic or travel');
|
|
816
|
+
if (j.travel !== undefined && j.type !== 'prismatic')
|
|
817
|
+
fail('JOINT_SCHEMA', path, 'only a prismatic joint travels a length');
|
|
818
|
+
if (j.type === 'prismatic' && j.travel && !limited) {
|
|
819
|
+
j.lowerLimit = 0;
|
|
820
|
+
j.upperLimit = 1;
|
|
821
|
+
}
|
|
822
|
+
/* A revolute joint without limits turns without end (USD); with them, both are given. A slide always has them. */
|
|
823
|
+
if (moving && !j.mimic && (limited || j.type === 'prismatic')) {
|
|
824
|
+
const [lo, hi] = [j.lowerLimit, j.upperLimit];
|
|
825
|
+
if (!(Number.isFinite(lo) && Number.isFinite(hi) && lo < hi))
|
|
826
|
+
fail('JOINT_SCHEMA', path, `a ${j.type} joint has both limits, lower below upper`);
|
|
827
|
+
if (!(lo <= 0 && 0 <= hi))
|
|
828
|
+
fail('JOINT_SCHEMA', path, 'the rest pose is the joint at 0, so its limits hold 0');
|
|
829
|
+
}
|
|
830
|
+
if (j.mimic) {
|
|
831
|
+
const leader = others.find(o => o.id === j.mimic.joint);
|
|
832
|
+
if (!leader || leader.type === 'fixed')
|
|
833
|
+
fail('JOINT_SCHEMA', path, `${String(j.mimic.joint)} is not a moving joint to follow`);
|
|
834
|
+
if (leader.mimic)
|
|
835
|
+
fail('JOINT_SCHEMA', path, `${leader.id} follows another joint; follow the one it follows`);
|
|
836
|
+
if ((leader.type === 'prismatic') !== (j.type === 'prismatic'))
|
|
837
|
+
fail('JOINT_SCHEMA', path, 'a joint follows one that moves the same way: a slide a slide, a turn a turn');
|
|
838
|
+
if (!Number.isFinite(j.mimic.multiplier))
|
|
839
|
+
fail('JOINT_SCHEMA', path, 'a mimic has a multiplier');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Write a joint's nodes from its record: the child's quarter turn, the fastening (the seat on the parent's face and
|
|
844
|
+
* 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
|
|
845
|
+
* are chosen the same way each time, so writing a record again gives the same nodes.
|
|
846
|
+
*/
|
|
847
|
+
function writeJoint(asset, j) {
|
|
848
|
+
const m = asset.document.model;
|
|
849
|
+
const child = j.body1.part;
|
|
850
|
+
const { pose } = partOf(m, child, child);
|
|
851
|
+
const old = pose.args.slice(3, 6);
|
|
852
|
+
pose.args = [...pose.args.slice(0, 3), ...AXES.map(a => constantOf(m, 'deg', j.origin?.turn?.[a] ?? 0, 'a'))];
|
|
853
|
+
for (const ref of old)
|
|
854
|
+
dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
|
|
855
|
+
attach(asset, {
|
|
856
|
+
kind: 'attach',
|
|
857
|
+
part: child,
|
|
858
|
+
face: j.body1.face,
|
|
859
|
+
to: 'plane' in j.body0 ? { plane: 'mounting-plane' } : { part: j.body0.part, face: j.body0.face },
|
|
860
|
+
...(j.origin?.facing ? { facing: j.origin.facing } : {}),
|
|
861
|
+
...(j.origin?.gap !== undefined ? { gap: j.origin.gap } : {}),
|
|
862
|
+
...(j.origin?.align ? { align: j.origin.align } : {}),
|
|
863
|
+
replace: true
|
|
864
|
+
});
|
|
865
|
+
if (j.type === 'fixed')
|
|
866
|
+
return;
|
|
867
|
+
const kind = j.type === 'prismatic' ? 'slide' : 'turn';
|
|
868
|
+
const unit = j.type === 'prismatic' ? (j.travel ? 'ratio' : 'mm') : 'deg';
|
|
869
|
+
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
|
+
const leader = j.mimic && (asset.joints ?? []).find(o => o.id === j.mimic.joint);
|
|
871
|
+
addMotion(asset, {
|
|
872
|
+
kind: 'add-motion',
|
|
873
|
+
part: child,
|
|
874
|
+
motion: { kind, axis: j.axis.toLowerCase() },
|
|
875
|
+
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
|
+
...(j.travel ? { travel: j.travel } : {}),
|
|
877
|
+
replace: true
|
|
878
|
+
}, leader ? { of: stateIdOf(leader), k: j.mimic.multiplier, c: j.mimic.offset ?? 0 } : undefined);
|
|
879
|
+
}
|
|
880
|
+
/** Take a joint's nodes away, leaving its child standing in the asset frame at the origin. */
|
|
881
|
+
function stripJoint(asset, j) {
|
|
882
|
+
const m = asset.document.model;
|
|
883
|
+
const child = j.body1.part;
|
|
884
|
+
if (nodeById(m, `${child}.motion`))
|
|
885
|
+
removeMotion(asset, { kind: 'remove-motion', part: child });
|
|
886
|
+
const { pose, place } = partOf(m, child, child);
|
|
887
|
+
const owned = new Set([`${child}.seat.rigid`, `${child}.chain`, `${child}.world`, `${child}.turned`]);
|
|
888
|
+
m.nodes = m.nodes.filter((n) => !owned.has(n.id) && !n.id.startsWith(`${child}.on.`));
|
|
889
|
+
const zero = constantOf(m, 'mm', 0, 'd');
|
|
890
|
+
const old = pose.args.slice(0, 3);
|
|
891
|
+
pose.args = [zero, zero, zero, ...pose.args.slice(3)];
|
|
892
|
+
pose.params = { ...pose.params, to: asset.document.capabilities.assetFrame };
|
|
893
|
+
// Its own position numbers, if it still had them, place nothing now: the joint places it.
|
|
894
|
+
for (const ref of old)
|
|
895
|
+
dropIfUnused(asset, ref, ownInput(m, child, ref) ? ref : undefined);
|
|
896
|
+
place.args = [place.args[0], pose.outputs.pose];
|
|
897
|
+
}
|
|
898
|
+
/** Joints in an order each can be written in: its parent's joint and the joint it mimics first. */
|
|
899
|
+
function writingOrder(joints) {
|
|
900
|
+
const done = new Set();
|
|
901
|
+
const out = [];
|
|
902
|
+
const byChild = new Map(joints.map(j => [j.body1.part, j]));
|
|
903
|
+
while (out.length < joints.length) {
|
|
904
|
+
const ready = joints.filter(j => {
|
|
905
|
+
if (done.has(j.id))
|
|
906
|
+
return false;
|
|
907
|
+
const up = 'part' in j.body0 ? byChild.get(j.body0.part) : undefined;
|
|
908
|
+
return (!up || done.has(up.id)) && (!j.mimic || done.has(j.mimic.joint));
|
|
909
|
+
});
|
|
910
|
+
if (!ready.length)
|
|
911
|
+
fail('JOINT_TREE', 'joints', 'the joints do not make a tree');
|
|
912
|
+
for (const j of ready) {
|
|
913
|
+
done.add(j.id);
|
|
914
|
+
out.push(j);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return out;
|
|
918
|
+
}
|
|
919
|
+
/** Every joint's nodes again, from the records: the ones below first away, then all written parents first. */
|
|
920
|
+
function rewriteJoints(asset) {
|
|
921
|
+
const joints = (asset.joints ?? []);
|
|
922
|
+
const order = writingOrder(joints);
|
|
923
|
+
const keep = { ...asset.stateDefaults };
|
|
924
|
+
for (const j of [...order].reverse())
|
|
925
|
+
stripJoint(asset, j);
|
|
926
|
+
for (const j of order)
|
|
927
|
+
writeJoint(asset, j);
|
|
928
|
+
// A joint's value as the author left it (「지금 자세를 기본으로」) is theirs, not the record's.
|
|
929
|
+
for (const j of order)
|
|
930
|
+
if (j.type !== 'fixed' && !j.mimic && keep[stateIdOf(j)] !== undefined)
|
|
931
|
+
asset.stateDefaults[stateIdOf(j)] = keep[stateIdOf(j)];
|
|
932
|
+
}
|
|
933
|
+
function setJoint(asset, joint) {
|
|
934
|
+
// As JSON: a field left undefined is a field not there, which is how the asset stores it.
|
|
935
|
+
const j = JSON.parse(JSON.stringify(joint ?? null));
|
|
936
|
+
checkJoint(asset, j);
|
|
937
|
+
/* The child's own quarter turn becomes the record's, the first time it is fastened. */
|
|
938
|
+
if (!jointAbove(asset, j.body1.part) && !j.origin?.turn) {
|
|
939
|
+
const turn = turnOf(asset.document.model, asset, j.body1.part, j.body1.part);
|
|
940
|
+
if (turn.some(t => t !== 0))
|
|
941
|
+
j.origin = { ...(j.origin ?? {}), turn: Object.fromEntries(AXES.map((a, i) => [a, turn[i]])) };
|
|
942
|
+
}
|
|
943
|
+
const joints = (asset.joints ?? []).filter(o => o.id !== j.id);
|
|
944
|
+
const followers = joints.filter(o => o.mimic?.joint === j.id);
|
|
945
|
+
if (followers.length && j.type === 'fixed')
|
|
946
|
+
fail('JOINT_SCHEMA', `joints.${j.id}`, `${followers.map(f => f.id).join(', ')} follow ${j.id}; it cannot stop moving`);
|
|
947
|
+
asset.joints = [...joints, j];
|
|
948
|
+
rewriteJoints(asset);
|
|
949
|
+
}
|
|
950
|
+
/** The joint goes; its child keeps where it stands now, as plain position numbers. */
|
|
951
|
+
function removeJoint(asset, id) {
|
|
952
|
+
const joints = (asset.joints ?? []);
|
|
953
|
+
const j = joints.find(o => o.id === id);
|
|
954
|
+
if (!j)
|
|
955
|
+
fail('EDIT_TARGET', `joints.${id}`, `there is no joint ${id}`);
|
|
956
|
+
const followers = joints.filter(o => o.mimic?.joint === id);
|
|
957
|
+
if (followers.length)
|
|
958
|
+
fail('JOINT_SCHEMA', `joints.${id}`, `${followers.map(f => f.id).join(', ')} follow ${id}; remove them first`);
|
|
959
|
+
const m = asset.document.model;
|
|
960
|
+
const child = j.body1.part;
|
|
961
|
+
const evaluated = compileV3Asset(asset).evaluate();
|
|
962
|
+
const placed = nodeById(m, child);
|
|
963
|
+
const at = evaluated.values?.[placed.args[1]];
|
|
964
|
+
if (!at?.t)
|
|
965
|
+
fail('TARGET_ABSENT', child, `${child} has no pose to read`);
|
|
966
|
+
const own = m.nodes.find((n) => n.id === `${child}.pose`);
|
|
967
|
+
const turned = (r, want) => r.some((row, i) => row.some((v, k) => Math.abs(v - want[i][k]) > 1e-9));
|
|
968
|
+
const ownTurn = evaluated.values?.[own.outputs.pose]?.r;
|
|
969
|
+
if (ownTurn && turned(at.r, ownTurn))
|
|
970
|
+
fail('EDIT_TARGET', child, `${child} stands turned by what it hangs from; taking the joint away would turn it`);
|
|
971
|
+
asset.joints = joints.filter(o => o.id !== id);
|
|
972
|
+
stripJoint(asset, j);
|
|
973
|
+
const translation = AXES.map((axis, i) => {
|
|
974
|
+
const input = `${child}.position.${axis}`;
|
|
975
|
+
const value = round6(at.t[i]);
|
|
976
|
+
if (!m.inputs.some((n) => n.id === input))
|
|
977
|
+
m.inputs.push({ id: input, unit: 'mm', min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
978
|
+
asset.designInputs[input] = value;
|
|
979
|
+
return input;
|
|
980
|
+
});
|
|
981
|
+
own.args = [...translation, ...own.args.slice(3)];
|
|
982
|
+
// Parts hanging from it are written again: they hang from where it stands now.
|
|
983
|
+
rewriteJoints(asset);
|
|
984
|
+
}
|
|
985
|
+
/** `attach` is the modeller's word for a fixed joint: the record says it, whatever joint was there keeps its id and motion. */
|
|
986
|
+
function fastenAsJoint(asset, a) {
|
|
987
|
+
const was = jointAbove(asset, a.part);
|
|
988
|
+
if (was && !a.replace)
|
|
989
|
+
fail('ATTACH_REPLACED', a.part, `${a.part} is already fastened by ${was.id}; pass replace to move it, or detach it first`);
|
|
990
|
+
const to = a.to;
|
|
991
|
+
setJoint(asset, {
|
|
992
|
+
...(was ?? { id: a.part, type: 'fixed' }),
|
|
993
|
+
body0: to?.plane !== undefined ? { plane: to.plane } : { part: to?.part, face: to?.face },
|
|
994
|
+
body1: { part: a.part, face: a.face },
|
|
995
|
+
origin: {
|
|
996
|
+
...(was?.origin?.turn ? { turn: was.origin.turn } : {}),
|
|
997
|
+
...(a.facing ? { facing: a.facing } : {}),
|
|
998
|
+
...(a.gap !== undefined ? { gap: a.gap } : {}),
|
|
999
|
+
...(a.align ? { align: a.align } : {})
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* The records are the canon: made again from them, the joints' nodes must be the nodes the asset holds. An asset
|
|
1005
|
+
* whose nodes or records were edited apart is refused, not quietly rebuilt (ADR-0093, chief architect's condition).
|
|
1006
|
+
*/
|
|
1007
|
+
export function checkV3Joints(asset) {
|
|
1008
|
+
const joints = asset.joints;
|
|
1009
|
+
if (!joints?.length)
|
|
1010
|
+
return;
|
|
1011
|
+
const again = structuredClone(asset);
|
|
1012
|
+
for (const j of joints)
|
|
1013
|
+
checkJoint({ ...again, joints: joints.filter(o => o.id !== j.id).concat(j) }, j);
|
|
1014
|
+
rewriteJoints(again);
|
|
1015
|
+
const canon = (model) => JSON.stringify({
|
|
1016
|
+
nodes: [...model.nodes].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
1017
|
+
inputs: [...model.inputs].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
1018
|
+
});
|
|
1019
|
+
if (canon(again.document.model) !== canon(asset.document.model))
|
|
1020
|
+
fail('JOINT_DRIFT', 'joints', 'the joints\' nodes are not what their records make; the asset was edited apart from its joints');
|
|
1021
|
+
}
|
|
686
1022
|
/** The frame a part is fastened into: its parent's, or the asset's. */
|
|
687
1023
|
const attachmentFrameOf = (asset, id) => {
|
|
688
1024
|
const parent = parentOf(asset.document.model, id);
|
|
689
1025
|
return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
|
|
690
1026
|
};
|
|
691
|
-
function addMotion(asset, a) {
|
|
1027
|
+
function addMotion(asset, a, follow) {
|
|
692
1028
|
const m = asset.document.model;
|
|
693
1029
|
const path = a.part;
|
|
694
1030
|
partOf(m, a.part, path);
|
|
@@ -707,26 +1043,40 @@ function addMotion(asset, a) {
|
|
|
707
1043
|
else if (frame !== 'attachment' && frame !== 'asset')
|
|
708
1044
|
fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
|
|
709
1045
|
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
1046
|
const name = namer(m, `${a.part}.motion`);
|
|
729
|
-
let quantity = st
|
|
1047
|
+
let quantity = st?.id;
|
|
1048
|
+
if (follow) {
|
|
1049
|
+
/* A mimic: the leader's value times k plus c, and no control of its own. */
|
|
1050
|
+
const scaledId = name('mimic');
|
|
1051
|
+
m.nodes.push({ id: scaledId, op: 'mul@1', args: [follow.of, constantOf(m, 'ratio', follow.k, 'k')], outputs: { value: `${scaledId}.value` } });
|
|
1052
|
+
quantity = `${scaledId}.value`;
|
|
1053
|
+
if (follow.c) {
|
|
1054
|
+
const plus = name('mimic.offset');
|
|
1055
|
+
const unitOf = m.inputs.find((i) => i.id === follow.of)?.unit ?? 'deg';
|
|
1056
|
+
m.nodes.push({ id: plus, op: 'add@1', args: [quantity, constantOf(m, unitOf, follow.c, 'd')], outputs: { value: `${plus}.value` } });
|
|
1057
|
+
quantity = `${plus}.value`;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
if (!st || typeof st.id !== 'string' || !st.id.trim())
|
|
1062
|
+
fail('SCHEMA', path, 'a state input id is required');
|
|
1063
|
+
if (m.inputs.some((i) => i.id === st.id))
|
|
1064
|
+
fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
|
|
1065
|
+
const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
|
|
1066
|
+
if (!wanted.includes(st.unit))
|
|
1067
|
+
fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
|
|
1068
|
+
if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
|
|
1069
|
+
fail('SCHEMA', path, 'a range with max above min is required');
|
|
1070
|
+
const start = st.start ?? st.min;
|
|
1071
|
+
if (!(start >= st.min && start <= st.max))
|
|
1072
|
+
fail('SCHEMA', path, 'the starting value is outside the range');
|
|
1073
|
+
m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
|
|
1074
|
+
asset.stateDefaults[st.id] = start;
|
|
1075
|
+
if (st.label !== undefined || st.sweep !== undefined) {
|
|
1076
|
+
asset.stateInputs = { ...(asset.stateInputs ?? {}) };
|
|
1077
|
+
asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
730
1080
|
if (a.motion.kind === 'slide' && st.unit === 'ratio') {
|
|
731
1081
|
if (!a.travel)
|
|
732
1082
|
fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
|
|
@@ -734,7 +1084,7 @@ function addMotion(asset, a) {
|
|
|
734
1084
|
const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
|
|
735
1085
|
const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
|
|
736
1086
|
const id = name('distance');
|
|
737
|
-
m.nodes.push({ id, op: 'mul@1', args: [
|
|
1087
|
+
m.nodes.push({ id, op: 'mul@1', args: [quantity, length], outputs: { value: `${id}.value` } });
|
|
738
1088
|
quantity = `${id}.value`;
|
|
739
1089
|
}
|
|
740
1090
|
else if (a.motion.kind === 'slide' && a.travel)
|
|
@@ -765,7 +1115,7 @@ function removeMotion(asset, a) {
|
|
|
765
1115
|
if (!motion)
|
|
766
1116
|
fail('EDIT_TARGET', a.part, `${a.part} does not move`);
|
|
767
1117
|
const quantity = motion.args[3];
|
|
768
|
-
const to = motion.params.to;
|
|
1118
|
+
const to = nodeById(m, `${a.part}.seat.rigid`)?.params?.to ?? motion.params.to;
|
|
769
1119
|
// Which control this motion introduced, read before the nodes that name it are taken away.
|
|
770
1120
|
const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
|
|
771
1121
|
?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
|
|
@@ -798,22 +1148,53 @@ function fingerprintOf(asset) {
|
|
|
798
1148
|
return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
|
|
799
1149
|
}
|
|
800
1150
|
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 });
|
|
1151
|
+
/** The same sum with each reference once. */
|
|
1152
|
+
const linNorm = (a) => {
|
|
1153
|
+
const by = new Map();
|
|
1154
|
+
for (const t of a.terms)
|
|
1155
|
+
by.set(t.ref, (by.get(t.ref) ?? 0) + t.k);
|
|
1156
|
+
return { terms: [...by].filter(([, k]) => k !== 0).map(([ref, k]) => ({ ref, k })), c: a.c };
|
|
1157
|
+
};
|
|
1158
|
+
/** A sum at least as large as either, where every reference is a size (never below zero): term by term, the larger. */
|
|
1159
|
+
const linAtLeast = (a, b) => {
|
|
1160
|
+
const by = new Map();
|
|
1161
|
+
for (const t of [...linNorm(a).terms, ...linNorm(b).terms])
|
|
1162
|
+
by.set(t.ref, Math.max(by.get(t.ref) ?? 0, t.k));
|
|
1163
|
+
return { terms: [...by].map(([ref, k]) => ({ ref, k })), c: Math.max(a.c, b.c) };
|
|
1164
|
+
};
|
|
801
1165
|
const linOf = (m, ref) => {
|
|
802
1166
|
const constant = m.constants.find((c) => c.id === ref);
|
|
803
1167
|
return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
|
|
804
1168
|
};
|
|
805
1169
|
/** How far a slide has gone when its control is at one end of its range. */
|
|
806
1170
|
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
1171
|
const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
|
|
810
|
-
|
|
811
|
-
|
|
1172
|
+
/* A control's value, or a mimic's: the leader's value times a constant, plus a constant. */
|
|
1173
|
+
const valueOf = (ref) => {
|
|
1174
|
+
const state = m.inputs.find((i) => i.id === ref && i.role === 'state');
|
|
1175
|
+
if (state)
|
|
1176
|
+
return pick(state);
|
|
1177
|
+
const w = writerOf(m, ref);
|
|
1178
|
+
const constant = (r) => m.constants.find((c) => c.id === r)?.value;
|
|
1179
|
+
if (w?.op === 'mul@1' && w.args.length === 2 && constant(w.args[1]) !== undefined) {
|
|
1180
|
+
const v = valueOf(w.args[0]);
|
|
1181
|
+
return v === null ? null : v * constant(w.args[1]);
|
|
1182
|
+
}
|
|
1183
|
+
if (w?.op === 'add@1' && w.args.length === 2 && constant(w.args[1]) !== undefined) {
|
|
1184
|
+
const v = valueOf(w.args[0]);
|
|
1185
|
+
return v === null ? null : v + constant(w.args[1]);
|
|
1186
|
+
}
|
|
1187
|
+
return null;
|
|
1188
|
+
};
|
|
1189
|
+
const quantity = motion.args[3];
|
|
1190
|
+
const direct = valueOf(quantity);
|
|
1191
|
+
if (direct !== null)
|
|
1192
|
+
return { terms: [], c: direct };
|
|
812
1193
|
const writer = writerOf(m, quantity);
|
|
813
1194
|
if (writer?.op === 'mul@1' && writer.args.length === 2) {
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
816
|
-
return { terms: [{ ref: writer.args[1], k:
|
|
1195
|
+
const v = valueOf(writer.args[0]);
|
|
1196
|
+
if (v !== null)
|
|
1197
|
+
return { terms: [{ ref: writer.args[1], k: v }], c: 0 };
|
|
817
1198
|
}
|
|
818
1199
|
return null;
|
|
819
1200
|
}
|
|
@@ -825,10 +1206,15 @@ function centreLin(m, asset, id, axis, bound) {
|
|
|
825
1206
|
const pose = nodeById(m, `${part}.pose`);
|
|
826
1207
|
if (!pose)
|
|
827
1208
|
return `${part} has no pose this command can read`;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1209
|
+
/* A part's own turn turns it about its centre; a turn above it would turn everything below. */
|
|
1210
|
+
if (part !== id)
|
|
1211
|
+
for (const ref of pose.args.slice(3, 6))
|
|
1212
|
+
if (angleOf(m, asset, ref) !== 0)
|
|
1213
|
+
return `${part} is turned, so its reach is not a box this command can add up`;
|
|
831
1214
|
out = linAdd(out, linOf(m, pose.args[i]));
|
|
1215
|
+
const seat = nodeById(m, `${part}.seat.rigid`);
|
|
1216
|
+
if (seat)
|
|
1217
|
+
out = linAdd(out, linOf(m, seat.args[i]));
|
|
832
1218
|
const motion = nodeById(m, `${part}.motion`);
|
|
833
1219
|
if (motion) {
|
|
834
1220
|
if (motion.op !== 'axis-slide@1')
|
|
@@ -847,6 +1233,161 @@ function centreLin(m, asset, id, axis, bound) {
|
|
|
847
1233
|
}
|
|
848
1234
|
return out;
|
|
849
1235
|
}
|
|
1236
|
+
/** A value written as a sum of design inputs times constants, or null where it is anything else. */
|
|
1237
|
+
function affineOf(m, ref, seen = 0) {
|
|
1238
|
+
if (seen > 64)
|
|
1239
|
+
return null;
|
|
1240
|
+
const constant = m.constants.find((c) => c.id === ref);
|
|
1241
|
+
if (constant)
|
|
1242
|
+
return { terms: [], c: constant.value };
|
|
1243
|
+
if (m.inputs.some((i) => i.id === ref))
|
|
1244
|
+
return { terms: [{ ref, k: 1 }], c: 0 };
|
|
1245
|
+
const writer = writerOf(m, ref);
|
|
1246
|
+
if (writer?.op === 'add@1') {
|
|
1247
|
+
let out = { terms: [], c: 0 };
|
|
1248
|
+
for (const arg of writer.args) {
|
|
1249
|
+
const part = affineOf(m, arg, seen + 1);
|
|
1250
|
+
if (!part)
|
|
1251
|
+
return null;
|
|
1252
|
+
out = linAdd(out, part);
|
|
1253
|
+
}
|
|
1254
|
+
return out;
|
|
1255
|
+
}
|
|
1256
|
+
if (writer?.op === 'mul@1' && writer.args.length === 2) {
|
|
1257
|
+
const [a, b] = writer.args.map((arg) => affineOf(m, arg, seen + 1));
|
|
1258
|
+
if (!a || !b)
|
|
1259
|
+
return null;
|
|
1260
|
+
if (!a.terms.length)
|
|
1261
|
+
return linAdd({ terms: [], c: 0 }, b, a.c);
|
|
1262
|
+
if (!b.terms.length)
|
|
1263
|
+
return linAdd({ terms: [], c: 0 }, a, b.c);
|
|
1264
|
+
}
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* How long a value can be, at most, as a sum that holds at every design size: each input's coefficient taken
|
|
1269
|
+
* positive. Sound only over inputs that are never below zero (sizes), so any other input makes it unreadable.
|
|
1270
|
+
*/
|
|
1271
|
+
function magnitudeOf(m, lin) {
|
|
1272
|
+
let out = { terms: [], c: 0 };
|
|
1273
|
+
for (const t of lin.terms) {
|
|
1274
|
+
const a = affineOf(m, t.ref);
|
|
1275
|
+
if (!a)
|
|
1276
|
+
return null;
|
|
1277
|
+
for (const u of a.terms) {
|
|
1278
|
+
const input = m.inputs.find((i) => i.id === u.ref);
|
|
1279
|
+
if (!input || input.role === 'state' || !(input.min >= 0))
|
|
1280
|
+
return null;
|
|
1281
|
+
out = linAdd(out, { terms: [{ ref: u.ref, k: Math.abs(u.k * t.k) }], c: 0 });
|
|
1282
|
+
}
|
|
1283
|
+
out.c += Math.abs(a.c * t.k);
|
|
1284
|
+
}
|
|
1285
|
+
out.c += Math.abs(lin.c);
|
|
1286
|
+
return out;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Where a part carried by a turn can reach: a ball about the point the first turn above it turns about, as wide as
|
|
1290
|
+
* every step from there to the part laid end to end, plus the part's own half size on each axis (the triangle
|
|
1291
|
+
* inequality -- it holds at every angle and every design size, so it is a bound, not a sample). As a box, that
|
|
1292
|
+
* ball is the pivot plus and minus its radius on each axis. Read by the proposal; the release gate still checks
|
|
1293
|
+
* the parts against it.
|
|
1294
|
+
*/
|
|
1295
|
+
function reachOf(m, asset, id, freezeless = true) {
|
|
1296
|
+
const chain = [];
|
|
1297
|
+
for (let part = id; part; part = parentOf(m, part))
|
|
1298
|
+
chain.push(part);
|
|
1299
|
+
const top = chain.reduce((found, part, i) => (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1' ? i : found), -1);
|
|
1300
|
+
if (top < 0)
|
|
1301
|
+
return `${id} is carried by no turn`;
|
|
1302
|
+
let radius = { terms: [], c: 0 };
|
|
1303
|
+
const grow = (lin, what) => {
|
|
1304
|
+
const size = lin && magnitudeOf(m, lin);
|
|
1305
|
+
if (!size)
|
|
1306
|
+
return `${what} is not a sum of sizes, so how far it reaches cannot be bounded`;
|
|
1307
|
+
radius = linAdd(radius, size);
|
|
1308
|
+
return null;
|
|
1309
|
+
};
|
|
1310
|
+
const sizes = standingRefsOf(m, asset, id, id, !freezeless);
|
|
1311
|
+
for (const axis of AXES) {
|
|
1312
|
+
const bad = grow({ terms: [{ ref: sizes[axis].ref, k: 0.5 * sizes[axis].k }], c: 0 }, `${id}'s size`);
|
|
1313
|
+
if (bad)
|
|
1314
|
+
return bad;
|
|
1315
|
+
}
|
|
1316
|
+
for (let i = 0; i <= top; i++) {
|
|
1317
|
+
const part = chain[i];
|
|
1318
|
+
const pose = nodeById(m, `${part}.pose`);
|
|
1319
|
+
if (!pose)
|
|
1320
|
+
return `${part} has no pose this command can read`;
|
|
1321
|
+
for (const ref of pose.args.slice(0, 3)) {
|
|
1322
|
+
const bad = grow(linOf(m, ref), `${part}'s place`);
|
|
1323
|
+
if (bad)
|
|
1324
|
+
return bad;
|
|
1325
|
+
}
|
|
1326
|
+
if (i < top) {
|
|
1327
|
+
const seat = nodeById(m, `${part}.seat.rigid`);
|
|
1328
|
+
for (const ref of seat?.args.slice(0, 3) ?? []) {
|
|
1329
|
+
const bad = grow(linOf(m, ref), `where ${part} is fastened`);
|
|
1330
|
+
if (bad)
|
|
1331
|
+
return bad;
|
|
1332
|
+
}
|
|
1333
|
+
const motion = nodeById(m, `${part}.motion`);
|
|
1334
|
+
if (motion?.op === 'axis-slide@1')
|
|
1335
|
+
for (const end of ['min', 'max']) {
|
|
1336
|
+
const bad = grow(travelAt(m, asset, motion, end), `${part}'s travel`);
|
|
1337
|
+
if (bad)
|
|
1338
|
+
return bad;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
// The pivot: where the topmost turn is fastened, and everything above it, which neither turns nor slides.
|
|
1343
|
+
const pivotPart = chain[top];
|
|
1344
|
+
const centre = {};
|
|
1345
|
+
for (const axis of AXES) {
|
|
1346
|
+
const k = AXES.indexOf(axis);
|
|
1347
|
+
let at = { terms: [], c: 0 };
|
|
1348
|
+
const seat = nodeById(m, `${pivotPart}.seat.rigid`);
|
|
1349
|
+
if (seat)
|
|
1350
|
+
at = linAdd(at, linOf(m, seat.args[k]));
|
|
1351
|
+
for (let part = parentOf(m, pivotPart); part; part = parentOf(m, part)) {
|
|
1352
|
+
const pose = nodeById(m, `${part}.pose`);
|
|
1353
|
+
if (!pose)
|
|
1354
|
+
return `${part} has no pose this command can read`;
|
|
1355
|
+
for (const ref of pose.args.slice(3, 6))
|
|
1356
|
+
if (angleOf(m, asset, ref) !== 0)
|
|
1357
|
+
return `${part} is turned, so where ${pivotPart} turns is not a point this command can add up`;
|
|
1358
|
+
if (nodeById(m, `${part}.motion`))
|
|
1359
|
+
return `${part} moves, and so does the point ${pivotPart} turns about`;
|
|
1360
|
+
at = linAdd(at, linOf(m, pose.args[k]));
|
|
1361
|
+
const above = nodeById(m, `${part}.seat.rigid`);
|
|
1362
|
+
if (above)
|
|
1363
|
+
at = linAdd(at, linOf(m, above.args[k]));
|
|
1364
|
+
}
|
|
1365
|
+
centre[axis] = at;
|
|
1366
|
+
}
|
|
1367
|
+
return { centre, radius };
|
|
1368
|
+
}
|
|
1369
|
+
/** A sum written as graph nodes: each term scaled, then added up, then the constant. */
|
|
1370
|
+
function emitLin(m, name, l, hint) {
|
|
1371
|
+
if (!l.terms.length)
|
|
1372
|
+
return constantOf(m, 'mm', round6(l.c), 'd');
|
|
1373
|
+
let out = '';
|
|
1374
|
+
for (const t of l.terms) {
|
|
1375
|
+
const piece = scaled(m, name, t.ref, t.k, 0, hint);
|
|
1376
|
+
if (!out)
|
|
1377
|
+
out = piece;
|
|
1378
|
+
else {
|
|
1379
|
+
const id = name(`${hint}.sum`);
|
|
1380
|
+
m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
|
|
1381
|
+
out = `${id}.value`;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (l.c !== 0) {
|
|
1385
|
+
const id = name(`${hint}.offset`);
|
|
1386
|
+
m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
|
|
1387
|
+
out = `${id}.value`;
|
|
1388
|
+
}
|
|
1389
|
+
return out;
|
|
1390
|
+
}
|
|
850
1391
|
/** The bounds the parts come to, as sums, with what could not be read named. */
|
|
851
1392
|
function occupancyLins(asset, over) {
|
|
852
1393
|
const m = asset.document.model;
|
|
@@ -854,13 +1395,34 @@ function occupancyLins(asset, over) {
|
|
|
854
1395
|
const skipped = [];
|
|
855
1396
|
const low = { x: [], y: [], z: [] };
|
|
856
1397
|
const high = { x: [], y: [], z: [] };
|
|
1398
|
+
/* Parts carried by the same turn share one ball: its radius the larger of theirs, term by term. */
|
|
1399
|
+
const balls = new Map();
|
|
857
1400
|
for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
|
|
858
1401
|
let sizes;
|
|
859
1402
|
try {
|
|
860
|
-
sizes =
|
|
1403
|
+
sizes = standingRefsOf(m, asset, place.id, place.id, false);
|
|
861
1404
|
}
|
|
862
|
-
catch {
|
|
863
|
-
skipped.push({ part: place.id, reason: 'not a
|
|
1405
|
+
catch (e) {
|
|
1406
|
+
skipped.push({ part: place.id, reason: e instanceof V3ContractError ? e.message : 'not a part whose size this command can read' });
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
const turns = (() => {
|
|
1410
|
+
for (let part = place.id; part; part = parentOf(m, part))
|
|
1411
|
+
if (nodeById(m, `${part}.motion`)?.op === 'axis-turn@1')
|
|
1412
|
+
return true;
|
|
1413
|
+
return false;
|
|
1414
|
+
})();
|
|
1415
|
+
if (turns) {
|
|
1416
|
+
const reach = reachOf(m, asset, place.id);
|
|
1417
|
+
if (typeof reach === 'string') {
|
|
1418
|
+
skipped.push({ part: place.id, reason: reach });
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
parts.push(place.id);
|
|
1422
|
+
const centre = Object.fromEntries(AXES.map(a => [a, linNorm(reach.centre[a])]));
|
|
1423
|
+
const key = JSON.stringify(centre);
|
|
1424
|
+
const ball = balls.get(key);
|
|
1425
|
+
balls.set(key, { centre, radius: ball ? linAtLeast(ball.radius, reach.radius) : linNorm(reach.radius) });
|
|
864
1426
|
continue;
|
|
865
1427
|
}
|
|
866
1428
|
/*
|
|
@@ -891,7 +1453,7 @@ function occupancyLins(asset, over) {
|
|
|
891
1453
|
}
|
|
892
1454
|
}
|
|
893
1455
|
}
|
|
894
|
-
return { parts, skipped, low, high };
|
|
1456
|
+
return { parts, skipped, low, high, balls: [...balls.values()] };
|
|
895
1457
|
}
|
|
896
1458
|
const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
|
|
897
1459
|
/** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
|
|
@@ -900,7 +1462,12 @@ export function proposeV3Occupancy(asset, options = {}) {
|
|
|
900
1462
|
if (over !== 'rest' && over !== 'range')
|
|
901
1463
|
fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
|
|
902
1464
|
const source = structuredClone(asset);
|
|
903
|
-
const { parts, skipped, low, high } = occupancyLins(source, over);
|
|
1465
|
+
const { parts, skipped, low, high, balls } = occupancyLins(source, over);
|
|
1466
|
+
for (const ball of balls)
|
|
1467
|
+
for (const axis of AXES) {
|
|
1468
|
+
low[axis].push(linAdd(ball.centre[axis], ball.radius, -1));
|
|
1469
|
+
high[axis].push(linAdd(ball.centre[axis], ball.radius, 1));
|
|
1470
|
+
}
|
|
904
1471
|
if (!parts.length)
|
|
905
1472
|
fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
|
|
906
1473
|
const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
|
|
@@ -923,7 +1490,15 @@ function declareOccupancy(asset, a) {
|
|
|
923
1490
|
fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
|
|
924
1491
|
padding[axis] = pad;
|
|
925
1492
|
}
|
|
926
|
-
const { parts, low, high } = occupancyLins(asset, a.proposal.over);
|
|
1493
|
+
const { parts, low, high, balls } = occupancyLins(asset, a.proposal.over);
|
|
1494
|
+
const name0 = namer(m, 'occupancy.reach');
|
|
1495
|
+
balls.forEach((ball, b) => {
|
|
1496
|
+
const radius = emitLin(m, name0, ball.radius, `${b}`);
|
|
1497
|
+
for (const axis of AXES) {
|
|
1498
|
+
low[axis].push(linAdd(ball.centre[axis], { terms: [{ ref: radius, k: 1 }], c: 0 }, -1));
|
|
1499
|
+
high[axis].push(linAdd(ball.centre[axis], { terms: [{ ref: radius, k: 1 }], c: 0 }, 1));
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
927
1502
|
for (const axis of AXES) {
|
|
928
1503
|
if (!padding[axis])
|
|
929
1504
|
continue;
|
|
@@ -933,27 +1508,7 @@ function declareOccupancy(asset, a) {
|
|
|
933
1508
|
if (parts.join('|') !== a.proposal.parts.join('|'))
|
|
934
1509
|
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
1510
|
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
|
-
};
|
|
1511
|
+
const emit = (l, hint) => emitLin(m, name, linNorm(l), hint);
|
|
957
1512
|
const pick = (ls, op, hint) => {
|
|
958
1513
|
const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
|
|
959
1514
|
if (refs.length === 1)
|