@things-factory/figure-ui 10.1.17 → 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.
Files changed (30) hide show
  1. package/client/modeller/figure-animations.ts +322 -115
  2. package/client/modeller/figure-preview.ts +83 -70
  3. package/client/modeller/figure-side.ts +1 -1
  4. package/client/modeller/figure-source.ts +10 -0
  5. package/client/modeller/proposal.ts +3 -1
  6. package/client/pages/figure-modeller-page.ts +1 -0
  7. package/dist-client/modeller/figure-animations.d.ts +33 -4
  8. package/dist-client/modeller/figure-animations.js +285 -114
  9. package/dist-client/modeller/figure-animations.js.map +1 -1
  10. package/dist-client/modeller/figure-preview.d.ts +19 -8
  11. package/dist-client/modeller/figure-preview.js +74 -70
  12. package/dist-client/modeller/figure-preview.js.map +1 -1
  13. package/dist-client/modeller/figure-side.js +1 -1
  14. package/dist-client/modeller/figure-side.js.map +1 -1
  15. package/dist-client/modeller/figure-source.d.ts +8 -0
  16. package/dist-client/modeller/figure-source.js +2 -0
  17. package/dist-client/modeller/figure-source.js.map +1 -1
  18. package/dist-client/modeller/proposal.d.ts +1 -1
  19. package/dist-client/modeller/proposal.js +2 -1
  20. package/dist-client/modeller/proposal.js.map +1 -1
  21. package/dist-client/pages/figure-modeller-page.js +1 -0
  22. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  23. package/dist-client/route.d.ts +1 -1
  24. package/dist-client/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +5 -5
  26. package/test/ai-proposal-contract.test.ts +41 -1
  27. package/test/figure-source.test.ts +47 -37
  28. package/test/i18n-prefix-guard.test.ts +8 -11
  29. package/translations/en.json +15 -7
  30. 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, CLIP_DRIVES, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model';
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', drive: 'loop', path: 'rotation', end: { x: 360, y: 0, z: 0 } },
10
- { id: 'gate', name: 'gate-open', drive: 'hold', path: 'rotation', end: { x: 0, y: 90, z: 0 }, pivot: { x: 0, y: 0, z: 0 } },
11
- { id: 'pusher', name: 'pusher-position', drive: 'hold', path: 'translation', end: { x: 100, y: 0, z: 0 } },
12
- { id: 'lift', name: 'lift-height', drive: 'hold', path: 'translation', end: { x: 0, y: 100, z: 0 } }
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
- /** 이미 쓴 이름을 비켜 간다. `type` 처럼 저장되는 식별자는 아니지만 인스턴스가 이 이름으로 구동한다. */
27
- function freeName(taken) {
28
- const used = new Set(taken.map(clip => clip.name));
29
- for (let n = 1;; n++) {
30
- const name = `clip-${n}`;
31
- if (!used.has(name))
32
- return name;
33
- }
34
- }
35
- function freeRecipeName(taken, base) {
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(`${base}-${n}`))
41
- return `${base}-${n}`;
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
- /** 펼쳐 둔 clip. 이름이 아니라 자리로 잡는다 — 이름은 편집 중에 바뀐다. */
48
- this.open = 0;
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
- <button @click=${() => this.addClip()} ?disabled=${this.partNames.length === 0}>
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
- ${this.partNames.length === 0
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.clips.length === 0 ? nothing : this.renderBudget()}
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
- renderClips() {
320
- return html `
321
- <section>
322
- ${this.clips.map((clip, at) => this.renderClip(clip, at))}
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 on = this.open === at;
328
- const duplicate = this.clips.some((other, i) => i !== at && other.name === clip.name);
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 ? -1 : at)} title=${i18next.t('figure.label.channels')}>
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(clip, at) {
488
+ renderChannels(kind, at) {
489
+ const channels = this.channelsOf(kind, at);
363
490
  return html `
364
- <p quiet>${i18next.t('figure.text.drive-' + (clip.drive ?? 'loop') + '-note')}</p>
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
- ${clip.channels.length === 0 ? html `<p warn>${i18next.t('figure.text.clip-needs-a-channel')}</p>` : nothing}
367
- ${clip.channels.map((channel, j) => this.renderChannel(channel, at, j))}
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(animations) {
685
+ tell(over) {
559
686
  if (!this.draft)
560
687
  return;
561
- const draft = { ...this.draft };
562
- if (animations.length === 0)
688
+ const draft = { ...this.draft, ...over };
689
+ if (!draft.animations?.length)
563
690
  delete draft.animations;
564
- else
565
- draft.animations = animations;
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
- patchChannel(at, j, next) {
572
- const clip = this.clips[at];
573
- if (!clip)
574
- return;
575
- this.patchClip(at, { channels: clip.channels.map((channel, i) => (i === j ? next : channel)) });
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.clips),
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
- /** Recipe creates a valid, editable two-pose clip; it never saves or publishes the Figure. */
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
- this.open = this.clips.length;
602
- this.tell([...this.clips, { name: freeRecipeName(this.clips, recipe.name), drive: recipe.drive, channels: [channel] }]);
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
- setDrive(at, drive) {
608
- this.patchClip(at, { drive });
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
- if (this.open >= at)
612
- this.open = Math.max(0, this.open - 1);
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
- addChannel(at) {
616
- const clip = this.clips[at];
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 (!clip || !target)
791
+ if (!target)
619
792
  return;
620
- this.patchClip(at, {
621
- channels: [...clip.channels, { target, path: 'translation', keys: keysFor('translation') }]
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.clips[at]?.channels[j];
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.clips[at]?.channels[j];
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
- const clip = this.clips[at];
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.clips[at]?.channels[j];
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.clips[at]?.channels[j];
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.clips[at]?.channels[j];
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([