@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
package/client/graphql/index.ts
CHANGED
|
@@ -332,3 +332,92 @@ export async function fetchFigureVersions(id: string): Promise<FigureVersion[]>
|
|
|
332
332
|
|
|
333
333
|
return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []
|
|
334
334
|
}
|
|
335
|
+
|
|
336
|
+
/** The recipes this domain sees (ADR-0095): released ones, its own and its parent's. Each is a figure-model V3Recipe. */
|
|
337
|
+
export async function fetchFigureRecipes(): Promise<Array<{ name: string; source: string }>> {
|
|
338
|
+
const response = await client.query({
|
|
339
|
+
query: gql`
|
|
340
|
+
query {
|
|
341
|
+
figureRecipes {
|
|
342
|
+
name
|
|
343
|
+
source
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
`,
|
|
347
|
+
fetchPolicy: 'network-only'
|
|
348
|
+
})
|
|
349
|
+
return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A recipe row as the management screen reads it: whose it is and whether it is released. */
|
|
353
|
+
export interface FigureRecipeRow {
|
|
354
|
+
name: string
|
|
355
|
+
source: string
|
|
356
|
+
state: 'draft' | 'released'
|
|
357
|
+
updatedAt?: string
|
|
358
|
+
domain?: { id: string; subdomain?: string }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Every recipe this domain sees, drafts too (ADR-0095). */
|
|
362
|
+
export async function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]> {
|
|
363
|
+
const response = await client.query({
|
|
364
|
+
query: gql`
|
|
365
|
+
query {
|
|
366
|
+
figureRecipes(all: true) {
|
|
367
|
+
name
|
|
368
|
+
source
|
|
369
|
+
state
|
|
370
|
+
updatedAt
|
|
371
|
+
domain {
|
|
372
|
+
id
|
|
373
|
+
subdomain
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
`,
|
|
378
|
+
fetchPolicy: 'network-only'
|
|
379
|
+
})
|
|
380
|
+
return unwrap<FigureRecipeRow[] | null>(response, 'figureRecipes') ?? []
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow> {
|
|
384
|
+
const response = await client.mutate({
|
|
385
|
+
mutation: gql`
|
|
386
|
+
mutation ($source: String!, $state: String) {
|
|
387
|
+
saveFigureRecipe(source: $source, state: $state) {
|
|
388
|
+
name
|
|
389
|
+
source
|
|
390
|
+
state
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
`,
|
|
394
|
+
variables: { source, state }
|
|
395
|
+
})
|
|
396
|
+
return unwrap<FigureRecipeRow>(response, 'saveFigureRecipe')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function setFigureRecipeState(name: string, state: 'draft' | 'released'): Promise<void> {
|
|
400
|
+
const response = await client.mutate({
|
|
401
|
+
mutation: gql`
|
|
402
|
+
mutation ($name: String!, $state: String!) {
|
|
403
|
+
setFigureRecipeState(name: $name, state: $state) {
|
|
404
|
+
name
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
`,
|
|
408
|
+
variables: { name, state }
|
|
409
|
+
})
|
|
410
|
+
unwrap(response, 'setFigureRecipeState')
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function deleteFigureRecipe(name: string): Promise<void> {
|
|
414
|
+
const response = await client.mutate({
|
|
415
|
+
mutation: gql`
|
|
416
|
+
mutation ($name: String!) {
|
|
417
|
+
deleteFigureRecipe(name: $name)
|
|
418
|
+
}
|
|
419
|
+
`,
|
|
420
|
+
variables: { name }
|
|
421
|
+
})
|
|
422
|
+
unwrap(response, 'deleteFigureRecipe')
|
|
423
|
+
}
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
fastenedTo,
|
|
24
24
|
heldBy,
|
|
25
25
|
followOf,
|
|
26
|
+
hangsFrom,
|
|
27
|
+
shownWhenOf,
|
|
28
|
+
visibilityControlsOf,
|
|
29
|
+
placeKeptOf,
|
|
26
30
|
hasInstanceSize,
|
|
27
31
|
holdsThings,
|
|
28
32
|
leadersFor,
|
|
@@ -140,7 +144,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
140
144
|
if (!asset || !this.partId) return nothing
|
|
141
145
|
if (this.section === 'motion')
|
|
142
146
|
return html`<section><h3>${i18next.t('figure.label.motion')}${this.help.button('figure.text.motion-explained')}</h3>${this.help.hint('figure.text.motion-explained')}
|
|
143
|
-
${this.renderMotion(asset)}</section>`
|
|
147
|
+
${this.renderMotion(asset)}${this.renderShown(asset)}</section>`
|
|
144
148
|
if (this.section === 'places')
|
|
145
149
|
return html`<section><h3>${i18next.t('figure.label.places-on-part', { part: this.partId })}${this.help.button('figure.text.place-explained')}</h3>${this.help.hint('figure.text.place-explained')}
|
|
146
150
|
${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}</section>`
|
|
@@ -231,7 +235,14 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
231
235
|
private renderDimensions(asset: V3Asset) {
|
|
232
236
|
const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
|
|
233
237
|
const round = followableAxes(asset, this.partId).length === 2
|
|
234
|
-
|
|
238
|
+
/* One step for the whole part (figure-model follow-size): the size is declared from the parts if there is none. */
|
|
239
|
+
const followsAll = followableAxes(asset, this.partId).every(axis => followOf(asset, this.partId, axis).of === 'instance')
|
|
240
|
+
const whole = followsAll
|
|
241
|
+
? nothing
|
|
242
|
+
: html`<button act follow-size ?disabled=${this.locked} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
|
|
243
|
+
<md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
|
|
244
|
+
</button>`
|
|
245
|
+
return html`${whole}${followableAxes(asset, this.partId).map(axis => {
|
|
235
246
|
const now = followOf(asset, this.partId, axis)
|
|
236
247
|
const value = choiceOf(now)
|
|
237
248
|
/* A cylinder's two measures are its radius and its length; its X and Y name them. */
|
|
@@ -271,7 +282,7 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
271
282
|
</label>`
|
|
272
283
|
: nothing}
|
|
273
284
|
</div>`
|
|
274
|
-
})
|
|
285
|
+
})}`
|
|
275
286
|
}
|
|
276
287
|
|
|
277
288
|
/** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
|
|
@@ -325,10 +336,22 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
325
336
|
)
|
|
326
337
|
}
|
|
327
338
|
|
|
339
|
+
/**
|
|
340
|
+
* A new aim for the fastening: where on the face is filled in as where the part stands now, so fastening it keeps it
|
|
341
|
+
* there (`placeKeptOf`). The author changes it from there.
|
|
342
|
+
*/
|
|
343
|
+
private aimAt(asset: V3Asset, f: ReturnType<typeof freshFasten>) {
|
|
344
|
+
const theirFaces = f.target ? facesOf(asset, f.target) : FACES
|
|
345
|
+
const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0]!
|
|
346
|
+
const aim = f.toPlane ? ('plane' as const) : f.target ? { part: f.target, face: targetFace } : undefined
|
|
347
|
+
this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {} }) }
|
|
348
|
+
}
|
|
349
|
+
|
|
328
350
|
private renderFastening(asset: V3Asset) {
|
|
329
351
|
const held = heldBy(asset, this.partId)
|
|
330
352
|
const f = this.fasten
|
|
331
|
-
|
|
353
|
+
/* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
|
|
354
|
+
const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one) && !hangsFrom(asset, one, this.partId))
|
|
332
355
|
/* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
|
|
333
356
|
/* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
|
|
334
357
|
const plane = mountingPlaneOf(asset)
|
|
@@ -354,19 +377,19 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
354
377
|
</select>
|
|
355
378
|
</div>
|
|
356
379
|
<label toggle>
|
|
357
|
-
<input type="checkbox" .checked=${f.toPlane} @change=${(e: Event) =>
|
|
380
|
+
<input type="checkbox" .checked=${f.toPlane} @change=${(e: Event) => this.aimAt(asset, { ...f, toPlane: (e.target as HTMLInputElement).checked })} />
|
|
358
381
|
${i18next.t('figure.label.stands-on-plane')}
|
|
359
382
|
</label>
|
|
360
383
|
${f.toPlane
|
|
361
384
|
? nothing
|
|
362
385
|
: html`<div form>
|
|
363
386
|
<span>${i18next.t('figure.label.target-part')}</span>
|
|
364
|
-
<select @change=${(e: Event) =>
|
|
387
|
+
<select @change=${(e: Event) => this.aimAt(asset, { ...f, target: (e.target as HTMLSelectElement).value })}>
|
|
365
388
|
<option value="" ?selected=${!f.target}>—</option>
|
|
366
389
|
${targets.map(one => html`<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
|
|
367
390
|
</select>
|
|
368
391
|
<span>${i18next.t('figure.label.target-face')}</span>
|
|
369
|
-
<select @change=${(e: Event) =>
|
|
392
|
+
<select @change=${(e: Event) => this.aimAt(asset, { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
|
|
370
393
|
${theirFaces.map(face => html`<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
|
|
371
394
|
</select>
|
|
372
395
|
<span>${i18next.t('figure.label.facing-meet')}</span>
|
|
@@ -474,6 +497,49 @@ export class FigureV3Links extends localize(i18next)(LitElement) {
|
|
|
474
497
|
</section>`
|
|
475
498
|
}
|
|
476
499
|
|
|
500
|
+
/**
|
|
501
|
+
* When the part is drawn: always, or while a control is at least a value -- a lamp lit while the gate is open. One
|
|
502
|
+
* part edit (`set-visibility`), one step. Offered once the figure has a control; a motion is what makes one.
|
|
503
|
+
*/
|
|
504
|
+
private renderShown(asset: V3Asset) {
|
|
505
|
+
const controls = visibilityControlsOf(asset)
|
|
506
|
+
if (!controls.length) return nothing
|
|
507
|
+
const now = shownWhenOf(asset, this.partId)
|
|
508
|
+
const send = (shownWhen: { input: string; atLeast: number } | null) =>
|
|
509
|
+
this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit: { kind: 'set-visibility', target: this.partId, shownWhen } }, bubbles: true, composed: true }))
|
|
510
|
+
const first = (id: string) => {
|
|
511
|
+
const c = controls.find(one => one.id === id)!
|
|
512
|
+
return Math.round((c.min + (c.max - c.min) * 0.01) * 1000) / 1000
|
|
513
|
+
}
|
|
514
|
+
const unit = now ? controls.find(c => c.id === now.input)?.unit : ''
|
|
515
|
+
return html`<div form>
|
|
516
|
+
<span>${i18next.t('figure.label.shown-when')}</span>
|
|
517
|
+
<select
|
|
518
|
+
?disabled=${this.locked}
|
|
519
|
+
@change=${(e: Event) => {
|
|
520
|
+
const id = (e.target as HTMLSelectElement).value
|
|
521
|
+
send(id ? { input: id, atLeast: now?.input === id ? now.atLeast : first(id) } : null)
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
<option value="" ?selected=${!now}>${i18next.t('figure.label.shown-always')}</option>
|
|
525
|
+
${controls.map(c => html`<option value=${c.id} ?selected=${now?.input === c.id}>${i18next.t('figure.label.shown-while', { control: c.label })}</option>`)}
|
|
526
|
+
</select>
|
|
527
|
+
${now
|
|
528
|
+
? html`<span>${i18next.t('figure.label.shown-at-least')}${unit ? ` (${unit})` : ''}</span>
|
|
529
|
+
<input
|
|
530
|
+
type="number"
|
|
531
|
+
step="any"
|
|
532
|
+
?disabled=${this.locked}
|
|
533
|
+
.value=${String(now.atLeast)}
|
|
534
|
+
@change=${(e: Event) => {
|
|
535
|
+
const v = (e.target as HTMLInputElement).valueAsNumber
|
|
536
|
+
if (Number.isFinite(v)) send({ input: now.input, atLeast: v })
|
|
537
|
+
}}
|
|
538
|
+
/>`
|
|
539
|
+
: nothing}
|
|
540
|
+
</div>`
|
|
541
|
+
}
|
|
542
|
+
|
|
477
543
|
private renderMotion(asset: V3Asset) {
|
|
478
544
|
const now = motionOf(asset, this.partId)
|
|
479
545
|
const joint = asset.joints?.find(j => j.body1.part === this.partId)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import '@material/web/icon/icon.js'
|
|
2
2
|
import { css, html, LitElement, nothing } from 'lit'
|
|
3
|
-
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
5
|
import { ScrollbarStyles } from '@operato/styles'
|
|
6
6
|
import { PART_LIMIT } from '@hatiolab/figure-model'
|
|
7
|
-
import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
|
+
import type { V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
|
|
8
|
+
import { recipeFieldOf, recipeReady, recipeValueOf, recipeWord } from './v3-recipes.js'
|
|
8
9
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
9
10
|
import { partLabelOf } from './v3-fields.js'
|
|
10
11
|
|
|
@@ -208,6 +209,83 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
208
209
|
border-radius: 6px;
|
|
209
210
|
cursor: pointer;
|
|
210
211
|
}
|
|
212
|
+
div[recipe-list] {
|
|
213
|
+
display: flex;
|
|
214
|
+
flex-wrap: wrap;
|
|
215
|
+
gap: 3px;
|
|
216
|
+
}
|
|
217
|
+
button[recipe] {
|
|
218
|
+
padding: 4px 8px;
|
|
219
|
+
font: inherit;
|
|
220
|
+
font-size: 0.75rem;
|
|
221
|
+
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
222
|
+
background: none;
|
|
223
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
224
|
+
border-radius: 999em;
|
|
225
|
+
cursor: pointer;
|
|
226
|
+
}
|
|
227
|
+
button[recipe][on] {
|
|
228
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
229
|
+
background: var(--md-sys-color-secondary-container);
|
|
230
|
+
border-color: transparent;
|
|
231
|
+
}
|
|
232
|
+
button[recipe]:disabled {
|
|
233
|
+
opacity: 0.38;
|
|
234
|
+
cursor: default;
|
|
235
|
+
}
|
|
236
|
+
div[recipe-form] {
|
|
237
|
+
display: grid;
|
|
238
|
+
gap: 6px;
|
|
239
|
+
margin-top: 8px;
|
|
240
|
+
padding: 8px;
|
|
241
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
242
|
+
border-radius: 8px;
|
|
243
|
+
font-size: 0.75rem;
|
|
244
|
+
}
|
|
245
|
+
/* The side pane is narrow (200 px, 160 under 860): the name above its field, not beside it. */
|
|
246
|
+
div[recipe-form] label[field] {
|
|
247
|
+
display: grid;
|
|
248
|
+
gap: 2px;
|
|
249
|
+
}
|
|
250
|
+
div[recipe-form] input[type='number'],
|
|
251
|
+
div[recipe-form] select {
|
|
252
|
+
min-width: 0;
|
|
253
|
+
font: inherit;
|
|
254
|
+
}
|
|
255
|
+
div[recipe-form] p[hint] {
|
|
256
|
+
margin: 0;
|
|
257
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
258
|
+
}
|
|
259
|
+
div[recipe-form] p[error] {
|
|
260
|
+
margin: 0;
|
|
261
|
+
padding: 6px;
|
|
262
|
+
white-space: pre-wrap;
|
|
263
|
+
color: var(--md-sys-color-on-error-container);
|
|
264
|
+
background: var(--md-sys-color-error-container);
|
|
265
|
+
border-radius: 6px;
|
|
266
|
+
}
|
|
267
|
+
div[recipe-actions] {
|
|
268
|
+
display: flex;
|
|
269
|
+
gap: 6px;
|
|
270
|
+
}
|
|
271
|
+
div[recipe-actions] button {
|
|
272
|
+
white-space: nowrap;
|
|
273
|
+
display: inline-flex;
|
|
274
|
+
align-items: center;
|
|
275
|
+
gap: 4px;
|
|
276
|
+
padding: 4px 10px;
|
|
277
|
+
font: inherit;
|
|
278
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
279
|
+
border-radius: 999em;
|
|
280
|
+
background: none;
|
|
281
|
+
color: var(--md-sys-color-on-surface);
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
}
|
|
284
|
+
div[recipe-actions] button[insert] {
|
|
285
|
+
border-color: transparent;
|
|
286
|
+
color: var(--md-sys-color-on-primary);
|
|
287
|
+
background: var(--md-sys-color-primary);
|
|
288
|
+
}
|
|
211
289
|
button[shape]:hover:not([disabled]) {
|
|
212
290
|
color: var(--md-sys-color-on-primary-container);
|
|
213
291
|
background-color: var(--md-sys-color-primary-container);
|
|
@@ -364,6 +442,13 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
364
442
|
* 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
|
|
365
443
|
*/
|
|
366
444
|
@property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
|
|
445
|
+
/** The recipes this domain sees (ADR-0095). */
|
|
446
|
+
@property({ attribute: false }) recipes: V3Recipe[] = []
|
|
447
|
+
/** Why the last recipe put in was refused -- the kernel's sentence, as it is (ADR-0095 condition 3). */
|
|
448
|
+
@property({ type: String }) recipeError = ''
|
|
449
|
+
/** The recipe whose form is open, and the values in it. */
|
|
450
|
+
@state() private openRecipe = ''
|
|
451
|
+
@state() private recipeValues: Record<string, V3RecipeValue> = {}
|
|
367
452
|
|
|
368
453
|
render() {
|
|
369
454
|
const b = this.partBudget
|
|
@@ -388,6 +473,7 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
388
473
|
</div>
|
|
389
474
|
${full ? html`<p full>${i18next.t('figure.text.part-limit-reached-short', { level: b!.level, limit: b!.limit })}</p>` : nothing}
|
|
390
475
|
</section>
|
|
476
|
+
${this.renderRecipes(full)}
|
|
391
477
|
|
|
392
478
|
<section list>
|
|
393
479
|
<h3>
|
|
@@ -539,6 +625,71 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
539
625
|
`
|
|
540
626
|
}
|
|
541
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Recipes (ADR-0095): a door, a roller row -- parts made in one step, with a few numbers and choices. A recipe for a
|
|
630
|
+
* part needs one chosen; its form opens under it, and putting it in is one step of the history.
|
|
631
|
+
*/
|
|
632
|
+
private renderRecipes(full: boolean) {
|
|
633
|
+
if (!this.recipes.length) return nothing
|
|
634
|
+
const language = i18next.language || 'en'
|
|
635
|
+
const open = this.recipes.find(r => r.name === this.openRecipe)
|
|
636
|
+
return html`<section recipes>
|
|
637
|
+
<h3>${i18next.t('figure.label.recipes')}</h3>
|
|
638
|
+
<div recipe-list>
|
|
639
|
+
${this.recipes.map(recipe => {
|
|
640
|
+
const ready = recipeReady(recipe, this.selected)
|
|
641
|
+
return html`<button
|
|
642
|
+
recipe=${recipe.name}
|
|
643
|
+
?on=${recipe.name === this.openRecipe}
|
|
644
|
+
?disabled=${this.locked || full || !ready}
|
|
645
|
+
title=${ready ? recipeWord(recipe.description, language) : i18next.t('figure.text.recipe-needs-a-part')}
|
|
646
|
+
@click=${() => this.openFor(recipe)}
|
|
647
|
+
>
|
|
648
|
+
${recipeWord(recipe.title, language)}
|
|
649
|
+
</button>`
|
|
650
|
+
})}
|
|
651
|
+
</div>
|
|
652
|
+
${this.selected || !this.recipes.some(r => r.target === 'part') ? nothing : html`<p hint>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
|
|
653
|
+
${open && recipeReady(open, this.selected) ? this.renderRecipeForm(open, language) : nothing}
|
|
654
|
+
</section>`
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private openFor(recipe: V3Recipe) {
|
|
658
|
+
if (this.openRecipe === recipe.name) {
|
|
659
|
+
this.openRecipe = ''
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
this.openRecipe = recipe.name
|
|
663
|
+
this.recipeValues = Object.fromEntries(recipe.params.map(p => [p.name, p.default]))
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private renderRecipeForm(recipe: V3Recipe, language: string) {
|
|
667
|
+
const set = (name: string, value: V3RecipeValue) => (this.recipeValues = { ...this.recipeValues, [name]: value })
|
|
668
|
+
return html`<div recipe-form>
|
|
669
|
+
${recipe.target === 'part' ? html`<p hint>${i18next.t('figure.text.recipe-onto', { part: this.selected })}</p>` : nothing}
|
|
670
|
+
${recipe.params.map(param => {
|
|
671
|
+
const label = recipeWord(param.label, language) || param.name
|
|
672
|
+
const value = this.recipeValues[param.name] ?? param.default
|
|
673
|
+
if (param.kind === 'flag')
|
|
674
|
+
return html`<label toggle><input type="checkbox" .checked=${value === true} @change=${(e: Event) => set(param.name, (e.target as HTMLInputElement).checked)} />${label}</label>`
|
|
675
|
+
if (param.kind === 'choice' || param.kind === 'face')
|
|
676
|
+
return html`<label field><span>${label}</span>
|
|
677
|
+
<select @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>
|
|
678
|
+
${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
|
|
679
|
+
</select></label>`
|
|
680
|
+
return html`<label field><span>${label}${param.kind === 'ratio' ? ' (%)' : ''}</span>
|
|
681
|
+
<input type="number" step="any" param=${param.name} .value=${recipeFieldOf(param, value)} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLInputElement).value))} /></label>`
|
|
682
|
+
})}
|
|
683
|
+
${this.recipeError ? html`<p role="alert" error>${this.recipeError}</p>` : nothing}
|
|
684
|
+
<div recipe-actions>
|
|
685
|
+
<button act insert ?disabled=${this.locked} @click=${() => this.ask('insert-recipe', { recipe, values: this.recipeValues })}>
|
|
686
|
+
<md-icon>add</md-icon>${i18next.t('figure.button.insert-recipe')}
|
|
687
|
+
</button>
|
|
688
|
+
<button act @click=${() => (this.openRecipe = '')}>${i18next.t('figure.button.close')}</button>
|
|
689
|
+
</div>
|
|
690
|
+
</div>`
|
|
691
|
+
}
|
|
692
|
+
|
|
542
693
|
/** 정본을 고치지 않는다 — 무엇을 하겠다는 것만 알린다. */
|
|
543
694
|
private ask(what: string, detail: object) {
|
|
544
695
|
this.dispatchEvent(new CustomEvent(what, { detail, bubbles: true, composed: true }))
|
|
@@ -4,8 +4,8 @@ import type { OxThreePane } from '@operato/layout'
|
|
|
4
4
|
import '@operato/layout'
|
|
5
5
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
6
6
|
import { live } from 'lit/directives/live.js'
|
|
7
|
-
import { PART_LIMIT, v3PartCountOf } from '@hatiolab/figure-model'
|
|
8
|
-
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
7
|
+
import { PART_LIMIT, parseV3Recipe, resolveV3Recipe, v3PartCountOf, v3RecipeIdFor } from '@hatiolab/figure-model'
|
|
8
|
+
import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation, V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
|
|
9
9
|
import type { V3ProductSession } from './v3-product-session.js'
|
|
10
10
|
import { captureV3Thumbnail } from './v3-thumbnail.js'
|
|
11
11
|
import { drawableV3Parts } from './v3-renderer.js'
|
|
@@ -19,7 +19,7 @@ import './figure-v3-preview.js'
|
|
|
19
19
|
import './figure-settings.js'
|
|
20
20
|
import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
|
|
21
21
|
import './figure-v3-capability.js'
|
|
22
|
-
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
22
|
+
import { gizmoChanges, gizmoReachOf, keepBottomChanges, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
|
|
23
23
|
import { keyActionOf } from './v3-keys.js'
|
|
24
24
|
import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './v3-fields.js'
|
|
25
25
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
@@ -72,6 +72,37 @@ export class FigureV3Editor extends LitElement {
|
|
|
72
72
|
ox-canvas-bar button[mode][aria-pressed='true'] {
|
|
73
73
|
border-color: var(--md-sys-color-primary);
|
|
74
74
|
}
|
|
75
|
+
/* The gizmo's tools, drawn as the bar's own icon buttons (undo, redo, fit). */
|
|
76
|
+
ox-canvas-bar .gizmo-tools {
|
|
77
|
+
display: inline-flex;
|
|
78
|
+
gap: 2px;
|
|
79
|
+
padding-left: 4px;
|
|
80
|
+
border-left: 1px solid var(--md-sys-color-outline-variant);
|
|
81
|
+
}
|
|
82
|
+
ox-canvas-bar button[tool] {
|
|
83
|
+
display: inline-grid;
|
|
84
|
+
place-items: center;
|
|
85
|
+
width: 32px;
|
|
86
|
+
height: 32px;
|
|
87
|
+
padding: 0;
|
|
88
|
+
border: none;
|
|
89
|
+
border-radius: var(--md-sys-shape-corner-small, 8px);
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
ox-canvas-bar button[tool]:hover:not(:disabled) {
|
|
95
|
+
background: var(--md-sys-color-surface-container-high);
|
|
96
|
+
color: var(--md-sys-color-on-surface);
|
|
97
|
+
}
|
|
98
|
+
ox-canvas-bar button[tool][aria-pressed='true'] {
|
|
99
|
+
background: var(--md-sys-color-secondary-container);
|
|
100
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
101
|
+
}
|
|
102
|
+
ox-canvas-bar button[tool]:disabled {
|
|
103
|
+
opacity: 0.38;
|
|
104
|
+
cursor: default;
|
|
105
|
+
}
|
|
75
106
|
figure-v3-preview {
|
|
76
107
|
min-width: 0;
|
|
77
108
|
min-height: 0;
|
|
@@ -220,12 +251,21 @@ export class FigureV3Editor extends LitElement {
|
|
|
220
251
|
color: var(--md-sys-color-on-error-container);
|
|
221
252
|
white-space: pre-wrap;
|
|
222
253
|
}
|
|
254
|
+
.notice {
|
|
255
|
+
padding: 8px 10px;
|
|
256
|
+
background: var(--md-sys-color-surface-container);
|
|
257
|
+
color: var(--md-sys-color-on-surface);
|
|
258
|
+
}
|
|
223
259
|
.hint {
|
|
224
260
|
color: var(--md-sys-color-on-surface-variant);
|
|
225
261
|
line-height: 1.5;
|
|
226
262
|
}
|
|
227
263
|
`]
|
|
228
264
|
@property({ attribute: false }) session?: V3ProductSession
|
|
265
|
+
/** The recipes this domain sees (ADR-0095), given by the page. */
|
|
266
|
+
@property({ attribute: false }) recipes: V3Recipe[] = []
|
|
267
|
+
/** Why the last recipe was refused, in the kernel's words. */
|
|
268
|
+
@state() private recipeError = ''
|
|
229
269
|
@property({ type: Number }) revision = 0
|
|
230
270
|
/** The verdict's count, drawn on its tab so it stays in sight while another tab is open. */
|
|
231
271
|
@property({ type: Number }) instances?: number
|
|
@@ -245,6 +285,10 @@ export class FigureV3Editor extends LitElement {
|
|
|
245
285
|
@state() private panes?: OxThreePane
|
|
246
286
|
/** What the middle shows: the parts being edited, or the figure placed on a board at eight sizes. */
|
|
247
287
|
@state() private middle: 'edit' | 'preview' = 'edit'
|
|
288
|
+
/** The gizmo shown on the selected part, as the tools in the bar and W · E · R choose it; none once the part changes. */
|
|
289
|
+
@state() private gizmoMode?: 'translate' | 'rotate' | 'scale'
|
|
290
|
+
/** A line saying what a command did besides what was asked; gone at the next command. */
|
|
291
|
+
@state() private notice = ''
|
|
248
292
|
/**
|
|
249
293
|
* How the workbench shows the figure, in the board's own state names. The viewer's, kept in
|
|
250
294
|
* their browser, never in the figure.
|
|
@@ -274,6 +318,33 @@ export class FigureV3Editor extends LitElement {
|
|
|
274
318
|
/** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
|
|
275
319
|
@state() private live: LiveValues = NOTHING_LIVE
|
|
276
320
|
private lastContext: string | null = null
|
|
321
|
+
/**
|
|
322
|
+
* Move, turn and size on the canvas, in the bar's tools: the gizmo the W · E · R keys give, found without knowing the
|
|
323
|
+
* keys (2026-09-26 -- a person dragging the part turned the camera). Each is offered only where the part's own
|
|
324
|
+
* numbers can take it (`gizmoReachOf`).
|
|
325
|
+
*/
|
|
326
|
+
private renderGizmoTools(editing: boolean) {
|
|
327
|
+
const reach = editing && this.selected ? this.reachOf(this.selected) : undefined
|
|
328
|
+
const tools = [
|
|
329
|
+
{ mode: 'translate', icon: 'open_with', key: 'W', can: !!reach?.translate },
|
|
330
|
+
{ mode: 'rotate', icon: '3d_rotation', key: 'E', can: !!reach?.rotate },
|
|
331
|
+
{ mode: 'scale', icon: 'open_in_full', key: 'R', can: !!reach?.scale }
|
|
332
|
+
] as const
|
|
333
|
+
return html`<span class="gizmo-tools" role="group" aria-label=${i18next.t('figure.label.gizmo-tools')}>
|
|
334
|
+
${tools.map(
|
|
335
|
+
tool => html`<button
|
|
336
|
+
tool
|
|
337
|
+
aria-pressed=${String(this.gizmoMode === tool.mode)}
|
|
338
|
+
?disabled=${!tool.can}
|
|
339
|
+
aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
|
|
340
|
+
title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
|
|
341
|
+
@click=${() => this.useGizmo(tool.mode)}
|
|
342
|
+
>
|
|
343
|
+
<md-icon>${tool.icon}</md-icon>
|
|
344
|
+
</button>`
|
|
345
|
+
)}
|
|
346
|
+
</span>`
|
|
347
|
+
}
|
|
277
348
|
/** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
|
|
278
349
|
private onKey = (e: KeyboardEvent) => {
|
|
279
350
|
const view = this.session?.read()
|
|
@@ -292,7 +363,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
292
363
|
part placed by a rule wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
|
|
293
364
|
*/
|
|
294
365
|
e.stopPropagation()
|
|
295
|
-
if (action === 'translate' || action === 'rotate' || action === 'scale') canvas
|
|
366
|
+
if (action === 'translate' || action === 'rotate' || action === 'scale') canvas && this.useGizmo(action)
|
|
296
367
|
else if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
|
|
297
368
|
else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
|
|
298
369
|
else if (action === 'duplicate') this.duplicatePart(this.selected)
|
|
@@ -316,6 +387,12 @@ export class FigureV3Editor extends LitElement {
|
|
|
316
387
|
this.hiddenParts = new Set()
|
|
317
388
|
}
|
|
318
389
|
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
390
|
+
if (_changed.has('selected')) this.gizmoMode = undefined
|
|
391
|
+
}
|
|
392
|
+
/** The scene toggles: the mode already shown takes the gizmo away. */
|
|
393
|
+
private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
|
|
394
|
+
this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')?.gizmo(mode)
|
|
395
|
+
this.gizmoMode = this.gizmoMode === mode ? undefined : mode
|
|
319
396
|
}
|
|
320
397
|
private run(action: () => unknown) {
|
|
321
398
|
try {
|
|
@@ -440,6 +517,37 @@ export class FigureV3Editor extends LitElement {
|
|
|
440
517
|
else next.add(id)
|
|
441
518
|
this.hiddenParts = next
|
|
442
519
|
}
|
|
520
|
+
/** Whether the figure declares its size (the three size inputs). */
|
|
521
|
+
private hasSize(): boolean {
|
|
522
|
+
const values = this.designValues()
|
|
523
|
+
return ['x', 'y', 'z'].every(axis => typeof values[`size.${axis}`] === 'number')
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* A recipe put in (ADR-0095): resolved on the figure as it stands -- its parts take the next free name, onto the part
|
|
527
|
+
* chosen -- and applied as one step. Refused, the kernel's sentence goes to the form as it is (condition 3).
|
|
528
|
+
*/
|
|
529
|
+
private insertRecipe(recipe: V3Recipe, values: Record<string, V3RecipeValue>) {
|
|
530
|
+
try {
|
|
531
|
+
const asset = JSON.parse(this.session!.source()) as V3Asset
|
|
532
|
+
const checked = parseV3Recipe(recipe)
|
|
533
|
+
const id = v3RecipeIdFor(asset, checked)
|
|
534
|
+
const steps = resolveV3Recipe(checked, { id, ...(checked.target === 'part' ? { target: this.selected } : {}), values })
|
|
535
|
+
this.session!.applyRecipe(steps)
|
|
536
|
+
this.recipeError = ''
|
|
537
|
+
this.error = ''
|
|
538
|
+
this.changed()
|
|
539
|
+
} catch (error) {
|
|
540
|
+
this.recipeError = error instanceof Error ? error.message : String(error)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** The design values of the asset being edited. */
|
|
544
|
+
private designValues(): Record<string, number> {
|
|
545
|
+
try {
|
|
546
|
+
return this.session ? (JSON.parse(this.session.source()).designInputs ?? {}) : {}
|
|
547
|
+
} catch {
|
|
548
|
+
return {}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
443
551
|
/** What the gizmo may write for the selected part — read off the asset being edited. */
|
|
444
552
|
private reachOf(id: string): GizmoReach {
|
|
445
553
|
const none = { translate: false, rotate: false, scale: false }
|
|
@@ -656,9 +764,20 @@ export class FigureV3Editor extends LitElement {
|
|
|
656
764
|
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
657
765
|
private linkCommand(action: V3AuthoringAction) {
|
|
658
766
|
this.retry = undefined
|
|
767
|
+
this.notice = ''
|
|
768
|
+
const hadSize = this.hasSize()
|
|
659
769
|
try {
|
|
660
770
|
this.session!.command(action)
|
|
661
771
|
this.error = ''
|
|
772
|
+
/* What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1). */
|
|
773
|
+
if (action.kind === 'follow-size' && !hadSize) {
|
|
774
|
+
const size = this.designValues()
|
|
775
|
+
this.notice = i18next.t('figure.text.size-declared-to-follow', {
|
|
776
|
+
x: Math.round(size['size.x'] ?? 0).toLocaleString(),
|
|
777
|
+
y: Math.round(size['size.y'] ?? 0).toLocaleString(),
|
|
778
|
+
z: Math.round(size['size.z'] ?? 0).toLocaleString()
|
|
779
|
+
})
|
|
780
|
+
}
|
|
662
781
|
this.changed()
|
|
663
782
|
} catch (error) {
|
|
664
783
|
const code = (error as { code?: string })?.code
|
|
@@ -681,9 +800,13 @@ export class FigureV3Editor extends LitElement {
|
|
|
681
800
|
private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
|
|
682
801
|
const input = event.target as HTMLInputElement,
|
|
683
802
|
value = input.valueAsNumber
|
|
803
|
+
/* A height typed in keeps the part's bottom where it is, as the scale gizmo does. */
|
|
804
|
+
const part = this.selected
|
|
805
|
+
const kept = field.role !== 'state' && part ? keepBottomChanges(part, this.reachOf(part), field.id, value, this.designValues()) : []
|
|
684
806
|
this.run(() =>
|
|
685
807
|
this.session!.apply([
|
|
686
|
-
{ kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
|
|
808
|
+
{ kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value },
|
|
809
|
+
...kept
|
|
687
810
|
])
|
|
688
811
|
)
|
|
689
812
|
// Rejected edits must also restore the displayed numeric value.
|
|
@@ -1002,6 +1125,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
1002
1125
|
@canvas-redo=${() => this.run(() => this.session!.redo())}
|
|
1003
1126
|
@canvas-fit=${() => this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')?.fit()}
|
|
1004
1127
|
>
|
|
1128
|
+
${this.renderGizmoTools(!unavailable && this.middle === 'edit')}
|
|
1005
1129
|
${(['edit', 'preview'] as const).map(
|
|
1006
1130
|
mode => html`<button slot="mode" mode aria-pressed=${String(this.middle === mode)} title=${i18next.t('figure.text.mode-' + mode + '-explained')} @click=${() => (this.middle = mode)}>
|
|
1007
1131
|
${i18next.t('figure.label.mode-' + mode)}
|
|
@@ -1018,6 +1142,7 @@ export class FigureV3Editor extends LitElement {
|
|
|
1018
1142
|
: null}
|
|
1019
1143
|
</div>`
|
|
1020
1144
|
: null}
|
|
1145
|
+
${this.notice ? html`<div role="status" class="notice">${this.notice}</div>` : null}
|
|
1021
1146
|
<ox-three-pane>
|
|
1022
1147
|
<figure-v3-parts
|
|
1023
1148
|
slot="start"
|
|
@@ -1029,6 +1154,9 @@ export class FigureV3Editor extends LitElement {
|
|
|
1029
1154
|
.selected=${this.selected}
|
|
1030
1155
|
?locked=${unavailable}
|
|
1031
1156
|
@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)}
|
|
1032
1160
|
@select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
|
|
1033
1161
|
@toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
|
|
1034
1162
|
@set-all-parts-hidden=${(e: CustomEvent) =>
|