@things-factory/figure-ui 10.1.208 → 10.1.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/client/modeller/data-interface.ts +5 -5
  2. package/client/modeller/editor.ts +97 -88
  3. package/client/modeller/fasten-geometry.ts +2 -1
  4. package/client/modeller/figure-links.ts +87 -157
  5. package/client/modeller/figure-size-editor.ts +233 -0
  6. package/client/modeller/joint-drag.ts +25 -21
  7. package/client/modeller/joint-fan.ts +4 -3
  8. package/client/modeller/links.ts +87 -36
  9. package/client/modeller/live.ts +33 -0
  10. package/client/modeller/motion-ends.ts +10 -6
  11. package/client/modeller/size-response.ts +95 -0
  12. package/dist-client/modeller/data-interface.d.ts +1 -1
  13. package/dist-client/modeller/data-interface.js +5 -5
  14. package/dist-client/modeller/data-interface.js.map +1 -1
  15. package/dist-client/modeller/editor.d.ts +14 -1
  16. package/dist-client/modeller/editor.js +103 -89
  17. package/dist-client/modeller/editor.js.map +1 -1
  18. package/dist-client/modeller/fasten-geometry.js +1 -1
  19. package/dist-client/modeller/fasten-geometry.js.map +1 -1
  20. package/dist-client/modeller/figure-links.d.ts +3 -4
  21. package/dist-client/modeller/figure-links.js +91 -145
  22. package/dist-client/modeller/figure-links.js.map +1 -1
  23. package/dist-client/modeller/figure-size-editor.d.ts +29 -0
  24. package/dist-client/modeller/figure-size-editor.js +262 -0
  25. package/dist-client/modeller/figure-size-editor.js.map +1 -0
  26. package/dist-client/modeller/joint-drag.d.ts +1 -1
  27. package/dist-client/modeller/joint-drag.js +27 -22
  28. package/dist-client/modeller/joint-drag.js.map +1 -1
  29. package/dist-client/modeller/joint-fan.js +4 -3
  30. package/dist-client/modeller/joint-fan.js.map +1 -1
  31. package/dist-client/modeller/links.d.ts +21 -9
  32. package/dist-client/modeller/links.js +85 -35
  33. package/dist-client/modeller/links.js.map +1 -1
  34. package/dist-client/modeller/live.d.ts +14 -0
  35. package/dist-client/modeller/live.js +31 -0
  36. package/dist-client/modeller/live.js.map +1 -1
  37. package/dist-client/modeller/motion-ends.d.ts +4 -1
  38. package/dist-client/modeller/motion-ends.js +10 -6
  39. package/dist-client/modeller/motion-ends.js.map +1 -1
  40. package/dist-client/modeller/size-response.d.ts +29 -0
  41. package/dist-client/modeller/size-response.js +99 -0
  42. package/dist-client/modeller/size-response.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/docs/evidence/2026-10-24/current-size-panel.png +0 -0
  45. package/docs/evidence/2026-10-24/integrated-size.png +0 -0
  46. package/docs/evidence/2026-10-24/multipart-response.png +0 -0
  47. package/docs/evidence/2026-10-24/repeat-layout.png +0 -0
  48. package/docs/evidence/2026-10-24/repeat-shared-style.png +0 -0
  49. package/docs/evidence/2026-10-24/size-in-layout.png +0 -0
  50. package/docs/evidence/2026-10-24/size-shared-style.png +0 -0
  51. package/docs/evidence/2026-10-24/width-preview.png +0 -0
  52. package/docs/multipart-response-review-2026-10-24.md +77 -0
  53. package/docs/response-authoring-2026-10-24.md +46 -0
  54. package/docs/size-and-repeat-ux-2026-10-24.md +67 -0
  55. package/package.json +7 -7
  56. package/test/attachment-size-contract.test.ts +47 -0
  57. package/test/browser/acceptance-page.js +1 -1
  58. package/test/browser/detail-page.js +1 -1
  59. package/test/browser/detail-tabs-page.js +1 -1
  60. package/test/browser/editing-page.js +1 -1
  61. package/test/browser/editing.check.mjs +66 -14
  62. package/test/browser/links.check.mjs +41 -22
  63. package/test/browser/modeller-page.js +1 -1
  64. package/test/browser/modeller-revision.check.mjs +21 -7
  65. package/test/browser/modeller.check.mjs +106 -39
  66. package/test/browser/multipart-response-page.js +8 -0
  67. package/test/browser/parts.check.mjs +2 -1
  68. package/test/browser/product-page.js +1 -1
  69. package/test/browser/props.check.mjs +17 -14
  70. package/test/browser/response-page.js +12 -0
  71. package/test/browser/row-preview.check.mjs +3 -3
  72. package/test/browser/screen.check.mjs +57 -13
  73. package/test/browser/serve-response.mjs +24 -0
  74. package/test/clip-ai-proposal.test.ts +1 -1
  75. package/test/clip-consent.test.ts +2 -2
  76. package/test/command-preview.test.ts +7 -7
  77. package/test/joint-drag.test.ts +6 -6
  78. package/test/joint-frames.test.ts +33 -32
  79. package/test/joints.ts +1 -6
  80. package/test/links.test.ts +60 -20
  81. package/test/multipart-response-fixture.js +17 -0
  82. package/test/multipart-response.test.ts +37 -0
  83. package/test/refusals.test.ts +2 -2
  84. package/test/resize-with-placement.test.ts +23 -1
  85. package/test/size-response.test.ts +49 -0
  86. package/test/size-source-pairing.test.ts +66 -0
  87. package/translations/en.json +47 -10
  88. package/translations/es.json +47 -10
  89. package/translations/ja.json +47 -10
  90. package/translations/ko.json +52 -15
  91. package/translations/ms.json +47 -10
  92. package/translations/zh.json +47 -10
@@ -3,24 +3,20 @@ import '@material/web/icon/icon.js'
3
3
  import { css, html, LitElement, nothing } from 'lit'
4
4
  import '@operato/input/ox-input-select-buttons.js'
5
5
  import './figure-slot-layout-panel.js'
6
+ import './figure-size-editor.js'
6
7
  import { customElement, property, state } from 'lit/decorators.js'
7
8
 
8
9
  import { i18next, localize } from '@operato/i18n'
9
- import { figureFaceOverhangOf } from '@hatiolab/figure-model'
10
- import type { FigureAsset, FigureAuthoringAction, FigureDimensionSource, FigureFace } from '@hatiolab/figure-model'
10
+ import { figureFaceOverhangOf, figureMotionsOf, motionIdOf } from '@hatiolab/figure-model'
11
+ import type { FigureAsset, FigureAuthoringAction, FigureFace } from '@hatiolab/figure-model'
11
12
 
12
13
  import {
13
14
  acceptsOf,
14
15
  AXES,
15
16
  canFasten,
16
17
  canFollow,
17
- followCommandOf,
18
- followableAxes,
19
18
  positionIsShare,
20
- choiceOf,
21
- followWordsOf,
22
19
  heldBy,
23
- followOf,
24
20
  shownWhenOf,
25
21
  visibilityControlsOf,
26
22
  fasteningOf,
@@ -32,6 +28,8 @@ import {
32
28
  motionFormRefusalOf,
33
29
  endPlaceKeyOf,
34
30
  motionJoint,
31
+ controlIdFor,
32
+ withoutAxis,
35
33
  placeFacesOf,
36
34
  placeIdFor,
37
35
  placesOn,
@@ -40,9 +38,7 @@ import {
40
38
  fixedJointOf,
41
39
  rowOf,
42
40
  positionsWorkedOut,
43
- measureNow,
44
41
  type Axis,
45
- type Follow,
46
42
  type MotionForm
47
43
  } from './links.js'
48
44
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
@@ -113,8 +109,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
113
109
  font-weight: 600;
114
110
  }
115
111
  /* What each direction does now: read-only rows. */
116
- div[resize-now] { display: grid; gap: 4px; margin: 12px 0; }
117
- div[resize-now] div[row] { grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px; }
112
+ div[resize-now] { display: grid; grid-template-columns: 1fr auto auto auto; gap: 4px 8px; margin: 12px 0; }
113
+ div[resize-now] div[row] { grid-column: 1 / -1; grid-template-columns: subgrid; align-items: center; }
118
114
  div[resize-now] span[measure] { font-variant-numeric: tabular-nums; }
119
115
  /* Size behaviour is a choice about dimensions only. It never implies a placement or a fastening. */
120
116
  /* One of the size group's rows: the group's gap spaces it, as it does the rest (a margin on top of it left a hole). */
@@ -124,13 +120,11 @@ export class FigureLinks extends localize(i18next)(LitElement) {
124
120
  details { margin-top: 8px; }
125
121
  details[row-fold] { margin: 0; }
126
122
  details[row-fold] > summary { padding: 0; color: var(--md-sys-color-on-surface); }
127
- details[row-fold] > summary h3 { margin: 0; }
123
+ details[row-fold] > summary h3 { margin: 0; flex: 1; }
128
124
  details[row-fold][open] > summary { margin-bottom: 8px; }
129
125
  /* What the command would do, said beside the result drawn on the canvas. */
130
126
  ul[preview-words] { margin: 8px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
131
127
  p[preview-words] { margin: 8px 0; padding: 8px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
132
- div[row-basis] { display: grid; gap: 4px; margin: 8px 0; }
133
- div[row-basis] label { display: flex; align-items: center; gap: 6px; }
134
128
  div[overhang] { display: grid; gap: 6px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; border: 1px solid var(--md-sys-color-outline-variant, #8884); }
135
129
  div[overhang] p { margin: 0; }
136
130
  ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; }
@@ -191,6 +185,8 @@ export class FigureLinks extends localize(i18next)(LitElement) {
191
185
  @state() private row = freshRow()
192
186
  /** The motion form is open over the part's own motion, to change it where it is. */
193
187
  @state() private editingMotion = false
188
+ /** Which of the joint's axes the form is over (ADR-0159); one past the last is a new axis. */
189
+ @state() private motionAxis = 0
194
190
 
195
191
  /*
196
192
  The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
@@ -209,7 +205,10 @@ export class FigureLinks extends localize(i18next)(LitElement) {
209
205
  this.motion = freshMotion()
210
206
  this.place = freshPlace()
211
207
  this.row = freshRow()
208
+ this.rowOpen = false
209
+ this.rowCandidate = undefined
212
210
  this.editingMotion = false
211
+ this.motionAxis = 0
213
212
  }
214
213
  }
215
214
 
@@ -273,13 +272,14 @@ export class FigureLinks extends localize(i18next)(LitElement) {
273
272
  How the part answers a size it is placed at (user 2026-10-17, the 「반응형」 tab): its own measures, where it
274
273
  stands, and the row it may make -- what Build Once, Fit Anywhere is authored with, in one place.
275
274
  */
276
- const positions = positionsWorkedOut(asset, this.partId)
275
+ const positions = held ? [] : positionsWorkedOut(asset, this.partId)
276
+ this.rowCandidate = undefined
277
277
  const response = html`
278
- <section size-response>
278
+ ${canFollow(asset, this.partId) ? html`<section size>
279
279
  <h3>${i18next.t('figure.label.group-size')}</h3>
280
- <p group-question>${i18next.t('figure.label.resize-intent')}</p>
281
- ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.part-cannot-follow')}</p>`}
282
- </section>
280
+ <figure-size-editor .revision=${this.revision} .partId=${this.partId} ?locked=${this.locked}></figure-size-editor>
281
+ <slot name="size-note"></slot>
282
+ </section>` : nothing}
283
283
  ${positions.length
284
284
  ? html`<section position-response>
285
285
  <h3>${i18next.t('figure.label.group-position-response')}</h3>
@@ -292,10 +292,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
292
292
  return response
293
293
  }
294
294
  const shown = html`
295
- <section size>
296
- <h3>${i18next.t('figure.label.group-size')}</h3>
297
- <slot name="size"></slot>
298
- </section>
295
+ <slot name="size"></slot>
299
296
  <section fastening>
300
297
  <h3>
301
298
  ${i18next.t('figure.label.group-place')}${this.help.button('figure.text.fastening-explained')}
@@ -379,80 +376,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
379
376
  this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit: { kind: 'set-capabilities', names } }, bubbles: true, composed: true }))
380
377
  }
381
378
 
382
- private renderDimensions(asset: FigureAsset) {
383
- const editable = followableAxes(asset, this.partId)
384
- const round = editable.length === 2
385
- const word = (axis: Axis) => i18next.t(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`)
386
- const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
387
- const followsAll = editable.every(axis => followOf(asset, this.partId, axis).of === 'instance')
388
- /* A size of its own on every axis is the usual: nothing to read, only the way to make it follow (user 2026-10-01). */
389
- const allOwn = editable.every(axis => followOf(asset, this.partId, axis).of === 'own')
390
- return html`${asset.sizing === 'uniform' ? html`<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
391
- ${allOwn ? nothing : html`<div resize-now>${editable.map(axis => {
392
- const now = followOf(asset, this.partId, axis)
393
- const following = now.of === 'instance' && now.axis === axis
394
- const custom = now.of !== 'own' && !following
395
- const mm = measureNow(this.revision!, this.partId, axis)
396
- return html`<div row>
397
- <b>${word(axis)}</b>
398
- <span measure>${mm === undefined ? '—' : `${Math.round(mm)} mm`}</span>
399
- <span>${custom ? followWordsOf(now, (key, params) => i18next.t(key, params)) : i18next.t(following ? 'figure.label.resize-following' : 'figure.label.follows-nothing')}</span>
400
- ${following
401
- ? html`<button quiet resize-unfollow=${axis} ?disabled=${this.locked}
402
- @click=${() => {
403
- this.follow(axis, 'own', now)
404
- }}>${i18next.t('figure.button.resize-unfollow')}</button>`
405
- : nothing}
406
- </div>`
407
- })}</div>`}
408
- ${followsAll
409
- ? nothing
410
- : html`<button act follow-size ?disabled=${this.locked || asset.sizing === 'uniform'} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
411
- <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
412
- </button>`}
413
- <details resize-choice><summary>${i18next.t('figure.label.resize-advanced')}</summary><div>${followableAxes(asset, this.partId).map(axis => {
414
- const now = followOf(asset, this.partId, axis)
415
- const value = choiceOf(now)
416
- /* A cylinder's two measures are its radius and its length; its X and Y name them. */
417
- const name = round ? i18next.t(axis === 'x' ? 'figure.label.field-radius' : 'figure.label.field-length') : axis.toUpperCase()
418
- return html`<div row>
419
- <b>${name}</b>
420
- <select ?disabled=${this.locked} @change=${(e: Event) => this.follow(axis, (e.target as HTMLSelectElement).value, now)}>
421
- <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
422
- ${AXES.filter(one => hasInstanceSize(asset, one)).map(
423
- one => html`<option value=${`share:${one}`} ?selected=${value === `share:${one}`}>
424
- ${i18next.t('figure.label.follows-instance-share', { axis: one.toUpperCase() })}
425
- </option>
426
- <option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
427
- ${i18next.t('figure.label.follows-instance', { axis: one.toUpperCase() })}
428
- </option>`
429
- )}
430
- ${others.flatMap(other =>
431
- AXES.map(
432
- one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${i18next.t('figure.label.follows-part-axis', { part: other, axis: one.toUpperCase() })}</option>`
433
- )
434
- )}
435
- ${now.of === 'expression' || now.of === 'input'
436
- ? html`<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
437
- : nothing}
438
- </select>
439
- ${now.of === 'instance' || now.of === 'part'
440
- ? html`<span></span>
441
- <label inline-plus>
442
- ${i18next.t('figure.label.follows-plus')}
443
- <input
444
- type="number"
445
- step="any"
446
- ?disabled=${this.locked}
447
- .value=${String(now.plus ?? 0)}
448
- @change=${(e: Event) => this.followPlus(axis, now, Number((e.target as HTMLInputElement).value) || 0)}
449
- />
450
- </label>`
451
- : nothing}
452
- </div>`
453
- })}</div></details>`
454
- }
455
-
456
379
  /**
457
380
  * 정렬·간격 고치기: the part moved inside the face it is fastened on, as one step -- the least move on each side it is
458
381
  * out, or centred when it is wider than the face. When nothing of it can be moved, why is said under the button, not
@@ -503,20 +426,6 @@ export class FigureLinks extends localize(i18next)(LitElement) {
503
426
  return where
504
427
  }
505
428
 
506
- /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
507
- private followPlus(axis: Axis, now: Follow, plus: number) {
508
- if (now.of !== 'instance' && now.of !== 'part') return
509
- if ((now.plus ?? 0) === plus) return
510
- const source: FigureDimensionSource = now.of === 'instance' ? { of: 'instance', axis: now.axis } : { of: 'part', part: now.part, axis: now.axis }
511
- const times = now.of === 'instance' ? now.times : (now.times ?? 1)
512
- this.command({ kind: 'link-dimension', part: this.partId, axis, source, ...(times !== 1 ? { times } : {}), ...(plus ? { plus } : {}), replace: true })
513
- }
514
-
515
- private follow(axis: Axis, choice: string, now: Follow) {
516
- const action = followCommandOf(this.revision!, this.partId, axis, choice, now)
517
- if (action) this.command(action)
518
- }
519
-
520
429
  /**
521
430
  * Positions worked out from other values, each with the way to
522
431
  * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
@@ -617,9 +526,9 @@ export class FigureLinks extends localize(i18next)(LitElement) {
617
526
  Folded, open while it is being worked (2026-10-02): a row is made now and then, and most parts stand on the plane,
618
527
  where the section said its reason open on every one. Opened, it says why or asks how.
619
528
  */
620
- const head = html`<summary><h3>${i18next.t('figure.label.repeat')}</h3></summary>`
529
+ const head = html`<summary><h3>${i18next.t('figure.label.repeat')} <span section-state>${i18next.t('figure.label.repeat-inactive')}</span></h3></summary>`
621
530
  if (blocked || !axes.length)
622
- return html`<section row><details row-fold>
531
+ return html`<section row><details row-fold open>
623
532
  ${head}
624
533
  <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
625
534
  ${blocked === 'unfastened'
@@ -640,27 +549,29 @@ export class FigureLinks extends localize(i18next)(LitElement) {
640
549
  const toggled = (e: Event) => (this.rowOpen = (e.target as HTMLDetailsElement).open)
641
550
  return html`<section row><details row-fold ?open=${this.rowOpen} @toggle=${toggled}>
642
551
  ${head}
552
+ <p hint>${i18next.t('figure.text.repeat-intro')}</p>
643
553
  <div form>
644
554
  <span>${i18next.t('figure.label.repeat-axis')}</span>
645
- <select @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
555
+ <select aria-label=${i18next.t('figure.label.repeat-axis')} ?disabled=${this.locked} @change=${(e: Event) => set({ ...r, axis: (e.target as HTMLSelectElement).value as Axis })}>
646
556
  ${axes.map(a => html`<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
647
557
  </select>
648
558
  </div>
649
- <div row-basis role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
650
- <label><input type="radio" name="row-basis" .checked=${r.fills} @change=${() => set({ ...r, fills: true })} />${i18next.t('figure.label.repeat-fills')}</label>
651
- <label><input type="radio" name="row-basis" .checked=${!r.fills} @change=${() => set({ ...r, fills: false })} />${i18next.t('figure.label.row-by-count')}</label>
559
+ <p hint>${i18next.t('figure.text.repeat-direction')}</p>
560
+ <div choice-row role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
561
+ <label toggle><input type="radio" name="row-basis" ?disabled=${this.locked} .checked=${r.fills} @change=${() => set({ ...r, fills: true })} />${i18next.t('figure.label.repeat-fills')}</label>
562
+ <label toggle><input type="radio" name="row-basis" ?disabled=${this.locked} .checked=${!r.fills} @change=${() => set({ ...r, fills: false })} />${i18next.t('figure.label.row-by-count')}</label>
652
563
  </div>
653
564
  ${r.fills
654
565
  ? html`<div form>
655
566
  <span>${i18next.t('figure.label.repeat-pitch')} · mm</span>
656
- <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => set({ ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
567
+ <input aria-label=${i18next.t('figure.label.repeat-pitch')} ?disabled=${this.locked} type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e: Event) => set({ ...r, pitch: Number((e.target as HTMLInputElement).value) })} />
657
568
  </div>`
658
569
  : html`<div form>
659
570
  <span>${i18next.t('figure.label.repeat-count')}</span>
660
- <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => set({ ...r, count: Number((e.target as HTMLInputElement).value) })} />
571
+ <input aria-label=${i18next.t('figure.label.repeat-count')} ?disabled=${this.locked} type="number" step="1" min="1" .value=${String(r.count)} @change=${(e: Event) => set({ ...r, count: Number((e.target as HTMLInputElement).value) })} />
661
572
  </div>
662
573
  <p hint>${i18next.t('figure.text.row-count-spreads')}</p>`}
663
- ${action && this.rowOpen ? this.renderPreviewWords() : nothing}
574
+ ${action && this.rowOpen ? html`<p hint>${i18next.t('figure.text.repeat-draft')}</p>${this.renderPreviewWords()}` : nothing}
664
575
  <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
665
576
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
666
577
  </button>
@@ -717,6 +628,43 @@ export class FigureLinks extends localize(i18next)(LitElement) {
717
628
  /* Which part moves, against what, which way, how far -- by name (ADR-0097 §5.5). */
718
629
  const against = joint ? ('plane' in joint.body0 ? i18next.t('figure.label.mounting-plane') : joint.body0.part) : ''
719
630
  const ends = this.motionEnds
631
+ /* One row an axis (ADR-0159): how it moves and by what, changed or taken away where it is; more added under them. */
632
+ const count = joint ? figureMotionsOf(joint).length : 1
633
+ const edit = (k: number) => {
634
+ if (!joint) return
635
+ this.motionAxis = k
636
+ this.motion = motionFormOf(joint, k)
637
+ this.editingMotion = true
638
+ }
639
+ const row = (k: number) => {
640
+ const it = k === 0 ? now : motionOf(asset, this.partId, k)
641
+ if (!it) return nothing
642
+ return html`<div pair motion-axis=${k}>
643
+ <span>
644
+ ${count > 1 ? html`<b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b> ` : nothing}${i18next.t('figure.label.motion-' + it.kind)} · ${it.vector ? jointAxisWord(it.vector) : String(it.axis).toUpperCase()}
645
+ ${it.travel
646
+ ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: it.travel.part, axis: it.travel.axis.toUpperCase(), plus: it.travel.plus ?? 0 })}</small>`
647
+ : nothing}
648
+ ${it.follows?.sum.length === 1
649
+ ? html`<small>${i18next.t('figure.label.motion-follows', { joint: it.follows.sum[0]!.joint, multiplier: it.follows.sum[0]!.multiplier, offset: it.follows.offset })}</small>`
650
+ : it.follows
651
+ ? html`<small>${i18next.t('figure.label.motion-follow')}: ${it.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${it.follows.offset}</small>`
652
+ : it.control
653
+ ? html`<small>${it.control.id} (${it.control.unit}) ${it.control.min} … ${it.control.max}</small>`
654
+ : nothing}
655
+ </span>
656
+ ${count > 1
657
+ ? html`<button act remove-axis ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: withoutAxis(joint, k) })}>
658
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-axis')}
659
+ </button>`
660
+ : html`<button act ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}>
661
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
662
+ </button>`}
663
+ <button act ?disabled=${this.locked || !joint} @click=${() => edit(k)}>
664
+ <md-icon>edit</md-icon>${i18next.t('figure.button.change-motion')}
665
+ </button>
666
+ </div>`
667
+ }
720
668
  return html`<p motion-summary>
721
669
  ${i18next.t('figure.text.motion-summary', {
722
670
  part: this.partId,
@@ -728,39 +676,17 @@ export class FigureLinks extends localize(i18next)(LitElement) {
728
676
  ${ends
729
677
  ? html`<p hint motion-ends>${i18next.t('figure.text.motion-ends-shown', { min: String(ends.min), max: String(ends.max), unit: ends.unit })}</p>`
730
678
  : nothing}
679
+ ${Array.from({ length: count }, (_, k) => row(k))}
731
680
  <div pair>
732
- <span>
733
- ${i18next.t('figure.label.motion-' + now.kind)} · ${now.vector ? jointAxisWord(now.vector) : String(now.axis).toUpperCase()}
734
- ${now.travel
735
- ? html`<small>${i18next.t('figure.label.motion-travel-is', { part: now.travel.part, axis: now.travel.axis.toUpperCase(), plus: now.travel.plus ?? 0 })}</small>`
681
+ <button act add-axis ?disabled=${this.locked || !joint} @click=${() => edit(count)}>
682
+ <md-icon>add</md-icon>${i18next.t('figure.button.add-axis')}
683
+ </button>
684
+ ${count > 1
685
+ ? html`<button act ?disabled=${this.locked || !joint} @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}>
686
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
687
+ </button>`
736
688
  : nothing}
737
- ${now.follows?.sum.length === 1
738
- ? 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>`
739
- : now.follows
740
- ? html`<small>${i18next.t('figure.label.motion-follow')}: ${now.follows.sum.map(t => `${t.joint} × ${t.multiplier}`).join(' + ')} + ${now.follows.offset}</small>`
741
- : now.control
742
- ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
743
- : nothing}
744
- </span>
745
- <button
746
- act
747
- ?disabled=${this.locked || !joint}
748
- @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}
749
- >
750
- <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
751
- </button>
752
- <button
753
- act
754
- ?disabled=${this.locked || !joint}
755
- @click=${() => {
756
- if (!joint) return
757
- this.motion = motionFormOf(joint)
758
- this.editingMotion = true
759
- }}
760
- >
761
- <md-icon>edit</md-icon>${i18next.t('figure.button.change-motion')}
762
- </button>
763
- </div>`
689
+ </div>`
764
690
  }
765
691
  /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
766
692
  if (!joint)
@@ -771,10 +697,13 @@ export class FigureLinks extends localize(i18next)(LitElement) {
771
697
  const m = this.motion
772
698
  const unit = m.kind === 'slide' ? 'mm' : 'deg'
773
699
  /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
774
- const leaders = leadersFor(asset, this.partId, m.kind)
700
+ const k = this.motionAxis
701
+ const leaders = leadersFor(asset, this.partId, m.kind, motionIdOf(joint, k))
702
+ const adding = k > 0 && k >= figureMotionsOf(joint).length
775
703
  const follow = leaders.some(j => j.id === m.follow) ? m.follow : ''
776
704
  const refusal = motionFormRefusalOf(m, follow)
777
705
  return html`
706
+ ${k > 0 ? html`<p motion-axis-heading><b>${i18next.t('figure.label.motion-axis-n', { n: k + 1 })}</b></p>` : nothing}
778
707
  <div pair>
779
708
  <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn' | 'continuous')}>
780
709
  <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
@@ -799,7 +728,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
799
728
  <span>${i18next.t('figure.label.motion-follow')}</span>
800
729
  <select @change=${(e: Event) => (this.motion = { ...m, follow: (e.target as HTMLSelectElement).value })}>
801
730
  <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
802
- ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.body1.part}${j.state ? ` · ${j.state.id}` : ''}</option>`)}
731
+ ${leaders.map(j => html`<option value=${j.id} ?selected=${j.id === follow}>${j.part}${j.state?.id ? ` · ${j.state.id}` : j.id !== j.part ? ` · ${j.id}` : ''}</option>`)}
803
732
  </select>
804
733
  </div>`
805
734
  : nothing}
@@ -830,7 +759,7 @@ export class FigureLinks extends localize(i18next)(LitElement) {
830
759
  : nothing}`
831
760
  : nothing}
832
761
  <span>${i18next.t('figure.label.control-id')}</span>
833
- <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
762
+ <input .value=${m.id} placeholder=${controlIdFor(this.partId, m.kind, k)} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
834
763
  ${m.kind === 'continuous' || (m.kind === 'slide' && (m.travel || m.travelSum))
835
764
  ? nothing
836
765
  : html`<span>${i18next.t('figure.label.control-range')} (${unit})</span>
@@ -844,14 +773,15 @@ export class FigureLinks extends localize(i18next)(LitElement) {
844
773
  ?disabled=${this.locked || !!refusal}
845
774
  title=${refusal === 'range' ? i18next.t('figure.text.range-needs-room') : ''}
846
775
  @click=${() => {
847
- this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })
776
+ this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId, k) })
848
777
  this.editingMotion = false
778
+ this.motionAxis = 0
849
779
  }}
850
780
  >
851
- <md-icon>animation</md-icon>${i18next.t(this.editingMotion ? 'figure.button.apply-motion-change' : 'figure.button.add-motion')}
781
+ <md-icon>animation</md-icon>${i18next.t(adding ? 'figure.button.add-axis' : this.editingMotion ? 'figure.button.apply-motion-change' : 'figure.button.add-motion')}
852
782
  </button>
853
783
  ${this.editingMotion
854
- ? html`<button act @click=${() => (this.editingMotion = false)}><md-icon>undo</md-icon>${i18next.t('figure.button.cancel-motion-change')}</button>`
784
+ ? html`<button act @click=${() => ((this.editingMotion = false), (this.motionAxis = 0))}><md-icon>undo</md-icon>${i18next.t('figure.button.cancel-motion-change')}</button>`
855
785
  : nothing}
856
786
  `
857
787
  }