@things-factory/figure-ui 10.1.207 → 10.1.210

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 (91) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +74 -57
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +26 -120
  5. package/client/modeller/figure-size-editor.ts +196 -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 +36 -24
  9. package/client/modeller/motion-ends.ts +10 -6
  10. package/client/modeller/size-response.ts +95 -0
  11. package/dist-client/modeller/data-interface.d.ts +1 -1
  12. package/dist-client/modeller/data-interface.js +5 -5
  13. package/dist-client/modeller/data-interface.js.map +1 -1
  14. package/dist-client/modeller/editor.d.ts +9 -1
  15. package/dist-client/modeller/editor.js +80 -58
  16. package/dist-client/modeller/editor.js.map +1 -1
  17. package/dist-client/modeller/fasten-geometry.js +1 -1
  18. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  19. package/dist-client/modeller/figure-links.d.ts +1 -4
  20. package/dist-client/modeller/figure-links.js +26 -107
  21. package/dist-client/modeller/figure-links.js.map +1 -1
  22. package/dist-client/modeller/figure-size-editor.d.ts +27 -0
  23. package/dist-client/modeller/figure-size-editor.js +221 -0
  24. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  25. package/dist-client/modeller/joint-drag.d.ts +1 -1
  26. package/dist-client/modeller/joint-drag.js +27 -22
  27. package/dist-client/modeller/joint-drag.js.map +1 -1
  28. package/dist-client/modeller/joint-fan.js +4 -3
  29. package/dist-client/modeller/joint-fan.js.map +1 -1
  30. package/dist-client/modeller/links.d.ts +9 -6
  31. package/dist-client/modeller/links.js +33 -21
  32. package/dist-client/modeller/links.js.map +1 -1
  33. package/dist-client/modeller/motion-ends.d.ts +4 -1
  34. package/dist-client/modeller/motion-ends.js +10 -6
  35. package/dist-client/modeller/motion-ends.js.map +1 -1
  36. package/dist-client/modeller/size-response.d.ts +29 -0
  37. package/dist-client/modeller/size-response.js +99 -0
  38. package/dist-client/modeller/size-response.js.map +1 -0
  39. package/dist-client/route.d.ts +1 -1
  40. package/dist-client/tsconfig.tsbuildinfo +1 -1
  41. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  42. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  43. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  44. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  45. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  46. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  47. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  48. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  49. package/docs/multipart-response-review-2026-10-24.md +77 -0
  50. package/docs/response-authoring-2026-10-24.md +46 -0
  51. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  52. package/package.json +8 -8
  53. package/test/attachment-size-contract.test.ts +47 -0
  54. package/test/browser/acceptance-page.js +1 -1
  55. package/test/browser/detail-page.js +1 -1
  56. package/test/browser/detail-tabs-page.js +1 -1
  57. package/test/browser/editing-page.js +1 -1
  58. package/test/browser/editing.check.mjs +28 -14
  59. package/test/browser/links.check.mjs +41 -22
  60. package/test/browser/modeller-page.js +1 -1
  61. package/test/browser/modeller-revision.check.mjs +21 -7
  62. package/test/browser/modeller.check.mjs +51 -39
  63. package/test/browser/multipart-response-page.js +8 -0
  64. package/test/browser/parts.check.mjs +2 -1
  65. package/test/browser/product-page.js +1 -1
  66. package/test/browser/props.check.mjs +17 -14
  67. package/test/browser/response-page.js +12 -0
  68. package/test/browser/row-preview.check.mjs +3 -3
  69. package/test/browser/screen.check.mjs +57 -13
  70. package/test/browser/serve-response.mjs +24 -0
  71. package/test/clip-ai-proposal.test.ts +1 -1
  72. package/test/clip-consent.test.ts +2 -2
  73. package/test/command-preview.test.ts +7 -7
  74. package/test/harness-serve.test.ts +1 -1
  75. package/test/heading-scale-guard.test.ts +3 -3
  76. package/test/joint-drag.test.ts +6 -6
  77. package/test/joint-frames.test.ts +33 -32
  78. package/test/joints.ts +1 -6
  79. package/test/links.test.ts +21 -20
  80. package/test/multipart-response-fixture.js +17 -0
  81. package/test/multipart-response.test.ts +37 -0
  82. package/test/refusals.test.ts +2 -2
  83. package/test/resize-with-placement.test.ts +23 -1
  84. package/test/size-response.test.ts +49 -0
  85. package/test/size-source-pairing.test.ts +66 -0
  86. package/translations/en.json +44 -10
  87. package/translations/es.json +44 -10
  88. package/translations/ja.json +44 -10
  89. package/translations/ko.json +58 -24
  90. package/translations/ms.json +44 -10
  91. package/translations/zh.json +44 -10
@@ -0,0 +1,196 @@
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 {
10
+ measureFactor,
11
+ sameSizeSource,
12
+ sizeInputOf,
13
+ sizeRelationOf,
14
+ sizeRelationSteps,
15
+ sourceMeasureOf,
16
+ suggestedSizeSource,
17
+ type SizeMode,
18
+ type SizeSource
19
+ } from './size-response.js'
20
+
21
+ /**
22
+ * 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
+ * hangs from, along the axis its own axis lies on as it stands -- the author turns it on, nothing more. Another source
24
+ * 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
+ */
27
+ @customElement('figure-size-editor')
28
+ export class FigureSizeEditor extends localize(i18next)(LitElement) {
29
+ @property({ attribute: false }) revision?: FigureRevision
30
+ @property() partId = ''
31
+ @property({ type: Boolean }) locked = false
32
+ @state() private error = ''
33
+ /* Rows whose source the author asked to choose: `<part>:<axis>`. */
34
+ @state() private choosing = new Set<string>()
35
+ private equivalentModes = new Map<string, 'ratio' | 'difference'>()
36
+ static styles = [SidePanelStyles, css`
37
+ :host { display: block; min-width: 0; }
38
+ .heading, .fields {
39
+ display: grid;
40
+ grid-template-columns: 24px minmax(55px, .85fr) 32px minmax(65px, 1fr) 42px;
41
+ gap: 6px;
42
+ align-items: center;
43
+ }
44
+ .heading { color: var(--md-sys-color-on-surface-variant); padding-bottom: 6px; }
45
+ .dimension { padding: 3px 0; }
46
+ .variable { display: flex; align-items: center; justify-content: center; min-height: var(--figure-control-height); }
47
+ .source { grid-column: 4 / -1; display: flex; align-items: center; gap: 6px; min-width: 0; }
48
+ .source span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
49
+ .modes { margin: 4px 0 2px 30px; }
50
+ .result { margin: 0 0 6px 30px; }
51
+ [role=alert] { color: var(--md-sys-color-error); }
52
+ `]
53
+ private apply(steps: FigureAuthoringAction[]) {
54
+ if (this.locked) return
55
+ this.error = ''
56
+ this.dispatchEvent(new CustomEvent('size-recipe', { detail: { steps }, bubbles: true, composed: true }))
57
+ this.requestUpdate()
58
+ }
59
+ private configure(axis: Axis, source: SizeSource, mode: 'ratio' | 'difference', mm?: number) {
60
+ if (this.locked || this.revision?.asset.sizing === 'uniform') return
61
+ try {
62
+ this.apply(sizeRelationSteps(this.revision!, this.partId, axis, source, mode, mm))
63
+ this.equivalentModes.set(`${this.partId}:${axis}`, mode)
64
+ } catch (e) {
65
+ this.error = i18next.t((e as Error).message === 'SIZE_VALUE' ? 'figure.text.size-invalid' : 'figure.text.size-unavailable-source')
66
+ this.requestUpdate()
67
+ }
68
+ }
69
+ private choose(key: string, on: boolean) {
70
+ const next = new Set(this.choosing)
71
+ if (on) next.add(key)
72
+ else next.delete(key)
73
+ this.choosing = next
74
+ }
75
+ /** What a source is called on the panel: the figure, or the part, with the axis it is measured along. */
76
+ private sourceWords(source: SizeSource) {
77
+ return `${source.of === 'instance' ? i18next.t('figure.label.size-figure') : source.part} ${source.axis.toUpperCase()}`
78
+ }
79
+ /** What following this way does, in millimetres: the share it is of its source, or the difference it keeps. */
80
+ private resultWords(revision: FigureRevision, source: SizeSource, mode: 'ratio' | 'difference', mm: number | undefined) {
81
+ const theirs = sourceMeasureOf(revision, source)
82
+ if (mm === undefined || theirs === undefined || !(theirs > 0)) return nothing
83
+ const words = { source: this.sourceWords(source), size: Number(theirs.toFixed(1)) }
84
+ if (mode === 'ratio') return i18next.t('figure.text.size-ratio-result', { ...words, pct: Number(((mm / theirs) * 100).toFixed(1)) })
85
+ const gap = Number((theirs - mm).toFixed(1))
86
+ if (Math.abs(gap) < 0.05) return i18next.t('figure.text.size-difference-same', words)
87
+ return i18next.t(gap > 0 ? 'figure.text.size-difference-less' : 'figure.text.size-difference-more', { ...words, gap: Math.abs(gap) })
88
+ }
89
+ render() {
90
+ const revision = this.revision
91
+ if (!revision) return nothing
92
+ const asset = revision.asset
93
+ const axes = followableAxes(asset, this.partId)
94
+ const targets = asset.appearance.map(a => a.target).filter(p => p !== this.partId && followableAxes(asset, p).length)
95
+ const uniform = asset.sizing === 'uniform'
96
+ return html`
97
+ <div class="heading" aria-hidden="true">
98
+ <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>
99
+ </div>
100
+ ${axes.map(axis => {
101
+ const key = `${this.partId}:${axis}`
102
+ const mm = measureNow(revision, this.partId, axis)
103
+ const relation = sizeRelationOf(revision, this.partId, axis)
104
+ const f = followOf(asset, this.partId, axis)
105
+ const source = relation.source
106
+ const suggested = suggestedSizeSource(asset, this.partId, axis)
107
+ let mode: SizeMode = relation.mode
108
+ if (source && (f.of === 'instance' || f.of === 'part') && !f.plus) {
109
+ const same = (source.of === 'part' ? measureFactor(asset, source.part, source.axis) : 1) / measureFactor(asset, this.partId, axis)
110
+ if (Math.abs((f.times ?? 1) - same) < 1e-6) mode = this.equivalentModes.get(key) ?? mode
111
+ }
112
+ const currentMode = mode === 'custom' ? 'ratio' : mode
113
+ const input = sizeInputOf(asset, this.partId, axis)
114
+ const label = `${axis.toUpperCase()}${measureFactor(asset, this.partId, axis) === 2 ? ' · ' + i18next.t('figure.label.size-diameter') : ''}`
115
+ /* The selects show when asked for, or when what it follows is not what its fastening gives -- or nothing is. */
116
+ const choosing = relation.variable && (this.choosing.has(key) || !sameSizeSource(source, suggested))
117
+ const selectedSource = source ? (source.of === 'instance' ? '@figure' : source.part) : '@custom'
118
+ const sourceAxes = source?.of === 'part' ? followableAxes(asset, source.part) : AXES
119
+ const changeSize = (e: Event) => {
120
+ const value = (e.target as HTMLInputElement).valueAsNumber
121
+ if (!(Number.isFinite(value) && value > 0)) {
122
+ this.error = i18next.t('figure.text.size-invalid')
123
+ this.requestUpdate()
124
+ return
125
+ }
126
+ if (relation.variable && source && mode !== 'custom') this.configure(axis, source, mode, value)
127
+ else if (!relation.variable && input) {
128
+ 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 }))
130
+ this.requestUpdate()
131
+ }
132
+ }
133
+ const follows = (e: Event) => {
134
+ if ((e.target as HTMLInputElement).checked) {
135
+ if (suggested) this.configure(axis, suggested, 'ratio')
136
+ else {
137
+ /* At an angle to what it hangs from: which of its measures it follows is the author's to pick. */
138
+ this.choose(key, true)
139
+ this.error = i18next.t('figure.text.size-unpaired')
140
+ }
141
+ } else if (mm !== undefined) {
142
+ this.choose(key, false)
143
+ this.apply([{ kind: 'unlink-dimension', part: this.partId, axis, mm: mm / measureFactor(asset, this.partId, axis) }])
144
+ }
145
+ }
146
+ return html`<div class="dimension" data-axis=${axis}>
147
+ <div class="fields">
148
+ <span>${label}</span>
149
+ <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}>
151
+ <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
+ @change=${follows}></label>
153
+ ${choosing || (this.choosing.has(key) && !relation.variable)
154
+ ? html`
155
+ <select aria-label=${`${this.partId} ${label} ${i18next.t('figure.label.size-source')}`} .value=${live(selectedSource)} ?disabled=${this.locked || uniform}
156
+ @change=${(e: Event) => {
157
+ const target = (e.target as HTMLSelectElement).value
158
+ const possible = target === '@figure' ? AXES : followableAxes(asset, target)
159
+ const targetAxis = possible.includes(source?.axis ?? axis) ? (source?.axis ?? axis) : possible[0]!
160
+ this.configure(axis, target === '@figure' ? { of: 'instance', axis: targetAxis } : { of: 'part', part: target, axis: targetAxis }, currentMode)
161
+ }}>
162
+ ${!source ? html`<option value="@custom" disabled>${i18next.t(relation.variable ? 'figure.label.size-existing-rule' : 'figure.label.size-pick-source')}</option>` : nothing}
163
+ <option value="@figure" ?selected=${selectedSource === '@figure'}>${i18next.t('figure.label.size-figure')}</option>
164
+ ${targets.map(p => html`<option value=${p} ?selected=${selectedSource === p}>${p}</option>`)}
165
+ </select>
166
+ <select aria-label=${`${this.partId} ${label} ${i18next.t('figure.label.size-source-axis')}`} .value=${live(source?.axis ?? axis)} ?disabled=${this.locked || uniform || !source}
167
+ @change=${(e: Event) => source && this.configure(axis, { ...source, axis: (e.target as HTMLSelectElement).value as Axis }, currentMode)}>
168
+ ${sourceAxes.map(a => html`<option value=${a} ?selected=${a === (source?.axis ?? axis)}>${a.toUpperCase()}</option>`)}
169
+ </select>`
170
+ : relation.variable && source
171
+ ? html`<div class="source">
172
+ <span>${this.sourceWords(source)}</span>
173
+ <button quiet ?disabled=${this.locked || uniform} @click=${() => this.choose(key, true)}>${i18next.t('figure.button.size-other-source')}</button>
174
+ </div>`
175
+ : html`<span></span><span></span>`}
176
+ </div>
177
+ ${relation.variable
178
+ ? html`<div class="modes" choice-row>
179
+ ${(['ratio', 'difference'] as const).map(
180
+ m => html`<label toggle><input type="radio" name=${`mode-${this.partId}-${axis}`} .checked=${live(mode === m)} ?disabled=${this.locked || uniform || !source}
181
+ @change=${() => source && this.configure(axis, source, m)}>${i18next.t(`figure.label.size-${m}`)}</label>`
182
+ )}
183
+ </div>
184
+ ${mode === 'custom'
185
+ ? html`<p hint>${i18next.t('figure.text.size-custom-rule')}</p>`
186
+ : source
187
+ ? html`<p hint class="result">${this.resultWords(revision, source, currentMode, mm)}</p>`
188
+ : nothing}`
189
+ : nothing}
190
+ </div>`
191
+ })}
192
+ ${uniform ? html`<p hint>${i18next.t('figure.text.size-uniform')}</p>` : nothing}
193
+ ${this.error ? html`<p hint role="alert">${this.error}</p>` : nothing}
194
+ `
195
+ }
196
+ }
@@ -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 } 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'
@@ -535,7 +535,9 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
535
535
  const m = modelOf(asset)
536
536
  const node = m.nodes.find(n => n.id === `${part}.motion`)
537
537
  if (!node) return undefined
538
- const record = asset.joints?.find(j => j.body1.part === part)
538
+ 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]
539
541
  /* A follower has no limits of its own: its range is its leaders', not a turn without end. */
540
542
  const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined && !record.mimic
541
543
  const vector = jointAxisVectorOf(record?.axis)
@@ -587,30 +589,32 @@ export interface MotionForm {
587
589
  * a range or an axis does not turn a follower into a joint of its own or drop its stroke.
588
590
  */
589
591
  followSum?: Array<{ joint: string; multiplier: number }>
590
- travelSum?: Extract<NonNullable<FigureJoint['travel']>, { sum: unknown }>
592
+ travelSum?: Extract<NonNullable<FigureJointMotion['travel']>, { sum: unknown }>
591
593
  }
592
594
 
593
595
  /**
594
596
  * 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
597
  * 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).
598
+ * 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`).
597
600
  */
598
601
  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
602
+ const a = figureMotionsOf(joint)[0]
603
+ const kind: MotionForm['kind'] = a?.type === 'prismatic' ? 'slide' : a?.lowerLimit === undefined && a?.upperLimit === undefined && !a?.mimic ? 'continuous' : 'turn'
604
+ const one = a?.travel && 'source' in a.travel ? a.travel : undefined
601
605
  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)
606
+ const travelSum = a?.travel && 'sum' in a.travel ? a.travel : undefined
607
+ const terms = figureMimicTermsOf(a?.mimic)
604
608
  const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
605
- const axisVector = jointAxisVectorOf(joint.axis)
609
+ const axisVector = jointAxisVectorOf(a?.axis)
606
610
  return {
607
611
  kind,
608
- axis: jointAxisToken(joint.axis) ?? 'x',
612
+ axis: jointAxisToken(a?.axis) ?? 'x',
609
613
  ...(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)),
614
+ /* 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. */
615
+ id: a?.state?.id ?? (a && !a.mimic ? a.id : ''),
616
+ min: travel || travelSum ? 0 : (a?.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
617
+ max: travel || travelSum ? 500 : (a?.upperLimit ?? (kind === 'slide' ? 500 : 90)),
614
618
  follow: single?.joint ?? '',
615
619
  multiplier: single?.multiplier ?? 1,
616
620
  offset: terms?.offset ?? 0,
@@ -622,11 +626,13 @@ export function motionFormOf(joint: FigureJoint): MotionForm {
622
626
  }
623
627
 
624
628
  /**
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.
629
+ * 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
+ * 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
631
  */
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'))
632
+ export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['kind']): Array<FigureNamedMotion & { part: string }> {
633
+ return figureAxesOf(asset.joints)
634
+ .filter(({ joint, motion }) => joint.body1.part !== part && !motion.mimic && (motion.type === 'prismatic') === (kind === 'slide'))
635
+ .map(({ joint, motion }) => ({ ...motion, part: joint.body1.part }))
630
636
  }
631
637
 
632
638
  /**
@@ -635,14 +641,20 @@ export function leadersFor(asset: FigureAsset, part: string, kind: MotionForm['k
635
641
  * control or limits of its own — its range is the leader's times the multiplier.
636
642
  */
637
643
  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). */
644
+ /*
645
+ 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).
647
+ */
648
+ const was = joint.motion?.[0]
639
649
  const moving = {
650
+ ...(was?.id !== undefined ? { id: was.id } : {}),
640
651
  type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
641
- axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJoint['axis']
652
+ axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJointMotion['axis'],
653
+ ...(was?.through ? { through: was.through } : {})
642
654
  }
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 } : {}) } })
655
+ const given = (motion: Omit<FigureJointMotion, 'type' | 'axis'>) => figureJointWithMotion(joint, [{ ...moving, ...motion }, ...(joint.motion?.slice(1) ?? [])])
656
+ if (m.follow) return given({ mimic: { motion: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } })
657
+ if (m.followSum) return given({ mimic: { sum: m.followSum.map(t => ({ motion: t.joint, multiplier: t.multiplier })), ...(m.offset ? { offset: m.offset } : {}) } })
646
658
  if (m.kind === 'slide' && m.travelSum && !m.travel) return given({ travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } })
647
659
  if (m.kind === 'slide' && m.travel) {
648
660
  const [of, axis] = m.travel.split(':')
@@ -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]!)
@@ -0,0 +1,95 @@
1
+ import { figureAxisPairsOf, figurePartsExtentOf, figureShapeProviderOf } from '@hatiolab/figure-model'
2
+ import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource } from '@hatiolab/figure-model'
3
+ import type { FigureRevision } from './revision.js'
4
+ import { dimensionNow, followOf, followableAxes, measureNow, type Axis } from './links.js'
5
+
6
+ export type SizeMode = 'ratio' | 'difference' | 'custom'
7
+ export type SizeSource = Extract<FigureDimensionSource, { of: 'instance' | 'part' }>
8
+
9
+ function editOf(asset: FigureAsset, part: string, axis: Axis) {
10
+ const shape = asset.document.model.nodes.find(n => n.id === `${part}.shape`)
11
+ const edit = shape && figureShapeProviderOf(shape)?.edit[axis]
12
+ return shape && edit && !('refused' in edit) ? { shape, edit } : undefined
13
+ }
14
+ export const measureFactor = (asset: FigureAsset, part: string, axis: Axis) => editOf(asset, part, axis)?.edit.k ?? 1
15
+
16
+ /** Only expose direct own inputs (including the canonical identity wrapper). Never overwrite an arbitrary expression. */
17
+ export function sizeInputOf(asset: FigureAsset, part: string, axis: Axis): string | undefined {
18
+ const e = editOf(asset, part, axis)
19
+ if (!e) return undefined
20
+ const model = asset.document.model
21
+ let ref = e.shape.args[e.edit.arg]
22
+ const seen = new Set<string>()
23
+ while (ref && !seen.has(ref)) {
24
+ seen.add(ref)
25
+ if (model.inputs.some(i => i.id === ref && i.role === 'design' && i.unit === 'mm') && ref.startsWith(`${part}.`)) return ref
26
+ const writer = model.nodes.find(n => Object.values(n.outputs).includes(ref!))
27
+ if (writer?.op !== 'mul@1' || model.constants.find(c => c.id === writer.args[1])?.value !== 1) return undefined
28
+ ref = writer.args[0]
29
+ }
30
+ }
31
+ export function sizeRelationOf(revision: FigureRevision, part: string, axis: Axis): { source?: SizeSource; mode: SizeMode; variable: boolean } {
32
+ const f = followOf(revision.asset, part, axis)
33
+ if (f.of === 'own') return { mode: 'ratio', variable: false }
34
+ if (f.of !== 'instance' && f.of !== 'part') return { mode: 'custom', variable: true }
35
+ const source: SizeSource = f.of === 'instance' ? { of: 'instance', axis: f.axis } : { of: 'part', part: f.part, axis: f.axis }
36
+ const equalFactor = (source.of === 'part' ? measureFactor(revision.asset, source.part, source.axis) : 1) / measureFactor(revision.asset, part, axis)
37
+ const mode = !(f.plus ?? 0) ? 'ratio' : Math.abs((f.times ?? 1) - equalFactor) < 1e-6 ? 'difference' : 'custom'
38
+ return { source, mode, variable: true }
39
+ }
40
+
41
+ /**
42
+ * What a measure follows when it is first made to follow (ADR-0159 3): what the part hangs from, along the axis its own
43
+ * axis lies on as it stands -- a roller laid on its side follows the frame's depth with its length, not the frame's
44
+ * height (the axes paired by figure-model, `figureAxisPairsOf`, never by their letters). On the mounting plane, or
45
+ * fastened to nothing, the figure's size. Nothing when it lies at an angle, or the parent has no measure along that
46
+ * axis: the author picks. Only a suggestion when a relation is made; a measure that already follows something keeps it.
47
+ */
48
+ export function suggestedSizeSource(asset: FigureAsset, part: string, axis: Axis): SizeSource | undefined {
49
+ const { parent, along } = figureAxisPairsOf(asset, part)
50
+ const on = along[axis]?.axis
51
+ if (!on) return undefined
52
+ if (!parent) return { of: 'instance', axis: on }
53
+ return followableAxes(asset, parent).includes(on) ? { of: 'part', part: parent, axis: on } : undefined
54
+ }
55
+
56
+ /** Whether two sources are one: the same part, or the figure, and the same axis. */
57
+ export const sameSizeSource = (a: SizeSource | undefined, b: SizeSource | undefined) =>
58
+ !!a && !!b && a.of === b.of && a.axis === b.axis && (a.of !== 'part' || (b.of === 'part' && a.part === b.part))
59
+
60
+ /** How long the source is now, in millimetres as the panel shows it (a radius read as a diameter). */
61
+ export function sourceMeasureOf(revision: FigureRevision, source: SizeSource): number | undefined {
62
+ if (source.of === 'instance') return revision.asset.designInputs[`size.${source.axis}`] ?? figurePartsExtentOf(revision.asset)[source.axis]
63
+ return measureNow(revision, source.part, source.axis)
64
+ }
65
+
66
+ /** Preserve the displayed size when choosing a source or mode. mm edits change only that row's relation. */
67
+ export function sizeRelationSteps(revision: FigureRevision, part: string, axis: Axis, source: SizeSource, mode: Exclude<SizeMode, 'custom'>, mm = measureNow(revision, part, axis)): FigureAuthoringAction[] {
68
+ if (!(typeof mm === 'number' && Number.isFinite(mm) && mm > 0)) throw Error('SIZE_VALUE')
69
+ if (source.of === 'part' && source.part === part) throw Error('SIZE_SOURCE')
70
+ const asset = revision.asset
71
+ const steps: FigureAuthoringAction[] = []
72
+ let raw: number | undefined
73
+ let sourceFactor = 1
74
+ if (source.of === 'instance') {
75
+ const declared = asset.document.model.inputs.some(i => i.id === `size.${source.axis}` && i.role === 'design')
76
+ if (declared) raw = asset.designInputs[`size.${source.axis}`]
77
+ else {
78
+ // Do not replace an existing partial size declaration while configuring an unrelated axis.
79
+ if (asset.document.model.inputs.some(i => /^size\.[xyz]$/.test(i.id))) throw Error('SIZE_SOURCE')
80
+ const at = figurePartsExtentOf(asset)
81
+ steps.push({ kind: 'declare-instance-size', at })
82
+ raw = at[source.axis]
83
+ }
84
+ } else {
85
+ if (!followableAxes(asset, source.part).includes(source.axis)) throw Error('SIZE_SOURCE')
86
+ raw = dimensionNow(revision, source.part, source.axis)
87
+ sourceFactor = measureFactor(asset, source.part, source.axis)
88
+ }
89
+ if (!(typeof raw === 'number' && raw > 0 && Number.isFinite(raw))) throw Error('SIZE_SOURCE')
90
+ const ownFactor = measureFactor(asset, part, axis)
91
+ const times = mode === 'ratio' ? mm / ownFactor / raw : sourceFactor / ownFactor
92
+ const plus = mode === 'difference' ? (mm - raw * sourceFactor) / ownFactor : 0
93
+ steps.push({ kind: 'link-dimension', part, axis, source, times, ...(plus ? { plus } : {}), replace: true })
94
+ return steps
95
+ }
@@ -78,7 +78,7 @@ export interface EndlessJoint {
78
78
  label: string;
79
79
  unit: 'deg' | 'rad';
80
80
  }
81
- /** The joints that turn without end: revolute, with no limits, leading no one's mimic. */
81
+ /** The axes that turn without end: revolute, with no limits, following no one (ADR-0159: every axis of every joint). */
82
82
  export declare function endlessJointsOf(asset: FigureAsset): EndlessJoint[];
83
83
  /** What the rotation card is told: the joint, and the data fields that turn it on, set its speed and its direction. */
84
84
  export interface RotationChoice {
@@ -4,7 +4,7 @@
4
4
  * of data is things-scene's; this only lists what there is to choose, drafts a binding the kernel then checks, and edits
5
5
  * the schema as a tree. Kept apart from the element so it is tested without a browser.
6
6
  */
7
- import { FIGURE_PART_ID, FIGURE_MOTION_SPEED_MAX, FigureContractError, figureDataRows, parseFigureAccessor } from '@hatiolab/figure-model';
7
+ import { FIGURE_PART_ID, FIGURE_MOTION_SPEED_MAX, FigureContractError, figureAxesOf, figureDataRows, motionStateIdOf, parseFigureAccessor } from '@hatiolab/figure-model';
8
8
  import { FigureInputs } from '@hatiolab/things-scene';
9
9
  export const behaviorKeyOf = (b) => b.kind === 'state'
10
10
  ? `state:${b.input}`
@@ -269,13 +269,13 @@ export function motionWrittenByData(asset) {
269
269
  }
270
270
  return out;
271
271
  }
272
- /** The joints that turn without end: revolute, with no limits, leading no one's mimic. */
272
+ /** The axes that turn without end: revolute, with no limits, following no one (ADR-0159: every axis of every joint). */
273
273
  export function endlessJointsOf(asset) {
274
274
  const out = [];
275
- for (const j of asset.joints ?? []) {
276
- if (j.type !== 'revolute' || j.lowerLimit !== undefined || j.upperLimit !== undefined || j.mimic)
275
+ for (const { motion: a } of figureAxesOf(asset.joints)) {
276
+ if (a.type !== 'revolute' || a.lowerLimit !== undefined || a.upperLimit !== undefined || a.mimic)
277
277
  continue;
278
- const input = j.state?.id ?? j.id;
278
+ const input = motionStateIdOf(a);
279
279
  const unit = asset.document.model.inputs.find(i => i.id === input)?.unit;
280
280
  if (unit !== 'deg' && unit !== 'rad')
281
281
  continue;