@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
@@ -1,4 +1,5 @@
1
1
  import gql from 'graphql-tag'
2
+ import { i18next } from '@operato/i18n'
2
3
  import { client } from '@operato/graphql'
3
4
 
4
5
  import type {
@@ -29,7 +30,9 @@ function unwrap<T>(response: { data?: Record<string, unknown>; errors?: readonly
29
30
  if (said) throw new Error(said)
30
31
 
31
32
  if (!response.data) {
32
- throw new Error(`서버가 ${field} 돌려주지 않았습니다.`)
33
+ /* The field name stays off the screen because the user cannot act on it; it goes to the console. */
34
+ console.error(`[figure-ui] the server returned no data for ${field}`)
35
+ throw new Error(i18next.t('figure.text.server-returned-no-data'))
33
36
  }
34
37
 
35
38
  return response.data[field] as T
@@ -6,7 +6,7 @@ import { i18next, localize } from '@operato/i18n'
6
6
  import { ScrollbarStyles } from '@operato/styles'
7
7
 
8
8
  import { AXES, CHANNEL_PATHS, INTERPOLATIONS, LIMITS } from '@hatiolab/figure-model'
9
- import type { ChannelPath, Vec3 } from '@hatiolab/figure-model'
9
+ import type { ChannelPath, JointChannel, PartChannel, Vec3 } from '@hatiolab/figure-model'
10
10
 
11
11
  import type { FigureDraft, PartModel } from './figure-source.js'
12
12
 
@@ -44,7 +44,13 @@ import type { FigureDraft, PartModel } from './figure-source.js'
44
44
  type Clip = NonNullable<FigureDraft['animations']>[number]
45
45
  type Parameter = NonNullable<FigureDraft['parameters']>[number]
46
46
  type Channel = Clip['channels'][number]
47
- type Keyframe = Channel['keys'][number]
47
+ type Keyframe = PartChannel['keys'][number]
48
+ type JointKey = JointChannel['keys'][number]
49
+
50
+ /** A channel that drives a joint names the joint and carries one number per key (ADR-0066). */
51
+ function isJointChannel(channel: Channel): channel is JointChannel {
52
+ return channel.path === undefined
53
+ }
48
54
 
49
55
  /** 어느 목록의 몇째인가. 채널·키 편집기가 둘을 함께 쓴다. */
50
56
  type MotionKind = 'clip' | 'parameter'
@@ -357,6 +363,11 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
357
363
  return this.parts.map(part => part.name).filter(name => !!name)
358
364
  }
359
365
 
366
+ /** Joints a channel can drive instead of a part (ADR-0066). */
367
+ private get joints(): NonNullable<FigureDraft['joints']> {
368
+ return this.draft?.joints ?? []
369
+ }
370
+
360
371
  /**
361
372
  * 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
362
373
  *
@@ -380,7 +391,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
380
391
  return html`
381
392
  <section>
382
393
  <h3>${i18next.t('figure.label.animation')}</h3>
383
- <p quiet>${i18next.t('figure.text.animation-is-driven-by-a-fact')}</p>
384
394
  </section>
385
395
 
386
396
  ${this.renderRecipes()}
@@ -488,6 +498,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
488
498
  const nameless = !parameter.name?.trim()
489
499
  const range = parameter.range ?? NEW_RANGE
490
500
  const backwards = !(range.max > range.min)
501
+ const slowness = parameter.clip?.duration ?? 0
491
502
 
492
503
  return html`
493
504
  <div clip ?on=${on}>
@@ -552,6 +563,21 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
552
563
  />
553
564
  </div>
554
565
 
566
+ <div row>
567
+ <label>${i18next.t('figure.label.parameter-duration')}</label>
568
+ <input
569
+ type="number"
570
+ step="0.1"
571
+ min="0"
572
+ ?bad=${slowness < 0}
573
+ .value=${live(parameter.clip?.duration === undefined ? '' : String(parameter.clip.duration))}
574
+ placeholder="0"
575
+ aria-label="duration"
576
+ @change=${(e: Event) => this.setDuration(at, (e.target as HTMLInputElement).value)}
577
+ />
578
+ </div>
579
+ <p quiet>${i18next.t('figure.text.parameter-duration-empty-is-instant')}</p>
580
+
555
581
  ${on ? this.renderChannels('parameter', at) : nothing}
556
582
  </div>
557
583
  `
@@ -579,24 +605,48 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
579
605
  * 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
580
606
  */
581
607
  private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
582
- const orphan = !this.partNames.includes(channel.target)
608
+ const joint = this.joints.find(one => one.name === channel.target)
609
+ const orphan = !this.partNames.includes(channel.target) && !joint
610
+ const target = html`
611
+ <div row>
612
+ <label>${i18next.t('figure.label.channel-target')}</label>
613
+ <select
614
+ .value=${live(channel.target ?? '')}
615
+ @change=${(e: Event) => this.retarget(kind, at, j, (e.target as HTMLSelectElement).value)}
616
+ >
617
+ ${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
618
+ <optgroup label=${i18next.t('figure.label.channel-target-parts')}>
619
+ ${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
620
+ </optgroup>
621
+ ${this.joints.length > 0
622
+ ? html`<optgroup label=${i18next.t('figure.label.channel-target-joints')}>
623
+ ${this.joints.map(one => html`<option value=${one.name}>${one.name}</option>`)}
624
+ </optgroup>`
625
+ : nothing}
626
+ </select>
627
+ <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
628
+ <md-icon>close</md-icon>
629
+ </button>
630
+ </div>
631
+ `
632
+
633
+ /* A joint channel has no path or pivot: the joint says what moves. Each key is the joint coordinate. */
634
+ if (isJointChannel(channel)) {
635
+ const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
636
+ return html`
637
+ <div channel ?orphan=${orphan}>
638
+ ${target} ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
639
+ <p quiet>${i18next.t('figure.text.joint-channel-value-' + unit)}</p>
640
+ ${this.renderJointKeys(kind, channel, at, j, joint?.limits)}
641
+ </div>
642
+ `
643
+ }
644
+
583
645
  const turning = channel.path === 'rotation'
584
646
 
585
647
  return html`
586
648
  <div channel ?orphan=${orphan}>
587
- <div row>
588
- <label>${i18next.t('figure.label.channel-target')}</label>
589
- <select
590
- .value=${live(channel.target ?? '')}
591
- @change=${(e: Event) => this.setChannel(kind, at, j, { target: (e.target as HTMLSelectElement).value })}
592
- >
593
- ${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
594
- ${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
595
- </select>
596
- <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
597
- <md-icon>close</md-icon>
598
- </button>
599
- </div>
649
+ ${target}
600
650
 
601
651
  ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
602
652
 
@@ -634,7 +684,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
634
684
  * 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
635
685
  * 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
636
686
  */
637
- private renderPivot(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
687
+ private renderPivot(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
638
688
  const pivot = channel.pivot
639
689
  const on = pivot !== undefined
640
690
 
@@ -676,13 +726,89 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
676
726
  * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
677
727
  * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
678
728
  */
679
- private renderKeys(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
729
+ /**
730
+ * Keys of a joint channel: a position and one joint coordinate each. A value outside the joint's limits
731
+ * is marked and left as typed; the format refuses it on save.
732
+ */
733
+ private renderJointKeys(
734
+ kind: MotionKind,
735
+ channel: JointChannel,
736
+ at: number,
737
+ j: number,
738
+ limits?: { min: number; max: number }
739
+ ): TemplateResult {
740
+ const keys = channel.keys ?? []
741
+ const onValues = kind === 'parameter'
742
+ const beyond = (key: JointKey) => !!limits && (key.value < limits.min || key.value > limits.max)
743
+
744
+ return html`
745
+ <table keys>
746
+ <tr>
747
+ <th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
748
+ <th>${i18next.t('figure.label.joint-value')}</th>
749
+ <th></th>
750
+ </tr>
751
+ ${keys.map((key, k) => {
752
+ const backwards = k > 0 && key.at <= (keys[k - 1]!.at ?? 0)
753
+ return html`
754
+ <tr>
755
+ <td>
756
+ <input
757
+ type="number"
758
+ step=${onValues ? '0.05' : '0.1'}
759
+ min="0"
760
+ max=${onValues ? '1' : nothing}
761
+ ?bad=${backwards || key.at < 0 || (onValues && key.at > 1)}
762
+ .value=${live(String(key.at))}
763
+ @change=${(e: Event) =>
764
+ this.setJointKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
765
+ />
766
+ </td>
767
+ <td>
768
+ <input
769
+ type="number"
770
+ step="1"
771
+ ?bad=${beyond(key)}
772
+ .value=${live(String(key.value))}
773
+ title=${beyond(key) ? i18next.t('figure.text.joint-value-beyond-limits') : ''}
774
+ @change=${(e: Event) => this.setJointKey(kind, at, j, k, { value: Number((e.target as HTMLInputElement).value) || 0 })}
775
+ />
776
+ </td>
777
+ <td drop>
778
+ <button
779
+ plain
780
+ ?disabled=${keys.length <= 2}
781
+ title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
782
+ @click=${() => this.removeKey(kind, at, j, k)}
783
+ >
784
+ <md-icon>close</md-icon>
785
+ </button>
786
+ </td>
787
+ </tr>
788
+ `
789
+ })}
790
+ </table>
791
+ ${keys.some(beyond) ? html`<p warn>${i18next.t('figure.text.joint-value-beyond-limits')}</p>` : nothing}
792
+ <button @click=${() => this.addKey(kind, at, j)}>
793
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
794
+ </button>
795
+ `
796
+ }
797
+
798
+ private renderKeys(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
680
799
  const keys = channel.keys ?? []
800
+ /*
801
+ Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
802
+ the format refuses it. The screen marks it and leaves the number alone: rewriting what the author
803
+ typed would hide what they wrote.
804
+ */
805
+ const onValues = kind === 'parameter'
806
+ const outside = (key: Keyframe) => onValues && key.at > 1
681
807
 
682
808
  return html`
683
809
  <table keys>
684
810
  <tr>
685
- <th>${i18next.t('figure.label.key-at')}</th>
811
+ <th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
686
812
  ${AXES.map(axis => html`<th>${axis}</th>`)}
687
813
  <th></th>
688
814
  </tr>
@@ -693,11 +819,16 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
693
819
  <td>
694
820
  <input
695
821
  type="number"
696
- step="0.1"
822
+ step=${onValues ? '0.05' : '0.1'}
697
823
  min="0"
698
- ?bad=${backwards || key.at < 0}
824
+ max=${onValues ? '1' : nothing}
825
+ ?bad=${backwards || key.at < 0 || outside(key)}
699
826
  .value=${live(String(key.at))}
700
- title=${backwards ? i18next.t('figure.text.keys-must-rise') : ''}
827
+ title=${backwards
828
+ ? i18next.t('figure.text.keys-must-rise')
829
+ : outside(key)
830
+ ? i18next.t('figure.text.parameter-key-within-span')
831
+ : ''}
701
832
  @change=${(e: Event) =>
702
833
  this.setKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
703
834
  />
@@ -736,6 +867,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
736
867
  ${keys.some((key, k) => k > 0 && key.at <= (keys[k - 1]!.at ?? 0))
737
868
  ? html`<p warn>${i18next.t('figure.text.keys-must-rise')}</p>`
738
869
  : nothing}
870
+ ${keys.some(outside) ? html`<p warn>${i18next.t('figure.text.parameter-key-within-span')}</p>` : nothing}
739
871
 
740
872
  <button @click=${() => this.addKey(kind, at, j)}>
741
873
  <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
@@ -883,6 +1015,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
883
1015
  this.patchParameter(at, { range: { ...(parameter.range ?? NEW_RANGE), ...patch } })
884
1016
  }
885
1017
 
1018
+ /**
1019
+ * Transition time. Clearing the input removes the field, which means instant. 0 is instant too, but
1020
+ * a stored 0 and a missing field are different sources, so an empty input is never saved as 0.
1021
+ */
1022
+ private setDuration(at: number, raw: string): void {
1023
+ const parameter = this.parameters[at]
1024
+ if (!parameter) return
1025
+ const { duration: _dropped, ...rest } = parameter.clip
1026
+ const text = raw.trim()
1027
+ const clip = text === '' || !Number.isFinite(Number(text)) ? rest : { ...rest, duration: Number(text) }
1028
+ this.patchParameter(at, { clip })
1029
+ }
1030
+
886
1031
  private setDefault(at: number, value: number): void {
887
1032
  this.patchParameter(at, { default: value })
888
1033
  }
@@ -907,11 +1052,36 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
907
1052
  ])
908
1053
  }
909
1054
 
910
- private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<Channel>): void {
1055
+ /**
1056
+ * Points a channel at another part or joint. Crossing between the two changes the channel's shape: a
1057
+ * joint channel has no path and one number per key, a part channel a path and a vector. The key positions
1058
+ * are kept, since they are the author's timing; values restart at the rest pose.
1059
+ */
1060
+ private retarget(kind: MotionKind, at: number, j: number, target: string): void {
911
1061
  const channel = this.channelsOf(kind, at)[j]
912
1062
  if (!channel) return
1063
+ const toJoint = this.joints.some(one => one.name === target)
1064
+
1065
+ if (toJoint && !isJointChannel(channel)) {
1066
+ const next: JointChannel = { target, keys: channel.keys.map(key => ({ at: key.at, value: 0 })) }
1067
+ if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
1068
+ this.patchChannel(kind, at, j, next)
1069
+ return
1070
+ }
1071
+ if (!toJoint && isJointChannel(channel)) {
1072
+ const next: PartChannel = { target, path: 'translation', keys: channel.keys.map(key => ({ at: key.at, value: startValue('translation') })) }
1073
+ if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
1074
+ this.patchChannel(kind, at, j, next)
1075
+ return
1076
+ }
1077
+ this.patchChannel(kind, at, j, { ...channel, target } as Channel)
1078
+ }
1079
+
1080
+ private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<PartChannel>): void {
1081
+ const channel = this.channelsOf(kind, at)[j]
1082
+ if (!channel || isJointChannel(channel)) return
913
1083
 
914
- const next: Channel = { ...channel, ...patch }
1084
+ const next: PartChannel = { ...channel, ...patch }
915
1085
  /* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
916
1086
  if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
917
1087
  this.patchChannel(kind, at, j, next)
@@ -926,10 +1096,10 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
926
1096
  */
927
1097
  private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
928
1098
  const channel = this.channelsOf(kind, at)[j]
929
- if (!channel) return
1099
+ if (!channel || isJointChannel(channel)) return
930
1100
 
931
1101
  const crossing = (channel.path === 'scale') !== (path === 'scale')
932
- const next: Channel = {
1102
+ const next: PartChannel = {
933
1103
  ...channel,
934
1104
  path,
935
1105
  keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
@@ -953,14 +1123,44 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
953
1123
  const channel = this.channelsOf(kind, at)[j]
954
1124
  if (!channel) return
955
1125
 
1126
+ if (isJointChannel(channel)) {
1127
+ const keys = channel.keys
1128
+ const end = keys[keys.length - 1]
1129
+ if (kind === 'parameter' && keys.length >= 2) {
1130
+ const before = keys[keys.length - 2]!
1131
+ const middle: JointKey = { at: (before.at + end!.at) / 2, value: before.value }
1132
+ this.patchChannel(kind, at, j, { ...channel, keys: [...keys.slice(0, -1), middle, end!] })
1133
+ return
1134
+ }
1135
+ this.patchChannel(kind, at, j, { ...channel, keys: [...keys, { at: (end?.at ?? 0) + 1, value: end?.value ?? 0 }] })
1136
+ return
1137
+ }
1138
+
956
1139
  const last = channel.keys[channel.keys.length - 1]
1140
+
1141
+ /*
1142
+ A parameter curve ends at 1, so appending a key would make it unreachable. Insert it between the last two keys.
1143
+ */
1144
+ if (kind === 'parameter' && channel.keys.length >= 2) {
1145
+ const before = channel.keys[channel.keys.length - 2]!
1146
+ const middle: Keyframe = { at: (before.at + last!.at) / 2, value: { ...before.value } }
1147
+ this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys.slice(0, -1), middle, last!] })
1148
+ return
1149
+ }
1150
+
957
1151
  const key: Keyframe = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } }
958
1152
  this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
959
1153
  }
960
1154
 
1155
+ private setJointKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<JointKey>): void {
1156
+ const channel = this.channelsOf(kind, at)[j]
1157
+ if (!channel || !isJointChannel(channel)) return
1158
+ this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key)) })
1159
+ }
1160
+
961
1161
  private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
962
1162
  const channel = this.channelsOf(kind, at)[j]
963
- if (!channel) return
1163
+ if (!channel || isJointChannel(channel)) return
964
1164
  this.patchChannel(kind, at, j, {
965
1165
  ...channel,
966
1166
  keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
@@ -971,6 +1171,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
971
1171
  private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
972
1172
  const channel = this.channelsOf(kind, at)[j]
973
1173
  if (!channel || channel.keys.length <= 2) return
974
- this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) })
1174
+ this.patchChannel(kind, at, j, { ...channel, keys: (channel.keys as { at: number }[]).filter((_, i) => i !== k) } as Channel)
975
1175
  }
976
1176
  }
@@ -438,11 +438,11 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
438
438
  ${(finding.fixes ?? []).map(fix => html`
439
439
  ${fix.safe ? html`
440
440
  <button apply-fix @click=${() => this.applyFix(fix)}>
441
- 안전하게 수정 적용 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} ${fix.to}
441
+ ${i18next.t('figure.button.apply-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
442
442
  </button>
443
443
  ` : nothing}
444
444
  <button preview-fix @click=${() => this.previewFix(fix)}>
445
- 수정안 미리보기 · ${fix.part}.anchor.${fix.axis}: ${fix.from ?? 'auto'} ${fix.to}
445
+ ${i18next.t('figure.button.preview-fix', { part: fix.part, axis: fix.axis.toUpperCase(), from: fix.from ?? 'auto', to: fix.to })}
446
446
  </button>
447
447
  `)}
448
448
  </li>
@@ -16,13 +16,38 @@ import {
16
16
  FIGURE_PLACEMENTS,
17
17
  trianglesOf
18
18
  } from '@hatiolab/figure-model'
19
- import { ANCHOR_RULES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
20
- import type { AnchorRule, Axis, FigurePart, FigurePlacement, MaterialPreset, PartRole, PrimitiveKind } from '@hatiolab/figure-model'
19
+ import { ANCHOR_RULES, JOINT_TYPES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
20
+ import type {
21
+ AnchorRule,
22
+ Axis,
23
+ FigurePart,
24
+ FigurePlacement,
25
+ JointType,
26
+ MaterialPreset,
27
+ PartRole,
28
+ PrimitiveKind,
29
+ Vec3
30
+ } from '@hatiolab/figure-model'
21
31
  import { activePalette } from '@hatiolab/things-scene'
22
32
 
23
33
  import { partToFigure, partFromFigure } from './figure-source.js'
24
34
  import type { FigureDraft, PartModel } from './figure-source.js'
25
35
  import { canMirror } from './part-edits.js'
36
+ import { jointOf, parentChoices, patchJoint, setJointType } from './joint-edits.js'
37
+
38
+ /** Axis choices for a joint. Written as a direction the author reads, stored as a vector. */
39
+ const JOINT_AXES: readonly { key: string; axis: Vec3 }[] = [
40
+ { key: '+x', axis: { x: 1, y: 0, z: 0 } },
41
+ { key: '-x', axis: { x: -1, y: 0, z: 0 } },
42
+ { key: '+y', axis: { x: 0, y: 1, z: 0 } },
43
+ { key: '-y', axis: { x: 0, y: -1, z: 0 } },
44
+ { key: '+z', axis: { x: 0, y: 0, z: 1 } },
45
+ { key: '-z', axis: { x: 0, y: 0, z: -1 } }
46
+ ]
47
+
48
+ function axisKeyOf(axis: Vec3): string | undefined {
49
+ return JOINT_AXES.find(one => one.axis.x === axis.x && one.axis.y === axis.y && one.axis.z === axis.z)?.key
50
+ }
26
51
 
27
52
  /**
28
53
  * 고른 부품의 속성.
@@ -820,7 +845,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
820
845
  묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
821
846
  */
822
847
  return html`
823
- ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
848
+ ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)} ${this.renderJoint(shown)}
824
849
  ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
825
850
  ${this.renderBehaviour(shown)}
826
851
  `
@@ -884,6 +909,121 @@ export class FigureInspector extends localize(i18next)(LitElement) {
884
909
  `
885
910
  }
886
911
 
912
+ /**
913
+ * How this part hangs from another and moves relative to it (ADR-0066).
914
+ *
915
+ * The parent is written on the part. The joint says how the part moves relative to that parent, and the
916
+ * parameter it comes with is what drives it; the preview shows that parameter as a slider.
917
+ */
918
+ private renderJoint(part: FigurePart) {
919
+ const joint = jointOf(this.draft, part.name)
920
+ const choices = parentChoices(this.parts, part.name)
921
+ const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
922
+ const axisKey = joint ? axisKeyOf(joint.axis) : undefined
923
+ const driver = joint
924
+ ? this.draft?.parameters?.find(one => one.clip?.channels?.some(channel => channel.target === joint.name))
925
+ : undefined
926
+
927
+ return html`
928
+ <section>
929
+ <h3>${i18next.t('figure.label.joint')}${this.help('figure.text.joint-explained')}</h3>
930
+
931
+ <div inline-field>
932
+ <label>${i18next.t('figure.label.joint-parent')}</label>
933
+ <select @change=${(e: Event) => this.setParent((e.target as HTMLSelectElement).value)}>
934
+ <option value="" ?selected=${part.parent === undefined}>${i18next.t('figure.label.joint-parent-none')}</option>
935
+ ${part.parent !== undefined && !choices.includes(part.parent)
936
+ ? html`<option value=${part.parent} selected>${part.parent}</option>`
937
+ : nothing}
938
+ ${choices.map(name => html`<option value=${name} ?selected=${name === part.parent}>${name}</option>`)}
939
+ </select>
940
+ </div>
941
+
942
+ <div inline-field>
943
+ <label>${i18next.t('figure.label.joint-type')}</label>
944
+ <select @change=${(e: Event) => this.setJointType(part, (e.target as HTMLSelectElement).value)}>
945
+ <option value="" ?selected=${!joint}>${i18next.t('figure.label.joint-type-none')}</option>
946
+ ${(JOINT_TYPES as readonly JointType[]).map(
947
+ type => html`<option value=${type} ?selected=${joint?.type === type}>${i18next.t('figure.label.joint-type-' + type)}</option>`
948
+ )}
949
+ </select>
950
+ </div>
951
+
952
+ ${joint
953
+ ? html`
954
+ <div inline-field>
955
+ <label>${i18next.t('figure.label.joint-axis')}</label>
956
+ <select @change=${(e: Event) => this.setJointAxis(joint.name, (e.target as HTMLSelectElement).value)}>
957
+ ${axisKey === undefined
958
+ ? html`<option value="" selected>${joint.axis.x}, ${joint.axis.y}, ${joint.axis.z}</option>`
959
+ : nothing}
960
+ ${JOINT_AXES.map(one => html`<option value=${one.key} ?selected=${one.key === axisKey}>${one.key.toUpperCase()}</option>`)}
961
+ </select>
962
+ </div>
963
+
964
+ <div vector-row>
965
+ <label>${i18next.t('figure.label.joint-origin')}</label>
966
+ <div triple>
967
+ ${(['x', 'y', 'z'] as const).map(axis =>
968
+ this.number(axis.toUpperCase(), joint.origin[axis], value =>
969
+ this.tellDraft(patchJoint(this.draft!, joint.name, { origin: { ...joint.origin, [axis]: value } }))
970
+ )
971
+ )}
972
+ </div>
973
+ </div>
974
+
975
+ ${joint.limits
976
+ ? html`
977
+ <div vector-row>
978
+ <label>${i18next.t('figure.label.joint-limits-' + unit)}</label>
979
+ <div triple>
980
+ ${this.number(i18next.t('figure.label.joint-limit-min'), joint.limits.min, value =>
981
+ this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, min: value } }))
982
+ )}
983
+ ${this.number(i18next.t('figure.label.joint-limit-max'), joint.limits.max, value =>
984
+ this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, max: value } }))
985
+ )}
986
+ </div>
987
+ </div>
988
+ ${joint.limits.min > 0 || joint.limits.max < 0
989
+ ? html`<p warn>${i18next.t('figure.text.joint-limits-must-include-zero')}</p>`
990
+ : nothing}
991
+ `
992
+ : nothing}
993
+
994
+ <p quiet>
995
+ ${driver
996
+ ? i18next.t('figure.text.joint-driven-by', { name: driver.label ?? driver.name })
997
+ : i18next.t('figure.text.joint-not-driven')}
998
+ </p>
999
+ `
1000
+ : nothing}
1001
+ </section>
1002
+ `
1003
+ }
1004
+
1005
+ private setParent(name: string) {
1006
+ this.change(part => {
1007
+ const { parent: _dropped, ...rest } = part
1008
+ return name === '' ? (rest as FigurePart) : { ...rest, parent: name }
1009
+ })
1010
+ }
1011
+
1012
+ private setJointType(part: FigurePart, value: string) {
1013
+ if (!this.draft) return
1014
+ this.tellDraft(setJointType(this.draft, part, value === '' ? undefined : (value as JointType)))
1015
+ }
1016
+
1017
+ private setJointAxis(name: string, key: string) {
1018
+ const choice = JOINT_AXES.find(one => one.key === key)
1019
+ if (!choice || !this.draft) return
1020
+ this.tellDraft(patchJoint(this.draft, name, { axis: { ...choice.axis } }))
1021
+ }
1022
+
1023
+ private tellDraft(draft: FigureDraft) {
1024
+ this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
1025
+ }
1026
+
887
1027
  /**
888
1028
  * 역할 하나를 켜고 끈다.
889
1029
  *
@@ -1704,8 +1844,14 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1704
1844
  }
1705
1845
 
1706
1846
  private renderAnchors(part: FigurePart) {
1847
+ /*
1848
+ The scene draft stores the box height as depth, so format y is draft.depth and z is draft.height.
1849
+ This used to pass them swapped, and every automatic anchor still came out the same: the
1850
+ nearest-face rule reads the sign of a centred position, which does not depend on the box size.
1851
+ It is fixed so the next rule that does read the box does not inherit the mistake.
1852
+ */
1707
1853
  const base = this.draft
1708
- ? { x: this.draft.width, y: this.draft.height, z: this.draft.depth }
1854
+ ? { x: this.draft.width, y: this.draft.depth, z: this.draft.height }
1709
1855
  : { x: 0, y: 0, z: 0 }
1710
1856
  const nameOf = (rule: string, axis: Axis) =>
1711
1857
  `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
@@ -1748,7 +1894,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1748
1894
  */
1749
1895
  return html`
1750
1896
  <div anchor-row>
1751
- <label>${axis.toUpperCase()}축</label>
1897
+ <label>${i18next.t('figure.label.axis-name', { axis: axis.toUpperCase() })}</label>
1752
1898
  <div anchor-options>
1753
1899
  ${(['auto', 'scale', 'min', 'center', 'max', 'span'] as const).map(rule => {
1754
1900
  const selected = rule === 'auto' ? !said : rule === said
@@ -6,7 +6,7 @@ import { compile, validate } from '@hatiolab/figure-model'
6
6
  import type { FigureSource } from '@hatiolab/figure-model'
7
7
  import { create, registerFigure } from '@hatiolab/things-scene'
8
8
 
9
- import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js'
9
+ import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH } from './figure-view.js'
10
10
  import type { ViewSettings } from './figure-view.js'
11
11
 
12
12
  /**
@@ -47,7 +47,7 @@ export const PREVIEW_FACTOR = 2
47
47
  * 안 담겨서 **네 칸씩 두 줄**로 놓는다.
48
48
  */
49
49
  export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
50
- { name: '기준', axes: [] },
50
+ { name: 'base', axes: [] },
51
51
  { name: 'x', axes: ['x'] },
52
52
  { name: 'y', axes: ['y'] },
53
53
  { name: 'z', axes: ['z'] },
@@ -208,7 +208,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
208
208
  ${this.cannot
209
209
  ? html`<div cannot>${this.cannot}</div>`
210
210
  : html`<div legend>
211
- ${PREVIEW_WAYS.map(way => html`<span>${way.name}</span>`)}
211
+ ${PREVIEW_WAYS.map(way => html`<span>${way.axes.length === 0 ? i18next.t('figure.label.preview-base') : way.name}</span>`)}
212
212
  </div>
213
213
  ${clips.length + parameters.length > 0 ? this.renderDrivers(clips, parameters) : ''}`}
214
214
  `
@@ -253,7 +253,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
253
253
 
254
254
  return html`
255
255
  <label>
256
- <span>${clip.name} <span drive>배속</span></span>
256
+ <span>${clip.name} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
257
257
  <input
258
258
  type="range"
259
259
  min="0"
@@ -435,7 +435,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
435
435
  어느 경우에도 담기는 경계만 잡는다. 바닥 기반은 가장 큰 것의 키까지, 천정 기반은
436
436
  천장까지다. 배치를 높이로 옮기는 계산을 여기서 다시 쓰면 그것이 두 벌째가 된다.
437
437
  */
438
- depth: Math.round(Math.max(view.ceilingHeight, base.y * biggest) * 1.1)
438
+ depth: previewBoardDepth(source, view.ceilingHeight, biggest)
439
439
  }
440
440
 
441
441
  this.scene = create({