@hatiolab/figure-model 0.6.5 → 0.6.6
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/asset-types.d.ts +94 -40
- package/dist/asset-types.d.ts.map +1 -1
- package/dist/contract.d.ts +38 -24
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/data-interface.d.ts +63 -50
- package/dist/data-interface.d.ts.map +1 -1
- package/dist/data-interface.js +620 -267
- package/dist/data-interface.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel-version.d.ts +1 -1
- package/dist/kernel-version.js +1 -1
- package/dist/motion-groups.d.ts +2 -1
- package/dist/motion-groups.d.ts.map +1 -1
- package/dist/motion-groups.js +2 -2
- package/dist/motion-groups.js.map +1 -1
- package/dist/part-edit.d.ts +23 -15
- package/dist/part-edit.d.ts.map +1 -1
- package/dist/part-edit.js +53 -50
- package/dist/part-edit.js.map +1 -1
- package/dist/proposal.d.ts +2 -2
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +22 -19
- package/dist/proposal.js.map +1 -1
- package/dist/release-contract.d.ts +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +34 -25
- package/dist/release-contract.js.map +1 -1
- package/dist/robots.d.ts +31 -7
- package/dist/robots.d.ts.map +1 -1
- package/dist/robots.js +87 -37
- package/dist/robots.js.map +1 -1
- package/package.json +1 -1
package/dist/data-interface.js
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
3
|
*/
|
|
4
4
|
/*
|
|
5
|
-
* A figure's
|
|
5
|
+
* A figure's data and what it moves inside the figure (ADR-0051 §8, data binding design appendix A).
|
|
6
6
|
*
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* its data
|
|
7
|
+
* The author describes the data the figure takes in the site's own field names -- objects, lists, numbers, booleans,
|
|
8
|
+
* text -- and binds values of it to what the figure does: a state input, a colour, a motion group's clip, speed or
|
|
9
|
+
* pause, a status port, the instance's position, the items in its places. A binding has the board's mapping shape --
|
|
10
|
+
* an accessor into the data, a rule (`value`, `map`, `range`, `scale`) and its param -- so a person who binds data on a
|
|
11
|
+
* board binds it on a figure in the same words. The figure's bindings are its own: every board that places it moves it
|
|
12
|
+
* with the same data, and a board rarely binds more.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* This module checks the declaration once, when the asset is read: each accessor goes down the schema to what its
|
|
15
|
+
* behavior reads, and each rule's results are what the behavior takes over the whole of the value's domain. While the
|
|
16
|
+
* figure runs it checks the data against the schema -- a value of the wrong kind refuses the whole of it -- and gives
|
|
17
|
+
* each binding the value it reads. Turning that value into the behavior's input by the rule is things-scene's, the same
|
|
18
|
+
* evaluator a board mapping runs; nothing here holds time or an instance.
|
|
15
19
|
*/
|
|
16
20
|
import { FigureContractError } from "./contract.js";
|
|
17
21
|
import { figureUnitKnown } from "./graph.js";
|
|
@@ -19,6 +23,8 @@ import { FIGURE_PART_ID } from "./part-ids.js";
|
|
|
19
23
|
/** The fastest a motion group plays: three times its authored speed. 0 stops it. */
|
|
20
24
|
export const FIGURE_MOTION_SPEED_MAX = 3;
|
|
21
25
|
const COLOUR = /^#[0-9a-f]{6}$/i;
|
|
26
|
+
/** A field name as the board's accessor reads it bare: a letter or `_`, then letters, digits, `_` and `-`. Not `$`: the accessor reads that as a variable. */
|
|
27
|
+
const FIELD_NAME = /^[A-Za-z_][\w-]*$/;
|
|
22
28
|
const fail = (code, path, message, details) => {
|
|
23
29
|
throw new FigureContractError(code, path, message, details);
|
|
24
30
|
};
|
|
@@ -34,337 +40,684 @@ function fields(o, required, optional, path) {
|
|
|
34
40
|
fail('ASSET_SCHEMA', path, `unknown ${k}`);
|
|
35
41
|
}
|
|
36
42
|
const finite = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* An accessor read as steps, or null when it is not one this contract takes: field names joined by `.`, each followed
|
|
45
|
+
* by at most one `[n]`. The board's accessor (JSONata) reads these the same way; nothing richer is taken, because a
|
|
46
|
+
* richer expression cannot be followed down the schema.
|
|
47
|
+
*/
|
|
48
|
+
export function parseFigureAccessor(accessor) {
|
|
49
|
+
if (typeof accessor !== 'string' || !accessor)
|
|
50
|
+
return null;
|
|
51
|
+
const steps = [];
|
|
52
|
+
for (const part of accessor.split('.')) {
|
|
53
|
+
const m = /^([A-Za-z_][\w-]*)(?:\[(\d+)\])?$/.exec(part);
|
|
54
|
+
if (!m)
|
|
55
|
+
return null;
|
|
56
|
+
steps.push({ field: m[1] });
|
|
57
|
+
if (m[2] !== undefined)
|
|
58
|
+
steps.push({ index: Number(m[2]) });
|
|
59
|
+
}
|
|
60
|
+
return steps;
|
|
61
|
+
}
|
|
62
|
+
/** An accessor written back from its steps. */
|
|
63
|
+
export const figureAccessorOf = (steps) => steps.map((s, i) => ('index' in s ? `[${s.index}]` : i ? `.${s.field}` : s.field)).join('');
|
|
64
|
+
function nodeAt(schema, steps) {
|
|
65
|
+
let node = { kind: 'object', fields: schema };
|
|
66
|
+
let field;
|
|
67
|
+
for (const step of steps) {
|
|
68
|
+
if ('field' in step) {
|
|
69
|
+
if (node.kind !== 'object')
|
|
70
|
+
return { why: 'absent' };
|
|
71
|
+
const next = node.fields.find(f => f.name === step.field);
|
|
72
|
+
if (!next)
|
|
73
|
+
return { why: 'absent' };
|
|
74
|
+
node = next;
|
|
75
|
+
field = next;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
if (node.kind !== 'list')
|
|
79
|
+
return { why: 'not-a-list' };
|
|
80
|
+
node = node.item;
|
|
81
|
+
field = undefined;
|
|
82
|
+
}
|
|
43
83
|
}
|
|
44
|
-
|
|
45
|
-
return typeof value === 'boolean' ? null : 'type-mismatch';
|
|
46
|
-
if (typeof value !== 'string')
|
|
47
|
-
return 'type-mismatch';
|
|
48
|
-
return property.values.includes(value) ? null : 'enum-unknown';
|
|
84
|
+
return { node, ...(field ? { field } : {}) };
|
|
49
85
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
function
|
|
53
|
-
if (!
|
|
86
|
+
const isValue = (n) => n.kind === 'number' || n.kind === 'boolean' || n.kind === 'text';
|
|
87
|
+
/* ---------------------------------------------------------------- the schema */
|
|
88
|
+
function checkNode(n, path, named) {
|
|
89
|
+
if (!n || typeof n !== 'object' || Array.isArray(n))
|
|
54
90
|
fail('ASSET_SCHEMA', path, 'object required');
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
91
|
+
const head = named ? ['name', 'label', 'json'] : [];
|
|
92
|
+
const kind = n.kind;
|
|
93
|
+
const invalid = (what, message) => refuse(`${path}.${what}`, message, { reason: 'data-field-invalid', field: path, what });
|
|
94
|
+
if (kind === 'object')
|
|
95
|
+
fields(n, ['kind', 'fields'], head, path);
|
|
96
|
+
else if (kind === 'list')
|
|
97
|
+
fields(n, ['kind', 'item'], head, path);
|
|
98
|
+
else if (kind === 'number')
|
|
99
|
+
fields(n, ['kind'], [...head, 'unit', 'min', 'max', 'default'], path);
|
|
58
100
|
else if (kind === 'boolean')
|
|
59
|
-
fields(
|
|
60
|
-
else if (kind === '
|
|
61
|
-
fields(
|
|
101
|
+
fields(n, ['kind'], [...head, 'default'], path);
|
|
102
|
+
else if (kind === 'text')
|
|
103
|
+
fields(n, ['kind'], [...head, 'values', 'default'], path);
|
|
62
104
|
else
|
|
63
|
-
fail('ASSET_SCHEMA', `${path}.kind`, `${String(kind)} is not a data kind; number, boolean or
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
105
|
+
fail('ASSET_SCHEMA', `${path}.kind`, `${String(kind)} is not a data kind; object, list, number, boolean or text`);
|
|
106
|
+
if (named) {
|
|
107
|
+
if (typeof n.name !== 'string' || !FIELD_NAME.test(n.name))
|
|
108
|
+
invalid('name', `${String(n.name)} is not a field name: a letter or _, then letters, digits, _ and -`);
|
|
109
|
+
if (n.label !== undefined && (typeof n.label !== 'string' || !n.label.trim()))
|
|
110
|
+
invalid('label', 'a name shown to a person, or none');
|
|
111
|
+
if (n.json !== undefined && (n.json !== true || (kind !== 'object' && kind !== 'list')))
|
|
112
|
+
invalid('json', 'only an object or a list may come as JSON text');
|
|
113
|
+
}
|
|
114
|
+
if (kind === 'object') {
|
|
115
|
+
if (!Array.isArray(n.fields))
|
|
116
|
+
fail('ASSET_SCHEMA', `${path}.fields`, 'a list of fields');
|
|
117
|
+
checkFields(n.fields, `${path}.fields`);
|
|
118
|
+
}
|
|
119
|
+
else if (kind === 'list')
|
|
120
|
+
checkNode(n.item, `${path}.item`, false);
|
|
121
|
+
else if (kind === 'number') {
|
|
122
|
+
if (n.unit !== undefined && !figureUnitKnown(n.unit))
|
|
123
|
+
invalid('unit', `${String(n.unit)} is not a unit`);
|
|
124
|
+
for (const k of ['min', 'max', 'default'])
|
|
125
|
+
if (n[k] !== undefined && !finite(n[k]))
|
|
126
|
+
invalid(k, 'a finite number');
|
|
127
|
+
if (n.min !== undefined && n.max !== undefined && !(n.min <= n.max))
|
|
128
|
+
invalid('min', 'a range from a number to one no smaller');
|
|
129
|
+
if (n.default !== undefined && ((n.min !== undefined && n.default < n.min) || (n.max !== undefined && n.default > n.max)))
|
|
130
|
+
invalid('default', 'the default lies in the range');
|
|
76
131
|
}
|
|
77
132
|
else if (kind === 'boolean') {
|
|
78
|
-
if (typeof
|
|
133
|
+
if (n.default !== undefined && typeof n.default !== 'boolean')
|
|
79
134
|
invalid('default', 'true or false');
|
|
80
135
|
}
|
|
81
136
|
else {
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
137
|
+
if (n.values !== undefined) {
|
|
138
|
+
if (!Array.isArray(n.values) || !n.values.length || n.values.some((v) => typeof v !== 'string' || !v))
|
|
139
|
+
invalid('values', 'one or more nonempty strings');
|
|
140
|
+
if (new Set(n.values).size !== n.values.length)
|
|
141
|
+
invalid('values', 'each value once');
|
|
142
|
+
}
|
|
143
|
+
if (n.default !== undefined) {
|
|
144
|
+
if (typeof n.default !== 'string')
|
|
145
|
+
invalid('default', 'a string');
|
|
146
|
+
if (n.values && !n.values.includes(n.default))
|
|
147
|
+
invalid('default', 'the default is one of the values');
|
|
148
|
+
}
|
|
88
149
|
}
|
|
89
|
-
|
|
150
|
+
}
|
|
151
|
+
function checkFields(list, path) {
|
|
152
|
+
const names = new Set();
|
|
153
|
+
list.forEach((f, i) => {
|
|
154
|
+
checkNode(f, `${path}[${i}]`, true);
|
|
155
|
+
if (names.has(f.name))
|
|
156
|
+
refuse(`${path}[${i}].name`, `${f.name} is a field twice`, { reason: 'data-field-duplicate', field: `${path}[${i}]`, name: f.name });
|
|
157
|
+
names.add(f.name);
|
|
158
|
+
});
|
|
90
159
|
}
|
|
91
160
|
const targetsOf = (asset) => ({
|
|
92
161
|
states: new Map(asset.document.model.inputs.filter(i => i.role === 'state').map(i => [i.id, i])),
|
|
93
162
|
palette: asset.palette,
|
|
94
|
-
groups: new Map((asset.motionGroups ?? []).map(g => [g.id, g.clips]))
|
|
163
|
+
groups: new Map((asset.motionGroups ?? []).map(g => [g.id, g.clips])),
|
|
164
|
+
ports: new Map((asset.dataInterface?.ports ?? []).map(p => [p.id, p])),
|
|
165
|
+
parts: new Set(asset.appearance.map(a => a.target))
|
|
95
166
|
});
|
|
96
|
-
/** The one thing a
|
|
97
|
-
const writeKey = (
|
|
98
|
-
const
|
|
99
|
-
function
|
|
100
|
-
if (!
|
|
167
|
+
/** The one thing a binding writes, as a key no other binding may hold. Items are stacked, so several may fill them. */
|
|
168
|
+
const writeKey = (b) => b.kind === 'state' ? `state ${b.input}` : b.kind === 'palette' ? `palette ${b.token}` : b.kind === 'status' ? `status ${b.port}` : b.kind === 'pose' ? `pose ${b.axis}` : b.kind === 'items' ? null : `${b.kind} ${b.group}`;
|
|
169
|
+
const behaviorId = (b) => b.kind === 'state' ? b.input : b.kind === 'palette' ? b.token : b.kind === 'status' ? b.port : b.kind === 'pose' ? b.axis : b.kind === 'items' ? (b.slot ?? '') : b.group;
|
|
170
|
+
function checkBehavior(b, path) {
|
|
171
|
+
if (!b || typeof b !== 'object' || Array.isArray(b))
|
|
101
172
|
fail('ASSET_SCHEMA', path, 'object required');
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
173
|
+
const k = b.kind;
|
|
174
|
+
if (k === 'state')
|
|
175
|
+
fields(b, ['kind', 'input', 'sourceMode'], [], path);
|
|
176
|
+
else if (k === 'palette')
|
|
177
|
+
fields(b, ['kind', 'token'], [], path);
|
|
178
|
+
else if (k === 'motion-clip' || k === 'motion-speed' || k === 'motion-paused')
|
|
179
|
+
fields(b, ['kind', 'group'], [], path);
|
|
180
|
+
else if (k === 'status')
|
|
181
|
+
fields(b, ['kind', 'port'], [], path);
|
|
182
|
+
else if (k === 'pose')
|
|
183
|
+
fields(b, ['kind', 'axis'], ['seconds'], path);
|
|
184
|
+
else if (k === 'items')
|
|
185
|
+
fields(b, ['kind'], ['slot', 'id', 'type', 'place', 'stack', 'status'], path);
|
|
108
186
|
else
|
|
109
|
-
fail('ASSET_SCHEMA', `${path}.kind`, `${String(
|
|
110
|
-
if (
|
|
187
|
+
fail('ASSET_SCHEMA', `${path}.kind`, `${String(k)} is not a behavior; state, palette, motion-clip, motion-speed, motion-paused, status, pose or items`);
|
|
188
|
+
if (k === 'state' && b.sourceMode !== 'observed' && b.sourceMode !== 'commanded')
|
|
111
189
|
fail('ASSET_SCHEMA', `${path}.sourceMode`, 'observed or commanded');
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
190
|
+
if (k === 'pose' && !['x', 'y', 'z', 'heading'].includes(b.axis))
|
|
191
|
+
fail('ASSET_SCHEMA', `${path}.axis`, 'x, y, z or heading');
|
|
192
|
+
if (k === 'pose' && b.seconds !== undefined && !(finite(b.seconds) && b.seconds >= 0))
|
|
193
|
+
fail('ASSET_SCHEMA', `${path}.seconds`, 'seconds from 0');
|
|
194
|
+
for (const f of ['input', 'token', 'group', 'port', 'slot', 'id', 'type', 'place', 'stack', 'status'])
|
|
195
|
+
if (Object.hasOwn(b, f) && (typeof b[f] !== 'string' || !b[f]))
|
|
196
|
+
fail('ASSET_SCHEMA', `${path}.${f}`, 'a nonempty string');
|
|
197
|
+
return b;
|
|
116
198
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
199
|
+
/** The values a value node takes, when they are few: a closed text's values, both booleans. Null when open. */
|
|
200
|
+
function closedDomain(node) {
|
|
201
|
+
if (node.kind === 'boolean')
|
|
202
|
+
return [false, true];
|
|
203
|
+
if (node.kind === 'text' && node.values)
|
|
204
|
+
return node.values;
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
/** One interval of a `range` param, as the board's evaluator reads its key: `from~to`, either end left open. */
|
|
208
|
+
function intervalOf(key) {
|
|
209
|
+
const parts = key.split('~');
|
|
210
|
+
if (parts.length !== 2)
|
|
211
|
+
return null;
|
|
212
|
+
const [a, b] = parts;
|
|
213
|
+
const from = a === '' ? -Infinity : Number(a);
|
|
214
|
+
const to = b === '' ? Infinity : Number(b);
|
|
215
|
+
if ((a !== '' && !finite(from)) || (b !== '' && !finite(to)) || !(from < to))
|
|
216
|
+
return null;
|
|
217
|
+
return [from, to];
|
|
135
218
|
}
|
|
136
219
|
/**
|
|
137
|
-
* Whether
|
|
138
|
-
*
|
|
220
|
+
* Whether a binding's results are what its behavior takes, over the whole of what the value can be (the detail design's
|
|
221
|
+
* check, kept): a number's range, every closed value, both booleans -- never a sample.
|
|
139
222
|
*/
|
|
140
|
-
function
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
/*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const values = domainOf(property);
|
|
152
|
-
if (!values)
|
|
153
|
-
return domain('kind', `a lookup reads an enum or a boolean; ${property.id} is a number`);
|
|
154
|
-
if (target.kind === 'motion-speed')
|
|
155
|
-
return domain('kind', 'a motion speed is a number, given as it is or scaled');
|
|
156
|
-
/* Each allowed value once, and nothing else: a value no case covers would have no result. */
|
|
157
|
-
const whens = transform.cases.map(k => k.when);
|
|
158
|
-
for (const w of whens)
|
|
159
|
-
if (!values.some(v => v === w))
|
|
160
|
-
domain('cases', `${String(w)} is not a value of ${property.id}`, { value: w });
|
|
161
|
-
for (const v of values) {
|
|
162
|
-
const n = whens.filter(w => w === v).length;
|
|
163
|
-
if (n !== 1)
|
|
164
|
-
domain('cases', `${String(v)} of ${property.id} is covered ${n} times; once`, { value: v });
|
|
223
|
+
function checkRule(b, node, targets, path) {
|
|
224
|
+
const domain = (why, message, extra = {}) => refuse(path, message, { reason: 'data-rule-domain', binding: b.id, why, ...extra });
|
|
225
|
+
const behavior = b.behavior;
|
|
226
|
+
/* Is the result one the behavior takes? */
|
|
227
|
+
const output = (value) => {
|
|
228
|
+
if (behavior.kind === 'state') {
|
|
229
|
+
const input = targets.states.get(behavior.input);
|
|
230
|
+
if (!finite(value))
|
|
231
|
+
return domain('output', `a state input takes a number in ${input.unit}`, { value });
|
|
232
|
+
if (value < input.min || value > input.max)
|
|
233
|
+
domain('output', `${value} is outside ${behavior.input}, ${input.min} to ${input.max}`, { value, within: [input.min, input.max] });
|
|
165
234
|
}
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
235
|
+
else if (behavior.kind === 'palette') {
|
|
236
|
+
if (typeof value !== 'string' || !COLOUR.test(value))
|
|
237
|
+
domain('output', 'a colour is #RRGGBB', { value });
|
|
238
|
+
}
|
|
239
|
+
else if (behavior.kind === 'motion-clip') {
|
|
240
|
+
if (typeof value !== 'string' || !targets.groups.get(behavior.group).includes(value))
|
|
241
|
+
domain('output', `${String(value)} is not a clip of ${behavior.group}`, { value });
|
|
242
|
+
}
|
|
243
|
+
else if (behavior.kind === 'motion-speed') {
|
|
244
|
+
if (!finite(value) || value < 0 || value > FIGURE_MOTION_SPEED_MAX)
|
|
245
|
+
domain('output', `a motion speed is 0 to ${FIGURE_MOTION_SPEED_MAX}`, { value });
|
|
246
|
+
}
|
|
247
|
+
else if (behavior.kind === 'motion-paused') {
|
|
248
|
+
if (typeof value !== 'boolean')
|
|
249
|
+
domain('output', 'a pause is true or false', { value });
|
|
250
|
+
}
|
|
251
|
+
else if (behavior.kind === 'status') {
|
|
252
|
+
if (typeof value !== 'string' && !finite(value))
|
|
253
|
+
domain('output', 'a status is a code', { value });
|
|
254
|
+
}
|
|
255
|
+
else if (behavior.kind === 'pose') {
|
|
256
|
+
if (!finite(value))
|
|
257
|
+
domain('output', 'a position is a number', { value });
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const param = b.param;
|
|
261
|
+
if (behavior.kind === 'items') {
|
|
262
|
+
if (b.rule !== 'value' || param !== undefined)
|
|
263
|
+
domain('kind', 'items are read as they are: rule value, no param');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!isValue(node))
|
|
267
|
+
return domain('kind', `${b.accessor} is ${node.kind === 'list' ? 'a list' : 'an object'}; this behavior reads one value`);
|
|
268
|
+
if (b.rule === 'map') {
|
|
269
|
+
if (!param || typeof param !== 'object')
|
|
270
|
+
return domain('cases', 'a map gives a result for each value: { value: result, default }');
|
|
271
|
+
const closed = closedDomain(node);
|
|
272
|
+
const keys = Object.keys(param).filter(k => k !== 'default');
|
|
273
|
+
if (closed)
|
|
274
|
+
for (const k of keys)
|
|
275
|
+
if (!closed.some(v => String(v) === k))
|
|
276
|
+
domain('cases', `${k} is not a value of ${b.accessor}`, { value: k });
|
|
277
|
+
const covered = closed ? closed.every(v => Object.hasOwn(param, String(v))) : false;
|
|
278
|
+
if (!covered && !Object.hasOwn(param, 'default'))
|
|
279
|
+
domain('default', closed ? `not every value of ${b.accessor} has a result; give a default` : `${b.accessor} is open; give a default`);
|
|
280
|
+
for (const k of Object.keys(param))
|
|
281
|
+
output(param[k]);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (b.rule === 'range') {
|
|
285
|
+
if (node.kind !== 'number')
|
|
286
|
+
return domain('kind', `a range reads a number; ${b.accessor} is ${node.kind}`);
|
|
287
|
+
if (!param || typeof param !== 'object')
|
|
288
|
+
return domain('cases', "a range gives a result for each interval: { 'from~to': result, default }");
|
|
289
|
+
const intervals = [];
|
|
290
|
+
for (const k of Object.keys(param)) {
|
|
291
|
+
if (k === 'default')
|
|
292
|
+
continue;
|
|
293
|
+
const iv = intervalOf(k);
|
|
294
|
+
if (!iv)
|
|
295
|
+
domain('cases', `${k} is not an interval: from~to, either end may be left out`, { value: k });
|
|
296
|
+
intervals.push(iv);
|
|
183
297
|
}
|
|
298
|
+
if (!Object.hasOwn(param, 'default') && !covers(intervals, node.min ?? -Infinity, node.max ?? Infinity))
|
|
299
|
+
domain('default', `the intervals leave values of ${b.accessor} without a result; give a default`);
|
|
300
|
+
for (const k of Object.keys(param))
|
|
301
|
+
output(param[k]);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (b.rule !== 'value' && b.rule !== 'scale')
|
|
305
|
+
return domain('kind', `${String(b.rule)} is not a rule; value, map, range or scale`);
|
|
306
|
+
/* value and scale: the value itself, or scale × value + offset. */
|
|
307
|
+
let scale = 1;
|
|
308
|
+
let offset = 0;
|
|
309
|
+
if (b.rule === 'scale') {
|
|
310
|
+
if (!param || !finite(param.scale) || !finite(param.offset) || Object.keys(param).some(k => k !== 'scale' && k !== 'offset'))
|
|
311
|
+
return domain('cases', 'a scale is { scale, offset }, two finite numbers');
|
|
312
|
+
if (node.kind !== 'number')
|
|
313
|
+
return domain('kind', `a scale reads a number; ${b.accessor} is ${node.kind}`);
|
|
314
|
+
scale = param.scale;
|
|
315
|
+
offset = param.offset;
|
|
316
|
+
}
|
|
317
|
+
else if (param !== undefined)
|
|
318
|
+
return domain('cases', 'value takes no param');
|
|
319
|
+
if (behavior.kind === 'status') {
|
|
320
|
+
if (b.rule === 'scale')
|
|
321
|
+
domain('kind', 'a status is a code; it is read as it is or by a map');
|
|
322
|
+
if (node.kind === 'boolean')
|
|
323
|
+
domain('kind', 'a status is a code: a number or a text');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (behavior.kind === 'motion-clip' || behavior.kind === 'palette') {
|
|
327
|
+
if (b.rule === 'scale' || node.kind !== 'text' || !node.values)
|
|
328
|
+
return domain('kind', `${behavior.kind === 'palette' ? 'a colour' : 'a clip'} is read as it is only from a text of fixed values; otherwise by a map`);
|
|
329
|
+
for (const v of node.values)
|
|
330
|
+
output(v);
|
|
184
331
|
return;
|
|
185
332
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (target.kind === 'palette' || target.kind === 'motion-clip')
|
|
190
|
-
return domain('kind', `${target.kind === 'palette' ? 'a colour' : 'a clip'} is chosen by a lookup`);
|
|
191
|
-
const scale = transform.kind === 'affine' ? transform.scale : 1;
|
|
192
|
-
const offset = transform.kind === 'affine' ? transform.offset : 0;
|
|
193
|
-
const ends = [scale * property.min + offset, scale * property.max + offset].sort((a, b) => a - b);
|
|
194
|
-
if (target.kind === 'motion-speed') {
|
|
195
|
-
if (property.unit !== 'ratio')
|
|
196
|
-
domain('unit', `a motion speed is a ratio; ${property.id} is in ${property.unit}`, { from: property.unit, to: 'ratio' });
|
|
197
|
-
if (ends[0] < 0 || ends[1] > FIGURE_MOTION_SPEED_MAX)
|
|
198
|
-
domain('range', `${property.id} gives ${ends[0]} to ${ends[1]}; a motion speed is 0 to ${FIGURE_MOTION_SPEED_MAX}`, { min: ends[0], max: ends[1], within: [0, FIGURE_MOTION_SPEED_MAX] });
|
|
333
|
+
if (behavior.kind === 'motion-paused') {
|
|
334
|
+
if (b.rule === 'scale' || node.kind !== 'boolean')
|
|
335
|
+
domain('kind', 'a pause is read as it is only from true or false; otherwise by a map');
|
|
199
336
|
return;
|
|
200
337
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
338
|
+
/* state, motion-speed and pose read a number. */
|
|
339
|
+
if (node.kind !== 'number')
|
|
340
|
+
return domain('kind', `${b.accessor} is ${node.kind === 'text' ? 'a text' : 'true or false'}; this behavior reads a number, or a value by a map`);
|
|
341
|
+
if (behavior.kind === 'pose') {
|
|
342
|
+
const unit = behavior.axis === 'heading' ? 'deg' : 'mm';
|
|
343
|
+
if (b.rule === 'value' && node.unit !== unit)
|
|
344
|
+
domain('unit', `${b.accessor} is in ${node.unit ?? 'no unit'}; a ${behavior.axis === 'heading' ? 'heading' : 'position'} is in ${unit}; say the unit, or scale it`, { from: node.unit ?? '', to: unit });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (behavior.kind === 'motion-speed') {
|
|
348
|
+
if (b.rule === 'value' && node.unit !== undefined && node.unit !== 'ratio')
|
|
349
|
+
domain('unit', `a motion speed is a ratio; ${b.accessor} is in ${node.unit}`, { from: node.unit, to: 'ratio' });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
const input = targets.states.get(behavior.input);
|
|
353
|
+
/* As it is, only in the same unit: no unit is guessed. Another unit is a scale the author writes. */
|
|
354
|
+
if (b.rule === 'value' && node.unit !== input.unit)
|
|
355
|
+
domain('unit', `${b.accessor} is in ${node.unit ?? 'no unit'} and ${behavior.input} in ${input.unit}; say how one becomes the other`, { from: node.unit ?? '', to: input.unit });
|
|
356
|
+
}
|
|
357
|
+
const [lo, hi] = behavior.kind === 'motion-speed' ? [0, FIGURE_MOTION_SPEED_MAX] : [targets.states.get(behavior.input).min, targets.states.get(behavior.input).max];
|
|
358
|
+
if (node.min === undefined || node.max === undefined)
|
|
359
|
+
return domain('range', `${b.accessor} has no range; give it one inside ${lo} to ${hi}`, { within: [lo, hi] });
|
|
360
|
+
const ends = [scale * node.min + offset, scale * node.max + offset].sort((x, y) => x - y);
|
|
361
|
+
if (ends[0] < lo || ends[1] > hi)
|
|
362
|
+
domain('range', `${b.accessor} gives ${ends[0]} to ${ends[1]}; ${behaviorId(behavior)} takes ${lo} to ${hi}`, { min: ends[0], max: ends[1], within: [lo, hi] });
|
|
363
|
+
}
|
|
364
|
+
/** Whether intervals `from <= v < to` (the board evaluator's reading) leave no value from min to max, both included, without one. */
|
|
365
|
+
function covers(intervals, min, max) {
|
|
366
|
+
let at = min;
|
|
367
|
+
for (const [from, to] of [...intervals].sort((a, b) => a[0] - b[0])) {
|
|
368
|
+
if (from > at)
|
|
369
|
+
return false;
|
|
370
|
+
if (to > at)
|
|
371
|
+
at = to;
|
|
372
|
+
if (at === Infinity || at > max)
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
function checkBinding(b, schema, targets, path) {
|
|
378
|
+
fields(b, ['id', 'accessor', 'behavior', 'rule'], ['param'], path);
|
|
379
|
+
if (typeof b.id !== 'string' || !FIGURE_PART_ID.test(b.id))
|
|
380
|
+
refuse(`${path}.id`, `${String(b.id)} is not an id: a letter, then letters, digits, - and _`, { reason: 'data-id-invalid', list: 'bindings', id: String(b.id) });
|
|
381
|
+
const behavior = checkBehavior(b.behavior, `${path}.behavior`);
|
|
382
|
+
if (b.param !== undefined && (!b.param || typeof b.param !== 'object' || Array.isArray(b.param)))
|
|
383
|
+
fail('ASSET_SCHEMA', `${path}.param`, 'an object of results');
|
|
384
|
+
/* The behavior is there. */
|
|
385
|
+
const absent = (id) => refuse(`${path}.behavior`, `${id} is not a ${behavior.kind} of this figure`, { reason: 'data-behavior-absent', binding: b.id, behavior: behavior.kind, id });
|
|
386
|
+
if (behavior.kind === 'state' && !targets.states.has(behavior.input))
|
|
387
|
+
absent(behavior.input);
|
|
388
|
+
if (behavior.kind === 'palette' && !Object.hasOwn(targets.palette, behavior.token))
|
|
389
|
+
absent(behavior.token);
|
|
390
|
+
if ((behavior.kind === 'motion-clip' || behavior.kind === 'motion-speed' || behavior.kind === 'motion-paused') && !targets.groups.has(behavior.group))
|
|
391
|
+
absent(behavior.group);
|
|
392
|
+
if (behavior.kind === 'status' && !targets.ports.has(behavior.port))
|
|
393
|
+
absent(behavior.port);
|
|
394
|
+
/* The accessor goes down the schema to what the behavior reads. */
|
|
395
|
+
const accessorRefused = (accessor, why) => refuse(`${path}.accessor`, `${accessor}: ${why}`, { reason: 'data-accessor-invalid', binding: b.id, accessor, why });
|
|
396
|
+
const steps = parseFigureAccessor(b.accessor);
|
|
397
|
+
if (!steps)
|
|
398
|
+
accessorRefused(String(b.accessor), 'syntax');
|
|
399
|
+
const found = nodeAt(schema, steps);
|
|
400
|
+
if ('why' in found)
|
|
401
|
+
return accessorRefused(b.accessor, found.why);
|
|
402
|
+
if (behavior.kind === 'items') {
|
|
403
|
+
if (found.node.kind === 'list') {
|
|
404
|
+
const item = found.node.item;
|
|
405
|
+
if (!behavior.id)
|
|
406
|
+
refuse(`${path}.behavior.id`, 'items from a list say which field of an element is its id', { reason: 'data-accessor-invalid', binding: b.id, accessor: b.accessor, why: 'item-id-required' });
|
|
407
|
+
for (const f of ['id', 'type', 'place', 'stack', 'status']) {
|
|
408
|
+
const rel = behavior[f];
|
|
409
|
+
if (rel === undefined)
|
|
410
|
+
continue;
|
|
411
|
+
const relSteps = parseFigureAccessor(rel);
|
|
412
|
+
if (!relSteps)
|
|
413
|
+
accessorRefused(rel, 'syntax');
|
|
414
|
+
const at = nodeAt(item.kind === 'object' ? item.fields : [], relSteps);
|
|
415
|
+
if ('why' in at)
|
|
416
|
+
accessorRefused(`${b.accessor}[].${rel}`, at.why);
|
|
417
|
+
else if (!isValue(at.node))
|
|
418
|
+
accessorRefused(`${b.accessor}[].${rel}`, 'not-a-value');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else if (isValue(found.node)) {
|
|
422
|
+
if (behavior.id || behavior.type || behavior.place || behavior.stack || behavior.status)
|
|
423
|
+
refuse(`${path}.behavior`, 'one value is the id itself; element fields are for a list', { reason: 'data-accessor-invalid', binding: b.id, accessor: b.accessor, why: 'not-a-list' });
|
|
424
|
+
}
|
|
425
|
+
else
|
|
426
|
+
accessorRefused(b.accessor, 'not-a-value');
|
|
427
|
+
}
|
|
428
|
+
else if (found.node.kind === 'list')
|
|
429
|
+
accessorRefused(b.accessor, 'list-needs-index');
|
|
430
|
+
else if (!isValue(found.node))
|
|
431
|
+
accessorRefused(b.accessor, 'not-a-value');
|
|
432
|
+
checkRule(b, found.node, targets, path);
|
|
433
|
+
return b;
|
|
434
|
+
}
|
|
435
|
+
function checkPort(p, targets, path) {
|
|
436
|
+
fields(p, ['id', 'label', 'tokens', 'parts'], ['group'], path);
|
|
437
|
+
if (typeof p.id !== 'string' || !FIGURE_PART_ID.test(p.id))
|
|
438
|
+
refuse(`${path}.id`, `${String(p.id)} is not an id: a letter, then letters, digits, - and _`, { reason: 'data-id-invalid', list: 'ports', id: String(p.id) });
|
|
439
|
+
const invalid = (what, message, id) => refuse(`${path}.${what}`, message, { reason: 'data-port-invalid', port: p.id, what, ...(id !== undefined ? { id } : {}) });
|
|
440
|
+
if (typeof p.label !== 'string' || !p.label.trim())
|
|
441
|
+
invalid('label', 'a name shown to a person');
|
|
442
|
+
if (!Array.isArray(p.tokens) || !Array.isArray(p.parts))
|
|
443
|
+
fail('ASSET_SCHEMA', path, 'tokens and parts are lists');
|
|
444
|
+
if (!p.tokens.length && !p.parts.length)
|
|
445
|
+
invalid('token', 'a status port shows on a colour token, a part, or both');
|
|
446
|
+
for (const t of p.tokens)
|
|
447
|
+
if (typeof t !== 'string' || !Object.hasOwn(targets.palette, t))
|
|
448
|
+
invalid('token', `${String(t)} is not a colour of this figure`, String(t));
|
|
449
|
+
for (const part of p.parts)
|
|
450
|
+
if (typeof part !== 'string' || !targets.parts.has(part))
|
|
451
|
+
invalid('part', `${String(part)} is not a part of this figure`, String(part));
|
|
452
|
+
if (p.group !== undefined && (typeof p.group !== 'string' || !targets.groups.has(p.group)))
|
|
453
|
+
invalid('group', `${String(p.group)} is not a motion group of this figure`, String(p.group));
|
|
454
|
+
return p;
|
|
211
455
|
}
|
|
212
456
|
/**
|
|
213
457
|
* Refuses a data interface the contract does not allow (`DATA_INTERFACE`, or `ASSET_SCHEMA` for its shape). Called by
|
|
214
|
-
* `compileFigureAsset`, after the drivers and motion groups, whose clips a
|
|
458
|
+
* `compileFigureAsset`, after the drivers and motion groups, whose clips a binding may name.
|
|
215
459
|
*/
|
|
216
460
|
export function validateFigureDataInterface(asset) {
|
|
217
461
|
const di = asset.dataInterface;
|
|
218
462
|
if (di === undefined)
|
|
219
463
|
return;
|
|
220
|
-
fields(di, ['version', '
|
|
221
|
-
if (di.version !== 'figure-data-interface-
|
|
464
|
+
fields(di, ['version', 'schema', 'bindings'], ['ports'], 'dataInterface');
|
|
465
|
+
if (di.version !== 'figure-data-interface-2')
|
|
222
466
|
fail('VERSION', 'dataInterface.version', String(di.version));
|
|
223
|
-
if (!Array.isArray(di.
|
|
224
|
-
fail('ASSET_SCHEMA', 'dataInterface.
|
|
225
|
-
if (!Array.isArray(di.
|
|
226
|
-
fail('ASSET_SCHEMA', 'dataInterface.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const property = checkProperty(p, path);
|
|
231
|
-
if (properties.has(property.id))
|
|
232
|
-
refuse(`${path}.id`, `${property.id} is declared twice`, { reason: 'data-id-duplicate', list: 'properties', id: property.id });
|
|
233
|
-
properties.set(property.id, property);
|
|
234
|
-
});
|
|
467
|
+
if (!Array.isArray(di.schema))
|
|
468
|
+
fail('ASSET_SCHEMA', 'dataInterface.schema', 'a list of fields');
|
|
469
|
+
if (!Array.isArray(di.bindings))
|
|
470
|
+
fail('ASSET_SCHEMA', 'dataInterface.bindings', 'a list of bindings');
|
|
471
|
+
if (di.ports !== undefined && !Array.isArray(di.ports))
|
|
472
|
+
fail('ASSET_SCHEMA', 'dataInterface.ports', 'a list of status ports');
|
|
473
|
+
checkFields(di.schema, 'dataInterface.schema');
|
|
235
474
|
const targets = targetsOf(asset);
|
|
475
|
+
const portIds = new Set();
|
|
476
|
+
(di.ports ?? []).forEach((p, i) => {
|
|
477
|
+
const port = checkPort(p, targets, `dataInterface.ports[${i}]`);
|
|
478
|
+
if (portIds.has(port.id))
|
|
479
|
+
refuse(`dataInterface.ports[${i}].id`, `${port.id} is declared twice`, { reason: 'data-id-duplicate', list: 'ports', id: port.id });
|
|
480
|
+
portIds.add(port.id);
|
|
481
|
+
});
|
|
236
482
|
const ids = new Set();
|
|
237
483
|
const writers = new Map();
|
|
238
|
-
di.
|
|
239
|
-
const path = `dataInterface.
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
refuse(`${path}.id`, `${
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const target = checkTarget(c.target, `${path}.target`);
|
|
248
|
-
const property = typeof c.property === 'string' && properties.has(c.property) ? properties.get(c.property) : undefined;
|
|
249
|
-
if (!property)
|
|
250
|
-
refuse(`${path}.property`, `${String(c.property)} is not a property of this figure`, { reason: 'data-property-absent', connection: c.id, property: String(c.property) });
|
|
251
|
-
checkConnection(c, property, targets, path);
|
|
252
|
-
const key = writeKey(target);
|
|
484
|
+
di.bindings.forEach((b, i) => {
|
|
485
|
+
const path = `dataInterface.bindings[${i}]`;
|
|
486
|
+
const binding = checkBinding(b, di.schema, targets, path);
|
|
487
|
+
if (ids.has(binding.id))
|
|
488
|
+
refuse(`${path}.id`, `${binding.id} is declared twice`, { reason: 'data-id-duplicate', list: 'bindings', id: binding.id });
|
|
489
|
+
ids.add(binding.id);
|
|
490
|
+
const key = writeKey(binding.behavior);
|
|
491
|
+
if (key === null)
|
|
492
|
+
return;
|
|
253
493
|
const other = writers.get(key);
|
|
254
494
|
if (other !== undefined)
|
|
255
|
-
refuse(`${path}.
|
|
256
|
-
|
|
495
|
+
refuse(`${path}.behavior`, `${other} and ${binding.id} both write ${key}; one writer each`, {
|
|
496
|
+
reason: 'data-behavior-duplicate',
|
|
497
|
+
bindings: [other, binding.id],
|
|
498
|
+
behavior: binding.behavior.kind,
|
|
499
|
+
id: behaviorId(binding.behavior)
|
|
500
|
+
});
|
|
501
|
+
writers.set(key, binding.id);
|
|
257
502
|
});
|
|
258
503
|
}
|
|
259
|
-
/**
|
|
260
|
-
* The data interface of an asset, checked against it, ready to read values; null when the figure declares none. A
|
|
261
|
-
* consumer makes this once per asset, not per frame.
|
|
262
|
-
*/
|
|
504
|
+
/** The data interface of an asset, checked, with what the schema has at each binding; null when the figure declares none. */
|
|
263
505
|
export function compileFigureDataInterface(asset) {
|
|
264
506
|
if (asset.dataInterface === undefined)
|
|
265
507
|
return null;
|
|
266
508
|
validateFigureDataInterface(asset);
|
|
267
509
|
const di = structuredClone(asset.dataInterface);
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
510
|
+
const bindings = di.bindings.map(binding => {
|
|
511
|
+
const node = nodeAt(di.schema, parseFigureAccessor(binding.accessor)).node;
|
|
512
|
+
let itemNodes;
|
|
513
|
+
if (binding.behavior.kind === 'items' && node.kind === 'list') {
|
|
514
|
+
const item = node.item;
|
|
515
|
+
itemNodes = {};
|
|
516
|
+
for (const role of ['id', 'type', 'place', 'stack', 'status']) {
|
|
517
|
+
const rel = binding.behavior[role];
|
|
518
|
+
if (rel)
|
|
519
|
+
itemNodes[role] = nodeAt(item.kind === 'object' ? item.fields : [], parseFigureAccessor(rel)).node;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return Object.freeze({ binding: Object.freeze(binding), node, ...(itemNodes ? { itemNodes: Object.freeze(itemNodes) } : {}) });
|
|
523
|
+
});
|
|
524
|
+
const jsonFields = [];
|
|
525
|
+
const walk = (list, prefix) => {
|
|
526
|
+
for (const f of list) {
|
|
527
|
+
const at = prefix ? `${prefix}.${f.name}` : f.name;
|
|
528
|
+
if (f.json)
|
|
529
|
+
jsonFields.push(at);
|
|
530
|
+
let node = f;
|
|
531
|
+
let path = at;
|
|
532
|
+
while (node.kind === 'list') {
|
|
533
|
+
path = `${path}[]`;
|
|
534
|
+
node = node.item;
|
|
535
|
+
}
|
|
536
|
+
if (node.kind === 'object')
|
|
537
|
+
walk(node.fields, path);
|
|
289
538
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
539
|
+
};
|
|
540
|
+
walk(di.schema, '');
|
|
541
|
+
return Object.freeze({ schema: Object.freeze(di.schema), ports: Object.freeze(di.ports ?? []), bindings: Object.freeze(bindings), jsonFields: Object.freeze(jsonFields) });
|
|
542
|
+
}
|
|
543
|
+
/** The schema as lines, parents before their fields, with the bindings that read each place. */
|
|
544
|
+
export function figureDataRows(di) {
|
|
545
|
+
const rows = [];
|
|
546
|
+
/* Every place a binding reads, as a row path: an accessor's indices read as `[]`, an item field under its list. */
|
|
547
|
+
const readers = new Map();
|
|
548
|
+
const note = (path, id) => readers.set(path, [...(readers.get(path) ?? []), id]);
|
|
549
|
+
const asRow = (accessor) => accessor.replace(/\[\d+\]/g, '[]');
|
|
550
|
+
for (const b of di.bindings) {
|
|
551
|
+
note(asRow(b.accessor), b.id);
|
|
552
|
+
if (b.behavior.kind === 'items')
|
|
553
|
+
for (const rel of [b.behavior.id, b.behavior.type, b.behavior.place, b.behavior.stack, b.behavior.status])
|
|
554
|
+
if (rel)
|
|
555
|
+
note(`${asRow(b.accessor)}[].${asRow(rel)}`, b.id);
|
|
293
556
|
}
|
|
294
|
-
|
|
557
|
+
const walk = (list, prefix, depth) => {
|
|
558
|
+
for (const f of list) {
|
|
559
|
+
const path = prefix ? `${prefix}.${f.name}` : f.name;
|
|
560
|
+
rows.push({ path, depth, name: f.name, ...(f.label ? { label: f.label } : {}), node: f, json: !!f.json, bindings: readers.get(path) ?? [] });
|
|
561
|
+
let node = f;
|
|
562
|
+
let at = path;
|
|
563
|
+
while (node.kind === 'list') {
|
|
564
|
+
at = `${at}[]`;
|
|
565
|
+
node = node.item;
|
|
566
|
+
}
|
|
567
|
+
if (node.kind === 'object')
|
|
568
|
+
walk(node.fields, at, depth + 1);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
walk(di.schema, '', 0);
|
|
572
|
+
return rows;
|
|
295
573
|
}
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
574
|
+
/** A value for a node, for an example: its default, else what it may take first. */
|
|
575
|
+
function exampleOf(node) {
|
|
576
|
+
if (node.kind === 'object')
|
|
577
|
+
return Object.fromEntries(node.fields.map(f => [f.name, exampleOf(f)]));
|
|
578
|
+
if (node.kind === 'list')
|
|
579
|
+
return [exampleOf(node.item)];
|
|
580
|
+
if (node.kind === 'number')
|
|
581
|
+
return node.default ?? (node.min !== undefined && node.max !== undefined ? node.min : node.min ?? node.max ?? 0);
|
|
582
|
+
if (node.kind === 'boolean')
|
|
583
|
+
return node.default ?? false;
|
|
584
|
+
return node.default ?? node.values?.[0] ?? '';
|
|
585
|
+
}
|
|
586
|
+
/** Data with every field of the schema, each at its default or a value it takes: what a person copies and changes. */
|
|
587
|
+
export function figureDataExample(di) {
|
|
588
|
+
return exampleOf({ kind: 'object', fields: di.schema });
|
|
589
|
+
}
|
|
590
|
+
/** A node guessed from one value of a sample. */
|
|
591
|
+
function inferNode(value) {
|
|
592
|
+
if (typeof value === 'string') {
|
|
593
|
+
const t = value.trim();
|
|
594
|
+
if ((t.startsWith('[') && t.endsWith(']')) || (t.startsWith('{') && t.endsWith('}'))) {
|
|
595
|
+
try {
|
|
596
|
+
const parsed = JSON.parse(t);
|
|
597
|
+
if (parsed && typeof parsed === 'object')
|
|
598
|
+
return { node: inferNode(parsed).node, json: true };
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
/* a text that only looks like JSON */
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return { node: { kind: 'text' } };
|
|
308
605
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
[values[id], sources[id]] = [plan.defaults[id], 'default'];
|
|
606
|
+
if (typeof value === 'number')
|
|
607
|
+
return { node: { kind: 'number' } };
|
|
608
|
+
if (typeof value === 'boolean')
|
|
609
|
+
return { node: { kind: 'boolean' } };
|
|
610
|
+
if (Array.isArray(value)) {
|
|
611
|
+
/* Every element's fields, so a field only some elements carry is not lost. */
|
|
612
|
+
const objects = value.filter(v => v && typeof v === 'object' && !Array.isArray(v));
|
|
613
|
+
if (objects.length)
|
|
614
|
+
return { node: { kind: 'list', item: { kind: 'object', fields: inferFields(Object.assign({}, ...objects)) } } };
|
|
615
|
+
return { node: { kind: 'list', item: value.length ? inferNode(value[0]).node : { kind: 'text' } } };
|
|
320
616
|
}
|
|
321
|
-
|
|
617
|
+
if (value && typeof value === 'object')
|
|
618
|
+
return { node: { kind: 'object', fields: inferFields(value) } };
|
|
619
|
+
return { node: { kind: 'text' } };
|
|
620
|
+
}
|
|
621
|
+
function inferFields(sample) {
|
|
622
|
+
return Object.keys(sample)
|
|
623
|
+
.filter(k => FIELD_NAME.test(k))
|
|
624
|
+
.map(k => {
|
|
625
|
+
const { node, json } = inferNode(sample[k]);
|
|
626
|
+
return { name: k, ...node, ...(json ? { json } : {}) };
|
|
627
|
+
});
|
|
322
628
|
}
|
|
323
629
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
630
|
+
* A schema guessed from one piece of a site's data: each field's kind as it came, a JSON text read as what it holds.
|
|
631
|
+
* Units, ranges and fixed values are not guessed -- a person says them. A field name the accessor cannot read bare is
|
|
632
|
+
* left out.
|
|
327
633
|
*/
|
|
328
|
-
export function
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const out = { states: Object.create(null), palette: Object.create(null), motion: Object.create(null) };
|
|
333
|
-
for (const c of plan.connections) {
|
|
334
|
-
const raw = (Object.hasOwn(values, c.property) ? values[c.property] : plan.defaults[c.property]);
|
|
335
|
-
const t = c.transform;
|
|
336
|
-
const value = t.kind === 'identity' ? raw : t.kind === 'affine' ? t.scale * raw + t.offset : t.cases.find(k => k.when === raw).value;
|
|
337
|
-
const target = c.target;
|
|
338
|
-
if (target.kind === 'state')
|
|
339
|
-
out.states[target.input] = { value: value, sourceMode: target.sourceMode };
|
|
340
|
-
else if (target.kind === 'palette')
|
|
341
|
-
out.palette[target.token] = value;
|
|
342
|
-
else if (target.kind === 'motion-clip')
|
|
343
|
-
out.motion[target.group] = { ...out.motion[target.group], clip: value };
|
|
344
|
-
else
|
|
345
|
-
out.motion[target.group] = { ...out.motion[target.group], speed: value };
|
|
346
|
-
}
|
|
347
|
-
return out;
|
|
634
|
+
export function inferFigureDataSchema(sample) {
|
|
635
|
+
if (!sample || typeof sample !== 'object' || Array.isArray(sample))
|
|
636
|
+
return [];
|
|
637
|
+
return inferFields(sample);
|
|
348
638
|
}
|
|
349
|
-
|
|
639
|
+
/* ---------------------------------------------------------------- renames */
|
|
640
|
+
/** The interface after a part was renamed: a state input the part owned (`<part>.…`) and a status port's part follow it. */
|
|
350
641
|
export function renameDataInterface(asset, from, to) {
|
|
351
642
|
if (!asset.dataInterface)
|
|
352
643
|
return;
|
|
353
644
|
const moved = (id) => (id === from ? to : id.startsWith(`${from}.`) ? to + id.slice(from.length) : id);
|
|
354
645
|
asset.dataInterface = {
|
|
355
646
|
...asset.dataInterface,
|
|
356
|
-
|
|
647
|
+
...(asset.dataInterface.ports ? { ports: asset.dataInterface.ports.map(p => ({ ...p, parts: p.parts.map(moved) })) } : {}),
|
|
648
|
+
bindings: asset.dataInterface.bindings.map(b => (b.behavior.kind === 'state' ? { ...b, behavior: { ...b.behavior, input: moved(b.behavior.input) } } : b))
|
|
357
649
|
};
|
|
358
650
|
}
|
|
359
|
-
/** The interface after a clip was renamed: every
|
|
651
|
+
/** The interface after a clip was renamed: every map that selects it in a motion group selects it by its new name. */
|
|
360
652
|
export function renameDataInterfaceClip(asset, group, from, to) {
|
|
361
653
|
if (!asset.dataInterface)
|
|
362
654
|
return;
|
|
363
655
|
asset.dataInterface = {
|
|
364
656
|
...asset.dataInterface,
|
|
365
|
-
|
|
366
|
-
? { ...
|
|
367
|
-
:
|
|
657
|
+
bindings: asset.dataInterface.bindings.map(b => b.behavior.kind === 'motion-clip' && b.behavior.group === group && b.param && (b.rule === 'map' || b.rule === 'range')
|
|
658
|
+
? { ...b, param: Object.fromEntries(Object.entries(b.param).map(([k, v]) => [k, v === from ? to : v])) }
|
|
659
|
+
: b)
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* The interface after a field was renamed: the field, and every accessor that goes through it, including an items
|
|
664
|
+
* binding's element fields. `accessor` names the field by its field names (no list indices); `to` is its new name.
|
|
665
|
+
*/
|
|
666
|
+
export function renameDataField(di, accessor, to) {
|
|
667
|
+
const steps = parseFigureAccessor(accessor);
|
|
668
|
+
if (!steps || steps.some(s => 'index' in s))
|
|
669
|
+
return fail('TARGET_ABSENT', accessor, `${accessor} does not name a field`);
|
|
670
|
+
if (typeof to !== 'string' || !FIELD_NAME.test(to))
|
|
671
|
+
refuse('to', `${String(to)} is not a field name`, { reason: 'data-field-invalid', field: accessor, what: 'name' });
|
|
672
|
+
const names = steps.map(s => s.field);
|
|
673
|
+
const out = structuredClone(di);
|
|
674
|
+
/* The field itself, down the schema through objects and lists. */
|
|
675
|
+
let list = out.schema;
|
|
676
|
+
names.forEach((name, i) => {
|
|
677
|
+
const f = list.find(x => x.name === name);
|
|
678
|
+
if (!f)
|
|
679
|
+
fail('TARGET_ABSENT', accessor, `${accessor} is not a field of this schema`);
|
|
680
|
+
if (i === names.length - 1) {
|
|
681
|
+
if (to !== name && list.some(x => x.name === to))
|
|
682
|
+
refuse('to', `${to} is a field already`, { reason: 'data-field-duplicate', field: accessor, name: to });
|
|
683
|
+
f.name = to;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
let node = f;
|
|
687
|
+
while (node.kind === 'list')
|
|
688
|
+
node = node.item;
|
|
689
|
+
if (node.kind !== 'object')
|
|
690
|
+
fail('TARGET_ABSENT', accessor, `${accessor} is not a field of this schema`);
|
|
691
|
+
list = node.fields;
|
|
692
|
+
});
|
|
693
|
+
/*
|
|
694
|
+
An accessor goes through the field when its field names, after `outer` (the list an element field sits in), start
|
|
695
|
+
with the renamed field's names. Its indices stay where they are.
|
|
696
|
+
*/
|
|
697
|
+
const through = (acc, outer = []) => {
|
|
698
|
+
const s = parseFigureAccessor(acc);
|
|
699
|
+
if (!s)
|
|
700
|
+
return acc;
|
|
701
|
+
let n = outer.length;
|
|
702
|
+
return figureAccessorOf(s.map(step => {
|
|
703
|
+
if ('index' in step)
|
|
704
|
+
return step;
|
|
705
|
+
const at = n++;
|
|
706
|
+
const prefix = [...outer, ...s.filter(x => 'field' in x).map(x => x.field)];
|
|
707
|
+
return at === names.length - 1 && names.every((name, i) => prefix[i] === name) ? { field: to } : step;
|
|
708
|
+
}));
|
|
368
709
|
};
|
|
710
|
+
out.bindings = out.bindings.map(b => {
|
|
711
|
+
const next = { ...b, accessor: through(b.accessor) };
|
|
712
|
+
if (b.behavior.kind !== 'items')
|
|
713
|
+
return next;
|
|
714
|
+
const outer = (parseFigureAccessor(b.accessor) ?? []).filter(x => 'field' in x).map(x => x.field);
|
|
715
|
+
const behavior = { ...b.behavior };
|
|
716
|
+
for (const role of ['id', 'type', 'place', 'stack', 'status'])
|
|
717
|
+
if (behavior[role])
|
|
718
|
+
behavior[role] = through(behavior[role], outer);
|
|
719
|
+
return { ...next, behavior };
|
|
720
|
+
});
|
|
721
|
+
return out;
|
|
369
722
|
}
|
|
370
723
|
//# sourceMappingURL=data-interface.js.map
|