@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
@@ -7,13 +7,15 @@ import { notify } from '@operato/layout';
7
7
  import { OxPrompt } from '@operato/popup';
8
8
  import { customElement, property, state } from 'lit/decorators.js';
9
9
  import { live } from 'lit/directives/live.js';
10
- import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor } from '@hatiolab/figure-model';
10
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, planFigureResize, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, figurePartFaceOf, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
11
11
  import './figure-recipe-catalog.js';
12
12
  import { captureFigureThumbnail } from './thumbnail.js';
13
13
  import { drawableFigureParts } from './renderer.js';
14
14
  import './canvas.js';
15
15
  import './figure-parts.js';
16
16
  import './figure-material-panel.js';
17
+ import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf } from './command-preview.js';
18
+ import { motionEndsOf } from './motion-ends.js';
17
19
  import './figure-links.js';
18
20
  import './figure-controls.js';
19
21
  import './figure-clips.js';
@@ -21,16 +23,17 @@ import './figure-preview.js';
21
23
  import './figure-settings.js';
22
24
  import { DEFAULT_VIEW } from './figure-view.js';
23
25
  import './figure-capability-panel.js';
24
- import { gizmoChanges, gizmoReachOf, keepBottomChanges } from './gizmo.js';
26
+ import { gizmoChanges, gizmoReachOf } from './gizmo.js';
25
27
  import { keyActionOf } from './keys.js';
26
28
  import { groupFields, ownerOf, partLabelOf, repeatOf } from './fields.js';
27
29
  import { SidePanelStyles } from './side-panel-styles.js';
28
30
  import { ScrollbarStyles } from '@operato/styles';
29
31
  import { alignChanges, boxOf, canMirror, mirrorChanges } from './align.js';
30
32
  import { paletteRecipe } from './palette.js';
31
- import { nudgeOf } from './stage-model.js';
32
- import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js';
33
- import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
33
+ import { nudgeOf, placementOf } from './stage-model.js';
34
+ import { roomRefitRowsOf } from './occupancy.js';
35
+ import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, resizeRefusalOf, sizeRangeRefusalOf } from './refusals.js';
36
+ import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
34
37
  import { jointFanOf } from './joint-fan.js';
35
38
  import { jointDragOf } from './joint-drag.js';
36
39
  import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './live.js';
@@ -60,6 +63,8 @@ let FigureEditor = class FigureEditor extends LitElement {
60
63
  * Parts hidden on the workbench — the author's view while working, not the asset's visibility. Not saved: a part
61
64
  * hidden here is still in the figure, still checked, still drawn on a board.
62
65
  */
66
+ /** The page offers an AI draft for a new figure (the modeller page, through the app's assistant dock). */
67
+ this.aiDraft = false;
63
68
  this.hiddenParts = new Set();
64
69
  /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
65
70
  this.live = NOTHING_LIVE;
@@ -94,6 +99,9 @@ let FigureEditor = class FigureEditor extends LitElement {
94
99
  else
95
100
  this.selected = '';
96
101
  };
102
+ this.foldedForWidth = false;
103
+ this.keptOpenByAuthor = false;
104
+ this.widthWatch = new ResizeObserver(([entry]) => this.fitPanesTo(entry.contentRect.width));
97
105
  /**
98
106
  * Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
99
107
  * notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
@@ -101,6 +109,19 @@ let FigureEditor = class FigureEditor extends LitElement {
101
109
  * other refusal is the app's notification, which stays until it is closed.
102
110
  */
103
111
  this.toldViewError = '';
112
+ /*
113
+ Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
114
+ 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
115
+ hide which part the panel speaks of. Another model starts clean.
116
+ */
117
+ this.panelMemory = new Map();
118
+ this.lastAnchors = {};
119
+ /** To the release that exists: the history tab's release section, not a second one here. */
120
+ this.goRelease = async () => {
121
+ this.tab = 'history';
122
+ await this.updateComplete;
123
+ await this.querySelector('figure-history-panel')?.showRelease?.();
124
+ };
104
125
  }
105
126
  static { FigureEditor_1 = this; }
106
127
  /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
@@ -158,6 +179,17 @@ let FigureEditor = class FigureEditor extends LitElement {
158
179
  opacity: 0.38;
159
180
  cursor: default;
160
181
  }
182
+ /*
183
+ Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
184
+ showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
185
+ */
186
+ ox-canvas-bar button[tool] md-icon {
187
+ --md-icon-size: 20px;
188
+ }
189
+ ox-canvas-bar button[tool]:focus-visible {
190
+ outline: 2px solid var(--md-sys-color-primary);
191
+ outline-offset: 1px;
192
+ }
161
193
  figure-preview {
162
194
  min-width: 0;
163
195
  min-height: 0;
@@ -188,9 +220,76 @@ let FigureEditor = class FigureEditor extends LitElement {
188
220
  min-height: 0;
189
221
  }
190
222
  figure-canvas {
223
+ flex: 1;
191
224
  min-width: 0;
192
225
  min-height: 0;
193
226
  }
227
+ div[middle] {
228
+ position: relative;
229
+ display: flex;
230
+ min-width: 0;
231
+ min-height: 0;
232
+ }
233
+ /* A new figure's first question, over the empty canvas. */
234
+ div[start] {
235
+ position: absolute;
236
+ inset: 50% auto auto 50%;
237
+ z-index: 10;
238
+ color: var(--md-sys-color-on-surface);
239
+ transform: translate(-50%, -50%);
240
+ width: min(440px, calc(100% - 32px));
241
+ box-sizing: border-box;
242
+ padding: 20px;
243
+ border-radius: 12px;
244
+ background: var(--md-sys-color-surface, #fff);
245
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
246
+ }
247
+ div[start] button[start-button] {
248
+ width: auto;
249
+ margin: 0;
250
+ font-size: var(--ui-t-body);
251
+ height: 36px;
252
+ padding: 0 16px;
253
+ border: 1px solid var(--md-sys-color-outline);
254
+ border-radius: 999em;
255
+ color: var(--md-sys-color-primary);
256
+ background: var(--md-sys-color-surface, #fff);
257
+ cursor: pointer;
258
+ }
259
+ div[start] button[start-button]:hover:not(:disabled) {
260
+ background: var(--md-sys-color-primary-container);
261
+ }
262
+ div[start] button[start-button][pressed] {
263
+ color: var(--md-sys-color-on-primary);
264
+ background: var(--md-sys-color-primary);
265
+ border-color: var(--md-sys-color-primary);
266
+ }
267
+ div[start] button[start-button]:disabled {
268
+ opacity: 0.5;
269
+ cursor: default;
270
+ }
271
+ div[start] h2 {
272
+ margin: 0 0 12px;
273
+ font-size: var(--ui-t-title);
274
+ }
275
+ div[start-paths] {
276
+ display: grid;
277
+ gap: 14px;
278
+ }
279
+ div[start-paths] h3 {
280
+ margin: 0 0 6px;
281
+ font-size: var(--ui-t-body);
282
+ }
283
+ div[start-shapes] {
284
+ display: flex;
285
+ gap: 8px;
286
+ flex-wrap: wrap;
287
+ }
288
+ div[start] small {
289
+ display: block;
290
+ margin-top: 4px;
291
+ color: var(--md-sys-color-on-surface-variant);
292
+ }
194
293
  /* The side: tabs over one panel at a time, each using the whole height. */
195
294
  aside {
196
295
  display: flex;
@@ -207,9 +306,12 @@ let FigureEditor = class FigureEditor extends LitElement {
207
306
  flex: 1;
208
307
  position: relative;
209
308
  display: flex;
309
+ flex-direction: column;
210
310
  align-items: center;
211
311
  justify-content: center;
212
- height: 38px;
312
+ gap: 1px;
313
+ min-width: 0;
314
+ height: 48px;
213
315
  padding: 0;
214
316
  border: none;
215
317
  border-radius: 0;
@@ -229,6 +331,14 @@ let FigureEditor = class FigureEditor extends LitElement {
229
331
  width: 20px;
230
332
  height: 20px;
231
333
  }
334
+ button[tab] span[tab-label] {
335
+ max-width: 100%;
336
+ overflow: hidden;
337
+ font-size: 0.66rem;
338
+ line-height: 1.2;
339
+ white-space: nowrap;
340
+ text-overflow: ellipsis;
341
+ }
232
342
  span[badge] {
233
343
  position: absolute;
234
344
  top: 3px;
@@ -258,24 +368,60 @@ let FigureEditor = class FigureEditor extends LitElement {
258
368
  }
259
369
  /* Which part the panel speaks of, and the way back to the whole figure. */
260
370
  div[subject] {
371
+ position: sticky;
372
+ top: 0;
373
+ z-index: 1;
374
+ display: grid;
375
+ gap: 4px;
376
+ padding: 8px 10px;
377
+ background: var(--md-sys-color-surface-container-lowest);
378
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
379
+ }
380
+ nav[path] {
261
381
  display: flex;
262
382
  align-items: center;
263
383
  gap: 6px;
264
- padding: 6px 10px 0;
384
+ min-width: 0;
265
385
  }
266
- div[subject] button[chip] {
267
- display: inline-flex;
268
- align-items: center;
269
- gap: 2px;
270
- height: 26px;
271
- padding: 0 10px 0 6px;
386
+ nav[path] button[model-crumb] {
387
+ flex: none;
388
+ max-width: 55%;
389
+ height: 28px;
390
+ padding: 0 8px;
391
+ overflow: hidden;
392
+ white-space: nowrap;
393
+ text-overflow: ellipsis;
394
+ border: 1px solid var(--md-sys-color-outline-variant);
272
395
  border-radius: 999em;
396
+ background: none;
397
+ color: var(--md-sys-color-primary);
398
+ cursor: pointer;
399
+ }
400
+ nav[path] button[model-crumb][on] {
401
+ border-color: var(--md-sys-color-primary);
402
+ background: var(--md-sys-color-primary-container);
403
+ }
404
+ nav[path] button[model-crumb]:focus-visible {
405
+ outline: 2px solid var(--md-sys-color-primary);
406
+ outline-offset: 1px;
407
+ }
408
+ nav[path] span[current] {
409
+ min-width: 0;
410
+ overflow: hidden;
411
+ white-space: nowrap;
412
+ text-overflow: ellipsis;
413
+ font-weight: 600;
414
+ }
415
+ div[subject-summary] {
416
+ display: grid;
417
+ gap: 1px;
418
+ }
419
+ div[subject-summary] b span {
420
+ font-weight: 400;
273
421
  color: var(--md-sys-color-on-surface-variant);
274
422
  }
275
- div[subject] md-icon {
276
- font-size: 16px;
277
- width: 16px;
278
- height: 16px;
423
+ div[subject-summary] small {
424
+ color: var(--md-sys-color-on-surface-variant);
279
425
  }
280
426
  button {
281
427
  font: inherit;
@@ -341,7 +487,7 @@ let FigureEditor = class FigureEditor extends LitElement {
341
487
  aria-pressed=${String(this.gizmoMode === tool.mode)}
342
488
  ?disabled=${!tool.can}
343
489
  aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
344
- title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
490
+ title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t(tool.mode === 'scale' && this.sizeDragUnsure(this.asset(), this.selected) ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-not-own', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })}`}
345
491
  @click=${() => this.useGizmo(tool.mode)}
346
492
  >
347
493
  <md-icon>${tool.icon}</md-icon>
@@ -351,11 +497,39 @@ let FigureEditor = class FigureEditor extends LitElement {
351
497
  connectedCallback() {
352
498
  super.connectedCallback();
353
499
  window.addEventListener('keydown', this.onKey, true);
500
+ this.widthWatch.observe(this);
354
501
  }
355
502
  disconnectedCallback() {
356
503
  window.removeEventListener('keydown', this.onKey, true);
504
+ this.widthWatch.disconnect();
357
505
  super.disconnectedCallback();
358
506
  }
507
+ /*
508
+ Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
509
+ FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
510
+ bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
511
+ */
512
+ static { this.FOLD_START_UNDER = 1100; }
513
+ fitPanesTo(width) {
514
+ const panes = this.panes;
515
+ if (!panes || !width)
516
+ return;
517
+ if (this.foldedForWidth && !panes.foldStart)
518
+ this.keptOpenByAuthor = true;
519
+ if (width < FigureEditor_1.FOLD_START_UNDER) {
520
+ if (!panes.foldStart && !this.keptOpenByAuthor) {
521
+ panes.foldStart = true;
522
+ this.foldedForWidth = true;
523
+ notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') });
524
+ }
525
+ }
526
+ else {
527
+ if (this.foldedForWidth && panes.foldStart)
528
+ panes.foldStart = false;
529
+ this.foldedForWidth = false;
530
+ this.keptOpenByAuthor = false;
531
+ }
532
+ }
359
533
  tell(changed) {
360
534
  if (changed.has('error') && this.error) {
361
535
  const retry = this.retry;
@@ -371,6 +545,38 @@ let FigureEditor = class FigureEditor extends LitElement {
371
545
  notify({ level: 'error', message: viewError });
372
546
  this.toldViewError = viewError;
373
547
  }
548
+ /*
549
+ An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
550
+ which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
551
+ step undo brings back (designer 2026-09-27).
552
+ */
553
+ askClipLoss(error, again) {
554
+ if (error?.code !== 'CLIPS_DROPPED')
555
+ return false;
556
+ const clips = error.clips;
557
+ /* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
558
+ const session = this.session;
559
+ const { context, revision } = session.read();
560
+ void this.confirmClipLoss(clips, again, { session, context: context, revision: revision });
561
+ return true;
562
+ }
563
+ async confirmClipLoss(clips, again, asked) {
564
+ const agreed = await OxPrompt.open({
565
+ type: 'warning',
566
+ text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
567
+ confirmButton: { text: i18next.t('figure.button.let-clips-go') },
568
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
569
+ });
570
+ if (!agreed)
571
+ return;
572
+ /* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
573
+ const now = this.session === asked.session ? asked.session.read() : undefined;
574
+ if (!now || now.context !== asked.context || now.revision !== asked.revision) {
575
+ this.error = i18next.t('figure.text.clips-consent-stale');
576
+ return;
577
+ }
578
+ asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again);
579
+ }
374
580
  async ask(message, retry) {
375
581
  const agreed = await OxPrompt.open({
376
582
  type: 'question',
@@ -385,6 +591,15 @@ let FigureEditor = class FigureEditor extends LitElement {
385
591
  else
386
592
  this.linkCommand(retry.action);
387
593
  }
594
+ propertiesPanel() {
595
+ return this.renderRoot.querySelector('div[panel]');
596
+ }
597
+ foldsOf(panel) {
598
+ const folds = [...panel.querySelectorAll('details')];
599
+ for (const inner of panel.querySelectorAll('figure-links'))
600
+ folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []));
601
+ return folds;
602
+ }
388
603
  willUpdate(_changed) {
389
604
  const view = this.session?.read();
390
605
  if (view?.context !== this.lastContext) {
@@ -392,6 +607,14 @@ let FigureEditor = class FigureEditor extends LitElement {
392
607
  this.selected = '';
393
608
  this.error = '';
394
609
  this.hiddenParts = new Set();
610
+ this.panelMemory.clear();
611
+ }
612
+ else if (_changed.has('selected')) {
613
+ const panel = this.propertiesPanel();
614
+ const was = _changed.get('selected') ?? '';
615
+ if (panel)
616
+ this.panelMemory.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) });
617
+ this.restorePanelFor = this.selected || '@model';
395
618
  }
396
619
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected))
397
620
  this.selected = '';
@@ -400,6 +623,13 @@ let FigureEditor = class FigureEditor extends LitElement {
400
623
  }
401
624
  /** The scene toggles: the mode already shown takes the gizmo away. */
402
625
  useGizmo(mode) {
626
+ if (mode === 'scale' && this.selected && this.gizmoMode !== 'scale') {
627
+ const asset = this.asset();
628
+ if (asset && this.sizeDragUnsure(asset, this.selected)) {
629
+ this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() });
630
+ return;
631
+ }
632
+ }
403
633
  this.renderRoot.querySelector('figure-canvas')?.gizmo(mode);
404
634
  this.gizmoMode = this.gizmoMode === mode ? undefined : mode;
405
635
  }
@@ -419,6 +649,8 @@ let FigureEditor = class FigureEditor extends LitElement {
419
649
  this.changed();
420
650
  }
421
651
  catch (error) {
652
+ if (this.askClipLoss(error, () => this.run(action)))
653
+ return;
422
654
  const say = (key, params) => i18next.t(key, params);
423
655
  this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? this.said(error);
424
656
  }
@@ -492,7 +724,8 @@ let FigureEditor = class FigureEditor extends LitElement {
492
724
  this.error = '';
493
725
  }
494
726
  catch (error) {
495
- this.error = this.said(error);
727
+ /* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
728
+ this.error = error?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error);
496
729
  }
497
730
  finally {
498
731
  this.changed();
@@ -537,6 +770,8 @@ let FigureEditor = class FigureEditor extends LitElement {
537
770
  this.changed();
538
771
  }
539
772
  catch (error) {
773
+ if (this.askClipLoss(error, () => this.removePart(id, refitRoom)))
774
+ return;
540
775
  /*
541
776
  A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
542
777
  this product declares is fitted to the parts (「부품에 맞추기」, the AI's fit-room -- no padding), so fitting it
@@ -600,12 +835,30 @@ let FigureEditor = class FigureEditor extends LitElement {
600
835
  if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id))
601
836
  return none;
602
837
  try {
603
- return gizmoReachOf(JSON.parse(this.session.source()), id);
838
+ const asset = JSON.parse(this.session.source());
839
+ const reach = gizmoReachOf(asset, id);
840
+ /*
841
+ A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
842
+ say which face that is -- a part fastened turned or twisted -- the drag would show one result and write another,
843
+ so sizing by the gizmo is not offered there; the fields still size it (designer 2026-09-27).
844
+ */
845
+ if (reach.scale && this.sizeDragUnsure(asset, id))
846
+ return { ...reach, scale: false };
847
+ return reach;
604
848
  }
605
849
  catch {
606
850
  return none;
607
851
  }
608
852
  }
853
+ /** Whether a size drag on this part could not show what is written: figure-model gives no face it keeps. */
854
+ sizeDragUnsure(asset, id) {
855
+ try {
856
+ return !figureScaleAnchorOf(asset, ownerOf(id));
857
+ }
858
+ catch {
859
+ return true;
860
+ }
861
+ }
609
862
  /** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
610
863
  transformed(id, result) {
611
864
  const view = this.session.read();
@@ -631,7 +884,19 @@ let FigureEditor = class FigureEditor extends LitElement {
631
884
  the shift had been taken for a move of the joint, and the size was dropped).
632
885
  */
633
886
  const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id));
634
- const changes = gizmoChanges(id, this.reachOf(id), jointed && result.scale ? { scale: result.scale } : result, sizes);
887
+ /* A size drag this part cannot show truly is not written: it springs back, and the reason is said. */
888
+ if (result.scale && result.scale.some(k => Math.abs(k - 1) > 1e-4) && this.sizeDragUnsure(asset, id)) {
889
+ this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(id), extreme: this.keptExtremeWord() });
890
+ return this.requestUpdate();
891
+ }
892
+ /*
893
+ A size dragged keeps the face the figure's placement keeps, as a size typed does (figure-model's one rule): the
894
+ gizmo's own anchoring on the opposite face is what the drag shows, not what is written (designer 2026-09-27).
895
+ */
896
+ const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4);
897
+ const taken = jointed || scaled ? { scale: result.scale } : result;
898
+ const written = gizmoChanges(id, this.reachOf(id), taken, sizes);
899
+ const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written;
635
900
  if (changes.length) {
636
901
  this.run(() => this.session.apply(changes));
637
902
  return;
@@ -700,6 +965,53 @@ let FigureEditor = class FigureEditor extends LitElement {
700
965
  this.selected = copy;
701
966
  });
702
967
  }
968
+ /**
969
+ * The one-off "put it on the plane", by the figure's placement (designer 2026-09-27): figure-model's rule
970
+ * (`figurePlaneAlignChanges`) -- a floor figure's part to the floor by its bottom, a ceiling one's to the ceiling by
971
+ * 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
972
+ * fastened. Refused, it says why.
973
+ */
974
+ /* What a tilted part's typed size keeps, by the figure's placement: not the floor's lowest point everywhere (designer 2026-09-27). */
975
+ keptExtremeWord() {
976
+ const placement = this.asset()?.placement ?? 'floor';
977
+ return i18next.t(`figure.label.kept-extreme-${placement}`);
978
+ }
979
+ planeContactOf(source, part) {
980
+ if (this.planeContact?.source !== source || this.planeContact.part !== part) {
981
+ let contact;
982
+ try {
983
+ contact = figurePlaneAlignChanges(parseFigureAsset(source), part).contact;
984
+ }
985
+ catch {
986
+ contact = undefined;
987
+ }
988
+ this.planeContact = { source, part, contact };
989
+ }
990
+ return this.planeContact.contact;
991
+ }
992
+ renderPlaneAlign(asset, unavailable) {
993
+ const placement = asset.placement ?? 'floor';
994
+ const icon = { floor: 'vertical_align_bottom', ceiling: 'vertical_align_top', wall: 'flip_to_back', center: 'vertical_align_center' }[placement];
995
+ /* 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). */
996
+ const contact = this.planeContactOf(this.session.source(), this.selected);
997
+ const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : '';
998
+ return html `<button plane-align=${placement} ?disabled=${unavailable} aria-label=${i18next.t(`figure.button.align-to-${placement}`)}
999
+ title=${i18next.t(`figure.text.align-to-${placement}${told}`)} @click=${() => this.alignToPlane()}>
1000
+ <md-icon>${icon}</md-icon>
1001
+ </button>`;
1002
+ }
1003
+ alignToPlane() {
1004
+ const asset = parseFigureAsset(this.session.source());
1005
+ const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected);
1006
+ if (refusal) {
1007
+ this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) });
1008
+ return;
1009
+ }
1010
+ if (changes.length)
1011
+ this.run(() => this.session.apply(changes));
1012
+ if ((contact === 'tilted' || contact === 'extreme') && !this.error)
1013
+ this.notice = i18next.t(`figure.text.aligned-${contact}-${asset.placement ?? 'floor'}`);
1014
+ }
703
1015
  renderTools(unavailable) {
704
1016
  const reach = this.reachOf(this.selected);
705
1017
  if (!this.selected || !reach.translate)
@@ -717,7 +1029,7 @@ let FigureEditor = class FigureEditor extends LitElement {
717
1029
  return html `<div tools title=${i18next.t('figure.text.tools-explained')}>
718
1030
  ${tool('centre-x', 'horizontal_distribute', 'figure.button.center-x', 'figure.text.center-on-x')}
719
1031
  ${tool('centre-z', 'vertical_align_center', 'figure.button.center-z', 'figure.text.center-on-z')}
720
- ${tool('floor', 'vertical_align_bottom', 'figure.button.to-floor', 'figure.text.put-on-floor')}
1032
+ ${this.renderPlaneAlign(asset, unavailable)}
721
1033
  ${['x', 'z'].map(axis => {
722
1034
  const able = canMirror(asset, this.selected, axis, reach);
723
1035
  return html `<button
@@ -791,9 +1103,37 @@ let FigureEditor = class FigureEditor extends LitElement {
791
1103
  .dims=${dims}
792
1104
  ?locked=${unavailable}
793
1105
  @command=${(e) => this.linkCommand(e.detail.action)}
1106
+ .previewWords=${previewWordsOf(this.currentPreview(), this.selected, (key, params) => i18next.t(key, params))}
1107
+ .detachWords=${this.detachWords(asset)}
1108
+ .overhangWords=${section === 'shape' ? this.overhangWords(asset) : []}
1109
+ .overhangNote=${section === 'shape' ? this.overhangNote(asset) : []}
1110
+ .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1111
+ @go-fasten=${() => this.goFasten()}
1112
+ @preview-command=${(e) => this.previewCommand(e.detail)}
1113
+ @resize-with-placement=${(e) => this.resizeWithPlacement(e.detail.request)}
794
1114
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
795
1115
  ></figure-links>`;
796
1116
  }
1117
+ /**
1118
+ * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
1119
+ * while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
1120
+ * rule can say, and the gizmo keeps its own (the face across from the handle).
1121
+ */
1122
+ anchorsOf(asset) {
1123
+ let next = {};
1124
+ try {
1125
+ const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined;
1126
+ if (anchor)
1127
+ next = { [this.selected]: anchor };
1128
+ }
1129
+ catch {
1130
+ next = {};
1131
+ }
1132
+ /* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
1133
+ if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors))
1134
+ this.lastAnchors = next;
1135
+ return this.lastAnchors;
1136
+ }
797
1137
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
798
1138
  renderControls(view, unavailable) {
799
1139
  let asset;
@@ -821,6 +1161,31 @@ let FigureEditor = class FigureEditor extends LitElement {
821
1161
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
822
1162
  ></figure-clips>`;
823
1163
  }
1164
+ /**
1165
+ * "Follow the size and keep where the figure is placed" (ADR-0097): figure-model plans it from the figure as it is now,
1166
+ * and the whole plan is one step of the history. A refusal with a way on -- move the part onto the plane, replace how
1167
+ * its position is worked out -- asks in the app's prompt and plans again with that choice; any other says why.
1168
+ */
1169
+ resizeWithPlacement(request) {
1170
+ this.retry = undefined;
1171
+ this.notice = '';
1172
+ const plan = planFigureResize(parseFigureAsset(this.session.source()), request);
1173
+ const refusal = plan.refusal;
1174
+ if (refusal) {
1175
+ const choice = refusal.choice;
1176
+ /* The gap is chosen on the screen beside the button, with its distance; only replacing a position rule is asked here. */
1177
+ this.retry = choice === 'replacePosition' ? { label: i18next.t('figure.button.resize-choice-replace-position'), run: () => this.resizeWithPlacement({ ...request, replacePosition: true }) } : undefined;
1178
+ this.error = resizeRefusalOf(plan, request.part, (key, params) => i18next.t(key, params));
1179
+ return;
1180
+ }
1181
+ this.run(() => this.session.applyRecipe(plan.steps));
1182
+ /*
1183
+ The declared room was measured from the part's old numbers and is not changed: said as a fact, not as a claim that
1184
+ the part leaves it -- that is measured where the room is declared again (designer 2026-09-27).
1185
+ */
1186
+ if (!this.error && plan.roomReads.length)
1187
+ this.notice = i18next.t('figure.text.resize-room-unchanged');
1188
+ }
824
1189
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
825
1190
  linkCommand(action) {
826
1191
  this.retry = undefined;
@@ -841,6 +1206,8 @@ let FigureEditor = class FigureEditor extends LitElement {
841
1206
  this.changed();
842
1207
  }
843
1208
  catch (error) {
1209
+ if (this.askClipLoss(error, () => this.linkCommand(action)))
1210
+ return;
844
1211
  const code = error?.code;
845
1212
  const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params));
846
1213
  if (narrowing) {
@@ -860,13 +1227,10 @@ let FigureEditor = class FigureEditor extends LitElement {
860
1227
  }
861
1228
  changeField(field, event) {
862
1229
  const input = event.target, value = input.valueAsNumber;
863
- /* A height typed in keeps the part's bottom where it is, as the scale gizmo does. */
1230
+ /* 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. */
864
1231
  const part = this.selected;
865
- const kept = field.role !== 'state' && part ? keepBottomChanges(part, this.reachOf(part), field.id, value, this.designValues()) : [];
866
- this.run(() => this.session.apply([
867
- { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value },
868
- ...kept
869
- ]));
1232
+ const change = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value };
1233
+ this.run(() => this.session.apply(field.role !== 'state' && part ? figureSizeEditChanges(parseFigureAsset(this.session.source()), part, [change]) : [change]));
870
1234
  // Rejected edits must also restore the displayed numeric value.
871
1235
  input.value = String(this.session.read().fields.find(i => i.id === field.id).value);
872
1236
  }
@@ -1014,7 +1378,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1014
1378
  * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now
1015
1379
  * (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
1016
1380
  */
1017
- partRows(geometry) {
1381
+ partRows(geometry, asset) {
1382
+ /* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
1383
+ const fastened = new Map((asset?.joints ?? []).map(j => [
1384
+ j.body1.part,
1385
+ 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
1386
+ ]));
1018
1387
  const rows = new Map();
1019
1388
  for (const g of geometry) {
1020
1389
  const id = ownerOf(g.id);
@@ -1028,7 +1397,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1028
1397
  provider: g.shape?.provider ?? '',
1029
1398
  hidden: this.hiddenParts.has(id),
1030
1399
  invisible: g.visible === false,
1031
- ...(id !== g.id ? { copies: 1 } : {})
1400
+ ...(id !== g.id ? { copies: 1 } : {}),
1401
+ ...(fastened.has(id) ? { fastenedTo: fastened.get(id) } : {})
1032
1402
  });
1033
1403
  }
1034
1404
  return [...rows.values()];
@@ -1084,6 +1454,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1084
1454
  @change=${(e) => this.setPitch(field, e)}
1085
1455
  />
1086
1456
  </div>
1457
+ <ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html `<li>${line}</li>`)}</ul>
1087
1458
  <p hint>
1088
1459
  ${fits && length !== undefined && longest !== undefined && limit !== undefined
1089
1460
  ? i18next.t('figure.text.repeat-what-pitch-gives', {
@@ -1102,6 +1473,15 @@ let FigureEditor = class FigureEditor extends LitElement {
1102
1473
  : nothing}
1103
1474
  </section>`;
1104
1475
  }
1476
+ /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
1477
+ rowNow(asset) {
1478
+ try {
1479
+ return figureRowOf(asset, this.selected, this.currentInputs());
1480
+ }
1481
+ catch {
1482
+ return undefined;
1483
+ }
1484
+ }
1105
1485
  /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1106
1486
  pitchInputOf(asset, part) {
1107
1487
  const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch;
@@ -1211,6 +1591,183 @@ let FigureEditor = class FigureEditor extends LitElement {
1211
1591
  }
1212
1592
  }
1213
1593
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
1594
+ /*
1595
+ Saving and releasing told apart, beside the save (ADR-0097 §5.6, Q-D2): what the saved figure is -- a draft never
1596
+ released, a draft while boards use an earlier release, a release -- as the server said it. Edits not saved are the
1597
+ status before it. Boards read a draft's latest release (figureBlueprint), so that is what is said.
1598
+ */
1599
+ releaseWords(view) {
1600
+ if (!view.id || !this.releaseState?.state)
1601
+ return '';
1602
+ const { state, version } = this.releaseState;
1603
+ if (state === 'released')
1604
+ return i18next.t('figure.text.release-state-released', { version: String(version) });
1605
+ return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new');
1606
+ }
1607
+ /** The fastening form's command, seen before it is done; null clears what was shown. */
1608
+ previewCommand(candidate) {
1609
+ if (!candidate || !this.session || !this.selected) {
1610
+ this.preview = undefined;
1611
+ return;
1612
+ }
1613
+ const view = this.session.read();
1614
+ const held = { context: view.context, revision: view.revision, key: JSON.stringify(candidate), part: this.selected };
1615
+ try {
1616
+ const seen = this.session.previewCommand(candidate.action);
1617
+ let face;
1618
+ if (candidate.face) {
1619
+ try {
1620
+ face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs());
1621
+ }
1622
+ catch {
1623
+ face = undefined;
1624
+ }
1625
+ }
1626
+ /* A row is read on the figure the command would make, by figure-model, as drawn. */
1627
+ const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined;
1628
+ this.preview = {
1629
+ ...held,
1630
+ context: seen.context,
1631
+ revision: seen.revision,
1632
+ geometry: seen.geometry,
1633
+ moves: seen.moves,
1634
+ added: seen.added,
1635
+ ...(row ? { row } : {}),
1636
+ clipsWouldGo: seen.clipsWouldGo,
1637
+ face,
1638
+ faceUnknown: !!candidate.face && !face
1639
+ };
1640
+ }
1641
+ catch (error) {
1642
+ /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1643
+ this.preview = { ...held, refused: this.said(error) };
1644
+ }
1645
+ }
1646
+ /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1647
+ currentPreview() {
1648
+ const p = this.preview;
1649
+ const view = this.session?.read();
1650
+ /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1651
+ if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties')
1652
+ return undefined;
1653
+ return p;
1654
+ }
1655
+ detachWords(asset) {
1656
+ const view = this.session?.read();
1657
+ const joint = asset?.joints?.find(j => j.body1.part === this.selected);
1658
+ if (!asset || !view || !joint || !this.session)
1659
+ return [];
1660
+ const key = `${view.context}|${view.revision}|${this.selected}`;
1661
+ if (this.detachSaid?.key === key)
1662
+ return this.detachSaid.words;
1663
+ const say = (k, params) => i18next.t(k, params);
1664
+ const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part);
1665
+ let words;
1666
+ try {
1667
+ const seen = this.session.previewCommand({ kind: 'detach', part: this.selected });
1668
+ const own = seen.moves.some(m => m.body === this.selected);
1669
+ words = own
1670
+ ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
1671
+ : [say('figure.text.detach-stays', { from })];
1672
+ const children = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => partLabelOf(j.body1.part));
1673
+ if (children.length)
1674
+ words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }));
1675
+ if (seen.clipsWouldGo.length)
1676
+ words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }));
1677
+ }
1678
+ catch (error) {
1679
+ words = [say('figure.text.detach-refused', { reason: this.said(error) })];
1680
+ }
1681
+ this.detachSaid = { key, words };
1682
+ return words;
1683
+ }
1684
+ /** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
1685
+ overhangNote(asset) {
1686
+ this.overhangWords(asset);
1687
+ return this.overhangSaid?.note ?? [];
1688
+ }
1689
+ overhangWords(asset) {
1690
+ const view = this.session?.read();
1691
+ if (!asset || !view || !this.selected)
1692
+ return [];
1693
+ const key = `${view.context}|${view.revision}|${this.selected}`;
1694
+ if (this.overhangSaid?.key === key)
1695
+ return this.overhangSaid.words;
1696
+ const say = (k, params) => i18next.t(k, params);
1697
+ const where = (o) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) });
1698
+ const sides = (o) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ');
1699
+ const words = [];
1700
+ const note = [];
1701
+ try {
1702
+ const inputs = this.currentInputs();
1703
+ const own = figureFaceOverhangOf(asset, this.selected, inputs);
1704
+ if (own?.state === 'larger') {
1705
+ note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }));
1706
+ if (own.lopsided.length)
1707
+ note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }));
1708
+ }
1709
+ if (own?.state === 'out')
1710
+ words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }));
1711
+ else if (own?.state === 'possible')
1712
+ words.push(say('figure.text.overhang-possible', where(own)));
1713
+ else if (own?.state === 'unknown')
1714
+ words.push(say('figure.text.overhang-unknown', where(own)));
1715
+ const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs));
1716
+ const past = held.filter((o) => o?.state === 'out' || o?.state === 'possible');
1717
+ if (past.length)
1718
+ words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }));
1719
+ }
1720
+ catch {
1721
+ /* Not read: nothing said rather than a guess. */
1722
+ }
1723
+ this.overhangSaid = { key, words, note };
1724
+ return words;
1725
+ }
1726
+ motionEnds() {
1727
+ const view = this.session?.read();
1728
+ if (!view || !this.selected)
1729
+ return null;
1730
+ const key = `${view.context}|${view.revision}|${this.selected}`;
1731
+ if (this.motionEndsSaid?.key !== key) {
1732
+ let ends = null;
1733
+ try {
1734
+ ends = motionEndsOf(parseFigureAsset(this.session.source()), this.selected, this.currentInputs());
1735
+ }
1736
+ catch {
1737
+ ends = null;
1738
+ }
1739
+ this.motionEndsSaid = { key, ends };
1740
+ }
1741
+ return this.motionEndsSaid.ends;
1742
+ }
1743
+ /** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
1744
+ async goFasten() {
1745
+ this.tab = 'properties';
1746
+ await this.updateComplete;
1747
+ const links = [...this.renderRoot.querySelectorAll('figure-links')].find(l => l.section === 'shape');
1748
+ await links?.showFastening?.();
1749
+ }
1750
+ /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1751
+ previewShown() {
1752
+ const p = this.currentPreview();
1753
+ /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1754
+ if (!p && this.tab === 'motion' && this.middle === 'edit')
1755
+ return { ghost: this.motionEnds()?.ghost ?? [], highlight: null };
1756
+ if (!p || p.refused)
1757
+ return { ghost: [], highlight: null };
1758
+ if (this.previewDrawn?.of !== p)
1759
+ this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) };
1760
+ return this.previewDrawn;
1761
+ }
1762
+ /**
1763
+ * 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
1764
+ * what the preview is trying. Read at the moment of sending, never cached, and never a reason to start a new
1765
+ * conversation -- selecting is not a new subject.
1766
+ */
1767
+ assistantFocus() {
1768
+ const trying = this.middle === 'preview' ? this.renderRoot.querySelector('figure-preview')?.tried?.() : undefined;
1769
+ return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) };
1770
+ }
1214
1771
  choose(id) {
1215
1772
  /* A copy on the canvas selects its repeat: the part the author made. */
1216
1773
  this.selected = id ? ownerOf(id) : id;
@@ -1225,28 +1782,125 @@ let FigureEditor = class FigureEditor extends LitElement {
1225
1782
  return undefined;
1226
1783
  }
1227
1784
  }
1785
+ /** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
1228
1786
  renderTab(tab, icon, label, badge) {
1229
- return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${label} @click=${() => (this.tab = tab)}>
1230
- <md-icon>${icon}</md-icon>${badge == null ? '' : html `<span badge>${badge.toLocaleString()}</span>`}
1787
+ /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1788
+ const name = i18next.t('figure.label.tab-short-' + tab);
1789
+ return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1790
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html `<span badge>${badge.toLocaleString()}</span>`}
1231
1791
  </button>`;
1232
1792
  }
1233
1793
  updated(changed) {
1234
1794
  super.updated?.(changed);
1235
1795
  this.tell(changed);
1796
+ if (this.restorePanelFor !== undefined) {
1797
+ const key = this.restorePanelFor;
1798
+ this.restorePanelFor = undefined;
1799
+ requestAnimationFrame(async () => {
1800
+ const panel = this.propertiesPanel();
1801
+ if (!panel || (this.selected || '@model') !== key)
1802
+ return;
1803
+ await Promise.all([...panel.querySelectorAll('figure-links')].map(el => el.updateComplete));
1804
+ const kept = this.panelMemory.get(key);
1805
+ if (kept)
1806
+ this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]));
1807
+ panel.scrollTop = kept?.top ?? 0;
1808
+ });
1809
+ }
1236
1810
  const panes = this.renderRoot.querySelector('ox-three-pane') ?? undefined;
1237
- if (panes !== this.panes)
1811
+ if (panes !== this.panes) {
1238
1812
  this.panes = panes;
1813
+ this.fitPanesTo(this.getBoundingClientRect().width);
1814
+ }
1239
1815
  }
1240
- /** Which part the panel speaks of, with the way back to the whole figure. */
1816
+ /**
1817
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
1818
+ * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
1819
+ * mounting face and the tags are not asked for first.
1820
+ */
1821
+ renderStart() {
1822
+ const shapes = ['box', 'cylinder', 'plate'];
1823
+ /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
1824
+ const placement = (this.asset()?.placement ?? 'floor');
1825
+ return html `<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
1826
+ <h2>${i18next.t('figure.text.start-question')}</h2>
1827
+ <div start-paths>
1828
+ <section>
1829
+ <h3>${i18next.t('figure.label.start-placement')}</h3>
1830
+ <div start-shapes role="radiogroup" aria-label=${i18next.t('figure.label.start-placement')}>
1831
+ ${['floor', 'ceiling', 'wall', 'center'].map(one => html `<button start-button role="radio" aria-checked=${String(one === placement)} ?pressed=${one === placement}
1832
+ @click=${() => one !== placement && this.linkCommand({ kind: 'set-placement', placement: one })}>
1833
+ ${i18next.t(`figure.label.resize-place-${one}`)}
1834
+ </button>`)}
1835
+ </div>
1836
+ <small>${i18next.t('figure.text.placement-is-intent')}</small>
1837
+ </section>
1838
+ <section>
1839
+ <h3>${i18next.t('figure.label.start-shape')}</h3>
1840
+ <div start-shapes>
1841
+ ${shapes.map(kind => html `<button start-button start-shape=${kind} @click=${() => this.add(kind)}>
1842
+ ${i18next.t(kind === 'box' ? 'figure.label.primitive-cube' : `figure.label.primitive-${kind}`)}
1843
+ </button>`)}
1844
+ </div>
1845
+ <small>${i18next.t('figure.text.start-shape-more')}</small>
1846
+ </section>
1847
+ <section>
1848
+ <h3>${i18next.t('figure.label.start-recipe')}</h3>
1849
+ <button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
1850
+ ${i18next.t('figure.button.start-recipe')}
1851
+ </button>
1852
+ ${this.templates.length ? nothing : html `<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
1853
+ </section>
1854
+ ${this.aiDraft
1855
+ ? html `<section>
1856
+ <h3>${i18next.t('figure.label.start-ai')}</h3>
1857
+ <button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
1858
+ ${i18next.t('figure.button.start-ai')}
1859
+ </button>
1860
+ <small>${i18next.t('figure.text.start-ai-optional')}</small>
1861
+ </section>`
1862
+ : nothing}
1863
+ </div>
1864
+ </div>`;
1865
+ }
1866
+ /**
1867
+ * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
1868
+ * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
1869
+ * model name is the same move as the parts list's root; there is no separate "whole figure" link.
1870
+ */
1241
1871
  renderSubject() {
1242
- if (!this.selected)
1243
- return null;
1872
+ const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name');
1873
+ let summary;
1874
+ try {
1875
+ summary = this.selected ? partSummaryOf(parseFigureAsset(this.session.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined;
1876
+ }
1877
+ catch {
1878
+ summary = undefined;
1879
+ }
1244
1880
  return html `<div subject>
1245
- <button chip title=${i18next.t('figure.button.whole-figure')} @click=${() => (this.selected = '')}>
1246
- <md-icon>arrow_back</md-icon>${i18next.t('figure.button.whole-figure')}
1247
- </button>
1881
+ <nav path aria-label=${i18next.t('figure.label.panel-path')}>
1882
+ <button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
1883
+ aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
1884
+ ${this.selected
1885
+ ? html `<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
1886
+ : html `<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
1887
+ </nav>
1888
+ ${summary
1889
+ ? html `<div subject-summary>
1890
+ ${summary.kind ? html `<b>${summary.kind}</b>` : nothing}
1891
+ <small>${summary.size}</small><small>${summary.relation}</small>
1892
+ </div>`
1893
+ : nothing}
1248
1894
  </div>`;
1249
1895
  }
1896
+ /**
1897
+ * 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
1898
+ * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
1899
+ */
1900
+ showModelSettings() {
1901
+ this.selected = '';
1902
+ this.tab = 'properties';
1903
+ }
1250
1904
  render() {
1251
1905
  const view = this.session?.read();
1252
1906
  if (!view)
@@ -1258,6 +1912,37 @@ let FigureEditor = class FigureEditor extends LitElement {
1258
1912
  /* A repeat's own row values (its pitch) have their own section, not the shape's. */
1259
1913
  const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'));
1260
1914
  const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')));
1915
+ /*
1916
+ A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
1917
+ nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
1918
+ */
1919
+ let roomOnly = [];
1920
+ try {
1921
+ roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session.source()), this.selected) : [];
1922
+ }
1923
+ catch {
1924
+ roomOnly = [];
1925
+ }
1926
+ const partFields = own.filter(i => !roomOnly.includes(i.id));
1927
+ /* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
1928
+ let held;
1929
+ try {
1930
+ const body = view.geometry.find(g => g.id === this.selected);
1931
+ const placed = body ? placementOf(body) : undefined;
1932
+ const parsed = this.selected ? parseFigureAsset(this.session.source()) : undefined;
1933
+ if (placed && parsed)
1934
+ held = {
1935
+ position: placed.position,
1936
+ rotation: placed.rotation,
1937
+ size: ['x', 'y', 'z'].map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
1938
+ /* 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). */
1939
+ sizeRuled: ['x', 'y', 'z'].every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
1940
+ };
1941
+ }
1942
+ catch {
1943
+ held = undefined;
1944
+ }
1945
+ const roomFields = own.filter(i => roomOnly.includes(i.id));
1261
1946
  const status = view.loading
1262
1947
  ? 'figure.text.loading-figure'
1263
1948
  : view.saving
@@ -1284,6 +1969,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1284
1969
  ${i18next.t('figure.label.mode-' + mode)}
1285
1970
  </button>`)}
1286
1971
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1972
+ <span slot="status" release-state>${this.releaseWords(view)}</span>
1973
+ <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>
1287
1974
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
1288
1975
  </ox-canvas-bar>
1289
1976
  ${this.catalogOpen
@@ -1303,10 +1990,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1303
1990
  slot="start"
1304
1991
  .budget=${this.budget}
1305
1992
  @show-maturity=${() => (this.tab = 'verdict')}
1306
- .parts=${this.partRows(view.geometry)}
1993
+ .parts=${this.partRows(view.geometry, asset)}
1307
1994
  .partBudget=${this.partBudgetOf(asset)}
1308
1995
  @set-detail-level=${(e) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
1309
1996
  .selected=${this.selected}
1997
+ .modelName=${view.name ?? ''}
1998
+ @show-model-settings=${() => this.showModelSettings()}
1310
1999
  ?locked=${unavailable}
1311
2000
  @add-part=${(e) => this.add(e.detail.kind)}
1312
2001
  .recipeCount=${this.templates.length}
@@ -1324,12 +2013,25 @@ let FigureEditor = class FigureEditor extends LitElement {
1324
2013
  .asset=${asset}
1325
2014
  .context=${view.context ?? ''}
1326
2015
  .view=${this.view}
2016
+ @configure-size=${async (e) => {
2017
+ this.middle = 'edit';
2018
+ this.selected = e.detail.part;
2019
+ this.tab = 'properties';
2020
+ await this.updateComplete;
2021
+ this.renderRoot.querySelector('figure-links')?.scrollIntoView({ block: 'nearest' });
2022
+ }}
1327
2023
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
1328
2024
  ></figure-preview>`
1329
- : html `<figure-canvas
2025
+ : html `<div middle>
2026
+ ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
2027
+ <figure-canvas
2028
+ ?startShown=${view.geometry.length === 0 && !unavailable}
1330
2029
  .view=${this.view}
1331
2030
  .parts=${this.drawnParts(view.geometry, asset)}
2031
+ .anchors=${this.anchorsOf(asset)}
1332
2032
  .fan=${this.fanOf(asset)}
2033
+ .ghost=${this.previewShown().ghost}
2034
+ .highlight=${this.previewShown().highlight}
1333
2035
  .palette=${view.palette ?? {}}
1334
2036
  .context=${view.context ?? ''}
1335
2037
  .selected=${this.selected}
@@ -1339,7 +2041,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1339
2041
  @render-error=${(e) => {
1340
2042
  this.error = e.detail.message;
1341
2043
  }}
1342
- ></figure-canvas>`}
2044
+ ></figure-canvas></div>`}
1343
2045
  <aside slot="end">
1344
2046
  <div tabs>
1345
2047
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1368,7 +2070,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1368
2070
  ${this.renderRepeatNote(view.geometry)}
1369
2071
  </section>
1370
2072
  ${this.renderRepeat(asset, view.geometry, unavailable)}
1371
- ${this.renderGroups(groupFields(own, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
2073
+ ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
2074
+ ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
1372
2075
  ${this.renderLinks(unavailable, 'shape')}
1373
2076
  <figure-material-panel
1374
2077
  .body=${{ ...part, id: this.selected }}
@@ -1377,7 +2080,15 @@ let FigureEditor = class FigureEditor extends LitElement {
1377
2080
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
1378
2081
  @part-live=${(e) => this.hold({}, e.detail.edit)}
1379
2082
  ></figure-material-panel>`
1380
- : html `<slot name="figure"></slot>
2083
+ : view.geometry.length === 0
2084
+ ? html `${this.renderSubject()}<section empty-figure>
2085
+ <p>${i18next.t('figure.text.empty-figure-next')}</p>
2086
+ <details>
2087
+ <summary>${i18next.t('figure.label.figure-info-later')}</summary>
2088
+ <slot name="figure"></slot>
2089
+ </details>
2090
+ </section>`
2091
+ : html `${this.renderSubject()}<slot name="figure"></slot>
1381
2092
  ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
1382
2093
  ${this.renderInstanceRange(asset, unavailable)}`}
1383
2094
  </div>
@@ -1406,11 +2117,11 @@ let FigureEditor = class FigureEditor extends LitElement {
1406
2117
  </ox-three-pane>`;
1407
2118
  }
1408
2119
  /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
1409
- renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null) {
2120
+ renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null, held) {
1410
2121
  return html `${groups.vectors.length || tools
1411
2122
  ? html `<section>
1412
2123
  <h3>${i18next.t(vectorTitle)}</h3>
1413
- ${groups.vectors.map(g => this.renderVector(g, unavailable))}
2124
+ ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
1414
2125
  ${tools ? html `<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
1415
2126
  </section>`
1416
2127
  : nothing}
@@ -1421,14 +2132,38 @@ let FigureEditor = class FigureEditor extends LitElement {
1421
2132
  </section>`
1422
2133
  : nothing}`;
1423
2134
  }
1424
- renderVector(group, unavailable) {
2135
+ /**
2136
+ * A box-like part's size row stands when a rule sets every side -- each following the figure's size -- with the
2137
+ * numbers the rule gives, as its position and turn rows do (designer 2026-09-27: not a blank, not gone).
2138
+ */
2139
+ withSizeRow(groups, held) {
2140
+ if (groups.vectors.some(v => v.kind === 'vector' && v.name === 'size'))
2141
+ return groups;
2142
+ if (!held?.sizeRuled || !held.size || held.size.length !== 3 || held.size.some(v => v === undefined))
2143
+ return groups;
2144
+ const at = groups.vectors.findIndex(v => v.kind === 'vector' && v.name === 'rotation');
2145
+ const vectors = [...groups.vectors];
2146
+ vectors.splice(at < 0 ? vectors.length : at + 1, 0, { kind: 'vector', name: 'size', cells: {} });
2147
+ return { ...groups, vectors };
2148
+ }
2149
+ renderVector(group, unavailable, held) {
1425
2150
  if (group.kind !== 'vector')
1426
2151
  return nothing;
1427
- const unit = group.cells.x.unit === 'deg' ? '°' : group.cells.x.unit;
2152
+ const unit = group.name === 'rotation' ? '°' : 'mm';
2153
+ const ruled = ['x', 'y', 'z'].filter(axis => !group.cells[axis]);
2154
+ const given = (axis) => {
2155
+ const v = held?.[group.name]?.[['x', 'y', 'z'].indexOf(axis)];
2156
+ return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10);
2157
+ };
1428
2158
  return html `<div vector-row>
1429
2159
  <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html ` <span unit>${unit}</span>`}</label>
1430
2160
  <div triple>
1431
- ${['x', 'y', 'z'].map(axis => html `<div cell>
2161
+ ${['x', 'y', 'z'].map(axis => !group.cells[axis]
2162
+ ? html `<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
2163
+ <span axis>${axis.toUpperCase()}</span>
2164
+ <input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
2165
+ </div>`
2166
+ : html `<div cell>
1432
2167
  <span axis ?scrub=${!unavailable} @pointerdown=${(e) => this.scrub(group.cells[axis], e, unavailable)}>${axis.toUpperCase()}</span>
1433
2168
  <input
1434
2169
  aria-label=${group.cells[axis].id}
@@ -1441,7 +2176,60 @@ let FigureEditor = class FigureEditor extends LitElement {
1441
2176
  />
1442
2177
  </div>`)}
1443
2178
  </div>
1444
- </div>`;
2179
+ </div>
2180
+ ${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}`;
2181
+ }
2182
+ /**
2183
+ * A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
2184
+ * typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
2185
+ */
2186
+ renderRoomOnly(fields, unavailable) {
2187
+ return html `<section room-only>
2188
+ <h3>${i18next.t('figure.label.room-only-inputs')}</h3>
2189
+ <p hint>${i18next.t('figure.text.room-only-inputs')}</p>
2190
+ <div form>
2191
+ ${fields.map(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 />`)}
2192
+ </div>
2193
+ <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
2194
+ </section>`;
2195
+ }
2196
+ /** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
2197
+ async askRoomRefit() {
2198
+ let refit;
2199
+ try {
2200
+ refit = figureRoomRefitOf(parseFigureAsset(this.session.source()));
2201
+ }
2202
+ catch (error) {
2203
+ this.error = this.said(error);
2204
+ return;
2205
+ }
2206
+ if (!refit)
2207
+ return;
2208
+ const rows = roomRefitRowsOf(refit);
2209
+ const outside = (list) => list.length
2210
+ ? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
2211
+ : i18next.t('figure.text.room-nothing-outside');
2212
+ const agreed = await OxPrompt.open({
2213
+ type: 'question',
2214
+ title: i18next.t('figure.label.room-refit'),
2215
+ template: html `<table room-refit>
2216
+ <tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
2217
+ ${rows.map(r => html `<tr>
2218
+ <td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
2219
+ <td>${r.declared}</td>
2220
+ <td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
2221
+ </tr>`)}
2222
+ </table>
2223
+ <p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
2224
+ <p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
2225
+ ${refit.drops.length
2226
+ ? 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>`
2227
+ : nothing}`,
2228
+ confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
2229
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
2230
+ });
2231
+ if (agreed)
2232
+ this.linkCommand(refit.step);
1445
2233
  }
1446
2234
  renderSingle(name, field, unavailable) {
1447
2235
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }));
@@ -1461,6 +2249,10 @@ __decorate([
1461
2249
  property({ attribute: false }),
1462
2250
  __metadata("design:type", Object)
1463
2251
  ], FigureEditor.prototype, "session", void 0);
2252
+ __decorate([
2253
+ property({ attribute: false }),
2254
+ __metadata("design:type", Object)
2255
+ ], FigureEditor.prototype, "releaseState", void 0);
1464
2256
  __decorate([
1465
2257
  property({ attribute: false }),
1466
2258
  __metadata("design:type", Array)
@@ -1537,6 +2329,10 @@ __decorate([
1537
2329
  state(),
1538
2330
  __metadata("design:type", Object)
1539
2331
  ], FigureEditor.prototype, "view", void 0);
2332
+ __decorate([
2333
+ property({ type: Boolean }),
2334
+ __metadata("design:type", Object)
2335
+ ], FigureEditor.prototype, "aiDraft", void 0);
1540
2336
  __decorate([
1541
2337
  state(),
1542
2338
  __metadata("design:type", Object)
@@ -1545,6 +2341,10 @@ __decorate([
1545
2341
  state(),
1546
2342
  __metadata("design:type", Object)
1547
2343
  ], FigureEditor.prototype, "live", void 0);
2344
+ __decorate([
2345
+ state(),
2346
+ __metadata("design:type", Object)
2347
+ ], FigureEditor.prototype, "preview", void 0);
1548
2348
  FigureEditor = FigureEditor_1 = __decorate([
1549
2349
  customElement('figure-editor')
1550
2350
  ], FigureEditor);