@things-factory/figure-ui 10.1.16 → 10.1.18
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/figure-animations.ts +331 -124
- package/client/modeller/figure-canvas.ts +32 -48
- package/client/modeller/figure-capabilities.ts +10 -10
- package/client/modeller/figure-inspector.ts +39 -28
- package/client/modeller/figure-parts.ts +1 -1
- package/client/modeller/figure-preview.ts +151 -41
- package/client/modeller/figure-report.ts +24 -6
- package/client/modeller/figure-settings.ts +45 -2
- package/client/modeller/figure-side.ts +6 -6
- package/client/modeller/figure-source.ts +62 -32
- package/client/modeller/figure-thumbnail.ts +5 -5
- package/client/modeller/figure-view.ts +53 -0
- package/client/modeller/part-edits.ts +36 -36
- package/client/modeller/proposal.ts +27 -4
- package/client/pages/figure-modeller-page.ts +65 -58
- package/dist-client/modeller/figure-animations.d.ts +37 -8
- package/dist-client/modeller/figure-animations.js +293 -122
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +7 -7
- package/dist-client/modeller/figure-canvas.js +27 -40
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-capabilities.d.ts +2 -2
- package/dist-client/modeller/figure-capabilities.js +9 -9
- package/dist-client/modeller/figure-capabilities.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +16 -5
- package/dist-client/modeller/figure-inspector.js +38 -27
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +1 -1
- package/dist-client/modeller/figure-parts.js +1 -1
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +56 -6
- package/dist-client/modeller/figure-preview.js +132 -39
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-report.d.ts +9 -0
- package/dist-client/modeller/figure-report.js +23 -5
- package/dist-client/modeller/figure-report.js.map +1 -1
- package/dist-client/modeller/figure-settings.js +42 -2
- package/dist-client/modeller/figure-settings.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +2 -2
- package/dist-client/modeller/figure-side.js +5 -5
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +25 -15
- package/dist-client/modeller/figure-source.js +42 -21
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
- package/dist-client/modeller/figure-thumbnail.js +3 -3
- package/dist-client/modeller/figure-thumbnail.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +45 -0
- package/dist-client/modeller/figure-view.js +27 -0
- package/dist-client/modeller/figure-view.js.map +1 -1
- package/dist-client/modeller/part-edits.d.ts +16 -16
- package/dist-client/modeller/part-edits.js +35 -35
- package/dist-client/modeller/part-edits.js.map +1 -1
- package/dist-client/modeller/proposal.d.ts +1 -1
- package/dist-client/modeller/proposal.js +16 -3
- package/dist-client/modeller/proposal.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +9 -10
- package/dist-client/pages/figure-modeller-page.js +61 -54
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal-contract.test.ts +433 -12
- package/test/expect.ts +55 -0
- package/test/figure-source.test.ts +572 -0
- package/test/i18n-prefix-guard.test.ts +9 -12
- package/{client/modeller → test}/part-edits.test.ts +50 -45
- package/translations/en.json +46 -35
- package/translations/ja.json +16 -21
- package/translations/ko.json +56 -45
- package/translations/ms.json +16 -21
- package/translations/zh.json +16 -21
- package/client/modeller/figure-ask.ts +0 -265
- package/client/modeller/figure-source.test.ts +0 -352
- package/dist-client/modeller/figure-ask.d.ts +0 -40
- package/dist-client/modeller/figure-ask.js +0 -282
- package/dist-client/modeller/figure-ask.js.map +0 -1
|
@@ -5,10 +5,10 @@ import { live } from 'lit/directives/live.js'
|
|
|
5
5
|
import { i18next, localize } from '@operato/i18n'
|
|
6
6
|
import { ScrollbarStyles } from '@operato/styles'
|
|
7
7
|
|
|
8
|
-
import { AXES, CHANNEL_PATHS,
|
|
8
|
+
import { AXES, CHANNEL_PATHS, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model'
|
|
9
9
|
import type { ChannelPath, Vec3 } from '@hatiolab/figure-model'
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type { FigureDraft, PartModel } from './figure-source.js'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* 이 도형이 **어떻게 움직이는가.**
|
|
@@ -30,32 +30,73 @@ import type { BoardModel, PartModel } from './figure-source.js'
|
|
|
30
30
|
*
|
|
31
31
|
* ## 트윈은 타임라인을 재생하지 않는다
|
|
32
32
|
*
|
|
33
|
-
* 「문 열기 clip 을 재생하라」가 아니라 **「지금 40% 열려 있다」**다.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* 「문 열기 clip 을 재생하라」가 아니라 **「지금 40% 열려 있다」**다. 그 둘은 다른 것이라
|
|
34
|
+
* 이 탭도 둘로 나뉜다.
|
|
35
|
+
*
|
|
36
|
+
* 애니메이션 시각이 흐른다. 인스턴스가 주는 수는 배속이다 — 도는 롤러
|
|
37
|
+
* 파라미터 값이 자세를 만든다. 인스턴스가 주는 수는 물리량이다 — 1200mm 내려온 호이스트
|
|
38
|
+
*
|
|
39
|
+
* 한동안 뒤엣것이 애니메이션 안에 `drive: 'hold'` 로 숨어 있었다. 형식의 판단은 맞았는데
|
|
40
|
+
* **자리와 이름이 틀려서**, 「길이값을 변수로 노출하라」는 요청에 저작자도 AI 도 이것을
|
|
41
|
+
* 못 찾았다 — 애니메이션 칸에 있을 거라고 생각하지 않는다(ADR-0051).
|
|
36
42
|
*/
|
|
37
43
|
|
|
38
|
-
type Clip = NonNullable<
|
|
44
|
+
type Clip = NonNullable<FigureDraft['animations']>[number]
|
|
45
|
+
type Parameter = NonNullable<FigureDraft['parameters']>[number]
|
|
39
46
|
type Channel = Clip['channels'][number]
|
|
40
47
|
type Keyframe = Channel['keys'][number]
|
|
41
48
|
|
|
42
|
-
/**
|
|
49
|
+
/** 어느 목록의 몇째인가. 채널·키 편집기가 둘을 함께 쓴다. */
|
|
50
|
+
type MotionKind = 'clip' | 'parameter'
|
|
51
|
+
|
|
52
|
+
/** A first-success motion: intent first, raw channel editing second. */
|
|
43
53
|
interface MotionRecipe {
|
|
44
54
|
id: 'conveyor' | 'gate' | 'pusher' | 'lift'
|
|
45
55
|
name: string
|
|
46
|
-
|
|
56
|
+
kind: MotionKind
|
|
47
57
|
path: ChannelPath
|
|
48
58
|
end: Vec3
|
|
49
59
|
pivot?: Vec3
|
|
60
|
+
/** 파라미터 레시피만. 인스턴스가 주고받는 물리량이다. */
|
|
61
|
+
range?: Parameter['range']
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
/*
|
|
65
|
+
레시피의 `end` 가 곧 그 범위의 끝 자세다 — 리프트가 100mm 올라가는 곡선이면 범위도
|
|
66
|
+
0~100mm 다. 둘이 어긋나면 저작자가 1200 을 주고도 100 만 올라가는 것을 보게 된다.
|
|
67
|
+
*/
|
|
52
68
|
const MOTION_RECIPES: readonly MotionRecipe[] = [
|
|
53
|
-
{ id: 'conveyor', name: 'conveyor-running',
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
{ id: 'conveyor', name: 'conveyor-running', kind: 'clip', path: 'rotation', end: { x: 360, y: 0, z: 0 } },
|
|
70
|
+
{
|
|
71
|
+
id: 'gate',
|
|
72
|
+
name: 'gate-open',
|
|
73
|
+
kind: 'parameter',
|
|
74
|
+
path: 'rotation',
|
|
75
|
+
end: { x: 0, y: 90, z: 0 },
|
|
76
|
+
pivot: { x: 0, y: 0, z: 0 },
|
|
77
|
+
range: { unit: '%', min: 0, max: 100 }
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'pusher',
|
|
81
|
+
name: 'pusher-position',
|
|
82
|
+
kind: 'parameter',
|
|
83
|
+
path: 'translation',
|
|
84
|
+
end: { x: 100, y: 0, z: 0 },
|
|
85
|
+
range: { unit: 'mm', min: 0, max: 100 }
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'lift',
|
|
89
|
+
name: 'lift-height',
|
|
90
|
+
kind: 'parameter',
|
|
91
|
+
path: 'translation',
|
|
92
|
+
end: { x: 0, y: 100, z: 0 },
|
|
93
|
+
range: { unit: 'mm', min: 0, max: 100 }
|
|
94
|
+
}
|
|
57
95
|
]
|
|
58
96
|
|
|
97
|
+
/** 새 파라미터의 범위. 단위는 저작자가 고쳐 쓴다 — 형식은 단위를 해석하지 않는다. */
|
|
98
|
+
const NEW_RANGE: Parameter['range'] = { unit: 'mm', min: 0, max: 1000 }
|
|
99
|
+
|
|
59
100
|
/** 새 clip 의 키 둘. **하나로 만들지 않는다** — 키 하나는 움직임이 아니라 자세다. */
|
|
60
101
|
const NEW_KEYS: Keyframe[] = [
|
|
61
102
|
{ at: 0, value: { x: 0, y: 0, z: 0 } },
|
|
@@ -71,19 +112,16 @@ function keysFor(path: ChannelPath): Keyframe[] {
|
|
|
71
112
|
return NEW_KEYS.map(key => ({ at: key.at, value: startValue(path) }))
|
|
72
113
|
}
|
|
73
114
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const used = new Set(taken.map(clip => clip.name))
|
|
85
|
-
if (!used.has(base)) return base
|
|
86
|
-
for (let n = 2; ; n++) if (!used.has(`${base}-${n}`)) return `${base}-${n}`
|
|
115
|
+
/**
|
|
116
|
+
* 이미 쓴 이름을 비켜 간다.
|
|
117
|
+
*
|
|
118
|
+
* **두 목록을 함께 본다.** 인스턴스는 이름 하나로 값을 주므로 애니메이션과 파라미터가
|
|
119
|
+
* 같은 이름을 가지면 어느 쪽에 준 것인지 알 수 없고, 형식이 그것을 오류로 막는다.
|
|
120
|
+
*/
|
|
121
|
+
function freeName(taken: readonly { name: string }[], stem: string): string {
|
|
122
|
+
const used = new Set(taken.map(one => one.name))
|
|
123
|
+
if (!used.has(stem)) return stem
|
|
124
|
+
for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
|
|
87
125
|
}
|
|
88
126
|
|
|
89
127
|
@customElement('figure-animations')
|
|
@@ -286,15 +324,32 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
286
324
|
`
|
|
287
325
|
]
|
|
288
326
|
|
|
289
|
-
@property({ type: Object })
|
|
327
|
+
@property({ type: Object }) draft?: FigureDraft
|
|
290
328
|
@property({ type: Array }) parts: PartModel[] = []
|
|
291
329
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
330
|
+
/**
|
|
331
|
+
* 펼쳐 둔 것. 이름이 아니라 **목록과 자리**로 잡는다 — 이름은 편집 중에 바뀐다.
|
|
332
|
+
*
|
|
333
|
+
* 목록이 둘이므로 자리 하나로는 모자란다. `clip:0` 과 `parameter:0` 은 다른 것이다.
|
|
334
|
+
*/
|
|
335
|
+
@state() private open = ''
|
|
294
336
|
@state() private recipeTarget = ''
|
|
295
337
|
|
|
296
338
|
private get clips(): Clip[] {
|
|
297
|
-
return this.
|
|
339
|
+
return this.draft?.animations ?? []
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private get parameters(): Parameter[] {
|
|
343
|
+
return this.draft?.parameters ?? []
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 두 목록의 이름을 한자리에서 본다 — 인스턴스가 이름 하나로 값을 준다. */
|
|
347
|
+
private get allNames(): { name: string }[] {
|
|
348
|
+
return [...this.clips, ...this.parameters]
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private channelsOf(kind: MotionKind, at: number): Channel[] {
|
|
352
|
+
return (kind === 'clip' ? this.clips[at]?.channels : this.parameters[at]?.clip?.channels) ?? []
|
|
298
353
|
}
|
|
299
354
|
|
|
300
355
|
/** 부품 이름 — 채널이 이것을 가리킨다. */
|
|
@@ -311,11 +366,16 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
311
366
|
private get movingParts(): number {
|
|
312
367
|
const moving = new Set<string>()
|
|
313
368
|
for (const clip of this.clips) for (const channel of clip.channels) moving.add(channel.target)
|
|
369
|
+
for (const parameter of this.parameters) {
|
|
370
|
+
for (const channel of parameter.clip?.channels ?? []) moving.add(channel.target)
|
|
371
|
+
}
|
|
314
372
|
return moving.size
|
|
315
373
|
}
|
|
316
374
|
|
|
317
375
|
render(): TemplateResult {
|
|
318
|
-
if (!this.
|
|
376
|
+
if (!this.draft) return html``
|
|
377
|
+
|
|
378
|
+
const nameless = this.partNames.length === 0
|
|
319
379
|
|
|
320
380
|
return html`
|
|
321
381
|
<section>
|
|
@@ -324,18 +384,27 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
324
384
|
</section>
|
|
325
385
|
|
|
326
386
|
${this.renderRecipes()}
|
|
327
|
-
${this.clips.length === 0 ? nothing : this.renderClips()}
|
|
328
387
|
|
|
329
388
|
<section>
|
|
330
|
-
<
|
|
389
|
+
<h3>${i18next.t('figure.label.parameters')}</h3>
|
|
390
|
+
<p quiet>${i18next.t('figure.text.parameter-is-a-value-not-a-timeline')}</p>
|
|
391
|
+
${this.parameters.map((parameter, at) => this.renderParameter(parameter, at))}
|
|
392
|
+
<button @click=${() => this.addParameter()} ?disabled=${nameless}>
|
|
393
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-parameter')}
|
|
394
|
+
</button>
|
|
395
|
+
</section>
|
|
396
|
+
|
|
397
|
+
<section>
|
|
398
|
+
<h3>${i18next.t('figure.label.clips')}</h3>
|
|
399
|
+
<p quiet>${i18next.t('figure.text.clip-is-time-running')}</p>
|
|
400
|
+
${this.clips.map((clip, at) => this.renderClip(clip, at))}
|
|
401
|
+
<button @click=${() => this.addClip()} ?disabled=${nameless}>
|
|
331
402
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-clip')}
|
|
332
403
|
</button>
|
|
333
|
-
${
|
|
334
|
-
? html`<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>`
|
|
335
|
-
: nothing}
|
|
404
|
+
${nameless ? html`<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>` : nothing}
|
|
336
405
|
</section>
|
|
337
406
|
|
|
338
|
-
${this.
|
|
407
|
+
${this.movingParts === 0 ? nothing : this.renderBudget()}
|
|
339
408
|
`
|
|
340
409
|
}
|
|
341
410
|
|
|
@@ -367,23 +436,22 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
367
436
|
`
|
|
368
437
|
}
|
|
369
438
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
</section>
|
|
375
|
-
`
|
|
439
|
+
/** 이름이 다른 것과 겹치나. 두 목록을 함께 본다 — 인스턴스는 이름 하나로 값을 준다. */
|
|
440
|
+
private taken(kind: MotionKind, at: number, name: string): boolean {
|
|
441
|
+
const mine = kind === 'clip' ? this.clips[at] : this.parameters[at]
|
|
442
|
+
return this.allNames.some(one => one !== mine && one.name === name)
|
|
376
443
|
}
|
|
377
444
|
|
|
378
445
|
private renderClip(clip: Clip, at: number): TemplateResult {
|
|
379
|
-
const
|
|
380
|
-
const
|
|
446
|
+
const key = `clip:${at}`
|
|
447
|
+
const on = this.open === key
|
|
448
|
+
const duplicate = this.taken('clip', at, clip.name)
|
|
381
449
|
const nameless = !clip.name?.trim()
|
|
382
450
|
|
|
383
451
|
return html`
|
|
384
452
|
<div clip ?on=${on}>
|
|
385
453
|
<div head>
|
|
386
|
-
<button plain @click=${() => (this.open = on ?
|
|
454
|
+
<button plain @click=${() => (this.open = on ? '' : key)} title=${i18next.t('figure.label.channels')}>
|
|
387
455
|
<md-icon>${on ? 'expand_more' : 'chevron_right'}</md-icon>
|
|
388
456
|
</button>
|
|
389
457
|
<input
|
|
@@ -394,15 +462,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
394
462
|
title=${duplicate ? i18next.t('figure.text.clip-name-taken') : i18next.t('figure.text.clip-name-is-how-it-is-driven')}
|
|
395
463
|
@change=${(e: Event) => this.renameClip(at, (e.target as HTMLInputElement).value)}
|
|
396
464
|
/>
|
|
397
|
-
<select
|
|
398
|
-
.value=${live(clip.drive ?? 'loop')}
|
|
399
|
-
title=${i18next.t('figure.text.drive-explained')}
|
|
400
|
-
@change=${(e: Event) => this.setDrive(at, (e.target as HTMLSelectElement).value as Clip['drive'])}
|
|
401
|
-
>
|
|
402
|
-
${CLIP_DRIVES.map(
|
|
403
|
-
drive => html`<option value=${drive}>${i18next.t('figure.label.drive-' + drive)}</option>`
|
|
404
|
-
)}
|
|
405
|
-
</select>
|
|
406
465
|
<button plain @click=${() => this.removeClip(at)} title=${i18next.t('figure.button.remove')}>
|
|
407
466
|
<md-icon>close</md-icon>
|
|
408
467
|
</button>
|
|
@@ -410,19 +469,104 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
410
469
|
|
|
411
470
|
${duplicate ? html`<p warn>${i18next.t('figure.text.clip-name-taken')}</p>` : nothing}
|
|
412
471
|
${nameless ? html`<p warn>${i18next.t('figure.text.clip-needs-a-name')}</p>` : nothing}
|
|
413
|
-
${on ? this.renderChannels(clip, at) : nothing}
|
|
472
|
+
${on ? this.renderChannels('clip', at) : nothing}
|
|
473
|
+
</div>
|
|
474
|
+
`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 파라미터 하나 — **이름·범위·자세.**
|
|
479
|
+
*
|
|
480
|
+
* 범위가 화면에 있어야 하는 이유는 그것이 **인스턴스와 주고받는 말**이기 때문이다.
|
|
481
|
+
* 곡선은 0~1 이고 인스턴스는 mm 로 준다. 그 사이를 런타임이 맵하는데, 무엇부터 무엇
|
|
482
|
+
* 까지인지 저작자가 정하지 않으면 값을 줄 수가 없다.
|
|
483
|
+
*/
|
|
484
|
+
private renderParameter(parameter: Parameter, at: number): TemplateResult {
|
|
485
|
+
const key = `parameter:${at}`
|
|
486
|
+
const on = this.open === key
|
|
487
|
+
const duplicate = this.taken('parameter', at, parameter.name)
|
|
488
|
+
const nameless = !parameter.name?.trim()
|
|
489
|
+
const range = parameter.range ?? NEW_RANGE
|
|
490
|
+
const backwards = !(range.max > range.min)
|
|
491
|
+
|
|
492
|
+
return html`
|
|
493
|
+
<div clip ?on=${on}>
|
|
494
|
+
<div head>
|
|
495
|
+
<button plain @click=${() => (this.open = on ? '' : key)} title=${i18next.t('figure.label.channels')}>
|
|
496
|
+
<md-icon>${on ? 'expand_more' : 'chevron_right'}</md-icon>
|
|
497
|
+
</button>
|
|
498
|
+
<input
|
|
499
|
+
name
|
|
500
|
+
.value=${live(parameter.name ?? '')}
|
|
501
|
+
?bad=${duplicate || nameless}
|
|
502
|
+
placeholder=${i18next.t('figure.label.parameter-name')}
|
|
503
|
+
title=${duplicate
|
|
504
|
+
? i18next.t('figure.text.clip-name-taken')
|
|
505
|
+
: i18next.t('figure.text.parameter-name-is-how-a-value-arrives')}
|
|
506
|
+
@change=${(e: Event) => this.renameParameter(at, (e.target as HTMLInputElement).value)}
|
|
507
|
+
/>
|
|
508
|
+
<button plain @click=${() => this.removeParameter(at)} title=${i18next.t('figure.button.remove')}>
|
|
509
|
+
<md-icon>close</md-icon>
|
|
510
|
+
</button>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
${duplicate ? html`<p warn>${i18next.t('figure.text.clip-name-taken')}</p>` : nothing}
|
|
514
|
+
${nameless ? html`<p warn>${i18next.t('figure.text.parameter-needs-a-name')}</p>` : nothing}
|
|
515
|
+
|
|
516
|
+
<div row>
|
|
517
|
+
<label>${i18next.t('figure.label.parameter-range')}</label>
|
|
518
|
+
<input
|
|
519
|
+
type="number"
|
|
520
|
+
step="1"
|
|
521
|
+
?bad=${backwards}
|
|
522
|
+
.value=${live(String(range.min))}
|
|
523
|
+
aria-label="min"
|
|
524
|
+
@change=${(e: Event) => this.setRange(at, { min: Number((e.target as HTMLInputElement).value) || 0 })}
|
|
525
|
+
/>
|
|
526
|
+
<input
|
|
527
|
+
type="number"
|
|
528
|
+
step="1"
|
|
529
|
+
?bad=${backwards}
|
|
530
|
+
.value=${live(String(range.max))}
|
|
531
|
+
aria-label="max"
|
|
532
|
+
@change=${(e: Event) => this.setRange(at, { max: Number((e.target as HTMLInputElement).value) || 0 })}
|
|
533
|
+
/>
|
|
534
|
+
<input
|
|
535
|
+
unit
|
|
536
|
+
.value=${live(range.unit ?? '')}
|
|
537
|
+
placeholder="mm"
|
|
538
|
+
aria-label="unit"
|
|
539
|
+
title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
|
|
540
|
+
@change=${(e: Event) => this.setRange(at, { unit: (e.target as HTMLInputElement).value.trim() })}
|
|
541
|
+
/>
|
|
542
|
+
</div>
|
|
543
|
+
${backwards ? html`<p warn>${i18next.t('figure.text.range-needs-room')}</p>` : nothing}
|
|
544
|
+
|
|
545
|
+
<div row>
|
|
546
|
+
<label>${i18next.t('figure.label.parameter-default')}</label>
|
|
547
|
+
<input
|
|
548
|
+
type="number"
|
|
549
|
+
step="1"
|
|
550
|
+
.value=${live(String(parameter.default ?? range.min))}
|
|
551
|
+
@change=${(e: Event) => this.setDefault(at, Number((e.target as HTMLInputElement).value) || 0)}
|
|
552
|
+
/>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
${on ? this.renderChannels('parameter', at) : nothing}
|
|
414
556
|
</div>
|
|
415
557
|
`
|
|
416
558
|
}
|
|
417
559
|
|
|
418
|
-
private renderChannels(
|
|
560
|
+
private renderChannels(kind: MotionKind, at: number): TemplateResult {
|
|
561
|
+
const channels = this.channelsOf(kind, at)
|
|
562
|
+
|
|
419
563
|
return html`
|
|
420
|
-
<p quiet>${i18next.t('figure.text.
|
|
564
|
+
<p quiet>${i18next.t(kind === 'clip' ? 'figure.text.clip-is-time-running' : 'figure.text.parameter-curve-is-the-value-axis')}</p>
|
|
421
565
|
|
|
422
|
-
${
|
|
423
|
-
${
|
|
566
|
+
${channels.length === 0 ? html`<p warn>${i18next.t('figure.text.clip-needs-a-channel')}</p>` : nothing}
|
|
567
|
+
${channels.map((channel, j) => this.renderChannel(kind, channel, at, j))}
|
|
424
568
|
|
|
425
|
-
<button @click=${() => this.addChannel(at)}>
|
|
569
|
+
<button @click=${() => this.addChannel(kind, at)}>
|
|
426
570
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-channel')}
|
|
427
571
|
</button>
|
|
428
572
|
`
|
|
@@ -434,7 +578,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
434
578
|
* 없는 부품을 가리키는 채널은 붉게 낸다. 부품 이름을 바꾸면 그 순간 이렇게 되는데,
|
|
435
579
|
* 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
|
|
436
580
|
*/
|
|
437
|
-
private renderChannel(channel: Channel, at: number, j: number): TemplateResult {
|
|
581
|
+
private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
|
|
438
582
|
const orphan = !this.partNames.includes(channel.target)
|
|
439
583
|
const turning = channel.path === 'rotation'
|
|
440
584
|
|
|
@@ -444,12 +588,12 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
444
588
|
<label>${i18next.t('figure.label.channel-target')}</label>
|
|
445
589
|
<select
|
|
446
590
|
.value=${live(channel.target ?? '')}
|
|
447
|
-
@change=${(e: Event) => this.setChannel(at, j, { target: (e.target as HTMLSelectElement).value })}
|
|
591
|
+
@change=${(e: Event) => this.setChannel(kind, at, j, { target: (e.target as HTMLSelectElement).value })}
|
|
448
592
|
>
|
|
449
593
|
${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
|
|
450
594
|
${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
|
|
451
595
|
</select>
|
|
452
|
-
<button plain @click=${() => this.removeChannel(at, j)} title=${i18next.t('figure.button.remove')}>
|
|
596
|
+
<button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
|
|
453
597
|
<md-icon>close</md-icon>
|
|
454
598
|
</button>
|
|
455
599
|
</div>
|
|
@@ -460,7 +604,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
460
604
|
<label>${i18next.t('figure.label.channel-path')}</label>
|
|
461
605
|
<select
|
|
462
606
|
.value=${live(channel.path)}
|
|
463
|
-
@change=${(e: Event) => this.setPath(at, j, (e.target as HTMLSelectElement).value as ChannelPath)}
|
|
607
|
+
@change=${(e: Event) => this.setPath(kind, at, j, (e.target as HTMLSelectElement).value as ChannelPath)}
|
|
464
608
|
>
|
|
465
609
|
${CHANNEL_PATHS.map(path => html`<option value=${path}>${i18next.t('figure.label.path-' + path)}</option>`)}
|
|
466
610
|
</select>
|
|
@@ -468,7 +612,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
468
612
|
.value=${live(channel.interpolation ?? 'linear')}
|
|
469
613
|
title=${i18next.t('figure.text.interpolation-explained')}
|
|
470
614
|
@change=${(e: Event) =>
|
|
471
|
-
this.setChannel(at, j, { interpolation: (e.target as HTMLSelectElement).value as Channel['interpolation'] })}
|
|
615
|
+
this.setChannel(kind, at, j, { interpolation: (e.target as HTMLSelectElement).value as Channel['interpolation'] })}
|
|
472
616
|
>
|
|
473
617
|
${INTERPOLATIONS.map(
|
|
474
618
|
one => html`<option value=${one}>${i18next.t('figure.label.interpolation-' + one)}</option>`
|
|
@@ -478,8 +622,8 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
478
622
|
|
|
479
623
|
<p quiet>${i18next.t('figure.text.path-' + channel.path + '-unit')}</p>
|
|
480
624
|
|
|
481
|
-
${turning ? this.renderPivot(channel, at, j) : nothing}
|
|
482
|
-
${this.renderKeys(channel, at, j)}
|
|
625
|
+
${turning ? this.renderPivot(kind, channel, at, j) : nothing}
|
|
626
|
+
${this.renderKeys(kind, channel, at, j)}
|
|
483
627
|
</div>
|
|
484
628
|
`
|
|
485
629
|
}
|
|
@@ -490,7 +634,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
490
634
|
* 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
|
|
491
635
|
* 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
|
|
492
636
|
*/
|
|
493
|
-
private renderPivot(channel: Channel, at: number, j: number): TemplateResult {
|
|
637
|
+
private renderPivot(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
|
|
494
638
|
const pivot = channel.pivot
|
|
495
639
|
const on = pivot !== undefined
|
|
496
640
|
|
|
@@ -498,7 +642,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
498
642
|
<div row>
|
|
499
643
|
<label>${i18next.t('figure.label.channel-pivot')}</label>
|
|
500
644
|
<button
|
|
501
|
-
@click=${() => this.setChannel(at, j, { pivot: on ? undefined : { x: 0, y: 0, z: 0 } })}
|
|
645
|
+
@click=${() => this.setChannel(kind, at, j, { pivot: on ? undefined : { x: 0, y: 0, z: 0 } })}
|
|
502
646
|
title=${i18next.t('figure.text.pivot-defaults-to-the-part-centre')}
|
|
503
647
|
>
|
|
504
648
|
${i18next.t(on ? 'figure.button.pivot-clear' : 'figure.button.pivot-set')}
|
|
@@ -512,7 +656,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
512
656
|
.value=${live(String(pivot![axis]))}
|
|
513
657
|
aria-label=${axis}
|
|
514
658
|
@change=${(e: Event) =>
|
|
515
|
-
this.setChannel(at, j, {
|
|
659
|
+
this.setChannel(kind, at, j, {
|
|
516
660
|
pivot: { ...pivot!, [axis]: Number((e.target as HTMLInputElement).value) || 0 }
|
|
517
661
|
})}
|
|
518
662
|
/>
|
|
@@ -532,7 +676,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
532
676
|
* 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
|
|
533
677
|
* 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
|
|
534
678
|
*/
|
|
535
|
-
private renderKeys(channel: Channel, at: number, j: number): TemplateResult {
|
|
679
|
+
private renderKeys(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
|
|
536
680
|
const keys = channel.keys ?? []
|
|
537
681
|
|
|
538
682
|
return html`
|
|
@@ -555,7 +699,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
555
699
|
.value=${live(String(key.at))}
|
|
556
700
|
title=${backwards ? i18next.t('figure.text.keys-must-rise') : ''}
|
|
557
701
|
@change=${(e: Event) =>
|
|
558
|
-
this.setKey(at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
|
|
702
|
+
this.setKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
|
|
559
703
|
/>
|
|
560
704
|
</td>
|
|
561
705
|
${AXES.map(
|
|
@@ -567,7 +711,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
567
711
|
.value=${live(String(key.value[axis]))}
|
|
568
712
|
aria-label=${axis}
|
|
569
713
|
@change=${(e: Event) =>
|
|
570
|
-
this.setKey(at, j, k, {
|
|
714
|
+
this.setKey(kind, at, j, k, {
|
|
571
715
|
value: { ...key.value, [axis]: Number((e.target as HTMLInputElement).value) || 0 }
|
|
572
716
|
})}
|
|
573
717
|
/>
|
|
@@ -579,7 +723,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
579
723
|
plain
|
|
580
724
|
?disabled=${keys.length <= 2}
|
|
581
725
|
title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
|
|
582
|
-
@click=${() => this.removeKey(at, j, k)}
|
|
726
|
+
@click=${() => this.removeKey(kind, at, j, k)}
|
|
583
727
|
>
|
|
584
728
|
<md-icon>close</md-icon>
|
|
585
729
|
</button>
|
|
@@ -593,7 +737,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
593
737
|
? html`<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
|
|
594
738
|
: nothing}
|
|
595
739
|
|
|
596
|
-
<button @click=${() => this.addKey(at, j)}>
|
|
740
|
+
<button @click=${() => this.addKey(kind, at, j)}>
|
|
597
741
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
|
|
598
742
|
</button>
|
|
599
743
|
`
|
|
@@ -626,29 +770,45 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
626
770
|
// ─── 고치기 ───────────────────────────────────────────────────────────────
|
|
627
771
|
|
|
628
772
|
/**
|
|
629
|
-
*
|
|
773
|
+
* 초안에 실어 올려보낸다.
|
|
630
774
|
*
|
|
631
|
-
* `animations` 는 도형 전체의 것이므로 부품 편집(`update-part`)이 아니라 `update-
|
|
775
|
+
* `animations` 는 도형 전체의 것이므로 부품 편집(`update-part`)이 아니라 `update-draft` 로
|
|
632
776
|
* 간다. 빈 배열은 칸을 지운다 — clip 을 다 지운 도형은 `animations: []` 가 아니라 없는 것이다.
|
|
633
777
|
*/
|
|
634
|
-
private tell(
|
|
635
|
-
if (!this.
|
|
778
|
+
private tell(over: { animations?: Clip[]; parameters?: Parameter[] }): void {
|
|
779
|
+
if (!this.draft) return
|
|
636
780
|
|
|
637
|
-
const
|
|
638
|
-
if (animations
|
|
639
|
-
|
|
781
|
+
const draft: FigureDraft = { ...this.draft, ...over }
|
|
782
|
+
if (!draft.animations?.length) delete draft.animations
|
|
783
|
+
if (!draft.parameters?.length) delete draft.parameters
|
|
640
784
|
|
|
641
|
-
this.dispatchEvent(new CustomEvent('update-
|
|
785
|
+
this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
|
|
642
786
|
}
|
|
643
787
|
|
|
644
788
|
private patchClip(at: number, patch: Partial<Clip>): void {
|
|
645
|
-
this.tell(this.clips.map((clip, i) => (i === at ? { ...clip, ...patch } : clip)))
|
|
789
|
+
this.tell({ animations: this.clips.map((clip, i) => (i === at ? { ...clip, ...patch } : clip)) })
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private patchParameter(at: number, patch: Partial<Parameter>): void {
|
|
793
|
+
this.tell({ parameters: this.parameters.map((one, i) => (i === at ? { ...one, ...patch } : one)) })
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 채널 묶음을 통째로 얹는다. **어느 목록이든 같은 자리에 닿는다.** */
|
|
797
|
+
private setChannels(kind: MotionKind, at: number, channels: Channel[]): void {
|
|
798
|
+
if (kind === 'clip') this.patchClip(at, { channels })
|
|
799
|
+
else {
|
|
800
|
+
const parameter = this.parameters[at]
|
|
801
|
+
if (!parameter) return
|
|
802
|
+
this.patchParameter(at, { clip: { ...parameter.clip, channels } })
|
|
803
|
+
}
|
|
646
804
|
}
|
|
647
805
|
|
|
648
|
-
private patchChannel(at: number, j: number, next: Channel): void {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
806
|
+
private patchChannel(kind: MotionKind, at: number, j: number, next: Channel): void {
|
|
807
|
+
this.setChannels(
|
|
808
|
+
kind,
|
|
809
|
+
at,
|
|
810
|
+
this.channelsOf(kind, at).map((channel, i) => (i === j ? next : channel))
|
|
811
|
+
)
|
|
652
812
|
}
|
|
653
813
|
|
|
654
814
|
private addClip(): void {
|
|
@@ -656,60 +816,105 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
656
816
|
if (!target) return
|
|
657
817
|
|
|
658
818
|
const clip: Clip = {
|
|
659
|
-
name: freeName(this.
|
|
660
|
-
drive: 'loop',
|
|
819
|
+
name: freeName(this.allNames, 'clip-1'),
|
|
661
820
|
channels: [{ target, path: 'translation', keys: keysFor('translation') }]
|
|
662
821
|
}
|
|
663
822
|
|
|
664
|
-
this.open = this.clips.length
|
|
665
|
-
this.tell([...this.clips, clip])
|
|
823
|
+
this.open = `clip:${this.clips.length}`
|
|
824
|
+
this.tell({ animations: [...this.clips, clip] })
|
|
666
825
|
}
|
|
667
826
|
|
|
668
|
-
|
|
827
|
+
private addParameter(): void {
|
|
828
|
+
const target = this.partNames[0]
|
|
829
|
+
if (!target) return
|
|
830
|
+
|
|
831
|
+
const parameter: Parameter = {
|
|
832
|
+
name: freeName(this.allNames, 'parameter-1'),
|
|
833
|
+
range: { ...NEW_RANGE },
|
|
834
|
+
clip: { channels: [{ target, path: 'translation', keys: keysFor('translation') }] }
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
this.open = `parameter:${this.parameters.length}`
|
|
838
|
+
this.tell({ parameters: [...this.parameters, parameter] })
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Recipe creates a valid, editable two-pose motion; it never saves or publishes the Figure.
|
|
843
|
+
*
|
|
844
|
+
* 어느 목록으로 가는지는 레시피가 안다 — 도는 롤러는 애니메이션이고 열리는 문은
|
|
845
|
+
* 파라미터다. 저작자에게 그 갈래를 먼저 고르게 하지 않는 이유가 이것이다.
|
|
846
|
+
*/
|
|
669
847
|
private addRecipe(recipe: MotionRecipe): void {
|
|
670
848
|
const target = this.recipeTarget || this.partNames[0]
|
|
671
849
|
if (!target) return
|
|
850
|
+
|
|
672
851
|
const channel: Channel = {
|
|
673
852
|
target,
|
|
674
853
|
path: recipe.path,
|
|
675
854
|
keys: [{ at: 0, value: startValue(recipe.path) }, { at: 1, value: { ...recipe.end } }]
|
|
676
855
|
}
|
|
677
856
|
if (recipe.pivot) channel.pivot = { ...recipe.pivot }
|
|
678
|
-
|
|
679
|
-
|
|
857
|
+
|
|
858
|
+
const name = freeName(this.allNames, recipe.name)
|
|
859
|
+
|
|
860
|
+
if (recipe.kind === 'clip') {
|
|
861
|
+
this.open = `clip:${this.clips.length}`
|
|
862
|
+
this.tell({ animations: [...this.clips, { name, channels: [channel] }] })
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
this.open = `parameter:${this.parameters.length}`
|
|
867
|
+
this.tell({
|
|
868
|
+
parameters: [...this.parameters, { name, range: { ...(recipe.range ?? NEW_RANGE) }, clip: { channels: [channel] } }]
|
|
869
|
+
})
|
|
680
870
|
}
|
|
681
871
|
|
|
682
872
|
private renameClip(at: number, name: string): void {
|
|
683
873
|
this.patchClip(at, { name: name.trim() })
|
|
684
874
|
}
|
|
685
875
|
|
|
686
|
-
private
|
|
687
|
-
this.
|
|
876
|
+
private renameParameter(at: number, name: string): void {
|
|
877
|
+
this.patchParameter(at, { name: name.trim() })
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private setRange(at: number, patch: Partial<Parameter['range']>): void {
|
|
881
|
+
const parameter = this.parameters[at]
|
|
882
|
+
if (!parameter) return
|
|
883
|
+
this.patchParameter(at, { range: { ...(parameter.range ?? NEW_RANGE), ...patch } })
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private setDefault(at: number, value: number): void {
|
|
887
|
+
this.patchParameter(at, { default: value })
|
|
688
888
|
}
|
|
689
889
|
|
|
690
890
|
private removeClip(at: number): void {
|
|
691
|
-
|
|
692
|
-
this.tell(this.clips.filter((_, i) => i !== at))
|
|
891
|
+
this.open = ''
|
|
892
|
+
this.tell({ animations: this.clips.filter((_, i) => i !== at) })
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private removeParameter(at: number): void {
|
|
896
|
+
this.open = ''
|
|
897
|
+
this.tell({ parameters: this.parameters.filter((_, i) => i !== at) })
|
|
693
898
|
}
|
|
694
899
|
|
|
695
|
-
private addChannel(at: number): void {
|
|
696
|
-
const clip = this.clips[at]
|
|
900
|
+
private addChannel(kind: MotionKind, at: number): void {
|
|
697
901
|
const target = this.partNames[0]
|
|
698
|
-
if (!
|
|
902
|
+
if (!target) return
|
|
699
903
|
|
|
700
|
-
this.
|
|
701
|
-
|
|
702
|
-
|
|
904
|
+
this.setChannels(kind, at, [
|
|
905
|
+
...this.channelsOf(kind, at),
|
|
906
|
+
{ target, path: 'translation', keys: keysFor('translation') }
|
|
907
|
+
])
|
|
703
908
|
}
|
|
704
909
|
|
|
705
|
-
private setChannel(at: number, j: number, patch: Partial<Channel>): void {
|
|
706
|
-
const channel = this.
|
|
910
|
+
private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<Channel>): void {
|
|
911
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
707
912
|
if (!channel) return
|
|
708
913
|
|
|
709
914
|
const next: Channel = { ...channel, ...patch }
|
|
710
915
|
/* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
|
|
711
916
|
if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
|
|
712
|
-
this.patchChannel(at, j, next)
|
|
917
|
+
this.patchChannel(kind, at, j, next)
|
|
713
918
|
}
|
|
714
919
|
|
|
715
920
|
/**
|
|
@@ -719,8 +924,8 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
719
924
|
* 두면 저작자는 부품이 사라진 것을 보고 도구가 고장 난 줄 안다. 그래서 옮김·회전 ↔ 배율을
|
|
720
925
|
* 건널 때 시작값을 그 경로의 제자리로 되돌린다. 시각은 지킨다 — 저작자가 짠 박자다.
|
|
721
926
|
*/
|
|
722
|
-
private setPath(at: number, j: number, path: ChannelPath): void {
|
|
723
|
-
const channel = this.
|
|
927
|
+
private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
|
|
928
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
724
929
|
if (!channel) return
|
|
725
930
|
|
|
726
931
|
const crossing = (channel.path === 'scale') !== (path === 'scale')
|
|
@@ -732,38 +937,40 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
|
|
|
732
937
|
/* 회전이 아니면 회전 중심은 쓰이지 않는다. 남겨 두면 검증이 「쓰이지 않는다」고 알린다. */
|
|
733
938
|
if (path !== 'rotation') delete next.pivot
|
|
734
939
|
|
|
735
|
-
this.patchChannel(at, j, next)
|
|
940
|
+
this.patchChannel(kind, at, j, next)
|
|
736
941
|
}
|
|
737
942
|
|
|
738
|
-
private removeChannel(at: number, j: number): void {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
943
|
+
private removeChannel(kind: MotionKind, at: number, j: number): void {
|
|
944
|
+
this.setChannels(
|
|
945
|
+
kind,
|
|
946
|
+
at,
|
|
947
|
+
this.channelsOf(kind, at).filter((_, i) => i !== j)
|
|
948
|
+
)
|
|
742
949
|
}
|
|
743
950
|
|
|
744
951
|
/** 마지막 키에서 1 초 뒤. 값은 그 키를 그대로 받는다 — 붙여 놓고 고치는 것이 짜기 쉽다. */
|
|
745
|
-
private addKey(at: number, j: number): void {
|
|
746
|
-
const channel = this.
|
|
952
|
+
private addKey(kind: MotionKind, at: number, j: number): void {
|
|
953
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
747
954
|
if (!channel) return
|
|
748
955
|
|
|
749
956
|
const last = channel.keys[channel.keys.length - 1]
|
|
750
957
|
const key: Keyframe = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } }
|
|
751
|
-
this.patchChannel(at, j, { ...channel, keys: [...channel.keys, key] })
|
|
958
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
|
|
752
959
|
}
|
|
753
960
|
|
|
754
|
-
private setKey(at: number, j: number, k: number, patch: Partial<Keyframe>): void {
|
|
755
|
-
const channel = this.
|
|
961
|
+
private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
|
|
962
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
756
963
|
if (!channel) return
|
|
757
|
-
this.patchChannel(at, j, {
|
|
964
|
+
this.patchChannel(kind, at, j, {
|
|
758
965
|
...channel,
|
|
759
966
|
keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
|
|
760
967
|
})
|
|
761
968
|
}
|
|
762
969
|
|
|
763
970
|
/** 둘 미만으로 내려가지 않는다 — 단추가 이미 죽어 있지만 밖에서 불릴 수 있다. */
|
|
764
|
-
private removeKey(at: number, j: number, k: number): void {
|
|
765
|
-
const channel = this.
|
|
971
|
+
private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
|
|
972
|
+
const channel = this.channelsOf(kind, at)[j]
|
|
766
973
|
if (!channel || channel.keys.length <= 2) return
|
|
767
|
-
this.patchChannel(at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) })
|
|
974
|
+
this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) })
|
|
768
975
|
}
|
|
769
976
|
}
|