@things-factory/figure-ui 10.1.156 → 10.1.158

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 (37) hide show
  1. package/client/modeller/clips.ts +43 -4
  2. package/client/modeller/data-interface.ts +110 -0
  3. package/client/modeller/editor.ts +16 -2
  4. package/client/modeller/figure-clips.ts +125 -12
  5. package/client/modeller/figure-data-panel.ts +308 -0
  6. package/client/modeller/figure-preview.ts +41 -4
  7. package/client/modeller/refusals.ts +46 -0
  8. package/dist-client/modeller/clips.d.ts +32 -2
  9. package/dist-client/modeller/clips.js +32 -4
  10. package/dist-client/modeller/clips.js.map +1 -1
  11. package/dist-client/modeller/data-interface.d.ts +42 -0
  12. package/dist-client/modeller/data-interface.js +95 -0
  13. package/dist-client/modeller/data-interface.js.map +1 -0
  14. package/dist-client/modeller/editor.d.ts +3 -0
  15. package/dist-client/modeller/editor.js +18 -0
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/figure-clips.d.ts +10 -0
  18. package/dist-client/modeller/figure-clips.js +125 -10
  19. package/dist-client/modeller/figure-clips.js.map +1 -1
  20. package/dist-client/modeller/figure-data-panel.d.ts +39 -0
  21. package/dist-client/modeller/figure-data-panel.js +317 -0
  22. package/dist-client/modeller/figure-data-panel.js.map +1 -0
  23. package/dist-client/modeller/figure-preview.d.ts +7 -0
  24. package/dist-client/modeller/figure-preview.js +49 -4
  25. package/dist-client/modeller/figure-preview.js.map +1 -1
  26. package/dist-client/modeller/refusals.d.ts +5 -0
  27. package/dist-client/modeller/refusals.js +58 -0
  28. package/dist-client/modeller/refusals.js.map +1 -1
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +6 -6
  31. package/test/data-motion.test.ts +137 -0
  32. package/translations/en.json +86 -2
  33. package/translations/es.json +86 -2
  34. package/translations/ja.json +86 -2
  35. package/translations/ko.json +86 -2
  36. package/translations/ms.json +86 -2
  37. package/translations/zh.json +86 -2
@@ -0,0 +1,308 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { FIGURE_UNITS } from '@hatiolab/figure-model'
8
+ import type { FigureAsset, FigureDataConnection, FigureDataProperty, FigureDataValue } from '@hatiolab/figure-model'
9
+
10
+ import { connectionDraftOf, dataSampleOf, dataTargetsOf, domainOf, propertyDraftOf, sampleValueOf, targetKeyOf, type DataTargetChoice } from './data-interface.js'
11
+ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
12
+ import { HelpToggles } from './help.js'
13
+
14
+ /**
15
+ * 데이터 -- the figure's public data properties and what each moves inside it (ADR-0051 §2.2). A property is a value a
16
+ * consumer maps its own data onto, by its id: a number with a unit and a range, a boolean, or an enum. A connection
17
+ * makes a property move a control, a colour, or which clip of a motion group plays and how fast. Every change is one
18
+ * part edit, and whether it holds is the kernel's check.
19
+ *
20
+ * The sample is not saved: values tried here, what they would move (figure-model's evaluation), and -- in the preview --
21
+ * the figure running on them (`data-sample`).
22
+ */
23
+ @customElement('figure-data-panel')
24
+ export class FigureDataPanel extends localize(i18next)(LitElement) {
25
+ private readonly help = new HelpToggles(this)
26
+ static styles = [SidePanelStyles, css`
27
+ :host {
28
+ display: block;
29
+ }
30
+ section[item] {
31
+ display: grid;
32
+ gap: 8px;
33
+ padding: 8px;
34
+ border: 1px solid var(--md-sys-color-outline-variant);
35
+ border-radius: 8px;
36
+ }
37
+ div[head] {
38
+ display: grid;
39
+ grid-template-columns: minmax(0, 1fr) 30px;
40
+ gap: 6px;
41
+ align-items: center;
42
+ }
43
+ div[head] strong {
44
+ min-width: 0;
45
+ overflow-wrap: anywhere;
46
+ }
47
+ div[head] button[act] {
48
+ width: 30px;
49
+ min-height: var(--figure-control-height);
50
+ margin: 0;
51
+ padding: 0;
52
+ }
53
+ div[cases] {
54
+ display: grid;
55
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
56
+ gap: 4px 6px;
57
+ align-items: center;
58
+ }
59
+ ul[result] {
60
+ margin: 0;
61
+ padding-left: 16px;
62
+ }
63
+ md-icon {
64
+ font-size: 16px;
65
+ width: 16px;
66
+ height: 16px;
67
+ }
68
+ `]
69
+
70
+ @property({ attribute: false }) asset?: FigureAsset
71
+ @property({ type: Boolean }) locked = false
72
+
73
+ @state() private adding = { label: '', kind: 'number' as FigureDataProperty['kind'] }
74
+ @state() private connecting = { property: '', target: '' }
75
+ /** The sample's values as typed, by property id; a property left out reads its default. */
76
+ @state() private sample: Record<string, FigureDataValue> = {}
77
+ @state() private sampling = false
78
+
79
+ render() {
80
+ const asset = this.asset
81
+ if (!asset) return nothing
82
+ const properties = asset.dataInterface?.properties ?? []
83
+ const connections = asset.dataInterface?.connections ?? []
84
+ return html`
85
+ <section>
86
+ <h3>${i18next.t('figure.label.data-properties')}${this.help.button('figure.text.data-properties-explained')}</h3>
87
+ ${this.help.hint('figure.text.data-properties-explained')}
88
+ ${properties.map(p => this.renderProperty(p))}
89
+ ${this.renderAddProperty(asset)}
90
+ </section>
91
+ <section>
92
+ <h3>${i18next.t('figure.label.data-connections')}${this.help.button('figure.text.data-connections-explained')}</h3>
93
+ ${this.help.hint('figure.text.data-connections-explained')}
94
+ ${connections.map(c => this.renderConnection(asset, c, properties.find(p => p.id === c.property)))}
95
+ ${properties.length ? this.renderAddConnection(asset, properties) : html`<p hint>${i18next.t('figure.text.data-no-properties')}</p>`}
96
+ </section>
97
+ ${properties.length ? this.renderSample(asset, properties) : nothing}
98
+ `
99
+ }
100
+
101
+ private renderProperty(p: FigureDataProperty) {
102
+ const set = (patch: Partial<FigureDataProperty>) => this.edit({ kind: 'set-data-property', property: { ...p, ...patch } as FigureDataProperty })
103
+ const value = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
104
+ const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
105
+ return html`<section item>
106
+ <div head>
107
+ <strong>${p.label} <small>(${i18next.t('figure.label.data-kind-' + p.kind)})</small></strong>
108
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-property')} @click=${() => this.edit({ kind: 'remove-data-property', id: p.id })}>
109
+ <md-icon>close</md-icon>
110
+ </button>
111
+ </div>
112
+ <div form>
113
+ <span>${i18next.t('figure.label.data-id')}</span>
114
+ <input .value=${p.id} ?disabled=${this.locked} @change=${(e: Event) => value(e).trim() !== p.id && this.edit({ kind: 'rename-data-property', id: p.id, to: value(e).trim() })} />
115
+ <span>${i18next.t('figure.label.name')}</span>
116
+ <input .value=${p.label} ?disabled=${this.locked} @change=${(e: Event) => value(e).trim() && set({ label: value(e).trim() })} />
117
+ ${p.kind === 'number'
118
+ ? html`<span>${i18next.t('figure.label.data-unit')}</span>
119
+ <select ?disabled=${this.locked} @change=${(e: Event) => set({ unit: value(e) as never })}>
120
+ ${FIGURE_UNITS.map(u => html`<option value=${u} ?selected=${u === p.unit}>${u}</option>`)}
121
+ </select>
122
+ <span>${i18next.t('figure.label.data-min')}</span>
123
+ <input type="number" step="any" .value=${String(p.min)} ?disabled=${this.locked} @change=${(e: Event) => set({ min: number(e) })} />
124
+ <span>${i18next.t('figure.label.data-max')}</span>
125
+ <input type="number" step="any" .value=${String(p.max)} ?disabled=${this.locked} @change=${(e: Event) => set({ max: number(e) })} />
126
+ <span>${i18next.t('figure.label.data-default')}</span>
127
+ <input type="number" step="any" .value=${String(p.default)} ?disabled=${this.locked} @change=${(e: Event) => set({ default: number(e) })} />`
128
+ : p.kind === 'boolean'
129
+ ? html`<span>${i18next.t('figure.label.data-default')}</span>
130
+ <select ?disabled=${this.locked} @change=${(e: Event) => set({ default: value(e) === 'true' })}>
131
+ ${[false, true].map(b => html`<option value=${String(b)} ?selected=${b === p.default}>${i18next.t(b ? 'figure.label.data-true' : 'figure.label.data-false')}</option>`)}
132
+ </select>`
133
+ : html`<span>${i18next.t('figure.label.data-values')}</span>
134
+ <input
135
+ .value=${p.values.join(', ')}
136
+ ?disabled=${this.locked}
137
+ @change=${(e: Event) => {
138
+ const values = value(e).split(',').map(v => v.trim()).filter(Boolean)
139
+ set({ values, default: values.includes(p.default) ? p.default : values[0]! } as never)
140
+ }}
141
+ />
142
+ <span>${i18next.t('figure.label.data-default')}</span>
143
+ <select ?disabled=${this.locked} @change=${(e: Event) => set({ default: value(e) })}>
144
+ ${p.values.map(v => html`<option value=${v} ?selected=${v === p.default}>${v}</option>`)}
145
+ </select>`}
146
+ </div>
147
+ </section>`
148
+ }
149
+
150
+ private renderAddProperty(asset: FigureAsset) {
151
+ const a = this.adding
152
+ return html`<div form>
153
+ <span>${i18next.t('figure.label.name')}</span>
154
+ <input .value=${a.label} @input=${(e: Event) => (this.adding = { ...a, label: (e.target as HTMLInputElement).value })} />
155
+ <span>${i18next.t('figure.label.data-kind')}</span>
156
+ <select @change=${(e: Event) => (this.adding = { ...a, kind: (e.target as HTMLSelectElement).value as FigureDataProperty['kind'] })}>
157
+ ${(['number', 'boolean', 'enum'] as const).map(k => html`<option value=${k} ?selected=${k === a.kind}>${i18next.t('figure.label.data-kind-' + k)}</option>`)}
158
+ </select>
159
+ </div>
160
+ <button
161
+ act
162
+ ?disabled=${this.locked || !a.label.trim()}
163
+ @click=${() => {
164
+ this.edit({ kind: 'set-data-property', property: propertyDraftOf(asset, a.label.trim(), a.kind) })
165
+ this.adding = { ...a, label: '' }
166
+ }}
167
+ >
168
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-property')}
169
+ </button>`
170
+ }
171
+
172
+ private targetName(asset: FigureAsset, c: FigureDataConnection, property?: FigureDataProperty): string {
173
+ const choice = property ? dataTargetsOf(asset, property.kind).find(t => t.key === targetKeyOf(c.target)) : undefined
174
+ const name = choice?.name ?? targetKeyOf(c.target)
175
+ return i18next.t('figure.label.data-target-' + c.target.kind, { name })
176
+ }
177
+
178
+ private renderConnection(asset: FigureAsset, c: FigureDataConnection, property?: FigureDataProperty) {
179
+ const set = (patch: Partial<FigureDataConnection>) => this.edit({ kind: 'set-data-connection', connection: { ...c, ...patch } })
180
+ const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
181
+ const t = c.transform
182
+ return html`<section item>
183
+ <div head>
184
+ <strong>${property?.label ?? c.property} → ${this.targetName(asset, c, property)}</strong>
185
+ <button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-data-connection')} @click=${() => this.edit({ kind: 'remove-data-connection', id: c.id })}>
186
+ <md-icon>close</md-icon>
187
+ </button>
188
+ </div>
189
+ <div form>
190
+ ${c.target.kind === 'state'
191
+ ? html`<span>${i18next.t('figure.label.data-source-mode')}</span>
192
+ <select ?disabled=${this.locked} @change=${(e: Event) => set({ target: { ...(c.target as { kind: 'state'; input: string }), sourceMode: (e.target as HTMLSelectElement).value as 'observed' | 'commanded' } })}>
193
+ ${(['observed', 'commanded'] as const).map(m => html`<option value=${m} ?selected=${c.target.kind === 'state' && c.target.sourceMode === m}>${i18next.t('figure.label.data-' + m)}</option>`)}
194
+ </select>`
195
+ : nothing}
196
+ ${t.kind === 'lookup'
197
+ ? nothing
198
+ : html`<span>${i18next.t('figure.label.data-transform')}</span>
199
+ <select ?disabled=${this.locked} @change=${(e: Event) => set({ transform: (e.target as HTMLSelectElement).value === 'affine' ? { kind: 'affine', scale: 1, offset: 0 } : { kind: 'identity' } })}>
200
+ <option value="identity" ?selected=${t.kind === 'identity'}>${i18next.t('figure.label.data-identity')}</option>
201
+ <option value="affine" ?selected=${t.kind === 'affine'}>${i18next.t('figure.label.data-affine')}</option>
202
+ </select>`}
203
+ ${t.kind === 'affine'
204
+ ? html`<span>${i18next.t('figure.label.data-scale')}</span>
205
+ <input type="number" step="any" .value=${String(t.scale)} ?disabled=${this.locked} @change=${(e: Event) => set({ transform: { ...t, scale: number(e) } })} />
206
+ <span>${i18next.t('figure.label.data-offset')}</span>
207
+ <input type="number" step="any" .value=${String(t.offset)} ?disabled=${this.locked} @change=${(e: Event) => set({ transform: { ...t, offset: number(e) } })} />`
208
+ : nothing}
209
+ </div>
210
+ ${t.kind === 'lookup' ? this.renderCases(asset, c, set) : nothing}
211
+ </section>`
212
+ }
213
+
214
+ /** A case per value of the property: what the target takes for it -- a number, a colour, or a clip of the group. */
215
+ private renderCases(asset: FigureAsset, c: FigureDataConnection, set: (patch: Partial<FigureDataConnection>) => void) {
216
+ if (c.transform.kind !== 'lookup') return nothing
217
+ const cases = c.transform.cases
218
+ const write = (i: number, value: FigureDataValue) => set({ transform: { kind: 'lookup', cases: cases.map((k, j) => (j === i ? { ...k, value } : k)) } })
219
+ const clips = c.target.kind === 'motion-clip' ? ((asset.motionGroups ?? []).find(g => c.target.kind === 'motion-clip' && g.id === c.target.group)?.clips ?? []) : []
220
+ return html`<div cases>
221
+ ${cases.map(
222
+ (k, i) => html`<span>${typeof k.when === 'boolean' ? i18next.t(k.when ? 'figure.label.data-true' : 'figure.label.data-false') : String(k.when)}</span>
223
+ ${c.target.kind === 'palette'
224
+ ? html`<input type="color" .value=${String(k.value)} ?disabled=${this.locked} @change=${(e: Event) => write(i, (e.target as HTMLInputElement).value)} />`
225
+ : c.target.kind === 'motion-clip'
226
+ ? html`<select ?disabled=${this.locked} @change=${(e: Event) => write(i, (e.target as HTMLSelectElement).value)}>
227
+ ${clips.map(clip => html`<option value=${clip} ?selected=${clip === k.value}>${clip}</option>`)}
228
+ </select>`
229
+ : html`<input type="number" step="any" .value=${String(k.value)} ?disabled=${this.locked} @change=${(e: Event) => write(i, (e.target as HTMLInputElement).valueAsNumber)} />`}`
230
+ )}
231
+ </div>`
232
+ }
233
+
234
+ private renderAddConnection(asset: FigureAsset, properties: FigureDataProperty[]) {
235
+ const a = this.connecting
236
+ const property = properties.find(p => p.id === a.property) ?? properties[0]!
237
+ const targets = dataTargetsOf(asset, property.kind)
238
+ const choice = targets.find(t => t.key === a.target) ?? targets[0]
239
+ return html`<div form>
240
+ <span>${i18next.t('figure.label.data-property')}</span>
241
+ <select @change=${(e: Event) => (this.connecting = { ...a, property: (e.target as HTMLSelectElement).value })}>
242
+ ${properties.map(p => html`<option value=${p.id} ?selected=${p === property}>${p.label}</option>`)}
243
+ </select>
244
+ <span>${i18next.t('figure.label.data-target')}</span>
245
+ <select ?disabled=${!targets.length} @change=${(e: Event) => (this.connecting = { ...a, target: (e.target as HTMLSelectElement).value })}>
246
+ ${targets.map((t: DataTargetChoice) => html`<option value=${t.key} ?selected=${t === choice}>${i18next.t('figure.label.data-target-' + t.target.kind, { name: t.name })}</option>`)}
247
+ </select>
248
+ </div>
249
+ <button act ?disabled=${this.locked || !choice} @click=${() => this.edit({ kind: 'set-data-connection', connection: connectionDraftOf(asset, property, choice!) })}>
250
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-data-connection')}
251
+ </button>`
252
+ }
253
+
254
+ /** Values to try: what they move, by figure-model's evaluation; in the preview, the figure running on them. */
255
+ private renderSample(asset: FigureAsset, properties: FigureDataProperty[]) {
256
+ const values = Object.fromEntries(properties.map(p => [p.id, this.sample[p.id] ?? p.default]))
257
+ const outcome = dataSampleOf(asset, values)
258
+ return html`<section>
259
+ <h3>${i18next.t('figure.label.data-sample')}${this.help.button('figure.text.data-sample-explained')}</h3>
260
+ ${this.help.hint('figure.text.data-sample-explained')}
261
+ <div form>
262
+ ${properties.map(
263
+ p => html`<span>${p.label}</span>
264
+ ${p.kind === 'number'
265
+ ? html`<input type="number" step="any" .value=${String(values[p.id])} @change=${(e: Event) => this.try(p, (e.target as HTMLInputElement).value)} />`
266
+ : html`<select @change=${(e: Event) => this.try(p, (e.target as HTMLSelectElement).value)}>
267
+ ${domainOf(p).map(v => html`<option value=${String(v)} ?selected=${v === values[p.id]}>${typeof v === 'boolean' ? i18next.t(v ? 'figure.label.data-true' : 'figure.label.data-false') : v}</option>`)}
268
+ </select>`}`
269
+ )}
270
+ </div>
271
+ ${'issues' in outcome
272
+ ? html`<p hint>${outcome.issues.map(i => i18next.t('figure.text.data-value-' + i.reason, { property: i.property ?? '' })).join(' · ')}</p>`
273
+ : html`<ul result>
274
+ ${Object.entries(outcome.result.states).map(([input, s]) => html`<li>${asset.stateInputs?.[input]?.label ?? input} = ${s.value}</li>`)}
275
+ ${Object.entries(outcome.result.palette).map(([token, colour]) => html`<li>${token} = ${colour}</li>`)}
276
+ ${Object.entries(outcome.result.motion).map(
277
+ ([group, m]) => html`<li>${(asset.motionGroups ?? []).find(g => g.id === group)?.label ?? group}: ${[m.clip, m.speed === undefined ? '' : `× ${m.speed}`].filter(Boolean).join(' ')}</li>`
278
+ )}
279
+ </ul>`}
280
+ <label toggle>
281
+ <input
282
+ type="checkbox"
283
+ .checked=${this.sampling}
284
+ @change=${(e: Event) => {
285
+ this.sampling = (e.target as HTMLInputElement).checked
286
+ this.tell(values)
287
+ }}
288
+ />
289
+ ${i18next.t('figure.label.data-sample-in-preview')}
290
+ </label>
291
+ </section>`
292
+ }
293
+
294
+ private try(property: FigureDataProperty, typed: string) {
295
+ this.sample = { ...this.sample, [property.id]: sampleValueOf(property, typed) }
296
+ const properties = this.asset?.dataInterface?.properties ?? []
297
+ this.tell(Object.fromEntries(properties.map(p => [p.id, this.sample[p.id] ?? p.default])))
298
+ }
299
+
300
+ /** The sample to the preview while it is asked for; null lets the preview's figure go back to its own values. */
301
+ private tell(values: Record<string, FigureDataValue>) {
302
+ this.dispatchEvent(new CustomEvent('data-sample', { detail: { values: this.sampling ? values : null }, bubbles: true, composed: true }))
303
+ }
304
+
305
+ private edit(edit: unknown) {
306
+ this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit }, bubbles: true, composed: true }))
307
+ }
308
+ }
@@ -196,6 +196,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
196
196
  /** Clip speeds and control values given here -- the preview's, not the figure's. */
197
197
  @state() private driving: Record<string, number> = {}
198
198
  @state() private giving: Record<string, number> = {}
199
+ /** Per motion group, the clip played here and how fast (ADR-0051 §3.2) -- the preview's, as a board's \`motion\`. */
200
+ @state() private moving: Record<string, { clip: string; speed: number }> = {}
201
+ /** Data values tried in the data tab: the preview's figures run on them as a live snapshot; null when none are. */
202
+ @property({ attribute: false }) sample: Record<string, unknown> | null = null
199
203
  /** The part chosen in the editor. The size help starts on it, so the help speaks of the part the author was working on. */
200
204
  @property() selected = ''
201
205
  @state() private setupPart = ''
@@ -215,7 +219,9 @@ export class FigurePreview extends localize(i18next)(LitElement) {
215
219
  render() {
216
220
  const asset = this.asset
217
221
  const controls = asset ? asset.document.model.inputs.filter(i => i.role === 'state') : []
218
- const clips = asset ? [...new Set((asset.drivers ?? []).map(d => d.clip))] : []
222
+ /* Clips in a motion group are played by the group's choice of clip, not each by its own speed. */
223
+ const grouped = new Set((asset?.motionGroups ?? []).flatMap(g => g.clips))
224
+ const clips = asset ? [...new Set((asset.drivers ?? []).map(d => d.clip))].filter(c => !grouped.has(c)) : []
219
225
  let clampsByWay: ReturnType<typeof previewClampsOf> = {}
220
226
  try {
221
227
  clampsByWay = asset ? previewClampsOf(asset, this.authoredSize()) : {}
@@ -245,7 +251,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
245
251
  </select>
246
252
  <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
247
253
  </div>` : nothing}
248
- ${controls.length + clips.length ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
254
+ ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) ? this.renderDrivers(asset!, controls, clips) : nothing}</div>`}`
249
255
  }
250
256
 
251
257
  private renderDrivers(asset: FigureAsset, controls: FigureAsset['document']['model']['inputs'], clips: string[]) {
@@ -287,9 +293,25 @@ export class FigurePreview extends localize(i18next)(LitElement) {
287
293
  <output>${value.toFixed(2)}</output>
288
294
  </label>`
289
295
  })}
296
+ ${(asset.motionGroups ?? []).map(group => {
297
+ const playing = this.moving[group.id] ?? { clip: group.defaultClip, speed: 0 }
298
+ return html`<label>
299
+ <span>${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
300
+ <select aria-label=${group.label} @change=${(e: Event) => this.move(group.id, { ...playing, clip: (e.target as HTMLSelectElement).value })}>
301
+ ${group.clips.map(c => html`<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
302
+ </select>
303
+ <input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e: Event) => this.move(group.id, { ...playing, speed: Number((e.target as HTMLInputElement).value) })} />
304
+ <output>${playing.speed.toFixed(2)}</output>
305
+ </label>`
306
+ })}
290
307
  </div>`
291
308
  }
292
309
 
310
+ private move(group: string, playing: { clip: string; speed: number }) {
311
+ this.moving = { ...this.moving, [group]: playing }
312
+ this.tellInstances('motion', Object.fromEntries(Object.entries(this.moving).map(([id, m]) => [id, { ...m, paused: false }])))
313
+ }
314
+
293
315
  private renderDial(control: { unit: string; min: number; max: number }, value: number) {
294
316
  if (control.unit !== 'deg') return html`<span></span>`
295
317
  const dial = dialShape(control.min, control.max, value)
@@ -316,11 +338,23 @@ export class FigurePreview extends localize(i18next)(LitElement) {
316
338
  this.giving = {}
317
339
  }
318
340
 
319
- private tellInstances(key: 'clips' | 'parameters', values: Record<string, number>) {
320
- for (const component of this.scene?.root?.components ?? []) component.set?.(key, { ...values })
341
+ private tellInstances(key: 'clips' | 'parameters' | 'motion' | 'figureData', values: Record<string, unknown> | null) {
342
+ for (const component of this.scene?.root?.components ?? []) {
343
+ const value = values === null ? null : { ...values }
344
+ /* A live snapshot is transient on the figure (its \`figureData\` accessor), never written into the saved model. */
345
+ if (key === 'figureData') (component as { figureData?: unknown }).figureData = value
346
+ else component.set?.(key, value)
347
+ }
348
+ }
349
+
350
+
351
+ /** The data sample to the figures standing now: a live snapshot, transient on each, never saved with the board. */
352
+ private tellSample() {
353
+ if (this.sample) this.tellInstances('figureData', { ...this.sample })
321
354
  }
322
355
 
323
356
  willUpdate(changed: Map<string, unknown>) {
357
+ if (changed.has('sample') && this.scene) this.tellInstances('figureData', this.sample ? { ...this.sample } : null)
324
358
  if (!this.hasUpdated) return
325
359
  if (!this.active) {
326
360
  this.stale = true
@@ -387,6 +421,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
387
421
  const again = this.scene?.rootContainer?.rebuildContentModel && this.registered === type
388
422
  if (!again) this.teardown()
389
423
  this.driving = {}
424
+ this.moving = {}
390
425
  registerFigure(type, asset)
391
426
  this.registered = type
392
427
  const { components, board } = previewInstances(type, size)
@@ -408,6 +443,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
408
443
  this.scene!.rootContainer!.rebuildContentModel!(model, { preserveThreeD: true })
409
444
  this.standing = signature
410
445
  this.cannot = ''
446
+ this.tellSample()
411
447
  return
412
448
  } catch {
413
449
  this.teardown()
@@ -425,6 +461,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
425
461
  this.scene.fit?.('ratio')
426
462
  this.scene.root?.fitCameraToModel?.()
427
463
  this.cannot = ''
464
+ this.tellSample()
428
465
  } catch (error) {
429
466
  this.cannot = (error as Error).message
430
467
  }
@@ -237,10 +237,56 @@ export function editRefusalOf(error: unknown, say: Say): string {
237
237
  documentLimitRefusalOf(error, say) ??
238
238
  sectionRefusalOf(error, say) ??
239
239
  slotLayoutRefusalOf(error, say) ??
240
+ dataMotionRefusalOf(error, say) ??
240
241
  say('figure.text.edit-refused', { code: codeOf(error) ?? '' })
241
242
  )
242
243
  }
243
244
 
245
+ /**
246
+ * A data property, a connection, a motion group or a clip's tracks refused (ADR-0051 §2.2, §3.2), said with the ids and
247
+ * numbers the kernel gave. Nothing for any other refusal.
248
+ */
249
+ export function dataMotionRefusalOf(error: unknown, say: Say): string | null {
250
+ const invalidId = detailsOf(error, 'data-id-invalid')
251
+ if (invalidId) return say('figure.text.data-id-invalid', { id: invalidId.id })
252
+ const twice = detailsOf(error, 'data-id-duplicate')
253
+ if (twice) return say('figure.text.data-id-duplicate', { id: twice.id })
254
+ const property = detailsOf(error, 'data-property-invalid')
255
+ if (property) return say('figure.text.data-property-invalid-' + property.field, { property: property.property })
256
+ const absent = detailsOf(error, 'data-property-absent')
257
+ if (absent) return say('figure.text.data-property-absent', { property: absent.property })
258
+ const target = detailsOf(error, 'data-target-absent')
259
+ if (target) return say('figure.text.data-target-absent-' + target.target, { id: target.id })
260
+ const writers = detailsOf(error, 'data-target-duplicate')
261
+ if (writers) return say('figure.text.data-target-duplicate', { a: writers.connections[0], b: writers.connections[1], id: writers.id })
262
+ const domain = detailsOf(error, 'data-transform-domain')
263
+ if (domain)
264
+ return say('figure.text.data-transform-' + domain.why, {
265
+ from: domain.from ?? '',
266
+ to: domain.to ?? '',
267
+ min: domain.min === undefined ? '' : numberOf(domain.min),
268
+ max: domain.max === undefined ? '' : numberOf(domain.max),
269
+ low: domain.within ? numberOf(domain.within[0]) : '',
270
+ high: domain.within ? numberOf(domain.within[1]) : '',
271
+ value: domain.value === undefined ? '' : String(domain.value)
272
+ })
273
+ const connected = detailsOf(error, 'data-property-connected')
274
+ if (connected) return say('figure.text.data-property-connected', { property: connected.property, connections: connected.connections.join(', ') })
275
+ const clipAbsent = detailsOf(error, 'motion-clip-absent')
276
+ if (clipAbsent) return say('figure.text.motion-clip-absent', { clip: clipAbsent.clip, group: clipAbsent.group })
277
+ const shared = detailsOf(error, 'motion-clip-shared')
278
+ if (shared) return say('figure.text.motion-clip-shared', { clip: shared.clip, group: shared.groups[0] })
279
+ const many = detailsOf(error, 'driver-writers')
280
+ if (many) return say('figure.text.driver-writers', { state: many.state, clips: many.clips.join(', ') })
281
+ const which = detailsOf(error, 'driver-clip-required')
282
+ if (which) return say('figure.text.driver-clip-required', { state: which.state, clips: which.clips.join(', ') })
283
+ const setting = detailsOf(error, 'motion-setting')
284
+ if (setting) return say('figure.text.motion-setting-' + setting.why, { group: setting.group, clip: setting.clip ?? '' })
285
+ const key = codeOf(error) === 'MOTION_GROUP' ? detailsOf(error, 'value-outside-range') : undefined
286
+ if (key) return say('figure.text.motion-key-outside-range', { input: key.input, value: numberOf(key.value), min: numberOf(key.min), max: numberOf(key.max) })
287
+ return null
288
+ }
289
+
244
290
  /**
245
291
  * A value outside the range its input declares, with the range and the value given. The figure's own size is named by
246
292
  * its axis; any other input by its range only.
@@ -20,8 +20,38 @@ export interface Clip {
20
20
  tracks: ClipTrack[];
21
21
  }
22
22
  export declare function clipsOf(asset: FigureAsset): Clip[];
23
- /** The controls no clip plays yet -- the ones a new track can take. One driver per control. */
24
- export declare function undrivenOf(asset: FigureAsset): string[];
23
+ /**
24
+ * The controls a new track of `clip` can take (ADR-0051 §3.2): one the clip does not play yet, and that no clip plays
25
+ * outside the clip's motion group -- `group` for a clip not made yet. A clip in no group takes only a control no clip
26
+ * plays. Without a clip, the controls no clip plays at all.
27
+ */
28
+ export declare function undrivenOf(asset: FigureAsset, clip?: string, group?: string): string[];
29
+ /** A motion group as the panel shows it: its setting, and the clips it holds in the clips' order. */
30
+ export interface ClipGroup {
31
+ id: string;
32
+ label: string;
33
+ clips: string[];
34
+ defaultClip: string;
35
+ transitionSeconds: number;
36
+ }
37
+ /** The figure's motion groups, and the clips in none -- the ones a group can take. */
38
+ export declare function clipGroupsOf(asset: FigureAsset): {
39
+ groups: ClipGroup[];
40
+ free: string[];
41
+ };
42
+ /** A group id not yet taken: `motion`, then `motion-2`, `motion-3`, … An id is not made from a label a person types. */
43
+ export declare function freeGroupIdOf(asset: FigureAsset): string;
44
+ /** The edit that writes a group back with a change: one whole group, as the kernel takes it. */
45
+ export declare const groupEdit: (group: ClipGroup, patch?: Partial<Omit<ClipGroup, "id">>) => {
46
+ kind: "set-motion-group";
47
+ group: {
48
+ id: string;
49
+ label: string;
50
+ clips: string[];
51
+ defaultClip: string;
52
+ transitionSeconds: number;
53
+ };
54
+ };
25
55
  /** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */
26
56
  export declare function poseAt(asset: FigureAsset, clip: string, seconds: number): FigureInputChange[];
27
57
  /** A key added at a moment, replacing one already there; the keys stay in time order. */
@@ -21,11 +21,39 @@ export function clipsOf(asset) {
21
21
  }
22
22
  return [...clips.values()];
23
23
  }
24
- /** The controls no clip plays yet -- the ones a new track can take. One driver per control. */
25
- export function undrivenOf(asset) {
26
- const driven = new Set((asset.drivers ?? []).map(d => d.state));
27
- return asset.document.model.inputs.filter(i => i.role === 'state' && !driven.has(i.id)).map(i => i.id);
24
+ /**
25
+ * The controls a new track of `clip` can take (ADR-0051 §3.2): one the clip does not play yet, and that no clip plays
26
+ * outside the clip's motion group -- `group` for a clip not made yet. A clip in no group takes only a control no clip
27
+ * plays. Without a clip, the controls no clip plays at all.
28
+ */
29
+ export function undrivenOf(asset, clip = '', group) {
30
+ const groupOf = new Map((asset.motionGroups ?? []).flatMap(g => g.clips.map(c => [c, g.id])));
31
+ const mine = group ?? (clip ? groupOf.get(clip) : undefined);
32
+ const taken = (state) => (asset.drivers ?? []).some(d => d.state === state && (d.clip === clip || mine === undefined || groupOf.get(d.clip) !== mine));
33
+ return asset.document.model.inputs.filter(i => i.role === 'state' && !taken(i.id)).map(i => i.id);
28
34
  }
35
+ /** The figure's motion groups, and the clips in none -- the ones a group can take. */
36
+ export function clipGroupsOf(asset) {
37
+ const groups = (asset.motionGroups ?? []).map(g => ({ ...g, clips: [...g.clips] }));
38
+ const grouped = new Set(groups.flatMap(g => g.clips));
39
+ const free = [...new Set((asset.drivers ?? []).map(d => d.clip))].filter(c => !grouped.has(c));
40
+ return { groups, free };
41
+ }
42
+ /** A group id not yet taken: `motion`, then `motion-2`, `motion-3`, … An id is not made from a label a person types. */
43
+ export function freeGroupIdOf(asset) {
44
+ const taken = new Set((asset.motionGroups ?? []).map(g => g.id));
45
+ if (!taken.has('motion'))
46
+ return 'motion';
47
+ let n = 2;
48
+ while (taken.has(`motion-${n}`))
49
+ n++;
50
+ return `motion-${n}`;
51
+ }
52
+ /** The edit that writes a group back with a change: one whole group, as the kernel takes it. */
53
+ export const groupEdit = (group, patch = {}) => ({
54
+ kind: 'set-motion-group',
55
+ group: { id: group.id, label: patch.label ?? group.label, clips: patch.clips ?? group.clips, defaultClip: patch.defaultClip ?? group.defaultClip, transitionSeconds: patch.transitionSeconds ?? group.transitionSeconds }
56
+ });
29
57
  /** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */
30
58
  export function poseAt(asset, clip, seconds) {
31
59
  const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]));
@@ -1 +1 @@
1
- {"version":3,"file":"clips.js","sourceRoot":"","sources":["../../client/modeller/clips.ts"],"names":[],"mappings":"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","sourcesContent":["import { driverValue } from '@hatiolab/figure-model'\nimport type { FigureAsset, FigureDriver, FigureInputChange } from '@hatiolab/figure-model'\n\n/*\n * The figure's clips: controls played by keys over time, grouped into clips that each run on one\n * clock. Kept apart from the element so the reading is tested without a browser.\n */\nexport interface ClipKey {\n seconds: number\n value: number\n}\nexport interface ClipTrack {\n state: string\n label: string\n unit: string\n min: number\n max: number\n keys: ClipKey[]\n interpolation: 'linear' | 'step'\n loop: 'wrap' | 'hold'\n accumulates: boolean\n}\nexport interface Clip {\n name: string\n duration: number\n tracks: ClipTrack[]\n}\n\nconst round = (n: number) => Math.round(n * 1000) / 1000\n\nexport function clipsOf(asset: FigureAsset): Clip[] {\n const inputs = new Map(asset.document.model.inputs.map(i => [i.id, i]))\n const clips = new Map<string, Clip>()\n for (const d of asset.drivers ?? []) {\n const input = inputs.get(d.state)\n const clip = clips.get(d.clip) ?? { name: d.clip, duration: d.time.duration, tracks: [] }\n clip.tracks.push({\n state: d.state,\n label: asset.stateInputs?.[d.state]?.label ?? d.state,\n unit: input?.unit ?? '',\n min: input?.min ?? 0,\n max: input?.max ?? 0,\n keys: d.keys.map(k => ({ seconds: round(k.at * d.time.duration), value: k.value })),\n interpolation: d.interpolation,\n loop: d.loop,\n accumulates: d.accumulates\n })\n clips.set(d.clip, clip)\n }\n return [...clips.values()]\n}\n\n/** The controls no clip plays yet -- the ones a new track can take. One driver per control. */\nexport function undrivenOf(asset: FigureAsset): string[] {\n const driven = new Set((asset.drivers ?? []).map(d => d.state))\n return asset.document.model.inputs.filter(i => i.role === 'state' && !driven.has(i.id)).map(i => i.id)\n}\n\n/** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */\nexport function poseAt(asset: FigureAsset, clip: string, seconds: number): FigureInputChange[] {\n const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]))\n return (asset.drivers ?? [])\n .filter((d: FigureDriver) => d.clip === clip)\n .map(d => {\n const unit = units.get(d.state)!\n return { kind: 'set-state' as const, input: d.state, unit, value: driverValue(d, seconds, unit === 'deg' || unit === 'rad' ? unit : 'other') }\n })\n}\n\n/** A key added at a moment, replacing one already there; the keys stay in time order. */\nexport function withKey(keys: readonly ClipKey[], key: ClipKey): ClipKey[] {\n return [...keys.filter(k => k.seconds !== key.seconds), key].sort((a, b) => a.seconds - b.seconds)\n}\n\n/** A track's keys without one; a track keeps at least its two ends, as the format asks. */\nexport function withoutKey(keys: readonly ClipKey[], index: number): ClipKey[] | null {\n if (keys.length <= 2 || index === 0) return null\n return keys.filter((_, i) => i !== index)\n}\n\n/** The edit that writes a track back. */\nexport const trackEdit = (clip: Clip, track: ClipTrack, patch: Partial<Pick<ClipTrack, 'keys' | 'loop' | 'interpolation'>> = {}) => ({\n kind: 'set-driver' as const,\n state: track.state,\n clip: clip.name,\n keys: patch.keys ?? track.keys,\n loop: patch.loop ?? track.loop,\n interpolation: patch.interpolation ?? track.interpolation\n})\n"]}
1
+ {"version":3,"file":"clips.js","sourceRoot":"","sources":["../../client/modeller/clips.ts"],"names":[],"mappings":"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","sourcesContent":["import { driverValue } from '@hatiolab/figure-model'\nimport type { FigureAsset, FigureDriver, FigureInputChange } from '@hatiolab/figure-model'\n\n/*\n * The figure's clips: controls played by keys over time, grouped into clips that each run on one\n * clock. Kept apart from the element so the reading is tested without a browser.\n */\nexport interface ClipKey {\n seconds: number\n value: number\n}\nexport interface ClipTrack {\n state: string\n label: string\n unit: string\n min: number\n max: number\n keys: ClipKey[]\n interpolation: 'linear' | 'step'\n loop: 'wrap' | 'hold'\n accumulates: boolean\n}\nexport interface Clip {\n name: string\n duration: number\n tracks: ClipTrack[]\n}\n\nconst round = (n: number) => Math.round(n * 1000) / 1000\n\nexport function clipsOf(asset: FigureAsset): Clip[] {\n const inputs = new Map(asset.document.model.inputs.map(i => [i.id, i]))\n const clips = new Map<string, Clip>()\n for (const d of asset.drivers ?? []) {\n const input = inputs.get(d.state)\n const clip = clips.get(d.clip) ?? { name: d.clip, duration: d.time.duration, tracks: [] }\n clip.tracks.push({\n state: d.state,\n label: asset.stateInputs?.[d.state]?.label ?? d.state,\n unit: input?.unit ?? '',\n min: input?.min ?? 0,\n max: input?.max ?? 0,\n keys: d.keys.map(k => ({ seconds: round(k.at * d.time.duration), value: k.value })),\n interpolation: d.interpolation,\n loop: d.loop,\n accumulates: d.accumulates\n })\n clips.set(d.clip, clip)\n }\n return [...clips.values()]\n}\n\n/**\n * The controls a new track of `clip` can take (ADR-0051 §3.2): one the clip does not play yet, and that no clip plays\n * outside the clip's motion group -- `group` for a clip not made yet. A clip in no group takes only a control no clip\n * plays. Without a clip, the controls no clip plays at all.\n */\nexport function undrivenOf(asset: FigureAsset, clip = '', group?: string): string[] {\n const groupOf = new Map((asset.motionGroups ?? []).flatMap(g => g.clips.map(c => [c, g.id] as const)))\n const mine = group ?? (clip ? groupOf.get(clip) : undefined)\n const taken = (state: string) =>\n (asset.drivers ?? []).some(d => d.state === state && (d.clip === clip || mine === undefined || groupOf.get(d.clip) !== mine))\n return asset.document.model.inputs.filter(i => i.role === 'state' && !taken(i.id)).map(i => i.id)\n}\n\n/** A motion group as the panel shows it: its setting, and the clips it holds in the clips' order. */\nexport interface ClipGroup {\n id: string\n label: string\n clips: string[]\n defaultClip: string\n transitionSeconds: number\n}\n\n/** The figure's motion groups, and the clips in none -- the ones a group can take. */\nexport function clipGroupsOf(asset: FigureAsset): { groups: ClipGroup[]; free: string[] } {\n const groups = (asset.motionGroups ?? []).map(g => ({ ...g, clips: [...g.clips] }))\n const grouped = new Set(groups.flatMap(g => g.clips))\n const free = [...new Set((asset.drivers ?? []).map(d => d.clip))].filter(c => !grouped.has(c))\n return { groups, free }\n}\n\n/** A group id not yet taken: `motion`, then `motion-2`, `motion-3`, … An id is not made from a label a person types. */\nexport function freeGroupIdOf(asset: FigureAsset): string {\n const taken = new Set((asset.motionGroups ?? []).map(g => g.id))\n if (!taken.has('motion')) return 'motion'\n let n = 2\n while (taken.has(`motion-${n}`)) n++\n return `motion-${n}`\n}\n\n/** The edit that writes a group back with a change: one whole group, as the kernel takes it. */\nexport const groupEdit = (group: ClipGroup, patch: Partial<Omit<ClipGroup, 'id'>> = {}) => ({\n kind: 'set-motion-group' as const,\n group: { id: group.id, label: patch.label ?? group.label, clips: patch.clips ?? group.clips, defaultClip: patch.defaultClip ?? group.defaultClip, transitionSeconds: patch.transitionSeconds ?? group.transitionSeconds }\n})\n\n/** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */\nexport function poseAt(asset: FigureAsset, clip: string, seconds: number): FigureInputChange[] {\n const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]))\n return (asset.drivers ?? [])\n .filter((d: FigureDriver) => d.clip === clip)\n .map(d => {\n const unit = units.get(d.state)!\n return { kind: 'set-state' as const, input: d.state, unit, value: driverValue(d, seconds, unit === 'deg' || unit === 'rad' ? unit : 'other') }\n })\n}\n\n/** A key added at a moment, replacing one already there; the keys stay in time order. */\nexport function withKey(keys: readonly ClipKey[], key: ClipKey): ClipKey[] {\n return [...keys.filter(k => k.seconds !== key.seconds), key].sort((a, b) => a.seconds - b.seconds)\n}\n\n/** A track's keys without one; a track keeps at least its two ends, as the format asks. */\nexport function withoutKey(keys: readonly ClipKey[], index: number): ClipKey[] | null {\n if (keys.length <= 2 || index === 0) return null\n return keys.filter((_, i) => i !== index)\n}\n\n/** The edit that writes a track back. */\nexport const trackEdit = (clip: Clip, track: ClipTrack, patch: Partial<Pick<ClipTrack, 'keys' | 'loop' | 'interpolation'>> = {}) => ({\n kind: 'set-driver' as const,\n state: track.state,\n clip: clip.name,\n keys: patch.keys ?? track.keys,\n loop: patch.loop ?? track.loop,\n interpolation: patch.interpolation ?? track.interpolation\n})\n"]}
@@ -0,0 +1,42 @@
1
+ import type { FigureAsset, FigureDataConnection, FigureDataProperty, FigureDataResult, FigureDataTarget, FigureDataValue, FigureDataValueIssue } from '@hatiolab/figure-model';
2
+ /** Something a connection can write, with what the screen shows of it. */
3
+ export interface DataTargetChoice {
4
+ /** One string per target, for a select's value. */
5
+ key: string;
6
+ target: FigureDataTarget;
7
+ /** The control's label, the colour token, or the group's label. */
8
+ name: string;
9
+ unit?: string;
10
+ min?: number;
11
+ max?: number;
12
+ }
13
+ export declare const targetKeyOf: (t: FigureDataTarget) => string;
14
+ /**
15
+ * 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
16
+ * moves a control, a colour or which clip of a group plays (through a case per value). A control is offered observed: a
17
+ * value read from the machine applies as it comes.
18
+ */
19
+ export declare function dataTargetsOf(asset: FigureAsset, kind: FigureDataProperty['kind']): DataTargetChoice[];
20
+ /** The values a lookup has a case for: an enum's values, or both booleans. Nothing for a number. */
21
+ export declare const domainOf: (property: FigureDataProperty) => FigureDataValue[];
22
+ /** An id not taken among `taken`: `base`, then `base-2`, `base-3`, … made into the kernel's id shape. */
23
+ export declare function freeDataIdOf(taken: Iterable<string>, base: string): string;
24
+ /**
25
+ * 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,
26
+ * or scaled from another; an enum or a boolean case by case, each case at what the target shows now -- the control's
27
+ * rest value, the colour, the group's first clip. The kernel says whether it holds.
28
+ */
29
+ export declare function connectionDraftOf(asset: FigureAsset, property: FigureDataProperty, choice: DataTargetChoice): FigureDataConnection;
30
+ /** What a property looks like when first made: a number 0 to 1, a boolean off, an enum of two values. */
31
+ export declare function propertyDraftOf(asset: FigureAsset, label: string, kind: FigureDataProperty['kind']): FigureDataProperty;
32
+ /** A value typed in a sample field, as the property takes it: a number, true or false, or the enum value. */
33
+ export declare function sampleValueOf(property: FigureDataProperty, typed: string): FigureDataValue;
34
+ /**
35
+ * What a sample of values would move, as the figure would run it: the kernel's check and evaluation of the interface.
36
+ * Refused whole when one value cannot be taken, with each one's reason, as a live snapshot is.
37
+ */
38
+ export declare function dataSampleOf(asset: FigureAsset, values: Record<string, FigureDataValue>): {
39
+ result: FigureDataResult;
40
+ } | {
41
+ issues: FigureDataValueIssue[];
42
+ };