@things-factory/figure-ui 10.1.49 → 10.1.51

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 (97) hide show
  1. package/client/graphql/v3-store.ts +2 -1
  2. package/client/modeller/figure-history-panel.ts +9 -1
  3. package/client/modeller/figure-v3-first-save.ts +98 -0
  4. package/client/modeller/figure-v3-links.ts +177 -31
  5. package/client/modeller/figure-v3-parts.ts +1 -0
  6. package/client/modeller/figure-v3-properties.ts +2 -1
  7. package/client/modeller/v3-canvas.ts +61 -7
  8. package/client/modeller/v3-editor.ts +96 -21
  9. package/client/modeller/v3-first-save.ts +34 -0
  10. package/client/modeller/v3-gizmo.ts +52 -3
  11. package/client/modeller/v3-joint-drag.ts +116 -0
  12. package/client/modeller/v3-joint-fan.ts +24 -11
  13. package/client/modeller/v3-keys.ts +12 -1
  14. package/client/modeller/v3-links.ts +119 -8
  15. package/client/modeller/v3-live.ts +11 -0
  16. package/client/modeller/v3-product-session.ts +4 -2
  17. package/client/modeller/v3-refusals.ts +21 -0
  18. package/client/pages/figure-v3-modeller-page.ts +57 -8
  19. package/dist-client/graphql/v3-store.js +2 -1
  20. package/dist-client/graphql/v3-store.js.map +1 -1
  21. package/dist-client/modeller/figure-history-panel.js +9 -1
  22. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  23. package/dist-client/modeller/figure-v3-capability.d.ts +1 -1
  24. package/dist-client/modeller/figure-v3-clips.d.ts +1 -1
  25. package/dist-client/modeller/figure-v3-controls.d.ts +1 -1
  26. package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
  27. package/dist-client/modeller/figure-v3-first-save.js +118 -0
  28. package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
  29. package/dist-client/modeller/figure-v3-links.d.ts +14 -1
  30. package/dist-client/modeller/figure-v3-links.js +169 -28
  31. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  32. package/dist-client/modeller/figure-v3-material.d.ts +1 -1
  33. package/dist-client/modeller/figure-v3-parts.js +1 -0
  34. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  35. package/dist-client/modeller/figure-v3-properties.d.ts +1 -1
  36. package/dist-client/modeller/figure-v3-properties.js +2 -1
  37. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  38. package/dist-client/modeller/v3-canvas.d.ts +11 -0
  39. package/dist-client/modeller/v3-canvas.js +61 -7
  40. package/dist-client/modeller/v3-canvas.js.map +1 -1
  41. package/dist-client/modeller/v3-clips.d.ts +1 -1
  42. package/dist-client/modeller/v3-editor.d.ts +13 -1
  43. package/dist-client/modeller/v3-editor.js +96 -21
  44. package/dist-client/modeller/v3-editor.js.map +1 -1
  45. package/dist-client/modeller/v3-first-save.d.ts +17 -0
  46. package/dist-client/modeller/v3-first-save.js +27 -0
  47. package/dist-client/modeller/v3-first-save.js.map +1 -0
  48. package/dist-client/modeller/v3-gizmo.d.ts +18 -0
  49. package/dist-client/modeller/v3-gizmo.js +45 -2
  50. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  51. package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
  52. package/dist-client/modeller/v3-joint-drag.js +97 -0
  53. package/dist-client/modeller/v3-joint-drag.js.map +1 -0
  54. package/dist-client/modeller/v3-joint-fan.js +24 -11
  55. package/dist-client/modeller/v3-joint-fan.js.map +1 -1
  56. package/dist-client/modeller/v3-keys.d.ts +9 -1
  57. package/dist-client/modeller/v3-keys.js +3 -0
  58. package/dist-client/modeller/v3-keys.js.map +1 -1
  59. package/dist-client/modeller/v3-links.d.ts +72 -3
  60. package/dist-client/modeller/v3-links.js +89 -6
  61. package/dist-client/modeller/v3-links.js.map +1 -1
  62. package/dist-client/modeller/v3-live.d.ts +6 -0
  63. package/dist-client/modeller/v3-live.js +10 -0
  64. package/dist-client/modeller/v3-live.js.map +1 -1
  65. package/dist-client/modeller/v3-product-session.d.ts +4 -1
  66. package/dist-client/modeller/v3-product-session.js +2 -2
  67. package/dist-client/modeller/v3-product-session.js.map +1 -1
  68. package/dist-client/modeller/v3-refusals.d.ts +4 -0
  69. package/dist-client/modeller/v3-refusals.js +21 -0
  70. package/dist-client/modeller/v3-refusals.js.map +1 -0
  71. package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
  72. package/dist-client/pages/figure-v3-modeller-page.js +56 -8
  73. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  74. package/dist-client/tsconfig.tsbuildinfo +1 -1
  75. package/package.json +7 -5
  76. package/test/browser/build.mjs +62 -0
  77. package/test/browser/modeller-page.js +198 -0
  78. package/test/browser/modeller.check.mjs +358 -0
  79. package/test/browser/serve.mjs +43 -0
  80. package/test/joints.ts +33 -0
  81. package/test/v3-clips.test.ts +3 -2
  82. package/test/v3-controls.test.ts +3 -2
  83. package/test/v3-fields.test.ts +3 -2
  84. package/test/v3-first-save.test.ts +84 -0
  85. package/test/v3-gizmo.test.ts +38 -5
  86. package/test/v3-joint-drag.test.ts +93 -0
  87. package/test/v3-joint-fan.test.ts +16 -6
  88. package/test/v3-keys.test.ts +15 -2
  89. package/test/v3-links.test.ts +114 -14
  90. package/test/v3-live.test.ts +23 -4
  91. package/test/v3-maturity.test.ts +2 -6
  92. package/test/v3-refusals.test.ts +28 -0
  93. package/translations/en.json +40 -5
  94. package/translations/ja.json +39 -4
  95. package/translations/ko.json +39 -4
  96. package/translations/ms.json +39 -4
  97. package/translations/zh.json +39 -4
@@ -0,0 +1,118 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import { LitElement, html, css, nothing } from 'lit';
3
+ import { customElement, property, state } from 'lit/decorators.js';
4
+ import { i18next } from '@operato/i18n';
5
+ import { closePopup } from '@operato/popup';
6
+ import '@operato/popup/ox-popup-actions.js';
7
+ import { CommonPopupStyles, FormFieldStyles } from '@operato/styles';
8
+ import '@material/web/button/filled-button.js';
9
+ import '@material/web/button/text-button.js';
10
+ import { firstSaveRefusalOf, releasableType, typeOf } from './v3-first-save.js';
11
+ /**
12
+ * The first save of a new figure: its type and its name, asked once (V2's save dialog, in the suite's popup look).
13
+ * Closes with the answer, or with nothing when cancelled -- then nothing is saved.
14
+ */
15
+ let FigureV3FirstSave = class FigureV3FirstSave extends LitElement {
16
+ constructor() {
17
+ super(...arguments);
18
+ /** The type the figure has so far -- empty for a temporary one, which is not offered. */
19
+ this.type = '';
20
+ this.name = '';
21
+ /** Type names already used in this domain. */
22
+ this.taken = [];
23
+ this.refusal = null;
24
+ this.onKey = (e) => {
25
+ if (e.key === 'Enter' && !e.isComposing)
26
+ this.confirm();
27
+ };
28
+ this.confirm = () => {
29
+ this.refusal = firstSaveRefusalOf(this.type, this.taken);
30
+ if (this.refusal)
31
+ return;
32
+ closePopup(this, { type: this.type, name: this.name.trim() || this.type });
33
+ };
34
+ }
35
+ static { this.styles = [
36
+ FormFieldStyles,
37
+ CommonPopupStyles,
38
+ css `
39
+ /* The popup's height is a share of the window (small is 40%): on a low window the buttons must still be reached. */
40
+ :host {
41
+ overflow-y: auto;
42
+ }
43
+ div[field] {
44
+ display: grid;
45
+ gap: 4px;
46
+ }
47
+ div[field] label {
48
+ font-size: var(--popup-font-size-small);
49
+ color: var(--md-sys-color-on-surface-variant);
50
+ }
51
+ div[field] input {
52
+ box-sizing: border-box;
53
+ width: 100%;
54
+ }
55
+ input[type-field] {
56
+ font-family: var(--mono-font, monospace);
57
+ }
58
+ `
59
+ ]; }
60
+ render() {
61
+ return html `
62
+ <div class="what">${i18next.t('figure.text.save-new-figure-desc')}</div>
63
+ <div field>
64
+ <label for="type">${i18next.t('figure.label.figure-type')}</label>
65
+ <input
66
+ id="type"
67
+ type-field
68
+ autofocus
69
+ .value=${this.type}
70
+ placeholder=${i18next.t('figure.text.figure-type-example')}
71
+ @input=${(e) => {
72
+ this.type = typeOf(e.target.value);
73
+ e.target.value = this.type;
74
+ this.refusal = null;
75
+ }}
76
+ @keydown=${this.onKey}
77
+ />
78
+ ${this.type && !releasableType(this.type) ? html `<div class="note">${i18next.t('figure.text.type-name-will-not-release')}</div>` : nothing}
79
+ </div>
80
+ <div field>
81
+ <label for="name">${i18next.t('figure.label.name')}</label>
82
+ <input
83
+ id="name"
84
+ .value=${this.name}
85
+ placeholder=${i18next.t('figure.text.figure-name-example')}
86
+ @input=${(e) => (this.name = e.target.value)}
87
+ @keydown=${this.onKey}
88
+ />
89
+ </div>
90
+ ${this.refusal ? html `<div class="failure" role="alert">${i18next.t(this.refusal.key, { type: this.refusal.type })}</div>` : nothing}
91
+ <ox-popup-actions>
92
+ <md-text-button slot="cancel" @click=${() => closePopup(this)}>${i18next.t('figure.button.cancel')}</md-text-button>
93
+ <md-filled-button slot="confirm" ?disabled=${!this.type} @click=${this.confirm}>${i18next.t('figure.button.save')}</md-filled-button>
94
+ </ox-popup-actions>
95
+ `;
96
+ }
97
+ };
98
+ __decorate([
99
+ property(),
100
+ __metadata("design:type", Object)
101
+ ], FigureV3FirstSave.prototype, "type", void 0);
102
+ __decorate([
103
+ property(),
104
+ __metadata("design:type", Object)
105
+ ], FigureV3FirstSave.prototype, "name", void 0);
106
+ __decorate([
107
+ property({ attribute: false }),
108
+ __metadata("design:type", Array)
109
+ ], FigureV3FirstSave.prototype, "taken", void 0);
110
+ __decorate([
111
+ state(),
112
+ __metadata("design:type", Object)
113
+ ], FigureV3FirstSave.prototype, "refusal", void 0);
114
+ FigureV3FirstSave = __decorate([
115
+ customElement('figure-v3-first-save')
116
+ ], FigureV3FirstSave);
117
+ export { FigureV3FirstSave };
118
+ //# sourceMappingURL=figure-v3-first-save.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-v3-first-save.js","sourceRoot":"","sources":["../../client/modeller/figure-v3-first-save.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement, html, css, nothing } from 'lit'\nimport { customElement, property, state } from 'lit/decorators.js'\nimport { i18next } from '@operato/i18n'\nimport { closePopup } from '@operato/popup'\nimport '@operato/popup/ox-popup-actions.js'\nimport { CommonPopupStyles, FormFieldStyles } from '@operato/styles'\nimport '@material/web/button/filled-button.js'\nimport '@material/web/button/text-button.js'\n\nimport { firstSaveRefusalOf, releasableType, typeOf, type FirstSave } from './v3-first-save.js'\n\n/**\n * The first save of a new figure: its type and its name, asked once (V2's save dialog, in the suite's popup look).\n * Closes with the answer, or with nothing when cancelled -- then nothing is saved.\n */\n@customElement('figure-v3-first-save')\nexport class FigureV3FirstSave extends LitElement {\n static styles = [\n FormFieldStyles,\n CommonPopupStyles,\n css`\n /* The popup's height is a share of the window (small is 40%): on a low window the buttons must still be reached. */\n :host {\n overflow-y: auto;\n }\n div[field] {\n display: grid;\n gap: 4px;\n }\n div[field] label {\n font-size: var(--popup-font-size-small);\n color: var(--md-sys-color-on-surface-variant);\n }\n div[field] input {\n box-sizing: border-box;\n width: 100%;\n }\n input[type-field] {\n font-family: var(--mono-font, monospace);\n }\n `\n ]\n\n /** The type the figure has so far -- empty for a temporary one, which is not offered. */\n @property() type = ''\n @property() name = ''\n /** Type names already used in this domain. */\n @property({ attribute: false }) taken: string[] = []\n @state() private refusal: { key: string; type?: string } | null = null\n\n render() {\n return html`\n <div class=\"what\">${i18next.t('figure.text.save-new-figure-desc')}</div>\n <div field>\n <label for=\"type\">${i18next.t('figure.label.figure-type')}</label>\n <input\n id=\"type\"\n type-field\n autofocus\n .value=${this.type}\n placeholder=${i18next.t('figure.text.figure-type-example')}\n @input=${(e: Event) => {\n this.type = typeOf((e.target as HTMLInputElement).value)\n ;(e.target as HTMLInputElement).value = this.type\n this.refusal = null\n }}\n @keydown=${this.onKey}\n />\n ${this.type && !releasableType(this.type) ? html`<div class=\"note\">${i18next.t('figure.text.type-name-will-not-release')}</div>` : nothing}\n </div>\n <div field>\n <label for=\"name\">${i18next.t('figure.label.name')}</label>\n <input\n id=\"name\"\n .value=${this.name}\n placeholder=${i18next.t('figure.text.figure-name-example')}\n @input=${(e: Event) => (this.name = (e.target as HTMLInputElement).value)}\n @keydown=${this.onKey}\n />\n </div>\n ${this.refusal ? html`<div class=\"failure\" role=\"alert\">${i18next.t(this.refusal.key, { type: this.refusal.type })}</div>` : nothing}\n <ox-popup-actions>\n <md-text-button slot=\"cancel\" @click=${() => closePopup(this)}>${i18next.t('figure.button.cancel')}</md-text-button>\n <md-filled-button slot=\"confirm\" ?disabled=${!this.type} @click=${this.confirm}>${i18next.t('figure.button.save')}</md-filled-button>\n </ox-popup-actions>\n `\n }\n\n private onKey = (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.isComposing) this.confirm()\n }\n\n private confirm = () => {\n this.refusal = firstSaveRefusalOf(this.type, this.taken)\n if (this.refusal) return\n closePopup(this, { type: this.type, name: this.name.trim() || this.type } satisfies FirstSave)\n }\n}\n"]}
@@ -30,7 +30,9 @@ export declare class FigureV3Links extends FigureV3Links_base {
30
30
  private fasten;
31
31
  private motion;
32
32
  private place;
33
- render(): import("lit-html").TemplateResult<1> | typeof nothing;
33
+ private row;
34
+ willUpdate(changed: Map<string, unknown>): void;
35
+ render(): typeof nothing | import("lit-html").TemplateResult<1>;
34
36
  /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
35
37
  private renderPlaces;
36
38
  /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
@@ -43,6 +45,17 @@ export declare class FigureV3Links extends FigureV3Links_base {
43
45
  */
44
46
  private renderPositions;
45
47
  private renderFastening;
48
+ /**
49
+ * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
50
+ * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
51
+ */
52
+ private renderAlignment;
53
+ /**
54
+ * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
55
+ * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
56
+ * (the editor's) shows it and takes it away.
57
+ */
58
+ private renderRow;
46
59
  private renderMotion;
47
60
  private setKind;
48
61
  private command;
@@ -4,10 +4,24 @@ 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 { V3_FACES } from '@hatiolab/figure-model';
7
- import { acceptsOf, AXES, canFasten, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, motionOf, placeFacesOf, placeIdFor, placesOn, positionsWorkedOut } from './v3-links.js';
7
+ import { acceptsOf, alignAxesOf, alignOf, AXES, canFasten, facesOf, canFollow, dimensionNow, followableAxes, positionIsShare, choiceOf, fastenedTo, followOf, hasInstanceSize, holdsThings, leadersFor, motionOf, motionJoint, placeFacesOf, placeIdFor, placesOn, rowAxesOf, positionsWorkedOut } from './v3-links.js';
8
8
  import { SidePanelStyles } from './side-panel-styles.js';
9
9
  import { HelpToggles } from './v3-help.js';
10
10
  const FACES = Object.keys(V3_FACES);
11
+ const freshFasten = () => ({
12
+ face: 'bottom',
13
+ toPlane: false,
14
+ target: '',
15
+ targetFace: 'top',
16
+ facing: 'meet',
17
+ gap: 0,
18
+ twist: 0,
19
+ align: {},
20
+ alignMm: {}
21
+ });
22
+ const freshMotion = () => ({ kind: 'slide', axis: 'x', id: '', min: 0, max: 500, follow: '', multiplier: 1, offset: 0 });
23
+ const freshRow = () => ({ axis: '', pitch: 200, fills: true, count: 5 });
24
+ const freshPlace = () => ({ role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 });
11
25
  /**
12
26
  * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
13
27
  *
@@ -30,9 +44,10 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
30
44
  this.locked = false;
31
45
  /** Which ties this shows: the part's shape (size and placement, in 속성), its places (자리·능력) or its motion (움직임). */
32
46
  this.section = 'shape';
33
- this.fasten = { face: 'bottom', toPlane: false, target: '', targetFace: 'top', facing: 'meet', gap: 0 };
34
- this.motion = { kind: 'slide', axis: 'x', id: '', min: 0, max: 500 };
35
- this.place = { role: 'grip', face: 'top', label: '', accepts: '', capacity: 1 };
47
+ this.fasten = freshFasten();
48
+ this.motion = freshMotion();
49
+ this.place = freshPlace();
50
+ this.row = freshRow();
36
51
  }
37
52
  static { this.styles = [SidePanelStyles, css `
38
53
  :host {
@@ -52,6 +67,18 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
52
67
  font-weight: 600;
53
68
  }
54
69
  `]; }
70
+ /*
71
+ The forms describe the next command for the selected part, so another part starts from the defaults: carrying the
72
+ last part's choices over fastened a shutter curtain level with a right edge nobody chose for it (2026-09-25).
73
+ */
74
+ willUpdate(changed) {
75
+ if (changed.has('partId') && changed.get('partId') !== undefined) {
76
+ this.fasten = freshFasten();
77
+ this.motion = freshMotion();
78
+ this.place = freshPlace();
79
+ this.row = freshRow();
80
+ }
81
+ }
55
82
  render() {
56
83
  const asset = this.asset;
57
84
  if (!asset || !this.partId)
@@ -72,6 +99,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
72
99
  ${this.renderPositions(asset)}
73
100
  ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html `<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
74
101
  </section>
102
+ ${this.renderRow(asset)}
75
103
  `;
76
104
  }
77
105
  /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
@@ -209,6 +237,13 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
209
237
  const to = fastenedTo(asset, this.partId);
210
238
  const f = this.fasten;
211
239
  const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one));
240
+ /* The faces are the ones each shape declares: a part is fastened by one of its outer faces, to any face of the target. */
241
+ const mineFaces = facesOf(asset, this.partId).filter(face => FACES.includes(face));
242
+ const myFace = mineFaces.includes(f.face) ? f.face : mineFaces[0];
243
+ const theirFaces = f.target ? facesOf(asset, f.target) : FACES;
244
+ const targetFace = theirFaces.includes(f.targetFace) ? f.targetFace : theirFaces[0];
245
+ const aim = f.toPlane ? 'plane' : { part: f.target, face: targetFace };
246
+ const align = f.target || f.toPlane ? alignOf(asset, this.partId, aim, f.align, f.alignMm, !!f.twist) : undefined;
212
247
  return html `
213
248
  ${to
214
249
  ? html `<div pair>
@@ -221,7 +256,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
221
256
  <div form>
222
257
  <span>${i18next.t('figure.label.my-face')}</span>
223
258
  <select @change=${(e) => (this.fasten = { ...f, face: e.target.value })}>
224
- ${FACES.map(face => html `<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
259
+ ${mineFaces.map(face => html `<option value=${face} ?selected=${face === myFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
225
260
  </select>
226
261
  </div>
227
262
  <label toggle>
@@ -238,7 +273,7 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
238
273
  </select>
239
274
  <span>${i18next.t('figure.label.target-face')}</span>
240
275
  <select @change=${(e) => (this.fasten = { ...f, targetFace: e.target.value })}>
241
- ${FACES.map(face => html `<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
276
+ ${theirFaces.map(face => html `<option value=${face} ?selected=${face === targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
242
277
  </select>
243
278
  <span>${i18next.t('figure.label.facing-meet')}</span>
244
279
  <select @change=${(e) => (this.fasten = { ...f, facing: e.target.value })}>
@@ -246,9 +281,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
246
281
  <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
247
282
  </select>
248
283
  </div>`}
284
+ ${this.renderAlignment(f.toPlane || f.target ? alignAxesOf(asset, this.partId, aim, !!f.twist) : [])}
249
285
  <div form>
250
286
  <span>${i18next.t('figure.label.gap')}</span>
251
287
  <input type="number" step="any" .value=${String(f.gap)} @change=${(e) => (this.fasten = { ...f, gap: Number(e.target.value) || 0 })} />
288
+ <span>${i18next.t('figure.label.twist')}</span>
289
+ <input type="number" step="any" .value=${String(f.twist)} @change=${(e) => (this.fasten = { ...f, twist: Number(e.target.value) || 0 })} />
252
290
  </div>
253
291
  <button
254
292
  act
@@ -256,10 +294,12 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
256
294
  @click=${() => this.command({
257
295
  kind: 'attach',
258
296
  part: this.partId,
259
- face: f.face,
260
- to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
297
+ face: myFace,
298
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: targetFace },
261
299
  ...(f.toPlane ? {} : { facing: f.facing }),
262
300
  ...(f.gap ? { gap: f.gap } : {}),
301
+ twist: f.twist,
302
+ ...(align ? { align } : {}),
263
303
  replace: !!to
264
304
  })}
265
305
  >
@@ -267,56 +307,153 @@ let FigureV3Links = class FigureV3Links extends localize(i18next)(LitElement) {
267
307
  </button>
268
308
  `;
269
309
  }
310
+ /**
311
+ * Where on the target face the part sits, on the two axes across it: centred, level with the target's edge on
312
+ * either side, or a distance off centre. A door hung level with the frame's front edge turns about that edge.
313
+ */
314
+ renderAlignment(axes) {
315
+ const f = this.fasten;
316
+ const set = (axis, how) => (this.fasten = { ...f, align: { ...f.align, [axis]: how } });
317
+ const setMm = (axis, mm) => (this.fasten = { ...f, alignMm: { ...f.alignMm, [axis]: mm } });
318
+ return html `<div form>
319
+ ${axes.map(({ axis, edges }) => {
320
+ const how = f.align[axis] === 'min' || f.align[axis] === 'max' ? (edges ? f.align[axis] : 'centre') : (f.align[axis] ?? 'centre');
321
+ return html `<span>${i18next.t('figure.label.align-' + axis)}</span>
322
+ <select @change=${(e) => set(axis, e.target.value)}>
323
+ <option value="centre" ?selected=${how === 'centre'}>${i18next.t('figure.label.align-centre')}</option>
324
+ ${edges
325
+ ? html `<option value="min" ?selected=${how === 'min'}>${i18next.t(`figure.label.align-${axis}-min`)}</option>
326
+ <option value="max" ?selected=${how === 'max'}>${i18next.t(`figure.label.align-${axis}-max`)}</option>`
327
+ : nothing}
328
+ <option value="mm" ?selected=${how === 'mm'}>${i18next.t('figure.label.align-mm')}</option>
329
+ </select>
330
+ ${how === 'mm'
331
+ ? html `<span></span><input type="number" step="any" aria-label=${i18next.t('figure.label.align-mm')} .value=${String(f.alignMm[axis] ?? 0)} @change=${(e) => setMm(axis, Number(e.target.value) || 0)} />`
332
+ : nothing}`;
333
+ })}
334
+ </div>`;
335
+ }
336
+ /**
337
+ * Making the part a row across what it is fastened to (figure-model `make-repeat`): along one of the axes across its
338
+ * face, copies a pitch apart filling the parent's length, or a count spread over it. Once made, the row's own section
339
+ * (the editor's) shows it and takes it away.
340
+ */
341
+ renderRow(asset) {
342
+ const axes = rowAxesOf(asset, this.partId);
343
+ if (!axes.length)
344
+ return nothing;
345
+ const r = this.row;
346
+ const axis = axes.includes(r.axis) ? r.axis : axes[0];
347
+ const ok = r.pitch > 0 && (r.fills || (Number.isSafeInteger(r.count) && r.count >= 1));
348
+ return html `<section>
349
+ <h3>${i18next.t('figure.label.repeat')}</h3>
350
+ <div form>
351
+ <span>${i18next.t('figure.label.repeat-axis')}</span>
352
+ <select @change=${(e) => (this.row = { ...r, axis: e.target.value })}>
353
+ ${axes.map(a => html `<option value=${a} ?selected=${a === axis}>${a.toUpperCase()}</option>`)}
354
+ </select>
355
+ <span>${i18next.t('figure.label.repeat-pitch')} (mm)</span>
356
+ <input type="number" step="any" min="0" .value=${String(r.pitch)} @change=${(e) => (this.row = { ...r, pitch: Number(e.target.value) })} />
357
+ </div>
358
+ <label toggle>
359
+ <input type="checkbox" .checked=${r.fills} @change=${(e) => (this.row = { ...r, fills: e.target.checked })} />
360
+ ${i18next.t('figure.label.repeat-fills')}
361
+ </label>
362
+ ${r.fills
363
+ ? nothing
364
+ : html `<div form>
365
+ <span>${i18next.t('figure.label.repeat-count')}</span>
366
+ <input type="number" step="1" min="1" .value=${String(r.count)} @change=${(e) => (this.row = { ...r, count: Number(e.target.value) })} />
367
+ </div>`}
368
+ <button
369
+ act
370
+ ?disabled=${this.locked || !ok}
371
+ @click=${() => this.command({ kind: 'make-repeat', part: this.partId, axis, pitch: r.pitch, ...(r.fills ? {} : { count: r.count }) })}
372
+ >
373
+ <md-icon>view_week</md-icon>${i18next.t('figure.button.make-repeat')}
374
+ </button>
375
+ </section>`;
376
+ }
270
377
  renderMotion(asset) {
271
378
  const now = motionOf(asset, this.partId);
379
+ const joint = asset.joints?.find(j => j.body1.part === this.partId);
272
380
  if (now) {
273
381
  return html `<div pair>
274
382
  <span>
275
383
  ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
276
- ${now.control ? html `<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>` : nothing}
384
+ ${now.follows
385
+ ? html `<small>${i18next.t('figure.label.motion-follows', { joint: now.follows.joint, multiplier: now.follows.multiplier, offset: now.follows.offset ?? 0 })}</small>`
386
+ : now.control
387
+ ? html `<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>`
388
+ : nothing}
277
389
  </span>
278
- <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
390
+ <button
391
+ act
392
+ ?disabled=${this.locked || !joint}
393
+ @click=${() => joint && this.command({ kind: 'set-joint', joint: { id: joint.id, type: 'fixed', body0: joint.body0, body1: joint.body1, ...(joint.origin ? { origin: joint.origin } : {}) } })}
394
+ >
279
395
  <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
280
396
  </button>
281
397
  </div>`;
282
398
  }
399
+ /* A joint needs a parent (URDF): a part fastened to nothing is fastened first. */
400
+ if (!joint)
401
+ return html `<p hint>${i18next.t('figure.text.fasten-before-moving')}</p>`;
283
402
  const m = this.motion;
284
- const unit = m.kind === 'turn' ? 'deg' : 'mm';
403
+ const unit = m.kind === 'slide' ? 'mm' : 'deg';
404
+ /* A follower follows a joint that moves the same way; a choice the kind no longer allows is dropped. */
405
+ const leaders = leadersFor(asset, this.partId, m.kind);
406
+ const follow = leaders.some(j => j.id === m.follow) ? m.follow : '';
285
407
  return html `
286
408
  <div pair>
287
409
  <select @change=${(e) => this.setKind(e.target.value)}>
288
410
  <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
289
411
  <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
412
+ <option value="continuous" ?selected=${m.kind === 'continuous'}>${i18next.t('figure.label.motion-continuous')}</option>
290
413
  </select>
291
414
  <select @change=${(e) => (this.motion = { ...m, axis: e.target.value })}>
292
415
  ${AXES.map(a => html `<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
293
416
  </select>
294
417
  </div>
295
- <div form>
296
- <span>${i18next.t('figure.label.control-id')}</span>
297
- <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e) => (this.motion = { ...m, id: e.target.value.trim() })} />
298
- <span>${i18next.t('figure.label.control-range')} (${unit})</span>
299
- <div pair>
300
- <input type="number" step="any" .value=${String(m.min)} @change=${(e) => (this.motion = { ...m, min: Number(e.target.value) })} />
301
- <input type="number" step="any" .value=${String(m.max)} @change=${(e) => (this.motion = { ...m, max: Number(e.target.value) })} />
302
- </div>
303
- </div>
418
+ ${leaders.length
419
+ ? html `<div form>
420
+ <span>${i18next.t('figure.label.motion-follow')}</span>
421
+ <select @change=${(e) => (this.motion = { ...m, follow: e.target.value })}>
422
+ <option value="" ?selected=${!follow}>${i18next.t('figure.label.motion-own-control')}</option>
423
+ ${leaders.map(j => html `<option value=${j.id} ?selected=${j.id === follow}>${j.body1.part}${j.state ? ` · ${j.state.id}` : ''}</option>`)}
424
+ </select>
425
+ </div>`
426
+ : nothing}
427
+ ${follow
428
+ ? html `<div form>
429
+ <span>${i18next.t('figure.label.motion-multiplier')}</span>
430
+ <input type="number" step="any" .value=${String(m.multiplier)} @change=${(e) => (this.motion = { ...m, multiplier: Number(e.target.value) })} />
431
+ <span>${i18next.t('figure.label.motion-offset')} (${unit})</span>
432
+ <input type="number" step="any" .value=${String(m.offset)} @change=${(e) => (this.motion = { ...m, offset: Number(e.target.value) || 0 })} />
433
+ </div>`
434
+ : html `<div form>
435
+ <span>${i18next.t('figure.label.control-id')}</span>
436
+ <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e) => (this.motion = { ...m, id: e.target.value.trim() })} />
437
+ ${m.kind === 'continuous'
438
+ ? nothing
439
+ : html `<span>${i18next.t('figure.label.control-range')} (${unit})</span>
440
+ <div pair>
441
+ <input type="number" step="any" .value=${String(m.min)} @change=${(e) => (this.motion = { ...m, min: Number(e.target.value) })} />
442
+ <input type="number" step="any" .value=${String(m.max)} @change=${(e) => (this.motion = { ...m, max: Number(e.target.value) })} />
443
+ </div>`}
444
+ </div>`}
304
445
  <button
305
446
  act
306
- ?disabled=${this.locked || !(m.max > m.min)}
307
- @click=${() => this.command({
308
- kind: 'add-motion',
309
- part: this.partId,
310
- motion: { kind: m.kind, axis: m.axis },
311
- state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
312
- })}
447
+ ?disabled=${this.locked || (follow ? !(Number.isFinite(m.multiplier) && m.multiplier !== 0) : m.kind !== 'continuous' && !(m.max > m.min && m.min <= 0 && 0 <= m.max))}
448
+ title=${!follow && m.kind !== 'continuous' && !(m.min <= 0 && 0 <= m.max) ? i18next.t('figure.text.joint-range-holds-zero') : ''}
449
+ @click=${() => this.command({ kind: 'set-joint', joint: motionJoint(joint, { ...m, follow }, this.partId) })}
313
450
  >
314
451
  <md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
315
452
  </button>
316
453
  `;
317
454
  }
318
455
  setKind(kind) {
319
- this.motion = kind === 'turn' ? { ...this.motion, kind, min: -90, max: 90 } : { ...this.motion, kind, min: 0, max: 500 };
456
+ this.motion = kind === 'slide' ? { ...this.motion, kind, min: 0, max: 500 } : { ...this.motion, kind, min: -90, max: 90 };
320
457
  }
321
458
  command(action) {
322
459
  this.dispatchEvent(new CustomEvent('v3-command', { detail: { action }, bubbles: true, composed: true }));
@@ -354,6 +491,10 @@ __decorate([
354
491
  state(),
355
492
  __metadata("design:type", Object)
356
493
  ], FigureV3Links.prototype, "place", void 0);
494
+ __decorate([
495
+ state(),
496
+ __metadata("design:type", Object)
497
+ ], FigureV3Links.prototype, "row", void 0);
357
498
  FigureV3Links = __decorate([
358
499
  customElement('figure-v3-links')
359
500
  ], FigureV3Links);