@things-factory/figure-ui 10.1.68 → 10.1.70
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 +89 -0
- package/client/modeller/figure-v3-links.ts +73 -7
- package/client/modeller/figure-v3-parts.ts +153 -2
- package/client/modeller/v3-editor.ts +133 -5
- package/client/modeller/v3-gizmo.ts +18 -2
- package/client/modeller/v3-links.ts +71 -2
- package/client/modeller/v3-palette.ts +2 -1
- package/client/modeller/v3-product-session.ts +8 -0
- package/client/modeller/v3-recipes.ts +69 -0
- package/client/pages/figure-recipe-page.ts +338 -0
- package/client/pages/figure-v3-modeller-page.ts +25 -2
- package/client/route.ts +4 -0
- package/dist-client/graphql/index.d.ts +21 -0
- package/dist-client/graphql/index.js +75 -0
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
- package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
- package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
- package/dist-client/modeller/figure-v3-links.d.ts +11 -1
- package/dist-client/modeller/figure-v3-links.js +69 -8
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +15 -1
- package/dist-client/modeller/figure-v3-parts.js +166 -1
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
- package/dist-client/modeller/v3-clips.d.ts +1 -1
- package/dist-client/modeller/v3-editor.d.ts +26 -0
- package/dist-client/modeller/v3-editor.js +147 -4
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-gizmo.d.ts +6 -0
- package/dist-client/modeller/v3-gizmo.js +20 -2
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +27 -0
- package/dist-client/modeller/v3-links.js +68 -2
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-palette.js +2 -1
- package/dist-client/modeller/v3-palette.js.map +1 -1
- package/dist-client/modeller/v3-product-session.d.ts +34 -0
- package/dist-client/modeller/v3-product-session.js +8 -0
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-recipes.d.ts +26 -0
- package/dist-client/modeller/v3-recipes.js +69 -0
- package/dist-client/modeller/v3-recipes.js.map +1 -0
- package/dist-client/pages/figure-recipe-page.d.ts +41 -0
- package/dist-client/pages/figure-recipe-page.js +352 -0
- package/dist-client/pages/figure-recipe-page.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
- package/dist-client/pages/figure-v3-modeller-page.js +29 -2
- 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 +3 -0
- 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 -5
- package/test/browser/modeller-page.js +15 -1
- package/test/browser/modeller.check.mjs +217 -2
- package/test/modeller-route.test.ts +9 -0
- package/test/v3-gizmo.test.ts +44 -0
- package/test/v3-links.test.ts +91 -1
- package/test/v3-recipes.test.ts +60 -0
- package/things-factory.config.js +1 -0
- package/translations/en.json +40 -8
- package/translations/ja.json +40 -8
- package/translations/ko.json +40 -8
- package/translations/ms.json +40 -8
- package/translations/zh.json +40 -8
|
@@ -29,8 +29,8 @@ export interface GizmoReach {
|
|
|
29
29
|
round?: { across: string[]; along: string[] }
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Shapes whose first three arguments are the extent along X, Y and Z. */
|
|
33
|
-
const BOXES = new Set(['box-shape@1', 'rounded-box@1'])
|
|
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'])
|
|
34
34
|
|
|
35
35
|
interface Node {
|
|
36
36
|
id: string
|
|
@@ -138,3 +138,19 @@ export function gizmoChanges(part: string, reach: GizmoReach, result: GizmoResul
|
|
|
138
138
|
}
|
|
139
139
|
return changes
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A height typed into its field keeps the part's bottom where it is, as the scale gizmo does (it anchors the opposite
|
|
144
|
+
* face): the part's own Y position moves by half the change. A post typed 2200 tall sank 933 mm under the floor
|
|
145
|
+
* (2026-09-26). Only for a part placed by its own numbers and standing unturned; otherwise its height is not up and down.
|
|
146
|
+
*/
|
|
147
|
+
export function keepBottomChanges(part: string, reach: GizmoReach, input: string, value: number, values: Record<string, number>): V3InputChange[] {
|
|
148
|
+
if (!reach.translate) return []
|
|
149
|
+
const height = reach.sizes?.[1] ?? reach.ownSizes?.[1] ?? reach.round?.along[0]
|
|
150
|
+
if (!height || height !== input) return []
|
|
151
|
+
if (AXES.some(axis => (values[`${part}.rotation.${axis}`] ?? 0) % 360 !== 0)) return []
|
|
152
|
+
const before = values[input],
|
|
153
|
+
y = values[`${part}.position.y`]
|
|
154
|
+
if (before === undefined || y === undefined || !Number.isFinite(value)) return []
|
|
155
|
+
return [{ kind: 'set-design', input: `${part}.position.y`, unit: 'mm', value: Math.round((y + (value - before) / 2) * 10) / 10 } as V3InputChange]
|
|
156
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { V3_FACES, v3ShapeFaces, v3PartsExtentOf, compileV3Asset, compileV3Graph, proposeV3Proportion, defaultInstanceSizeRange, foldInstanceSizeRange, v3AttachmentsOf, v3BindingsOf, v3InstanceSizeLimitsOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { V3SizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { V3Asset, V3BindingPlace, V3Face, V3Joint } from '@hatiolab/figure-model'
|
|
4
4
|
|
|
@@ -122,7 +122,8 @@ export function canFollow(asset: V3Asset, part: string): boolean {
|
|
|
122
122
|
export function followableAxes(asset: V3Asset, part: string): Axis[] {
|
|
123
123
|
const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
|
|
124
124
|
if (!shape) return []
|
|
125
|
-
|
|
125
|
+
/* 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]
|
|
126
127
|
if (shape.op === 'cylinder-shape@1') return ['x', 'y']
|
|
127
128
|
return []
|
|
128
129
|
}
|
|
@@ -210,6 +211,74 @@ export function alignAxesOf(asset: V3Asset, part: string, target: { part: string
|
|
|
210
211
|
return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/** The controls a part's showing can follow: the figure's state inputs, with their range and label. */
|
|
215
|
+
export function visibilityControlsOf(asset: V3Asset): Array<{ id: string; unit: string; min: number; max: number; label: string }> {
|
|
216
|
+
const labels = (asset as unknown as { stateInputs?: Record<string, { label?: string }> }).stateInputs ?? {}
|
|
217
|
+
return modelOf(asset)
|
|
218
|
+
.inputs.filter(input => input.role === 'state')
|
|
219
|
+
.map(input => ({ id: input.id, unit: input.unit ?? '', min: input.min ?? 0, max: input.max ?? 1, label: labels[input.id]?.label || input.id }))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** When the part is drawn (figure-model `set-visibility`): always (null), or while a control is at least a value. */
|
|
223
|
+
export function shownWhenOf(asset: V3Asset, part: string): { input: string; atLeast: number } | null {
|
|
224
|
+
const look = asset.appearance.find(one => one.target === part) as { visibility?: string } | undefined
|
|
225
|
+
if (!look?.visibility) return null
|
|
226
|
+
const m = modelOf(asset)
|
|
227
|
+
const node = m.nodes.find(n => n.op === 'at-least@1' && Object.values(n.outputs).includes(look.visibility!))
|
|
228
|
+
const at = node && m.constants.find(c => c.id === node.args[1])
|
|
229
|
+
return node && at ? { input: node.args[0]!, atLeast: at.value } : null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether `part` hangs from `from`, through any number of fastenings. */
|
|
233
|
+
export function hangsFrom(asset: V3Asset, part: string, from: string): boolean {
|
|
234
|
+
const seen = new Set<string>()
|
|
235
|
+
for (let at: string | undefined = part; at && !seen.has(at); ) {
|
|
236
|
+
seen.add(at)
|
|
237
|
+
const j = ((asset.joints ?? []) as V3Joint[]).find(o => o.body1.part === at)
|
|
238
|
+
at = j && 'part' in j.body0 ? (j.body0 as { part: string }).part : undefined
|
|
239
|
+
if (at === from) return true
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The alignment that keeps the part where it stands when it is fastened: on each axis across the face, its distance
|
|
246
|
+
* from the target's centre (the plane's is the origin). Fastening used to centre it -- a door set 100 mm in front of
|
|
247
|
+
* its frame slid into it (2026-09-26). Left centred where it already is, and where the target stands turned.
|
|
248
|
+
*/
|
|
249
|
+
export function placeKeptOf(
|
|
250
|
+
asset: V3Asset,
|
|
251
|
+
part: string,
|
|
252
|
+
target: { part: string; face: string } | 'plane',
|
|
253
|
+
twisted = false
|
|
254
|
+
): { align: Partial<Record<Axis, Alignment>>; alignMm: Partial<Record<Axis, number>> } {
|
|
255
|
+
const kept = { align: {} as Partial<Record<Axis, Alignment>>, alignMm: {} as Partial<Record<Axis, number>> }
|
|
256
|
+
let geometry
|
|
257
|
+
try {
|
|
258
|
+
geometry = compileV3Asset(asset).evaluate().geometry as unknown as Array<{ id: string; pose?: { r: number[][]; t: number[] } }>
|
|
259
|
+
} catch {
|
|
260
|
+
return kept
|
|
261
|
+
}
|
|
262
|
+
const poseOf = (id: string) => geometry.find(g => g.id === id)?.pose
|
|
263
|
+
const mine = poseOf(part)
|
|
264
|
+
if (!mine) return kept
|
|
265
|
+
let centre = [0, 0, 0]
|
|
266
|
+
if (target !== 'plane') {
|
|
267
|
+
const theirs = poseOf(target.part)
|
|
268
|
+
const upright = theirs?.r.every((row, i) => row.every((v, j) => Math.abs(v - (i === j ? 1 : 0)) < 1e-9))
|
|
269
|
+
if (!theirs || !upright) return kept
|
|
270
|
+
centre = theirs.t
|
|
271
|
+
}
|
|
272
|
+
for (const { axis } of alignAxesOf(asset, part, target, twisted)) {
|
|
273
|
+
const k = AXES.indexOf(axis)
|
|
274
|
+
const off = Math.round((mine.t[k]! - centre[k]!) * 10) / 10
|
|
275
|
+
if (Math.abs(off) < 0.5) continue
|
|
276
|
+
kept.align[axis] = 'mm'
|
|
277
|
+
kept.alignMm[axis] = off
|
|
278
|
+
}
|
|
279
|
+
return kept
|
|
280
|
+
}
|
|
281
|
+
|
|
213
282
|
/** The `align` of an attach command from the form's choices: only the axes across that face, and centred ones left out. */
|
|
214
283
|
export function alignOf(
|
|
215
284
|
asset: V3Asset,
|
|
@@ -39,7 +39,8 @@ export function paletteRecipe(kind: V3PaletteKind, id: string): V3PrimitiveCreat
|
|
|
39
39
|
id,
|
|
40
40
|
position: { x: 0, y: 0, z: 0 },
|
|
41
41
|
kind: 'portal',
|
|
42
|
-
|
|
42
|
+
/* A door frame at a door's size, as a wall is a wall's: its width, height, depth and the jambs' and head's thickness. */
|
|
43
|
+
dimensions: { width: 1000, height: 2200, depth: 150, jamb: 100, head: 100 }
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
}
|
|
@@ -120,6 +120,14 @@ export function createV3ProductSession(store: V3ProductStore) {
|
|
|
120
120
|
writable()
|
|
121
121
|
return workspace.applyProposal({ origin: 'ai', context, expectedRevision, steps })
|
|
122
122
|
},
|
|
123
|
+
/**
|
|
124
|
+
* A recipe put in by the person (ADR-0095): its resolved commands, as one step of the history, on the version open
|
|
125
|
+
* now. The same path as a proposal; the person is its origin.
|
|
126
|
+
*/
|
|
127
|
+
applyRecipe(steps: readonly V3ProposalStep[]) {
|
|
128
|
+
const at = writable()
|
|
129
|
+
return workspace.applyProposal({ origin: 'ui', context: at.context, expectedRevision: at.expectedRevision, steps })
|
|
130
|
+
},
|
|
123
131
|
add(part: V3PrimitiveCreation) {
|
|
124
132
|
return workspace.addPrimitive({ ...writable(), part })
|
|
125
133
|
},
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { V3Recipe, V3RecipeParam, V3RecipeValue } from '@hatiolab/figure-model'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* The recipe section's rules (ADR-0095), kept apart from the element so they can be tested without a browser.
|
|
5
|
+
*/
|
|
6
|
+
|
|
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) {
|
|
17
|
+
try {
|
|
18
|
+
const recipe = JSON.parse(row.source)
|
|
19
|
+
if (recipe && typeof recipe.name === 'string' && Array.isArray(recipe.steps)) out.push(recipe)
|
|
20
|
+
} catch {
|
|
21
|
+
/* not a recipe */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a recipe can be put in now: one for a part needs a part chosen. */
|
|
28
|
+
export function recipeReady(recipe: Pick<V3Recipe, 'target'>, selected: string): boolean {
|
|
29
|
+
return recipe.target === 'figure' || !!selected
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A field's value as the recipe takes it. A ratio is shown and typed as a percentage (35 for 0.35), since that is how a
|
|
34
|
+
* person says a share; the rest are taken as they are. A number field left empty or not a number keeps the default.
|
|
35
|
+
*/
|
|
36
|
+
export function recipeValueOf(param: V3RecipeParam, typed: string | boolean): V3RecipeValue {
|
|
37
|
+
if (param.kind === 'flag') return typed === true || typed === 'true'
|
|
38
|
+
if (param.kind === 'ratio' || param.kind === 'mm') {
|
|
39
|
+
const n = typeof typed === 'string' && typed.trim() !== '' ? Number(typed) : NaN
|
|
40
|
+
if (!Number.isFinite(n)) return param.default
|
|
41
|
+
return param.kind === 'ratio' ? Math.round(n * 1e4) / 1e6 : n
|
|
42
|
+
}
|
|
43
|
+
const option = (param.options ?? []).find(o => String(o) === String(typed))
|
|
44
|
+
return option ?? param.default
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a field shows for a value: a ratio as a percentage. */
|
|
48
|
+
export function recipeFieldOf(param: V3RecipeParam, value: V3RecipeValue): string {
|
|
49
|
+
if (param.kind === 'ratio' && typeof value === 'number') return String(Math.round(value * 1e6) / 1e4)
|
|
50
|
+
return String(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
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
|
+
|
|
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(' / ')
|
|
69
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
import '../modeller/figure-v3-preview.js'
|
|
3
|
+
|
|
4
|
+
import { css, html, nothing } from 'lit'
|
|
5
|
+
import { customElement, state } from 'lit/decorators.js'
|
|
6
|
+
|
|
7
|
+
import { i18next, localize } from '@operato/i18n'
|
|
8
|
+
import { PageView } from '@operato/shell'
|
|
9
|
+
import { OxPrompt } from '@operato/popup'
|
|
10
|
+
import { ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
|
|
11
|
+
import { applyV3Recipe, parseV3Recipe, v3RecipeTestRoom } from '@hatiolab/figure-model'
|
|
12
|
+
import type { V3Asset, V3Recipe } from '@hatiolab/figure-model'
|
|
13
|
+
|
|
14
|
+
import { deleteFigureRecipe, fetchFigureRecipeRows, saveFigureRecipe, setFigureRecipeState, type FigureRecipeRow } from '../graphql/index.js'
|
|
15
|
+
import { paramRangeOf, recipeFieldOf, recipeIsOwn, recipesOf, recipeWord, subdomainOf } from '../modeller/v3-recipes.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The recipes (ADR-0095): what each one puts in, shown put into a room at several sizes, and whose it is.
|
|
19
|
+
*
|
|
20
|
+
* A recipe of this domain's own can be released, taken back to draft, changed or deleted. A parent's -- the ones the
|
|
21
|
+
* system domain ships -- is copied in under its name to be changed, and the copy stands in for it. Figures a recipe was
|
|
22
|
+
* put into keep their parts whatever happens here.
|
|
23
|
+
*/
|
|
24
|
+
@customElement('figure-recipe-page')
|
|
25
|
+
export class FigureRecipePage extends localize(i18next)(PageView) {
|
|
26
|
+
static styles = [
|
|
27
|
+
WorkSurfacePageStyles,
|
|
28
|
+
ScrollbarStyles,
|
|
29
|
+
css`
|
|
30
|
+
:host {
|
|
31
|
+
display: grid;
|
|
32
|
+
grid-template-columns: minmax(220px, 280px) 1fr;
|
|
33
|
+
background-color: var(--md-sys-color-surface);
|
|
34
|
+
color: var(--md-sys-color-on-surface);
|
|
35
|
+
font-size: 0.85rem;
|
|
36
|
+
}
|
|
37
|
+
@container (max-width: 640px) {
|
|
38
|
+
:host {
|
|
39
|
+
grid-template-columns: 1fr;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
nav {
|
|
43
|
+
overflow: auto;
|
|
44
|
+
border-right: 1px solid var(--md-sys-color-outline-variant);
|
|
45
|
+
padding: 8px;
|
|
46
|
+
}
|
|
47
|
+
nav p[intro] {
|
|
48
|
+
margin: 4px 4px 10px;
|
|
49
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
50
|
+
line-height: 1.5;
|
|
51
|
+
}
|
|
52
|
+
button[row] {
|
|
53
|
+
display: grid;
|
|
54
|
+
gap: 2px;
|
|
55
|
+
width: 100%;
|
|
56
|
+
padding: 8px 10px;
|
|
57
|
+
text-align: left;
|
|
58
|
+
font: inherit;
|
|
59
|
+
color: inherit;
|
|
60
|
+
background: none;
|
|
61
|
+
border: none;
|
|
62
|
+
border-radius: 8px;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
button[row][on] {
|
|
66
|
+
background: var(--md-sys-color-secondary-container);
|
|
67
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
68
|
+
}
|
|
69
|
+
button[row] small {
|
|
70
|
+
display: flex;
|
|
71
|
+
gap: 6px;
|
|
72
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
73
|
+
}
|
|
74
|
+
span[chip] {
|
|
75
|
+
padding: 0 6px;
|
|
76
|
+
border-radius: 999em;
|
|
77
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
78
|
+
font-size: 0.7rem;
|
|
79
|
+
}
|
|
80
|
+
span[chip][draft] {
|
|
81
|
+
color: var(--md-sys-color-tertiary);
|
|
82
|
+
border-color: var(--md-sys-color-tertiary);
|
|
83
|
+
}
|
|
84
|
+
main {
|
|
85
|
+
overflow: auto;
|
|
86
|
+
padding: 12px 16px;
|
|
87
|
+
display: grid;
|
|
88
|
+
gap: 12px;
|
|
89
|
+
align-content: start;
|
|
90
|
+
}
|
|
91
|
+
main h2 {
|
|
92
|
+
margin: 0;
|
|
93
|
+
font-size: var(--ui-t-title, 16px);
|
|
94
|
+
}
|
|
95
|
+
main p {
|
|
96
|
+
margin: 0;
|
|
97
|
+
line-height: 1.5;
|
|
98
|
+
}
|
|
99
|
+
p[muted] {
|
|
100
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
101
|
+
}
|
|
102
|
+
table {
|
|
103
|
+
border-collapse: collapse;
|
|
104
|
+
width: 100%;
|
|
105
|
+
max-width: 720px;
|
|
106
|
+
}
|
|
107
|
+
th,
|
|
108
|
+
td {
|
|
109
|
+
text-align: left;
|
|
110
|
+
padding: 4px 8px;
|
|
111
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
112
|
+
}
|
|
113
|
+
figure-v3-preview {
|
|
114
|
+
height: 420px;
|
|
115
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
}
|
|
119
|
+
div[actions] {
|
|
120
|
+
display: flex;
|
|
121
|
+
flex-wrap: wrap;
|
|
122
|
+
gap: 8px;
|
|
123
|
+
}
|
|
124
|
+
div[actions] button {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 4px;
|
|
128
|
+
padding: 6px 12px;
|
|
129
|
+
font: inherit;
|
|
130
|
+
color: var(--md-sys-color-on-surface);
|
|
131
|
+
background: none;
|
|
132
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
133
|
+
border-radius: 999em;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
}
|
|
136
|
+
div[actions] button[primary] {
|
|
137
|
+
color: var(--md-sys-color-on-primary);
|
|
138
|
+
background: var(--md-sys-color-primary);
|
|
139
|
+
border-color: transparent;
|
|
140
|
+
}
|
|
141
|
+
div[actions] button[danger] {
|
|
142
|
+
color: var(--md-sys-color-error);
|
|
143
|
+
border-color: var(--md-sys-color-error);
|
|
144
|
+
}
|
|
145
|
+
p[role='alert'] {
|
|
146
|
+
padding: 8px;
|
|
147
|
+
white-space: pre-wrap;
|
|
148
|
+
color: var(--md-sys-color-on-error-container);
|
|
149
|
+
background: var(--md-sys-color-error-container);
|
|
150
|
+
border-radius: 8px;
|
|
151
|
+
}
|
|
152
|
+
textarea {
|
|
153
|
+
width: 100%;
|
|
154
|
+
min-height: 320px;
|
|
155
|
+
font: 0.78rem/1.4 monospace;
|
|
156
|
+
box-sizing: border-box;
|
|
157
|
+
}
|
|
158
|
+
`
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
@state() private rows: FigureRecipeRow[] = []
|
|
162
|
+
@state() private chosen = ''
|
|
163
|
+
@state() private error = ''
|
|
164
|
+
@state() private editing = false
|
|
165
|
+
@state() private text = ''
|
|
166
|
+
|
|
167
|
+
get context() {
|
|
168
|
+
return { title: i18next.t('figure.title.recipes') }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async pageUpdated(_changes: Record<string, unknown>, _lifecycle: unknown, before: { active?: boolean }) {
|
|
172
|
+
if (this.active && !before?.active) await this.read()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async read() {
|
|
176
|
+
try {
|
|
177
|
+
this.rows = await fetchFigureRecipeRows()
|
|
178
|
+
if (!this.rows.some(r => r.name === this.chosen)) this.chosen = this.rows[0]?.name ?? ''
|
|
179
|
+
this.error = ''
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.error = error instanceof Error ? error.message : String(error)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private get subdomain() {
|
|
186
|
+
return subdomainOf(location.pathname)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
render() {
|
|
190
|
+
const language = i18next.language || 'en'
|
|
191
|
+
const recipes = recipesOf(this.rows)
|
|
192
|
+
const row = this.rows.find(r => r.name === this.chosen)
|
|
193
|
+
const recipe = recipes.find(r => r.name === this.chosen)
|
|
194
|
+
return html`
|
|
195
|
+
<nav>
|
|
196
|
+
<p intro>${i18next.t('figure.text.recipes-intro')}</p>
|
|
197
|
+
${this.rows.map(one => {
|
|
198
|
+
const r = recipes.find(x => x.name === one.name)
|
|
199
|
+
return html`<button row ?on=${one.name === this.chosen} @click=${() => this.choose(one.name)}>
|
|
200
|
+
<span>${r ? recipeWord(r.title, language) : one.name}</span>
|
|
201
|
+
<small>
|
|
202
|
+
<span>${one.name}</span>
|
|
203
|
+
${one.state === 'draft' ? html`<span chip draft>${i18next.t('figure.label.recipe-draft')}</span>` : nothing}
|
|
204
|
+
${recipeIsOwn(one, this.subdomain) ? nothing : html`<span chip>${i18next.t('figure.label.recipe-inherited')}</span>`}
|
|
205
|
+
</small>
|
|
206
|
+
</button>`
|
|
207
|
+
})}
|
|
208
|
+
</nav>
|
|
209
|
+
<main>
|
|
210
|
+
${this.error ? html`<p role="alert">${this.error}</p>` : nothing}
|
|
211
|
+
${row && recipe ? this.renderRecipe(row, recipe, language) : html`<p muted>${i18next.t('figure.text.recipes-empty')}</p>`}
|
|
212
|
+
</main>
|
|
213
|
+
`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private renderRecipe(row: FigureRecipeRow, recipe: V3Recipe, language: string) {
|
|
217
|
+
const own = recipeIsOwn(row, this.subdomain)
|
|
218
|
+
return html`
|
|
219
|
+
<h2>${recipeWord(recipe.title, language)}</h2>
|
|
220
|
+
<p>${recipeWord(recipe.description, language)}</p>
|
|
221
|
+
<p muted>
|
|
222
|
+
${i18next.t(recipe.target === 'part' ? 'figure.text.recipe-target-part' : 'figure.text.recipe-target-figure')} ·
|
|
223
|
+
${i18next.t(own ? 'figure.text.recipe-own' : 'figure.text.recipe-inherited', { domain: row.domain?.subdomain ?? '' })}
|
|
224
|
+
</p>
|
|
225
|
+
<table>
|
|
226
|
+
<thead>
|
|
227
|
+
<tr>
|
|
228
|
+
<th>${i18next.t('figure.label.recipe-param')}</th>
|
|
229
|
+
<th>${i18next.t('figure.label.recipe-default')}</th>
|
|
230
|
+
<th>${i18next.t('figure.label.recipe-range')}</th>
|
|
231
|
+
</tr>
|
|
232
|
+
</thead>
|
|
233
|
+
<tbody>
|
|
234
|
+
${recipe.params.map(
|
|
235
|
+
p => html`<tr>
|
|
236
|
+
<td>${recipeWord(p.label, language) || p.name}</td>
|
|
237
|
+
<td>${p.kind === 'flag' ? i18next.t(p.default ? 'figure.label.yes' : 'figure.label.no') : recipeFieldOf(p, p.default)}${p.kind === 'ratio' ? ' %' : ''}</td>
|
|
238
|
+
<td>${paramRangeOf(p)}</td>
|
|
239
|
+
</tr>`
|
|
240
|
+
)}
|
|
241
|
+
</tbody>
|
|
242
|
+
</table>
|
|
243
|
+
${this.renderPreview(recipe)}
|
|
244
|
+
<div actions>
|
|
245
|
+
${own
|
|
246
|
+
? html`${row.state === 'draft'
|
|
247
|
+
? html`<button primary @click=${() => this.setState(row.name, 'released')}>${i18next.t('figure.button.recipe-release')}</button>`
|
|
248
|
+
: html`<button @click=${() => this.setState(row.name, 'draft')}>${i18next.t('figure.button.recipe-to-draft')}</button>`}
|
|
249
|
+
<button danger @click=${() => this.deleteRecipe(row.name)}><md-icon>delete</md-icon>${i18next.t('figure.button.recipe-delete')}</button>`
|
|
250
|
+
: html`<button primary @click=${() => this.copyIn(row)}>${i18next.t('figure.button.recipe-copy-in')}</button>`}
|
|
251
|
+
<button @click=${() => this.edit(row)}><md-icon>data_object</md-icon>${i18next.t('figure.button.recipe-edit-source')}</button>
|
|
252
|
+
</div>
|
|
253
|
+
${this.editing
|
|
254
|
+
? html`<p muted>${i18next.t('figure.text.recipe-edit-source')}</p>
|
|
255
|
+
<textarea .value=${this.text} @input=${(e: Event) => (this.text = (e.target as HTMLTextAreaElement).value)}></textarea>
|
|
256
|
+
<div actions>
|
|
257
|
+
<button primary @click=${() => this.saveText(own ? row.state : 'draft')}>${i18next.t('figure.button.save')}</button>
|
|
258
|
+
<button @click=${() => (this.editing = false)}>${i18next.t('figure.button.close')}</button>
|
|
259
|
+
</div>`
|
|
260
|
+
: nothing}
|
|
261
|
+
`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The recipe put into the room it is checked in, with its defaults: what it makes, at several sizes. */
|
|
265
|
+
private renderPreview(recipe: V3Recipe) {
|
|
266
|
+
let asset: V3Asset
|
|
267
|
+
try {
|
|
268
|
+
const room = v3RecipeTestRoom()
|
|
269
|
+
asset = applyV3Recipe(room.asset, parseV3Recipe(recipe), recipe.target === 'part' ? { target: room.target } : {}).asset
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return html`<p role="alert">${error instanceof Error ? error.message : String(error)}</p>`
|
|
272
|
+
}
|
|
273
|
+
return html`<p muted>${i18next.t('figure.text.recipe-preview')}</p>
|
|
274
|
+
<figure-v3-preview .asset=${asset} .context=${`recipe:${recipe.name}`}></figure-v3-preview>`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private choose(name: string) {
|
|
278
|
+
this.chosen = name
|
|
279
|
+
this.editing = false
|
|
280
|
+
this.error = ''
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private edit(row: FigureRecipeRow) {
|
|
284
|
+
this.editing = !this.editing
|
|
285
|
+
try {
|
|
286
|
+
this.text = JSON.stringify(JSON.parse(row.source), null, 2)
|
|
287
|
+
} catch {
|
|
288
|
+
this.text = row.source
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private async run(action: () => Promise<unknown>) {
|
|
293
|
+
try {
|
|
294
|
+
await action()
|
|
295
|
+
this.error = ''
|
|
296
|
+
await this.read()
|
|
297
|
+
} catch (error) {
|
|
298
|
+
this.error = error instanceof Error ? error.message : String(error)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private setState(name: string, state: 'draft' | 'released') {
|
|
303
|
+
return this.run(() => setFigureRecipeState(name, state))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* The app's own prompt, as every operato screen asks -- not the browser's confirm box (user, 2026-09-24). */
|
|
307
|
+
private async deleteRecipe(name: string) {
|
|
308
|
+
const sure = await OxPrompt.open({
|
|
309
|
+
type: 'warning',
|
|
310
|
+
title: i18next.t('figure.button.recipe-delete'),
|
|
311
|
+
text: i18next.t('figure.text.recipe-delete-confirm', { name }),
|
|
312
|
+
confirmButton: { text: i18next.t('figure.button.recipe-delete') },
|
|
313
|
+
cancelButton: { text: i18next.t('figure.button.close') }
|
|
314
|
+
})
|
|
315
|
+
if (sure) await this.run(() => deleteFigureRecipe(name))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A parent's recipe copied into this domain as a draft, to be changed here. */
|
|
319
|
+
private copyIn(row: FigureRecipeRow) {
|
|
320
|
+
return this.run(() => saveFigureRecipe(row.source, 'draft'))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The source as typed: checked by the server in the test room; a new name is a new recipe. */
|
|
324
|
+
private async saveText(state: 'draft' | 'released') {
|
|
325
|
+
let name = ''
|
|
326
|
+
try {
|
|
327
|
+
name = JSON.parse(this.text).name ?? ''
|
|
328
|
+
} catch (error) {
|
|
329
|
+
this.error = error instanceof Error ? error.message : String(error)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
await this.run(async () => {
|
|
333
|
+
await saveFigureRecipe(this.text, state)
|
|
334
|
+
this.chosen = name
|
|
335
|
+
this.editing = false
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -5,7 +5,9 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
|
|
|
5
5
|
import { createV3ProductSession } from '../modeller/v3-product-session.js'
|
|
6
6
|
import { v3ProductStore } from '../graphql/v3-store.js'
|
|
7
7
|
import { i18next } from '@operato/i18n'
|
|
8
|
-
import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
|
|
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
11
|
import { occupancyFromParts } from '../modeller/v3-occupancy.js'
|
|
10
12
|
import type { V3AuthoringAction } from '@hatiolab/figure-model'
|
|
11
13
|
import '../modeller/v3-editor.js'
|
|
@@ -93,6 +95,9 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
93
95
|
private routeId: string | undefined
|
|
94
96
|
private discardApproved = false
|
|
95
97
|
@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
|
|
96
101
|
@state() private error = ''
|
|
97
102
|
/**
|
|
98
103
|
* 발행 패널이 읽는 행의 사실 — 상태와 발행 판 번호. 원본은 세션이 들고, 이 둘은 행에 있다.
|
|
@@ -328,13 +333,24 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
328
333
|
detail.error = error instanceof Error ? error.message : String(error)
|
|
329
334
|
}
|
|
330
335
|
}
|
|
336
|
+
/*
|
|
337
|
+
Reloading or closing the tab is not a move the shell asks `canDeactivate` about: a new figure not yet saved was lost
|
|
338
|
+
without a word (2026-09-26). The browser's own question is the only one it allows there.
|
|
339
|
+
*/
|
|
340
|
+
private holdUnsaved = (event: BeforeUnloadEvent) => {
|
|
341
|
+
if (!this.active || !this.session.read().dirty) return
|
|
342
|
+
event.preventDefault()
|
|
343
|
+
event.returnValue = ''
|
|
344
|
+
}
|
|
331
345
|
connectedCallback() {
|
|
332
346
|
super.connectedCallback()
|
|
333
347
|
window.addEventListener('figure-v3-apply', this.applyAI)
|
|
348
|
+
window.addEventListener('beforeunload', this.holdUnsaved)
|
|
334
349
|
}
|
|
335
350
|
disconnectedCallback() {
|
|
336
351
|
this.withdrawAssistant()
|
|
337
352
|
window.removeEventListener('figure-v3-apply', this.applyAI)
|
|
353
|
+
window.removeEventListener('beforeunload', this.holdUnsaved)
|
|
338
354
|
super.disconnectedCallback()
|
|
339
355
|
}
|
|
340
356
|
async canDeactivate() {
|
|
@@ -360,6 +376,13 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
360
376
|
this.withdrawAssistant()
|
|
361
377
|
return
|
|
362
378
|
}
|
|
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))
|
|
385
|
+
}
|
|
363
386
|
const id = lifecycle?.resourceId
|
|
364
387
|
if (this.session.read().context && id === this.routeId && !changes.active) return
|
|
365
388
|
try {
|
|
@@ -424,7 +447,7 @@ export class FigureV3ModellerPage extends PageView {
|
|
|
424
447
|
render() {
|
|
425
448
|
if (this.error) return html`<p role="alert" class="error">${this.error}</p>`
|
|
426
449
|
const view = this.session.read()
|
|
427
|
-
return html`<figure-v3-editor .session=${this.session} .firstSave=${this.askFirstSave} .revision=${this.revision} .instances=${this.instances} .budget=${this.budget} @v3-changed=${this.changed}>
|
|
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}>
|
|
428
451
|
${this.commandError ? html`<p slot="notice" fit-error role="alert">${this.commandError}</p>` : ''}
|
|
429
452
|
<figure-v3-properties
|
|
430
453
|
slot="figure"
|
package/client/route.ts
CHANGED
|
@@ -4,6 +4,10 @@ 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
|
+
|
|
7
11
|
/*
|
|
8
12
|
One modeller since ADR-0087 step 5. `figure-v3-modeller` is kept as an alias for links made before the switch.
|
|
9
13
|
*/
|
|
@@ -65,3 +65,24 @@ 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<{
|
|
70
|
+
name: string;
|
|
71
|
+
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
|
+
};
|
|
83
|
+
}
|
|
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>;
|