@hatiolab/figure-model 0.1.76 → 0.1.78
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/CHANGELOG.md +12 -0
- package/README.md +60 -138
- package/dist/index.d.ts +1 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -26
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +1 -2
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.js +1 -1
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +9 -12
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +4 -5
- package/dist/v3-authoring-actions.js.map +1 -1
- package/dist/v3-capabilities.js +4 -1
- package/dist/v3-capabilities.js.map +1 -1
- package/dist/v3-cost.d.ts.map +1 -1
- package/dist/v3-cost.js +3 -5
- package/dist/v3-cost.js.map +1 -1
- package/dist/v3-create.d.ts +1 -1
- package/dist/v3-create.d.ts.map +1 -1
- package/dist/v3-examples.js +1 -1
- package/dist/v3-gate.d.ts +7 -8
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +11 -14
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-graph.d.ts +1 -2
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +5 -7
- package/dist/v3-graph.js.map +1 -1
- package/dist/v3-instance-size.d.ts +3 -5
- package/dist/v3-instance-size.d.ts.map +1 -1
- package/dist/v3-instance-size.js +2 -4
- package/dist/v3-instance-size.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +1 -1
- package/dist/v3-kernel-version.js +1 -1
- package/dist/v3-part-edit.d.ts +4 -5
- package/dist/v3-part-edit.d.ts.map +1 -1
- package/dist/v3-part-edit.js +2 -2
- package/dist/v3-part-edit.js.map +1 -1
- package/dist/v3-proposal.js +1 -1
- package/dist/v3-proposal.js.map +1 -1
- package/dist/v3-release-contract.js +1 -1
- package/dist/v3-release-contract.js.map +1 -1
- package/dist/v3-score.d.ts +4 -5
- package/dist/v3-score.d.ts.map +1 -1
- package/dist/v3-score.js +7 -8
- package/dist/v3-score.js.map +1 -1
- package/dist/v3-words.d.ts +105 -0
- package/dist/v3-words.d.ts.map +1 -0
- package/dist/v3-words.js +107 -0
- package/dist/v3-words.js.map +1 -0
- package/docs/assembly-constraints-v3.md +13 -23
- package/docs/v3-asset-persistence.md +4 -4
- package/docs/v3-common-kernel-experiment.md +1 -1
- package/docs/v3-core-semantics.md +3 -3
- package/docs/v3-cutover.md +5 -3
- package/docs/v3-design.md +13 -26
- package/docs/v3-editor-integration.md +2 -2
- package/docs/v3-full-conveyor-validation.md +3 -3
- package/docs/v3-layout-system.md +1 -1
- package/docs/v3-minimal-model.md +0 -1
- package/docs/v3-motion-contract.md +21 -56
- package/docs/v3-runtime-extraction.md +7 -7
- package/docs/v3-sample-validation-expanded.md +1 -1
- package/docs/v3-sample-validation.md +1 -1
- package/docs/v3-shape-dimension-contract.md +23 -57
- package/docs/v3-status.md +5 -5
- package/docs/v3-storage-integration.md +4 -4
- package/package.json +2 -20
- package/dist/blueprint-shape.d.ts +0 -10
- package/dist/blueprint-shape.d.ts.map +0 -1
- package/dist/blueprint-shape.js +0 -194
- package/dist/blueprint-shape.js.map +0 -1
- package/dist/blueprint.d.ts +0 -73
- package/dist/blueprint.d.ts.map +0 -1
- package/dist/blueprint.js +0 -388
- package/dist/blueprint.js.map +0 -1
- package/dist/cost-chart.d.ts +0 -35
- package/dist/cost-chart.d.ts.map +0 -1
- package/dist/cost-chart.js +0 -150
- package/dist/cost-chart.js.map +0 -1
- package/dist/cost.d.ts +0 -162
- package/dist/cost.d.ts.map +0 -1
- package/dist/cost.js +0 -194
- package/dist/cost.js.map +0 -1
- package/dist/gate.d.ts +0 -65
- package/dist/gate.d.ts.map +0 -1
- package/dist/gate.js +0 -284
- package/dist/gate.js.map +0 -1
- package/dist/grouping.d.ts +0 -121
- package/dist/grouping.d.ts.map +0 -1
- package/dist/grouping.js +0 -224
- package/dist/grouping.js.map +0 -1
- package/dist/keys.d.ts +0 -45
- package/dist/keys.d.ts.map +0 -1
- package/dist/keys.js +0 -84
- package/dist/keys.js.map +0 -1
- package/dist/origin.d.ts +0 -33
- package/dist/origin.d.ts.map +0 -1
- package/dist/origin.js +0 -46
- package/dist/origin.js.map +0 -1
- package/dist/release-contract.d.ts +0 -38
- package/dist/release-contract.d.ts.map +0 -1
- package/dist/release-contract.js +0 -246
- package/dist/release-contract.js.map +0 -1
- package/dist/sizing.d.ts +0 -231
- package/dist/sizing.d.ts.map +0 -1
- package/dist/sizing.js +0 -550
- package/dist/sizing.js.map +0 -1
- package/dist/types.d.ts +0 -1233
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -547
- package/dist/types.js.map +0 -1
- package/dist/v3-from-v2.d.ts +0 -203
- package/dist/v3-from-v2.d.ts.map +0 -1
- package/dist/v3-from-v2.js +0 -2053
- package/dist/v3-from-v2.js.map +0 -1
- package/dist/v3-mesh-compare.d.ts +0 -24
- package/dist/v3-mesh-compare.d.ts.map +0 -1
- package/dist/v3-mesh-compare.js +0 -55
- package/dist/v3-mesh-compare.js.map +0 -1
- package/dist/validate.d.ts +0 -7
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -1119
- package/dist/validate.js.map +0 -1
- package/dist/visual-evidence.d.ts +0 -21
- package/dist/visual-evidence.d.ts.map +0 -1
- package/dist/visual-evidence.js +0 -52
- package/dist/visual-evidence.js.map +0 -1
- package/docs/design.md +0 -193
- package/docs/format-survey.md +0 -108
- package/docs/format.md +0 -704
- package/docs/sizing.md +0 -183
- package/docs/validation.md +0 -357
package/dist/v3-from-v2.js
DELETED
|
@@ -1,2053 +0,0 @@
|
|
|
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
|
-
* - a scale channel (a state input moving a dimension), until the shape-dimension contract §2 is implemented
|
|
18
|
-
* - joints and parameters, until `docs/v3-motion-contract.md` is implemented
|
|
19
|
-
*
|
|
20
|
-
* What it normalises on purpose it lists in `notes` with the value before and after: a corner radius V2 clamped
|
|
21
|
-
* when drawing, a polygon path recentred with its placement moved by the same amount, the palette it snapshotted.
|
|
22
|
-
* What it converts but does not carry, it lists in `lost`. Nothing is dropped without being named in one of the two.
|
|
23
|
-
*
|
|
24
|
-
* `compareV2WithV3` is the definition of "the same" from ADR-0087 decision 2: same part list, each
|
|
25
|
-
* part's centre, world extents and rotation within tolerance, same material, at the base size and at
|
|
26
|
-
* every combination of doubled axes.
|
|
27
|
-
*/
|
|
28
|
-
import { defaultInstanceSizeRange, foldInstanceSizeRange, V3_SIZE_POSITIVE_STEP } from "./v3-instance-size.js";
|
|
29
|
-
import { compileV3Asset } from "./v3-asset.js";
|
|
30
|
-
import { v3WorldBoxesOf, v3WritersOf } from "./v3-gate.js";
|
|
31
|
-
import { V3ContractError } from "./v3-contract.js";
|
|
32
|
-
import { validate } from "./validate.js";
|
|
33
|
-
import { centredPart } from "./origin.js";
|
|
34
|
-
import { rotatedExtentOf } from "./blueprint.js";
|
|
35
|
-
import { anchorOf, clearance, longAxis, repeatOffset, repeatPlan, sizingPosition, sizingScale } from "./sizing.js";
|
|
36
|
-
import { AXES, REPEAT_LIMIT, SEGMENT_PRESETS } from "./types.js";
|
|
37
|
-
import { driverValue } from "./v3-driver.js";
|
|
38
|
-
import { hausdorff, scalePoints, transformPoints, v2BodyPoints } from "./v3-mesh-compare.js";
|
|
39
|
-
import { jointOriginPosition } from "./sizing.js";
|
|
40
|
-
/** A short stable fingerprint of the palette, so a report can say which colours it snapshotted. */
|
|
41
|
-
export function paletteHash(palette) {
|
|
42
|
-
const text = JSON.stringify(Object.entries(palette).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
43
|
-
let h = 5381;
|
|
44
|
-
for (let i = 0; i < text.length; i++)
|
|
45
|
-
h = ((h * 33) ^ text.charCodeAt(i)) >>> 0;
|
|
46
|
-
return h.toString(16).padStart(8, '0');
|
|
47
|
-
}
|
|
48
|
-
/** The V3 design inputs that stand for the instance size, in mm. */
|
|
49
|
-
export const V3_SIZE_INPUTS = Object.freeze({ x: 'size.x', y: 'size.y', z: 'size.z' });
|
|
50
|
-
/** Above this many state inputs the corners are not enumerated for the declared volume; the resting pose stands. */
|
|
51
|
-
const OCCUPANCY_STATE_CAP = 10;
|
|
52
|
-
const ONE = { kind: 'one' };
|
|
53
|
-
const SEG_DEFAULT = 12;
|
|
54
|
-
const RIGHT_ANGLE_TOLERANCE = 1e-9;
|
|
55
|
-
/** Builds the V3 graph value by value. Constants are shared by value; node ids stay readable. */
|
|
56
|
-
class GraphBuilder {
|
|
57
|
-
graph = { version: 'r3-spatial-1', inputs: [], constants: [], nodes: [] };
|
|
58
|
-
ids = new Set();
|
|
59
|
-
constants = new Map();
|
|
60
|
-
claim(id, path) {
|
|
61
|
-
if (this.ids.has(id))
|
|
62
|
-
throw new V3ContractError('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
63
|
-
this.ids.add(id);
|
|
64
|
-
return id;
|
|
65
|
-
}
|
|
66
|
-
input(id, unit, min, max, role) {
|
|
67
|
-
this.graph.inputs.push({ id: this.claim(id, 'input'), unit, min, max, role });
|
|
68
|
-
return id;
|
|
69
|
-
}
|
|
70
|
-
constant(raw, unit, hint) {
|
|
71
|
-
const value = raw === 0 ? 0 : raw; // -0 would not survive JSON
|
|
72
|
-
const key = `${unit}:${value}`;
|
|
73
|
-
const known = this.constants.get(key);
|
|
74
|
-
if (known)
|
|
75
|
-
return known;
|
|
76
|
-
let id = `k.${hint}`;
|
|
77
|
-
for (let n = 2; this.ids.has(id); n++)
|
|
78
|
-
id = `k.${hint}.${n}`;
|
|
79
|
-
this.claim(id, 'constant');
|
|
80
|
-
this.graph.constants.push({ id, unit, value });
|
|
81
|
-
this.constants.set(key, id);
|
|
82
|
-
return id;
|
|
83
|
-
}
|
|
84
|
-
node(id, op, args, port, params) {
|
|
85
|
-
const out = `${id}.value`;
|
|
86
|
-
const node = { id: this.claim(id, 'node'), op, args, outputs: { [port]: this.claim(out, id) } };
|
|
87
|
-
if (params)
|
|
88
|
-
node.params = params;
|
|
89
|
-
this.graph.nodes.push(node);
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
add(id, a, b) {
|
|
93
|
-
return this.node(id, 'add@1', [a, b], 'value');
|
|
94
|
-
}
|
|
95
|
-
mul(id, a, b) {
|
|
96
|
-
return this.node(id, 'mul@1', [a, b], 'value');
|
|
97
|
-
}
|
|
98
|
-
div(id, a, b) {
|
|
99
|
-
return this.node(id, 'div@1', [a, b], 'value');
|
|
100
|
-
}
|
|
101
|
-
/** A length as a graph reference: constants become shared constant entries. */
|
|
102
|
-
len(v, hint) {
|
|
103
|
-
return v.kind === 'ref' ? v.ref : this.constant(v.value, 'mm', hint);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
function round6(v) {
|
|
107
|
-
return Math.round(v * 1e6) / 1e6;
|
|
108
|
-
}
|
|
109
|
-
function isRightAngle(deg) {
|
|
110
|
-
const turns = deg / 90;
|
|
111
|
-
return Math.abs(turns - Math.round(turns)) < RIGHT_ANGLE_TOLERANCE;
|
|
112
|
-
}
|
|
113
|
-
/** Rx·Ry·Rz for degrees, the order V2 (`three` 'XYZ') and V3 `rigid@1` share. */
|
|
114
|
-
export function eulerXYZ(rotation) {
|
|
115
|
-
const rad = Math.PI / 180;
|
|
116
|
-
const cx = Math.cos((rotation?.x ?? 0) * rad), sx = Math.sin((rotation?.x ?? 0) * rad);
|
|
117
|
-
const cy = Math.cos((rotation?.y ?? 0) * rad), sy = Math.sin((rotation?.y ?? 0) * rad);
|
|
118
|
-
const cz = Math.cos((rotation?.z ?? 0) * rad), sz = Math.sin((rotation?.z ?? 0) * rad);
|
|
119
|
-
return [
|
|
120
|
-
[cy * cz, -cy * sz, sy],
|
|
121
|
-
[sx * sy * cz + cx * sz, -sx * sy * sz + cx * cz, -sx * cy],
|
|
122
|
-
[-cx * sy * cz + sx * sz, cx * sy * sz + sx * cz, cx * cy]
|
|
123
|
-
];
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Which world axis each local axis lies nearest to, for any rotation; null when two local axes claim one world axis
|
|
127
|
-
* (a 45° turn). Used only when re-authoring a rotated part without shear.
|
|
128
|
-
*/
|
|
129
|
-
function nearestAxisPermutation(rotation) {
|
|
130
|
-
const r = eulerXYZ(rotation);
|
|
131
|
-
const out = {};
|
|
132
|
-
const taken = new Set();
|
|
133
|
-
for (const [i, local] of AXES.entries()) {
|
|
134
|
-
let best = 'x', size = -1;
|
|
135
|
-
for (const [a, world] of AXES.entries()) {
|
|
136
|
-
const v = Math.abs(r[a][i]);
|
|
137
|
-
if (v > size)
|
|
138
|
-
(size = v), (best = world);
|
|
139
|
-
}
|
|
140
|
-
if (taken.has(best))
|
|
141
|
-
return null;
|
|
142
|
-
taken.add(best);
|
|
143
|
-
out[local] = best;
|
|
144
|
-
}
|
|
145
|
-
return out;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Does scaling per world axis commute with this rotation?
|
|
149
|
-
*
|
|
150
|
-
* V2 bakes the rotation into the mesh and then scales it per world axis: the drawn body is `S·R·v`. A rigid V3
|
|
151
|
-
* part scales its own dimensions and then rotates: `R·S·v`. Those are the same body exactly when `S` and `R`
|
|
152
|
-
* commute, and diag(f) commutes with R when every pair of axes the rotation mixes carries the same factor.
|
|
153
|
-
*
|
|
154
|
-
* This is not a corner case. ROLL_CRADLE's pads are turned 41° about X and follow the instance on Y and Z with
|
|
155
|
-
* the same factor (`anchor: y scale, z aspect-y`), so V2 never sheared them — it scaled them uniformly inside
|
|
156
|
-
* the plane the rotation turns in. Such a part converts exactly, with each local dimension taking its own axis's
|
|
157
|
-
* factor, and needs no re-authoring.
|
|
158
|
-
*/
|
|
159
|
-
function scalingCommutesWithRotation(rotation, same) {
|
|
160
|
-
const R = eulerXYZ(rotation);
|
|
161
|
-
for (const [i, a] of AXES.entries())
|
|
162
|
-
for (const [j, b] of AXES.entries()) {
|
|
163
|
-
if (i === j)
|
|
164
|
-
continue;
|
|
165
|
-
if (Math.abs(R[i][j]) > MIXING_TOLERANCE && !same(a, b))
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
/** Below this, a rotation does not mix two axes enough to tell a shear from a uniform scale at figure sizes. */
|
|
171
|
-
const MIXING_TOLERANCE = 1e-9;
|
|
172
|
-
/** Which world axis each local axis lands on, when the rotation is a multiple of 90° on every axis. */
|
|
173
|
-
function axisPermutation(rotation) {
|
|
174
|
-
if (!AXES.every(a => isRightAngle(rotation?.[a] ?? 0)))
|
|
175
|
-
return null;
|
|
176
|
-
const r = eulerXYZ(rotation);
|
|
177
|
-
const out = {};
|
|
178
|
-
for (const [i, local] of AXES.entries()) {
|
|
179
|
-
const world = AXES.find((_, a) => Math.abs(r[a][i]) > 0.5);
|
|
180
|
-
if (!world)
|
|
181
|
-
return null;
|
|
182
|
-
out[local] = world;
|
|
183
|
-
}
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
186
|
-
function materialOf(part, palette) {
|
|
187
|
-
const m = part.material ?? {};
|
|
188
|
-
const known = ['token', 'preset', 'transparent', 'emissive'];
|
|
189
|
-
const extra = Object.keys(m).filter(k => !known.includes(k));
|
|
190
|
-
if (extra.length)
|
|
191
|
-
return { refusal: { code: 'MATERIAL_FIELD', part: part.name, detail: `V3 material has no ${extra.join(', ')}` } };
|
|
192
|
-
if (!m.token)
|
|
193
|
-
return { refusal: { code: 'MATERIAL_TOKEN', part: part.name, detail: 'material has no palette token' } };
|
|
194
|
-
if (!Object.hasOwn(palette, m.token))
|
|
195
|
-
return { refusal: { code: 'PALETTE_TOKEN', part: part.name, detail: `no colour for ${m.token}` } };
|
|
196
|
-
const material = { token: m.token };
|
|
197
|
-
if (m.preset !== undefined)
|
|
198
|
-
material.preset = m.preset;
|
|
199
|
-
if (m.transparent !== undefined)
|
|
200
|
-
material.transparent = m.transparent;
|
|
201
|
-
if (m.emissive) {
|
|
202
|
-
const token = m.emissive.token ?? m.token;
|
|
203
|
-
if (!Object.hasOwn(palette, token))
|
|
204
|
-
return { refusal: { code: 'PALETTE_TOKEN', part: part.name, detail: `no colour for emissive ${token}` } };
|
|
205
|
-
// V2 reads a missing `on` as off (`keys.ts`); V3 wants it written down.
|
|
206
|
-
material.emissive = { token, intensity: m.emissive.intensity, on: m.emissive.on ?? false };
|
|
207
|
-
}
|
|
208
|
-
return { material };
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Convert one validated V2 source to a V3 asset that places every part where V2 places it, at every
|
|
212
|
-
* instance size in the input range. See the file comment for what is refused and what is listed as lost.
|
|
213
|
-
*/
|
|
214
|
-
export function convertV2ToV3(source, options) {
|
|
215
|
-
const lost = [];
|
|
216
|
-
const refusals = [];
|
|
217
|
-
const notes = [];
|
|
218
|
-
const rangeChanges = [];
|
|
219
|
-
let method = 'mechanical';
|
|
220
|
-
const checked = validate(source);
|
|
221
|
-
if (checked.errors.length) {
|
|
222
|
-
return {
|
|
223
|
-
status: 'refused',
|
|
224
|
-
refusals: checked.errors.map(e => ({ code: 'V2_INVALID', part: e.path, detail: `${e.code}: ${e.message}` })),
|
|
225
|
-
lost,
|
|
226
|
-
rangeChanges,
|
|
227
|
-
notes
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
const src = source;
|
|
231
|
-
const base = src.base;
|
|
232
|
-
const palette = options.palette;
|
|
233
|
-
notes.push({ code: 'PALETTE', detail: `colours snapshotted from palette ${options.paletteId ?? `#${paletteHash(palette)}`}` });
|
|
234
|
-
/*
|
|
235
|
-
Not a loss (chief architect's ruling, 2026-09-24): in V2 the grade only capped the part count. V3 measures the
|
|
236
|
-
same budget as triangles, draw calls and how many fit on one screen (`v3CostOf`), so the grade is replaced, not
|
|
237
|
-
carried. A V3 proposer must not ask for it either.
|
|
238
|
-
*/
|
|
239
|
-
/* Carried (user, 2026-09-24): the detail level budgets the part count before a part is added. */
|
|
240
|
-
if (src.styleKit)
|
|
241
|
-
lost.push({ code: 'STYLE_KIT', detail: `styleKit "${src.styleKit}" has no V3 field` });
|
|
242
|
-
/*
|
|
243
|
-
A figure with joints is a mechanism, and a mechanism does not shear: it should grow by one ratio (chief architect's
|
|
244
|
-
ruling 2026-09-24). The converter proposes that and does not apply it -- `sizing: 'uniform'` is the author's to
|
|
245
|
-
declare, with `scale-uniformly`, after looking at what the figure does at another size.
|
|
246
|
-
*/
|
|
247
|
-
if (Array.isArray(src.joints) && src.joints.length)
|
|
248
|
-
notes.push({ code: 'UNIFORM_SIZING_PROPOSED', detail: `${src.joints.length} joint(s): a mechanism should grow by one ratio -- declare it with scale-uniformly` });
|
|
249
|
-
const g = new GraphBuilder();
|
|
250
|
-
const asset = {
|
|
251
|
-
version: 'figure-v3-asset-1',
|
|
252
|
-
name: options.name ?? src.type,
|
|
253
|
-
document: {
|
|
254
|
-
version: 'figure-v3-prototype-1',
|
|
255
|
-
model: g.graph,
|
|
256
|
-
capabilities: {
|
|
257
|
-
version: 'r3-capability-3',
|
|
258
|
-
assetFrame: 'asset',
|
|
259
|
-
bindings: [],
|
|
260
|
-
// ADR-0051 ④: the names travel; V3 checks them against the same list V2 did.
|
|
261
|
-
...(src.capabilities?.length ? { names: [...src.capabilities] } : {})
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
designInputs: {},
|
|
265
|
-
stateDefaults: {},
|
|
266
|
-
palette: {},
|
|
267
|
-
appearance: [],
|
|
268
|
-
// ADR-0044 · ADR-0045: the V2 base box and placement become the declared volume, written as the reach on
|
|
269
|
-
// each side of the origin (designer's ruling 2026-09-23). V2 read a missing placement as floor.
|
|
270
|
-
occupancy: { bounds: null, placement: src.placement ?? 'floor' },
|
|
271
|
-
...(src.detailLevel ? { detailLevel: src.detailLevel } : {})
|
|
272
|
-
};
|
|
273
|
-
// Instance size inputs. The range starts at the pitch on a spreading repeat axis (V2 draws at least one
|
|
274
|
-
// copy below that; `fit-pitch@1` draws none) and stops where V2 would silently clamp the copy count.
|
|
275
|
-
// A spanned part fills the room between its two gaps. Below that room V2 draws a 0.001 mm sliver (`sizing.ts`
|
|
276
|
-
// SLIVER); V3 refuses a non-positive dimension, so the minimum is the room plus the numerical step above zero.
|
|
277
|
-
// Shrinking the accepted range is a behaviour change and is recorded as one (designer's ruling 2). The edges are
|
|
278
|
-
// read off the V2 parts here; turning them into a range is `foldInstanceSizeRange`, which the authoring command
|
|
279
|
-
// `declare-instance-size` calls too (chief architect's ruling 2026-09-24).
|
|
280
|
-
const parts = src.parts.map(p => centredPart(p, base));
|
|
281
|
-
const limits = [];
|
|
282
|
-
for (const cp of parts) {
|
|
283
|
-
if (cp.sizing !== 'repeat' || !cp.repeat)
|
|
284
|
-
continue;
|
|
285
|
-
const axis = cp.repeat.axis;
|
|
286
|
-
if (anchorOf(withSizing(cp), base, axis) === 'scale')
|
|
287
|
-
continue;
|
|
288
|
-
limits.push({ axis, part: cp.name, kind: 'repeat', pitch: cp.repeat.pitch, cap: REPEAT_LIMIT * cp.repeat.pitch });
|
|
289
|
-
}
|
|
290
|
-
for (const cp of parts) {
|
|
291
|
-
const subject = withSizing(cp);
|
|
292
|
-
for (const a of AXES) {
|
|
293
|
-
if (anchorOf(subject, base, a) !== 'span')
|
|
294
|
-
continue;
|
|
295
|
-
const gaps = clearance(subject, base, a);
|
|
296
|
-
limits.push({ axis: a, part: cp.name, kind: 'span', room: gaps.min + gaps.max });
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
const folded = foldInstanceSizeRange(defaultInstanceSizeRange(base), limits);
|
|
300
|
-
for (const move of folded.moves) {
|
|
301
|
-
const limit = move.limit;
|
|
302
|
-
if (limit.kind === 'repeat') {
|
|
303
|
-
rangeChanges.push({
|
|
304
|
-
input: V3_SIZE_INPUTS[limit.axis],
|
|
305
|
-
part: limit.part,
|
|
306
|
-
v2: `any size; above ${limit.cap} mm V2 stopped adding copies at ${REPEAT_LIMIT} and drew the rest as a shorter row`,
|
|
307
|
-
v3: { min: move.range.min, max: limit.cap },
|
|
308
|
-
reason: `V3 refuses a size whose repeat count would exceed ${REPEAT_LIMIT} (fit-pitch@1 RESOURCE_LIMIT) instead of clamping the count`
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
else if (limit.kind === 'span') {
|
|
312
|
-
rangeChanges.push({
|
|
313
|
-
input: V3_SIZE_INPUTS[limit.axis],
|
|
314
|
-
part: limit.part,
|
|
315
|
-
v2: `any size; at or below ${round6(limit.room)} mm the spanned part was drawn as a 0.001 mm sliver`,
|
|
316
|
-
v3: { min: move.range.min, max: move.range.max },
|
|
317
|
-
reason: `the spanned part must keep a positive length (${V3_SIZE_POSITIVE_STEP} mm is the numerical step above zero, not a minimum part size)`
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
const sizeMin = { x: folded.range.x.min, y: folded.range.y.min, z: folded.range.z.min };
|
|
322
|
-
const sizeMax = { x: folded.range.x.max, y: folded.range.y.max, z: folded.range.z.max };
|
|
323
|
-
const size = {};
|
|
324
|
-
for (const a of AXES) {
|
|
325
|
-
size[a] = g.input(V3_SIZE_INPUTS[a], 'mm', sizeMin[a], sizeMax[a], 'design');
|
|
326
|
-
asset.designInputs[V3_SIZE_INPUTS[a]] = base[a];
|
|
327
|
-
}
|
|
328
|
-
const ratio = (v, hint) => g.constant(round6(v), 'ratio', hint);
|
|
329
|
-
const scaleFactor = new Map();
|
|
330
|
-
const factorOfScale = (a) => {
|
|
331
|
-
let f = scaleFactor.get(a);
|
|
332
|
-
if (!f) {
|
|
333
|
-
f = { kind: 'ref', ref: g.div(`scale.${a}`, size[a], g.constant(base[a], 'mm', `base.${a}`)), sig: `scale.${a}` };
|
|
334
|
-
scaleFactor.set(a, f);
|
|
335
|
-
}
|
|
336
|
-
return f;
|
|
337
|
-
};
|
|
338
|
-
const times = (id, v, f) => {
|
|
339
|
-
if (f.kind === 'one')
|
|
340
|
-
return v;
|
|
341
|
-
if (v.kind === 'const' && v.value === 0)
|
|
342
|
-
return v;
|
|
343
|
-
return { kind: 'ref', ref: g.mul(id, g.len(v, id), f.ref) };
|
|
344
|
-
};
|
|
345
|
-
const plans = [];
|
|
346
|
-
const seen = new Set();
|
|
347
|
-
// Motion (docs/v3-motion-contract.md), planned after the parts and read when the part nodes are written.
|
|
348
|
-
const info = new Map();
|
|
349
|
-
const spinOf = new Map();
|
|
350
|
-
const shiftsOf = new Map();
|
|
351
|
-
// A scale channel: per local axis, a ratio the part's dimensions are multiplied by (contract §2: dims × affine(p)).
|
|
352
|
-
const scaleOf = new Map();
|
|
353
|
-
/** part name → the flag value saying whether it is shown. Written by a channel that V2 spelled as scale 0. */
|
|
354
|
-
const visibilityOf = new Map();
|
|
355
|
-
/** Per part and axis: how far a state channel stretches or shrinks it, from the channel's own keys. */
|
|
356
|
-
const scaleRangeOf = new Map();
|
|
357
|
-
/** Per part: where it sits, how big it is, and whether it moves on its own. Read when the volume is declared. */
|
|
358
|
-
const reachOf = new Map();
|
|
359
|
-
/** Per joint: the three length values of its origin, and the joint above it. */
|
|
360
|
-
const jointOriginOf = new Map();
|
|
361
|
-
const frameOf = new Map(); // part → the joint whose frame carries it
|
|
362
|
-
const motionOf = new Map(); // joint → pose asset.rest → asset
|
|
363
|
-
const partsByName = new Map(parts.map(cp => [cp.name, cp]));
|
|
364
|
-
const jointByChild = new Map((src.joints ?? []).map(j => [j.child, j]));
|
|
365
|
-
const carrierJointOf = (name) => {
|
|
366
|
-
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
367
|
-
const j = jointByChild.get(p.name);
|
|
368
|
-
if (j)
|
|
369
|
-
return j.name;
|
|
370
|
-
}
|
|
371
|
-
return undefined;
|
|
372
|
-
};
|
|
373
|
-
for (const cp of parts) {
|
|
374
|
-
const carrier = carrierJointOf(cp.name);
|
|
375
|
-
if (carrier)
|
|
376
|
-
frameOf.set(cp.name, carrier);
|
|
377
|
-
}
|
|
378
|
-
for (const cp of parts) {
|
|
379
|
-
const name = cp.name;
|
|
380
|
-
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(name)) {
|
|
381
|
-
refusals.push({ code: 'PART_ID', part: name, detail: 'V3 ids start with a letter and use letters, digits, _ . -' });
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
if (seen.has(name)) {
|
|
385
|
-
refusals.push({ code: 'PART_ID', part: name, detail: 'duplicate part name' });
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
seen.add(name);
|
|
389
|
-
if (cp.parent && !frameOf.has(name))
|
|
390
|
-
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` });
|
|
391
|
-
if (cp.label)
|
|
392
|
-
lost.push({ code: 'LABEL', part: name, detail: 'label has no V3 field' });
|
|
393
|
-
if (cp.materialSlot)
|
|
394
|
-
lost.push({ code: 'MATERIAL_SLOT', part: name, detail: 'materialSlot has no V3 field' });
|
|
395
|
-
if (cp.capability)
|
|
396
|
-
lost.push({ code: 'PART_CAPABILITY', part: name, detail: 'a capability anchor: V2 never draws it; its slot/port role is not carried as a V3 binding yet, so it is placed nowhere' });
|
|
397
|
-
const { material, refusal } = materialOf(cp, palette);
|
|
398
|
-
if (refusal) {
|
|
399
|
-
refusals.push(refusal);
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
for (const token of [material.token, material.emissive?.token])
|
|
403
|
-
if (token)
|
|
404
|
-
asset.palette[token] = palette[token];
|
|
405
|
-
if (cp.primitive === 'wall')
|
|
406
|
-
lost.push({ code: 'WALL_KIND', part: name, detail: 'drawn as a box; the "wall" kind has no V3 field' });
|
|
407
|
-
const hollow = cp.shape?.hollow && cp.shape.hollow.wall > 0 ? cp.shape.hollow : null;
|
|
408
|
-
if (hollow && cp.primitive !== 'rect') {
|
|
409
|
-
refusals.push({ code: 'NO_PROVIDER', part: name, detail: `V3 has no hollow ${cp.primitive}` });
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
// A polygon's path is drawn as written, so its bounding box may sit off the part centre. V3 keeps the visible
|
|
413
|
-
// position: the path is recentred and the placement moves by the same amount (ADR-0087 ruling 3).
|
|
414
|
-
let path = cp.primitive === 'polygon' ? cp.shape?.path ?? [] : [];
|
|
415
|
-
const recentre = { x: 0, z: 0 };
|
|
416
|
-
if (cp.primitive === 'polygon') {
|
|
417
|
-
const xs = path.map(q => q.x), zs = path.map(q => q.y);
|
|
418
|
-
const spanX = Math.max(...xs) - Math.min(...xs), spanZ = Math.max(...zs) - Math.min(...zs);
|
|
419
|
-
if (path.length < 3 || Math.abs(spanX - cp.transform.size.x) > 1e-6 || Math.abs(spanZ - cp.transform.size.z) > 1e-6) {
|
|
420
|
-
// V2 sizing reads `size` while the drawing reads the path; when they disagree there is no one truth to keep.
|
|
421
|
-
refusals.push({
|
|
422
|
-
code: 'POLYGON_SIZE_MISMATCH',
|
|
423
|
-
part: name,
|
|
424
|
-
detail: `path spans ${spanX}×${spanZ}; size says ${cp.transform.size.x}×${cp.transform.size.z}`
|
|
425
|
-
});
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
recentre.x = round6((Math.max(...xs) + Math.min(...xs)) / 2);
|
|
429
|
-
recentre.z = round6((Math.max(...zs) + Math.min(...zs)) / 2);
|
|
430
|
-
if (recentre.x !== 0 || recentre.z !== 0) {
|
|
431
|
-
path = path.map(q => ({ x: round6(q.x - recentre.x), y: round6(q.y - recentre.z) }));
|
|
432
|
-
notes.push({
|
|
433
|
-
code: 'POLYGON_RECENTRED',
|
|
434
|
-
part: name,
|
|
435
|
-
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`
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
if (cp.shape?.round)
|
|
439
|
-
lost.push({ code: 'POLYGON_ROUND', part: name, detail: 'V2 ignores round on a polygon; so does V3' });
|
|
440
|
-
}
|
|
441
|
-
const segments = cp.segments ?? SEG_DEFAULT;
|
|
442
|
-
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && !SEGMENT_PRESETS.includes(segments)) {
|
|
443
|
-
refusals.push({ code: 'SEGMENTS', part: name, detail: `${segments} segments; V3 accepts ${SEGMENT_PRESETS.join(', ')}` });
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
const subject = withSizing(cp);
|
|
447
|
-
const extent = rotatedExtentOf(cp);
|
|
448
|
-
const rules = { x: anchorOf(subject, base, 'x'), y: anchorOf(subject, base, 'y'), z: anchorOf(subject, base, 'z') };
|
|
449
|
-
const repeat = cp.sizing === 'repeat' && cp.repeat ? cp.repeat : null;
|
|
450
|
-
// World factor per axis: how much the rotated part's extent along that axis follows the instance.
|
|
451
|
-
const factor = {};
|
|
452
|
-
const centre = {};
|
|
453
|
-
const pending = [];
|
|
454
|
-
for (const a of AXES) {
|
|
455
|
-
const rule = rules[a];
|
|
456
|
-
const gaps = clearance(subject, base, a);
|
|
457
|
-
const reach = extent[a] / 2;
|
|
458
|
-
const pos = cp.transform.position[a];
|
|
459
|
-
if (rule === 'scale') {
|
|
460
|
-
factor[a] = factorOfScale(a);
|
|
461
|
-
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`)) };
|
|
462
|
-
}
|
|
463
|
-
else if (rule === 'span') {
|
|
464
|
-
const free = g.add(`${name}.${a}.free`, size[a], g.constant(round6(-(gaps.min + gaps.max)), 'mm', `${name}.${a}.gaps`));
|
|
465
|
-
factor[a] = { kind: 'ref', ref: g.div(`${name}.${a}.factor`, free, g.constant(extent[a], 'mm', `${name}.${a}.own`)), sig: `span.${name}.${a}` };
|
|
466
|
-
centre[a] = { kind: 'const', value: round6((gaps.min - gaps.max) / 2) };
|
|
467
|
-
}
|
|
468
|
-
else if (rule === 'min') {
|
|
469
|
-
factor[a] = ONE;
|
|
470
|
-
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`)) };
|
|
471
|
-
}
|
|
472
|
-
else if (rule === 'max') {
|
|
473
|
-
factor[a] = ONE;
|
|
474
|
-
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`)) };
|
|
475
|
-
}
|
|
476
|
-
else if (rule === 'center') {
|
|
477
|
-
factor[a] = ONE;
|
|
478
|
-
centre[a] = { kind: 'const', value: pos };
|
|
479
|
-
}
|
|
480
|
-
else {
|
|
481
|
-
pending.push(a); // aspect-*: follows another axis, settled below
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
// A capability anchor is a place, not a body: V2 compiles it apart from the drawn groups and never renders it
|
|
485
|
-
// (the independent check against the V2 renderer found the converter drawing one). Its position is kept for
|
|
486
|
-
// joints that attach to it; nothing is placed.
|
|
487
|
-
if (cp.capability) {
|
|
488
|
-
info.set(name, { centre, factor, repeat: false });
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
// A curved part keeps a round section: both cross axes take the geometric mean of their two factors
|
|
492
|
-
// (`sizing.ts` keepRound). When the two are the same expression the mean is that expression; otherwise
|
|
493
|
-
// it is a `geomean@1` node. V2 does this before an aspect axis copies its neighbour, so the copy sees the mean.
|
|
494
|
-
if ((cp.primitive === 'cylinder' || cp.primitive === 'sphere') && cp.keepRound !== false) {
|
|
495
|
-
const spin = cp.primitive === 'sphere' ? null : longAxis(subject);
|
|
496
|
-
const across = AXES.filter(a => a !== spin);
|
|
497
|
-
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
|
|
498
|
-
const same = fs.every(f => f.kind === 'one') || fs.every(f => f.kind === 'ref' && f.sig === fs[0].sig);
|
|
499
|
-
if (!same) {
|
|
500
|
-
const asRef = (f) => (f.kind === 'one' ? ratio(1, 'one') : f.ref);
|
|
501
|
-
const even = { kind: 'ref', ref: g.node(`${name}.round`, 'geomean@1', fs.map(asRef), 'value'), sig: `round.${name}` };
|
|
502
|
-
for (const a of across)
|
|
503
|
-
factor[a] = even;
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
for (const a of pending) {
|
|
507
|
-
const named = rules[a].slice('aspect-'.length);
|
|
508
|
-
factor[a] = factor[named];
|
|
509
|
-
centre[a] = times(`${name}.${a}.centre`, { kind: 'const', value: cp.transform.position[a] }, factor[named]);
|
|
510
|
-
}
|
|
511
|
-
// The V3 asset frame has its origin at the bottom of the box (ADR-0065); V2 sizing works from the centre.
|
|
512
|
-
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'))) };
|
|
513
|
-
// A recentred polygon moves back by R·c, and that offset follows the world factor of each axis as the drawn
|
|
514
|
-
// mesh did in V2 (rotation baked, then scaled per world axis).
|
|
515
|
-
if (recentre.x !== 0 || recentre.z !== 0) {
|
|
516
|
-
const R = eulerXYZ(cp.transform.rotation);
|
|
517
|
-
for (const [a, axis] of AXES.entries()) {
|
|
518
|
-
const k = round6(R[a][0] * recentre.x + R[a][2] * recentre.z);
|
|
519
|
-
if (k === 0)
|
|
520
|
-
continue;
|
|
521
|
-
const shift = times(`${name}.${axis}.recentre`, { kind: 'const', value: k }, factor[axis]);
|
|
522
|
-
centre[axis] = { kind: 'ref', ref: g.add(`${name}.${axis}.drawnAt`, g.len(centre[axis], `${name}.${axis}.declared`), g.len(shift, `${name}.${axis}.shift`)) };
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
// Local dimensions scale by the factor of the world axis each local axis lands on.
|
|
526
|
-
let perm = axisPermutation(cp.transform.rotation);
|
|
527
|
-
const follows = AXES.some(a => factor[a].kind !== 'one');
|
|
528
|
-
// Fastened to another part: rigid, with the centre riding that part's face (designer's ruling 2026-09-22).
|
|
529
|
-
const fastening = options.reauthor?.attach?.find(x => x.part === name);
|
|
530
|
-
if (fastening) {
|
|
531
|
-
const mount = parts.find(x => x.name === fastening.to);
|
|
532
|
-
if (!mount) {
|
|
533
|
-
refusals.push({ code: 'ATTACH_TARGET', part: name, detail: `it is to be fastened to "${fastening.to}", which is not a part of this figure` });
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
/*
|
|
537
|
-
Which face it is fastened to: the one it stands out through. A panel set into its housing still counts,
|
|
538
|
-
which is how the notching machine's control panel is drawn — it is sunk into the enclosure and only its
|
|
539
|
-
front shows. Exactly one face must qualify, or there is nothing to call the mounting.
|
|
540
|
-
*/
|
|
541
|
-
const reachOf = (q, a) => ({ lo: q.transform.position[a] - q.transform.size[a] / 2, hi: q.transform.position[a] + q.transform.size[a] / 2 });
|
|
542
|
-
const held = [];
|
|
543
|
-
for (const a of AXES) {
|
|
544
|
-
const mine = reachOf(cp, a), theirs = reachOf(mount, a);
|
|
545
|
-
if (mine.hi > theirs.hi + 1e-9)
|
|
546
|
-
held.push({ axis: a, face: theirs.hi, offset: cp.transform.position[a] - theirs.hi });
|
|
547
|
-
else if (mine.lo < theirs.lo - 1e-9)
|
|
548
|
-
held.push({ axis: a, face: theirs.lo, offset: cp.transform.position[a] - theirs.lo });
|
|
549
|
-
}
|
|
550
|
-
if (held.length !== 1) {
|
|
551
|
-
refusals.push({
|
|
552
|
-
code: 'ATTACH_FACE',
|
|
553
|
-
part: name,
|
|
554
|
-
detail: held.length
|
|
555
|
-
? `it stands out of "${fastening.to}" on ${held.map(h => h.axis).join(' and ')}; which face holds it cannot be read from the drawing`
|
|
556
|
-
: `it is wholly inside "${fastening.to}", so there is no face it stands out through`
|
|
557
|
-
});
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
const face = held[0];
|
|
561
|
-
if (anchorOf(withSizing(mount), base, face.axis) !== 'scale') {
|
|
562
|
-
refusals.push({ code: 'ATTACH_MOUNT', part: name, detail: `"${fastening.to}" does not follow the instance proportionally on ${face.axis}, so its face is not a ratio of the size` });
|
|
563
|
-
continue;
|
|
564
|
-
}
|
|
565
|
-
// The mount's face is that ratio of the instance size; the gap the author drew is kept as it is.
|
|
566
|
-
const faceRatio = face.face / base[face.axis];
|
|
567
|
-
const rideTo = g.add(`${name}.${face.axis}.fastened`, g.mul(`${name}.${face.axis}.face`, size[face.axis], ratio(round6(faceRatio), `${name}.${face.axis}.faceAt`)), g.constant(round6(face.offset), 'mm', `${name}.${face.axis}.gap`));
|
|
568
|
-
for (const a of AXES)
|
|
569
|
-
factor[a] = ONE;
|
|
570
|
-
centre[face.axis] = { kind: 'ref', ref: rideTo };
|
|
571
|
-
perm = { x: 'x', y: 'y', z: 'z' };
|
|
572
|
-
method = 're-authored';
|
|
573
|
-
notes.push({
|
|
574
|
-
code: 'RE_AUTHORED_FASTENED',
|
|
575
|
-
part: name,
|
|
576
|
-
detail: `tilted (${AXES.map(a => `${a}:${cp.transform.rotation?.[a] ?? 0}°`).join(', ')}) and following the instance, which V2 shears. Re-authored: it keeps its authored dimensions and tilt, and its centre rides the ${face.axis} face of "${fastening.to}" at the ${face.offset.toFixed(1)} mm the author drew. The gap between them is the same at every instance size`
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
if (!perm && follows) {
|
|
580
|
-
// A rotation that only mixes axes carrying the same factor is not a shear: scaling and rotating commute,
|
|
581
|
-
// so each local axis keeps its own axis's factor and the body is the one V2 draws.
|
|
582
|
-
const sameFactor = (a, b) => {
|
|
583
|
-
const x = factor[a], y = factor[b];
|
|
584
|
-
if (x.kind === 'one' && y.kind === 'one')
|
|
585
|
-
return true;
|
|
586
|
-
return x.kind === 'ref' && y.kind === 'ref' && x.sig !== undefined && x.sig === y.sig;
|
|
587
|
-
};
|
|
588
|
-
if (scalingCommutesWithRotation(cp.transform.rotation, sameFactor)) {
|
|
589
|
-
perm = { x: 'x', y: 'y', z: 'z' };
|
|
590
|
-
notes.push({
|
|
591
|
-
code: 'ROTATED_UNIFORM_PLANE',
|
|
592
|
-
part: name,
|
|
593
|
-
detail: `turned off the axes and following the instance, but every pair of axes the rotation mixes carries the same factor, so scaling and rotating commute. V2 scaled it uniformly inside the plane it turns in rather than shearing it; converted as V2 draws it, with no re-authoring`
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
if (!perm && follows) {
|
|
598
|
-
const nearest = options.reauthor?.rotatedFollows === 'nearest-axis' ? nearestAxisPermutation(cp.transform.rotation) : null;
|
|
599
|
-
if (!nearest) {
|
|
600
|
-
refusals.push({ code: 'ROTATED_FOLLOWS', part: name, detail: 'rotated off the axes and its size follows the instance; V2 shears it, V3 cannot' });
|
|
601
|
-
continue;
|
|
602
|
-
}
|
|
603
|
-
perm = nearest;
|
|
604
|
-
method = 're-authored';
|
|
605
|
-
const rot = cp.transform.rotation ?? {};
|
|
606
|
-
notes.push({
|
|
607
|
-
code: 'RE_AUTHORED_ROTATED',
|
|
608
|
-
part: name,
|
|
609
|
-
detail: `rotated (${AXES.map(a => `${a}:${rot[a] ?? 0}°`).join(', ')}) and its size follows the instance; V2 sheared the mesh. Re-authored rigid: each local dimension follows the world axis it lies nearest to (${AXES.map(a => `${a}→${nearest[a]}`).join(', ')}). Same as V2 where the factors on the in-plane axes agree; different elsewhere, by design`
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
// A channel scale is planned after the parts and read when the shape node is written, so `dim` is called
|
|
613
|
-
// inside the shape closures only.
|
|
614
|
-
const withChannel = (v, local, hint) => {
|
|
615
|
-
const sc = scaleOf.get(name)?.[local];
|
|
616
|
-
if (!sc)
|
|
617
|
-
return v;
|
|
618
|
-
return { kind: 'ref', ref: g.mul(`${name}.${hint}.scaled`, g.len(v, `${name}.${hint}`), sc) };
|
|
619
|
-
};
|
|
620
|
-
// Memoised: the shape writes it and the declared volume reads it, and a node id may only be claimed once.
|
|
621
|
-
const dims = {};
|
|
622
|
-
const raws = {};
|
|
623
|
-
/** The dimension before any state channel scales it. The declared volume measures from this. */
|
|
624
|
-
const rawDim = (local) => (raws[local] ??= times(`${name}.dim.${local}`, { kind: 'const', value: cp.transform.size[local] }, perm ? factor[perm[local]] : ONE));
|
|
625
|
-
const dim = (local) => (dims[local] ??= withChannel(rawDim(local), local, `dim.${local}`));
|
|
626
|
-
const channelSplits = () => {
|
|
627
|
-
const sc = scaleOf.get(name);
|
|
628
|
-
return !!sc && (sc.x ?? null) !== (sc.z ?? null);
|
|
629
|
-
};
|
|
630
|
-
const localFrame = `${name}.local`;
|
|
631
|
-
// A part that spins gets its shape in a `spun` frame; the turn maps spun → local.
|
|
632
|
-
const shapeFrame = () => (spinOf.has(name) ? `${name}.spun` : localFrame);
|
|
633
|
-
const rot = cp.transform.rotation;
|
|
634
|
-
const rotationRefs = AXES.map(a => g.constant(rot?.[a] ?? 0, 'deg', `deg.${rot?.[a] ?? 0}`));
|
|
635
|
-
let shape;
|
|
636
|
-
if (cp.primitive === 'cylinder') {
|
|
637
|
-
// V2 builds every cylinder from a unit cylinder with equal end radii and scales it by size (things-scene
|
|
638
|
-
// geometry-bank): size.x ≠ size.z, or two cross factors that differ with keepRound off, is an elliptic
|
|
639
|
-
// cylinder, never a frustum. Equal radii by the same expression stay on cylinder-shape@1.
|
|
640
|
-
const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
641
|
-
const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
|
|
642
|
-
const equalSizes = Math.abs(cp.transform.size.x - cp.transform.size.z) <= 1e-9;
|
|
643
|
-
if (!(sameFactor && equalSizes))
|
|
644
|
-
notes.push({ code: 'ELLIPTIC_SECTION', part: name, detail: `section is not a circle (${!equalSizes ? `size.x ${cp.transform.size.x} ≠ size.z ${cp.transform.size.z}` : 'keepRound is off and the two cross axes follow the instance differently'}); written as cylinder-shape@2 with radiusX and radiusZ, as V2 drew it` });
|
|
645
|
-
shape = () => {
|
|
646
|
-
const radiusX = withChannel(times(`${name}.dim.radiusX`, { kind: 'const', value: cp.transform.size.x / 2 }, fx), 'x', 'radiusX');
|
|
647
|
-
const radiusZ = withChannel(times(`${name}.dim.radiusZ`, { kind: 'const', value: cp.transform.size.z / 2 }, fz), 'z', 'radiusZ');
|
|
648
|
-
const length = dim('y');
|
|
649
|
-
const round = sameFactor && equalSizes && !channelSplits();
|
|
650
|
-
return round
|
|
651
|
-
? g.node(`${name}.shape`, 'cylinder-shape@1', [g.len(radiusX, `${name}.radius`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() })
|
|
652
|
-
: g.node(`${name}.shape`, 'cylinder-shape@2', [g.len(radiusX, `${name}.radiusX`), g.len(radiusZ, `${name}.radiusZ`), g.len(length, `${name}.length`)], 'shape', { frame: shapeFrame() });
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
else if (cp.primitive === 'sphere') {
|
|
656
|
-
const r = (local) => withChannel(times(`${name}.dim.radius${local.toUpperCase()}`, { kind: 'const', value: cp.transform.size[local] / 2 }, perm ? factor[perm[local]] : ONE), local, `radius${local.toUpperCase()}`);
|
|
657
|
-
shape = () => {
|
|
658
|
-
const [rx, ry, rz] = [r('x'), r('y'), r('z')];
|
|
659
|
-
return g.node(`${name}.shape`, 'sphere-shape@1', [g.len(rx, `${name}.rx`), g.len(ry, `${name}.ry`), g.len(rz, `${name}.rz`)], 'shape', { frame: shapeFrame() });
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
else if (cp.primitive === 'polygon') {
|
|
663
|
-
const fx = perm ? factor[perm.x] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
664
|
-
shape = () => {
|
|
665
|
-
const h = dim('y');
|
|
666
|
-
const points = path.flatMap((q, i) => [
|
|
667
|
-
withChannel(times(`${name}.p${i}.x`, { kind: 'const', value: q.x }, fx), 'x', `p${i}.x`),
|
|
668
|
-
withChannel(times(`${name}.p${i}.z`, { kind: 'const', value: q.y }, fz), 'z', `p${i}.z`)
|
|
669
|
-
]);
|
|
670
|
-
return g.node(`${name}.shape`, 'polygon-shape@1', [g.len(h, `${name}.h`), ...points.map((v, i) => g.len(v, `${name}.pt${i}`))], 'shape', { frame: shapeFrame() });
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
else if (cp.primitive === 'rect') {
|
|
674
|
-
// V2 draws the corner with min(round, width/2, depth/2) (things-scene roundedRect). Write what was drawn.
|
|
675
|
-
const declared = cp.shape?.round ?? 0;
|
|
676
|
-
const round = round6(Math.min(declared, cp.transform.size.x / 2, cp.transform.size.z / 2));
|
|
677
|
-
if (round < declared)
|
|
678
|
-
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)` });
|
|
679
|
-
// V2 scales the finished mesh per world axis, so a corner drawn with radius r becomes an elliptic arc with
|
|
680
|
-
// semi-axes r·fx, r·fz (shape-dimension contract §1). When the two factors are one expression, @1 is enough.
|
|
681
|
-
const fx = perm ? factor[perm.x] : ONE, fy = perm ? factor[perm.y] : ONE, fz = perm ? factor[perm.z] : ONE;
|
|
682
|
-
const sameFactor = (fx.kind === 'one' && fz.kind === 'one') || (fx.kind === 'ref' && fz.kind === 'ref' && fx.sig === fz.sig);
|
|
683
|
-
if (round > 0 && !sameFactor)
|
|
684
|
-
notes.push({ code: 'CORNER_PER_AXIS', part: name, detail: `corner radius ${round} mm follows x and z differently under sizing; written with per-axis corner semi-axes, as V2 drew it` });
|
|
685
|
-
shape = () => {
|
|
686
|
-
const roundX = withChannel(times(`${name}.roundX`, { kind: 'const', value: round }, fx), 'x', 'roundX');
|
|
687
|
-
const roundZ = withChannel(times(`${name}.roundZ`, { kind: 'const', value: round }, fz), 'z', 'roundZ');
|
|
688
|
-
const perAxis = round > 0 && (!sameFactor || channelSplits());
|
|
689
|
-
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
690
|
-
if (hollow) {
|
|
691
|
-
// A wall's thickness follows the axis it lies across and the floor follows the height.
|
|
692
|
-
const wallX = withChannel(times(`${name}.wallX`, { kind: 'const', value: hollow.wall }, fx), 'x', 'wallX');
|
|
693
|
-
const wallZ = withChannel(times(`${name}.wallZ`, { kind: 'const', value: hollow.wall }, fz), 'z', 'wallZ');
|
|
694
|
-
const floor = withChannel(times(`${name}.floor`, { kind: 'const', value: hollow.floor ?? hollow.wall }, fy), 'y', 'floor');
|
|
695
|
-
return perAxis
|
|
696
|
-
? g.node(`${name}.shape`, 'hollow-box@2', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.roundX`), g.len(roundZ, `${name}.roundZ`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: shapeFrame() })
|
|
697
|
-
: g.node(`${name}.shape`, 'hollow-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.round`), g.len(wallX, `${name}.wallX`), g.len(wallZ, `${name}.wallZ`), g.len(floor, `${name}.floor`)], 'shape', { frame: shapeFrame() });
|
|
698
|
-
}
|
|
699
|
-
return perAxis
|
|
700
|
-
? g.node(`${name}.shape`, 'rounded-box@2', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.roundX`), g.len(roundZ, `${name}.roundZ`)], 'shape', { frame: shapeFrame() })
|
|
701
|
-
: g.node(`${name}.shape`, 'rounded-box@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`), g.len(roundX, `${name}.round`)], 'shape', { frame: shapeFrame() });
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
shape = () => {
|
|
706
|
-
const [w, h, d] = [dim('x'), dim('y'), dim('z')];
|
|
707
|
-
return g.node(`${name}.shape`, 'box-shape@1', [g.len(w, `${name}.w`), g.len(h, `${name}.h`), g.len(d, `${name}.d`)], 'shape', { frame: shapeFrame() });
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
const appearance = { target: name, material: material };
|
|
711
|
-
if (cp.primitive === 'cylinder' || cp.primitive === 'sphere')
|
|
712
|
-
appearance.segments = segments;
|
|
713
|
-
info.set(name, { centre, factor, repeat: !!repeat });
|
|
714
|
-
/** The widest this body's side can be: its own dimension, times the most a state channel stretches it. */
|
|
715
|
-
const widestOf = (a) => {
|
|
716
|
-
const range = scaleRangeOf.get(name)?.[a];
|
|
717
|
-
if (!scaleOf.get(name)?.[a])
|
|
718
|
-
return dim(a);
|
|
719
|
-
if (!range)
|
|
720
|
-
return null; // a channel whose reach is not known; the volume cannot be produced from it
|
|
721
|
-
const k = Math.max(Math.abs(range.min), Math.abs(range.max));
|
|
722
|
-
return k === 1 ? rawDim(a) : times(`${name}.widest.${a}`, rawDim(a), { kind: 'ref', ref: ratio(round6(k), `${name}.widest.${a}.k`), sig: `widest.${name}.${a}` });
|
|
723
|
-
};
|
|
724
|
-
/*
|
|
725
|
-
A part's dimensions are its own, measured along its own axes; the declared volume is measured along the
|
|
726
|
-
asset's. A body turned by its authored rotation fills, on one asset axis, the sum of its three sides
|
|
727
|
-
weighted by how much each leans onto that axis — the box around the turned box. For a quarter turn that is
|
|
728
|
-
a plain swap, and for anything between it is wider than the body, which is the direction to be wrong in.
|
|
729
|
-
Reading the local side as if it were the world side is how a motor lying on its side came to be declared
|
|
730
|
-
15 mm short.
|
|
731
|
-
*/
|
|
732
|
-
const rotationMatrix = eulerXYZ(cp.transform.rotation);
|
|
733
|
-
const worldSpan = (a) => {
|
|
734
|
-
const row = rotationMatrix[AXES.indexOf(a)];
|
|
735
|
-
/*
|
|
736
|
-
A bound that rounds inward is not a bound, so every weight that stays is rounded away from zero.
|
|
737
|
-
|
|
738
|
-
A weight too small to be worth its own node is not moved onto another axis: the axes carry different
|
|
739
|
-
sides, and 5e-10 of a 100000 mm side is not covered by 5e-10 more of a 0.01 mm one (V3 designer's
|
|
740
|
-
counterexample 2026-09-23). It is dropped only when its own side is a fixed length, and then its whole
|
|
741
|
-
contribution in millimetres is added on, rounded up. A side that follows the instance keeps its term.
|
|
742
|
-
*/
|
|
743
|
-
const terms = [];
|
|
744
|
-
let extraMm = 0;
|
|
745
|
-
for (const [i, local] of AXES.entries()) {
|
|
746
|
-
const k = Math.abs(row[i]);
|
|
747
|
-
if (k === 0)
|
|
748
|
-
continue;
|
|
749
|
-
const side = widestOf(local);
|
|
750
|
-
if (!side)
|
|
751
|
-
return null;
|
|
752
|
-
const up = Math.ceil(k * 1e6) / 1e6;
|
|
753
|
-
if (k < 1e-9 && side.kind === 'const') {
|
|
754
|
-
extraMm += Math.ceil(up * side.value * 1e6) / 1e6;
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
terms.push({ k: up, len: side });
|
|
758
|
-
}
|
|
759
|
-
return { terms, extraMm };
|
|
760
|
-
};
|
|
761
|
-
// The declared volume is built from the structure, so it needs each body's own reach and where it sits.
|
|
762
|
-
reachOf.set(name, {
|
|
763
|
-
centre,
|
|
764
|
-
dim,
|
|
765
|
-
turns: () => spinOf.has(name),
|
|
766
|
-
widest: widestOf,
|
|
767
|
-
worldSpan,
|
|
768
|
-
slides: () => (shiftsOf.get(name) ?? [])
|
|
769
|
-
.filter(sh => sh.travel)
|
|
770
|
-
.map(sh => ({ axis: sh.axis, factor: factor[sh.axis], travel: sh.travel }))
|
|
771
|
-
});
|
|
772
|
-
if (!repeat) {
|
|
773
|
-
plans.push({
|
|
774
|
-
appearance,
|
|
775
|
-
nodes: () => {
|
|
776
|
-
const s = shape();
|
|
777
|
-
const shifts = shiftsOf.get(name) ?? [];
|
|
778
|
-
const frame = frameOf.get(name);
|
|
779
|
-
const finalTo = frame ? 'asset.rest' : 'asset';
|
|
780
|
-
// rest: local → F0; each slide F_k → F_k+1 along an asset axis; the last lands in finalTo.
|
|
781
|
-
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 });
|
|
782
|
-
shifts.forEach((shift, k) => {
|
|
783
|
-
const axis = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
|
|
784
|
-
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}` });
|
|
785
|
-
pose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, pose], 'pose');
|
|
786
|
-
});
|
|
787
|
-
if (frame)
|
|
788
|
-
pose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), pose], 'pose');
|
|
789
|
-
const spin = spinOf.get(name);
|
|
790
|
-
if (spin) {
|
|
791
|
-
const axis = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
|
|
792
|
-
const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axis, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
|
|
793
|
-
pose = g.node(`${name}.spinning`, 'compose@1', [pose, turn], 'pose');
|
|
794
|
-
}
|
|
795
|
-
g.node(name, 'place@1', [s, pose], 'placed');
|
|
796
|
-
}
|
|
797
|
-
});
|
|
798
|
-
continue;
|
|
799
|
-
}
|
|
800
|
-
// Repeat: copies pitch apart in the world (times the world factor of that axis), centred on the authored
|
|
801
|
-
// position, count = floor(room / pitch) with room = the instance size when the row spreads, else the base.
|
|
802
|
-
const axis = repeat.axis;
|
|
803
|
-
const spreads = rules[axis] !== 'scale';
|
|
804
|
-
const itemFrame = `${name}.item`;
|
|
805
|
-
plans.push({
|
|
806
|
-
appearance,
|
|
807
|
-
nodes: () => {
|
|
808
|
-
const s = shape();
|
|
809
|
-
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 });
|
|
810
|
-
// A spinning repeated part (the conveyor's rollers) turns each copy about its own axis: the turn goes on the member.
|
|
811
|
-
const spin = spinOf.get(name);
|
|
812
|
-
if (spin) {
|
|
813
|
-
const axisRefs = AXES.map(a => ratio(a === spin.axis ? 1 : 0, a === spin.axis ? 'one' : 'zeroRatio'));
|
|
814
|
-
const turn = g.node(`${name}.turn`, 'axis-turn@1', [...axisRefs, spin.q], 'pose', { from: `${name}.spun`, to: localFrame });
|
|
815
|
-
memberPose = g.node(`${name}.spinning`, 'compose@1', [memberPose, turn], 'pose');
|
|
816
|
-
}
|
|
817
|
-
const member = g.node(name, 'member@1', [s, memberPose], 'placed');
|
|
818
|
-
const assembly = g.node(`${name}.assembly`, 'assembly@1', [member], 'assembly');
|
|
819
|
-
let layout;
|
|
820
|
-
let rowStart;
|
|
821
|
-
if (spreads) {
|
|
822
|
-
const zero = g.constant(0, 'mm', 'zero');
|
|
823
|
-
const pitch = g.constant(repeat.pitch, 'mm', `${name}.pitch`);
|
|
824
|
-
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');
|
|
825
|
-
rowStart = g.add(`${name}.rowStart`, g.len(centre[axis], `${name}.${axis}`), g.mul(`${name}.halfRow`, size[axis], ratio(-0.5, 'minusHalf')));
|
|
826
|
-
}
|
|
827
|
-
else {
|
|
828
|
-
// A fixed count spread pitch·factor apart: `fixed-count@1` spaces `count` items evenly over L - e,
|
|
829
|
-
// so L = (count - 1)·pitch·factor + e with a 1 mm item extent, and the row starts e/2 before the first copy.
|
|
830
|
-
const count = Math.max(1, Math.min(REPEAT_LIMIT, Math.floor(base[axis] / repeat.pitch)));
|
|
831
|
-
const e = g.constant(1, 'mm', 'one');
|
|
832
|
-
const row = times(`${name}.row`, { kind: 'const', value: (count - 1) * repeat.pitch }, factor[axis]);
|
|
833
|
-
const L = g.add(`${name}.rowLength`, g.len(row, `${name}.row`), e);
|
|
834
|
-
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');
|
|
835
|
-
const halfRow = times(`${name}.halfRow`, { kind: 'const', value: -((count - 1) * repeat.pitch) / 2 }, factor[axis]);
|
|
836
|
-
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'));
|
|
837
|
-
}
|
|
838
|
-
// The row as a whole shifts with a translation channel and moves with the joint frame that carries it.
|
|
839
|
-
const shifts = shiftsOf.get(name) ?? [];
|
|
840
|
-
const frame = frameOf.get(name);
|
|
841
|
-
const finalTo = frame ? 'asset.rest' : 'asset';
|
|
842
|
-
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 });
|
|
843
|
-
shifts.forEach((shift, k) => {
|
|
844
|
-
const axisRefs = AXES.map(a => ratio(a === shift.axis ? 1 : 0, a === shift.axis ? 'one' : 'zeroRatio'));
|
|
845
|
-
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}` });
|
|
846
|
-
rootPose = g.node(`${name}.shifted${k}`, 'compose@1', [slide, rootPose], 'pose');
|
|
847
|
-
});
|
|
848
|
-
if (frame)
|
|
849
|
-
rootPose = g.node(`${name}.moved`, 'compose@1', [motionOf.get(frame), rootPose], 'pose');
|
|
850
|
-
g.node(`${name}.repeat`, 'repeat@1', [layout, assembly, rootPose], 'collection', { axis });
|
|
851
|
-
}
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
asset.occupancy.bounds = {
|
|
855
|
-
x: { min: g.mul('occupancy.x.min', size.x, ratio(-0.5, 'minusHalf')), max: g.mul('occupancy.x.max', size.x, ratio(0.5, 'half')) },
|
|
856
|
-
y: { min: g.constant(0, 'mm', 'zero'), max: V3_SIZE_INPUTS.y },
|
|
857
|
-
z: { min: g.mul('occupancy.z.min', size.z, ratio(-0.5, 'minusHalf')), max: g.mul('occupancy.z.max', size.z, ratio(0.5, 'half')) }
|
|
858
|
-
};
|
|
859
|
-
const motion = planMotion({ src, g, base, size, info, spinOf, shiftsOf, scaleOf, scaleRangeOf, visibilityOf, jointOriginOf, lost, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio, times, factorOfScale });
|
|
860
|
-
if (motion.reauthored)
|
|
861
|
-
method = 're-authored';
|
|
862
|
-
if (refusals.length)
|
|
863
|
-
return { status: 'refused', refusals, lost, rangeChanges, notes };
|
|
864
|
-
for (const plan of plans) {
|
|
865
|
-
plan.nodes();
|
|
866
|
-
const shown = visibilityOf.get(plan.appearance.target);
|
|
867
|
-
asset.appearance.push(shown ? { ...plan.appearance, visibility: shown } : plan.appearance);
|
|
868
|
-
}
|
|
869
|
-
if (options.occupancy === 'measured') {
|
|
870
|
-
/** Which joint carries a part: the nearest one above it in the parent chain. */
|
|
871
|
-
const jointsByChild = new Map((src.joints ?? []).map(j => [j.child, j.name]));
|
|
872
|
-
const carrierOfPart = (name) => {
|
|
873
|
-
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
874
|
-
const j = jointsByChild.get(p.name);
|
|
875
|
-
if (j)
|
|
876
|
-
return j;
|
|
877
|
-
}
|
|
878
|
-
return undefined;
|
|
879
|
-
};
|
|
880
|
-
declareOccupancy({ g, parts, reachOf, jointOriginOf, carrierOf: carrierOfPart, base, designInputs: asset.designInputs, asset, refusals, notes });
|
|
881
|
-
if (refusals.length)
|
|
882
|
-
return { status: 'refused', refusals, lost, rangeChanges, notes };
|
|
883
|
-
}
|
|
884
|
-
try {
|
|
885
|
-
compileV3Asset(asset);
|
|
886
|
-
}
|
|
887
|
-
catch (e) {
|
|
888
|
-
const err = e;
|
|
889
|
-
return { status: 'refused', refusals: [{ code: `V3_${err.code ?? 'COMPILE'}`, part: err.path, detail: err.message }], lost, rangeChanges, notes };
|
|
890
|
-
}
|
|
891
|
-
return { status: 'converted', method, asset, lost, rangeChanges, notes };
|
|
892
|
-
}
|
|
893
|
-
/**
|
|
894
|
-
* Declares the volume the figure occupies, from the structure of the figure rather than from measurements.
|
|
895
|
-
*
|
|
896
|
-
* V2 had one box for two jobs: the size a figure is authored and placed at, and the room it takes up. In V3
|
|
897
|
-
* they are separate (V3 designer's ruling 2026-09-22), and the volume is the reach on each side of the origin
|
|
898
|
-
* (ruling 2026-09-23), so it can describe a part below the mounting plane or off one side only.
|
|
899
|
-
*
|
|
900
|
-
* **Why not measure it.** An earlier version sampled the reach at three instance sizes on one axis and fitted a
|
|
901
|
-
* straight line. That says nothing between or beyond those sizes, nothing about changing two axes together, and
|
|
902
|
-
* nothing about the poses between the state corners it happened to visit. Passing the third point proved
|
|
903
|
-
* nothing (designer's ruling 2026-09-23).
|
|
904
|
-
*
|
|
905
|
-
* **What is built instead.** For every body, a bound that holds in every allowed state because of how the
|
|
906
|
-
* figure is put together, written as graph values so it is right at every instance size by construction:
|
|
907
|
-
*
|
|
908
|
-
* a body that does not move exactly its own box
|
|
909
|
-
* a body that turns where it sits its centre, give or take its own reach
|
|
910
|
-
* a body carried by joints the outermost joint's origin, give or take the chain: the distance out
|
|
911
|
-
* to each joint below it, any sliding travel those joints allow, and the
|
|
912
|
-
* body's own reach
|
|
913
|
-
*
|
|
914
|
-
* A turning joint can point what hangs below it in any direction, so the chain is summed as lengths and applied
|
|
915
|
-
* to every axis. That is wider than the true swept volume; it is a bound that can be explained, not a guess.
|
|
916
|
-
* Joint angle ranges can tighten it later. This is arithmetic about where the parts can be, not a claim about
|
|
917
|
-
* collisions, loads or safety, and it applies to a revolving door and a lift the same way it applies to an arm.
|
|
918
|
-
*
|
|
919
|
-
* A length is summed as `|dx| + |dy| + |dz|`, which is at least the straight-line distance, so the bound stays
|
|
920
|
-
* on the safe side. `|x|` is `max(x, -x)`.
|
|
921
|
-
*/
|
|
922
|
-
/**
|
|
923
|
-
* A value read as `base + Σ slope_a · size_a`, or null when it is not of that shape.
|
|
924
|
-
*
|
|
925
|
-
* This reads the graph, it does not sample it, so what it returns is the value at every instance size and not
|
|
926
|
-
* a fit through a few of them. It is how the declaration knows which parts can never leave the authored box:
|
|
927
|
-
* every size input is positive, so a difference whose base and slopes are all non-negative is never negative.
|
|
928
|
-
*/
|
|
929
|
-
function affineInSize(g, designInputs, ref) {
|
|
930
|
-
const zero = () => ({ x: 0, y: 0, z: 0 });
|
|
931
|
-
const constants = new Map(g.graph.constants.map(k => [k.id, k.value * (k.unit === 'cm' ? 10 : k.unit === 'm' ? 1000 : k.unit === 'percent' ? 0.01 : 1)]));
|
|
932
|
-
const writers = new Map();
|
|
933
|
-
for (const n of g.graph.nodes)
|
|
934
|
-
for (const out of Object.values(n.outputs))
|
|
935
|
-
writers.set(out, n);
|
|
936
|
-
const seen = new Set();
|
|
937
|
-
const walk = (r) => {
|
|
938
|
-
if (seen.has(r))
|
|
939
|
-
return null;
|
|
940
|
-
seen.add(r);
|
|
941
|
-
try {
|
|
942
|
-
for (const a of AXES)
|
|
943
|
-
if (r === V3_SIZE_INPUTS[a])
|
|
944
|
-
return { base: 0, slope: { ...zero(), [a]: 1 } };
|
|
945
|
-
if (constants.has(r))
|
|
946
|
-
return { base: constants.get(r), slope: zero() };
|
|
947
|
-
if (Object.hasOwn(designInputs, r))
|
|
948
|
-
return { base: designInputs[r], slope: zero() };
|
|
949
|
-
const n = writers.get(r);
|
|
950
|
-
if (!n)
|
|
951
|
-
return null;
|
|
952
|
-
if (n.op === 'add@1') {
|
|
953
|
-
const a = walk(n.args[0]), b = walk(n.args[1]);
|
|
954
|
-
return a && b ? { base: a.base + b.base, slope: { x: a.slope.x + b.slope.x, y: a.slope.y + b.slope.y, z: a.slope.z + b.slope.z } } : null;
|
|
955
|
-
}
|
|
956
|
-
if (n.op === 'div@1') {
|
|
957
|
-
const a = walk(n.args[0]), b = walk(n.args[1]);
|
|
958
|
-
// Only a constant denominator keeps the value affine; a span's factor is (size − gaps) / a fixed length.
|
|
959
|
-
if (!a || !b || AXES.some(x => b.slope[x] !== 0) || b.base === 0)
|
|
960
|
-
return null;
|
|
961
|
-
return { base: a.base / b.base, slope: { x: a.slope.x / b.base, y: a.slope.y / b.base, z: a.slope.z / b.base } };
|
|
962
|
-
}
|
|
963
|
-
if (n.op === 'mul@1') {
|
|
964
|
-
const a = walk(n.args[0]), b = walk(n.args[1]);
|
|
965
|
-
if (!a || !b)
|
|
966
|
-
return null;
|
|
967
|
-
const flat = (v) => AXES.every(x => v.slope[x] === 0);
|
|
968
|
-
if (flat(b))
|
|
969
|
-
return { base: a.base * b.base, slope: { x: a.slope.x * b.base, y: a.slope.y * b.base, z: a.slope.z * b.base } };
|
|
970
|
-
if (flat(a))
|
|
971
|
-
return { base: a.base * b.base, slope: { x: b.slope.x * a.base, y: b.slope.y * a.base, z: b.slope.z * a.base } };
|
|
972
|
-
return null;
|
|
973
|
-
}
|
|
974
|
-
return null;
|
|
975
|
-
}
|
|
976
|
-
finally {
|
|
977
|
-
seen.delete(r);
|
|
978
|
-
}
|
|
979
|
-
};
|
|
980
|
-
return walk(ref);
|
|
981
|
-
}
|
|
982
|
-
function declareOccupancy(c) {
|
|
983
|
-
const { g, parts, reachOf, jointOriginOf, carrierOf, base, designInputs, asset, refusals, notes } = c;
|
|
984
|
-
const authoredBounds = asset.occupancy.bounds;
|
|
985
|
-
const affOfLen = (l) => (l.kind === 'const' ? { base: l.value, slope: { x: 0, y: 0, z: 0 } } : affineInSize(g, designInputs, l.ref));
|
|
986
|
-
const affAdd = (a, b, k = 1) => ({
|
|
987
|
-
base: a.base + k * b.base,
|
|
988
|
-
slope: AXES.reduce((o, x) => ((o[x] = a.slope[x] + k * b.slope[x]), o), {})
|
|
989
|
-
});
|
|
990
|
-
const affScale = (a, k) => ({ base: a.base * k, slope: AXES.reduce((o, x) => ((o[x] = a.slope[x] * k), o), {}) });
|
|
991
|
-
/** Is this value never past the authored bound on that side, at any instance size? Read from the graph. */
|
|
992
|
-
const neverPast = (mine, a, side) => {
|
|
993
|
-
const theirs = affineInSize(g, designInputs, authoredBounds[a][side]);
|
|
994
|
-
if (!mine || !theirs)
|
|
995
|
-
return false;
|
|
996
|
-
// min side: authored − mine ≤ 0 everywhere. max side: mine − authored ≤ 0 everywhere.
|
|
997
|
-
const d = side === 'min'
|
|
998
|
-
? { base: theirs.base - mine.base, slope: AXES.reduce((o, x) => ((o[x] = theirs.slope[x] - mine.slope[x]), o), {}) }
|
|
999
|
-
: { base: mine.base - theirs.base, slope: AXES.reduce((o, x) => ((o[x] = mine.slope[x] - theirs.slope[x]), o), {}) };
|
|
1000
|
-
// Only the slack that floating point itself needs. SAME_TOLERANCE_MM is how close V2 and V3 must draw the
|
|
1001
|
-
// same body; borrowing it here let a part hang 1 mm outside the volume that is supposed to hold it.
|
|
1002
|
-
return d.base <= FLOAT_SLACK_MM && AXES.every(x => d.slope[x] <= FLOAT_SLACK_MM * 1e-3);
|
|
1003
|
-
};
|
|
1004
|
-
let seq = 0;
|
|
1005
|
-
const id = () => `occ.v${seq++}`;
|
|
1006
|
-
const isNum = (v) => 'n' in v;
|
|
1007
|
-
const ref = (v, hint) => (isNum(v) ? g.constant(round6(v.n), 'mm', hint) : v.ref);
|
|
1008
|
-
const ofLen = (l) => (l.kind === 'const' ? { n: l.value } : { ref: l.ref });
|
|
1009
|
-
/*
|
|
1010
|
-
The same sum turns up again and again: every body on a shelf is half its own depth from the same place, and
|
|
1011
|
-
every joint in a chain is measured from the one above it by parts that share it. Writing each of those once
|
|
1012
|
-
is what keeps a twenty-part figure inside the document's node limit, and it changes no value.
|
|
1013
|
-
*/
|
|
1014
|
-
const shared = new Map();
|
|
1015
|
-
const once = (key, build) => {
|
|
1016
|
-
const known = shared.get(key);
|
|
1017
|
-
if (known)
|
|
1018
|
-
return known;
|
|
1019
|
-
const made = build();
|
|
1020
|
-
shared.set(key, made);
|
|
1021
|
-
return made;
|
|
1022
|
-
};
|
|
1023
|
-
const add = (a, b) => {
|
|
1024
|
-
if (isNum(a) && isNum(b))
|
|
1025
|
-
return { n: a.n + b.n };
|
|
1026
|
-
if (isNum(a) && a.n === 0)
|
|
1027
|
-
return b;
|
|
1028
|
-
if (isNum(b) && b.n === 0)
|
|
1029
|
-
return a;
|
|
1030
|
-
const ra = ref(a, id()), rb = ref(b, id());
|
|
1031
|
-
const key = `+|${[ra, rb].sort().join('|')}`;
|
|
1032
|
-
return { ref: once(key, () => g.add(id(), ra, rb)) };
|
|
1033
|
-
};
|
|
1034
|
-
const scale = (a, k) => {
|
|
1035
|
-
if (isNum(a))
|
|
1036
|
-
return { n: a.n * k };
|
|
1037
|
-
if (k === 1)
|
|
1038
|
-
return a;
|
|
1039
|
-
const key = `*|${a.ref}|${round6(k)}`;
|
|
1040
|
-
return { ref: once(key, () => g.mul(id(), a.ref, g.constant(k, 'ratio', `occ.k${round6(k)}`))) };
|
|
1041
|
-
};
|
|
1042
|
-
const abs = (a) => {
|
|
1043
|
-
if (isNum(a))
|
|
1044
|
-
return { n: Math.abs(a.n) };
|
|
1045
|
-
const negated = ref(scale(a, -1), id());
|
|
1046
|
-
return { ref: once(`abs|${a.ref}`, () => g.node(id(), 'max@1', [a.ref, negated], 'value')) };
|
|
1047
|
-
};
|
|
1048
|
-
const sum = (vs) => vs.reduce(add, { n: 0 });
|
|
1049
|
-
/** |a − b| summed over the three axes: at least the straight-line distance between two places. */
|
|
1050
|
-
const spread = (a, b) => sum(a.map((v, i) => abs(add(v, scale(b[i], -1)))));
|
|
1051
|
-
const low = { x: [], y: [], z: [] };
|
|
1052
|
-
const high = { x: [], y: [], z: [] };
|
|
1053
|
-
/** The reach of a chain of joints, shared by every part that hangs from it. */
|
|
1054
|
-
const chainReach = new Map();
|
|
1055
|
-
/** Per chain: how far each body hanging from it reaches, so only the furthest is written. */
|
|
1056
|
-
const furthest = new Map();
|
|
1057
|
-
const originVals = (j) => jointOriginOf.get(j).origin.map(r => ({ ref: r }));
|
|
1058
|
-
const reachOfChain = (chain) => {
|
|
1059
|
-
const key = chain.join('/');
|
|
1060
|
-
const known = chainReach.get(key);
|
|
1061
|
-
if (known)
|
|
1062
|
-
return known;
|
|
1063
|
-
const below = chain.length > 1 ? reachOfChain(chain.slice(0, -1)) : { n: 0 };
|
|
1064
|
-
const step = chain.length > 1 ? spread(originVals(chain[chain.length - 2]), originVals(chain[chain.length - 1])) : { n: 0 };
|
|
1065
|
-
const travel = { n: jointOriginOf.get(chain[chain.length - 1]).slide };
|
|
1066
|
-
const out = sum([below, step, travel]);
|
|
1067
|
-
chainReach.set(key, out);
|
|
1068
|
-
return out;
|
|
1069
|
-
};
|
|
1070
|
-
for (const cp of parts) {
|
|
1071
|
-
const own = reachOf.get(cp.name);
|
|
1072
|
-
if (!own)
|
|
1073
|
-
continue; // a capability anchor or a refused part: nothing is drawn for it
|
|
1074
|
-
/*
|
|
1075
|
-
A body whose size follows a state input is measured at the widest that channel allows, from the channel's
|
|
1076
|
-
own keys, so the declared volume stands still while the figure moves (shape-dimension contract §2). A
|
|
1077
|
-
channel whose reach is not known is reported rather than guessed at.
|
|
1078
|
-
*/
|
|
1079
|
-
if (AXES.some(a => own.widest(a) === null || own.worldSpan(a) === null)) {
|
|
1080
|
-
refusals.push({
|
|
1081
|
-
code: 'OCCUPANCY_STATE_SIZED',
|
|
1082
|
-
part: cp.name,
|
|
1083
|
-
detail: `its size follows a state channel whose reach is not known, so the volume cannot be produced from it; a declared volume has to stand still while the figure moves`
|
|
1084
|
-
});
|
|
1085
|
-
continue;
|
|
1086
|
-
}
|
|
1087
|
-
const centre = AXES.map(a => ofLen(own.centre[a]));
|
|
1088
|
-
// Built only where it is used: a body needs either its own half-sides or its reach in any direction, not both.
|
|
1089
|
-
const halfCache = {};
|
|
1090
|
-
const half = (a) => {
|
|
1091
|
-
const span = own.worldSpan(a);
|
|
1092
|
-
return (halfCache[a] ??= scale(add(sum(span.terms.map(t => scale(ofLen(t.len), t.k))), { n: span.extraMm }), 0.5));
|
|
1093
|
-
};
|
|
1094
|
-
/** Half of all three sides together: at least the way from the centre to any corner, however it is turned. */
|
|
1095
|
-
let anyWayCache = null;
|
|
1096
|
-
const anyWay = () => (anyWayCache ??= scale(sum(AXES.map(a => ofLen(own.widest(a)))), 0.5));
|
|
1097
|
-
const chain = [];
|
|
1098
|
-
for (let j = carrierOf(cp.name); j; j = jointOriginOf.get(j)?.above) {
|
|
1099
|
-
if (chain.includes(j))
|
|
1100
|
-
break;
|
|
1101
|
-
chain.unshift(j);
|
|
1102
|
-
}
|
|
1103
|
-
if (!chain.length) {
|
|
1104
|
-
/*
|
|
1105
|
-
It stays in one frame. Turning where it sits is covered by its own reach in every direction; sliding is
|
|
1106
|
-
covered by an interval along the axis it slides on, which handles a negative direction and an
|
|
1107
|
-
asymmetric range without widening the other axes (designer's ruling 2026-09-23). Nothing above it
|
|
1108
|
-
rotates, so the axis it slides along is the axis it slides along at every pose.
|
|
1109
|
-
|
|
1110
|
-
There is no shortcut here for a part that looks like it stays inside. `neverPast` decides that, by
|
|
1111
|
-
reading the value rather than by trusting that position and shape scale together — keepRound, a fixed
|
|
1112
|
-
section and a repeat all break that, and an overhang tolerated at the authored size grows with it.
|
|
1113
|
-
*/
|
|
1114
|
-
const round = own.turns() ? anyWay() : null;
|
|
1115
|
-
const travelOn = (a) => {
|
|
1116
|
-
let lo = { n: 0 }, hi = { n: 0 };
|
|
1117
|
-
for (const sl of own.slides()) {
|
|
1118
|
-
if (sl.axis !== a)
|
|
1119
|
-
continue;
|
|
1120
|
-
/*
|
|
1121
|
-
Every world factor the converter writes is positive, so the interval keeps the travel's own order.
|
|
1122
|
-
The travel is a length, so it enters as millimetres; the factor is the ratio it is stretched by.
|
|
1123
|
-
*/
|
|
1124
|
-
const fac = sl.factor;
|
|
1125
|
-
const f = (v) => fac.kind === 'one'
|
|
1126
|
-
? { n: v }
|
|
1127
|
-
: { ref: once(`travel|${fac.ref}|${round6(v)}`, () => g.mul(id(), g.constant(round6(v), 'mm', `occ.travel${round6(v)}`), fac.ref)) };
|
|
1128
|
-
lo = add(lo, f(Math.min(sl.travel.min, sl.travel.max)));
|
|
1129
|
-
hi = add(hi, f(Math.max(sl.travel.min, sl.travel.max)));
|
|
1130
|
-
}
|
|
1131
|
-
return { lo, hi };
|
|
1132
|
-
};
|
|
1133
|
-
const affCentre = AXES.map(a => affOfLen(own.centre[a]));
|
|
1134
|
-
const affHalf = AXES.map(a => {
|
|
1135
|
-
const d = affOfLen(own.widest(a));
|
|
1136
|
-
return d && affScale(d, 0.5);
|
|
1137
|
-
});
|
|
1138
|
-
const affRound = own.turns()
|
|
1139
|
-
? affHalf.reduce((acc, h) => (acc && h ? affAdd(acc, h) : null), { base: 0, slope: { x: 0, y: 0, z: 0 } })
|
|
1140
|
-
: null;
|
|
1141
|
-
for (const [i, a] of AXES.entries()) {
|
|
1142
|
-
const rAff = own.turns() ? affRound : affHalf[i];
|
|
1143
|
-
const t = travelOn(a);
|
|
1144
|
-
const tAff = (v) => (isNum(v) ? { base: v.n, slope: { x: 0, y: 0, z: 0 } } : affineInSize(g, designInputs, v.ref));
|
|
1145
|
-
const cA = affCentre[i];
|
|
1146
|
-
const tLo = tAff(t.lo), tHi = tAff(t.hi);
|
|
1147
|
-
const loAff = cA && rAff && tLo ? affAdd(affAdd(cA, rAff, -1), tLo) : null;
|
|
1148
|
-
const hiAff = cA && rAff && tHi ? affAdd(affAdd(cA, rAff), tHi) : null;
|
|
1149
|
-
const reach = own.turns() ? round : half(a);
|
|
1150
|
-
if (!neverPast(loAff, a, 'min'))
|
|
1151
|
-
low[a].push(add(add(centre[i], scale(reach, -1)), t.lo));
|
|
1152
|
-
if (!neverPast(hiAff, a, 'max'))
|
|
1153
|
-
high[a].push(add(add(centre[i], reach), t.hi));
|
|
1154
|
-
}
|
|
1155
|
-
continue;
|
|
1156
|
-
}
|
|
1157
|
-
/*
|
|
1158
|
-
Every body on one chain swings about the same place, so the chain keeps the furthest of them and writes
|
|
1159
|
-
one pair of bounds. A pair for each body would say the same thing, only more of it.
|
|
1160
|
-
*/
|
|
1161
|
-
const radius = sum([reachOfChain(chain), spread(originVals(chain[chain.length - 1]), centre), anyWay()]);
|
|
1162
|
-
const key = chain.join('/');
|
|
1163
|
-
const held = furthest.get(key);
|
|
1164
|
-
furthest.set(key, held ? { chain, radius: [...held.radius, radius] } : { chain, radius: [radius] });
|
|
1165
|
-
notes.push({
|
|
1166
|
-
code: 'OCCUPANCY_FROM_STRUCTURE',
|
|
1167
|
-
part: cp.name,
|
|
1168
|
-
detail: `carried by ${chain.join(' → ')}, so it can be anywhere within that chain's reach of ${chain[0]}: the way out to each joint below it, any sliding travel they allow, and its own size. The declared volume holds that at every instance size, without measuring a pose`
|
|
1169
|
-
});
|
|
1170
|
-
}
|
|
1171
|
-
for (const { chain, radius } of furthest.values()) {
|
|
1172
|
-
const anchor = originVals(chain[0]);
|
|
1173
|
-
const worst = radius.every(isNum)
|
|
1174
|
-
? { n: Math.max(...radius.map(r => r.n)) }
|
|
1175
|
-
: radius.length === 1
|
|
1176
|
-
? radius[0]
|
|
1177
|
-
: { ref: g.node(id(), 'max@1', radius.map((r, i) => ref(r, `occ.r${i}`)), 'value') };
|
|
1178
|
-
for (const [i, a] of AXES.entries()) {
|
|
1179
|
-
low[a].push(add(anchor[i], scale(worst, -1)));
|
|
1180
|
-
high[a].push(add(anchor[i], worst));
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
if (!reachOf.size) {
|
|
1184
|
-
refusals.push({ code: 'OCCUPANCY_NO_BODIES', part: '*', detail: 'nothing is drawn, so there is no volume to declare' });
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
1187
|
-
for (const a of AXES) {
|
|
1188
|
-
if (low[a].length)
|
|
1189
|
-
asset.occupancy.bounds[a].min = g.node(`occupancy.${a}.min.held`, 'min@1', [authoredBounds[a].min, ...low[a].map((v, i) => ref(v, `occ.${a}.lo${i}`))], 'value');
|
|
1190
|
-
if (high[a].length)
|
|
1191
|
-
asset.occupancy.bounds[a].max = g.node(`occupancy.${a}.max.held`, 'max@1', [authoredBounds[a].max, ...high[a].map((v, i) => ref(v, `occ.${a}.hi${i}`))], 'value');
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
/** The subject the sizing rules read: `sizing` filled in like the blueprint does. */
|
|
1195
|
-
function withSizing(cp) {
|
|
1196
|
-
return { ...cp, sizing: cp.sizing ?? 'scale' };
|
|
1197
|
-
}
|
|
1198
|
-
const I3 = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
1199
|
-
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]));
|
|
1200
|
-
const mv3 = (a, v) => a.map(r => r[0] * v[0] + r[1] * v[1] + r[2] * v[2]);
|
|
1201
|
-
const tr3 = (a) => [0, 1, 2].map(i => [0, 1, 2].map(j => a[j][i]));
|
|
1202
|
-
/** Rotation about a unit axis by degrees (Rodrigues), the matrix three's setFromAxisAngle builds. */
|
|
1203
|
-
export function axisAngle(axis, deg) {
|
|
1204
|
-
const n = Math.hypot(axis.x, axis.y, axis.z);
|
|
1205
|
-
const [x, y, z] = [axis.x / n, axis.y / n, axis.z / n];
|
|
1206
|
-
const q = (deg * Math.PI) / 180, c = Math.cos(q), s = Math.sin(q), k = 1 - c;
|
|
1207
|
-
return [
|
|
1208
|
-
[c + x * x * k, x * y * k - z * s, x * z * k + y * s],
|
|
1209
|
-
[y * x * k + z * s, c + y * y * k, y * z * k - x * s],
|
|
1210
|
-
[z * x * k - y * s, z * y * k + x * s, c + z * z * k]
|
|
1211
|
-
];
|
|
1212
|
-
}
|
|
1213
|
-
/** V2's channel sampling (`things-scene` sampleChannel): hold the ends, `step` keeps the earlier key. */
|
|
1214
|
-
function sampleV2(channel, at) {
|
|
1215
|
-
const keys = channel.keys;
|
|
1216
|
-
if (at <= keys[0].at)
|
|
1217
|
-
return keys[0].value;
|
|
1218
|
-
const last = keys[keys.length - 1];
|
|
1219
|
-
if (at >= last.at)
|
|
1220
|
-
return last.value;
|
|
1221
|
-
let i = 0;
|
|
1222
|
-
while (i < keys.length - 2 && keys[i + 1].at <= at)
|
|
1223
|
-
i++;
|
|
1224
|
-
const from = keys[i], to = keys[i + 1];
|
|
1225
|
-
if (channel.interpolation === 'step')
|
|
1226
|
-
return from.value;
|
|
1227
|
-
const span = to.at - from.at;
|
|
1228
|
-
const r = span > 0 ? (at - from.at) / span : 0;
|
|
1229
|
-
if (typeof from.value === 'number')
|
|
1230
|
-
return (from.value + r * (to.value - from.value));
|
|
1231
|
-
const f = from.value, t = to.value;
|
|
1232
|
-
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) };
|
|
1233
|
-
}
|
|
1234
|
-
/** V2's clip length: the last key time across its channels, in seconds. */
|
|
1235
|
-
export function v2ClipDuration(clip) {
|
|
1236
|
-
let last = 0;
|
|
1237
|
-
for (const ch of clip.channels) {
|
|
1238
|
-
const end = ch.keys[ch.keys.length - 1]?.at;
|
|
1239
|
-
if (typeof end === 'number' && end > last)
|
|
1240
|
-
last = end;
|
|
1241
|
-
}
|
|
1242
|
-
return last;
|
|
1243
|
-
}
|
|
1244
|
-
/**
|
|
1245
|
-
* Where V2 draws every part (and copy) at an instance scale and a motion state, as oriented world boxes.
|
|
1246
|
-
* Motion follows things-scene: a translation channel shifts the part in the figure frame by the part's world
|
|
1247
|
-
* factor; a rotation channel turns it about its own centre in its own frame (R_part·R_ch·R_part⁻¹); a joint turns
|
|
1248
|
-
* everything in its frame about its sized origin, parents first (ADR-0066).
|
|
1249
|
-
*/
|
|
1250
|
-
export function v2WorldBoxes(source, scale, state = {}, options = {}) {
|
|
1251
|
-
const base = source.base;
|
|
1252
|
-
const out = [];
|
|
1253
|
-
const shifts = new Map();
|
|
1254
|
-
const scales = new Map();
|
|
1255
|
-
const turns = new Map();
|
|
1256
|
-
const jointValues = new Map();
|
|
1257
|
-
const rotationOf = new Map(source.parts.map(p => [p.name, eulerXYZ(p.transform.rotation)]));
|
|
1258
|
-
const apply = (channels, at) => {
|
|
1259
|
-
for (const ch of channels) {
|
|
1260
|
-
if (ch.path === undefined) {
|
|
1261
|
-
jointValues.set(ch.target, (jointValues.get(ch.target) ?? 0) + sampleV2(ch, at));
|
|
1262
|
-
continue;
|
|
1263
|
-
}
|
|
1264
|
-
const v = sampleV2(ch, at);
|
|
1265
|
-
if (ch.path === 'scale') {
|
|
1266
|
-
const s0 = scales.get(ch.target) ?? { x: 1, y: 1, z: 1 };
|
|
1267
|
-
scales.set(ch.target, { x: s0.x * v.x, y: s0.y * v.y, z: s0.z * v.z });
|
|
1268
|
-
}
|
|
1269
|
-
else if (ch.path === 'translation') {
|
|
1270
|
-
const s0 = shifts.get(ch.target) ?? { x: 0, y: 0, z: 0 };
|
|
1271
|
-
shifts.set(ch.target, { x: s0.x + v.x, y: s0.y + v.y, z: s0.z + v.z });
|
|
1272
|
-
}
|
|
1273
|
-
else if (ch.path === 'rotation') {
|
|
1274
|
-
const Rp = rotationOf.get(ch.target) ?? I3;
|
|
1275
|
-
const Rch = eulerXYZ(v);
|
|
1276
|
-
const wrapped = mm3(mm3(Rp, Rch), tr3(Rp));
|
|
1277
|
-
turns.set(ch.target, mm3(turns.get(ch.target) ?? I3, wrapped));
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
};
|
|
1281
|
-
for (const p of source.parameters ?? []) {
|
|
1282
|
-
const value = state.parameters?.[p.name] ?? p.default ?? p.range.min;
|
|
1283
|
-
const u = p.range.max > p.range.min ? (value - p.range.min) / (p.range.max - p.range.min) : 0;
|
|
1284
|
-
apply(p.clip.channels, u);
|
|
1285
|
-
}
|
|
1286
|
-
for (const clip of source.animations ?? []) {
|
|
1287
|
-
const duration = v2ClipDuration(clip);
|
|
1288
|
-
const t = state.clipTime?.[clip.name] ?? 0;
|
|
1289
|
-
apply(clip.channels, duration > 0 ? t % duration : 0);
|
|
1290
|
-
}
|
|
1291
|
-
// Joint frames: world transform per joint, parents first.
|
|
1292
|
-
const partsByName = new Map(source.parts.map(p => [p.name, p]));
|
|
1293
|
-
const jointByChild = new Map((source.joints ?? []).map(j => [j.child, j]));
|
|
1294
|
-
const carrierOf = (name) => {
|
|
1295
|
-
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
1296
|
-
const j = jointByChild.get(p.name);
|
|
1297
|
-
if (j)
|
|
1298
|
-
return j;
|
|
1299
|
-
}
|
|
1300
|
-
return undefined;
|
|
1301
|
-
};
|
|
1302
|
-
const worldOf = new Map();
|
|
1303
|
-
const worldJoint = (j) => {
|
|
1304
|
-
const known = worldOf.get(j.name);
|
|
1305
|
-
if (known)
|
|
1306
|
-
return known;
|
|
1307
|
-
const child = partsByName.get(j.child);
|
|
1308
|
-
const attach = child.parent ? partsByName.get(child.parent) : undefined;
|
|
1309
|
-
const cj = { origin: { ...j.origin, y: j.origin.y - base.y / 2 } };
|
|
1310
|
-
const at = jointOriginPosition(cj, attach ? withSizing(centredPart(attach, base)) : undefined, scale, base);
|
|
1311
|
-
const o = [at.x * scale.x, at.y * scale.y + (base.y * scale.y) / 2, at.z * scale.z];
|
|
1312
|
-
const limits = j.limits;
|
|
1313
|
-
let q = jointValues.get(j.name) ?? 0;
|
|
1314
|
-
if (limits)
|
|
1315
|
-
q = Math.min(Math.max(q, limits.min), limits.max);
|
|
1316
|
-
let local;
|
|
1317
|
-
if (j.type === 'prismatic') {
|
|
1318
|
-
const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
|
|
1319
|
-
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] };
|
|
1320
|
-
}
|
|
1321
|
-
else {
|
|
1322
|
-
const R = axisAngle(j.axis, q);
|
|
1323
|
-
const Ro = mv3(R, o);
|
|
1324
|
-
local = { r: R, t: [o[0] - Ro[0], o[1] - Ro[1], o[2] - Ro[2]] };
|
|
1325
|
-
}
|
|
1326
|
-
const parentJoint = attach ? carrierOf(attach.name) : undefined;
|
|
1327
|
-
const world = parentJoint ? composeRT(worldJoint(parentJoint), local) : local;
|
|
1328
|
-
worldOf.set(j.name, world);
|
|
1329
|
-
return world;
|
|
1330
|
-
};
|
|
1331
|
-
for (const part of source.parts) {
|
|
1332
|
-
// Capability anchors are not drawn by V2 (blueprint `anchors`, not `groups`).
|
|
1333
|
-
if (part.capability)
|
|
1334
|
-
continue;
|
|
1335
|
-
const cp = withSizing(centredPart(part, base));
|
|
1336
|
-
const at = sizingPosition(cp, scale, base);
|
|
1337
|
-
const sized = sizingScale(cp, scale, base);
|
|
1338
|
-
const drawnSize = { ...cp.transform.size };
|
|
1339
|
-
const extent = rotatedExtentOf(cp);
|
|
1340
|
-
let rotation = eulerXYZ(part.transform.rotation);
|
|
1341
|
-
const world = (a) => ({ centre: at[a] * scale[a], extent: extent[a] * sized[a] * scale[a], factor: sized[a] * scale[a] });
|
|
1342
|
-
const box = { x: world('x'), y: world('y'), z: world('z') };
|
|
1343
|
-
box.y.centre += (base.y * scale.y) / 2;
|
|
1344
|
-
// A polygon is drawn where its path says, which may be off the part centre: rotation baked, then scaled per world axis.
|
|
1345
|
-
let bodyPath = part.shape?.path;
|
|
1346
|
-
if (part.primitive === 'polygon' && part.shape?.path?.length) {
|
|
1347
|
-
const xs = part.shape.path.map(q => q.x), zs = part.shape.path.map(q => q.y);
|
|
1348
|
-
const c = [(Math.max(...xs) + Math.min(...xs)) / 2, 0, (Math.max(...zs) + Math.min(...zs)) / 2];
|
|
1349
|
-
for (const [a, axis] of AXES.entries())
|
|
1350
|
-
box[axis].centre += (rotation[a][0] * c[0] + rotation[a][2] * c[2]) * box[axis].factor;
|
|
1351
|
-
// The sampled body is centred on its drawn centre, so the path is taken about that centre.
|
|
1352
|
-
bodyPath = part.shape.path.map(q => ({ x: q.x - c[0], y: q.y - c[2] }));
|
|
1353
|
-
}
|
|
1354
|
-
// A scale channel multiplies the part's own dimensions about its centre (V2 pose.scale in the part frame).
|
|
1355
|
-
const chScale = scales.get(part.name) ?? { x: 1, y: 1, z: 1 };
|
|
1356
|
-
// Surface points: body in its own frame → channel scale → baked rotation → scaled per world axis → motion → centre.
|
|
1357
|
-
const localPoints = options.points
|
|
1358
|
-
? scalePoints(transformPoints(scalePoints(v2BodyPoints({ primitive: part.primitive, size: drawnSize, segments: part.segments ?? SEG_DEFAULT, round: part.shape?.round, hollow: part.shape?.hollow, path: bodyPath }), chScale), rotation, [0, 0, 0]), { x: box.x.factor, y: box.y.factor, z: box.z.factor })
|
|
1359
|
-
: null;
|
|
1360
|
-
// Local dimensions: each local axis lands on a world axis and takes that axis's factor, times any channel scale
|
|
1361
|
-
// (V2 applies pose.scale in the part's own frame, about its centre).
|
|
1362
|
-
/*
|
|
1363
|
-
Which world axis each local axis takes its factor from. A right-angle rotation permutes them. A rotation
|
|
1364
|
-
that only mixes axes carrying the same factor commutes with the scaling, so each local axis keeps its own
|
|
1365
|
-
(`scalingCommutesWithRotation`). Anything else is a real shear: the drawn body is not a box, so there are no
|
|
1366
|
-
local dimensions to report and the unscaled ones stand in. The surface points are sampled from the sheared
|
|
1367
|
-
body either way, so a shear is still caught by the mesh comparison.
|
|
1368
|
-
*/
|
|
1369
|
-
const perm = axisPermutation(part.transform.rotation) ??
|
|
1370
|
-
(scalingCommutesWithRotation(part.transform.rotation, (a, b) => Math.abs(box[a].factor - box[b].factor) <= 1e-9)
|
|
1371
|
-
? { x: 'x', y: 'y', z: 'z' }
|
|
1372
|
-
: null);
|
|
1373
|
-
const dims = [
|
|
1374
|
-
drawnSize.x * (perm ? box[perm.x].factor : 1) * chScale.x,
|
|
1375
|
-
drawnSize.y * (perm ? box[perm.y].factor : 1) * chScale.y,
|
|
1376
|
-
drawnSize.z * (perm ? box[perm.z].factor : 1) * chScale.z
|
|
1377
|
-
];
|
|
1378
|
-
// Motion on the part itself: shift in the figure frame by the world factor, turn about the centre.
|
|
1379
|
-
const shift = shifts.get(part.name);
|
|
1380
|
-
if (shift)
|
|
1381
|
-
for (const a of AXES)
|
|
1382
|
-
box[a].centre += shift[a] * box[a].factor;
|
|
1383
|
-
const turn = turns.get(part.name);
|
|
1384
|
-
let motionRotation = I3;
|
|
1385
|
-
if (turn) {
|
|
1386
|
-
rotation = mm3(turn, rotation);
|
|
1387
|
-
motionRotation = turn;
|
|
1388
|
-
}
|
|
1389
|
-
// Then the joint frame that carries it.
|
|
1390
|
-
const carrier = carrierOf(part.name);
|
|
1391
|
-
const W = carrier ? worldJoint(carrier) : undefined;
|
|
1392
|
-
const plan = repeatPlan(cp, scale, base);
|
|
1393
|
-
const copies = plan ? plan.count : 1;
|
|
1394
|
-
for (let i = 0; i < copies; i++) {
|
|
1395
|
-
let centre = { x: box.x.centre, y: box.y.centre, z: box.z.centre };
|
|
1396
|
-
if (plan)
|
|
1397
|
-
centre[plan.axis] += repeatOffset(i, plan.count, plan.pitch) * box[plan.axis].factor;
|
|
1398
|
-
let R = rotation;
|
|
1399
|
-
let M = motionRotation;
|
|
1400
|
-
if (W) {
|
|
1401
|
-
const c = mv3(W.r, [centre.x, centre.y, centre.z]);
|
|
1402
|
-
centre = { x: c[0] + W.t[0], y: c[1] + W.t[1], z: c[2] + W.t[2] };
|
|
1403
|
-
R = mm3(W.r, rotation);
|
|
1404
|
-
M = mm3(W.r, motionRotation);
|
|
1405
|
-
}
|
|
1406
|
-
// V2 hides a part by scaling it to nothing. Nothing is drawn there, so it is not a drawn body.
|
|
1407
|
-
if (dims.some(d => d <= 0))
|
|
1408
|
-
continue;
|
|
1409
|
-
const ext = AXES.map((_, a) => dims.reduce((sum, d, i) => sum + Math.abs(R[a][i]) * d, 0));
|
|
1410
|
-
const box2 = { part: part.name, centre, extent: { x: ext[0], y: ext[1], z: ext[2] }, dims, rotation: R };
|
|
1411
|
-
if (localPoints)
|
|
1412
|
-
box2.points = transformPoints(localPoints, M, [centre.x, centre.y, centre.z]);
|
|
1413
|
-
out.push(box2);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
return out;
|
|
1417
|
-
}
|
|
1418
|
-
function composeRT(a, b) {
|
|
1419
|
-
const rt = mv3(a.r, b.t);
|
|
1420
|
-
return { r: mm3(a.r, b.r), t: [rt[0] + a.t[0], rt[1] + a.t[1], rt[2] + a.t[2]] };
|
|
1421
|
-
}
|
|
1422
|
-
/** Where a converted V3 asset draws every part at an instance size, as world boxes. */
|
|
1423
|
-
export function v3WorldBoxes(asset, size, stateOverrides = {}, options = {}) {
|
|
1424
|
-
const inputs = { ...asset.designInputs, 'size.x': size.x, 'size.y': size.y, 'size.z': size.z };
|
|
1425
|
-
const evaluated = compileV3Asset({ ...asset, designInputs: inputs }).evaluate(stateOverrides);
|
|
1426
|
-
return v3WorldBoxesOf(evaluated.geometry, v3WritersOf(asset), options);
|
|
1427
|
-
}
|
|
1428
|
-
/**
|
|
1429
|
-
* Instance scales at which a repeated part's copy count changes, one just below and one just above each
|
|
1430
|
-
* boundary near the base count (completion criterion: repeat-count boundaries).
|
|
1431
|
-
*/
|
|
1432
|
-
export function repeatBoundaryScales(source) {
|
|
1433
|
-
const out = [];
|
|
1434
|
-
for (const part of source.parts) {
|
|
1435
|
-
if (part.sizing !== 'repeat' || !part.repeat)
|
|
1436
|
-
continue;
|
|
1437
|
-
const axis = part.repeat.axis, pitch = part.repeat.pitch, length = source.base[axis];
|
|
1438
|
-
const count = Math.floor(length / pitch);
|
|
1439
|
-
for (const k of [count, count + 1]) {
|
|
1440
|
-
for (const delta of [-0.5, 0.5]) {
|
|
1441
|
-
const L = k * pitch + delta;
|
|
1442
|
-
if (L <= 0)
|
|
1443
|
-
continue;
|
|
1444
|
-
out.push({ x: 1, y: 1, z: 1, [axis]: L / length });
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
return out;
|
|
1449
|
-
}
|
|
1450
|
-
/** ADR-0087 decision 2 tolerances. */
|
|
1451
|
-
/** The only slack a bound may have: what double arithmetic leaves behind, not a product tolerance. */
|
|
1452
|
-
const FLOAT_SLACK_MM = 1e-9;
|
|
1453
|
-
export const SAME_TOLERANCE_MM = 1;
|
|
1454
|
-
export const SAME_TOLERANCE_DEG = 0.1;
|
|
1455
|
-
/** The V3 state overrides that stand for a V2 motion state: parameters by name, drivers at their clip's time. */
|
|
1456
|
-
export function v3StateOverrides(asset, state) {
|
|
1457
|
-
const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]));
|
|
1458
|
-
const out = {};
|
|
1459
|
-
for (const [name, value] of Object.entries(state.parameters ?? {}))
|
|
1460
|
-
if (units.has(name))
|
|
1461
|
-
out[name] = value;
|
|
1462
|
-
for (const d of asset.drivers ?? []) {
|
|
1463
|
-
const t = state.clipTime?.[d.clip] ?? 0;
|
|
1464
|
-
const unit = units.get(d.state);
|
|
1465
|
-
out[d.state] = driverValue(d, t, unit === 'deg' || unit === 'rad' ? unit : 'other');
|
|
1466
|
-
}
|
|
1467
|
-
return out;
|
|
1468
|
-
}
|
|
1469
|
-
function rotationAngleDeg(a, b) {
|
|
1470
|
-
// The angle of a·bᵀ. Its trace is the sum of the element-wise products of a and b.
|
|
1471
|
-
let trace = 0;
|
|
1472
|
-
for (let i = 0; i < 3; i++)
|
|
1473
|
-
for (let k = 0; k < 3; k++)
|
|
1474
|
-
trace += a[i][k] * b[i][k];
|
|
1475
|
-
const c = Math.min(1, Math.max(-1, (trace - 1) / 2));
|
|
1476
|
-
return (Math.acos(c) * 180) / Math.PI;
|
|
1477
|
-
}
|
|
1478
|
-
/**
|
|
1479
|
-
* "The same" (ADR-0087 decision 2): the V2 source and the V3 asset draw the same parts, each with the same
|
|
1480
|
-
* centre and local dimensions within 1 mm and the same rotation within 0.1° (as a relative angle), with the same
|
|
1481
|
-
* material, at the base size and at every combination of doubled axes, at rest and at every motion state given.
|
|
1482
|
-
*/
|
|
1483
|
-
export function compareV2WithV3(source, asset, options = {}) {
|
|
1484
|
-
const opts = typeof options === 'number' ? { factor: options } : options;
|
|
1485
|
-
const factor = opts.factor ?? 2;
|
|
1486
|
-
const mesh = opts.mesh ?? true;
|
|
1487
|
-
const steps = opts.shrink ? [1, factor, 1 / factor] : [1, factor];
|
|
1488
|
-
const scales = [];
|
|
1489
|
-
for (const x of steps)
|
|
1490
|
-
for (const y of steps)
|
|
1491
|
-
for (const z of steps)
|
|
1492
|
-
scales.push({ x, y, z });
|
|
1493
|
-
scales.push(...(opts.extraScales ?? []));
|
|
1494
|
-
const states = [{ label: 'rest' }, ...(opts.states ?? [])];
|
|
1495
|
-
const differences = [];
|
|
1496
|
-
let boxesCompared = 0;
|
|
1497
|
-
const materials = new Map(asset.appearance.map(a => [a.target, a]));
|
|
1498
|
-
for (const part of source.parts) {
|
|
1499
|
-
if (part.capability)
|
|
1500
|
-
continue; // an anchor has no body on either side
|
|
1501
|
-
const look = materials.get(part.name);
|
|
1502
|
-
if (!look) {
|
|
1503
|
-
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'appearance', v2: JSON.stringify(part.material), v3: 'missing' });
|
|
1504
|
-
continue;
|
|
1505
|
-
}
|
|
1506
|
-
const expected = { token: part.material.token };
|
|
1507
|
-
if (part.material.preset !== undefined)
|
|
1508
|
-
expected.preset = part.material.preset;
|
|
1509
|
-
if (part.material.transparent !== undefined)
|
|
1510
|
-
expected.transparent = part.material.transparent;
|
|
1511
|
-
if (part.material.emissive)
|
|
1512
|
-
expected.emissive = { token: part.material.emissive.token ?? part.material.token, intensity: part.material.emissive.intensity, on: part.material.emissive.on ?? false };
|
|
1513
|
-
if (JSON.stringify(expected) !== JSON.stringify(look.material))
|
|
1514
|
-
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'material', v2: JSON.stringify(expected), v3: JSON.stringify(look.material) });
|
|
1515
|
-
if ((part.primitive === 'cylinder' || part.primitive === 'sphere') && look.segments !== (part.segments ?? SEG_DEFAULT))
|
|
1516
|
-
differences.push({ part: part.name, scale: scales[0], state: 'rest', what: 'segments', v2: String(part.segments ?? SEG_DEFAULT), v3: String(look.segments) });
|
|
1517
|
-
}
|
|
1518
|
-
const byPart = (boxes) => {
|
|
1519
|
-
const m = new Map();
|
|
1520
|
-
for (const b of boxes)
|
|
1521
|
-
(m.get(b.part) ?? m.set(b.part, []).get(b.part)).push(b);
|
|
1522
|
-
for (const list of m.values())
|
|
1523
|
-
list.sort((p, q) => p.centre.x - q.centre.x || p.centre.y - q.centre.y || p.centre.z - q.centre.z);
|
|
1524
|
-
return m;
|
|
1525
|
-
};
|
|
1526
|
-
for (const state of states) {
|
|
1527
|
-
const overrides = v3StateOverrides(asset, state);
|
|
1528
|
-
for (const scale of scales) {
|
|
1529
|
-
const size = { x: source.base.x * scale.x, y: source.base.y * scale.y, z: source.base.z * scale.z };
|
|
1530
|
-
const v2 = byPart(v2WorldBoxes(source, scale, state, { points: mesh }));
|
|
1531
|
-
let v3;
|
|
1532
|
-
try {
|
|
1533
|
-
v3 = byPart(v3WorldBoxes(asset, size, overrides, { points: mesh }));
|
|
1534
|
-
}
|
|
1535
|
-
catch (e) {
|
|
1536
|
-
// V3 refuses what V2 drew anyway (a collapsed span as a sliver, a joint value past its limit). Recorded, not thrown.
|
|
1537
|
-
const err = e;
|
|
1538
|
-
differences.push({ part: '*', scale, state: state.label, what: `refused:${err.code}`, v2: 'draws it', v3: err.message });
|
|
1539
|
-
continue;
|
|
1540
|
-
}
|
|
1541
|
-
for (const name of new Set([...v2.keys(), ...v3.keys()])) {
|
|
1542
|
-
const a = v2.get(name) ?? [], b = v3.get(name) ?? [];
|
|
1543
|
-
if (a.length !== b.length) {
|
|
1544
|
-
differences.push({ part: name, scale, state: state.label, what: 'copies', v2: String(a.length), v3: String(b.length) });
|
|
1545
|
-
continue;
|
|
1546
|
-
}
|
|
1547
|
-
for (const [i, p] of a.entries()) {
|
|
1548
|
-
const q = b[i];
|
|
1549
|
-
boxesCompared++;
|
|
1550
|
-
const tag = a.length > 1 ? `[${i}]` : '';
|
|
1551
|
-
for (const axis of AXES)
|
|
1552
|
-
if (Math.abs(p.centre[axis] - q.centre[axis]) > SAME_TOLERANCE_MM)
|
|
1553
|
-
differences.push({ part: name, scale, state: state.label, what: `centre.${axis}${tag}`, v2: p.centre[axis].toFixed(3), v3: q.centre[axis].toFixed(3) });
|
|
1554
|
-
for (const [i, d] of p.dims.entries())
|
|
1555
|
-
if (Math.abs(d - q.dims[i]) > SAME_TOLERANCE_MM)
|
|
1556
|
-
differences.push({ part: name, scale, state: state.label, what: `dims.${AXES[i]}${tag}`, v2: d.toFixed(3), v3: q.dims[i].toFixed(3) });
|
|
1557
|
-
const turn = rotationAngleDeg(p.rotation, q.rotation);
|
|
1558
|
-
if (turn > SAME_TOLERANCE_DEG)
|
|
1559
|
-
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` });
|
|
1560
|
-
if (mesh && p.points && q.points) {
|
|
1561
|
-
const gap = hausdorff(p.points, q.points);
|
|
1562
|
-
if (gap > SAME_TOLERANCE_MM)
|
|
1563
|
-
differences.push({ part: name, scale, state: state.label, what: `mesh${tag}`, v2: `${p.points.length} points`, v3: `surface ${gap.toFixed(3)} mm apart` });
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
return {
|
|
1570
|
-
same: differences.length === 0,
|
|
1571
|
-
scales,
|
|
1572
|
-
states: states.map(s => s.label),
|
|
1573
|
-
boxesCompared,
|
|
1574
|
-
sampling: { toleranceMm: SAME_TOLERANCE_MM, toleranceDeg: SAME_TOLERANCE_DEG, mesh, cornerDivisions: SEGMENT_PRESETS[0], sphereGrid: '6 rings × 12 around + poles', cylinderSegments: "the part's own segment count, both rims" },
|
|
1575
|
-
differences
|
|
1576
|
-
};
|
|
1577
|
-
}
|
|
1578
|
-
const V2_UNIT = { '%': 'percent', deg: 'deg', rad: 'rad', mm: 'mm', cm: 'cm', m: 'm', s: 's' };
|
|
1579
|
-
/**
|
|
1580
|
-
* Motion, per docs/v3-motion-contract.md. State inputs stand for V2 parameters (name, unit, range, default kept)
|
|
1581
|
-
* and for what clips drive; joints become `attach@1(T·turn·T⁻¹)` chains parents first; part channels become a
|
|
1582
|
-
* slide along an asset axis (times the part's world factor, as V2 shifted it) or a spin about the part's own axis.
|
|
1583
|
-
* What the contract does not cover is refused by name; a channel that moves nothing is noted and skipped.
|
|
1584
|
-
*/
|
|
1585
|
-
function planMotion(c) {
|
|
1586
|
-
const { src, g, base, size, info, spinOf, shiftsOf, scaleOf, scaleRangeOf, visibilityOf, jointOriginOf, lost, frameOf, motionOf, asset, refusals, notes, rangeChanges, ratio } = c;
|
|
1587
|
-
let reauthored = false;
|
|
1588
|
-
const params = src.parameters ?? [];
|
|
1589
|
-
const clips = src.animations ?? [];
|
|
1590
|
-
const joints = src.joints ?? [];
|
|
1591
|
-
if (!params.length && !clips.length && !joints.length)
|
|
1592
|
-
return { reauthored };
|
|
1593
|
-
const partsByName = new Map(src.parts.map(p => [p.name, p]));
|
|
1594
|
-
const jointsByName = new Map(joints.map(j => [j.name, j]));
|
|
1595
|
-
// State inputs for parameters.
|
|
1596
|
-
const paramUnit = new Map();
|
|
1597
|
-
for (const p of params) {
|
|
1598
|
-
const unit = V2_UNIT[p.range.unit];
|
|
1599
|
-
if (!unit) {
|
|
1600
|
-
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `unit "${p.range.unit}" has no V3 unit` });
|
|
1601
|
-
continue;
|
|
1602
|
-
}
|
|
1603
|
-
// V2 keeps parameters, parts and joints in separate name spaces; a V3 graph has one. The parameter keeps its
|
|
1604
|
-
// name (motion contract), so a clash is refused rather than renamed behind the author's back.
|
|
1605
|
-
if (partsByName.has(p.name) || jointsByName.has(p.name) && joints.some(j => j.name === p.name && j.child === p.name)) {
|
|
1606
|
-
refusals.push({ code: 'NAME_COLLISION', part: p.name, detail: `parameter "${p.name}" shares its name with a part; V3 state inputs and parts share one name space` });
|
|
1607
|
-
continue;
|
|
1608
|
-
}
|
|
1609
|
-
g.input(p.name, unit, p.range.min, p.range.max, 'state');
|
|
1610
|
-
asset.stateDefaults[p.name] = p.default ?? p.range.min;
|
|
1611
|
-
paramUnit.set(p.name, unit);
|
|
1612
|
-
/*
|
|
1613
|
-
What a person sees for this input, and how fast the figure travels to a new value for it. Both are kept
|
|
1614
|
-
(V3 designer's ruling 2026-09-22): the label is display metadata beside the stable id, and the sweep is
|
|
1615
|
-
the asset's own statement about its movement — a gripper that closes over 0.6 s must not close at once.
|
|
1616
|
-
*/
|
|
1617
|
-
const policy = {};
|
|
1618
|
-
if (p.label)
|
|
1619
|
-
policy.label = p.label;
|
|
1620
|
-
if (p.clip?.duration !== undefined && Number.isFinite(p.clip.duration) && p.clip.duration >= 0)
|
|
1621
|
-
policy.sweep = p.clip.duration;
|
|
1622
|
-
if (Object.keys(policy).length)
|
|
1623
|
-
(asset.stateInputs ??= {})[p.name] = policy;
|
|
1624
|
-
}
|
|
1625
|
-
/** q = v0 + (p - min)·(v1 - v0)/(max - min), written in the output unit. Null when the channel does not move. */
|
|
1626
|
-
const affine = (p, v0, v1, outUnit, hint) => {
|
|
1627
|
-
if (v0 === v1)
|
|
1628
|
-
return null;
|
|
1629
|
-
const pUnit = paramUnit.get(p.name);
|
|
1630
|
-
if (!pUnit)
|
|
1631
|
-
return undefined;
|
|
1632
|
-
const span = p.range.max - p.range.min;
|
|
1633
|
-
let slopeUnit;
|
|
1634
|
-
let slope;
|
|
1635
|
-
if (pUnit === outUnit) {
|
|
1636
|
-
slopeUnit = 'ratio';
|
|
1637
|
-
slope = (v1 - v0) / span;
|
|
1638
|
-
}
|
|
1639
|
-
else if (pUnit === 'percent') {
|
|
1640
|
-
// A percent input reaches the kernel as a ratio (×0.01), so the slope is per ratio.
|
|
1641
|
-
slopeUnit = outUnit;
|
|
1642
|
-
slope = ((v1 - v0) * 100) / span;
|
|
1643
|
-
}
|
|
1644
|
-
else {
|
|
1645
|
-
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot write a ${outUnit} value here` });
|
|
1646
|
-
return undefined;
|
|
1647
|
-
}
|
|
1648
|
-
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1649
|
-
return g.add(`${hint}.q`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), slopeUnit, `${hint}.slope`)), g.constant(round6(v0), outUnit, `${hint}.v0`));
|
|
1650
|
-
};
|
|
1651
|
-
/** A dimensionless factor f = s0 + (p - min)·(s1 - s0)/(max - min), for a scale channel. */
|
|
1652
|
-
const affineRatio = (p, s0, s1, hint) => {
|
|
1653
|
-
const pUnit = paramUnit.get(p.name);
|
|
1654
|
-
if (!pUnit)
|
|
1655
|
-
return undefined;
|
|
1656
|
-
const span = p.range.max - p.range.min;
|
|
1657
|
-
if (pUnit !== 'percent' && pUnit !== 'ratio') {
|
|
1658
|
-
// A slope in ratio-per-<unit> has no V3 unit; only dimensionless parameters may scale a dimension.
|
|
1659
|
-
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale a dimension; a percent or ratio parameter can` });
|
|
1660
|
-
return undefined;
|
|
1661
|
-
}
|
|
1662
|
-
const slope = pUnit === 'percent' ? ((s1 - s0) * 100) / span : (s1 - s0) / span;
|
|
1663
|
-
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1664
|
-
return g.add(`${hint}.f`, g.mul(`${hint}.scaled`, u, g.constant(round6(slope), 'ratio', `${hint}.slope`)), g.constant(round6(s0), 'ratio', `${hint}.s0`));
|
|
1665
|
-
};
|
|
1666
|
-
/**
|
|
1667
|
-
* A dimensionless factor through every key of a linear channel: f = curve(u, at₀,s₀, at₁,s₁, …) with u = (p − min)/span
|
|
1668
|
-
* as a ratio in [0, 1], the keys' `at` in the same ratio (piecewise-linear curves, V3 designer's approval
|
|
1669
|
-
* 2026-09-22). Two keys at 0 and 1 take the affine form instead, so earlier assets keep their graphs.
|
|
1670
|
-
*/
|
|
1671
|
-
/** The parameter mapped onto 0..1 as a ratio, the axis every key's `at` is measured on. */
|
|
1672
|
-
const unitPosition = (p, hint) => {
|
|
1673
|
-
const pUnit = paramUnit.get(p.name);
|
|
1674
|
-
if (!pUnit)
|
|
1675
|
-
return undefined;
|
|
1676
|
-
if (pUnit !== 'percent' && pUnit !== 'ratio') {
|
|
1677
|
-
refusals.push({ code: 'PARAMETER_UNIT', part: p.name, detail: `a ${pUnit} parameter cannot scale or hide a part; a percent or ratio parameter can` });
|
|
1678
|
-
return undefined;
|
|
1679
|
-
}
|
|
1680
|
-
const span = p.range.max - p.range.min;
|
|
1681
|
-
const slope = pUnit === 'percent' ? 100 / span : 1 / span;
|
|
1682
|
-
const u = g.add(`${hint}.u`, p.name, g.constant(round6(-p.range.min), pUnit, `${hint}.min`));
|
|
1683
|
-
return g.mul(`${hint}.u01`, u, g.constant(round6(slope), 'ratio', `${hint}.perSpan`));
|
|
1684
|
-
};
|
|
1685
|
-
const curveRatio = (p, keys, hint) => {
|
|
1686
|
-
const u01 = unitPosition(p, hint);
|
|
1687
|
-
if (u01 === undefined)
|
|
1688
|
-
return undefined;
|
|
1689
|
-
const args = [u01];
|
|
1690
|
-
keys.forEach((k, i) => args.push(g.constant(round6(k.at), 'ratio', `${hint}.at${i}`), g.constant(round6(k.value), 'ratio', `${hint}.s${i}`)));
|
|
1691
|
-
return g.node(`${hint}.curve`, 'curve@1', args, 'value');
|
|
1692
|
-
};
|
|
1693
|
-
/** A linear channel with keys in order inside [0, 1]; anything else is refused by name. */
|
|
1694
|
-
const linearKeys = (ch, owner) => {
|
|
1695
|
-
const keys = ch.keys;
|
|
1696
|
-
if ((ch.interpolation ?? 'linear') !== 'linear') {
|
|
1697
|
-
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: ${ch.interpolation} interpolation is not written into the graph; linear keys are` });
|
|
1698
|
-
return false;
|
|
1699
|
-
}
|
|
1700
|
-
if (keys.length < 2 || keys.some((k, i) => i > 0 && !(k.at > keys[i - 1].at)) || keys[0].at < 0 || keys[keys.length - 1].at > 1) {
|
|
1701
|
-
refusals.push({ code: 'PARAMETER_CURVE', part: owner, detail: `channel to ${ch.target}: keys must be two or more, strictly increasing, inside [0, 1]` });
|
|
1702
|
-
return false;
|
|
1703
|
-
}
|
|
1704
|
-
return true;
|
|
1705
|
-
};
|
|
1706
|
-
const twoKeyLinear = (ch, owner) => {
|
|
1707
|
-
const keys = ch.keys;
|
|
1708
|
-
if (keys.length !== 2 || keys[0].at !== 0 || keys[1].at !== 1 || (ch.interpolation ?? 'linear') !== 'linear') {
|
|
1709
|
-
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` });
|
|
1710
|
-
return false;
|
|
1711
|
-
}
|
|
1712
|
-
return true;
|
|
1713
|
-
};
|
|
1714
|
-
const varying = (keys) => AXES.filter(a => keys.some(k => k.value[a] !== keys[0].value[a]));
|
|
1715
|
-
// Which parameter drives each joint; refuse two writers.
|
|
1716
|
-
const jointWriter = new Map();
|
|
1717
|
-
const partWriters = new Map(); // `${part}/${path}/${axis}` → owners
|
|
1718
|
-
const claim = (key, owner, part) => {
|
|
1719
|
-
const owners = partWriters.get(key) ?? new Set();
|
|
1720
|
-
if (owners.size && !owners.has(owner)) {
|
|
1721
|
-
refusals.push({ code: 'MOTION_MULTI_WRITER', part, detail: `${[...owners].join(', ')} and ${owner} both write ${key}; one writer per state` });
|
|
1722
|
-
return false;
|
|
1723
|
-
}
|
|
1724
|
-
owners.add(owner);
|
|
1725
|
-
partWriters.set(key, owners);
|
|
1726
|
-
return true;
|
|
1727
|
-
};
|
|
1728
|
-
for (const p of params) {
|
|
1729
|
-
if (!paramUnit.has(p.name))
|
|
1730
|
-
continue;
|
|
1731
|
-
for (const ch of p.clip.channels) {
|
|
1732
|
-
if (ch.path === undefined) {
|
|
1733
|
-
if (!jointsByName.has(ch.target)) {
|
|
1734
|
-
refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a joint` });
|
|
1735
|
-
continue;
|
|
1736
|
-
}
|
|
1737
|
-
if (!twoKeyLinear(ch, p.name))
|
|
1738
|
-
continue;
|
|
1739
|
-
if (jointWriter.has(ch.target)) {
|
|
1740
|
-
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: `${jointWriter.get(ch.target).p.name} and ${p.name} both drive the joint` });
|
|
1741
|
-
continue;
|
|
1742
|
-
}
|
|
1743
|
-
const keys = ch.keys;
|
|
1744
|
-
jointWriter.set(ch.target, { p, v0: keys[0].value, v1: keys[1].value });
|
|
1745
|
-
continue;
|
|
1746
|
-
}
|
|
1747
|
-
const part = partsByName.get(ch.target);
|
|
1748
|
-
if (!part) {
|
|
1749
|
-
refusals.push({ code: 'PARAMETER_TARGET', part: p.name, detail: `channel targets "${ch.target}", which is not a part` });
|
|
1750
|
-
continue;
|
|
1751
|
-
}
|
|
1752
|
-
if (!info.has(ch.target))
|
|
1753
|
-
continue; // the part itself was refused above, and that refusal already names it
|
|
1754
|
-
if (ch.pivot) {
|
|
1755
|
-
refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered by the motion contract' });
|
|
1756
|
-
continue;
|
|
1757
|
-
}
|
|
1758
|
-
const keys = ch.keys;
|
|
1759
|
-
const axes = varying(keys);
|
|
1760
|
-
if (ch.path === 'scale') {
|
|
1761
|
-
// Shape-dimension contract §2: a state input may change a dimension. Each varying axis of the part gets a
|
|
1762
|
-
// ratio factor affine in the parameter; the part's centre does not move (V2 scaled about the centre).
|
|
1763
|
-
if (axes.length === 0) {
|
|
1764
|
-
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `parameter ${p.name} has a scale channel whose keys do not change; nothing to write` });
|
|
1765
|
-
continue;
|
|
1766
|
-
}
|
|
1767
|
-
if (!linearKeys(ch, p.name))
|
|
1768
|
-
continue;
|
|
1769
|
-
if (!axisPermutation(partsByName.get(ch.target).transform.rotation)) {
|
|
1770
|
-
refusals.push({ code: 'ROTATED_FOLLOWS', part: ch.target, detail: 'a scale channel on a part rotated off the axes would shear it; not covered' });
|
|
1771
|
-
continue;
|
|
1772
|
-
}
|
|
1773
|
-
/*
|
|
1774
|
-
A scale of 0 is how V2 hides a part: the lamp glow is scaled to nothing until the gate opens. A V3
|
|
1775
|
-
dimension stays positive (designer's ruling 2026-09-22), so this is not a size at all — it is
|
|
1776
|
-
visibility, and it becomes a flag. Covered shape: the channel starts at 0 on every varying axis and,
|
|
1777
|
-
from its first non-zero key on, sits at exactly 1 (the part's declared size) on all of them. The part
|
|
1778
|
-
then keeps its own dimensions and is shown from that key onwards.
|
|
1779
|
-
|
|
1780
|
-
Anything else — a channel that hides and also resizes — is refused by name. Writing it as a floored
|
|
1781
|
-
size would make "not visible" mean "very small", which is the thing that was refused.
|
|
1782
|
-
*/
|
|
1783
|
-
const hidesAt = keys.findIndex(k => axes.every(a => k.value[a] > 0));
|
|
1784
|
-
if (keys.some(k => axes.some(a => k.value[a] <= 0))) {
|
|
1785
|
-
const shaped = hidesAt > 0 &&
|
|
1786
|
-
keys.slice(0, hidesAt).every(k => axes.every(a => k.value[a] === 0)) &&
|
|
1787
|
-
keys.slice(hidesAt).every(k => axes.every(a => k.value[a] === 1));
|
|
1788
|
-
if (!shaped) {
|
|
1789
|
-
refusals.push({ code: 'SCALE_ZERO', part: ch.target, detail: `parameter ${p.name} scales ${ch.target} to zero and to sizes other than its own; hiding and resizing in one channel is not covered` });
|
|
1790
|
-
continue;
|
|
1791
|
-
}
|
|
1792
|
-
if (visibilityOf.has(ch.target)) {
|
|
1793
|
-
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: `two channels decide whether ${ch.target} is shown` });
|
|
1794
|
-
continue;
|
|
1795
|
-
}
|
|
1796
|
-
let claimed = true;
|
|
1797
|
-
for (const axis of axes)
|
|
1798
|
-
if (!claim(`${ch.target}/scale/${axis}`, p.name, ch.target)) {
|
|
1799
|
-
claimed = false;
|
|
1800
|
-
break;
|
|
1801
|
-
}
|
|
1802
|
-
if (!claimed)
|
|
1803
|
-
continue;
|
|
1804
|
-
const hint = `${p.name}.${ch.target}.shown`;
|
|
1805
|
-
const u01 = unitPosition(p, hint);
|
|
1806
|
-
if (u01 === undefined)
|
|
1807
|
-
continue;
|
|
1808
|
-
const at = keys[hidesAt].at;
|
|
1809
|
-
visibilityOf.set(ch.target, g.node(`${hint}.flag`, 'at-least@1', [u01, g.constant(round6(at), 'ratio', `${hint}.at`)], 'value'));
|
|
1810
|
-
reauthored = true;
|
|
1811
|
-
notes.push({
|
|
1812
|
-
code: 'RE_AUTHORED_VISIBILITY',
|
|
1813
|
-
part: ch.target,
|
|
1814
|
-
detail: `V2 hid ${ch.target} by scaling it to 0 and grew it to full size between ${keys[hidesAt - 1].at} and ${at} of parameter ${p.name}. V3 gives the part its own dimensions at all times and a visibility flag that turns on at ${at}. Identical to V2 below ${keys[hidesAt - 1].at} (hidden) and at ${at} and above (full size); inside that band V2 drew a growing part and V3 draws none. The growth was a way of spelling on and off, so it is re-authored, not floored to a small size`
|
|
1815
|
-
});
|
|
1816
|
-
continue;
|
|
1817
|
-
}
|
|
1818
|
-
const twoKeys = keys.length === 2 && keys[0].at === 0 && keys[1].at === 1;
|
|
1819
|
-
const entry = scaleOf.get(ch.target) ?? {};
|
|
1820
|
-
let ok = true;
|
|
1821
|
-
for (const axis of axes) {
|
|
1822
|
-
if (!claim(`${ch.target}/scale/${axis}`, p.name, ch.target)) {
|
|
1823
|
-
ok = false;
|
|
1824
|
-
break;
|
|
1825
|
-
}
|
|
1826
|
-
const hint = `${p.name}.${ch.target}.scale.${axis}`;
|
|
1827
|
-
const f = twoKeys
|
|
1828
|
-
? affineRatio(p, keys[0].value[axis], keys[1].value[axis], hint)
|
|
1829
|
-
: curveRatio(p, keys.map(k => ({ at: k.at, value: k.value[axis] })), hint);
|
|
1830
|
-
if (f === undefined) {
|
|
1831
|
-
ok = false;
|
|
1832
|
-
break;
|
|
1833
|
-
}
|
|
1834
|
-
entry[axis] = f;
|
|
1835
|
-
}
|
|
1836
|
-
if (!ok)
|
|
1837
|
-
continue;
|
|
1838
|
-
scaleOf.set(ch.target, entry);
|
|
1839
|
-
const ranges = scaleRangeOf.get(ch.target) ?? {};
|
|
1840
|
-
for (const axis of axes) {
|
|
1841
|
-
const vs = keys.map(k => k.value[axis]);
|
|
1842
|
-
ranges[axis] = { min: Math.min(...vs), max: Math.max(...vs) };
|
|
1843
|
-
}
|
|
1844
|
-
scaleRangeOf.set(ch.target, ranges);
|
|
1845
|
-
const shape = twoKeys ? `${keys[0].value[axes[0]]} → ${keys[1].value[axes[0]]} on ${axes[0]}` : `${keys.length} keys on ${axes[0]}: ${keys.map(k => `${k.at}:${k.value[axes[0]]}`).join(' ')}`;
|
|
1846
|
-
notes.push({ code: 'STATE_DIMENSION', part: ch.target, detail: `dimensions on ${axes.join(', ')} follow parameter ${p.name} (${shape}); the occupancy must hold over its range` });
|
|
1847
|
-
continue;
|
|
1848
|
-
}
|
|
1849
|
-
if (axes.length === 0) {
|
|
1850
|
-
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` });
|
|
1851
|
-
continue;
|
|
1852
|
-
}
|
|
1853
|
-
if (axes.length > 1) {
|
|
1854
|
-
refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
|
|
1855
|
-
continue;
|
|
1856
|
-
}
|
|
1857
|
-
if (!twoKeyLinear(ch, p.name))
|
|
1858
|
-
continue;
|
|
1859
|
-
const axis = axes[0];
|
|
1860
|
-
if (!claim(`${ch.target}/${ch.path}/${axis}`, p.name, ch.target))
|
|
1861
|
-
continue;
|
|
1862
|
-
const q = affine(p, keys[0].value[axis], keys[1].value[axis], ch.path === 'rotation' ? 'deg' : 'mm', `${p.name}.${ch.target}.${ch.path}`);
|
|
1863
|
-
if (q === undefined)
|
|
1864
|
-
continue;
|
|
1865
|
-
if (q === null)
|
|
1866
|
-
continue;
|
|
1867
|
-
if (ch.path === 'rotation') {
|
|
1868
|
-
if (spinOf.has(ch.target)) {
|
|
1869
|
-
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
|
|
1870
|
-
continue;
|
|
1871
|
-
}
|
|
1872
|
-
spinOf.set(ch.target, { axis, q });
|
|
1873
|
-
}
|
|
1874
|
-
else {
|
|
1875
|
-
// V2 shifts a part in the figure frame by the part's world factor along that axis.
|
|
1876
|
-
const f = info.get(ch.target).factor[axis];
|
|
1877
|
-
const scaled = f.kind === 'one' ? q : g.mul(`${p.name}.${ch.target}.shift.${axis}`, q, f.ref);
|
|
1878
|
-
const ends = [keys[0].value[axis], keys[1].value[axis]];
|
|
1879
|
-
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled, travel: { min: Math.min(...ends), max: Math.max(...ends) } });
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
// Clips: a driver per channel, writing a state input of its own.
|
|
1884
|
-
const drivers = [];
|
|
1885
|
-
for (const clip of clips) {
|
|
1886
|
-
const duration = v2ClipDuration(clip);
|
|
1887
|
-
if (!(duration > 0)) {
|
|
1888
|
-
notes.push({ code: 'MOTION_STATIC_CLIP', detail: `animation "${clip.name}" has no duration; nothing to write` });
|
|
1889
|
-
continue;
|
|
1890
|
-
}
|
|
1891
|
-
for (const ch of clip.channels) {
|
|
1892
|
-
const owner = `clip ${clip.name}`;
|
|
1893
|
-
if (ch.path === undefined) {
|
|
1894
|
-
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} drives a joint directly; joints are driven by parameters in this contract` });
|
|
1895
|
-
continue;
|
|
1896
|
-
}
|
|
1897
|
-
if (!partsByName.has(ch.target)) {
|
|
1898
|
-
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} targets a part that does not exist` });
|
|
1899
|
-
continue;
|
|
1900
|
-
}
|
|
1901
|
-
if (!info.has(ch.target))
|
|
1902
|
-
continue; // refused above under its own name
|
|
1903
|
-
if (ch.path === 'scale') {
|
|
1904
|
-
refusals.push({ code: 'ANIMATION_CHANNEL', part: ch.target, detail: `${owner} scales the part; a scale channel is shape deformation, not covered` });
|
|
1905
|
-
continue;
|
|
1906
|
-
}
|
|
1907
|
-
if (ch.pivot) {
|
|
1908
|
-
refusals.push({ code: 'MOTION_PIVOT', part: ch.target, detail: 'a rotation pivot away from the part centre is not covered' });
|
|
1909
|
-
continue;
|
|
1910
|
-
}
|
|
1911
|
-
const keys = ch.keys;
|
|
1912
|
-
const axes = varying(keys);
|
|
1913
|
-
if (axes.length === 0) {
|
|
1914
|
-
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: ch.target, detail: `${owner} has a ${ch.path} channel whose keys do not change; nothing to write` });
|
|
1915
|
-
continue;
|
|
1916
|
-
}
|
|
1917
|
-
if (axes.length > 1) {
|
|
1918
|
-
refusals.push({ code: 'MOTION_COMPOUND_CHANNEL', part: ch.target, detail: `${ch.path} varies on ${axes.join(', ')} at once; one axis per channel is covered` });
|
|
1919
|
-
continue;
|
|
1920
|
-
}
|
|
1921
|
-
const axis = axes[0];
|
|
1922
|
-
if (!claim(`${ch.target}/${ch.path}/${axis}`, owner, ch.target))
|
|
1923
|
-
continue;
|
|
1924
|
-
const values = keys.map(k => k.value[axis]);
|
|
1925
|
-
const rotation = ch.path === 'rotation';
|
|
1926
|
-
const stateId = rotation ? `${ch.target}.spin` : `${ch.target}.shift.${axis}`;
|
|
1927
|
-
const min = Math.min(...values), max = Math.max(...values);
|
|
1928
|
-
g.input(stateId, rotation ? 'deg' : 'mm', rotation ? -180 : min, rotation ? 180 : max, 'state');
|
|
1929
|
-
asset.stateDefaults[stateId] = rotation ? normalise180(values[0]) : values[0];
|
|
1930
|
-
drivers.push({
|
|
1931
|
-
id: `${clip.name}/${ch.target}/${ch.path}`,
|
|
1932
|
-
clip: clip.name,
|
|
1933
|
-
state: stateId,
|
|
1934
|
-
time: { unit: 's', duration },
|
|
1935
|
-
keys: keys.map(k => ({ at: round6(k.at / duration), value: k.value[axis] })),
|
|
1936
|
-
interpolation: ch.interpolation ?? 'linear',
|
|
1937
|
-
loop: 'wrap',
|
|
1938
|
-
accumulates: rotation
|
|
1939
|
-
});
|
|
1940
|
-
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` });
|
|
1941
|
-
if (rotation) {
|
|
1942
|
-
if (spinOf.has(ch.target)) {
|
|
1943
|
-
refusals.push({ code: 'MOTION_MULTI_WRITER', part: ch.target, detail: 'two rotation channels on one part' });
|
|
1944
|
-
continue;
|
|
1945
|
-
}
|
|
1946
|
-
spinOf.set(ch.target, { axis, q: stateId });
|
|
1947
|
-
}
|
|
1948
|
-
else {
|
|
1949
|
-
const f = info.get(ch.target).factor[axis];
|
|
1950
|
-
const scaled = f.kind === 'one' ? stateId : g.mul(`${clip.name}.${ch.target}.shift.${axis}`, stateId, f.ref);
|
|
1951
|
-
(shiftsOf.get(ch.target) ?? shiftsOf.set(ch.target, []).get(ch.target)).push({ axis, q: scaled, travel: { min, max } });
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
if (drivers.length)
|
|
1956
|
-
asset.drivers = drivers;
|
|
1957
|
-
// Joints, parents first.
|
|
1958
|
-
const carrierOf = (name) => {
|
|
1959
|
-
for (let p = partsByName.get(name); p; p = p.parent ? partsByName.get(p.parent) : undefined) {
|
|
1960
|
-
const j = joints.find(x => x.child === p.name);
|
|
1961
|
-
if (j)
|
|
1962
|
-
return j;
|
|
1963
|
-
}
|
|
1964
|
-
return undefined;
|
|
1965
|
-
};
|
|
1966
|
-
const depth = (j) => {
|
|
1967
|
-
const child = partsByName.get(j.child);
|
|
1968
|
-
const above = child?.parent ? carrierOf(child.parent) : undefined;
|
|
1969
|
-
return above ? 1 + depth(above) : 0;
|
|
1970
|
-
};
|
|
1971
|
-
for (const j of [...joints].sort((a, b) => depth(a) - depth(b))) {
|
|
1972
|
-
const child = partsByName.get(j.child);
|
|
1973
|
-
if (!child || !info.has(j.child)) {
|
|
1974
|
-
refusals.push({ code: 'JOINT_CHILD', part: j.name, detail: `child "${j.child}" was not converted` });
|
|
1975
|
-
continue;
|
|
1976
|
-
}
|
|
1977
|
-
if (info.get(j.child).repeat) {
|
|
1978
|
-
refusals.push({ code: 'MOTION_REPEAT', part: j.child, detail: 'a repeated part cannot be a joint child in this contract' });
|
|
1979
|
-
continue;
|
|
1980
|
-
}
|
|
1981
|
-
// The state input: the driving parameter's affine, or the joint's own input when nothing drives it.
|
|
1982
|
-
let q;
|
|
1983
|
-
const writer = jointWriter.get(j.name);
|
|
1984
|
-
const limits = j.limits;
|
|
1985
|
-
if (writer) {
|
|
1986
|
-
const r = affine(writer.p, writer.v0, writer.v1, j.type === 'prismatic' ? 'mm' : 'deg', `${writer.p.name}.${j.name}`);
|
|
1987
|
-
if (r === undefined)
|
|
1988
|
-
continue;
|
|
1989
|
-
if (r === null) {
|
|
1990
|
-
notes.push({ code: 'MOTION_STATIC_CHANNEL', part: j.name, detail: `parameter ${writer.p.name} does not change the joint; it stays at 0` });
|
|
1991
|
-
q = g.constant(0, j.type === 'prismatic' ? 'mm' : 'deg', 'zeroQ');
|
|
1992
|
-
}
|
|
1993
|
-
else
|
|
1994
|
-
q = r;
|
|
1995
|
-
}
|
|
1996
|
-
else {
|
|
1997
|
-
const [min, max] = limits ? [limits.min, limits.max] : j.type === 'continuous' ? [-180, 180] : [-1e6, 1e6];
|
|
1998
|
-
q = g.input(j.name, j.type === 'prismatic' ? 'mm' : 'deg', min, max, 'state');
|
|
1999
|
-
asset.stateDefaults[j.name] = 0;
|
|
2000
|
-
}
|
|
2001
|
-
if (limits && !writer)
|
|
2002
|
-
rangeChanges.push({ input: j.name, part: j.name, v2: `any value; outside [${limits.min}, ${limits.max}] V2 clamped it to the limit`, v3: { min: limits.min, max: limits.max }, reason: 'V3 refuses a joint value outside its limits (INPUT_RANGE) instead of clamping' });
|
|
2003
|
-
if (limits && writer)
|
|
2004
|
-
notes.push({ code: 'JOINT_LIMIT', part: j.name, detail: `the joint's limits [${limits.min}, ${limits.max}] are carried by the parameter ${writer.p.name}'s own range; V3 refuses a value outside it where V2 clamped` });
|
|
2005
|
-
if (j.type === 'prismatic')
|
|
2006
|
-
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' });
|
|
2007
|
-
// Sized origin: the carrying part's centre plus the rest offset scaled by that part's world factor (jointOriginPosition).
|
|
2008
|
-
const attach = child.parent ? partsByName.get(child.parent) : undefined;
|
|
2009
|
-
const originC = { ...j.origin, y: j.origin.y - base.y / 2 };
|
|
2010
|
-
const originRef = [];
|
|
2011
|
-
for (const a of AXES) {
|
|
2012
|
-
let expr;
|
|
2013
|
-
if (attach && info.has(attach.name)) {
|
|
2014
|
-
const ai = info.get(attach.name);
|
|
2015
|
-
const ac = centredPart(attach, base).transform.position;
|
|
2016
|
-
const offset = c.times(`${j.name}.origin.${a}.offset`, { kind: 'const', value: round6(originC[a] - ac[a]) }, ai.factor[a]);
|
|
2017
|
-
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`)) };
|
|
2018
|
-
}
|
|
2019
|
-
else {
|
|
2020
|
-
// No carrying part: only the instance scale moves the origin (V2 keeps the rest position).
|
|
2021
|
-
const v = a === 'y' ? originC.y + base.y / 2 : originC[a];
|
|
2022
|
-
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`)) };
|
|
2023
|
-
}
|
|
2024
|
-
originRef.push(g.len(expr, `${j.name}.origin.${a}`));
|
|
2025
|
-
}
|
|
2026
|
-
const n = Math.hypot(j.axis.x, j.axis.y, j.axis.z);
|
|
2027
|
-
// Full precision: the kernel accepts a unit axis to 1e-6 and normalises, but the converter does not round it.
|
|
2028
|
-
const axisRefs = AXES.map(a => g.constant(j.axis[a] / n, 'ratio', `${j.name}.axis.${a}`));
|
|
2029
|
-
const zeroDeg = AXES.map(() => g.constant(0, 'deg', 'deg.0'));
|
|
2030
|
-
const onParent = `${j.name}.onParent`, onChild = `${j.name}.onChild`;
|
|
2031
|
-
const parentJoint = attach ? carrierOf(attach.name) : undefined;
|
|
2032
|
-
jointOriginOf.set(j.name, {
|
|
2033
|
-
origin: [...originRef],
|
|
2034
|
-
...(parentJoint ? { above: parentJoint.name } : {}),
|
|
2035
|
-
slide: j.type === 'prismatic' ? Math.max(Math.abs(limits?.min ?? 0), Math.abs(limits?.max ?? 0)) : 0
|
|
2036
|
-
});
|
|
2037
|
-
const restFrame = 'asset.rest';
|
|
2038
|
-
const toFrame = parentJoint ? restFrame : 'asset';
|
|
2039
|
-
const A = g.node(`${j.name}.frameOnParent`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onParent, to: toFrame });
|
|
2040
|
-
const B = g.node(`${j.name}.frameOnChild`, 'rigid@1', [...originRef, ...zeroDeg], 'pose', { from: onChild, to: restFrame });
|
|
2041
|
-
const O = g.node(`${j.name}.motion`, j.type === 'prismatic' ? 'axis-slide@1' : 'axis-turn@1', [...axisRefs, q], 'pose', { from: onChild, to: onParent });
|
|
2042
|
-
let M = g.node(`${j.name}.attach`, 'attach@1', [A, O, B], 'pose');
|
|
2043
|
-
if (parentJoint)
|
|
2044
|
-
M = g.node(`${j.name}.chain`, 'compose@1', [motionOf.get(parentJoint.name), M], 'pose');
|
|
2045
|
-
motionOf.set(j.name, M);
|
|
2046
|
-
}
|
|
2047
|
-
return { reauthored };
|
|
2048
|
-
}
|
|
2049
|
-
function normalise180(deg) {
|
|
2050
|
-
const w = ((((deg + 180) % 360) + 360) % 360) - 180;
|
|
2051
|
-
return w === 180 ? -180 : w;
|
|
2052
|
-
}
|
|
2053
|
-
//# sourceMappingURL=v3-from-v2.js.map
|