@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.
- package/client/graphql/index.ts +28 -69
- package/client/modeller/figure-v3-parts.ts +16 -136
- package/client/modeller/figure-v3-recipe-catalog.ts +348 -0
- package/client/modeller/v3-editor.ts +25 -5
- package/client/modeller/v3-fields.ts +21 -16
- package/client/modeller/v3-gizmo.ts +1 -1
- package/client/modeller/v3-links.ts +7 -66
- package/client/modeller/v3-palette.ts +3 -0
- package/client/modeller/v3-recipe-source.ts +15 -0
- package/client/modeller/v3-recipes.ts +50 -27
- package/client/pages/figure-v3-modeller-page.ts +16 -13
- package/client/route.ts +0 -4
- package/dist-client/graphql/index.d.ts +11 -18
- package/dist-client/graphql/index.js +18 -59
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
- package/dist-client/modeller/figure-v3-parts.js +17 -147
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +36 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js +382 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +8 -3
- package/dist-client/modeller/v3-editor.js +35 -6
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +2 -2
- package/dist-client/modeller/v3-fields.js +20 -18
- package/dist-client/modeller/v3-fields.js.map +1 -1
- package/dist-client/modeller/v3-gizmo.js +1 -1
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +8 -25
- package/dist-client/modeller/v3-links.js +4 -66
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-palette.js +3 -0
- package/dist-client/modeller/v3-palette.js.map +1 -1
- package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
- package/dist-client/modeller/v3-recipe-source.js +14 -0
- package/dist-client/modeller/v3-recipe-source.js.map +1 -0
- package/dist-client/modeller/v3-recipes.d.ts +25 -14
- package/dist-client/modeller/v3-recipes.js +42 -28
- package/dist-client/modeller/v3-recipes.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
- package/dist-client/pages/figure-v3-modeller-page.js +16 -13
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +0 -3
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/scripts/template-thumbnails.mjs +53 -0
- package/test/browser/build.mjs +3 -3
- package/test/browser/modeller-page.js +25 -3
- package/test/browser/modeller.check.mjs +57 -47
- package/test/browser/thumbnail-page.js +14 -0
- package/test/v3-fields.test.ts +5 -1
- package/test/v3-links.test.ts +12 -0
- package/test/v3-recipes.test.ts +42 -25
- package/things-factory.config.js +0 -1
- package/translations/en.json +38 -23
- package/translations/ja.json +38 -23
- package/translations/ko.json +38 -23
- package/translations/ms.json +38 -23
- package/translations/zh.json +38 -23
- package/client/pages/figure-recipe-page.ts +0 -338
- package/dist-client/pages/figure-recipe-page.d.ts +0 -41
- package/dist-client/pages/figure-recipe-page.js +0 -352
- 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
|
|
266
|
-
@property({ attribute: false })
|
|
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
|
-
.
|
|
1158
|
-
|
|
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.
|
|
48
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
71
|
-
const copies =
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
/**
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
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
|
-
/**
|
|
64
|
-
export function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
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
|
|
99
|
-
@state() private
|
|
100
|
-
private
|
|
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.
|
|
380
|
-
this.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
.
|
|
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} .
|
|
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
|
-
/**
|
|
69
|
-
export
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
export declare function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
variables: { lang }
|
|
298
303
|
});
|
|
299
|
-
return unwrap(response, '
|
|
304
|
+
return unwrap(response, 'figureRecipeTemplates') ?? [];
|
|
300
305
|
}
|
|
301
|
-
/**
|
|
302
|
-
export async function
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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, '
|
|
318
|
+
return unwrap(response, 'figureRecipeTemplate')?.guide ?? '';
|
|
360
319
|
}
|
|
361
320
|
//# sourceMappingURL=index.js.map
|