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