@things-factory/figure-ui 10.1.162 → 10.1.164

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 (35) hide show
  1. package/client/modeller/data-interface.ts +334 -79
  2. package/client/modeller/editor.ts +4 -3
  3. package/client/modeller/figure-data-panel.ts +683 -193
  4. package/client/modeller/figure-preview.ts +46 -30
  5. package/client/modeller/refusals.ts +18 -14
  6. package/client/viewparts/figure-detail-dock.ts +9 -3
  7. package/dist-client/modeller/data-interface.d.ts +91 -30
  8. package/dist-client/modeller/data-interface.js +314 -71
  9. package/dist-client/modeller/data-interface.js.map +1 -1
  10. package/dist-client/modeller/editor.d.ts +1 -1
  11. package/dist-client/modeller/editor.js +3 -2
  12. package/dist-client/modeller/editor.js.map +1 -1
  13. package/dist-client/modeller/figure-data-panel.d.ts +42 -13
  14. package/dist-client/modeller/figure-data-panel.js +679 -186
  15. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  16. package/dist-client/modeller/figure-preview.d.ts +7 -5
  17. package/dist-client/modeller/figure-preview.js +47 -30
  18. package/dist-client/modeller/figure-preview.js.map +1 -1
  19. package/dist-client/modeller/refusals.d.ts +2 -2
  20. package/dist-client/modeller/refusals.js +24 -18
  21. package/dist-client/modeller/refusals.js.map +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/dist-client/viewparts/figure-detail-dock.js +9 -3
  24. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  25. package/package.json +7 -7
  26. package/test/browser/build.mjs +9 -0
  27. package/test/browser/detail-tabs-page.js +36 -0
  28. package/test/browser/detail-tabs.check.mjs +123 -0
  29. package/test/data-motion.test.ts +173 -53
  30. package/translations/en.json +122 -43
  31. package/translations/es.json +122 -43
  32. package/translations/ja.json +122 -43
  33. package/translations/ko.json +122 -43
  34. package/translations/ms.json +122 -43
  35. package/translations/zh.json +122 -43
@@ -1,127 +1,382 @@
1
1
  /*
2
- * The figure's public data properties and what each moves (ADR-0051 §2.2): what the data tab offers and shows. Every
3
- * rule is figure-model's -- which transform a property may take, whether a connection holds, what a sample moves; this
4
- * only lists what there is to choose and drafts a first connection the kernel then checks. Kept apart from the element so
5
- * it is tested without a browser.
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
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
+ * the schema as a tree. Kept apart from the element so it is tested without a browser.
6
6
  */
7
- import { compileFigureDataInterface, evaluateFigureDataInterface, validateFigureDataValues, FIGURE_PART_ID } from '@hatiolab/figure-model'
8
- import type { FigureAsset, FigureDataConnection, FigureDataProperty, FigureDataResult, FigureDataTarget, FigureDataValue, FigureDataValueIssue } from '@hatiolab/figure-model'
7
+ import { FIGURE_PART_ID, FIGURE_MOTION_SPEED_MAX, FigureContractError, figureDataRows, parseFigureAccessor } from '@hatiolab/figure-model'
8
+ import type { FigureAsset, FigureDataBehavior, FigureDataBinding, FigureDataField, FigureDataNode, FigureDataRow, FigureDataValue, FigureDataValueIssue, FigureProposalStep, FigureStatusPort } from '@hatiolab/figure-model'
9
+ import { FigureInputs, type FigureDataResult } from '@hatiolab/things-scene'
9
10
 
10
- /** Something a connection can write, with what the screen shows of it. */
11
- export interface DataTargetChoice {
12
- /** One string per target, for a select's value. */
11
+ /** Something a binding can move, with what the screen shows of it. */
12
+ export interface BehaviorChoice {
13
+ /** One string per behavior, for a select's value. */
13
14
  key: string
14
- target: FigureDataTarget
15
- /** The control's label, the colour token, or the group's label. */
15
+ behavior: FigureDataBehavior
16
+ /** The screen's word for the kind: a control, a colour, a group's clip, … */
17
+ kind: FigureDataBehavior['kind']
18
+ /** The control's label, the colour token, the group's label, the port's label, the axis. */
16
19
  name: string
17
20
  unit?: string
18
21
  min?: number
19
22
  max?: number
20
23
  }
21
24
 
22
- export const targetKeyOf = (t: FigureDataTarget): string =>
23
- t.kind === 'state' ? `state:${t.input}` : t.kind === 'palette' ? `palette:${t.token}` : `${t.kind}:${t.group}`
25
+ export const behaviorKeyOf = (b: FigureDataBehavior): string =>
26
+ b.kind === 'state'
27
+ ? `state:${b.input}`
28
+ : b.kind === 'palette'
29
+ ? `palette:${b.token}`
30
+ : b.kind === 'status'
31
+ ? `status:${b.port}`
32
+ : b.kind === 'pose'
33
+ ? `pose:${b.axis}`
34
+ : b.kind === 'items'
35
+ ? `items:${b.slot ?? ''}`
36
+ : `${b.kind}:${b.group}`
24
37
 
25
38
  /**
26
- * What a property of this kind can be connected to: a number moves a control or a group's speed; an enum or a boolean
27
- * moves a control, a colour or which clip of a group plays (through a case per value). A control is offered observed: a
28
- * value read from the machine applies as it comes.
39
+ * Everything the figure can do that data may drive: each control, each colour, each motion group's clip, speed and
40
+ * pause, each status port, its position and heading, and -- when it has places -- the items in them.
29
41
  */
30
- export function dataTargetsOf(asset: FigureAsset, kind: FigureDataProperty['kind']): DataTargetChoice[] {
31
- const out: DataTargetChoice[] = []
42
+ export function behaviorsOf(asset: FigureAsset): BehaviorChoice[] {
43
+ const out: BehaviorChoice[] = []
44
+ const add = (behavior: FigureDataBehavior, name: string, more: Partial<BehaviorChoice> = {}) => out.push({ key: behaviorKeyOf(behavior), behavior, kind: behavior.kind, name, ...more })
32
45
  for (const input of asset.document.model.inputs) {
33
46
  if (input.role !== 'state') continue
34
- const target: FigureDataTarget = { kind: 'state', input: input.id, sourceMode: 'observed' }
35
- out.push({ key: targetKeyOf(target), target, name: asset.stateInputs?.[input.id]?.label ?? input.id, unit: input.unit, min: input.min, max: input.max })
47
+ add({ kind: 'state', input: input.id, sourceMode: 'observed' }, asset.stateInputs?.[input.id]?.label ?? input.id, { unit: input.unit, min: input.min, max: input.max })
36
48
  }
37
- if (kind !== 'number')
38
- for (const token of Object.keys(asset.palette)) {
39
- const target: FigureDataTarget = { kind: 'palette', token }
40
- out.push({ key: targetKeyOf(target), target, name: token })
41
- }
49
+ for (const token of Object.keys(asset.palette)) add({ kind: 'palette', token }, token)
42
50
  for (const group of asset.motionGroups ?? []) {
43
- const target: FigureDataTarget = kind === 'number' ? { kind: 'motion-speed', group: group.id } : { kind: 'motion-clip', group: group.id }
44
- out.push({ key: targetKeyOf(target), target, name: group.label })
51
+ add({ kind: 'motion-clip', group: group.id }, group.label)
52
+ add({ kind: 'motion-speed', group: group.id }, group.label, { min: 0, max: FIGURE_MOTION_SPEED_MAX })
53
+ add({ kind: 'motion-paused', group: group.id }, group.label)
45
54
  }
55
+ for (const port of asset.dataInterface?.ports ?? []) add({ kind: 'status', port: port.id }, port.label)
56
+ for (const axis of ['x', 'z', 'y', 'heading'] as const) add({ kind: 'pose', axis, seconds: 1 }, axis, { unit: axis === 'heading' ? 'deg' : 'mm' })
57
+ if (asset.document.capabilities.slotLayout || asset.document.capabilities.names?.includes('Holdable')) add({ kind: 'items' }, asset.document.capabilities.slotLayout?.label ?? 'items')
46
58
  return out
47
59
  }
48
60
 
49
- /** The values a lookup has a case for: an enum's values, or both booleans. Nothing for a number. */
50
- export const domainOf = (property: FigureDataProperty): FigureDataValue[] =>
51
- property.kind === 'enum' ? [...property.values] : property.kind === 'boolean' ? [false, true] : []
61
+ const isValue = (n: FigureDataNode): n is Extract<FigureDataNode, { kind: 'number' | 'boolean' | 'text' }> => n.kind === 'number' || n.kind === 'boolean' || n.kind === 'text'
62
+
63
+ /** Whether a field of this kind can drive the behavior, by some rule -- the choices offered for it. */
64
+ export function fitsField(node: FigureDataNode, choice: BehaviorChoice): boolean {
65
+ if (choice.kind === 'items') return node.kind === 'list' || node.kind === 'text' || node.kind === 'number'
66
+ if (!isValue(node)) return false
67
+ if (choice.kind === 'pose') return node.kind === 'number'
68
+ if (choice.kind === 'motion-speed') return node.kind === 'number' || node.kind === 'boolean'
69
+ if (choice.kind === 'motion-paused') return node.kind === 'boolean' || (node.kind === 'text' && !!node.values)
70
+ if (choice.kind === 'status') return node.kind !== 'boolean'
71
+ return true
72
+ }
52
73
 
53
74
  /** An id not taken among `taken`: `base`, then `base-2`, `base-3`, … made into the kernel's id shape. */
54
- export function freeDataIdOf(taken: Iterable<string>, base: string): string {
75
+ export function freeIdOf(taken: Iterable<string>, base: string): string {
55
76
  const used = new Set(taken)
56
- const cleaned = base.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^[^a-zA-Z]+/, '').replace(/-+$/, '') || 'data'
57
- const id = FIGURE_PART_ID.test(cleaned) ? cleaned : 'data'
77
+ const cleaned = base.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^[^a-zA-Z]+/, '').replace(/-+$/, '') || 'binding'
78
+ const id = FIGURE_PART_ID.test(cleaned) ? cleaned : 'binding'
58
79
  if (!used.has(id)) return id
59
80
  let n = 2
60
81
  while (used.has(`${id}-${n}`)) n++
61
82
  return `${id}-${n}`
62
83
  }
63
84
 
85
+ /** How many mm, or degrees, one of a unit is: only lengths and angles are converted. */
86
+ const TO_MM: Record<string, number> = { mm: 1, cm: 10, m: 1000 }
87
+ const TO_DEG: Record<string, number> = { deg: 1, rad: 180 / Math.PI }
88
+
89
+ /** The values a closed field takes: a closed text's values, both booleans. Empty when open. */
90
+ export const domainOf = (node: FigureDataNode): FigureDataValue[] => (node.kind === 'boolean' ? [false, true] : node.kind === 'text' && node.values ? [...node.values] : [])
91
+
64
92
  /**
65
- * A first connection of a property to a target, for the author to adjust: a number as it is in the control's own unit,
66
- * or scaled from another; an enum or a boolean case by case, each case at what the target shows now -- the control's
67
- * rest value, the colour, the group's first clip. The kernel says whether it holds.
93
+ * 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
94
+ * when only its unit or range differs, one result per value (a map) for a closed field, a default for an open one.
95
+ * Each result starts at what the behavior shows now -- the control's rest value, the colour, the group's default clip --
96
+ * or, for a clip, the clip of the value's own name. The kernel says whether it holds.
68
97
  */
69
- export function connectionDraftOf(asset: FigureAsset, property: FigureDataProperty, choice: DataTargetChoice): FigureDataConnection {
70
- const taken = (asset.dataInterface?.connections ?? []).map(c => c.id)
71
- const id = freeDataIdOf(taken, `${property.id}-${choice.target.kind === 'state' ? choice.target.input : choice.target.kind === 'palette' ? 'colour' : choice.target.group}`)
72
- const target = choice.target
73
- if (property.kind === 'number') {
74
- const same = target.kind === 'motion-speed' || (target.kind === 'state' && choice.unit === property.unit)
75
- return { id, property: property.id, transform: same ? { kind: 'identity' } : { kind: 'affine', scale: 1, offset: 0 }, target }
98
+ export function bindingDraftOf(asset: FigureAsset, accessor: string, node: FigureDataNode, choice: BehaviorChoice): FigureDataBinding {
99
+ const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id)
100
+ const name = accessor.replace(/\[\d+\]/g, '').split('.').pop() ?? accessor
101
+ const id = freeIdOf(taken, `${name}-${choice.kind === 'state' ? (choice.behavior as { input: string }).input : choice.kind}`)
102
+ const behavior = choice.behavior
103
+ const make = (rule: FigureDataBinding['rule'], param?: FigureDataBinding['param']): FigureDataBinding => ({ id, accessor, behavior, rule, ...(param ? { param } : {}) })
104
+ const domain = domainOf(node)
105
+ const caseMap = (each: (v: FigureDataValue) => FigureDataValue, fallback: FigureDataValue) => Object.fromEntries([...domain.map(v => [String(v), each(v)] as const), ...(domain.length ? [] : [['default', fallback] as const])])
106
+
107
+ if (behavior.kind === 'items') {
108
+ if (node.kind !== 'list') return make('value')
109
+ const fields = node.item.kind === 'object' ? node.item.fields : []
110
+ const pick = (...words: RegExp[]) => fields.find(f => isValue(f) && words.some(w => w.test(f.name)))?.name
111
+ const itemId = pick(/^id$/i, /name$/i, /id$/i) ?? fields.find(isValue)?.name
112
+ const type = pick(/type$/i)
113
+ const place = pick(/^slot/i, /place/i, /port/i)
114
+ const stack = pick(/stack/i, /level/i)
115
+ return { id, accessor, behavior: { kind: 'items', ...(itemId ? { id: itemId } : {}), ...(type ? { type } : {}), ...(place ? { place } : {}), ...(stack ? { stack } : {}) }, rule: 'value' }
76
116
  }
77
- const now: FigureDataValue =
78
- target.kind === 'state'
79
- ? (asset.stateDefaults[target.input] ?? 0)
80
- : target.kind === 'palette'
81
- ? (asset.palette[target.token] ?? '#000000')
82
- : ((asset.motionGroups ?? []).find(g => g.id === target.group)?.defaultClip ?? '')
83
- return { id, property: property.id, transform: { kind: 'lookup', cases: domainOf(property).map(when => ({ when, value: now })) }, target }
84
- }
85
-
86
- /** What a property looks like when first made: a number 0 to 1, a boolean off, an enum of two values. */
87
- export function propertyDraftOf(asset: FigureAsset, label: string, kind: FigureDataProperty['kind']): FigureDataProperty {
88
- const id = freeDataIdOf((asset.dataInterface?.properties ?? []).map(p => p.id), label)
89
- if (kind === 'number') return { id, label, kind, unit: 'ratio', min: 0, max: 1, default: 0 }
90
- if (kind === 'boolean') return { id, label, kind, default: false }
91
- return { id, label, kind, values: ['off', 'on'], default: 'off' }
92
- }
93
-
94
- /** A value typed in a sample field, as the property takes it: a number, true or false, or the enum value. */
95
- export function sampleValueOf(property: FigureDataProperty, typed: string): FigureDataValue {
96
- if (property.kind === 'number') return typed.trim() === '' ? Number.NaN : Number(typed)
97
- if (property.kind === 'boolean') return typed === 'true'
98
- return typed
117
+ if (behavior.kind === 'status') return make('value')
118
+ if (behavior.kind === 'palette') {
119
+ const now = asset.palette[behavior.token] ?? '#000000'
120
+ if (node.kind === 'number') return make('range', { default: now })
121
+ return make('map', caseMap(() => now, now))
122
+ }
123
+ if (behavior.kind === 'motion-clip') {
124
+ const group = (asset.motionGroups ?? []).find(g => g.id === behavior.group)
125
+ const clips = group?.clips ?? []
126
+ if (node.kind === 'text' && node.values && node.values.every(v => clips.includes(v))) return make('value')
127
+ return make('map', caseMap(v => (clips.includes(String(v)) ? String(v) : (group?.defaultClip ?? '')), group?.defaultClip ?? ''))
128
+ }
129
+ if (behavior.kind === 'motion-paused') {
130
+ if (node.kind === 'boolean') return make('value')
131
+ return make('map', caseMap(() => false, false))
132
+ }
133
+ if (behavior.kind === 'pose') {
134
+ if (node.kind !== 'number') return make('value')
135
+ const want = behavior.axis === 'heading' ? TO_DEG : TO_MM
136
+ const unit = node.unit ?? ''
137
+ if (unit === (behavior.axis === 'heading' ? 'deg' : 'mm')) return make('value')
138
+ return unit in want ? make('scale', { scale: want[unit]!, offset: 0 }) : make('value')
139
+ }
140
+ /* state and motion-speed: a number into a range. */
141
+ const [lo, hi, unit] =
142
+ behavior.kind === 'motion-speed' ? [0, FIGURE_MOTION_SPEED_MAX, 'ratio'] : [choice.min ?? 0, choice.max ?? 1, choice.unit ?? '']
143
+ if (node.kind === 'number') {
144
+ const sameUnit = behavior.kind === 'motion-speed' ? node.unit === undefined || node.unit === 'ratio' : node.unit === unit
145
+ const inside = node.min !== undefined && node.max !== undefined && node.min >= lo && node.max <= hi
146
+ if (sameUnit && inside) return make('value')
147
+ if (node.min !== undefined && node.max !== undefined && node.max > node.min) {
148
+ /* The field's range laid onto the behavior's. */
149
+ const scale = (hi - lo) / (node.max - node.min)
150
+ return make('scale', { scale: round(scale), offset: round(lo - scale * node.min) })
151
+ }
152
+ return make('value')
153
+ }
154
+ const rest = behavior.kind === 'motion-speed' ? 1 : (asset.stateDefaults[(behavior as { input: string }).input] ?? lo)
155
+ if (node.kind === 'boolean') return make('map', { false: behavior.kind === 'motion-speed' ? 0 : lo, true: behavior.kind === 'motion-speed' ? 1 : hi })
156
+ return make('map', caseMap(() => rest, rest))
157
+ }
158
+ const round = (v: number) => Math.round(v * 1e6) / 1e6
159
+
160
+ /* ---------------------------------------------------------------- the schema as a tree */
161
+
162
+ /** A row's path (`L[].F`) as the accessor a value binding reads (`L[0].F`): the first element of each list. */
163
+ export const accessorOfRow = (path: string): string => path.replace(/\[\]/g, '[0]')
164
+
165
+ /** The field at a row's path, or undefined. */
166
+ export function fieldAt(schema: readonly FigureDataField[], path: string): FigureDataField | undefined {
167
+ let list: readonly FigureDataField[] = schema
168
+ let found: FigureDataField | undefined
169
+ for (const part of path.split('.')) {
170
+ const name = part.replace(/\[\]/g, '')
171
+ found = list.find(f => f.name === name)
172
+ if (!found) return undefined
173
+ let node: FigureDataNode = found
174
+ while (node.kind === 'list') node = node.item
175
+ list = node.kind === 'object' ? node.fields : []
176
+ }
177
+ return found
178
+ }
179
+
180
+ /** The fields under a row's path, where a new field goes: the top, an object's fields, a list's element's fields. */
181
+ function fieldsUnder(schema: FigureDataField[], path: string): FigureDataField[] | undefined {
182
+ if (!path) return schema
183
+ const field = fieldAt(schema, path)
184
+ if (!field) return undefined
185
+ let node: FigureDataNode = field
186
+ while (node.kind === 'list') node = node.item
187
+ return node.kind === 'object' ? node.fields : undefined
188
+ }
189
+
190
+ /** The schema with a field added under a path ('' for the top): a copy, the given one left as it is. */
191
+ export function schemaWithField(schema: readonly FigureDataField[], under: string, field: FigureDataField): FigureDataField[] {
192
+ const out = structuredClone(schema) as FigureDataField[]
193
+ fieldsUnder(out, under)?.push(field)
194
+ return out
195
+ }
196
+
197
+ /** The schema with the field at a path replaced (its name kept unless the new one says another). */
198
+ export function schemaWithFieldReplaced(schema: readonly FigureDataField[], path: string, field: FigureDataField): FigureDataField[] {
199
+ const out = structuredClone(schema) as FigureDataField[]
200
+ const parent = path.includes('.') ? path.slice(0, path.lastIndexOf('.')) : ''
201
+ const list = fieldsUnder(out, parent)
202
+ const name = path.split('.').pop()!.replace(/\[\]/g, '')
203
+ const at = list?.findIndex(f => f.name === name) ?? -1
204
+ if (list && at >= 0) list[at] = field
205
+ return out
206
+ }
207
+
208
+ /** The schema without the field at a path. */
209
+ export function schemaWithoutField(schema: readonly FigureDataField[], path: string): FigureDataField[] {
210
+ const out = structuredClone(schema) as FigureDataField[]
211
+ const parent = path.includes('.') ? path.slice(0, path.lastIndexOf('.')) : ''
212
+ const list = fieldsUnder(out, parent)
213
+ const name = path.split('.').pop()!.replace(/\[\]/g, '')
214
+ const at = list?.findIndex(f => f.name === name) ?? -1
215
+ if (list && at >= 0) list.splice(at, 1)
216
+ return out
99
217
  }
100
218
 
219
+ /** A field of another kind, its name, label and json flag kept, the rest started fresh. */
220
+ export function fieldOfKind(field: FigureDataField, kind: FigureDataNode['kind']): FigureDataField {
221
+ const head = { name: field.name, ...(field.label ? { label: field.label } : {}) }
222
+ if (kind === 'object') return { ...head, kind, fields: field.kind === 'object' ? field.fields : [] }
223
+ if (kind === 'list') return { ...head, kind, item: field.kind === 'list' ? field.item : { kind: 'object', fields: [] } }
224
+ return { ...head, kind } as FigureDataField
225
+ }
226
+
227
+ /** The rows of the schema, each with the bindings that read it (figure-model's view). */
228
+ export const schemaRowsOf = (asset: FigureAsset): FigureDataRow[] => (asset.dataInterface ? figureDataRows(asset.dataInterface) : [])
229
+
230
+ /* ---------------------------------------------------------------- reading a sample */
231
+
101
232
  /**
102
- * What a sample of values would move, as the figure would run it: the kernel's check and evaluation of the interface.
103
- * Refused whole when one value cannot be taken, with each one's reason, as a live snapshot is.
233
+ * What a sample of data would do on this figure, read the one way a placed figure reads it (things-scene's figure
234
+ * inputs): what it moves, or why it is refused, each value's reason. Nothing here reads data itself.
104
235
  */
105
- export function dataSampleOf(asset: FigureAsset, values: Record<string, FigureDataValue>): { result: FigureDataResult } | { issues: FigureDataValueIssue[] } {
106
- const plan = compileFigureDataInterface(asset)
107
- if (!plan) return { issues: [] }
108
- const issues = validateFigureDataValues(plan, values)
109
- return issues.length ? { issues } : { result: evaluateFigureDataInterface(plan, values) }
236
+ export function sampleOf(asset: FigureAsset, sample: unknown): { issues: FigureDataValueIssue[]; result: FigureDataResult } {
237
+ const inputs = new FigureInputs(asset)
238
+ const taken = inputs.setData(sample)
239
+ return { issues: taken.accepted ? [] : (taken.issues[0]?.issues ?? []), result: inputs.data }
240
+ }
241
+
242
+ /** 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. */
243
+ export const valueRowsOf = (asset: FigureAsset): FigureDataRow[] => schemaRowsOf(asset).filter(r => isValue(r.node) && r.bindings.length > 0 && !r.path.includes('[]'))
244
+
245
+ /** A value typed or chosen for a row, as its kind reads it: a number (NaN when empty), true or false, or the text. */
246
+ export function typedValueOf(node: FigureDataNode, typed: string): FigureDataValue {
247
+ if (node.kind === 'number') return typed.trim() === '' ? Number.NaN : Number(typed)
248
+ if (node.kind === 'boolean') return typed === 'true'
249
+ return typed
250
+ }
251
+
252
+ /** The parts a status port may show on: the parts the figure's appearance names. */
253
+ export const partsOf = (asset: FigureAsset): string[] => [...new Set(asset.appearance.map(a => a.target))]
254
+
255
+ /** A first status port: the label as given, on the first colour token, no part yet. The kernel says whether it holds. */
256
+ export function portDraftOf(asset: FigureAsset, label: string): FigureStatusPort {
257
+ const taken = (asset.dataInterface?.ports ?? []).map(p => p.id)
258
+ const token = Object.keys(asset.palette)[0]
259
+ return { id: freeIdOf(taken, label.trim() || 'status'), label: label.trim(), tokens: token ? [token] : [], parts: [] }
110
260
  }
111
261
 
262
+ /** Whether a text is an accessor the kernel takes. */
263
+ export const accessorTaken = (accessor: string): boolean => parseFigureAccessor(accessor) !== null
264
+
112
265
  /**
113
- * Which fields of each motion group the data interface writes: its clip (a lookup to `motion-clip`), its speed
114
- * (`motion-speed`). The data always writes them -- from a live value, a saved one, or the property's default -- and
115
- * wins over a group's own setting for that field (detail design §5.1), so a control for such a field moves nothing.
266
+ * Which fields of each motion group the data binds: its clip, its speed. A field bound wins over the group's own setting
267
+ * whenever the data, a saved value or the schema's default gives it a value, so the preview drives it through the data.
116
268
  */
117
269
  export function motionWrittenByData(asset: FigureAsset): Record<string, { clip: boolean; speed: boolean }> {
118
270
  const out: Record<string, { clip: boolean; speed: boolean }> = {}
119
- for (const c of asset.dataInterface?.connections ?? []) {
120
- if (c.target.kind !== 'motion-clip' && c.target.kind !== 'motion-speed') continue
121
- const one = (out[c.target.group] ??= { clip: false, speed: false })
122
- if (c.target.kind === 'motion-clip') one.clip = true
271
+ for (const b of asset.dataInterface?.bindings ?? []) {
272
+ if (b.behavior.kind !== 'motion-clip' && b.behavior.kind !== 'motion-speed') continue
273
+ const one = (out[b.behavior.group] ??= { clip: false, speed: false })
274
+ if (b.behavior.kind === 'motion-clip') one.clip = true
123
275
  else one.speed = true
124
276
  }
125
277
  return out
126
278
  }
127
279
 
280
+ /* ---------------------------------------------------------------- the rotation card */
281
+
282
+ /** A joint that turns without end (a roller, a fan, a turntable): its control, by the id the clips write. */
283
+ export interface EndlessJoint {
284
+ input: string
285
+ label: string
286
+ unit: 'deg' | 'rad'
287
+ }
288
+
289
+ /** The joints that turn without end: revolute, with no limits, leading no one's mimic. */
290
+ export function endlessJointsOf(asset: FigureAsset): EndlessJoint[] {
291
+ const out: EndlessJoint[] = []
292
+ for (const j of asset.joints ?? []) {
293
+ if (j.type !== 'revolute' || j.lowerLimit !== undefined || j.upperLimit !== undefined || j.mimic) continue
294
+ const input = j.state?.id ?? j.id
295
+ const unit = asset.document.model.inputs.find(i => i.id === input)?.unit
296
+ if (unit !== 'deg' && unit !== 'rad') continue
297
+ if (!out.some(e => e.input === input)) out.push({ input, label: asset.stateInputs?.[input]?.label ?? input, unit })
298
+ }
299
+ return out
300
+ }
301
+
302
+ /** What the rotation card is told: the joint, and the data fields that turn it on, set its speed and its direction. */
303
+ export interface RotationChoice {
304
+ input: string
305
+ /** The motion group's name, shown to a person. */
306
+ label: string
307
+ /** A boolean field: true turns, false stops where it is. */
308
+ on?: string
309
+ /** A number field in turns per minute, and the most it gives; the field's range is set to 0..max when it has none. */
310
+ speed?: { path: string; max: number }
311
+ /** A closed text field, and its value that turns forward and the one that turns back. */
312
+ direction?: { path: string; forward: string; reverse: string }
313
+ }
314
+
315
+ /**
316
+ * The rotation card's commands (data binding design decision 1, 「회전」): a clip that turns the joint once around and
317
+ * keeps adding the turn (wrap, accumulating), a reverse one when a direction is given, the motion group holding them,
318
+ * and the bindings -- on to the group's pause, speed to its speed, direction to its clip. Nothing new runs them: they
319
+ * are the drivers, the group and the bindings any author could write, and stay editable one by one.
320
+ *
321
+ * The clip lasts `T = 3 × 60 / max rpm` seconds, so the most the field gives plays at the group's top speed, 3 -- the
322
+ * speed range is not widened. One turn per `T` at speed 1, and speed = rpm × T / 60. A stop is a pause: the joint stays
323
+ * where it is, where a stopped clip would jump back to its first key.
324
+ */
325
+ export function rotationStepsOf(asset: FigureAsset, c: RotationChoice): FigureProposalStep[] {
326
+ const joint = endlessJointsOf(asset).find(j => j.input === c.input)
327
+ if (!joint) throw new FigureContractError('TARGET_ABSENT', c.input, `${c.input} is not a joint that turns without end`)
328
+ const turn = joint.unit === 'deg' ? 360 : 2 * Math.PI
329
+ const seconds = c.speed && c.speed.max > 0 ? round((3 * 60) / c.speed.max) : 1
330
+ const group = freeIdOf((asset.motionGroups ?? []).map(g => g.id), `${joint.input}-spin`)
331
+ const forward = group
332
+ const reverse = `${group}-reverse`
333
+ const steps: FigureProposalStep[] = []
334
+ const schema = asset.dataInterface?.schema ?? []
335
+ if (c.speed) {
336
+ const field = fieldAt(schema, c.speed.path)
337
+ if (field?.kind === 'number' && (field.min === undefined || field.max === undefined))
338
+ steps.push({ kind: 'set-data-schema', schema: schemaWithFieldReplaced(schema, c.speed.path, { ...field, min: field.min ?? 0, max: field.max ?? c.speed.max }) })
339
+ }
340
+ const spin = (clip: string, way: 1 | -1, inGroup: boolean): FigureProposalStep => ({
341
+ kind: 'set-driver',
342
+ state: joint.input,
343
+ clip,
344
+ duration: seconds,
345
+ keys: [
346
+ { seconds: 0, value: 0 },
347
+ { seconds, value: way * turn }
348
+ ],
349
+ interpolation: 'linear',
350
+ loop: 'wrap',
351
+ accumulates: true,
352
+ ...(inGroup ? { group } : {})
353
+ })
354
+ const clips = c.direction ? [forward, reverse] : [forward]
355
+ steps.push(spin(forward, 1, false))
356
+ steps.push({ kind: 'set-motion-group', group: { id: group, label: c.label, clips: [forward], defaultClip: forward, transitionSeconds: 0 } })
357
+ if (c.direction) {
358
+ steps.push(spin(reverse, -1, true))
359
+ steps.push({ kind: 'set-motion-group', group: { id: group, label: c.label, clips, defaultClip: forward, transitionSeconds: 0 } })
360
+ }
361
+ const taken = (asset.dataInterface?.bindings ?? []).map(b => b.id)
362
+ const bind = (base: string, binding: Omit<FigureDataBinding, 'id'>): FigureProposalStep => {
363
+ const id = freeIdOf(taken, base)
364
+ taken.push(id)
365
+ return { kind: 'set-data-binding', binding: { id, ...binding } }
366
+ }
367
+ if (c.on) steps.push(bind(`${group}-on`, { accessor: c.on, behavior: { kind: 'motion-paused', group }, rule: 'map', param: { true: false, false: true } }))
368
+ if (c.speed) steps.push(bind(`${group}-speed`, { accessor: c.speed.path, behavior: { kind: 'motion-speed', group }, rule: 'scale', param: { scale: round(seconds / 60), offset: 0 } }))
369
+ if (c.direction) {
370
+ const values = domainOf(fieldAt(schema, c.direction.path) ?? { kind: 'text' }).map(String)
371
+ const covered = values.every(v => v === c.direction!.forward || v === c.direction!.reverse)
372
+ steps.push(
373
+ bind(`${group}-direction`, {
374
+ accessor: c.direction.path,
375
+ behavior: { kind: 'motion-clip', group },
376
+ rule: 'map',
377
+ param: { [c.direction.forward]: forward, [c.direction.reverse]: reverse, ...(covered ? {} : { default: forward }) }
378
+ })
379
+ )
380
+ }
381
+ return steps
382
+ }
@@ -750,8 +750,8 @@ export class FigureEditor extends LitElement {
750
750
  return this.lastAnchors
751
751
  }
752
752
  private lastAnchors: Record<string, ScaleAnchor> = {}
753
- /** Data values tried in the data tab, run by the preview's figure; null when none are (ADR-0051 §2.2). */
754
- @state() private dataSample: Record<string, unknown> | null = null
753
+ /** Data tried in the data tab, given to the preview's figures as their `data`; null when none is (ADR-0051 §8). */
754
+ @state() private dataSample: unknown = null
755
755
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
756
756
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
757
757
  const asset = this.shown()?.asset
@@ -1666,8 +1666,9 @@ export class FigureEditor extends LitElement {
1666
1666
  <figure-data-panel
1667
1667
  .asset=${asset}
1668
1668
  ?locked=${unavailable}
1669
- @data-sample=${(e: CustomEvent) => (this.dataSample = e.detail.values)}
1669
+ @data-sample=${(e: CustomEvent) => (this.dataSample = e.detail.data)}
1670
1670
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1671
+ @part-edits=${(e: CustomEvent) => this.run(() => this.session!.applyRecipe(e.detail.edits))}
1671
1672
  ></figure-data-panel>
1672
1673
  </div>
1673
1674
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>