@hatiolab/figure-model 0.1.55 → 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 +101 -5
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +803 -133
- 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
|
@@ -25,12 +25,18 @@ import { compileV3Asset } from "./v3-asset.js";
|
|
|
25
25
|
import { compileV3Graph } from "./v3-graph.js";
|
|
26
26
|
import { V3ContractError } from "./v3-contract.js";
|
|
27
27
|
import { AXES } from "./types.js";
|
|
28
|
-
import { foldInstanceSizeRange, v3InstanceSizeLimitsOf, V3_INSTANCE_SIZE_INPUTS } from "./v3-instance-size.js";
|
|
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);
|
|
@@ -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);
|
|
@@ -559,6 +646,9 @@ export function applyV3Authoring(source, action) {
|
|
|
559
646
|
case 'set-repeat':
|
|
560
647
|
setRepeat(asset, action);
|
|
561
648
|
break;
|
|
649
|
+
case 'nudge-part':
|
|
650
|
+
nudgePart(asset, action);
|
|
651
|
+
break;
|
|
562
652
|
case 'clear-mounting-face':
|
|
563
653
|
clearMountingFace(asset);
|
|
564
654
|
break;
|
|
@@ -578,8 +668,11 @@ export function applyV3Authoring(source, action) {
|
|
|
578
668
|
default:
|
|
579
669
|
fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
|
|
580
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);
|
|
581
674
|
compileV3Asset(asset);
|
|
582
|
-
holdsOverDeclaredSize(asset);
|
|
675
|
+
holdsOverDeclaredSize(asset, source);
|
|
583
676
|
return asset;
|
|
584
677
|
}
|
|
585
678
|
/* ----------------------------------------------------------- instance size */
|
|
@@ -630,21 +723,45 @@ function declareInstanceSize(asset, a) {
|
|
|
630
723
|
* makes a part 200 mm shorter than the instance is caught when the instance may be 1 mm wide — the kernel alone
|
|
631
724
|
* accepts it, and the board then fails to draw the figure at small sizes.
|
|
632
725
|
*/
|
|
633
|
-
|
|
726
|
+
/** Where the declared size range reaches past what the figure holds: per axis, the bound it would need, and who sets it. */
|
|
727
|
+
function sizeRangeGaps(asset) {
|
|
634
728
|
const m = asset.document.model;
|
|
635
729
|
if (!AXES.every(axis => sizeInputOf(m, axis)))
|
|
636
|
-
return;
|
|
730
|
+
return [];
|
|
637
731
|
const range = Object.fromEntries(AXES.map(axis => [axis, { min: sizeInputOf(m, axis).min, max: sizeInputOf(m, axis).max }]));
|
|
638
732
|
const limits = v3InstanceSizeLimitsOf(asset, range);
|
|
639
733
|
const { range: holds } = foldInstanceSizeRange(range, limits);
|
|
734
|
+
const gaps = [];
|
|
640
735
|
for (const axis of AXES) {
|
|
641
|
-
const path = V3_INSTANCE_SIZE_INPUTS[axis];
|
|
642
736
|
const who = limits.find(l => l.axis === axis)?.part;
|
|
643
|
-
const by = who ? ` (${who})` : '';
|
|
644
737
|
if (holds[axis].min > range[axis].min)
|
|
645
|
-
|
|
738
|
+
gaps.push({ axis, bound: 'min', need: holds[axis].min, declared: range[axis].min, who });
|
|
646
739
|
if (holds[axis].max < range[axis].max)
|
|
647
|
-
|
|
740
|
+
gaps.push({ axis, bound: 'max', need: holds[axis].max, declared: range[axis].max, who });
|
|
741
|
+
}
|
|
742
|
+
return gaps;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* After a command, the declared size range must still hold at its ends. A gap the figure already had before the
|
|
746
|
+
* command -- a range the conversion declared too wide (FORMATION_TRAY: X from 1 mm, its cells gone below 8.8 mm) -- is
|
|
747
|
+
* not the command's doing: it is refused only if it made a gap or widened one. Refusing every command for it had
|
|
748
|
+
* blocked even choosing a detail level (user, 2026-09-24).
|
|
749
|
+
*/
|
|
750
|
+
function holdsOverDeclaredSize(asset, before) {
|
|
751
|
+
const now = sizeRangeGaps(asset);
|
|
752
|
+
if (!now.length)
|
|
753
|
+
return;
|
|
754
|
+
const was = before ? sizeRangeGaps(before) : [];
|
|
755
|
+
for (const gap of now) {
|
|
756
|
+
const prior = was.find(g => g.axis === gap.axis && g.bound === gap.bound);
|
|
757
|
+
const worse = !prior || (gap.bound === 'min' ? gap.need > prior.need + 1e-9 || gap.declared < prior.declared : gap.need < prior.need - 1e-9 || gap.declared > prior.declared);
|
|
758
|
+
if (!worse)
|
|
759
|
+
continue;
|
|
760
|
+
const path = V3_INSTANCE_SIZE_INPUTS[gap.axis];
|
|
761
|
+
const by = gap.who ? ` (${gap.who})` : '';
|
|
762
|
+
if (gap.bound === 'min')
|
|
763
|
+
fail('INSTANCE_SIZE_RANGE', path, `below ${round6(gap.need)} mm a length goes to zero${by}; declare the smallest size at ${round6(gap.need)} mm or more`);
|
|
764
|
+
fail('INSTANCE_SIZE_RANGE', path, `above ${round6(gap.need)} mm the figure cannot be drawn${by}; declare the largest size at ${round6(gap.need)} mm or less`);
|
|
648
765
|
}
|
|
649
766
|
}
|
|
650
767
|
/** What each part is fastened to, for a reader that wants the structure rather than the graph. */
|
|
@@ -656,12 +773,258 @@ export function v3AttachmentsOf(asset) {
|
|
|
656
773
|
out[n.id] = parentOf(m, n.id);
|
|
657
774
|
return out;
|
|
658
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
|
+
}
|
|
659
1022
|
/** The frame a part is fastened into: its parent's, or the asset's. */
|
|
660
1023
|
const attachmentFrameOf = (asset, id) => {
|
|
661
1024
|
const parent = parentOf(asset.document.model, id);
|
|
662
1025
|
return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
|
|
663
1026
|
};
|
|
664
|
-
function addMotion(asset, a) {
|
|
1027
|
+
function addMotion(asset, a, follow) {
|
|
665
1028
|
const m = asset.document.model;
|
|
666
1029
|
const path = a.part;
|
|
667
1030
|
partOf(m, a.part, path);
|
|
@@ -680,26 +1043,40 @@ function addMotion(asset, a) {
|
|
|
680
1043
|
else if (frame !== 'attachment' && frame !== 'asset')
|
|
681
1044
|
fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
|
|
682
1045
|
const st = a.state;
|
|
683
|
-
if (!st || typeof st.id !== 'string' || !st.id.trim())
|
|
684
|
-
fail('SCHEMA', path, 'a state input id is required');
|
|
685
|
-
if (m.inputs.some((i) => i.id === st.id))
|
|
686
|
-
fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
|
|
687
|
-
const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
|
|
688
|
-
if (!wanted.includes(st.unit))
|
|
689
|
-
fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
|
|
690
|
-
if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
|
|
691
|
-
fail('SCHEMA', path, 'a range with max above min is required');
|
|
692
|
-
const start = st.start ?? st.min;
|
|
693
|
-
if (!(start >= st.min && start <= st.max))
|
|
694
|
-
fail('SCHEMA', path, 'the starting value is outside the range');
|
|
695
|
-
m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
|
|
696
|
-
asset.stateDefaults[st.id] = start;
|
|
697
|
-
if (st.label !== undefined || st.sweep !== undefined) {
|
|
698
|
-
asset.stateInputs = { ...(asset.stateInputs ?? {}) };
|
|
699
|
-
asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
|
|
700
|
-
}
|
|
701
1046
|
const name = namer(m, `${a.part}.motion`);
|
|
702
|
-
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
|
+
}
|
|
703
1080
|
if (a.motion.kind === 'slide' && st.unit === 'ratio') {
|
|
704
1081
|
if (!a.travel)
|
|
705
1082
|
fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
|
|
@@ -707,7 +1084,7 @@ function addMotion(asset, a) {
|
|
|
707
1084
|
const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
|
|
708
1085
|
const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
|
|
709
1086
|
const id = name('distance');
|
|
710
|
-
m.nodes.push({ id, op: 'mul@1', args: [
|
|
1087
|
+
m.nodes.push({ id, op: 'mul@1', args: [quantity, length], outputs: { value: `${id}.value` } });
|
|
711
1088
|
quantity = `${id}.value`;
|
|
712
1089
|
}
|
|
713
1090
|
else if (a.motion.kind === 'slide' && a.travel)
|
|
@@ -738,7 +1115,7 @@ function removeMotion(asset, a) {
|
|
|
738
1115
|
if (!motion)
|
|
739
1116
|
fail('EDIT_TARGET', a.part, `${a.part} does not move`);
|
|
740
1117
|
const quantity = motion.args[3];
|
|
741
|
-
const to = motion.params.to;
|
|
1118
|
+
const to = nodeById(m, `${a.part}.seat.rigid`)?.params?.to ?? motion.params.to;
|
|
742
1119
|
// Which control this motion introduced, read before the nodes that name it are taken away.
|
|
743
1120
|
const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
|
|
744
1121
|
?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
|
|
@@ -771,22 +1148,53 @@ function fingerprintOf(asset) {
|
|
|
771
1148
|
return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
|
|
772
1149
|
}
|
|
773
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
|
+
};
|
|
774
1165
|
const linOf = (m, ref) => {
|
|
775
1166
|
const constant = m.constants.find((c) => c.id === ref);
|
|
776
1167
|
return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
|
|
777
1168
|
};
|
|
778
1169
|
/** How far a slide has gone when its control is at one end of its range. */
|
|
779
1170
|
function travelAt(m, asset, motion, bound) {
|
|
780
|
-
const quantity = motion.args[3];
|
|
781
|
-
const direct = m.inputs.find((i) => i.id === quantity && i.role === 'state');
|
|
782
1171
|
const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
|
|
783
|
-
|
|
784
|
-
|
|
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 };
|
|
785
1193
|
const writer = writerOf(m, quantity);
|
|
786
1194
|
if (writer?.op === 'mul@1' && writer.args.length === 2) {
|
|
787
|
-
const
|
|
788
|
-
if (
|
|
789
|
-
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 };
|
|
790
1198
|
}
|
|
791
1199
|
return null;
|
|
792
1200
|
}
|
|
@@ -798,10 +1206,15 @@ function centreLin(m, asset, id, axis, bound) {
|
|
|
798
1206
|
const pose = nodeById(m, `${part}.pose`);
|
|
799
1207
|
if (!pose)
|
|
800
1208
|
return `${part} has no pose this command can read`;
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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`;
|
|
804
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]));
|
|
805
1218
|
const motion = nodeById(m, `${part}.motion`);
|
|
806
1219
|
if (motion) {
|
|
807
1220
|
if (motion.op !== 'axis-slide@1')
|
|
@@ -820,6 +1233,161 @@ function centreLin(m, asset, id, axis, bound) {
|
|
|
820
1233
|
}
|
|
821
1234
|
return out;
|
|
822
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
|
+
}
|
|
823
1391
|
/** The bounds the parts come to, as sums, with what could not be read named. */
|
|
824
1392
|
function occupancyLins(asset, over) {
|
|
825
1393
|
const m = asset.document.model;
|
|
@@ -827,13 +1395,34 @@ function occupancyLins(asset, over) {
|
|
|
827
1395
|
const skipped = [];
|
|
828
1396
|
const low = { x: [], y: [], z: [] };
|
|
829
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();
|
|
830
1400
|
for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
|
|
831
1401
|
let sizes;
|
|
832
1402
|
try {
|
|
833
|
-
sizes =
|
|
1403
|
+
sizes = standingRefsOf(m, asset, place.id, place.id, false);
|
|
834
1404
|
}
|
|
835
|
-
catch {
|
|
836
|
-
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) });
|
|
837
1426
|
continue;
|
|
838
1427
|
}
|
|
839
1428
|
/*
|
|
@@ -864,7 +1453,7 @@ function occupancyLins(asset, over) {
|
|
|
864
1453
|
}
|
|
865
1454
|
}
|
|
866
1455
|
}
|
|
867
|
-
return { parts, skipped, low, high };
|
|
1456
|
+
return { parts, skipped, low, high, balls: [...balls.values()] };
|
|
868
1457
|
}
|
|
869
1458
|
const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
|
|
870
1459
|
/** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
|
|
@@ -873,7 +1462,12 @@ export function proposeV3Occupancy(asset, options = {}) {
|
|
|
873
1462
|
if (over !== 'rest' && over !== 'range')
|
|
874
1463
|
fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
|
|
875
1464
|
const source = structuredClone(asset);
|
|
876
|
-
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
|
+
}
|
|
877
1471
|
if (!parts.length)
|
|
878
1472
|
fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
|
|
879
1473
|
const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
|
|
@@ -896,7 +1490,15 @@ function declareOccupancy(asset, a) {
|
|
|
896
1490
|
fail('OCCUPANCY_PADDING', `occupancy.${axis}`, `padding is millimetres, zero or more; got ${String(pad)}`);
|
|
897
1491
|
padding[axis] = pad;
|
|
898
1492
|
}
|
|
899
|
-
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
|
+
});
|
|
900
1502
|
for (const axis of AXES) {
|
|
901
1503
|
if (!padding[axis])
|
|
902
1504
|
continue;
|
|
@@ -906,27 +1508,7 @@ function declareOccupancy(asset, a) {
|
|
|
906
1508
|
if (parts.join('|') !== a.proposal.parts.join('|'))
|
|
907
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`);
|
|
908
1510
|
const name = namer(m, 'occupancy');
|
|
909
|
-
const emit = (l, hint) =>
|
|
910
|
-
if (!l.terms.length)
|
|
911
|
-
return constantOf(m, 'mm', round6(l.c), 'd');
|
|
912
|
-
let out = '';
|
|
913
|
-
for (const t of l.terms) {
|
|
914
|
-
const piece = scaled(m, name, t.ref, t.k, 0, hint);
|
|
915
|
-
if (!out)
|
|
916
|
-
out = piece;
|
|
917
|
-
else {
|
|
918
|
-
const id = name(`${hint}.sum`);
|
|
919
|
-
m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
|
|
920
|
-
out = `${id}.value`;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
if (l.c !== 0) {
|
|
924
|
-
const id = name(`${hint}.offset`);
|
|
925
|
-
m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
|
|
926
|
-
out = `${id}.value`;
|
|
927
|
-
}
|
|
928
|
-
return out;
|
|
929
|
-
};
|
|
1511
|
+
const emit = (l, hint) => emitLin(m, name, linNorm(l), hint);
|
|
930
1512
|
const pick = (ls, op, hint) => {
|
|
931
1513
|
const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
|
|
932
1514
|
if (refs.length === 1)
|
|
@@ -1068,6 +1650,83 @@ function clearMountingFace(asset) {
|
|
|
1068
1650
|
delete asset.occupancy.contact;
|
|
1069
1651
|
return asset;
|
|
1070
1652
|
}
|
|
1653
|
+
function nudgePart(asset, a) {
|
|
1654
|
+
const m = asset.document.model;
|
|
1655
|
+
const path = a.part;
|
|
1656
|
+
const { pose } = partOf(m, a.part, path);
|
|
1657
|
+
if (pose.op !== 'rigid@1')
|
|
1658
|
+
fail('EDIT_TARGET', path, `${a.part} is not posed by a rigid@1 this command can move`);
|
|
1659
|
+
if (pose.params?.to !== 'asset')
|
|
1660
|
+
fail('EDIT_TARGET', path, `${a.part} is carried by a joint or a seat, which places it`);
|
|
1661
|
+
if (parentOf(m, a.part))
|
|
1662
|
+
fail('EDIT_TARGET', path, `${a.part} is fastened to ${parentOf(m, a.part)}, which places it; detach it first`);
|
|
1663
|
+
const shift = (slot, by, unit, kind, axis) => {
|
|
1664
|
+
if (!by)
|
|
1665
|
+
return;
|
|
1666
|
+
if (!Number.isFinite(by))
|
|
1667
|
+
fail('SCHEMA', `${path}.${kind}.${axis}`, 'a finite amount required');
|
|
1668
|
+
const was = pose.args[slot];
|
|
1669
|
+
const own = `${a.part}.${kind}.${axis}`;
|
|
1670
|
+
/* Already the part's own number: add to it. */
|
|
1671
|
+
if (was === own && m.inputs.some((i) => i.id === own && i.role === 'design')) {
|
|
1672
|
+
asset.designInputs[own] = round6(asset.designInputs[own] + by);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const constant = m.constants.find((c) => c.id === was);
|
|
1676
|
+
const free = (id) => !m.inputs.some((i) => i.id === id) && !m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id));
|
|
1677
|
+
const input = (id, value) => {
|
|
1678
|
+
m.inputs.push({ id, unit, min: -Number.MAX_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
1679
|
+
asset.designInputs[id] = round6(value);
|
|
1680
|
+
};
|
|
1681
|
+
/* A plain number becomes the part's own, moved. */
|
|
1682
|
+
if (constant && free(own)) {
|
|
1683
|
+
input(own, constant.value + by);
|
|
1684
|
+
pose.args = pose.args.map((r, k) => (k === slot ? own : r));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
/* A worked-out place keeps its rule and takes the drag beside it. */
|
|
1688
|
+
const nudge = `${a.part}.${kind}.nudge.${axis}`;
|
|
1689
|
+
if (m.inputs.some((i) => i.id === nudge)) {
|
|
1690
|
+
asset.designInputs[nudge] = round6(asset.designInputs[nudge] + by);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
input(nudge, by);
|
|
1694
|
+
const id = namer(m, `${a.part}.${kind}`)(`nudged.${axis}`);
|
|
1695
|
+
m.nodes.push({ id, op: 'add@1', args: [was, nudge], outputs: { value: `${id}.value` } });
|
|
1696
|
+
pose.args = pose.args.map((r, k) => (k === slot ? `${id}.value` : r));
|
|
1697
|
+
};
|
|
1698
|
+
AXES.forEach((axis, i) => shift(i, a.by?.[axis] ?? 0, 'mm', 'position', axis));
|
|
1699
|
+
AXES.forEach((axis, i) => shift(3 + i, a.turn?.[axis] ?? 0, 'deg', 'rotation', axis));
|
|
1700
|
+
/* Sizes: the measure's own node carries a factor beside its rule (`stableDimension` routes every measure through one). */
|
|
1701
|
+
const shape = partOf(m, a.part, path).shape;
|
|
1702
|
+
const measures = shape.op === 'cylinder-shape@1' ? ['x', 'y'] : AXES.filter((_, i) => shape.args[i] !== undefined);
|
|
1703
|
+
for (const axis of measures) {
|
|
1704
|
+
/* A cylinder's radius answers to the cross axis the drag moved more. */
|
|
1705
|
+
const factor = shape.op === 'cylinder-shape@1' && axis === 'x' ? mostChanged(a.grow?.x, a.grow?.z) : a.grow?.[axis];
|
|
1706
|
+
if (factor === undefined || factor === 1)
|
|
1707
|
+
continue;
|
|
1708
|
+
if (!(Number.isFinite(factor) && factor > 0))
|
|
1709
|
+
fail('GEOMETRY_DOMAIN', `${path}.dimension.${axis}`, 'a positive factor required');
|
|
1710
|
+
stableDimension(m, a.part, axis, `${path}.dimension.${axis}`);
|
|
1711
|
+
const node = nodeById(m, `${a.part}.dimension.${axis}`);
|
|
1712
|
+
const id = `${a.part}.dimension.scale.${axis}`;
|
|
1713
|
+
if (node.args[1] === id)
|
|
1714
|
+
asset.designInputs[id] = round6(asset.designInputs[id] * factor);
|
|
1715
|
+
else {
|
|
1716
|
+
if (m.inputs.some((i) => i.id === id))
|
|
1717
|
+
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
1718
|
+
m.inputs.push({ id, unit: 'ratio', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
1719
|
+
const was = m.constants.find((c) => c.id === node.args[1])?.value ?? 1;
|
|
1720
|
+
asset.designInputs[id] = round6(was * factor);
|
|
1721
|
+
node.args = [node.args[0], id];
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
return asset;
|
|
1725
|
+
}
|
|
1726
|
+
const mostChanged = (a, b) => {
|
|
1727
|
+
const pick = [a, b].filter((v) => v !== undefined);
|
|
1728
|
+
return pick.length ? pick.reduce((x, y) => (Math.abs(y - 1) > Math.abs(x - 1) ? y : x), 1) : undefined;
|
|
1729
|
+
};
|
|
1071
1730
|
function setRepeat(asset, a) {
|
|
1072
1731
|
const m = asset.document.model;
|
|
1073
1732
|
const path = `${a.part}.repeat`;
|
|
@@ -1080,27 +1739,38 @@ function setRepeat(asset, a) {
|
|
|
1080
1739
|
fail('EDIT_TARGET', path, `${a.part} lays its copies out by ${layout.op}, which has no pitch`);
|
|
1081
1740
|
if (!(Number.isFinite(a.pitch) && a.pitch > 0))
|
|
1082
1741
|
fail('GEOMETRY_DOMAIN', path, 'a positive pitch is required');
|
|
1742
|
+
/*
|
|
1743
|
+
One number, the pitch: copies = floor(row length / pitch), at most `limit` of them (the kernel's copy cap, V2's).
|
|
1744
|
+
So the longest row the figure may be placed at is limit x pitch -- the converter set the length's upper bound that
|
|
1745
|
+
way from the V2 pitch. A new pitch moves that bound with it; the pitch is refused only when the length the figure
|
|
1746
|
+
is authored at would already hold more copies than the cap.
|
|
1747
|
+
*/
|
|
1748
|
+
const values = compileV3Graph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
|
|
1749
|
+
const lengthRef = layout.args[0];
|
|
1750
|
+
const authored = values[lengthRef];
|
|
1751
|
+
const limit = (values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value);
|
|
1752
|
+
const smallest = Math.ceil((authored / limit) * 1000) / 1000;
|
|
1753
|
+
if (a.pitch < smallest)
|
|
1754
|
+
fail('INPUT_RANGE', path, `at the length it is authored (${round6(authored)} mm) a pitch under ${smallest} mm makes more than ${limit} copies`);
|
|
1083
1755
|
const id = `${a.part}.repeat.pitch`;
|
|
1084
|
-
|
|
1085
|
-
if (!input) {
|
|
1756
|
+
if (!m.inputs.some((i) => i.id === id)) {
|
|
1086
1757
|
if (m.nodes.some((n) => n.id === id || Object.values(n.outputs).includes(id)))
|
|
1087
1758
|
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
1088
|
-
|
|
1089
|
-
const values = compileV3Graph(m).evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values;
|
|
1090
|
-
const lengthRef = layout.args[0];
|
|
1091
|
-
const longest = m.inputs.find((i) => i.id === lengthRef)?.max ?? values[lengthRef];
|
|
1092
|
-
const limit = values[layout.args[6]] ?? m.constants.find((c) => c.id === layout.args[6])?.value;
|
|
1093
|
-
const lowest = Number.isFinite(longest) && limit > 0 ? Math.ceil((longest / limit) * 1000) / 1000 : 1;
|
|
1094
|
-
input = { id, unit: 'mm', min: Math.max(lowest, 0.001), max: Number.isFinite(longest) ? longest : 1e6, role: 'design' };
|
|
1095
|
-
m.inputs.push(input);
|
|
1759
|
+
m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
1096
1760
|
const was = [layout.args[3], layout.args[4]];
|
|
1097
1761
|
layout.args = layout.args.map((r, k) => (k === 3 || k === 4 ? id : r));
|
|
1098
1762
|
for (const ref of new Set(was))
|
|
1099
1763
|
dropIfUnused(asset, ref);
|
|
1100
1764
|
}
|
|
1101
|
-
if (a.pitch < input.min)
|
|
1102
|
-
fail('INPUT_RANGE', path, `a pitch under ${input.min} mm puts more copies in the row than the kernel allows at its longest`);
|
|
1103
1765
|
asset.designInputs[id] = a.pitch;
|
|
1766
|
+
/* The longest length follows the pitch, within the default range (ten times the authored length). */
|
|
1767
|
+
const length = m.inputs.find((i) => i.id === lengthRef && i.role === 'design');
|
|
1768
|
+
if (length) {
|
|
1769
|
+
const longest = Math.min(round6(limit * a.pitch), round6(authored * V3_INSTANCE_SIZE_DEFAULT.times));
|
|
1770
|
+
length.max = Math.max(longest, authored);
|
|
1771
|
+
if (length.min > length.max)
|
|
1772
|
+
length.min = Math.min(length.min, authored);
|
|
1773
|
+
}
|
|
1104
1774
|
return asset;
|
|
1105
1775
|
}
|
|
1106
1776
|
function scaleUniformly(asset) {
|