@things-factory/figure-ui 10.1.208 → 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.
- package/client/modeller/data-interface.ts +5 -5
- package/client/modeller/editor.ts +74 -57
- package/client/modeller/fasten-geometry.ts +2 -1
- package/client/modeller/figure-links.ts +26 -120
- package/client/modeller/figure-size-editor.ts +196 -0
- package/client/modeller/joint-drag.ts +25 -21
- package/client/modeller/joint-fan.ts +4 -3
- package/client/modeller/links.ts +36 -24
- 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 +9 -1
- package/dist-client/modeller/editor.js +80 -58
- 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 +1 -4
- package/dist-client/modeller/figure-links.js +26 -107
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-size-editor.d.ts +27 -0
- package/dist-client/modeller/figure-size-editor.js +221 -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 +9 -6
- package/dist-client/modeller/links.js +33 -21
- package/dist-client/modeller/links.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/route.d.ts +1 -1
- 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 +28 -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 +51 -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 +21 -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 +44 -10
- package/translations/es.json +44 -10
- package/translations/ja.json +44 -10
- package/translations/ko.json +49 -15
- package/translations/ms.json +44 -10
- 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
|
|
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 } 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
|
|
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<
|
|
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
|
|
600
|
-
const
|
|
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 =
|
|
603
|
-
const terms = figureMimicTermsOf(
|
|
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(
|
|
609
|
+
const axisVector = jointAxisVectorOf(a?.axis)
|
|
606
610
|
return {
|
|
607
611
|
kind,
|
|
608
|
-
axis: jointAxisToken(
|
|
612
|
+
axis: jointAxisToken(a?.axis) ?? 'x',
|
|
609
613
|
...(axisVector ? { axisVector } : {}),
|
|
610
|
-
/*
|
|
611
|
-
id:
|
|
612
|
-
min: travel || travelSum ? 0 : (
|
|
613
|
-
max: travel || travelSum ? 500 : (
|
|
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
|
|
626
|
-
* turn a turn — and has a control of its own, since a follower follows the
|
|
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']):
|
|
629
|
-
return (asset.joints
|
|
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
|
-
/*
|
|
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
|
|
652
|
+
axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJointMotion['axis'],
|
|
653
|
+
...(was?.through ? { through: was.through } : {})
|
|
642
654
|
}
|
|
643
|
-
const given = (motion: Omit<
|
|
644
|
-
if (m.follow) return given({ mimic: {
|
|
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
|
-
/**
|
|
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]!)
|
|
@@ -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
|
|
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
|
|
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
|
|
276
|
-
if (
|
|
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 =
|
|
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;
|