@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.
Files changed (54) hide show
  1. package/dist/index.d.ts +4 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/v3-asset-types.d.ts +89 -16
  6. package/dist/v3-asset-types.d.ts.map +1 -1
  7. package/dist/v3-asset.d.ts.map +1 -1
  8. package/dist/v3-asset.js +34 -25
  9. package/dist/v3-asset.js.map +1 -1
  10. package/dist/v3-authoring-actions.d.ts +97 -0
  11. package/dist/v3-authoring-actions.d.ts.map +1 -0
  12. package/dist/v3-authoring-actions.js +384 -0
  13. package/dist/v3-authoring-actions.js.map +1 -0
  14. package/dist/v3-capabilities.d.ts.map +1 -1
  15. package/dist/v3-capabilities.js +4 -77
  16. package/dist/v3-capabilities.js.map +1 -1
  17. package/dist/v3-driver.d.ts +24 -1
  18. package/dist/v3-driver.d.ts.map +1 -1
  19. package/dist/v3-driver.js +68 -2
  20. package/dist/v3-driver.js.map +1 -1
  21. package/dist/v3-from-v2.d.ts +34 -1
  22. package/dist/v3-from-v2.d.ts.map +1 -1
  23. package/dist/v3-from-v2.js +676 -19
  24. package/dist/v3-from-v2.js.map +1 -1
  25. package/dist/v3-gate.d.ts +59 -6
  26. package/dist/v3-gate.d.ts.map +1 -1
  27. package/dist/v3-gate.js +310 -45
  28. package/dist/v3-gate.js.map +1 -1
  29. package/dist/v3-graph-types.d.ts +9 -1
  30. package/dist/v3-graph-types.d.ts.map +1 -1
  31. package/dist/v3-graph.d.ts +5 -3
  32. package/dist/v3-graph.d.ts.map +1 -1
  33. package/dist/v3-graph.js +93 -4
  34. package/dist/v3-graph.js.map +1 -1
  35. package/dist/v3-kernel-version.d.ts +1 -1
  36. package/dist/v3-kernel-version.js +1 -1
  37. package/dist/v3-mesh-compare.d.ts +2 -5
  38. package/dist/v3-mesh-compare.d.ts.map +1 -1
  39. package/dist/v3-mesh-compare.js +2 -145
  40. package/dist/v3-mesh-compare.js.map +1 -1
  41. package/dist/v3-shape-sampling.d.ts +14 -0
  42. package/dist/v3-shape-sampling.d.ts.map +1 -0
  43. package/dist/v3-shape-sampling.js +120 -0
  44. package/dist/v3-shape-sampling.js.map +1 -0
  45. package/dist/v3-surface.d.ts +15 -0
  46. package/dist/v3-surface.d.ts.map +1 -0
  47. package/dist/v3-surface.js +104 -0
  48. package/dist/v3-surface.js.map +1 -0
  49. package/docs/prototypes/v3-asset.schema.json +0 -8
  50. package/docs/v3-asset-persistence.md +1 -1
  51. package/docs/v3-legacy-risk-audit-2026-09-23.md +39 -0
  52. package/docs/v3-operator-contracts.md +2 -0
  53. package/docs/v3-shape-dimension-contract.md +111 -0
  54. package/package.json +1 -1
@@ -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 occupancy; V2 read a missing placement as floor.
214
- occupancy: { extent: { ...V3_SIZE_INPUTS }, placement: src.placement ?? 'floor' }
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
- const perm = axisPermutation(cp.transform.rotation);
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
- refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
463
- continue;
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
- const dim = (local) => withChannel(times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE), local, `dim.${local}`);
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
- planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
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
- asset.appearance.push(plan.appearance);
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: 'mechanical', asset, lost, rangeChanges, notes };
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
- const perm = axisPermutation(part.transform.rotation);
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 clip = d.id.split('/')[0];
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 (!twoKeyLinear(ch, p.name))
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 f = affineRatio(p, keys[0].value[axis], keys[1].value[axis], `${p.name}.${ch.target}.scale.${axis}`);
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
- notes.push({ code: 'STATE_DIMENSION', part: ch.target, detail: `dimensions on ${axes.join(', ')} follow parameter ${p.name} (${keys[0].value[axes[0]]} → ${keys[1].value[axes[0]]} on ${axes[0]}); the occupancy must hold over its range` });
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
- (shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
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;