@things-factory/figure-ui 10.1.25 → 10.1.27

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 (50) hide show
  1. package/client/graphql/index.ts +4 -1
  2. package/client/modeller/figure-animations.ts +229 -29
  3. package/client/modeller/figure-history-panel.ts +2 -2
  4. package/client/modeller/figure-inspector.ts +151 -5
  5. package/client/modeller/figure-preview.ts +5 -5
  6. package/client/modeller/figure-source.ts +33 -45
  7. package/client/modeller/figure-view.ts +20 -0
  8. package/client/modeller/joint-edits.ts +201 -0
  9. package/client/modeller/proposal-session.ts +8 -2
  10. package/client/pages/figure-modeller-page.ts +50 -15
  11. package/dist-client/graphql/index.js +4 -1
  12. package/dist-client/graphql/index.js.map +1 -1
  13. package/dist-client/modeller/figure-animations.d.ts +19 -0
  14. package/dist-client/modeller/figure-animations.js +206 -22
  15. package/dist-client/modeller/figure-animations.js.map +1 -1
  16. package/dist-client/modeller/figure-history-panel.js +2 -2
  17. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  18. package/dist-client/modeller/figure-inspector.d.ts +11 -0
  19. package/dist-client/modeller/figure-inspector.js +124 -4
  20. package/dist-client/modeller/figure-inspector.js.map +1 -1
  21. package/dist-client/modeller/figure-preview.js +5 -5
  22. package/dist-client/modeller/figure-preview.js.map +1 -1
  23. package/dist-client/modeller/figure-source.d.ts +12 -1
  24. package/dist-client/modeller/figure-source.js +13 -40
  25. package/dist-client/modeller/figure-source.js.map +1 -1
  26. package/dist-client/modeller/figure-view.d.ts +16 -0
  27. package/dist-client/modeller/figure-view.js +15 -0
  28. package/dist-client/modeller/figure-view.js.map +1 -1
  29. package/dist-client/modeller/joint-edits.d.ts +29 -0
  30. package/dist-client/modeller/joint-edits.js +175 -0
  31. package/dist-client/modeller/joint-edits.js.map +1 -0
  32. package/dist-client/modeller/proposal-session.d.ts +5 -0
  33. package/dist-client/modeller/proposal-session.js +3 -2
  34. package/dist-client/modeller/proposal-session.js.map +1 -1
  35. package/dist-client/pages/figure-modeller-page.d.ts +2 -22
  36. package/dist-client/pages/figure-modeller-page.js +46 -15
  37. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  38. package/dist-client/tsconfig.tsbuildinfo +1 -0
  39. package/dist-server/tsconfig.tsbuildinfo +1 -0
  40. package/package.json +3 -3
  41. package/test/ai-proposal-contract.test.ts +3 -2
  42. package/test/figure-source.test.ts +43 -57
  43. package/test/i18n-prefix-guard.test.ts +2 -3
  44. package/test/joint-edits.test.ts +130 -0
  45. package/test/preview-board-depth.test.ts +27 -0
  46. package/translations/en.json +119 -80
  47. package/translations/ja.json +208 -58
  48. package/translations/ko.json +139 -100
  49. package/translations/ms.json +206 -56
  50. package/translations/zh.json +207 -57
@@ -19,6 +19,8 @@ export declare class FigureAnimations extends FigureAnimations_base {
19
19
  private channelsOf;
20
20
  /** 부품 이름 — 채널이 이것을 가리킨다. */
21
21
  private get partNames();
22
+ /** Joints a channel can drive instead of a part (ADR-0066). */
23
+ private get joints();
22
24
  /**
23
25
  * 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
24
26
  *
@@ -69,6 +71,11 @@ export declare class FigureAnimations extends FigureAnimations_base {
69
71
  * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
70
72
  * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
71
73
  */
74
+ /**
75
+ * Keys of a joint channel: a position and one joint coordinate each. A value outside the joint's limits
76
+ * is marked and left as typed; the format refuses it on save.
77
+ */
78
+ private renderJointKeys;
72
79
  private renderKeys;
73
80
  /**
74
81
  * 움직이는 부품 예산.
@@ -102,10 +109,21 @@ export declare class FigureAnimations extends FigureAnimations_base {
102
109
  private renameClip;
103
110
  private renameParameter;
104
111
  private setRange;
112
+ /**
113
+ * Transition time. Clearing the input removes the field, which means instant. 0 is instant too, but
114
+ * a stored 0 and a missing field are different sources, so an empty input is never saved as 0.
115
+ */
116
+ private setDuration;
105
117
  private setDefault;
106
118
  private removeClip;
107
119
  private removeParameter;
108
120
  private addChannel;
121
+ /**
122
+ * Points a channel at another part or joint. Crossing between the two changes the channel's shape: a
123
+ * joint channel has no path and one number per key, a part channel a path and a vector. The key positions
124
+ * are kept, since they are the author's timing; values restart at the rest pose.
125
+ */
126
+ private retarget;
109
127
  private setChannel;
110
128
  /**
111
129
  * 경로를 바꾼다 — **값까지 함께 옮긴다.**
@@ -118,6 +136,7 @@ export declare class FigureAnimations extends FigureAnimations_base {
118
136
  private removeChannel;
119
137
  /** 마지막 키에서 1 초 뒤. 값은 그 키를 그대로 받는다 — 붙여 놓고 고치는 것이 짜기 쉽다. */
120
138
  private addKey;
139
+ private setJointKey;
121
140
  private setKey;
122
141
  /** 둘 미만으로 내려가지 않는다 — 단추가 이미 죽어 있지만 밖에서 불릴 수 있다. */
123
142
  private removeKey;
@@ -5,6 +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
  import { AXES, CHANNEL_PATHS, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model';
8
+ /** A channel that drives a joint names the joint and carries one number per key (ADR-0066). */
9
+ function isJointChannel(channel) {
10
+ return channel.path === undefined;
11
+ }
8
12
  /*
9
13
  레시피의 `end` 가 곧 그 범위의 끝 자세다 — 리프트가 100mm 올라가는 곡선이면 범위도
10
14
  0~100mm 다. 둘이 어긋나면 저작자가 1200 을 주고도 100 만 올라가는 것을 보게 된다.
@@ -291,6 +295,10 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
291
295
  get partNames() {
292
296
  return this.parts.map(part => part.name).filter(name => !!name);
293
297
  }
298
+ /** Joints a channel can drive instead of a part (ADR-0066). */
299
+ get joints() {
300
+ return this.draft?.joints ?? [];
301
+ }
294
302
  /**
295
303
  * 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
296
304
  *
@@ -315,7 +323,6 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
315
323
  return html `
316
324
  <section>
317
325
  <h3>${i18next.t('figure.label.animation')}</h3>
318
- <p quiet>${i18next.t('figure.text.animation-is-driven-by-a-fact')}</p>
319
326
  </section>
320
327
 
321
328
  ${this.renderRecipes()}
@@ -418,6 +425,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
418
425
  const nameless = !parameter.name?.trim();
419
426
  const range = parameter.range ?? NEW_RANGE;
420
427
  const backwards = !(range.max > range.min);
428
+ const slowness = parameter.clip?.duration ?? 0;
421
429
  return html `
422
430
  <div clip ?on=${on}>
423
431
  <div head>
@@ -481,6 +489,21 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
481
489
  />
482
490
  </div>
483
491
 
492
+ <div row>
493
+ <label>${i18next.t('figure.label.parameter-duration')}</label>
494
+ <input
495
+ type="number"
496
+ step="0.1"
497
+ min="0"
498
+ ?bad=${slowness < 0}
499
+ .value=${live(parameter.clip?.duration === undefined ? '' : String(parameter.clip.duration))}
500
+ placeholder="0"
501
+ aria-label="duration"
502
+ @change=${(e) => this.setDuration(at, e.target.value)}
503
+ />
504
+ </div>
505
+ <p quiet>${i18next.t('figure.text.parameter-duration-empty-is-instant')}</p>
506
+
484
507
  ${on ? this.renderChannels('parameter', at) : nothing}
485
508
  </div>
486
509
  `;
@@ -505,23 +528,45 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
505
528
  * 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
506
529
  */
507
530
  renderChannel(kind, channel, at, j) {
508
- const orphan = !this.partNames.includes(channel.target);
531
+ const joint = this.joints.find(one => one.name === channel.target);
532
+ const orphan = !this.partNames.includes(channel.target) && !joint;
533
+ const target = html `
534
+ <div row>
535
+ <label>${i18next.t('figure.label.channel-target')}</label>
536
+ <select
537
+ .value=${live(channel.target ?? '')}
538
+ @change=${(e) => this.retarget(kind, at, j, e.target.value)}
539
+ >
540
+ ${orphan ? html `<option value=${channel.target}>${channel.target}</option>` : nothing}
541
+ <optgroup label=${i18next.t('figure.label.channel-target-parts')}>
542
+ ${this.partNames.map(name => html `<option value=${name}>${name}</option>`)}
543
+ </optgroup>
544
+ ${this.joints.length > 0
545
+ ? html `<optgroup label=${i18next.t('figure.label.channel-target-joints')}>
546
+ ${this.joints.map(one => html `<option value=${one.name}>${one.name}</option>`)}
547
+ </optgroup>`
548
+ : nothing}
549
+ </select>
550
+ <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
551
+ <md-icon>close</md-icon>
552
+ </button>
553
+ </div>
554
+ `;
555
+ /* A joint channel has no path or pivot: the joint says what moves. Each key is the joint coordinate. */
556
+ if (isJointChannel(channel)) {
557
+ const unit = joint?.type === 'prismatic' ? 'mm' : 'deg';
558
+ return html `
559
+ <div channel ?orphan=${orphan}>
560
+ ${target} ${orphan ? html `<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
561
+ <p quiet>${i18next.t('figure.text.joint-channel-value-' + unit)}</p>
562
+ ${this.renderJointKeys(kind, channel, at, j, joint?.limits)}
563
+ </div>
564
+ `;
565
+ }
509
566
  const turning = channel.path === 'rotation';
510
567
  return html `
511
568
  <div channel ?orphan=${orphan}>
512
- <div row>
513
- <label>${i18next.t('figure.label.channel-target')}</label>
514
- <select
515
- .value=${live(channel.target ?? '')}
516
- @change=${(e) => this.setChannel(kind, at, j, { target: e.target.value })}
517
- >
518
- ${orphan ? html `<option value=${channel.target}>${channel.target}</option>` : nothing}
519
- ${this.partNames.map(name => html `<option value=${name}>${name}</option>`)}
520
- </select>
521
- <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
522
- <md-icon>close</md-icon>
523
- </button>
524
- </div>
569
+ ${target}
525
570
 
526
571
  ${orphan ? html `<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
527
572
 
@@ -592,12 +637,79 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
592
637
  * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
593
638
  * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
594
639
  */
640
+ /**
641
+ * Keys of a joint channel: a position and one joint coordinate each. A value outside the joint's limits
642
+ * is marked and left as typed; the format refuses it on save.
643
+ */
644
+ renderJointKeys(kind, channel, at, j, limits) {
645
+ const keys = channel.keys ?? [];
646
+ const onValues = kind === 'parameter';
647
+ const beyond = (key) => !!limits && (key.value < limits.min || key.value > limits.max);
648
+ return html `
649
+ <table keys>
650
+ <tr>
651
+ <th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
652
+ <th>${i18next.t('figure.label.joint-value')}</th>
653
+ <th></th>
654
+ </tr>
655
+ ${keys.map((key, k) => {
656
+ const backwards = k > 0 && key.at <= (keys[k - 1].at ?? 0);
657
+ return html `
658
+ <tr>
659
+ <td>
660
+ <input
661
+ type="number"
662
+ step=${onValues ? '0.05' : '0.1'}
663
+ min="0"
664
+ max=${onValues ? '1' : nothing}
665
+ ?bad=${backwards || key.at < 0 || (onValues && key.at > 1)}
666
+ .value=${live(String(key.at))}
667
+ @change=${(e) => this.setJointKey(kind, at, j, k, { at: Math.max(0, Number(e.target.value) || 0) })}
668
+ />
669
+ </td>
670
+ <td>
671
+ <input
672
+ type="number"
673
+ step="1"
674
+ ?bad=${beyond(key)}
675
+ .value=${live(String(key.value))}
676
+ title=${beyond(key) ? i18next.t('figure.text.joint-value-beyond-limits') : ''}
677
+ @change=${(e) => this.setJointKey(kind, at, j, k, { value: Number(e.target.value) || 0 })}
678
+ />
679
+ </td>
680
+ <td drop>
681
+ <button
682
+ plain
683
+ ?disabled=${keys.length <= 2}
684
+ title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
685
+ @click=${() => this.removeKey(kind, at, j, k)}
686
+ >
687
+ <md-icon>close</md-icon>
688
+ </button>
689
+ </td>
690
+ </tr>
691
+ `;
692
+ })}
693
+ </table>
694
+ ${keys.some(beyond) ? html `<p warn>${i18next.t('figure.text.joint-value-beyond-limits')}</p>` : nothing}
695
+ <button @click=${() => this.addKey(kind, at, j)}>
696
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
697
+ </button>
698
+ `;
699
+ }
595
700
  renderKeys(kind, channel, at, j) {
596
701
  const keys = channel.keys ?? [];
702
+ /*
703
+ Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
704
+ the format refuses it. The screen marks it and leaves the number alone: rewriting what the author
705
+ typed would hide what they wrote.
706
+ */
707
+ const onValues = kind === 'parameter';
708
+ const outside = (key) => onValues && key.at > 1;
597
709
  return html `
598
710
  <table keys>
599
711
  <tr>
600
- <th>${i18next.t('figure.label.key-at')}</th>
712
+ <th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
601
713
  ${AXES.map(axis => html `<th>${axis}</th>`)}
602
714
  <th></th>
603
715
  </tr>
@@ -608,11 +720,16 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
608
720
  <td>
609
721
  <input
610
722
  type="number"
611
- step="0.1"
723
+ step=${onValues ? '0.05' : '0.1'}
612
724
  min="0"
613
- ?bad=${backwards || key.at < 0}
725
+ max=${onValues ? '1' : nothing}
726
+ ?bad=${backwards || key.at < 0 || outside(key)}
614
727
  .value=${live(String(key.at))}
615
- title=${backwards ? i18next.t('figure.text.keys-must-rise') : ''}
728
+ title=${backwards
729
+ ? i18next.t('figure.text.keys-must-rise')
730
+ : outside(key)
731
+ ? i18next.t('figure.text.parameter-key-within-span')
732
+ : ''}
616
733
  @change=${(e) => this.setKey(kind, at, j, k, { at: Math.max(0, Number(e.target.value) || 0) })}
617
734
  />
618
735
  </td>
@@ -647,6 +764,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
647
764
  ${keys.some((key, k) => k > 0 && key.at <= (keys[k - 1].at ?? 0))
648
765
  ? html `<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
649
766
  : nothing}
767
+ ${keys.some(outside) ? html `<p warn>${i18next.t('figure.text.parameter-key-within-span')}</p>` : nothing}
650
768
 
651
769
  <button @click=${() => this.addKey(kind, at, j)}>
652
770
  <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
@@ -775,6 +893,19 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
775
893
  return;
776
894
  this.patchParameter(at, { range: { ...(parameter.range ?? NEW_RANGE), ...patch } });
777
895
  }
896
+ /**
897
+ * Transition time. Clearing the input removes the field, which means instant. 0 is instant too, but
898
+ * a stored 0 and a missing field are different sources, so an empty input is never saved as 0.
899
+ */
900
+ setDuration(at, raw) {
901
+ const parameter = this.parameters[at];
902
+ if (!parameter)
903
+ return;
904
+ const { duration: _dropped, ...rest } = parameter.clip;
905
+ const text = raw.trim();
906
+ const clip = text === '' || !Number.isFinite(Number(text)) ? rest : { ...rest, duration: Number(text) };
907
+ this.patchParameter(at, { clip });
908
+ }
778
909
  setDefault(at, value) {
779
910
  this.patchParameter(at, { default: value });
780
911
  }
@@ -795,10 +926,36 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
795
926
  { target, path: 'translation', keys: keysFor('translation') }
796
927
  ]);
797
928
  }
798
- setChannel(kind, at, j, patch) {
929
+ /**
930
+ * Points a channel at another part or joint. Crossing between the two changes the channel's shape: a
931
+ * joint channel has no path and one number per key, a part channel a path and a vector. The key positions
932
+ * are kept, since they are the author's timing; values restart at the rest pose.
933
+ */
934
+ retarget(kind, at, j, target) {
799
935
  const channel = this.channelsOf(kind, at)[j];
800
936
  if (!channel)
801
937
  return;
938
+ const toJoint = this.joints.some(one => one.name === target);
939
+ if (toJoint && !isJointChannel(channel)) {
940
+ const next = { target, keys: channel.keys.map(key => ({ at: key.at, value: 0 })) };
941
+ if (channel.interpolation !== undefined)
942
+ next.interpolation = channel.interpolation;
943
+ this.patchChannel(kind, at, j, next);
944
+ return;
945
+ }
946
+ if (!toJoint && isJointChannel(channel)) {
947
+ const next = { target, path: 'translation', keys: channel.keys.map(key => ({ at: key.at, value: startValue('translation') })) };
948
+ if (channel.interpolation !== undefined)
949
+ next.interpolation = channel.interpolation;
950
+ this.patchChannel(kind, at, j, next);
951
+ return;
952
+ }
953
+ this.patchChannel(kind, at, j, { ...channel, target });
954
+ }
955
+ setChannel(kind, at, j, patch) {
956
+ const channel = this.channelsOf(kind, at)[j];
957
+ if (!channel || isJointChannel(channel))
958
+ return;
802
959
  const next = { ...channel, ...patch };
803
960
  /* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
804
961
  if ('pivot' in patch && patch.pivot === undefined)
@@ -814,7 +971,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
814
971
  */
815
972
  setPath(kind, at, j, path) {
816
973
  const channel = this.channelsOf(kind, at)[j];
817
- if (!channel)
974
+ if (!channel || isJointChannel(channel))
818
975
  return;
819
976
  const crossing = (channel.path === 'scale') !== (path === 'scale');
820
977
  const next = {
@@ -835,13 +992,40 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
835
992
  const channel = this.channelsOf(kind, at)[j];
836
993
  if (!channel)
837
994
  return;
995
+ if (isJointChannel(channel)) {
996
+ const keys = channel.keys;
997
+ const end = keys[keys.length - 1];
998
+ if (kind === 'parameter' && keys.length >= 2) {
999
+ const before = keys[keys.length - 2];
1000
+ const middle = { at: (before.at + end.at) / 2, value: before.value };
1001
+ this.patchChannel(kind, at, j, { ...channel, keys: [...keys.slice(0, -1), middle, end] });
1002
+ return;
1003
+ }
1004
+ this.patchChannel(kind, at, j, { ...channel, keys: [...keys, { at: (end?.at ?? 0) + 1, value: end?.value ?? 0 }] });
1005
+ return;
1006
+ }
838
1007
  const last = channel.keys[channel.keys.length - 1];
1008
+ /*
1009
+ A parameter curve ends at 1, so appending a key would make it unreachable. Insert it between the last two keys.
1010
+ */
1011
+ if (kind === 'parameter' && channel.keys.length >= 2) {
1012
+ const before = channel.keys[channel.keys.length - 2];
1013
+ const middle = { at: (before.at + last.at) / 2, value: { ...before.value } };
1014
+ this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys.slice(0, -1), middle, last] });
1015
+ return;
1016
+ }
839
1017
  const key = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } };
840
1018
  this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] });
841
1019
  }
1020
+ setJointKey(kind, at, j, k, patch) {
1021
+ const channel = this.channelsOf(kind, at)[j];
1022
+ if (!channel || !isJointChannel(channel))
1023
+ return;
1024
+ this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key)) });
1025
+ }
842
1026
  setKey(kind, at, j, k, patch) {
843
1027
  const channel = this.channelsOf(kind, at)[j];
844
- if (!channel)
1028
+ if (!channel || isJointChannel(channel))
845
1029
  return;
846
1030
  this.patchChannel(kind, at, j, {
847
1031
  ...channel,