@things-factory/figure-ui 10.1.271 → 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 (79) hide show
  1. package/client/modeller/canvas.ts +4 -2
  2. package/client/modeller/clips.ts +74 -4
  3. package/client/modeller/fasten-form.ts +69 -4
  4. package/client/modeller/figure-cavity-drawing.ts +1 -3
  5. package/client/modeller/figure-clips.ts +101 -8
  6. package/client/modeller/figure-fasten-dialog.ts +41 -30
  7. package/client/modeller/figure-fasten-drawing.ts +3 -1
  8. package/client/modeller/figure-first-save.ts +4 -3
  9. package/client/modeller/figure-links.ts +85 -28
  10. package/client/modeller/figure-preview.ts +41 -3
  11. package/client/modeller/figure-size-editor.ts +7 -2
  12. package/client/modeller/first-save.ts +8 -0
  13. package/client/modeller/focus-on-render.ts +4 -0
  14. package/client/modeller/pivot.ts +76 -0
  15. package/client/modeller/size-response.ts +14 -0
  16. package/client/modeller/stage-model.ts +50 -6
  17. package/dist-client/modeller/canvas.d.ts +1 -0
  18. package/dist-client/modeller/canvas.js +3 -2
  19. package/dist-client/modeller/canvas.js.map +1 -1
  20. package/dist-client/modeller/clips.d.ts +21 -4
  21. package/dist-client/modeller/clips.js +69 -4
  22. package/dist-client/modeller/clips.js.map +1 -1
  23. package/dist-client/modeller/fasten-form.d.ts +8 -0
  24. package/dist-client/modeller/fasten-form.js +64 -5
  25. package/dist-client/modeller/fasten-form.js.map +1 -1
  26. package/dist-client/modeller/figure-cavity-drawing.js +1 -3
  27. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  28. package/dist-client/modeller/figure-clips.d.ts +9 -0
  29. package/dist-client/modeller/figure-clips.js +104 -8
  30. package/dist-client/modeller/figure-clips.js.map +1 -1
  31. package/dist-client/modeller/figure-fasten-dialog.js +39 -24
  32. package/dist-client/modeller/figure-fasten-dialog.js.map +1 -1
  33. package/dist-client/modeller/figure-fasten-drawing.js +3 -1
  34. package/dist-client/modeller/figure-fasten-drawing.js.map +1 -1
  35. package/dist-client/modeller/figure-first-save.js +4 -3
  36. package/dist-client/modeller/figure-first-save.js.map +1 -1
  37. package/dist-client/modeller/figure-links.d.ts +9 -0
  38. package/dist-client/modeller/figure-links.js +89 -35
  39. package/dist-client/modeller/figure-links.js.map +1 -1
  40. package/dist-client/modeller/figure-preview.js +39 -3
  41. package/dist-client/modeller/figure-preview.js.map +1 -1
  42. package/dist-client/modeller/figure-size-editor.js +7 -3
  43. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  44. package/dist-client/modeller/first-save.d.ts +5 -0
  45. package/dist-client/modeller/first-save.js +7 -0
  46. package/dist-client/modeller/first-save.js.map +1 -1
  47. package/dist-client/modeller/focus-on-render.d.ts +2 -0
  48. package/dist-client/modeller/focus-on-render.js +4 -0
  49. package/dist-client/modeller/focus-on-render.js.map +1 -0
  50. package/dist-client/modeller/pivot.d.ts +12 -0
  51. package/dist-client/modeller/pivot.js +59 -0
  52. package/dist-client/modeller/pivot.js.map +1 -0
  53. package/dist-client/modeller/size-response.d.ts +6 -0
  54. package/dist-client/modeller/size-response.js +14 -0
  55. package/dist-client/modeller/size-response.js.map +1 -1
  56. package/dist-client/modeller/stage-model.d.ts +17 -3
  57. package/dist-client/modeller/stage-model.js +44 -6
  58. package/dist-client/modeller/stage-model.js.map +1 -1
  59. package/dist-client/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +4 -4
  61. package/test/browser/editing.check.mjs +6 -5
  62. package/test/browser/section.check.mjs +21 -0
  63. package/test/clips.test.ts +49 -4
  64. package/test/fasten-form.test.ts +44 -0
  65. package/test/first-save.test.ts +11 -1
  66. package/test/pivot.test.ts +57 -0
  67. package/test/size-response.test.ts +10 -2
  68. package/test/stage-model.test.ts +23 -1
  69. package/translations/en.json +8 -0
  70. package/translations/es.json +8 -0
  71. package/translations/ja.json +8 -0
  72. package/translations/ko.json +8 -0
  73. package/translations/ms.json +8 -0
  74. package/translations/zh.json +8 -0
  75. package/tutorial/humanoid/README.md +22 -0
  76. package/tutorial/humanoid/chapters.mjs +247 -0
  77. package/tutorial/humanoid/encode.mjs +26 -0
  78. package/tutorial/humanoid/lib.mjs +197 -0
  79. package/tutorial/humanoid/record.mjs +65 -0
@@ -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
@@ -16,6 +16,7 @@ import { dataValueAt, domainOf, motionWrittenByData, sampledStatesOf, typedValue
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;
@@ -556,6 +579,7 @@ export class FigurePreview extends localize(i18next)(LitElement) {
556
579
  ${i18next.t(this.controlsFolded ? 'figure.button.controls-unfold' : 'figure.button.controls-fold')}
557
580
  </button>`
558
581
  if (this.controlsFolded) return html`<div clips folded>${fold}</div>`
582
+ const played = playedControlsOf(asset, this.driving, this.moving)
559
583
  return html`<div clips>
560
584
  ${fold}
561
585
  ${changed.length
@@ -580,6 +604,18 @@ export class FigurePreview extends localize(i18next)(LitElement) {
580
604
  /* The step the editor's controls take (\`controlsOf\`): a degree for an angle, a hundredth of the range otherwise. */
581
605
  const step = steps.get(control.id) ?? (control.max - control.min) / 100
582
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>`
583
619
  return html`<label>
584
620
  <span>${label} <span drive>${unitWord(control.unit)}</span></span>
585
621
  ${this.renderDial(control, value)}
@@ -628,9 +664,11 @@ export class FigurePreview extends localize(i18next)(LitElement) {
628
664
  ? nothing
629
665
  : html`<label>
630
666
  <span>${group.label} <span drive>${i18next.t('figure.label.clip')}</span></span>
631
- <select aria-label=${group.label} @change=${(e: Event) => this.move(group.id, { ...playing, clip: (e.target as HTMLSelectElement).value })}>
632
- ${group.clips.map(c => html`<option value=${c} ?selected=${c === playing.clip}>${c}</option>`)}
633
- </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>
634
672
  </label>`}
635
673
  ${byData.speed
636
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()))
@@ -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
+ }
@@ -13,6 +13,20 @@ function editOf(asset: FigureAsset, part: string, axis: Axis) {
13
13
  }
14
14
  export const measureFactor = (asset: FigureAsset, part: string, axis: Axis) => editOf(asset, part, axis)?.edit.k ?? 1
15
15
 
16
+ /**
17
+ * A size row's name: the axis, and 「지름」 for a measure set by its radius. A round body's one diameter across sets the
18
+ * axis it has no row of its own for as well, and says so -- a sphere read 「X · 지름」 and 「Y · 지름」, and a person
19
+ * looked for its Z (tutorial run 2026-11-12); now 「X·Z · 지름」 across and 「Y · 지름」 up.
20
+ */
21
+ export function sizeAxisWord(asset: FigureAsset, part: string, axis: Axis, diameter: string): string {
22
+ if (measureFactor(asset, part, axis) !== 2) return axis.toUpperCase()
23
+ const axes = followableAxes(asset, part)
24
+ const across = (['x', 'y', 'z'] as const).filter(a => !axes.includes(a))
25
+ const first = axes.find(a => measureFactor(asset, part, a) === 2)
26
+ const named = first === axis && across.length ? [axis, ...across].map(a => a.toUpperCase()).join('·') : axis.toUpperCase()
27
+ return `${named} · ${diameter}`
28
+ }
29
+
16
30
  /** Only expose direct own inputs (including the canonical identity wrapper). Never overwrite an arbitrary expression. */
17
31
  export function sizeInputOf(asset: FigureAsset, part: string, axis: Axis): string | undefined {
18
32
  const e = editOf(asset, part, axis)
@@ -183,20 +183,64 @@ export function boardFor(parts: readonly FigureRenderPart[], at: Board & { depth
183
183
  return {
184
184
  width: Math.max(at.width, Math.ceil(reach('x') * 1.2), 200),
185
185
  height: Math.max(at.height, Math.ceil(reach('z') * 1.2), 200),
186
- /* Only grows, as the plan does: a raised arm made the board taller than it was, and a new board lost the camera. */
187
- depth: Math.max(at.depth ?? 0, Math.ceil(size.y * 1.1), 200)
186
+ /*
187
+ Only grows, as the plan does: a raised arm made the board taller than it was, and a new board lost the camera. The
188
+ height is reached from the floor, as width and depth are from the middle: a pelvis set 880 mm up stood as tall as
189
+ its own 180 mm, and the view never followed it up (review 2026-11-11).
190
+ */
191
+ depth: Math.max(at.depth ?? 0, Math.ceil(Math.max(size.y, box.isEmpty() ? 0 : Math.max(Math.abs(box.max.y), Math.abs(box.min.y))) * 1.1), 200)
188
192
  }
189
193
  }
190
194
 
195
+ /** Where the figure's drawn parts reach, in mm: the box the view was last fitted round, or the one it has now. */
196
+ export interface DrawnSpan {
197
+ min: [number, number, number]
198
+ max: [number, number, number]
199
+ }
200
+
201
+ export function drawnSpanOf(parts: readonly FigureRenderPart[]): DrawnSpan | undefined {
202
+ const box = new THREE.Box3()
203
+ for (const part of parts) {
204
+ const frame = frameOf(part)
205
+ if (!frame) continue
206
+ const half = frame.extent.clone().multiplyScalar(0.5)
207
+ for (const x of [-1, 1]) for (const y of [-1, 1]) for (const z of [-1, 1])
208
+ box.expandByPoint(new THREE.Vector3(x * half.x, y * half.y, z * half.z).add(frame.centre).applyMatrix4(frame.rotation).add(frame.origin))
209
+ }
210
+ return box.isEmpty() ? undefined : { min: box.min.toArray() as [number, number, number], max: box.max.toArray() as [number, number, number] }
211
+ }
212
+
213
+ /**
214
+ * Whether the figure now reaches past the box the view was fitted round, on any side, by more than a tenth of that box
215
+ * (review 2026-11-11: a thigh hung under a pelvis ran off the bottom of the view; the board's size alone, measured from
216
+ * the middle and the floor, had not changed enough to say so).
217
+ */
218
+ export function outgrowsFit(fitted: DrawnSpan | undefined, now: DrawnSpan | undefined): boolean {
219
+ if (!fitted || !now) return false
220
+ return [0, 1, 2].some(i => {
221
+ const room = Math.max((fitted.max[i]! - fitted.min[i]!) * 0.1, 20)
222
+ return now.max[i]! > fitted.max[i]! + room || now.min[i]! < fitted.min[i]! - room
223
+ })
224
+ }
225
+
226
+ /** How far the figure may grow past its last fit before the view follows: inside the fit's own margin. */
227
+ export const REFIT_GROWN = 1.1
228
+
191
229
  /**
192
230
  * Whether the figure's reach has changed so far since the view was last fitted that the camera should fit it again:
193
- * more than twice, or less than half, on any axis (first 15 minutes, 2026-10-16: a 100 mm box typed to 3000 × 800 × 600
194
- * put the camera inside it, and the author saw grey until pressing 「화면에 맞추기」). A drag that nudges a part does not
195
- * move the camera.
231
+ * grown past `REFIT_GROWN`, or shrunk below half, on any axis (first 15 minutes, 2026-10-16: a 100 mm box typed to
232
+ * 3000 × 800 × 600 put the camera inside it, and the author saw grey until pressing 「화면에 맞추기」). A drag that nudges
233
+ * a part within the board's rounding does not move the camera.
196
234
  */
235
+
197
236
  export function needsRefit(fitted: (Board & { depth: number }) | undefined, now: Board & { depth: number }): boolean {
198
237
  if (!fitted) return false
199
- return (['width', 'height', 'depth'] as const).some(key => now[key] > fitted[key] * 2 || now[key] < fitted[key] / 2)
238
+ /*
239
+ Grown past the room the fit leaves round it (the fit's margin, 1.15) the figure runs off the view: a head added on a
240
+ body went out at the top, and the author pressed the fit after every part (review 2026-11-11). Shrunk, it is only
241
+ small in the view: fitted again at half.
242
+ */
243
+ return (['width', 'height', 'depth'] as const).some(key => now[key] > fitted[key] * REFIT_GROWN || now[key] < fitted[key] / 2)
200
244
  }
201
245
 
202
246
  /**
@@ -39,6 +39,7 @@ export declare class FigureCanvas extends LitElement {
39
39
  private board?;
40
40
  /** The figure's reach when the view was last fitted to it. */
41
41
  private fittedFor?;
42
+ private fittedSpan?;
42
43
  private refitTimer?;
43
44
  private standingFor?;
44
45
  private watcher?;
@@ -3,7 +3,7 @@ import { LitElement, html, css, unsafeCSS } from 'lit';
3
3
  import { customElement, property, query } from 'lit/decorators.js';
4
4
  import { create, Model, niceGridStep } from '@hatiolab/things-scene';
5
5
  import { DEFAULT_VIEW, sceneLook, WORKBENCH } from '@things-factory/figure-viewer/dist-client/figure-view.js';
6
- import { boardFor, changedKeys, componentOf, gizmoResultOf, needsRefit } from './stage-model.js';
6
+ import { boardFor, changedKeys, componentOf, drawnSpanOf, gizmoResultOf, needsRefit, outgrowsFit } from './stage-model.js';
7
7
  import { JOINT_FAN } from './joint-fan.js';
8
8
  import { SLOT_MARK, SLOT_MARK_CHOSEN } from './slot-layout-host.js';
9
9
  import { PREVIEW, TARGET_FACE } from './command-preview.js';
@@ -146,6 +146,7 @@ let FigureCanvas = class FigureCanvas extends LitElement {
146
146
  /** The whole figure in view: the board's fit, then the 3D camera's own. */
147
147
  fit() {
148
148
  this.fittedFor = boardFor(this.parts);
149
+ this.fittedSpan = drawnSpanOf(this.parts);
149
150
  this.scene?.fit?.('ratio');
150
151
  this.scene?.root?.fitCameraToModel?.();
151
152
  }
@@ -176,7 +177,7 @@ let FigureCanvas = class FigureCanvas extends LitElement {
176
177
  */
177
178
  clearTimeout(this.refitTimer);
178
179
  if (this.parts.length)
179
- this.refitTimer = setTimeout(() => needsRefit(this.fittedFor, boardFor(this.parts)) && this.fit(), REFIT_SETTLE_MS);
180
+ this.refitTimer = setTimeout(() => (needsRefit(this.fittedFor, boardFor(this.parts)) || outgrowsFit(this.fittedSpan, drawnSpanOf(this.parts))) && this.fit(), REFIT_SETTLE_MS);
180
181
  return;
181
182
  }
182
183
  this.teardown();