@things-factory/figure-ui 10.1.46 → 10.1.47
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/angle-dial.ts +70 -0
- package/client/modeller/figure-settings.ts +328 -0
- package/client/modeller/figure-v3-capability.ts +137 -0
- package/client/modeller/figure-v3-clips.ts +253 -0
- package/client/modeller/figure-v3-controls.ts +212 -0
- package/client/modeller/figure-v3-links.ts +61 -81
- package/client/modeller/figure-v3-material.ts +108 -35
- package/client/modeller/figure-v3-parts.ts +96 -3
- package/client/modeller/figure-v3-preview.ts +326 -0
- package/client/modeller/figure-v3-properties.ts +29 -57
- package/client/modeller/figure-v3-verdict.ts +598 -60
- package/client/modeller/figure-view.ts +152 -0
- package/client/modeller/side-panel-styles.ts +326 -0
- package/client/modeller/v3-canvas.ts +221 -263
- package/client/modeller/v3-clips.ts +89 -0
- package/client/modeller/v3-controls.ts +54 -0
- package/client/modeller/v3-editor.ts +531 -223
- package/client/modeller/v3-fields.ts +62 -0
- package/client/modeller/v3-help.ts +41 -0
- package/client/modeller/v3-keys.ts +34 -0
- package/client/modeller/v3-links.ts +93 -9
- package/client/modeller/v3-live.ts +79 -0
- package/client/modeller/v3-maturity.ts +415 -0
- package/client/modeller/v3-palette.ts +45 -0
- package/client/modeller/v3-preview-model.ts +54 -0
- package/client/modeller/v3-stage-model.ts +159 -0
- package/client/pages/figure-list-page.ts +4 -4
- package/client/pages/figure-v3-modeller-page.ts +66 -134
- package/dist-client/modeller/angle-dial.d.ts +32 -0
- package/dist-client/modeller/angle-dial.js +41 -0
- package/dist-client/modeller/angle-dial.js.map +1 -0
- package/dist-client/modeller/figure-settings.d.ts +39 -0
- package/dist-client/modeller/figure-settings.js +317 -0
- package/dist-client/modeller/figure-settings.js.map +1 -0
- package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
- package/dist-client/modeller/figure-v3-capability.js +141 -0
- package/dist-client/modeller/figure-v3-capability.js.map +1 -0
- package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
- package/dist-client/modeller/figure-v3-clips.js +268 -0
- package/dist-client/modeller/figure-v3-clips.js.map +1 -0
- package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
- package/dist-client/modeller/figure-v3-controls.js +226 -0
- package/dist-client/modeller/figure-v3-controls.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +5 -2
- package/dist-client/modeller/figure-v3-links.js +64 -82
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +5 -2
- package/dist-client/modeller/figure-v3-material.js +109 -35
- package/dist-client/modeller/figure-v3-material.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
- package/dist-client/modeller/figure-v3-parts.js +88 -3
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
- package/dist-client/modeller/figure-v3-preview.js +322 -0
- package/dist-client/modeller/figure-v3-preview.js.map +1 -0
- package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
- package/dist-client/modeller/figure-v3-properties.js +29 -57
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
- package/dist-client/modeller/figure-v3-verdict.js +579 -59
- package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +118 -0
- package/dist-client/modeller/figure-view.js +78 -0
- package/dist-client/modeller/figure-view.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.d.ts +23 -0
- package/dist-client/modeller/side-panel-styles.js +326 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +29 -37
- package/dist-client/modeller/v3-canvas.js +188 -260
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +39 -0
- package/dist-client/modeller/v3-clips.js +58 -0
- package/dist-client/modeller/v3-clips.js.map +1 -0
- package/dist-client/modeller/v3-controls.d.ts +28 -0
- package/dist-client/modeller/v3-controls.js +28 -0
- package/dist-client/modeller/v3-controls.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +58 -1
- package/dist-client/modeller/v3-editor.js +555 -218
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +30 -0
- package/dist-client/modeller/v3-fields.js +46 -0
- package/dist-client/modeller/v3-fields.js.map +1 -0
- package/dist-client/modeller/v3-help.d.ts +18 -0
- package/dist-client/modeller/v3-help.js +37 -0
- package/dist-client/modeller/v3-help.js.map +1 -0
- package/dist-client/modeller/v3-keys.d.ts +14 -0
- package/dist-client/modeller/v3-keys.js +23 -0
- package/dist-client/modeller/v3-keys.js.map +1 -0
- package/dist-client/modeller/v3-links.d.ts +30 -1
- package/dist-client/modeller/v3-links.js +94 -8
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +31 -0
- package/dist-client/modeller/v3-live.js +58 -0
- package/dist-client/modeller/v3-live.js.map +1 -0
- package/dist-client/modeller/v3-maturity.d.ts +120 -0
- package/dist-client/modeller/v3-maturity.js +317 -0
- package/dist-client/modeller/v3-maturity.js.map +1 -0
- package/dist-client/modeller/v3-palette.d.ts +7 -0
- package/dist-client/modeller/v3-palette.js +42 -0
- package/dist-client/modeller/v3-palette.js.map +1 -0
- package/dist-client/modeller/v3-preview-model.d.ts +32 -0
- package/dist-client/modeller/v3-preview-model.js +48 -0
- package/dist-client/modeller/v3-preview-model.js.map +1 -0
- package/dist-client/modeller/v3-stage-model.d.ts +31 -0
- package/dist-client/modeller/v3-stage-model.js +121 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -0
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
- package/dist-client/pages/figure-v3-modeller-page.js +64 -136
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
- package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/v3-clips.test.ts +70 -0
- package/test/v3-controls.test.ts +68 -0
- package/test/v3-fields.test.ts +62 -0
- package/test/v3-gizmo.test.ts +3 -5
- package/test/v3-keys.test.ts +37 -0
- package/test/v3-layout.test.ts +78 -0
- package/test/v3-links.test.ts +77 -6
- package/test/v3-live.test.ts +99 -0
- package/test/v3-material.test.ts +6 -0
- package/test/v3-maturity.test.ts +268 -0
- package/test/v3-parts-wiring.test.ts +3 -1
- package/test/v3-preview.test.ts +42 -0
- package/test/v3-properties.test.ts +4 -2
- package/test/v3-scene-canvas.test.ts +85 -0
- package/test/v3-stage-model.test.ts +25 -0
- package/test/v3-theme.test.ts +16 -25
- package/test/v3-verdict.test.ts +12 -6
- package/test/v3-view.test.ts +20 -0
- package/translations/en.json +84 -5
- package/translations/ja.json +86 -7
- package/translations/ko.json +84 -5
- package/translations/ms.json +86 -7
- package/translations/zh.json +86 -7
|
@@ -0,0 +1,253 @@
|
|
|
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 type { V3Asset } from '@hatiolab/figure-model'
|
|
8
|
+
|
|
9
|
+
import { clipsOf, poseAt, trackEdit, undrivenOf, withKey, withoutKey, type Clip, type ClipTrack } from './v3-clips.js'
|
|
10
|
+
import { SidePanelStyles } from './side-panel-styles.js'
|
|
11
|
+
import { HelpToggles } from './v3-help.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 클립 — V2's animations (parity row ⑥): controls played by keys over time. A clip is one clock; each track plays one
|
|
15
|
+
* control. Scrubbing a clip shows its pose at that moment (one batch of input changes, not saved as the default); a key
|
|
16
|
+
* is added at the scrubbed moment with the control's value there, so a pose is made with the controls' sliders and
|
|
17
|
+
* then kept as a key -- the way V2's recorder worked. Every change is one part edit.
|
|
18
|
+
*/
|
|
19
|
+
@customElement('figure-v3-clips')
|
|
20
|
+
export class FigureV3Clips extends localize(i18next)(LitElement) {
|
|
21
|
+
private readonly help = new HelpToggles(this)
|
|
22
|
+
static styles = [SidePanelStyles, css`
|
|
23
|
+
:host {
|
|
24
|
+
display: block;
|
|
25
|
+
}
|
|
26
|
+
section[clip] {
|
|
27
|
+
display: grid;
|
|
28
|
+
gap: 8px;
|
|
29
|
+
padding: 8px;
|
|
30
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
31
|
+
border-radius: 8px;
|
|
32
|
+
}
|
|
33
|
+
div[row] {
|
|
34
|
+
display: grid;
|
|
35
|
+
grid-template-columns: minmax(0, 1fr) 72px;
|
|
36
|
+
gap: 6px;
|
|
37
|
+
align-items: center;
|
|
38
|
+
}
|
|
39
|
+
div[track] {
|
|
40
|
+
display: grid;
|
|
41
|
+
gap: 4px;
|
|
42
|
+
padding-top: 6px;
|
|
43
|
+
border-top: 1px dashed var(--md-sys-color-outline-variant);
|
|
44
|
+
}
|
|
45
|
+
/*
|
|
46
|
+
The track's name on its own line, then how it repeats and the button that removes it. A flex row let the shared
|
|
47
|
+
full-width select and button squeeze the name to one letter a line (user, 2026-09-24).
|
|
48
|
+
*/
|
|
49
|
+
div[track-head] {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: minmax(0, 1fr) 30px;
|
|
52
|
+
gap: 4px 6px;
|
|
53
|
+
align-items: center;
|
|
54
|
+
}
|
|
55
|
+
div[track-head] strong {
|
|
56
|
+
grid-column: 1 / -1;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
overflow-wrap: anywhere;
|
|
59
|
+
}
|
|
60
|
+
div[track-head] button[act] {
|
|
61
|
+
width: 30px;
|
|
62
|
+
min-height: var(--figure-control-height);
|
|
63
|
+
margin: 0;
|
|
64
|
+
padding: 0;
|
|
65
|
+
}
|
|
66
|
+
div[keys] {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-wrap: wrap;
|
|
69
|
+
gap: 4px;
|
|
70
|
+
}
|
|
71
|
+
span[key] {
|
|
72
|
+
display: inline-flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 2px;
|
|
75
|
+
padding: 0 2px 0 8px;
|
|
76
|
+
border-radius: 999em;
|
|
77
|
+
background: var(--md-sys-color-surface-container);
|
|
78
|
+
font-variant-numeric: tabular-nums;
|
|
79
|
+
}
|
|
80
|
+
span[key] button {
|
|
81
|
+
width: 22px;
|
|
82
|
+
height: 22px;
|
|
83
|
+
padding: 0;
|
|
84
|
+
border: none;
|
|
85
|
+
background: none;
|
|
86
|
+
}
|
|
87
|
+
md-icon {
|
|
88
|
+
font-size: 16px;
|
|
89
|
+
width: 16px;
|
|
90
|
+
height: 16px;
|
|
91
|
+
}
|
|
92
|
+
`]
|
|
93
|
+
|
|
94
|
+
@property({ attribute: false }) asset?: V3Asset
|
|
95
|
+
/** The values the editor holds now, by input id -- what a new key takes. */
|
|
96
|
+
@property({ attribute: false }) values: Record<string, number> = {}
|
|
97
|
+
@property({ type: Boolean }) locked = false
|
|
98
|
+
|
|
99
|
+
/** Where each clip is scrubbed to, in seconds. */
|
|
100
|
+
@state() private at: Record<string, number> = {}
|
|
101
|
+
@state() private adding = { state: '', clip: '', name: '', duration: 2 }
|
|
102
|
+
|
|
103
|
+
render() {
|
|
104
|
+
const asset = this.asset
|
|
105
|
+
if (!asset) return nothing
|
|
106
|
+
const clips = clipsOf(asset)
|
|
107
|
+
return html`
|
|
108
|
+
<section>
|
|
109
|
+
<h3>${i18next.t('figure.label.clips')}${this.help.button('figure.text.clips-explained')}</h3>
|
|
110
|
+
${this.help.hint('figure.text.clips-explained')}
|
|
111
|
+
${clips.map(clip => this.renderClip(clip))}
|
|
112
|
+
</section>
|
|
113
|
+
${this.renderAdd(asset, clips)}
|
|
114
|
+
`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private renderClip(clip: Clip) {
|
|
118
|
+
const at = Math.min(this.at[clip.name] ?? 0, clip.duration)
|
|
119
|
+
const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
|
|
120
|
+
return html`<section clip>
|
|
121
|
+
<div form>
|
|
122
|
+
<span>${i18next.t('figure.label.clip-name')}</span>
|
|
123
|
+
<input .value=${clip.name} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ kind: 'set-clip', clip: clip.name, name: (e.target as HTMLInputElement).value })} />
|
|
124
|
+
<span>${i18next.t('figure.label.clip-duration')}</span>
|
|
125
|
+
<input type="number" min="0.1" step="any" .value=${String(clip.duration)} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ kind: 'set-clip', clip: clip.name, duration: number(e) })} />
|
|
126
|
+
</div>
|
|
127
|
+
<div row>
|
|
128
|
+
<input
|
|
129
|
+
type="range"
|
|
130
|
+
min="0"
|
|
131
|
+
max=${clip.duration}
|
|
132
|
+
step=${clip.duration / 100}
|
|
133
|
+
.value=${String(at)}
|
|
134
|
+
?disabled=${this.locked}
|
|
135
|
+
aria-label=${i18next.t('figure.label.clip-time')}
|
|
136
|
+
@input=${(e: Event) => this.scrub(clip, number(e), true)}
|
|
137
|
+
@change=${(e: Event) => this.scrub(clip, number(e))}
|
|
138
|
+
/>
|
|
139
|
+
<small>${at.toFixed(2)} s</small>
|
|
140
|
+
</div>
|
|
141
|
+
${clip.tracks.map(track => this.renderTrack(clip, track, at))}
|
|
142
|
+
</section>`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private renderTrack(clip: Clip, track: ClipTrack, at: number) {
|
|
146
|
+
const current = this.values[track.state]
|
|
147
|
+
return html`<div track>
|
|
148
|
+
<div track-head>
|
|
149
|
+
<strong>${track.label} <small>(${track.unit})</small></strong>
|
|
150
|
+
<select
|
|
151
|
+
aria-label=${i18next.t('figure.label.clip-loop')}
|
|
152
|
+
?disabled=${this.locked}
|
|
153
|
+
@change=${(e: Event) => this.edit(trackEdit(clip, track, { loop: (e.target as HTMLSelectElement).value as 'wrap' | 'hold' }))}
|
|
154
|
+
>
|
|
155
|
+
<option value="wrap" ?selected=${track.loop === 'wrap'}>${i18next.t('figure.label.clip-loop-wrap')}</option>
|
|
156
|
+
<option value="hold" ?selected=${track.loop === 'hold'}>${i18next.t('figure.label.clip-loop-hold')}</option>
|
|
157
|
+
</select>
|
|
158
|
+
<button act ?disabled=${this.locked} title=${i18next.t('figure.button.remove-track')} @click=${() => this.edit({ kind: 'remove-driver', state: track.state })}>
|
|
159
|
+
<md-icon>close</md-icon>
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
<div keys>
|
|
163
|
+
${track.keys.map(
|
|
164
|
+
(key, i) => html`<span key
|
|
165
|
+
>${key.seconds}s → ${key.value}
|
|
166
|
+
<button
|
|
167
|
+
?disabled=${this.locked || !withoutKey(track.keys, i)}
|
|
168
|
+
title=${i18next.t('figure.button.remove-key')}
|
|
169
|
+
aria-label=${i18next.t('figure.button.remove-key')}
|
|
170
|
+
@click=${() => this.edit(trackEdit(clip, track, { keys: withoutKey(track.keys, i)! }))}
|
|
171
|
+
>
|
|
172
|
+
<md-icon>close</md-icon>
|
|
173
|
+
</button></span
|
|
174
|
+
>`
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
<button
|
|
178
|
+
act
|
|
179
|
+
?disabled=${this.locked || current === undefined}
|
|
180
|
+
@click=${() => this.edit(trackEdit(clip, track, { keys: withKey(track.keys, { seconds: Math.round(at * 1000) / 1000, value: current! }) }))}
|
|
181
|
+
>
|
|
182
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-key-here', { seconds: at.toFixed(2), value: current ?? '' })}
|
|
183
|
+
</button>
|
|
184
|
+
</div>`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private renderAdd(asset: V3Asset, 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
|
+
const a = this.adding
|
|
191
|
+
const stateId = free.includes(a.state) ? a.state : free[0]!
|
|
192
|
+
const joining = clips.find(c => c.name === a.clip)
|
|
193
|
+
const name = joining ? joining.name : a.name.trim()
|
|
194
|
+
const input = asset.document.model.inputs.find(i => i.id === stateId)!
|
|
195
|
+
const duration = joining ? joining.duration : a.duration
|
|
196
|
+
const ready = !!name && duration > 0
|
|
197
|
+
return html`<section><h3>${i18next.t('figure.label.add-track')}</h3>
|
|
198
|
+
<div form>
|
|
199
|
+
<span>${i18next.t('figure.label.controls')}</span>
|
|
200
|
+
<select @change=${(e: Event) => (this.adding = { ...a, state: (e.target as HTMLSelectElement).value })}>
|
|
201
|
+
${free.map(id => html`<option value=${id} ?selected=${id === stateId}>${asset.stateInputs?.[id]?.label ?? id}</option>`)}
|
|
202
|
+
</select>
|
|
203
|
+
<span>${i18next.t('figure.label.clip')}</span>
|
|
204
|
+
<select @change=${(e: Event) => (this.adding = { ...a, clip: (e.target as HTMLSelectElement).value })}>
|
|
205
|
+
<option value="" ?selected=${!joining}>${i18next.t('figure.label.new-clip')}</option>
|
|
206
|
+
${clips.map(c => html`<option value=${c.name} ?selected=${c === joining}>${c.name}</option>`)}
|
|
207
|
+
</select>
|
|
208
|
+
${joining
|
|
209
|
+
? nothing
|
|
210
|
+
: html`<span>${i18next.t('figure.label.clip-name')}</span>
|
|
211
|
+
<input .value=${a.name} placeholder="cycle" @input=${(e: Event) => (this.adding = { ...a, name: (e.target as HTMLInputElement).value })} />
|
|
212
|
+
<span>${i18next.t('figure.label.clip-duration')}</span>
|
|
213
|
+
<input type="number" min="0.1" step="any" .value=${String(a.duration)} @change=${(e: Event) => (this.adding = { ...a, duration: (e.target as HTMLInputElement).valueAsNumber })} />`}
|
|
214
|
+
</div>
|
|
215
|
+
<button
|
|
216
|
+
act
|
|
217
|
+
?disabled=${this.locked || !ready}
|
|
218
|
+
@click=${() =>
|
|
219
|
+
this.edit({
|
|
220
|
+
kind: 'set-driver',
|
|
221
|
+
state: stateId,
|
|
222
|
+
clip: name,
|
|
223
|
+
...(joining ? {} : { duration }),
|
|
224
|
+
keys: [
|
|
225
|
+
{ seconds: 0, value: input.min },
|
|
226
|
+
{ seconds: duration, value: input.max }
|
|
227
|
+
]
|
|
228
|
+
})}
|
|
229
|
+
>
|
|
230
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-track')}
|
|
231
|
+
</button></section>`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Show the clip's pose at a moment: one batch of input changes, not saved as the default. */
|
|
235
|
+
/** Show the clip's pose at a moment. While the bar is dragged the pose is live (drawn, not recorded); the release
|
|
236
|
+
* applies it as one batch of input changes. */
|
|
237
|
+
private scrub(clip: Clip, seconds: number, live = false) {
|
|
238
|
+
if (!this.asset || !Number.isFinite(seconds)) return
|
|
239
|
+
this.at = { ...this.at, [clip.name]: seconds }
|
|
240
|
+
const changes = poseAt(this.asset, clip.name, seconds)
|
|
241
|
+
if (!changes.length) return
|
|
242
|
+
if (live) {
|
|
243
|
+
const values = Object.fromEntries(changes.map(c => [c.input, c.value]))
|
|
244
|
+
this.dispatchEvent(new CustomEvent('v3-states-live', { detail: { values }, bubbles: true, composed: true }))
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
this.dispatchEvent(new CustomEvent('v3-states', { detail: { changes }, bubbles: true, composed: true }))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private edit(edit: unknown) {
|
|
251
|
+
this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit }, bubbles: true, composed: true }))
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
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 type { V3Asset } from '@hatiolab/figure-model'
|
|
8
|
+
|
|
9
|
+
import { controlsOf, poseAsDefault, poseDiffers, type Control } from './v3-controls.js'
|
|
10
|
+
import { dialShape } from './angle-dial.js'
|
|
11
|
+
import { SidePanelStyles } from './side-panel-styles.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The figure's controls (parity row ⑥, V2's parameters and its pose tools): each value a board sets to pose the figure,
|
|
15
|
+
* with a slider to try it, its name, range and travel time, and a button that keeps the pose being looked at as the
|
|
16
|
+
* pose it stands in when placed.
|
|
17
|
+
*
|
|
18
|
+
* Trying a value is an input change (`v3-state`, not saved as the default); naming, the range and the travel time are
|
|
19
|
+
* one `set-state-input` part edit each; keeping the pose is one `set-state-defaults` edit. A control is made with
|
|
20
|
+
* a motion on a part (「움직임」), which is where it gets its meaning.
|
|
21
|
+
*/
|
|
22
|
+
@customElement('figure-v3-controls')
|
|
23
|
+
export class FigureV3Controls extends localize(i18next)(LitElement) {
|
|
24
|
+
static styles = [SidePanelStyles, css`
|
|
25
|
+
:host {
|
|
26
|
+
display: block;
|
|
27
|
+
}
|
|
28
|
+
/* One control, one line: its name, a dial for an angle, a thin slider, the value (V2's preview row). */
|
|
29
|
+
div[control] {
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-columns: minmax(0, 1fr) 18px minmax(64px, 1.2fr) 58px 22px;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 6px;
|
|
34
|
+
min-height: 28px;
|
|
35
|
+
}
|
|
36
|
+
div[control] > span[name] {
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
text-overflow: ellipsis;
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
div[control] > span[name] em {
|
|
42
|
+
font-style: normal;
|
|
43
|
+
font-size: 0.64rem;
|
|
44
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
45
|
+
}
|
|
46
|
+
div[control] input[type='number'] {
|
|
47
|
+
min-height: 24px;
|
|
48
|
+
padding: 2px 5px;
|
|
49
|
+
font-family: var(--mono-font, monospace);
|
|
50
|
+
text-align: right;
|
|
51
|
+
}
|
|
52
|
+
svg[dial] {
|
|
53
|
+
width: 18px;
|
|
54
|
+
height: 18px;
|
|
55
|
+
}
|
|
56
|
+
svg[dial] path {
|
|
57
|
+
fill: var(--md-sys-color-surface-container-high);
|
|
58
|
+
}
|
|
59
|
+
svg[dial] circle {
|
|
60
|
+
fill: none;
|
|
61
|
+
stroke: var(--md-sys-color-outline-variant);
|
|
62
|
+
}
|
|
63
|
+
svg[dial] line {
|
|
64
|
+
stroke: var(--md-sys-color-primary);
|
|
65
|
+
stroke-width: 2;
|
|
66
|
+
stroke-linecap: round;
|
|
67
|
+
}
|
|
68
|
+
button[more] {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
width: 22px;
|
|
73
|
+
height: 22px;
|
|
74
|
+
padding: 0;
|
|
75
|
+
border: none;
|
|
76
|
+
border-radius: 4px;
|
|
77
|
+
background: none;
|
|
78
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
button[more][aria-expanded='true'] {
|
|
82
|
+
color: var(--md-sys-color-primary);
|
|
83
|
+
background: var(--md-sys-color-surface-container);
|
|
84
|
+
}
|
|
85
|
+
button[more] md-icon {
|
|
86
|
+
font-size: 16px;
|
|
87
|
+
width: 16px;
|
|
88
|
+
height: 16px;
|
|
89
|
+
}
|
|
90
|
+
/* The rarely changed: the name, the range, the travel time -- opened per control. */
|
|
91
|
+
div[settings] {
|
|
92
|
+
margin: 2px 0 8px;
|
|
93
|
+
padding: 6px 0 6px 8px;
|
|
94
|
+
border-left: 2px solid var(--md-sys-color-outline-variant);
|
|
95
|
+
}
|
|
96
|
+
`]
|
|
97
|
+
|
|
98
|
+
/** The asset being edited. */
|
|
99
|
+
@property({ attribute: false }) asset?: V3Asset
|
|
100
|
+
/** The values the editor holds now, by input id. */
|
|
101
|
+
@property({ attribute: false }) values: Record<string, number> = {}
|
|
102
|
+
@property({ type: Boolean }) locked = false
|
|
103
|
+
|
|
104
|
+
/** Which control's settings are open. */
|
|
105
|
+
@state() private open = ''
|
|
106
|
+
/** Values in hand while a slider is dragged: the dial and the number follow them before the release commits. */
|
|
107
|
+
@state() private live: Record<string, number> = {}
|
|
108
|
+
|
|
109
|
+
render() {
|
|
110
|
+
if (!this.asset) return nothing
|
|
111
|
+
const controls = controlsOf(this.asset, this.values)
|
|
112
|
+
return html`<section>
|
|
113
|
+
<h3>${i18next.t('figure.label.controls')}</h3>
|
|
114
|
+
${controls.length
|
|
115
|
+
? html`${controls.map(control => this.renderControl(control))}
|
|
116
|
+
<button act ?disabled=${this.locked || !poseDiffers(controls)} @click=${() => this.edit(poseAsDefault(controls))}>
|
|
117
|
+
<md-icon>push_pin</md-icon>${i18next.t('figure.button.keep-pose')}
|
|
118
|
+
</button>`
|
|
119
|
+
: html`<p hint>${i18next.t('figure.text.no-controls')}</p>`}
|
|
120
|
+
</section>`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private renderControl(control: Control) {
|
|
124
|
+
const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
|
|
125
|
+
const c = this.live[control.id] === undefined ? control : { ...control, value: this.live[control.id]! }
|
|
126
|
+
const open = this.open === c.id
|
|
127
|
+
return html`<div control>
|
|
128
|
+
<span name title=${c.id}>${c.label} <em>${c.unit}</em></span>
|
|
129
|
+
${c.unit === 'deg' ? this.renderDial(c) : html`<span></span>`}
|
|
130
|
+
<input
|
|
131
|
+
type="range"
|
|
132
|
+
min=${c.min}
|
|
133
|
+
max=${c.max}
|
|
134
|
+
step=${c.step}
|
|
135
|
+
.value=${String(c.value)}
|
|
136
|
+
?disabled=${this.locked}
|
|
137
|
+
aria-label=${c.label}
|
|
138
|
+
@input=${(e: Event) => this.drag(c, number(e))}
|
|
139
|
+
@change=${(e: Event) => this.try(c, number(e))}
|
|
140
|
+
/>
|
|
141
|
+
<input type="number" step="any" .value=${String(Math.round(c.value * 100) / 100)} ?disabled=${this.locked} aria-label=${c.label} @change=${(e: Event) => this.try(c, number(e))} />
|
|
142
|
+
<button
|
|
143
|
+
more
|
|
144
|
+
aria-expanded=${String(open)}
|
|
145
|
+
title=${i18next.t('figure.button.control-settings')}
|
|
146
|
+
aria-label=${i18next.t('figure.button.control-settings')}
|
|
147
|
+
@click=${() => (this.open = open ? '' : c.id)}
|
|
148
|
+
>
|
|
149
|
+
<md-icon>tune</md-icon>
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
${open ? this.renderSettings(c) : nothing}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private renderDial(c: Control) {
|
|
156
|
+
const dial = dialShape(c.min, c.max, c.value)
|
|
157
|
+
const span = dial.radius * 2 + 2
|
|
158
|
+
return html`<svg dial viewBox="0 0 ${span} ${span}" aria-hidden="true">
|
|
159
|
+
<path d=${dial.sector}></path>
|
|
160
|
+
<circle cx=${dial.centre.x} cy=${dial.centre.y} r=${dial.radius}></circle>
|
|
161
|
+
<line x1=${dial.centre.x} y1=${dial.centre.y} x2=${dial.needle.x} y2=${dial.needle.y}></line>
|
|
162
|
+
</svg>`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private renderSettings(c: Control) {
|
|
166
|
+
const policy = (patch: Partial<{ label: string; sweep: number; min: number; max: number }>) =>
|
|
167
|
+
this.edit({
|
|
168
|
+
kind: 'set-state-input',
|
|
169
|
+
input: c.id,
|
|
170
|
+
label: patch.label ?? (c.named ? c.label : undefined),
|
|
171
|
+
sweep: patch.sweep ?? c.sweep,
|
|
172
|
+
...(patch.min !== undefined ? { min: patch.min } : {}),
|
|
173
|
+
...(patch.max !== undefined ? { max: patch.max } : {})
|
|
174
|
+
})
|
|
175
|
+
const number = (e: Event) => (e.target as HTMLInputElement).valueAsNumber
|
|
176
|
+
return html`<div settings>
|
|
177
|
+
<div form>
|
|
178
|
+
<span>${i18next.t('figure.label.control-name')}</span>
|
|
179
|
+
<input .value=${c.named ? c.label : ''} placeholder=${c.id} ?disabled=${this.locked} @change=${(e: Event) => policy({ label: (e.target as HTMLInputElement).value })} />
|
|
180
|
+
<span>${i18next.t('figure.label.control-range')} (${c.unit})</span>
|
|
181
|
+
<div pair>
|
|
182
|
+
<input type="number" step="any" .value=${String(c.min)} ?disabled=${this.locked} @change=${(e: Event) => policy({ min: number(e) })} />
|
|
183
|
+
<input type="number" step="any" .value=${String(c.max)} ?disabled=${this.locked} @change=${(e: Event) => policy({ max: number(e) })} />
|
|
184
|
+
</div>
|
|
185
|
+
<span>${i18next.t('figure.label.control-sweep')}</span>
|
|
186
|
+
<input type="number" min="0" step="any" .value=${String(c.sweep)} ?disabled=${this.locked} @change=${(e: Event) => policy({ sweep: number(e) })} />
|
|
187
|
+
</div>
|
|
188
|
+
<small>${i18next.t('figure.text.control-starts-at', { value: c.start, unit: c.unit })}</small>
|
|
189
|
+
</div>`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** While dragging: the value is told as live -- the editor draws that pose at once and records nothing. */
|
|
193
|
+
private drag(c: Control, value: number) {
|
|
194
|
+
if (!Number.isFinite(value)) return
|
|
195
|
+
this.live = { ...this.live, [c.id]: value }
|
|
196
|
+
this.dispatchEvent(new CustomEvent('v3-state-live', { detail: { input: c.id, value }, bubbles: true, composed: true }))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Try a value: the pose being looked at, clamped to the range. Not saved as the default. The slider sends it on
|
|
200
|
+
* release, so one drag is one step of the undo history rather than one per pixel. */
|
|
201
|
+
private try(c: Control, value: number) {
|
|
202
|
+
if (!Number.isFinite(value)) return
|
|
203
|
+
const clamped = Math.min(Math.max(value, c.min), c.max)
|
|
204
|
+
const { [c.id]: _released, ...rest } = this.live
|
|
205
|
+
this.live = rest
|
|
206
|
+
this.dispatchEvent(new CustomEvent('v3-state', { detail: { input: c.id, unit: c.unit, value: clamped }, bubbles: true, composed: true }))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private edit(edit: unknown) {
|
|
210
|
+
this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit }, bubbles: true, composed: true }))
|
|
211
|
+
}
|
|
212
|
+
}
|