@things-factory/figure-ui 10.1.67 → 10.1.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/client/graphql/index.ts +16 -0
  2. package/client/modeller/figure-v3-links.ts +73 -7
  3. package/client/modeller/figure-v3-parts.ts +153 -2
  4. package/client/modeller/v3-editor.ts +133 -5
  5. package/client/modeller/v3-gizmo.ts +18 -2
  6. package/client/modeller/v3-links.ts +71 -2
  7. package/client/modeller/v3-palette.ts +2 -1
  8. package/client/modeller/v3-product-session.ts +8 -0
  9. package/client/modeller/v3-recipes.ts +51 -0
  10. package/client/pages/figure-v3-modeller-page.ts +25 -2
  11. package/dist-client/graphql/index.d.ts +5 -0
  12. package/dist-client/graphql/index.js +15 -0
  13. package/dist-client/graphql/index.js.map +1 -1
  14. package/dist-client/modeller/figure-v3-links.d.ts +10 -0
  15. package/dist-client/modeller/figure-v3-links.js +69 -8
  16. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  17. package/dist-client/modeller/figure-v3-parts.d.ts +15 -1
  18. package/dist-client/modeller/figure-v3-parts.js +166 -1
  19. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  20. package/dist-client/modeller/v3-editor.d.ts +26 -0
  21. package/dist-client/modeller/v3-editor.js +147 -4
  22. package/dist-client/modeller/v3-editor.js.map +1 -1
  23. package/dist-client/modeller/v3-gizmo.d.ts +6 -0
  24. package/dist-client/modeller/v3-gizmo.js +20 -2
  25. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  26. package/dist-client/modeller/v3-links.d.ts +27 -0
  27. package/dist-client/modeller/v3-links.js +68 -2
  28. package/dist-client/modeller/v3-links.js.map +1 -1
  29. package/dist-client/modeller/v3-palette.js +2 -1
  30. package/dist-client/modeller/v3-palette.js.map +1 -1
  31. package/dist-client/modeller/v3-product-session.d.ts +34 -0
  32. package/dist-client/modeller/v3-product-session.js +8 -0
  33. package/dist-client/modeller/v3-product-session.js.map +1 -1
  34. package/dist-client/modeller/v3-recipes.d.ts +16 -0
  35. package/dist-client/modeller/v3-recipes.js +51 -0
  36. package/dist-client/modeller/v3-recipes.js.map +1 -0
  37. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  38. package/dist-client/pages/figure-v3-modeller-page.js +29 -2
  39. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  40. package/dist-client/route.d.ts +1 -1
  41. package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
  42. package/dist-client/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +5 -5
  44. package/test/browser/modeller-page.js +15 -1
  45. package/test/browser/modeller.check.mjs +226 -4
  46. package/test/modeller-route.test.ts +9 -0
  47. package/test/v3-gizmo.test.ts +44 -0
  48. package/test/v3-links.test.ts +91 -1
  49. package/test/v3-recipes.test.ts +44 -0
  50. package/translations/en.json +18 -8
  51. package/translations/ja.json +18 -8
  52. package/translations/ko.json +18 -8
  53. package/translations/ms.json +18 -8
  54. package/translations/zh.json +18 -8
@@ -332,3 +332,19 @@ 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
+ }
@@ -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
- return followableAxes(asset, this.partId).map(axis => {
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
- const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
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) => (this.fasten = { ...f, toPlane: (e.target as HTMLInputElement).checked })} />
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) => (this.fasten = { ...f, target: (e.target as HTMLSelectElement).value })}>
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) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
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
+ div[recipe-form] label[field] {
246
+ display: grid;
247
+ grid-template-columns: 1fr 5.5em;
248
+ align-items: center;
249
+ gap: 6px;
250
+ }
251
+ div[recipe-form] input[type='number'],
252
+ div[recipe-form] select {
253
+ min-width: 0;
254
+ font: inherit;
255
+ }
256
+ div[recipe-form] p[hint] {
257
+ margin: 0;
258
+ color: var(--md-sys-color-on-surface-variant);
259
+ }
260
+ div[recipe-form] p[error] {
261
+ margin: 0;
262
+ padding: 6px;
263
+ white-space: pre-wrap;
264
+ color: var(--md-sys-color-on-error-container);
265
+ background: var(--md-sys-color-error-container);
266
+ border-radius: 6px;
267
+ }
268
+ div[recipe-actions] {
269
+ display: flex;
270
+ gap: 6px;
271
+ }
272
+ div[recipe-actions] button {
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?.gizmo(action)
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) =>
@@ -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
+ }