@things-factory/figure-ui 10.1.37 → 10.1.39
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/index.ts +53 -0
- package/client/graphql/v3-store.ts +8 -2
- package/client/modeller/figure-history-panel.ts +33 -87
- package/client/modeller/figure-v3-parts.ts +365 -0
- package/client/modeller/figure-v3-verdict.ts +247 -0
- package/client/modeller/gate-findings.ts +105 -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 +272 -12
- package/client/route.ts +8 -0
- package/dist-client/graphql/index.d.ts +8 -0
- package/dist-client/graphql/index.js +51 -0
- package/dist-client/graphql/index.js.map +1 -1
- 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 +21 -0
- package/dist-client/modeller/figure-history-panel.js +34 -87
- 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/figure-v3-verdict.d.ts +50 -0
- package/dist-client/modeller/figure-v3-verdict.js +268 -0
- package/dist-client/modeller/figure-v3-verdict.js.map +1 -0
- package/dist-client/modeller/gate-findings.d.ts +12 -0
- package/dist-client/modeller/gate-findings.js +102 -0
- package/dist-client/modeller/gate-findings.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 +52 -1
- package/dist-client/pages/figure-v3-modeller-page.js +294 -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/test/v3-verdict.test.ts +82 -0
- package/things-factory.config.js +6 -1
- package/translations/en.json +79 -1
- package/translations/ja.json +79 -1
- package/translations/ko.json +79 -1
- package/translations/ms.json +79 -1
- package/translations/zh.json +79 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
|
|
3
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
4
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
|
+
|
|
6
|
+
import { i18next, localize } from '@operato/i18n'
|
|
7
|
+
import { parseV3Asset, v3CostOf } from '@hatiolab/figure-model'
|
|
8
|
+
|
|
9
|
+
import { inspectFigureDraft } from '../graphql/index.js'
|
|
10
|
+
import type { FigureInspection } from '../types.js'
|
|
11
|
+
import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
|
|
12
|
+
import { capacityFrom, MEASURED } from './scene-budget.js'
|
|
13
|
+
import type { FigureCapacity } from './scene-budget.js'
|
|
14
|
+
|
|
15
|
+
/** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
|
|
16
|
+
export const VERDICT_SETTLE_MS = 400
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 판정 — the V3 modeller's verdict while editing (parity row ①).
|
|
20
|
+
*
|
|
21
|
+
* The V2 modeller shows, as the author edits, how many of the figure fit on one screen and what would stop it
|
|
22
|
+
* being published. The V3 page showed neither: the author learned what blocked a release only after saving, in the
|
|
23
|
+
* release panel. This tab answers both from the source being edited.
|
|
24
|
+
*
|
|
25
|
+
* The count is computed here, from `v3CostOf` and the same `capacityFrom` the catalogue cards use, so the tab and
|
|
26
|
+
* the card cannot disagree. The findings are not: they come from the server's release door (`inspectFigureDraft`),
|
|
27
|
+
* the same door and the same sentences as the release panel. A second judgement on the screen would say something
|
|
28
|
+
* different the first time either changed.
|
|
29
|
+
*
|
|
30
|
+
* No grade. V2's grade measures V2 reuse; nothing measures it for V3, so the tab shows no letter rather than one
|
|
31
|
+
* nothing computed.
|
|
32
|
+
*/
|
|
33
|
+
@customElement('figure-v3-verdict')
|
|
34
|
+
export class FigureV3Verdict extends localize(i18next)(LitElement) {
|
|
35
|
+
static styles = [
|
|
36
|
+
gateFindingStyles,
|
|
37
|
+
css`
|
|
38
|
+
:host {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 14px;
|
|
42
|
+
padding: 12px;
|
|
43
|
+
overflow: auto;
|
|
44
|
+
font-size: 0.76rem;
|
|
45
|
+
color: var(--md-sys-color-on-surface);
|
|
46
|
+
}
|
|
47
|
+
section > h3 {
|
|
48
|
+
margin: 0 0 6px 0;
|
|
49
|
+
font-size: var(--ui-t-micro, 11px);
|
|
50
|
+
font-weight: 700;
|
|
51
|
+
letter-spacing: 0.04em;
|
|
52
|
+
text-transform: uppercase;
|
|
53
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
54
|
+
}
|
|
55
|
+
p {
|
|
56
|
+
margin: 4px 0 0;
|
|
57
|
+
}
|
|
58
|
+
p[lead],
|
|
59
|
+
p[basis],
|
|
60
|
+
p[quiet] {
|
|
61
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
62
|
+
}
|
|
63
|
+
div[count] {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: baseline;
|
|
66
|
+
gap: 6px;
|
|
67
|
+
}
|
|
68
|
+
span[capacity] {
|
|
69
|
+
font-size: 2.1rem;
|
|
70
|
+
font-weight: 800;
|
|
71
|
+
line-height: 1;
|
|
72
|
+
letter-spacing: -0.01em;
|
|
73
|
+
color: var(--md-sys-color-primary);
|
|
74
|
+
}
|
|
75
|
+
span[unit] {
|
|
76
|
+
font-size: 0.9rem;
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
color: var(--md-sys-color-primary);
|
|
79
|
+
}
|
|
80
|
+
p[clear] {
|
|
81
|
+
color: var(--md-sys-color-primary);
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
}
|
|
84
|
+
p[failed] {
|
|
85
|
+
color: var(--md-sys-color-error);
|
|
86
|
+
}
|
|
87
|
+
button[fit] {
|
|
88
|
+
align-self: flex-start;
|
|
89
|
+
margin-top: 4px;
|
|
90
|
+
padding: 4px 10px;
|
|
91
|
+
font: inherit;
|
|
92
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
93
|
+
background: var(--md-sys-color-secondary-container);
|
|
94
|
+
border: none;
|
|
95
|
+
border-radius: 999em;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
}
|
|
98
|
+
`
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
/** The source being edited, serialised. Every change of it is a new question. */
|
|
102
|
+
@property({ type: String }) source?: string
|
|
103
|
+
/** The saved row, if any — the server reads its type and last release from it. */
|
|
104
|
+
@property({ type: String }) figureId?: string
|
|
105
|
+
/** Changing the type changes the verdict (a temporary type is refused), so it is a reason to ask again. */
|
|
106
|
+
@property({ type: String }) figureType?: string
|
|
107
|
+
|
|
108
|
+
@state() private capacity?: FigureCapacity
|
|
109
|
+
/** Bodies in the asset, drawn or hidden. With none there is nothing to fit the space to, so the button is not offered. */
|
|
110
|
+
@state() private bodies = 0
|
|
111
|
+
@state() private inspection?: FigureInspection
|
|
112
|
+
@state() private failure = ''
|
|
113
|
+
@state() private asking = false
|
|
114
|
+
|
|
115
|
+
private timer?: ReturnType<typeof setTimeout>
|
|
116
|
+
/** Answers can come back out of order; only the newest question's answer is shown. */
|
|
117
|
+
private asked = 0
|
|
118
|
+
|
|
119
|
+
disconnectedCallback() {
|
|
120
|
+
clearTimeout(this.timer)
|
|
121
|
+
super.disconnectedCallback()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
updated(changes: Map<string, unknown>) {
|
|
125
|
+
if (changes.has('source')) this.measure()
|
|
126
|
+
if (changes.has('source') || changes.has('figureId') || changes.has('figureType')) this.schedule()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private measure() {
|
|
130
|
+
let capacity: FigureCapacity | undefined
|
|
131
|
+
let bodies = 0
|
|
132
|
+
try {
|
|
133
|
+
const cost = this.source ? v3CostOf(parseV3Asset(this.source)) : undefined
|
|
134
|
+
capacity = cost ? capacityFrom(cost.groups, cost.triangles) : undefined
|
|
135
|
+
bodies = cost ? cost.groups + cost.hidden : 0
|
|
136
|
+
} catch {
|
|
137
|
+
/* A source the format refuses has no count; the server's finding below says why. */
|
|
138
|
+
capacity = undefined
|
|
139
|
+
}
|
|
140
|
+
this.capacity = capacity
|
|
141
|
+
this.bodies = bodies
|
|
142
|
+
this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: capacity?.instances } }))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private schedule() {
|
|
146
|
+
clearTimeout(this.timer)
|
|
147
|
+
if (!this.source) {
|
|
148
|
+
this.inspection = undefined
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
this.timer = setTimeout(() => this.ask(), VERDICT_SETTLE_MS)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async ask() {
|
|
155
|
+
const question = ++this.asked
|
|
156
|
+
const source = this.source!
|
|
157
|
+
this.asking = true
|
|
158
|
+
try {
|
|
159
|
+
const inspection = await inspectFigureDraft(source, this.figureId)
|
|
160
|
+
if (question !== this.asked) return
|
|
161
|
+
this.inspection = inspection
|
|
162
|
+
this.failure = ''
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (question !== this.asked) return
|
|
165
|
+
/* The server's reason as it gave it — the screen does not make one up. The last answer is dropped with it. */
|
|
166
|
+
this.inspection = undefined
|
|
167
|
+
this.failure = error instanceof Error ? error.message : String(error)
|
|
168
|
+
} finally {
|
|
169
|
+
if (question === this.asked) this.asking = false
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
render() {
|
|
174
|
+
return html`${this.renderCount()} ${this.renderGate()}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private renderCount() {
|
|
178
|
+
const capacity = this.capacity
|
|
179
|
+
return html`
|
|
180
|
+
<section>
|
|
181
|
+
<h3>${i18next.t('figure.label.maturity')}</h3>
|
|
182
|
+
<p lead>${i18next.t('figure.text.how-many-fit')}</p>
|
|
183
|
+
${capacity
|
|
184
|
+
? html`
|
|
185
|
+
<div count>
|
|
186
|
+
<span capacity>${capacity.instances.toLocaleString()}</span>
|
|
187
|
+
<span unit>${i18next.t('figure.text.instances-unit')}</span>
|
|
188
|
+
</div>
|
|
189
|
+
<p basis>
|
|
190
|
+
${i18next.t('figure.text.bound-by-' + capacity.bound, {
|
|
191
|
+
per: capacity.per.toLocaleString(),
|
|
192
|
+
budget: capacity.budget.toLocaleString()
|
|
193
|
+
})}
|
|
194
|
+
<em>
|
|
195
|
+
${capacity.bound === 'groups'
|
|
196
|
+
? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
|
|
197
|
+
: i18next.t('figure.text.budget-not-measured-yet')}
|
|
198
|
+
</em>
|
|
199
|
+
</p>
|
|
200
|
+
`
|
|
201
|
+
: html`<p quiet>${i18next.t('figure.text.maturity-blocked-no-parts')}</p>`}
|
|
202
|
+
</section>
|
|
203
|
+
`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private renderGate() {
|
|
207
|
+
const findings = this.inspection?.findings ?? []
|
|
208
|
+
const blocking = findings.filter(finding => finding.blocking)
|
|
209
|
+
const advisory = findings.filter(finding => !finding.blocking)
|
|
210
|
+
return html`
|
|
211
|
+
<section>
|
|
212
|
+
<h3>${i18next.t('figure.label.release-verdict')}</h3>
|
|
213
|
+
${this.failure
|
|
214
|
+
? html`<p failed role="alert">${i18next.t('figure.text.verdict-not-received', { reason: this.failure })}</p>`
|
|
215
|
+
: !this.inspection
|
|
216
|
+
? html`<p quiet>${i18next.t(this.asking ? 'figure.text.verdict-asking' : 'figure.text.verdict-waiting')}</p>`
|
|
217
|
+
: blocking.length
|
|
218
|
+
? html`<p>${i18next.t('figure.text.cannot-release-now')}</p>`
|
|
219
|
+
: html`<p clear>${i18next.t('figure.text.nothing-blocks-release')}</p>`}
|
|
220
|
+
${blocking.length
|
|
221
|
+
? html`<ul gate>
|
|
222
|
+
<li head>${i18next.t('figure.text.must-fix-to-release')}</li>
|
|
223
|
+
${blocking.map(finding => renderGateFinding(finding, true, this.actionsFor(finding.code)))}
|
|
224
|
+
</ul>`
|
|
225
|
+
: nothing}
|
|
226
|
+
${advisory.length
|
|
227
|
+
? html`<ul gate>
|
|
228
|
+
<li head quiet>${i18next.t('figure.text.does-not-block')}</li>
|
|
229
|
+
${advisory.map(finding => renderGateFinding(finding, false))}
|
|
230
|
+
</ul>`
|
|
231
|
+
: nothing}
|
|
232
|
+
</section>
|
|
233
|
+
`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The same way out the release panel offers for the same two findings — declare the space from the parts. */
|
|
237
|
+
private actionsFor(code: string) {
|
|
238
|
+
if (code !== 'part-outside-occupancy' && code !== 'occupancy-undeclared') return nothing
|
|
239
|
+
/* A new figure with no parts is refused as undeclared too, but fitting needs parts to measure (seen 2026-09-24). */
|
|
240
|
+
if (!this.bodies) return nothing
|
|
241
|
+
return html`<button fit @click=${this.fit}>${i18next.t('figure.button.fit-base-to-parts')}</button>`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fit = () => {
|
|
245
|
+
this.dispatchEvent(new CustomEvent('fit-base-to-parts', { bubbles: true, composed: true }))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { css, html, nothing, type TemplateResult } from 'lit'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
3
|
+
|
|
4
|
+
import type { FigureGateFinding } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One release-gate finding as a line on screen — the measured fact, where, why it matters, and what to change.
|
|
8
|
+
*
|
|
9
|
+
* Two places draw these: the release panel (`figure-history-panel`) and the V3 modeller's verdict while editing
|
|
10
|
+
* (`figure-v3-verdict`). They show the same findings from the same server door, so they draw them one way; a copy
|
|
11
|
+
* in each would drift the first time one of them is touched. The caller passes its own action buttons, because
|
|
12
|
+
* what can be done about a finding depends on the screen it is shown on.
|
|
13
|
+
*/
|
|
14
|
+
export function renderGateFinding(finding: FigureGateFinding, blocking: boolean, actions: unknown = nothing): TemplateResult {
|
|
15
|
+
return html`
|
|
16
|
+
<li finding ?blocking=${blocking}>
|
|
17
|
+
<span msg>${finding.message}</span>
|
|
18
|
+
${finding.at
|
|
19
|
+
? html`
|
|
20
|
+
<details affected>
|
|
21
|
+
<summary>${i18next.t('figure.text.show-affected-parts')}</summary>
|
|
22
|
+
<code where>${finding.at}</code>
|
|
23
|
+
</details>
|
|
24
|
+
`
|
|
25
|
+
: nothing}
|
|
26
|
+
${finding.why ? html`<span why-line>${finding.why}</span>` : nothing}
|
|
27
|
+
${finding.how ? html`<span how-line><md-icon>build</md-icon>${finding.how}</span>` : nothing}
|
|
28
|
+
${actions}
|
|
29
|
+
</li>
|
|
30
|
+
`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const gateFindingStyles = css`
|
|
34
|
+
ul[gate] {
|
|
35
|
+
margin: var(--spacing-small, 6px) 0 0;
|
|
36
|
+
padding: 0;
|
|
37
|
+
list-style: none;
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
gap: 3px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
li[head] {
|
|
44
|
+
font: var(--label-font, inherit);
|
|
45
|
+
font-size: 0.72rem;
|
|
46
|
+
color: var(--md-sys-color-error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
li[head][quiet] {
|
|
50
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
li[finding] {
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
align-items: flex-start;
|
|
57
|
+
gap: 3px;
|
|
58
|
+
padding: 3px 6px;
|
|
59
|
+
font-size: 0.72rem;
|
|
60
|
+
line-height: 1.5;
|
|
61
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
62
|
+
background-color: var(--md-sys-color-surface-container);
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
li[finding][blocking] {
|
|
67
|
+
color: var(--md-sys-color-on-error-container, var(--md-sys-color-on-surface));
|
|
68
|
+
background-color: var(--md-sys-color-error-container);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
li[finding] code[where] {
|
|
72
|
+
font-family: var(--mono-font, monospace);
|
|
73
|
+
font-size: 0.68rem;
|
|
74
|
+
opacity: 0.85;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
li[finding] details[affected] {
|
|
78
|
+
width: 100%;
|
|
79
|
+
color: inherit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
li[finding] details[affected] summary {
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-weight: 600;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* 왜 나쁜지는 곁글로 — 읽어야 하지만 잰 것보다 먼저 눈에 들 필요는 없다 */
|
|
88
|
+
li[finding] span[why-line] {
|
|
89
|
+
opacity: 0.8;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* 무엇을 바꾸면 되나 — 이 줄이 실제로 사람이 할 일이다. 연장 표를 달아 눈에 걸리게 한다 */
|
|
93
|
+
li[finding] span[how-line] {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: flex-start;
|
|
96
|
+
gap: 4px;
|
|
97
|
+
padding-top: 2px;
|
|
98
|
+
font-weight: 600;
|
|
99
|
+
}
|
|
100
|
+
li[finding] span[how-line] md-icon {
|
|
101
|
+
--md-icon-size: 13px;
|
|
102
|
+
flex: none;
|
|
103
|
+
margin-top: 1px;
|
|
104
|
+
}
|
|
105
|
+
`
|
|
@@ -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 {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import './v3-canvas.js'
|
|
2
|
+
|
|
3
|
+
import type { V3RenderPart } from './v3-renderer.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 카드에 쓸 그림을 찍는다 — **숨은 자리에 V3 캔버스를 하나 더 세워서.**
|
|
7
|
+
*
|
|
8
|
+
* V2 의 `figure-thumbnail.ts` 와 같은 규율이고, 까닭도 같다.
|
|
9
|
+
*
|
|
10
|
+
* ## 왜 보이는 캔버스를 찍지 않나
|
|
11
|
+
*
|
|
12
|
+
* 저작면의 캔버스는 저작자가 돌려 둔 각도와 확대로 서 있다. 그것을 찍으면 카드마다 프레임이
|
|
13
|
+
* 달라지고, 방금 확대해 보던 나사 하나가 그 자산의 얼굴이 된다. 목록은 견주는 자리이므로 그림은
|
|
14
|
+
* **정해진 크기와 정해진 카메라**로 찍는다. `figure-v3-canvas` 의 `thumbnail()` 은 보이는 캔버스를
|
|
15
|
+
* 그대로 뽑으므로 카드용이 아니다 — 여기서는 새로 세운 캔버스가 스스로 `fit()` 한 자리에서 찍는다.
|
|
16
|
+
*
|
|
17
|
+
* ## 왜 서버가 아니라 브라우저인가
|
|
18
|
+
*
|
|
19
|
+
* 백엔드에 GPU 가 없는 배포가 많아 헤드리스로 3D 를 찍는 것이 자주 실패한다. 도형을 그릴 GPU 는
|
|
20
|
+
* 이미 보는 사람 손에 있다.
|
|
21
|
+
*
|
|
22
|
+
* ## 못 찍어도 저장은 간다
|
|
23
|
+
*
|
|
24
|
+
* 탭이 뒤로 가면 브라우저가 `requestAnimationFrame` 을 멈춘다. 그 위에 저장이 얹혀 있으면 저장이
|
|
25
|
+
* 통째로 멈춘다 — 단추는 잠긴 채로, 아무 말도 없이. 그래서 시계와 경주시키고, 늦으면 그림 없이
|
|
26
|
+
* 간다. 정본이 그림보다 중하고, 그림은 다음에 저작면을 열 때 다시 채운다.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** 카드는 4:3 이다. V2 와 같은 크기 — 목록에 두 판이 섞여 있어도 카드가 고르다. */
|
|
30
|
+
const WIDTH = 480
|
|
31
|
+
const HEIGHT = 360
|
|
32
|
+
|
|
33
|
+
/** 넘으면 그림 없이 저장한다. 빠르기가 아니라 저장이 멈추지 않는 것이 목적이다. */
|
|
34
|
+
const PATIENCE = 5000
|
|
35
|
+
|
|
36
|
+
interface Shooter extends HTMLElement {
|
|
37
|
+
parts?: V3RenderPart[]
|
|
38
|
+
palette?: Record<string, string>
|
|
39
|
+
context?: string
|
|
40
|
+
thumbnail?: () => string | undefined
|
|
41
|
+
fit?: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
45
|
+
|
|
46
|
+
/** 씬이 서고 카메라가 자리를 잡을 틈. rAF 가 멈춘 탭에서도 풀리도록 시계와 경주시킨다. */
|
|
47
|
+
function settle(): Promise<void> {
|
|
48
|
+
const frames = new Promise<void>(resolve =>
|
|
49
|
+
requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(resolve, 150)))
|
|
50
|
+
)
|
|
51
|
+
return Promise.race([frames, delay(600)])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function within<T>(work: Promise<T>, ms: number): Promise<T | undefined> {
|
|
55
|
+
return Promise.race([work, delay(ms).then(() => undefined)])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function captureV3Thumbnail(
|
|
59
|
+
parts: V3RenderPart[],
|
|
60
|
+
palette: Record<string, string>
|
|
61
|
+
): Promise<string | undefined> {
|
|
62
|
+
if (!parts.length) return undefined
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
화면 밖에 둔다. `display: none` 이면 캔버스가 크기를 못 얻어 아무것도 그리지 못한다.
|
|
66
|
+
WebGL context 는 브라우저가 8~16개에서 끊으므로 찍은 뒤 반드시 버린다(아래 finally).
|
|
67
|
+
*/
|
|
68
|
+
const host = document.createElement('div')
|
|
69
|
+
host.setAttribute('figure-v3-thumbnail-stage', '')
|
|
70
|
+
host.style.cssText = `position: fixed; left: -10000px; top: 0; width: ${WIDTH}px; height: ${HEIGHT}px; pointer-events: none;`
|
|
71
|
+
document.body.appendChild(host)
|
|
72
|
+
|
|
73
|
+
const shooter = document.createElement('figure-v3-canvas') as Shooter
|
|
74
|
+
shooter.parts = parts
|
|
75
|
+
shooter.palette = palette
|
|
76
|
+
shooter.context = 'thumbnail'
|
|
77
|
+
shooter.style.cssText = 'display: block; width: 100%; height: 100%;'
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
host.appendChild(shooter)
|
|
81
|
+
const shot = await within(
|
|
82
|
+
(async () => {
|
|
83
|
+
await settle()
|
|
84
|
+
shooter.fit?.()
|
|
85
|
+
return shooter.thumbnail?.()
|
|
86
|
+
})(),
|
|
87
|
+
PATIENCE
|
|
88
|
+
)
|
|
89
|
+
if (!shot) console.warn(`[thumbnail] ${PATIENCE}ms 안에 못 찍었습니다 — 그림 없이 저장합니다`)
|
|
90
|
+
return shot
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn(`[thumbnail] 세우지 못했습니다 — ${(e as Error).message}`)
|
|
93
|
+
return undefined
|
|
94
|
+
} finally {
|
|
95
|
+
host.remove()
|
|
96
|
+
}
|
|
97
|
+
}
|