@things-factory/figure-ui 10.1.223 → 10.1.224

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.
Files changed (59) hide show
  1. package/client/modeller/canvas.ts +2 -2
  2. package/client/modeller/consumer-capabilities.ts +66 -0
  3. package/client/modeller/data-interface.ts +230 -80
  4. package/client/modeller/editor.ts +14 -7
  5. package/client/modeller/figure-capability-panel.ts +145 -1
  6. package/client/modeller/figure-data-panel.ts +495 -188
  7. package/client/modeller/figure-links.ts +130 -12
  8. package/client/modeller/figure-preview.ts +19 -2
  9. package/client/modeller/figure-slot-layout-panel.ts +7 -3
  10. package/client/modeller/links.ts +58 -0
  11. package/client/modeller/refusals.ts +12 -4
  12. package/client/modeller/slot-layout-host.ts +76 -5
  13. package/dist-client/modeller/canvas.js +2 -2
  14. package/dist-client/modeller/canvas.js.map +1 -1
  15. package/dist-client/modeller/consumer-capabilities.d.ts +29 -0
  16. package/dist-client/modeller/consumer-capabilities.js +60 -0
  17. package/dist-client/modeller/consumer-capabilities.js.map +1 -0
  18. package/dist-client/modeller/data-interface.d.ts +83 -17
  19. package/dist-client/modeller/data-interface.js +194 -82
  20. package/dist-client/modeller/data-interface.js.map +1 -1
  21. package/dist-client/modeller/editor.d.ts +5 -2
  22. package/dist-client/modeller/editor.js +16 -5
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-capability-panel.d.ts +12 -0
  25. package/dist-client/modeller/figure-capability-panel.js +123 -1
  26. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-data-panel.d.ts +57 -13
  28. package/dist-client/modeller/figure-data-panel.js +461 -180
  29. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  30. package/dist-client/modeller/figure-links.d.ts +11 -0
  31. package/dist-client/modeller/figure-links.js +137 -16
  32. package/dist-client/modeller/figure-links.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +4 -0
  34. package/dist-client/modeller/figure-preview.js +19 -1
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-slot-layout-panel.js +6 -4
  37. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -1
  38. package/dist-client/modeller/links.d.ts +22 -0
  39. package/dist-client/modeller/links.js +48 -0
  40. package/dist-client/modeller/links.js.map +1 -1
  41. package/dist-client/modeller/refusals.js +14 -6
  42. package/dist-client/modeller/refusals.js.map +1 -1
  43. package/dist-client/modeller/slot-layout-host.d.ts +28 -3
  44. package/dist-client/modeller/slot-layout-host.js +68 -6
  45. package/dist-client/modeller/slot-layout-host.js.map +1 -1
  46. package/dist-client/tsconfig.tsbuildinfo +1 -1
  47. package/package.json +7 -7
  48. package/test/browser/data-table-page.js +9 -0
  49. package/test/browser/links.check.mjs +25 -3
  50. package/test/consumer-capabilities.test.ts +116 -0
  51. package/test/data-motion.test.ts +94 -27
  52. package/test/links.test.ts +15 -0
  53. package/test/slot-layout-host.test.ts +63 -5
  54. package/translations/en.json +81 -23
  55. package/translations/es.json +81 -23
  56. package/translations/ja.json +81 -23
  57. package/translations/ko.json +81 -23
  58. package/translations/ms.json +81 -23
  59. package/translations/zh.json +81 -23
@@ -1,6 +1,6 @@
1
1
  /*
2
- * The figure's data and what it moves (ADR-0051 §8, data binding design appendix A): what the data tab offers and
3
- * shows. Every rule is figure-model's -- which accessor holds, which rule's results a behavior takes -- and the reading
2
+ * The figure's data and what it moves (ADR-0051 §8, ADR-0166): what the data tab offers and shows -- one binding, its
3
+ * method as the field's kind allows (as it is, scaled, a code table, a range table). Every rule is figure-model's -- which accessor holds, which rule's results a behavior takes -- and the reading
4
4
  * of data is things-scene's; this only lists what there is to choose, drafts a binding the kernel then checks, and edits
5
5
  * the schema as a tree. Kept apart from the element so it is tested without a browser.
6
6
  */
@@ -10,16 +10,14 @@ export const behaviorKeyOf = (b) => b.kind === 'state'
10
10
  ? `state:${b.input}`
11
11
  : b.kind === 'palette'
12
12
  ? `palette:${b.token}`
13
- : b.kind === 'status'
14
- ? `status:${b.port}`
15
- : b.kind === 'pose'
16
- ? `pose:${b.axis}`
17
- : b.kind === 'items'
18
- ? `items:${b.slot ?? ''}`
19
- : `${b.kind}:${b.group}`;
13
+ : b.kind === 'pose'
14
+ ? `pose:${b.axis}`
15
+ : b.kind === 'items'
16
+ ? `items:${b.slot ?? ''}`
17
+ : `${b.kind}:${b.group}`;
20
18
  /**
21
- * Everything the figure can do that data may drive: each control, each colour, each motion group's clip, speed and
22
- * pause, each status port, its position and heading, and -- when it has places -- the items in them.
19
+ * Everything a value binding may drive: each control, each colour, each motion group's clip, speed and pause, its
20
+ * position and heading, and -- when it has places -- the items in them.
23
21
  */
24
22
  export function behaviorsOf(asset) {
25
23
  const out = [];
@@ -32,12 +30,10 @@ export function behaviorsOf(asset) {
32
30
  for (const token of Object.keys(asset.palette))
33
31
  add({ kind: 'palette', token }, token);
34
32
  for (const group of asset.motionGroups ?? []) {
35
- add({ kind: 'motion-clip', group: group.id }, group.label);
33
+ add({ kind: 'motion-clip', group: group.id }, group.label, { clips: [...group.clips] });
36
34
  add({ kind: 'motion-speed', group: group.id }, group.label, { min: 0, max: FIGURE_MOTION_SPEED_MAX });
37
35
  add({ kind: 'motion-paused', group: group.id }, group.label);
38
36
  }
39
- for (const port of asset.dataInterface?.ports ?? [])
40
- add({ kind: 'status', port: port.id }, port.label);
41
37
  for (const axis of ['x', 'z', 'y', 'heading'])
42
38
  add({ kind: 'pose', axis, seconds: 1 }, axis, { unit: axis === 'heading' ? 'deg' : 'mm' });
43
39
  if (asset.document.capabilities.slotLayout || asset.document.capabilities.names?.includes('Holdable'))
@@ -45,22 +41,41 @@ export function behaviorsOf(asset) {
45
41
  return out;
46
42
  }
47
43
  const isValue = (n) => n.kind === 'number' || n.kind === 'boolean' || n.kind === 'text';
48
- /** Whether a field of this kind can drive the behavior, by some rule -- the choices offered for it. */
44
+ /**
45
+ * Whether a field of this kind drives the behavior as it is or scaled -- the choices a value binding offers for it. A
46
+ * value that picks a look per code is a table (ADR-0166 2).
47
+ */
49
48
  export function fitsField(node, choice) {
50
49
  if (choice.kind === 'items')
51
50
  return node.kind === 'list' || node.kind === 'text' || node.kind === 'number';
52
51
  if (!isValue(node))
53
52
  return false;
54
- if (choice.kind === 'pose')
55
- return node.kind === 'number';
56
- if (choice.kind === 'motion-speed')
57
- return node.kind === 'number' || node.kind === 'boolean';
58
53
  if (choice.kind === 'motion-paused')
59
- return node.kind === 'boolean' || (node.kind === 'text' && !!node.values);
60
- if (choice.kind === 'status')
61
- return node.kind !== 'boolean';
62
- return true;
54
+ return node.kind === 'boolean';
55
+ if (choice.kind === 'palette')
56
+ return node.kind === 'text' && !!node.values?.every(v => /^#[0-9a-f]{6}$/i.test(v));
57
+ if (choice.kind === 'motion-clip') {
58
+ const clips = choice.behavior.kind === 'motion-clip' ? choice.clips : undefined;
59
+ return node.kind === 'text' && !!node.values && (!clips || node.values.every(v => clips.includes(v)));
60
+ }
61
+ return node.kind === 'number';
62
+ }
63
+ /**
64
+ * The methods a field allows (ADR-0166 2): a number as it is, scaled or by a range table; a text or true/false by a code
65
+ * table, or as it is where a behavior takes it so; a list as it is (its items). A new field is made by a table.
66
+ */
67
+ export function methodsOf(node, choices) {
68
+ if (!node)
69
+ return ['code', 'ranges'];
70
+ const fitting = choices.some(c => fitsField(node, c));
71
+ if (node.kind === 'number')
72
+ return [...(fitting ? ['value', 'scale'] : []), 'ranges'];
73
+ if (node.kind === 'text' || node.kind === 'boolean')
74
+ return [...(fitting ? ['value'] : []), 'code'];
75
+ return fitting ? ['value'] : [];
63
76
  }
77
+ /** The method a binding is shown by. */
78
+ export const methodOf = (b) => (b.rule === 'table' ? (b.lookup === 'code' ? 'code' : 'ranges') : b.rule);
64
79
  /** An id not taken among `taken`: `base`, then `base-2`, `base-3`, … made into the kernel's id shape. */
65
80
  export function freeIdOf(taken, base) {
66
81
  const used = new Set(taken);
@@ -79,19 +94,15 @@ const TO_DEG = { deg: 1, rad: 180 / Math.PI };
79
94
  /** The values a closed field takes: a closed text's values, both booleans. Empty when open. */
80
95
  export const domainOf = (node) => (node.kind === 'boolean' ? [false, true] : node.kind === 'text' && node.values ? [...node.values] : []);
81
96
  /**
82
- * A first binding of a field to a behavior, for the author to adjust: the value as it is when it already fits, a scale
83
- * when only its unit or range differs, one result per value (a map) for a closed field, a default for an open one.
84
- * Each result starts at what the behavior shows now -- the control's rest value, the colour, the group's default clip --
85
- * or, for a clip, the clip of the value's own name. The kernel says whether it holds.
97
+ * A first value binding of a field to a behavior, for the author to adjust: the value as it is when it already fits, a
98
+ * scale when only its unit or range differs -- or the method the author chose. The kernel says whether it holds.
86
99
  */
87
- export function bindingDraftOf(asset, accessor, node, choice) {
100
+ export function bindingDraftOf(asset, accessor, node, choice, method) {
88
101
  const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id);
89
102
  const name = accessor.replace(/\[\d+\]/g, '').split('.').pop() ?? accessor;
90
103
  const id = freeIdOf(taken, `${name}-${choice.kind === 'state' ? choice.behavior.input : choice.kind}`);
91
104
  const behavior = choice.behavior;
92
105
  const make = (rule, param) => ({ id, accessor, behavior, rule, ...(param ? { param } : {}) });
93
- const domain = domainOf(node);
94
- const caseMap = (each, fallback) => Object.fromEntries([...domain.map(v => [String(v), each(v)]), ...(domain.length ? [] : [['default', fallback]])]);
95
106
  if (behavior.kind === 'items') {
96
107
  if (node.kind !== 'list')
97
108
  return make('value');
@@ -103,53 +114,141 @@ export function bindingDraftOf(asset, accessor, node, choice) {
103
114
  const stack = pick(/stack/i, /level/i);
104
115
  return { id, accessor, behavior: { kind: 'items', ...(itemId ? { id: itemId } : {}), ...(type ? { type } : {}), ...(place ? { place } : {}), ...(stack ? { stack } : {}) }, rule: 'value' };
105
116
  }
106
- if (behavior.kind === 'status')
117
+ if (node.kind !== 'number')
107
118
  return make('value');
108
- if (behavior.kind === 'palette') {
109
- const now = asset.palette[behavior.token] ?? '#000000';
110
- if (node.kind === 'number')
111
- return make('range', { default: now });
112
- return make('map', caseMap(() => now, now));
113
- }
114
- if (behavior.kind === 'motion-clip') {
115
- const group = (asset.motionGroups ?? []).find(g => g.id === behavior.group);
116
- const clips = group?.clips ?? [];
117
- if (node.kind === 'text' && node.values && node.values.every(v => clips.includes(v)))
118
- return make('value');
119
- return make('map', caseMap(v => (clips.includes(String(v)) ? String(v) : (group?.defaultClip ?? '')), group?.defaultClip ?? ''));
120
- }
121
- if (behavior.kind === 'motion-paused') {
122
- if (node.kind === 'boolean')
123
- return make('value');
124
- return make('map', caseMap(() => false, false));
125
- }
126
119
  if (behavior.kind === 'pose') {
127
- if (node.kind !== 'number')
128
- return make('value');
129
120
  const want = behavior.axis === 'heading' ? TO_DEG : TO_MM;
130
121
  const unit = node.unit ?? '';
131
- if (unit === (behavior.axis === 'heading' ? 'deg' : 'mm'))
122
+ if (method === 'value' || unit === (behavior.axis === 'heading' ? 'deg' : 'mm') || (method === undefined && !(unit in want)))
132
123
  return make('value');
133
- return unit in want ? make('scale', { scale: want[unit], offset: 0 }) : make('value');
124
+ return make('scale', { scale: want[unit] ?? 1, offset: 0 });
134
125
  }
135
126
  /* state and motion-speed: a number into a range. */
136
127
  const [lo, hi, unit] = behavior.kind === 'motion-speed' ? [0, FIGURE_MOTION_SPEED_MAX, 'ratio'] : [choice.min ?? 0, choice.max ?? 1, choice.unit ?? ''];
137
- if (node.kind === 'number') {
138
- const sameUnit = behavior.kind === 'motion-speed' ? node.unit === undefined || node.unit === 'ratio' : node.unit === unit;
139
- const inside = node.min !== undefined && node.max !== undefined && node.min >= lo && node.max <= hi;
140
- if (sameUnit && inside)
141
- return make('value');
142
- if (node.min !== undefined && node.max !== undefined && node.max > node.min) {
143
- /* The field's range laid onto the behavior's. */
144
- const scale = (hi - lo) / (node.max - node.min);
145
- return make('scale', { scale: round(scale), offset: round(lo - scale * node.min) });
146
- }
128
+ const sameUnit = behavior.kind === 'motion-speed' ? node.unit === undefined || node.unit === 'ratio' : node.unit === unit;
129
+ const inside = node.min !== undefined && node.max !== undefined && node.min >= lo && node.max <= hi;
130
+ if (method === 'value' || (method === undefined && sameUnit && inside))
147
131
  return make('value');
132
+ if (node.min !== undefined && node.max !== undefined && node.max > node.min) {
133
+ /* The field's range laid onto the behavior's. */
134
+ const scale = (hi - lo) / (node.max - node.min);
135
+ return make('scale', { scale: round(scale), offset: round(lo - scale * node.min) });
148
136
  }
149
- const rest = behavior.kind === 'motion-speed' ? 1 : (asset.stateDefaults[behavior.input] ?? lo);
150
- if (node.kind === 'boolean')
151
- return make('map', { false: behavior.kind === 'motion-speed' ? 0 : lo, true: behavior.kind === 'motion-speed' ? 1 : hi });
152
- return make('map', caseMap(() => rest, rest));
137
+ return method === 'scale' ? make('scale', { scale: 1, offset: 0 }) : make('value');
138
+ }
139
+ /* ---------------------------------------------------------------- tables (ADR-0166 3) */
140
+ /** The first code a new code table names, and the next ones: `CODE`, `CODE2`, … */
141
+ export function freeCodeOf(taken) {
142
+ if (!taken.includes('CODE'))
143
+ return 'CODE';
144
+ let n = 2;
145
+ while (taken.includes(`CODE${n}`))
146
+ n++;
147
+ return `CODE${n}`;
148
+ }
149
+ /** A legend role from a binding's id: lower case letters, digits and `-`. */
150
+ export const legendRoleOf = (id) => id
151
+ .toLowerCase()
152
+ .replace(/[^a-z0-9-]+/g, '-')
153
+ .replace(/^-+|-+$/g, '') || 'legend';
154
+ /**
155
+ * A first table on a field (ADR-0166 10): a row per value of a closed field, an open field's rows typed later, one
156
+ * interval over the whole of a number. A new field is made by the table (P2): a code table's a text whose values are the
157
+ * table's keys, starting with one code; a range table's a number. Returns the binding and, for a new field, the schema
158
+ * with it.
159
+ */
160
+ export function tableDraftOf(asset, field, method, targets) {
161
+ const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id);
162
+ const made = 'name' in field;
163
+ const name = made ? field.name : (field.path.replace(/\[\]/g, '').split('.').pop() ?? field.path);
164
+ const id = freeIdOf(taken, name.toLowerCase());
165
+ const node = made ? (method === 'code' ? { kind: 'text', values: ['CODE'] } : { kind: 'number' }) : field.node;
166
+ const accessor = made ? field.name : accessorOfRow(field.path);
167
+ const table = method === 'code' ? Object.fromEntries(domainOf(node).map(v => [String(v), {}])) : { [`${node.kind === 'number' ? (node.min ?? '') : ''}~`]: {} };
168
+ const binding = { id, accessor, rule: 'table', lookup: method, targets: tidyTargets(targets), table };
169
+ if (!made)
170
+ return { binding };
171
+ const schema = [...(asset.dataInterface?.schema ?? []), { name: field.name, ...node }];
172
+ return { binding, schema };
173
+ }
174
+ /** Targets with empty lists left out, as the kernel keeps them. */
175
+ const tidyTargets = (t) => ({
176
+ ...(t.parts?.length ? { parts: [...t.parts] } : {}),
177
+ ...(t.inputs?.length ? { inputs: [...t.inputs] } : {}),
178
+ ...(t.group ? { group: t.group } : {})
179
+ });
180
+ /** A look with only the columns its targets take: a target taken away takes its columns with it. */
181
+ function lookFor(look, t) {
182
+ const out = {};
183
+ if (t.parts?.length) {
184
+ if (look.color !== undefined)
185
+ out.color = look.color;
186
+ if (look.effect !== undefined)
187
+ out.effect = look.effect;
188
+ }
189
+ if (t.inputs?.length && look.values) {
190
+ const values = Object.fromEntries(Object.entries(look.values).filter(([id]) => t.inputs.includes(id)));
191
+ if (Object.keys(values).length)
192
+ out.values = values;
193
+ }
194
+ if (t.group) {
195
+ if (look.clip !== undefined)
196
+ out.clip = look.clip;
197
+ if (look.speed !== undefined)
198
+ out.speed = look.speed;
199
+ if (look.paused !== undefined)
200
+ out.paused = look.paused;
201
+ }
202
+ return out;
203
+ }
204
+ /** The table with these targets: each look keeps only the columns they take. */
205
+ export function tableWithTargets(b, targets) {
206
+ const t = tidyTargets(targets);
207
+ return { ...b, targets: t, table: Object.fromEntries(Object.entries(b.table).map(([k, look]) => [k, lookFor(look, t)])) };
208
+ }
209
+ /** The table with one look set: the whole look given, a column left out cleared. */
210
+ export const tableWithLook = (b, key, look) => ({ ...b, table: { ...b.table, [key]: lookFor(look, b.targets) } });
211
+ /** The table's rows renamed, added or taken away, in order; `default` kept last. */
212
+ export function tableWithKeys(b, rows) {
213
+ const d = b.table.default;
214
+ return { ...b, table: { ...Object.fromEntries(rows), ...(d !== undefined ? { default: d } : {}) } };
215
+ }
216
+ /**
217
+ * The columns a table's targets give its looks, as the row editor shows them: colour and effect for parts, a value per
218
+ * state input (its unit and range), the clip, speed and pause of the group.
219
+ */
220
+ export function tableColumnsOf(asset, b) {
221
+ const out = [];
222
+ if (b.targets.parts?.length)
223
+ out.push({ kind: 'color' }, { kind: 'effect' });
224
+ for (const id of b.targets.inputs ?? []) {
225
+ const input = asset.document.model.inputs.find(i => i.id === id);
226
+ out.push({ kind: 'value', id, label: asset.stateInputs?.[id]?.label ?? id, unit: input?.unit, min: input?.min, max: input?.max });
227
+ }
228
+ if (b.targets.group) {
229
+ const group = (asset.motionGroups ?? []).find(g => g.id === b.targets.group);
230
+ out.push({ kind: 'clip', clips: group?.clips ?? [] }, { kind: 'speed' }, { kind: 'paused' });
231
+ }
232
+ return out;
233
+ }
234
+ /** The state inputs a table may give values to: each control, with its label. */
235
+ export const stateInputsOf = (asset) => asset.document.model.inputs.filter(i => i.role === 'state').map(i => ({ id: i.id, label: asset.stateInputs?.[i.id]?.label ?? i.id }));
236
+ /**
237
+ * A code table's field follows its keys (ADR-0166 risk 3): when the field is a closed text, its values are the table's
238
+ * codes. The schema with the field so, or undefined when the field is open or already so.
239
+ */
240
+ export function schemaFollowingCodes(asset, b, codes) {
241
+ const schema = asset.dataInterface?.schema ?? [];
242
+ const path = b.accessor.replace(/\[\d+\]/g, '[]');
243
+ const field = fieldAt(schema, path);
244
+ if (field?.kind !== 'text' || !field.values || !codes.length)
245
+ return undefined;
246
+ if (field.values.length === codes.length && field.values.every((v, i) => v === codes[i]))
247
+ return undefined;
248
+ const next = { ...field, values: [...codes] };
249
+ if (next.default !== undefined && !codes.includes(next.default))
250
+ delete next.default;
251
+ return schemaWithFieldReplaced(schema, path, next);
153
252
  }
154
253
  const round = (v) => Math.round(v * 1e6) / 1e6;
155
254
  /* ---------------------------------------------------------------- the schema as a tree */
@@ -225,12 +324,19 @@ export const schemaRowsOf = (asset) => (asset.dataInterface ? figureDataRows(ass
225
324
  /* ---------------------------------------------------------------- reading a sample */
226
325
  /**
227
326
  * What a sample of data would do on this figure, read the one way a placed figure reads it (things-scene's figure
228
- * inputs): what it moves, or why it is refused, each value's reason. Nothing here reads data itself.
327
+ * inputs): what it moves, or why it is refused, each value's reason; each table's look and where it came from, and what
328
+ * of a legend was left out. `legends`: sample legends by role, as a scene's legend of the role's name would be.
229
329
  */
230
- export function sampleOf(asset, sample) {
330
+ export function sampleOf(asset, sample, legends = {}) {
231
331
  const inputs = new FigureInputs(asset);
332
+ inputs.setThemes({ legends: Object.fromEntries(Object.entries(legends).map(([role, legend]) => [role, { legend, source: 'domain', name: role }])) });
232
333
  const taken = inputs.setData(sample);
233
- return { issues: taken.accepted ? [] : (taken.issues[0]?.issues ?? []), result: inputs.data };
334
+ return {
335
+ issues: taken.accepted ? [] : (taken.issues[0]?.issues ?? []),
336
+ result: inputs.data,
337
+ uses: inputs.tableUses,
338
+ notes: inputs.diagnostics().filter(i => i.severity === 'note')
339
+ };
234
340
  }
235
341
  /** The rows a person gives a value by hand, as a board's saved values: one value, read by a binding, not inside a list. */
236
342
  export const valueRowsOf = (asset) => schemaRowsOf(asset).filter(r => isValue(r.node) && r.bindings.length > 0 && !r.path.includes('[]'));
@@ -242,14 +348,8 @@ export function typedValueOf(node, typed) {
242
348
  return typed === 'true';
243
349
  return typed;
244
350
  }
245
- /** The parts a status port may show on: the parts the figure's appearance names. */
351
+ /** The parts a table may show on: the parts the figure's appearance names. */
246
352
  export const partsOf = (asset) => [...new Set(asset.appearance.map(a => a.target))];
247
- /** A first status port: the label as given, on the first colour token, no part yet. The kernel says whether it holds. */
248
- export function portDraftOf(asset, label) {
249
- const taken = (asset.dataInterface?.ports ?? []).map(p => p.id);
250
- const token = Object.keys(asset.palette)[0];
251
- return { id: freeIdOf(taken, label.trim() || 'status'), label: label.trim(), tokens: token ? [token] : [], parts: [] };
252
- }
253
353
  /** Whether a text is an accessor the kernel takes. */
254
354
  export const accessorTaken = (accessor) => parseFigureAccessor(accessor) !== null;
255
355
  /**
@@ -259,6 +359,17 @@ export const accessorTaken = (accessor) => parseFigureAccessor(accessor) !== nul
259
359
  export function motionWrittenByData(asset) {
260
360
  const out = {};
261
361
  for (const b of asset.dataInterface?.bindings ?? []) {
362
+ if (b.rule === 'table') {
363
+ if (!b.targets.group)
364
+ continue;
365
+ const one = (out[b.targets.group] ??= { clip: false, speed: false });
366
+ const looks = Object.values(b.table);
367
+ if (looks.some(l => l.clip !== undefined))
368
+ one.clip = true;
369
+ if (looks.some(l => l.speed !== undefined))
370
+ one.speed = true;
371
+ continue;
372
+ }
262
373
  if (b.behavior.kind !== 'motion-clip' && b.behavior.kind !== 'motion-speed')
263
374
  continue;
264
375
  const one = (out[b.behavior.group] ??= { clip: false, speed: false });
@@ -287,7 +398,7 @@ export function endlessJointsOf(asset) {
287
398
  /**
288
399
  * The rotation card's commands (data binding design decision 1, 「회전」): a clip that turns the joint once around and
289
400
  * keeps adding the turn (wrap, accumulating), a reverse one when a direction is given, the motion group holding them,
290
- * and the bindings -- on to the group's pause, speed to its speed, direction to its clip. Nothing new runs them: they
401
+ * and the bindings -- on, by a table, to the group's pause, speed to its speed, direction, by a table, to its clip. Nothing new runs them: they
291
402
  * are the drivers, the group and the bindings any author could write, and stay editable one by one.
292
403
  *
293
404
  * The clip lasts `T = 3 × 60 / max rpm` seconds, so the most the field gives plays at the group's top speed, 3 -- the
@@ -338,7 +449,7 @@ export function rotationStepsOf(asset, c) {
338
449
  return { kind: 'set-data-binding', binding: { id, ...binding } };
339
450
  };
340
451
  if (c.on)
341
- steps.push(bind(`${group}-on`, { accessor: c.on, behavior: { kind: 'motion-paused', group }, rule: 'map', param: { true: false, false: true } }));
452
+ steps.push(bind(`${group}-on`, { accessor: c.on, rule: 'table', lookup: 'code', targets: { group }, table: { true: { paused: false }, false: { paused: true } } }));
342
453
  if (c.speed)
343
454
  steps.push(bind(`${group}-speed`, { accessor: c.speed.path, behavior: { kind: 'motion-speed', group }, rule: 'scale', param: { scale: round(seconds / 60), offset: 0 } }));
344
455
  if (c.direction) {
@@ -346,9 +457,10 @@ export function rotationStepsOf(asset, c) {
346
457
  const covered = values.every(v => v === c.direction.forward || v === c.direction.reverse);
347
458
  steps.push(bind(`${group}-direction`, {
348
459
  accessor: c.direction.path,
349
- behavior: { kind: 'motion-clip', group },
350
- rule: 'map',
351
- param: { [c.direction.forward]: forward, [c.direction.reverse]: reverse, ...(covered ? {} : { default: forward }) }
460
+ rule: 'table',
461
+ lookup: 'code',
462
+ targets: { group },
463
+ table: { [c.direction.forward]: { clip: forward }, [c.direction.reverse]: { clip: reverse }, ...(covered ? {} : { default: { clip: forward } }) }
352
464
  }));
353
465
  }
354
466
  return steps;