@things-factory/figure-ui 10.1.177 → 10.1.179

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 (91) hide show
  1. package/client/figure-released.ts +6 -0
  2. package/client/graphql/index.ts +101 -25
  3. package/client/graphql/store.ts +2 -1
  4. package/client/index.ts +1 -0
  5. package/client/modeller/editor.ts +10 -2
  6. package/client/modeller/figure-first-save.ts +23 -3
  7. package/client/modeller/figure-parts.ts +53 -23
  8. package/client/modeller/figure-properties.ts +41 -7
  9. package/client/modeller/first-save.ts +2 -0
  10. package/client/modeller/product-session.ts +5 -3
  11. package/client/modeller/property-sections.ts +45 -0
  12. package/client/pages/figure-list-page.ts +52 -10
  13. package/client/pages/figure-modeller-page.ts +15 -3
  14. package/client/setting-let/figure-namespace-setting-let.ts +113 -31
  15. package/client/types.ts +8 -0
  16. package/client/viewparts/figure-detail-dock.ts +124 -9
  17. package/client/viewparts/figure-thumb.ts +6 -3
  18. package/dist-client/figure-released.d.ts +3 -0
  19. package/dist-client/figure-released.js +5 -0
  20. package/dist-client/figure-released.js.map +1 -0
  21. package/dist-client/graphql/index.d.ts +25 -13
  22. package/dist-client/graphql/index.js +85 -16
  23. package/dist-client/graphql/index.js.map +1 -1
  24. package/dist-client/graphql/store.js +2 -1
  25. package/dist-client/graphql/store.js.map +1 -1
  26. package/dist-client/index.d.ts +1 -0
  27. package/dist-client/index.js +1 -0
  28. package/dist-client/index.js.map +1 -1
  29. package/dist-client/modeller/editor.d.ts +2 -0
  30. package/dist-client/modeller/editor.js +13 -2
  31. package/dist-client/modeller/editor.js.map +1 -1
  32. package/dist-client/modeller/figure-first-save.d.ts +8 -0
  33. package/dist-client/modeller/figure-first-save.js +28 -3
  34. package/dist-client/modeller/figure-first-save.js.map +1 -1
  35. package/dist-client/modeller/figure-links.d.ts +1 -1
  36. package/dist-client/modeller/figure-parts.d.ts +5 -0
  37. package/dist-client/modeller/figure-parts.js +54 -22
  38. package/dist-client/modeller/figure-parts.js.map +1 -1
  39. package/dist-client/modeller/figure-properties.d.ts +4 -0
  40. package/dist-client/modeller/figure-properties.js +45 -7
  41. package/dist-client/modeller/figure-properties.js.map +1 -1
  42. package/dist-client/modeller/first-save.d.ts +2 -0
  43. package/dist-client/modeller/first-save.js.map +1 -1
  44. package/dist-client/modeller/product-session.d.ts +4 -1
  45. package/dist-client/modeller/product-session.js +3 -3
  46. package/dist-client/modeller/product-session.js.map +1 -1
  47. package/dist-client/modeller/property-sections.d.ts +26 -0
  48. package/dist-client/modeller/property-sections.js +17 -0
  49. package/dist-client/modeller/property-sections.js.map +1 -0
  50. package/dist-client/pages/figure-list-page.d.ts +4 -1
  51. package/dist-client/pages/figure-list-page.js +56 -10
  52. package/dist-client/pages/figure-list-page.js.map +1 -1
  53. package/dist-client/pages/figure-modeller-page.d.ts +2 -0
  54. package/dist-client/pages/figure-modeller-page.js +19 -3
  55. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  56. package/dist-client/route.d.ts +1 -1
  57. package/dist-client/setting-let/figure-namespace-setting-let.d.ts +16 -5
  58. package/dist-client/setting-let/figure-namespace-setting-let.js +129 -31
  59. package/dist-client/setting-let/figure-namespace-setting-let.js.map +1 -1
  60. package/dist-client/tsconfig.tsbuildinfo +1 -1
  61. package/dist-client/types.d.ts +12 -0
  62. package/dist-client/types.js.map +1 -1
  63. package/dist-client/viewparts/figure-detail-dock.d.ts +33 -2
  64. package/dist-client/viewparts/figure-detail-dock.js +171 -11
  65. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  66. package/dist-client/viewparts/figure-thumb.d.ts +2 -0
  67. package/dist-client/viewparts/figure-thumb.js +13 -3
  68. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  69. package/package.json +8 -7
  70. package/test/browser/category-page.js +28 -0
  71. package/test/browser/category.check.mjs +54 -0
  72. package/test/browser/mesh-select.check.mjs +1 -1
  73. package/test/browser/namespace-page.js +75 -0
  74. package/test/browser/namespace.check.mjs +104 -0
  75. package/test/browser/parts-page.js +8 -2
  76. package/test/browser/places-mesh.check.mjs +30 -2
  77. package/test/browser/property-section-page.js +45 -0
  78. package/test/browser/property-section.check.mjs +48 -0
  79. package/test/browser/props.check.mjs +13 -2
  80. package/test/browser/public-page.js +39 -0
  81. package/test/browser/public.check.mjs +79 -0
  82. package/test/browser/retire-page.js +37 -0
  83. package/test/browser/retire.check.mjs +96 -0
  84. package/test/figure-released.test.ts +54 -0
  85. package/test/modeller-route.test.ts +2 -1
  86. package/translations/en.json +20 -5
  87. package/translations/es.json +20 -5
  88. package/translations/ja.json +20 -5
  89. package/translations/ko.json +20 -5
  90. package/translations/ms.json +20 -5
  91. package/translations/zh.json +20 -5
@@ -0,0 +1,6 @@
1
+ import type { Figure } from './types.js'
2
+
3
+ /** Whether a figure has ever been released: such a figure is retired, never deleted (the server refuses it too). */
4
+ export function hasBeenReleased(figure: Pick<Figure, 'version'>): boolean {
5
+ return Number(figure.version) > 0
6
+ }
@@ -1,6 +1,6 @@
1
1
  import gql from 'graphql-tag'
2
2
  import { i18next } from '@operato/i18n'
3
- import { client } from '@operato/graphql'
3
+ import { client, uploadContext, type UploadProgress } from '@operato/graphql'
4
4
 
5
5
  import { reached, refusedIn } from './reached.js'
6
6
 
@@ -57,6 +57,9 @@ const FIGURE_LIST_FIELDS = `
57
57
  tags
58
58
  state
59
59
  version
60
+ deprecated
61
+ supersededBy
62
+ public
60
63
  score
61
64
  triangles
62
65
  groups
@@ -77,12 +80,15 @@ export async function fetchFigureList(params: {
77
80
  limit?: number
78
81
  filters?: unknown[]
79
82
  sortings?: { name: string; desc?: boolean }[]
83
+ /** 'public': other domains' public figures, with their domain (LV figure-server design G2). The domain's own by default. */
84
+ scope?: 'own' | 'public'
80
85
  }): Promise<FigureListResult> {
86
+ const field = params.scope === 'public' ? 'publicFigures' : 'figures'
81
87
  const response = await client.query({
82
88
  query: gql`
83
89
  query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!]) {
84
- figures(filters: $filters, pagination: $pagination, sortings: $sortings) {
85
- items { ${FIGURE_LIST_FIELDS} }
90
+ ${field}(filters: $filters, pagination: $pagination, sortings: $sortings) {
91
+ items { ${FIGURE_LIST_FIELDS} ${params.scope === 'public' ? 'domain { id name subdomain }' : ''} }
86
92
  total
87
93
  }
88
94
  }
@@ -95,7 +101,7 @@ export async function fetchFigureList(params: {
95
101
  fetchPolicy: 'network-only'
96
102
  })
97
103
 
98
- return unwrap<FigureListResult>(response, 'figures')
104
+ return unwrap<FigureListResult>(response, field)
99
105
  }
100
106
 
101
107
  /** 저작면에 필요한 전부 — `source` 를 포함한다. */
@@ -174,6 +180,38 @@ export async function updateFigure(id: string, patch: FigurePatch): Promise<Save
174
180
  return unwrap<SaveResult>(response, 'updateFigure')
175
181
  }
176
182
 
183
+ /** Makes a released figure public to other domains, or private again (LV figure-server design G2). */
184
+ export async function setFigurePublic(id: string, isPublic: boolean): Promise<Figure> {
185
+ const response = await client.mutate({
186
+ mutation: gql`
187
+ mutation ($id: String!, $public: Boolean!) {
188
+ setFigurePublic(id: $id, public: $public) {
189
+ ${FIGURE_LIST_FIELDS}
190
+ }
191
+ }
192
+ `,
193
+ variables: { id, public: isPublic }
194
+ })
195
+
196
+ return unwrap<Figure>(response, 'setFigurePublic')
197
+ }
198
+
199
+ /** Retires a released figure, naming the type to use instead, or brings it back. Release rows are not touched. */
200
+ export async function setFigureDeprecation(id: string, deprecated: boolean, supersededBy?: string | null): Promise<Figure> {
201
+ const response = await client.mutate({
202
+ mutation: gql`
203
+ mutation ($id: String!, $deprecated: Boolean!, $supersededBy: String) {
204
+ setFigureDeprecation(id: $id, deprecated: $deprecated, supersededBy: $supersededBy) {
205
+ ${FIGURE_LIST_FIELDS}
206
+ }
207
+ }
208
+ `,
209
+ variables: { id, deprecated, supersededBy: supersededBy || null }
210
+ })
211
+
212
+ return unwrap<Figure>(response, 'setFigureDeprecation')
213
+ }
214
+
177
215
  export async function deleteFigure(id: string): Promise<boolean> {
178
216
  const response = await client.mutate({
179
217
  mutation: gql`
@@ -211,42 +249,80 @@ export async function releaseFigure(id: string, comment?: string): Promise<Figur
211
249
  return unwrap<Figure>(response, 'releaseFigure')
212
250
  }
213
251
 
214
- /** A domain's figure namespace as the setting screen shows it (ADR-0114). */
215
- export interface FigureNamespaceState {
216
- /** What new figures get; null when neither the setting nor the subdomain is a namespace. */
217
- namespace: string | null
218
- /** The setting as written; empty when the subdomain is used. */
219
- written: string
220
- subdomain: string
221
- /** The domain's figures carry it: it cannot be set any more. */
222
- fixed: boolean
252
+ /** A namespace the domain owns (LV figure-server design G1): the `{namespace}` of the full name `{namespace}/{type}`. */
253
+ export interface FigureNamespace {
254
+ id: string
255
+ name: string
256
+ description?: string | null
257
+ state: 'active' | 'suspended'
258
+ isDefault: boolean
223
259
  }
224
260
 
225
- const FIGURE_NAMESPACE_FIELDS = 'namespace written subdomain fixed'
261
+ const FIGURE_NAMESPACE_FIELDS = 'id name description state isDefault'
226
262
 
227
- export async function fetchFigureNamespace(): Promise<FigureNamespaceState> {
263
+ /** The categories the server allows, or null when a category is free text (LV figure-server design G5). */
264
+ export async function fetchFigureCategories(): Promise<string[] | null> {
228
265
  const response = await client.query({
229
266
  query: gql`
230
267
  query {
231
- figureNamespace { ${FIGURE_NAMESPACE_FIELDS} }
268
+ figureCategories
232
269
  }
233
270
  `,
234
271
  fetchPolicy: 'network-only'
235
272
  })
236
- return unwrap<FigureNamespaceState>(response, 'figureNamespace')
273
+ return unwrap<string[] | null>(response, 'figureCategories') ?? null
274
+ }
275
+
276
+ /** The domain's namespaces, its default first. */
277
+ export async function fetchFigureNamespaces(): Promise<FigureNamespace[]> {
278
+ const response = await client.query({
279
+ query: gql`
280
+ query {
281
+ figureNamespaces { ${FIGURE_NAMESPACE_FIELDS} }
282
+ }
283
+ `,
284
+ fetchPolicy: 'network-only'
285
+ })
286
+ return unwrap<FigureNamespace[]>(response, 'figureNamespaces') ?? []
287
+ }
288
+
289
+ /** Adds a namespace to the domain; the server says why when it refuses (not a namespace, another domain's). */
290
+ export async function createFigureNamespace(name: string, isDefault = false): Promise<FigureNamespace> {
291
+ const response = await client.mutate({
292
+ mutation: gql`
293
+ mutation ($name: String!, $isDefault: Boolean) {
294
+ createFigureNamespace(name: $name, isDefault: $isDefault) { ${FIGURE_NAMESPACE_FIELDS} }
295
+ }
296
+ `,
297
+ variables: { name, isDefault }
298
+ })
299
+ return unwrap<FigureNamespace>(response, 'createFigureNamespace')
300
+ }
301
+
302
+ /** Makes one of the domain's active namespaces its default. */
303
+ export async function setDefaultFigureNamespace(name: string): Promise<FigureNamespace> {
304
+ const response = await client.mutate({
305
+ mutation: gql`
306
+ mutation ($name: String!) {
307
+ setDefaultFigureNamespace(name: $name) { ${FIGURE_NAMESPACE_FIELDS} }
308
+ }
309
+ `,
310
+ variables: { name }
311
+ })
312
+ return unwrap<FigureNamespace>(response, 'setDefaultFigureNamespace')
237
313
  }
238
314
 
239
- /** Writes the namespace; empty takes the subdomain. The server says why when it refuses. */
240
- export async function setFigureNamespace(namespace: string): Promise<FigureNamespaceState> {
315
+ /** Suspends a namespace or makes it active again. */
316
+ export async function setFigureNamespaceState(name: string, state: 'active' | 'suspended'): Promise<FigureNamespace> {
241
317
  const response = await client.mutate({
242
318
  mutation: gql`
243
- mutation ($namespace: String!) {
244
- setFigureNamespace(namespace: $namespace) { ${FIGURE_NAMESPACE_FIELDS} }
319
+ mutation ($name: String!, $state: String!) {
320
+ setFigureNamespaceState(name: $name, state: $state) { ${FIGURE_NAMESPACE_FIELDS} }
245
321
  }
246
322
  `,
247
- variables: { namespace }
323
+ variables: { name, state }
248
324
  })
249
- return unwrap<FigureNamespaceState>(response, 'setFigureNamespace')
325
+ return unwrap<FigureNamespace>(response, 'setFigureNamespaceState')
250
326
  }
251
327
 
252
328
  /** A draft a revert kept when it wrote over it (ADR-0097 §5.6). Not a released version. */
@@ -518,7 +594,7 @@ export async function fetchRecipeGuide(name: string, lang: string): Promise<stri
518
594
  * A GLB for a mesh part (ADR-0129): the server reads it, keeps it by its hash and answers what it holds. A file a board
519
595
  * cannot draw is refused with the reason in the screen's words.
520
596
  */
521
- export async function uploadFigureMesh(file: File): Promise<import('../modeller/mesh-import.js').MeshUpload> {
597
+ export async function uploadFigureMesh(file: File, onProgress: UploadProgress): Promise<import('../modeller/mesh-import.js').MeshUpload> {
522
598
  const response = await client.mutate({
523
599
  mutation: gql`
524
600
  mutation UploadFigureMesh($file: Upload!) {
@@ -529,7 +605,7 @@ export async function uploadFigureMesh(file: File): Promise<import('../modeller/
529
605
  }
530
606
  `,
531
607
  variables: { file },
532
- context: { hasUpload: true }
608
+ context: uploadContext(onProgress)
533
609
  })
534
610
  return unwrap(response, 'uploadFigureMesh')
535
611
  }
@@ -34,7 +34,7 @@ export const figureProductStore: FigureProductStore = {
34
34
  async save(request) {
35
35
  const response = await reached(client.mutate({
36
36
  mutation: gql`
37
- mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!]) {
37
+ mutation SaveFigure($source: String!, $id: String, $expectedSource: String, $creationId: String, $thumbnail: String, $type: String, $proposalKeys: [String!], $namespace: String) {
38
38
  saveFigure(
39
39
  source: $source
40
40
  id: $id
@@ -43,6 +43,7 @@ export const figureProductStore: FigureProductStore = {
43
43
  thumbnail: $thumbnail
44
44
  type: $type
45
45
  proposalKeys: $proposalKeys
46
+ namespace: $namespace
46
47
  ) {
47
48
  id
48
49
  source
package/client/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './types.js'
2
2
  export * from './graphql/index.js'
3
3
  export * from './figure-assistant.js'
4
4
  export * from './setting-let/figure-namespace-setting-let.js'
5
+ export * from './modeller/property-sections.js'
@@ -124,6 +124,8 @@ export class FigureEditor extends LitElement {
124
124
  @property({ attribute: false }) firstSave?: (name: string) => Promise<FirstSave | undefined>
125
125
  @state() private selected = ''
126
126
  @state() private error = ''
127
+ /** How far the GLB being brought in has gone up (0‒1), while it does. */
128
+ @state() private meshProgress?: number
127
129
  /*
128
130
  * An edit that takes a face away which something stands on (ADR-0100 A8): held, with what the kernel says stands
129
131
  * there, until the author chooses where each goes -- then the same edit goes again with the remap. Shown where the
@@ -392,6 +394,7 @@ export class FigureEditor extends LitElement {
392
394
  be taken is not an error: the save goes on without it (see `thumbnail.ts`).
393
395
  */
394
396
  let type: string | undefined
397
+ let namespace: string | undefined
395
398
  if (!this.session.read().id && this.firstSave) {
396
399
  const answer = await this.firstSave(this.session.read().name ?? '')
397
400
  if (!answer) return
@@ -401,10 +404,11 @@ export class FigureEditor extends LitElement {
401
404
  this.changed()
402
405
  }
403
406
  type = answer.type
407
+ namespace = answer.namespace
404
408
  }
405
409
  const view = this.session.read()
406
410
  const thumbnail = await captureFigureThumbnail(drawableFigureParts(view.geometry ?? []), view.palette ?? {})
407
- const promise = this.session.save({ thumbnail, type })
411
+ const promise = this.session.save({ thumbnail, type, namespace })
408
412
  this.changed()
409
413
  await promise
410
414
  this.error = ''
@@ -432,11 +436,14 @@ export class FigureEditor extends LitElement {
432
436
  private async importMesh(file: File) {
433
437
  if (!this.session) return
434
438
  let upload: Awaited<ReturnType<typeof uploadFigureMesh>>
439
+ this.meshProgress = 0
435
440
  try {
436
- upload = await uploadFigureMesh(file)
441
+ upload = await uploadFigureMesh(file, fraction => (this.meshProgress = fraction))
437
442
  } catch (error) {
438
443
  this.error = this.said(error)
439
444
  return
445
+ } finally {
446
+ this.meshProgress = undefined
440
447
  }
441
448
  const choices = meshChoicesOf(upload)
442
449
  /* Only the whole file and the one node that is all of it: there is nothing to choose between. */
@@ -1710,6 +1717,7 @@ export class FigureEditor extends LitElement {
1710
1717
  ?locked=${unavailable}
1711
1718
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
1712
1719
  @import-mesh=${(e: CustomEvent) => this.importMesh(e.detail.file)}
1720
+ .meshProgress=${this.meshProgress}
1713
1721
  .recipeCount=${this.templates.length}
1714
1722
  @open-recipe-catalog=${() => ((this.catalogOpen = true), (this.recipeError = ''))}
1715
1723
  @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
@@ -31,11 +31,12 @@ export class FigureFirstSave extends LitElement {
31
31
  font-size: var(--popup-font-size-small);
32
32
  color: var(--md-sys-color-on-surface-variant);
33
33
  }
34
- div[field] input {
34
+ div[field] input,
35
+ div[field] select {
35
36
  box-sizing: border-box;
36
37
  width: 100%;
37
38
  }
38
- input[type-field] {
39
+ [type-field] {
39
40
  font-family: var(--ui-mono, ui-monospace, monospace);
40
41
  }
41
42
  `
@@ -46,6 +47,9 @@ export class FigureFirstSave extends LitElement {
46
47
  @property() name = ''
47
48
  /** Type names already used in this domain. */
48
49
  @property({ attribute: false }) taken: string[] = []
50
+ /** The domain's active namespaces, its default first. Asked only when there are several (LV figure-server design G1). */
51
+ @property({ attribute: false }) namespaces: { name: string; isDefault: boolean }[] = []
52
+ @state() private namespace = ''
49
53
  @state() private refusal: { key: string; type?: string } | null = null
50
54
  /** The identifier offered, while the author has not changed it. */
51
55
  @state() private suggested = ''
@@ -105,6 +109,16 @@ export class FigureFirstSave extends LitElement {
105
109
  ? html`<div class="note">${i18next.t('figure.text.identifier-suggested')}</div>`
106
110
  : nothing}
107
111
  </div>
112
+ ${this.namespaces.length > 1
113
+ ? html`<div field>
114
+ <label for="namespace">${i18next.t('figure.label.namespace')}</label>
115
+ <select id="namespace" type-field @change=${(e: Event) => (this.namespace = (e.target as HTMLSelectElement).value)}>
116
+ ${this.namespaces.map(
117
+ namespace => html`<option value=${namespace.name} ?selected=${namespace.name === this.chosenNamespace}>${namespace.name}</option>`
118
+ )}
119
+ </select>
120
+ </div>`
121
+ : nothing}
108
122
  ${this.refusal ? html`<div class="failure" role="alert">${i18next.t(this.refusal.key, { type: this.refusal.type })}</div>` : nothing}
109
123
  <ox-popup-actions>
110
124
  <md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.cancel')}</md-text-button>
@@ -120,6 +134,12 @@ export class FigureFirstSave extends LitElement {
120
134
  private confirm = () => {
121
135
  this.refusal = firstSaveRefusalOf(this.type, this.taken)
122
136
  if (this.refusal) return
123
- closePopup(this, { type: this.type, name: this.name.trim() || this.type } satisfies FirstSave)
137
+ const namespace = this.namespaces.length > 1 ? this.chosenNamespace : undefined
138
+ closePopup(this, { type: this.type, name: this.name.trim() || this.type, ...(namespace ? { namespace } : {}) } satisfies FirstSave)
139
+ }
140
+
141
+ /** The namespace chosen, or the domain's default until one is. */
142
+ private get chosenNamespace(): string {
143
+ return this.namespace || this.namespaces.find(namespace => namespace.isDefault)?.name || this.namespaces[0]?.name || ''
124
144
  }
125
145
  }
@@ -1,6 +1,7 @@
1
1
  import '@material/web/icon/icon.js'
2
+ import '@operato/input/ox-input-file.js'
2
3
  import { css, html, LitElement, nothing } from 'lit'
3
- import { customElement, property } from 'lit/decorators.js'
4
+ import { customElement, property, query } from 'lit/decorators.js'
4
5
  import { i18next, localize } from '@operato/i18n'
5
6
  import { ScrollbarStyles } from '@operato/styles'
6
7
  import { DETAIL_LEVELS, PART_LIMIT, type DetailLevel } from '@hatiolab/figure-model'
@@ -263,13 +264,38 @@ export class FigureParts extends localize(i18next)(LitElement) {
263
264
  overflow: hidden;
264
265
  text-overflow: ellipsis;
265
266
  }
266
- button[shape][import] {
267
+ /*
268
+ The one file picker (ox-input-file, quiet) on that row: a GLB refused or going up is said on the spot. Its button
269
+ wears the tiles' words and lit hand -- the picker's own 13/600 stood louder than the shapes' heading.
270
+ */
271
+ ox-input-file[mesh-file] {
267
272
  grid-column: 1 / -1;
268
- flex-direction: row;
269
273
  justify-content: center;
274
+ font-size: var(--ui-t-micro, 11px);
275
+ --ox-input-file-action-height: 26px;
276
+ }
277
+ ox-input-file[mesh-file]::part(attach) {
270
278
  gap: 6px;
271
- min-height: 26px;
272
279
  padding: 0 8px;
280
+ font: var(--label-font, inherit);
281
+ font-size: var(--ui-t-micro, 11px);
282
+ font-weight: 400;
283
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
284
+ border-radius: 4px;
285
+ }
286
+ ox-input-file[mesh-file]:not([disabled])::part(attach):hover {
287
+ color: var(--md-sys-color-on-primary-container);
288
+ background-color: var(--md-sys-color-primary-container);
289
+ }
290
+ ox-input-file[mesh-file][disabled]::part(attach) {
291
+ opacity: 0.35;
292
+ }
293
+ ox-input-file[mesh-file] md-icon {
294
+ --md-icon-size: 18px;
295
+ }
296
+ section[shapes].candrop {
297
+ outline: 1px dashed var(--md-sys-color-primary);
298
+ outline-offset: -1px;
273
299
  }
274
300
  /* An action in the side panel's look: icon and words in the primary colour, no pill. */
275
301
  button[recipe-catalog] {
@@ -460,12 +486,22 @@ export class FigureParts extends localize(i18next)(LitElement) {
460
486
  @property({ attribute: false }) partBudget?: { level: DetailLevel | null; used: number; limit: number | null }
461
487
  /** How many recipe templates the catalog holds; none, and the section is not drawn. */
462
488
  @property({ type: Number }) recipeCount = 0
489
+ /** How far the GLB being brought in has gone up (0‒1), while it does; the picker shows it. */
490
+ @property({ type: Number }) meshProgress?: number
491
+
492
+ /* A GLB dragged over the shapes is a part brought in: the section is where it is dropped (page standard §7-1). */
493
+ @query('section[shapes]') private shapes?: HTMLElement
494
+
495
+ firstUpdated() {
496
+ /* The drop area is drawn by the first render; the picker is told it by the next. */
497
+ this.requestUpdate()
498
+ }
463
499
 
464
500
  render() {
465
501
  const b = this.partBudget
466
502
  const full = !!b && b.limit !== null && b.used >= b.limit
467
503
  return html`
468
- <section>
504
+ <section shapes>
469
505
  <h3>${i18next.t('figure.label.shapes')}</h3>
470
506
  <div palette>
471
507
  ${RECIPES.map(
@@ -481,28 +517,22 @@ export class FigureParts extends localize(i18next)(LitElement) {
481
517
  </button>
482
518
  `
483
519
  )}
484
- <button
485
- shape
486
- import
487
- ?disabled=${this.locked || full}
488
- title=${full ? i18next.t('figure.text.part-limit-reached') : i18next.t('figure.text.import-mesh-hint')}
489
- @click=${() => this.renderRoot.querySelector<HTMLInputElement>('input[mesh-file]')?.click()}
490
- >
491
- <md-icon>view_in_ar</md-icon>
492
- <span>${i18next.t('figure.button.import-mesh')}</span>
493
- </button>
494
- <input
520
+ <ox-input-file
521
+ quiet
522
+ hide-filelist
495
523
  mesh-file
496
- type="file"
497
524
  accept=".glb,model/gltf-binary"
498
- hidden
499
- @change=${(e: Event) => {
500
- const input = e.target as HTMLInputElement
501
- const file = input.files?.[0]
502
- input.value = ''
525
+ .label=${i18next.t('figure.button.import-mesh')}
526
+ title=${full ? i18next.t('figure.text.part-limit-reached') : i18next.t('figure.text.import-mesh-hint')}
527
+ ?disabled=${this.locked || full}
528
+ .dropTarget=${this.shapes}
529
+ .progress=${this.meshProgress}
530
+ @change=${(e: CustomEvent) => {
531
+ const file = (e.detail as File[] | undefined)?.[0]
503
532
  if (file) this.ask('import-mesh', { file })
533
+ ;(e.currentTarget as HTMLElement & { reset(): void }).reset()
504
534
  }}
505
- />
535
+ ><md-icon slot="icon">view_in_ar</md-icon></ox-input-file>
506
536
  </div>
507
537
  ${full ? html`<p full>${i18next.t('figure.text.part-limit-reached-short', { level: b!.level, limit: b!.limit })}</p>` : nothing}
508
538
  </section>
@@ -12,6 +12,7 @@ import { occupancyFromParts } from './occupancy.js'
12
12
  import { tagsOf, figureFigureFacts, type Placement, type FigureFigureFacts } from './figure-facts.js'
13
13
  import { offeredMountingOf } from './mounting-part.js'
14
14
  import type { FigureRevision } from './revision.js'
15
+ import { figurePropertySections } from './property-sections.js'
15
16
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
16
17
  import { ScrollbarStyles } from '@operato/styles'
17
18
 
@@ -65,6 +66,8 @@ export class FigureProperties extends localize(i18next)(LitElement) {
65
66
  /** The saved row, if any. Category and tags live on it. */
66
67
  @property({ type: String }) figureId?: string
67
68
  @property({ type: String }) figureCategory?: string
69
+ /** The categories the server allows (LV figure-server design G5): chosen from, and fixed once set. Null: free text. */
70
+ @property({ attribute: false }) figureCategories?: string[] | null
68
71
  @property({ type: Array }) figureTags?: string[]
69
72
  /** The part and face picked for the mounting face, before 「이 면으로」 is pressed. */
70
73
  @state() private pickedPart?: string
@@ -127,7 +130,8 @@ export class FigureProperties extends localize(i18next)(LitElement) {
127
130
  <section>
128
131
  <h3>${i18next.t('figure.label.group-catalog')}</h3>
129
132
  ${this.renderCatalog()}
130
- </section>`
133
+ </section>
134
+ ${this.renderAppSections()}`
131
135
  }
132
136
 
133
137
  private renderName(facts: FigureFigureFacts) {
@@ -226,17 +230,47 @@ export class FigureProperties extends localize(i18next)(LitElement) {
226
230
  `
227
231
  }
228
232
 
233
+ /** The sections an app added (G11), after the panel's own, for the figures they are for. A section that throws says so in its place. */
234
+ private renderAppSections() {
235
+ const asset = this.revision?.asset
236
+ if (!asset) return nothing
237
+ const figure = { id: this.figureId, category: this.figureCategory }
238
+ return figurePropertySections().map(section => {
239
+ try {
240
+ if (!section.when({ asset, figure })) return nothing
241
+ return html`<section data-section=${section.id}>
242
+ <h3>${section.title()}</h3>
243
+ ${section.render({ asset, figure, command: action => this.command(action) })}
244
+ </section>`
245
+ } catch (error) {
246
+ console.error(`[figure-ui] the property section '${section.id}' failed`, error)
247
+ return html`<section data-section=${section.id}>
248
+ <p role="alert">${i18next.t('figure.text.property-section-failed', { section: section.id, reason: (error as Error)?.message ?? String(error) })}</p>
249
+ </section>`
250
+ }
251
+ })
252
+ }
253
+
229
254
  private renderCatalog() {
230
255
  const saved = !!this.figureId
231
256
  return html`
232
257
  <div field>
233
258
  <label>${i18next.t('figure.label.category')}</label>
234
- <input
235
- .value=${this.figureCategory ?? ''}
236
- ?disabled=${!saved}
237
- placeholder=${i18next.t('figure.text.category-placeholder')}
238
- @change=${(e: Event) => this.catalog({ category: (e.target as HTMLInputElement).value.trim() })}
239
- />
259
+ ${this.figureCategories?.length
260
+ ? html`<select
261
+ category
262
+ ?disabled=${!saved || !!this.figureCategory}
263
+ @change=${(e: Event) => this.catalog({ category: (e.target as HTMLSelectElement).value })}
264
+ >
265
+ ${this.figureCategory ? nothing : html`<option value="" selected disabled>${i18next.t('figure.text.category-choose')}</option>`}
266
+ ${this.figureCategories.map(code => html`<option value=${code} ?selected=${code === this.figureCategory}>${code}</option>`)}
267
+ </select>`
268
+ : html`<input
269
+ .value=${this.figureCategory ?? ''}
270
+ ?disabled=${!saved}
271
+ placeholder=${i18next.t('figure.text.category-placeholder')}
272
+ @change=${(e: Event) => this.catalog({ category: (e.target as HTMLInputElement).value.trim() })}
273
+ />`}
240
274
  </div>
241
275
  <div field>
242
276
  <label>${i18next.t('figure.label.tags')}</label>
@@ -69,4 +69,6 @@ export function firstSaveRefusalOf(type: string, taken: readonly string[]): { ke
69
69
  export interface FirstSave {
70
70
  type: string
71
71
  name: string
72
+ /** The domain's namespace to create it under, when the domain has several; its default when absent. */
73
+ namespace?: string
72
74
  }
@@ -47,6 +47,8 @@ export interface FigureProductStore {
47
47
  thumbnail?: string
48
48
  /** The type name a new figure is created under (the first save asks for it). Not sent for an existing one. */
49
49
  type?: string
50
+ /** The namespace a new figure is created under, when the first save chose one. Not sent for an existing one. */
51
+ namespace?: string
50
52
  /** Keys of the AI proposals whose steps the source holds: the server records them with the save. */
51
53
  proposalKeys?: string[]
52
54
  }): Promise<FigureProductRecord>
@@ -280,7 +282,7 @@ export function createFigureProductSession(store: FigureProductStore) {
280
282
  * nothing about drawing. A save with no picture is a save: a tab in the background cannot draw, and the
281
283
  * source matters more than the card (see `thumbnail.ts`).
282
284
  */
283
- async save({ thumbnail, type }: { thumbnail?: string; type?: string } = {}) {
285
+ async save({ thumbnail, type, namespace }: { thumbnail?: string; type?: string; namespace?: string } = {}) {
284
286
  const control = writable(),
285
287
  requestEpoch = epoch
286
288
  const exported = workspace.export(control)
@@ -290,7 +292,7 @@ export function createFigureProductSession(store: FigureProductStore) {
290
292
  try {
291
293
  let record: FigureProductRecord
292
294
  try {
293
- record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }) })
295
+ record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
294
296
  } catch (error) {
295
297
  /*
296
298
  A save that failed may still have landed: the answer lost on the way back (SAVE_UNREACHABLE), or a second
@@ -323,7 +325,7 @@ export function createFigureProductSession(store: FigureProductStore) {
323
325
  */
324
326
  baseline = landed.source
325
327
  unconfirmed.clear()
326
- record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }) })
328
+ record = await store.save({ id, source: exported.text, expectedSource: baseline, creationId, thumbnail, proposalKeys, ...(id || !type ? {} : { type }), ...(id || !namespace ? {} : { namespace }) })
327
329
  } else {
328
330
  if (code === 'SAVE_UNREACHABLE') unconfirmed.set(exported.text, { thumbnail })
329
331
  throw error
@@ -0,0 +1,45 @@
1
+ import type { TemplateResult } from 'lit'
2
+ import type { FigureAsset, FigureAuthoringAction } from '@hatiolab/figure-model'
3
+
4
+ /*
5
+ * Sections an app adds to the modeller's properties panel (LV figure-server design G11). The panel draws them after its
6
+ * own, in the order registered, for the figures they are for. A section changes the figure only by the modeller's
7
+ * own commands -- one step of the history each, undone like any other -- so an app's section is no second editor. The
8
+ * LV figure-server's CARRIER section writes the `fmsim/carrier` extension with `set-extension` (ADR-0123).
9
+ */
10
+
11
+ /** What a section is shown, and the one way it changes the figure. */
12
+ export interface FigurePropertySectionContext {
13
+ asset: FigureAsset
14
+ /** The figure's row: its id once saved, its category. */
15
+ figure: { id?: string; category?: string }
16
+ /** Runs one modeller command, as the panel's own fields do. */
17
+ command(action: FigureAuthoringAction): void
18
+ }
19
+
20
+ export interface FigurePropertySection {
21
+ /** Unique among sections; drawn as the section's `data-section`. */
22
+ id: string
23
+ /** The heading over the section. */
24
+ title(): string
25
+ /** Whether this figure gets the section, e.g. by its category. */
26
+ when(context: Omit<FigurePropertySectionContext, 'command'>): boolean
27
+ render(context: FigurePropertySectionContext): TemplateResult
28
+ }
29
+
30
+ const sections: FigurePropertySection[] = []
31
+
32
+ /** Adds a section to the properties panel. Returns the way to take it off again. A second section of one id is refused. */
33
+ export function registerFigurePropertySection(section: FigurePropertySection): () => void {
34
+ if (sections.some(known => known.id === section.id)) throw new Error(`a figure property section '${section.id}' is already registered`)
35
+ sections.push(section)
36
+ return () => {
37
+ const at = sections.indexOf(section)
38
+ if (at >= 0) sections.splice(at, 1)
39
+ }
40
+ }
41
+
42
+ /** The sections registered, in their order. */
43
+ export function figurePropertySections(): readonly FigurePropertySection[] {
44
+ return sections
45
+ }