@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.
Files changed (68) hide show
  1. package/client/graphql/index.ts +6 -1
  2. package/client/modeller/editor.ts +5 -11
  3. package/client/modeller/fasten-geometry.ts +1 -1
  4. package/client/modeller/figure-links.ts +52 -17
  5. package/client/modeller/joint-axis.ts +36 -0
  6. package/client/modeller/joint-drag.ts +30 -10
  7. package/client/modeller/joint-fan.ts +2 -1
  8. package/client/modeller/links.ts +70 -20
  9. package/client/modeller/recipes.ts +6 -1
  10. package/client/pages/figure-list-page.ts +4 -4
  11. package/client/types.ts +1 -1
  12. package/client/viewparts/detail-motion.ts +11 -0
  13. package/client/viewparts/figure-asset-viewer.ts +177 -0
  14. package/client/viewparts/figure-detail-dock.ts +187 -0
  15. package/dist-client/graphql/index.d.ts +5 -0
  16. package/dist-client/graphql/index.js +5 -1
  17. package/dist-client/graphql/index.js.map +1 -1
  18. package/dist-client/modeller/clips.d.ts +1 -1
  19. package/dist-client/modeller/editor.d.ts +3 -2
  20. package/dist-client/modeller/editor.js +5 -11
  21. package/dist-client/modeller/editor.js.map +1 -1
  22. package/dist-client/modeller/fasten-geometry.js +1 -1
  23. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  24. package/dist-client/modeller/figure-links.d.ts +1 -0
  25. package/dist-client/modeller/figure-links.js +65 -33
  26. package/dist-client/modeller/figure-links.js.map +1 -1
  27. package/dist-client/modeller/joint-axis.d.ts +13 -0
  28. package/dist-client/modeller/joint-axis.js +31 -0
  29. package/dist-client/modeller/joint-axis.js.map +1 -0
  30. package/dist-client/modeller/joint-drag.js +29 -9
  31. package/dist-client/modeller/joint-drag.js.map +1 -1
  32. package/dist-client/modeller/joint-fan.js +2 -1
  33. package/dist-client/modeller/joint-fan.js.map +1 -1
  34. package/dist-client/modeller/links.d.ts +34 -4
  35. package/dist-client/modeller/links.js +59 -18
  36. package/dist-client/modeller/links.js.map +1 -1
  37. package/dist-client/modeller/recipes.js +5 -1
  38. package/dist-client/modeller/recipes.js.map +1 -1
  39. package/dist-client/pages/figure-list-page.d.ts +2 -3
  40. package/dist-client/pages/figure-list-page.js +4 -4
  41. package/dist-client/pages/figure-list-page.js.map +1 -1
  42. package/dist-client/route.d.ts +1 -1
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/types.d.ts +1 -1
  45. package/dist-client/types.js.map +1 -1
  46. package/dist-client/viewparts/detail-motion.d.ts +6 -0
  47. package/dist-client/viewparts/detail-motion.js +9 -0
  48. package/dist-client/viewparts/detail-motion.js.map +1 -0
  49. package/dist-client/viewparts/figure-asset-viewer.d.ts +31 -0
  50. package/dist-client/viewparts/figure-asset-viewer.js +198 -0
  51. package/dist-client/viewparts/figure-asset-viewer.js.map +1 -0
  52. package/dist-client/viewparts/figure-detail-dock.d.ts +36 -0
  53. package/dist-client/viewparts/figure-detail-dock.js +224 -0
  54. package/dist-client/viewparts/figure-detail-dock.js.map +1 -0
  55. package/package.json +6 -6
  56. package/test/browser/detail-page.js +34 -0
  57. package/test/browser/serve-detail.mjs +10 -0
  58. package/test/command-preview.test.ts +2 -2
  59. package/test/detail-motion.test.ts +26 -0
  60. package/test/joint-frames.test.ts +293 -0
  61. package/test/links.test.ts +20 -1
  62. package/test/modeller-route.test.ts +4 -2
  63. package/test/verdict.test.ts +3 -1
  64. package/translations/en.json +20 -2
  65. package/translations/ja.json +20 -2
  66. package/translations/ko.json +20 -2
  67. package/translations/ms.json +20 -2
  68. package/translations/zh.json +20 -2
@@ -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?: string }>(response, 'figureReleased')?.source ?? undefined
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(`figure.label.face-${j.body0.face}`)}`
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, a
1811
- * recipe, or an AI draft when the page offers one. Nothing is added until one is chosen; the type, the space, the
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 renderHeld(asset: FigureAsset) {
440
+ private heldWords(asset: FigureAsset) {
438
441
  const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId)
439
- const face = joint ? i18next.t(`figure.label.face-${joint.body1.face}`) : ''
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(`figure.label.face-${joint.body0.face}`) })
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> ${where}</p>
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 ?? 0 })}</small>`
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
- <select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
912
- ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
913
- </select>
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
- ${leaders.length
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
- ${follow
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
- <option value="" ?selected=${!m.travel}>${i18next.t('figure.label.motion-travel-mm')}</option>
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 || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.kind === 'slide' && m.travel) && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
959
- title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
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: string) => new THREE.Vector3(/x/i.test(axis) ? 1 : 0, /y/i.test(axis) ? 1 : 0, /z/i.test(axis) ? 1 : 0)
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 the joint sits in. */
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(AXIS_NAMES.map(a => (a.toUpperCase() === record.axis ? 1 : 0))))
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
  }
@@ -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
- return AXES.filter(axis => axis !== FIGURE_FACES[j.body1.face])
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 = 'part' in record.body0 ? record.body0.face : 'top'
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?: { joint: string; multiplier: number; offset?: number }
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
- const endless = record?.type === 'revolute' && record.lowerLimit === undefined && record.upperLimit === undefined
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 travel = record?.travel?.source && 'part' in record.travel.source ? { part: record.travel.source.part, axis: record.travel.source.axis, ...(record.travel.plus ? { plus: record.travel.plus } : {}) } : undefined
491
- return { kind, axis, control: control(quantity), ...(record?.mimic ? { follows: record.mimic } : {}), ...(travel ? { travel } : {}) }
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 travel = joint.travel?.source && 'part' in joint.travel.source ? `${joint.travel.source.part}:${joint.travel.source.axis}` : ''
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 ?? 'X').toLowerCase() as Axis,
524
- id: joint.state?.id ?? '',
525
- min: travel ? 0 : (joint.lowerLimit ?? (kind === 'slide' ? 0 : -90)),
526
- max: travel ? 500 : (joint.upperLimit ?? (kind === 'slide' ? 500 : 90)),
527
- follow: joint.mimic?.joint ?? '',
528
- multiplier: joint.mimic?.multiplier ?? 1,
529
- offset: joint.mimic?.offset ?? 0,
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: joint.travel?.plus ?? 0
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 'X' | 'Y' | 'Z'
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: recipePartName(m.follows) } }
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 ''