@things-factory/figure-ui 10.1.6 → 10.1.8

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 (51) hide show
  1. package/client/graphql/index.ts +25 -0
  2. package/client/modeller/figure-animations.ts +769 -0
  3. package/client/modeller/figure-ask.ts +6 -1
  4. package/client/modeller/figure-history-panel.ts +52 -1
  5. package/client/modeller/figure-inspector.ts +520 -120
  6. package/client/modeller/figure-side.ts +65 -17
  7. package/client/modeller/proposal-session.ts +92 -0
  8. package/client/pages/figure-list-page.ts +56 -4
  9. package/client/pages/figure-modeller-page.ts +204 -179
  10. package/client/types.ts +39 -0
  11. package/client/viewparts/figure-thumb.ts +78 -0
  12. package/dist-client/graphql/index.d.ts +3 -1
  13. package/dist-client/graphql/index.js +22 -0
  14. package/dist-client/graphql/index.js.map +1 -1
  15. package/dist-client/modeller/figure-animations.d.ts +96 -0
  16. package/dist-client/modeller/figure-animations.js +708 -0
  17. package/dist-client/modeller/figure-animations.js.map +1 -0
  18. package/dist-client/modeller/figure-ask.d.ts +3 -0
  19. package/dist-client/modeller/figure-ask.js +9 -0
  20. package/dist-client/modeller/figure-ask.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.d.ts +7 -0
  22. package/dist-client/modeller/figure-history-panel.js +50 -0
  23. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  24. package/dist-client/modeller/figure-inspector.d.ts +17 -0
  25. package/dist-client/modeller/figure-inspector.js +506 -107
  26. package/dist-client/modeller/figure-inspector.js.map +1 -1
  27. package/dist-client/modeller/figure-side.d.ts +23 -0
  28. package/dist-client/modeller/figure-side.js +68 -16
  29. package/dist-client/modeller/figure-side.js.map +1 -1
  30. package/dist-client/modeller/proposal-session.d.ts +23 -0
  31. package/dist-client/modeller/proposal-session.js +65 -0
  32. package/dist-client/modeller/proposal-session.js.map +1 -0
  33. package/dist-client/pages/figure-list-page.d.ts +10 -0
  34. package/dist-client/pages/figure-list-page.js +55 -4
  35. package/dist-client/pages/figure-list-page.js.map +1 -1
  36. package/dist-client/pages/figure-modeller-page.d.ts +50 -28
  37. package/dist-client/pages/figure-modeller-page.js +197 -176
  38. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  39. package/dist-client/tsconfig.tsbuildinfo +1 -1
  40. package/dist-client/types.d.ts +44 -0
  41. package/dist-client/types.js.map +1 -1
  42. package/dist-client/viewparts/figure-thumb.js +74 -0
  43. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  44. package/package.json +4 -5
  45. package/test/ai-proposal-contract.test.ts +169 -0
  46. package/test/i18n-prefix-guard.test.ts +42 -0
  47. package/translations/en.json +82 -6
  48. package/translations/ja.json +34 -1
  49. package/translations/ko.json +92 -6
  50. package/translations/ms.json +34 -1
  51. package/translations/zh.json +34 -1
@@ -72,6 +72,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
72
72
  display: flex;
73
73
  flex-direction: column;
74
74
  min-height: 0;
75
+ --figure-control-height: 30px;
75
76
  overflow-y: auto;
76
77
  background-color: var(--md-sys-color-surface-container-lowest);
77
78
  color: var(--md-sys-color-on-surface);
@@ -96,9 +97,39 @@ export class FigureInspector extends localize(i18next)(LitElement) {
96
97
  칸 높이는 건드리지 않는다(작게 만드는 것이 아니라 **빈 곳을 줄이는 것**이다).
97
98
  */
98
99
  section {
99
- padding: 6px 10px;
100
+ padding: 8px 10px;
100
101
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
101
102
  }
103
+ /* 입력 행은 촘촘하게, 서로 다른 편집 의도 사이에는 호흡을 둔다. */
104
+ section + section {
105
+ margin-top: 6px;
106
+ }
107
+ section[collapsible] {
108
+ padding: 0;
109
+ }
110
+ details[group] > summary {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ min-height: 34px;
115
+ padding: 0 10px;
116
+ cursor: pointer;
117
+ list-style: none;
118
+ }
119
+ details[group] > summary::-webkit-details-marker {
120
+ display: none;
121
+ }
122
+ details[group] > summary h3 {
123
+ margin: 0;
124
+ }
125
+ details[group] > summary md-icon {
126
+ --md-icon-size: 16px;
127
+ color: var(--md-sys-color-on-surface-variant);
128
+ }
129
+ details[group] > div[group-content] {
130
+ padding: 4px 10px 8px;
131
+ border-top: 1px solid var(--md-sys-color-outline-variant);
132
+ }
102
133
  h3 {
103
134
  display: flex;
104
135
  align-items: center;
@@ -111,6 +142,42 @@ export class FigureInspector extends localize(i18next)(LitElement) {
111
142
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
112
143
  }
113
144
 
145
+ /* 자산 메타데이터는 부품을 고친다는 현재 문맥에서는 요약으로만 둔다. */
146
+ details[asset] {
147
+ margin: -1px 0;
148
+ }
149
+ details[asset] summary {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 6px;
153
+ min-height: 26px;
154
+ cursor: pointer;
155
+ list-style: none;
156
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
157
+ }
158
+ details[asset] summary::-webkit-details-marker {
159
+ display: none;
160
+ }
161
+ details[asset] summary::before {
162
+ content: '›';
163
+ font-size: 1.1rem;
164
+ line-height: 1;
165
+ transition: transform 120ms ease;
166
+ }
167
+ details[asset][open] summary::before {
168
+ transform: rotate(90deg);
169
+ }
170
+ details[asset] summary b {
171
+ font-size: var(--ui-t-micro, 11px);
172
+ letter-spacing: 0.04em;
173
+ }
174
+ details[asset] summary small {
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ font-size: 0.7rem;
179
+ }
180
+
114
181
  label {
115
182
  display: flex;
116
183
  align-items: center;
@@ -156,6 +223,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
156
223
  input,
157
224
  select {
158
225
  width: 100%;
226
+ min-height: var(--figure-control-height);
159
227
  box-sizing: border-box;
160
228
  padding: 4px 7px;
161
229
  font: var(--input-field-font, inherit);
@@ -180,6 +248,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
180
248
  */
181
249
  div[cell] {
182
250
  display: flex;
251
+ box-sizing: border-box;
252
+ height: var(--figure-control-height);
253
+ min-height: var(--figure-control-height);
183
254
  align-items: center;
184
255
  gap: 2px;
185
256
  padding-left: 6px;
@@ -200,6 +271,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
200
271
  }
201
272
  div[cell] input {
202
273
  min-width: 0;
274
+ min-height: 0;
275
+ height: calc(var(--figure-control-height) - 2px);
203
276
  padding: 4px 6px 4px 2px;
204
277
  font-family: var(--mono-font, monospace);
205
278
  text-align: right;
@@ -219,11 +292,163 @@ export class FigureInspector extends localize(i18next)(LitElement) {
219
292
  }
220
293
  div[field] + div[field],
221
294
  div[triple] + div[field],
222
- div[field] + div[triple] {
295
+ div[field] + div[triple],
296
+ div[inline-field] + div[inline-field],
297
+ div[inline-field] + div[field],
298
+ div[field] + div[inline-field],
299
+ div[vector-row] + div[vector-row],
300
+ div[vector-row] + div[field],
301
+ div[field] + div[vector-row] {
223
302
  /* 칸 사이 7 → 5. 칸 자체는 그대로 두고 사이만 좁힌다. */
224
303
  margin-top: 5px;
225
304
  }
226
305
 
306
+ /* 짧은 라벨과 단일 값은 같은 줄에서 읽는다. 복잡한 선택기에는 쓰지 않는다. */
307
+ div[inline-field] {
308
+ display: grid;
309
+ /* 같은 편집 밀도 안에서는 입력 시작선을 맞춘다. 라벨 길이가 칸 폭을 흔들면 안 된다. */
310
+ grid-template-columns: 64px minmax(0, 1fr);
311
+ align-items: center;
312
+ gap: 7px;
313
+ }
314
+ div[inline-field] > label {
315
+ margin: 0;
316
+ white-space: nowrap;
317
+ }
318
+ /* 한 줄 입력의 설명은 입력칸을 밀어내지 않고 바로 아래 전폭에서 읽는다. */
319
+ div[inline-field] > p[hint] {
320
+ grid-column: 1 / -1;
321
+ margin: -2px 0 1px;
322
+ }
323
+ div[inline-field] > code[fixed] {
324
+ min-width: 0;
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ /* X·Y·Z는 함께 비교하는 값이므로, 묶음 제목만 왼쪽에 두고 입력은 한 행에 둔다. */
331
+ div[vector-row] {
332
+ display: grid;
333
+ grid-template-columns: 64px minmax(0, 1fr);
334
+ align-items: center;
335
+ gap: 7px;
336
+ }
337
+ div[vector-row] > label {
338
+ margin: 0;
339
+ white-space: nowrap;
340
+ }
341
+ div[vector-row] > p[hint] {
342
+ grid-column: 1 / -1;
343
+ margin: -2px 0 1px;
344
+ }
345
+ div[sizing-choices] {
346
+ display: grid;
347
+ grid-template-columns: 1fr 1fr;
348
+ gap: 5px;
349
+ }
350
+ button[sizing-choice] {
351
+ display: flex;
352
+ flex-direction: column;
353
+ align-items: flex-start;
354
+ gap: 2px;
355
+ min-height: 52px;
356
+ padding: 7px 8px;
357
+ color: var(--md-sys-color-on-surface);
358
+ background: var(--md-sys-color-surface-container-lowest);
359
+ border: 1px solid var(--md-sys-color-outline-variant);
360
+ border-radius: 7px;
361
+ text-align: left;
362
+ cursor: pointer;
363
+ }
364
+ button[sizing-choice][on] {
365
+ color: var(--md-sys-color-on-primary-container);
366
+ background: var(--md-sys-color-primary-container);
367
+ border-color: transparent;
368
+ }
369
+ button[sizing-choice] b {
370
+ font-size: 0.74rem;
371
+ }
372
+ button[sizing-choice] small {
373
+ font-size: 0.64rem;
374
+ line-height: 1.35;
375
+ opacity: 0.8;
376
+ }
377
+ div[anchor-rows] {
378
+ display: grid;
379
+ gap: 6px;
380
+ }
381
+ div[anchor-row] {
382
+ display: grid;
383
+ grid-template-columns: 46px minmax(0, 1fr);
384
+ align-items: start;
385
+ gap: 8px;
386
+ padding: 6px 7px;
387
+ background: var(--md-sys-color-surface-container);
388
+ border-radius: 7px;
389
+ }
390
+ div[anchor-row] label {
391
+ margin: 0;
392
+ font-size: 0.7rem;
393
+ font-weight: 700;
394
+ }
395
+ div[anchor-options] {
396
+ display: grid;
397
+ grid-template-columns: repeat(3, minmax(0, 1fr));
398
+ gap: 4px;
399
+ }
400
+ button[anchor-choice] {
401
+ min-width: 0;
402
+ height: var(--figure-control-height);
403
+ box-sizing: border-box;
404
+ padding: 4px 3px;
405
+ overflow: hidden;
406
+ color: var(--md-sys-color-on-surface);
407
+ font: var(--label-font, inherit);
408
+ font-size: 0.63rem;
409
+ line-height: 1.2;
410
+ text-overflow: ellipsis;
411
+ white-space: nowrap;
412
+ background: var(--md-sys-color-surface-container-lowest);
413
+ border: 1px solid var(--md-sys-color-outline-variant);
414
+ border-radius: 5px;
415
+ cursor: pointer;
416
+ }
417
+ button[anchor-choice][on] {
418
+ color: var(--md-sys-color-on-primary-container);
419
+ background: var(--md-sys-color-primary-container);
420
+ border-color: transparent;
421
+ }
422
+ details[anchor-advanced] {
423
+ grid-column: 2;
424
+ margin-top: 4px;
425
+ }
426
+ details[anchor-advanced] summary {
427
+ cursor: pointer;
428
+ font-size: 0.62rem;
429
+ color: var(--md-sys-color-on-surface-variant);
430
+ }
431
+ details[anchor-advanced] div[anchor-options] {
432
+ margin-top: 4px;
433
+ }
434
+ div[palette-meaning] {
435
+ display: grid;
436
+ gap: 2px;
437
+ margin-top: 7px;
438
+ padding: 6px 8px;
439
+ color: var(--md-sys-color-on-surface);
440
+ background: var(--md-sys-color-surface-container);
441
+ border-radius: 7px;
442
+ }
443
+ div[palette-meaning] b {
444
+ font-size: 0.72rem;
445
+ }
446
+ div[palette-meaning] small {
447
+ color: var(--md-sys-color-on-surface-variant);
448
+ font-size: 0.64rem;
449
+ line-height: 1.35;
450
+ }
451
+
227
452
  /*
228
453
  정밀 제작 도구.
229
454
 
@@ -232,13 +457,17 @@ export class FigureInspector extends localize(i18next)(LitElement) {
232
457
  늘 조금씩 어긋나고, 기준 치수를 바꾸면 전부 다시 해야 한다.
233
458
  */
234
459
  div[tools] {
235
- display: flex;
236
- flex-wrap: wrap;
237
- gap: 3px;
238
- margin-top: 6px;
460
+ display: grid;
461
+ grid-template-columns: repeat(6, minmax(0, 1fr));
462
+ gap: 4px;
239
463
  }
240
464
  div[tools] button {
241
- padding: 3px 8px;
465
+ display: grid;
466
+ place-items: center;
467
+ min-width: 0;
468
+ height: var(--figure-control-height);
469
+ box-sizing: border-box;
470
+ padding: 0;
242
471
  font: var(--label-font, inherit);
243
472
  font-size: 0.7rem;
244
473
  color: var(--md-sys-color-on-surface);
@@ -247,6 +476,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
247
476
  border-radius: 6px;
248
477
  cursor: pointer;
249
478
  }
479
+ div[tools] button md-icon {
480
+ font-size: 17px;
481
+ }
250
482
  div[tools] button[disabled] {
251
483
  opacity: 0.35;
252
484
  cursor: default;
@@ -256,11 +488,6 @@ export class FigureInspector extends localize(i18next)(LitElement) {
256
488
  background-color: var(--md-sys-color-primary-container);
257
489
  border-color: transparent;
258
490
  }
259
- div[tools] span[sep] {
260
- width: 1px;
261
- margin: 2px 3px;
262
- background-color: var(--md-sys-color-outline-variant);
263
- }
264
491
 
265
492
  /* 지금 어느 축이 늘어나나 — 크기를 고치면 바뀔 수 있어서 값으로 적는다 */
266
493
  p[axis-now] {
@@ -319,7 +546,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
319
546
  */
320
547
  div[segments] {
321
548
  display: grid;
322
- grid-template-columns: repeat(3, 1fr);
549
+ grid-template-columns: repeat(4, 1fr);
323
550
  gap: 4px;
324
551
  }
325
552
  /* 원 유지 · 끄기 — 고르는 값이라 분할 수와 같은 모양으로 둔다 */
@@ -377,8 +604,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
377
604
  button[seg] svg {
378
605
  display: block;
379
606
  /* 작으면 12 와 16 이 둘 다 동그라미로 보인다 — 각이 읽힐 만큼은 키운다 */
380
- width: 40px;
381
- height: 40px;
607
+ width: 32px;
608
+ height: 32px;
382
609
  }
383
610
  button[seg] svg polygon {
384
611
  fill: var(--md-sys-color-surface-container-highest);
@@ -401,7 +628,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
401
628
  button[seg][on] small {
402
629
  color: inherit;
403
630
  }
404
- /* 프리셋 밖의 값도 있다. 다만 권하는 쪽을 크게 둔다 */
631
+ /* 성능·실루엣 검증을 마친 프리셋만 고른다. */
405
632
  details[free] {
406
633
  margin-top: 6px;
407
634
  }
@@ -494,6 +721,26 @@ export class FigureInspector extends localize(i18next)(LitElement) {
494
721
  label[toggle] input {
495
722
  width: auto;
496
723
  }
724
+ div[role-choices] {
725
+ display: grid;
726
+ grid-template-columns: repeat(3, minmax(0, 1fr));
727
+ gap: 4px;
728
+ }
729
+ div[role-choices] label[toggle] {
730
+ justify-content: center;
731
+ min-height: 30px;
732
+ margin: 0;
733
+ padding: 2px 4px;
734
+ border: 1px solid var(--md-sys-color-outline-variant);
735
+ border-radius: 6px;
736
+ font-size: 0.68rem;
737
+ text-align: center;
738
+ }
739
+ div[role-choices] label[toggle]:has(input:checked) {
740
+ color: var(--md-sys-color-on-primary-container);
741
+ background: var(--md-sys-color-primary-container);
742
+ border-color: transparent;
743
+ }
497
744
 
498
745
  p[quiet] {
499
746
  margin: 0;
@@ -530,6 +777,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
530
777
  /** 보드. 위치를 보드 중심 기준으로 바꾸려면 이것이 있어야 한다. */
531
778
  /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
532
779
  @property({ type: String }) figureName = ''
780
+ /** 분류 하나 — 팔레트에서 묶는 기준. */
781
+ @property({ type: String }) figureCategory?: string
782
+ /** 태그 여럿 — 분류로는 못 하는 찾기. */
783
+ @property({ type: Array }) figureTags?: string[]
533
784
 
534
785
  /** 펴 둔 도움말. 부품을 바꿔도 유지된다 — 매번 다시 펴게 하지 않는다. */
535
786
  @state() private opened = new Set<string>()
@@ -548,7 +799,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
548
799
  const part = this.chosen
549
800
  if (!part || !this.board) {
550
801
  return html`
551
- ${this.renderFigure()}
802
+ ${this.renderFigure(false)}
552
803
  <section>
553
804
  <h3>${i18next.t('figure.label.properties')}</h3>
554
805
  <p quiet>${i18next.t('figure.text.select-a-part-to-edit')}</p>
@@ -567,7 +818,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
567
818
  묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
568
819
  */
569
820
  return html`
570
- ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
821
+ ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
571
822
  ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
572
823
  ${this.renderBehaviour(shown)}
573
824
  `
@@ -593,18 +844,20 @@ export class FigureInspector extends localize(i18next)(LitElement) {
593
844
  <section>
594
845
  <h3>${i18next.t('figure.label.role-in-capability')}${this.help('figure.text.anchors-are-not-drawn')}</h3>
595
846
 
596
- ${(PART_ROLES as readonly PartRole[]).map(
597
- one => html`
598
- <label toggle>
599
- <input
600
- type="checkbox"
601
- .checked=${live(roles.includes(one))}
602
- @change=${(e: Event) => this.toggleRole(one, (e.target as HTMLInputElement).checked)}
603
- />
604
- ${i18next.t('figure.label.role-' + one)}
605
- </label>
606
- `
607
- )}
847
+ <div role-choices>
848
+ ${(PART_ROLES as readonly PartRole[]).map(
849
+ one => html`
850
+ <label toggle>
851
+ <input
852
+ type="checkbox"
853
+ .checked=${live(roles.includes(one))}
854
+ @change=${(e: Event) => this.toggleRole(one, (e.target as HTMLInputElement).checked)}
855
+ />
856
+ ${i18next.t('figure.label.role-' + one)}
857
+ </label>
858
+ `
859
+ )}
860
+ </div>
608
861
  ${roles.length === 0 ? html`<p quiet>${i18next.t('figure.text.role-none-means-drawn')}</p>` : nothing}
609
862
 
610
863
  ${roles.includes('slot')
@@ -691,11 +944,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
691
944
  *
692
945
  * 저장되는 식별자다. 배치된 보드가 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
693
946
  */
694
- private renderFigure() {
695
- return html`
696
- <section>
697
- <h3>${i18next.t('figure.label.figure')}</h3>
698
- <div field>
947
+ private renderFigure(compact = false) {
948
+ const fields = html`
949
+ <div inline-field>
699
950
  <label>${i18next.t('figure.label.name')}</label>
700
951
  <input
701
952
  .value=${this.figureName}
@@ -710,32 +961,99 @@ export class FigureInspector extends localize(i18next)(LitElement) {
710
961
  )}
711
962
  />
712
963
  </div>
713
- <div field>
714
- <label>${i18next.t('figure.label.figure-type')}${this.help('figure.text.type-name-cannot-change')}</label>
964
+ <div inline-field>
965
+ <label>${i18next.t('figure.label.figure-type')}${this.helpButton('figure.text.type-name-cannot-change')}</label>
715
966
  <code fixed title=${i18next.t('figure.text.type-name-cannot-change')}>
716
967
  <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
717
968
  </code>
969
+ ${this.helpHint('figure.text.type-name-cannot-change')}
718
970
  </div>
719
971
  <!--
720
972
  왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
721
973
  알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
722
974
  -->
975
+
976
+ <div inline-field>
977
+ <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
978
+ <input
979
+ .value=${this.figureCategory ?? ''}
980
+ placeholder=${i18next.t('figure.text.category-placeholder')}
981
+ @change=${(e: Event) => this.tellCatalog({ category: (e.target as HTMLInputElement).value.trim() || undefined })}
982
+ />
983
+ ${this.helpHint('figure.text.category-explained')}
984
+ </div>
985
+
986
+ <div inline-field>
987
+ <label>${i18next.t('figure.label.tags')}${this.helpButton('figure.text.tags-explained')}</label>
988
+ <input
989
+ .value=${(this.figureTags ?? []).join(', ')}
990
+ placeholder=${i18next.t('figure.text.tags-placeholder')}
991
+ @change=${(e: Event) => this.tellTags((e.target as HTMLInputElement).value)}
992
+ />
993
+ ${this.helpHint('figure.text.tags-explained')}
994
+ </div>
995
+ `
996
+
997
+ if (compact) {
998
+ return html`
999
+ <section>
1000
+ <details asset>
1001
+ <summary>
1002
+ <b>${i18next.t('figure.label.figure')}</b>
1003
+ <small>${this.figureName || this.board?.figureType || ''}</small>
1004
+ </summary>
1005
+ ${fields}
1006
+ </details>
1007
+ </section>
1008
+ `
1009
+ }
1010
+
1011
+ return html`
1012
+ <section>
1013
+ <h3>${i18next.t('figure.label.figure')}</h3>
1014
+ ${fields}
723
1015
  </section>
724
1016
  `
725
1017
  }
726
1018
 
1019
+ /**
1020
+ * 태그 줄을 목록으로.
1021
+ *
1022
+ * 쉼표로 가르고 `#` 은 떼어 낸다 — 사람은 `#반출` 이라고 적고, 저장되는 것은 `반출` 이다.
1023
+ * 겹친 것은 한 번만 남긴다. 빈 줄은 **빈 배열**이지 `undefined` 가 아니다 — 전부 지우려는
1024
+ * 저작자가 지울 방법을 잃지 않게.
1025
+ */
1026
+ private tellTags(raw: string) {
1027
+ const tags = [
1028
+ ...new Set(
1029
+ raw
1030
+ .split(',')
1031
+ .map(one => one.trim().replace(/^#+/, '').trim())
1032
+ .filter(one => one.length > 0)
1033
+ )
1034
+ ]
1035
+
1036
+ this.tellCatalog({ tags })
1037
+ }
1038
+
1039
+ /** 카탈로그 값이 바뀌었다고 알린다. 정본은 저작면이 든다 — 여기서는 알리기만 한다. */
1040
+ private tellCatalog(patch: { category?: string; tags?: string[] }) {
1041
+ this.dispatchEvent(new CustomEvent('figure-catalog', { detail: patch, bubbles: true, composed: true }))
1042
+ }
1043
+
727
1044
  private renderIdentity(part: FigurePart) {
728
1045
  return html`
729
1046
  <section>
730
1047
  <h3>${i18next.t('figure.label.part')}</h3>
731
- <div field>
732
- <label>${i18next.t('figure.label.name')}${this.help('figure.text.renaming-a-part-breaks-bindings')}</label>
1048
+ <div inline-field>
1049
+ <label>${i18next.t('figure.label.name')}${this.helpButton('figure.text.renaming-a-part-breaks-bindings')}</label>
733
1050
  <input
734
1051
  .value=${part.name}
735
1052
  @change=${(e: Event) => this.put({ name: (e.target as HTMLInputElement).value })}
736
1053
  />
1054
+ ${this.helpHint('figure.text.renaming-a-part-breaks-bindings')}
737
1055
  </div>
738
- <div field>
1056
+ <div inline-field>
739
1057
  <label>${i18next.t('figure.label.shape')}</label>
740
1058
  <select @change=${(e: Event) => this.changePrimitive((e.target as HTMLSelectElement).value as PrimitiveKind)}>
741
1059
  ${PRIMITIVE_KINDS.map(
@@ -759,16 +1077,18 @@ export class FigureInspector extends localize(i18next)(LitElement) {
759
1077
  <section>
760
1078
  <h3>${i18next.t('figure.label.placement')}</h3>
761
1079
 
762
- <label>${i18next.t('figure.label.part-position')}</label>
763
- <div triple>
764
- ${(['x', 'y', 'z'] as const).map(axis =>
765
- this.number(axis.toUpperCase(), t.position[axis], value =>
766
- this.putTransform({ position: { ...t.position, [axis]: value } })
767
- )
768
- )}
1080
+ <div vector-row>
1081
+ <label>${i18next.t('figure.label.part-position')}</label>
1082
+ <div triple>
1083
+ ${(['x', 'y', 'z'] as const).map(axis =>
1084
+ this.number(axis.toUpperCase(), t.position[axis], value =>
1085
+ this.putTransform({ position: { ...t.position, [axis]: value } })
1086
+ )
1087
+ )}
1088
+ </div>
769
1089
  </div>
770
1090
 
771
- <div field>
1091
+ <div vector-row>
772
1092
  <label>${i18next.t('figure.label.part-size')}</label>
773
1093
  <div triple>
774
1094
  ${(['x', 'y', 'z'] as const).map(axis =>
@@ -782,48 +1102,64 @@ export class FigureInspector extends localize(i18next)(LitElement) {
782
1102
  </div>
783
1103
  </div>
784
1104
 
785
- <div field>
786
- <label>${i18next.t('figure.label.tools')}</label>
1105
+ <div vector-row>
1106
+ <label>${i18next.t('figure.label.rotation-degrees')}</label>
1107
+ <div triple>
1108
+ ${(['x', 'y', 'z'] as const).map(axis =>
1109
+ this.number(axis.toUpperCase(), rotation[axis] ?? 0, value =>
1110
+ this.putTransform({ rotation: { ...rotation, [axis]: value } })
1111
+ )
1112
+ )}
1113
+ </div>
1114
+ </div>
1115
+
1116
+ <div vector-row>
1117
+ <label>${i18next.t('figure.label.tools')}${this.helpButton('figure.text.tools-explained')}</label>
787
1118
  <div tools>
788
- <button title=${i18next.t('figure.text.center-on-x')} @click=${() => this.ask('center', 'x')}>
789
- ${i18next.t('figure.button.center-x')}
1119
+ <button
1120
+ aria-label=${i18next.t('figure.button.center-x')}
1121
+ title=${i18next.t('figure.text.center-on-x')}
1122
+ @click=${() => this.ask('center', 'x')}
1123
+ >
1124
+ <md-icon>horizontal_distribute</md-icon>
790
1125
  </button>
791
- <button title=${i18next.t('figure.text.center-on-z')} @click=${() => this.ask('center', 'z')}>
792
- ${i18next.t('figure.button.center-z')}
1126
+ <button
1127
+ aria-label=${i18next.t('figure.button.center-z')}
1128
+ title=${i18next.t('figure.text.center-on-z')}
1129
+ @click=${() => this.ask('center', 'z')}
1130
+ >
1131
+ <md-icon>vertical_align_center</md-icon>
793
1132
  </button>
794
- <button title=${i18next.t('figure.text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
795
- ${i18next.t('figure.button.to-floor')}
1133
+ <button
1134
+ aria-label=${i18next.t('figure.button.to-floor')}
1135
+ title=${i18next.t('figure.text.put-on-floor')}
1136
+ @click=${() => this.ask('floor', 'y')}
1137
+ >
1138
+ <md-icon>vertical_align_bottom</md-icon>
796
1139
  </button>
797
- <span sep></span>
798
1140
  ${(['x', 'z'] as const).map(axis => {
799
1141
  // 이미 가운데면 대칭이 제자리다 — 겹친 부품이 조용히 생기지 않게 막는다
800
1142
  const able = this.board ? canMirror(this.board, this.parts, this.selected, axis) : false
801
1143
  return html`
802
1144
  <button
803
1145
  ?disabled=${!able}
1146
+ aria-label=${i18next.t('figure.button.mirror-' + axis)}
804
1147
  title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
805
1148
  @click=${() => this.ask('mirror', axis)}
806
1149
  >
807
- ${i18next.t('figure.button.mirror-' + axis)}
1150
+ <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
808
1151
  </button>
809
1152
  `
810
1153
  })}
811
- <span sep></span>
812
- <button title=${i18next.t('figure.text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
813
- ${i18next.t('figure.button.snap')}
1154
+ <button
1155
+ aria-label=${i18next.t('figure.button.snap')}
1156
+ title=${i18next.t('figure.text.snap-explained')}
1157
+ @click=${() => this.ask('snap', 'x')}
1158
+ >
1159
+ <md-icon>grid_on</md-icon>
814
1160
  </button>
815
1161
  </div>
816
- </div>
817
-
818
- <div field>
819
- <label>${i18next.t('figure.label.rotation-degrees')}</label>
820
- <div triple>
821
- ${(['x', 'y', 'z'] as const).map(axis =>
822
- this.number(axis.toUpperCase(), rotation[axis] ?? 0, value =>
823
- this.putTransform({ rotation: { ...rotation, [axis]: value } })
824
- )
825
- )}
826
- </div>
1162
+ ${this.helpHint('figure.text.tools-explained')}
827
1163
  </div>
828
1164
  </section>
829
1165
  `
@@ -860,11 +1196,11 @@ export class FigureInspector extends localize(i18next)(LitElement) {
860
1196
  this.put({ shape: { ...(part.shape ?? {}), ...over } })
861
1197
 
862
1198
  return html`
863
- <div field>
1199
+ <div inline-field>
864
1200
  <label>${i18next.t('figure.label.corner-round')}</label>
865
1201
  ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
866
1202
  </div>
867
- <div field>
1203
+ <div inline-field>
868
1204
  <label>${i18next.t('figure.label.hollow-wall')}</label>
869
1205
  ${this.number('', wall, value =>
870
1206
  reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined })
@@ -872,7 +1208,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
872
1208
  </div>
873
1209
  ${wall > 0
874
1210
  ? html`
875
- <div field>
1211
+ <div inline-field>
876
1212
  <label>${i18next.t('figure.label.hollow-floor')}</label>
877
1213
  ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
878
1214
  </div>
@@ -917,12 +1253,14 @@ export class FigureInspector extends localize(i18next)(LitElement) {
917
1253
 
918
1254
  private renderSegments(part: FigurePart) {
919
1255
  const current = part.segments ?? SEGMENT_PRESETS[1]
1256
+ const common = SEGMENT_PRESETS.slice(0, 4)
1257
+ const advanced = SEGMENT_PRESETS.slice(4)
920
1258
 
921
1259
  return html`
922
1260
  <div field>
923
1261
  <label>${i18next.t('figure.label.segments')}</label>
924
1262
  <div segments>
925
- ${SEGMENT_PRESETS.map(preset => {
1263
+ ${common.map(preset => {
926
1264
  const triangles = trianglesOf({ ...part, segments: preset })
927
1265
  return html`
928
1266
  <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
@@ -934,9 +1272,13 @@ export class FigureInspector extends localize(i18next)(LitElement) {
934
1272
  })}
935
1273
  </div>
936
1274
 
937
- <details free>
938
- <summary>${i18next.t('figure.text.other-segment-count')}</summary>
939
- ${this.number('', current, value => this.put({ segments: value }), 3)}
1275
+ <details free ?open=${advanced.includes(current)}>
1276
+ <summary>${i18next.t('figure.text.more-segment-options')}</summary>
1277
+ <div presets>
1278
+ ${advanced.map(preset => html`
1279
+ <button ?on=${current === preset} @click=${() => this.put({ segments: preset })}>${preset}</button>
1280
+ `)}
1281
+ </div>
940
1282
  </details>
941
1283
  </div>
942
1284
  `
@@ -964,17 +1306,38 @@ export class FigureInspector extends localize(i18next)(LitElement) {
964
1306
  }
965
1307
 
966
1308
  /** 견본 한 줄. 무리마다 부른다. */
1309
+ private palettePurpose(token?: string) {
1310
+ const purposes: Record<string, string> = {
1311
+ 'palette.primary': 'primary',
1312
+ 'palette.accent': 'accent',
1313
+ 'palette.neutral': 'neutral',
1314
+ 'palette.dark': 'dark',
1315
+ 'palette.light': 'light',
1316
+ 'palette.surface': 'surface',
1317
+ 'palette.status-danger': 'danger',
1318
+ 'palette.status-warning': 'warning',
1319
+ 'palette.status-info': 'info',
1320
+ 'palette.status-success': 'success'
1321
+ }
1322
+
1323
+ return i18next.t(`figure.label.palette-purpose-${purposes[token ?? ''] ?? 'registered'}`)
1324
+ }
1325
+
967
1326
  private swatches(entries: [string, string][], chosen?: string) {
968
1327
  return entries.map(
969
- ([token, color]) => html`
1328
+ ([token, color]) => {
1329
+ const purpose = this.palettePurpose(token)
1330
+ return html`
970
1331
  <button
971
1332
  swatch
972
1333
  ?on=${token === chosen}
973
- title=${token}
1334
+ aria-label=${purpose}
1335
+ title=${purpose}
974
1336
  style="--swatch:${color}"
975
1337
  @click=${() => this.putMaterial({ token })}
976
1338
  ></button>
977
1339
  `
1340
+ }
978
1341
  )
979
1342
  }
980
1343
 
@@ -1001,7 +1364,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1001
1364
  <h3>${i18next.t('figure.label.part-material')}</h3>
1002
1365
 
1003
1366
  <div field>
1004
- <label>${i18next.t('figure.label.palette-token')}</label>
1367
+ <label>${i18next.t('figure.label.palette-role')}</label>
1005
1368
  <div swatches>
1006
1369
  ${this.swatches(painted, material.token)}
1007
1370
  </div>
@@ -1013,7 +1376,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1013
1376
  </div>
1014
1377
  `
1015
1378
  : ''}
1016
- <span token>${material.token ?? i18next.t('figure.label.no-color-chosen')}</span>
1379
+ <div palette-meaning>
1380
+ <b>${material.token ? this.palettePurpose(material.token) : i18next.t('figure.label.no-color-chosen')}</b>
1381
+ <small>${i18next.t('figure.text.palette-purpose-explained')}</small>
1382
+ </div>
1017
1383
  </div>
1018
1384
 
1019
1385
  <div field>
@@ -1138,21 +1504,25 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1138
1504
  return html`
1139
1505
  <section>
1140
1506
  <h3>${i18next.t('figure.label.sizing-rule')}${this.help('figure.text.sizing-' + sizing + '-explained')}</h3>
1141
- <select @change=${(e: Event) => this.put({ sizing: (e.target as HTMLSelectElement).value as never })}>
1507
+ <div sizing-choices>
1142
1508
  ${SIZING_RULES.map(
1143
1509
  rule => html`
1144
- <option value=${rule} ?selected=${rule === sizing}>${i18next.t('figure.label.sizing-' + rule)}</option>
1510
+ <button sizing-choice ?on=${rule === sizing} @click=${() => this.put({ sizing: rule })}>
1511
+ <b>${i18next.t('figure.label.sizing-' + rule)}</b>
1512
+ <small>${i18next.t('figure.text.sizing-' + rule + '-explained')}</small>
1513
+ </button>
1145
1514
  `
1146
1515
  )}
1147
- </select>
1148
- ${sizing === 'scale' ? nothing : this.help('figure.text.sizing-costs-one-draw-call')}
1149
- ${sizing === 'repeat' ? this.help('figure.text.repeat-edits-the-unit') : nothing}
1516
+ </div>
1517
+ ${sizing === 'scale'
1518
+ ? nothing
1519
+ : html`<p cost>${i18next.t('figure.text.sizing-costs-one-draw-call')}</p>`}
1150
1520
 
1151
1521
  ${this.renderAnchors(part)}
1152
1522
 
1153
1523
  ${sizing === 'repeat'
1154
1524
  ? html`
1155
- <div field>
1525
+ <div inline-field>
1156
1526
  <label>${i18next.t('figure.label.repeat-axis')}</label>
1157
1527
  <select
1158
1528
  @change=${(e: Event) =>
@@ -1163,9 +1533,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1163
1533
  )}
1164
1534
  </select>
1165
1535
  </div>
1166
- <div field>
1167
- <label>${i18next.t('figure.label.repeat-pitch')}${this.help('figure.text.repeat-pitch-explained')}</label>
1536
+ <div inline-field>
1537
+ <label>${i18next.t('figure.label.repeat-pitch')}${this.helpButton('figure.text.repeat-pitch-explained')}</label>
1168
1538
  ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
1539
+ ${this.helpHint('figure.text.repeat-pitch-explained')}
1169
1540
  </div>
1170
1541
  ${this.renderRepeatCount(part)}
1171
1542
  `
@@ -1255,6 +1626,10 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1255
1626
  * 계속 고치는 사람이 매번 다시 펴야 하면 접은 것이 되레 방해가 된다.
1256
1627
  */
1257
1628
  private help(key: string) {
1629
+ return html`${this.helpButton(key)}${this.helpHint(key)}`
1630
+ }
1631
+
1632
+ private helpButton(key: string) {
1258
1633
  const shown = this.opened.has(key)
1259
1634
 
1260
1635
  return html`
@@ -1269,20 +1644,38 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1269
1644
  >
1270
1645
  <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
1271
1646
  </button>
1272
- ${shown ? html`<p hint>${i18next.t(key)}</p>` : nothing}
1273
1647
  `
1274
1648
  }
1275
1649
 
1650
+ private helpHint(key: string) {
1651
+ return this.opened.has(key) ? html`<p hint>${i18next.t(key)}</p>` : nothing
1652
+ }
1653
+
1276
1654
  private renderAnchors(part: FigurePart) {
1277
1655
  const base = this.board
1278
1656
  ? { x: this.board.width, y: this.board.height, z: this.board.depth }
1279
1657
  : { x: 0, y: 0, z: 0 }
1658
+ const nameOf = (rule: string, axis: Axis) =>
1659
+ `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
1660
+ const setAnchor = (axis: Axis, rule?: AnchorRule) => {
1661
+ const anchor = { ...(part.anchor ?? {}) }
1662
+ /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1663
+ if (rule) anchor[axis] = rule
1664
+ else delete anchor[axis]
1665
+ this.put({ anchor: Object.keys(anchor).length ? anchor : undefined })
1666
+ }
1667
+ const shortNameOf = (rule: AnchorRule | 'auto', axis: Axis) => {
1668
+ if (rule === 'auto') return i18next.t('figure.label.anchor-choice-auto')
1669
+ if (rule === 'min' || rule === 'max') return i18next.t(`figure.label.anchor-choice-${rule}-${axis}`)
1670
+ return i18next.t(`figure.label.anchor-choice-${rule}`)
1671
+ }
1280
1672
 
1281
1673
  return html`
1282
1674
  <div field>
1283
- <!-- anchor.x」로 적힌 사유를 화면에서 찾을 있게 이름에 그 낱말을 남긴다 -->
1675
+ <!-- 저장 키(anchor)는 남기되, 사용자는 축마다 어느 면을 기준으로 삼는지 읽는다. -->
1284
1676
  <label>${i18next.t('figure.label.anchor')} (anchor)${this.help('figure.text.anchor-explained')}</label>
1285
- ${(['x', 'y', 'z'] as const).map(axis => {
1677
+ <div anchor-rows>
1678
+ ${(['x', 'y', 'z'] as const).map(axis => {
1286
1679
  const said = part.anchor?.[axis]
1287
1680
  const auto = anchorOfPart(part, base, axis)
1288
1681
 
@@ -1301,35 +1694,42 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1301
1694
  `max` 는 정본에 저장되는 이름이고 서버 오류·형식 문서·게이트 사유가 다 그 이름을 쓴다.
1302
1695
  숨기면 안 되는 종류다. 동작이 먼저고 이름이 괄호로 따라온다.
1303
1696
  */
1304
- const nameOf = (rule: string) =>
1305
- `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
1306
-
1307
1697
  return html`
1308
- <div cell>
1309
- <span axis>${axis}</span>
1310
- <select
1311
- @change=${(e: Event) => {
1312
- const value = (e.target as HTMLSelectElement).value
1313
- const anchor = { ...(part.anchor ?? {}) }
1314
- /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
1315
- if (value) {
1316
- anchor[axis] = value as AnchorRule
1317
- } else {
1318
- delete anchor[axis]
1319
- }
1320
- this.put({ anchor: Object.keys(anchor).length ? anchor : undefined })
1321
- }}
1322
- >
1323
- <option value="" ?selected=${!said}>
1324
- ${i18next.t('figure.label.anchor-auto', { rule: nameOf(auto) })}
1325
- </option>
1326
- ${ANCHOR_RULES.map(
1327
- rule => html`<option value=${rule} ?selected=${rule === said}>${nameOf(rule)}</option>`
1328
- )}
1329
- </select>
1698
+ <div anchor-row>
1699
+ <label>${axis.toUpperCase()}축</label>
1700
+ <div anchor-options>
1701
+ ${(['auto', 'scale', 'min', 'center', 'max', 'span'] as const).map(rule => {
1702
+ const selected = rule === 'auto' ? !said : rule === said
1703
+ const fullName = rule === 'auto' ? i18next.t('figure.label.anchor-auto', { rule: nameOf(auto, axis) }) : nameOf(rule, axis)
1704
+ return html`
1705
+ <button
1706
+ anchor-choice
1707
+ ?on=${selected}
1708
+ aria-pressed=${selected ? 'true' : 'false'}
1709
+ title=${fullName}
1710
+ @click=${() => setAnchor(axis, rule === 'auto' ? undefined : rule)}
1711
+ >${shortNameOf(rule, axis)}</button>
1712
+ `
1713
+ })}
1714
+ </div>
1715
+ <details anchor-advanced>
1716
+ <summary>${i18next.t('figure.label.anchor-advanced')}</summary>
1717
+ <div anchor-options>
1718
+ ${ANCHOR_RULES.filter(rule => rule.startsWith('aspect-')).map(rule => html`
1719
+ <button
1720
+ anchor-choice
1721
+ ?on=${rule === said}
1722
+ aria-pressed=${rule === said ? 'true' : 'false'}
1723
+ title=${nameOf(rule, axis)}
1724
+ @click=${() => setAnchor(axis, rule)}
1725
+ >${i18next.t(`figure.label.anchor-choice-${rule}`)}</button>
1726
+ `)}
1727
+ </div>
1728
+ </details>
1330
1729
  </div>
1331
1730
  `
1332
- })}
1731
+ })}
1732
+ </div>
1333
1733
  </div>
1334
1734
  `
1335
1735
  }