@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.
Files changed (33) hide show
  1. package/client/modeller/figure-v3-properties.ts +254 -0
  2. package/client/modeller/v3-canvas.ts +27 -3
  3. package/client/modeller/v3-editor.ts +39 -11
  4. package/client/modeller/v3-figure-facts.ts +53 -0
  5. package/client/modeller/v3-occupancy.ts +6 -2
  6. package/client/pages/figure-v3-modeller-page.ts +68 -13
  7. package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
  8. package/dist-client/modeller/figure-v3-properties.js +259 -0
  9. package/dist-client/modeller/figure-v3-properties.js.map +1 -0
  10. package/dist-client/modeller/v3-canvas.d.ts +6 -0
  11. package/dist-client/modeller/v3-canvas.js +27 -3
  12. package/dist-client/modeller/v3-canvas.js.map +1 -1
  13. package/dist-client/modeller/v3-editor.js +39 -11
  14. package/dist-client/modeller/v3-editor.js.map +1 -1
  15. package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
  16. package/dist-client/modeller/v3-figure-facts.js +34 -0
  17. package/dist-client/modeller/v3-figure-facts.js.map +1 -0
  18. package/dist-client/modeller/v3-occupancy.d.ts +5 -1
  19. package/dist-client/modeller/v3-occupancy.js +4 -2
  20. package/dist-client/modeller/v3-occupancy.js.map +1 -1
  21. package/dist-client/pages/figure-v3-modeller-page.d.ts +16 -2
  22. package/dist-client/pages/figure-v3-modeller-page.js +78 -22
  23. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  24. package/dist-client/route.d.ts +1 -1
  25. package/dist-client/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +4 -4
  27. package/test/v3-properties.test.ts +93 -0
  28. package/test/v3-theme.test.ts +67 -0
  29. package/translations/en.json +6 -0
  30. package/translations/ja.json +6 -0
  31. package/translations/ko.json +6 -0
  32. package/translations/ms.json +6 -0
  33. 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
- background: #eef2f4;
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: #526773;
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.scene.background = new THREE.Color('#eef2f4')
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 #d4dde2;
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 #d4dde2;
74
+ border-right: 1px solid var(--md-sys-color-outline-variant);
69
75
  }
70
76
  aside:last-child {
71
- border-left: 1px solid #d4dde2;
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 #cbd5dc;
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: #d6edf0;
102
- border-color: #337783;
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: #fff0eb;
126
- color: #922716;
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: #637783;
140
+ color: var(--md-sys-color-on-surface-variant);
134
141
  line-height: 1.5;
135
142
  }
136
- @media (max-width: 800px) {
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-top: 1px solid #d4dde2;
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(source: string): V3AuthoringAction {
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 } = { version: 0 }
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 「부품에 맞추기」 did nothing. Shown beside the tabs — the editor stays, since nothing in it was lost. */
150
- @state() private fitError = ''
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 fit makes the message stale — the author has done something about it. */
189
- this.fitError = ''
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 = { state: figure.state as 'draft' | 'released', version: figure.version ?? 0, type: figure.type }
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(occupancyFromParts(this.session.source()))
231
- this.fitError = ''
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.fitError =
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
- if (figure?.id && figure.id === this.session.read().id) this.row = { state: figure.state, version: figure.version ?? 0 }
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.fitError ? html`<p fit-error role="alert">${this.fitError}</p>` : ''}
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>`}