@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.
- package/client/modeller/canvas.ts +1 -1
- package/client/modeller/editor.ts +2 -2
- package/client/modeller/figure-capability-panel.ts +6 -24
- package/client/modeller/figure-carrier.ts +1 -1
- package/client/modeller/figure-clips.ts +1 -1
- package/client/modeller/figure-controls.ts +2 -2
- package/client/modeller/figure-face-mapping.ts +1 -1
- package/client/modeller/figure-links.ts +3 -2
- package/client/modeller/figure-material-panel.ts +1 -1
- package/client/modeller/figure-preview.ts +2 -2
- package/client/modeller/figure-properties.ts +1 -1
- package/client/modeller/figure-section-editor.ts +1 -1
- package/client/modeller/figure-section-panel.ts +1 -1
- package/client/modeller/figure-settings.ts +2 -2
- package/client/modeller/figure-verdict.ts +1 -1
- package/client/pages/figure-list-page.ts +7 -1
- package/client/viewparts/figure-detail-dock.ts +90 -41
- package/client/viewparts/figure-thumb.ts +2 -22
- package/dist-client/modeller/canvas.d.ts +1 -1
- package/dist-client/modeller/canvas.js +1 -1
- package/dist-client/modeller/canvas.js.map +1 -1
- package/dist-client/modeller/editor.js +2 -2
- package/dist-client/modeller/editor.js.map +1 -1
- package/dist-client/modeller/figure-capability-panel.d.ts +0 -3
- package/dist-client/modeller/figure-capability-panel.js +5 -18
- package/dist-client/modeller/figure-capability-panel.js.map +1 -1
- package/dist-client/modeller/figure-carrier.js +1 -1
- package/dist-client/modeller/figure-carrier.js.map +1 -1
- package/dist-client/modeller/figure-clips.js +1 -1
- package/dist-client/modeller/figure-clips.js.map +1 -1
- package/dist-client/modeller/figure-controls.js +2 -2
- package/dist-client/modeller/figure-controls.js.map +1 -1
- package/dist-client/modeller/figure-face-mapping.js +1 -1
- package/dist-client/modeller/figure-face-mapping.js.map +1 -1
- package/dist-client/modeller/figure-links.js +3 -2
- package/dist-client/modeller/figure-links.js.map +1 -1
- package/dist-client/modeller/figure-material-panel.js +1 -1
- package/dist-client/modeller/figure-material-panel.js.map +1 -1
- package/dist-client/modeller/figure-preview.d.ts +1 -1
- package/dist-client/modeller/figure-preview.js +2 -2
- package/dist-client/modeller/figure-preview.js.map +1 -1
- package/dist-client/modeller/figure-properties.js +1 -1
- package/dist-client/modeller/figure-properties.js.map +1 -1
- package/dist-client/modeller/figure-section-editor.js +1 -1
- package/dist-client/modeller/figure-section-editor.js.map +1 -1
- package/dist-client/modeller/figure-section-panel.js +1 -1
- package/dist-client/modeller/figure-section-panel.js.map +1 -1
- package/dist-client/modeller/figure-settings.d.ts +1 -1
- package/dist-client/modeller/figure-settings.js +1 -1
- package/dist-client/modeller/figure-settings.js.map +1 -1
- package/dist-client/modeller/figure-verdict.js +1 -1
- package/dist-client/modeller/figure-verdict.js.map +1 -1
- package/dist-client/pages/figure-list-page.d.ts +1 -0
- package/dist-client/pages/figure-list-page.js +11 -1
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/viewparts/figure-detail-dock.d.ts +10 -1
- package/dist-client/viewparts/figure-detail-dock.js +93 -41
- package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
- package/dist-client/viewparts/figure-thumb.d.ts +1 -0
- package/dist-client/viewparts/figure-thumb.js +2 -22
- package/dist-client/viewparts/figure-thumb.js.map +1 -1
- package/package.json +5 -4
- package/test/browser/detail-page.js +10 -2
- package/test/browser/product-page.check.mjs +1 -1
- package/test/browser/screen-page.js +1 -1
- package/test/browser/serve-detail.mjs +7 -2
- package/test/figure-controls-keys.test.ts +19 -0
- package/test/i18n-prefix-guard.test.ts +4 -1
- package/test/layout.test.ts +4 -3
- package/test/links.test.ts +5 -4
- package/test/properties.test.ts +2 -1
- package/test/refusals.test.ts +2 -1
- package/test/theme.test.ts +1 -1
- package/test/verdict.test.ts +2 -1
- package/test/words.ts +20 -0
- package/translations/en.json +2 -33
- package/translations/es.json +2 -33
- package/translations/ja.json +2 -33
- package/translations/ko.json +2 -33
- package/translations/ms.json +2 -33
- package/translations/zh.json +2 -33
- package/client/modeller/controls.ts +0 -54
- package/client/modeller/figure-view.ts +0 -151
- package/client/modeller/preview-model.ts +0 -89
- package/client/modeller/side-panel-styles.ts +0 -374
- package/client/viewparts/detail-motion.ts +0 -11
- package/client/viewparts/figure-asset-viewer.ts +0 -177
- package/dist-client/modeller/controls.d.ts +0 -28
- package/dist-client/modeller/controls.js +0 -28
- package/dist-client/modeller/controls.js.map +0 -1
- package/dist-client/modeller/figure-view.d.ts +0 -117
- package/dist-client/modeller/figure-view.js +0 -78
- package/dist-client/modeller/figure-view.js.map +0 -1
- package/dist-client/modeller/preview-model.d.ts +0 -54
- package/dist-client/modeller/preview-model.js +0 -79
- package/dist-client/modeller/preview-model.js.map +0 -1
- package/dist-client/modeller/side-panel-styles.d.ts +0 -27
- package/dist-client/modeller/side-panel-styles.js +0 -374
- package/dist-client/modeller/side-panel-styles.js.map +0 -1
- package/dist-client/viewparts/detail-motion.d.ts +0 -6
- package/dist-client/viewparts/detail-motion.js +0 -9
- package/dist-client/viewparts/detail-motion.js.map +0 -1
- package/dist-client/viewparts/figure-asset-viewer.d.ts +0 -31
- package/dist-client/viewparts/figure-asset-viewer.js +0 -198
- package/dist-client/viewparts/figure-asset-viewer.js.map +0 -1
- package/test/controls.test.ts +0 -60
- package/test/detail-motion.test.ts +0 -26
- package/test/preview.test.ts +0 -53
- 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
|
-
};
|