@things-factory/figure-ui 10.1.168 → 10.1.170

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 (123) hide show
  1. package/client/figure-assistant.ts +10 -1
  2. package/client/modeller/affected-parts.ts +11 -0
  3. package/client/modeller/ai-proposal.ts +40 -9
  4. package/client/modeller/canvas.ts +15 -1
  5. package/client/modeller/editor-styles.ts +98 -6
  6. package/client/modeller/editor.ts +212 -53
  7. package/client/modeller/fasten-geometry.ts +19 -0
  8. package/client/modeller/figure-fasten-drawing.ts +4 -3
  9. package/client/modeller/figure-links.ts +80 -37
  10. package/client/modeller/figure-material-panel.ts +20 -1
  11. package/client/modeller/figure-preview.ts +117 -8
  12. package/client/modeller/figure-properties.ts +1 -2
  13. package/client/modeller/figure-section-panel.ts +5 -0
  14. package/client/modeller/figure-verdict.ts +9 -2
  15. package/client/modeller/gate-findings.ts +12 -4
  16. package/client/modeller/group-menu.ts +57 -0
  17. package/client/modeller/links.ts +22 -1
  18. package/client/modeller/modeller-keys.ts +6 -0
  19. package/client/modeller/preview-size.ts +32 -0
  20. package/client/modeller/product-session.ts +23 -9
  21. package/client/modeller/refusals.ts +14 -0
  22. package/client/modeller/stage-model.ts +11 -0
  23. package/client/pages/figure-modeller-page.ts +15 -6
  24. package/dist-client/figure-assistant.d.ts +6 -0
  25. package/dist-client/figure-assistant.js +9 -1
  26. package/dist-client/figure-assistant.js.map +1 -1
  27. package/dist-client/modeller/affected-parts.d.ts +6 -0
  28. package/dist-client/modeller/affected-parts.js +12 -0
  29. package/dist-client/modeller/affected-parts.js.map +1 -0
  30. package/dist-client/modeller/ai-proposal.d.ts +15 -5
  31. package/dist-client/modeller/ai-proposal.js +39 -10
  32. package/dist-client/modeller/ai-proposal.js.map +1 -1
  33. package/dist-client/modeller/canvas.d.ts +3 -0
  34. package/dist-client/modeller/canvas.js +12 -1
  35. package/dist-client/modeller/canvas.js.map +1 -1
  36. package/dist-client/modeller/editor-styles.js +98 -6
  37. package/dist-client/modeller/editor-styles.js.map +1 -1
  38. package/dist-client/modeller/editor.d.ts +42 -1
  39. package/dist-client/modeller/editor.js +220 -51
  40. package/dist-client/modeller/editor.js.map +1 -1
  41. package/dist-client/modeller/fasten-geometry.d.ts +27 -0
  42. package/dist-client/modeller/fasten-geometry.js +14 -0
  43. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  44. package/dist-client/modeller/figure-fasten-drawing.js +4 -3
  45. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  46. package/dist-client/modeller/figure-links.d.ts +3 -1
  47. package/dist-client/modeller/figure-links.js +84 -38
  48. package/dist-client/modeller/figure-links.js.map +1 -1
  49. package/dist-client/modeller/figure-material-panel.d.ts +8 -1
  50. package/dist-client/modeller/figure-material-panel.js +24 -1
  51. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  52. package/dist-client/modeller/figure-preview.d.ts +12 -0
  53. package/dist-client/modeller/figure-preview.js +116 -5
  54. package/dist-client/modeller/figure-preview.js.map +1 -1
  55. package/dist-client/modeller/figure-properties.js +1 -2
  56. package/dist-client/modeller/figure-properties.js.map +1 -1
  57. package/dist-client/modeller/figure-section-panel.d.ts +1 -1
  58. package/dist-client/modeller/figure-section-panel.js +5 -0
  59. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  60. package/dist-client/modeller/figure-verdict.d.ts +1 -0
  61. package/dist-client/modeller/figure-verdict.js +8 -2
  62. package/dist-client/modeller/figure-verdict.js.map +1 -1
  63. package/dist-client/modeller/gate-findings.d.ts +3 -1
  64. package/dist-client/modeller/gate-findings.js +8 -4
  65. package/dist-client/modeller/gate-findings.js.map +1 -1
  66. package/dist-client/modeller/group-menu.d.ts +18 -0
  67. package/dist-client/modeller/group-menu.js +52 -0
  68. package/dist-client/modeller/group-menu.js.map +1 -0
  69. package/dist-client/modeller/links.d.ts +5 -0
  70. package/dist-client/modeller/links.js +21 -1
  71. package/dist-client/modeller/links.js.map +1 -1
  72. package/dist-client/modeller/modeller-keys.js +7 -0
  73. package/dist-client/modeller/modeller-keys.js.map +1 -1
  74. package/dist-client/modeller/preview-size.d.ts +23 -0
  75. package/dist-client/modeller/preview-size.js +32 -0
  76. package/dist-client/modeller/preview-size.js.map +1 -0
  77. package/dist-client/modeller/product-session.d.ts +0 -1
  78. package/dist-client/modeller/product-session.js +22 -9
  79. package/dist-client/modeller/product-session.js.map +1 -1
  80. package/dist-client/modeller/refusals.d.ts +1 -5
  81. package/dist-client/modeller/refusals.js +14 -1
  82. package/dist-client/modeller/refusals.js.map +1 -1
  83. package/dist-client/modeller/stage-model.d.ts +11 -0
  84. package/dist-client/modeller/stage-model.js +11 -0
  85. package/dist-client/modeller/stage-model.js.map +1 -1
  86. package/dist-client/pages/figure-modeller-page.d.ts +3 -0
  87. package/dist-client/pages/figure-modeller-page.js +18 -5
  88. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  89. package/dist-client/route.d.ts +1 -1
  90. package/dist-client/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +8 -8
  92. package/test/ai-proposal.test.ts +43 -4
  93. package/test/browser/editing-page.js +3 -1
  94. package/test/browser/editing.check.mjs +2 -1
  95. package/test/browser/links-page.js +1 -0
  96. package/test/browser/links.check.mjs +20 -5
  97. package/test/browser/modeller.check.mjs +6 -0
  98. package/test/browser/more-open.js +11 -0
  99. package/test/browser/parts.check.mjs +11 -1
  100. package/test/browser/places-mesh.check.mjs +9 -3
  101. package/test/browser/product-page.check.mjs +9 -4
  102. package/test/browser/props.check.mjs +224 -0
  103. package/test/browser/screen-page.js +17 -0
  104. package/test/browser/screen.check.mjs +171 -29
  105. package/test/browser/section-page.js +1 -0
  106. package/test/browser/section.check.mjs +6 -0
  107. package/test/command-preview.test.ts +2 -2
  108. package/test/fasten-geometry.test.ts +15 -0
  109. package/test/fields.test.ts +4 -2
  110. package/test/figure-assistant-surface.test.ts +38 -0
  111. package/test/first-minute.test.ts +15 -2
  112. package/test/layout.test.ts +1 -1
  113. package/test/preview-size.test.ts +33 -0
  114. package/test/refusals.test.ts +1 -1
  115. package/test/resize-with-placement.test.ts +1 -1
  116. package/test/stage-model.test.ts +11 -1
  117. package/test/verdict.test.ts +7 -0
  118. package/translations/en.json +43 -27
  119. package/translations/es.json +24 -8
  120. package/translations/ja.json +25 -9
  121. package/translations/ko.json +56 -40
  122. package/translations/ms.json +25 -9
  123. package/translations/zh.json +25 -9
@@ -55,7 +55,9 @@ import { nudgeOf, placementOf, type ScaleAnchor } from './stage-model.js'
55
55
 
56
56
  /** The numbers a rule gives a part's position, turn and size, per axis, for the cells that have no field. */
57
57
  type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
58
- import { roomRefitRowsOf } from './occupancy.js'
58
+ import { occupancyFromParts, roomRefitRowsOf } from './occupancy.js'
59
+ import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
60
+ import type { FigureGateFinding } from '../types.js'
59
61
  import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js'
60
62
  import type { FigureRenderPart } from './renderer.js'
61
63
  import type { FigureBudgetLine } from './maturity.js'
@@ -66,13 +68,22 @@ import { jointDragOf } from './joint-drag.js'
66
68
  import type { FirstSave } from './first-save.js'
67
69
  import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
68
70
  import { startingShapeKindOfBody } from './starting-shape.js'
71
+ import { groupMenu, GroupMenuStyles } from './group-menu.js'
69
72
 
70
73
 
71
74
  /**
72
- * The side's tabs: properties, what it does (places), motion, its data (ADR-0051), the verdict, the view, the release history. A figure-level tab (verdict, history) gives way to properties when a part is chosen; a tab about
75
+ * The side's four tabs (chief architect 2026-10-16): properties, motion, its data (ADR-0051), the verdict. What it does
76
+ * (its places) is a group of the properties; the view is a menu over the canvas and the release history opens from the
77
+ * bar, since neither is about the part chosen. The verdict gives way to properties when a part is chosen; a tab about
73
78
  * parts keeps showing the newly chosen part.
74
79
  */
75
- type SideTab = 'properties' | 'capability' | 'motion' | 'data' | 'verdict' | 'view' | 'history'
80
+ type SideTab = 'properties' | 'motion' | 'data' | 'verdict'
81
+
82
+ /** What opens over the work from the bar: the view settings or the release history, one at a time. */
83
+ type BarOverlay = 'view' | 'history'
84
+
85
+ /** The person's own fold of the properties' 「더 보기」, kept in their browser. */
86
+ const MORE_OPEN_KEY = 'figure-properties-more-open'
76
87
 
77
88
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
78
89
  const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS }
@@ -80,10 +91,16 @@ const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS }
80
91
  @customElement('figure-editor')
81
92
  export class FigureEditor extends LitElement {
82
93
  /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
83
- static styles = [ScrollbarStyles, SidePanelStyles, EditorStyles]
94
+ static styles = [ScrollbarStyles, SidePanelStyles, gateFindingStyles, GroupMenuStyles, EditorStyles]
84
95
  @property({ attribute: false }) session?: FigureProductSession
85
96
  /** The saved figure's state and version, as the server gave them (the page reads the row). */
86
97
  @property({ attribute: false }) releaseState?: { state?: 'draft' | 'released'; version: number }
98
+ /**
99
+ * What blocks release now, as the page's verdict heard it: said beside 「발행」 with the way out at hand, so an author
100
+ * does not find it at the foot of the verdict tab (first 15 minutes, 2026-10-16; chief architect, Q1 B).
101
+ */
102
+ @property({ attribute: false }) releaseBlockers: FigureGateFinding[] = []
103
+ @state() private blockersOpen = false
87
104
  /** The recipe templates (ADR-0095), read by the page from the server. */
88
105
  @property({ attribute: false }) templates: RecipeTemplate[] = []
89
106
  /** Reads a template's guide; given by the page. */
@@ -115,6 +132,28 @@ export class FigureEditor extends LitElement {
115
132
  /** The same link offered again with the size range narrowed, after a link the declared range could not hold. */
116
133
  @state() private retry?: Retry
117
134
  @state() private tab: SideTab = 'properties'
135
+ /** The view settings or the release history, open over the work from the bar. */
136
+ @state() private overlay?: BarOverlay
137
+ /** The capability group open: the canvas marks the places only while their group is in sight. */
138
+ @state() private capabilityOpen = false
139
+ /** 「더 보기」 open, as this person left it last (chief architect 2026-10-16: kept per person). */
140
+ @state() private moreOpen = FigureEditor.storedMoreOpen()
141
+ private static storedMoreOpen(): boolean {
142
+ try {
143
+ return localStorage.getItem(MORE_OPEN_KEY) === '1'
144
+ } catch {
145
+ return false
146
+ }
147
+ }
148
+ private setMoreOpen(open: boolean) {
149
+ if (open === this.moreOpen) return
150
+ this.moreOpen = open
151
+ try {
152
+ localStorage.setItem(MORE_OPEN_KEY, open ? '1' : '0')
153
+ } catch {
154
+ /* A browser that keeps nothing folds it again next time. */
155
+ }
156
+ }
118
157
  /** The frame the bar folds - found after the first render, since the bar is drawn before it. */
119
158
  @state() private panes?: OxThreePane
120
159
  /** What the middle shows: the parts being edited, or the figure placed on a board at eight sizes. */
@@ -713,7 +752,7 @@ export class FigureEditor extends LitElement {
713
752
  }
714
753
  }
715
754
  /** Size response, fastening and motion for the selected part, as authoring commands. */
716
- private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
755
+ private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion', slotted: unknown = nothing) {
717
756
  const shown = this.shown()
718
757
  if (!shown) return null
719
758
  return html`<figure-links
@@ -730,7 +769,126 @@ export class FigureEditor extends LitElement {
730
769
  @go-fasten=${() => this.goFasten()}
731
770
  @preview-command=${(e: CustomEvent) => this.commandPreview.ask(e.detail)}
732
771
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
733
- ></figure-links>`
772
+ >${slotted}</figure-links>`
773
+ }
774
+ /**
775
+ * A part's groups in one order whatever its state (chief architect 2026-10-16): its size, its place, its row. The links
776
+ * draw the three groups and their rules; the part's own numbers go into them by slot. A fastened part's place and turn
777
+ * are its fastening's: the place group says it in one line and the locked cells are not drawn (user 2026-10-16).
778
+ */
779
+ private renderPartGroups(shown: FigureRevision | undefined, geometry: readonly { id: string }[], fields: FieldLike[], unavailable: boolean, held?: HeldValues) {
780
+ const groups = this.withSizeRow(groupFields(fields, this.selected), held)
781
+ const size = groups.vectors.filter(g => g.kind === 'vector' && g.name === 'size')
782
+ const place = groups.vectors.filter(g => g.kind === 'vector' && g.name !== 'size')
783
+ const fastened = !!this.asset()?.joints?.some(j => j.body1.part === ownerOf(this.selected))
784
+ const placeLocked = fastened && place.every(g => g.kind === 'vector' && (['x', 'y', 'z'] as const).every(axis => !g.cells[axis]))
785
+ return this.renderLinks(
786
+ unavailable,
787
+ 'shape',
788
+ html`${size.length || groups.singles.length
789
+ ? html`<div slot="size" group-fields>
790
+ ${size.map(g => this.renderVector(g, unavailable, held))}
791
+ ${groups.singles.length
792
+ ? html`<div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>`
793
+ : nothing}
794
+ </div>`
795
+ : nothing}
796
+ ${place.length && !placeLocked
797
+ ? html`<div slot="place" group-fields>${place.map(g => this.renderVector(g, unavailable, held))}${this.renderHeldAxes(place)}</div>`
798
+ : nothing}
799
+ ${this.renderRepeat(shown, geometry, unavailable)}`
800
+ )
801
+ }
802
+ /**
803
+ * What the figure does -- its places (ADR-0118) -- as a group of the properties, folded: a declaration made once,
804
+ * whose marks on the canvas show while it is open. A verdict that finds it empty still says where it is.
805
+ */
806
+ private renderCapability(asset: FigureAsset | undefined, unavailable: boolean) {
807
+ return html`<section capability>
808
+ <details capability-fold ?open=${this.capabilityOpen} @toggle=${(e: Event) => (this.capabilityOpen = (e.target as HTMLDetailsElement).open)}>
809
+ <summary><h3>${i18next.t('figure.label.tab-capability')}</h3></summary>
810
+ ${this.selected ? this.renderLinks(unavailable, 'places') : nothing}
811
+ <figure-capability-panel
812
+ .asset=${asset}
813
+ .selected=${this.selected}
814
+ ?locked=${unavailable}
815
+ @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
816
+ @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
817
+ ></figure-capability-panel>
818
+ </details>
819
+ </section>`
820
+ }
821
+ /**
822
+ * 「더 보기」: what few parts need, folded by default and left as this person last left it (chief architect
823
+ * 2026-10-16) -- the section, its corners and inside; transparency, glow and segments; the mirrored copy and the
824
+ * alignment tools.
825
+ */
826
+ private renderMore(part: object, palette: Record<string, string>, unavailable: boolean) {
827
+ const tools = this.renderTools(unavailable)
828
+ return html`<section more>
829
+ <details more-fold ?open=${this.moreOpen} @toggle=${(e: Event) => this.setMoreOpen((e.target as HTMLDetailsElement).open)}>
830
+ <summary><h3>${i18next.t('figure.label.group-more')}</h3></summary>
831
+ <div more-body>
832
+ ${tools ? html`<div group-fields>${tools}</div>` : nothing}
833
+ <figure-section-panel
834
+ embedded
835
+ .body=${{ ...part, id: this.selected }}
836
+ ?locked=${unavailable}
837
+ .faceMapping=${this.faceMapping?.from === 'panel' ? this.faceMapping.need : undefined}
838
+ @face-mapped=${(e: CustomEvent) => this.mapFaces(e.detail.remap)}
839
+ @face-mapping-cancel=${() => (this.faceMapping = undefined)}
840
+ @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
841
+ @command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
842
+ @section-editor-open=${() => (this.sectionEditing = this.selected)}
843
+ ></figure-section-panel>
844
+ <figure-material-panel
845
+ show="more"
846
+ .body=${{ ...part, id: this.selected }}
847
+ .palette=${palette}
848
+ ?locked=${unavailable}
849
+ @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
850
+ @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
851
+ ></figure-material-panel>
852
+ </div>
853
+ </details>
854
+ </section>`
855
+ }
856
+ /** A bar button that opens the view settings or the release history over the work; pressed while it is open. */
857
+ private renderOverlayButton(which: BarOverlay, icon: string, label: string, slot?: string) {
858
+ const open = this.overlay === which
859
+ return html`<button
860
+ slot=${slot ?? nothing}
861
+ tool
862
+ bar-overlay=${which}
863
+ aria-pressed=${String(open)}
864
+ aria-label=${i18next.t(label)}
865
+ title=${i18next.t(label)}
866
+ @click=${() => (this.overlay = open ? undefined : which)}
867
+ >
868
+ <md-icon>${icon}</md-icon>
869
+ </button>`
870
+ }
871
+ /**
872
+ * The view settings or the release history, over the work under the bar. Both stay drawn, hidden when closed: the
873
+ * history keeps what it read, as the tabs kept it. Esc closes it.
874
+ */
875
+ private renderOverlay() {
876
+ const close = () => (this.overlay = undefined)
877
+ const panel = (which: BarOverlay, label: string, body: unknown) => html`<div
878
+ bar-overlay-panel=${which}
879
+ role="dialog"
880
+ aria-label=${i18next.t(label)}
881
+ ?off=${this.overlay !== which}
882
+ @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && (e.stopPropagation(), close())}
883
+ >
884
+ <div overlay-head>
885
+ <h3>${i18next.t(label)}</h3>
886
+ <button quiet aria-label=${i18next.t('figure.button.close')} title=${i18next.t('figure.button.close')} @click=${close}><md-icon>close</md-icon></button>
887
+ </div>
888
+ <div overlay-body>${body}</div>
889
+ </div>`
890
+ return html`${panel('view', 'figure.label.tab-environment', html`<figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>`)}
891
+ ${panel('history', 'figure.label.history', html`<slot name="history"></slot>`)}`
734
892
  }
735
893
  /**
736
894
  * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
@@ -789,6 +947,7 @@ export class FigureEditor extends LitElement {
789
947
  this.retry = undefined
790
948
  this.notice = ''
791
949
  const hadSize = this.hasSize()
950
+ const hadSpace = !!this.shown()?.asset.occupancy
792
951
  try {
793
952
  this.session!.command(action)
794
953
  this.error = ''
@@ -796,7 +955,9 @@ export class FigureEditor extends LitElement {
796
955
  /* What the command did besides what was asked, in one line: the figure's size declared to follow (ADR ruling Q1). */
797
956
  if (action.kind === 'follow-size' && !hadSize) {
798
957
  const size = this.designValues()
799
- this.notice = i18next.t('figure.text.size-declared-to-follow', {
958
+ /* The same step may have fitted the space the figure takes to its parts (figure-model, ruling 2026-10-16 Q1 A): one line says both. */
959
+ const spaceToo = !hadSpace && !!this.shown()?.asset.occupancy
960
+ this.notice = i18next.t(spaceToo ? 'figure.text.size-and-space-declared-to-follow' : 'figure.text.size-declared-to-follow', {
800
961
  x: Math.round(size['size.x'] ?? 0).toLocaleString(),
801
962
  y: Math.round(size['size.y'] ?? 0).toLocaleString(),
802
963
  z: Math.round(size['size.z'] ?? 0).toLocaleString()
@@ -1042,8 +1203,12 @@ export class FigureEditor extends LitElement {
1042
1203
  const longest = model.inputs.find(i => i.id === lengthRef)?.max
1043
1204
  const limit = model.constants.find(c => c.id === layout?.args[6])?.value
1044
1205
  const field = { id: own, unit: 'mm' as const, role: 'design', value: pitch ?? 0 }
1045
- return html`<section>
1046
- <h3>${i18next.t('figure.label.repeat')}</h3>
1206
+ const removable = !!asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
1207
+ return html`<section slot="row" row>
1208
+ <h3>
1209
+ ${i18next.t('figure.label.repeat')}
1210
+ ${groupMenu([{ label: i18next.t('figure.button.remove-repeat'), icon: 'close', disabled: unavailable || !removable, run: () => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected })) }])}
1211
+ </h3>
1047
1212
  <div inline-field><label>${i18next.t('figure.label.repeat-axis')}</label><span value>${axis.toUpperCase()}</span></div>
1048
1213
  <div inline-field><label>${i18next.t('figure.label.repeat-count')}</label><span value>${repeat.count}</span></div>
1049
1214
  <div inline-field>
@@ -1069,11 +1234,6 @@ export class FigureEditor extends LitElement {
1069
1234
  : i18next.t(fits ? 'figure.text.repeat-fits-length' : 'figure.text.repeat-fixed-count')}
1070
1235
  </p>
1071
1236
  ${this.renderPublicPitch(asset, pitch ?? 0, unavailable)}
1072
- ${asset.joints?.some(j => j.body1.part === this.selected && j.repeat)
1073
- ? html`<button act ?disabled=${unavailable} @click=${() => this.run(() => this.session!.command({ kind: 'remove-repeat', part: this.selected }))}>
1074
- <md-icon>close</md-icon>${i18next.t('figure.button.remove-repeat')}
1075
- </button>`
1076
- : nothing}
1077
1237
  </section>`
1078
1238
  }
1079
1239
  /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
@@ -1197,9 +1357,25 @@ export class FigureEditor extends LitElement {
1197
1357
  if (state === 'released') return i18next.t('figure.text.release-state-released', { version: String(version) })
1198
1358
  return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new')
1199
1359
  }
1200
- /** To the release that exists: the history tab's release section, not a second one here. */
1360
+ /** The findings that block release, under the bar beside 「발행」, each with the way out the verdict tab offers for it. */
1361
+ private renderReleaseBlockers(asset: FigureAsset | undefined, unavailable: boolean) {
1362
+ const parts = (asset?.appearance ?? []).map(look => look.target)
1363
+ const way = (code: string) =>
1364
+ (code === 'occupancy-undeclared' || code === 'part-outside-occupancy') && asset && parts.length
1365
+ ? html`<button act ?disabled=${unavailable} @click=${() => this.linkCommand(occupancyFromParts(asset))}>
1366
+ <md-icon>fit_screen</md-icon>${i18next.t('figure.button.fit-base-to-parts')}
1367
+ </button>`
1368
+ : nothing
1369
+ return html`<div release-blockers role="dialog" aria-label=${i18next.t('figure.text.must-fix-to-release')} @keydown=${(e: KeyboardEvent) => e.key === 'Escape' && (this.blockersOpen = false)}>
1370
+ <ul gate>
1371
+ <li head>${i18next.t('figure.text.must-fix-to-release')}</li>
1372
+ ${this.releaseBlockers.map(finding => renderGateFinding(finding, true, way(finding.code), parts))}
1373
+ </ul>
1374
+ </div>`
1375
+ }
1376
+ /** To the release that exists: the history's release section, opened from the bar, not a second one here. */
1201
1377
  private goRelease = async () => {
1202
- this.tab = 'history'
1378
+ this.overlay = 'history'
1203
1379
  await this.updateComplete
1204
1380
  await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1205
1381
  }
@@ -1289,7 +1465,7 @@ export class FigureEditor extends LitElement {
1289
1465
  /* A copy on the canvas selects its repeat: the part the author made. */
1290
1466
  this.selected = id ? ownerOf(id) : id
1291
1467
  this.faceMapping = undefined
1292
- if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
1468
+ if (id && this.tab === 'verdict') this.tab = 'properties'
1293
1469
  }
1294
1470
  private asset(): FigureAsset | undefined {
1295
1471
  return this.shown()?.asset
@@ -1304,6 +1480,8 @@ export class FigureEditor extends LitElement {
1304
1480
  }
1305
1481
  updated(changed: PropertyValues<this>) {
1306
1482
  super.updated?.(changed)
1483
+ /* Nothing blocks release any more: the list under the bar closes, and opens fresh when something does again. */
1484
+ if (changed.has('releaseBlockers') && !this.releaseBlockers.length && this.blockersOpen) this.blockersOpen = false
1307
1485
  this.refusals.tell(changed)
1308
1486
  this.panelMemory.restore(() => this.selected)
1309
1487
  const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
@@ -1381,7 +1559,6 @@ export class FigureEditor extends LitElement {
1381
1559
  </button>`
1382
1560
  )}
1383
1561
  </div>
1384
- <small>${i18next.t('figure.text.placement-is-intent')}</small>
1385
1562
  </section>
1386
1563
  <section>
1387
1564
  <h3>${i18next.t('figure.label.start-shape')}</h3>
@@ -1497,6 +1674,7 @@ export class FigureEditor extends LitElement {
1497
1674
  @canvas-fit=${() => this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.fit()}
1498
1675
  >
1499
1676
  ${this.renderGizmoTools(!unavailable && this.middle === 'edit')}
1677
+ ${this.renderOverlayButton('view', 'wb_sunny', 'figure.label.tab-environment')}
1500
1678
  ${(['edit', 'preview'] as const).map(
1501
1679
  mode => html`<button slot="mode" mode aria-pressed=${String(this.middle === mode)} title=${i18next.t('figure.text.mode-' + mode + '-explained')} @click=${() => (this.middle = mode)}>
1502
1680
  ${i18next.t('figure.label.mode-' + mode)}
@@ -1504,9 +1682,16 @@ export class FigureEditor extends LitElement {
1504
1682
  )}
1505
1683
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1506
1684
  <span slot="status" release-state>${this.releaseWords(view)}</span>
1685
+ ${this.renderOverlayButton('history', 'history', 'figure.label.history', 'commit')}
1686
+ ${this.releaseBlockers.length
1687
+ ? html`<button slot="commit" release-blocked aria-expanded=${String(this.blockersOpen)} @click=${() => (this.blockersOpen = !this.blockersOpen)}>
1688
+ <md-icon>error</md-icon>${i18next.t('figure.button.release-blocked', { count: this.releaseBlockers.length })}
1689
+ </button>`
1690
+ : nothing}
1507
1691
  <button slot="commit" publish ?disabled=${!view.id} title=${view.id ? '' : i18next.t('figure.text.save-the-figure-before-releasing')} @click=${this.goRelease}>${i18next.t('figure.button.release')}</button>
1508
1692
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
1509
1693
  </ox-canvas-bar>
1694
+ ${this.blockersOpen && this.releaseBlockers.length ? this.renderReleaseBlockers(asset, unavailable) : nothing}
1510
1695
  ${this.catalogOpen
1511
1696
  ? html`<figure-recipe-catalog
1512
1697
  .templates=${this.templates}
@@ -1573,7 +1758,7 @@ export class FigureEditor extends LitElement {
1573
1758
  .fan=${this.fanOf(shown, live)}
1574
1759
  .ghost=${this.previewShown().ghost}
1575
1760
  .highlight=${this.previewShown().highlight}
1576
- .marks=${this.tab === 'capability' ? this.slotMarks(view, asset) : NO_MARKS}
1761
+ .marks=${this.tab === 'properties' && this.capabilityOpen ? this.slotMarks(view, asset) : NO_MARKS}
1577
1762
  .palette=${view.palette ?? {}}
1578
1763
  .context=${view.context ?? ''}
1579
1764
  .selected=${this.selected}
@@ -1587,12 +1772,9 @@ export class FigureEditor extends LitElement {
1587
1772
  <aside slot="end">
1588
1773
  <div tabs>
1589
1774
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
1590
- ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
1591
1775
  ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
1592
1776
  ${this.renderTab('data', 'sensors', i18next.t('figure.label.tab-data'))}
1593
1777
  ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
1594
- ${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
1595
- ${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
1596
1778
  </div>
1597
1779
  <slot name="notice"></slot>
1598
1780
  <div panel ?off=${tab !== 'properties'}>
@@ -1612,27 +1794,17 @@ export class FigureEditor extends LitElement {
1612
1794
  </div>
1613
1795
  ${this.renderRepeatNote(view.geometry)}
1614
1796
  </section>
1615
- ${this.renderRepeat(shown, view.geometry, unavailable)}
1616
- ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
1797
+ ${this.renderPartGroups(shown, view.geometry, partFields, unavailable, held)}
1617
1798
  ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
1618
- ${this.renderLinks(unavailable, 'shape')}
1619
- <figure-section-panel
1620
- .body=${{ ...part, id: this.selected }}
1621
- ?locked=${unavailable}
1622
- .faceMapping=${this.faceMapping?.from === 'panel' ? this.faceMapping.need : undefined}
1623
- @face-mapped=${(e: CustomEvent) => this.mapFaces(e.detail.remap)}
1624
- @face-mapping-cancel=${() => (this.faceMapping = undefined)}
1625
- @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1626
- @command-live=${(e: CustomEvent) => this.holdAction(e.detail.action)}
1627
- @section-editor-open=${() => (this.sectionEditing = this.selected)}
1628
- ></figure-section-panel>
1629
1799
  <figure-material-panel
1630
1800
  .body=${{ ...part, id: this.selected }}
1631
1801
  .palette=${view.palette ?? {}}
1632
1802
  ?locked=${unavailable}
1633
1803
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1634
1804
  @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
1635
- ></figure-material-panel>`
1805
+ ></figure-material-panel>
1806
+ ${this.renderCapability(asset, unavailable)}
1807
+ ${this.renderMore(part, view.palette ?? {}, unavailable)}`
1636
1808
  : view.geometry.length === 0
1637
1809
  ? html`${this.renderSubject()}<section empty-figure>
1638
1810
  <p>${i18next.t('figure.text.empty-figure-next')}</p>
@@ -1643,18 +1815,8 @@ export class FigureEditor extends LitElement {
1643
1815
  </section>`
1644
1816
  : html`${this.renderSubject()}<slot name="figure"></slot>
1645
1817
  ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
1646
- ${this.renderInstanceRange(shown, unavailable)}`}
1647
- </div>
1648
- <div panel ?off=${tab !== 'capability'}>
1649
- ${this.renderSubject()}
1650
- ${this.selected && part ? this.renderLinks(unavailable, 'places') : null}
1651
- <figure-capability-panel
1652
- .asset=${asset}
1653
- .selected=${this.selected}
1654
- ?locked=${unavailable}
1655
- @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
1656
- @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1657
- ></figure-capability-panel>
1818
+ ${this.renderInstanceRange(shown, unavailable)}
1819
+ ${this.renderCapability(asset, unavailable)}`}
1658
1820
  </div>
1659
1821
  <div panel ?off=${tab !== 'motion'}>
1660
1822
  ${this.renderSubject()}
@@ -1672,12 +1834,9 @@ export class FigureEditor extends LitElement {
1672
1834
  ></figure-data-panel>
1673
1835
  </div>
1674
1836
  <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
1675
- <div panel flush ?off=${tab !== 'view'}>
1676
- <figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>
1677
- </div>
1678
- <div panel flush ?off=${tab !== 'history'}><slot name="history"></slot></div>
1679
1837
  </aside>
1680
- </ox-three-pane>`
1838
+ </ox-three-pane>
1839
+ ${this.renderOverlay()}`
1681
1840
  }
1682
1841
  /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
1683
1842
  private renderGroups(
@@ -185,3 +185,22 @@ export function screenOf(axes: readonly [Axis, Axis]): { h: Axis; v: Axis; sign:
185
185
  const v = axes.find(a => a !== h)!
186
186
  return { h, v, sign: { x: 1, y: 1, z: 1, [v]: v === 'y' ? -1 : 1 } as Record<Axis, 1 | -1> }
187
187
  }
188
+
189
+ /**
190
+ * Where the fastening drawing writes its two names so neither lies over the other (user 2026-10-16: 「roller」 over the
191
+ * roller's thin bar, and 「frame 윗면 2000 × 600」 cut by it). The face's name stands above the face, inside its top edge
192
+ * only when the face fills the drawing. The part's name stands in the part when the part is wide and tall enough for it,
193
+ * else just under it -- above it when the drawing ends first. Widths are estimated: 7 px a letter at 11-12 px.
194
+ */
195
+ export function fastenLabelsOf(
196
+ face: { x: number; y: number; w: number; h: number },
197
+ part: { x: number; y: number; w: number; h: number },
198
+ partName: string,
199
+ height: number
200
+ ): { face: { x: number; y: number }; part: { x: number; y: number; inside: boolean } } {
201
+ const facePlace = face.y >= 16 ? { x: face.x, y: face.y - 5 } : { x: face.x + 4, y: face.y + 14 }
202
+ const fits = part.w >= partName.length * 7 + 8 && part.h >= 16
203
+ if (fits) return { face: facePlace, part: { x: part.x + part.w / 2, y: part.y + part.h / 2 + 4, inside: true } }
204
+ const below = part.y + part.h + 14
205
+ return { face: facePlace, part: { x: part.x + part.w / 2, y: below <= height - 20 ? below : part.y - 5, inside: false } }
206
+ }
@@ -4,7 +4,7 @@ import { customElement, property, state } from 'lit/decorators.js'
4
4
  import { i18next, localize } from '@operato/i18n'
5
5
  import type { FigureFace } from '@hatiolab/figure-model'
6
6
 
7
- import { alignmentAt, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
7
+ import { alignmentAt, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
8
8
  import type { Alignment } from './links.js'
9
9
 
10
10
  type Axis = 'x' | 'y' | 'z'
@@ -299,6 +299,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
299
299
  }
300
300
  const faceName = this.target ? `${this.target} ${i18next.t('figure.label.face-' + this.face)}` : this.planeWord
301
301
  const sizeWords = faceSized ? ` ${Math.round(view.face[h]!)} × ${Math.round(view.face[v]!)}` : ''
302
+ const labels = fastenLabelsOf(faceSized ? { x: fx, y: fy, w: fw, h: fh } : { x: 6, y: 4, w: W - 12, h: H - 8 }, { x: px, y: py, w: pw, h: ph }, this.partName, H)
302
303
  return html`<div where tabindex="0" @keydown=${(ev: KeyboardEvent) => this.keys(ev, view)}>
303
304
  ${svg`<svg viewBox="0 0 ${W} ${H}" role="img" aria-label=${faceName}
304
305
  @pointermove=${(ev: PointerEvent) => this.drag(ev, view, cx, cy)}
@@ -306,7 +307,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
306
307
  ${faceSized
307
308
  ? svg`<rect x=${fx} y=${fy} width=${fw} height=${fh} rx="4" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`
308
309
  : svg`<rect x="1" y="1" width=${W - 2} height=${H - 2} rx="8" style="fill:var(--md-sys-color-primary-container);stroke:var(--md-sys-color-primary);stroke-width:2" />`}
309
- <text x=${faceSized ? fx + 4 : 10} y=${faceSized ? fy + fh - 6 : 18} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
310
+ <text face-name x=${labels.face.x} y=${labels.face.y} font-size="11" pointer-events="none" style="fill:var(--md-sys-color-primary)">${faceName}${sizeWords}</text>
310
311
  ${guide(h)}${guide(v)}
311
312
  <g footprint style=${`cursor:${this.locked ? 'default' : 'grab'}`} @pointerdown=${(ev: PointerEvent) => this.grab(ev, at, caught)}>
312
313
  <rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2} style="fill:var(--md-sys-color-tertiary-container)" />
@@ -317,7 +318,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
317
318
  : nothing}
318
319
  <rect x=${px} y=${py} width=${pw} height=${ph} rx=${round ? pw / 2 : 2}
319
320
  style=${`fill:none;stroke:var(--md-sys-color-on-surface);stroke-width:1.5;stroke-dasharray:${touching ? '' : '4 3'}`} />
320
- <text x=${px + pw / 2} y=${py + ph / 2 + 4} text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.partName}</text>
321
+ <text part-name x=${labels.part.x} y=${labels.part.y} text-anchor="middle" font-size="12" pointer-events="none" style="fill:var(--md-sys-color-on-surface)">${this.partName}</text>
321
322
  </g>
322
323
  ${dim(h)}${dim(v)}
323
324
  </svg>`}