@things-factory/figure-ui 10.1.38 → 10.1.40
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/modeller/figure-history-panel.ts +11 -85
- package/client/modeller/figure-v3-properties.ts +254 -0
- package/client/modeller/figure-v3-verdict.ts +247 -0
- package/client/modeller/gate-findings.ts +105 -0
- package/client/modeller/v3-canvas.ts +27 -3
- package/client/modeller/v3-editor.ts +39 -11
- package/client/modeller/v3-figure-facts.ts +53 -0
- package/client/modeller/v3-occupancy.ts +6 -2
- package/client/pages/figure-v3-modeller-page.ts +250 -17
- 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/modeller/figure-history-panel.d.ts +6 -0
- package/dist-client/modeller/figure-history-panel.js +9 -85
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
- package/dist-client/modeller/figure-v3-properties.js +259 -0
- package/dist-client/modeller/figure-v3-properties.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-canvas.d.ts +6 -0
- package/dist-client/modeller/v3-canvas.js +27 -3
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.js +39 -11
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
- package/dist-client/modeller/v3-figure-facts.js +34 -0
- package/dist-client/modeller/v3-figure-facts.js.map +1 -0
- package/dist-client/modeller/v3-occupancy.d.ts +5 -1
- package/dist-client/modeller/v3-occupancy.js +4 -2
- package/dist-client/modeller/v3-occupancy.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +40 -0
- package/dist-client/pages/figure-v3-modeller-page.js +264 -18
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/test/v3-properties.test.ts +93 -0
- package/test/v3-theme.test.ts +67 -0
- package/test/v3-verdict.test.ts +82 -0
- package/translations/en.json +12 -0
- package/translations/ja.json +12 -0
- package/translations/ko.json +12 -0
- package/translations/ms.json +12 -0
- package/translations/zh.json +12 -0
|
@@ -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
|
+
`
|
|
@@ -13,7 +13,12 @@ export class FigureV3Canvas extends LitElement {
|
|
|
13
13
|
min-width: 0;
|
|
14
14
|
min-height: 0;
|
|
15
15
|
position: relative;
|
|
16
|
-
|
|
16
|
+
/*
|
|
17
|
+
The working board is one step above the surface in both themes (UX, 2026-09-24): surface-container is 238
|
|
18
|
+
in light and 31 in dark. The 3D background is read from this same value (paintBackground), so the
|
|
19
|
+
first paint and the scene agree and there is no flash between them.
|
|
20
|
+
*/
|
|
21
|
+
background: var(--md-sys-color-surface-container);
|
|
17
22
|
}
|
|
18
23
|
canvas {
|
|
19
24
|
display: block;
|
|
@@ -25,7 +30,7 @@ export class FigureV3Canvas extends LitElement {
|
|
|
25
30
|
inset: 45% 1rem auto;
|
|
26
31
|
text-align: center;
|
|
27
32
|
pointer-events: none;
|
|
28
|
-
color:
|
|
33
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
29
34
|
}
|
|
30
35
|
`
|
|
31
36
|
@property({ attribute: false }) parts: V3RenderPart[] = []
|
|
@@ -37,6 +42,7 @@ export class FigureV3Canvas extends LitElement {
|
|
|
37
42
|
private readonly camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000000)
|
|
38
43
|
private controls?: OrbitControls
|
|
39
44
|
private observer?: ResizeObserver
|
|
45
|
+
private themeObserver?: MutationObserver
|
|
40
46
|
private initialized = false
|
|
41
47
|
private contextAtRender = ''
|
|
42
48
|
render() {
|
|
@@ -55,7 +61,16 @@ export class FigureV3Canvas extends LitElement {
|
|
|
55
61
|
const canvas = this.renderRoot.querySelector('canvas')!
|
|
56
62
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true })
|
|
57
63
|
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
|
|
58
|
-
this.
|
|
64
|
+
this.paintBackground()
|
|
65
|
+
/*
|
|
66
|
+
The app switches theme by the body's class (light · dark). Reading the colour once at start left a light
|
|
67
|
+
board in the middle of a dark screen after every switch (seen 2026-09-24), so it is read again then.
|
|
68
|
+
*/
|
|
69
|
+
this.themeObserver = new MutationObserver(() => {
|
|
70
|
+
this.paintBackground()
|
|
71
|
+
this.draw()
|
|
72
|
+
})
|
|
73
|
+
this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] })
|
|
59
74
|
this.scene.add(this.group, new THREE.HemisphereLight(0xffffff, 0x62717d, 2.5))
|
|
60
75
|
const light = new THREE.DirectionalLight(0xffffff, 3)
|
|
61
76
|
light.position.set(1, 2, 3)
|
|
@@ -113,6 +128,14 @@ export class FigureV3Canvas extends LitElement {
|
|
|
113
128
|
this.controls?.update()
|
|
114
129
|
this.draw()
|
|
115
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The scene's background, taken from the host's computed background — the token resolved and normalised to
|
|
133
|
+
* `rgb(r, g, b)`, which THREE.Color reads. The token's own text may use the space-separated form it does not.
|
|
134
|
+
*/
|
|
135
|
+
private paintBackground() {
|
|
136
|
+
const colour = getComputedStyle(this).backgroundColor
|
|
137
|
+
if (colour && colour !== 'rgba(0, 0, 0, 0)' && colour !== 'transparent') this.scene.background = new THREE.Color(colour)
|
|
138
|
+
}
|
|
116
139
|
private draw = () => {
|
|
117
140
|
if (this.renderer) this.renderer.render(this.scene, this.camera)
|
|
118
141
|
}
|
|
@@ -134,6 +157,7 @@ export class FigureV3Canvas extends LitElement {
|
|
|
134
157
|
}
|
|
135
158
|
disconnectedCallback() {
|
|
136
159
|
this.observer?.disconnect()
|
|
160
|
+
this.themeObserver?.disconnect()
|
|
137
161
|
this.controls?.dispose()
|
|
138
162
|
disposeV3Meshes(this.group)
|
|
139
163
|
this.renderer?.dispose()
|
|
@@ -42,6 +42,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
42
42
|
flex-direction: column;
|
|
43
43
|
min-height: 0;
|
|
44
44
|
height: 100%;
|
|
45
|
+
/*
|
|
46
|
+
The editor folds by the width it is given, not the window's. The rail (208 open, 56 closed) and the page's
|
|
47
|
+
320 side take their share first, so a window rule was wrong whenever either was there: at 945 with the rail
|
|
48
|
+
open the editor had 417 and still drew its 660 of columns, over the side (UX ruling, 2026-09-24).
|
|
49
|
+
*/
|
|
50
|
+
container-type: inline-size;
|
|
45
51
|
font: 13px sans-serif;
|
|
46
52
|
color: var(--md-sys-color-on-surface, #22343f);
|
|
47
53
|
}
|
|
@@ -50,7 +56,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
50
56
|
align-items: center;
|
|
51
57
|
gap: 8px;
|
|
52
58
|
padding: 10px;
|
|
53
|
-
border-bottom: 1px solid
|
|
59
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
54
60
|
flex-wrap: wrap;
|
|
55
61
|
}
|
|
56
62
|
main {
|
|
@@ -65,10 +71,10 @@ export class FigureV3Editor extends LitElement {
|
|
|
65
71
|
background: var(--md-sys-color-surface, #fff);
|
|
66
72
|
}
|
|
67
73
|
aside:first-child {
|
|
68
|
-
border-right: 1px solid
|
|
74
|
+
border-right: 1px solid var(--md-sys-color-outline-variant);
|
|
69
75
|
}
|
|
70
76
|
aside:last-child {
|
|
71
|
-
border-left: 1px solid
|
|
77
|
+
border-left: 1px solid var(--md-sys-color-outline-variant);
|
|
72
78
|
}
|
|
73
79
|
h3 {
|
|
74
80
|
font-size: var(--ui-t-body, 13px);
|
|
@@ -83,7 +89,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
83
89
|
font: inherit;
|
|
84
90
|
box-sizing: border-box;
|
|
85
91
|
height: 34px;
|
|
86
|
-
border: 1px solid
|
|
92
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
87
93
|
border-radius: 6px;
|
|
88
94
|
background: var(--md-sys-color-surface, #fff);
|
|
89
95
|
color: inherit;
|
|
@@ -98,8 +104,9 @@ export class FigureV3Editor extends LitElement {
|
|
|
98
104
|
cursor: default;
|
|
99
105
|
}
|
|
100
106
|
button[aria-pressed='true'] {
|
|
101
|
-
background:
|
|
102
|
-
|
|
107
|
+
background: var(--md-sys-color-secondary-container);
|
|
108
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
109
|
+
border-color: var(--md-sys-color-primary);
|
|
103
110
|
}
|
|
104
111
|
.parts {
|
|
105
112
|
display: grid;
|
|
@@ -122,25 +129,46 @@ export class FigureV3Editor extends LitElement {
|
|
|
122
129
|
}
|
|
123
130
|
.error {
|
|
124
131
|
padding: 10px;
|
|
125
|
-
background:
|
|
126
|
-
color:
|
|
132
|
+
background: var(--md-sys-color-error-container);
|
|
133
|
+
color: var(--md-sys-color-on-error-container);
|
|
127
134
|
white-space: pre-wrap;
|
|
128
135
|
}
|
|
129
136
|
.status {
|
|
130
137
|
margin-left: auto;
|
|
131
138
|
}
|
|
132
139
|
.hint {
|
|
133
|
-
color:
|
|
140
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
134
141
|
line-height: 1.5;
|
|
135
142
|
}
|
|
136
|
-
|
|
143
|
+
/* 660 and over: parts | canvas | inputs. Below that the sum of the columns' least widths no longer fits. */
|
|
144
|
+
@container (max-width: 659px) {
|
|
137
145
|
main {
|
|
138
146
|
grid-template-columns: 140px minmax(160px, 1fr);
|
|
139
147
|
}
|
|
140
148
|
aside:last-child {
|
|
141
149
|
grid-column: 1/-1;
|
|
142
150
|
max-height: 260px;
|
|
143
|
-
border-
|
|
151
|
+
border-left: none;
|
|
152
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/* Under 420: one column. The canvas first — seeing what is being made comes before the lists — and main scrolls. */
|
|
156
|
+
@container (max-width: 419px) {
|
|
157
|
+
main {
|
|
158
|
+
grid-template-columns: minmax(0, 1fr);
|
|
159
|
+
grid-template-rows: minmax(240px, 45%);
|
|
160
|
+
grid-auto-rows: auto;
|
|
161
|
+
overflow-y: auto;
|
|
162
|
+
}
|
|
163
|
+
figure-v3-canvas {
|
|
164
|
+
order: -1;
|
|
165
|
+
}
|
|
166
|
+
aside:first-child {
|
|
167
|
+
border-right: none;
|
|
168
|
+
border-top: 1px solid var(--md-sys-color-outline-variant);
|
|
169
|
+
}
|
|
170
|
+
aside:last-child {
|
|
171
|
+
max-height: none;
|
|
144
172
|
}
|
|
145
173
|
}
|
|
146
174
|
`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { parseV3Asset } from '@hatiolab/figure-model'
|
|
2
|
+
import type { V3Asset, V3Face } from '@hatiolab/figure-model'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* What the V3 properties tab reads, kept apart from the element so it can be tested without a browser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Placement = 'floor' | 'ceiling' | 'center'
|
|
9
|
+
|
|
10
|
+
/** What the properties tab reads off the asset. Absent parts of the asset read as absent, never as a default. */
|
|
11
|
+
export interface V3FigureFacts {
|
|
12
|
+
name: string
|
|
13
|
+
parts: string[]
|
|
14
|
+
declared: boolean
|
|
15
|
+
placement?: Placement
|
|
16
|
+
mounting?: { part: string; face: V3Face }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The asset's own facts, as the properties tab shows them.
|
|
21
|
+
*
|
|
22
|
+
* The mounting face is read back from the feature `set-mounting-face` writes (`<part>.mount.<face>`): the asset
|
|
23
|
+
* records the face only as that node, so this is where the screen learns which one is chosen. Parts are the placed
|
|
24
|
+
* bodies (`place@1`), in the order the model lists them.
|
|
25
|
+
*/
|
|
26
|
+
export function v3FigureFacts(source: string): V3FigureFacts {
|
|
27
|
+
const asset = parseV3Asset(source) as V3Asset & { document: { model: { nodes: Array<{ id: string; op: string }> } } }
|
|
28
|
+
const parts = asset.document.model.nodes.filter(node => node.op === 'place@1').map(node => node.id)
|
|
29
|
+
const feature = (asset.occupancy?.contact as { surface?: { feature?: string } } | undefined)?.surface?.feature
|
|
30
|
+
const found = feature?.match(/^(.+)\.mount\.(left|right|bottom|top|back|front)\.value$/)
|
|
31
|
+
return {
|
|
32
|
+
name: asset.name,
|
|
33
|
+
parts,
|
|
34
|
+
declared: !!asset.occupancy,
|
|
35
|
+
placement: asset.occupancy?.placement as Placement | undefined,
|
|
36
|
+
mounting: found ? { part: found[1], face: found[2] as V3Face } : undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A tag line as a list — the V2 inspector's rule: split on commas, drop a leading `#`, keep each once. An empty
|
|
42
|
+
* line is an empty list, so an author can clear every tag.
|
|
43
|
+
*/
|
|
44
|
+
export function tagsOf(raw: string): string[] {
|
|
45
|
+
return [
|
|
46
|
+
...new Set(
|
|
47
|
+
raw
|
|
48
|
+
.split(',')
|
|
49
|
+
.map(one => one.trim().replace(/^#+/, '').trim())
|
|
50
|
+
.filter(one => one.length > 0)
|
|
51
|
+
)
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -12,11 +12,15 @@ import { parseV3Asset, proposeV3Occupancy, type V3AuthoringAction } from '@hatio
|
|
|
12
12
|
* 막는다. 배치 기준은 이미 선언된 것을 지키고, 처음 선언하는 도형은 바닥에 선다고 둔다 — 새로 만든 도형의
|
|
13
13
|
* 모델러 기본이 바닥이다.
|
|
14
14
|
*/
|
|
15
|
-
export function occupancyFromParts(
|
|
15
|
+
export function occupancyFromParts(
|
|
16
|
+
source: string,
|
|
17
|
+
/** A different face to place it by — the properties tab's choice. Absent keeps the declared one. */
|
|
18
|
+
placement?: 'floor' | 'ceiling' | 'center'
|
|
19
|
+
): Extract<V3AuthoringAction, { kind: 'declare-occupancy' }> {
|
|
16
20
|
const asset = parseV3Asset(source)
|
|
17
21
|
return {
|
|
18
22
|
kind: 'declare-occupancy',
|
|
19
23
|
proposal: proposeV3Occupancy(asset, { over: 'range' }),
|
|
20
|
-
placement: asset.occupancy?.placement ?? 'floor'
|
|
24
|
+
placement: placement ?? asset.occupancy?.placement ?? 'floor'
|
|
21
25
|
}
|
|
22
26
|
}
|