@things-factory/figure-ui 10.1.208 → 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.
Files changed (92) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +97 -88
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +87 -157
  5. package/client/modeller/figure-size-editor.ts +233 -0
  6. package/client/modeller/joint-drag.ts +25 -21
  7. package/client/modeller/joint-fan.ts +4 -3
  8. package/client/modeller/links.ts +87 -36
  9. package/client/modeller/live.ts +33 -0
  10. package/client/modeller/motion-ends.ts +10 -6
  11. package/client/modeller/size-response.ts +95 -0
  12. package/dist-client/modeller/data-interface.d.ts +1 -1
  13. package/dist-client/modeller/data-interface.js +5 -5
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -1
  16. package/dist-client/modeller/editor.js +103 -89
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/fasten-geometry.js +1 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-links.d.ts +3 -4
  21. package/dist-client/modeller/figure-links.js +91 -145
  22. package/dist-client/modeller/figure-links.js.map +1 -1
  23. package/dist-client/modeller/figure-size-editor.d.ts +29 -0
  24. package/dist-client/modeller/figure-size-editor.js +262 -0
  25. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  26. package/dist-client/modeller/joint-drag.d.ts +1 -1
  27. package/dist-client/modeller/joint-drag.js +27 -22
  28. package/dist-client/modeller/joint-drag.js.map +1 -1
  29. package/dist-client/modeller/joint-fan.js +4 -3
  30. package/dist-client/modeller/joint-fan.js.map +1 -1
  31. package/dist-client/modeller/links.d.ts +21 -9
  32. package/dist-client/modeller/links.js +85 -35
  33. package/dist-client/modeller/links.js.map +1 -1
  34. package/dist-client/modeller/live.d.ts +14 -0
  35. package/dist-client/modeller/live.js +31 -0
  36. package/dist-client/modeller/live.js.map +1 -1
  37. package/dist-client/modeller/motion-ends.d.ts +4 -1
  38. package/dist-client/modeller/motion-ends.js +10 -6
  39. package/dist-client/modeller/motion-ends.js.map +1 -1
  40. package/dist-client/modeller/size-response.d.ts +29 -0
  41. package/dist-client/modeller/size-response.js +99 -0
  42. package/dist-client/modeller/size-response.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  45. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  46. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  47. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  48. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  49. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  50. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  51. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  52. package/docs/multipart-response-review-2026-10-24.md +77 -0
  53. package/docs/response-authoring-2026-10-24.md +46 -0
  54. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  55. package/package.json +7 -7
  56. package/test/attachment-size-contract.test.ts +47 -0
  57. package/test/browser/acceptance-page.js +1 -1
  58. package/test/browser/detail-page.js +1 -1
  59. package/test/browser/detail-tabs-page.js +1 -1
  60. package/test/browser/editing-page.js +1 -1
  61. package/test/browser/editing.check.mjs +66 -14
  62. package/test/browser/links.check.mjs +41 -22
  63. package/test/browser/modeller-page.js +1 -1
  64. package/test/browser/modeller-revision.check.mjs +21 -7
  65. package/test/browser/modeller.check.mjs +106 -39
  66. package/test/browser/multipart-response-page.js +8 -0
  67. package/test/browser/parts.check.mjs +2 -1
  68. package/test/browser/product-page.js +1 -1
  69. package/test/browser/props.check.mjs +17 -14
  70. package/test/browser/response-page.js +12 -0
  71. package/test/browser/row-preview.check.mjs +3 -3
  72. package/test/browser/screen.check.mjs +57 -13
  73. package/test/browser/serve-response.mjs +24 -0
  74. package/test/clip-ai-proposal.test.ts +1 -1
  75. package/test/clip-consent.test.ts +2 -2
  76. package/test/command-preview.test.ts +7 -7
  77. package/test/joint-drag.test.ts +6 -6
  78. package/test/joint-frames.test.ts +33 -32
  79. package/test/joints.ts +1 -6
  80. package/test/links.test.ts +60 -20
  81. package/test/multipart-response-fixture.js +17 -0
  82. package/test/multipart-response.test.ts +37 -0
  83. package/test/refusals.test.ts +2 -2
  84. package/test/resize-with-placement.test.ts +23 -1
  85. package/test/size-response.test.ts +49 -0
  86. package/test/size-source-pairing.test.ts +66 -0
  87. package/translations/en.json +47 -10
  88. package/translations/es.json +47 -10
  89. package/translations/ja.json +47 -10
  90. package/translations/ko.json +52 -15
  91. package/translations/ms.json +47 -10
  92. package/translations/zh.json +47 -10
@@ -0,0 +1,233 @@
1
+ import { LitElement, html, css, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { live } from 'lit/directives/live.js'
4
+ import { i18next, localize } from '@operato/i18n'
5
+ import type { FigureAuthoringAction } from '@hatiolab/figure-model'
6
+ import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
7
+ import type { FigureRevision } from './revision.js'
8
+ import { AXES, followOf, followableAxes, measureNow, type Axis } from './links.js'
9
+ import { scrubLabel } from './live.js'
10
+ import {
11
+ measureFactor,
12
+ sameSizeSource,
13
+ sizeInputOf,
14
+ sizeRelationOf,
15
+ sizeRelationSteps,
16
+ sourceMeasureOf,
17
+ suggestedSizeSource,
18
+ type SizeMode,
19
+ type SizeSource
20
+ } from './size-response.js'
21
+
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
24
+ * hangs from, along the axis its own axis lies on as it stands -- the author turns it on, nothing more. Another source
25
+ * is there when asked for, or when there is no such axis (the part lies at an angle) or the measure already follows
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.
28
+ */
29
+ @customElement('figure-size-editor')
30
+ export class FigureSizeEditor extends localize(i18next)(LitElement) {
31
+ @property({ attribute: false }) revision?: FigureRevision
32
+ @property() partId = ''
33
+ @property({ type: Boolean }) locked = false
34
+ @state() private error = ''
35
+ /* Rows whose source the author asked to choose: `<part>:<axis>`. */
36
+ @state() private choosing = new Set<string>()
37
+ private equivalentModes = new Map<string, 'ratio' | 'difference'>()
38
+ static styles = [SidePanelStyles, css`
39
+ :host { display: block; min-width: 0; }
40
+ .heading, .fields {
41
+ display: grid;
42
+ grid-template-columns: 24px minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
43
+ gap: 6px;
44
+ align-items: center;
45
+ }
46
+ .heading { color: var(--md-sys-color-on-surface-variant); padding-bottom: 6px; }
47
+ .dimension { padding: 3px 0; }
48
+ .variable { display: flex; align-items: center; justify-content: center; min-height: var(--figure-control-height); }
49
+ .source { grid-column: 4 / -1; display: flex; align-items: center; gap: 6px; min-width: 0; }
50
+ .source span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51
+ .modes { margin: 4px 0 2px 30px; }
52
+ .result { margin: 0 0 6px 30px; }
53
+ [role=alert] { color: var(--md-sys-color-error); }
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
+ }
58
+ private apply(steps: FigureAuthoringAction[]) {
59
+ if (this.locked) return
60
+ this.error = ''
61
+ this.dispatchEvent(new CustomEvent('size-recipe', { detail: { steps }, bubbles: true, composed: true }))
62
+ this.requestUpdate()
63
+ }
64
+ private configure(axis: Axis, source: SizeSource, mode: 'ratio' | 'difference', mm?: number) {
65
+ if (this.locked || this.revision?.asset.sizing === 'uniform') return
66
+ try {
67
+ this.apply(sizeRelationSteps(this.revision!, this.partId, axis, source, mode, mm))
68
+ this.equivalentModes.set(`${this.partId}:${axis}`, mode)
69
+ } catch (e) {
70
+ this.error = i18next.t((e as Error).message === 'SIZE_VALUE' ? 'figure.text.size-invalid' : 'figure.text.size-unavailable-source')
71
+ this.requestUpdate()
72
+ }
73
+ }
74
+ private choose(key: string, on: boolean) {
75
+ const next = new Set(this.choosing)
76
+ if (on) next.add(key)
77
+ else next.delete(key)
78
+ this.choosing = next
79
+ }
80
+ /** What a source is called on the panel: the figure, or the part, with the axis it is measured along. */
81
+ private sourceWords(source: SizeSource) {
82
+ return `${source.of === 'instance' ? i18next.t('figure.label.size-figure') : source.part} ${source.axis.toUpperCase()}`
83
+ }
84
+ /** What following this way does, in millimetres: the share it is of its source, or the difference it keeps. */
85
+ private resultWords(revision: FigureRevision, source: SizeSource, mode: 'ratio' | 'difference', mm: number | undefined) {
86
+ const theirs = sourceMeasureOf(revision, source)
87
+ if (mm === undefined || theirs === undefined || !(theirs > 0)) return nothing
88
+ const words = { source: this.sourceWords(source), size: Number(theirs.toFixed(1)) }
89
+ if (mode === 'ratio') return i18next.t('figure.text.size-ratio-result', { ...words, pct: Number(((mm / theirs) * 100).toFixed(1)) })
90
+ const gap = Number((theirs - mm).toFixed(1))
91
+ if (Math.abs(gap) < 0.05) return i18next.t('figure.text.size-difference-same', words)
92
+ return i18next.t(gap > 0 ? 'figure.text.size-difference-less' : 'figure.text.size-difference-more', { ...words, gap: Math.abs(gap) })
93
+ }
94
+ render() {
95
+ const revision = this.revision
96
+ if (!revision) return nothing
97
+ const asset = revision.asset
98
+ const axes = followableAxes(asset, this.partId)
99
+ const targets = asset.appearance.map(a => a.target).filter(p => p !== this.partId && followableAxes(asset, p).length)
100
+ const uniform = asset.sizing === 'uniform'
101
+ return html`
102
+ <div class="heading" aria-hidden="true">
103
+ <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>
104
+ </div>
105
+ ${axes.map(axis => {
106
+ const key = `${this.partId}:${axis}`
107
+ const mm = measureNow(revision, this.partId, axis)
108
+ const relation = sizeRelationOf(revision, this.partId, axis)
109
+ const f = followOf(asset, this.partId, axis)
110
+ const source = relation.source
111
+ const suggested = suggestedSizeSource(asset, this.partId, axis)
112
+ let mode: SizeMode = relation.mode
113
+ if (source && (f.of === 'instance' || f.of === 'part') && !f.plus) {
114
+ const same = (source.of === 'part' ? measureFactor(asset, source.part, source.axis) : 1) / measureFactor(asset, this.partId, axis)
115
+ if (Math.abs((f.times ?? 1) - same) < 1e-6) mode = this.equivalentModes.get(key) ?? mode
116
+ }
117
+ const currentMode = mode === 'custom' ? 'ratio' : mode
118
+ const input = sizeInputOf(asset, this.partId, axis)
119
+ const label = `${axis.toUpperCase()}${measureFactor(asset, this.partId, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
120
+ /* The selects show when asked for, or when what it follows is not what its fastening gives -- or nothing is. */
121
+ const choosing = relation.variable && (this.choosing.has(key) || !sameSizeSource(source, suggested))
122
+ const selectedSource = source ? (source.of === 'instance' ? '@figure' : source.part) : '@custom'
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
+ }
156
+ const changeSize = (e: Event) => {
157
+ const value = (e.target as HTMLInputElement).valueAsNumber
158
+ if (!(Number.isFinite(value) && value > 0)) {
159
+ this.error = i18next.t('figure.text.size-invalid')
160
+ this.requestUpdate()
161
+ return
162
+ }
163
+ if (relation.variable && source && mode !== 'custom') this.configure(axis, source, mode, value)
164
+ else if (!relation.variable && input) {
165
+ this.error = ''
166
+ this.tell('size-input', { input, value: value / factor, part: this.partId })
167
+ this.requestUpdate()
168
+ }
169
+ }
170
+ const follows = (e: Event) => {
171
+ if ((e.target as HTMLInputElement).checked) {
172
+ if (suggested) this.configure(axis, suggested, 'ratio')
173
+ else {
174
+ /* At an angle to what it hangs from: which of its measures it follows is the author's to pick. */
175
+ this.choose(key, true)
176
+ this.error = i18next.t('figure.text.size-unpaired')
177
+ }
178
+ } else if (mm !== undefined) {
179
+ this.choose(key, false)
180
+ this.apply([{ kind: 'unlink-dimension', part: this.partId, axis, mm: mm / measureFactor(asset, this.partId, axis) }])
181
+ }
182
+ }
183
+ return html`<div class="dimension" data-axis=${axis}>
184
+ <div class="fields">
185
+ <span ?scrub=${own} @pointerdown=${scrubSize}>${label}</span>
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))))}
187
+ ?disabled=${this.locked} ?readonly=${relation.variable ? mode === 'custom' || uniform : !input} @input=${typingSize} @change=${changeSize}>
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}
189
+ @change=${follows}></label>
190
+ ${choosing || (this.choosing.has(key) && !relation.variable)
191
+ ? html`
192
+ <select aria-label=${`${this.partId} ${label} ${i18next.t('figure.label.size-source')}`} .value=${live(selectedSource)} ?disabled=${this.locked || uniform}
193
+ @change=${(e: Event) => {
194
+ const target = (e.target as HTMLSelectElement).value
195
+ const possible = target === '@figure' ? AXES : followableAxes(asset, target)
196
+ const targetAxis = possible.includes(source?.axis ?? axis) ? (source?.axis ?? axis) : possible[0]!
197
+ this.configure(axis, target === '@figure' ? { of: 'instance', axis: targetAxis } : { of: 'part', part: target, axis: targetAxis }, currentMode)
198
+ }}>
199
+ ${!source ? html`<option value="@custom" disabled>${i18next.t(relation.variable ? 'figure.label.size-existing-rule' : 'figure.label.size-pick-source')}</option>` : nothing}
200
+ <option value="@figure" ?selected=${selectedSource === '@figure'}>${i18next.t('figure.label.size-figure')}</option>
201
+ ${targets.map(p => html`<option value=${p} ?selected=${selectedSource === p}>${p}</option>`)}
202
+ </select>
203
+ <select aria-label=${`${this.partId} ${label} ${i18next.t('figure.label.size-source-axis')}`} .value=${live(source?.axis ?? axis)} ?disabled=${this.locked || uniform || !source}
204
+ @change=${(e: Event) => source && this.configure(axis, { ...source, axis: (e.target as HTMLSelectElement).value as Axis }, currentMode)}>
205
+ ${sourceAxes.map(a => html`<option value=${a} ?selected=${a === (source?.axis ?? axis)}>${a.toUpperCase()}</option>`)}
206
+ </select>`
207
+ : relation.variable && source
208
+ ? html`<div class="source">
209
+ <span>${this.sourceWords(source)}</span>
210
+ <button quiet ?disabled=${this.locked || uniform} @click=${() => this.choose(key, true)}>${i18next.t('figure.button.size-other-source')}</button>
211
+ </div>`
212
+ : html`<span></span><span></span>`}
213
+ </div>
214
+ ${relation.variable
215
+ ? html`<div class="modes" choice-row>
216
+ ${(['ratio', 'difference'] as const).map(
217
+ m => html`<label toggle><input type="radio" name=${`mode-${this.partId}-${axis}`} .checked=${live(mode === m)} ?disabled=${this.locked || uniform || !source}
218
+ @change=${() => source && this.configure(axis, source, m)}>${i18next.t(`figure.label.size-${m}`)}</label>`
219
+ )}
220
+ </div>
221
+ ${mode === 'custom'
222
+ ? html`<p hint>${i18next.t('figure.text.size-custom-rule')}</p>`
223
+ : source
224
+ ? html`<p hint class="result">${this.resultWords(revision, source, currentMode, mm)}</p>`
225
+ : nothing}`
226
+ : nothing}
227
+ </div>`
228
+ })}
229
+ ${uniform ? html`<p hint>${i18next.t('figure.text.size-uniform')}</p>` : nothing}
230
+ ${this.error ? html`<p hint role="alert">${this.error}</p>` : nothing}
231
+ `
232
+ }
233
+ }
@@ -1,5 +1,5 @@
1
1
  import * as THREE from 'three'
2
- import { compileFigureAsset, figureMimicTermsOf } from '@hatiolab/figure-model'
2
+ import { compileFigureAsset, figureMimicTermsOf, figureMotionsOf, motionStateIdOf, type FigureNamedMotion } from '@hatiolab/figure-model'
3
3
  import type { FigureAsset, FigureJoint } from '@hatiolab/figure-model'
4
4
 
5
5
  import type { GizmoResult } from './gizmo.js'
@@ -22,7 +22,7 @@ import type { FigureRenderPart } from './renderer.js'
22
22
  export type JointDrag =
23
23
  | { kind: 'value'; input: string; unit: 'deg' | 'mm' | 'ratio'; value: number }
24
24
  | { kind: 'joint'; joint: FigureJoint }
25
- | { kind: 'refused'; reason: 'mimic' | 'fixed-turn' | 'origin-follows' | 'turned-slide' | 'off-axis'; joint: string; leader?: string; axis?: string }
25
+ | { kind: 'refused'; reason: 'mimic' | 'fixed-turn' | 'origin-follows' | 'turned-slide' | 'off-axis' | 'several-axes'; joint: string; leader?: string; axis?: string }
26
26
 
27
27
  type Axis = 'x' | 'y' | 'z'
28
28
  const AXES: Axis[] = ['x', 'y', 'z']
@@ -42,11 +42,11 @@ const eulerMatrixOrder = (deg: number[], order: THREE.EulerOrder) => new THREE.M
42
42
  /** The part's own pose on its end, any angle, as the record says it (`origin.childRotate`, Rz·Ry·Rx, ADR-0140 3.11). */
43
43
  const turnMatrix = (j: FigureJoint) => eulerMatrixOrder(AXES.map(a => j.origin?.childRotate?.[a] ?? 0), 'ZYX')
44
44
 
45
- /** The joint frame's rotation in the figure: the part's rotation now, less its own turn and less the joint's. */
46
- function jointFrameRotation(j: FigureJoint, part: FigureRenderPart, angle: number): THREE.Matrix4 {
45
+ /** The joint frame's rotation in the figure: the part's rotation now, less its own turn and less its one axis's. */
46
+ function jointFrameRotation(j: FigureJoint, a: FigureNamedMotion | undefined, part: FigureRenderPart, angle: number): THREE.Matrix4 {
47
47
  const now = matrixOf(part.pose.r as number[][])
48
48
  const own = turnMatrix(j)
49
- const turn = j.type === 'revolute' ? new THREE.Matrix4().makeRotationAxis(unit(j.axis!), THREE.MathUtils.degToRad(angle)) : new THREE.Matrix4()
49
+ const turn = a?.type === 'revolute' ? new THREE.Matrix4().makeRotationAxis(unit(a.axis), THREE.MathUtils.degToRad(angle)) : new THREE.Matrix4()
50
50
  return now.multiply(own.invert()).multiply(turn.invert())
51
51
  }
52
52
 
@@ -54,47 +54,51 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
54
54
  const j = asset.joints?.find(joint => joint.body1.part === id)
55
55
  /* A size is the part's own, not the joint's: a scale drag goes to the part's measures (the editor drops the shift). */
56
56
  if (!j || result.scale || (!result.rotation && !result.position)) return null
57
- const stateId = j.state?.id ?? j.id
57
+ /* A drag moves one axis, or the joint's place: a joint of several axes (ADR-0159) is moved by its controls. */
58
+ const axes = figureMotionsOf(j)
59
+ if (axes.length > 1) return { kind: 'refused', reason: 'several-axes', joint: j.id }
60
+ const a = axes[0]
61
+ const stateId = a ? motionStateIdOf(a) : j.id
58
62
  const now = values[stateId] ?? asset.stateDefaults[stateId] ?? 0
59
- const turns = j.type === 'revolute'
60
- /* A revolute joint without limits turns without end (USD). */
61
- const endless = turns && j.lowerLimit === undefined && j.upperLimit === undefined
63
+ const turns = a?.type === 'revolute'
64
+ /* A revolute axis without limits turns without end (USD). */
65
+ const endless = turns && a.lowerLimit === undefined && a.upperLimit === undefined
62
66
 
63
67
  if (result.rotation) {
64
- if (!turns) return { kind: 'refused', reason: j.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
65
- if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
68
+ if (!turns) return { kind: 'refused', reason: a?.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
69
+ if (a.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(a.mimic)!.sum.map(t => t.joint).join(', ') }
66
70
  /* The turn about the joint's axis (the twist of the change): the only turn the joint can make. */
67
71
  const before = new THREE.Quaternion().setFromRotationMatrix(matrixOf(part.pose.r as number[][]))
68
72
  const after = new THREE.Quaternion().setFromRotationMatrix(eulerMatrix(result.rotation))
69
73
  const change = after.multiply(before.invert())
70
- const axis = unit(j.axis!).applyMatrix4(jointFrameRotation(j, part, now)).normalize()
74
+ const axis = unit(a.axis).applyMatrix4(jointFrameRotation(j, a, part, now)).normalize()
71
75
  const twist = 2 * Math.atan2(change.x * axis.x + change.y * axis.y + change.z * axis.z, change.w)
72
76
  /* Turned about another axis: the joint cannot make that turn, and nothing is written for it. */
73
77
  const whole = 2 * Math.acos(Math.min(1, Math.abs(change.w)))
74
- if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: jointAxisWord(j.axis) }
78
+ if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: jointAxisWord(a.axis) }
75
79
  let value = now + THREE.MathUtils.radToDeg(twist)
76
80
  if (endless) value = ((((value + 180) % 360) + 360) % 360) - 180
77
- else value = Math.min(Math.max(value, j.lowerLimit!), j.upperLimit!)
81
+ else value = Math.min(Math.max(value, a.lowerLimit!), a.upperLimit!)
78
82
  return { kind: 'value', input: stateId, unit: 'deg', value: round(value, 0.1) }
79
83
  }
80
84
 
81
85
  const moved = new THREE.Vector3(...result.position!).sub(new THREE.Vector3(...(part.pose.t as [number, number, number])))
82
86
  if (moved.length() < 0.5) return null
83
- const frame = jointFrameRotation(j, part, turns ? now : 0)
87
+ const frame = jointFrameRotation(j, a, part, turns ? now : 0)
84
88
 
85
- if (j.type === 'prismatic') {
86
- if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
89
+ if (a?.type === 'prismatic') {
90
+ if (a.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(a.mimic)!.sum.map(t => t.joint).join(', ') }
87
91
  /* How far one unit of the value moves the part along its axis, measured on the figure itself (a ratio travels a length). */
88
- const along = unit(j.axis!).applyMatrix4(frame).normalize()
92
+ const along = unit(a.axis).applyMatrix4(frame).normalize()
89
93
  const at = (v: number) => {
90
94
  const g = compileFigureAsset(asset).evaluate({ ...values, [stateId]: v }).geometry.find(g => g.id === id && 'pose' in g) as { pose: { t: number[] } } | undefined
91
95
  return g ? new THREE.Vector3(...(g.pose.t as [number, number, number])).dot(along) : 0
92
96
  }
93
- const step = j.travel ? 0.01 : 1
97
+ const step = a.travel ? 0.01 : 1
94
98
  const rate = (at(now + step) - at(now)) / step
95
99
  if (Math.abs(rate) > 1e-9 && Math.abs(moved.dot(along)) >= moved.length() * 0.7) {
96
- const value = Math.min(Math.max(now + moved.dot(along) / rate, j.lowerLimit!), j.upperLimit!)
97
- return { kind: 'value', input: stateId, unit: j.travel ? 'ratio' : 'mm', value: round(value, j.travel ? 0.001 : 1) }
100
+ const value = Math.min(Math.max(now + moved.dot(along) / rate, a.lowerLimit!), a.upperLimit!)
101
+ return { kind: 'value', input: stateId, unit: a.travel ? 'ratio' : 'mm', value: round(value, a.travel ? 0.001 : 1) }
98
102
  }
99
103
  }
100
104
 
@@ -98,10 +98,11 @@ function turnOf(asset: FigureAsset, part: string, values: Record<string, unknown
98
98
  about the joint's axis. A part that does not turn itself shows the joint that sits on it, when there is one: the
99
99
  elbow block shows the elbow (user, 2026-09-24: 「elbow-housing itself is a axis」).
100
100
  */
101
- const turning = (asset.joints ?? []).filter(j => j.type === 'revolute')
101
+ /* A joint's fan is its first axis's (ADR-0159): `<child>.motion`. */
102
+ const turning = (asset.joints ?? []).filter(j => j.motion?.[0]?.type === 'revolute')
102
103
  const record = turning.find(j => j.body1.part === part) ?? (() => {
103
104
  const on = turning.filter(j => 'part' in j.body0 && j.body0.part === part)
104
- return on.length === 1 && !(asset.joints ?? []).some(j => j.body1.part === part && j.type !== 'fixed') ? on[0] : undefined
105
+ return on.length === 1 && !(asset.joints ?? []).some(j => j.body1.part === part && j.motion?.length) ? on[0] : undefined
105
106
  })()
106
107
  if (record) {
107
108
  const child = record.body1.part
@@ -113,7 +114,7 @@ function turnOf(asset: FigureAsset, part: string, values: Record<string, unknown
113
114
  /* In the child's own frame: the joint is where its pose puts the seat's origin, the axis turned back by its pose. */
114
115
  const back = (v: number[]) => [0, 1, 2].map(i => pose.r[0]![i]! * v[0]! + pose.r[1]![i]! * v[1]! + pose.r[2]![i]! * v[2]!)
115
116
  const pivot = back(pose.t.map(v => -v))
116
- const axis = norm(back(jointAxisUnit(record.axis) ?? [0, 0, 0]))
117
+ const axis = norm(back(jointAxisUnit(record.motion![0]!.axis) ?? [0, 0, 0]))
117
118
  if (!axis) return null
118
119
  return { carrier, pivot, axis, toward: [0, 0, 0], control, turnedPart: child }
119
120
  }
@@ -1,6 +1,6 @@
1
- import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeRangeOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf, figureFastenKeptPlaceOf, figureFastenAxesOf, figureJointAsFixed, figureJointWithMotion, figureSurfaceOf } 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
- import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureFacing, FigureJoint, FigureSurfacePlace } from '@hatiolab/figure-model'
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'
5
5
  import { boxOf } from './align.js'
6
6
  import { graphValuesOf, type FigureRevision } from './revision.js'
@@ -531,14 +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
- const record = asset.joints?.find(j => j.body1.part === part)
536
+ const joint = asset.joints?.find(j => j.body1.part === part)
537
+ const record = joint && figureMotionsOf(joint)[k]
539
538
  /* A follower has no limits of its own: its range is its leaders', not a turn without end. */
540
539
  const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined && !record.mimic
541
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
542
560
  const kind = endless ? 'continuous' : node.op === 'axis-turn@1' ? 'turn' : 'slide'
543
561
  const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
544
562
  const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
@@ -557,9 +575,7 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
557
575
  }
558
576
  return undefined
559
577
  }
560
- const one = record?.travel && 'source' in record.travel ? record.travel : undefined
561
- const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
562
- return { kind, axis, ...(vector ? { vector } : {}), control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
578
+ return { kind, axis, control: control(quantity), ...said }
563
579
  }
564
580
 
565
581
  /** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
@@ -587,30 +603,38 @@ export interface MotionForm {
587
603
  * a range or an axis does not turn a follower into a joint of its own or drop its stroke.
588
604
  */
589
605
  followSum?: Array<{ joint: string; multiplier: number }>
590
- travelSum?: Extract<NonNullable<FigureJoint['travel']>, { sum: unknown }>
606
+ travelSum?: Extract<NonNullable<FigureJointMotion['travel']>, { sum: unknown }>
591
607
  }
592
608
 
593
609
  /**
594
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
595
611
  * travels by, what it follows -- rather than taken away and made again. Taking it away is refused while another joint
596
- * follows it, which left a leader's stroke fixed for good (the telescopic conveyor, 2026-09-25).
597
- */
598
- export function motionFormOf(joint: FigureJoint): MotionForm {
599
- const kind: MotionForm['kind'] = joint.type === 'prismatic' ? 'slide' : joint.lowerLimit === undefined && joint.upperLimit === undefined && !joint.mimic ? 'continuous' : 'turn'
600
- const one = joint.travel && 'source' in joint.travel ? joint.travel : undefined
612
+ * follows it, which left a leader's stroke fixed for good (the telescopic conveyor, 2026-09-25). The form is the
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
+ }
623
+ const kind: MotionForm['kind'] = a?.type === 'prismatic' ? 'slide' : a?.lowerLimit === undefined && a?.upperLimit === undefined && !a?.mimic ? 'continuous' : 'turn'
624
+ const one = a?.travel && 'source' in a.travel ? a.travel : undefined
601
625
  const travel = one?.source && 'part' in one.source ? `${one.source.part}:${one.source.axis}` : ''
602
- const travelSum = joint.travel && 'sum' in joint.travel ? joint.travel : undefined
603
- const terms = figureMimicTermsOf(joint.mimic)
626
+ const travelSum = a?.travel && 'sum' in a.travel ? a.travel : undefined
627
+ const terms = figureMimicTermsOf(a?.mimic)
604
628
  const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
605
- const axisVector = jointAxisVectorOf(joint.axis)
629
+ const axisVector = jointAxisVectorOf(a?.axis)
606
630
  return {
607
631
  kind,
608
- axis: jointAxisToken(joint.axis) ?? 'x',
632
+ axis: jointAxisToken(a?.axis) ?? 'x',
609
633
  ...(axisVector ? { axisVector } : {}),
610
- /* A moving joint with no state record is driven by the input named after it (figure-model `stateIdOf`): keep that name, so an edit does not rename the control a board or a controller writes to. */
611
- id: joint.state?.id ?? (joint.type !== 'fixed' && !joint.mimic ? joint.id : ''),
612
- min: travel || travelSum ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
613
- max: travel || travelSum ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
634
+ /* An axis with no state record is driven by the input named after it (figure-model `motionStateIdOf`): keep that name, so an edit does not rename the control a board or a controller writes to. */
635
+ id: a?.state?.id ?? (a && !a.mimic ? a.id : ''),
636
+ min: travel || travelSum ? 0 : (a?.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
637
+ max: travel || travelSum ? 500 : (a?.upperLimit ?? (kind === 'slide' ? 500 : 90)),
614
638
  follow: single?.joint ?? '',
615
639
  multiplier: single?.multiplier ?? 1,
616
640
  offset: terms?.offset ?? 0,
@@ -622,11 +646,14 @@ export function motionFormOf(joint: FigureJoint): MotionForm {
622
646
  }
623
647
 
624
648
  /**
625
- * The joints a part's joint can move with (URDF's mimic): a joint that moves the same way — a slide follows a slide, a
626
- * turn a turn — and has a control of its own, since a follower follows the joint that is driven.
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
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).
627
651
  */
628
- export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['kind']): FigureJoint[] {
629
- return (asset.joints ?? []).filter(j => j.body1.part !== part && j.type !== 'fixed' && !j.mimic && (j.type === 'prismatic') === (kind === 'slide'))
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. */
654
+ return figureAxesOf(asset.joints)
655
+ .filter(({ joint, motion }) => (self === undefined ? joint.body1.part !== part : motion.id !== self) && !motion.mimic && (motion.type === 'prismatic') === (kind === 'slide'))
656
+ .map(({ joint, motion }) => ({ ...motion, part: joint.body1.part }))
630
657
  }
631
658
 
632
659
  /**
@@ -634,21 +661,45 @@ export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['k
634
661
  * a turn without end, as in USD) and a control; a follower gets the leader, the multiplier and the offset, and no
635
662
  * control or limits of its own — its range is the leader's times the multiplier.
636
663
  */
637
- export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): FigureJoint {
638
- /* Where it is fastened -- its row and its frame's own words too -- is the kernel's to keep (`figureJointWithMotion`, ADR-0140 D6). */
664
+ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string, k = 0): FigureJoint {
665
+ /*
666
+ Where it is fastened -- its row and its frame's own words too -- is the kernel's to keep (`figureJointWithMotion`,
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).
669
+ */
670
+ const was = joint.motion?.[k]
639
671
  const moving = {
672
+ ...(was?.id !== undefined ? { id: was.id } : {}),
640
673
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
641
- axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJoint['axis']
674
+ axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJointMotion['axis'],
675
+ ...(was?.through ? { through: was.through } : {})
642
676
  }
643
- const given = (motion: Omit<Parameters<typeof figureJointWithMotion>[1], 'type' | 'axis'>) => figureJointWithMotion(joint, { ...moving, ...motion })
644
- if (m.follow) return given({ mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } })
645
- if (m.followSum) return given({ mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } })
646
- if (m.kind === 'slide' && m.travelSum && !m.travel) return given({ travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } })
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)
680
+ if (m.follow) return given({ mimic: { motion: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } })
681
+ if (m.followSum) return given({ mimic: { sum: m.followSum.map(t => ({ motion: t.joint, multiplier: t.multiplier })), ...(m.offset ? { offset: m.offset } : {}) } })
682
+ if (m.kind === 'slide' && m.travelSum && !m.travel) return given({ travel: m.travelSum, state: { id: control } })
647
683
  if (m.kind === 'slide' && m.travel) {
648
684
  const [of, axis] = m.travel.split(':')
649
- 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 } })
650
686
  }
651
- 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) }))])
652
703
  }
653
704
 
654
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
@@ -1,4 +1,4 @@
1
- import { compileFigureAsset } from '@hatiolab/figure-model'
1
+ import { compileFigureAsset, figureMotionsOf, motionStateIdOf } from '@hatiolab/figure-model'
2
2
 
3
3
  import { PREVIEW, TARGET_FACE } from './command-preview.js'
4
4
  import type { FigureRenderPart } from './renderer.js'
@@ -28,13 +28,17 @@ export interface MotionEnds {
28
28
 
29
29
  const samePose = (a: Pose, b: Pose) => [0, 1, 2].every(i => Math.abs(a.t[i]! - b.t[i]!) < 1e-6 && [0, 1, 2].every(k => Math.abs(a.r[i]![k]! - b.r[i]![k]!) < 1e-9))
30
30
 
31
- /** The ends of the selected part's own motion, or nothing: a part that does not move, follows another, or turns without end. */
31
+ /**
32
+ * The ends of the selected part's own motion -- its joint's first axis (ADR-0159) -- or nothing: a part that does not
33
+ * move, follows another, or turns without end.
34
+ */
32
35
  export function motionEndsOf(revision: FigureRevision, part: string): MotionEnds | null {
33
36
  const asset = revision.asset
34
37
  const joint = asset.joints?.find(j => j.body1.part === part)
35
- /* A turn without end is a revolute joint whose control has no range: the range check below leaves it out. */
36
- if (!joint || joint.type === 'fixed' || joint.mimic) return null
37
- const control = joint.state?.id ?? joint.id
38
+ const axis = joint && figureMotionsOf(joint)[0]
39
+ /* A turn without end is a revolute axis whose control has no range: the range check below leaves it out. */
40
+ if (!axis || axis.mimic) return null
41
+ const control = motionStateIdOf(axis)
38
42
  const input = asset.document.model.inputs.find(i => i.id === control && i.role === 'state')
39
43
  if (!input || typeof input.min !== 'number' || typeof input.max !== 'number' || input.min === input.max) return null
40
44
  /* The version's design values are its asset's; its pose is every control as set now, this one at each end. */
@@ -58,7 +62,7 @@ export function motionEndsOf(revision: FigureRevision, part: string): MotionEnds
58
62
  ]
59
63
  /* A slide: the line its own body travels, from the lowest value to the highest. */
60
64
  const own = moving.find(b => b.id === part)
61
- if (joint.type === 'prismatic' && own) {
65
+ if (axis.type === 'prismatic' && own) {
62
66
  const from = own.pose.t
63
67
  const to = byId.get(part)!.pose.t
64
68
  const v = [0, 1, 2].map(i => to[i]! - from[i]!)