@things-factory/figure-ui 10.1.169 → 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 (105) hide show
  1. package/client/modeller/affected-parts.ts +11 -0
  2. package/client/modeller/canvas.ts +15 -1
  3. package/client/modeller/editor-styles.ts +98 -6
  4. package/client/modeller/editor.ts +212 -53
  5. package/client/modeller/fasten-geometry.ts +19 -0
  6. package/client/modeller/figure-fasten-drawing.ts +4 -3
  7. package/client/modeller/figure-links.ts +80 -37
  8. package/client/modeller/figure-material-panel.ts +20 -1
  9. package/client/modeller/figure-preview.ts +117 -8
  10. package/client/modeller/figure-properties.ts +1 -2
  11. package/client/modeller/figure-section-panel.ts +5 -0
  12. package/client/modeller/figure-verdict.ts +9 -2
  13. package/client/modeller/gate-findings.ts +12 -4
  14. package/client/modeller/group-menu.ts +57 -0
  15. package/client/modeller/links.ts +22 -1
  16. package/client/modeller/modeller-keys.ts +6 -0
  17. package/client/modeller/preview-size.ts +32 -0
  18. package/client/modeller/stage-model.ts +11 -0
  19. package/client/pages/figure-modeller-page.ts +8 -2
  20. package/dist-client/modeller/affected-parts.d.ts +6 -0
  21. package/dist-client/modeller/affected-parts.js +12 -0
  22. package/dist-client/modeller/affected-parts.js.map +1 -0
  23. package/dist-client/modeller/canvas.d.ts +3 -0
  24. package/dist-client/modeller/canvas.js +12 -1
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +1 -1
  27. package/dist-client/modeller/editor-styles.js +98 -6
  28. package/dist-client/modeller/editor-styles.js.map +1 -1
  29. package/dist-client/modeller/editor.d.ts +42 -1
  30. package/dist-client/modeller/editor.js +220 -51
  31. package/dist-client/modeller/editor.js.map +1 -1
  32. package/dist-client/modeller/fasten-geometry.d.ts +27 -0
  33. package/dist-client/modeller/fasten-geometry.js +14 -0
  34. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  35. package/dist-client/modeller/figure-fasten-drawing.js +4 -3
  36. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  37. package/dist-client/modeller/figure-links.d.ts +3 -1
  38. package/dist-client/modeller/figure-links.js +84 -38
  39. package/dist-client/modeller/figure-links.js.map +1 -1
  40. package/dist-client/modeller/figure-material-panel.d.ts +8 -1
  41. package/dist-client/modeller/figure-material-panel.js +24 -1
  42. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  43. package/dist-client/modeller/figure-preview.d.ts +12 -0
  44. package/dist-client/modeller/figure-preview.js +116 -5
  45. package/dist-client/modeller/figure-preview.js.map +1 -1
  46. package/dist-client/modeller/figure-properties.js +1 -2
  47. package/dist-client/modeller/figure-properties.js.map +1 -1
  48. package/dist-client/modeller/figure-section-panel.d.ts +1 -1
  49. package/dist-client/modeller/figure-section-panel.js +5 -0
  50. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  51. package/dist-client/modeller/figure-verdict.d.ts +1 -0
  52. package/dist-client/modeller/figure-verdict.js +8 -2
  53. package/dist-client/modeller/figure-verdict.js.map +1 -1
  54. package/dist-client/modeller/gate-findings.d.ts +3 -1
  55. package/dist-client/modeller/gate-findings.js +8 -4
  56. package/dist-client/modeller/gate-findings.js.map +1 -1
  57. package/dist-client/modeller/group-menu.d.ts +18 -0
  58. package/dist-client/modeller/group-menu.js +52 -0
  59. package/dist-client/modeller/group-menu.js.map +1 -0
  60. package/dist-client/modeller/links.d.ts +5 -0
  61. package/dist-client/modeller/links.js +21 -1
  62. package/dist-client/modeller/links.js.map +1 -1
  63. package/dist-client/modeller/modeller-keys.js +7 -0
  64. package/dist-client/modeller/modeller-keys.js.map +1 -1
  65. package/dist-client/modeller/preview-size.d.ts +23 -0
  66. package/dist-client/modeller/preview-size.js +32 -0
  67. package/dist-client/modeller/preview-size.js.map +1 -0
  68. package/dist-client/modeller/stage-model.d.ts +11 -0
  69. package/dist-client/modeller/stage-model.js +11 -0
  70. package/dist-client/modeller/stage-model.js.map +1 -1
  71. package/dist-client/pages/figure-modeller-page.d.ts +3 -0
  72. package/dist-client/pages/figure-modeller-page.js +11 -2
  73. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +8 -8
  76. package/test/browser/editing-page.js +3 -1
  77. package/test/browser/editing.check.mjs +2 -1
  78. package/test/browser/links-page.js +1 -0
  79. package/test/browser/links.check.mjs +20 -5
  80. package/test/browser/modeller.check.mjs +6 -0
  81. package/test/browser/more-open.js +11 -0
  82. package/test/browser/parts.check.mjs +11 -1
  83. package/test/browser/places-mesh.check.mjs +9 -3
  84. package/test/browser/product-page.check.mjs +9 -4
  85. package/test/browser/props.check.mjs +224 -0
  86. package/test/browser/screen-page.js +17 -0
  87. package/test/browser/screen.check.mjs +171 -29
  88. package/test/browser/section-page.js +1 -0
  89. package/test/browser/section.check.mjs +6 -0
  90. package/test/command-preview.test.ts +2 -2
  91. package/test/fasten-geometry.test.ts +15 -0
  92. package/test/fields.test.ts +4 -2
  93. package/test/first-minute.test.ts +15 -2
  94. package/test/layout.test.ts +1 -1
  95. package/test/preview-size.test.ts +33 -0
  96. package/test/refusals.test.ts +1 -1
  97. package/test/resize-with-placement.test.ts +1 -1
  98. package/test/stage-model.test.ts +11 -1
  99. package/test/verdict.test.ts +7 -0
  100. package/translations/en.json +37 -27
  101. package/translations/es.json +18 -8
  102. package/translations/ja.json +19 -9
  103. package/translations/ko.json +50 -40
  104. package/translations/ms.json +19 -9
  105. package/translations/zh.json +19 -9
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The parts a release finding points at, by the author's names. The server's `at` lists the first word of each path it
3
+ * could not prove -- a part's name, or a section of the format such as 「occupancy」 -- and the screen showed it as it came
4
+ * (「occupancy · roller-row-1-roller」, user 2026-10-16). Only the figure's own part names are kept.
5
+ */
6
+ export function affectedPartsOf(at: string, parts: readonly string[]): string[] {
7
+ return at
8
+ .split('·')
9
+ .map(word => word.trim())
10
+ .filter(word => parts.includes(word))
11
+ }
@@ -5,7 +5,7 @@ import { create, Model, niceGridStep } from '@hatiolab/things-scene'
5
5
  import type { GizmoReach } from './gizmo.js'
6
6
  import type { FigureRenderPart } from './renderer.js'
7
7
  import { DEFAULT_VIEW, sceneLook, WORKBENCH, type ViewSettings } from '@things-factory/figure-viewer/dist-client/figure-view.js'
8
- import { boardFor, changedKeys, componentOf, gizmoResultOf, type Board, type ScaleAnchor } from './stage-model.js'
8
+ import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit, type Board, type ScaleAnchor } from './stage-model.js'
9
9
  import { JOINT_FAN } from './joint-fan.js'
10
10
  import { SLOT_MARK } from './slot-layout-host.js'
11
11
  import { PREVIEW, TARGET_FACE } from './command-preview.js'
@@ -48,6 +48,9 @@ interface SceneHandle {
48
48
  root?: SceneLayer
49
49
  }
50
50
 
51
+ /* How long the figure stands still before the camera judges whether to follow it. */
52
+ const REFIT_SETTLE_MS = 400
53
+
51
54
  /* The fan's colour when the figure's palette names no accent. */
52
55
  const FAN_COLOUR = '#ff8a00'
53
56
 
@@ -103,6 +106,9 @@ export class FigureCanvas extends LitElement {
103
106
 
104
107
  private scene?: SceneHandle
105
108
  private board?: Board & { depth: number }
109
+ /** The figure's reach when the view was last fitted to it. */
110
+ private fittedFor?: Board & { depth: number }
111
+ private refitTimer?: ReturnType<typeof setTimeout>
106
112
  private standingFor?: string
107
113
  private watcher?: ResizeObserver
108
114
  /** Set while this canvas writes to components, so the scene's echo of it is not taken for a drag. */
@@ -134,6 +140,7 @@ export class FigureCanvas extends LitElement {
134
140
 
135
141
  disconnectedCallback() {
136
142
  super.disconnectedCallback()
143
+ clearTimeout(this.refitTimer)
137
144
  this.watcher?.disconnect()
138
145
  this.watcher = undefined
139
146
  this.teardown()
@@ -162,6 +169,7 @@ export class FigureCanvas extends LitElement {
162
169
 
163
170
  /** The whole figure in view: the board's fit, then the 3D camera's own. */
164
171
  fit() {
172
+ this.fittedFor = boardFor(this.parts)
165
173
  this.scene?.fit?.('ratio')
166
174
  this.scene?.root?.fitCameraToModel?.()
167
175
  }
@@ -186,6 +194,12 @@ export class FigureCanvas extends LitElement {
186
194
  */
187
195
  if (outgrown) this.grow(needed)
188
196
  this.sync()
197
+ /*
198
+ Grown or shrunk far past the view (a size typed in): the camera follows, once the figure has settled. A value is
199
+ drawn as it is typed -- 「3」, 「30」, 「300」 on the way to 3000 -- so the reach is judged after the last change.
200
+ */
201
+ clearTimeout(this.refitTimer)
202
+ if (this.parts.length) this.refitTimer = setTimeout(() => needsRefit(this.fittedFor, boardFor(this.parts)) && this.fit(), REFIT_SETTLE_MS)
189
203
  return
190
204
  }
191
205
  this.teardown()
@@ -3,6 +3,7 @@ import { css } from 'lit'
3
3
  /** The modeller editor's own styles: the bar's tools, the panes, the start card, the side's tabs and panels. */
4
4
  export const EditorStyles = css`
5
5
  :host {
6
+ position: relative;
6
7
  display: flex;
7
8
  flex-direction: column;
8
9
  min-height: 0;
@@ -16,6 +17,7 @@ export const EditorStyles = css`
16
17
  fit in the suite's places. What the editor puts in it - the mode switch, the status, save - is styled here. */
17
18
  ox-canvas-bar button[mode] {
18
19
  height: 28px;
20
+ border: 1px solid transparent;
19
21
  padding: 0 10px;
20
22
  border-radius: 999em;
21
23
  border-color: transparent;
@@ -250,6 +252,63 @@ export const EditorStyles = css`
250
252
  [off] {
251
253
  display: none !important;
252
254
  }
255
+ /* The part's own numbers inside a group the links draw (size, place): one column, the panel's gap. */
256
+ div[group-fields] {
257
+ display: grid;
258
+ gap: 8px;
259
+ }
260
+ /* A folded group (capability, 「더 보기」): its title is the fold's handle, at the title's size. */
261
+ section[more] > details > summary,
262
+ section[capability] > details > summary {
263
+ padding: 0;
264
+ }
265
+ section[more] > details > summary > h3,
266
+ section[capability] > details > summary > h3 {
267
+ margin: 0;
268
+ }
269
+ div[more-body] {
270
+ display: grid;
271
+ gap: 16px;
272
+ margin-top: 12px;
273
+ }
274
+ /*
275
+ The view settings and the release history, opened from the bar over the right of the work: neither is about the
276
+ part chosen, so neither takes a tab beside the part's (chief architect 2026-10-16).
277
+ */
278
+ :host {
279
+ position: relative;
280
+ }
281
+ div[bar-overlay-panel] {
282
+ position: absolute;
283
+ top: var(--figure-bar-height, 44px);
284
+ right: 8px;
285
+ z-index: 5;
286
+ display: flex;
287
+ flex-direction: column;
288
+ width: min(360px, calc(100% - 16px));
289
+ max-height: calc(100% - var(--figure-bar-height, 44px) - 16px);
290
+ background: var(--md-sys-color-surface-container-lowest);
291
+ border: 1px solid var(--md-sys-color-outline-variant);
292
+ border-radius: 8px;
293
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
294
+ }
295
+ div[overlay-head] {
296
+ display: flex;
297
+ align-items: center;
298
+ justify-content: space-between;
299
+ padding: 8px 8px 8px 12px;
300
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
301
+ }
302
+ div[overlay-head] h3 {
303
+ margin: 0;
304
+ }
305
+ div[overlay-head] button[quiet] {
306
+ margin-left: 0;
307
+ }
308
+ div[overlay-body] {
309
+ min-height: 0;
310
+ overflow: auto;
311
+ }
253
312
  /* Which part the panel speaks of. */
254
313
  div[subject] {
255
314
  position: sticky;
@@ -277,19 +336,52 @@ export const EditorStyles = css`
277
336
  div[subject-summary] small {
278
337
  color: var(--md-sys-color-on-surface-variant);
279
338
  }
339
+ /*
340
+ No rule here draws every button as a box: the side's buttons take the shared panel rules (SidePanelStyles), one
341
+ height with the fields beside them, and only the bar's publish keeps an outline (user 2026-10-16: 「버튼 박스도
342
+ 과하게 사용되어서」 -- a bare 「button」 rule had put a 32px box round every button the editor drew).
343
+ */
280
344
  button {
281
345
  font: inherit;
282
346
  box-sizing: border-box;
347
+ color: inherit;
348
+ min-width: 0;
349
+ cursor: pointer;
350
+ }
351
+ /* What blocks release, beside 「발행」: one red word button, and its list under the bar. */
352
+ ox-canvas-bar button[release-blocked] {
353
+ display: inline-flex;
354
+ align-items: center;
355
+ gap: 4px;
283
356
  height: 32px;
284
- border: 1px solid var(--md-sys-color-outline-variant);
357
+ padding: 0 10px;
358
+ border: none;
285
359
  border-radius: 6px;
360
+ color: var(--md-sys-color-on-error-container);
361
+ background: var(--md-sys-color-error-container);
362
+ }
363
+ ox-canvas-bar button[release-blocked] md-icon {
364
+ --md-icon-size: 18px;
365
+ }
366
+ div[release-blockers] {
367
+ position: absolute;
368
+ top: 46px;
369
+ right: 8px;
370
+ z-index: 6;
371
+ width: min(340px, calc(100% - 16px));
372
+ padding: 8px 10px 10px;
373
+ box-sizing: border-box;
286
374
  background: var(--md-sys-color-surface);
287
- color: inherit;
288
- padding: 0 8px;
289
- min-width: 0;
375
+ border: 1px solid var(--md-sys-color-outline-variant);
376
+ border-radius: 8px;
377
+ box-shadow: var(--md-sys-elevation-2, 0 2px 8px #0003);
290
378
  }
291
- button {
292
- cursor: pointer;
379
+ ox-canvas-bar button[publish] {
380
+ height: 32px;
381
+ padding: 0 8px;
382
+ border: 1px solid var(--md-sys-color-outline-variant);
383
+ border-radius: 6px;
384
+ background: var(--md-sys-color-surface);
293
385
  }
294
386
  button:disabled {
295
387
  opacity: 0.45;
@@ -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
+ }