@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.
- package/client/modeller/data-interface.ts +334 -79
- package/client/modeller/editor.ts +4 -3
- package/client/modeller/figure-data-panel.ts +683 -193
- package/client/modeller/figure-preview.ts +46 -30
- package/client/modeller/refusals.ts +18 -14
- package/client/viewparts/figure-detail-dock.ts +9 -3
- package/dist-client/modeller/data-interface.d.ts +91 -30
- package/dist-client/modeller/data-interface.js +314 -71
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +1 -1
- package/dist-client/modeller/editor.js +3 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +42 -13
- package/dist-client/modeller/figure-data-panel.js +679 -186
- package/dist-client/modeller/figure-data-panel.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +7 -5
- package/dist-client/modeller/figure-preview.js +47 -30
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +2 -2
- package/dist-client/modeller/refusals.js +24 -18
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/viewparts/figure-detail-dock.js +9 -3
- package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
- package/package.json +7 -7
- package/test/browser/build.mjs +9 -0
- package/test/browser/detail-tabs-page.js +36 -0
- package/test/browser/detail-tabs.check.mjs +123 -0
- package/test/data-motion.test.ts +173 -53
- package/translations/en.json +122 -43
- package/translations/es.json +122 -43
- package/translations/ja.json +122 -43
- package/translations/ko.json +122 -43
- package/translations/ms.json +122 -43
- package/translations/zh.json +122 -43
|
@@ -1,127 +1,382 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The figure's
|
|
3
|
-
* rule is figure-model's -- which
|
|
4
|
-
* only lists what there is to choose
|
|
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 {
|
|
8
|
-
import type { FigureAsset,
|
|
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
|
|
11
|
-
export interface
|
|
12
|
-
/** One string per
|
|
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
|
-
|
|
15
|
-
/** The
|
|
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
|
|
23
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
|
31
|
-
const out:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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(/-+$/, '') || '
|
|
57
|
-
const id = FIGURE_PART_ID.test(cleaned) ? cleaned : '
|
|
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
|
|
66
|
-
*
|
|
67
|
-
* rest value, the colour, the group's
|
|
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
|
|
70
|
-
const taken = (asset.dataInterface?.
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (kind === '
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
103
|
-
*
|
|
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
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
114
|
-
*
|
|
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
|
|
120
|
-
if (
|
|
121
|
-
const one = (out[
|
|
122
|
-
if (
|
|
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
|
|
754
|
-
@state() private dataSample:
|
|
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.
|
|
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>
|