@hatiolab/figure-model 0.1.34 → 0.1.36
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 +66 -3
- package/dist/v3-from-v2.d.ts.map +1 -1
- package/dist/v3-from-v2.js +359 -94
- 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 +194 -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 +15 -3
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +118 -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-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
|
@@ -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;
|
|
@@ -119,6 +126,28 @@ export function eulerXYZ(rotation) {
|
|
|
119
126
|
[-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]
|
|
120
127
|
];
|
|
121
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Which world axis each local axis lies nearest to, for any rotation; null when two local axes claim one world axis
|
|
131
|
+
* (a 45° turn). Used only when re-authoring a rotated part without shear.
|
|
132
|
+
*/
|
|
133
|
+
function nearestAxisPermutation(rotation) {
|
|
134
|
+
const r = eulerXYZ(rotation);
|
|
135
|
+
const out = {};
|
|
136
|
+
const taken = new Set();
|
|
137
|
+
for (const [i, local] of AXES.entries()) {
|
|
138
|
+
let best = 'x', size = -1;
|
|
139
|
+
for (const [a, world] of AXES.entries()) {
|
|
140
|
+
const v = Math.abs(r[a][i]);
|
|
141
|
+
if (v > size)
|
|
142
|
+
(size = v), (best = world);
|
|
143
|
+
}
|
|
144
|
+
if (taken.has(best))
|
|
145
|
+
return null;
|
|
146
|
+
taken.add(best);
|
|
147
|
+
out[local] = best;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
122
151
|
/** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
|
|
123
152
|
function axisPermutation(rotation) {
|
|
124
153
|
if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
|
|
@@ -165,12 +194,15 @@ export function convertV2ToV3(source, options) {
|
|
|
165
194
|
const lost = [];
|
|
166
195
|
const refusals = [];
|
|
167
196
|
const notes = [];
|
|
197
|
+
const rangeChanges = [];
|
|
198
|
+
let method = 'mechanical';
|
|
168
199
|
const checked = validate(source);
|
|
169
200
|
if (checked.errors.length) {
|
|
170
201
|
return {
|
|
171
202
|
status: 'refused',
|
|
172
203
|
refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
|
|
173
204
|
lost,
|
|
205
|
+
rangeChanges,
|
|
174
206
|
notes
|
|
175
207
|
};
|
|
176
208
|
}
|
|
@@ -219,13 +251,39 @@ export function convertV2ToV3(source, options) {
|
|
|
219
251
|
const cap = REPEAT_LIMIT * cp.repeat.pitch;
|
|
220
252
|
if (sizeMax[axis] > cap) {
|
|
221
253
|
sizeMax[axis] = cap;
|
|
222
|
-
|
|
223
|
-
|
|
254
|
+
rangeChanges.push({
|
|
255
|
+
input: V3_SIZE_INPUTS[axis],
|
|
224
256
|
part: cp.name,
|
|
225
|
-
|
|
257
|
+
v2: `any size; above ${cap} mm V2 stopped adding copies at ${REPEAT_LIMIT} and drew the rest as a shorter row`,
|
|
258
|
+
v3: { min: sizeMin[axis], max: cap },
|
|
259
|
+
reason: `V3 refuses a size whose repeat count would exceed ${REPEAT_LIMIT} (fit-pitch@1 RESOURCE_LIMIT) instead of clamping the count`
|
|
226
260
|
});
|
|
227
261
|
}
|
|
228
262
|
}
|
|
263
|
+
// A spanned part fills the room between its two gaps. Below that room V2 draws a 0.001 mm sliver (`sizing.ts`
|
|
264
|
+
// SLIVER); V3 refuses a non-positive dimension. The condition is length > 0, nothing more: the size input's
|
|
265
|
+
// minimum is the room itself plus the smallest step that keeps the evaluated length positive in double precision.
|
|
266
|
+
// Shrinking the accepted range is a behaviour change and is recorded as one (designer's ruling 2).
|
|
267
|
+
for (const cp of parts) {
|
|
268
|
+
const subject = withSizing(cp);
|
|
269
|
+
for (const a of AXES) {
|
|
270
|
+
if (anchorOf(subject, base, a) !== 'span')
|
|
271
|
+
continue;
|
|
272
|
+
const gaps = clearance(subject, base, a);
|
|
273
|
+
const room = gaps.min + gaps.max;
|
|
274
|
+
const floor = room + SPAN_POSITIVE_STEP;
|
|
275
|
+
if (floor > sizeMin[a]) {
|
|
276
|
+
sizeMin[a] = floor;
|
|
277
|
+
rangeChanges.push({
|
|
278
|
+
input: V3_SIZE_INPUTS[a],
|
|
279
|
+
part: cp.name,
|
|
280
|
+
v2: `any size; at or below ${round6(room)} mm the spanned part was drawn as a 0.001 mm sliver`,
|
|
281
|
+
v3: { min: floor, max: sizeMax[a] },
|
|
282
|
+
reason: `the spanned part must keep a positive length (${SPAN_POSITIVE_STEP} mm is the numerical step above zero, not a minimum part size)`
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
229
287
|
const size = {};
|
|
230
288
|
for (const a of AXES) {
|
|
231
289
|
size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
|
|
@@ -254,6 +312,8 @@ export function convertV2ToV3(source, options) {
|
|
|
254
312
|
const info = new Map();
|
|
255
313
|
const spinOf = new Map();
|
|
256
314
|
const shiftsOf = new Map();
|
|
315
|
+
// A scale channel: per local axis, a ratio the part's dimensions are multiplied by (contract §2: dims × affine(p)).
|
|
316
|
+
const scaleOf = new Map();
|
|
257
317
|
const frameOf = new Map(); // part → the joint whose frame carries it
|
|
258
318
|
const motionOf = new Map(); // joint → pose asset.rest → asset
|
|
259
319
|
const partsByName = new Map(parts.map(cp => [cp.name, cp]));
|
|
@@ -289,7 +349,7 @@ export function convertV2ToV3(source, options) {
|
|
|
289
349
|
if (cp.materialSlot)
|
|
290
350
|
lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
|
|
291
351
|
if (cp.capability)
|
|
292
|
-
lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'slot/port role is not carried as a V3 binding' });
|
|
352
|
+
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
353
|
const { material, refusal } = materialOf(cp, palette);
|
|
294
354
|
if (refusal) {
|
|
295
355
|
refusals.push(refusal);
|
|
@@ -334,8 +394,6 @@ export function convertV2ToV3(source, options) {
|
|
|
334
394
|
if (cp.shape?.round)
|
|
335
395
|
lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
|
|
336
396
|
}
|
|
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
397
|
const segments = cp.segments ?? SEG_DEFAULT;
|
|
340
398
|
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
|
|
341
399
|
refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
|
|
@@ -379,6 +437,13 @@ export function convertV2ToV3(source, options) {
|
|
|
379
437
|
pending.push(a); // aspect-*: follows another axis, settled below
|
|
380
438
|
}
|
|
381
439
|
}
|
|
440
|
+
// A capability anchor is a place, not a body: V2 compiles it apart from the drawn groups and never renders it
|
|
441
|
+
// (the independent check against the V2 renderer found the converter drawing one). Its position is kept for
|
|
442
|
+
// joints that attach to it; nothing is placed.
|
|
443
|
+
if (cp.capability) {
|
|
444
|
+
info.set(name, { centre, factor, repeat: false });
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
382
447
|
// A curved part keeps a round section: both cross axes take the geometric mean of their two factors
|
|
383
448
|
// (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
|
|
384
449
|
// it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
|
|
@@ -414,60 +479,78 @@ export function convertV2ToV3(source, options) {
|
|
|
414
479
|
}
|
|
415
480
|
}
|
|
416
481
|
// Local dimensions scale by the factor of the world axis each local axis lands on.
|
|
417
|
-
|
|
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
|
-
}
|
|
482
|
+
let perm = axisPermutation(cp.transform.rotation);
|
|
433
483
|
const follows = AXES.some(a => factor[a].kind !== 'one');
|
|
434
484
|
if (!perm && follows) {
|
|
435
|
-
|
|
436
|
-
|
|
485
|
+
const nearest = options.reauthor?.rotatedFollows === 'nearest-axis' ? nearestAxisPermutation(cp.transform.rotation) : null;
|
|
486
|
+
if (!nearest) {
|
|
487
|
+
refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
perm = nearest;
|
|
491
|
+
method = 're-authored';
|
|
492
|
+
const rot = cp.transform.rotation ?? {};
|
|
493
|
+
notes.push({
|
|
494
|
+
code: 'RE_AUTHORED_ROTATED',
|
|
495
|
+
part: name,
|
|
496
|
+
detail: `rotated (${AXES.map(a => `${a}:${rot[a] ?? 0}°`).join(', ')}) and its size follows the instance; V2 sheared the mesh. Re-authored rigid: each local dimension follows the world axis it lies nearest to (${AXES.map(a => `${a}→${nearest[a]}`).join(', ')}). Same as V2 where the factors on the in-plane axes agree; different elsewhere, by design`
|
|
497
|
+
});
|
|
437
498
|
}
|
|
438
|
-
|
|
499
|
+
// A channel scale is planned after the parts and read when the shape node is written, so `dim` is called
|
|
500
|
+
// inside the shape closures only.
|
|
501
|
+
const withChannel = (v, local, hint) => {
|
|
502
|
+
const sc = scaleOf.get(name)?.[local];
|
|
503
|
+
if (!sc)
|
|
504
|
+
return v;
|
|
505
|
+
return { kind: 'ref', ref: g.mul(`${name}.${hint}.scaled`, g.len(v, `${name}.${hint}`), sc) };
|
|
506
|
+
};
|
|
507
|
+
const dim = (local) => withChannel(times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE), local, `dim.${local}`);
|
|
508
|
+
const channelSplits = () => {
|
|
509
|
+
const sc = scaleOf.get(name);
|
|
510
|
+
return !!sc && (sc.x ?? null) !== (sc.z ?? null);
|
|
511
|
+
};
|
|
439
512
|
const localFrame = `${name}.local`;
|
|
440
513
|
// A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
|
|
441
514
|
const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
|
|
442
515
|
const rot = cp.transform.rotation;
|
|
443
516
|
const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
|
|
444
517
|
let shape;
|
|
445
|
-
if (cp.primitive === 'cylinder'
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
518
|
+
if (cp.primitive === 'cylinder') {
|
|
519
|
+
// V2 builds every cylinder from a unit cylinder with equal end radii and scales it by size (things-scene
|
|
520
|
+
// geometry-bank): size.x ≠ size.z, or two cross factors that differ with keepRound off, is an elliptic
|
|
521
|
+
// cylinder, never a frustum. Equal radii by the same expression stay on cylinder-shape@1.
|
|
522
|
+
const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
523
|
+
const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
|
|
524
|
+
const equalSizes = Math.abs(cp.transform.size.x - cp.transform.size.z) <= 1e-9;
|
|
525
|
+
if (!(sameFactor && equalSizes))
|
|
526
|
+
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` });
|
|
527
|
+
shape = () => {
|
|
528
|
+
const radiusX = withChannel(times(`${name}.dim.radiusX`, { kind: 'const', value: cp.transform.size.x / 2 }, fx), 'x', 'radiusX');
|
|
529
|
+
const radiusZ = withChannel(times(`${name}.dim.radiusZ`, { kind: 'const', value: cp.transform.size.z / 2 }, fz), 'z', 'radiusZ');
|
|
530
|
+
const length = dim('y');
|
|
531
|
+
const round = sameFactor && equalSizes && !channelSplits();
|
|
532
|
+
return round
|
|
533
|
+
? g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radiusX, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() })
|
|
534
|
+
: 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() });
|
|
535
|
+
};
|
|
457
536
|
}
|
|
458
537
|
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
|
-
|
|
538
|
+
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()}`);
|
|
539
|
+
shape = () => {
|
|
540
|
+
const [rx, ry, rz] = [r('x'), r('y'), r('z')];
|
|
541
|
+
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() });
|
|
542
|
+
};
|
|
462
543
|
}
|
|
463
544
|
else if (cp.primitive === 'polygon') {
|
|
464
|
-
const h = dim('y');
|
|
465
545
|
const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
546
|
+
shape = () => {
|
|
547
|
+
const h = dim('y');
|
|
548
|
+
const points = path.flatMap((q, i) => [
|
|
549
|
+
withChannel(times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx), 'x', `p${i}.x`),
|
|
550
|
+
withChannel(times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz), 'z', `p${i}.z`)
|
|
551
|
+
]);
|
|
552
|
+
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() });
|
|
553
|
+
};
|
|
471
554
|
}
|
|
472
555
|
else if (cp.primitive === 'rect') {
|
|
473
556
|
// V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
|
|
@@ -475,23 +558,36 @@ export function convertV2ToV3(source, options) {
|
|
|
475
558
|
const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
|
|
476
559
|
if (round < declared)
|
|
477
560
|
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
|
-
|
|
561
|
+
// V2 scales the finished mesh per world axis, so a corner drawn with radius r becomes an elliptic arc with
|
|
562
|
+
// semi-axes r·fx, r·fz (shape-dimension contract §1). When the two factors are one expression, @1 is enough.
|
|
563
|
+
const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
564
|
+
const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
|
|
565
|
+
if (round > 0 && !sameFactor)
|
|
566
|
+
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` });
|
|
567
|
+
shape = () => {
|
|
568
|
+
const roundX = withChannel(times(`${name}.roundX`, { kind: 'const', value: round }, fx), 'x', 'roundX');
|
|
569
|
+
const roundZ = withChannel(times(`${name}.roundZ`, { kind: 'const', value: round }, fz), 'z', 'roundZ');
|
|
570
|
+
const perAxis = round > 0 && (!sameFactor || channelSplits());
|
|
571
|
+
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
572
|
+
if (hollow) {
|
|
573
|
+
// A wall's thickness follows the axis it lies across and the floor follows the height.
|
|
574
|
+
const wallX = withChannel(times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx), 'x', 'wallX');
|
|
575
|
+
const wallZ = withChannel(times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz), 'z', 'wallZ');
|
|
576
|
+
const floor = withChannel(times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy), 'y', 'floor');
|
|
577
|
+
return perAxis
|
|
578
|
+
? 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() })
|
|
579
|
+
: 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() });
|
|
580
|
+
}
|
|
581
|
+
return perAxis
|
|
582
|
+
? 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() })
|
|
583
|
+
: 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() });
|
|
584
|
+
};
|
|
491
585
|
}
|
|
492
586
|
else {
|
|
493
|
-
|
|
494
|
-
|
|
587
|
+
shape = () => {
|
|
588
|
+
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
589
|
+
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() });
|
|
590
|
+
};
|
|
495
591
|
}
|
|
496
592
|
const appearance = { target: name, material: material };
|
|
497
593
|
if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
|
|
@@ -579,9 +675,9 @@ export function convertV2ToV3(source, options) {
|
|
|
579
675
|
}
|
|
580
676
|
});
|
|
581
677
|
}
|
|
582
|
-
planMotion({ src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio, times, factorOfScale });
|
|
678
|
+
planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
|
|
583
679
|
if (refusals.length)
|
|
584
|
-
return { status: 'refused', refusals, lost, notes };
|
|
680
|
+
return { status: 'refused', refusals, lost, rangeChanges, notes };
|
|
585
681
|
for (const plan of plans) {
|
|
586
682
|
plan.nodes();
|
|
587
683
|
asset.appearance.push(plan.appearance);
|
|
@@ -591,9 +687,9 @@ export function convertV2ToV3(source, options) {
|
|
|
591
687
|
}
|
|
592
688
|
catch (e) {
|
|
593
689
|
const err = e;
|
|
594
|
-
return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, notes };
|
|
690
|
+
return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, rangeChanges, notes };
|
|
595
691
|
}
|
|
596
|
-
return { status: 'converted', asset, lost, notes };
|
|
692
|
+
return { status: 'converted', method, asset, lost, rangeChanges, notes };
|
|
597
693
|
}
|
|
598
694
|
/** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
|
|
599
695
|
function withSizing(cp) {
|
|
@@ -651,10 +747,11 @@ export function v2ClipDuration(clip) {
|
|
|
651
747
|
* factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
|
|
652
748
|
* everything in its frame about its sized origin, parents first (ADR-0066).
|
|
653
749
|
*/
|
|
654
|
-
export function v2WorldBoxes(source, scale, state = {}) {
|
|
750
|
+
export function v2WorldBoxes(source, scale, state = {}, options = {}) {
|
|
655
751
|
const base = source.base;
|
|
656
752
|
const out = [];
|
|
657
753
|
const shifts = new Map();
|
|
754
|
+
const scales = new Map();
|
|
658
755
|
const turns = new Map();
|
|
659
756
|
const jointValues = new Map();
|
|
660
757
|
const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
|
|
@@ -665,7 +762,11 @@ export function v2WorldBoxes(source, scale, state = {}) {
|
|
|
665
762
|
continue;
|
|
666
763
|
}
|
|
667
764
|
const v = sampleV2(ch, at);
|
|
668
|
-
if (ch.path === '
|
|
765
|
+
if (ch.path === 'scale') {
|
|
766
|
+
const s0 = scales.get(ch.target) ?? { x: 1, y: 1, z: 1 };
|
|
767
|
+
scales.set(ch.target, { x: s0.x * v.x, y: s0.y * v.y, z: s0.z * v.z });
|
|
768
|
+
}
|
|
769
|
+
else if (ch.path === 'translation') {
|
|
669
770
|
const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
|
|
670
771
|
shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
|
|
671
772
|
}
|
|
@@ -728,32 +829,41 @@ export function v2WorldBoxes(source, scale, state = {}) {
|
|
|
728
829
|
return world;
|
|
729
830
|
};
|
|
730
831
|
for (const part of source.parts) {
|
|
832
|
+
// Capability anchors are not drawn by V2 (blueprint `anchors`, not `groups`).
|
|
833
|
+
if (part.capability)
|
|
834
|
+
continue;
|
|
731
835
|
const cp = withSizing(centredPart(part, base));
|
|
732
836
|
const at = sizingPosition(cp, scale, base);
|
|
733
837
|
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
838
|
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 } });
|
|
839
|
+
const extent = rotatedExtentOf(cp);
|
|
740
840
|
let rotation = eulerXYZ(part.transform.rotation);
|
|
741
841
|
const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
|
|
742
842
|
const box = { x: world('x'), y: world('y'), z: world('z') };
|
|
743
843
|
box.y.centre += (base.y * scale.y) / 2;
|
|
744
844
|
// A polygon is drawn where its path says, which may be off the part centre: rotation baked, then scaled per world axis.
|
|
845
|
+
let bodyPath = part.shape?.path;
|
|
745
846
|
if (part.primitive === 'polygon' && part.shape?.path?.length) {
|
|
746
847
|
const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
|
|
747
848
|
const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
|
|
748
849
|
for (const [a, axis] of AXES.entries())
|
|
749
850
|
box[axis].centre += (rotation[a][0] * c[0] + rotation[a][2] * c[2]) * box[axis].factor;
|
|
851
|
+
// The sampled body is centred on its drawn centre, so the path is taken about that centre.
|
|
852
|
+
bodyPath = part.shape.path.map(q => ({ x: q.x - c[0], y: q.y - c[2] }));
|
|
750
853
|
}
|
|
751
|
-
//
|
|
854
|
+
// A scale channel multiplies the part's own dimensions about its centre (V2 pose.scale in the part frame).
|
|
855
|
+
const chScale = scales.get(part.name) ?? { x: 1, y: 1, z: 1 };
|
|
856
|
+
// Surface points: body in its own frame → channel scale → baked rotation → scaled per world axis → motion → centre.
|
|
857
|
+
const localPoints = options.points
|
|
858
|
+
? 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 })
|
|
859
|
+
: null;
|
|
860
|
+
// Local dimensions: each local axis lands on a world axis and takes that axis's factor, times any channel scale
|
|
861
|
+
// (V2 applies pose.scale in the part's own frame, about its centre).
|
|
752
862
|
const perm = axisPermutation(part.transform.rotation);
|
|
753
863
|
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)
|
|
864
|
+
drawnSize.x * (perm ? box[perm.x].factor : 1) * chScale.x,
|
|
865
|
+
drawnSize.y * (perm ? box[perm.y].factor : 1) * chScale.y,
|
|
866
|
+
drawnSize.z * (perm ? box[perm.z].factor : 1) * chScale.z
|
|
757
867
|
];
|
|
758
868
|
// Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
|
|
759
869
|
const shift = shifts.get(part.name);
|
|
@@ -761,8 +871,11 @@ export function v2WorldBoxes(source, scale, state = {}) {
|
|
|
761
871
|
for (const a of AXES)
|
|
762
872
|
box[a].centre += shift[a] * box[a].factor;
|
|
763
873
|
const turn = turns.get(part.name);
|
|
764
|
-
|
|
874
|
+
let motionRotation = I3;
|
|
875
|
+
if (turn) {
|
|
765
876
|
rotation = mm3(turn, rotation);
|
|
877
|
+
motionRotation = turn;
|
|
878
|
+
}
|
|
766
879
|
// Then the joint frame that carries it.
|
|
767
880
|
const carrier = carrierOf(part.name);
|
|
768
881
|
const W = carrier ? worldJoint(carrier) : undefined;
|
|
@@ -773,13 +886,18 @@ export function v2WorldBoxes(source, scale, state = {}) {
|
|
|
773
886
|
if (plan)
|
|
774
887
|
centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
|
|
775
888
|
let R = rotation;
|
|
889
|
+
let M = motionRotation;
|
|
776
890
|
if (W) {
|
|
777
891
|
const c = mv3(W.r, [centre.x, centre.y, centre.z]);
|
|
778
892
|
centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
|
|
779
893
|
R = mm3(W.r, rotation);
|
|
894
|
+
M = mm3(W.r, motionRotation);
|
|
780
895
|
}
|
|
781
896
|
const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
|
|
782
|
-
|
|
897
|
+
const box2 = { part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R };
|
|
898
|
+
if (localPoints)
|
|
899
|
+
box2.points = transformPoints(localPoints, M, [centre.x, centre.y, centre.z]);
|
|
900
|
+
out.push(box2);
|
|
783
901
|
}
|
|
784
902
|
}
|
|
785
903
|
return out;
|
|
@@ -789,10 +907,32 @@ function composeRT(a, b) {
|
|
|
789
907
|
return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
|
|
790
908
|
}
|
|
791
909
|
/** Where a converted V3 asset draws every part at an instance size, as world boxes. */
|
|
792
|
-
export function v3WorldBoxes(asset, size, stateOverrides = {}) {
|
|
910
|
+
export function v3WorldBoxes(asset, size, stateOverrides = {}, options = {}) {
|
|
793
911
|
const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
|
|
794
912
|
const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
|
|
795
|
-
return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
|
|
913
|
+
return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset), options);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Instance scales at which a repeated part's copy count changes, one just below and one just above each
|
|
917
|
+
* boundary near the base count (completion criterion: repeat-count boundaries).
|
|
918
|
+
*/
|
|
919
|
+
export function repeatBoundaryScales(source) {
|
|
920
|
+
const out = [];
|
|
921
|
+
for (const part of source.parts) {
|
|
922
|
+
if (part.sizing !== 'repeat' || !part.repeat)
|
|
923
|
+
continue;
|
|
924
|
+
const axis = part.repeat.axis, pitch = part.repeat.pitch, length = source.base[axis];
|
|
925
|
+
const count = Math.floor(length / pitch);
|
|
926
|
+
for (const k of [count, count + 1]) {
|
|
927
|
+
for (const delta of [-0.5, 0.5]) {
|
|
928
|
+
const L = k * pitch + delta;
|
|
929
|
+
if (L <= 0)
|
|
930
|
+
continue;
|
|
931
|
+
out.push({ x: 1, y: 1, z: 1, [axis]: L / length });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return out;
|
|
796
936
|
}
|
|
797
937
|
/** ADR-0087 decision 2 tolerances. */
|
|
798
938
|
export const SAME_TOLERANCE_MM = 1;
|
|
@@ -829,16 +969,21 @@ function rotationAngleDeg(a, b) {
|
|
|
829
969
|
export function compareV2WithV3(source, asset, options = {}) {
|
|
830
970
|
const opts = typeof options === 'number' ? { factor: options } : options;
|
|
831
971
|
const factor = opts.factor ?? 2;
|
|
972
|
+
const mesh = opts.mesh ?? true;
|
|
973
|
+
const steps = opts.shrink ? [1, factor, 1 / factor] : [1, factor];
|
|
832
974
|
const scales = [];
|
|
833
|
-
for (const x of
|
|
834
|
-
for (const y of
|
|
835
|
-
for (const z of
|
|
975
|
+
for (const x of steps)
|
|
976
|
+
for (const y of steps)
|
|
977
|
+
for (const z of steps)
|
|
836
978
|
scales.push({ x, y, z });
|
|
979
|
+
scales.push(...(opts.extraScales ?? []));
|
|
837
980
|
const states = [{ label: 'rest' }, ...(opts.states ?? [])];
|
|
838
981
|
const differences = [];
|
|
839
982
|
let boxesCompared = 0;
|
|
840
983
|
const materials = new Map(asset.appearance.map(a => [a.target, a]));
|
|
841
984
|
for (const part of source.parts) {
|
|
985
|
+
if (part.capability)
|
|
986
|
+
continue; // an anchor has no body on either side
|
|
842
987
|
const look = materials.get(part.name);
|
|
843
988
|
if (!look) {
|
|
844
989
|
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
|
|
@@ -868,8 +1013,17 @@ export function compareV2WithV3(source, asset, options = {}) {
|
|
|
868
1013
|
const overrides = v3StateOverrides(asset, state);
|
|
869
1014
|
for (const scale of scales) {
|
|
870
1015
|
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
|
-
|
|
1016
|
+
const v2 = byPart(v2WorldBoxes(source, scale, state, { points: mesh }));
|
|
1017
|
+
let v3;
|
|
1018
|
+
try {
|
|
1019
|
+
v3 = byPart(v3WorldBoxes(asset, size, overrides, { points: mesh }));
|
|
1020
|
+
}
|
|
1021
|
+
catch (e) {
|
|
1022
|
+
// V3 refuses what V2 drew anyway (a collapsed span as a sliver, a joint value past its limit). Recorded, not thrown.
|
|
1023
|
+
const err = e;
|
|
1024
|
+
differences.push({ part: '*', scale, state: state.label, what: `refused:${err.code}`, v2: 'draws it', v3: err.message });
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
873
1027
|
for (const name of new Set([...v2.keys(), ...v3.keys()])) {
|
|
874
1028
|
const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
|
|
875
1029
|
if (a.length !== b.length) {
|
|
@@ -889,11 +1043,23 @@ export function compareV2WithV3(source, asset, options = {}) {
|
|
|
889
1043
|
const turn = rotationAngleDeg(p.rotation, q.rotation);
|
|
890
1044
|
if (turn > SAME_TOLERANCE_DEG)
|
|
891
1045
|
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` });
|
|
1046
|
+
if (mesh && p.points && q.points) {
|
|
1047
|
+
const gap = hausdorff(p.points, q.points);
|
|
1048
|
+
if (gap > SAME_TOLERANCE_MM)
|
|
1049
|
+
differences.push({ part: name, scale, state: state.label, what: `mesh${tag}`, v2: `${p.points.length} points`, v3: `surface ${gap.toFixed(3)} mm apart` });
|
|
1050
|
+
}
|
|
892
1051
|
}
|
|
893
1052
|
}
|
|
894
1053
|
}
|
|
895
1054
|
}
|
|
896
|
-
return {
|
|
1055
|
+
return {
|
|
1056
|
+
same: differences.length === 0,
|
|
1057
|
+
scales,
|
|
1058
|
+
states: states.map(s => s.label),
|
|
1059
|
+
boxesCompared,
|
|
1060
|
+
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" },
|
|
1061
|
+
differences
|
|
1062
|
+
};
|
|
897
1063
|
}
|
|
898
1064
|
const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
|
|
899
1065
|
/**
|
|
@@ -903,7 +1069,7 @@ const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m:
|
|
|
903
1069
|
* What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
|
|
904
1070
|
*/
|
|
905
1071
|
function planMotion(c) {
|
|
906
|
-
const { src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio } = c;
|
|
1072
|
+
const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
|
|
907
1073
|
const params = src.parameters ?? [];
|
|
908
1074
|
const clips = src.animations ?? [];
|
|
909
1075
|
const joints = src.joints ?? [];
|
|
@@ -919,6 +1085,12 @@ function planMotion(c) {
|
|
|
919
1085
|
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
|
|
920
1086
|
continue;
|
|
921
1087
|
}
|
|
1088
|
+
// V2 keeps parameters, parts and joints in separate name spaces; a V3 graph has one. The parameter keeps its
|
|
1089
|
+
// name (motion contract), so a clash is refused rather than renamed behind the author's back.
|
|
1090
|
+
if (partsByName.has(p.name) || jointsByName.has(p.name) && joints.some(j => j.name === p.name && j.child === p.name)) {
|
|
1091
|
+
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` });
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
922
1094
|
g.input(p.name, unit, p.range.min, p.range.max, 'state');
|
|
923
1095
|
asset.stateDefaults[p.name] = p.default ?? p.range.min;
|
|
924
1096
|
paramUnit.set(p.name, unit);
|
|
@@ -949,6 +1121,55 @@ function planMotion(c) {
|
|
|
949
1121
|
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
950
1122
|
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
1123
|
};
|
|
1124
|
+
/** A dimensionless factor f = s0 + (p - min)·(s1 - s0)/(max - min), for a scale channel. */
|
|
1125
|
+
const affineRatio = (p, s0, s1, hint) => {
|
|
1126
|
+
const pUnit = paramUnit.get(p.name);
|
|
1127
|
+
if (!pUnit)
|
|
1128
|
+
return undefined;
|
|
1129
|
+
const span = p.range.max - p.range.min;
|
|
1130
|
+
if (pUnit !== 'percent' && pUnit !== 'ratio') {
|
|
1131
|
+
// A slope in ratio-per-<unit> has no V3 unit; only dimensionless parameters may scale a dimension.
|
|
1132
|
+
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale a dimension; a percent or ratio parameter can` });
|
|
1133
|
+
return undefined;
|
|
1134
|
+
}
|
|
1135
|
+
const slope = pUnit === 'percent' ? ((s1 - s0) * 100) / span : (s1 - s0) / span;
|
|
1136
|
+
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1137
|
+
return g.add(`${hint}.f`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), 'ratio', `${hint}.slope`)), g.constant(round6(s0), 'ratio', `${hint}.s0`));
|
|
1138
|
+
};
|
|
1139
|
+
/**
|
|
1140
|
+
* A dimensionless factor through every key of a linear channel: f = curve(u, at₀,s₀, at₁,s₁, …) with u = (p − min)/span
|
|
1141
|
+
* as a ratio in [0, 1], the keys' `at` in the same ratio (piecewise-linear curves, V3 designer's approval
|
|
1142
|
+
* 2026-09-22). Two keys at 0 and 1 take the affine form instead, so earlier assets keep their graphs.
|
|
1143
|
+
*/
|
|
1144
|
+
const curveRatio = (p, keys, hint) => {
|
|
1145
|
+
const pUnit = paramUnit.get(p.name);
|
|
1146
|
+
if (!pUnit)
|
|
1147
|
+
return undefined;
|
|
1148
|
+
const span = p.range.max - p.range.min;
|
|
1149
|
+
if (pUnit !== 'percent' && pUnit !== 'ratio') {
|
|
1150
|
+
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale a dimension; a percent or ratio parameter can` });
|
|
1151
|
+
return undefined;
|
|
1152
|
+
}
|
|
1153
|
+
const slope = pUnit === 'percent' ? 100 / span : 1 / span;
|
|
1154
|
+
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1155
|
+
const u01 = g.mul(`${hint}.u01`, u, g.constant(round6(slope), 'ratio', `${hint}.perSpan`));
|
|
1156
|
+
const args = [u01];
|
|
1157
|
+
keys.forEach((k, i) => args.push(g.constant(round6(k.at), 'ratio', `${hint}.at${i}`), g.constant(round6(k.value), 'ratio', `${hint}.s${i}`)));
|
|
1158
|
+
return g.node(`${hint}.curve`, 'curve@1', args, 'value');
|
|
1159
|
+
};
|
|
1160
|
+
/** A linear channel with keys in order inside [0, 1]; anything else is refused by name. */
|
|
1161
|
+
const linearKeys = (ch, owner) => {
|
|
1162
|
+
const keys = ch.keys;
|
|
1163
|
+
if ((ch.interpolation ?? 'linear') !== 'linear') {
|
|
1164
|
+
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: ${ch.interpolation} interpolation is not written into the graph; linear keys are` });
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
if (keys.length < 2 || keys.some((k, i) => i > 0 && !(k.at > keys[i - 1].at)) || keys[0].at < 0 || keys[keys.length - 1].at > 1) {
|
|
1168
|
+
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: keys must be two or more, strictly increasing, inside [0, 1]` });
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
return true;
|
|
1172
|
+
};
|
|
952
1173
|
const twoKeyLinear = (ch, owner) => {
|
|
953
1174
|
const keys = ch.keys;
|
|
954
1175
|
if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
|
|
@@ -997,16 +1218,57 @@ function planMotion(c) {
|
|
|
997
1218
|
}
|
|
998
1219
|
if (!info.has(ch.target))
|
|
999
1220
|
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
1221
|
if (ch.pivot) {
|
|
1005
1222
|
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
1223
|
continue;
|
|
1007
1224
|
}
|
|
1008
1225
|
const keys = ch.keys;
|
|
1009
1226
|
const axes = varying(keys);
|
|
1227
|
+
if (ch.path === 'scale') {
|
|
1228
|
+
// Shape-dimension contract §2: a state input may change a dimension. Each varying axis of the part gets a
|
|
1229
|
+
// ratio factor affine in the parameter; the part's centre does not move (V2 scaled about the centre).
|
|
1230
|
+
if (axes.length === 0) {
|
|
1231
|
+
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` });
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (!linearKeys(ch, p.name))
|
|
1235
|
+
continue;
|
|
1236
|
+
if (!axisPermutation(partsByName.get(ch.target).transform.rotation)) {
|
|
1237
|
+
refusals.push({ code: 'ROTATED_FOLLOWS', part: ch.target, detail: 'a scale channel on a part rotated off the axes would shear it; not covered' });
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
// A scale of 0 is how V2 hides a part (the lamp glow before it lights). A V3 dimension must be positive, so
|
|
1241
|
+
// this is refused until the designer rules on the rule for it (a floor as for span, or a visibility channel).
|
|
1242
|
+
const zero = axes.find(axis => keys.some(k => k.value[axis] <= 0));
|
|
1243
|
+
if (zero) {
|
|
1244
|
+
refusals.push({ code: 'SCALE_ZERO', part: ch.target, detail: `parameter ${p.name} scales ${zero} to ${Math.min(...keys.map(k => k.value[zero]))}; V2 draws nothing there, a V3 dimension must be positive` });
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
const twoKeys = keys.length === 2 && keys[0].at === 0 && keys[1].at === 1;
|
|
1248
|
+
const entry = scaleOf.get(ch.target) ?? {};
|
|
1249
|
+
let ok = true;
|
|
1250
|
+
for (const axis of axes) {
|
|
1251
|
+
if (!claim(`${ch.target}/scale/${axis}`, p.name, ch.target)) {
|
|
1252
|
+
ok = false;
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
const hint = `${p.name}.${ch.target}.scale.${axis}`;
|
|
1256
|
+
const f = twoKeys
|
|
1257
|
+
? affineRatio(p, keys[0].value[axis], keys[1].value[axis], hint)
|
|
1258
|
+
: curveRatio(p, keys.map(k => ({ at: k.at, value: k.value[axis] })), hint);
|
|
1259
|
+
if (f === undefined) {
|
|
1260
|
+
ok = false;
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
entry[axis] = f;
|
|
1264
|
+
}
|
|
1265
|
+
if (!ok)
|
|
1266
|
+
continue;
|
|
1267
|
+
scaleOf.set(ch.target, entry);
|
|
1268
|
+
const shape = twoKeys ? `${keys[0].value[axes[0]]} → ${keys[1].value[axes[0]]} on ${axes[0]}` : `${keys.length} keys on ${axes[0]}: ${keys.map(k => `${k.at}:${k.value[axes[0]]}`).join(' ')}`;
|
|
1269
|
+
notes.push({ code: 'STATE_DIMENSION', part: ch.target, detail: `dimensions on ${axes.join(', ')} follow parameter ${p.name} (${shape}); the occupancy must hold over its range` });
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1010
1272
|
if (axes.length === 0) {
|
|
1011
1273
|
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
1274
|
continue;
|
|
@@ -1157,8 +1419,10 @@ function planMotion(c) {
|
|
|
1157
1419
|
q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
|
|
1158
1420
|
asset.stateDefaults[j.name] = 0;
|
|
1159
1421
|
}
|
|
1160
|
-
if (limits)
|
|
1161
|
-
|
|
1422
|
+
if (limits && !writer)
|
|
1423
|
+
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' });
|
|
1424
|
+
if (limits && writer)
|
|
1425
|
+
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
1426
|
if (j.type === 'prismatic')
|
|
1163
1427
|
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
1428
|
// Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
|
|
@@ -1181,7 +1445,8 @@ function planMotion(c) {
|
|
|
1181
1445
|
originRef.push(g.len(expr, `${j.name}.origin.${a}`));
|
|
1182
1446
|
}
|
|
1183
1447
|
const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
|
|
1184
|
-
|
|
1448
|
+
// Full precision: the kernel accepts a unit axis to 1e-6 and normalises, but the converter does not round it.
|
|
1449
|
+
const axisRefs = AXES.map(a => g.constant(j.axis[a] / n, 'ratio', `${j.name}.axis.${a}`));
|
|
1185
1450
|
const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
|
|
1186
1451
|
const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
|
|
1187
1452
|
const parentJoint = attach ? carrierOf(attach.name) : undefined;
|