@things-factory/figure-ui 10.1.81 → 10.1.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/figure-assistant.ts +2 -2
- package/client/graphql/index.ts +99 -10
- package/client/graphql/reached.ts +25 -0
- package/client/graphql/store.ts +27 -4
- package/client/modeller/ai-proposal.ts +74 -9
- package/client/modeller/align.ts +2 -3
- package/client/modeller/canvas.ts +18 -5
- package/client/modeller/command-preview.ts +113 -0
- package/client/modeller/editor.ts +826 -54
- package/client/modeller/fields.ts +12 -4
- package/client/modeller/figure-facts.ts +2 -2
- package/client/modeller/figure-first-save.ts +36 -14
- package/client/modeller/figure-history-panel.ts +133 -14
- package/client/modeller/figure-links.ts +385 -68
- package/client/modeller/figure-parts.ts +56 -2
- package/client/modeller/figure-preview.ts +19 -1
- package/client/modeller/figure-properties.ts +3 -3
- package/client/modeller/figure-revert-choice.ts +49 -0
- package/client/modeller/first-save.ts +16 -0
- package/client/modeller/gizmo.ts +0 -16
- package/client/modeller/links.ts +142 -15
- package/client/modeller/motion-ends.ts +83 -0
- package/client/modeller/occupancy.ts +28 -2
- package/client/modeller/product-session.ts +154 -18
- package/client/modeller/refusals.ts +15 -0
- package/client/modeller/revert-request.ts +26 -0
- package/client/modeller/side-panel-styles.ts +12 -0
- package/client/modeller/stage-model.ts +25 -3
- package/client/pages/figure-modeller-page.ts +68 -29
- package/dist-client/figure-assistant.js +2 -2
- package/dist-client/figure-assistant.js.map +1 -1
- package/dist-client/graphql/index.d.ts +39 -2
- package/dist-client/graphql/index.js +63 -9
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/graphql/reached.d.ts +10 -0
- package/dist-client/graphql/reached.js +28 -0
- package/dist-client/graphql/reached.js.map +1 -0
- package/dist-client/graphql/store.js +27 -4
- package/dist-client/graphql/store.js.map +1 -1
- package/dist-client/modeller/ai-proposal.d.ts +31 -2
- package/dist-client/modeller/ai-proposal.js +65 -6
- package/dist-client/modeller/ai-proposal.js.map +1 -1
- package/dist-client/modeller/align.d.ts +1 -1
- package/dist-client/modeller/align.js +0 -2
- package/dist-client/modeller/align.js.map +1 -1
- package/dist-client/modeller/canvas.d.ts +11 -0
- package/dist-client/modeller/canvas.js +33 -4
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/command-preview.d.ts +35 -0
- package/dist-client/modeller/command-preview.js +95 -0
- package/dist-client/modeller/command-preview.js.map +1 -0
- package/dist-client/modeller/editor.d.ts +113 -1
- package/dist-client/modeller/editor.js +850 -50
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fields.d.ts +3 -2
- package/dist-client/modeller/fields.js +10 -3
- package/dist-client/modeller/fields.js.map +1 -1
- package/dist-client/modeller/figure-facts.js +2 -2
- package/dist-client/modeller/figure-facts.js.map +1 -1
- package/dist-client/modeller/figure-first-save.d.ts +3 -4
- package/dist-client/modeller/figure-first-save.js +41 -14
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.d.ts +31 -0
- package/dist-client/modeller/figure-history-panel.js +130 -14
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +51 -1
- package/dist-client/modeller/figure-links.js +392 -72
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-parts.d.ts +7 -0
- package/dist-client/modeller/figure-parts.js +55 -2
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +6 -0
- package/dist-client/modeller/figure-preview.js +22 -1
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.js +3 -3
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
- package/dist-client/modeller/figure-revert-choice.js +59 -0
- package/dist-client/modeller/figure-revert-choice.js.map +1 -0
- package/dist-client/modeller/first-save.d.ts +7 -0
- package/dist-client/modeller/first-save.js +17 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/gizmo.d.ts +0 -6
- package/dist-client/modeller/gizmo.js +0 -18
- package/dist-client/modeller/gizmo.js.map +1 -1
- package/dist-client/modeller/links.d.ts +49 -4
- package/dist-client/modeller/links.js +141 -15
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/motion-ends.d.ts +12 -0
- package/dist-client/modeller/motion-ends.js +65 -0
- package/dist-client/modeller/motion-ends.js.map +1 -0
- package/dist-client/modeller/occupancy.d.ts +14 -1
- package/dist-client/modeller/occupancy.js +14 -1
- package/dist-client/modeller/occupancy.js.map +1 -1
- package/dist-client/modeller/product-session.d.ts +43 -2
- package/dist-client/modeller/product-session.js +146 -12
- package/dist-client/modeller/product-session.js.map +1 -1
- package/dist-client/modeller/refusals.d.ts +13 -0
- package/dist-client/modeller/refusals.js +16 -0
- package/dist-client/modeller/refusals.js.map +1 -1
- package/dist-client/modeller/revert-request.d.ts +16 -0
- package/dist-client/modeller/revert-request.js +11 -0
- package/dist-client/modeller/revert-request.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.js +12 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +18 -2
- package/dist-client/modeller/stage-model.js +21 -3
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/pages/figure-modeller-page.d.ts +8 -1
- package/dist-client/pages/figure-modeller-page.js +72 -34
- package/dist-client/pages/figure-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/test/ai-proposal.test.ts +2 -2
- package/test/align.test.ts +50 -5
- package/test/browser/acceptance-page.js +187 -0
- package/test/browser/build.mjs +5 -1
- package/test/browser/revert-page.js +47 -0
- package/test/browser/serve-acceptance.mjs +85 -0
- package/test/browser/serve-revert.mjs +119 -0
- package/test/clip-ai-proposal.test.ts +104 -0
- package/test/clip-consent.test.ts +88 -0
- package/test/command-preview.test.ts +288 -0
- package/test/fields.test.ts +4 -3
- package/test/figure-assistant-surface.test.ts +4 -1
- package/test/first-minute.test.ts +96 -0
- package/test/first-save-suggest.test.ts +42 -0
- package/test/gizmo.test.ts +20 -16
- package/test/joint-fan.test.ts +2 -1
- package/test/layout.test.ts +9 -2
- package/test/links.test.ts +7 -4
- package/test/proposal-record.test.ts +126 -0
- package/test/release-entry.test.ts +28 -0
- package/test/release-panel.test.ts +3 -2
- package/test/resize-with-placement.test.ts +225 -0
- package/test/revert-choice.test.ts +39 -0
- package/test/revert-keeping.test.ts +102 -0
- package/test/save-landed.test.ts +198 -0
- package/translations/en.json +222 -10
- package/translations/ja.json +223 -11
- package/translations/ko.json +223 -11
- package/translations/ms.json +222 -10
- package/translations/zh.json +223 -11
|
@@ -7,13 +7,15 @@ import { notify } from '@operato/layout';
|
|
|
7
7
|
import { OxPrompt } from '@operato/popup';
|
|
8
8
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
9
9
|
import { live } from 'lit/directives/live.js';
|
|
10
|
-
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor } from '@hatiolab/figure-model';
|
|
10
|
+
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, planFigureResize, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, figurePartFaceOf, figureRowOf, figureFaceOverhangOf, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model';
|
|
11
11
|
import './figure-recipe-catalog.js';
|
|
12
12
|
import { captureFigureThumbnail } from './thumbnail.js';
|
|
13
13
|
import { drawableFigureParts } from './renderer.js';
|
|
14
14
|
import './canvas.js';
|
|
15
15
|
import './figure-parts.js';
|
|
16
16
|
import './figure-material-panel.js';
|
|
17
|
+
import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf } from './command-preview.js';
|
|
18
|
+
import { motionEndsOf } from './motion-ends.js';
|
|
17
19
|
import './figure-links.js';
|
|
18
20
|
import './figure-controls.js';
|
|
19
21
|
import './figure-clips.js';
|
|
@@ -21,16 +23,17 @@ import './figure-preview.js';
|
|
|
21
23
|
import './figure-settings.js';
|
|
22
24
|
import { DEFAULT_VIEW } from './figure-view.js';
|
|
23
25
|
import './figure-capability-panel.js';
|
|
24
|
-
import { gizmoChanges, gizmoReachOf
|
|
26
|
+
import { gizmoChanges, gizmoReachOf } from './gizmo.js';
|
|
25
27
|
import { keyActionOf } from './keys.js';
|
|
26
28
|
import { groupFields, ownerOf, partLabelOf, repeatOf } from './fields.js';
|
|
27
29
|
import { SidePanelStyles } from './side-panel-styles.js';
|
|
28
30
|
import { ScrollbarStyles } from '@operato/styles';
|
|
29
31
|
import { alignChanges, boxOf, canMirror, mirrorChanges } from './align.js';
|
|
30
32
|
import { paletteRecipe } from './palette.js';
|
|
31
|
-
import { nudgeOf } from './stage-model.js';
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
33
|
+
import { nudgeOf, placementOf } from './stage-model.js';
|
|
34
|
+
import { roomRefitRowsOf } from './occupancy.js';
|
|
35
|
+
import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, resizeRefusalOf, sizeRangeRefusalOf } from './refusals.js';
|
|
36
|
+
import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js';
|
|
34
37
|
import { jointFanOf } from './joint-fan.js';
|
|
35
38
|
import { jointDragOf } from './joint-drag.js';
|
|
36
39
|
import { liveOverSession, NOTHING_LIVE, scrubbed, scrubStep } from './live.js';
|
|
@@ -60,6 +63,8 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
60
63
|
* Parts hidden on the workbench — the author's view while working, not the asset's visibility. Not saved: a part
|
|
61
64
|
* hidden here is still in the figure, still checked, still drawn on a board.
|
|
62
65
|
*/
|
|
66
|
+
/** The page offers an AI draft for a new figure (the modeller page, through the app's assistant dock). */
|
|
67
|
+
this.aiDraft = false;
|
|
63
68
|
this.hiddenParts = new Set();
|
|
64
69
|
/** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
|
|
65
70
|
this.live = NOTHING_LIVE;
|
|
@@ -94,6 +99,9 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
94
99
|
else
|
|
95
100
|
this.selected = '';
|
|
96
101
|
};
|
|
102
|
+
this.foldedForWidth = false;
|
|
103
|
+
this.keptOpenByAuthor = false;
|
|
104
|
+
this.widthWatch = new ResizeObserver(([entry]) => this.fitPanesTo(entry.contentRect.width));
|
|
97
105
|
/**
|
|
98
106
|
* Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
|
|
99
107
|
* notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
|
|
@@ -101,6 +109,19 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
101
109
|
* other refusal is the app's notification, which stays until it is closed.
|
|
102
110
|
*/
|
|
103
111
|
this.toldViewError = '';
|
|
112
|
+
/*
|
|
113
|
+
Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
|
|
114
|
+
to one finds it as it was left (designer 2026-09-27, B.1). The path stays held at the top, so a scroll kept cannot
|
|
115
|
+
hide which part the panel speaks of. Another model starts clean.
|
|
116
|
+
*/
|
|
117
|
+
this.panelMemory = new Map();
|
|
118
|
+
this.lastAnchors = {};
|
|
119
|
+
/** To the release that exists: the history tab's release section, not a second one here. */
|
|
120
|
+
this.goRelease = async () => {
|
|
121
|
+
this.tab = 'history';
|
|
122
|
+
await this.updateComplete;
|
|
123
|
+
await this.querySelector('figure-history-panel')?.showRelease?.();
|
|
124
|
+
};
|
|
104
125
|
}
|
|
105
126
|
static { FigureEditor_1 = this; }
|
|
106
127
|
/* The operato scrollbar, as every scrolling panel of the app has it -- the side panel scrolled with the browser's own. */
|
|
@@ -158,6 +179,17 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
158
179
|
opacity: 0.38;
|
|
159
180
|
cursor: default;
|
|
160
181
|
}
|
|
182
|
+
/*
|
|
183
|
+
Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
|
|
184
|
+
showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
|
|
185
|
+
*/
|
|
186
|
+
ox-canvas-bar button[tool] md-icon {
|
|
187
|
+
--md-icon-size: 20px;
|
|
188
|
+
}
|
|
189
|
+
ox-canvas-bar button[tool]:focus-visible {
|
|
190
|
+
outline: 2px solid var(--md-sys-color-primary);
|
|
191
|
+
outline-offset: 1px;
|
|
192
|
+
}
|
|
161
193
|
figure-preview {
|
|
162
194
|
min-width: 0;
|
|
163
195
|
min-height: 0;
|
|
@@ -188,9 +220,76 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
188
220
|
min-height: 0;
|
|
189
221
|
}
|
|
190
222
|
figure-canvas {
|
|
223
|
+
flex: 1;
|
|
191
224
|
min-width: 0;
|
|
192
225
|
min-height: 0;
|
|
193
226
|
}
|
|
227
|
+
div[middle] {
|
|
228
|
+
position: relative;
|
|
229
|
+
display: flex;
|
|
230
|
+
min-width: 0;
|
|
231
|
+
min-height: 0;
|
|
232
|
+
}
|
|
233
|
+
/* A new figure's first question, over the empty canvas. */
|
|
234
|
+
div[start] {
|
|
235
|
+
position: absolute;
|
|
236
|
+
inset: 50% auto auto 50%;
|
|
237
|
+
z-index: 10;
|
|
238
|
+
color: var(--md-sys-color-on-surface);
|
|
239
|
+
transform: translate(-50%, -50%);
|
|
240
|
+
width: min(440px, calc(100% - 32px));
|
|
241
|
+
box-sizing: border-box;
|
|
242
|
+
padding: 20px;
|
|
243
|
+
border-radius: 12px;
|
|
244
|
+
background: var(--md-sys-color-surface, #fff);
|
|
245
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
|
246
|
+
}
|
|
247
|
+
div[start] button[start-button] {
|
|
248
|
+
width: auto;
|
|
249
|
+
margin: 0;
|
|
250
|
+
font-size: var(--ui-t-body);
|
|
251
|
+
height: 36px;
|
|
252
|
+
padding: 0 16px;
|
|
253
|
+
border: 1px solid var(--md-sys-color-outline);
|
|
254
|
+
border-radius: 999em;
|
|
255
|
+
color: var(--md-sys-color-primary);
|
|
256
|
+
background: var(--md-sys-color-surface, #fff);
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
}
|
|
259
|
+
div[start] button[start-button]:hover:not(:disabled) {
|
|
260
|
+
background: var(--md-sys-color-primary-container);
|
|
261
|
+
}
|
|
262
|
+
div[start] button[start-button][pressed] {
|
|
263
|
+
color: var(--md-sys-color-on-primary);
|
|
264
|
+
background: var(--md-sys-color-primary);
|
|
265
|
+
border-color: var(--md-sys-color-primary);
|
|
266
|
+
}
|
|
267
|
+
div[start] button[start-button]:disabled {
|
|
268
|
+
opacity: 0.5;
|
|
269
|
+
cursor: default;
|
|
270
|
+
}
|
|
271
|
+
div[start] h2 {
|
|
272
|
+
margin: 0 0 12px;
|
|
273
|
+
font-size: var(--ui-t-title);
|
|
274
|
+
}
|
|
275
|
+
div[start-paths] {
|
|
276
|
+
display: grid;
|
|
277
|
+
gap: 14px;
|
|
278
|
+
}
|
|
279
|
+
div[start-paths] h3 {
|
|
280
|
+
margin: 0 0 6px;
|
|
281
|
+
font-size: var(--ui-t-body);
|
|
282
|
+
}
|
|
283
|
+
div[start-shapes] {
|
|
284
|
+
display: flex;
|
|
285
|
+
gap: 8px;
|
|
286
|
+
flex-wrap: wrap;
|
|
287
|
+
}
|
|
288
|
+
div[start] small {
|
|
289
|
+
display: block;
|
|
290
|
+
margin-top: 4px;
|
|
291
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
292
|
+
}
|
|
194
293
|
/* The side: tabs over one panel at a time, each using the whole height. */
|
|
195
294
|
aside {
|
|
196
295
|
display: flex;
|
|
@@ -207,9 +306,12 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
207
306
|
flex: 1;
|
|
208
307
|
position: relative;
|
|
209
308
|
display: flex;
|
|
309
|
+
flex-direction: column;
|
|
210
310
|
align-items: center;
|
|
211
311
|
justify-content: center;
|
|
212
|
-
|
|
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
|
-
|
|
384
|
+
min-width: 0;
|
|
265
385
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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]
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
1032
|
+
${this.renderPlaneAlign(asset, unavailable)}
|
|
721
1033
|
${['x', 'z'].map(axis => {
|
|
722
1034
|
const able = canMirror(asset, this.selected, axis, reach);
|
|
723
1035
|
return html `<button
|
|
@@ -791,9 +1103,37 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
791
1103
|
.dims=${dims}
|
|
792
1104
|
?locked=${unavailable}
|
|
793
1105
|
@command=${(e) => this.linkCommand(e.detail.action)}
|
|
1106
|
+
.previewWords=${previewWordsOf(this.currentPreview(), this.selected, (key, params) => i18next.t(key, params))}
|
|
1107
|
+
.detachWords=${this.detachWords(asset)}
|
|
1108
|
+
.overhangWords=${section === 'shape' ? this.overhangWords(asset) : []}
|
|
1109
|
+
.overhangNote=${section === 'shape' ? this.overhangNote(asset) : []}
|
|
1110
|
+
.motionEnds=${section === 'motion' ? this.motionEnds() : null}
|
|
1111
|
+
@go-fasten=${() => this.goFasten()}
|
|
1112
|
+
@preview-command=${(e) => this.previewCommand(e.detail)}
|
|
1113
|
+
@resize-with-placement=${(e) => this.resizeWithPlacement(e.detail.request)}
|
|
794
1114
|
@part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
|
|
795
1115
|
></figure-links>`;
|
|
796
1116
|
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
|
|
1119
|
+
* while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
|
|
1120
|
+
* rule can say, and the gizmo keeps its own (the face across from the handle).
|
|
1121
|
+
*/
|
|
1122
|
+
anchorsOf(asset) {
|
|
1123
|
+
let next = {};
|
|
1124
|
+
try {
|
|
1125
|
+
const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined;
|
|
1126
|
+
if (anchor)
|
|
1127
|
+
next = { [this.selected]: anchor };
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
next = {};
|
|
1131
|
+
}
|
|
1132
|
+
/* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
|
|
1133
|
+
if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors))
|
|
1134
|
+
this.lastAnchors = next;
|
|
1135
|
+
return this.lastAnchors;
|
|
1136
|
+
}
|
|
797
1137
|
/** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
|
|
798
1138
|
renderControls(view, unavailable) {
|
|
799
1139
|
let asset;
|
|
@@ -821,6 +1161,31 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
821
1161
|
@part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
|
|
822
1162
|
></figure-clips>`;
|
|
823
1163
|
}
|
|
1164
|
+
/**
|
|
1165
|
+
* "Follow the size and keep where the figure is placed" (ADR-0097): figure-model plans it from the figure as it is now,
|
|
1166
|
+
* and the whole plan is one step of the history. A refusal with a way on -- move the part onto the plane, replace how
|
|
1167
|
+
* its position is worked out -- asks in the app's prompt and plans again with that choice; any other says why.
|
|
1168
|
+
*/
|
|
1169
|
+
resizeWithPlacement(request) {
|
|
1170
|
+
this.retry = undefined;
|
|
1171
|
+
this.notice = '';
|
|
1172
|
+
const plan = planFigureResize(parseFigureAsset(this.session.source()), request);
|
|
1173
|
+
const refusal = plan.refusal;
|
|
1174
|
+
if (refusal) {
|
|
1175
|
+
const choice = refusal.choice;
|
|
1176
|
+
/* The gap is chosen on the screen beside the button, with its distance; only replacing a position rule is asked here. */
|
|
1177
|
+
this.retry = choice === 'replacePosition' ? { label: i18next.t('figure.button.resize-choice-replace-position'), run: () => this.resizeWithPlacement({ ...request, replacePosition: true }) } : undefined;
|
|
1178
|
+
this.error = resizeRefusalOf(plan, request.part, (key, params) => i18next.t(key, params));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
this.run(() => this.session.applyRecipe(plan.steps));
|
|
1182
|
+
/*
|
|
1183
|
+
The declared room was measured from the part's old numbers and is not changed: said as a fact, not as a claim that
|
|
1184
|
+
the part leaves it -- that is measured where the room is declared again (designer 2026-09-27).
|
|
1185
|
+
*/
|
|
1186
|
+
if (!this.error && plan.roomReads.length)
|
|
1187
|
+
this.notice = i18next.t('figure.text.resize-room-unchanged');
|
|
1188
|
+
}
|
|
824
1189
|
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
825
1190
|
linkCommand(action) {
|
|
826
1191
|
this.retry = undefined;
|
|
@@ -841,6 +1206,8 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
841
1206
|
this.changed();
|
|
842
1207
|
}
|
|
843
1208
|
catch (error) {
|
|
1209
|
+
if (this.askClipLoss(error, () => this.linkCommand(action)))
|
|
1210
|
+
return;
|
|
844
1211
|
const code = error?.code;
|
|
845
1212
|
const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params));
|
|
846
1213
|
if (narrowing) {
|
|
@@ -860,13 +1227,10 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
860
1227
|
}
|
|
861
1228
|
changeField(field, event) {
|
|
862
1229
|
const input = event.target, value = input.valueAsNumber;
|
|
863
|
-
/* A
|
|
1230
|
+
/* A size typed in keeps the face the figure's placement keeps -- figure-model's one rule, as the gizmo and "follow the size" do. */
|
|
864
1231
|
const part = this.selected;
|
|
865
|
-
const
|
|
866
|
-
this.run(() => this.session.apply([
|
|
867
|
-
{ kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value },
|
|
868
|
-
...kept
|
|
869
|
-
]));
|
|
1232
|
+
const change = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value };
|
|
1233
|
+
this.run(() => this.session.apply(field.role !== 'state' && part ? figureSizeEditChanges(parseFigureAsset(this.session.source()), part, [change]) : [change]));
|
|
870
1234
|
// Rejected edits must also restore the displayed numeric value.
|
|
871
1235
|
input.value = String(this.session.read().fields.find(i => i.id === field.id).value);
|
|
872
1236
|
}
|
|
@@ -1014,7 +1378,12 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1014
1378
|
* The parts list, one row per part the author made: a repeat is one row with how many copies it makes now
|
|
1015
1379
|
* (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
1016
1380
|
*/
|
|
1017
|
-
partRows(geometry) {
|
|
1381
|
+
partRows(geometry, asset) {
|
|
1382
|
+
/* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
|
|
1383
|
+
const fastened = new Map((asset?.joints ?? []).map(j => [
|
|
1384
|
+
j.body1.part,
|
|
1385
|
+
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
|
|
1386
|
+
]));
|
|
1018
1387
|
const rows = new Map();
|
|
1019
1388
|
for (const g of geometry) {
|
|
1020
1389
|
const id = ownerOf(g.id);
|
|
@@ -1028,7 +1397,8 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1028
1397
|
provider: g.shape?.provider ?? '',
|
|
1029
1398
|
hidden: this.hiddenParts.has(id),
|
|
1030
1399
|
invisible: g.visible === false,
|
|
1031
|
-
...(id !== g.id ? { copies: 1 } : {})
|
|
1400
|
+
...(id !== g.id ? { copies: 1 } : {}),
|
|
1401
|
+
...(fastened.has(id) ? { fastenedTo: fastened.get(id) } : {})
|
|
1032
1402
|
});
|
|
1033
1403
|
}
|
|
1034
1404
|
return [...rows.values()];
|
|
@@ -1084,6 +1454,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1084
1454
|
@change=${(e) => this.setPitch(field, e)}
|
|
1085
1455
|
/>
|
|
1086
1456
|
</div>
|
|
1457
|
+
<ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html `<li>${line}</li>`)}</ul>
|
|
1087
1458
|
<p hint>
|
|
1088
1459
|
${fits && length !== undefined && longest !== undefined && limit !== undefined
|
|
1089
1460
|
? i18next.t('figure.text.repeat-what-pitch-gives', {
|
|
@@ -1102,6 +1473,15 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1102
1473
|
: nothing}
|
|
1103
1474
|
</section>`;
|
|
1104
1475
|
}
|
|
1476
|
+
/** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
|
|
1477
|
+
rowNow(asset) {
|
|
1478
|
+
try {
|
|
1479
|
+
return figureRowOf(asset, this.selected, this.currentInputs());
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return undefined;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1105
1485
|
/** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
|
|
1106
1486
|
pitchInputOf(asset, part) {
|
|
1107
1487
|
const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch;
|
|
@@ -1211,6 +1591,183 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1211
1591
|
}
|
|
1212
1592
|
}
|
|
1213
1593
|
/** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
|
|
1594
|
+
/*
|
|
1595
|
+
Saving and releasing told apart, beside the save (ADR-0097 §5.6, Q-D2): what the saved figure is -- a draft never
|
|
1596
|
+
released, a draft while boards use an earlier release, a release -- as the server said it. Edits not saved are the
|
|
1597
|
+
status before it. Boards read a draft's latest release (figureBlueprint), so that is what is said.
|
|
1598
|
+
*/
|
|
1599
|
+
releaseWords(view) {
|
|
1600
|
+
if (!view.id || !this.releaseState?.state)
|
|
1601
|
+
return '';
|
|
1602
|
+
const { state, version } = this.releaseState;
|
|
1603
|
+
if (state === 'released')
|
|
1604
|
+
return i18next.t('figure.text.release-state-released', { version: String(version) });
|
|
1605
|
+
return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new');
|
|
1606
|
+
}
|
|
1607
|
+
/** The fastening form's command, seen before it is done; null clears what was shown. */
|
|
1608
|
+
previewCommand(candidate) {
|
|
1609
|
+
if (!candidate || !this.session || !this.selected) {
|
|
1610
|
+
this.preview = undefined;
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const view = this.session.read();
|
|
1614
|
+
const held = { context: view.context, revision: view.revision, key: JSON.stringify(candidate), part: this.selected };
|
|
1615
|
+
try {
|
|
1616
|
+
const seen = this.session.previewCommand(candidate.action);
|
|
1617
|
+
let face;
|
|
1618
|
+
if (candidate.face) {
|
|
1619
|
+
try {
|
|
1620
|
+
face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs());
|
|
1621
|
+
}
|
|
1622
|
+
catch {
|
|
1623
|
+
face = undefined;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
/* A row is read on the figure the command would make, by figure-model, as drawn. */
|
|
1627
|
+
const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined;
|
|
1628
|
+
this.preview = {
|
|
1629
|
+
...held,
|
|
1630
|
+
context: seen.context,
|
|
1631
|
+
revision: seen.revision,
|
|
1632
|
+
geometry: seen.geometry,
|
|
1633
|
+
moves: seen.moves,
|
|
1634
|
+
added: seen.added,
|
|
1635
|
+
...(row ? { row } : {}),
|
|
1636
|
+
clipsWouldGo: seen.clipsWouldGo,
|
|
1637
|
+
face,
|
|
1638
|
+
faceUnknown: !!candidate.face && !face
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
catch (error) {
|
|
1642
|
+
/* Refused: nothing of an earlier result stays on screen, and the reason is said. */
|
|
1643
|
+
this.preview = { ...held, refused: this.said(error) };
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
/** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
|
|
1647
|
+
currentPreview() {
|
|
1648
|
+
const p = this.preview;
|
|
1649
|
+
const view = this.session?.read();
|
|
1650
|
+
/* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
|
|
1651
|
+
if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties')
|
|
1652
|
+
return undefined;
|
|
1653
|
+
return p;
|
|
1654
|
+
}
|
|
1655
|
+
detachWords(asset) {
|
|
1656
|
+
const view = this.session?.read();
|
|
1657
|
+
const joint = asset?.joints?.find(j => j.body1.part === this.selected);
|
|
1658
|
+
if (!asset || !view || !joint || !this.session)
|
|
1659
|
+
return [];
|
|
1660
|
+
const key = `${view.context}|${view.revision}|${this.selected}`;
|
|
1661
|
+
if (this.detachSaid?.key === key)
|
|
1662
|
+
return this.detachSaid.words;
|
|
1663
|
+
const say = (k, params) => i18next.t(k, params);
|
|
1664
|
+
const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part);
|
|
1665
|
+
let words;
|
|
1666
|
+
try {
|
|
1667
|
+
const seen = this.session.previewCommand({ kind: 'detach', part: this.selected });
|
|
1668
|
+
const own = seen.moves.some(m => m.body === this.selected);
|
|
1669
|
+
words = own
|
|
1670
|
+
? [say('figure.text.detach-moves'), ...previewWordsOf({ context: seen.context, revision: seen.revision, key, moves: seen.moves }, this.selected, say)]
|
|
1671
|
+
: [say('figure.text.detach-stays', { from })];
|
|
1672
|
+
const children = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => partLabelOf(j.body1.part));
|
|
1673
|
+
if (children.length)
|
|
1674
|
+
words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }));
|
|
1675
|
+
if (seen.clipsWouldGo.length)
|
|
1676
|
+
words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }));
|
|
1677
|
+
}
|
|
1678
|
+
catch (error) {
|
|
1679
|
+
words = [say('figure.text.detach-refused', { reason: this.said(error) })];
|
|
1680
|
+
}
|
|
1681
|
+
this.detachSaid = { key, words };
|
|
1682
|
+
return words;
|
|
1683
|
+
}
|
|
1684
|
+
/** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
|
|
1685
|
+
overhangNote(asset) {
|
|
1686
|
+
this.overhangWords(asset);
|
|
1687
|
+
return this.overhangSaid?.note ?? [];
|
|
1688
|
+
}
|
|
1689
|
+
overhangWords(asset) {
|
|
1690
|
+
const view = this.session?.read();
|
|
1691
|
+
if (!asset || !view || !this.selected)
|
|
1692
|
+
return [];
|
|
1693
|
+
const key = `${view.context}|${view.revision}|${this.selected}`;
|
|
1694
|
+
if (this.overhangSaid?.key === key)
|
|
1695
|
+
return this.overhangSaid.words;
|
|
1696
|
+
const say = (k, params) => i18next.t(k, params);
|
|
1697
|
+
const where = (o) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) });
|
|
1698
|
+
const sides = (o) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ');
|
|
1699
|
+
const words = [];
|
|
1700
|
+
const note = [];
|
|
1701
|
+
try {
|
|
1702
|
+
const inputs = this.currentInputs();
|
|
1703
|
+
const own = figureFaceOverhangOf(asset, this.selected, inputs);
|
|
1704
|
+
if (own?.state === 'larger') {
|
|
1705
|
+
note.push(say('figure.text.overhang-larger', { ...where(own), sides: sides(own) }));
|
|
1706
|
+
if (own.lopsided.length)
|
|
1707
|
+
note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }));
|
|
1708
|
+
}
|
|
1709
|
+
if (own?.state === 'out')
|
|
1710
|
+
words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }));
|
|
1711
|
+
else if (own?.state === 'possible')
|
|
1712
|
+
words.push(say('figure.text.overhang-possible', where(own)));
|
|
1713
|
+
else if (own?.state === 'unknown')
|
|
1714
|
+
words.push(say('figure.text.overhang-unknown', where(own)));
|
|
1715
|
+
const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs));
|
|
1716
|
+
const past = held.filter((o) => o?.state === 'out' || o?.state === 'possible');
|
|
1717
|
+
if (past.length)
|
|
1718
|
+
words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }));
|
|
1719
|
+
}
|
|
1720
|
+
catch {
|
|
1721
|
+
/* Not read: nothing said rather than a guess. */
|
|
1722
|
+
}
|
|
1723
|
+
this.overhangSaid = { key, words, note };
|
|
1724
|
+
return words;
|
|
1725
|
+
}
|
|
1726
|
+
motionEnds() {
|
|
1727
|
+
const view = this.session?.read();
|
|
1728
|
+
if (!view || !this.selected)
|
|
1729
|
+
return null;
|
|
1730
|
+
const key = `${view.context}|${view.revision}|${this.selected}`;
|
|
1731
|
+
if (this.motionEndsSaid?.key !== key) {
|
|
1732
|
+
let ends = null;
|
|
1733
|
+
try {
|
|
1734
|
+
ends = motionEndsOf(parseFigureAsset(this.session.source()), this.selected, this.currentInputs());
|
|
1735
|
+
}
|
|
1736
|
+
catch {
|
|
1737
|
+
ends = null;
|
|
1738
|
+
}
|
|
1739
|
+
this.motionEndsSaid = { key, ends };
|
|
1740
|
+
}
|
|
1741
|
+
return this.motionEndsSaid.ends;
|
|
1742
|
+
}
|
|
1743
|
+
/** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
|
|
1744
|
+
async goFasten() {
|
|
1745
|
+
this.tab = 'properties';
|
|
1746
|
+
await this.updateComplete;
|
|
1747
|
+
const links = [...this.renderRoot.querySelectorAll('figure-links')].find(l => l.section === 'shape');
|
|
1748
|
+
await links?.showFastening?.();
|
|
1749
|
+
}
|
|
1750
|
+
/** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
|
|
1751
|
+
previewShown() {
|
|
1752
|
+
const p = this.currentPreview();
|
|
1753
|
+
/* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
|
|
1754
|
+
if (!p && this.tab === 'motion' && this.middle === 'edit')
|
|
1755
|
+
return { ghost: this.motionEnds()?.ghost ?? [], highlight: null };
|
|
1756
|
+
if (!p || p.refused)
|
|
1757
|
+
return { ghost: [], highlight: null };
|
|
1758
|
+
if (this.previewDrawn?.of !== p)
|
|
1759
|
+
this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) };
|
|
1760
|
+
return this.previewDrawn;
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* What the author has in view, for the AI dock to hear with a request (ADR-0097 §5.6 item 5): the part selected and
|
|
1764
|
+
* what the preview is trying. Read at the moment of sending, never cached, and never a reason to start a new
|
|
1765
|
+
* conversation -- selecting is not a new subject.
|
|
1766
|
+
*/
|
|
1767
|
+
assistantFocus() {
|
|
1768
|
+
const trying = this.middle === 'preview' ? this.renderRoot.querySelector('figure-preview')?.tried?.() : undefined;
|
|
1769
|
+
return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) };
|
|
1770
|
+
}
|
|
1214
1771
|
choose(id) {
|
|
1215
1772
|
/* A copy on the canvas selects its repeat: the part the author made. */
|
|
1216
1773
|
this.selected = id ? ownerOf(id) : id;
|
|
@@ -1225,28 +1782,125 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1225
1782
|
return undefined;
|
|
1226
1783
|
}
|
|
1227
1784
|
}
|
|
1785
|
+
/** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
|
|
1228
1786
|
renderTab(tab, icon, label, badge) {
|
|
1229
|
-
|
|
1230
|
-
|
|
1787
|
+
/* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
|
|
1788
|
+
const name = i18next.t('figure.label.tab-short-' + tab);
|
|
1789
|
+
return html `<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
|
|
1790
|
+
<md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html `<span badge>${badge.toLocaleString()}</span>`}
|
|
1231
1791
|
</button>`;
|
|
1232
1792
|
}
|
|
1233
1793
|
updated(changed) {
|
|
1234
1794
|
super.updated?.(changed);
|
|
1235
1795
|
this.tell(changed);
|
|
1796
|
+
if (this.restorePanelFor !== undefined) {
|
|
1797
|
+
const key = this.restorePanelFor;
|
|
1798
|
+
this.restorePanelFor = undefined;
|
|
1799
|
+
requestAnimationFrame(async () => {
|
|
1800
|
+
const panel = this.propertiesPanel();
|
|
1801
|
+
if (!panel || (this.selected || '@model') !== key)
|
|
1802
|
+
return;
|
|
1803
|
+
await Promise.all([...panel.querySelectorAll('figure-links')].map(el => el.updateComplete));
|
|
1804
|
+
const kept = this.panelMemory.get(key);
|
|
1805
|
+
if (kept)
|
|
1806
|
+
this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]));
|
|
1807
|
+
panel.scrollTop = kept?.top ?? 0;
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1236
1810
|
const panes = this.renderRoot.querySelector('ox-three-pane') ?? undefined;
|
|
1237
|
-
if (panes !== this.panes)
|
|
1811
|
+
if (panes !== this.panes) {
|
|
1238
1812
|
this.panes = panes;
|
|
1813
|
+
this.fitPanesTo(this.getBoundingClientRect().width);
|
|
1814
|
+
}
|
|
1239
1815
|
}
|
|
1240
|
-
/**
|
|
1816
|
+
/**
|
|
1817
|
+
* A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
|
|
1818
|
+
* recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
|
|
1819
|
+
* mounting face and the tags are not asked for first.
|
|
1820
|
+
*/
|
|
1821
|
+
renderStart() {
|
|
1822
|
+
const shapes = ['box', 'cylinder', 'plate'];
|
|
1823
|
+
/* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
|
|
1824
|
+
const placement = (this.asset()?.placement ?? 'floor');
|
|
1825
|
+
return html `<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
|
|
1826
|
+
<h2>${i18next.t('figure.text.start-question')}</h2>
|
|
1827
|
+
<div start-paths>
|
|
1828
|
+
<section>
|
|
1829
|
+
<h3>${i18next.t('figure.label.start-placement')}</h3>
|
|
1830
|
+
<div start-shapes role="radiogroup" aria-label=${i18next.t('figure.label.start-placement')}>
|
|
1831
|
+
${['floor', 'ceiling', 'wall', 'center'].map(one => html `<button start-button role="radio" aria-checked=${String(one === placement)} ?pressed=${one === placement}
|
|
1832
|
+
@click=${() => one !== placement && this.linkCommand({ kind: 'set-placement', placement: one })}>
|
|
1833
|
+
${i18next.t(`figure.label.resize-place-${one}`)}
|
|
1834
|
+
</button>`)}
|
|
1835
|
+
</div>
|
|
1836
|
+
<small>${i18next.t('figure.text.placement-is-intent')}</small>
|
|
1837
|
+
</section>
|
|
1838
|
+
<section>
|
|
1839
|
+
<h3>${i18next.t('figure.label.start-shape')}</h3>
|
|
1840
|
+
<div start-shapes>
|
|
1841
|
+
${shapes.map(kind => html `<button start-button start-shape=${kind} @click=${() => this.add(kind)}>
|
|
1842
|
+
${i18next.t(kind === 'box' ? 'figure.label.primitive-cube' : `figure.label.primitive-${kind}`)}
|
|
1843
|
+
</button>`)}
|
|
1844
|
+
</div>
|
|
1845
|
+
<small>${i18next.t('figure.text.start-shape-more')}</small>
|
|
1846
|
+
</section>
|
|
1847
|
+
<section>
|
|
1848
|
+
<h3>${i18next.t('figure.label.start-recipe')}</h3>
|
|
1849
|
+
<button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
|
|
1850
|
+
${i18next.t('figure.button.start-recipe')}
|
|
1851
|
+
</button>
|
|
1852
|
+
${this.templates.length ? nothing : html `<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
|
|
1853
|
+
</section>
|
|
1854
|
+
${this.aiDraft
|
|
1855
|
+
? html `<section>
|
|
1856
|
+
<h3>${i18next.t('figure.label.start-ai')}</h3>
|
|
1857
|
+
<button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
|
|
1858
|
+
${i18next.t('figure.button.start-ai')}
|
|
1859
|
+
</button>
|
|
1860
|
+
<small>${i18next.t('figure.text.start-ai-optional')}</small>
|
|
1861
|
+
</section>`
|
|
1862
|
+
: nothing}
|
|
1863
|
+
</div>
|
|
1864
|
+
</div>`;
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
|
|
1868
|
+
* with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
|
|
1869
|
+
* model name is the same move as the parts list's root; there is no separate "whole figure" link.
|
|
1870
|
+
*/
|
|
1241
1871
|
renderSubject() {
|
|
1242
|
-
|
|
1243
|
-
|
|
1872
|
+
const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name');
|
|
1873
|
+
let summary;
|
|
1874
|
+
try {
|
|
1875
|
+
summary = this.selected ? partSummaryOf(parseFigureAsset(this.session.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined;
|
|
1876
|
+
}
|
|
1877
|
+
catch {
|
|
1878
|
+
summary = undefined;
|
|
1879
|
+
}
|
|
1244
1880
|
return html `<div subject>
|
|
1245
|
-
<
|
|
1246
|
-
<
|
|
1247
|
-
|
|
1881
|
+
<nav path aria-label=${i18next.t('figure.label.panel-path')}>
|
|
1882
|
+
<button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
|
|
1883
|
+
aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
|
|
1884
|
+
${this.selected
|
|
1885
|
+
? html `<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
|
|
1886
|
+
: html `<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
|
|
1887
|
+
</nav>
|
|
1888
|
+
${summary
|
|
1889
|
+
? html `<div subject-summary>
|
|
1890
|
+
${summary.kind ? html `<b>${summary.kind}</b>` : nothing}
|
|
1891
|
+
<small>${summary.size}</small><small>${summary.relation}</small>
|
|
1892
|
+
</div>`
|
|
1893
|
+
: nothing}
|
|
1248
1894
|
</div>`;
|
|
1249
1895
|
}
|
|
1896
|
+
/**
|
|
1897
|
+
* The model's settings: the same move from the parts list's root and from the panel's path (B.1). It shows them on
|
|
1898
|
+
* the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
|
|
1899
|
+
*/
|
|
1900
|
+
showModelSettings() {
|
|
1901
|
+
this.selected = '';
|
|
1902
|
+
this.tab = 'properties';
|
|
1903
|
+
}
|
|
1250
1904
|
render() {
|
|
1251
1905
|
const view = this.session?.read();
|
|
1252
1906
|
if (!view)
|
|
@@ -1258,6 +1912,37 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1258
1912
|
/* A repeat's own row values (its pitch) have their own section, not the shape's. */
|
|
1259
1913
|
const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'));
|
|
1260
1914
|
const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')));
|
|
1915
|
+
/*
|
|
1916
|
+
A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
|
|
1917
|
+
nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
|
|
1918
|
+
*/
|
|
1919
|
+
let roomOnly = [];
|
|
1920
|
+
try {
|
|
1921
|
+
roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session.source()), this.selected) : [];
|
|
1922
|
+
}
|
|
1923
|
+
catch {
|
|
1924
|
+
roomOnly = [];
|
|
1925
|
+
}
|
|
1926
|
+
const partFields = own.filter(i => !roomOnly.includes(i.id));
|
|
1927
|
+
/* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
|
|
1928
|
+
let held;
|
|
1929
|
+
try {
|
|
1930
|
+
const body = view.geometry.find(g => g.id === this.selected);
|
|
1931
|
+
const placed = body ? placementOf(body) : undefined;
|
|
1932
|
+
const parsed = this.selected ? parseFigureAsset(this.session.source()) : undefined;
|
|
1933
|
+
if (placed && parsed)
|
|
1934
|
+
held = {
|
|
1935
|
+
position: placed.position,
|
|
1936
|
+
rotation: placed.rotation,
|
|
1937
|
+
size: ['x', 'y', 'z'].map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
|
|
1938
|
+
/* Only a size a rule sets: a palette part's own numbers are its shape's fields, not a rule's (found in the browser). */
|
|
1939
|
+
sizeRuled: ['x', 'y', 'z'].every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
catch {
|
|
1943
|
+
held = undefined;
|
|
1944
|
+
}
|
|
1945
|
+
const roomFields = own.filter(i => roomOnly.includes(i.id));
|
|
1261
1946
|
const status = view.loading
|
|
1262
1947
|
? 'figure.text.loading-figure'
|
|
1263
1948
|
: view.saving
|
|
@@ -1284,6 +1969,8 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1284
1969
|
${i18next.t('figure.label.mode-' + mode)}
|
|
1285
1970
|
</button>`)}
|
|
1286
1971
|
<span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
|
|
1972
|
+
<span slot="status" release-state>${this.releaseWords(view)}</span>
|
|
1973
|
+
<button slot="commit" publish ?disabled=${!view.id} title=${view.id ? '' : i18next.t('figure.text.save-the-figure-before-releasing')} @click=${this.goRelease}>${i18next.t('figure.button.release')}</button>
|
|
1287
1974
|
<button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
|
|
1288
1975
|
</ox-canvas-bar>
|
|
1289
1976
|
${this.catalogOpen
|
|
@@ -1303,10 +1990,12 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1303
1990
|
slot="start"
|
|
1304
1991
|
.budget=${this.budget}
|
|
1305
1992
|
@show-maturity=${() => (this.tab = 'verdict')}
|
|
1306
|
-
.parts=${this.partRows(view.geometry)}
|
|
1993
|
+
.parts=${this.partRows(view.geometry, asset)}
|
|
1307
1994
|
.partBudget=${this.partBudgetOf(asset)}
|
|
1308
1995
|
@set-detail-level=${(e) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
|
|
1309
1996
|
.selected=${this.selected}
|
|
1997
|
+
.modelName=${view.name ?? ''}
|
|
1998
|
+
@show-model-settings=${() => this.showModelSettings()}
|
|
1310
1999
|
?locked=${unavailable}
|
|
1311
2000
|
@add-part=${(e) => this.add(e.detail.kind)}
|
|
1312
2001
|
.recipeCount=${this.templates.length}
|
|
@@ -1324,12 +2013,25 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1324
2013
|
.asset=${asset}
|
|
1325
2014
|
.context=${view.context ?? ''}
|
|
1326
2015
|
.view=${this.view}
|
|
2016
|
+
@configure-size=${async (e) => {
|
|
2017
|
+
this.middle = 'edit';
|
|
2018
|
+
this.selected = e.detail.part;
|
|
2019
|
+
this.tab = 'properties';
|
|
2020
|
+
await this.updateComplete;
|
|
2021
|
+
this.renderRoot.querySelector('figure-links')?.scrollIntoView({ block: 'nearest' });
|
|
2022
|
+
}}
|
|
1327
2023
|
@part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
|
|
1328
2024
|
></figure-preview>`
|
|
1329
|
-
: html `<
|
|
2025
|
+
: html `<div middle>
|
|
2026
|
+
${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
|
|
2027
|
+
<figure-canvas
|
|
2028
|
+
?startShown=${view.geometry.length === 0 && !unavailable}
|
|
1330
2029
|
.view=${this.view}
|
|
1331
2030
|
.parts=${this.drawnParts(view.geometry, asset)}
|
|
2031
|
+
.anchors=${this.anchorsOf(asset)}
|
|
1332
2032
|
.fan=${this.fanOf(asset)}
|
|
2033
|
+
.ghost=${this.previewShown().ghost}
|
|
2034
|
+
.highlight=${this.previewShown().highlight}
|
|
1333
2035
|
.palette=${view.palette ?? {}}
|
|
1334
2036
|
.context=${view.context ?? ''}
|
|
1335
2037
|
.selected=${this.selected}
|
|
@@ -1339,7 +2041,7 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1339
2041
|
@render-error=${(e) => {
|
|
1340
2042
|
this.error = e.detail.message;
|
|
1341
2043
|
}}
|
|
1342
|
-
></figure-canvas>`}
|
|
2044
|
+
></figure-canvas></div>`}
|
|
1343
2045
|
<aside slot="end">
|
|
1344
2046
|
<div tabs>
|
|
1345
2047
|
${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
|
|
@@ -1368,7 +2070,8 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1368
2070
|
${this.renderRepeatNote(view.geometry)}
|
|
1369
2071
|
</section>
|
|
1370
2072
|
${this.renderRepeat(asset, view.geometry, unavailable)}
|
|
1371
|
-
${this.renderGroups(groupFields(
|
|
2073
|
+
${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
|
|
2074
|
+
${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
|
|
1372
2075
|
${this.renderLinks(unavailable, 'shape')}
|
|
1373
2076
|
<figure-material-panel
|
|
1374
2077
|
.body=${{ ...part, id: this.selected }}
|
|
@@ -1377,7 +2080,15 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1377
2080
|
@part-edit=${(e) => this.run(() => this.session.edit(e.detail.edit))}
|
|
1378
2081
|
@part-live=${(e) => this.hold({}, e.detail.edit)}
|
|
1379
2082
|
></figure-material-panel>`
|
|
1380
|
-
:
|
|
2083
|
+
: view.geometry.length === 0
|
|
2084
|
+
? html `${this.renderSubject()}<section empty-figure>
|
|
2085
|
+
<p>${i18next.t('figure.text.empty-figure-next')}</p>
|
|
2086
|
+
<details>
|
|
2087
|
+
<summary>${i18next.t('figure.label.figure-info-later')}</summary>
|
|
2088
|
+
<slot name="figure"></slot>
|
|
2089
|
+
</details>
|
|
2090
|
+
</section>`
|
|
2091
|
+
: html `${this.renderSubject()}<slot name="figure"></slot>
|
|
1381
2092
|
${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
|
|
1382
2093
|
${this.renderInstanceRange(asset, unavailable)}`}
|
|
1383
2094
|
</div>
|
|
@@ -1406,11 +2117,11 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1406
2117
|
</ox-three-pane>`;
|
|
1407
2118
|
}
|
|
1408
2119
|
/** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
|
|
1409
|
-
renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null) {
|
|
2120
|
+
renderGroups(groups, vectorTitle, singleTitle, unavailable, tools = null, held) {
|
|
1410
2121
|
return html `${groups.vectors.length || tools
|
|
1411
2122
|
? html `<section>
|
|
1412
2123
|
<h3>${i18next.t(vectorTitle)}</h3>
|
|
1413
|
-
${groups.vectors.map(g => this.renderVector(g, unavailable))}
|
|
2124
|
+
${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
|
|
1414
2125
|
${tools ? html `<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
|
|
1415
2126
|
</section>`
|
|
1416
2127
|
: nothing}
|
|
@@ -1421,14 +2132,38 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1421
2132
|
</section>`
|
|
1422
2133
|
: nothing}`;
|
|
1423
2134
|
}
|
|
1424
|
-
|
|
2135
|
+
/**
|
|
2136
|
+
* A box-like part's size row stands when a rule sets every side -- each following the figure's size -- with the
|
|
2137
|
+
* numbers the rule gives, as its position and turn rows do (designer 2026-09-27: not a blank, not gone).
|
|
2138
|
+
*/
|
|
2139
|
+
withSizeRow(groups, held) {
|
|
2140
|
+
if (groups.vectors.some(v => v.kind === 'vector' && v.name === 'size'))
|
|
2141
|
+
return groups;
|
|
2142
|
+
if (!held?.sizeRuled || !held.size || held.size.length !== 3 || held.size.some(v => v === undefined))
|
|
2143
|
+
return groups;
|
|
2144
|
+
const at = groups.vectors.findIndex(v => v.kind === 'vector' && v.name === 'rotation');
|
|
2145
|
+
const vectors = [...groups.vectors];
|
|
2146
|
+
vectors.splice(at < 0 ? vectors.length : at + 1, 0, { kind: 'vector', name: 'size', cells: {} });
|
|
2147
|
+
return { ...groups, vectors };
|
|
2148
|
+
}
|
|
2149
|
+
renderVector(group, unavailable, held) {
|
|
1425
2150
|
if (group.kind !== 'vector')
|
|
1426
2151
|
return nothing;
|
|
1427
|
-
const unit = group.
|
|
2152
|
+
const unit = group.name === 'rotation' ? '°' : 'mm';
|
|
2153
|
+
const ruled = ['x', 'y', 'z'].filter(axis => !group.cells[axis]);
|
|
2154
|
+
const given = (axis) => {
|
|
2155
|
+
const v = held?.[group.name]?.[['x', 'y', 'z'].indexOf(axis)];
|
|
2156
|
+
return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10);
|
|
2157
|
+
};
|
|
1428
2158
|
return html `<div vector-row>
|
|
1429
2159
|
<label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html ` <span unit>${unit}</span>`}</label>
|
|
1430
2160
|
<div triple>
|
|
1431
|
-
${['x', 'y', 'z'].map(axis =>
|
|
2161
|
+
${['x', 'y', 'z'].map(axis => !group.cells[axis]
|
|
2162
|
+
? html `<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
|
|
2163
|
+
<span axis>${axis.toUpperCase()}</span>
|
|
2164
|
+
<input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
|
|
2165
|
+
</div>`
|
|
2166
|
+
: html `<div cell>
|
|
1432
2167
|
<span axis ?scrub=${!unavailable} @pointerdown=${(e) => this.scrub(group.cells[axis], e, unavailable)}>${axis.toUpperCase()}</span>
|
|
1433
2168
|
<input
|
|
1434
2169
|
aria-label=${group.cells[axis].id}
|
|
@@ -1441,7 +2176,60 @@ let FigureEditor = class FigureEditor extends LitElement {
|
|
|
1441
2176
|
/>
|
|
1442
2177
|
</div>`)}
|
|
1443
2178
|
</div>
|
|
1444
|
-
</div
|
|
2179
|
+
</div>
|
|
2180
|
+
${ruled.length ? html `<p hint held-axes>${i18next.t('figure.text.axes-held-by-rule', { group: i18next.t('figure.label.group-' + group.name), axes: ruled.map(a => a.toUpperCase()).join(' · ') })}</p>` : nothing}`;
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
|
|
2184
|
+
* typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
|
|
2185
|
+
*/
|
|
2186
|
+
renderRoomOnly(fields, unavailable) {
|
|
2187
|
+
return html `<section room-only>
|
|
2188
|
+
<h3>${i18next.t('figure.label.room-only-inputs')}</h3>
|
|
2189
|
+
<p hint>${i18next.t('figure.text.room-only-inputs')}</p>
|
|
2190
|
+
<div form>
|
|
2191
|
+
${fields.map(f => html `<span>${fieldWordsOf(f.id, key => i18next.t(key))} (${f.unit})</span><input aria-label=${f.id} type="number" .value=${String(f.value)} disabled />`)}
|
|
2192
|
+
</div>
|
|
2193
|
+
<button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
|
|
2194
|
+
</section>`;
|
|
2195
|
+
}
|
|
2196
|
+
/** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
|
|
2197
|
+
async askRoomRefit() {
|
|
2198
|
+
let refit;
|
|
2199
|
+
try {
|
|
2200
|
+
refit = figureRoomRefitOf(parseFigureAsset(this.session.source()));
|
|
2201
|
+
}
|
|
2202
|
+
catch (error) {
|
|
2203
|
+
this.error = this.said(error);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
if (!refit)
|
|
2207
|
+
return;
|
|
2208
|
+
const rows = roomRefitRowsOf(refit);
|
|
2209
|
+
const outside = (list) => list.length
|
|
2210
|
+
? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
|
|
2211
|
+
: i18next.t('figure.text.room-nothing-outside');
|
|
2212
|
+
const agreed = await OxPrompt.open({
|
|
2213
|
+
type: 'question',
|
|
2214
|
+
title: i18next.t('figure.label.room-refit'),
|
|
2215
|
+
template: html `<table room-refit>
|
|
2216
|
+
<tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
|
|
2217
|
+
${rows.map(r => html `<tr>
|
|
2218
|
+
<td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
|
|
2219
|
+
<td>${r.declared}</td>
|
|
2220
|
+
<td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
|
|
2221
|
+
</tr>`)}
|
|
2222
|
+
</table>
|
|
2223
|
+
<p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
|
|
2224
|
+
<p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
|
|
2225
|
+
${refit.drops.length
|
|
2226
|
+
? html `<p>${i18next.t('figure.text.room-drops', { fields: [...new Set(refit.drops.map(id => `${partLabelOf(id.split('.')[0])} ${fieldWordsOf(id, key => i18next.t(key))}`))].join(' · ') })}</p>`
|
|
2227
|
+
: nothing}`,
|
|
2228
|
+
confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
|
|
2229
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
2230
|
+
});
|
|
2231
|
+
if (agreed)
|
|
2232
|
+
this.linkCommand(refit.step);
|
|
1445
2233
|
}
|
|
1446
2234
|
renderSingle(name, field, unavailable) {
|
|
1447
2235
|
const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }));
|
|
@@ -1461,6 +2249,10 @@ __decorate([
|
|
|
1461
2249
|
property({ attribute: false }),
|
|
1462
2250
|
__metadata("design:type", Object)
|
|
1463
2251
|
], FigureEditor.prototype, "session", void 0);
|
|
2252
|
+
__decorate([
|
|
2253
|
+
property({ attribute: false }),
|
|
2254
|
+
__metadata("design:type", Object)
|
|
2255
|
+
], FigureEditor.prototype, "releaseState", void 0);
|
|
1464
2256
|
__decorate([
|
|
1465
2257
|
property({ attribute: false }),
|
|
1466
2258
|
__metadata("design:type", Array)
|
|
@@ -1537,6 +2329,10 @@ __decorate([
|
|
|
1537
2329
|
state(),
|
|
1538
2330
|
__metadata("design:type", Object)
|
|
1539
2331
|
], FigureEditor.prototype, "view", void 0);
|
|
2332
|
+
__decorate([
|
|
2333
|
+
property({ type: Boolean }),
|
|
2334
|
+
__metadata("design:type", Object)
|
|
2335
|
+
], FigureEditor.prototype, "aiDraft", void 0);
|
|
1540
2336
|
__decorate([
|
|
1541
2337
|
state(),
|
|
1542
2338
|
__metadata("design:type", Object)
|
|
@@ -1545,6 +2341,10 @@ __decorate([
|
|
|
1545
2341
|
state(),
|
|
1546
2342
|
__metadata("design:type", Object)
|
|
1547
2343
|
], FigureEditor.prototype, "live", void 0);
|
|
2344
|
+
__decorate([
|
|
2345
|
+
state(),
|
|
2346
|
+
__metadata("design:type", Object)
|
|
2347
|
+
], FigureEditor.prototype, "preview", void 0);
|
|
1548
2348
|
FigureEditor = FigureEditor_1 = __decorate([
|
|
1549
2349
|
customElement('figure-editor')
|
|
1550
2350
|
], FigureEditor);
|