@things-factory/figure-ui 10.1.115 → 10.1.117
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/graphql/index.ts +6 -1
- package/client/modeller/editor.ts +5 -11
- package/client/modeller/fasten-geometry.ts +1 -1
- package/client/modeller/figure-links.ts +52 -17
- package/client/modeller/joint-axis.ts +36 -0
- package/client/modeller/joint-drag.ts +30 -10
- package/client/modeller/joint-fan.ts +2 -1
- package/client/modeller/links.ts +70 -20
- package/client/modeller/recipes.ts +6 -1
- package/client/pages/figure-list-page.ts +4 -4
- package/client/types.ts +1 -1
- package/client/viewparts/detail-motion.ts +11 -0
- package/client/viewparts/figure-asset-viewer.ts +177 -0
- package/client/viewparts/figure-detail-dock.ts +187 -0
- package/dist-client/graphql/index.d.ts +5 -0
- package/dist-client/graphql/index.js +5 -1
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/clips.d.ts +1 -1
- package/dist-client/modeller/editor.d.ts +3 -2
- package/dist-client/modeller/editor.js +5 -11
- 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 -0
- package/dist-client/modeller/figure-links.js +65 -33
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/joint-axis.d.ts +13 -0
- package/dist-client/modeller/joint-axis.js +31 -0
- package/dist-client/modeller/joint-axis.js.map +1 -0
- package/dist-client/modeller/joint-drag.js +29 -9
- package/dist-client/modeller/joint-drag.js.map +1 -1
- package/dist-client/modeller/joint-fan.js +2 -1
- package/dist-client/modeller/joint-fan.js.map +1 -1
- package/dist-client/modeller/links.d.ts +34 -4
- package/dist-client/modeller/links.js +59 -18
- package/dist-client/modeller/links.js.map +1 -1
- package/dist-client/modeller/recipes.js +5 -1
- package/dist-client/modeller/recipes.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +2 -3
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/types.d.ts +1 -1
- package/dist-client/types.js.map +1 -1
- package/dist-client/viewparts/detail-motion.d.ts +6 -0
- package/dist-client/viewparts/detail-motion.js +9 -0
- package/dist-client/viewparts/detail-motion.js.map +1 -0
- package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
- package/dist-client/viewparts/figure-asset-viewer.js +198 -0
- package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
- package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
- package/dist-client/viewparts/figure-detail-dock.js +224 -0
- package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
- package/package.json +6 -6
- package/test/browser/detail-page.js +34 -0
- package/test/browser/serve-detail.mjs +10 -0
- package/test/command-preview.test.ts +2 -2
- package/test/detail-motion.test.ts +26 -0
- package/test/joint-frames.test.ts +293 -0
- package/test/links.test.ts +20 -1
- package/test/modeller-route.test.ts +4 -2
- package/test/verdict.test.ts +3 -1
- package/translations/en.json +20 -2
- package/translations/ja.json +20 -2
- package/translations/ko.json +20 -2
- package/translations/ms.json +20 -2
- package/translations/zh.json +20 -2
package/client/graphql/index.ts
CHANGED
|
@@ -383,6 +383,11 @@ export async function inspectFigureDraft(source: string, id?: string): Promise<F
|
|
|
383
383
|
* 전 초안은 서버가 모르므로 판정도 화면에서 한다 — 형식의 `contractChanges` 를 같이 쓴다.
|
|
384
384
|
*/
|
|
385
385
|
export async function fetchReleasedSource(id: string): Promise<string | undefined> {
|
|
386
|
+
return (await fetchReleasedFigure(id))?.source
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Read-only catalogue preview: source and version come from the same released snapshot. */
|
|
390
|
+
export async function fetchReleasedFigure(id: string): Promise<{ version: number; source: string } | undefined> {
|
|
386
391
|
const response = await client.query({
|
|
387
392
|
query: gql`
|
|
388
393
|
query FigureReleased($id: String!) {
|
|
@@ -396,7 +401,7 @@ export async function fetchReleasedSource(id: string): Promise<string | undefine
|
|
|
396
401
|
fetchPolicy: 'network-only'
|
|
397
402
|
})
|
|
398
403
|
|
|
399
|
-
return unwrap<{ source
|
|
404
|
+
return unwrap<{ version: number; source: string } | undefined>(response, 'figureReleased') ?? undefined
|
|
400
405
|
}
|
|
401
406
|
|
|
402
407
|
export async function fetchFigureVersions(id: string): Promise<FigureVersion[]> {
|
|
@@ -46,7 +46,7 @@ import { roomRefitRowsOf } from './occupancy.js'
|
|
|
46
46
|
import { editRefusalOf, inDeclaredRoom, inputRangeRefusalOf, narrowingRetryOf, partRefusalOf, placementRefusalOf, recipeRefusalOf, removalNoticeOf, sizeRangeRefusalOf } from './refusals.js'
|
|
47
47
|
import type { FigureRenderPart } from './renderer.js'
|
|
48
48
|
import type { FigureBudgetLine } from './maturity.js'
|
|
49
|
-
import { fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
49
|
+
import { endPlaceKeyOf, fieldWordsOf, followOf, measureNow, partSummaryOf, instanceRangeOf, instanceRangeToDeclare, proportionToFollow, rangeDiffers, rangeWith, turnsAboutAJoint, valueNow } from './links.js'
|
|
50
50
|
import { jointFanOf } from './joint-fan.js'
|
|
51
51
|
import { jointDragOf } from './joint-drag.js'
|
|
52
52
|
import type { FirstSave } from './first-save.js'
|
|
@@ -1386,7 +1386,7 @@ export class FigureEditor extends LitElement {
|
|
|
1386
1386
|
const fastened = new Map(
|
|
1387
1387
|
(asset?.joints ?? []).map(j => [
|
|
1388
1388
|
j.body1.part,
|
|
1389
|
-
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(
|
|
1389
|
+
'plane' in j.body0 ? i18next.t('figure.label.mounting-plane') : `${partLabelOf(j.body0.part)} · ${i18next.t(endPlaceKeyOf(j.body0))}`
|
|
1390
1390
|
])
|
|
1391
1391
|
)
|
|
1392
1392
|
const rows = new Map<string, FigurePartRow>()
|
|
@@ -1807,8 +1807,9 @@ export class FigureEditor extends LitElement {
|
|
|
1807
1807
|
}
|
|
1808
1808
|
}
|
|
1809
1809
|
/**
|
|
1810
|
-
* A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
|
|
1811
|
-
*
|
|
1810
|
+
* A new figure's first question, on the canvas where the work will be (designer 2026-09-27, U04): a basic shape
|
|
1811
|
+
* or an AI draft when the page offers one. Recipes require a host part and are reached from the parts panel after
|
|
1812
|
+
* that part exists. Nothing is added until one is chosen; the type, the space, the
|
|
1812
1813
|
* mounting face and the tags are not asked for first.
|
|
1813
1814
|
*/
|
|
1814
1815
|
/** The section editor over the canvas, the figure behind it drawing what the working points make. */
|
|
@@ -1886,13 +1887,6 @@ export class FigureEditor extends LitElement {
|
|
|
1886
1887
|
</div>
|
|
1887
1888
|
<small>${i18next.t('figure.text.start-shape-more')}</small>
|
|
1888
1889
|
</section>
|
|
1889
|
-
<section>
|
|
1890
|
-
<h3>${i18next.t('figure.label.start-recipe')}</h3>
|
|
1891
|
-
<button start-button start-recipe ?disabled=${!this.templates.length} @click=${() => ((this.catalogOpen = true), (this.recipeError = ''))}>
|
|
1892
|
-
${i18next.t('figure.button.start-recipe')}
|
|
1893
|
-
</button>
|
|
1894
|
-
${this.templates.length ? nothing : html`<small>${i18next.t('figure.text.start-no-recipes')}</small>`}
|
|
1895
|
-
</section>
|
|
1896
1890
|
${this.aiDraft
|
|
1897
1891
|
? html`<section>
|
|
1898
1892
|
<h3>${i18next.t('figure.label.start-ai')}</h3>
|
|
@@ -139,7 +139,7 @@ export function overhangOf(view: FaceView, at: Record<Axis, number>): { touching
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/** Whether the part turns about a joint: then 가운데를 면에 is offered -- a roller spinning in place. */
|
|
142
|
-
export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || j.body1.face === 'centre'))
|
|
142
|
+
export const turnsInPlace = (asset: FigureAsset, part: string) => (asset.joints ?? []).some(j => j.body1.part === part && (j.type === 'revolute' || ('face' in j.body1 && j.body1.face === 'centre')))
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* How the face lies on the screen: X across when it is one of the face's axes, else Z; the other down the screen -- up
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
leadersFor,
|
|
34
34
|
motionOf,
|
|
35
35
|
motionFormOf,
|
|
36
|
+
motionFormRefusalOf,
|
|
37
|
+
endPlaceKeyOf,
|
|
36
38
|
motionJoint,
|
|
37
39
|
placeFacesOf,
|
|
38
40
|
placeIdFor,
|
|
@@ -54,6 +56,7 @@ import './figure-fasten-drawing.js'
|
|
|
54
56
|
import type { FastenChange } from './figure-fasten-drawing.js'
|
|
55
57
|
import { faceViewOf, modeOf, myFaceFor, turnsInPlace, type FastenMode } from './fasten-geometry.js'
|
|
56
58
|
import { HelpToggles } from './help.js'
|
|
59
|
+
import { jointAxisWord } from './joint-axis.js'
|
|
57
60
|
|
|
58
61
|
const FACES = Object.keys(FIGURE_FACES) as FigureFace[]
|
|
59
62
|
/** The target list's entry for the plane the figure stands by. */
|
|
@@ -434,17 +437,22 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
434
437
|
* What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
|
|
435
438
|
* or take it off. Right after "follow size, attached" this is the result, not a refusal (designer 2026-09-27).
|
|
436
439
|
*/
|
|
437
|
-
private
|
|
440
|
+
private heldWords(asset: FigureAsset) {
|
|
438
441
|
const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
|
|
439
|
-
const face = joint ? i18next.t(
|
|
442
|
+
const face = joint ? i18next.t(endPlaceKeyOf(joint.body1)) : ''
|
|
440
443
|
const gap = typeof joint?.origin?.gap === 'number' ? joint.origin.gap : 0
|
|
441
444
|
const where = !joint
|
|
442
445
|
? i18next.t('figure.text.held-by-rule')
|
|
443
446
|
: 'plane' in joint.body0
|
|
444
447
|
? i18next.t('figure.text.held-on-plane', { face, place: i18next.t(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
|
|
445
|
-
: i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(
|
|
448
|
+
: i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(endPlaceKeyOf(joint.body0)) })
|
|
449
|
+
return where
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private renderHeld(asset: FigureAsset) {
|
|
453
|
+
const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
|
|
446
454
|
return html`<div resize-held>
|
|
447
|
-
<p><md-icon>link</md-icon> ${
|
|
455
|
+
<p><md-icon>link</md-icon> ${this.heldWords(asset)}</p>
|
|
448
456
|
<button act open-fastening @click=${() => this.openFastening(asset)}>
|
|
449
457
|
<md-icon>link</md-icon>${i18next.t('figure.button.open-fastening')}
|
|
450
458
|
</button>
|
|
@@ -521,6 +529,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
521
529
|
|
|
522
530
|
private renderFastening(asset: FigureAsset) {
|
|
523
531
|
const held = heldBy(asset, this.partId)
|
|
532
|
+
/* Placed by an origin (ADR-0107 decision 4): attach writes faces, so the form would move it off its origin. Said, and taken off only. */
|
|
533
|
+
if (fasteningOf(asset, this.partId)?.atOrigin) {
|
|
534
|
+
this.fastenCandidate = undefined
|
|
535
|
+
return html`<div fastened fasten-at-origin>
|
|
536
|
+
<p>${this.heldWords(asset)}</p>
|
|
537
|
+
<button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
|
|
538
|
+
<md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
|
|
539
|
+
</button>
|
|
540
|
+
${this.renderDetachWords()}
|
|
541
|
+
</div>`
|
|
542
|
+
}
|
|
524
543
|
const f = this.fasten
|
|
525
544
|
/* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
|
|
526
545
|
const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
|
|
@@ -852,7 +871,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
852
871
|
part: this.partId,
|
|
853
872
|
against,
|
|
854
873
|
kind: i18next.t('figure.label.motion-' + now.kind),
|
|
855
|
-
axis: String(now.axis).toUpperCase()
|
|
874
|
+
axis: now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()
|
|
856
875
|
})}
|
|
857
876
|
</p>
|
|
858
877
|
${ends
|
|
@@ -860,12 +879,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
860
879
|
: nothing}
|
|
861
880
|
<div pair>
|
|
862
881
|
<span>
|
|
863
|
-
${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
|
|
882
|
+
${i18next.t('figure.label.motion-' + now.kind)} · ${now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()}
|
|
864
883
|
${now.travel
|
|
865
884
|
? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
|
|
866
885
|
: nothing}
|
|
867
|
-
${now.follows
|
|
868
|
-
? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset
|
|
886
|
+
${now.follows?.sum.length === 1
|
|
887
|
+
? html`<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.sum[0]!.joint, multiplier: now.follows.sum[0]!.multiplier, offset: now.follows.offset })}</small>`
|
|
888
|
+
: now.follows
|
|
889
|
+
? html`<small>${i18next.t('figure.label.motion-follow')}: ${now.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${now.follows.offset}</small>`
|
|
869
890
|
: now.control
|
|
870
891
|
? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
|
|
871
892
|
: nothing}
|
|
@@ -901,6 +922,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
901
922
|
/* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
|
|
902
923
|
const leaders = leadersFor(asset, this.partId, m.kind)
|
|
903
924
|
const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
|
|
925
|
+
const refusal = motionFormRefusalOf(m, follow)
|
|
904
926
|
return html`
|
|
905
927
|
<div pair>
|
|
906
928
|
<select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
|
|
@@ -908,11 +930,20 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
908
930
|
<option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
|
|
909
931
|
<option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
|
|
910
932
|
</select>
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
933
|
+
${m.axisVector
|
|
934
|
+
? html`<span motion-axis-vector>${i18next.t('figure.label.motion-axis')} ${jointAxisWord(m.axisVector)}</span>`
|
|
935
|
+
: html`<select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
|
|
936
|
+
${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
|
|
937
|
+
</select>`}
|
|
914
938
|
</div>
|
|
915
|
-
${
|
|
939
|
+
${m.followSum
|
|
940
|
+
? html`<div form>
|
|
941
|
+
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
942
|
+
<span>${m.followSum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')}</span>
|
|
943
|
+
<span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
|
|
944
|
+
<input type="number" step="any" .value=${String(m.offset)} @change=${(e: Event) => (this.motion = { ...m, offset: Number((e.target as HTMLInputElement).value) || 0 })} />
|
|
945
|
+
</div>`
|
|
946
|
+
: leaders.length
|
|
916
947
|
? html`<div form>
|
|
917
948
|
<span>${i18next.t('figure.label.motion-follow')}</span>
|
|
918
949
|
<select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
|
|
@@ -921,7 +952,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
921
952
|
</select>
|
|
922
953
|
</div>`
|
|
923
954
|
: nothing}
|
|
924
|
-
${
|
|
955
|
+
${m.followSum
|
|
956
|
+
? nothing
|
|
957
|
+
: follow
|
|
925
958
|
? html`<div form>
|
|
926
959
|
<span>${i18next.t('figure.label.motion-multiplier')}</span>
|
|
927
960
|
<input type="number" step="any" .value=${String(m.multiplier)} @change=${(e: Event) => (this.motion = { ...m, multiplier: Number((e.target as HTMLInputElement).value) })} />
|
|
@@ -932,7 +965,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
932
965
|
${m.kind === 'slide'
|
|
933
966
|
? html`<span>${i18next.t('figure.label.motion-travel')}</span>
|
|
934
967
|
<select @change=${(e: Event) => (this.motion = { ...m, travel: (e.target as HTMLSelectElement).value })}>
|
|
935
|
-
|
|
968
|
+
${m.travelSum
|
|
969
|
+
? html`<option value="" ?selected=${!m.travel}>${m.travelSum.sum.map(t => `${t.from.of === 'input' ? t.from.input : `${t.from.of === 'part' ? t.from.part : i18next.t('figure.label.motion-travel')} · ${t.from.axis.toUpperCase()}`}${t.times !== undefined && t.times !== 1 ? ` × ${t.times}` : ''}`).join(' + ')}</option>`
|
|
970
|
+
: html`<option value="" ?selected=${!m.travel}>${i18next.t('figure.label.motion-travel-mm')}</option>`}
|
|
936
971
|
${asset.appearance
|
|
937
972
|
.map(look => look.target)
|
|
938
973
|
.filter(one => canFollow(asset, one))
|
|
@@ -945,7 +980,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
945
980
|
: nothing}
|
|
946
981
|
<span>${i18next.t('figure.label.control-id')}</span>
|
|
947
982
|
<input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
|
|
948
|
-
${m.kind === 'continuous' || (m.kind === 'slide' && m.travel)
|
|
983
|
+
${m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))
|
|
949
984
|
? nothing
|
|
950
985
|
: html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
|
|
951
986
|
<div pair>
|
|
@@ -955,8 +990,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
|
|
|
955
990
|
</div>`}
|
|
956
991
|
<button
|
|
957
992
|
act
|
|
958
|
-
?disabled=${this.locked ||
|
|
959
|
-
title=${
|
|
993
|
+
?disabled=${this.locked || !!refusal}
|
|
994
|
+
title=${refusal === 'range' ? i18next.t('figure.text.range-needs-room') : ''}
|
|
960
995
|
@click=${() => {
|
|
961
996
|
this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
|
|
962
997
|
this.editingMotion = false
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A joint's axis as the record keeps it (ADR-0107 decision 1): one of USD's tokens, or any vector in the joint frame
|
|
3
|
+
* (URDF's `axis xyz`). One reader for every place that turns it into a direction or says it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type JointAxisVector = { x: number; y: number; z: number }
|
|
7
|
+
|
|
8
|
+
const isVector = (axis: unknown): axis is JointAxisVector =>
|
|
9
|
+
!!axis && typeof axis === 'object' && ['x', 'y', 'z'].every(k => typeof (axis as Record<string, unknown>)[k] === 'number' && Number.isFinite((axis as Record<string, number>)[k]))
|
|
10
|
+
|
|
11
|
+
/** The axis as a unit vector in the joint frame: a token's own axis, a vector divided by its length. Nothing for none. */
|
|
12
|
+
export function jointAxisUnit(axis: unknown): [number, number, number] | undefined {
|
|
13
|
+
if (typeof axis === 'string') {
|
|
14
|
+
const k = ['x', 'y', 'z'].indexOf(axis.toLowerCase())
|
|
15
|
+
return k < 0 ? undefined : ([0, 1, 2].map(i => (i === k ? 1 : 0)) as [number, number, number])
|
|
16
|
+
}
|
|
17
|
+
if (!isVector(axis)) return undefined
|
|
18
|
+
const n = Math.hypot(axis.x, axis.y, axis.z)
|
|
19
|
+
return n > 1e-9 ? [axis.x / n, axis.y / n, axis.z / n] : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The token, lowercase, when the axis is one; nothing for a vector. */
|
|
23
|
+
export function jointAxisToken(axis: unknown): 'x' | 'y' | 'z' | undefined {
|
|
24
|
+
const k = typeof axis === 'string' ? axis.toLowerCase() : ''
|
|
25
|
+
return k === 'x' || k === 'y' || k === 'z' ? k : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The vector, as written, when the axis is one. */
|
|
29
|
+
export const jointAxisVectorOf = (axis: unknown): JointAxisVector | undefined => (isVector(axis) ? { x: axis.x, y: axis.y, z: axis.z } : undefined)
|
|
30
|
+
|
|
31
|
+
/** The axis said: `X` for a token, `(1, 1, 0)` for a vector as written. */
|
|
32
|
+
export function jointAxisWord(axis: unknown): string {
|
|
33
|
+
const token = jointAxisToken(axis)
|
|
34
|
+
if (token) return token.toUpperCase()
|
|
35
|
+
return isVector(axis) ? `(${axis.x}, ${axis.y}, ${axis.z})` : '?'
|
|
36
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as THREE from 'three'
|
|
2
|
-
import { compileFigureAsset } from '@hatiolab/figure-model'
|
|
2
|
+
import { compileFigureAsset, figureMimicTermsOf } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
|
|
5
5
|
import type { GizmoResult } from './gizmo.js'
|
|
6
|
+
import { jointAxisUnit, jointAxisWord } from './joint-axis.js'
|
|
6
7
|
import type { FigureRenderPart } from './renderer.js'
|
|
7
8
|
|
|
8
9
|
/*
|
|
@@ -11,7 +12,8 @@ import type { FigureRenderPart } from './renderer.js'
|
|
|
11
12
|
*
|
|
12
13
|
* - turning a part on a revolute or continuous joint sets the joint's value -- the turn about the joint's axis;
|
|
13
14
|
* - sliding a part on a prismatic joint along its axis sets its value;
|
|
14
|
-
* - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it
|
|
15
|
+
* - moving a part any other way moves the joint's origin: the gap along the face it sits on, the offset across it;
|
|
16
|
+
* at a parent's origin (ADR-0107 decision 4), its `origin.offset` in the parent's frame.
|
|
15
17
|
*
|
|
16
18
|
* What would break a relation is refused and said: an origin that follows another part's size, a joint that mimics
|
|
17
19
|
* another, a turn on a joint that does not turn.
|
|
@@ -30,9 +32,15 @@ const FACE_SIGN: Record<string, number> = { left: -1, right: 1, bottom: -1, top:
|
|
|
30
32
|
|
|
31
33
|
const matrixOf = (r: number[][]) => new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1)
|
|
32
34
|
const eulerMatrix = (deg: number[]) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), 'XYZ'))
|
|
33
|
-
const unit = (axis:
|
|
35
|
+
const unit = (axis: unknown) => new THREE.Vector3(...(jointAxisUnit(axis) ?? [0, 0, 0]))
|
|
34
36
|
const round = (v: number, step: number) => Number((Math.round(v / step) * step).toFixed(Math.max(0, -Math.floor(Math.log10(step)))))
|
|
35
37
|
|
|
38
|
+
/** The joint frame's words of ADR-0107, read without the kernel's types (an older kernel has none). */
|
|
39
|
+
const framesOf = (j: FigureJoint) => (j.origin ?? {}) as { offset?: Partial<Record<Axis, unknown>>; rotate?: Partial<Record<Axis, number>> }
|
|
40
|
+
/** `origin.rotate`, URDF's rpy: Rz·Ry·Rx about the fixed axes. */
|
|
41
|
+
const rotateMatrix = (j: FigureJoint) => eulerMatrixOrder(AXES.map(a => framesOf(j).rotate?.[a] ?? 0), 'ZYX')
|
|
42
|
+
const eulerMatrixOrder = (deg: number[], order: THREE.EulerOrder) => new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(...(deg.map(THREE.MathUtils.degToRad) as [number, number, number]), order))
|
|
43
|
+
|
|
36
44
|
/** The part's own quarter turn in the joint frame, as the record says it. */
|
|
37
45
|
const turnMatrix = (j: FigureJoint) => eulerMatrix(AXES.map(a => j.origin?.turn?.[a] ?? 0))
|
|
38
46
|
|
|
@@ -56,7 +64,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
56
64
|
|
|
57
65
|
if (result.rotation) {
|
|
58
66
|
if (!turns) return { kind: 'refused', reason: j.type === 'prismatic' ? 'turned-slide' : 'fixed-turn', joint: j.id }
|
|
59
|
-
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
|
|
67
|
+
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
|
|
60
68
|
/* The turn about the joint's axis (the twist of the change): the only turn the joint can make. */
|
|
61
69
|
const before = new THREE.Quaternion().setFromRotationMatrix(matrixOf(part.pose.r as number[][]))
|
|
62
70
|
const after = new THREE.Quaternion().setFromRotationMatrix(eulerMatrix(result.rotation))
|
|
@@ -65,7 +73,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
65
73
|
const twist = 2 * Math.atan2(change.x * axis.x + change.y * axis.y + change.z * axis.z, change.w)
|
|
66
74
|
/* Turned about another axis: the joint cannot make that turn, and nothing is written for it. */
|
|
67
75
|
const whole = 2 * Math.acos(Math.min(1, Math.abs(change.w)))
|
|
68
|
-
if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: j.axis
|
|
76
|
+
if (Math.abs(twist) < whole * 0.5) return { kind: 'refused', reason: 'off-axis', joint: j.id, axis: jointAxisWord(j.axis) }
|
|
69
77
|
let value = now + THREE.MathUtils.radToDeg(twist)
|
|
70
78
|
if (endless) value = ((((value + 180) % 360) + 360) % 360) - 180
|
|
71
79
|
else value = Math.min(Math.max(value, j.lowerLimit!), j.upperLimit!)
|
|
@@ -77,7 +85,7 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
77
85
|
const frame = jointFrameRotation(j, part, turns ? now : 0)
|
|
78
86
|
|
|
79
87
|
if (j.type === 'prismatic') {
|
|
80
|
-
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: j.mimic.joint }
|
|
88
|
+
if (j.mimic) return { kind: 'refused', reason: 'mimic', joint: j.id, leader: figureMimicTermsOf(j.mimic)!.sum.map(t => t.joint).join(', ') }
|
|
81
89
|
/* How far one unit of the value moves the part along its axis, measured on the figure itself (a ratio travels a length). */
|
|
82
90
|
const along = unit(j.axis!).applyMatrix4(frame).normalize()
|
|
83
91
|
const at = (v: number) => {
|
|
@@ -92,12 +100,24 @@ export function jointDragOf(asset: FigureAsset, id: string, part: FigureRenderPa
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
/* Moving the joint's origin: the change in the frame
|
|
96
|
-
const local = moved.clone().applyMatrix4(frame.clone().invert())
|
|
103
|
+
/* Moving the joint's origin: the change in the frame it is placed in, before `origin.rotate` turns the joint frame. */
|
|
104
|
+
const local = moved.clone().applyMatrix4(frame.clone().multiply(rotateMatrix(j).invert()).invert())
|
|
105
|
+
const origin: NonNullable<FigureJoint['origin']> = { ...(j.origin ?? {}) }
|
|
106
|
+
if (!('plane' in j.body0) && !('face' in j.body0)) {
|
|
107
|
+
/* At the parent's origin there is no face: the frame moves by its offset, along the parent's axes. */
|
|
108
|
+
const offset = { ...(framesOf(j).offset ?? {}) }
|
|
109
|
+
for (const axis of AXES) {
|
|
110
|
+
const d = local[axis]
|
|
111
|
+
if (Math.abs(d) < 0.5) continue
|
|
112
|
+
const was = offset[axis] ?? 0
|
|
113
|
+
if (typeof was !== 'number') return { kind: 'refused', reason: 'origin-follows', joint: j.id }
|
|
114
|
+
offset[axis] = round(was + d, 1)
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'joint', joint: { ...j, origin: { ...origin, offset } } as FigureJoint }
|
|
117
|
+
}
|
|
97
118
|
const normal: Axis = 'plane' in j.body0 ? 'y' : FACE_AXIS[j.body0.face]!
|
|
98
119
|
/* Which way a larger gap moves the part: out of the face it meets (on the plane: up when it stands, down when it hangs). */
|
|
99
|
-
const outward = 'plane' in j.body0 ? (j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
|
|
100
|
-
const origin: NonNullable<FigureJoint['origin']> = { ...(j.origin ?? {}) }
|
|
120
|
+
const outward = 'plane' in j.body0 ? ('face' in j.body1 && j.body1.face === 'bottom' ? 1 : -1) : ((j.origin?.facing ?? 'meet') === 'meet' ? 1 : -1) * FACE_SIGN[j.body0.face]!
|
|
101
121
|
const along = local[normal]
|
|
102
122
|
if (Math.abs(along) >= 0.5) {
|
|
103
123
|
const gap = origin.gap ?? 0
|
|
@@ -3,6 +3,7 @@ import type { FigureAsset } from '@hatiolab/figure-model'
|
|
|
3
3
|
|
|
4
4
|
import type { FigureRenderPart } from './renderer.js'
|
|
5
5
|
import { sectionShape } from './drawn-shapes.js'
|
|
6
|
+
import { jointAxisUnit } from './joint-axis.js'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
* Where the selected part turns, and how far -- a translucent fan on the editing board. An axis name says nothing about which way a part swings; on a six-axis arm the author went to the preview and
|
|
@@ -111,7 +112,7 @@ function turnOf(asset: FigureAsset, part: string, values: Record<string, unknown
|
|
|
111
112
|
/* In the child's own frame: the joint is where its pose puts the seat's origin, the axis turned back by its pose. */
|
|
112
113
|
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]!)
|
|
113
114
|
const pivot = back(pose.t.map(v => -v))
|
|
114
|
-
const axis = norm(back(
|
|
115
|
+
const axis = norm(back(jointAxisUnit(record.axis) ?? [0, 0, 0]))
|
|
115
116
|
if (!axis) return null
|
|
116
117
|
return { carrier, pivot, axis, toward: [0, 0, 0], control, turnedPart: child }
|
|
117
118
|
}
|
package/client/modeller/links.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf } from '@hatiolab/figure-model'
|
|
1
|
+
import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, compileFigureGraph, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf } from '@hatiolab/figure-model'
|
|
2
2
|
import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
|
|
3
3
|
import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
|
|
4
4
|
import { startingShapeKindOf } from './starting-shape.js'
|
|
5
|
+
import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
* What a part is tied to — the readers behind the parts' size response, fastening and motion sections. Kept apart from the element so they can be tested without a browser.
|
|
@@ -52,6 +53,9 @@ export function heldBy(asset: FigureAsset, part: string): { part: string } | { p
|
|
|
52
53
|
return to ? { part: to } : null
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** The word for where a joint end sits: its face's, or the part's origin for an end placed by it (ADR-0107 decision 4). */
|
|
57
|
+
export const endPlaceKeyOf = (end: object) => ('face' in end ? `figure.label.face-${(end as { face: string }).face}` : 'figure.label.joint-origin')
|
|
58
|
+
|
|
55
59
|
/** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
|
|
56
60
|
export function canFollow(asset: FigureAsset, part: string): boolean {
|
|
57
61
|
return followableAxes(asset, part).length > 0
|
|
@@ -231,20 +235,24 @@ export function rowAxesOf(asset: FigureAsset, part: string): Axis[] {
|
|
|
231
235
|
/* A part that moves may be a row too: every copy on the one control, from its own place (designer 2026-09-27). */
|
|
232
236
|
if (!j || j.repeat || !('part' in j.body0)) return []
|
|
233
237
|
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return []
|
|
234
|
-
|
|
238
|
+
/* Placed by an origin: no face to lay the row across, and the kernel refuses it. */
|
|
239
|
+
const by = j.body1
|
|
240
|
+
if (!('face' in j.body0) || !('face' in by)) return []
|
|
241
|
+
return AXES.filter(axis => axis !== FIGURE_FACES[by.face])
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/**
|
|
238
245
|
* Why a part cannot be made into a row here, in the terms `rowAxesOf` refuses it: said, not hidden (ADR-0097 §5.5).
|
|
239
246
|
* Nothing when it can, or when it already is a row (that row is shown where it stands).
|
|
240
247
|
*/
|
|
241
|
-
export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | undefined {
|
|
248
|
+
export function rowBlockOf(asset: FigureAsset, part: string): 'unfastened' | 'on-plane' | 'holds-parts' | 'at-origin' | undefined {
|
|
242
249
|
const joints = asset.joints ?? []
|
|
243
250
|
const j = joints.find(o => o.body1.part === part)
|
|
244
251
|
if (!j) return 'unfastened'
|
|
245
252
|
if (j.repeat) return undefined
|
|
246
253
|
if (!('part' in j.body0)) return 'on-plane'
|
|
247
254
|
if (joints.some(o => 'part' in o.body0 && o.body0.part === part)) return 'holds-parts'
|
|
255
|
+
if (!('face' in j.body0) || !('face' in j.body1)) return 'at-origin'
|
|
248
256
|
return undefined
|
|
249
257
|
}
|
|
250
258
|
|
|
@@ -332,12 +340,15 @@ export function fasteningOf(
|
|
|
332
340
|
twist: number
|
|
333
341
|
align: Partial<Record<Axis, Alignment>>
|
|
334
342
|
alignMm: Partial<Record<Axis, number>>
|
|
343
|
+
/** Placed by a part's origin (ADR-0107 decision 4): no face to fasten by, and the form does not write it. */
|
|
344
|
+
atOrigin?: true
|
|
335
345
|
} | null {
|
|
336
346
|
const record = asset.joints?.find(j => j.body1.part === part)
|
|
337
347
|
if (!record) return null
|
|
338
348
|
const toPlane = 'plane' in record.body0
|
|
339
349
|
const target = 'part' in record.body0 ? record.body0.part : ''
|
|
340
|
-
const targetFace = '
|
|
350
|
+
const targetFace = 'face' in record.body0 ? record.body0.face : 'top'
|
|
351
|
+
const atOrigin = !('plane' in record.body0) && (!('face' in record.body0) || !('face' in record.body1))
|
|
341
352
|
const twist = record.origin?.twist ?? 0
|
|
342
353
|
/* Where across the face: as the record says it, and where it says nothing, where the part stands now. */
|
|
343
354
|
const kept = placeKeptOf(asset, part, toPlane ? 'plane' : { part: target, face: targetFace }, !!twist)
|
|
@@ -357,14 +368,15 @@ export function fasteningOf(
|
|
|
357
368
|
}
|
|
358
369
|
const gap = record.origin?.gap
|
|
359
370
|
return {
|
|
360
|
-
face: record.body1.face,
|
|
371
|
+
face: 'face' in record.body1 ? record.body1.face : 'centre',
|
|
361
372
|
toPlane,
|
|
362
373
|
target,
|
|
363
374
|
targetFace,
|
|
364
375
|
facing: record.origin?.facing ?? 'meet',
|
|
365
376
|
gap: typeof gap === 'number' ? gap : 0,
|
|
366
377
|
twist,
|
|
367
|
-
...kept
|
|
378
|
+
...kept,
|
|
379
|
+
...(atOrigin ? { atOrigin: true as const } : {})
|
|
368
380
|
}
|
|
369
381
|
}
|
|
370
382
|
|
|
@@ -456,9 +468,12 @@ export function positionIsShare(asset: FigureAsset, part: string, axis: Axis): b
|
|
|
456
468
|
export interface Motion {
|
|
457
469
|
kind: 'slide' | 'turn' | 'continuous'
|
|
458
470
|
axis: Axis | '?'
|
|
471
|
+
/** The record's axis when it is a vector, as written (the graph holds it turned into the parent's frame). */
|
|
472
|
+
vector?: JointAxisVector
|
|
459
473
|
control?: { id: string; unit?: string; min?: number; max?: number }
|
|
460
474
|
/** A joint that moves with another (URDF's mimic): the leader's value times `multiplier`, plus `offset`. */
|
|
461
|
-
follows
|
|
475
|
+
/** What it follows, as a sum of leaders each with its multiplier, plus an offset (one leader is a sum of one). */
|
|
476
|
+
follows?: { sum: Array<{ joint: string; multiplier: number }>; offset: number }
|
|
462
477
|
/** A slide whose distance is a part's length (plus a difference): the control is its share. */
|
|
463
478
|
travel?: { part: string; axis: string; plus?: number }
|
|
464
479
|
}
|
|
@@ -468,7 +483,9 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
|
|
|
468
483
|
const node = m.nodes.find(n => n.id === `${part}.motion`)
|
|
469
484
|
if (!node) return undefined
|
|
470
485
|
const record = asset.joints?.find(j => j.body1.part === part)
|
|
471
|
-
|
|
486
|
+
/* A follower has no limits of its own: its range is its leaders', not a turn without end. */
|
|
487
|
+
const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined && !record.mimic
|
|
488
|
+
const vector = jointAxisVectorOf(record?.axis)
|
|
472
489
|
const kind = endless ? 'continuous' : node.op === 'axis-turn@1' ? 'turn' : 'slide'
|
|
473
490
|
const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
|
|
474
491
|
const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
|
|
@@ -487,14 +504,17 @@ export function motionOf(asset: FigureAsset, part: string): Motion | undefined {
|
|
|
487
504
|
}
|
|
488
505
|
return undefined
|
|
489
506
|
}
|
|
490
|
-
const
|
|
491
|
-
|
|
507
|
+
const one = record?.travel && 'source' in record.travel ? record.travel : undefined
|
|
508
|
+
const travel = one?.source && 'part' in one.source ? { part: one.source.part, axis: one.source.axis, ...(one.plus ? { plus: one.plus } : {}) } : undefined
|
|
509
|
+
return { kind, axis, ...(vector ? { vector } : {}), control: control(quantity), ...(record?.mimic ? { follows: figureMimicTermsOf(record.mimic)! } : {}), ...(travel ? { travel } : {}) }
|
|
492
510
|
}
|
|
493
511
|
|
|
494
512
|
/** What the motion form holds: how the part moves, and either its own control or the joint it moves with. */
|
|
495
513
|
export interface MotionForm {
|
|
496
514
|
kind: 'slide' | 'turn' | 'continuous'
|
|
497
515
|
axis: Axis
|
|
516
|
+
/** An axis that is a vector (ADR-0107): said, not chosen here, and written back unchanged. */
|
|
517
|
+
axisVector?: JointAxisVector
|
|
498
518
|
id: string
|
|
499
519
|
min: number
|
|
500
520
|
max: number
|
|
@@ -508,6 +528,13 @@ export interface MotionForm {
|
|
|
508
528
|
*/
|
|
509
529
|
travel?: string
|
|
510
530
|
travelPlus?: number
|
|
531
|
+
/**
|
|
532
|
+
* Kept as they are and not edited here: a follower of several leaders (`{ sum, offset }`, the offset still in
|
|
533
|
+
* `offset`) and a stroke that is a sum of lengths. The form says them and writes them back unchanged, so saving
|
|
534
|
+
* a range or an axis does not turn a follower into a joint of its own or drop its stroke.
|
|
535
|
+
*/
|
|
536
|
+
followSum?: Array<{ joint: string; multiplier: number }>
|
|
537
|
+
travelSum?: Extract<NonNullable<FigureJoint['travel']>, { sum: unknown }>
|
|
511
538
|
}
|
|
512
539
|
|
|
513
540
|
/**
|
|
@@ -517,18 +544,27 @@ export interface MotionForm {
|
|
|
517
544
|
*/
|
|
518
545
|
export function motionFormOf(joint: FigureJoint): MotionForm {
|
|
519
546
|
const kind: MotionForm['kind'] = joint.type === 'prismatic' ? 'slide' : joint.lowerLimit === undefined && joint.upperLimit === undefined && !joint.mimic ? 'continuous' : 'turn'
|
|
520
|
-
const
|
|
547
|
+
const one = joint.travel && 'source' in joint.travel ? joint.travel : undefined
|
|
548
|
+
const travel = one?.source && 'part' in one.source ? `${one.source.part}:${one.source.axis}` : ''
|
|
549
|
+
const travelSum = joint.travel && 'sum' in joint.travel ? joint.travel : undefined
|
|
550
|
+
const terms = figureMimicTermsOf(joint.mimic)
|
|
551
|
+
const single = terms?.sum.length === 1 ? terms.sum[0] : undefined
|
|
552
|
+
const axisVector = jointAxisVectorOf(joint.axis)
|
|
521
553
|
return {
|
|
522
554
|
kind,
|
|
523
|
-
axis: (joint.axis ?? '
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
555
|
+
axis: jointAxisToken(joint.axis) ?? 'x',
|
|
556
|
+
...(axisVector ? { axisVector } : {}),
|
|
557
|
+
/* A moving joint with no state record is driven by the input named after it (figure-model `stateIdOf`): keep that name, so an edit does not rename the control a board or a controller writes to. */
|
|
558
|
+
id: joint.state?.id ?? (joint.type !== 'fixed' && !joint.mimic ? joint.id : ''),
|
|
559
|
+
min: travel || travelSum ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
|
|
560
|
+
max: travel || travelSum ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
|
|
561
|
+
follow: single?.joint ?? '',
|
|
562
|
+
multiplier: single?.multiplier ?? 1,
|
|
563
|
+
offset: terms?.offset ?? 0,
|
|
530
564
|
travel,
|
|
531
|
-
travelPlus:
|
|
565
|
+
travelPlus: one?.plus ?? 0,
|
|
566
|
+
...(terms && !single ? { followSum: terms.sum } : {}),
|
|
567
|
+
...(travelSum ? { travelSum } : {})
|
|
532
568
|
}
|
|
533
569
|
}
|
|
534
570
|
|
|
@@ -554,9 +590,11 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
|
|
|
554
590
|
/* A row keeps being a row when it is given a motion: dropped here, the row went with it (designer 2026-09-27). */
|
|
555
591
|
...(joint.repeat ? { repeat: joint.repeat } : {}),
|
|
556
592
|
type: m.kind === 'slide' ? ('prismatic' as const) : ('revolute' as const),
|
|
557
|
-
axis: m.axis.toUpperCase() as '
|
|
593
|
+
axis: (m.axisVector ?? m.axis.toUpperCase()) as FigureJoint['axis']
|
|
558
594
|
}
|
|
559
595
|
if (m.follow) return { ...moving, mimic: { joint: m.follow, multiplier: m.multiplier, ...(m.offset ? { offset: m.offset } : {}) } }
|
|
596
|
+
if (m.followSum) return { ...moving, mimic: { sum: m.followSum, ...(m.offset ? { offset: m.offset } : {}) } }
|
|
597
|
+
if (m.kind === 'slide' && m.travelSum && !m.travel) return { ...moving, travel: m.travelSum, state: { id: m.id || `${part}-${m.kind}` } }
|
|
560
598
|
if (m.kind === 'slide' && m.travel) {
|
|
561
599
|
const [of, axis] = m.travel.split(':')
|
|
562
600
|
return { ...moving, travel: { source: { of: 'part', part: of!, axis: axis as Axis }, ...(m.travelPlus ? { plus: m.travelPlus } : {}) }, state: { id: m.id || `${part}-${m.kind}` } }
|
|
@@ -564,6 +602,18 @@ export function motionJoint(joint: FigureJoint, m: MotionForm, part: string): Fi
|
|
|
564
602
|
return { ...moving, ...(m.kind === 'continuous' ? {} : { lowerLimit: m.min, upperLimit: m.max }), state: { id: m.id || `${part}-${m.kind}` } }
|
|
565
603
|
}
|
|
566
604
|
|
|
605
|
+
/**
|
|
606
|
+
* Why the motion form cannot be saved as it stands, as the kernel would refuse it: a follower's multiplier that is not
|
|
607
|
+
* a number or is 0, or a range whose most is not above its least. A range need not hold 0 -- the control starts at the
|
|
608
|
+
* nearest end (a lift that rests at 200 mm).
|
|
609
|
+
*/
|
|
610
|
+
export function motionFormRefusalOf(m: MotionForm, follow: string): 'multiplier' | 'range' | undefined {
|
|
611
|
+
if (m.followSum) return undefined
|
|
612
|
+
if (follow) return Number.isFinite(m.multiplier) && m.multiplier !== 0 ? undefined : 'multiplier'
|
|
613
|
+
if (m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))) return undefined
|
|
614
|
+
return Number.isFinite(m.min) && Number.isFinite(m.max) && m.max > m.min ? undefined : 'range'
|
|
615
|
+
}
|
|
616
|
+
|
|
567
617
|
/** The select's value for what a dimension follows now. */
|
|
568
618
|
export function choiceOf(follow: Follow): string {
|
|
569
619
|
switch (follow.of) {
|
|
@@ -39,7 +39,7 @@ export function recipeWorkings(recipe: FigureRecipe): Array<{ key: string; words
|
|
|
39
39
|
for (const m of d.moves)
|
|
40
40
|
lines.push(
|
|
41
41
|
m.follows
|
|
42
|
-
? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader:
|
|
42
|
+
? { key: 'figure.text.recipe-moves-with', words: { part: recipePartName(m.part), leader: recipeLeaders(m.follows).map(recipePartName).join(', ') } }
|
|
43
43
|
: { key: m.kind === 'slide' ? 'figure.text.recipe-slides' : 'figure.text.recipe-turns', words: { part: recipePartName(m.part) } }
|
|
44
44
|
)
|
|
45
45
|
if (d.controls.length) lines.push({ key: 'figure.text.recipe-controls', words: { count: d.controls.length } })
|
|
@@ -48,6 +48,11 @@ export function recipeWorkings(recipe: FigureRecipe): Array<{ key: string; words
|
|
|
48
48
|
return lines
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Released kernels return one leader; newer kernels can return an affine sum. */
|
|
52
|
+
function recipeLeaders(follows: string | { sum: Array<{ joint: string }> }): string[] {
|
|
53
|
+
return typeof follows === 'string' ? [follows] : follows.sum.map(term => term.joint)
|
|
54
|
+
}
|
|
55
|
+
|
|
51
56
|
/** A recipe's words in the language on screen: that language, else English, else the first it has. */
|
|
52
57
|
export function recipeWord(words: Record<string, string> | undefined, language: string): string {
|
|
53
58
|
if (!words) return ''
|