@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
@@ -0,0 +1,201 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ import type { FigureJoint, FigurePart, JointType, Vec3 } from '@hatiolab/figure-model'
6
+
7
+ import type { FigureDraft, PartModel } from './figure-source.js'
8
+
9
+ /**
10
+ * Editing joints and parents (ADR-0066). Pure functions over the draft and the part list, so the inspector
11
+ * only renders and dispatches.
12
+ *
13
+ * A joint is always created together with the parameter that drives it. Values reach a joint only through
14
+ * `parameters`, and the preview draws a slider per parameter, so a joint without one could be defined but
15
+ * never seen moving.
16
+ */
17
+
18
+ type Parameter = NonNullable<FigureDraft['parameters']>[number]
19
+
20
+ /** The limits a new joint starts with. Revolute in degrees, prismatic in millimetres. */
21
+ const NEW_LIMITS: Record<Exclude<JointType, 'continuous'>, { min: number; max: number }> = {
22
+ revolute: { min: -90, max: 90 },
23
+ prismatic: { min: 0, max: 500 }
24
+ }
25
+
26
+ /** The span a parameter driving a continuous joint offers: one turn either way. */
27
+ const CONTINUOUS_SPAN = { min: -360, max: 360 }
28
+
29
+ /** The joint whose child is this part, if any. */
30
+ export function jointOf(draft: FigureDraft | undefined, part: string): FigureJoint | undefined {
31
+ return draft?.joints?.find(joint => joint.child === part)
32
+ }
33
+
34
+ /**
35
+ * Parts a part may be attached to: every other part except itself and anything attached below it, since
36
+ * either would close a loop the format refuses (`parent-cycle`).
37
+ */
38
+ export function parentChoices(parts: readonly PartModel[], part: string): string[] {
39
+ const below = new Set<string>([part])
40
+ let grew = true
41
+ while (grew) {
42
+ grew = false
43
+ for (const one of parts) {
44
+ if (one.parent !== undefined && below.has(one.parent) && !below.has(one.name)) {
45
+ below.add(one.name)
46
+ grew = true
47
+ }
48
+ }
49
+ }
50
+ return parts.map(one => one.name).filter(name => !!name && !below.has(name))
51
+ }
52
+
53
+ function freeName(taken: Iterable<string>, stem: string): string {
54
+ const used = new Set(taken)
55
+ if (!used.has(stem)) return stem
56
+ for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
57
+ }
58
+
59
+ /** The span the driving parameter offers for a joint: its limits, or one turn either way when it has none. */
60
+ function spanOf(joint: FigureJoint): { min: number; max: number } {
61
+ return joint.limits ?? CONTINUOUS_SPAN
62
+ }
63
+
64
+ function drivingParameter(joint: FigureJoint): Parameter {
65
+ const span = spanOf(joint)
66
+ return {
67
+ name: joint.name,
68
+ range: { unit: joint.type === 'prismatic' ? 'mm' : 'deg', min: span.min, max: span.max },
69
+ default: 0,
70
+ clip: {
71
+ channels: [
72
+ {
73
+ target: joint.name,
74
+ keys: [
75
+ { at: 0, value: span.min },
76
+ { at: 1, value: span.max }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Whether a parameter is the plain one `setJointType` made for a joint: one channel on that joint, keys at
86
+ * 0 and 1 spanning the given limits. Only such a parameter follows the joint when its limits or type change;
87
+ * one the author reshaped is left as written.
88
+ */
89
+ function isPlainDriver(parameter: Parameter, joint: FigureJoint, span: { min: number; max: number }): boolean {
90
+ const channels = parameter.clip?.channels ?? []
91
+ if (channels.length !== 1) return false
92
+ const [channel] = channels
93
+ if (channel!.target !== joint.name || channel!.path !== undefined) return false
94
+ const keys = channel!.keys as { at: number; value: number }[]
95
+ return (
96
+ keys.length === 2 &&
97
+ keys[0]!.at === 0 &&
98
+ keys[1]!.at === 1 &&
99
+ keys[0]!.value === span.min &&
100
+ keys[1]!.value === span.max &&
101
+ parameter.range?.min === span.min &&
102
+ parameter.range?.max === span.max
103
+ )
104
+ }
105
+
106
+ /**
107
+ * Sets how a part moves relative to its parent, or clears the joint with `undefined`.
108
+ *
109
+ * - From none: adds a joint whose origin is the centre of the part's bottom face, with a vertical axis and
110
+ * starting limits, and a parameter that drives it over those limits.
111
+ * - To none: removes the joint and every channel that drove it; a parameter or clip left with no channel
112
+ * is removed too, because the format refuses an empty one.
113
+ * - Between types: keeps the origin and axis, sets limits for the new type (none for continuous), and
114
+ * moves a plain driving parameter along with them.
115
+ */
116
+ export function setJointType(draft: FigureDraft, part: FigurePart, type: JointType | undefined): FigureDraft {
117
+ const joints = draft.joints ?? []
118
+ const existing = joints.find(joint => joint.child === part.name)
119
+
120
+ if (type === undefined) {
121
+ if (!existing) return draft
122
+ return withoutJoint(draft, existing.name)
123
+ }
124
+
125
+ if (!existing) {
126
+ const { position, size } = part.transform
127
+ const joint: FigureJoint = {
128
+ name: freeName([...joints.map(one => one.name)], `${part.name}-joint`),
129
+ child: part.name,
130
+ type,
131
+ origin: { x: position.x, y: position.y - size.y / 2, z: position.z },
132
+ axis: { x: 0, y: 1, z: 0 }
133
+ }
134
+ if (type !== 'continuous') joint.limits = { ...NEW_LIMITS[type] }
135
+
136
+ const parameters = draft.parameters ?? []
137
+ const taken = [...parameters.map(one => one.name), ...(draft.animations ?? []).map(one => one.name)]
138
+ const driver = drivingParameter(joint)
139
+ driver.name = freeName(taken, joint.name)
140
+
141
+ return { ...draft, joints: [...joints, joint], parameters: [...parameters, driver] }
142
+ }
143
+
144
+ if (existing.type === type) return draft
145
+
146
+ const next: FigureJoint = { ...existing, type }
147
+ if (type === 'continuous') delete next.limits
148
+ else if (existing.type === 'continuous' || (existing.type === 'prismatic') !== (type === 'prismatic')) {
149
+ next.limits = { ...NEW_LIMITS[type] }
150
+ }
151
+
152
+ return replaceJoint(draft, existing, next)
153
+ }
154
+
155
+ /** Changes a joint's origin, axis or limits. A plain driving parameter follows new limits. */
156
+ export function patchJoint(
157
+ draft: FigureDraft,
158
+ name: string,
159
+ patch: { origin?: Vec3; axis?: Vec3; limits?: { min: number; max: number } }
160
+ ): FigureDraft {
161
+ const existing = draft.joints?.find(joint => joint.name === name)
162
+ if (!existing) return draft
163
+ const next: FigureJoint = { ...existing, ...patch }
164
+ if (existing.type === 'continuous') delete next.limits
165
+ return replaceJoint(draft, existing, next)
166
+ }
167
+
168
+ function replaceJoint(draft: FigureDraft, before: FigureJoint, after: FigureJoint): FigureDraft {
169
+ const joints = (draft.joints ?? []).map(joint => (joint === before ? after : joint))
170
+ const was = spanOf(before)
171
+ const unitChanged = (before.type === 'prismatic') !== (after.type === 'prismatic')
172
+ const spanChanged = was.min !== spanOf(after).min || was.max !== spanOf(after).max
173
+
174
+ if (!unitChanged && !spanChanged) return { ...draft, joints }
175
+
176
+ const parameters = (draft.parameters ?? []).map(parameter => {
177
+ if (!isPlainDriver(parameter, before, was)) return parameter
178
+ const driver = drivingParameter(after)
179
+ return { ...parameter, range: driver.range, clip: { ...parameter.clip, channels: driver.clip.channels } }
180
+ })
181
+ return { ...draft, joints, parameters }
182
+ }
183
+
184
+ function withoutJoint(draft: FigureDraft, name: string): FigureDraft {
185
+ const keep = <T extends { channels?: { target: string }[] }>(one: T) => (one.channels ?? []).filter(channel => channel.target !== name)
186
+
187
+ const parameters = (draft.parameters ?? [])
188
+ .map(parameter => ({ ...parameter, clip: { ...parameter.clip, channels: keep(parameter.clip) } }) as Parameter)
189
+ .filter(parameter => parameter.clip.channels.length > 0)
190
+ const animations = (draft.animations ?? [])
191
+ .map(clip => ({ ...clip, channels: keep(clip) }) as NonNullable<FigureDraft['animations']>[number])
192
+ .filter(clip => clip.channels.length > 0)
193
+ const joints = (draft.joints ?? []).filter(joint => joint.name !== name)
194
+
195
+ const next: FigureDraft = { ...draft }
196
+ if (joints.length > 0) next.joints = joints
197
+ else delete next.joints
198
+ if (draft.parameters !== undefined) next.parameters = parameters
199
+ if (draft.animations !== undefined) next.animations = animations
200
+ return next
201
+ }
@@ -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
  * 움직이는 부품 예산.
@@ -111,6 +118,12 @@ export declare class FigureAnimations extends FigureAnimations_base {
111
118
  private removeClip;
112
119
  private removeParameter;
113
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;
114
127
  private setChannel;
115
128
  /**
116
129
  * 경로를 바꾼다 — **값까지 함께 옮긴다.**
@@ -123,6 +136,7 @@ export declare class FigureAnimations extends FigureAnimations_base {
123
136
  private removeChannel;
124
137
  /** 마지막 키에서 1 초 뒤. 값은 그 키를 그대로 받는다 — 붙여 놓고 고치는 것이 짜기 쉽다. */
125
138
  private addKey;
139
+ private setJointKey;
126
140
  private setKey;
127
141
  /** 둘 미만으로 내려가지 않는다 — 단추가 이미 죽어 있지만 밖에서 불릴 수 있다. */
128
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
  *
@@ -520,23 +528,45 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
520
528
  * 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
521
529
  */
522
530
  renderChannel(kind, channel, at, j) {
523
- 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
+ }
524
566
  const turning = channel.path === 'rotation';
525
567
  return html `
526
568
  <div channel ?orphan=${orphan}>
527
- <div row>
528
- <label>${i18next.t('figure.label.channel-target')}</label>
529
- <select
530
- .value=${live(channel.target ?? '')}
531
- @change=${(e) => this.setChannel(kind, at, j, { target: e.target.value })}
532
- >
533
- ${orphan ? html `<option value=${channel.target}>${channel.target}</option>` : nothing}
534
- ${this.partNames.map(name => html `<option value=${name}>${name}</option>`)}
535
- </select>
536
- <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
537
- <md-icon>close</md-icon>
538
- </button>
539
- </div>
569
+ ${target}
540
570
 
541
571
  ${orphan ? html `<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
542
572
 
@@ -607,6 +637,66 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
607
637
  * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
608
638
  * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
609
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
+ }
610
700
  renderKeys(kind, channel, at, j) {
611
701
  const keys = channel.keys ?? [];
612
702
  /*
@@ -836,10 +926,36 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
836
926
  { target, path: 'translation', keys: keysFor('translation') }
837
927
  ]);
838
928
  }
839
- 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) {
840
935
  const channel = this.channelsOf(kind, at)[j];
841
936
  if (!channel)
842
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;
843
959
  const next = { ...channel, ...patch };
844
960
  /* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
845
961
  if ('pivot' in patch && patch.pivot === undefined)
@@ -855,7 +971,7 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
855
971
  */
856
972
  setPath(kind, at, j, path) {
857
973
  const channel = this.channelsOf(kind, at)[j];
858
- if (!channel)
974
+ if (!channel || isJointChannel(channel))
859
975
  return;
860
976
  const crossing = (channel.path === 'scale') !== (path === 'scale');
861
977
  const next = {
@@ -876,6 +992,18 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
876
992
  const channel = this.channelsOf(kind, at)[j];
877
993
  if (!channel)
878
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
+ }
879
1007
  const last = channel.keys[channel.keys.length - 1];
880
1008
  /*
881
1009
  A parameter curve ends at 1, so appending a key would make it unreachable. Insert it between the last two keys.
@@ -889,9 +1017,15 @@ let FigureAnimations = class FigureAnimations extends localize(i18next)(LitEleme
889
1017
  const key = { at: (last?.at ?? 0) + 1, value: { ...(last?.value ?? startValue(channel.path)) } };
890
1018
  this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] });
891
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
+ }
892
1026
  setKey(kind, at, j, k, patch) {
893
1027
  const channel = this.channelsOf(kind, at)[j];
894
- if (!channel)
1028
+ if (!channel || isJointChannel(channel))
895
1029
  return;
896
1030
  this.patchChannel(kind, at, j, {
897
1031
  ...channel,