@things-factory/figure-ui 10.1.155 → 10.1.157
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/clips.ts +43 -4
- package/client/modeller/data-interface.ts +110 -0
- package/client/modeller/editor.ts +16 -2
- package/client/modeller/figure-clips.ts +125 -12
- package/client/modeller/figure-data-panel.ts +308 -0
- package/client/modeller/figure-preview.ts +41 -4
- package/client/modeller/refusals.ts +46 -0
- package/dist-client/modeller/clips.d.ts +32 -2
- package/dist-client/modeller/clips.js +32 -4
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/data-interface.d.ts +42 -0
- package/dist-client/modeller/data-interface.js +95 -0
- package/dist-client/modeller/data-interface.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +3 -0
- package/dist-client/modeller/editor.js +18 -0
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +10 -0
- package/dist-client/modeller/figure-clips.js +125 -10
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +39 -0
- package/dist-client/modeller/figure-data-panel.js +317 -0
- package/dist-client/modeller/figure-data-panel.js.map +1 -0
- package/dist-client/modeller/figure-preview.d.ts +7 -0
- package/dist-client/modeller/figure-preview.js +49 -4
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +5 -0
- package/dist-client/modeller/refusals.js +58 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/data-motion.test.ts +135 -0
- package/translations/en.json +85 -2
- package/translations/es.json +84 -1
- package/translations/ja.json +85 -2
- package/translations/ko.json +85 -2
- package/translations/ms.json +85 -2
- package/translations/zh.json +85 -2
package/client/modeller/clips.ts
CHANGED
|
@@ -50,12 +50,51 @@ export function clipsOf(asset: FigureAsset): Clip[] {
|
|
|
50
50
|
return [...clips.values()]
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
/**
|
|
54
|
+
* 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
|
|
55
|
+
* outside the clip's motion group -- `group` for a clip not made yet. A clip in no group takes only a control no clip
|
|
56
|
+
* plays. Without a clip, the controls no clip plays at all.
|
|
57
|
+
*/
|
|
58
|
+
export function undrivenOf(asset: FigureAsset, clip = '', group?: string): string[] {
|
|
59
|
+
const groupOf = new Map((asset.motionGroups ?? []).flatMap(g => g.clips.map(c => [c, g.id] as const)))
|
|
60
|
+
const mine = group ?? (clip ? groupOf.get(clip) : undefined)
|
|
61
|
+
const taken = (state: string) =>
|
|
62
|
+
(asset.drivers ?? []).some(d => d.state === state && (d.clip === clip || mine === undefined || groupOf.get(d.clip) !== mine))
|
|
63
|
+
return asset.document.model.inputs.filter(i => i.role === 'state' && !taken(i.id)).map(i => i.id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A motion group as the panel shows it: its setting, and the clips it holds in the clips' order. */
|
|
67
|
+
export interface ClipGroup {
|
|
68
|
+
id: string
|
|
69
|
+
label: string
|
|
70
|
+
clips: string[]
|
|
71
|
+
defaultClip: string
|
|
72
|
+
transitionSeconds: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The figure's motion groups, and the clips in none -- the ones a group can take. */
|
|
76
|
+
export function clipGroupsOf(asset: FigureAsset): { groups: ClipGroup[]; free: string[] } {
|
|
77
|
+
const groups = (asset.motionGroups ?? []).map(g => ({ ...g, clips: [...g.clips] }))
|
|
78
|
+
const grouped = new Set(groups.flatMap(g => g.clips))
|
|
79
|
+
const free = [...new Set((asset.drivers ?? []).map(d => d.clip))].filter(c => !grouped.has(c))
|
|
80
|
+
return { groups, free }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A group id not yet taken: `motion`, then `motion-2`, `motion-3`, … An id is not made from a label a person types. */
|
|
84
|
+
export function freeGroupIdOf(asset: FigureAsset): string {
|
|
85
|
+
const taken = new Set((asset.motionGroups ?? []).map(g => g.id))
|
|
86
|
+
if (!taken.has('motion')) return 'motion'
|
|
87
|
+
let n = 2
|
|
88
|
+
while (taken.has(`motion-${n}`)) n++
|
|
89
|
+
return `motion-${n}`
|
|
57
90
|
}
|
|
58
91
|
|
|
92
|
+
/** The edit that writes a group back with a change: one whole group, as the kernel takes it. */
|
|
93
|
+
export const groupEdit = (group: ClipGroup, patch: Partial<Omit<ClipGroup, 'id'>> = {}) => ({
|
|
94
|
+
kind: 'set-motion-group' as const,
|
|
95
|
+
group: { id: group.id, label: patch.label ?? group.label, clips: patch.clips ?? group.clips, defaultClip: patch.defaultClip ?? group.defaultClip, transitionSeconds: patch.transitionSeconds ?? group.transitionSeconds }
|
|
96
|
+
})
|
|
97
|
+
|
|
59
98
|
/** The pose a clip gives at a moment, as one batch of state changes -- one step of the history. */
|
|
60
99
|
export function poseAt(asset: FigureAsset, clip: string, seconds: number): FigureInputChange[] {
|
|
61
100
|
const units = new Map(asset.document.model.inputs.map(i => [i.id, i.unit]))
|
|
@@ -0,0 +1,110 @@
|
|
|
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.
|
|
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'
|
|
9
|
+
|
|
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. */
|
|
13
|
+
key: string
|
|
14
|
+
target: FigureDataTarget
|
|
15
|
+
/** The control's label, the colour token, or the group's label. */
|
|
16
|
+
name: string
|
|
17
|
+
unit?: string
|
|
18
|
+
min?: number
|
|
19
|
+
max?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const targetKeyOf = (t: FigureDataTarget): string =>
|
|
23
|
+
t.kind === 'state' ? `state:${t.input}` : t.kind === 'palette' ? `palette:${t.token}` : `${t.kind}:${t.group}`
|
|
24
|
+
|
|
25
|
+
/**
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
export function dataTargetsOf(asset: FigureAsset, kind: FigureDataProperty['kind']): DataTargetChoice[] {
|
|
31
|
+
const out: DataTargetChoice[] = []
|
|
32
|
+
for (const input of asset.document.model.inputs) {
|
|
33
|
+
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 })
|
|
36
|
+
}
|
|
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
|
+
}
|
|
42
|
+
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 })
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
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] : []
|
|
52
|
+
|
|
53
|
+
/** 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 {
|
|
55
|
+
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'
|
|
58
|
+
if (!used.has(id)) return id
|
|
59
|
+
let n = 2
|
|
60
|
+
while (used.has(`${id}-${n}`)) n++
|
|
61
|
+
return `${id}-${n}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
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.
|
|
68
|
+
*/
|
|
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 }
|
|
76
|
+
}
|
|
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
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
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.
|
|
104
|
+
*/
|
|
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) }
|
|
110
|
+
}
|
|
@@ -24,6 +24,7 @@ import { motionEndsOf, type MotionEnds } from './motion-ends.js'
|
|
|
24
24
|
import './figure-links.js'
|
|
25
25
|
import './figure-controls.js'
|
|
26
26
|
import './figure-clips.js'
|
|
27
|
+
import './figure-data-panel.js'
|
|
27
28
|
import './figure-preview.js'
|
|
28
29
|
import './figure-settings.js'
|
|
29
30
|
import { DEFAULT_VIEW, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
|
|
@@ -68,10 +69,10 @@ import { startingShapeKindOfBody } from './starting-shape.js'
|
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
* The side's tabs: properties, what it does (places), motion, the verdict, the view, the release history. A figure-level tab (verdict, history) gives way to properties when a part is chosen; a tab about
|
|
72
|
+
* The side's tabs: properties, what it does (places), motion, its data (ADR-0051), the verdict, the view, the release history. A figure-level tab (verdict, history) gives way to properties when a part is chosen; a tab about
|
|
72
73
|
* parts keeps showing the newly chosen part.
|
|
73
74
|
*/
|
|
74
|
-
type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
|
|
75
|
+
type SideTab = 'properties' | 'capability' | 'motion' | 'data' | 'verdict' | 'view' | 'history'
|
|
75
76
|
|
|
76
77
|
/** Product editor surface. The injected session is shared by page persistence and AI commands. */
|
|
77
78
|
const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS }
|
|
@@ -749,6 +750,8 @@ export class FigureEditor extends LitElement {
|
|
|
749
750
|
return this.lastAnchors
|
|
750
751
|
}
|
|
751
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
|
|
752
755
|
/** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
|
|
753
756
|
private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
|
|
754
757
|
const asset = this.shown()?.asset
|
|
@@ -1549,6 +1552,7 @@ export class FigureEditor extends LitElement {
|
|
|
1549
1552
|
.context=${view.context ?? ''}
|
|
1550
1553
|
.view=${this.view}
|
|
1551
1554
|
.selected=${this.selected}
|
|
1555
|
+
.sample=${this.dataSample}
|
|
1552
1556
|
@configure-size=${async (e: CustomEvent<{ part: string }>) => {
|
|
1553
1557
|
this.middle = 'edit'
|
|
1554
1558
|
this.selected = e.detail.part
|
|
@@ -1585,6 +1589,7 @@ export class FigureEditor extends LitElement {
|
|
|
1585
1589
|
${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
|
|
1586
1590
|
${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
|
|
1587
1591
|
${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
|
|
1592
|
+
${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
|
|
1588
1593
|
${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
|
|
1589
1594
|
${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
|
|
1590
1595
|
${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
|
|
@@ -1656,6 +1661,15 @@ export class FigureEditor extends LitElement {
|
|
|
1656
1661
|
${this.selected && part ? this.renderLinks(unavailable, 'motion') : html`<section><p hint>${i18next.t('figure.text.choose-a-part-to-move')}</p></section>`}
|
|
1657
1662
|
${this.renderControls(view, unavailable)}
|
|
1658
1663
|
</div>
|
|
1664
|
+
<div panel ?off=${tab !== 'data'}>
|
|
1665
|
+
${this.renderSubject()}
|
|
1666
|
+
<figure-data-panel
|
|
1667
|
+
.asset=${asset}
|
|
1668
|
+
?locked=${unavailable}
|
|
1669
|
+
@data-sample=${(e: CustomEvent) => (this.dataSample = e.detail.values)}
|
|
1670
|
+
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
1671
|
+
></figure-data-panel>
|
|
1672
|
+
</div>
|
|
1659
1673
|
<div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
|
|
1660
1674
|
<div panel flush ?off=${tab !== 'view'}>
|
|
1661
1675
|
<figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>
|
|
@@ -6,7 +6,7 @@ import { customElement, property, state } from 'lit/decorators.js'
|
|
|
6
6
|
import { i18next, localize } from '@operato/i18n'
|
|
7
7
|
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
8
8
|
|
|
9
|
-
import { clipsOf, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipTrack } from './clips.js'
|
|
9
|
+
import { clipGroupsOf, clipsOf, freeGroupIdOf, groupEdit, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipGroup, type ClipTrack } from './clips.js'
|
|
10
10
|
import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
|
|
11
11
|
import { HelpToggles } from './help.js'
|
|
12
12
|
|
|
@@ -15,6 +15,9 @@ import { HelpToggles } from './help.js'
|
|
|
15
15
|
* control. Scrubbing a clip shows its pose at that moment (one batch of input changes, not saved as the default); a key
|
|
16
16
|
* is added at the scrubbed moment with the control's value there, so a pose is made with the controls' sliders and
|
|
17
17
|
* then kept as a key. Every change is one part edit.
|
|
18
|
+
*
|
|
19
|
+
* Clips of one motion group play one at a time on the same controls -- idle, walk, run (ADR-0051 §3.2): only clips of
|
|
20
|
+
* one group may play the same control, so a new clip is put in a group as it is made when it is to take one.
|
|
18
21
|
*/
|
|
19
22
|
@customElement('figure-clips')
|
|
20
23
|
export class FigureClips extends localize(i18next)(LitElement) {
|
|
@@ -98,7 +101,8 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
98
101
|
|
|
99
102
|
/** Where each clip is scrubbed to, in seconds. */
|
|
100
103
|
@state() private at: Record<string, number> = {}
|
|
101
|
-
@state() private adding = { state: '', clip: '', name: '', duration: 2 }
|
|
104
|
+
@state() private adding = { state: '', clip: '', name: '', duration: 2, group: '' }
|
|
105
|
+
@state() private grouping = { clip: '', label: '' }
|
|
102
106
|
|
|
103
107
|
render() {
|
|
104
108
|
const asset = this.asset
|
|
@@ -110,7 +114,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
110
114
|
${this.help.hint('figure.text.clips-explained')}
|
|
111
115
|
${clips.map(clip => this.renderClip(clip))}
|
|
112
116
|
</section>
|
|
113
|
-
${this.renderAdd(asset, clips)}
|
|
117
|
+
${this.renderAdd(asset, clips)} ${this.renderGroups(asset)}
|
|
114
118
|
`
|
|
115
119
|
}
|
|
116
120
|
|
|
@@ -155,7 +159,7 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
155
159
|
<option value="wrap" ?selected=${track.loop === 'wrap'}>${i18next.t('figure.label.clip-loop-wrap')}</option>
|
|
156
160
|
<option value="hold" ?selected=${track.loop === 'hold'}>${i18next.t('figure.label.clip-loop-hold')}</option>
|
|
157
161
|
</select>
|
|
158
|
-
<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state })}>
|
|
162
|
+
<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state, clip: clip.name })}>
|
|
159
163
|
<md-icon>close</md-icon>
|
|
160
164
|
</button>
|
|
161
165
|
</div>
|
|
@@ -185,19 +189,22 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
private renderAdd(asset: FigureAsset, clips: Clip[]) {
|
|
188
|
-
const free = undrivenOf(asset)
|
|
189
|
-
if (!free.length) return clips.length ? nothing : html`<p hint>${i18next.t('figure.text.no-controls')}</p>`
|
|
190
192
|
const a = this.adding
|
|
191
|
-
const stateId = free.includes(a.state) ? a.state : free[0]!
|
|
192
193
|
const joining = clips.find(c => c.name === a.clip)
|
|
194
|
+
const { groups } = clipGroupsOf(asset)
|
|
195
|
+
const group = joining ? undefined : groups.find(g => g.id === a.group)
|
|
196
|
+
/* The controls this clip may take: not played by it yet, and by no clip outside its group. */
|
|
197
|
+
const free = undrivenOf(asset, joining?.name ?? '', group?.id)
|
|
198
|
+
if (!free.length && !clips.length && !groups.length) return html`<p hint>${i18next.t('figure.text.no-controls')}</p>`
|
|
199
|
+
const stateId = free.includes(a.state) ? a.state : free[0]
|
|
193
200
|
const name = joining ? joining.name : a.name.trim()
|
|
194
|
-
const input = asset.document.model.inputs.find(i => i.id === stateId)
|
|
201
|
+
const input = asset.document.model.inputs.find(i => i.id === stateId)
|
|
195
202
|
const duration = joining ? joining.duration : a.duration
|
|
196
|
-
const ready = !!name && duration > 0
|
|
203
|
+
const ready = !!name && duration > 0 && !!input
|
|
197
204
|
return html`<section><h3>${i18next.t('figure.label.add-track')}</h3>
|
|
198
205
|
<div form>
|
|
199
206
|
<span>${i18next.t('figure.label.controls')}</span>
|
|
200
|
-
<select @change=${(e: Event) => (this.adding = { ...a, state: (e.target as HTMLSelectElement).value })}>
|
|
207
|
+
<select ?disabled=${!free.length} @change=${(e: Event) => (this.adding = { ...a, state: (e.target as HTMLSelectElement).value })}>
|
|
201
208
|
${free.map(id => html`<option value=${id} ?selected=${id === stateId}>${asset.stateInputs?.[id]?.label ?? id}</option>`)}
|
|
202
209
|
</select>
|
|
203
210
|
<span>${i18next.t('figure.label.clip')}</span>
|
|
@@ -205,6 +212,13 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
205
212
|
<option value="" ?selected=${!joining}>${i18next.t('figure.label.new-clip')}</option>
|
|
206
213
|
${clips.map(c => html`<option value=${c.name} ?selected=${c === joining}>${c.name}</option>`)}
|
|
207
214
|
</select>
|
|
215
|
+
${joining || !groups.length
|
|
216
|
+
? nothing
|
|
217
|
+
: html`<span>${i18next.t('figure.label.motion-group')}</span>
|
|
218
|
+
<select @change=${(e: Event) => (this.adding = { ...a, group: (e.target as HTMLSelectElement).value })}>
|
|
219
|
+
<option value="" ?selected=${!group}>${i18next.t('figure.label.no-motion-group')}</option>
|
|
220
|
+
${groups.map(g => html`<option value=${g.id} ?selected=${g === group}>${g.label}</option>`)}
|
|
221
|
+
</select>`}
|
|
208
222
|
${joining
|
|
209
223
|
? nothing
|
|
210
224
|
: html`<span>${i18next.t('figure.label.clip-name')}</span>
|
|
@@ -221,9 +235,10 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
221
235
|
state: stateId,
|
|
222
236
|
clip: name,
|
|
223
237
|
...(joining ? {} : { duration }),
|
|
238
|
+
...(group ? { group: group.id } : {}),
|
|
224
239
|
keys: [
|
|
225
|
-
{ seconds: 0, value: input
|
|
226
|
-
{ seconds: duration, value: input
|
|
240
|
+
{ seconds: 0, value: input!.min },
|
|
241
|
+
{ seconds: duration, value: input!.max }
|
|
227
242
|
]
|
|
228
243
|
})}
|
|
229
244
|
>
|
|
@@ -231,6 +246,104 @@ export class FigureClips extends localize(i18next)(LitElement) {
|
|
|
231
246
|
</button></section>`
|
|
232
247
|
}
|
|
233
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The motion groups: a name, the clip that plays until another is chosen, how long a change of clip blends, and the
|
|
251
|
+
* clips each holds. A group is made from a clip in none; a clip in none joins one here. Every change is one edit.
|
|
252
|
+
*/
|
|
253
|
+
private renderGroups(asset: FigureAsset) {
|
|
254
|
+
const { groups, free } = clipGroupsOf(asset)
|
|
255
|
+
if (!groups.length && !free.length) return nothing
|
|
256
|
+
const g = this.grouping
|
|
257
|
+
const first = free.includes(g.clip) ? g.clip : free[0]
|
|
258
|
+
return html`<section>
|
|
259
|
+
<h3>${i18next.t('figure.label.motion-groups')}${this.help.button('figure.text.motion-groups-explained')}</h3>
|
|
260
|
+
${this.help.hint('figure.text.motion-groups-explained')}
|
|
261
|
+
${groups.map(group => this.renderGroup(group, free))}
|
|
262
|
+
${free.length
|
|
263
|
+
? html`<div form>
|
|
264
|
+
<span>${i18next.t('figure.label.motion-group-name')}</span>
|
|
265
|
+
<input .value=${g.label} placeholder=${i18next.t('figure.label.motion-group')} @input=${(e: Event) => (this.grouping = { ...g, label: (e.target as HTMLInputElement).value })} />
|
|
266
|
+
<span>${i18next.t('figure.label.clip')}</span>
|
|
267
|
+
<select @change=${(e: Event) => (this.grouping = { ...g, clip: (e.target as HTMLSelectElement).value })}>
|
|
268
|
+
${free.map(c => html`<option value=${c} ?selected=${c === first}>${c}</option>`)}
|
|
269
|
+
</select>
|
|
270
|
+
</div>
|
|
271
|
+
<button
|
|
272
|
+
act
|
|
273
|
+
?disabled=${this.locked || !first || !g.label.trim()}
|
|
274
|
+
@click=${() => {
|
|
275
|
+
this.edit({ kind: 'set-motion-group', group: { id: freeGroupIdOf(asset), label: g.label.trim(), clips: [first!], defaultClip: first!, transitionSeconds: 0.2 } })
|
|
276
|
+
this.grouping = { clip: '', label: '' }
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-motion-group')}
|
|
280
|
+
</button>`
|
|
281
|
+
: nothing}
|
|
282
|
+
</section>`
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private renderGroup(group: ClipGroup, free: string[]) {
|
|
286
|
+
const value = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value
|
|
287
|
+
return html`<section clip>
|
|
288
|
+
<div track-head>
|
|
289
|
+
<strong>${group.label}</strong>
|
|
290
|
+
<span></span>
|
|
291
|
+
<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-motion-group')} @click=${() => this.edit({ kind: 'remove-motion-group', id: group.id })}>
|
|
292
|
+
<md-icon>close</md-icon>
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
<div form>
|
|
296
|
+
<span>${i18next.t('figure.label.motion-group-name')}</span>
|
|
297
|
+
<input .value=${group.label} ?disabled=${this.locked} @change=${(e: Event) => value(e).trim() && this.edit(groupEdit(group, { label: value(e).trim() }))} />
|
|
298
|
+
<span>${i18next.t('figure.label.motion-group-default')}</span>
|
|
299
|
+
<select ?disabled=${this.locked} @change=${(e: Event) => this.edit(groupEdit(group, { defaultClip: value(e) }))}>
|
|
300
|
+
${group.clips.map(c => html`<option value=${c} ?selected=${c === group.defaultClip}>${c}</option>`)}
|
|
301
|
+
</select>
|
|
302
|
+
<span>${i18next.t('figure.label.motion-group-transition')}</span>
|
|
303
|
+
<input
|
|
304
|
+
type="number"
|
|
305
|
+
min="0"
|
|
306
|
+
step="any"
|
|
307
|
+
.value=${String(group.transitionSeconds)}
|
|
308
|
+
?disabled=${this.locked}
|
|
309
|
+
@change=${(e: Event) => this.edit(groupEdit(group, { transitionSeconds: (e.target as HTMLInputElement).valueAsNumber }))}
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
<div keys>
|
|
313
|
+
${group.clips.map(
|
|
314
|
+
c => html`<span key
|
|
315
|
+
>${c}
|
|
316
|
+
<button
|
|
317
|
+
?disabled=${this.locked || group.clips.length < 2}
|
|
318
|
+
title=${i18next.t('figure.button.remove-clip-from-group')}
|
|
319
|
+
aria-label=${i18next.t('figure.button.remove-clip-from-group')}
|
|
320
|
+
@click=${() => {
|
|
321
|
+
const clips = group.clips.filter(x => x !== c)
|
|
322
|
+
this.edit(groupEdit(group, { clips, defaultClip: group.defaultClip === c ? clips[0]! : group.defaultClip }))
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<md-icon>close</md-icon>
|
|
326
|
+
</button></span
|
|
327
|
+
>`
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
${free.length
|
|
331
|
+
? html`<select
|
|
332
|
+
aria-label=${i18next.t('figure.button.add-clip-to-group')}
|
|
333
|
+
?disabled=${this.locked}
|
|
334
|
+
@change=${(e: Event) => {
|
|
335
|
+
const c = value(e)
|
|
336
|
+
if (c) this.edit(groupEdit(group, { clips: [...group.clips, c] }))
|
|
337
|
+
;(e.target as HTMLSelectElement).value = ''
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
<option value="" selected>${i18next.t('figure.button.add-clip-to-group')}</option>
|
|
341
|
+
${free.map(c => html`<option value=${c}>${c}</option>`)}
|
|
342
|
+
</select>`
|
|
343
|
+
: nothing}
|
|
344
|
+
</section>`
|
|
345
|
+
}
|
|
346
|
+
|
|
234
347
|
/** Show the clip's pose at a moment: one batch of input changes, not saved as the default. */
|
|
235
348
|
/** Show the clip's pose at a moment. While the bar is dragged the pose is live (drawn, not recorded); the release
|
|
236
349
|
* applies it as one batch of input changes. */
|