@hatiolab/figure-model 0.1.32 → 0.1.33
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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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 +24 -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 +17 -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-from-v2.d.ts +70 -0
- package/dist/v3-from-v2.d.ts.map +1 -0
- package/dist/v3-from-v2.js +595 -0
- package/dist/v3-from-v2.js.map +1 -0
- package/dist/v3-gate.d.ts +35 -0
- package/dist/v3-gate.d.ts.map +1 -0
- package/dist/v3-gate.js +131 -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 +8 -0
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +99 -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-operator-contracts.md +4 -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,595 @@
|
|
|
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 cylinder with two radii, a polygon whose path
|
|
15
|
+
* does not fill its declared size about the centre (V2 itself draws that one off-centre)
|
|
16
|
+
* - a part rotated off the axes whose size still follows the instance (V2 shears it; V3 cannot)
|
|
17
|
+
* - joints and parameters (motion semantics are not converted here)
|
|
18
|
+
*
|
|
19
|
+
* What it converts but does not carry, it lists in `lost` so the caller can show it. Nothing is dropped
|
|
20
|
+
* without being named there.
|
|
21
|
+
*
|
|
22
|
+
* `compareV2WithV3` is the definition of "the same" from ADR-0087 decision 2: same part list, each
|
|
23
|
+
* part's centre, world extents and rotation within tolerance, same material, at the base size and at
|
|
24
|
+
* every combination of doubled axes.
|
|
25
|
+
*/
|
|
26
|
+
import { compileV3Asset } from "./v3-asset.js";
|
|
27
|
+
import { v3WorldBoxesOf, v3WritersOf } from "./v3-gate.js";
|
|
28
|
+
import { V3ContractError } from "./v3-contract.js";
|
|
29
|
+
import { validate } from "./validate.js";
|
|
30
|
+
import { centredPart } from "./origin.js";
|
|
31
|
+
import { rotatedExtentOf } from "./blueprint.js";
|
|
32
|
+
import { anchorOf, clearance, longAxis, repeatOffset, repeatPlan, sizingPosition, sizingScale } from "./sizing.js";
|
|
33
|
+
import { AXES, REPEAT_LIMIT, SEGMENT_PRESETS } from "./types.js";
|
|
34
|
+
/** The V3 design inputs that stand for the instance size, in mm. */
|
|
35
|
+
export const V3_SIZE_INPUTS = Object.freeze({ x: 'size.x', y: 'size.y', z: 'size.z' });
|
|
36
|
+
/** How far a V2 instance may grow in the converted asset, as a multiple of the base box. */
|
|
37
|
+
const SIZE_RANGE = 10;
|
|
38
|
+
const ONE = { kind: 'one' };
|
|
39
|
+
const SEG_DEFAULT = 12;
|
|
40
|
+
const RIGHT_ANGLE_TOLERANCE = 1e-9;
|
|
41
|
+
/** Builds the V3 graph value by value. Constants are shared by value; node ids stay readable. */
|
|
42
|
+
class GraphBuilder {
|
|
43
|
+
graph = { version: 'r3-spatial-1', inputs: [], constants: [], nodes: [] };
|
|
44
|
+
ids = new Set();
|
|
45
|
+
constants = new Map();
|
|
46
|
+
claim(id, path) {
|
|
47
|
+
if (this.ids.has(id))
|
|
48
|
+
throw new V3ContractError('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
49
|
+
this.ids.add(id);
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
input(id, unit, min, max, role) {
|
|
53
|
+
this.graph.inputs.push({ id: this.claim(id, 'input'), unit, min, max, role });
|
|
54
|
+
return id;
|
|
55
|
+
}
|
|
56
|
+
constant(raw, unit, hint) {
|
|
57
|
+
const value = raw === 0 ? 0 : raw; // -0 would not survive JSON
|
|
58
|
+
const key = `${unit}:${value}`;
|
|
59
|
+
const known = this.constants.get(key);
|
|
60
|
+
if (known)
|
|
61
|
+
return known;
|
|
62
|
+
let id = `k.${hint}`;
|
|
63
|
+
for (let n = 2; this.ids.has(id); n++)
|
|
64
|
+
id = `k.${hint}.${n}`;
|
|
65
|
+
this.claim(id, 'constant');
|
|
66
|
+
this.graph.constants.push({ id, unit, value });
|
|
67
|
+
this.constants.set(key, id);
|
|
68
|
+
return id;
|
|
69
|
+
}
|
|
70
|
+
node(id, op, args, port, params) {
|
|
71
|
+
const out = `${id}.value`;
|
|
72
|
+
const node = { id: this.claim(id, 'node'), op, args, outputs: { [port]: this.claim(out, id) } };
|
|
73
|
+
if (params)
|
|
74
|
+
node.params = params;
|
|
75
|
+
this.graph.nodes.push(node);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
add(id, a, b) {
|
|
79
|
+
return this.node(id, 'add@1', [a, b], 'value');
|
|
80
|
+
}
|
|
81
|
+
mul(id, a, b) {
|
|
82
|
+
return this.node(id, 'mul@1', [a, b], 'value');
|
|
83
|
+
}
|
|
84
|
+
div(id, a, b) {
|
|
85
|
+
return this.node(id, 'div@1', [a, b], 'value');
|
|
86
|
+
}
|
|
87
|
+
/** A length as a graph reference: constants become shared constant entries. */
|
|
88
|
+
len(v, hint) {
|
|
89
|
+
return v.kind === 'ref' ? v.ref : this.constant(v.value, 'mm', hint);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function round6(v) {
|
|
93
|
+
return Math.round(v * 1e6) / 1e6;
|
|
94
|
+
}
|
|
95
|
+
function isRightAngle(deg) {
|
|
96
|
+
const turns = deg / 90;
|
|
97
|
+
return Math.abs(turns - Math.round(turns)) < RIGHT_ANGLE_TOLERANCE;
|
|
98
|
+
}
|
|
99
|
+
/** Rx·Ry·Rz for degrees, the order V2 (`three` 'XYZ') and V3 `rigid@1` share. */
|
|
100
|
+
export function eulerXYZ(rotation) {
|
|
101
|
+
const rad = Math.PI / 180;
|
|
102
|
+
const cx = Math.cos((rotation?.x ?? 0) * rad), sx = Math.sin((rotation?.x ?? 0) * rad);
|
|
103
|
+
const cy = Math.cos((rotation?.y ?? 0) * rad), sy = Math.sin((rotation?.y ?? 0) * rad);
|
|
104
|
+
const cz = Math.cos((rotation?.z ?? 0) * rad), sz = Math.sin((rotation?.z ?? 0) * rad);
|
|
105
|
+
return [
|
|
106
|
+
[cy * cz, -cy * sz, sy],
|
|
107
|
+
[sx * sy * cz + cx * sz, -sx * sy * sz + cx * cz, -sx * cy],
|
|
108
|
+
[-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
/** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
|
|
112
|
+
function axisPermutation(rotation) {
|
|
113
|
+
if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
|
|
114
|
+
return null;
|
|
115
|
+
const r = eulerXYZ(rotation);
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [i, local] of AXES.entries()) {
|
|
118
|
+
const world = AXES.find((_, a) => Math.abs(r[a][i]) > 0.5);
|
|
119
|
+
if (!world)
|
|
120
|
+
return null;
|
|
121
|
+
out[local] = world;
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function materialOf(part, palette) {
|
|
126
|
+
const m = part.material ?? {};
|
|
127
|
+
const known = ['token', 'preset', 'transparent', 'emissive'];
|
|
128
|
+
const extra = Object.keys(m).filter(k => !known.includes(k));
|
|
129
|
+
if (extra.length)
|
|
130
|
+
return { refusal: { code: 'MATERIAL_FIELD', part: part.name, detail: `V3 material has no ${extra.join(', ')}` } };
|
|
131
|
+
if (!m.token)
|
|
132
|
+
return { refusal: { code: 'MATERIAL_TOKEN', part: part.name, detail: 'material has no palette token' } };
|
|
133
|
+
if (!Object.hasOwn(palette, m.token))
|
|
134
|
+
return { refusal: { code: 'PALETTE_TOKEN', part: part.name, detail: `no colour for ${m.token}` } };
|
|
135
|
+
const material = { token: m.token };
|
|
136
|
+
if (m.preset !== undefined)
|
|
137
|
+
material.preset = m.preset;
|
|
138
|
+
if (m.transparent !== undefined)
|
|
139
|
+
material.transparent = m.transparent;
|
|
140
|
+
if (m.emissive) {
|
|
141
|
+
const token = m.emissive.token ?? m.token;
|
|
142
|
+
if (!Object.hasOwn(palette, token))
|
|
143
|
+
return { refusal: { code: 'PALETTE_TOKEN', part: part.name, detail: `no colour for emissive ${token}` } };
|
|
144
|
+
// V2 reads a missing `on` as off (`keys.ts`); V3 wants it written down.
|
|
145
|
+
material.emissive = { token, intensity: m.emissive.intensity, on: m.emissive.on ?? false };
|
|
146
|
+
}
|
|
147
|
+
return { material };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Convert one validated V2 source to a V3 asset that places every part where V2 places it, at every
|
|
151
|
+
* instance size in the input range. See the file comment for what is refused and what is listed as lost.
|
|
152
|
+
*/
|
|
153
|
+
export function convertV2ToV3(source, options) {
|
|
154
|
+
const lost = [];
|
|
155
|
+
const refusals = [];
|
|
156
|
+
const checked = validate(source);
|
|
157
|
+
if (checked.errors.length) {
|
|
158
|
+
return {
|
|
159
|
+
status: 'refused',
|
|
160
|
+
refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
|
|
161
|
+
lost
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const src = source;
|
|
165
|
+
const base = src.base;
|
|
166
|
+
const palette = options.palette;
|
|
167
|
+
if (src.joints?.length)
|
|
168
|
+
refusals.push({ code: 'JOINTS', detail: `${src.joints.length} joints; motion is not converted` });
|
|
169
|
+
if (src.parameters?.length)
|
|
170
|
+
refusals.push({ code: 'PARAMETERS', detail: `${src.parameters.length} parameters; motion is not converted` });
|
|
171
|
+
for (const clip of src.animations ?? [])
|
|
172
|
+
lost.push({ code: 'ANIMATION', detail: `animation "${clip.name}" (${clip.channels.length} channels) is not carried` });
|
|
173
|
+
if (src.detailLevel)
|
|
174
|
+
lost.push({ code: 'DETAIL_LEVEL', detail: `detailLevel "${src.detailLevel}" has no V3 field` });
|
|
175
|
+
if (src.styleKit)
|
|
176
|
+
lost.push({ code: 'STYLE_KIT', detail: `styleKit "${src.styleKit}" has no V3 field` });
|
|
177
|
+
const g = new GraphBuilder();
|
|
178
|
+
const asset = {
|
|
179
|
+
version: 'figure-v3-asset-1',
|
|
180
|
+
name: options.name ?? src.type,
|
|
181
|
+
document: {
|
|
182
|
+
version: 'figure-v3-prototype-1',
|
|
183
|
+
model: g.graph,
|
|
184
|
+
capabilities: {
|
|
185
|
+
version: 'r3-capability-3',
|
|
186
|
+
assetFrame: 'asset',
|
|
187
|
+
bindings: [],
|
|
188
|
+
// ADR-0051 ④: the names travel; V3 checks them against the same list V2 did.
|
|
189
|
+
...(src.capabilities?.length ? { names: [...src.capabilities] } : {})
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
designInputs: {},
|
|
193
|
+
stateDefaults: {},
|
|
194
|
+
palette: {},
|
|
195
|
+
appearance: [],
|
|
196
|
+
// ADR-0044 · ADR-0045: the V2 base box and placement become the declared occupancy; V2 read a missing placement as floor.
|
|
197
|
+
occupancy: { extent: { ...V3_SIZE_INPUTS }, placement: src.placement ?? 'floor' }
|
|
198
|
+
};
|
|
199
|
+
// Instance size inputs. The range starts at the pitch on a spreading repeat axis (V2 draws at least one
|
|
200
|
+
// copy below that; `fit-pitch@1` draws none) and stops where V2 would silently clamp the copy count.
|
|
201
|
+
const sizeMin = { x: 1, y: 1, z: 1 };
|
|
202
|
+
const sizeMax = { x: base.x * SIZE_RANGE, y: base.y * SIZE_RANGE, z: base.z * SIZE_RANGE };
|
|
203
|
+
const parts = src.parts.map(p => centredPart(p, base));
|
|
204
|
+
for (const cp of parts) {
|
|
205
|
+
if (cp.sizing !== 'repeat' || !cp.repeat)
|
|
206
|
+
continue;
|
|
207
|
+
const axis = cp.repeat.axis;
|
|
208
|
+
if (anchorOf(withSizing(cp), base, axis) === 'scale')
|
|
209
|
+
continue;
|
|
210
|
+
sizeMin[axis] = Math.max(sizeMin[axis], cp.repeat.pitch);
|
|
211
|
+
const cap = REPEAT_LIMIT * cp.repeat.pitch;
|
|
212
|
+
if (sizeMax[axis] > cap) {
|
|
213
|
+
sizeMax[axis] = cap;
|
|
214
|
+
lost.push({
|
|
215
|
+
code: 'REPEAT_LIMIT',
|
|
216
|
+
part: cp.name,
|
|
217
|
+
detail: `V2 clamps the copy count at ${REPEAT_LIMIT}; the V3 input ${V3_SIZE_INPUTS[axis]} stops at ${cap} mm instead`
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const size = {};
|
|
222
|
+
for (const a of AXES) {
|
|
223
|
+
size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
|
|
224
|
+
asset.designInputs[V3_SIZE_INPUTS[a]] = base[a];
|
|
225
|
+
}
|
|
226
|
+
const ratio = (v, hint) => g.constant(round6(v), 'ratio', hint);
|
|
227
|
+
const scaleFactor = new Map();
|
|
228
|
+
const factorOfScale = (a) => {
|
|
229
|
+
let f = scaleFactor.get(a);
|
|
230
|
+
if (!f) {
|
|
231
|
+
f = { kind: 'ref', ref: g.div(`scale.${a}`, size[a], g.constant(base[a], 'mm', `base.${a}`)), sig: `scale.${a}` };
|
|
232
|
+
scaleFactor.set(a, f);
|
|
233
|
+
}
|
|
234
|
+
return f;
|
|
235
|
+
};
|
|
236
|
+
const times = (id, v, f) => {
|
|
237
|
+
if (f.kind === 'one')
|
|
238
|
+
return v;
|
|
239
|
+
if (v.kind === 'const' && v.value === 0)
|
|
240
|
+
return v;
|
|
241
|
+
return { kind: 'ref', ref: g.mul(id, g.len(v, id), f.ref) };
|
|
242
|
+
};
|
|
243
|
+
const plans = [];
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
for (const cp of parts) {
|
|
246
|
+
const name = cp.name;
|
|
247
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(name)) {
|
|
248
|
+
refusals.push({ code: 'PART_ID', part: name, detail: 'V3 ids start with a letter and use letters, digits, _ . -' });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (seen.has(name)) {
|
|
252
|
+
refusals.push({ code: 'PART_ID', part: name, detail: 'duplicate part name' });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
seen.add(name);
|
|
256
|
+
if (cp.parent)
|
|
257
|
+
lost.push({ code: 'PARENT', part: name, detail: `attached to "${cp.parent}"; V3 places it in the asset frame` });
|
|
258
|
+
if (cp.label)
|
|
259
|
+
lost.push({ code: 'LABEL', part: name, detail: 'label has no V3 field' });
|
|
260
|
+
if (cp.materialSlot)
|
|
261
|
+
lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
|
|
262
|
+
if (cp.capability)
|
|
263
|
+
lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'slot/port role is not carried as a V3 binding' });
|
|
264
|
+
const { material, refusal } = materialOf(cp, palette);
|
|
265
|
+
if (refusal) {
|
|
266
|
+
refusals.push(refusal);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const token of [material.token, material.emissive?.token])
|
|
270
|
+
if (token)
|
|
271
|
+
asset.palette[token] = palette[token];
|
|
272
|
+
if (cp.primitive === 'wall')
|
|
273
|
+
lost.push({ code: 'WALL_KIND', part: name, detail: 'drawn as a box; the "wall" kind has no V3 field' });
|
|
274
|
+
const hollow = cp.shape?.hollow && cp.shape.hollow.wall > 0 ? cp.shape.hollow : null;
|
|
275
|
+
if (hollow && cp.primitive !== 'rect') {
|
|
276
|
+
refusals.push({ code: 'NO_PROVIDER', part: name, detail: `V3 has no hollow ${cp.primitive}` });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
|
|
280
|
+
if (cp.primitive === 'polygon') {
|
|
281
|
+
// V2 sizing reads `size`, but draws the path as written. The two agree only when the path fills the
|
|
282
|
+
// size box symmetrically about the part centre; otherwise V2 itself draws the part off its declared centre.
|
|
283
|
+
const xs = path.map(q => q.x), zs = path.map(q => q.y);
|
|
284
|
+
const spanX = Math.max(...xs) - Math.min(...xs), spanZ = Math.max(...zs) - Math.min(...zs);
|
|
285
|
+
const midX = (Math.max(...xs) + Math.min(...xs)) / 2, midZ = (Math.max(...zs) + Math.min(...zs)) / 2;
|
|
286
|
+
if (path.length < 3 || Math.abs(spanX - cp.transform.size.x) > 1e-6 || Math.abs(spanZ - cp.transform.size.z) > 1e-6 || Math.abs(midX) > 1e-6 || Math.abs(midZ) > 1e-6) {
|
|
287
|
+
refusals.push({
|
|
288
|
+
code: 'POLYGON_OFF_CENTRE',
|
|
289
|
+
part: name,
|
|
290
|
+
detail: `path spans ${spanX}×${spanZ} about (${midX}, ${midZ}); size says ${cp.transform.size.x}×${cp.transform.size.z} about the centre`
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (cp.shape?.round)
|
|
295
|
+
lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
|
|
296
|
+
}
|
|
297
|
+
if (cp.primitive === 'cylinder' && Math.abs(cp.transform.size.x - cp.transform.size.z) > 1e-9) {
|
|
298
|
+
refusals.push({ code: 'CONE', part: name, detail: `size.x ${cp.transform.size.x} ≠ size.z ${cp.transform.size.z}; V3 cylinders have one radius` });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const segments = cp.segments ?? SEG_DEFAULT;
|
|
302
|
+
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
|
|
303
|
+
refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const subject = withSizing(cp);
|
|
307
|
+
const extent = rotatedExtentOf(cp);
|
|
308
|
+
const rules = { x: anchorOf(subject, base, 'x'), y: anchorOf(subject, base, 'y'), z: anchorOf(subject, base, 'z') };
|
|
309
|
+
const repeat = cp.sizing === 'repeat' && cp.repeat ? cp.repeat : null;
|
|
310
|
+
// World factor per axis: how much the rotated part's extent along that axis follows the instance.
|
|
311
|
+
const factor = {};
|
|
312
|
+
const centre = {};
|
|
313
|
+
const pending = [];
|
|
314
|
+
for (const a of AXES) {
|
|
315
|
+
const rule = rules[a];
|
|
316
|
+
const gaps = clearance(subject, base, a);
|
|
317
|
+
const reach = extent[a] / 2;
|
|
318
|
+
const pos = cp.transform.position[a];
|
|
319
|
+
if (rule === 'scale') {
|
|
320
|
+
factor[a] = factorOfScale(a);
|
|
321
|
+
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`)) };
|
|
322
|
+
}
|
|
323
|
+
else if (rule === 'span') {
|
|
324
|
+
const free = g.add(`${name}.${a}.free`, size[a], g.constant(round6(-(gaps.min + gaps.max)), 'mm', `${name}.${a}.gaps`));
|
|
325
|
+
factor[a] = { kind: 'ref', ref: g.div(`${name}.${a}.factor`, free, g.constant(extent[a], 'mm', `${name}.${a}.own`)), sig: `span.${name}.${a}` };
|
|
326
|
+
centre[a] = { kind: 'const', value: round6((gaps.min - gaps.max) / 2) };
|
|
327
|
+
}
|
|
328
|
+
else if (rule === 'min') {
|
|
329
|
+
factor[a] = ONE;
|
|
330
|
+
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`)) };
|
|
331
|
+
}
|
|
332
|
+
else if (rule === 'max') {
|
|
333
|
+
factor[a] = ONE;
|
|
334
|
+
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`)) };
|
|
335
|
+
}
|
|
336
|
+
else if (rule === 'center') {
|
|
337
|
+
factor[a] = ONE;
|
|
338
|
+
centre[a] = { kind: 'const', value: pos };
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
pending.push(a); // aspect-*: follows another axis, settled below
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const a of pending) {
|
|
345
|
+
const named = rules[a].slice('aspect-'.length);
|
|
346
|
+
factor[a] = factor[named];
|
|
347
|
+
centre[a] = times(`${name}.${a}.centre`, { kind: 'const', value: cp.transform.position[a] }, factor[named]);
|
|
348
|
+
}
|
|
349
|
+
// The V3 asset frame has its origin at the bottom of the box (ADR-0065); V2 sizing works from the centre.
|
|
350
|
+
centre.y = { kind: 'ref', ref: g.add(`${name}.y.fromBottom`, g.len(centre.y, `${name}.y.centred`), g.mul(`${name}.y.lift`, size.y, ratio(0.5, 'half'))) };
|
|
351
|
+
// A curved part keeps a round section: both cross axes take the geometric mean of their two factors
|
|
352
|
+
// (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
|
|
353
|
+
// it is a `geomean@1` node.
|
|
354
|
+
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && cp.keepRound !== false) {
|
|
355
|
+
const spin = cp.primitive === 'sphere' ? null : longAxis(subject);
|
|
356
|
+
const across = AXES.filter(a => a !== spin);
|
|
357
|
+
const fs = across.map(a => factor[a]);
|
|
358
|
+
const same = fs.every(f => f.kind === 'one') || fs.every(f => f.kind === 'ref' && f.sig === fs[0].sig);
|
|
359
|
+
if (!same) {
|
|
360
|
+
const asRef = (f) => (f.kind === 'one' ? ratio(1, 'one') : f.ref);
|
|
361
|
+
const even = { kind: 'ref', ref: g.node(`${name}.round`, 'geomean@1', fs.map(asRef), 'value'), sig: `round.${name}` };
|
|
362
|
+
for (const a of across)
|
|
363
|
+
factor[a] = even;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Local dimensions scale by the factor of the world axis each local axis lands on.
|
|
367
|
+
const perm = axisPermutation(cp.transform.rotation);
|
|
368
|
+
const follows = AXES.some(a => factor[a].kind !== 'one');
|
|
369
|
+
if (!perm && follows) {
|
|
370
|
+
refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const dim = (local) => times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE);
|
|
374
|
+
const localFrame = `${name}.local`;
|
|
375
|
+
const rot = cp.transform.rotation;
|
|
376
|
+
const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
|
|
377
|
+
let shape;
|
|
378
|
+
if (cp.primitive === 'cylinder') {
|
|
379
|
+
const radius = times(`${name}.dim.radius`, { kind: 'const', value: cp.transform.size.x / 2 }, perm ? factor[perm.x] : ONE);
|
|
380
|
+
const length = dim('y');
|
|
381
|
+
shape = () => g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radius, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: localFrame });
|
|
382
|
+
}
|
|
383
|
+
else if (cp.primitive === 'sphere') {
|
|
384
|
+
const r = (local) => times(`${name}.dim.radius${local.toUpperCase()}`, { kind: 'const', value: cp.transform.size[local] / 2 }, perm ? factor[perm[local]] : ONE);
|
|
385
|
+
const [rx, ry, rz] = [r('x'), r('y'), r('z')];
|
|
386
|
+
shape = () => g.node(`${name}.shape`, 'sphere-shape@1', [g.len(rx, `${name}.rx`), g.len(ry, `${name}.ry`), g.len(rz, `${name}.rz`)], 'shape', { frame: localFrame });
|
|
387
|
+
}
|
|
388
|
+
else if (cp.primitive === 'polygon') {
|
|
389
|
+
const h = dim('y');
|
|
390
|
+
const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
391
|
+
const points = path.flatMap((q, i) => [
|
|
392
|
+
times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx),
|
|
393
|
+
times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz)
|
|
394
|
+
]);
|
|
395
|
+
shape = () => g.node(`${name}.shape`, 'polygon-shape@1', [g.len(h, `${name}.h`), ...points.map((v, i) => g.len(v, `${name}.pt${i}`))], 'shape', { frame: localFrame });
|
|
396
|
+
}
|
|
397
|
+
else if (cp.primitive === 'rect') {
|
|
398
|
+
const round = cp.shape?.round ?? 0;
|
|
399
|
+
if (round > 0 && (factor.x.kind !== 'one' || factor.z.kind !== 'one'))
|
|
400
|
+
lost.push({ code: 'ROUND_NOT_SCALED', part: name, detail: `corner radius ${round} mm stays fixed while the section follows the instance; V2 scales it with the section` });
|
|
401
|
+
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
402
|
+
if (hollow) {
|
|
403
|
+
// V2 scales the finished mesh, so a wall's thickness follows the axis it lies across and the floor follows the height.
|
|
404
|
+
const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
405
|
+
const wallX = times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx);
|
|
406
|
+
const wallZ = times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz);
|
|
407
|
+
const floor = times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy);
|
|
408
|
+
shape = () => g.node(`${name}.shape`, 'hollow-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.constant(round, 'mm', `${name}.round`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: localFrame });
|
|
409
|
+
}
|
|
410
|
+
else
|
|
411
|
+
shape = () => g.node(`${name}.shape`, 'rounded-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.constant(round, 'mm', `${name}.round`)], 'shape', { frame: localFrame });
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
415
|
+
shape = () => g.node(`${name}.shape`, 'box-shape@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`)], 'shape', { frame: localFrame });
|
|
416
|
+
}
|
|
417
|
+
const appearance = { target: name, material: material };
|
|
418
|
+
if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
|
|
419
|
+
appearance.segments = segments;
|
|
420
|
+
if (!repeat) {
|
|
421
|
+
plans.push({
|
|
422
|
+
appearance,
|
|
423
|
+
nodes: () => {
|
|
424
|
+
const s = shape();
|
|
425
|
+
const pose = g.node(`${name}.pose`, 'rigid@1', [...AXES.map(a => g.len(centre[a], `${name}.${a}`)), ...rotationRefs], 'pose', { from: localFrame, to: 'asset' });
|
|
426
|
+
g.node(name, 'place@1', [s, pose], 'placed');
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
// Repeat: copies pitch apart in the world (times the world factor of that axis), centred on the authored
|
|
432
|
+
// position, count = floor(room / pitch) with room = the instance size when the row spreads, else the base.
|
|
433
|
+
const axis = repeat.axis;
|
|
434
|
+
const spreads = rules[axis] !== 'scale';
|
|
435
|
+
const itemFrame = `${name}.item`;
|
|
436
|
+
plans.push({
|
|
437
|
+
appearance,
|
|
438
|
+
nodes: () => {
|
|
439
|
+
const s = shape();
|
|
440
|
+
const 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 });
|
|
441
|
+
const member = g.node(name, 'member@1', [s, memberPose], 'placed');
|
|
442
|
+
const assembly = g.node(`${name}.assembly`, 'assembly@1', [member], 'assembly');
|
|
443
|
+
let layout;
|
|
444
|
+
let rowStart;
|
|
445
|
+
if (spreads) {
|
|
446
|
+
const zero = g.constant(0, 'mm', 'zero');
|
|
447
|
+
const pitch = g.constant(repeat.pitch, 'mm', `${name}.pitch`);
|
|
448
|
+
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');
|
|
449
|
+
rowStart = g.add(`${name}.rowStart`, g.len(centre[axis], `${name}.${axis}`), g.mul(`${name}.halfRow`, size[axis], ratio(-0.5, 'minusHalf')));
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// A fixed count spread pitch·factor apart: `fixed-count@1` spaces `count` items evenly over L - e,
|
|
453
|
+
// so L = (count - 1)·pitch·factor + e with a 1 mm item extent, and the row starts e/2 before the first copy.
|
|
454
|
+
const count = Math.max(1, Math.min(REPEAT_LIMIT, Math.floor(base[axis] / repeat.pitch)));
|
|
455
|
+
const e = g.constant(1, 'mm', 'one');
|
|
456
|
+
const row = times(`${name}.row`, { kind: 'const', value: (count - 1) * repeat.pitch }, factor[axis]);
|
|
457
|
+
const L = g.add(`${name}.rowLength`, g.len(row, `${name}.row`), e);
|
|
458
|
+
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');
|
|
459
|
+
const halfRow = times(`${name}.halfRow`, { kind: 'const', value: -((count - 1) * repeat.pitch) / 2 }, factor[axis]);
|
|
460
|
+
rowStart = g.add(`${name}.rowStart`, g.add(`${name}.rowCentre`, g.len(centre[axis], `${name}.${axis}`), g.len(halfRow, `${name}.halfRow`)), g.constant(-0.5, 'mm', 'minusHalfMm'));
|
|
461
|
+
}
|
|
462
|
+
const 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: 'asset' });
|
|
463
|
+
g.node(`${name}.repeat`, 'repeat@1', [layout, assembly, rootPose], 'collection', { axis });
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if (refusals.length)
|
|
468
|
+
return { status: 'refused', refusals, lost };
|
|
469
|
+
for (const plan of plans) {
|
|
470
|
+
plan.nodes();
|
|
471
|
+
asset.appearance.push(plan.appearance);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
compileV3Asset(asset);
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
const err = e;
|
|
478
|
+
return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost };
|
|
479
|
+
}
|
|
480
|
+
return { status: 'converted', asset, lost };
|
|
481
|
+
}
|
|
482
|
+
/** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
|
|
483
|
+
function withSizing(cp) {
|
|
484
|
+
return { ...cp, sizing: cp.sizing ?? 'scale' };
|
|
485
|
+
}
|
|
486
|
+
/** Where V2 draws every part (and copy) at an instance scale, as world boxes. */
|
|
487
|
+
export function v2WorldBoxes(source, scale) {
|
|
488
|
+
const base = source.base;
|
|
489
|
+
const out = [];
|
|
490
|
+
for (const part of source.parts) {
|
|
491
|
+
const cp = withSizing(centredPart(part, base));
|
|
492
|
+
const at = sizingPosition(cp, scale, base);
|
|
493
|
+
const sized = sizingScale(cp, scale, base);
|
|
494
|
+
const extent = rotatedExtentOf(cp);
|
|
495
|
+
const rotation = eulerXYZ(part.transform.rotation);
|
|
496
|
+
const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
|
|
497
|
+
const box = { x: world('x'), y: world('y'), z: world('z') };
|
|
498
|
+
box.y.centre += (base.y * scale.y) / 2;
|
|
499
|
+
const plan = repeatPlan(cp, scale, base);
|
|
500
|
+
const copies = plan ? plan.count : 1;
|
|
501
|
+
for (let i = 0; i < copies; i++) {
|
|
502
|
+
const centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
|
|
503
|
+
if (plan)
|
|
504
|
+
centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
|
|
505
|
+
out.push({ part: part.name, centre, extent: { x: box.x.extent, y: box.y.extent, z: box.z.extent }, rotation });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
/** Where a converted V3 asset draws every part at an instance size, as world boxes. */
|
|
511
|
+
export function v3WorldBoxes(asset, size) {
|
|
512
|
+
const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
|
|
513
|
+
const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate();
|
|
514
|
+
return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset));
|
|
515
|
+
}
|
|
516
|
+
/** ADR-0087 decision 2 tolerances. */
|
|
517
|
+
export const SAME_TOLERANCE_MM = 1;
|
|
518
|
+
export const SAME_TOLERANCE_DEG = 0.1;
|
|
519
|
+
function rotationAngleDeg(a, b) {
|
|
520
|
+
// The angle of a·bᵀ. Its trace is the sum of the element-wise products of a and b.
|
|
521
|
+
let trace = 0;
|
|
522
|
+
for (let i = 0; i < 3; i++)
|
|
523
|
+
for (let k = 0; k < 3; k++)
|
|
524
|
+
trace += a[i][k] * b[i][k];
|
|
525
|
+
const c = Math.min(1, Math.max(-1, (trace - 1) / 2));
|
|
526
|
+
return (Math.acos(c) * 180) / Math.PI;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* "The same" (ADR-0087 decision 2): the V2 source and the V3 asset draw the same parts, each at the same
|
|
530
|
+
* centre and world extents within 1 mm and the same rotation within 0.1°, with the same material, at the
|
|
531
|
+
* base size and at every combination of doubled axes.
|
|
532
|
+
*/
|
|
533
|
+
export function compareV2WithV3(source, asset, factor = 2) {
|
|
534
|
+
const scales = [];
|
|
535
|
+
for (const x of [1, factor])
|
|
536
|
+
for (const y of [1, factor])
|
|
537
|
+
for (const z of [1, factor])
|
|
538
|
+
scales.push({ x, y, z });
|
|
539
|
+
const differences = [];
|
|
540
|
+
let boxesCompared = 0;
|
|
541
|
+
const materials = new Map(asset.appearance.map(a => [a.target, a]));
|
|
542
|
+
for (const part of source.parts) {
|
|
543
|
+
const look = materials.get(part.name);
|
|
544
|
+
if (!look) {
|
|
545
|
+
differences.push({ part: part.name, scale: scales[0], what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const expected = { token: part.material.token };
|
|
549
|
+
if (part.material.preset !== undefined)
|
|
550
|
+
expected.preset = part.material.preset;
|
|
551
|
+
if (part.material.transparent !== undefined)
|
|
552
|
+
expected.transparent = part.material.transparent;
|
|
553
|
+
if (part.material.emissive)
|
|
554
|
+
expected.emissive = { token: part.material.emissive.token ?? part.material.token, intensity: part.material.emissive.intensity, on: part.material.emissive.on ?? false };
|
|
555
|
+
if (JSON.stringify(expected) !== JSON.stringify(look.material))
|
|
556
|
+
differences.push({ part: part.name, scale: scales[0], what: 'material', v2: JSON.stringify(expected), v3: JSON.stringify(look.material) });
|
|
557
|
+
if ((part.primitive === 'cylinder' || part.primitive === 'sphere') && look.segments !== (part.segments ?? SEG_DEFAULT))
|
|
558
|
+
differences.push({ part: part.name, scale: scales[0], what: 'segments', v2: String(part.segments ?? SEG_DEFAULT), v3: String(look.segments) });
|
|
559
|
+
}
|
|
560
|
+
for (const scale of scales) {
|
|
561
|
+
const size = { x: source.base.x * scale.x, y: source.base.y * scale.y, z: source.base.z * scale.z };
|
|
562
|
+
const byPart = (boxes) => {
|
|
563
|
+
const m = new Map();
|
|
564
|
+
for (const b of boxes)
|
|
565
|
+
(m.get(b.part) ?? m.set(b.part, []).get(b.part)).push(b);
|
|
566
|
+
for (const list of m.values())
|
|
567
|
+
list.sort((p, q) => p.centre.x - q.centre.x || p.centre.y - q.centre.y || p.centre.z - q.centre.z);
|
|
568
|
+
return m;
|
|
569
|
+
};
|
|
570
|
+
const v2 = byPart(v2WorldBoxes(source, scale));
|
|
571
|
+
const v3 = byPart(v3WorldBoxes(asset, size));
|
|
572
|
+
for (const name of new Set([...v2.keys(), ...v3.keys()])) {
|
|
573
|
+
const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
|
|
574
|
+
if (a.length !== b.length) {
|
|
575
|
+
differences.push({ part: name, scale, what: 'copies', v2: String(a.length), v3: String(b.length) });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
for (const [i, p] of a.entries()) {
|
|
579
|
+
const q = b[i];
|
|
580
|
+
boxesCompared++;
|
|
581
|
+
for (const axis of AXES) {
|
|
582
|
+
if (Math.abs(p.centre[axis] - q.centre[axis]) > SAME_TOLERANCE_MM)
|
|
583
|
+
differences.push({ part: name, scale, what: `centre.${axis}${a.length > 1 ? `[${i}]` : ''}`, v2: p.centre[axis].toFixed(3), v3: q.centre[axis].toFixed(3) });
|
|
584
|
+
if (Math.abs(p.extent[axis] - q.extent[axis]) > SAME_TOLERANCE_MM)
|
|
585
|
+
differences.push({ part: name, scale, what: `extent.${axis}${a.length > 1 ? `[${i}]` : ''}`, v2: p.extent[axis].toFixed(3), v3: q.extent[axis].toFixed(3) });
|
|
586
|
+
}
|
|
587
|
+
const turn = rotationAngleDeg(p.rotation, q.rotation);
|
|
588
|
+
if (turn > SAME_TOLERANCE_DEG)
|
|
589
|
+
differences.push({ part: name, scale, what: 'rotation', v2: JSON.stringify(p.rotation.map(r => r.map(v => +v.toFixed(4)))), v3: `${turn.toFixed(3)}° apart` });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { same: differences.length === 0, scales, boxesCompared, differences };
|
|
594
|
+
}
|
|
595
|
+
//# sourceMappingURL=v3-from-v2.js.map
|