@things-factory/figure-ui 10.1.81 → 10.1.82

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 (145) 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 +826 -54
  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 +385 -68
  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/clips.d.ts +1 -1
  50. package/dist-client/modeller/command-preview.d.ts +35 -0
  51. package/dist-client/modeller/command-preview.js +95 -0
  52. package/dist-client/modeller/command-preview.js.map +1 -0
  53. package/dist-client/modeller/editor.d.ts +113 -1
  54. package/dist-client/modeller/editor.js +850 -50
  55. package/dist-client/modeller/editor.js.map +1 -1
  56. package/dist-client/modeller/fields.d.ts +3 -2
  57. package/dist-client/modeller/fields.js +10 -3
  58. package/dist-client/modeller/fields.js.map +1 -1
  59. package/dist-client/modeller/figure-facts.js +2 -2
  60. package/dist-client/modeller/figure-facts.js.map +1 -1
  61. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  62. package/dist-client/modeller/figure-first-save.js +41 -14
  63. package/dist-client/modeller/figure-first-save.js.map +1 -1
  64. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  65. package/dist-client/modeller/figure-history-panel.js +130 -14
  66. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-links.d.ts +51 -1
  68. package/dist-client/modeller/figure-links.js +392 -72
  69. package/dist-client/modeller/figure-links.js.map +1 -1
  70. package/dist-client/modeller/figure-parts.d.ts +7 -0
  71. package/dist-client/modeller/figure-parts.js +55 -2
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.d.ts +6 -0
  74. package/dist-client/modeller/figure-preview.js +22 -1
  75. package/dist-client/modeller/figure-preview.js.map +1 -1
  76. package/dist-client/modeller/figure-properties.js +3 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  79. package/dist-client/modeller/figure-revert-choice.js +59 -0
  80. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  81. package/dist-client/modeller/first-save.d.ts +7 -0
  82. package/dist-client/modeller/first-save.js +17 -0
  83. package/dist-client/modeller/first-save.js.map +1 -1
  84. package/dist-client/modeller/gizmo.d.ts +0 -6
  85. package/dist-client/modeller/gizmo.js +0 -18
  86. package/dist-client/modeller/gizmo.js.map +1 -1
  87. package/dist-client/modeller/links.d.ts +49 -4
  88. package/dist-client/modeller/links.js +141 -15
  89. package/dist-client/modeller/links.js.map +1 -1
  90. package/dist-client/modeller/motion-ends.d.ts +12 -0
  91. package/dist-client/modeller/motion-ends.js +65 -0
  92. package/dist-client/modeller/motion-ends.js.map +1 -0
  93. package/dist-client/modeller/occupancy.d.ts +14 -1
  94. package/dist-client/modeller/occupancy.js +14 -1
  95. package/dist-client/modeller/occupancy.js.map +1 -1
  96. package/dist-client/modeller/product-session.d.ts +43 -2
  97. package/dist-client/modeller/product-session.js +146 -12
  98. package/dist-client/modeller/product-session.js.map +1 -1
  99. package/dist-client/modeller/refusals.d.ts +13 -0
  100. package/dist-client/modeller/refusals.js +16 -0
  101. package/dist-client/modeller/refusals.js.map +1 -1
  102. package/dist-client/modeller/revert-request.d.ts +16 -0
  103. package/dist-client/modeller/revert-request.js +11 -0
  104. package/dist-client/modeller/revert-request.js.map +1 -0
  105. package/dist-client/modeller/side-panel-styles.js +12 -0
  106. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  107. package/dist-client/modeller/stage-model.d.ts +18 -2
  108. package/dist-client/modeller/stage-model.js +21 -3
  109. package/dist-client/modeller/stage-model.js.map +1 -1
  110. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  111. package/dist-client/pages/figure-modeller-page.js +72 -34
  112. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  113. package/dist-client/route.d.ts +1 -1
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +5 -5
  116. package/test/ai-proposal.test.ts +2 -2
  117. package/test/align.test.ts +50 -5
  118. package/test/browser/acceptance-page.js +187 -0
  119. package/test/browser/build.mjs +5 -1
  120. package/test/browser/revert-page.js +47 -0
  121. package/test/browser/serve-acceptance.mjs +85 -0
  122. package/test/browser/serve-revert.mjs +119 -0
  123. package/test/clip-ai-proposal.test.ts +104 -0
  124. package/test/clip-consent.test.ts +88 -0
  125. package/test/command-preview.test.ts +288 -0
  126. package/test/fields.test.ts +4 -3
  127. package/test/figure-assistant-surface.test.ts +4 -1
  128. package/test/first-minute.test.ts +96 -0
  129. package/test/first-save-suggest.test.ts +42 -0
  130. package/test/gizmo.test.ts +20 -16
  131. package/test/joint-fan.test.ts +2 -1
  132. package/test/layout.test.ts +9 -2
  133. package/test/links.test.ts +7 -4
  134. package/test/proposal-record.test.ts +126 -0
  135. package/test/release-entry.test.ts +28 -0
  136. package/test/release-panel.test.ts +3 -2
  137. package/test/resize-with-placement.test.ts +225 -0
  138. package/test/revert-choice.test.ts +39 -0
  139. package/test/revert-keeping.test.ts +102 -0
  140. package/test/save-landed.test.ts +198 -0
  141. package/translations/en.json +222 -10
  142. package/translations/ja.json +223 -11
  143. package/translations/ko.json +223 -11
  144. package/translations/ms.json +222 -10
  145. package/translations/zh.json +223 -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, planFigureResize, 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, FigureResizeRequest, 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'
36
- import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.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'
42
+ import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, resizeRefusalOf, 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;
158
+ min-width: 0;
159
+ min-height: 0;
160
+ }
161
+ div[middle] {
162
+ position: relative;
163
+ display: flex;
140
164
  min-width: 0;
141
165
  min-height: 0;
142
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,35 @@ 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)}
1092
+ @resize-with-placement=${(e: CustomEvent) => this.resizeWithPlacement(e.detail.request)}
782
1093
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
783
1094
  ></figure-links>`
784
1095
  }
1096
+ /**
1097
+ * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
1098
+ * while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
1099
+ * rule can say, and the gizmo keeps its own (the face across from the handle).
1100
+ */
1101
+ private anchorsOf(asset: FigureAsset | undefined): Record<string, ScaleAnchor> {
1102
+ let next: Record<string, ScaleAnchor> = {}
1103
+ try {
1104
+ const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined
1105
+ if (anchor) next = { [this.selected]: anchor }
1106
+ } catch {
1107
+ next = {}
1108
+ }
1109
+ /* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
1110
+ if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors)) this.lastAnchors = next
1111
+ return this.lastAnchors
1112
+ }
1113
+ private lastAnchors: Record<string, ScaleAnchor> = {}
785
1114
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
786
1115
  private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
787
1116
  let asset
@@ -808,6 +1137,30 @@ export class FigureEditor extends LitElement {
808
1137
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
809
1138
  ></figure-clips>`
810
1139
  }
1140
+ /**
1141
+ * "Follow the size and keep where the figure is placed" (ADR-0097): figure-model plans it from the figure as it is now,
1142
+ * and the whole plan is one step of the history. A refusal with a way on -- move the part onto the plane, replace how
1143
+ * its position is worked out -- asks in the app's prompt and plans again with that choice; any other says why.
1144
+ */
1145
+ private resizeWithPlacement(request: FigureResizeRequest) {
1146
+ this.retry = undefined
1147
+ this.notice = ''
1148
+ const plan = planFigureResize(parseFigureAsset(this.session!.source()), request)
1149
+ const refusal = plan.refusal
1150
+ if (refusal) {
1151
+ const choice = refusal.choice
1152
+ /* The gap is chosen on the screen beside the button, with its distance; only replacing a position rule is asked here. */
1153
+ this.retry = choice === 'replacePosition' ? { label: i18next.t('figure.button.resize-choice-replace-position'), run: () => this.resizeWithPlacement({ ...request, replacePosition: true }) } : undefined
1154
+ this.error = resizeRefusalOf(plan, request.part, (key, params) => i18next.t(key, params))
1155
+ return
1156
+ }
1157
+ this.run(() => this.session!.applyRecipe(plan.steps))
1158
+ /*
1159
+ The declared room was measured from the part's old numbers and is not changed: said as a fact, not as a claim that
1160
+ the part leaves it -- that is measured where the room is declared again (designer 2026-09-27).
1161
+ */
1162
+ if (!this.error && plan.roomReads.length) this.notice = i18next.t('figure.text.resize-room-unchanged')
1163
+ }
811
1164
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
812
1165
  private linkCommand(action: FigureAuthoringAction) {
813
1166
  this.retry = undefined
@@ -827,6 +1180,7 @@ export class FigureEditor extends LitElement {
827
1180
  }
828
1181
  this.changed()
829
1182
  } catch (error) {
1183
+ if (this.askClipLoss(error, () => this.linkCommand(action))) return
830
1184
  const code = (error as { code?: string })?.code
831
1185
  const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
832
1186
  if (narrowing) {
@@ -847,15 +1201,10 @@ export class FigureEditor extends LitElement {
847
1201
  private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
848
1202
  const input = event.target as HTMLInputElement,
849
1203
  value = input.valueAsNumber
850
- /* A height typed in keeps the part's bottom where it is, as the scale gizmo does. */
1204
+ /* 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
1205
  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
- )
1206
+ const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1207
+ this.run(() => this.session!.apply(field.role !== 'state' && part ? figureSizeEditChanges(parseFigureAsset(this.session!.source()), part, [change]) : [change]))
859
1208
  // Rejected edits must also restore the displayed numeric value.
860
1209
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
861
1210
  }
@@ -1000,7 +1349,14 @@ export class FigureEditor extends LitElement {
1000
1349
  * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now
1001
1350
  * (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
1002
1351
  */
1003
- private partRows(geometry: readonly { id: string }[]): FigurePartRow[] {
1352
+ private partRows(geometry: readonly { id: string }[], asset?: FigureAsset): FigurePartRow[] {
1353
+ /* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
1354
+ const fastened = new Map(
1355
+ (asset?.joints ?? []).map(j => [
1356
+ j.body1.part,
1357
+ 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
1358
+ ])
1359
+ )
1004
1360
  const rows = new Map<string, FigurePartRow>()
1005
1361
  for (const g of geometry) {
1006
1362
  const id = ownerOf(g.id)
@@ -1014,7 +1370,8 @@ export class FigureEditor extends LitElement {
1014
1370
  provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
1015
1371
  hidden: this.hiddenParts.has(id),
1016
1372
  invisible: (g as { visible?: boolean }).visible === false,
1017
- ...(id !== g.id ? { copies: 1 } : {})
1373
+ ...(id !== g.id ? { copies: 1 } : {}),
1374
+ ...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})
1018
1375
  })
1019
1376
  }
1020
1377
  return [...rows.values()]
@@ -1070,6 +1427,7 @@ export class FigureEditor extends LitElement {
1070
1427
  @change=${(e: Event) => this.setPitch(field, e)}
1071
1428
  />
1072
1429
  </div>
1430
+ <ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html`<li>${line}</li>`)}</ul>
1073
1431
  <p hint>
1074
1432
  ${fits && length !== undefined && longest !== undefined && limit !== undefined
1075
1433
  ? i18next.t('figure.text.repeat-what-pitch-gives', {
@@ -1088,6 +1446,14 @@ export class FigureEditor extends LitElement {
1088
1446
  : nothing}
1089
1447
  </section>`
1090
1448
  }
1449
+ /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
1450
+ private rowNow(asset: FigureAsset): FigureRowReading | undefined {
1451
+ try {
1452
+ return figureRowOf(asset, this.selected, this.currentInputs())
1453
+ } catch {
1454
+ return undefined
1455
+ }
1456
+ }
1091
1457
  /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1092
1458
  private pitchInputOf(asset: FigureAsset, part: string): string | undefined {
1093
1459
  const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch as { from?: { of?: string; input?: string } } | number | undefined
@@ -1194,6 +1560,179 @@ export class FigureEditor extends LitElement {
1194
1560
  }
1195
1561
  }
1196
1562
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
1563
+ /*
1564
+ Saving and releasing told apart, beside the save (ADR-0097 §5.6, Q-D2): what the saved figure is -- a draft never
1565
+ released, a draft while boards use an earlier release, a release -- as the server said it. Edits not saved are the
1566
+ status before it. Boards read a draft's latest release (figureBlueprint), so that is what is said.
1567
+ */
1568
+ private releaseWords(view: { id?: string | null }): string {
1569
+ if (!view.id || !this.releaseState?.state) return ''
1570
+ const { state, version } = this.releaseState
1571
+ if (state === 'released') return i18next.t('figure.text.release-state-released', { version: String(version) })
1572
+ return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new')
1573
+ }
1574
+ /** To the release that exists: the history tab's release section, not a second one here. */
1575
+ private goRelease = async () => {
1576
+ this.tab = 'history'
1577
+ await this.updateComplete
1578
+ await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
1579
+ }
1580
+ /** The fastening form's command, seen before it is done; null clears what was shown. */
1581
+ private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
1582
+ if (!candidate || !this.session || !this.selected) {
1583
+ this.preview = undefined
1584
+ return
1585
+ }
1586
+ const view = this.session.read()
1587
+ const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
1588
+ try {
1589
+ const seen = this.session.previewCommand(candidate.action)
1590
+ let face: FigureFaceArea | undefined
1591
+ if (candidate.face) {
1592
+ try {
1593
+ face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs())
1594
+ } catch {
1595
+ face = undefined
1596
+ }
1597
+ }
1598
+ /* A row is read on the figure the command would make, by figure-model, as drawn. */
1599
+ const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined
1600
+ this.preview = {
1601
+ ...held,
1602
+ context: seen.context,
1603
+ revision: seen.revision,
1604
+ geometry: seen.geometry,
1605
+ moves: seen.moves,
1606
+ added: seen.added,
1607
+ ...(row ? { row } : {}),
1608
+ clipsWouldGo: seen.clipsWouldGo,
1609
+ face,
1610
+ faceUnknown: !!candidate.face && !face
1611
+ }
1612
+ } catch (error) {
1613
+ /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1614
+ this.preview = { ...held, refused: this.said(error) }
1615
+ }
1616
+ }
1617
+ /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1618
+ private currentPreview(): (HeldPreview & { part: string }) | undefined {
1619
+ const p = this.preview
1620
+ const view = this.session?.read()
1621
+ /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1622
+ if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties') return undefined
1623
+ return p
1624
+ }
1625
+ /*
1626
+ What taking the selected part off would do, worked out on a copy before the button is pressed (ADR-0097 §5.5): whether
1627
+ it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
1628
+ when the copy shows it does.
1629
+ */
1630
+ private detachSaid?: { key: string; words: string[] }
1631
+ private detachWords(asset: FigureAsset | undefined): string[] {
1632
+ const view = this.session?.read()
1633
+ const joint = asset?.joints?.find(j => j.body1.part === this.selected)
1634
+ if (!asset || !view || !joint || !this.session) return []
1635
+ const key = `${view.context}|${view.revision}|${this.selected}`
1636
+ if (this.detachSaid?.key === key) return this.detachSaid.words
1637
+ const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1638
+ const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part)
1639
+ let words: string[]
1640
+ try {
1641
+ const seen = this.session.previewCommand({ kind: 'detach', part: this.selected })
1642
+ const own = seen.moves.some(m => m.body === this.selected)
1643
+ words = own
1644
+ ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
1645
+ : [say('figure.text.detach-stays', { from })]
1646
+ const children = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => partLabelOf(j.body1.part))
1647
+ if (children.length) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
1648
+ if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
1649
+ } catch (error) {
1650
+ words = [say('figure.text.detach-refused', { reason: this.said(error) })]
1651
+ }
1652
+ this.detachSaid = { key, words }
1653
+ return words
1654
+ }
1655
+ /*
1656
+ 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
1657
+ to it -- said apart from the room, nothing moved. Read once per figure, version and part.
1658
+ */
1659
+ private overhangSaid?: { key: string; words: string[]; note: string[] }
1660
+ /** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
1661
+ private overhangNote(asset: FigureAsset | undefined): string[] {
1662
+ this.overhangWords(asset)
1663
+ return this.overhangSaid?.note ?? []
1664
+ }
1665
+ private overhangWords(asset: FigureAsset | undefined): string[] {
1666
+ const view = this.session?.read()
1667
+ if (!asset || !view || !this.selected) return []
1668
+ const key = `${view.context}|${view.revision}|${this.selected}`
1669
+ if (this.overhangSaid?.key === key) return this.overhangSaid.words
1670
+ const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
1671
+ const where = (o: FigureFaceOverhang) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
1672
+ const sides = (o: FigureFaceOverhang) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ')
1673
+ const words: string[] = []
1674
+ const note: string[] = []
1675
+ try {
1676
+ const inputs = this.currentInputs()
1677
+ const own = figureFaceOverhangOf(asset, this.selected, inputs)
1678
+ if (own?.state === 'larger') {
1679
+ note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }))
1680
+ if (own.lopsided.length) note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }))
1681
+ }
1682
+ if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))
1683
+ else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))
1684
+ else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))
1685
+ const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs))
1686
+ const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible')
1687
+ if (past.length) words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }))
1688
+ } catch {
1689
+ /* Not read: nothing said rather than a guess. */
1690
+ }
1691
+ this.overhangSaid = { key, words, note }
1692
+ return words
1693
+ }
1694
+ /** The selected part's motion at both ends of its range, made once per figure, version and part. */
1695
+ private motionEndsSaid?: { key: string; ends: MotionEnds | null }
1696
+ private motionEnds(): MotionEnds | null {
1697
+ const view = this.session?.read()
1698
+ if (!view || !this.selected) return null
1699
+ const key = `${view.context}|${view.revision}|${this.selected}`
1700
+ if (this.motionEndsSaid?.key !== key) {
1701
+ let ends: MotionEnds | null = null
1702
+ try {
1703
+ ends = motionEndsOf(parseFigureAsset(this.session!.source()), this.selected, this.currentInputs())
1704
+ } catch {
1705
+ ends = null
1706
+ }
1707
+ this.motionEndsSaid = { key, ends }
1708
+ }
1709
+ return this.motionEndsSaid.ends
1710
+ }
1711
+ /** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
1712
+ private async goFasten() {
1713
+ this.tab = 'properties'
1714
+ await this.updateComplete
1715
+ const links = [...this.renderRoot.querySelectorAll<HTMLElement & { section?: string; showFastening?: () => Promise<void> }>('figure-links')].find(l => l.section === 'shape')
1716
+ await links?.showFastening?.()
1717
+ }
1718
+ /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1719
+ private previewShown(): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
1720
+ const p = this.currentPreview()
1721
+ /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1722
+ if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }
1723
+ if (!p || p.refused) return { ghost: [], highlight: null }
1724
+ if (this.previewDrawn?.of !== p) this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) }
1725
+ return this.previewDrawn
1726
+ }
1727
+ /**
1728
+ * 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
1729
+ * what the preview is trying. Read at the moment of sending, never cached, and never a reason to start a new
1730
+ * conversation -- selecting is not a new subject.
1731
+ */
1732
+ assistantFocus(): { part?: string; trying?: { clips?: Record<string, number>; controls?: Record<string, number> } } {
1733
+ const trying = this.middle === 'preview' ? this.renderRoot.querySelector<HTMLElement & { tried?: () => { clips?: Record<string, number>; controls?: Record<string, number> } | undefined }>('figure-preview')?.tried?.() : undefined
1734
+ return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) }
1735
+ }
1197
1736
  private choose(id: string) {
1198
1737
  /* A copy on the canvas selects its repeat: the part the author made. */
1199
1738
  this.selected = id ? ownerOf(id) : id
@@ -1206,26 +1745,126 @@ export class FigureEditor extends LitElement {
1206
1745
  return undefined
1207
1746
  }
1208
1747
  }
1748
+ /** 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
1749
  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>`}
1750
+ /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1751
+ const name = i18next.t('figure.label.tab-short-' + tab)
1752
+ return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1753
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
1212
1754
  </button>`
1213
1755
  }
1214
1756
  updated(changed: PropertyValues<this>) {
1215
1757
  super.updated?.(changed)
1216
1758
  this.tell(changed)
1759
+ if (this.restorePanelFor !== undefined) {
1760
+ const key = this.restorePanelFor
1761
+ this.restorePanelFor = undefined
1762
+ requestAnimationFrame(async () => {
1763
+ const panel = this.propertiesPanel()
1764
+ if (!panel || (this.selected || '@model') !== key) return
1765
+ await Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete))
1766
+ const kept = this.panelMemory.get(key)
1767
+ if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
1768
+ panel.scrollTop = kept?.top ?? 0
1769
+ })
1770
+ }
1217
1771
  const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
1218
- if (panes !== this.panes) this.panes = panes
1772
+ if (panes !== this.panes) {
1773
+ this.panes = panes
1774
+ this.fitPanesTo(this.getBoundingClientRect().width)
1775
+ }
1776
+ }
1777
+ /**
1778
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
1779
+ * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
1780
+ * mounting face and the tags are not asked for first.
1781
+ */
1782
+ private renderStart() {
1783
+ const shapes: FigurePaletteKind[] = ['box', 'cylinder', 'plate']
1784
+ /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
1785
+ const placement = ((this.asset() as FigureAsset | undefined)?.placement ?? 'floor') as FigurePlacement
1786
+ return html`<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
1787
+ <h2>${i18next.t('figure.text.start-question')}</h2>
1788
+ <div start-paths>
1789
+ <section>
1790
+ <h3>${i18next.t('figure.label.start-placement')}</h3>
1791
+ <div start-shapes role="radiogroup" aria-label=${i18next.t('figure.label.start-placement')}>
1792
+ ${(['floor', 'ceiling', 'wall', 'center'] as const).map(
1793
+ one => html`<button start-button role="radio" aria-checked=${String(one === placement)} ?pressed=${one === placement}
1794
+ @click=${() => one !== placement && this.linkCommand({ kind: 'set-placement', placement: one })}>
1795
+ ${i18next.t(`figure.label.resize-place-${one}`)}
1796
+ </button>`
1797
+ )}
1798
+ </div>
1799
+ <small>${i18next.t('figure.text.placement-is-intent')}</small>
1800
+ </section>
1801
+ <section>
1802
+ <h3>${i18next.t('figure.label.start-shape')}</h3>
1803
+ <div start-shapes>
1804
+ ${shapes.map(
1805
+ kind => html`<button start-button start-shape=${kind} @click=${() => this.add(kind)}>
1806
+ ${i18next.t(kind === 'box' ? 'figure.label.primitive-cube' : `figure.label.primitive-${kind}`)}
1807
+ </button>`
1808
+ )}
1809
+ </div>
1810
+ <small>${i18next.t('figure.text.start-shape-more')}</small>
1811
+ </section>
1812
+ <section>
1813
+ <h3>${i18next.t('figure.label.start-recipe')}</h3>
1814
+ <button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
1815
+ ${i18next.t('figure.button.start-recipe')}
1816
+ </button>
1817
+ ${this.templates.length ? nothing : html`<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
1818
+ </section>
1819
+ ${this.aiDraft
1820
+ ? html`<section>
1821
+ <h3>${i18next.t('figure.label.start-ai')}</h3>
1822
+ <button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
1823
+ ${i18next.t('figure.button.start-ai')}
1824
+ </button>
1825
+ <small>${i18next.t('figure.text.start-ai-optional')}</small>
1826
+ </section>`
1827
+ : nothing}
1828
+ </div>
1829
+ </div>`
1219
1830
  }
1220
- /** Which part the panel speaks of, with the way back to the whole figure. */
1831
+ /**
1832
+ * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
1833
+ * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
1834
+ * model name is the same move as the parts list's root; there is no separate "whole figure" link.
1835
+ */
1221
1836
  private renderSubject() {
1222
- if (!this.selected) return null
1837
+ const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name')
1838
+ let summary: { kind: string; size: string; relation: string } | undefined
1839
+ try {
1840
+ summary = this.selected ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined
1841
+ } catch {
1842
+ summary = undefined
1843
+ }
1223
1844
  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>
1845
+ <nav path aria-label=${i18next.t('figure.label.panel-path')}>
1846
+ <button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
1847
+ aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
1848
+ ${this.selected
1849
+ ? html`<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
1850
+ : html`<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
1851
+ </nav>
1852
+ ${summary
1853
+ ? html`<div subject-summary>
1854
+ ${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
1855
+ <small>${summary.size}</small><small>${summary.relation}</small>
1856
+ </div>`
1857
+ : nothing}
1227
1858
  </div>`
1228
1859
  }
1860
+ /**
1861
+ * 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
1862
+ * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
1863
+ */
1864
+ private showModelSettings() {
1865
+ this.selected = ''
1866
+ this.tab = 'properties'
1867
+ }
1229
1868
  render() {
1230
1869
  const view = this.session?.read()
1231
1870
  if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
@@ -1236,6 +1875,35 @@ export class FigureEditor extends LitElement {
1236
1875
  /* A repeat's own row values (its pitch) have their own section, not the shape's. */
1237
1876
  const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
1238
1877
  const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
1878
+ /*
1879
+ A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
1880
+ nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
1881
+ */
1882
+ let roomOnly: string[] = []
1883
+ try {
1884
+ roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session!.source()), this.selected) : []
1885
+ } catch {
1886
+ roomOnly = []
1887
+ }
1888
+ const partFields = own.filter(i => !roomOnly.includes(i.id))
1889
+ /* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
1890
+ let held: HeldValues | undefined
1891
+ try {
1892
+ const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
1893
+ const placed = body ? placementOf(body) : undefined
1894
+ const parsed = this.selected ? parseFigureAsset(this.session!.source()) : undefined
1895
+ if (placed && parsed)
1896
+ held = {
1897
+ position: placed.position,
1898
+ rotation: placed.rotation,
1899
+ size: (['x', 'y', 'z'] as const).map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
1900
+ /* 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). */
1901
+ sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
1902
+ }
1903
+ } catch {
1904
+ held = undefined
1905
+ }
1906
+ const roomFields = own.filter(i => roomOnly.includes(i.id))
1239
1907
  const status = view.loading
1240
1908
  ? 'figure.text.loading-figure'
1241
1909
  : view.saving
@@ -1264,6 +1932,8 @@ export class FigureEditor extends LitElement {
1264
1932
  </button>`
1265
1933
  )}
1266
1934
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1935
+ <span slot="status" release-state>${this.releaseWords(view)}</span>
1936
+ <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
1937
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
1268
1938
  </ox-canvas-bar>
1269
1939
  ${this.catalogOpen
@@ -1283,10 +1953,12 @@ export class FigureEditor extends LitElement {
1283
1953
  slot="start"
1284
1954
  .budget=${this.budget}
1285
1955
  @show-maturity=${() => (this.tab = 'verdict')}
1286
- .parts=${this.partRows(view.geometry)}
1956
+ .parts=${this.partRows(view.geometry, asset as FigureAsset | undefined)}
1287
1957
  .partBudget=${this.partBudgetOf(asset as FigureAsset | undefined)}
1288
1958
  @set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
1289
1959
  .selected=${this.selected}
1960
+ .modelName=${view.name ?? ''}
1961
+ @show-model-settings=${() => this.showModelSettings()}
1290
1962
  ?locked=${unavailable}
1291
1963
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
1292
1964
  .recipeCount=${this.templates.length}
@@ -1305,12 +1977,25 @@ export class FigureEditor extends LitElement {
1305
1977
  .asset=${asset}
1306
1978
  .context=${view.context ?? ''}
1307
1979
  .view=${this.view}
1980
+ @configure-size=${async (e: CustomEvent<{ part: string }>) => {
1981
+ this.middle = 'edit'
1982
+ this.selected = e.detail.part
1983
+ this.tab = 'properties'
1984
+ await this.updateComplete
1985
+ this.renderRoot.querySelector('figure-links')?.scrollIntoView({ block: 'nearest' })
1986
+ }}
1308
1987
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1309
1988
  ></figure-preview>`
1310
- : html`<figure-canvas
1989
+ : html`<div middle>
1990
+ ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
1991
+ <figure-canvas
1992
+ ?startShown=${view.geometry.length === 0 && !unavailable}
1311
1993
  .view=${this.view}
1312
1994
  .parts=${this.drawnParts(view.geometry, asset)}
1995
+ .anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
1313
1996
  .fan=${this.fanOf(asset as FigureAsset | undefined)}
1997
+ .ghost=${this.previewShown().ghost}
1998
+ .highlight=${this.previewShown().highlight}
1314
1999
  .palette=${view.palette ?? {}}
1315
2000
  .context=${view.context ?? ''}
1316
2001
  .selected=${this.selected}
@@ -1320,7 +2005,7 @@ export class FigureEditor extends LitElement {
1320
2005
  @render-error=${(e: CustomEvent<Error>) => {
1321
2006
  this.error = e.detail.message
1322
2007
  }}
1323
- ></figure-canvas>`}
2008
+ ></figure-canvas></div>`}
1324
2009
  <aside slot="end">
1325
2010
  <div tabs>
1326
2011
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1349,7 +2034,8 @@ export class FigureEditor extends LitElement {
1349
2034
  ${this.renderRepeatNote(view.geometry)}
1350
2035
  </section>
1351
2036
  ${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))}
2037
+ ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
2038
+ ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
1353
2039
  ${this.renderLinks(unavailable, 'shape')}
1354
2040
  <figure-material-panel
1355
2041
  .body=${{ ...part, id: this.selected }}
@@ -1358,7 +2044,15 @@ export class FigureEditor extends LitElement {
1358
2044
  @part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
1359
2045
  @part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
1360
2046
  ></figure-material-panel>`
1361
- : html`<slot name="figure"></slot>
2047
+ : view.geometry.length === 0
2048
+ ? html`${this.renderSubject()}<section empty-figure>
2049
+ <p>${i18next.t('figure.text.empty-figure-next')}</p>
2050
+ <details>
2051
+ <summary>${i18next.t('figure.label.figure-info-later')}</summary>
2052
+ <slot name="figure"></slot>
2053
+ </details>
2054
+ </section>`
2055
+ : html`${this.renderSubject()}<slot name="figure"></slot>
1362
2056
  ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
1363
2057
  ${this.renderInstanceRange(asset as FigureAsset | undefined, unavailable)}`}
1364
2058
  </div>
@@ -1392,12 +2086,13 @@ export class FigureEditor extends LitElement {
1392
2086
  vectorTitle: string,
1393
2087
  singleTitle: string,
1394
2088
  unavailable: boolean,
1395
- tools: unknown = null
2089
+ tools: unknown = null,
2090
+ held?: HeldValues
1396
2091
  ) {
1397
2092
  return html`${groups.vectors.length || tools
1398
2093
  ? html`<section>
1399
2094
  <h3>${i18next.t(vectorTitle)}</h3>
1400
- ${groups.vectors.map(g => this.renderVector(g, unavailable))}
2095
+ ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
1401
2096
  ${tools ? html`<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
1402
2097
  </section>`
1403
2098
  : nothing}
@@ -1408,20 +2103,42 @@ export class FigureEditor extends LitElement {
1408
2103
  </section>`
1409
2104
  : nothing}`
1410
2105
  }
1411
- private renderVector(group: FieldGroup, unavailable: boolean) {
2106
+ /**
2107
+ * A box-like part's size row stands when a rule sets every side -- each following the figure's size -- with the
2108
+ * numbers the rule gives, as its position and turn rows do (designer 2026-09-27: not a blank, not gone).
2109
+ */
2110
+ private withSizeRow(groups: ReturnType<typeof groupFields>, held?: HeldValues) {
2111
+ if (groups.vectors.some(v => v.kind === 'vector' && v.name === 'size')) return groups
2112
+ if (!held?.sizeRuled || !held.size || held.size.length !== 3 || held.size.some(v => v === undefined)) return groups
2113
+ const at = groups.vectors.findIndex(v => v.kind === 'vector' && v.name === 'rotation')
2114
+ const vectors = [...groups.vectors]
2115
+ vectors.splice(at < 0 ? vectors.length : at + 1, 0, { kind: 'vector', name: 'size', cells: {} })
2116
+ return { ...groups, vectors }
2117
+ }
2118
+ private renderVector(group: FieldGroup, unavailable: boolean, held?: HeldValues) {
1412
2119
  if (group.kind !== 'vector') return nothing
1413
- const unit = group.cells.x.unit === 'deg' ? '°' : group.cells.x.unit
2120
+ const unit = group.name === 'rotation' ? '°' : 'mm'
2121
+ const ruled = (['x', 'y', 'z'] as const).filter(axis => !group.cells[axis])
2122
+ const given = (axis: 'x' | 'y' | 'z') => {
2123
+ const v = held?.[group.name]?.[['x', 'y', 'z'].indexOf(axis)]
2124
+ return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10)
2125
+ }
1414
2126
  return html`<div vector-row>
1415
2127
  <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html` <span unit>${unit}</span>`}</label>
1416
2128
  <div triple>
1417
2129
  ${(['x', 'y', 'z'] as const).map(
1418
- axis => html`<div cell>
2130
+ axis => !group.cells[axis]
2131
+ ? html`<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
2132
+ <span axis>${axis.toUpperCase()}</span>
2133
+ <input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
2134
+ </div>`
2135
+ : html`<div cell>
1419
2136
  <span axis ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}>${axis.toUpperCase()}</span>
1420
2137
  <input
1421
- aria-label=${group.cells[axis].id}
2138
+ aria-label=${group.cells[axis]!.id}
1422
2139
  type="number"
1423
2140
  step="any"
1424
- .value=${String(group.cells[axis].value)}
2141
+ .value=${String(group.cells[axis]!.value)}
1425
2142
  ?disabled=${unavailable}
1426
2143
  @input=${(e: Event) => this.typing(group.cells[axis] as never, e)}
1427
2144
  @change=${(e: Event) => this.changeField(group.cells[axis] as never, e)}
@@ -1429,7 +2146,62 @@ export class FigureEditor extends LitElement {
1429
2146
  </div>`
1430
2147
  )}
1431
2148
  </div>
1432
- </div>`
2149
+ </div>
2150
+ ${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}`
2151
+ }
2152
+ /**
2153
+ * A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
2154
+ * typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
2155
+ */
2156
+ private renderRoomOnly(fields: FieldLike[], unavailable: boolean) {
2157
+ return html`<section room-only>
2158
+ <h3>${i18next.t('figure.label.room-only-inputs')}</h3>
2159
+ <p hint>${i18next.t('figure.text.room-only-inputs')}</p>
2160
+ <div form>
2161
+ ${fields.map(
2162
+ 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 />`
2163
+ )}
2164
+ </div>
2165
+ <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
2166
+ </section>`
2167
+ }
2168
+ /** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
2169
+ private async askRoomRefit() {
2170
+ let refit
2171
+ try {
2172
+ refit = figureRoomRefitOf(parseFigureAsset(this.session!.source()))
2173
+ } catch (error) {
2174
+ this.error = this.said(error)
2175
+ return
2176
+ }
2177
+ if (!refit) return
2178
+ const rows = roomRefitRowsOf(refit)
2179
+ const outside = (list: typeof refit.outside.declared) =>
2180
+ list.length
2181
+ ? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
2182
+ : i18next.t('figure.text.room-nothing-outside')
2183
+ const agreed = await OxPrompt.open({
2184
+ type: 'question',
2185
+ title: i18next.t('figure.label.room-refit'),
2186
+ template: html`<table room-refit>
2187
+ <tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
2188
+ ${rows.map(
2189
+ r => html`<tr>
2190
+ <td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
2191
+ <td>${r.declared}</td>
2192
+ <td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
2193
+ </tr>`
2194
+ )}
2195
+ </table>
2196
+ <p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
2197
+ <p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
2198
+ ${refit.drops.length
2199
+ ? 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>`
2200
+ : nothing}`,
2201
+ confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
2202
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
2203
+ })
2204
+ if (agreed) this.linkCommand(refit.step)
1433
2205
  }
1434
2206
  private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
1435
2207
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))