@things-factory/figure-ui 10.1.210 → 10.1.211

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.
@@ -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) {
@@ -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
  }
@@ -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'
@@ -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
  /**
@@ -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
@@ -270,6 +270,11 @@ export declare class FigureEditor extends LitElement {
270
270
  /** Runs a command; true when it was taken. */
271
271
  private linkCommand;
272
272
  private changeField;
273
+ /**
274
+ * 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
275
+ * fastening -- figure-model's one size function (ADR-0140 4.1). Gives the value the input holds after, refused or not.
276
+ */
277
+ private writeField;
273
278
  /** Typing or a held spinner: each number on the way is drawn; the field's change records it. */
274
279
  private typing;
275
280
  /**