@things-factory/figure-ui 10.1.153 → 10.1.155

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 (110) hide show
  1. package/client/modeller/canvas.ts +1 -1
  2. package/client/modeller/editor.ts +2 -2
  3. package/client/modeller/figure-capability-panel.ts +6 -24
  4. package/client/modeller/figure-carrier.ts +1 -1
  5. package/client/modeller/figure-clips.ts +1 -1
  6. package/client/modeller/figure-controls.ts +2 -2
  7. package/client/modeller/figure-face-mapping.ts +1 -1
  8. package/client/modeller/figure-links.ts +3 -2
  9. package/client/modeller/figure-material-panel.ts +1 -1
  10. package/client/modeller/figure-preview.ts +2 -2
  11. package/client/modeller/figure-properties.ts +1 -1
  12. package/client/modeller/figure-section-editor.ts +1 -1
  13. package/client/modeller/figure-section-panel.ts +1 -1
  14. package/client/modeller/figure-settings.ts +2 -2
  15. package/client/modeller/figure-verdict.ts +1 -1
  16. package/client/pages/figure-list-page.ts +7 -1
  17. package/client/viewparts/figure-detail-dock.ts +90 -41
  18. package/client/viewparts/figure-thumb.ts +2 -22
  19. package/dist-client/modeller/canvas.d.ts +1 -1
  20. package/dist-client/modeller/canvas.js +1 -1
  21. package/dist-client/modeller/canvas.js.map +1 -1
  22. package/dist-client/modeller/editor.js +2 -2
  23. package/dist-client/modeller/editor.js.map +1 -1
  24. package/dist-client/modeller/figure-capability-panel.d.ts +0 -3
  25. package/dist-client/modeller/figure-capability-panel.js +5 -18
  26. package/dist-client/modeller/figure-capability-panel.js.map +1 -1
  27. package/dist-client/modeller/figure-carrier.js +1 -1
  28. package/dist-client/modeller/figure-carrier.js.map +1 -1
  29. package/dist-client/modeller/figure-clips.js +1 -1
  30. package/dist-client/modeller/figure-clips.js.map +1 -1
  31. package/dist-client/modeller/figure-controls.js +2 -2
  32. package/dist-client/modeller/figure-controls.js.map +1 -1
  33. package/dist-client/modeller/figure-face-mapping.js +1 -1
  34. package/dist-client/modeller/figure-face-mapping.js.map +1 -1
  35. package/dist-client/modeller/figure-links.js +3 -2
  36. package/dist-client/modeller/figure-links.js.map +1 -1
  37. package/dist-client/modeller/figure-material-panel.js +1 -1
  38. package/dist-client/modeller/figure-material-panel.js.map +1 -1
  39. package/dist-client/modeller/figure-preview.d.ts +1 -1
  40. package/dist-client/modeller/figure-preview.js +2 -2
  41. package/dist-client/modeller/figure-preview.js.map +1 -1
  42. package/dist-client/modeller/figure-properties.js +1 -1
  43. package/dist-client/modeller/figure-properties.js.map +1 -1
  44. package/dist-client/modeller/figure-section-editor.js +1 -1
  45. package/dist-client/modeller/figure-section-editor.js.map +1 -1
  46. package/dist-client/modeller/figure-section-panel.js +1 -1
  47. package/dist-client/modeller/figure-section-panel.js.map +1 -1
  48. package/dist-client/modeller/figure-settings.d.ts +1 -1
  49. package/dist-client/modeller/figure-settings.js +1 -1
  50. package/dist-client/modeller/figure-settings.js.map +1 -1
  51. package/dist-client/modeller/figure-verdict.js +1 -1
  52. package/dist-client/modeller/figure-verdict.js.map +1 -1
  53. package/dist-client/pages/figure-list-page.d.ts +1 -0
  54. package/dist-client/pages/figure-list-page.js +11 -1
  55. package/dist-client/pages/figure-list-page.js.map +1 -1
  56. package/dist-client/tsconfig.tsbuildinfo +1 -1
  57. package/dist-client/viewparts/figure-detail-dock.d.ts +10 -1
  58. package/dist-client/viewparts/figure-detail-dock.js +93 -41
  59. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  60. package/dist-client/viewparts/figure-thumb.d.ts +1 -0
  61. package/dist-client/viewparts/figure-thumb.js +2 -22
  62. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  63. package/package.json +5 -4
  64. package/test/browser/detail-page.js +10 -2
  65. package/test/browser/product-page.check.mjs +1 -1
  66. package/test/browser/screen-page.js +1 -1
  67. package/test/browser/serve-detail.mjs +7 -2
  68. package/test/figure-controls-keys.test.ts +19 -0
  69. package/test/i18n-prefix-guard.test.ts +4 -1
  70. package/test/layout.test.ts +4 -3
  71. package/test/links.test.ts +5 -4
  72. package/test/properties.test.ts +2 -1
  73. package/test/refusals.test.ts +2 -1
  74. package/test/theme.test.ts +1 -1
  75. package/test/verdict.test.ts +2 -1
  76. package/test/words.ts +20 -0
  77. package/translations/en.json +2 -33
  78. package/translations/es.json +2 -33
  79. package/translations/ja.json +2 -33
  80. package/translations/ko.json +2 -33
  81. package/translations/ms.json +2 -33
  82. package/translations/zh.json +2 -33
  83. package/client/modeller/controls.ts +0 -54
  84. package/client/modeller/figure-view.ts +0 -151
  85. package/client/modeller/preview-model.ts +0 -89
  86. package/client/modeller/side-panel-styles.ts +0 -374
  87. package/client/viewparts/detail-motion.ts +0 -11
  88. package/client/viewparts/figure-asset-viewer.ts +0 -177
  89. package/dist-client/modeller/controls.d.ts +0 -28
  90. package/dist-client/modeller/controls.js +0 -28
  91. package/dist-client/modeller/controls.js.map +0 -1
  92. package/dist-client/modeller/figure-view.d.ts +0 -117
  93. package/dist-client/modeller/figure-view.js +0 -78
  94. package/dist-client/modeller/figure-view.js.map +0 -1
  95. package/dist-client/modeller/preview-model.d.ts +0 -54
  96. package/dist-client/modeller/preview-model.js +0 -79
  97. package/dist-client/modeller/preview-model.js.map +0 -1
  98. package/dist-client/modeller/side-panel-styles.d.ts +0 -27
  99. package/dist-client/modeller/side-panel-styles.js +0 -374
  100. package/dist-client/modeller/side-panel-styles.js.map +0 -1
  101. package/dist-client/viewparts/detail-motion.d.ts +0 -6
  102. package/dist-client/viewparts/detail-motion.js +0 -9
  103. package/dist-client/viewparts/detail-motion.js.map +0 -1
  104. package/dist-client/viewparts/figure-asset-viewer.d.ts +0 -31
  105. package/dist-client/viewparts/figure-asset-viewer.js +0 -198
  106. package/dist-client/viewparts/figure-asset-viewer.js.map +0 -1
  107. package/test/controls.test.ts +0 -60
  108. package/test/detail-motion.test.ts +0 -26
  109. package/test/preview.test.ts +0 -53
  110. package/test/view.test.ts +0 -13
@@ -1,89 +0,0 @@
1
- import { compileFigureAsset, figureOccupiedBoxesOf, figureWritersOf, FIGURE_INSTANCE_SIZE_INPUTS } from '@hatiolab/figure-model'
2
- import type { FigureAsset } from '@hatiolab/figure-model'
3
-
4
- /* The preview's arithmetic, apart from the element so it is tested without a browser (see figure-preview.ts). */
5
-
6
- export const PREVIEW_FACTOR = 2
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
-
13
- export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
14
- { name: 'base', axes: [] },
15
- { name: 'x', axes: ['x'] },
16
- { name: 'y', axes: ['y'] },
17
- { name: 'z', axes: ['z'] },
18
- { name: 'x·y', axes: ['x', 'y'] },
19
- { name: 'x·z', axes: ['x', 'z'] },
20
- { name: 'y·z', axes: ['y', 'z'] },
21
- { name: 'x·y·z', axes: ['x', 'y', 'z'] }
22
- ]
23
-
24
- /** The size the figure was made at: its instance size when it declares one, else the room its bodies take. */
25
- export function authoredSizeOf(asset: FigureAsset): { x: number; y: number; z: number } {
26
- const d = asset.designInputs
27
- const declared = (['x', 'y', 'z'] as const).map(axis => d[FIGURE_INSTANCE_SIZE_INPUTS[axis]])
28
- if (declared.every(v => typeof v === 'number' && v > 0)) return { x: declared[0]!, y: declared[1]!, z: declared[2]! }
29
- /* No declared size: the room its bodies take up, so the instance is placed at the size it draws. */
30
- const boxes = figureOccupiedBoxesOf(compileFigureAsset(asset).evaluate().geometry, figureWritersOf(asset))
31
- const span = (axis: 'x' | 'y' | 'z') =>
32
- boxes.length ? Math.max(...boxes.map(b => b.centre[axis] + b.extent[axis] / 2)) - Math.min(...boxes.map(b => b.centre[axis] - b.extent[axis] / 2)) : 0
33
- return { x: Math.max(1, Math.round(span('x'))), y: Math.max(1, Math.round(span('y'))), z: Math.max(1, Math.round(span('z'))) }
34
- }
35
-
36
- /** The eight instances: where each stands on the board and how large, in the scene's words. */
37
- export function previewInstances(type: string, size: { x: number; y: number; z: number }) {
38
- const COLUMNS = 4
39
- const cell = { x: size.x * PREVIEW_FACTOR * 1.25, z: size.z * PREVIEW_FACTOR * 1.25 }
40
- const components = PREVIEW_WAYS.map((way, at) => {
41
- const grow = (axis: 'x' | 'y' | 'z') => (way.axes.includes(axis) ? PREVIEW_FACTOR : 1)
42
- const width = Math.round(size.x * grow('x'))
43
- const height = Math.round(size.z * grow('z'))
44
- const column = at % COLUMNS
45
- const row = Math.floor(at / COLUMNS)
46
- return {
47
- type,
48
- id: `at-${way.name}`,
49
- left: Math.round(column * cell.x + (cell.x - width) / 2),
50
- top: Math.round(row * cell.z + (cell.z - height) / 2),
51
- width,
52
- height,
53
- /* Always written: without it the height is taken as the plan's mean and "grow y only" would not grow it. */
54
- depth: Math.round(size.y * grow('y'))
55
- }
56
- })
57
- return { components, board: { width: Math.round(cell.x * COLUMNS), height: Math.round(cell.z * Math.ceil(PREVIEW_WAYS.length / COLUMNS)) } }
58
- }
59
-
60
-
61
- /**
62
- * Where a variant asks a size the figure does not declare, the size it is drawn at instead -- as the board draws it
63
- * (things-scene clamps each axis to the declared range; chief architect's ruling 2026-09-25). Per variant, empty when
64
- * it is drawn as asked. A figure that follows its height only is drawn at its one width in the wider variants, and
65
- * the preview says so rather than drawing them as though they grew.
66
- */
67
- export function previewClampsOf(asset: FigureAsset, size: { x: number; y: number; z: number }): Record<string, Array<{ axis: 'x' | 'y' | 'z'; asked: number; used: number }>> {
68
- const inputs = asset.document.model.inputs
69
- return Object.fromEntries(
70
- PREVIEW_WAYS.map(way => [
71
- way.name,
72
- (['x', 'y', 'z'] as const).flatMap(axis => {
73
- const input = inputs.find(i => i.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
74
- if (!input) return []
75
- const asked = Math.round(size[axis] * (way.axes.includes(axis) ? PREVIEW_FACTOR : 1))
76
- const used = Math.round(Math.min(Math.max(asked, input.min ?? -Infinity), input.max ?? Infinity))
77
- return used === asked ? [] : [{ axis, asked, used }]
78
- })
79
- ])
80
- )
81
- }
82
-
83
- /**
84
- * The part the preview's size help speaks of: the one picked in the help, else the one chosen in the editor, else the
85
- * first that has a size rule. A choice that is not among the parts (removed, or a part with no rule) is passed over.
86
- */
87
- export function setupPartOf(picked: string, chosen: string, targets: string[]): string {
88
- return [picked, chosen].find(id => !!id && targets.includes(id)) ?? targets[0] ?? ''
89
- }
@@ -1,374 +0,0 @@
1
- import { css } from 'lit'
2
-
3
- /**
4
- * The side panel's one look, for every element that draws in the modeller's side.
5
- *
6
- * The side is drawn by several elements (the type field, the figure's properties, a part's values, fastening, places,
7
- * motion, material, controls, clips, the environment) and each had styled its own fields: three label styles and
8
- * three field heights in one column (user, 2026-09-24: "why are the styles and layout all different?"). They share
9
- * this sheet now and keep only what is theirs.
10
- *
11
- * The vocabulary:
12
- * section one titled group; the side is a stack of them
13
- * h3 a group's title
14
- *
15
- * Three sizes and two weights (user 2026-10-01, the panel checked for balance: eight sizes, and titles smaller than
16
- * what they titled): a title at the body token and semibold, everything else at the small token, and a hint at the
17
- * micro one. Sections are 16 px above their title and 12 px below their last line.
18
- * div[inline-field] a short label and its one field on one line
19
- * div[field] a label above a full-width field
20
- * div[form] short labels beside their fields, the fields starting on one line
21
- * div[vector-row] a title, then X · Y · Z in one row of cells with the axis inside (`div[triple]` of
22
- * `div[cell]`) -- three values read together, as vector tools lay them out
23
- * div[pair] two controls side by side
24
- * label[toggle] a checkbox and its words
25
- * p[hint] · small the quiet line under a field
26
- * button[act] an action under the fields it acts on
27
- * [scrub] a label dragged sideways to scrub its field
28
- */
29
- export const SidePanelStyles = css`
30
- :host {
31
- --figure-control-height: 30px;
32
- font-size: var(--ui-t-small, 11.5px);
33
- color: var(--md-sys-color-on-surface);
34
- }
35
- /* The side is a stack of sections, each a titled group. */
36
- section {
37
- display: grid;
38
- gap: 8px;
39
- padding: 16px 12px 12px;
40
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
41
- }
42
- section > h3 {
43
- margin: 0;
44
- }
45
- section div[field] + div[field],
46
- section div[inline-field] + div[inline-field],
47
- section div[vector-row] + div[vector-row] {
48
- margin-top: 0;
49
- }
50
- h3 {
51
- display: flex;
52
- align-items: center;
53
- gap: 4px;
54
- margin: 14px 0 6px;
55
- font-size: var(--ui-t-body, 13px);
56
- font-weight: 600;
57
- color: var(--md-sys-color-on-surface);
58
- }
59
- h3:first-child {
60
- margin-top: 0;
61
- }
62
- /* Two weights only: a bold word in a line is the title's weight, not the browser's heavier one. */
63
- b,
64
- strong {
65
- font-weight: 600;
66
- }
67
- label,
68
- div[form] > span {
69
- font-size: inherit;
70
- color: var(--md-sys-color-on-surface-variant);
71
- }
72
- div[field] {
73
- display: grid;
74
- gap: 2px;
75
- }
76
- div[field] + div[field],
77
- div[field] + div[form],
78
- div[form] + div[field],
79
- div[vector-row] + div[vector-row],
80
- div[vector-row] + div[field],
81
- div[field] + div[vector-row] {
82
- margin-top: 5px;
83
- }
84
- div[form],
85
- div[inline-field],
86
- div[vector-row] {
87
- display: grid;
88
- grid-template-columns: 64px minmax(0, 1fr);
89
- align-items: center;
90
- gap: 5px 7px;
91
- }
92
- div[form] > span,
93
- div[inline-field] > label,
94
- div[vector-row] > label {
95
- overflow-wrap: anywhere;
96
- white-space: normal;
97
- }
98
- /* A value read, not edited, beside its label: the field's size and colour, not the page's. */
99
- div[inline-field] > span[value] {
100
- font-size: inherit;
101
- color: var(--md-sys-color-on-surface);
102
- }
103
- label span[unit] {
104
- color: var(--md-sys-color-outline);
105
- }
106
- div[inline-field] + div[inline-field],
107
- div[inline-field] + div[vector-row],
108
- div[vector-row] + div[inline-field] {
109
- margin-top: 5px;
110
- }
111
- /* A row of worded tools (align, snap; mirror copies). */
112
- div[tools] {
113
- display: flex;
114
- flex-wrap: wrap;
115
- gap: 4px;
116
- }
117
- div[tools] button {
118
- height: var(--figure-control-height);
119
- padding: 0 10px;
120
- font: inherit;
121
- color: var(--md-sys-color-on-surface);
122
- background: var(--md-sys-color-surface-container-lowest);
123
- border: 1px solid var(--md-sys-color-outline-variant);
124
- border-radius: 6px;
125
- cursor: pointer;
126
- }
127
- div[tools] button:hover:not(:disabled) {
128
- background: var(--md-sys-color-surface-container);
129
- }
130
- div[tools] button:disabled {
131
- opacity: 0.45;
132
- cursor: default;
133
- }
134
- div[pair] {
135
- display: flex;
136
- gap: 4px;
137
- }
138
- div[pair] > * {
139
- flex: 1;
140
- min-width: 0;
141
- }
142
- div[triple] {
143
- display: grid;
144
- grid-template-columns: repeat(3, minmax(0, 1fr));
145
- gap: 4px;
146
- }
147
- input,
148
- select {
149
- width: 100%;
150
- min-width: 0;
151
- min-height: var(--figure-control-height);
152
- box-sizing: border-box;
153
- padding: 4px 7px;
154
- font: inherit;
155
- color: var(--md-sys-color-on-surface);
156
- background-color: var(--md-sys-color-surface-container-lowest);
157
- border: 1px solid var(--md-sys-color-outline-variant);
158
- border-radius: 6px;
159
- }
160
- /* The browser's own box and dot, in the panel's colour. */
161
- input[type='checkbox'],
162
- input[type='radio'] {
163
- width: auto;
164
- min-height: 0;
165
- margin: 0;
166
- accent-color: var(--md-sys-color-primary);
167
- }
168
- /* A fold: the panel's chevron, not the browser's triangle. */
169
- summary {
170
- display: flex;
171
- align-items: center;
172
- gap: 6px;
173
- padding: 6px 0;
174
- list-style: none;
175
- cursor: pointer;
176
- color: var(--md-sys-color-on-surface-variant);
177
- }
178
- summary::-webkit-details-marker {
179
- display: none;
180
- }
181
- summary::before {
182
- content: '';
183
- flex: none;
184
- width: 6px;
185
- height: 6px;
186
- margin: 0 2px;
187
- border-right: 1.5px solid currentColor;
188
- border-bottom: 1.5px solid currentColor;
189
- transform: rotate(-45deg);
190
- transition: transform 0.15s;
191
- }
192
- details[open] > summary::before {
193
- transform: rotate(45deg);
194
- }
195
- /* A thin track and a small thumb: a slider is a line to drag along, not a block (user, 2026-09-24). */
196
- input[type='range'] {
197
- -webkit-appearance: none;
198
- appearance: none;
199
- min-height: 16px;
200
- height: 16px;
201
- padding: 0;
202
- border: none;
203
- background: transparent;
204
- cursor: pointer;
205
- }
206
- input[type='range']::-webkit-slider-runnable-track {
207
- height: 3px;
208
- border-radius: 2px;
209
- background: var(--md-sys-color-outline-variant);
210
- }
211
- input[type='range']::-webkit-slider-thumb {
212
- -webkit-appearance: none;
213
- width: 12px;
214
- height: 12px;
215
- margin-top: -4.5px;
216
- border: none;
217
- border-radius: 50%;
218
- background: var(--md-sys-color-primary);
219
- }
220
- input[type='range']::-moz-range-track {
221
- height: 3px;
222
- border-radius: 2px;
223
- background: var(--md-sys-color-outline-variant);
224
- }
225
- input[type='range']::-moz-range-thumb {
226
- width: 12px;
227
- height: 12px;
228
- border: none;
229
- border-radius: 50%;
230
- background: var(--md-sys-color-primary);
231
- }
232
- input[type='range']:focus {
233
- outline: none;
234
- }
235
- input[type='range']:focus-visible::-webkit-slider-thumb {
236
- outline: 2px solid var(--md-sys-color-primary);
237
- outline-offset: 2px;
238
- }
239
- input[type='color'] {
240
- padding: 2px;
241
- width: 40px;
242
- }
243
- input:focus,
244
- select:focus {
245
- outline: 2px solid var(--md-sys-color-primary);
246
- outline-offset: -1px;
247
- }
248
- input:disabled,
249
- select:disabled {
250
- opacity: 0.6;
251
- }
252
- /* A number with its axis inside the cell, on the left. */
253
- div[cell] {
254
- display: flex;
255
- box-sizing: border-box;
256
- height: var(--figure-control-height);
257
- align-items: center;
258
- gap: 2px;
259
- padding-left: 6px;
260
- background-color: var(--md-sys-color-surface-container-lowest);
261
- border: 1px solid var(--md-sys-color-outline-variant);
262
- border-radius: 6px;
263
- }
264
- div[cell]:focus-within {
265
- outline: 2px solid var(--md-sys-color-primary);
266
- outline-offset: -1px;
267
- border-color: transparent;
268
- }
269
- /* A label that scrubs its field when dragged sideways (vector tools' way). */
270
- [scrub] {
271
- cursor: ew-resize;
272
- user-select: none;
273
- touch-action: none;
274
- }
275
- div[cell] span[axis] {
276
- flex: none;
277
- font-size: var(--ui-t-micro, 11px);
278
- letter-spacing: 0.02em;
279
- color: var(--md-sys-color-on-surface-variant);
280
- }
281
- div[cell] input {
282
- min-height: 0;
283
- height: calc(var(--figure-control-height) - 2px);
284
- padding: 4px 6px 4px 2px;
285
- font-family: var(--mono-font, monospace);
286
- text-align: right;
287
- background: none;
288
- border: none;
289
- border-radius: 0;
290
- }
291
- div[cell] input:focus {
292
- outline: none;
293
- }
294
- /* An axis a rule sets: its number, read-only, on a quieter ground (ADR-0097). */
295
- div[cell][held] {
296
- background-color: var(--md-sys-color-surface-container);
297
- border-style: dashed;
298
- }
299
- div[cell][held] input {
300
- color: var(--md-sys-color-on-surface-variant);
301
- font-style: italic;
302
- }
303
- p[held-axes] {
304
- margin: 2px 0 6px;
305
- }
306
- label[toggle] {
307
- display: flex;
308
- align-items: center;
309
- gap: 6px;
310
- color: var(--md-sys-color-on-surface);
311
- }
312
- /* The 「?」 beside a title: no border, it says there is more rather than asking to be pressed. */
313
- button[help] {
314
- display: inline-flex;
315
- align-items: center;
316
- padding: 0;
317
- margin: 0;
318
- color: var(--md-sys-color-outline);
319
- background: none;
320
- border: none;
321
- cursor: pointer;
322
- }
323
- button[help] md-icon {
324
- font-size: 13px;
325
- width: 13px;
326
- height: 13px;
327
- }
328
- button[help]:hover,
329
- button[help][aria-expanded='true'] {
330
- color: var(--md-sys-color-primary);
331
- }
332
- p[hint],
333
- small {
334
- display: block;
335
- margin: 2px 0 4px;
336
- font-size: var(--ui-t-micro, 11px);
337
- line-height: 1.5;
338
- color: var(--md-sys-color-on-surface-variant);
339
- }
340
- /*
341
- * An action under its fields: as wide as its words, not a band across the panel -- four full-width grey bars read as
342
- * four walls and none of them as the one to press (user 2026-10-01). Every one carries its icon.
343
- */
344
- button[act] {
345
- display: inline-flex;
346
- align-items: center;
347
- justify-content: center;
348
- justify-self: start;
349
- gap: 6px;
350
- width: auto;
351
- max-width: 100%;
352
- margin-top: 4px;
353
- padding: 0 12px;
354
- min-height: var(--figure-control-height);
355
- font: inherit;
356
- color: var(--md-sys-color-primary);
357
- background-color: var(--md-sys-color-surface-container-lowest);
358
- border: 1px solid var(--md-sys-color-outline-variant);
359
- border-radius: 6px;
360
- cursor: pointer;
361
- }
362
- button[act]:hover:not(:disabled) {
363
- background-color: var(--md-sys-color-primary-container);
364
- }
365
- button[act]:disabled {
366
- opacity: 0.5;
367
- cursor: default;
368
- }
369
- button[act] md-icon {
370
- font-size: 16px;
371
- width: 16px;
372
- height: 16px;
373
- }
374
- `
@@ -1,11 +0,0 @@
1
- import type { FigureAsset } from '@hatiolab/figure-model'
2
-
3
- /** External values win over drivers; release only the controls owned by the chosen clip. */
4
- export function parametersForClip(asset: FigureAsset, values: Record<string, number>, clip: string) {
5
- const driven = new Set((asset.drivers ?? []).filter(d => d.clip === clip).map(d => d.state))
6
- return Object.fromEntries(Object.entries(values).filter(([id]) => !driven.has(id)))
7
- }
8
-
9
- export function clipForControl(asset: FigureAsset, id: string) {
10
- return asset.drivers?.find(d => d.state === id)?.clip
11
- }
@@ -1,177 +0,0 @@
1
- import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
2
- import { customElement, property, query, state } from 'lit/decorators.js'
3
- import { i18next, localize } from '@operato/i18n'
4
- import { compileFigureAsset, FIGURE_INSTANCE_SIZE_INPUTS, type FigureAsset } from '@hatiolab/figure-model'
5
- import { create, registerFigure, unregisterFigure } from '@hatiolab/things-scene'
6
- import { authoredSizeOf } from '../modeller/preview-model.js'
7
- import { controlsOf } from '../modeller/controls.js'
8
- import { DEFAULT_VIEW, previewBoardDepth, sceneLook } from '../modeller/figure-view.js'
9
- import { clipForControl, parametersForClip } from './detail-motion.js'
10
-
11
- type Axis = 'x' | 'y' | 'z'
12
- interface Scene {
13
- dispose?(): void
14
- resize?(): void
15
- fit?(type?: string): void
16
- root?: {
17
- fitCameraToModel?(): void
18
- components?: Array<{ set(key: string, value: unknown): void }>
19
- }
20
- }
21
-
22
- /** A single board instance. Sizes and poses are transient, and never change the asset or call a mutation. */
23
- @customElement('figure-asset-viewer')
24
- export class FigureAssetViewer extends localize(i18next)(LitElement) {
25
- static styles = css`
26
- :host { display: block; min-width: 0; }
27
- [viewport] { height: 300px; position: relative; background: #858585; }
28
- [stage] { position: absolute; inset: 0; width: 100%; height: 100%; }
29
- section { padding: 12px 16px; border-bottom: 1px solid var(--md-sys-color-outline-variant); }
30
- h3 { margin: 0 0 10px; font-size: var(--ui-t-body, 13px); }
31
- label { display: grid; grid-template-columns: 72px minmax(0, 1fr) 90px; align-items: center; gap: 8px; margin: 8px 0; }
32
- input { min-width: 0; width: 100%; box-sizing: border-box; }
33
- input[type=number] { padding: 6px; }
34
- input, button { font: inherit; color: inherit; }
35
- button { padding: 8px 12px; border: 1px solid var(--md-sys-color-outline); border-radius: 8px; background: var(--md-sys-color-surface); cursor: pointer; }
36
- [clips] { display: flex; flex-wrap: wrap; gap: 8px; }
37
- p { font-size: 12px; line-height: 1.5; }
38
- [error] { color: var(--md-sys-color-error); }
39
- `
40
- @property({ attribute: false }) asset?: FigureAsset
41
- @query('[stage]') private stage!: HTMLElement
42
- @state() private sizes: Record<Axis, number> = { x: 1, y: 1, z: 1 }
43
- @state() private values: Record<string, number> = {}
44
- @state() private playing = ''
45
- @state() private error = ''
46
- private scene?: Scene
47
- private registered?: string
48
- private observer?: ResizeObserver
49
- private needsScene = false
50
- private played = new Set<string>()
51
-
52
- connectedCallback() {
53
- super.connectedCallback()
54
- this.needsScene = true
55
- if (this.hasUpdated) this.observer?.observe(this.stage)
56
- this.requestUpdate()
57
- }
58
-
59
- protected updated(changed: PropertyValues) {
60
- // Reconnection and a new asset can arrive in the same update. Build exactly once.
61
- if (changed.has('asset') || this.needsScene) {
62
- this.needsScene = false
63
- this.standUp()
64
- }
65
- }
66
- firstUpdated() {
67
- this.observer = new ResizeObserver(() => this.scene?.resize?.())
68
- this.observer.observe(this.stage)
69
- }
70
- disconnectedCallback() {
71
- super.disconnectedCallback()
72
- this.observer?.disconnect()
73
- this.teardown()
74
- }
75
-
76
- render() {
77
- const asset = this.asset
78
- return html`<div viewport><div stage></div></div>
79
- ${this.error ? html`<p error role="alert">${this.error}</p>` : nothing}
80
- ${asset ? html`<section>
81
- <h3>${i18next.t('figure.label.detail-size')}</h3>
82
- ${(['x', 'y', 'z'] as const).map(axis => {
83
- const input = asset.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
84
- return html`<label><span>${axis.toUpperCase()} · mm</span>
85
- <input type="range" aria-label=${`${axis.toUpperCase()} mm`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
86
- .value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
87
- @input=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
88
- <input type="number" aria-label=${`${axis.toUpperCase()} mm value`} min=${input?.min ?? this.sizes[axis]} max=${input?.max ?? this.sizes[axis]} step="any"
89
- .value=${String(this.sizes[axis])} ?disabled=${!input || input.min === input.max}
90
- @change=${(e: Event) => this.resize(axis, (e.target as HTMLInputElement).valueAsNumber)}>
91
- </label>`
92
- })}
93
- <button @click=${() => this.reset()}>${i18next.t('figure.button.detail-reset')}</button>
94
- <p>${i18next.t('figure.text.detail-transient')}</p>
95
- </section>
96
- <section><h3>${i18next.t('figure.label.motion')}</h3>
97
- ${controlsOf(asset, this.values).map(c => html`<label><span>${c.label} · ${c.unit}</span>
98
- <input type="range" aria-label=${c.label} min=${c.min} max=${c.max} step=${c.step || 'any'} .value=${String(c.value)}
99
- ?disabled=${!!this.playing && clipForControl(asset, c.id) === this.playing}
100
- @input=${(e: Event) => this.pose(c.id, (e.target as HTMLInputElement).valueAsNumber)}>
101
- <output>${this.playing && clipForControl(asset, c.id) === this.playing ? i18next.t('figure.text.detail-playing')
102
- : this.played.has(clipForControl(asset, c.id) ?? '') && !(c.id in this.values) ? i18next.t('figure.text.detail-paused') : Math.round(c.value * 100) / 100}</output></label>`)}
103
- <div clips>${[...new Set((asset.drivers ?? []).map(d => d.clip))].map(name => html`<button aria-pressed=${String(this.playing === name)}
104
- @click=${() => this.play(name)}>${this.playing === name ? '⏸' : '▶'} ${name}</button>`)}</div>
105
- ${!controlsOf(asset).length && ![...new Set((asset.drivers ?? []).map(d => d.clip))].length ? html`<p>${i18next.t('figure.text.detail-no-motion')}</p>` : nothing}
106
- </section>` : nothing}`
107
- }
108
-
109
- private tell(key: string, value: unknown) {
110
- this.scene?.root?.components?.[0]?.set(key, value)
111
- }
112
- private resize(axis: Axis, value: number) {
113
- const input = this.asset?.document.model.inputs.find(input => input.id === FIGURE_INSTANCE_SIZE_INPUTS[axis])
114
- if (!input || !Number.isFinite(value)) { this.requestUpdate(); return }
115
- const used = Math.min(input.max, Math.max(input.min, value))
116
- this.sizes = { ...this.sizes, [axis]: used }
117
- this.tell(axis === 'x' ? 'width' : axis === 'y' ? 'depth' : 'height', used)
118
- this.scene?.root?.fitCameraToModel?.()
119
- }
120
- private pose(id: string, value: number) {
121
- if (!Number.isFinite(value)) return
122
- this.values = { ...this.values, [id]: value }
123
- this.tell('parameters', this.values)
124
- }
125
- private play(name: string) {
126
- this.playing = this.playing === name ? '' : name
127
- if (this.playing && this.asset) {
128
- this.played.add(name)
129
- this.values = parametersForClip(this.asset, this.values, name)
130
- this.tell('parameters', this.values)
131
- }
132
- this.tell('clips', Object.fromEntries([...new Set((this.asset?.drivers ?? []).map(d => d.clip))].map(key => [key, key === this.playing ? 1 : 0])))
133
- }
134
- private reset() {
135
- if (!this.asset) return
136
- // A new instance also resets driver clocks. Clearing speeds alone freezes the last pose.
137
- this.standUp()
138
- }
139
- private standUp() {
140
- this.teardown()
141
- this.error = ''
142
- const asset = this.asset
143
- if (!asset) return
144
- this.values = {}
145
- this.playing = ''
146
- this.played.clear()
147
- try {
148
- compileFigureAsset(asset)
149
- if (!asset.appearance.length) throw Error(i18next.t('figure.text.preview-needs-a-part'))
150
- this.sizes = authoredSizeOf(asset)
151
- const type = `figure-detail-${crypto.randomUUID()}`
152
- registerFigure(type, asset)
153
- this.registered = type
154
- const size = this.sizes
155
- this.scene = create({ target: this.stage, mode: 0, model: {
156
- type: 'model-layer', width: size.x * 2, height: size.z * 2,
157
- depth: previewBoardDepth({ placement: asset.placement, base: { y: size.y } }, DEFAULT_VIEW.ceilingHeight, 1),
158
- threed: true, sky: 'color', ...sceneLook(DEFAULT_VIEW),
159
- placement: DEFAULT_VIEW.placement, heights: { floor: 0, ceiling: DEFAULT_VIEW.ceilingHeight },
160
- pixelRatio: Math.min(3, (window.devicePixelRatio || 1) * 2),
161
- components: [{ type, id: 'detail-instance', left: size.x / 2, top: size.z / 2, width: size.x, height: size.z, depth: size.y }]
162
- } }) as Scene
163
- this.scene.resize?.()
164
- this.scene.fit?.('ratio')
165
- this.scene.root?.fitCameraToModel?.()
166
- } catch (error) {
167
- this.error = (error as Error).message
168
- this.teardown()
169
- }
170
- }
171
- private teardown() {
172
- this.scene?.dispose?.()
173
- this.scene = undefined
174
- if (this.registered) unregisterFigure(this.registered)
175
- this.registered = undefined
176
- }
177
- }
@@ -1,28 +0,0 @@
1
- import type { FigureAsset } from '@hatiolab/figure-model';
2
- export interface Control {
3
- id: string;
4
- /** The name a person reads, or the id when none is given. */
5
- label: string;
6
- named: boolean;
7
- unit: string;
8
- min: number;
9
- max: number;
10
- /** Where it stands in the editor now -- the pose being looked at, not yet the default. */
11
- value: number;
12
- /** Where it starts when the figure is placed: the default pose. */
13
- start: number;
14
- /** Seconds to travel the whole range, 0 when it moves at once. */
15
- sweep: number;
16
- /** A slider step: one degree for an angle, else a hundredth of the range. */
17
- step: number;
18
- }
19
- export declare function controlsOf(asset: FigureAsset, values?: Record<string, number>): Control[];
20
- /** Whether the pose being looked at differs from the default pose -- only then is there a pose to keep. */
21
- export declare const poseDiffers: (controls: readonly Control[]) => boolean;
22
- /** The current pose as the defaults: one `set-state-defaults` edit. */
23
- export declare const poseAsDefault: (controls: readonly Control[]) => {
24
- kind: "set-state-defaults";
25
- values: {
26
- [k: string]: number;
27
- };
28
- };