@things-factory/figure-ui 10.1.70 → 10.1.71

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 (67) hide show
  1. package/client/graphql/index.ts +28 -69
  2. package/client/modeller/figure-v3-parts.ts +16 -136
  3. package/client/modeller/figure-v3-recipe-catalog.ts +348 -0
  4. package/client/modeller/v3-editor.ts +25 -5
  5. package/client/modeller/v3-fields.ts +21 -16
  6. package/client/modeller/v3-gizmo.ts +1 -1
  7. package/client/modeller/v3-links.ts +7 -66
  8. package/client/modeller/v3-palette.ts +3 -0
  9. package/client/modeller/v3-recipe-source.ts +15 -0
  10. package/client/modeller/v3-recipes.ts +50 -27
  11. package/client/pages/figure-v3-modeller-page.ts +16 -13
  12. package/client/route.ts +0 -4
  13. package/dist-client/graphql/index.d.ts +11 -18
  14. package/dist-client/graphql/index.js +18 -59
  15. package/dist-client/graphql/index.js.map +1 -1
  16. package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
  17. package/dist-client/modeller/figure-v3-parts.js +17 -147
  18. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  19. package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +36 -0
  20. package/dist-client/modeller/figure-v3-recipe-catalog.js +382 -0
  21. package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
  22. package/dist-client/modeller/v3-editor.d.ts +8 -3
  23. package/dist-client/modeller/v3-editor.js +35 -6
  24. package/dist-client/modeller/v3-editor.js.map +1 -1
  25. package/dist-client/modeller/v3-fields.d.ts +2 -2
  26. package/dist-client/modeller/v3-fields.js +20 -18
  27. package/dist-client/modeller/v3-fields.js.map +1 -1
  28. package/dist-client/modeller/v3-gizmo.js +1 -1
  29. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  30. package/dist-client/modeller/v3-links.d.ts +8 -25
  31. package/dist-client/modeller/v3-links.js +4 -66
  32. package/dist-client/modeller/v3-links.js.map +1 -1
  33. package/dist-client/modeller/v3-palette.js +3 -0
  34. package/dist-client/modeller/v3-palette.js.map +1 -1
  35. package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
  36. package/dist-client/modeller/v3-recipe-source.js +14 -0
  37. package/dist-client/modeller/v3-recipe-source.js.map +1 -0
  38. package/dist-client/modeller/v3-recipes.d.ts +25 -14
  39. package/dist-client/modeller/v3-recipes.js +42 -28
  40. package/dist-client/modeller/v3-recipes.js.map +1 -1
  41. package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
  42. package/dist-client/pages/figure-v3-modeller-page.js +16 -13
  43. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  44. package/dist-client/route.d.ts +1 -1
  45. package/dist-client/route.js +0 -3
  46. package/dist-client/route.js.map +1 -1
  47. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  48. package/dist-client/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +5 -4
  50. package/scripts/template-thumbnails.mjs +53 -0
  51. package/test/browser/build.mjs +3 -3
  52. package/test/browser/modeller-page.js +25 -3
  53. package/test/browser/modeller.check.mjs +57 -47
  54. package/test/browser/thumbnail-page.js +14 -0
  55. package/test/v3-fields.test.ts +5 -1
  56. package/test/v3-links.test.ts +12 -0
  57. package/test/v3-recipes.test.ts +42 -25
  58. package/things-factory.config.js +0 -1
  59. package/translations/en.json +38 -23
  60. package/translations/ja.json +38 -23
  61. package/translations/ko.json +38 -23
  62. package/translations/ms.json +38 -23
  63. package/translations/zh.json +38 -23
  64. package/client/pages/figure-recipe-page.ts +0 -338
  65. package/dist-client/pages/figure-recipe-page.d.ts +0 -41
  66. package/dist-client/pages/figure-recipe-page.js +0 -352
  67. package/dist-client/pages/figure-recipe-page.js.map +0 -1
@@ -5,6 +5,8 @@ import '@operato/layout'
5
5
  import { customElement, property, state } from 'lit/decorators.js'
6
6
  import { live } from 'lit/directives/live.js'
7
7
  import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model'
8
+ import type { RecipeTemplate } from './v3-recipes.js'
9
+ import './figure-v3-recipe-catalog.js'
8
10
  import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
9
11
  import type { V3ProductSession } from './v3-product-session.js'
10
12
  import { captureV3Thumbnail } from './v3-thumbnail.js'
@@ -262,8 +264,12 @@ export class FigureV3Editor extends LitElement {
262
264
  }
263
265
  `]
264
266
  @property({ attribute: false }) session?: V3ProductSession
265
- /** The recipes this domain sees (ADR-0095), given by the page. */
266
- @property({ attribute: false }) recipes: V3Recipe[] = []
267
+ /** The recipe templates (ADR-0095), read by the page from the server. */
268
+ @property({ attribute: false }) templates: RecipeTemplate[] = []
269
+ /** Reads a template's guide; given by the page. */
270
+ @property({ attribute: false }) guideOf?: (name: string) => Promise<string>
271
+ @property({ attribute: false }) thumbnailOf?: (name: string) => string
272
+ @state() private catalogOpen = false
267
273
  /** Why the last recipe was refused, in the kernel's words. */
268
274
  @state() private recipeError = ''
269
275
  @property({ type: Number }) revision = 0
@@ -534,6 +540,7 @@ export class FigureV3Editor extends LitElement {
534
540
  const steps = resolveV3Recipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
535
541
  this.session!.applyRecipe(steps)
536
542
  this.recipeError = ''
543
+ this.catalogOpen = false
537
544
  this.error = ''
538
545
  this.changed()
539
546
  } catch (error) {
@@ -989,6 +996,8 @@ export class FigureV3Editor extends LitElement {
989
996
  private renderRepeat(asset: V3Asset | undefined, geometry: readonly { id: string }[], unavailable: boolean) {
990
997
  const repeat = repeatOf(this.selected, geometry)
991
998
  if (!asset || !repeat || repeat.index) return nothing
999
+ /* The row is set on its part; what hangs from that part is copied with it and has no row of its own. */
1000
+ if (!asset.document.model.nodes.some(n => n.id === `${this.selected}.repeat`)) return nothing
992
1001
  const model = asset.document.model as unknown as {
993
1002
  nodes: { id: string; op: string; args: string[]; params?: Record<string, string> }[]
994
1003
  constants: { id: string; value: number }[]
@@ -1143,6 +1152,18 @@ export class FigureV3Editor extends LitElement {
1143
1152
  </div>`
1144
1153
  : null}
1145
1154
  ${this.notice ? html`<div role="status" class="notice">${this.notice}</div>` : null}
1155
+ ${this.catalogOpen
1156
+ ? html`<figure-v3-recipe-catalog
1157
+ .templates=${this.templates}
1158
+ .selected=${this.selected}
1159
+ .error=${this.recipeError}
1160
+ .guideOf=${this.guideOf}
1161
+ .thumbnailOf=${this.thumbnailOf}
1162
+ ?locked=${unavailable}
1163
+ @insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}
1164
+ @close-catalog=${() => (this.catalogOpen = false)}
1165
+ ></figure-v3-recipe-catalog>`
1166
+ : null}
1146
1167
  <ox-three-pane>
1147
1168
  <figure-v3-parts
1148
1169
  slot="start"
@@ -1154,9 +1175,8 @@ export class FigureV3Editor extends LitElement {
1154
1175
  .selected=${this.selected}
1155
1176
  ?locked=${unavailable}
1156
1177
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
1157
- .recipes=${this.recipes}
1158
- .recipeError=${this.recipeError}
1159
- @insert-recipe=${(e: CustomEvent) => this.insertRecipe(e.detail.recipe, e.detail.values)}
1178
+ .recipeCount=${this.templates.length}
1179
+ @open-recipe-catalog=${() => ((this.catalogOpen = true), (this.recipeError = ''))}
1160
1180
  @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
1161
1181
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
1162
1182
  @set-all-parts-hidden=${(e: CustomEvent) =>
@@ -42,22 +42,25 @@ export function groupFields(fields: readonly FieldLike[], part?: string): { vect
42
42
  return { vectors, singles }
43
43
  }
44
44
 
45
+ /**
46
+ * The part a copy copies: the member at the end of its path. A row repeats its part and what is fastened to it
47
+ * (ADR-0093, 2026-09-26), so the copies of one row are of several parts -- a roller's and the mark on it.
48
+ */
49
+ const memberOf = (path: unknown[]): string => {
50
+ const last = [...path].reverse().find((step): step is string => typeof step === 'string')!
51
+ return last.replace(/\.(placed|value)$/, '').replace(/\.repeat$/, '')
52
+ }
53
+
45
54
  /**
46
55
  * The name a person reads for a body. A repeated body's id is its path through the repeats (`["roller.repeat",0,
47
- * "roller.value"]`), which the list showed as it was; it reads as the repeat's name and the member's place counted
48
- * from 1 -- `roller 1`, and `rack 2·3` inside two repeats. Any other id is its own name.
56
+ * "roller.placed"]`), which the list showed as it was; it reads as the part it copies and its place counted from 1 --
57
+ * `roller 1`, and `mark 1` for the mark the first roller carries. Any other id is its own name.
49
58
  */
50
59
  export function partLabelOf(id: string): string {
51
- if (!id.startsWith('[')) return id
52
- let path: unknown
53
- try {
54
- path = JSON.parse(id)
55
- } catch {
56
- return id
57
- }
58
- if (!Array.isArray(path) || typeof path[0] !== 'string') return id
60
+ const path = pathOf(id)
61
+ if (!path) return id
59
62
  const places = path.filter((step): step is number => typeof step === 'number').map(n => n + 1)
60
- return places.length ? `${path[0].replace(/\.repeat$/, '')} ${places.join('·')}` : id
63
+ return places.length ? `${memberOf(path)} ${places.join('·')}` : id
61
64
  }
62
65
 
63
66
  /**
@@ -66,14 +69,16 @@ export function partLabelOf(id: string): string {
66
69
  */
67
70
  export function repeatOf(id: string, geometry: readonly unknown[] | undefined): { name: string; count: number; index: number } | null {
68
71
  const path = pathOf(id)
72
+ const paths = (geometry ?? []).map(g => pathOf((g as { id?: string }).id ?? '')).filter((p): p is unknown[] => !!p)
69
73
  if (!path) {
70
- /* The repeat itself, by its name: how many copies it makes now. */
71
- const copies = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === `${id}.repeat`).length
74
+ /* The part itself, by its name: how many copies of it the row makes now. */
75
+ const copies = paths.filter(p => memberOf(p) === id).length
72
76
  return copies ? { name: id, count: copies, index: 0 } : null
73
77
  }
74
- const count = (geometry ?? []).filter(g => pathOf((g as { id?: string }).id ?? '')?.[0] === path[0]).length
78
+ const name = memberOf(path)
79
+ const count = paths.filter(p => p[0] === path[0] && memberOf(p) === name).length
75
80
  const index = path.find((step): step is number => typeof step === 'number')
76
- return { name: String(path[0]).replace(/\.repeat$/, ''), count, index: (index ?? 0) + 1 }
81
+ return { name, count, index: (index ?? 0) + 1 }
77
82
  }
78
83
 
79
84
  function pathOf(id: string): unknown[] | null {
@@ -93,6 +98,6 @@ function pathOf(id: string): unknown[] | null {
93
98
  */
94
99
  export function ownerOf(id: string): string {
95
100
  const path = pathOf(id)
96
- return path ? String(path[0]).replace(/\.repeat$/, '') : id
101
+ return path ? memberOf(path) : id
97
102
  }
98
103
 
@@ -30,7 +30,7 @@ export interface GizmoReach {
30
30
  }
31
31
 
32
32
  /** Shapes whose first three arguments are the extent along X, Y and Z: a door frame's too (its width, height, depth). */
33
- const BOXES = new Set(['box-shape@1', 'rounded-box@1', 'portal-profile@2'])
33
+ const BOXES = new Set(['box-shape@1', 'rounded-box@1', 'portal-profile@2', 'hollow-box@2'])
34
34
 
35
35
  interface Node {
36
36
  id: string
@@ -1,5 +1,5 @@
1
- import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Asset, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
- import type { V3SizeRange } from '@hatiolab/figure-model'
1
+ import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Asset, compileV3Graph, v3FollowOf, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
2
+ import type { V3Follow, V3SizeRange } from '@hatiolab/figure-model'
3
3
  import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
4
4
 
5
5
  /*
@@ -18,76 +18,17 @@ interface Node {
18
18
  const modelOf = (asset: V3Asset) =>
19
19
  asset.document.model as unknown as { nodes: Node[]; inputs: Array<{ id: string; role?: string; unit?: string; min?: number; max?: number }>; constants: Array<{ id: string; value: number }> }
20
20
 
21
- /** What one dimension of a part is worked out from. */
22
- export type Follow =
23
- | { of: 'own' }
24
- /** `times` 1 is the instance size itself; any other is a share of it. */
25
- | { of: 'instance'; axis: Axis; times: number; plus?: number }
26
- | { of: 'part'; part: string; axis: Axis; times?: number; plus?: number }
27
- | { of: 'input'; input: string }
28
- /** Worked out some other way (an expression) — shown, not offered as a choice. */
29
- | { of: 'expression' }
21
+ /** What one dimension of a part is worked out from (figure-model's `V3Follow`). */
22
+ export type Follow = V3Follow
30
23
 
31
24
  /**
32
25
  * `link-dimension` routes a part's dimension through `<part>.dimension.<axis>` and points its first argument at the
33
26
  * source, directly or through the nodes that scale it. Walking back through those nodes finds the source: the
34
27
  * instance size (`size.<axis>`), another part's dimension (`<part>.dimension.<axis>.value`) or a declared input.
35
28
  */
29
+ /** What one measure of a part is worked out from, read back from the graph (figure-model's reader, one copy). */
36
30
  export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
37
- const m = modelOf(asset)
38
- /*
39
- A link routes the dimension through `<part>.dimension.<axis>`; before any link the shape reads its measure directly,
40
- and that may already be worked out (ROBOT_ARM's flange fills what is left).
41
- */
42
- const node = m.nodes.find(n => n.id === `${part}.dimension.${axis}`)
43
- const start = node ? node.args[0] : m.nodes.find(n => n.id === `${part}.shape`)?.args[AXES.indexOf(axis)]
44
- if (!start || m.constants.some(c => c.id === start)) return { of: 'own' }
45
- const writerOf = (ref: string) => m.nodes.find(n => Object.values(n.outputs).includes(ref))
46
- const seen = new Set<string>()
47
- const constant = (ref: string) => m.constants.find(c => c.id === ref)
48
- /* The constant factors met on the way back: a share of the instance size is `size × share`. */
49
- let times = 1
50
- /*
51
- A link the author made is `source × times + plus` (link-dimension): its outermost sum with a number is the plus --
52
- a leaf the opening's width less its gaps. Deeper sums, or a measure no link routes, read as worked out elsewhere.
53
- */
54
- let plus = 0
55
- let at = start
56
- const outer = node && writerOf(start)
57
- if (outer?.op === 'add@1') {
58
- const numbers = outer.args.filter(arg => constant(arg))
59
- const onward = outer.args.filter(arg => !constant(arg))
60
- if (numbers.length === 1 && onward.length === 1) {
61
- plus = constant(numbers[0]!)!.value
62
- at = onward[0]!
63
- }
64
- }
65
- const withPlus = <T extends object>(f: T) => (plus ? { ...f, plus } : f)
66
- const walk = (ref: string): Follow => {
67
- if (seen.has(ref)) return { of: 'expression' }
68
- seen.add(ref)
69
- const instance = /^size\.([xyz])$/.exec(ref)
70
- if (instance) return withPlus({ of: 'instance' as const, axis: instance[1] as Axis, times: Math.round(times * 1e6) / 1e6 })
71
- const other = /^(.+)\.dimension\.([xyz])\.value$/.exec(ref)
72
- if (other) return withPlus({ of: 'part' as const, part: other[1]!, axis: other[2] as Axis, ...(times !== 1 ? { times: Math.round(times * 1e6) / 1e6 } : {}) })
73
- const input = m.inputs.find(i => i.id === ref)
74
- if (input) return ref.startsWith(`${part}.`) ? { of: 'own' } : { of: 'input', input: ref }
75
- const writer = writerOf(ref)
76
- if (!writer) return { of: 'expression' }
77
- /* Follow the one argument that is not a constant factor. */
78
- const onward = writer.args.filter(arg => !constant(arg))
79
- if (onward.length !== 1) return { of: 'expression' }
80
- /*
81
- Only a product is a share of its source. A sum with a number is not (「fill what is left」 is the size plus a negative
82
- gap -- ROBOT_ARM's tool flange), nor anything else; those read as worked out some other way.
83
- */
84
- const factors = writer.args.filter(arg => constant(arg)).map(arg => constant(arg)!.value)
85
- if (writer.op === 'mul@1') times *= factors.reduce((a, b) => a * b, 1)
86
- else if (writer.op === 'div@1' && writer.args[0] === onward[0] && factors.length === 1 && factors[0] !== 0) times /= factors[0]!
87
- else if (factors.some(v => v !== 0) || !['mul@1', 'div@1', 'add@1', 'sub@1'].includes(writer.op)) return { of: 'expression' }
88
- return walk(onward[0]!)
89
- }
90
- return walk(at)
31
+ return v3FollowOf(asset, part, axis)
91
32
  }
92
33
 
93
34
  /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
@@ -123,7 +64,7 @@ export function followableAxes(asset: V3Asset, part: string): Axis[] {
123
64
  const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
124
65
  if (!shape) return []
125
66
  /* A door frame's first three measures are its width, height and depth, as a box's (figure-model portal-profile@2). */
126
- if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1' || shape.op === 'portal-profile@2') return [...AXES]
67
+ if (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1' || shape.op === 'portal-profile@2' || shape.op === 'hollow-box@2') return [...AXES]
127
68
  if (shape.op === 'cylinder-shape@1') return ['x', 'y']
128
69
  return []
129
70
  }
@@ -34,6 +34,9 @@ export function paletteRecipe(kind: V3PaletteKind, id: string): V3PrimitiveCreat
34
34
  return { ...standing(10), kind: 'box', dimensions: { width: 600, height: 10, depth: 400 } }
35
35
  case 'wall':
36
36
  return { ...standing(2400), kind: 'box', dimensions: { width: 1000, height: 2400, depth: 100 } }
37
+ case 'hollow-box':
38
+ /* A tote at a tote's size: walls and floor that keep their thickness when it grows. */
39
+ return { ...standing(300), kind: 'hollow-box', dimensions: { width: 600, height: 300, depth: 400, roundX: 20, roundZ: 20, wallX: 10, wallZ: 10, floor: 15 } }
37
40
  case 'portal':
38
41
  return {
39
42
  id,
@@ -0,0 +1,15 @@
1
+ import { fetchRecipeGuide, fetchRecipeTemplates } from '../graphql/index.js'
2
+ import { templatesOf, type RecipeTemplate } from './v3-recipes.js'
3
+
4
+ /**
5
+ * Where the modeller gets its recipe templates: the server's static files (ADR-0095, amended 2026-09-26). The one place
6
+ * they are read -- the rules in `v3-recipes.ts` stay free of the network so they are tested without it.
7
+ */
8
+ export async function availableRecipes(lang: string): Promise<RecipeTemplate[]> {
9
+ return templatesOf(await fetchRecipeTemplates(lang))
10
+ }
11
+
12
+ /** One template's guide, or '' while none is written -- the screen then shows no guide and no word about one. */
13
+ export function recipeGuide(name: string, lang: string): Promise<string> {
14
+ return fetchRecipeGuide(name, lang)
15
+ }
@@ -1,22 +1,24 @@
1
+ import { describeV3Recipe } from '@hatiolab/figure-model'
1
2
  import type { V3Recipe, V3RecipeParam, V3RecipeValue } from '@hatiolab/figure-model'
2
3
 
4
+ import type { RecipeTemplateRow } from '../graphql/index.js'
5
+
6
+ /** A template in the catalog: the server's row, its recipe parsed. */
7
+ export interface RecipeTemplate extends Omit<RecipeTemplateRow, 'source'> {
8
+ recipe: V3Recipe
9
+ }
10
+
3
11
  /*
4
12
  * The recipe section's rules (ADR-0095), kept apart from the element so they can be tested without a browser.
5
13
  */
6
14
 
7
- /** A recipe's words in the language on screen: that language, else English, else the first it has. */
8
- export function recipeWord(words: Record<string, string> | undefined, language: string): string {
9
- if (!words) return ''
10
- return words[language] ?? words[language.split('-')[0]!] ?? words.en ?? Object.values(words)[0] ?? ''
11
- }
12
-
13
- /** The recipes as the server gave them, parsed; a row that is not a recipe is left out rather than breaking the list. */
14
- export function recipesOf(rows: ReadonlyArray<{ source: string }>): V3Recipe[] {
15
- const out: V3Recipe[] = []
16
- for (const row of rows) {
15
+ /** The server's rows as templates. A row that is not a recipe is left out rather than breaking the catalog. */
16
+ export function templatesOf(rows: ReadonlyArray<RecipeTemplateRow>): RecipeTemplate[] {
17
+ const out: RecipeTemplate[] = []
18
+ for (const { source, ...row } of rows) {
17
19
  try {
18
- const recipe = JSON.parse(row.source)
19
- if (recipe && typeof recipe.name === 'string' && Array.isArray(recipe.steps)) out.push(recipe)
20
+ const recipe = JSON.parse(source)
21
+ if (recipe && typeof recipe.name === 'string' && Array.isArray(recipe.steps)) out.push({ ...row, recipe })
20
22
  } catch {
21
23
  /* not a recipe */
22
24
  }
@@ -24,6 +26,34 @@ export function recipesOf(rows: ReadonlyArray<{ source: string }>): V3Recipe[] {
24
26
  return out
25
27
  }
26
28
 
29
+ /** A part the recipe makes, as the catalog names it: its own name, the placeholder left off. */
30
+ export const recipePartName = (part: string) => part.replace(/^\{id\}-/, '')
31
+
32
+ /**
33
+ * How a template works, in lines the catalog shows -- worked out from its steps (figure-model `describeV3Recipe`), each
34
+ * a translation key and its words.
35
+ */
36
+ export function recipeWorkings(recipe: V3Recipe): Array<{ key: string; words: Record<string, string | number> }> {
37
+ const d = describeV3Recipe(recipe)
38
+ const lines: Array<{ key: string; words: Record<string, string | number> }> = []
39
+ for (const m of d.moves)
40
+ lines.push(
41
+ m.follows
42
+ ? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader: recipePartName(m.follows) } }
43
+ : { key: m.kind === 'slide' ? 'figure.text.recipe-slides' : 'figure.text.recipe-turns', words: { part: recipePartName(m.part) } }
44
+ )
45
+ if (d.controls.length) lines.push({ key: 'figure.text.recipe-controls', words: { count: d.controls.length } })
46
+ if (d.shownByControl.length) lines.push({ key: 'figure.text.recipe-shown-by-control', words: { parts: d.shownByControl.map(recipePartName).join(', ') } })
47
+ for (const row of d.rows) lines.push({ key: 'figure.text.recipe-row', words: { part: recipePartName(row) } })
48
+ return lines
49
+ }
50
+
51
+ /** A recipe's words in the language on screen: that language, else English, else the first it has. */
52
+ export function recipeWord(words: Record<string, string> | undefined, language: string): string {
53
+ if (!words) return ''
54
+ return words[language] ?? words[language.split('-')[0]!] ?? words.en ?? Object.values(words)[0] ?? ''
55
+ }
56
+
27
57
  /** Whether a recipe can be put in now: one for a part needs a part chosen. */
28
58
  export function recipeReady(recipe: Pick<V3Recipe, 'target'>, selected: string): boolean {
29
59
  return recipe.target === 'figure' || !!selected
@@ -50,20 +80,13 @@ export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): strin
50
80
  return String(value)
51
81
  }
52
82
 
53
- /** The domain on screen, from the address (`/domain/<subdomain>/…`); '' when the address names none. */
54
- export function subdomainOf(pathname: string): string {
55
- return /^\/domain\/([^/]+)/.exec(pathname)?.[1] ?? ''
56
- }
57
-
58
- /** Whose a recipe row is: this domain's own (it may change it) or a parent's (copied in to be changed). */
59
- export function recipeIsOwn(row: { domain?: { subdomain?: string } }, subdomain: string): boolean {
60
- return !!subdomain && row.domain?.subdomain === subdomain
61
- }
62
83
 
63
- /** What a parameter takes, in a few words: a range, a percentage range, or its options. */
64
- export function paramRangeOf(param: V3RecipeParam): string {
65
- if (param.kind === 'ratio') return `${recipeFieldOf(param, param.min ?? 0)} – ${recipeFieldOf(param, param.max ?? 1)} %`
66
- if (param.kind === 'mm') return `${param.min ?? ''} – ${param.max ?? ''} mm`
67
- if (param.kind === 'flag') return ''
68
- return (param.options ?? []).join(' / ')
84
+ /** The templates a category and words find: a word matches the title, the description, the name or a tag. */
85
+ export function recipesMatching<T extends { category: string; title: string; description: string; name: string; tags: string[] }>(templates: readonly T[], category: string, query: string): T[] {
86
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
87
+ return templates.filter(t => {
88
+ if (category && t.category !== category) return false
89
+ const text = [t.title, t.description, t.name, ...t.tags].join(' ').toLowerCase()
90
+ return words.every(w => text.includes(w))
91
+ })
69
92
  }
@@ -1,13 +1,14 @@
1
1
  import { html, css } from 'lit'
2
2
  import { customElement, state } from 'lit/decorators.js'
3
3
  import { navigate, PageView } from '@operato/shell'
4
+ import { getPathInfo } from '@operato/utils'
4
5
  import { modellerPathOf } from '../modeller/modeller-route.js'
5
6
  import { createV3ProductSession } from '../modeller/v3-product-session.js'
6
7
  import { v3ProductStore } from '../graphql/v3-store.js'
7
8
  import { i18next } from '@operato/i18n'
8
- import { fetchFigure, fetchFigureRecipes, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
9
- import { recipesOf } from '../modeller/v3-recipes.js'
10
- import type { V3Recipe } from '@hatiolab/figure-model'
9
+ import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
10
+ import { availableRecipes, recipeGuide } from '../modeller/v3-recipe-source.js'
11
+ import type { RecipeTemplate } from '../modeller/v3-recipes.js'
11
12
  import { occupancyFromParts } from '../modeller/v3-occupancy.js'
12
13
  import type { V3AuthoringAction } from '@hatiolab/figure-model'
13
14
  import '../modeller/v3-editor.js'
@@ -95,9 +96,12 @@ export class FigureV3ModellerPage extends PageView {
95
96
  private routeId: string | undefined
96
97
  private discardApproved = false
97
98
  @state() private revision = 0
98
- /** The recipes this domain sees (ADR-0095), read once when the page is first shown. */
99
- @state() private recipes: V3Recipe[] = []
100
- private recipesRead = false
99
+ /** The recipe templates (ADR-0095), read once when the page is first shown; none read, and the section is not drawn. */
100
+ @state() private templates: RecipeTemplate[] = []
101
+ private templatesRead = false
102
+ private guideOf = (name: string) => recipeGuide(name, i18next.language || 'en')
103
+ /* The domain part of the address is read the way the shell reads it (figure-thumb does the same). */
104
+ private thumbnailOf = (name: string) => `${getPathInfo(location.pathname).contextPath}/figure-recipe-thumbnail/${name}`
101
105
  @state() private error = ''
102
106
  /**
103
107
  * 발행 패널이 읽는 행의 사실 — 상태와 발행 판 번호. 원본은 세션이 들고, 이 둘은 행에 있다.
@@ -376,12 +380,11 @@ export class FigureV3ModellerPage extends PageView {
376
380
  this.withdrawAssistant()
377
381
  return
378
382
  }
379
- if (!this.recipesRead) {
380
- this.recipesRead = true
381
- /* A recipe list that cannot be read leaves the section out; the modeller works without it. */
382
- fetchFigureRecipes()
383
- .then(rows => (this.recipes = recipesOf(rows)))
384
- .catch(() => (this.recipesRead = false))
383
+ if (!this.templatesRead) {
384
+ this.templatesRead = true
385
+ availableRecipes(i18next.language || 'en')
386
+ .then(templates => (this.templates = templates))
387
+ .catch(() => (this.templatesRead = false))
385
388
  }
386
389
  const id = lifecycle?.resourceId
387
390
  if (this.session.read().context && id === this.routeId && !changes.active) return
@@ -447,7 +450,7 @@ export class FigureV3ModellerPage extends PageView {
447
450
  render() {
448
451
  if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
449
452
  const view = this.session.read()
450
- return html`<figure-v3-editor .session=${this.session} .recipes=${this.recipes} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
453
+ return html`<figure-v3-editor .session=${this.session} .templates=${this.templates} .guideOf=${this.guideOf} .thumbnailOf=${this.thumbnailOf} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
451
454
  ${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
452
455
  <figure-v3-properties
453
456
  slot="figure"
package/client/route.ts CHANGED
@@ -4,10 +4,6 @@ export default function route(page: string) {
4
4
  import('./pages/figure-list-page')
5
5
  return page
6
6
 
7
- case 'figure-recipes':
8
- import('./pages/figure-recipe-page')
9
- return page
10
-
11
7
  /*
12
8
  One modeller since ADR-0087 step 5. `figure-v3-modeller` is kept as an alias for links made before the switch.
13
9
  */
@@ -65,24 +65,17 @@ export declare function inspectFigureDraft(source: string, id?: string): Promise
65
65
  */
66
66
  export declare function fetchReleasedSource(id: string): Promise<string | undefined>;
67
67
  export declare function fetchFigureVersions(id: string): Promise<FigureVersion[]>;
68
- /** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
69
- export declare function fetchFigureRecipes(): Promise<Array<{
68
+ /** A recipe template as the server gives it (ADR-0095, amended): words in the language asked, the recipe as JSON. */
69
+ export interface RecipeTemplateRow {
70
70
  name: string;
71
+ category: string;
72
+ tags: string[];
73
+ target: 'part' | 'figure';
74
+ title: string;
75
+ description: string;
71
76
  source: string;
72
- }>>;
73
- /** A recipe row as the management screen reads it: whose it is and whether it is released. */
74
- export interface FigureRecipeRow {
75
- name: string;
76
- source: string;
77
- state: 'draft' | 'released';
78
- updatedAt?: string;
79
- domain?: {
80
- id: string;
81
- subdomain?: string;
82
- };
77
+ hasThumbnail: boolean;
83
78
  }
84
- /** Every recipe this domain sees, drafts too (ADR-0095). */
85
- export declare function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]>;
86
- export declare function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow>;
87
- export declare function setFigureRecipeState(name: string, state: 'draft' | 'released'): Promise<void>;
88
- export declare function deleteFigureRecipe(name: string): Promise<void>;
79
+ export declare function fetchRecipeTemplates(lang: string): Promise<RecipeTemplateRow[]>;
80
+ /** One template's guide in the language asked, or '' while none is written. */
81
+ export declare function fetchRecipeGuide(name: string, lang: string): Promise<string>;
@@ -283,79 +283,38 @@ export async function fetchFigureVersions(id) {
283
283
  });
284
284
  return unwrap(response, 'figureVersions') ?? [];
285
285
  }
286
- /** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
287
- export async function fetchFigureRecipes() {
286
+ export async function fetchRecipeTemplates(lang) {
288
287
  const response = await client.query({
289
288
  query: gql `
290
- query {
291
- figureRecipes {
289
+ query ($lang: String) {
290
+ figureRecipeTemplates(lang: $lang) {
292
291
  name
292
+ category
293
+ tags
294
+ target
295
+ title
296
+ description
293
297
  source
298
+ hasThumbnail
294
299
  }
295
300
  }
296
301
  `,
297
- fetchPolicy: 'network-only'
302
+ variables: { lang }
298
303
  });
299
- return unwrap(response, 'figureRecipes') ?? [];
304
+ return unwrap(response, 'figureRecipeTemplates') ?? [];
300
305
  }
301
- /** Every recipe this domain sees, drafts too (ADR-0095). */
302
- export async function fetchFigureRecipeRows() {
306
+ /** One template's guide in the language asked, or '' while none is written. */
307
+ export async function fetchRecipeGuide(name, lang) {
303
308
  const response = await client.query({
304
309
  query: gql `
305
- query {
306
- figureRecipes(all: true) {
307
- name
308
- source
309
- state
310
- updatedAt
311
- domain {
312
- id
313
- subdomain
314
- }
310
+ query ($name: String!, $lang: String) {
311
+ figureRecipeTemplate(name: $name, lang: $lang) {
312
+ guide
315
313
  }
316
314
  }
317
315
  `,
318
- fetchPolicy: 'network-only'
319
- });
320
- return unwrap(response, 'figureRecipes') ?? [];
321
- }
322
- export async function saveFigureRecipe(source, state) {
323
- const response = await client.mutate({
324
- mutation: gql `
325
- mutation ($source: String!, $state: String) {
326
- saveFigureRecipe(source: $source, state: $state) {
327
- name
328
- source
329
- state
330
- }
331
- }
332
- `,
333
- variables: { source, state }
334
- });
335
- return unwrap(response, 'saveFigureRecipe');
336
- }
337
- export async function setFigureRecipeState(name, state) {
338
- const response = await client.mutate({
339
- mutation: gql `
340
- mutation ($name: String!, $state: String!) {
341
- setFigureRecipeState(name: $name, state: $state) {
342
- name
343
- }
344
- }
345
- `,
346
- variables: { name, state }
347
- });
348
- unwrap(response, 'setFigureRecipeState');
349
- }
350
- export async function deleteFigureRecipe(name) {
351
- const response = await client.mutate({
352
- mutation: gql `
353
- mutation ($name: String!) {
354
- deleteFigureRecipe(name: $name)
355
- }
356
- `,
357
- variables: { name }
316
+ variables: { name, lang }
358
317
  });
359
- unwrap(response, 'deleteFigureRecipe');
318
+ return unwrap(response, 'figureRecipeTemplate')?.guide ?? '';
360
319
  }
361
320
  //# sourceMappingURL=index.js.map