@things-factory/figure-ui 10.1.81 → 10.1.83

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 (148) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +800 -53
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +357 -94
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/command-preview.d.ts +35 -0
  50. package/dist-client/modeller/command-preview.js +95 -0
  51. package/dist-client/modeller/command-preview.js.map +1 -0
  52. package/dist-client/modeller/editor.d.ts +107 -1
  53. package/dist-client/modeller/editor.js +823 -49
  54. package/dist-client/modeller/editor.js.map +1 -1
  55. package/dist-client/modeller/fields.d.ts +3 -2
  56. package/dist-client/modeller/fields.js +10 -3
  57. package/dist-client/modeller/fields.js.map +1 -1
  58. package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
  59. package/dist-client/modeller/figure-clips.d.ts +1 -1
  60. package/dist-client/modeller/figure-controls.d.ts +1 -1
  61. package/dist-client/modeller/figure-facts.js +2 -2
  62. package/dist-client/modeller/figure-facts.js.map +1 -1
  63. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  64. package/dist-client/modeller/figure-first-save.js +41 -14
  65. package/dist-client/modeller/figure-first-save.js.map +1 -1
  66. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  67. package/dist-client/modeller/figure-history-panel.js +130 -14
  68. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  69. package/dist-client/modeller/figure-links.d.ts +52 -2
  70. package/dist-client/modeller/figure-links.js +366 -97
  71. package/dist-client/modeller/figure-links.js.map +1 -1
  72. package/dist-client/modeller/figure-material-panel.d.ts +1 -1
  73. package/dist-client/modeller/figure-parts.d.ts +7 -0
  74. package/dist-client/modeller/figure-parts.js +55 -2
  75. package/dist-client/modeller/figure-parts.js.map +1 -1
  76. package/dist-client/modeller/figure-preview.d.ts +6 -0
  77. package/dist-client/modeller/figure-preview.js +22 -1
  78. package/dist-client/modeller/figure-preview.js.map +1 -1
  79. package/dist-client/modeller/figure-properties.d.ts +1 -1
  80. package/dist-client/modeller/figure-properties.js +3 -3
  81. package/dist-client/modeller/figure-properties.js.map +1 -1
  82. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  83. package/dist-client/modeller/figure-revert-choice.js +59 -0
  84. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  85. package/dist-client/modeller/first-save.d.ts +7 -0
  86. package/dist-client/modeller/first-save.js +17 -0
  87. package/dist-client/modeller/first-save.js.map +1 -1
  88. package/dist-client/modeller/gizmo.d.ts +0 -6
  89. package/dist-client/modeller/gizmo.js +0 -18
  90. package/dist-client/modeller/gizmo.js.map +1 -1
  91. package/dist-client/modeller/links.d.ts +49 -4
  92. package/dist-client/modeller/links.js +141 -15
  93. package/dist-client/modeller/links.js.map +1 -1
  94. package/dist-client/modeller/motion-ends.d.ts +12 -0
  95. package/dist-client/modeller/motion-ends.js +65 -0
  96. package/dist-client/modeller/motion-ends.js.map +1 -0
  97. package/dist-client/modeller/occupancy.d.ts +14 -1
  98. package/dist-client/modeller/occupancy.js +14 -1
  99. package/dist-client/modeller/occupancy.js.map +1 -1
  100. package/dist-client/modeller/product-session.d.ts +43 -2
  101. package/dist-client/modeller/product-session.js +146 -12
  102. package/dist-client/modeller/product-session.js.map +1 -1
  103. package/dist-client/modeller/refusals.d.ts +13 -0
  104. package/dist-client/modeller/refusals.js +16 -0
  105. package/dist-client/modeller/refusals.js.map +1 -1
  106. package/dist-client/modeller/revert-request.d.ts +16 -0
  107. package/dist-client/modeller/revert-request.js +11 -0
  108. package/dist-client/modeller/revert-request.js.map +1 -0
  109. package/dist-client/modeller/side-panel-styles.js +12 -0
  110. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  111. package/dist-client/modeller/stage-model.d.ts +18 -2
  112. package/dist-client/modeller/stage-model.js +21 -3
  113. package/dist-client/modeller/stage-model.js.map +1 -1
  114. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  115. package/dist-client/pages/figure-modeller-page.js +72 -34
  116. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  117. package/dist-client/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +5 -5
  119. package/test/ai-proposal.test.ts +2 -2
  120. package/test/align.test.ts +50 -5
  121. package/test/browser/acceptance-page.js +187 -0
  122. package/test/browser/build.mjs +5 -1
  123. package/test/browser/revert-page.js +47 -0
  124. package/test/browser/serve-acceptance.mjs +85 -0
  125. package/test/browser/serve-revert.mjs +119 -0
  126. package/test/clip-ai-proposal.test.ts +104 -0
  127. package/test/clip-consent.test.ts +88 -0
  128. package/test/command-preview.test.ts +288 -0
  129. package/test/fields.test.ts +4 -3
  130. package/test/figure-assistant-surface.test.ts +4 -1
  131. package/test/first-minute.test.ts +96 -0
  132. package/test/first-save-suggest.test.ts +42 -0
  133. package/test/gizmo.test.ts +20 -16
  134. package/test/joint-fan.test.ts +2 -1
  135. package/test/layout.test.ts +9 -2
  136. package/test/links.test.ts +9 -5
  137. package/test/proposal-record.test.ts +126 -0
  138. package/test/release-entry.test.ts +28 -0
  139. package/test/release-panel.test.ts +3 -2
  140. package/test/resize-with-placement.test.ts +226 -0
  141. package/test/revert-choice.test.ts +39 -0
  142. package/test/revert-keeping.test.ts +102 -0
  143. package/test/save-landed.test.ts +198 -0
  144. package/translations/en.json +229 -11
  145. package/translations/ja.json +229 -11
  146. package/translations/ko.json +230 -12
  147. package/translations/ms.json +228 -10
  148. package/translations/zh.json +229 -11
@@ -6,16 +6,18 @@ import { notify } from '@operato/layout'
6
6
  import { OxPrompt } from '@operato/popup'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
  import { live } from 'lit/directives/live.js'
9
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor } from '@hatiolab/figure-model'
9
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
10
10
  import type { RecipeTemplate } from './recipes.js'
11
11
  import './figure-recipe-catalog.js'
12
- import type { FigureAsset, FigureAuthoringAction, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue } from '@hatiolab/figure-model'
12
+ import type { FigureAsset, FigureAuthoringAction, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigurePlacement } from '@hatiolab/figure-model'
13
13
  import type { FigureProductSession } from './product-session.js'
14
14
  import { captureFigureThumbnail } from './thumbnail.js'
15
15
  import { drawableFigureParts } from './renderer.js'
16
16
  import './canvas.js'
17
17
  import './figure-parts.js'
18
18
  import './figure-material-panel.js'
19
+ import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf, type HeldPreview } from './command-preview.js'
20
+ import { motionEndsOf, type MotionEnds } from './motion-ends.js'
19
21
  import './figure-links.js'
20
22
  import './figure-controls.js'
21
23
  import './figure-clips.js'
@@ -23,7 +25,7 @@ import './figure-preview.js'
23
25
  import './figure-settings.js'
24
26
  import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
25
27
  import './figure-capability-panel.js'
26
- import { gizmoChanges, gizmoReachOf, keepBottomChanges, type GizmoReach, type GizmoResult } from './gizmo.js'
28
+ import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './gizmo.js'
27
29
  import { keyActionOf } from './keys.js'
28
30
  import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
29
31
  import { SidePanelStyles } from './side-panel-styles.js'
@@ -32,11 +34,15 @@ import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from '.
32
34
  import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
33
35
  import { paletteRecipe } from './palette.js'
34
36
  import type { FigureCanvas } from './canvas.js'
35
- import { nudgeOf } from './stage-model.js'
37
+ import { nudgeOf, placementOf, type ScaleAnchor } from './stage-model.js'
38
+
39
+ /** The numbers a rule gives a part's position, turn and size, per axis, for the cells that have no field. */
40
+ type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
41
+ import { roomRefitRowsOf } from './occupancy.js'
36
42
  import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js'
37
43
  import type { FigureRenderPart } from './renderer.js'
38
44
  import type { FigureBudgetLine } from './maturity.js'
39
- import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
45
+ import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
40
46
  import { jointFanOf } from './joint-fan.js'
41
47
  import { jointDragOf } from './joint-drag.js'
42
48
  import type { FirstSave } from './first-save.js'
@@ -107,6 +113,17 @@ export class FigureEditor extends LitElement {
107
113
  opacity: 0.38;
108
114
  cursor: default;
109
115
  }
116
+ /*
117
+ Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
118
+ showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
119
+ */
120
+ ox-canvas-bar button[tool] md-icon {
121
+ --md-icon-size: 20px;
122
+ }
123
+ ox-canvas-bar button[tool]:focus-visible {
124
+ outline: 2px solid var(--md-sys-color-primary);
125
+ outline-offset: 1px;
126
+ }
110
127
  figure-preview {
111
128
  min-width: 0;
112
129
  min-height: 0;
@@ -137,9 +154,76 @@ export class FigureEditor extends LitElement {
137
154
  min-height: 0;
138
155
  }
139
156
  figure-canvas {
157
+ flex: 1;
140
158
  min-width: 0;
141
159
  min-height: 0;
142
160
  }
161
+ div[middle] {
162
+ position: relative;
163
+ display: flex;
164
+ min-width: 0;
165
+ min-height: 0;
166
+ }
167
+ /* A new figure's first question, over the empty canvas. */
168
+ div[start] {
169
+ position: absolute;
170
+ inset: 50% auto auto 50%;
171
+ z-index: 10;
172
+ color: var(--md-sys-color-on-surface);
173
+ transform: translate(-50%, -50%);
174
+ width: min(440px, calc(100% - 32px));
175
+ box-sizing: border-box;
176
+ padding: 20px;
177
+ border-radius: 12px;
178
+ background: var(--md-sys-color-surface, #fff);
179
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
180
+ }
181
+ div[start] button[start-button] {
182
+ width: auto;
183
+ margin: 0;
184
+ font-size: var(--ui-t-body);
185
+ height: 36px;
186
+ padding: 0 16px;
187
+ border: 1px solid var(--md-sys-color-outline);
188
+ border-radius: 999em;
189
+ color: var(--md-sys-color-primary);
190
+ background: var(--md-sys-color-surface, #fff);
191
+ cursor: pointer;
192
+ }
193
+ div[start] button[start-button]:hover:not(:disabled) {
194
+ background: var(--md-sys-color-primary-container);
195
+ }
196
+ div[start] button[start-button][pressed] {
197
+ color: var(--md-sys-color-on-primary);
198
+ background: var(--md-sys-color-primary);
199
+ border-color: var(--md-sys-color-primary);
200
+ }
201
+ div[start] button[start-button]:disabled {
202
+ opacity: 0.5;
203
+ cursor: default;
204
+ }
205
+ div[start] h2 {
206
+ margin: 0 0 12px;
207
+ font-size: var(--ui-t-title);
208
+ }
209
+ div[start-paths] {
210
+ display: grid;
211
+ gap: 14px;
212
+ }
213
+ div[start-paths] h3 {
214
+ margin: 0 0 6px;
215
+ font-size: var(--ui-t-body);
216
+ }
217
+ div[start-shapes] {
218
+ display: flex;
219
+ gap: 8px;
220
+ flex-wrap: wrap;
221
+ }
222
+ div[start] small {
223
+ display: block;
224
+ margin-top: 4px;
225
+ color: var(--md-sys-color-on-surface-variant);
226
+ }
143
227
  /* The side: tabs over one panel at a time, each using the whole height. */
144
228
  aside {
145
229
  display: flex;
@@ -156,9 +240,12 @@ export class FigureEditor extends LitElement {
156
240
  flex: 1;
157
241
  position: relative;
158
242
  display: flex;
243
+ flex-direction: column;
159
244
  align-items: center;
160
245
  justify-content: center;
161
- height: 38px;
246
+ gap: 1px;
247
+ min-width: 0;
248
+ height: 48px;
162
249
  padding: 0;
163
250
  border: none;
164
251
  border-radius: 0;
@@ -178,6 +265,14 @@ export class FigureEditor extends LitElement {
178
265
  width: 20px;
179
266
  height: 20px;
180
267
  }
268
+ button[tab] span[tab-label] {
269
+ max-width: 100%;
270
+ overflow: hidden;
271
+ font-size: 0.66rem;
272
+ line-height: 1.2;
273
+ white-space: nowrap;
274
+ text-overflow: ellipsis;
275
+ }
181
276
  span[badge] {
182
277
  position: absolute;
183
278
  top: 3px;
@@ -207,24 +302,60 @@ export class FigureEditor extends LitElement {
207
302
  }
208
303
  /* Which part the panel speaks of, and the way back to the whole figure. */
209
304
  div[subject] {
305
+ position: sticky;
306
+ top: 0;
307
+ z-index: 1;
308
+ display: grid;
309
+ gap: 4px;
310
+ padding: 8px 10px;
311
+ background: var(--md-sys-color-surface-container-lowest);
312
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
313
+ }
314
+ nav[path] {
210
315
  display: flex;
211
316
  align-items: center;
212
317
  gap: 6px;
213
- padding: 6px 10px 0;
318
+ min-width: 0;
214
319
  }
215
- div[subject] button[chip] {
216
- display: inline-flex;
217
- align-items: center;
218
- gap: 2px;
219
- height: 26px;
220
- padding: 0 10px 0 6px;
320
+ nav[path] button[model-crumb] {
321
+ flex: none;
322
+ max-width: 55%;
323
+ height: 28px;
324
+ padding: 0 8px;
325
+ overflow: hidden;
326
+ white-space: nowrap;
327
+ text-overflow: ellipsis;
328
+ border: 1px solid var(--md-sys-color-outline-variant);
221
329
  border-radius: 999em;
330
+ background: none;
331
+ color: var(--md-sys-color-primary);
332
+ cursor: pointer;
333
+ }
334
+ nav[path] button[model-crumb][on] {
335
+ border-color: var(--md-sys-color-primary);
336
+ background: var(--md-sys-color-primary-container);
337
+ }
338
+ nav[path] button[model-crumb]:focus-visible {
339
+ outline: 2px solid var(--md-sys-color-primary);
340
+ outline-offset: 1px;
341
+ }
342
+ nav[path] span[current] {
343
+ min-width: 0;
344
+ overflow: hidden;
345
+ white-space: nowrap;
346
+ text-overflow: ellipsis;
347
+ font-weight: 600;
348
+ }
349
+ div[subject-summary] {
350
+ display: grid;
351
+ gap: 1px;
352
+ }
353
+ div[subject-summary] b span {
354
+ font-weight: 400;
222
355
  color: var(--md-sys-color-on-surface-variant);
223
356
  }
224
- div[subject] md-icon {
225
- font-size: 16px;
226
- width: 16px;
227
- height: 16px;
357
+ div[subject-summary] small {
358
+ color: var(--md-sys-color-on-surface-variant);
228
359
  }
229
360
  button {
230
361
  font: inherit;
@@ -255,6 +386,8 @@ export class FigureEditor extends LitElement {
255
386
  }
256
387
  `]
257
388
  @property({ attribute: false }) session?: FigureProductSession
389
+ /** The saved figure's state and version, as the server gave them (the page reads the row). */
390
+ @property({ attribute: false }) releaseState?: { state?: 'draft' | 'released'; version: number }
258
391
  /** The recipe templates (ADR-0095), read by the page from the server. */
259
392
  @property({ attribute: false }) templates: RecipeTemplate[] = []
260
393
  /** Reads a template's guide; given by the page. */
@@ -313,9 +446,17 @@ export class FigureEditor extends LitElement {
313
446
  * Parts hidden on the workbench — the author's view while working, not the asset's visibility. Not saved: a part
314
447
  * hidden here is still in the figure, still checked, still drawn on a board.
315
448
  */
449
+ /** The page offers an AI draft for a new figure (the modeller page, through the app's assistant dock). */
450
+ @property({ type: Boolean }) aiDraft = false
316
451
  @state() private hiddenParts = new Set<string>()
317
452
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
318
453
  @state() private live: LiveValues = NOTHING_LIVE
454
+ /**
455
+ * A command seen before it is done (ADR-0097 §5.5): worked out on a copy for the figure, version, part and command it
456
+ * names, and shown only while all four are still the ones on screen. Nothing of it is saved or undone.
457
+ */
458
+ @state() private preview?: HeldPreview & { part: string }
459
+ private previewDrawn?: { of: HeldPreview; ghost: FigureRenderPart[]; highlight: FigureRenderPart | null }
319
460
  private lastContext: string | null = null
320
461
  /**
321
462
  * Move, turn and size on the canvas, in the bar's tools: the gizmo the W · E · R keys give, found without knowing the
@@ -336,7 +477,7 @@ export class FigureEditor extends LitElement {
336
477
  aria-pressed=${String(this.gizmoMode === tool.mode)}
337
478
  ?disabled=${!tool.can}
338
479
  aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
339
- title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
480
+ title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t(tool.mode === 'scale' && this.sizeDragUnsure(this.asset() as FigureAsset, this.selected) ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-not-own', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })}`}
340
481
  @click=${() => this.useGizmo(tool.mode)}
341
482
  >
342
483
  <md-icon>${tool.icon}</md-icon>
@@ -372,11 +513,38 @@ export class FigureEditor extends LitElement {
372
513
  connectedCallback() {
373
514
  super.connectedCallback()
374
515
  window.addEventListener('keydown', this.onKey, true)
516
+ this.widthWatch.observe(this)
375
517
  }
376
518
  disconnectedCallback() {
377
519
  window.removeEventListener('keydown', this.onKey, true)
520
+ this.widthWatch.disconnect()
378
521
  super.disconnectedCallback()
379
522
  }
523
+ /*
524
+ Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
525
+ FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
526
+ bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
527
+ */
528
+ private static readonly FOLD_START_UNDER = 1100
529
+ private foldedForWidth = false
530
+ private keptOpenByAuthor = false
531
+ private widthWatch = new ResizeObserver(([entry]) => this.fitPanesTo(entry!.contentRect.width))
532
+ private fitPanesTo(width: number) {
533
+ const panes = this.panes
534
+ if (!panes || !width) return
535
+ if (this.foldedForWidth && !panes.foldStart) this.keptOpenByAuthor = true
536
+ if (width < FigureEditor.FOLD_START_UNDER) {
537
+ if (!panes.foldStart && !this.keptOpenByAuthor) {
538
+ panes.foldStart = true
539
+ this.foldedForWidth = true
540
+ notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
541
+ }
542
+ } else {
543
+ if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
544
+ this.foldedForWidth = false
545
+ this.keptOpenByAuthor = false
546
+ }
547
+ }
380
548
  /**
381
549
  * Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
382
550
  * notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
@@ -395,6 +563,36 @@ export class FigureEditor extends LitElement {
395
563
  if (viewError && viewError !== this.toldViewError) notify({ level: 'error', message: viewError })
396
564
  this.toldViewError = viewError
397
565
  }
566
+ /*
567
+ An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
568
+ which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
569
+ step undo brings back (designer 2026-09-27).
570
+ */
571
+ private askClipLoss(error: unknown, again: () => void): boolean {
572
+ if ((error as { code?: string })?.code !== 'CLIPS_DROPPED') return false
573
+ const clips = (error as { clips: FigureClipLoss[] }).clips
574
+ /* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
575
+ const session = this.session!
576
+ const { context, revision } = session.read()
577
+ void this.confirmClipLoss(clips, again, { session, context: context!, revision: revision! })
578
+ return true
579
+ }
580
+ private async confirmClipLoss(clips: FigureClipLoss[], again: () => void, asked: { session: FigureProductSession; context: string; revision: number }) {
581
+ const agreed = await OxPrompt.open({
582
+ type: 'warning',
583
+ text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
584
+ confirmButton: { text: i18next.t('figure.button.let-clips-go') },
585
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
586
+ })
587
+ if (!agreed) return
588
+ /* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
589
+ const now = this.session === asked.session ? asked.session.read() : undefined
590
+ if (!now || now.context !== asked.context || now.revision !== asked.revision) {
591
+ this.error = i18next.t('figure.text.clips-consent-stale')
592
+ return
593
+ }
594
+ asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)
595
+ }
398
596
  private async ask(message: string, retry: { label: string; action?: FigureAuthoringAction; run?: () => void }) {
399
597
  const agreed = await OxPrompt.open({
400
598
  type: 'question',
@@ -406,6 +604,21 @@ export class FigureEditor extends LitElement {
406
604
  if (retry.run) retry.run()
407
605
  else this.linkCommand(retry.action!)
408
606
  }
607
+ /*
608
+ Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
609
+ to one finds it as it was left (designer 2026-09-27, B.1). The path stays held at the top, so a scroll kept cannot
610
+ hide which part the panel speaks of. Another model starts clean.
611
+ */
612
+ private panelMemory = new Map<string, { top: number; open: boolean[] }>()
613
+ private restorePanelFor?: string
614
+ private propertiesPanel() {
615
+ return this.renderRoot.querySelector<HTMLElement>('div[panel]')
616
+ }
617
+ private foldsOf(panel: HTMLElement): HTMLDetailsElement[] {
618
+ const folds = [...panel.querySelectorAll('details')]
619
+ for (const inner of panel.querySelectorAll('figure-links')) folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []))
620
+ return folds
621
+ }
409
622
  protected willUpdate(_changed: PropertyValues) {
410
623
  const view = this.session?.read()
411
624
  if (view?.context !== this.lastContext) {
@@ -413,12 +626,25 @@ export class FigureEditor extends LitElement {
413
626
  this.selected = ''
414
627
  this.error = ''
415
628
  this.hiddenParts = new Set()
629
+ this.panelMemory.clear()
630
+ } else if (_changed.has('selected')) {
631
+ const panel = this.propertiesPanel()
632
+ const was = (_changed.get('selected') as string | undefined) ?? ''
633
+ if (panel) this.panelMemory.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) })
634
+ this.restorePanelFor = this.selected || '@model'
416
635
  }
417
636
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
418
637
  if (_changed.has('selected')) this.gizmoMode = undefined
419
638
  }
420
639
  /** The scene toggles: the mode already shown takes the gizmo away. */
421
640
  private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
641
+ if (mode === 'scale' && this.selected && this.gizmoMode !== 'scale') {
642
+ const asset = this.asset() as FigureAsset | undefined
643
+ if (asset && this.sizeDragUnsure(asset, this.selected)) {
644
+ this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })
645
+ return
646
+ }
647
+ }
422
648
  this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.gizmo(mode)
423
649
  this.gizmoMode = this.gizmoMode === mode ? undefined : mode
424
650
  }
@@ -437,6 +663,7 @@ export class FigureEditor extends LitElement {
437
663
  this.notice = ''
438
664
  this.changed()
439
665
  } catch (error) {
666
+ if (this.askClipLoss(error, () => this.run(action))) return
440
667
  const say = (key: string, params: Record<string, string>) => i18next.t(key, params)
441
668
  this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? this.said(error)
442
669
  } finally {
@@ -504,7 +731,8 @@ export class FigureEditor extends LitElement {
504
731
  await promise
505
732
  this.error = ''
506
733
  } catch (error) {
507
- this.error = this.said(error)
734
+ /* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
735
+ this.error = (error as { code?: string })?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error)
508
736
  } finally {
509
737
  this.changed()
510
738
  }
@@ -544,6 +772,7 @@ export class FigureEditor extends LitElement {
544
772
  if (this.selected === id) this.selected = ''
545
773
  this.changed()
546
774
  } catch (error) {
775
+ if (this.askClipLoss(error, () => this.removePart(id, refitRoom))) return
547
776
  /*
548
777
  A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
549
778
  this product declares is fitted to the parts (「부품에 맞추기」, the AI's fit-room -- no padding), so fitting it
@@ -601,11 +830,27 @@ export class FigureEditor extends LitElement {
601
830
  /* A repeat is moved and sized through its row, not by a gizmo on one copy. */
602
831
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
603
832
  try {
604
- return gizmoReachOf(JSON.parse(this.session.source()), id)
833
+ const asset = JSON.parse(this.session.source())
834
+ const reach = gizmoReachOf(asset, id)
835
+ /*
836
+ A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
837
+ say which face that is -- a part fastened turned or twisted -- the drag would show one result and write another,
838
+ so sizing by the gizmo is not offered there; the fields still size it (designer 2026-09-27).
839
+ */
840
+ if (reach.scale && this.sizeDragUnsure(asset, id)) return { ...reach, scale: false }
841
+ return reach
605
842
  } catch {
606
843
  return none
607
844
  }
608
845
  }
846
+ /** Whether a size drag on this part could not show what is written: figure-model gives no face it keeps. */
847
+ private sizeDragUnsure(asset: FigureAsset, id: string): boolean {
848
+ try {
849
+ return !figureScaleAnchorOf(asset, ownerOf(id))
850
+ } catch {
851
+ return true
852
+ }
853
+ }
609
854
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
610
855
  private transformed(id: string, result: GizmoResult) {
611
856
  const view = this.session!.read()
@@ -629,7 +874,19 @@ export class FigureEditor extends LitElement {
629
874
  the shift had been taken for a move of the joint, and the size was dropped).
630
875
  */
631
876
  const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id))
632
- const changes = gizmoChanges(id, this.reachOf(id), jointed && result.scale ? { scale: result.scale } : result, sizes)
877
+ /* A size drag this part cannot show truly is not written: it springs back, and the reason is said. */
878
+ if (result.scale && result.scale.some(k => Math.abs(k - 1) > 1e-4) && this.sizeDragUnsure(asset, id)) {
879
+ this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(id), extreme: this.keptExtremeWord() })
880
+ return this.requestUpdate()
881
+ }
882
+ /*
883
+ A size dragged keeps the face the figure's placement keeps, as a size typed does (figure-model's one rule): the
884
+ gizmo's own anchoring on the opposite face is what the drag shows, not what is written (designer 2026-09-27).
885
+ */
886
+ const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4)
887
+ const taken = jointed || scaled ? { scale: result.scale } : result
888
+ const written = gizmoChanges(id, this.reachOf(id), taken, sizes)
889
+ const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written
633
890
  if (changes.length) {
634
891
  this.run(() => this.session!.apply(changes))
635
892
  return
@@ -692,6 +949,52 @@ export class FigureEditor extends LitElement {
692
949
  this.selected = copy
693
950
  })
694
951
  }
952
+ /**
953
+ * The one-off "put it on the plane", by the figure's placement (designer 2026-09-27): figure-model's rule
954
+ * (`figurePlaneAlignChanges`) -- a floor figure's part to the floor by its bottom, a ceiling one's to the ceiling by
955
+ * its top, a wall one's to the wall by its back, a centred one's centre to the origin's height. Once: nothing is
956
+ * fastened. Refused, it says why.
957
+ */
958
+ /* What a tilted part's typed size keeps, by the figure's placement: not the floor's lowest point everywhere (designer 2026-09-27). */
959
+ private keptExtremeWord(): string {
960
+ const placement = (this.asset() as FigureAsset | undefined)?.placement ?? 'floor'
961
+ return i18next.t(`figure.label.kept-extreme-${placement}`)
962
+ }
963
+ /* How the selected part would meet the plane, kept per source and part: working it out compiles the figure. */
964
+ private planeContact?: { source: string; part: string; contact?: FigurePlaneContact }
965
+ private planeContactOf(source: string, part: string): FigurePlaneContact | undefined {
966
+ if (this.planeContact?.source !== source || this.planeContact.part !== part) {
967
+ let contact: FigurePlaneContact | undefined
968
+ try {
969
+ contact = figurePlaneAlignChanges(parseFigureAsset(source), part).contact
970
+ } catch {
971
+ contact = undefined
972
+ }
973
+ this.planeContact = { source, part, contact }
974
+ }
975
+ return this.planeContact.contact
976
+ }
977
+ private renderPlaneAlign(asset: FigureAsset, unavailable: boolean) {
978
+ const placement = asset.placement ?? 'floor'
979
+ const icon = { floor: 'vertical_align_bottom', ceiling: 'vertical_align_top', wall: 'flip_to_back', center: 'vertical_align_center' }[placement]
980
+ /* 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). */
981
+ const contact = this.planeContactOf(this.session!.source(), this.selected)
982
+ const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
983
+ return html`<button plane-align=${placement} ?disabled=${unavailable} aria-label=${i18next.t(`figure.button.align-to-${placement}`)}
984
+ title=${i18next.t(`figure.text.align-to-${placement}${told}`)} @click=${() => this.alignToPlane()}>
985
+ <md-icon>${icon}</md-icon>
986
+ </button>`
987
+ }
988
+ private alignToPlane() {
989
+ const asset = parseFigureAsset(this.session!.source())
990
+ const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
991
+ if (refusal) {
992
+ this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
993
+ return
994
+ }
995
+ if (changes.length) this.run(() => this.session!.apply(changes))
996
+ if ((contact === 'tilted' || contact === 'extreme') && !this.error) this.notice = i18next.t(`figure.text.aligned-${contact}-${asset.placement ?? 'floor'}`)
997
+ }
695
998
  private renderTools(unavailable: boolean) {
696
999
  const reach = this.reachOf(this.selected)
697
1000
  if (!this.selected || !reach.translate) return null
@@ -708,7 +1011,7 @@ export class FigureEditor extends LitElement {
708
1011
  return html`<div tools title=${i18next.t('figure.text.tools-explained')}>
709
1012
  ${tool('centre-x', 'horizontal_distribute', 'figure.button.center-x', 'figure.text.center-on-x')}
710
1013
  ${tool('centre-z', 'vertical_align_center', 'figure.button.center-z', 'figure.text.center-on-z')}
711
- ${tool('floor', 'vertical_align_bottom', 'figure.button.to-floor', 'figure.text.put-on-floor')}
1014
+ ${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
712
1015
  ${(['x', 'z'] as const).map(axis => {
713
1016
  const able = canMirror(asset as never, this.selected, axis, reach)
714
1017
  return html`<button
@@ -779,9 +1082,34 @@ export class FigureEditor extends LitElement {
779
1082
  .dims=${dims}
780
1083
  ?locked=${unavailable}
781
1084
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
1085
+ .previewWords=${previewWordsOf(this.currentPreview(), this.selected, (key, params) => i18next.t(key, params))}
1086
+ .detachWords=${this.detachWords(asset as FigureAsset | undefined)}
1087
+ .overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : []}
1088
+ .overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : []}
1089
+ .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1090
+ @go-fasten=${() => this.goFasten()}
1091
+ @preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
782
1092
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
783
1093
  ></figure-links>`
784
1094
  }
1095
+ /**
1096
+ * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
1097
+ * while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
1098
+ * rule can say, and the gizmo keeps its own (the face across from the handle).
1099
+ */
1100
+ private anchorsOf(asset: FigureAsset | undefined): Record<string, ScaleAnchor> {
1101
+ let next: Record<string, ScaleAnchor> = {}
1102
+ try {
1103
+ const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined
1104
+ if (anchor) next = { [this.selected]: anchor }
1105
+ } catch {
1106
+ next = {}
1107
+ }
1108
+ /* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
1109
+ if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors)) this.lastAnchors = next
1110
+ return this.lastAnchors
1111
+ }
1112
+ private lastAnchors: Record<string, ScaleAnchor> = {}
785
1113
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
786
1114
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
787
1115
  let asset
@@ -827,6 +1155,7 @@ export class FigureEditor extends LitElement {
827
1155
  }
828
1156
  this.changed()
829
1157
  } catch (error) {
1158
+ if (this.askClipLoss(error, () => this.linkCommand(action))) return
830
1159
  const code = (error as { code?: string })?.code
831
1160
  const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
832
1161
  if (narrowing) {
@@ -847,15 +1176,10 @@ export class FigureEditor extends LitElement {
847
1176
  private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
848
1177
  const input = event.target as HTMLInputElement,
849
1178
  value = input.valueAsNumber
850
- /* A height typed in keeps the part's bottom where it is, as the scale gizmo does. */
1179
+ /* A size typed in keeps the face the figure's placement keeps -- figure-model's one rule, as the gizmo and "follow the size" do. */
851
1180
  const part = this.selected
852
- const kept = field.role !== 'state' && part ? keepBottomChanges(part, this.reachOf(part), field.id, value, this.designValues()) : []
853
- this.run(() =>
854
- this.session!.apply([
855
- { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value },
856
- ...kept
857
- ])
858
- )
1181
+ const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1182
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(parseFigureAsset(this.session!.source()), part, [change]) : [change]))
859
1183
  // Rejected edits must also restore the displayed numeric value.
860
1184
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
861
1185
  }
@@ -1000,7 +1324,14 @@ export class FigureEditor extends LitElement {
1000
1324
  * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now
1001
1325
  * (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
1002
1326
  */
1003
- private partRows(geometry: readonly { id: string }[]): FigurePartRow[] {
1327
+ private partRows(geometry: readonly { id: string }[], asset?: FigureAsset): FigurePartRow[] {
1328
+ /* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
1329
+ const fastened = new Map(
1330
+ (asset?.joints ?? []).map(j => [
1331
+ j.body1.part,
1332
+ 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
1333
+ ])
1334
+ )
1004
1335
  const rows = new Map<string, FigurePartRow>()
1005
1336
  for (const g of geometry) {
1006
1337
  const id = ownerOf(g.id)
@@ -1014,7 +1345,8 @@ export class FigureEditor extends LitElement {
1014
1345
  provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
1015
1346
  hidden: this.hiddenParts.has(id),
1016
1347
  invisible: (g as { visible?: boolean }).visible === false,
1017
- ...(id !== g.id ? { copies: 1 } : {})
1348
+ ...(id !== g.id ? { copies: 1 } : {}),
1349
+ ...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})
1018
1350
  })
1019
1351
  }
1020
1352
  return [...rows.values()]
@@ -1070,6 +1402,7 @@ export class FigureEditor extends LitElement {
1070
1402
  @change=${(e: Event) => this.setPitch(field, e)}
1071
1403
  />
1072
1404
  </div>
1405
+ <ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html`<li>${line}</li>`)}</ul>
1073
1406
  <p hint>
1074
1407
  ${fits && length !== undefined && longest !== undefined && limit !== undefined
1075
1408
  ? i18next.t('figure.text.repeat-what-pitch-gives', {
@@ -1088,6 +1421,14 @@ export class FigureEditor extends LitElement {
1088
1421
  : nothing}
1089
1422
  </section>`
1090
1423
  }
1424
+ /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
1425
+ private rowNow(asset: FigureAsset): FigureRowReading | undefined {
1426
+ try {
1427
+ return figureRowOf(asset, this.selected, this.currentInputs())
1428
+ } catch {
1429
+ return undefined
1430
+ }
1431
+ }
1091
1432
  /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1092
1433
  private pitchInputOf(asset: FigureAsset, part: string): string | undefined {
1093
1434
  const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch as { from?: { of?: string; input?: string } } | number | undefined
@@ -1194,6 +1535,179 @@ export class FigureEditor extends LitElement {
1194
1535
  }
1195
1536
  }
1196
1537
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
1538
+ /*
1539
+ Saving and releasing told apart, beside the save (ADR-0097 §5.6, Q-D2): what the saved figure is -- a draft never
1540
+ released, a draft while boards use an earlier release, a release -- as the server said it. Edits not saved are the
1541
+ status before it. Boards read a draft's latest release (figureBlueprint), so that is what is said.
1542
+ */
1543
+ private releaseWords(view: { id?: string | null }): string {
1544
+ if (!view.id || !this.releaseState?.state) return ''
1545
+ const { state, version } = this.releaseState
1546
+ if (state === 'released') return i18next.t('figure.text.release-state-released', { version: String(version) })
1547
+ return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new')
1548
+ }
1549
+ /** To the release that exists: the history tab's release section, not a second one here. */
1550
+ private goRelease = async () => {
1551
+ this.tab = 'history'
1552
+ await this.updateComplete
1553
+ await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1554
+ }
1555
+ /** The fastening form's command, seen before it is done; null clears what was shown. */
1556
+ private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
1557
+ if (!candidate || !this.session || !this.selected) {
1558
+ this.preview = undefined
1559
+ return
1560
+ }
1561
+ const view = this.session.read()
1562
+ const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
1563
+ try {
1564
+ const seen = this.session.previewCommand(candidate.action)
1565
+ let face: FigureFaceArea | undefined
1566
+ if (candidate.face) {
1567
+ try {
1568
+ face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs())
1569
+ } catch {
1570
+ face = undefined
1571
+ }
1572
+ }
1573
+ /* A row is read on the figure the command would make, by figure-model, as drawn. */
1574
+ const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined
1575
+ this.preview = {
1576
+ ...held,
1577
+ context: seen.context,
1578
+ revision: seen.revision,
1579
+ geometry: seen.geometry,
1580
+ moves: seen.moves,
1581
+ added: seen.added,
1582
+ ...(row ? { row } : {}),
1583
+ clipsWouldGo: seen.clipsWouldGo,
1584
+ face,
1585
+ faceUnknown: !!candidate.face && !face
1586
+ }
1587
+ } catch (error) {
1588
+ /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1589
+ this.preview = { ...held, refused: this.said(error) }
1590
+ }
1591
+ }
1592
+ /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1593
+ private currentPreview(): (HeldPreview & { part: string }) | undefined {
1594
+ const p = this.preview
1595
+ const view = this.session?.read()
1596
+ /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1597
+ if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties') return undefined
1598
+ return p
1599
+ }
1600
+ /*
1601
+ What taking the selected part off would do, worked out on a copy before the button is pressed (ADR-0097 §5.5): whether
1602
+ it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
1603
+ when the copy shows it does.
1604
+ */
1605
+ private detachSaid?: { key: string; words: string[] }
1606
+ private detachWords(asset: FigureAsset | undefined): string[] {
1607
+ const view = this.session?.read()
1608
+ const joint = asset?.joints?.find(j => j.body1.part === this.selected)
1609
+ if (!asset || !view || !joint || !this.session) return []
1610
+ const key = `${view.context}|${view.revision}|${this.selected}`
1611
+ if (this.detachSaid?.key === key) return this.detachSaid.words
1612
+ const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1613
+ const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part)
1614
+ let words: string[]
1615
+ try {
1616
+ const seen = this.session.previewCommand({ kind: 'detach', part: this.selected })
1617
+ const own = seen.moves.some(m => m.body === this.selected)
1618
+ words = own
1619
+ ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
1620
+ : [say('figure.text.detach-stays', { from })]
1621
+ const children = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => partLabelOf(j.body1.part))
1622
+ if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
1623
+ if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
1624
+ } catch (error) {
1625
+ words = [say('figure.text.detach-refused', { reason: this.said(error) })]
1626
+ }
1627
+ this.detachSaid = { key, words }
1628
+ return words
1629
+ }
1630
+ /*
1631
+ Past the edge of the face a part is fastened to (ADR-0097 §5.5, Q3): the selected part's own, and the parts fastened
1632
+ to it -- said apart from the room, nothing moved. Read once per figure, version and part.
1633
+ */
1634
+ private overhangSaid?: { key: string; words: string[]; note: string[] }
1635
+ /** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
1636
+ private overhangNote(asset: FigureAsset | undefined): string[] {
1637
+ this.overhangWords(asset)
1638
+ return this.overhangSaid?.note ?? []
1639
+ }
1640
+ private overhangWords(asset: FigureAsset | undefined): string[] {
1641
+ const view = this.session?.read()
1642
+ if (!asset || !view || !this.selected) return []
1643
+ const key = `${view.context}|${view.revision}|${this.selected}`
1644
+ if (this.overhangSaid?.key === key) return this.overhangSaid.words
1645
+ const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1646
+ const where = (o: FigureFaceOverhang) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
1647
+ const sides = (o: FigureFaceOverhang) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ')
1648
+ const words: string[] = []
1649
+ const note: string[] = []
1650
+ try {
1651
+ const inputs = this.currentInputs()
1652
+ const own = figureFaceOverhangOf(asset, this.selected, inputs)
1653
+ if (own?.state === 'larger') {
1654
+ note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }))
1655
+ if (own.lopsided.length) note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }))
1656
+ }
1657
+ if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))
1658
+ else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))
1659
+ else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))
1660
+ const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs))
1661
+ const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible')
1662
+ if (past.length) words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }))
1663
+ } catch {
1664
+ /* Not read: nothing said rather than a guess. */
1665
+ }
1666
+ this.overhangSaid = { key, words, note }
1667
+ return words
1668
+ }
1669
+ /** The selected part's motion at both ends of its range, made once per figure, version and part. */
1670
+ private motionEndsSaid?: { key: string; ends: MotionEnds | null }
1671
+ private motionEnds(): MotionEnds | null {
1672
+ const view = this.session?.read()
1673
+ if (!view || !this.selected) return null
1674
+ const key = `${view.context}|${view.revision}|${this.selected}`
1675
+ if (this.motionEndsSaid?.key !== key) {
1676
+ let ends: MotionEnds | null = null
1677
+ try {
1678
+ ends = motionEndsOf(parseFigureAsset(this.session!.source()), this.selected, this.currentInputs())
1679
+ } catch {
1680
+ ends = null
1681
+ }
1682
+ this.motionEndsSaid = { key, ends }
1683
+ }
1684
+ return this.motionEndsSaid.ends
1685
+ }
1686
+ /** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
1687
+ private async goFasten() {
1688
+ this.tab = 'properties'
1689
+ await this.updateComplete
1690
+ const links = [...this.renderRoot.querySelectorAll<HTMLElement & { section?: string; showFastening?: () => Promise<void> }>('figure-links')].find(l => l.section === 'shape')
1691
+ await links?.showFastening?.()
1692
+ }
1693
+ /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1694
+ private previewShown(): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
1695
+ const p = this.currentPreview()
1696
+ /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1697
+ if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }
1698
+ if (!p || p.refused) return { ghost: [], highlight: null }
1699
+ if (this.previewDrawn?.of !== p) this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) }
1700
+ return this.previewDrawn
1701
+ }
1702
+ /**
1703
+ * What the author has in view, for the AI dock to hear with a request (ADR-0097 §5.6 item 5): the part selected and
1704
+ * what the preview is trying. Read at the moment of sending, never cached, and never a reason to start a new
1705
+ * conversation -- selecting is not a new subject.
1706
+ */
1707
+ assistantFocus(): { part?: string; trying?: { clips?: Record<string, number>; controls?: Record<string, number> } } {
1708
+ const trying = this.middle === 'preview' ? this.renderRoot.querySelector<HTMLElement & { tried?: () => { clips?: Record<string, number>; controls?: Record<string, number> } | undefined }>('figure-preview')?.tried?.() : undefined
1709
+ return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) }
1710
+ }
1197
1711
  private choose(id: string) {
1198
1712
  /* A copy on the canvas selects its repeat: the part the author made. */
1199
1713
  this.selected = id ? ownerOf(id) : id
@@ -1206,26 +1720,126 @@ export class FigureEditor extends LitElement {
1206
1720
  return undefined
1207
1721
  }
1208
1722
  }
1723
+ /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1209
1724
  private renderTab(tab: SideTab, icon: string, label: string, badge?: number) {
1210
- return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${label} @click=${() => (this.tab = tab)}>
1211
- <md-icon>${icon}</md-icon>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
1725
+ /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1726
+ const name = i18next.t('figure.label.tab-short-' + tab)
1727
+ return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1728
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
1212
1729
  </button>`
1213
1730
  }
1214
1731
  updated(changed: PropertyValues<this>) {
1215
1732
  super.updated?.(changed)
1216
1733
  this.tell(changed)
1734
+ if (this.restorePanelFor !== undefined) {
1735
+ const key = this.restorePanelFor
1736
+ this.restorePanelFor = undefined
1737
+ requestAnimationFrame(async () => {
1738
+ const panel = this.propertiesPanel()
1739
+ if (!panel || (this.selected || '@model') !== key) return
1740
+ await Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete))
1741
+ const kept = this.panelMemory.get(key)
1742
+ if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
1743
+ panel.scrollTop = kept?.top ?? 0
1744
+ })
1745
+ }
1217
1746
  const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
1218
- if (panes !== this.panes) this.panes = panes
1747
+ if (panes !== this.panes) {
1748
+ this.panes = panes
1749
+ this.fitPanesTo(this.getBoundingClientRect().width)
1750
+ }
1219
1751
  }
1220
- /** Which part the panel speaks of, with the way back to the whole figure. */
1752
+ /**
1753
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
1754
+ * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
1755
+ * mounting face and the tags are not asked for first.
1756
+ */
1757
+ private renderStart() {
1758
+ const shapes: FigurePaletteKind[] = ['box', 'cylinder', 'plate']
1759
+ /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
1760
+ const placement = ((this.asset() as FigureAsset | undefined)?.placement ?? 'floor') as FigurePlacement
1761
+ return html`<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
1762
+ <h2>${i18next.t('figure.text.start-question')}</h2>
1763
+ <div start-paths>
1764
+ <section>
1765
+ <h3>${i18next.t('figure.label.start-placement')}</h3>
1766
+ <div start-shapes role="radiogroup" aria-label=${i18next.t('figure.label.start-placement')}>
1767
+ ${(['floor', 'ceiling', 'wall', 'center'] as const).map(
1768
+ one => html`<button start-button role="radio" aria-checked=${String(one === placement)} ?pressed=${one === placement}
1769
+ @click=${() => one !== placement && this.linkCommand({ kind: 'set-placement', placement: one })}>
1770
+ ${i18next.t(`figure.label.resize-place-${one}`)}
1771
+ </button>`
1772
+ )}
1773
+ </div>
1774
+ <small>${i18next.t('figure.text.placement-is-intent')}</small>
1775
+ </section>
1776
+ <section>
1777
+ <h3>${i18next.t('figure.label.start-shape')}</h3>
1778
+ <div start-shapes>
1779
+ ${shapes.map(
1780
+ kind => html`<button start-button start-shape=${kind} @click=${() => this.add(kind)}>
1781
+ ${i18next.t(kind === 'box' ? 'figure.label.primitive-cube' : `figure.label.primitive-${kind}`)}
1782
+ </button>`
1783
+ )}
1784
+ </div>
1785
+ <small>${i18next.t('figure.text.start-shape-more')}</small>
1786
+ </section>
1787
+ <section>
1788
+ <h3>${i18next.t('figure.label.start-recipe')}</h3>
1789
+ <button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
1790
+ ${i18next.t('figure.button.start-recipe')}
1791
+ </button>
1792
+ ${this.templates.length ? nothing : html`<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
1793
+ </section>
1794
+ ${this.aiDraft
1795
+ ? html`<section>
1796
+ <h3>${i18next.t('figure.label.start-ai')}</h3>
1797
+ <button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
1798
+ ${i18next.t('figure.button.start-ai')}
1799
+ </button>
1800
+ <small>${i18next.t('figure.text.start-ai-optional')}</small>
1801
+ </section>`
1802
+ : nothing}
1803
+ </div>
1804
+ </div>`
1805
+ }
1806
+ /**
1807
+ * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
1808
+ * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
1809
+ * model name is the same move as the parts list's root; there is no separate "whole figure" link.
1810
+ */
1221
1811
  private renderSubject() {
1222
- if (!this.selected) return null
1812
+ const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name')
1813
+ let summary: { kind: string; size: string; relation: string } | undefined
1814
+ try {
1815
+ summary = this.selected ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined
1816
+ } catch {
1817
+ summary = undefined
1818
+ }
1223
1819
  return html`<div subject>
1224
- <button chip title=${i18next.t('figure.button.whole-figure')} @click=${() => (this.selected = '')}>
1225
- <md-icon>arrow_back</md-icon>${i18next.t('figure.button.whole-figure')}
1226
- </button>
1820
+ <nav path aria-label=${i18next.t('figure.label.panel-path')}>
1821
+ <button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
1822
+ aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
1823
+ ${this.selected
1824
+ ? html`<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
1825
+ : html`<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
1826
+ </nav>
1827
+ ${summary
1828
+ ? html`<div subject-summary>
1829
+ ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1830
+ <small>${summary.size}</small><small>${summary.relation}</small>
1831
+ </div>`
1832
+ : nothing}
1227
1833
  </div>`
1228
1834
  }
1835
+ /**
1836
+ * 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
1837
+ * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
1838
+ */
1839
+ private showModelSettings() {
1840
+ this.selected = ''
1841
+ this.tab = 'properties'
1842
+ }
1229
1843
  render() {
1230
1844
  const view = this.session?.read()
1231
1845
  if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
@@ -1236,6 +1850,35 @@ export class FigureEditor extends LitElement {
1236
1850
  /* A repeat's own row values (its pitch) have their own section, not the shape's. */
1237
1851
  const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
1238
1852
  const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
1853
+ /*
1854
+ A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
1855
+ nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
1856
+ */
1857
+ let roomOnly: string[] = []
1858
+ try {
1859
+ roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session!.source()), this.selected) : []
1860
+ } catch {
1861
+ roomOnly = []
1862
+ }
1863
+ const partFields = own.filter(i => !roomOnly.includes(i.id))
1864
+ /* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
1865
+ let held: HeldValues | undefined
1866
+ try {
1867
+ const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
1868
+ const placed = body ? placementOf(body) : undefined
1869
+ const parsed = this.selected ? parseFigureAsset(this.session!.source()) : undefined
1870
+ if (placed && parsed)
1871
+ held = {
1872
+ position: placed.position,
1873
+ rotation: placed.rotation,
1874
+ size: (['x', 'y', 'z'] as const).map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
1875
+ /* Only a size a rule sets: a palette part's own numbers are its shape's fields, not a rule's (found in the browser). */
1876
+ sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
1877
+ }
1878
+ } catch {
1879
+ held = undefined
1880
+ }
1881
+ const roomFields = own.filter(i => roomOnly.includes(i.id))
1239
1882
  const status = view.loading
1240
1883
  ? 'figure.text.loading-figure'
1241
1884
  : view.saving
@@ -1264,6 +1907,8 @@ export class FigureEditor extends LitElement {
1264
1907
  </button>`
1265
1908
  )}
1266
1909
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1910
+ <span slot="status" release-state>${this.releaseWords(view)}</span>
1911
+ <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>
1267
1912
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
1268
1913
  </ox-canvas-bar>
1269
1914
  ${this.catalogOpen
@@ -1283,10 +1928,12 @@ export class FigureEditor extends LitElement {
1283
1928
  slot="start"
1284
1929
  .budget=${this.budget}
1285
1930
  @show-maturity=${() => (this.tab = 'verdict')}
1286
- .parts=${this.partRows(view.geometry)}
1931
+ .parts=${this.partRows(view.geometry, asset as FigureAsset | undefined)}
1287
1932
  .partBudget=${this.partBudgetOf(asset as FigureAsset | undefined)}
1288
1933
  @set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
1289
1934
  .selected=${this.selected}
1935
+ .modelName=${view.name ?? ''}
1936
+ @show-model-settings=${() => this.showModelSettings()}
1290
1937
  ?locked=${unavailable}
1291
1938
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
1292
1939
  .recipeCount=${this.templates.length}
@@ -1305,12 +1952,25 @@ export class FigureEditor extends LitElement {
1305
1952
  .asset=${asset}
1306
1953
  .context=${view.context ?? ''}
1307
1954
  .view=${this.view}
1955
+ @configure-size=${async (e: CustomEvent<{ part: string }>) => {
1956
+ this.middle = 'edit'
1957
+ this.selected = e.detail.part
1958
+ this.tab = 'properties'
1959
+ await this.updateComplete
1960
+ this.renderRoot.querySelector('figure-links')?.scrollIntoView({ block: 'nearest' })
1961
+ }}
1308
1962
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1309
1963
  ></figure-preview>`
1310
- : html`<figure-canvas
1964
+ : html`<div middle>
1965
+ ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
1966
+ <figure-canvas
1967
+ ?startShown=${view.geometry.length === 0 && !unavailable}
1311
1968
  .view=${this.view}
1312
1969
  .parts=${this.drawnParts(view.geometry, asset)}
1970
+ .anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
1313
1971
  .fan=${this.fanOf(asset as FigureAsset | undefined)}
1972
+ .ghost=${this.previewShown().ghost}
1973
+ .highlight=${this.previewShown().highlight}
1314
1974
  .palette=${view.palette ?? {}}
1315
1975
  .context=${view.context ?? ''}
1316
1976
  .selected=${this.selected}
@@ -1320,7 +1980,7 @@ export class FigureEditor extends LitElement {
1320
1980
  @render-error=${(e: CustomEvent<Error>) => {
1321
1981
  this.error = e.detail.message
1322
1982
  }}
1323
- ></figure-canvas>`}
1983
+ ></figure-canvas></div>`}
1324
1984
  <aside slot="end">
1325
1985
  <div tabs>
1326
1986
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1349,7 +2009,8 @@ export class FigureEditor extends LitElement {
1349
2009
  ${this.renderRepeatNote(view.geometry)}
1350
2010
  </section>
1351
2011
  ${this.renderRepeat(asset as FigureAsset | undefined, view.geometry, unavailable)}
1352
- ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
2012
+ ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
2013
+ ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
1353
2014
  ${this.renderLinks(unavailable, 'shape')}
1354
2015
  <figure-material-panel
1355
2016
  .body=${{ ...part, id: this.selected }}
@@ -1358,7 +2019,15 @@ export class FigureEditor extends LitElement {
1358
2019
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1359
2020
  @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
1360
2021
  ></figure-material-panel>`
1361
- : html`<slot name="figure"></slot>
2022
+ : view.geometry.length === 0
2023
+ ? html`${this.renderSubject()}<section empty-figure>
2024
+ <p>${i18next.t('figure.text.empty-figure-next')}</p>
2025
+ <details>
2026
+ <summary>${i18next.t('figure.label.figure-info-later')}</summary>
2027
+ <slot name="figure"></slot>
2028
+ </details>
2029
+ </section>`
2030
+ : html`${this.renderSubject()}<slot name="figure"></slot>
1362
2031
  ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
1363
2032
  ${this.renderInstanceRange(asset as FigureAsset | undefined, unavailable)}`}
1364
2033
  </div>
@@ -1392,12 +2061,13 @@ export class FigureEditor extends LitElement {
1392
2061
  vectorTitle: string,
1393
2062
  singleTitle: string,
1394
2063
  unavailable: boolean,
1395
- tools: unknown = null
2064
+ tools: unknown = null,
2065
+ held?: HeldValues
1396
2066
  ) {
1397
2067
  return html`${groups.vectors.length || tools
1398
2068
  ? html`<section>
1399
2069
  <h3>${i18next.t(vectorTitle)}</h3>
1400
- ${groups.vectors.map(g => this.renderVector(g, unavailable))}
2070
+ ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
1401
2071
  ${tools ? html`<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
1402
2072
  </section>`
1403
2073
  : nothing}
@@ -1408,20 +2078,42 @@ export class FigureEditor extends LitElement {
1408
2078
  </section>`
1409
2079
  : nothing}`
1410
2080
  }
1411
- private renderVector(group: FieldGroup, unavailable: boolean) {
2081
+ /**
2082
+ * A box-like part's size row stands when a rule sets every side -- each following the figure's size -- with the
2083
+ * numbers the rule gives, as its position and turn rows do (designer 2026-09-27: not a blank, not gone).
2084
+ */
2085
+ private withSizeRow(groups: ReturnType<typeof groupFields>, held?: HeldValues) {
2086
+ if (groups.vectors.some(v => v.kind === 'vector' && v.name === 'size')) return groups
2087
+ if (!held?.sizeRuled || !held.size || held.size.length !== 3 || held.size.some(v => v === undefined)) return groups
2088
+ const at = groups.vectors.findIndex(v => v.kind === 'vector' && v.name === 'rotation')
2089
+ const vectors = [...groups.vectors]
2090
+ vectors.splice(at < 0 ? vectors.length : at + 1, 0, { kind: 'vector', name: 'size', cells: {} })
2091
+ return { ...groups, vectors }
2092
+ }
2093
+ private renderVector(group: FieldGroup, unavailable: boolean, held?: HeldValues) {
1412
2094
  if (group.kind !== 'vector') return nothing
1413
- const unit = group.cells.x.unit === 'deg' ? '°' : group.cells.x.unit
2095
+ const unit = group.name === 'rotation' ? '°' : 'mm'
2096
+ const ruled = (['x', 'y', 'z'] as const).filter(axis => !group.cells[axis])
2097
+ const given = (axis: 'x' | 'y' | 'z') => {
2098
+ const v = held?.[group.name]?.[['x', 'y', 'z'].indexOf(axis)]
2099
+ return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10)
2100
+ }
1414
2101
  return html`<div vector-row>
1415
2102
  <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html` <span unit>${unit}</span>`}</label>
1416
2103
  <div triple>
1417
2104
  ${(['x', 'y', 'z'] as const).map(
1418
- axis => html`<div cell>
2105
+ axis => !group.cells[axis]
2106
+ ? html`<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
2107
+ <span axis>${axis.toUpperCase()}</span>
2108
+ <input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
2109
+ </div>`
2110
+ : html`<div cell>
1419
2111
  <span axis ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}>${axis.toUpperCase()}</span>
1420
2112
  <input
1421
- aria-label=${group.cells[axis].id}
2113
+ aria-label=${group.cells[axis]!.id}
1422
2114
  type="number"
1423
2115
  step="any"
1424
- .value=${String(group.cells[axis].value)}
2116
+ .value=${String(group.cells[axis]!.value)}
1425
2117
  ?disabled=${unavailable}
1426
2118
  @input=${(e: Event) => this.typing(group.cells[axis] as never, e)}
1427
2119
  @change=${(e: Event) => this.changeField(group.cells[axis] as never, e)}
@@ -1429,7 +2121,62 @@ export class FigureEditor extends LitElement {
1429
2121
  </div>`
1430
2122
  )}
1431
2123
  </div>
1432
- </div>`
2124
+ </div>
2125
+ ${ruled.length ? html`<p hint held-axes>${i18next.t('figure.text.axes-held-by-rule', { group: i18next.t('figure.label.group-' + group.name), axes: ruled.map(a => a.toUpperCase()).join(' · ') })}</p>` : nothing}`
2126
+ }
2127
+ /**
2128
+ * A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
2129
+ * typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
2130
+ */
2131
+ private renderRoomOnly(fields: FieldLike[], unavailable: boolean) {
2132
+ return html`<section room-only>
2133
+ <h3>${i18next.t('figure.label.room-only-inputs')}</h3>
2134
+ <p hint>${i18next.t('figure.text.room-only-inputs')}</p>
2135
+ <div form>
2136
+ ${fields.map(
2137
+ f => html`<span>${fieldWordsOf(f.id, key => i18next.t(key))} (${f.unit})</span><input aria-label=${f.id} type="number" .value=${String(f.value)} disabled />`
2138
+ )}
2139
+ </div>
2140
+ <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
2141
+ </section>`
2142
+ }
2143
+ /** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
2144
+ private async askRoomRefit() {
2145
+ let refit
2146
+ try {
2147
+ refit = figureRoomRefitOf(parseFigureAsset(this.session!.source()))
2148
+ } catch (error) {
2149
+ this.error = this.said(error)
2150
+ return
2151
+ }
2152
+ if (!refit) return
2153
+ const rows = roomRefitRowsOf(refit)
2154
+ const outside = (list: typeof refit.outside.declared) =>
2155
+ list.length
2156
+ ? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
2157
+ : i18next.t('figure.text.room-nothing-outside')
2158
+ const agreed = await OxPrompt.open({
2159
+ type: 'question',
2160
+ title: i18next.t('figure.label.room-refit'),
2161
+ template: html`<table room-refit>
2162
+ <tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
2163
+ ${rows.map(
2164
+ r => html`<tr>
2165
+ <td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
2166
+ <td>${r.declared}</td>
2167
+ <td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
2168
+ </tr>`
2169
+ )}
2170
+ </table>
2171
+ <p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
2172
+ <p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
2173
+ ${refit.drops.length
2174
+ ? html`<p>${i18next.t('figure.text.room-drops', { fields: [...new Set(refit.drops.map(id => `${partLabelOf(id.split('.')[0]!)} ${fieldWordsOf(id, key => i18next.t(key))}`))].join(' · ') })}</p>`
2175
+ : nothing}`,
2176
+ confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
2177
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
2178
+ })
2179
+ if (agreed) this.linkCommand(refit.step)
1433
2180
  }
1434
2181
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
1435
2182
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))