@things-factory/figure-ui 10.1.3

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 (112) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/graphql/index.ts +209 -0
  3. package/client/index.ts +2 -0
  4. package/client/modeller/figure-ask.ts +247 -0
  5. package/client/modeller/figure-canvas.ts +482 -0
  6. package/client/modeller/figure-inspector.ts +1054 -0
  7. package/client/modeller/figure-parts.ts +479 -0
  8. package/client/modeller/figure-preview.ts +381 -0
  9. package/client/modeller/figure-report.ts +895 -0
  10. package/client/modeller/figure-settings.ts +285 -0
  11. package/client/modeller/figure-side.ts +162 -0
  12. package/client/modeller/figure-source.test.ts +336 -0
  13. package/client/modeller/figure-source.ts +338 -0
  14. package/client/modeller/figure-view.ts +79 -0
  15. package/client/modeller/maturity.ts +288 -0
  16. package/client/modeller/part-edits.test.ts +252 -0
  17. package/client/modeller/part-edits.ts +246 -0
  18. package/client/modeller/proposal.ts +142 -0
  19. package/client/modeller/scene-budget.ts +102 -0
  20. package/client/pages/figure-list-page.ts +274 -0
  21. package/client/pages/figure-modeller-page.ts +1067 -0
  22. package/client/route.ts +11 -0
  23. package/client/themes/brand.css +51 -0
  24. package/client/themes/dark.css +64 -0
  25. package/client/themes/light.css +95 -0
  26. package/client/tsconfig.json +19 -0
  27. package/client/types.ts +96 -0
  28. package/client/viewparts/figure-card.ts +226 -0
  29. package/dist-client/bootstrap.d.ts +14 -0
  30. package/dist-client/bootstrap.js +17 -0
  31. package/dist-client/bootstrap.js.map +1 -0
  32. package/dist-client/graphql/index.d.ts +41 -0
  33. package/dist-client/graphql/index.js +173 -0
  34. package/dist-client/graphql/index.js.map +1 -0
  35. package/dist-client/index.d.ts +2 -0
  36. package/dist-client/index.js +3 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/modeller/figure-ask.d.ts +36 -0
  39. package/dist-client/modeller/figure-ask.js +256 -0
  40. package/dist-client/modeller/figure-ask.js.map +1 -0
  41. package/dist-client/modeller/figure-canvas.d.ts +128 -0
  42. package/dist-client/modeller/figure-canvas.js +431 -0
  43. package/dist-client/modeller/figure-canvas.js.map +1 -0
  44. package/dist-client/modeller/figure-inspector.d.ts +149 -0
  45. package/dist-client/modeller/figure-inspector.js +991 -0
  46. package/dist-client/modeller/figure-inspector.js.map +1 -0
  47. package/dist-client/modeller/figure-parts.d.ts +64 -0
  48. package/dist-client/modeller/figure-parts.js +478 -0
  49. package/dist-client/modeller/figure-parts.js.map +1 -0
  50. package/dist-client/modeller/figure-preview.d.ts +62 -0
  51. package/dist-client/modeller/figure-preview.js +360 -0
  52. package/dist-client/modeller/figure-preview.js.map +1 -0
  53. package/dist-client/modeller/figure-report.d.ts +134 -0
  54. package/dist-client/modeller/figure-report.js +881 -0
  55. package/dist-client/modeller/figure-report.js.map +1 -0
  56. package/dist-client/modeller/figure-settings.d.ts +39 -0
  57. package/dist-client/modeller/figure-settings.js +277 -0
  58. package/dist-client/modeller/figure-settings.js.map +1 -0
  59. package/dist-client/modeller/figure-side.d.ts +41 -0
  60. package/dist-client/modeller/figure-side.js +175 -0
  61. package/dist-client/modeller/figure-side.js.map +1 -0
  62. package/dist-client/modeller/figure-source.d.ts +196 -0
  63. package/dist-client/modeller/figure-source.js +153 -0
  64. package/dist-client/modeller/figure-source.js.map +1 -0
  65. package/dist-client/modeller/figure-view.d.ts +57 -0
  66. package/dist-client/modeller/figure-view.js +36 -0
  67. package/dist-client/modeller/figure-view.js.map +1 -0
  68. package/dist-client/modeller/maturity.d.ts +55 -0
  69. package/dist-client/modeller/maturity.js +212 -0
  70. package/dist-client/modeller/maturity.js.map +1 -0
  71. package/dist-client/modeller/part-edits.d.ts +64 -0
  72. package/dist-client/modeller/part-edits.js +218 -0
  73. package/dist-client/modeller/part-edits.js.map +1 -0
  74. package/dist-client/modeller/proposal.d.ts +45 -0
  75. package/dist-client/modeller/proposal.js +96 -0
  76. package/dist-client/modeller/proposal.js.map +1 -0
  77. package/dist-client/modeller/scene-budget.d.ts +78 -0
  78. package/dist-client/modeller/scene-budget.js +80 -0
  79. package/dist-client/modeller/scene-budget.js.map +1 -0
  80. package/dist-client/pages/figure-list-page.d.ts +40 -0
  81. package/dist-client/pages/figure-list-page.js +280 -0
  82. package/dist-client/pages/figure-list-page.js.map +1 -0
  83. package/dist-client/pages/figure-modeller-page.d.ts +219 -0
  84. package/dist-client/pages/figure-modeller-page.js +1045 -0
  85. package/dist-client/pages/figure-modeller-page.js.map +1 -0
  86. package/dist-client/route.d.ts +1 -0
  87. package/dist-client/route.js +11 -0
  88. package/dist-client/route.js.map +1 -0
  89. package/dist-client/themes/brand.css +51 -0
  90. package/dist-client/themes/dark.css +64 -0
  91. package/dist-client/themes/light.css +95 -0
  92. package/dist-client/types.d.ts +93 -0
  93. package/dist-client/types.js +2 -0
  94. package/dist-client/types.js.map +1 -0
  95. package/dist-client/viewparts/figure-card.d.ts +24 -0
  96. package/dist-client/viewparts/figure-card.js +221 -0
  97. package/dist-client/viewparts/figure-card.js.map +1 -0
  98. package/dist-server/index.d.ts +7 -0
  99. package/dist-server/index.js +3 -0
  100. package/dist-server/index.js.map +1 -0
  101. package/package.json +41 -0
  102. package/server/index.ts +7 -0
  103. package/server/tsconfig.json +18 -0
  104. package/test/brand-palette.test.ts +188 -0
  105. package/test/heading-scale-guard.test.ts +144 -0
  106. package/test/resolve-ts-siblings.mjs +67 -0
  107. package/things-factory.config.js +11 -0
  108. package/translations/en.json +247 -0
  109. package/translations/ja.json +247 -0
  110. package/translations/ko.json +247 -0
  111. package/translations/ms.json +247 -0
  112. package/translations/zh.json +247 -0
@@ -0,0 +1,991 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import { css, html, LitElement, nothing, svg } from 'lit';
3
+ import { customElement, property } from 'lit/decorators.js';
4
+ import { i18next, localize } from '@operato/i18n';
5
+ import { ScrollbarStyles } from '@operato/styles';
6
+ import { EXTRUDE_PRIMITIVES, MATERIAL_PRESETS, PRIMITIVE_KINDS, SEGMENTED_PRIMITIVES, SEGMENT_PRESETS, SIZING_RULES, trianglesOf } from '@hatiolab/figure-model';
7
+ import { activePalette } from '@hatiolab/things-scene';
8
+ import { partToFigure, partFromFigure } from './figure-source.js';
9
+ import { canMirror } from './part-edits.js';
10
+ /**
11
+ * 고른 부품의 속성.
12
+ *
13
+ * ## 여기가 사용자 용어로 보여 주는 자리다
14
+ *
15
+ * 모델러 페이지가 갖고 있는 것은 **씬 모델**이다 — 보드 위에 부품 컴포넌트가 놓인 상태.
16
+ * 그런데 사용자에게 보여야 하는 것은 씬 속성 이름(`left`·`top`·`zPos`)이 아니라
17
+ * **X·Y·Z** 다. 다른 3D 도구가 모두 그렇게 부르고, 위치도 부품의 **중심**으로 읽는다.
18
+ *
19
+ * 그래서 이 패널은 받은 부품을 FigureSource 형태로 **변환해서 보여 주고, 수정한 것을
20
+ * 다시 씬 모델로 되돌려 알린다.** 변환은 `figure-source.ts` 가 한다 — 여기서 축을 다시 계산하지
21
+ * 않는다.
22
+ *
23
+ * ## 있는 것만 보여 준다
24
+ *
25
+ * 분할 수는 곡면이 있는 도형에만, 모서리 굴림은 단면을 뽑는 도형에만 뜻이 있다.
26
+ * 뜻 없는 칸을 회색으로 늘어놓으면 저작자가 「왜 안 먹지」를 고민하게 된다 —
27
+ * 아예 내지 않는다.
28
+ *
29
+ * ## 이름은 저장되는 식별자다
30
+ *
31
+ * `FigurePart.name` 은 바인딩·애니메이션·슬롯이 가리키는 이름이다. 고치면 배치된
32
+ * 인스턴스의 바인딩이 **조용히** 끊긴다. 그래서 이름 칸에만 경고를 붙인다.
33
+ *
34
+ * ## 「상태 색이 붙는 자리」를 걷었다
35
+ *
36
+ * `FigurePart.materialSlot`(당시 이름은 `slot`) 을 고르는 칸이 있었는데 **아무 일도 하지 않았다.** things-scene 의
37
+ * figure 런타임이 `slot` 을 읽지 않는다 — 선언 한 줄이 전부다. 되는 줄 알고 만들었고,
38
+ * 확인하지 않은 것이 잘못이다.
39
+ *
40
+ * 그리고 되게 만들더라도 이 모양이 아니다. 상태 색은 특별한 것이 아니라 **속성 하나**이고,
41
+ * 툴팁·라벨·표시 여부와 같은 자리에서 다뤄져야 한다. 그 설계가 정해지면(figure-model#2)
42
+ * 이 자리에 **속성 선언과 부품 바인딩** 편집기가 들어온다.
43
+ *
44
+ * 그때까지 빈 약속을 두지 않는다. 아무 일도 안 하는 칸은 없느니만 못하다.
45
+ *
46
+ * ## 고치지 않는다
47
+ *
48
+ * 정본 한 벌은 저작면이 갖는다. 여기서는 바뀐 부품을 알릴 뿐이다.
49
+ */
50
+ let FigureInspector = class FigureInspector extends localize(i18next)(LitElement) {
51
+ constructor() {
52
+ super(...arguments);
53
+ /** 보드 위의 부품들. 씬 모델 그대로다. */
54
+ this.parts = [];
55
+ this.selected = -1;
56
+ }
57
+ static { this.styles = [
58
+ ScrollbarStyles,
59
+ css `
60
+ :host {
61
+ display: flex;
62
+ flex-direction: column;
63
+ min-height: 0;
64
+ overflow-y: auto;
65
+ background-color: var(--md-sys-color-surface-container-lowest);
66
+ color: var(--md-sys-color-on-surface);
67
+ font: var(--label-font, inherit);
68
+ /*
69
+ 이 패널의 본문 크기를 여기서 한 번 정한다.
70
+
71
+ 전에는 규칙마다 따로 적었다. 그러면 빠뜨린 규칙이 본문 기본 크기(1rem)를
72
+ 물려받아 그 줄만 제목처럼 커진다. 오류가 아니라서 화면을 띄워 봐야 안다.
73
+ 이 파일에서만 두 번 났다.
74
+
75
+ form 요소(input · select · button)는 글꼴을 물려받지 않으므로 각자 적어야 한다.
76
+ 그것 말고는 여기 값을 따른다.
77
+ */
78
+ font-size: 0.76rem;
79
+ }
80
+
81
+ section {
82
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
83
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
84
+ }
85
+ h3 {
86
+ margin: 0 0 6px 0;
87
+ font-size: var(--ui-t-micro, 11px);
88
+ font-weight: 700;
89
+ letter-spacing: 0.04em;
90
+ text-transform: uppercase;
91
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
92
+ }
93
+
94
+ label {
95
+ display: block;
96
+ margin-bottom: 2px;
97
+ font-size: 0.68rem;
98
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
99
+ }
100
+
101
+ input,
102
+ select {
103
+ width: 100%;
104
+ box-sizing: border-box;
105
+ padding: 4px 7px;
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-container-lowest);
110
+ border: 1px solid var(--md-sys-color-outline-variant);
111
+ border-radius: 6px;
112
+ }
113
+ input:focus,
114
+ select:focus {
115
+ outline: 2px solid var(--md-sys-color-primary);
116
+ outline-offset: -1px;
117
+ }
118
+
119
+ /*
120
+ 숫자 칸 — 라벨을 칸 **안** 왼쪽에 둔다.
121
+
122
+ 벡터 저작도구(Sketch·Figma)가 X·Y·W·H 를 이렇게 둔다. 라벨을 위에 얹으면 줄마다
123
+ 두 줄을 먹어서, 자리·크기·회전 아홉 칸이 화면 절반을 차지한다. 안에 두면 한 줄에
124
+ 셋이 들어가고 무엇이 무엇인지도 붙어 읽힌다.
125
+ */
126
+ div[cell] {
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 2px;
130
+ padding-left: 6px;
131
+ background-color: var(--md-sys-color-surface-container-lowest);
132
+ border: 1px solid var(--md-sys-color-outline-variant);
133
+ border-radius: 6px;
134
+ }
135
+ div[cell]:focus-within {
136
+ outline: 2px solid var(--md-sys-color-primary);
137
+ outline-offset: -1px;
138
+ border-color: transparent;
139
+ }
140
+ div[cell] span[axis] {
141
+ flex: none;
142
+ font-size: 0.64rem;
143
+ letter-spacing: 0.02em;
144
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
145
+ }
146
+ div[cell] input {
147
+ min-width: 0;
148
+ padding: 4px 6px 4px 2px;
149
+ font-family: var(--mono-font, monospace);
150
+ text-align: right;
151
+ background: none;
152
+ border: none;
153
+ border-radius: 0;
154
+ }
155
+ div[cell] input:focus {
156
+ outline: none;
157
+ }
158
+
159
+ /* 축 셋을 한 줄에 — 좌표는 셋이 같이 읽혀야 한다 */
160
+ div[triple] {
161
+ display: grid;
162
+ grid-template-columns: repeat(3, 1fr);
163
+ gap: 4px;
164
+ }
165
+ div[field] + div[field],
166
+ div[triple] + div[field],
167
+ div[field] + div[triple] {
168
+ margin-top: 7px;
169
+ }
170
+
171
+ /*
172
+ 정밀 제작 도구.
173
+
174
+ 보드 편집기는 자산을 배치하는 곳이라 대충 놓아도 되지만, 여기는 자산을 만드는
175
+ 곳이다. 「몸통 한가운데」·「바닥에 붙이기」·「반대쪽에도 하나」를 손으로 계산하면
176
+ 늘 조금씩 어긋나고, 기준 치수를 바꾸면 전부 다시 해야 한다.
177
+ */
178
+ div[tools] {
179
+ display: flex;
180
+ flex-wrap: wrap;
181
+ gap: 3px;
182
+ margin-top: 6px;
183
+ }
184
+ div[tools] button {
185
+ padding: 3px 8px;
186
+ font: var(--label-font, inherit);
187
+ font-size: 0.7rem;
188
+ color: var(--md-sys-color-on-surface);
189
+ background: none;
190
+ border: 1px solid var(--md-sys-color-outline-variant);
191
+ border-radius: 6px;
192
+ cursor: pointer;
193
+ }
194
+ div[tools] button[disabled] {
195
+ opacity: 0.35;
196
+ cursor: default;
197
+ }
198
+ div[tools] button:hover:not([disabled]) {
199
+ color: var(--md-sys-color-on-primary-container);
200
+ background-color: var(--md-sys-color-primary-container);
201
+ border-color: transparent;
202
+ }
203
+ div[tools] span[sep] {
204
+ width: 1px;
205
+ margin: 2px 3px;
206
+ background-color: var(--md-sys-color-outline-variant);
207
+ }
208
+
209
+ /* 지금 어느 축이 늘어나나 — 크기를 고치면 바뀔 수 있어서 값으로 적는다 */
210
+ p[axis-now] {
211
+ margin: 4px 0 0 0;
212
+ font-size: 0.7rem;
213
+ color: var(--md-sys-color-primary);
214
+ }
215
+ /* 이 선택이 무엇을 무는지 — 성숙도의 묶음 수에 그대로 잡힌다 */
216
+ p[cost] {
217
+ margin: 4px 0 0 0;
218
+ font-size: 0.68rem;
219
+ line-height: 1.5;
220
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
221
+ }
222
+
223
+ /* 이름을 바꾸면 무엇이 끊기는지 말한다 */
224
+ p[warn] {
225
+ margin: 4px 0 0 0;
226
+ font-size: 0.68rem;
227
+ line-height: 1.5;
228
+ color: var(--md-sys-color-tertiary);
229
+ }
230
+
231
+ /* 분할 수 프리셋 — 자유 입력보다 이 셋을 권한다 */
232
+ div[presets] {
233
+ display: flex;
234
+ gap: 4px;
235
+ margin-top: 4px;
236
+ }
237
+ div[presets] button {
238
+ flex: 1;
239
+ padding: 3px 0;
240
+ font: var(--label-font, inherit);
241
+ font-size: 0.72rem;
242
+ color: var(--md-sys-color-on-surface);
243
+ background: none;
244
+ border: 1px solid var(--md-sys-color-outline-variant);
245
+ border-radius: 6px;
246
+ cursor: pointer;
247
+ }
248
+ div[presets] button[on] {
249
+ color: var(--md-sys-color-on-primary-container);
250
+ background-color: var(--md-sys-color-primary-container);
251
+ border-color: transparent;
252
+ }
253
+
254
+ /*
255
+ 분할 수 선택 — 모양과 삼각형 수를 **함께** 보여 준다.
256
+
257
+ 숫자만 늘어놓으면 8 과 16 이 무엇이 다른지 알 수 없다. 로우폴리에서 이 선택이
258
+ 곧 「몇 개까지 편하게 놓을 수 있나」를 정하므로, 각지는 정도와 그 값이 무는
259
+ 삼각형 수를 한자리에서 보아야 한다.
260
+
261
+ 실루엣은 지어낸 그림이 아니다 — 곡면 프리미티브를 위에서 본 윤곽이 정확히
262
+ 정n각형이다. 삼각형 수는 화면이 셈하지 않고 figure-model 의 trianglesOf 가 낸다.
263
+ */
264
+ div[segments] {
265
+ display: grid;
266
+ grid-template-columns: repeat(3, 1fr);
267
+ gap: 4px;
268
+ }
269
+ /* 원 유지 · 끄기 — 고르는 값이라 분할 수와 같은 모양으로 둔다 */
270
+ div[choices] {
271
+ display: grid;
272
+ grid-template-columns: 1fr 1fr;
273
+ gap: 4px;
274
+ }
275
+ button[choice] {
276
+ display: flex;
277
+ flex-direction: column;
278
+ align-items: flex-start;
279
+ gap: 1px;
280
+ padding: 6px 8px;
281
+ text-align: left;
282
+ font: var(--label-font, inherit);
283
+ color: var(--md-sys-color-on-surface);
284
+ background: none;
285
+ border: 1px solid var(--md-sys-color-outline-variant);
286
+ border-radius: 6px;
287
+ cursor: pointer;
288
+ }
289
+ button[choice]:hover:not([on]) {
290
+ background-color: var(--md-sys-color-surface-container);
291
+ }
292
+ button[choice][on] {
293
+ color: var(--md-sys-color-on-primary-container);
294
+ background-color: var(--md-sys-color-primary-container);
295
+ border-color: transparent;
296
+ }
297
+ button[choice] small {
298
+ opacity: 0.75;
299
+ }
300
+ button[seg] {
301
+ display: flex;
302
+ flex-direction: column;
303
+ align-items: center;
304
+ gap: 1px;
305
+ padding: 6px 2px 4px 2px;
306
+ font: var(--label-font, inherit);
307
+ color: var(--md-sys-color-on-surface);
308
+ background: none;
309
+ border: 1px solid var(--md-sys-color-outline-variant);
310
+ border-radius: 6px;
311
+ cursor: pointer;
312
+ }
313
+ button[seg]:hover:not([on]) {
314
+ background-color: var(--md-sys-color-surface-container);
315
+ }
316
+ button[seg][on] {
317
+ color: var(--md-sys-color-on-primary-container);
318
+ background-color: var(--md-sys-color-primary-container);
319
+ border-color: transparent;
320
+ }
321
+ button[seg] svg {
322
+ display: block;
323
+ /* 작으면 12 와 16 이 둘 다 동그라미로 보인다 — 각이 읽힐 만큼은 키운다 */
324
+ width: 40px;
325
+ height: 40px;
326
+ }
327
+ button[seg] svg polygon {
328
+ fill: var(--md-sys-color-surface-container-highest);
329
+ stroke: currentColor;
330
+ stroke-width: 1.1;
331
+ stroke-linejoin: miter;
332
+ }
333
+ button[seg][on] svg polygon {
334
+ fill: var(--md-sys-color-surface-container-lowest);
335
+ }
336
+ button[seg] b {
337
+ font-size: 0.8rem;
338
+ font-weight: 700;
339
+ line-height: 1.1;
340
+ }
341
+ button[seg] small {
342
+ font-size: 0.62rem;
343
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
344
+ }
345
+ button[seg][on] small {
346
+ color: inherit;
347
+ }
348
+ /* 프리셋 밖의 값도 쓸 수 있다. 다만 권하는 쪽을 크게 둔다 */
349
+ details[free] {
350
+ margin-top: 6px;
351
+ }
352
+ details[free] summary {
353
+ font-size: 0.68rem;
354
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
355
+ cursor: pointer;
356
+ }
357
+ details[free] div[cell] {
358
+ margin-top: 4px;
359
+ }
360
+
361
+ /*
362
+ 색 견본.
363
+
364
+ 토큰을 글자로 받지 않는다. things-scene 의 팔레트 선언이 「여기 든 이름이 저작에서
365
+ 고를 수 있는 전부이고, 자유색을 허용하지 않는 것이 감각적인 로우폴리의 첫 규칙」
366
+ 이라고 못박고 있다. 글자 칸으로 두면 그 규칙이 화면에서 사라지고, 오타 하나로
367
+ resolveToken 이 던진다.
368
+ */
369
+ div[swatches] {
370
+ display: flex;
371
+ flex-wrap: wrap;
372
+ gap: 6px;
373
+ }
374
+ /*
375
+ 두 무리의 경계. 「칠한 색」과 「켜진 색」이 한 줄에 섞이면 accent(주황)와
376
+ status-warning(주황)이 나란히 놓여 무엇이 무엇인지 알 수 없다.
377
+ */
378
+ label[group] {
379
+ margin-top: 8px;
380
+ padding-top: 8px;
381
+ font-size: var(--ui-t-micro, 11px);
382
+ letter-spacing: 0.03em;
383
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
384
+ border-top: 1px solid var(--md-sys-color-outline-variant);
385
+ }
386
+
387
+ /* 값이 옆에 붙어야 끌면서 얼마인지 안다 */
388
+ div[slider] {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 8px;
392
+ }
393
+ div[slider] input[type='range'] {
394
+ flex: 1;
395
+ accent-color: var(--md-sys-color-primary);
396
+ cursor: pointer;
397
+ }
398
+ div[slider] output {
399
+ flex: none;
400
+ width: 2.6em;
401
+ text-align: right;
402
+ font: var(--mono-font, ui-monospace, monospace);
403
+ font-size: 0.72rem;
404
+ color: var(--md-sys-color-on-surface-variant);
405
+ }
406
+ button[swatch] {
407
+ width: 26px;
408
+ height: 26px;
409
+ padding: 0;
410
+ background-color: var(--swatch);
411
+ border: 1px solid rgba(0, 0, 0, 0.16);
412
+ border-radius: 50%;
413
+ cursor: pointer;
414
+ }
415
+ button[swatch][on] {
416
+ /* 고른 것은 테두리가 아니라 고리로 표시한다 — 색 자체를 가리지 않으려고 */
417
+ box-shadow:
418
+ 0 0 0 2px var(--md-sys-color-surface-container-lowest),
419
+ 0 0 0 4px var(--md-sys-color-primary);
420
+ }
421
+ span[token] {
422
+ display: block;
423
+ margin-top: 6px;
424
+ font-family: var(--mono-font, monospace);
425
+ font-size: 0.68rem;
426
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
427
+ }
428
+
429
+ label[toggle] {
430
+ display: flex;
431
+ align-items: center;
432
+ gap: 6px;
433
+ margin: 7px 0 0 0;
434
+ font-size: 0.74rem;
435
+ color: var(--md-sys-color-on-surface);
436
+ cursor: pointer;
437
+ }
438
+ label[toggle] input {
439
+ width: auto;
440
+ }
441
+
442
+ p[quiet] {
443
+ margin: 0;
444
+ font-size: 0.76rem;
445
+ line-height: 1.6;
446
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
447
+ }
448
+ `
449
+ ]; }
450
+ /** `part` 는 HTMLElement 의 속성(DOMTokenList)이다 — 가리면 형이 어긋난다. */
451
+ get chosen() {
452
+ return this.parts?.[this.selected];
453
+ }
454
+ render() {
455
+ const part = this.chosen;
456
+ if (!part || !this.board) {
457
+ return html `
458
+ <section>
459
+ <h3>${i18next.t('label.properties')}</h3>
460
+ <p quiet>${i18next.t('text.select-a-part-to-edit')}</p>
461
+ </section>
462
+ `;
463
+ }
464
+ // 여기부터는 사용자 용어다 — X·Y·Z, 위치는 부품 중심, 회전은 도(degree).
465
+ const shown = partToFigure(part, this.board);
466
+ return html `
467
+ ${this.renderIdentity(shown)} ${this.renderPlacement(shown)} ${this.renderShape(shown)}
468
+ ${this.renderMaterial(shown)} ${this.renderBehaviour(shown)}
469
+ `;
470
+ }
471
+ renderIdentity(part) {
472
+ return html `
473
+ <section>
474
+ <h3>${i18next.t('label.part')}</h3>
475
+ <div field>
476
+ <label>${i18next.t('label.name')}</label>
477
+ <input
478
+ .value=${part.name}
479
+ @change=${(e) => this.put({ name: e.target.value })}
480
+ />
481
+ <p warn>${i18next.t('text.renaming-a-part-breaks-bindings')}</p>
482
+ </div>
483
+ <div field>
484
+ <label>${i18next.t('label.shape')}</label>
485
+ <select @change=${(e) => this.changePrimitive(e.target.value)}>
486
+ ${PRIMITIVE_KINDS.map(kind => html `
487
+ <option value=${kind} ?selected=${kind === part.primitive}>
488
+ ${i18next.t('label.primitive-' + kind)}
489
+ </option>
490
+ `)}
491
+ </select>
492
+ </div>
493
+ </section>
494
+ `;
495
+ }
496
+ renderPlacement(part) {
497
+ const t = part.transform;
498
+ const rotation = t.rotation ?? {};
499
+ return html `
500
+ <section>
501
+ <h3>${i18next.t('label.placement')}</h3>
502
+
503
+ <label>${i18next.t('label.position')}</label>
504
+ <div triple>
505
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.position[axis], value => this.putTransform({ position: { ...t.position, [axis]: value } })))}
506
+ </div>
507
+
508
+ <div field>
509
+ <label>${i18next.t('label.size')}</label>
510
+ <div triple>
511
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), t.size[axis], value => this.putTransform({ size: { ...t.size, [axis]: value } }), 1))}
512
+ </div>
513
+ </div>
514
+
515
+ <div field>
516
+ <label>${i18next.t('label.tools')}</label>
517
+ <div tools>
518
+ <button title=${i18next.t('text.center-on-x')} @click=${() => this.ask('center', 'x')}>
519
+ ${i18next.t('button.center-x')}
520
+ </button>
521
+ <button title=${i18next.t('text.center-on-z')} @click=${() => this.ask('center', 'z')}>
522
+ ${i18next.t('button.center-z')}
523
+ </button>
524
+ <button title=${i18next.t('text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
525
+ ${i18next.t('button.to-floor')}
526
+ </button>
527
+ <span sep></span>
528
+ ${['x', 'z'].map(axis => {
529
+ // 이미 가운데면 대칭이 제자리다 — 겹친 부품이 조용히 생기지 않게 막는다
530
+ const able = this.board ? canMirror(this.board, this.parts, this.selected, axis) : false;
531
+ return html `
532
+ <button
533
+ ?disabled=${!able}
534
+ title=${i18next.t(able ? 'text.mirror-explained' : 'text.mirror-would-overlap')}
535
+ @click=${() => this.ask('mirror', axis)}
536
+ >
537
+ ${i18next.t('button.mirror-' + axis)}
538
+ </button>
539
+ `;
540
+ })}
541
+ <span sep></span>
542
+ <button title=${i18next.t('text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
543
+ ${i18next.t('button.snap')}
544
+ </button>
545
+ </div>
546
+ </div>
547
+
548
+ <div field>
549
+ <label>${i18next.t('label.rotation-degrees')}</label>
550
+ <div triple>
551
+ ${['x', 'y', 'z'].map(axis => this.number(axis.toUpperCase(), rotation[axis] ?? 0, value => this.putTransform({ rotation: { ...rotation, [axis]: value } })))}
552
+ </div>
553
+ </div>
554
+ </section>
555
+ `;
556
+ }
557
+ /** 분할 수와 모서리 굴림 — 해당하는 도형에만 낸다. */
558
+ renderShape(part) {
559
+ const segmented = SEGMENTED_PRIMITIVES.includes(part.primitive);
560
+ const extruded = EXTRUDE_PRIMITIVES.includes(part.primitive);
561
+ if (!segmented && !extruded)
562
+ return nothing;
563
+ return html `
564
+ <section>
565
+ <h3>${i18next.t('label.shape-detail')}</h3>
566
+ ${segmented ? this.renderSegments(part) : nothing}
567
+ ${segmented ? this.renderKeepRound(part) : nothing}
568
+ ${extruded ? this.renderExtruded(part) : nothing}
569
+ </section>
570
+ `;
571
+ }
572
+ /**
573
+ * 민 단면 — 모서리와 **속을 파는 두께.**
574
+ *
575
+ * 벽 두께를 0 으로 두면 통짜다. 0 보다 크면 그만큼 안으로 들어온 테가 생기고, 그것이
576
+ * 트레이 · 토트 · 통 · 케이스가 되는 자리다. 바닥은 비워 두면 벽과 같고, 0 으로 두면
577
+ * 위아래가 다 뚫린 테두리가 된다.
578
+ */
579
+ renderExtruded(part) {
580
+ const hollow = part.shape?.hollow;
581
+ const wall = hollow?.wall ?? 0;
582
+ const reshape = (over) => this.put({ shape: { ...(part.shape ?? {}), ...over } });
583
+ return html `
584
+ <div field>
585
+ <label>${i18next.t('label.corner-round')}</label>
586
+ ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
587
+ </div>
588
+ <div field>
589
+ <label>${i18next.t('label.hollow-wall')}</label>
590
+ ${this.number('', wall, value => reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined }))}
591
+ </div>
592
+ ${wall > 0
593
+ ? html `
594
+ <div field>
595
+ <label>${i18next.t('label.hollow-floor')}</label>
596
+ ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
597
+ </div>
598
+ `
599
+ : nothing}
600
+ `;
601
+ }
602
+ /**
603
+ * 분할 수 — 모양과 무게를 함께 고른다.
604
+ *
605
+ * 삼각형 수는 화면이 셈하지 않는다. `trianglesOf` 에 그 분할 수를 가진 부품을 주고
606
+ * 물어본다 — 화면이 제 나름의 공식을 들면 figure-model 이 규칙을 바꿔도 화면만
607
+ * 옛 답을 계속 낸다.
608
+ */
609
+ /**
610
+ * 원통을 원통으로 둘까 — **고르는 값이고, 값을 치른다.**
611
+ *
612
+ * 인스턴스가 축마다 다른 배율을 받으면 원기둥이 타원 기둥이 된다. 지키려면 제 변환이
613
+ * 있어야 하고, 합친 형상은 변환을 하나만 받으므로 지키는 곡면 부품 하나가 draw call
614
+ * 하나다. 저작자가 그 대가를 모르고 고르면 안 되므로 그 자리에 적는다.
615
+ */
616
+ renderKeepRound(part) {
617
+ const on = part.keepRound !== false;
618
+ return html `
619
+ <div field>
620
+ <label>${i18next.t('label.keep-round')}</label>
621
+ <div choices>
622
+ <button choice ?on=${on} @click=${() => this.put({ keepRound: undefined })}>
623
+ <b>${i18next.t('text.keep-round-on')}</b>
624
+ <small>${i18next.t('text.keep-round-cost')}</small>
625
+ </button>
626
+ <button choice ?on=${!on} @click=${() => this.put({ keepRound: false })}>
627
+ <b>${i18next.t('text.keep-round-off')}</b>
628
+ <small>${i18next.t('text.keep-round-merged')}</small>
629
+ </button>
630
+ </div>
631
+ </div>
632
+ `;
633
+ }
634
+ renderSegments(part) {
635
+ const current = part.segments ?? SEGMENT_PRESETS[1];
636
+ return html `
637
+ <div field>
638
+ <label>${i18next.t('label.segments')}</label>
639
+ <div segments>
640
+ ${SEGMENT_PRESETS.map(preset => {
641
+ const triangles = trianglesOf({ ...part, segments: preset });
642
+ return html `
643
+ <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
644
+ ${this.silhouette(preset)}
645
+ <b>${preset}</b>
646
+ <small>${i18next.t('text.n-triangles', { count: triangles })}</small>
647
+ </button>
648
+ `;
649
+ })}
650
+ </div>
651
+
652
+ <details free>
653
+ <summary>${i18next.t('text.other-segment-count')}</summary>
654
+ ${this.number('', current, value => this.put({ segments: value }), 3)}
655
+ </details>
656
+ </div>
657
+ `;
658
+ }
659
+ /**
660
+ * 분할 수의 실루엣.
661
+ *
662
+ * 곡면 프리미티브를 위에서 본 윤곽이 정확히 정n각형이다 — 원기둥의 단면도, 구의
663
+ * 적도도. 그림을 지어내는 것이 아니라 실제로 만들어지는 모양이다.
664
+ */
665
+ silhouette(segments) {
666
+ const points = [];
667
+ for (let i = 0; i < segments; i++) {
668
+ // 꼭짓점 하나가 위로 오게 -90도에서 시작한다
669
+ const angle = -Math.PI / 2 + (i * 2 * Math.PI) / segments;
670
+ points.push(`${(16 + 13 * Math.cos(angle)).toFixed(2)},${(16 + 13 * Math.sin(angle)).toFixed(2)}`);
671
+ }
672
+ return svg `
673
+ <svg viewBox="0 0 32 32" aria-hidden="true">
674
+ <polygon points=${points.join(' ')}></polygon>
675
+ </svg>
676
+ `;
677
+ }
678
+ /** 견본 한 줄. 무리마다 부른다. */
679
+ swatches(entries, chosen) {
680
+ return entries.map(([token, color]) => html `
681
+ <button
682
+ swatch
683
+ ?on=${token === chosen}
684
+ title=${token}
685
+ style="--swatch:${color}"
686
+ @click=${() => this.putMaterial({ token })}
687
+ ></button>
688
+ `);
689
+ }
690
+ renderMaterial(part) {
691
+ const material = part.material ?? {};
692
+ const palette = activePalette();
693
+ /*
694
+ 두 무리로 나눠 보인다 — **칠한 색**과 **켜진 색**.
695
+
696
+ `palette.status-*` 는 램프 · 표시등에 쓰라고 있는 색이다. 한 줄에 섞어 두면
697
+ `accent`(주황)와 `status-warning`(주황)이 나란히 놓여 무엇이 무엇인지 알 수
698
+ 없다. 값을 비틀어 벌리는 대신 **고르는 자리에서 경계를 긋는다.**
699
+ */
700
+ const entries = Object.entries(palette);
701
+ const painted = entries.filter(([token]) => !token.startsWith('palette.status-'));
702
+ const lit = entries.filter(([token]) => token.startsWith('palette.status-'));
703
+ /** 자체발광. 없으면 안 빛나는 부품이다. */
704
+ const glow = material.emissive;
705
+ return html `
706
+ <section>
707
+ <h3>${i18next.t('label.material')}</h3>
708
+
709
+ <div field>
710
+ <label>${i18next.t('label.palette-token')}</label>
711
+ <div swatches>
712
+ ${this.swatches(painted, material.token)}
713
+ </div>
714
+ ${lit.length
715
+ ? html `
716
+ <label group>${i18next.t('label.status-colors')}</label>
717
+ <div swatches>
718
+ ${this.swatches(lit, material.token)}
719
+ </div>
720
+ `
721
+ : ''}
722
+ <span token>${material.token ?? i18next.t('label.no-color-chosen')}</span>
723
+ </div>
724
+
725
+ <div field>
726
+ <label>${i18next.t('label.preset')}</label>
727
+ <select
728
+ @change=${(e) => {
729
+ const value = e.target.value;
730
+ this.putMaterial({ preset: (value || undefined) });
731
+ }}
732
+ >
733
+ <option value="" ?selected=${!material.preset}>${i18next.t('label.none')}</option>
734
+ ${MATERIAL_PRESETS.map(preset => html `
735
+ <option value=${preset} ?selected=${preset === material.preset}>
736
+ ${i18next.t('label.preset-' + preset)}
737
+ </option>
738
+ `)}
739
+ </select>
740
+ </div>
741
+
742
+ <!-- 값이 없으면 각지게 그린다 — 형식이 그렇게 못 박았다. 체크상자도 그대로 보인다 -->
743
+ <label toggle>
744
+ <input
745
+ type="checkbox"
746
+ .checked=${material.flatShading ?? true}
747
+ @change=${(e) => this.putMaterial({ flatShading: e.target.checked })}
748
+ />
749
+ ${i18next.t('label.flat-shading')}
750
+ </label>
751
+ <label toggle>
752
+ <input
753
+ type="checkbox"
754
+ .checked=${!!material.transparent}
755
+ @change=${(e) => this.putMaterial({ transparent: e.target.checked })}
756
+ />
757
+ ${i18next.t('label.transparent')}
758
+ </label>
759
+
760
+ <div field>
761
+ <label>${i18next.t('label.emissive')}</label>
762
+ <p cost>${i18next.t('text.emissive-explained')}</p>
763
+
764
+ <div slider>
765
+ <input
766
+ type="range"
767
+ min="0"
768
+ max="1"
769
+ step="0.05"
770
+ .value=${String(glow?.intensity ?? 0)}
771
+ @input=${(e) => this.putEmissive(Number(e.target.value))}
772
+ />
773
+ <output>${(glow?.intensity ?? 0).toFixed(2)}</output>
774
+ </div>
775
+ </div>
776
+
777
+ ${glow
778
+ ? html `
779
+ <div field>
780
+ <label>${i18next.t('label.emissive-color')}</label>
781
+ <div swatches>
782
+ <button
783
+ swatch
784
+ same
785
+ ?on=${!glow.token}
786
+ title=${i18next.t('label.same-as-off')}
787
+ style="--swatch:${(material.token && palette[material.token]) || palette['palette.neutral']}"
788
+ @click=${() => this.putMaterial({ emissive: { ...glow, token: undefined } })}
789
+ ></button>
790
+ ${lit.map(([token, color]) => html `
791
+ <button
792
+ swatch
793
+ ?on=${token === glow.token}
794
+ title=${token}
795
+ style="--swatch:${color}"
796
+ @click=${() => this.putMaterial({ emissive: { ...glow, token } })}
797
+ ></button>
798
+ `)}
799
+ </div>
800
+ <span token>${glow.token ?? i18next.t('label.same-as-off')}</span>
801
+ </div>
802
+
803
+ <label toggle>
804
+ <input
805
+ type="checkbox"
806
+ .checked=${!!glow.on}
807
+ @change=${(e) => this.putMaterial({ emissive: { ...glow, on: e.target.checked } })}
808
+ />
809
+ ${i18next.t('label.on-by-default')}
810
+ </label>
811
+ <p cost>${i18next.t('text.on-by-default-explained')}</p>
812
+ `
813
+ : ''}
814
+
815
+ </section>
816
+ `;
817
+ }
818
+ /**
819
+ * 인스턴스 크기가 바뀔 때 이 부품이 어떻게 변하나.
820
+ *
821
+ * ## 늘어나는 축은 아직 못 보여 준다
822
+ *
823
+ * `stretch` 는 형식에 축을 적는 자리가 없어서 그리는 쪽이 **가장 길게 뻗은 축**으로
824
+ * 정한다. 그 답을 화면에 적어 주려면 규칙을 가진 쪽에 물어야 하는데, 그 함수
825
+ * (`stretchAxisOf` · `stretchAxis`)가 아직 배포된 판에 없다.
826
+ *
827
+ * 화면이 제 나름대로 계산하지 않는다. 규칙이 두 벌이 되면 보이는 축과 실제로 늘어나는
828
+ * 축이 갈리고, 그 어긋남은 화면을 믿게 만든 다음에 드러난다. 배포되면 잇는다.
829
+ *
830
+ * ## 값이 있는 것을 밝힌다
831
+ *
832
+ * `scale` 이 아닌 부품은 합쳐 그릴 수 없어 draw call 을 하나 더 쓴다. 그 비용이
833
+ * 성숙도의 묶음 수에 그대로 잡힌다. 고르는 자리에서 미리 말해 준다.
834
+ */
835
+ renderBehaviour(part) {
836
+ const sizing = part.sizing ?? 'scale';
837
+ const repeat = part.repeat ?? { axis: 'x', pitch: 0 };
838
+ return html `
839
+ <section>
840
+ <h3>${i18next.t('label.sizing-rule')}</h3>
841
+ <select @change=${(e) => this.put({ sizing: e.target.value })}>
842
+ ${SIZING_RULES.map(rule => html `
843
+ <option value=${rule} ?selected=${rule === sizing}>${i18next.t('label.sizing-' + rule)}</option>
844
+ `)}
845
+ </select>
846
+ <p warn>${i18next.t('text.sizing-' + sizing + '-explained')}</p>
847
+
848
+ ${sizing === 'scale'
849
+ ? nothing
850
+ : html `<p cost>${i18next.t('text.sizing-costs-one-draw-call')}</p>`}
851
+ ${sizing === 'repeat'
852
+ ? html `<p cost>${i18next.t('text.repeat-edits-the-unit')}</p>`
853
+ : nothing}
854
+
855
+ ${sizing === 'repeat'
856
+ ? html `
857
+ <div field>
858
+ <label>${i18next.t('label.repeat-axis')}</label>
859
+ <select
860
+ @change=${(e) => this.put({ repeat: { ...repeat, axis: e.target.value } })}
861
+ >
862
+ ${['x', 'y', 'z'].map(axis => html `<option value=${axis} ?selected=${axis === repeat.axis}>${axis}</option>`)}
863
+ </select>
864
+ </div>
865
+ <div field>
866
+ <label>${i18next.t('label.repeat-pitch')}</label>
867
+ ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
868
+ <p cost>${i18next.t('text.repeat-pitch-explained')}</p>
869
+ </div>
870
+ `
871
+ : nothing}
872
+ </section>
873
+ `;
874
+ }
875
+ /**
876
+ * 숫자 칸.
877
+ *
878
+ * 빈 칸이나 글자를 넣었을 때 0 으로 바꿔치지 않는다 — 저작자가 지우는 중일 수 있고,
879
+ * 조용히 0 이 되면 부품이 사라진 것처럼 보인다. 숫자가 아니면 그냥 안 받는다.
880
+ */
881
+ number(label, value, set, min) {
882
+ return html `
883
+ <div cell>
884
+ ${label ? html `<span axis>${label}</span>` : nothing}
885
+ <input
886
+ type="number"
887
+ .value=${String(value)}
888
+ min=${min ?? nothing}
889
+ @change=${(e) => {
890
+ const raw = e.target.value;
891
+ const parsed = Number(raw);
892
+ if (raw === '' || !Number.isFinite(parsed)) {
893
+ // 되돌려 놓는다. 안 그러면 화면과 정본이 갈린다.
894
+ ;
895
+ e.target.value = String(value);
896
+ return;
897
+ }
898
+ set(parsed);
899
+ }}
900
+ />
901
+ </div>
902
+ `;
903
+ }
904
+ /**
905
+ * 도형을 바꾼다.
906
+ *
907
+ * 도형이 바뀌면 그 도형에 뜻 없는 값이 남는다. 남겨 두면 검증이 「무시됨」 위반을
908
+ * 내므로 여기서 걷는다.
909
+ */
910
+ changePrimitive(primitive) {
911
+ this.change(part => {
912
+ const next = { ...part, primitive };
913
+ if (!SEGMENTED_PRIMITIVES.includes(primitive))
914
+ delete next.segments;
915
+ if (!EXTRUDE_PRIMITIVES.includes(primitive))
916
+ delete next.shape;
917
+ if (SEGMENTED_PRIMITIVES.includes(primitive) && next.segments === undefined) {
918
+ next.segments = SEGMENT_PRESETS[1];
919
+ }
920
+ return next;
921
+ });
922
+ }
923
+ put(patch) {
924
+ this.change(part => ({ ...part, ...patch }));
925
+ }
926
+ putTransform(patch) {
927
+ this.change(part => ({ ...part, transform: { ...part.transform, ...patch } }));
928
+ }
929
+ /**
930
+ * 발광 세기를 고친다. **0 이면 블록을 통째로 지운다.**
931
+ *
932
+ * 세기 0 인 발광을 남겨 두면 「안 빛나는데 램프인 것」이 되어, 재질 키가 갈려 묶음이
933
+ * 헛되이 하나 는다. 0 은 「램프가 아니다」로 읽는 것이 맞다.
934
+ *
935
+ * `Number(value) || undefined` 를 쓰면 안 된다 — 0 이 falsy 라 변경으로 안 잡혀 옛
936
+ * 값이 그대로 남는다. 실제로 램프가 안 꺼졌다.
937
+ */
938
+ putEmissive(intensity) {
939
+ // `chosen` 은 씬 모델이다. 형식으로 옮긴 값은 `partToFigure` 이 만든 것이라 여기서 다시 꺼낸다.
940
+ const glow = this.board && this.chosen ? partToFigure(this.chosen, this.board).material?.emissive : undefined;
941
+ this.putMaterial({ emissive: intensity > 0 ? { ...glow, intensity } : undefined });
942
+ }
943
+ putMaterial(patch) {
944
+ this.change(part => ({
945
+ ...part,
946
+ material: { ...(part.material ?? {}), ...patch }
947
+ }));
948
+ }
949
+ /**
950
+ * 수정한 것을 씬 모델로 되돌려 알린다.
951
+ *
952
+ * 사용자는 X·Y·Z 로 수정하고, 모델러 페이지는 씬 모델을 갖는다. 그 사이를 여기서
953
+ * 잇는다 — 변환해서 수정하고 다시 되돌린다. **왕복 변환이 무손실이라야** 위치 하나
954
+ * 고쳤을 뿐인데 회전값이 조금씩 밀리는 일이 없다(`fold.test.ts` 가 이를 확인한다).
955
+ */
956
+ change(edit) {
957
+ const part = this.chosen;
958
+ const board = this.board;
959
+ if (!part || !board)
960
+ return;
961
+ this.emit(partFromFigure(edit(partToFigure(part, board)), board));
962
+ }
963
+ /** 정밀 도구를 시킨다. 정본은 저작면이 갖는다 — 여기서는 알리기만 한다. */
964
+ ask(what, axis) {
965
+ this.dispatchEvent(new CustomEvent('part-tool', {
966
+ detail: { index: this.selected, tool: what, axis },
967
+ bubbles: true,
968
+ composed: true
969
+ }));
970
+ }
971
+ emit(part) {
972
+ this.dispatchEvent(new CustomEvent('update-part', { detail: { index: this.selected, part }, bubbles: true, composed: true }));
973
+ }
974
+ };
975
+ __decorate([
976
+ property({ type: Object }),
977
+ __metadata("design:type", Object)
978
+ ], FigureInspector.prototype, "board", void 0);
979
+ __decorate([
980
+ property({ type: Array }),
981
+ __metadata("design:type", Array)
982
+ ], FigureInspector.prototype, "parts", void 0);
983
+ __decorate([
984
+ property({ type: Number }),
985
+ __metadata("design:type", Object)
986
+ ], FigureInspector.prototype, "selected", void 0);
987
+ FigureInspector = __decorate([
988
+ customElement('figure-inspector')
989
+ ], FigureInspector);
990
+ export { FigureInspector };
991
+ //# sourceMappingURL=figure-inspector.js.map