@hatiolab/figure-model 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/sizing.d.ts +11 -0
- package/dist/sizing.d.ts.map +1 -1
- package/dist/sizing.js +8 -4
- package/dist/sizing.js.map +1 -1
- package/dist/v3-asset-types.d.ts +48 -2
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +19 -3
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-capabilities.d.ts.map +1 -1
- package/dist/v3-capabilities.js +13 -1
- package/dist/v3-capabilities.js.map +1 -1
- package/dist/v3-create.js +1 -1
- package/dist/v3-create.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 +116 -0
- package/dist/v3-from-v2.d.ts.map +1 -0
- package/dist/v3-from-v2.js +1203 -0
- package/dist/v3-from-v2.js.map +1 -0
- package/dist/v3-gate.d.ts +38 -0
- package/dist/v3-gate.d.ts.map +1 -0
- package/dist/v3-gate.js +134 -0
- package/dist/v3-gate.js.map +1 -0
- package/dist/v3-graph-types.d.ts +1 -1
- package/dist/v3-graph-types.d.ts.map +1 -1
- package/dist/v3-graph.d.ts +14 -0
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +162 -10
- package/dist/v3-graph.js.map +1 -1
- package/docs/prototypes/v3-asset-samples.mjs +1 -1
- package/docs/prototypes/v3-asset.schema.json +1 -1
- package/docs/v3-asset-persistence.md +3 -1
- package/docs/v3-design.md +3 -0
- package/docs/v3-motion-contract.md +178 -0
- package/docs/v3-operator-contracts.md +5 -0
- package/docs/v3-review-2026-09-22.md +127 -0
- package/docs/v3-status.md +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
/*
|
|
5
|
+
* V2 → V3 converter (ADR-0087 decisions 1 and 2).
|
|
6
|
+
*
|
|
7
|
+
* A V2 source describes each part by an authored box and a per-axis rule that says how the part
|
|
8
|
+
* follows the instance size: `scale`, `fixed`/`min`/`max`/`center`, `span`, `aspect-*`, and `repeat`.
|
|
9
|
+
* Every one of those rules is a first-degree expression in the instance size (`src/sizing.ts`), so the
|
|
10
|
+
* same placement can be written as a V3 graph of `add@1`, `mul@1`, `div@1` and `fit-pitch@1` nodes.
|
|
11
|
+
* That is what this file does. It reproduces V2 behaviour; it does not improve the asset.
|
|
12
|
+
*
|
|
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 polygon whose path does not fill its declared size
|
|
15
|
+
* - a part rotated off the axes whose size still follows the instance (V2 shears it; V3 placement is rigid,
|
|
16
|
+
* ruling 1: those parts are re-authored, not converted)
|
|
17
|
+
* - joints and parameters, until `docs/v3-motion-contract.md` is implemented
|
|
18
|
+
*
|
|
19
|
+
* What it normalises on purpose it lists in `notes` with the value before and after: a corner radius V2 clamped
|
|
20
|
+
* when drawing, a polygon path recentred with its placement moved by the same amount, the palette it snapshotted.
|
|
21
|
+
* What it converts but does not carry, it lists in `lost`. Nothing is dropped without being named in one of the two.
|
|
22
|
+
*
|
|
23
|
+
* `compareV2WithV3` is the definition of "the same" from ADR-0087 decision 2: same part list, each
|
|
24
|
+
* part's centre, world extents and rotation within tolerance, same material, at the base size and at
|
|
25
|
+
* every combination of doubled axes.
|
|
26
|
+
*/
|
|
27
|
+
import { compileV3Asset } from "./v3-asset.js";
|
|
28
|
+
import { v3WorldBoxesOf, v3WritersOf } from "./v3-gate.js";
|
|
29
|
+
import { V3ContractError } from "./v3-contract.js";
|
|
30
|
+
import { validate } from "./validate.js";
|
|
31
|
+
import { centredPart } from "./origin.js";
|
|
32
|
+
import { rotatedExtentOf } from "./blueprint.js";
|
|
33
|
+
import { anchorOf, clearance, longAxis, repeatOffset, repeatPlan, sizingPosition, sizingScale } from "./sizing.js";
|
|
34
|
+
import { AXES, REPEAT_LIMIT, SEGMENT_PRESETS } from "./types.js";
|
|
35
|
+
import { driverValue } from "./v3-driver.js";
|
|
36
|
+
import { jointOriginPosition } from "./sizing.js";
|
|
37
|
+
/** A short stable fingerprint of the palette, so a report can say which colours it snapshotted. */
|
|
38
|
+
export function paletteHash(palette) {
|
|
39
|
+
const text = JSON.stringify(Object.entries(palette).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
40
|
+
let h = 5381;
|
|
41
|
+
for (let i = 0; i < text.length; i++)
|
|
42
|
+
h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
|
|
43
|
+
return h.toString(16).padStart(8, '0');
|
|
44
|
+
}
|
|
45
|
+
/** The V3 design inputs that stand for the instance size, in mm. */
|
|
46
|
+
export const V3_SIZE_INPUTS = Object.freeze({ x: 'size.x', y: 'size.y', z: 'size.z' });
|
|
47
|
+
/** How far a V2 instance may grow in the converted asset, as a multiple of the base box. */
|
|
48
|
+
const SIZE_RANGE = 10;
|
|
49
|
+
const ONE = { kind: 'one' };
|
|
50
|
+
const SEG_DEFAULT = 12;
|
|
51
|
+
const RIGHT_ANGLE_TOLERANCE = 1e-9;
|
|
52
|
+
/** Builds the V3 graph value by value. Constants are shared by value; node ids stay readable. */
|
|
53
|
+
class GraphBuilder {
|
|
54
|
+
graph = { version: 'r3-spatial-1', inputs: [], constants: [], nodes: [] };
|
|
55
|
+
ids = new Set();
|
|
56
|
+
constants = new Map();
|
|
57
|
+
claim(id, path) {
|
|
58
|
+
if (this.ids.has(id))
|
|
59
|
+
throw new V3ContractError('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
60
|
+
this.ids.add(id);
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
input(id, unit, min, max, role) {
|
|
64
|
+
this.graph.inputs.push({ id: this.claim(id, 'input'), unit, min, max, role });
|
|
65
|
+
return id;
|
|
66
|
+
}
|
|
67
|
+
constant(raw, unit, hint) {
|
|
68
|
+
const value = raw === 0 ? 0 : raw; // -0 would not survive JSON
|
|
69
|
+
const key = `${unit}:${value}`;
|
|
70
|
+
const known = this.constants.get(key);
|
|
71
|
+
if (known)
|
|
72
|
+
return known;
|
|
73
|
+
let id = `k.${hint}`;
|
|
74
|
+
for (let n = 2; this.ids.has(id); n++)
|
|
75
|
+
id = `k.${hint}.${n}`;
|
|
76
|
+
this.claim(id, 'constant');
|
|
77
|
+
this.graph.constants.push({ id, unit, value });
|
|
78
|
+
this.constants.set(key, id);
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
node(id, op, args, port, params) {
|
|
82
|
+
const out = `${id}.value`;
|
|
83
|
+
const node = { id: this.claim(id, 'node'), op, args, outputs: { [port]: this.claim(out, id) } };
|
|
84
|
+
if (params)
|
|
85
|
+
node.params = params;
|
|
86
|
+
this.graph.nodes.push(node);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
add(id, a, b) {
|
|
90
|
+
return this.node(id, 'add@1', [a, b], 'value');
|
|
91
|
+
}
|
|
92
|
+
mul(id, a, b) {
|
|
93
|
+
return this.node(id, 'mul@1', [a, b], 'value');
|
|
94
|
+
}
|
|
95
|
+
div(id, a, b) {
|
|
96
|
+
return this.node(id, 'div@1', [a, b], 'value');
|
|
97
|
+
}
|
|
98
|
+
/** A length as a graph reference: constants become shared constant entries. */
|
|
99
|
+
len(v, hint) {
|
|
100
|
+
return v.kind === 'ref' ? v.ref : this.constant(v.value, 'mm', hint);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function round6(v) {
|
|
104
|
+
return Math.round(v * 1e6) / 1e6;
|
|
105
|
+
}
|
|
106
|
+
function isRightAngle(deg) {
|
|
107
|
+
const turns = deg / 90;
|
|
108
|
+
return Math.abs(turns - Math.round(turns)) < RIGHT_ANGLE_TOLERANCE;
|
|
109
|
+
}
|
|
110
|
+
/** Rx·Ry·Rz for degrees, the order V2 (`three` 'XYZ') and V3 `rigid@1` share. */
|
|
111
|
+
export function eulerXYZ(rotation) {
|
|
112
|
+
const rad = Math.PI / 180;
|
|
113
|
+
const cx = Math.cos((rotation?.x ?? 0) * rad), sx = Math.sin((rotation?.x ?? 0) * rad);
|
|
114
|
+
const cy = Math.cos((rotation?.y ?? 0) * rad), sy = Math.sin((rotation?.y ?? 0) * rad);
|
|
115
|
+
const cz = Math.cos((rotation?.z ?? 0) * rad), sz = Math.sin((rotation?.z ?? 0) * rad);
|
|
116
|
+
return [
|
|
117
|
+
[cy * cz, -cy * sz, sy],
|
|
118
|
+
[sx * sy * cz + cx * sz, -sx * sy * sz + cx * cz, -sx * cy],
|
|
119
|
+
[-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
/** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
|
|
123
|
+
function axisPermutation(rotation) {
|
|
124
|
+
if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
|
|
125
|
+
return null;
|
|
126
|
+
const r = eulerXYZ(rotation);
|
|
127
|
+
const out = {};
|
|
128
|
+
for (const [i, local] of AXES.entries()) {
|
|
129
|
+
const world = AXES.find((_, a) => Math.abs(r[a][i]) > 0.5);
|
|
130
|
+
if (!world)
|
|
131
|
+
return null;
|
|
132
|
+
out[local] = world;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function materialOf(part, palette) {
|
|
137
|
+
const m = part.material ?? {};
|
|
138
|
+
const known = ['token', 'preset', 'transparent', 'emissive'];
|
|
139
|
+
const extra = Object.keys(m).filter(k => !known.includes(k));
|
|
140
|
+
if (extra.length)
|
|
141
|
+
return { refusal: { code: 'MATERIAL_FIELD', part: part.name, detail: `V3 material has no ${extra.join(', ')}` } };
|
|
142
|
+
if (!m.token)
|
|
143
|
+
return { refusal: { code: 'MATERIAL_TOKEN', part: part.name, detail: 'material has no palette token' } };
|
|
144
|
+
if (!Object.hasOwn(palette, m.token))
|
|
145
|
+
return { refusal: { code: 'PALETTE_TOKEN', part: part.name, detail: `no colour for ${m.token}` } };
|
|
146
|
+
const material = { token: m.token };
|
|
147
|
+
if (m.preset !== undefined)
|
|
148
|
+
material.preset = m.preset;
|
|
149
|
+
if (m.transparent !== undefined)
|
|
150
|
+
material.transparent = m.transparent;
|
|
151
|
+
if (m.emissive) {
|
|
152
|
+
const token = m.emissive.token ?? m.token;
|
|
153
|
+
if (!Object.hasOwn(palette, token))
|
|
154
|
+
return { refusal: { code: 'PALETTE_TOKEN', part: part.name, detail: `no colour for emissive ${token}` } };
|
|
155
|
+
// V2 reads a missing `on` as off (`keys.ts`); V3 wants it written down.
|
|
156
|
+
material.emissive = { token, intensity: m.emissive.intensity, on: m.emissive.on ?? false };
|
|
157
|
+
}
|
|
158
|
+
return { material };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Convert one validated V2 source to a V3 asset that places every part where V2 places it, at every
|
|
162
|
+
* instance size in the input range. See the file comment for what is refused and what is listed as lost.
|
|
163
|
+
*/
|
|
164
|
+
export function convertV2ToV3(source, options) {
|
|
165
|
+
const lost = [];
|
|
166
|
+
const refusals = [];
|
|
167
|
+
const notes = [];
|
|
168
|
+
const checked = validate(source);
|
|
169
|
+
if (checked.errors.length) {
|
|
170
|
+
return {
|
|
171
|
+
status: 'refused',
|
|
172
|
+
refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
|
|
173
|
+
lost,
|
|
174
|
+
notes
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const src = source;
|
|
178
|
+
const base = src.base;
|
|
179
|
+
const palette = options.palette;
|
|
180
|
+
notes.push({ code: 'PALETTE', detail: `colours snapshotted from palette ${options.paletteId ?? `#${paletteHash(palette)}`}` });
|
|
181
|
+
if (src.detailLevel)
|
|
182
|
+
lost.push({ code: 'DETAIL_LEVEL', detail: `detailLevel "${src.detailLevel}" has no V3 field` });
|
|
183
|
+
if (src.styleKit)
|
|
184
|
+
lost.push({ code: 'STYLE_KIT', detail: `styleKit "${src.styleKit}" has no V3 field` });
|
|
185
|
+
const g = new GraphBuilder();
|
|
186
|
+
const asset = {
|
|
187
|
+
version: 'figure-v3-asset-1',
|
|
188
|
+
name: options.name ?? src.type,
|
|
189
|
+
document: {
|
|
190
|
+
version: 'figure-v3-prototype-1',
|
|
191
|
+
model: g.graph,
|
|
192
|
+
capabilities: {
|
|
193
|
+
version: 'r3-capability-3',
|
|
194
|
+
assetFrame: 'asset',
|
|
195
|
+
bindings: [],
|
|
196
|
+
// ADR-0051 ④: the names travel; V3 checks them against the same list V2 did.
|
|
197
|
+
...(src.capabilities?.length ? { names: [...src.capabilities] } : {})
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
designInputs: {},
|
|
201
|
+
stateDefaults: {},
|
|
202
|
+
palette: {},
|
|
203
|
+
appearance: [],
|
|
204
|
+
// ADR-0044 · ADR-0045: the V2 base box and placement become the declared occupancy; V2 read a missing placement as floor.
|
|
205
|
+
occupancy: { extent: { ...V3_SIZE_INPUTS }, placement: src.placement ?? 'floor' }
|
|
206
|
+
};
|
|
207
|
+
// Instance size inputs. The range starts at the pitch on a spreading repeat axis (V2 draws at least one
|
|
208
|
+
// copy below that; `fit-pitch@1` draws none) and stops where V2 would silently clamp the copy count.
|
|
209
|
+
const sizeMin = { x: 1, y: 1, z: 1 };
|
|
210
|
+
const sizeMax = { x: base.x * SIZE_RANGE, y: base.y * SIZE_RANGE, z: base.z * SIZE_RANGE };
|
|
211
|
+
const parts = src.parts.map(p => centredPart(p, base));
|
|
212
|
+
for (const cp of parts) {
|
|
213
|
+
if (cp.sizing !== 'repeat' || !cp.repeat)
|
|
214
|
+
continue;
|
|
215
|
+
const axis = cp.repeat.axis;
|
|
216
|
+
if (anchorOf(withSizing(cp), base, axis) === 'scale')
|
|
217
|
+
continue;
|
|
218
|
+
sizeMin[axis] = Math.max(sizeMin[axis], cp.repeat.pitch);
|
|
219
|
+
const cap = REPEAT_LIMIT * cp.repeat.pitch;
|
|
220
|
+
if (sizeMax[axis] > cap) {
|
|
221
|
+
sizeMax[axis] = cap;
|
|
222
|
+
lost.push({
|
|
223
|
+
code: 'REPEAT_LIMIT',
|
|
224
|
+
part: cp.name,
|
|
225
|
+
detail: `V2 clamps the copy count at ${REPEAT_LIMIT}; the V3 input ${V3_SIZE_INPUTS[axis]} stops at ${cap} mm instead`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const size = {};
|
|
230
|
+
for (const a of AXES) {
|
|
231
|
+
size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
|
|
232
|
+
asset.designInputs[V3_SIZE_INPUTS[a]] = base[a];
|
|
233
|
+
}
|
|
234
|
+
const ratio = (v, hint) => g.constant(round6(v), 'ratio', hint);
|
|
235
|
+
const scaleFactor = new Map();
|
|
236
|
+
const factorOfScale = (a) => {
|
|
237
|
+
let f = scaleFactor.get(a);
|
|
238
|
+
if (!f) {
|
|
239
|
+
f = { kind: 'ref', ref: g.div(`scale.${a}`, size[a], g.constant(base[a], 'mm', `base.${a}`)), sig: `scale.${a}` };
|
|
240
|
+
scaleFactor.set(a, f);
|
|
241
|
+
}
|
|
242
|
+
return f;
|
|
243
|
+
};
|
|
244
|
+
const times = (id, v, f) => {
|
|
245
|
+
if (f.kind === 'one')
|
|
246
|
+
return v;
|
|
247
|
+
if (v.kind === 'const' && v.value === 0)
|
|
248
|
+
return v;
|
|
249
|
+
return { kind: 'ref', ref: g.mul(id, g.len(v, id), f.ref) };
|
|
250
|
+
};
|
|
251
|
+
const plans = [];
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
// Motion (docs/v3-motion-contract.md), planned after the parts and read when the part nodes are written.
|
|
254
|
+
const info = new Map();
|
|
255
|
+
const spinOf = new Map();
|
|
256
|
+
const shiftsOf = new Map();
|
|
257
|
+
const frameOf = new Map(); // part → the joint whose frame carries it
|
|
258
|
+
const motionOf = new Map(); // joint → pose asset.rest → asset
|
|
259
|
+
const partsByName = new Map(parts.map(cp => [cp.name, cp]));
|
|
260
|
+
const jointByChild = new Map((src.joints ?? []).map(j => [j.child, j]));
|
|
261
|
+
const carrierJointOf = (name) => {
|
|
262
|
+
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
263
|
+
const j = jointByChild.get(p.name);
|
|
264
|
+
if (j)
|
|
265
|
+
return j.name;
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
};
|
|
269
|
+
for (const cp of parts) {
|
|
270
|
+
const carrier = carrierJointOf(cp.name);
|
|
271
|
+
if (carrier)
|
|
272
|
+
frameOf.set(cp.name, carrier);
|
|
273
|
+
}
|
|
274
|
+
for (const cp of parts) {
|
|
275
|
+
const name = cp.name;
|
|
276
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(name)) {
|
|
277
|
+
refusals.push({ code: 'PART_ID', part: name, detail: 'V3 ids start with a letter and use letters, digits, _ . -' });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (seen.has(name)) {
|
|
281
|
+
refusals.push({ code: 'PART_ID', part: name, detail: 'duplicate part name' });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
seen.add(name);
|
|
285
|
+
if (cp.parent && !frameOf.has(name))
|
|
286
|
+
lost.push({ code: 'PARENT', part: name, detail: `attached to "${cp.parent}" with no joint above it; V3 places it in the asset frame, where it already was at rest` });
|
|
287
|
+
if (cp.label)
|
|
288
|
+
lost.push({ code: 'LABEL', part: name, detail: 'label has no V3 field' });
|
|
289
|
+
if (cp.materialSlot)
|
|
290
|
+
lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
|
|
291
|
+
if (cp.capability)
|
|
292
|
+
lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'slot/port role is not carried as a V3 binding' });
|
|
293
|
+
const { material, refusal } = materialOf(cp, palette);
|
|
294
|
+
if (refusal) {
|
|
295
|
+
refusals.push(refusal);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
for (const token of [material.token, material.emissive?.token])
|
|
299
|
+
if (token)
|
|
300
|
+
asset.palette[token] = palette[token];
|
|
301
|
+
if (cp.primitive === 'wall')
|
|
302
|
+
lost.push({ code: 'WALL_KIND', part: name, detail: 'drawn as a box; the "wall" kind has no V3 field' });
|
|
303
|
+
const hollow = cp.shape?.hollow && cp.shape.hollow.wall > 0 ? cp.shape.hollow : null;
|
|
304
|
+
if (hollow && cp.primitive !== 'rect') {
|
|
305
|
+
refusals.push({ code: 'NO_PROVIDER', part: name, detail: `V3 has no hollow ${cp.primitive}` });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// A polygon's path is drawn as written, so its bounding box may sit off the part centre. V3 keeps the visible
|
|
309
|
+
// position: the path is recentred and the placement moves by the same amount (ADR-0087 ruling 3).
|
|
310
|
+
let path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
|
|
311
|
+
const recentre = { x: 0, z: 0 };
|
|
312
|
+
if (cp.primitive === 'polygon') {
|
|
313
|
+
const xs = path.map(q => q.x), zs = path.map(q => q.y);
|
|
314
|
+
const spanX = Math.max(...xs) - Math.min(...xs), spanZ = Math.max(...zs) - Math.min(...zs);
|
|
315
|
+
if (path.length < 3 || Math.abs(spanX - cp.transform.size.x) > 1e-6 || Math.abs(spanZ - cp.transform.size.z) > 1e-6) {
|
|
316
|
+
// V2 sizing reads `size` while the drawing reads the path; when they disagree there is no one truth to keep.
|
|
317
|
+
refusals.push({
|
|
318
|
+
code: 'POLYGON_SIZE_MISMATCH',
|
|
319
|
+
part: name,
|
|
320
|
+
detail: `path spans ${spanX}×${spanZ}; size says ${cp.transform.size.x}×${cp.transform.size.z}`
|
|
321
|
+
});
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
recentre.x = round6((Math.max(...xs) + Math.min(...xs)) / 2);
|
|
325
|
+
recentre.z = round6((Math.max(...zs) + Math.min(...zs)) / 2);
|
|
326
|
+
if (recentre.x !== 0 || recentre.z !== 0) {
|
|
327
|
+
path = path.map(q => ({ x: round6(q.x - recentre.x), y: round6(q.y - recentre.z) }));
|
|
328
|
+
notes.push({
|
|
329
|
+
code: 'POLYGON_RECENTRED',
|
|
330
|
+
part: name,
|
|
331
|
+
detail: `path was drawn about (${recentre.x}, ${recentre.z}) in its own frame; moved to the origin and the placement moved by the same amount, so it draws where it drew`
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (cp.shape?.round)
|
|
335
|
+
lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
|
|
336
|
+
}
|
|
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
|
+
const segments = cp.segments ?? SEG_DEFAULT;
|
|
340
|
+
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
|
|
341
|
+
refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const subject = withSizing(cp);
|
|
345
|
+
const extent = rotatedExtentOf(cp);
|
|
346
|
+
const rules = { x: anchorOf(subject, base, 'x'), y: anchorOf(subject, base, 'y'), z: anchorOf(subject, base, 'z') };
|
|
347
|
+
const repeat = cp.sizing === 'repeat' && cp.repeat ? cp.repeat : null;
|
|
348
|
+
// World factor per axis: how much the rotated part's extent along that axis follows the instance.
|
|
349
|
+
const factor = {};
|
|
350
|
+
const centre = {};
|
|
351
|
+
const pending = [];
|
|
352
|
+
for (const a of AXES) {
|
|
353
|
+
const rule = rules[a];
|
|
354
|
+
const gaps = clearance(subject, base, a);
|
|
355
|
+
const reach = extent[a] / 2;
|
|
356
|
+
const pos = cp.transform.position[a];
|
|
357
|
+
if (rule === 'scale') {
|
|
358
|
+
factor[a] = factorOfScale(a);
|
|
359
|
+
centre[a] = pos === 0 ? { kind: 'const', value: 0 } : { kind: 'ref', ref: g.mul(`${name}.${a}.centre`, size[a], ratio(pos / base[a], `${name}.${a}.at`)) };
|
|
360
|
+
}
|
|
361
|
+
else if (rule === 'span') {
|
|
362
|
+
const free = g.add(`${name}.${a}.free`, size[a], g.constant(round6(-(gaps.min + gaps.max)), 'mm', `${name}.${a}.gaps`));
|
|
363
|
+
factor[a] = { kind: 'ref', ref: g.div(`${name}.${a}.factor`, free, g.constant(extent[a], 'mm', `${name}.${a}.own`)), sig: `span.${name}.${a}` };
|
|
364
|
+
centre[a] = { kind: 'const', value: round6((gaps.min - gaps.max) / 2) };
|
|
365
|
+
}
|
|
366
|
+
else if (rule === 'min') {
|
|
367
|
+
factor[a] = ONE;
|
|
368
|
+
centre[a] = { kind: 'ref', ref: g.add(`${name}.${a}.centre`, g.mul(`${name}.${a}.half`, size[a], ratio(-0.5, 'minusHalf')), g.constant(round6(gaps.min + reach), 'mm', `${name}.${a}.fromMin`)) };
|
|
369
|
+
}
|
|
370
|
+
else if (rule === 'max') {
|
|
371
|
+
factor[a] = ONE;
|
|
372
|
+
centre[a] = { kind: 'ref', ref: g.add(`${name}.${a}.centre`, g.mul(`${name}.${a}.half`, size[a], ratio(0.5, 'half')), g.constant(round6(-(gaps.max + reach)), 'mm', `${name}.${a}.fromMax`)) };
|
|
373
|
+
}
|
|
374
|
+
else if (rule === 'center') {
|
|
375
|
+
factor[a] = ONE;
|
|
376
|
+
centre[a] = { kind: 'const', value: pos };
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
pending.push(a); // aspect-*: follows another axis, settled below
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// A curved part keeps a round section: both cross axes take the geometric mean of their two factors
|
|
383
|
+
// (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
|
|
384
|
+
// it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
|
|
385
|
+
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && cp.keepRound !== false) {
|
|
386
|
+
const spin = cp.primitive === 'sphere' ? null : longAxis(subject);
|
|
387
|
+
const across = AXES.filter(a => a !== spin);
|
|
388
|
+
const fs = across.map(a => factor[a] ?? ONE); // an aspect axis is still unresolved here and counts as fixed, as V2's keepRound sees it
|
|
389
|
+
const same = fs.every(f => f.kind === 'one') || fs.every(f => f.kind === 'ref' && f.sig === fs[0].sig);
|
|
390
|
+
if (!same) {
|
|
391
|
+
const asRef = (f) => (f.kind === 'one' ? ratio(1, 'one') : f.ref);
|
|
392
|
+
const even = { kind: 'ref', ref: g.node(`${name}.round`, 'geomean@1', fs.map(asRef), 'value'), sig: `round.${name}` };
|
|
393
|
+
for (const a of across)
|
|
394
|
+
factor[a] = even;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (const a of pending) {
|
|
398
|
+
const named = rules[a].slice('aspect-'.length);
|
|
399
|
+
factor[a] = factor[named];
|
|
400
|
+
centre[a] = times(`${name}.${a}.centre`, { kind: 'const', value: cp.transform.position[a] }, factor[named]);
|
|
401
|
+
}
|
|
402
|
+
// The V3 asset frame has its origin at the bottom of the box (ADR-0065); V2 sizing works from the centre.
|
|
403
|
+
centre.y = { kind: 'ref', ref: g.add(`${name}.y.fromBottom`, g.len(centre.y, `${name}.y.centred`), g.mul(`${name}.y.lift`, size.y, ratio(0.5, 'half'))) };
|
|
404
|
+
// A recentred polygon moves back by R·c, and that offset follows the world factor of each axis as the drawn
|
|
405
|
+
// mesh did in V2 (rotation baked, then scaled per world axis).
|
|
406
|
+
if (recentre.x !== 0 || recentre.z !== 0) {
|
|
407
|
+
const R = eulerXYZ(cp.transform.rotation);
|
|
408
|
+
for (const [a, axis] of AXES.entries()) {
|
|
409
|
+
const k = round6(R[a][0] * recentre.x + R[a][2] * recentre.z);
|
|
410
|
+
if (k === 0)
|
|
411
|
+
continue;
|
|
412
|
+
const shift = times(`${name}.${axis}.recentre`, { kind: 'const', value: k }, factor[axis]);
|
|
413
|
+
centre[axis] = { kind: 'ref', ref: g.add(`${name}.${axis}.drawnAt`, g.len(centre[axis], `${name}.${axis}.declared`), g.len(shift, `${name}.${axis}.shift`)) };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Local dimensions scale by the factor of the world axis each local axis lands on.
|
|
417
|
+
const perm = axisPermutation(cp.transform.rotation);
|
|
418
|
+
// A cylinder that does not keep its section round goes elliptical under a lopsided instance in V2. V3 cylinders
|
|
419
|
+
// and frustums have one radius per end (ruling 6: per-axis radii are a shape-definition change, not made here).
|
|
420
|
+
if (cp.primitive === 'cylinder' && cp.keepRound === false && perm) {
|
|
421
|
+
const spin = longAxis(subject);
|
|
422
|
+
const across = AXES.filter(a => a !== spin).map(a => factor[a]);
|
|
423
|
+
const same = across.every(f => f.kind === 'one') || across.every(f => f.kind === 'ref' && f.sig === across[0].sig);
|
|
424
|
+
if (!same) {
|
|
425
|
+
refusals.push({
|
|
426
|
+
code: 'ELLIPTIC_SECTION',
|
|
427
|
+
part: name,
|
|
428
|
+
detail: `keepRound is off and the section follows ${AXES.filter(a => a !== spin).map(a => `${a}:${rules[a]}`).join(' and ')}; V2 draws an ellipse, V3 has one radius per end`
|
|
429
|
+
});
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const follows = AXES.some(a => factor[a].kind !== 'one');
|
|
434
|
+
if (!perm && follows) {
|
|
435
|
+
refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const dim = (local) => times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE);
|
|
439
|
+
const localFrame = `${name}.local`;
|
|
440
|
+
// A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
|
|
441
|
+
const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
|
|
442
|
+
const rot = cp.transform.rotation;
|
|
443
|
+
const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
|
|
444
|
+
let shape;
|
|
445
|
+
if (cp.primitive === 'cylinder' && frustum) {
|
|
446
|
+
// Both radii follow the same cross factor (keepRound made the two cross axes agree above).
|
|
447
|
+
const across = perm ? factor[perm.x] : ONE;
|
|
448
|
+
const top = times(`${name}.dim.radiusTop`, { kind: 'const', value: cp.transform.size.x / 2 }, across);
|
|
449
|
+
const bottom = times(`${name}.dim.radiusBottom`, { kind: 'const', value: cp.transform.size.z / 2 }, across);
|
|
450
|
+
const height = dim('y');
|
|
451
|
+
shape = () => g.node(`${name}.shape`, 'frustum-shape@1', [g.len(top, `${name}.radiusTop`), g.len(bottom, `${name}.radiusBottom`), g.len(height, `${name}.height`)], 'shape', { frame: shapeFrame() });
|
|
452
|
+
}
|
|
453
|
+
else if (cp.primitive === 'cylinder') {
|
|
454
|
+
const radius = times(`${name}.dim.radius`, { kind: 'const', value: cp.transform.size.x / 2 }, perm ? factor[perm.x] : ONE);
|
|
455
|
+
const length = dim('y');
|
|
456
|
+
shape = () => g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radius, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() });
|
|
457
|
+
}
|
|
458
|
+
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
|
+
const [rx, ry, rz] = [r('x'), r('y'), r('z')];
|
|
461
|
+
shape = () => g.node(`${name}.shape`, 'sphere-shape@1', [g.len(rx, `${name}.rx`), g.len(ry, `${name}.ry`), g.len(rz, `${name}.rz`)], 'shape', { frame: shapeFrame() });
|
|
462
|
+
}
|
|
463
|
+
else if (cp.primitive === 'polygon') {
|
|
464
|
+
const h = dim('y');
|
|
465
|
+
const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
466
|
+
const points = path.flatMap((q, i) => [
|
|
467
|
+
times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx),
|
|
468
|
+
times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz)
|
|
469
|
+
]);
|
|
470
|
+
shape = () => g.node(`${name}.shape`, 'polygon-shape@1', [g.len(h, `${name}.h`), ...points.map((v, i) => g.len(v, `${name}.pt${i}`))], 'shape', { frame: shapeFrame() });
|
|
471
|
+
}
|
|
472
|
+
else if (cp.primitive === 'rect') {
|
|
473
|
+
// V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
|
|
474
|
+
const declared = cp.shape?.round ?? 0;
|
|
475
|
+
const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
|
|
476
|
+
if (round < declared)
|
|
477
|
+
notes.push({ code: 'ROUND_CLAMPED', part: name, detail: `corner radius declared ${declared} mm, drawn ${round} mm (half of the ${cp.transform.size.x}×${cp.transform.size.z} section)` });
|
|
478
|
+
if (round > 0 && (factor.x.kind !== 'one' || factor.z.kind !== 'one'))
|
|
479
|
+
lost.push({ code: 'ROUND_NOT_SCALED', part: name, detail: `corner radius ${round} mm stays fixed while the section follows the instance; V2 scales it with the section` });
|
|
480
|
+
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
481
|
+
if (hollow) {
|
|
482
|
+
// V2 scales the finished mesh, so a wall's thickness follows the axis it lies across and the floor follows the height.
|
|
483
|
+
const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
484
|
+
const wallX = times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx);
|
|
485
|
+
const wallZ = times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz);
|
|
486
|
+
const floor = times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy);
|
|
487
|
+
shape = () => g.node(`${name}.shape`, 'hollow-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.constant(round, 'mm', `${name}.round`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: shapeFrame() });
|
|
488
|
+
}
|
|
489
|
+
else
|
|
490
|
+
shape = () => g.node(`${name}.shape`, 'rounded-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.constant(round, 'mm', `${name}.round`)], 'shape', { frame: shapeFrame() });
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
494
|
+
shape = () => g.node(`${name}.shape`, 'box-shape@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`)], 'shape', { frame: shapeFrame() });
|
|
495
|
+
}
|
|
496
|
+
const appearance = { target: name, material: material };
|
|
497
|
+
if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
|
|
498
|
+
appearance.segments = segments;
|
|
499
|
+
info.set(name, { centre, factor, repeat: !!repeat });
|
|
500
|
+
if (!repeat) {
|
|
501
|
+
plans.push({
|
|
502
|
+
appearance,
|
|
503
|
+
nodes: () => {
|
|
504
|
+
const s = shape();
|
|
505
|
+
const shifts = shiftsOf.get(name) ?? [];
|
|
506
|
+
const frame = frameOf.get(name);
|
|
507
|
+
const finalTo = frame ? 'asset.rest' : 'asset';
|
|
508
|
+
// rest: local → F0; each slide F_k → F_k+1 along an asset axis; the last lands in finalTo.
|
|
509
|
+
let pose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => g.len(centre[a], `${name}.${a}`)), ...rotationRefs], 'pose', { from: localFrame, to: shifts.length ? `${name}.rest0` : finalTo });
|
|
510
|
+
shifts.forEach((shift, k) => {
|
|
511
|
+
const axis = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
|
|
512
|
+
const slide = g.node(`${name}.slide${k}`, 'axis-slide@1', [...axis, shift.q], 'pose', { from: `${name}.rest${k}`, to: k === shifts.length - 1 ? finalTo : `${name}.rest${k + 1}` });
|
|
513
|
+
pose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, pose], 'pose');
|
|
514
|
+
});
|
|
515
|
+
if (frame)
|
|
516
|
+
pose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), pose], 'pose');
|
|
517
|
+
const spin = spinOf.get(name);
|
|
518
|
+
if (spin) {
|
|
519
|
+
const axis = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
|
|
520
|
+
const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axis, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
|
|
521
|
+
pose = g.node(`${name}.spinning`, 'compose@1', [pose, turn], 'pose');
|
|
522
|
+
}
|
|
523
|
+
g.node(name, 'place@1', [s, pose], 'placed');
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// Repeat: copies pitch apart in the world (times the world factor of that axis), centred on the authored
|
|
529
|
+
// position, count = floor(room / pitch) with room = the instance size when the row spreads, else the base.
|
|
530
|
+
const axis = repeat.axis;
|
|
531
|
+
const spreads = rules[axis] !== 'scale';
|
|
532
|
+
const itemFrame = `${name}.item`;
|
|
533
|
+
plans.push({
|
|
534
|
+
appearance,
|
|
535
|
+
nodes: () => {
|
|
536
|
+
const s = shape();
|
|
537
|
+
let memberPose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => (a === axis ? g.constant(0, 'mm', 'zero') : g.len(centre[a], `${name}.${a}`))), ...rotationRefs], 'pose', { from: localFrame, to: itemFrame });
|
|
538
|
+
// A spinning repeated part (the conveyor's rollers) turns each copy about its own axis: the turn goes on the member.
|
|
539
|
+
const spin = spinOf.get(name);
|
|
540
|
+
if (spin) {
|
|
541
|
+
const axisRefs = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
|
|
542
|
+
const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axisRefs, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
|
|
543
|
+
memberPose = g.node(`${name}.spinning`, 'compose@1', [memberPose, turn], 'pose');
|
|
544
|
+
}
|
|
545
|
+
const member = g.node(name, 'member@1', [s, memberPose], 'placed');
|
|
546
|
+
const assembly = g.node(`${name}.assembly`, 'assembly@1', [member], 'assembly');
|
|
547
|
+
let layout;
|
|
548
|
+
let rowStart;
|
|
549
|
+
if (spreads) {
|
|
550
|
+
const zero = g.constant(0, 'mm', 'zero');
|
|
551
|
+
const pitch = g.constant(repeat.pitch, 'mm', `${name}.pitch`);
|
|
552
|
+
layout = g.node(`${name}.layout`, 'fit-pitch@1', [size[axis], zero, zero, pitch, pitch, ratio(0.5, 'half'), g.constant(REPEAT_LIMIT, 'count', 'repeatLimit')], 'layout');
|
|
553
|
+
rowStart = g.add(`${name}.rowStart`, g.len(centre[axis], `${name}.${axis}`), g.mul(`${name}.halfRow`, size[axis], ratio(-0.5, 'minusHalf')));
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
// A fixed count spread pitch·factor apart: `fixed-count@1` spaces `count` items evenly over L - e,
|
|
557
|
+
// so L = (count - 1)·pitch·factor + e with a 1 mm item extent, and the row starts e/2 before the first copy.
|
|
558
|
+
const count = Math.max(1, Math.min(REPEAT_LIMIT, Math.floor(base[axis] / repeat.pitch)));
|
|
559
|
+
const e = g.constant(1, 'mm', 'one');
|
|
560
|
+
const row = times(`${name}.row`, { kind: 'const', value: (count - 1) * repeat.pitch }, factor[axis]);
|
|
561
|
+
const L = g.add(`${name}.rowLength`, g.len(row, `${name}.row`), e);
|
|
562
|
+
layout = g.node(`${name}.layout`, 'fixed-count@1', [g.constant(count, 'count', `${name}.count`), L, g.constant(0, 'mm', 'zero'), g.constant(0, 'mm', 'zero'), e, ratio(0.5, 'half'), g.constant(REPEAT_LIMIT, 'count', 'repeatLimit')], 'layout');
|
|
563
|
+
const halfRow = times(`${name}.halfRow`, { kind: 'const', value: -((count - 1) * repeat.pitch) / 2 }, factor[axis]);
|
|
564
|
+
rowStart = g.add(`${name}.rowStart`, g.add(`${name}.rowCentre`, g.len(centre[axis], `${name}.${axis}`), g.len(halfRow, `${name}.halfRow`)), g.constant(-0.5, 'mm', 'minusHalfMm'));
|
|
565
|
+
}
|
|
566
|
+
// The row as a whole shifts with a translation channel and moves with the joint frame that carries it.
|
|
567
|
+
const shifts = shiftsOf.get(name) ?? [];
|
|
568
|
+
const frame = frameOf.get(name);
|
|
569
|
+
const finalTo = frame ? 'asset.rest' : 'asset';
|
|
570
|
+
let rootPose = g.node(`${name}.root`, 'rigid@1', [...AXES.map(a => (a === axis ? rowStart : g.constant(0, 'mm', 'zero'))), ...AXES.map(() => g.constant(0, 'deg', 'deg.0'))], 'pose', { from: itemFrame, to: shifts.length ? `${name}.rest0` : finalTo });
|
|
571
|
+
shifts.forEach((shift, k) => {
|
|
572
|
+
const axisRefs = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
|
|
573
|
+
const slide = g.node(`${name}.slide${k}`, 'axis-slide@1', [...axisRefs, shift.q], 'pose', { from: `${name}.rest${k}`, to: k === shifts.length - 1 ? finalTo : `${name}.rest${k + 1}` });
|
|
574
|
+
rootPose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, rootPose], 'pose');
|
|
575
|
+
});
|
|
576
|
+
if (frame)
|
|
577
|
+
rootPose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), rootPose], 'pose');
|
|
578
|
+
g.node(`${name}.repeat`, 'repeat@1', [layout, assembly, rootPose], 'collection', { axis });
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
planMotion({ src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio, times, factorOfScale });
|
|
583
|
+
if (refusals.length)
|
|
584
|
+
return { status: 'refused', refusals, lost, notes };
|
|
585
|
+
for (const plan of plans) {
|
|
586
|
+
plan.nodes();
|
|
587
|
+
asset.appearance.push(plan.appearance);
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
compileV3Asset(asset);
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
const err = e;
|
|
594
|
+
return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, notes };
|
|
595
|
+
}
|
|
596
|
+
return { status: 'converted', asset, lost, notes };
|
|
597
|
+
}
|
|
598
|
+
/** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
|
|
599
|
+
function withSizing(cp) {
|
|
600
|
+
return { ...cp, sizing: cp.sizing ?? 'scale' };
|
|
601
|
+
}
|
|
602
|
+
const I3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
603
|
+
const mm3 = (a, b) => a.map(r => [0, 1, 2].map(j => r[0] * b[0][j] + r[1] * b[1][j] + r[2] * b[2][j]));
|
|
604
|
+
const mv3 = (a, v) => a.map(r => r[0] * v[0] + r[1] * v[1] + r[2] * v[2]);
|
|
605
|
+
const tr3 = (a) => [0, 1, 2].map(i => [0, 1, 2].map(j => a[j][i]));
|
|
606
|
+
/** Rotation about a unit axis by degrees (Rodrigues), the matrix three's setFromAxisAngle builds. */
|
|
607
|
+
export function axisAngle(axis, deg) {
|
|
608
|
+
const n = Math.hypot(axis.x, axis.y, axis.z);
|
|
609
|
+
const [x, y, z] = [axis.x / n, axis.y / n, axis.z / n];
|
|
610
|
+
const q = (deg * Math.PI) / 180, c = Math.cos(q), s = Math.sin(q), k = 1 - c;
|
|
611
|
+
return [
|
|
612
|
+
[c + x * x * k, x * y * k - z * s, x * z * k + y * s],
|
|
613
|
+
[y * x * k + z * s, c + y * y * k, y * z * k - x * s],
|
|
614
|
+
[z * x * k - y * s, z * y * k + x * s, c + z * z * k]
|
|
615
|
+
];
|
|
616
|
+
}
|
|
617
|
+
/** V2's channel sampling (`things-scene` sampleChannel): hold the ends, `step` keeps the earlier key. */
|
|
618
|
+
function sampleV2(channel, at) {
|
|
619
|
+
const keys = channel.keys;
|
|
620
|
+
if (at <= keys[0].at)
|
|
621
|
+
return keys[0].value;
|
|
622
|
+
const last = keys[keys.length - 1];
|
|
623
|
+
if (at >= last.at)
|
|
624
|
+
return last.value;
|
|
625
|
+
let i = 0;
|
|
626
|
+
while (i < keys.length - 2 && keys[i + 1].at <= at)
|
|
627
|
+
i++;
|
|
628
|
+
const from = keys[i], to = keys[i + 1];
|
|
629
|
+
if (channel.interpolation === 'step')
|
|
630
|
+
return from.value;
|
|
631
|
+
const span = to.at - from.at;
|
|
632
|
+
const r = span > 0 ? (at - from.at) / span : 0;
|
|
633
|
+
if (typeof from.value === 'number')
|
|
634
|
+
return (from.value + r * (to.value - from.value));
|
|
635
|
+
const f = from.value, t = to.value;
|
|
636
|
+
return { x: f.x + r * (t.x - f.x), y: f.y + r * (t.y - f.y), z: f.z + r * (t.z - f.z) };
|
|
637
|
+
}
|
|
638
|
+
/** V2's clip length: the last key time across its channels, in seconds. */
|
|
639
|
+
export function v2ClipDuration(clip) {
|
|
640
|
+
let last = 0;
|
|
641
|
+
for (const ch of clip.channels) {
|
|
642
|
+
const end = ch.keys[ch.keys.length - 1]?.at;
|
|
643
|
+
if (typeof end === 'number' && end > last)
|
|
644
|
+
last = end;
|
|
645
|
+
}
|
|
646
|
+
return last;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Where V2 draws every part (and copy) at an instance scale and a motion state, as oriented world boxes.
|
|
650
|
+
* Motion follows things-scene: a translation channel shifts the part in the figure frame by the part's world
|
|
651
|
+
* factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
|
|
652
|
+
* everything in its frame about its sized origin, parents first (ADR-0066).
|
|
653
|
+
*/
|
|
654
|
+
export function v2WorldBoxes(source, scale, state = {}) {
|
|
655
|
+
const base = source.base;
|
|
656
|
+
const out = [];
|
|
657
|
+
const shifts = new Map();
|
|
658
|
+
const turns = new Map();
|
|
659
|
+
const jointValues = new Map();
|
|
660
|
+
const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
|
|
661
|
+
const apply = (channels, at) => {
|
|
662
|
+
for (const ch of channels) {
|
|
663
|
+
if (ch.path === undefined) {
|
|
664
|
+
jointValues.set(ch.target, (jointValues.get(ch.target) ?? 0) + sampleV2(ch, at));
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const v = sampleV2(ch, at);
|
|
668
|
+
if (ch.path === 'translation') {
|
|
669
|
+
const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
|
|
670
|
+
shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
|
|
671
|
+
}
|
|
672
|
+
else if (ch.path === 'rotation') {
|
|
673
|
+
const Rp = rotationOf.get(ch.target) ?? I3;
|
|
674
|
+
const Rch = eulerXYZ(v);
|
|
675
|
+
const wrapped = mm3(mm3(Rp, Rch), tr3(Rp));
|
|
676
|
+
turns.set(ch.target, mm3(turns.get(ch.target) ?? I3, wrapped));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
for (const p of source.parameters ?? []) {
|
|
681
|
+
const value = state.parameters?.[p.name] ?? p.default ?? p.range.min;
|
|
682
|
+
const u = p.range.max > p.range.min ? (value - p.range.min) / (p.range.max - p.range.min) : 0;
|
|
683
|
+
apply(p.clip.channels, u);
|
|
684
|
+
}
|
|
685
|
+
for (const clip of source.animations ?? []) {
|
|
686
|
+
const duration = v2ClipDuration(clip);
|
|
687
|
+
const t = state.clipTime?.[clip.name] ?? 0;
|
|
688
|
+
apply(clip.channels, duration > 0 ? t % duration : 0);
|
|
689
|
+
}
|
|
690
|
+
// Joint frames: world transform per joint, parents first.
|
|
691
|
+
const partsByName = new Map(source.parts.map(p => [p.name, p]));
|
|
692
|
+
const jointByChild = new Map((source.joints ?? []).map(j => [j.child, j]));
|
|
693
|
+
const carrierOf = (name) => {
|
|
694
|
+
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
695
|
+
const j = jointByChild.get(p.name);
|
|
696
|
+
if (j)
|
|
697
|
+
return j;
|
|
698
|
+
}
|
|
699
|
+
return undefined;
|
|
700
|
+
};
|
|
701
|
+
const worldOf = new Map();
|
|
702
|
+
const worldJoint = (j) => {
|
|
703
|
+
const known = worldOf.get(j.name);
|
|
704
|
+
if (known)
|
|
705
|
+
return known;
|
|
706
|
+
const child = partsByName.get(j.child);
|
|
707
|
+
const attach = child.parent ? partsByName.get(child.parent) : undefined;
|
|
708
|
+
const cj = { origin: { ...j.origin, y: j.origin.y - base.y / 2 } };
|
|
709
|
+
const at = jointOriginPosition(cj, attach ? withSizing(centredPart(attach, base)) : undefined, scale, base);
|
|
710
|
+
const o = [at.x * scale.x, at.y * scale.y + (base.y * scale.y) / 2, at.z * scale.z];
|
|
711
|
+
const limits = j.limits;
|
|
712
|
+
let q = jointValues.get(j.name) ?? 0;
|
|
713
|
+
if (limits)
|
|
714
|
+
q = Math.min(Math.max(q, limits.min), limits.max);
|
|
715
|
+
let local;
|
|
716
|
+
if (j.type === 'prismatic') {
|
|
717
|
+
const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
|
|
718
|
+
local = { r: I3, t: [(j.axis.x / n) * q * scale.x, (j.axis.y / n) * q * scale.y, (j.axis.z / n) * q * scale.z] };
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
const R = axisAngle(j.axis, q);
|
|
722
|
+
const Ro = mv3(R, o);
|
|
723
|
+
local = { r: R, t: [o[0] - Ro[0], o[1] - Ro[1], o[2] - Ro[2]] };
|
|
724
|
+
}
|
|
725
|
+
const parentJoint = attach ? carrierOf(attach.name) : undefined;
|
|
726
|
+
const world = parentJoint ? composeRT(worldJoint(parentJoint), local) : local;
|
|
727
|
+
worldOf.set(j.name, world);
|
|
728
|
+
return world;
|
|
729
|
+
};
|
|
730
|
+
for (const part of source.parts) {
|
|
731
|
+
const cp = withSizing(centredPart(part, base));
|
|
732
|
+
const at = sizingPosition(cp, scale, base);
|
|
733
|
+
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
|
+
const drawnSize = { ...cp.transform.size };
|
|
737
|
+
if (part.primitive === 'cylinder' && drawnSize.x !== drawnSize.z)
|
|
738
|
+
drawnSize.x = drawnSize.z = Math.max(drawnSize.x, drawnSize.z);
|
|
739
|
+
const extent = rotatedExtentOf({ transform: { ...cp.transform, size: drawnSize } });
|
|
740
|
+
let rotation = eulerXYZ(part.transform.rotation);
|
|
741
|
+
const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
|
|
742
|
+
const box = { x: world('x'), y: world('y'), z: world('z') };
|
|
743
|
+
box.y.centre += (base.y * scale.y) / 2;
|
|
744
|
+
// A polygon is drawn where its path says, which may be off the part centre: rotation baked, then scaled per world axis.
|
|
745
|
+
if (part.primitive === 'polygon' && part.shape?.path?.length) {
|
|
746
|
+
const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
|
|
747
|
+
const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
|
|
748
|
+
for (const [a, axis] of AXES.entries())
|
|
749
|
+
box[axis].centre += (rotation[a][0] * c[0] + rotation[a][2] * c[2]) * box[axis].factor;
|
|
750
|
+
}
|
|
751
|
+
// Local dimensions: each local axis lands on a world axis and takes that axis's factor.
|
|
752
|
+
const perm = axisPermutation(part.transform.rotation);
|
|
753
|
+
const dims = [
|
|
754
|
+
drawnSize.x * (perm ? box[perm.x].factor : 1),
|
|
755
|
+
drawnSize.y * (perm ? box[perm.y].factor : 1),
|
|
756
|
+
drawnSize.z * (perm ? box[perm.z].factor : 1)
|
|
757
|
+
];
|
|
758
|
+
// Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
|
|
759
|
+
const shift = shifts.get(part.name);
|
|
760
|
+
if (shift)
|
|
761
|
+
for (const a of AXES)
|
|
762
|
+
box[a].centre += shift[a] * box[a].factor;
|
|
763
|
+
const turn = turns.get(part.name);
|
|
764
|
+
if (turn)
|
|
765
|
+
rotation = mm3(turn, rotation);
|
|
766
|
+
// Then the joint frame that carries it.
|
|
767
|
+
const carrier = carrierOf(part.name);
|
|
768
|
+
const W = carrier ? worldJoint(carrier) : undefined;
|
|
769
|
+
const plan = repeatPlan(cp, scale, base);
|
|
770
|
+
const copies = plan ? plan.count : 1;
|
|
771
|
+
for (let i = 0; i < copies; i++) {
|
|
772
|
+
let centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
|
|
773
|
+
if (plan)
|
|
774
|
+
centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
|
|
775
|
+
let R = rotation;
|
|
776
|
+
if (W) {
|
|
777
|
+
const c = mv3(W.r, [centre.x, centre.y, centre.z]);
|
|
778
|
+
centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
|
|
779
|
+
R = mm3(W.r, rotation);
|
|
780
|
+
}
|
|
781
|
+
const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
|
|
782
|
+
out.push({ part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return out;
|
|
786
|
+
}
|
|
787
|
+
function composeRT(a, b) {
|
|
788
|
+
const rt = mv3(a.r, b.t);
|
|
789
|
+
return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
|
|
790
|
+
}
|
|
791
|
+
/** Where a converted V3 asset draws every part at an instance size, as world boxes. */
|
|
792
|
+
export function v3WorldBoxes(asset, size, stateOverrides = {}) {
|
|
793
|
+
const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
|
|
794
|
+
const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
|
|
795
|
+
return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
|
|
796
|
+
}
|
|
797
|
+
/** ADR-0087 decision 2 tolerances. */
|
|
798
|
+
export const SAME_TOLERANCE_MM = 1;
|
|
799
|
+
export const SAME_TOLERANCE_DEG = 0.1;
|
|
800
|
+
/** The V3 state overrides that stand for a V2 motion state: parameters by name, drivers at their clip's time. */
|
|
801
|
+
export function v3StateOverrides(asset, state) {
|
|
802
|
+
const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]));
|
|
803
|
+
const out = {};
|
|
804
|
+
for (const [name, value] of Object.entries(state.parameters ?? {}))
|
|
805
|
+
if (units.has(name))
|
|
806
|
+
out[name] = value;
|
|
807
|
+
for (const d of asset.drivers ?? []) {
|
|
808
|
+
const clip = d.id.split('/')[0];
|
|
809
|
+
const t = state.clipTime?.[clip] ?? 0;
|
|
810
|
+
const unit = units.get(d.state);
|
|
811
|
+
out[d.state] = driverValue(d, t, unit === 'deg' || unit === 'rad' ? unit : 'other');
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
function rotationAngleDeg(a, b) {
|
|
816
|
+
// The angle of a·bᵀ. Its trace is the sum of the element-wise products of a and b.
|
|
817
|
+
let trace = 0;
|
|
818
|
+
for (let i = 0; i < 3; i++)
|
|
819
|
+
for (let k = 0; k < 3; k++)
|
|
820
|
+
trace += a[i][k] * b[i][k];
|
|
821
|
+
const c = Math.min(1, Math.max(-1, (trace - 1) / 2));
|
|
822
|
+
return (Math.acos(c) * 180) / Math.PI;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* "The same" (ADR-0087 decision 2): the V2 source and the V3 asset draw the same parts, each with the same
|
|
826
|
+
* centre and local dimensions within 1 mm and the same rotation within 0.1° (as a relative angle), with the same
|
|
827
|
+
* material, at the base size and at every combination of doubled axes, at rest and at every motion state given.
|
|
828
|
+
*/
|
|
829
|
+
export function compareV2WithV3(source, asset, options = {}) {
|
|
830
|
+
const opts = typeof options === 'number' ? { factor: options } : options;
|
|
831
|
+
const factor = opts.factor ?? 2;
|
|
832
|
+
const scales = [];
|
|
833
|
+
for (const x of [1, factor])
|
|
834
|
+
for (const y of [1, factor])
|
|
835
|
+
for (const z of [1, factor])
|
|
836
|
+
scales.push({ x, y, z });
|
|
837
|
+
const states = [{ label: 'rest' }, ...(opts.states ?? [])];
|
|
838
|
+
const differences = [];
|
|
839
|
+
let boxesCompared = 0;
|
|
840
|
+
const materials = new Map(asset.appearance.map(a => [a.target, a]));
|
|
841
|
+
for (const part of source.parts) {
|
|
842
|
+
const look = materials.get(part.name);
|
|
843
|
+
if (!look) {
|
|
844
|
+
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const expected = { token: part.material.token };
|
|
848
|
+
if (part.material.preset !== undefined)
|
|
849
|
+
expected.preset = part.material.preset;
|
|
850
|
+
if (part.material.transparent !== undefined)
|
|
851
|
+
expected.transparent = part.material.transparent;
|
|
852
|
+
if (part.material.emissive)
|
|
853
|
+
expected.emissive = { token: part.material.emissive.token ?? part.material.token, intensity: part.material.emissive.intensity, on: part.material.emissive.on ?? false };
|
|
854
|
+
if (JSON.stringify(expected) !== JSON.stringify(look.material))
|
|
855
|
+
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'material', v2: JSON.stringify(expected), v3: JSON.stringify(look.material) });
|
|
856
|
+
if ((part.primitive === 'cylinder' || part.primitive === 'sphere') && look.segments !== (part.segments ?? SEG_DEFAULT))
|
|
857
|
+
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'segments', v2: String(part.segments ?? SEG_DEFAULT), v3: String(look.segments) });
|
|
858
|
+
}
|
|
859
|
+
const byPart = (boxes) => {
|
|
860
|
+
const m = new Map();
|
|
861
|
+
for (const b of boxes)
|
|
862
|
+
(m.get(b.part) ?? m.set(b.part, []).get(b.part)).push(b);
|
|
863
|
+
for (const list of m.values())
|
|
864
|
+
list.sort((p, q) => p.centre.x - q.centre.x || p.centre.y - q.centre.y || p.centre.z - q.centre.z);
|
|
865
|
+
return m;
|
|
866
|
+
};
|
|
867
|
+
for (const state of states) {
|
|
868
|
+
const overrides = v3StateOverrides(asset, state);
|
|
869
|
+
for (const scale of scales) {
|
|
870
|
+
const size = { x: source.base.x * scale.x, y: source.base.y * scale.y, z: source.base.z * scale.z };
|
|
871
|
+
const v2 = byPart(v2WorldBoxes(source, scale, state));
|
|
872
|
+
const v3 = byPart(v3WorldBoxes(asset, size, overrides));
|
|
873
|
+
for (const name of new Set([...v2.keys(), ...v3.keys()])) {
|
|
874
|
+
const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
|
|
875
|
+
if (a.length !== b.length) {
|
|
876
|
+
differences.push({ part: name, scale, state: state.label, what: 'copies', v2: String(a.length), v3: String(b.length) });
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
for (const [i, p] of a.entries()) {
|
|
880
|
+
const q = b[i];
|
|
881
|
+
boxesCompared++;
|
|
882
|
+
const tag = a.length > 1 ? `[${i}]` : '';
|
|
883
|
+
for (const axis of AXES)
|
|
884
|
+
if (Math.abs(p.centre[axis] - q.centre[axis]) > SAME_TOLERANCE_MM)
|
|
885
|
+
differences.push({ part: name, scale, state: state.label, what: `centre.${axis}${tag}`, v2: p.centre[axis].toFixed(3), v3: q.centre[axis].toFixed(3) });
|
|
886
|
+
for (const [i, d] of p.dims.entries())
|
|
887
|
+
if (Math.abs(d - q.dims[i]) > SAME_TOLERANCE_MM)
|
|
888
|
+
differences.push({ part: name, scale, state: state.label, what: `dims.${AXES[i]}${tag}`, v2: d.toFixed(3), v3: q.dims[i].toFixed(3) });
|
|
889
|
+
const turn = rotationAngleDeg(p.rotation, q.rotation);
|
|
890
|
+
if (turn > SAME_TOLERANCE_DEG)
|
|
891
|
+
differences.push({ part: name, scale, state: state.label, what: `rotation${tag}`, v2: JSON.stringify(p.rotation.map(r => r.map(v => +v.toFixed(4)))), v3: `${turn.toFixed(3)}° apart` });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return { same: differences.length === 0, scales, states: states.map(s => s.label), boxesCompared, differences };
|
|
897
|
+
}
|
|
898
|
+
const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
|
|
899
|
+
/**
|
|
900
|
+
* Motion, per docs/v3-motion-contract.md. State inputs stand for V2 parameters (name, unit, range, default kept)
|
|
901
|
+
* and for what clips drive; joints become `attach@1(T·turn·T⁻¹)` chains parents first; part channels become a
|
|
902
|
+
* slide along an asset axis (times the part's world factor, as V2 shifted it) or a spin about the part's own axis.
|
|
903
|
+
* What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
|
|
904
|
+
*/
|
|
905
|
+
function planMotion(c) {
|
|
906
|
+
const { src, g, base, size, info, spinOf, shiftsOf, frameOf, motionOf, asset, refusals, notes, ratio } = c;
|
|
907
|
+
const params = src.parameters ?? [];
|
|
908
|
+
const clips = src.animations ?? [];
|
|
909
|
+
const joints = src.joints ?? [];
|
|
910
|
+
if (!params.length && !clips.length && !joints.length)
|
|
911
|
+
return;
|
|
912
|
+
const partsByName = new Map(src.parts.map(p => [p.name, p]));
|
|
913
|
+
const jointsByName = new Map(joints.map(j => [j.name, j]));
|
|
914
|
+
// State inputs for parameters.
|
|
915
|
+
const paramUnit = new Map();
|
|
916
|
+
for (const p of params) {
|
|
917
|
+
const unit = V2_UNIT[p.range.unit];
|
|
918
|
+
if (!unit) {
|
|
919
|
+
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
g.input(p.name, unit, p.range.min, p.range.max, 'state');
|
|
923
|
+
asset.stateDefaults[p.name] = p.default ?? p.range.min;
|
|
924
|
+
paramUnit.set(p.name, unit);
|
|
925
|
+
}
|
|
926
|
+
/** q = v0 + (p - min)·(v1 - v0)/(max - min), written in the output unit. Null when the channel does not move. */
|
|
927
|
+
const affine = (p, v0, v1, outUnit, hint) => {
|
|
928
|
+
if (v0 === v1)
|
|
929
|
+
return null;
|
|
930
|
+
const pUnit = paramUnit.get(p.name);
|
|
931
|
+
if (!pUnit)
|
|
932
|
+
return undefined;
|
|
933
|
+
const span = p.range.max - p.range.min;
|
|
934
|
+
let slopeUnit;
|
|
935
|
+
let slope;
|
|
936
|
+
if (pUnit === outUnit) {
|
|
937
|
+
slopeUnit = 'ratio';
|
|
938
|
+
slope = (v1 - v0) / span;
|
|
939
|
+
}
|
|
940
|
+
else if (pUnit === 'percent') {
|
|
941
|
+
// A percent input reaches the kernel as a ratio (×0.01), so the slope is per ratio.
|
|
942
|
+
slopeUnit = outUnit;
|
|
943
|
+
slope = ((v1 - v0) * 100) / span;
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot write a ${outUnit} value here` });
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
950
|
+
return g.add(`${hint}.q`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), slopeUnit, `${hint}.slope`)), g.constant(round6(v0), outUnit, `${hint}.v0`));
|
|
951
|
+
};
|
|
952
|
+
const twoKeyLinear = (ch, owner) => {
|
|
953
|
+
const keys = ch.keys;
|
|
954
|
+
if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
|
|
955
|
+
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: only two linear keys at 0 and 1 are written into the graph` });
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
return true;
|
|
959
|
+
};
|
|
960
|
+
const varying = (keys) => AXES.filter(a => keys.some(k => k.value[a] !== keys[0].value[a]));
|
|
961
|
+
// Which parameter drives each joint; refuse two writers.
|
|
962
|
+
const jointWriter = new Map();
|
|
963
|
+
const partWriters = new Map(); // `${part}/${path}/${axis}` → owners
|
|
964
|
+
const claim = (key, owner, part) => {
|
|
965
|
+
const owners = partWriters.get(key) ?? new Set();
|
|
966
|
+
if (owners.size && !owners.has(owner)) {
|
|
967
|
+
refusals.push({ code: 'MOTION_MULTI_WRITER', part, detail: `${[...owners].join(', ')} and ${owner} both write ${key}; one writer per state` });
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
owners.add(owner);
|
|
971
|
+
partWriters.set(key, owners);
|
|
972
|
+
return true;
|
|
973
|
+
};
|
|
974
|
+
for (const p of params) {
|
|
975
|
+
if (!paramUnit.has(p.name))
|
|
976
|
+
continue;
|
|
977
|
+
for (const ch of p.clip.channels) {
|
|
978
|
+
if (ch.path === undefined) {
|
|
979
|
+
if (!jointsByName.has(ch.target)) {
|
|
980
|
+
refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a joint` });
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (!twoKeyLinear(ch, p.name))
|
|
984
|
+
continue;
|
|
985
|
+
if (jointWriter.has(ch.target)) {
|
|
986
|
+
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: `${jointWriter.get(ch.target).p.name} and ${p.name} both drive the joint` });
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const keys = ch.keys;
|
|
990
|
+
jointWriter.set(ch.target, { p, v0: keys[0].value, v1: keys[1].value });
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const part = partsByName.get(ch.target);
|
|
994
|
+
if (!part) {
|
|
995
|
+
refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a part` });
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (!info.has(ch.target))
|
|
999
|
+
continue; // the part itself was refused above, and that refusal already names it
|
|
1000
|
+
if (ch.path === 'scale') {
|
|
1001
|
+
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `parameter ${p.name} scales the part; a scale channel is shape deformation, not covered by the motion contract` });
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
if (ch.pivot) {
|
|
1005
|
+
refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered by the motion contract' });
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
const keys = ch.keys;
|
|
1009
|
+
const axes = varying(keys);
|
|
1010
|
+
if (axes.length === 0) {
|
|
1011
|
+
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `parameter ${p.name} has a ${ch.path} channel whose keys do not change; nothing to write` });
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (axes.length > 1) {
|
|
1015
|
+
refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (!twoKeyLinear(ch, p.name))
|
|
1019
|
+
continue;
|
|
1020
|
+
const axis = axes[0];
|
|
1021
|
+
if (!claim(`${ch.target}/${ch.path}/${axis}`, p.name, ch.target))
|
|
1022
|
+
continue;
|
|
1023
|
+
const q = affine(p, keys[0].value[axis], keys[1].value[axis], ch.path === 'rotation' ? 'deg' : 'mm', `${p.name}.${ch.target}.${ch.path}`);
|
|
1024
|
+
if (q === undefined)
|
|
1025
|
+
continue;
|
|
1026
|
+
if (q === null)
|
|
1027
|
+
continue;
|
|
1028
|
+
if (ch.path === 'rotation') {
|
|
1029
|
+
if (spinOf.has(ch.target)) {
|
|
1030
|
+
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
spinOf.set(ch.target, { axis, q });
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
// V2 shifts a part in the figure frame by the part's world factor along that axis.
|
|
1037
|
+
const f = info.get(ch.target).factor[axis];
|
|
1038
|
+
const scaled = f.kind === 'one' ? q : g.mul(`${p.name}.${ch.target}.shift.${axis}`, q, f.ref);
|
|
1039
|
+
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Clips: a driver per channel, writing a state input of its own.
|
|
1044
|
+
const drivers = [];
|
|
1045
|
+
for (const clip of clips) {
|
|
1046
|
+
const duration = v2ClipDuration(clip);
|
|
1047
|
+
if (!(duration > 0)) {
|
|
1048
|
+
notes.push({ code: 'MOTION_STATIC_CLIP', detail: `animation "${clip.name}" has no duration; nothing to write` });
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
for (const ch of clip.channels) {
|
|
1052
|
+
const owner = `clip ${clip.name}`;
|
|
1053
|
+
if (ch.path === undefined) {
|
|
1054
|
+
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} drives a joint directly; joints are driven by parameters in this contract` });
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
if (!partsByName.has(ch.target)) {
|
|
1058
|
+
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} targets a part that does not exist` });
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (!info.has(ch.target))
|
|
1062
|
+
continue; // refused above under its own name
|
|
1063
|
+
if (ch.path === 'scale') {
|
|
1064
|
+
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} scales the part; a scale channel is shape deformation, not covered` });
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (ch.pivot) {
|
|
1068
|
+
refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered' });
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
const keys = ch.keys;
|
|
1072
|
+
const axes = varying(keys);
|
|
1073
|
+
if (axes.length === 0) {
|
|
1074
|
+
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `${owner} has a ${ch.path} channel whose keys do not change; nothing to write` });
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (axes.length > 1) {
|
|
1078
|
+
refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const axis = axes[0];
|
|
1082
|
+
if (!claim(`${ch.target}/${ch.path}/${axis}`, owner, ch.target))
|
|
1083
|
+
continue;
|
|
1084
|
+
const values = keys.map(k => k.value[axis]);
|
|
1085
|
+
const rotation = ch.path === 'rotation';
|
|
1086
|
+
const stateId = rotation ? `${ch.target}.spin` : `${ch.target}.shift.${axis}`;
|
|
1087
|
+
const min = Math.min(...values), max = Math.max(...values);
|
|
1088
|
+
g.input(stateId, rotation ? 'deg' : 'mm', rotation ? -180 : min, rotation ? 180 : max, 'state');
|
|
1089
|
+
asset.stateDefaults[stateId] = rotation ? normalise180(values[0]) : values[0];
|
|
1090
|
+
drivers.push({
|
|
1091
|
+
id: `${clip.name}/${ch.target}/${ch.path}`,
|
|
1092
|
+
state: stateId,
|
|
1093
|
+
time: { unit: 's', duration },
|
|
1094
|
+
keys: keys.map(k => ({ at: round6(k.at / duration), value: k.value[axis] })),
|
|
1095
|
+
interpolation: ch.interpolation ?? 'linear',
|
|
1096
|
+
loop: 'wrap',
|
|
1097
|
+
accumulates: rotation
|
|
1098
|
+
});
|
|
1099
|
+
notes.push({ code: 'CLIP_TIME', detail: `animation "${clip.name}": V2 keys in seconds, last at ${duration} s; written as duration ${duration} s with fractional keys` });
|
|
1100
|
+
if (rotation) {
|
|
1101
|
+
if (spinOf.has(ch.target)) {
|
|
1102
|
+
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
spinOf.set(ch.target, { axis, q: stateId });
|
|
1106
|
+
}
|
|
1107
|
+
else {
|
|
1108
|
+
const f = info.get(ch.target).factor[axis];
|
|
1109
|
+
const scaled = f.kind === 'one' ? stateId : g.mul(`${clip.name}.${ch.target}.shift.${axis}`, stateId, f.ref);
|
|
1110
|
+
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
if (drivers.length)
|
|
1115
|
+
asset.drivers = drivers;
|
|
1116
|
+
// Joints, parents first.
|
|
1117
|
+
const carrierOf = (name) => {
|
|
1118
|
+
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
1119
|
+
const j = joints.find(x => x.child === p.name);
|
|
1120
|
+
if (j)
|
|
1121
|
+
return j;
|
|
1122
|
+
}
|
|
1123
|
+
return undefined;
|
|
1124
|
+
};
|
|
1125
|
+
const depth = (j) => {
|
|
1126
|
+
const child = partsByName.get(j.child);
|
|
1127
|
+
const above = child?.parent ? carrierOf(child.parent) : undefined;
|
|
1128
|
+
return above ? 1 + depth(above) : 0;
|
|
1129
|
+
};
|
|
1130
|
+
for (const j of [...joints].sort((a, b) => depth(a) - depth(b))) {
|
|
1131
|
+
const child = partsByName.get(j.child);
|
|
1132
|
+
if (!child || !info.has(j.child)) {
|
|
1133
|
+
refusals.push({ code: 'JOINT_CHILD', part: j.name, detail: `child "${j.child}" was not converted` });
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (info.get(j.child).repeat) {
|
|
1137
|
+
refusals.push({ code: 'MOTION_REPEAT', part: j.child, detail: 'a repeated part cannot be a joint child in this contract' });
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
// The state input: the driving parameter's affine, or the joint's own input when nothing drives it.
|
|
1141
|
+
let q;
|
|
1142
|
+
const writer = jointWriter.get(j.name);
|
|
1143
|
+
const limits = j.limits;
|
|
1144
|
+
if (writer) {
|
|
1145
|
+
const r = affine(writer.p, writer.v0, writer.v1, j.type === 'prismatic' ? 'mm' : 'deg', `${writer.p.name}.${j.name}`);
|
|
1146
|
+
if (r === undefined)
|
|
1147
|
+
continue;
|
|
1148
|
+
if (r === null) {
|
|
1149
|
+
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: j.name, detail: `parameter ${writer.p.name} does not change the joint; it stays at 0` });
|
|
1150
|
+
q = g.constant(0, j.type === 'prismatic' ? 'mm' : 'deg', 'zeroQ');
|
|
1151
|
+
}
|
|
1152
|
+
else
|
|
1153
|
+
q = r;
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
const [min, max] = limits ? [limits.min, limits.max] : j.type === 'continuous' ? [-180, 180] : [-1e6, 1e6];
|
|
1157
|
+
q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
|
|
1158
|
+
asset.stateDefaults[j.name] = 0;
|
|
1159
|
+
}
|
|
1160
|
+
if (limits)
|
|
1161
|
+
notes.push({ code: 'JOINT_LIMIT', part: j.name, detail: `V2 clamps a value outside [${limits.min}, ${limits.max}] to the limit; V3 refuses it (INPUT_RANGE)` });
|
|
1162
|
+
if (j.type === 'prismatic')
|
|
1163
|
+
notes.push({ code: 'PRISMATIC_TRAVEL', part: j.name, detail: 'V2 scaled the travel by the instance scale; V3 travel is the value itself, per the contract' });
|
|
1164
|
+
// Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
|
|
1165
|
+
const attach = child.parent ? partsByName.get(child.parent) : undefined;
|
|
1166
|
+
const originC = { ...j.origin, y: j.origin.y - base.y / 2 };
|
|
1167
|
+
const originRef = [];
|
|
1168
|
+
for (const a of AXES) {
|
|
1169
|
+
let expr;
|
|
1170
|
+
if (attach && info.has(attach.name)) {
|
|
1171
|
+
const ai = info.get(attach.name);
|
|
1172
|
+
const ac = centredPart(attach, base).transform.position;
|
|
1173
|
+
const offset = c.times(`${j.name}.origin.${a}.offset`, { kind: 'const', value: round6(originC[a] - ac[a]) }, ai.factor[a]);
|
|
1174
|
+
expr = { kind: 'ref', ref: g.add(`${j.name}.origin.${a}`, g.len(ai.centre[a], `${j.name}.origin.${a}.seat`), g.len(offset, `${j.name}.origin.${a}.off`)) };
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
// No carrying part: only the instance scale moves the origin (V2 keeps the rest position).
|
|
1178
|
+
const v = a === 'y' ? originC.y + base.y / 2 : originC[a];
|
|
1179
|
+
expr = v === 0 ? { kind: 'const', value: 0 } : { kind: 'ref', ref: g.mul(`${j.name}.origin.${a}`, size[a], ratio(v / base[a], `${j.name}.origin.${a}.at`)) };
|
|
1180
|
+
}
|
|
1181
|
+
originRef.push(g.len(expr, `${j.name}.origin.${a}`));
|
|
1182
|
+
}
|
|
1183
|
+
const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
|
|
1184
|
+
const axisRefs = AXES.map(a => ratio(j.axis[a] / n, `${j.name}.axis.${a}`));
|
|
1185
|
+
const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
|
|
1186
|
+
const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
|
|
1187
|
+
const parentJoint = attach ? carrierOf(attach.name) : undefined;
|
|
1188
|
+
const restFrame = 'asset.rest';
|
|
1189
|
+
const toFrame = parentJoint ? restFrame : 'asset';
|
|
1190
|
+
const A = g.node(`${j.name}.frameOnParent`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onParent, to: toFrame });
|
|
1191
|
+
const B = g.node(`${j.name}.frameOnChild`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onChild, to: restFrame });
|
|
1192
|
+
const O = g.node(`${j.name}.motion`, j.type === 'prismatic' ? 'axis-slide@1' : 'axis-turn@1', [...axisRefs, q], 'pose', { from: onChild, to: onParent });
|
|
1193
|
+
let M = g.node(`${j.name}.attach`, 'attach@1', [A, O, B], 'pose');
|
|
1194
|
+
if (parentJoint)
|
|
1195
|
+
M = g.node(`${j.name}.chain`, 'compose@1', [motionOf.get(parentJoint.name), M], 'pose');
|
|
1196
|
+
motionOf.set(j.name, M);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function normalise180(deg) {
|
|
1200
|
+
const w = ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
1201
|
+
return w === 180 ? -180 : w;
|
|
1202
|
+
}
|
|
1203
|
+
//# sourceMappingURL=v3-from-v2.js.map
|