@things-factory/figure-ui 10.1.26 → 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 (34) hide show
  1. package/client/modeller/figure-animations.ts +172 -24
  2. package/client/modeller/figure-inspector.ts +143 -3
  3. package/client/modeller/figure-preview.ts +2 -2
  4. package/client/modeller/figure-source.ts +16 -4
  5. package/client/modeller/figure-view.ts +20 -0
  6. package/client/modeller/joint-edits.ts +201 -0
  7. package/dist-client/modeller/figure-animations.d.ts +14 -0
  8. package/dist-client/modeller/figure-animations.js +151 -17
  9. package/dist-client/modeller/figure-animations.js.map +1 -1
  10. package/dist-client/modeller/figure-inspector.d.ts +11 -0
  11. package/dist-client/modeller/figure-inspector.js +116 -2
  12. package/dist-client/modeller/figure-inspector.js.map +1 -1
  13. package/dist-client/modeller/figure-preview.js +2 -2
  14. package/dist-client/modeller/figure-preview.js.map +1 -1
  15. package/dist-client/modeller/figure-source.d.ts +8 -0
  16. package/dist-client/modeller/figure-source.js +8 -4
  17. package/dist-client/modeller/figure-source.js.map +1 -1
  18. package/dist-client/modeller/figure-view.d.ts +16 -0
  19. package/dist-client/modeller/figure-view.js +15 -0
  20. package/dist-client/modeller/figure-view.js.map +1 -1
  21. package/dist-client/modeller/joint-edits.d.ts +29 -0
  22. package/dist-client/modeller/joint-edits.js +175 -0
  23. package/dist-client/modeller/joint-edits.js.map +1 -0
  24. package/dist-client/tsconfig.tsbuildinfo +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/test/figure-source.test.ts +32 -0
  28. package/test/joint-edits.test.ts +130 -0
  29. package/test/preview-board-depth.test.ts +27 -0
  30. package/translations/en.json +24 -0
  31. package/translations/ja.json +24 -0
  32. package/translations/ko.json +24 -0
  33. package/translations/ms.json +24 -0
  34. package/translations/zh.json +24 -0
@@ -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
  *
@@ -594,24 +605,48 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
594
605
  * 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
595
606
  */
596
607
  private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
597
- 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
+
598
645
  const turning = channel.path === 'rotation'
599
646
 
600
647
  return html`
601
648
  <div channel ?orphan=${orphan}>
602
- <div row>
603
- <label>${i18next.t('figure.label.channel-target')}</label>
604
- <select
605
- .value=${live(channel.target ?? '')}
606
- @change=${(e: Event) => this.setChannel(kind, at, j, { target: (e.target as HTMLSelectElement).value })}
607
- >
608
- ${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
609
- ${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
610
- </select>
611
- <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
612
- <md-icon>close</md-icon>
613
- </button>
614
- </div>
649
+ ${target}
615
650
 
616
651
  ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
617
652
 
@@ -649,7 +684,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
649
684
  * 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
650
685
  * 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
651
686
  */
652
- private renderPivot(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
687
+ private renderPivot(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
653
688
  const pivot = channel.pivot
654
689
  const on = pivot !== undefined
655
690
 
@@ -691,7 +726,76 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
691
726
  * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
692
727
  * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
693
728
  */
694
- 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 {
695
799
  const keys = channel.keys ?? []
696
800
  /*
697
801
  Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
@@ -948,11 +1052,36 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
948
1052
  ])
949
1053
  }
950
1054
 
951
- 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 {
952
1061
  const channel = this.channelsOf(kind, at)[j]
953
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
+ }
954
1079
 
955
- const next: Channel = { ...channel, ...patch }
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
1083
+
1084
+ const next: PartChannel = { ...channel, ...patch }
956
1085
  /* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
957
1086
  if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
958
1087
  this.patchChannel(kind, at, j, next)
@@ -967,10 +1096,10 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
967
1096
  */
968
1097
  private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
969
1098
  const channel = this.channelsOf(kind, at)[j]
970
- if (!channel) return
1099
+ if (!channel || isJointChannel(channel)) return
971
1100
 
972
1101
  const crossing = (channel.path === 'scale') !== (path === 'scale')
973
- const next: Channel = {
1102
+ const next: PartChannel = {
974
1103
  ...channel,
975
1104
  path,
976
1105
  keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
@@ -994,6 +1123,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
994
1123
  const channel = this.channelsOf(kind, at)[j]
995
1124
  if (!channel) return
996
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
+
997
1139
  const last = channel.keys[channel.keys.length - 1]
998
1140
 
999
1141
  /*
@@ -1010,9 +1152,15 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
1010
1152
  this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
1011
1153
  }
1012
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
+
1013
1161
  private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
1014
1162
  const channel = this.channelsOf(kind, at)[j]
1015
- if (!channel) return
1163
+ if (!channel || isJointChannel(channel)) return
1016
1164
  this.patchChannel(kind, at, j, {
1017
1165
  ...channel,
1018
1166
  keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
@@ -1023,6 +1171,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
1023
1171
  private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
1024
1172
  const channel = this.channelsOf(kind, at)[j]
1025
1173
  if (!channel || channel.keys.length <= 2) return
1026
- 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)
1027
1175
  }
1028
1176
  }
@@ -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
  *
@@ -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
  /**
@@ -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({
@@ -128,6 +128,12 @@ export interface FigureDraft {
128
128
  * 주나**를 말하는 다른 칸이다.
129
129
  */
130
130
  capabilities?: FigureSource['capabilities']
131
+ /**
132
+ * How parts move relative to their parents (ADR-0066). Kept in figure coordinates as stored, the same
133
+ * terms the inspector shows. Carried even when nothing edits them, so opening and saving never drops
134
+ * them.
135
+ */
136
+ joints?: FigureSource['joints']
131
137
  }
132
138
 
133
139
  /** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
@@ -202,6 +208,8 @@ export interface PartModel {
202
208
  * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
203
209
  */
204
210
  capability?: FigurePart['capability']
211
+ /** The part this one is attached to (ADR-0066). The one place a parent is written. */
212
+ parent?: FigurePart['parent']
205
213
  }
206
214
 
207
215
  const RAD_TO_DEG = 180 / Math.PI
@@ -293,7 +301,8 @@ export function toFigureSource(draft: FigureDraft, parts: PartModel[]): FigureSo
293
301
  parts: parts.map(part => partTo(part, half)),
294
302
  animations: draft.animations,
295
303
  parameters: draft.parameters,
296
- capabilities: draft.capabilities
304
+ capabilities: draft.capabilities,
305
+ joints: draft.joints?.length ? draft.joints : undefined
297
306
  }) as FigureSource
298
307
  }
299
308
 
@@ -342,7 +351,8 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
342
351
  anchor: part.anchor,
343
352
  repeat: part.repeat,
344
353
  label: part.label,
345
- capability: part.capability
354
+ capability: part.capability,
355
+ parent: part.parent
346
356
  }) as FigurePart
347
357
  }
348
358
 
@@ -362,7 +372,8 @@ export function fromFigureSource(source: FigureSource): { draft: FigureDraft; pa
362
372
  /* Opened as stored. A legacy `drive: 'hold'` clip is refused by the format now; stored ones were migrated. */
363
373
  animations: source.animations?.length ? source.animations : undefined,
364
374
  parameters: source.parameters?.length ? source.parameters : undefined,
365
- capabilities: source.capabilities
375
+ capabilities: source.capabilities,
376
+ joints: source.joints?.length ? source.joints : undefined
366
377
  }) as FigureDraft
367
378
 
368
379
  return { draft, parts: source.parts.map(part => partFrom(part, half)) }
@@ -412,6 +423,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
412
423
  anchor: part.anchor,
413
424
  repeat: part.repeat,
414
425
  label: part.label,
415
- capability: part.capability
426
+ capability: part.capability,
427
+ parent: part.parent
416
428
  }) as PartModel
417
429
  }
@@ -130,3 +130,23 @@ export const DEFAULT_VIEW: ViewSettings = {
130
130
  dirShadowEnabled: true,
131
131
  hemiIntensity: 4.0
132
132
  }
133
+
134
+ /**
135
+ * How tall the preview board is: enough room for the tallest instance where the figure actually stands.
136
+ *
137
+ * A floor or center figure stands on the floor, so the tallest instance is the whole height. Only a
138
+ * ceiling figure needs the room up to the ceiling. The board used to reach the ceiling for every figure,
139
+ * which made a 920 tall AGV preview 4400 tall.
140
+ *
141
+ * This is an envelope, not a placement rule: where each instance goes is still the scene's
142
+ * (`FigureRealObject.effectiveZPos`), and the camera fits what the scene drew.
143
+ */
144
+ export function previewBoardDepth(
145
+ figure: { placement?: 'floor' | 'ceiling' | 'center'; base: { y: number } },
146
+ ceilingHeight: number,
147
+ factor: number
148
+ ): number {
149
+ const tallest = figure.base.y * factor
150
+ const reach = figure.placement === 'ceiling' ? Math.max(ceilingHeight, tallest) : tallest
151
+ return Math.round(reach * 1.1)
152
+ }