@things-factory/figure-ui 10.1.3 → 10.1.4

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 (35) hide show
  1. package/client/modeller/figure-ask.ts +14 -4
  2. package/client/modeller/figure-canvas.ts +53 -0
  3. package/client/modeller/figure-inspector.ts +78 -2
  4. package/client/modeller/figure-side.ts +3 -0
  5. package/client/modeller/figure-thumbnail.ts +78 -0
  6. package/client/pages/figure-list-page.ts +67 -8
  7. package/client/pages/figure-modeller-page.ts +92 -19
  8. package/dist-client/modeller/figure-ask.js +14 -4
  9. package/dist-client/modeller/figure-ask.js.map +1 -1
  10. package/dist-client/modeller/figure-canvas.d.ts +11 -0
  11. package/dist-client/modeller/figure-canvas.js +42 -0
  12. package/dist-client/modeller/figure-canvas.js.map +1 -1
  13. package/dist-client/modeller/figure-inspector.d.ts +17 -0
  14. package/dist-client/modeller/figure-inspector.js +77 -2
  15. package/dist-client/modeller/figure-inspector.js.map +1 -1
  16. package/dist-client/modeller/figure-side.d.ts +1 -0
  17. package/dist-client/modeller/figure-side.js +12 -0
  18. package/dist-client/modeller/figure-side.js.map +1 -1
  19. package/dist-client/modeller/figure-thumbnail.d.ts +3 -0
  20. package/dist-client/modeller/figure-thumbnail.js +59 -0
  21. package/dist-client/modeller/figure-thumbnail.js.map +1 -0
  22. package/dist-client/pages/figure-list-page.d.ts +5 -0
  23. package/dist-client/pages/figure-list-page.js +70 -10
  24. package/dist-client/pages/figure-list-page.js.map +1 -1
  25. package/dist-client/pages/figure-modeller-page.d.ts +18 -0
  26. package/dist-client/pages/figure-modeller-page.js +89 -19
  27. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  28. package/dist-client/tsconfig.tsbuildinfo +1 -0
  29. package/dist-server/tsconfig.tsbuildinfo +1 -0
  30. package/package.json +3 -3
  31. package/translations/en.json +4 -0
  32. package/translations/ja.json +4 -0
  33. package/translations/ko.json +4 -0
  34. package/translations/ms.json +4 -0
  35. package/translations/zh.json +4 -0
@@ -31,10 +31,19 @@ import type { FigureProposal } from '../types.js'
31
31
  @customElement('figure-ask')
32
32
  export class FigureAsk extends localize(i18next)(LitElement) {
33
33
  static styles = css`
34
+ /*
35
+ 이 부품은 **자기 줄을 갖지 않는다.**
36
+
37
+ 전에는 테두리와 여백으로 스스로 한 줄이 되었다. 저작면의 머리줄 바로 아래에 놓이니
38
+ 화면 위쪽이 두 줄이 되었고, 위 줄은 이름을 페이지 제목과 겹쳐 적고 있었다. 캔버스가
39
+ 주인공인 화면에서 그 두 줄은 비싸다.
40
+
41
+ 이제 머리줄 안에 들어가 그 줄의 남는 폭을 쓴다. 테두리·여백은 담는 줄이 갖는다.
42
+ */
34
43
  :host {
35
- display: block;
36
- background-color: var(--md-sys-color-surface-container-lowest);
37
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
44
+ display: flex;
45
+ flex: 1;
46
+ min-width: 0;
38
47
  font: var(--label-font, inherit);
39
48
  /*
40
49
  이 줄의 글자 크기를 여기서 정한다.
@@ -49,9 +58,10 @@ export class FigureAsk extends localize(i18next)(LitElement) {
49
58
 
50
59
  form {
51
60
  display: flex;
61
+ flex: 1;
62
+ min-width: 0;
52
63
  align-items: center;
53
64
  gap: var(--spacing-medium, 8px);
54
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
55
65
  }
56
66
 
57
67
  md-icon[lead] {
@@ -204,6 +204,59 @@ export class FigureCanvas extends localize(i18next)(LitElement) {
204
204
  @state() private cannot = ''
205
205
 
206
206
  private scene?: SceneHandle
207
+
208
+ /**
209
+ * 지금 세워진 것을 그림으로 낸다 — 카드에 쓸 썸네일.
210
+ *
211
+ * 부르는 쪽은 이 부품을 **숨은 자리에 480×360 으로 하나 더 세워** 부른다
212
+ * (`figure-thumbnail.ts`). 보이는 저작면을 찍지 않는 이유는, 저작자가 돌려 둔 각도와 확대,
213
+ * 그리고 창 크기에 따라 달라지는 칸 비율이 그대로 카드에 박히기 때문이다 — 나사를 확대해 보던
214
+ * 중에 저장하면 그 자산의 얼굴이 나사가 된다. 목록은 견주는 자리라 프레임이 일정해야 한다.
215
+ *
216
+ * 못 찍으면 `undefined` 다. 빈 그림을 만들어 「그림이 있다」고 말하지 않는다.
217
+ */
218
+ async snapshot(width = 480, height = 360): Promise<string | undefined> {
219
+ /*
220
+ **3D 를 낼 수 있을 때만 찍는다.**
221
+
222
+ 씬의 `toDataURL` 은 **2D 씬을 렌더한다.** 그것으로 찍어 보니 흰 종이 한 장이 나왔다
223
+ (480×291 · 834바이트 · 순백 — 2D 판의 면인 `fillStyle: PAPER` 이 화면 전체였다). 3D 픽셀은
224
+ `model-layer.renderer3d`(THREE.WebGLRenderer) 쪽에 있고, 그 렌더러는
225
+ `preserveDrawingBuffer: false` 라 **그린 직후 같은 task 에서** 읽어야 한다. 그 절차를 아는
226
+ 것은 씬이므로, things-scene 이 그 일을 공개 API 로 내주면 여기서 부른다.
227
+
228
+ 아직 없으면 `undefined` 다. 흰 종이를 썸네일이라고 저장하면 카드가 「그림이 있다」고 말하면서
229
+ 아무것도 보여 주지 않고, 저장할 때마다 판 번호만 올라간다 — 없는 것보다 나쁘다.
230
+ */
231
+ const layer = this.scene?.root as
232
+ | (SceneBoard & {
233
+ snapshot3d?: (
234
+ width?: number,
235
+ height?: number,
236
+ type?: string,
237
+ quality?: number
238
+ ) => Promise<string | undefined>
239
+ })
240
+ | undefined
241
+
242
+ if (!layer?.snapshot3d) {
243
+ return undefined
244
+ }
245
+
246
+ try {
247
+ /*
248
+ 알파가 필요 없고 카드에 들어갈 그림이라 webp 로 찍는다. png 는 같은 그림이 서너 배 무겁고,
249
+ 그 무게가 도형마다 DB 와 목록 응답에 실린다. webp 를 못 만드는 브라우저는 캔버스가 알아서
250
+ png 를 돌려준다 — 우리가 판정하지 않는다.
251
+ */
252
+ const url = await layer.snapshot3d(width, height, 'image/webp', 0.85)
253
+ return typeof url === 'string' && url.startsWith('data:') ? url : undefined
254
+ } catch (e) {
255
+ /* 원인을 지어내지 않는다. 못 찍은 것이 저장을 막지는 않는다. */
256
+ console.warn(`[thumbnail] 찍지 못했습니다 — ${(e as Error).message}`)
257
+ return undefined
258
+ }
259
+ }
207
260
  /** 지금 씬이 서 있는 판. 이것이 달라지면 다시 세운다. */
208
261
  private standing?: string
209
262
  /** 칸 크기를 따라가게 지켜본다. 안 지켜보면 0x0 으로 고정된다. */
@@ -1,3 +1,5 @@
1
+ import '@material/web/icon/icon.js'
2
+
1
3
  import { css, html, LitElement, nothing, svg } from 'lit'
2
4
  import { customElement, property } from 'lit/decorators.js'
3
5
 
@@ -452,10 +454,36 @@ export class FigureInspector extends localize(i18next)(LitElement) {
452
454
  line-height: 1.6;
453
455
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
454
456
  }
457
+
458
+ /*
459
+ 고칠 수 없는 값 — 입력칸처럼 보이지 않게 한다.
460
+
461
+ 전에는 그냥 글자였고, 옆의 이름 칸과 나란히 있으니 눌러도 되는 것처럼 보였다. 자물쇠와
462
+ 칸 없는 바탕이 「읽는 값」이라고 먼저 말한다.
463
+ */
464
+ code[fixed] {
465
+ display: inline-flex;
466
+ align-items: center;
467
+ gap: 4px;
468
+ padding: 4px 8px;
469
+ border-radius: 6px;
470
+ background-color: var(--md-sys-color-surface-container-high, rgba(0, 0, 0, 0.05));
471
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
472
+ font-family: var(--mono-font, monospace);
473
+ font-size: 0.76rem;
474
+ }
475
+
476
+ code[fixed] md-icon {
477
+ --md-icon-size: 13px;
478
+ opacity: 0.7;
479
+ }
455
480
  `
456
481
  ]
457
482
 
458
483
  /** 보드. 위치를 보드 중심 기준으로 바꾸려면 이것이 있어야 한다. */
484
+ /** 도형의 표시 이름. 정본은 저작면이 들고, 여기서는 보여 주고 바꾼다(`figure-rename` 으로 알린다). */
485
+ @property({ type: String }) figureName = ''
486
+
459
487
  @property({ type: Object }) board?: BoardModel
460
488
  /** 보드 위의 부품들. 씬 모델 그대로다. */
461
489
  @property({ type: Array }) parts: PartModel[] = []
@@ -470,6 +498,7 @@ export class FigureInspector extends localize(i18next)(LitElement) {
470
498
  const part = this.chosen
471
499
  if (!part || !this.board) {
472
500
  return html`
501
+ ${this.renderFigure()}
473
502
  <section>
474
503
  <h3>${i18next.t('label.properties')}</h3>
475
504
  <p quiet>${i18next.t('text.select-a-part-to-edit')}</p>
@@ -481,8 +510,55 @@ export class FigureInspector extends localize(i18next)(LitElement) {
481
510
  const shown = partToFigure(part, this.board)
482
511
 
483
512
  return html`
484
- ${this.renderIdentity(shown)} ${this.renderPlacement(shown)} ${this.renderShape(shown)}
485
- ${this.renderMaterial(shown)} ${this.renderBehaviour(shown)}
513
+ ${this.renderFigure()} ${this.renderIdentity(shown)} ${this.renderPlacement(shown)}
514
+ ${this.renderShape(shown)} ${this.renderMaterial(shown)} ${this.renderBehaviour(shown)}
515
+ `
516
+ }
517
+
518
+ /**
519
+ * 도형 자체 — 이름과 타입.
520
+ *
521
+ * ## 왜 머리줄이 아니라 여기인가
522
+ *
523
+ * 저작면 위에 줄을 하나 더 두고 이름 칸을 놓았었다. 앱에서는 셸이 페이지 제목으로 그 이름을
524
+ * 이미 그리므로 같은 글자가 두 번 나오고, 캔버스가 주인공인 화면에서 그 줄만큼 높이를 잃었다.
525
+ * 속성 패널은 「이 도형이 무엇인가」를 다루는 자리이므로 이름도 여기 산다.
526
+ *
527
+ * ## 타입은 읽기만 한다
528
+ *
529
+ * 저장되는 식별자다. 배치된 보드가 이 이름으로 타입을 찾으므로 만든 뒤에는 못 바꾼다.
530
+ */
531
+ private renderFigure() {
532
+ return html`
533
+ <section>
534
+ <h3>${i18next.t('label.figure')}</h3>
535
+ <div field>
536
+ <label>${i18next.t('label.name')}</label>
537
+ <input
538
+ .value=${this.figureName}
539
+ placeholder=${i18next.t('text.figure-display-name')}
540
+ @change=${(e: Event) =>
541
+ this.dispatchEvent(
542
+ new CustomEvent('figure-rename', {
543
+ detail: { name: (e.target as HTMLInputElement).value },
544
+ bubbles: true,
545
+ composed: true
546
+ })
547
+ )}
548
+ />
549
+ </div>
550
+ <div field>
551
+ <label>${i18next.t('label.type')}</label>
552
+ <code fixed title=${i18next.t('text.type-name-cannot-change')}>
553
+ <md-icon>lock</md-icon>${this.board?.figureType ?? ''}
554
+ </code>
555
+ </div>
556
+ <!--
557
+ 왜 못 고치는지 **적어 둔다.** 전에는 tooltip 에만 있었고, 그러면 눌러 보고 안 되는 것을
558
+ 알게 된다 — 화면이 안 되는 이유를 말하지 않으면 사용자가 자기 실수라고 생각한다.
559
+ -->
560
+ <p quiet>${i18next.t('text.type-name-cannot-change')}</p>
561
+ </section>
486
562
  `
487
563
  }
488
564
 
@@ -116,6 +116,8 @@ export class FigureSide extends localize(i18next)(LitElement) {
116
116
  속성은 **수정하는** 곳이라 편집 중인 씬 모델을 받는다. 판정은 **저장될 것을 측정하는**
117
117
  곳이라 변환된 FigureSource 를 받는다. 변환은 모델러 페이지가 한 번만 해서 내려보낸다.
118
118
  */
119
+ @property({ type: String }) figureName = ''
120
+
119
121
  @property({ type: Object }) board?: BoardModel
120
122
  @property({ type: Array }) parts: PartModel[] = []
121
123
  @property({ type: Object }) source?: FigureSource
@@ -137,6 +139,7 @@ export class FigureSide extends localize(i18next)(LitElement) {
137
139
 
138
140
  <figure-inspector
139
141
  ?off=${this.tab !== 'properties'}
142
+ .figureName=${this.figureName}
140
143
  .board=${this.board}
141
144
  .parts=${this.parts}
142
145
  .selected=${this.selected}
@@ -0,0 +1,78 @@
1
+ import './figure-canvas.js'
2
+
3
+ import type { BoardModel, PartModel } from './figure-source.js'
4
+
5
+ /**
6
+ * 카드에 쓸 그림을 찍는다 — **숨은 자리에 저작 캔버스를 하나 더 세워서.**
7
+ *
8
+ * ## 왜 보이는 캔버스를 찍지 않나
9
+ *
10
+ * 저작면의 캔버스는 저작자가 돌려 둔 각도와 확대로 서 있고, 칸의 비율도 창 크기와 접힌 패널에 따라
11
+ * 다르다. 그것을 찍으면 카드마다 프레임이 달라지고, 방금 확대해 보던 나사 하나가 그 자산의 얼굴이
12
+ * 된다. 목록은 견주는 자리이므로 그림은 **정해진 크기와 정해진 카메라**로 찍는다.
13
+ *
14
+ * ## 왜 씬을 여기서 세우지 않고 부품을 세우나
15
+ *
16
+ * 씬 값(환경 · 하늘 · 조명 · 픽셀 비율 · 좌표 바탕)은 `figure-canvas` 가 든다. 여기서 다시 적으면
17
+ * 두 벌이 되고, 그러면 **카드에 보이는 그림과 저작면에서 보던 것이 아무 오류 없이 갈린다.** 그래서
18
+ * 같은 부품을 `viewOnly` 로 하나 더 세워 그 부품이 자기 씬을 세우게 하고, 찍은 뒤 버린다.
19
+ *
20
+ * ## 왜 서버가 아니라 브라우저인가
21
+ *
22
+ * 보드는 저장할 때 서버가 찍는데, 백엔드에 GPU 가 없는 배포가 많아 헤드리스로 3D 를 찍는 것이 자주
23
+ * 실패한다. 도형을 그릴 GPU 는 이미 보는 사람 손에 있다.
24
+ */
25
+
26
+ /** 카드는 4:3 이다. */
27
+ const WIDTH = 480
28
+ const HEIGHT = 360
29
+
30
+ interface Shooter extends HTMLElement {
31
+ board?: BoardModel
32
+ parts?: PartModel[]
33
+ viewOnly?: boolean
34
+ snapshot?: (width?: number, height?: number) => Promise<string | undefined>
35
+ }
36
+
37
+ /** 프레임 두 장 뒤에 한 번 더 쉰다 — 씬이 서고 카메라가 자리를 잡을 틈. 씬에 그 신호가 없다. */
38
+ function settle(): Promise<void> {
39
+ return new Promise(resolve =>
40
+ requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)))
41
+ )
42
+ }
43
+
44
+ export async function captureThumbnail(
45
+ board: BoardModel | undefined,
46
+ parts: PartModel[]
47
+ ): Promise<string | undefined> {
48
+ if (!board || !parts.length) {
49
+ return undefined
50
+ }
51
+
52
+ /*
53
+ 화면 밖에 둔다. `display: none` 이면 캔버스가 크기를 못 얻어 아무것도 그리지 못한다.
54
+ WebGL context 는 브라우저가 8~16개에서 끊으므로 찍은 뒤 반드시 버린다(아래 finally).
55
+ */
56
+ const host = document.createElement('div')
57
+ host.setAttribute('figure-thumbnail-stage', '')
58
+ host.style.cssText = `position: fixed; left: -10000px; top: 0; width: ${WIDTH}px; height: ${HEIGHT}px; pointer-events: none;`
59
+ document.body.appendChild(host)
60
+
61
+ const shooter = document.createElement('figure-canvas') as Shooter
62
+ shooter.viewOnly = true
63
+ shooter.board = board
64
+ shooter.parts = parts
65
+ shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
66
+
67
+ try {
68
+ host.appendChild(shooter)
69
+ await settle()
70
+
71
+ return shooter.snapshot ? await shooter.snapshot(WIDTH, HEIGHT) : undefined
72
+ } catch (e) {
73
+ console.warn(`[thumbnail] 세우지 못했습니다 — ${(e as Error).message}`)
74
+ return undefined
75
+ } finally {
76
+ host.remove()
77
+ }
78
+ }
@@ -5,6 +5,7 @@ import { css, html } from 'lit'
5
5
  import { customElement, property, state } from 'lit/decorators.js'
6
6
 
7
7
  import { i18next, localize } from '@operato/i18n'
8
+ import { auth } from '@things-factory/auth-base/dist-client'
8
9
  import { navigate, PageView } from '@operato/shell'
9
10
  import { ScrollbarStyles } from '@operato/styles'
10
11
 
@@ -134,6 +135,10 @@ export class FigureListPage extends FigureListPageBase {
134
135
  @state() private search = ''
135
136
  @state() private stateFilter = ''
136
137
  @state() private loaded = false
138
+ /** 못 불러온 사유. 빈 목록과 구분해서 보여 준다. */
139
+ @state() private failure = ''
140
+
141
+ private listen?: () => void
137
142
 
138
143
  get context() {
139
144
  return {
@@ -183,7 +188,16 @@ export class FigureListPage extends FigureListPageBase {
183
188
 
184
189
  ${!this.loaded
185
190
  ? html``
186
- : this.figures.length === 0
191
+ : this.failure
192
+ ? html`
193
+ <div empty>
194
+ <md-icon>error</md-icon>
195
+ <p>${i18next.t('text.could-not-load-figures')}</p>
196
+ <p quiet>${this.failure}</p>
197
+ <button @click=${() => this.reload()}>${i18next.t('button.try-again')}</button>
198
+ </div>
199
+ `
200
+ : this.figures.length === 0
187
201
  ? this.renderEmpty()
188
202
  : html`
189
203
  <div cards>
@@ -239,6 +253,38 @@ export class FigureListPage extends FigureListPageBase {
239
253
  `
240
254
  }
241
255
 
256
+ connectedCallback() {
257
+ super.connectedCallback()
258
+
259
+ /*
260
+ **로그인 정보가 도착하면 다시 불러온다.**
261
+
262
+ 화면이 서는 것과 프로필이 오는 것은 순서가 정해져 있지 않고, 목록 질의는 로그인된 사람으로
263
+ 나간다. 첫 질의가 프로필보다 앞서면 그 한 번이 실패하고, 그 뒤로 다시 묻는 사람이 없으면
264
+ 화면은 비어 있는 채로 남는다. lite-menu 도 같은 신호로 같은 순서 문제를 푼다
265
+ (`auth.on('profile')` — 그쪽 주석에 그 사연이 적혀 있다).
266
+
267
+ **이 화면에서 그 실패를 실제로 본 적은 없다.** 빈 화면을 봤던 것은 숨은 탭에서 조사할 때였고,
268
+ 그때는 셸이 첫 `pageUpdated` 를 requestAnimationFrame 안에서 부르는데 숨은 탭은 rAF 를 돌리지
269
+ 않아 질의가 아예 나가지 않았다 — 원인이 달랐다. 그래도 이 구독은 남긴다: 값이 싸고, 못 불러온
270
+ 경우를 화면이 말하게 한 아래 처리와 짝이다.
271
+ */
272
+ this.listen = () => {
273
+ if (this.active) {
274
+ void this.reload()
275
+ }
276
+ }
277
+ auth.on('profile', this.listen)
278
+ }
279
+
280
+ disconnectedCallback() {
281
+ if (this.listen) {
282
+ auth.off?.('profile', this.listen)
283
+ this.listen = undefined
284
+ }
285
+ super.disconnectedCallback()
286
+ }
287
+
242
288
  async pageUpdated(changes: Record<string, unknown>, lifecycle: Record<string, unknown>) {
243
289
  if (this.active) {
244
290
  await this.reload()
@@ -246,14 +292,27 @@ export class FigureListPage extends FigureListPageBase {
246
292
  }
247
293
 
248
294
  private async reload() {
249
- const { items, total } = await fetchFigureList({
250
- search: this.search || undefined,
251
- state: this.stateFilter || undefined
252
- })
295
+ /*
296
+ **못 불러왔으면 그렇게 말한다.**
297
+
298
+ 전에는 여기서 던진 오류가 아무도 받지 않아 `loaded` 가 거짓으로 남고, 화면은 아무것도 없이
299
+ 비어 있었다. 목록이 비어 있는 것과 못 불러온 것은 다른 사실이고, 사용자가 할 일도 다르다 —
300
+ 하나는 만들면 되고, 하나는 다시 시도하거나 사람을 불러야 한다.
301
+ */
302
+ try {
303
+ const { items, total } = await fetchFigureList({
304
+ search: this.search || undefined,
305
+ state: this.stateFilter || undefined
306
+ })
253
307
 
254
- this.figures = items
255
- this.total = total
256
- this.loaded = true
308
+ this.figures = items
309
+ this.total = total
310
+ this.failure = ''
311
+ } catch (e) {
312
+ this.failure = (e as Error).message
313
+ } finally {
314
+ this.loaded = true
315
+ }
257
316
  }
258
317
 
259
318
  private openModeller(id?: string) {
@@ -2,6 +2,7 @@ import '@material/web/icon/icon.js'
2
2
  import '../modeller/figure-side.js'
3
3
  import '../modeller/figure-ask.js'
4
4
  import '../modeller/figure-canvas.js'
5
+ import { captureThumbnail } from '../modeller/figure-thumbnail.js'
5
6
  import '../modeller/figure-preview.js'
6
7
  import '../modeller/figure-parts.js'
7
8
  import '../modeller/figure-inspector.js'
@@ -188,7 +189,13 @@ export class FigureModellerPage extends FigureModellerPageBase {
188
189
  display: none;
189
190
  }
190
191
 
191
- /* 머리줄 — 무엇을 고치는 중인지 늘 보인다 */
192
+ /*
193
+ 머리줄 — 이름 · 타입 · 미저장 표시 · 말로 시키는 줄 · 저장을 **한 줄에** 담는다.
194
+
195
+ 이름은 페이지 제목에도 나온다(셸이 context.title 로 그린다). 그래도 여기 입력칸을 두는
196
+ 이유는 **고치는 자리**가 필요해서다 — 제목은 읽는 것이고 이 칸은 바꾸는 것이다. 두 줄로
197
+ 벌리는 대신 한 줄에 모아 캔버스에 높이를 넘긴다.
198
+ */
192
199
  div[header] {
193
200
  display: flex;
194
201
  align-items: center;
@@ -196,9 +203,13 @@ export class FigureModellerPage extends FigureModellerPageBase {
196
203
  padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
197
204
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
198
205
  }
206
+ div[header] figure-ask {
207
+ flex: 1;
208
+ min-width: 0;
209
+ }
199
210
  input[name] {
200
211
  flex: none;
201
- width: 220px;
212
+ width: 180px;
202
213
  padding: 5px 9px;
203
214
  font: var(--input-field-font, inherit);
204
215
  font-size: 0.82rem;
@@ -522,7 +533,18 @@ export class FigureModellerPage extends FigureModellerPageBase {
522
533
 
523
534
  get context() {
524
535
  return {
525
- title: this.figure?.name ?? i18next.t('title.new-figure')
536
+ title: this.figure?.name ?? i18next.t('title.new-figure'),
537
+ /*
538
+ **제목 줄은 끈다.** 이름은 그대로 둔다.
539
+
540
+ 이 화면의 주인공은 캔버스다. 이름은 속성 패널에서 보고 고치고, 브라우저 탭에도
541
+ 「operato figure · 작업자」로 나온다. 그 위에 제목 줄을 한 겹 더 얹으면 같은 말이 세 번이고,
542
+ 그만큼 캔버스가 낮아진다.
543
+
544
+ `title` 을 비우는 것으로 끄지 않는 이유는 그 값을 MDI 탭도 읽기 때문이다 — 비우면 탭 이름이
545
+ 함께 사라진다. 이름은 하나이고 그리는 자리마다 켜고 끈다(`ox-page-title-bar`).
546
+ */
547
+ heading: false
526
548
  }
527
549
  }
528
550
 
@@ -531,30 +553,31 @@ export class FigureModellerPage extends FigureModellerPageBase {
531
553
 
532
554
  return html`
533
555
  <div header>
534
- <input
535
- name
536
- .value=${this.figure?.name ?? ''}
537
- placeholder=${i18next.t('text.figure-display-name')}
538
- @change=${(e: Event) => this.rename((e.target as HTMLInputElement).value)}
539
- />
540
- <code type title=${i18next.t('text.type-name-cannot-change')}>${this.board?.figureType ?? ''}</code>
556
+ <!--
557
+ 이름과 타입은 **속성 패널**에 있다(figure-inspector 의 도형 절). 여기 있던 이름 칸은
558
+ 하네스 시절의 자리였다 — 앱에서는 셸이 페이지 제목으로 같은 이름을 그리므로 글자가 두 번
559
+ 나오고, 캔버스가 주인공인 화면에서 그만큼 높이를 잃었다.
560
+ -->
541
561
  ${this.dirty ? html`<span dirty>${i18next.t('text.unsaved-changes')}</span>` : ''}
542
- <div spacer></div>
562
+ <!--
563
+ 말로 시키는 줄이 머리줄의 남는 폭을 쓴다. 따로 두면 화면 위가 두 줄이 되고, 그 위 줄은
564
+ 이름을 페이지 제목과 겹쳐 적는다.
565
+ -->
566
+ <figure-ask
567
+ .source=${source}
568
+ .type=${source?.type ?? ''}
569
+ @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
570
+ ></figure-ask>
543
571
  <button save ?disabled=${!this.canSave} @click=${() => this.save()}>
544
572
  ${this.saving ? i18next.t('text.saving') : i18next.t('button.save')}
545
573
  </button>
546
574
  </div>
547
575
 
548
- <figure-ask
549
- .source=${source}
550
- .type=${source?.type ?? ''}
551
- @proposed=${(e: CustomEvent) => this.receive(e.detail.proposal)}
552
- ></figure-ask>
553
-
554
576
  <div
555
577
  edits
556
578
  ?collapse-left=${this.collapsed.left}
557
579
  ?collapse-right=${this.collapsed.right}
580
+ @figure-rename=${(e: CustomEvent) => this.rename(e.detail.name)}
558
581
  @add-part=${(e: CustomEvent) => this.addPart(e.detail.primitive)}
559
582
  @select-part=${(e: CustomEvent) => (this.selected = e.detail.index)}
560
583
  @remove-part=${(e: CustomEvent) => this.removePart(e.detail.index)}
@@ -614,6 +637,7 @@ export class FigureModellerPage extends FigureModellerPageBase {
614
637
  </div>
615
638
 
616
639
  <figure-side
640
+ .figureName=${this.figure?.name ?? ''}
617
641
  .board=${this.board}
618
642
  .parts=${this.parts}
619
643
  .source=${source}
@@ -862,6 +886,11 @@ export class FigureModellerPage extends FigureModellerPageBase {
862
886
 
863
887
  this.figure = figure
864
888
 
889
+ /* 그림이 없으면 이 자리에서 채운다(아래 `fillThumbnail`). 기다리지 않는다 — 화면이 먼저다. */
890
+ if (!figure.thumbnail && figure.id) {
891
+ void this.fillThumbnail(figure.id)
892
+ }
893
+
865
894
  // 서버가 준 것을 그대로 쓴다. 읽지 못하면 오류보고없이 빈 상태로 바꾸지 않는다.
866
895
  // 그러면 사용자가 저장을 선택해 원본을 덮어쓴다.
867
896
  const model = figure.source ? fromFigureSource(JSON.parse(figure.source) as FigureSource) : undefined
@@ -1025,6 +1054,39 @@ export class FigureModellerPage extends FigureModellerPageBase {
1025
1054
  if (at >= 0) this.selected = at
1026
1055
  }
1027
1056
 
1057
+ /**
1058
+ * 그림이 없는 도형을 **열었을 때 한 번** 찍어 채운다.
1059
+ *
1060
+ * ## 왜 여기인가
1061
+ *
1062
+ * 보드는 저장할 때 서버가 찍는다. 그런데 백엔드에 GPU 가 없는 배포가 많아 헤드리스로 3D 를 찍는
1063
+ * 것이 자주 실패한다. 도형을 그리는 GPU 는 이미 보는 사람 손에 있고, 씬은 지금 여기 서 있다.
1064
+ *
1065
+ * 저작면을 거치지 않고 생긴 것들이 그림이 없다 — 씨앗으로 넣은 표본, AI 가 낸 후보, 반입한 자산.
1066
+ * 그것들을 한 번 열어 보면 그때 채워진다.
1067
+ *
1068
+ * ## 저장이 아니다
1069
+ *
1070
+ * 그림만 보낸다. 정본을 건드리지 않으므로 미저장 표시도 켜지 않는다 — 열어 봤다는 것이 고쳤다는
1071
+ * 뜻이 되면 저작자가 저장할지 말지를 매번 고민한다.
1072
+ */
1073
+ private async fillThumbnail(id: string): Promise<void> {
1074
+ await this.updateComplete
1075
+
1076
+ const thumbnail = await captureThumbnail(this.board, this.parts)
1077
+ if (!thumbnail || this.figure?.id !== id) {
1078
+ return
1079
+ }
1080
+
1081
+ try {
1082
+ await updateFigure(id, { thumbnail })
1083
+ this.figure = { ...this.figure, thumbnail }
1084
+ } catch (e) {
1085
+ /* 볼 권한만 있는 사람일 수 있다. 화면은 그대로 두고 조용히 넘어간다. */
1086
+ console.warn(`[thumbnail] ${id} 를 채우지 못했습니다 — ${(e as Error).message}`)
1087
+ }
1088
+ }
1089
+
1028
1090
  private async save() {
1029
1091
  if (!this.canSave || !this.folded || !this.figure) {
1030
1092
  return
@@ -1033,11 +1095,21 @@ export class FigureModellerPage extends FigureModellerPageBase {
1033
1095
  this.saving = true
1034
1096
  try {
1035
1097
  const sourceJson = JSON.stringify(this.folded)
1098
+ /*
1099
+ 보이는 것을 그대로 찍어 함께 보낸다.
1100
+
1101
+ 카드가 그림 없이 서 있으면 카탈로그에서 무엇인지 못 고른다. 찍는 자리를 저장에 두는 이유는
1102
+ **그 순간이 정본이 바뀌는 순간**이라서다 — 따로 찍는 절차를 두면 그림과 정본이 갈린다.
1103
+
1104
+ 못 찍으면 보내지 않는다(`undefined`). 빈 그림을 넣어 「그림이 있다」고 말하지 않는다.
1105
+ */
1106
+ const thumbnail = await captureThumbnail(this.board, this.parts)
1036
1107
 
1037
1108
  if (this.figure.id) {
1038
1109
  const result = await updateFigure(this.figure.id, {
1039
1110
  name: this.figure.name,
1040
- source: sourceJson
1111
+ source: sourceJson,
1112
+ thumbnail
1041
1113
  })
1042
1114
  this.figure = result.figure
1043
1115
  this.violations = result.violations
@@ -1052,7 +1124,8 @@ export class FigureModellerPage extends FigureModellerPageBase {
1052
1124
  const result = await createFigure({
1053
1125
  type: this.folded.type,
1054
1126
  name: this.figure.name,
1055
- source: sourceJson
1127
+ source: sourceJson,
1128
+ thumbnail
1056
1129
  })
1057
1130
  this.figure = result.figure
1058
1131
  this.violations = result.violations
@@ -32,10 +32,19 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
32
32
  this.failure = '';
33
33
  }
34
34
  static { this.styles = css `
35
+ /*
36
+ 이 부품은 **자기 줄을 갖지 않는다.**
37
+
38
+ 전에는 테두리와 여백으로 스스로 한 줄이 되었다. 저작면의 머리줄 바로 아래에 놓이니
39
+ 화면 위쪽이 두 줄이 되었고, 위 줄은 이름을 페이지 제목과 겹쳐 적고 있었다. 캔버스가
40
+ 주인공인 화면에서 그 두 줄은 비싸다.
41
+
42
+ 이제 머리줄 안에 들어가 그 줄의 남는 폭을 쓴다. 테두리·여백은 담는 줄이 갖는다.
43
+ */
35
44
  :host {
36
- display: block;
37
- background-color: var(--md-sys-color-surface-container-lowest);
38
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
45
+ display: flex;
46
+ flex: 1;
47
+ min-width: 0;
39
48
  font: var(--label-font, inherit);
40
49
  /*
41
50
  이 줄의 글자 크기를 여기서 정한다.
@@ -50,9 +59,10 @@ let FigureAsk = class FigureAsk extends localize(i18next)(LitElement) {
50
59
 
51
60
  form {
52
61
  display: flex;
62
+ flex: 1;
63
+ min-width: 0;
53
64
  align-items: center;
54
65
  gap: var(--spacing-medium, 8px);
55
- padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
56
66
  }
57
67
 
58
68
  md-icon[lead] {