@things-factory/figure-ui 10.1.271 → 10.1.273
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/modeller/canvas.ts +4 -2
- package/client/modeller/clips.ts +74 -4
- package/client/modeller/data-interface.ts +30 -18
- package/client/modeller/editor-styles.ts +17 -6
- package/client/modeller/editor.ts +2 -0
- package/client/modeller/fasten-form.ts +81 -6
- package/client/modeller/fasten-geometry.ts +5 -3
- package/client/modeller/figure-cavity-drawing.ts +1 -3
- package/client/modeller/figure-clips.ts +101 -8
- package/client/modeller/figure-data-panel.ts +384 -200
- package/client/modeller/figure-fasten-dialog.ts +41 -30
- package/client/modeller/figure-fasten-drawing.ts +24 -14
- package/client/modeller/figure-first-save.ts +4 -3
- package/client/modeller/figure-links.ts +109 -37
- package/client/modeller/figure-parts.ts +7 -11
- package/client/modeller/figure-preview.ts +299 -79
- package/client/modeller/figure-size-editor.ts +7 -2
- package/client/modeller/first-save.ts +8 -0
- package/client/modeller/focus-on-render.ts +4 -0
- package/client/modeller/pivot.ts +76 -0
- package/client/modeller/size-response.ts +14 -0
- package/client/modeller/stage-model.ts +50 -6
- package/dist-client/modeller/canvas.d.ts +1 -0
- package/dist-client/modeller/canvas.js +3 -2
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +17 -3
- package/dist-client/modeller/clips.js +69 -4
- package/dist-client/modeller/clips.js.map +1 -1
- package/dist-client/modeller/data-interface.d.ts +11 -8
- package/dist-client/modeller/data-interface.js +27 -17
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor-styles.js +17 -6
- package/dist-client/modeller/editor-styles.js.map +1 -1
- package/dist-client/modeller/editor.js +2 -0
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-form.d.ts +13 -0
- package/dist-client/modeller/fasten-form.js +72 -7
- package/dist-client/modeller/fasten-form.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.d.ts +3 -2
- package/dist-client/modeller/fasten-geometry.js +5 -3
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-cavity-drawing.js +1 -3
- package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
- package/dist-client/modeller/figure-clips.d.ts +9 -0
- package/dist-client/modeller/figure-clips.js +104 -8
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-data-panel.d.ts +10 -19
- package/dist-client/modeller/figure-data-panel.js +382 -190
- package/dist-client/modeller/figure-data-panel.js.map +1 -1
- package/dist-client/modeller/figure-fasten-dialog.js +39 -24
- package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
- package/dist-client/modeller/figure-fasten-drawing.js +24 -14
- package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
- package/dist-client/modeller/figure-first-save.js +4 -3
- package/dist-client/modeller/figure-first-save.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +9 -0
- package/dist-client/modeller/figure-links.js +120 -51
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-parts.js +7 -11
- package/dist-client/modeller/figure-parts.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +13 -0
- package/dist-client/modeller/figure-preview.js +296 -80
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-size-editor.js +7 -3
- package/dist-client/modeller/figure-size-editor.js.map +1 -1
- package/dist-client/modeller/first-save.d.ts +5 -0
- package/dist-client/modeller/first-save.js +7 -0
- package/dist-client/modeller/first-save.js.map +1 -1
- package/dist-client/modeller/focus-on-render.d.ts +2 -0
- package/dist-client/modeller/focus-on-render.js +4 -0
- package/dist-client/modeller/focus-on-render.js.map +1 -0
- package/dist-client/modeller/pivot.d.ts +12 -0
- package/dist-client/modeller/pivot.js +59 -0
- package/dist-client/modeller/pivot.js.map +1 -0
- package/dist-client/modeller/size-response.d.ts +6 -0
- package/dist-client/modeller/size-response.js +14 -0
- package/dist-client/modeller/size-response.js.map +1 -1
- package/dist-client/modeller/stage-model.d.ts +17 -3
- package/dist-client/modeller/stage-model.js +44 -6
- package/dist-client/modeller/stage-model.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/test/browser/editing-page.js +1 -1
- package/test/browser/editing.check.mjs +6 -5
- package/test/browser/links.check.mjs +30 -0
- package/test/browser/modeller.check.mjs +2 -2
- package/test/browser/parts-page.js +4 -0
- package/test/browser/parts.check.mjs +16 -10
- package/test/browser/screen-page.js +12 -1
- package/test/browser/screen.check.mjs +142 -11
- package/test/browser/section.check.mjs +21 -0
- package/test/browser/serve-response.mjs +5 -2
- package/test/browser/side-panel-sweep.check.mjs +171 -10
- package/test/clips.test.ts +49 -4
- package/test/data-motion.test.ts +39 -8
- package/test/fasten-form.test.ts +69 -0
- package/test/fasten-geometry.test.ts +13 -0
- package/test/first-save.test.ts +11 -1
- package/test/pivot.test.ts +57 -0
- package/test/size-response.test.ts +10 -2
- package/test/stage-model.test.ts +23 -1
- package/translations/en.json +31 -11
- package/translations/es.json +30 -10
- package/translations/ja.json +30 -10
- package/translations/ko.json +32 -12
- package/translations/ms.json +30 -10
- package/translations/zh.json +30 -10
- package/tutorial/humanoid/README.md +22 -0
- package/tutorial/humanoid/chapters.mjs +247 -0
- package/tutorial/humanoid/encode.mjs +26 -0
- package/tutorial/humanoid/lib.mjs +197 -0
- package/tutorial/humanoid/record.mjs +65 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
1
2
|
import { css, html, LitElement, nothing, unsafeCSS } from 'lit'
|
|
2
3
|
import { customElement, property, query, state } from 'lit/decorators.js'
|
|
3
4
|
|
|
4
5
|
import { i18next, localize } from '@operato/i18n'
|
|
5
6
|
import { unitWord } from './unit-words.js'
|
|
7
|
+
import { scrubLabel } from './live.js'
|
|
6
8
|
import * as THREE from 'three'
|
|
7
9
|
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
8
10
|
import type { FigureAsset, FigureDataRow, FigureDataValue, FigureGraphStateInput } from '@hatiolab/figure-model'
|
|
@@ -16,6 +18,7 @@ import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValue
|
|
|
16
18
|
import { derivedOf, type FigureRevision } from './revision.js'
|
|
17
19
|
import { authoredSizeOf, bodyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js'
|
|
18
20
|
import { previewSizeWordsOf } from './preview-size.js'
|
|
21
|
+
import { playedControlsOf } from './clips.js'
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* The figure placed on a board.
|
|
@@ -95,21 +98,43 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
95
98
|
background-color: rgba(28, 28, 30, 0.72);
|
|
96
99
|
border-radius: 4px;
|
|
97
100
|
}
|
|
98
|
-
|
|
101
|
+
/*
|
|
102
|
+
The legend, the size sliders and the controls in one column over the stage: the controls take what is left under
|
|
103
|
+
the two above and scroll inside it. A tall robot's rows in a short preview ran off the stage's top, the keep and
|
|
104
|
+
fold buttons with them, and nothing scrolled (2026-11-12); a legend on two lines in a narrow preview is room taken too.
|
|
105
|
+
*/
|
|
106
|
+
div[over] {
|
|
99
107
|
position: absolute;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
inset: 8px;
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
align-items: flex-start;
|
|
112
|
+
gap: 8px;
|
|
113
|
+
pointer-events: none;
|
|
114
|
+
}
|
|
115
|
+
div[over] > div[legend],
|
|
116
|
+
div[over] > div[sizes] {
|
|
117
|
+
pointer-events: auto;
|
|
118
|
+
}
|
|
119
|
+
div[foot] {
|
|
120
|
+
align-self: stretch;
|
|
121
|
+
flex: 1;
|
|
122
|
+
min-height: 0;
|
|
103
123
|
display: flex;
|
|
104
124
|
flex-wrap: wrap-reverse;
|
|
105
125
|
justify-content: space-between;
|
|
106
126
|
align-items: flex-end;
|
|
127
|
+
align-content: flex-start;
|
|
107
128
|
gap: 8px;
|
|
108
129
|
pointer-events: none;
|
|
109
130
|
}
|
|
110
131
|
div[foot] > * { pointer-events: auto; }
|
|
111
|
-
|
|
112
|
-
div[setup]
|
|
132
|
+
/* One line over the figure -- the part and the button -- its words in its title: three lines covered the figure (user 2026-11-12). */
|
|
133
|
+
div[setup] { max-width: min(320px, 100%); box-sizing: border-box; padding: 6px 8px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: flex; align-items: center; gap: 6px; font-size: var(--ui-t-micro, 11px); }
|
|
134
|
+
div[setup] select { flex: 1 1 auto; }
|
|
135
|
+
div[setup] button { flex: none; white-space: nowrap; }
|
|
136
|
+
/* The controls' last row: as wide as they are, a line over it in place of a box of its own. */
|
|
137
|
+
div[clips] div[setup] { max-width: none; padding-top: 8px; border-top: 1px solid rgba(255, 255, 255, 0.14); border-radius: 0; color: inherit; }
|
|
113
138
|
/* The help's choice and button in the look of the controls beside it, not the browser's grey (sweep 2026-10-27 U7). */
|
|
114
139
|
div[setup] select,
|
|
115
140
|
div[setup] button {
|
|
@@ -132,9 +157,8 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
132
157
|
}
|
|
133
158
|
/* Which is which: side by side means nothing without a legend. */
|
|
134
159
|
div[legend] {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
left: 8px;
|
|
160
|
+
max-width: 100%;
|
|
161
|
+
box-sizing: border-box;
|
|
138
162
|
display: flex;
|
|
139
163
|
gap: 6px;
|
|
140
164
|
padding: 4px 8px;
|
|
@@ -179,13 +203,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
179
203
|
}
|
|
180
204
|
/* The one figure's size: under the legend, in the controls' look. */
|
|
181
205
|
div[sizes] {
|
|
182
|
-
position: absolute;
|
|
183
|
-
top: 40px;
|
|
184
|
-
left: 8px;
|
|
185
206
|
display: grid;
|
|
186
207
|
gap: 6px;
|
|
187
208
|
padding: 8px 10px;
|
|
188
|
-
max-width:
|
|
209
|
+
max-width: 100%;
|
|
189
210
|
box-sizing: border-box;
|
|
190
211
|
color: rgba(255, 255, 255, 0.92);
|
|
191
212
|
background-color: rgba(28, 28, 30, 0.72);
|
|
@@ -206,8 +227,12 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
206
227
|
font-variant-numeric: tabular-nums;
|
|
207
228
|
text-align: right;
|
|
208
229
|
}
|
|
209
|
-
|
|
210
|
-
|
|
230
|
+
/* The size said at the legend's end, on its line: a box of its own under the legend took a second line (user 2026-11-12). */
|
|
231
|
+
div[legend] span[said] {
|
|
232
|
+
padding-left: 6px;
|
|
233
|
+
border-left: 1px solid rgba(255, 255, 255, 0.22);
|
|
234
|
+
align-self: center;
|
|
235
|
+
white-space: nowrap;
|
|
211
236
|
font-variant-numeric: tabular-nums;
|
|
212
237
|
font-weight: 600;
|
|
213
238
|
}
|
|
@@ -222,8 +247,12 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
222
247
|
*/
|
|
223
248
|
div[clips] {
|
|
224
249
|
margin-left: auto;
|
|
250
|
+
max-height: 100%;
|
|
251
|
+
box-sizing: border-box;
|
|
252
|
+
overflow-y: auto;
|
|
253
|
+
overscroll-behavior: contain;
|
|
225
254
|
display: grid;
|
|
226
|
-
grid-template-columns:
|
|
255
|
+
grid-template-columns: max-content 20px 96px 44px;
|
|
227
256
|
gap: 6px 8px;
|
|
228
257
|
align-items: center;
|
|
229
258
|
padding: 8px 10px;
|
|
@@ -240,6 +269,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
240
269
|
grid-template-columns: subgrid;
|
|
241
270
|
align-items: center;
|
|
242
271
|
}
|
|
272
|
+
/* A name and its unit on one line: 「position.x」 put its 「mm」 on a second line with room beside it (user 2026-11-12). */
|
|
273
|
+
div[clips] label > span:first-child {
|
|
274
|
+
white-space: nowrap;
|
|
275
|
+
}
|
|
243
276
|
/* A motion group's clip: as wide as the dial, slider and value together, so its name reads. */
|
|
244
277
|
div[clips] select {
|
|
245
278
|
grid-column: 2 / -1;
|
|
@@ -250,6 +283,28 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
250
283
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
251
284
|
border-radius: 5px;
|
|
252
285
|
}
|
|
286
|
+
/* A motion group's clips as buttons: a list the system draws is not seen in a recording of the preview. */
|
|
287
|
+
div[clips] span[clip-choices] {
|
|
288
|
+
grid-column: 2 / -1;
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-wrap: wrap;
|
|
291
|
+
gap: 4px;
|
|
292
|
+
min-width: 0;
|
|
293
|
+
}
|
|
294
|
+
div[clips] span[clip-choices] button {
|
|
295
|
+
font: inherit;
|
|
296
|
+
color: inherit;
|
|
297
|
+
padding: 1px 8px;
|
|
298
|
+
background-color: rgba(255, 255, 255, 0.08);
|
|
299
|
+
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
300
|
+
border-radius: 10px;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
}
|
|
303
|
+
div[clips] span[clip-choices] button[aria-pressed='true'] {
|
|
304
|
+
background-color: rgba(255, 255, 255, 0.3);
|
|
305
|
+
border-color: rgba(255, 255, 255, 0.6);
|
|
306
|
+
font-weight: 600;
|
|
307
|
+
}
|
|
253
308
|
div[clips] svg[dial] {
|
|
254
309
|
width: 20px;
|
|
255
310
|
height: 20px;
|
|
@@ -280,9 +335,65 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
280
335
|
div[clips] button[fold-controls]:hover {
|
|
281
336
|
opacity: 1;
|
|
282
337
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
338
|
+
/* The keep, clear and fold line, and the size help's, stay in sight while the rows between them scroll. */
|
|
339
|
+
div[clips] div[actions],
|
|
340
|
+
div[clips] div[setup] {
|
|
341
|
+
position: sticky;
|
|
342
|
+
z-index: 1;
|
|
343
|
+
margin-left: -10px;
|
|
344
|
+
margin-right: -10px;
|
|
345
|
+
padding-left: 10px;
|
|
346
|
+
padding-right: 10px;
|
|
347
|
+
background-color: rgba(28, 28, 30, 0.96);
|
|
348
|
+
}
|
|
349
|
+
div[clips] div[actions] {
|
|
350
|
+
top: -8px;
|
|
351
|
+
margin-top: -8px;
|
|
352
|
+
padding-top: 8px;
|
|
353
|
+
padding-bottom: 4px;
|
|
354
|
+
}
|
|
355
|
+
div[clips] div[setup] {
|
|
356
|
+
bottom: -8px;
|
|
357
|
+
margin-bottom: -8px;
|
|
358
|
+
padding-bottom: 8px;
|
|
359
|
+
}
|
|
360
|
+
div[clips] div[actions] {
|
|
361
|
+
display: flex;
|
|
362
|
+
align-items: center;
|
|
363
|
+
gap: 6px;
|
|
364
|
+
min-width: 0;
|
|
365
|
+
}
|
|
366
|
+
/* Folded, the box is as wide as its button: the icon and the words that say it opens the controls. */
|
|
367
|
+
div[clips][folded] {
|
|
368
|
+
display: flex;
|
|
369
|
+
flex-direction: column;
|
|
370
|
+
align-items: stretch;
|
|
371
|
+
gap: 6px;
|
|
372
|
+
}
|
|
373
|
+
div[clips][folded] button[fold-controls] {
|
|
374
|
+
gap: 4px;
|
|
375
|
+
padding: 2px 4px;
|
|
376
|
+
font-size: var(--ui-t-micro, 11px);
|
|
377
|
+
}
|
|
378
|
+
/* An icon as tall as the two buttons beside it, at the line's right end. */
|
|
379
|
+
div[clips] button[fold-controls] {
|
|
380
|
+
display: inline-flex;
|
|
381
|
+
align-items: center;
|
|
382
|
+
}
|
|
383
|
+
div[clips] button[fold-controls] md-icon {
|
|
384
|
+
--md-icon-size: 16px;
|
|
385
|
+
}
|
|
386
|
+
div[clips] div[actions] button[fold-controls] {
|
|
387
|
+
margin-left: auto;
|
|
388
|
+
padding: 2px 3px;
|
|
389
|
+
border: 1px solid transparent;
|
|
390
|
+
}
|
|
391
|
+
/* On one line with the fold, each word on one line: a label broken inside a word ("기본으 / 로") read badly (user 2026-11-12). */
|
|
392
|
+
div[clips] button[pose],
|
|
393
|
+
div[clips] button[clear-tried] {
|
|
394
|
+
flex: none;
|
|
395
|
+
white-space: nowrap;
|
|
396
|
+
padding: 3px 8px;
|
|
286
397
|
font: inherit;
|
|
287
398
|
font-size: var(--ui-t-micro, 11px);
|
|
288
399
|
color: rgba(255, 255, 255, 0.92);
|
|
@@ -291,7 +402,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
291
402
|
border-radius: 5px;
|
|
292
403
|
cursor: pointer;
|
|
293
404
|
}
|
|
294
|
-
div[clips] button[pose]:
|
|
405
|
+
div[clips] button[pose]:disabled,
|
|
406
|
+
div[clips] button[clear-tried]:disabled {
|
|
407
|
+
opacity: 0.4;
|
|
408
|
+
cursor: default;
|
|
409
|
+
}
|
|
410
|
+
div[clips] button[pose]:not(:disabled):hover,
|
|
411
|
+
div[clips] button[clear-tried]:not(:disabled):hover {
|
|
295
412
|
background-color: rgba(255, 255, 255, 0.24);
|
|
296
413
|
}
|
|
297
414
|
div[clips] span[drive] {
|
|
@@ -303,7 +420,20 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
303
420
|
accent-color: var(--md-sys-color-primary);
|
|
304
421
|
}
|
|
305
422
|
/* A value typed, not slid: in the look of the group's clip choice, starting where the sliders start. */
|
|
306
|
-
|
|
423
|
+
/* A name that scrubs its value, in the look the side panel gives it (figure-viewer side-panel-styles \`[scrub]\`). */
|
|
424
|
+
div[clips] label > span[scrub] {
|
|
425
|
+
cursor: ew-resize;
|
|
426
|
+
user-select: none;
|
|
427
|
+
touch-action: none;
|
|
428
|
+
}
|
|
429
|
+
/* A check box in the dial's column, where every field starts. */
|
|
430
|
+
div[clips] input[type='checkbox'] {
|
|
431
|
+
grid-column: 2 / -1;
|
|
432
|
+
justify-self: start;
|
|
433
|
+
width: auto;
|
|
434
|
+
margin: 0;
|
|
435
|
+
}
|
|
436
|
+
div[clips] input:not([type='range']):not([type='checkbox']) {
|
|
307
437
|
box-sizing: border-box;
|
|
308
438
|
min-width: 0;
|
|
309
439
|
font: inherit;
|
|
@@ -312,9 +442,15 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
312
442
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
|
313
443
|
border-radius: 5px;
|
|
314
444
|
}
|
|
315
|
-
|
|
316
|
-
|
|
445
|
+
/*
|
|
446
|
+
Three widths only: a slider, its value's box, and a field that is the whole row's -- a choice, a text, a number with
|
|
447
|
+
no range -- from where the dial starts to the right edge, as the choices are (user 2026-11-12: every width differed).
|
|
448
|
+
*/
|
|
449
|
+
div[clips] input:not([type='range']):not([type='number']):not([type='checkbox']),
|
|
450
|
+
div[clips] input[wide] {
|
|
451
|
+
grid-column: 2 / -1;
|
|
317
452
|
width: auto;
|
|
453
|
+
padding: 1px 4px;
|
|
318
454
|
}
|
|
319
455
|
/* A control's value, typed where the slider cannot land on it (a step of the range's hundredth, robot dog 2026-11-03). */
|
|
320
456
|
div[clips] input[typed] {
|
|
@@ -324,7 +460,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
324
460
|
font-variant-numeric: tabular-nums;
|
|
325
461
|
}
|
|
326
462
|
div[clips] label[sampled] output {
|
|
327
|
-
grid-column:
|
|
463
|
+
grid-column: 2 / -1;
|
|
328
464
|
text-align: left;
|
|
329
465
|
}
|
|
330
466
|
div[clips] output {
|
|
@@ -426,7 +562,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
426
562
|
${this.tags.length ? html`<div tags>${this.tags.map(t => html`<span style=${`left:${t.x}px;top:${t.y}px`}>${t.name}</span>`)}</div>` : nothing}
|
|
427
563
|
${this.cannot
|
|
428
564
|
? html`<div cannot>${this.cannot}</div>`
|
|
429
|
-
: html`<div legend>
|
|
565
|
+
: html`<div over><div legend>
|
|
430
566
|
${(['one', 'eight'] as const).map(
|
|
431
567
|
way => html`<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`
|
|
432
568
|
)}
|
|
@@ -444,16 +580,29 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
444
580
|
: nothing}
|
|
445
581
|
</span>`
|
|
446
582
|
})}
|
|
583
|
+
${this.way === 'one' && asset && this.trying
|
|
584
|
+
? html`<span said>${previewSizeWordsOf(asset, this.trying, (key: string, params?: Record<string, string>) => i18next.t(key, params))}</span>`
|
|
585
|
+
: nothing}
|
|
447
586
|
</div>
|
|
448
587
|
${this.way === 'one' && asset && this.trying ? this.renderSizes(asset, this.trying) : nothing}
|
|
449
|
-
<div foot>${asset?.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
588
|
+
<div foot>${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0)
|
|
589
|
+
? this.renderDrivers(asset!, controls, clips, this.renderSetup(asset, setupPart))
|
|
590
|
+
: this.renderSetup(asset, setupPart)}</div></div>`}`
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/*
|
|
594
|
+
The size help, shown only where the figure's size can change: the part and the button on one line, its words in its
|
|
595
|
+
title. With controls it is their last row, as wide as they are -- a box of its own left a gap at its right when the
|
|
596
|
+
two stood one over the other, and three lines of it covered the figure (user 2026-11-12).
|
|
597
|
+
*/
|
|
598
|
+
private renderSetup(asset: FigureAsset | undefined, setupPart: string) {
|
|
599
|
+
if (!asset?.appearance.length || !(['x', 'y', 'z'] as const).some(axis => sizeSpanOf(asset, axis))) return nothing
|
|
600
|
+
return html`<div setup title=${i18next.t('figure.text.preview-setup')}>
|
|
601
|
+
<select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e: Event) => (this.setupPart = (e.target as HTMLSelectElement).value)}>
|
|
602
|
+
${asset.appearance.map(p => html`<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
|
|
603
|
+
</select>
|
|
604
|
+
<button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
|
|
605
|
+
</div>`
|
|
457
606
|
}
|
|
458
607
|
|
|
459
608
|
private lookFrom(view: PreviewView) {
|
|
@@ -463,7 +612,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
463
612
|
|
|
464
613
|
/** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
|
|
465
614
|
private renderSizes(asset: FigureAsset, size: { x: number; y: number; z: number }) {
|
|
466
|
-
|
|
615
|
+
if (!(['x', 'y', 'z'] as const).some(axis => sizeSpanOf(asset, axis))) return nothing
|
|
467
616
|
return html`<div sizes>
|
|
468
617
|
${(['x', 'y', 'z'] as const).map(axis => {
|
|
469
618
|
const range = sizeSpanOf(asset, axis)
|
|
@@ -476,7 +625,6 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
476
625
|
<output>${Math.round(size[axis]).toLocaleString()}</output>
|
|
477
626
|
</label>`
|
|
478
627
|
})}
|
|
479
|
-
<p said>${previewSizeWordsOf(asset, size, say)}</p>
|
|
480
628
|
</div>`
|
|
481
629
|
}
|
|
482
630
|
|
|
@@ -548,26 +696,38 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
548
696
|
this.scene?.root?.fitCameraToModel?.()
|
|
549
697
|
}
|
|
550
698
|
|
|
551
|
-
private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
|
|
699
|
+
private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[], setup: unknown) {
|
|
552
700
|
const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]))
|
|
553
701
|
const sampledStates = sampledStatesOf(asset, this.sample)
|
|
554
702
|
const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
|
|
555
|
-
|
|
556
|
-
|
|
703
|
+
/* An icon, its words in its title: the words took the room of a button (user 2026-11-12). */
|
|
704
|
+
const foldWords = i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')
|
|
705
|
+
const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} title=${foldWords} aria-label=${foldWords} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
|
|
706
|
+
<md-icon>${this.controlsFolded ? 'unfold_more' : 'unfold_less'}</md-icon>
|
|
557
707
|
</button>`
|
|
558
|
-
|
|
708
|
+
/* Folded, the icon says what it opens: an icon alone in an empty box said nothing (user 2026-11-12). */
|
|
709
|
+
if (this.controlsFolded)
|
|
710
|
+
return html`<div clips folded>
|
|
711
|
+
<button fold-controls aria-expanded="false" @click=${() => (this.controlsFolded = false)}>
|
|
712
|
+
<md-icon>unfold_more</md-icon>${foldWords}
|
|
713
|
+
</button>
|
|
714
|
+
${setup}
|
|
715
|
+
</div>`
|
|
716
|
+
const played = playedControlsOf(asset, this.driving, this.moving)
|
|
717
|
+
/*
|
|
718
|
+
Keep, clear and fold on one line, there from the start: keep and clear unable until something is tried. Coming and
|
|
719
|
+
going, they moved the rows under them while a slider was being moved (user 2026-11-12).
|
|
720
|
+
*/
|
|
559
721
|
return html`<div clips>
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
</button>`
|
|
570
|
-
: nothing}
|
|
722
|
+
<div actions>
|
|
723
|
+
<button pose ?disabled=${!changed.length} title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
|
|
724
|
+
${i18next.t('figure.button.pose-as-default')}
|
|
725
|
+
</button>
|
|
726
|
+
<button clear-tried ?disabled=${!Object.keys(this.giving).length} title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
|
|
727
|
+
${i18next.t('figure.button.clear-tried')}
|
|
728
|
+
</button>
|
|
729
|
+
${fold}
|
|
730
|
+
</div>
|
|
571
731
|
${controls.map(control => {
|
|
572
732
|
/* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
|
|
573
733
|
const fromSample = sampledStates[control.id]
|
|
@@ -580,8 +740,20 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
580
740
|
/* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
|
|
581
741
|
const step = steps.get(control.id) ?? (control.max - control.min) / 100
|
|
582
742
|
const label = asset.stateInputs?.[control.id]?.label ?? control.id
|
|
743
|
+
/*
|
|
744
|
+
A control a playing clip drives moves on the figure while its row stood still at its last number -- 0 under a
|
|
745
|
+
walking leg (review 2026-11-11). The row says the clip has it, and is not given a value until the clip stops.
|
|
746
|
+
*/
|
|
747
|
+
const by = played.get(control.id)
|
|
748
|
+
if (by)
|
|
749
|
+
return html`<label played title=${i18next.t('figure.text.control-played-explained', { clip: by })}>
|
|
750
|
+
<span>${label} <span drive>${unitWord(control.unit)}</span></span>
|
|
751
|
+
<span></span>
|
|
752
|
+
<input type="range" min=${control.min} max=${control.max} .value=${String(value)} disabled />
|
|
753
|
+
<output>${i18next.t('figure.text.control-played', { clip: by })}</output>
|
|
754
|
+
</label>`
|
|
583
755
|
return html`<label>
|
|
584
|
-
|
|
756
|
+
${this.scrubName(html`${label} <span drive>${unitWord(control.unit)}</span>`, control.unit, value, control.min, control.max, v => this.give(control.id, v))}
|
|
585
757
|
${this.renderDial(control, value)}
|
|
586
758
|
<input
|
|
587
759
|
type="range"
|
|
@@ -591,19 +763,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
591
763
|
.value=${String(value)}
|
|
592
764
|
@input=${(e: Event) => this.give(control.id, Number((e.target as HTMLInputElement).value))}
|
|
593
765
|
/>
|
|
594
|
-
|
|
595
|
-
typed
|
|
596
|
-
type="number"
|
|
597
|
-
step="any"
|
|
598
|
-
min=${control.min}
|
|
599
|
-
max=${control.max}
|
|
600
|
-
aria-label=${label}
|
|
601
|
-
.value=${String(Math.round(value * 100) / 100)}
|
|
602
|
-
@change=${(e: Event) => {
|
|
603
|
-
const typed = (e.target as HTMLInputElement).valueAsNumber
|
|
604
|
-
if (Number.isFinite(typed)) this.give(control.id, Math.min(Math.max(typed, control.min), control.max))
|
|
605
|
-
}}
|
|
606
|
-
/>
|
|
766
|
+
${this.typedBox(label, value, control.min, control.max, typed => this.give(control.id, typed))}
|
|
607
767
|
</label>`
|
|
608
768
|
})}
|
|
609
769
|
${clips.map(clip => {
|
|
@@ -613,10 +773,10 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
613
773
|
*/
|
|
614
774
|
const value = this.driving[clip] ?? 0
|
|
615
775
|
return html`<label>
|
|
616
|
-
|
|
776
|
+
${this.scrubName(html`${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span>`, 'ratio', value, 0, 3, v => this.drive(clip, v))}
|
|
617
777
|
<span></span>
|
|
618
778
|
<input type="range" min="0" max="3" step="0.05" .value=${String(value)} @input=${(e: Event) => this.drive(clip, Number((e.target as HTMLInputElement).value))} />
|
|
619
|
-
|
|
779
|
+
${this.typedBox(clip, value, 0, 3, typed => this.drive(clip, typed))}
|
|
620
780
|
</label>`
|
|
621
781
|
})}
|
|
622
782
|
${valueRowsOf(asset).map(row => this.renderDataValue(row))}
|
|
@@ -628,22 +788,67 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
628
788
|
? nothing
|
|
629
789
|
: html`<label>
|
|
630
790
|
<span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
|
|
631
|
-
<
|
|
632
|
-
${group.clips.map(
|
|
633
|
-
|
|
791
|
+
<span clip-choices role="group" aria-label=${group.label}>
|
|
792
|
+
${group.clips.map(
|
|
793
|
+
c => html`<button type="button" aria-pressed=${String(c === playing.clip)} @click=${() => this.move(group.id, { ...playing, clip: c })}>${c}</button>`
|
|
794
|
+
)}
|
|
795
|
+
</span>
|
|
634
796
|
</label>`}
|
|
635
797
|
${byData.speed
|
|
636
798
|
? nothing
|
|
637
799
|
: html`<label>
|
|
638
|
-
|
|
800
|
+
${this.scrubName(html`${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span>`, 'ratio', playing.speed, 0, 3, v => this.move(group.id, { ...playing, speed: v }))}
|
|
639
801
|
<span></span>
|
|
640
802
|
<input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e: Event) => this.move(group.id, { ...playing, speed: Number((e.target as HTMLInputElement).value) })} />
|
|
641
|
-
|
|
803
|
+
${this.typedBox(group.label, playing.speed, 0, 3, typed => this.move(group.id, { ...playing, speed: typed }))}
|
|
642
804
|
</label>`}`
|
|
643
805
|
})}
|
|
806
|
+
${setup}
|
|
644
807
|
</div>`
|
|
645
808
|
}
|
|
646
809
|
|
|
810
|
+
/**
|
|
811
|
+
* A row's name that scrubs its value when dragged sideways, as the property panel's names do (\`scrubLabel\`): Shift for
|
|
812
|
+
* finer steps, held within the row's range. The preview's values are tried, not recorded, so each move is the value.
|
|
813
|
+
* The sliders ran 360 degrees over 96 pixels, too coarse to land an angle (user 2026-11-12).
|
|
814
|
+
*/
|
|
815
|
+
private scrubName(name: unknown, unit: string, value: number, min: number, max: number, set: (value: number) => void) {
|
|
816
|
+
const held = (v: number) => Math.min(Math.max(v, min), max)
|
|
817
|
+
return html`<span
|
|
818
|
+
scrub
|
|
819
|
+
@pointerdown=${(down: PointerEvent) => {
|
|
820
|
+
/* A press that did not move gives nothing: an empty field is not given its slider's start by a click on its name. */
|
|
821
|
+
let moved = false
|
|
822
|
+
scrubLabel(down, { value, unit }, {
|
|
823
|
+
move: v => ((moved = true), set(held(v))),
|
|
824
|
+
release: v => set(held(v)),
|
|
825
|
+
cancel: () => moved && set(value)
|
|
826
|
+
})
|
|
827
|
+
}}
|
|
828
|
+
>${name}</span
|
|
829
|
+
>`
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* A slider's value as a box to type in, the same on every row: the value a slider cannot land on, typed (a step of the
|
|
834
|
+
* range's hundredth, robot dog 2026-11-03); one column of boxes, not boxes over plain numbers (user 2026-11-12).
|
|
835
|
+
*/
|
|
836
|
+
private typedBox(label: string, value: number, min: number, max: number, set: (typed: number) => void) {
|
|
837
|
+
return html`<input
|
|
838
|
+
typed
|
|
839
|
+
type="number"
|
|
840
|
+
step="any"
|
|
841
|
+
min=${Number.isFinite(min) ? min : nothing}
|
|
842
|
+
max=${Number.isFinite(max) ? max : nothing}
|
|
843
|
+
aria-label=${label}
|
|
844
|
+
.value=${String(Math.round(value * 100) / 100)}
|
|
845
|
+
@change=${(e: Event) => {
|
|
846
|
+
const typed = (e.target as HTMLInputElement).valueAsNumber
|
|
847
|
+
if (Number.isFinite(typed)) set(Math.min(Math.max(typed, min), max))
|
|
848
|
+
}}
|
|
849
|
+
/>`
|
|
850
|
+
}
|
|
851
|
+
|
|
647
852
|
/** A value the data's bindings read, as the board's saved values give it: a number on a slider when it has a range, a choice when it is closed. */
|
|
648
853
|
private renderDataValue(row: FigureDataRow) {
|
|
649
854
|
const node = row.node
|
|
@@ -656,22 +861,37 @@ export class FigurePreview extends localize(i18next)(LitElement) {
|
|
|
656
861
|
if (sampled !== undefined)
|
|
657
862
|
return html`<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
|
|
658
863
|
<span>${label} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
|
|
659
|
-
<output>${typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
|
|
864
|
+
<output>${typeof sampled === 'boolean' ? i18next.t(sampled ? 'figure.label.data-true' : 'figure.label.data-false') : typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
|
|
660
865
|
</label>`
|
|
661
866
|
const fallback = 'default' in node ? node.default : undefined
|
|
662
867
|
const value = this.valuing[row.path] ?? fallback
|
|
663
868
|
if (node.kind === 'number') {
|
|
664
869
|
const ranged = node.min !== undefined && node.max !== undefined && node.max > node.min
|
|
870
|
+
/*
|
|
871
|
+
An angle is drawn as the controls' angles are -- dial, slider, box -- whether or not its field declares a range:
|
|
872
|
+
one with none slides over one turn, and any angle can still be typed. 「방향 deg」 was a bare number field under
|
|
873
|
+
「머리 각도 deg」 on a slider (user 2026-11-12).
|
|
874
|
+
*/
|
|
875
|
+
const angle = node.unit === 'deg'
|
|
876
|
+
const slide = ranged ? { min: node.min!, max: node.max! } : angle ? { min: 0, max: 360 } : undefined
|
|
877
|
+
const scrubFrom = Number(value ?? slide?.min ?? 0)
|
|
665
878
|
return html`<label>
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
879
|
+
${this.scrubName(html`${label} <span drive>${unitWord(node.unit)}</span>`, node.unit ?? '', Number.isFinite(scrubFrom) ? scrubFrom : 0, ranged ? node.min! : -Infinity, ranged ? node.max! : Infinity, v => this.value(row, String(v)))}
|
|
880
|
+
${slide
|
|
881
|
+
? html`${this.renderDial({ unit: node.unit ?? '', ...slide }, Number(value ?? slide.min))}
|
|
882
|
+
<input type="range" min=${slide.min} max=${slide.max} step=${angle ? 1 : Math.max((slide.max - slide.min) / 100, 0.01)} .value=${String(value ?? slide.min)} @input=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />
|
|
883
|
+
${ranged
|
|
884
|
+
? this.typedBox(label, Number(value ?? slide.min), slide.min, slide.max, typed => this.value(row, String(typed)))
|
|
885
|
+
: this.typedBox(label, Number(value ?? slide.min), -Infinity, Infinity, typed => this.value(row, String(typed)))}`
|
|
886
|
+
: html`<input wide type="number" step="any" aria-label=${label} .value=${value === undefined ? '' : String(value)} @change=${(e: Event) => this.value(row, (e.target as HTMLInputElement).value)} />`}
|
|
673
887
|
</label>`
|
|
674
888
|
}
|
|
889
|
+
/* A yes or no is a check box, not a list of two words to open (user 2026-11-12). Unchecked is false, as the board reads it. */
|
|
890
|
+
if (node.kind === 'boolean')
|
|
891
|
+
return html`<label>
|
|
892
|
+
<span>${label}</span>
|
|
893
|
+
<input type="checkbox" aria-label=${label} .checked=${value === true} @change=${(e: Event) => this.value(row, String((e.target as HTMLInputElement).checked))} />
|
|
894
|
+
</label>`
|
|
675
895
|
const domain = domainOf(node)
|
|
676
896
|
if (!domain.length)
|
|
677
897
|
return html`<label>
|
|
@@ -10,6 +10,7 @@ import { scrubLabel } from './live.js'
|
|
|
10
10
|
import {
|
|
11
11
|
measureFactor,
|
|
12
12
|
sameSizeSource,
|
|
13
|
+
sizeAxisWord,
|
|
13
14
|
sizeInputOf,
|
|
14
15
|
sizeRelationOf,
|
|
15
16
|
sizeRelationSteps,
|
|
@@ -39,10 +40,13 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
|
|
|
39
40
|
:host { display: block; min-width: 0; }
|
|
40
41
|
.heading, .fields {
|
|
41
42
|
display: grid;
|
|
42
|
-
grid-template-columns: 24px minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
|
|
43
|
+
grid-template-columns: var(--axis-column, 24px) minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
|
|
43
44
|
gap: 6px;
|
|
44
45
|
align-items: center;
|
|
45
46
|
}
|
|
47
|
+
/* A round body's rows name its diameter (「X·Z · 지름」): every row's first column widens alike, so the rows still line up. */
|
|
48
|
+
:host([named-axes]) { --axis-column: 76px; }
|
|
49
|
+
.fields > span:first-child { white-space: nowrap; }
|
|
46
50
|
.heading { color: var(--md-sys-color-on-surface-variant); padding-bottom: 6px; }
|
|
47
51
|
.dimension { padding: 3px 0; }
|
|
48
52
|
.variable { display: flex; align-items: center; justify-content: center; min-height: var(--figure-control-height); }
|
|
@@ -98,6 +102,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
|
|
|
98
102
|
const axes = followableAxes(asset, this.partId)
|
|
99
103
|
const targets = asset.appearance.map(a => a.target).filter(p => p !== this.partId && followableAxes(asset, p).length)
|
|
100
104
|
const uniform = asset.sizing === 'uniform'
|
|
105
|
+
this.toggleAttribute('named-axes', axes.some(axis => measureFactor(asset, this.partId, axis) === 2))
|
|
101
106
|
return html`
|
|
102
107
|
<div class="heading" aria-hidden="true">
|
|
103
108
|
<span>${i18next.t('figure.label.size-axis')}</span><span>${i18next.t('figure.label.size-measure')}</span><span>${i18next.t('figure.label.size-follows')}</span><span>${i18next.t('figure.label.size-source')}</span><span></span>
|
|
@@ -116,7 +121,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
|
|
|
116
121
|
}
|
|
117
122
|
const currentMode = mode === 'custom' ? 'ratio' : mode
|
|
118
123
|
const input = sizeInputOf(asset, this.partId, axis)
|
|
119
|
-
const label =
|
|
124
|
+
const label = sizeAxisWord(asset, this.partId, axis, i18next.t('figure.label.size-diameter'))
|
|
120
125
|
/* The selects show when asked for, or when what it follows is not what its fastening gives -- or nothing is. */
|
|
121
126
|
const choosing = relation.variable && (this.choosing.has(key) || !sameSizeSource(source, suggested))
|
|
122
127
|
const selectedSource = source ? (source.of === 'instance' ? '@figure' : source.part) : '@custom'
|
|
@@ -11,6 +11,14 @@ export function typeOf(raw: string): string {
|
|
|
11
11
|
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The identifier as it is being typed: `typeOf`, but the hyphen just typed at the end stays, so the next word can follow
|
|
16
|
+
* it (tutorial 2026-11-11: `fork-agv` typed key by key came out `forkagv`). The save takes `typeOf` of it.
|
|
17
|
+
*/
|
|
18
|
+
export function typingTypeOf(raw: string): string {
|
|
19
|
+
return raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '')
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
/**
|
|
15
23
|
* Whether this name could be released, by the format's own rules (`type-not-identifier`, `type-is-placeholder`).
|
|
16
24
|
* Saving under it is not blocked -- it can still change before release -- but the author hears it now.
|