@hatiolab/figure-model 0.1.33 → 0.1.34

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.
@@ -11,13 +11,14 @@
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
+ * - joints and parameters, until `docs/v3-motion-contract.md` is implemented
18
18
  *
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.
19
+ * What it normalises on purpose it lists in `notes` with the value before and after: a corner radius V2 clamped
20
+ * when drawing, a polygon path recentred with its placement moved by the same amount, the palette it snapshotted.
21
+ * What it converts but does not carry, it lists in `lost`. Nothing is dropped without being named in one of the two.
21
22
  *
22
23
  * `compareV2WithV3` is the definition of "the same" from ADR-0087 decision 2: same part list, each
23
24
  * part's centre, world extents and rotation within tolerance, same material, at the base size and at
@@ -31,6 +32,16 @@ import { centredPart } from "./origin.js";
31
32
  import { rotatedExtentOf } from "./blueprint.js";
32
33
  import { anchorOf, clearance, longAxis, repeatOffset, repeatPlan, sizingPosition, sizingScale } from "./sizing.js";
33
34
  import { AXES, REPEAT_LIMIT, SEGMENT_PRESETS } from "./types.js";
35
+ import { driverValue } from "./v3-driver.js";
36
+ import { jointOriginPosition } from "./sizing.js";
37
+ /** A short stable fingerprint of the palette, so a report can say which colours it snapshotted. */
38
+ export function paletteHash(palette) {
39
+ const text = JSON.stringify(Object.entries(palette).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
40
+ let h = 5381;
41
+ for (let i = 0; i < text.length; i++)
42
+ h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
43
+ return h.toString(16).padStart(8, '0');
44
+ }
34
45
  /** The V3 design inputs that stand for the instance size, in mm. */
35
46
  export const V3_SIZE_INPUTS = Object.freeze({ x: 'size.x', y: 'size.y', z: 'size.z' });
36
47
  /** How far a V2 instance may grow in the converted asset, as a multiple of the base box. */
@@ -153,23 +164,20 @@ function materialOf(part, palette) {
153
164
  export function convertV2ToV3(source, options) {
154
165
  const lost = [];
155
166
  const refusals = [];
167
+ const notes = [];
156
168
  const checked = validate(source);
157
169
  if (checked.errors.length) {
158
170
  return {
159
171
  status: 'refused',
160
172
  refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
161
- lost
173
+ lost,
174
+ notes
162
175
  };
163
176
  }
164
177
  const src = source;
165
178
  const base = src.base;
166
179
  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` });
180
+ notes.push({ code: 'PALETTE', detail: `colours snapshotted from palette ${options.paletteId ?? `#${paletteHash(palette)}`}` });
173
181
  if (src.detailLevel)
174
182
  lost.push({ code: 'DETAIL_LEVEL', detail: `detailLevel "${src.detailLevel}" has no V3 field` });
175
183
  if (src.styleKit)
@@ -242,6 +250,27 @@ export function convertV2ToV3(source, options) {
242
250
  };
243
251
  const plans = [];
244
252
  const seen = new Set();
253
+ // Motion (docs/v3-motion-contract.md), planned after the parts and read when the part nodes are written.
254
+ const info = new Map();
255
+ const spinOf = new Map();
256
+ const shiftsOf = new Map();
257
+ const frameOf = new Map(); // part → the joint whose frame carries it
258
+ const motionOf = new Map(); // joint → pose asset.rest → asset
259
+ const partsByName = new Map(parts.map(cp => [cp.name, cp]));
260
+ const jointByChild = new Map((src.joints ?? []).map(j => [j.child, j]));
261
+ const carrierJointOf = (name) => {
262
+ for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
263
+ const j = jointByChild.get(p.name);
264
+ if (j)
265
+ return j.name;
266
+ }
267
+ return undefined;
268
+ };
269
+ for (const cp of parts) {
270
+ const carrier = carrierJointOf(cp.name);
271
+ if (carrier)
272
+ frameOf.set(cp.name, carrier);
273
+ }
245
274
  for (const cp of parts) {
246
275
  const name = cp.name;
247
276
  if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(name)) {
@@ -253,8 +282,8 @@ export function convertV2ToV3(source, options) {
253
282
  continue;
254
283
  }
255
284
  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` });
285
+ if (cp.parent && !frameOf.has(name))
286
+ 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
287
  if (cp.label)
259
288
  lost.push({ code: 'LABEL', part: name, detail: 'label has no V3 field' });
260
289
  if (cp.materialSlot)
@@ -276,28 +305,37 @@ export function convertV2ToV3(source, options) {
276
305
  refusals.push({ code: 'NO_PROVIDER', part: name, detail: `V3 has no hollow ${cp.primitive}` });
277
306
  continue;
278
307
  }
279
- const path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
308
+ // A polygon's path is drawn as written, so its bounding box may sit off the part centre. V3 keeps the visible
309
+ // position: the path is recentred and the placement moves by the same amount (ADR-0087 ruling 3).
310
+ let path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
311
+ const recentre = { x: 0, z: 0 };
280
312
  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
313
  const xs = path.map(q => q.x), zs = path.map(q => q.y);
284
314
  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) {
315
+ if (path.length < 3 || Math.abs(spanX - cp.transform.size.x) > 1e-6 || Math.abs(spanZ - cp.transform.size.z) > 1e-6) {
316
+ // V2 sizing reads `size` while the drawing reads the path; when they disagree there is no one truth to keep.
287
317
  refusals.push({
288
- code: 'POLYGON_OFF_CENTRE',
318
+ code: 'POLYGON_SIZE_MISMATCH',
289
319
  part: name,
290
- detail: `path spans ${spanX}×${spanZ} about (${midX}, ${midZ}); size says ${cp.transform.size.x}×${cp.transform.size.z} about the centre`
320
+ detail: `path spans ${spanX}×${spanZ}; size says ${cp.transform.size.x}×${cp.transform.size.z}`
291
321
  });
292
322
  continue;
293
323
  }
324
+ recentre.x = round6((Math.max(...xs) + Math.min(...xs)) / 2);
325
+ recentre.z = round6((Math.max(...zs) + Math.min(...zs)) / 2);
326
+ if (recentre.x !== 0 || recentre.z !== 0) {
327
+ path = path.map(q => ({ x: round6(q.x - recentre.x), y: round6(q.y - recentre.z) }));
328
+ notes.push({
329
+ code: 'POLYGON_RECENTRED',
330
+ part: name,
331
+ 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`
332
+ });
333
+ }
294
334
  if (cp.shape?.round)
295
335
  lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
296
336
  }
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
- }
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;
301
339
  const segments = cp.segments ?? SEG_DEFAULT;
302
340
  if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
303
341
  refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
@@ -341,20 +379,13 @@ export function convertV2ToV3(source, options) {
341
379
  pending.push(a); // aspect-*: follows another axis, settled below
342
380
  }
343
381
  }
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]);
348
- }
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
382
  // A curved part keeps a round section: both cross axes take the geometric mean of their two factors
352
383
  // (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
353
- // it is a `geomean@1` node.
384
+ // it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
354
385
  if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && cp.keepRound !== false) {
355
386
  const spin = cp.primitive === 'sphere' ? null : longAxis(subject);
356
387
  const across = AXES.filter(a => a !== spin);
357
- const fs = across.map(a => factor[a]);
388
+ 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
389
  const same = fs.every(f => f.kind === 'one') || fs.every(f => f.kind === 'ref' && f.sig === fs[0].sig);
359
390
  if (!same) {
360
391
  const asRef = (f) => (f.kind === 'one' ? ratio(1, 'one') : f.ref);
@@ -363,8 +394,42 @@ export function convertV2ToV3(source, options) {
363
394
  factor[a] = even;
364
395
  }
365
396
  }
397
+ for (const a of pending) {
398
+ const named = rules[a].slice('aspect-'.length);
399
+ factor[a] = factor[named];
400
+ centre[a] = times(`${name}.${a}.centre`, { kind: 'const', value: cp.transform.position[a] }, factor[named]);
401
+ }
402
+ // The V3 asset frame has its origin at the bottom of the box (ADR-0065); V2 sizing works from the centre.
403
+ 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'))) };
404
+ // A recentred polygon moves back by R·c, and that offset follows the world factor of each axis as the drawn
405
+ // mesh did in V2 (rotation baked, then scaled per world axis).
406
+ if (recentre.x !== 0 || recentre.z !== 0) {
407
+ const R = eulerXYZ(cp.transform.rotation);
408
+ for (const [a, axis] of AXES.entries()) {
409
+ const k = round6(R[a][0] * recentre.x + R[a][2] * recentre.z);
410
+ if (k === 0)
411
+ continue;
412
+ const shift = times(`${name}.${axis}.recentre`, { kind: 'const', value: k }, factor[axis]);
413
+ centre[axis] = { kind: 'ref', ref: g.add(`${name}.${axis}.drawnAt`, g.len(centre[axis], `${name}.${axis}.declared`), g.len(shift, `${name}.${axis}.shift`)) };
414
+ }
415
+ }
366
416
  // Local dimensions scale by the factor of the world axis each local axis lands on.
367
417
  const perm = axisPermutation(cp.transform.rotation);
418
+ // A cylinder that does not keep its section round goes elliptical under a lopsided instance in V2. V3 cylinders
419
+ // and frustums have one radius per end (ruling 6: per-axis radii are a shape-definition change, not made here).
420
+ if (cp.primitive === 'cylinder' && cp.keepRound === false && perm) {
421
+ const spin = longAxis(subject);
422
+ const across = AXES.filter(a => a !== spin).map(a => factor[a]);
423
+ const same = across.every(f => f.kind === 'one') || across.every(f => f.kind === 'ref' && f.sig === across[0].sig);
424
+ if (!same) {
425
+ refusals.push({
426
+ code: 'ELLIPTIC_SECTION',
427
+ part: name,
428
+ detail: `keepRound is off and the section follows ${AXES.filter(a => a !== spin).map(a => `${a}:${rules[a]}`).join(' and ')}; V2 draws an ellipse, V3 has one radius per end`
429
+ });
430
+ continue;
431
+ }
432
+ }
368
433
  const follows = AXES.some(a => factor[a].kind !== 'one');
369
434
  if (!perm && follows) {
370
435
  refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
@@ -372,18 +437,28 @@ export function convertV2ToV3(source, options) {
372
437
  }
373
438
  const dim = (local) => times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE);
374
439
  const localFrame = `${name}.local`;
440
+ // A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
441
+ const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
375
442
  const rot = cp.transform.rotation;
376
443
  const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
377
444
  let shape;
378
- if (cp.primitive === 'cylinder') {
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') {
379
454
  const radius = times(`${name}.dim.radius`, { kind: 'const', value: cp.transform.size.x / 2 }, perm ? factor[perm.x] : ONE);
380
455
  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 });
456
+ shape = () => g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radius, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() });
382
457
  }
383
458
  else if (cp.primitive === 'sphere') {
384
459
  const r = (local) => times(`${name}.dim.radius${local.toUpperCase()}`, { kind: 'const', value: cp.transform.size[local] / 2 }, perm ? factor[perm[local]] : ONE);
385
460
  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 });
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() });
387
462
  }
388
463
  else if (cp.primitive === 'polygon') {
389
464
  const h = dim('y');
@@ -392,10 +467,14 @@ export function convertV2ToV3(source, options) {
392
467
  times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx),
393
468
  times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz)
394
469
  ]);
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 });
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() });
396
471
  }
397
472
  else if (cp.primitive === 'rect') {
398
- const round = cp.shape?.round ?? 0;
473
+ // V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
474
+ const declared = cp.shape?.round ?? 0;
475
+ const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
476
+ if (round < declared)
477
+ 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)` });
399
478
  if (round > 0 && (factor.x.kind !== 'one' || factor.z.kind !== 'one'))
400
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` });
401
480
  const [w, h, d] = [dim('x'), dim('y'), dim('z')];
@@ -405,24 +484,42 @@ export function convertV2ToV3(source, options) {
405
484
  const wallX = times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx);
406
485
  const wallZ = times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz);
407
486
  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 });
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() });
409
488
  }
410
489
  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 });
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() });
412
491
  }
413
492
  else {
414
493
  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 });
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() });
416
495
  }
417
496
  const appearance = { target: name, material: material };
418
497
  if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
419
498
  appearance.segments = segments;
499
+ info.set(name, { centre, factor, repeat: !!repeat });
420
500
  if (!repeat) {
421
501
  plans.push({
422
502
  appearance,
423
503
  nodes: () => {
424
504
  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' });
505
+ const shifts = shiftsOf.get(name) ?? [];
506
+ const frame = frameOf.get(name);
507
+ const finalTo = frame ? 'asset.rest' : 'asset';
508
+ // rest: local → F0; each slide F_k → F_k+1 along an asset axis; the last lands in finalTo.
509
+ 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 });
510
+ shifts.forEach((shift, k) => {
511
+ const axis = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
512
+ 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}` });
513
+ pose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, pose], 'pose');
514
+ });
515
+ if (frame)
516
+ pose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), pose], 'pose');
517
+ const spin = spinOf.get(name);
518
+ if (spin) {
519
+ const axis = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
520
+ const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axis, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
521
+ pose = g.node(`${name}.spinning`, 'compose@1', [pose, turn], 'pose');
522
+ }
426
523
  g.node(name, 'place@1', [s, pose], 'placed');
427
524
  }
428
525
  });
@@ -437,7 +534,14 @@ export function convertV2ToV3(source, options) {
437
534
  appearance,
438
535
  nodes: () => {
439
536
  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 });
537
+ 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 });
538
+ // A spinning repeated part (the conveyor's rollers) turns each copy about its own axis: the turn goes on the member.
539
+ const spin = spinOf.get(name);
540
+ if (spin) {
541
+ const axisRefs = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
542
+ const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axisRefs, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
543
+ memberPose = g.node(`${name}.spinning`, 'compose@1', [memberPose, turn], 'pose');
544
+ }
441
545
  const member = g.node(name, 'member@1', [s, memberPose], 'placed');
442
546
  const assembly = g.node(`${name}.assembly`, 'assembly@1', [member], 'assembly');
443
547
  let layout;
@@ -459,13 +563,25 @@ export function convertV2ToV3(source, options) {
459
563
  const halfRow = times(`${name}.halfRow`, { kind: 'const', value: -((count - 1) * repeat.pitch) / 2 }, factor[axis]);
460
564
  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
565
  }
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' });
566
+ // The row as a whole shifts with a translation channel and moves with the joint frame that carries it.
567
+ const shifts = shiftsOf.get(name) ?? [];
568
+ const frame = frameOf.get(name);
569
+ const finalTo = frame ? 'asset.rest' : 'asset';
570
+ 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 });
571
+ shifts.forEach((shift, k) => {
572
+ const axisRefs = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
573
+ 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}` });
574
+ rootPose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, rootPose], 'pose');
575
+ });
576
+ if (frame)
577
+ rootPose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), rootPose], 'pose');
463
578
  g.node(`${name}.repeat`, 'repeat@1', [layout, assembly, rootPose], 'collection', { axis });
464
579
  }
465
580
  });
466
581
  }
582
+ planMotion({ src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio, times, factorOfScale });
467
583
  if (refusals.length)
468
- return { status: 'refused', refusals, lost };
584
+ return { status: 'refused', refusals, lost, notes };
469
585
  for (const plan of plans) {
470
586
  plan.nodes();
471
587
  asset.appearance.push(plan.appearance);
@@ -475,47 +591,227 @@ export function convertV2ToV3(source, options) {
475
591
  }
476
592
  catch (e) {
477
593
  const err = e;
478
- return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost };
594
+ return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, notes };
479
595
  }
480
- return { status: 'converted', asset, lost };
596
+ return { status: 'converted', asset, lost, notes };
481
597
  }
482
598
  /** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
483
599
  function withSizing(cp) {
484
600
  return { ...cp, sizing: cp.sizing ?? 'scale' };
485
601
  }
486
- /** Where V2 draws every part (and copy) at an instance scale, as world boxes. */
487
- export function v2WorldBoxes(source, scale) {
602
+ const I3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
603
+ 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]));
604
+ const mv3 = (a, v) => a.map(r => r[0] * v[0] + r[1] * v[1] + r[2] * v[2]);
605
+ const tr3 = (a) => [0, 1, 2].map(i => [0, 1, 2].map(j => a[j][i]));
606
+ /** Rotation about a unit axis by degrees (Rodrigues), the matrix three's setFromAxisAngle builds. */
607
+ export function axisAngle(axis, deg) {
608
+ const n = Math.hypot(axis.x, axis.y, axis.z);
609
+ const [x, y, z] = [axis.x / n, axis.y / n, axis.z / n];
610
+ const q = (deg * Math.PI) / 180, c = Math.cos(q), s = Math.sin(q), k = 1 - c;
611
+ return [
612
+ [c + x * x * k, x * y * k - z * s, x * z * k + y * s],
613
+ [y * x * k + z * s, c + y * y * k, y * z * k - x * s],
614
+ [z * x * k - y * s, z * y * k + x * s, c + z * z * k]
615
+ ];
616
+ }
617
+ /** V2's channel sampling (`things-scene` sampleChannel): hold the ends, `step` keeps the earlier key. */
618
+ function sampleV2(channel, at) {
619
+ const keys = channel.keys;
620
+ if (at <= keys[0].at)
621
+ return keys[0].value;
622
+ const last = keys[keys.length - 1];
623
+ if (at >= last.at)
624
+ return last.value;
625
+ let i = 0;
626
+ while (i < keys.length - 2 && keys[i + 1].at <= at)
627
+ i++;
628
+ const from = keys[i], to = keys[i + 1];
629
+ if (channel.interpolation === 'step')
630
+ return from.value;
631
+ const span = to.at - from.at;
632
+ const r = span > 0 ? (at - from.at) / span : 0;
633
+ if (typeof from.value === 'number')
634
+ return (from.value + r * (to.value - from.value));
635
+ const f = from.value, t = to.value;
636
+ 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) };
637
+ }
638
+ /** V2's clip length: the last key time across its channels, in seconds. */
639
+ export function v2ClipDuration(clip) {
640
+ let last = 0;
641
+ for (const ch of clip.channels) {
642
+ const end = ch.keys[ch.keys.length - 1]?.at;
643
+ if (typeof end === 'number' && end > last)
644
+ last = end;
645
+ }
646
+ return last;
647
+ }
648
+ /**
649
+ * Where V2 draws every part (and copy) at an instance scale and a motion state, as oriented world boxes.
650
+ * Motion follows things-scene: a translation channel shifts the part in the figure frame by the part's world
651
+ * factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
652
+ * everything in its frame about its sized origin, parents first (ADR-0066).
653
+ */
654
+ export function v2WorldBoxes(source, scale, state = {}) {
488
655
  const base = source.base;
489
656
  const out = [];
657
+ const shifts = new Map();
658
+ const turns = new Map();
659
+ const jointValues = new Map();
660
+ const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
661
+ const apply = (channels, at) => {
662
+ for (const ch of channels) {
663
+ if (ch.path === undefined) {
664
+ jointValues.set(ch.target, (jointValues.get(ch.target) ?? 0) + sampleV2(ch, at));
665
+ continue;
666
+ }
667
+ const v = sampleV2(ch, at);
668
+ if (ch.path === 'translation') {
669
+ const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
670
+ shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
671
+ }
672
+ else if (ch.path === 'rotation') {
673
+ const Rp = rotationOf.get(ch.target) ?? I3;
674
+ const Rch = eulerXYZ(v);
675
+ const wrapped = mm3(mm3(Rp, Rch), tr3(Rp));
676
+ turns.set(ch.target, mm3(turns.get(ch.target) ?? I3, wrapped));
677
+ }
678
+ }
679
+ };
680
+ for (const p of source.parameters ?? []) {
681
+ const value = state.parameters?.[p.name] ?? p.default ?? p.range.min;
682
+ const u = p.range.max > p.range.min ? (value - p.range.min) / (p.range.max - p.range.min) : 0;
683
+ apply(p.clip.channels, u);
684
+ }
685
+ for (const clip of source.animations ?? []) {
686
+ const duration = v2ClipDuration(clip);
687
+ const t = state.clipTime?.[clip.name] ?? 0;
688
+ apply(clip.channels, duration > 0 ? t % duration : 0);
689
+ }
690
+ // Joint frames: world transform per joint, parents first.
691
+ const partsByName = new Map(source.parts.map(p => [p.name, p]));
692
+ const jointByChild = new Map((source.joints ?? []).map(j => [j.child, j]));
693
+ const carrierOf = (name) => {
694
+ for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
695
+ const j = jointByChild.get(p.name);
696
+ if (j)
697
+ return j;
698
+ }
699
+ return undefined;
700
+ };
701
+ const worldOf = new Map();
702
+ const worldJoint = (j) => {
703
+ const known = worldOf.get(j.name);
704
+ if (known)
705
+ return known;
706
+ const child = partsByName.get(j.child);
707
+ const attach = child.parent ? partsByName.get(child.parent) : undefined;
708
+ const cj = { origin: { ...j.origin, y: j.origin.y - base.y / 2 } };
709
+ const at = jointOriginPosition(cj, attach ? withSizing(centredPart(attach, base)) : undefined, scale, base);
710
+ const o = [at.x * scale.x, at.y * scale.y + (base.y * scale.y) / 2, at.z * scale.z];
711
+ const limits = j.limits;
712
+ let q = jointValues.get(j.name) ?? 0;
713
+ if (limits)
714
+ q = Math.min(Math.max(q, limits.min), limits.max);
715
+ let local;
716
+ if (j.type === 'prismatic') {
717
+ const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
718
+ 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] };
719
+ }
720
+ else {
721
+ const R = axisAngle(j.axis, q);
722
+ const Ro = mv3(R, o);
723
+ local = { r: R, t: [o[0] - Ro[0], o[1] - Ro[1], o[2] - Ro[2]] };
724
+ }
725
+ const parentJoint = attach ? carrierOf(attach.name) : undefined;
726
+ const world = parentJoint ? composeRT(worldJoint(parentJoint), local) : local;
727
+ worldOf.set(j.name, world);
728
+ return world;
729
+ };
490
730
  for (const part of source.parts) {
491
731
  const cp = withSizing(centredPart(part, base));
492
732
  const at = sizingPosition(cp, scale, base);
493
733
  const sized = sizingScale(cp, scale, base);
494
- const extent = rotatedExtentOf(cp);
495
- const rotation = eulerXYZ(part.transform.rotation);
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
+ 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 } });
740
+ let rotation = eulerXYZ(part.transform.rotation);
496
741
  const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
497
742
  const box = { x: world('x'), y: world('y'), z: world('z') };
498
743
  box.y.centre += (base.y * scale.y) / 2;
744
+ // A polygon is drawn where its path says, which may be off the part centre: rotation baked, then scaled per world axis.
745
+ if (part.primitive === 'polygon' && part.shape?.path?.length) {
746
+ const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
747
+ const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
748
+ for (const [a, axis] of AXES.entries())
749
+ box[axis].centre += (rotation[a][0] * c[0] + rotation[a][2] * c[2]) * box[axis].factor;
750
+ }
751
+ // Local dimensions: each local axis lands on a world axis and takes that axis's factor.
752
+ const perm = axisPermutation(part.transform.rotation);
753
+ 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)
757
+ ];
758
+ // Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
759
+ const shift = shifts.get(part.name);
760
+ if (shift)
761
+ for (const a of AXES)
762
+ box[a].centre += shift[a] * box[a].factor;
763
+ const turn = turns.get(part.name);
764
+ if (turn)
765
+ rotation = mm3(turn, rotation);
766
+ // Then the joint frame that carries it.
767
+ const carrier = carrierOf(part.name);
768
+ const W = carrier ? worldJoint(carrier) : undefined;
499
769
  const plan = repeatPlan(cp, scale, base);
500
770
  const copies = plan ? plan.count : 1;
501
771
  for (let i = 0; i < copies; i++) {
502
- const centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
772
+ let centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
503
773
  if (plan)
504
774
  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 });
775
+ let R = rotation;
776
+ if (W) {
777
+ const c = mv3(W.r, [centre.x, centre.y, centre.z]);
778
+ centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
779
+ R = mm3(W.r, rotation);
780
+ }
781
+ 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 });
506
783
  }
507
784
  }
508
785
  return out;
509
786
  }
787
+ function composeRT(a, b) {
788
+ const rt = mv3(a.r, b.t);
789
+ return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
790
+ }
510
791
  /** Where a converted V3 asset draws every part at an instance size, as world boxes. */
511
- export function v3WorldBoxes(asset, size) {
792
+ export function v3WorldBoxes(asset, size, stateOverrides = {}) {
512
793
  const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
513
- const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate();
794
+ const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
514
795
  return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
515
796
  }
516
797
  /** ADR-0087 decision 2 tolerances. */
517
798
  export const SAME_TOLERANCE_MM = 1;
518
799
  export const SAME_TOLERANCE_DEG = 0.1;
800
+ /** The V3 state overrides that stand for a V2 motion state: parameters by name, drivers at their clip's time. */
801
+ export function v3StateOverrides(asset, state) {
802
+ const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]));
803
+ const out = {};
804
+ for (const [name, value] of Object.entries(state.parameters ?? {}))
805
+ if (units.has(name))
806
+ out[name] = value;
807
+ for (const d of asset.drivers ?? []) {
808
+ const clip = d.id.split('/')[0];
809
+ const t = state.clipTime?.[clip] ?? 0;
810
+ const unit = units.get(d.state);
811
+ out[d.state] = driverValue(d, t, unit === 'deg' || unit === 'rad' ? unit : 'other');
812
+ }
813
+ return out;
814
+ }
519
815
  function rotationAngleDeg(a, b) {
520
816
  // The angle of a·bᵀ. Its trace is the sum of the element-wise products of a and b.
521
817
  let trace = 0;
@@ -526,23 +822,26 @@ function rotationAngleDeg(a, b) {
526
822
  return (Math.acos(c) * 180) / Math.PI;
527
823
  }
528
824
  /**
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.
825
+ * "The same" (ADR-0087 decision 2): the V2 source and the V3 asset draw the same parts, each with the same
826
+ * centre and local dimensions within 1 mm and the same rotation within 0.1° (as a relative angle), with the same
827
+ * material, at the base size and at every combination of doubled axes, at rest and at every motion state given.
532
828
  */
533
- export function compareV2WithV3(source, asset, factor = 2) {
829
+ export function compareV2WithV3(source, asset, options = {}) {
830
+ const opts = typeof options === 'number' ? { factor: options } : options;
831
+ const factor = opts.factor ?? 2;
534
832
  const scales = [];
535
833
  for (const x of [1, factor])
536
834
  for (const y of [1, factor])
537
835
  for (const z of [1, factor])
538
836
  scales.push({ x, y, z });
837
+ const states = [{ label: 'rest' }, ...(opts.states ?? [])];
539
838
  const differences = [];
540
839
  let boxesCompared = 0;
541
840
  const materials = new Map(asset.appearance.map(a => [a.target, a]));
542
841
  for (const part of source.parts) {
543
842
  const look = materials.get(part.name);
544
843
  if (!look) {
545
- differences.push({ part: part.name, scale: scales[0], what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
844
+ differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
546
845
  continue;
547
846
  }
548
847
  const expected = { token: part.material.token };
@@ -553,43 +852,352 @@ export function compareV2WithV3(source, asset, factor = 2) {
553
852
  if (part.material.emissive)
554
853
  expected.emissive = { token: part.material.emissive.token ?? part.material.token, intensity: part.material.emissive.intensity, on: part.material.emissive.on ?? false };
555
854
  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) });
855
+ differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'material', v2: JSON.stringify(expected), v3: JSON.stringify(look.material) });
557
856
  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) });
857
+ differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'segments', v2: String(part.segments ?? SEG_DEFAULT), v3: String(look.segments) });
559
858
  }
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) });
859
+ const byPart = (boxes) => {
860
+ const m = new Map();
861
+ for (const b of boxes)
862
+ (m.get(b.part) ?? m.set(b.part, []).get(b.part)).push(b);
863
+ for (const list of m.values())
864
+ list.sort((p, q) => p.centre.x - q.centre.x || p.centre.y - q.centre.y || p.centre.z - q.centre.z);
865
+ return m;
866
+ };
867
+ for (const state of states) {
868
+ const overrides = v3StateOverrides(asset, state);
869
+ for (const scale of scales) {
870
+ 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));
873
+ for (const name of new Set([...v2.keys(), ...v3.keys()])) {
874
+ const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
875
+ if (a.length !== b.length) {
876
+ differences.push({ part: name, scale, state: state.label, what: 'copies', v2: String(a.length), v3: String(b.length) });
877
+ continue;
878
+ }
879
+ for (const [i, p] of a.entries()) {
880
+ const q = b[i];
881
+ boxesCompared++;
882
+ const tag = a.length > 1 ? `[${i}]` : '';
883
+ for (const axis of AXES)
884
+ if (Math.abs(p.centre[axis] - q.centre[axis]) > SAME_TOLERANCE_MM)
885
+ differences.push({ part: name, scale, state: state.label, what: `centre.${axis}${tag}`, v2: p.centre[axis].toFixed(3), v3: q.centre[axis].toFixed(3) });
886
+ for (const [i, d] of p.dims.entries())
887
+ if (Math.abs(d - q.dims[i]) > SAME_TOLERANCE_MM)
888
+ differences.push({ part: name, scale, state: state.label, what: `dims.${AXES[i]}${tag}`, v2: d.toFixed(3), v3: q.dims[i].toFixed(3) });
889
+ const turn = rotationAngleDeg(p.rotation, q.rotation);
890
+ if (turn > SAME_TOLERANCE_DEG)
891
+ 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` });
892
+ }
893
+ }
894
+ }
895
+ }
896
+ return { same: differences.length === 0, scales, states: states.map(s => s.label), boxesCompared, differences };
897
+ }
898
+ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
899
+ /**
900
+ * Motion, per docs/v3-motion-contract.md. State inputs stand for V2 parameters (name, unit, range, default kept)
901
+ * and for what clips drive; joints become `attach@1(T·turn·T⁻¹)` chains parents first; part channels become a
902
+ * slide along an asset axis (times the part's world factor, as V2 shifted it) or a spin about the part's own axis.
903
+ * What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
904
+ */
905
+ function planMotion(c) {
906
+ const { src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio } = c;
907
+ const params = src.parameters ?? [];
908
+ const clips = src.animations ?? [];
909
+ const joints = src.joints ?? [];
910
+ if (!params.length && !clips.length && !joints.length)
911
+ return;
912
+ const partsByName = new Map(src.parts.map(p => [p.name, p]));
913
+ const jointsByName = new Map(joints.map(j => [j.name, j]));
914
+ // State inputs for parameters.
915
+ const paramUnit = new Map();
916
+ for (const p of params) {
917
+ const unit = V2_UNIT[p.range.unit];
918
+ if (!unit) {
919
+ refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
920
+ continue;
921
+ }
922
+ g.input(p.name, unit, p.range.min, p.range.max, 'state');
923
+ asset.stateDefaults[p.name] = p.default ?? p.range.min;
924
+ paramUnit.set(p.name, unit);
925
+ }
926
+ /** q = v0 + (p - min)·(v1 - v0)/(max - min), written in the output unit. Null when the channel does not move. */
927
+ const affine = (p, v0, v1, outUnit, hint) => {
928
+ if (v0 === v1)
929
+ return null;
930
+ const pUnit = paramUnit.get(p.name);
931
+ if (!pUnit)
932
+ return undefined;
933
+ const span = p.range.max - p.range.min;
934
+ let slopeUnit;
935
+ let slope;
936
+ if (pUnit === outUnit) {
937
+ slopeUnit = 'ratio';
938
+ slope = (v1 - v0) / span;
939
+ }
940
+ else if (pUnit === 'percent') {
941
+ // A percent input reaches the kernel as a ratio (×0.01), so the slope is per ratio.
942
+ slopeUnit = outUnit;
943
+ slope = ((v1 - v0) * 100) / span;
944
+ }
945
+ else {
946
+ refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot write a ${outUnit} value here` });
947
+ return undefined;
948
+ }
949
+ const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
950
+ 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
+ };
952
+ const twoKeyLinear = (ch, owner) => {
953
+ const keys = ch.keys;
954
+ if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
955
+ 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` });
956
+ return false;
957
+ }
958
+ return true;
959
+ };
960
+ const varying = (keys) => AXES.filter(a => keys.some(k => k.value[a] !== keys[0].value[a]));
961
+ // Which parameter drives each joint; refuse two writers.
962
+ const jointWriter = new Map();
963
+ const partWriters = new Map(); // `${part}/${path}/${axis}` → owners
964
+ const claim = (key, owner, part) => {
965
+ const owners = partWriters.get(key) ?? new Set();
966
+ if (owners.size && !owners.has(owner)) {
967
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part, detail: `${[...owners].join(', ')} and ${owner} both write ${key}; one writer per state` });
968
+ return false;
969
+ }
970
+ owners.add(owner);
971
+ partWriters.set(key, owners);
972
+ return true;
973
+ };
974
+ for (const p of params) {
975
+ if (!paramUnit.has(p.name))
976
+ continue;
977
+ for (const ch of p.clip.channels) {
978
+ if (ch.path === undefined) {
979
+ if (!jointsByName.has(ch.target)) {
980
+ refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a joint` });
981
+ continue;
982
+ }
983
+ if (!twoKeyLinear(ch, p.name))
984
+ continue;
985
+ if (jointWriter.has(ch.target)) {
986
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: `${jointWriter.get(ch.target).p.name} and ${p.name} both drive the joint` });
987
+ continue;
988
+ }
989
+ const keys = ch.keys;
990
+ jointWriter.set(ch.target, { p, v0: keys[0].value, v1: keys[1].value });
991
+ continue;
992
+ }
993
+ const part = partsByName.get(ch.target);
994
+ if (!part) {
995
+ refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a part` });
996
+ continue;
997
+ }
998
+ if (!info.has(ch.target))
999
+ 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
+ if (ch.pivot) {
1005
+ 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
+ continue;
1007
+ }
1008
+ const keys = ch.keys;
1009
+ const axes = varying(keys);
1010
+ if (axes.length === 0) {
1011
+ 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
+ continue;
1013
+ }
1014
+ if (axes.length > 1) {
1015
+ refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
1016
+ continue;
1017
+ }
1018
+ if (!twoKeyLinear(ch, p.name))
1019
+ continue;
1020
+ const axis = axes[0];
1021
+ if (!claim(`${ch.target}/${ch.path}/${axis}`, p.name, ch.target))
1022
+ continue;
1023
+ const q = affine(p, keys[0].value[axis], keys[1].value[axis], ch.path === 'rotation' ? 'deg' : 'mm', `${p.name}.${ch.target}.${ch.path}`);
1024
+ if (q === undefined)
1025
+ continue;
1026
+ if (q === null)
1027
+ continue;
1028
+ if (ch.path === 'rotation') {
1029
+ if (spinOf.has(ch.target)) {
1030
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
1031
+ continue;
1032
+ }
1033
+ spinOf.set(ch.target, { axis, q });
1034
+ }
1035
+ else {
1036
+ // V2 shifts a part in the figure frame by the part's world factor along that axis.
1037
+ const f = info.get(ch.target).factor[axis];
1038
+ const scaled = f.kind === 'one' ? q : g.mul(`${p.name}.${ch.target}.shift.${axis}`, q, f.ref);
1039
+ (shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
1040
+ }
1041
+ }
1042
+ }
1043
+ // Clips: a driver per channel, writing a state input of its own.
1044
+ const drivers = [];
1045
+ for (const clip of clips) {
1046
+ const duration = v2ClipDuration(clip);
1047
+ if (!(duration > 0)) {
1048
+ notes.push({ code: 'MOTION_STATIC_CLIP', detail: `animation "${clip.name}" has no duration; nothing to write` });
1049
+ continue;
1050
+ }
1051
+ for (const ch of clip.channels) {
1052
+ const owner = `clip ${clip.name}`;
1053
+ if (ch.path === undefined) {
1054
+ refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} drives a joint directly; joints are driven by parameters in this contract` });
1055
+ continue;
1056
+ }
1057
+ if (!partsByName.has(ch.target)) {
1058
+ refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} targets a part that does not exist` });
1059
+ continue;
1060
+ }
1061
+ if (!info.has(ch.target))
1062
+ continue; // refused above under its own name
1063
+ if (ch.path === 'scale') {
1064
+ refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} scales the part; a scale channel is shape deformation, not covered` });
1065
+ continue;
1066
+ }
1067
+ if (ch.pivot) {
1068
+ refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered' });
576
1069
  continue;
577
1070
  }
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) });
1071
+ const keys = ch.keys;
1072
+ const axes = varying(keys);
1073
+ if (axes.length === 0) {
1074
+ notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `${owner} has a ${ch.path} channel whose keys do not change; nothing to write` });
1075
+ continue;
1076
+ }
1077
+ if (axes.length > 1) {
1078
+ refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
1079
+ continue;
1080
+ }
1081
+ const axis = axes[0];
1082
+ if (!claim(`${ch.target}/${ch.path}/${axis}`, owner, ch.target))
1083
+ continue;
1084
+ const values = keys.map(k => k.value[axis]);
1085
+ const rotation = ch.path === 'rotation';
1086
+ const stateId = rotation ? `${ch.target}.spin` : `${ch.target}.shift.${axis}`;
1087
+ const min = Math.min(...values), max = Math.max(...values);
1088
+ g.input(stateId, rotation ? 'deg' : 'mm', rotation ? -180 : min, rotation ? 180 : max, 'state');
1089
+ asset.stateDefaults[stateId] = rotation ? normalise180(values[0]) : values[0];
1090
+ drivers.push({
1091
+ id: `${clip.name}/${ch.target}/${ch.path}`,
1092
+ state: stateId,
1093
+ time: { unit: 's', duration },
1094
+ keys: keys.map(k => ({ at: round6(k.at / duration), value: k.value[axis] })),
1095
+ interpolation: ch.interpolation ?? 'linear',
1096
+ loop: 'wrap',
1097
+ accumulates: rotation
1098
+ });
1099
+ 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` });
1100
+ if (rotation) {
1101
+ if (spinOf.has(ch.target)) {
1102
+ refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
1103
+ continue;
586
1104
  }
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` });
1105
+ spinOf.set(ch.target, { axis, q: stateId });
1106
+ }
1107
+ else {
1108
+ const f = info.get(ch.target).factor[axis];
1109
+ const scaled = f.kind === 'one' ? stateId : g.mul(`${clip.name}.${ch.target}.shift.${axis}`, stateId, f.ref);
1110
+ (shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
1111
+ }
1112
+ }
1113
+ }
1114
+ if (drivers.length)
1115
+ asset.drivers = drivers;
1116
+ // Joints, parents first.
1117
+ const carrierOf = (name) => {
1118
+ for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
1119
+ const j = joints.find(x => x.child === p.name);
1120
+ if (j)
1121
+ return j;
1122
+ }
1123
+ return undefined;
1124
+ };
1125
+ const depth = (j) => {
1126
+ const child = partsByName.get(j.child);
1127
+ const above = child?.parent ? carrierOf(child.parent) : undefined;
1128
+ return above ? 1 + depth(above) : 0;
1129
+ };
1130
+ for (const j of [...joints].sort((a, b) => depth(a) - depth(b))) {
1131
+ const child = partsByName.get(j.child);
1132
+ if (!child || !info.has(j.child)) {
1133
+ refusals.push({ code: 'JOINT_CHILD', part: j.name, detail: `child "${j.child}" was not converted` });
1134
+ continue;
1135
+ }
1136
+ if (info.get(j.child).repeat) {
1137
+ refusals.push({ code: 'MOTION_REPEAT', part: j.child, detail: 'a repeated part cannot be a joint child in this contract' });
1138
+ continue;
1139
+ }
1140
+ // The state input: the driving parameter's affine, or the joint's own input when nothing drives it.
1141
+ let q;
1142
+ const writer = jointWriter.get(j.name);
1143
+ const limits = j.limits;
1144
+ if (writer) {
1145
+ const r = affine(writer.p, writer.v0, writer.v1, j.type === 'prismatic' ? 'mm' : 'deg', `${writer.p.name}.${j.name}`);
1146
+ if (r === undefined)
1147
+ continue;
1148
+ if (r === null) {
1149
+ notes.push({ code: 'MOTION_STATIC_CHANNEL', part: j.name, detail: `parameter ${writer.p.name} does not change the joint; it stays at 0` });
1150
+ q = g.constant(0, j.type === 'prismatic' ? 'mm' : 'deg', 'zeroQ');
590
1151
  }
1152
+ else
1153
+ q = r;
1154
+ }
1155
+ else {
1156
+ const [min, max] = limits ? [limits.min, limits.max] : j.type === 'continuous' ? [-180, 180] : [-1e6, 1e6];
1157
+ q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
1158
+ asset.stateDefaults[j.name] = 0;
591
1159
  }
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)` });
1162
+ if (j.type === 'prismatic')
1163
+ 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
+ // Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
1165
+ const attach = child.parent ? partsByName.get(child.parent) : undefined;
1166
+ const originC = { ...j.origin, y: j.origin.y - base.y / 2 };
1167
+ const originRef = [];
1168
+ for (const a of AXES) {
1169
+ let expr;
1170
+ if (attach && info.has(attach.name)) {
1171
+ const ai = info.get(attach.name);
1172
+ const ac = centredPart(attach, base).transform.position;
1173
+ const offset = c.times(`${j.name}.origin.${a}.offset`, { kind: 'const', value: round6(originC[a] - ac[a]) }, ai.factor[a]);
1174
+ 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`)) };
1175
+ }
1176
+ else {
1177
+ // No carrying part: only the instance scale moves the origin (V2 keeps the rest position).
1178
+ const v = a === 'y' ? originC.y + base.y / 2 : originC[a];
1179
+ 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`)) };
1180
+ }
1181
+ originRef.push(g.len(expr, `${j.name}.origin.${a}`));
1182
+ }
1183
+ 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}`));
1185
+ const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
1186
+ const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
1187
+ const parentJoint = attach ? carrierOf(attach.name) : undefined;
1188
+ const restFrame = 'asset.rest';
1189
+ const toFrame = parentJoint ? restFrame : 'asset';
1190
+ const A = g.node(`${j.name}.frameOnParent`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onParent, to: toFrame });
1191
+ const B = g.node(`${j.name}.frameOnChild`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onChild, to: restFrame });
1192
+ const O = g.node(`${j.name}.motion`, j.type === 'prismatic' ? 'axis-slide@1' : 'axis-turn@1', [...axisRefs, q], 'pose', { from: onChild, to: onParent });
1193
+ let M = g.node(`${j.name}.attach`, 'attach@1', [A, O, B], 'pose');
1194
+ if (parentJoint)
1195
+ M = g.node(`${j.name}.chain`, 'compose@1', [motionOf.get(parentJoint.name), M], 'pose');
1196
+ motionOf.set(j.name, M);
592
1197
  }
593
- return { same: differences.length === 0, scales, boxesCompared, differences };
1198
+ }
1199
+ function normalise180(deg) {
1200
+ const w = ((((deg + 180) % 360) + 360) % 360) - 180;
1201
+ return w === 180 ? -180 : w;
594
1202
  }
595
1203
  //# sourceMappingURL=v3-from-v2.js.map