@things-factory/figure-ui 10.1.27 → 10.1.29

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 (91) hide show
  1. package/client/graphql/index.ts +23 -0
  2. package/client/modeller/angle-dial.ts +70 -0
  3. package/client/modeller/base-box.ts +104 -0
  4. package/client/modeller/figure-animations.ts +90 -17
  5. package/client/modeller/figure-canvas.ts +24 -6
  6. package/client/modeller/figure-inspector.ts +169 -5
  7. package/client/modeller/figure-preview.ts +110 -3
  8. package/client/modeller/figure-report.ts +20 -32
  9. package/client/modeller/figure-side.ts +10 -1
  10. package/client/modeller/figure-source.ts +8 -0
  11. package/client/modeller/joint-edits.ts +66 -5
  12. package/client/modeller/joint-gizmo.ts +180 -0
  13. package/client/modeller/part-edits.ts +75 -9
  14. package/client/modeller/pose-defaults.ts +67 -0
  15. package/client/modeller/proposal.ts +9 -3
  16. package/client/modeller/scene-budget.ts +31 -40
  17. package/client/pages/figure-list-page.ts +4 -9
  18. package/client/pages/figure-modeller-page.ts +144 -43
  19. package/client/types.ts +2 -0
  20. package/dist-client/graphql/index.d.ts +7 -0
  21. package/dist-client/graphql/index.js +21 -0
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/angle-dial.d.ts +32 -0
  24. package/dist-client/modeller/angle-dial.js +41 -0
  25. package/dist-client/modeller/angle-dial.js.map +1 -0
  26. package/dist-client/modeller/base-box.d.ts +47 -0
  27. package/dist-client/modeller/base-box.js +52 -0
  28. package/dist-client/modeller/base-box.js.map +1 -0
  29. package/dist-client/modeller/figure-animations.d.ts +19 -4
  30. package/dist-client/modeller/figure-animations.js +86 -17
  31. package/dist-client/modeller/figure-animations.js.map +1 -1
  32. package/dist-client/modeller/figure-canvas.d.ts +9 -0
  33. package/dist-client/modeller/figure-canvas.js +20 -6
  34. package/dist-client/modeller/figure-canvas.js.map +1 -1
  35. package/dist-client/modeller/figure-inspector.d.ts +37 -0
  36. package/dist-client/modeller/figure-inspector.js +159 -5
  37. package/dist-client/modeller/figure-inspector.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.d.ts +26 -0
  39. package/dist-client/modeller/figure-preview.js +99 -3
  40. package/dist-client/modeller/figure-preview.js.map +1 -1
  41. package/dist-client/modeller/figure-report.d.ts +11 -6
  42. package/dist-client/modeller/figure-report.js +20 -35
  43. package/dist-client/modeller/figure-report.js.map +1 -1
  44. package/dist-client/modeller/figure-side.d.ts +5 -1
  45. package/dist-client/modeller/figure-side.js +13 -0
  46. package/dist-client/modeller/figure-side.js.map +1 -1
  47. package/dist-client/modeller/figure-source.d.ts +7 -0
  48. package/dist-client/modeller/figure-source.js.map +1 -1
  49. package/dist-client/modeller/joint-edits.d.ts +23 -3
  50. package/dist-client/modeller/joint-edits.js +53 -5
  51. package/dist-client/modeller/joint-edits.js.map +1 -1
  52. package/dist-client/modeller/joint-gizmo.d.ts +34 -0
  53. package/dist-client/modeller/joint-gizmo.js +158 -0
  54. package/dist-client/modeller/joint-gizmo.js.map +1 -0
  55. package/dist-client/modeller/part-edits.d.ts +29 -4
  56. package/dist-client/modeller/part-edits.js +61 -9
  57. package/dist-client/modeller/part-edits.js.map +1 -1
  58. package/dist-client/modeller/pose-defaults.d.ts +9 -0
  59. package/dist-client/modeller/pose-defaults.js +35 -0
  60. package/dist-client/modeller/pose-defaults.js.map +1 -0
  61. package/dist-client/modeller/proposal.d.ts +1 -1
  62. package/dist-client/modeller/proposal.js +8 -3
  63. package/dist-client/modeller/proposal.js.map +1 -1
  64. package/dist-client/modeller/scene-budget.d.ts +31 -34
  65. package/dist-client/modeller/scene-budget.js +28 -37
  66. package/dist-client/modeller/scene-budget.js.map +1 -1
  67. package/dist-client/pages/figure-list-page.js +4 -9
  68. package/dist-client/pages/figure-list-page.js.map +1 -1
  69. package/dist-client/pages/figure-modeller-page.d.ts +50 -0
  70. package/dist-client/pages/figure-modeller-page.js +141 -42
  71. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  72. package/dist-client/tsconfig.tsbuildinfo +1 -1
  73. package/dist-client/types.d.ts +2 -0
  74. package/dist-client/types.js.map +1 -1
  75. package/package.json +5 -5
  76. package/test/ai-proposal-contract.test.ts +16 -11
  77. package/test/angle-dial.test.ts +78 -0
  78. package/test/base-box.test.ts +165 -0
  79. package/test/expect.ts +2 -0
  80. package/test/i18n-prefix-guard.test.ts +17 -0
  81. package/test/joint-edits.test.ts +37 -2
  82. package/test/joint-gizmo.test.ts +174 -0
  83. package/test/joint-needle.test.ts +162 -0
  84. package/test/part-edits.test.ts +100 -0
  85. package/test/pose-defaults.test.ts +71 -0
  86. package/test/scene-budget.test.ts +82 -0
  87. package/translations/en.json +16 -9
  88. package/translations/ja.json +16 -9
  89. package/translations/ko.json +16 -9
  90. package/translations/ms.json +16 -9
  91. package/translations/zh.json +16 -9
@@ -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,70 @@
1
+ /*
2
+ * Copyright © HatioLab Inc. All rights reserved.
3
+ */
4
+
5
+ /**
6
+ * 막대 옆에 서는 **각도 눈**. 지금 각이 범위의 어디인지를 한 눈에 보인다.
7
+ *
8
+ * ## 왜 3D 로는 모자랐나
9
+ *
10
+ * 관절이 돌아도 화면이 그대로인 경우가 있다 — 제 축에서 대칭인 부품이 그렇다. 그래서 관절이
11
+ * 모는 부품에 바늘을 달아 미리보기에서 함께 돌게 했는데(`joint-gizmo.ts` 의 `jointNeedles`),
12
+ * **미리보기에서는 그 바늘이 4 × 2 픽셀이다**(잰 값: 부품 100mm · 바늘 60 × 12mm · 여덟 대를
13
+ * 한 화면에 세운 상태). 미리보기는 크기 반응을 보려고 여덟 대를 나란히 놓는 자리라 한 대가
14
+ * 20 픽셀쯤이고, 그 위의 어떤 표시도 몇 픽셀을 넘지 못한다.
15
+ *
16
+ * 각을 읽는 일은 3D 의 배율에서 떼어 놓는다. 눈은 막대 옆 24 픽셀 자리에 서고, 도형이 아무리
17
+ * 작게 서 있어도 같은 크기로 읽힌다.
18
+ */
19
+
20
+ export interface DialShape {
21
+ /** 가동 범위를 채운 부채꼴. SVG path 의 `d`. */
22
+ sector: string
23
+ /** 지금 값이 가리키는 끝점. */
24
+ needle: { x: number; y: number }
25
+ centre: { x: number; y: number }
26
+ radius: number
27
+ }
28
+
29
+ /** 0 도가 위를 가리키고, 값이 커지면 시계 방향으로 돈다 — 화면의 각과 같은 방향이다. */
30
+ function at(centre: { x: number; y: number }, radius: number, degrees: number) {
31
+ const rad = (degrees * Math.PI) / 180
32
+ return {
33
+ x: +(centre.x + radius * Math.sin(rad)).toFixed(2),
34
+ y: +(centre.y - radius * Math.cos(rad)).toFixed(2)
35
+ }
36
+ }
37
+
38
+ /** 한 바퀴에 가까우면 부채꼴 대신 온전한 원이다. */
39
+ const FULL = 359.9
40
+
41
+ /**
42
+ * 눈의 모양. 값이 범위를 벗어나면 범위 끝에 세운다 — 밖을 가리키는 바늘은 없는 값을 말한다.
43
+ */
44
+ export function dialShape(min: number, max: number, value: number, radius = 9): DialShape {
45
+ const centre = { x: radius + 1, y: radius + 1 }
46
+ const span = Math.abs(max - min)
47
+ const held = Math.min(Math.max(value, Math.min(min, max)), Math.max(min, max))
48
+
49
+ if (span >= FULL) {
50
+ const top = at(centre, radius, 0)
51
+ const bottom = at(centre, radius, 180)
52
+ return {
53
+ sector: `M ${top.x} ${top.y} A ${radius} ${radius} 0 1 1 ${bottom.x} ${bottom.y} A ${radius} ${radius} 0 1 1 ${top.x} ${top.y} Z`,
54
+ needle: at(centre, radius, held),
55
+ centre,
56
+ radius
57
+ }
58
+ }
59
+
60
+ const from = at(centre, radius, min)
61
+ const to = at(centre, radius, max)
62
+ const large = span > 180 ? 1 : 0
63
+
64
+ return {
65
+ sector: `M ${centre.x} ${centre.y} L ${from.x} ${from.y} A ${radius} ${radius} 0 ${large} 1 ${to.x} ${to.y} Z`,
66
+ needle: at(centre, radius, held),
67
+ centre,
68
+ radius
69
+ }
70
+ }
@@ -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
+ }
@@ -130,6 +130,18 @@ function freeName(taken: readonly { name: string }[], stem: string): string {
130
130
  for (let n = 2; ; n++) if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`
131
131
  }
132
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
+
133
145
  @customElement('figure-animations')
134
146
  export class FigureAnimations extends localize(i18next)(LitElement) {
135
147
  static styles = [
@@ -225,6 +237,21 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
225
237
  min-width: 3.2em;
226
238
  color: var(--md-sys-color-on-surface-variant);
227
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
+ }
228
255
 
229
256
  /* ── keyframe ─────────────────────────────────────────────────── */
230
257
 
@@ -341,6 +368,9 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
341
368
  @state() private open = ''
342
369
  @state() private recipeTarget = ''
343
370
 
371
+ /** 단위를 손으로 적는 중인 파라미터. 목록에 없는 말을 적으려고 연 칸이다. */
372
+ @state() private typingUnit = new Set<number>()
373
+
344
374
  private get clips(): Clip[] {
345
375
  return this.draft?.animations ?? []
346
376
  }
@@ -542,14 +572,7 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
542
572
  aria-label="max"
543
573
  @change=${(e: Event) => this.setRange(at, { max: Number((e.target as HTMLInputElement).value) || 0 })}
544
574
  />
545
- <input
546
- unit
547
- .value=${live(range.unit ?? '')}
548
- placeholder="mm"
549
- aria-label="unit"
550
- title=${i18next.t('figure.text.unit-is-a-word-not-a-rule')}
551
- @change=${(e: Event) => this.setRange(at, { unit: (e.target as HTMLInputElement).value.trim() })}
552
- />
575
+ ${this.renderUnit(at, range.unit ?? '')}
553
576
  </div>
554
577
  ${backwards ? html`<p warn>${i18next.t('figure.text.range-needs-room')}</p>` : nothing}
555
578
 
@@ -876,25 +899,25 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
876
899
  }
877
900
 
878
901
  /**
879
- * 움직이는 부품 예산.
902
+ * 따로 움직이는 부품 예산.
880
903
  *
881
- * **넘어도 막지 않는다.** 움직이는 부품은 저마다 단독 묶음이라 draw call 이 늘고 프레임마다
882
- * 갱신이 는다 — 그 대가가 수로 보이면 저작자가 아낀다. 랜드마크 설비 하나는 정교해도 되고,
883
- * 그 값이 도면 예산에 드러나면 된다.
904
+ * **넘어도 막지 않는다.** 변환을 지는 부품은 저마다 단독 묶음이라 draw call 이 늘고,
905
+ * 그만큼 화면에 함께 세울 수 있는 대수가 준다 — 그 대가가 수로 보이면 저작자가 아낀다.
906
+ * 랜드마크 설비 하나는 정교해도 되고, 그 값이 도면 예산에 드러나면 된다.
884
907
  */
885
908
  private renderBudget(): TemplateResult {
886
909
  const used = this.movingParts
887
- const over = used > LIMITS.animatedParts
910
+ const over = used > LIMITS.independentParts
888
911
 
889
912
  return html`
890
913
  <section>
891
- <h3>${i18next.t('figure.label.budget-animations')}</h3>
914
+ <h3>${i18next.t('figure.label.budget-independent')}</h3>
892
915
  <div budget ?over=${over}>
893
916
  <strong>${used}</strong>
894
- <span quiet>/ ${LIMITS.animatedParts}</span>
917
+ <span quiet>/ ${LIMITS.independentParts}</span>
895
918
  </div>
896
- <p quiet>${i18next.t('figure.text.animated-parts-cost')}</p>
897
- ${over ? html`<p warn>${i18next.t('figure.text.animated-parts-over')}</p>` : nothing}
919
+ <p quiet>${i18next.t('figure.text.independent-parts-cost')}</p>
920
+ ${over ? html`<p warn>${i18next.t('figure.text.independent-parts-over')}</p>` : nothing}
898
921
  </section>
899
922
  `
900
923
  }
@@ -1009,6 +1032,56 @@ export class FigureAnimations extends localize(i18next)(LitElement) {
1009
1032
  this.patchParameter(at, { name: name.trim() })
1010
1033
  }
1011
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
+
1012
1085
  private setRange(at: number, patch: Partial<Parameter['range']>): void {
1013
1086
  const parameter = this.parameters[at]
1014
1087
  if (!parameter) return
@@ -4,7 +4,7 @@ import { customElement, property, query, state } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import { create, Model } from '@hatiolab/things-scene'
6
6
 
7
- import { gridStep } from './part-edits.js'
7
+ import { gridStep, roundPart } from './part-edits.js'
8
8
  import { DEFAULT_VIEW, sceneLook, WORKBENCH } from './figure-view.js'
9
9
  import type { ViewSettings } from './figure-view.js'
10
10
  import type { FigureDraft, PartModel } from './figure-source.js'
@@ -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
  /*
@@ -521,8 +536,11 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
521
536
  const was = this.parts.find(part => part.name === name)
522
537
  if (!was) return
523
538
 
524
- // 씬 모델 그대로 올려 보낸다. 모델러 페이지가 갖고 있는 것도 씬 모델이라 변환할 것이 없다.
525
- this.tell('part-changed', { name, part: { ...was, ...changed!.state } as PartModel })
539
+ /*
540
+ 모델 그대로 올려 보낸다 모델러 페이지가 갖고 있는 것도 씬 모델이라 변환할 것이 없다.
541
+ 밀리미터로만 맞춘다: 기즈모는 비율을 곱해 크기를 내므로 소수가 그대로 남는다.
542
+ */
543
+ this.tell('part-changed', { name, part: roundPart({ ...was, ...changed!.state } as PartModel) })
526
544
  })
527
545
  }
528
546