@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
|
@@ -6,16 +6,18 @@ import { notify } from '@operato/layout'
|
|
|
6
6
|
import { OxPrompt } from '@operato/popup'
|
|
7
7
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
8
8
|
import { live } from 'lit/directives/live.js'
|
|
9
|
-
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor } from '@hatiolab/figure-model'
|
|
9
|
+
import { PART_LIMIT, parseFigureRecipe, resolveFigureRecipe, figurePartCountOf, figureRecipeIdFor, planFigureResize, parseFigureAsset, figureSizeEditChanges, figureScaleAnchorOf, figurePlaneAlignChanges, type FigurePlaneContact, type FigureClipLoss, figurePartFaceOf, type FigureFaceArea, figureRowOf, type FigureRowReading, figureFaceOverhangOf, type FigureFaceOverhang, figureRoomOnlyInputsOf, figureRoomRefitOf } from '@hatiolab/figure-model'
|
|
10
10
|
import type { RecipeTemplate } from './recipes.js'
|
|
11
11
|
import './figure-recipe-catalog.js'
|
|
12
|
-
import type { FigureAsset, FigureAuthoringAction, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue } from '@hatiolab/figure-model'
|
|
12
|
+
import type { FigureAsset, FigureAuthoringAction, FigureInputChange, FigurePartEdit, FigurePrimitiveCreation, FigureProposalStep, FigureRecipe, FigureRecipeValue, FigureResizeRequest, FigurePlacement } from '@hatiolab/figure-model'
|
|
13
13
|
import type { FigureProductSession } from './product-session.js'
|
|
14
14
|
import { captureFigureThumbnail } from './thumbnail.js'
|
|
15
15
|
import { drawableFigureParts } from './renderer.js'
|
|
16
16
|
import './canvas.js'
|
|
17
17
|
import './figure-parts.js'
|
|
18
18
|
import './figure-material-panel.js'
|
|
19
|
+
import { faceHighlightOf, ghostPartsOf, previewWordsOf, rowWordsOf, type HeldPreview } from './command-preview.js'
|
|
20
|
+
import { motionEndsOf, type MotionEnds } from './motion-ends.js'
|
|
19
21
|
import './figure-links.js'
|
|
20
22
|
import './figure-controls.js'
|
|
21
23
|
import './figure-clips.js'
|
|
@@ -23,7 +25,7 @@ import './figure-preview.js'
|
|
|
23
25
|
import './figure-settings.js'
|
|
24
26
|
import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
|
|
25
27
|
import './figure-capability-panel.js'
|
|
26
|
-
import { gizmoChanges, gizmoReachOf,
|
|
28
|
+
import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './gizmo.js'
|
|
27
29
|
import { keyActionOf } from './keys.js'
|
|
28
30
|
import { groupFields, ownerOf, partLabelOf, repeatOf, type FieldLike, type FieldGroup } from './fields.js'
|
|
29
31
|
import { SidePanelStyles } from './side-panel-styles.js'
|
|
@@ -32,11 +34,15 @@ import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from '.
|
|
|
32
34
|
import type { FigurePaletteKind, FigurePartRow } from './figure-parts.js'
|
|
33
35
|
import { paletteRecipe } from './palette.js'
|
|
34
36
|
import type { FigureCanvas } from './canvas.js'
|
|
35
|
-
import { nudgeOf } from './stage-model.js'
|
|
36
|
-
|
|
37
|
+
import { nudgeOf, placementOf, type ScaleAnchor } from './stage-model.js'
|
|
38
|
+
|
|
39
|
+
/** The numbers a rule gives a part's position, turn and size, per axis, for the cells that have no field. */
|
|
40
|
+
type HeldValues = { position?: number[]; rotation?: number[]; size?: (number | undefined)[]; sizeRuled?: boolean }
|
|
41
|
+
import { roomRefitRowsOf } from './occupancy.js'
|
|
42
|
+
import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, resizeRefusalOf, sizeRangeRefusalOf } from './refusals.js'
|
|
37
43
|
import type { FigureRenderPart } from './renderer.js'
|
|
38
44
|
import type { FigureBudgetLine } from './maturity.js'
|
|
39
|
-
import { instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
45
|
+
import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
40
46
|
import { jointFanOf } from './joint-fan.js'
|
|
41
47
|
import { jointDragOf } from './joint-drag.js'
|
|
42
48
|
import type { FirstSave } from './first-save.js'
|
|
@@ -107,6 +113,17 @@ export class FigureEditor extends LitElement {
|
|
|
107
113
|
opacity: 0.38;
|
|
108
114
|
cursor: default;
|
|
109
115
|
}
|
|
116
|
+
/*
|
|
117
|
+
Slotted into the bar from here, the tools do not get the bar's own icon size (20px, set inside its shadow): they
|
|
118
|
+
showed at md-icon's 24px, heavier than undo, redo and fit beside them. The same size, and the bar's focus ring.
|
|
119
|
+
*/
|
|
120
|
+
ox-canvas-bar button[tool] md-icon {
|
|
121
|
+
--md-icon-size: 20px;
|
|
122
|
+
}
|
|
123
|
+
ox-canvas-bar button[tool]:focus-visible {
|
|
124
|
+
outline: 2px solid var(--md-sys-color-primary);
|
|
125
|
+
outline-offset: 1px;
|
|
126
|
+
}
|
|
110
127
|
figure-preview {
|
|
111
128
|
min-width: 0;
|
|
112
129
|
min-height: 0;
|
|
@@ -137,9 +154,76 @@ export class FigureEditor extends LitElement {
|
|
|
137
154
|
min-height: 0;
|
|
138
155
|
}
|
|
139
156
|
figure-canvas {
|
|
157
|
+
flex: 1;
|
|
158
|
+
min-width: 0;
|
|
159
|
+
min-height: 0;
|
|
160
|
+
}
|
|
161
|
+
div[middle] {
|
|
162
|
+
position: relative;
|
|
163
|
+
display: flex;
|
|
140
164
|
min-width: 0;
|
|
141
165
|
min-height: 0;
|
|
142
166
|
}
|
|
167
|
+
/* A new figure's first question, over the empty canvas. */
|
|
168
|
+
div[start] {
|
|
169
|
+
position: absolute;
|
|
170
|
+
inset: 50% auto auto 50%;
|
|
171
|
+
z-index: 10;
|
|
172
|
+
color: var(--md-sys-color-on-surface);
|
|
173
|
+
transform: translate(-50%, -50%);
|
|
174
|
+
width: min(440px, calc(100% - 32px));
|
|
175
|
+
box-sizing: border-box;
|
|
176
|
+
padding: 20px;
|
|
177
|
+
border-radius: 12px;
|
|
178
|
+
background: var(--md-sys-color-surface, #fff);
|
|
179
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
|
180
|
+
}
|
|
181
|
+
div[start] button[start-button] {
|
|
182
|
+
width: auto;
|
|
183
|
+
margin: 0;
|
|
184
|
+
font-size: var(--ui-t-body);
|
|
185
|
+
height: 36px;
|
|
186
|
+
padding: 0 16px;
|
|
187
|
+
border: 1px solid var(--md-sys-color-outline);
|
|
188
|
+
border-radius: 999em;
|
|
189
|
+
color: var(--md-sys-color-primary);
|
|
190
|
+
background: var(--md-sys-color-surface, #fff);
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
}
|
|
193
|
+
div[start] button[start-button]:hover:not(:disabled) {
|
|
194
|
+
background: var(--md-sys-color-primary-container);
|
|
195
|
+
}
|
|
196
|
+
div[start] button[start-button][pressed] {
|
|
197
|
+
color: var(--md-sys-color-on-primary);
|
|
198
|
+
background: var(--md-sys-color-primary);
|
|
199
|
+
border-color: var(--md-sys-color-primary);
|
|
200
|
+
}
|
|
201
|
+
div[start] button[start-button]:disabled {
|
|
202
|
+
opacity: 0.5;
|
|
203
|
+
cursor: default;
|
|
204
|
+
}
|
|
205
|
+
div[start] h2 {
|
|
206
|
+
margin: 0 0 12px;
|
|
207
|
+
font-size: var(--ui-t-title);
|
|
208
|
+
}
|
|
209
|
+
div[start-paths] {
|
|
210
|
+
display: grid;
|
|
211
|
+
gap: 14px;
|
|
212
|
+
}
|
|
213
|
+
div[start-paths] h3 {
|
|
214
|
+
margin: 0 0 6px;
|
|
215
|
+
font-size: var(--ui-t-body);
|
|
216
|
+
}
|
|
217
|
+
div[start-shapes] {
|
|
218
|
+
display: flex;
|
|
219
|
+
gap: 8px;
|
|
220
|
+
flex-wrap: wrap;
|
|
221
|
+
}
|
|
222
|
+
div[start] small {
|
|
223
|
+
display: block;
|
|
224
|
+
margin-top: 4px;
|
|
225
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
226
|
+
}
|
|
143
227
|
/* The side: tabs over one panel at a time, each using the whole height. */
|
|
144
228
|
aside {
|
|
145
229
|
display: flex;
|
|
@@ -156,9 +240,12 @@ export class FigureEditor extends LitElement {
|
|
|
156
240
|
flex: 1;
|
|
157
241
|
position: relative;
|
|
158
242
|
display: flex;
|
|
243
|
+
flex-direction: column;
|
|
159
244
|
align-items: center;
|
|
160
245
|
justify-content: center;
|
|
161
|
-
|
|
246
|
+
gap: 1px;
|
|
247
|
+
min-width: 0;
|
|
248
|
+
height: 48px;
|
|
162
249
|
padding: 0;
|
|
163
250
|
border: none;
|
|
164
251
|
border-radius: 0;
|
|
@@ -178,6 +265,14 @@ export class FigureEditor extends LitElement {
|
|
|
178
265
|
width: 20px;
|
|
179
266
|
height: 20px;
|
|
180
267
|
}
|
|
268
|
+
button[tab] span[tab-label] {
|
|
269
|
+
max-width: 100%;
|
|
270
|
+
overflow: hidden;
|
|
271
|
+
font-size: 0.66rem;
|
|
272
|
+
line-height: 1.2;
|
|
273
|
+
white-space: nowrap;
|
|
274
|
+
text-overflow: ellipsis;
|
|
275
|
+
}
|
|
181
276
|
span[badge] {
|
|
182
277
|
position: absolute;
|
|
183
278
|
top: 3px;
|
|
@@ -207,24 +302,60 @@ export class FigureEditor extends LitElement {
|
|
|
207
302
|
}
|
|
208
303
|
/* Which part the panel speaks of, and the way back to the whole figure. */
|
|
209
304
|
div[subject] {
|
|
305
|
+
position: sticky;
|
|
306
|
+
top: 0;
|
|
307
|
+
z-index: 1;
|
|
308
|
+
display: grid;
|
|
309
|
+
gap: 4px;
|
|
310
|
+
padding: 8px 10px;
|
|
311
|
+
background: var(--md-sys-color-surface-container-lowest);
|
|
312
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
313
|
+
}
|
|
314
|
+
nav[path] {
|
|
210
315
|
display: flex;
|
|
211
316
|
align-items: center;
|
|
212
317
|
gap: 6px;
|
|
213
|
-
|
|
318
|
+
min-width: 0;
|
|
214
319
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
320
|
+
nav[path] button[model-crumb] {
|
|
321
|
+
flex: none;
|
|
322
|
+
max-width: 55%;
|
|
323
|
+
height: 28px;
|
|
324
|
+
padding: 0 8px;
|
|
325
|
+
overflow: hidden;
|
|
326
|
+
white-space: nowrap;
|
|
327
|
+
text-overflow: ellipsis;
|
|
328
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
221
329
|
border-radius: 999em;
|
|
330
|
+
background: none;
|
|
331
|
+
color: var(--md-sys-color-primary);
|
|
332
|
+
cursor: pointer;
|
|
333
|
+
}
|
|
334
|
+
nav[path] button[model-crumb][on] {
|
|
335
|
+
border-color: var(--md-sys-color-primary);
|
|
336
|
+
background: var(--md-sys-color-primary-container);
|
|
337
|
+
}
|
|
338
|
+
nav[path] button[model-crumb]:focus-visible {
|
|
339
|
+
outline: 2px solid var(--md-sys-color-primary);
|
|
340
|
+
outline-offset: 1px;
|
|
341
|
+
}
|
|
342
|
+
nav[path] span[current] {
|
|
343
|
+
min-width: 0;
|
|
344
|
+
overflow: hidden;
|
|
345
|
+
white-space: nowrap;
|
|
346
|
+
text-overflow: ellipsis;
|
|
347
|
+
font-weight: 600;
|
|
348
|
+
}
|
|
349
|
+
div[subject-summary] {
|
|
350
|
+
display: grid;
|
|
351
|
+
gap: 1px;
|
|
352
|
+
}
|
|
353
|
+
div[subject-summary] b span {
|
|
354
|
+
font-weight: 400;
|
|
222
355
|
color: var(--md-sys-color-on-surface-variant);
|
|
223
356
|
}
|
|
224
|
-
div[subject]
|
|
225
|
-
|
|
226
|
-
width: 16px;
|
|
227
|
-
height: 16px;
|
|
357
|
+
div[subject-summary] small {
|
|
358
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
228
359
|
}
|
|
229
360
|
button {
|
|
230
361
|
font: inherit;
|
|
@@ -255,6 +386,8 @@ export class FigureEditor extends LitElement {
|
|
|
255
386
|
}
|
|
256
387
|
`]
|
|
257
388
|
@property({ attribute: false }) session?: FigureProductSession
|
|
389
|
+
/** The saved figure's state and version, as the server gave them (the page reads the row). */
|
|
390
|
+
@property({ attribute: false }) releaseState?: { state?: 'draft' | 'released'; version: number }
|
|
258
391
|
/** The recipe templates (ADR-0095), read by the page from the server. */
|
|
259
392
|
@property({ attribute: false }) templates: RecipeTemplate[] = []
|
|
260
393
|
/** Reads a template's guide; given by the page. */
|
|
@@ -313,9 +446,17 @@ export class FigureEditor extends LitElement {
|
|
|
313
446
|
* Parts hidden on the workbench — the author's view while working, not the asset's visibility. Not saved: a part
|
|
314
447
|
* hidden here is still in the figure, still checked, still drawn on a board.
|
|
315
448
|
*/
|
|
449
|
+
/** The page offers an AI draft for a new figure (the modeller page, through the app's assistant dock). */
|
|
450
|
+
@property({ type: Boolean }) aiDraft = false
|
|
316
451
|
@state() private hiddenParts = new Set<string>()
|
|
317
452
|
/** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
|
|
318
453
|
@state() private live: LiveValues = NOTHING_LIVE
|
|
454
|
+
/**
|
|
455
|
+
* A command seen before it is done (ADR-0097 §5.5): worked out on a copy for the figure, version, part and command it
|
|
456
|
+
* names, and shown only while all four are still the ones on screen. Nothing of it is saved or undone.
|
|
457
|
+
*/
|
|
458
|
+
@state() private preview?: HeldPreview & { part: string }
|
|
459
|
+
private previewDrawn?: { of: HeldPreview; ghost: FigureRenderPart[]; highlight: FigureRenderPart | null }
|
|
319
460
|
private lastContext: string | null = null
|
|
320
461
|
/**
|
|
321
462
|
* Move, turn and size on the canvas, in the bar's tools: the gizmo the W · E · R keys give, found without knowing the
|
|
@@ -336,7 +477,7 @@ export class FigureEditor extends LitElement {
|
|
|
336
477
|
aria-pressed=${String(this.gizmoMode === tool.mode)}
|
|
337
478
|
?disabled=${!tool.can}
|
|
338
479
|
aria-label=${i18next.t('figure.button.gizmo-' + tool.mode)}
|
|
339
|
-
title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t('figure.text.gizmo-not-own')}`}
|
|
480
|
+
title=${`${i18next.t('figure.button.gizmo-' + tool.mode)} (${tool.key})${tool.can || !this.selected ? '' : ' · ' + i18next.t(tool.mode === 'scale' && this.sizeDragUnsure(this.asset() as FigureAsset, this.selected) ? 'figure.text.gizmo-size-turned-fastening' : 'figure.text.gizmo-not-own', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })}`}
|
|
340
481
|
@click=${() => this.useGizmo(tool.mode)}
|
|
341
482
|
>
|
|
342
483
|
<md-icon>${tool.icon}</md-icon>
|
|
@@ -372,11 +513,38 @@ export class FigureEditor extends LitElement {
|
|
|
372
513
|
connectedCallback() {
|
|
373
514
|
super.connectedCallback()
|
|
374
515
|
window.addEventListener('keydown', this.onKey, true)
|
|
516
|
+
this.widthWatch.observe(this)
|
|
375
517
|
}
|
|
376
518
|
disconnectedCallback() {
|
|
377
519
|
window.removeEventListener('keydown', this.onKey, true)
|
|
520
|
+
this.widthWatch.disconnect()
|
|
378
521
|
super.disconnectedCallback()
|
|
379
522
|
}
|
|
523
|
+
/*
|
|
524
|
+
Narrow, the parts list gives way to the canvas rather than all three shrinking (designer 2026-09-27, U11): under
|
|
525
|
+
FOLD_START_UNDER px the canvas would be left under about 560 px beside both sides. The fold is said once, and the
|
|
526
|
+
bar's own toggle opens it again; opened by the author, it is not folded again until the screen has been wide.
|
|
527
|
+
*/
|
|
528
|
+
private static readonly FOLD_START_UNDER = 1100
|
|
529
|
+
private foldedForWidth = false
|
|
530
|
+
private keptOpenByAuthor = false
|
|
531
|
+
private widthWatch = new ResizeObserver(([entry]) => this.fitPanesTo(entry!.contentRect.width))
|
|
532
|
+
private fitPanesTo(width: number) {
|
|
533
|
+
const panes = this.panes
|
|
534
|
+
if (!panes || !width) return
|
|
535
|
+
if (this.foldedForWidth && !panes.foldStart) this.keptOpenByAuthor = true
|
|
536
|
+
if (width < FigureEditor.FOLD_START_UNDER) {
|
|
537
|
+
if (!panes.foldStart && !this.keptOpenByAuthor) {
|
|
538
|
+
panes.foldStart = true
|
|
539
|
+
this.foldedForWidth = true
|
|
540
|
+
notify({ level: 'info', message: i18next.t('figure.text.parts-panel-folded-narrow') })
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
if (this.foldedForWidth && panes.foldStart) panes.foldStart = false
|
|
544
|
+
this.foldedForWidth = false
|
|
545
|
+
this.keptOpenByAuthor = false
|
|
546
|
+
}
|
|
547
|
+
}
|
|
380
548
|
/**
|
|
381
549
|
* Refusals and notices go to the app's shared surfaces, as every screen's do (user, 2026-09-27: 「왜 모두가 사용하는
|
|
382
550
|
* notification 오류 표현 방법을 사용하지 않고」 · 「혹은 oxprompt 에서 확인하고 진행하던지」). A refusal with a way on --
|
|
@@ -395,6 +563,36 @@ export class FigureEditor extends LitElement {
|
|
|
395
563
|
if (viewError && viewError !== this.toldViewError) notify({ level: 'error', message: viewError })
|
|
396
564
|
this.toldViewError = viewError
|
|
397
565
|
}
|
|
566
|
+
/*
|
|
567
|
+
An edit that would stop clips is refused by figure-model with the clips named (CLIPS_DROPPED). The person is told
|
|
568
|
+
which, and why, before anything is written; agreed, the same edit is made again letting exactly those go, as one
|
|
569
|
+
step undo brings back (designer 2026-09-27).
|
|
570
|
+
*/
|
|
571
|
+
private askClipLoss(error: unknown, again: () => void): boolean {
|
|
572
|
+
if ((error as { code?: string })?.code !== 'CLIPS_DROPPED') return false
|
|
573
|
+
const clips = (error as { clips: FigureClipLoss[] }).clips
|
|
574
|
+
/* The consent is for this edit, on this figure as it is now: held with it while the person reads. */
|
|
575
|
+
const session = this.session!
|
|
576
|
+
const { context, revision } = session.read()
|
|
577
|
+
void this.confirmClipLoss(clips, again, { session, context: context!, revision: revision! })
|
|
578
|
+
return true
|
|
579
|
+
}
|
|
580
|
+
private async confirmClipLoss(clips: FigureClipLoss[], again: () => void, asked: { session: FigureProductSession; context: string; revision: number }) {
|
|
581
|
+
const agreed = await OxPrompt.open({
|
|
582
|
+
type: 'warning',
|
|
583
|
+
text: i18next.t('figure.text.clips-would-go', { clips: clips.map(c => i18next.t(`figure.text.clip-${c.why}`, { clip: c.clip })).join(', ') }),
|
|
584
|
+
confirmButton: { text: i18next.t('figure.button.let-clips-go') },
|
|
585
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
586
|
+
})
|
|
587
|
+
if (!agreed) return
|
|
588
|
+
/* Changed while the person read -- another figure, or an edit in between: the consent was not for this; nothing is applied. */
|
|
589
|
+
const now = this.session === asked.session ? asked.session.read() : undefined
|
|
590
|
+
if (!now || now.context !== asked.context || now.revision !== asked.revision) {
|
|
591
|
+
this.error = i18next.t('figure.text.clips-consent-stale')
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
asked.session.withConsent({ dropClips: clips.map(c => c.id), context: asked.context, revision: asked.revision }, again)
|
|
595
|
+
}
|
|
398
596
|
private async ask(message: string, retry: { label: string; action?: FigureAuthoringAction; run?: () => void }) {
|
|
399
597
|
const agreed = await OxPrompt.open({
|
|
400
598
|
type: 'question',
|
|
@@ -406,6 +604,21 @@ export class FigureEditor extends LitElement {
|
|
|
406
604
|
if (retry.run) retry.run()
|
|
407
605
|
else this.linkCommand(retry.action!)
|
|
408
606
|
}
|
|
607
|
+
/*
|
|
608
|
+
Per model and per part, how far its properties panel was scrolled and which of its folds were open, so going back
|
|
609
|
+
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
|
|
610
|
+
hide which part the panel speaks of. Another model starts clean.
|
|
611
|
+
*/
|
|
612
|
+
private panelMemory = new Map<string, { top: number; open: boolean[] }>()
|
|
613
|
+
private restorePanelFor?: string
|
|
614
|
+
private propertiesPanel() {
|
|
615
|
+
return this.renderRoot.querySelector<HTMLElement>('div[panel]')
|
|
616
|
+
}
|
|
617
|
+
private foldsOf(panel: HTMLElement): HTMLDetailsElement[] {
|
|
618
|
+
const folds = [...panel.querySelectorAll('details')]
|
|
619
|
+
for (const inner of panel.querySelectorAll('figure-links')) folds.push(...(inner.shadowRoot?.querySelectorAll('details') ?? []))
|
|
620
|
+
return folds
|
|
621
|
+
}
|
|
409
622
|
protected willUpdate(_changed: PropertyValues) {
|
|
410
623
|
const view = this.session?.read()
|
|
411
624
|
if (view?.context !== this.lastContext) {
|
|
@@ -413,12 +626,25 @@ export class FigureEditor extends LitElement {
|
|
|
413
626
|
this.selected = ''
|
|
414
627
|
this.error = ''
|
|
415
628
|
this.hiddenParts = new Set()
|
|
629
|
+
this.panelMemory.clear()
|
|
630
|
+
} else if (_changed.has('selected')) {
|
|
631
|
+
const panel = this.propertiesPanel()
|
|
632
|
+
const was = (_changed.get('selected') as string | undefined) ?? ''
|
|
633
|
+
if (panel) this.panelMemory.set(was || '@model', { top: panel.scrollTop, open: this.foldsOf(panel).map(d => d.open) })
|
|
634
|
+
this.restorePanelFor = this.selected || '@model'
|
|
416
635
|
}
|
|
417
636
|
if (view && !view.geometry.some(g => ownerOf(g.id) === this.selected)) this.selected = ''
|
|
418
637
|
if (_changed.has('selected')) this.gizmoMode = undefined
|
|
419
638
|
}
|
|
420
639
|
/** The scene toggles: the mode already shown takes the gizmo away. */
|
|
421
640
|
private useGizmo(mode: 'translate' | 'rotate' | 'scale') {
|
|
641
|
+
if (mode === 'scale' && this.selected && this.gizmoMode !== 'scale') {
|
|
642
|
+
const asset = this.asset() as FigureAsset | undefined
|
|
643
|
+
if (asset && this.sizeDragUnsure(asset, this.selected)) {
|
|
644
|
+
this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(this.selected), extreme: this.keptExtremeWord() })
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
}
|
|
422
648
|
this.renderRoot.querySelector<FigureCanvas>('figure-canvas')?.gizmo(mode)
|
|
423
649
|
this.gizmoMode = this.gizmoMode === mode ? undefined : mode
|
|
424
650
|
}
|
|
@@ -437,6 +663,7 @@ export class FigureEditor extends LitElement {
|
|
|
437
663
|
this.notice = ''
|
|
438
664
|
this.changed()
|
|
439
665
|
} catch (error) {
|
|
666
|
+
if (this.askClipLoss(error, () => this.run(action))) return
|
|
440
667
|
const say = (key: string, params: Record<string, string>) => i18next.t(key, params)
|
|
441
668
|
this.error = sizeRangeRefusalOf(error, say) ?? inputRangeRefusalOf(error, say) ?? this.said(error)
|
|
442
669
|
} finally {
|
|
@@ -504,7 +731,8 @@ export class FigureEditor extends LitElement {
|
|
|
504
731
|
await promise
|
|
505
732
|
this.error = ''
|
|
506
733
|
} catch (error) {
|
|
507
|
-
|
|
734
|
+
/* Not reached: whether it was written is not known, and the edits are still here -- said so, not "not saved". */
|
|
735
|
+
this.error = (error as { code?: string })?.code === 'SAVE_UNREACHABLE' ? i18next.t('figure.text.save-unreachable') : this.said(error)
|
|
508
736
|
} finally {
|
|
509
737
|
this.changed()
|
|
510
738
|
}
|
|
@@ -544,6 +772,7 @@ export class FigureEditor extends LitElement {
|
|
|
544
772
|
if (this.selected === id) this.selected = ''
|
|
545
773
|
this.changed()
|
|
546
774
|
} catch (error) {
|
|
775
|
+
if (this.askClipLoss(error, () => this.removePart(id, refitRoom))) return
|
|
547
776
|
/*
|
|
548
777
|
A part in the declared space goes, and the space is fitted to the parts left, in the same click: every space
|
|
549
778
|
this product declares is fitted to the parts (「부품에 맞추기」, the AI's fit-room -- no padding), so fitting it
|
|
@@ -601,11 +830,27 @@ export class FigureEditor extends LitElement {
|
|
|
601
830
|
/* A repeat is moved and sized through its row, not by a gizmo on one copy. */
|
|
602
831
|
if (!id || !this.session || !this.session.read().geometry.some(g => g.id === id)) return none
|
|
603
832
|
try {
|
|
604
|
-
|
|
833
|
+
const asset = JSON.parse(this.session.source())
|
|
834
|
+
const reach = gizmoReachOf(asset, id)
|
|
835
|
+
/*
|
|
836
|
+
A size drag shows the face it keeps and the commit keeps the same (figureScaleAnchorOf). Where figure-model cannot
|
|
837
|
+
say which face that is -- a part fastened turned or twisted -- the drag would show one result and write another,
|
|
838
|
+
so sizing by the gizmo is not offered there; the fields still size it (designer 2026-09-27).
|
|
839
|
+
*/
|
|
840
|
+
if (reach.scale && this.sizeDragUnsure(asset, id)) return { ...reach, scale: false }
|
|
841
|
+
return reach
|
|
605
842
|
} catch {
|
|
606
843
|
return none
|
|
607
844
|
}
|
|
608
845
|
}
|
|
846
|
+
/** Whether a size drag on this part could not show what is written: figure-model gives no face it keeps. */
|
|
847
|
+
private sizeDragUnsure(asset: FigureAsset, id: string): boolean {
|
|
848
|
+
try {
|
|
849
|
+
return !figureScaleAnchorOf(asset, ownerOf(id))
|
|
850
|
+
} catch {
|
|
851
|
+
return true
|
|
852
|
+
}
|
|
853
|
+
}
|
|
609
854
|
/** A drag, written back as the part's own inputs — one step of the history, like typing the numbers. */
|
|
610
855
|
private transformed(id: string, result: GizmoResult) {
|
|
611
856
|
const view = this.session!.read()
|
|
@@ -629,7 +874,19 @@ export class FigureEditor extends LitElement {
|
|
|
629
874
|
the shift had been taken for a move of the joint, and the size was dropped).
|
|
630
875
|
*/
|
|
631
876
|
const jointed = !!asset.joints?.some(j => j.body1.part === ownerOf(id))
|
|
632
|
-
|
|
877
|
+
/* A size drag this part cannot show truly is not written: it springs back, and the reason is said. */
|
|
878
|
+
if (result.scale && result.scale.some(k => Math.abs(k - 1) > 1e-4) && this.sizeDragUnsure(asset, id)) {
|
|
879
|
+
this.error = i18next.t('figure.text.gizmo-size-turned-fastening', { part: partLabelOf(id), extreme: this.keptExtremeWord() })
|
|
880
|
+
return this.requestUpdate()
|
|
881
|
+
}
|
|
882
|
+
/*
|
|
883
|
+
A size dragged keeps the face the figure's placement keeps, as a size typed does (figure-model's one rule): the
|
|
884
|
+
gizmo's own anchoring on the opposite face is what the drag shows, not what is written (designer 2026-09-27).
|
|
885
|
+
*/
|
|
886
|
+
const scaled = !!result.scale && !result.rotation && result.scale.some(k => Math.abs(k - 1) > 1e-4)
|
|
887
|
+
const taken = jointed || scaled ? { scale: result.scale } : result
|
|
888
|
+
const written = gizmoChanges(id, this.reachOf(id), taken, sizes)
|
|
889
|
+
const changes = scaled && !jointed ? figureSizeEditChanges(asset, ownerOf(id), written) : written
|
|
633
890
|
if (changes.length) {
|
|
634
891
|
this.run(() => this.session!.apply(changes))
|
|
635
892
|
return
|
|
@@ -692,6 +949,52 @@ export class FigureEditor extends LitElement {
|
|
|
692
949
|
this.selected = copy
|
|
693
950
|
})
|
|
694
951
|
}
|
|
952
|
+
/**
|
|
953
|
+
* The one-off "put it on the plane", by the figure's placement (designer 2026-09-27): figure-model's rule
|
|
954
|
+
* (`figurePlaneAlignChanges`) -- a floor figure's part to the floor by its bottom, a ceiling one's to the ceiling by
|
|
955
|
+
* 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
|
|
956
|
+
* fastened. Refused, it says why.
|
|
957
|
+
*/
|
|
958
|
+
/* What a tilted part's typed size keeps, by the figure's placement: not the floor's lowest point everywhere (designer 2026-09-27). */
|
|
959
|
+
private keptExtremeWord(): string {
|
|
960
|
+
const placement = (this.asset() as FigureAsset | undefined)?.placement ?? 'floor'
|
|
961
|
+
return i18next.t(`figure.label.kept-extreme-${placement}`)
|
|
962
|
+
}
|
|
963
|
+
/* How the selected part would meet the plane, kept per source and part: working it out compiles the figure. */
|
|
964
|
+
private planeContact?: { source: string; part: string; contact?: FigurePlaneContact }
|
|
965
|
+
private planeContactOf(source: string, part: string): FigurePlaneContact | undefined {
|
|
966
|
+
if (this.planeContact?.source !== source || this.planeContact.part !== part) {
|
|
967
|
+
let contact: FigurePlaneContact | undefined
|
|
968
|
+
try {
|
|
969
|
+
contact = figurePlaneAlignChanges(parseFigureAsset(source), part).contact
|
|
970
|
+
} catch {
|
|
971
|
+
contact = undefined
|
|
972
|
+
}
|
|
973
|
+
this.planeContact = { source, part, contact }
|
|
974
|
+
}
|
|
975
|
+
return this.planeContact.contact
|
|
976
|
+
}
|
|
977
|
+
private renderPlaneAlign(asset: FigureAsset, unavailable: boolean) {
|
|
978
|
+
const placement = asset.placement ?? 'floor'
|
|
979
|
+
const icon = { floor: 'vertical_align_bottom', ceiling: 'vertical_align_top', wall: 'flip_to_back', center: 'vertical_align_center' }[placement]
|
|
980
|
+
/* 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). */
|
|
981
|
+
const contact = this.planeContactOf(this.session!.source(), this.selected)
|
|
982
|
+
const told = contact === 'tilted' || contact === 'extreme' ? `-${contact}` : ''
|
|
983
|
+
return html`<button plane-align=${placement} ?disabled=${unavailable} aria-label=${i18next.t(`figure.button.align-to-${placement}`)}
|
|
984
|
+
title=${i18next.t(`figure.text.align-to-${placement}${told}`)} @click=${() => this.alignToPlane()}>
|
|
985
|
+
<md-icon>${icon}</md-icon>
|
|
986
|
+
</button>`
|
|
987
|
+
}
|
|
988
|
+
private alignToPlane() {
|
|
989
|
+
const asset = parseFigureAsset(this.session!.source())
|
|
990
|
+
const { changes, contact, refusal } = figurePlaneAlignChanges(asset, this.selected)
|
|
991
|
+
if (refusal) {
|
|
992
|
+
this.error = i18next.t(`figure.text.align-refused-${refusal.toLowerCase().replace(/_/g, '-')}`, { part: partLabelOf(this.selected) })
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
if (changes.length) this.run(() => this.session!.apply(changes))
|
|
996
|
+
if ((contact === 'tilted' || contact === 'extreme') && !this.error) this.notice = i18next.t(`figure.text.aligned-${contact}-${asset.placement ?? 'floor'}`)
|
|
997
|
+
}
|
|
695
998
|
private renderTools(unavailable: boolean) {
|
|
696
999
|
const reach = this.reachOf(this.selected)
|
|
697
1000
|
if (!this.selected || !reach.translate) return null
|
|
@@ -708,7 +1011,7 @@ export class FigureEditor extends LitElement {
|
|
|
708
1011
|
return html`<div tools title=${i18next.t('figure.text.tools-explained')}>
|
|
709
1012
|
${tool('centre-x', 'horizontal_distribute', 'figure.button.center-x', 'figure.text.center-on-x')}
|
|
710
1013
|
${tool('centre-z', 'vertical_align_center', 'figure.button.center-z', 'figure.text.center-on-z')}
|
|
711
|
-
${
|
|
1014
|
+
${this.renderPlaneAlign(asset as FigureAsset, unavailable)}
|
|
712
1015
|
${(['x', 'z'] as const).map(axis => {
|
|
713
1016
|
const able = canMirror(asset as never, this.selected, axis, reach)
|
|
714
1017
|
return html`<button
|
|
@@ -779,9 +1082,35 @@ export class FigureEditor extends LitElement {
|
|
|
779
1082
|
.dims=${dims}
|
|
780
1083
|
?locked=${unavailable}
|
|
781
1084
|
@command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
|
|
1085
|
+
.previewWords=${previewWordsOf(this.currentPreview(), this.selected, (key, params) => i18next.t(key, params))}
|
|
1086
|
+
.detachWords=${this.detachWords(asset as FigureAsset | undefined)}
|
|
1087
|
+
.overhangWords=${section === 'shape' ? this.overhangWords(asset as FigureAsset | undefined) : []}
|
|
1088
|
+
.overhangNote=${section === 'shape' ? this.overhangNote(asset as FigureAsset | undefined) : []}
|
|
1089
|
+
.motionEnds=${section === 'motion' ? this.motionEnds() : null}
|
|
1090
|
+
@go-fasten=${() => this.goFasten()}
|
|
1091
|
+
@preview-command=${(e: CustomEvent) => this.previewCommand(e.detail)}
|
|
1092
|
+
@resize-with-placement=${(e: CustomEvent) => this.resizeWithPlacement(e.detail.request)}
|
|
782
1093
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
783
1094
|
></figure-links>`
|
|
784
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* The face a size drag keeps for the selected part, figure-model's rule (`figureScaleAnchorOf`): the gizmo shows it
|
|
1098
|
+
* while dragging and the commit keeps it, so nothing jumps on release. A part whose fastening is turned has none the
|
|
1099
|
+
* rule can say, and the gizmo keeps its own (the face across from the handle).
|
|
1100
|
+
*/
|
|
1101
|
+
private anchorsOf(asset: FigureAsset | undefined): Record<string, ScaleAnchor> {
|
|
1102
|
+
let next: Record<string, ScaleAnchor> = {}
|
|
1103
|
+
try {
|
|
1104
|
+
const anchor = asset && this.selected ? figureScaleAnchorOf(asset, this.selected) : undefined
|
|
1105
|
+
if (anchor) next = { [this.selected]: anchor }
|
|
1106
|
+
} catch {
|
|
1107
|
+
next = {}
|
|
1108
|
+
}
|
|
1109
|
+
/* The same object while it says the same thing: the canvas stands its parts up again when this changes. */
|
|
1110
|
+
if (JSON.stringify(next) !== JSON.stringify(this.lastAnchors)) this.lastAnchors = next
|
|
1111
|
+
return this.lastAnchors
|
|
1112
|
+
}
|
|
1113
|
+
private lastAnchors: Record<string, ScaleAnchor> = {}
|
|
785
1114
|
/** The figure's controls: try a value, name it, set its range and travel time, keep the pose. */
|
|
786
1115
|
private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
|
|
787
1116
|
let asset
|
|
@@ -808,6 +1137,30 @@ export class FigureEditor extends LitElement {
|
|
|
808
1137
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
809
1138
|
></figure-clips>`
|
|
810
1139
|
}
|
|
1140
|
+
/**
|
|
1141
|
+
* "Follow the size and keep where the figure is placed" (ADR-0097): figure-model plans it from the figure as it is now,
|
|
1142
|
+
* and the whole plan is one step of the history. A refusal with a way on -- move the part onto the plane, replace how
|
|
1143
|
+
* its position is worked out -- asks in the app's prompt and plans again with that choice; any other says why.
|
|
1144
|
+
*/
|
|
1145
|
+
private resizeWithPlacement(request: FigureResizeRequest) {
|
|
1146
|
+
this.retry = undefined
|
|
1147
|
+
this.notice = ''
|
|
1148
|
+
const plan = planFigureResize(parseFigureAsset(this.session!.source()), request)
|
|
1149
|
+
const refusal = plan.refusal
|
|
1150
|
+
if (refusal) {
|
|
1151
|
+
const choice = refusal.choice
|
|
1152
|
+
/* The gap is chosen on the screen beside the button, with its distance; only replacing a position rule is asked here. */
|
|
1153
|
+
this.retry = choice === 'replacePosition' ? { label: i18next.t('figure.button.resize-choice-replace-position'), run: () => this.resizeWithPlacement({ ...request, replacePosition: true }) } : undefined
|
|
1154
|
+
this.error = resizeRefusalOf(plan, request.part, (key, params) => i18next.t(key, params))
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
this.run(() => this.session!.applyRecipe(plan.steps))
|
|
1158
|
+
/*
|
|
1159
|
+
The declared room was measured from the part's old numbers and is not changed: said as a fact, not as a claim that
|
|
1160
|
+
the part leaves it -- that is measured where the room is declared again (designer 2026-09-27).
|
|
1161
|
+
*/
|
|
1162
|
+
if (!this.error && plan.roomReads.length) this.notice = i18next.t('figure.text.resize-room-unchanged')
|
|
1163
|
+
}
|
|
811
1164
|
/** One command, one step of the history. A refusal a person can meet here is said in their words. */
|
|
812
1165
|
private linkCommand(action: FigureAuthoringAction) {
|
|
813
1166
|
this.retry = undefined
|
|
@@ -827,6 +1180,7 @@ export class FigureEditor extends LitElement {
|
|
|
827
1180
|
}
|
|
828
1181
|
this.changed()
|
|
829
1182
|
} catch (error) {
|
|
1183
|
+
if (this.askClipLoss(error, () => this.linkCommand(action))) return
|
|
830
1184
|
const code = (error as { code?: string })?.code
|
|
831
1185
|
const narrowing = narrowingRetryOf(action, error, (key, params) => i18next.t(key, params))
|
|
832
1186
|
if (narrowing) {
|
|
@@ -847,15 +1201,10 @@ export class FigureEditor extends LitElement {
|
|
|
847
1201
|
private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
|
|
848
1202
|
const input = event.target as HTMLInputElement,
|
|
849
1203
|
value = input.valueAsNumber
|
|
850
|
-
/* 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. */
|
|
851
1205
|
const part = this.selected
|
|
852
|
-
const
|
|
853
|
-
this.run(() =>
|
|
854
|
-
this.session!.apply([
|
|
855
|
-
{ kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value },
|
|
856
|
-
...kept
|
|
857
|
-
])
|
|
858
|
-
)
|
|
1206
|
+
const change: FigureInputChange = { 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]))
|
|
859
1208
|
// Rejected edits must also restore the displayed numeric value.
|
|
860
1209
|
input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
|
|
861
1210
|
}
|
|
@@ -1000,7 +1349,14 @@ export class FigureEditor extends LitElement {
|
|
|
1000
1349
|
* The parts list, one row per part the author made: a repeat is one row with how many copies it makes now
|
|
1001
1350
|
* (user, 2026-09-24: the conveyor's sixteen rollers were sixteen rows).
|
|
1002
1351
|
*/
|
|
1003
|
-
private partRows(geometry: readonly { id: string }[]): FigurePartRow[] {
|
|
1352
|
+
private partRows(geometry: readonly { id: string }[], asset?: FigureAsset): FigurePartRow[] {
|
|
1353
|
+
/* Where each part is fastened, straight above it: `part · face`, or the mounting plane (ADR-0097 §5.5). */
|
|
1354
|
+
const fastened = new Map(
|
|
1355
|
+
(asset?.joints ?? []).map(j => [
|
|
1356
|
+
j.body1.part,
|
|
1357
|
+
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(`figure.label.face-${j.body0.face}`)}`
|
|
1358
|
+
])
|
|
1359
|
+
)
|
|
1004
1360
|
const rows = new Map<string, FigurePartRow>()
|
|
1005
1361
|
for (const g of geometry) {
|
|
1006
1362
|
const id = ownerOf(g.id)
|
|
@@ -1014,7 +1370,8 @@ export class FigureEditor extends LitElement {
|
|
|
1014
1370
|
provider: (g as { shape?: { provider?: string } }).shape?.provider ?? '',
|
|
1015
1371
|
hidden: this.hiddenParts.has(id),
|
|
1016
1372
|
invisible: (g as { visible?: boolean }).visible === false,
|
|
1017
|
-
...(id !== g.id ? { copies: 1 } : {})
|
|
1373
|
+
...(id !== g.id ? { copies: 1 } : {}),
|
|
1374
|
+
...(fastened.has(id) ? { fastenedTo: fastened.get(id)! } : {})
|
|
1018
1375
|
})
|
|
1019
1376
|
}
|
|
1020
1377
|
return [...rows.values()]
|
|
@@ -1070,6 +1427,7 @@ export class FigureEditor extends LitElement {
|
|
|
1070
1427
|
@change=${(e: Event) => this.setPitch(field, e)}
|
|
1071
1428
|
/>
|
|
1072
1429
|
</div>
|
|
1430
|
+
<ul row-reading>${rowWordsOf(this.rowNow(asset), (key, params) => i18next.t(key, params)).map(line => html`<li>${line}</li>`)}</ul>
|
|
1073
1431
|
<p hint>
|
|
1074
1432
|
${fits && length !== undefined && longest !== undefined && limit !== undefined
|
|
1075
1433
|
? i18next.t('figure.text.repeat-what-pitch-gives', {
|
|
@@ -1088,6 +1446,14 @@ export class FigureEditor extends LitElement {
|
|
|
1088
1446
|
: nothing}
|
|
1089
1447
|
</section>`
|
|
1090
1448
|
}
|
|
1449
|
+
/** The selected row as it is drawn now, read by figure-model: nothing when it cannot be read. */
|
|
1450
|
+
private rowNow(asset: FigureAsset): FigureRowReading | undefined {
|
|
1451
|
+
try {
|
|
1452
|
+
return figureRowOf(asset, this.selected, this.currentInputs())
|
|
1453
|
+
} catch {
|
|
1454
|
+
return undefined
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1091
1457
|
/** The design input a row's pitch is read from, when it is read from one (ADR-0096). */
|
|
1092
1458
|
private pitchInputOf(asset: FigureAsset, part: string): string | undefined {
|
|
1093
1459
|
const pitch = asset.joints?.find(j => j.body1.part === part && j.repeat)?.repeat?.pitch as { from?: { of?: string; input?: string } } | number | undefined
|
|
@@ -1194,6 +1560,179 @@ export class FigureEditor extends LitElement {
|
|
|
1194
1560
|
}
|
|
1195
1561
|
}
|
|
1196
1562
|
/** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
|
|
1563
|
+
/*
|
|
1564
|
+
Saving and releasing told apart, beside the save (ADR-0097 §5.6, Q-D2): what the saved figure is -- a draft never
|
|
1565
|
+
released, a draft while boards use an earlier release, a release -- as the server said it. Edits not saved are the
|
|
1566
|
+
status before it. Boards read a draft's latest release (figureBlueprint), so that is what is said.
|
|
1567
|
+
*/
|
|
1568
|
+
private releaseWords(view: { id?: string | null }): string {
|
|
1569
|
+
if (!view.id || !this.releaseState?.state) return ''
|
|
1570
|
+
const { state, version } = this.releaseState
|
|
1571
|
+
if (state === 'released') return i18next.t('figure.text.release-state-released', { version: String(version) })
|
|
1572
|
+
return version > 0 ? i18next.t('figure.text.release-state-draft-over', { version: String(version) }) : i18next.t('figure.text.release-state-draft-new')
|
|
1573
|
+
}
|
|
1574
|
+
/** To the release that exists: the history tab's release section, not a second one here. */
|
|
1575
|
+
private goRelease = async () => {
|
|
1576
|
+
this.tab = 'history'
|
|
1577
|
+
await this.updateComplete
|
|
1578
|
+
await this.querySelector<HTMLElement & { showRelease?: () => Promise<void> }>('figure-history-panel')?.showRelease?.()
|
|
1579
|
+
}
|
|
1580
|
+
/** The fastening form's command, seen before it is done; null clears what was shown. */
|
|
1581
|
+
private previewCommand(candidate: { action: FigureAuthoringAction; face?: { part: string; face: string } } | null) {
|
|
1582
|
+
if (!candidate || !this.session || !this.selected) {
|
|
1583
|
+
this.preview = undefined
|
|
1584
|
+
return
|
|
1585
|
+
}
|
|
1586
|
+
const view = this.session.read()
|
|
1587
|
+
const held = { context: view.context!, revision: view.revision!, key: JSON.stringify(candidate), part: this.selected }
|
|
1588
|
+
try {
|
|
1589
|
+
const seen = this.session.previewCommand(candidate.action)
|
|
1590
|
+
let face: FigureFaceArea | undefined
|
|
1591
|
+
if (candidate.face) {
|
|
1592
|
+
try {
|
|
1593
|
+
face = figurePartFaceOf(parseFigureAsset(this.session.source()), candidate.face.part, candidate.face.face, this.currentInputs())
|
|
1594
|
+
} catch {
|
|
1595
|
+
face = undefined
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
/* A row is read on the figure the command would make, by figure-model, as drawn. */
|
|
1599
|
+
const row = candidate.action.kind === 'make-repeat' ? figureRowOf(seen.asset, this.selected, this.currentInputs()) : undefined
|
|
1600
|
+
this.preview = {
|
|
1601
|
+
...held,
|
|
1602
|
+
context: seen.context,
|
|
1603
|
+
revision: seen.revision,
|
|
1604
|
+
geometry: seen.geometry,
|
|
1605
|
+
moves: seen.moves,
|
|
1606
|
+
added: seen.added,
|
|
1607
|
+
...(row ? { row } : {}),
|
|
1608
|
+
clipsWouldGo: seen.clipsWouldGo,
|
|
1609
|
+
face,
|
|
1610
|
+
faceUnknown: !!candidate.face && !face
|
|
1611
|
+
}
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
/* Refused: nothing of an earlier result stays on screen, and the reason is said. */
|
|
1614
|
+
this.preview = { ...held, refused: this.said(error) }
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
/** The preview, while it still speaks of what is on screen: this figure, this version, this part. */
|
|
1618
|
+
private currentPreview(): (HeldPreview & { part: string }) | undefined {
|
|
1619
|
+
const p = this.preview
|
|
1620
|
+
const view = this.session?.read()
|
|
1621
|
+
/* Only where its form is: the properties panel stays in the page on another tab, and did not clear it (2026-09-27, :3391). */
|
|
1622
|
+
if (!p || !view || p.context !== view.context || p.revision !== view.revision || p.part !== this.selected || this.middle !== 'edit' || this.tab !== 'properties') return undefined
|
|
1623
|
+
return p
|
|
1624
|
+
}
|
|
1625
|
+
/*
|
|
1626
|
+
What taking the selected part off would do, worked out on a copy before the button is pressed (ADR-0097 §5.5): whether
|
|
1627
|
+
it stays where it stands now, what stays fastened to it, which clips would go. "It stays where it is" is said only
|
|
1628
|
+
when the copy shows it does.
|
|
1629
|
+
*/
|
|
1630
|
+
private detachSaid?: { key: string; words: string[] }
|
|
1631
|
+
private detachWords(asset: FigureAsset | undefined): string[] {
|
|
1632
|
+
const view = this.session?.read()
|
|
1633
|
+
const joint = asset?.joints?.find(j => j.body1.part === this.selected)
|
|
1634
|
+
if (!asset || !view || !joint || !this.session) return []
|
|
1635
|
+
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1636
|
+
if (this.detachSaid?.key === key) return this.detachSaid.words
|
|
1637
|
+
const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
|
|
1638
|
+
const from = 'plane' in joint.body0 ? say('figure.label.mounting-plane') : partLabelOf(joint.body0.part)
|
|
1639
|
+
let words: string[]
|
|
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) words.push(say('figure.text.detach-keeps-children', { parts: children.join(', ') }))
|
|
1648
|
+
if (seen.clipsWouldGo.length) words.push(say('figure.text.detach-clips', { clips: seen.clipsWouldGo.map(c => c.clip).join(', ') }))
|
|
1649
|
+
} catch (error) {
|
|
1650
|
+
words = [say('figure.text.detach-refused', { reason: this.said(error) })]
|
|
1651
|
+
}
|
|
1652
|
+
this.detachSaid = { key, words }
|
|
1653
|
+
return words
|
|
1654
|
+
}
|
|
1655
|
+
/*
|
|
1656
|
+
Past the edge of the face a part is fastened to (ADR-0097 §5.5, Q3): the selected part's own, and the parts fastened
|
|
1657
|
+
to it -- said apart from the room, nothing moved. Read once per figure, version and part.
|
|
1658
|
+
*/
|
|
1659
|
+
private overhangSaid?: { key: string; words: string[]; note: string[] }
|
|
1660
|
+
/** Wider than its face: a fact of the relation, said in its details, not as a warning (designer 2026-09-27). */
|
|
1661
|
+
private overhangNote(asset: FigureAsset | undefined): string[] {
|
|
1662
|
+
this.overhangWords(asset)
|
|
1663
|
+
return this.overhangSaid?.note ?? []
|
|
1664
|
+
}
|
|
1665
|
+
private overhangWords(asset: FigureAsset | undefined): string[] {
|
|
1666
|
+
const view = this.session?.read()
|
|
1667
|
+
if (!asset || !view || !this.selected) return []
|
|
1668
|
+
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1669
|
+
if (this.overhangSaid?.key === key) return this.overhangSaid.words
|
|
1670
|
+
const say = (k: string, params?: Record<string, string>) => i18next.t(k, params)
|
|
1671
|
+
const where = (o: FigureFaceOverhang) => ({ on: partLabelOf(o.on.part), face: say(`figure.label.face-${o.on.face}`) })
|
|
1672
|
+
const sides = (o: FigureFaceOverhang) => o.sides.map(s => say(`figure.text.overhang-side-${s.side}`, { axis: s.axis.toUpperCase(), mm: String(s.mm) })).join(', ')
|
|
1673
|
+
const words: string[] = []
|
|
1674
|
+
const note: string[] = []
|
|
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) note.push(say('figure.text.overhang-lopsided', { axes: own.lopsided.map(a => a.toUpperCase()).join(', ') }))
|
|
1681
|
+
}
|
|
1682
|
+
if (own?.state === 'out') words.push(say('figure.text.overhang-out', { ...where(own), sides: sides(own) }))
|
|
1683
|
+
else if (own?.state === 'possible') words.push(say('figure.text.overhang-possible', where(own)))
|
|
1684
|
+
else if (own?.state === 'unknown') words.push(say('figure.text.overhang-unknown', where(own)))
|
|
1685
|
+
const held = (asset.joints ?? []).filter(j => 'part' in j.body0 && j.body0.part === this.selected).map(j => figureFaceOverhangOf(asset, j.body1.part, inputs))
|
|
1686
|
+
const past = held.filter((o): o is FigureFaceOverhang => o?.state === 'out' || o?.state === 'possible')
|
|
1687
|
+
if (past.length) words.push(say('figure.text.overhang-held', { parts: past.map(o => partLabelOf(o.part)).join(', ') }))
|
|
1688
|
+
} catch {
|
|
1689
|
+
/* Not read: nothing said rather than a guess. */
|
|
1690
|
+
}
|
|
1691
|
+
this.overhangSaid = { key, words, note }
|
|
1692
|
+
return words
|
|
1693
|
+
}
|
|
1694
|
+
/** The selected part's motion at both ends of its range, made once per figure, version and part. */
|
|
1695
|
+
private motionEndsSaid?: { key: string; ends: MotionEnds | null }
|
|
1696
|
+
private motionEnds(): MotionEnds | null {
|
|
1697
|
+
const view = this.session?.read()
|
|
1698
|
+
if (!view || !this.selected) return null
|
|
1699
|
+
const key = `${view.context}|${view.revision}|${this.selected}`
|
|
1700
|
+
if (this.motionEndsSaid?.key !== key) {
|
|
1701
|
+
let ends: MotionEnds | null = null
|
|
1702
|
+
try {
|
|
1703
|
+
ends = motionEndsOf(parseFigureAsset(this.session!.source()), this.selected, this.currentInputs())
|
|
1704
|
+
} catch {
|
|
1705
|
+
ends = null
|
|
1706
|
+
}
|
|
1707
|
+
this.motionEndsSaid = { key, ends }
|
|
1708
|
+
}
|
|
1709
|
+
return this.motionEndsSaid.ends
|
|
1710
|
+
}
|
|
1711
|
+
/** "Fasten it first", from the motion tab: to the properties tab, the fastening form open on this part. */
|
|
1712
|
+
private async goFasten() {
|
|
1713
|
+
this.tab = 'properties'
|
|
1714
|
+
await this.updateComplete
|
|
1715
|
+
const links = [...this.renderRoot.querySelectorAll<HTMLElement & { section?: string; showFastening?: () => Promise<void> }>('figure-links')].find(l => l.section === 'shape')
|
|
1716
|
+
await links?.showFastening?.()
|
|
1717
|
+
}
|
|
1718
|
+
/** What the canvas draws of it, made once per preview: a new list every render would stand the board up each time. */
|
|
1719
|
+
private previewShown(): { ghost: FigureRenderPart[]; highlight: FigureRenderPart | null } {
|
|
1720
|
+
const p = this.currentPreview()
|
|
1721
|
+
/* On the motion tab, with nothing being tried: where the selected part's motion takes it, at both ends. */
|
|
1722
|
+
if (!p && this.tab === 'motion' && this.middle === 'edit') return { ghost: this.motionEnds()?.ghost ?? [], highlight: null }
|
|
1723
|
+
if (!p || p.refused) return { ghost: [], highlight: null }
|
|
1724
|
+
if (this.previewDrawn?.of !== p) this.previewDrawn = { of: p, ghost: ghostPartsOf(p), highlight: faceHighlightOf(p.face) }
|
|
1725
|
+
return this.previewDrawn
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* 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
|
|
1729
|
+
* what the preview is trying. Read at the moment of sending, never cached, and never a reason to start a new
|
|
1730
|
+
* conversation -- selecting is not a new subject.
|
|
1731
|
+
*/
|
|
1732
|
+
assistantFocus(): { part?: string; trying?: { clips?: Record<string, number>; controls?: Record<string, number> } } {
|
|
1733
|
+
const trying = this.middle === 'preview' ? this.renderRoot.querySelector<HTMLElement & { tried?: () => { clips?: Record<string, number>; controls?: Record<string, number> } | undefined }>('figure-preview')?.tried?.() : undefined
|
|
1734
|
+
return { ...(this.selected ? { part: this.selected } : {}), ...(trying ? { trying } : {}) }
|
|
1735
|
+
}
|
|
1197
1736
|
private choose(id: string) {
|
|
1198
1737
|
/* A copy on the canvas selects its repeat: the part the author made. */
|
|
1199
1738
|
this.selected = id ? ownerOf(id) : id
|
|
@@ -1206,26 +1745,126 @@ export class FigureEditor extends LitElement {
|
|
|
1206
1745
|
return undefined
|
|
1207
1746
|
}
|
|
1208
1747
|
}
|
|
1748
|
+
/** A side tab says what it is in words under its icon: the icons alone had to be learnt (designer 2026-09-27, U05). */
|
|
1209
1749
|
private renderTab(tab: SideTab, icon: string, label: string, badge?: number) {
|
|
1210
|
-
|
|
1211
|
-
|
|
1750
|
+
/* Its accessible name is the name it shows (designer 2026-09-27); the longer title stays as its tooltip. */
|
|
1751
|
+
const name = i18next.t('figure.label.tab-short-' + tab)
|
|
1752
|
+
return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${name} @click=${() => (this.tab = tab)}>
|
|
1753
|
+
<md-icon>${icon}</md-icon><span tab-label aria-hidden="true">${name}</span>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
|
|
1212
1754
|
</button>`
|
|
1213
1755
|
}
|
|
1214
1756
|
updated(changed: PropertyValues<this>) {
|
|
1215
1757
|
super.updated?.(changed)
|
|
1216
1758
|
this.tell(changed)
|
|
1759
|
+
if (this.restorePanelFor !== undefined) {
|
|
1760
|
+
const key = this.restorePanelFor
|
|
1761
|
+
this.restorePanelFor = undefined
|
|
1762
|
+
requestAnimationFrame(async () => {
|
|
1763
|
+
const panel = this.propertiesPanel()
|
|
1764
|
+
if (!panel || (this.selected || '@model') !== key) return
|
|
1765
|
+
await Promise.all([...panel.querySelectorAll<HTMLElement & { updateComplete?: Promise<unknown> }>('figure-links')].map(el => el.updateComplete))
|
|
1766
|
+
const kept = this.panelMemory.get(key)
|
|
1767
|
+
if (kept) this.foldsOf(panel).forEach((fold, i) => i < kept.open.length && (fold.open = kept.open[i]!))
|
|
1768
|
+
panel.scrollTop = kept?.top ?? 0
|
|
1769
|
+
})
|
|
1770
|
+
}
|
|
1217
1771
|
const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
|
|
1218
|
-
if (panes !== this.panes)
|
|
1772
|
+
if (panes !== this.panes) {
|
|
1773
|
+
this.panes = panes
|
|
1774
|
+
this.fitPanesTo(this.getBoundingClientRect().width)
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape, a
|
|
1779
|
+
* recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
|
|
1780
|
+
* mounting face and the tags are not asked for first.
|
|
1781
|
+
*/
|
|
1782
|
+
private renderStart() {
|
|
1783
|
+
const shapes: FigurePaletteKind[] = ['box', 'cylinder', 'plate']
|
|
1784
|
+
/* Where it is authored from, chosen before any part (ADR-0097): the figure's own setting, no room made up for it. */
|
|
1785
|
+
const placement = ((this.asset() as FigureAsset | undefined)?.placement ?? 'floor') as FigurePlacement
|
|
1786
|
+
return html`<div start role="region" aria-label=${i18next.t('figure.text.start-question')}>
|
|
1787
|
+
<h2>${i18next.t('figure.text.start-question')}</h2>
|
|
1788
|
+
<div start-paths>
|
|
1789
|
+
<section>
|
|
1790
|
+
<h3>${i18next.t('figure.label.start-placement')}</h3>
|
|
1791
|
+
<div start-shapes role="radiogroup" aria-label=${i18next.t('figure.label.start-placement')}>
|
|
1792
|
+
${(['floor', 'ceiling', 'wall', 'center'] as const).map(
|
|
1793
|
+
one => html`<button start-button role="radio" aria-checked=${String(one === placement)} ?pressed=${one === placement}
|
|
1794
|
+
@click=${() => one !== placement && this.linkCommand({ kind: 'set-placement', placement: one })}>
|
|
1795
|
+
${i18next.t(`figure.label.resize-place-${one}`)}
|
|
1796
|
+
</button>`
|
|
1797
|
+
)}
|
|
1798
|
+
</div>
|
|
1799
|
+
<small>${i18next.t('figure.text.placement-is-intent')}</small>
|
|
1800
|
+
</section>
|
|
1801
|
+
<section>
|
|
1802
|
+
<h3>${i18next.t('figure.label.start-shape')}</h3>
|
|
1803
|
+
<div start-shapes>
|
|
1804
|
+
${shapes.map(
|
|
1805
|
+
kind => html`<button start-button start-shape=${kind} @click=${() => this.add(kind)}>
|
|
1806
|
+
${i18next.t(kind === 'box' ? 'figure.label.primitive-cube' : `figure.label.primitive-${kind}`)}
|
|
1807
|
+
</button>`
|
|
1808
|
+
)}
|
|
1809
|
+
</div>
|
|
1810
|
+
<small>${i18next.t('figure.text.start-shape-more')}</small>
|
|
1811
|
+
</section>
|
|
1812
|
+
<section>
|
|
1813
|
+
<h3>${i18next.t('figure.label.start-recipe')}</h3>
|
|
1814
|
+
<button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
|
|
1815
|
+
${i18next.t('figure.button.start-recipe')}
|
|
1816
|
+
</button>
|
|
1817
|
+
${this.templates.length ? nothing : html`<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
|
|
1818
|
+
</section>
|
|
1819
|
+
${this.aiDraft
|
|
1820
|
+
? html`<section>
|
|
1821
|
+
<h3>${i18next.t('figure.label.start-ai')}</h3>
|
|
1822
|
+
<button start-button start-ai @click=${() => this.dispatchEvent(new CustomEvent('ask-ai-draft', { bubbles: true, composed: true }))}>
|
|
1823
|
+
${i18next.t('figure.button.start-ai')}
|
|
1824
|
+
</button>
|
|
1825
|
+
<small>${i18next.t('figure.text.start-ai-optional')}</small>
|
|
1826
|
+
</section>`
|
|
1827
|
+
: nothing}
|
|
1828
|
+
</div>
|
|
1829
|
+
</div>`
|
|
1219
1830
|
}
|
|
1220
|
-
/**
|
|
1831
|
+
/**
|
|
1832
|
+
* Where the panel is, held at its top: the model by its name -- the way back to its settings -- and the part chosen,
|
|
1833
|
+
* with the part's three lines: its kind, how its size answers, what holds it (designer 2026-09-27, B and B.1). The
|
|
1834
|
+
* model name is the same move as the parts list's root; there is no separate "whole figure" link.
|
|
1835
|
+
*/
|
|
1221
1836
|
private renderSubject() {
|
|
1222
|
-
|
|
1837
|
+
const name = this.session?.read()?.name || i18next.t('figure.text.new-figure-name')
|
|
1838
|
+
let summary: { kind: string; size: string; relation: string } | undefined
|
|
1839
|
+
try {
|
|
1840
|
+
summary = this.selected ? partSummaryOf(parseFigureAsset(this.session!.source()), ownerOf(this.selected), (key, params) => i18next.t(key, params)) : undefined
|
|
1841
|
+
} catch {
|
|
1842
|
+
summary = undefined
|
|
1843
|
+
}
|
|
1223
1844
|
return html`<div subject>
|
|
1224
|
-
<
|
|
1225
|
-
<
|
|
1226
|
-
|
|
1845
|
+
<nav path aria-label=${i18next.t('figure.label.panel-path')}>
|
|
1846
|
+
<button model-crumb ?on=${!this.selected} title=${`${i18next.t('figure.button.show-model-settings')} · ${name}`}
|
|
1847
|
+
aria-label=${`${i18next.t('figure.button.show-model-settings')}: ${name}`} @click=${() => this.showModelSettings()}>${name}</button>
|
|
1848
|
+
${this.selected
|
|
1849
|
+
? html`<span aria-hidden="true">›</span><span current aria-current="location" title=${partLabelOf(this.selected)}>${partLabelOf(this.selected)}</span>`
|
|
1850
|
+
: html`<span aria-hidden="true">·</span><span current aria-current="location">${i18next.t('figure.label.model-settings')}</span>`}
|
|
1851
|
+
</nav>
|
|
1852
|
+
${summary
|
|
1853
|
+
? html`<div subject-summary>
|
|
1854
|
+
${summary.kind ? html`<b>${summary.kind}</b>` : nothing}
|
|
1855
|
+
<small>${summary.size}</small><small>${summary.relation}</small>
|
|
1856
|
+
</div>`
|
|
1857
|
+
: nothing}
|
|
1227
1858
|
</div>`
|
|
1228
1859
|
}
|
|
1860
|
+
/**
|
|
1861
|
+
* 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
|
|
1862
|
+
* the properties tab whatever tab was open; it changes no data, no camera, no history and no AI conversation.
|
|
1863
|
+
*/
|
|
1864
|
+
private showModelSettings() {
|
|
1865
|
+
this.selected = ''
|
|
1866
|
+
this.tab = 'properties'
|
|
1867
|
+
}
|
|
1229
1868
|
render() {
|
|
1230
1869
|
const view = this.session?.read()
|
|
1231
1870
|
if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
|
|
@@ -1236,6 +1875,35 @@ export class FigureEditor extends LitElement {
|
|
|
1236
1875
|
/* A repeat's own row values (its pitch) have their own section, not the shape's. */
|
|
1237
1876
|
const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.') && !i.id.startsWith(this.selected + '.repeat.'))
|
|
1238
1877
|
const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(ownerOf(g.id) + '.')))
|
|
1878
|
+
/*
|
|
1879
|
+
A part's field only the declared room still reads -- its old height position once the floor holds it -- moves
|
|
1880
|
+
nothing; it is shown apart as what it is, not as a position (ADR-0097, designer 2026-09-27).
|
|
1881
|
+
*/
|
|
1882
|
+
let roomOnly: string[] = []
|
|
1883
|
+
try {
|
|
1884
|
+
roomOnly = this.selected ? figureRoomOnlyInputsOf(parseFigureAsset(this.session!.source()), this.selected) : []
|
|
1885
|
+
} catch {
|
|
1886
|
+
roomOnly = []
|
|
1887
|
+
}
|
|
1888
|
+
const partFields = own.filter(i => !roomOnly.includes(i.id))
|
|
1889
|
+
/* The numbers a rule gives the axes that have no field of their own -- shown read-only, never as a blank. */
|
|
1890
|
+
let held: HeldValues | undefined
|
|
1891
|
+
try {
|
|
1892
|
+
const body = view.geometry.find(g => g.id === this.selected) as unknown as FigureRenderPart | undefined
|
|
1893
|
+
const placed = body ? placementOf(body) : undefined
|
|
1894
|
+
const parsed = this.selected ? parseFigureAsset(this.session!.source()) : undefined
|
|
1895
|
+
if (placed && parsed)
|
|
1896
|
+
held = {
|
|
1897
|
+
position: placed.position,
|
|
1898
|
+
rotation: placed.rotation,
|
|
1899
|
+
size: (['x', 'y', 'z'] as const).map(axis => measureNow(parsed, ownerOf(this.selected), axis)),
|
|
1900
|
+
/* 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). */
|
|
1901
|
+
sizeRuled: (['x', 'y', 'z'] as const).every(axis => followOf(parsed, ownerOf(this.selected), axis).of !== 'own')
|
|
1902
|
+
}
|
|
1903
|
+
} catch {
|
|
1904
|
+
held = undefined
|
|
1905
|
+
}
|
|
1906
|
+
const roomFields = own.filter(i => roomOnly.includes(i.id))
|
|
1239
1907
|
const status = view.loading
|
|
1240
1908
|
? 'figure.text.loading-figure'
|
|
1241
1909
|
: view.saving
|
|
@@ -1264,6 +1932,8 @@ export class FigureEditor extends LitElement {
|
|
|
1264
1932
|
</button>`
|
|
1265
1933
|
)}
|
|
1266
1934
|
<span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
|
|
1935
|
+
<span slot="status" release-state>${this.releaseWords(view)}</span>
|
|
1936
|
+
<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>
|
|
1267
1937
|
<button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
|
|
1268
1938
|
</ox-canvas-bar>
|
|
1269
1939
|
${this.catalogOpen
|
|
@@ -1283,10 +1953,12 @@ export class FigureEditor extends LitElement {
|
|
|
1283
1953
|
slot="start"
|
|
1284
1954
|
.budget=${this.budget}
|
|
1285
1955
|
@show-maturity=${() => (this.tab = 'verdict')}
|
|
1286
|
-
.parts=${this.partRows(view.geometry)}
|
|
1956
|
+
.parts=${this.partRows(view.geometry, asset as FigureAsset | undefined)}
|
|
1287
1957
|
.partBudget=${this.partBudgetOf(asset as FigureAsset | undefined)}
|
|
1288
1958
|
@set-detail-level=${(e: CustomEvent) => this.linkCommand({ kind: 'set-detail-level', level: e.detail.level })}
|
|
1289
1959
|
.selected=${this.selected}
|
|
1960
|
+
.modelName=${view.name ?? ''}
|
|
1961
|
+
@show-model-settings=${() => this.showModelSettings()}
|
|
1290
1962
|
?locked=${unavailable}
|
|
1291
1963
|
@add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
|
|
1292
1964
|
.recipeCount=${this.templates.length}
|
|
@@ -1305,12 +1977,25 @@ export class FigureEditor extends LitElement {
|
|
|
1305
1977
|
.asset=${asset}
|
|
1306
1978
|
.context=${view.context ?? ''}
|
|
1307
1979
|
.view=${this.view}
|
|
1980
|
+
@configure-size=${async (e: CustomEvent<{ part: string }>) => {
|
|
1981
|
+
this.middle = 'edit'
|
|
1982
|
+
this.selected = e.detail.part
|
|
1983
|
+
this.tab = 'properties'
|
|
1984
|
+
await this.updateComplete
|
|
1985
|
+
this.renderRoot.querySelector('figure-links')?.scrollIntoView({ block: 'nearest' })
|
|
1986
|
+
}}
|
|
1308
1987
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
1309
1988
|
></figure-preview>`
|
|
1310
|
-
: html`<
|
|
1989
|
+
: html`<div middle>
|
|
1990
|
+
${view.geometry.length === 0 && !unavailable ? this.renderStart() : nothing}
|
|
1991
|
+
<figure-canvas
|
|
1992
|
+
?startShown=${view.geometry.length === 0 && !unavailable}
|
|
1311
1993
|
.view=${this.view}
|
|
1312
1994
|
.parts=${this.drawnParts(view.geometry, asset)}
|
|
1995
|
+
.anchors=${this.anchorsOf(asset as FigureAsset | undefined)}
|
|
1313
1996
|
.fan=${this.fanOf(asset as FigureAsset | undefined)}
|
|
1997
|
+
.ghost=${this.previewShown().ghost}
|
|
1998
|
+
.highlight=${this.previewShown().highlight}
|
|
1314
1999
|
.palette=${view.palette ?? {}}
|
|
1315
2000
|
.context=${view.context ?? ''}
|
|
1316
2001
|
.selected=${this.selected}
|
|
@@ -1320,7 +2005,7 @@ export class FigureEditor extends LitElement {
|
|
|
1320
2005
|
@render-error=${(e: CustomEvent<Error>) => {
|
|
1321
2006
|
this.error = e.detail.message
|
|
1322
2007
|
}}
|
|
1323
|
-
></figure-canvas>`}
|
|
2008
|
+
></figure-canvas></div>`}
|
|
1324
2009
|
<aside slot="end">
|
|
1325
2010
|
<div tabs>
|
|
1326
2011
|
${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
|
|
@@ -1349,7 +2034,8 @@ export class FigureEditor extends LitElement {
|
|
|
1349
2034
|
${this.renderRepeatNote(view.geometry)}
|
|
1350
2035
|
</section>
|
|
1351
2036
|
${this.renderRepeat(asset as FigureAsset | undefined, view.geometry, unavailable)}
|
|
1352
|
-
${this.renderGroups(groupFields(
|
|
2037
|
+
${this.renderGroups(this.withSizeRow(groupFields(partFields, this.selected), held), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable), held)}
|
|
2038
|
+
${roomFields.length ? this.renderRoomOnly(roomFields, unavailable) : nothing}
|
|
1353
2039
|
${this.renderLinks(unavailable, 'shape')}
|
|
1354
2040
|
<figure-material-panel
|
|
1355
2041
|
.body=${{ ...part, id: this.selected }}
|
|
@@ -1358,7 +2044,15 @@ export class FigureEditor extends LitElement {
|
|
|
1358
2044
|
@part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
|
|
1359
2045
|
@part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
|
|
1360
2046
|
></figure-material-panel>`
|
|
1361
|
-
:
|
|
2047
|
+
: view.geometry.length === 0
|
|
2048
|
+
? html`${this.renderSubject()}<section empty-figure>
|
|
2049
|
+
<p>${i18next.t('figure.text.empty-figure-next')}</p>
|
|
2050
|
+
<details>
|
|
2051
|
+
<summary>${i18next.t('figure.label.figure-info-later')}</summary>
|
|
2052
|
+
<slot name="figure"></slot>
|
|
2053
|
+
</details>
|
|
2054
|
+
</section>`
|
|
2055
|
+
: html`${this.renderSubject()}<slot name="figure"></slot>
|
|
1362
2056
|
${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
|
|
1363
2057
|
${this.renderInstanceRange(asset as FigureAsset | undefined, unavailable)}`}
|
|
1364
2058
|
</div>
|
|
@@ -1392,12 +2086,13 @@ export class FigureEditor extends LitElement {
|
|
|
1392
2086
|
vectorTitle: string,
|
|
1393
2087
|
singleTitle: string,
|
|
1394
2088
|
unavailable: boolean,
|
|
1395
|
-
tools: unknown = null
|
|
2089
|
+
tools: unknown = null,
|
|
2090
|
+
held?: HeldValues
|
|
1396
2091
|
) {
|
|
1397
2092
|
return html`${groups.vectors.length || tools
|
|
1398
2093
|
? html`<section>
|
|
1399
2094
|
<h3>${i18next.t(vectorTitle)}</h3>
|
|
1400
|
-
${groups.vectors.map(g => this.renderVector(g, unavailable))}
|
|
2095
|
+
${groups.vectors.map(g => this.renderVector(g, unavailable, held))}
|
|
1401
2096
|
${tools ? html`<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
|
|
1402
2097
|
</section>`
|
|
1403
2098
|
: nothing}
|
|
@@ -1408,20 +2103,42 @@ export class FigureEditor extends LitElement {
|
|
|
1408
2103
|
</section>`
|
|
1409
2104
|
: nothing}`
|
|
1410
2105
|
}
|
|
1411
|
-
|
|
2106
|
+
/**
|
|
2107
|
+
* A box-like part's size row stands when a rule sets every side -- each following the figure's size -- with the
|
|
2108
|
+
* numbers the rule gives, as its position and turn rows do (designer 2026-09-27: not a blank, not gone).
|
|
2109
|
+
*/
|
|
2110
|
+
private withSizeRow(groups: ReturnType<typeof groupFields>, held?: HeldValues) {
|
|
2111
|
+
if (groups.vectors.some(v => v.kind === 'vector' && v.name === 'size')) return groups
|
|
2112
|
+
if (!held?.sizeRuled || !held.size || held.size.length !== 3 || held.size.some(v => v === undefined)) return groups
|
|
2113
|
+
const at = groups.vectors.findIndex(v => v.kind === 'vector' && v.name === 'rotation')
|
|
2114
|
+
const vectors = [...groups.vectors]
|
|
2115
|
+
vectors.splice(at < 0 ? vectors.length : at + 1, 0, { kind: 'vector', name: 'size', cells: {} })
|
|
2116
|
+
return { ...groups, vectors }
|
|
2117
|
+
}
|
|
2118
|
+
private renderVector(group: FieldGroup, unavailable: boolean, held?: HeldValues) {
|
|
1412
2119
|
if (group.kind !== 'vector') return nothing
|
|
1413
|
-
const unit = group.
|
|
2120
|
+
const unit = group.name === 'rotation' ? '°' : 'mm'
|
|
2121
|
+
const ruled = (['x', 'y', 'z'] as const).filter(axis => !group.cells[axis])
|
|
2122
|
+
const given = (axis: 'x' | 'y' | 'z') => {
|
|
2123
|
+
const v = held?.[group.name]?.[['x', 'y', 'z'].indexOf(axis)]
|
|
2124
|
+
return v === undefined || !Number.isFinite(v) ? '—' : String(Math.round(v * 10) / 10)
|
|
2125
|
+
}
|
|
1414
2126
|
return html`<div vector-row>
|
|
1415
2127
|
<label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html` <span unit>${unit}</span>`}</label>
|
|
1416
2128
|
<div triple>
|
|
1417
2129
|
${(['x', 'y', 'z'] as const).map(
|
|
1418
|
-
axis =>
|
|
2130
|
+
axis => !group.cells[axis]
|
|
2131
|
+
? html`<div cell held title=${i18next.t('figure.text.axis-held-by-rule')}>
|
|
2132
|
+
<span axis>${axis.toUpperCase()}</span>
|
|
2133
|
+
<input type="text" readonly .value=${given(axis)} aria-label=${`${group.name}.${axis} · ${i18next.t('figure.text.axis-held-by-rule')}`} />
|
|
2134
|
+
</div>`
|
|
2135
|
+
: html`<div cell>
|
|
1419
2136
|
<span axis ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}>${axis.toUpperCase()}</span>
|
|
1420
2137
|
<input
|
|
1421
|
-
aria-label=${group.cells[axis]
|
|
2138
|
+
aria-label=${group.cells[axis]!.id}
|
|
1422
2139
|
type="number"
|
|
1423
2140
|
step="any"
|
|
1424
|
-
.value=${String(group.cells[axis]
|
|
2141
|
+
.value=${String(group.cells[axis]!.value)}
|
|
1425
2142
|
?disabled=${unavailable}
|
|
1426
2143
|
@input=${(e: Event) => this.typing(group.cells[axis] as never, e)}
|
|
1427
2144
|
@change=${(e: Event) => this.changeField(group.cells[axis] as never, e)}
|
|
@@ -1429,7 +2146,62 @@ export class FigureEditor extends LitElement {
|
|
|
1429
2146
|
</div>`
|
|
1430
2147
|
)}
|
|
1431
2148
|
</div>
|
|
1432
|
-
</div
|
|
2149
|
+
</div>
|
|
2150
|
+
${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}`
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* A part's fields only the declared room still reads (ADR-0097, designer 2026-09-27): shown as what they are and not
|
|
2154
|
+
* typed into -- they move nothing. Declaring the room again is offered; nothing is deleted or widened on the way.
|
|
2155
|
+
*/
|
|
2156
|
+
private renderRoomOnly(fields: FieldLike[], unavailable: boolean) {
|
|
2157
|
+
return html`<section room-only>
|
|
2158
|
+
<h3>${i18next.t('figure.label.room-only-inputs')}</h3>
|
|
2159
|
+
<p hint>${i18next.t('figure.text.room-only-inputs')}</p>
|
|
2160
|
+
<div form>
|
|
2161
|
+
${fields.map(
|
|
2162
|
+
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 />`
|
|
2163
|
+
)}
|
|
2164
|
+
</div>
|
|
2165
|
+
<button act room-refit ?disabled=${unavailable} @click=${() => this.askRoomRefit()}>${i18next.t('figure.button.room-refit')}</button>
|
|
2166
|
+
</section>`
|
|
2167
|
+
}
|
|
2168
|
+
/** The room as declared and as proposed, and what lies outside each as measured, before the author confirms. */
|
|
2169
|
+
private async askRoomRefit() {
|
|
2170
|
+
let refit
|
|
2171
|
+
try {
|
|
2172
|
+
refit = figureRoomRefitOf(parseFigureAsset(this.session!.source()))
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
this.error = this.said(error)
|
|
2175
|
+
return
|
|
2176
|
+
}
|
|
2177
|
+
if (!refit) return
|
|
2178
|
+
const rows = roomRefitRowsOf(refit)
|
|
2179
|
+
const outside = (list: typeof refit.outside.declared) =>
|
|
2180
|
+
list.length
|
|
2181
|
+
? list.map(o => i18next.t('figure.text.room-part-outside', { part: partLabelOf(o.part), axis: o.axis.toUpperCase(), mm: String(o.mm) })).join(' · ')
|
|
2182
|
+
: i18next.t('figure.text.room-nothing-outside')
|
|
2183
|
+
const agreed = await OxPrompt.open({
|
|
2184
|
+
type: 'question',
|
|
2185
|
+
title: i18next.t('figure.label.room-refit'),
|
|
2186
|
+
template: html`<table room-refit>
|
|
2187
|
+
<tr><th></th><th>${i18next.t('figure.label.room-declared')}</th><th>${i18next.t('figure.label.room-proposed')}</th></tr>
|
|
2188
|
+
${rows.map(
|
|
2189
|
+
r => html`<tr>
|
|
2190
|
+
<td>${i18next.t(`figure.label.room-at-${r.at}`)} ${r.axis.toUpperCase()}</td>
|
|
2191
|
+
<td>${r.declared}</td>
|
|
2192
|
+
<td>${r.same ? i18next.t('figure.text.room-same') : r.proposed}</td>
|
|
2193
|
+
</tr>`
|
|
2194
|
+
)}
|
|
2195
|
+
</table>
|
|
2196
|
+
<p>${i18next.t('figure.label.room-declared')}: ${outside(refit.outside.declared)}</p>
|
|
2197
|
+
<p>${i18next.t('figure.label.room-proposed')}: ${outside(refit.outside.proposed)}</p>
|
|
2198
|
+
${refit.drops.length
|
|
2199
|
+
? 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>`
|
|
2200
|
+
: nothing}`,
|
|
2201
|
+
confirmButton: { text: i18next.t('figure.button.room-refit-confirm') },
|
|
2202
|
+
cancelButton: { text: i18next.t('figure.button.keep-editing') }
|
|
2203
|
+
})
|
|
2204
|
+
if (agreed) this.linkCommand(refit.step)
|
|
1433
2205
|
}
|
|
1434
2206
|
private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
|
|
1435
2207
|
const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
|