@hatiolab/figure-model 0.1.34 → 0.1.36

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.
@@ -14,6 +14,7 @@
14
14
  * - a hollow polygon (V3 has hollow boxes only), a polygon whose path does not fill its declared size
15
15
  * - a part rotated off the axes whose size still follows the instance (V2 shears it; V3 placement is rigid,
16
16
  * ruling 1: those parts are re-authored, not converted)
17
+ * - a scale channel (a state input moving a dimension), until the shape-dimension contract §2 is implemented
17
18
  * - joints and parameters, until `docs/v3-motion-contract.md` is implemented
18
19
  *
19
20
  * What it normalises on purpose it lists in `notes` with the value before and after: a corner radius V2 clamped
@@ -33,6 +34,7 @@ import { rotatedExtentOf } from "./blueprint.js";
33
34
  import { anchorOf, clearance, longAxis, repeatOffset, repeatPlan, sizingPosition, sizingScale } from "./sizing.js";
34
35
  import { AXES, REPEAT_LIMIT, SEGMENT_PRESETS } from "./types.js";
35
36
  import { driverValue } from "./v3-driver.js";
37
+ import { hausdorff, scalePoints, transformPoints, v2BodyPoints } from "./v3-mesh-compare.js";
36
38
  import { jointOriginPosition } from "./sizing.js";
37
39
  /** A short stable fingerprint of the palette, so a report can say which colours it snapshotted. */
38
40
  export function paletteHash(palette) {
@@ -46,6 +48,11 @@ export function paletteHash(palette) {
46
48
  export const V3_SIZE_INPUTS = Object.freeze({ x: 'size.x', y: 'size.y', z: 'size.z' });
47
49
  /** How far a V2 instance may grow in the converted asset, as a multiple of the base box. */
48
50
  const SIZE_RANGE = 10;
51
+ /**
52
+ * The step above a spanned part's room at which its evaluated length is still positive in double precision at the
53
+ * sizes figures have (mm, up to 10⁵). A numerical stability bound, not a minimum part size (designer's ruling 2).
54
+ */
55
+ const SPAN_POSITIVE_STEP = 1e-6;
49
56
  const ONE = { kind: 'one' };
50
57
  const SEG_DEFAULT = 12;
51
58
  const RIGHT_ANGLE_TOLERANCE = 1e-9;
@@ -119,6 +126,28 @@ export function eulerXYZ(rotation) {
119
126
  [-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]
120
127
  ];
121
128
  }
129
+ /**
130
+ * Which world axis each local axis lies nearest to, for any rotation; null when two local axes claim one world axis
131
+ * (a 45° turn). Used only when re-authoring a rotated part without shear.
132
+ */
133
+ function nearestAxisPermutation(rotation) {
134
+ const r = eulerXYZ(rotation);
135
+ const out = {};
136
+ const taken = new Set();
137
+ for (const [i, local] of AXES.entries()) {
138
+ let best = 'x', size = -1;
139
+ for (const [a, world] of AXES.entries()) {
140
+ const v = Math.abs(r[a][i]);
141
+ if (v > size)
142
+ (size = v), (best = world);
143
+ }
144
+ if (taken.has(best))
145
+ return null;
146
+ taken.add(best);
147
+ out[local] = best;
148
+ }
149
+ return out;
150
+ }
122
151
  /** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
123
152
  function axisPermutation(rotation) {
124
153
  if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
@@ -165,12 +194,15 @@ export function convertV2ToV3(source, options) {
165
194
  const lost = [];
166
195
  const refusals = [];
167
196
  const notes = [];
197
+ const rangeChanges = [];
198
+ let method = 'mechanical';
168
199
  const checked = validate(source);
169
200
  if (checked.errors.length) {
170
201
  return {
171
202
  status: 'refused',
172
203
  refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
173
204
  lost,
205
+ rangeChanges,
174
206
  notes
175
207
  };
176
208
  }
@@ -219,13 +251,39 @@ export function convertV2ToV3(source, options) {
219
251
  const cap = REPEAT_LIMIT * cp.repeat.pitch;
220
252
  if (sizeMax[axis] > cap) {
221
253
  sizeMax[axis] = cap;
222
- lost.push({
223
- code: 'REPEAT_LIMIT',
254
+ rangeChanges.push({
255
+ input: V3_SIZE_INPUTS[axis],
224
256
  part: cp.name,
225
- detail: `V2 clamps the copy count at ${REPEAT_LIMIT}; the V3 input ${V3_SIZE_INPUTS[axis]} stops at ${cap} mm instead`
257
+ v2: `any size; above ${cap} mm V2 stopped adding copies at ${REPEAT_LIMIT} and drew the rest as a shorter row`,
258
+ v3: { min: sizeMin[axis], max: cap },
259
+ reason: `V3 refuses a size whose repeat count would exceed ${REPEAT_LIMIT} (fit-pitch@1 RESOURCE_LIMIT) instead of clamping the count`
226
260
  });
227
261
  }
228
262
  }
263
+ // A spanned part fills the room between its two gaps. Below that room V2 draws a 0.001 mm sliver (`sizing.ts`
264
+ // SLIVER); V3 refuses a non-positive dimension. The condition is length > 0, nothing more: the size input's
265
+ // minimum is the room itself plus the smallest step that keeps the evaluated length positive in double precision.
266
+ // Shrinking the accepted range is a behaviour change and is recorded as one (designer's ruling 2).
267
+ for (const cp of parts) {
268
+ const subject = withSizing(cp);
269
+ for (const a of AXES) {
270
+ if (anchorOf(subject, base, a) !== 'span')
271
+ continue;
272
+ const gaps = clearance(subject, base, a);
273
+ const room = gaps.min + gaps.max;
274
+ const floor = room + SPAN_POSITIVE_STEP;
275
+ if (floor > sizeMin[a]) {
276
+ sizeMin[a] = floor;
277
+ rangeChanges.push({
278
+ input: V3_SIZE_INPUTS[a],
279
+ part: cp.name,
280
+ v2: `any size; at or below ${round6(room)} mm the spanned part was drawn as a 0.001 mm sliver`,
281
+ v3: { min: floor, max: sizeMax[a] },
282
+ reason: `the spanned part must keep a positive length (${SPAN_POSITIVE_STEP} mm is the numerical step above zero, not a minimum part size)`
283
+ });
284
+ }
285
+ }
286
+ }
229
287
  const size = {};
230
288
  for (const a of AXES) {
231
289
  size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
@@ -254,6 +312,8 @@ export function convertV2ToV3(source, options) {
254
312
  const info = new Map();
255
313
  const spinOf = new Map();
256
314
  const shiftsOf = new Map();
315
+ // A scale channel: per local axis, a ratio the part's dimensions are multiplied by (contract §2: dims × affine(p)).
316
+ const scaleOf = new Map();
257
317
  const frameOf = new Map(); // part → the joint whose frame carries it
258
318
  const motionOf = new Map(); // joint → pose asset.rest → asset
259
319
  const partsByName = new Map(parts.map(cp => [cp.name, cp]));
@@ -289,7 +349,7 @@ export function convertV2ToV3(source, options) {
289
349
  if (cp.materialSlot)
290
350
  lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
291
351
  if (cp.capability)
292
- lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'slot/port role is not carried as a V3 binding' });
352
+ lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'a capability anchor: V2 never draws it; its slot/port role is not carried as a V3 binding yet, so it is placed nowhere' });
293
353
  const { material, refusal } = materialOf(cp, palette);
294
354
  if (refusal) {
295
355
  refusals.push(refusal);
@@ -334,8 +394,6 @@ export function convertV2ToV3(source, options) {
334
394
  if (cp.shape?.round)
335
395
  lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
336
396
  }
337
- // V2 draws a cylinder with size.x as the top diameter and size.z as the bottom one; unequal sizes are a frustum.
338
- const frustum = cp.primitive === 'cylinder' && Math.abs(cp.transform.size.x - cp.transform.size.z) > 1e-9;
339
397
  const segments = cp.segments ?? SEG_DEFAULT;
340
398
  if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
341
399
  refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
@@ -379,6 +437,13 @@ export function convertV2ToV3(source, options) {
379
437
  pending.push(a); // aspect-*: follows another axis, settled below
380
438
  }
381
439
  }
440
+ // A capability anchor is a place, not a body: V2 compiles it apart from the drawn groups and never renders it
441
+ // (the independent check against the V2 renderer found the converter drawing one). Its position is kept for
442
+ // joints that attach to it; nothing is placed.
443
+ if (cp.capability) {
444
+ info.set(name, { centre, factor, repeat: false });
445
+ continue;
446
+ }
382
447
  // A curved part keeps a round section: both cross axes take the geometric mean of their two factors
383
448
  // (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
384
449
  // it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
@@ -414,60 +479,78 @@ export function convertV2ToV3(source, options) {
414
479
  }
415
480
  }
416
481
  // Local dimensions scale by the factor of the world axis each local axis lands on.
417
- const perm = axisPermutation(cp.transform.rotation);
418
- // A cylinder that does not keep its section round goes elliptical under a lopsided instance in V2. V3 cylinders
419
- // and frustums have one radius per end (ruling 6: per-axis radii are a shape-definition change, not made here).
420
- if (cp.primitive === 'cylinder' && cp.keepRound === false && perm) {
421
- const spin = longAxis(subject);
422
- const across = AXES.filter(a => a !== spin).map(a => factor[a]);
423
- const same = across.every(f => f.kind === 'one') || across.every(f => f.kind === 'ref' && f.sig === across[0].sig);
424
- if (!same) {
425
- refusals.push({
426
- code: 'ELLIPTIC_SECTION',
427
- part: name,
428
- detail: `keepRound is off and the section follows ${AXES.filter(a => a !== spin).map(a => `${a}:${rules[a]}`).join(' and ')}; V2 draws an ellipse, V3 has one radius per end`
429
- });
430
- continue;
431
- }
432
- }
482
+ let perm = axisPermutation(cp.transform.rotation);
433
483
  const follows = AXES.some(a => factor[a].kind !== 'one');
434
484
  if (!perm && follows) {
435
- refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
436
- continue;
485
+ const nearest = options.reauthor?.rotatedFollows === 'nearest-axis' ? nearestAxisPermutation(cp.transform.rotation) : null;
486
+ if (!nearest) {
487
+ refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
488
+ continue;
489
+ }
490
+ perm = nearest;
491
+ method = 're-authored';
492
+ const rot = cp.transform.rotation ?? {};
493
+ notes.push({
494
+ code: 'RE_AUTHORED_ROTATED',
495
+ part: name,
496
+ 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`
497
+ });
437
498
  }
438
- const dim = (local) => times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE);
499
+ // A channel scale is planned after the parts and read when the shape node is written, so `dim` is called
500
+ // inside the shape closures only.
501
+ const withChannel = (v, local, hint) => {
502
+ const sc = scaleOf.get(name)?.[local];
503
+ if (!sc)
504
+ return v;
505
+ return { kind: 'ref', ref: g.mul(`${name}.${hint}.scaled`, g.len(v, `${name}.${hint}`), sc) };
506
+ };
507
+ const dim = (local) => withChannel(times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE), local, `dim.${local}`);
508
+ const channelSplits = () => {
509
+ const sc = scaleOf.get(name);
510
+ return !!sc && (sc.x ?? null) !== (sc.z ?? null);
511
+ };
439
512
  const localFrame = `${name}.local`;
440
513
  // A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
441
514
  const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
442
515
  const rot = cp.transform.rotation;
443
516
  const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
444
517
  let shape;
445
- if (cp.primitive === 'cylinder' && frustum) {
446
- // Both radii follow the same cross factor (keepRound made the two cross axes agree above).
447
- const across = perm ? factor[perm.x] : ONE;
448
- const top = times(`${name}.dim.radiusTop`, { kind: 'const', value: cp.transform.size.x / 2 }, across);
449
- const bottom = times(`${name}.dim.radiusBottom`, { kind: 'const', value: cp.transform.size.z / 2 }, across);
450
- const height = dim('y');
451
- shape = () => g.node(`${name}.shape`, 'frustum-shape@1', [g.len(top, `${name}.radiusTop`), g.len(bottom, `${name}.radiusBottom`), g.len(height, `${name}.height`)], 'shape', { frame: shapeFrame() });
452
- }
453
- else if (cp.primitive === 'cylinder') {
454
- const radius = times(`${name}.dim.radius`, { kind: 'const', value: cp.transform.size.x / 2 }, perm ? factor[perm.x] : ONE);
455
- const length = dim('y');
456
- shape = () => g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radius, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() });
518
+ if (cp.primitive === 'cylinder') {
519
+ // V2 builds every cylinder from a unit cylinder with equal end radii and scales it by size (things-scene
520
+ // geometry-bank): size.x ≠ size.z, or two cross factors that differ with keepRound off, is an elliptic
521
+ // cylinder, never a frustum. Equal radii by the same expression stay on cylinder-shape@1.
522
+ const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
523
+ const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
524
+ const equalSizes = Math.abs(cp.transform.size.x - cp.transform.size.z) <= 1e-9;
525
+ if (!(sameFactor && equalSizes))
526
+ notes.push({ code: 'ELLIPTIC_SECTION', part: name, detail: `section is not a circle (${!equalSizes ? `size.x ${cp.transform.size.x} ≠ size.z ${cp.transform.size.z}` : 'keepRound is off and the two cross axes follow the instance differently'}); written as cylinder-shape@2 with radiusX and radiusZ, as V2 drew it` });
527
+ shape = () => {
528
+ const radiusX = withChannel(times(`${name}.dim.radiusX`, { kind: 'const', value: cp.transform.size.x / 2 }, fx), 'x', 'radiusX');
529
+ const radiusZ = withChannel(times(`${name}.dim.radiusZ`, { kind: 'const', value: cp.transform.size.z / 2 }, fz), 'z', 'radiusZ');
530
+ const length = dim('y');
531
+ const round = sameFactor && equalSizes && !channelSplits();
532
+ return round
533
+ ? g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radiusX, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() })
534
+ : g.node(`${name}.shape`, 'cylinder-shape@2', [g.len(radiusX, `${name}.radiusX`), g.len(radiusZ, `${name}.radiusZ`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() });
535
+ };
457
536
  }
458
537
  else if (cp.primitive === 'sphere') {
459
- const r = (local) => times(`${name}.dim.radius${local.toUpperCase()}`, { kind: 'const', value: cp.transform.size[local] / 2 }, perm ? factor[perm[local]] : ONE);
460
- const [rx, ry, rz] = [r('x'), r('y'), r('z')];
461
- shape = () => g.node(`${name}.shape`, 'sphere-shape@1', [g.len(rx, `${name}.rx`), g.len(ry, `${name}.ry`), g.len(rz, `${name}.rz`)], 'shape', { frame: shapeFrame() });
538
+ const r = (local) => withChannel(times(`${name}.dim.radius${local.toUpperCase()}`, { kind: 'const', value: cp.transform.size[local] / 2 }, perm ? factor[perm[local]] : ONE), local, `radius${local.toUpperCase()}`);
539
+ shape = () => {
540
+ const [rx, ry, rz] = [r('x'), r('y'), r('z')];
541
+ return g.node(`${name}.shape`, 'sphere-shape@1', [g.len(rx, `${name}.rx`), g.len(ry, `${name}.ry`), g.len(rz, `${name}.rz`)], 'shape', { frame: shapeFrame() });
542
+ };
462
543
  }
463
544
  else if (cp.primitive === 'polygon') {
464
- const h = dim('y');
465
545
  const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
466
- const points = path.flatMap((q, i) => [
467
- times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx),
468
- times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz)
469
- ]);
470
- shape = () => g.node(`${name}.shape`, 'polygon-shape@1', [g.len(h, `${name}.h`), ...points.map((v, i) => g.len(v, `${name}.pt${i}`))], 'shape', { frame: shapeFrame() });
546
+ shape = () => {
547
+ const h = dim('y');
548
+ const points = path.flatMap((q, i) => [
549
+ withChannel(times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx), 'x', `p${i}.x`),
550
+ withChannel(times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz), 'z', `p${i}.z`)
551
+ ]);
552
+ return g.node(`${name}.shape`, 'polygon-shape@1', [g.len(h, `${name}.h`), ...points.map((v, i) => g.len(v, `${name}.pt${i}`))], 'shape', { frame: shapeFrame() });
553
+ };
471
554
  }
472
555
  else if (cp.primitive === 'rect') {
473
556
  // V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
@@ -475,23 +558,36 @@ export function convertV2ToV3(source, options) {
475
558
  const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
476
559
  if (round < declared)
477
560
  notes.push({ code: 'ROUND_CLAMPED', part: name, detail: `corner radius declared ${declared} mm, drawn ${round} mm (half of the ${cp.transform.size.x}×${cp.transform.size.z} section)` });
478
- if (round > 0 && (factor.x.kind !== 'one' || factor.z.kind !== 'one'))
479
- lost.push({ code: 'ROUND_NOT_SCALED', part: name, detail: `corner radius ${round} mm stays fixed while the section follows the instance; V2 scales it with the section` });
480
- const [w, h, d] = [dim('x'), dim('y'), dim('z')];
481
- if (hollow) {
482
- // V2 scales the finished mesh, so a wall's thickness follows the axis it lies across and the floor follows the height.
483
- const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
484
- const wallX = times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx);
485
- const wallZ = times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz);
486
- const floor = times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy);
487
- shape = () => g.node(`${name}.shape`, 'hollow-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.constant(round, 'mm', `${name}.round`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: shapeFrame() });
488
- }
489
- else
490
- shape = () => g.node(`${name}.shape`, 'rounded-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.constant(round, 'mm', `${name}.round`)], 'shape', { frame: shapeFrame() });
561
+ // V2 scales the finished mesh per world axis, so a corner drawn with radius r becomes an elliptic arc with
562
+ // semi-axes r·fx, r·fz (shape-dimension contract §1). When the two factors are one expression, @1 is enough.
563
+ const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
564
+ const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
565
+ if (round > 0 && !sameFactor)
566
+ notes.push({ code: 'CORNER_PER_AXIS', part: name, detail: `corner radius ${round} mm follows x and z differently under sizing; written with per-axis corner semi-axes, as V2 drew it` });
567
+ shape = () => {
568
+ const roundX = withChannel(times(`${name}.roundX`, { kind: 'const', value: round }, fx), 'x', 'roundX');
569
+ const roundZ = withChannel(times(`${name}.roundZ`, { kind: 'const', value: round }, fz), 'z', 'roundZ');
570
+ const perAxis = round > 0 && (!sameFactor || channelSplits());
571
+ const [w, h, d] = [dim('x'), dim('y'), dim('z')];
572
+ if (hollow) {
573
+ // A wall's thickness follows the axis it lies across and the floor follows the height.
574
+ const wallX = withChannel(times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx), 'x', 'wallX');
575
+ const wallZ = withChannel(times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz), 'z', 'wallZ');
576
+ const floor = withChannel(times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy), 'y', 'floor');
577
+ return perAxis
578
+ ? g.node(`${name}.shape`, 'hollow-box@2', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.roundX`), g.len(roundZ, `${name}.roundZ`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: shapeFrame() })
579
+ : g.node(`${name}.shape`, 'hollow-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.round`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: shapeFrame() });
580
+ }
581
+ return perAxis
582
+ ? g.node(`${name}.shape`, 'rounded-box@2', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.roundX`), g.len(roundZ, `${name}.roundZ`)], 'shape', { frame: shapeFrame() })
583
+ : g.node(`${name}.shape`, 'rounded-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.round`)], 'shape', { frame: shapeFrame() });
584
+ };
491
585
  }
492
586
  else {
493
- const [w, h, d] = [dim('x'), dim('y'), dim('z')];
494
- shape = () => g.node(`${name}.shape`, 'box-shape@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`)], 'shape', { frame: shapeFrame() });
587
+ shape = () => {
588
+ const [w, h, d] = [dim('x'), dim('y'), dim('z')];
589
+ return g.node(`${name}.shape`, 'box-shape@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`)], 'shape', { frame: shapeFrame() });
590
+ };
495
591
  }
496
592
  const appearance = { target: name, material: material };
497
593
  if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
@@ -579,9 +675,9 @@ export function convertV2ToV3(source, options) {
579
675
  }
580
676
  });
581
677
  }
582
- planMotion({ src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio, times, factorOfScale });
678
+ planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
583
679
  if (refusals.length)
584
- return { status: 'refused', refusals, lost, notes };
680
+ return { status: 'refused', refusals, lost, rangeChanges, notes };
585
681
  for (const plan of plans) {
586
682
  plan.nodes();
587
683
  asset.appearance.push(plan.appearance);
@@ -591,9 +687,9 @@ export function convertV2ToV3(source, options) {
591
687
  }
592
688
  catch (e) {
593
689
  const err = e;
594
- return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, notes };
690
+ return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, rangeChanges, notes };
595
691
  }
596
- return { status: 'converted', asset, lost, notes };
692
+ return { status: 'converted', method, asset, lost, rangeChanges, notes };
597
693
  }
598
694
  /** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
599
695
  function withSizing(cp) {
@@ -651,10 +747,11 @@ export function v2ClipDuration(clip) {
651
747
  * factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
652
748
  * everything in its frame about its sized origin, parents first (ADR-0066).
653
749
  */
654
- export function v2WorldBoxes(source, scale, state = {}) {
750
+ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
655
751
  const base = source.base;
656
752
  const out = [];
657
753
  const shifts = new Map();
754
+ const scales = new Map();
658
755
  const turns = new Map();
659
756
  const jointValues = new Map();
660
757
  const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
@@ -665,7 +762,11 @@ export function v2WorldBoxes(source, scale, state = {}) {
665
762
  continue;
666
763
  }
667
764
  const v = sampleV2(ch, at);
668
- if (ch.path === 'translation') {
765
+ if (ch.path === 'scale') {
766
+ const s0 = scales.get(ch.target) ?? { x: 1, y: 1, z: 1 };
767
+ scales.set(ch.target, { x: s0.x * v.x, y: s0.y * v.y, z: s0.z * v.z });
768
+ }
769
+ else if (ch.path === 'translation') {
669
770
  const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
670
771
  shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
671
772
  }
@@ -728,32 +829,41 @@ export function v2WorldBoxes(source, scale, state = {}) {
728
829
  return world;
729
830
  };
730
831
  for (const part of source.parts) {
832
+ // Capability anchors are not drawn by V2 (blueprint `anchors`, not `groups`).
833
+ if (part.capability)
834
+ continue;
731
835
  const cp = withSizing(centredPart(part, base));
732
836
  const at = sizingPosition(cp, scale, base);
733
837
  const sized = sizingScale(cp, scale, base);
734
- // V2 draws a two-diameter cylinder as wide as its larger end on both cross axes, while its sizing maths reads
735
- // `size` as written. The drawn body is what a converter must match.
736
838
  const drawnSize = { ...cp.transform.size };
737
- if (part.primitive === 'cylinder' && drawnSize.x !== drawnSize.z)
738
- drawnSize.x = drawnSize.z = Math.max(drawnSize.x, drawnSize.z);
739
- const extent = rotatedExtentOf({ transform: { ...cp.transform, size: drawnSize } });
839
+ const extent = rotatedExtentOf(cp);
740
840
  let rotation = eulerXYZ(part.transform.rotation);
741
841
  const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
742
842
  const box = { x: world('x'), y: world('y'), z: world('z') };
743
843
  box.y.centre += (base.y * scale.y) / 2;
744
844
  // A polygon is drawn where its path says, which may be off the part centre: rotation baked, then scaled per world axis.
845
+ let bodyPath = part.shape?.path;
745
846
  if (part.primitive === 'polygon' && part.shape?.path?.length) {
746
847
  const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
747
848
  const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
748
849
  for (const [a, axis] of AXES.entries())
749
850
  box[axis].centre += (rotation[a][0] * c[0] + rotation[a][2] * c[2]) * box[axis].factor;
851
+ // The sampled body is centred on its drawn centre, so the path is taken about that centre.
852
+ bodyPath = part.shape.path.map(q => ({ x: q.x - c[0], y: q.y - c[2] }));
750
853
  }
751
- // Local dimensions: each local axis lands on a world axis and takes that axis's factor.
854
+ // A scale channel multiplies the part's own dimensions about its centre (V2 pose.scale in the part frame).
855
+ const chScale = scales.get(part.name) ?? { x: 1, y: 1, z: 1 };
856
+ // Surface points: body in its own frame → channel scale → baked rotation → scaled per world axis → motion → centre.
857
+ const localPoints = options.points
858
+ ? scalePoints(transformPoints(scalePoints(v2BodyPoints({ primitive: part.primitive, size: drawnSize, segments: part.segments ?? SEG_DEFAULT, round: part.shape?.round, hollow: part.shape?.hollow, path: bodyPath }), chScale), rotation, [0, 0, 0]), { x: box.x.factor, y: box.y.factor, z: box.z.factor })
859
+ : null;
860
+ // Local dimensions: each local axis lands on a world axis and takes that axis's factor, times any channel scale
861
+ // (V2 applies pose.scale in the part's own frame, about its centre).
752
862
  const perm = axisPermutation(part.transform.rotation);
753
863
  const dims = [
754
- drawnSize.x * (perm ? box[perm.x].factor : 1),
755
- drawnSize.y * (perm ? box[perm.y].factor : 1),
756
- drawnSize.z * (perm ? box[perm.z].factor : 1)
864
+ drawnSize.x * (perm ? box[perm.x].factor : 1) * chScale.x,
865
+ drawnSize.y * (perm ? box[perm.y].factor : 1) * chScale.y,
866
+ drawnSize.z * (perm ? box[perm.z].factor : 1) * chScale.z
757
867
  ];
758
868
  // Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
759
869
  const shift = shifts.get(part.name);
@@ -761,8 +871,11 @@ export function v2WorldBoxes(source, scale, state = {}) {
761
871
  for (const a of AXES)
762
872
  box[a].centre += shift[a] * box[a].factor;
763
873
  const turn = turns.get(part.name);
764
- if (turn)
874
+ let motionRotation = I3;
875
+ if (turn) {
765
876
  rotation = mm3(turn, rotation);
877
+ motionRotation = turn;
878
+ }
766
879
  // Then the joint frame that carries it.
767
880
  const carrier = carrierOf(part.name);
768
881
  const W = carrier ? worldJoint(carrier) : undefined;
@@ -773,13 +886,18 @@ export function v2WorldBoxes(source, scale, state = {}) {
773
886
  if (plan)
774
887
  centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
775
888
  let R = rotation;
889
+ let M = motionRotation;
776
890
  if (W) {
777
891
  const c = mv3(W.r, [centre.x, centre.y, centre.z]);
778
892
  centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
779
893
  R = mm3(W.r, rotation);
894
+ M = mm3(W.r, motionRotation);
780
895
  }
781
896
  const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
782
- out.push({ part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R });
897
+ const box2 = { part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R };
898
+ if (localPoints)
899
+ box2.points = transformPoints(localPoints, M, [centre.x, centre.y, centre.z]);
900
+ out.push(box2);
783
901
  }
784
902
  }
785
903
  return out;
@@ -789,10 +907,32 @@ function composeRT(a, b) {
789
907
  return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
790
908
  }
791
909
  /** Where a converted V3 asset draws every part at an instance size, as world boxes. */
792
- export function v3WorldBoxes(asset, size, stateOverrides = {}) {
910
+ export function v3WorldBoxes(asset, size, stateOverrides = {}, options = {}) {
793
911
  const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
794
912
  const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
795
- return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
913
+ return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset), options);
914
+ }
915
+ /**
916
+ * Instance scales at which a repeated part's copy count changes, one just below and one just above each
917
+ * boundary near the base count (completion criterion: repeat-count boundaries).
918
+ */
919
+ export function repeatBoundaryScales(source) {
920
+ const out = [];
921
+ for (const part of source.parts) {
922
+ if (part.sizing !== 'repeat' || !part.repeat)
923
+ continue;
924
+ const axis = part.repeat.axis, pitch = part.repeat.pitch, length = source.base[axis];
925
+ const count = Math.floor(length / pitch);
926
+ for (const k of [count, count + 1]) {
927
+ for (const delta of [-0.5, 0.5]) {
928
+ const L = k * pitch + delta;
929
+ if (L <= 0)
930
+ continue;
931
+ out.push({ x: 1, y: 1, z: 1, [axis]: L / length });
932
+ }
933
+ }
934
+ }
935
+ return out;
796
936
  }
797
937
  /** ADR-0087 decision 2 tolerances. */
798
938
  export const SAME_TOLERANCE_MM = 1;
@@ -829,16 +969,21 @@ function rotationAngleDeg(a, b) {
829
969
  export function compareV2WithV3(source, asset, options = {}) {
830
970
  const opts = typeof options === 'number' ? { factor: options } : options;
831
971
  const factor = opts.factor ?? 2;
972
+ const mesh = opts.mesh ?? true;
973
+ const steps = opts.shrink ? [1, factor, 1 / factor] : [1, factor];
832
974
  const scales = [];
833
- for (const x of [1, factor])
834
- for (const y of [1, factor])
835
- for (const z of [1, factor])
975
+ for (const x of steps)
976
+ for (const y of steps)
977
+ for (const z of steps)
836
978
  scales.push({ x, y, z });
979
+ scales.push(...(opts.extraScales ?? []));
837
980
  const states = [{ label: 'rest' }, ...(opts.states ?? [])];
838
981
  const differences = [];
839
982
  let boxesCompared = 0;
840
983
  const materials = new Map(asset.appearance.map(a => [a.target, a]));
841
984
  for (const part of source.parts) {
985
+ if (part.capability)
986
+ continue; // an anchor has no body on either side
842
987
  const look = materials.get(part.name);
843
988
  if (!look) {
844
989
  differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
@@ -868,8 +1013,17 @@ export function compareV2WithV3(source, asset, options = {}) {
868
1013
  const overrides = v3StateOverrides(asset, state);
869
1014
  for (const scale of scales) {
870
1015
  const size = { x: source.base.x * scale.x, y: source.base.y * scale.y, z: source.base.z * scale.z };
871
- const v2 = byPart(v2WorldBoxes(source, scale, state));
872
- const v3 = byPart(v3WorldBoxes(asset, size, overrides));
1016
+ const v2 = byPart(v2WorldBoxes(source, scale, state, { points: mesh }));
1017
+ let v3;
1018
+ try {
1019
+ v3 = byPart(v3WorldBoxes(asset, size, overrides, { points: mesh }));
1020
+ }
1021
+ catch (e) {
1022
+ // V3 refuses what V2 drew anyway (a collapsed span as a sliver, a joint value past its limit). Recorded, not thrown.
1023
+ const err = e;
1024
+ differences.push({ part: '*', scale, state: state.label, what: `refused:${err.code}`, v2: 'draws it', v3: err.message });
1025
+ continue;
1026
+ }
873
1027
  for (const name of new Set([...v2.keys(), ...v3.keys()])) {
874
1028
  const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
875
1029
  if (a.length !== b.length) {
@@ -889,11 +1043,23 @@ export function compareV2WithV3(source, asset, options = {}) {
889
1043
  const turn = rotationAngleDeg(p.rotation, q.rotation);
890
1044
  if (turn > SAME_TOLERANCE_DEG)
891
1045
  differences.push({ part: name, scale, state: state.label, what: `rotation${tag}`, v2: JSON.stringify(p.rotation.map(r => r.map(v => +v.toFixed(4)))), v3: `${turn.toFixed(3)}° apart` });
1046
+ if (mesh && p.points && q.points) {
1047
+ const gap = hausdorff(p.points, q.points);
1048
+ if (gap > SAME_TOLERANCE_MM)
1049
+ differences.push({ part: name, scale, state: state.label, what: `mesh${tag}`, v2: `${p.points.length} points`, v3: `surface ${gap.toFixed(3)} mm apart` });
1050
+ }
892
1051
  }
893
1052
  }
894
1053
  }
895
1054
  }
896
- return { same: differences.length === 0, scales, states: states.map(s => s.label), boxesCompared, differences };
1055
+ return {
1056
+ same: differences.length === 0,
1057
+ scales,
1058
+ states: states.map(s => s.label),
1059
+ boxesCompared,
1060
+ sampling: { toleranceMm: SAME_TOLERANCE_MM, toleranceDeg: SAME_TOLERANCE_DEG, mesh, cornerDivisions: SEGMENT_PRESETS[0], sphereGrid: '6 rings × 12 around + poles', cylinderSegments: "the part's own segment count, both rims" },
1061
+ differences
1062
+ };
897
1063
  }
898
1064
  const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
899
1065
  /**
@@ -903,7 +1069,7 @@ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m:
903
1069
  * What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
904
1070
  */
905
1071
  function planMotion(c) {
906
- const { src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio } = c;
1072
+ const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
907
1073
  const params = src.parameters ?? [];
908
1074
  const clips = src.animations ?? [];
909
1075
  const joints = src.joints ?? [];
@@ -919,6 +1085,12 @@ function planMotion(c) {
919
1085
  refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
920
1086
  continue;
921
1087
  }
1088
+ // V2 keeps parameters, parts and joints in separate name spaces; a V3 graph has one. The parameter keeps its
1089
+ // name (motion contract), so a clash is refused rather than renamed behind the author's back.
1090
+ if (partsByName.has(p.name) || jointsByName.has(p.name) && joints.some(j => j.name === p.name && j.child === p.name)) {
1091
+ refusals.push({ code: 'NAME_COLLISION', part: p.name, detail: `parameter "${p.name}" shares its name with a part; V3 state inputs and parts share one name space` });
1092
+ continue;
1093
+ }
922
1094
  g.input(p.name, unit, p.range.min, p.range.max, 'state');
923
1095
  asset.stateDefaults[p.name] = p.default ?? p.range.min;
924
1096
  paramUnit.set(p.name, unit);
@@ -949,6 +1121,55 @@ function planMotion(c) {
949
1121
  const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
950
1122
  return g.add(`${hint}.q`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), slopeUnit, `${hint}.slope`)), g.constant(round6(v0), outUnit, `${hint}.v0`));
951
1123
  };
1124
+ /** A dimensionless factor f = s0 + (p - min)·(s1 - s0)/(max - min), for a scale channel. */
1125
+ const affineRatio = (p, s0, s1, hint) => {
1126
+ const pUnit = paramUnit.get(p.name);
1127
+ if (!pUnit)
1128
+ return undefined;
1129
+ const span = p.range.max - p.range.min;
1130
+ if (pUnit !== 'percent' && pUnit !== 'ratio') {
1131
+ // A slope in ratio-per-<unit> has no V3 unit; only dimensionless parameters may scale a dimension.
1132
+ refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale a dimension; a percent or ratio parameter can` });
1133
+ return undefined;
1134
+ }
1135
+ const slope = pUnit === 'percent' ? ((s1 - s0) * 100) / span : (s1 - s0) / span;
1136
+ const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
1137
+ return g.add(`${hint}.f`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), 'ratio', `${hint}.slope`)), g.constant(round6(s0), 'ratio', `${hint}.s0`));
1138
+ };
1139
+ /**
1140
+ * A dimensionless factor through every key of a linear channel: f = curve(u, at₀,s₀, at₁,s₁, …) with u = (p − min)/span
1141
+ * as a ratio in [0, 1], the keys' `at` in the same ratio (piecewise-linear curves, V3 designer's approval
1142
+ * 2026-09-22). Two keys at 0 and 1 take the affine form instead, so earlier assets keep their graphs.
1143
+ */
1144
+ const curveRatio = (p, keys, hint) => {
1145
+ const pUnit = paramUnit.get(p.name);
1146
+ if (!pUnit)
1147
+ return undefined;
1148
+ const span = p.range.max - p.range.min;
1149
+ 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` });
1151
+ return undefined;
1152
+ }
1153
+ const slope = pUnit === 'percent' ? 100 / span : 1 / span;
1154
+ 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`));
1156
+ const args = [u01];
1157
+ 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
+ return g.node(`${hint}.curve`, 'curve@1', args, 'value');
1159
+ };
1160
+ /** A linear channel with keys in order inside [0, 1]; anything else is refused by name. */
1161
+ const linearKeys = (ch, owner) => {
1162
+ const keys = ch.keys;
1163
+ if ((ch.interpolation ?? 'linear') !== 'linear') {
1164
+ refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: ${ch.interpolation} interpolation is not written into the graph; linear keys are` });
1165
+ return false;
1166
+ }
1167
+ 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) {
1168
+ refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: keys must be two or more, strictly increasing, inside [0, 1]` });
1169
+ return false;
1170
+ }
1171
+ return true;
1172
+ };
952
1173
  const twoKeyLinear = (ch, owner) => {
953
1174
  const keys = ch.keys;
954
1175
  if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
@@ -997,16 +1218,57 @@ function planMotion(c) {
997
1218
  }
998
1219
  if (!info.has(ch.target))
999
1220
  continue; // the part itself was refused above, and that refusal already names it
1000
- if (ch.path === 'scale') {
1001
- refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `parameter ${p.name} scales the part; a scale channel is shape deformation, not covered by the motion contract` });
1002
- continue;
1003
- }
1004
1221
  if (ch.pivot) {
1005
1222
  refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered by the motion contract' });
1006
1223
  continue;
1007
1224
  }
1008
1225
  const keys = ch.keys;
1009
1226
  const axes = varying(keys);
1227
+ if (ch.path === 'scale') {
1228
+ // Shape-dimension contract §2: a state input may change a dimension. Each varying axis of the part gets a
1229
+ // ratio factor affine in the parameter; the part's centre does not move (V2 scaled about the centre).
1230
+ if (axes.length === 0) {
1231
+ 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` });
1232
+ continue;
1233
+ }
1234
+ if (!linearKeys(ch, p.name))
1235
+ continue;
1236
+ if (!axisPermutation(partsByName.get(ch.target).transform.rotation)) {
1237
+ 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
+ continue;
1239
+ }
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` });
1245
+ continue;
1246
+ }
1247
+ const twoKeys = keys.length === 2 && keys[0].at === 0 && keys[1].at === 1;
1248
+ const entry = scaleOf.get(ch.target) ?? {};
1249
+ let ok = true;
1250
+ for (const axis of axes) {
1251
+ if (!claim(`${ch.target}/scale/${axis}`, p.name, ch.target)) {
1252
+ ok = false;
1253
+ break;
1254
+ }
1255
+ const hint = `${p.name}.${ch.target}.scale.${axis}`;
1256
+ const f = twoKeys
1257
+ ? affineRatio(p, keys[0].value[axis], keys[1].value[axis], hint)
1258
+ : curveRatio(p, keys.map(k => ({ at: k.at, value: k.value[axis] })), hint);
1259
+ if (f === undefined) {
1260
+ ok = false;
1261
+ break;
1262
+ }
1263
+ entry[axis] = f;
1264
+ }
1265
+ if (!ok)
1266
+ continue;
1267
+ scaleOf.set(ch.target, entry);
1268
+ 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
+ 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
+ continue;
1271
+ }
1010
1272
  if (axes.length === 0) {
1011
1273
  notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `parameter ${p.name} has a ${ch.path} channel whose keys do not change; nothing to write` });
1012
1274
  continue;
@@ -1157,8 +1419,10 @@ function planMotion(c) {
1157
1419
  q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
1158
1420
  asset.stateDefaults[j.name] = 0;
1159
1421
  }
1160
- if (limits)
1161
- notes.push({ code: 'JOINT_LIMIT', part: j.name, detail: `V2 clamps a value outside [${limits.min}, ${limits.max}] to the limit; V3 refuses it (INPUT_RANGE)` });
1422
+ if (limits && !writer)
1423
+ rangeChanges.push({ input: j.name, part: j.name, v2: `any value; outside [${limits.min}, ${limits.max}] V2 clamped it to the limit`, v3: { min: limits.min, max: limits.max }, reason: 'V3 refuses a joint value outside its limits (INPUT_RANGE) instead of clamping' });
1424
+ if (limits && writer)
1425
+ notes.push({ code: 'JOINT_LIMIT', part: j.name, detail: `the joint's limits [${limits.min}, ${limits.max}] are carried by the parameter ${writer.p.name}'s own range; V3 refuses a value outside it where V2 clamped` });
1162
1426
  if (j.type === 'prismatic')
1163
1427
  notes.push({ code: 'PRISMATIC_TRAVEL', part: j.name, detail: 'V2 scaled the travel by the instance scale; V3 travel is the value itself, per the contract' });
1164
1428
  // Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
@@ -1181,7 +1445,8 @@ function planMotion(c) {
1181
1445
  originRef.push(g.len(expr, `${j.name}.origin.${a}`));
1182
1446
  }
1183
1447
  const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
1184
- const axisRefs = AXES.map(a => ratio(j.axis[a] / n, `${j.name}.axis.${a}`));
1448
+ // Full precision: the kernel accepts a unit axis to 1e-6 and normalises, but the converter does not round it.
1449
+ const axisRefs = AXES.map(a => g.constant(j.axis[a] / n, 'ratio', `${j.name}.axis.${a}`));
1185
1450
  const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
1186
1451
  const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
1187
1452
  const parentJoint = attach ? carrierOf(attach.name) : undefined;