@hatiolab/figure-model 0.1.33 → 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.
Files changed (41) hide show
  1. package/dist/index.d.ts +6 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/v3-asset-types.d.ts +26 -0
  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 +7 -2
  9. package/dist/v3-asset.js.map +1 -1
  10. package/dist/v3-capabilities.js +1 -0
  11. package/dist/v3-capabilities.js.map +1 -1
  12. package/dist/v3-driver.d.ts +16 -0
  13. package/dist/v3-driver.d.ts.map +1 -0
  14. package/dist/v3-driver.js +145 -0
  15. package/dist/v3-driver.js.map +1 -0
  16. package/dist/v3-from-v2.d.ts +109 -9
  17. package/dist/v3-from-v2.d.ts.map +1 -1
  18. package/dist/v3-from-v2.js +912 -119
  19. package/dist/v3-from-v2.js.map +1 -1
  20. package/dist/v3-gate.d.ts +56 -4
  21. package/dist/v3-gate.d.ts.map +1 -1
  22. package/dist/v3-gate.js +178 -21
  23. package/dist/v3-gate.js.map +1 -1
  24. package/dist/v3-graph-types.d.ts +6 -1
  25. package/dist/v3-graph-types.d.ts.map +1 -1
  26. package/dist/v3-graph.d.ts +17 -1
  27. package/dist/v3-graph.d.ts.map +1 -1
  28. package/dist/v3-graph.js +144 -20
  29. package/dist/v3-graph.js.map +1 -1
  30. package/dist/v3-kernel-version.d.ts +8 -0
  31. package/dist/v3-kernel-version.d.ts.map +1 -0
  32. package/dist/v3-kernel-version.js +11 -0
  33. package/dist/v3-kernel-version.js.map +1 -0
  34. package/dist/v3-mesh-compare.d.ts +27 -0
  35. package/dist/v3-mesh-compare.d.ts.map +1 -0
  36. package/dist/v3-mesh-compare.js +198 -0
  37. package/dist/v3-mesh-compare.js.map +1 -0
  38. package/docs/v3-motion-contract.md +178 -0
  39. package/docs/v3-operator-contracts.md +4 -0
  40. package/docs/v3-shape-dimension-contract.md +146 -0
  41. package/package.json +2 -1
@@ -11,13 +11,15 @@
11
11
  * That is what this file does. It reproduces V2 behaviour; it does not improve the asset.
12
12
  *
13
13
  * What it cannot express, it refuses by name (ADR-0087 decision 2: no silent flattening):
14
- * - a hollow polygon (V3 has hollow boxes only), a cylinder with two radii, a polygon whose path
15
- * does not fill its declared size about the centre (V2 itself draws that one off-centre)
16
- * - a part rotated off the axes whose size still follows the instance (V2 shears it; V3 cannot)
17
- * - joints and parameters (motion semantics are not converted here)
14
+ * - a hollow polygon (V3 has hollow boxes only), a polygon whose path does not fill its declared size
15
+ * - a part rotated off the axes whose size still follows the instance (V2 shears it; V3 placement is rigid,
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
18
+ * - joints and parameters, until `docs/v3-motion-contract.md` is implemented
18
19
  *
19
- * What it converts but does not carry, it lists in `lost` so the caller can show it. Nothing is dropped
20
- * without being named there.
20
+ * What it normalises on purpose it lists in `notes` with the value before and after: a corner radius V2 clamped
21
+ * when drawing, a polygon path recentred with its placement moved by the same amount, the palette it snapshotted.
22
+ * What it converts but does not carry, it lists in `lost`. Nothing is dropped without being named in one of the two.
21
23
  *
22
24
  * `compareV2WithV3` is the definition of "the same" from ADR-0087 decision 2: same part list, each
23
25
  * part's centre, world extents and rotation within tolerance, same material, at the base size and at
@@ -31,10 +33,26 @@ import { centredPart } from "./origin.js";
31
33
  import { rotatedExtentOf } from "./blueprint.js";
32
34
  import { anchorOf, clearance, longAxis, repeatOffset, repeatPlan, sizingPosition, sizingScale } from "./sizing.js";
33
35
  import { AXES, REPEAT_LIMIT, SEGMENT_PRESETS } from "./types.js";
36
+ import { driverValue } from "./v3-driver.js";
37
+ import { hausdorff, scalePoints, transformPoints, v2BodyPoints } from "./v3-mesh-compare.js";
38
+ import { jointOriginPosition } from "./sizing.js";
39
+ /** A short stable fingerprint of the palette, so a report can say which colours it snapshotted. */
40
+ export function paletteHash(palette) {
41
+ const text = JSON.stringify(Object.entries(palette).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
42
+ let h = 5381;
43
+ for (let i = 0; i < text.length; i++)
44
+ h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
45
+ return h.toString(16).padStart(8, '0');
46
+ }
34
47
  /** The V3 design inputs that stand for the instance size, in mm. */
35
48
  export const V3_SIZE_INPUTS = Object.freeze({ x: 'size.x', y: 'size.y', z: 'size.z' });
36
49
  /** How far a V2 instance may grow in the converted asset, as a multiple of the base box. */
37
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;
38
56
  const ONE = { kind: 'one' };
39
57
  const SEG_DEFAULT = 12;
40
58
  const RIGHT_ANGLE_TOLERANCE = 1e-9;
@@ -153,23 +171,22 @@ function materialOf(part, palette) {
153
171
  export function convertV2ToV3(source, options) {
154
172
  const lost = [];
155
173
  const refusals = [];
174
+ const notes = [];
175
+ const rangeChanges = [];
156
176
  const checked = validate(source);
157
177
  if (checked.errors.length) {
158
178
  return {
159
179
  status: 'refused',
160
180
  refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
161
- lost
181
+ lost,
182
+ rangeChanges,
183
+ notes
162
184
  };
163
185
  }
164
186
  const src = source;
165
187
  const base = src.base;
166
188
  const palette = options.palette;
167
- if (src.joints?.length)
168
- refusals.push({ code: 'JOINTS', detail: `${src.joints.length} joints; motion is not converted` });
169
- if (src.parameters?.length)
170
- refusals.push({ code: 'PARAMETERS', detail: `${src.parameters.length} parameters; motion is not converted` });
171
- for (const clip of src.animations ?? [])
172
- lost.push({ code: 'ANIMATION', detail: `animation "${clip.name}" (${clip.channels.length} channels) is not carried` });
189
+ notes.push({ code: 'PALETTE', detail: `colours snapshotted from palette ${options.paletteId ?? `#${paletteHash(palette)}`}` });
173
190
  if (src.detailLevel)
174
191
  lost.push({ code: 'DETAIL_LEVEL', detail: `detailLevel "${src.detailLevel}" has no V3 field` });
175
192
  if (src.styleKit)
@@ -211,13 +228,39 @@ export function convertV2ToV3(source, options) {
211
228
  const cap = REPEAT_LIMIT * cp.repeat.pitch;
212
229
  if (sizeMax[axis] > cap) {
213
230
  sizeMax[axis] = cap;
214
- lost.push({
215
- code: 'REPEAT_LIMIT',
231
+ rangeChanges.push({
232
+ input: V3_SIZE_INPUTS[axis],
216
233
  part: cp.name,
217
- 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`
218
237
  });
219
238
  }
220
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
+ }
221
264
  const size = {};
222
265
  for (const a of AXES) {
223
266
  size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
@@ -242,6 +285,29 @@ export function convertV2ToV3(source, options) {
242
285
  };
243
286
  const plans = [];
244
287
  const seen = new Set();
288
+ // Motion (docs/v3-motion-contract.md), planned after the parts and read when the part nodes are written.
289
+ const info = new Map();
290
+ const spinOf = new Map();
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();
294
+ const frameOf = new Map(); // part → the joint whose frame carries it
295
+ const motionOf = new Map(); // joint → pose asset.rest → asset
296
+ const partsByName = new Map(parts.map(cp => [cp.name, cp]));
297
+ const jointByChild = new Map((src.joints ?? []).map(j => [j.child, j]));
298
+ const carrierJointOf = (name) => {
299
+ for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
300
+ const j = jointByChild.get(p.name);
301
+ if (j)
302
+ return j.name;
303
+ }
304
+ return undefined;
305
+ };
306
+ for (const cp of parts) {
307
+ const carrier = carrierJointOf(cp.name);
308
+ if (carrier)
309
+ frameOf.set(cp.name, carrier);
310
+ }
245
311
  for (const cp of parts) {
246
312
  const name = cp.name;
247
313
  if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(name)) {
@@ -253,14 +319,14 @@ export function convertV2ToV3(source, options) {
253
319
  continue;
254
320
  }
255
321
  seen.add(name);
256
- if (cp.parent)
257
- lost.push({ code: 'PARENT', part: name, detail: `attached to "${cp.parent}"; V3 places it in the asset frame` });
322
+ if (cp.parent && !frameOf.has(name))
323
+ lost.push({ code: 'PARENT', part: name, detail: `attached to "${cp.parent}" with no joint above it; V3 places it in the asset frame, where it already was at rest` });
258
324
  if (cp.label)
259
325
  lost.push({ code: 'LABEL', part: name, detail: 'label has no V3 field' });
260
326
  if (cp.materialSlot)
261
327
  lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
262
328
  if (cp.capability)
263
- 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' });
264
330
  const { material, refusal } = materialOf(cp, palette);
265
331
  if (refusal) {
266
332
  refusals.push(refusal);
@@ -276,28 +342,35 @@ export function convertV2ToV3(source, options) {
276
342
  refusals.push({ code: 'NO_PROVIDER', part: name, detail: `V3 has no hollow ${cp.primitive}` });
277
343
  continue;
278
344
  }
279
- const path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
345
+ // A polygon's path is drawn as written, so its bounding box may sit off the part centre. V3 keeps the visible
346
+ // position: the path is recentred and the placement moves by the same amount (ADR-0087 ruling 3).
347
+ let path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
348
+ const recentre = { x: 0, z: 0 };
280
349
  if (cp.primitive === 'polygon') {
281
- // V2 sizing reads `size`, but draws the path as written. The two agree only when the path fills the
282
- // size box symmetrically about the part centre; otherwise V2 itself draws the part off its declared centre.
283
350
  const xs = path.map(q => q.x), zs = path.map(q => q.y);
284
351
  const spanX = Math.max(...xs) - Math.min(...xs), spanZ = Math.max(...zs) - Math.min(...zs);
285
- const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midZ = (Math.max(...zs) + Math.min(...zs)) / 2;
286
- if (path.length < 3 || Math.abs(spanX - cp.transform.size.x) > 1e-6 || Math.abs(spanZ - cp.transform.size.z) > 1e-6 || Math.abs(midX) > 1e-6 || Math.abs(midZ) > 1e-6) {
352
+ if (path.length < 3 || Math.abs(spanX - cp.transform.size.x) > 1e-6 || Math.abs(spanZ - cp.transform.size.z) > 1e-6) {
353
+ // V2 sizing reads `size` while the drawing reads the path; when they disagree there is no one truth to keep.
287
354
  refusals.push({
288
- code: 'POLYGON_OFF_CENTRE',
355
+ code: 'POLYGON_SIZE_MISMATCH',
289
356
  part: name,
290
- detail: `path spans ${spanX}×${spanZ} about (${midX}, ${midZ}); size says ${cp.transform.size.x}×${cp.transform.size.z} about the centre`
357
+ detail: `path spans ${spanX}×${spanZ}; size says ${cp.transform.size.x}×${cp.transform.size.z}`
291
358
  });
292
359
  continue;
293
360
  }
361
+ recentre.x = round6((Math.max(...xs) + Math.min(...xs)) / 2);
362
+ recentre.z = round6((Math.max(...zs) + Math.min(...zs)) / 2);
363
+ if (recentre.x !== 0 || recentre.z !== 0) {
364
+ path = path.map(q => ({ x: round6(q.x - recentre.x), y: round6(q.y - recentre.z) }));
365
+ notes.push({
366
+ code: 'POLYGON_RECENTRED',
367
+ part: name,
368
+ detail: `path was drawn about (${recentre.x}, ${recentre.z}) in its own frame; moved to the origin and the placement moved by the same amount, so it draws where it drew`
369
+ });
370
+ }
294
371
  if (cp.shape?.round)
295
372
  lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
296
373
  }
297
- if (cp.primitive === 'cylinder' && Math.abs(cp.transform.size.x - cp.transform.size.z) > 1e-9) {
298
- refusals.push({ code: 'CONE', part: name, detail: `size.x ${cp.transform.size.x} ≠ size.z ${cp.transform.size.z}; V3 cylinders have one radius` });
299
- continue;
300
- }
301
374
  const segments = cp.segments ?? SEG_DEFAULT;
302
375
  if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
303
376
  refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
@@ -341,20 +414,20 @@ export function convertV2ToV3(source, options) {
341
414
  pending.push(a); // aspect-*: follows another axis, settled below
342
415
  }
343
416
  }
344
- for (const a of pending) {
345
- const named = rules[a].slice('aspect-'.length);
346
- factor[a] = factor[named];
347
- centre[a] = times(`${name}.${a}.centre`, { kind: 'const', value: cp.transform.position[a] }, factor[named]);
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;
348
423
  }
349
- // The V3 asset frame has its origin at the bottom of the box (ADR-0065); V2 sizing works from the centre.
350
- centre.y = { kind: 'ref', ref: g.add(`${name}.y.fromBottom`, g.len(centre.y, `${name}.y.centred`), g.mul(`${name}.y.lift`, size.y, ratio(0.5, 'half'))) };
351
424
  // A curved part keeps a round section: both cross axes take the geometric mean of their two factors
352
425
  // (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
353
- // it is a `geomean@1` node.
426
+ // it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
354
427
  if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && cp.keepRound !== false) {
355
428
  const spin = cp.primitive === 'sphere' ? null : longAxis(subject);
356
429
  const across = AXES.filter(a => a !== spin);
357
- const fs = across.map(a => factor[a]);
430
+ const fs = across.map(a => factor[a] ?? ONE); // an aspect axis is still unresolved here and counts as fixed, as V2's keepRound sees it
358
431
  const same = fs.every(f => f.kind === 'one') || fs.every(f => f.kind === 'ref' && f.sig === fs[0].sig);
359
432
  if (!same) {
360
433
  const asRef = (f) => (f.kind === 'one' ? ratio(1, 'one') : f.ref);
@@ -363,6 +436,25 @@ export function convertV2ToV3(source, options) {
363
436
  factor[a] = even;
364
437
  }
365
438
  }
439
+ for (const a of pending) {
440
+ const named = rules[a].slice('aspect-'.length);
441
+ factor[a] = factor[named];
442
+ centre[a] = times(`${name}.${a}.centre`, { kind: 'const', value: cp.transform.position[a] }, factor[named]);
443
+ }
444
+ // The V3 asset frame has its origin at the bottom of the box (ADR-0065); V2 sizing works from the centre.
445
+ centre.y = { kind: 'ref', ref: g.add(`${name}.y.fromBottom`, g.len(centre.y, `${name}.y.centred`), g.mul(`${name}.y.lift`, size.y, ratio(0.5, 'half'))) };
446
+ // A recentred polygon moves back by R·c, and that offset follows the world factor of each axis as the drawn
447
+ // mesh did in V2 (rotation baked, then scaled per world axis).
448
+ if (recentre.x !== 0 || recentre.z !== 0) {
449
+ const R = eulerXYZ(cp.transform.rotation);
450
+ for (const [a, axis] of AXES.entries()) {
451
+ const k = round6(R[a][0] * recentre.x + R[a][2] * recentre.z);
452
+ if (k === 0)
453
+ continue;
454
+ const shift = times(`${name}.${axis}.recentre`, { kind: 'const', value: k }, factor[axis]);
455
+ centre[axis] = { kind: 'ref', ref: g.add(`${name}.${axis}.drawnAt`, g.len(centre[axis], `${name}.${axis}.declared`), g.len(shift, `${name}.${axis}.shift`)) };
456
+ }
457
+ }
366
458
  // Local dimensions scale by the factor of the world axis each local axis lands on.
367
459
  const perm = axisPermutation(cp.transform.rotation);
368
460
  const follows = AXES.some(a => factor[a].kind !== 'one');
@@ -370,59 +462,126 @@ export function convertV2ToV3(source, options) {
370
462
  refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
371
463
  continue;
372
464
  }
373
- 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
+ };
374
478
  const localFrame = `${name}.local`;
479
+ // A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
480
+ const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
375
481
  const rot = cp.transform.rotation;
376
482
  const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
377
483
  let shape;
378
484
  if (cp.primitive === 'cylinder') {
379
- const radius = times(`${name}.dim.radius`, { kind: 'const', value: cp.transform.size.x / 2 }, perm ? factor[perm.x] : ONE);
380
- const length = dim('y');
381
- shape = () => g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radius, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: localFrame });
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
+ };
382
502
  }
383
503
  else if (cp.primitive === 'sphere') {
384
- const r = (local) => times(`${name}.dim.radius${local.toUpperCase()}`, { kind: 'const', value: cp.transform.size[local] / 2 }, perm ? factor[perm[local]] : ONE);
385
- const [rx, ry, rz] = [r('x'), r('y'), r('z')];
386
- 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: localFrame });
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
+ };
387
509
  }
388
510
  else if (cp.primitive === 'polygon') {
389
- const h = dim('y');
390
511
  const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
391
- const points = path.flatMap((q, i) => [
392
- times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx),
393
- times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz)
394
- ]);
395
- 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: localFrame });
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
+ };
396
520
  }
397
521
  else if (cp.primitive === 'rect') {
398
- const round = cp.shape?.round ?? 0;
399
- if (round > 0 && (factor.x.kind !== 'one' || factor.z.kind !== 'one'))
400
- 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` });
401
- const [w, h, d] = [dim('x'), dim('y'), dim('z')];
402
- if (hollow) {
403
- // V2 scales the finished mesh, so a wall's thickness follows the axis it lies across and the floor follows the height.
404
- const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
405
- const wallX = times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx);
406
- const wallZ = times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz);
407
- const floor = times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy);
408
- 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: localFrame });
409
- }
410
- else
411
- 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: localFrame });
522
+ // V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
523
+ const declared = cp.shape?.round ?? 0;
524
+ const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
525
+ if (round < declared)
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)` });
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
+ };
412
551
  }
413
552
  else {
414
- const [w, h, d] = [dim('x'), dim('y'), dim('z')];
415
- 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: localFrame });
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
+ };
416
557
  }
417
558
  const appearance = { target: name, material: material };
418
559
  if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
419
560
  appearance.segments = segments;
561
+ info.set(name, { centre, factor, repeat: !!repeat });
420
562
  if (!repeat) {
421
563
  plans.push({
422
564
  appearance,
423
565
  nodes: () => {
424
566
  const s = shape();
425
- const pose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => g.len(centre[a], `${name}.${a}`)), ...rotationRefs], 'pose', { from: localFrame, to: 'asset' });
567
+ const shifts = shiftsOf.get(name) ?? [];
568
+ const frame = frameOf.get(name);
569
+ const finalTo = frame ? 'asset.rest' : 'asset';
570
+ // rest: local → F0; each slide F_k → F_k+1 along an asset axis; the last lands in finalTo.
571
+ let pose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => g.len(centre[a], `${name}.${a}`)), ...rotationRefs], 'pose', { from: localFrame, to: shifts.length ? `${name}.rest0` : finalTo });
572
+ shifts.forEach((shift, k) => {
573
+ const axis = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
574
+ const slide = g.node(`${name}.slide${k}`, 'axis-slide@1', [...axis, shift.q], 'pose', { from: `${name}.rest${k}`, to: k === shifts.length - 1 ? finalTo : `${name}.rest${k + 1}` });
575
+ pose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, pose], 'pose');
576
+ });
577
+ if (frame)
578
+ pose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), pose], 'pose');
579
+ const spin = spinOf.get(name);
580
+ if (spin) {
581
+ const axis = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
582
+ const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axis, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
583
+ pose = g.node(`${name}.spinning`, 'compose@1', [pose, turn], 'pose');
584
+ }
426
585
  g.node(name, 'place@1', [s, pose], 'placed');
427
586
  }
428
587
  });
@@ -437,7 +596,14 @@ export function convertV2ToV3(source, options) {
437
596
  appearance,
438
597
  nodes: () => {
439
598
  const s = shape();
440
- const memberPose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => (a === axis ? g.constant(0, 'mm', 'zero') : g.len(centre[a], `${name}.${a}`))), ...rotationRefs], 'pose', { from: localFrame, to: itemFrame });
599
+ let memberPose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => (a === axis ? g.constant(0, 'mm', 'zero') : g.len(centre[a], `${name}.${a}`))), ...rotationRefs], 'pose', { from: localFrame, to: itemFrame });
600
+ // A spinning repeated part (the conveyor's rollers) turns each copy about its own axis: the turn goes on the member.
601
+ const spin = spinOf.get(name);
602
+ if (spin) {
603
+ const axisRefs = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
604
+ const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axisRefs, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
605
+ memberPose = g.node(`${name}.spinning`, 'compose@1', [memberPose, turn], 'pose');
606
+ }
441
607
  const member = g.node(name, 'member@1', [s, memberPose], 'placed');
442
608
  const assembly = g.node(`${name}.assembly`, 'assembly@1', [member], 'assembly');
443
609
  let layout;
@@ -459,13 +625,25 @@ export function convertV2ToV3(source, options) {
459
625
  const halfRow = times(`${name}.halfRow`, { kind: 'const', value: -((count - 1) * repeat.pitch) / 2 }, factor[axis]);
460
626
  rowStart = g.add(`${name}.rowStart`, g.add(`${name}.rowCentre`, g.len(centre[axis], `${name}.${axis}`), g.len(halfRow, `${name}.halfRow`)), g.constant(-0.5, 'mm', 'minusHalfMm'));
461
627
  }
462
- const rootPose = g.node(`${name}.root`, 'rigid@1', [...AXES.map(a => (a === axis ? rowStart : g.constant(0, 'mm', 'zero'))), ...AXES.map(() => g.constant(0, 'deg', 'deg.0'))], 'pose', { from: itemFrame, to: 'asset' });
628
+ // The row as a whole shifts with a translation channel and moves with the joint frame that carries it.
629
+ const shifts = shiftsOf.get(name) ?? [];
630
+ const frame = frameOf.get(name);
631
+ const finalTo = frame ? 'asset.rest' : 'asset';
632
+ let rootPose = g.node(`${name}.root`, 'rigid@1', [...AXES.map(a => (a === axis ? rowStart : g.constant(0, 'mm', 'zero'))), ...AXES.map(() => g.constant(0, 'deg', 'deg.0'))], 'pose', { from: itemFrame, to: shifts.length ? `${name}.rest0` : finalTo });
633
+ shifts.forEach((shift, k) => {
634
+ const axisRefs = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
635
+ const slide = g.node(`${name}.slide${k}`, 'axis-slide@1', [...axisRefs, shift.q], 'pose', { from: `${name}.rest${k}`, to: k === shifts.length - 1 ? finalTo : `${name}.rest${k + 1}` });
636
+ rootPose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, rootPose], 'pose');
637
+ });
638
+ if (frame)
639
+ rootPose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), rootPose], 'pose');
463
640
  g.node(`${name}.repeat`, 'repeat@1', [layout, assembly, rootPose], 'collection', { axis });
464
641
  }
465
642
  });
466
643
  }
644
+ planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
467
645
  if (refusals.length)
468
- return { status: 'refused', refusals, lost };
646
+ return { status: 'refused', refusals, lost, rangeChanges, notes };
469
647
  for (const plan of plans) {
470
648
  plan.nodes();
471
649
  asset.appearance.push(plan.appearance);
@@ -475,47 +653,271 @@ export function convertV2ToV3(source, options) {
475
653
  }
476
654
  catch (e) {
477
655
  const err = e;
478
- return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost };
656
+ return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, rangeChanges, notes };
479
657
  }
480
- return { status: 'converted', asset, lost };
658
+ return { status: 'converted', method: 'mechanical', asset, lost, rangeChanges, notes };
481
659
  }
482
660
  /** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
483
661
  function withSizing(cp) {
484
662
  return { ...cp, sizing: cp.sizing ?? 'scale' };
485
663
  }
486
- /** Where V2 draws every part (and copy) at an instance scale, as world boxes. */
487
- export function v2WorldBoxes(source, scale) {
664
+ const I3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
665
+ const mm3 = (a, b) => a.map(r => [0, 1, 2].map(j => r[0] * b[0][j] + r[1] * b[1][j] + r[2] * b[2][j]));
666
+ const mv3 = (a, v) => a.map(r => r[0] * v[0] + r[1] * v[1] + r[2] * v[2]);
667
+ const tr3 = (a) => [0, 1, 2].map(i => [0, 1, 2].map(j => a[j][i]));
668
+ /** Rotation about a unit axis by degrees (Rodrigues), the matrix three's setFromAxisAngle builds. */
669
+ export function axisAngle(axis, deg) {
670
+ const n = Math.hypot(axis.x, axis.y, axis.z);
671
+ const [x, y, z] = [axis.x / n, axis.y / n, axis.z / n];
672
+ const q = (deg * Math.PI) / 180, c = Math.cos(q), s = Math.sin(q), k = 1 - c;
673
+ return [
674
+ [c + x * x * k, x * y * k - z * s, x * z * k + y * s],
675
+ [y * x * k + z * s, c + y * y * k, y * z * k - x * s],
676
+ [z * x * k - y * s, z * y * k + x * s, c + z * z * k]
677
+ ];
678
+ }
679
+ /** V2's channel sampling (`things-scene` sampleChannel): hold the ends, `step` keeps the earlier key. */
680
+ function sampleV2(channel, at) {
681
+ const keys = channel.keys;
682
+ if (at <= keys[0].at)
683
+ return keys[0].value;
684
+ const last = keys[keys.length - 1];
685
+ if (at >= last.at)
686
+ return last.value;
687
+ let i = 0;
688
+ while (i < keys.length - 2 && keys[i + 1].at <= at)
689
+ i++;
690
+ const from = keys[i], to = keys[i + 1];
691
+ if (channel.interpolation === 'step')
692
+ return from.value;
693
+ const span = to.at - from.at;
694
+ const r = span > 0 ? (at - from.at) / span : 0;
695
+ if (typeof from.value === 'number')
696
+ return (from.value + r * (to.value - from.value));
697
+ const f = from.value, t = to.value;
698
+ return { x: f.x + r * (t.x - f.x), y: f.y + r * (t.y - f.y), z: f.z + r * (t.z - f.z) };
699
+ }
700
+ /** V2's clip length: the last key time across its channels, in seconds. */
701
+ export function v2ClipDuration(clip) {
702
+ let last = 0;
703
+ for (const ch of clip.channels) {
704
+ const end = ch.keys[ch.keys.length - 1]?.at;
705
+ if (typeof end === 'number' && end > last)
706
+ last = end;
707
+ }
708
+ return last;
709
+ }
710
+ /**
711
+ * Where V2 draws every part (and copy) at an instance scale and a motion state, as oriented world boxes.
712
+ * Motion follows things-scene: a translation channel shifts the part in the figure frame by the part's world
713
+ * factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
714
+ * everything in its frame about its sized origin, parents first (ADR-0066).
715
+ */
716
+ export function v2WorldBoxes(source, scale, state = {}, options = {}) {
488
717
  const base = source.base;
489
718
  const out = [];
719
+ const shifts = new Map();
720
+ const scales = new Map();
721
+ const turns = new Map();
722
+ const jointValues = new Map();
723
+ const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
724
+ const apply = (channels, at) => {
725
+ for (const ch of channels) {
726
+ if (ch.path === undefined) {
727
+ jointValues.set(ch.target, (jointValues.get(ch.target) ?? 0) + sampleV2(ch, at));
728
+ continue;
729
+ }
730
+ const v = sampleV2(ch, at);
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') {
736
+ const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
737
+ shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
738
+ }
739
+ else if (ch.path === 'rotation') {
740
+ const Rp = rotationOf.get(ch.target) ?? I3;
741
+ const Rch = eulerXYZ(v);
742
+ const wrapped = mm3(mm3(Rp, Rch), tr3(Rp));
743
+ turns.set(ch.target, mm3(turns.get(ch.target) ?? I3, wrapped));
744
+ }
745
+ }
746
+ };
747
+ for (const p of source.parameters ?? []) {
748
+ const value = state.parameters?.[p.name] ?? p.default ?? p.range.min;
749
+ const u = p.range.max > p.range.min ? (value - p.range.min) / (p.range.max - p.range.min) : 0;
750
+ apply(p.clip.channels, u);
751
+ }
752
+ for (const clip of source.animations ?? []) {
753
+ const duration = v2ClipDuration(clip);
754
+ const t = state.clipTime?.[clip.name] ?? 0;
755
+ apply(clip.channels, duration > 0 ? t % duration : 0);
756
+ }
757
+ // Joint frames: world transform per joint, parents first.
758
+ const partsByName = new Map(source.parts.map(p => [p.name, p]));
759
+ const jointByChild = new Map((source.joints ?? []).map(j => [j.child, j]));
760
+ const carrierOf = (name) => {
761
+ for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
762
+ const j = jointByChild.get(p.name);
763
+ if (j)
764
+ return j;
765
+ }
766
+ return undefined;
767
+ };
768
+ const worldOf = new Map();
769
+ const worldJoint = (j) => {
770
+ const known = worldOf.get(j.name);
771
+ if (known)
772
+ return known;
773
+ const child = partsByName.get(j.child);
774
+ const attach = child.parent ? partsByName.get(child.parent) : undefined;
775
+ const cj = { origin: { ...j.origin, y: j.origin.y - base.y / 2 } };
776
+ const at = jointOriginPosition(cj, attach ? withSizing(centredPart(attach, base)) : undefined, scale, base);
777
+ const o = [at.x * scale.x, at.y * scale.y + (base.y * scale.y) / 2, at.z * scale.z];
778
+ const limits = j.limits;
779
+ let q = jointValues.get(j.name) ?? 0;
780
+ if (limits)
781
+ q = Math.min(Math.max(q, limits.min), limits.max);
782
+ let local;
783
+ if (j.type === 'prismatic') {
784
+ const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
785
+ local = { r: I3, t: [(j.axis.x / n) * q * scale.x, (j.axis.y / n) * q * scale.y, (j.axis.z / n) * q * scale.z] };
786
+ }
787
+ else {
788
+ const R = axisAngle(j.axis, q);
789
+ const Ro = mv3(R, o);
790
+ local = { r: R, t: [o[0] - Ro[0], o[1] - Ro[1], o[2] - Ro[2]] };
791
+ }
792
+ const parentJoint = attach ? carrierOf(attach.name) : undefined;
793
+ const world = parentJoint ? composeRT(worldJoint(parentJoint), local) : local;
794
+ worldOf.set(j.name, world);
795
+ return world;
796
+ };
490
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;
491
801
  const cp = withSizing(centredPart(part, base));
492
802
  const at = sizingPosition(cp, scale, base);
493
803
  const sized = sizingScale(cp, scale, base);
804
+ const drawnSize = { ...cp.transform.size };
494
805
  const extent = rotatedExtentOf(cp);
495
- const rotation = eulerXYZ(part.transform.rotation);
806
+ let rotation = eulerXYZ(part.transform.rotation);
496
807
  const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
497
808
  const box = { x: world('x'), y: world('y'), z: world('z') };
498
809
  box.y.centre += (base.y * scale.y) / 2;
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;
812
+ if (part.primitive === 'polygon' && part.shape?.path?.length) {
813
+ const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
814
+ const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
815
+ for (const [a, axis] of AXES.entries())
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] }));
819
+ }
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).
828
+ const perm = axisPermutation(part.transform.rotation);
829
+ const dims = [
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
833
+ ];
834
+ // Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
835
+ const shift = shifts.get(part.name);
836
+ if (shift)
837
+ for (const a of AXES)
838
+ box[a].centre += shift[a] * box[a].factor;
839
+ const turn = turns.get(part.name);
840
+ let motionRotation = I3;
841
+ if (turn) {
842
+ rotation = mm3(turn, rotation);
843
+ motionRotation = turn;
844
+ }
845
+ // Then the joint frame that carries it.
846
+ const carrier = carrierOf(part.name);
847
+ const W = carrier ? worldJoint(carrier) : undefined;
499
848
  const plan = repeatPlan(cp, scale, base);
500
849
  const copies = plan ? plan.count : 1;
501
850
  for (let i = 0; i < copies; i++) {
502
- const centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
851
+ let centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
503
852
  if (plan)
504
853
  centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
505
- out.push({ part: part.name, centre, extent: { x: box.x.extent, y: box.y.extent, z: box.z.extent }, rotation });
854
+ let R = rotation;
855
+ let M = motionRotation;
856
+ if (W) {
857
+ const c = mv3(W.r, [centre.x, centre.y, centre.z]);
858
+ centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
859
+ R = mm3(W.r, rotation);
860
+ M = mm3(W.r, motionRotation);
861
+ }
862
+ const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
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);
506
867
  }
507
868
  }
508
869
  return out;
509
870
  }
871
+ function composeRT(a, b) {
872
+ const rt = mv3(a.r, b.t);
873
+ return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
874
+ }
510
875
  /** Where a converted V3 asset draws every part at an instance size, as world boxes. */
511
- export function v3WorldBoxes(asset, size) {
876
+ export function v3WorldBoxes(asset, size, stateOverrides = {}, options = {}) {
512
877
  const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
513
- const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate();
514
- return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
878
+ const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
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;
515
902
  }
516
903
  /** ADR-0087 decision 2 tolerances. */
517
904
  export const SAME_TOLERANCE_MM = 1;
518
905
  export const SAME_TOLERANCE_DEG = 0.1;
906
+ /** The V3 state overrides that stand for a V2 motion state: parameters by name, drivers at their clip's time. */
907
+ export function v3StateOverrides(asset, state) {
908
+ const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]));
909
+ const out = {};
910
+ for (const [name, value] of Object.entries(state.parameters ?? {}))
911
+ if (units.has(name))
912
+ out[name] = value;
913
+ for (const d of asset.drivers ?? []) {
914
+ const clip = d.id.split('/')[0];
915
+ const t = state.clipTime?.[clip] ?? 0;
916
+ const unit = units.get(d.state);
917
+ out[d.state] = driverValue(d, t, unit === 'deg' || unit === 'rad' ? unit : 'other');
918
+ }
919
+ return out;
920
+ }
519
921
  function rotationAngleDeg(a, b) {
520
922
  // The angle of a·bᵀ. Its trace is the sum of the element-wise products of a and b.
521
923
  let trace = 0;
@@ -526,23 +928,31 @@ function rotationAngleDeg(a, b) {
526
928
  return (Math.acos(c) * 180) / Math.PI;
527
929
  }
528
930
  /**
529
- * "The same" (ADR-0087 decision 2): the V2 source and the V3 asset draw the same parts, each at the same
530
- * centre and world extents within 1 mm and the same rotation within 0.1°, with the same material, at the
531
- * base size and at every combination of doubled axes.
931
+ * "The same" (ADR-0087 decision 2): the V2 source and the V3 asset draw the same parts, each with the same
932
+ * centre and local dimensions within 1 mm and the same rotation within 0.1° (as a relative angle), with the same
933
+ * material, at the base size and at every combination of doubled axes, at rest and at every motion state given.
532
934
  */
533
- export function compareV2WithV3(source, asset, factor = 2) {
935
+ export function compareV2WithV3(source, asset, options = {}) {
936
+ const opts = typeof options === 'number' ? { factor: options } : options;
937
+ const factor = opts.factor ?? 2;
938
+ const mesh = opts.mesh ?? true;
939
+ const steps = opts.shrink ? [1, factor, 1 / factor] : [1, factor];
534
940
  const scales = [];
535
- for (const x of [1, factor])
536
- for (const y of [1, factor])
537
- for (const z of [1, factor])
941
+ for (const x of steps)
942
+ for (const y of steps)
943
+ for (const z of steps)
538
944
  scales.push({ x, y, z });
945
+ scales.push(...(opts.extraScales ?? []));
946
+ const states = [{ label: 'rest' }, ...(opts.states ?? [])];
539
947
  const differences = [];
540
948
  let boxesCompared = 0;
541
949
  const materials = new Map(asset.appearance.map(a => [a.target, a]));
542
950
  for (const part of source.parts) {
951
+ if (part.capability)
952
+ continue; // an anchor has no body on either side
543
953
  const look = materials.get(part.name);
544
954
  if (!look) {
545
- differences.push({ part: part.name, scale: scales[0], what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
955
+ differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
546
956
  continue;
547
957
  }
548
958
  const expected = { token: part.material.token };
@@ -553,43 +963,426 @@ export function compareV2WithV3(source, asset, factor = 2) {
553
963
  if (part.material.emissive)
554
964
  expected.emissive = { token: part.material.emissive.token ?? part.material.token, intensity: part.material.emissive.intensity, on: part.material.emissive.on ?? false };
555
965
  if (JSON.stringify(expected) !== JSON.stringify(look.material))
556
- differences.push({ part: part.name, scale: scales[0], what: 'material', v2: JSON.stringify(expected), v3: JSON.stringify(look.material) });
966
+ differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'material', v2: JSON.stringify(expected), v3: JSON.stringify(look.material) });
557
967
  if ((part.primitive === 'cylinder' || part.primitive === 'sphere') && look.segments !== (part.segments ?? SEG_DEFAULT))
558
- differences.push({ part: part.name, scale: scales[0], what: 'segments', v2: String(part.segments ?? SEG_DEFAULT), v3: String(look.segments) });
968
+ differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'segments', v2: String(part.segments ?? SEG_DEFAULT), v3: String(look.segments) });
559
969
  }
560
- for (const scale of scales) {
561
- const size = { x: source.base.x * scale.x, y: source.base.y * scale.y, z: source.base.z * scale.z };
562
- const byPart = (boxes) => {
563
- const m = new Map();
564
- for (const b of boxes)
565
- (m.get(b.part) ?? m.set(b.part, []).get(b.part)).push(b);
566
- for (const list of m.values())
567
- list.sort((p, q) => p.centre.x - q.centre.x || p.centre.y - q.centre.y || p.centre.z - q.centre.z);
568
- return m;
569
- };
570
- const v2 = byPart(v2WorldBoxes(source, scale));
571
- const v3 = byPart(v3WorldBoxes(asset, size));
572
- for (const name of new Set([...v2.keys(), ...v3.keys()])) {
573
- const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
574
- if (a.length !== b.length) {
575
- differences.push({ part: name, scale, what: 'copies', v2: String(a.length), v3: String(b.length) });
970
+ const byPart = (boxes) => {
971
+ const m = new Map();
972
+ for (const b of boxes)
973
+ (m.get(b.part) ?? m.set(b.part, []).get(b.part)).push(b);
974
+ for (const list of m.values())
975
+ list.sort((p, q) => p.centre.x - q.centre.x || p.centre.y - q.centre.y || p.centre.z - q.centre.z);
976
+ return m;
977
+ };
978
+ for (const state of states) {
979
+ const overrides = v3StateOverrides(asset, state);
980
+ for (const scale of scales) {
981
+ const size = { x: source.base.x * scale.x, y: source.base.y * scale.y, z: source.base.z * scale.z };
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 });
576
991
  continue;
577
992
  }
578
- for (const [i, p] of a.entries()) {
579
- const q = b[i];
580
- boxesCompared++;
581
- for (const axis of AXES) {
582
- if (Math.abs(p.centre[axis] - q.centre[axis]) > SAME_TOLERANCE_MM)
583
- differences.push({ part: name, scale, what: `centre.${axis}${a.length > 1 ? `[${i}]` : ''}`, v2: p.centre[axis].toFixed(3), v3: q.centre[axis].toFixed(3) });
584
- if (Math.abs(p.extent[axis] - q.extent[axis]) > SAME_TOLERANCE_MM)
585
- differences.push({ part: name, scale, what: `extent.${axis}${a.length > 1 ? `[${i}]` : ''}`, v2: p.extent[axis].toFixed(3), v3: q.extent[axis].toFixed(3) });
993
+ for (const name of new Set([...v2.keys(), ...v3.keys()])) {
994
+ const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
995
+ if (a.length !== b.length) {
996
+ differences.push({ part: name, scale, state: state.label, what: 'copies', v2: String(a.length), v3: String(b.length) });
997
+ continue;
586
998
  }
587
- const turn = rotationAngleDeg(p.rotation, q.rotation);
588
- if (turn > SAME_TOLERANCE_DEG)
589
- differences.push({ part: name, scale, what: 'rotation', v2: JSON.stringify(p.rotation.map(r => r.map(v => +v.toFixed(4)))), v3: `${turn.toFixed(3)}° apart` });
999
+ for (const [i, p] of a.entries()) {
1000
+ const q = b[i];
1001
+ boxesCompared++;
1002
+ const tag = a.length > 1 ? `[${i}]` : '';
1003
+ for (const axis of AXES)
1004
+ if (Math.abs(p.centre[axis] - q.centre[axis]) > SAME_TOLERANCE_MM)
1005
+ differences.push({ part: name, scale, state: state.label, what: `centre.${axis}${tag}`, v2: p.centre[axis].toFixed(3), v3: q.centre[axis].toFixed(3) });
1006
+ for (const [i, d] of p.dims.entries())
1007
+ if (Math.abs(d - q.dims[i]) > SAME_TOLERANCE_MM)
1008
+ differences.push({ part: name, scale, state: state.label, what: `dims.${AXES[i]}${tag}`, v2: d.toFixed(3), v3: q.dims[i].toFixed(3) });
1009
+ const turn = rotationAngleDeg(p.rotation, q.rotation);
1010
+ if (turn > SAME_TOLERANCE_DEG)
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
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ }
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
+ };
1029
+ }
1030
+ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
1031
+ /**
1032
+ * Motion, per docs/v3-motion-contract.md. State inputs stand for V2 parameters (name, unit, range, default kept)
1033
+ * and for what clips drive; joints become `attach@1(T·turn·T⁻¹)` chains parents first; part channels become a
1034
+ * slide along an asset axis (times the part's world factor, as V2 shifted it) or a spin about the part's own axis.
1035
+ * What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
1036
+ */
1037
+ function planMotion(c) {
1038
+ const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
1039
+ const params = src.parameters ?? [];
1040
+ const clips = src.animations ?? [];
1041
+ const joints = src.joints ?? [];
1042
+ if (!params.length && !clips.length && !joints.length)
1043
+ return;
1044
+ const partsByName = new Map(src.parts.map(p => [p.name, p]));
1045
+ const jointsByName = new Map(joints.map(j => [j.name, j]));
1046
+ // State inputs for parameters.
1047
+ const paramUnit = new Map();
1048
+ for (const p of params) {
1049
+ const unit = V2_UNIT[p.range.unit];
1050
+ if (!unit) {
1051
+ refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
1052
+ continue;
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
+ }
1060
+ g.input(p.name, unit, p.range.min, p.range.max, 'state');
1061
+ asset.stateDefaults[p.name] = p.default ?? p.range.min;
1062
+ paramUnit.set(p.name, unit);
1063
+ }
1064
+ /** q = v0 + (p - min)·(v1 - v0)/(max - min), written in the output unit. Null when the channel does not move. */
1065
+ const affine = (p, v0, v1, outUnit, hint) => {
1066
+ if (v0 === v1)
1067
+ return null;
1068
+ const pUnit = paramUnit.get(p.name);
1069
+ if (!pUnit)
1070
+ return undefined;
1071
+ const span = p.range.max - p.range.min;
1072
+ let slopeUnit;
1073
+ let slope;
1074
+ if (pUnit === outUnit) {
1075
+ slopeUnit = 'ratio';
1076
+ slope = (v1 - v0) / span;
1077
+ }
1078
+ else if (pUnit === 'percent') {
1079
+ // A percent input reaches the kernel as a ratio (×0.01), so the slope is per ratio.
1080
+ slopeUnit = outUnit;
1081
+ slope = ((v1 - v0) * 100) / span;
1082
+ }
1083
+ else {
1084
+ refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot write a ${outUnit} value here` });
1085
+ return undefined;
1086
+ }
1087
+ const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
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`));
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
+ };
1105
+ const twoKeyLinear = (ch, owner) => {
1106
+ const keys = ch.keys;
1107
+ if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
1108
+ refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: only two linear keys at 0 and 1 are written into the graph` });
1109
+ return false;
1110
+ }
1111
+ return true;
1112
+ };
1113
+ const varying = (keys) => AXES.filter(a => keys.some(k => k.value[a] !== keys[0].value[a]));
1114
+ // Which parameter drives each joint; refuse two writers.
1115
+ const jointWriter = new Map();
1116
+ const partWriters = new Map(); // `${part}/${path}/${axis}` → owners
1117
+ const claim = (key, owner, part) => {
1118
+ const owners = partWriters.get(key) ?? new Set();
1119
+ if (owners.size && !owners.has(owner)) {
1120
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part, detail: `${[...owners].join(', ')} and ${owner} both write ${key}; one writer per state` });
1121
+ return false;
1122
+ }
1123
+ owners.add(owner);
1124
+ partWriters.set(key, owners);
1125
+ return true;
1126
+ };
1127
+ for (const p of params) {
1128
+ if (!paramUnit.has(p.name))
1129
+ continue;
1130
+ for (const ch of p.clip.channels) {
1131
+ if (ch.path === undefined) {
1132
+ if (!jointsByName.has(ch.target)) {
1133
+ refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a joint` });
1134
+ continue;
1135
+ }
1136
+ if (!twoKeyLinear(ch, p.name))
1137
+ continue;
1138
+ if (jointWriter.has(ch.target)) {
1139
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: `${jointWriter.get(ch.target).p.name} and ${p.name} both drive the joint` });
1140
+ continue;
1141
+ }
1142
+ const keys = ch.keys;
1143
+ jointWriter.set(ch.target, { p, v0: keys[0].value, v1: keys[1].value });
1144
+ continue;
1145
+ }
1146
+ const part = partsByName.get(ch.target);
1147
+ if (!part) {
1148
+ refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a part` });
1149
+ continue;
1150
+ }
1151
+ if (!info.has(ch.target))
1152
+ continue; // the part itself was refused above, and that refusal already names it
1153
+ if (ch.pivot) {
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' });
1155
+ continue;
1156
+ }
1157
+ const keys = ch.keys;
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
+ }
1192
+ if (axes.length === 0) {
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` });
1194
+ continue;
1195
+ }
1196
+ if (axes.length > 1) {
1197
+ refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
1198
+ continue;
1199
+ }
1200
+ if (!twoKeyLinear(ch, p.name))
1201
+ continue;
1202
+ const axis = axes[0];
1203
+ if (!claim(`${ch.target}/${ch.path}/${axis}`, p.name, ch.target))
1204
+ continue;
1205
+ const q = affine(p, keys[0].value[axis], keys[1].value[axis], ch.path === 'rotation' ? 'deg' : 'mm', `${p.name}.${ch.target}.${ch.path}`);
1206
+ if (q === undefined)
1207
+ continue;
1208
+ if (q === null)
1209
+ continue;
1210
+ if (ch.path === 'rotation') {
1211
+ if (spinOf.has(ch.target)) {
1212
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
1213
+ continue;
1214
+ }
1215
+ spinOf.set(ch.target, { axis, q });
1216
+ }
1217
+ else {
1218
+ // V2 shifts a part in the figure frame by the part's world factor along that axis.
1219
+ const f = info.get(ch.target).factor[axis];
1220
+ const scaled = f.kind === 'one' ? q : g.mul(`${p.name}.${ch.target}.shift.${axis}`, q, f.ref);
1221
+ (shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
1222
+ }
1223
+ }
1224
+ }
1225
+ // Clips: a driver per channel, writing a state input of its own.
1226
+ const drivers = [];
1227
+ for (const clip of clips) {
1228
+ const duration = v2ClipDuration(clip);
1229
+ if (!(duration > 0)) {
1230
+ notes.push({ code: 'MOTION_STATIC_CLIP', detail: `animation "${clip.name}" has no duration; nothing to write` });
1231
+ continue;
1232
+ }
1233
+ for (const ch of clip.channels) {
1234
+ const owner = `clip ${clip.name}`;
1235
+ if (ch.path === undefined) {
1236
+ refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} drives a joint directly; joints are driven by parameters in this contract` });
1237
+ continue;
1238
+ }
1239
+ if (!partsByName.has(ch.target)) {
1240
+ refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} targets a part that does not exist` });
1241
+ continue;
1242
+ }
1243
+ if (!info.has(ch.target))
1244
+ continue; // refused above under its own name
1245
+ if (ch.path === 'scale') {
1246
+ refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} scales the part; a scale channel is shape deformation, not covered` });
1247
+ continue;
1248
+ }
1249
+ if (ch.pivot) {
1250
+ refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered' });
1251
+ continue;
1252
+ }
1253
+ const keys = ch.keys;
1254
+ const axes = varying(keys);
1255
+ if (axes.length === 0) {
1256
+ notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `${owner} has a ${ch.path} channel whose keys do not change; nothing to write` });
1257
+ continue;
1258
+ }
1259
+ if (axes.length > 1) {
1260
+ refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
1261
+ continue;
1262
+ }
1263
+ const axis = axes[0];
1264
+ if (!claim(`${ch.target}/${ch.path}/${axis}`, owner, ch.target))
1265
+ continue;
1266
+ const values = keys.map(k => k.value[axis]);
1267
+ const rotation = ch.path === 'rotation';
1268
+ const stateId = rotation ? `${ch.target}.spin` : `${ch.target}.shift.${axis}`;
1269
+ const min = Math.min(...values), max = Math.max(...values);
1270
+ g.input(stateId, rotation ? 'deg' : 'mm', rotation ? -180 : min, rotation ? 180 : max, 'state');
1271
+ asset.stateDefaults[stateId] = rotation ? normalise180(values[0]) : values[0];
1272
+ drivers.push({
1273
+ id: `${clip.name}/${ch.target}/${ch.path}`,
1274
+ state: stateId,
1275
+ time: { unit: 's', duration },
1276
+ keys: keys.map(k => ({ at: round6(k.at / duration), value: k.value[axis] })),
1277
+ interpolation: ch.interpolation ?? 'linear',
1278
+ loop: 'wrap',
1279
+ accumulates: rotation
1280
+ });
1281
+ notes.push({ code: 'CLIP_TIME', detail: `animation "${clip.name}": V2 keys in seconds, last at ${duration} s; written as duration ${duration} s with fractional keys` });
1282
+ if (rotation) {
1283
+ if (spinOf.has(ch.target)) {
1284
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
1285
+ continue;
1286
+ }
1287
+ spinOf.set(ch.target, { axis, q: stateId });
1288
+ }
1289
+ else {
1290
+ const f = info.get(ch.target).factor[axis];
1291
+ const scaled = f.kind === 'one' ? stateId : g.mul(`${clip.name}.${ch.target}.shift.${axis}`, stateId, f.ref);
1292
+ (shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
590
1293
  }
591
1294
  }
592
1295
  }
593
- return { same: differences.length === 0, scales, boxesCompared, differences };
1296
+ if (drivers.length)
1297
+ asset.drivers = drivers;
1298
+ // Joints, parents first.
1299
+ const carrierOf = (name) => {
1300
+ for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
1301
+ const j = joints.find(x => x.child === p.name);
1302
+ if (j)
1303
+ return j;
1304
+ }
1305
+ return undefined;
1306
+ };
1307
+ const depth = (j) => {
1308
+ const child = partsByName.get(j.child);
1309
+ const above = child?.parent ? carrierOf(child.parent) : undefined;
1310
+ return above ? 1 + depth(above) : 0;
1311
+ };
1312
+ for (const j of [...joints].sort((a, b) => depth(a) - depth(b))) {
1313
+ const child = partsByName.get(j.child);
1314
+ if (!child || !info.has(j.child)) {
1315
+ refusals.push({ code: 'JOINT_CHILD', part: j.name, detail: `child "${j.child}" was not converted` });
1316
+ continue;
1317
+ }
1318
+ if (info.get(j.child).repeat) {
1319
+ refusals.push({ code: 'MOTION_REPEAT', part: j.child, detail: 'a repeated part cannot be a joint child in this contract' });
1320
+ continue;
1321
+ }
1322
+ // The state input: the driving parameter's affine, or the joint's own input when nothing drives it.
1323
+ let q;
1324
+ const writer = jointWriter.get(j.name);
1325
+ const limits = j.limits;
1326
+ if (writer) {
1327
+ const r = affine(writer.p, writer.v0, writer.v1, j.type === 'prismatic' ? 'mm' : 'deg', `${writer.p.name}.${j.name}`);
1328
+ if (r === undefined)
1329
+ continue;
1330
+ if (r === null) {
1331
+ notes.push({ code: 'MOTION_STATIC_CHANNEL', part: j.name, detail: `parameter ${writer.p.name} does not change the joint; it stays at 0` });
1332
+ q = g.constant(0, j.type === 'prismatic' ? 'mm' : 'deg', 'zeroQ');
1333
+ }
1334
+ else
1335
+ q = r;
1336
+ }
1337
+ else {
1338
+ const [min, max] = limits ? [limits.min, limits.max] : j.type === 'continuous' ? [-180, 180] : [-1e6, 1e6];
1339
+ q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
1340
+ asset.stateDefaults[j.name] = 0;
1341
+ }
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` });
1346
+ if (j.type === 'prismatic')
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' });
1348
+ // Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
1349
+ const attach = child.parent ? partsByName.get(child.parent) : undefined;
1350
+ const originC = { ...j.origin, y: j.origin.y - base.y / 2 };
1351
+ const originRef = [];
1352
+ for (const a of AXES) {
1353
+ let expr;
1354
+ if (attach && info.has(attach.name)) {
1355
+ const ai = info.get(attach.name);
1356
+ const ac = centredPart(attach, base).transform.position;
1357
+ const offset = c.times(`${j.name}.origin.${a}.offset`, { kind: 'const', value: round6(originC[a] - ac[a]) }, ai.factor[a]);
1358
+ expr = { kind: 'ref', ref: g.add(`${j.name}.origin.${a}`, g.len(ai.centre[a], `${j.name}.origin.${a}.seat`), g.len(offset, `${j.name}.origin.${a}.off`)) };
1359
+ }
1360
+ else {
1361
+ // No carrying part: only the instance scale moves the origin (V2 keeps the rest position).
1362
+ const v = a === 'y' ? originC.y + base.y / 2 : originC[a];
1363
+ expr = v === 0 ? { kind: 'const', value: 0 } : { kind: 'ref', ref: g.mul(`${j.name}.origin.${a}`, size[a], ratio(v / base[a], `${j.name}.origin.${a}.at`)) };
1364
+ }
1365
+ originRef.push(g.len(expr, `${j.name}.origin.${a}`));
1366
+ }
1367
+ const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
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}`));
1370
+ const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
1371
+ const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
1372
+ const parentJoint = attach ? carrierOf(attach.name) : undefined;
1373
+ const restFrame = 'asset.rest';
1374
+ const toFrame = parentJoint ? restFrame : 'asset';
1375
+ const A = g.node(`${j.name}.frameOnParent`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onParent, to: toFrame });
1376
+ const B = g.node(`${j.name}.frameOnChild`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onChild, to: restFrame });
1377
+ const O = g.node(`${j.name}.motion`, j.type === 'prismatic' ? 'axis-slide@1' : 'axis-turn@1', [...axisRefs, q], 'pose', { from: onChild, to: onParent });
1378
+ let M = g.node(`${j.name}.attach`, 'attach@1', [A, O, B], 'pose');
1379
+ if (parentJoint)
1380
+ M = g.node(`${j.name}.chain`, 'compose@1', [motionOf.get(parentJoint.name), M], 'pose');
1381
+ motionOf.set(j.name, M);
1382
+ }
1383
+ }
1384
+ function normalise180(deg) {
1385
+ const w = ((((deg + 180) % 360) + 360) % 360) - 180;
1386
+ return w === 180 ? -180 : w;
594
1387
  }
595
1388
  //# sourceMappingURL=v3-from-v2.js.map