@things-factory/figure-ui 10.1.39 → 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/modeller/figure-v3-properties.ts +254 -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 +68 -13
- 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/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 +16 -2
- package/dist-client/pages/figure-v3-modeller-page.js +78 -22
- 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/translations/en.json +6 -0
- package/translations/ja.json +6 -0
- package/translations/ko.json +6 -0
- package/translations/ms.json +6 -0
- package/translations/zh.json +6 -0
|
@@ -0,0 +1,254 @@
|
|
|
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 { V3_FACES } from '@hatiolab/figure-model'
|
|
8
|
+
import type { V3AuthoringAction, V3Face } from '@hatiolab/figure-model'
|
|
9
|
+
|
|
10
|
+
import { occupancyFromParts } from './v3-occupancy.js'
|
|
11
|
+
import { tagsOf, v3FigureFacts, type Placement, type V3FigureFacts } from './v3-figure-facts.js'
|
|
12
|
+
|
|
13
|
+
const PLACEMENTS: Placement[] = ['floor', 'ceiling', 'center']
|
|
14
|
+
const FACES = Object.keys(V3_FACES) as V3Face[]
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 속성 — the V3 modeller's figure properties (parity row ②).
|
|
18
|
+
*
|
|
19
|
+
* The V2 inspector opens with what the figure is: its name, the face it is placed by, the room it takes up, where
|
|
20
|
+
* the catalogue files it. The V3 page had only the type. The same rows, in the same order, drawn the way the V2
|
|
21
|
+
* inspector draws them.
|
|
22
|
+
*
|
|
23
|
+
* Two kinds of change leave this tab, and it does neither itself:
|
|
24
|
+
* - `v3-command` carries an authoring command (rename, declare the room, the mounting face). The page runs it
|
|
25
|
+
* through the session, so it is one step of the same undo history as every other edit, and is saved with it.
|
|
26
|
+
* - `v3-catalog` carries a change to the row (category, tags). Those are not in the asset; the page writes them
|
|
27
|
+
* to the saved row at once, so they need a saved figure.
|
|
28
|
+
*
|
|
29
|
+
* Not here yet, because V3 has no place for them: editing the declared room by hand (V3 declares it from the
|
|
30
|
+
* parts, as graph expressions), and the detail level (the converter records it as lost). Both wait on a V3 ruling.
|
|
31
|
+
*/
|
|
32
|
+
@customElement('figure-v3-properties')
|
|
33
|
+
export class FigureV3Properties extends localize(i18next)(LitElement) {
|
|
34
|
+
static styles = css`
|
|
35
|
+
:host {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
gap: 12px;
|
|
39
|
+
padding: 12px;
|
|
40
|
+
overflow: auto;
|
|
41
|
+
font-size: 0.76rem;
|
|
42
|
+
color: var(--md-sys-color-on-surface);
|
|
43
|
+
}
|
|
44
|
+
div[field] {
|
|
45
|
+
display: grid;
|
|
46
|
+
gap: 4px;
|
|
47
|
+
}
|
|
48
|
+
div[field] > label {
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
}
|
|
51
|
+
div[row] {
|
|
52
|
+
display: flex;
|
|
53
|
+
gap: 6px;
|
|
54
|
+
align-items: center;
|
|
55
|
+
}
|
|
56
|
+
input,
|
|
57
|
+
select,
|
|
58
|
+
button {
|
|
59
|
+
font: inherit;
|
|
60
|
+
min-width: 0;
|
|
61
|
+
}
|
|
62
|
+
input,
|
|
63
|
+
select {
|
|
64
|
+
padding: 5px 8px;
|
|
65
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
66
|
+
border-radius: 4px;
|
|
67
|
+
background: var(--md-sys-color-surface);
|
|
68
|
+
color: var(--md-sys-color-on-surface);
|
|
69
|
+
}
|
|
70
|
+
div[row] > select {
|
|
71
|
+
flex: 1;
|
|
72
|
+
}
|
|
73
|
+
button {
|
|
74
|
+
display: inline-flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 4px;
|
|
77
|
+
padding: 4px 10px;
|
|
78
|
+
border: none;
|
|
79
|
+
border-radius: 999em;
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
82
|
+
background: var(--md-sys-color-secondary-container);
|
|
83
|
+
}
|
|
84
|
+
button:disabled,
|
|
85
|
+
input:disabled,
|
|
86
|
+
select:disabled {
|
|
87
|
+
opacity: 0.5;
|
|
88
|
+
cursor: default;
|
|
89
|
+
}
|
|
90
|
+
button md-icon {
|
|
91
|
+
font-size: 16px;
|
|
92
|
+
width: 16px;
|
|
93
|
+
height: 16px;
|
|
94
|
+
}
|
|
95
|
+
small {
|
|
96
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
97
|
+
}
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
/** The source being edited. */
|
|
101
|
+
@property({ type: String }) source?: string
|
|
102
|
+
/** The saved row, if any. Category and tags live on it. */
|
|
103
|
+
@property({ type: String }) figureId?: string
|
|
104
|
+
@property({ type: String }) figureCategory?: string
|
|
105
|
+
@property({ type: Array }) figureTags?: string[]
|
|
106
|
+
/** The part and face picked for the mounting face, before 「이 면으로」 is pressed. */
|
|
107
|
+
@state() private pickedPart?: string
|
|
108
|
+
@state() private pickedFace?: V3Face
|
|
109
|
+
|
|
110
|
+
private facts(): V3FigureFacts | undefined {
|
|
111
|
+
try {
|
|
112
|
+
return this.source ? v3FigureFacts(this.source) : undefined
|
|
113
|
+
} catch {
|
|
114
|
+
/* A source the format refuses has no facts to show; the verdict tab says why. */
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
render() {
|
|
120
|
+
const facts = this.facts()
|
|
121
|
+
if (!facts) return nothing
|
|
122
|
+
return html`${this.renderName(facts)} ${this.renderPlacement(facts)} ${this.renderSpace(facts)} ${this.renderMounting(facts)}
|
|
123
|
+
${this.renderCatalog()}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private renderName(facts: V3FigureFacts) {
|
|
127
|
+
return html`
|
|
128
|
+
<div field>
|
|
129
|
+
<label>${i18next.t('figure.label.name')}</label>
|
|
130
|
+
<input
|
|
131
|
+
.value=${facts.name}
|
|
132
|
+
placeholder=${i18next.t('figure.text.figure-display-name')}
|
|
133
|
+
@change=${(event: Event) => {
|
|
134
|
+
const input = event.target as HTMLInputElement
|
|
135
|
+
if (!input.value.trim()) {
|
|
136
|
+
/* The format refuses a blank name; put the old one back rather than send a command that fails. */
|
|
137
|
+
input.value = facts.name
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (input.value.trim() !== facts.name) this.command({ kind: 'rename', name: input.value })
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The face the figure is placed by. In V3 it is part of the room declaration, so choosing one declares the room
|
|
149
|
+
* again from the parts — the only way V3 declares it — with the new face. The hint says so.
|
|
150
|
+
*/
|
|
151
|
+
private renderPlacement(facts: V3FigureFacts) {
|
|
152
|
+
const current = facts.placement ?? 'floor'
|
|
153
|
+
return html`
|
|
154
|
+
<div field>
|
|
155
|
+
<label>${i18next.t('figure.label.figure-placement')}</label>
|
|
156
|
+
<select
|
|
157
|
+
?disabled=${!facts.parts.length}
|
|
158
|
+
@change=${(event: Event) => this.declare((event.target as HTMLSelectElement).value as Placement)}
|
|
159
|
+
>
|
|
160
|
+
${PLACEMENTS.map(
|
|
161
|
+
one => html`<option value=${one} ?selected=${one === current}>${i18next.t('figure.label.placement-' + one)}</option>`
|
|
162
|
+
)}
|
|
163
|
+
</select>
|
|
164
|
+
<small>${i18next.t(facts.parts.length ? 'figure.text.placement-redeclares' : 'figure.text.fit-needs-parts')}</small>
|
|
165
|
+
</div>
|
|
166
|
+
`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private renderSpace(facts: V3FigureFacts) {
|
|
170
|
+
return html`
|
|
171
|
+
<div field>
|
|
172
|
+
<label>${i18next.t('figure.label.declared-space')}</label>
|
|
173
|
+
<div row>
|
|
174
|
+
<span>${i18next.t(facts.declared ? 'figure.text.declared-space-declared' : 'figure.text.declared-space-none')}</span>
|
|
175
|
+
<button fit ?disabled=${!facts.parts.length} @click=${() => this.declare(facts.placement ?? 'floor')}>
|
|
176
|
+
<md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private renderMounting(facts: V3FigureFacts) {
|
|
184
|
+
const mounting = facts.mounting
|
|
185
|
+
const part = [this.pickedPart, mounting?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
|
|
186
|
+
const face: V3Face = this.pickedFace ?? mounting?.face ?? 'bottom'
|
|
187
|
+
return html`
|
|
188
|
+
<div field>
|
|
189
|
+
<label>${i18next.t('figure.label.mounting-face')}</label>
|
|
190
|
+
<div row>
|
|
191
|
+
<select ?disabled=${!facts.declared} @change=${(e: Event) => (this.pickedPart = (e.target as HTMLSelectElement).value)}>
|
|
192
|
+
${facts.parts.map(one => html`<option value=${one} ?selected=${one === part}>${one}</option>`)}
|
|
193
|
+
</select>
|
|
194
|
+
<select ?disabled=${!facts.declared} @change=${(e: Event) => (this.pickedFace = (e.target as HTMLSelectElement).value as V3Face)}>
|
|
195
|
+
${FACES.map(one => html`<option value=${one} ?selected=${one === face}>${i18next.t('figure.label.face-' + one)}</option>`)}
|
|
196
|
+
</select>
|
|
197
|
+
</div>
|
|
198
|
+
<div row>
|
|
199
|
+
<button ?disabled=${!facts.declared || !part} @click=${() => this.command({ kind: 'set-mounting-face', part: part!, face })}>
|
|
200
|
+
${i18next.t('figure.button.set-mounting-face')}
|
|
201
|
+
</button>
|
|
202
|
+
<button ?disabled=${!mounting} @click=${() => this.command({ kind: 'clear-mounting-face' })}>
|
|
203
|
+
${i18next.t('figure.button.clear-mounting-face')}
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
<small>
|
|
207
|
+
${facts.declared
|
|
208
|
+
? mounting
|
|
209
|
+
? `${mounting.part} · ${i18next.t('figure.label.face-' + mounting.face)}`
|
|
210
|
+
: i18next.t('figure.label.mounting-face-none')
|
|
211
|
+
: i18next.t('figure.text.mounting-face-needs-space')}
|
|
212
|
+
</small>
|
|
213
|
+
</div>
|
|
214
|
+
`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private renderCatalog() {
|
|
218
|
+
const saved = !!this.figureId
|
|
219
|
+
return html`
|
|
220
|
+
<div field>
|
|
221
|
+
<label>${i18next.t('figure.label.category')}</label>
|
|
222
|
+
<input
|
|
223
|
+
.value=${this.figureCategory ?? ''}
|
|
224
|
+
?disabled=${!saved}
|
|
225
|
+
placeholder=${i18next.t('figure.text.category-placeholder')}
|
|
226
|
+
@change=${(e: Event) => this.catalog({ category: (e.target as HTMLInputElement).value.trim() })}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
<div field>
|
|
230
|
+
<label>${i18next.t('figure.label.tags')}</label>
|
|
231
|
+
<input
|
|
232
|
+
.value=${(this.figureTags ?? []).join(', ')}
|
|
233
|
+
?disabled=${!saved}
|
|
234
|
+
placeholder=${i18next.t('figure.text.tags-placeholder')}
|
|
235
|
+
@change=${(e: Event) => this.catalog({ tags: tagsOf((e.target as HTMLInputElement).value) })}
|
|
236
|
+
/>
|
|
237
|
+
${saved ? nothing : html`<small>${i18next.t('figure.text.catalog-needs-a-save')}</small>`}
|
|
238
|
+
</div>
|
|
239
|
+
`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private declare(placement: Placement) {
|
|
243
|
+
if (!this.source) return
|
|
244
|
+
this.command(occupancyFromParts(this.source, placement))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private command(action: V3AuthoringAction) {
|
|
248
|
+
this.dispatchEvent(new CustomEvent('v3-command', { detail: { action }, bubbles: true, composed: true }))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private catalog(patch: { category?: string; tags?: string[] }) {
|
|
252
|
+
this.dispatchEvent(new CustomEvent('v3-catalog', { detail: patch, bubbles: true, composed: true }))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -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
|
}
|
|
@@ -6,10 +6,12 @@ import { v3ProductStore } from '../graphql/v3-store.js'
|
|
|
6
6
|
import { i18next } from '@operato/i18n'
|
|
7
7
|
import { fetchFigure, updateFigure } from '../graphql/index.js'
|
|
8
8
|
import { occupancyFromParts } from '../modeller/v3-occupancy.js'
|
|
9
|
+
import type { V3AuthoringAction } from '@hatiolab/figure-model'
|
|
9
10
|
import '../modeller/v3-editor.js'
|
|
10
11
|
import '@material/web/icon/icon.js'
|
|
11
12
|
import '../modeller/figure-history-panel.js'
|
|
12
13
|
import '../modeller/figure-v3-verdict.js'
|
|
14
|
+
import '../modeller/figure-v3-properties.js'
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* The V3 product page has exactly one authoritative workspace for UI and AI input transactions.
|
|
@@ -144,15 +146,17 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
144
146
|
* 발행 판정에 막히는데 그 선언을 할 단추도 없었다(2026-09-24 확인). 서버의 문 하나가 두 형식을 다 읽으므로
|
|
145
147
|
* V2 화면의 패널을 그대로 건다.
|
|
146
148
|
*/
|
|
147
|
-
@state() private row: { state?: 'draft' | 'released'; version: number; type?: string } = {
|
|
149
|
+
@state() private row: { state?: 'draft' | 'released'; version: number; type?: string; category?: string; tags?: string[] } = {
|
|
150
|
+
version: 0
|
|
151
|
+
}
|
|
148
152
|
@state() private typeError = ''
|
|
149
|
-
/** Why
|
|
150
|
-
@state() private
|
|
153
|
+
/** Why a command from the side did nothing. Shown beside the tabs — the editor stays, since nothing in it was lost. */
|
|
154
|
+
@state() private commandError = ''
|
|
151
155
|
/**
|
|
152
156
|
* Which tab the side shows. The verdict comes first: it is what the author needs while editing, and the count on
|
|
153
157
|
* its tab stays in sight while the history is open — as on the V2 page.
|
|
154
158
|
*/
|
|
155
|
-
@state() private tab: 'verdict' | 'history' = 'verdict'
|
|
159
|
+
@state() private tab: 'properties' | 'verdict' | 'history' = 'verdict'
|
|
156
160
|
@state() private instances?: number
|
|
157
161
|
/**
|
|
158
162
|
* The source the verdict judges. The session refuses to export while it loads or saves, so the last one read is
|
|
@@ -168,6 +172,7 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
168
172
|
return this.draft
|
|
169
173
|
}
|
|
170
174
|
private wasDirty = false
|
|
175
|
+
private titled?: string | null
|
|
171
176
|
private announce = () => {
|
|
172
177
|
const view = this.session.read()
|
|
173
178
|
if (!this.active || view.loading || view.saving || view.error || !view.context) return
|
|
@@ -185,9 +190,14 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
185
190
|
}
|
|
186
191
|
private changed = () => {
|
|
187
192
|
this.revision++
|
|
188
|
-
/* Any edit after a failed
|
|
189
|
-
this.
|
|
193
|
+
/* Any edit after a failed command makes the message stale — the author has done something about it. */
|
|
194
|
+
this.commandError = ''
|
|
190
195
|
const view = this.session.read()
|
|
196
|
+
/* The title bar reads the name through `context`, which the shell asks for only when told — a rename left it stale. */
|
|
197
|
+
if (view.name !== this.titled) {
|
|
198
|
+
this.titled = view.name
|
|
199
|
+
this.updateContext()
|
|
200
|
+
}
|
|
191
201
|
if (!view.saving) this.announce()
|
|
192
202
|
/* 저장이 끝난 순간 행을 다시 읽는다 — 발행된 도형을 고쳐 저장하면 서버가 초안으로 내린다. */
|
|
193
203
|
if (this.wasDirty && !view.dirty && !view.saving && view.id) this.readRow(view.id)
|
|
@@ -197,7 +207,13 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
197
207
|
try {
|
|
198
208
|
const figure = await fetchFigure(id)
|
|
199
209
|
if (this.session.read().id === id)
|
|
200
|
-
this.row = {
|
|
210
|
+
this.row = {
|
|
211
|
+
state: figure.state as 'draft' | 'released',
|
|
212
|
+
version: figure.version ?? 0,
|
|
213
|
+
type: figure.type,
|
|
214
|
+
category: figure.category ?? undefined,
|
|
215
|
+
tags: figure.tags ?? undefined
|
|
216
|
+
}
|
|
201
217
|
} catch {
|
|
202
218
|
/* 행을 못 읽으면 패널은 판 번호 0 으로 보인다 — 발행 문이 서버에서 다시 판단하므로 여기서 지어내지 않는다. */
|
|
203
219
|
}
|
|
@@ -226,16 +242,28 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
226
242
|
}
|
|
227
243
|
/** 「부품에 맞추기」 — 공간을 지금 부품에서 다시 선언한다. 저장은 저작자가 따로 한다. */
|
|
228
244
|
private fitToParts = () => {
|
|
245
|
+
this.runCommand(() => occupancyFromParts(this.session.source()))
|
|
246
|
+
}
|
|
247
|
+
/** A command from the properties tab — rename, declare the room, the mounting face. */
|
|
248
|
+
private onCommand = (event: Event) => {
|
|
249
|
+
const action = (event as CustomEvent).detail?.action as V3AuthoringAction | undefined
|
|
250
|
+
if (action) this.runCommand(() => action)
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* One step of the session's history, whichever part of the side asked for it — so undo takes it back like any
|
|
254
|
+
* other edit, and it is saved with the figure.
|
|
255
|
+
*/
|
|
256
|
+
private runCommand(actionOf: () => V3AuthoringAction) {
|
|
229
257
|
try {
|
|
230
|
-
this.session.command(
|
|
231
|
-
this.
|
|
258
|
+
this.session.command(actionOf())
|
|
259
|
+
this.commandError = ''
|
|
232
260
|
this.changed()
|
|
233
261
|
} catch (error) {
|
|
234
262
|
/*
|
|
235
263
|
This used to set the page error, which replaces the whole editor with one raw line — for a figure with no
|
|
236
264
|
parts that line was 「OCCUPANCY_NO_PARTS: occupancy: no part could be measured」 (2026-09-24).
|
|
237
265
|
*/
|
|
238
|
-
this.
|
|
266
|
+
this.commandError =
|
|
239
267
|
(error as { code?: string })?.code === 'OCCUPANCY_NO_PARTS'
|
|
240
268
|
? i18next.t('figure.text.fit-needs-parts')
|
|
241
269
|
: error instanceof Error
|
|
@@ -243,9 +271,26 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
243
271
|
: String(error)
|
|
244
272
|
}
|
|
245
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Category and tags — the catalogue's, kept on the row, not in the asset. Written at once, as the V2 page does;
|
|
276
|
+
* the server's refusal is shown as it gave it.
|
|
277
|
+
*/
|
|
278
|
+
private onCatalog = async (event: Event) => {
|
|
279
|
+
const patch = (event as CustomEvent).detail as { category?: string; tags?: string[] }
|
|
280
|
+
const id = this.session.read().id
|
|
281
|
+
if (!id || !patch) return
|
|
282
|
+
try {
|
|
283
|
+
const result = await updateFigure(id, patch)
|
|
284
|
+
this.commandError = ''
|
|
285
|
+
this.row = { ...this.row, category: result.figure.category ?? undefined, tags: result.figure.tags ?? undefined }
|
|
286
|
+
} catch (error) {
|
|
287
|
+
this.commandError = error instanceof Error ? error.message : String(error)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
246
290
|
private released = (event: Event) => {
|
|
247
291
|
const figure = (event as CustomEvent).detail?.figure
|
|
248
|
-
|
|
292
|
+
/* Keep the rest of the row: rebuilding it from these two dropped the type, and the locked type field went blank. */
|
|
293
|
+
if (figure?.id && figure.id === this.session.read().id) this.row = { ...this.row, state: figure.state, version: figure.version ?? 0 }
|
|
249
294
|
}
|
|
250
295
|
/** 과거 판으로 되돌렸으면 서버의 원본이 바뀐 것이다 — 다시 연다. 패널은 저장 안 된 것이 있으면 되돌리기를 막는다. */
|
|
251
296
|
private reverted = async () => {
|
|
@@ -340,11 +385,21 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
340
385
|
/>`}
|
|
341
386
|
${this.typeError ? html`<small role="alert">${this.typeError}</small>` : ''}
|
|
342
387
|
</label>
|
|
343
|
-
${this.
|
|
388
|
+
${this.commandError ? html`<p fit-error role="alert">${this.commandError}</p>` : ''}
|
|
344
389
|
<div tabs>
|
|
390
|
+
${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
|
|
345
391
|
${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
|
|
346
392
|
${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
|
|
347
393
|
</div>
|
|
394
|
+
<figure-v3-properties
|
|
395
|
+
?off=${this.tab !== 'properties'}
|
|
396
|
+
.source=${this.draftSource()}
|
|
397
|
+
.figureId=${view.id}
|
|
398
|
+
.figureCategory=${this.row.category}
|
|
399
|
+
.figureTags=${this.row.tags}
|
|
400
|
+
@v3-command=${this.onCommand}
|
|
401
|
+
@v3-catalog=${this.onCatalog}
|
|
402
|
+
></figure-v3-properties>
|
|
348
403
|
<figure-v3-verdict
|
|
349
404
|
?off=${this.tab !== 'verdict'}
|
|
350
405
|
.source=${this.draftSource()}
|
|
@@ -367,7 +422,7 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
367
422
|
></figure-history-panel>
|
|
368
423
|
</aside>`
|
|
369
424
|
}
|
|
370
|
-
private renderTab(tab: 'verdict' | 'history', icon: string, label: string, badge?: number) {
|
|
425
|
+
private renderTab(tab: 'properties' | 'verdict' | 'history', icon: string, label: string, badge?: number) {
|
|
371
426
|
return html`
|
|
372
427
|
<button tab ?on=${this.tab === tab} title=${label} aria-label=${label} @click=${() => (this.tab = tab)}>
|
|
373
428
|
<md-icon>${icon}</md-icon>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
|