@things-factory/figure-ui 10.1.17 → 10.1.19
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 +322 -115
- package/client/modeller/figure-preview.ts +83 -70
- package/client/modeller/figure-side.ts +1 -1
- package/client/modeller/figure-source.ts +10 -0
- package/client/modeller/proposal.ts +3 -1
- package/client/pages/figure-modeller-page.ts +1 -0
- package/dist-client/modeller/figure-animations.d.ts +33 -4
- package/dist-client/modeller/figure-animations.js +285 -114
- package/dist-client/modeller/figure-animations.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +19 -8
- package/dist-client/modeller/figure-preview.js +74 -70
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-side.js +1 -1
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-source.d.ts +8 -0
- package/dist-client/modeller/figure-source.js +2 -0
- package/dist-client/modeller/figure-source.js.map +1 -1
- package/dist-client/modeller/proposal.d.ts +1 -1
- package/dist-client/modeller/proposal.js +2 -1
- package/dist-client/modeller/proposal.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.js +1 -0
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal-contract.test.ts +41 -1
- package/test/figure-source.test.ts +47 -37
- package/test/i18n-prefix-guard.test.ts +8 -11
- package/translations/en.json +15 -7
- package/translations/ko.json +15 -7
|
@@ -4,13 +4,41 @@ import { customElement, property, state } from 'lit/decorators.js';
|
|
|
4
4
|
import { live } from 'lit/directives/live.js';
|
|
5
5
|
import { i18next, localize } from '@operato/i18n';
|
|
6
6
|
import { ScrollbarStyles } from '@operato/styles';
|
|
7
|
-
import { AXES, CHANNEL_PATHS,
|
|
7
|
+
import { AXES, CHANNEL_PATHS, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model';
|
|
8
|
+
/*
|
|
9
|
+
레시피의 `end` 가 곧 그 범위의 끝 자세다 — 리프트가 100mm 올라가는 곡선이면 범위도
|
|
10
|
+
0~100mm 다. 둘이 어긋나면 저작자가 1200 을 주고도 100 만 올라가는 것을 보게 된다.
|
|
11
|
+
*/
|
|
8
12
|
const MOTION_RECIPES = [
|
|
9
|
-
{ id: 'conveyor', name: 'conveyor-running',
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
{ id: 'conveyor', name: 'conveyor-running', kind: 'clip', path: 'rotation', end: { x: 360, y: 0, z: 0 } },
|
|
14
|
+
{
|
|
15
|
+
id: 'gate',
|
|
16
|
+
name: 'gate-open',
|
|
17
|
+
kind: 'parameter',
|
|
18
|
+
path: 'rotation',
|
|
19
|
+
end: { x: 0, y: 90, z: 0 },
|
|
20
|
+
pivot: { x: 0, y: 0, z: 0 },
|
|
21
|
+
range: { unit: '%', min: 0, max: 100 }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'pusher',
|
|
25
|
+
name: 'pusher-position',
|
|
26
|
+
kind: 'parameter',
|
|
27
|
+
path: 'translation',
|
|
28
|
+
end: { x: 100, y: 0, z: 0 },
|
|
29
|
+
range: { unit: 'mm', min: 0, max: 100 }
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'lift',
|
|
33
|
+
name: 'lift-height',
|
|
34
|
+
kind: 'parameter',
|
|
35
|
+
path: 'translation',
|
|
36
|
+
end: { x: 0, y: 100, z: 0 },
|
|
37
|
+
range: { unit: 'mm', min: 0, max: 100 }
|
|
38
|
+
}
|
|
13
39
|
];
|
|
40
|
+
/** 새 파라미터의 범위. 단위는 저작자가 고쳐 쓴다 — 형식은 단위를 해석하지 않는다. */
|
|
41
|
+
const NEW_RANGE = { unit: 'mm', min: 0, max: 1000 };
|
|
14
42
|
/** 새 clip 의 키 둘. **하나로 만들지 않는다** — 키 하나는 움직임이 아니라 자세다. */
|
|
15
43
|
const NEW_KEYS = [
|
|
16
44
|
{ at: 0, value: { x: 0, y: 0, z: 0 } },
|
|
@@ -23,29 +51,30 @@ function startValue(path) {
|
|
|
23
51
|
function keysFor(path) {
|
|
24
52
|
return NEW_KEYS.map(key => ({ at: key.at, value: startValue(path) }));
|
|
25
53
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const used = new Set(taken.map(clip => clip.name));
|
|
37
|
-
if (!used.has(base))
|
|
38
|
-
return base;
|
|
54
|
+
/**
|
|
55
|
+
* 이미 쓴 이름을 비켜 간다.
|
|
56
|
+
*
|
|
57
|
+
* **두 목록을 함께 본다.** 인스턴스는 이름 하나로 값을 주므로 애니메이션과 파라미터가
|
|
58
|
+
* 같은 이름을 가지면 어느 쪽에 준 것인지 알 수 없고, 형식이 그것을 오류로 막는다.
|
|
59
|
+
*/
|
|
60
|
+
function freeName(taken, stem) {
|
|
61
|
+
const used = new Set(taken.map(one => one.name));
|
|
62
|
+
if (!used.has(stem))
|
|
63
|
+
return stem;
|
|
39
64
|
for (let n = 2;; n++)
|
|
40
|
-
if (!used.has(`${
|
|
41
|
-
return `${
|
|
65
|
+
if (!used.has(`${stem}-${n}`))
|
|
66
|
+
return `${stem}-${n}`;
|
|
42
67
|
}
|
|
43
68
|
let FigureAnimations = class FigureAnimations extends localize(i18next)(LitElement) {
|
|
44
69
|
constructor() {
|
|
45
70
|
super(...arguments);
|
|
46
71
|
this.parts = [];
|
|
47
|
-
/**
|
|
48
|
-
|
|
72
|
+
/**
|
|
73
|
+
* 펼쳐 둔 것. 이름이 아니라 **목록과 자리**로 잡는다 — 이름은 편집 중에 바뀐다.
|
|
74
|
+
*
|
|
75
|
+
* 목록이 둘이므로 자리 하나로는 모자란다. `clip:0` 과 `parameter:0` 은 다른 것이다.
|
|
76
|
+
*/
|
|
77
|
+
this.open = '';
|
|
49
78
|
this.recipeTarget = '';
|
|
50
79
|
}
|
|
51
80
|
static { this.styles = [
|
|
@@ -248,6 +277,16 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
248
277
|
get clips() {
|
|
249
278
|
return this.draft?.animations ?? [];
|
|
250
279
|
}
|
|
280
|
+
get parameters() {
|
|
281
|
+
return this.draft?.parameters ?? [];
|
|
282
|
+
}
|
|
283
|
+
/** 두 목록의 이름을 한자리에서 본다 — 인스턴스가 이름 하나로 값을 준다. */
|
|
284
|
+
get allNames() {
|
|
285
|
+
return [...this.clips, ...this.parameters];
|
|
286
|
+
}
|
|
287
|
+
channelsOf(kind, at) {
|
|
288
|
+
return (kind === 'clip' ? this.clips[at]?.channels : this.parameters[at]?.clip?.channels) ?? [];
|
|
289
|
+
}
|
|
251
290
|
/** 부품 이름 — 채널이 이것을 가리킨다. */
|
|
252
291
|
get partNames() {
|
|
253
292
|
return this.parts.map(part => part.name).filter(name => !!name);
|
|
@@ -263,11 +302,16 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
263
302
|
for (const clip of this.clips)
|
|
264
303
|
for (const channel of clip.channels)
|
|
265
304
|
moving.add(channel.target);
|
|
305
|
+
for (const parameter of this.parameters) {
|
|
306
|
+
for (const channel of parameter.clip?.channels ?? [])
|
|
307
|
+
moving.add(channel.target);
|
|
308
|
+
}
|
|
266
309
|
return moving.size;
|
|
267
310
|
}
|
|
268
311
|
render() {
|
|
269
312
|
if (!this.draft)
|
|
270
313
|
return html ``;
|
|
314
|
+
const nameless = this.partNames.length === 0;
|
|
271
315
|
return html `
|
|
272
316
|
<section>
|
|
273
317
|
<h3>${i18next.t('figure.label.animation')}</h3>
|
|
@@ -275,18 +319,27 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
275
319
|
</section>
|
|
276
320
|
|
|
277
321
|
${this.renderRecipes()}
|
|
278
|
-
${this.clips.length === 0 ? nothing : this.renderClips()}
|
|
279
322
|
|
|
280
323
|
<section>
|
|
281
|
-
<
|
|
324
|
+
<h3>${i18next.t('figure.label.parameters')}</h3>
|
|
325
|
+
<p quiet>${i18next.t('figure.text.parameter-is-a-value-not-a-timeline')}</p>
|
|
326
|
+
${this.parameters.map((parameter, at) => this.renderParameter(parameter, at))}
|
|
327
|
+
<button @click=${() => this.addParameter()} ?disabled=${nameless}>
|
|
328
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.add-parameter')}
|
|
329
|
+
</button>
|
|
330
|
+
</section>
|
|
331
|
+
|
|
332
|
+
<section>
|
|
333
|
+
<h3>${i18next.t('figure.label.clips')}</h3>
|
|
334
|
+
<p quiet>${i18next.t('figure.text.clip-is-time-running')}</p>
|
|
335
|
+
${this.clips.map((clip, at) => this.renderClip(clip, at))}
|
|
336
|
+
<button @click=${() => this.addClip()} ?disabled=${nameless}>
|
|
282
337
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-clip')}
|
|
283
338
|
</button>
|
|
284
|
-
${
|
|
285
|
-
? html `<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>`
|
|
286
|
-
: nothing}
|
|
339
|
+
${nameless ? html `<p quiet>${i18next.t('figure.text.animation-needs-a-named-part')}</p>` : nothing}
|
|
287
340
|
</section>
|
|
288
341
|
|
|
289
|
-
${this.
|
|
342
|
+
${this.movingParts === 0 ? nothing : this.renderBudget()}
|
|
290
343
|
`;
|
|
291
344
|
}
|
|
292
345
|
/**
|
|
@@ -316,21 +369,20 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
316
369
|
</section>
|
|
317
370
|
`;
|
|
318
371
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
</section>
|
|
324
|
-
`;
|
|
372
|
+
/** 이름이 다른 것과 겹치나. 두 목록을 함께 본다 — 인스턴스는 이름 하나로 값을 준다. */
|
|
373
|
+
taken(kind, at, name) {
|
|
374
|
+
const mine = kind === 'clip' ? this.clips[at] : this.parameters[at];
|
|
375
|
+
return this.allNames.some(one => one !== mine && one.name === name);
|
|
325
376
|
}
|
|
326
377
|
renderClip(clip, at) {
|
|
327
|
-
const
|
|
328
|
-
const
|
|
378
|
+
const key = `clip:${at}`;
|
|
379
|
+
const on = this.open === key;
|
|
380
|
+
const duplicate = this.taken('clip', at, clip.name);
|
|
329
381
|
const nameless = !clip.name?.trim();
|
|
330
382
|
return html `
|
|
331
383
|
<div clip ?on=${on}>
|
|
332
384
|
<div head>
|
|
333
|
-
<button plain @click=${() => (this.open = on ?
|
|
385
|
+
<button plain @click=${() => (this.open = on ? '' : key)} title=${i18next.t('figure.label.channels')}>
|
|
334
386
|
<md-icon>${on ? 'expand_more' : 'chevron_right'}</md-icon>
|
|
335
387
|
</button>
|
|
336
388
|
<input
|
|
@@ -341,13 +393,6 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
341
393
|
title=${duplicate ? i18next.t('figure.text.clip-name-taken') : i18next.t('figure.text.clip-name-is-how-it-is-driven')}
|
|
342
394
|
@change=${(e) => this.renameClip(at, e.target.value)}
|
|
343
395
|
/>
|
|
344
|
-
<select
|
|
345
|
-
.value=${live(clip.drive ?? 'loop')}
|
|
346
|
-
title=${i18next.t('figure.text.drive-explained')}
|
|
347
|
-
@change=${(e) => this.setDrive(at, e.target.value)}
|
|
348
|
-
>
|
|
349
|
-
${CLIP_DRIVES.map(drive => html `<option value=${drive}>${i18next.t('figure.label.drive-' + drive)}</option>`)}
|
|
350
|
-
</select>
|
|
351
396
|
<button plain @click=${() => this.removeClip(at)} title=${i18next.t('figure.button.remove')}>
|
|
352
397
|
<md-icon>close</md-icon>
|
|
353
398
|
</button>
|
|
@@ -355,18 +400,100 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
355
400
|
|
|
356
401
|
${duplicate ? html `<p warn>${i18next.t('figure.text.clip-name-taken')}</p>` : nothing}
|
|
357
402
|
${nameless ? html `<p warn>${i18next.t('figure.text.clip-needs-a-name')}</p>` : nothing}
|
|
358
|
-
${on ? this.renderChannels(clip, at) : nothing}
|
|
403
|
+
${on ? this.renderChannels('clip', at) : nothing}
|
|
404
|
+
</div>
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* 파라미터 하나 — **이름·범위·자세.**
|
|
409
|
+
*
|
|
410
|
+
* 범위가 화면에 있어야 하는 이유는 그것이 **인스턴스와 주고받는 말**이기 때문이다.
|
|
411
|
+
* 곡선은 0~1 이고 인스턴스는 mm 로 준다. 그 사이를 런타임이 맵하는데, 무엇부터 무엇
|
|
412
|
+
* 까지인지 저작자가 정하지 않으면 값을 줄 수가 없다.
|
|
413
|
+
*/
|
|
414
|
+
renderParameter(parameter, at) {
|
|
415
|
+
const key = `parameter:${at}`;
|
|
416
|
+
const on = this.open === key;
|
|
417
|
+
const duplicate = this.taken('parameter', at, parameter.name);
|
|
418
|
+
const nameless = !parameter.name?.trim();
|
|
419
|
+
const range = parameter.range ?? NEW_RANGE;
|
|
420
|
+
const backwards = !(range.max > range.min);
|
|
421
|
+
return html `
|
|
422
|
+
<div clip ?on=${on}>
|
|
423
|
+
<div head>
|
|
424
|
+
<button plain @click=${() => (this.open = on ? '' : key)} title=${i18next.t('figure.label.channels')}>
|
|
425
|
+
<md-icon>${on ? 'expand_more' : 'chevron_right'}</md-icon>
|
|
426
|
+
</button>
|
|
427
|
+
<input
|
|
428
|
+
name
|
|
429
|
+
.value=${live(parameter.name ?? '')}
|
|
430
|
+
?bad=${duplicate || nameless}
|
|
431
|
+
placeholder=${i18next.t('figure.label.parameter-name')}
|
|
432
|
+
title=${duplicate
|
|
433
|
+
? i18next.t('figure.text.clip-name-taken')
|
|
434
|
+
: i18next.t('figure.text.parameter-name-is-how-a-value-arrives')}
|
|
435
|
+
@change=${(e) => this.renameParameter(at, e.target.value)}
|
|
436
|
+
/>
|
|
437
|
+
<button plain @click=${() => this.removeParameter(at)} title=${i18next.t('figure.button.remove')}>
|
|
438
|
+
<md-icon>close</md-icon>
|
|
439
|
+
</button>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
${duplicate ? html `<p warn>${i18next.t('figure.text.clip-name-taken')}</p>` : nothing}
|
|
443
|
+
${nameless ? html `<p warn>${i18next.t('figure.text.parameter-needs-a-name')}</p>` : nothing}
|
|
444
|
+
|
|
445
|
+
<div row>
|
|
446
|
+
<label>${i18next.t('figure.label.parameter-range')}</label>
|
|
447
|
+
<input
|
|
448
|
+
type="number"
|
|
449
|
+
step="1"
|
|
450
|
+
?bad=${backwards}
|
|
451
|
+
.value=${live(String(range.min))}
|
|
452
|
+
aria-label="min"
|
|
453
|
+
@change=${(e) => this.setRange(at, { min: Number(e.target.value) || 0 })}
|
|
454
|
+
/>
|
|
455
|
+
<input
|
|
456
|
+
type="number"
|
|
457
|
+
step="1"
|
|
458
|
+
?bad=${backwards}
|
|
459
|
+
.value=${live(String(range.max))}
|
|
460
|
+
aria-label="max"
|
|
461
|
+
@change=${(e) => this.setRange(at, { max: Number(e.target.value) || 0 })}
|
|
462
|
+
/>
|
|
463
|
+
<input
|
|
464
|
+
unit
|
|
465
|
+
.value=${live(range.unit ?? '')}
|
|
466
|
+
placeholder="mm"
|
|
467
|
+
aria-label="unit"
|
|
468
|
+
title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
|
|
469
|
+
@change=${(e) => this.setRange(at, { unit: e.target.value.trim() })}
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
${backwards ? html `<p warn>${i18next.t('figure.text.range-needs-room')}</p>` : nothing}
|
|
473
|
+
|
|
474
|
+
<div row>
|
|
475
|
+
<label>${i18next.t('figure.label.parameter-default')}</label>
|
|
476
|
+
<input
|
|
477
|
+
type="number"
|
|
478
|
+
step="1"
|
|
479
|
+
.value=${live(String(parameter.default ?? range.min))}
|
|
480
|
+
@change=${(e) => this.setDefault(at, Number(e.target.value) || 0)}
|
|
481
|
+
/>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
${on ? this.renderChannels('parameter', at) : nothing}
|
|
359
485
|
</div>
|
|
360
486
|
`;
|
|
361
487
|
}
|
|
362
|
-
renderChannels(
|
|
488
|
+
renderChannels(kind, at) {
|
|
489
|
+
const channels = this.channelsOf(kind, at);
|
|
363
490
|
return html `
|
|
364
|
-
<p quiet>${i18next.t('figure.text.
|
|
491
|
+
<p quiet>${i18next.t(kind === 'clip' ? 'figure.text.clip-is-time-running' : 'figure.text.parameter-curve-is-the-value-axis')}</p>
|
|
365
492
|
|
|
366
|
-
${
|
|
367
|
-
${
|
|
493
|
+
${channels.length === 0 ? html `<p warn>${i18next.t('figure.text.clip-needs-a-channel')}</p>` : nothing}
|
|
494
|
+
${channels.map((channel, j) => this.renderChannel(kind, channel, at, j))}
|
|
368
495
|
|
|
369
|
-
<button @click=${() => this.addChannel(at)}>
|
|
496
|
+
<button @click=${() => this.addChannel(kind, at)}>
|
|
370
497
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-channel')}
|
|
371
498
|
</button>
|
|
372
499
|
`;
|
|
@@ -377,7 +504,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
377
504
|
* 없는 부품을 가리키는 채널은 붉게 낸다. 부품 이름을 바꾸면 그 순간 이렇게 되는데,
|
|
378
505
|
* 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
|
|
379
506
|
*/
|
|
380
|
-
renderChannel(channel, at, j) {
|
|
507
|
+
renderChannel(kind, channel, at, j) {
|
|
381
508
|
const orphan = !this.partNames.includes(channel.target);
|
|
382
509
|
const turning = channel.path === 'rotation';
|
|
383
510
|
return html `
|
|
@@ -386,12 +513,12 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
386
513
|
<label>${i18next.t('figure.label.channel-target')}</label>
|
|
387
514
|
<select
|
|
388
515
|
.value=${live(channel.target ?? '')}
|
|
389
|
-
@change=${(e) => this.setChannel(at, j, { target: e.target.value })}
|
|
516
|
+
@change=${(e) => this.setChannel(kind, at, j, { target: e.target.value })}
|
|
390
517
|
>
|
|
391
518
|
${orphan ? html `<option value=${channel.target}>${channel.target}</option>` : nothing}
|
|
392
519
|
${this.partNames.map(name => html `<option value=${name}>${name}</option>`)}
|
|
393
520
|
</select>
|
|
394
|
-
<button plain @click=${() => this.removeChannel(at, j)} title=${i18next.t('figure.button.remove')}>
|
|
521
|
+
<button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
|
|
395
522
|
<md-icon>close</md-icon>
|
|
396
523
|
</button>
|
|
397
524
|
</div>
|
|
@@ -402,14 +529,14 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
402
529
|
<label>${i18next.t('figure.label.channel-path')}</label>
|
|
403
530
|
<select
|
|
404
531
|
.value=${live(channel.path)}
|
|
405
|
-
@change=${(e) => this.setPath(at, j, e.target.value)}
|
|
532
|
+
@change=${(e) => this.setPath(kind, at, j, e.target.value)}
|
|
406
533
|
>
|
|
407
534
|
${CHANNEL_PATHS.map(path => html `<option value=${path}>${i18next.t('figure.label.path-' + path)}</option>`)}
|
|
408
535
|
</select>
|
|
409
536
|
<select
|
|
410
537
|
.value=${live(channel.interpolation ?? 'linear')}
|
|
411
538
|
title=${i18next.t('figure.text.interpolation-explained')}
|
|
412
|
-
@change=${(e) => this.setChannel(at, j, { interpolation: e.target.value })}
|
|
539
|
+
@change=${(e) => this.setChannel(kind, at, j, { interpolation: e.target.value })}
|
|
413
540
|
>
|
|
414
541
|
${INTERPOLATIONS.map(one => html `<option value=${one}>${i18next.t('figure.label.interpolation-' + one)}</option>`)}
|
|
415
542
|
</select>
|
|
@@ -417,8 +544,8 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
417
544
|
|
|
418
545
|
<p quiet>${i18next.t('figure.text.path-' + channel.path + '-unit')}</p>
|
|
419
546
|
|
|
420
|
-
${turning ? this.renderPivot(channel, at, j) : nothing}
|
|
421
|
-
${this.renderKeys(channel, at, j)}
|
|
547
|
+
${turning ? this.renderPivot(kind, channel, at, j) : nothing}
|
|
548
|
+
${this.renderKeys(kind, channel, at, j)}
|
|
422
549
|
</div>
|
|
423
550
|
`;
|
|
424
551
|
}
|
|
@@ -428,14 +555,14 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
428
555
|
* 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
|
|
429
556
|
* 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
|
|
430
557
|
*/
|
|
431
|
-
renderPivot(channel, at, j) {
|
|
558
|
+
renderPivot(kind, channel, at, j) {
|
|
432
559
|
const pivot = channel.pivot;
|
|
433
560
|
const on = pivot !== undefined;
|
|
434
561
|
return html `
|
|
435
562
|
<div row>
|
|
436
563
|
<label>${i18next.t('figure.label.channel-pivot')}</label>
|
|
437
564
|
<button
|
|
438
|
-
@click=${() => this.setChannel(at, j, { pivot: on ? undefined : { x: 0, y: 0, z: 0 } })}
|
|
565
|
+
@click=${() => this.setChannel(kind, at, j, { pivot: on ? undefined : { x: 0, y: 0, z: 0 } })}
|
|
439
566
|
title=${i18next.t('figure.text.pivot-defaults-to-the-part-centre')}
|
|
440
567
|
>
|
|
441
568
|
${i18next.t(on ? 'figure.button.pivot-clear' : 'figure.button.pivot-set')}
|
|
@@ -447,7 +574,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
447
574
|
step="1"
|
|
448
575
|
.value=${live(String(pivot[axis]))}
|
|
449
576
|
aria-label=${axis}
|
|
450
|
-
@change=${(e) => this.setChannel(at, j, {
|
|
577
|
+
@change=${(e) => this.setChannel(kind, at, j, {
|
|
451
578
|
pivot: { ...pivot, [axis]: Number(e.target.value) || 0 }
|
|
452
579
|
})}
|
|
453
580
|
/>
|
|
@@ -465,7 +592,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
465
592
|
* 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
|
|
466
593
|
* 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
|
|
467
594
|
*/
|
|
468
|
-
renderKeys(channel, at, j) {
|
|
595
|
+
renderKeys(kind, channel, at, j) {
|
|
469
596
|
const keys = channel.keys ?? [];
|
|
470
597
|
return html `
|
|
471
598
|
<table keys>
|
|
@@ -486,7 +613,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
486
613
|
?bad=${backwards || key.at < 0}
|
|
487
614
|
.value=${live(String(key.at))}
|
|
488
615
|
title=${backwards ? i18next.t('figure.text.keys-must-rise') : ''}
|
|
489
|
-
@change=${(e) => this.setKey(at, j, k, { at: Math.max(0, Number(e.target.value) || 0) })}
|
|
616
|
+
@change=${(e) => this.setKey(kind, at, j, k, { at: Math.max(0, Number(e.target.value) || 0) })}
|
|
490
617
|
/>
|
|
491
618
|
</td>
|
|
492
619
|
${AXES.map(axis => html `
|
|
@@ -496,7 +623,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
496
623
|
step=${channel.path === 'scale' ? '0.1' : '1'}
|
|
497
624
|
.value=${live(String(key.value[axis]))}
|
|
498
625
|
aria-label=${axis}
|
|
499
|
-
@change=${(e) => this.setKey(at, j, k, {
|
|
626
|
+
@change=${(e) => this.setKey(kind, at, j, k, {
|
|
500
627
|
value: { ...key.value, [axis]: Number(e.target.value) || 0 }
|
|
501
628
|
})}
|
|
502
629
|
/>
|
|
@@ -507,7 +634,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
507
634
|
plain
|
|
508
635
|
?disabled=${keys.length <= 2}
|
|
509
636
|
title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
|
|
510
|
-
@click=${() => this.removeKey(at, j, k)}
|
|
637
|
+
@click=${() => this.removeKey(kind, at, j, k)}
|
|
511
638
|
>
|
|
512
639
|
<md-icon>close</md-icon>
|
|
513
640
|
</button>
|
|
@@ -521,7 +648,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
521
648
|
? html `<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
|
|
522
649
|
: nothing}
|
|
523
650
|
|
|
524
|
-
<button @click=${() => this.addKey(at, j)}>
|
|
651
|
+
<button @click=${() => this.addKey(kind, at, j)}>
|
|
525
652
|
<md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
|
|
526
653
|
</button>
|
|
527
654
|
`;
|
|
@@ -555,38 +682,65 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
555
682
|
* `animations` 는 도형 전체의 것이므로 부품 편집(`update-part`)이 아니라 `update-draft` 로
|
|
556
683
|
* 간다. 빈 배열은 칸을 지운다 — clip 을 다 지운 도형은 `animations: []` 가 아니라 없는 것이다.
|
|
557
684
|
*/
|
|
558
|
-
tell(
|
|
685
|
+
tell(over) {
|
|
559
686
|
if (!this.draft)
|
|
560
687
|
return;
|
|
561
|
-
const draft = { ...this.draft };
|
|
562
|
-
if (animations
|
|
688
|
+
const draft = { ...this.draft, ...over };
|
|
689
|
+
if (!draft.animations?.length)
|
|
563
690
|
delete draft.animations;
|
|
564
|
-
|
|
565
|
-
draft.
|
|
691
|
+
if (!draft.parameters?.length)
|
|
692
|
+
delete draft.parameters;
|
|
566
693
|
this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }));
|
|
567
694
|
}
|
|
568
695
|
patchClip(at, patch) {
|
|
569
|
-
this.tell(this.clips.map((clip, i) => (i === at ? { ...clip, ...patch } : clip)));
|
|
696
|
+
this.tell({ animations: this.clips.map((clip, i) => (i === at ? { ...clip, ...patch } : clip)) });
|
|
570
697
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
698
|
+
patchParameter(at, patch) {
|
|
699
|
+
this.tell({ parameters: this.parameters.map((one, i) => (i === at ? { ...one, ...patch } : one)) });
|
|
700
|
+
}
|
|
701
|
+
/** 채널 묶음을 통째로 얹는다. **어느 목록이든 같은 자리에 닿는다.** */
|
|
702
|
+
setChannels(kind, at, channels) {
|
|
703
|
+
if (kind === 'clip')
|
|
704
|
+
this.patchClip(at, { channels });
|
|
705
|
+
else {
|
|
706
|
+
const parameter = this.parameters[at];
|
|
707
|
+
if (!parameter)
|
|
708
|
+
return;
|
|
709
|
+
this.patchParameter(at, { clip: { ...parameter.clip, channels } });
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
patchChannel(kind, at, j, next) {
|
|
713
|
+
this.setChannels(kind, at, this.channelsOf(kind, at).map((channel, i) => (i === j ? next : channel)));
|
|
576
714
|
}
|
|
577
715
|
addClip() {
|
|
578
716
|
const target = this.partNames[0];
|
|
579
717
|
if (!target)
|
|
580
718
|
return;
|
|
581
719
|
const clip = {
|
|
582
|
-
name: freeName(this.
|
|
583
|
-
drive: 'loop',
|
|
720
|
+
name: freeName(this.allNames, 'clip-1'),
|
|
584
721
|
channels: [{ target, path: 'translation', keys: keysFor('translation') }]
|
|
585
722
|
};
|
|
586
|
-
this.open = this.clips.length
|
|
587
|
-
this.tell([...this.clips, clip]);
|
|
723
|
+
this.open = `clip:${this.clips.length}`;
|
|
724
|
+
this.tell({ animations: [...this.clips, clip] });
|
|
725
|
+
}
|
|
726
|
+
addParameter() {
|
|
727
|
+
const target = this.partNames[0];
|
|
728
|
+
if (!target)
|
|
729
|
+
return;
|
|
730
|
+
const parameter = {
|
|
731
|
+
name: freeName(this.allNames, 'parameter-1'),
|
|
732
|
+
range: { ...NEW_RANGE },
|
|
733
|
+
clip: { channels: [{ target, path: 'translation', keys: keysFor('translation') }] }
|
|
734
|
+
};
|
|
735
|
+
this.open = `parameter:${this.parameters.length}`;
|
|
736
|
+
this.tell({ parameters: [...this.parameters, parameter] });
|
|
588
737
|
}
|
|
589
|
-
/**
|
|
738
|
+
/**
|
|
739
|
+
* Recipe creates a valid, editable two-pose motion; it never saves or publishes the Figure.
|
|
740
|
+
*
|
|
741
|
+
* 어느 목록으로 가는지는 레시피가 안다 — 도는 롤러는 애니메이션이고 열리는 문은
|
|
742
|
+
* 파라미터다. 저작자에게 그 갈래를 먼저 고르게 하지 않는 이유가 이것이다.
|
|
743
|
+
*/
|
|
590
744
|
addRecipe(recipe) {
|
|
591
745
|
const target = this.recipeTarget || this.partNames[0];
|
|
592
746
|
if (!target)
|
|
@@ -598,38 +752,58 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
598
752
|
};
|
|
599
753
|
if (recipe.pivot)
|
|
600
754
|
channel.pivot = { ...recipe.pivot };
|
|
601
|
-
|
|
602
|
-
|
|
755
|
+
const name = freeName(this.allNames, recipe.name);
|
|
756
|
+
if (recipe.kind === 'clip') {
|
|
757
|
+
this.open = `clip:${this.clips.length}`;
|
|
758
|
+
this.tell({ animations: [...this.clips, { name, channels: [channel] }] });
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
this.open = `parameter:${this.parameters.length}`;
|
|
762
|
+
this.tell({
|
|
763
|
+
parameters: [...this.parameters, { name, range: { ...(recipe.range ?? NEW_RANGE) }, clip: { channels: [channel] } }]
|
|
764
|
+
});
|
|
603
765
|
}
|
|
604
766
|
renameClip(at, name) {
|
|
605
767
|
this.patchClip(at, { name: name.trim() });
|
|
606
768
|
}
|
|
607
|
-
|
|
608
|
-
this.
|
|
769
|
+
renameParameter(at, name) {
|
|
770
|
+
this.patchParameter(at, { name: name.trim() });
|
|
771
|
+
}
|
|
772
|
+
setRange(at, patch) {
|
|
773
|
+
const parameter = this.parameters[at];
|
|
774
|
+
if (!parameter)
|
|
775
|
+
return;
|
|
776
|
+
this.patchParameter(at, { range: { ...(parameter.range ?? NEW_RANGE), ...patch } });
|
|
777
|
+
}
|
|
778
|
+
setDefault(at, value) {
|
|
779
|
+
this.patchParameter(at, { default: value });
|
|
609
780
|
}
|
|
610
781
|
removeClip(at) {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
this.tell(this.clips.filter((_, i) => i !== at));
|
|
782
|
+
this.open = '';
|
|
783
|
+
this.tell({ animations: this.clips.filter((_, i) => i !== at) });
|
|
614
784
|
}
|
|
615
|
-
|
|
616
|
-
|
|
785
|
+
removeParameter(at) {
|
|
786
|
+
this.open = '';
|
|
787
|
+
this.tell({ parameters: this.parameters.filter((_, i) => i !== at) });
|
|
788
|
+
}
|
|
789
|
+
addChannel(kind, at) {
|
|
617
790
|
const target = this.partNames[0];
|
|
618
|
-
if (!
|
|
791
|
+
if (!target)
|
|
619
792
|
return;
|
|
620
|
-
this.
|
|
621
|
-
|
|
622
|
-
|
|
793
|
+
this.setChannels(kind, at, [
|
|
794
|
+
...this.channelsOf(kind, at),
|
|
795
|
+
{ target, path: 'translation', keys: keysFor('translation') }
|
|
796
|
+
]);
|
|
623
797
|
}
|
|
624
|
-
setChannel(at, j, patch) {
|
|
625
|
-
const channel = this.
|
|
798
|
+
setChannel(kind, at, j, patch) {
|
|
799
|
+
const channel = this.channelsOf(kind, at)[j];
|
|
626
800
|
if (!channel)
|
|
627
801
|
return;
|
|
628
802
|
const next = { ...channel, ...patch };
|
|
629
803
|
/* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
|
|
630
804
|
if ('pivot' in patch && patch.pivot === undefined)
|
|
631
805
|
delete next.pivot;
|
|
632
|
-
this.patchChannel(at, j, next);
|
|
806
|
+
this.patchChannel(kind, at, j, next);
|
|
633
807
|
}
|
|
634
808
|
/**
|
|
635
809
|
* 경로를 바꾼다 — **값까지 함께 옮긴다.**
|
|
@@ -638,8 +812,8 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
638
812
|
* 두면 저작자는 부품이 사라진 것을 보고 도구가 고장 난 줄 안다. 그래서 옮김·회전 ↔ 배율을
|
|
639
813
|
* 건널 때 시작값을 그 경로의 제자리로 되돌린다. 시각은 지킨다 — 저작자가 짠 박자다.
|
|
640
814
|
*/
|
|
641
|
-
setPath(at, j, path) {
|
|
642
|
-
const channel = this.
|
|
815
|
+
setPath(kind, at, j, path) {
|
|
816
|
+
const channel = this.channelsOf(kind, at)[j];
|
|
643
817
|
if (!channel)
|
|
644
818
|
return;
|
|
645
819
|
const crossing = (channel.path === 'scale') !== (path === 'scale');
|
|
@@ -651,38 +825,35 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
|
|
|
651
825
|
/* 회전이 아니면 회전 중심은 쓰이지 않는다. 남겨 두면 검증이 「쓰이지 않는다」고 알린다. */
|
|
652
826
|
if (path !== 'rotation')
|
|
653
827
|
delete next.pivot;
|
|
654
|
-
this.patchChannel(at, j, next);
|
|
828
|
+
this.patchChannel(kind, at, j, next);
|
|
655
829
|
}
|
|
656
|
-
removeChannel(at, j) {
|
|
657
|
-
|
|
658
|
-
if (!clip)
|
|
659
|
-
return;
|
|
660
|
-
this.patchClip(at, { channels: clip.channels.filter((_, i) => i !== j) });
|
|
830
|
+
removeChannel(kind, at, j) {
|
|
831
|
+
this.setChannels(kind, at, this.channelsOf(kind, at).filter((_, i) => i !== j));
|
|
661
832
|
}
|
|
662
833
|
/** 마지막 키에서 1 초 뒤. 값은 그 키를 그대로 받는다 — 붙여 놓고 고치는 것이 짜기 쉽다. */
|
|
663
|
-
addKey(at, j) {
|
|
664
|
-
const channel = this.
|
|
834
|
+
addKey(kind, at, j) {
|
|
835
|
+
const channel = this.channelsOf(kind, at)[j];
|
|
665
836
|
if (!channel)
|
|
666
837
|
return;
|
|
667
838
|
const last = channel.keys[channel.keys.length - 1];
|
|
668
839
|
const key = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } };
|
|
669
|
-
this.patchChannel(at, j, { ...channel, keys: [...channel.keys, key] });
|
|
840
|
+
this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] });
|
|
670
841
|
}
|
|
671
|
-
setKey(at, j, k, patch) {
|
|
672
|
-
const channel = this.
|
|
842
|
+
setKey(kind, at, j, k, patch) {
|
|
843
|
+
const channel = this.channelsOf(kind, at)[j];
|
|
673
844
|
if (!channel)
|
|
674
845
|
return;
|
|
675
|
-
this.patchChannel(at, j, {
|
|
846
|
+
this.patchChannel(kind, at, j, {
|
|
676
847
|
...channel,
|
|
677
848
|
keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
|
|
678
849
|
});
|
|
679
850
|
}
|
|
680
851
|
/** 둘 미만으로 내려가지 않는다 — 단추가 이미 죽어 있지만 밖에서 불릴 수 있다. */
|
|
681
|
-
removeKey(at, j, k) {
|
|
682
|
-
const channel = this.
|
|
852
|
+
removeKey(kind, at, j, k) {
|
|
853
|
+
const channel = this.channelsOf(kind, at)[j];
|
|
683
854
|
if (!channel || channel.keys.length <= 2)
|
|
684
855
|
return;
|
|
685
|
-
this.patchChannel(at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) });
|
|
856
|
+
this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) });
|
|
686
857
|
}
|
|
687
858
|
};
|
|
688
859
|
__decorate([
|