@hatiolab/figure-model 0.1.34 → 0.1.35

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;
@@ -165,12 +172,14 @@ export function convertV2ToV3(source, options) {
165
172
  const lost = [];
166
173
  const refusals = [];
167
174
  const notes = [];
175
+ const rangeChanges = [];
168
176
  const checked = validate(source);
169
177
  if (checked.errors.length) {
170
178
  return {
171
179
  status: 'refused',
172
180
  refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
173
181
  lost,
182
+ rangeChanges,
174
183
  notes
175
184
  };
176
185
  }
@@ -219,13 +228,39 @@ export function convertV2ToV3(source, options) {
219
228
  const cap = REPEAT_LIMIT * cp.repeat.pitch;
220
229
  if (sizeMax[axis] > cap) {
221
230
  sizeMax[axis] = cap;
222
- lost.push({
223
- code: 'REPEAT_LIMIT',
231
+ rangeChanges.push({
232
+ input: V3_SIZE_INPUTS[axis],
224
233
  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`
234
+ v2: `any size; above ${cap} mm V2 stopped adding copies at ${REPEAT_LIMIT} and drew the rest as a shorter row`,
235
+ v3: { min: sizeMin[axis], max: cap },
236
+ reason: `V3 refuses a size whose repeat count would exceed ${REPEAT_LIMIT} (fit-pitch@1 RESOURCE_LIMIT) instead of clamping the count`
226
237
  });
227
238
  }
228
239
  }
240
+ // A spanned part fills the room between its two gaps. Below that room V2 draws a 0.001 mm sliver (`sizing.ts`
241
+ // SLIVER); V3 refuses a non-positive dimension. The condition is length > 0, nothing more: the size input's
242
+ // minimum is the room itself plus the smallest step that keeps the evaluated length positive in double precision.
243
+ // Shrinking the accepted range is a behaviour change and is recorded as one (designer's ruling 2).
244
+ for (const cp of parts) {
245
+ const subject = withSizing(cp);
246
+ for (const a of AXES) {
247
+ if (anchorOf(subject, base, a) !== 'span')
248
+ continue;
249
+ const gaps = clearance(subject, base, a);
250
+ const room = gaps.min + gaps.max;
251
+ const floor = room + SPAN_POSITIVE_STEP;
252
+ if (floor > sizeMin[a]) {
253
+ sizeMin[a] = floor;
254
+ rangeChanges.push({
255
+ input: V3_SIZE_INPUTS[a],
256
+ part: cp.name,
257
+ v2: `any size; at or below ${round6(room)} mm the spanned part was drawn as a 0.001 mm sliver`,
258
+ v3: { min: floor, max: sizeMax[a] },
259
+ reason: `the spanned part must keep a positive length (${SPAN_POSITIVE_STEP} mm is the numerical step above zero, not a minimum part size)`
260
+ });
261
+ }
262
+ }
263
+ }
229
264
  const size = {};
230
265
  for (const a of AXES) {
231
266
  size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
@@ -254,6 +289,8 @@ export function convertV2ToV3(source, options) {
254
289
  const info = new Map();
255
290
  const spinOf = new Map();
256
291
  const shiftsOf = new Map();
292
+ // A scale channel: per local axis, a ratio the part's dimensions are multiplied by (contract §2: dims × affine(p)).
293
+ const scaleOf = new Map();
257
294
  const frameOf = new Map(); // part → the joint whose frame carries it
258
295
  const motionOf = new Map(); // joint → pose asset.rest → asset
259
296
  const partsByName = new Map(parts.map(cp => [cp.name, cp]));
@@ -289,7 +326,7 @@ export function convertV2ToV3(source, options) {
289
326
  if (cp.materialSlot)
290
327
  lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
291
328
  if (cp.capability)
292
- lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'slot/port role is not carried as a V3 binding' });
329
+ 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
330
  const { material, refusal } = materialOf(cp, palette);
294
331
  if (refusal) {
295
332
  refusals.push(refusal);
@@ -334,8 +371,6 @@ export function convertV2ToV3(source, options) {
334
371
  if (cp.shape?.round)
335
372
  lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
336
373
  }
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
374
  const segments = cp.segments ?? SEG_DEFAULT;
340
375
  if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
341
376
  refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
@@ -379,6 +414,13 @@ export function convertV2ToV3(source, options) {
379
414
  pending.push(a); // aspect-*: follows another axis, settled below
380
415
  }
381
416
  }
417
+ // A capability anchor is a place, not a body: V2 compiles it apart from the drawn groups and never renders it
418
+ // (the independent check against the V2 renderer found the converter drawing one). Its position is kept for
419
+ // joints that attach to it; nothing is placed.
420
+ if (cp.capability) {
421
+ info.set(name, { centre, factor, repeat: false });
422
+ continue;
423
+ }
382
424
  // A curved part keeps a round section: both cross axes take the geometric mean of their two factors
383
425
  // (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
384
426
  // it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
@@ -415,59 +457,66 @@ export function convertV2ToV3(source, options) {
415
457
  }
416
458
  // Local dimensions scale by the factor of the world axis each local axis lands on.
417
459
  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
- }
433
460
  const follows = AXES.some(a => factor[a].kind !== 'one');
434
461
  if (!perm && follows) {
435
462
  refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
436
463
  continue;
437
464
  }
438
- const dim = (local) => times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE);
465
+ // A channel scale is planned after the parts and read when the shape node is written, so `dim` is called
466
+ // inside the shape closures only.
467
+ const withChannel = (v, local, hint) => {
468
+ const sc = scaleOf.get(name)?.[local];
469
+ if (!sc)
470
+ return v;
471
+ return { kind: 'ref', ref: g.mul(`${name}.${hint}.scaled`, g.len(v, `${name}.${hint}`), sc) };
472
+ };
473
+ const dim = (local) => withChannel(times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE), local, `dim.${local}`);
474
+ const channelSplits = () => {
475
+ const sc = scaleOf.get(name);
476
+ return !!sc && (sc.x ?? null) !== (sc.z ?? null);
477
+ };
439
478
  const localFrame = `${name}.local`;
440
479
  // A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
441
480
  const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
442
481
  const rot = cp.transform.rotation;
443
482
  const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
444
483
  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() });
484
+ if (cp.primitive === 'cylinder') {
485
+ // V2 builds every cylinder from a unit cylinder with equal end radii and scales it by size (things-scene
486
+ // geometry-bank): size.x ≠ size.z, or two cross factors that differ with keepRound off, is an elliptic
487
+ // cylinder, never a frustum. Equal radii by the same expression stay on cylinder-shape@1.
488
+ const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
489
+ const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
490
+ const equalSizes = Math.abs(cp.transform.size.x - cp.transform.size.z) <= 1e-9;
491
+ if (!(sameFactor && equalSizes))
492
+ 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` });
493
+ shape = () => {
494
+ const radiusX = withChannel(times(`${name}.dim.radiusX`, { kind: 'const', value: cp.transform.size.x / 2 }, fx), 'x', 'radiusX');
495
+ const radiusZ = withChannel(times(`${name}.dim.radiusZ`, { kind: 'const', value: cp.transform.size.z / 2 }, fz), 'z', 'radiusZ');
496
+ const length = dim('y');
497
+ const round = sameFactor && equalSizes && !channelSplits();
498
+ return round
499
+ ? g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radiusX, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() })
500
+ : 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() });
501
+ };
457
502
  }
458
503
  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() });
504
+ 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()}`);
505
+ shape = () => {
506
+ const [rx, ry, rz] = [r('x'), r('y'), r('z')];
507
+ 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() });
508
+ };
462
509
  }
463
510
  else if (cp.primitive === 'polygon') {
464
- const h = dim('y');
465
511
  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() });
512
+ shape = () => {
513
+ const h = dim('y');
514
+ const points = path.flatMap((q, i) => [
515
+ withChannel(times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx), 'x', `p${i}.x`),
516
+ withChannel(times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz), 'z', `p${i}.z`)
517
+ ]);
518
+ 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() });
519
+ };
471
520
  }
472
521
  else if (cp.primitive === 'rect') {
473
522
  // V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
@@ -475,23 +524,36 @@ export function convertV2ToV3(source, options) {
475
524
  const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
476
525
  if (round < declared)
477
526
  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() });
527
+ // V2 scales the finished mesh per world axis, so a corner drawn with radius r becomes an elliptic arc with
528
+ // semi-axes r·fx, r·fz (shape-dimension contract §1). When the two factors are one expression, @1 is enough.
529
+ const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
530
+ const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
531
+ if (round > 0 && !sameFactor)
532
+ 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` });
533
+ shape = () => {
534
+ const roundX = withChannel(times(`${name}.roundX`, { kind: 'const', value: round }, fx), 'x', 'roundX');
535
+ const roundZ = withChannel(times(`${name}.roundZ`, { kind: 'const', value: round }, fz), 'z', 'roundZ');
536
+ const perAxis = round > 0 && (!sameFactor || channelSplits());
537
+ const [w, h, d] = [dim('x'), dim('y'), dim('z')];
538
+ if (hollow) {
539
+ // A wall's thickness follows the axis it lies across and the floor follows the height.
540
+ const wallX = withChannel(times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx), 'x', 'wallX');
541
+ const wallZ = withChannel(times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz), 'z', 'wallZ');
542
+ const floor = withChannel(times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy), 'y', 'floor');
543
+ return perAxis
544
+ ? 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() })
545
+ : 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() });
546
+ }
547
+ return perAxis
548
+ ? 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() })
549
+ : 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() });
550
+ };
491
551
  }
492
552
  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() });
553
+ shape = () => {
554
+ const [w, h, d] = [dim('x'), dim('y'), dim('z')];
555
+ 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() });
556
+ };
495
557
  }
496
558
  const appearance = { target: name, material: material };
497
559
  if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
@@ -579,9 +641,9 @@ export function convertV2ToV3(source, options) {
579
641
  }
580
642
  });
581
643
  }
582
- planMotion({ src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio, times, factorOfScale });
644
+ planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
583
645
  if (refusals.length)
584
- return { status: 'refused', refusals, lost, notes };
646
+ return { status: 'refused', refusals, lost, rangeChanges, notes };
585
647
  for (const plan of plans) {
586
648
  plan.nodes();
587
649
  asset.appearance.push(plan.appearance);
@@ -591,9 +653,9 @@ export function convertV2ToV3(source, options) {
591
653
  }
592
654
  catch (e) {
593
655
  const err = e;
594
- return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, notes };
656
+ return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, rangeChanges, notes };
595
657
  }
596
- return { status: 'converted', asset, lost, notes };
658
+ return { status: 'converted', method: 'mechanical', asset, lost, rangeChanges, notes };
597
659
  }
598
660
  /** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
599
661
  function withSizing(cp) {
@@ -651,10 +713,11 @@ export function v2ClipDuration(clip) {
651
713
  * factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
652
714
  * everything in its frame about its sized origin, parents first (ADR-0066).
653
715
  */
654
- export function v2WorldBoxes(source, scale, state = {}) {
716
+ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
655
717
  const base = source.base;
656
718
  const out = [];
657
719
  const shifts = new Map();
720
+ const scales = new Map();
658
721
  const turns = new Map();
659
722
  const jointValues = new Map();
660
723
  const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
@@ -665,7 +728,11 @@ export function v2WorldBoxes(source, scale, state = {}) {
665
728
  continue;
666
729
  }
667
730
  const v = sampleV2(ch, at);
668
- if (ch.path === 'translation') {
731
+ if (ch.path === 'scale') {
732
+ const s0 = scales.get(ch.target) ?? { x: 1, y: 1, z: 1 };
733
+ scales.set(ch.target, { x: s0.x * v.x, y: s0.y * v.y, z: s0.z * v.z });
734
+ }
735
+ else if (ch.path === 'translation') {
669
736
  const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
670
737
  shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
671
738
  }
@@ -728,32 +795,41 @@ export function v2WorldBoxes(source, scale, state = {}) {
728
795
  return world;
729
796
  };
730
797
  for (const part of source.parts) {
798
+ // Capability anchors are not drawn by V2 (blueprint `anchors`, not `groups`).
799
+ if (part.capability)
800
+ continue;
731
801
  const cp = withSizing(centredPart(part, base));
732
802
  const at = sizingPosition(cp, scale, base);
733
803
  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
804
  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 } });
805
+ const extent = rotatedExtentOf(cp);
740
806
  let rotation = eulerXYZ(part.transform.rotation);
741
807
  const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
742
808
  const box = { x: world('x'), y: world('y'), z: world('z') };
743
809
  box.y.centre += (base.y * scale.y) / 2;
744
810
  // A polygon is drawn where its path says, which may be off the part centre: rotation baked, then scaled per world axis.
811
+ let bodyPath = part.shape?.path;
745
812
  if (part.primitive === 'polygon' && part.shape?.path?.length) {
746
813
  const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
747
814
  const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
748
815
  for (const [a, axis] of AXES.entries())
749
816
  box[axis].centre += (rotation[a][0] * c[0] + rotation[a][2] * c[2]) * box[axis].factor;
817
+ // The sampled body is centred on its drawn centre, so the path is taken about that centre.
818
+ bodyPath = part.shape.path.map(q => ({ x: q.x - c[0], y: q.y - c[2] }));
750
819
  }
751
- // Local dimensions: each local axis lands on a world axis and takes that axis's factor.
820
+ // A scale channel multiplies the part's own dimensions about its centre (V2 pose.scale in the part frame).
821
+ const chScale = scales.get(part.name) ?? { x: 1, y: 1, z: 1 };
822
+ // Surface points: body in its own frame → channel scale → baked rotation → scaled per world axis → motion → centre.
823
+ const localPoints = options.points
824
+ ? 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 })
825
+ : null;
826
+ // Local dimensions: each local axis lands on a world axis and takes that axis's factor, times any channel scale
827
+ // (V2 applies pose.scale in the part's own frame, about its centre).
752
828
  const perm = axisPermutation(part.transform.rotation);
753
829
  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)
830
+ drawnSize.x * (perm ? box[perm.x].factor : 1) * chScale.x,
831
+ drawnSize.y * (perm ? box[perm.y].factor : 1) * chScale.y,
832
+ drawnSize.z * (perm ? box[perm.z].factor : 1) * chScale.z
757
833
  ];
758
834
  // Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
759
835
  const shift = shifts.get(part.name);
@@ -761,8 +837,11 @@ export function v2WorldBoxes(source, scale, state = {}) {
761
837
  for (const a of AXES)
762
838
  box[a].centre += shift[a] * box[a].factor;
763
839
  const turn = turns.get(part.name);
764
- if (turn)
840
+ let motionRotation = I3;
841
+ if (turn) {
765
842
  rotation = mm3(turn, rotation);
843
+ motionRotation = turn;
844
+ }
766
845
  // Then the joint frame that carries it.
767
846
  const carrier = carrierOf(part.name);
768
847
  const W = carrier ? worldJoint(carrier) : undefined;
@@ -773,13 +852,18 @@ export function v2WorldBoxes(source, scale, state = {}) {
773
852
  if (plan)
774
853
  centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
775
854
  let R = rotation;
855
+ let M = motionRotation;
776
856
  if (W) {
777
857
  const c = mv3(W.r, [centre.x, centre.y, centre.z]);
778
858
  centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
779
859
  R = mm3(W.r, rotation);
860
+ M = mm3(W.r, motionRotation);
780
861
  }
781
862
  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 });
863
+ const box2 = { part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R };
864
+ if (localPoints)
865
+ box2.points = transformPoints(localPoints, M, [centre.x, centre.y, centre.z]);
866
+ out.push(box2);
783
867
  }
784
868
  }
785
869
  return out;
@@ -789,10 +873,32 @@ function composeRT(a, b) {
789
873
  return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
790
874
  }
791
875
  /** Where a converted V3 asset draws every part at an instance size, as world boxes. */
792
- export function v3WorldBoxes(asset, size, stateOverrides = {}) {
876
+ export function v3WorldBoxes(asset, size, stateOverrides = {}, options = {}) {
793
877
  const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
794
878
  const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
795
- return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
879
+ return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset), options);
880
+ }
881
+ /**
882
+ * Instance scales at which a repeated part's copy count changes, one just below and one just above each
883
+ * boundary near the base count (completion criterion: repeat-count boundaries).
884
+ */
885
+ export function repeatBoundaryScales(source) {
886
+ const out = [];
887
+ for (const part of source.parts) {
888
+ if (part.sizing !== 'repeat' || !part.repeat)
889
+ continue;
890
+ const axis = part.repeat.axis, pitch = part.repeat.pitch, length = source.base[axis];
891
+ const count = Math.floor(length / pitch);
892
+ for (const k of [count, count + 1]) {
893
+ for (const delta of [-0.5, 0.5]) {
894
+ const L = k * pitch + delta;
895
+ if (L <= 0)
896
+ continue;
897
+ out.push({ x: 1, y: 1, z: 1, [axis]: L / length });
898
+ }
899
+ }
900
+ }
901
+ return out;
796
902
  }
797
903
  /** ADR-0087 decision 2 tolerances. */
798
904
  export const SAME_TOLERANCE_MM = 1;
@@ -829,16 +935,21 @@ function rotationAngleDeg(a, b) {
829
935
  export function compareV2WithV3(source, asset, options = {}) {
830
936
  const opts = typeof options === 'number' ? { factor: options } : options;
831
937
  const factor = opts.factor ?? 2;
938
+ const mesh = opts.mesh ?? true;
939
+ const steps = opts.shrink ? [1, factor, 1 / factor] : [1, factor];
832
940
  const scales = [];
833
- for (const x of [1, factor])
834
- for (const y of [1, factor])
835
- for (const z of [1, factor])
941
+ for (const x of steps)
942
+ for (const y of steps)
943
+ for (const z of steps)
836
944
  scales.push({ x, y, z });
945
+ scales.push(...(opts.extraScales ?? []));
837
946
  const states = [{ label: 'rest' }, ...(opts.states ?? [])];
838
947
  const differences = [];
839
948
  let boxesCompared = 0;
840
949
  const materials = new Map(asset.appearance.map(a => [a.target, a]));
841
950
  for (const part of source.parts) {
951
+ if (part.capability)
952
+ continue; // an anchor has no body on either side
842
953
  const look = materials.get(part.name);
843
954
  if (!look) {
844
955
  differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
@@ -868,8 +979,17 @@ export function compareV2WithV3(source, asset, options = {}) {
868
979
  const overrides = v3StateOverrides(asset, state);
869
980
  for (const scale of scales) {
870
981
  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));
982
+ const v2 = byPart(v2WorldBoxes(source, scale, state, { points: mesh }));
983
+ let v3;
984
+ try {
985
+ v3 = byPart(v3WorldBoxes(asset, size, overrides, { points: mesh }));
986
+ }
987
+ catch (e) {
988
+ // V3 refuses what V2 drew anyway (a collapsed span as a sliver, a joint value past its limit). Recorded, not thrown.
989
+ const err = e;
990
+ differences.push({ part: '*', scale, state: state.label, what: `refused:${err.code}`, v2: 'draws it', v3: err.message });
991
+ continue;
992
+ }
873
993
  for (const name of new Set([...v2.keys(), ...v3.keys()])) {
874
994
  const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
875
995
  if (a.length !== b.length) {
@@ -889,11 +1009,23 @@ export function compareV2WithV3(source, asset, options = {}) {
889
1009
  const turn = rotationAngleDeg(p.rotation, q.rotation);
890
1010
  if (turn > SAME_TOLERANCE_DEG)
891
1011
  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` });
1012
+ if (mesh && p.points && q.points) {
1013
+ const gap = hausdorff(p.points, q.points);
1014
+ if (gap > SAME_TOLERANCE_MM)
1015
+ differences.push({ part: name, scale, state: state.label, what: `mesh${tag}`, v2: `${p.points.length} points`, v3: `surface ${gap.toFixed(3)} mm apart` });
1016
+ }
892
1017
  }
893
1018
  }
894
1019
  }
895
1020
  }
896
- return { same: differences.length === 0, scales, states: states.map(s => s.label), boxesCompared, differences };
1021
+ return {
1022
+ same: differences.length === 0,
1023
+ scales,
1024
+ states: states.map(s => s.label),
1025
+ boxesCompared,
1026
+ 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" },
1027
+ differences
1028
+ };
897
1029
  }
898
1030
  const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
899
1031
  /**
@@ -903,7 +1035,7 @@ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m:
903
1035
  * What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
904
1036
  */
905
1037
  function planMotion(c) {
906
- const { src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio } = c;
1038
+ const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
907
1039
  const params = src.parameters ?? [];
908
1040
  const clips = src.animations ?? [];
909
1041
  const joints = src.joints ?? [];
@@ -919,6 +1051,12 @@ function planMotion(c) {
919
1051
  refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
920
1052
  continue;
921
1053
  }
1054
+ // V2 keeps parameters, parts and joints in separate name spaces; a V3 graph has one. The parameter keeps its
1055
+ // name (motion contract), so a clash is refused rather than renamed behind the author's back.
1056
+ if (partsByName.has(p.name) || jointsByName.has(p.name) && joints.some(j => j.name === p.name && j.child === p.name)) {
1057
+ 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` });
1058
+ continue;
1059
+ }
922
1060
  g.input(p.name, unit, p.range.min, p.range.max, 'state');
923
1061
  asset.stateDefaults[p.name] = p.default ?? p.range.min;
924
1062
  paramUnit.set(p.name, unit);
@@ -949,6 +1087,21 @@ function planMotion(c) {
949
1087
  const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
950
1088
  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
1089
  };
1090
+ /** A dimensionless factor f = s0 + (p - min)·(s1 - s0)/(max - min), for a scale channel. */
1091
+ const affineRatio = (p, s0, s1, hint) => {
1092
+ const pUnit = paramUnit.get(p.name);
1093
+ if (!pUnit)
1094
+ return undefined;
1095
+ const span = p.range.max - p.range.min;
1096
+ if (pUnit !== 'percent' && pUnit !== 'ratio') {
1097
+ // A slope in ratio-per-<unit> has no V3 unit; only dimensionless parameters may scale a dimension.
1098
+ refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale a dimension; a percent or ratio parameter can` });
1099
+ return undefined;
1100
+ }
1101
+ const slope = pUnit === 'percent' ? ((s1 - s0) * 100) / span : (s1 - s0) / span;
1102
+ const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
1103
+ return g.add(`${hint}.f`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), 'ratio', `${hint}.slope`)), g.constant(round6(s0), 'ratio', `${hint}.s0`));
1104
+ };
952
1105
  const twoKeyLinear = (ch, owner) => {
953
1106
  const keys = ch.keys;
954
1107
  if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
@@ -997,16 +1150,45 @@ function planMotion(c) {
997
1150
  }
998
1151
  if (!info.has(ch.target))
999
1152
  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
1153
  if (ch.pivot) {
1005
1154
  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
1155
  continue;
1007
1156
  }
1008
1157
  const keys = ch.keys;
1009
1158
  const axes = varying(keys);
1159
+ if (ch.path === 'scale') {
1160
+ // Shape-dimension contract §2: a state input may change a dimension. Each varying axis of the part gets a
1161
+ // ratio factor affine in the parameter; the part's centre does not move (V2 scaled about the centre).
1162
+ if (axes.length === 0) {
1163
+ notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `parameter ${p.name} has a scale channel whose keys do not change; nothing to write` });
1164
+ continue;
1165
+ }
1166
+ if (!twoKeyLinear(ch, p.name))
1167
+ continue;
1168
+ if (!axisPermutation(partsByName.get(ch.target).transform.rotation)) {
1169
+ refusals.push({ code: 'ROTATED_FOLLOWS', part: ch.target, detail: 'a scale channel on a part rotated off the axes would shear it; not covered' });
1170
+ continue;
1171
+ }
1172
+ const entry = scaleOf.get(ch.target) ?? {};
1173
+ let ok = true;
1174
+ for (const axis of axes) {
1175
+ if (!claim(`${ch.target}/scale/${axis}`, p.name, ch.target)) {
1176
+ ok = false;
1177
+ break;
1178
+ }
1179
+ const f = affineRatio(p, keys[0].value[axis], keys[1].value[axis], `${p.name}.${ch.target}.scale.${axis}`);
1180
+ if (f === undefined) {
1181
+ ok = false;
1182
+ break;
1183
+ }
1184
+ entry[axis] = f;
1185
+ }
1186
+ if (!ok)
1187
+ continue;
1188
+ scaleOf.set(ch.target, entry);
1189
+ notes.push({ code: 'STATE_DIMENSION', part: ch.target, detail: `dimensions on ${axes.join(', ')} follow parameter ${p.name} (${keys[0].value[axes[0]]} → ${keys[1].value[axes[0]]} on ${axes[0]}); the occupancy must hold over its range` });
1190
+ continue;
1191
+ }
1010
1192
  if (axes.length === 0) {
1011
1193
  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
1194
  continue;
@@ -1157,8 +1339,10 @@ function planMotion(c) {
1157
1339
  q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
1158
1340
  asset.stateDefaults[j.name] = 0;
1159
1341
  }
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)` });
1342
+ if (limits && !writer)
1343
+ 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' });
1344
+ if (limits && writer)
1345
+ 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
1346
  if (j.type === 'prismatic')
1163
1347
  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
1348
  // Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
@@ -1181,7 +1365,8 @@ function planMotion(c) {
1181
1365
  originRef.push(g.len(expr, `${j.name}.origin.${a}`));
1182
1366
  }
1183
1367
  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}`));
1368
+ // Full precision: the kernel accepts a unit axis to 1e-6 and normalises, but the converter does not round it.
1369
+ const axisRefs = AXES.map(a => g.constant(j.axis[a] / n, 'ratio', `${j.name}.axis.${a}`));
1185
1370
  const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
1186
1371
  const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
1187
1372
  const parentJoint = attach ? carrierOf(attach.name) : undefined;