@things-factory/figure-ui 10.1.44 → 10.1.46

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 (129) hide show
  1. package/client/modeller/figure-v3-links.ts +394 -0
  2. package/client/modeller/modeller-route.ts +6 -21
  3. package/client/modeller/v3-editor.ts +47 -5
  4. package/client/modeller/v3-gizmo.ts +13 -2
  5. package/client/modeller/v3-links.ts +170 -0
  6. package/client/pages/figure-v3-modeller-page.ts +2 -5
  7. package/client/route.ts +2 -6
  8. package/dist-client/modeller/figure-v3-links.d.ts +47 -0
  9. package/dist-client/modeller/figure-v3-links.js +379 -0
  10. package/dist-client/modeller/figure-v3-links.js.map +1 -0
  11. package/dist-client/modeller/modeller-route.d.ts +4 -11
  12. package/dist-client/modeller/modeller-route.js +5 -22
  13. package/dist-client/modeller/modeller-route.js.map +1 -1
  14. package/dist-client/modeller/v3-editor.d.ts +5 -0
  15. package/dist-client/modeller/v3-editor.js +49 -4
  16. package/dist-client/modeller/v3-editor.js.map +1 -1
  17. package/dist-client/modeller/v3-gizmo.js +11 -1
  18. package/dist-client/modeller/v3-gizmo.js.map +1 -1
  19. package/dist-client/modeller/v3-links.d.ts +67 -0
  20. package/dist-client/modeller/v3-links.js +141 -0
  21. package/dist-client/modeller/v3-links.js.map +1 -0
  22. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -5
  23. package/dist-client/pages/figure-v3-modeller-page.js +2 -5
  24. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/route.js +2 -5
  27. package/dist-client/route.js.map +1 -1
  28. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +4 -4
  31. package/test/ai-proposal-contract.test.ts +0 -88
  32. package/test/figure-source.test.ts +0 -102
  33. package/test/i18n-prefix-guard.test.ts +0 -24
  34. package/test/modeller-route.test.ts +14 -25
  35. package/test/part-edits.test.ts +0 -7
  36. package/test/scene-budget.test.ts +0 -7
  37. package/test/v3-gizmo.test.ts +10 -0
  38. package/test/v3-links.test.ts +163 -0
  39. package/things-factory.config.js +2 -5
  40. package/translations/en.json +21 -1
  41. package/translations/ja.json +21 -1
  42. package/translations/ko.json +21 -1
  43. package/translations/ms.json +21 -1
  44. package/translations/zh.json +21 -1
  45. package/client/modeller/angle-dial.ts +0 -70
  46. package/client/modeller/base-box.ts +0 -175
  47. package/client/modeller/figure-animations.ts +0 -1249
  48. package/client/modeller/figure-canvas.ts +0 -567
  49. package/client/modeller/figure-capabilities.ts +0 -379
  50. package/client/modeller/figure-inspector.ts +0 -2208
  51. package/client/modeller/figure-parts.ts +0 -479
  52. package/client/modeller/figure-preview.ts +0 -598
  53. package/client/modeller/figure-report.ts +0 -966
  54. package/client/modeller/figure-settings.ts +0 -328
  55. package/client/modeller/figure-side.ts +0 -272
  56. package/client/modeller/figure-thumbnail.ts +0 -123
  57. package/client/modeller/figure-view.ts +0 -152
  58. package/client/modeller/joint-edits.ts +0 -262
  59. package/client/modeller/joint-gizmo.ts +0 -180
  60. package/client/modeller/maturity.ts +0 -288
  61. package/client/modeller/new-figure-entry.ts +0 -4
  62. package/client/modeller/pose-defaults.ts +0 -67
  63. package/client/pages/figure-modeller-page.ts +0 -2082
  64. package/dist-client/modeller/angle-dial.d.ts +0 -32
  65. package/dist-client/modeller/angle-dial.js +0 -41
  66. package/dist-client/modeller/angle-dial.js.map +0 -1
  67. package/dist-client/modeller/base-box.d.ts +0 -71
  68. package/dist-client/modeller/base-box.js +0 -110
  69. package/dist-client/modeller/base-box.js.map +0 -1
  70. package/dist-client/modeller/figure-animations.d.ts +0 -159
  71. package/dist-client/modeller/figure-animations.js +0 -1132
  72. package/dist-client/modeller/figure-animations.js.map +0 -1
  73. package/dist-client/modeller/figure-canvas.d.ts +0 -150
  74. package/dist-client/modeller/figure-canvas.js +0 -506
  75. package/dist-client/modeller/figure-canvas.js.map +0 -1
  76. package/dist-client/modeller/figure-capabilities.d.ts +0 -42
  77. package/dist-client/modeller/figure-capabilities.js +0 -349
  78. package/dist-client/modeller/figure-capabilities.js.map +0 -1
  79. package/dist-client/modeller/figure-inspector.d.ts +0 -329
  80. package/dist-client/modeller/figure-inspector.js +0 -2095
  81. package/dist-client/modeller/figure-inspector.js.map +0 -1
  82. package/dist-client/modeller/figure-parts.d.ts +0 -64
  83. package/dist-client/modeller/figure-parts.js +0 -478
  84. package/dist-client/modeller/figure-parts.js.map +0 -1
  85. package/dist-client/modeller/figure-preview.d.ts +0 -138
  86. package/dist-client/modeller/figure-preview.js +0 -549
  87. package/dist-client/modeller/figure-preview.js.map +0 -1
  88. package/dist-client/modeller/figure-report.d.ts +0 -165
  89. package/dist-client/modeller/figure-report.js +0 -952
  90. package/dist-client/modeller/figure-report.js.map +0 -1
  91. package/dist-client/modeller/figure-settings.d.ts +0 -39
  92. package/dist-client/modeller/figure-settings.js +0 -317
  93. package/dist-client/modeller/figure-settings.js.map +0 -1
  94. package/dist-client/modeller/figure-side.d.ts +0 -78
  95. package/dist-client/modeller/figure-side.js +0 -317
  96. package/dist-client/modeller/figure-side.js.map +0 -1
  97. package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
  98. package/dist-client/modeller/figure-thumbnail.js +0 -95
  99. package/dist-client/modeller/figure-thumbnail.js.map +0 -1
  100. package/dist-client/modeller/figure-view.d.ts +0 -118
  101. package/dist-client/modeller/figure-view.js +0 -78
  102. package/dist-client/modeller/figure-view.js.map +0 -1
  103. package/dist-client/modeller/joint-edits.d.ts +0 -49
  104. package/dist-client/modeller/joint-edits.js +0 -223
  105. package/dist-client/modeller/joint-edits.js.map +0 -1
  106. package/dist-client/modeller/joint-gizmo.d.ts +0 -34
  107. package/dist-client/modeller/joint-gizmo.js +0 -158
  108. package/dist-client/modeller/joint-gizmo.js.map +0 -1
  109. package/dist-client/modeller/maturity.d.ts +0 -55
  110. package/dist-client/modeller/maturity.js +0 -212
  111. package/dist-client/modeller/maturity.js.map +0 -1
  112. package/dist-client/modeller/new-figure-entry.d.ts +0 -2
  113. package/dist-client/modeller/new-figure-entry.js +0 -5
  114. package/dist-client/modeller/new-figure-entry.js.map +0 -1
  115. package/dist-client/modeller/pose-defaults.d.ts +0 -9
  116. package/dist-client/modeller/pose-defaults.js +0 -35
  117. package/dist-client/modeller/pose-defaults.js.map +0 -1
  118. package/dist-client/pages/figure-modeller-page.d.ts +0 -419
  119. package/dist-client/pages/figure-modeller-page.js +0 -2060
  120. package/dist-client/pages/figure-modeller-page.js.map +0 -1
  121. package/test/angle-dial.test.ts +0 -78
  122. package/test/base-box.test.ts +0 -235
  123. package/test/joint-edits.test.ts +0 -165
  124. package/test/joint-gizmo.test.ts +0 -174
  125. package/test/joint-needle.test.ts +0 -162
  126. package/test/modeller-undo-redo.test.ts +0 -33
  127. package/test/new-figure-entry.test.ts +0 -15
  128. package/test/pose-defaults.test.ts +0 -71
  129. package/test/preview-board-depth.test.ts +0 -27
@@ -0,0 +1,394 @@
1
+ import '@material/web/icon/icon.js'
2
+
3
+ import { css, html, LitElement, nothing } from 'lit'
4
+ import { customElement, property, state } from 'lit/decorators.js'
5
+
6
+ import { i18next, localize } from '@operato/i18n'
7
+ import { V3_FACES } from '@hatiolab/figure-model'
8
+ import type { V3Asset, V3AuthoringAction, V3DimensionSource, V3Face } from '@hatiolab/figure-model'
9
+
10
+ import {
11
+ acceptsOf,
12
+ AXES,
13
+ canFasten,
14
+ canFollow,
15
+ choiceOf,
16
+ fastenedTo,
17
+ followOf,
18
+ hasInstanceSize,
19
+ holdsThings,
20
+ motionOf,
21
+ placeFacesOf,
22
+ placeIdFor,
23
+ placesOn,
24
+ positionsWorkedOut,
25
+ type Axis,
26
+ type Follow
27
+ } from './v3-links.js'
28
+
29
+ const FACES = Object.keys(V3_FACES) as V3Face[]
30
+
31
+ /**
32
+ * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
33
+ *
34
+ * 「Build Once, Fit Anywhere」 lives here: a dimension that follows the instance size or another part is what lets
35
+ * a figure be placed at any size, and a part fastened to the face it rests on is what keeps it there when that
36
+ * happens. The release gate refuses a figure that falls apart when resized, and tells the author to fasten the
37
+ * parts that come away — this is where they do it. Motion is the V2 joint: a slide or a turn driven by a control.
38
+ *
39
+ * The V2 inspector has these as its dimensions, placement and joint sections, in that order; so does this. Every
40
+ * change is one figure-model authoring command, sent as `v3-command` and run by the editor through the session — one
41
+ * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
42
+ * size the figure does not declare) are not offered, so the author is not sent into a refusal.
43
+ */
44
+ @customElement('figure-v3-links')
45
+ export class FigureV3Links extends localize(i18next)(LitElement) {
46
+ static styles = css`
47
+ :host {
48
+ display: grid;
49
+ gap: 10px;
50
+ }
51
+ h3 {
52
+ font-size: var(--ui-t-body, 13px);
53
+ margin: 12px 0 0;
54
+ }
55
+ div[row] {
56
+ display: grid;
57
+ grid-template-columns: 24px minmax(0, 1fr);
58
+ gap: 6px;
59
+ align-items: center;
60
+ }
61
+ div[form] {
62
+ display: grid;
63
+ grid-template-columns: 96px minmax(0, 1fr);
64
+ gap: 6px;
65
+ align-items: center;
66
+ }
67
+ div[pair] {
68
+ display: flex;
69
+ gap: 6px;
70
+ }
71
+ div[pair] > * {
72
+ flex: 1;
73
+ min-width: 0;
74
+ }
75
+ select,
76
+ input,
77
+ button {
78
+ font: inherit;
79
+ min-width: 0;
80
+ }
81
+ button[act] {
82
+ justify-self: start;
83
+ display: inline-flex;
84
+ align-items: center;
85
+ gap: 4px;
86
+ padding: 4px 10px;
87
+ border: none;
88
+ border-radius: 999em;
89
+ cursor: pointer;
90
+ color: var(--md-sys-color-on-secondary-container);
91
+ background: var(--md-sys-color-secondary-container);
92
+ }
93
+ button[act]:disabled {
94
+ opacity: 0.5;
95
+ cursor: default;
96
+ }
97
+ button[act] md-icon {
98
+ font-size: 16px;
99
+ width: 16px;
100
+ height: 16px;
101
+ }
102
+ small,
103
+ p[hint] {
104
+ margin: 0;
105
+ color: var(--md-sys-color-on-surface-variant);
106
+ }
107
+ b[bound] {
108
+ font-weight: 600;
109
+ }
110
+ label[toggle] {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 6px;
114
+ }
115
+ `
116
+
117
+ /** The asset being edited. */
118
+ @property({ attribute: false }) asset?: V3Asset
119
+ /** The selected part. */
120
+ @property() partId = ''
121
+ /** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
122
+ @property({ attribute: false }) dims?: [number, number, number]
123
+ @property({ type: Boolean }) locked = false
124
+
125
+ @state() private fasten = { face: 'bottom' as V3Face, toPlane: false, target: '', targetFace: 'top' as V3Face, facing: 'meet' as 'meet' | 'flush', gap: 0 }
126
+ @state() private motion = { kind: 'slide' as 'slide' | 'turn', axis: 'x' as Axis, id: '', min: 0, max: 500 }
127
+ @state() private place = { role: 'grip' as 'seat' | 'grip', face: 'top' as V3Face, label: '', accepts: '', capacity: 1 }
128
+
129
+ render() {
130
+ const asset = this.asset
131
+ if (!asset || !this.partId) return nothing
132
+ return html`
133
+ <h3>${i18next.t('figure.label.dimensions')}</h3>
134
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
135
+ <h3>${i18next.t('figure.label.placement')}</h3>
136
+ ${this.renderPositions(asset)}
137
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
138
+ <h3>${i18next.t('figure.label.motion')}</h3>
139
+ ${this.renderMotion(asset)}
140
+ <h3>${i18next.t('figure.label.places')}</h3>
141
+ ${placeFacesOf(asset, this.partId).length ? this.renderPlaces(asset) : html`<p hint>${i18next.t('figure.text.places-need-a-box')}</p>`}
142
+ `
143
+ }
144
+
145
+ /** Where things are held on this part: a seat things rest on, or a grip things are held in (parity row ⑤). */
146
+ private renderPlaces(asset: V3Asset) {
147
+ const faces = placeFacesOf(asset, this.partId)
148
+ const p = this.place
149
+ const face = faces.includes(p.face) ? p.face : faces[0]!
150
+ const accepts = acceptsOf(p.accepts)
151
+ const capacityOk = Number.isSafeInteger(p.capacity) && p.capacity >= 1
152
+ const roleName = (role: string) => i18next.t('figure.label.place-' + role)
153
+ return html`
154
+ ${placesOn(asset, this.partId).map(
155
+ one => html`<div pair>
156
+ <span>
157
+ <b bound>${one.label}</b>
158
+ <small>${roleName(one.role)} · ${one.face ? i18next.t('figure.label.face-' + one.face) : ''} · ${one.accepts.join(', ')} · ${i18next.t('figure.label.place-holds', { count: one.capacity })}</small>
159
+ </span>
160
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-binding', id: one.id })}>
161
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-place')}
162
+ </button>
163
+ </div>`
164
+ )}
165
+ <p hint>${i18next.t('figure.text.place-explained')}</p>
166
+ ${holdsThings(asset)
167
+ ? nothing
168
+ : html`<p hint>${i18next.t('figure.text.place-needs-holding')}</p>
169
+ <button act ?disabled=${this.locked} @click=${() => this.declareHolding(asset)}>
170
+ <md-icon>inventory_2</md-icon>${i18next.t('figure.button.declare-holding')}
171
+ </button>`}
172
+ <div pair>
173
+ <select @change=${(e: Event) => (this.place = { ...p, role: (e.target as HTMLSelectElement).value as 'seat' | 'grip' })}>
174
+ <option value="grip" ?selected=${p.role === 'grip'}>${roleName('grip')}</option>
175
+ <option value="seat" ?selected=${p.role === 'seat'}>${roleName('seat')}</option>
176
+ </select>
177
+ <select @change=${(e: Event) => (this.place = { ...p, face: (e.target as HTMLSelectElement).value as V3Face })}>
178
+ ${faces.map(one => html`<option value=${one} ?selected=${one === face}>${i18next.t('figure.label.face-' + one)}</option>`)}
179
+ </select>
180
+ </div>
181
+ <div form>
182
+ <span>${i18next.t('figure.label.place-name')}</span>
183
+ <input .value=${p.label} placeholder=${roleName(p.role)} @change=${(e: Event) => (this.place = { ...p, label: (e.target as HTMLInputElement).value })} />
184
+ <span>${i18next.t('figure.label.place-accepts')}</span>
185
+ <input .value=${p.accepts} placeholder="BOX, TOTE" @input=${(e: Event) => (this.place = { ...p, accepts: (e.target as HTMLInputElement).value })} />
186
+ <span>${i18next.t('figure.label.place-capacity')}</span>
187
+ <input type="number" min="1" step="1" .value=${String(p.capacity)} @change=${(e: Event) => (this.place = { ...p, capacity: (e.target as HTMLInputElement).valueAsNumber })} />
188
+ </div>
189
+ ${accepts.length ? nothing : html`<small>${i18next.t('figure.text.place-accepts-needed')}</small>`}
190
+ <button
191
+ act
192
+ ?disabled=${this.locked || !accepts.length || !capacityOk}
193
+ @click=${() =>
194
+ this.command({
195
+ kind: 'add-binding',
196
+ id: placeIdFor(asset, this.partId, p.role),
197
+ label: p.label.trim() || roleName(p.role),
198
+ role: p.role,
199
+ part: this.partId,
200
+ face,
201
+ accepts,
202
+ capacity: p.capacity
203
+ })}
204
+ >
205
+ <md-icon>back_hand</md-icon>${i18next.t('figure.button.add-place')}
206
+ </button>
207
+ `
208
+ }
209
+
210
+ /** The figure holds things: `Capacity` and `Holdable`, the ladder's middle rung. One part edit, one undo step. */
211
+ private declareHolding(asset: V3Asset) {
212
+ const names = [...new Set([...(asset.document.capabilities.names ?? []), 'Capacity', 'Holdable'])]
213
+ this.dispatchEvent(new CustomEvent('v3-part-edit', { detail: { edit: { kind: 'set-capabilities', names } }, bubbles: true, composed: true }))
214
+ }
215
+
216
+ private renderDimensions(asset: V3Asset) {
217
+ const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
218
+ return AXES.map(axis => {
219
+ const now = followOf(asset, this.partId, axis)
220
+ const value = choiceOf(now)
221
+ return html`<div row>
222
+ <b>${axis.toUpperCase()}</b>
223
+ <select ?disabled=${this.locked || now.of === 'expression'} @change=${(e: Event) => this.follow(axis, (e.target as HTMLSelectElement).value, now)}>
224
+ <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
225
+ ${AXES.filter(one => hasInstanceSize(asset, one)).map(
226
+ one => html`<option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
227
+ ${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
228
+ </option>`
229
+ )}
230
+ ${others.flatMap(other =>
231
+ AXES.map(
232
+ one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${other} · ${one.toUpperCase()}</option>`
233
+ )
234
+ )}
235
+ ${now.of === 'expression' || now.of === 'input'
236
+ ? html`<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
237
+ : nothing}
238
+ </select>
239
+ </div>`
240
+ })
241
+ }
242
+
243
+ private follow(axis: Axis, choice: string, now: Follow) {
244
+ const following = now.of !== 'own'
245
+ if (choice === 'own') {
246
+ if (!following) return
247
+ const mm = Math.max(1, Math.round(this.dims?.[AXES.indexOf(axis)] ?? 100))
248
+ this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm })
249
+ return
250
+ }
251
+ const [of, a, b] = choice.split(':')
252
+ const source: V3DimensionSource = of === 'instance' ? { of: 'instance', axis: a as Axis } : { of: 'part', part: a!, axis: b as Axis }
253
+ this.command({ kind: 'link-dimension', part: this.partId, axis, source, replace: following })
254
+ }
255
+
256
+ /**
257
+ * Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to
258
+ * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
259
+ */
260
+ private renderPositions(asset: V3Asset) {
261
+ return positionsWorkedOut(asset, this.partId).map(
262
+ axis => html`<div pair>
263
+ <span>${axis.toUpperCase()} · ${i18next.t('figure.label.position-worked-out')}</span>
264
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'fix-position', part: this.partId, axis })}>
265
+ <md-icon>push_pin</md-icon>${i18next.t('figure.button.fix-position')}
266
+ </button>
267
+ </div>`
268
+ )
269
+ }
270
+
271
+ private renderFastening(asset: V3Asset) {
272
+ const to = fastenedTo(asset, this.partId)
273
+ const f = this.fasten
274
+ const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
275
+ return html`
276
+ ${to
277
+ ? html`<div pair>
278
+ <span>${i18next.t('figure.label.fastened-to')}: <b bound>${to}</b></span>
279
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
280
+ <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
281
+ </button>
282
+ </div>`
283
+ : html`<p hint>${i18next.t('figure.text.fastening-explained')}</p>`}
284
+ <div form>
285
+ <span>${i18next.t('figure.label.my-face')}</span>
286
+ <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
287
+ ${FACES.map(face => html`<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
288
+ </select>
289
+ </div>
290
+ <label toggle>
291
+ <input type="checkbox" .checked=${f.toPlane} @change=${(e: Event) => (this.fasten = { ...f, toPlane: (e.target as HTMLInputElement).checked })} />
292
+ ${i18next.t('figure.label.stands-on-plane')}
293
+ </label>
294
+ ${f.toPlane
295
+ ? nothing
296
+ : html`<div form>
297
+ <span>${i18next.t('figure.label.target-part')}</span>
298
+ <select @change=${(e: Event) => (this.fasten = { ...f, target: (e.target as HTMLSelectElement).value })}>
299
+ <option value="" ?selected=${!f.target}>—</option>
300
+ ${targets.map(one => html`<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
301
+ </select>
302
+ <span>${i18next.t('figure.label.target-face')}</span>
303
+ <select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
304
+ ${FACES.map(face => html`<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
305
+ </select>
306
+ <span>${i18next.t('figure.label.facing-meet')}</span>
307
+ <select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
308
+ <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
309
+ <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
310
+ </select>
311
+ </div>`}
312
+ <div form>
313
+ <span>${i18next.t('figure.label.gap')}</span>
314
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
315
+ </div>
316
+ <button
317
+ act
318
+ ?disabled=${this.locked || (!f.toPlane && !f.target)}
319
+ @click=${() =>
320
+ this.command({
321
+ kind: 'attach',
322
+ part: this.partId,
323
+ face: f.face,
324
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
325
+ ...(f.toPlane ? {} : { facing: f.facing }),
326
+ ...(f.gap ? { gap: f.gap } : {}),
327
+ replace: !!to
328
+ } as V3AuthoringAction)}
329
+ >
330
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
331
+ </button>
332
+ `
333
+ }
334
+
335
+ private renderMotion(asset: V3Asset) {
336
+ const now = motionOf(asset, this.partId)
337
+ if (now) {
338
+ return html`<div pair>
339
+ <span>
340
+ ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
341
+ ${now.control ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>` : nothing}
342
+ </span>
343
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
344
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
345
+ </button>
346
+ </div>`
347
+ }
348
+ const m = this.motion
349
+ const unit = m.kind === 'turn' ? 'deg' : 'mm'
350
+ return html`
351
+ <p hint>${i18next.t('figure.text.motion-explained')}</p>
352
+ <div pair>
353
+ <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
354
+ <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
355
+ <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
356
+ </select>
357
+ <select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
358
+ ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
359
+ </select>
360
+ </div>
361
+ <div form>
362
+ <span>${i18next.t('figure.label.control-id')}</span>
363
+ <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
364
+ <span>${i18next.t('figure.label.control-range')} (${unit})</span>
365
+ <div pair>
366
+ <input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
367
+ <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
368
+ </div>
369
+ </div>
370
+ <button
371
+ act
372
+ ?disabled=${this.locked || !(m.max > m.min)}
373
+ @click=${() =>
374
+ this.command({
375
+ kind: 'add-motion',
376
+ part: this.partId,
377
+ motion: { kind: m.kind, axis: m.axis },
378
+ state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
379
+ })}
380
+ >
381
+ <md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
382
+ </button>
383
+ `
384
+ }
385
+
386
+ private setKind(kind: 'slide' | 'turn') {
387
+ this.motion = kind === 'turn' ? { ...this.motion, kind, min: -90, max: 90 } : { ...this.motion, kind, min: 0, max: 500 }
388
+ }
389
+
390
+ private command(action: V3AuthoringAction) {
391
+ this.dispatchEvent(new CustomEvent('v3-command', { detail: { action }, bubbles: true, composed: true }))
392
+ }
393
+ }
394
+
@@ -1,25 +1,10 @@
1
1
  /**
2
- * Which modeller opens a figure — by the format of its source, which the server reports (`format`).
3
- *
4
- * The catalogue sent every figure to the V2 modeller, so a converted figure could only be opened by typing the V3
5
- * page's address (2026-09-24). A new figure starts in V3: new V2 figures would only add to what the cutover has to
6
- * convert (ADR-0087). A figure is opened in V3 only when the server says it is V3 — an unknown format goes to the
7
- * V2 page, which reports what it could not read.
2
+ * The one modeller. Every figure is V3 since ADR-0087 step 5 (2026-09-24, user decision: V2 is not kept), so the
3
+ * catalogue, a new figure and an old address all open the same page. `figure-v3-modeller` stays as an alias so a
4
+ * link written before the switch still lands.
8
5
  */
9
- export const V3_MODELLER = 'figure-v3-modeller'
10
- export const V2_MODELLER = 'figure-modeller'
6
+ export const MODELLER = 'figure-modeller'
11
7
 
12
- export function modellerPathOf(figure?: { id?: string; format?: string | null }): string {
13
- if (!figure?.id) return V3_MODELLER
14
- return figure.format === 'v3' ? `${V3_MODELLER}/${figure.id}` : `${V2_MODELLER}/${figure.id}`
15
- }
16
-
17
- /** The version a V3 asset stamps on itself. The V2 page forwards such a figure rather than failing on it. */
18
- export function isV3Source(source?: string | null): boolean {
19
- if (!source) return false
20
- try {
21
- return (JSON.parse(source) as { version?: unknown })?.version === 'figure-v3-asset-1'
22
- } catch {
23
- return false
24
- }
8
+ export function modellerPathOf(figure?: { id?: string }): string {
9
+ return figure?.id ? `${MODELLER}/${figure.id}` : MODELLER
25
10
  }
@@ -1,15 +1,16 @@
1
1
  import { LitElement, html, css, type PropertyValues } from 'lit'
2
2
  import { i18next } from '@operato/i18n'
3
3
  import { customElement, property, state } from 'lit/decorators.js'
4
- import type { V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
4
+ import type { V3AuthoringAction, V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
5
5
  import type { V3ProductSession } from './v3-product-session.js'
6
6
  import { captureV3Thumbnail } from './v3-thumbnail.js'
7
7
  import { drawableV3Parts } from './v3-renderer.js'
8
8
  import './v3-canvas.js'
9
9
  import './figure-v3-parts.js'
10
10
  import './figure-v3-material.js'
11
+ import './figure-v3-links.js'
11
12
  import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
12
- import { alignChanges, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
13
+ import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
13
14
  import type { V3PartRow } from './figure-v3-parts.js'
14
15
  import type { FigureV3Canvas } from './v3-canvas.js'
15
16
 
@@ -420,6 +421,47 @@ export class FigureV3Editor extends LitElement {
420
421
  : String(error)
421
422
  }
422
423
  }
424
+ /** Size response, fastening and motion for the selected part (parity ④ ⑤ ⑥), as authoring commands. */
425
+ private renderLinks(unavailable: boolean) {
426
+ let asset
427
+ try {
428
+ asset = JSON.parse(this.session!.source())
429
+ } catch {
430
+ return null
431
+ }
432
+ let dims: [number, number, number] | undefined
433
+ try {
434
+ dims = boxOf(asset, this.selected)?.dims
435
+ } catch {
436
+ dims = undefined
437
+ }
438
+ return html`<figure-v3-links
439
+ .asset=${asset}
440
+ .partId=${this.selected}
441
+ .dims=${dims}
442
+ ?locked=${unavailable}
443
+ @v3-command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
444
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
445
+ ></figure-v3-links>`
446
+ }
447
+ /** One command, one step of the history. A refusal a person can meet here is said in their words. */
448
+ private linkCommand(action: V3AuthoringAction) {
449
+ try {
450
+ this.session!.command(action)
451
+ this.error = ''
452
+ this.changed()
453
+ } catch (error) {
454
+ const code = (error as { code?: string })?.code
455
+ this.error =
456
+ code === 'DIMENSION_CYCLE'
457
+ ? i18next.t('figure.text.follow-would-loop')
458
+ : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
459
+ ? i18next.t('figure.text.fasten-would-loop')
460
+ : error instanceof Error
461
+ ? error.message
462
+ : String(error)
463
+ }
464
+ }
423
465
  private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
424
466
  const input = event.target as HTMLInputElement,
425
467
  value = input.valueAsNumber
@@ -508,7 +550,7 @@ export class FigureV3Editor extends LitElement {
508
550
  ${fields.map(field => html`<label><span>${fieldLabel(field.id)} (${field.unit})${field.role === 'state' ? ' · 동작' : ''}</span><input aria-label=${field.id} type="number" step="any" .value=${String(field.value)} ?disabled=${unavailable} @change=${(e: Event) => this.changeField(field, e)} /></label>`)}
509
551
  ${
510
552
  part
511
- ? html`<figure-v3-material
553
+ ? html`${this.renderLinks(unavailable)}<figure-v3-material
512
554
  .body=${part}
513
555
  .palette=${view.palette ?? {}}
514
556
  ?locked=${unavailable}
@@ -518,8 +560,8 @@ export class FigureV3Editor extends LitElement {
518
560
  }
519
561
  ${
520
562
  view.bindings.length
521
- ? html`<h3>배치 연결점</h3>
522
- ${view.bindings.map(b => html`<p>${b.label} · ${b.role}</p>`)}`
563
+ ? html`<h3>${i18next.t('figure.label.places')}</h3>
564
+ ${view.bindings.map(b => html`<p>${b.label} · ${i18next.t('figure.label.place-' + b.role)}</p>`)}`
523
565
  : null
524
566
  }
525
567
  </aside>
@@ -1,4 +1,5 @@
1
- import type { V3InputChange } from '@hatiolab/figure-model'
1
+ import { v3AttachmentsOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset, V3InputChange } from '@hatiolab/figure-model'
2
3
 
3
4
  /**
4
5
  * What the gizmo may write for a part — the V2 gizmo's move · turn · size, on a V3 asset (parity row ③).
@@ -40,7 +41,17 @@ export function gizmoReachOf(asset: AssetLike, part: string): GizmoReach {
40
41
  const allDesign = (ids: string[]) => ids.every(id => design.has(id))
41
42
  const pose = nodes.find(node => node.id === `${part}.pose`)
42
43
  const placed = nodes.find(node => node.id === part && node.op === 'place@1')
43
- const posed = !!pose && pose.op === 'rigid@1' && pose.params?.to === 'asset' && placed?.args[1] === `${part}.poseValue`
44
+ const direct = !!pose && pose.op === 'rigid@1' && pose.params?.to === 'asset' && placed?.args[1] === `${part}.poseValue`
45
+ /*
46
+ A part taken off another (detach) stands on a seat into the asset frame with its own position numbers back. It
47
+ can be moved like a part never fastened — unless it moves by itself, when where it is drawn is not its position.
48
+ */
49
+ const reseated =
50
+ !!pose &&
51
+ pose.op === 'rigid@1' &&
52
+ !nodes.some(node => node.id === `${part}.motion`) &&
53
+ v3AttachmentsOf(asset as unknown as V3Asset)[part] === null
54
+ const posed = direct || reseated
44
55
  const shape = nodes.find(node => node.id === `${part}.shape`)
45
56
  const extent = shape && BOXES.has(shape.op) ? shape.args.slice(0, 3) : []
46
57
  const sizes =