@things-factory/figure-ui 10.1.5 → 10.1.7

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 (79) hide show
  1. package/client/graphql/index.ts +36 -3
  2. package/client/modeller/figure-animations.ts +712 -0
  3. package/client/modeller/figure-ask.ts +5 -5
  4. package/client/modeller/figure-canvas.ts +1 -1
  5. package/client/modeller/figure-capabilities.ts +372 -0
  6. package/client/modeller/figure-history-panel.ts +171 -18
  7. package/client/modeller/figure-inspector.ts +439 -76
  8. package/client/modeller/figure-parts.ts +15 -15
  9. package/client/modeller/figure-preview.ts +2 -2
  10. package/client/modeller/figure-report.ts +102 -37
  11. package/client/modeller/figure-settings.ts +20 -20
  12. package/client/modeller/figure-side.ts +91 -15
  13. package/client/modeller/figure-source.test.ts +15 -1
  14. package/client/modeller/figure-source.ts +31 -15
  15. package/client/modeller/figure-thumbnail.ts +49 -4
  16. package/client/modeller/maturity.ts +10 -10
  17. package/client/pages/figure-list-page.ts +204 -66
  18. package/client/pages/figure-modeller-page.ts +158 -42
  19. package/client/types.ts +31 -0
  20. package/client/viewparts/figure-thumb.ts +250 -0
  21. package/dist-client/graphql/index.d.ts +8 -1
  22. package/dist-client/graphql/index.js +33 -3
  23. package/dist-client/graphql/index.js.map +1 -1
  24. package/dist-client/modeller/figure-animations.d.ts +93 -0
  25. package/dist-client/modeller/figure-animations.js +655 -0
  26. package/dist-client/modeller/figure-animations.js.map +1 -0
  27. package/dist-client/modeller/figure-ask.js +5 -5
  28. package/dist-client/modeller/figure-ask.js.map +1 -1
  29. package/dist-client/modeller/figure-canvas.js +1 -1
  30. package/dist-client/modeller/figure-canvas.js.map +1 -1
  31. package/dist-client/modeller/figure-capabilities.d.ts +42 -0
  32. package/dist-client/modeller/figure-capabilities.js +344 -0
  33. package/dist-client/modeller/figure-capabilities.js.map +1 -0
  34. package/dist-client/modeller/figure-history-panel.d.ts +15 -1
  35. package/dist-client/modeller/figure-history-panel.js +165 -18
  36. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  37. package/dist-client/modeller/figure-inspector.d.ts +98 -0
  38. package/dist-client/modeller/figure-inspector.js +425 -75
  39. package/dist-client/modeller/figure-inspector.js.map +1 -1
  40. package/dist-client/modeller/figure-parts.js +15 -15
  41. package/dist-client/modeller/figure-parts.js.map +1 -1
  42. package/dist-client/modeller/figure-preview.js +2 -2
  43. package/dist-client/modeller/figure-preview.js.map +1 -1
  44. package/dist-client/modeller/figure-report.d.ts +18 -1
  45. package/dist-client/modeller/figure-report.js +105 -37
  46. package/dist-client/modeller/figure-report.js.map +1 -1
  47. package/dist-client/modeller/figure-settings.js +19 -19
  48. package/dist-client/modeller/figure-settings.js.map +1 -1
  49. package/dist-client/modeller/figure-side.d.ts +24 -0
  50. package/dist-client/modeller/figure-side.js +94 -14
  51. package/dist-client/modeller/figure-side.js.map +1 -1
  52. package/dist-client/modeller/figure-source.d.ts +15 -8
  53. package/dist-client/modeller/figure-source.js +16 -7
  54. package/dist-client/modeller/figure-source.js.map +1 -1
  55. package/dist-client/modeller/figure-thumbnail.js +40 -4
  56. package/dist-client/modeller/figure-thumbnail.js.map +1 -1
  57. package/dist-client/modeller/maturity.js +10 -10
  58. package/dist-client/modeller/maturity.js.map +1 -1
  59. package/dist-client/pages/figure-list-page.d.ts +11 -4
  60. package/dist-client/pages/figure-list-page.js +201 -63
  61. package/dist-client/pages/figure-list-page.js.map +1 -1
  62. package/dist-client/pages/figure-modeller-page.d.ts +33 -0
  63. package/dist-client/pages/figure-modeller-page.js +156 -42
  64. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  65. package/dist-client/tsconfig.tsbuildinfo +1 -1
  66. package/dist-client/types.d.ts +29 -0
  67. package/dist-client/types.js.map +1 -1
  68. package/dist-client/viewparts/figure-thumb.d.ts +29 -0
  69. package/dist-client/viewparts/figure-thumb.js +242 -0
  70. package/dist-client/viewparts/figure-thumb.js.map +1 -0
  71. package/dist-server/tsconfig.tsbuildinfo +1 -1
  72. package/package.json +6 -5
  73. package/test/i18n-prefix-guard.test.ts +171 -0
  74. package/test/korean-register-guard.test.ts +83 -0
  75. package/translations/en.json +351 -264
  76. package/translations/ja.json +276 -264
  77. package/translations/ko.json +351 -264
  78. package/translations/ms.json +276 -264
  79. package/translations/zh.json +276 -264
@@ -1,4 +1,7 @@
1
+ import '@material/web/icon/icon.js'
1
2
  import './figure-inspector.js'
3
+ import './figure-capabilities.js'
4
+ import './figure-animations.js'
2
5
  import './figure-report.js'
3
6
  import './figure-settings.js'
4
7
  import './figure-history-panel.js'
@@ -13,7 +16,7 @@ import type { BoardModel, PartModel } from './figure-source.js'
13
16
  import { DEFAULT_VIEW } from './figure-view.js'
14
17
  import type { ViewSettings } from './figure-view.js'
15
18
 
16
- type Tab = 'properties' | 'verdict' | 'view' | 'history'
19
+ type Tab = 'properties' | 'capability' | 'animation' | 'verdict' | 'view' | 'history'
17
20
 
18
21
  /**
19
22
  * 저작면 오른쪽 칸 — 속성과 판정을 탭으로 겹쳐 둔다.
@@ -65,13 +68,22 @@ export class FigureSide extends localize(i18next)(LitElement) {
65
68
  }
66
69
  button[tab] {
67
70
  flex: 1;
71
+ /*
72
+ **아이콘 하나가 한 칸을 쓴다.**
73
+
74
+ 전에는 이름을 적었는데, 넷이 좁은 칸을 나눠 쓰면서 접혀 「성숙 / 도」가 되었다. 접히면
75
+ 그 탭만 두 줄이 되어 높이가 서로 달라진다. 다섯이 되면서 더는 줄여 짜 넣을 수 없어
76
+ 아이콘으로 바꿨고, 이름은 title 과 aria-label 로 남는다.
77
+
78
+ 배지는 여전히 자리를 차지하지 않고 **아이콘 위에 겹친다**(아래 span[badge]). 개수는
79
+ 곁들이는 것이다.
80
+ */
81
+ position: relative;
68
82
  display: flex;
69
83
  align-items: center;
70
84
  justify-content: center;
71
- gap: 6px;
72
- padding: 8px 6px;
73
- font: var(--label-font, inherit);
74
- font-size: 0.76rem;
85
+ white-space: nowrap;
86
+ padding: 7px 6px;
75
87
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
76
88
  background: none;
77
89
  border: none;
@@ -88,17 +100,34 @@ export class FigureSide extends localize(i18next)(LitElement) {
88
100
  border-bottom-color: var(--md-sys-color-primary);
89
101
  }
90
102
 
91
- /* 판정을 덮어 두어도 개수는 여기 남는다 */
103
+ /*
104
+ 판정을 덮어 두어도 개수는 여기 남는다.
105
+
106
+ 자리를 먹지 않게 오른쪽 위에 겹쳐 둔다 — 탭 이름을 밀지 않으므로 이름이 접히지 않는다.
107
+ 숫자가 커져도(1,200 같은 것) 이름 위로 넘칠 뿐 줄을 바꾸지 않는다.
108
+ */
109
+ button[tab] md-icon {
110
+ font-size: 20px;
111
+ width: 20px;
112
+ height: 20px;
113
+ }
92
114
  span[badge] {
93
- padding: 1px 7px;
94
- font-size: 0.72rem;
115
+ position: absolute;
116
+ /* 이름 위에 겹치되 위로 붙지 않게 — 탭 가운데 글자와 눈높이를 조금 맞춘다 */
117
+ top: 5px;
118
+ right: 2px;
119
+ padding: 0 5px;
120
+ font-size: 0.62rem;
95
121
  font-weight: 700;
122
+ line-height: 1.5;
96
123
  color: var(--md-sys-color-on-primary-container);
97
124
  background-color: var(--md-sys-color-primary-container);
98
125
  border-radius: 999em;
126
+ pointer-events: none;
99
127
  }
100
128
 
101
129
  figure-inspector,
130
+ figure-animations,
102
131
  figure-report,
103
132
  figure-settings {
104
133
  flex: 1;
@@ -118,6 +147,9 @@ export class FigureSide extends localize(i18next)(LitElement) {
118
147
  곳이라 변환된 FigureSource 를 받는다. 변환은 모델러 페이지가 한 번만 해서 내려보낸다.
119
148
  */
120
149
  @property({ type: String }) figureName = ''
150
+ /** 카탈로그 분류·태그. 속성 탭이 보여 주고 바꾼다. */
151
+ @property({ type: String }) figureCategory?: string
152
+ @property({ type: Array }) figureTags?: string[]
121
153
  /** 이력 탭이 발행하고 되돌리는 데 필요한 것들. 정본은 저작면이 든다. */
122
154
  @property({ type: String }) figureId?: string
123
155
  @property({ type: String }) figureState?: 'draft' | 'released'
@@ -135,23 +167,51 @@ export class FigureSide extends localize(i18next)(LitElement) {
135
167
  /** 판정이 알려 준 개수. 탭 이름표에 붙는다. */
136
168
  @state() private instances?: number
137
169
 
170
+ /**
171
+ * 이 도형이 든 clip 수. 탭 아이콘에 붙는다.
172
+ *
173
+ * 아이콘만으로는 「여기에 무언가 있다」를 알 수 없다. 대부분의 도형은 움직이지 않으므로
174
+ * 이 탭은 늘 비어 있을 것이고, 그러면 움직이는 도형을 열었을 때 그 사실이 눈에 걸려야 한다.
175
+ * 0 이면 아무것도 안 붙인다 — 「0」을 붙이면 없는 것이 있는 것처럼 자리를 차지한다.
176
+ */
177
+ private get clipCount(): number | undefined {
178
+ const n = this.board?.animations?.length ?? 0
179
+ return n === 0 ? undefined : n
180
+ }
181
+
138
182
  render() {
139
183
  return html`
140
184
  <div tabs>
141
- ${this.renderTab('properties', i18next.t('label.properties'))}
142
- ${this.renderTab('verdict', i18next.t('label.maturity'), this.instances)}
143
- ${this.renderTab('view', i18next.t('label.view'))}
144
- ${this.renderTab('history', i18next.t('label.history'))}
185
+ ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
186
+ ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
187
+ ${this.renderTab('animation', 'animation', i18next.t('figure.label.animation'), this.clipCount)}
188
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
189
+ ${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
190
+ ${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
145
191
  </div>
146
192
 
147
193
  <figure-inspector
148
194
  ?off=${this.tab !== 'properties'}
149
195
  .figureName=${this.figureName}
196
+ .figureCategory=${this.figureCategory}
197
+ .figureTags=${this.figureTags}
150
198
  .board=${this.board}
151
199
  .parts=${this.parts}
152
200
  .selected=${this.selected}
153
201
  ></figure-inspector>
154
202
 
203
+ <figure-capabilities
204
+ ?off=${this.tab !== 'capability'}
205
+ .board=${this.board}
206
+ .parts=${this.parts}
207
+ ></figure-capabilities>
208
+
209
+ <figure-animations
210
+ ?off=${this.tab !== 'animation'}
211
+ .board=${this.board}
212
+ .parts=${this.parts}
213
+ ></figure-animations>
214
+
155
215
  <figure-report
156
216
  ?off=${this.tab !== 'verdict'}
157
217
  .source=${this.source}
@@ -173,10 +233,26 @@ export class FigureSide extends localize(i18next)(LitElement) {
173
233
  `
174
234
  }
175
235
 
176
- private renderTab(tab: Tab, label: string, badge?: number) {
236
+ /**
237
+ * 탭 하나 — **아이콘이 이름을 대신한다.**
238
+ *
239
+ * 탭이 다섯이 되면서 이름이 좁은 칸을 나눠 쓰다 접혔다. 앞으로 더 늘어날 자리이므로 이름을
240
+ * 줄여 짜 넣는 대신 아이콘으로 바꾼다.
241
+ *
242
+ * **이름을 버리지는 않는다.** 아이콘만으로는 무엇인지 모르는 사람이 있고, 특히 「능력」처럼
243
+ * 이 도구에만 있는 개념이 그렇다. 그래서 이름은 `title` 로 남아 마우스를 올리면 나오고,
244
+ * `aria-label` 로 읽는 기계에도 그대로 간다.
245
+ */
246
+ private renderTab(tab: Tab, icon: string, label: string, badge?: number) {
177
247
  return html`
178
- <button tab ?on=${this.tab === tab} @click=${() => (this.tab = tab)}>
179
- ${label}${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
248
+ <button
249
+ tab
250
+ ?on=${this.tab === tab}
251
+ title=${label}
252
+ aria-label=${label}
253
+ @click=${() => (this.tab = tab)}
254
+ >
255
+ <md-icon>${icon}</md-icon>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
180
256
  </button>
181
257
  `
182
258
  }
@@ -149,7 +149,6 @@ const FULL: FigureSource = {
149
149
  base: { x: 3000, y: 800, z: 2000 },
150
150
  detailLevel: 'L',
151
151
  styleKit: 'factory',
152
- anchor: { x: 0, y: -400, z: 0 },
153
152
  parts: [
154
153
  {
155
154
  name: 'deck',
@@ -200,8 +199,23 @@ const FULL: FigureSource = {
200
199
  material: { token: 'palette.neutral' },
201
200
  sizing: 'repeat',
202
201
  repeat: { axis: 'x', pitch: 200 }
202
+ },
203
+ {
204
+ /*
205
+ 물건을 놓는 자리이면서 드나드는 문이다 — AGV 의 상판이 그렇다. **그려지지 않으므로**
206
+ 재질이 없다.
207
+
208
+ 그리지 않는 것이라 화면으로는 왕복에서 누락됐는지 알 수 없다. 자리를 잃어버린 도형은
209
+ 멀쩡해 보이면서 아무것도 받지 못한다.
210
+ */
211
+ name: 'seat',
212
+ primitive: 'cube',
213
+ transform: { position: { x: 0, y: 420, z: 0 }, size: { x: 700, y: 500, z: 500 } },
214
+ capability: { roles: ['slot', 'port-in', 'port-out'], accepts: ['PALLET'], capacity: 2 }
203
215
  }
204
216
  ],
217
+ /* 이 도형이 지는 능력. 부품이 아니라 도형 전체의 것이다. */
218
+ capabilities: ['FlowNode'],
205
219
  /*
206
220
  움직임. 편집면이 아직 안 만지는 자리이며, **바로 그래서 여기 있다.**
207
221
 
@@ -82,14 +82,6 @@ export interface BoardModel {
82
82
  figureType?: string
83
83
  detailLevel?: DetailLevel
84
84
  styleKit?: string
85
- /**
86
- * 형상 전체의 기준점.
87
- *
88
- * **부품의 `anchor` 와 다른 것이다.** 이쪽은 점(`Vec3`)이고 저쪽은 축별로 어느 면을
89
- * 붙잡느냐다. 한 형식 안에 같은 낱말이 둘인 것은 좋지 않으나, 이름을 바꾸면 저장된
90
- * 원본이 따라와야 하므로 여기서 정하지 않는다.
91
- */
92
- anchor?: Vec3
93
85
  /**
94
86
  * 움직임. **부품 바깥에 있다** — clip 하나가 부품 여럿을 함께 움직이기 때문이다.
95
87
  *
@@ -97,6 +89,14 @@ export interface BoardModel {
97
89
  * 저장하는 것만으로 **조용히 지워지기** 때문이다.
98
90
  */
99
91
  animations?: FigureSource['animations']
92
+ /**
93
+ * 이 도형이 지는 능력. 씬의 capability mixin 이름이다.
94
+ *
95
+ * **부품이 아니라 도형 전체의 것이다** — 「이 컨베이어는 흐름의 마디다」는 부품 하나가
96
+ * 아니라 도형이 지는 성질이다. 부품 쪽 `capability` 는 그 능력을 **어느 부품이 받쳐
97
+ * 주나**를 말하는 다른 칸이다.
98
+ */
99
+ capabilities?: FigureSource['capabilities']
100
100
  }
101
101
 
102
102
  /** 부품 컴포넌트의 상태. 씬의 용어 그대로다. */
@@ -159,6 +159,13 @@ export interface PartModel {
159
159
  anchor?: FigurePart['anchor']
160
160
  repeat?: FigurePart['repeat']
161
161
  label?: FigurePart['label']
162
+ /**
163
+ * 이 부품이 능력의 어느 자리인가 — 물건이 앉는 자리(slot)이거나 드나드는 문(port)이다.
164
+ *
165
+ * 이 칸이 있는 부품은 **그려지지 않는다.** 저작면에서는 어디인지 보여야 하니 그리지만,
166
+ * 청사진에서는 `groups` 가 아니라 `anchors` 로 간다.
167
+ */
168
+ capability?: FigurePart['capability']
162
169
  }
163
170
 
164
171
  const RAD_TO_DEG = 180 / Math.PI
@@ -223,11 +230,11 @@ export function toFigureSource(board: BoardModel, parts: PartModel[]): FigureSou
223
230
  return withoutEmpty({
224
231
  type: board.figureType ?? '',
225
232
  base: { x: board.width, y: board.depth, z: board.height },
226
- anchor: board.anchor,
227
233
  detailLevel: board.detailLevel,
228
234
  styleKit: board.styleKit,
229
235
  parts: parts.map(part => partTo(part, half)),
230
- animations: board.animations
236
+ animations: board.animations,
237
+ capabilities: board.capabilities
231
238
  }) as FigureSource
232
239
  }
233
240
 
@@ -239,6 +246,13 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
239
246
  })
240
247
 
241
248
  const shape = withoutEmpty({ round: part.round, path: part.path, hollow: part.hollow })
249
+ /*
250
+ 재질이 하나도 없으면 **칸 자체를 안 적는다.**
251
+
252
+ 빈 객체를 적으면 형식이 「재질은 있는데 무엇으로 칠할지 모른다」로 읽어
253
+ `material-unresolvable` 을 낸다. 그리지 않는 자리(`capability`)에는 재질이 없는 것이
254
+ 정상이고, 그 부품이 조용히 오류를 달고 다니게 된다.
255
+ */
242
256
  const material = withoutEmpty({
243
257
  token: part.token,
244
258
  preset: part.preset,
@@ -262,13 +276,14 @@ function partTo(part: PartModel, half: Vec3): FigurePart {
262
276
  }),
263
277
  segments: part.segments,
264
278
  shape: Object.keys(shape).length > 0 ? shape : undefined,
265
- material,
279
+ material: Object.keys(material).length > 0 ? material : undefined,
266
280
  materialSlot: part.materialSlot,
267
281
  keepRound: part.keepRound,
268
282
  sizing: part.sizing,
269
283
  anchor: part.anchor,
270
284
  repeat: part.repeat,
271
- label: part.label
285
+ label: part.label,
286
+ capability: part.capability
272
287
  }) as FigurePart
273
288
  }
274
289
 
@@ -283,8 +298,8 @@ export function fromFigureSource(source: FigureSource): { board: BoardModel; par
283
298
  figureType: source.type,
284
299
  detailLevel: source.detailLevel,
285
300
  styleKit: source.styleKit,
286
- anchor: source.anchor,
287
- animations: source.animations
301
+ animations: source.animations,
302
+ capabilities: source.capabilities
288
303
  }) as BoardModel
289
304
 
290
305
  return { board, parts: source.parts.map(part => partFrom(part, half)) }
@@ -333,6 +348,7 @@ function partFrom(part: FigurePart, half: Vec3): PartModel {
333
348
  sizing: part.sizing,
334
349
  anchor: part.anchor,
335
350
  repeat: part.repeat,
336
- label: part.label
351
+ label: part.label,
352
+ capability: part.capability
337
353
  }) as PartModel
338
354
  }
@@ -34,11 +34,40 @@ interface Shooter extends HTMLElement {
34
34
  snapshot?: (width?: number, height?: number) => Promise<string | undefined>
35
35
  }
36
36
 
37
- /** 프레임 두 장 뒤에 한 번 더 쉰다 — 씬이 서고 카메라가 자리를 잡을 틈. 씬에 그 신호가 없다. */
37
+ /**
38
+ * 씬이 서고 카메라가 자리를 잡을 틈 — 프레임 두 장 뒤에 한 번 더 쉰다. 씬에 그 신호가 없다.
39
+ *
40
+ * **rAF 만 기다리면 안 된다.** 탭이 뒤로 가거나 창이 가려지면 브라우저가 rAF 를 멈추고, 그러면
41
+ * 이 약속이 영원히 안 풀린다. 그 위에 저장이 얹혀 있어서 저장이 통째로 멈췄다 — 단추는 잠긴 채로,
42
+ * 아무 말도 없이. [확인함] 2026-09-10 포커스 없는 탭에서 `saving: true` 로 고정.
43
+ *
44
+ * 그래서 시계와 함께 경주시킨다. 늦게 깨어난 쪽은 버려지고, 화면이 안 그려지는 상황이면 아래
45
+ * `snapshot` 이 빈 것을 돌려주거나 시간이 다 된다 — 어느 쪽이든 **저장은 계속 간다.**
46
+ */
38
47
  function settle(): Promise<void> {
39
- return new Promise(resolve =>
48
+ const frames = new Promise<void>(resolve =>
40
49
  requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)))
41
50
  )
51
+
52
+ return Promise.race([frames, delay(600)])
53
+ }
54
+
55
+ function delay(ms: number): Promise<void> {
56
+ return new Promise(resolve => setTimeout(resolve, ms))
57
+ }
58
+
59
+ /**
60
+ * 그림을 기다리는 시간의 한도.
61
+ *
62
+ * 3D 를 세워 한 장 찍는 데 걸리는 시간은 형상과 기계에 따라 다르지만, 이 한도의 목적은 빠르기가
63
+ * 아니라 **저장이 멈추지 않는 것**이다. 넘으면 그림 없이 저장한다 — 정본이 그림보다 중하고,
64
+ * 그림은 다음에 저작면을 열 때 다시 채운다(`figure-modeller-page` 의 fillThumbnail).
65
+ */
66
+ const PATIENCE = 5000
67
+
68
+ /** 시간이 다 되면 없는 것으로 답한다. 던지지 않는다 — 그림이 없는 것은 실패가 아니다. */
69
+ function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
70
+ return Promise.race([work, delay(ms).then(() => undefined)])
42
71
  }
43
72
 
44
73
  export async function captureThumbnail(
@@ -66,9 +95,25 @@ export async function captureThumbnail(
66
95
 
67
96
  try {
68
97
  host.appendChild(shooter)
69
- await settle()
70
98
 
71
- return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
99
+ /*
100
+ **한도 안에 못 찍으면 그림 없이 간다.** 이 함수를 부르는 자리가 저장이고, 저장은 그림을
101
+ 기다리다 멈춰서는 안 된다. 못 찍은 것은 조용히 넘기지 않고 콘솔에 남긴다 — 그림이 계속
102
+ 안 생기면 그 줄이 단서가 된다.
103
+ */
104
+ const shot = await within(
105
+ (async () => {
106
+ await settle()
107
+ return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
108
+ })(),
109
+ PATIENCE
110
+ )
111
+
112
+ if (!shot) {
113
+ console.warn(`[thumbnail] ${PATIENCE}ms 안에 못 찍었습니다 — 그림 없이 저장합니다`)
114
+ }
115
+
116
+ return shot
72
117
  } catch (e) {
73
118
  console.warn(`[thumbnail] 세우지 못했습니다 — ${(e as Error).message}`)
74
119
  return undefined
@@ -70,7 +70,7 @@ export function maturityOf(source: FigureSource, cost: FigureCost, score: Figure
70
70
  function scale(cost: FigureCost): MaturityAxis {
71
71
  const capacity = capacityFrom(cost.groups, cost.triangles)
72
72
  if (!capacity) {
73
- return { key: 'scale', blocked: 'text.maturity-blocked-no-parts' }
73
+ return { key: 'scale', blocked: 'figure.text.maturity-blocked-no-parts' }
74
74
  }
75
75
 
76
76
  return {
@@ -79,7 +79,7 @@ function scale(cost: FigureCost): MaturityAxis {
79
79
  // 목표를 넘긴 여유는 다른 축을 고치는 데 쓰는 편이 낫다.
80
80
  ratio: Math.min(1, capacity.instances / BOARD_ASSETS),
81
81
  reading: String(capacity.instances),
82
- because: 'text.maturity-scale-because'
82
+ because: 'figure.text.maturity-scale-because'
83
83
  }
84
84
  }
85
85
 
@@ -102,8 +102,8 @@ function consistency(source: FigureSource, cost: FigureCost): MaturityAxis {
102
102
  return {
103
103
  key: 'consistency',
104
104
  ratio: (3 - faults) / 3,
105
- readingKey: faults === 0 ? 'text.maturity-consistency-clean' : 'text.maturity-consistency-faults',
106
- because: 'text.maturity-consistency-because'
105
+ readingKey: faults === 0 ? 'figure.text.maturity-consistency-clean' : 'figure.text.maturity-consistency-faults',
106
+ because: 'figure.text.maturity-consistency-because'
107
107
  }
108
108
  }
109
109
 
@@ -117,7 +117,7 @@ function consistency(source: FigureSource, cost: FigureCost): MaturityAxis {
117
117
  function reuse(score: FigureScore): MaturityAxis {
118
118
  const { distinct, parts, saved } = score.reuse
119
119
  if (parts === 0) {
120
- return { key: 'reuse', blocked: 'text.maturity-blocked-no-parts' }
120
+ return { key: 'reuse', blocked: 'figure.text.maturity-blocked-no-parts' }
121
121
  }
122
122
 
123
123
  return {
@@ -125,7 +125,7 @@ function reuse(score: FigureScore): MaturityAxis {
125
125
  // ratio 가 낮을수록 많이 되쓴다 — 뒤집어서 높은 쪽이 좋게 만든다
126
126
  ratio: 1 - distinct / parts,
127
127
  reading: String(saved),
128
- because: 'text.maturity-reuse-because'
128
+ because: 'figure.text.maturity-reuse-because'
129
129
  }
130
130
  }
131
131
 
@@ -148,7 +148,7 @@ function detail(source: FigureSource, cost: FigureCost): MaturityAxis {
148
148
  // 절반쯤 쓴 것을 제일 좋게 본다. 텅 비어도, 넘쳐도 등급과 안 맞는다.
149
149
  ratio: used > 1 ? Math.max(0, 2 - used) : Math.min(1, used * 2),
150
150
  reading: `${cost.parts} / ${limit}`,
151
- because: 'text.maturity-detail-because'
151
+ because: 'figure.text.maturity-detail-because'
152
152
  }
153
153
  }
154
154
 
@@ -162,7 +162,7 @@ function detail(source: FigureSource, cost: FigureCost): MaturityAxis {
162
162
  * **아무 형상도 보여 줄 수 없는 상태**다. 축 자체가 아직 없는 것이다.
163
163
  */
164
164
  function reactive(source: FigureSource): MaturityAxis {
165
- return { key: 'reactive', blocked: 'text.maturity-blocked-no-binding' }
165
+ return { key: 'reactive', blocked: 'figure.text.maturity-blocked-no-binding' }
166
166
  }
167
167
 
168
168
  /**
@@ -205,7 +205,7 @@ function reactive(source: FigureSource): MaturityAxis {
205
205
  function readable(source: FigureSource, cost: FigureCost): MaturityAxis {
206
206
  const regions = topViewRegions(source)
207
207
  if (regions.total === 0) {
208
- return { key: 'readable', blocked: 'text.maturity-blocked-no-parts' }
208
+ return { key: 'readable', blocked: 'figure.text.maturity-blocked-no-parts' }
209
209
  }
210
210
 
211
211
  // 배경도 한 구역으로 센다 — 발자국을 얼마나 채우나가 식별성의 일부다.
@@ -221,7 +221,7 @@ function readable(source: FigureSource, cost: FigureCost): MaturityAxis {
221
221
  key: 'readable',
222
222
  ratio: Math.min(1, entropy / ceiling),
223
223
  reading: `${regions.byMaterial.size}`,
224
- because: 'text.maturity-readable-because'
224
+ because: 'figure.text.maturity-readable-because'
225
225
  }
226
226
  }
227
227