@things-factory/figure-ui 10.1.147 → 10.1.149

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 (209) hide show
  1. package/client/bootstrap.ts +10 -2
  2. package/client/graphql/index.ts +20 -0
  3. package/client/modeller/canvas.ts +6 -2
  4. package/client/modeller/carrier-declaration.ts +63 -0
  5. package/client/modeller/editor.ts +62 -27
  6. package/client/modeller/figure-carrier.ts +117 -0
  7. package/client/modeller/figure-cavity-drawing.ts +3 -9
  8. package/client/modeller/figure-first-save.ts +4 -7
  9. package/client/modeller/figure-history-panel.ts +2 -3
  10. package/client/modeller/figure-links.ts +46 -60
  11. package/client/modeller/figure-material-panel.ts +4 -14
  12. package/client/modeller/figure-mesh-choice.ts +68 -0
  13. package/client/modeller/figure-parts.ts +23 -1
  14. package/client/modeller/figure-preview.ts +2 -2
  15. package/client/modeller/figure-properties.ts +5 -3
  16. package/client/modeller/figure-section-editor.ts +12 -33
  17. package/client/modeller/figure-section-panel.ts +6 -7
  18. package/client/modeller/figure-slot-layout-panel.ts +208 -0
  19. package/client/modeller/figure-verdict.ts +21 -31
  20. package/client/modeller/first-save.ts +23 -0
  21. package/client/modeller/links.ts +52 -1
  22. package/client/modeller/material-edit.ts +38 -0
  23. package/client/modeller/maturity.ts +28 -0
  24. package/client/modeller/mesh-import.ts +77 -0
  25. package/client/modeller/modeller-route.ts +9 -0
  26. package/client/modeller/mounting-part.ts +15 -0
  27. package/client/modeller/newest-answer.ts +21 -0
  28. package/client/modeller/preview-model.ts +5 -0
  29. package/client/modeller/refusals.ts +168 -82
  30. package/client/modeller/section-controls.ts +25 -1
  31. package/client/modeller/section-drawing.ts +43 -1
  32. package/client/modeller/slot-layout-host.ts +140 -0
  33. package/client/pages/figure-modeller-page.ts +6 -5
  34. package/client/viewparts/detail-route.ts +7 -0
  35. package/client/viewparts/figure-detail-dock.ts +1 -3
  36. package/dist-client/bootstrap.d.ts +1 -1
  37. package/dist-client/bootstrap.js +9 -2
  38. package/dist-client/bootstrap.js.map +1 -1
  39. package/dist-client/graphql/index.d.ts +5 -0
  40. package/dist-client/graphql/index.js +19 -0
  41. package/dist-client/graphql/index.js.map +1 -1
  42. package/dist-client/modeller/canvas.d.ts +2 -0
  43. package/dist-client/modeller/canvas.js +10 -2
  44. package/dist-client/modeller/canvas.js.map +1 -1
  45. package/dist-client/modeller/carrier-declaration.d.ts +31 -0
  46. package/dist-client/modeller/carrier-declaration.js +53 -0
  47. package/dist-client/modeller/carrier-declaration.js.map +1 -0
  48. package/dist-client/modeller/editor.d.ts +13 -0
  49. package/dist-client/modeller/editor.js +62 -27
  50. package/dist-client/modeller/editor.js.map +1 -1
  51. package/dist-client/modeller/figure-carrier.d.ts +26 -0
  52. package/dist-client/modeller/figure-carrier.js +123 -0
  53. package/dist-client/modeller/figure-carrier.js.map +1 -0
  54. package/dist-client/modeller/figure-cavity-drawing.js +3 -11
  55. package/dist-client/modeller/figure-cavity-drawing.js.map +1 -1
  56. package/dist-client/modeller/figure-first-save.d.ts +4 -0
  57. package/dist-client/modeller/figure-first-save.js +5 -8
  58. package/dist-client/modeller/figure-first-save.js.map +1 -1
  59. package/dist-client/modeller/figure-history-panel.js +2 -4
  60. package/dist-client/modeller/figure-history-panel.js.map +1 -1
  61. package/dist-client/modeller/figure-links.d.ts +4 -1
  62. package/dist-client/modeller/figure-links.js +47 -57
  63. package/dist-client/modeller/figure-links.js.map +1 -1
  64. package/dist-client/modeller/figure-material-panel.d.ts +0 -1
  65. package/dist-client/modeller/figure-material-panel.js +4 -14
  66. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  67. package/dist-client/modeller/figure-mesh-choice.d.ts +16 -0
  68. package/dist-client/modeller/figure-mesh-choice.js +75 -0
  69. package/dist-client/modeller/figure-mesh-choice.js.map +1 -0
  70. package/dist-client/modeller/figure-parts.d.ts +1 -1
  71. package/dist-client/modeller/figure-parts.js +22 -0
  72. package/dist-client/modeller/figure-parts.js.map +1 -1
  73. package/dist-client/modeller/figure-preview.js +2 -2
  74. package/dist-client/modeller/figure-preview.js.map +1 -1
  75. package/dist-client/modeller/figure-properties.d.ts +2 -1
  76. package/dist-client/modeller/figure-properties.js +5 -3
  77. package/dist-client/modeller/figure-properties.js.map +1 -1
  78. package/dist-client/modeller/figure-section-editor.d.ts +2 -2
  79. package/dist-client/modeller/figure-section-editor.js +12 -33
  80. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  81. package/dist-client/modeller/figure-section-panel.js +6 -7
  82. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  83. package/dist-client/modeller/figure-slot-layout-panel.d.ts +36 -0
  84. package/dist-client/modeller/figure-slot-layout-panel.js +198 -0
  85. package/dist-client/modeller/figure-slot-layout-panel.js.map +1 -0
  86. package/dist-client/modeller/figure-verdict.d.ts +1 -1
  87. package/dist-client/modeller/figure-verdict.js +19 -37
  88. package/dist-client/modeller/figure-verdict.js.map +1 -1
  89. package/dist-client/modeller/first-save.d.ts +10 -0
  90. package/dist-client/modeller/first-save.js +16 -0
  91. package/dist-client/modeller/first-save.js.map +1 -1
  92. package/dist-client/modeller/links.d.ts +26 -1
  93. package/dist-client/modeller/links.js +43 -0
  94. package/dist-client/modeller/links.js.map +1 -1
  95. package/dist-client/modeller/material-edit.d.ts +11 -0
  96. package/dist-client/modeller/material-edit.js +28 -0
  97. package/dist-client/modeller/material-edit.js.map +1 -0
  98. package/dist-client/modeller/maturity.d.ts +15 -0
  99. package/dist-client/modeller/maturity.js +23 -0
  100. package/dist-client/modeller/maturity.js.map +1 -1
  101. package/dist-client/modeller/mesh-import.d.ts +47 -0
  102. package/dist-client/modeller/mesh-import.js +47 -0
  103. package/dist-client/modeller/mesh-import.js.map +1 -0
  104. package/dist-client/modeller/modeller-route.d.ts +5 -0
  105. package/dist-client/modeller/modeller-route.js +9 -0
  106. package/dist-client/modeller/modeller-route.js.map +1 -1
  107. package/dist-client/modeller/mounting-part.d.ts +14 -0
  108. package/dist-client/modeller/mounting-part.js +10 -0
  109. package/dist-client/modeller/mounting-part.js.map +1 -0
  110. package/dist-client/modeller/newest-answer.d.ts +12 -0
  111. package/dist-client/modeller/newest-answer.js +21 -0
  112. package/dist-client/modeller/newest-answer.js.map +1 -0
  113. package/dist-client/modeller/preview-model.d.ts +2 -0
  114. package/dist-client/modeller/preview-model.js +4 -0
  115. package/dist-client/modeller/preview-model.js.map +1 -1
  116. package/dist-client/modeller/refusals.d.ts +26 -5
  117. package/dist-client/modeller/refusals.js +184 -86
  118. package/dist-client/modeller/refusals.js.map +1 -1
  119. package/dist-client/modeller/section-controls.d.ts +17 -1
  120. package/dist-client/modeller/section-controls.js +18 -0
  121. package/dist-client/modeller/section-controls.js.map +1 -1
  122. package/dist-client/modeller/section-drawing.d.ts +24 -1
  123. package/dist-client/modeller/section-drawing.js +42 -0
  124. package/dist-client/modeller/section-drawing.js.map +1 -1
  125. package/dist-client/modeller/slot-layout-host.d.ts +81 -0
  126. package/dist-client/modeller/slot-layout-host.js +105 -0
  127. package/dist-client/modeller/slot-layout-host.js.map +1 -0
  128. package/dist-client/pages/figure-modeller-page.js +5 -4
  129. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  130. package/dist-client/tsconfig.tsbuildinfo +1 -1
  131. package/dist-client/viewparts/detail-route.d.ts +5 -0
  132. package/dist-client/viewparts/detail-route.js +8 -0
  133. package/dist-client/viewparts/detail-route.js.map +1 -0
  134. package/dist-client/viewparts/figure-detail-dock.d.ts +0 -1
  135. package/dist-client/viewparts/figure-detail-dock.js +1 -3
  136. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  137. package/package.json +9 -8
  138. package/test/align.test.ts +0 -9
  139. package/test/browser/assistant-stand-in.js +32 -0
  140. package/test/browser/build.mjs +6 -1
  141. package/test/browser/editing-page.js +224 -0
  142. package/test/browser/editing.check.mjs +550 -0
  143. package/test/browser/links-page.js +113 -0
  144. package/test/browser/links.check.mjs +568 -0
  145. package/test/browser/modeller.check.mjs +21 -0
  146. package/test/browser/parts-page.js +120 -0
  147. package/test/browser/parts.check.mjs +328 -0
  148. package/test/browser/places-mesh-page.js +36 -0
  149. package/test/browser/places-mesh.check.mjs +239 -0
  150. package/test/browser/product-page.check.mjs +682 -0
  151. package/test/browser/product-page.js +276 -0
  152. package/test/browser/screen-page.js +112 -0
  153. package/test/browser/screen-product-page.js +62 -0
  154. package/test/browser/screen.check.mjs +1003 -0
  155. package/test/browser/section-page.js +256 -0
  156. package/test/browser/section.check.mjs +719 -0
  157. package/test/browser/serve.mjs +20 -12
  158. package/test/carrier-declaration.test.ts +69 -0
  159. package/test/cavity-drawing.test.ts +2 -18
  160. package/test/clip-ai-proposal.test.ts +1 -5
  161. package/test/clip-consent.test.ts +0 -5
  162. package/test/clips.test.ts +0 -7
  163. package/test/command-preview.test.ts +0 -68
  164. package/test/controls.test.ts +0 -9
  165. package/test/detail-dock.test.ts +6 -25
  166. package/test/face-mapping.test.ts +1 -20
  167. package/test/fasten-drawing.test.ts +0 -23
  168. package/test/fields.test.ts +6 -33
  169. package/test/figure-assistant-surface.test.ts +32 -38
  170. package/test/first-minute.test.ts +3 -55
  171. package/test/first-save-suggest.test.ts +11 -8
  172. package/test/first-save.test.ts +4 -26
  173. package/test/gizmo.test.ts +0 -25
  174. package/test/joint-fan.test.ts +0 -10
  175. package/test/keys.test.ts +0 -7
  176. package/test/layout.test.ts +4 -73
  177. package/test/links.test.ts +48 -67
  178. package/test/live.test.ts +0 -34
  179. package/test/material.test.ts +17 -26
  180. package/test/maturity.test.ts +13 -16
  181. package/test/mesh-import.test.ts +76 -0
  182. package/test/modeller-route.test.ts +3 -19
  183. package/test/package-download.test.ts +6 -7
  184. package/test/parts-wiring.test.ts +9 -51
  185. package/test/preview.test.ts +5 -46
  186. package/test/properties.test.ts +11 -26
  187. package/test/proposal-record.test.ts +2 -19
  188. package/test/recipes.test.ts +0 -15
  189. package/test/refusals.test.ts +257 -151
  190. package/test/release-entry.test.ts +1 -9
  191. package/test/release-panel.test.ts +1 -22
  192. package/test/resize-with-placement.test.ts +0 -32
  193. package/test/revert-choice.test.ts +1 -22
  194. package/test/revert-keeping.test.ts +1 -12
  195. package/test/save-landed.test.ts +16 -14
  196. package/test/scene-canvas.test.ts +2 -13
  197. package/test/section-editor.test.ts +30 -83
  198. package/test/section-panel.test.ts +22 -17
  199. package/test/slot-layout-host.test.ts +117 -0
  200. package/test/theme.test.ts +5 -9
  201. package/test/verdict.test.ts +44 -41
  202. package/test/view.test.ts +5 -12
  203. package/translations/en.json +61 -1
  204. package/translations/es.json +61 -1
  205. package/translations/ja.json +61 -1
  206. package/translations/ko.json +61 -1
  207. package/translations/ms.json +61 -1
  208. package/translations/zh.json +61 -1
  209. package/test/thumbnail.test.ts +0 -29
@@ -0,0 +1,208 @@
1
+ import { css, html, LitElement, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { i18next, localize } from '@operato/i18n'
4
+ import '@material/web/icon/icon.js'
5
+ import '@operato/slot-layout-editor'
6
+ import { compileFigureAsset, type FigureAsset, type SlotLayout } from '@hatiolab/figure-model'
7
+
8
+ import {
9
+ anchorFacesOf,
10
+ anchorIdFor,
11
+ figureSlotIssues,
12
+ slotLayoutCommandOf,
13
+ slotLayoutFormOf,
14
+ slotPresetsOf,
15
+ type SlotAnchor,
16
+ type SlotAnchorFace,
17
+ type SlotLayoutForm
18
+ } from './slot-layout-host.js'
19
+
20
+ /**
21
+ * A figure's slot layout (ADR-0118): places made by a rule -- anchors on parts' tops or bottoms, tiers on them, slots in
22
+ * the tiers. The tiers and slots are edited in the shared editor (`ox-slot-layout-editor`, the one fmsim hosts); this
23
+ * panel owns what only the modeller knows: which part's face each anchor is, the layout's name and what its slots are.
24
+ * Every change goes out as one `set-slot-layout` command -- one step of the history, refused by figure-model in its words.
25
+ *
26
+ * The layout here is the only one: a scene or a run never overrides it (ADR-0118 decision 8), so there is no "default".
27
+ */
28
+ @customElement('figure-slot-layout-panel')
29
+ export class FigureSlotLayoutPanel extends localize(i18next)(LitElement) {
30
+ static styles = css`
31
+ :host {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 6px;
35
+ }
36
+ div[form] {
37
+ display: grid;
38
+ grid-template-columns: auto 1fr;
39
+ gap: 4px 8px;
40
+ align-items: center;
41
+ }
42
+ ul {
43
+ list-style: none;
44
+ margin: 0;
45
+ padding: 0;
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 4px;
49
+ }
50
+ li {
51
+ display: grid;
52
+ grid-template-columns: 1fr 1fr auto auto;
53
+ gap: 4px;
54
+ align-items: center;
55
+ }
56
+ input,
57
+ select {
58
+ min-width: 0;
59
+ font: inherit;
60
+ }
61
+ div[actions] {
62
+ display: flex;
63
+ flex-wrap: wrap;
64
+ gap: 4px;
65
+ }
66
+ button {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 4px;
70
+ font: inherit;
71
+ }
72
+ md-icon {
73
+ --md-icon-size: 16px;
74
+ }
75
+ small[hint] {
76
+ color: var(--md-sys-color-on-surface-variant);
77
+ }
78
+ `
79
+
80
+ @property({ attribute: false }) asset?: FigureAsset
81
+ /** The part selected on the canvas: what a new anchor stands on. */
82
+ @property() partId = ''
83
+ @property({ type: Boolean }) locked = false
84
+
85
+ /** The name, role and anchors being edited; started again from the figure whenever the figure changes. */
86
+ @state() private form?: SlotLayoutForm
87
+ private formFor?: FigureAsset
88
+ private options?: { key: string; asset: FigureAsset; value: object }
89
+ /*
90
+ The figure's layout, read once per figure: the editor starts its draft again whenever its value changes, so a value
91
+ read again every render would throw the author's edits away.
92
+ */
93
+ private stored?: ReturnType<typeof slotLayoutFormOf>
94
+
95
+ willUpdate() {
96
+ /* A figure changed under the panel (an undo, another edit): what is shown is the figure's, not an old draft. */
97
+ if (this.asset !== this.formFor) {
98
+ this.formFor = this.asset
99
+ this.stored = this.asset ? slotLayoutFormOf(this.asset, this.partId) : undefined
100
+ this.form = this.stored?.form
101
+ }
102
+ }
103
+
104
+ render() {
105
+ const asset = this.asset
106
+ const stored = this.stored
107
+ const form = this.form
108
+ if (!asset || !stored || !form) return nothing
109
+ const parts = asset.document.model.nodes.filter(n => n.op === 'place@1').map(n => n.id)
110
+ const changed = JSON.stringify(form) !== JSON.stringify(stored.form)
111
+ return html`
112
+ <div form>
113
+ <span>${i18next.t('figure.label.slot-layout-name')}</span>
114
+ <input .value=${form.label} placeholder=${i18next.t('figure.label.slot-layout')} ?disabled=${this.locked} @change=${(e: Event) => this.edit({ label: (e.target as HTMLInputElement).value })} />
115
+ <span>${i18next.t('figure.label.slot-layout-role')}</span>
116
+ <select ?disabled=${this.locked} @change=${(e: Event) => this.edit({ role: (e.target as HTMLSelectElement).value as 'seat' | 'grip' })}>
117
+ <option value="seat" ?selected=${form.role === 'seat'}>${i18next.t('figure.label.place-seat')}</option>
118
+ <option value="grip" ?selected=${form.role === 'grip'}>${i18next.t('figure.label.place-grip')}</option>
119
+ </select>
120
+ </div>
121
+
122
+ <h4>${i18next.t('figure.label.slot-anchors')}</h4>
123
+ <ul>
124
+ ${form.anchors.map(
125
+ (a, i) => html`<li>
126
+ <input .value=${a.id} aria-label=${i18next.t('figure.label.slot-anchor-id')} ?disabled=${this.locked} @change=${(e: Event) => this.anchor(i, { id: (e.target as HTMLInputElement).value.trim() })} />
127
+ <select aria-label=${i18next.t('figure.label.part')} ?disabled=${this.locked} @change=${(e: Event) => this.anchor(i, { part: (e.target as HTMLSelectElement).value })}>
128
+ ${parts.map(p => html`<option value=${p} ?selected=${p === a.part}>${p}</option>`)}
129
+ </select>
130
+ <select aria-label=${i18next.t('figure.label.face')} ?disabled=${this.locked} @change=${(e: Event) => this.anchor(i, { face: (e.target as HTMLSelectElement).value as SlotAnchorFace })}>
131
+ ${(['top', 'bottom'] as const).map(f => html`<option value=${f} ?selected=${f === a.face}>${i18next.t('figure.label.face-' + f)}</option>`)}
132
+ </select>
133
+ <button ?disabled=${this.locked || form.anchors.length <= 1} title=${i18next.t('figure.button.remove-slot-anchor')} @click=${() => this.edit({ anchors: form.anchors.filter((_, k) => k !== i) })}>
134
+ <md-icon>close</md-icon>
135
+ </button>
136
+ </li>`
137
+ )}
138
+ </ul>
139
+ <div actions>
140
+ ${(['top', 'bottom'] as const).map(
141
+ face => html`<button ?disabled=${this.locked || !this.partId} @click=${() => this.addAnchor(face)}>
142
+ <md-icon>add</md-icon>${i18next.t(face === 'top' ? 'figure.button.add-slot-anchor-top' : 'figure.button.add-slot-anchor-bottom', { part: this.partId })}
143
+ </button>`
144
+ )}
145
+ </div>
146
+ <small hint>${i18next.t('figure.text.slot-anchor-pick')}</small>
147
+
148
+ <ox-slot-layout-editor .value=${stored.layout} .options=${this.editorOptions(asset, form, stored.layout)} @change=${(e: Event) => this.apply((e.target as { value?: SlotLayout }).value)}></ox-slot-layout-editor>
149
+
150
+ <div actions>
151
+ ${stored.layout && changed
152
+ ? html`<button ?disabled=${this.locked} @click=${() => this.apply(stored.layout)}><md-icon>check</md-icon>${i18next.t('figure.button.apply-slot-anchors')}</button>`
153
+ : nothing}
154
+ ${stored.layout
155
+ ? html`<button ?disabled=${this.locked} @click=${() => this.command({ kind: 'remove-slot-layout' })}><md-icon>delete</md-icon>${i18next.t('figure.button.remove-slot-layout')}</button>`
156
+ : nothing}
157
+ </div>
158
+ `
159
+ }
160
+
161
+ /*
162
+ The editor's options, made again only when what they are made from changes: the editor starts its draft again when its
163
+ starting layout changes, so a new object every render would throw the author's edits away.
164
+ */
165
+ private editorOptions(asset: FigureAsset, form: SlotLayoutForm, layout: SlotLayout | undefined) {
166
+ const key = JSON.stringify([form.anchors, !!layout])
167
+ if (this.options?.key === key && this.options.asset === asset) return this.options.value
168
+ const geometry = compileFigureAsset(asset).evaluate().geometry as never
169
+ const anchorFaces = anchorFacesOf(form.anchors, geometry)
170
+ const value = {
171
+ canUseDefault: false,
172
+ presets: slotPresetsOf(form.anchors, key => i18next.t(key)),
173
+ /* The first anchor's face is the size a preset is laid out over; the slot height is the author's. */
174
+ presetSize: anchorFaces[form.anchors[0]?.id ?? ''] ?? {},
175
+ anchors: form.anchors.map(a => ({ id: a.id, label: `${a.id} · ${a.part} ${i18next.t('figure.label.face-' + a.face)}` })),
176
+ anchorFaces,
177
+ validate: (layout: SlotLayout) => figureSlotIssues(layout, form.anchors.map(a => a.id)),
178
+ issueText: (issue: { code: string; path: string }) =>
179
+ issue.code === 'ACCEPTS_REQUIRED' ? `${issue.path}: ${i18next.t('figure.text.slot-accepts-required')}` : undefined
180
+ }
181
+ this.options = { key, asset, value }
182
+ return value
183
+ }
184
+
185
+ private edit(patch: Partial<SlotLayoutForm>) {
186
+ this.form = { ...this.form!, ...patch }
187
+ }
188
+
189
+ private anchor(i: number, patch: Partial<SlotAnchor>) {
190
+ this.edit({ anchors: this.form!.anchors.map((a, k) => (k === i ? { ...a, ...patch } : a)) })
191
+ }
192
+
193
+ /** A new anchor on the selected part's face: what an author picks on the canvas comes here. */
194
+ private addAnchor(face: SlotAnchorFace) {
195
+ const anchors = this.form!.anchors
196
+ this.edit({ anchors: [...anchors, { id: anchorIdFor(this.partId, anchors.map(a => a.id)), part: this.partId, face }] })
197
+ }
198
+
199
+ /** The whole layout with the anchors and name as they stand: one command, one step. */
200
+ private apply(layout: SlotLayout | undefined) {
201
+ if (!layout || !this.form) return
202
+ this.command(slotLayoutCommandOf(this.form, layout, i18next.t('figure.label.slot-layout')))
203
+ }
204
+
205
+ private command(action: object) {
206
+ this.dispatchEvent(new CustomEvent('command', { detail: { action }, bubbles: true, composed: true }))
207
+ }
208
+ }
@@ -5,7 +5,7 @@ import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
7
  import { ScrollbarStyles } from '@operato/styles'
8
- import { LIMITS, FIGURE_PER_INSTANCE, figureCostOf } from '@hatiolab/figure-model'
8
+ import { LIMITS, FIGURE_PER_INSTANCE } from '@hatiolab/figure-model'
9
9
 
10
10
  import { inspectFigureDraft } from '../graphql/index.js'
11
11
  import type { FigureInspection } from '../types.js'
@@ -13,7 +13,8 @@ import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
13
13
  import { MEASURED } from './scene-budget.js'
14
14
  import type { FigureRevision } from './revision.js'
15
15
  import { SidePanelStyles } from './side-panel-styles.js'
16
- import { budgetOf, CURVE_SIZE, curveLayout, GRADE_SCALE, shortNumber, figureReportOf } from './maturity.js'
16
+ import { CURVE_SIZE, curveLayout, gradeBoundsOf, shortNumber, verdictMeasureOf } from './maturity.js'
17
+ import { newestAnswer } from './newest-answer.js'
17
18
  import type { FigureBudgetUse, FigureMaturityAxis, FigureReport } from './maturity.js'
18
19
 
19
20
  /** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
@@ -400,7 +401,7 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
400
401
 
401
402
  private timer?: ReturnType<typeof setTimeout>
402
403
  /** Answers can come back out of order; only the newest question's answer is shown. */
403
- private asked = 0
404
+ private answers = newestAnswer<FigureInspection>()
404
405
 
405
406
  disconnectedCallback() {
406
407
  clearTimeout(this.timer)
@@ -423,21 +424,11 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
423
424
  }
424
425
 
425
426
  private measure() {
426
- let report: FigureReport | undefined
427
- let bodies = 0
428
- try {
429
- const asset = this.revision?.asset
430
- const cost = asset ? figureCostOf(asset) : undefined
431
- bodies = cost ? cost.groups + cost.hidden : 0
432
- report = asset ? figureReportOf(asset) : undefined
433
- } catch {
434
- /* A source the format refuses has no report; the server's finding below says why. */
435
- report = undefined
436
- }
427
+ const { report, bodies, told } = verdictMeasureOf(this.revision?.asset)
437
428
  this.report = report
438
429
  this.bodies = bodies
439
430
  if (!report) this.expanded = false
440
- this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: report?.capacity.instances, budget: budgetOf(report) } }))
431
+ this.dispatchEvent(new CustomEvent('capacity-changed', { detail: told }))
441
432
  }
442
433
 
443
434
  private schedule() {
@@ -450,22 +441,22 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
450
441
  }
451
442
 
452
443
  private async ask() {
453
- const question = ++this.asked
454
444
  const source = this.revision!.text
455
445
  this.asking = true
456
- try {
457
- const inspection = await inspectFigureDraft(source, this.figureId)
458
- if (question !== this.asked) return
459
- this.inspection = inspection
460
- this.failure = ''
461
- } catch (error) {
462
- if (question !== this.asked) return
463
- /* The server's reason as it gave it — the screen does not make one up. The last answer is dropped with it. */
464
- this.inspection = undefined
465
- this.failure = error instanceof Error ? error.message : String(error)
466
- } finally {
467
- if (question === this.asked) this.asking = false
468
- }
446
+ await this.answers(
447
+ () => inspectFigureDraft(source, this.figureId),
448
+ outcome => {
449
+ this.asking = false
450
+ if ('answer' in outcome) {
451
+ this.inspection = outcome.answer
452
+ this.failure = ''
453
+ } else {
454
+ /* The server's reason as it gave it — the screen does not make one up. The last answer is dropped with it. */
455
+ this.inspection = undefined
456
+ this.failure = outcome.failure instanceof Error ? outcome.failure.message : String(outcome.failure)
457
+ }
458
+ }
459
+ )
469
460
  }
470
461
 
471
462
  render() {
@@ -545,11 +536,10 @@ export class FigureVerdict extends localize(i18next)(LitElement) {
545
536
 
546
537
  /** A · B · C · D and where this figure stands, drawn from the bounds the grade is computed with. */
547
538
  private renderGradeScale(now: string) {
548
- const bounds = [...GRADE_SCALE.map(([grade, bound]) => [grade, `≤ ${bound * 100}%`]), ['D', '> 100%']]
549
539
  return html`
550
540
  <p scale>
551
541
  <span>${i18next.t('figure.text.grade-scale-lead')}</span>
552
- ${bounds.map(([grade, bound]) => html`<span ?now=${grade === now}><b>${grade}</b> ${bound}</span>`)}
542
+ ${gradeBoundsOf().map(([grade, bound]) => html`<span ?now=${grade === now}><b>${grade}</b> ${bound}</span>`)}
553
543
  </p>
554
544
  `
555
545
  }
@@ -35,6 +35,29 @@ export function suggestTypeOf(name: string, taken: readonly string[], random: ()
35
35
  }
36
36
  }
37
37
 
38
+ /** The shape of an identifier the modeller offers (`suggestTypeOf`'s random form). */
39
+ const OFFERED = /^fig-[0-9a-z]{6}$/
40
+
41
+ /**
42
+ * The identifier the first save's question shows, given the one it opens on (Q-D3). The identifier is not the name:
43
+ * renaming does not change it, and a retried first save opens on the one answered before. A new one is offered only when
44
+ * there is none, or when an offered one is found taken -- told by the server, or in the list; one the author typed is
45
+ * kept as typed, and its refusal said. `suggested` is the identifier now on offer, absent when that is unchanged.
46
+ */
47
+ export function offeredTypeOf(
48
+ type: string,
49
+ name: string,
50
+ taken: readonly string[],
51
+ random: () => number = Math.random
52
+ ): { type: string; suggested?: string } {
53
+ const offeredAndTaken = OFFERED.test(type) && taken.includes(type)
54
+ if (!type || offeredAndTaken) {
55
+ const made = suggestTypeOf(name, taken, random)
56
+ return { type: made, suggested: made }
57
+ }
58
+ return OFFERED.test(type) ? { type, suggested: type } : { type }
59
+ }
60
+
38
61
  /** Why the first save cannot go on with this type, as an i18n key, or null when it can. */
39
62
  export function firstSaveRefusalOf(type: string, taken: readonly string[]): { key: string; type?: string } | null {
40
63
  if (!type) return { key: 'figure.text.enter-a-type-name' }
@@ -1,7 +1,8 @@
1
1
  import { FIGURE_FACES, figureShapeFaces, figurePartsExtentOf, compileFigureAsset, figureFollowOf, proposeFigureProportion, defaultInstanceSizeRange, foldInstanceSizeRange, figureAttachmentsOf, figureBindingsOf, figureInstanceSizeLimitsOf, figureFollowableAxesOf, figureShapeProviderOf, figureMimicTermsOf } from '@hatiolab/figure-model'
2
2
  import type { FigureFollow, FigureSizeRange } from '@hatiolab/figure-model'
3
- import type { FigureAsset, FigureBindingPlace, FigureFace, FigureJoint } from '@hatiolab/figure-model'
3
+ import type { FigureAsset, FigureAuthoringAction, FigureBindingPlace, FigureDimensionSource, FigureFace, FigureJoint } from '@hatiolab/figure-model'
4
4
  import { startingShapeKindOf } from './starting-shape.js'
5
+ import { boxOf } from './align.js'
5
6
  import { graphValuesOf, type FigureRevision } from './revision.js'
6
7
  import { jointAxisToken, jointAxisVectorOf, type JointAxisVector } from './joint-axis.js'
7
8
 
@@ -178,6 +179,27 @@ export function dimensionNow(revision: FigureRevision, part: string, axis: Axis)
178
179
  }
179
180
  }
180
181
 
182
+ /**
183
+ * The command a dimension's rule chosen in the size form sends (`choice` as `choiceOf` writes it), or nothing to send.
184
+ * The measure as it stands decides the numbers: a dimension stops following at it, and a share is taken of it, so
185
+ * neither choice changes the part's size. A share needs the figure's size on that axis.
186
+ */
187
+ export function followCommandOf(revision: FigureRevision, part: string, axis: Axis, choice: string, now: Follow): FigureAuthoringAction | undefined {
188
+ const following = now.of !== 'own'
189
+ /* The part's own measure on the axis now (a cylinder's radius, not its width), else its extent as drawn. */
190
+ const current = dimensionNow(revision, part, axis) ?? boxOf(revision, part)?.dims[AXES.indexOf(axis)] ?? 100
191
+ if (choice === 'own') return following ? { kind: 'unlink-dimension', part, axis, mm: Math.max(1, Math.round(current)) } : undefined
192
+ const [of, a, b] = choice.split(':')
193
+ if (of === 'share') {
194
+ const size = revision.asset.designInputs[`size.${a}`]
195
+ if (!size) return undefined
196
+ const times = Math.round((current / size) * 1e6) / 1e6
197
+ return { kind: 'link-dimension', part, axis, source: { of: 'instance', axis: a as Axis }, times, replace: following }
198
+ }
199
+ const source: FigureDimensionSource = of === 'instance' ? { of: 'instance', axis: a as Axis } : { of: 'part', part: a!, axis: b as Axis }
200
+ return { kind: 'link-dimension', part, axis, source, replace: following }
201
+ }
202
+
181
203
  /**
182
204
  * How long a part is along one axis now, as it is drawn: the shape's own measure through figure-model's provider (a
183
205
  * cylinder's width is twice its radius), read after any rule it follows -- so a linked measure still says its size.
@@ -290,6 +312,35 @@ export function alignAxesOf(asset: FigureAsset, part: string, target: { part: st
290
312
  return AXES.filter(axis => axis !== along).map(axis => ({ axis, edges: !twisted && OUTER[axis].every(f => theirs.has(f) && mine.has(f)) }))
291
313
  }
292
314
 
315
+ /**
316
+ * The faces of its own a part may be fastened by: its outer faces -- on the mounting plane only the ones the plane takes
317
+ * (a bottom or a top on the floor, a back on a wall) -- and, to another part, its centre, which a roller spins about.
318
+ */
319
+ export function ownFacesOf(asset: FigureAsset, part: string, toPlane: boolean): Array<FigureFace | 'centre'> {
320
+ const outer = Object.keys(FIGURE_FACES) as FigureFace[]
321
+ const plane = mountingPlaneOf(asset)
322
+ return [
323
+ ...(facesOf(asset, part).filter(face => outer.includes(face as FigureFace) && (!toPlane || plane.faces.includes(face as FigureFace))) as FigureFace[]),
324
+ ...(toPlane ? [] : (['centre'] as const))
325
+ ]
326
+ }
327
+
328
+ /**
329
+ * The axes across the face the fastening form asks about in rows: only where the face drawing (`view`, its axes) cannot
330
+ * set the place -- no drawing at all, an axis it does not draw, a place that follows a position field, or one the
331
+ * figure's size may scale.
332
+ */
333
+ export function alignRowsOf(
334
+ asset: FigureAsset,
335
+ part: string,
336
+ aim: { part: string; face: string } | 'plane',
337
+ view: { axes: readonly Axis[] } | undefined,
338
+ twisted: boolean,
339
+ alignInput: Partial<Record<Axis, string>> = {}
340
+ ): { axis: Axis; edges: boolean }[] {
341
+ return alignAxesOf(asset, part, aim, twisted).filter(({ axis }) => !view || !view.axes.includes(axis) || !!alignInput[axis] || hasInstanceSize(asset, axis))
342
+ }
343
+
293
344
  /** The controls a part's showing can follow: the figure's state inputs, with their range and label. */
294
345
  export function visibilityControlsOf(asset: FigureAsset): Array<{ id: string; unit: string; min: number; max: number; label: string }> {
295
346
  const labels = (asset as unknown as { stateInputs?: Record<string, { label?: string }> }).stateInputs ?? {}
@@ -0,0 +1,38 @@
1
+ import type { FigureMaterial, FigurePartEdit } from '@hatiolab/figure-model'
2
+
3
+ /*
4
+ * The edit the material panel (`figure-material-panel`) sends for a change of a part's material: worked out here, apart
5
+ * from the panel, so what it sends can be read without a screen.
6
+ */
7
+
8
+ /** The colours a material is picked from: the app's palette, with the asset's own colours over it. */
9
+ export function offeredPaletteOf(app: Record<string, string>, asset: Record<string, string>): Record<string, string> {
10
+ return { ...app, ...asset }
11
+ }
12
+
13
+ /** The glow at an intensity: 0 is no glow at all; a new glow takes the part's own colour until another is picked. */
14
+ export function glowOf(material: FigureMaterial, intensity: number): Partial<FigureMaterial> {
15
+ return { emissive: intensity > 0 ? { token: material.emissive?.token ?? material.token, intensity, on: true } : undefined }
16
+ }
17
+
18
+ /**
19
+ * The material with a change over it, as one `set-material` edit. A field the change empties is left out -- the format
20
+ * refuses an undefined field. A colour it names that the asset does not carry yet (a new asset carries only its primary
21
+ * colour) is sent with it, from the colours offered, and added in the same edit.
22
+ */
23
+ export function materialEditOf(
24
+ target: string,
25
+ material: FigureMaterial,
26
+ patch: Partial<FigureMaterial>,
27
+ assetPalette: Record<string, string>,
28
+ offered: Record<string, string>
29
+ ): FigurePartEdit {
30
+ const next: FigureMaterial = { ...material, ...patch }
31
+ for (const key of Object.keys(next) as (keyof FigureMaterial)[]) if (next[key] === undefined) delete next[key]
32
+ const palette = Object.fromEntries(
33
+ [next.token, next.emissive?.token]
34
+ .filter((token): token is string => !!token && assetPalette[token] === undefined && offered[token] !== undefined)
35
+ .map(token => [token, offered[token]!])
36
+ )
37
+ return { kind: 'set-material', target, material: next, ...(Object.keys(palette).length ? { palette } : {}) }
38
+ }
@@ -50,6 +50,11 @@ import type { FigureCapacity } from './scene-budget.js'
50
50
  export { FIGURE_BUDGET_KEYS, FIGURE_GRADE_SCALE as GRADE_SCALE, figureGradeOf as gradeOf }
51
51
  export type { FigureBudgetKey, FigureBudgetUse, FigureScore }
52
52
 
53
+ /** A · B · C · D as the tab draws its scale: each grade with the bound it is computed with, and D past the last. */
54
+ export function gradeBoundsOf(): [string, string][] {
55
+ return [...FIGURE_GRADE_SCALE.map(([grade, bound]): [string, string] => [grade, `≤ ${bound * 100}%`]), ['D', '> 100%']]
56
+ }
57
+
53
58
  export type FigureAxisKey = 'scale' | 'consistency' | 'reuse' | 'detail' | 'reactive' | 'readable'
54
59
 
55
60
  /** One maturity axis. */
@@ -411,6 +416,29 @@ export interface FigureBudgetLine {
411
416
  tightest: { key: string; used: number; limit: number }
412
417
  }
413
418
 
419
+ /**
420
+ * What the verdict tab measures of the figure on screen once it settles: the report, the bodies there are to fit the
421
+ * space to (drawn or hidden -- a hidden part is still a part to measure), and what it tells the page: the count for its
422
+ * tab and the parts list's line, from the same report. A source the format refuses has no report.
423
+ */
424
+ export function verdictMeasureOf(asset: FigureAsset | undefined): {
425
+ report?: FigureReport
426
+ bodies: number
427
+ told: { instances?: number; budget?: FigureBudgetLine }
428
+ } {
429
+ let report: FigureReport | undefined
430
+ let bodies = 0
431
+ try {
432
+ const cost = asset ? figureCostOf(asset) : undefined
433
+ bodies = cost ? cost.groups + cost.hidden : 0
434
+ report = asset ? figureReportOf(asset) : undefined
435
+ } catch {
436
+ /* A source the format refuses has no report; the server's finding says why. */
437
+ report = undefined
438
+ }
439
+ return { report, bodies, told: { instances: report?.capacity.instances, budget: budgetOf(report) } }
440
+ }
441
+
414
442
  export function budgetOf(report: FigureReport | undefined): FigureBudgetLine | undefined {
415
443
  if (!report) return undefined
416
444
  const { tightest } = report.score
@@ -0,0 +1,77 @@
1
+ /*
2
+ * A GLB made into a mesh part (ADR-0129, figure-model contract §13). The server reads the file when it is uploaded and
3
+ * answers its hash and what it holds; this turns that answer and the author's choice -- the whole file, or one node of it
4
+ * -- into the part figure-model adds. Kept free of Lit and the network so node tests read it directly.
5
+ */
6
+ import type { FigurePrimitiveCreation } from '@hatiolab/figure-model'
7
+
8
+ type Vec = { x: number; y: number; z: number }
9
+ type Box = { min: Vec; max: Vec }
10
+
11
+ /** What `uploadFigureMesh` answers: the file's hash and what the server read of it. */
12
+ export interface MeshUpload {
13
+ hash: string
14
+ summary: {
15
+ bounds: Box
16
+ triangles: number
17
+ primitives: number
18
+ unit: 'm'
19
+ nodes: Array<{
20
+ index: number
21
+ name: string
22
+ parent: number | null
23
+ bounds: Box | null
24
+ triangles: number
25
+ primitives: number
26
+ position: Vec
27
+ rotation: Vec
28
+ refused?: 'no-mesh' | 'sheared'
29
+ }>
30
+ }
31
+ }
32
+
33
+ /** What the author can make a part of: the whole file first, then each node that holds a mesh and has a frame. */
34
+ export function meshChoicesOf(upload: MeshUpload): Array<{ node?: number; name: string; triangles: number }> {
35
+ return [
36
+ { name: '', triangles: upload.summary.triangles },
37
+ ...upload.summary.nodes.filter(n => n.bounds && !n.refused).map(n => ({ node: n.index, name: n.name, triangles: n.triangles }))
38
+ ]
39
+ }
40
+
41
+ /** A part id from a node's name: letter-led, letters, digits, `_` and `-`; another number when it is taken. */
42
+ export function meshPartIdOf(name: string, taken: ReadonlySet<string>): string {
43
+ const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^[^a-zA-Z]+/, '').replace(/-+$/, '')
44
+ const base = cleaned || 'mesh'
45
+ if (!taken.has(base)) return base
46
+ let n = 2
47
+ while (taken.has(`${base}-${n}`)) n++
48
+ return `${base}-${n}`
49
+ }
50
+
51
+ /**
52
+ * The part: its bounds and counts as the server read them, its size the reach along its longest axis (so it is 1:1),
53
+ * and -- for a node -- standing where the node stood in the file, so a crane taken apart node by node stands as it did.
54
+ */
55
+ export function meshPartOf(upload: MeshUpload, choice: { node?: number }, id: string): FigurePrimitiveCreation {
56
+ const node = choice.node === undefined ? undefined : upload.summary.nodes.find(n => n.index === choice.node)
57
+ if (choice.node !== undefined && (!node?.bounds || node.refused)) throw new Error(`node ${choice.node} of the file cannot be a part`)
58
+ const bounds = node ? node.bounds! : upload.summary.bounds
59
+ const size = Math.max(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z)
60
+ const turned = node && (node.rotation.x || node.rotation.y || node.rotation.z)
61
+ return {
62
+ kind: 'mesh',
63
+ id,
64
+ dimensions: { size },
65
+ mesh: {
66
+ asset: upload.hash,
67
+ format: 'glb',
68
+ unit: upload.summary.unit,
69
+ ...(node ? { node: node.index } : {}),
70
+ bounds,
71
+ triangles: node ? node.triangles : upload.summary.triangles,
72
+ primitives: node ? node.primitives : upload.summary.primitives
73
+ },
74
+ position: node ? { ...node.position } : { x: 0, y: 0, z: 0 },
75
+ ...(turned ? { rotation: { ...node!.rotation } } : {})
76
+ }
77
+ }
@@ -7,3 +7,12 @@ export const MODELLER = 'figure-modeller'
7
7
  export function modellerPathOf(figure?: { id?: string }): string {
8
8
  return figure?.id ? `${MODELLER}/${figure.id}` : MODELLER
9
9
  }
10
+
11
+ /**
12
+ * Where one released version downloads as a package file, by its release reference {namespace}/{type}@{version}
13
+ * (ADR-0114) -- or undefined before the figure has a full name.
14
+ */
15
+ export function packageHrefOf(contextPath: string, namespace: string | null | undefined, type: string | null | undefined, version: number): string | undefined {
16
+ if (!namespace || !type) return undefined
17
+ return `${contextPath}/figure-package?refs=${encodeURIComponent(`${namespace}/${type}@${version}`)}`
18
+ }
@@ -0,0 +1,15 @@
1
+ import type { FigureFace } from '@hatiolab/figure-model'
2
+ import type { FigureFigureFacts } from './figure-facts.js'
3
+
4
+ /**
5
+ * The mounting face the properties tab offers: the part and face picked in its lists, else the one set, else where the
6
+ * figure meets its plane (`suggested`) -- a conveyor's legs, not its bed (walk 1, 2026-09-26) -- else the first part.
7
+ * A part no longer in the figure is passed over.
8
+ */
9
+ export function offeredMountingOf(
10
+ facts: Pick<FigureFigureFacts, 'parts' | 'mounting' | 'suggested'>,
11
+ picked: { part?: string; face?: FigureFace } = {}
12
+ ): { part?: string; face: FigureFace } {
13
+ const part = [picked.part, facts.mounting?.part, facts.suggested?.part, facts.parts[0]].find(one => one && facts.parts.includes(one))
14
+ return { part, face: picked.face ?? facts.mounting?.face ?? facts.suggested?.face ?? 'bottom' }
15
+ }
@@ -0,0 +1,21 @@
1
+ /** What came back for a question: its answer, or why there is none. */
2
+ export type FigureOutcome<T> = { answer: T } | { failure: unknown }
3
+
4
+ /**
5
+ * Questions asked again while an earlier one is still out (a figure edited while the server judges it): answers can
6
+ * come back out of order, and only the newest question's lands -- its answer or its failure alike. An older one arriving
7
+ * late is dropped, so it never covers the newer one.
8
+ */
9
+ export function newestAnswer<T>() {
10
+ let asked = 0
11
+ return async (question: () => Promise<T>, land: (outcome: FigureOutcome<T>) => void): Promise<void> => {
12
+ const mine = ++asked
13
+ let outcome: FigureOutcome<T>
14
+ try {
15
+ outcome = { answer: await question() }
16
+ } catch (failure) {
17
+ outcome = { failure }
18
+ }
19
+ if (mine === asked) land(outcome)
20
+ }
21
+ }
@@ -5,6 +5,11 @@ import type { FigureAsset } from '@hatiolab/figure-model'
5
5
 
6
6
  export const PREVIEW_FACTOR = 2
7
7
 
8
+ /** The type the preview registers the figure under: this figure's own, never a real type a board uses. */
9
+ export function previewTypeOf(context: string): string {
10
+ return `figure-preview-${context || 'new'}`
11
+ }
12
+
8
13
  export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
9
14
  { name: 'base', axes: [] },
10
15
  { name: 'x', axes: ['x'] },