@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.
Files changed (78) hide show
  1. package/client/modeller/figure-animations.ts +331 -124
  2. package/client/modeller/figure-canvas.ts +32 -48
  3. package/client/modeller/figure-capabilities.ts +10 -10
  4. package/client/modeller/figure-inspector.ts +39 -28
  5. package/client/modeller/figure-parts.ts +1 -1
  6. package/client/modeller/figure-preview.ts +151 -41
  7. package/client/modeller/figure-report.ts +24 -6
  8. package/client/modeller/figure-settings.ts +45 -2
  9. package/client/modeller/figure-side.ts +6 -6
  10. package/client/modeller/figure-source.ts +62 -32
  11. package/client/modeller/figure-thumbnail.ts +5 -5
  12. package/client/modeller/figure-view.ts +53 -0
  13. package/client/modeller/part-edits.ts +36 -36
  14. package/client/modeller/proposal.ts +27 -4
  15. package/client/pages/figure-modeller-page.ts +65 -58
  16. package/dist-client/modeller/figure-animations.d.ts +37 -8
  17. package/dist-client/modeller/figure-animations.js +293 -122
  18. package/dist-client/modeller/figure-animations.js.map +1 -1
  19. package/dist-client/modeller/figure-canvas.d.ts +7 -7
  20. package/dist-client/modeller/figure-canvas.js +27 -40
  21. package/dist-client/modeller/figure-canvas.js.map +1 -1
  22. package/dist-client/modeller/figure-capabilities.d.ts +2 -2
  23. package/dist-client/modeller/figure-capabilities.js +9 -9
  24. package/dist-client/modeller/figure-capabilities.js.map +1 -1
  25. package/dist-client/modeller/figure-inspector.d.ts +16 -5
  26. package/dist-client/modeller/figure-inspector.js +38 -27
  27. package/dist-client/modeller/figure-inspector.js.map +1 -1
  28. package/dist-client/modeller/figure-parts.d.ts +1 -1
  29. package/dist-client/modeller/figure-parts.js +1 -1
  30. package/dist-client/modeller/figure-parts.js.map +1 -1
  31. package/dist-client/modeller/figure-preview.d.ts +56 -6
  32. package/dist-client/modeller/figure-preview.js +132 -39
  33. package/dist-client/modeller/figure-preview.js.map +1 -1
  34. package/dist-client/modeller/figure-report.d.ts +9 -0
  35. package/dist-client/modeller/figure-report.js +23 -5
  36. package/dist-client/modeller/figure-report.js.map +1 -1
  37. package/dist-client/modeller/figure-settings.js +42 -2
  38. package/dist-client/modeller/figure-settings.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +2 -2
  40. package/dist-client/modeller/figure-side.js +5 -5
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +25 -15
  43. package/dist-client/modeller/figure-source.js +42 -21
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-thumbnail.d.ts +2 -2
  46. package/dist-client/modeller/figure-thumbnail.js +3 -3
  47. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  48. package/dist-client/modeller/figure-view.d.ts +45 -0
  49. package/dist-client/modeller/figure-view.js +27 -0
  50. package/dist-client/modeller/figure-view.js.map +1 -1
  51. package/dist-client/modeller/part-edits.d.ts +16 -16
  52. package/dist-client/modeller/part-edits.js +35 -35
  53. package/dist-client/modeller/part-edits.js.map +1 -1
  54. package/dist-client/modeller/proposal.d.ts +1 -1
  55. package/dist-client/modeller/proposal.js +16 -3
  56. package/dist-client/modeller/proposal.js.map +1 -1
  57. package/dist-client/pages/figure-modeller-page.d.ts +9 -10
  58. package/dist-client/pages/figure-modeller-page.js +61 -54
  59. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  60. package/dist-client/route.d.ts +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-server/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +5 -5
  64. package/test/ai-proposal-contract.test.ts +433 -12
  65. package/test/expect.ts +55 -0
  66. package/test/figure-source.test.ts +572 -0
  67. package/test/i18n-prefix-guard.test.ts +9 -12
  68. package/{client/modeller → test}/part-edits.test.ts +50 -45
  69. package/translations/en.json +46 -35
  70. package/translations/ja.json +16 -21
  71. package/translations/ko.json +56 -45
  72. package/translations/ms.json +16 -21
  73. package/translations/zh.json +16 -21
  74. package/client/modeller/figure-ask.ts +0 -265
  75. package/client/modeller/figure-source.test.ts +0 -352
  76. package/dist-client/modeller/figure-ask.d.ts +0 -40
  77. package/dist-client/modeller/figure-ask.js +0 -282
  78. 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, CLIP_DRIVES, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model'
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 { BoardModel, PartModel } from './figure-source.js'
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% 열려 있다」**다. 그래서 clip 마다 그 값을
34
- * 어떻게 받을지(`drive`)를 저작 시점에 정한다 — 도는 롤러는 배속을 받고, 열린 문은 0~1 위치를
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<BoardModel['animations']>[number]
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
- /** A first-success motion: intent first, raw clip/channel editing second. */
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
- drive: Clip['drive']
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', drive: 'loop', path: 'rotation', end: { x: 360, y: 0, z: 0 } },
54
- { id: 'gate', name: 'gate-open', drive: 'hold', path: 'rotation', end: { x: 0, y: 90, z: 0 }, pivot: { x: 0, y: 0, z: 0 } },
55
- { id: 'pusher', name: 'pusher-position', drive: 'hold', path: 'translation', end: { x: 100, y: 0, z: 0 } },
56
- { id: 'lift', name: 'lift-height', drive: 'hold', path: 'translation', end: { x: 0, y: 100, z: 0 } }
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
- /** 이미 쓴 이름을 비켜 간다. `type` 처럼 저장되는 식별자는 아니지만 인스턴스가 이 이름으로 구동한다. */
75
- function freeName(taken: readonly Clip[]): string {
76
- const used = new Set(taken.map(clip => clip.name))
77
- for (let n = 1; ; n++) {
78
- const name = `clip-${n}`
79
- if (!used.has(name)) return name
80
- }
81
- }
82
-
83
- function freeRecipeName(taken: readonly Clip[], base: string): string {
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 }) board?: BoardModel
327
+ @property({ type: Object }) draft?: FigureDraft
290
328
  @property({ type: Array }) parts: PartModel[] = []
291
329
 
292
- /** 펼쳐 둔 clip. 이름이 아니라 자리로 잡는다 — 이름은 편집 중에 바뀐다. */
293
- @state() private open = 0
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.board?.animations ?? []
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.board) return html``
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
- <button @click=${() => this.addClip()} ?disabled=${this.partNames.length === 0}>
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
- ${this.partNames.length === 0
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.clips.length === 0 ? nothing : this.renderBudget()}
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
- private renderClips(): TemplateResult {
371
- return html`
372
- <section>
373
- ${this.clips.map((clip, at) => this.renderClip(clip, at))}
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 on = this.open === at
380
- const duplicate = this.clips.some((other, i) => i !== at && other.name === clip.name)
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 ? -1 : at)} title=${i18next.t('figure.label.channels')}>
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(clip: Clip, at: number): TemplateResult {
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.drive-' + (clip.drive ?? 'loop') + '-note')}</p>
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
- ${clip.channels.length === 0 ? html`<p warn>${i18next.t('figure.text.clip-needs-a-channel')}</p>` : nothing}
423
- ${clip.channels.map((channel, j) => this.renderChannel(channel, at, j))}
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-board` 로
775
+ * `animations` 는 도형 전체의 것이므로 부품 편집(`update-part`)이 아니라 `update-draft` 로
632
776
  * 간다. 빈 배열은 칸을 지운다 — clip 을 다 지운 도형은 `animations: []` 가 아니라 없는 것이다.
633
777
  */
634
- private tell(animations: Clip[]): void {
635
- if (!this.board) return
778
+ private tell(over: { animations?: Clip[]; parameters?: Parameter[] }): void {
779
+ if (!this.draft) return
636
780
 
637
- const board: BoardModel = { ...this.board }
638
- if (animations.length === 0) delete board.animations
639
- else board.animations = animations
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-board', { detail: { board }, bubbles: true, composed: true }))
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
- const clip = this.clips[at]
650
- if (!clip) return
651
- this.patchClip(at, { channels: clip.channels.map((channel, i) => (i === j ? next : channel)) })
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.clips),
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
- /** Recipe creates a valid, editable two-pose clip; it never saves or publishes the Figure. */
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
- this.open = this.clips.length
679
- this.tell([...this.clips, { name: freeRecipeName(this.clips, recipe.name), drive: recipe.drive, channels: [channel] }])
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 setDrive(at: number, drive: Clip['drive']): void {
687
- this.patchClip(at, { drive })
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
- if (this.open >= at) this.open = Math.max(0, this.open - 1)
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 (!clip || !target) return
902
+ if (!target) return
699
903
 
700
- this.patchClip(at, {
701
- channels: [...clip.channels, { target, path: 'translation', keys: keysFor('translation') }]
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.clips[at]?.channels[j]
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.clips[at]?.channels[j]
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
- const clip = this.clips[at]
740
- if (!clip) return
741
- this.patchClip(at, { channels: clip.channels.filter((_, i) => i !== j) })
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.clips[at]?.channels[j]
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.clips[at]?.channels[j]
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.clips[at]?.channels[j]
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
  }