@things-factory/figure-ui 10.1.39 → 10.1.42

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 (70) hide show
  1. package/client/graphql/index.ts +1 -0
  2. package/client/modeller/figure-v3-material.ts +248 -0
  3. package/client/modeller/figure-v3-parts.ts +16 -1
  4. package/client/modeller/figure-v3-properties.ts +254 -0
  5. package/client/modeller/modeller-route.ts +25 -0
  6. package/client/modeller/v3-align.ts +84 -0
  7. package/client/modeller/v3-canvas.ts +167 -5
  8. package/client/modeller/v3-editor.ts +207 -47
  9. package/client/modeller/v3-figure-facts.ts +53 -0
  10. package/client/modeller/v3-gizmo.ts +80 -0
  11. package/client/modeller/v3-occupancy.ts +6 -2
  12. package/client/pages/figure-list-page.ts +5 -3
  13. package/client/pages/figure-modeller-page.ts +9 -0
  14. package/client/pages/figure-v3-modeller-page.ts +68 -13
  15. package/client/types.ts +2 -0
  16. package/dist-client/graphql/index.js +1 -0
  17. package/dist-client/graphql/index.js.map +1 -1
  18. package/dist-client/modeller/figure-v3-material.d.ts +36 -0
  19. package/dist-client/modeller/figure-v3-material.js +249 -0
  20. package/dist-client/modeller/figure-v3-material.js.map +1 -0
  21. package/dist-client/modeller/figure-v3-parts.js +16 -1
  22. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-properties.d.ts +46 -0
  24. package/dist-client/modeller/figure-v3-properties.js +259 -0
  25. package/dist-client/modeller/figure-v3-properties.js.map +1 -0
  26. package/dist-client/modeller/modeller-route.d.ts +16 -0
  27. package/dist-client/modeller/modeller-route.js +27 -0
  28. package/dist-client/modeller/modeller-route.js.map +1 -0
  29. package/dist-client/modeller/v3-align.d.ts +26 -0
  30. package/dist-client/modeller/v3-align.js +65 -0
  31. package/dist-client/modeller/v3-align.js.map +1 -0
  32. package/dist-client/modeller/v3-canvas.d.ts +24 -0
  33. package/dist-client/modeller/v3-canvas.js +181 -5
  34. package/dist-client/modeller/v3-canvas.js.map +1 -1
  35. package/dist-client/modeller/v3-editor.d.ts +24 -0
  36. package/dist-client/modeller/v3-editor.js +218 -44
  37. package/dist-client/modeller/v3-editor.js.map +1 -1
  38. package/dist-client/modeller/v3-figure-facts.d.ts +26 -0
  39. package/dist-client/modeller/v3-figure-facts.js +34 -0
  40. package/dist-client/modeller/v3-figure-facts.js.map +1 -0
  41. package/dist-client/modeller/v3-gizmo.d.ts +48 -0
  42. package/dist-client/modeller/v3-gizmo.js +40 -0
  43. package/dist-client/modeller/v3-gizmo.js.map +1 -0
  44. package/dist-client/modeller/v3-occupancy.d.ts +5 -1
  45. package/dist-client/modeller/v3-occupancy.js +4 -2
  46. package/dist-client/modeller/v3-occupancy.js.map +1 -1
  47. package/dist-client/pages/figure-list-page.d.ts +1 -0
  48. package/dist-client/pages/figure-list-page.js +5 -3
  49. package/dist-client/pages/figure-list-page.js.map +1 -1
  50. package/dist-client/pages/figure-modeller-page.js +9 -0
  51. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  52. package/dist-client/pages/figure-v3-modeller-page.d.ts +16 -2
  53. package/dist-client/pages/figure-v3-modeller-page.js +78 -22
  54. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/dist-client/types.d.ts +2 -0
  57. package/dist-client/types.js.map +1 -1
  58. package/package.json +4 -4
  59. package/test/modeller-route.test.ts +42 -0
  60. package/test/v3-align.test.ts +64 -0
  61. package/test/v3-gizmo.test.ts +97 -0
  62. package/test/v3-material.test.ts +68 -0
  63. package/test/v3-parts-wiring.test.ts +56 -0
  64. package/test/v3-properties.test.ts +93 -0
  65. package/test/v3-theme.test.ts +67 -0
  66. package/translations/en.json +11 -0
  67. package/translations/ja.json +11 -0
  68. package/translations/ko.json +11 -0
  69. package/translations/ms.json +11 -0
  70. package/translations/zh.json +11 -0
@@ -59,6 +59,7 @@ const FIGURE_LIST_FIELDS = `
59
59
  score
60
60
  triangles
61
61
  groups
62
+ format
62
63
  thumbnailUpdatedAt
63
64
  updatedAt
64
65
  updater { id name }
@@ -0,0 +1,248 @@
1
+ import { css, html, LitElement, nothing } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+
4
+ import { i18next, localize } from '@operato/i18n'
5
+ import { MATERIAL_PRESETS, SEGMENT_PRESETS, v3TrianglesOf } from '@hatiolab/figure-model'
6
+ import { activePalette } from '@hatiolab/things-scene'
7
+ import type { V3AssetGeometry, V3Material, V3PartEdit } from '@hatiolab/figure-model'
8
+
9
+ /** The purpose each palette role is for, by its token — the V2 inspector's words for the same roles. */
10
+ const PURPOSE: Record<string, string> = {
11
+ 'palette.primary': 'primary',
12
+ 'palette.accent': 'accent',
13
+ 'palette.neutral': 'neutral',
14
+ 'palette.dark': 'dark',
15
+ 'palette.light': 'light',
16
+ 'palette.surface': 'surface',
17
+ 'palette.status-danger': 'danger',
18
+ 'palette.status-warning': 'warning',
19
+ 'palette.status-info': 'info',
20
+ 'palette.status-success': 'success'
21
+ }
22
+ export const purposeOf = (token?: string) => i18next.t(`figure.label.palette-purpose-${PURPOSE[token ?? ''] ?? 'registered'}`)
23
+
24
+ /** Bodies with a rim — the ones `set-segments` accepts. */
25
+ const ROUND = new Set(['cylinder', 'ellipticCylinder', 'sphere', 'frustum'])
26
+ const DEFAULT_SEGMENTS = 12
27
+
28
+ /**
29
+ * 재질 — a V3 part's material, drawn as the V2 inspector draws it (V3 parity row ③).
30
+ *
31
+ * The V3 editor showed the colour role as a raw token (`palette.dark`) in a select and nothing else. The V3 material
32
+ * holds the colour role, a preset, transparency and a glow; a round body also has how finely its rim is drawn. This
33
+ * shows those, with the V2 words: painted colours and lit colours as two groups of swatches, each named for what it
34
+ * is for.
35
+ *
36
+ * Not here, because a V3 material has no place for them: flat shading and casting shadows (V2 fields the converter
37
+ * does not carry). The glow's colour comes from the lit colours, as in V2.
38
+ *
39
+ * Every change leaves as one `v3-part-edit`; the editor runs it through the session, one step of the undo history.
40
+ */
41
+ @customElement('figure-v3-material')
42
+ export class FigureV3Material extends localize(i18next)(LitElement) {
43
+ static styles = css`
44
+ :host {
45
+ display: grid;
46
+ gap: 10px;
47
+ }
48
+ h3 {
49
+ font-size: var(--ui-t-body, 13px);
50
+ margin: 12px 0 0;
51
+ }
52
+ div[field] {
53
+ display: grid;
54
+ gap: 4px;
55
+ }
56
+ div[field] > label {
57
+ font-weight: 600;
58
+ }
59
+ div[swatches] {
60
+ display: flex;
61
+ flex-wrap: wrap;
62
+ gap: 4px;
63
+ }
64
+ button[swatch] {
65
+ width: 22px;
66
+ height: 22px;
67
+ padding: 0;
68
+ border-radius: 50%;
69
+ border: 1px solid var(--md-sys-color-outline-variant);
70
+ background: var(--swatch);
71
+ cursor: pointer;
72
+ }
73
+ button[swatch][on] {
74
+ outline: 2px solid var(--md-sys-color-primary);
75
+ outline-offset: 1px;
76
+ }
77
+ small {
78
+ color: var(--md-sys-color-on-surface-variant);
79
+ }
80
+ select,
81
+ input[type='range'] {
82
+ font: inherit;
83
+ min-width: 0;
84
+ }
85
+ label[toggle] {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 6px;
89
+ }
90
+ div[segments] {
91
+ display: grid;
92
+ grid-template-columns: repeat(3, 1fr);
93
+ gap: 4px;
94
+ }
95
+ div[segments] button {
96
+ display: grid;
97
+ padding: 4px;
98
+ font: inherit;
99
+ border: 1px solid var(--md-sys-color-outline-variant);
100
+ border-radius: 6px;
101
+ background: var(--md-sys-color-surface);
102
+ color: var(--md-sys-color-on-surface);
103
+ cursor: pointer;
104
+ }
105
+ div[segments] button[on] {
106
+ background: var(--md-sys-color-secondary-container);
107
+ color: var(--md-sys-color-on-secondary-container);
108
+ border-color: var(--md-sys-color-primary);
109
+ }
110
+ `
111
+
112
+ /** The selected body as evaluated — its material, its segments and its shape. */
113
+ @property({ attribute: false }) body?: V3AssetGeometry & { id: string }
114
+ /** The asset's palette, token → colour. */
115
+ @property({ attribute: false }) palette: Record<string, string> = {}
116
+ @property({ type: Boolean }) locked = false
117
+
118
+ render() {
119
+ const part = this.body
120
+ if (!part) return nothing
121
+ const material = part.material
122
+ /*
123
+ The roles offered are the app's palette, as in V2, with any the asset carries beyond it. A new asset carries
124
+ only its primary colour; a role it lacks is sent with its colour and added in the same edit (`put`).
125
+ */
126
+ const entries = Object.entries(this.offered())
127
+ const painted = entries.filter(([token]) => !token.startsWith('palette.status-'))
128
+ const lit = entries.filter(([token]) => token.startsWith('palette.status-'))
129
+ const glow = material.emissive
130
+ return html`
131
+ <h3>${i18next.t('figure.label.part-material')}</h3>
132
+ <div field>
133
+ <label>${i18next.t('figure.label.palette-role')}</label>
134
+ <div swatches>${this.swatches(painted, material.token, token => this.put({ token }))}</div>
135
+ ${lit.length
136
+ ? html`<small>${i18next.t('figure.label.status-colors')}</small>
137
+ <div swatches>${this.swatches(lit, material.token, token => this.put({ token }))}</div>`
138
+ : nothing}
139
+ <small><b>${purposeOf(material.token)}</b> — ${i18next.t('figure.text.palette-purpose-explained')}</small>
140
+ </div>
141
+ <div field>
142
+ <label>${i18next.t('figure.label.preset')}</label>
143
+ <select
144
+ ?disabled=${this.locked}
145
+ @change=${(e: Event) => this.put({ preset: ((e.target as HTMLSelectElement).value || undefined) as V3Material['preset'] })}
146
+ >
147
+ <option value="" ?selected=${!material.preset}>${i18next.t('figure.label.none')}</option>
148
+ ${MATERIAL_PRESETS.map(
149
+ preset => html`<option value=${preset} ?selected=${preset === material.preset}>${i18next.t('figure.label.preset-' + preset)}</option>`
150
+ )}
151
+ </select>
152
+ </div>
153
+ <label toggle>
154
+ <input
155
+ type="checkbox"
156
+ ?disabled=${this.locked}
157
+ .checked=${!!material.transparent}
158
+ @change=${(e: Event) => this.put({ transparent: (e.target as HTMLInputElement).checked || undefined })}
159
+ />
160
+ ${i18next.t('figure.label.transparent')}
161
+ </label>
162
+ <div field>
163
+ <label>${i18next.t('figure.label.emissive')}</label>
164
+ <input
165
+ type="range"
166
+ min="0"
167
+ max="1"
168
+ step="0.05"
169
+ ?disabled=${this.locked}
170
+ .value=${String(glow?.intensity ?? 0)}
171
+ @change=${(e: Event) => this.glow(Number((e.target as HTMLInputElement).value))}
172
+ />
173
+ <small>${(glow?.intensity ?? 0).toFixed(2)}</small>
174
+ </div>
175
+ ${glow
176
+ ? html`<div field>
177
+ <label>${i18next.t('figure.label.emissive-color')}</label>
178
+ <div swatches>${this.swatches(lit.length ? lit : painted, glow.token, token => this.put({ emissive: { ...glow, token } }))}</div>
179
+ </div>`
180
+ : nothing}
181
+ ${this.renderSegments(part)}
182
+ `
183
+ }
184
+
185
+ private renderSegments(part: V3AssetGeometry & { id: string }) {
186
+ const provider = (part as { shape?: { provider?: string } }).shape?.provider ?? ''
187
+ if (!ROUND.has(provider)) return nothing
188
+ const current = part.segments ?? DEFAULT_SEGMENTS
189
+ return html`
190
+ <div field>
191
+ <label>${i18next.t('figure.label.segments')}</label>
192
+ <div segments>
193
+ ${SEGMENT_PRESETS.map(
194
+ preset => html`<button
195
+ ?on=${preset === current}
196
+ ?disabled=${this.locked}
197
+ @click=${() => this.edit({ kind: 'set-segments', target: part.id, segments: preset })}
198
+ >
199
+ <b>${preset}</b>
200
+ <small>${i18next.t('figure.text.n-triangles', { count: v3TrianglesOf({ ...part, segments: preset }) })}</small>
201
+ </button>`
202
+ )}
203
+ </div>
204
+ </div>
205
+ `
206
+ }
207
+
208
+ private swatches(entries: [string, string][], chosen: string | undefined, pick: (token: string) => void) {
209
+ return entries.map(
210
+ ([token, colour]) => html`<button
211
+ swatch
212
+ ?on=${token === chosen}
213
+ ?disabled=${this.locked}
214
+ title=${purposeOf(token)}
215
+ aria-label=${purposeOf(token)}
216
+ style="--swatch:${colour}"
217
+ @click=${() => pick(token)}
218
+ ></button>`
219
+ )
220
+ }
221
+
222
+ private offered(): Record<string, string> {
223
+ return { ...(activePalette() as Record<string, string>), ...this.palette }
224
+ }
225
+
226
+ /** Intensity 0 is no glow at all, as in V2; a new glow takes the part's own colour until another is picked. */
227
+ private glow(intensity: number) {
228
+ const material = this.body!.material
229
+ this.put({ emissive: intensity > 0 ? { token: material.emissive?.token ?? material.token, intensity, on: true } : undefined })
230
+ }
231
+
232
+ private put(patch: Partial<V3Material>) {
233
+ const material: V3Material = { ...this.body!.material, ...patch }
234
+ for (const key of Object.keys(material) as (keyof V3Material)[]) if (material[key] === undefined) delete material[key]
235
+ /* The colours this material names that the asset does not carry yet. */
236
+ const offered = this.offered()
237
+ const palette = Object.fromEntries(
238
+ [material.token, material.emissive?.token]
239
+ .filter((token): token is string => !!token && this.palette[token] === undefined && offered[token] !== undefined)
240
+ .map(token => [token, offered[token]!])
241
+ )
242
+ this.edit({ kind: 'set-material', target: this.body!.id, material, ...(Object.keys(palette).length ? { palette } : {}) })
243
+ }
244
+
245
+ private edit(edit: V3PartEdit) {
246
+ this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit }, bubbles: true, composed: true }))
247
+ }
248
+ }
@@ -47,7 +47,7 @@ const PROVIDER_ICON: Record<string, string> = {
47
47
  const RECIPES: Array<{ kind: V3PrimitiveCreation['kind']; icon: string; label: string }> = [
48
48
  { kind: 'box', icon: 'deployed_code', label: 'figure.label.primitive-cube' },
49
49
  { kind: 'cylinder', icon: 'database', label: 'figure.label.primitive-cylinder' },
50
- { kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rect' },
50
+ { kind: 'rounded-box', icon: 'crop_square', label: 'figure.label.primitive-rounded-box' },
51
51
  { kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
52
52
  ]
53
53
 
@@ -66,6 +66,8 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
66
66
  color: var(--md-sys-color-on-surface);
67
67
  font: var(--label-font, inherit);
68
68
  font-size: 0.76rem;
69
+ /* Folds by its own width: the editor gives this column 140 px when it has two columns (UX, 2026-09-24). */
70
+ container-type: inline-size;
69
71
  }
70
72
  section {
71
73
  padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
@@ -232,6 +234,19 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
232
234
  --md-icon-size: 15px;
233
235
  color: inherit;
234
236
  }
237
+ /*
238
+ Under 200 px the selected row's four buttons pushed its name out and the last one past the edge (seen at 140,
239
+ 2026-09-24). They go to a line of their own under the name.
240
+ */
241
+ @container (max-width: 199px) {
242
+ li[selected] {
243
+ flex-wrap: wrap;
244
+ }
245
+ li[selected] div[ops] {
246
+ flex-basis: 100%;
247
+ justify-content: flex-end;
248
+ }
249
+ }
235
250
  p[quiet] {
236
251
  margin: 0;
237
252
  font-size: 0.76rem;
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which modeller opens a figure — by the format of its source, which the server reports (`format`).
3
+ *
4
+ * The catalogue sent every figure to the V2 modeller, so a converted figure could only be opened by typing the V3
5
+ * page's address (2026-09-24). A new figure starts in V3: new V2 figures would only add to what the cutover has to
6
+ * convert (ADR-0087). A figure is opened in V3 only when the server says it is V3 — an unknown format goes to the
7
+ * V2 page, which reports what it could not read.
8
+ */
9
+ export const V3_MODELLER = 'figure-v3-modeller'
10
+ export const V2_MODELLER = 'figure-modeller'
11
+
12
+ export function modellerPathOf(figure?: { id?: string; format?: string | null }): string {
13
+ if (!figure?.id) return V3_MODELLER
14
+ return figure.format === 'v3' ? `${V3_MODELLER}/${figure.id}` : `${V2_MODELLER}/${figure.id}`
15
+ }
16
+
17
+ /** The version a V3 asset stamps on itself. The V2 page forwards such a figure rather than failing on it. */
18
+ export function isV3Source(source?: string | null): boolean {
19
+ if (!source) return false
20
+ try {
21
+ return (JSON.parse(source) as { version?: unknown })?.version === 'figure-v3-asset-1'
22
+ } catch {
23
+ return false
24
+ }
25
+ }
@@ -0,0 +1,84 @@
1
+ import { compileV3Asset, v3OccupiedBoxesOf, v3WritersOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3InputChange, V3WorldBox } from '@hatiolab/figure-model'
3
+
4
+ import { niceStep } from './part-edits.js'
5
+ import type { GizmoReach } from './v3-gizmo.js'
6
+
7
+ /**
8
+ * 맞추기 — the V2 inspector's align tools on a V3 part (parity row ③): centre on X, centre on Z, stand on the floor,
9
+ * mirror on X or Z, snap to the grid.
10
+ *
11
+ * V2 measured against its base box, which ran from 0 to its size on each axis. A V3 asset's frame has the floor at
12
+ * y 0 and X and Z centred on 0, so centring puts the part's box centre on 0 and the floor puts its lowest point on
13
+ * y 0. The part's box is its evaluated, rotated extent (`v3OccupiedBoxesOf`), so a turned part lands by what it
14
+ * actually covers, not by its unturned size.
15
+ *
16
+ * Every tool writes the part's own position (and, for snap, size) inputs — the same ones the gizmo writes, and only
17
+ * where the gizmo could (`GizmoReach`).
18
+ */
19
+ export type AlignTool = 'centre-x' | 'centre-z' | 'floor' | 'snap'
20
+
21
+ const AXES = ['x', 'y', 'z'] as const
22
+
23
+ export function boxOf(asset: V3Asset, part: string): V3WorldBox | undefined {
24
+ const geometry = compileV3Asset(asset).evaluate().geometry
25
+ return v3OccupiedBoxesOf(geometry, v3WritersOf(asset)).find(box => box.part === part)
26
+ }
27
+
28
+ /** The grid V2 snaps to: about five steps across the figure, on 1 · 2 · 5 multiples. */
29
+ export function gridStepOf(asset: V3Asset): number {
30
+ const geometry = compileV3Asset(asset).evaluate().geometry
31
+ const boxes = v3OccupiedBoxesOf(geometry, v3WritersOf(asset))
32
+ if (!boxes.length) return 1
33
+ const span = Math.max(
34
+ ...AXES.map(axis => {
35
+ const lo = Math.min(...boxes.map(b => b.centre[axis] - b.extent[axis] / 2))
36
+ const hi = Math.max(...boxes.map(b => b.centre[axis] + b.extent[axis] / 2))
37
+ return hi - lo
38
+ })
39
+ )
40
+ return niceStep(span)
41
+ }
42
+
43
+ export function alignChanges(asset: V3Asset, part: string, reach: GizmoReach, tool: AlignTool): V3InputChange[] {
44
+ if (!reach.translate) return []
45
+ const box = boxOf(asset, part)
46
+ if (!box) return []
47
+ const value = (id: string) => asset.designInputs[id] ?? 0
48
+ const set = (input: string, next: number): V3InputChange => ({ kind: 'set-design', input, unit: 'mm', value: Math.round(next) })
49
+ const at = (axis: 'x' | 'y' | 'z') => `${part}.position.${axis}`
50
+ switch (tool) {
51
+ case 'centre-x':
52
+ return [set(at('x'), value(at('x')) - box.centre.x)]
53
+ case 'centre-z':
54
+ return [set(at('z'), value(at('z')) - box.centre.z)]
55
+ case 'floor':
56
+ return [set(at('y'), value(at('y')) - (box.centre.y - box.extent.y / 2))]
57
+ case 'snap': {
58
+ const step = gridStepOf(asset)
59
+ const round = (n: number) => Math.round(n / step) * step
60
+ const changes = AXES.map(axis => set(at(axis), round(value(at(axis)))))
61
+ /* A size never snaps to 0 — the part would look gone. */
62
+ if (reach.scale && reach.sizes)
63
+ for (const id of reach.sizes) changes.push(set(id, Math.max(step, round(value(id)))))
64
+ return changes
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Where a mirrored copy goes: the same distance on the other side of the centre plane. The copy is made by
71
+ * `duplicate-part` first; this is the position the copy is then given.
72
+ */
73
+ export function mirrorChanges(asset: V3Asset, part: string, copy: string, axis: 'x' | 'z'): V3InputChange[] {
74
+ const box = boxOf(asset, part)
75
+ if (!box) return []
76
+ const id = `${part}.position.${axis}`
77
+ return [{ kind: 'set-design', input: `${copy}.position.${axis}`, unit: 'mm', value: Math.round((asset.designInputs[id] ?? 0) - 2 * box.centre[axis]) }]
78
+ }
79
+
80
+ /** Mirroring a part that already sits on the centre plane would put the copy inside it — V2 refuses it too. */
81
+ export function canMirror(asset: V3Asset, part: string, axis: 'x' | 'z', reach: GizmoReach): boolean {
82
+ const box = reach.translate ? boxOf(asset, part) : undefined
83
+ return !!box && Math.abs(box.centre[axis]) >= 0.5
84
+ }