@hatiolab/figure-model 0.1.35 → 0.1.37
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 +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +89 -16
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +34 -25
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +97 -0
- package/dist/v3-authoring-actions.d.ts.map +1 -0
- package/dist/v3-authoring-actions.js +384 -0
- package/dist/v3-authoring-actions.js.map +1 -0
- package/dist/v3-capabilities.d.ts.map +1 -1
- package/dist/v3-capabilities.js +4 -77
- package/dist/v3-capabilities.js.map +1 -1
- package/dist/v3-driver.d.ts +24 -1
- package/dist/v3-driver.d.ts.map +1 -1
- package/dist/v3-driver.js +68 -2
- package/dist/v3-driver.js.map +1 -1
- package/dist/v3-from-v2.d.ts +34 -1
- package/dist/v3-from-v2.d.ts.map +1 -1
- package/dist/v3-from-v2.js +676 -19
- package/dist/v3-from-v2.js.map +1 -1
- package/dist/v3-gate.d.ts +59 -6
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +310 -45
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-graph-types.d.ts +9 -1
- package/dist/v3-graph-types.d.ts.map +1 -1
- package/dist/v3-graph.d.ts +5 -3
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +93 -4
- package/dist/v3-graph.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +1 -1
- package/dist/v3-kernel-version.js +1 -1
- package/dist/v3-mesh-compare.d.ts +2 -5
- package/dist/v3-mesh-compare.d.ts.map +1 -1
- package/dist/v3-mesh-compare.js +2 -145
- package/dist/v3-mesh-compare.js.map +1 -1
- package/dist/v3-shape-sampling.d.ts +14 -0
- package/dist/v3-shape-sampling.d.ts.map +1 -0
- package/dist/v3-shape-sampling.js +120 -0
- package/dist/v3-shape-sampling.js.map +1 -0
- package/dist/v3-surface.d.ts +15 -0
- package/dist/v3-surface.d.ts.map +1 -0
- package/dist/v3-surface.js +104 -0
- package/dist/v3-surface.js.map +1 -0
- package/docs/prototypes/v3-asset.schema.json +0 -8
- package/docs/v3-asset-persistence.md +1 -1
- package/docs/v3-legacy-risk-audit-2026-09-23.md +39 -0
- package/docs/v3-operator-contracts.md +2 -0
- package/docs/v3-shape-dimension-contract.md +111 -0
- package/package.json +1 -1
package/dist/v3-from-v2.js
CHANGED
|
@@ -53,6 +53,8 @@ const SIZE_RANGE = 10;
|
|
|
53
53
|
* sizes figures have (mm, up to 10⁵). A numerical stability bound, not a minimum part size (designer's ruling 2).
|
|
54
54
|
*/
|
|
55
55
|
const SPAN_POSITIVE_STEP = 1e-6;
|
|
56
|
+
/** Above this many state inputs the corners are not enumerated for the declared volume; the resting pose stands. */
|
|
57
|
+
const OCCUPANCY_STATE_CAP = 10;
|
|
56
58
|
const ONE = { kind: 'one' };
|
|
57
59
|
const SEG_DEFAULT = 12;
|
|
58
60
|
const RIGHT_ANGLE_TOLERANCE = 1e-9;
|
|
@@ -126,6 +128,53 @@ export function eulerXYZ(rotation) {
|
|
|
126
128
|
[-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]
|
|
127
129
|
];
|
|
128
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Which world axis each local axis lies nearest to, for any rotation; null when two local axes claim one world axis
|
|
133
|
+
* (a 45° turn). Used only when re-authoring a rotated part without shear.
|
|
134
|
+
*/
|
|
135
|
+
function nearestAxisPermutation(rotation) {
|
|
136
|
+
const r = eulerXYZ(rotation);
|
|
137
|
+
const out = {};
|
|
138
|
+
const taken = new Set();
|
|
139
|
+
for (const [i, local] of AXES.entries()) {
|
|
140
|
+
let best = 'x', size = -1;
|
|
141
|
+
for (const [a, world] of AXES.entries()) {
|
|
142
|
+
const v = Math.abs(r[a][i]);
|
|
143
|
+
if (v > size)
|
|
144
|
+
(size = v), (best = world);
|
|
145
|
+
}
|
|
146
|
+
if (taken.has(best))
|
|
147
|
+
return null;
|
|
148
|
+
taken.add(best);
|
|
149
|
+
out[local] = best;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Does scaling per world axis commute with this rotation?
|
|
155
|
+
*
|
|
156
|
+
* V2 bakes the rotation into the mesh and then scales it per world axis: the drawn body is `S·R·v`. A rigid V3
|
|
157
|
+
* part scales its own dimensions and then rotates: `R·S·v`. Those are the same body exactly when `S` and `R`
|
|
158
|
+
* commute, and diag(f) commutes with R when every pair of axes the rotation mixes carries the same factor.
|
|
159
|
+
*
|
|
160
|
+
* This is not a corner case. ROLL_CRADLE's pads are turned 41° about X and follow the instance on Y and Z with
|
|
161
|
+
* the same factor (`anchor: y scale, z aspect-y`), so V2 never sheared them — it scaled them uniformly inside
|
|
162
|
+
* the plane the rotation turns in. Such a part converts exactly, with each local dimension taking its own axis's
|
|
163
|
+
* factor, and needs no re-authoring.
|
|
164
|
+
*/
|
|
165
|
+
function scalingCommutesWithRotation(rotation, same) {
|
|
166
|
+
const R = eulerXYZ(rotation);
|
|
167
|
+
for (const [i, a] of AXES.entries())
|
|
168
|
+
for (const [j, b] of AXES.entries()) {
|
|
169
|
+
if (i === j)
|
|
170
|
+
continue;
|
|
171
|
+
if (Math.abs(R[i][j]) > MIXING_TOLERANCE && !same(a, b))
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
/** Below this, a rotation does not mix two axes enough to tell a shear from a uniform scale at figure sizes. */
|
|
177
|
+
const MIXING_TOLERANCE = 1e-9;
|
|
129
178
|
/** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
|
|
130
179
|
function axisPermutation(rotation) {
|
|
131
180
|
if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
|
|
@@ -173,6 +222,7 @@ export function convertV2ToV3(source, options) {
|
|
|
173
222
|
const refusals = [];
|
|
174
223
|
const notes = [];
|
|
175
224
|
const rangeChanges = [];
|
|
225
|
+
let method = 'mechanical';
|
|
176
226
|
const checked = validate(source);
|
|
177
227
|
if (checked.errors.length) {
|
|
178
228
|
return {
|
|
@@ -210,8 +260,9 @@ export function convertV2ToV3(source, options) {
|
|
|
210
260
|
stateDefaults: {},
|
|
211
261
|
palette: {},
|
|
212
262
|
appearance: [],
|
|
213
|
-
// ADR-0044 · ADR-0045: the V2 base box and placement become the declared
|
|
214
|
-
|
|
263
|
+
// ADR-0044 · ADR-0045: the V2 base box and placement become the declared volume, written as the reach on
|
|
264
|
+
// each side of the origin (designer's ruling 2026-09-23). V2 read a missing placement as floor.
|
|
265
|
+
occupancy: { bounds: null, placement: src.placement ?? 'floor' }
|
|
215
266
|
};
|
|
216
267
|
// Instance size inputs. The range starts at the pitch on a spreading repeat axis (V2 draws at least one
|
|
217
268
|
// copy below that; `fit-pitch@1` draws none) and stops where V2 would silently clamp the copy count.
|
|
@@ -291,6 +342,14 @@ export function convertV2ToV3(source, options) {
|
|
|
291
342
|
const shiftsOf = new Map();
|
|
292
343
|
// A scale channel: per local axis, a ratio the part's dimensions are multiplied by (contract §2: dims × affine(p)).
|
|
293
344
|
const scaleOf = new Map();
|
|
345
|
+
/** part name → the flag value saying whether it is shown. Written by a channel that V2 spelled as scale 0. */
|
|
346
|
+
const visibilityOf = new Map();
|
|
347
|
+
/** Per part and axis: how far a state channel stretches or shrinks it, from the channel's own keys. */
|
|
348
|
+
const scaleRangeOf = new Map();
|
|
349
|
+
/** Per part: where it sits, how big it is, and whether it moves on its own. Read when the volume is declared. */
|
|
350
|
+
const reachOf = new Map();
|
|
351
|
+
/** Per joint: the three length values of its origin, and the joint above it. */
|
|
352
|
+
const jointOriginOf = new Map();
|
|
294
353
|
const frameOf = new Map(); // part → the joint whose frame carries it
|
|
295
354
|
const motionOf = new Map(); // joint → pose asset.rest → asset
|
|
296
355
|
const partsByName = new Map(parts.map(cp => [cp.name, cp]));
|
|
@@ -456,11 +515,91 @@ export function convertV2ToV3(source, options) {
|
|
|
456
515
|
}
|
|
457
516
|
}
|
|
458
517
|
// Local dimensions scale by the factor of the world axis each local axis lands on.
|
|
459
|
-
|
|
518
|
+
let perm = axisPermutation(cp.transform.rotation);
|
|
460
519
|
const follows = AXES.some(a => factor[a].kind !== 'one');
|
|
520
|
+
// Fastened to another part: rigid, with the centre riding that part's face (designer's ruling 2026-09-22).
|
|
521
|
+
const fastening = options.reauthor?.attach?.find(x => x.part === name);
|
|
522
|
+
if (fastening) {
|
|
523
|
+
const mount = parts.find(x => x.name === fastening.to);
|
|
524
|
+
if (!mount) {
|
|
525
|
+
refusals.push({ code: 'ATTACH_TARGET', part: name, detail: `it is to be fastened to "${fastening.to}", which is not a part of this figure` });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
/*
|
|
529
|
+
Which face it is fastened to: the one it stands out through. A panel set into its housing still counts,
|
|
530
|
+
which is how the notching machine's control panel is drawn — it is sunk into the enclosure and only its
|
|
531
|
+
front shows. Exactly one face must qualify, or there is nothing to call the mounting.
|
|
532
|
+
*/
|
|
533
|
+
const reachOf = (q, a) => ({ lo: q.transform.position[a] - q.transform.size[a] / 2, hi: q.transform.position[a] + q.transform.size[a] / 2 });
|
|
534
|
+
const held = [];
|
|
535
|
+
for (const a of AXES) {
|
|
536
|
+
const mine = reachOf(cp, a), theirs = reachOf(mount, a);
|
|
537
|
+
if (mine.hi > theirs.hi + 1e-9)
|
|
538
|
+
held.push({ axis: a, face: theirs.hi, offset: cp.transform.position[a] - theirs.hi });
|
|
539
|
+
else if (mine.lo < theirs.lo - 1e-9)
|
|
540
|
+
held.push({ axis: a, face: theirs.lo, offset: cp.transform.position[a] - theirs.lo });
|
|
541
|
+
}
|
|
542
|
+
if (held.length !== 1) {
|
|
543
|
+
refusals.push({
|
|
544
|
+
code: 'ATTACH_FACE',
|
|
545
|
+
part: name,
|
|
546
|
+
detail: held.length
|
|
547
|
+
? `it stands out of "${fastening.to}" on ${held.map(h => h.axis).join(' and ')}; which face holds it cannot be read from the drawing`
|
|
548
|
+
: `it is wholly inside "${fastening.to}", so there is no face it stands out through`
|
|
549
|
+
});
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const face = held[0];
|
|
553
|
+
if (anchorOf(withSizing(mount), base, face.axis) !== 'scale') {
|
|
554
|
+
refusals.push({ code: 'ATTACH_MOUNT', part: name, detail: `"${fastening.to}" does not follow the instance proportionally on ${face.axis}, so its face is not a ratio of the size` });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
// The mount's face is that ratio of the instance size; the gap the author drew is kept as it is.
|
|
558
|
+
const faceRatio = face.face / base[face.axis];
|
|
559
|
+
const rideTo = g.add(`${name}.${face.axis}.fastened`, g.mul(`${name}.${face.axis}.face`, size[face.axis], ratio(round6(faceRatio), `${name}.${face.axis}.faceAt`)), g.constant(round6(face.offset), 'mm', `${name}.${face.axis}.gap`));
|
|
560
|
+
for (const a of AXES)
|
|
561
|
+
factor[a] = ONE;
|
|
562
|
+
centre[face.axis] = { kind: 'ref', ref: rideTo };
|
|
563
|
+
perm = { x: 'x', y: 'y', z: 'z' };
|
|
564
|
+
method = 're-authored';
|
|
565
|
+
notes.push({
|
|
566
|
+
code: 'RE_AUTHORED_FASTENED',
|
|
567
|
+
part: name,
|
|
568
|
+
detail: `tilted (${AXES.map(a => `${a}:${cp.transform.rotation?.[a] ?? 0}°`).join(', ')}) and following the instance, which V2 shears. Re-authored: it keeps its authored dimensions and tilt, and its centre rides the ${face.axis} face of "${fastening.to}" at the ${face.offset.toFixed(1)} mm the author drew. The gap between them is the same at every instance size`
|
|
569
|
+
});
|
|
570
|
+
}
|
|
461
571
|
if (!perm && follows) {
|
|
462
|
-
|
|
463
|
-
|
|
572
|
+
// A rotation that only mixes axes carrying the same factor is not a shear: scaling and rotating commute,
|
|
573
|
+
// so each local axis keeps its own axis's factor and the body is the one V2 draws.
|
|
574
|
+
const sameFactor = (a, b) => {
|
|
575
|
+
const x = factor[a], y = factor[b];
|
|
576
|
+
if (x.kind === 'one' && y.kind === 'one')
|
|
577
|
+
return true;
|
|
578
|
+
return x.kind === 'ref' && y.kind === 'ref' && x.sig !== undefined && x.sig === y.sig;
|
|
579
|
+
};
|
|
580
|
+
if (scalingCommutesWithRotation(cp.transform.rotation, sameFactor)) {
|
|
581
|
+
perm = { x: 'x', y: 'y', z: 'z' };
|
|
582
|
+
notes.push({
|
|
583
|
+
code: 'ROTATED_UNIFORM_PLANE',
|
|
584
|
+
part: name,
|
|
585
|
+
detail: `turned off the axes and following the instance, but every pair of axes the rotation mixes carries the same factor, so scaling and rotating commute. V2 scaled it uniformly inside the plane it turns in rather than shearing it; converted as V2 draws it, with no re-authoring`
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!perm && follows) {
|
|
590
|
+
const nearest = options.reauthor?.rotatedFollows === 'nearest-axis' ? nearestAxisPermutation(cp.transform.rotation) : null;
|
|
591
|
+
if (!nearest) {
|
|
592
|
+
refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
perm = nearest;
|
|
596
|
+
method = 're-authored';
|
|
597
|
+
const rot = cp.transform.rotation ?? {};
|
|
598
|
+
notes.push({
|
|
599
|
+
code: 'RE_AUTHORED_ROTATED',
|
|
600
|
+
part: name,
|
|
601
|
+
detail: `rotated (${AXES.map(a => `${a}:${rot[a] ?? 0}°`).join(', ')}) and its size follows the instance; V2 sheared the mesh. Re-authored rigid: each local dimension follows the world axis it lies nearest to (${AXES.map(a => `${a}→${nearest[a]}`).join(', ')}). Same as V2 where the factors on the in-plane axes agree; different elsewhere, by design`
|
|
602
|
+
});
|
|
464
603
|
}
|
|
465
604
|
// A channel scale is planned after the parts and read when the shape node is written, so `dim` is called
|
|
466
605
|
// inside the shape closures only.
|
|
@@ -470,7 +609,12 @@ export function convertV2ToV3(source, options) {
|
|
|
470
609
|
return v;
|
|
471
610
|
return { kind: 'ref', ref: g.mul(`${name}.${hint}.scaled`, g.len(v, `${name}.${hint}`), sc) };
|
|
472
611
|
};
|
|
473
|
-
|
|
612
|
+
// Memoised: the shape writes it and the declared volume reads it, and a node id may only be claimed once.
|
|
613
|
+
const dims = {};
|
|
614
|
+
const raws = {};
|
|
615
|
+
/** The dimension before any state channel scales it. The declared volume measures from this. */
|
|
616
|
+
const rawDim = (local) => (raws[local] ??= times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE));
|
|
617
|
+
const dim = (local) => (dims[local] ??= withChannel(rawDim(local), local, `dim.${local}`));
|
|
474
618
|
const channelSplits = () => {
|
|
475
619
|
const sc = scaleOf.get(name);
|
|
476
620
|
return !!sc && (sc.x ?? null) !== (sc.z ?? null);
|
|
@@ -559,6 +703,64 @@ export function convertV2ToV3(source, options) {
|
|
|
559
703
|
if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
|
|
560
704
|
appearance.segments = segments;
|
|
561
705
|
info.set(name, { centre, factor, repeat: !!repeat });
|
|
706
|
+
/** The widest this body's side can be: its own dimension, times the most a state channel stretches it. */
|
|
707
|
+
const widestOf = (a) => {
|
|
708
|
+
const range = scaleRangeOf.get(name)?.[a];
|
|
709
|
+
if (!scaleOf.get(name)?.[a])
|
|
710
|
+
return dim(a);
|
|
711
|
+
if (!range)
|
|
712
|
+
return null; // a channel whose reach is not known; the volume cannot be produced from it
|
|
713
|
+
const k = Math.max(Math.abs(range.min), Math.abs(range.max));
|
|
714
|
+
return k === 1 ? rawDim(a) : times(`${name}.widest.${a}`, rawDim(a), { kind: 'ref', ref: ratio(round6(k), `${name}.widest.${a}.k`), sig: `widest.${name}.${a}` });
|
|
715
|
+
};
|
|
716
|
+
/*
|
|
717
|
+
A part's dimensions are its own, measured along its own axes; the declared volume is measured along the
|
|
718
|
+
asset's. A body turned by its authored rotation fills, on one asset axis, the sum of its three sides
|
|
719
|
+
weighted by how much each leans onto that axis — the box around the turned box. For a quarter turn that is
|
|
720
|
+
a plain swap, and for anything between it is wider than the body, which is the direction to be wrong in.
|
|
721
|
+
Reading the local side as if it were the world side is how a motor lying on its side came to be declared
|
|
722
|
+
15 mm short.
|
|
723
|
+
*/
|
|
724
|
+
const rotationMatrix = eulerXYZ(cp.transform.rotation);
|
|
725
|
+
const worldSpan = (a) => {
|
|
726
|
+
const row = rotationMatrix[AXES.indexOf(a)];
|
|
727
|
+
/*
|
|
728
|
+
A bound that rounds inward is not a bound, so every weight that stays is rounded away from zero.
|
|
729
|
+
|
|
730
|
+
A weight too small to be worth its own node is not moved onto another axis: the axes carry different
|
|
731
|
+
sides, and 5e-10 of a 100000 mm side is not covered by 5e-10 more of a 0.01 mm one (V3 designer's
|
|
732
|
+
counterexample 2026-09-23). It is dropped only when its own side is a fixed length, and then its whole
|
|
733
|
+
contribution in millimetres is added on, rounded up. A side that follows the instance keeps its term.
|
|
734
|
+
*/
|
|
735
|
+
const terms = [];
|
|
736
|
+
let extraMm = 0;
|
|
737
|
+
for (const [i, local] of AXES.entries()) {
|
|
738
|
+
const k = Math.abs(row[i]);
|
|
739
|
+
if (k === 0)
|
|
740
|
+
continue;
|
|
741
|
+
const side = widestOf(local);
|
|
742
|
+
if (!side)
|
|
743
|
+
return null;
|
|
744
|
+
const up = Math.ceil(k * 1e6) / 1e6;
|
|
745
|
+
if (k < 1e-9 && side.kind === 'const') {
|
|
746
|
+
extraMm += Math.ceil(up * side.value * 1e6) / 1e6;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
terms.push({ k: up, len: side });
|
|
750
|
+
}
|
|
751
|
+
return { terms, extraMm };
|
|
752
|
+
};
|
|
753
|
+
// The declared volume is built from the structure, so it needs each body's own reach and where it sits.
|
|
754
|
+
reachOf.set(name, {
|
|
755
|
+
centre,
|
|
756
|
+
dim,
|
|
757
|
+
turns: () => spinOf.has(name),
|
|
758
|
+
widest: widestOf,
|
|
759
|
+
worldSpan,
|
|
760
|
+
slides: () => (shiftsOf.get(name) ?? [])
|
|
761
|
+
.filter(sh => sh.travel)
|
|
762
|
+
.map(sh => ({ axis: sh.axis, factor: factor[sh.axis], travel: sh.travel }))
|
|
763
|
+
});
|
|
562
764
|
if (!repeat) {
|
|
563
765
|
plans.push({
|
|
564
766
|
appearance,
|
|
@@ -641,12 +843,35 @@ export function convertV2ToV3(source, options) {
|
|
|
641
843
|
}
|
|
642
844
|
});
|
|
643
845
|
}
|
|
644
|
-
|
|
846
|
+
asset.occupancy.bounds = {
|
|
847
|
+
x: { min: g.mul('occupancy.x.min', size.x, ratio(-0.5, 'minusHalf')), max: g.mul('occupancy.x.max', size.x, ratio(0.5, 'half')) },
|
|
848
|
+
y: { min: g.constant(0, 'mm', 'zero'), max: V3_SIZE_INPUTS.y },
|
|
849
|
+
z: { min: g.mul('occupancy.z.min', size.z, ratio(-0.5, 'minusHalf')), max: g.mul('occupancy.z.max', size.z, ratio(0.5, 'half')) }
|
|
850
|
+
};
|
|
851
|
+
const motion = planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, scaleRangeOf, visibilityOf, jointOriginOf, lost, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
|
|
852
|
+
if (motion.reauthored)
|
|
853
|
+
method = 're-authored';
|
|
645
854
|
if (refusals.length)
|
|
646
855
|
return { status: 'refused', refusals, lost, rangeChanges, notes };
|
|
647
856
|
for (const plan of plans) {
|
|
648
857
|
plan.nodes();
|
|
649
|
-
|
|
858
|
+
const shown = visibilityOf.get(plan.appearance.target);
|
|
859
|
+
asset.appearance.push(shown ? { ...plan.appearance, visibility: shown } : plan.appearance);
|
|
860
|
+
}
|
|
861
|
+
if (options.occupancy === 'measured') {
|
|
862
|
+
/** Which joint carries a part: the nearest one above it in the parent chain. */
|
|
863
|
+
const jointsByChild = new Map((src.joints ?? []).map(j => [j.child, j.name]));
|
|
864
|
+
const carrierOfPart = (name) => {
|
|
865
|
+
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
866
|
+
const j = jointsByChild.get(p.name);
|
|
867
|
+
if (j)
|
|
868
|
+
return j;
|
|
869
|
+
}
|
|
870
|
+
return undefined;
|
|
871
|
+
};
|
|
872
|
+
declareOccupancy({ g, parts, reachOf, jointOriginOf, carrierOf: carrierOfPart, base, designInputs: asset.designInputs, asset, refusals, notes });
|
|
873
|
+
if (refusals.length)
|
|
874
|
+
return { status: 'refused', refusals, lost, rangeChanges, notes };
|
|
650
875
|
}
|
|
651
876
|
try {
|
|
652
877
|
compileV3Asset(asset);
|
|
@@ -655,7 +880,308 @@ export function convertV2ToV3(source, options) {
|
|
|
655
880
|
const err = e;
|
|
656
881
|
return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, rangeChanges, notes };
|
|
657
882
|
}
|
|
658
|
-
return { status: 'converted', method
|
|
883
|
+
return { status: 'converted', method, asset, lost, rangeChanges, notes };
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Declares the volume the figure occupies, from the structure of the figure rather than from measurements.
|
|
887
|
+
*
|
|
888
|
+
* V2 had one box for two jobs: the size a figure is authored and placed at, and the room it takes up. In V3
|
|
889
|
+
* they are separate (V3 designer's ruling 2026-09-22), and the volume is the reach on each side of the origin
|
|
890
|
+
* (ruling 2026-09-23), so it can describe a part below the mounting plane or off one side only.
|
|
891
|
+
*
|
|
892
|
+
* **Why not measure it.** An earlier version sampled the reach at three instance sizes on one axis and fitted a
|
|
893
|
+
* straight line. That says nothing between or beyond those sizes, nothing about changing two axes together, and
|
|
894
|
+
* nothing about the poses between the state corners it happened to visit. Passing the third point proved
|
|
895
|
+
* nothing (designer's ruling 2026-09-23).
|
|
896
|
+
*
|
|
897
|
+
* **What is built instead.** For every body, a bound that holds in every allowed state because of how the
|
|
898
|
+
* figure is put together, written as graph values so it is right at every instance size by construction:
|
|
899
|
+
*
|
|
900
|
+
* a body that does not move exactly its own box
|
|
901
|
+
* a body that turns where it sits its centre, give or take its own reach
|
|
902
|
+
* a body carried by joints the outermost joint's origin, give or take the chain: the distance out
|
|
903
|
+
* to each joint below it, any sliding travel those joints allow, and the
|
|
904
|
+
* body's own reach
|
|
905
|
+
*
|
|
906
|
+
* A turning joint can point what hangs below it in any direction, so the chain is summed as lengths and applied
|
|
907
|
+
* to every axis. That is wider than the true swept volume; it is a bound that can be explained, not a guess.
|
|
908
|
+
* Joint angle ranges can tighten it later. This is arithmetic about where the parts can be, not a claim about
|
|
909
|
+
* collisions, loads or safety, and it applies to a revolving door and a lift the same way it applies to an arm.
|
|
910
|
+
*
|
|
911
|
+
* A length is summed as `|dx| + |dy| + |dz|`, which is at least the straight-line distance, so the bound stays
|
|
912
|
+
* on the safe side. `|x|` is `max(x, -x)`.
|
|
913
|
+
*/
|
|
914
|
+
/**
|
|
915
|
+
* A value read as `base + Σ slope_a · size_a`, or null when it is not of that shape.
|
|
916
|
+
*
|
|
917
|
+
* This reads the graph, it does not sample it, so what it returns is the value at every instance size and not
|
|
918
|
+
* a fit through a few of them. It is how the declaration knows which parts can never leave the authored box:
|
|
919
|
+
* every size input is positive, so a difference whose base and slopes are all non-negative is never negative.
|
|
920
|
+
*/
|
|
921
|
+
function affineInSize(g, designInputs, ref) {
|
|
922
|
+
const zero = () => ({ x: 0, y: 0, z: 0 });
|
|
923
|
+
const constants = new Map(g.graph.constants.map(k => [k.id, k.value * (k.unit === 'cm' ? 10 : k.unit === 'm' ? 1000 : k.unit === 'percent' ? 0.01 : 1)]));
|
|
924
|
+
const writers = new Map();
|
|
925
|
+
for (const n of g.graph.nodes)
|
|
926
|
+
for (const out of Object.values(n.outputs))
|
|
927
|
+
writers.set(out, n);
|
|
928
|
+
const seen = new Set();
|
|
929
|
+
const walk = (r) => {
|
|
930
|
+
if (seen.has(r))
|
|
931
|
+
return null;
|
|
932
|
+
seen.add(r);
|
|
933
|
+
try {
|
|
934
|
+
for (const a of AXES)
|
|
935
|
+
if (r === V3_SIZE_INPUTS[a])
|
|
936
|
+
return { base: 0, slope: { ...zero(), [a]: 1 } };
|
|
937
|
+
if (constants.has(r))
|
|
938
|
+
return { base: constants.get(r), slope: zero() };
|
|
939
|
+
if (Object.hasOwn(designInputs, r))
|
|
940
|
+
return { base: designInputs[r], slope: zero() };
|
|
941
|
+
const n = writers.get(r);
|
|
942
|
+
if (!n)
|
|
943
|
+
return null;
|
|
944
|
+
if (n.op === 'add@1') {
|
|
945
|
+
const a = walk(n.args[0]), b = walk(n.args[1]);
|
|
946
|
+
return a && b ? { base: a.base + b.base, slope: { x: a.slope.x + b.slope.x, y: a.slope.y + b.slope.y, z: a.slope.z + b.slope.z } } : null;
|
|
947
|
+
}
|
|
948
|
+
if (n.op === 'div@1') {
|
|
949
|
+
const a = walk(n.args[0]), b = walk(n.args[1]);
|
|
950
|
+
// Only a constant denominator keeps the value affine; a span's factor is (size − gaps) / a fixed length.
|
|
951
|
+
if (!a || !b || AXES.some(x => b.slope[x] !== 0) || b.base === 0)
|
|
952
|
+
return null;
|
|
953
|
+
return { base: a.base / b.base, slope: { x: a.slope.x / b.base, y: a.slope.y / b.base, z: a.slope.z / b.base } };
|
|
954
|
+
}
|
|
955
|
+
if (n.op === 'mul@1') {
|
|
956
|
+
const a = walk(n.args[0]), b = walk(n.args[1]);
|
|
957
|
+
if (!a || !b)
|
|
958
|
+
return null;
|
|
959
|
+
const flat = (v) => AXES.every(x => v.slope[x] === 0);
|
|
960
|
+
if (flat(b))
|
|
961
|
+
return { base: a.base * b.base, slope: { x: a.slope.x * b.base, y: a.slope.y * b.base, z: a.slope.z * b.base } };
|
|
962
|
+
if (flat(a))
|
|
963
|
+
return { base: a.base * b.base, slope: { x: b.slope.x * a.base, y: b.slope.y * a.base, z: b.slope.z * a.base } };
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
finally {
|
|
969
|
+
seen.delete(r);
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
return walk(ref);
|
|
973
|
+
}
|
|
974
|
+
function declareOccupancy(c) {
|
|
975
|
+
const { g, parts, reachOf, jointOriginOf, carrierOf, base, designInputs, asset, refusals, notes } = c;
|
|
976
|
+
const authoredBounds = asset.occupancy.bounds;
|
|
977
|
+
const affOfLen = (l) => (l.kind === 'const' ? { base: l.value, slope: { x: 0, y: 0, z: 0 } } : affineInSize(g, designInputs, l.ref));
|
|
978
|
+
const affAdd = (a, b, k = 1) => ({
|
|
979
|
+
base: a.base + k * b.base,
|
|
980
|
+
slope: AXES.reduce((o, x) => ((o[x] = a.slope[x] + k * b.slope[x]), o), {})
|
|
981
|
+
});
|
|
982
|
+
const affScale = (a, k) => ({ base: a.base * k, slope: AXES.reduce((o, x) => ((o[x] = a.slope[x] * k), o), {}) });
|
|
983
|
+
/** Is this value never past the authored bound on that side, at any instance size? Read from the graph. */
|
|
984
|
+
const neverPast = (mine, a, side) => {
|
|
985
|
+
const theirs = affineInSize(g, designInputs, authoredBounds[a][side]);
|
|
986
|
+
if (!mine || !theirs)
|
|
987
|
+
return false;
|
|
988
|
+
// min side: authored − mine ≤ 0 everywhere. max side: mine − authored ≤ 0 everywhere.
|
|
989
|
+
const d = side === 'min'
|
|
990
|
+
? { base: theirs.base - mine.base, slope: AXES.reduce((o, x) => ((o[x] = theirs.slope[x] - mine.slope[x]), o), {}) }
|
|
991
|
+
: { base: mine.base - theirs.base, slope: AXES.reduce((o, x) => ((o[x] = mine.slope[x] - theirs.slope[x]), o), {}) };
|
|
992
|
+
// Only the slack that floating point itself needs. SAME_TOLERANCE_MM is how close V2 and V3 must draw the
|
|
993
|
+
// same body; borrowing it here let a part hang 1 mm outside the volume that is supposed to hold it.
|
|
994
|
+
return d.base <= FLOAT_SLACK_MM && AXES.every(x => d.slope[x] <= FLOAT_SLACK_MM * 1e-3);
|
|
995
|
+
};
|
|
996
|
+
let seq = 0;
|
|
997
|
+
const id = () => `occ.v${seq++}`;
|
|
998
|
+
const isNum = (v) => 'n' in v;
|
|
999
|
+
const ref = (v, hint) => (isNum(v) ? g.constant(round6(v.n), 'mm', hint) : v.ref);
|
|
1000
|
+
const ofLen = (l) => (l.kind === 'const' ? { n: l.value } : { ref: l.ref });
|
|
1001
|
+
/*
|
|
1002
|
+
The same sum turns up again and again: every body on a shelf is half its own depth from the same place, and
|
|
1003
|
+
every joint in a chain is measured from the one above it by parts that share it. Writing each of those once
|
|
1004
|
+
is what keeps a twenty-part figure inside the document's node limit, and it changes no value.
|
|
1005
|
+
*/
|
|
1006
|
+
const shared = new Map();
|
|
1007
|
+
const once = (key, build) => {
|
|
1008
|
+
const known = shared.get(key);
|
|
1009
|
+
if (known)
|
|
1010
|
+
return known;
|
|
1011
|
+
const made = build();
|
|
1012
|
+
shared.set(key, made);
|
|
1013
|
+
return made;
|
|
1014
|
+
};
|
|
1015
|
+
const add = (a, b) => {
|
|
1016
|
+
if (isNum(a) && isNum(b))
|
|
1017
|
+
return { n: a.n + b.n };
|
|
1018
|
+
if (isNum(a) && a.n === 0)
|
|
1019
|
+
return b;
|
|
1020
|
+
if (isNum(b) && b.n === 0)
|
|
1021
|
+
return a;
|
|
1022
|
+
const ra = ref(a, id()), rb = ref(b, id());
|
|
1023
|
+
const key = `+|${[ra, rb].sort().join('|')}`;
|
|
1024
|
+
return { ref: once(key, () => g.add(id(), ra, rb)) };
|
|
1025
|
+
};
|
|
1026
|
+
const scale = (a, k) => {
|
|
1027
|
+
if (isNum(a))
|
|
1028
|
+
return { n: a.n * k };
|
|
1029
|
+
if (k === 1)
|
|
1030
|
+
return a;
|
|
1031
|
+
const key = `*|${a.ref}|${round6(k)}`;
|
|
1032
|
+
return { ref: once(key, () => g.mul(id(), a.ref, g.constant(k, 'ratio', `occ.k${round6(k)}`))) };
|
|
1033
|
+
};
|
|
1034
|
+
const abs = (a) => {
|
|
1035
|
+
if (isNum(a))
|
|
1036
|
+
return { n: Math.abs(a.n) };
|
|
1037
|
+
const negated = ref(scale(a, -1), id());
|
|
1038
|
+
return { ref: once(`abs|${a.ref}`, () => g.node(id(), 'max@1', [a.ref, negated], 'value')) };
|
|
1039
|
+
};
|
|
1040
|
+
const sum = (vs) => vs.reduce(add, { n: 0 });
|
|
1041
|
+
/** |a − b| summed over the three axes: at least the straight-line distance between two places. */
|
|
1042
|
+
const spread = (a, b) => sum(a.map((v, i) => abs(add(v, scale(b[i], -1)))));
|
|
1043
|
+
const low = { x: [], y: [], z: [] };
|
|
1044
|
+
const high = { x: [], y: [], z: [] };
|
|
1045
|
+
/** The reach of a chain of joints, shared by every part that hangs from it. */
|
|
1046
|
+
const chainReach = new Map();
|
|
1047
|
+
/** Per chain: how far each body hanging from it reaches, so only the furthest is written. */
|
|
1048
|
+
const furthest = new Map();
|
|
1049
|
+
const originVals = (j) => jointOriginOf.get(j).origin.map(r => ({ ref: r }));
|
|
1050
|
+
const reachOfChain = (chain) => {
|
|
1051
|
+
const key = chain.join('/');
|
|
1052
|
+
const known = chainReach.get(key);
|
|
1053
|
+
if (known)
|
|
1054
|
+
return known;
|
|
1055
|
+
const below = chain.length > 1 ? reachOfChain(chain.slice(0, -1)) : { n: 0 };
|
|
1056
|
+
const step = chain.length > 1 ? spread(originVals(chain[chain.length - 2]), originVals(chain[chain.length - 1])) : { n: 0 };
|
|
1057
|
+
const travel = { n: jointOriginOf.get(chain[chain.length - 1]).slide };
|
|
1058
|
+
const out = sum([below, step, travel]);
|
|
1059
|
+
chainReach.set(key, out);
|
|
1060
|
+
return out;
|
|
1061
|
+
};
|
|
1062
|
+
for (const cp of parts) {
|
|
1063
|
+
const own = reachOf.get(cp.name);
|
|
1064
|
+
if (!own)
|
|
1065
|
+
continue; // a capability anchor or a refused part: nothing is drawn for it
|
|
1066
|
+
/*
|
|
1067
|
+
A body whose size follows a state input is measured at the widest that channel allows, from the channel's
|
|
1068
|
+
own keys, so the declared volume stands still while the figure moves (shape-dimension contract §2). A
|
|
1069
|
+
channel whose reach is not known is reported rather than guessed at.
|
|
1070
|
+
*/
|
|
1071
|
+
if (AXES.some(a => own.widest(a) === null || own.worldSpan(a) === null)) {
|
|
1072
|
+
refusals.push({
|
|
1073
|
+
code: 'OCCUPANCY_STATE_SIZED',
|
|
1074
|
+
part: cp.name,
|
|
1075
|
+
detail: `its size follows a state channel whose reach is not known, so the volume cannot be produced from it; a declared volume has to stand still while the figure moves`
|
|
1076
|
+
});
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const centre = AXES.map(a => ofLen(own.centre[a]));
|
|
1080
|
+
// Built only where it is used: a body needs either its own half-sides or its reach in any direction, not both.
|
|
1081
|
+
const halfCache = {};
|
|
1082
|
+
const half = (a) => {
|
|
1083
|
+
const span = own.worldSpan(a);
|
|
1084
|
+
return (halfCache[a] ??= scale(add(sum(span.terms.map(t => scale(ofLen(t.len), t.k))), { n: span.extraMm }), 0.5));
|
|
1085
|
+
};
|
|
1086
|
+
/** Half of all three sides together: at least the way from the centre to any corner, however it is turned. */
|
|
1087
|
+
let anyWayCache = null;
|
|
1088
|
+
const anyWay = () => (anyWayCache ??= scale(sum(AXES.map(a => ofLen(own.widest(a)))), 0.5));
|
|
1089
|
+
const chain = [];
|
|
1090
|
+
for (let j = carrierOf(cp.name); j; j = jointOriginOf.get(j)?.above) {
|
|
1091
|
+
if (chain.includes(j))
|
|
1092
|
+
break;
|
|
1093
|
+
chain.unshift(j);
|
|
1094
|
+
}
|
|
1095
|
+
if (!chain.length) {
|
|
1096
|
+
/*
|
|
1097
|
+
It stays in one frame. Turning where it sits is covered by its own reach in every direction; sliding is
|
|
1098
|
+
covered by an interval along the axis it slides on, which handles a negative direction and an
|
|
1099
|
+
asymmetric range without widening the other axes (designer's ruling 2026-09-23). Nothing above it
|
|
1100
|
+
rotates, so the axis it slides along is the axis it slides along at every pose.
|
|
1101
|
+
|
|
1102
|
+
There is no shortcut here for a part that looks like it stays inside. `neverPast` decides that, by
|
|
1103
|
+
reading the value rather than by trusting that position and shape scale together — keepRound, a fixed
|
|
1104
|
+
section and a repeat all break that, and an overhang tolerated at the authored size grows with it.
|
|
1105
|
+
*/
|
|
1106
|
+
const round = own.turns() ? anyWay() : null;
|
|
1107
|
+
const travelOn = (a) => {
|
|
1108
|
+
let lo = { n: 0 }, hi = { n: 0 };
|
|
1109
|
+
for (const sl of own.slides()) {
|
|
1110
|
+
if (sl.axis !== a)
|
|
1111
|
+
continue;
|
|
1112
|
+
/*
|
|
1113
|
+
Every world factor the converter writes is positive, so the interval keeps the travel's own order.
|
|
1114
|
+
The travel is a length, so it enters as millimetres; the factor is the ratio it is stretched by.
|
|
1115
|
+
*/
|
|
1116
|
+
const fac = sl.factor;
|
|
1117
|
+
const f = (v) => fac.kind === 'one'
|
|
1118
|
+
? { n: v }
|
|
1119
|
+
: { ref: once(`travel|${fac.ref}|${round6(v)}`, () => g.mul(id(), g.constant(round6(v), 'mm', `occ.travel${round6(v)}`), fac.ref)) };
|
|
1120
|
+
lo = add(lo, f(Math.min(sl.travel.min, sl.travel.max)));
|
|
1121
|
+
hi = add(hi, f(Math.max(sl.travel.min, sl.travel.max)));
|
|
1122
|
+
}
|
|
1123
|
+
return { lo, hi };
|
|
1124
|
+
};
|
|
1125
|
+
const affCentre = AXES.map(a => affOfLen(own.centre[a]));
|
|
1126
|
+
const affHalf = AXES.map(a => {
|
|
1127
|
+
const d = affOfLen(own.widest(a));
|
|
1128
|
+
return d && affScale(d, 0.5);
|
|
1129
|
+
});
|
|
1130
|
+
const affRound = own.turns()
|
|
1131
|
+
? affHalf.reduce((acc, h) => (acc && h ? affAdd(acc, h) : null), { base: 0, slope: { x: 0, y: 0, z: 0 } })
|
|
1132
|
+
: null;
|
|
1133
|
+
for (const [i, a] of AXES.entries()) {
|
|
1134
|
+
const rAff = own.turns() ? affRound : affHalf[i];
|
|
1135
|
+
const t = travelOn(a);
|
|
1136
|
+
const tAff = (v) => (isNum(v) ? { base: v.n, slope: { x: 0, y: 0, z: 0 } } : affineInSize(g, designInputs, v.ref));
|
|
1137
|
+
const cA = affCentre[i];
|
|
1138
|
+
const tLo = tAff(t.lo), tHi = tAff(t.hi);
|
|
1139
|
+
const loAff = cA && rAff && tLo ? affAdd(affAdd(cA, rAff, -1), tLo) : null;
|
|
1140
|
+
const hiAff = cA && rAff && tHi ? affAdd(affAdd(cA, rAff), tHi) : null;
|
|
1141
|
+
const reach = own.turns() ? round : half(a);
|
|
1142
|
+
if (!neverPast(loAff, a, 'min'))
|
|
1143
|
+
low[a].push(add(add(centre[i], scale(reach, -1)), t.lo));
|
|
1144
|
+
if (!neverPast(hiAff, a, 'max'))
|
|
1145
|
+
high[a].push(add(add(centre[i], reach), t.hi));
|
|
1146
|
+
}
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
/*
|
|
1150
|
+
Every body on one chain swings about the same place, so the chain keeps the furthest of them and writes
|
|
1151
|
+
one pair of bounds. A pair for each body would say the same thing, only more of it.
|
|
1152
|
+
*/
|
|
1153
|
+
const radius = sum([reachOfChain(chain), spread(originVals(chain[chain.length - 1]), centre), anyWay()]);
|
|
1154
|
+
const key = chain.join('/');
|
|
1155
|
+
const held = furthest.get(key);
|
|
1156
|
+
furthest.set(key, held ? { chain, radius: [...held.radius, radius] } : { chain, radius: [radius] });
|
|
1157
|
+
notes.push({
|
|
1158
|
+
code: 'OCCUPANCY_FROM_STRUCTURE',
|
|
1159
|
+
part: cp.name,
|
|
1160
|
+
detail: `carried by ${chain.join(' → ')}, so it can be anywhere within that chain's reach of ${chain[0]}: the way out to each joint below it, any sliding travel they allow, and its own size. The declared volume holds that at every instance size, without measuring a pose`
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
for (const { chain, radius } of furthest.values()) {
|
|
1164
|
+
const anchor = originVals(chain[0]);
|
|
1165
|
+
const worst = radius.every(isNum)
|
|
1166
|
+
? { n: Math.max(...radius.map(r => r.n)) }
|
|
1167
|
+
: radius.length === 1
|
|
1168
|
+
? radius[0]
|
|
1169
|
+
: { ref: g.node(id(), 'max@1', radius.map((r, i) => ref(r, `occ.r${i}`)), 'value') };
|
|
1170
|
+
for (const [i, a] of AXES.entries()) {
|
|
1171
|
+
low[a].push(add(anchor[i], scale(worst, -1)));
|
|
1172
|
+
high[a].push(add(anchor[i], worst));
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (!reachOf.size) {
|
|
1176
|
+
refusals.push({ code: 'OCCUPANCY_NO_BODIES', part: '*', detail: 'nothing is drawn, so there is no volume to declare' });
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
for (const a of AXES) {
|
|
1180
|
+
if (low[a].length)
|
|
1181
|
+
asset.occupancy.bounds[a].min = g.node(`occupancy.${a}.min.held`, 'min@1', [authoredBounds[a].min, ...low[a].map((v, i) => ref(v, `occ.${a}.lo${i}`))], 'value');
|
|
1182
|
+
if (high[a].length)
|
|
1183
|
+
asset.occupancy.bounds[a].max = g.node(`occupancy.${a}.max.held`, 'max@1', [authoredBounds[a].max, ...high[a].map((v, i) => ref(v, `occ.${a}.hi${i}`))], 'value');
|
|
1184
|
+
}
|
|
659
1185
|
}
|
|
660
1186
|
/** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
|
|
661
1187
|
function withSizing(cp) {
|
|
@@ -825,7 +1351,17 @@ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
|
|
|
825
1351
|
: null;
|
|
826
1352
|
// Local dimensions: each local axis lands on a world axis and takes that axis's factor, times any channel scale
|
|
827
1353
|
// (V2 applies pose.scale in the part's own frame, about its centre).
|
|
828
|
-
|
|
1354
|
+
/*
|
|
1355
|
+
Which world axis each local axis takes its factor from. A right-angle rotation permutes them. A rotation
|
|
1356
|
+
that only mixes axes carrying the same factor commutes with the scaling, so each local axis keeps its own
|
|
1357
|
+
(`scalingCommutesWithRotation`). Anything else is a real shear: the drawn body is not a box, so there are no
|
|
1358
|
+
local dimensions to report and the unscaled ones stand in. The surface points are sampled from the sheared
|
|
1359
|
+
body either way, so a shear is still caught by the mesh comparison.
|
|
1360
|
+
*/
|
|
1361
|
+
const perm = axisPermutation(part.transform.rotation) ??
|
|
1362
|
+
(scalingCommutesWithRotation(part.transform.rotation, (a, b) => Math.abs(box[a].factor - box[b].factor) <= 1e-9)
|
|
1363
|
+
? { x: 'x', y: 'y', z: 'z' }
|
|
1364
|
+
: null);
|
|
829
1365
|
const dims = [
|
|
830
1366
|
drawnSize.x * (perm ? box[perm.x].factor : 1) * chScale.x,
|
|
831
1367
|
drawnSize.y * (perm ? box[perm.y].factor : 1) * chScale.y,
|
|
@@ -859,6 +1395,9 @@ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
|
|
|
859
1395
|
R = mm3(W.r, rotation);
|
|
860
1396
|
M = mm3(W.r, motionRotation);
|
|
861
1397
|
}
|
|
1398
|
+
// V2 hides a part by scaling it to nothing. Nothing is drawn there, so it is not a drawn body.
|
|
1399
|
+
if (dims.some(d => d <= 0))
|
|
1400
|
+
continue;
|
|
862
1401
|
const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
|
|
863
1402
|
const box2 = { part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R };
|
|
864
1403
|
if (localPoints)
|
|
@@ -901,6 +1440,8 @@ export function repeatBoundaryScales(source) {
|
|
|
901
1440
|
return out;
|
|
902
1441
|
}
|
|
903
1442
|
/** ADR-0087 decision 2 tolerances. */
|
|
1443
|
+
/** The only slack a bound may have: what double arithmetic leaves behind, not a product tolerance. */
|
|
1444
|
+
const FLOAT_SLACK_MM = 1e-9;
|
|
904
1445
|
export const SAME_TOLERANCE_MM = 1;
|
|
905
1446
|
export const SAME_TOLERANCE_DEG = 0.1;
|
|
906
1447
|
/** The V3 state overrides that stand for a V2 motion state: parameters by name, drivers at their clip's time. */
|
|
@@ -911,8 +1452,7 @@ export function v3StateOverrides(asset, state) {
|
|
|
911
1452
|
if (units.has(name))
|
|
912
1453
|
out[name] = value;
|
|
913
1454
|
for (const d of asset.drivers ?? []) {
|
|
914
|
-
const
|
|
915
|
-
const t = state.clipTime?.[clip] ?? 0;
|
|
1455
|
+
const t = state.clipTime?.[d.clip] ?? 0;
|
|
916
1456
|
const unit = units.get(d.state);
|
|
917
1457
|
out[d.state] = driverValue(d, t, unit === 'deg' || unit === 'rad' ? unit : 'other');
|
|
918
1458
|
}
|
|
@@ -1035,12 +1575,13 @@ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m:
|
|
|
1035
1575
|
* What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
|
|
1036
1576
|
*/
|
|
1037
1577
|
function planMotion(c) {
|
|
1038
|
-
const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
|
|
1578
|
+
const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, scaleRangeOf, visibilityOf, jointOriginOf, lost, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
|
|
1579
|
+
let reauthored = false;
|
|
1039
1580
|
const params = src.parameters ?? [];
|
|
1040
1581
|
const clips = src.animations ?? [];
|
|
1041
1582
|
const joints = src.joints ?? [];
|
|
1042
1583
|
if (!params.length && !clips.length && !joints.length)
|
|
1043
|
-
return;
|
|
1584
|
+
return { reauthored };
|
|
1044
1585
|
const partsByName = new Map(src.parts.map(p => [p.name, p]));
|
|
1045
1586
|
const jointsByName = new Map(joints.map(j => [j.name, j]));
|
|
1046
1587
|
// State inputs for parameters.
|
|
@@ -1060,6 +1601,18 @@ function planMotion(c) {
|
|
|
1060
1601
|
g.input(p.name, unit, p.range.min, p.range.max, 'state');
|
|
1061
1602
|
asset.stateDefaults[p.name] = p.default ?? p.range.min;
|
|
1062
1603
|
paramUnit.set(p.name, unit);
|
|
1604
|
+
/*
|
|
1605
|
+
What a person sees for this input, and how fast the figure travels to a new value for it. Both are kept
|
|
1606
|
+
(V3 designer's ruling 2026-09-22): the label is display metadata beside the stable id, and the sweep is
|
|
1607
|
+
the asset's own statement about its movement — a gripper that closes over 0.6 s must not close at once.
|
|
1608
|
+
*/
|
|
1609
|
+
const policy = {};
|
|
1610
|
+
if (p.label)
|
|
1611
|
+
policy.label = p.label;
|
|
1612
|
+
if (p.clip?.duration !== undefined && Number.isFinite(p.clip.duration) && p.clip.duration >= 0)
|
|
1613
|
+
policy.sweep = p.clip.duration;
|
|
1614
|
+
if (Object.keys(policy).length)
|
|
1615
|
+
(asset.stateInputs ??= {})[p.name] = policy;
|
|
1063
1616
|
}
|
|
1064
1617
|
/** q = v0 + (p - min)·(v1 - v0)/(max - min), written in the output unit. Null when the channel does not move. */
|
|
1065
1618
|
const affine = (p, v0, v1, outUnit, hint) => {
|
|
@@ -1102,6 +1655,46 @@ function planMotion(c) {
|
|
|
1102
1655
|
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1103
1656
|
return g.add(`${hint}.f`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), 'ratio', `${hint}.slope`)), g.constant(round6(s0), 'ratio', `${hint}.s0`));
|
|
1104
1657
|
};
|
|
1658
|
+
/**
|
|
1659
|
+
* A dimensionless factor through every key of a linear channel: f = curve(u, at₀,s₀, at₁,s₁, …) with u = (p − min)/span
|
|
1660
|
+
* as a ratio in [0, 1], the keys' `at` in the same ratio (piecewise-linear curves, V3 designer's approval
|
|
1661
|
+
* 2026-09-22). Two keys at 0 and 1 take the affine form instead, so earlier assets keep their graphs.
|
|
1662
|
+
*/
|
|
1663
|
+
/** The parameter mapped onto 0..1 as a ratio, the axis every key's `at` is measured on. */
|
|
1664
|
+
const unitPosition = (p, hint) => {
|
|
1665
|
+
const pUnit = paramUnit.get(p.name);
|
|
1666
|
+
if (!pUnit)
|
|
1667
|
+
return undefined;
|
|
1668
|
+
if (pUnit !== 'percent' && pUnit !== 'ratio') {
|
|
1669
|
+
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale or hide a part; a percent or ratio parameter can` });
|
|
1670
|
+
return undefined;
|
|
1671
|
+
}
|
|
1672
|
+
const span = p.range.max - p.range.min;
|
|
1673
|
+
const slope = pUnit === 'percent' ? 100 / span : 1 / span;
|
|
1674
|
+
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1675
|
+
return g.mul(`${hint}.u01`, u, g.constant(round6(slope), 'ratio', `${hint}.perSpan`));
|
|
1676
|
+
};
|
|
1677
|
+
const curveRatio = (p, keys, hint) => {
|
|
1678
|
+
const u01 = unitPosition(p, hint);
|
|
1679
|
+
if (u01 === undefined)
|
|
1680
|
+
return undefined;
|
|
1681
|
+
const args = [u01];
|
|
1682
|
+
keys.forEach((k, i) => args.push(g.constant(round6(k.at), 'ratio', `${hint}.at${i}`), g.constant(round6(k.value), 'ratio', `${hint}.s${i}`)));
|
|
1683
|
+
return g.node(`${hint}.curve`, 'curve@1', args, 'value');
|
|
1684
|
+
};
|
|
1685
|
+
/** A linear channel with keys in order inside [0, 1]; anything else is refused by name. */
|
|
1686
|
+
const linearKeys = (ch, owner) => {
|
|
1687
|
+
const keys = ch.keys;
|
|
1688
|
+
if ((ch.interpolation ?? 'linear') !== 'linear') {
|
|
1689
|
+
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: ${ch.interpolation} interpolation is not written into the graph; linear keys are` });
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
if (keys.length < 2 || keys.some((k, i) => i > 0 && !(k.at > keys[i - 1].at)) || keys[0].at < 0 || keys[keys.length - 1].at > 1) {
|
|
1693
|
+
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: keys must be two or more, strictly increasing, inside [0, 1]` });
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
return true;
|
|
1697
|
+
};
|
|
1105
1698
|
const twoKeyLinear = (ch, owner) => {
|
|
1106
1699
|
const keys = ch.keys;
|
|
1107
1700
|
if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
|
|
@@ -1163,12 +1756,58 @@ function planMotion(c) {
|
|
|
1163
1756
|
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `parameter ${p.name} has a scale channel whose keys do not change; nothing to write` });
|
|
1164
1757
|
continue;
|
|
1165
1758
|
}
|
|
1166
|
-
if (!
|
|
1759
|
+
if (!linearKeys(ch, p.name))
|
|
1167
1760
|
continue;
|
|
1168
1761
|
if (!axisPermutation(partsByName.get(ch.target).transform.rotation)) {
|
|
1169
1762
|
refusals.push({ code: 'ROTATED_FOLLOWS', part: ch.target, detail: 'a scale channel on a part rotated off the axes would shear it; not covered' });
|
|
1170
1763
|
continue;
|
|
1171
1764
|
}
|
|
1765
|
+
/*
|
|
1766
|
+
A scale of 0 is how V2 hides a part: the lamp glow is scaled to nothing until the gate opens. A V3
|
|
1767
|
+
dimension stays positive (designer's ruling 2026-09-22), so this is not a size at all — it is
|
|
1768
|
+
visibility, and it becomes a flag. Covered shape: the channel starts at 0 on every varying axis and,
|
|
1769
|
+
from its first non-zero key on, sits at exactly 1 (the part's declared size) on all of them. The part
|
|
1770
|
+
then keeps its own dimensions and is shown from that key onwards.
|
|
1771
|
+
|
|
1772
|
+
Anything else — a channel that hides and also resizes — is refused by name. Writing it as a floored
|
|
1773
|
+
size would make "not visible" mean "very small", which is the thing that was refused.
|
|
1774
|
+
*/
|
|
1775
|
+
const hidesAt = keys.findIndex(k => axes.every(a => k.value[a] > 0));
|
|
1776
|
+
if (keys.some(k => axes.some(a => k.value[a] <= 0))) {
|
|
1777
|
+
const shaped = hidesAt > 0 &&
|
|
1778
|
+
keys.slice(0, hidesAt).every(k => axes.every(a => k.value[a] === 0)) &&
|
|
1779
|
+
keys.slice(hidesAt).every(k => axes.every(a => k.value[a] === 1));
|
|
1780
|
+
if (!shaped) {
|
|
1781
|
+
refusals.push({ code: 'SCALE_ZERO', part: ch.target, detail: `parameter ${p.name} scales ${ch.target} to zero and to sizes other than its own; hiding and resizing in one channel is not covered` });
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
if (visibilityOf.has(ch.target)) {
|
|
1785
|
+
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: `two channels decide whether ${ch.target} is shown` });
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
let claimed = true;
|
|
1789
|
+
for (const axis of axes)
|
|
1790
|
+
if (!claim(`${ch.target}/scale/${axis}`, p.name, ch.target)) {
|
|
1791
|
+
claimed = false;
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
if (!claimed)
|
|
1795
|
+
continue;
|
|
1796
|
+
const hint = `${p.name}.${ch.target}.shown`;
|
|
1797
|
+
const u01 = unitPosition(p, hint);
|
|
1798
|
+
if (u01 === undefined)
|
|
1799
|
+
continue;
|
|
1800
|
+
const at = keys[hidesAt].at;
|
|
1801
|
+
visibilityOf.set(ch.target, g.node(`${hint}.flag`, 'at-least@1', [u01, g.constant(round6(at), 'ratio', `${hint}.at`)], 'value'));
|
|
1802
|
+
reauthored = true;
|
|
1803
|
+
notes.push({
|
|
1804
|
+
code: 'RE_AUTHORED_VISIBILITY',
|
|
1805
|
+
part: ch.target,
|
|
1806
|
+
detail: `V2 hid ${ch.target} by scaling it to 0 and grew it to full size between ${keys[hidesAt - 1].at} and ${at} of parameter ${p.name}. V3 gives the part its own dimensions at all times and a visibility flag that turns on at ${at}. Identical to V2 below ${keys[hidesAt - 1].at} (hidden) and at ${at} and above (full size); inside that band V2 drew a growing part and V3 draws none. The growth was a way of spelling on and off, so it is re-authored, not floored to a small size`
|
|
1807
|
+
});
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
const twoKeys = keys.length === 2 && keys[0].at === 0 && keys[1].at === 1;
|
|
1172
1811
|
const entry = scaleOf.get(ch.target) ?? {};
|
|
1173
1812
|
let ok = true;
|
|
1174
1813
|
for (const axis of axes) {
|
|
@@ -1176,7 +1815,10 @@ function planMotion(c) {
|
|
|
1176
1815
|
ok = false;
|
|
1177
1816
|
break;
|
|
1178
1817
|
}
|
|
1179
|
-
const
|
|
1818
|
+
const hint = `${p.name}.${ch.target}.scale.${axis}`;
|
|
1819
|
+
const f = twoKeys
|
|
1820
|
+
? affineRatio(p, keys[0].value[axis], keys[1].value[axis], hint)
|
|
1821
|
+
: curveRatio(p, keys.map(k => ({ at: k.at, value: k.value[axis] })), hint);
|
|
1180
1822
|
if (f === undefined) {
|
|
1181
1823
|
ok = false;
|
|
1182
1824
|
break;
|
|
@@ -1186,7 +1828,14 @@ function planMotion(c) {
|
|
|
1186
1828
|
if (!ok)
|
|
1187
1829
|
continue;
|
|
1188
1830
|
scaleOf.set(ch.target, entry);
|
|
1189
|
-
|
|
1831
|
+
const ranges = scaleRangeOf.get(ch.target) ?? {};
|
|
1832
|
+
for (const axis of axes) {
|
|
1833
|
+
const vs = keys.map(k => k.value[axis]);
|
|
1834
|
+
ranges[axis] = { min: Math.min(...vs), max: Math.max(...vs) };
|
|
1835
|
+
}
|
|
1836
|
+
scaleRangeOf.set(ch.target, ranges);
|
|
1837
|
+
const shape = twoKeys ? `${keys[0].value[axes[0]]} → ${keys[1].value[axes[0]]} on ${axes[0]}` : `${keys.length} keys on ${axes[0]}: ${keys.map(k => `${k.at}:${k.value[axes[0]]}`).join(' ')}`;
|
|
1838
|
+
notes.push({ code: 'STATE_DIMENSION', part: ch.target, detail: `dimensions on ${axes.join(', ')} follow parameter ${p.name} (${shape}); the occupancy must hold over its range` });
|
|
1190
1839
|
continue;
|
|
1191
1840
|
}
|
|
1192
1841
|
if (axes.length === 0) {
|
|
@@ -1218,7 +1867,8 @@ function planMotion(c) {
|
|
|
1218
1867
|
// V2 shifts a part in the figure frame by the part's world factor along that axis.
|
|
1219
1868
|
const f = info.get(ch.target).factor[axis];
|
|
1220
1869
|
const scaled = f.kind === 'one' ? q : g.mul(`${p.name}.${ch.target}.shift.${axis}`, q, f.ref);
|
|
1221
|
-
|
|
1870
|
+
const ends = [keys[0].value[axis], keys[1].value[axis]];
|
|
1871
|
+
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled, travel: { min: Math.min(...ends), max: Math.max(...ends) } });
|
|
1222
1872
|
}
|
|
1223
1873
|
}
|
|
1224
1874
|
}
|
|
@@ -1271,6 +1921,7 @@ function planMotion(c) {
|
|
|
1271
1921
|
asset.stateDefaults[stateId] = rotation ? normalise180(values[0]) : values[0];
|
|
1272
1922
|
drivers.push({
|
|
1273
1923
|
id: `${clip.name}/${ch.target}/${ch.path}`,
|
|
1924
|
+
clip: clip.name,
|
|
1274
1925
|
state: stateId,
|
|
1275
1926
|
time: { unit: 's', duration },
|
|
1276
1927
|
keys: keys.map(k => ({ at: round6(k.at / duration), value: k.value[axis] })),
|
|
@@ -1289,7 +1940,7 @@ function planMotion(c) {
|
|
|
1289
1940
|
else {
|
|
1290
1941
|
const f = info.get(ch.target).factor[axis];
|
|
1291
1942
|
const scaled = f.kind === 'one' ? stateId : g.mul(`${clip.name}.${ch.target}.shift.${axis}`, stateId, f.ref);
|
|
1292
|
-
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
|
|
1943
|
+
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled, travel: { min, max } });
|
|
1293
1944
|
}
|
|
1294
1945
|
}
|
|
1295
1946
|
}
|
|
@@ -1370,6 +2021,11 @@ function planMotion(c) {
|
|
|
1370
2021
|
const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
|
|
1371
2022
|
const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
|
|
1372
2023
|
const parentJoint = attach ? carrierOf(attach.name) : undefined;
|
|
2024
|
+
jointOriginOf.set(j.name, {
|
|
2025
|
+
origin: [...originRef],
|
|
2026
|
+
...(parentJoint ? { above: parentJoint.name } : {}),
|
|
2027
|
+
slide: j.type === 'prismatic' ? Math.max(Math.abs(limits?.min ?? 0), Math.abs(limits?.max ?? 0)) : 0
|
|
2028
|
+
});
|
|
1373
2029
|
const restFrame = 'asset.rest';
|
|
1374
2030
|
const toFrame = parentJoint ? restFrame : 'asset';
|
|
1375
2031
|
const A = g.node(`${j.name}.frameOnParent`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onParent, to: toFrame });
|
|
@@ -1380,6 +2036,7 @@ function planMotion(c) {
|
|
|
1380
2036
|
M = g.node(`${j.name}.chain`, 'compose@1', [motionOf.get(parentJoint.name), M], 'pose');
|
|
1381
2037
|
motionOf.set(j.name, M);
|
|
1382
2038
|
}
|
|
2039
|
+
return { reauthored };
|
|
1383
2040
|
}
|
|
1384
2041
|
function normalise180(deg) {
|
|
1385
2042
|
const w = ((((deg + 180) % 360) + 360) % 360) - 180;
|