@things-factory/figure-ui 10.1.44 → 10.1.45

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 (128) hide show
  1. package/client/modeller/figure-v3-links.ts +303 -0
  2. package/client/modeller/modeller-route.ts +6 -21
  3. package/client/modeller/v3-editor.ts +44 -3
  4. package/client/modeller/v3-gizmo.ts +13 -2
  5. package/client/modeller/v3-links.ts +138 -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 +42 -0
  9. package/dist-client/modeller/figure-v3-links.js +306 -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 +46 -2
  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 +54 -0
  20. package/dist-client/modeller/v3-links.js +110 -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.tsbuildinfo +1 -1
  29. package/package.json +4 -4
  30. package/test/ai-proposal-contract.test.ts +0 -88
  31. package/test/figure-source.test.ts +0 -102
  32. package/test/i18n-prefix-guard.test.ts +0 -24
  33. package/test/modeller-route.test.ts +14 -25
  34. package/test/part-edits.test.ts +0 -7
  35. package/test/scene-budget.test.ts +0 -7
  36. package/test/v3-gizmo.test.ts +10 -0
  37. package/test/v3-links.test.ts +123 -0
  38. package/things-factory.config.js +2 -5
  39. package/translations/en.json +6 -0
  40. package/translations/ja.json +6 -0
  41. package/translations/ko.json +6 -0
  42. package/translations/ms.json +6 -0
  43. package/translations/zh.json +6 -0
  44. package/client/modeller/angle-dial.ts +0 -70
  45. package/client/modeller/base-box.ts +0 -175
  46. package/client/modeller/figure-animations.ts +0 -1249
  47. package/client/modeller/figure-canvas.ts +0 -567
  48. package/client/modeller/figure-capabilities.ts +0 -379
  49. package/client/modeller/figure-inspector.ts +0 -2208
  50. package/client/modeller/figure-parts.ts +0 -479
  51. package/client/modeller/figure-preview.ts +0 -598
  52. package/client/modeller/figure-report.ts +0 -966
  53. package/client/modeller/figure-settings.ts +0 -328
  54. package/client/modeller/figure-side.ts +0 -272
  55. package/client/modeller/figure-thumbnail.ts +0 -123
  56. package/client/modeller/figure-view.ts +0 -152
  57. package/client/modeller/joint-edits.ts +0 -262
  58. package/client/modeller/joint-gizmo.ts +0 -180
  59. package/client/modeller/maturity.ts +0 -288
  60. package/client/modeller/new-figure-entry.ts +0 -4
  61. package/client/modeller/pose-defaults.ts +0 -67
  62. package/client/pages/figure-modeller-page.ts +0 -2082
  63. package/dist-client/modeller/angle-dial.d.ts +0 -32
  64. package/dist-client/modeller/angle-dial.js +0 -41
  65. package/dist-client/modeller/angle-dial.js.map +0 -1
  66. package/dist-client/modeller/base-box.d.ts +0 -71
  67. package/dist-client/modeller/base-box.js +0 -110
  68. package/dist-client/modeller/base-box.js.map +0 -1
  69. package/dist-client/modeller/figure-animations.d.ts +0 -159
  70. package/dist-client/modeller/figure-animations.js +0 -1132
  71. package/dist-client/modeller/figure-animations.js.map +0 -1
  72. package/dist-client/modeller/figure-canvas.d.ts +0 -150
  73. package/dist-client/modeller/figure-canvas.js +0 -506
  74. package/dist-client/modeller/figure-canvas.js.map +0 -1
  75. package/dist-client/modeller/figure-capabilities.d.ts +0 -42
  76. package/dist-client/modeller/figure-capabilities.js +0 -349
  77. package/dist-client/modeller/figure-capabilities.js.map +0 -1
  78. package/dist-client/modeller/figure-inspector.d.ts +0 -329
  79. package/dist-client/modeller/figure-inspector.js +0 -2095
  80. package/dist-client/modeller/figure-inspector.js.map +0 -1
  81. package/dist-client/modeller/figure-parts.d.ts +0 -64
  82. package/dist-client/modeller/figure-parts.js +0 -478
  83. package/dist-client/modeller/figure-parts.js.map +0 -1
  84. package/dist-client/modeller/figure-preview.d.ts +0 -138
  85. package/dist-client/modeller/figure-preview.js +0 -549
  86. package/dist-client/modeller/figure-preview.js.map +0 -1
  87. package/dist-client/modeller/figure-report.d.ts +0 -165
  88. package/dist-client/modeller/figure-report.js +0 -952
  89. package/dist-client/modeller/figure-report.js.map +0 -1
  90. package/dist-client/modeller/figure-settings.d.ts +0 -39
  91. package/dist-client/modeller/figure-settings.js +0 -317
  92. package/dist-client/modeller/figure-settings.js.map +0 -1
  93. package/dist-client/modeller/figure-side.d.ts +0 -78
  94. package/dist-client/modeller/figure-side.js +0 -317
  95. package/dist-client/modeller/figure-side.js.map +0 -1
  96. package/dist-client/modeller/figure-thumbnail.d.ts +0 -3
  97. package/dist-client/modeller/figure-thumbnail.js +0 -95
  98. package/dist-client/modeller/figure-thumbnail.js.map +0 -1
  99. package/dist-client/modeller/figure-view.d.ts +0 -118
  100. package/dist-client/modeller/figure-view.js +0 -78
  101. package/dist-client/modeller/figure-view.js.map +0 -1
  102. package/dist-client/modeller/joint-edits.d.ts +0 -49
  103. package/dist-client/modeller/joint-edits.js +0 -223
  104. package/dist-client/modeller/joint-edits.js.map +0 -1
  105. package/dist-client/modeller/joint-gizmo.d.ts +0 -34
  106. package/dist-client/modeller/joint-gizmo.js +0 -158
  107. package/dist-client/modeller/joint-gizmo.js.map +0 -1
  108. package/dist-client/modeller/maturity.d.ts +0 -55
  109. package/dist-client/modeller/maturity.js +0 -212
  110. package/dist-client/modeller/maturity.js.map +0 -1
  111. package/dist-client/modeller/new-figure-entry.d.ts +0 -2
  112. package/dist-client/modeller/new-figure-entry.js +0 -5
  113. package/dist-client/modeller/new-figure-entry.js.map +0 -1
  114. package/dist-client/modeller/pose-defaults.d.ts +0 -9
  115. package/dist-client/modeller/pose-defaults.js +0 -35
  116. package/dist-client/modeller/pose-defaults.js.map +0 -1
  117. package/dist-client/pages/figure-modeller-page.d.ts +0 -419
  118. package/dist-client/pages/figure-modeller-page.js +0 -2060
  119. package/dist-client/pages/figure-modeller-page.js.map +0 -1
  120. package/test/angle-dial.test.ts +0 -78
  121. package/test/base-box.test.ts +0 -235
  122. package/test/joint-edits.test.ts +0 -165
  123. package/test/joint-gizmo.test.ts +0 -174
  124. package/test/joint-needle.test.ts +0 -162
  125. package/test/modeller-undo-redo.test.ts +0 -33
  126. package/test/new-figure-entry.test.ts +0 -15
  127. package/test/pose-defaults.test.ts +0 -71
  128. package/test/preview-board-depth.test.ts +0 -27
@@ -0,0 +1,303 @@
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 { AXES, canFasten, canFollow, choiceOf, fastenedTo, followOf, hasInstanceSize, motionOf, positionsWorkedOut, type Axis, type Follow } from './v3-links.js'
11
+
12
+ const FACES = Object.keys(V3_FACES) as V3Face[]
13
+
14
+ /**
15
+ * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
16
+ *
17
+ * 「Build Once, Fit Anywhere」 lives here: a dimension that follows the instance size or another part is what lets
18
+ * a figure be placed at any size, and a part fastened to the face it rests on is what keeps it there when that
19
+ * happens. The release gate refuses a figure that falls apart when resized, and tells the author to fasten the
20
+ * parts that come away — this is where they do it. Motion is the V2 joint: a slide or a turn driven by a control.
21
+ *
22
+ * The V2 inspector has these as its dimensions, placement and joint sections, in that order; so does this. Every
23
+ * change is one figure-model authoring command, sent as `v3-command` and run by the editor through the session — one
24
+ * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
25
+ * size the figure does not declare) are not offered, so the author is not sent into a refusal.
26
+ */
27
+ @customElement('figure-v3-links')
28
+ export class FigureV3Links extends localize(i18next)(LitElement) {
29
+ static styles = css`
30
+ :host {
31
+ display: grid;
32
+ gap: 10px;
33
+ }
34
+ h3 {
35
+ font-size: var(--ui-t-body, 13px);
36
+ margin: 12px 0 0;
37
+ }
38
+ div[row] {
39
+ display: grid;
40
+ grid-template-columns: 24px minmax(0, 1fr);
41
+ gap: 6px;
42
+ align-items: center;
43
+ }
44
+ div[form] {
45
+ display: grid;
46
+ grid-template-columns: 96px minmax(0, 1fr);
47
+ gap: 6px;
48
+ align-items: center;
49
+ }
50
+ div[pair] {
51
+ display: flex;
52
+ gap: 6px;
53
+ }
54
+ div[pair] > * {
55
+ flex: 1;
56
+ min-width: 0;
57
+ }
58
+ select,
59
+ input,
60
+ button {
61
+ font: inherit;
62
+ min-width: 0;
63
+ }
64
+ button[act] {
65
+ justify-self: start;
66
+ display: inline-flex;
67
+ align-items: center;
68
+ gap: 4px;
69
+ padding: 4px 10px;
70
+ border: none;
71
+ border-radius: 999em;
72
+ cursor: pointer;
73
+ color: var(--md-sys-color-on-secondary-container);
74
+ background: var(--md-sys-color-secondary-container);
75
+ }
76
+ button[act]:disabled {
77
+ opacity: 0.5;
78
+ cursor: default;
79
+ }
80
+ button[act] md-icon {
81
+ font-size: 16px;
82
+ width: 16px;
83
+ height: 16px;
84
+ }
85
+ small,
86
+ p[hint] {
87
+ margin: 0;
88
+ color: var(--md-sys-color-on-surface-variant);
89
+ }
90
+ b[bound] {
91
+ font-weight: 600;
92
+ }
93
+ label[toggle] {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 6px;
97
+ }
98
+ `
99
+
100
+ /** The asset being edited. */
101
+ @property({ attribute: false }) asset?: V3Asset
102
+ /** The selected part. */
103
+ @property() partId = ''
104
+ /** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
105
+ @property({ attribute: false }) dims?: [number, number, number]
106
+ @property({ type: Boolean }) locked = false
107
+
108
+ @state() private fasten = { face: 'bottom' as V3Face, toPlane: false, target: '', targetFace: 'top' as V3Face, facing: 'meet' as 'meet' | 'flush', gap: 0 }
109
+ @state() private motion = { kind: 'slide' as 'slide' | 'turn', axis: 'x' as Axis, id: '', min: 0, max: 500 }
110
+
111
+ render() {
112
+ const asset = this.asset
113
+ if (!asset || !this.partId) return nothing
114
+ return html`
115
+ <h3>${i18next.t('figure.label.dimensions')}</h3>
116
+ ${canFollow(asset, this.partId) ? this.renderDimensions(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
117
+ <h3>${i18next.t('figure.label.placement')}</h3>
118
+ ${this.renderPositions(asset)}
119
+ ${canFasten(asset, this.partId) ? this.renderFastening(asset) : html`<p hint>${i18next.t('figure.text.links-need-a-box')}</p>`}
120
+ <h3>${i18next.t('figure.label.motion')}</h3>
121
+ ${this.renderMotion(asset)}
122
+ `
123
+ }
124
+
125
+ private renderDimensions(asset: V3Asset) {
126
+ const others = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFollow(asset, one))
127
+ return AXES.map(axis => {
128
+ const now = followOf(asset, this.partId, axis)
129
+ const value = choiceOf(now)
130
+ return html`<div row>
131
+ <b>${axis.toUpperCase()}</b>
132
+ <select ?disabled=${this.locked || now.of === 'expression'} @change=${(e: Event) => this.follow(axis, (e.target as HTMLSelectElement).value, now)}>
133
+ <option value="own" ?selected=${value === 'own'}>${i18next.t('figure.label.follows-nothing')}</option>
134
+ ${AXES.filter(one => hasInstanceSize(asset, one)).map(
135
+ one => html`<option value=${`instance:${one}`} ?selected=${value === `instance:${one}`}>
136
+ ${i18next.t('figure.label.follows-instance')} · ${one.toUpperCase()}
137
+ </option>`
138
+ )}
139
+ ${others.flatMap(other =>
140
+ AXES.map(
141
+ one => html`<option value=${`part:${other}:${one}`} ?selected=${value === `part:${other}:${one}`}>${other} · ${one.toUpperCase()}</option>`
142
+ )
143
+ )}
144
+ ${now.of === 'expression' || now.of === 'input'
145
+ ? html`<option value=${value} selected>${now.of === 'input' ? now.input : i18next.t('figure.label.follows-expression')}</option>`
146
+ : nothing}
147
+ </select>
148
+ </div>`
149
+ })
150
+ }
151
+
152
+ private follow(axis: Axis, choice: string, now: Follow) {
153
+ const following = now.of !== 'own'
154
+ if (choice === 'own') {
155
+ if (!following) return
156
+ const mm = Math.max(1, Math.round(this.dims?.[AXES.indexOf(axis)] ?? 100))
157
+ this.command({ kind: 'unlink-dimension', part: this.partId, axis, mm })
158
+ return
159
+ }
160
+ const [of, a, b] = choice.split(':')
161
+ const source: V3DimensionSource = of === 'instance' ? { of: 'instance', axis: a as Axis } : { of: 'part', part: a!, axis: b as Axis }
162
+ this.command({ kind: 'link-dimension', part: this.partId, axis, source, replace: following })
163
+ }
164
+
165
+ /**
166
+ * Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to
167
+ * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
168
+ */
169
+ private renderPositions(asset: V3Asset) {
170
+ return positionsWorkedOut(asset, this.partId).map(
171
+ axis => html`<div pair>
172
+ <span>${axis.toUpperCase()} · ${i18next.t('figure.label.position-worked-out')}</span>
173
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'fix-position', part: this.partId, axis })}>
174
+ <md-icon>push_pin</md-icon>${i18next.t('figure.button.fix-position')}
175
+ </button>
176
+ </div>`
177
+ )
178
+ }
179
+
180
+ private renderFastening(asset: V3Asset) {
181
+ const to = fastenedTo(asset, this.partId)
182
+ const f = this.fasten
183
+ const targets = asset.appearance.map(look => look.target).filter(one => one !== this.partId && canFasten(asset, one))
184
+ return html`
185
+ ${to
186
+ ? html`<div pair>
187
+ <span>${i18next.t('figure.label.fastened-to')}: <b bound>${to}</b></span>
188
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'detach', part: this.partId })}>
189
+ <md-icon>link_off</md-icon>${i18next.t('figure.button.detach')}
190
+ </button>
191
+ </div>`
192
+ : html`<p hint>${i18next.t('figure.text.fastening-explained')}</p>`}
193
+ <div form>
194
+ <span>${i18next.t('figure.label.my-face')}</span>
195
+ <select @change=${(e: Event) => (this.fasten = { ...f, face: (e.target as HTMLSelectElement).value as V3Face })}>
196
+ ${FACES.map(face => html`<option value=${face} ?selected=${face === f.face}>${i18next.t('figure.label.face-' + face)}</option>`)}
197
+ </select>
198
+ </div>
199
+ <label toggle>
200
+ <input type="checkbox" .checked=${f.toPlane} @change=${(e: Event) => (this.fasten = { ...f, toPlane: (e.target as HTMLInputElement).checked })} />
201
+ ${i18next.t('figure.label.stands-on-plane')}
202
+ </label>
203
+ ${f.toPlane
204
+ ? nothing
205
+ : html`<div form>
206
+ <span>${i18next.t('figure.label.target-part')}</span>
207
+ <select @change=${(e: Event) => (this.fasten = { ...f, target: (e.target as HTMLSelectElement).value })}>
208
+ <option value="" ?selected=${!f.target}>—</option>
209
+ ${targets.map(one => html`<option value=${one} ?selected=${one === f.target}>${one}</option>`)}
210
+ </select>
211
+ <span>${i18next.t('figure.label.target-face')}</span>
212
+ <select @change=${(e: Event) => (this.fasten = { ...f, targetFace: (e.target as HTMLSelectElement).value as V3Face })}>
213
+ ${FACES.map(face => html`<option value=${face} ?selected=${face === f.targetFace}>${i18next.t('figure.label.face-' + face)}</option>`)}
214
+ </select>
215
+ <span>${i18next.t('figure.label.facing-meet')}</span>
216
+ <select @change=${(e: Event) => (this.fasten = { ...f, facing: (e.target as HTMLSelectElement).value as 'meet' | 'flush' })}>
217
+ <option value="meet" ?selected=${f.facing === 'meet'}>${i18next.t('figure.label.facing-meet')}</option>
218
+ <option value="flush" ?selected=${f.facing === 'flush'}>${i18next.t('figure.label.facing-flush')}</option>
219
+ </select>
220
+ </div>`}
221
+ <div form>
222
+ <span>${i18next.t('figure.label.gap')}</span>
223
+ <input type="number" step="any" .value=${String(f.gap)} @change=${(e: Event) => (this.fasten = { ...f, gap: Number((e.target as HTMLInputElement).value) || 0 })} />
224
+ </div>
225
+ <button
226
+ act
227
+ ?disabled=${this.locked || (!f.toPlane && !f.target)}
228
+ @click=${() =>
229
+ this.command({
230
+ kind: 'attach',
231
+ part: this.partId,
232
+ face: f.face,
233
+ to: f.toPlane ? { plane: 'mounting-plane' } : { part: f.target, face: f.targetFace },
234
+ ...(f.toPlane ? {} : { facing: f.facing }),
235
+ ...(f.gap ? { gap: f.gap } : {}),
236
+ replace: !!to
237
+ } as V3AuthoringAction)}
238
+ >
239
+ <md-icon>link</md-icon>${i18next.t('figure.button.fasten')}
240
+ </button>
241
+ `
242
+ }
243
+
244
+ private renderMotion(asset: V3Asset) {
245
+ const now = motionOf(asset, this.partId)
246
+ if (now) {
247
+ return html`<div pair>
248
+ <span>
249
+ ${i18next.t('figure.label.motion-' + now.kind)} · ${String(now.axis).toUpperCase()}
250
+ ${now.control ? html`<small>${now.control.id} (${now.control.unit}) ${now.control.min} … ${now.control.max}</small>` : nothing}
251
+ </span>
252
+ <button act ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-motion', part: this.partId })}>
253
+ <md-icon>close</md-icon>${i18next.t('figure.button.remove-motion')}
254
+ </button>
255
+ </div>`
256
+ }
257
+ const m = this.motion
258
+ const unit = m.kind === 'turn' ? 'deg' : 'mm'
259
+ return html`
260
+ <p hint>${i18next.t('figure.text.motion-explained')}</p>
261
+ <div pair>
262
+ <select @change=${(e: Event) => this.setKind((e.target as HTMLSelectElement).value as 'slide' | 'turn')}>
263
+ <option value="slide" ?selected=${m.kind === 'slide'}>${i18next.t('figure.label.motion-slide')}</option>
264
+ <option value="turn" ?selected=${m.kind === 'turn'}>${i18next.t('figure.label.motion-turn')}</option>
265
+ </select>
266
+ <select @change=${(e: Event) => (this.motion = { ...m, axis: (e.target as HTMLSelectElement).value as Axis })}>
267
+ ${AXES.map(a => html`<option value=${a} ?selected=${a === m.axis}>${i18next.t('figure.label.motion-axis')} ${a.toUpperCase()}</option>`)}
268
+ </select>
269
+ </div>
270
+ <div form>
271
+ <span>${i18next.t('figure.label.control-id')}</span>
272
+ <input .value=${m.id} placeholder=${`${this.partId}-${m.kind}`} @change=${(e: Event) => (this.motion = { ...m, id: (e.target as HTMLInputElement).value.trim() })} />
273
+ <span>${i18next.t('figure.label.control-range')} (${unit})</span>
274
+ <div pair>
275
+ <input type="number" step="any" .value=${String(m.min)} @change=${(e: Event) => (this.motion = { ...m, min: Number((e.target as HTMLInputElement).value) })} />
276
+ <input type="number" step="any" .value=${String(m.max)} @change=${(e: Event) => (this.motion = { ...m, max: Number((e.target as HTMLInputElement).value) })} />
277
+ </div>
278
+ </div>
279
+ <button
280
+ act
281
+ ?disabled=${this.locked || !(m.max > m.min)}
282
+ @click=${() =>
283
+ this.command({
284
+ kind: 'add-motion',
285
+ part: this.partId,
286
+ motion: { kind: m.kind, axis: m.axis },
287
+ state: { id: m.id || `${this.partId}-${m.kind}`, unit, min: m.min, max: m.max, start: m.min }
288
+ })}
289
+ >
290
+ <md-icon>animation</md-icon>${i18next.t('figure.button.add-motion')}
291
+ </button>
292
+ `
293
+ }
294
+
295
+ private setKind(kind: 'slide' | 'turn') {
296
+ this.motion = kind === 'turn' ? { ...this.motion, kind, min: -90, max: 90 } : { ...this.motion, kind, min: 0, max: 500 }
297
+ }
298
+
299
+ private command(action: V3AuthoringAction) {
300
+ this.dispatchEvent(new CustomEvent('v3-command', { detail: { action }, bubbles: true, composed: true }))
301
+ }
302
+ }
303
+
@@ -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,46 @@ 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
+ ></figure-v3-links>`
445
+ }
446
+ /** One command, one step of the history. A refusal a person can meet here is said in their words. */
447
+ private linkCommand(action: V3AuthoringAction) {
448
+ try {
449
+ this.session!.command(action)
450
+ this.error = ''
451
+ this.changed()
452
+ } catch (error) {
453
+ const code = (error as { code?: string })?.code
454
+ this.error =
455
+ code === 'DIMENSION_CYCLE'
456
+ ? i18next.t('figure.text.follow-would-loop')
457
+ : code === 'FASTEN_CYCLE' || code === 'ATTACH_CYCLE'
458
+ ? i18next.t('figure.text.fasten-would-loop')
459
+ : error instanceof Error
460
+ ? error.message
461
+ : String(error)
462
+ }
463
+ }
423
464
  private changeField(field: { id: string; unit: V3InputChange['unit']; role?: string }, event: Event) {
424
465
  const input = event.target as HTMLInputElement,
425
466
  value = input.valueAsNumber
@@ -508,7 +549,7 @@ export class FigureV3Editor extends LitElement {
508
549
  ${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
550
  ${
510
551
  part
511
- ? html`<figure-v3-material
552
+ ? html`${this.renderLinks(unavailable)}<figure-v3-material
512
553
  .body=${part}
513
554
  .palette=${view.palette ?? {}}
514
555
  ?locked=${unavailable}
@@ -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 =
@@ -0,0 +1,138 @@
1
+ import { v3AttachmentsOf } from '@hatiolab/figure-model'
2
+ import type { V3Asset } from '@hatiolab/figure-model'
3
+
4
+ /*
5
+ * What a V3 part is tied to — the readers behind the parts' size response, fastening and motion sections (parity rows
6
+ * ④ ⑤ ⑥). Kept apart from the element so they can be tested without a browser.
7
+ */
8
+
9
+ export type Axis = 'x' | 'y' | 'z'
10
+ export const AXES: readonly Axis[] = ['x', 'y', 'z']
11
+
12
+ interface Node {
13
+ id: string
14
+ op: string
15
+ args: string[]
16
+ outputs: Record<string, string>
17
+ }
18
+ const modelOf = (asset: V3Asset) =>
19
+ asset.document.model as unknown as { nodes: Node[]; inputs: Array<{ id: string; role?: string; unit?: string; min?: number; max?: number }>; constants: Array<{ id: string; value: number }> }
20
+
21
+ /** What one dimension of a part is worked out from. */
22
+ export type Follow =
23
+ | { of: 'own' }
24
+ | { of: 'instance'; axis: Axis }
25
+ | { of: 'part'; part: string; axis: Axis }
26
+ | { of: 'input'; input: string }
27
+ /** Worked out some other way (the converter writes expressions) — shown, not offered as a choice. */
28
+ | { of: 'expression' }
29
+
30
+ /**
31
+ * `link-dimension` routes a part's dimension through `<part>.dimension.<axis>` and points its first argument at the
32
+ * source, directly or through the nodes that scale it. Walking back through those nodes finds the source: the
33
+ * instance size (`size.<axis>`), another part's dimension (`<part>.dimension.<axis>.value`) or a declared input.
34
+ */
35
+ export function followOf(asset: V3Asset, part: string, axis: Axis): Follow {
36
+ const m = modelOf(asset)
37
+ const node = m.nodes.find(n => n.id === `${part}.dimension.${axis}`)
38
+ if (!node) return { of: 'own' }
39
+ const writerOf = (ref: string) => m.nodes.find(n => Object.values(n.outputs).includes(ref))
40
+ const seen = new Set<string>()
41
+ const walk = (ref: string): Follow => {
42
+ if (seen.has(ref)) return { of: 'expression' }
43
+ seen.add(ref)
44
+ const instance = /^size\.([xyz])$/.exec(ref)
45
+ if (instance) return { of: 'instance', axis: instance[1] as Axis }
46
+ const other = /^(.+)\.dimension\.([xyz])\.value$/.exec(ref)
47
+ if (other) return { of: 'part', part: other[1]!, axis: other[2] as Axis }
48
+ const input = m.inputs.find(i => i.id === ref)
49
+ if (input) return ref.startsWith(`${part}.`) ? { of: 'own' } : { of: 'input', input: ref }
50
+ const writer = writerOf(ref)
51
+ if (!writer) return { of: 'expression' }
52
+ /* Follow the one argument that is not a constant factor. */
53
+ const onward = writer.args.filter(arg => !m.constants.some(c => c.id === arg))
54
+ return onward.length === 1 ? walk(onward[0]!) : { of: 'expression' }
55
+ }
56
+ return walk(node.args[0]!)
57
+ }
58
+
59
+ /** Whether the asset declares an instance size on an axis — only then can a dimension follow it. */
60
+ export const hasInstanceSize = (asset: V3Asset, axis: Axis) => modelOf(asset).inputs.some(i => i.id === `size.${axis}` && i.role === 'design')
61
+
62
+ /** The part this one is fastened to, or `null`. The mounting plane counts as fastened (the model names no part). */
63
+ export function fastenedTo(asset: V3Asset, part: string): string | null {
64
+ return v3AttachmentsOf(asset)[part] ?? null
65
+ }
66
+
67
+ /** Whether a dimension of this part can follow something — box-shaped, whose three sizes are X, Y and Z. */
68
+ export function canFollow(asset: V3Asset, part: string): boolean {
69
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
70
+ return !!shape && (shape.op === 'box-shape@1' || shape.op === 'rounded-box@1')
71
+ }
72
+
73
+ /** Whether `attach` can take this part: a box, or a cylinder (figure-model measures its faces since 0.1.47). */
74
+ export function canFasten(asset: V3Asset, part: string): boolean {
75
+ const shape = modelOf(asset).nodes.find(n => n.id === `${part}.shape`)
76
+ return canFollow(asset, part) || shape?.op === 'cylinder-shape@1'
77
+ }
78
+
79
+ /**
80
+ * The axes on which the part's position is worked out from other values rather than being its own number — the
81
+ * converter writes V2's anchor rules that way. `fix-position` makes one its own number where it is now.
82
+ */
83
+ export function positionsWorkedOut(asset: V3Asset, part: string): Axis[] {
84
+ const m = modelOf(asset)
85
+ const pose = m.nodes.find(n => n.id === `${part}.pose`)
86
+ if (!pose || pose.op !== 'rigid@1' || fastenedTo(asset, part)) return []
87
+ const own = (ref: string) => ref.startsWith(`${part}.`) && m.inputs.some(i => i.id === ref && i.role === 'design')
88
+ const constant = (ref: string) => m.constants.some(c => c.id === ref)
89
+ return AXES.filter((_axis, i) => !own(pose.args[i]!) && !constant(pose.args[i]!))
90
+ }
91
+
92
+ /** How a part moves, if it does: slide or turn, along which axis, driven by which control. */
93
+ export interface Motion {
94
+ kind: 'slide' | 'turn'
95
+ axis: Axis | '?'
96
+ control?: { id: string; unit?: string; min?: number; max?: number }
97
+ }
98
+
99
+ export function motionOf(asset: V3Asset, part: string): Motion | undefined {
100
+ const m = modelOf(asset)
101
+ const node = m.nodes.find(n => n.id === `${part}.motion`)
102
+ if (!node) return undefined
103
+ const kind = node.op === 'axis-turn@1' ? 'turn' : 'slide'
104
+ const unit = node.args.slice(0, 3).map(ref => m.constants.find(c => c.id === ref)?.value)
105
+ const axis = AXES[unit.findIndex(v => v !== undefined && v !== 0)] ?? '?'
106
+ /* The quantity is the fourth argument, a state input directly or through the nodes that scale it. */
107
+ const quantity = node.args[3]
108
+ const seen = new Set<string>()
109
+ const control = (ref?: string): Motion['control'] => {
110
+ if (!ref || seen.has(ref)) return undefined
111
+ seen.add(ref)
112
+ const input = m.inputs.find(i => i.id === ref && i.role === 'state')
113
+ if (input) return { id: input.id, unit: input.unit, min: input.min, max: input.max }
114
+ const writer = m.nodes.find(n => Object.values(n.outputs).includes(ref))
115
+ for (const arg of writer?.args ?? []) {
116
+ const found = control(arg)
117
+ if (found) return found
118
+ }
119
+ return undefined
120
+ }
121
+ return { kind, axis, control: control(quantity) }
122
+ }
123
+
124
+ /** The select's value for what a dimension follows now. */
125
+ export function choiceOf(follow: Follow): string {
126
+ switch (follow.of) {
127
+ case 'own':
128
+ return 'own'
129
+ case 'instance':
130
+ return `instance:${follow.axis}`
131
+ case 'part':
132
+ return `part:${follow.part}:${follow.axis}`
133
+ case 'input':
134
+ return `input:${follow.input}`
135
+ case 'expression':
136
+ return 'expression'
137
+ }
138
+ }
@@ -16,11 +16,8 @@ import '../modeller/figure-v3-properties.js'
16
16
  /**
17
17
  * The V3 product page has exactly one authoritative workspace for UI and AI input transactions.
18
18
  *
19
- * It answers to `figure-v3-modeller` while the catalogue's assets are still V2. The designer's order switches
20
- * the production data and the consuming paths together (ADR-0087, step 5): until the assets are converted, a
21
- * `figure-modeller` that opened this page would fail on every figure in the catalogue. Having its own route
22
- * is what makes steps 3 and 4 — the conversion on a recoverable copy, and the canvas check — possible at all.
23
- * At step 5 the V2 page goes, and this one takes the `figure-modeller` name and route.
19
+ * The one modeller since ADR-0087 step 5 (2026-09-24): it answers to `figure-modeller`, and to `figure-v3-modeller`
20
+ * as an alias for links made before the switch.
24
21
  */
25
22
  @customElement('figure-v3-modeller-page')
26
23
  export class FigureV3ModellerPage extends PageView {
package/client/route.ts CHANGED
@@ -4,14 +4,10 @@ export default function route(page: string) {
4
4
  import('./pages/figure-list-page')
5
5
  return page
6
6
 
7
- case 'figure-modeller':
8
- import('./pages/figure-modeller-page')
9
- return page
10
-
11
7
  /*
12
- The V3 modeller, under its own name until the catalogue's assets are V3 (ADR-0087, step 5). Both pages
13
- define a custom element, and an element name may be registered once, so they cannot share one.
8
+ One modeller since ADR-0087 step 5. `figure-v3-modeller` is kept as an alias for links made before the switch.
14
9
  */
10
+ case 'figure-modeller':
15
11
  case 'figure-v3-modeller':
16
12
  import('./pages/figure-v3-modeller-page')
17
13
  return page
@@ -0,0 +1,42 @@
1
+ import '@material/web/icon/icon.js';
2
+ import { LitElement, nothing } from 'lit';
3
+ import type { V3Asset } from '@hatiolab/figure-model';
4
+ declare const FigureV3Links_base: (new (...args: any[]) => LitElement) & typeof LitElement;
5
+ /**
6
+ * What a part is tied to — its size response, what it is fastened to, how it moves (parity rows ④ ⑤ ⑥).
7
+ *
8
+ * 「Build Once, Fit Anywhere」 lives here: a dimension that follows the instance size or another part is what lets
9
+ * a figure be placed at any size, and a part fastened to the face it rests on is what keeps it there when that
10
+ * happens. The release gate refuses a figure that falls apart when resized, and tells the author to fasten the
11
+ * parts that come away — this is where they do it. Motion is the V2 joint: a slide or a turn driven by a control.
12
+ *
13
+ * The V2 inspector has these as its dimensions, placement and joint sections, in that order; so does this. Every
14
+ * change is one figure-model authoring command, sent as `v3-command` and run by the editor through the session — one
15
+ * step of the undo history. Choices the command would refuse (a round body fastened, a size following an instance
16
+ * size the figure does not declare) are not offered, so the author is not sent into a refusal.
17
+ */
18
+ export declare class FigureV3Links extends FigureV3Links_base {
19
+ static styles: import("lit").CSSResult;
20
+ /** The asset being edited. */
21
+ asset?: V3Asset;
22
+ /** The selected part. */
23
+ partId: string;
24
+ /** The part's own extent along its axes now, in mm — what a dimension is fixed at when it stops following. */
25
+ dims?: [number, number, number];
26
+ locked: boolean;
27
+ private fasten;
28
+ private motion;
29
+ render(): import("lit-html").TemplateResult<1> | typeof nothing;
30
+ private renderDimensions;
31
+ private follow;
32
+ /**
33
+ * Positions worked out from other values — V2's anchor rules, as the converter wrote them — each with the way to
34
+ * keep it where it is. This is the way out the release gate names when a resize leaves a part in the air.
35
+ */
36
+ private renderPositions;
37
+ private renderFastening;
38
+ private renderMotion;
39
+ private setKind;
40
+ private command;
41
+ }
42
+ export {};