@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.
- package/client/modeller/figure-ask.ts +14 -4
- package/client/modeller/figure-canvas.ts +53 -0
- package/client/modeller/figure-inspector.ts +78 -2
- package/client/modeller/figure-side.ts +3 -0
- package/client/modeller/figure-thumbnail.ts +78 -0
- package/client/pages/figure-list-page.ts +67 -8
- package/client/pages/figure-modeller-page.ts +92 -19
- package/dist-client/modeller/figure-ask.js +14 -4
- package/dist-client/modeller/figure-ask.js.map +1 -1
- package/dist-client/modeller/figure-canvas.d.ts +11 -0
- package/dist-client/modeller/figure-canvas.js +42 -0
- package/dist-client/modeller/figure-canvas.js.map +1 -1
- package/dist-client/modeller/figure-inspector.d.ts +17 -0
- package/dist-client/modeller/figure-inspector.js +77 -2
- package/dist-client/modeller/figure-inspector.js.map +1 -1
- package/dist-client/modeller/figure-side.d.ts +1 -0
- package/dist-client/modeller/figure-side.js +12 -0
- package/dist-client/modeller/figure-side.js.map +1 -1
- package/dist-client/modeller/figure-thumbnail.d.ts +3 -0
- package/dist-client/modeller/figure-thumbnail.js +59 -0
- package/dist-client/modeller/figure-thumbnail.js.map +1 -0
- package/dist-client/pages/figure-list-page.d.ts +5 -0
- package/dist-client/pages/figure-list-page.js +70 -10
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +18 -0
- package/dist-client/pages/figure-modeller-page.js +89 -19
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +3 -3
- package/translations/en.json +4 -0
- package/translations/ja.json +4 -0
- package/translations/ko.json +4 -0
- package/translations/ms.json +4 -0
- 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:
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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:
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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:
|
|
37
|
-
|
|
38
|
-
|
|
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] {
|