@things-factory/figure-ui 10.1.37 → 10.1.38
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/graphql/v3-store.ts +8 -2
- package/client/modeller/figure-history-panel.ts +22 -2
- package/client/modeller/figure-v3-parts.ts +365 -0
- package/client/modeller/v3-editor.ts +11 -2
- package/client/modeller/v3-occupancy.ts +22 -0
- package/client/modeller/v3-product-session.ts +23 -4
- package/client/modeller/v3-renderer.ts +27 -18
- package/client/modeller/v3-thumbnail.ts +97 -0
- package/client/pages/figure-v3-modeller-page.ts +94 -12
- package/client/route.ts +8 -0
- package/dist-client/graphql/v3-store.js +8 -2
- package/dist-client/graphql/v3-store.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +15 -0
- package/dist-client/modeller/figure-history-panel.js +25 -2
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +41 -0
- package/dist-client/modeller/figure-v3-parts.js +352 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -0
- package/dist-client/modeller/v3-editor.js +11 -2
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-occupancy.d.ts +14 -0
- package/dist-client/modeller/v3-occupancy.js +22 -0
- package/dist-client/modeller/v3-occupancy.js.map +1 -0
- package/dist-client/modeller/v3-product-session.d.ts +44 -2
- package/dist-client/modeller/v3-product-session.js +13 -2
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-renderer.d.ts +9 -0
- package/dist-client/modeller/v3-renderer.js +22 -30
- package/dist-client/modeller/v3-renderer.js.map +1 -1
- package/dist-client/modeller/v3-thumbnail.d.ts +3 -0
- package/dist-client/modeller/v3-thumbnail.js +74 -0
- package/dist-client/modeller/v3-thumbnail.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +26 -1
- package/dist-client/pages/figure-v3-modeller-page.js +104 -12
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +7 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/resolve-ts-siblings.mjs +18 -0
- package/test/v3-product-write.test.ts +14 -2
- package/test/v3-release-panel.test.ts +73 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +73 -1
- package/translations/ja.json +73 -1
- package/translations/ko.json +73 -1
- package/translations/ms.json +73 -1
- package/translations/zh.json +73 -1
|
@@ -13,8 +13,14 @@ export const v3ProductStore: V3ProductStore = {
|
|
|
13
13
|
async save(request) {
|
|
14
14
|
const response = await client.mutate({
|
|
15
15
|
mutation: gql`
|
|
16
|
-
mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String) {
|
|
17
|
-
saveFigureV3(
|
|
16
|
+
mutation SaveFigureV3($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String) {
|
|
17
|
+
saveFigureV3(
|
|
18
|
+
source: $source
|
|
19
|
+
id: $id
|
|
20
|
+
expectedSource: $expectedSource
|
|
21
|
+
creationId: $creationId
|
|
22
|
+
thumbnail: $thumbnail
|
|
23
|
+
) {
|
|
18
24
|
id
|
|
19
25
|
source
|
|
20
26
|
}
|
|
@@ -326,6 +326,12 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
326
326
|
@property({ type: Number }) figureVersion = 0
|
|
327
327
|
/** 저장하지 않은 것이 있나 — 있으면 발행을 막는다. */
|
|
328
328
|
@property({ type: Boolean }) dirty = false
|
|
329
|
+
/**
|
|
330
|
+
* 열린 도형의 형식. 발행 · 판본 · 복원은 figure id 로만 서버를 묻고 서버의 한 문이 두 형식을 다 읽으므로 두 화면이
|
|
331
|
+
* 이 패널을 함께 쓴다. 갈리는 것은 조치 단추 하나다 — 부품을 옮기는 두 조치는 형식마다 다른 편집이라, 받는
|
|
332
|
+
* 화면이 없는 단추를 그리면 눌러도 아무 일이 없다.
|
|
333
|
+
*/
|
|
334
|
+
@property({ type: String }) format: 'v2' | 'v3' = 'v2'
|
|
329
335
|
/** 직전 발행판의 기준 상자. 외형 변화 안내에서 X·Z 복원 선택지에만 쓴다. */
|
|
330
336
|
@property({ attribute: false }) releasedBase?: { x: number; y: number; z: number }
|
|
331
337
|
|
|
@@ -479,12 +485,12 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
479
485
|
` : nothing}
|
|
480
486
|
${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
|
|
481
487
|
${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
|
|
482
|
-
${finding.code
|
|
488
|
+
${this.offersFit(finding.code)
|
|
483
489
|
? html`<button fit-base @click=${() => this.fitBase()}>
|
|
484
490
|
${i18next.t('figure.button.fit-base-to-parts')}
|
|
485
491
|
</button>`
|
|
486
492
|
: nothing}
|
|
487
|
-
${finding.code === 'parts-off-placement-face'
|
|
493
|
+
${finding.code === 'parts-off-placement-face' && this.format === 'v2'
|
|
488
494
|
? html`<button fit-base @click=${() => this.settleOnFloor()}>
|
|
489
495
|
${i18next.t('figure.button.settle-on-floor')}
|
|
490
496
|
</button>`
|
|
@@ -548,6 +554,20 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
|
|
|
548
554
|
* 그래서 화면이 자기 자리에서 같은 동작을 낸다. 누르면 상자만 커지고 부품은 제자리이며, 저장은
|
|
549
555
|
* 여전히 저작자가 따로 한다.
|
|
550
556
|
*/
|
|
557
|
+
/**
|
|
558
|
+
* 「부품에 맞추기」를 내미는 판정. V2 는 기준 상자를 부품에 맞추고, V3 는 공간을 부품에서 다시 선언한다
|
|
559
|
+
* (`declare-occupancy`) — 둘 다 부품은 그대로 두고 선언만 바꾼다. V3 에서 공간을 아직 선언하지 않은 도형도
|
|
560
|
+
* 같은 단추로 선언한다: 새로 만든 V3 도형은 전부 그 판정으로 막히고, 화면에 다른 길이 없다.
|
|
561
|
+
*
|
|
562
|
+
* V3 의 장착면이 떠 있는 것은 여기 없다. 부품 하나를 평면에 붙이면 그 부품에 붙지 않은 것은 따라오지 않아
|
|
563
|
+
* V2 의 「바닥에 앉히기」와 뜻이 다르다. 판정의 「어떻게」가 할 일을 말한다.
|
|
564
|
+
*/
|
|
565
|
+
private offersFit(code: string): boolean {
|
|
566
|
+
return this.format === 'v3'
|
|
567
|
+
? code === 'part-outside-occupancy' || code === 'occupancy-undeclared'
|
|
568
|
+
: code === 'part-outside-base'
|
|
569
|
+
}
|
|
570
|
+
|
|
551
571
|
private fitBase() {
|
|
552
572
|
this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
|
|
553
573
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
3
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
4
|
+
import { i18next, localize } from '@operato/i18n'
|
|
5
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
6
|
+
import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 도형 팔레트와 부품 목록 — V3 자산의 것.
|
|
10
|
+
*
|
|
11
|
+
* `figure-parts`(V2) 와 **같은 자리, 같은 조작, 같은 낱말**이다. 저작자가 V2 에서 익힌 손이 그대로
|
|
12
|
+
* 가야 한다: 팔레트는 목록 위에, 눈 단추는 늘 자리를 차지하고, 조작은 고른 줄에서만 보인다.
|
|
13
|
+
*
|
|
14
|
+
* 다른 것은 읽는 대상뿐이다. V2 는 부품의 `primitive` 를 읽었고 여기서는 V3 평가가 준 `provider` 를
|
|
15
|
+
* 읽는다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
|
|
16
|
+
* 보이고 안 보이는 것)와는 다른 것이다. 그것은 속성 패널의 몫이다.
|
|
17
|
+
*
|
|
18
|
+
* 이 컴포넌트는 정본을 만지지 않는다. 무엇을 하겠다는 것만 알린다 — V2 와 같은 규칙, 같은 사건 이름.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 한 줄 — V3 평가에서 이 패널이 읽는 것만. */
|
|
22
|
+
export interface V3PartRow {
|
|
23
|
+
id: string
|
|
24
|
+
provider: string
|
|
25
|
+
/** 작업대에서 잠깐 가린 것. 저장되지 않는다. */
|
|
26
|
+
hidden?: boolean
|
|
27
|
+
/** 자산이 지금 상태에서 보이지 않는다고 한 것. 저장되는 것이고, 여기서는 표시만 한다. */
|
|
28
|
+
invisible?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 제공자마다의 아이콘. Material Symbols 이름이다. */
|
|
32
|
+
const PROVIDER_ICON: Record<string, string> = {
|
|
33
|
+
box: 'deployed_code',
|
|
34
|
+
cylinder: 'database',
|
|
35
|
+
ellipticCylinder: 'database',
|
|
36
|
+
frustum: 'change_history',
|
|
37
|
+
sphere: 'circle',
|
|
38
|
+
roundedBox: 'crop_square',
|
|
39
|
+
roundedBoxXZ: 'crop_square',
|
|
40
|
+
hollowBox: 'check_box_outline_blank',
|
|
41
|
+
hollowBoxXZ: 'check_box_outline_blank',
|
|
42
|
+
polygon: 'pentagon',
|
|
43
|
+
portal: 'door_front'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 팔레트 — 저작 API 가 만들 수 있는 것만 보인다. */
|
|
47
|
+
const RECIPES: Array<{ kind: V3PrimitiveCreation['kind']; icon: string; label: string }> = [
|
|
48
|
+
{ kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
|
|
49
|
+
{ kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
|
|
50
|
+
{ kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rect' },
|
|
51
|
+
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
@customElement('figure-v3-parts')
|
|
55
|
+
export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
56
|
+
static styles = [
|
|
57
|
+
ScrollbarStyles,
|
|
58
|
+
css`
|
|
59
|
+
:host {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
min-height: 0;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
background-color: var(--md-sys-color-surface-container-lowest);
|
|
65
|
+
border-right: 1px solid var(--md-sys-color-outline-variant);
|
|
66
|
+
color: var(--md-sys-color-on-surface);
|
|
67
|
+
font: var(--label-font, inherit);
|
|
68
|
+
font-size: 0.76rem;
|
|
69
|
+
}
|
|
70
|
+
section {
|
|
71
|
+
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
72
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
73
|
+
}
|
|
74
|
+
section[list] {
|
|
75
|
+
flex: 1;
|
|
76
|
+
min-height: 0;
|
|
77
|
+
overflow-y: auto;
|
|
78
|
+
}
|
|
79
|
+
h3 {
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: baseline;
|
|
82
|
+
justify-content: space-between;
|
|
83
|
+
gap: 6px;
|
|
84
|
+
margin: 0 0 6px 0;
|
|
85
|
+
font-size: var(--ui-t-micro, 11px);
|
|
86
|
+
font-weight: 700;
|
|
87
|
+
letter-spacing: 0.04em;
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
90
|
+
}
|
|
91
|
+
span[room] {
|
|
92
|
+
font-size: 0.7rem;
|
|
93
|
+
font-weight: 400;
|
|
94
|
+
letter-spacing: 0;
|
|
95
|
+
text-transform: none;
|
|
96
|
+
}
|
|
97
|
+
div[palette] {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: repeat(2, 1fr);
|
|
100
|
+
gap: 4px;
|
|
101
|
+
}
|
|
102
|
+
button[shape] {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
align-items: center;
|
|
106
|
+
gap: 2px;
|
|
107
|
+
padding: 7px 2px 5px 2px;
|
|
108
|
+
font: var(--label-font, inherit);
|
|
109
|
+
font-size: 0.66rem;
|
|
110
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
111
|
+
background: none;
|
|
112
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
113
|
+
border-radius: 6px;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
}
|
|
116
|
+
button[shape]:hover:not([disabled]) {
|
|
117
|
+
color: var(--md-sys-color-on-primary-container);
|
|
118
|
+
background-color: var(--md-sys-color-primary-container);
|
|
119
|
+
border-color: transparent;
|
|
120
|
+
}
|
|
121
|
+
button[shape][disabled] {
|
|
122
|
+
opacity: 0.35;
|
|
123
|
+
cursor: default;
|
|
124
|
+
}
|
|
125
|
+
button[shape] md-icon {
|
|
126
|
+
--md-icon-size: 20px;
|
|
127
|
+
}
|
|
128
|
+
ol {
|
|
129
|
+
margin: 0;
|
|
130
|
+
padding: 0;
|
|
131
|
+
list-style: none;
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-direction: column;
|
|
134
|
+
gap: 2px;
|
|
135
|
+
}
|
|
136
|
+
li {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: 6px;
|
|
140
|
+
padding: 4px 6px;
|
|
141
|
+
border-radius: 6px;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
li:hover {
|
|
145
|
+
background-color: var(--md-sys-color-surface-container);
|
|
146
|
+
}
|
|
147
|
+
li[selected] {
|
|
148
|
+
color: var(--md-sys-color-on-primary-container);
|
|
149
|
+
background-color: var(--md-sys-color-primary-container);
|
|
150
|
+
}
|
|
151
|
+
li md-icon {
|
|
152
|
+
--md-icon-size: 16px;
|
|
153
|
+
flex: none;
|
|
154
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
155
|
+
}
|
|
156
|
+
button[eye][all] {
|
|
157
|
+
margin-left: auto;
|
|
158
|
+
opacity: 0.75;
|
|
159
|
+
}
|
|
160
|
+
button[eye][all]:hover {
|
|
161
|
+
opacity: 1;
|
|
162
|
+
}
|
|
163
|
+
button[eye] {
|
|
164
|
+
display: flex;
|
|
165
|
+
flex: none;
|
|
166
|
+
padding: 1px;
|
|
167
|
+
color: inherit;
|
|
168
|
+
background: none;
|
|
169
|
+
border: none;
|
|
170
|
+
border-radius: 4px;
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
opacity: 0;
|
|
173
|
+
}
|
|
174
|
+
li:hover button[eye],
|
|
175
|
+
li[selected] button[eye],
|
|
176
|
+
button[eye][off] {
|
|
177
|
+
opacity: 1;
|
|
178
|
+
}
|
|
179
|
+
button[eye][off] {
|
|
180
|
+
color: var(--md-sys-color-tertiary);
|
|
181
|
+
}
|
|
182
|
+
button[eye] md-icon {
|
|
183
|
+
--md-icon-size: 15px;
|
|
184
|
+
color: inherit;
|
|
185
|
+
}
|
|
186
|
+
li[off] span[label],
|
|
187
|
+
li[off] > md-icon,
|
|
188
|
+
li[invisible] span[label],
|
|
189
|
+
li[invisible] > md-icon {
|
|
190
|
+
opacity: 0.45;
|
|
191
|
+
}
|
|
192
|
+
li[selected] md-icon {
|
|
193
|
+
color: inherit;
|
|
194
|
+
}
|
|
195
|
+
span[label] {
|
|
196
|
+
flex: 1;
|
|
197
|
+
min-width: 0;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
text-overflow: ellipsis;
|
|
200
|
+
white-space: nowrap;
|
|
201
|
+
font-size: 0.78rem;
|
|
202
|
+
}
|
|
203
|
+
span[label] small {
|
|
204
|
+
margin-left: 4px;
|
|
205
|
+
font-size: 0.66rem;
|
|
206
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
207
|
+
}
|
|
208
|
+
div[ops] {
|
|
209
|
+
display: none;
|
|
210
|
+
gap: 1px;
|
|
211
|
+
}
|
|
212
|
+
li[selected] div[ops] {
|
|
213
|
+
display: flex;
|
|
214
|
+
}
|
|
215
|
+
div[ops] button {
|
|
216
|
+
display: flex;
|
|
217
|
+
padding: 2px;
|
|
218
|
+
color: inherit;
|
|
219
|
+
background: none;
|
|
220
|
+
border: none;
|
|
221
|
+
border-radius: 4px;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
div[ops] button:hover:not([disabled]) {
|
|
225
|
+
background-color: var(--md-sys-color-surface-container-lowest);
|
|
226
|
+
}
|
|
227
|
+
div[ops] button[disabled] {
|
|
228
|
+
opacity: 0.3;
|
|
229
|
+
cursor: default;
|
|
230
|
+
}
|
|
231
|
+
div[ops] md-icon {
|
|
232
|
+
--md-icon-size: 15px;
|
|
233
|
+
color: inherit;
|
|
234
|
+
}
|
|
235
|
+
p[quiet] {
|
|
236
|
+
margin: 0;
|
|
237
|
+
font-size: 0.76rem;
|
|
238
|
+
line-height: 1.6;
|
|
239
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
@property({ type: Array }) parts: V3PartRow[] = []
|
|
245
|
+
/** 고른 부품의 id. 자리(index)가 아니다 — V3 부품은 id 로 안정하게 가리킨다. */
|
|
246
|
+
@property({ type: String }) selected = ''
|
|
247
|
+
/** 편집할 수 없을 때(불러오는 중 · 저장 중) 팔레트와 조작을 잠근다. */
|
|
248
|
+
@property({ type: Boolean }) locked = false
|
|
249
|
+
|
|
250
|
+
render() {
|
|
251
|
+
return html`
|
|
252
|
+
<section>
|
|
253
|
+
<h3>${i18next.t('figure.label.shapes')}</h3>
|
|
254
|
+
<div palette>
|
|
255
|
+
${RECIPES.map(
|
|
256
|
+
recipe => html`
|
|
257
|
+
<button
|
|
258
|
+
shape
|
|
259
|
+
?disabled=${this.locked}
|
|
260
|
+
title=${i18next.t(recipe.label)}
|
|
261
|
+
@click=${() => this.ask('add-part', { kind: recipe.kind })}
|
|
262
|
+
>
|
|
263
|
+
<md-icon>${recipe.icon}</md-icon>
|
|
264
|
+
<span>${i18next.t(recipe.label)}</span>
|
|
265
|
+
</button>
|
|
266
|
+
`
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</section>
|
|
270
|
+
|
|
271
|
+
<section list>
|
|
272
|
+
<h3>
|
|
273
|
+
<span>${i18next.t('figure.label.parts')}</span>
|
|
274
|
+
<span room>${this.parts.length}</span>
|
|
275
|
+
${this.renderShowAll()}
|
|
276
|
+
</h3>
|
|
277
|
+
${this.parts.length === 0
|
|
278
|
+
? html`<p quiet>${i18next.t('figure.text.no-parts-yet')}</p>`
|
|
279
|
+
: html`<ol>
|
|
280
|
+
${this.parts.map((part, index) => this.renderRow(part, index))}
|
|
281
|
+
</ol>`}
|
|
282
|
+
</section>
|
|
283
|
+
`
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 한꺼번에 감추고 한꺼번에 되돌린다 — 하나라도 감춰져 있으면 다 되돌리고, 다 보이면 다 감춘다. */
|
|
287
|
+
private renderShowAll() {
|
|
288
|
+
if (this.parts.length === 0) return nothing
|
|
289
|
+
const anyHidden = this.parts.some(part => part.hidden)
|
|
290
|
+
return html`
|
|
291
|
+
<button
|
|
292
|
+
eye
|
|
293
|
+
all
|
|
294
|
+
?off=${!anyHidden}
|
|
295
|
+
title=${i18next.t(anyHidden ? 'figure.text.show-all-parts' : 'figure.text.hide-all-parts')}
|
|
296
|
+
@click=${(e: Event) => {
|
|
297
|
+
e.stopPropagation()
|
|
298
|
+
this.ask('set-all-parts-hidden', { hidden: !anyHidden })
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<md-icon>${anyHidden ? 'visibility' : 'visibility_off'}</md-icon>
|
|
302
|
+
</button>
|
|
303
|
+
`
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private renderRow(part: V3PartRow, index: number) {
|
|
307
|
+
const selected = part.id === this.selected
|
|
308
|
+
const off = !!part.hidden
|
|
309
|
+
return html`
|
|
310
|
+
<li
|
|
311
|
+
?selected=${selected}
|
|
312
|
+
?off=${off}
|
|
313
|
+
?invisible=${!!part.invisible}
|
|
314
|
+
@click=${() => this.ask('select-part', { id: part.id })}
|
|
315
|
+
>
|
|
316
|
+
<button
|
|
317
|
+
eye
|
|
318
|
+
?off=${off}
|
|
319
|
+
title=${i18next.t(off ? 'figure.text.show-this-part' : 'figure.text.hide-this-part')}
|
|
320
|
+
@click=${(e: Event) => {
|
|
321
|
+
e.stopPropagation()
|
|
322
|
+
this.ask('toggle-part-hidden', { id: part.id })
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<md-icon>${off ? 'visibility_off' : 'visibility'}</md-icon>
|
|
326
|
+
</button>
|
|
327
|
+
<md-icon>${PROVIDER_ICON[part.provider] ?? 'deployed_code'}</md-icon>
|
|
328
|
+
<span label title=${part.id}
|
|
329
|
+
>${part.id}${part.invisible ? html`<small>${i18next.t('figure.label.state-hidden')}</small>` : nothing}</span
|
|
330
|
+
>
|
|
331
|
+
${selected ? this.renderOps(part.id, index) : nothing}
|
|
332
|
+
</li>
|
|
333
|
+
`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 고른 줄의 조작. 못 하는 것은 빼지 않고 흐리게 둔다 — 자리가 밀리면 연달아 지우다 엉뚱한 것을 누른다. */
|
|
337
|
+
private renderOps(id: string, index: number) {
|
|
338
|
+
const count = this.parts.length
|
|
339
|
+
const op = (name: string, icon: string, detail: object, enabled = true) => html`
|
|
340
|
+
<button
|
|
341
|
+
op=${name}
|
|
342
|
+
?disabled=${!enabled || this.locked}
|
|
343
|
+
title=${i18next.t('figure.button.' + name)}
|
|
344
|
+
@click=${(e: Event) => {
|
|
345
|
+
e.stopPropagation()
|
|
346
|
+
if (enabled && !this.locked) this.ask(name, detail)
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<md-icon>${icon}</md-icon>
|
|
350
|
+
</button>
|
|
351
|
+
`
|
|
352
|
+
return html`
|
|
353
|
+
<div ops>
|
|
354
|
+
${op('move-part-up', 'arrow_upward', { id, to: index - 1 }, index > 0)}
|
|
355
|
+
${op('move-part-down', 'arrow_downward', { id, to: index + 1 }, index < count - 1)}
|
|
356
|
+
${op('duplicate-part', 'content_copy', { id })} ${op('remove-part', 'delete', { id })}
|
|
357
|
+
</div>
|
|
358
|
+
`
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 정본을 고치지 않는다 — 무엇을 하겠다는 것만 알린다. */
|
|
362
|
+
private ask(what: string, detail: object) {
|
|
363
|
+
this.dispatchEvent(new CustomEvent(what, { detail, bubbles: true, composed: true }))
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -2,6 +2,8 @@ import { LitElement, html, css, type PropertyValues } from 'lit'
|
|
|
2
2
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
3
|
import type { V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
4
4
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
5
|
+
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
6
|
+
import { drawableV3Parts } from './v3-renderer.js'
|
|
5
7
|
import './v3-canvas.js'
|
|
6
8
|
import type { FigureV3Canvas } from './v3-canvas.js'
|
|
7
9
|
|
|
@@ -172,7 +174,14 @@ export class FigureV3Editor extends LitElement {
|
|
|
172
174
|
private async save() {
|
|
173
175
|
if (!this.session) return
|
|
174
176
|
try {
|
|
175
|
-
|
|
177
|
+
/*
|
|
178
|
+
The card's picture is taken before the save starts, from a canvas of its own at the card's size —
|
|
179
|
+
not from the one on screen, which stands wherever the author left it turned. A picture that cannot
|
|
180
|
+
be taken is not an error: the save goes on without it (see `v3-thumbnail.ts`).
|
|
181
|
+
*/
|
|
182
|
+
const view = this.session.read()
|
|
183
|
+
const thumbnail = await captureV3Thumbnail(drawableV3Parts(view.geometry ?? []), view.palette ?? {})
|
|
184
|
+
const promise = this.session.save({ thumbnail })
|
|
176
185
|
this.changed()
|
|
177
186
|
await promise
|
|
178
187
|
this.error = ''
|
|
@@ -274,7 +283,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
274
283
|
</div>
|
|
275
284
|
</aside>
|
|
276
285
|
<figure-v3-canvas
|
|
277
|
-
.parts=${view.geometry}
|
|
286
|
+
.parts=${drawableV3Parts(view.geometry ?? [])}
|
|
278
287
|
.palette=${view.palette ?? {}}
|
|
279
288
|
.context=${view.context ?? ''}
|
|
280
289
|
@part-selected=${(e: CustomEvent<string>) => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { parseV3Asset, proposeV3Occupancy, type V3AuthoringAction } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「부품에 맞추기」의 V3 짝 — 공간을 지금 부품에서 다시 선언하는 한 걸음.
|
|
5
|
+
*
|
|
6
|
+
* figure-model 은 이것을 둘로 나눠 두었다: 제안(`proposeV3Occupancy`)은 무엇이 선언될지를 보여 주고, 확인
|
|
7
|
+
* (`declare-occupancy`)은 그 제안이 읽은 모양 그대로일 때만 받는다. 저작자가 누른 단추가 그 확인이다. 제안과 확인
|
|
8
|
+
* 사이에 모델이 바뀌면 figure-model 이 `OCCUPANCY_STALE` 로 거절하므로, 여기서는 누른 순간의 원본으로 둘을 한
|
|
9
|
+
* 번에 만든다.
|
|
10
|
+
*
|
|
11
|
+
* 범위는 움직임 전체다(`over: 'range'`). 쉬는 자세만 담으면 움직이는 부품이 선언한 공간을 넘어 발행 판정이 다시
|
|
12
|
+
* 막는다. 배치 기준은 이미 선언된 것을 지키고, 처음 선언하는 도형은 바닥에 선다고 둔다 — 새로 만든 도형의
|
|
13
|
+
* 모델러 기본이 바닥이다.
|
|
14
|
+
*/
|
|
15
|
+
export function occupancyFromParts(source: string): V3AuthoringAction {
|
|
16
|
+
const asset = parseV3Asset(source)
|
|
17
|
+
return {
|
|
18
|
+
kind: 'declare-occupancy',
|
|
19
|
+
proposal: proposeV3Occupancy(asset, { over: 'range' }),
|
|
20
|
+
placement: asset.occupancy?.placement ?? 'floor'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
parseV3Asset,
|
|
6
6
|
type V3InputChange,
|
|
7
7
|
type V3PrimitiveCreation,
|
|
8
|
-
type V3PartEdit
|
|
8
|
+
type V3PartEdit,
|
|
9
|
+
type V3AuthoringAction
|
|
9
10
|
} from '@hatiolab/figure-model'
|
|
10
11
|
|
|
11
12
|
export interface V3ProductRecord {
|
|
@@ -16,7 +17,14 @@ export interface V3ProductRecord {
|
|
|
16
17
|
/** Persistence is injected; neither the model engine nor the renderer owns the product ID. */
|
|
17
18
|
export interface V3ProductStore {
|
|
18
19
|
load(id: string): Promise<V3ProductRecord>
|
|
19
|
-
save(request: {
|
|
20
|
+
save(request: {
|
|
21
|
+
id?: string
|
|
22
|
+
source: string
|
|
23
|
+
expectedSource?: string
|
|
24
|
+
creationId?: string
|
|
25
|
+
/** The card's picture. Absent when the browser could not draw one; the source is saved either way. */
|
|
26
|
+
thumbnail?: string
|
|
27
|
+
}): Promise<V3ProductRecord>
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/** Product lifecycle boundary. A late response can never change another editor's asset or ID. */
|
|
@@ -107,6 +115,10 @@ export function createV3ProductSession(store: V3ProductStore) {
|
|
|
107
115
|
edit(edit: V3PartEdit) {
|
|
108
116
|
return workspace.editPart({ ...writable(), edit })
|
|
109
117
|
},
|
|
118
|
+
/** One authoring command — what a person means, not a graph edit — as one step of the same history. */
|
|
119
|
+
command(action: V3AuthoringAction) {
|
|
120
|
+
return workspace.command({ ...writable(), action })
|
|
121
|
+
},
|
|
110
122
|
undo() {
|
|
111
123
|
return workspace.undo(writable())
|
|
112
124
|
},
|
|
@@ -117,13 +129,20 @@ export function createV3ProductSession(store: V3ProductStore) {
|
|
|
117
129
|
const { context } = writable()
|
|
118
130
|
return workspace.export({ context }).text
|
|
119
131
|
},
|
|
120
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Saves the model, and the card's picture when the caller managed to take one.
|
|
134
|
+
*
|
|
135
|
+
* The picture is the caller's because taking it needs a canvas, and this session is the place that knows
|
|
136
|
+
* nothing about drawing. A save with no picture is a save: a tab in the background cannot draw, and the
|
|
137
|
+
* source matters more than the card (see `v3-thumbnail.ts`).
|
|
138
|
+
*/
|
|
139
|
+
async save({ thumbnail }: { thumbnail?: string } = {}) {
|
|
121
140
|
const control = writable(),
|
|
122
141
|
requestEpoch = epoch
|
|
123
142
|
const exported = workspace.export(control)
|
|
124
143
|
saving = true
|
|
125
144
|
try {
|
|
126
|
-
const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId })
|
|
145
|
+
const record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail })
|
|
127
146
|
if (requestEpoch !== epoch) return read()
|
|
128
147
|
if (!record.id || (id !== undefined && record.id !== id)) throw Error('저장 응답의 Figure 대상이 다릅니다.')
|
|
129
148
|
if (serializeV3Asset(parseV3Asset(record.source)) !== exported.text)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as THREE from 'three'
|
|
2
|
+
import { buildV3Geometry } from '@hatiolab/things-scene'
|
|
2
3
|
|
|
3
4
|
/** Evaluated V3 boundary: graph values are resolved before reaching the renderer. */
|
|
4
5
|
export interface V3RenderPart {
|
|
@@ -9,24 +10,32 @@ export interface V3RenderPart {
|
|
|
9
10
|
segments?:number
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The bodies a canvas draws, out of everything the asset evaluated.
|
|
15
|
+
*
|
|
16
|
+
* Two are left out. A scalar body carries no shape or pose — it is a number the graph produced, not something
|
|
17
|
+
* on screen. A body the asset hides is evaluated, placed and gated exactly as a shown one, and excluded from
|
|
18
|
+
* rendering, from shadows and from pointer picking (V3 designer's ruling 2026-09-22); drawing it would make
|
|
19
|
+
* "hidden" mean nothing in the modeller while it means something on a board.
|
|
20
|
+
*/
|
|
21
|
+
export function drawableV3Parts(geometry: readonly unknown[]): V3RenderPart[] {
|
|
22
|
+
return geometry.filter(
|
|
23
|
+
(body): body is V3RenderPart =>
|
|
24
|
+
!!body && typeof body === 'object' && 'shape' in body && 'pose' in body && (body as { visible?: boolean }).visible !== false
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The mesh for one body — things-scene's builder, not a copy of it.
|
|
30
|
+
*
|
|
31
|
+
* The modeller kept its own provider switch and it knew four providers; the converter writes eleven. A body the
|
|
32
|
+
* board drew was refused here with "Unsupported V3 provider: roundedBoxXZ" (2026-09-24, the first canvas check of
|
|
33
|
+
* ADR-0087 step 4). One builder, and the canvas an author looks at shows what a board will show.
|
|
34
|
+
*/
|
|
35
|
+
function geometry(part: V3RenderPart): THREE.BufferGeometry {
|
|
36
|
+
const built = buildV3Geometry(part.shape as never, part.segments ?? 12) as THREE.BufferGeometry | undefined
|
|
37
|
+
if (!built) throw Error('Unsupported V3 provider: ' + part.shape.provider)
|
|
38
|
+
return built
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export function disposeV3Meshes(group:THREE.Group):void {
|