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