@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.
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +24 -0
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +4 -2
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-driver.d.ts +16 -0
- package/dist/v3-driver.d.ts.map +1 -0
- package/dist/v3-driver.js +145 -0
- package/dist/v3-driver.js.map +1 -0
- package/dist/v3-from-v2.d.ts +54 -8
- package/dist/v3-from-v2.d.ts.map +1 -1
- package/dist/v3-from-v2.js +700 -92
- package/dist/v3-from-v2.js.map +1 -1
- package/dist/v3-gate.d.ts +3 -0
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +4 -1
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-graph-types.d.ts +1 -1
- package/dist/v3-graph-types.d.ts.map +1 -1
- package/dist/v3-graph.d.ts +7 -1
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +75 -12
- package/dist/v3-graph.js.map +1 -1
- package/docs/v3-motion-contract.md +178 -0
- package/docs/v3-operator-contracts.md +1 -0
- package/package.json +1 -1
package/dist/v3-from-v2.js
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - joints and parameters
|
|
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
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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: '
|
|
318
|
+
code: 'POLYGON_SIZE_MISMATCH',
|
|
289
319
|
part: name,
|
|
290
|
-
detail: `path spans ${spanX}×${spanZ}
|
|
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
|
-
|
|
298
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
530
|
-
* centre and
|
|
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,
|
|
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
|
-
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|