@hatiolab/figure-model 0.1.37 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +131 -2
- package/dist/v3-authoring-actions.d.ts.map +1 -1
- package/dist/v3-authoring-actions.js +501 -73
- package/dist/v3-authoring-actions.js.map +1 -1
- package/dist/v3-cost.d.ts +14 -0
- package/dist/v3-cost.d.ts.map +1 -0
- package/dist/v3-cost.js +69 -0
- package/dist/v3-cost.js.map +1 -0
- package/dist/v3-json.d.ts +0 -1
- package/dist/v3-json.d.ts.map +1 -1
- package/dist/v3-json.js +16 -1
- package/dist/v3-json.js.map +1 -1
- package/dist/v3-kernel-version.d.ts +1 -1
- package/dist/v3-kernel-version.js +1 -1
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* parent carries the child with it. A part that is fastened to nothing poses straight into the asset frame.
|
|
23
23
|
*/
|
|
24
24
|
import { compileV3Asset } from "./v3-asset.js";
|
|
25
|
+
import { compileV3Graph } from "./v3-graph.js";
|
|
25
26
|
import { V3ContractError } from "./v3-contract.js";
|
|
26
27
|
import { AXES } from "./types.js";
|
|
27
28
|
const fail = (code, path, message) => {
|
|
@@ -54,15 +55,22 @@ function sizeRefsOf(m, id, path) {
|
|
|
54
55
|
fail('EDIT_TARGET', path, `${id} is a ${shape.op}; a face of it is not an axis-aligned plane this command can measure`);
|
|
55
56
|
return { x: shape.args[0], y: shape.args[1], z: shape.args[2] };
|
|
56
57
|
}
|
|
58
|
+
/** The angle a pose argument holds, whether it is a constant or a design input, or null when it is neither. */
|
|
59
|
+
function angleOf(m, asset, ref) {
|
|
60
|
+
const constant = m.constants.find((c) => c.id === ref);
|
|
61
|
+
if (constant)
|
|
62
|
+
return constant.value;
|
|
63
|
+
const input = m.inputs.find((i) => i.id === ref);
|
|
64
|
+
if (input && input.role !== 'state')
|
|
65
|
+
return asset.designInputs[ref] ?? null;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
57
68
|
/** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
|
|
58
69
|
function requireUnturned(m, asset, id, path) {
|
|
59
70
|
const { pose } = partOf(m, id, path);
|
|
60
|
-
for (const ref of pose.args.slice(3, 6))
|
|
61
|
-
|
|
62
|
-
const value = constant ? constant.value : asset.designInputs[ref];
|
|
63
|
-
if (value === undefined || value !== 0)
|
|
71
|
+
for (const ref of pose.args.slice(3, 6))
|
|
72
|
+
if (angleOf(m, asset, ref) !== 0)
|
|
64
73
|
fail('EDIT_TARGET', path, `${id} is turned; fastening a turned part by its faces is not covered by this command`);
|
|
65
|
-
}
|
|
66
74
|
}
|
|
67
75
|
/** Fresh node ids under a part, so two commands never claim one id. */
|
|
68
76
|
function namer(m, prefix) {
|
|
@@ -100,6 +108,37 @@ function scaled(m, name, ref, times, plus, hint) {
|
|
|
100
108
|
}
|
|
101
109
|
return out;
|
|
102
110
|
}
|
|
111
|
+
/** A measure as one graph reference: a constant, or the source it follows times a multiple plus a margin. */
|
|
112
|
+
function measureRef(m, asset, name, measure, hint, path) {
|
|
113
|
+
if (measure === undefined)
|
|
114
|
+
return constantOf(m, 'mm', 0, 'd');
|
|
115
|
+
if (typeof measure === 'number') {
|
|
116
|
+
if (!Number.isFinite(measure))
|
|
117
|
+
fail('SCHEMA', path, 'a finite length is required');
|
|
118
|
+
return constantOf(m, 'mm', measure, 'd');
|
|
119
|
+
}
|
|
120
|
+
if (!measure || typeof measure !== 'object' || !measure.from)
|
|
121
|
+
fail('SCHEMA', path, 'a length is a number of millimetres, or something to follow');
|
|
122
|
+
return scaled(m, name, sourceRef(m, asset, measure.from, path), measure.times ?? 1, measure.plus ?? 0, hint);
|
|
123
|
+
}
|
|
124
|
+
/** Σ k·ref, written as nodes, with nothing emitted for a weight of zero and no node for a single term. */
|
|
125
|
+
function sumOf(m, name, terms, hint) {
|
|
126
|
+
const live = terms.filter(t => t.k !== 0);
|
|
127
|
+
if (!live.length)
|
|
128
|
+
return constantOf(m, 'mm', 0, 'd');
|
|
129
|
+
let out = '';
|
|
130
|
+
for (const [i, t] of live.entries()) {
|
|
131
|
+
const piece = scaled(m, name, t.ref, t.k, 0, `${hint}.${i}`);
|
|
132
|
+
if (!out)
|
|
133
|
+
out = piece;
|
|
134
|
+
else {
|
|
135
|
+
const id = name(`${hint}.${i}.sum`);
|
|
136
|
+
m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
|
|
137
|
+
out = `${id}.value`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
103
142
|
function sourceRef(m, asset, source, path) {
|
|
104
143
|
if (!source || typeof source !== 'object')
|
|
105
144
|
fail('SCHEMA', path, 'a dimension source is required');
|
|
@@ -118,56 +157,91 @@ function sourceRef(m, asset, source, path) {
|
|
|
118
157
|
return source.input;
|
|
119
158
|
}
|
|
120
159
|
if (source.of === 'part')
|
|
121
|
-
return
|
|
160
|
+
return stableDimension(m, source.part, source.axis, path);
|
|
122
161
|
return fail('SCHEMA', path, `${String(source.of)} is not a dimension source`);
|
|
123
162
|
}
|
|
124
163
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
164
|
+
* The name of a part's dimension, which stays the same name however that dimension is later worked out.
|
|
165
|
+
*
|
|
166
|
+
* "B is as wide as A" has to keep meaning that when A is told to follow something else. Reading A's shape
|
|
167
|
+
* argument at the moment of linking gave B whatever A happened to read then — A's own control — so relinking A
|
|
168
|
+
* left B on the old value (V3 designer's counterexample 2026-09-23: A went to 300 and B stayed at 100). Every
|
|
169
|
+
* dimension a command touches gets a node of its own, `<part>.dimension.<axis>`, and that is what the shape
|
|
170
|
+
* reads and what anything following it reads. Relinking rewrites what that node computes, and everyone pointing
|
|
171
|
+
* at it comes along — fastenings included, since they measure the same references.
|
|
128
172
|
*/
|
|
129
|
-
|
|
173
|
+
function stableDimension(m, part, axis, path) {
|
|
174
|
+
const shape = partOf(m, part, path).shape;
|
|
175
|
+
const i = AXES.indexOf(axis);
|
|
176
|
+
const id = `${part}.dimension.${axis}`;
|
|
177
|
+
const ref = `${id}.value`;
|
|
178
|
+
if (nodeById(m, id))
|
|
179
|
+
return ref;
|
|
180
|
+
const one = constantOf(m, 'ratio', 1, 'k');
|
|
181
|
+
m.nodes.push({ id, op: 'mul@1', args: [shape.args[i], one], outputs: { value: ref } });
|
|
182
|
+
shape.args = shape.args.map((r, k) => (k === i ? ref : r));
|
|
183
|
+
return ref;
|
|
184
|
+
}
|
|
185
|
+
/** What a part's dimension is worked out from right now: the first argument of its own dimension node. */
|
|
186
|
+
const dimensionSource = (m, part, axis) => nodeById(m, `${part}.dimension.${axis}`)?.args?.[0];
|
|
187
|
+
/** Whether this side of the part already follows something, rather than reading its own control. */
|
|
188
|
+
const followsAlready = (m, part, axis) => {
|
|
189
|
+
const from = dimensionSource(m, part, axis);
|
|
190
|
+
return from !== undefined && from !== `${part}.size.${axis}`;
|
|
191
|
+
};
|
|
130
192
|
function linkDimension(asset, a) {
|
|
131
193
|
const m = asset.document.model;
|
|
132
194
|
const path = `${a.part}.${a.axis}`;
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
if (followsAlready(a.part, a.axis, current) && !a.replace)
|
|
195
|
+
const mine = stableDimension(m, a.part, a.axis, path);
|
|
196
|
+
if (followsAlready(m, a.part, a.axis) && !a.replace)
|
|
136
197
|
fail('DIMENSION_LINKED', path, `${a.part}'s ${a.axis} already follows something; pass replace to change what it follows, or unlink it first`);
|
|
137
198
|
const times = a.times ?? 1;
|
|
138
199
|
const plus = a.plus ?? 0;
|
|
139
200
|
if (!Number.isFinite(times) || !Number.isFinite(plus))
|
|
140
201
|
fail('SCHEMA', path, 'finite times and plus required');
|
|
202
|
+
if (a.source.of === 'part' && a.source.part === a.part && a.source.axis === a.axis)
|
|
203
|
+
fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
|
|
141
204
|
const from = sourceRef(m, asset, a.source, path);
|
|
142
|
-
if (from ===
|
|
205
|
+
if (from === mine)
|
|
143
206
|
fail('DIMENSION_CYCLE', path, `${a.part}'s ${a.axis} cannot follow itself`);
|
|
207
|
+
if (reaches(m, from, mine))
|
|
208
|
+
fail('DIMENSION_CYCLE', path, `${String(a.source.of === 'part' ? a.source.part : a.source.of)} already follows ${a.part}'s ${a.axis}`);
|
|
144
209
|
const name = namer(m, `${a.part}.${a.axis}`);
|
|
145
210
|
const value = scaled(m, name, from, times, plus, 'follows');
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
dropIfUnused(asset, current, `${a.part}.size.${a.axis}`);
|
|
211
|
+
const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
|
|
212
|
+
const was = node.args[0];
|
|
213
|
+
node.args = [value, node.args[1]];
|
|
214
|
+
// The part's own control is no longer read by anything; leaving it would show a dial that changes nothing.
|
|
215
|
+
dropIfUnused(asset, was, `${a.part}.size.${a.axis}`);
|
|
152
216
|
return asset;
|
|
153
217
|
}
|
|
218
|
+
/** Whether one value is worked out from another, so a link cannot be made to close a loop. */
|
|
219
|
+
function reaches(m, from, target, seen = new Set()) {
|
|
220
|
+
if (from === target)
|
|
221
|
+
return true;
|
|
222
|
+
if (seen.has(from))
|
|
223
|
+
return false;
|
|
224
|
+
seen.add(from);
|
|
225
|
+
const writer = writerOf(m, from);
|
|
226
|
+
return !!writer && writer.args.some((ref) => reaches(m, ref, target, seen));
|
|
227
|
+
}
|
|
154
228
|
function unlinkDimension(asset, a) {
|
|
155
229
|
const m = asset.document.model;
|
|
156
230
|
const path = `${a.part}.${a.axis}`;
|
|
157
231
|
if (!(Number.isFinite(a.mm) && a.mm > 0))
|
|
158
232
|
fail('GEOMETRY_DOMAIN', path, 'a positive length is required');
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (!followsAlready(a.part, a.axis, current))
|
|
233
|
+
stableDimension(m, a.part, a.axis, path);
|
|
234
|
+
if (!followsAlready(m, a.part, a.axis))
|
|
162
235
|
fail('EDIT_TARGET', path, `${a.part}'s ${a.axis} does not follow anything`);
|
|
163
236
|
const id = `${a.part}.size.${a.axis}`;
|
|
164
237
|
if (m.inputs.some((i) => i.id === id))
|
|
165
238
|
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
166
239
|
m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
167
240
|
asset.designInputs[id] = a.mm;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
241
|
+
const node = nodeById(m, `${a.part}.dimension.${a.axis}`);
|
|
242
|
+
const was = node.args[0];
|
|
243
|
+
node.args = [id, node.args[1]];
|
|
244
|
+
dropIfUnused(asset, was);
|
|
171
245
|
return asset;
|
|
172
246
|
}
|
|
173
247
|
/**
|
|
@@ -207,75 +281,100 @@ function parentOf(m, id) {
|
|
|
207
281
|
}
|
|
208
282
|
function attach(asset, a) {
|
|
209
283
|
const m = asset.document.model;
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
284
|
+
const to = a.to;
|
|
285
|
+
const path = `${a.part} to ${to?.plane ?? to?.part}`;
|
|
286
|
+
if (!to || typeof to !== 'object')
|
|
287
|
+
fail('SCHEMA', path, 'a target face, or the plane the figure is mounted on, is required');
|
|
288
|
+
if (to.plane !== undefined)
|
|
289
|
+
return standOnPlane(asset, a, path);
|
|
290
|
+
if (a.part === to.part)
|
|
214
291
|
fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
|
|
215
|
-
for (const face of [a.face,
|
|
292
|
+
for (const face of [a.face, to.face])
|
|
216
293
|
if (!Object.hasOwn(V3_FACES, String(face)))
|
|
217
294
|
fail('SCHEMA', path, `${String(face)} is not a face`);
|
|
218
295
|
const facing = a.facing ?? 'meet';
|
|
219
296
|
if (facing !== 'meet' && facing !== 'flush')
|
|
220
297
|
fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
|
|
221
|
-
const
|
|
222
|
-
if (!Number.isFinite(gap))
|
|
223
|
-
fail('SCHEMA', path, 'a finite gap is required');
|
|
224
|
-
const axis = V3_FACES[a.to.face];
|
|
298
|
+
const axis = V3_FACES[to.face];
|
|
225
299
|
if (V3_FACES[a.face] !== axis)
|
|
226
|
-
fail('ATTACH_FACE', path, `${a.face} faces along ${V3_FACES[a.face]} and ${
|
|
227
|
-
if (facing === 'meet' && a.face !== OPPOSITE[
|
|
228
|
-
fail('ATTACH_FACE', path, `to meet ${
|
|
229
|
-
if (facing === 'flush' && a.face !==
|
|
230
|
-
fail('ATTACH_FACE', path, `to lie flush with ${
|
|
300
|
+
fail('ATTACH_FACE', path, `${a.face} faces along ${V3_FACES[a.face]} and ${to.face} along ${axis}; fastening them would need a turn, which this command does not do`);
|
|
301
|
+
if (facing === 'meet' && a.face !== OPPOSITE[to.face])
|
|
302
|
+
fail('ATTACH_FACE', path, `to meet ${to.face}, ${a.part} offers its ${OPPOSITE[to.face]}; pass flush to lay ${a.face} in the same plane instead`);
|
|
303
|
+
if (facing === 'flush' && a.face !== to.face)
|
|
304
|
+
fail('ATTACH_FACE', path, `to lie flush with ${to.face}, ${a.part} offers its ${to.face}`);
|
|
231
305
|
if (parentOf(m, a.part) && !a.replace)
|
|
232
306
|
fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
|
|
233
307
|
// A part cannot end up its own ancestor.
|
|
234
|
-
for (let up =
|
|
308
|
+
for (let up = to.part; up; up = parentOf(m, up))
|
|
235
309
|
if (up === a.part)
|
|
236
|
-
fail('ATTACH_CYCLE', path, `${
|
|
310
|
+
fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
|
|
237
311
|
const mine = sizeRefsOf(m, a.part, path);
|
|
238
|
-
const theirs = sizeRefsOf(m,
|
|
312
|
+
const theirs = sizeRefsOf(m, to.part, path);
|
|
239
313
|
requireUnturned(m, asset, a.part, path);
|
|
240
|
-
requireUnturned(m, asset,
|
|
241
|
-
const name = namer(m, `${a.part}.on.${
|
|
242
|
-
const
|
|
243
|
-
const sign = SIGN[a.to.face];
|
|
314
|
+
requireUnturned(m, asset, to.part, path);
|
|
315
|
+
const name = namer(m, `${a.part}.on.${to.part}`);
|
|
316
|
+
const sign = SIGN[to.face];
|
|
244
317
|
/*
|
|
245
318
|
Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
|
|
246
319
|
gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
|
|
247
320
|
*/
|
|
248
|
-
const reach = facing === 'meet' ? sign * (gap + 0) : -sign * 0;
|
|
249
|
-
void reach;
|
|
250
|
-
const ownHalf = half(mine[axis], `half.${axis}`);
|
|
251
|
-
const theirHalf = half(theirs[axis], `their.half.${axis}`);
|
|
252
321
|
const outward = facing === 'meet' ? sign : -sign;
|
|
253
|
-
const
|
|
254
|
-
m.nodes.push({ id: alongId, op: 'add@1', args: [scaled(m, name, theirHalf, sign, 0, `face.${axis}`), scaled(m, name, ownHalf, outward, gap * outward, `stand.${axis}`)], outputs: { value: `${alongId}.value` } });
|
|
322
|
+
const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
|
|
255
323
|
const translation = { x: '', y: '', z: '' };
|
|
256
|
-
translation[axis] =
|
|
324
|
+
translation[axis] = sumOf(m, name, [
|
|
325
|
+
{ ref: theirs[axis], k: sign * 0.5 },
|
|
326
|
+
{ ref: mine[axis], k: outward * 0.5 },
|
|
327
|
+
{ ref: gap, k: outward }
|
|
328
|
+
], `along.${axis}`);
|
|
257
329
|
// Across the face. Centred by default; min and max keep the two parts' edges level as either is resized.
|
|
258
330
|
for (const other of AXES.filter(x => x !== axis)) {
|
|
259
331
|
const how = a.align?.[other] ?? 'centre';
|
|
260
332
|
if (how === 'centre')
|
|
261
333
|
translation[other] = constantOf(m, 'mm', 0, 'd');
|
|
262
|
-
else if (typeof how === 'object' && how &&
|
|
263
|
-
translation[other] =
|
|
334
|
+
else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
|
|
335
|
+
translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
|
|
264
336
|
else if (how === 'min' || how === 'max') {
|
|
265
337
|
const s = how === 'min' ? -1 : 1;
|
|
266
|
-
|
|
267
|
-
m.nodes.push({
|
|
268
|
-
id,
|
|
269
|
-
op: 'add@1',
|
|
270
|
-
args: [scaled(m, name, half(theirs[other], `their.half.${other}`), s, 0, `edge.${other}`), scaled(m, name, half(mine[other], `half.${other}`), -s, 0, `inset.${other}`)],
|
|
271
|
-
outputs: { value: `${id}.value` }
|
|
272
|
-
});
|
|
273
|
-
translation[other] = `${id}.value`;
|
|
338
|
+
translation[other] = sumOf(m, name, [{ ref: theirs[other], k: s * 0.5 }, { ref: mine[other], k: -s * 0.5 }], `align.${other}`);
|
|
274
339
|
}
|
|
275
340
|
else
|
|
276
341
|
fail('SCHEMA', path, `${String(how)} is not an alignment`);
|
|
277
342
|
}
|
|
278
|
-
reseat(asset, a.part, `${
|
|
343
|
+
reseat(asset, a.part, `${to.part}.local`, [translation.x, translation.y, translation.z]);
|
|
344
|
+
return asset;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Standing a part on the plane the figure is mounted on. The plane has no width and no depth, so there is
|
|
348
|
+
* nothing to line the part's edges up with: across the plane, only the centre or a measured offset.
|
|
349
|
+
*/
|
|
350
|
+
function standOnPlane(asset, a, path) {
|
|
351
|
+
const m = asset.document.model;
|
|
352
|
+
const to = a.to;
|
|
353
|
+
if (to.plane !== 'mounting-plane')
|
|
354
|
+
fail('SCHEMA', path, `${String(to.plane)} is not a plane a part can stand on`);
|
|
355
|
+
if (a.facing !== undefined && a.facing !== 'meet')
|
|
356
|
+
fail('SCHEMA', path, 'a part standing on the plane meets it; flush says nothing here');
|
|
357
|
+
if (a.face !== 'bottom' && a.face !== 'top')
|
|
358
|
+
fail('ATTACH_FACE', path, `${String(a.face)} faces along ${V3_FACES[a.face] ?? '?'}; the plane faces along y, so a part stands on it by its bottom or hangs from it by its top`);
|
|
359
|
+
if (parentOf(m, a.part) && !a.replace)
|
|
360
|
+
fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to stand it on the plane instead`);
|
|
361
|
+
const mine = sizeRefsOf(m, a.part, path);
|
|
362
|
+
requireUnturned(m, asset, a.part, path);
|
|
363
|
+
const name = namer(m, `${a.part}.on.plane`);
|
|
364
|
+
const outward = a.face === 'bottom' ? 1 : -1;
|
|
365
|
+
const gap = measureRef(m, asset, name, a.gap, 'gap.y', path);
|
|
366
|
+
const translation = { x: '', y: '', z: '' };
|
|
367
|
+
translation.y = sumOf(m, name, [{ ref: mine.y, k: outward * 0.5 }, { ref: gap, k: outward }], 'stand.y');
|
|
368
|
+
for (const other of ['x', 'z']) {
|
|
369
|
+
const how = a.align?.[other] ?? 'centre';
|
|
370
|
+
if (how === 'centre')
|
|
371
|
+
translation[other] = constantOf(m, 'mm', 0, 'd');
|
|
372
|
+
else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
|
|
373
|
+
translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
|
|
374
|
+
else
|
|
375
|
+
fail('SCHEMA', path, `the plane has no edges to line ${a.part} up with on ${other}; give the centre or a measured offset`);
|
|
376
|
+
}
|
|
377
|
+
reseat(asset, a.part, asset.document.capabilities.assetFrame, [translation.x, translation.y, translation.z]);
|
|
279
378
|
return asset;
|
|
280
379
|
}
|
|
281
380
|
function detach(asset, a) {
|
|
@@ -284,13 +383,36 @@ function detach(asset, a) {
|
|
|
284
383
|
const parent = parentOf(m, a.part);
|
|
285
384
|
if (!parent)
|
|
286
385
|
fail('EDIT_TARGET', path, `${a.part} is not fastened to anything`);
|
|
287
|
-
|
|
386
|
+
/*
|
|
387
|
+
Detaching keeps the part where it stands, and keeps it moving the way it moved. Taking the world pose and
|
|
388
|
+
writing it in as the part's own pose did neither: the part's motion was already in that pose and was then
|
|
389
|
+
applied a second time, so a part with 20 mm of travel jumped to 40 (V3 designer's counterexample
|
|
390
|
+
2026-09-23).
|
|
391
|
+
|
|
392
|
+
What the part's pose has to gain is exactly what the parent's chain was contributing — and that is a plain
|
|
393
|
+
translation only while nothing above the part is turned. Where something is, the two cannot be separated
|
|
394
|
+
into a translation the pose can absorb, and this refuses rather than moving the part.
|
|
395
|
+
*/
|
|
396
|
+
for (let up = parent; up; up = parentOf(m, up))
|
|
397
|
+
requireUnturned(m, asset, up, path);
|
|
288
398
|
const evaluated = compileV3Asset(asset).evaluate();
|
|
289
|
-
const
|
|
290
|
-
if (!
|
|
291
|
-
fail('TARGET_ABSENT', path, `${
|
|
292
|
-
const
|
|
293
|
-
|
|
399
|
+
const above = evaluated.values?.[nodeById(m, `${parent}.world`)?.outputs?.pose ?? nodeById(m, `${parent}.chain`)?.outputs?.pose ?? nodeById(m, `${parent}.pose`).outputs.pose];
|
|
400
|
+
if (!above || !above.t)
|
|
401
|
+
fail('TARGET_ABSENT', path, `${parent} has no pose to read`);
|
|
402
|
+
const turned = above.r.some((row, i) => row.some((v, j) => Math.abs(v - (i === j ? 1 : 0)) > 1e-9));
|
|
403
|
+
if (turned)
|
|
404
|
+
fail('EDIT_TARGET', path, `${parent} is turned; taking ${a.part} off it would move it, and this command does not move a part`);
|
|
405
|
+
const { pose } = partOf(m, a.part, path);
|
|
406
|
+
const own = evaluated.values?.[pose.outputs.pose];
|
|
407
|
+
if (!own || !own.t)
|
|
408
|
+
fail('TARGET_ABSENT', path, `${a.part} has no pose to read`);
|
|
409
|
+
/*
|
|
410
|
+
Where it stands, as plain numbers: what the parent was contributing plus what the part's own pose was. Its
|
|
411
|
+
motion is left out, because the motion node stays and would otherwise be counted twice. Numbers rather than
|
|
412
|
+
references, because a part that is no longer fastened to the plate should not still grow with it.
|
|
413
|
+
*/
|
|
414
|
+
const translation = AXES.map((_axis, i) => constantOf(m, 'mm', round6(above.t[i] + own.t[i]), 'd'));
|
|
415
|
+
reseat(asset, a.part, asset.document.capabilities.assetFrame, translation);
|
|
294
416
|
return asset;
|
|
295
417
|
}
|
|
296
418
|
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
@@ -301,8 +423,9 @@ const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
|
301
423
|
function reseat(asset, id, to, translation) {
|
|
302
424
|
const m = asset.document.model;
|
|
303
425
|
const { place, pose } = partOf(m, id, id);
|
|
304
|
-
const old = pose.args.slice(0, 3);
|
|
305
|
-
|
|
426
|
+
const old = translation ? pose.args.slice(0, 3) : [];
|
|
427
|
+
if (translation)
|
|
428
|
+
pose.args = [...translation, ...pose.args.slice(3)];
|
|
306
429
|
pose.params = { ...pose.params, to: `${id}.seat` };
|
|
307
430
|
const motion = nodeById(m, `${id}.motion`);
|
|
308
431
|
if (motion)
|
|
@@ -366,6 +489,21 @@ export function applyV3Authoring(source, action) {
|
|
|
366
489
|
case 'detach':
|
|
367
490
|
detach(asset, action);
|
|
368
491
|
break;
|
|
492
|
+
case 'add-motion':
|
|
493
|
+
addMotion(asset, action);
|
|
494
|
+
break;
|
|
495
|
+
case 'remove-motion':
|
|
496
|
+
removeMotion(asset, action);
|
|
497
|
+
break;
|
|
498
|
+
case 'declare-occupancy':
|
|
499
|
+
declareOccupancy(asset, action);
|
|
500
|
+
break;
|
|
501
|
+
case 'set-mounting-face':
|
|
502
|
+
setMountingFace(asset, action);
|
|
503
|
+
break;
|
|
504
|
+
case 'clear-mounting-face':
|
|
505
|
+
clearMountingFace(asset);
|
|
506
|
+
break;
|
|
369
507
|
default:
|
|
370
508
|
fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
|
|
371
509
|
}
|
|
@@ -381,4 +519,294 @@ export function v3AttachmentsOf(asset) {
|
|
|
381
519
|
out[n.id] = parentOf(m, n.id);
|
|
382
520
|
return out;
|
|
383
521
|
}
|
|
522
|
+
/** The frame a part is fastened into: its parent's, or the asset's. */
|
|
523
|
+
const attachmentFrameOf = (asset, id) => {
|
|
524
|
+
const parent = parentOf(asset.document.model, id);
|
|
525
|
+
return parent ? `${parent}.local` : asset.document.capabilities.assetFrame;
|
|
526
|
+
};
|
|
527
|
+
function addMotion(asset, a) {
|
|
528
|
+
const m = asset.document.model;
|
|
529
|
+
const path = a.part;
|
|
530
|
+
partOf(m, a.part, path);
|
|
531
|
+
if (!a.motion || (a.motion.kind !== 'slide' && a.motion.kind !== 'turn'))
|
|
532
|
+
fail('SCHEMA', path, 'a slide or a turn is required');
|
|
533
|
+
if (!AXES.includes(a.motion.axis))
|
|
534
|
+
fail('SCHEMA', path, `${String(a.motion.axis)} is not an axis`);
|
|
535
|
+
if (nodeById(m, `${a.part}.motion`) && !a.replace)
|
|
536
|
+
fail('MOTION_REPLACED', path, `${a.part} already moves; pass replace to change how, or remove the motion first`);
|
|
537
|
+
if (nodeById(m, `${a.part}.motion`))
|
|
538
|
+
removeMotion(asset, { kind: 'remove-motion', part: a.part });
|
|
539
|
+
const frame = a.motion.frame ?? 'attachment';
|
|
540
|
+
const seatedIn = attachmentFrameOf(asset, a.part);
|
|
541
|
+
if (frame === 'asset' && seatedIn !== asset.document.capabilities.assetFrame)
|
|
542
|
+
fail('MOTION_FRAME', path, `${a.part} is fastened into ${seatedIn}; an axis in the asset frame would have to be re-expressed there, which this command does not do`);
|
|
543
|
+
else if (frame !== 'attachment' && frame !== 'asset')
|
|
544
|
+
fail('SCHEMA', path, `${String(frame)} is not a frame for the axis`);
|
|
545
|
+
const st = a.state;
|
|
546
|
+
if (!st || typeof st.id !== 'string' || !st.id.trim())
|
|
547
|
+
fail('SCHEMA', path, 'a state input id is required');
|
|
548
|
+
if (m.inputs.some((i) => i.id === st.id))
|
|
549
|
+
fail('DUPLICATE_WRITER', path, `${st.id} already exists`);
|
|
550
|
+
const wanted = a.motion.kind === 'turn' ? ['deg'] : ['mm', 'ratio'];
|
|
551
|
+
if (!wanted.includes(st.unit))
|
|
552
|
+
fail('SCHEMA', path, `a ${a.motion.kind} takes a ${wanted.join(' or ')} control, not ${String(st.unit)}`);
|
|
553
|
+
if (!(Number.isFinite(st.min) && Number.isFinite(st.max) && st.max > st.min))
|
|
554
|
+
fail('SCHEMA', path, 'a range with max above min is required');
|
|
555
|
+
const start = st.start ?? st.min;
|
|
556
|
+
if (!(start >= st.min && start <= st.max))
|
|
557
|
+
fail('SCHEMA', path, 'the starting value is outside the range');
|
|
558
|
+
m.inputs.push({ id: st.id, unit: st.unit, min: st.min, max: st.max, role: 'state' });
|
|
559
|
+
asset.stateDefaults[st.id] = start;
|
|
560
|
+
if (st.label !== undefined || st.sweep !== undefined) {
|
|
561
|
+
asset.stateInputs = { ...(asset.stateInputs ?? {}) };
|
|
562
|
+
asset.stateInputs[st.id] = { ...(st.label !== undefined ? { label: st.label } : {}), ...(st.sweep !== undefined ? { sweep: st.sweep } : {}) };
|
|
563
|
+
}
|
|
564
|
+
const name = namer(m, `${a.part}.motion`);
|
|
565
|
+
let quantity = st.id;
|
|
566
|
+
if (a.motion.kind === 'slide' && st.unit === 'ratio') {
|
|
567
|
+
if (!a.travel)
|
|
568
|
+
fail('SCHEMA', path, 'a ratio control needs travel: how far the part goes at 1');
|
|
569
|
+
const travel = a.travel;
|
|
570
|
+
const base = travel.source ? sourceRef(m, asset, travel.source, path) : constantOf(m, 'mm', travel.plus ?? 0, 'd');
|
|
571
|
+
const length = travel.source ? scaled(m, name, base, travel.times ?? 1, travel.plus ?? 0, 'travel') : base;
|
|
572
|
+
const id = name('distance');
|
|
573
|
+
m.nodes.push({ id, op: 'mul@1', args: [st.id, length], outputs: { value: `${id}.value` } });
|
|
574
|
+
quantity = `${id}.value`;
|
|
575
|
+
}
|
|
576
|
+
else if (a.motion.kind === 'slide' && a.travel)
|
|
577
|
+
fail('SCHEMA', path, 'a mm control is the distance; travel would say it twice');
|
|
578
|
+
const unit = AXES.map(x => constantOf(m, 'ratio', x === a.motion.axis ? 1 : 0, 'axis'));
|
|
579
|
+
m.nodes.push({
|
|
580
|
+
id: `${a.part}.motion`,
|
|
581
|
+
op: a.motion.kind === 'slide' ? 'axis-slide@1' : 'axis-turn@1',
|
|
582
|
+
args: [...unit, quantity],
|
|
583
|
+
outputs: { pose: `${a.part}.motion.value` },
|
|
584
|
+
params: { from: `${a.part}.seat`, to: seatedIn }
|
|
585
|
+
});
|
|
586
|
+
reseat(asset, a.part, seatedIn);
|
|
587
|
+
if (a.clip) {
|
|
588
|
+
const c = a.clip;
|
|
589
|
+
if (typeof c.name !== 'string' || !c.name.trim())
|
|
590
|
+
fail('SCHEMA', path, 'a clip is named');
|
|
591
|
+
if (!(Number.isFinite(c.duration) && c.duration > 0))
|
|
592
|
+
fail('SCHEMA', path, 'a clip runs for a positive number of seconds');
|
|
593
|
+
asset.drivers = [...(asset.drivers ?? []), { id: `${a.part}/${c.name}`, clip: c.name, state: st.id, time: { unit: 's', duration: c.duration }, keys: structuredClone(c.keys), interpolation: 'linear', loop: c.loop ?? 'wrap', accumulates: c.accumulates ?? false }];
|
|
594
|
+
}
|
|
595
|
+
return asset;
|
|
596
|
+
}
|
|
597
|
+
/** "This part does not move any more." The control it created goes with it, and so does its clip. */
|
|
598
|
+
function removeMotion(asset, a) {
|
|
599
|
+
const m = asset.document.model;
|
|
600
|
+
const motion = nodeById(m, `${a.part}.motion`);
|
|
601
|
+
if (!motion)
|
|
602
|
+
fail('EDIT_TARGET', a.part, `${a.part} does not move`);
|
|
603
|
+
const quantity = motion.args[3];
|
|
604
|
+
const to = motion.params.to;
|
|
605
|
+
// Which control this motion introduced, read before the nodes that name it are taken away.
|
|
606
|
+
const state = m.inputs.find((i) => i.id === quantity && i.role === 'state')
|
|
607
|
+
?? m.inputs.find((i) => i.role === 'state' && (writerOf(m, quantity)?.args ?? []).includes(i.id));
|
|
608
|
+
m.nodes = m.nodes.filter((n) => n !== motion);
|
|
609
|
+
reseat(asset, a.part, to);
|
|
610
|
+
dropIfUnused(asset, quantity, quantity);
|
|
611
|
+
if (state && !m.nodes.some((n) => n.args.includes(state.id))) {
|
|
612
|
+
m.inputs = m.inputs.filter((i) => i !== state);
|
|
613
|
+
delete asset.stateDefaults[state.id];
|
|
614
|
+
if (asset.stateInputs)
|
|
615
|
+
delete asset.stateInputs[state.id];
|
|
616
|
+
if (asset.drivers)
|
|
617
|
+
asset.drivers = asset.drivers.filter((d) => d.state !== state.id);
|
|
618
|
+
if (asset.drivers && !asset.drivers.length)
|
|
619
|
+
delete asset.drivers;
|
|
620
|
+
if (asset.stateInputs && !Object.keys(asset.stateInputs).length)
|
|
621
|
+
delete asset.stateInputs;
|
|
622
|
+
}
|
|
623
|
+
return asset;
|
|
624
|
+
}
|
|
625
|
+
/** A figure as one value, for saying "this is the figure I read". The declaration itself is left out of it. */
|
|
626
|
+
function fingerprintOf(asset) {
|
|
627
|
+
const text = JSON.stringify({ ...asset, occupancy: asset.occupancy ? { ...asset.occupancy, bounds: null } : undefined });
|
|
628
|
+
let h1 = 0x811c9dc5;
|
|
629
|
+
let h2 = 0x01000193;
|
|
630
|
+
for (let i = 0; i < text.length; i++) {
|
|
631
|
+
h1 = Math.imul(h1 ^ text.charCodeAt(i), 0x01000193) >>> 0;
|
|
632
|
+
h2 = Math.imul(h2 + text.charCodeAt(i), 0x85ebca6b) >>> 0;
|
|
633
|
+
}
|
|
634
|
+
return `${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}:${text.length}`;
|
|
635
|
+
}
|
|
636
|
+
const linAdd = (a, b, k = 1) => ({ terms: [...a.terms, ...b.terms.map(t => ({ ref: t.ref, k: t.k * k }))], c: a.c + b.c * k });
|
|
637
|
+
const linOf = (m, ref) => {
|
|
638
|
+
const constant = m.constants.find((c) => c.id === ref);
|
|
639
|
+
return constant ? { terms: [], c: constant.value } : { terms: [{ ref, k: 1 }], c: 0 };
|
|
640
|
+
};
|
|
641
|
+
/** How far a slide has gone when its control is at one end of its range. */
|
|
642
|
+
function travelAt(m, asset, motion, bound) {
|
|
643
|
+
const quantity = motion.args[3];
|
|
644
|
+
const direct = m.inputs.find((i) => i.id === quantity && i.role === 'state');
|
|
645
|
+
const pick = (i) => (bound === 'start' ? (asset.stateDefaults[i.id] ?? i.min) : bound === 'min' ? i.min : i.max);
|
|
646
|
+
if (direct)
|
|
647
|
+
return { terms: [], c: pick(direct) };
|
|
648
|
+
const writer = writerOf(m, quantity);
|
|
649
|
+
if (writer?.op === 'mul@1' && writer.args.length === 2) {
|
|
650
|
+
const state = m.inputs.find((i) => i.id === writer.args[0] && i.role === 'state');
|
|
651
|
+
if (state)
|
|
652
|
+
return { terms: [{ ref: writer.args[1], k: pick(state) }], c: 0 };
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
/** Where a part's centre is on one axis, as a sum, following whatever it is fastened to. */
|
|
657
|
+
function centreLin(m, asset, id, axis, bound) {
|
|
658
|
+
const i = AXES.indexOf(axis);
|
|
659
|
+
let out = { terms: [], c: 0 };
|
|
660
|
+
for (let part = id; part; part = parentOf(m, part)) {
|
|
661
|
+
const pose = nodeById(m, `${part}.pose`);
|
|
662
|
+
if (!pose)
|
|
663
|
+
return `${part} has no pose this command can read`;
|
|
664
|
+
for (const ref of pose.args.slice(3, 6))
|
|
665
|
+
if (angleOf(m, asset, ref) !== 0)
|
|
666
|
+
return `${part} is turned, so its reach is not a box this command can add up`;
|
|
667
|
+
out = linAdd(out, linOf(m, pose.args[i]));
|
|
668
|
+
const motion = nodeById(m, `${part}.motion`);
|
|
669
|
+
if (motion) {
|
|
670
|
+
if (motion.op !== 'axis-slide@1')
|
|
671
|
+
return `${part} turns, so its reach is not a box this command can add up`;
|
|
672
|
+
const unit = motion.args.slice(0, 3).map((ref) => m.constants.find((k) => k.id === ref)?.value ?? null);
|
|
673
|
+
if (unit.some((v) => v === null))
|
|
674
|
+
return `${part}'s slide axis is not a constant`;
|
|
675
|
+
const along = unit[i];
|
|
676
|
+
if (along !== 0) {
|
|
677
|
+
const travel = travelAt(m, asset, motion, bound);
|
|
678
|
+
if (!travel)
|
|
679
|
+
return `${part}'s travel is not a distance this command can read`;
|
|
680
|
+
out = linAdd(out, travel, along);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
/** The bounds the parts come to, as sums, with what could not be read named. */
|
|
687
|
+
function occupancyLins(asset, over) {
|
|
688
|
+
const m = asset.document.model;
|
|
689
|
+
const parts = [];
|
|
690
|
+
const skipped = [];
|
|
691
|
+
const low = { x: [], y: [], z: [] };
|
|
692
|
+
const high = { x: [], y: [], z: [] };
|
|
693
|
+
for (const place of m.nodes.filter((n) => n.op === 'place@1')) {
|
|
694
|
+
let sizes;
|
|
695
|
+
try {
|
|
696
|
+
sizes = sizeRefsOf(m, place.id, place.id);
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
skipped.push({ part: place.id, reason: 'not a box-shaped part; this command measures boxes' });
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
/*
|
|
703
|
+
Both ends of every control are candidates, and which one is the low end is not decided here: a travel of
|
|
704
|
+
−40 mm puts the low end at the control's maximum (V3 designer's counterexample 2026-09-23, where the
|
|
705
|
+
proposal read −50..10 for a part that needs −90..50). Both candidates go into the same min and the same
|
|
706
|
+
max, so the answer holds whichever way the travel runs, and holds at every design size, because the
|
|
707
|
+
choice is made when the graph is evaluated rather than when the proposal is written.
|
|
708
|
+
*/
|
|
709
|
+
const ends = over === 'rest' ? ['start'] : ['min', 'max'];
|
|
710
|
+
const readings = AXES.map(axis => ({ axis, at: ends.map(end => centreLin(m, asset, place.id, axis, end)) }));
|
|
711
|
+
const bad = readings.flatMap(r => r.at).find(v => typeof v === 'string');
|
|
712
|
+
if (bad) {
|
|
713
|
+
skipped.push({ part: place.id, reason: String(bad) });
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
parts.push(place.id);
|
|
717
|
+
for (const r of readings) {
|
|
718
|
+
const half = { terms: [{ ref: sizes[r.axis], k: 0.5 }], c: 0 };
|
|
719
|
+
const seen = new Set();
|
|
720
|
+
for (const centre of r.at) {
|
|
721
|
+
const key = JSON.stringify([centre.terms.map(t => [t.ref, t.k]).sort(), centre.c]);
|
|
722
|
+
if (seen.has(key))
|
|
723
|
+
continue; // a part that does not move gives the same candidate twice
|
|
724
|
+
seen.add(key);
|
|
725
|
+
low[r.axis].push(linAdd(centre, half, -1));
|
|
726
|
+
high[r.axis].push(linAdd(centre, half, 1));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return { parts, skipped, low, high };
|
|
731
|
+
}
|
|
732
|
+
const valueOfLin = (values, l) => l.terms.reduce((s, t) => s + t.k * (values[t.ref] ?? NaN), l.c);
|
|
733
|
+
/** What the parts come to, and what a person is being asked to confirm. Reads the asset; changes nothing. */
|
|
734
|
+
export function proposeV3Occupancy(asset, options = {}) {
|
|
735
|
+
const over = options.over ?? 'range';
|
|
736
|
+
if (over !== 'rest' && over !== 'range')
|
|
737
|
+
fail('SCHEMA', 'over', `${String(over)} is neither rest nor range`);
|
|
738
|
+
const source = structuredClone(asset);
|
|
739
|
+
const { parts, skipped, low, high } = occupancyLins(source, over);
|
|
740
|
+
if (!parts.length)
|
|
741
|
+
fail('OCCUPANCY_NO_PARTS', 'occupancy', `no part could be measured${skipped.length ? `: ${skipped.map(s => `${s.part} — ${s.reason}`).join('; ')}` : ''}`);
|
|
742
|
+
const values = compileV3Graph(source.document.model).evaluate({ ...source.designInputs, ...source.stateDefaults }).values;
|
|
743
|
+
const at = Object.fromEntries(AXES.map(axis => [axis, { min: round6(Math.min(...low[axis].map(l => valueOfLin(values, l)))), max: round6(Math.max(...high[axis].map(l => valueOfLin(values, l)))) }]));
|
|
744
|
+
return { over, at, parts, skipped, basis: fingerprintOf(source) };
|
|
745
|
+
}
|
|
746
|
+
function declareOccupancy(asset, a) {
|
|
747
|
+
const m = asset.document.model;
|
|
748
|
+
if (!a.proposal || (a.proposal.over !== 'rest' && a.proposal.over !== 'range'))
|
|
749
|
+
fail('SCHEMA', 'occupancy', 'a proposal is required; propose first, then confirm');
|
|
750
|
+
if (!['floor', 'ceiling', 'center'].includes(a.placement))
|
|
751
|
+
fail('SCHEMA', 'occupancy', `${String(a.placement)} is not a placement`);
|
|
752
|
+
const now = fingerprintOf(asset);
|
|
753
|
+
if (a.proposal.basis !== now)
|
|
754
|
+
fail('OCCUPANCY_STALE', 'occupancy', 'the figure changed since this was proposed, so the volume it would declare is not the one that was looked at; propose again and confirm that');
|
|
755
|
+
const { parts, low, high } = occupancyLins(asset, a.proposal.over);
|
|
756
|
+
if (parts.join('|') !== a.proposal.parts.join('|'))
|
|
757
|
+
fail('OCCUPANCY_STALE', 'occupancy', `the figure changed since the proposal was made (${a.proposal.parts.join(', ')} then, ${parts.join(', ')} now); propose again and confirm that`);
|
|
758
|
+
const name = namer(m, 'occupancy');
|
|
759
|
+
const emit = (l, hint) => {
|
|
760
|
+
if (!l.terms.length)
|
|
761
|
+
return constantOf(m, 'mm', round6(l.c), 'd');
|
|
762
|
+
let out = '';
|
|
763
|
+
for (const t of l.terms) {
|
|
764
|
+
const piece = scaled(m, name, t.ref, t.k, 0, hint);
|
|
765
|
+
if (!out)
|
|
766
|
+
out = piece;
|
|
767
|
+
else {
|
|
768
|
+
const id = name(`${hint}.sum`);
|
|
769
|
+
m.nodes.push({ id, op: 'add@1', args: [out, piece], outputs: { value: `${id}.value` } });
|
|
770
|
+
out = `${id}.value`;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (l.c !== 0) {
|
|
774
|
+
const id = name(`${hint}.offset`);
|
|
775
|
+
m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', round6(l.c), 'd')], outputs: { value: `${id}.value` } });
|
|
776
|
+
out = `${id}.value`;
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
};
|
|
780
|
+
const pick = (ls, op, hint) => {
|
|
781
|
+
const refs = ls.map((l, k) => emit(l, `${hint}.${k}`));
|
|
782
|
+
if (refs.length === 1)
|
|
783
|
+
return refs[0];
|
|
784
|
+
const id = name(hint);
|
|
785
|
+
m.nodes.push({ id, op, args: refs, outputs: { value: `${id}.value` } });
|
|
786
|
+
return `${id}.value`;
|
|
787
|
+
};
|
|
788
|
+
const bounds = Object.fromEntries(AXES.map(axis => [axis, { min: pick(low[axis], 'min@1', `${axis}.min`), max: pick(high[axis], 'max@1', `${axis}.max`) }]));
|
|
789
|
+
asset.occupancy = { ...(asset.occupancy?.contact ? { contact: asset.occupancy.contact } : {}), bounds, placement: a.placement };
|
|
790
|
+
return asset;
|
|
791
|
+
}
|
|
792
|
+
/** "This face is what the figure is mounted on." Kept apart from how much room the figure takes up. */
|
|
793
|
+
function setMountingFace(asset, a) {
|
|
794
|
+
const m = asset.document.model;
|
|
795
|
+
if (!asset.occupancy)
|
|
796
|
+
fail('OCCUPANCY_UNDECLARED', 'occupancy', 'declare the room the figure takes up before naming the face it stands on');
|
|
797
|
+
if (!Object.hasOwn(V3_FACES, String(a.face)))
|
|
798
|
+
fail('SCHEMA', a.part, `${String(a.face)} is not a face`);
|
|
799
|
+
const { place } = partOf(m, a.part, a.part);
|
|
800
|
+
const id = `${a.part}.mount.${a.face}`;
|
|
801
|
+
if (!nodeById(m, id))
|
|
802
|
+
m.nodes.push({ id, op: 'feature@1', args: [place.args[0]], outputs: { pose: `${id}.value` }, params: { frame: `${id}.frame`, name: a.face } });
|
|
803
|
+
asset.occupancy.contact = { plane: 'mounting-plane', surface: { placement: place.outputs.placed, feature: `${id}.value` } };
|
|
804
|
+
return asset;
|
|
805
|
+
}
|
|
806
|
+
function clearMountingFace(asset) {
|
|
807
|
+
if (!asset.occupancy?.contact)
|
|
808
|
+
fail('EDIT_TARGET', 'occupancy', 'this figure names no mounting face');
|
|
809
|
+
delete asset.occupancy.contact;
|
|
810
|
+
return asset;
|
|
811
|
+
}
|
|
384
812
|
//# sourceMappingURL=v3-authoring-actions.js.map
|