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