@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.
Files changed (112) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/data-interface.ts +30 -18
  4. package/client/modeller/editor-styles.ts +17 -6
  5. package/client/modeller/editor.ts +2 -0
  6. package/client/modeller/fasten-form.ts +81 -6
  7. package/client/modeller/fasten-geometry.ts +5 -3
  8. package/client/modeller/figure-cavity-drawing.ts +1 -3
  9. package/client/modeller/figure-clips.ts +101 -8
  10. package/client/modeller/figure-data-panel.ts +384 -200
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +24 -14
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +109 -37
  15. package/client/modeller/figure-parts.ts +7 -11
  16. package/client/modeller/figure-preview.ts +299 -79
  17. package/client/modeller/figure-size-editor.ts +7 -2
  18. package/client/modeller/first-save.ts +8 -0
  19. package/client/modeller/focus-on-render.ts +4 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/size-response.ts +14 -0
  22. package/client/modeller/stage-model.ts +50 -6
  23. package/dist-client/modeller/canvas.d.ts +1 -0
  24. package/dist-client/modeller/canvas.js +3 -2
  25. package/dist-client/modeller/canvas.js.map +1 -1
  26. package/dist-client/modeller/clips.d.ts +17 -3
  27. package/dist-client/modeller/clips.js +69 -4
  28. package/dist-client/modeller/clips.js.map +1 -1
  29. package/dist-client/modeller/data-interface.d.ts +11 -8
  30. package/dist-client/modeller/data-interface.js +27 -17
  31. package/dist-client/modeller/data-interface.js.map +1 -1
  32. package/dist-client/modeller/editor-styles.js +17 -6
  33. package/dist-client/modeller/editor-styles.js.map +1 -1
  34. package/dist-client/modeller/editor.js +2 -0
  35. package/dist-client/modeller/editor.js.map +1 -1
  36. package/dist-client/modeller/fasten-form.d.ts +13 -0
  37. package/dist-client/modeller/fasten-form.js +72 -7
  38. package/dist-client/modeller/fasten-form.js.map +1 -1
  39. package/dist-client/modeller/fasten-geometry.d.ts +3 -2
  40. package/dist-client/modeller/fasten-geometry.js +5 -3
  41. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  42. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  43. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  44. package/dist-client/modeller/figure-clips.d.ts +9 -0
  45. package/dist-client/modeller/figure-clips.js +104 -8
  46. package/dist-client/modeller/figure-clips.js.map +1 -1
  47. package/dist-client/modeller/figure-data-panel.d.ts +10 -19
  48. package/dist-client/modeller/figure-data-panel.js +382 -190
  49. package/dist-client/modeller/figure-data-panel.js.map +1 -1
  50. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  51. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-drawing.js +24 -14
  53. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  54. package/dist-client/modeller/figure-first-save.js +4 -3
  55. package/dist-client/modeller/figure-first-save.js.map +1 -1
  56. package/dist-client/modeller/figure-links.d.ts +9 -0
  57. package/dist-client/modeller/figure-links.js +120 -51
  58. package/dist-client/modeller/figure-links.js.map +1 -1
  59. package/dist-client/modeller/figure-parts.js +7 -11
  60. package/dist-client/modeller/figure-parts.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.d.ts +13 -0
  62. package/dist-client/modeller/figure-preview.js +296 -80
  63. package/dist-client/modeller/figure-preview.js.map +1 -1
  64. package/dist-client/modeller/figure-size-editor.js +7 -3
  65. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  66. package/dist-client/modeller/first-save.d.ts +5 -0
  67. package/dist-client/modeller/first-save.js +7 -0
  68. package/dist-client/modeller/first-save.js.map +1 -1
  69. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  70. package/dist-client/modeller/focus-on-render.js +4 -0
  71. package/dist-client/modeller/focus-on-render.js.map +1 -0
  72. package/dist-client/modeller/pivot.d.ts +12 -0
  73. package/dist-client/modeller/pivot.js +59 -0
  74. package/dist-client/modeller/pivot.js.map +1 -0
  75. package/dist-client/modeller/size-response.d.ts +6 -0
  76. package/dist-client/modeller/size-response.js +14 -0
  77. package/dist-client/modeller/size-response.js.map +1 -1
  78. package/dist-client/modeller/stage-model.d.ts +17 -3
  79. package/dist-client/modeller/stage-model.js +44 -6
  80. package/dist-client/modeller/stage-model.js.map +1 -1
  81. package/dist-client/tsconfig.tsbuildinfo +1 -1
  82. package/package.json +8 -7
  83. package/test/browser/editing-page.js +1 -1
  84. package/test/browser/editing.check.mjs +6 -5
  85. package/test/browser/links.check.mjs +30 -0
  86. package/test/browser/modeller.check.mjs +2 -2
  87. package/test/browser/parts-page.js +4 -0
  88. package/test/browser/parts.check.mjs +16 -10
  89. package/test/browser/screen-page.js +12 -1
  90. package/test/browser/screen.check.mjs +142 -11
  91. package/test/browser/section.check.mjs +21 -0
  92. package/test/browser/serve-response.mjs +5 -2
  93. package/test/browser/side-panel-sweep.check.mjs +171 -10
  94. package/test/clips.test.ts +49 -4
  95. package/test/data-motion.test.ts +39 -8
  96. package/test/fasten-form.test.ts +69 -0
  97. package/test/fasten-geometry.test.ts +13 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/size-response.test.ts +10 -2
  101. package/test/stage-model.test.ts +23 -1
  102. package/translations/en.json +31 -11
  103. package/translations/es.json +30 -10
  104. package/translations/ja.json +30 -10
  105. package/translations/ko.json +32 -12
  106. package/translations/ms.json +30 -10
  107. package/translations/zh.json +30 -10
  108. package/tutorial/humanoid/README.md +22 -0
  109. package/tutorial/humanoid/chapters.mjs +247 -0
  110. package/tutorial/humanoid/encode.mjs +26 -0
  111. package/tutorial/humanoid/lib.mjs +197 -0
  112. package/tutorial/humanoid/record.mjs +65 -0
@@ -1,8 +1,10 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
+ import '@material/web/icon/icon.js';
2
3
  import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
3
4
  import { customElement, property, query, state } from 'lit/decorators.js';
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 { create, figureLegendSourcesChanged, registerFigure, unregisterFigure } from '@hatiolab/things-scene';
@@ -14,6 +16,7 @@ import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValue
14
16
  import { derivedOf } from './revision.js';
15
17
  import { authoredSizeOf, bodyAtSize, PREVIEW_FACTOR, PREVIEW_WAYS, previewClampsOf, previewInstances, previewOneInstance, previewTypeOf, setupPartOf, sizeSpanOf, sizeWithinRange } from '@things-factory/figure-viewer/dist-client/preview-model.js';
16
18
  import { previewSizeWordsOf } from './preview-size.js';
19
+ import { playedControlsOf } from './clips.js';
17
20
  const PREVIEW_VIEWS = ['perspective', 'front', 'right', 'top'];
18
21
  let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
19
22
  constructor() {
@@ -92,21 +95,43 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
92
95
  background-color: rgba(28, 28, 30, 0.72);
93
96
  border-radius: 4px;
94
97
  }
95
- div[foot] {
98
+ /*
99
+ The legend, the size sliders and the controls in one column over the stage: the controls take what is left under
100
+ 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
101
+ fold buttons with them, and nothing scrolled (2026-11-12); a legend on two lines in a narrow preview is room taken too.
102
+ */
103
+ div[over] {
96
104
  position: absolute;
97
- left: 8px;
98
- right: 8px;
99
- bottom: 8px;
105
+ inset: 8px;
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: flex-start;
109
+ gap: 8px;
110
+ pointer-events: none;
111
+ }
112
+ div[over] > div[legend],
113
+ div[over] > div[sizes] {
114
+ pointer-events: auto;
115
+ }
116
+ div[foot] {
117
+ align-self: stretch;
118
+ flex: 1;
119
+ min-height: 0;
100
120
  display: flex;
101
121
  flex-wrap: wrap-reverse;
102
122
  justify-content: space-between;
103
123
  align-items: flex-end;
124
+ align-content: flex-start;
104
125
  gap: 8px;
105
126
  pointer-events: none;
106
127
  }
107
128
  div[foot] > * { pointer-events: auto; }
108
- div[setup] { max-width: min(320px, 100%); padding: 12px; border-radius: 8px; background: rgba(28,28,30,.9); color: #fff; display: grid; gap: 8px; }
109
- div[setup] p { margin: 0; line-height: 1.5; }
129
+ /* One line over the figure -- the part and the button -- its words in its title: three lines covered the figure (user 2026-11-12). */
130
+ 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); }
131
+ div[setup] select { flex: 1 1 auto; }
132
+ div[setup] button { flex: none; white-space: nowrap; }
133
+ /* The controls' last row: as wide as they are, a line over it in place of a box of its own. */
134
+ 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; }
110
135
  /* The help's choice and button in the look of the controls beside it, not the browser's grey (sweep 2026-10-27 U7). */
111
136
  div[setup] select,
112
137
  div[setup] button {
@@ -129,9 +154,8 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
129
154
  }
130
155
  /* Which is which: side by side means nothing without a legend. */
131
156
  div[legend] {
132
- position: absolute;
133
- top: 8px;
134
- left: 8px;
157
+ max-width: 100%;
158
+ box-sizing: border-box;
135
159
  display: flex;
136
160
  gap: 6px;
137
161
  padding: 4px 8px;
@@ -176,13 +200,10 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
176
200
  }
177
201
  /* The one figure's size: under the legend, in the controls' look. */
178
202
  div[sizes] {
179
- position: absolute;
180
- top: 40px;
181
- left: 8px;
182
203
  display: grid;
183
204
  gap: 6px;
184
205
  padding: 8px 10px;
185
- max-width: calc(100% - 16px);
206
+ max-width: 100%;
186
207
  box-sizing: border-box;
187
208
  color: rgba(255, 255, 255, 0.92);
188
209
  background-color: rgba(28, 28, 30, 0.72);
@@ -203,8 +224,12 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
203
224
  font-variant-numeric: tabular-nums;
204
225
  text-align: right;
205
226
  }
206
- div[sizes] p[said] {
207
- margin: 0;
227
+ /* 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). */
228
+ div[legend] span[said] {
229
+ padding-left: 6px;
230
+ border-left: 1px solid rgba(255, 255, 255, 0.22);
231
+ align-self: center;
232
+ white-space: nowrap;
208
233
  font-variant-numeric: tabular-nums;
209
234
  font-weight: 600;
210
235
  }
@@ -219,8 +244,12 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
219
244
  */
220
245
  div[clips] {
221
246
  margin-left: auto;
247
+ max-height: 100%;
248
+ box-sizing: border-box;
249
+ overflow-y: auto;
250
+ overscroll-behavior: contain;
222
251
  display: grid;
223
- grid-template-columns: auto 20px 96px 44px;
252
+ grid-template-columns: max-content 20px 96px 44px;
224
253
  gap: 6px 8px;
225
254
  align-items: center;
226
255
  padding: 8px 10px;
@@ -237,6 +266,10 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
237
266
  grid-template-columns: subgrid;
238
267
  align-items: center;
239
268
  }
269
+ /* 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). */
270
+ div[clips] label > span:first-child {
271
+ white-space: nowrap;
272
+ }
240
273
  /* A motion group's clip: as wide as the dial, slider and value together, so its name reads. */
241
274
  div[clips] select {
242
275
  grid-column: 2 / -1;
@@ -247,6 +280,28 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
247
280
  border: 1px solid rgba(255, 255, 255, 0.22);
248
281
  border-radius: 5px;
249
282
  }
283
+ /* A motion group's clips as buttons: a list the system draws is not seen in a recording of the preview. */
284
+ div[clips] span[clip-choices] {
285
+ grid-column: 2 / -1;
286
+ display: flex;
287
+ flex-wrap: wrap;
288
+ gap: 4px;
289
+ min-width: 0;
290
+ }
291
+ div[clips] span[clip-choices] button {
292
+ font: inherit;
293
+ color: inherit;
294
+ padding: 1px 8px;
295
+ background-color: rgba(255, 255, 255, 0.08);
296
+ border: 1px solid rgba(255, 255, 255, 0.22);
297
+ border-radius: 10px;
298
+ cursor: pointer;
299
+ }
300
+ div[clips] span[clip-choices] button[aria-pressed='true'] {
301
+ background-color: rgba(255, 255, 255, 0.3);
302
+ border-color: rgba(255, 255, 255, 0.6);
303
+ font-weight: 600;
304
+ }
250
305
  div[clips] svg[dial] {
251
306
  width: 20px;
252
307
  height: 20px;
@@ -277,9 +332,65 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
277
332
  div[clips] button[fold-controls]:hover {
278
333
  opacity: 1;
279
334
  }
280
- div[clips] button[pose] {
281
- align-self: stretch;
282
- padding: 4px 8px;
335
+ /* The keep, clear and fold line, and the size help's, stay in sight while the rows between them scroll. */
336
+ div[clips] div[actions],
337
+ div[clips] div[setup] {
338
+ position: sticky;
339
+ z-index: 1;
340
+ margin-left: -10px;
341
+ margin-right: -10px;
342
+ padding-left: 10px;
343
+ padding-right: 10px;
344
+ background-color: rgba(28, 28, 30, 0.96);
345
+ }
346
+ div[clips] div[actions] {
347
+ top: -8px;
348
+ margin-top: -8px;
349
+ padding-top: 8px;
350
+ padding-bottom: 4px;
351
+ }
352
+ div[clips] div[setup] {
353
+ bottom: -8px;
354
+ margin-bottom: -8px;
355
+ padding-bottom: 8px;
356
+ }
357
+ div[clips] div[actions] {
358
+ display: flex;
359
+ align-items: center;
360
+ gap: 6px;
361
+ min-width: 0;
362
+ }
363
+ /* Folded, the box is as wide as its button: the icon and the words that say it opens the controls. */
364
+ div[clips][folded] {
365
+ display: flex;
366
+ flex-direction: column;
367
+ align-items: stretch;
368
+ gap: 6px;
369
+ }
370
+ div[clips][folded] button[fold-controls] {
371
+ gap: 4px;
372
+ padding: 2px 4px;
373
+ font-size: var(--ui-t-micro, 11px);
374
+ }
375
+ /* An icon as tall as the two buttons beside it, at the line's right end. */
376
+ div[clips] button[fold-controls] {
377
+ display: inline-flex;
378
+ align-items: center;
379
+ }
380
+ div[clips] button[fold-controls] md-icon {
381
+ --md-icon-size: 16px;
382
+ }
383
+ div[clips] div[actions] button[fold-controls] {
384
+ margin-left: auto;
385
+ padding: 2px 3px;
386
+ border: 1px solid transparent;
387
+ }
388
+ /* On one line with the fold, each word on one line: a label broken inside a word ("기본으 / 로") read badly (user 2026-11-12). */
389
+ div[clips] button[pose],
390
+ div[clips] button[clear-tried] {
391
+ flex: none;
392
+ white-space: nowrap;
393
+ padding: 3px 8px;
283
394
  font: inherit;
284
395
  font-size: var(--ui-t-micro, 11px);
285
396
  color: rgba(255, 255, 255, 0.92);
@@ -288,7 +399,13 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
288
399
  border-radius: 5px;
289
400
  cursor: pointer;
290
401
  }
291
- div[clips] button[pose]:hover {
402
+ div[clips] button[pose]:disabled,
403
+ div[clips] button[clear-tried]:disabled {
404
+ opacity: 0.4;
405
+ cursor: default;
406
+ }
407
+ div[clips] button[pose]:not(:disabled):hover,
408
+ div[clips] button[clear-tried]:not(:disabled):hover {
292
409
  background-color: rgba(255, 255, 255, 0.24);
293
410
  }
294
411
  div[clips] span[drive] {
@@ -300,7 +417,20 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
300
417
  accent-color: var(--md-sys-color-primary);
301
418
  }
302
419
  /* A value typed, not slid: in the look of the group's clip choice, starting where the sliders start. */
303
- div[clips] input:not([type='range']) {
420
+ /* A name that scrubs its value, in the look the side panel gives it (figure-viewer side-panel-styles \`[scrub]\`). */
421
+ div[clips] label > span[scrub] {
422
+ cursor: ew-resize;
423
+ user-select: none;
424
+ touch-action: none;
425
+ }
426
+ /* A check box in the dial's column, where every field starts. */
427
+ div[clips] input[type='checkbox'] {
428
+ grid-column: 2 / -1;
429
+ justify-self: start;
430
+ width: auto;
431
+ margin: 0;
432
+ }
433
+ div[clips] input:not([type='range']):not([type='checkbox']) {
304
434
  box-sizing: border-box;
305
435
  min-width: 0;
306
436
  font: inherit;
@@ -309,9 +439,15 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
309
439
  border: 1px solid rgba(255, 255, 255, 0.22);
310
440
  border-radius: 5px;
311
441
  }
312
- div[clips] input:not([type='range']):not([type='number']) {
313
- grid-column: 3 / -1;
442
+ /*
443
+ 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
444
+ no range -- from where the dial starts to the right edge, as the choices are (user 2026-11-12: every width differed).
445
+ */
446
+ div[clips] input:not([type='range']):not([type='number']):not([type='checkbox']),
447
+ div[clips] input[wide] {
448
+ grid-column: 2 / -1;
314
449
  width: auto;
450
+ padding: 1px 4px;
315
451
  }
316
452
  /* A control's value, typed where the slider cannot land on it (a step of the range's hundredth, robot dog 2026-11-03). */
317
453
  div[clips] input[typed] {
@@ -321,7 +457,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
321
457
  font-variant-numeric: tabular-nums;
322
458
  }
323
459
  div[clips] label[sampled] output {
324
- grid-column: 3 / -1;
460
+ grid-column: 2 / -1;
325
461
  text-align: left;
326
462
  }
327
463
  div[clips] output {
@@ -375,7 +511,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
375
511
  ${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}
376
512
  ${this.cannot
377
513
  ? html `<div cannot>${this.cannot}</div>`
378
- : html `<div legend>
514
+ : html `<div over><div legend>
379
515
  ${['one', 'eight'].map(way => html `<button way=${way} aria-pressed=${String(this.way === way)} @click=${() => this.chooseWay(way)}>${i18next.t(`figure.label.preview-way-${way}`)}</button>`)}
380
516
  <span views>
381
517
  ${PREVIEW_VIEWS.map(view => html `<button view=${view} aria-pressed=${String(this.looking === view)} @click=${() => this.lookFrom(view)}>${i18next.t(`figure.label.preview-view-${view}`)}</button>`)}
@@ -389,16 +525,29 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
389
525
  : nothing}
390
526
  </span>`;
391
527
  })}
528
+ ${this.way === 'one' && asset && this.trying
529
+ ? html `<span said>${previewSizeWordsOf(asset, this.trying, (key, params) => i18next.t(key, params))}</span>`
530
+ : nothing}
392
531
  </div>
393
532
  ${this.way === 'one' && asset && this.trying ? this.renderSizes(asset, this.trying) : nothing}
394
- <div foot>${asset?.appearance.length ? html `<div setup>
395
- <p>${i18next.t('figure.text.preview-setup')}</p>
396
- <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e) => this.setupPart = e.target.value}>
397
- ${asset.appearance.map(p => html `<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
398
- </select>
399
- <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
400
- </div>` : nothing}
401
- ${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0) ? this.renderDrivers(asset, controls, clips) : nothing}</div>`}`;
533
+ <div foot>${controls.length + clips.length + (asset?.motionGroups?.length ?? 0) + (asset ? valueRowsOf(asset).length : 0)
534
+ ? this.renderDrivers(asset, controls, clips, this.renderSetup(asset, setupPart))
535
+ : this.renderSetup(asset, setupPart)}</div></div>`}`;
536
+ }
537
+ /*
538
+ The size help, shown only where the figure's size can change: the part and the button on one line, its words in its
539
+ 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
540
+ two stood one over the other, and three lines of it covered the figure (user 2026-11-12).
541
+ */
542
+ renderSetup(asset, setupPart) {
543
+ if (!asset?.appearance.length || !['x', 'y', 'z'].some(axis => sizeSpanOf(asset, axis)))
544
+ return nothing;
545
+ return html `<div setup title=${i18next.t('figure.text.preview-setup')}>
546
+ <select aria-label=${i18next.t('figure.label.resize-part')} @change=${(e) => (this.setupPart = e.target.value)}>
547
+ ${asset.appearance.map(p => html `<option value=${p.target} ?selected=${p.target === setupPart}>${p.target}</option>`)}
548
+ </select>
549
+ <button @click=${() => this.dispatchEvent(new CustomEvent('configure-size', { detail: { part: setupPart }, bubbles: true, composed: true }))}>${i18next.t('figure.button.configure-size')}</button>
550
+ </div>`;
402
551
  }
403
552
  lookFrom(view) {
404
553
  this.scene?.root?.setCameraView?.(view);
@@ -406,7 +555,8 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
406
555
  }
407
556
  /** The one figure's size: a slider per axis the figure lets change, and what it is at the size tried. */
408
557
  renderSizes(asset, size) {
409
- const say = (key, params) => i18next.t(key, params);
558
+ if (!['x', 'y', 'z'].some(axis => sizeSpanOf(asset, axis)))
559
+ return nothing;
410
560
  return html `<div sizes>
411
561
  ${['x', 'y', 'z'].map(axis => {
412
562
  const range = sizeSpanOf(asset, axis);
@@ -420,7 +570,6 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
420
570
  <output>${Math.round(size[axis]).toLocaleString()}</output>
421
571
  </label>`;
422
572
  })}
423
- <p said>${previewSizeWordsOf(asset, size, say)}</p>
424
573
  </div>`;
425
574
  }
426
575
  /*
@@ -495,27 +644,38 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
495
644
  this.scene?.fit?.('ratio');
496
645
  this.scene?.root?.fitCameraToModel?.();
497
646
  }
498
- renderDrivers(asset, controls, clips) {
647
+ renderDrivers(asset, controls, clips, setup) {
499
648
  const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]));
500
649
  const sampledStates = sampledStatesOf(asset, this.sample);
501
650
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id]);
502
- const fold = html `<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
503
- ${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
651
+ /* An icon, its words in its title: the words took the room of a button (user 2026-11-12). */
652
+ const foldWords = i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold');
653
+ const fold = html `<button fold-controls aria-expanded=${String(!this.controlsFolded)} title=${foldWords} aria-label=${foldWords} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
654
+ <md-icon>${this.controlsFolded ? 'unfold_more' : 'unfold_less'}</md-icon>
504
655
  </button>`;
656
+ /* Folded, the icon says what it opens: an icon alone in an empty box said nothing (user 2026-11-12). */
505
657
  if (this.controlsFolded)
506
- return html `<div clips folded>${fold}</div>`;
658
+ return html `<div clips folded>
659
+ <button fold-controls aria-expanded="false" @click=${() => (this.controlsFolded = false)}>
660
+ <md-icon>unfold_more</md-icon>${foldWords}
661
+ </button>
662
+ ${setup}
663
+ </div>`;
664
+ const played = playedControlsOf(asset, this.driving, this.moving);
665
+ /*
666
+ Keep, clear and fold on one line, there from the start: keep and clear unable until something is tried. Coming and
667
+ going, they moved the rows under them while a slider was being moved (user 2026-11-12).
668
+ */
507
669
  return html `<div clips>
508
- ${fold}
509
- ${changed.length
510
- ? html `<button pose title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
511
- ${i18next.t('figure.button.pose-as-default')}
512
- </button>`
513
- : nothing}
514
- ${Object.keys(this.giving).length
515
- ? html `<button clear-tried title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
516
- ${i18next.t('figure.button.clear-tried')}
517
- </button>`
518
- : nothing}
670
+ <div actions>
671
+ <button pose ?disabled=${!changed.length} title=${i18next.t('figure.text.pose-as-default-explained')} @click=${() => this.poseAsDefault()}>
672
+ ${i18next.t('figure.button.pose-as-default')}
673
+ </button>
674
+ <button clear-tried ?disabled=${!Object.keys(this.giving).length} title=${i18next.t('figure.text.clear-tried-explained')} @click=${() => this.clearTried()}>
675
+ ${i18next.t('figure.button.clear-tried')}
676
+ </button>
677
+ ${fold}
678
+ </div>
519
679
  ${controls.map(control => {
520
680
  /* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
521
681
  const fromSample = sampledStates[control.id];
@@ -528,8 +688,20 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
528
688
  /* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
529
689
  const step = steps.get(control.id) ?? (control.max - control.min) / 100;
530
690
  const label = asset.stateInputs?.[control.id]?.label ?? control.id;
691
+ /*
692
+ A control a playing clip drives moves on the figure while its row stood still at its last number -- 0 under a
693
+ walking leg (review 2026-11-11). The row says the clip has it, and is not given a value until the clip stops.
694
+ */
695
+ const by = played.get(control.id);
696
+ if (by)
697
+ return html `<label played title=${i18next.t('figure.text.control-played-explained', { clip: by })}>
698
+ <span>${label} <span drive>${unitWord(control.unit)}</span></span>
699
+ <span></span>
700
+ <input type="range" min=${control.min} max=${control.max} .value=${String(value)} disabled />
701
+ <output>${i18next.t('figure.text.control-played', { clip: by })}</output>
702
+ </label>`;
531
703
  return html `<label>
532
- <span>${label} <span drive>${unitWord(control.unit)}</span></span>
704
+ ${this.scrubName(html `${label} <span drive>${unitWord(control.unit)}</span>`, control.unit, value, control.min, control.max, v => this.give(control.id, v))}
533
705
  ${this.renderDial(control, value)}
534
706
  <input
535
707
  type="range"
@@ -539,20 +711,7 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
539
711
  .value=${String(value)}
540
712
  @input=${(e) => this.give(control.id, Number(e.target.value))}
541
713
  />
542
- <input
543
- typed
544
- type="number"
545
- step="any"
546
- min=${control.min}
547
- max=${control.max}
548
- aria-label=${label}
549
- .value=${String(Math.round(value * 100) / 100)}
550
- @change=${(e) => {
551
- const typed = e.target.valueAsNumber;
552
- if (Number.isFinite(typed))
553
- this.give(control.id, Math.min(Math.max(typed, control.min), control.max));
554
- }}
555
- />
714
+ ${this.typedBox(label, value, control.min, control.max, typed => this.give(control.id, typed))}
556
715
  </label>`;
557
716
  })}
558
717
  ${clips.map(clip => {
@@ -562,10 +721,10 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
562
721
  */
563
722
  const value = this.driving[clip] ?? 0;
564
723
  return html `<label>
565
- <span>${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
724
+ ${this.scrubName(html `${clip} <span drive>${i18next.t('figure.label.playback-speed')}</span>`, 'ratio', value, 0, 3, v => this.drive(clip, v))}
566
725
  <span></span>
567
726
  <input type="range" min="0" max="3" step="0.05" .value=${String(value)} @input=${(e) => this.drive(clip, Number(e.target.value))} />
568
- <output>${value.toFixed(2)}</output>
727
+ ${this.typedBox(clip, value, 0, 3, typed => this.drive(clip, typed))}
569
728
  </label>`;
570
729
  })}
571
730
  ${valueRowsOf(asset).map(row => this.renderDataValue(row))}
@@ -577,21 +736,63 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
577
736
  ? nothing
578
737
  : html `<label>
579
738
  <span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
580
- <select aria-label=${group.label} @change=${(e) => this.move(group.id, { ...playing, clip: e.target.value })}>
581
- ${group.clips.map(c => html `<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
582
- </select>
739
+ <span clip-choices role="group" aria-label=${group.label}>
740
+ ${group.clips.map(c => html `<button type="button" aria-pressed=${String(c === playing.clip)} @click=${() => this.move(group.id, { ...playing, clip: c })}>${c}</button>`)}
741
+ </span>
583
742
  </label>`}
584
743
  ${byData.speed
585
744
  ? nothing
586
745
  : html `<label>
587
- <span>${group.label} <span drive>${i18next.t('figure.label.playback-speed')}</span></span>
746
+ ${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 }))}
588
747
  <span></span>
589
748
  <input type="range" min="0" max="3" step="0.05" .value=${String(playing.speed)} @input=${(e) => this.move(group.id, { ...playing, speed: Number(e.target.value) })} />
590
- <output>${playing.speed.toFixed(2)}</output>
749
+ ${this.typedBox(group.label, playing.speed, 0, 3, typed => this.move(group.id, { ...playing, speed: typed }))}
591
750
  </label>`}`;
592
751
  })}
752
+ ${setup}
593
753
  </div>`;
594
754
  }
755
+ /**
756
+ * A row's name that scrubs its value when dragged sideways, as the property panel's names do (\`scrubLabel\`): Shift for
757
+ * finer steps, held within the row's range. The preview's values are tried, not recorded, so each move is the value.
758
+ * The sliders ran 360 degrees over 96 pixels, too coarse to land an angle (user 2026-11-12).
759
+ */
760
+ scrubName(name, unit, value, min, max, set) {
761
+ const held = (v) => Math.min(Math.max(v, min), max);
762
+ return html `<span
763
+ scrub
764
+ @pointerdown=${(down) => {
765
+ /* A press that did not move gives nothing: an empty field is not given its slider's start by a click on its name. */
766
+ let moved = false;
767
+ scrubLabel(down, { value, unit }, {
768
+ move: v => ((moved = true), set(held(v))),
769
+ release: v => set(held(v)),
770
+ cancel: () => moved && set(value)
771
+ });
772
+ }}
773
+ >${name}</span
774
+ >`;
775
+ }
776
+ /**
777
+ * 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
778
+ * range's hundredth, robot dog 2026-11-03); one column of boxes, not boxes over plain numbers (user 2026-11-12).
779
+ */
780
+ typedBox(label, value, min, max, set) {
781
+ return html `<input
782
+ typed
783
+ type="number"
784
+ step="any"
785
+ min=${Number.isFinite(min) ? min : nothing}
786
+ max=${Number.isFinite(max) ? max : nothing}
787
+ aria-label=${label}
788
+ .value=${String(Math.round(value * 100) / 100)}
789
+ @change=${(e) => {
790
+ const typed = e.target.valueAsNumber;
791
+ if (Number.isFinite(typed))
792
+ set(Math.min(Math.max(typed, min), max));
793
+ }}
794
+ />`;
795
+ }
595
796
  /** 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. */
596
797
  renderDataValue(row) {
597
798
  const node = row.node;
@@ -604,22 +805,37 @@ let FigurePreview = class FigurePreview extends localize(i18next)(LitElement) {
604
805
  if (sampled !== undefined)
605
806
  return html `<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
606
807
  <span>${label} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
607
- <output>${typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
808
+ <output>${typeof sampled === 'boolean' ? i18next.t(sampled ? 'figure.label.data-true' : 'figure.label.data-false') : typeof sampled === 'object' ? JSON.stringify(sampled) : String(sampled)}</output>
608
809
  </label>`;
609
810
  const fallback = 'default' in node ? node.default : undefined;
610
811
  const value = this.valuing[row.path] ?? fallback;
611
812
  if (node.kind === 'number') {
612
813
  const ranged = node.min !== undefined && node.max !== undefined && node.max > node.min;
814
+ /*
815
+ An angle is drawn as the controls' angles are -- dial, slider, box -- whether or not its field declares a range:
816
+ one with none slides over one turn, and any angle can still be typed. 「방향 deg」 was a bare number field under
817
+ 「머리 각도 deg」 on a slider (user 2026-11-12).
818
+ */
819
+ const angle = node.unit === 'deg';
820
+ const slide = ranged ? { min: node.min, max: node.max } : angle ? { min: 0, max: 360 } : undefined;
821
+ const scrubFrom = Number(value ?? slide?.min ?? 0);
613
822
  return html `<label>
614
- <span>${label} <span drive>${unitWord(node.unit)}</span></span>
615
- <span></span>
616
- ${ranged
617
- ? html `<input type="range" min=${node.min} max=${node.max} step=${Math.max((node.max - node.min) / 100, 0.01)} .value=${String(value ?? node.min)} @input=${(e) => this.value(row, e.target.value)} />
618
- <output>${value === undefined ? '' : Number(value).toFixed(2)}</output>`
619
- : html `<input type="number" step="any" .value=${value === undefined ? '' : String(value)} @change=${(e) => this.value(row, e.target.value)} />
620
- <span></span>`}
823
+ ${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)))}
824
+ ${slide
825
+ ? html `${this.renderDial({ unit: node.unit ?? '', ...slide }, Number(value ?? slide.min))}
826
+ <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) => this.value(row, e.target.value)} />
827
+ ${ranged
828
+ ? this.typedBox(label, Number(value ?? slide.min), slide.min, slide.max, typed => this.value(row, String(typed)))
829
+ : this.typedBox(label, Number(value ?? slide.min), -Infinity, Infinity, typed => this.value(row, String(typed)))}`
830
+ : html `<input wide type="number" step="any" aria-label=${label} .value=${value === undefined ? '' : String(value)} @change=${(e) => this.value(row, e.target.value)} />`}
621
831
  </label>`;
622
832
  }
833
+ /* 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. */
834
+ if (node.kind === 'boolean')
835
+ return html `<label>
836
+ <span>${label}</span>
837
+ <input type="checkbox" aria-label=${label} .checked=${value === true} @change=${(e) => this.value(row, String(e.target.checked))} />
838
+ </label>`;
623
839
  const domain = domainOf(node);
624
840
  if (!domain.length)
625
841
  return html `<label>