@things-factory/figure-ui 10.1.5 → 10.1.6

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 (75) hide show
  1. package/client/graphql/index.ts +35 -3
  2. package/client/modeller/figure-ask.ts +5 -5
  3. package/client/modeller/figure-canvas.ts +1 -1
  4. package/client/modeller/figure-capabilities.ts +372 -0
  5. package/client/modeller/figure-history-panel.ts +171 -18
  6. package/client/modeller/figure-inspector.ts +392 -76
  7. package/client/modeller/figure-parts.ts +15 -15
  8. package/client/modeller/figure-preview.ts +2 -2
  9. package/client/modeller/figure-report.ts +102 -37
  10. package/client/modeller/figure-settings.ts +20 -20
  11. package/client/modeller/figure-side.ts +37 -9
  12. package/client/modeller/figure-source.test.ts +15 -1
  13. package/client/modeller/figure-source.ts +31 -15
  14. package/client/modeller/figure-thumbnail.ts +49 -4
  15. package/client/modeller/maturity.ts +10 -10
  16. package/client/pages/figure-list-page.ts +152 -66
  17. package/client/pages/figure-modeller-page.ts +260 -43
  18. package/client/types.ts +25 -0
  19. package/client/viewparts/figure-thumb.ts +172 -0
  20. package/dist-client/graphql/index.d.ts +8 -1
  21. package/dist-client/graphql/index.js +32 -3
  22. package/dist-client/graphql/index.js.map +1 -1
  23. package/dist-client/modeller/figure-ask.js +5 -5
  24. package/dist-client/modeller/figure-ask.js.map +1 -1
  25. package/dist-client/modeller/figure-canvas.js +1 -1
  26. package/dist-client/modeller/figure-canvas.js.map +1 -1
  27. package/dist-client/modeller/figure-capabilities.d.ts +42 -0
  28. package/dist-client/modeller/figure-capabilities.js +344 -0
  29. package/dist-client/modeller/figure-capabilities.js.map +1 -0
  30. package/dist-client/modeller/figure-history-panel.d.ts +15 -1
  31. package/dist-client/modeller/figure-history-panel.js +165 -18
  32. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  33. package/dist-client/modeller/figure-inspector.d.ts +84 -0
  34. package/dist-client/modeller/figure-inspector.js +379 -75
  35. package/dist-client/modeller/figure-inspector.js.map +1 -1
  36. package/dist-client/modeller/figure-parts.js +15 -15
  37. package/dist-client/modeller/figure-parts.js.map +1 -1
  38. package/dist-client/modeller/figure-preview.js +2 -2
  39. package/dist-client/modeller/figure-preview.js.map +1 -1
  40. package/dist-client/modeller/figure-report.d.ts +18 -1
  41. package/dist-client/modeller/figure-report.js +105 -37
  42. package/dist-client/modeller/figure-report.js.map +1 -1
  43. package/dist-client/modeller/figure-settings.js +19 -19
  44. package/dist-client/modeller/figure-settings.js.map +1 -1
  45. package/dist-client/modeller/figure-side.d.ts +1 -0
  46. package/dist-client/modeller/figure-side.js +36 -8
  47. package/dist-client/modeller/figure-side.js.map +1 -1
  48. package/dist-client/modeller/figure-source.d.ts +15 -8
  49. package/dist-client/modeller/figure-source.js +16 -7
  50. package/dist-client/modeller/figure-source.js.map +1 -1
  51. package/dist-client/modeller/figure-thumbnail.js +40 -4
  52. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  53. package/dist-client/modeller/maturity.js +10 -10
  54. package/dist-client/modeller/maturity.js.map +1 -1
  55. package/dist-client/pages/figure-list-page.d.ts +1 -4
  56. package/dist-client/pages/figure-list-page.js +150 -63
  57. package/dist-client/pages/figure-list-page.js.map +1 -1
  58. package/dist-client/pages/figure-modeller-page.d.ts +45 -0
  59. package/dist-client/pages/figure-modeller-page.js +259 -43
  60. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  61. package/dist-client/tsconfig.tsbuildinfo +1 -1
  62. package/dist-client/types.d.ts +23 -0
  63. package/dist-client/types.js.map +1 -1
  64. package/dist-client/viewparts/figure-thumb.d.ts +29 -0
  65. package/dist-client/viewparts/figure-thumb.js +168 -0
  66. package/dist-client/viewparts/figure-thumb.js.map +1 -0
  67. package/dist-server/tsconfig.tsbuildinfo +1 -1
  68. package/package.json +7 -5
  69. package/test/i18n-prefix-guard.test.ts +129 -0
  70. package/test/korean-register-guard.test.ts +83 -0
  71. package/translations/en.json +307 -264
  72. package/translations/ja.json +275 -264
  73. package/translations/ko.json +307 -264
  74. package/translations/ms.json +275 -264
  75. package/translations/zh.json +275 -264
@@ -1,7 +1,8 @@
1
1
  import '@material/web/icon/icon.js'
2
2
 
3
3
  import { css, html, LitElement, nothing, svg } from 'lit'
4
- import { customElement, property } from 'lit/decorators.js'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+ import { live } from 'lit/directives/live.js'
5
6
 
6
7
  import { i18next, localize } from '@operato/i18n'
7
8
  import { ScrollbarStyles } from '@operato/styles'
@@ -14,7 +15,8 @@ import {
14
15
  SIZING_RULES,
15
16
  trianglesOf
16
17
  } from '@hatiolab/figure-model'
17
- import type { Axis, FigurePart, MaterialPreset, PrimitiveKind } from '@hatiolab/figure-model'
18
+ import { ANCHOR_RULES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
19
+ import type { AnchorRule, Axis, FigurePart, MaterialPreset, PartRole, PrimitiveKind } from '@hatiolab/figure-model'
18
20
  import { activePalette } from '@hatiolab/things-scene'
19
21
 
20
22
  import { partToFigure, partFromFigure } from './figure-source.js'
@@ -87,12 +89,21 @@ export class FigureInspector extends localize(i18next)(LitElement) {
87
89
  font-size: 0.76rem;
88
90
  }
89
91
 
92
+ /*
93
+ ── 자리를 아끼는 판 ──────────────────────────────────────────────────────
94
+ 속성은 앞으로 더 늘어난다(능력 · 포트 · 애니메이션 · 기준정보). 지금 여백을 그대로 두면
95
+ 그것들이 들어올 자리가 없다. 절 여백을 6/10 으로, 제목 아래를 4px 로 줄였다 — 글자 크기와
96
+ 칸 높이는 건드리지 않는다(작게 만드는 것이 아니라 **빈 곳을 줄이는 것**이다).
97
+ */
90
98
  section {
91
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
99
+ padding: 6px 10px;
92
100
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
93
101
  }
94
102
  h3 {
95
- margin: 0 0 6px 0;
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 4px;
106
+ margin: 0 0 4px 0;
96
107
  font-size: var(--ui-t-micro, 11px);
97
108
  font-weight: 700;
98
109
  letter-spacing: 0.04em;
@@ -101,12 +112,47 @@ export class FigureInspector extends localize(i18next)(LitElement) {
101
112
  }
102
113
 
103
114
  label {
104
- display: block;
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 3px;
105
118
  margin-bottom: 2px;
106
119
  font-size: 0.68rem;
107
120
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
108
121
  }
109
122
 
123
+ /*
124
+ 도움말 물음표. **글자 줄을 하나 먹지 않는다** — 라벨·제목 줄의 남는 폭에 얹힌다.
125
+ 아이콘만 두고 테두리를 안 두는 이유는 이것이 누를 것이라기보다 「더 있다」는 표시라서다.
126
+ */
127
+ button[help] {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ padding: 0;
131
+ margin: 0;
132
+ color: var(--md-sys-color-outline);
133
+ background: none;
134
+ border: none;
135
+ cursor: pointer;
136
+ }
137
+ button[help] md-icon {
138
+ --md-icon-size: 13px;
139
+ font-size: 13px;
140
+ }
141
+ button[help]:hover,
142
+ button[help][aria-expanded='true'] {
143
+ color: var(--md-sys-color-primary);
144
+ }
145
+
146
+ /* 편 도움말. 라벨 아래 한 칸 들여 붙인다 — 무엇에 딸린 말인지 보이게. */
147
+ p[hint] {
148
+ margin: 2px 0 4px 0;
149
+ padding-left: 6px;
150
+ font-size: 0.68rem;
151
+ line-height: 1.5;
152
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
153
+ border-left: 2px solid var(--md-sys-color-outline-variant);
154
+ }
155
+
110
156
  input,
111
157
  select {
112
158
  width: 100%;
@@ -174,7 +220,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
174
220
  div[field] + div[field],
175
221
  div[triple] + div[field],
176
222
  div[field] + div[triple] {
177
- margin-top: 7px;
223
+ /* 칸 사이 7 → 5. 칸 자체는 그대로 두고 사이만 좁힌다. */
224
+ margin-top: 5px;
178
225
  }
179
226
 
180
227
  /*
@@ -484,6 +531,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
484
531
  /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
485
532
  @property({ type: String }) figureName = ''
486
533
 
534
+ /** 펴 둔 도움말. 부품을 바꿔도 유지된다 — 매번 다시 펴게 하지 않는다. */
535
+ @state() private opened = new Set<string>()
536
+
487
537
  @property({ type: Object }) board?: BoardModel
488
538
  /** 보드 위의 부품들. 씬 모델 그대로다. */
489
539
  @property({ type: Array }) parts: PartModel[] = []
@@ -500,8 +550,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
500
550
  return html`
501
551
  ${this.renderFigure()}
502
552
  <section>
503
- <h3>${i18next.t('label.properties')}</h3>
504
- <p quiet>${i18next.t('text.select-a-part-to-edit')}</p>
553
+ <h3>${i18next.t('figure.label.properties')}</h3>
554
+ <p quiet>${i18next.t('figure.text.select-a-part-to-edit')}</p>
505
555
  </section>
506
556
  `
507
557
  }
@@ -509,12 +559,125 @@ export class FigureInspector extends localize(i18next)(LitElement) {
509
559
  // 여기부터는 사용자 용어다 — X·Y·Z, 위치는 부품 중심, 회전은 도(degree).
510
560
  const shown = partToFigure(part, this.board)
511
561
 
562
+ /*
563
+ 역할을 먼저 묻는다.
564
+
565
+ 자리(slot)로 지정한 부품은 **그려지지 않는다.** 그 사실을 모른 채 재질부터 고르면 안 쓰일
566
+ 색을 고르게 된다. 그리고 자리는 도형이 능력을 질 때만 뜻이 있으므로, 능력이 없으면 이
567
+ 묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
568
+ */
569
+ return html`
570
+ ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderRole(shown)}
571
+ ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
572
+ ${this.renderBehaviour(shown)}
573
+ `
574
+ }
575
+
576
+ /**
577
+ * ── 이 부품이 능력에서 맡는 자리 ────────────────────────────────────────────
578
+ *
579
+ * 「능력」 탭이 도형 전체가 무엇을 하는지 정하고, 어느 부품이 그것을 받쳐 주는지는 여기서
580
+ * 정한다. **부품을 고치는 곳이 한 군데여야** 하기 때문이다 — 목록을 따로 두면 같은 부품을
581
+ * 두 화면에서 고치게 되고, 저작자는 어느 쪽이 이기는지 매번 생각해야 한다.
582
+ *
583
+ * 칸 수는 `repeat` 가 있으면 받지 않는다. 반복하는 자리의 칸 수는 **인스턴스 크기**가 정한다 —
584
+ * 컨베이어가 길어지면 실을 자리가 늘어나는 것이 그것이고, 여기 적은 값은 안 쓰인다.
585
+ */
586
+ private renderRole(part: FigurePart) {
587
+ if (!this.board?.capabilities?.length) return nothing
588
+
589
+ const roles = part.capability?.roles ?? []
590
+ const repeats = part.sizing === 'repeat' && !!part.repeat
591
+
512
592
  return html`
513
- ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderPlacement(shown)}
514
- ${this.renderShape(shown)} ${this.renderMaterial(shown)} ${this.renderBehaviour(shown)}
593
+ <section>
594
+ <h3>${i18next.t('figure.label.role-in-capability')}${this.help('figure.text.anchors-are-not-drawn')}</h3>
595
+
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
+ )}
608
+ ${roles.length === 0 ? html`<p quiet>${i18next.t('figure.text.role-none-means-drawn')}</p>` : nothing}
609
+
610
+ ${roles.includes('slot')
611
+ ? html`
612
+ <div field>
613
+ <label>${i18next.t('figure.label.accepts')}${this.help('figure.text.accepts-explained')}</label>
614
+ <input
615
+ .value=${(part.capability?.accepts ?? []).join(', ')}
616
+ placeholder=${i18next.t('figure.text.accepts-anything')}
617
+ @change=${(e: Event) => this.setAccepts((e.target as HTMLInputElement).value)}
618
+ />
619
+ </div>
620
+ <div field>
621
+ <label>${i18next.t('figure.label.slot-capacity')}</label>
622
+ ${repeats
623
+ ? html`<p quiet>${i18next.t('figure.text.capacity-from-repeat')}</p>`
624
+ : this.number('', part.capability?.capacity ?? 1, value => this.setCapacity(value), 1)}
625
+ </div>
626
+ `
627
+ : nothing}
628
+ </section>
515
629
  `
516
630
  }
517
631
 
632
+ /**
633
+ * 역할 하나를 켜고 끈다.
634
+ *
635
+ * **여럿을 함께 맡을 수 있다.** AGV 의 상판은 물건을 놓는 자리이면서 그 자리로 물건이
636
+ * 들어오고 나간다. 그래서 하나를 고르는 선택 상자가 아니라 확인 상자다.
637
+ *
638
+ * 마지막 역할을 끄면 `capability` 를 통째로 지운다. 빈 배열을 남기면 형식이 오류로 보고,
639
+ * 저작자가 보기에는 아무 역할도 없는 평범한 부품인데 발행이 막힌다.
640
+ */
641
+ private toggleRole(role: PartRole, on: boolean) {
642
+ this.change(part => {
643
+ const roles = (part.capability?.roles ?? []).filter(one => one !== role)
644
+ if (on) roles.push(role)
645
+
646
+ if (roles.length === 0) {
647
+ const { capability, ...rest } = part
648
+ return rest as FigurePart
649
+ }
650
+
651
+ // 형식이 적은 순서를 지킨다 — 켠 순서대로 두면 같은 뜻이 다른 값으로 저장된다.
652
+ const ordered = (PART_ROLES as readonly PartRole[]).filter(one => roles.includes(one))
653
+ return { ...part, capability: { ...part.capability, roles: ordered } }
654
+ })
655
+ }
656
+
657
+ private setAccepts(raw: string) {
658
+ const accepts = raw
659
+ .split(',')
660
+ .map(one => one.trim())
661
+ .filter(one => one.length > 0)
662
+
663
+ this.change(part => {
664
+ const spec = { ...part.capability, roles: part.capability?.roles ?? ['slot' as PartRole] }
665
+ // 빈 목록은 제한하지 않는다는 뜻이다. 빈 배열을 저장하면 아무것도 못 받는 것으로 읽힌다.
666
+ if (accepts.length > 0) spec.accepts = accepts
667
+ else delete spec.accepts
668
+ return { ...part, capability: spec as NonNullable<FigurePart['capability']> }
669
+ })
670
+ }
671
+
672
+ private setCapacity(value: number) {
673
+ this.change(part => {
674
+ const spec = { ...part.capability, roles: part.capability?.roles ?? ['slot' as PartRole] }
675
+ if (Number.isFinite(value) && value >= 1) spec.capacity = Math.floor(value)
676
+ else delete spec.capacity
677
+ return { ...part, capability: spec as NonNullable<FigurePart['capability']> }
678
+ })
679
+ }
680
+
518
681
  /**
519
682
  * 도형 자체 — 이름과 타입.
520
683
  *
@@ -531,12 +694,12 @@ export class FigureInspector extends localize(i18next)(LitElement) {
531
694
  private renderFigure() {
532
695
  return html`
533
696
  <section>
534
- <h3>${i18next.t('label.figure')}</h3>
697
+ <h3>${i18next.t('figure.label.figure')}</h3>
535
698
  <div field>
536
- <label>${i18next.t('label.name')}</label>
699
+ <label>${i18next.t('figure.label.name')}</label>
537
700
  <input
538
701
  .value=${this.figureName}
539
- placeholder=${i18next.t('text.figure-display-name')}
702
+ placeholder=${i18next.t('figure.text.figure-display-name')}
540
703
  @change=${(e: Event) =>
541
704
  this.dispatchEvent(
542
705
  new CustomEvent('figure-rename', {
@@ -548,8 +711,8 @@ export class FigureInspector extends localize(i18next)(LitElement) {
548
711
  />
549
712
  </div>
550
713
  <div field>
551
- <label>${i18next.t('label.type')}</label>
552
- <code fixed title=${i18next.t('text.type-name-cannot-change')}>
714
+ <label>${i18next.t('figure.label.figure-type')}${this.help('figure.text.type-name-cannot-change')}</label>
715
+ <code fixed title=${i18next.t('figure.text.type-name-cannot-change')}>
553
716
  <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
554
717
  </code>
555
718
  </div>
@@ -557,7 +720,6 @@ export class FigureInspector extends localize(i18next)(LitElement) {
557
720
  왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
558
721
  알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
559
722
  -->
560
- <p quiet>${i18next.t('text.type-name-cannot-change')}</p>
561
723
  </section>
562
724
  `
563
725
  }
@@ -565,22 +727,21 @@ export class FigureInspector extends localize(i18next)(LitElement) {
565
727
  private renderIdentity(part: FigurePart) {
566
728
  return html`
567
729
  <section>
568
- <h3>${i18next.t('label.part')}</h3>
730
+ <h3>${i18next.t('figure.label.part')}</h3>
569
731
  <div field>
570
- <label>${i18next.t('label.name')}</label>
732
+ <label>${i18next.t('figure.label.name')}${this.help('figure.text.renaming-a-part-breaks-bindings')}</label>
571
733
  <input
572
734
  .value=${part.name}
573
735
  @change=${(e: Event) => this.put({ name: (e.target as HTMLInputElement).value })}
574
736
  />
575
- <p warn>${i18next.t('text.renaming-a-part-breaks-bindings')}</p>
576
737
  </div>
577
738
  <div field>
578
- <label>${i18next.t('label.shape')}</label>
739
+ <label>${i18next.t('figure.label.shape')}</label>
579
740
  <select @change=${(e: Event) => this.changePrimitive((e.target as HTMLSelectElement).value as PrimitiveKind)}>
580
741
  ${PRIMITIVE_KINDS.map(
581
742
  kind => html`
582
743
  <option value=${kind} ?selected=${kind === part.primitive}>
583
- ${i18next.t('label.primitive-' + kind)}
744
+ ${i18next.t('figure.label.primitive-' + kind)}
584
745
  </option>
585
746
  `
586
747
  )}
@@ -596,9 +757,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
596
757
 
597
758
  return html`
598
759
  <section>
599
- <h3>${i18next.t('label.placement')}</h3>
760
+ <h3>${i18next.t('figure.label.placement')}</h3>
600
761
 
601
- <label>${i18next.t('label.position')}</label>
762
+ <label>${i18next.t('figure.label.part-position')}</label>
602
763
  <div triple>
603
764
  ${(['x', 'y', 'z'] as const).map(axis =>
604
765
  this.number(axis.toUpperCase(), t.position[axis], value =>
@@ -608,7 +769,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
608
769
  </div>
609
770
 
610
771
  <div field>
611
- <label>${i18next.t('label.size')}</label>
772
+ <label>${i18next.t('figure.label.part-size')}</label>
612
773
  <div triple>
613
774
  ${(['x', 'y', 'z'] as const).map(axis =>
614
775
  this.number(
@@ -622,16 +783,16 @@ export class FigureInspector extends localize(i18next)(LitElement) {
622
783
  </div>
623
784
 
624
785
  <div field>
625
- <label>${i18next.t('label.tools')}</label>
786
+ <label>${i18next.t('figure.label.tools')}</label>
626
787
  <div tools>
627
- <button title=${i18next.t('text.center-on-x')} @click=${() => this.ask('center', 'x')}>
628
- ${i18next.t('button.center-x')}
788
+ <button title=${i18next.t('figure.text.center-on-x')} @click=${() => this.ask('center', 'x')}>
789
+ ${i18next.t('figure.button.center-x')}
629
790
  </button>
630
- <button title=${i18next.t('text.center-on-z')} @click=${() => this.ask('center', 'z')}>
631
- ${i18next.t('button.center-z')}
791
+ <button title=${i18next.t('figure.text.center-on-z')} @click=${() => this.ask('center', 'z')}>
792
+ ${i18next.t('figure.button.center-z')}
632
793
  </button>
633
- <button title=${i18next.t('text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
634
- ${i18next.t('button.to-floor')}
794
+ <button title=${i18next.t('figure.text.put-on-floor')} @click=${() => this.ask('floor', 'y')}>
795
+ ${i18next.t('figure.button.to-floor')}
635
796
  </button>
636
797
  <span sep></span>
637
798
  ${(['x', 'z'] as const).map(axis => {
@@ -640,22 +801,22 @@ export class FigureInspector extends localize(i18next)(LitElement) {
640
801
  return html`
641
802
  <button
642
803
  ?disabled=${!able}
643
- title=${i18next.t(able ? 'text.mirror-explained' : 'text.mirror-would-overlap')}
804
+ title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
644
805
  @click=${() => this.ask('mirror', axis)}
645
806
  >
646
- ${i18next.t('button.mirror-' + axis)}
807
+ ${i18next.t('figure.button.mirror-' + axis)}
647
808
  </button>
648
809
  `
649
810
  })}
650
811
  <span sep></span>
651
- <button title=${i18next.t('text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
652
- ${i18next.t('button.snap')}
812
+ <button title=${i18next.t('figure.text.snap-explained')} @click=${() => this.ask('snap', 'x')}>
813
+ ${i18next.t('figure.button.snap')}
653
814
  </button>
654
815
  </div>
655
816
  </div>
656
817
 
657
818
  <div field>
658
- <label>${i18next.t('label.rotation-degrees')}</label>
819
+ <label>${i18next.t('figure.label.rotation-degrees')}</label>
659
820
  <div triple>
660
821
  ${(['x', 'y', 'z'] as const).map(axis =>
661
822
  this.number(axis.toUpperCase(), rotation[axis] ?? 0, value =>
@@ -676,7 +837,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
676
837
 
677
838
  return html`
678
839
  <section>
679
- <h3>${i18next.t('label.shape-detail')}</h3>
840
+ <h3>${i18next.t('figure.label.shape-detail')}</h3>
680
841
  ${segmented ? this.renderSegments(part) : nothing}
681
842
  ${segmented ? this.renderKeepRound(part) : nothing}
682
843
  ${extruded ? this.renderExtruded(part) : nothing}
@@ -700,11 +861,11 @@ export class FigureInspector extends localize(i18next)(LitElement) {
700
861
 
701
862
  return html`
702
863
  <div field>
703
- <label>${i18next.t('label.corner-round')}</label>
864
+ <label>${i18next.t('figure.label.corner-round')}</label>
704
865
  ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
705
866
  </div>
706
867
  <div field>
707
- <label>${i18next.t('label.hollow-wall')}</label>
868
+ <label>${i18next.t('figure.label.hollow-wall')}</label>
708
869
  ${this.number('', wall, value =>
709
870
  reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined })
710
871
  )}
@@ -712,7 +873,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
712
873
  ${wall > 0
713
874
  ? html`
714
875
  <div field>
715
- <label>${i18next.t('label.hollow-floor')}</label>
876
+ <label>${i18next.t('figure.label.hollow-floor')}</label>
716
877
  ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
717
878
  </div>
718
879
  `
@@ -739,15 +900,15 @@ export class FigureInspector extends localize(i18next)(LitElement) {
739
900
 
740
901
  return html`
741
902
  <div field>
742
- <label>${i18next.t('label.keep-round')}</label>
903
+ <label>${i18next.t('figure.label.keep-round')}</label>
743
904
  <div choices>
744
905
  <button choice ?on=${on} @click=${() => this.put({ keepRound: undefined })}>
745
- <b>${i18next.t('text.keep-round-on')}</b>
746
- <small>${i18next.t('text.keep-round-cost')}</small>
906
+ <b>${i18next.t('figure.text.keep-round-on')}</b>
907
+ <small>${i18next.t('figure.text.keep-round-cost')}</small>
747
908
  </button>
748
909
  <button choice ?on=${!on} @click=${() => this.put({ keepRound: false })}>
749
- <b>${i18next.t('text.keep-round-off')}</b>
750
- <small>${i18next.t('text.keep-round-merged')}</small>
910
+ <b>${i18next.t('figure.text.keep-round-off')}</b>
911
+ <small>${i18next.t('figure.text.keep-round-merged')}</small>
751
912
  </button>
752
913
  </div>
753
914
  </div>
@@ -759,7 +920,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
759
920
 
760
921
  return html`
761
922
  <div field>
762
- <label>${i18next.t('label.segments')}</label>
923
+ <label>${i18next.t('figure.label.segments')}</label>
763
924
  <div segments>
764
925
  ${SEGMENT_PRESETS.map(preset => {
765
926
  const triangles = trianglesOf({ ...part, segments: preset })
@@ -767,14 +928,14 @@ export class FigureInspector extends localize(i18next)(LitElement) {
767
928
  <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
768
929
  ${this.silhouette(preset)}
769
930
  <b>${preset}</b>
770
- <small>${i18next.t('text.n-triangles', { count: triangles })}</small>
931
+ <small>${i18next.t('figure.text.n-triangles', { count: triangles })}</small>
771
932
  </button>
772
933
  `
773
934
  })}
774
935
  </div>
775
936
 
776
937
  <details free>
777
- <summary>${i18next.t('text.other-segment-count')}</summary>
938
+ <summary>${i18next.t('figure.text.other-segment-count')}</summary>
778
939
  ${this.number('', current, value => this.put({ segments: value }), 3)}
779
940
  </details>
780
941
  </div>
@@ -837,37 +998,37 @@ export class FigureInspector extends localize(i18next)(LitElement) {
837
998
 
838
999
  return html`
839
1000
  <section>
840
- <h3>${i18next.t('label.material')}</h3>
1001
+ <h3>${i18next.t('figure.label.part-material')}</h3>
841
1002
 
842
1003
  <div field>
843
- <label>${i18next.t('label.palette-token')}</label>
1004
+ <label>${i18next.t('figure.label.palette-token')}</label>
844
1005
  <div swatches>
845
1006
  ${this.swatches(painted, material.token)}
846
1007
  </div>
847
1008
  ${lit.length
848
1009
  ? html`
849
- <label group>${i18next.t('label.status-colors')}</label>
1010
+ <label group>${i18next.t('figure.label.status-colors')}</label>
850
1011
  <div swatches>
851
1012
  ${this.swatches(lit, material.token)}
852
1013
  </div>
853
1014
  `
854
1015
  : ''}
855
- <span token>${material.token ?? i18next.t('label.no-color-chosen')}</span>
1016
+ <span token>${material.token ?? i18next.t('figure.label.no-color-chosen')}</span>
856
1017
  </div>
857
1018
 
858
1019
  <div field>
859
- <label>${i18next.t('label.preset')}</label>
1020
+ <label>${i18next.t('figure.label.preset')}</label>
860
1021
  <select
861
1022
  @change=${(e: Event) => {
862
1023
  const value = (e.target as HTMLSelectElement).value
863
1024
  this.putMaterial({ preset: (value || undefined) as MaterialPreset | undefined })
864
1025
  }}
865
1026
  >
866
- <option value="" ?selected=${!material.preset}>${i18next.t('label.none')}</option>
1027
+ <option value="" ?selected=${!material.preset}>${i18next.t('figure.label.none')}</option>
867
1028
  ${MATERIAL_PRESETS.map(
868
1029
  preset => html`
869
1030
  <option value=${preset} ?selected=${preset === material.preset}>
870
- ${i18next.t('label.preset-' + preset)}
1031
+ ${i18next.t('figure.label.preset-' + preset)}
871
1032
  </option>
872
1033
  `
873
1034
  )}
@@ -881,7 +1042,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
881
1042
  .checked=${material.flatShading ?? true}
882
1043
  @change=${(e: Event) => this.putMaterial({ flatShading: (e.target as HTMLInputElement).checked })}
883
1044
  />
884
- ${i18next.t('label.flat-shading')}
1045
+ ${i18next.t('figure.label.flat-shading')}
885
1046
  </label>
886
1047
  <label toggle>
887
1048
  <input
@@ -889,12 +1050,11 @@ export class FigureInspector extends localize(i18next)(LitElement) {
889
1050
  .checked=${!!material.transparent}
890
1051
  @change=${(e: Event) => this.putMaterial({ transparent: (e.target as HTMLInputElement).checked })}
891
1052
  />
892
- ${i18next.t('label.transparent')}
1053
+ ${i18next.t('figure.label.transparent')}
893
1054
  </label>
894
1055
 
895
1056
  <div field>
896
- <label>${i18next.t('label.emissive')}</label>
897
- <p cost>${i18next.t('text.emissive-explained')}</p>
1057
+ <label>${i18next.t('figure.label.emissive')}${this.help('figure.text.emissive-explained')}</label>
898
1058
 
899
1059
  <div slider>
900
1060
  <input
@@ -912,13 +1072,13 @@ export class FigureInspector extends localize(i18next)(LitElement) {
912
1072
  ${glow
913
1073
  ? html`
914
1074
  <div field>
915
- <label>${i18next.t('label.emissive-color')}</label>
1075
+ <label>${i18next.t('figure.label.emissive-color')}</label>
916
1076
  <div swatches>
917
1077
  <button
918
1078
  swatch
919
1079
  same
920
1080
  ?on=${!glow.token}
921
- title=${i18next.t('label.same-as-off')}
1081
+ title=${i18next.t('figure.label.same-as-off')}
922
1082
  style="--swatch:${(material.token && palette[material.token]) || palette['palette.neutral']}"
923
1083
  @click=${() => this.putMaterial({ emissive: { ...glow, token: undefined } })}
924
1084
  ></button>
@@ -934,7 +1094,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
934
1094
  `
935
1095
  )}
936
1096
  </div>
937
- <span token>${glow.token ?? i18next.t('label.same-as-off')}</span>
1097
+ <span token>${glow.token ?? i18next.t('figure.label.same-as-off')}</span>
938
1098
  </div>
939
1099
 
940
1100
  <label toggle>
@@ -944,9 +1104,9 @@ export class FigureInspector extends localize(i18next)(LitElement) {
944
1104
  @change=${(e: Event) =>
945
1105
  this.putMaterial({ emissive: { ...glow, on: (e.target as HTMLInputElement).checked } })}
946
1106
  />
947
- ${i18next.t('label.on-by-default')}
1107
+ ${i18next.t('figure.label.on-by-default')}
948
1108
  </label>
949
- <p cost>${i18next.t('text.on-by-default-explained')}</p>
1109
+ ${this.help('figure.text.on-by-default-explained')}
950
1110
  `
951
1111
  : ''}
952
1112
 
@@ -977,27 +1137,23 @@ export class FigureInspector extends localize(i18next)(LitElement) {
977
1137
 
978
1138
  return html`
979
1139
  <section>
980
- <h3>${i18next.t('label.sizing-rule')}</h3>
1140
+ <h3>${i18next.t('figure.label.sizing-rule')}${this.help('figure.text.sizing-' + sizing + '-explained')}</h3>
981
1141
  <select @change=${(e: Event) => this.put({ sizing: (e.target as HTMLSelectElement).value as never })}>
982
1142
  ${SIZING_RULES.map(
983
1143
  rule => html`
984
- <option value=${rule} ?selected=${rule === sizing}>${i18next.t('label.sizing-' + rule)}</option>
1144
+ <option value=${rule} ?selected=${rule === sizing}>${i18next.t('figure.label.sizing-' + rule)}</option>
985
1145
  `
986
1146
  )}
987
1147
  </select>
988
- <p warn>${i18next.t('text.sizing-' + sizing + '-explained')}</p>
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}
989
1150
 
990
- ${sizing === 'scale'
991
- ? nothing
992
- : html`<p cost>${i18next.t('text.sizing-costs-one-draw-call')}</p>`}
993
- ${sizing === 'repeat'
994
- ? html`<p cost>${i18next.t('text.repeat-edits-the-unit')}</p>`
995
- : nothing}
1151
+ ${this.renderAnchors(part)}
996
1152
 
997
1153
  ${sizing === 'repeat'
998
1154
  ? html`
999
1155
  <div field>
1000
- <label>${i18next.t('label.repeat-axis')}</label>
1156
+ <label>${i18next.t('figure.label.repeat-axis')}</label>
1001
1157
  <select
1002
1158
  @change=${(e: Event) =>
1003
1159
  this.put({ repeat: { ...repeat, axis: (e.target as HTMLSelectElement).value as Axis } })}
@@ -1008,16 +1164,176 @@ export class FigureInspector extends localize(i18next)(LitElement) {
1008
1164
  </select>
1009
1165
  </div>
1010
1166
  <div field>
1011
- <label>${i18next.t('label.repeat-pitch')}</label>
1167
+ <label>${i18next.t('figure.label.repeat-pitch')}${this.help('figure.text.repeat-pitch-explained')}</label>
1012
1168
  ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
1013
- <p cost>${i18next.t('text.repeat-pitch-explained')}</p>
1014
1169
  </div>
1170
+ ${this.renderRepeatCount(part)}
1015
1171
  `
1016
1172
  : nothing}
1017
1173
  </section>
1018
1174
  `
1019
1175
  }
1020
1176
 
1177
+ /**
1178
+ * ── 몇 개가 되나 ──────────────────────────────────────────────────────────
1179
+ *
1180
+ * 저작 화면은 축과 피치만 받고 있었다. 형식이 가진 반복 설정이 그 둘뿐이므로 편집할 것은
1181
+ * 다 있었는데, **결과인 개수를 어디에도 표시하지 않았다.** 피치 300 을 적은 저작자가 몇 개가
1182
+ * 되는지 알려면 미리보기를 세어야 했다.
1183
+ *
1184
+ * 개수는 그리는 쪽이 쓰는 `repeatPlan` 을 그대로 불러 얻는다. 여기서 다시 계산하면 화면이
1185
+ * 말하는 개수와 실제로 그려지는 개수가 달라진다.
1186
+ *
1187
+ * ## 두 가지 반복이 있다
1188
+ *
1189
+ * 컨베이어의 롤러는 길어지면 **개수가 늘고** 한 칸 간격은 그대로다. 셀트레이의 셀은 커지면
1190
+ * **셀이 커지고** 개수는 그대로다 — 몇 셀짜리 트레이냐는 화면 크기와 무관하다. 그 갈림을
1191
+ * 이 축의 anchor 가 정하는데 화면에는 그 말이 없었다. 그래서 어느 쪽인지 함께 적는다.
1192
+ */
1193
+ private renderRepeatCount(part: FigurePart) {
1194
+ const board = this.board
1195
+ if (!board) return nothing
1196
+
1197
+ const base = { x: board.width, y: board.depth, z: board.height }
1198
+ const plan = repeatPlanOfPart(part, { x: 1, y: 1, z: 1 }, base)
1199
+ if (!plan) return nothing
1200
+
1201
+ const grows = anchorOfPart(part, base, plan.axis) !== 'scale'
1202
+
1203
+ return html`
1204
+ <div field>
1205
+ <label>${i18next.t('figure.label.repeat-count')}</label>
1206
+ <p quiet>
1207
+ ${i18next.t('figure.text.repeat-count-now', { count: plan.count })}
1208
+ ${plan.count >= REPEAT_LIMIT
1209
+ ? html`<br />${i18next.t('figure.text.repeat-count-capped', { limit: REPEAT_LIMIT })}`
1210
+ : ''}
1211
+ <br />${i18next.t(grows ? 'figure.text.repeat-grows-with-instance' : 'figure.text.repeat-keeps-count')}
1212
+ </p>
1213
+ </div>
1214
+ `
1215
+ }
1216
+
1217
+ /**
1218
+ * ── 어느 면을 붙잡나 ──────────────────────────────────────────────────────
1219
+ *
1220
+ * **이 자리가 없었다.** 형식은 축마다 `anchor` 를 지고 다니고(`figure-source.ts` 가 왕복시킨다),
1221
+ * 발행 게이트는 「valve-wheel 의 anchor.z 를 max 로 두라」고 사유까지 말하는데, 저작면에는 그
1222
+ * 값을 바꿀 자리가 없었다 — 고치라는 말만 있고 고칠 문이 없었다.
1223
+ *
1224
+ * ## 「자동」이 기본이다
1225
+ *
1226
+ * 형식에서 `anchor` 는 적지 않아도 된다. 안 적으면 **가까운 면**을 붙잡고, 표본 열넷 중 어느
1227
+ * 것도 이 값을 적지 않고 제대로 선다. 그래서 축마다 첫 항목이 「자동」이고, 지금 자동이 무엇으로
1228
+ * 읽히는지를 **함께 보여 준다** — 그것을 모르면 무엇을 바꾸는지 모른 채 고르게 된다.
1229
+ *
1230
+ * 자동이 무엇인지는 형식에게 묻는다(`anchorOfPart`). 여기서 「위 절반이면 max」를 다시 세면
1231
+ * 화면이 말하는 면과 실제로 그려지는 면이 갈린다.
1232
+ *
1233
+ * ## `scale` 인 부품에도 보여 준다
1234
+ *
1235
+ * 처음에는 감췄다 — 형식 주석에 「`sizing` 이 `scale` 인 부품에는 뜻이 없다」고 적혀 있어서다.
1236
+ * **그 문장은 기본값에 대한 것이다.** `anchorOf` 는 적힌 값을 가장 먼저 보고(`if (said) return
1237
+ * said`), 그 뒤에야 `sizing` 을 본다. 즉 `scale` 부품에도 면을 적으면 그것이 이긴다.
1238
+ *
1239
+ * 감춰 두면 어긋남이 난다: 발행 게이트가 막는 MIXER 의 `valve-wheel` 이 `scale` 부품이고,
1240
+ * 게이트의 사유는 「그 부품의 anchor.z 를 max 로 두라」다. 고치라는 자리에 문을 닫아 둔 셈이었다.
1241
+ *
1242
+ * 대신 `scale` 일 때는 자동이 무엇인지가 「함께 커진다」로 보이므로, 저작자가 그것을 바꾸는
1243
+ * 것임을 알 수 있다.
1244
+ */
1245
+ /**
1246
+ * ── 도움말은 자리를 먹지 않는다 ─────────────────────────────────────────────
1247
+ *
1248
+ * 설명 문단이 열 줄 있었고 그 줄들이 패널 높이의 상당 부분이었다. 속성은 앞으로 더 늘어난다
1249
+ * (능력 · 포트 · 애니메이션 · 기준정보) — 늘 보이는 설명은 그 자리를 먼저 먹는다.
1250
+ *
1251
+ * 그래서 물음표로 접는다. **지우지 않는다** — 그 문장들은 「이걸 고르면 무엇이 달라지나」를
1252
+ * 말하는 것이고, 없으면 닫힌 목록을 고를 수 없다. 한 번 읽으면 되는 것이라 접어 두는 것이 맞다.
1253
+ *
1254
+ * 편 것은 **부품을 바꿔도 그대로 둔다**(`opened` 는 부품에 딸린 상태가 아니다). 같은 것을
1255
+ * 계속 고치는 사람이 매번 다시 펴야 하면 접은 것이 되레 방해가 된다.
1256
+ */
1257
+ private help(key: string) {
1258
+ const shown = this.opened.has(key)
1259
+
1260
+ return html`
1261
+ <button
1262
+ help
1263
+ aria-label=${i18next.t('figure.label.help')}
1264
+ aria-expanded=${shown ? 'true' : 'false'}
1265
+ @click=${() => {
1266
+ shown ? this.opened.delete(key) : this.opened.add(key)
1267
+ this.requestUpdate()
1268
+ }}
1269
+ >
1270
+ <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
1271
+ </button>
1272
+ ${shown ? html`<p hint>${i18next.t(key)}</p>` : nothing}
1273
+ `
1274
+ }
1275
+
1276
+ private renderAnchors(part: FigurePart) {
1277
+ const base = this.board
1278
+ ? { x: this.board.width, y: this.board.height, z: this.board.depth }
1279
+ : { x: 0, y: 0, z: 0 }
1280
+
1281
+ return html`
1282
+ <div field>
1283
+ <!-- 「anchor.x」로 적힌 사유를 화면에서 찾을 수 있게 칸 이름에 그 낱말을 남긴다 -->
1284
+ <label>${i18next.t('figure.label.anchor')} (anchor)${this.help('figure.text.anchor-explained')}</label>
1285
+ ${(['x', 'y', 'z'] as const).map(axis => {
1286
+ const said = part.anchor?.[axis]
1287
+ const auto = anchorOfPart(part, base, axis)
1288
+
1289
+ /*
1290
+ **면 이름은 축마다 다르다.** 처음에는 min 을 「아래·왼쪽·뒤 면」으로 한 줄에 다 적었는데,
1291
+ x 줄에서 「아래」와 「뒤」는 관계가 없어 읽는 사람이 무엇을 고르는지 몰랐다.
1292
+
1293
+ 그리고 이름씨가 아니라 **동작**으로 적는다 — 「오른쪽 면」이 아니라 「오른쪽 면에 붙어
1294
+ 있는다」다. 「anchor.x 를 max 로 둔다」가 무슨 뜻인지 모르겠다는 말이 그 자리에서 나왔다.
1295
+ */
1296
+ /*
1297
+ **값 이름을 함께 보인다.** 동작만 적었더니(「오른쪽 면에 붙어 있는다») 발행 게이트가
1298
+ 「anchor.x 를 max 로 두라」고 말할 때 화면에서 그 낱말을 찾을 수 없었다 — 사용자가
1299
+ 그 자리에서 지적했다.
1300
+
1301
+ `max` 는 정본에 저장되는 이름이고 서버 오류·형식 문서·게이트 사유가 다 그 이름을 쓴다.
1302
+ 숨기면 안 되는 종류다. 동작이 먼저고 이름이 괄호로 따라온다.
1303
+ */
1304
+ const nameOf = (rule: string) =>
1305
+ `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
1306
+
1307
+ 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>
1330
+ </div>
1331
+ `
1332
+ })}
1333
+ </div>
1334
+ `
1335
+ }
1336
+
1021
1337
  /**
1022
1338
  * 숫자 칸.
1023
1339
  *