@things-factory/figure-ui 10.1.26 → 10.1.28

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 (83) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/base-box.ts +104 -0
  3. package/client/modeller/figure-animations.ts +253 -32
  4. package/client/modeller/figure-canvas.ts +18 -3
  5. package/client/modeller/figure-inspector.ts +292 -3
  6. package/client/modeller/figure-preview.ts +57 -2
  7. package/client/modeller/figure-report.ts +9 -4
  8. package/client/modeller/figure-side.ts +9 -1
  9. package/client/modeller/figure-source.ts +24 -4
  10. package/client/modeller/figure-view.ts +20 -0
  11. package/client/modeller/joint-edits.ts +262 -0
  12. package/client/modeller/joint-gizmo.ts +118 -0
  13. package/client/modeller/pose-defaults.ts +67 -0
  14. package/client/modeller/proposal.ts +9 -3
  15. package/client/modeller/scene-budget.ts +31 -40
  16. package/client/pages/figure-list-page.ts +4 -9
  17. package/client/pages/figure-modeller-page.ts +124 -41
  18. package/dist-client/graphql/index.d.ts +7 -0
  19. package/dist-client/graphql/index.js +21 -0
  20. package/dist-client/graphql/index.js.map +1 -1
  21. package/dist-client/modeller/base-box.d.ts +47 -0
  22. package/dist-client/modeller/base-box.js +52 -0
  23. package/dist-client/modeller/base-box.js.map +1 -0
  24. package/dist-client/modeller/figure-animations.d.ts +29 -0
  25. package/dist-client/modeller/figure-animations.js +228 -25
  26. package/dist-client/modeller/figure-animations.js.map +1 -1
  27. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  28. package/dist-client/modeller/figure-canvas.js +14 -3
  29. package/dist-client/modeller/figure-canvas.js.map +1 -1
  30. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  31. package/dist-client/modeller/figure-inspector.js +253 -2
  32. package/dist-client/modeller/figure-inspector.js.map +1 -1
  33. package/dist-client/modeller/figure-preview.d.ts +15 -0
  34. package/dist-client/modeller/figure-preview.js +48 -2
  35. package/dist-client/modeller/figure-preview.js.map +1 -1
  36. package/dist-client/modeller/figure-report.d.ts +3 -2
  37. package/dist-client/modeller/figure-report.js +9 -4
  38. package/dist-client/modeller/figure-report.js.map +1 -1
  39. package/dist-client/modeller/figure-side.d.ts +5 -1
  40. package/dist-client/modeller/figure-side.js +12 -0
  41. package/dist-client/modeller/figure-side.js.map +1 -1
  42. package/dist-client/modeller/figure-source.d.ts +15 -0
  43. package/dist-client/modeller/figure-source.js +8 -4
  44. package/dist-client/modeller/figure-source.js.map +1 -1
  45. package/dist-client/modeller/figure-view.d.ts +16 -0
  46. package/dist-client/modeller/figure-view.js +15 -0
  47. package/dist-client/modeller/figure-view.js.map +1 -1
  48. package/dist-client/modeller/joint-edits.d.ts +49 -0
  49. package/dist-client/modeller/joint-edits.js +223 -0
  50. package/dist-client/modeller/joint-edits.js.map +1 -0
  51. package/dist-client/modeller/joint-gizmo.d.ts +31 -0
  52. package/dist-client/modeller/joint-gizmo.js +104 -0
  53. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  54. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  55. package/dist-client/modeller/pose-defaults.js +35 -0
  56. package/dist-client/modeller/pose-defaults.js.map +1 -0
  57. package/dist-client/modeller/proposal.d.ts +1 -1
  58. package/dist-client/modeller/proposal.js +8 -3
  59. package/dist-client/modeller/proposal.js.map +1 -1
  60. package/dist-client/modeller/scene-budget.d.ts +31 -34
  61. package/dist-client/modeller/scene-budget.js +28 -37
  62. package/dist-client/modeller/scene-budget.js.map +1 -1
  63. package/dist-client/pages/figure-list-page.js +4 -9
  64. package/dist-client/pages/figure-list-page.js.map +1 -1
  65. package/dist-client/pages/figure-modeller-page.d.ts +43 -0
  66. package/dist-client/pages/figure-modeller-page.js +121 -40
  67. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  68. package/dist-client/tsconfig.tsbuildinfo +1 -1
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +3 -3
  71. package/test/ai-proposal-contract.test.ts +16 -11
  72. package/test/base-box.test.ts +154 -0
  73. package/test/figure-source.test.ts +32 -0
  74. package/test/joint-edits.test.ts +165 -0
  75. package/test/joint-gizmo.test.ts +174 -0
  76. package/test/pose-defaults.test.ts +71 -0
  77. package/test/preview-board-depth.test.ts +27 -0
  78. package/test/scene-budget.test.ts +53 -0
  79. package/translations/en.json +37 -2
  80. package/translations/ja.json +37 -2
  81. package/translations/ko.json +37 -2
  82. package/translations/ms.json +37 -2
  83. package/translations/zh.json +37 -2
@@ -323,6 +323,29 @@ export async function inspectFigure(id: string): Promise<FigureInspection> {
323
323
  }
324
324
 
325
325
  /** 발행된 판들 — 최근 것부터 열 개. */
326
+ /**
327
+ * 직전 발행판의 정본. 한 번도 발행 안 했으면 `undefined`.
328
+ *
329
+ * 저작 중에 「이 고침이 이미 놓인 것을 깨나」를 그 자리에서 보여 주려면 견줄 것이 필요하다. 저장
330
+ * 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
331
+ */
332
+ export async function fetchReleasedSource(id: string): Promise<string | undefined> {
333
+ const response = await client.query({
334
+ query: gql`
335
+ query FigureReleased($id: String!) {
336
+ figureReleased(id: $id) {
337
+ version
338
+ source
339
+ }
340
+ }
341
+ `,
342
+ variables: { id },
343
+ fetchPolicy: 'network-only'
344
+ })
345
+
346
+ return unwrap<{ source?: string }>(response, 'figureReleased')?.source ?? undefined
347
+ }
348
+
326
349
  export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
327
350
  const response = await client.query({
328
351
  query: gql`
@@ -0,0 +1,104 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ import type { FigureDraft, PartModel } from './figure-source.js'
6
+
7
+ /**
8
+ * 기준 상자를 고친다 — **부품은 제자리에 둔 채.**
9
+ *
10
+ * ## 왜 화면에 있어야 하나
11
+ *
12
+ * 기준 상자는 도면에 놓일 때의 배율을 정하는 수다(ADR-0044 ①). 부품이 그 상자를 넘으면 발행이
13
+ * 막히고, 고치는 길은 「상자 숫자 하나를 키우는 것」인데 **화면에 그 칸이 없었다.** 저작자는
14
+ * 넘쳤다는 말만 듣고 고칠 자리를 못 찾았다.
15
+ *
16
+ * ## 상자를 키우면 부품이 움직인다 — 그것을 되돌린다
17
+ *
18
+ * 저작면의 부품 자리는 판의 왼쪽 위에서 잰 값이고, 저장 형식의 자리는 **상자의 한가운데**에서 잰
19
+ * 값이다(`toFigureSource` 가 절반을 뺀다). 그래서 상자만 키우면 한가운데가 옮겨 가고 부품이 전부
20
+ * 반대로 밀린다. 여기서 늘어난 절반만큼 부품을 함께 옮겨, 저장되는 자리가 한 값도 안 바뀌게 한다.
21
+ *
22
+ * ## 「부품에 맞추기」는 한가운데를 지킨다
23
+ *
24
+ * 부품을 감싸는 가장 작은 상자를 만들 때, 부품 쪽을 **옮기지 않는다.** 한가운데에서 가장 먼
25
+ * 부품까지의 거리를 양쪽으로 잡는다. 부품이 한쪽으로 치우쳐 있으면 반대쪽에 여유가 남는데,
26
+ * 그것이 사실이다 — 치우친 것을 가운데로 끌어오면 저작자가 놓은 자리가 소리 없이 달라지고,
27
+ * 발행된 도형에서는 문(port)의 자리가 함께 움직인다.
28
+ *
29
+ * 높이는 밑면이 바닥이라 한쪽만 늘린다. 바닥 아래로 내려간 부품이 있으면 그 부품은 상자 밖에
30
+ * 남는다 — 그것을 담으려면 부품을 올려야 하고, 그것은 저작자가 정할 일이다. 발행 관문이 이미
31
+ * `part-outside-base` 로 말한다.
32
+ */
33
+
34
+ export interface BaseSize {
35
+ /** 저작면의 가로 — 형식의 `base.x`. */
36
+ width: number
37
+ /** 저작면의 세로 — 형식의 `base.z`. */
38
+ height: number
39
+ /** 높이 — 형식의 `base.y`. */
40
+ depth: number
41
+ }
42
+
43
+ /** 상자는 밀리미터 하나보다 작아질 수 없다. 0 이 되면 배율이 0 으로 나뉜다. */
44
+ const LEAST = 1
45
+
46
+ const round = (value: number) => Math.round(value)
47
+
48
+ export const baseOf = (draft: FigureDraft): BaseSize => ({
49
+ width: draft.width,
50
+ height: draft.height,
51
+ depth: draft.depth
52
+ })
53
+
54
+ /** 부품 전부를 담는 가장 작은 상자. 부품이 없으면 잴 것이 없다. */
55
+ export function baseFittingParts(draft: FigureDraft, parts: readonly PartModel[]): BaseSize | undefined {
56
+ if (parts.length === 0) return undefined
57
+
58
+ const middle = { x: draft.width / 2, y: draft.height / 2 }
59
+ let half = { x: 0, y: 0 }
60
+ let tallest = 0
61
+
62
+ for (const part of parts) {
63
+ half = {
64
+ x: Math.max(half.x, Math.abs(part.left - middle.x), Math.abs(part.left + part.width - middle.x)),
65
+ y: Math.max(half.y, Math.abs(part.top - middle.y), Math.abs(part.top + part.height - middle.y))
66
+ }
67
+ tallest = Math.max(tallest, part.zPos + part.depth)
68
+ }
69
+
70
+ return {
71
+ width: Math.max(LEAST, round(half.x * 2)),
72
+ height: Math.max(LEAST, round(half.y * 2)),
73
+ depth: Math.max(LEAST, round(tallest))
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 상자를 이 크기로. 부품은 저장되는 자리가 그대로이도록 함께 옮긴다.
79
+ *
80
+ * 달라질 것이 없으면 **받은 것을 그대로 돌려준다** — 고친 것이 없는데 「저장 안 됨」이 붙지 않게.
81
+ */
82
+ export function resizeBase(
83
+ draft: FigureDraft,
84
+ parts: readonly PartModel[],
85
+ next: BaseSize
86
+ ): { draft: FigureDraft; parts: readonly PartModel[] } {
87
+ const size = {
88
+ width: Math.max(LEAST, round(next.width)),
89
+ height: Math.max(LEAST, round(next.height)),
90
+ depth: Math.max(LEAST, round(next.depth))
91
+ }
92
+
93
+ if (size.width === draft.width && size.height === draft.height && size.depth === draft.depth) {
94
+ return { draft, parts }
95
+ }
96
+
97
+ const shift = { x: (size.width - draft.width) / 2, y: (size.height - draft.height) / 2 }
98
+ const moved =
99
+ shift.x === 0 && shift.y === 0
100
+ ? parts
101
+ : parts.map(part => ({ ...part, left: part.left + shift.x, top: part.top + shift.y }))
102
+
103
+ return { draft: { ...draft, ...size }, parts: moved }
104
+ }
@@ -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'
@@ -124,6 +130,18 @@ function freeName(taken: readonly { name: string }[], stem: string): string {
124
130
  for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
125
131
  }
126
132
 
133
+ /**
134
+ * 파라미터 범위에 적히는 단위 — **권하는 것이지 가두는 것이 아니다.**
135
+ *
136
+ * 관절이 만드는 파라미터가 `deg` 와 `mm` 을 쓰고(회전과 직선), 그리퍼·문 같은 값은 `%` 를 쓴다.
137
+ * 속도는 `mm/s` 와 `rpm`, 시간은 `s` 다. 이 여섯이 지금 쓰이는 전부이고, 목록에 없는 말을 적는
138
+ * 길은 그대로 열려 있다.
139
+ */
140
+ const PARAMETER_UNITS = ['deg', 'mm', '%', 'mm/s', 'rpm', 's'] as const
141
+
142
+ /** 목록 대신 손으로 적겠다는 표. `select` 의 값으로 쓴다. */
143
+ const UNIT_TYPE_IT = '__type-it'
144
+
127
145
  @customElement('figure-animations')
128
146
  export class FigureAnimations extends localize(i18next)(LitElement) {
129
147
  static styles = [
@@ -219,6 +237,21 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
219
237
  min-width: 3.2em;
220
238
  color: var(--md-sys-color-on-surface-variant);
221
239
  }
240
+ /*
241
+ 칸이 줄어들 줄 알아야 한다. 숫자 입력칸은 제 기본 너비(20자 남짓)를 갖고 있고 flex 는
242
+ 기본 크기 아래로는 안 줄이므로, 범위처럼 한 줄에 셋이 서는 줄은 패널 밖으로 밀려
243
+ 오른쪽 칸이 잘렸다.
244
+ */
245
+ div[row] > input {
246
+ flex: 1 1 0;
247
+ min-width: 0;
248
+ }
249
+ /* 단위는 「mm」 정도의 짧은 말이라 숫자 칸보다 좁게 둔다. 고르는 칸도 같은 폭이다. */
250
+ div[row] > input[unit],
251
+ div[row] > select[unit] {
252
+ flex: 0 1 5.5em;
253
+ min-width: 0;
254
+ }
222
255
 
223
256
  /* ── keyframe ─────────────────────────────────────────────────── */
224
257
 
@@ -335,6 +368,9 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
335
368
  @state() private open = ''
336
369
  @state() private recipeTarget = ''
337
370
 
371
+ /** 단위를 손으로 적는 중인 파라미터. 목록에 없는 말을 적으려고 연 칸이다. */
372
+ @state() private typingUnit = new Set<number>()
373
+
338
374
  private get clips(): Clip[] {
339
375
  return this.draft?.animations ?? []
340
376
  }
@@ -357,6 +393,11 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
357
393
  return this.parts.map(part => part.name).filter(name => !!name)
358
394
  }
359
395
 
396
+ /** Joints a channel can drive instead of a part (ADR-0066). */
397
+ private get joints(): NonNullable<FigureDraft['joints']> {
398
+ return this.draft?.joints ?? []
399
+ }
400
+
360
401
  /**
361
402
  * 몇 부품이 움직이나 — 채널이 가리키는 **서로 다른 부품**의 수.
362
403
  *
@@ -531,14 +572,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
531
572
  aria-label="max"
532
573
  @change=${(e: Event) => this.setRange(at, { max: Number((e.target as HTMLInputElement).value) || 0 })}
533
574
  />
534
- <input
535
- unit
536
- .value=${live(range.unit ?? '')}
537
- placeholder="mm"
538
- aria-label="unit"
539
- title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
540
- @change=${(e: Event) => this.setRange(at, { unit: (e.target as HTMLInputElement).value.trim() })}
541
- />
575
+ ${this.renderUnit(at, range.unit ?? '')}
542
576
  </div>
543
577
  ${backwards ? html`<p warn>${i18next.t('figure.text.range-needs-room')}</p>` : nothing}
544
578
 
@@ -594,24 +628,48 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
594
628
  * 검증은 저장할 때 말하고 여기서는 **그 자리에서** 보여 준다.
595
629
  */
596
630
  private renderChannel(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
597
- const orphan = !this.partNames.includes(channel.target)
631
+ const joint = this.joints.find(one => one.name === channel.target)
632
+ const orphan = !this.partNames.includes(channel.target) && !joint
633
+ const target = html`
634
+ <div row>
635
+ <label>${i18next.t('figure.label.channel-target')}</label>
636
+ <select
637
+ .value=${live(channel.target ?? '')}
638
+ @change=${(e: Event) => this.retarget(kind, at, j, (e.target as HTMLSelectElement).value)}
639
+ >
640
+ ${orphan ? html`<option value=${channel.target}>${channel.target}</option>` : nothing}
641
+ <optgroup label=${i18next.t('figure.label.channel-target-parts')}>
642
+ ${this.partNames.map(name => html`<option value=${name}>${name}</option>`)}
643
+ </optgroup>
644
+ ${this.joints.length > 0
645
+ ? html`<optgroup label=${i18next.t('figure.label.channel-target-joints')}>
646
+ ${this.joints.map(one => html`<option value=${one.name}>${one.name}</option>`)}
647
+ </optgroup>`
648
+ : nothing}
649
+ </select>
650
+ <button plain @click=${() => this.removeChannel(kind, at, j)} title=${i18next.t('figure.button.remove')}>
651
+ <md-icon>close</md-icon>
652
+ </button>
653
+ </div>
654
+ `
655
+
656
+ /* A joint channel has no path or pivot: the joint says what moves. Each key is the joint coordinate. */
657
+ if (isJointChannel(channel)) {
658
+ const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
659
+ return html`
660
+ <div channel ?orphan=${orphan}>
661
+ ${target} ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
662
+ <p quiet>${i18next.t('figure.text.joint-channel-value-' + unit)}</p>
663
+ ${this.renderJointKeys(kind, channel, at, j, joint?.limits)}
664
+ </div>
665
+ `
666
+ }
667
+
598
668
  const turning = channel.path === 'rotation'
599
669
 
600
670
  return html`
601
671
  <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>
672
+ ${target}
615
673
 
616
674
  ${orphan ? html`<p warn>${i18next.t('figure.text.channel-target-is-gone')}</p>` : nothing}
617
675
 
@@ -649,7 +707,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
649
707
  * 그래서 경로가 rotation 일 때만 낸다. 언제나 내면 저작자가 옮김·배율에도 적고, 검증이
650
708
  * 저장할 때 「쓰이지 않는다」고 알린다. 안 보이면 적을 일이 없다.
651
709
  */
652
- private renderPivot(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
710
+ private renderPivot(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
653
711
  const pivot = channel.pivot
654
712
  const on = pivot !== undefined
655
713
 
@@ -691,7 +749,76 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
691
749
  * 시각이 오름차순이어야 하는 것도 형식의 규칙이다. 여기서는 어긋난 줄을 붉게 내고 값을
692
750
  * 고치지는 않는다 — 저작자가 3 을 1 로 고치던 중일 수 있다.
693
751
  */
694
- private renderKeys(kind: MotionKind, channel: Channel, at: number, j: number): TemplateResult {
752
+ /**
753
+ * Keys of a joint channel: a position and one joint coordinate each. A value outside the joint's limits
754
+ * is marked and left as typed; the format refuses it on save.
755
+ */
756
+ private renderJointKeys(
757
+ kind: MotionKind,
758
+ channel: JointChannel,
759
+ at: number,
760
+ j: number,
761
+ limits?: { min: number; max: number }
762
+ ): TemplateResult {
763
+ const keys = channel.keys ?? []
764
+ const onValues = kind === 'parameter'
765
+ const beyond = (key: JointKey) => !!limits && (key.value < limits.min || key.value > limits.max)
766
+
767
+ return html`
768
+ <table keys>
769
+ <tr>
770
+ <th>${i18next.t(onValues ? 'figure.label.key-at-value' : 'figure.label.key-at')}</th>
771
+ <th>${i18next.t('figure.label.joint-value')}</th>
772
+ <th></th>
773
+ </tr>
774
+ ${keys.map((key, k) => {
775
+ const backwards = k > 0 && key.at <= (keys[k - 1]!.at ?? 0)
776
+ return html`
777
+ <tr>
778
+ <td>
779
+ <input
780
+ type="number"
781
+ step=${onValues ? '0.05' : '0.1'}
782
+ min="0"
783
+ max=${onValues ? '1' : nothing}
784
+ ?bad=${backwards || key.at < 0 || (onValues && key.at > 1)}
785
+ .value=${live(String(key.at))}
786
+ @change=${(e: Event) =>
787
+ this.setJointKey(kind, at, j, k, { at: Math.max(0, Number((e.target as HTMLInputElement).value) || 0) })}
788
+ />
789
+ </td>
790
+ <td>
791
+ <input
792
+ type="number"
793
+ step="1"
794
+ ?bad=${beyond(key)}
795
+ .value=${live(String(key.value))}
796
+ title=${beyond(key) ? i18next.t('figure.text.joint-value-beyond-limits') : ''}
797
+ @change=${(e: Event) => this.setJointKey(kind, at, j, k, { value: Number((e.target as HTMLInputElement).value) || 0 })}
798
+ />
799
+ </td>
800
+ <td drop>
801
+ <button
802
+ plain
803
+ ?disabled=${keys.length <= 2}
804
+ title=${keys.length <= 2 ? i18next.t('figure.text.two-keys-at-least') : i18next.t('figure.button.remove')}
805
+ @click=${() => this.removeKey(kind, at, j, k)}
806
+ >
807
+ <md-icon>close</md-icon>
808
+ </button>
809
+ </td>
810
+ </tr>
811
+ `
812
+ })}
813
+ </table>
814
+ ${keys.some(beyond) ? html`<p warn>${i18next.t('figure.text.joint-value-beyond-limits')}</p>` : nothing}
815
+ <button @click=${() => this.addKey(kind, at, j)}>
816
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-key')}
817
+ </button>
818
+ `
819
+ }
820
+
821
+ private renderKeys(kind: MotionKind, channel: PartChannel, at: number, j: number): TemplateResult {
695
822
  const keys = channel.keys ?? []
696
823
  /*
697
824
  Keys on a parameter curve sit on the 0..1 value span (ADR-0051). A key above 1 is unreachable and
@@ -905,6 +1032,56 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
905
1032
  this.patchParameter(at, { name: name.trim() })
906
1033
  }
907
1034
 
1035
+ /**
1036
+ * 단위 칸 — **목록이 보이는 채로, 적는 길도 열어 둔다.**
1037
+ *
1038
+ * 처음에는 `datalist` 를 붙였다. 그것은 브라우저가 쥐고 있는 기능이라 **눌러 보기 전에는 목록이
1039
+ * 있는지조차 안 보인다** — 화살표도 없고, 어디에 어떻게 뜨는지도 브라우저가 정한다. 고를 수
1040
+ * 있다는 것을 알리는 것이 이 칸의 목적이므로 `select` 로 바꿨다.
1041
+ *
1042
+ * 형식은 단위를 규칙으로 쓰지 않으므로(표시용 말이다) 목록에 가두지 않는다. 마지막 줄이
1043
+ * 「직접 적기」이고, 목록에 없는 말이 이미 적혀 있으면 처음부터 적는 칸으로 연다.
1044
+ */
1045
+ private renderUnit(at: number, unit: string) {
1046
+ const listed = (PARAMETER_UNITS as readonly string[]).includes(unit)
1047
+ const typing = this.typingUnit.has(at) || (unit !== '' && !listed)
1048
+
1049
+ if (typing) {
1050
+ return html`<input
1051
+ unit
1052
+ .value=${live(unit)}
1053
+ placeholder="mm"
1054
+ aria-label="unit"
1055
+ title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
1056
+ @change=${(e: Event) => this.setRange(at, { unit: (e.target as HTMLInputElement).value.trim() })}
1057
+ />`
1058
+ }
1059
+
1060
+ return html`<select
1061
+ unit
1062
+ aria-label="unit"
1063
+ title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
1064
+ @change=${(e: Event) => this.chooseUnit(at, (e.target as HTMLSelectElement).value)}
1065
+ >
1066
+ <option value="" ?selected=${unit === ''}>${i18next.t('figure.label.unit-none')}</option>
1067
+ ${PARAMETER_UNITS.map(one => html`<option value=${one} ?selected=${one === unit}>${one}</option>`)}
1068
+ <option value=${UNIT_TYPE_IT}>${i18next.t('figure.label.unit-type-it')}</option>
1069
+ </select>`
1070
+ }
1071
+
1072
+ /** 고른 것을 넣는다. 「직접 적기」는 값이 아니라 **칸을 바꾸는 것**이다. */
1073
+ private chooseUnit(at: number, value: string): void {
1074
+ if (value === UNIT_TYPE_IT) {
1075
+ this.typingUnit = new Set([...this.typingUnit, at])
1076
+ return
1077
+ }
1078
+
1079
+ const next = new Set(this.typingUnit)
1080
+ next.delete(at)
1081
+ this.typingUnit = next
1082
+ this.setRange(at, { unit: value })
1083
+ }
1084
+
908
1085
  private setRange(at: number, patch: Partial<Parameter['range']>): void {
909
1086
  const parameter = this.parameters[at]
910
1087
  if (!parameter) return
@@ -948,11 +1125,36 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
948
1125
  ])
949
1126
  }
950
1127
 
951
- private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<Channel>): void {
1128
+ /**
1129
+ * Points a channel at another part or joint. Crossing between the two changes the channel's shape: a
1130
+ * joint channel has no path and one number per key, a part channel a path and a vector. The key positions
1131
+ * are kept, since they are the author's timing; values restart at the rest pose.
1132
+ */
1133
+ private retarget(kind: MotionKind, at: number, j: number, target: string): void {
952
1134
  const channel = this.channelsOf(kind, at)[j]
953
1135
  if (!channel) return
1136
+ const toJoint = this.joints.some(one => one.name === target)
954
1137
 
955
- const next: Channel = { ...channel, ...patch }
1138
+ if (toJoint && !isJointChannel(channel)) {
1139
+ const next: JointChannel = { target, keys: channel.keys.map(key => ({ at: key.at, value: 0 })) }
1140
+ if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
1141
+ this.patchChannel(kind, at, j, next)
1142
+ return
1143
+ }
1144
+ if (!toJoint && isJointChannel(channel)) {
1145
+ const next: PartChannel = { target, path: 'translation', keys: channel.keys.map(key => ({ at: key.at, value: startValue('translation') })) }
1146
+ if (channel.interpolation !== undefined) next.interpolation = channel.interpolation
1147
+ this.patchChannel(kind, at, j, next)
1148
+ return
1149
+ }
1150
+ this.patchChannel(kind, at, j, { ...channel, target } as Channel)
1151
+ }
1152
+
1153
+ private setChannel(kind: MotionKind, at: number, j: number, patch: Partial<PartChannel>): void {
1154
+ const channel = this.channelsOf(kind, at)[j]
1155
+ if (!channel || isJointChannel(channel)) return
1156
+
1157
+ const next: PartChannel = { ...channel, ...patch }
956
1158
  /* `undefined` 를 얹는 것은 지우는 뜻이다. 남겨 두면 저장 형식에 빈 칸이 실린다. */
957
1159
  if ('pivot' in patch && patch.pivot === undefined) delete next.pivot
958
1160
  this.patchChannel(kind, at, j, next)
@@ -967,10 +1169,10 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
967
1169
  */
968
1170
  private setPath(kind: MotionKind, at: number, j: number, path: ChannelPath): void {
969
1171
  const channel = this.channelsOf(kind, at)[j]
970
- if (!channel) return
1172
+ if (!channel || isJointChannel(channel)) return
971
1173
 
972
1174
  const crossing = (channel.path === 'scale') !== (path === 'scale')
973
- const next: Channel = {
1175
+ const next: PartChannel = {
974
1176
  ...channel,
975
1177
  path,
976
1178
  keys: crossing ? channel.keys.map(key => ({ at: key.at, value: startValue(path) })) : channel.keys
@@ -994,6 +1196,19 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
994
1196
  const channel = this.channelsOf(kind, at)[j]
995
1197
  if (!channel) return
996
1198
 
1199
+ if (isJointChannel(channel)) {
1200
+ const keys = channel.keys
1201
+ const end = keys[keys.length - 1]
1202
+ if (kind === 'parameter' && keys.length >= 2) {
1203
+ const before = keys[keys.length - 2]!
1204
+ const middle: JointKey = { at: (before.at + end!.at) / 2, value: before.value }
1205
+ this.patchChannel(kind, at, j, { ...channel, keys: [...keys.slice(0, -1), middle, end!] })
1206
+ return
1207
+ }
1208
+ this.patchChannel(kind, at, j, { ...channel, keys: [...keys, { at: (end?.at ?? 0) + 1, value: end?.value ?? 0 }] })
1209
+ return
1210
+ }
1211
+
997
1212
  const last = channel.keys[channel.keys.length - 1]
998
1213
 
999
1214
  /*
@@ -1010,9 +1225,15 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
1010
1225
  this.patchChannel(kind, at, j, { ...channel, keys: [...channel.keys, key] })
1011
1226
  }
1012
1227
 
1228
+ private setJointKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<JointKey>): void {
1229
+ const channel = this.channelsOf(kind, at)[j]
1230
+ if (!channel || !isJointChannel(channel)) return
1231
+ this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key)) })
1232
+ }
1233
+
1013
1234
  private setKey(kind: MotionKind, at: number, j: number, k: number, patch: Partial<Keyframe>): void {
1014
1235
  const channel = this.channelsOf(kind, at)[j]
1015
- if (!channel) return
1236
+ if (!channel || isJointChannel(channel)) return
1016
1237
  this.patchChannel(kind, at, j, {
1017
1238
  ...channel,
1018
1239
  keys: channel.keys.map((key, i) => (i === k ? { ...key, ...patch } : key))
@@ -1023,6 +1244,6 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
1023
1244
  private removeKey(kind: MotionKind, at: number, j: number, k: number): void {
1024
1245
  const channel = this.channelsOf(kind, at)[j]
1025
1246
  if (!channel || channel.keys.length <= 2) return
1026
- this.patchChannel(kind, at, j, { ...channel, keys: channel.keys.filter((_, i) => i !== k) })
1247
+ this.patchChannel(kind, at, j, { ...channel, keys: (channel.keys as { at: number }[]).filter((_, i) => i !== k) } as Channel)
1027
1248
  }
1028
1249
  }
@@ -192,6 +192,14 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
192
192
  /** 지금 고른 부품 이름. 목록에서 고른 것을 캔버스에 알린다. */
193
193
  @property({ type: String }) picked?: string
194
194
 
195
+ /**
196
+ * 관절이 도는 자리를 그리는 부채꼴(`joint-gizmo.ts`). 저작면에만 있고 저장되지 않는다.
197
+ *
198
+ * 부품과 같은 길로 세운다 — 씬에 겹쳐 그리는 통로를 따로 뚫지 않는다. `locked` 라 클릭도
199
+ * 마퀴도 집지 않는다.
200
+ */
201
+ @property({ type: Object }) gizmo?: PartModel
202
+
195
203
  /**
196
204
  * 보기 전용 — 씬에서 일어난 일을 밖에 알리지 않는다.
197
205
  *
@@ -278,6 +286,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
278
286
 
279
287
  if (changed.has('revision')) this.standing = undefined
280
288
  if (changed.has('draft') || changed.has('parts') || changed.has('figureId') || changed.has('revision')) this.standUp()
289
+ else if (changed.has('gizmo')) this.syncParts()
281
290
  if (changed.has('view')) this.applyView()
282
291
  if (changed.has('picked')) this.showPicked()
283
292
  }
@@ -358,7 +367,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
358
367
  없어야 한다: 좌표 모눈과 겹쳐 싸우고 원점 아래쪽을 가린다.
359
368
  */
360
369
  fillStyle: PAPER,
361
- components: this.parts.map(part => ({ ...part }))
370
+ components: this.standing3d().map(part => ({ ...part }))
362
371
  },
363
372
  // **편집 모드**로 만든다. 도면의 3D 모델러가 그 모드에서 카메라 시점 버튼 ·
364
373
  // 기즈모 모드 · 도움말 · 단축키를 띄운다.
@@ -449,11 +458,17 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
449
458
  * 다시 세우면 고른 것과 카메라가 날아가서, 숫자 하나 고칠 때마다 화면이 처음으로
450
459
  * 돌아간다.
451
460
  */
461
+ /** 판에 세울 것 전부 — 저작한 부품과, 있으면 관절 부채꼴. */
462
+ private standing3d(): PartModel[] {
463
+ return this.gizmo ? [...this.parts, this.gizmo] : this.parts
464
+ }
465
+
452
466
  private syncParts() {
453
467
  const root = this.scene?.root
454
468
  if (!root) return
455
469
 
456
- const wanted = new Map(this.parts.map(part => [part.name, part]))
470
+ const all = this.standing3d()
471
+ const wanted = new Map(all.map(part => [part.name, part]))
457
472
  const standing = this.partsOnCanvas()
458
473
 
459
474
  // 없어진 것을 제거한다
@@ -464,7 +479,7 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
464
479
  }
465
480
  }
466
481
 
467
- for (const part of this.parts) {
482
+ for (const part of all) {
468
483
  const found = standing.get(part.name)
469
484
  if (found) {
470
485
  /*