@things-factory/figure-ui 10.1.43 → 10.1.45

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 (128) hide show
  1. package/client/modeller/figure-v3-links.ts +303 -0
  2. package/client/modeller/modeller-route.ts +6 -21
  3. package/client/modeller/v3-editor.ts +44 -3
  4. package/client/modeller/v3-gizmo.ts +13 -2
  5. package/client/modeller/v3-links.ts +138 -0
  6. package/client/pages/figure-v3-modeller-page.ts +2 -5
  7. package/client/route.ts +2 -6
  8. package/dist-client/modeller/figure-v3-links.d.ts +42 -0
  9. package/dist-client/modeller/figure-v3-links.js +306 -0
  10. package/dist-client/modeller/figure-v3-links.js.map +1 -0
  11. package/dist-client/modeller/modeller-route.d.ts +4 -11
  12. package/dist-client/modeller/modeller-route.js +5 -22
  13. package/dist-client/modeller/modeller-route.js.map +1 -1
  14. package/dist-client/modeller/v3-editor.d.ts +5 -0
  15. package/dist-client/modeller/v3-editor.js +46 -2
  16. package/dist-client/modeller/v3-editor.js.map +1 -1
  17. package/dist-client/modeller/v3-gizmo.js +11 -1
  18. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  19. package/dist-client/modeller/v3-links.d.ts +54 -0
  20. package/dist-client/modeller/v3-links.js +110 -0
  21. package/dist-client/modeller/v3-links.js.map +1 -0
  22. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js +2 -5
  24. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/route.js +2 -5
  27. package/dist-client/route.js.map +1 -1
  28. package/dist-client/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +4 -4
  30. package/test/ai-proposal-contract.test.ts +0 -88
  31. package/test/figure-source.test.ts +0 -102
  32. package/test/i18n-prefix-guard.test.ts +0 -24
  33. package/test/modeller-route.test.ts +14 -25
  34. package/test/part-edits.test.ts +0 -7
  35. package/test/scene-budget.test.ts +0 -7
  36. package/test/v3-gizmo.test.ts +10 -0
  37. package/test/v3-links.test.ts +123 -0
  38. package/things-factory.config.js +2 -5
  39. package/translations/en.json +6 -0
  40. package/translations/ja.json +11 -1
  41. package/translations/ko.json +6 -0
  42. package/translations/ms.json +11 -1
  43. package/translations/zh.json +11 -1
  44. package/client/modeller/angle-dial.ts +0 -70
  45. package/client/modeller/base-box.ts +0 -175
  46. package/client/modeller/figure-animations.ts +0 -1249
  47. package/client/modeller/figure-canvas.ts +0 -567
  48. package/client/modeller/figure-capabilities.ts +0 -379
  49. package/client/modeller/figure-inspector.ts +0 -2208
  50. package/client/modeller/figure-parts.ts +0 -479
  51. package/client/modeller/figure-preview.ts +0 -598
  52. package/client/modeller/figure-report.ts +0 -966
  53. package/client/modeller/figure-settings.ts +0 -328
  54. package/client/modeller/figure-side.ts +0 -272
  55. package/client/modeller/figure-thumbnail.ts +0 -123
  56. package/client/modeller/figure-view.ts +0 -152
  57. package/client/modeller/joint-edits.ts +0 -262
  58. package/client/modeller/joint-gizmo.ts +0 -180
  59. package/client/modeller/maturity.ts +0 -288
  60. package/client/modeller/new-figure-entry.ts +0 -4
  61. package/client/modeller/pose-defaults.ts +0 -67
  62. package/client/pages/figure-modeller-page.ts +0 -2082
  63. package/dist-client/modeller/angle-dial.d.ts +0 -32
  64. package/dist-client/modeller/angle-dial.js +0 -41
  65. package/dist-client/modeller/angle-dial.js.map +0 -1
  66. package/dist-client/modeller/base-box.d.ts +0 -71
  67. package/dist-client/modeller/base-box.js +0 -110
  68. package/dist-client/modeller/base-box.js.map +0 -1
  69. package/dist-client/modeller/figure-animations.d.ts +0 -159
  70. package/dist-client/modeller/figure-animations.js +0 -1132
  71. package/dist-client/modeller/figure-animations.js.map +0 -1
  72. package/dist-client/modeller/figure-canvas.d.ts +0 -150
  73. package/dist-client/modeller/figure-canvas.js +0 -506
  74. package/dist-client/modeller/figure-canvas.js.map +0 -1
  75. package/dist-client/modeller/figure-capabilities.d.ts +0 -42
  76. package/dist-client/modeller/figure-capabilities.js +0 -349
  77. package/dist-client/modeller/figure-capabilities.js.map +0 -1
  78. package/dist-client/modeller/figure-inspector.d.ts +0 -329
  79. package/dist-client/modeller/figure-inspector.js +0 -2095
  80. package/dist-client/modeller/figure-inspector.js.map +0 -1
  81. package/dist-client/modeller/figure-parts.d.ts +0 -64
  82. package/dist-client/modeller/figure-parts.js +0 -478
  83. package/dist-client/modeller/figure-parts.js.map +0 -1
  84. package/dist-client/modeller/figure-preview.d.ts +0 -138
  85. package/dist-client/modeller/figure-preview.js +0 -549
  86. package/dist-client/modeller/figure-preview.js.map +0 -1
  87. package/dist-client/modeller/figure-report.d.ts +0 -165
  88. package/dist-client/modeller/figure-report.js +0 -952
  89. package/dist-client/modeller/figure-report.js.map +0 -1
  90. package/dist-client/modeller/figure-settings.d.ts +0 -39
  91. package/dist-client/modeller/figure-settings.js +0 -317
  92. package/dist-client/modeller/figure-settings.js.map +0 -1
  93. package/dist-client/modeller/figure-side.d.ts +0 -78
  94. package/dist-client/modeller/figure-side.js +0 -317
  95. package/dist-client/modeller/figure-side.js.map +0 -1
  96. package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
  97. package/dist-client/modeller/figure-thumbnail.js +0 -95
  98. package/dist-client/modeller/figure-thumbnail.js.map +0 -1
  99. package/dist-client/modeller/figure-view.d.ts +0 -118
  100. package/dist-client/modeller/figure-view.js +0 -78
  101. package/dist-client/modeller/figure-view.js.map +0 -1
  102. package/dist-client/modeller/joint-edits.d.ts +0 -49
  103. package/dist-client/modeller/joint-edits.js +0 -223
  104. package/dist-client/modeller/joint-edits.js.map +0 -1
  105. package/dist-client/modeller/joint-gizmo.d.ts +0 -34
  106. package/dist-client/modeller/joint-gizmo.js +0 -158
  107. package/dist-client/modeller/joint-gizmo.js.map +0 -1
  108. package/dist-client/modeller/maturity.d.ts +0 -55
  109. package/dist-client/modeller/maturity.js +0 -212
  110. package/dist-client/modeller/maturity.js.map +0 -1
  111. package/dist-client/modeller/new-figure-entry.d.ts +0 -2
  112. package/dist-client/modeller/new-figure-entry.js +0 -5
  113. package/dist-client/modeller/new-figure-entry.js.map +0 -1
  114. package/dist-client/modeller/pose-defaults.d.ts +0 -9
  115. package/dist-client/modeller/pose-defaults.js +0 -35
  116. package/dist-client/modeller/pose-defaults.js.map +0 -1
  117. package/dist-client/pages/figure-modeller-page.d.ts +0 -419
  118. package/dist-client/pages/figure-modeller-page.js +0 -2060
  119. package/dist-client/pages/figure-modeller-page.js.map +0 -1
  120. package/test/angle-dial.test.ts +0 -78
  121. package/test/base-box.test.ts +0 -235
  122. package/test/joint-edits.test.ts +0 -165
  123. package/test/joint-gizmo.test.ts +0 -174
  124. package/test/joint-needle.test.ts +0 -162
  125. package/test/modeller-undo-redo.test.ts +0 -33
  126. package/test/new-figure-entry.test.ts +0 -15
  127. package/test/pose-defaults.test.ts +0 -71
  128. package/test/preview-board-depth.test.ts +0 -27
@@ -1,2208 +0,0 @@
1
- import '@material/web/icon/icon.js'
2
-
3
- import { css, html, LitElement, nothing, svg } from 'lit'
4
- import { customElement, property, state } from 'lit/decorators.js'
5
- import { live } from 'lit/directives/live.js'
6
-
7
- import { i18next, localize } from '@operato/i18n'
8
- import { ScrollbarStyles } from '@operato/styles'
9
- import {
10
- EXTRUDE_PRIMITIVES,
11
- MATERIAL_PRESETS,
12
- PRIMITIVE_KINDS,
13
- SEGMENTED_PRIMITIVES,
14
- SEGMENT_PRESETS,
15
- SIZING_RULES,
16
- FIGURE_PLACEMENTS,
17
- trianglesOf
18
- } from '@hatiolab/figure-model'
19
- import { ANCHOR_RULES, JOINT_TYPES, PART_ROLES, REPEAT_LIMIT, anchorOfPart, repeatPlanOfPart } from '@hatiolab/figure-model'
20
- import type {
21
- AnchorRule,
22
- Axis,
23
- ContractChange,
24
- FigurePart,
25
- FigurePlacement,
26
- JointType,
27
- MaterialPreset,
28
- PartRole,
29
- PrimitiveKind,
30
- Vec3
31
- } from '@hatiolab/figure-model'
32
- import { activePalette } from '@hatiolab/things-scene'
33
-
34
- import { partToFigure, partFromFigure } from './figure-source.js'
35
- import type { FigureDraft, PartModel } from './figure-source.js'
36
- import { canMirror } from './part-edits.js'
37
- import { jointOf, parentChoices, patchJoint, setJointType } from './joint-edits.js'
38
- import { baseFittingParts, baseOf, resizeBase } from './base-box.js'
39
- import type { BaseSize } from './base-box.js'
40
-
41
- /** Axis choices for a joint. Written as a direction the author reads, stored as a vector. */
42
- const JOINT_AXES: readonly { key: string; axis: Vec3 }[] = [
43
- { key: '+x', axis: { x: 1, y: 0, z: 0 } },
44
- { key: '-x', axis: { x: -1, y: 0, z: 0 } },
45
- { key: '+y', axis: { x: 0, y: 1, z: 0 } },
46
- { key: '-y', axis: { x: 0, y: -1, z: 0 } },
47
- { key: '+z', axis: { x: 0, y: 0, z: 1 } },
48
- { key: '-z', axis: { x: 0, y: 0, z: -1 } }
49
- ]
50
-
51
- function axisKeyOf(axis: Vec3): string | undefined {
52
- return JOINT_AXES.find(one => one.axis.x === axis.x && one.axis.y === axis.y && one.axis.z === axis.z)?.key
53
- }
54
-
55
- /**
56
- * 고른 부품의 속성.
57
- *
58
- * ## 여기가 사용자 용어로 보여 주는 자리다
59
- *
60
- * 모델러 페이지가 갖고 있는 것은 **씬 모델**이다 — 기준 상자 위에 부품 컴포넌트가 놓인 상태.
61
- * 그런데 사용자에게 보여야 하는 것은 씬 속성 이름(`left`·`top`·`zPos`)이 아니라
62
- * **X·Y·Z** 다. 다른 3D 도구가 모두 그렇게 부르고, 위치도 부품의 **중심**으로 읽는다.
63
- *
64
- * 그래서 이 패널은 받은 부품을 FigureSource 형태로 **변환해서 보여 주고, 수정한 것을
65
- * 다시 씬 모델로 되돌려 알린다.** 변환은 `figure-source.ts` 가 한다 — 여기서 축을 다시 계산하지
66
- * 않는다.
67
- *
68
- * ## 있는 것만 보여 준다
69
- *
70
- * 분할 수는 곡면이 있는 도형에만, 모서리 굴림은 단면을 뽑는 도형에만 뜻이 있다.
71
- * 뜻 없는 칸을 회색으로 늘어놓으면 저작자가 「왜 안 먹지」를 고민하게 된다 —
72
- * 아예 내지 않는다.
73
- *
74
- * ## 이름은 저장되는 식별자다
75
- *
76
- * `FigurePart.name` 은 바인딩·애니메이션·슬롯이 가리키는 이름이다. 고치면 배치된
77
- * 인스턴스의 바인딩이 **조용히** 끊긴다. 그래서 이름 칸에만 경고를 붙인다.
78
- *
79
- * ## 「상태 색이 붙는 자리」를 걷었다
80
- *
81
- * `FigurePart.materialSlot`(당시 이름은 `slot`) 을 고르는 칸이 있었는데 **아무 일도 하지 않았다.** things-scene 의
82
- * figure 런타임이 `slot` 을 읽지 않는다 — 선언 한 줄이 전부다. 되는 줄 알고 만들었고,
83
- * 확인하지 않은 것이 잘못이다.
84
- *
85
- * 그리고 되게 만들더라도 이 모양이 아니다. 상태 색은 특별한 것이 아니라 **속성 하나**이고,
86
- * 툴팁·라벨·표시 여부와 같은 자리에서 다뤄져야 한다. 그 설계가 정해지면(figure-model#2)
87
- * 이 자리에 **속성 선언과 부품 바인딩** 편집기가 들어온다.
88
- *
89
- * 그때까지 빈 약속을 두지 않는다. 아무 일도 안 하는 칸은 없느니만 못하다.
90
- *
91
- * ## 고치지 않는다
92
- *
93
- * 정본 한 벌은 저작면이 갖는다. 여기서는 바뀐 부품을 알릴 뿐이다.
94
- */
95
- @customElement('figure-inspector')
96
- export class FigureInspector extends localize(i18next)(LitElement) {
97
- static styles = [
98
- ScrollbarStyles,
99
- css`
100
- :host {
101
- display: flex;
102
- flex-direction: column;
103
- min-height: 0;
104
- --figure-control-height: 30px;
105
- overflow-y: auto;
106
- background-color: var(--md-sys-color-surface-container-lowest);
107
- color: var(--md-sys-color-on-surface);
108
- font: var(--label-font, inherit);
109
- /*
110
- 이 패널의 본문 크기를 여기서 한 번 정한다.
111
-
112
- 전에는 규칙마다 따로 적었다. 그러면 빠뜨린 규칙이 본문 기본 크기(1rem)를
113
- 물려받아 그 줄만 제목처럼 커진다. 오류가 아니라서 화면을 띄워 봐야 안다.
114
- 이 파일에서만 두 번 났다.
115
-
116
- form 요소(input · select · button)는 글꼴을 물려받지 않으므로 각자 적어야 한다.
117
- 그것 말고는 여기 값을 따른다.
118
- */
119
- font-size: 0.76rem;
120
- }
121
-
122
- /*
123
- ── 자리를 아끼는 판 ──────────────────────────────────────────────────────
124
- 속성은 앞으로 더 늘어난다(능력 · 포트 · 애니메이션 · 기준정보). 지금 여백을 그대로 두면
125
- 그것들이 들어올 자리가 없다. 절 여백을 6/10 으로, 제목 아래를 4px 로 줄였다 — 글자 크기와
126
- 칸 높이는 건드리지 않는다(작게 만드는 것이 아니라 **빈 곳을 줄이는 것**이다).
127
- */
128
- section {
129
- padding: 8px 10px;
130
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
131
- }
132
- /* 입력 행은 촘촘하게, 서로 다른 편집 의도 사이에는 호흡을 둔다. */
133
- section + section {
134
- margin-top: 6px;
135
- }
136
- section[collapsible] {
137
- padding: 0;
138
- }
139
- details[group] > summary {
140
- display: flex;
141
- align-items: center;
142
- justify-content: space-between;
143
- min-height: 34px;
144
- padding: 0 10px;
145
- cursor: pointer;
146
- list-style: none;
147
- }
148
- details[group] > summary::-webkit-details-marker {
149
- display: none;
150
- }
151
- details[group] > summary h3 {
152
- margin: 0;
153
- }
154
- details[group] > summary md-icon {
155
- --md-icon-size: 16px;
156
- color: var(--md-sys-color-on-surface-variant);
157
- }
158
- details[group] > div[group-content] {
159
- padding: 4px 10px 8px;
160
- border-top: 1px solid var(--md-sys-color-outline-variant);
161
- }
162
- h3 {
163
- display: flex;
164
- align-items: center;
165
- gap: 4px;
166
- margin: 0 0 4px 0;
167
- font-size: var(--ui-t-micro, 11px);
168
- font-weight: 700;
169
- letter-spacing: 0.04em;
170
- text-transform: uppercase;
171
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
172
- }
173
-
174
- /* 자산 메타데이터는 부품을 고친다는 현재 문맥에서는 요약으로만 둔다. */
175
- details[asset] {
176
- margin: -1px 0;
177
- }
178
- details[asset] summary {
179
- display: flex;
180
- align-items: center;
181
- gap: 6px;
182
- min-height: 26px;
183
- cursor: pointer;
184
- list-style: none;
185
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
186
- }
187
- details[asset] summary::-webkit-details-marker {
188
- display: none;
189
- }
190
- details[asset] summary::before {
191
- content: '›';
192
- font-size: 1.1rem;
193
- line-height: 1;
194
- transition: transform 120ms ease;
195
- }
196
- details[asset][open] summary::before {
197
- transform: rotate(90deg);
198
- }
199
- details[asset] summary b {
200
- font-size: var(--ui-t-micro, 11px);
201
- letter-spacing: 0.04em;
202
- }
203
- details[asset] summary small {
204
- overflow: hidden;
205
- text-overflow: ellipsis;
206
- white-space: nowrap;
207
- font-size: 0.7rem;
208
- }
209
-
210
- label {
211
- display: flex;
212
- align-items: center;
213
- gap: 3px;
214
- margin-bottom: 2px;
215
- font-size: 0.68rem;
216
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
217
- }
218
-
219
- /*
220
- 도움말 물음표. **글자 줄을 하나 먹지 않는다** — 라벨·제목 줄의 남는 폭에 얹힌다.
221
- 아이콘만 두고 테두리를 안 두는 이유는 이것이 누를 것이라기보다 「더 있다」는 표시라서다.
222
- */
223
- button[help] {
224
- display: inline-flex;
225
- align-items: center;
226
- padding: 0;
227
- margin: 0;
228
- color: var(--md-sys-color-outline);
229
- background: none;
230
- border: none;
231
- cursor: pointer;
232
- }
233
- button[help] md-icon {
234
- --md-icon-size: 13px;
235
- font-size: 13px;
236
- }
237
- button[help]:hover,
238
- button[help][aria-expanded='true'] {
239
- color: var(--md-sys-color-primary);
240
- }
241
-
242
- /* 편 도움말. 라벨 아래 한 칸 들여 붙인다 — 무엇에 딸린 말인지 보이게. */
243
- p[hint] {
244
- margin: 2px 0 4px 0;
245
- padding-left: 6px;
246
- font-size: 0.68rem;
247
- line-height: 1.5;
248
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
249
- border-left: 2px solid var(--md-sys-color-outline-variant);
250
- }
251
-
252
- input,
253
- select {
254
- width: 100%;
255
- min-height: var(--figure-control-height);
256
- box-sizing: border-box;
257
- padding: 4px 7px;
258
- font: var(--input-field-font, inherit);
259
- font-size: 0.76rem;
260
- color: var(--md-sys-color-on-surface);
261
- background-color: var(--md-sys-color-surface-container-lowest);
262
- border: 1px solid var(--md-sys-color-outline-variant);
263
- border-radius: 6px;
264
- }
265
- input:focus,
266
- select:focus {
267
- outline: 2px solid var(--md-sys-color-primary);
268
- outline-offset: -1px;
269
- }
270
-
271
- /*
272
- 숫자 칸 — 라벨을 칸 **안** 왼쪽에 둔다.
273
-
274
- 벡터 저작도구(Sketch·Figma)가 X·Y·W·H 를 이렇게 둔다. 라벨을 위에 얹으면 줄마다
275
- 두 줄을 먹어서, 자리·크기·회전 아홉 칸이 화면 절반을 차지한다. 안에 두면 한 줄에
276
- 셋이 들어가고 무엇이 무엇인지도 붙어 읽힌다.
277
- */
278
- div[cell] {
279
- display: flex;
280
- box-sizing: border-box;
281
- height: var(--figure-control-height);
282
- min-height: var(--figure-control-height);
283
- align-items: center;
284
- gap: 2px;
285
- padding-left: 6px;
286
- background-color: var(--md-sys-color-surface-container-lowest);
287
- border: 1px solid var(--md-sys-color-outline-variant);
288
- border-radius: 6px;
289
- }
290
- div[cell]:focus-within {
291
- outline: 2px solid var(--md-sys-color-primary);
292
- outline-offset: -1px;
293
- border-color: transparent;
294
- }
295
- div[cell] span[axis] {
296
- flex: none;
297
- font-size: 0.64rem;
298
- letter-spacing: 0.02em;
299
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
300
- }
301
- div[cell] input {
302
- min-width: 0;
303
- min-height: 0;
304
- height: calc(var(--figure-control-height) - 2px);
305
- padding: 4px 6px 4px 2px;
306
- font-family: var(--mono-font, monospace);
307
- text-align: right;
308
- background: none;
309
- border: none;
310
- border-radius: 0;
311
- }
312
- div[cell] input:focus {
313
- outline: none;
314
- }
315
-
316
- /* 축 셋을 한 줄에 — 좌표는 셋이 같이 읽혀야 한다 */
317
- div[triple] {
318
- display: grid;
319
- grid-template-columns: repeat(3, 1fr);
320
- gap: 4px;
321
- }
322
- /*
323
- 값이 둘인 줄은 셋으로 나누지 않는다. 가동 범위를 세 칸짜리 줄에 넣었더니 칸이 폭의 1/3 이라
324
- 「최소」라는 말과 -120 이 한 칸에 못 들어가 숫자가 잘렸다.
325
- */
326
- div[double] {
327
- display: grid;
328
- grid-template-columns: repeat(2, 1fr);
329
- gap: 4px;
330
- }
331
- /* 칸 아래에 붙는 한 줄짜리 행동. 칸과 같은 폭으로 둬야 한 묶음으로 읽힌다. */
332
- button[fit] {
333
- display: inline-flex;
334
- align-items: center;
335
- justify-content: center;
336
- gap: 4px;
337
- width: 100%;
338
- margin-top: 4px;
339
- padding: 3px 8px;
340
- font: inherit;
341
- font-size: 0.72rem;
342
- color: var(--md-sys-color-on-surface);
343
- background-color: var(--md-sys-color-surface-container);
344
- border: 1px solid var(--md-sys-color-outline-variant);
345
- border-radius: 6px;
346
- cursor: pointer;
347
- }
348
- /* 고친 칸 바로 아래에 붙는 말. 막는 것은 오류 색, 알리는 것은 조용한 색. */
349
- p[contract] {
350
- display: flex;
351
- gap: 4px;
352
- margin: 4px 0 0;
353
- font-size: 0.68rem;
354
- line-height: 1.4;
355
- color: var(--md-sys-color-on-surface-variant);
356
- }
357
- p[contract][blocking] {
358
- color: var(--md-sys-color-error);
359
- }
360
- p[contract] md-icon {
361
- --md-icon-size: 14px;
362
- flex: none;
363
- }
364
-
365
- button[fit]:hover {
366
- background-color: var(--md-sys-color-surface-container-high);
367
- }
368
- button[fit] md-icon {
369
- --md-icon-size: 15px;
370
- }
371
- div[field] + div[field],
372
- div[triple] + div[field],
373
- div[field] + div[triple],
374
- div[double] + div[field],
375
- div[field] + div[double],
376
- div[inline-field] + div[inline-field],
377
- div[inline-field] + div[field],
378
- div[field] + div[inline-field],
379
- div[vector-row] + div[vector-row],
380
- div[vector-row] + div[field],
381
- div[field] + div[vector-row] {
382
- /* 칸 사이 7 → 5. 칸 자체는 그대로 두고 사이만 좁힌다. */
383
- margin-top: 5px;
384
- }
385
-
386
- /* 짧은 라벨과 단일 값은 같은 줄에서 읽는다. 복잡한 선택기에는 쓰지 않는다. */
387
- div[inline-field] {
388
- display: grid;
389
- /* 같은 편집 밀도 안에서는 입력 시작선을 맞춘다. 라벨 길이가 칸 폭을 흔들면 안 된다. */
390
- grid-template-columns: 64px minmax(0, 1fr);
391
- align-items: center;
392
- gap: 7px;
393
- }
394
- div[inline-field] > label {
395
- margin: 0;
396
- white-space: nowrap;
397
- }
398
- /* 한 줄 입력의 설명은 입력칸을 밀어내지 않고 바로 아래 전폭에서 읽는다. */
399
- div[inline-field] > p[hint] {
400
- grid-column: 1 / -1;
401
- margin: -2px 0 1px;
402
- }
403
- div[inline-field] > code[fixed] {
404
- min-width: 0;
405
- overflow: hidden;
406
- text-overflow: ellipsis;
407
- white-space: nowrap;
408
- }
409
-
410
- /* X·Y·Z는 함께 비교하는 값이므로, 묶음 제목만 왼쪽에 두고 입력은 한 행에 둔다. */
411
- div[vector-row] {
412
- display: grid;
413
- grid-template-columns: 64px minmax(0, 1fr);
414
- align-items: center;
415
- gap: 7px;
416
- }
417
- div[vector-row] > label {
418
- margin: 0;
419
- white-space: nowrap;
420
- }
421
- div[vector-row] > p[hint] {
422
- grid-column: 1 / -1;
423
- margin: -2px 0 1px;
424
- }
425
- div[sizing-choices] {
426
- display: grid;
427
- grid-template-columns: 1fr 1fr;
428
- gap: 5px;
429
- }
430
- button[sizing-choice] {
431
- display: flex;
432
- flex-direction: column;
433
- align-items: flex-start;
434
- gap: 2px;
435
- min-height: 52px;
436
- padding: 7px 8px;
437
- color: var(--md-sys-color-on-surface);
438
- background: var(--md-sys-color-surface-container-lowest);
439
- border: 1px solid var(--md-sys-color-outline-variant);
440
- border-radius: 7px;
441
- text-align: left;
442
- cursor: pointer;
443
- }
444
- button[sizing-choice][on] {
445
- color: var(--md-sys-color-on-primary-container);
446
- background: var(--md-sys-color-primary-container);
447
- border-color: transparent;
448
- }
449
- button[sizing-choice] b {
450
- font-size: 0.74rem;
451
- }
452
- button[sizing-choice] small {
453
- font-size: 0.64rem;
454
- line-height: 1.35;
455
- opacity: 0.8;
456
- }
457
- div[anchor-rows] {
458
- display: grid;
459
- gap: 6px;
460
- }
461
- div[anchor-row] {
462
- display: grid;
463
- grid-template-columns: 46px minmax(0, 1fr);
464
- align-items: start;
465
- gap: 8px;
466
- padding: 6px 7px;
467
- background: var(--md-sys-color-surface-container);
468
- border-radius: 7px;
469
- }
470
- div[anchor-row] label {
471
- margin: 0;
472
- font-size: 0.7rem;
473
- font-weight: 700;
474
- }
475
- div[anchor-options] {
476
- display: grid;
477
- grid-template-columns: repeat(3, minmax(0, 1fr));
478
- gap: 4px;
479
- }
480
- button[anchor-choice] {
481
- min-width: 0;
482
- height: var(--figure-control-height);
483
- box-sizing: border-box;
484
- padding: 4px 3px;
485
- overflow: hidden;
486
- color: var(--md-sys-color-on-surface);
487
- font: var(--label-font, inherit);
488
- font-size: 0.63rem;
489
- line-height: 1.2;
490
- text-overflow: ellipsis;
491
- white-space: nowrap;
492
- background: var(--md-sys-color-surface-container-lowest);
493
- border: 1px solid var(--md-sys-color-outline-variant);
494
- border-radius: 5px;
495
- cursor: pointer;
496
- }
497
- button[anchor-choice][on] {
498
- color: var(--md-sys-color-on-primary-container);
499
- background: var(--md-sys-color-primary-container);
500
- border-color: transparent;
501
- }
502
- details[anchor-advanced] {
503
- grid-column: 2;
504
- margin-top: 4px;
505
- }
506
- details[anchor-advanced] summary {
507
- cursor: pointer;
508
- font-size: 0.62rem;
509
- color: var(--md-sys-color-on-surface-variant);
510
- }
511
- details[anchor-advanced] div[anchor-options] {
512
- margin-top: 4px;
513
- }
514
- div[palette-meaning] {
515
- display: grid;
516
- gap: 2px;
517
- margin-top: 7px;
518
- padding: 6px 8px;
519
- color: var(--md-sys-color-on-surface);
520
- background: var(--md-sys-color-surface-container);
521
- border-radius: 7px;
522
- }
523
- div[palette-meaning] b {
524
- font-size: 0.72rem;
525
- }
526
- div[palette-meaning] small {
527
- color: var(--md-sys-color-on-surface-variant);
528
- font-size: 0.64rem;
529
- line-height: 1.35;
530
- }
531
-
532
- /*
533
- 정밀 제작 도구.
534
-
535
- 도면 편집기는 자산을 배치하는 곳이라 대충 놓아도 되지만, 여기는 자산을 만드는
536
- 곳이다. 「몸통 한가운데」·「바닥에 붙이기」·「반대쪽에도 하나」를 손으로 계산하면
537
- 늘 조금씩 어긋나고, 기준 치수를 바꾸면 전부 다시 해야 한다.
538
- */
539
- div[tools] {
540
- display: grid;
541
- grid-template-columns: repeat(6, minmax(0, 1fr));
542
- gap: 4px;
543
- }
544
- div[tools] button {
545
- display: grid;
546
- place-items: center;
547
- min-width: 0;
548
- height: var(--figure-control-height);
549
- box-sizing: border-box;
550
- padding: 0;
551
- font: var(--label-font, inherit);
552
- font-size: 0.7rem;
553
- color: var(--md-sys-color-on-surface);
554
- background: none;
555
- border: 1px solid var(--md-sys-color-outline-variant);
556
- border-radius: 6px;
557
- cursor: pointer;
558
- }
559
- div[tools] button md-icon {
560
- font-size: 17px;
561
- }
562
- div[tools] button[disabled] {
563
- opacity: 0.35;
564
- cursor: default;
565
- }
566
- div[tools] button:hover:not([disabled]) {
567
- color: var(--md-sys-color-on-primary-container);
568
- background-color: var(--md-sys-color-primary-container);
569
- border-color: transparent;
570
- }
571
-
572
- /* 지금 어느 축이 늘어나나 — 크기를 고치면 바뀔 수 있어서 값으로 적는다 */
573
- p[axis-now] {
574
- margin: 4px 0 0 0;
575
- font-size: 0.7rem;
576
- color: var(--md-sys-color-primary);
577
- }
578
- /* 이 선택이 무엇을 무는지 — 성숙도의 묶음 수에 그대로 잡힌다 */
579
- p[cost] {
580
- margin: 4px 0 0 0;
581
- font-size: 0.68rem;
582
- line-height: 1.5;
583
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
584
- }
585
-
586
- /* 이름을 바꾸면 무엇이 끊기는지 말한다 */
587
- p[warn] {
588
- margin: 4px 0 0 0;
589
- font-size: 0.68rem;
590
- line-height: 1.5;
591
- color: var(--md-sys-color-tertiary);
592
- }
593
-
594
- /* 분할 수 프리셋 — 자유 입력보다 이 셋을 권한다 */
595
- div[presets] {
596
- display: flex;
597
- gap: 4px;
598
- margin-top: 4px;
599
- }
600
- div[presets] button {
601
- flex: 1;
602
- padding: 3px 0;
603
- font: var(--label-font, inherit);
604
- font-size: 0.72rem;
605
- color: var(--md-sys-color-on-surface);
606
- background: none;
607
- border: 1px solid var(--md-sys-color-outline-variant);
608
- border-radius: 6px;
609
- cursor: pointer;
610
- }
611
- div[presets] button[on] {
612
- color: var(--md-sys-color-on-primary-container);
613
- background-color: var(--md-sys-color-primary-container);
614
- border-color: transparent;
615
- }
616
-
617
- /*
618
- 분할 수 선택 — 모양과 삼각형 수를 **함께** 보여 준다.
619
-
620
- 숫자만 늘어놓으면 8 과 16 이 무엇이 다른지 알 수 없다. 로우폴리에서 이 선택이
621
- 곧 「몇 개까지 편하게 놓을 수 있나」를 정하므로, 각지는 정도와 그 값이 무는
622
- 삼각형 수를 한자리에서 보아야 한다.
623
-
624
- 실루엣은 지어낸 그림이 아니다 — 곡면 프리미티브를 위에서 본 윤곽이 정확히
625
- 정n각형이다. 삼각형 수는 화면이 셈하지 않고 figure-model 의 trianglesOf 가 낸다.
626
- */
627
- div[segments] {
628
- display: grid;
629
- grid-template-columns: repeat(4, 1fr);
630
- gap: 4px;
631
- }
632
- /* 원 유지 · 끄기 — 고르는 값이라 분할 수와 같은 모양으로 둔다 */
633
- div[choices] {
634
- display: grid;
635
- grid-template-columns: 1fr 1fr;
636
- gap: 4px;
637
- }
638
- button[choice] {
639
- display: flex;
640
- flex-direction: column;
641
- align-items: flex-start;
642
- gap: 1px;
643
- padding: 6px 8px;
644
- text-align: left;
645
- font: var(--label-font, inherit);
646
- color: var(--md-sys-color-on-surface);
647
- background: none;
648
- border: 1px solid var(--md-sys-color-outline-variant);
649
- border-radius: 6px;
650
- cursor: pointer;
651
- }
652
- button[choice]:hover:not([on]) {
653
- background-color: var(--md-sys-color-surface-container);
654
- }
655
- button[choice][on] {
656
- color: var(--md-sys-color-on-primary-container);
657
- background-color: var(--md-sys-color-primary-container);
658
- border-color: transparent;
659
- }
660
- button[choice] small {
661
- opacity: 0.75;
662
- }
663
- button[seg] {
664
- display: flex;
665
- flex-direction: column;
666
- align-items: center;
667
- gap: 1px;
668
- padding: 6px 2px 4px 2px;
669
- font: var(--label-font, inherit);
670
- color: var(--md-sys-color-on-surface);
671
- background: none;
672
- border: 1px solid var(--md-sys-color-outline-variant);
673
- border-radius: 6px;
674
- cursor: pointer;
675
- }
676
- button[seg]:hover:not([on]) {
677
- background-color: var(--md-sys-color-surface-container);
678
- }
679
- button[seg][on] {
680
- color: var(--md-sys-color-on-primary-container);
681
- background-color: var(--md-sys-color-primary-container);
682
- border-color: transparent;
683
- }
684
- button[seg] svg {
685
- display: block;
686
- /* 작으면 12 와 16 이 둘 다 동그라미로 보인다 — 각이 읽힐 만큼은 키운다 */
687
- width: 32px;
688
- height: 32px;
689
- }
690
- button[seg] svg polygon {
691
- fill: var(--md-sys-color-surface-container-highest);
692
- stroke: currentColor;
693
- stroke-width: 1.1;
694
- stroke-linejoin: miter;
695
- }
696
- button[seg][on] svg polygon {
697
- fill: var(--md-sys-color-surface-container-lowest);
698
- }
699
- button[seg] b {
700
- font-size: 0.8rem;
701
- font-weight: 700;
702
- line-height: 1.1;
703
- }
704
- button[seg] small {
705
- font-size: 0.62rem;
706
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
707
- }
708
- button[seg][on] small {
709
- color: inherit;
710
- }
711
- /* 성능·실루엣 검증을 마친 프리셋만 고른다. */
712
- details[free] {
713
- margin-top: 6px;
714
- }
715
- details[free] summary {
716
- font-size: 0.68rem;
717
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
718
- cursor: pointer;
719
- }
720
- details[free] div[cell] {
721
- margin-top: 4px;
722
- }
723
-
724
- /*
725
- 색 견본.
726
-
727
- 토큰을 글자로 받지 않는다. things-scene 의 팔레트 선언이 「여기 든 이름이 저작에서
728
- 고를 수 있는 전부이고, 자유색을 허용하지 않는 것이 감각적인 로우폴리의 첫 규칙」
729
- 이라고 못박고 있다. 글자 칸으로 두면 그 규칙이 화면에서 사라지고, 오타 하나로
730
- resolveToken 이 던진다.
731
- */
732
- div[swatches] {
733
- display: flex;
734
- flex-wrap: wrap;
735
- gap: 6px;
736
- }
737
- /*
738
- 두 무리의 경계. 「칠한 색」과 「켜진 색」이 한 줄에 섞이면 accent(주황)와
739
- status-warning(주황)이 나란히 놓여 무엇이 무엇인지 알 수 없다.
740
- */
741
- label[group] {
742
- margin-top: 8px;
743
- padding-top: 8px;
744
- font-size: var(--ui-t-micro, 11px);
745
- letter-spacing: 0.03em;
746
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
747
- border-top: 1px solid var(--md-sys-color-outline-variant);
748
- }
749
-
750
- /* 값이 옆에 붙어야 끌면서 얼마인지 안다 */
751
- div[slider] {
752
- display: flex;
753
- align-items: center;
754
- gap: 8px;
755
- }
756
- div[slider] input[type='range'] {
757
- flex: 1;
758
- accent-color: var(--md-sys-color-primary);
759
- cursor: pointer;
760
- }
761
- div[slider] output {
762
- flex: none;
763
- width: 2.6em;
764
- text-align: right;
765
- font: var(--mono-font, ui-monospace, monospace);
766
- font-size: 0.72rem;
767
- color: var(--md-sys-color-on-surface-variant);
768
- }
769
- button[swatch] {
770
- width: 26px;
771
- height: 26px;
772
- padding: 0;
773
- background-color: var(--swatch);
774
- border: 1px solid rgba(0, 0, 0, 0.16);
775
- border-radius: 50%;
776
- cursor: pointer;
777
- }
778
- button[swatch][on] {
779
- /* 고른 것은 테두리가 아니라 고리로 표시한다 — 색 자체를 가리지 않으려고 */
780
- box-shadow:
781
- 0 0 0 2px var(--md-sys-color-surface-container-lowest),
782
- 0 0 0 4px var(--md-sys-color-primary);
783
- }
784
- span[token] {
785
- display: block;
786
- margin-top: 6px;
787
- font-family: var(--mono-font, monospace);
788
- font-size: 0.68rem;
789
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
790
- }
791
-
792
- label[toggle] {
793
- display: flex;
794
- align-items: center;
795
- gap: 6px;
796
- margin: 7px 0 0 0;
797
- font-size: 0.74rem;
798
- color: var(--md-sys-color-on-surface);
799
- cursor: pointer;
800
- }
801
- label[toggle] input {
802
- width: auto;
803
- }
804
- div[role-choices] {
805
- display: grid;
806
- grid-template-columns: repeat(3, minmax(0, 1fr));
807
- gap: 4px;
808
- }
809
- div[role-choices] label[toggle] {
810
- justify-content: center;
811
- min-height: 30px;
812
- margin: 0;
813
- padding: 2px 4px;
814
- border: 1px solid var(--md-sys-color-outline-variant);
815
- border-radius: 6px;
816
- font-size: 0.68rem;
817
- text-align: center;
818
- }
819
- div[role-choices] label[toggle]:has(input:checked) {
820
- color: var(--md-sys-color-on-primary-container);
821
- background: var(--md-sys-color-primary-container);
822
- border-color: transparent;
823
- }
824
-
825
- p[quiet] {
826
- margin: 0;
827
- font-size: 0.76rem;
828
- line-height: 1.6;
829
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
830
- }
831
-
832
- /*
833
- 고칠 수 없는 값 — 입력칸처럼 보이지 않게 한다.
834
-
835
- 전에는 그냥 글자였고, 옆의 이름 칸과 나란히 있으니 눌러도 되는 것처럼 보였다. 자물쇠와
836
- 칸 없는 바탕이 「읽는 값」이라고 먼저 말한다.
837
- */
838
- code[fixed] {
839
- display: inline-flex;
840
- align-items: center;
841
- gap: 4px;
842
- padding: 4px 8px;
843
- border-radius: 6px;
844
- background-color: var(--md-sys-color-surface-container-high, rgba(0, 0, 0, 0.05));
845
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
846
- font-family: var(--mono-font, monospace);
847
- font-size: 0.76rem;
848
- }
849
-
850
- code[fixed] md-icon {
851
- --md-icon-size: 13px;
852
- opacity: 0.7;
853
- }
854
- `
855
- ]
856
-
857
- /** 도형의 DB 식별자. 비어 있으면 미저장 신규 모델이다. */
858
- @property({ type: String }) figureId = ''
859
-
860
- /**
861
- * 발행 판 번호. 0 이면 한 번도 발행되지 않았고, 그때만 `type` 을 고칠 수 있다(ADR-0063 결정 4).
862
- */
863
- @property({ type: Number }) figureVersion = 0
864
-
865
- /**
866
- * 타입이 굳었나 — **한 번이라도 발행됐으면 굳는다**(ADR-0063 결정 4).
867
- *
868
- * 저장되지 않은 새 도형과 아직 발행 전인 초안에서는 고칠 수 있다. 전에는 「저장됐나」로
869
- * 갈랐고, 그래서 저작자가 기계가 지은 이름(`FIGURE_MU6RJH47`)을 한 번도 못 고쳤다.
870
- */
871
- private get typeIsFixed(): boolean {
872
- return !!this.figureId && (this.figureVersion ?? 0) > 0
873
- }
874
- /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
875
- @property({ type: String }) figureName = ''
876
- /** 분류 하나 — 팔레트에서 묶는 기준. */
877
- @property({ type: String }) figureCategory?: string
878
- /** 태그 여럿 — 분류로는 못 하는 찾기. */
879
- @property({ type: Array }) figureTags?: string[]
880
-
881
- /** 펴 둔 도움말. 부품을 바꿔도 유지된다 — 매번 다시 펴게 하지 않는다. */
882
- @state() private opened = new Set<string>()
883
-
884
- @property({ type: Object }) draft?: FigureDraft
885
- /** 초안 위의 부품들. 씬 모델 그대로다. */
886
- @property({ type: Array }) parts: PartModel[] = []
887
-
888
- /**
889
- * 이 고침이 이미 놓인 인스턴스에 무엇을 하나 — 발행된 도형에만 있다(ADR-0063 결정 3).
890
- *
891
- * 발행 문에서만 말하면 저작자는 열 군데를 고친 뒤에 듣는다. 고치는 칸 옆에서 말한다.
892
- */
893
- @property({ type: Array }) contract: ContractChange[] = []
894
-
895
- /** 견주지 못한 까닭. 빈 목록과 뜻이 다르다 — 「깨지 않는다」가 아니라 「모른다」다. */
896
- @property({ type: String }) contractFailure?: string
897
- @property({ type: Number }) selected = -1
898
-
899
- /** `part` 는 HTMLElement 의 속성(DOMTokenList)이다 — 가리면 형이 어긋난다. */
900
- private get chosen(): PartModel | undefined {
901
- return this.parts?.[this.selected]
902
- }
903
-
904
- render() {
905
- const part = this.chosen
906
- if (!part || !this.draft) {
907
- return html`
908
- ${this.renderFigure(false)}
909
- <section>
910
- <h3>${i18next.t('figure.label.properties')}</h3>
911
- <p quiet>${i18next.t('figure.text.select-a-part-to-edit')}</p>
912
- </section>
913
- `
914
- }
915
-
916
- // 여기부터는 사용자 용어다 — X·Y·Z, 위치는 부품 중심, 회전은 도(degree).
917
- const shown = partToFigure(part, this.draft)
918
-
919
- /*
920
- 역할을 먼저 묻는다.
921
-
922
- 자리(slot)로 지정한 부품은 **그려지지 않는다.** 그 사실을 모른 채 재질부터 고르면 안 쓰일
923
- 색을 고르게 된다. 그리고 자리는 도형이 능력을 질 때만 뜻이 있으므로, 능력이 없으면 이
924
- 묶음은 아예 나오지 않는다 — 해 둬도 아무 일도 안 일어나는 칸을 보여 주지 않는다.
925
- */
926
- return html`
927
- ${this.renderFigure(true)} ${this.renderIdentity(shown)} ${this.renderRole(shown)} ${this.renderJoint(shown)}
928
- ${this.renderPlacement(shown)} ${this.renderShape(shown)} ${this.renderMaterial(shown)}
929
- ${this.renderBehaviour(shown)}
930
- `
931
- }
932
-
933
- /**
934
- * ── 이 부품이 능력에서 맡는 자리 ────────────────────────────────────────────
935
- *
936
- * 「능력」 탭이 도형 전체가 무엇을 하는지 정하고, 어느 부품이 그것을 받쳐 주는지는 여기서
937
- * 정한다. **부품을 고치는 곳이 한 군데여야** 하기 때문이다 — 목록을 따로 두면 같은 부품을
938
- * 두 화면에서 고치게 되고, 저작자는 어느 쪽이 이기는지 매번 생각해야 한다.
939
- *
940
- * 칸 수는 `repeat` 가 있으면 받지 않는다. 반복하는 자리의 칸 수는 **인스턴스 크기**가 정한다 —
941
- * 컨베이어가 길어지면 실을 자리가 늘어나는 것이 그것이고, 여기 적은 값은 안 쓰인다.
942
- */
943
- private renderRole(part: FigurePart) {
944
- if (!this.draft?.capabilities?.length) return nothing
945
-
946
- const roles = part.capability?.roles ?? []
947
- const repeats = part.sizing === 'repeat' && !!part.repeat
948
-
949
- return html`
950
- <section>
951
- <h3>${i18next.t('figure.label.role-in-capability')}${this.help('figure.text.anchors-are-not-drawn')}</h3>
952
-
953
- <div role-choices>
954
- ${(PART_ROLES as readonly PartRole[]).map(
955
- one => html`
956
- <label toggle>
957
- <input
958
- type="checkbox"
959
- .checked=${live(roles.includes(one))}
960
- @change=${(e: Event) => this.toggleRole(one, (e.target as HTMLInputElement).checked)}
961
- />
962
- ${i18next.t('figure.label.role-' + one)}
963
- </label>
964
- `
965
- )}
966
- </div>
967
- ${roles.length === 0 ? html`<p quiet>${i18next.t('figure.text.role-none-means-drawn')}</p>` : nothing}
968
-
969
- ${roles.includes('slot')
970
- ? html`
971
- <div field>
972
- <label>${i18next.t('figure.label.accepts')}${this.help('figure.text.accepts-explained')}</label>
973
- <input
974
- .value=${(part.capability?.accepts ?? []).join(', ')}
975
- placeholder=${i18next.t('figure.text.accepts-anything')}
976
- @change=${(e: Event) => this.setAccepts((e.target as HTMLInputElement).value)}
977
- />
978
- </div>
979
- <div field>
980
- <label>${i18next.t('figure.label.slot-capacity')}</label>
981
- ${repeats
982
- ? html`<p quiet>${i18next.t('figure.text.capacity-from-repeat')}</p>`
983
- : this.number('', part.capability?.capacity ?? 1, value => this.setCapacity(value), 1)}
984
- </div>
985
- `
986
- : nothing}
987
- </section>
988
- `
989
- }
990
-
991
- /**
992
- * How this part hangs from another and moves relative to it (ADR-0066).
993
- *
994
- * The parent is written on the part. The joint says how the part moves relative to that parent, and the
995
- * parameter it comes with is what drives it; the preview shows that parameter as a slider.
996
- */
997
- private renderJoint(part: FigurePart) {
998
- const joint = jointOf(this.draft, part.name)
999
- const choices = parentChoices(this.parts, part.name)
1000
- const unit = joint?.type === 'prismatic' ? 'mm' : 'deg'
1001
- const axisKey = joint ? axisKeyOf(joint.axis) : undefined
1002
- const driver = joint
1003
- ? this.draft?.parameters?.find(one => one.clip?.channels?.some(channel => channel.target === joint.name))
1004
- : undefined
1005
-
1006
- return html`
1007
- <section>
1008
- <h3>${i18next.t('figure.label.joint')}${this.help('figure.text.joint-explained')}</h3>
1009
-
1010
- <div inline-field>
1011
- <label>${i18next.t('figure.label.joint-parent')}</label>
1012
- <select @change=${(e: Event) => this.setParent((e.target as HTMLSelectElement).value)}>
1013
- <option value="" ?selected=${part.parent === undefined}>${i18next.t('figure.label.joint-parent-none')}</option>
1014
- ${part.parent !== undefined && !choices.includes(part.parent)
1015
- ? html`<option value=${part.parent} selected>${part.parent}</option>`
1016
- : nothing}
1017
- ${choices.map(name => html`<option value=${name} ?selected=${name === part.parent}>${name}</option>`)}
1018
- </select>
1019
- </div>
1020
-
1021
- <div inline-field>
1022
- <label>${i18next.t('figure.label.joint-type')}</label>
1023
- <select @change=${(e: Event) => this.setJointType(part, (e.target as HTMLSelectElement).value)}>
1024
- <option value="" ?selected=${!joint}>${i18next.t('figure.label.joint-type-none')}</option>
1025
- ${(JOINT_TYPES as readonly JointType[]).map(
1026
- type => html`<option value=${type} ?selected=${joint?.type === type}>${i18next.t('figure.label.joint-type-' + type)}</option>`
1027
- )}
1028
- </select>
1029
- </div>
1030
-
1031
- ${joint
1032
- ? html`
1033
- <div inline-field>
1034
- <label>${i18next.t('figure.label.joint-axis')}</label>
1035
- <select @change=${(e: Event) => this.setJointAxis(joint.name, (e.target as HTMLSelectElement).value)}>
1036
- ${axisKey === undefined
1037
- ? html`<option value="" selected>${joint.axis.x}, ${joint.axis.y}, ${joint.axis.z}</option>`
1038
- : nothing}
1039
- ${JOINT_AXES.map(one => html`<option value=${one.key} ?selected=${one.key === axisKey}>${one.key.toUpperCase()}</option>`)}
1040
- </select>
1041
- </div>
1042
-
1043
- <div vector-row>
1044
- <label>${i18next.t('figure.label.joint-origin')}</label>
1045
- <div triple>
1046
- ${(['x', 'y', 'z'] as const).map(axis =>
1047
- this.number(axis.toUpperCase(), joint.origin[axis], value =>
1048
- this.tellDraft(patchJoint(this.draft!, joint.name, { origin: { ...joint.origin, [axis]: value } }))
1049
- )
1050
- )}
1051
- </div>
1052
- </div>
1053
-
1054
- ${joint.limits
1055
- ? html`
1056
- <div vector-row>
1057
- <label>${i18next.t('figure.label.joint-limits-' + unit)}</label>
1058
- <div double>
1059
- ${this.number(i18next.t('figure.label.joint-limit-min'), joint.limits.min, value =>
1060
- this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, min: value } }))
1061
- )}
1062
- ${this.number(i18next.t('figure.label.joint-limit-max'), joint.limits.max, value =>
1063
- this.tellDraft(patchJoint(this.draft!, joint.name, { limits: { ...joint.limits!, max: value } }))
1064
- )}
1065
- </div>
1066
- </div>
1067
- ${joint.limits.min > 0 || joint.limits.max < 0
1068
- ? html`<p warn>${i18next.t('figure.text.joint-limits-must-include-zero')}</p>`
1069
- : nothing}
1070
- `
1071
- : nothing}
1072
-
1073
- <p quiet>
1074
- ${driver
1075
- ? i18next.t('figure.text.joint-driven-by', { name: driver.label ?? driver.name })
1076
- : i18next.t('figure.text.joint-not-driven')}
1077
- </p>
1078
- `
1079
- : nothing}
1080
- </section>
1081
- `
1082
- }
1083
-
1084
- private setParent(name: string) {
1085
- this.change(part => {
1086
- const { parent: _dropped, ...rest } = part
1087
- return name === '' ? (rest as FigurePart) : { ...rest, parent: name }
1088
- })
1089
- }
1090
-
1091
- /**
1092
- * 기준 상자를 고친다 — 부품은 저장되는 자리에 그대로 둔 채(`base-box.ts`).
1093
- *
1094
- * 초안과 부품이 함께 바뀌므로 둘을 한 번에 올린다. 따로 올리면 되돌리기가 둘로 갈리고, 그 사이
1095
- * 한 번만 되돌린 화면은 부품이 반쯤 밀린 모습이 된다.
1096
- */
1097
- /**
1098
- * 기준 상자를 고친 것이 **이미 놓인 인스턴스에 무엇을 하나** — 고친 칸 바로 아래에서 말한다.
1099
- *
1100
- * 막는 것과 알리는 것을 가른다. 비율이 달라지면 놓인 것이 찌그러지므로 발행이 막히고, 크기만
1101
- * 달라지는 것은 실척이 어긋날 뿐이라 알리기만 한다. 까닭은 형식이 적은 것을 그대로 옮긴다 —
1102
- * 여기서 다시 쓰면 두 벌이 되고 곧 갈린다.
1103
- */
1104
- private renderBaseContract() {
1105
- if (this.contractFailure) {
1106
- return html`<p contract>
1107
- <md-icon>help</md-icon>
1108
- <span>${i18next.t('figure.text.contract-not-compared', { why: this.contractFailure })}</span>
1109
- </p>`
1110
- }
1111
-
1112
- const mine = this.contract.filter(one => one.code === 'base-ratio-changed' || one.code === 'base-size-changed')
1113
- if (mine.length === 0) return nothing
1114
-
1115
- return html`${mine.map(
1116
- one => html`<p contract ?blocking=${one.blocking}>
1117
- <md-icon>${one.blocking ? 'block' : 'info'}</md-icon>
1118
- <span><b>${one.message}</b> ${one.why}</span>
1119
- </p>`
1120
- )}`
1121
- }
1122
-
1123
- private resizeBase(patch: Partial<BaseSize>) {
1124
- if (!this.draft) return
1125
-
1126
- const next = resizeBase(this.draft, this.parts, { ...baseOf(this.draft), ...patch })
1127
- if (next.draft === this.draft) return
1128
-
1129
- this.dispatchEvent(
1130
- new CustomEvent('resize-base', {
1131
- detail: { draft: next.draft, parts: next.parts },
1132
- bubbles: true,
1133
- composed: true
1134
- })
1135
- )
1136
- }
1137
-
1138
- /** 부품 전부를 감싸는 가장 작은 상자로. 맞출 것이 없으면 아무 일도 안 한다. */
1139
- private fitBaseToParts() {
1140
- if (!this.draft) return
1141
- const fitted = baseFittingParts(this.draft, this.parts)
1142
- if (fitted) this.resizeBase(fitted)
1143
- }
1144
-
1145
- private setJointType(part: FigurePart, value: string) {
1146
- if (!this.draft) return
1147
-
1148
- /* 붙은 부품을 함께 준다 — 새 관절이 둘이 맞닿는 자리에 서려면 그쪽 치수를 알아야 한다. */
1149
- const parent = part.parent ? this.parts.find(one => one.name === part.parent) : undefined
1150
- this.tellDraft(
1151
- setJointType(
1152
- this.draft,
1153
- part,
1154
- value === '' ? undefined : (value as JointType),
1155
- parent ? partToFigure(parent, this.draft) : undefined
1156
- )
1157
- )
1158
- }
1159
-
1160
- private setJointAxis(name: string, key: string) {
1161
- const choice = JOINT_AXES.find(one => one.key === key)
1162
- if (!choice || !this.draft) return
1163
- this.tellDraft(patchJoint(this.draft, name, { axis: { ...choice.axis } }))
1164
- }
1165
-
1166
- private tellDraft(draft: FigureDraft) {
1167
- this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
1168
- }
1169
-
1170
- /**
1171
- * 역할 하나를 켜고 끈다.
1172
- *
1173
- * **여럿을 함께 맡을 수 있다.** AGV 의 상판은 물건을 놓는 자리이면서 그 자리로 물건이
1174
- * 들어오고 나간다. 그래서 하나를 고르는 선택 상자가 아니라 확인 상자다.
1175
- *
1176
- * 마지막 역할을 끄면 `capability` 를 통째로 지운다. 빈 배열을 남기면 형식이 오류로 보고,
1177
- * 저작자가 보기에는 아무 역할도 없는 평범한 부품인데 발행이 막힌다.
1178
- */
1179
- private toggleRole(role: PartRole, on: boolean) {
1180
- this.change(part => {
1181
- const roles = (part.capability?.roles ?? []).filter(one => one !== role)
1182
- if (on) roles.push(role)
1183
-
1184
- if (roles.length === 0) {
1185
- const { capability, ...rest } = part
1186
- return rest as FigurePart
1187
- }
1188
-
1189
- // 형식이 적은 순서를 지킨다 — 켠 순서대로 두면 같은 뜻이 다른 값으로 저장된다.
1190
- const ordered = (PART_ROLES as readonly PartRole[]).filter(one => roles.includes(one))
1191
- return { ...part, capability: { ...part.capability, roles: ordered } }
1192
- })
1193
- }
1194
-
1195
- private setAccepts(raw: string) {
1196
- const accepts = raw
1197
- .split(',')
1198
- .map(one => one.trim())
1199
- .filter(one => one.length > 0)
1200
-
1201
- this.change(part => {
1202
- const spec = { ...part.capability, roles: part.capability?.roles ?? ['slot' as PartRole] }
1203
- // 빈 목록은 제한하지 않는다는 뜻이다. 빈 배열을 저장하면 아무것도 못 받는 것으로 읽힌다.
1204
- if (accepts.length > 0) spec.accepts = accepts
1205
- else delete spec.accepts
1206
- return { ...part, capability: spec as NonNullable<FigurePart['capability']> }
1207
- })
1208
- }
1209
-
1210
- private setCapacity(value: number) {
1211
- this.change(part => {
1212
- const spec = { ...part.capability, roles: part.capability?.roles ?? ['slot' as PartRole] }
1213
- if (Number.isFinite(value) && value >= 1) spec.capacity = Math.floor(value)
1214
- else delete spec.capacity
1215
- return { ...part, capability: spec as NonNullable<FigurePart['capability']> }
1216
- })
1217
- }
1218
-
1219
- /**
1220
- * 도형 자체 — 이름과 타입.
1221
- *
1222
- * ## 왜 머리줄이 아니라 여기인가
1223
- *
1224
- * 저작면 위에 줄을 하나 더 두고 이름 칸을 놓았었다. 앱에서는 셸이 페이지 제목으로 그 이름을
1225
- * 이미 그리므로 같은 글자가 두 번 나오고, 캔버스가 주인공인 화면에서 그 줄만큼 높이를 잃었다.
1226
- * 속성 패널은 「이 도형이 무엇인가」를 다루는 자리이므로 이름도 여기 산다.
1227
- *
1228
- * ## 타입은 읽기만 한다
1229
- *
1230
- * 저장되는 식별자다. 배치된 도면이 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
1231
- */
1232
- private renderFigure(compact = false) {
1233
- const fields = html`
1234
- <div inline-field>
1235
- <label>${i18next.t('figure.label.name')}</label>
1236
- <input
1237
- .value=${this.figureName}
1238
- placeholder=${i18next.t('figure.text.figure-display-name')}
1239
- @change=${(e: Event) =>
1240
- this.dispatchEvent(
1241
- new CustomEvent('figure-rename', {
1242
- detail: { name: (e.target as HTMLInputElement).value },
1243
- bubbles: true,
1244
- composed: true
1245
- })
1246
- )}
1247
- />
1248
- </div>
1249
- <div inline-field>
1250
- <label>${i18next.t('figure.label.figure-type')}${this.typeIsFixed ? this.helpButton('figure.text.type-name-cannot-change') : nothing}</label>
1251
- ${!this.typeIsFixed
1252
- ? html`<input
1253
- .value=${this.draft?.figureType ?? ''}
1254
- placeholder=${i18next.t('figure.text.figure-type-placeholder')}
1255
- @change=${(e: Event) =>
1256
- this.dispatchEvent(
1257
- new CustomEvent('figure-type-change', {
1258
- detail: { type: (e.target as HTMLInputElement).value.trim().toUpperCase() },
1259
- bubbles: true,
1260
- composed: true
1261
- })
1262
- )}
1263
- />`
1264
- : html`<code fixed title=${i18next.t('figure.text.type-name-cannot-change')}>
1265
- <md-icon>lock</md-icon>${this.draft?.figureType ?? ''}
1266
- </code>
1267
- ${this.helpHint('figure.text.type-name-cannot-change')}`}
1268
- </div>
1269
- <!--
1270
- 왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
1271
- 알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
1272
- -->
1273
-
1274
- <div inline-field>
1275
- <label>${i18next.t('figure.label.figure-placement')}${this.helpButton('figure.text.placement-explained')}</label>
1276
- <select
1277
- .value=${this.draft?.placement ?? 'floor'}
1278
- @change=${(e: Event) => this.setPlacement((e.target as HTMLSelectElement).value as FigurePlacement)}
1279
- >
1280
- <option value="floor" ?selected=${(this.draft?.placement ?? 'floor') === 'floor'}>
1281
- ${i18next.t('figure.label.placement-floor')}
1282
- </option>
1283
- <option value="ceiling" ?selected=${this.draft?.placement === 'ceiling'}>
1284
- ${i18next.t('figure.label.placement-ceiling')}
1285
- </option>
1286
- <option value="center" ?selected=${this.draft?.placement === 'center'}>
1287
- ${i18next.t('figure.label.placement-center')}
1288
- </option>
1289
- </select>
1290
- ${this.helpHint('figure.text.placement-explained')}
1291
- </div>
1292
-
1293
- <!--
1294
- 기준 상자 — **고치는 길이 화면에 있어야 한다.** 부품이 이 상자를 넘으면 발행이 막히고,
1295
- 고치는 방법은 상자 숫자를 키우는 것이다(ADR-0044 ④). 그 칸이 없어서 저작자는 넘쳤다는
1296
- 말만 듣고 고칠 자리를 못 찾았다.
1297
- -->
1298
- <div field>
1299
- <label>${i18next.t('figure.label.base-box')}${this.helpButton('figure.text.base-box-explained')}</label>
1300
- <div triple>
1301
- ${this.number('X', this.draft?.width ?? 0, value => this.resizeBase({ width: value }), 1)}
1302
- ${this.number('Y', this.draft?.depth ?? 0, value => this.resizeBase({ depth: value }), 1)}
1303
- ${this.number('Z', this.draft?.height ?? 0, value => this.resizeBase({ height: value }), 1)}
1304
- </div>
1305
- ${this.parts.length > 0
1306
- ? html`<button fit @click=${() => this.fitBaseToParts()} title=${i18next.t('figure.text.fit-base-explained')}>
1307
- <md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
1308
- </button>`
1309
- : nothing}
1310
- ${this.renderBaseContract()}
1311
- ${this.helpHint('figure.text.base-box-explained')}
1312
- </div>
1313
-
1314
- <div inline-field>
1315
- <label>${i18next.t('figure.label.category')}${this.helpButton('figure.text.category-explained')}</label>
1316
- <input
1317
- .value=${this.figureCategory ?? ''}
1318
- placeholder=${i18next.t('figure.text.category-placeholder')}
1319
- @change=${(e: Event) => this.tellCatalog({ category: (e.target as HTMLInputElement).value.trim() || undefined })}
1320
- />
1321
- ${this.helpHint('figure.text.category-explained')}
1322
- </div>
1323
-
1324
- <div inline-field>
1325
- <label>${i18next.t('figure.label.tags')}${this.helpButton('figure.text.tags-explained')}</label>
1326
- <input
1327
- .value=${(this.figureTags ?? []).join(', ')}
1328
- placeholder=${i18next.t('figure.text.tags-placeholder')}
1329
- @change=${(e: Event) => this.tellTags((e.target as HTMLInputElement).value)}
1330
- />
1331
- ${this.helpHint('figure.text.tags-explained')}
1332
- </div>
1333
- `
1334
-
1335
- if (compact) {
1336
- return html`
1337
- <section>
1338
- <details asset>
1339
- <summary>
1340
- <b>${i18next.t('figure.label.figure')}</b>
1341
- <small>${this.figureName || this.draft?.figureType || ''}</small>
1342
- </summary>
1343
- ${fields}
1344
- </details>
1345
- </section>
1346
- `
1347
- }
1348
-
1349
- return html`
1350
- <section>
1351
- <h3>${i18next.t('figure.label.figure')}</h3>
1352
- ${fields}
1353
- </section>
1354
- `
1355
- }
1356
-
1357
- /**
1358
- * 태그 줄을 목록으로.
1359
- *
1360
- * 쉼표로 가르고 `#` 은 떼어 낸다 — 사람은 `#반출` 이라고 적고, 저장되는 것은 `반출` 이다.
1361
- * 겹친 것은 한 번만 남긴다. 빈 줄은 **빈 배열**이지 `undefined` 가 아니다 — 전부 지우려는
1362
- * 저작자가 지울 방법을 잃지 않게.
1363
- */
1364
- private tellTags(raw: string) {
1365
- const tags = [
1366
- ...new Set(
1367
- raw
1368
- .split(',')
1369
- .map(one => one.trim().replace(/^#+/, '').trim())
1370
- .filter(one => one.length > 0)
1371
- )
1372
- ]
1373
-
1374
- this.tellCatalog({ tags })
1375
- }
1376
-
1377
- /** 카탈로그 값이 바뀌었다고 알린다. 정본은 저작면이 든다 — 여기서는 알리기만 한다. */
1378
- private tellCatalog(patch: { category?: string; tags?: string[] }) {
1379
- this.dispatchEvent(new CustomEvent('figure-catalog', { detail: patch, bubbles: true, composed: true }))
1380
- }
1381
-
1382
- /**
1383
- * 3D 배치 기준면을 바꾼다 ('floor' | 'ceiling' | 'center').
1384
- *
1385
- * **부품은 건드리지 않는다.** 이 값은 「이 종류는 어느 면에 붙는가」를 말할 뿐이고,
1386
- * 저작한 모양은 그것과 무관하다. 형식이 부품을 상자 중심에서 재므로, 배치가 달라도
1387
- * `parts` 는 한 글자도 안 바뀐다.
1388
- *
1389
- * 한동안 여기서 `zPos` 를 다시 계산했다. 편집 캔버스가 이 값을 씬의 좌표 모드로
1390
- * 흘려보내던 때의 일인데, 그러면 배치를 고를 때마다 저작자가 적어 둔 높이가 전부
1391
- * 다른 숫자로 보였다 — 저장되는 것은 그대로인데. 좌표 모드는 이제 보기 설정이고
1392
- * (`figure-view.ts`), 편집 캔버스는 늘 상자 기준이다.
1393
- */
1394
- private setPlacement(placement: FigurePlacement) {
1395
- if (!this.draft) return
1396
- const draft = { ...this.draft, placement }
1397
- this.dispatchEvent(new CustomEvent('update-draft', { detail: { draft }, bubbles: true, composed: true }))
1398
- }
1399
-
1400
- private renderIdentity(part: FigurePart) {
1401
- return html`
1402
- <section>
1403
- <h3>${i18next.t('figure.label.part')}</h3>
1404
- <div inline-field>
1405
- <label>${i18next.t('figure.label.name')}${this.helpButton('figure.text.renaming-a-part-breaks-bindings')}</label>
1406
- <input
1407
- .value=${part.name}
1408
- @change=${(e: Event) => this.put({ name: (e.target as HTMLInputElement).value })}
1409
- />
1410
- ${this.helpHint('figure.text.renaming-a-part-breaks-bindings')}
1411
- </div>
1412
- <div inline-field>
1413
- <label>${i18next.t('figure.label.shape')}</label>
1414
- <select @change=${(e: Event) => this.changePrimitive((e.target as HTMLSelectElement).value as PrimitiveKind)}>
1415
- ${PRIMITIVE_KINDS.map(
1416
- kind => html`
1417
- <option value=${kind} ?selected=${kind === part.primitive}>
1418
- ${i18next.t('figure.label.primitive-' + kind)}
1419
- </option>
1420
- `
1421
- )}
1422
- </select>
1423
- </div>
1424
- </section>
1425
- `
1426
- }
1427
-
1428
- private renderPlacement(part: FigurePart) {
1429
- const t = part.transform
1430
- const rotation = t.rotation ?? {}
1431
-
1432
- return html`
1433
- <section>
1434
- <h3>${i18next.t('figure.label.placement')}</h3>
1435
-
1436
- <div vector-row>
1437
- <label>${i18next.t('figure.label.part-position')}</label>
1438
- <div triple>
1439
- ${(['x', 'y', 'z'] as const).map(axis =>
1440
- this.number(axis.toUpperCase(), t.position[axis], value =>
1441
- this.putTransform({ position: { ...t.position, [axis]: value } })
1442
- )
1443
- )}
1444
- </div>
1445
- </div>
1446
-
1447
- <div vector-row>
1448
- <label>${i18next.t('figure.label.part-size')}</label>
1449
- <div triple>
1450
- ${(['x', 'y', 'z'] as const).map(axis =>
1451
- this.number(
1452
- axis.toUpperCase(),
1453
- t.size[axis],
1454
- value => this.putTransform({ size: { ...t.size, [axis]: value } }),
1455
- 1
1456
- )
1457
- )}
1458
- </div>
1459
- </div>
1460
-
1461
- <div vector-row>
1462
- <label>${i18next.t('figure.label.rotation-degrees')}</label>
1463
- <div triple>
1464
- ${(['x', 'y', 'z'] as const).map(axis =>
1465
- this.number(axis.toUpperCase(), rotation[axis] ?? 0, value =>
1466
- this.putTransform({ rotation: { ...rotation, [axis]: value } })
1467
- )
1468
- )}
1469
- </div>
1470
- </div>
1471
-
1472
- <div vector-row>
1473
- <label>${i18next.t('figure.label.tools')}${this.helpButton('figure.text.tools-explained')}</label>
1474
- <div tools>
1475
- <button
1476
- aria-label=${i18next.t('figure.button.center-x')}
1477
- title=${i18next.t('figure.text.center-on-x')}
1478
- @click=${() => this.ask('center', 'x')}
1479
- >
1480
- <md-icon>horizontal_distribute</md-icon>
1481
- </button>
1482
- <button
1483
- aria-label=${i18next.t('figure.button.center-z')}
1484
- title=${i18next.t('figure.text.center-on-z')}
1485
- @click=${() => this.ask('center', 'z')}
1486
- >
1487
- <md-icon>vertical_align_center</md-icon>
1488
- </button>
1489
- <button
1490
- aria-label=${i18next.t('figure.button.to-floor')}
1491
- title=${i18next.t('figure.text.put-on-floor')}
1492
- @click=${() => this.ask('floor', 'y')}
1493
- >
1494
- <md-icon>vertical_align_bottom</md-icon>
1495
- </button>
1496
- ${(['x', 'z'] as const).map(axis => {
1497
- // 이미 가운데면 대칭이 제자리다 — 겹친 부품이 조용히 생기지 않게 막는다
1498
- const able = this.draft ? canMirror(this.draft, this.parts, this.selected, axis) : false
1499
- return html`
1500
- <button
1501
- ?disabled=${!able}
1502
- aria-label=${i18next.t('figure.button.mirror-' + axis)}
1503
- title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
1504
- @click=${() => this.ask('mirror', axis)}
1505
- >
1506
- <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
1507
- </button>
1508
- `
1509
- })}
1510
- <button
1511
- aria-label=${i18next.t('figure.button.snap')}
1512
- title=${i18next.t('figure.text.snap-explained')}
1513
- @click=${() => this.ask('snap', 'x')}
1514
- >
1515
- <md-icon>grid_on</md-icon>
1516
- </button>
1517
- </div>
1518
- ${this.helpHint('figure.text.tools-explained')}
1519
- </div>
1520
- </section>
1521
- `
1522
- }
1523
-
1524
- /** 분할 수와 모서리 굴림 — 해당하는 도형에만 낸다. */
1525
- private renderShape(part: FigurePart) {
1526
- const segmented = SEGMENTED_PRIMITIVES.includes(part.primitive)
1527
- const extruded = EXTRUDE_PRIMITIVES.includes(part.primitive)
1528
- if (!segmented && !extruded) return nothing
1529
-
1530
- return html`
1531
- <section>
1532
- <h3>${i18next.t('figure.label.shape-detail')}</h3>
1533
- ${segmented ? this.renderSegments(part) : nothing}
1534
- ${segmented ? this.renderKeepRound(part) : nothing}
1535
- ${extruded ? this.renderExtruded(part) : nothing}
1536
- </section>
1537
- `
1538
- }
1539
-
1540
- /**
1541
- * 민 단면 — 모서리와 **속을 파는 두께.**
1542
- *
1543
- * 벽 두께를 0 으로 두면 통짜다. 0 보다 크면 그만큼 안으로 들어온 테가 생기고, 그것이
1544
- * 트레이 · 토트 · 통 · 케이스가 되는 자리다. 바닥은 비워 두면 벽과 같고, 0 으로 두면
1545
- * 위아래가 다 뚫린 테두리가 된다.
1546
- */
1547
- private renderExtruded(part: FigurePart) {
1548
- const hollow = part.shape?.hollow
1549
- const wall = hollow?.wall ?? 0
1550
-
1551
- const reshape = (over: Record<string, unknown>) =>
1552
- this.put({ shape: { ...(part.shape ?? {}), ...over } })
1553
-
1554
- return html`
1555
- <div inline-field>
1556
- <label>${i18next.t('figure.label.corner-round')}</label>
1557
- ${this.number('', part.shape?.round ?? 0, value => reshape({ round: value }))}
1558
- </div>
1559
- <div inline-field>
1560
- <label>${i18next.t('figure.label.hollow-wall')}</label>
1561
- ${this.number('', wall, value =>
1562
- reshape({ hollow: value > 0 ? { ...(hollow ?? {}), wall: value } : undefined })
1563
- )}
1564
- </div>
1565
- ${wall > 0
1566
- ? html`
1567
- <div inline-field>
1568
- <label>${i18next.t('figure.label.hollow-floor')}</label>
1569
- ${this.number('', hollow?.floor ?? wall, value => reshape({ hollow: { ...hollow, wall, floor: value } }))}
1570
- </div>
1571
- `
1572
- : nothing}
1573
- `
1574
- }
1575
-
1576
- /**
1577
- * 분할 수 — 모양과 무게를 함께 고른다.
1578
- *
1579
- * 삼각형 수는 화면이 셈하지 않는다. `trianglesOf` 에 그 분할 수를 가진 부품을 주고
1580
- * 물어본다 — 화면이 제 나름의 공식을 들면 figure-model 이 규칙을 바꿔도 화면만
1581
- * 옛 답을 계속 낸다.
1582
- */
1583
- /**
1584
- * 원통을 원통으로 둘까 — **고르는 값이고, 값을 치른다.**
1585
- *
1586
- * 인스턴스가 축마다 다른 배율을 받으면 원기둥이 타원 기둥이 된다. 지키려면 제 변환이
1587
- * 있어야 하고, 합친 형상은 변환을 하나만 받으므로 지키는 곡면 부품 하나가 draw call
1588
- * 하나다. 저작자가 그 대가를 모르고 고르면 안 되므로 그 자리에 적는다.
1589
- */
1590
- private renderKeepRound(part: FigurePart) {
1591
- const on = part.keepRound !== false
1592
-
1593
- return html`
1594
- <div field>
1595
- <label>${i18next.t('figure.label.keep-round')}</label>
1596
- <div choices>
1597
- <button choice ?on=${on} @click=${() => this.put({ keepRound: undefined })}>
1598
- <b>${i18next.t('figure.text.keep-round-on')}</b>
1599
- <small>${i18next.t('figure.text.keep-round-cost')}</small>
1600
- </button>
1601
- <button choice ?on=${!on} @click=${() => this.put({ keepRound: false })}>
1602
- <b>${i18next.t('figure.text.keep-round-off')}</b>
1603
- <small>${i18next.t('figure.text.keep-round-merged')}</small>
1604
- </button>
1605
- </div>
1606
- </div>
1607
- `
1608
- }
1609
-
1610
- private renderSegments(part: FigurePart) {
1611
- const current = part.segments ?? SEGMENT_PRESETS[1]
1612
- const common = SEGMENT_PRESETS.slice(0, 4)
1613
- const advanced = SEGMENT_PRESETS.slice(4)
1614
-
1615
- return html`
1616
- <div field>
1617
- <label>${i18next.t('figure.label.segments')}</label>
1618
- <div segments>
1619
- ${common.map(preset => {
1620
- const triangles = trianglesOf({ ...part, segments: preset })
1621
- return html`
1622
- <button seg ?on=${current === preset} @click=${() => this.put({ segments: preset })}>
1623
- ${this.silhouette(preset)}
1624
- <b>${preset}</b>
1625
- <small>${i18next.t('figure.text.n-triangles', { count: triangles })}</small>
1626
- </button>
1627
- `
1628
- })}
1629
- </div>
1630
-
1631
- <details free ?open=${advanced.includes(current)}>
1632
- <summary>${i18next.t('figure.text.more-segment-options')}</summary>
1633
- <div presets>
1634
- ${advanced.map(preset => html`
1635
- <button ?on=${current === preset} @click=${() => this.put({ segments: preset })}>${preset}</button>
1636
- `)}
1637
- </div>
1638
- </details>
1639
- </div>
1640
- `
1641
- }
1642
-
1643
- /**
1644
- * 분할 수의 실루엣.
1645
- *
1646
- * 곡면 프리미티브를 위에서 본 윤곽이 정확히 정n각형이다 — 원기둥의 단면도, 구의
1647
- * 적도도. 그림을 지어내는 것이 아니라 실제로 만들어지는 모양이다.
1648
- */
1649
- private silhouette(segments: number) {
1650
- const points: string[] = []
1651
- for (let i = 0; i < segments; i++) {
1652
- // 꼭짓점 하나가 위로 오게 -90도에서 시작한다
1653
- const angle = -Math.PI / 2 + (i * 2 * Math.PI) / segments
1654
- points.push(`${(16 + 13 * Math.cos(angle)).toFixed(2)},${(16 + 13 * Math.sin(angle)).toFixed(2)}`)
1655
- }
1656
-
1657
- return svg`
1658
- <svg viewBox="0 0 32 32" aria-hidden="true">
1659
- <polygon points=${points.join(' ')}></polygon>
1660
- </svg>
1661
- `
1662
- }
1663
-
1664
- /** 견본 한 줄. 무리마다 부른다. */
1665
- private palettePurpose(token?: string) {
1666
- const purposes: Record<string, string> = {
1667
- 'palette.primary': 'primary',
1668
- 'palette.accent': 'accent',
1669
- 'palette.neutral': 'neutral',
1670
- 'palette.dark': 'dark',
1671
- 'palette.light': 'light',
1672
- 'palette.surface': 'surface',
1673
- 'palette.status-danger': 'danger',
1674
- 'palette.status-warning': 'warning',
1675
- 'palette.status-info': 'info',
1676
- 'palette.status-success': 'success'
1677
- }
1678
-
1679
- return i18next.t(`figure.label.palette-purpose-${purposes[token ?? ''] ?? 'registered'}`)
1680
- }
1681
-
1682
- private swatches(entries: [string, string][], chosen?: string) {
1683
- return entries.map(
1684
- ([token, color]) => {
1685
- const purpose = this.palettePurpose(token)
1686
- return html`
1687
- <button
1688
- swatch
1689
- ?on=${token === chosen}
1690
- aria-label=${purpose}
1691
- title=${purpose}
1692
- style="--swatch:${color}"
1693
- @click=${() => this.putMaterial({ token })}
1694
- ></button>
1695
- `
1696
- }
1697
- )
1698
- }
1699
-
1700
- private renderMaterial(part: FigurePart) {
1701
- const material = part.material ?? {}
1702
- const palette = activePalette() as Record<string, string>
1703
-
1704
- /*
1705
- 두 무리로 나눠 보인다 — **칠한 색**과 **켜진 색**.
1706
-
1707
- `palette.status-*` 는 램프 · 표시등에 쓰라고 있는 색이다. 한 줄에 섞어 두면
1708
- `accent`(주황)와 `status-warning`(주황)이 나란히 놓여 무엇이 무엇인지 알 수
1709
- 없다. 값을 비틀어 벌리는 대신 **고르는 자리에서 경계를 긋는다.**
1710
- */
1711
- const entries = Object.entries(palette)
1712
- const painted = entries.filter(([token]) => !token.startsWith('palette.status-'))
1713
- const lit = entries.filter(([token]) => token.startsWith('palette.status-'))
1714
-
1715
- /** 자체발광. 없으면 안 빛나는 부품이다. */
1716
- const glow = material.emissive
1717
-
1718
- return html`
1719
- <section>
1720
- <h3>${i18next.t('figure.label.part-material')}</h3>
1721
-
1722
- <div field>
1723
- <label>${i18next.t('figure.label.palette-role')}</label>
1724
- <div swatches>
1725
- ${this.swatches(painted, material.token)}
1726
- </div>
1727
- ${lit.length
1728
- ? html`
1729
- <label group>${i18next.t('figure.label.status-colors')}</label>
1730
- <div swatches>
1731
- ${this.swatches(lit, material.token)}
1732
- </div>
1733
- `
1734
- : ''}
1735
- <div palette-meaning>
1736
- <b>${material.token ? this.palettePurpose(material.token) : i18next.t('figure.label.no-color-chosen')}</b>
1737
- <small>${i18next.t('figure.text.palette-purpose-explained')}</small>
1738
- </div>
1739
- </div>
1740
-
1741
- <div field>
1742
- <label>${i18next.t('figure.label.preset')}</label>
1743
- <select
1744
- @change=${(e: Event) => {
1745
- const value = (e.target as HTMLSelectElement).value
1746
- this.putMaterial({ preset: (value || undefined) as MaterialPreset | undefined })
1747
- }}
1748
- >
1749
- <option value="" ?selected=${!material.preset}>${i18next.t('figure.label.none')}</option>
1750
- ${MATERIAL_PRESETS.map(
1751
- preset => html`
1752
- <option value=${preset} ?selected=${preset === material.preset}>
1753
- ${i18next.t('figure.label.preset-' + preset)}
1754
- </option>
1755
- `
1756
- )}
1757
- </select>
1758
- </div>
1759
-
1760
- <!-- 값이 없으면 각지게 그린다 — 형식이 그렇게 못 박았다. 체크상자도 그대로 보인다 -->
1761
- <label toggle>
1762
- <input
1763
- type="checkbox"
1764
- .checked=${material.flatShading ?? true}
1765
- @change=${(e: Event) => this.putMaterial({ flatShading: (e.target as HTMLInputElement).checked })}
1766
- />
1767
- ${i18next.t('figure.label.flat-shading')}
1768
- </label>
1769
- <label toggle>
1770
- <input
1771
- type="checkbox"
1772
- .checked=${!!material.transparent}
1773
- @change=${(e: Event) => this.putMaterial({ transparent: (e.target as HTMLInputElement).checked })}
1774
- />
1775
- ${i18next.t('figure.label.transparent')}
1776
- </label>
1777
-
1778
- <div field>
1779
- <label>${i18next.t('figure.label.emissive')}${this.help('figure.text.emissive-explained')}</label>
1780
-
1781
- <div slider>
1782
- <input
1783
- type="range"
1784
- min="0"
1785
- max="1"
1786
- step="0.05"
1787
- .value=${String(glow?.intensity ?? 0)}
1788
- @input=${(e: Event) => this.putEmissive(Number((e.target as HTMLInputElement).value))}
1789
- />
1790
- <output>${(glow?.intensity ?? 0).toFixed(2)}</output>
1791
- </div>
1792
- </div>
1793
-
1794
- ${glow
1795
- ? html`
1796
- <div field>
1797
- <label>${i18next.t('figure.label.emissive-color')}</label>
1798
- <div swatches>
1799
- <button
1800
- swatch
1801
- same
1802
- ?on=${!glow.token}
1803
- title=${i18next.t('figure.label.same-as-off')}
1804
- style="--swatch:${(material.token && palette[material.token]) || palette['palette.neutral']}"
1805
- @click=${() => this.putMaterial({ emissive: { ...glow, token: undefined } })}
1806
- ></button>
1807
- ${lit.map(
1808
- ([token, color]) => html`
1809
- <button
1810
- swatch
1811
- ?on=${token === glow.token}
1812
- title=${token}
1813
- style="--swatch:${color}"
1814
- @click=${() => this.putMaterial({ emissive: { ...glow, token } })}
1815
- ></button>
1816
- `
1817
- )}
1818
- </div>
1819
- <span token>${glow.token ?? i18next.t('figure.label.same-as-off')}</span>
1820
- </div>
1821
-
1822
- <label toggle>
1823
- <input
1824
- type="checkbox"
1825
- .checked=${!!glow.on}
1826
- @change=${(e: Event) =>
1827
- this.putMaterial({ emissive: { ...glow, on: (e.target as HTMLInputElement).checked } })}
1828
- />
1829
- ${i18next.t('figure.label.on-by-default')}
1830
- </label>
1831
- ${this.help('figure.text.on-by-default-explained')}
1832
- `
1833
- : ''}
1834
-
1835
- </section>
1836
- `
1837
- }
1838
-
1839
- /**
1840
- * 인스턴스 크기가 바뀔 때 이 부품이 어떻게 변하나.
1841
- *
1842
- * ## 늘어나는 축은 아직 못 보여 준다
1843
- *
1844
- * `stretch` 는 형식에 축을 적는 자리가 없어서 그리는 쪽이 **가장 길게 뻗은 축**으로
1845
- * 정한다. 그 답을 화면에 적어 주려면 규칙을 가진 쪽에 물어야 하는데, 그 함수
1846
- * (`stretchAxisOf` · `stretchAxis`)가 아직 배포된 판에 없다.
1847
- *
1848
- * 화면이 제 나름대로 계산하지 않는다. 규칙이 두 벌이 되면 보이는 축과 실제로 늘어나는
1849
- * 축이 갈리고, 그 어긋남은 화면을 믿게 만든 다음에 드러난다. 배포되면 잇는다.
1850
- *
1851
- * ## 값이 있는 것을 밝힌다
1852
- *
1853
- * `scale` 이 아닌 부품은 합쳐 그릴 수 없어 draw call 을 하나 더 쓴다. 그 비용이
1854
- * 성숙도의 묶음 수에 그대로 잡힌다. 고르는 자리에서 미리 말해 준다.
1855
- */
1856
- private renderBehaviour(part: FigurePart) {
1857
- const sizing = part.sizing ?? 'scale'
1858
- const repeat = part.repeat ?? { axis: 'x' as Axis, pitch: 0 }
1859
-
1860
- return html`
1861
- <section>
1862
- <h3>${i18next.t('figure.label.sizing-rule')}${this.help('figure.text.sizing-' + sizing + '-explained')}</h3>
1863
- <div sizing-choices>
1864
- ${SIZING_RULES.map(
1865
- rule => html`
1866
- <button sizing-choice ?on=${rule === sizing} @click=${() => this.put({ sizing: rule })}>
1867
- <b>${i18next.t('figure.label.sizing-' + rule)}</b>
1868
- <small>${i18next.t('figure.text.sizing-' + rule + '-explained')}</small>
1869
- </button>
1870
- `
1871
- )}
1872
- </div>
1873
- ${sizing === 'scale'
1874
- ? nothing
1875
- : html`<p cost>${i18next.t('figure.text.sizing-costs-one-draw-call')}</p>`}
1876
-
1877
- ${this.renderAnchors(part)}
1878
-
1879
- ${sizing === 'repeat'
1880
- ? html`
1881
- <div inline-field>
1882
- <label>${i18next.t('figure.label.repeat-axis')}</label>
1883
- <select
1884
- @change=${(e: Event) =>
1885
- this.put({ repeat: { ...repeat, axis: (e.target as HTMLSelectElement).value as Axis } })}
1886
- >
1887
- ${(['x', 'y', 'z'] as const).map(
1888
- axis => html`<option value=${axis} ?selected=${axis === repeat.axis}>${axis}</option>`
1889
- )}
1890
- </select>
1891
- </div>
1892
- <div inline-field>
1893
- <label>${i18next.t('figure.label.repeat-pitch')}${this.helpButton('figure.text.repeat-pitch-explained')}</label>
1894
- ${this.number('', repeat.pitch, value => this.put({ repeat: { ...repeat, pitch: value } }), 1)}
1895
- ${this.helpHint('figure.text.repeat-pitch-explained')}
1896
- </div>
1897
- ${this.renderRepeatCount(part)}
1898
- `
1899
- : nothing}
1900
- </section>
1901
- `
1902
- }
1903
-
1904
- /**
1905
- * ── 몇 개가 되나 ──────────────────────────────────────────────────────────
1906
- *
1907
- * 저작 화면은 축과 피치만 받고 있었다. 형식이 가진 반복 설정이 그 둘뿐이므로 편집할 것은
1908
- * 다 있었는데, **결과인 개수를 어디에도 표시하지 않았다.** 피치 300 을 적은 저작자가 몇 개가
1909
- * 되는지 알려면 미리보기를 세어야 했다.
1910
- *
1911
- * 개수는 그리는 쪽이 쓰는 `repeatPlan` 을 그대로 불러 얻는다. 여기서 다시 계산하면 화면이
1912
- * 말하는 개수와 실제로 그려지는 개수가 달라진다.
1913
- *
1914
- * ## 두 가지 반복이 있다
1915
- *
1916
- * 컨베이어의 롤러는 길어지면 **개수가 늘고** 한 칸 간격은 그대로다. 셀트레이의 셀은 커지면
1917
- * **셀이 커지고** 개수는 그대로다 — 몇 셀짜리 트레이냐는 화면 크기와 무관하다. 그 갈림을
1918
- * 이 축의 anchor 가 정하는데 화면에는 그 말이 없었다. 그래서 어느 쪽인지 함께 적는다.
1919
- */
1920
- private renderRepeatCount(part: FigurePart) {
1921
- const draft = this.draft
1922
- if (!draft) return nothing
1923
-
1924
- const base = { x: draft.width, y: draft.depth, z: draft.height }
1925
- const plan = repeatPlanOfPart(part, { x: 1, y: 1, z: 1 }, base)
1926
- if (!plan) return nothing
1927
-
1928
- const grows = anchorOfPart(part, base, plan.axis) !== 'scale'
1929
-
1930
- return html`
1931
- <div field>
1932
- <label>${i18next.t('figure.label.repeat-count')}</label>
1933
- <p quiet>
1934
- ${i18next.t('figure.text.repeat-count-now', { count: plan.count })}
1935
- ${plan.count >= REPEAT_LIMIT
1936
- ? html`<br />${i18next.t('figure.text.repeat-count-capped', { limit: REPEAT_LIMIT })}`
1937
- : ''}
1938
- <br />${i18next.t(grows ? 'figure.text.repeat-grows-with-instance' : 'figure.text.repeat-keeps-count')}
1939
- </p>
1940
- </div>
1941
- `
1942
- }
1943
-
1944
- /**
1945
- * ── 어느 면을 붙잡나 ──────────────────────────────────────────────────────
1946
- *
1947
- * **이 자리가 없었다.** 형식은 축마다 `anchor` 를 지고 다니고(`figure-source.ts` 가 왕복시킨다),
1948
- * 발행 게이트는 「valve-wheel 의 anchor.z 를 max 로 두라」고 사유까지 말하는데, 저작면에는 그
1949
- * 값을 바꿀 자리가 없었다 — 고치라는 말만 있고 고칠 문이 없었다.
1950
- *
1951
- * ## 「자동」이 기본이다
1952
- *
1953
- * 형식에서 `anchor` 는 적지 않아도 된다. 안 적으면 **가까운 면**을 붙잡고, 표본 열넷 중 어느
1954
- * 것도 이 값을 적지 않고 제대로 선다. 그래서 축마다 첫 항목이 「자동」이고, 지금 자동이 무엇으로
1955
- * 읽히는지를 **함께 보여 준다** — 그것을 모르면 무엇을 바꾸는지 모른 채 고르게 된다.
1956
- *
1957
- * 자동이 무엇인지는 형식에게 묻는다(`anchorOfPart`). 여기서 「위 절반이면 max」를 다시 세면
1958
- * 화면이 말하는 면과 실제로 그려지는 면이 갈린다.
1959
- *
1960
- * ## `scale` 인 부품에도 보여 준다
1961
- *
1962
- * 처음에는 감췄다 — 형식 주석에 「`sizing` 이 `scale` 인 부품에는 뜻이 없다」고 적혀 있어서다.
1963
- * **그 문장은 기본값에 대한 것이다.** `anchorOf` 는 적힌 값을 가장 먼저 보고(`if (said) return
1964
- * said`), 그 뒤에야 `sizing` 을 본다. 즉 `scale` 부품에도 면을 적으면 그것이 이긴다.
1965
- *
1966
- * 감춰 두면 어긋남이 난다: 발행 게이트가 막는 MIXER 의 `valve-wheel` 이 `scale` 부품이고,
1967
- * 게이트의 사유는 「그 부품의 anchor.z 를 max 로 두라」다. 고치라는 자리에 문을 닫아 둔 셈이었다.
1968
- *
1969
- * 대신 `scale` 일 때는 자동이 무엇인지가 「함께 커진다」로 보이므로, 저작자가 그것을 바꾸는
1970
- * 것임을 알 수 있다.
1971
- */
1972
- /**
1973
- * ── 도움말은 자리를 먹지 않는다 ─────────────────────────────────────────────
1974
- *
1975
- * 설명 문단이 열 줄 있었고 그 줄들이 패널 높이의 상당 부분이었다. 속성은 앞으로 더 늘어난다
1976
- * (능력 · 포트 · 애니메이션 · 기준정보) — 늘 보이는 설명은 그 자리를 먼저 먹는다.
1977
- *
1978
- * 그래서 물음표로 접는다. **지우지 않는다** — 그 문장들은 「이걸 고르면 무엇이 달라지나」를
1979
- * 말하는 것이고, 없으면 닫힌 목록을 고를 수 없다. 한 번 읽으면 되는 것이라 접어 두는 것이 맞다.
1980
- *
1981
- * 편 것은 **부품을 바꿔도 그대로 둔다**(`opened` 는 부품에 딸린 상태가 아니다). 같은 것을
1982
- * 계속 고치는 사람이 매번 다시 펴야 하면 접은 것이 되레 방해가 된다.
1983
- */
1984
- private help(key: string) {
1985
- return html`${this.helpButton(key)}${this.helpHint(key)}`
1986
- }
1987
-
1988
- private helpButton(key: string) {
1989
- const shown = this.opened.has(key)
1990
-
1991
- return html`
1992
- <button
1993
- help
1994
- aria-label=${i18next.t('figure.label.help')}
1995
- aria-expanded=${shown ? 'true' : 'false'}
1996
- @click=${() => {
1997
- shown ? this.opened.delete(key) : this.opened.add(key)
1998
- this.requestUpdate()
1999
- }}
2000
- >
2001
- <md-icon>${shown ? 'help' : 'help_outline'}</md-icon>
2002
- </button>
2003
- `
2004
- }
2005
-
2006
- private helpHint(key: string) {
2007
- return this.opened.has(key) ? html`<p hint>${i18next.t(key)}</p>` : nothing
2008
- }
2009
-
2010
- private renderAnchors(part: FigurePart) {
2011
- /*
2012
- The scene draft stores the box height as depth, so format y is draft.depth and z is draft.height.
2013
- This used to pass them swapped, and every automatic anchor still came out the same: the
2014
- nearest-face rule reads the sign of a centred position, which does not depend on the box size.
2015
- It is fixed so the next rule that does read the box does not inherit the mistake.
2016
- */
2017
- const base = this.draft
2018
- ? { x: this.draft.width, y: this.draft.depth, z: this.draft.height }
2019
- : { x: 0, y: 0, z: 0 }
2020
- const nameOf = (rule: string, axis: Axis) =>
2021
- `${i18next.t(rule === 'min' || rule === 'max' ? `figure.label.anchor-${rule}-${axis}` : `figure.label.anchor-${rule}`)} (${rule})`
2022
- const setAnchor = (axis: Axis, rule?: AnchorRule) => {
2023
- const anchor = { ...(part.anchor ?? {}) }
2024
- /* 「자동」은 칸을 지우는 것이다 — 빈 값을 넣어 두면 형식이 그것을 값으로 읽는다. */
2025
- if (rule) anchor[axis] = rule
2026
- else delete anchor[axis]
2027
- this.put({ anchor: Object.keys(anchor).length ? anchor : undefined })
2028
- }
2029
- const shortNameOf = (rule: AnchorRule | 'auto', axis: Axis) => {
2030
- if (rule === 'auto') return i18next.t('figure.label.anchor-choice-auto')
2031
- if (rule === 'min' || rule === 'max') return i18next.t(`figure.label.anchor-choice-${rule}-${axis}`)
2032
- return i18next.t(`figure.label.anchor-choice-${rule}`)
2033
- }
2034
-
2035
- return html`
2036
- <div field>
2037
- <!-- 저장 키(anchor)는 남기되, 사용자는 축마다 어느 면을 기준으로 삼는지 읽는다. -->
2038
- <label>${i18next.t('figure.label.anchor')} (anchor)${this.help('figure.text.anchor-explained')}</label>
2039
- <div anchor-rows>
2040
- ${(['x', 'y', 'z'] as const).map(axis => {
2041
- const said = part.anchor?.[axis]
2042
- const auto = anchorOfPart(part, base, axis)
2043
-
2044
- /*
2045
- **면 이름은 축마다 다르다.** 처음에는 min 을 「아래·왼쪽·뒤 면」으로 한 줄에 다 적었는데,
2046
- x 줄에서 「아래」와 「뒤」는 관계가 없어 읽는 사람이 무엇을 고르는지 몰랐다.
2047
-
2048
- 그리고 이름씨가 아니라 **동작**으로 적는다 — 「오른쪽 면」이 아니라 「오른쪽 면에 붙어
2049
- 있는다」다. 「anchor.x 를 max 로 둔다」가 무슨 뜻인지 모르겠다는 말이 그 자리에서 나왔다.
2050
- */
2051
- /*
2052
- **값 이름을 함께 보인다.** 동작만 적었더니(「오른쪽 면에 붙어 있는다») 발행 게이트가
2053
- 「anchor.x 를 max 로 두라」고 말할 때 화면에서 그 낱말을 찾을 수 없었다 — 사용자가
2054
- 그 자리에서 지적했다.
2055
-
2056
- `max` 는 정본에 저장되는 이름이고 서버 오류·형식 문서·게이트 사유가 다 그 이름을 쓴다.
2057
- 숨기면 안 되는 종류다. 동작이 먼저고 이름이 괄호로 따라온다.
2058
- */
2059
- return html`
2060
- <div anchor-row>
2061
- <label>${i18next.t('figure.label.axis-name', { axis: axis.toUpperCase() })}</label>
2062
- <div anchor-options>
2063
- ${(['auto', 'scale', 'min', 'center', 'max', 'span'] as const).map(rule => {
2064
- const selected = rule === 'auto' ? !said : rule === said
2065
- const fullName = rule === 'auto' ? i18next.t('figure.label.anchor-auto', { rule: nameOf(auto, axis) }) : nameOf(rule, axis)
2066
- return html`
2067
- <button
2068
- anchor-choice
2069
- ?on=${selected}
2070
- aria-pressed=${selected ? 'true' : 'false'}
2071
- title=${fullName}
2072
- @click=${() => setAnchor(axis, rule === 'auto' ? undefined : rule)}
2073
- >${shortNameOf(rule, axis)}</button>
2074
- `
2075
- })}
2076
- </div>
2077
- <details anchor-advanced>
2078
- <summary>${i18next.t('figure.label.anchor-advanced')}</summary>
2079
- <div anchor-options>
2080
- ${ANCHOR_RULES.filter(rule => rule.startsWith('aspect-')).map(rule => html`
2081
- <button
2082
- anchor-choice
2083
- ?on=${rule === said}
2084
- aria-pressed=${rule === said ? 'true' : 'false'}
2085
- title=${nameOf(rule, axis)}
2086
- @click=${() => setAnchor(axis, rule)}
2087
- >${i18next.t(`figure.label.anchor-choice-${rule}`)}</button>
2088
- `)}
2089
- </div>
2090
- </details>
2091
- </div>
2092
- `
2093
- })}
2094
- </div>
2095
- </div>
2096
- `
2097
- }
2098
-
2099
- /**
2100
- * 숫자 칸.
2101
- *
2102
- * 빈 칸이나 글자를 넣었을 때 0 으로 바꿔치지 않는다 — 저작자가 지우는 중일 수 있고,
2103
- * 조용히 0 이 되면 부품이 사라진 것처럼 보인다. 숫자가 아니면 그냥 안 받는다.
2104
- */
2105
- private number(label: string, value: number, set: (value: number) => void, min?: number) {
2106
- return html`
2107
- <div cell>
2108
- ${label ? html`<span axis>${label}</span>` : nothing}
2109
- <input
2110
- type="number"
2111
- .value=${String(value)}
2112
- min=${min ?? nothing}
2113
- @change=${(e: Event) => {
2114
- const raw = (e.target as HTMLInputElement).value
2115
- const parsed = Number(raw)
2116
- if (raw === '' || !Number.isFinite(parsed)) {
2117
- // 되돌려 놓는다. 안 그러면 화면과 정본이 갈린다.
2118
- ;(e.target as HTMLInputElement).value = String(value)
2119
- return
2120
- }
2121
- set(parsed)
2122
- }}
2123
- />
2124
- </div>
2125
- `
2126
- }
2127
-
2128
- /**
2129
- * 도형을 바꾼다.
2130
- *
2131
- * 도형이 바뀌면 그 도형에 뜻 없는 값이 남는다. 남겨 두면 검증이 「무시됨」 위반을
2132
- * 내므로 여기서 걷는다.
2133
- */
2134
- private changePrimitive(primitive: PrimitiveKind) {
2135
- this.change(part => {
2136
- const next: FigurePart = { ...part, primitive }
2137
- if (!SEGMENTED_PRIMITIVES.includes(primitive)) delete next.segments
2138
- if (!EXTRUDE_PRIMITIVES.includes(primitive)) delete next.shape
2139
- if (SEGMENTED_PRIMITIVES.includes(primitive) && next.segments === undefined) {
2140
- next.segments = SEGMENT_PRESETS[1]
2141
- }
2142
- return next
2143
- })
2144
- }
2145
-
2146
- private put(patch: Partial<FigurePart>) {
2147
- this.change(part => ({ ...part, ...patch }))
2148
- }
2149
-
2150
- private putTransform(patch: Record<string, unknown>) {
2151
- this.change(part => ({ ...part, transform: { ...part.transform, ...patch } as FigurePart['transform'] }))
2152
- }
2153
-
2154
- /**
2155
- * 발광 세기를 고친다. **0 이면 블록을 통째로 지운다.**
2156
- *
2157
- * 세기 0 인 발광을 남겨 두면 「안 빛나는데 램프인 것」이 되어, 재질 키가 갈려 묶음이
2158
- * 헛되이 하나 는다. 0 은 「램프가 아니다」로 읽는 것이 맞다.
2159
- *
2160
- * `Number(value) || undefined` 를 쓰면 안 된다 — 0 이 falsy 라 변경으로 안 잡혀 옛
2161
- * 값이 그대로 남는다. 실제로 램프가 안 꺼졌다.
2162
- */
2163
- private putEmissive(intensity: number) {
2164
- // `chosen` 은 씬 모델이다. 형식으로 옮긴 값은 `partToFigure` 이 만든 것이라 여기서 다시 꺼낸다.
2165
- const glow = this.draft && this.chosen ? partToFigure(this.chosen, this.draft).material?.emissive : undefined
2166
-
2167
- this.putMaterial({ emissive: intensity > 0 ? { ...glow, intensity } : undefined })
2168
- }
2169
-
2170
- private putMaterial(patch: Record<string, unknown>) {
2171
- this.change(part => ({
2172
- ...part,
2173
- material: { ...(part.material ?? {}), ...patch } as FigurePart['material']
2174
- }))
2175
- }
2176
-
2177
- /**
2178
- * 수정한 것을 씬 모델로 되돌려 알린다.
2179
- *
2180
- * 사용자는 X·Y·Z 로 수정하고, 모델러 페이지는 씬 모델을 갖는다. 그 사이를 여기서
2181
- * 잇는다 — 변환해서 수정하고 다시 되돌린다. **왕복 변환이 무손실이라야** 위치 하나
2182
- * 고쳤을 뿐인데 회전값이 조금씩 밀리는 일이 없다(`fold.test.ts` 가 이를 확인한다).
2183
- */
2184
- private change(edit: (part: FigurePart) => FigurePart) {
2185
- const part = this.chosen
2186
- const draft = this.draft
2187
- if (!part || !draft) return
2188
-
2189
- this.emit(partFromFigure(edit(partToFigure(part, draft)), draft))
2190
- }
2191
-
2192
- /** 정밀 도구를 시킨다. 정본은 저작면이 갖는다 — 여기서는 알리기만 한다. */
2193
- private ask(what: string, axis: string) {
2194
- this.dispatchEvent(
2195
- new CustomEvent('part-tool', {
2196
- detail: { index: this.selected, tool: what, axis },
2197
- bubbles: true,
2198
- composed: true
2199
- })
2200
- )
2201
- }
2202
-
2203
- private emit(part: PartModel) {
2204
- this.dispatchEvent(
2205
- new CustomEvent('update-part', { detail: { index: this.selected, part }, bubbles: true, composed: true })
2206
- )
2207
- }
2208
- }