@hatiolab/figure-model 0.1.36 → 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 (53) 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 +25 -1
  22. package/dist/v3-from-v2.d.ts.map +1 -1
  23. package/dist/v3-from-v2.js +598 -21
  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 +291 -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.map +1 -1
  32. package/dist/v3-graph.js +53 -1
  33. package/dist/v3-graph.js.map +1 -1
  34. package/dist/v3-kernel-version.d.ts +1 -1
  35. package/dist/v3-kernel-version.js +1 -1
  36. package/dist/v3-mesh-compare.d.ts +2 -5
  37. package/dist/v3-mesh-compare.d.ts.map +1 -1
  38. package/dist/v3-mesh-compare.js +2 -145
  39. package/dist/v3-mesh-compare.js.map +1 -1
  40. package/dist/v3-shape-sampling.d.ts +14 -0
  41. package/dist/v3-shape-sampling.d.ts.map +1 -0
  42. package/dist/v3-shape-sampling.js +120 -0
  43. package/dist/v3-shape-sampling.js.map +1 -0
  44. package/dist/v3-surface.d.ts +15 -0
  45. package/dist/v3-surface.d.ts.map +1 -0
  46. package/dist/v3-surface.js +104 -0
  47. package/dist/v3-surface.js.map +1 -0
  48. package/docs/prototypes/v3-asset.schema.json +0 -8
  49. package/docs/v3-asset-persistence.md +1 -1
  50. package/docs/v3-legacy-risk-audit-2026-09-23.md +39 -0
  51. package/docs/v3-operator-contracts.md +1 -0
  52. package/docs/v3-shape-dimension-contract.md +111 -0
  53. 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;
@@ -148,6 +150,31 @@ function nearestAxisPermutation(rotation) {
148
150
  }
149
151
  return out;
150
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;
151
178
  /** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
152
179
  function axisPermutation(rotation) {
153
180
  if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
@@ -233,8 +260,9 @@ export function convertV2ToV3(source, options) {
233
260
  stateDefaults: {},
234
261
  palette: {},
235
262
  appearance: [],
236
- // ADR-0044 · ADR-0045: the V2 base box and placement become the declared occupancy; V2 read a missing placement as floor.
237
- 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' }
238
266
  };
239
267
  // Instance size inputs. The range starts at the pitch on a spreading repeat axis (V2 draws at least one
240
268
  // copy below that; `fit-pitch@1` draws none) and stops where V2 would silently clamp the copy count.
@@ -314,6 +342,14 @@ export function convertV2ToV3(source, options) {
314
342
  const shiftsOf = new Map();
315
343
  // A scale channel: per local axis, a ratio the part's dimensions are multiplied by (contract §2: dims × affine(p)).
316
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();
317
353
  const frameOf = new Map(); // part → the joint whose frame carries it
318
354
  const motionOf = new Map(); // joint → pose asset.rest → asset
319
355
  const partsByName = new Map(parts.map(cp => [cp.name, cp]));
@@ -481,6 +517,75 @@ export function convertV2ToV3(source, options) {
481
517
  // Local dimensions scale by the factor of the world axis each local axis lands on.
482
518
  let perm = axisPermutation(cp.transform.rotation);
483
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
+ }
571
+ if (!perm && follows) {
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
+ }
484
589
  if (!perm && follows) {
485
590
  const nearest = options.reauthor?.rotatedFollows === 'nearest-axis' ? nearestAxisPermutation(cp.transform.rotation) : null;
486
591
  if (!nearest) {
@@ -504,7 +609,12 @@ export function convertV2ToV3(source, options) {
504
609
  return v;
505
610
  return { kind: 'ref', ref: g.mul(`${name}.${hint}.scaled`, g.len(v, `${name}.${hint}`), sc) };
506
611
  };
507
- 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}`));
508
618
  const channelSplits = () => {
509
619
  const sc = scaleOf.get(name);
510
620
  return !!sc && (sc.x ?? null) !== (sc.z ?? null);
@@ -593,6 +703,64 @@ export function convertV2ToV3(source, options) {
593
703
  if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
594
704
  appearance.segments = segments;
595
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
+ });
596
764
  if (!repeat) {
597
765
  plans.push({
598
766
  appearance,
@@ -675,12 +843,35 @@ export function convertV2ToV3(source, options) {
675
843
  }
676
844
  });
677
845
  }
678
- 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';
679
854
  if (refusals.length)
680
855
  return { status: 'refused', refusals, lost, rangeChanges, notes };
681
856
  for (const plan of plans) {
682
857
  plan.nodes();
683
- 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 };
684
875
  }
685
876
  try {
686
877
  compileV3Asset(asset);
@@ -691,6 +882,307 @@ export function convertV2ToV3(source, options) {
691
882
  }
692
883
  return { status: 'converted', method, asset, lost, rangeChanges, notes };
693
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
+ }
1185
+ }
694
1186
  /** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
695
1187
  function withSizing(cp) {
696
1188
  return { ...cp, sizing: cp.sizing ?? 'scale' };
@@ -859,7 +1351,17 @@ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
859
1351
  : null;
860
1352
  // Local dimensions: each local axis lands on a world axis and takes that axis's factor, times any channel scale
861
1353
  // (V2 applies pose.scale in the part's own frame, about its centre).
862
- 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);
863
1365
  const dims = [
864
1366
  drawnSize.x * (perm ? box[perm.x].factor : 1) * chScale.x,
865
1367
  drawnSize.y * (perm ? box[perm.y].factor : 1) * chScale.y,
@@ -893,6 +1395,9 @@ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
893
1395
  R = mm3(W.r, rotation);
894
1396
  M = mm3(W.r, motionRotation);
895
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;
896
1401
  const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
897
1402
  const box2 = { part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R };
898
1403
  if (localPoints)
@@ -935,6 +1440,8 @@ export function repeatBoundaryScales(source) {
935
1440
  return out;
936
1441
  }
937
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;
938
1445
  export const SAME_TOLERANCE_MM = 1;
939
1446
  export const SAME_TOLERANCE_DEG = 0.1;
940
1447
  /** The V3 state overrides that stand for a V2 motion state: parameters by name, drivers at their clip's time. */
@@ -945,8 +1452,7 @@ export function v3StateOverrides(asset, state) {
945
1452
  if (units.has(name))
946
1453
  out[name] = value;
947
1454
  for (const d of asset.drivers ?? []) {
948
- const clip = d.id.split('/')[0];
949
- const t = state.clipTime?.[clip] ?? 0;
1455
+ const t = state.clipTime?.[d.clip] ?? 0;
950
1456
  const unit = units.get(d.state);
951
1457
  out[d.state] = driverValue(d, t, unit === 'deg' || unit === 'rad' ? unit : 'other');
952
1458
  }
@@ -1069,12 +1575,13 @@ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m:
1069
1575
  * What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
1070
1576
  */
1071
1577
  function planMotion(c) {
1072
- 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;
1073
1580
  const params = src.parameters ?? [];
1074
1581
  const clips = src.animations ?? [];
1075
1582
  const joints = src.joints ?? [];
1076
1583
  if (!params.length && !clips.length && !joints.length)
1077
- return;
1584
+ return { reauthored };
1078
1585
  const partsByName = new Map(src.parts.map(p => [p.name, p]));
1079
1586
  const jointsByName = new Map(joints.map(j => [j.name, j]));
1080
1587
  // State inputs for parameters.
@@ -1094,6 +1601,18 @@ function planMotion(c) {
1094
1601
  g.input(p.name, unit, p.range.min, p.range.max, 'state');
1095
1602
  asset.stateDefaults[p.name] = p.default ?? p.range.min;
1096
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;
1097
1616
  }
1098
1617
  /** q = v0 + (p - min)·(v1 - v0)/(max - min), written in the output unit. Null when the channel does not move. */
1099
1618
  const affine = (p, v0, v1, outUnit, hint) => {
@@ -1141,18 +1660,24 @@ function planMotion(c) {
1141
1660
  * as a ratio in [0, 1], the keys' `at` in the same ratio (piecewise-linear curves, V3 designer's approval
1142
1661
  * 2026-09-22). Two keys at 0 and 1 take the affine form instead, so earlier assets keep their graphs.
1143
1662
  */
1144
- const curveRatio = (p, keys, hint) => {
1663
+ /** The parameter mapped onto 0..1 as a ratio, the axis every key's `at` is measured on. */
1664
+ const unitPosition = (p, hint) => {
1145
1665
  const pUnit = paramUnit.get(p.name);
1146
1666
  if (!pUnit)
1147
1667
  return undefined;
1148
- const span = p.range.max - p.range.min;
1149
1668
  if (pUnit !== 'percent' && pUnit !== 'ratio') {
1150
- refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale a dimension; a percent or ratio parameter can` });
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` });
1151
1670
  return undefined;
1152
1671
  }
1672
+ const span = p.range.max - p.range.min;
1153
1673
  const slope = pUnit === 'percent' ? 100 / span : 1 / span;
1154
1674
  const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
1155
- const u01 = g.mul(`${hint}.u01`, u, g.constant(round6(slope), 'ratio', `${hint}.perSpan`));
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;
1156
1681
  const args = [u01];
1157
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}`)));
1158
1683
  return g.node(`${hint}.curve`, 'curve@1', args, 'value');
@@ -1237,11 +1762,49 @@ function planMotion(c) {
1237
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' });
1238
1763
  continue;
1239
1764
  }
1240
- // A scale of 0 is how V2 hides a part (the lamp glow before it lights). A V3 dimension must be positive, so
1241
- // this is refused until the designer rules on the rule for it (a floor as for span, or a visibility channel).
1242
- const zero = axes.find(axis => keys.some(k => k.value[axis] <= 0));
1243
- if (zero) {
1244
- refusals.push({ code: 'SCALE_ZERO', part: ch.target, detail: `parameter ${p.name} scales ${zero} to ${Math.min(...keys.map(k => k.value[zero]))}; V2 draws nothing there, a V3 dimension must be positive` });
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
+ });
1245
1808
  continue;
1246
1809
  }
1247
1810
  const twoKeys = keys.length === 2 && keys[0].at === 0 && keys[1].at === 1;
@@ -1265,6 +1828,12 @@ function planMotion(c) {
1265
1828
  if (!ok)
1266
1829
  continue;
1267
1830
  scaleOf.set(ch.target, entry);
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);
1268
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(' ')}`;
1269
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` });
1270
1839
  continue;
@@ -1298,7 +1867,8 @@ function planMotion(c) {
1298
1867
  // V2 shifts a part in the figure frame by the part's world factor along that axis.
1299
1868
  const f = info.get(ch.target).factor[axis];
1300
1869
  const scaled = f.kind === 'one' ? q : g.mul(`${p.name}.${ch.target}.shift.${axis}`, q, f.ref);
1301
- (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) } });
1302
1872
  }
1303
1873
  }
1304
1874
  }
@@ -1351,6 +1921,7 @@ function planMotion(c) {
1351
1921
  asset.stateDefaults[stateId] = rotation ? normalise180(values[0]) : values[0];
1352
1922
  drivers.push({
1353
1923
  id: `${clip.name}/${ch.target}/${ch.path}`,
1924
+ clip: clip.name,
1354
1925
  state: stateId,
1355
1926
  time: { unit: 's', duration },
1356
1927
  keys: keys.map(k => ({ at: round6(k.at / duration), value: k.value[axis] })),
@@ -1369,7 +1940,7 @@ function planMotion(c) {
1369
1940
  else {
1370
1941
  const f = info.get(ch.target).factor[axis];
1371
1942
  const scaled = f.kind === 'one' ? stateId : g.mul(`${clip.name}.${ch.target}.shift.${axis}`, stateId, f.ref);
1372
- (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 } });
1373
1944
  }
1374
1945
  }
1375
1946
  }
@@ -1450,6 +2021,11 @@ function planMotion(c) {
1450
2021
  const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
1451
2022
  const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
1452
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
+ });
1453
2029
  const restFrame = 'asset.rest';
1454
2030
  const toFrame = parentJoint ? restFrame : 'asset';
1455
2031
  const A = g.node(`${j.name}.frameOnParent`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onParent, to: toFrame });
@@ -1460,6 +2036,7 @@ function planMotion(c) {
1460
2036
  M = g.node(`${j.name}.chain`, 'compose@1', [motionOf.get(parentJoint.name), M], 'pose');
1461
2037
  motionOf.set(j.name, M);
1462
2038
  }
2039
+ return { reauthored };
1463
2040
  }
1464
2041
  function normalise180(deg) {
1465
2042
  const w = ((((deg + 180) % 360) + 360) % 360) - 180;