@things-factory/figure-ui 10.1.210 → 10.1.212

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 (92) hide show
  1. package/client/graphql/index.ts +1 -1
  2. package/client/modeller/angle-dial.ts +1 -1
  3. package/client/modeller/canvas.ts +1 -1
  4. package/client/modeller/editor.ts +24 -32
  5. package/client/modeller/figure-history-panel.ts +1 -1
  6. package/client/modeller/figure-links.ts +61 -37
  7. package/client/modeller/figure-parts.ts +3 -3
  8. package/client/modeller/figure-settings.ts +1 -1
  9. package/client/modeller/figure-size-editor.ts +41 -4
  10. package/client/modeller/links.ts +63 -24
  11. package/client/modeller/live.ts +33 -0
  12. package/client/modeller/scene-budget.ts +3 -3
  13. package/client/modeller/thumbnail.ts +1 -1
  14. package/client/pages/figure-list-page.ts +2 -2
  15. package/client/pages/figure-modeller-page.ts +4 -4
  16. package/client/types.ts +2 -2
  17. package/client/viewparts/figure-thumb.ts +1 -1
  18. package/dist-client/graphql/index.d.ts +1 -1
  19. package/dist-client/graphql/index.js +1 -1
  20. package/dist-client/graphql/index.js.map +1 -1
  21. package/dist-client/modeller/angle-dial.d.ts +1 -1
  22. package/dist-client/modeller/angle-dial.js.map +1 -1
  23. package/dist-client/modeller/canvas.js +1 -1
  24. package/dist-client/modeller/canvas.js.map +1 -1
  25. package/dist-client/modeller/editor.d.ts +5 -0
  26. package/dist-client/modeller/editor.js +24 -32
  27. package/dist-client/modeller/editor.js.map +1 -1
  28. package/dist-client/modeller/figure-history-panel.d.ts +1 -1
  29. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  30. package/dist-client/modeller/figure-links.d.ts +2 -0
  31. package/dist-client/modeller/figure-links.js +66 -39
  32. package/dist-client/modeller/figure-links.js.map +1 -1
  33. package/dist-client/modeller/figure-parts.d.ts +3 -3
  34. package/dist-client/modeller/figure-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-settings.js +1 -1
  36. package/dist-client/modeller/figure-settings.js.map +1 -1
  37. package/dist-client/modeller/figure-size-editor.d.ts +3 -1
  38. package/dist-client/modeller/figure-size-editor.js +45 -4
  39. package/dist-client/modeller/figure-size-editor.js.map +1 -1
  40. package/dist-client/modeller/links.d.ts +15 -6
  41. package/dist-client/modeller/links.js +63 -25
  42. package/dist-client/modeller/links.js.map +1 -1
  43. package/dist-client/modeller/live.d.ts +14 -0
  44. package/dist-client/modeller/live.js +31 -0
  45. package/dist-client/modeller/live.js.map +1 -1
  46. package/dist-client/modeller/scene-budget.d.ts +3 -3
  47. package/dist-client/modeller/scene-budget.js +3 -3
  48. package/dist-client/modeller/scene-budget.js.map +1 -1
  49. package/dist-client/modeller/thumbnail.js +1 -1
  50. package/dist-client/modeller/thumbnail.js.map +1 -1
  51. package/dist-client/pages/figure-list-page.d.ts +1 -1
  52. package/dist-client/pages/figure-list-page.js +2 -2
  53. package/dist-client/pages/figure-list-page.js.map +1 -1
  54. package/dist-client/pages/figure-modeller-page.d.ts +3 -3
  55. package/dist-client/pages/figure-modeller-page.js +4 -4
  56. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  57. package/dist-client/route.d.ts +1 -1
  58. package/dist-client/tsconfig.tsbuildinfo +1 -1
  59. package/dist-client/types.d.ts +2 -2
  60. package/dist-client/types.js +1 -1
  61. package/dist-client/types.js.map +1 -1
  62. package/dist-client/viewparts/figure-thumb.js +1 -1
  63. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  64. package/docs/multipart-response-review-2026-10-24.md +1 -1
  65. package/docs/response-authoring-2026-10-24.md +4 -4
  66. package/package.json +6 -6
  67. package/test/ai-proposal.test.ts +1 -1
  68. package/test/align.test.ts +1 -1
  69. package/test/browser/editing.check.mjs +38 -0
  70. package/test/browser/modeller.check.mjs +55 -0
  71. package/test/command-preview.test.ts +3 -3
  72. package/test/fields.test.ts +2 -2
  73. package/test/first-minute.test.ts +5 -5
  74. package/test/first-save.test.ts +3 -3
  75. package/test/gizmo.test.ts +3 -3
  76. package/test/keys.test.ts +2 -2
  77. package/test/layout.test.ts +1 -1
  78. package/test/links.test.ts +53 -14
  79. package/test/live.test.ts +4 -4
  80. package/test/material.test.ts +1 -1
  81. package/test/properties.test.ts +1 -1
  82. package/test/proposal-record.test.ts +1 -1
  83. package/test/refusals.test.ts +1 -1
  84. package/test/resize-with-placement.test.ts +1 -1
  85. package/test/scene-budget.test.ts +4 -4
  86. package/test/verdict.test.ts +1 -1
  87. package/translations/en.json +3 -0
  88. package/translations/es.json +3 -0
  89. package/translations/ja.json +3 -0
  90. package/translations/ko.json +109 -106
  91. package/translations/ms.json +3 -0
  92. package/translations/zh.json +3 -0
@@ -399,7 +399,7 @@ export interface ProposalOutcome {
399
399
  }
400
400
 
401
401
  /**
402
- * 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 도형들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,
402
+ * 제안이 무엇이 됐나 -- 그 제안을 담은 저장이 들어간 피규어들. 비었으면 저장되지 않았다(적용만 하고 저장 전이거나, 되돌렸거나,
403
403
  * 적용하지 않았다). 도크의 「결과 확인」이 이것을 묻는다: 도크의 기억이 아니라 서버의 기록이다.
404
404
  */
405
405
  export async function fetchProposalOutcome(proposalKey: string): Promise<ProposalOutcome[]> {
@@ -13,7 +13,7 @@
13
13
  * 한 화면에 세운 상태). 미리보기는 크기 반응을 보려고 여덟 대를 나란히 놓는 자리라 한 대가
14
14
  * 20 픽셀쯤이고, 그 위의 어떤 표시도 몇 픽셀을 넘지 못한다.
15
15
  *
16
- * 각을 읽는 일은 3D 의 배율에서 떼어 놓는다. 눈은 막대 옆 24 픽셀 자리에 서고, 도형이 아무리
16
+ * 각을 읽는 일은 3D 의 배율에서 떼어 놓는다. 눈은 막대 옆 24 픽셀 자리에 서고, 피규어가 아무리
17
17
  * 작게 서 있어도 같은 크기로 읽힌다.
18
18
  */
19
19
 
@@ -121,7 +121,7 @@ export class FigureCanvas extends LitElement {
121
121
  render() {
122
122
  /*
123
123
  * No line of its own over an empty stage: a new figure's start card says how to begin, and an empty stage otherwise
124
- * is one loading, or with every part hidden -- where 「도형을 추가해…」 flashed and said the wrong thing (2026-10-02).
124
+ * is one loading, or with every part hidden -- where the empty stage's hint flashed and said the wrong thing (2026-10-02).
125
125
  */
126
126
  return html`<div stage></div>`
127
127
  }
@@ -69,7 +69,7 @@ import { jointFanOf } from './joint-fan.js'
69
69
  import { derivedOf, type FigureRevision } from './revision.js'
70
70
  import { jointDragOf } from './joint-drag.js'
71
71
  import type { FirstSave } from './first-save.js'
72
- import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './live.js'
72
+ import { isLive, livePartsOf, liveOverSession, NOTHING_LIVE, scrubLabel, type LiveValues } from './live.js'
73
73
  import { startingShapeKindOfBody } from './starting-shape.js'
74
74
  import { groupMenu, GroupMenuStyles } from './group-menu.js'
75
75
 
@@ -824,7 +824,9 @@ export class FigureEditor extends LitElement {
824
824
  ?locked=${unavailable}
825
825
  @command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
826
826
  @size-recipe=${(e: CustomEvent) => this.applySizeSteps(e.detail.steps)}
827
- @size-input=${(e: CustomEvent) => this.run(() => this.session!.apply(figureSizeEditOf(this.shown()!.asset, e.detail.part, [{ kind: 'set-design', input: e.detail.input, unit: 'mm', value: e.detail.value }], { keep: 'centre' }).changes))}
827
+ @size-input=${(e: CustomEvent) => this.writeField({ id: e.detail.input, unit: 'mm' }, e.detail.value)}
828
+ @size-typing=${(e: CustomEvent) => this.hold(this.heldOf({ id: e.detail.input, unit: 'mm' }, e.detail.value))}
829
+ @size-let-go=${() => (this.live = NOTHING_LIVE)}
828
830
  @fasten-open=${() => this.openFastening()}
829
831
  .previewWords=${this.commandPreview.words()}
830
832
  .detachWords=${this.commandPreview.detachWords(shown)}
@@ -1025,9 +1027,15 @@ export class FigureEditor extends LitElement {
1025
1027
  }
1026
1028
  }
1027
1029
  private changeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, event: Event) {
1028
- const input = event.target as HTMLInputElement,
1029
- value = input.valueAsNumber
1030
- /* A number typed keeps the part's middle, or its fastening -- figure-model's one size function (ADR-0140 4.1). */
1030
+ const input = event.target as HTMLInputElement
1031
+ // Rejected edits must also restore the displayed numeric value.
1032
+ input.value = String(this.writeField(field, input.valueAsNumber))
1033
+ }
1034
+ /**
1035
+ * A number written to an input, from a field, a scrub or the size editor's rows: a size keeps the part's middle, or its
1036
+ * fastening -- figure-model's one size function (ADR-0140 4.1). Gives the value the input holds after, refused or not.
1037
+ */
1038
+ private writeField(field: { id: string; unit: FigureInputChange['unit']; role?: string }, value: number): number {
1031
1039
  const part = this.selected
1032
1040
  const change: FigureInputChange = { kind: field.role === 'state' ? 'set-state' : 'set-design', input: field.id, unit: field.unit, value }
1033
1041
  const asset = this.shown()?.asset
@@ -1036,8 +1044,7 @@ export class FigureEditor extends LitElement {
1036
1044
  /* Typed past the mounting plane: said, with the way to fasten it there (ADR-0140 5). */
1037
1045
  const after = asset && part && field.role !== 'state' ? throughPlaneOf(this.shown()!.asset, drawableFigureParts(this.session!.read().geometry ?? []), part) : 0
1038
1046
  if (after > before + 0.5) this.tellThroughPlane(part, after)
1039
- // Rejected edits must also restore the displayed numeric value.
1040
- input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
1047
+ return this.session!.read().fields.find(i => i.id === field.id)!.value
1041
1048
  }
1042
1049
  /** Typing or a held spinner: each number on the way is drawn; the field's change records it. */
1043
1050
  private typing(field: { id: string; unit?: FigureInputChange['unit']; role?: string }, event: Event) {
@@ -1065,39 +1072,24 @@ export class FigureEditor extends LitElement {
1065
1072
  * pointer, and the release records one step. The field's number follows too, so the value in hand is always read.
1066
1073
  */
1067
1074
  private scrub(field: { id: string; unit: FigureInputChange['unit']; role?: string; value: number }, down: PointerEvent, unavailable: boolean) {
1068
- if (unavailable || down.button !== 0) return
1075
+ if (unavailable) return
1069
1076
  const label = down.currentTarget as HTMLElement
1070
1077
  const input = (label.closest('div[cell]')?.querySelector('input') ?? label.nextElementSibling) as HTMLInputElement | null
1071
1078
  if (!input) return
1072
- down.preventDefault()
1073
- label.setPointerCapture(down.pointerId)
1074
- const start = field.value
1075
- let value = start
1076
1079
  /* Prepared once for the scrub: each move is a size through the same function its release writes with. */
1077
1080
  const asset = this.shown()?.asset
1078
1081
  const sized = asset && this.selected && field.role !== 'state' ? (() => { try { return figureSizeEditorOf(asset, this.selected) } catch { return undefined } })() : undefined
1079
- const move = (e: PointerEvent) => {
1080
- value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey))
1081
- input.value = String(value)
1082
- this.hold(this.heldOf(field, value, sized))
1083
- }
1084
- const up = (e: PointerEvent) => {
1085
- label.removeEventListener('pointermove', move)
1086
- label.removeEventListener('pointerup', up)
1087
- label.removeEventListener('pointercancel', up)
1088
- /* A drag the system took away is not the author's value: the field goes back to where it was. */
1089
- if (e.type === 'pointercancel') value = start
1090
- if (value === start) {
1091
- input.value = String(start)
1082
+ scrubLabel(down, field, {
1083
+ move: value => {
1084
+ input.value = String(value)
1085
+ this.hold(this.heldOf(field, value, sized))
1086
+ },
1087
+ release: value => (input.value = String(this.writeField(field, value))),
1088
+ cancel: () => {
1089
+ input.value = String(field.value)
1092
1090
  this.live = NOTHING_LIVE
1093
- return
1094
1091
  }
1095
- input.value = String(value)
1096
- this.changeField(field, { target: input } as unknown as Event)
1097
- }
1098
- label.addEventListener('pointermove', move)
1099
- label.addEventListener('pointerup', up)
1100
- label.addEventListener('pointercancel', up)
1092
+ })
1101
1093
  }
1102
1094
  /** The sizes a board may give the figure: the ends the author typed, and the rest as far as its parts allow. */
1103
1095
  private renderInstanceRange(shown: FigureRevision | undefined, unavailable: boolean) {
@@ -223,7 +223,7 @@ export class FigureHistoryPanel extends localize(i18next)(LitElement) {
223
223
  `
224
224
  ]
225
225
 
226
- /** 지금 열려 있는 도형. 없으면(새로 만드는 중) 발행할 것이 없다. */
226
+ /** 지금 열려 있는 피규어. 없으면(새로 만드는 중) 발행할 것이 없다. */
227
227
  @property({ type: String }) figureId?: string
228
228
  @property({ type: String }) figureState?: 'draft' | 'released'
229
229
  @property({ type: Number }) figureVersion = 0
@@ -7,7 +7,7 @@ import './figure-size-editor.js'
7
7
  import { customElement, property, state } from 'lit/decorators.js'
8
8
 
9
9
  import { i18next, localize } from '@operato/i18n'
10
- import { figureFaceOverhangOf } from '@hatiolab/figure-model'
10
+ import { figureFaceOverhangOf, figureMotionsOf, motionIdOf } from '@hatiolab/figure-model'
11
11
  import type { FigureAsset, FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
12
12
 
13
13
  import {
@@ -28,6 +28,8 @@ import {
28
28
  motionFormRefusalOf,
29
29
  endPlaceKeyOf,
30
30
  motionJoint,
31
+ controlIdFor,
32
+ withoutAxis,
31
33
  placeFacesOf,
32
34
  placeIdFor,
33
35
  placesOn,
@@ -183,6 +185,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
183
185
  @state() private row = freshRow()
184
186
  /** The motion form is open over the part's own motion, to change it where it is. */
185
187
  @state() private editingMotion = false
188
+ /** Which of the joint's axes the form is over (ADR-0159); one past the last is a new axis. */
189
+ @state() private motionAxis = 0
186
190
 
187
191
  /*
188
192
  The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
@@ -204,6 +208,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
204
208
  this.rowOpen = false
205
209
  this.rowCandidate = undefined
206
210
  this.editingMotion = false
211
+ this.motionAxis = 0
207
212
  }
208
213
  }
209
214
 
@@ -623,6 +628,43 @@ export class FigureLinks extends localize(i18next)(LitElement) {
623
628
  /* Which part moves, against what, which way, how far -- by name (ADR-0097 §5.5). */
624
629
  const against = joint ? ('plane' in joint.body0 ? i18next.t('figure.label.mounting-plane') : joint.body0.part) : ''
625
630
  const ends = this.motionEnds
631
+ /* One row an axis (ADR-0159): how it moves and by what, changed or taken away where it is; more added under them. */
632
+ const count = joint ? figureMotionsOf(joint).length : 1
633
+ const edit = (k: number) => {
634
+ if (!joint) return
635
+ this.motionAxis = k
636
+ this.motion = motionFormOf(joint, k)
637
+ this.editingMotion = true
638
+ }
639
+ const row = (k: number) => {
640
+ const it = k === 0 ? now : motionOf(asset, this.partId, k)
641
+ if (!it) return nothing
642
+ return html`<div pair motion-axis=${k}>
643
+ <span>
644
+ ${count > 1 ? html`<b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b> ` : nothing}${i18next.t('figure.label.motion-' + it.kind)} · ${it.vector ? jointAxisWord(it.vector) : String(it.axis).toUpperCase()}
645
+ ${it.travel
646
+ ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: it.travel.part, axis: it.travel.axis.toUpperCase(), plus: it.travel.plus ?? 0 })}</small>`
647
+ : nothing}
648
+ ${it.follows?.sum.length === 1
649
+ ? html`<small>${i18next.t('figure.label.motion-follows', { joint: it.follows.sum[0]!.joint, multiplier: it.follows.sum[0]!.multiplier, offset: it.follows.offset })}</small>`
650
+ : it.follows
651
+ ? html`<small>${i18next.t('figure.label.motion-follow')}: ${it.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${it.follows.offset}</small>`
652
+ : it.control
653
+ ? html`<small>${it.control.id} (${it.control.unit}) ${it.control.min} … ${it.control.max}</small>`
654
+ : nothing}
655
+ </span>
656
+ ${count > 1
657
+ ? html`<button act remove-axis ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: withoutAxis(joint, k) })}>
658
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-axis')}
659
+ </button>`
660
+ : html`<button act ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}>
661
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
662
+ </button>`}
663
+ <button act ?disabled=${this.locked || !joint} @click=${() => edit(k)}>
664
+ <md-icon>edit</md-icon>${i18next.t('figure.button.change-motion')}
665
+ </button>
666
+ </div>`
667
+ }
626
668
  return html`<p motion-summary>
627
669
  ${i18next.t('figure.text.motion-summary', {
628
670
  part: this.partId,
@@ -634,39 +676,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
634
676
  ${ends
635
677
  ? html`<p hint motion-ends>${i18next.t('figure.text.motion-ends-shown', { min: String(ends.min), max: String(ends.max), unit: ends.unit })}</p>`
636
678
  : nothing}
679
+ ${Array.from({ length: count }, (_, k) => row(k))}
637
680
  <div pair>
638
- <span>
639
- ${i18next.t('figure.label.motion-' + now.kind)} · ${now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()}
640
- ${now.travel
641
- ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
681
+ <button act add-axis ?disabled=${this.locked || !joint} @click=${() => edit(count)}>
682
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-axis')}
683
+ </button>
684
+ ${count > 1
685
+ ? html`<button act ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}>
686
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
687
+ </button>`
642
688
  : nothing}
643
- ${now.follows?.sum.length === 1
644
- ? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.sum[0]!.joint, multiplier: now.follows.sum[0]!.multiplier, offset: now.follows.offset })}</small>`
645
- : now.follows
646
- ? html`<small>${i18next.t('figure.label.motion-follow')}: ${now.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${now.follows.offset}</small>`
647
- : now.control
648
- ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
649
- : nothing}
650
- </span>
651
- <button
652
- act
653
- ?disabled=${this.locked || !joint}
654
- @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}
655
- >
656
- <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
657
- </button>
658
- <button
659
- act
660
- ?disabled=${this.locked || !joint}
661
- @click=${() => {
662
- if (!joint) return
663
- this.motion = motionFormOf(joint)
664
- this.editingMotion = true
665
- }}
666
- >
667
- <md-icon>edit</md-icon>${i18next.t('figure.button.change-motion')}
668
- </button>
669
- </div>`
689
+ </div>`
670
690
  }
671
691
  /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
672
692
  if (!joint)
@@ -677,10 +697,13 @@ export class FigureLinks extends localize(i18next)(LitElement) {
677
697
  const m = this.motion
678
698
  const unit = m.kind === 'slide' ? 'mm' : 'deg'
679
699
  /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
680
- const leaders = leadersFor(asset, this.partId, m.kind)
700
+ const k = this.motionAxis
701
+ const leaders = leadersFor(asset, this.partId, m.kind, motionIdOf(joint, k))
702
+ const adding = k > 0 && k >= figureMotionsOf(joint).length
681
703
  const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
682
704
  const refusal = motionFormRefusalOf(m, follow)
683
705
  return html`
706
+ ${k > 0 ? html`<p motion-axis-heading><b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b></p>` : nothing}
684
707
  <div pair>
685
708
  <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
686
709
  <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
@@ -736,7 +759,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
736
759
  : nothing}`
737
760
  : nothing}
738
761
  <span>${i18next.t('figure.label.control-id')}</span>
739
- <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
762
+ <input .value=${m.id} placeholder=${controlIdFor(this.partId, m.kind, k)} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
740
763
  ${m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))
741
764
  ? nothing
742
765
  : html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
@@ -750,14 +773,15 @@ export class FigureLinks extends localize(i18next)(LitElement) {
750
773
  ?disabled=${this.locked || !!refusal}
751
774
  title=${refusal === 'range' ? i18next.t('figure.text.range-needs-room') : ''}
752
775
  @click=${() => {
753
- this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
776
+ this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId, k) })
754
777
  this.editingMotion = false
778
+ this.motionAxis = 0
755
779
  }}
756
780
  >
757
- <md-icon>animation</md-icon>${i18next.t(this.editingMotion ? 'figure.button.apply-motion-change' : 'figure.button.add-motion')}
781
+ <md-icon>animation</md-icon>${i18next.t(adding ? 'figure.button.add-axis' : this.editingMotion ? 'figure.button.apply-motion-change' : 'figure.button.add-motion')}
758
782
  </button>
759
783
  ${this.editingMotion
760
- ? html`<button act @click=${() => (this.editingMotion = false)}><md-icon>undo</md-icon>${i18next.t('figure.button.cancel-motion-change')}</button>`
784
+ ? html`<button act @click=${() => ((this.editingMotion = false), (this.motionAxis = 0))}><md-icon>undo</md-icon>${i18next.t('figure.button.cancel-motion-change')}</button>`
761
785
  : nothing}
762
786
  `
763
787
  }
@@ -10,11 +10,11 @@ import type { FigureBudgetLine } from './maturity.js'
10
10
  import { partLabelOf } from './fields.js'
11
11
 
12
12
  /**
13
- * 도형 팔레트와 부품 목록 — 자산의 것.
13
+ * 부품 팔레트와 부품 목록 — 자산의 것.
14
14
  *
15
15
  * 팔레트는 목록 위에, 눈 단추는 늘 자리를 차지하고, 조작은 고른 줄에서만 보인다.
16
16
  *
17
- * 부품의 도형은 평가가 준 형상에서 읽은 시작 모양 이름(`kind`)으로 보인다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
17
+ * 부품은 평가가 준 형상에서 읽은 시작 모양 이름(`kind`)으로 보인다. 「숨김」은 여기서도 **작업대의 것**이다 — 저장되지 않고, 자산의 `visibility`(상태에 따라
18
18
  * 보이고 안 보이는 것)와는 다른 것이다. 그것은 속성 패널의 몫이다.
19
19
  *
20
20
  * 이 컴포넌트는 정본을 만지지 않는다. 무엇을 하겠다는 것만 알린다.
@@ -475,7 +475,7 @@ export class FigureParts extends localize(i18next)(LitElement) {
475
475
  /** 편집할 수 없을 때(불러오는 중 · 저장 중) 팔레트와 조작을 잠근다. */
476
476
  @property({ type: Boolean }) locked = false
477
477
  /**
478
- * 이 도형이 치르는 값 — 삼각형 · 묶음 · 한 화면에 몇 대 · 등급. 부품을 더하는 바로 그 자리에서 보여야 로우폴리의
478
+ * 이 피규어가 치르는 값 — 삼각형 · 묶음 · 한 화면에 몇 대 · 등급. 부품을 더하는 바로 그 자리에서 보여야 로우폴리의
479
479
  * 기준이 저작을 이끈다. 등급이 D 면 무엇이 넘쳤는지를 적는다. 누르면 성숙도 탭이 열린다.
480
480
  */
481
481
  @property({ attribute: false }) budget?: FigureBudgetLine
@@ -166,7 +166,7 @@ export class FigureSettings extends localize(i18next)(LitElement) {
166
166
  있나」이기 때문이다. 미리보기가 도면에 놓인 모습을 말하는 자리이므로, 어떤 도면을
167
167
  가정하는지가 먼저 정해져야 그 다음이 뜻을 갖는다.
168
168
 
169
- 도형 탭의 「배치 기준」과 짝이지 같은 것이 아니다. 저쪽은 이 도형이 어느 면에
169
+ 피규어 탭의 「배치 기준」과 짝이지 같은 것이 아니다. 저쪽은 이 피규어가 어느 면에
170
170
  붙는가(저장된다), 이쪽은 그 면을 가진 도면이 어느 쪽을 기준으로 재는가(저장 안 된다).
171
171
  -->
172
172
  <section>
@@ -6,6 +6,7 @@ import type { FigureAuthoringAction } from '@hatiolab/figure-model'
6
6
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
7
7
  import type { FigureRevision } from './revision.js'
8
8
  import { AXES, followOf, followableAxes, measureNow, type Axis } from './links.js'
9
+ import { scrubLabel } from './live.js'
9
10
  import {
10
11
  measureFactor,
11
12
  sameSizeSource,
@@ -22,7 +23,8 @@ import {
22
23
  * A part's measures and what each follows, one row an axis (ADR-0159 3). Made to follow, a measure takes what the part
23
24
  * hangs from, along the axis its own axis lies on as it stands -- the author turns it on, nothing more. Another source
24
25
  * is there when asked for, or when there is no such axis (the part lies at an angle) or the measure already follows
25
- * something else; each way of following says what it will do, in millimetres.
26
+ * something else; each way of following says what it will do, in millimetres. A measure of the part's own is a value in
27
+ * hand as on any field (user 2026-09-24): drawn while it is typed or its label is dragged, written once on the release.
26
28
  */
27
29
  @customElement('figure-size-editor')
28
30
  export class FigureSizeEditor extends localize(i18next)(LitElement) {
@@ -50,6 +52,9 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
50
52
  .result { margin: 0 0 6px 30px; }
51
53
  [role=alert] { color: var(--md-sys-color-error); }
52
54
  `]
55
+ private tell(name: 'size-input' | 'size-typing' | 'size-let-go', detail?: object) {
56
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
57
+ }
53
58
  private apply(steps: FigureAuthoringAction[]) {
54
59
  if (this.locked) return
55
60
  this.error = ''
@@ -116,6 +121,38 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
116
121
  const choosing = relation.variable && (this.choosing.has(key) || !sameSizeSource(source, suggested))
117
122
  const selectedSource = source ? (source.of === 'instance' ? '@figure' : source.part) : '@custom'
118
123
  const sourceAxes = source?.of === 'part' ? followableAxes(asset, source.part) : AXES
124
+ const factor = measureFactor(asset, this.partId, axis)
125
+ /* Its own measure, no rule over it: in hand while typed or scrubbed, as the input it is (a diameter, its radius). */
126
+ const own = !relation.variable && !!input && !this.locked
127
+ const typingSize = (e: Event) => {
128
+ const value = (e.target as HTMLInputElement).valueAsNumber
129
+ if (own && Number.isFinite(value) && value > 0) this.tell('size-typing', { input, value: value / factor })
130
+ }
131
+ const scrubSize = (down: PointerEvent) => {
132
+ if (!own || mm === undefined) return
133
+ const field = (down.currentTarget as HTMLElement).closest('.fields')?.querySelector<HTMLInputElement>('input[type=number]')
134
+ if (!field) return
135
+ scrubLabel(down, { value: mm, unit: 'mm' }, {
136
+ move: value => {
137
+ field.value = String(value)
138
+ if (value > 0) this.tell('size-typing', { input, value: value / factor })
139
+ },
140
+ release: value => {
141
+ if (!(value > 0)) {
142
+ field.value = String(mm)
143
+ this.tell('size-let-go')
144
+ return
145
+ }
146
+ this.error = ''
147
+ this.tell('size-input', { input, value: value / factor, part: this.partId })
148
+ this.requestUpdate()
149
+ },
150
+ cancel: () => {
151
+ field.value = String(mm)
152
+ this.tell('size-let-go')
153
+ }
154
+ })
155
+ }
119
156
  const changeSize = (e: Event) => {
120
157
  const value = (e.target as HTMLInputElement).valueAsNumber
121
158
  if (!(Number.isFinite(value) && value > 0)) {
@@ -126,7 +163,7 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
126
163
  if (relation.variable && source && mode !== 'custom') this.configure(axis, source, mode, value)
127
164
  else if (!relation.variable && input) {
128
165
  this.error = ''
129
- this.dispatchEvent(new CustomEvent('size-input', { detail: { input, value: value / measureFactor(asset, this.partId, axis), part: this.partId }, bubbles: true, composed: true }))
166
+ this.tell('size-input', { input, value: value / factor, part: this.partId })
130
167
  this.requestUpdate()
131
168
  }
132
169
  }
@@ -145,9 +182,9 @@ export class FigureSizeEditor extends localize(i18next)(LitElement) {
145
182
  }
146
183
  return html`<div class="dimension" data-axis=${axis}>
147
184
  <div class="fields">
148
- <span>${label}</span>
185
+ <span ?scrub=${own} @pointerdown=${scrubSize}>${label}</span>
149
186
  <input type="number" step="any" min="0" aria-label=${`${this.partId} ${label} ${i18next.t('figure.label.size-measure')}`} .value=${live(mm === undefined ? '' : String(Number(mm.toFixed(6))))}
150
- ?disabled=${this.locked} ?readonly=${relation.variable ? mode === 'custom' || uniform : !input} @change=${changeSize}>
187
+ ?disabled=${this.locked} ?readonly=${relation.variable ? mode === 'custom' || uniform : !input} @input=${typingSize} @change=${changeSize}>
151
188
  <label class="variable"><input type="checkbox" aria-label=${`${this.partId} ${label} ${i18next.t('figure.label.size-follows')}`} .checked=${live(relation.variable || this.choosing.has(key))} ?disabled=${this.locked || uniform}
152
189
  @change=${follows}></label>
153
190
  ${choosing || (this.choosing.has(key) && !relation.variable)
@@ -1,4 +1,4 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeRangeOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf, figureAxesOf, figureMotionsOf } from '@hatiolab/figure-model'
1
+ import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeRangeOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf, figureAxesOf, figureMotionsOf, motionIdOf, motionStateIdOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureInstanceSizeRange } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint, FigureJointMotion, FigureNamedMotion, FigureSurfacePlace } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
@@ -152,7 +152,7 @@ export function partSummaryOf(
152
152
  if (f.of === 'instance') {
153
153
  key = f.times === 1 ? 'figure.text.summary-size-follows' : 'figure.text.summary-size-share'
154
154
  /* A measure following the figure's own size on its own axis says no measure word, so the axes that do so are one
155
- sentence -- 「너비 · 높이 · 깊이는 도형 크기를 따라 바뀝니다」, not three (user 2026-10-16). */
155
+ sentence -- 「너비 · 높이 · 깊이는 피규어 크기를 따라 바뀝니다」, not three (user 2026-10-16). */
156
156
  params = f.times === 1 && f.axis === axis && !f.plus ? {} : { pct: pct(f.times), whole: say(`figure.label.measure-${f.axis}`) }
157
157
  if (f.plus) (key = key + '-plus'), (params.plus = String(f.plus))
158
158
  } else if (f.of === 'part') {
@@ -531,16 +531,32 @@ export interface Motion {
531
531
  travel?: { part: string; axis: string; plus?: number }
532
532
  }
533
533
 
534
- export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
534
+ export function motionOf(asset: FigureAsset, part: string, k = 0): Motion | undefined {
535
535
  const m = modelOf(asset)
536
- const node = m.nodes.find(n => n.id === `${part}.motion`)
537
- if (!node) return undefined
538
536
  const joint = asset.joints?.find(j => j.body1.part === part)
539
- /* The motion the form shows is the joint's first axis, `<part>.motion` (ADR-0159). */
540
- const record = joint && figureMotionsOf(joint)[0]
537
+ const record = joint && figureMotionsOf(joint)[k]
541
538
  /* A follower has no limits of its own: its range is its leaders', not a turn without end. */
542
539
  const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined && !record.mimic
543
540
  const vector = jointAxisVectorOf(record?.axis)
541
+ const one = record?.travel && 'source' in record.travel ? record.travel : undefined
542
+ const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
543
+ const said = { ...(vector ? { vector } : {}), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
544
+ /*
545
+ An axis after the first is said as its record writes it -- the joint frame's axis, the control by its id -- the frame
546
+ the form edits it in (ADR-0159). The first is read from its node, `<part>.motion`, as before.
547
+ */
548
+ if (k > 0) {
549
+ if (!record) return undefined
550
+ const input = m.inputs.find(i => i.id === motionStateIdOf(record) && i.role === 'state')
551
+ return {
552
+ kind: endless ? 'continuous' : record.type === 'revolute' ? 'turn' : 'slide',
553
+ axis: jointAxisToken(record.axis) ?? '?',
554
+ ...(record.mimic || !input ? {} : { control: { id: input.id, unit: input.unit, min: input.min, max: input.max } }),
555
+ ...said
556
+ }
557
+ }
558
+ const node = m.nodes.find(n => n.id === `${part}.motion`)
559
+ if (!node) return undefined
544
560
  const kind = endless ? 'continuous' : node.op === 'axis-turn@1' ? 'turn' : 'slide'
545
561
  const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
546
562
  const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
@@ -559,9 +575,7 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
559
575
  }
560
576
  return undefined
561
577
  }
562
- const one = record?.travel && 'source' in record.travel ? record.travel : undefined
563
- const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
564
- return { kind, axis, ...(vector ? { vector } : {}), control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
578
+ return { kind, axis, control: control(quantity), ...said }
565
579
  }
566
580
 
567
581
  /** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
@@ -596,10 +610,16 @@ export interface MotionForm {
596
610
  * The motion form filled from a joint as it is, so a motion can be changed where it is -- its range, the length it
597
611
  * travels by, what it follows -- rather than taken away and made again. Taking it away is refused while another joint
598
612
  * follows it, which left a leader's stroke fixed for good (the telescopic conveyor, 2026-09-25). The form is the
599
- * joint's first axis (ADR-0159); the axes after it are kept as they are when it is written (`motionJoint`).
600
- */
601
- export function motionFormOf(joint: FigureJoint): MotionForm {
602
- const a = figureMotionsOf(joint)[0]
613
+ * joint's axis `k` (ADR-0159), the first unless said; the other axes are kept as they are when it is written
614
+ * (`motionJoint`). An axis past the last is a new one: a turn about the first of X, Y, Z the joint's axes do not use.
615
+ */
616
+ export function motionFormOf(joint: FigureJoint, k = 0): MotionForm {
617
+ const axes = figureMotionsOf(joint)
618
+ const a = axes[k]
619
+ if (!a && k > 0) {
620
+ const used = new Set(axes.map(x => jointAxisToken(x.axis)))
621
+ return { kind: 'turn', axis: AXES.find(x => !used.has(x)) ?? 'x', id: '', min: -90, max: 90, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 }
622
+ }
603
623
  const kind: MotionForm['kind'] = a?.type === 'prismatic' ? 'slide' : a?.lowerLimit === undefined && a?.upperLimit === undefined && !a?.mimic ? 'continuous' : 'turn'
604
624
  const one = a?.travel && 'source' in a.travel ? a.travel : undefined
605
625
  const travel = one?.source && 'part' in one.source ? `${one.source.part}:${one.source.axis}` : ''
@@ -629,9 +649,10 @@ export function motionFormOf(joint: FigureJoint): MotionForm {
629
649
  * The axes a part's joint can move with (URDF's mimic): one of another part's that moves the same way — a slide follows
630
650
  * a slide, a turn a turn — and has a control of its own, since a follower follows the axis that is driven (ADR-0159).
631
651
  */
632
- export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['kind']): Array<FigureNamedMotion & { part: string }> {
652
+ export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['kind'], self?: string): Array<FigureNamedMotion & { part: string }> {
653
+ /* Another axis of the same joint may lead (a shoulder's second axis following its first); never the axis itself. */
633
654
  return figureAxesOf(asset.joints)
634
- .filter(({ joint, motion }) => joint.body1.part !== part && !motion.mimic && (motion.type === 'prismatic') === (kind === 'slide'))
655
+ .filter(({ joint, motion }) => (self === undefined ? joint.body1.part !== part : motion.id !== self) && !motion.mimic && (motion.type === 'prismatic') === (kind === 'slide'))
635
656
  .map(({ joint, motion }) => ({ ...motion, part: joint.body1.part }))
636
657
  }
637
658
 
@@ -640,27 +661,45 @@ export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['k
640
661
  * a turn without end, as in USD) and a control; a follower gets the leader, the multiplier and the offset, and no
641
662
  * control or limits of its own — its range is the leader's times the multiplier.
642
663
  */
643
- export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): FigureJoint {
664
+ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string, k = 0): FigureJoint {
644
665
  /*
645
666
  Where it is fastened -- its row and its frame's own words too -- is the kernel's to keep (`figureJointWithMotion`,
646
- ADR-0140 D6). The form writes the first axis, under the name it had; the axes after it stay as they are (ADR-0159).
667
+ ADR-0140 D6). The form writes axis `k` under the name it had, or adds it past the last; the others stay as they are
668
+ (ADR-0159).
647
669
  */
648
- const was = joint.motion?.[0]
670
+ const was = joint.motion?.[k]
649
671
  const moving = {
650
672
  ...(was?.id !== undefined ? { id: was.id } : {}),
651
673
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
652
674
  axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJointMotion['axis'],
653
675
  ...(was?.through ? { through: was.through } : {})
654
676
  }
655
- const given = (motion: Omit<FigureJointMotion, 'type' | 'axis'>) => figureJointWithMotion(joint, [{ ...moving, ...motion }, ...(joint.motion?.slice(1) ?? [])])
677
+ const axes = joint.motion ?? []
678
+ const given = (motion: Omit<FigureJointMotion, 'type' | 'axis'>) => figureJointWithMotion(joint, [...axes.slice(0, k), { ...moving, ...motion }, ...axes.slice(k + 1)])
679
+ const control = m.id || controlIdFor(part, m.kind, k)
656
680
  if (m.follow) return given({ mimic: { motion: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } })
657
681
  if (m.followSum) return given({ mimic: { sum: m.followSum.map(t => ({ motion: t.joint, multiplier: t.multiplier })), ...(m.offset ? { offset: m.offset } : {}) } })
658
- if (m.kind === 'slide' && m.travelSum && !m.travel) return given({ travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } })
682
+ if (m.kind === 'slide' && m.travelSum && !m.travel) return given({ travel: m.travelSum, state: { id: control } })
659
683
  if (m.kind === 'slide' && m.travel) {
660
684
  const [of, axis] = m.travel.split(':')
661
- return given({ travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } })
685
+ return given({ travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: control } })
662
686
  }
663
- return given({ ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } })
687
+ return given({ ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: control } })
688
+ }
689
+
690
+ /** The control an axis gets when the author names none: `<part>-<kind>`, and `<part>-<kind>-<n>` for the n-th axis. */
691
+ export function controlIdFor(part: string, kind: MotionForm['kind'], k: number): string {
692
+ return k === 0 ? `${part}-${kind}` : `${part}-${kind}-${k + 1}`
693
+ }
694
+
695
+ /**
696
+ * The joint without its axis `k`. The axes after it keep the names they went by: one named by its place
697
+ * (`<joint>.<k>`, figure-model `motionIdOf`) is given that name before it moves up, so its control is not renamed under
698
+ * a board that writes to it.
699
+ */
700
+ export function withoutAxis(joint: FigureJoint, k: number): FigureJoint {
701
+ const axes = joint.motion ?? []
702
+ return figureJointWithMotion(joint, [...axes.slice(0, k), ...axes.slice(k + 1).map((a, i) => (a.id !== undefined ? a : { ...a, id: motionIdOf(joint, k + 1 + i) }))])
664
703
  }
665
704
 
666
705
  /**
@@ -677,7 +716,7 @@ export function motionFormRefusalOf(m: MotionForm, follow: string): 'multiplier'
677
716
 
678
717
  /** The select's value for what a dimension follows now. */
679
718
  /**
680
- * What a measure that is not its own follows, in the words of the choice that set it -- 「도형 X 크기에 비례」, 「bed의 X」 --
719
+ * What a measure that is not its own follows, in the words of the choice that set it -- 「피규어 X 크기에 비례」, 「bed의 X」 --
681
720
  * where the row once said only 「다른 규칙에 연결됨」 (user 2026-10-16: which rule?).
682
721
  */
683
722
  export function followWordsOf(follow: Follow, say: (key: string, params?: Record<string, string>) => string): string {
@@ -90,6 +90,39 @@ export function scrubbed(start: number, pixels: number, step: number): number {
90
90
  return Number(value.toFixed(digits))
91
91
  }
92
92
 
93
+ /**
94
+ * A label dragged sideways scrubs a value, as vector tools do: each move gives the value the pointer has come to, the
95
+ * release gives the last one, and a drag that ends where it began -- or that the system took away -- gives it back. The
96
+ * label holds the pointer for the drag. Every field that scrubs goes through here: the part's own numbers and the size
97
+ * editor's rows.
98
+ */
99
+ export function scrubLabel(
100
+ down: PointerEvent,
101
+ from: { value: number; unit: string },
102
+ to: { move: (value: number) => void; release: (value: number) => void; cancel: () => void }
103
+ ): void {
104
+ if (down.button !== 0) return
105
+ const label = down.currentTarget as HTMLElement
106
+ down.preventDefault()
107
+ label.setPointerCapture(down.pointerId)
108
+ let value = from.value
109
+ const move = (e: PointerEvent) => {
110
+ value = scrubbed(from.value, e.clientX - down.clientX, scrubStep(from.unit, from.value, e.shiftKey))
111
+ to.move(value)
112
+ }
113
+ const up = (e: PointerEvent) => {
114
+ label.removeEventListener('pointermove', move)
115
+ label.removeEventListener('pointerup', up)
116
+ label.removeEventListener('pointercancel', up)
117
+ /* A drag the system took away is not the author's value. */
118
+ if (e.type === 'pointercancel' || value === from.value) to.cancel()
119
+ else to.release(value)
120
+ }
121
+ label.addEventListener('pointermove', move)
122
+ label.addEventListener('pointerup', up)
123
+ label.addEventListener('pointercancel', up)
124
+ }
125
+
93
126
  /**
94
127
  * The bodies to draw while a value is in hand, over every input of the version as the session holds it now: the pose the
95
128
  * author has already set with the other controls, and the design values. Drawing over the asset's saved defaults put