@things-factory/figure-ui 10.1.91 → 10.1.92

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 (30) hide show
  1. package/client/modeller/editor.ts +61 -90
  2. package/client/modeller/fields.ts +8 -1
  3. package/client/modeller/figure-section-close-choice.ts +52 -0
  4. package/client/modeller/figure-section-editor.ts +58 -3
  5. package/client/modeller/figure-section-panel.ts +3 -0
  6. package/client/modeller/side-panel-styles.ts +4 -11
  7. package/dist-client/modeller/editor.d.ts +10 -4
  8. package/dist-client/modeller/editor.js +59 -88
  9. package/dist-client/modeller/editor.js.map +1 -1
  10. package/dist-client/modeller/fields.js +8 -1
  11. package/dist-client/modeller/fields.js.map +1 -1
  12. package/dist-client/modeller/figure-section-close-choice.d.ts +17 -0
  13. package/dist-client/modeller/figure-section-close-choice.js +58 -0
  14. package/dist-client/modeller/figure-section-close-choice.js.map +1 -0
  15. package/dist-client/modeller/figure-section-editor.d.ts +11 -1
  16. package/dist-client/modeller/figure-section-editor.js +57 -3
  17. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  18. package/dist-client/modeller/figure-section-panel.js +3 -0
  19. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  20. package/dist-client/modeller/side-panel-styles.js +4 -11
  21. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  22. package/dist-client/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +2 -2
  24. package/test/layout.test.ts +19 -4
  25. package/test/section-editor.test.ts +31 -8
  26. package/translations/en.json +19 -15
  27. package/translations/ja.json +15 -11
  28. package/translations/ko.json +19 -15
  29. package/translations/ms.json +15 -11
  30. package/translations/zh.json +15 -11
@@ -305,7 +305,7 @@ export class FigureEditor extends LitElement {
305
305
  [off] {
306
306
  display: none !important;
307
307
  }
308
- /* Which part the panel speaks of, and the way back to the whole figure. */
308
+ /* Which part the panel speaks of. */
309
309
  div[subject] {
310
310
  position: sticky;
311
311
  top: 0;
@@ -316,41 +316,6 @@ export class FigureEditor extends LitElement {
316
316
  background: var(--md-sys-color-surface-container-lowest);
317
317
  border-bottom: 1px solid var(--md-sys-color-outline-variant);
318
318
  }
319
- nav[path] {
320
- display: flex;
321
- align-items: center;
322
- gap: 6px;
323
- min-width: 0;
324
- }
325
- nav[path] button[model-crumb] {
326
- flex: none;
327
- max-width: 55%;
328
- height: 28px;
329
- padding: 0 8px;
330
- overflow: hidden;
331
- white-space: nowrap;
332
- text-overflow: ellipsis;
333
- border: 1px solid var(--md-sys-color-outline-variant);
334
- border-radius: 999em;
335
- background: none;
336
- color: var(--md-sys-color-primary);
337
- cursor: pointer;
338
- }
339
- nav[path] button[model-crumb][on] {
340
- border-color: var(--md-sys-color-primary);
341
- background: var(--md-sys-color-primary-container);
342
- }
343
- nav[path] button[model-crumb]:focus-visible {
344
- outline: 2px solid var(--md-sys-color-primary);
345
- outline-offset: 1px;
346
- }
347
- nav[path] span[current] {
348
- min-width: 0;
349
- overflow: hidden;
350
- white-space: nowrap;
351
- text-overflow: ellipsis;
352
- font-weight: 600;
353
- }
354
319
  div[subject-summary] {
355
320
  display: grid;
356
321
  gap: 1px;
@@ -651,7 +616,26 @@ export class FigureEditor extends LitElement {
651
616
  * The section editor is of one part: when the selection moves off it by any way -- another part picked, the scene's
652
617
  * own Esc, the part removed, another figure opened -- it closes and lets go of the preview it held.
653
618
  */
654
- if (this.sectionEditing && this.sectionEditing !== this.selected) this.closeSectionEditor()
619
+ if (this.sectionEditing && this.sectionEditing !== this.selected) this.leaveSectionEditor(this.selected)
620
+ }
621
+ /**
622
+ * The selection moved off the part being edited. With nothing changed there the editor closes; with changes, the part
623
+ * stays selected while the editor asks (apply, let go, keep editing), and the move goes on only once it closed. A part
624
+ * gone or another figure opened has nothing to go back to: the editor closes.
625
+ */
626
+ private leaveSectionEditor(next: string) {
627
+ const editing = this.sectionEditing!
628
+ const editor = this.renderRoot.querySelector('figure-section-editor') as (HTMLElement & { dirty: boolean; requestClose(): Promise<boolean> }) | null
629
+ const view = this.session?.read()
630
+ const stillThere = !!view?.geometry.some(g => ownerOf(g.id) === editing)
631
+ if (!editor?.dirty || !stillThere) {
632
+ this.closeSectionEditor()
633
+ return
634
+ }
635
+ this.selected = editing
636
+ editor.requestClose().then(closed => {
637
+ if (closed && this.selected === editing) this.selected = next
638
+ })
655
639
  }
656
640
  /** The scene toggles: the mode already shown takes the gizmo away. */
657
641
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
@@ -1003,14 +987,11 @@ export class FigureEditor extends LitElement {
1003
987
  }
1004
988
  private renderPlaneAlign(asset: FigureAsset, unavailable: boolean) {
1005
989
  const placement = asset.placement ?? 'floor'
1006
- const icon = { floor: 'vertical_align_bottom', ceiling: 'vertical_align_top', wall: 'flip_to_back', center: 'vertical_align_center' }[placement]
1007
990
  /* A tilted part touches the plane at a point or an edge, a ball at its lowest; the tooltip says so rather than promise a face (designer 2026-09-27). */
1008
991
  const contact = this.planeContactOf(this.session!.source(), this.selected)
1009
992
  const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
1010
- return html`<button plane-align=${placement} ?disabled=${unavailable} aria-label=${i18next.t(`figure.button.align-to-${placement}`)}
1011
- title=${i18next.t(`figure.text.align-to-${placement}${told}`)} @click=${() => this.alignToPlane()}>
1012
- <md-icon>${icon}</md-icon>
1013
- </button>`
993
+ return html`<button plane-align=${placement} ?disabled=${unavailable} title=${i18next.t(`figure.text.align-to-${placement}${told}`)}
994
+ @click=${() => this.alignToPlane()}>${i18next.t(`figure.button.align-to-${placement}`)}</button>`
1014
995
  }
1015
996
  private alignToPlane() {
1016
997
  const asset = parseFigureAsset(this.session!.source())
@@ -1031,27 +1012,33 @@ export class FigureEditor extends LitElement {
1031
1012
  } catch {
1032
1013
  return null
1033
1014
  }
1034
- const tool = (name: AlignTool, icon: string, label: string, title: string) =>
1035
- html`<button ?disabled=${unavailable} aria-label=${i18next.t(label)} title=${i18next.t(title)} @click=${() => this.align(name)}>
1036
- <md-icon>${icon}</md-icon>
1037
- </button>`
1038
- return html`<div tools title=${i18next.t('figure.text.tools-explained')}>
1039
- ${tool('centre-x', 'horizontal_distribute', 'figure.button.center-x', 'figure.text.center-on-x')}
1040
- ${tool('centre-z', 'vertical_align_center', 'figure.button.center-z', 'figure.text.center-on-z')}
1041
- ${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
1042
- ${(['x', 'z'] as const).map(axis => {
1043
- const able = canMirror(asset as never, this.selected, axis, reach)
1044
- return html`<button
1045
- ?disabled=${unavailable || !able}
1046
- aria-label=${i18next.t('figure.button.mirror-' + axis)}
1047
- title=${i18next.t(able ? 'figure.text.mirror-explained' : 'figure.text.mirror-would-overlap')}
1048
- @click=${() => this.mirror(axis)}
1049
- >
1050
- <md-icon>${axis === 'x' ? 'flip' : 'flip_camera_android'}</md-icon>
1051
- </button>`
1052
- })}
1053
- ${tool('snap', 'grid_on', 'figure.button.snap', 'figure.text.snap-explained')}
1054
- </div>`
1015
+ /*
1016
+ * In words, two rows by what they do (user 2026-10-01: 「무엇을 하는 것인지 모르겠다」): moving the part to the figure's
1017
+ * centre lines, onto its plane or onto the grid; and making a mirrored copy, which makes a part and moves nothing.
1018
+ */
1019
+ const tool = (name: AlignTool, label: string, title: string) =>
1020
+ html`<button ?disabled=${unavailable} title=${i18next.t(title)} @click=${() => this.align(name)}>${i18next.t(label)}</button>`
1021
+ const mirrors = (['x', 'z'] as const).map(axis => ({ axis, able: canMirror(asset as never, this.selected, axis, reach) }))
1022
+ return html`<div vector-row>
1023
+ <label>${i18next.t('figure.label.tools')}</label>
1024
+ <div tools>
1025
+ ${tool('centre-x', 'figure.button.center-x', 'figure.text.center-on-x')}
1026
+ ${tool('centre-z', 'figure.button.center-z', 'figure.text.center-on-z')}
1027
+ ${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
1028
+ ${tool('snap', 'figure.button.snap', 'figure.text.snap-explained')}
1029
+ </div>
1030
+ </div>
1031
+ <div vector-row>
1032
+ <label>${i18next.t('figure.label.mirror-copy')}</label>
1033
+ <div tools>
1034
+ ${mirrors.map(
1035
+ ({ axis, able }) => html`<button ?disabled=${unavailable || !able} title=${i18next.t('figure.text.mirror-explained')} @click=${() => this.mirror(axis)}>
1036
+ ${i18next.t('figure.button.mirror-' + axis)}
1037
+ </button>`
1038
+ )}
1039
+ </div>
1040
+ </div>
1041
+ ${mirrors.every(m => !m.able) ? html`<p hint>${i18next.t('figure.text.mirror-would-overlap')}</p>` : nothing}`
1055
1042
  }
1056
1043
  /**
1057
1044
  * A part's name. It renames every value the part owns, and what refers to them follows
@@ -1872,36 +1859,27 @@ export class FigureEditor extends LitElement {
1872
1859
  </div>`
1873
1860
  }
1874
1861
  /**
1875
- * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
1876
- * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
1877
- * model name is the same move as the parts list's root; there is no separate "whole figure" link.
1862
+ * The part the panel speaks of, held at its top: its kind, how its size answers, what holds it (designer 2026-09-27,
1863
+ * B). No path above it (user 2026-10-01): the parts list and the canvas already show which part is chosen, and the
1864
+ * list's root is the way back to the model's settings.
1878
1865
  */
1879
1866
  private renderSubject() {
1880
- const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name')
1881
1867
  let summary: { kind: string; size: string; relation: string } | undefined
1882
1868
  try {
1883
1869
  summary = this.selected ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined
1884
1870
  } catch {
1885
1871
  summary = undefined
1886
1872
  }
1873
+ if (!summary) return nothing
1887
1874
  return html`<div subject>
1888
- <nav path aria-label=${i18next.t('figure.label.panel-path')}>
1889
- <button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
1890
- aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
1891
- ${this.selected
1892
- ? html`<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
1893
- : html`<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
1894
- </nav>
1895
- ${summary
1896
- ? html`<div subject-summary>
1897
- ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1898
- <small>${summary.size}</small><small>${summary.relation}</small>
1899
- </div>`
1900
- : nothing}
1875
+ <div subject-summary>
1876
+ ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1877
+ <small>${summary.size}</small><small>${summary.relation}</small>
1878
+ </div>
1901
1879
  </div>`
1902
1880
  }
1903
1881
  /**
1904
- * The model's settings: the same move from the parts list's root and from the panel's path (B.1). It shows them on
1882
+ * The model's settings, from the parts list's root (B.1). It shows them on
1905
1883
  * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
1906
1884
  */
1907
1885
  private showModelSettings() {
@@ -2144,7 +2122,7 @@ export class FigureEditor extends LitElement {
2144
2122
  ? html`<section>
2145
2123
  <h3>${i18next.t(vectorTitle)}</h3>
2146
2124
  ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
2147
- ${tools ? html`<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
2125
+ ${tools ?? nothing}
2148
2126
  </section>`
2149
2127
  : nothing}
2150
2128
  ${groups.singles.length
@@ -2255,14 +2233,7 @@ export class FigureEditor extends LitElement {
2255
2233
  if (agreed) this.linkCommand(refit.step)
2256
2234
  }
2257
2235
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
2258
- /* A drawn section's point is called by its number and axis, as the section editor calls it (ADR-0100 §2.1, Q9). */
2259
- const point = /^point\.p(\d+)\.([xyz])$/.exec(name)
2260
- const apex = /^apex\.([xyz])$/.exec(name)
2261
- const words = point
2262
- ? [i18next.t('figure.label.section-editor-coordinate', { point: point[1]!, axis: point[2]!.toUpperCase() })]
2263
- : apex
2264
- ? [`${i18next.t('figure.label.section-editor-apex-mark')} ${apex[1]!.toUpperCase()}`]
2265
- : name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2236
+ const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
2266
2237
  return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
2267
2238
  ><input
2268
2239
  aria-label=${field.id}
@@ -46,7 +46,14 @@ export function groupFields(fields: readonly FieldLike[], part?: string): { vect
46
46
  AXES.forEach((axis, i) => byId.has(ids[i]!) && (cells[axis] = byId.get(ids[i]!)!))
47
47
  vectors.push({ kind: 'vector', name, cells })
48
48
  }
49
- const singles: FieldGroup[] = own.filter(f => !used.has(f.id)).map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
49
+ /*
50
+ A drawn section's points and a pyramid's apex are edited in the section editor, where a move is checked as it is made
51
+ and the points stand in the outline's order (user 2026-10-01): not here, one field each, in the order they were made.
52
+ */
53
+ const drawn = (id: string) => /^(point\.[^.]+|apex)\.[xyz]$/.test(id.slice(prefix.length))
54
+ const singles: FieldGroup[] = own
55
+ .filter(f => !used.has(f.id) && !drawn(f.id))
56
+ .map(field => ({ kind: 'single', name: nameOf(field.id.slice(prefix.length)), field }))
50
57
  return { vectors, singles }
51
58
  }
52
59
 
@@ -0,0 +1,52 @@
1
+ import { LitElement, html, css, nothing } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import { closePopup } from '@operato/popup'
5
+ import '@operato/popup/ox-popup-actions.js'
6
+ import { CommonPopupStyles } from '@operato/styles'
7
+ import '@material/web/button/filled-button.js'
8
+ import '@material/web/button/text-button.js'
9
+
10
+ /** What to do with a section edit not applied yet, when its editor is about to close (user 2026-10-01). */
11
+ export type SectionCloseChoice = 'apply' | 'discard'
12
+
13
+ /**
14
+ * Closing the section editor with changes not applied: apply them, let them go, or keep editing. Closes with the choice,
15
+ * or with nothing -- then the editor stays open as it was. Apply is not offered while the working shape would be refused
16
+ * or the figure changed since the editor opened; the words say which.
17
+ */
18
+ @customElement('figure-section-close-choice')
19
+ export class FigureSectionCloseChoice extends LitElement {
20
+ static styles = [
21
+ CommonPopupStyles,
22
+ css`
23
+ :host {
24
+ overflow-y: auto;
25
+ }
26
+ p {
27
+ margin: 0 0 12px;
28
+ }
29
+ `
30
+ ]
31
+
32
+ /** Why the working shape cannot be applied now, or empty when it can. */
33
+ @property({ type: String }) held = ''
34
+
35
+ render() {
36
+ return html`
37
+ <p>${i18next.t('figure.text.section-editor-close-changed')}</p>
38
+ ${this.held ? html`<p>${this.held}</p>` : nothing}
39
+ <ox-popup-actions>
40
+ <md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.keep-editing')}</md-text-button>
41
+ <md-text-button slot="start" destructive @click=${() => closePopup(this, 'discard' satisfies SectionCloseChoice)}
42
+ >${i18next.t('figure.button.section-editor-discard')}</md-text-button
43
+ >
44
+ ${this.held
45
+ ? nothing
46
+ : html`<md-filled-button slot="confirm" @click=${() => closePopup(this, 'apply' satisfies SectionCloseChoice)}
47
+ >${i18next.t('figure.button.section-editor-apply-and-close')}</md-filled-button
48
+ >`}
49
+ </ox-popup-actions>
50
+ `
51
+ }
52
+ }
@@ -8,6 +8,9 @@ import type { FigureAuthoringAction, FigureEvaluatedShape, FigureSection, Figure
8
8
  import { SidePanelStyles } from './side-panel-styles.js'
9
9
  import { apexOver, drawnOf, insertAfter, markedBy, movePoint, pointLabel, removePoint, sectionOf, workingIssuesOf, workingOf, type SectionWorking } from './section-drawing.js'
10
10
  import { END_FACE } from './section-controls.js'
11
+ import { openPopup } from '@operato/layout'
12
+ import './figure-section-close-choice.js'
13
+ import type { SectionCloseChoice } from './figure-section-close-choice.js'
11
14
 
12
15
  type Axis = 'x' | 'y' | 'z'
13
16
 
@@ -34,7 +37,8 @@ export const NARROW_BELOW = 900
34
37
  *
35
38
  * What the kernel would refuse is marked on the outline and said in the list; apply is held back, and the solid shows
36
39
  * the last valid state, which the caption names. Apply sends one `set-section` command (one step of the history); cancel
37
- * or Esc sends nothing and lets the preview go. The undo here is the editor's own. If the figure changes while the editor
40
+ * or Esc sends nothing and lets the preview go -- after asking, when something was changed: apply it, let it go, or keep
41
+ * editing (`requestClose`). The undo here is the editor's own. If the figure changes while the editor
38
42
  * is open, apply is refused until it is opened again.
39
43
  */
40
44
  @customElement('figure-section-editor')
@@ -306,10 +310,12 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
306
310
  */
307
311
  private keys = (e: KeyboardEvent) => {
308
312
  const inField = (e.composedPath()[0] as HTMLElement)?.tagName === 'INPUT'
313
+ /* While the close is being asked, the question has the keys: Esc there is 계속 편집. */
314
+ if (this.asking) return
309
315
  const take = () => (e.preventDefault(), e.stopImmediatePropagation())
310
316
  if (e.key === 'Escape') {
311
317
  take()
312
- this.cancel()
318
+ this.requestClose()
313
319
  } else if ((e.key === 'Delete' || e.key === 'Backspace') && !inField && this.selected) {
314
320
  /* In a field the key deletes a character; outside, the chosen point. */
315
321
  take()
@@ -390,6 +396,55 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
390
396
  this.close()
391
397
  }
392
398
 
399
+ /** Whether anything was changed since the editor opened. */
400
+ get dirty(): boolean {
401
+ const key = (w: SectionWorking) =>
402
+ JSON.stringify([
403
+ w.points.map(p => [p.id, p.x ?? 0, p.y ?? 0, p.z ?? 0]),
404
+ Object.entries(w.across).sort(),
405
+ w.length,
406
+ w.apex ? Object.entries(w.apex).sort() : null
407
+ ])
408
+ return !!this.opened && key(this.working) !== key(this.opened)
409
+ }
410
+
411
+ private asking = false
412
+ /**
413
+ * Close, asking first when something was changed (user 2026-10-01): apply it, let it go, or keep editing. True when the
414
+ * editor closed; false when the person keeps editing.
415
+ */
416
+ async requestClose(): Promise<boolean> {
417
+ if (!this.dirty) {
418
+ this.cancel()
419
+ return true
420
+ }
421
+ if (this.asking) return false
422
+ const held = this.stale
423
+ ? i18next.t('figure.text.section-editor-stale')
424
+ : this.valid(this.working)
425
+ ? ''
426
+ : i18next.t('figure.text.section-editor-close-invalid')
427
+ this.asking = true
428
+ try {
429
+ const choice = await openPopup<SectionCloseChoice>(html`<figure-section-close-choice .held=${held}></figure-section-close-choice>`, {
430
+ backdrop: true,
431
+ size: 'small',
432
+ title: i18next.t('figure.label.section-editor')
433
+ }).result
434
+ if (choice === 'apply' && !held) {
435
+ this.apply()
436
+ return true
437
+ }
438
+ if (choice === 'discard') {
439
+ this.cancel()
440
+ return true
441
+ }
442
+ return false
443
+ } finally {
444
+ this.asking = false
445
+ }
446
+ }
447
+
393
448
  private cancel() {
394
449
  this.dispatchEvent(new CustomEvent('command-live', { detail: { action: null }, bubbles: true, composed: true }))
395
450
  this.close()
@@ -441,7 +496,7 @@ export class FigureSectionEditor extends localize(i18next)(LitElement) {
441
496
  ? i18next.t('figure.text.section-editor-stale')
442
497
  : i18next.t(this.kind === 'polygon' ? 'figure.text.section-editor-hint' : 'figure.text.section-editor-hint-sized')}
443
498
  </span>
444
- <button @click=${() => this.cancel()}>${i18next.t('figure.button.section-editor-cancel')}</button>
499
+ <button @click=${() => this.requestClose()}>${i18next.t('figure.button.section-editor-cancel')}</button>
445
500
  <button primary ?disabled=${this.stale || words.length > 0} @click=${() => this.apply()}>${i18next.t('figure.button.section-editor-apply')}</button>
446
501
  </footer>
447
502
  `
@@ -106,6 +106,9 @@ export class FigureSectionPanel extends localize(i18next)(LitElement) {
106
106
  <button act ?disabled=${this.locked} @click=${() => this.dispatchEvent(new CustomEvent('section-editor-open', { bubbles: true, composed: true }))}>
107
107
  ${i18next.t('figure.button.section-editor-open')}
108
108
  </button>
109
+ ${e.section.kind === 'polygon'
110
+ ? html`<small>${i18next.t(e.apex ? 'figure.text.section-points-apex' : 'figure.text.section-points', { points: String(e.section.points.length) })}</small>`
111
+ : nothing}
109
112
  </div>`
110
113
  : nothing}
111
114
  ${kind === 'rectangle' ? this.renderCorner(body) : nothing}
@@ -101,19 +101,17 @@ export const SidePanelStyles = css`
101
101
  div[vector-row] + div[inline-field] {
102
102
  margin-top: 5px;
103
103
  }
104
- /* A row of icon tools (align, mirror, snap). */
104
+ /* A row of worded tools (align, snap; mirror copies). */
105
105
  div[tools] {
106
106
  display: flex;
107
107
  flex-wrap: wrap;
108
108
  gap: 4px;
109
109
  }
110
110
  div[tools] button {
111
- display: inline-flex;
112
- align-items: center;
113
- justify-content: center;
114
- width: 30px;
115
111
  height: 28px;
116
- padding: 0;
112
+ padding: 0 10px;
113
+ font: inherit;
114
+ font-size: 0.78rem;
117
115
  color: var(--md-sys-color-on-surface);
118
116
  background: var(--md-sys-color-surface-container-lowest);
119
117
  border: 1px solid var(--md-sys-color-outline-variant);
@@ -127,11 +125,6 @@ export const SidePanelStyles = css`
127
125
  opacity: 0.45;
128
126
  cursor: default;
129
127
  }
130
- div[tools] md-icon {
131
- font-size: 17px;
132
- width: 17px;
133
- height: 17px;
134
- }
135
128
  div[pair] {
136
129
  display: flex;
137
130
  gap: 4px;
@@ -114,6 +114,12 @@ export declare class FigureEditor extends LitElement {
114
114
  private propertiesPanel;
115
115
  private foldsOf;
116
116
  protected willUpdate(_changed: PropertyValues): void;
117
+ /**
118
+ * The selection moved off the part being edited. With nothing changed there the editor closes; with changes, the part
119
+ * stays selected while the editor asks (apply, let go, keep editing), and the move goes on only once it closed. A part
120
+ * gone or another figure opened has nothing to go back to: the editor closes.
121
+ */
122
+ private leaveSectionEditor;
117
123
  /** The scene toggles: the mode already shown takes the gizmo away. */
118
124
  private useGizmo;
119
125
  /**
@@ -303,13 +309,13 @@ export declare class FigureEditor extends LitElement {
303
309
  private pointBlockedOf;
304
310
  private renderStart;
305
311
  /**
306
- * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
307
- * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
308
- * model name is the same move as the parts list's root; there is no separate "whole figure" link.
312
+ * The part the panel speaks of, held at its top: its kind, how its size answers, what holds it (designer 2026-09-27,
313
+ * B). No path above it (user 2026-10-01): the parts list and the canvas already show which part is chosen, and the
314
+ * list's root is the way back to the model's settings.
309
315
  */
310
316
  private renderSubject;
311
317
  /**
312
- * The model's settings: the same move from the parts list's root and from the panel's path (B.1). It shows them on
318
+ * The model's settings, from the parts list's root (B.1). It shows them on
313
319
  * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
314
320
  */
315
321
  private showModelSettings;