@things-factory/figure-ui 10.1.151 → 10.1.153

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 (86) hide show
  1. package/client/modeller/ai-proposal.ts +14 -13
  2. package/client/modeller/command-preview-controller.ts +210 -0
  3. package/client/modeller/editor-styles.ts +307 -0
  4. package/client/modeller/editor.ts +65 -634
  5. package/client/modeller/fasten-geometry.ts +32 -0
  6. package/client/modeller/figure-first-save.ts +9 -1
  7. package/client/modeller/figure-links.ts +32 -14
  8. package/client/modeller/mesh-import.ts +0 -10
  9. package/client/modeller/modeller-keys.ts +60 -0
  10. package/client/modeller/pane-fold.ts +61 -0
  11. package/client/modeller/panel-memory.ts +53 -0
  12. package/client/modeller/refusal-teller.ts +93 -0
  13. package/client/modeller/refusals.ts +32 -2
  14. package/dist-client/modeller/ai-proposal.d.ts +6 -5
  15. package/dist-client/modeller/ai-proposal.js +9 -9
  16. package/dist-client/modeller/ai-proposal.js.map +1 -1
  17. package/dist-client/modeller/clips.d.ts +1 -1
  18. package/dist-client/modeller/command-preview-controller.d.ts +75 -0
  19. package/dist-client/modeller/command-preview-controller.js +178 -0
  20. package/dist-client/modeller/command-preview-controller.js.map +1 -0
  21. package/dist-client/modeller/editor-styles.d.ts +2 -0
  22. package/dist-client/modeller/editor-styles.js +307 -0
  23. package/dist-client/modeller/editor-styles.js.map +1 -0
  24. package/dist-client/modeller/editor.d.ts +5 -56
  25. package/dist-client/modeller/editor.js +76 -656
  26. package/dist-client/modeller/editor.js.map +1 -1
  27. package/dist-client/modeller/fasten-geometry.d.ts +13 -0
  28. package/dist-client/modeller/fasten-geometry.js +29 -0
  29. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  30. package/dist-client/modeller/figure-first-save.d.ts +1 -0
  31. package/dist-client/modeller/figure-first-save.js +8 -1
  32. package/dist-client/modeller/figure-first-save.js.map +1 -1
  33. package/dist-client/modeller/figure-links.d.ts +9 -2
  34. package/dist-client/modeller/figure-links.js +35 -14
  35. package/dist-client/modeller/figure-links.js.map +1 -1
  36. package/dist-client/modeller/mesh-import.d.ts +0 -2
  37. package/dist-client/modeller/mesh-import.js +0 -11
  38. package/dist-client/modeller/mesh-import.js.map +1 -1
  39. package/dist-client/modeller/modeller-keys.d.ts +22 -0
  40. package/dist-client/modeller/modeller-keys.js +43 -0
  41. package/dist-client/modeller/modeller-keys.js.map +1 -0
  42. package/dist-client/modeller/pane-fold.d.ts +15 -0
  43. package/dist-client/modeller/pane-fold.js +60 -0
  44. package/dist-client/modeller/pane-fold.js.map +1 -0
  45. package/dist-client/modeller/panel-memory.d.ts +15 -0
  46. package/dist-client/modeller/panel-memory.js +54 -0
  47. package/dist-client/modeller/panel-memory.js.map +1 -0
  48. package/dist-client/modeller/product-session.d.ts +4 -28
  49. package/dist-client/modeller/refusal-teller.d.ts +36 -0
  50. package/dist-client/modeller/refusal-teller.js +81 -0
  51. package/dist-client/modeller/refusal-teller.js.map +1 -0
  52. package/dist-client/modeller/refusals.d.ts +12 -1
  53. package/dist-client/modeller/refusals.js +26 -1
  54. package/dist-client/modeller/refusals.js.map +1 -1
  55. package/dist-client/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +5 -5
  57. package/test/browser/build.mjs +7 -2
  58. package/test/browser/editing.check.mjs +14 -0
  59. package/test/browser/fix-alignment.check.mjs +112 -0
  60. package/test/browser/links.check.mjs +69 -13
  61. package/test/browser/mesh-select.check.mjs +91 -0
  62. package/test/browser/modeller-page.js +36 -0
  63. package/test/browser/modeller-revision.check.mjs +18 -6
  64. package/test/browser/modeller.check.mjs +25 -0
  65. package/test/browser/part-ids.check.mjs +24 -6
  66. package/test/browser/parts-page.js +9 -1
  67. package/test/browser/parts.check.mjs +61 -2
  68. package/test/browser/places-mesh.check.mjs +25 -9
  69. package/test/browser/product-page.check.mjs +42 -2
  70. package/test/browser/row-preview.check.mjs +81 -0
  71. package/test/browser/screen.check.mjs +35 -1
  72. package/test/clip-ai-proposal.test.ts +4 -1
  73. package/test/clip-consent.test.ts +9 -2
  74. package/test/fields.test.ts +5 -9
  75. package/test/fix-alignment.test.ts +32 -0
  76. package/test/layout.test.ts +3 -1
  77. package/test/mesh-import.test.ts +1 -8
  78. package/test/parts-wiring.test.ts +1 -5
  79. package/test/refusals.test.ts +46 -10
  80. package/test/theme.test.ts +5 -15
  81. package/translations/en.json +8 -0
  82. package/translations/es.json +8 -0
  83. package/translations/ja.json +8 -0
  84. package/translations/ko.json +8 -0
  85. package/translations/ms.json +8 -0
  86. package/translations/zh.json +8 -0
@@ -1,13 +1,13 @@
1
1
  var FigureEditor_1;
2
2
  import { __decorate, __metadata } from "tslib";
3
- import { LitElement, html, css, nothing } from 'lit';
3
+ import { LitElement, html, nothing } from 'lit';
4
4
  import { i18next } from '@operato/i18n';
5
5
  import '@operato/layout';
6
- import { notify, openPopup } from '@operato/layout';
6
+ import { openPopup } 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, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, figurePartFaceOf, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
10
+ import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figurePartIdFor, figureRecipeIdFor, figureSizeEditChanges, figureScaleAnchorOf, figureSectionMeasuresOf, figurePlaneAlignChanges, 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';
@@ -16,7 +16,8 @@ import './figure-parts.js';
16
16
  import './figure-material-panel.js';
17
17
  import './figure-section-panel.js';
18
18
  import './figure-section-editor.js';
19
- import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf } from './command-preview.js';
19
+ import { rowWordsOf } from './command-preview.js';
20
+ import { CommandPreview, NO_WORDS } from './command-preview-controller.js';
20
21
  import { motionEndsOf } from './motion-ends.js';
21
22
  import './figure-links.js';
22
23
  import './figure-controls.js';
@@ -26,13 +27,17 @@ import './figure-settings.js';
26
27
  import { DEFAULT_VIEW } from './figure-view.js';
27
28
  import './figure-capability-panel.js';
28
29
  import { gizmoChanges, gizmoReachOf } from './gizmo.js';
29
- import { keyActionOf } from './keys.js';
30
+ import { ModellerKeys } from './modeller-keys.js';
31
+ import { PaneFold } from './pane-fold.js';
32
+ import { PanelMemory } from './panel-memory.js';
33
+ import { RefusalTeller } from './refusal-teller.js';
30
34
  import { groupFields, ownerOf, partLabelOf, repeatOf } from './fields.js';
31
35
  import { SidePanelStyles } from './side-panel-styles.js';
36
+ import { EditorStyles } from './editor-styles.js';
32
37
  import { ScrollbarStyles } from '@operato/styles';
33
38
  import { alignChanges, canMirror, mirrorChanges } from './align.js';
34
39
  import { paletteRecipe } from './palette.js';
35
- import { meshChoicesOf, meshPartIdOf, meshPartOf } from './mesh-import.js';
40
+ import { meshChoicesOf, meshPartOf } from './mesh-import.js';
36
41
  import './figure-mesh-choice.js';
37
42
  import { uploadFigureMesh } from '../graphql/index.js';
38
43
  import { slotMarksOf } from './slot-layout-host.js';
@@ -44,28 +49,12 @@ import { roomRefitRowsOf } from './occupancy.js';
44
49
  import { editRefusalOf, inDeclaredRoom, narrowingRetryOf, partNameRefusalOf, partRefusalOf, placedRefusalOf, recipeRefusalOf, removalNoticeOf } from './refusals.js';
45
50
  import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
46
51
  import { jointFanOf } from './joint-fan.js';
47
- import { derivedHeld, derivedOf } from './revision.js';
52
+ import { derivedOf } from './revision.js';
48
53
  import { jointDragOf } from './joint-drag.js';
49
54
  import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './live.js';
50
55
  import { startingShapeKindOfBody } from './starting-shape.js';
51
- /** Whether the narrow fold was said in this browser before; true from now on. Storage refused: said, as before. */
52
- function foldSaid() {
53
- try {
54
- if (localStorage.getItem('figure-fold-said'))
55
- return true;
56
- localStorage.setItem('figure-fold-said', '1');
57
- }
58
- catch {
59
- /* No storage here: said every time, as it was. */
60
- }
61
- return false;
62
- }
63
56
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
64
- /** The words a panel is given when there are none: one array, so it does not read as a change. */
65
- const NO_WORDS = [];
66
57
  const NO_OVERHANG = { words: NO_WORDS, note: NO_WORDS };
67
- /** How long a command's candidate rests before its preview is worked out. */
68
- const PREVIEW_SETTLE_MS = 150;
69
58
  let FigureEditor = class FigureEditor extends LitElement {
70
59
  constructor() {
71
60
  super(...arguments);
@@ -99,58 +88,36 @@ let FigureEditor = class FigureEditor extends LitElement {
99
88
  /** The part whose drawn section is open in the section editor (ADR-0100 §3.2), or null. */
100
89
  this.sectionEditing = null;
101
90
  this.lastContext = null;
102
- /** The modeller's keys (`keys.ts`): undo, redo, duplicate, remove, back to the whole figure, the gizmo's modes. */
103
- this.onKey = (e) => {
104
- const view = this.session?.read();
105
- if (!view || view.loading || view.saving || !view.context || !this.isConnected)
106
- return;
107
- /*
108
- * The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
109
- * Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
110
- */
111
- if (this.sectionEditing)
112
- return;
113
- const canvas = this.renderRoot.querySelector('figure-canvas');
114
- const path = e.composedPath();
115
- const action = keyActionOf({ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: path[0], inScene: !!canvas && path.includes(canvas) }, this.selected);
116
- if (!action)
117
- return;
118
- e.preventDefault();
119
- /*
120
- The session's history is the only one. With the canvas focused the scene took Ctrl+Z as well, undoing on its own
121
- board the last value the canvas had written -- which the canvas then told as a drag: an undo of a drag on a
122
- part placed by a rule wrote the drag again (2026-09-25). Heard first (capture) and kept from the scene.
123
- */
124
- e.stopPropagation();
125
- if (action === 'translate' || action === 'rotate' || action === 'scale')
126
- canvas && this.useGizmo(action);
127
- else if (action === 'undo')
128
- view.canUndo && this.run(() => this.session.undo());
129
- else if (action === 'redo')
130
- view.canRedo && this.run(() => this.session.redo());
131
- else if (action === 'duplicate')
132
- this.duplicatePart(this.selected);
133
- else if (action === 'remove')
134
- this.removePart(this.selected);
135
- else
136
- this.selected = '';
137
- };
138
- this.foldedForWidth = false;
139
- this.keptOpenByAuthor = false;
140
- this.widthWatch = new ResizeObserver(([entry]) => this.fitPanesTo(entry.contentRect.width));
141
- /**
142
- * Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
143
- * notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
144
- * delete and fit the space again, narrow the range -- asks in the app's prompt and goes on when confirmed; any
145
- * other refusal is the app's notification, which stays until it is closed.
146
- */
147
- this.toldViewError = '';
148
- /*
149
- Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
150
- 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
151
- hide which part the panel speaks of. Another model starts clean.
152
- */
153
- this.panelMemory = new Map();
91
+ this.commandPreview = new CommandPreview(this, {
92
+ session: () => this.session,
93
+ selected: () => this.selected,
94
+ shown: () => this.shown(),
95
+ formsInSight: () => this.middle === 'edit' && this.tab === 'properties',
96
+ said: error => this.said(error)
97
+ });
98
+ this.keys = new ModellerKeys(this, {
99
+ taking: () => {
100
+ const view = this.session?.read();
101
+ /*
102
+ * The section editor open takes the keys for its own: Esc cancels it, Delete deletes a point, Ctrl+Z undoes a point.
103
+ * Heard here first, they deselected the part, deleted the part and undid the figure's last step (2026-09-30).
104
+ */
105
+ return !!view && !view.loading && !view.saving && !!view.context && this.isConnected && !this.sectionEditing;
106
+ },
107
+ scene: () => this.renderRoot.querySelector('figure-canvas'),
108
+ selected: () => this.selected,
109
+ act: action => this.keyAction(action)
110
+ });
111
+ this.paneFold = new PaneFold(this, () => this.panes);
112
+ this.refusals = new RefusalTeller({
113
+ session: () => this.session,
114
+ error: () => this.error,
115
+ notice: () => this.notice,
116
+ retry: () => this.retry,
117
+ refuse: message => (this.error = message),
118
+ command: action => this.linkCommand(action)
119
+ });
120
+ this.panelMemory = new PanelMemory(() => this.renderRoot);
154
121
  /** The preview is made when first asked for, and kept (hidden) after that; it is taken down with the editor. */
155
122
  this.previewMounted = false;
156
123
  this.lastAnchors = {};
@@ -163,310 +130,7 @@ let FigureEditor = class FigureEditor extends LitElement {
163
130
  }
164
131
  static { FigureEditor_1 = this; }
165
132
  /* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
166
- static { this.styles = [ScrollbarStyles, SidePanelStyles, css `
167
- :host {
168
- display: flex;
169
- flex-direction: column;
170
- min-height: 0;
171
- height: 100%;
172
- /* The editor folds by the width it is given, not the window's (UX ruling, 2026-09-24). */
173
- container-type: inline-size;
174
- font: 13px sans-serif;
175
- color: var(--md-sys-color-on-surface);
176
- }
177
- /* The bar over the work is ox-canvas-bar (ADR-0060 decision 8): the folds at its edges, undo, redo and
178
- fit in the suite's places. What the editor puts in it - the mode switch, the status, save - is styled here. */
179
- ox-canvas-bar button[mode] {
180
- height: 28px;
181
- padding: 0 10px;
182
- border-radius: 999em;
183
- border-color: transparent;
184
- background: none;
185
- }
186
- ox-canvas-bar button[mode][aria-pressed='true'] {
187
- border-color: var(--md-sys-color-primary);
188
- }
189
- /* The gizmo's tools, drawn as the bar's own icon buttons (undo, redo, fit). */
190
- ox-canvas-bar .gizmo-tools {
191
- display: inline-flex;
192
- gap: 2px;
193
- padding-left: 4px;
194
- border-left: 1px solid var(--md-sys-color-outline-variant);
195
- }
196
- ox-canvas-bar button[tool] {
197
- display: inline-grid;
198
- place-items: center;
199
- width: 32px;
200
- height: 32px;
201
- padding: 0;
202
- border: none;
203
- border-radius: var(--md-sys-shape-corner-small, 8px);
204
- background: transparent;
205
- color: var(--md-sys-color-on-surface-variant);
206
- cursor: pointer;
207
- }
208
- ox-canvas-bar button[tool]:hover:not(:disabled) {
209
- background: var(--md-sys-color-surface-container-high);
210
- color: var(--md-sys-color-on-surface);
211
- }
212
- ox-canvas-bar button[tool][aria-pressed='true'] {
213
- background: var(--md-sys-color-secondary-container);
214
- color: var(--md-sys-color-on-secondary-container);
215
- }
216
- ox-canvas-bar button[tool]:disabled {
217
- opacity: 0.38;
218
- cursor: default;
219
- }
220
- /*
221
- Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
222
- showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
223
- */
224
- ox-canvas-bar button[tool] md-icon {
225
- --md-icon-size: 20px;
226
- }
227
- ox-canvas-bar button[tool]:focus-visible {
228
- outline: 2px solid var(--md-sys-color-primary);
229
- outline-offset: 1px;
230
- }
231
- figure-preview {
232
- min-width: 0;
233
- min-height: 0;
234
- }
235
- /* The edit canvas stays standing while the preview is in front (a 3D scene costs a third of a second to make), out of sight. */
236
- div[middle][hidden] {
237
- display: none;
238
- }
239
- /* The preview, once made, stays standing out of sight too: its 3D scene is not made again each time it is asked for. */
240
- figure-preview[hidden] {
241
- display: none;
242
- }
243
- ox-canvas-bar .status {
244
- padding: 0 8px;
245
- color: var(--md-sys-color-on-surface-variant);
246
- }
247
- ox-canvas-bar .status[dirty] {
248
- color: var(--md-sys-color-primary);
249
- font-weight: 600;
250
- }
251
- ox-canvas-bar button[save] {
252
- height: 32px;
253
- padding: 0 14px;
254
- border: none;
255
- border-radius: 999em;
256
- color: var(--md-sys-color-on-primary);
257
- background: var(--md-sys-color-primary);
258
- }
259
- /* The three panes are ox-three-pane's (ADR-0060 decision 8): widths 200 | 1fr | 340, 160 | 300 under
260
- 860px and one column with the middle first under 560px are the frame's, as are the dividers. */
261
- ox-three-pane {
262
- flex: 1;
263
- min-height: 0;
264
- }
265
- figure-parts {
266
- min-height: 0;
267
- }
268
- figure-canvas {
269
- flex: 1;
270
- min-width: 0;
271
- min-height: 0;
272
- }
273
- div[middle] {
274
- position: relative;
275
- display: flex;
276
- min-width: 0;
277
- min-height: 0;
278
- }
279
- /* A new figure's first question, over the empty canvas. */
280
- div[start] {
281
- position: absolute;
282
- inset: 50% auto auto 50%;
283
- z-index: 10;
284
- color: var(--md-sys-color-on-surface);
285
- transform: translate(-50%, -50%);
286
- width: min(440px, calc(100% - 32px));
287
- box-sizing: border-box;
288
- padding: 20px;
289
- border-radius: 12px;
290
- background: var(--md-sys-color-surface, #fff);
291
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
292
- }
293
- div[start] button[start-button] {
294
- width: auto;
295
- margin: 0;
296
- font-size: var(--ui-t-body);
297
- height: 36px;
298
- padding: 0 16px;
299
- border: 1px solid var(--md-sys-color-outline);
300
- border-radius: 999em;
301
- color: var(--md-sys-color-primary);
302
- background: var(--md-sys-color-surface, #fff);
303
- cursor: pointer;
304
- }
305
- div[start] button[start-button]:hover:not(:disabled) {
306
- background: var(--md-sys-color-primary-container);
307
- }
308
- div[start] button[start-button][pressed] {
309
- color: var(--md-sys-color-on-primary);
310
- background: var(--md-sys-color-primary);
311
- border-color: var(--md-sys-color-primary);
312
- }
313
- div[start] button[start-button]:disabled {
314
- opacity: 0.5;
315
- cursor: default;
316
- }
317
- div[start] h2 {
318
- margin: 0 0 12px;
319
- font-size: var(--ui-t-title);
320
- }
321
- div[start-paths] {
322
- display: grid;
323
- gap: 14px;
324
- }
325
- div[start-paths] h3 {
326
- margin: 0 0 6px;
327
- font-size: var(--ui-t-body);
328
- }
329
- div[start-shapes] {
330
- display: flex;
331
- gap: 8px;
332
- flex-wrap: wrap;
333
- }
334
- div[start] small {
335
- display: block;
336
- margin-top: 4px;
337
- color: var(--md-sys-color-on-surface-variant);
338
- }
339
- /* The side: tabs over one panel at a time, each using the whole height. */
340
- aside {
341
- display: flex;
342
- flex-direction: column;
343
- min-height: 0;
344
- background: var(--md-sys-color-surface-container-lowest);
345
- }
346
- div[tabs] {
347
- display: flex;
348
- flex: none;
349
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
350
- }
351
- button[tab] {
352
- flex: 1;
353
- position: relative;
354
- display: flex;
355
- flex-direction: column;
356
- align-items: center;
357
- justify-content: center;
358
- gap: 1px;
359
- min-width: 0;
360
- height: 48px;
361
- padding: 0;
362
- border: none;
363
- border-radius: 0;
364
- border-bottom: 2px solid transparent;
365
- background: none;
366
- color: var(--md-sys-color-on-surface-variant);
367
- }
368
- button[tab]:hover:not([on]) {
369
- background: var(--md-sys-color-surface-container);
370
- }
371
- button[tab][on] {
372
- color: var(--md-sys-color-primary);
373
- border-bottom-color: var(--md-sys-color-primary);
374
- }
375
- button[tab] md-icon {
376
- font-size: 20px;
377
- width: 20px;
378
- height: 20px;
379
- }
380
- button[tab] span[tab-label] {
381
- max-width: 100%;
382
- overflow: hidden;
383
- font-size: 0.66rem;
384
- line-height: 1.2;
385
- white-space: nowrap;
386
- text-overflow: ellipsis;
387
- }
388
- span[badge] {
389
- position: absolute;
390
- top: 3px;
391
- right: 2px;
392
- padding: 0 5px;
393
- font-size: 0.62rem;
394
- font-weight: 700;
395
- line-height: 1.5;
396
- color: var(--md-sys-color-on-primary-container);
397
- background: var(--md-sys-color-primary-container);
398
- border-radius: 999em;
399
- pointer-events: none;
400
- }
401
- /* The sections inside carry the margins (side-panel-styles); the panel only scrolls. */
402
- div[panel] {
403
- flex: 1;
404
- min-height: 0;
405
- overflow: auto;
406
- padding: 0;
407
- }
408
- div[panel][flush] {
409
- padding: 0;
410
- }
411
- /* Hidden, not removed: a panel keeps what it holds (the verdict its last answer) while another tab is open. */
412
- [off] {
413
- display: none !important;
414
- }
415
- /* Which part the panel speaks of. */
416
- div[subject] {
417
- position: sticky;
418
- top: 0;
419
- z-index: 1;
420
- display: grid;
421
- gap: 4px;
422
- padding: 10px 12px;
423
- background: var(--md-sys-color-surface-container-lowest);
424
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
425
- }
426
- /* The panel's three sizes (side-panel-styles): the part's kind at the title's, its two lines at the hint's. */
427
- div[subject-summary] {
428
- display: grid;
429
- gap: 2px;
430
- }
431
- div[subject-summary] b {
432
- font-size: var(--ui-t-body, 13px);
433
- font-weight: 600;
434
- }
435
- div[subject-summary] b span {
436
- font-weight: 400;
437
- color: var(--md-sys-color-on-surface-variant);
438
- }
439
- div[subject-summary] small {
440
- color: var(--md-sys-color-on-surface-variant);
441
- }
442
- button {
443
- font: inherit;
444
- box-sizing: border-box;
445
- height: 32px;
446
- border: 1px solid var(--md-sys-color-outline-variant);
447
- border-radius: 6px;
448
- background: var(--md-sys-color-surface);
449
- color: inherit;
450
- padding: 0 8px;
451
- min-width: 0;
452
- }
453
- button {
454
- cursor: pointer;
455
- }
456
- button:disabled {
457
- opacity: 0.45;
458
- cursor: default;
459
- }
460
- button[aria-pressed='true'] {
461
- background: var(--md-sys-color-secondary-container);
462
- color: var(--md-sys-color-on-secondary-container);
463
- border-color: var(--md-sys-color-primary);
464
- }
465
- .hint {
466
- color: var(--md-sys-color-on-surface-variant);
467
- line-height: 1.5;
468
- }
469
- `]; }
133
+ static { this.styles = [ScrollbarStyles, SidePanelStyles, EditorStyles]; }
470
134
  static storedView() {
471
135
  try {
472
136
  const stored = JSON.parse(localStorage.getItem('figure-view') ?? 'null');
@@ -510,115 +174,21 @@ let FigureEditor = class FigureEditor extends LitElement {
510
174
  </button>`)}
511
175
  </span>`;
512
176
  }
513
- connectedCallback() {
514
- super.connectedCallback();
515
- window.addEventListener('keydown', this.onKey, true);
516
- this.widthWatch.observe(this);
517
- }
518
- disconnectedCallback() {
519
- window.removeEventListener('keydown', this.onKey, true);
520
- this.widthWatch.disconnect();
521
- clearTimeout(this.previewTimer);
522
- clearTimeout(this.detachTimer);
523
- super.disconnectedCallback();
524
- }
525
- /*
526
- Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
527
- FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
528
- bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
529
- Said once in this browser, not on every opening (2026-10-02): the list folds as before, the notice does not come back.
530
- */
531
- static { this.FOLD_START_UNDER = 1100; }
532
- fitPanesTo(width) {
533
- const panes = this.panes;
534
- if (!panes || !width)
535
- return;
536
- if (this.foldedForWidth && !panes.foldStart)
537
- this.keptOpenByAuthor = true;
538
- if (width < FigureEditor_1.FOLD_START_UNDER) {
539
- if (!panes.foldStart && !this.keptOpenByAuthor) {
540
- panes.foldStart = true;
541
- this.foldedForWidth = true;
542
- if (!foldSaid())
543
- notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') });
544
- }
545
- }
546
- else {
547
- if (this.foldedForWidth && panes.foldStart)
548
- panes.foldStart = false;
549
- this.foldedForWidth = false;
550
- this.keptOpenByAuthor = false;
551
- }
552
- }
553
- tell(changed) {
554
- if (changed.has('error') && this.error) {
555
- const retry = this.retry;
556
- if (retry)
557
- this.ask(this.error, retry);
558
- else
559
- notify({ level: 'error', message: this.error });
560
- }
561
- if (changed.has('notice') && this.notice)
562
- notify({ level: 'info', message: this.notice });
563
- const viewError = this.session?.read()?.error ?? '';
564
- if (viewError && viewError !== this.toldViewError)
565
- notify({ level: 'error', message: viewError });
566
- this.toldViewError = viewError;
567
- }
568
- /*
569
- An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
570
- which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
571
- step undo brings back (designer 2026-09-27).
572
- */
573
- askClipLoss(error, again) {
574
- if (error?.code !== 'CLIPS_DROPPED')
575
- return false;
576
- const clips = error.clips;
577
- /* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
578
- const session = this.session;
579
- const { context, revision } = session.read();
580
- void this.confirmClipLoss(clips, again, { session, context: context, revision: revision });
581
- return true;
582
- }
583
- async confirmClipLoss(clips, again, asked) {
584
- const agreed = await OxPrompt.open({
585
- type: 'warning',
586
- text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
587
- confirmButton: { text: i18next.t('figure.button.let-clips-go') },
588
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
589
- });
590
- if (!agreed)
591
- return;
592
- /* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
593
- const now = this.session === asked.session ? asked.session.read() : undefined;
594
- if (!now || now.context !== asked.context || now.revision !== asked.revision) {
595
- this.error = i18next.t('figure.text.clips-consent-stale');
596
- return;
597
- }
598
- asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again);
599
- }
600
- async ask(message, retry) {
601
- const agreed = await OxPrompt.open({
602
- type: 'question',
603
- text: message,
604
- confirmButton: { text: retry.label },
605
- cancelButton: { text: i18next.t('figure.button.keep-editing') }
606
- });
607
- if (!agreed)
608
- return;
609
- if (retry.run)
610
- retry.run();
177
+ keyAction(action) {
178
+ const view = this.session.read();
179
+ const canvas = this.renderRoot.querySelector('figure-canvas');
180
+ if (action === 'translate' || action === 'rotate' || action === 'scale')
181
+ canvas && this.useGizmo(action);
182
+ else if (action === 'undo')
183
+ view.canUndo && this.run(() => this.session.undo());
184
+ else if (action === 'redo')
185
+ view.canRedo && this.run(() => this.session.redo());
186
+ else if (action === 'duplicate')
187
+ this.duplicatePart(this.selected);
188
+ else if (action === 'remove')
189
+ this.removePart(this.selected);
611
190
  else
612
- this.linkCommand(retry.action);
613
- }
614
- propertiesPanel() {
615
- return this.renderRoot.querySelector('div[panel]');
616
- }
617
- foldsOf(panel) {
618
- const folds = [...panel.querySelectorAll('details')];
619
- for (const inner of panel.querySelectorAll('figure-links'))
620
- folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []));
621
- return folds;
191
+ this.selected = '';
622
192
  }
623
193
  willUpdate(_changed) {
624
194
  if (this.middle === 'preview')
@@ -632,11 +202,7 @@ let FigureEditor = class FigureEditor extends LitElement {
632
202
  this.panelMemory.clear();
633
203
  }
634
204
  else if (_changed.has('selected')) {
635
- const panel = this.propertiesPanel();
636
- const was = _changed.get('selected') ?? '';
637
- if (panel)
638
- this.panelMemory.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) });
639
- this.restorePanelFor = this.selected || '@model';
205
+ this.panelMemory.leave(_changed.get('selected') ?? '', this.selected);
640
206
  }
641
207
  if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected))
642
208
  this.selected = '';
@@ -697,7 +263,7 @@ let FigureEditor = class FigureEditor extends LitElement {
697
263
  this.changed();
698
264
  }
699
265
  catch (error) {
700
- if (this.askClipLoss(error, () => this.run(action)))
266
+ if (this.refusals.askClipLoss(error, () => this.run(action)))
701
267
  return;
702
268
  this.error = this.said(error);
703
269
  }
@@ -737,14 +303,6 @@ let FigureEditor = class FigureEditor extends LitElement {
737
303
  shown() {
738
304
  return this.session?.revisionNow();
739
305
  }
740
- /**
741
- * The figure's parts by id, one per authored part as the parts list shows them. Not the drawn bodies: a row's copies
742
- * are drawn under ids of their own, so a part made into a row was missing from those and its id was offered to the
743
- * next part, which figure-model refused (DUPLICATE_PART).
744
- */
745
- partIds() {
746
- return new Set(this.session?.revisionNow()?.asset.appearance.map(look => look.target) ?? []);
747
- }
748
306
  /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
749
307
  hold(values, edit) {
750
308
  this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) };
@@ -802,11 +360,8 @@ let FigureEditor = class FigureEditor extends LitElement {
802
360
  if (!this.session)
803
361
  return;
804
362
  this.run(() => {
805
- const ids = this.partIds();
806
- let n = 1;
807
- while (ids.has(`${kind}-${n}`))
808
- n++;
809
- const part = paletteRecipe(kind, `${kind}-${n}`);
363
+ /* The id is figure-model's to choose: what is in use is its rule, not a list the screen keeps. */
364
+ const part = paletteRecipe(kind, figurePartIdFor(this.session.revisionNow().asset, kind));
810
365
  this.session.add(part);
811
366
  this.selected = part.id;
812
367
  });
@@ -840,8 +395,7 @@ let FigureEditor = class FigureEditor extends LitElement {
840
395
  return;
841
396
  const node = upload.summary.nodes.find(n => n.index === choice.node);
842
397
  this.run(() => {
843
- const taken = this.partIds();
844
- const id = meshPartIdOf(node?.name ?? file.name.replace(/\.glb$/i, ''), taken);
398
+ const id = figurePartIdFor(this.session.revisionNow().asset, node?.name ?? file.name.replace(/\.glb$/i, ''));
845
399
  this.session.add(meshPartOf(upload, choice, id));
846
400
  this.selected = id;
847
401
  });
@@ -852,9 +406,8 @@ let FigureEditor = class FigureEditor extends LitElement {
852
406
  */
853
407
  duplicatePart(id) {
854
408
  this.run(() => {
855
- const before = this.partIds();
856
- this.session.edit({ kind: 'duplicate-part', target: id });
857
- this.selected = [...this.partIds()].find(part => !before.has(part)) ?? id;
409
+ /* The copy is the part the edit made, as figure-model named it. */
410
+ this.selected = this.session.edit({ kind: 'duplicate-part', target: id }).made?.[0] ?? id;
858
411
  });
859
412
  }
860
413
  movePart(id, to) {
@@ -872,7 +425,7 @@ let FigureEditor = class FigureEditor extends LitElement {
872
425
  this.changed();
873
426
  }
874
427
  catch (error) {
875
- if (this.askClipLoss(error, () => this.removePart(id, refitRoom)))
428
+ if (this.refusals.askClipLoss(error, () => this.removePart(id, refitRoom)))
876
429
  return;
877
430
  /*
878
431
  A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
@@ -1053,9 +606,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1053
606
  mirror(axis) {
1054
607
  this.run(() => {
1055
608
  const id = this.selected;
1056
- const before = this.partIds();
1057
- this.session.edit({ kind: 'duplicate-part', target: id });
1058
- const copy = [...this.partIds()].find(part => !before.has(part));
609
+ const copy = this.session.edit({ kind: 'duplicate-part', target: id }).made?.[0];
1059
610
  if (!copy)
1060
611
  return;
1061
612
  this.session.apply(mirrorChanges(this.shown(), id, copy, axis));
@@ -1165,15 +716,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1165
716
  this.error = partNameRefusalOf(error, (key, params) => i18next.t(key, params)) ?? this.said(error);
1166
717
  }
1167
718
  }
1168
- previewWordsNow() {
1169
- const preview = this.currentPreview();
1170
- const language = i18next.language;
1171
- if (this.previewWordsSaid && this.previewWordsSaid.preview === preview && this.previewWordsSaid.part === this.selected && this.previewWordsSaid.language === language)
1172
- return this.previewWordsSaid.words;
1173
- const words = previewWordsOf(preview, this.selected, (key, params) => i18next.t(key, params));
1174
- this.previewWordsSaid = { preview, part: this.selected, language, words };
1175
- return words;
1176
- }
719
+ /** Size response, fastening and motion for the selected part, as authoring commands. */
1177
720
  renderLinks(unavailable, section) {
1178
721
  const shown = this.shown();
1179
722
  if (!shown)
@@ -1184,13 +727,13 @@ let FigureEditor = class FigureEditor extends LitElement {
1184
727
  .partId=${this.selected}
1185
728
  ?locked=${unavailable}
1186
729
  @command=${(e) => this.linkCommand(e.detail.action)}
1187
- .previewWords=${this.previewWordsNow()}
1188
- .detachWords=${this.detachWords(shown)}
730
+ .previewWords=${this.commandPreview.words()}
731
+ .detachWords=${this.commandPreview.detachWords(shown)}
1189
732
  .overhangWords=${section === 'shape' ? this.overhangOf(shown).words : NO_WORDS}
1190
733
  .overhangNote=${section === 'shape' ? this.overhangOf(shown).note : NO_WORDS}
1191
734
  .motionEnds=${section === 'motion' ? this.motionEnds() : null}
1192
735
  @go-fasten=${() => this.goFasten()}
1193
- @preview-command=${(e) => this.previewCommand(e.detail)}
736
+ @preview-command=${(e) => this.commandPreview.ask(e.detail)}
1194
737
  @part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
1195
738
  ></figure-links>`;
1196
739
  }
@@ -1271,7 +814,7 @@ let FigureEditor = class FigureEditor extends LitElement {
1271
814
  return true;
1272
815
  }
1273
816
  catch (error) {
1274
- if (this.askClipLoss(error, () => this.linkCommand(action, from)))
817
+ if (this.refusals.askClipLoss(error, () => this.linkCommand(action, from)))
1275
818
  return false;
1276
819
  const code = error?.code;
1277
820
  /* Something stands on a face this takes away: ask where it goes, there, rather than only saying no. */
@@ -1672,108 +1215,6 @@ let FigureEditor = class FigureEditor extends LitElement {
1672
1215
  return i18next.t('figure.text.release-state-released', { version: String(version) });
1673
1216
  return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new');
1674
1217
  }
1675
- previewCommand(candidate) {
1676
- clearTimeout(this.previewTimer);
1677
- if (!candidate || !this.session || !this.selected) {
1678
- this.preview = undefined;
1679
- return;
1680
- }
1681
- const view = this.session.read();
1682
- const same = this.preview && this.preview.key === JSON.stringify(candidate) && this.preview.context === view.context && this.preview.part === this.selected && 'revision' in this.preview && this.preview.revision === view.revision;
1683
- if (same)
1684
- return;
1685
- this.previewTimer = setTimeout(() => this.computePreview(candidate), PREVIEW_SETTLE_MS);
1686
- }
1687
- computePreview(candidate) {
1688
- if (!this.session || !this.selected)
1689
- return;
1690
- const view = this.session.read();
1691
- const held = { context: view.context, revision: view.revision, key: JSON.stringify(candidate), part: this.selected };
1692
- try {
1693
- const seen = this.session.previewCommand(candidate.action);
1694
- let face;
1695
- if (candidate.face) {
1696
- try {
1697
- const shown = this.shown();
1698
- face = figurePartFaceOf(shown.asset, candidate.face.part, candidate.face.face, shown.inputs);
1699
- }
1700
- catch {
1701
- face = undefined;
1702
- }
1703
- }
1704
- /* A row is read on the figure the command would make, by figure-model, as drawn. */
1705
- const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.shown().inputs) : undefined;
1706
- this.preview = {
1707
- ...held,
1708
- context: seen.context,
1709
- revision: seen.revision,
1710
- geometry: seen.geometry,
1711
- moves: seen.moves,
1712
- added: seen.added,
1713
- ...(row ? { row } : {}),
1714
- clipsWouldGo: seen.clipsWouldGo,
1715
- face,
1716
- faceUnknown: !!candidate.face && !face
1717
- };
1718
- }
1719
- catch (error) {
1720
- /* Refused: nothing of an earlier result stays on screen, and the reason is said. */
1721
- this.preview = { ...held, refused: this.said(error) };
1722
- }
1723
- }
1724
- /** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
1725
- currentPreview() {
1726
- const p = this.preview;
1727
- const view = this.session?.read();
1728
- /* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
1729
- if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties')
1730
- return undefined;
1731
- return p;
1732
- }
1733
- /**
1734
- * Said once the figure has rested: working it out is a preview of the command on a copy (tens of milliseconds), and the
1735
- * edit that made this version should be on screen before it. Until then nothing is said, as for a part with no joint.
1736
- */
1737
- detachWords(shown) {
1738
- const part = this.selected;
1739
- if (!this.session || !shown.asset.joints?.some(j => j.body1.part === part))
1740
- return NO_WORDS;
1741
- const key = `detach|${part}`;
1742
- const said = derivedHeld(shown, key);
1743
- if (said)
1744
- return said;
1745
- if (this.detachAsked?.shown !== shown || this.detachAsked.part !== part) {
1746
- this.detachAsked = { shown, part };
1747
- clearTimeout(this.detachTimer);
1748
- this.detachTimer = setTimeout(() => {
1749
- if (this.shown() !== shown || this.selected !== part)
1750
- return;
1751
- derivedOf(shown, key, () => this.computeDetachWords(shown, part));
1752
- this.requestUpdate();
1753
- }, PREVIEW_SETTLE_MS);
1754
- }
1755
- return NO_WORDS;
1756
- }
1757
- computeDetachWords(shown, part) {
1758
- const say = (k, params) => i18next.t(k, params);
1759
- let words;
1760
- try {
1761
- const seen = this.session.previewCommand({ kind: 'detach', part });
1762
- const own = seen.moves.some(m => m.body === part);
1763
- words = own
1764
- ? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key: `detach|${part}`, moves: seen.moves }, part, say)]
1765
- : /* It stays where it is: as expected, so nothing is said (user 2026-10-01). */ [];
1766
- const children = (shown.asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === part).map(j => partLabelOf(j.body1.part));
1767
- if (children.length)
1768
- words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }));
1769
- if (seen.clipsWouldGo.length)
1770
- words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }));
1771
- }
1772
- catch (error) {
1773
- words = [say('figure.text.detach-refused', { reason: this.said(error) })];
1774
- }
1775
- return words;
1776
- }
1777
1218
  /*
1778
1219
  Past the edge of the face a part is fastened to (ADR-0097 §5.5, Q3): the selected part's own, and the parts fastened
1779
1220
  to it -- said apart from the room, nothing moved. Read once per version, part and language. The note says it is wider
@@ -1838,15 +1279,11 @@ let FigureEditor = class FigureEditor extends LitElement {
1838
1279
  }
1839
1280
  /** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
1840
1281
  previewShown() {
1841
- const p = this.currentPreview();
1282
+ const p = this.commandPreview.current();
1842
1283
  /* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
1843
1284
  if (!p && this.tab === 'motion' && this.middle === 'edit')
1844
1285
  return { ghost: this.motionEnds()?.ghost ?? [], highlight: null };
1845
- if (!p || p.refused)
1846
- return { ghost: [], highlight: null };
1847
- if (this.previewDrawn?.of !== p)
1848
- this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) };
1849
- return this.previewDrawn;
1286
+ return this.commandPreview.drawnOf(p);
1850
1287
  }
1851
1288
  /**
1852
1289
  * 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
@@ -1889,25 +1326,12 @@ let FigureEditor = class FigureEditor extends LitElement {
1889
1326
  }
1890
1327
  updated(changed) {
1891
1328
  super.updated?.(changed);
1892
- this.tell(changed);
1893
- if (this.restorePanelFor !== undefined) {
1894
- const key = this.restorePanelFor;
1895
- this.restorePanelFor = undefined;
1896
- requestAnimationFrame(async () => {
1897
- const panel = this.propertiesPanel();
1898
- if (!panel || (this.selected || '@model') !== key)
1899
- return;
1900
- await Promise.all([...panel.querySelectorAll('figure-links')].map(el => el.updateComplete));
1901
- const kept = this.panelMemory.get(key);
1902
- if (kept)
1903
- this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]));
1904
- panel.scrollTop = kept?.top ?? 0;
1905
- });
1906
- }
1329
+ this.refusals.tell(changed);
1330
+ this.panelMemory.restore(() => this.selected);
1907
1331
  const panes = this.renderRoot.querySelector('ox-three-pane') ?? undefined;
1908
1332
  if (panes !== this.panes) {
1909
1333
  this.panes = panes;
1910
- this.fitPanesTo(this.getBoundingClientRect().width);
1334
+ this.paneFold.fitTo(this.getBoundingClientRect().width);
1911
1335
  }
1912
1336
  }
1913
1337
  /**
@@ -2516,10 +1940,6 @@ __decorate([
2516
1940
  state(),
2517
1941
  __metadata("design:type", Object)
2518
1942
  ], FigureEditor.prototype, "sectionEditing", void 0);
2519
- __decorate([
2520
- state(),
2521
- __metadata("design:type", Object)
2522
- ], FigureEditor.prototype, "preview", void 0);
2523
1943
  FigureEditor = FigureEditor_1 = __decorate([
2524
1944
  customElement('figure-editor')
2525
1945
  ], FigureEditor);