@things-factory/figure-ui 10.1.81 → 10.1.83

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 (148) hide show
  1. package/client/figure-assistant.ts +2 -2
  2. package/client/graphql/index.ts +99 -10
  3. package/client/graphql/reached.ts +25 -0
  4. package/client/graphql/store.ts +27 -4
  5. package/client/modeller/ai-proposal.ts +74 -9
  6. package/client/modeller/align.ts +2 -3
  7. package/client/modeller/canvas.ts +18 -5
  8. package/client/modeller/command-preview.ts +113 -0
  9. package/client/modeller/editor.ts +800 -53
  10. package/client/modeller/fields.ts +12 -4
  11. package/client/modeller/figure-facts.ts +2 -2
  12. package/client/modeller/figure-first-save.ts +36 -14
  13. package/client/modeller/figure-history-panel.ts +133 -14
  14. package/client/modeller/figure-links.ts +357 -94
  15. package/client/modeller/figure-parts.ts +56 -2
  16. package/client/modeller/figure-preview.ts +19 -1
  17. package/client/modeller/figure-properties.ts +3 -3
  18. package/client/modeller/figure-revert-choice.ts +49 -0
  19. package/client/modeller/first-save.ts +16 -0
  20. package/client/modeller/gizmo.ts +0 -16
  21. package/client/modeller/links.ts +142 -15
  22. package/client/modeller/motion-ends.ts +83 -0
  23. package/client/modeller/occupancy.ts +28 -2
  24. package/client/modeller/product-session.ts +154 -18
  25. package/client/modeller/refusals.ts +15 -0
  26. package/client/modeller/revert-request.ts +26 -0
  27. package/client/modeller/side-panel-styles.ts +12 -0
  28. package/client/modeller/stage-model.ts +25 -3
  29. package/client/pages/figure-modeller-page.ts +68 -29
  30. package/dist-client/figure-assistant.js +2 -2
  31. package/dist-client/figure-assistant.js.map +1 -1
  32. package/dist-client/graphql/index.d.ts +39 -2
  33. package/dist-client/graphql/index.js +63 -9
  34. package/dist-client/graphql/index.js.map +1 -1
  35. package/dist-client/graphql/reached.d.ts +10 -0
  36. package/dist-client/graphql/reached.js +28 -0
  37. package/dist-client/graphql/reached.js.map +1 -0
  38. package/dist-client/graphql/store.js +27 -4
  39. package/dist-client/graphql/store.js.map +1 -1
  40. package/dist-client/modeller/ai-proposal.d.ts +31 -2
  41. package/dist-client/modeller/ai-proposal.js +65 -6
  42. package/dist-client/modeller/ai-proposal.js.map +1 -1
  43. package/dist-client/modeller/align.d.ts +1 -1
  44. package/dist-client/modeller/align.js +0 -2
  45. package/dist-client/modeller/align.js.map +1 -1
  46. package/dist-client/modeller/canvas.d.ts +11 -0
  47. package/dist-client/modeller/canvas.js +33 -4
  48. package/dist-client/modeller/canvas.js.map +1 -1
  49. package/dist-client/modeller/command-preview.d.ts +35 -0
  50. package/dist-client/modeller/command-preview.js +95 -0
  51. package/dist-client/modeller/command-preview.js.map +1 -0
  52. package/dist-client/modeller/editor.d.ts +107 -1
  53. package/dist-client/modeller/editor.js +823 -49
  54. package/dist-client/modeller/editor.js.map +1 -1
  55. package/dist-client/modeller/fields.d.ts +3 -2
  56. package/dist-client/modeller/fields.js +10 -3
  57. package/dist-client/modeller/fields.js.map +1 -1
  58. package/dist-client/modeller/figure-capability-panel.d.ts +1 -1
  59. package/dist-client/modeller/figure-clips.d.ts +1 -1
  60. package/dist-client/modeller/figure-controls.d.ts +1 -1
  61. package/dist-client/modeller/figure-facts.js +2 -2
  62. package/dist-client/modeller/figure-facts.js.map +1 -1
  63. package/dist-client/modeller/figure-first-save.d.ts +3 -4
  64. package/dist-client/modeller/figure-first-save.js +41 -14
  65. package/dist-client/modeller/figure-first-save.js.map +1 -1
  66. package/dist-client/modeller/figure-history-panel.d.ts +31 -0
  67. package/dist-client/modeller/figure-history-panel.js +130 -14
  68. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  69. package/dist-client/modeller/figure-links.d.ts +52 -2
  70. package/dist-client/modeller/figure-links.js +366 -97
  71. package/dist-client/modeller/figure-links.js.map +1 -1
  72. package/dist-client/modeller/figure-material-panel.d.ts +1 -1
  73. package/dist-client/modeller/figure-parts.d.ts +7 -0
  74. package/dist-client/modeller/figure-parts.js +55 -2
  75. package/dist-client/modeller/figure-parts.js.map +1 -1
  76. package/dist-client/modeller/figure-preview.d.ts +6 -0
  77. package/dist-client/modeller/figure-preview.js +22 -1
  78. package/dist-client/modeller/figure-preview.js.map +1 -1
  79. package/dist-client/modeller/figure-properties.d.ts +1 -1
  80. package/dist-client/modeller/figure-properties.js +3 -3
  81. package/dist-client/modeller/figure-properties.js.map +1 -1
  82. package/dist-client/modeller/figure-revert-choice.d.ts +17 -0
  83. package/dist-client/modeller/figure-revert-choice.js +59 -0
  84. package/dist-client/modeller/figure-revert-choice.js.map +1 -0
  85. package/dist-client/modeller/first-save.d.ts +7 -0
  86. package/dist-client/modeller/first-save.js +17 -0
  87. package/dist-client/modeller/first-save.js.map +1 -1
  88. package/dist-client/modeller/gizmo.d.ts +0 -6
  89. package/dist-client/modeller/gizmo.js +0 -18
  90. package/dist-client/modeller/gizmo.js.map +1 -1
  91. package/dist-client/modeller/links.d.ts +49 -4
  92. package/dist-client/modeller/links.js +141 -15
  93. package/dist-client/modeller/links.js.map +1 -1
  94. package/dist-client/modeller/motion-ends.d.ts +12 -0
  95. package/dist-client/modeller/motion-ends.js +65 -0
  96. package/dist-client/modeller/motion-ends.js.map +1 -0
  97. package/dist-client/modeller/occupancy.d.ts +14 -1
  98. package/dist-client/modeller/occupancy.js +14 -1
  99. package/dist-client/modeller/occupancy.js.map +1 -1
  100. package/dist-client/modeller/product-session.d.ts +43 -2
  101. package/dist-client/modeller/product-session.js +146 -12
  102. package/dist-client/modeller/product-session.js.map +1 -1
  103. package/dist-client/modeller/refusals.d.ts +13 -0
  104. package/dist-client/modeller/refusals.js +16 -0
  105. package/dist-client/modeller/refusals.js.map +1 -1
  106. package/dist-client/modeller/revert-request.d.ts +16 -0
  107. package/dist-client/modeller/revert-request.js +11 -0
  108. package/dist-client/modeller/revert-request.js.map +1 -0
  109. package/dist-client/modeller/side-panel-styles.js +12 -0
  110. package/dist-client/modeller/side-panel-styles.js.map +1 -1
  111. package/dist-client/modeller/stage-model.d.ts +18 -2
  112. package/dist-client/modeller/stage-model.js +21 -3
  113. package/dist-client/modeller/stage-model.js.map +1 -1
  114. package/dist-client/pages/figure-modeller-page.d.ts +8 -1
  115. package/dist-client/pages/figure-modeller-page.js +72 -34
  116. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  117. package/dist-client/tsconfig.tsbuildinfo +1 -1
  118. package/package.json +5 -5
  119. package/test/ai-proposal.test.ts +2 -2
  120. package/test/align.test.ts +50 -5
  121. package/test/browser/acceptance-page.js +187 -0
  122. package/test/browser/build.mjs +5 -1
  123. package/test/browser/revert-page.js +47 -0
  124. package/test/browser/serve-acceptance.mjs +85 -0
  125. package/test/browser/serve-revert.mjs +119 -0
  126. package/test/clip-ai-proposal.test.ts +104 -0
  127. package/test/clip-consent.test.ts +88 -0
  128. package/test/command-preview.test.ts +288 -0
  129. package/test/fields.test.ts +4 -3
  130. package/test/figure-assistant-surface.test.ts +4 -1
  131. package/test/first-minute.test.ts +96 -0
  132. package/test/first-save-suggest.test.ts +42 -0
  133. package/test/gizmo.test.ts +20 -16
  134. package/test/joint-fan.test.ts +2 -1
  135. package/test/layout.test.ts +9 -2
  136. package/test/links.test.ts +9 -5
  137. package/test/proposal-record.test.ts +126 -0
  138. package/test/release-entry.test.ts +28 -0
  139. package/test/release-panel.test.ts +3 -2
  140. package/test/resize-with-placement.test.ts +226 -0
  141. package/test/revert-choice.test.ts +39 -0
  142. package/test/revert-keeping.test.ts +102 -0
  143. package/test/save-landed.test.ts +198 -0
  144. package/translations/en.json +229 -11
  145. package/translations/ja.json +229 -11
  146. package/translations/ko.json +230 -12
  147. package/translations/ms.json +228 -10
  148. package/translations/zh.json +229 -11
@@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from 'lit';
4
4
  import { customElement, property, state } from 'lit/decorators.js';
5
5
  import { i18next, localize } from '@operato/i18n';
6
6
  import { FIGURE_FACES } from '@hatiolab/figure-model';
7
- import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, fasteningOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './links.js';
7
+ import { acceptsOf, alignAxesOf, mountingPlaneOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, heldBy, followOf, hangsFrom, shownWhenOf, visibilityControlsOf, placeKeptOf, fasteningOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionFormOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, rowBlockOf, fixedJointOf, rowOf, positionsWorkedOut, fieldWordsOf, measureNow } from './links.js';
8
8
  import { SidePanelStyles } from './side-panel-styles.js';
9
9
  import { HelpToggles } from './help.js';
10
10
  const FACES = Object.keys(FIGURE_FACES);
@@ -17,7 +17,8 @@ const freshFasten = () => ({
17
17
  gap: 0,
18
18
  twist: 0,
19
19
  align: {},
20
- alignMm: {}
20
+ alignMm: {},
21
+ alignInput: {}
21
22
  });
22
23
  const freshMotion = () => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0, travel: '', travelPlus: 0 });
23
24
  const freshRow = () => ({ axis: '', pitch: 200, fills: true, count: 5 });
@@ -44,6 +45,23 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
44
45
  this.locked = false;
45
46
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
46
47
  this.section = 'shape';
48
+ /** What the fastening command would do, in words (the editor works it out: `command-preview.ts`). */
49
+ this.previewWords = [];
50
+ /** Where the selected part's motion takes it, at both ends of its range (the editor draws them on the canvas). */
51
+ this.motionEnds = null;
52
+ /** Past the edge of the face it is fastened to, or parts fastened to it past theirs (ADR-0097 §5.5, Q3). */
53
+ this.overhangWords = [];
54
+ /** Wider than the face it is fastened to: how far past each edge, and whether off to one side -- a fact, not a warning. */
55
+ this.overhangNote = [];
56
+ /** What taking this part off would do, said beside the buttons that do it (worked out on a copy by the editor). */
57
+ this.detachWords = [];
58
+ this.told = '';
59
+ /* Two forms describe a command here: the fastening and the row. The one being worked on is the one seen. */
60
+ this.working = 'fasten';
61
+ /** A new fastening starts with a target. Its face and alignment are asked only after that target is chosen. */
62
+ this.choosingFastening = false;
63
+ /** The fastening section, marked for a moment when "open attachment settings" brings it into view. */
64
+ this.spotlit = false;
47
65
  this.fasten = freshFasten();
48
66
  this.motion = freshMotion();
49
67
  this.place = freshPlace();
@@ -68,6 +86,29 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
68
86
  b[bound] {
69
87
  font-weight: 600;
70
88
  }
89
+ /* What each direction does now: read-only rows. */
90
+ div[resize-now] { display: grid; gap: 4px; margin: 12px 0; }
91
+ div[resize-now] div[row] { grid-template-columns: 1fr auto auto auto; align-items: center; gap: 8px; }
92
+ div[resize-now] span[measure] { font-variant-numeric: tabular-nums; }
93
+ button[link] { background: none; border: none; padding: 0; color: var(--md-sys-color-primary, #477b9e); text-decoration: underline; cursor: pointer; font: inherit; }
94
+ /* Size behaviour is a choice about dimensions only. It never implies a placement or a fastening. */
95
+ details[resize-choice] { margin: 12px 0; padding: 0; border: 0; }
96
+ details[resize-choice] > div { display: grid; gap: 8px; padding: 8px 0; }
97
+ div[resize-held] { display: grid; gap: 8px; margin: 12px 0; padding: 10px 12px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); }
98
+ div[resize-held] p { margin: 0; display: flex; align-items: center; gap: 6px; }
99
+ p[fasten-mode] { margin: 0; font-weight: 600; }
100
+ p[fasten-mode][editing] { color: var(--md-sys-color-primary, #477b9e); }
101
+ section[fastening][spotlit] { outline: 2px solid var(--md-sys-color-primary, #477b9e); outline-offset: 4px; border-radius: 6px; transition: outline-color 0.3s; }
102
+ details { margin-top: 14px; }
103
+ summary { cursor: pointer; padding: 8px 0; }
104
+ /* What the command would do, said beside the result drawn on the canvas. */
105
+ ul[preview-words] { margin: 10px 0; padding: 8px 12px 8px 26px; border-radius: 6px; background: var(--md-sys-color-surface-container, #8881); display: grid; gap: 4px; }
106
+ div[row-basis] { display: grid; gap: 4px; margin: 8px 0; }
107
+ div[row-basis] label { display: flex; align-items: center; gap: 6px; }
108
+ 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); }
109
+ div[overhang] p { margin: 0; }
110
+ ul[detach-words] { margin: 4px 0 10px; padding-left: 20px; display: grid; gap: 2px; font-size: 0.8rem; opacity: 0.85; }
111
+ span[fasten-summary] { opacity: 0.8; }
71
112
  /* The difference a following measure keeps: under its choice, on the same line as its words. */
72
113
  label[inline-plus] {
73
114
  display: flex;
@@ -79,12 +120,9 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
79
120
  min-width: 0;
80
121
  }
81
122
  `]; }
82
- /*
83
- The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
84
- last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
85
- */
86
- willUpdate(changed) {
87
- if (changed.has('partId') && changed.get('partId') !== undefined) {
123
+ willUpdate(_changed) {
124
+ if (this.asset && this.partId && this.partId !== this.loadedFor) {
125
+ this.loadedFor = this.partId;
88
126
  /* A part's own fastening is not another part's choices: the form opens on it (walk 1, 2026-09-26). */
89
127
  const own = this.asset && this.partId ? fasteningOf(this.asset, this.partId) : null;
90
128
  this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
@@ -92,10 +130,32 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
92
130
  this.place = freshPlace();
93
131
  this.row = freshRow();
94
132
  this.editingMotion = false;
133
+ this.choosingFastening = false;
95
134
  }
96
135
  }
136
+ updated() {
137
+ /* Only the section that holds the fastening form speaks of it: the panel has one of these per section, and the
138
+ others' silence cleared what it showed (2026-09-27, :3391). */
139
+ if (this.section !== 'shape' && !this.told)
140
+ return;
141
+ const said = JSON.stringify(this.candidate ?? null);
142
+ if (said === this.told)
143
+ return;
144
+ this.told = said;
145
+ this.dispatchEvent(new CustomEvent('preview-command', { detail: this.candidate ?? null, bubbles: true, composed: true }));
146
+ }
147
+ disconnectedCallback() {
148
+ super.disconnectedCallback();
149
+ /* The panel left: what it showed goes with it. */
150
+ if (this.told && this.told !== 'null')
151
+ this.dispatchEvent(new CustomEvent('preview-command', { detail: null, bubbles: true, composed: true }));
152
+ this.told = '';
153
+ }
97
154
  render() {
98
155
  const asset = this.asset;
156
+ this.candidate = undefined;
157
+ this.fastenCandidate = undefined;
158
+ this.rowCandidate = undefined;
99
159
  if (!asset || !this.partId)
100
160
  return nothing;
101
161
  if (this.section === 'motion')
@@ -104,18 +164,20 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
104
164
  if (this.section === 'places')
105
165
  return html `<section><h3>${i18next.t('figure.label.places-on-part', { part: this.partId })}${this.help.button('figure.text.place-explained')}</h3>${this.help.hint('figure.text.place-explained')}
106
166
  ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html `<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}</section>`;
107
- return html `
167
+ const shown = html `
108
168
  <section>
109
- <h3>${i18next.t('figure.label.dimensions')}</h3>
169
+ <h3>${i18next.t('figure.label.resize-intent')}</h3>
110
170
  ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
111
171
  </section>
112
- <section>
172
+ <section fastening ?spotlit=${this.spotlit} @focusin=${() => (this.working = 'fasten')}>
113
173
  <h3>${i18next.t('figure.label.placement')}${this.help.button('figure.text.fastening-explained')}</h3>${this.help.hint('figure.text.fastening-explained')}
114
174
  ${this.renderPositions(asset)}
115
175
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
116
176
  </section>
117
177
  ${this.renderRow(asset)}
118
178
  `;
179
+ this.candidate = this.working === 'row' && this.rowCandidate ? { action: this.rowCandidate } : this.fastenCandidate;
180
+ return shown;
119
181
  }
120
182
  /** Where things are held on this part: a seat things rest on, or a grip things are held in. */
121
183
  renderPlaces(asset) {
@@ -183,16 +245,36 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
183
245
  this.dispatchEvent(new CustomEvent('part-edit', { detail: { edit: { kind: 'set-capabilities', names } }, bubbles: true, composed: true }));
184
246
  }
185
247
  renderDimensions(asset) {
248
+ const editable = followableAxes(asset, this.partId);
249
+ const round = editable.length === 2;
250
+ const word = (axis) => i18next.t(round && axis === 'x' ? 'figure.label.resize-diameter' : `figure.label.resize-${axis}`);
186
251
  const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one));
187
- const round = followableAxes(asset, this.partId).length === 2;
188
- /* One step for the whole part (figure-model follow-size): the size is declared from the parts if there is none. */
189
- const followsAll = followableAxes(asset, this.partId).every(axis => followOf(asset, this.partId, axis).of === 'instance');
190
- const whole = followsAll
191
- ? nothing
192
- : html `<button act follow-size ?disabled=${this.locked} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
193
- <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
194
- </button>`;
195
- return html `${whole}${followableAxes(asset, this.partId).map(axis => {
252
+ const followsAll = editable.every(axis => followOf(asset, this.partId, axis).of === 'instance');
253
+ return html `<p hint>${i18next.t('figure.text.resize-intent')}</p>
254
+ ${asset.sizing === 'uniform' ? html `<p hint>${i18next.t('figure.text.resize-uniform-warning')}</p>` : nothing}
255
+ <div resize-now>${editable.map(axis => {
256
+ const now = followOf(asset, this.partId, axis);
257
+ const following = now.of === 'instance' && now.axis === axis;
258
+ const custom = now.of !== 'own' && !following;
259
+ const mm = measureNow(asset, this.partId, axis);
260
+ return html `<div row>
261
+ <b>${word(axis)}</b>
262
+ <span measure>${mm === undefined ? '—' : `${Math.round(mm)} mm`}</span>
263
+ <span>${i18next.t(custom ? 'figure.label.resize-custom' : following ? 'figure.label.resize-following' : 'figure.label.follows-nothing')}</span>
264
+ ${following
265
+ ? html `<button link resize-unfollow=${axis} ?disabled=${this.locked}
266
+ @click=${() => {
267
+ this.follow(asset, axis, 'own', now);
268
+ }}>${i18next.t('figure.button.resize-unfollow')}</button>`
269
+ : nothing}
270
+ </div>`;
271
+ })}</div>
272
+ ${followsAll
273
+ ? html `<p hint>${i18next.t('figure.text.resize-all-following')}</p>`
274
+ : html `<button act follow-size ?disabled=${this.locked || asset.sizing === 'uniform'} @click=${() => this.command({ kind: 'follow-size', part: this.partId })}>
275
+ <md-icon>open_in_full</md-icon>${i18next.t('figure.button.follow-size')}
276
+ </button>`}
277
+ <details resize-choice><summary>${i18next.t('figure.label.resize-advanced')}</summary><div>${followableAxes(asset, this.partId).map(axis => {
196
278
  const now = followOf(asset, this.partId, axis);
197
279
  const value = choiceOf(now);
198
280
  /* A cylinder's two measures are its radius and its length; its X and Y name them. */
@@ -226,7 +308,65 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
226
308
  </label>`
227
309
  : nothing}
228
310
  </div>`;
229
- })}`;
311
+ })}</div></details>`;
312
+ }
313
+ /**
314
+ * "Open attachment settings": the fastening this part has now -- its face, what it is on, the gap -- in the form below,
315
+ * brought into view with the focus on it. The form is loaded again first: the part was fastened by "follow size,
316
+ * attached" while it was selected, so the form still held what it had before (designer 2026-09-27: it opened the
317
+ * holding places instead).
318
+ */
319
+ /** The fastening's details open, focus in them: where the gap and the place across the face are changed. */
320
+ async openDetail() {
321
+ const detail = this.renderRoot.querySelector('details[fasten-detail]');
322
+ if (!detail)
323
+ return;
324
+ detail.open = true;
325
+ this.working = 'fasten';
326
+ await this.updateComplete;
327
+ detail.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
328
+ detail.querySelector('select, input')?.focus({ preventScroll: true });
329
+ }
330
+ /** The fastening form, open on this part and in view: for "fasten it first" from elsewhere. */
331
+ async showFastening() {
332
+ if (this.asset)
333
+ await this.openFastening(this.asset);
334
+ }
335
+ async openFastening(asset) {
336
+ const own = fasteningOf(asset, this.partId);
337
+ this.fasten = own ? { ...freshFasten(), ...own } : freshFasten();
338
+ this.spotlit = true;
339
+ await this.updateComplete;
340
+ const section = this.renderRoot.querySelector('section[fastening]');
341
+ section?.scrollIntoView({ block: 'start', behavior: 'smooth' });
342
+ (section?.querySelector('select, input') ?? section?.querySelector('button'))?.focus({ preventScroll: true });
343
+ setTimeout(() => (this.spotlit = false), 1600);
344
+ }
345
+ /**
346
+ * What holds the part, said as its state -- 「아랫면을 바닥에 유지 중」 -- with the ways to change it: open the fastening,
347
+ * or take it off. Right after "follow size, attached" this is the result, not a refusal (designer 2026-09-27).
348
+ */
349
+ renderHeld(asset) {
350
+ const joint = (asset.joints ?? []).find(j => j.body1.part === this.partId);
351
+ const face = joint ? i18next.t(`figure.label.face-${joint.body1.face}`) : '';
352
+ const gap = typeof joint?.origin?.gap === 'number' ? joint.origin.gap : 0;
353
+ const where = !joint
354
+ ? i18next.t('figure.text.held-by-rule')
355
+ : 'plane' in joint.body0
356
+ ? i18next.t('figure.text.held-on-plane', { face, place: i18next.t(`figure.label.resize-place-${asset.placement ?? 'floor'}`), gap: String(gap) })
357
+ : i18next.t('figure.text.held-on-part', { face, other: joint.body0.part, otherFace: i18next.t(`figure.label.face-${joint.body0.face}`) });
358
+ return html `<div resize-held>
359
+ <p><md-icon>link</md-icon> ${where}</p>
360
+ <button act open-fastening @click=${() => this.openFastening(asset)}>
361
+ ${i18next.t('figure.button.open-fastening')}
362
+ </button>
363
+ ${joint
364
+ ? html `<button act detach-held ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
365
+ ${i18next.t('figure.button.detach-held')}
366
+ </button>
367
+ ${this.renderDetachWords()}`
368
+ : nothing}
369
+ </div>`;
230
370
  }
231
371
  /** The same source, the same share, a new difference: a leaf the opening less its gaps (link-dimension's `plus`). */
232
372
  followPlus(axis, now, plus) {
@@ -286,13 +426,15 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
286
426
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
287
427
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
288
428
  const aim = f.toPlane ? 'plane' : f.target ? { part: f.target, face: targetFace } : undefined;
289
- this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {} }) };
429
+ this.fasten = { ...f, ...(aim ? placeKeptOf(asset, this.partId, aim, !!f.twist) : { align: {}, alignMm: {}, alignInput: {} }) };
290
430
  }
291
431
  renderFastening(asset) {
292
432
  const held = heldBy(asset, this.partId);
293
433
  const f = this.fasten;
294
434
  /* Not a part that hangs from this one: fastening a frame to its own roller would close a loop. */
295
- const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one) && !hangsFrom(asset, one, this.partId));
435
+ const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
436
+ /* Shown, not dropped, and not choosable, with the reason (ADR-0097 §5.5): fastening it would close a loop. */
437
+ const loops = (one) => hangsFrom(asset, one, this.partId);
296
438
  /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
297
439
  /* On the mounting plane only the faces it takes: a bottom or a top on the floor, a back on a wall. */
298
440
  const plane = mountingPlaneOf(asset);
@@ -305,70 +447,124 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
305
447
  const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
306
448
  const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
307
449
  const aim = f.toPlane ? 'plane' : { part: f.target, face: targetFace };
308
- const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined;
450
+ const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist, f.alignInput) : undefined;
451
+ const action = f.toPlane || f.target
452
+ ? {
453
+ kind: 'attach',
454
+ part: this.partId,
455
+ face: myFace,
456
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
457
+ ...(f.toPlane ? {} : { facing: f.facing }),
458
+ ...(f.gap ? { gap: f.gap } : {}),
459
+ twist: f.twist,
460
+ ...(align ? { align } : {}),
461
+ replace: !!held
462
+ }
463
+ : undefined;
464
+ if (action)
465
+ this.fastenCandidate = { action, ...(f.toPlane ? {} : { face: { part: f.target, face: targetFace } }) };
466
+ /* What the details hold that is not their default, and where on the face it sits: said while they are folded. */
467
+ const detail = [
468
+ ...(f.facing === 'flush' && !f.toPlane ? [i18next.t('figure.label.facing-flush')] : []),
469
+ ...Object.entries(align ?? {}).map(([axis, one]) => one === 'min' || one === 'max'
470
+ ? i18next.t(`figure.text.fasten-summary-edge-${one}`, { axis: axis.toUpperCase() })
471
+ : typeof one.mm === 'number'
472
+ ? i18next.t('figure.text.fasten-summary-off-centre', { axis: axis.toUpperCase(), mm: String(Math.round(one.mm * 10) / 10) })
473
+ : i18next.t('figure.text.fasten-summary-follows', { axis: axis.toUpperCase() })),
474
+ ...(f.gap ? [i18next.t('figure.text.fasten-summary-gap', { mm: String(f.gap) })] : []),
475
+ ...(f.twist ? [i18next.t('figure.text.fasten-summary-twist', { degrees: String(f.twist) })] : [])
476
+ ];
477
+ /*
478
+ Editing the fastening it has, or fastening it anew: said, with the relation named, so the form is not taken for a
479
+ blank new one while it holds the current (designer 2026-09-27). Applied unchanged, the same fastening is written
480
+ again, fields and all (resize-with-placement.test).
481
+ */
482
+ const current = asset.joints?.find(j => j.body1.part === this.partId);
483
+ const editing = current
484
+ ? i18next.t('figure.text.fasten-editing', {
485
+ face: i18next.t(`figure.label.face-${current.body1.face}`),
486
+ target: 'plane' in current.body0 ? i18next.t('figure.label.mounting-plane') : `${current.body0.part} · ${i18next.t(`figure.label.face-${current.body0.face}`)}`
487
+ })
488
+ : i18next.t('figure.text.fasten-new');
489
+ /* A blank attachment is not a form to decipher. Ask for the thing to attach to first. */
490
+ if (!current && !this.choosingFastening && !f.target)
491
+ return html `
492
+ <p hint>${i18next.t('figure.text.fasten-start')}</p>
493
+ <button act ?disabled=${this.locked} @click=${() => (this.choosingFastening = true)}>
494
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten-start')}
495
+ </button>
496
+ `;
309
497
  return html `
498
+ <p fasten-mode ?editing=${!!current}>${editing}</p>
499
+ ${this.overhangNote.length ? html `<p hint overhang-note>${this.overhangNote.join(' ')}</p>` : nothing}
500
+ ${this.overhangWords.length
501
+ ? html `<div overhang role="status">
502
+ ${this.overhangWords.map(line => html `<p>${line}</p>`)}
503
+ ${current
504
+ ? html `<button act @click=${() => this.openDetail()}><md-icon>tune</md-icon>${i18next.t('figure.button.fix-alignment')}</button>`
505
+ : nothing}
506
+ </div>`
507
+ : nothing}
310
508
  ${held
311
509
  ? html `<div pair>
312
510
  <span>${i18next.t('figure.label.fastened-to')}: <b bound>${'plane' in held ? i18next.t('figure.label.mounting-plane') : held.part}</b></span>
313
511
  <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
314
512
  <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
315
513
  </button>
316
- </div>`
514
+ </div>
515
+ ${this.renderDetachWords()}`
317
516
  : nothing}
318
- <div form>
319
- <span>${i18next.t('figure.label.my-face')}</span>
320
- <select @change=${(e) => (this.fasten = { ...f, face: e.target.value })}>
321
- ${mineFaces.map(face => html `<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
322
- </select>
323
- </div>
324
- <label toggle>
325
- <input type="checkbox" .checked=${f.toPlane} @change=${(e) => this.aimAt(asset, { ...f, toPlane: e.target.checked })} />
326
- ${i18next.t('figure.label.stands-on-plane')}
327
- </label>
328
517
  ${f.toPlane
518
+ ? html `<p hint>${i18next.t('figure.text.fasten-edit-on-plane')}</p>`
519
+ : html `<div form>
520
+ <span>${i18next.t('figure.label.target-part')}</span>
521
+ <select @change=${(e) => this.aimAt(asset, { ...f, target: e.target.value })}>
522
+ <option value="" ?selected=${!f.target}>${i18next.t('figure.label.target-part-placeholder')}</option>
523
+ ${targets.map(one => loops(one)
524
+ ? html `<option value=${one} disabled>${i18next.t('figure.text.target-would-loop', { part: one, mine: this.partId })}</option>`
525
+ : html `<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
526
+ </select>
527
+ </div>
528
+ ${f.target
529
+ ? html `<div form>
530
+ <span>${i18next.t('figure.label.my-face')}</span>
531
+ <select @change=${(e) => (this.fasten = { ...f, face: e.target.value })}>
532
+ ${mineFaces.map(face => html `<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
533
+ </select>
534
+ <span>${i18next.t('figure.label.target-face')}</span>
535
+ <select @change=${(e) => this.aimAt(asset, { ...f, targetFace: e.target.value })}>
536
+ ${theirFaces.map(face => html `<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
537
+ </select>
538
+ </div>`
539
+ : html `<p hint>${i18next.t('figure.text.fasten-target-first')}</p>`}`}
540
+ ${(f.toPlane || f.target) ? html `<details fasten-detail>
541
+ <summary>${i18next.t('figure.label.fasten-detail')} · <span fasten-summary>${detail.length ? detail.join(' · ') : i18next.t('figure.text.fasten-summary-default')}</span></summary>
542
+ ${f.toPlane
329
543
  ? nothing
330
544
  : html `<div form>
331
- <span>${i18next.t('figure.label.target-part')}</span>
332
- <select @change=${(e) => this.aimAt(asset, { ...f, target: e.target.value })}>
333
- <option value="" ?selected=${!f.target}>—</option>
334
- ${targets.map(one => html `<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
335
- </select>
336
- <span>${i18next.t('figure.label.target-face')}</span>
337
- <select @change=${(e) => this.aimAt(asset, { ...f, targetFace: e.target.value })}>
338
- ${theirFaces.map(face => html `<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
339
- </select>
340
- <span>${i18next.t('figure.label.facing-meet')}</span>
341
- <select @change=${(e) => (this.fasten = { ...f, facing: e.target.value })}>
342
- <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
343
- <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
344
- </select>
345
- </div>`}
346
- ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
347
- <div form>
348
- <span>${i18next.t('figure.label.gap')}</span>
349
- <input type="number" step="any" .value=${String(f.gap)} @change=${(e) => (this.fasten = { ...f, gap: Number(e.target.value) || 0 })} />
350
- <span>${i18next.t('figure.label.twist')}</span>
351
- <input type="number" step="any" .value=${String(f.twist)} @change=${(e) => (this.fasten = { ...f, twist: Number(e.target.value) || 0 })} />
352
- </div>
353
- <button
354
- act
355
- ?disabled=${this.locked || (!f.toPlane && !f.target)}
356
- @click=${() => this.command({
357
- kind: 'attach',
358
- part: this.partId,
359
- face: myFace,
360
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
361
- ...(f.toPlane ? {} : { facing: f.facing }),
362
- ...(f.gap ? { gap: f.gap } : {}),
363
- twist: f.twist,
364
- ...(align ? { align } : {}),
365
- replace: !!held
366
- })}
367
- >
368
- <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
369
- </button>
545
+ <span>${i18next.t('figure.label.facing')}</span>
546
+ <select @change=${(e) => (this.fasten = { ...f, facing: e.target.value })}>
547
+ <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
548
+ <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
549
+ </select>
550
+ </div>`}
551
+ ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
552
+ <div form>
553
+ <span>${i18next.t('figure.label.gap')}</span>
554
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e) => (this.fasten = { ...f, gap: Number(e.target.value) || 0 })} />
555
+ <span>${i18next.t('figure.label.twist')}</span>
556
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e) => (this.fasten = { ...f, twist: Number(e.target.value) || 0 })} />
557
+ </div>
558
+ </details>
559
+ ${action && this.working === 'fasten' ? this.renderPreviewWords() : nothing}
560
+ <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
561
+ <md-icon>link</md-icon>${i18next.t(current ? 'figure.button.fasten-update' : 'figure.button.fasten')}
562
+ </button>` : nothing}
370
563
  `;
371
564
  }
565
+ renderDetachWords() {
566
+ return this.detachWords.length ? html `<ul detach-words>${this.detachWords.map(line => html `<li>${line}</li>`)}</ul>` : nothing;
567
+ }
372
568
  /**
373
569
  * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
374
570
  * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
@@ -387,6 +583,9 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
387
583
  ? html `<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
388
584
  <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
389
585
  : nothing}
586
+ ${f.alignInput?.[axis]
587
+ ? html `<option value="input" ?selected=${how === 'input'}>${i18next.t('figure.label.align-input', { field: fieldWordsOf(f.alignInput[axis], key => i18next.t(key)) })}</option>`
588
+ : nothing}
390
589
  <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
391
590
  ${this.asset && hasInstanceSize(this.asset, axis)
392
591
  ? html `<option value="share" ?selected=${how === 'share'}>${i18next.t('figure.label.align-share')}</option>`
@@ -403,38 +602,59 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
403
602
  * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
404
603
  * (the editor's) shows it and takes it away.
405
604
  */
605
+ renderPreviewWords() {
606
+ return this.previewWords.length ? html `<ul preview-words role="status">${this.previewWords.map(line => html `<li>${line}</li>`)}</ul>` : nothing;
607
+ }
608
+ /**
609
+ * A row (ADR-0097 §5.5): said as what it makes, "arranged in a row". Where it cannot be made here, why, rather than
610
+ * nothing. Filling the length, the centre spacing is the author's; a count of their own is spread evenly over the
611
+ * length -- figure-model draws it so, and the form does not ask for a spacing it would not use.
612
+ */
406
613
  renderRow(asset) {
407
- const axes = rowAxesOf(asset, this.partId);
408
- if (!axes.length)
614
+ if (rowOf(asset, this.partId))
409
615
  return nothing;
616
+ const blocked = rowBlockOf(asset, this.partId);
617
+ const axes = rowAxesOf(asset, this.partId);
410
618
  const r = this.row;
619
+ if (blocked || !axes.length)
620
+ return html `<section row>
621
+ <h3>${i18next.t('figure.label.repeat')}</h3>
622
+ <p hint row-blocked>${i18next.t(`figure.text.row-blocked-${blocked ?? 'unfastened'}`, { part: this.partId })}</p>
623
+ ${blocked === 'unfastened'
624
+ ? html `<button act @click=${() => this.openFastening(asset)}><md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}</button>`
625
+ : nothing}
626
+ </section>`;
411
627
  const axis = axes.includes(r.axis) ? r.axis : axes[0];
412
- const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1));
413
- return html `<section>
628
+ const ok = r.fills ? r.pitch > 0 : Number.isSafeInteger(r.count) && r.count >= 1;
629
+ const action = ok
630
+ ? { kind: 'make-repeat', part: this.partId, axis, pitch: r.fills ? r.pitch : Math.max(r.pitch, 1), ...(r.fills ? {} : { count: r.count }) }
631
+ : undefined;
632
+ this.rowCandidate = action;
633
+ const set = (next) => ((this.row = next), (this.working = 'row'));
634
+ return html `<section row @focusin=${() => (this.working = 'row')}>
414
635
  <h3>${i18next.t('figure.label.repeat')}</h3>
415
636
  <div form>
416
637
  <span>${i18next.t('figure.label.repeat-axis')}</span>
417
- <select @change=${(e) => (this.row = { ...r, axis: e.target.value })}>
638
+ <select @change=${(e) => set({ ...r, axis: e.target.value })}>
418
639
  ${axes.map(a => html `<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
419
640
  </select>
420
- <span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
421
- <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e) => (this.row = { ...r, pitch: Number(e.target.value) })} />
422
641
  </div>
423
- <label toggle>
424
- <input type="checkbox" .checked=${r.fills} @change=${(e) => (this.row = { ...r, fills: e.target.checked })} />
425
- ${i18next.t('figure.label.repeat-fills')}
426
- </label>
642
+ <div row-basis role="radiogroup" aria-label=${i18next.t('figure.label.row-basis')}>
643
+ <label><input type="radio" name="row-basis" .checked=${r.fills} @change=${() => set({ ...r, fills: true })} />${i18next.t('figure.label.repeat-fills')}</label>
644
+ <label><input type="radio" name="row-basis" .checked=${!r.fills} @change=${() => set({ ...r, fills: false })} />${i18next.t('figure.label.row-by-count')}</label>
645
+ </div>
427
646
  ${r.fills
428
- ? nothing
647
+ ? html `<div form>
648
+ <span>${i18next.t('figure.label.repeat-pitch')} · mm</span>
649
+ <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e) => set({ ...r, pitch: Number(e.target.value) })} />
650
+ </div>`
429
651
  : html `<div form>
430
- <span>${i18next.t('figure.label.repeat-count')}</span>
431
- <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e) => (this.row = { ...r, count: Number(e.target.value) })} />
432
- </div>`}
433
- <button
434
- act
435
- ?disabled=${this.locked || !ok}
436
- @click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) })}
437
- >
652
+ <span>${i18next.t('figure.label.repeat-count')}</span>
653
+ <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e) => set({ ...r, count: Number(e.target.value) })} />
654
+ </div>
655
+ <p hint>${i18next.t('figure.text.row-count-spreads')}</p>`}
656
+ ${action && this.working === 'row' ? this.renderPreviewWords() : nothing}
657
+ <button act ?disabled=${this.locked || !action} @click=${() => action && this.command(action)}>
438
658
  <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
439
659
  </button>
440
660
  </section>`;
@@ -486,7 +706,21 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
486
706
  const now = motionOf(asset, this.partId);
487
707
  const joint = asset.joints?.find(j => j.body1.part === this.partId);
488
708
  if (now && !(this.editingMotion && joint)) {
489
- return html `<div pair>
709
+ /* Which part moves, against what, which way, how far -- by name (ADR-0097 §5.5). */
710
+ const against = joint ? ('plane' in joint.body0 ? i18next.t('figure.label.mounting-plane') : joint.body0.part) : '';
711
+ const ends = this.motionEnds;
712
+ return html `<p motion-summary>
713
+ ${i18next.t('figure.text.motion-summary', {
714
+ part: this.partId,
715
+ against,
716
+ kind: i18next.t('figure.label.motion-' + now.kind),
717
+ axis: String(now.axis).toUpperCase()
718
+ })}
719
+ </p>
720
+ ${ends
721
+ ? html `<p hint motion-ends>${i18next.t('figure.text.motion-ends-shown', { min: String(ends.min), max: String(ends.max), unit: ends.unit })}</p>`
722
+ : nothing}
723
+ <div pair>
490
724
  <span>
491
725
  ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
492
726
  ${now.travel
@@ -501,7 +735,7 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
501
735
  <button
502
736
  act
503
737
  ?disabled=${this.locked || !joint}
504
- @click=${() => joint && this.command({ kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1, ...(joint.origin ? { origin: joint.origin } : {}) } })}
738
+ @click=${() => joint && this.command({ kind: 'set-joint', joint: fixedJointOf(joint) })}
505
739
  >
506
740
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
507
741
  </button>
@@ -521,7 +755,10 @@ let FigureLinks = class FigureLinks extends localize(i18next)(LitElement) {
521
755
  }
522
756
  /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
523
757
  if (!joint)
524
- return html `<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>`;
758
+ return html `<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>
759
+ <button act go-fasten @click=${() => this.dispatchEvent(new CustomEvent('go-fasten', { bubbles: true, composed: true }))}>
760
+ <md-icon>link</md-icon>${i18next.t('figure.button.go-fasten')}
761
+ </button>`;
525
762
  const m = this.motion;
526
763
  const unit = m.kind === 'slide' ? 'mm' : 'deg';
527
764
  /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
@@ -622,6 +859,38 @@ __decorate([
622
859
  property(),
623
860
  __metadata("design:type", String)
624
861
  ], FigureLinks.prototype, "section", void 0);
862
+ __decorate([
863
+ property({ attribute: false }),
864
+ __metadata("design:type", Array)
865
+ ], FigureLinks.prototype, "previewWords", void 0);
866
+ __decorate([
867
+ property({ attribute: false }),
868
+ __metadata("design:type", Object)
869
+ ], FigureLinks.prototype, "motionEnds", void 0);
870
+ __decorate([
871
+ property({ attribute: false }),
872
+ __metadata("design:type", Array)
873
+ ], FigureLinks.prototype, "overhangWords", void 0);
874
+ __decorate([
875
+ property({ attribute: false }),
876
+ __metadata("design:type", Array)
877
+ ], FigureLinks.prototype, "overhangNote", void 0);
878
+ __decorate([
879
+ property({ attribute: false }),
880
+ __metadata("design:type", Array)
881
+ ], FigureLinks.prototype, "detachWords", void 0);
882
+ __decorate([
883
+ state(),
884
+ __metadata("design:type", String)
885
+ ], FigureLinks.prototype, "working", void 0);
886
+ __decorate([
887
+ state(),
888
+ __metadata("design:type", Object)
889
+ ], FigureLinks.prototype, "choosingFastening", void 0);
890
+ __decorate([
891
+ state(),
892
+ __metadata("design:type", Object)
893
+ ], FigureLinks.prototype, "spotlit", void 0);
625
894
  __decorate([
626
895
  state(),
627
896
  __metadata("design:type", Object)