@hatiolab/figure-model 0.1.36 → 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 +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/v3-asset-types.d.ts +89 -16
- package/dist/v3-asset-types.d.ts.map +1 -1
- package/dist/v3-asset.d.ts.map +1 -1
- package/dist/v3-asset.js +34 -25
- package/dist/v3-asset.js.map +1 -1
- package/dist/v3-authoring-actions.d.ts +226 -0
- package/dist/v3-authoring-actions.d.ts.map +1 -0
- package/dist/v3-authoring-actions.js +812 -0
- package/dist/v3-authoring-actions.js.map +1 -0
- package/dist/v3-capabilities.d.ts.map +1 -1
- package/dist/v3-capabilities.js +4 -77
- package/dist/v3-capabilities.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-driver.d.ts +24 -1
- package/dist/v3-driver.d.ts.map +1 -1
- package/dist/v3-driver.js +68 -2
- package/dist/v3-driver.js.map +1 -1
- package/dist/v3-from-v2.d.ts +25 -1
- package/dist/v3-from-v2.d.ts.map +1 -1
- package/dist/v3-from-v2.js +598 -21
- package/dist/v3-from-v2.js.map +1 -1
- package/dist/v3-gate.d.ts +59 -6
- package/dist/v3-gate.d.ts.map +1 -1
- package/dist/v3-gate.js +291 -45
- package/dist/v3-gate.js.map +1 -1
- package/dist/v3-graph-types.d.ts +9 -1
- package/dist/v3-graph-types.d.ts.map +1 -1
- package/dist/v3-graph.d.ts.map +1 -1
- package/dist/v3-graph.js +53 -1
- package/dist/v3-graph.js.map +1 -1
- 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/dist/v3-mesh-compare.d.ts +2 -5
- package/dist/v3-mesh-compare.d.ts.map +1 -1
- package/dist/v3-mesh-compare.js +2 -145
- package/dist/v3-mesh-compare.js.map +1 -1
- package/dist/v3-shape-sampling.d.ts +14 -0
- package/dist/v3-shape-sampling.d.ts.map +1 -0
- package/dist/v3-shape-sampling.js +120 -0
- package/dist/v3-shape-sampling.js.map +1 -0
- package/dist/v3-surface.d.ts +15 -0
- package/dist/v3-surface.d.ts.map +1 -0
- package/dist/v3-surface.js +104 -0
- package/dist/v3-surface.js.map +1 -0
- package/docs/prototypes/v3-asset.schema.json +0 -8
- package/docs/v3-asset-persistence.md +1 -1
- package/docs/v3-legacy-risk-audit-2026-09-23.md +39 -0
- package/docs/v3-operator-contracts.md +1 -0
- package/docs/v3-shape-dimension-contract.md +111 -0
- package/package.json +1 -1
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
/*
|
|
5
|
+
* What a person is trying to say when they build a figure, as commands.
|
|
6
|
+
*
|
|
7
|
+
* The four things the hand-written sliding gate needed and the authoring API had no word for (V3 designer's
|
|
8
|
+
* ruling 2026-09-23): a dimension that follows something, a part fastened to a face, a part that moves, and the
|
|
9
|
+
* room the figure takes up. This file carries the first two; the other two follow.
|
|
10
|
+
*
|
|
11
|
+
* Every command here is pure. It clones the asset, edits the clone, compiles it, and returns it — so a command
|
|
12
|
+
* that fails throws with the original untouched, and one command is one step to undo. Part ids never change, so
|
|
13
|
+
* a reference an author or a connector holds keeps pointing at the same part after any of these.
|
|
14
|
+
*
|
|
15
|
+
* Frames. A part's placement is built from three nodes that are never merged, so adding one does not erase
|
|
16
|
+
* another (V3 designer's ruling: attachment and motion must not overwrite the same pose):
|
|
17
|
+
*
|
|
18
|
+
* <id>.local --<id>.pose--> <id>.seat --<id>.motion--> <parent>.local --<parent>.world--> asset
|
|
19
|
+
*
|
|
20
|
+
* `<id>.pose` is where the part sits on what it is fastened to. `<id>.motion` is how it moves from there, and is
|
|
21
|
+
* absent until something makes it move. `<parent>.world` is the parent's own chain, so moving or resizing the
|
|
22
|
+
* parent carries the child with it. A part that is fastened to nothing poses straight into the asset frame.
|
|
23
|
+
*/
|
|
24
|
+
import { compileV3Asset } from "./v3-asset.js";
|
|
25
|
+
import { compileV3Graph } from "./v3-graph.js";
|
|
26
|
+
import { V3ContractError } from "./v3-contract.js";
|
|
27
|
+
import { AXES } from "./types.js";
|
|
28
|
+
const fail = (code, path, message) => {
|
|
29
|
+
throw new V3ContractError(code, path, message);
|
|
30
|
+
};
|
|
31
|
+
/** The six faces of a box-shaped part, and the axis each one faces along. */
|
|
32
|
+
export const V3_FACES = { left: 'x', right: 'x', bottom: 'y', top: 'y', back: 'z', front: 'z' };
|
|
33
|
+
const SIGN = { left: -1, right: 1, bottom: -1, top: 1, back: -1, front: 1 };
|
|
34
|
+
const OPPOSITE = { left: 'right', right: 'left', bottom: 'top', top: 'bottom', back: 'front', front: 'back' };
|
|
35
|
+
const clone = (asset) => structuredClone(asset);
|
|
36
|
+
const nodeById = (m, id) => m.nodes.find((n) => n.id === id);
|
|
37
|
+
const writerOf = (m, ref) => m.nodes.find((n) => Object.values(n.outputs).includes(ref));
|
|
38
|
+
/** A part an authoring command can work with, and the pieces of it those commands need. */
|
|
39
|
+
function partOf(m, id, path) {
|
|
40
|
+
const place = nodeById(m, id);
|
|
41
|
+
if (!place || place.op !== 'place@1')
|
|
42
|
+
fail('TARGET_ABSENT', path, `${id} is not a placed part`);
|
|
43
|
+
const shape = writerOf(m, place.args[0]);
|
|
44
|
+
if (!shape)
|
|
45
|
+
fail('TARGET_ABSENT', path, `${id} has no shape`);
|
|
46
|
+
const pose = nodeById(m, `${id}.pose`);
|
|
47
|
+
if (!pose || pose.op !== 'rigid@1')
|
|
48
|
+
fail('EDIT_TARGET', path, `${id} is not posed by a rigid@1 this command can read`);
|
|
49
|
+
return { place, shape, pose };
|
|
50
|
+
}
|
|
51
|
+
/** The three size references of a box-shaped part, or a refusal naming what it is instead. */
|
|
52
|
+
function sizeRefsOf(m, id, path) {
|
|
53
|
+
const { shape } = partOf(m, id, path);
|
|
54
|
+
if (shape.op !== 'box-shape@1' && shape.op !== 'rounded-box@1' && shape.op !== 'rounded-box@2')
|
|
55
|
+
fail('EDIT_TARGET', path, `${id} is a ${shape.op}; a face of it is not an axis-aligned plane this command can measure`);
|
|
56
|
+
return { x: shape.args[0], y: shape.args[1], z: shape.args[2] };
|
|
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
|
+
}
|
|
68
|
+
/** A part with no turn in its pose: a face of a turned body is not the plane this command assumes. */
|
|
69
|
+
function requireUnturned(m, asset, id, path) {
|
|
70
|
+
const { pose } = partOf(m, id, path);
|
|
71
|
+
for (const ref of pose.args.slice(3, 6))
|
|
72
|
+
if (angleOf(m, asset, ref) !== 0)
|
|
73
|
+
fail('EDIT_TARGET', path, `${id} is turned; fastening a turned part by its faces is not covered by this command`);
|
|
74
|
+
}
|
|
75
|
+
/** Fresh node ids under a part, so two commands never claim one id. */
|
|
76
|
+
function namer(m, prefix) {
|
|
77
|
+
const taken = new Set([...m.nodes.map((n) => n.id), ...m.nodes.flatMap((n) => Object.values(n.outputs)), ...m.constants.map((c) => c.id), ...m.inputs.map((i) => i.id)]);
|
|
78
|
+
return (hint) => {
|
|
79
|
+
let id = `${prefix}.${hint}`;
|
|
80
|
+
for (let k = 2; taken.has(id) || taken.has(`${id}.value`); k++)
|
|
81
|
+
id = `${prefix}.${hint}.${k}`;
|
|
82
|
+
taken.add(id);
|
|
83
|
+
taken.add(`${id}.value`);
|
|
84
|
+
return id;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** A constant, reusing one that already holds the same number in the same unit. */
|
|
88
|
+
function constantOf(m, unit, value, hint) {
|
|
89
|
+
const found = m.constants.find((c) => c.unit === unit && c.value === value);
|
|
90
|
+
if (found)
|
|
91
|
+
return found.id;
|
|
92
|
+
const id = `${hint}.${unit}.${String(value).replace(/[^0-9a-zA-Z]/g, '_')}`;
|
|
93
|
+
m.constants.push({ id, unit, value });
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
/** value = ref × times + plus, written as nodes, and folded away when there is nothing to do. */
|
|
97
|
+
function scaled(m, name, ref, times, plus, hint) {
|
|
98
|
+
let out = ref;
|
|
99
|
+
if (times !== 1) {
|
|
100
|
+
const id = name(`${hint}.times`);
|
|
101
|
+
m.nodes.push({ id, op: 'mul@1', args: [out, constantOf(m, 'ratio', times, 'k')], outputs: { value: `${id}.value` } });
|
|
102
|
+
out = `${id}.value`;
|
|
103
|
+
}
|
|
104
|
+
if (plus !== 0) {
|
|
105
|
+
const id = name(`${hint}.plus`);
|
|
106
|
+
m.nodes.push({ id, op: 'add@1', args: [out, constantOf(m, 'mm', plus, 'd')], outputs: { value: `${id}.value` } });
|
|
107
|
+
out = `${id}.value`;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
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
|
+
}
|
|
142
|
+
function sourceRef(m, asset, source, path) {
|
|
143
|
+
if (!source || typeof source !== 'object')
|
|
144
|
+
fail('SCHEMA', path, 'a dimension source is required');
|
|
145
|
+
if (source.of === 'instance') {
|
|
146
|
+
const id = `size.${source.axis}`;
|
|
147
|
+
if (!m.inputs.some((i) => i.id === id))
|
|
148
|
+
fail('TARGET_ABSENT', path, `the instance has no ${id}; declare it before following it`);
|
|
149
|
+
return id;
|
|
150
|
+
}
|
|
151
|
+
if (source.of === 'input') {
|
|
152
|
+
const input = m.inputs.find((i) => i.id === source.input);
|
|
153
|
+
if (!input)
|
|
154
|
+
fail('TARGET_ABSENT', path, `${source.input} is not an input of this asset`);
|
|
155
|
+
if (input.role === 'state')
|
|
156
|
+
fail('EDIT_TARGET', path, `${source.input} is a state input; a dimension that follows state is a different declaration`);
|
|
157
|
+
return source.input;
|
|
158
|
+
}
|
|
159
|
+
if (source.of === 'part')
|
|
160
|
+
return stableDimension(m, source.part, source.axis, path);
|
|
161
|
+
return fail('SCHEMA', path, `${String(source.of)} is not a dimension source`);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
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.
|
|
172
|
+
*/
|
|
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
|
+
};
|
|
192
|
+
function linkDimension(asset, a) {
|
|
193
|
+
const m = asset.document.model;
|
|
194
|
+
const path = `${a.part}.${a.axis}`;
|
|
195
|
+
const mine = stableDimension(m, a.part, a.axis, path);
|
|
196
|
+
if (followsAlready(m, a.part, a.axis) && !a.replace)
|
|
197
|
+
fail('DIMENSION_LINKED', path, `${a.part}'s ${a.axis} already follows something; pass replace to change what it follows, or unlink it first`);
|
|
198
|
+
const times = a.times ?? 1;
|
|
199
|
+
const plus = a.plus ?? 0;
|
|
200
|
+
if (!Number.isFinite(times) || !Number.isFinite(plus))
|
|
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`);
|
|
204
|
+
const from = sourceRef(m, asset, a.source, path);
|
|
205
|
+
if (from === mine)
|
|
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}`);
|
|
209
|
+
const name = namer(m, `${a.part}.${a.axis}`);
|
|
210
|
+
const value = scaled(m, name, from, times, plus, 'follows');
|
|
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}`);
|
|
216
|
+
return asset;
|
|
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
|
+
}
|
|
228
|
+
function unlinkDimension(asset, a) {
|
|
229
|
+
const m = asset.document.model;
|
|
230
|
+
const path = `${a.part}.${a.axis}`;
|
|
231
|
+
if (!(Number.isFinite(a.mm) && a.mm > 0))
|
|
232
|
+
fail('GEOMETRY_DOMAIN', path, 'a positive length is required');
|
|
233
|
+
stableDimension(m, a.part, a.axis, path);
|
|
234
|
+
if (!followsAlready(m, a.part, a.axis))
|
|
235
|
+
fail('EDIT_TARGET', path, `${a.part}'s ${a.axis} does not follow anything`);
|
|
236
|
+
const id = `${a.part}.size.${a.axis}`;
|
|
237
|
+
if (m.inputs.some((i) => i.id === id))
|
|
238
|
+
fail('DUPLICATE_WRITER', path, `${id} already exists`);
|
|
239
|
+
m.inputs.push({ id, unit: 'mm', min: Number.MIN_VALUE, max: Number.MAX_VALUE, role: 'design' });
|
|
240
|
+
asset.designInputs[id] = a.mm;
|
|
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);
|
|
245
|
+
return asset;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Remove a value nothing reads any more, and whatever fed only it. An input is removed only when it is the one
|
|
249
|
+
* this command owns — the part's own dimension control. A shared input, such as the instance's size, stays even
|
|
250
|
+
* when the command that stopped reading it was the last reader: it is the author's, not this command's.
|
|
251
|
+
*/
|
|
252
|
+
function dropIfUnused(asset, ref, ownedInput) {
|
|
253
|
+
const m = asset.document.model;
|
|
254
|
+
const referenced = (r) => m.nodes.some((n) => n.args.includes(r) || Object.values(n.params ?? {}).includes(r)) || Object.values(asset.occupancy?.bounds ?? {}).some((b) => b.min === r || b.max === r);
|
|
255
|
+
if (referenced(ref))
|
|
256
|
+
return;
|
|
257
|
+
const input = m.inputs.findIndex((i) => i.id === ref);
|
|
258
|
+
if (input >= 0) {
|
|
259
|
+
if (ref !== ownedInput)
|
|
260
|
+
return;
|
|
261
|
+
m.inputs.splice(input, 1);
|
|
262
|
+
delete asset.designInputs[ref];
|
|
263
|
+
delete asset.stateDefaults[ref];
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const writer = writerOf(m, ref);
|
|
267
|
+
if (!writer || writer.op === 'place@1' || Object.values(writer.outputs).some((r) => r !== ref && referenced(r)))
|
|
268
|
+
return;
|
|
269
|
+
m.nodes = m.nodes.filter((n) => n !== writer);
|
|
270
|
+
for (const arg of writer.args)
|
|
271
|
+
dropIfUnused(asset, arg);
|
|
272
|
+
}
|
|
273
|
+
/** Which part each part is fastened to, read from the frames rather than from a second record of it. */
|
|
274
|
+
function parentOf(m, id) {
|
|
275
|
+
if (!nodeById(m, `${id}.chain`))
|
|
276
|
+
return null;
|
|
277
|
+
// The seat's own frame leads either to a motion node or, when the part does not move, to an identity rigid.
|
|
278
|
+
const above = nodeById(m, `${id}.motion`) ?? nodeById(m, `${id}.seat.rigid`);
|
|
279
|
+
const match = /^(.*)\.local$/.exec(String(above?.params?.to));
|
|
280
|
+
return match && match[1] !== id ? match[1] : null;
|
|
281
|
+
}
|
|
282
|
+
function attach(asset, a) {
|
|
283
|
+
const m = asset.document.model;
|
|
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)
|
|
291
|
+
fail('ATTACH_CYCLE', path, 'a part cannot be fastened to itself');
|
|
292
|
+
for (const face of [a.face, to.face])
|
|
293
|
+
if (!Object.hasOwn(V3_FACES, String(face)))
|
|
294
|
+
fail('SCHEMA', path, `${String(face)} is not a face`);
|
|
295
|
+
const facing = a.facing ?? 'meet';
|
|
296
|
+
if (facing !== 'meet' && facing !== 'flush')
|
|
297
|
+
fail('SCHEMA', path, `${String(facing)} is neither meet nor flush`);
|
|
298
|
+
const axis = V3_FACES[to.face];
|
|
299
|
+
if (V3_FACES[a.face] !== axis)
|
|
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}`);
|
|
305
|
+
if (parentOf(m, a.part) && !a.replace)
|
|
306
|
+
fail('ATTACH_REPLACED', path, `${a.part} is already fastened to ${parentOf(m, a.part)}; pass replace to move it, or detach it first`);
|
|
307
|
+
// A part cannot end up its own ancestor.
|
|
308
|
+
for (let up = to.part; up; up = parentOf(m, up))
|
|
309
|
+
if (up === a.part)
|
|
310
|
+
fail('ATTACH_CYCLE', path, `${to.part} already hangs from ${a.part}`);
|
|
311
|
+
const mine = sizeRefsOf(m, a.part, path);
|
|
312
|
+
const theirs = sizeRefsOf(m, to.part, path);
|
|
313
|
+
requireUnturned(m, asset, a.part, path);
|
|
314
|
+
requireUnturned(m, asset, to.part, path);
|
|
315
|
+
const name = namer(m, `${a.part}.on.${to.part}`);
|
|
316
|
+
const sign = SIGN[to.face];
|
|
317
|
+
/*
|
|
318
|
+
Along the face's own axis. `meet`: the part's centre is half its own depth past the target's face, plus the
|
|
319
|
+
gap. `flush`: the part's centre is half its own depth back from that face, so its named face lies in it.
|
|
320
|
+
*/
|
|
321
|
+
const outward = facing === 'meet' ? sign : -sign;
|
|
322
|
+
const gap = measureRef(m, asset, name, a.gap, `gap.${axis}`, path);
|
|
323
|
+
const translation = { x: '', y: '', z: '' };
|
|
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}`);
|
|
329
|
+
// Across the face. Centred by default; min and max keep the two parts' edges level as either is resized.
|
|
330
|
+
for (const other of AXES.filter(x => x !== axis)) {
|
|
331
|
+
const how = a.align?.[other] ?? 'centre';
|
|
332
|
+
if (how === 'centre')
|
|
333
|
+
translation[other] = constantOf(m, 'mm', 0, 'd');
|
|
334
|
+
else if (typeof how === 'object' && how && Object.hasOwn(how, 'mm'))
|
|
335
|
+
translation[other] = measureRef(m, asset, name, how.mm, `align.${other}`, path);
|
|
336
|
+
else if (how === 'min' || how === 'max') {
|
|
337
|
+
const s = how === 'min' ? -1 : 1;
|
|
338
|
+
translation[other] = sumOf(m, name, [{ ref: theirs[other], k: s * 0.5 }, { ref: mine[other], k: -s * 0.5 }], `align.${other}`);
|
|
339
|
+
}
|
|
340
|
+
else
|
|
341
|
+
fail('SCHEMA', path, `${String(how)} is not an alignment`);
|
|
342
|
+
}
|
|
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]);
|
|
378
|
+
return asset;
|
|
379
|
+
}
|
|
380
|
+
function detach(asset, a) {
|
|
381
|
+
const m = asset.document.model;
|
|
382
|
+
const path = a.part;
|
|
383
|
+
const parent = parentOf(m, a.part);
|
|
384
|
+
if (!parent)
|
|
385
|
+
fail('EDIT_TARGET', path, `${a.part} is not fastened to anything`);
|
|
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);
|
|
398
|
+
const evaluated = compileV3Asset(asset).evaluate();
|
|
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);
|
|
416
|
+
return asset;
|
|
417
|
+
}
|
|
418
|
+
const round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
419
|
+
/**
|
|
420
|
+
* Put a part's pose on a new frame with a new translation, and rebuild the chain that carries it to the asset.
|
|
421
|
+
* The part's own id, its shape and its `placed` reference all stay as they were.
|
|
422
|
+
*/
|
|
423
|
+
function reseat(asset, id, to, translation) {
|
|
424
|
+
const m = asset.document.model;
|
|
425
|
+
const { place, pose } = partOf(m, id, id);
|
|
426
|
+
const old = translation ? pose.args.slice(0, 3) : [];
|
|
427
|
+
if (translation)
|
|
428
|
+
pose.args = [...translation, ...pose.args.slice(3)];
|
|
429
|
+
pose.params = { ...pose.params, to: `${id}.seat` };
|
|
430
|
+
const motion = nodeById(m, `${id}.motion`);
|
|
431
|
+
if (motion)
|
|
432
|
+
motion.params = { ...motion.params, from: `${id}.seat`, to };
|
|
433
|
+
// seat → the frame it is fastened to. Without a motion node the seat is that frame, through an identity rigid.
|
|
434
|
+
let chain = nodeById(m, `${id}.chain`);
|
|
435
|
+
if (!chain) {
|
|
436
|
+
chain = { id: `${id}.chain`, op: 'compose@1', args: ['', pose.outputs.pose], outputs: { pose: `${id}.chain.value` } };
|
|
437
|
+
m.nodes.push(chain);
|
|
438
|
+
}
|
|
439
|
+
let seat = nodeById(m, `${id}.seat.rigid`);
|
|
440
|
+
if (!motion) {
|
|
441
|
+
const zero = constantOf(m, 'mm', 0, 'd');
|
|
442
|
+
const noTurn = constantOf(m, 'deg', 0, 'a');
|
|
443
|
+
if (!seat) {
|
|
444
|
+
seat = { id: `${id}.seat.rigid`, op: 'rigid@1', args: [zero, zero, zero, noTurn, noTurn, noTurn], outputs: { pose: `${id}.seat.value` }, params: { from: `${id}.seat`, to } };
|
|
445
|
+
m.nodes.push(seat);
|
|
446
|
+
}
|
|
447
|
+
else
|
|
448
|
+
seat.params = { ...seat.params, to };
|
|
449
|
+
chain.args = [seat.outputs.pose, pose.outputs.pose];
|
|
450
|
+
}
|
|
451
|
+
else
|
|
452
|
+
chain.args = [motion.outputs.pose, pose.outputs.pose];
|
|
453
|
+
const parent = /^(.*)\.local$/.exec(to);
|
|
454
|
+
let world = nodeById(m, `${id}.world`);
|
|
455
|
+
if (parent && parent[1] !== id) {
|
|
456
|
+
const above = nodeById(m, `${parent[1]}.world`)?.outputs?.pose ?? nodeById(m, `${parent[1]}.chain`)?.outputs?.pose ?? nodeById(m, `${parent[1]}.pose`).outputs.pose;
|
|
457
|
+
if (!world) {
|
|
458
|
+
world = { id: `${id}.world`, op: 'compose@1', args: [above, chain.outputs.pose], outputs: { pose: `${id}.world.value` } };
|
|
459
|
+
m.nodes.push(world);
|
|
460
|
+
}
|
|
461
|
+
else
|
|
462
|
+
world.args = [above, chain.outputs.pose];
|
|
463
|
+
place.args = [place.args[0], world.outputs.pose];
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
if (world)
|
|
467
|
+
m.nodes = m.nodes.filter((n) => n !== world);
|
|
468
|
+
place.args = [place.args[0], chain.outputs.pose];
|
|
469
|
+
}
|
|
470
|
+
for (const ref of old)
|
|
471
|
+
dropIfUnused(asset, ref);
|
|
472
|
+
}
|
|
473
|
+
/** Apply one authoring command. Throws on any refusal, leaving the asset it was given untouched. */
|
|
474
|
+
export function applyV3Authoring(source, action) {
|
|
475
|
+
compileV3Asset(source);
|
|
476
|
+
if (!action || typeof action !== 'object')
|
|
477
|
+
fail('SCHEMA', 'action', 'an authoring action is required');
|
|
478
|
+
const asset = clone(source);
|
|
479
|
+
switch (action.kind) {
|
|
480
|
+
case 'link-dimension':
|
|
481
|
+
linkDimension(asset, action);
|
|
482
|
+
break;
|
|
483
|
+
case 'unlink-dimension':
|
|
484
|
+
unlinkDimension(asset, action);
|
|
485
|
+
break;
|
|
486
|
+
case 'attach':
|
|
487
|
+
attach(asset, action);
|
|
488
|
+
break;
|
|
489
|
+
case 'detach':
|
|
490
|
+
detach(asset, action);
|
|
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;
|
|
507
|
+
default:
|
|
508
|
+
fail('SCHEMA', 'action', `${String(action.kind)} is not an authoring action`);
|
|
509
|
+
}
|
|
510
|
+
compileV3Asset(asset);
|
|
511
|
+
return asset;
|
|
512
|
+
}
|
|
513
|
+
/** What each part is fastened to, for a reader that wants the structure rather than the graph. */
|
|
514
|
+
export function v3AttachmentsOf(asset) {
|
|
515
|
+
const m = asset.document.model;
|
|
516
|
+
const out = {};
|
|
517
|
+
for (const n of m.nodes)
|
|
518
|
+
if (n.op === 'place@1')
|
|
519
|
+
out[n.id] = parentOf(m, n.id);
|
|
520
|
+
return out;
|
|
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
|
+
}
|
|
812
|
+
//# sourceMappingURL=v3-authoring-actions.js.map
|