@things-factory/figure-ui 10.1.270 → 10.1.272

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 (113) hide show
  1. package/client/graphql/index.ts +2 -0
  2. package/client/modeller/canvas.ts +4 -2
  3. package/client/modeller/clips.ts +74 -4
  4. package/client/modeller/data-interface.ts +14 -0
  5. package/client/modeller/editor-styles.ts +11 -0
  6. package/client/modeller/editor.ts +39 -11
  7. package/client/modeller/fasten-form.ts +73 -3
  8. package/client/modeller/fasten-geometry.ts +11 -1
  9. package/client/modeller/figure-cavity-drawing.ts +1 -3
  10. package/client/modeller/figure-clips.ts +101 -8
  11. package/client/modeller/figure-fasten-dialog.ts +41 -30
  12. package/client/modeller/figure-fasten-drawing.ts +5 -4
  13. package/client/modeller/figure-first-save.ts +4 -3
  14. package/client/modeller/figure-links.ts +85 -28
  15. package/client/modeller/figure-preview.ts +50 -4
  16. package/client/modeller/figure-size-editor.ts +7 -2
  17. package/client/modeller/first-save.ts +8 -0
  18. package/client/modeller/focus-on-render.ts +4 -0
  19. package/client/modeller/palette.ts +27 -0
  20. package/client/modeller/pivot.ts +76 -0
  21. package/client/modeller/product-session.ts +5 -1
  22. package/client/modeller/size-response.ts +14 -0
  23. package/client/modeller/stage-model.ts +50 -6
  24. package/client/types.ts +2 -0
  25. package/dist-client/graphql/index.js +2 -0
  26. package/dist-client/graphql/index.js.map +1 -1
  27. package/dist-client/modeller/canvas.d.ts +1 -0
  28. package/dist-client/modeller/canvas.js +3 -2
  29. package/dist-client/modeller/canvas.js.map +1 -1
  30. package/dist-client/modeller/clips.d.ts +23 -6
  31. package/dist-client/modeller/clips.js +69 -4
  32. package/dist-client/modeller/clips.js.map +1 -1
  33. package/dist-client/modeller/data-interface.d.ts +5 -0
  34. package/dist-client/modeller/data-interface.js +15 -0
  35. package/dist-client/modeller/data-interface.js.map +1 -1
  36. package/dist-client/modeller/editor-styles.js +11 -0
  37. package/dist-client/modeller/editor-styles.js.map +1 -1
  38. package/dist-client/modeller/editor.d.ts +6 -0
  39. package/dist-client/modeller/editor.js +35 -11
  40. package/dist-client/modeller/editor.js.map +1 -1
  41. package/dist-client/modeller/fasten-form.d.ts +8 -0
  42. package/dist-client/modeller/fasten-form.js +68 -5
  43. package/dist-client/modeller/fasten-form.js.map +1 -1
  44. package/dist-client/modeller/fasten-geometry.d.ts +6 -0
  45. package/dist-client/modeller/fasten-geometry.js +10 -1
  46. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  47. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  48. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  49. package/dist-client/modeller/figure-clips.d.ts +9 -0
  50. package/dist-client/modeller/figure-clips.js +104 -8
  51. package/dist-client/modeller/figure-clips.js.map +1 -1
  52. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  53. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  54. package/dist-client/modeller/figure-fasten-drawing.js +5 -4
  55. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  56. package/dist-client/modeller/figure-first-save.js +4 -3
  57. package/dist-client/modeller/figure-first-save.js.map +1 -1
  58. package/dist-client/modeller/figure-links.d.ts +9 -0
  59. package/dist-client/modeller/figure-links.js +89 -35
  60. package/dist-client/modeller/figure-links.js.map +1 -1
  61. package/dist-client/modeller/figure-preview.js +48 -4
  62. package/dist-client/modeller/figure-preview.js.map +1 -1
  63. package/dist-client/modeller/figure-size-editor.js +7 -3
  64. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  65. package/dist-client/modeller/first-save.d.ts +5 -0
  66. package/dist-client/modeller/first-save.js +7 -0
  67. package/dist-client/modeller/first-save.js.map +1 -1
  68. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  69. package/dist-client/modeller/focus-on-render.js +4 -0
  70. package/dist-client/modeller/focus-on-render.js.map +1 -0
  71. package/dist-client/modeller/links.d.ts +1 -1
  72. package/dist-client/modeller/palette.d.ts +14 -0
  73. package/dist-client/modeller/palette.js +23 -0
  74. package/dist-client/modeller/palette.js.map +1 -1
  75. package/dist-client/modeller/pivot.d.ts +12 -0
  76. package/dist-client/modeller/pivot.js +59 -0
  77. package/dist-client/modeller/pivot.js.map +1 -0
  78. package/dist-client/modeller/product-session.js +6 -2
  79. package/dist-client/modeller/product-session.js.map +1 -1
  80. package/dist-client/modeller/size-response.d.ts +6 -0
  81. package/dist-client/modeller/size-response.js +14 -0
  82. package/dist-client/modeller/size-response.js.map +1 -1
  83. package/dist-client/modeller/stage-model.d.ts +17 -3
  84. package/dist-client/modeller/stage-model.js +44 -6
  85. package/dist-client/modeller/stage-model.js.map +1 -1
  86. package/dist-client/route.d.ts +1 -1
  87. package/dist-client/tsconfig.tsbuildinfo +1 -1
  88. package/dist-client/types.d.ts +2 -0
  89. package/dist-client/types.js.map +1 -1
  90. package/package.json +5 -5
  91. package/test/browser/editing.check.mjs +6 -5
  92. package/test/browser/section.check.mjs +21 -0
  93. package/test/clips.test.ts +49 -4
  94. package/test/data-motion.test.ts +8 -0
  95. package/test/fasten-form.test.ts +57 -0
  96. package/test/fasten-geometry.test.ts +25 -1
  97. package/test/first-part-on-plane.test.ts +52 -0
  98. package/test/first-save.test.ts +11 -1
  99. package/test/pivot.test.ts +57 -0
  100. package/test/save-landed.test.ts +20 -1
  101. package/test/size-response.test.ts +10 -2
  102. package/test/stage-model.test.ts +23 -1
  103. package/translations/en.json +9 -0
  104. package/translations/es.json +9 -0
  105. package/translations/ja.json +9 -0
  106. package/translations/ko.json +9 -0
  107. package/translations/ms.json +9 -0
  108. package/translations/zh.json +9 -0
  109. package/tutorial/humanoid/README.md +22 -0
  110. package/tutorial/humanoid/chapters.mjs +247 -0
  111. package/tutorial/humanoid/encode.mjs +26 -0
  112. package/tutorial/humanoid/lib.mjs +197 -0
  113. package/tutorial/humanoid/record.mjs +65 -0
@@ -89,7 +89,28 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
89
89
  }
90
90
  details[fasten-detail] > div[form] {
91
91
  margin-top: 8px;
92
- max-width: 320px;
92
+ max-width: 420px;
93
+ }
94
+ /* Where across the face, as buttons: a list the system draws is not seen in a recording, nor reached by a key in every browser. */
95
+ div[align-choices] {
96
+ display: flex;
97
+ flex-wrap: wrap;
98
+ gap: 4px;
99
+ }
100
+ div[align-choices] button {
101
+ font: inherit;
102
+ font-size: var(--ui-t-small, 11.5px);
103
+ padding: 3px 8px;
104
+ border-radius: 12px;
105
+ border: 1px solid var(--md-sys-color-outline-variant, #c4c7c5);
106
+ background: transparent;
107
+ color: inherit;
108
+ cursor: pointer;
109
+ }
110
+ div[align-choices] button[aria-pressed='true'] {
111
+ border-color: var(--md-sys-color-primary, #0d8f86);
112
+ background: var(--md-sys-color-primary-container, #cdeeea);
113
+ font-weight: 600;
93
114
  }
94
115
  span[fasten-summary] {
95
116
  opacity: 0.8;
@@ -253,7 +274,7 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
253
274
  <summary>
254
275
  ${i18next.t('figure.label.fasten-detail')}${detail.length ? html` · <span fasten-summary>${detail.join(' · ')}</span>` : nothing}
255
276
  </summary>
256
- ${plan.alignRows.length ? this.renderAlignment(plan.alignRows) : nothing}
277
+ ${(plan.alignAxes ?? plan.alignRows).length ? this.renderAlignment(plan.alignAxes ?? plan.alignRows) : nothing}
257
278
  <div form>
258
279
  <span>${i18next.t('figure.label.gap')}</span>
259
280
  <input
@@ -384,34 +405,24 @@ export class FigureFastenDialog extends localize(i18next)(LitElement) {
384
405
  : 'centre'
385
406
  : (f.align[axis] ?? 'centre')
386
407
  return html`<span>${i18next.t('figure.label.align-' + axis)}</span>
387
- <select @change=${(e: Event) => set(axis, (e.target as HTMLSelectElement).value as Alignment)}>
388
- <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
389
- ${
390
- edges
391
- ? html`<option value="min" ?selected=${how === 'min'}>
392
- ${i18next.t(`figure.label.align-${axis}-min`)}
393
- </option>
394
- <option value="max" ?selected=${how === 'max'}>
395
- ${i18next.t(`figure.label.align-${axis}-max`)}
396
- </option>`
397
- : nothing
398
- }
399
- ${
400
- f.alignInput?.[axis]
401
- ? html`<option value="input" ?selected=${how === 'input'}>
402
- ${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) })}
403
- </option>`
404
- : nothing
405
- }
406
- <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
407
- ${
408
- this.revision && hasInstanceSize(this.revision.asset, axis)
409
- ? html`<option value="share" ?selected=${how === 'share'}>
410
- ${i18next.t('figure.label.align-share')}
411
- </option>`
412
- : nothing
413
- }
414
- </select>
408
+ <div align-choices role="group" aria-label=${i18next.t('figure.label.align-' + axis)}>
409
+ ${[
410
+ { value: 'centre' as Alignment, words: i18next.t('figure.label.align-centre'), shown: true },
411
+ { value: 'min' as Alignment, words: i18next.t(`figure.label.align-${axis}-min`), shown: edges },
412
+ { value: 'max' as Alignment, words: i18next.t(`figure.label.align-${axis}-max`), shown: edges },
413
+ {
414
+ value: 'input' as Alignment,
415
+ words: f.alignInput?.[axis] ? i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis]!, key => i18next.t(key)) }) : '',
416
+ shown: !!f.alignInput?.[axis]
417
+ },
418
+ { value: 'mm' as Alignment, words: i18next.t('figure.label.align-mm'), shown: true },
419
+ { value: 'share' as Alignment, words: i18next.t('figure.label.align-share'), shown: !!this.revision && hasInstanceSize(this.revision.asset, axis) }
420
+ ]
421
+ .filter(choice => choice.shown)
422
+ .map(
423
+ choice => html`<button type="button" aria-pressed=${String(how === choice.value)} @click=${() => set(axis, choice.value)}>${choice.words}</button>`
424
+ )}
425
+ </div>
415
426
  ${
416
427
  how === 'mm' || how === 'share'
417
428
  ? html`<span></span
@@ -6,7 +6,8 @@ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-
6
6
  import { figureAroundNormalOf, figureAroundOf, figureCaughtAngleOf, figureSurfaceAnglesOf, figureSurfacePlaceOf } from '@hatiolab/figure-model'
7
7
  import type { FigureFace, FigureSurfacePlace } from '@hatiolab/figure-model'
8
8
 
9
- import { alignmentAt, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
9
+ import { focusOnRender } from './focus-on-render.js'
10
+ import { alignmentAt, drawingSpanOf, fastenLabelsOf, offsetOf, overhangOf, screenOf, type FaceView, type FastenMode } from './fasten-geometry.js'
10
11
  import type { Alignment } from './links.js'
11
12
 
12
13
  type Axis = 'x' | 'y' | 'z'
@@ -332,8 +333,7 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
332
333
  const { h, v, sign } = screenOf(view.axes)
333
334
  const W = WHERE_W,
334
335
  H = WHERE_H
335
- /* Room around the face for the part to go past its edges by about half itself; a plane with no size, three parts wide. */
336
- const span = (a: Axis) => (view.face[a] !== undefined ? view.face[a]! + view.part[a] : view.part[a] * 3)
336
+ const span = (a: Axis) => drawingSpanOf(view.face[a], view.part[a])
337
337
  const k = Math.min((W - 40) / span(h), (H - 40) / span(v))
338
338
  this.scale = k
339
339
  const cx = W / 2,
@@ -433,7 +433,8 @@ export class FigureFastenDrawing extends localize(i18next)(LitElement) {
433
433
  this.change({ axis: a, how: 'mm', mm: Math.round(value) })
434
434
  this.released()
435
435
  }}
436
- @blur=${() => (this.typing = undefined)} />`
436
+ @blur=${() => (this.typing = undefined)}
437
+ ${focusOnRender()} />`
437
438
  }
438
439
 
439
440
  private grab(ev: PointerEvent, at: Record<Axis, number>, caught: Partial<Record<Axis, string>>) {
@@ -7,7 +7,7 @@ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles'
7
7
  import '@material/web/button/filled-button.js'
8
8
  import '@material/web/button/text-button.js'
9
9
 
10
- import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, type FirstSave } from './first-save.js'
10
+ import { firstSaveRefusalOf, offeredTypeOf, releasableType, typeOf, typingTypeOf, type FirstSave } from './first-save.js'
11
11
 
12
12
  /**
13
13
  * The first save of a new figure: its type and its name, asked once, in the suite's popup look.
@@ -97,13 +97,13 @@ export class FigureFirstSave extends LitElement {
97
97
  .value=${this.type}
98
98
  placeholder=${i18next.t('figure.text.figure-type-example')}
99
99
  @input=${(e: Event) => {
100
- this.type = typeOf((e.target as HTMLInputElement).value)
100
+ this.type = typingTypeOf((e.target as HTMLInputElement).value)
101
101
  ;(e.target as HTMLInputElement).value = this.type
102
102
  this.refusal = null
103
103
  }}
104
104
  @keydown=${this.onKey}
105
105
  />
106
- ${this.type && !releasableType(this.type)
106
+ ${typeOf(this.type) && !releasableType(typeOf(this.type))
107
107
  ? html`<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>`
108
108
  : this.suggested && this.type === this.suggested
109
109
  ? html`<div class="note">${i18next.t('figure.text.identifier-suggested')}</div>`
@@ -132,6 +132,7 @@ export class FigureFirstSave extends LitElement {
132
132
  }
133
133
 
134
134
  private confirm = () => {
135
+ this.type = typeOf(this.type)
135
136
  this.refusal = firstSaveRefusalOf(this.type, this.taken)
136
137
  if (this.refusal) return
137
138
  const namespace = this.namespaces.length > 1 ? this.chosenNamespace : undefined
@@ -50,7 +50,7 @@ import {
50
50
  stateSizeFixedOf,
51
51
  type StateSizeForm
52
52
  } from './links.js'
53
- import { measureFactor } from './size-response.js'
53
+ import { measureFactor, sizeAxisWord } from './size-response.js'
54
54
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
55
55
  import { insideAlignmentOf } from './fasten-geometry.js'
56
56
  import { fastenFormOf, fastenPlanOf, fastenSentenceOf } from './fasten-form.js'
@@ -58,9 +58,22 @@ import type { FigureRevision } from './revision.js'
58
58
  import { HelpToggles } from './help.js'
59
59
  import { groupMenu, GroupMenuStyles } from './group-menu.js'
60
60
  import { jointAxisWord } from './joint-axis.js'
61
+ import { PIVOT_POINTS, pivotPointOf, pivotPointsOf, type PivotPoint } from './pivot.js'
61
62
  import { placeTerms } from '@things-factory/figure-viewer/dist-client/detail-facts.js'
62
63
 
63
64
  const freshMotion = (): MotionForm => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 })
65
+
66
+ /**
67
+ * The motion a part's form starts from: a part hung on another part most often turns there -- a thigh at the hip, a door
68
+ * at its frame -- so it starts as a turn; a part on the plane, or on nothing, as a slide (review 2026-11-11: eight
69
+ * joints of a humanoid, each changed from slide to turn by hand).
70
+ */
71
+ export function motionStartOf(revision: FigureRevision | undefined, part: string): MotionForm {
72
+ const own = revision ? fasteningOf(revision, part) : undefined
73
+ return own && !own.toPlane && own.target ? { ...freshMotion(), kind: 'turn', min: -90, max: 90 } : freshMotion()
74
+ }
75
+
76
+ const sameMotion = (a: MotionForm, b: MotionForm) => JSON.stringify(a) === JSON.stringify(b)
64
77
  const freshRow = () => ({ axis: '' as Axis | '', pitch: 200, fills: true, count: 5 })
65
78
  const freshPlace = () => ({ role: 'grip' as 'seat' | 'grip', face: 'top' as FigureFace, label: '', accepts: '', capacity: 1 })
66
79
 
@@ -85,6 +98,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
85
98
  :host {
86
99
  display: block;
87
100
  }
101
+ /* The turn's axis through the part's top, middle or bottom, by name, under the numbers it fills in. */
102
+ div[pivot-points] {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 8px;
106
+ margin: 4px 0 8px;
107
+ }
108
+ div[pivot-points] > span {
109
+ color: var(--md-sys-color-on-surface-variant);
110
+ font-size: var(--ui-t-small, 11.5px);
111
+ }
88
112
  /* A group's sub-question, under its title: quieter than the title, as a label is. */
89
113
  p[group-question] {
90
114
  margin: 4px 0 0;
@@ -216,7 +240,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
216
240
  if (changed.has('asset') || changed.has('partId')) this.fixNote = ''
217
241
  if (this.asset && this.partId && this.partId !== this.loadedFor) {
218
242
  this.loadedFor = this.partId
219
- this.motion = freshMotion()
243
+ this.motion = motionStartOf(this.revision, this.partId)
220
244
  this.place = freshPlace()
221
245
  this.placeEditing = null
222
246
  this.row = freshRow()
@@ -224,6 +248,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
224
248
  this.rowCandidate = undefined
225
249
  this.editingMotion = false
226
250
  this.motionAxis = 0
251
+ } else if (changed.has('revision') && this.partId && !this.editingMotion) {
252
+ /* Fastened since the form was loaded, a form nobody has touched starts as that part's motion now does. */
253
+ const was = motionStartOf(changed.get('revision') as FigureRevision | undefined, this.partId)
254
+ if (sameMotion(this.motion, was)) this.motion = motionStartOf(this.revision, this.partId)
227
255
  }
228
256
  }
229
257
 
@@ -624,7 +652,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
624
652
  const part = this.partId
625
653
  const axes = followableAxes(asset, part)
626
654
  if (!axes.length) return nothing
627
- const word = (axis: Axis) => `${axis.toUpperCase()}${measureFactor(asset, part, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
655
+ const word = (axis: Axis) => sizeAxisWord(asset, part, axis, i18next.t('figure.label.size-diameter'))
628
656
  const sized = axes.flatMap(axis => {
629
657
  const f = followOf(asset, part, axis)
630
658
  return f.of === 'state' ? [{ axis, f }] : []
@@ -760,7 +788,33 @@ export class FigureLinks extends localize(i18next)(LitElement) {
760
788
  </div>`
761
789
  )}
762
790
  </div>
763
- </div>`
791
+ </div>
792
+ ${this.renderPivotPoints(m)}`
793
+ }
794
+
795
+ /*
796
+ The axis put through the part's top end, middle or bottom end by name: the numbers above are measured from the
797
+ joint's origin, which a person cannot see (tutorial run 2026-11-12: a shoulder given Y 120 "to reach the shoulder"
798
+ turned about a point above it). The button that matches what is typed stands pressed.
799
+ */
800
+ private pivotCache?: { key: string; points: ReturnType<typeof pivotPointsOf> }
801
+ private renderPivotPoints(m: MotionForm) {
802
+ if (!this.revision || m.throughFollows) return nothing
803
+ const key = `${this.revision.revision}:${this.partId}:${m.axis}`
804
+ if (this.pivotCache?.key !== key) this.pivotCache = { key, points: pivotPointsOf(this.revision, this.partId, m.axis as Axis) }
805
+ const points = this.pivotCache.points
806
+ if (!points) return nothing
807
+ return html`<div pivot-points>
808
+ <span>${i18next.t('figure.label.pivot-at')}</span>
809
+ <ox-input-select-buttons
810
+ .value=${pivotPointOf(points, m.through, m.axis as Axis) ?? ''}
811
+ .options=${PIVOT_POINTS.map(p => ({ value: p, display: i18next.t(`figure.label.pivot-${p}`) }))}
812
+ @change=${(e: CustomEvent) => {
813
+ const at = points[e.detail as PivotPoint]
814
+ if (at) this.motion = { ...m, through: { ...at } }
815
+ }}
816
+ ></ox-input-select-buttons>
817
+ </div>`
764
818
  }
765
819
 
766
820
  private renderMotion(asset: FigureAsset) {
@@ -844,6 +898,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
844
898
  const adding = k > 0 && k >= figureMotionsOf(joint).length
845
899
  const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
846
900
  const refusal = motionFormRefusalOf(m, follow)
901
+ /* Following another joint is offered after the part's own control: a row above it moved every field down a row, and
902
+ a name typed there chose a joint to follow (review 2026-11-11). */
847
903
  return html`
848
904
  ${k > 0 ? html`<p motion-axis-heading><b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b></p>` : nothing}
849
905
  <div pair>
@@ -858,31 +914,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
858
914
  ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
859
915
  </select>`}
860
916
  </div>
861
- ${m.followSum
862
- ? html`<div form>
863
- <span>${i18next.t('figure.label.motion-follow')}</span>
864
- <span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
865
- <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
866
- <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
867
- </div>`
868
- : leaders.length
869
- ? html`<div form>
870
- <span>${i18next.t('figure.label.motion-follow')}</span>
871
- <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
872
- <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
873
- ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
874
- </select>
875
- </div>`
876
- : nothing}
877
- ${m.followSum
917
+ ${m.followSum || follow
878
918
  ? nothing
879
- : follow
880
- ? html`<div form>
881
- <span>${i18next.t('figure.label.motion-multiplier')}</span>
882
- <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
883
- <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
884
- <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
885
- </div>`
886
919
  : html`<div form>
887
920
  ${m.kind === 'slide'
888
921
  ? html`<span>${i18next.t('figure.label.motion-travel')}</span>
@@ -910,6 +943,30 @@ export class FigureLinks extends localize(i18next)(LitElement) {
910
943
  <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
911
944
  </div>`}
912
945
  </div>`}
946
+ ${m.followSum
947
+ ? html`<div form>
948
+ <span>${i18next.t('figure.label.motion-follow')}</span>
949
+ <span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
950
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
951
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
952
+ </div>`
953
+ : leaders.length
954
+ ? html`<div form>
955
+ <span>${i18next.t('figure.label.motion-follow')}</span>
956
+ <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
957
+ <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
958
+ ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
959
+ </select>
960
+ </div>`
961
+ : nothing}
962
+ ${!m.followSum && follow
963
+ ? html`<div form>
964
+ <span>${i18next.t('figure.label.motion-multiplier')}</span>
965
+ <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
966
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
967
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
968
+ </div>`
969
+ : nothing}
913
970
  ${m.kind === 'slide' ? nothing : this.renderThrough(m)}
914
971
  <button
915
972
  act
@@ -12,10 +12,11 @@ import { DEFAULT_VIEW, previewBoardDepth, sceneLook, WORKBENCH, type ViewSetting
12
12
  import { parametersForClip } from '@things-factory/figure-viewer/dist-client/detail-motion.js'
13
13
  import { controlsOf } from '@things-factory/figure-viewer/dist-client/controls.js'
14
14
  import { dialShape } from './angle-dial.js'
15
- import { dataValueAt, domainOf, motionWrittenByData, typedValueOf, valueRowsOf } from './data-interface.js'
15
+ import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValueOf, valueRowsOf } from './data-interface.js'
16
16
  import { derivedOf, type FigureRevision } from './revision.js'
17
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'
18
18
  import { previewSizeWordsOf } from './preview-size.js'
19
+ import { playedControlsOf } from './clips.js'
19
20
 
20
21
  /**
21
22
  * The figure placed on a board.
@@ -250,6 +251,28 @@ export class FigurePreview extends localize(i18next)(LitElement) {
250
251
  border: 1px solid rgba(255, 255, 255, 0.22);
251
252
  border-radius: 5px;
252
253
  }
254
+ /* A motion group's clips as buttons: a list the system draws is not seen in a recording of the preview. */
255
+ div[clips] span[clip-choices] {
256
+ grid-column: 2 / -1;
257
+ display: flex;
258
+ flex-wrap: wrap;
259
+ gap: 4px;
260
+ min-width: 0;
261
+ }
262
+ div[clips] span[clip-choices] button {
263
+ font: inherit;
264
+ color: inherit;
265
+ padding: 1px 8px;
266
+ background-color: rgba(255, 255, 255, 0.08);
267
+ border: 1px solid rgba(255, 255, 255, 0.22);
268
+ border-radius: 10px;
269
+ cursor: pointer;
270
+ }
271
+ div[clips] span[clip-choices] button[aria-pressed='true'] {
272
+ background-color: rgba(255, 255, 255, 0.3);
273
+ border-color: rgba(255, 255, 255, 0.6);
274
+ font-weight: 600;
275
+ }
253
276
  div[clips] svg[dial] {
254
277
  width: 20px;
255
278
  height: 20px;
@@ -550,11 +573,13 @@ export class FigurePreview extends localize(i18next)(LitElement) {
550
573
 
551
574
  private renderDrivers(asset: FigureAsset, controls: FigureGraphStateInput[], clips: string[]) {
552
575
  const steps = new Map(controlsOf(asset).map(c => [c.id, c.step]))
576
+ const sampledStates = sampledStatesOf(asset, this.sample)
553
577
  const changed = controls.filter(c => this.giving[c.id] !== undefined && this.giving[c.id] !== asset.stateDefaults[c.id])
554
578
  const fold = html`<button fold-controls aria-expanded=${String(!this.controlsFolded)} @click=${() => (this.controlsFolded = !this.controlsFolded)}>
555
579
  ${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
556
580
  </button>`
557
581
  if (this.controlsFolded) return html`<div clips folded>${fold}</div>`
582
+ const played = playedControlsOf(asset, this.driving, this.moving)
558
583
  return html`<div clips>
559
584
  ${fold}
560
585
  ${changed.length
@@ -568,10 +593,29 @@ export class FigurePreview extends localize(i18next)(LitElement) {
568
593
  </button>`
569
594
  : nothing}
570
595
  ${controls.map(control => {
596
+ /* A control the data tab's sample sets is what the figure shows, as a value row the sample gives: said, not offered. */
597
+ const fromSample = sampledStates[control.id]
598
+ if (fromSample !== undefined)
599
+ return html`<label sampled title=${i18next.t('figure.text.data-value-from-sample')}>
600
+ <span>${asset.stateInputs?.[control.id]?.label ?? control.id} <span drive>${i18next.t('figure.label.data-value-from-sample')}</span></span>
601
+ <output>${Math.round(fromSample * 100) / 100} ${unitWord(control.unit)}</output>
602
+ </label>`
571
603
  const value = this.giving[control.id] ?? asset.stateDefaults[control.id] ?? control.min
572
604
  /* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
573
605
  const step = steps.get(control.id) ?? (control.max - control.min) / 100
574
606
  const label = asset.stateInputs?.[control.id]?.label ?? control.id
607
+ /*
608
+ A control a playing clip drives moves on the figure while its row stood still at its last number -- 0 under a
609
+ walking leg (review 2026-11-11). The row says the clip has it, and is not given a value until the clip stops.
610
+ */
611
+ const by = played.get(control.id)
612
+ if (by)
613
+ return html`<label played title=${i18next.t('figure.text.control-played-explained', { clip: by })}>
614
+ <span>${label} <span drive>${unitWord(control.unit)}</span></span>
615
+ <span></span>
616
+ <input type="range" min=${control.min} max=${control.max} .value=${String(value)} disabled />
617
+ <output>${i18next.t('figure.text.control-played', { clip: by })}</output>
618
+ </label>`
575
619
  return html`<label>
576
620
  <span>${label} <span drive>${unitWord(control.unit)}</span></span>
577
621
  ${this.renderDial(control, value)}
@@ -620,9 +664,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
620
664
  ? nothing
621
665
  : html`<label>
622
666
  <span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
623
- <select aria-label=${group.label} @change=${(e: Event) => this.move(group.id, { ...playing, clip: (e.target as HTMLSelectElement).value })}>
624
- ${group.clips.map(c => html`<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
625
- </select>
667
+ <span clip-choices role="group" aria-label=${group.label}>
668
+ ${group.clips.map(
669
+ c => html`<button type="button" aria-pressed=${String(c === playing.clip)} @click=${() => this.move(group.id, { ...playing, clip: c })}>${c}</button>`
670
+ )}
671
+ </span>
626
672
  </label>`}
627
673
  ${byData.speed
628
674
  ? nothing
@@ -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 = `${axis.toUpperCase()}${measureFactor(asset, this.partId, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
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.
@@ -0,0 +1,4 @@
1
+ import { ref } from 'lit/directives/ref.js'
2
+
3
+ /** A field opened by a press takes the keys at once, its value selected to be typed over. */
4
+ export const focusOnRender = () => ref(el => el && requestAnimationFrame(() => (el as HTMLInputElement).select()))
@@ -69,3 +69,30 @@ export function paletteRecipe(kind: FigurePaletteKind, id: string): FigurePrimit
69
69
  }
70
70
  }
71
71
  }
72
+
73
+ /**
74
+ * Whether a part added now is the figure's base, added fastened to its mounting plane: the figure has no part yet and
75
+ * stands on a plane -- the floor (also when not said), the ceiling or a wall. A centred figure has no plane to stand on.
76
+ */
77
+ export function firstPartFastensToPlane(drawn: readonly unknown[], placement: string | undefined): boolean {
78
+ return drawn.length === 0 && placement !== 'center'
79
+ }
80
+
81
+ const PALETTE_KINDS: FigurePaletteKind[] = ['box', 'cylinder', 'rounded-box', 'sphere', 'frustum', 'polygon', 'pyramid', 'pipe', 'plate', 'wall', 'hollow-box', 'portal']
82
+
83
+ /**
84
+ * Whether a drawn part still stands where the palette put it -- unturned, at one of the places a part is added at. Such a
85
+ * part was not placed by the author: fastening it has no place of theirs to keep (tutorial 2026-11-11, a mast added
86
+ * inside the body was fastened 550 mm deep in it).
87
+ */
88
+ export function standsWhereAdded(pose: { r: number[][]; t: number[] } | undefined): boolean {
89
+ if (!pose) return false
90
+ const unturned = pose.r.every((row, i) => row.every((v, j) => Math.abs(v - (i === j ? 1 : 0)) < 1e-9))
91
+ return (
92
+ unturned &&
93
+ PALETTE_KINDS.some(kind => {
94
+ const { x, y, z } = paletteRecipe(kind, 'p').position
95
+ return [x, y, z].every((v, i) => Math.abs(v - pose.t[i]!) < 1e-6)
96
+ })
97
+ )
98
+ }
@@ -0,0 +1,76 @@
1
+ import { compileFigureGraph } from '@hatiolab/figure-model'
2
+
3
+ import type { Axis } from './links.js'
4
+ import type { FigureRevision } from './revision.js'
5
+
6
+ /*
7
+ * Where a turn's axis may be put by name: the part's top end, its middle, its bottom end. The motion form's 「축이
8
+ * 지나는 점」 is measured from the joint's origin along the joint's own axes, which a person cannot see -- in the
9
+ * humanoid lesson the shoulder was given Y 120 to lift the pivot "to the shoulder" when the origin already stood at the
10
+ * arm's top end (attached level with the torso's top), and the arm turned about a point 120 mm above it (tutorial run
11
+ * 2026-11-12). A named point is worked out here from the part as it rests, so the number is right whatever the face,
12
+ * the alignment or the gap.
13
+ */
14
+
15
+ export type PivotPoint = 'top' | 'middle' | 'bottom'
16
+ export const PIVOT_POINTS: readonly PivotPoint[] = ['top', 'middle', 'bottom']
17
+
18
+ type Pose = { r: number[][]; t: number[] }
19
+ type Faces = Partial<Record<'left' | 'right' | 'bottom' | 'top' | 'back' | 'front', { at: number }>>
20
+
21
+ /**
22
+ * The `through` that puts a turn's axis through each named point of `part`, in the joint's frame: null when the part
23
+ * has no joint to another part, or its shape has no faces to measure. A distance along the turn's own axis (`along`)
24
+ * moves nothing and is left out: the shoulder's points read Y alone, not an X the person would wonder about.
25
+ */
26
+ export function pivotPointsOf(revision: FigureRevision, part: string, along?: Axis): Record<PivotPoint, Partial<Record<Axis, number>>> | null {
27
+ const joint = revision.asset.joints?.find(j => j.body1.part === part)
28
+ if (!joint || !('part' in joint.body0)) return null
29
+ const faces = (revision.geometry.find(g => (g as { id: string }).id === part) as { shape?: { faces?: Faces } } | undefined)?.shape?.faces
30
+ const mid = (lo: keyof Faces, hi: keyof Faces) => (faces?.[lo] && faces?.[hi] ? (faces[lo]!.at + faces[hi]!.at) / 2 : undefined)
31
+ const [x, z] = [mid('left', 'right'), mid('back', 'front')]
32
+ if (x === undefined || z === undefined || !faces?.top || !faces?.bottom) return null
33
+ const pose = restingPoseOf(revision, part)
34
+ if (!pose) return null
35
+ /* The pose carries the part's own frame into the joint's: a point of the part, said in the joint's frame. */
36
+ const inJoint = (p: number[]): Partial<Record<Axis, number>> => {
37
+ const v = [0, 1, 2].map(i => pose.r[i]![0]! * p[0]! + pose.r[i]![1]! * p[1]! + pose.r[i]![2]! * p[2]! + pose.t[i]!)
38
+ const out: Partial<Record<Axis, number>> = {}
39
+ ;(['x', 'y', 'z'] as const).forEach((a, i) => {
40
+ const mm = Math.round(v[i]! * 10) / 10
41
+ if (a !== along && Math.abs(mm) >= 0.05) out[a] = mm
42
+ })
43
+ return out
44
+ }
45
+ return {
46
+ top: inJoint([x, faces.top.at, z]),
47
+ middle: inJoint([x, (faces.top.at + faces.bottom.at) / 2, z]),
48
+ bottom: inJoint([x, faces.bottom.at, z])
49
+ }
50
+ }
51
+
52
+ /** The part's pose in its joint's frame with every control at its default: the joint at rest, not as the sliders left it. */
53
+ function restingPoseOf(revision: FigureRevision, part: string): Pose | null {
54
+ const m = revision.asset.document.model as unknown as { nodes: { id: string; outputs: Record<string, string> }[] }
55
+ const node = m.nodes.find(n => n.id === `${part}.pose`)
56
+ if (!node) return null
57
+ try {
58
+ const values = compileFigureGraph(m as never).evaluate({ ...revision.inputs, ...(revision.asset.stateDefaults ?? {}) }).values as Record<string, unknown>
59
+ const pose = values[Object.values(node.outputs)[0]!] as Pose | undefined
60
+ return pose?.r && pose?.t ? pose : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
66
+ /** Which named point a `through` is, when it is one: the form marks the button it matches. */
67
+ export function pivotPointOf(
68
+ points: Record<PivotPoint, Partial<Record<Axis, number>>> | null,
69
+ through: Partial<Record<Axis, number>> | undefined,
70
+ along?: Axis
71
+ ): PivotPoint | undefined {
72
+ if (!points) return undefined
73
+ const same = (a: Partial<Record<Axis, number>>, b: Partial<Record<Axis, number>> = {}) =>
74
+ (['x', 'y', 'z'] as const).every(k => k === along || Math.abs((a[k] ?? 0) - (b[k] ?? 0)) < 0.5)
75
+ return PIVOT_POINTS.find(p => same(points[p], through))
76
+ }
@@ -265,7 +265,11 @@ export function createFigureProductSession(store: FigureProductStore) {
265
265
  */
266
266
  detachAsCopy() {
267
267
  if (saving) throw refusal('SESSION_SAVING', '저장이 끝난 뒤 편집해 주세요.')
268
- if (!conflict) throw refusal('SESSION_NO_CONFLICT', '충돌한 저장이 없습니다.')
268
+ /*
269
+ After a conflict, or to release edits that break the released contract as a new figure type (the release gate's
270
+ way out, tutorial 2026-11-11): either way the figure it came from stays as the server has it.
271
+ */
272
+ if (!conflict && !id) throw refusal('SESSION_NOT_SAVED', '저장된 피규어가 아닙니다.')
269
273
  id = undefined
270
274
  baseline = undefined
271
275
  creationId = crypto.randomUUID()