@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.
- package/client/modeller/data-interface.ts +5 -5
- package/client/modeller/editor.ts +97 -88
- package/client/modeller/fasten-geometry.ts +2 -1
- package/client/modeller/figure-links.ts +87 -157
- package/client/modeller/figure-size-editor.ts +233 -0
- package/client/modeller/joint-drag.ts +25 -21
- package/client/modeller/joint-fan.ts +4 -3
- package/client/modeller/links.ts +87 -36
- package/client/modeller/live.ts +33 -0
- package/client/modeller/motion-ends.ts +10 -6
- package/client/modeller/size-response.ts +95 -0
- package/dist-client/modeller/data-interface.d.ts +1 -1
- package/dist-client/modeller/data-interface.js +5 -5
- package/dist-client/modeller/data-interface.js.map +1 -1
- package/dist-client/modeller/editor.d.ts +14 -1
- package/dist-client/modeller/editor.js +103 -89
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/fasten-geometry.js +1 -1
- package/dist-client/modeller/fasten-geometry.js.map +1 -1
- package/dist-client/modeller/figure-links.d.ts +3 -4
- package/dist-client/modeller/figure-links.js +91 -145
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-size-editor.d.ts +29 -0
- package/dist-client/modeller/figure-size-editor.js +262 -0
- package/dist-client/modeller/figure-size-editor.js.map +1 -0
- package/dist-client/modeller/joint-drag.d.ts +1 -1
- package/dist-client/modeller/joint-drag.js +27 -22
- package/dist-client/modeller/joint-drag.js.map +1 -1
- package/dist-client/modeller/joint-fan.js +4 -3
- package/dist-client/modeller/joint-fan.js.map +1 -1
- package/dist-client/modeller/links.d.ts +21 -9
- package/dist-client/modeller/links.js +85 -35
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/live.d.ts +14 -0
- package/dist-client/modeller/live.js +31 -0
- package/dist-client/modeller/live.js.map +1 -1
- package/dist-client/modeller/motion-ends.d.ts +4 -1
- package/dist-client/modeller/motion-ends.js +10 -6
- package/dist-client/modeller/motion-ends.js.map +1 -1
- package/dist-client/modeller/size-response.d.ts +29 -0
- package/dist-client/modeller/size-response.js +99 -0
- package/dist-client/modeller/size-response.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
- package/docs/evidence/2026-10-24/integrated-size.png +0 -0
- package/docs/evidence/2026-10-24/multipart-response.png +0 -0
- package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
- package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
- package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
- package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
- package/docs/evidence/2026-10-24/width-preview.png +0 -0
- package/docs/multipart-response-review-2026-10-24.md +77 -0
- package/docs/response-authoring-2026-10-24.md +46 -0
- package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
- package/package.json +7 -7
- package/test/attachment-size-contract.test.ts +47 -0
- package/test/browser/acceptance-page.js +1 -1
- package/test/browser/detail-page.js +1 -1
- package/test/browser/detail-tabs-page.js +1 -1
- package/test/browser/editing-page.js +1 -1
- package/test/browser/editing.check.mjs +66 -14
- package/test/browser/links.check.mjs +41 -22
- package/test/browser/modeller-page.js +1 -1
- package/test/browser/modeller-revision.check.mjs +21 -7
- package/test/browser/modeller.check.mjs +106 -39
- package/test/browser/multipart-response-page.js +8 -0
- package/test/browser/parts.check.mjs +2 -1
- package/test/browser/product-page.js +1 -1
- package/test/browser/props.check.mjs +17 -14
- package/test/browser/response-page.js +12 -0
- package/test/browser/row-preview.check.mjs +3 -3
- package/test/browser/screen.check.mjs +57 -13
- package/test/browser/serve-response.mjs +24 -0
- package/test/clip-ai-proposal.test.ts +1 -1
- package/test/clip-consent.test.ts +2 -2
- package/test/command-preview.test.ts +7 -7
- package/test/joint-drag.test.ts +6 -6
- package/test/joint-frames.test.ts +33 -32
- package/test/joints.ts +1 -6
- package/test/links.test.ts +60 -20
- package/test/multipart-response-fixture.js +17 -0
- package/test/multipart-response.test.ts +37 -0
- package/test/refusals.test.ts +2 -2
- package/test/resize-with-placement.test.ts +23 -1
- package/test/size-response.test.ts +49 -0
- package/test/size-source-pairing.test.ts +66 -0
- package/translations/en.json +47 -10
- package/translations/es.json +47 -10
- package/translations/ja.json +47 -10
- package/translations/ko.json +52 -15
- package/translations/ms.json +47 -10
- 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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
60
|
-
/* A revolute
|
|
61
|
-
const endless = turns &&
|
|
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:
|
|
65
|
-
if (
|
|
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(
|
|
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(
|
|
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,
|
|
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 (
|
|
86
|
-
if (
|
|
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(
|
|
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 =
|
|
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,
|
|
97
|
-
return { kind: 'value', input: stateId, unit:
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/client/modeller/links.ts
CHANGED
|
@@ -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
|
|
537
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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 =
|
|
603
|
-
const terms = figureMimicTermsOf(
|
|
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(
|
|
629
|
+
const axisVector = jointAxisVectorOf(a?.axis)
|
|
606
630
|
return {
|
|
607
631
|
kind,
|
|
608
|
-
axis: jointAxisToken(
|
|
632
|
+
axis: jointAxisToken(a?.axis) ?? 'x',
|
|
609
633
|
...(axisVector ? { axisVector } : {}),
|
|
610
|
-
/*
|
|
611
|
-
id:
|
|
612
|
-
min: travel || travelSum ? 0 : (
|
|
613
|
-
max: travel || travelSum ? 500 : (
|
|
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
|
|
626
|
-
* turn a turn — and has a control of its own, since a follower follows the
|
|
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']):
|
|
629
|
-
|
|
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
|
-
/*
|
|
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
|
|
674
|
+
axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJointMotion['axis'],
|
|
675
|
+
...(was?.through ? { through: was.through } : {})
|
|
642
676
|
}
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (m.
|
|
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:
|
|
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:
|
|
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
|
/**
|
package/client/modeller/live.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 (
|
|
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]!)
|