@things-factory/figure-ui 10.1.81 → 10.1.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +800 -53
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +357 -94
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/command-preview.d.ts +35 -0
  50. package/dist-client/modeller/command-preview.js +95 -0
  51. package/dist-client/modeller/command-preview.js.map +1 -0
  52. package/dist-client/modeller/editor.d.ts +107 -1
  53. package/dist-client/modeller/editor.js +823 -49
  54. package/dist-client/modeller/editor.js.map +1 -1
  55. package/dist-client/modeller/fields.d.ts +3 -2
  56. package/dist-client/modeller/fields.js +10 -3
  57. package/dist-client/modeller/fields.js.map +1 -1
  58. package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
  59. package/dist-client/modeller/figure-clips.d.ts +1 -1
  60. package/dist-client/modeller/figure-controls.d.ts +1 -1
  61. package/dist-client/modeller/figure-facts.js +2 -2
  62. package/dist-client/modeller/figure-facts.js.map +1 -1
  63. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  64. package/dist-client/modeller/figure-first-save.js +41 -14
  65. package/dist-client/modeller/figure-first-save.js.map +1 -1
  66. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  67. package/dist-client/modeller/figure-history-panel.js +130 -14
  68. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  69. package/dist-client/modeller/figure-links.d.ts +52 -2
  70. package/dist-client/modeller/figure-links.js +366 -97
  71. package/dist-client/modeller/figure-links.js.map +1 -1
  72. package/dist-client/modeller/figure-material-panel.d.ts +1 -1
  73. package/dist-client/modeller/figure-parts.d.ts +7 -0
  74. package/dist-client/modeller/figure-parts.js +55 -2
  75. package/dist-client/modeller/figure-parts.js.map +1 -1
  76. package/dist-client/modeller/figure-preview.d.ts +6 -0
  77. package/dist-client/modeller/figure-preview.js +22 -1
  78. package/dist-client/modeller/figure-preview.js.map +1 -1
  79. package/dist-client/modeller/figure-properties.d.ts +1 -1
  80. package/dist-client/modeller/figure-properties.js +3 -3
  81. package/dist-client/modeller/figure-properties.js.map +1 -1
  82. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  83. package/dist-client/modeller/figure-revert-choice.js +59 -0
  84. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  85. package/dist-client/modeller/first-save.d.ts +7 -0
  86. package/dist-client/modeller/first-save.js +17 -0
  87. package/dist-client/modeller/first-save.js.map +1 -1
  88. package/dist-client/modeller/gizmo.d.ts +0 -6
  89. package/dist-client/modeller/gizmo.js +0 -18
  90. package/dist-client/modeller/gizmo.js.map +1 -1
  91. package/dist-client/modeller/links.d.ts +49 -4
  92. package/dist-client/modeller/links.js +141 -15
  93. package/dist-client/modeller/links.js.map +1 -1
  94. package/dist-client/modeller/motion-ends.d.ts +12 -0
  95. package/dist-client/modeller/motion-ends.js +65 -0
  96. package/dist-client/modeller/motion-ends.js.map +1 -0
  97. package/dist-client/modeller/occupancy.d.ts +14 -1
  98. package/dist-client/modeller/occupancy.js +14 -1
  99. package/dist-client/modeller/occupancy.js.map +1 -1
  100. package/dist-client/modeller/product-session.d.ts +43 -2
  101. package/dist-client/modeller/product-session.js +146 -12
  102. package/dist-client/modeller/product-session.js.map +1 -1
  103. package/dist-client/modeller/refusals.d.ts +13 -0
  104. package/dist-client/modeller/refusals.js +16 -0
  105. package/dist-client/modeller/refusals.js.map +1 -1
  106. package/dist-client/modeller/revert-request.d.ts +16 -0
  107. package/dist-client/modeller/revert-request.js +11 -0
  108. package/dist-client/modeller/revert-request.js.map +1 -0
  109. package/dist-client/modeller/side-panel-styles.js +12 -0
  110. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  111. package/dist-client/modeller/stage-model.d.ts +18 -2
  112. package/dist-client/modeller/stage-model.js +21 -3
  113. package/dist-client/modeller/stage-model.js.map +1 -1
  114. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  115. package/dist-client/pages/figure-modeller-page.js +72 -34
  116. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  117. package/dist-client/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +5 -5
  119. package/test/ai-proposal.test.ts +2 -2
  120. package/test/align.test.ts +50 -5
  121. package/test/browser/acceptance-page.js +187 -0
  122. package/test/browser/build.mjs +5 -1
  123. package/test/browser/revert-page.js +47 -0
  124. package/test/browser/serve-acceptance.mjs +85 -0
  125. package/test/browser/serve-revert.mjs +119 -0
  126. package/test/clip-ai-proposal.test.ts +104 -0
  127. package/test/clip-consent.test.ts +88 -0
  128. package/test/command-preview.test.ts +288 -0
  129. package/test/fields.test.ts +4 -3
  130. package/test/figure-assistant-surface.test.ts +4 -1
  131. package/test/first-minute.test.ts +96 -0
  132. package/test/first-save-suggest.test.ts +42 -0
  133. package/test/gizmo.test.ts +20 -16
  134. package/test/joint-fan.test.ts +2 -1
  135. package/test/layout.test.ts +9 -2
  136. package/test/links.test.ts +9 -5
  137. package/test/proposal-record.test.ts +126 -0
  138. package/test/release-entry.test.ts +28 -0
  139. package/test/release-panel.test.ts +3 -2
  140. package/test/resize-with-placement.test.ts +226 -0
  141. package/test/revert-choice.test.ts +39 -0
  142. package/test/revert-keeping.test.ts +102 -0
  143. package/test/save-landed.test.ts +198 -0
  144. package/translations/en.json +229 -11
  145. package/translations/ja.json +229 -11
  146. package/translations/ko.json +230 -12
  147. package/translations/ms.json +228 -10
  148. package/translations/zh.json +229 -11
@@ -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, 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';
33
+ import { nudgeOf, placementOf } from './stage-model.js';
34
+ import { roomRefitRowsOf } from './occupancy.js';
32
35
  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';
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,36 @@ 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)}
794
1113
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
795
1114
  ></figure-links>`;
796
1115
  }
1116
+ /**
1117
+ * The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
1118
+ * while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
1119
+ * rule can say, and the gizmo keeps its own (the face across from the handle).
1120
+ */
1121
+ anchorsOf(asset) {
1122
+ let next = {};
1123
+ try {
1124
+ const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined;
1125
+ if (anchor)
1126
+ next = { [this.selected]: anchor };
1127
+ }
1128
+ catch {
1129
+ next = {};
1130
+ }
1131
+ /* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
1132
+ if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors))
1133
+ this.lastAnchors = next;
1134
+ return this.lastAnchors;
1135
+ }
797
1136
  /** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
798
1137
  renderControls(view, unavailable) {
799
1138
  let asset;
@@ -841,6 +1180,8 @@ let FigureEditor = class FigureEditor extends LitElement {
841
1180
  this.changed();
842
1181
  }
843
1182
  catch (error) {
1183
+ if (this.askClipLoss(error, () => this.linkCommand(action)))
1184
+ return;
844
1185
  const code = error?.code;
845
1186
  const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params));
846
1187
  if (narrowing) {
@@ -860,13 +1201,10 @@ let FigureEditor = class FigureEditor extends LitElement {
860
1201
  }
861
1202
  changeField(field, event) {
862
1203
  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. */
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. */
864
1205
  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
- ]));
1206
+ const change = { 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]));
870
1208
  // Rejected edits must also restore the displayed numeric value.
871
1209
  input.value = String(this.session.read().fields.find(i => i.id === field.id).value);
872
1210
  }
@@ -1014,7 +1352,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1014
1352
  * The parts list, one row per part the author made: a repeat is one row with how many copies it makes now
1015
1353
  * (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
1016
1354
  */
1017
- partRows(geometry) {
1355
+ partRows(geometry, asset) {
1356
+ /* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
1357
+ const fastened = new Map((asset?.joints ?? []).map(j => [
1358
+ j.body1.part,
1359
+ 'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
1360
+ ]));
1018
1361
  const rows = new Map();
1019
1362
  for (const g of geometry) {
1020
1363
  const id = ownerOf(g.id);
@@ -1028,7 +1371,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1028
1371
  provider: g.shape?.provider ?? '',
1029
1372
  hidden: this.hiddenParts.has(id),
1030
1373
  invisible: g.visible === false,
1031
- ...(id !== g.id ? { copies: 1 } : {})
1374
+ ...(id !== g.id ? { copies: 1 } : {}),
1375
+ ...(fastened.has(id) ? { fastenedTo: fastened.get(id) } : {})
1032
1376
  });
1033
1377
  }
1034
1378
  return [...rows.values()];
@@ -1084,6 +1428,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1084
1428
  @change=${(e) => this.setPitch(field, e)}
1085
1429
  />
1086
1430
  </div>
1431
+ <ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html `<li>${line}</li>`)}</ul>
1087
1432
  <p hint>
1088
1433
  ${fits && length !== undefined && longest !== undefined && limit !== undefined
1089
1434
  ? i18next.t('figure.text.repeat-what-pitch-gives', {
@@ -1102,6 +1447,15 @@ let FigureEditor = class FigureEditor extends LitElement {
1102
1447
  : nothing}
1103
1448
  </section>`;
1104
1449
  }
1450
+ /** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
1451
+ rowNow(asset) {
1452
+ try {
1453
+ return figureRowOf(asset, this.selected, this.currentInputs());
1454
+ }
1455
+ catch {
1456
+ return undefined;
1457
+ }
1458
+ }
1105
1459
  /** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
1106
1460
  pitchInputOf(asset, part) {
1107
1461
  const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch;
@@ -1211,6 +1565,183 @@ let FigureEditor = class FigureEditor extends LitElement {
1211
1565
  }
1212
1566
  }
1213
1567
  /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
1568
+ /*
1569
+ Saving and releasing told apart, beside the save (ADR-0097 §5.6, Q-D2): what the saved figure is -- a draft never
1570
+ released, a draft while boards use an earlier release, a release -- as the server said it. Edits not saved are the
1571
+ status before it. Boards read a draft's latest release (figureBlueprint), so that is what is said.
1572
+ */
1573
+ releaseWords(view) {
1574
+ if (!view.id || !this.releaseState?.state)
1575
+ return '';
1576
+ const { state, version } = this.releaseState;
1577
+ if (state === 'released')
1578
+ return i18next.t('figure.text.release-state-released', { version: String(version) });
1579
+ return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new');
1580
+ }
1581
+ /** The fastening form's command, seen before it is done; null clears what was shown. */
1582
+ previewCommand(candidate) {
1583
+ if (!candidate || !this.session || !this.selected) {
1584
+ this.preview = undefined;
1585
+ return;
1586
+ }
1587
+ const view = this.session.read();
1588
+ const held = { context: view.context, revision: view.revision, key: JSON.stringify(candidate), part: this.selected };
1589
+ try {
1590
+ const seen = this.session.previewCommand(candidate.action);
1591
+ let face;
1592
+ if (candidate.face) {
1593
+ try {
1594
+ face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs());
1595
+ }
1596
+ catch {
1597
+ face = undefined;
1598
+ }
1599
+ }
1600
+ /* A row is read on the figure the command would make, by figure-model, as drawn. */
1601
+ const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined;
1602
+ this.preview = {
1603
+ ...held,
1604
+ context: seen.context,
1605
+ revision: seen.revision,
1606
+ geometry: seen.geometry,
1607
+ moves: seen.moves,
1608
+ added: seen.added,
1609
+ ...(row ? { row } : {}),
1610
+ clipsWouldGo: seen.clipsWouldGo,
1611
+ face,
1612
+ faceUnknown: !!candidate.face && !face
1613
+ };
1614
+ }
1615
+ catch (error) {
1616
+ /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1617
+ this.preview = { ...held, refused: this.said(error) };
1618
+ }
1619
+ }
1620
+ /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1621
+ currentPreview() {
1622
+ const p = this.preview;
1623
+ const view = this.session?.read();
1624
+ /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1625
+ if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties')
1626
+ return undefined;
1627
+ return p;
1628
+ }
1629
+ detachWords(asset) {
1630
+ const view = this.session?.read();
1631
+ const joint = asset?.joints?.find(j => j.body1.part === this.selected);
1632
+ if (!asset || !view || !joint || !this.session)
1633
+ return [];
1634
+ const key = `${view.context}|${view.revision}|${this.selected}`;
1635
+ if (this.detachSaid?.key === key)
1636
+ return this.detachSaid.words;
1637
+ const say = (k, params) => i18next.t(k, params);
1638
+ const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part);
1639
+ let words;
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)
1648
+ words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }));
1649
+ if (seen.clipsWouldGo.length)
1650
+ words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }));
1651
+ }
1652
+ catch (error) {
1653
+ words = [say('figure.text.detach-refused', { reason: this.said(error) })];
1654
+ }
1655
+ this.detachSaid = { key, words };
1656
+ return words;
1657
+ }
1658
+ /** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
1659
+ overhangNote(asset) {
1660
+ this.overhangWords(asset);
1661
+ return this.overhangSaid?.note ?? [];
1662
+ }
1663
+ overhangWords(asset) {
1664
+ const view = this.session?.read();
1665
+ if (!asset || !view || !this.selected)
1666
+ return [];
1667
+ const key = `${view.context}|${view.revision}|${this.selected}`;
1668
+ if (this.overhangSaid?.key === key)
1669
+ return this.overhangSaid.words;
1670
+ const say = (k, params) => i18next.t(k, params);
1671
+ const where = (o) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) });
1672
+ const sides = (o) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ');
1673
+ const words = [];
1674
+ const note = [];
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)
1681
+ note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }));
1682
+ }
1683
+ if (own?.state === 'out')
1684
+ words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }));
1685
+ else if (own?.state === 'possible')
1686
+ words.push(say('figure.text.overhang-possible', where(own)));
1687
+ else if (own?.state === 'unknown')
1688
+ words.push(say('figure.text.overhang-unknown', where(own)));
1689
+ const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs));
1690
+ const past = held.filter((o) => o?.state === 'out' || o?.state === 'possible');
1691
+ if (past.length)
1692
+ words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }));
1693
+ }
1694
+ catch {
1695
+ /* Not read: nothing said rather than a guess. */
1696
+ }
1697
+ this.overhangSaid = { key, words, note };
1698
+ return words;
1699
+ }
1700
+ motionEnds() {
1701
+ const view = this.session?.read();
1702
+ if (!view || !this.selected)
1703
+ return null;
1704
+ const key = `${view.context}|${view.revision}|${this.selected}`;
1705
+ if (this.motionEndsSaid?.key !== key) {
1706
+ let ends = null;
1707
+ try {
1708
+ ends = motionEndsOf(parseFigureAsset(this.session.source()), this.selected, this.currentInputs());
1709
+ }
1710
+ catch {
1711
+ ends = null;
1712
+ }
1713
+ this.motionEndsSaid = { key, ends };
1714
+ }
1715
+ return this.motionEndsSaid.ends;
1716
+ }
1717
+ /** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
1718
+ async goFasten() {
1719
+ this.tab = 'properties';
1720
+ await this.updateComplete;
1721
+ const links = [...this.renderRoot.querySelectorAll('figure-links')].find(l => l.section === 'shape');
1722
+ await links?.showFastening?.();
1723
+ }
1724
+ /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1725
+ previewShown() {
1726
+ const p = this.currentPreview();
1727
+ /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1728
+ if (!p && this.tab === 'motion' && this.middle === 'edit')
1729
+ return { ghost: this.motionEnds()?.ghost ?? [], highlight: null };
1730
+ if (!p || p.refused)
1731
+ return { ghost: [], highlight: null };
1732
+ if (this.previewDrawn?.of !== p)
1733
+ this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) };
1734
+ return this.previewDrawn;
1735
+ }
1736
+ /**
1737
+ * 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
1738
+ * what the preview is trying. Read at the moment of sending, never cached, and never a reason to start a new
1739
+ * conversation -- selecting is not a new subject.
1740
+ */
1741
+ assistantFocus() {
1742
+ const trying = this.middle === 'preview' ? this.renderRoot.querySelector('figure-preview')?.tried?.() : undefined;
1743
+ return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) };
1744
+ }
1214
1745
  choose(id) {
1215
1746
  /* A copy on the canvas selects its repeat: the part the author made. */
1216
1747
  this.selected = id ? ownerOf(id) : id;
@@ -1225,28 +1756,125 @@ let FigureEditor = class FigureEditor extends LitElement {
1225
1756
  return undefined;
1226
1757
  }
1227
1758
  }
1759
+ /** 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
1760
  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>`}
1761
+ /* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
1762
+ const name = i18next.t('figure.label.tab-short-' + tab);
1763
+ return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
1764
+ <md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html `<span badge>${badge.toLocaleString()}</span>`}
1231
1765
  </button>`;
1232
1766
  }
1233
1767
  updated(changed) {
1234
1768
  super.updated?.(changed);
1235
1769
  this.tell(changed);
1770
+ if (this.restorePanelFor !== undefined) {
1771
+ const key = this.restorePanelFor;
1772
+ this.restorePanelFor = undefined;
1773
+ requestAnimationFrame(async () => {
1774
+ const panel = this.propertiesPanel();
1775
+ if (!panel || (this.selected || '@model') !== key)
1776
+ return;
1777
+ await Promise.all([...panel.querySelectorAll('figure-links')].map(el => el.updateComplete));
1778
+ const kept = this.panelMemory.get(key);
1779
+ if (kept)
1780
+ this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]));
1781
+ panel.scrollTop = kept?.top ?? 0;
1782
+ });
1783
+ }
1236
1784
  const panes = this.renderRoot.querySelector('ox-three-pane') ?? undefined;
1237
- if (panes !== this.panes)
1785
+ if (panes !== this.panes) {
1238
1786
  this.panes = panes;
1787
+ this.fitPanesTo(this.getBoundingClientRect().width);
1788
+ }
1239
1789
  }
1240
- /** Which part the panel speaks of, with the way back to the whole figure. */
1790
+ /**
1791
+ * A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
1792
+ * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
1793
+ * mounting face and the tags are not asked for first.
1794
+ */
1795
+ renderStart() {
1796
+ const shapes = ['box', 'cylinder', 'plate'];
1797
+ /* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
1798
+ const placement = (this.asset()?.placement ?? 'floor');
1799
+ return html `<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
1800
+ <h2>${i18next.t('figure.text.start-question')}</h2>
1801
+ <div start-paths>
1802
+ <section>
1803
+ <h3>${i18next.t('figure.label.start-placement')}</h3>
1804
+ <div start-shapes role="radiogroup" aria-label=${i18next.t('figure.label.start-placement')}>
1805
+ ${['floor', 'ceiling', 'wall', 'center'].map(one => html `<button start-button role="radio" aria-checked=${String(one === placement)} ?pressed=${one === placement}
1806
+ @click=${() => one !== placement && this.linkCommand({ kind: 'set-placement', placement: one })}>
1807
+ ${i18next.t(`figure.label.resize-place-${one}`)}
1808
+ </button>`)}
1809
+ </div>
1810
+ <small>${i18next.t('figure.text.placement-is-intent')}</small>
1811
+ </section>
1812
+ <section>
1813
+ <h3>${i18next.t('figure.label.start-shape')}</h3>
1814
+ <div start-shapes>
1815
+ ${shapes.map(kind => html `<button start-button start-shape=${kind} @click=${() => this.add(kind)}>
1816
+ ${i18next.t(kind === 'box' ? 'figure.label.primitive-cube' : `figure.label.primitive-${kind}`)}
1817
+ </button>`)}
1818
+ </div>
1819
+ <small>${i18next.t('figure.text.start-shape-more')}</small>
1820
+ </section>
1821
+ <section>
1822
+ <h3>${i18next.t('figure.label.start-recipe')}</h3>
1823
+ <button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
1824
+ ${i18next.t('figure.button.start-recipe')}
1825
+ </button>
1826
+ ${this.templates.length ? nothing : html `<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
1827
+ </section>
1828
+ ${this.aiDraft
1829
+ ? html `<section>
1830
+ <h3>${i18next.t('figure.label.start-ai')}</h3>
1831
+ <button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
1832
+ ${i18next.t('figure.button.start-ai')}
1833
+ </button>
1834
+ <small>${i18next.t('figure.text.start-ai-optional')}</small>
1835
+ </section>`
1836
+ : nothing}
1837
+ </div>
1838
+ </div>`;
1839
+ }
1840
+ /**
1841
+ * Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
1842
+ * with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
1843
+ * model name is the same move as the parts list's root; there is no separate "whole figure" link.
1844
+ */
1241
1845
  renderSubject() {
1242
- if (!this.selected)
1243
- return null;
1846
+ const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name');
1847
+ let summary;
1848
+ try {
1849
+ summary = this.selected ? partSummaryOf(parseFigureAsset(this.session.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined;
1850
+ }
1851
+ catch {
1852
+ summary = undefined;
1853
+ }
1244
1854
  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>
1855
+ <nav path aria-label=${i18next.t('figure.label.panel-path')}>
1856
+ <button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
1857
+ aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
1858
+ ${this.selected
1859
+ ? html `<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
1860
+ : html `<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
1861
+ </nav>
1862
+ ${summary
1863
+ ? html `<div subject-summary>
1864
+ ${summary.kind ? html `<b>${summary.kind}</b>` : nothing}
1865
+ <small>${summary.size}</small><small>${summary.relation}</small>
1866
+ </div>`
1867
+ : nothing}
1248
1868
  </div>`;
1249
1869
  }
1870
+ /**
1871
+ * 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
1872
+ * the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
1873
+ */
1874
+ showModelSettings() {
1875
+ this.selected = '';
1876
+ this.tab = 'properties';
1877
+ }
1250
1878
  render() {
1251
1879
  const view = this.session?.read();
1252
1880
  if (!view)
@@ -1258,6 +1886,37 @@ let FigureEditor = class FigureEditor extends LitElement {
1258
1886
  /* A repeat's own row values (its pitch) have their own section, not the shape's. */
1259
1887
  const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'));
1260
1888
  const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')));
1889
+ /*
1890
+ A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
1891
+ nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
1892
+ */
1893
+ let roomOnly = [];
1894
+ try {
1895
+ roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session.source()), this.selected) : [];
1896
+ }
1897
+ catch {
1898
+ roomOnly = [];
1899
+ }
1900
+ const partFields = own.filter(i => !roomOnly.includes(i.id));
1901
+ /* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
1902
+ let held;
1903
+ try {
1904
+ const body = view.geometry.find(g => g.id === this.selected);
1905
+ const placed = body ? placementOf(body) : undefined;
1906
+ const parsed = this.selected ? parseFigureAsset(this.session.source()) : undefined;
1907
+ if (placed && parsed)
1908
+ held = {
1909
+ position: placed.position,
1910
+ rotation: placed.rotation,
1911
+ size: ['x', 'y', 'z'].map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
1912
+ /* 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). */
1913
+ sizeRuled: ['x', 'y', 'z'].every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
1914
+ };
1915
+ }
1916
+ catch {
1917
+ held = undefined;
1918
+ }
1919
+ const roomFields = own.filter(i => roomOnly.includes(i.id));
1261
1920
  const status = view.loading
1262
1921
  ? 'figure.text.loading-figure'
1263
1922
  : view.saving
@@ -1284,6 +1943,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1284
1943
  ${i18next.t('figure.label.mode-' + mode)}
1285
1944
  </button>`)}
1286
1945
  <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
1946
+ <span slot="status" release-state>${this.releaseWords(view)}</span>
1947
+ <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
1948
  <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
1288
1949
  </ox-canvas-bar>
1289
1950
  ${this.catalogOpen
@@ -1303,10 +1964,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1303
1964
  slot="start"
1304
1965
  .budget=${this.budget}
1305
1966
  @show-maturity=${() => (this.tab = 'verdict')}
1306
- .parts=${this.partRows(view.geometry)}
1967
+ .parts=${this.partRows(view.geometry, asset)}
1307
1968
  .partBudget=${this.partBudgetOf(asset)}
1308
1969
  @set-detail-level=${(e) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
1309
1970
  .selected=${this.selected}
1971
+ .modelName=${view.name ?? ''}
1972
+ @show-model-settings=${() => this.showModelSettings()}
1310
1973
  ?locked=${unavailable}
1311
1974
  @add-part=${(e) => this.add(e.detail.kind)}
1312
1975
  .recipeCount=${this.templates.length}
@@ -1324,12 +1987,25 @@ let FigureEditor = class FigureEditor extends LitElement {
1324
1987
  .asset=${asset}
1325
1988
  .context=${view.context ?? ''}
1326
1989
  .view=${this.view}
1990
+ @configure-size=${async (e) => {
1991
+ this.middle = 'edit';
1992
+ this.selected = e.detail.part;
1993
+ this.tab = 'properties';
1994
+ await this.updateComplete;
1995
+ this.renderRoot.querySelector('figure-links')?.scrollIntoView({ block: 'nearest' });
1996
+ }}
1327
1997
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
1328
1998
  ></figure-preview>`
1329
- : html `<figure-canvas
1999
+ : html `<div middle>
2000
+ ${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
2001
+ <figure-canvas
2002
+ ?startShown=${view.geometry.length === 0 && !unavailable}
1330
2003
  .view=${this.view}
1331
2004
  .parts=${this.drawnParts(view.geometry, asset)}
2005
+ .anchors=${this.anchorsOf(asset)}
1332
2006
  .fan=${this.fanOf(asset)}
2007
+ .ghost=${this.previewShown().ghost}
2008
+ .highlight=${this.previewShown().highlight}
1333
2009
  .palette=${view.palette ?? {}}
1334
2010
  .context=${view.context ?? ''}
1335
2011
  .selected=${this.selected}
@@ -1339,7 +2015,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1339
2015
  @render-error=${(e) => {
1340
2016
  this.error = e.detail.message;
1341
2017
  }}
1342
- ></figure-canvas>`}
2018
+ ></figure-canvas></div>`}
1343
2019
  <aside slot="end">
1344
2020
  <div tabs>
1345
2021
  ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
@@ -1368,7 +2044,8 @@ let FigureEditor = class FigureEditor extends LitElement {
1368
2044
  ${this.renderRepeatNote(view.geometry)}
1369
2045
  </section>
1370
2046
  ${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))}
2047
+ ${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
2048
+ ${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
1372
2049
  ${this.renderLinks(unavailable, 'shape')}
1373
2050
  <figure-material-panel
1374
2051
  .body=${{ ...part, id: this.selected }}
@@ -1377,7 +2054,15 @@ let FigureEditor = class FigureEditor extends LitElement {
1377
2054
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
1378
2055
  @part-live=${(e) => this.hold({}, e.detail.edit)}
1379
2056
  ></figure-material-panel>`
1380
- : html `<slot name="figure"></slot>
2057
+ : view.geometry.length === 0
2058
+ ? html `${this.renderSubject()}<section empty-figure>
2059
+ <p>${i18next.t('figure.text.empty-figure-next')}</p>
2060
+ <details>
2061
+ <summary>${i18next.t('figure.label.figure-info-later')}</summary>
2062
+ <slot name="figure"></slot>
2063
+ </details>
2064
+ </section>`
2065
+ : html `${this.renderSubject()}<slot name="figure"></slot>
1381
2066
  ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
1382
2067
  ${this.renderInstanceRange(asset, unavailable)}`}
1383
2068
  </div>
@@ -1406,11 +2091,11 @@ let FigureEditor = class FigureEditor extends LitElement {
1406
2091
  </ox-three-pane>`;
1407
2092
  }
1408
2093
  /** 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) {
2094
+ renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null, held) {
1410
2095
  return html `${groups.vectors.length || tools
1411
2096
  ? html `<section>
1412
2097
  <h3>${i18next.t(vectorTitle)}</h3>
1413
- ${groups.vectors.map(g => this.renderVector(g, unavailable))}
2098
+ ${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
1414
2099
  ${tools ? html `<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
1415
2100
  </section>`
1416
2101
  : nothing}
@@ -1421,14 +2106,38 @@ let FigureEditor = class FigureEditor extends LitElement {
1421
2106
  </section>`
1422
2107
  : nothing}`;
1423
2108
  }
1424
- renderVector(group, unavailable) {
2109
+ /**
2110
+ * A box-like part's size row stands when a rule sets every side -- each following the figure's size -- with the
2111
+ * numbers the rule gives, as its position and turn rows do (designer 2026-09-27: not a blank, not gone).
2112
+ */
2113
+ withSizeRow(groups, held) {
2114
+ if (groups.vectors.some(v => v.kind === 'vector' && v.name === 'size'))
2115
+ return groups;
2116
+ if (!held?.sizeRuled || !held.size || held.size.length !== 3 || held.size.some(v => v === undefined))
2117
+ return groups;
2118
+ const at = groups.vectors.findIndex(v => v.kind === 'vector' && v.name === 'rotation');
2119
+ const vectors = [...groups.vectors];
2120
+ vectors.splice(at < 0 ? vectors.length : at + 1, 0, { kind: 'vector', name: 'size', cells: {} });
2121
+ return { ...groups, vectors };
2122
+ }
2123
+ renderVector(group, unavailable, held) {
1425
2124
  if (group.kind !== 'vector')
1426
2125
  return nothing;
1427
- const unit = group.cells.x.unit === 'deg' ? '°' : group.cells.x.unit;
2126
+ const unit = group.name === 'rotation' ? '°' : 'mm';
2127
+ const ruled = ['x', 'y', 'z'].filter(axis => !group.cells[axis]);
2128
+ const given = (axis) => {
2129
+ const v = held?.[group.name]?.[['x', 'y', 'z'].indexOf(axis)];
2130
+ return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10);
2131
+ };
1428
2132
  return html `<div vector-row>
1429
2133
  <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html ` <span unit>${unit}</span>`}</label>
1430
2134
  <div triple>
1431
- ${['x', 'y', 'z'].map(axis => html `<div cell>
2135
+ ${['x', 'y', 'z'].map(axis => !group.cells[axis]
2136
+ ? html `<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
2137
+ <span axis>${axis.toUpperCase()}</span>
2138
+ <input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
2139
+ </div>`
2140
+ : html `<div cell>
1432
2141
  <span axis ?scrub=${!unavailable} @pointerdown=${(e) => this.scrub(group.cells[axis], e, unavailable)}>${axis.toUpperCase()}</span>
1433
2142
  <input
1434
2143
  aria-label=${group.cells[axis].id}
@@ -1441,7 +2150,60 @@ let FigureEditor = class FigureEditor extends LitElement {
1441
2150
  />
1442
2151
  </div>`)}
1443
2152
  </div>
1444
- </div>`;
2153
+ </div>
2154
+ ${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}`;
2155
+ }
2156
+ /**
2157
+ * A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
2158
+ * typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
2159
+ */
2160
+ renderRoomOnly(fields, unavailable) {
2161
+ return html `<section room-only>
2162
+ <h3>${i18next.t('figure.label.room-only-inputs')}</h3>
2163
+ <p hint>${i18next.t('figure.text.room-only-inputs')}</p>
2164
+ <div form>
2165
+ ${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 />`)}
2166
+ </div>
2167
+ <button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
2168
+ </section>`;
2169
+ }
2170
+ /** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
2171
+ async askRoomRefit() {
2172
+ let refit;
2173
+ try {
2174
+ refit = figureRoomRefitOf(parseFigureAsset(this.session.source()));
2175
+ }
2176
+ catch (error) {
2177
+ this.error = this.said(error);
2178
+ return;
2179
+ }
2180
+ if (!refit)
2181
+ return;
2182
+ const rows = roomRefitRowsOf(refit);
2183
+ const outside = (list) => list.length
2184
+ ? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
2185
+ : i18next.t('figure.text.room-nothing-outside');
2186
+ const agreed = await OxPrompt.open({
2187
+ type: 'question',
2188
+ title: i18next.t('figure.label.room-refit'),
2189
+ template: html `<table room-refit>
2190
+ <tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
2191
+ ${rows.map(r => html `<tr>
2192
+ <td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
2193
+ <td>${r.declared}</td>
2194
+ <td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
2195
+ </tr>`)}
2196
+ </table>
2197
+ <p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
2198
+ <p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
2199
+ ${refit.drops.length
2200
+ ? 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>`
2201
+ : nothing}`,
2202
+ confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
2203
+ cancelButton: { text: i18next.t('figure.button.keep-editing') }
2204
+ });
2205
+ if (agreed)
2206
+ this.linkCommand(refit.step);
1445
2207
  }
1446
2208
  renderSingle(name, field, unavailable) {
1447
2209
  const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }));
@@ -1461,6 +2223,10 @@ __decorate([
1461
2223
  property({ attribute: false }),
1462
2224
  __metadata("design:type", Object)
1463
2225
  ], FigureEditor.prototype, "session", void 0);
2226
+ __decorate([
2227
+ property({ attribute: false }),
2228
+ __metadata("design:type", Object)
2229
+ ], FigureEditor.prototype, "releaseState", void 0);
1464
2230
  __decorate([
1465
2231
  property({ attribute: false }),
1466
2232
  __metadata("design:type", Array)
@@ -1537,6 +2303,10 @@ __decorate([
1537
2303
  state(),
1538
2304
  __metadata("design:type", Object)
1539
2305
  ], FigureEditor.prototype, "view", void 0);
2306
+ __decorate([
2307
+ property({ type: Boolean }),
2308
+ __metadata("design:type", Object)
2309
+ ], FigureEditor.prototype, "aiDraft", void 0);
1540
2310
  __decorate([
1541
2311
  state(),
1542
2312
  __metadata("design:type", Object)
@@ -1545,6 +2315,10 @@ __decorate([
1545
2315
  state(),
1546
2316
  __metadata("design:type", Object)
1547
2317
  ], FigureEditor.prototype, "live", void 0);
2318
+ __decorate([
2319
+ state(),
2320
+ __metadata("design:type", Object)
2321
+ ], FigureEditor.prototype, "preview", void 0);
1548
2322
  FigureEditor = FigureEditor_1 = __decorate([
1549
2323
  customElement('figure-editor')
1550
2324
  ], FigureEditor);