@things-factory/figure-ui 10.1.45 → 10.1.47

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 (138) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +150 -79
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +221 -263
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +532 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +126 -10
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +10 -2
  45. package/dist-client/modeller/figure-v3-links.js +134 -79
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +556 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +44 -2
  90. package/dist-client/modeller/v3-links.js +125 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +118 -7
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +98 -5
  135. package/translations/ja.json +100 -7
  136. package/translations/ko.json +98 -5
  137. package/translations/ms.json +100 -7
  138. package/translations/zh.json +100 -7
@@ -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,328 @@
1
+ import { css, html, LitElement } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+
4
+ import { i18next, localize } from '@operato/i18n'
5
+ import { ScrollbarStyles } from '@operato/styles'
6
+
7
+ import { DEFAULT_VIEW, ENVIRONMENTS, SCENE_REFERENCES } from './figure-view.js'
8
+ import type { EnvironmentName, ViewSettings } from './figure-view.js'
9
+
10
+ /**
11
+ * 저작 화면의 **보기 설정**.
12
+ *
13
+ * ## 자산이 아니라 작업대다
14
+ *
15
+ * 여기서 고치는 것은 저장되지 않는다. 조명을 밝게 하고 바닥을 켠 것은 지금 보기 편한
16
+ * 상태일 뿐이다. 그래서 「저장 안 됨」을 화면에 적어 둔다 — 안 적으면 저작자가 조명을
17
+ * 맞춰 두고 그것이 자산에 딸려 간다고 여긴다.
18
+ *
19
+ * ## 값을 들고 있지 않는다
20
+ *
21
+ * 이 패널은 받은 것을 그리고 바뀐 것을 알린다. 정본은 모델러 페이지가 갖는다 — 속성
22
+ * 패널과 같은 방식이다. 여기서 들고 있으면 페이지가 다시 그릴 때 두 벌이 갈린다.
23
+ */
24
+ @customElement('figure-settings')
25
+ export class FigureSettings extends localize(i18next)(LitElement) {
26
+ static styles = [
27
+ ScrollbarStyles,
28
+ css`
29
+ :host {
30
+ display: flex;
31
+ flex-direction: column;
32
+ min-height: 0;
33
+ overflow-y: auto;
34
+ background-color: var(--md-sys-color-surface-container-lowest);
35
+ color: var(--md-sys-color-on-surface);
36
+ font: var(--label-font, inherit);
37
+ font-size: 0.76rem;
38
+ }
39
+
40
+ section {
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: var(--spacing-medium, 8px);
44
+ padding: var(--spacing-large, 12px);
45
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
46
+ }
47
+
48
+ /* 속성 패널의 묶음 제목과 같은 규격이다 — 두 탭이 다른 눈금을 쓰면 따로 논다 */
49
+ h3 {
50
+ margin: 0 0 2px 0;
51
+ font-size: var(--ui-t-micro, 11px);
52
+ font-weight: 700;
53
+ letter-spacing: 0.04em;
54
+ text-transform: uppercase;
55
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
56
+ }
57
+
58
+ /* 저장되지 않는다는 것을 한 번 말해 둔다 */
59
+ p[quiet] {
60
+ margin: 0;
61
+ font-size: 0.72rem;
62
+ line-height: 1.5;
63
+ color: var(--md-sys-color-on-surface-variant);
64
+ }
65
+
66
+ label[row] {
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: space-between;
70
+ gap: var(--spacing-medium, 8px);
71
+ cursor: pointer;
72
+ }
73
+ label[row] span {
74
+ display: flex;
75
+ flex-direction: column;
76
+ }
77
+ label[row] em {
78
+ font-style: normal;
79
+ font-size: 0.7rem;
80
+ color: var(--md-sys-color-on-surface-variant);
81
+ }
82
+
83
+ input[type='checkbox'] {
84
+ flex: none;
85
+ width: 16px;
86
+ height: 16px;
87
+ margin: 0;
88
+ accent-color: var(--md-sys-color-primary);
89
+ cursor: pointer;
90
+ }
91
+
92
+ label[field] {
93
+ display: flex;
94
+ flex-direction: column;
95
+ gap: 4px;
96
+ }
97
+ label[field] > span {
98
+ color: var(--md-sys-color-on-surface-variant);
99
+ }
100
+
101
+ select,
102
+ input[type='number'],
103
+ input[type='color'] {
104
+ width: 100%;
105
+ padding: 5px 6px;
106
+ font: var(--input-field-font, inherit);
107
+ font-size: 0.76rem;
108
+ color: var(--md-sys-color-on-surface);
109
+ background-color: var(--md-sys-color-surface);
110
+ border: 1px solid var(--md-sys-color-outline-variant);
111
+ border-radius: 4px;
112
+ }
113
+ input[type='color'] {
114
+ height: 30px;
115
+ padding: 2px;
116
+ cursor: pointer;
117
+ }
118
+
119
+ /* 값이 옆에 붙어야 끌면서 얼마인지 안다 */
120
+ div[slider] {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: var(--spacing-medium, 8px);
124
+ }
125
+ input[type='range'] {
126
+ flex: 1;
127
+ accent-color: var(--md-sys-color-primary);
128
+ cursor: pointer;
129
+ }
130
+ div[slider] output {
131
+ flex: none;
132
+ width: 2.4em;
133
+ text-align: right;
134
+ font: var(--mono-font, ui-monospace, monospace);
135
+ font-size: 0.72rem;
136
+ color: var(--md-sys-color-on-surface-variant);
137
+ }
138
+
139
+ button[reset] {
140
+ align-self: flex-start;
141
+ padding: 5px 12px;
142
+ font: var(--label-font, inherit);
143
+ font-size: 0.74rem;
144
+ color: var(--md-sys-color-on-surface-variant);
145
+ background: none;
146
+ border: 1px solid var(--md-sys-color-outline-variant);
147
+ border-radius: 4px;
148
+ cursor: pointer;
149
+ }
150
+ button[reset]:hover {
151
+ background-color: var(--md-sys-color-surface-container);
152
+ }
153
+ `
154
+ ]
155
+
156
+ @property({ type: Object }) view: ViewSettings = DEFAULT_VIEW
157
+
158
+ render() {
159
+ const view = this.view ?? DEFAULT_VIEW
160
+
161
+ return html`
162
+ <!--
163
+ 놓이는 도면.
164
+
165
+ 이 묶음이 맨 위인 것은, 나머지가 「어떻게 보이나」인데 이것은 「무엇으로 보고
166
+ 있나」이기 때문이다. 미리보기가 도면에 놓인 모습을 말하는 자리이므로, 어떤 도면을
167
+ 가정하는지가 먼저 정해져야 그 다음이 뜻을 갖는다.
168
+
169
+ 도형 탭의 「배치 기준」과 짝이지 같은 것이 아니다. 저쪽은 이 도형이 어느 면에
170
+ 붙는가(저장된다), 이쪽은 그 면을 가진 도면이 어느 쪽을 기준으로 재는가(저장 안 된다).
171
+ -->
172
+ <section>
173
+ <h3>${i18next.t('figure.label.assumed-drawing')}</h3>
174
+ <p quiet>${i18next.t('figure.text.view-settings-are-not-saved')}</p>
175
+
176
+ <label field>
177
+ <span>${i18next.t('figure.label.scene-reference')}</span>
178
+ <select
179
+ .value=${view.placement}
180
+ @change=${(e: Event) =>
181
+ this.put({ placement: (e.target as HTMLSelectElement).value as ViewSettings['placement'] })}
182
+ >
183
+ ${SCENE_REFERENCES.map(
184
+ name => html`<option value=${name} ?selected=${view.placement === name}>
185
+ ${i18next.t(`figure.label.scene-reference-${name}`)}
186
+ </option>`
187
+ )}
188
+ </select>
189
+ </label>
190
+ <p quiet>${i18next.t('figure.text.scene-reference-explained')}</p>
191
+
192
+ <label field>
193
+ <span>${i18next.t('figure.label.ceiling-height')}</span>
194
+ <input
195
+ type="number"
196
+ min="0"
197
+ step="100"
198
+ .value=${String(view.ceilingHeight)}
199
+ @change=${(e: Event) => this.put({ ceilingHeight: Number((e.target as HTMLInputElement).value) || 0 })}
200
+ />
201
+ </label>
202
+ <p quiet>${i18next.t('figure.text.ceiling-height-explained')}</p>
203
+ </section>
204
+
205
+ <section>
206
+ <h3>${i18next.t('figure.label.guides')}</h3>
207
+
208
+ ${this.toggle('grid', view.grid, i18next.t('figure.label.coordinate-ground'), i18next.t('figure.text.coordinate-ground-explained'))}
209
+ ${this.toggle('gridLabels', view.gridLabels, i18next.t('figure.label.axis-labels'))}
210
+ ${this.toggle('floor', view.floor, i18next.t('figure.label.scene-floor'), i18next.t('figure.text.floor-explained'))}
211
+ </section>
212
+
213
+ <section>
214
+ <h3>${i18next.t('figure.label.environment')}</h3>
215
+
216
+ <label field>
217
+ <span>${i18next.t('figure.label.preset')}</span>
218
+ <select
219
+ .value=${view.environment}
220
+ @change=${(e: Event) => this.put({ environment: (e.target as HTMLSelectElement).value as EnvironmentName })}
221
+ >
222
+ ${ENVIRONMENTS.map(
223
+ name => html`<option value=${name} ?selected=${view.environment === name}>
224
+ ${i18next.t(`figure.label.environment-${name}`)}
225
+ </option>`
226
+ )}
227
+ </select>
228
+ </label>
229
+ <p quiet>${i18next.t('figure.text.environment-explained')}</p>
230
+
231
+ <label field>
232
+ <span>${i18next.t('figure.label.sky-color')}</span>
233
+ <input
234
+ type="color"
235
+ .value=${view.skyColor}
236
+ @change=${(e: Event) => this.put({ skyColor: (e.target as HTMLInputElement).value })}
237
+ />
238
+ </label>
239
+ </section>
240
+
241
+ <section>
242
+ <h3>${i18next.t('figure.label.key-light')}</h3>
243
+
244
+ ${this.slider('dirLightIntensity', view.dirLightIntensity, i18next.t('figure.label.intensity'), 0, 5, 0.1)}
245
+ ${this.toggle(
246
+ 'dirLightFollowCamera',
247
+ view.dirLightFollowCamera,
248
+ i18next.t('figure.label.follow-camera'),
249
+ i18next.t('figure.text.follow-camera-explained')
250
+ )}
251
+ ${this.toggle('dirShadowEnabled', view.dirShadowEnabled, i18next.t('figure.label.cast-shadow'))}
252
+ ${view.dirLightIntensity === 0 && view.hemiIntensity === 0
253
+ ? html`<p quiet>${i18next.t('figure.text.all-lights-off')}</p>`
254
+ : ''}
255
+ </section>
256
+
257
+ <section>
258
+ <h3>${i18next.t('figure.label.hemisphere-light')}</h3>
259
+
260
+ ${this.slider('hemiIntensity', view.hemiIntensity, i18next.t('figure.label.intensity'), 0, 8, 0.1)}
261
+ <p quiet>${i18next.t('figure.text.hemisphere-light-explained')}</p>
262
+ </section>
263
+
264
+ <section>
265
+ <button reset @click=${() => this.reset()}>${i18next.t('figure.button.reset-view')}</button>
266
+ </section>
267
+ `
268
+ }
269
+
270
+ private toggle(key: keyof ViewSettings, on: boolean, label: string, hint?: string) {
271
+ return html`
272
+ <label row>
273
+ <span>
274
+ ${label}${hint ? html`<em>${hint}</em>` : ''}
275
+ </span>
276
+ <input
277
+ type="checkbox"
278
+ .checked=${on}
279
+ @change=${(e: Event) => this.put({ [key]: (e.target as HTMLInputElement).checked } as Partial<ViewSettings>)}
280
+ />
281
+ </label>
282
+ `
283
+ }
284
+
285
+ private slider(key: keyof ViewSettings, value: number, label: string, min: number, max: number, step: number) {
286
+ return html`
287
+ <label field>
288
+ <span>${label}</span>
289
+ <div slider>
290
+ <input
291
+ type="range"
292
+ min=${min}
293
+ max=${max}
294
+ step=${step}
295
+ .value=${String(value)}
296
+ @input=${(e: Event) =>
297
+ this.put({ [key]: Number((e.target as HTMLInputElement).value) } as Partial<ViewSettings>)}
298
+ />
299
+ <output>${value.toFixed(1)}</output>
300
+ </div>
301
+ </label>
302
+ `
303
+ }
304
+
305
+ /** 처음 값으로 되돌린다. 만지다 길을 잃었을 때 돌아올 자리가 있어야 한다. */
306
+ private reset() {
307
+ this.tell({ ...DEFAULT_VIEW })
308
+ }
309
+
310
+ private put(patch: Partial<ViewSettings>) {
311
+ this.tell({ ...(this.view ?? DEFAULT_VIEW), ...patch })
312
+ }
313
+
314
+ /**
315
+ * 바뀐 것을 알린다.
316
+ *
317
+ * **알리기 전에 내 것도 바꿔 둔다.** Lit 은 속성을 다음 렌더에 넣으므로, 같은 프레임에
318
+ * 두 번 고치면 두 번째가 `this.view` 의 옛 값을 읽어 첫 번째를 덮는다. 실제로 그랬다 —
319
+ * 축 가이드를 끄고 바닥판을 켰더니 축 가이드가 도로 켜져 있었고, 화면의 체크 상자와
320
+ * 씬의 상태가 서로 다른 말을 했다.
321
+ *
322
+ * 정본은 여전히 모델러 페이지가 갖는다. 여기 값은 다음 렌더에 같은 것으로 덮인다.
323
+ */
324
+ private tell(view: ViewSettings) {
325
+ this.view = view
326
+ this.dispatchEvent(new CustomEvent('update-view', { detail: { view }, bubbles: true, composed: true }))
327
+ }
328
+ }
@@ -0,0 +1,137 @@
1
+ import { css, html, LitElement, nothing } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { live } from 'lit/directives/live.js'
4
+
5
+ import { i18next, localize } from '@operato/i18n'
6
+ import { v3BindingsOf } from '@hatiolab/figure-model'
7
+ import type { FigureCapability, V3Asset } from '@hatiolab/figure-model'
8
+ import { SidePanelStyles } from './side-panel-styles.js'
9
+
10
+ /*
11
+ One rung per set of the format's capability names. Typed as the format's names, so a misspelt rung does not pass
12
+ (ADR-0051 ④, the V2 ladder's lesson).
13
+ */
14
+ type Rung = 'none' | 'hold' | 'flow'
15
+ const RUNGS: Readonly<Record<Rung, readonly FigureCapability[]>> = Object.freeze({
16
+ none: [],
17
+ hold: ['Capacity', 'Holdable'],
18
+ flow: ['FlowNode']
19
+ })
20
+
21
+ /** Which rung a set of names is, or null when it is none of them -- not invented. */
22
+ export function rungOf(names: readonly string[] | undefined): Rung | null {
23
+ const has = new Set(names ?? [])
24
+ if (has.size === 0) return 'none'
25
+ for (const [rung, wanted] of Object.entries(RUNGS) as [Rung, readonly FigureCapability[]][])
26
+ if (wanted.length === has.size && wanted.every(name => has.has(name))) return rung
27
+ return null
28
+ }
29
+
30
+ /**
31
+ * 자리·능력 — what the figure does on a board (V2's 「능력」 tab, parity row ⑤): the ladder from drawing only, to
32
+ * holding things, to passing them on, and every place things are held, whatever part it is on. A place is not drawn,
33
+ * so the list is the only way to find one; choosing a row selects its part, where the place is edited.
34
+ */
35
+ @customElement('figure-v3-capability')
36
+ export class FigureV3Capability extends localize(i18next)(LitElement) {
37
+ static styles = [SidePanelStyles, css`
38
+ :host {
39
+ display: block;
40
+ }
41
+ label[rung] {
42
+ display: flex;
43
+ gap: 8px;
44
+ align-items: flex-start;
45
+ padding: 8px;
46
+ border: 1px solid var(--md-sys-color-outline-variant);
47
+ border-radius: 8px;
48
+ cursor: pointer;
49
+ }
50
+ label[rung][on] {
51
+ border-color: var(--md-sys-color-primary);
52
+ background: var(--md-sys-color-secondary-container);
53
+ color: var(--md-sys-color-on-secondary-container);
54
+ }
55
+ label[rung] span {
56
+ display: grid;
57
+ gap: 2px;
58
+ }
59
+ label[rung] em,
60
+ p[hint],
61
+ small {
62
+ font-style: normal;
63
+ margin: 0;
64
+ color: var(--md-sys-color-on-surface-variant);
65
+ }
66
+ button[place] {
67
+ display: grid;
68
+ gap: 2px;
69
+ text-align: left;
70
+ padding: 6px 8px;
71
+ font: inherit;
72
+ color: inherit;
73
+ border: 1px solid var(--md-sys-color-outline-variant);
74
+ border-radius: 8px;
75
+ background: none;
76
+ cursor: pointer;
77
+ }
78
+ button[place][on] {
79
+ border-color: var(--md-sys-color-primary);
80
+ }
81
+ code {
82
+ font-size: 0.9em;
83
+ color: var(--md-sys-color-on-surface-variant);
84
+ }
85
+ `]
86
+
87
+ @property({ attribute: false }) asset?: V3Asset
88
+ /** The selected part, whose places are marked. */
89
+ @property() selected = ''
90
+ @property({ type: Boolean }) locked = false
91
+
92
+ render() {
93
+ const asset = this.asset
94
+ if (!asset) return nothing
95
+ const names = asset.document.capabilities.names ?? []
96
+ const rung = rungOf(names)
97
+ const places = v3BindingsOf(asset)
98
+ return html`<section>
99
+ <h3>${i18next.t('figure.label.what-this-does')}</h3>
100
+ ${(['none', 'hold', 'flow'] as const).map(
101
+ which => html`<label rung ?on=${rung === which}>
102
+ <input type="radio" name="rung" .checked=${live(rung === which)} ?disabled=${this.locked} @change=${() => this.setRung(which)} />
103
+ <span>
104
+ <strong>${i18next.t(`figure.label.rung-${which}`)}</strong>
105
+ <em>${i18next.t(`figure.text.rung-${which}-note`)}</em>
106
+ </span>
107
+ </label>`
108
+ )}
109
+ ${rung === null ? html`<p hint>${i18next.t('figure.text.capability-hand-written')}</p>` : nothing}
110
+ ${names.length ? html`<code>${names.join(' · ')}</code>` : nothing}
111
+ </section>
112
+ <section>
113
+ <h3>${i18next.t('figure.label.places')}</h3>
114
+ ${places.length
115
+ ? places.map(
116
+ one => html`<button place ?on=${one.part === this.selected} @click=${() => one.part && this.pick(one.part)}>
117
+ <strong>${one.label}</strong>
118
+ <small>
119
+ ${i18next.t('figure.label.place-' + one.role)} · ${one.part ?? '?'}${one.face ? ' · ' + i18next.t('figure.label.face-' + one.face) : ''} ·
120
+ ${one.accepts.join(', ')} · ${i18next.t('figure.label.place-holds', { count: one.capacity })}
121
+ </small>
122
+ </button>`
123
+ )
124
+ : html`<p hint>${i18next.t('figure.text.no-places')}</p>`}
125
+ ${places.length && rung === 'none' ? html`<p hint>${i18next.t('figure.text.place-needs-holding')}</p>` : nothing}
126
+ </section>
127
+ `
128
+ }
129
+
130
+ private setRung(which: Rung) {
131
+ this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit: { kind: 'set-capabilities', names: [...RUNGS[which]] } }, bubbles: true, composed: true }))
132
+ }
133
+
134
+ private pick(part: string) {
135
+ this.dispatchEvent(new CustomEvent('select-part', { detail: { id: part }, bubbles: true, composed: true }))
136
+ }
137
+ }