@things-factory/figure-ui 10.1.45 → 10.1.47

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 (138) hide show
  1. package/client/modeller/angle-dial.ts +70 -0
  2. package/client/modeller/figure-settings.ts +328 -0
  3. package/client/modeller/figure-v3-capability.ts +137 -0
  4. package/client/modeller/figure-v3-clips.ts +253 -0
  5. package/client/modeller/figure-v3-controls.ts +212 -0
  6. package/client/modeller/figure-v3-links.ts +150 -79
  7. package/client/modeller/figure-v3-material.ts +108 -35
  8. package/client/modeller/figure-v3-parts.ts +96 -3
  9. package/client/modeller/figure-v3-preview.ts +326 -0
  10. package/client/modeller/figure-v3-properties.ts +29 -57
  11. package/client/modeller/figure-v3-verdict.ts +598 -60
  12. package/client/modeller/figure-view.ts +152 -0
  13. package/client/modeller/side-panel-styles.ts +326 -0
  14. package/client/modeller/v3-canvas.ts +221 -263
  15. package/client/modeller/v3-clips.ts +89 -0
  16. package/client/modeller/v3-controls.ts +54 -0
  17. package/client/modeller/v3-editor.ts +532 -223
  18. package/client/modeller/v3-fields.ts +62 -0
  19. package/client/modeller/v3-help.ts +41 -0
  20. package/client/modeller/v3-keys.ts +34 -0
  21. package/client/modeller/v3-links.ts +126 -10
  22. package/client/modeller/v3-live.ts +79 -0
  23. package/client/modeller/v3-maturity.ts +415 -0
  24. package/client/modeller/v3-palette.ts +45 -0
  25. package/client/modeller/v3-preview-model.ts +54 -0
  26. package/client/modeller/v3-stage-model.ts +159 -0
  27. package/client/pages/figure-list-page.ts +4 -4
  28. package/client/pages/figure-v3-modeller-page.ts +66 -134
  29. package/dist-client/modeller/angle-dial.d.ts +32 -0
  30. package/dist-client/modeller/angle-dial.js +41 -0
  31. package/dist-client/modeller/angle-dial.js.map +1 -0
  32. package/dist-client/modeller/figure-settings.d.ts +39 -0
  33. package/dist-client/modeller/figure-settings.js +317 -0
  34. package/dist-client/modeller/figure-settings.js.map +1 -0
  35. package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
  36. package/dist-client/modeller/figure-v3-capability.js +141 -0
  37. package/dist-client/modeller/figure-v3-capability.js.map +1 -0
  38. package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
  39. package/dist-client/modeller/figure-v3-clips.js +268 -0
  40. package/dist-client/modeller/figure-v3-clips.js.map +1 -0
  41. package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
  42. package/dist-client/modeller/figure-v3-controls.js +226 -0
  43. package/dist-client/modeller/figure-v3-controls.js.map +1 -0
  44. package/dist-client/modeller/figure-v3-links.d.ts +10 -2
  45. package/dist-client/modeller/figure-v3-links.js +134 -79
  46. package/dist-client/modeller/figure-v3-links.js.map +1 -1
  47. package/dist-client/modeller/figure-v3-material.d.ts +5 -2
  48. package/dist-client/modeller/figure-v3-material.js +109 -35
  49. package/dist-client/modeller/figure-v3-material.js.map +1 -1
  50. package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
  51. package/dist-client/modeller/figure-v3-parts.js +88 -3
  52. package/dist-client/modeller/figure-v3-parts.js.map +1 -1
  53. package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
  54. package/dist-client/modeller/figure-v3-preview.js +322 -0
  55. package/dist-client/modeller/figure-v3-preview.js.map +1 -0
  56. package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
  57. package/dist-client/modeller/figure-v3-properties.js +29 -57
  58. package/dist-client/modeller/figure-v3-properties.js.map +1 -1
  59. package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
  60. package/dist-client/modeller/figure-v3-verdict.js +579 -59
  61. package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
  62. package/dist-client/modeller/figure-view.d.ts +118 -0
  63. package/dist-client/modeller/figure-view.js +78 -0
  64. package/dist-client/modeller/figure-view.js.map +1 -0
  65. package/dist-client/modeller/side-panel-styles.d.ts +23 -0
  66. package/dist-client/modeller/side-panel-styles.js +326 -0
  67. package/dist-client/modeller/side-panel-styles.js.map +1 -0
  68. package/dist-client/modeller/v3-canvas.d.ts +29 -37
  69. package/dist-client/modeller/v3-canvas.js +188 -260
  70. package/dist-client/modeller/v3-canvas.js.map +1 -1
  71. package/dist-client/modeller/v3-clips.d.ts +39 -0
  72. package/dist-client/modeller/v3-clips.js +58 -0
  73. package/dist-client/modeller/v3-clips.js.map +1 -0
  74. package/dist-client/modeller/v3-controls.d.ts +28 -0
  75. package/dist-client/modeller/v3-controls.js +28 -0
  76. package/dist-client/modeller/v3-controls.js.map +1 -0
  77. package/dist-client/modeller/v3-editor.d.ts +58 -1
  78. package/dist-client/modeller/v3-editor.js +556 -218
  79. package/dist-client/modeller/v3-editor.js.map +1 -1
  80. package/dist-client/modeller/v3-fields.d.ts +30 -0
  81. package/dist-client/modeller/v3-fields.js +46 -0
  82. package/dist-client/modeller/v3-fields.js.map +1 -0
  83. package/dist-client/modeller/v3-help.d.ts +18 -0
  84. package/dist-client/modeller/v3-help.js +37 -0
  85. package/dist-client/modeller/v3-help.js.map +1 -0
  86. package/dist-client/modeller/v3-keys.d.ts +14 -0
  87. package/dist-client/modeller/v3-keys.js +23 -0
  88. package/dist-client/modeller/v3-keys.js.map +1 -0
  89. package/dist-client/modeller/v3-links.d.ts +44 -2
  90. package/dist-client/modeller/v3-links.js +125 -8
  91. package/dist-client/modeller/v3-links.js.map +1 -1
  92. package/dist-client/modeller/v3-live.d.ts +31 -0
  93. package/dist-client/modeller/v3-live.js +58 -0
  94. package/dist-client/modeller/v3-live.js.map +1 -0
  95. package/dist-client/modeller/v3-maturity.d.ts +120 -0
  96. package/dist-client/modeller/v3-maturity.js +317 -0
  97. package/dist-client/modeller/v3-maturity.js.map +1 -0
  98. package/dist-client/modeller/v3-palette.d.ts +7 -0
  99. package/dist-client/modeller/v3-palette.js +42 -0
  100. package/dist-client/modeller/v3-palette.js.map +1 -0
  101. package/dist-client/modeller/v3-preview-model.d.ts +32 -0
  102. package/dist-client/modeller/v3-preview-model.js +48 -0
  103. package/dist-client/modeller/v3-preview-model.js.map +1 -0
  104. package/dist-client/modeller/v3-stage-model.d.ts +31 -0
  105. package/dist-client/modeller/v3-stage-model.js +121 -0
  106. package/dist-client/modeller/v3-stage-model.js.map +1 -0
  107. package/dist-client/pages/figure-list-page.js +4 -4
  108. package/dist-client/pages/figure-list-page.js.map +1 -1
  109. package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
  110. package/dist-client/pages/figure-v3-modeller-page.js +64 -136
  111. package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
  112. package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
  113. package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
  114. package/dist-client/tsconfig.tsbuildinfo +1 -1
  115. package/package.json +6 -6
  116. package/test/v3-clips.test.ts +70 -0
  117. package/test/v3-controls.test.ts +68 -0
  118. package/test/v3-fields.test.ts +62 -0
  119. package/test/v3-gizmo.test.ts +3 -5
  120. package/test/v3-keys.test.ts +37 -0
  121. package/test/v3-layout.test.ts +78 -0
  122. package/test/v3-links.test.ts +118 -7
  123. package/test/v3-live.test.ts +99 -0
  124. package/test/v3-material.test.ts +6 -0
  125. package/test/v3-maturity.test.ts +268 -0
  126. package/test/v3-parts-wiring.test.ts +3 -1
  127. package/test/v3-preview.test.ts +42 -0
  128. package/test/v3-properties.test.ts +4 -2
  129. package/test/v3-scene-canvas.test.ts +85 -0
  130. package/test/v3-stage-model.test.ts +25 -0
  131. package/test/v3-theme.test.ts +16 -25
  132. package/test/v3-verdict.test.ts +12 -6
  133. package/test/v3-view.test.ts +20 -0
  134. package/translations/en.json +98 -5
  135. package/translations/ja.json +100 -7
  136. package/translations/ko.json +98 -5
  137. package/translations/ms.json +100 -7
  138. package/translations/zh.json +100 -7
@@ -1,7 +1,9 @@
1
- import { LitElement, html, css, type PropertyValues } from 'lit'
1
+ import { LitElement, html, css, nothing, type PropertyValues } from 'lit'
2
2
  import { i18next } from '@operato/i18n'
3
+ import type { OxThreePane } from '@operato/layout'
4
+ import '@operato/layout'
3
5
  import { customElement, property, state } from 'lit/decorators.js'
4
- import type { V3AuthoringAction, V3InputChange, V3PrimitiveCreation } from '@hatiolab/figure-model'
6
+ import type { V3Asset, V3AuthoringAction, V3InputChange, V3PartEdit, V3PrimitiveCreation } from '@hatiolab/figure-model'
5
7
  import type { V3ProductSession } from './v3-product-session.js'
6
8
  import { captureV3Thumbnail } from './v3-thumbnail.js'
7
9
  import { drawableV3Parts } from './v3-renderer.js'
@@ -9,96 +11,184 @@ import './v3-canvas.js'
9
11
  import './figure-v3-parts.js'
10
12
  import './figure-v3-material.js'
11
13
  import './figure-v3-links.js'
14
+ import './figure-v3-controls.js'
15
+ import './figure-v3-clips.js'
16
+ import './figure-v3-preview.js'
17
+ import './figure-settings.js'
18
+ import { DEFAULT_VIEW, type ViewSettings } from './figure-view.js'
19
+ import './figure-v3-capability.js'
12
20
  import { gizmoChanges, gizmoReachOf, type GizmoReach, type GizmoResult } from './v3-gizmo.js'
21
+ import { keyActionOf } from './v3-keys.js'
22
+ import { groupFields, type FieldLike, type FieldGroup } from './v3-fields.js'
23
+ import { SidePanelStyles } from './side-panel-styles.js'
13
24
  import { alignChanges, boxOf, canMirror, mirrorChanges, type AlignTool } from './v3-align.js'
14
- import type { V3PartRow } from './figure-v3-parts.js'
25
+ import type { V3PaletteKind, V3PartRow } from './figure-v3-parts.js'
26
+ import { paletteRecipe } from './v3-palette.js'
15
27
  import type { FigureV3Canvas } from './v3-canvas.js'
28
+ import type { V3BudgetLine } from './v3-maturity.js'
29
+ import { instanceRangeOf, rangeDiffers } from './v3-links.js'
30
+ import { isLive, livePartsOf, NOTHING_LIVE, scrubbed, scrubStep, type LiveValues } from './v3-live.js'
16
31
 
17
- const names: Record<string, string> = {
18
- position: '위치',
19
- rotation: '회전',
20
- size: '크기',
21
- dimension: '치수',
22
- width: '너비',
23
- height: '높이',
24
- depth: '깊이',
25
- radius: '반지름',
26
- length: '길이',
27
- round: '모서리 반경',
28
- left: '왼쪽',
29
- right: '오른쪽',
30
- bottom: '아랫면',
31
- top: '윗면',
32
- openingLeft: '개구부 왼쪽',
33
- openingRight: '개구부 오른쪽',
34
- openingTop: '개구부 윗면'
35
- }
36
- function fieldLabel(id: string) {
37
- return id
38
- .split('.')
39
- .map(p => names[p] ?? p.toUpperCase())
40
- .join(' · ')
41
- }
32
+
33
+ /**
34
+ * The side's tabs, V2's six in V2's order where V3 has them: properties, what it does (places), motion, the verdict,
35
+ * the release history. A figure-level tab (verdict, history) gives way to properties when a part is chosen; a tab about
36
+ * parts keeps showing the newly chosen part.
37
+ */
38
+ type SideTab = 'properties' | 'capability' | 'motion' | 'verdict' | 'view' | 'history'
42
39
 
43
40
  /** Product editor surface. The injected session is shared by page persistence and AI commands. */
44
41
  @customElement('figure-v3-editor')
45
42
  export class FigureV3Editor extends LitElement {
46
- static styles = css`
43
+ static styles = [SidePanelStyles, css`
47
44
  :host {
48
45
  display: flex;
49
46
  flex-direction: column;
50
47
  min-height: 0;
51
48
  height: 100%;
52
- /*
53
- The editor folds by the width it is given, not the window's. The rail (208 open, 56 closed) and the page's
54
- 320 side take their share first, so a window rule was wrong whenever either was there: at 945 with the rail
55
- open the editor had 417 and still drew its 660 of columns, over the side (UX ruling, 2026-09-24).
56
- */
49
+ /* The editor folds by the width it is given, not the window's (UX ruling, 2026-09-24). */
57
50
  container-type: inline-size;
58
51
  font: 13px sans-serif;
59
- color: var(--md-sys-color-on-surface, #22343f);
52
+ color: var(--md-sys-color-on-surface);
53
+ }
54
+ /* The bar over the work is ox-canvas-bar (ADR-0060 decision 8): the folds at its edges, undo, redo and
55
+ fit in the suite's places. What the editor puts in it - the mode switch, the status, save - is styled here. */
56
+ ox-canvas-bar button[mode] {
57
+ height: 28px;
58
+ padding: 0 10px;
59
+ border-radius: 999em;
60
+ border-color: transparent;
61
+ background: none;
62
+ }
63
+ ox-canvas-bar button[mode][aria-pressed='true'] {
64
+ border-color: var(--md-sys-color-primary);
60
65
  }
61
- header {
62
- display: flex;
63
- align-items: center;
64
- gap: 8px;
65
- padding: 10px;
66
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
67
- flex-wrap: wrap;
66
+ figure-v3-preview {
67
+ min-width: 0;
68
+ min-height: 0;
69
+ }
70
+ ox-canvas-bar .status {
71
+ padding: 0 8px;
72
+ color: var(--md-sys-color-on-surface-variant);
73
+ }
74
+ ox-canvas-bar .status[dirty] {
75
+ color: var(--md-sys-color-primary);
76
+ font-weight: 600;
68
77
  }
69
- main {
78
+ ox-canvas-bar button[save] {
79
+ height: 32px;
80
+ padding: 0 14px;
81
+ border: none;
82
+ border-radius: 999em;
83
+ color: var(--md-sys-color-on-primary);
84
+ background: var(--md-sys-color-primary);
85
+ }
86
+ /* The three panes are ox-three-pane's (ADR-0060 decision 8): widths 200 | 1fr | 340, 160 | 300 under
87
+ 860px and one column with the middle first under 560px are the frame's, as are the dividers. */
88
+ ox-three-pane {
70
89
  flex: 1;
71
90
  min-height: 0;
72
- display: grid;
73
- grid-template-columns: 180px minmax(180px, 1fr) 300px;
74
91
  }
92
+ figure-v3-parts {
93
+ min-height: 0;
94
+ }
95
+ figure-v3-canvas {
96
+ min-width: 0;
97
+ min-height: 0;
98
+ }
99
+ /* The side: tabs over one panel at a time, each using the whole height (V2's side, and why it had tabs). */
75
100
  aside {
76
- padding: 12px;
77
- overflow: auto;
78
- background: var(--md-sys-color-surface, #fff);
101
+ display: flex;
102
+ flex-direction: column;
103
+ min-height: 0;
104
+ background: var(--md-sys-color-surface-container-lowest);
79
105
  }
80
- figure-v3-parts {
106
+ div[tabs] {
107
+ display: flex;
108
+ flex: none;
109
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
110
+ }
111
+ button[tab] {
112
+ flex: 1;
113
+ position: relative;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ height: 38px;
118
+ padding: 0;
119
+ border: none;
120
+ border-radius: 0;
121
+ border-bottom: 2px solid transparent;
122
+ background: none;
123
+ color: var(--md-sys-color-on-surface-variant);
124
+ }
125
+ button[tab]:hover:not([on]) {
126
+ background: var(--md-sys-color-surface-container);
127
+ }
128
+ button[tab][on] {
129
+ color: var(--md-sys-color-primary);
130
+ border-bottom-color: var(--md-sys-color-primary);
131
+ }
132
+ button[tab] md-icon {
133
+ font-size: 20px;
134
+ width: 20px;
135
+ height: 20px;
136
+ }
137
+ span[badge] {
138
+ position: absolute;
139
+ top: 3px;
140
+ right: 2px;
141
+ padding: 0 5px;
142
+ font-size: 0.62rem;
143
+ font-weight: 700;
144
+ line-height: 1.5;
145
+ color: var(--md-sys-color-on-primary-container);
146
+ background: var(--md-sys-color-primary-container);
147
+ border-radius: 999em;
148
+ pointer-events: none;
149
+ }
150
+ /* The sections inside carry the margins (side-panel-styles); the panel only scrolls. */
151
+ div[panel] {
152
+ flex: 1;
81
153
  min-height: 0;
154
+ overflow: auto;
155
+ padding: 0;
82
156
  }
83
- aside:last-child {
84
- border-left: 1px solid var(--md-sys-color-outline-variant);
157
+ div[panel][flush] {
158
+ padding: 0;
85
159
  }
86
- h3 {
87
- font-size: var(--ui-t-body, 13px);
88
- margin: 22px 0 10px;
160
+ /* Hidden, not removed: a panel keeps what it holds (the verdict its last answer) while another tab is open. */
161
+ [off] {
162
+ display: none !important;
89
163
  }
90
- h3:first-child {
91
- margin-top: 0;
164
+ /* Which part the panel speaks of, and the way back to the whole figure. */
165
+ div[subject] {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 6px;
169
+ padding: 6px 10px 0;
92
170
  }
93
- button,
94
- input,
95
- select {
171
+ div[subject] button[chip] {
172
+ display: inline-flex;
173
+ align-items: center;
174
+ gap: 2px;
175
+ height: 26px;
176
+ padding: 0 10px 0 6px;
177
+ border-radius: 999em;
178
+ color: var(--md-sys-color-on-surface-variant);
179
+ }
180
+ div[subject] md-icon {
181
+ font-size: 16px;
182
+ width: 16px;
183
+ height: 16px;
184
+ }
185
+ button {
96
186
  font: inherit;
97
187
  box-sizing: border-box;
98
- height: 34px;
188
+ height: 32px;
99
189
  border: 1px solid var(--md-sys-color-outline-variant);
100
190
  border-radius: 6px;
101
- background: var(--md-sys-color-surface, #fff);
191
+ background: var(--md-sys-color-surface);
102
192
  color: inherit;
103
193
  padding: 0 8px;
104
194
  min-width: 0;
@@ -115,104 +205,80 @@ export class FigureV3Editor extends LitElement {
115
205
  color: var(--md-sys-color-on-secondary-container);
116
206
  border-color: var(--md-sys-color-primary);
117
207
  }
118
- label[name-field] {
119
- margin: 0 0 8px;
120
- }
121
- label[name-field] input {
122
- font-weight: 600;
123
- }
124
- div[tools] {
125
- display: flex;
126
- flex-wrap: wrap;
127
- gap: 4px;
128
- margin: 0 0 8px;
129
- }
130
- div[tools] button {
131
- display: inline-flex;
132
- align-items: center;
133
- justify-content: center;
134
- width: 34px;
135
- padding: 0;
136
- }
137
- div[tools] md-icon {
138
- font-size: 18px;
139
- width: 18px;
140
- height: 18px;
141
- }
142
- .parts {
143
- display: grid;
144
- gap: 6px;
145
- }
146
- .recipes {
147
- display: grid;
148
- grid-template-columns: 1fr 1fr;
149
- gap: 6px;
150
- }
151
- label {
152
- display: grid;
153
- grid-template-columns: 112px minmax(0, 1fr);
154
- align-items: center;
155
- gap: 8px;
156
- margin: 8px 0;
157
- }
158
- label span {
159
- overflow-wrap: anywhere;
160
- }
161
208
  .error {
162
209
  padding: 10px;
163
210
  background: var(--md-sys-color-error-container);
164
211
  color: var(--md-sys-color-on-error-container);
165
212
  white-space: pre-wrap;
166
213
  }
167
- .status {
168
- margin-left: auto;
169
- }
170
214
  .hint {
171
215
  color: var(--md-sys-color-on-surface-variant);
172
216
  line-height: 1.5;
173
217
  }
174
- /* 660 and over: parts | canvas | inputs. Below that the sum of the columns' least widths no longer fits. */
175
- @container (max-width: 659px) {
176
- main {
177
- grid-template-columns: 140px minmax(160px, 1fr);
178
- }
179
- aside:last-child {
180
- grid-column: 1/-1;
181
- max-height: 260px;
182
- border-left: none;
183
- border-top: 1px solid var(--md-sys-color-outline-variant);
184
- }
185
- }
186
- /* Under 420: one column. The canvas first — seeing what is being made comes before the lists — and main scrolls. */
187
- @container (max-width: 419px) {
188
- main {
189
- grid-template-columns: minmax(0, 1fr);
190
- grid-template-rows: minmax(240px, 45%);
191
- grid-auto-rows: auto;
192
- overflow-y: auto;
193
- }
194
- figure-v3-canvas {
195
- order: -1;
196
- }
197
- figure-v3-parts {
198
- border-right: none;
199
- border-top: 1px solid var(--md-sys-color-outline-variant);
200
- }
201
- aside:last-child {
202
- max-height: none;
203
- }
204
- }
205
- `
218
+ `]
206
219
  @property({ attribute: false }) session?: V3ProductSession
207
220
  @property({ type: Number }) revision = 0
221
+ /** The verdict's count, drawn on its tab so it stays in sight while another tab is open (V2's badge). */
222
+ @property({ type: Number }) instances?: number
223
+ /** The cost line the parts list shows, from the maturity report. */
224
+ @property({ attribute: false }) budget?: V3BudgetLine
208
225
  @state() private selected = ''
209
226
  @state() private error = ''
227
+ @state() private tab: SideTab = 'properties'
228
+ /** The frame the bar folds - found after the first render, since the bar is drawn before it. */
229
+ @state() private panes?: OxThreePane
230
+ /** What the middle shows: the parts being edited, or the figure placed on a board at eight sizes (V2's two modes). */
231
+ @state() private middle: 'edit' | 'preview' = 'edit'
232
+ /**
233
+ * How the workbench shows the figure -- V2's view settings, the board's own state names. The viewer's, kept in
234
+ * their browser, never in the figure.
235
+ */
236
+ @state() private view: ViewSettings = FigureV3Editor.storedView()
237
+ private static storedView(): ViewSettings {
238
+ try {
239
+ const stored = JSON.parse(localStorage.getItem('figure-v3-view') ?? 'null')
240
+ return stored && typeof stored === 'object' ? { ...DEFAULT_VIEW, ...stored } : { ...DEFAULT_VIEW }
241
+ } catch {
242
+ return { ...DEFAULT_VIEW }
243
+ }
244
+ }
245
+ private setView(view: ViewSettings) {
246
+ this.view = { ...DEFAULT_VIEW, ...view }
247
+ try {
248
+ localStorage.setItem('figure-v3-view', JSON.stringify(this.view))
249
+ } catch {
250
+ /* A browser that keeps nothing still shows the change for this visit. */
251
+ }
252
+ }
210
253
  /**
211
254
  * Parts hidden on the workbench — the author's view while working, not the asset's visibility. Not saved, as in
212
255
  * the V2 modeller: a part hidden here is still in the figure, still checked, still drawn on a board.
213
256
  */
214
257
  @state() private hiddenParts = new Set<string>()
258
+ /** A value in hand -- typed, held on a spinner, scrubbed, dragged -- drawn at once and recorded on release. */
259
+ @state() private live: LiveValues = NOTHING_LIVE
215
260
  private lastContext: string | null = null
261
+ /** The modeller's keys (`v3-keys.ts`): undo, redo, duplicate, remove, back to the whole figure. */
262
+ private onKey = (e: KeyboardEvent) => {
263
+ const view = this.session?.read()
264
+ if (!view || view.loading || view.saving || !view.context || !this.isConnected) return
265
+ const action = keyActionOf({ key: e.key, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, altKey: e.altKey, target: e.composedPath()[0] as HTMLElement }, this.selected)
266
+ if (!action) return
267
+ e.preventDefault()
268
+ if (action === 'undo') view.canUndo && this.run(() => this.session!.undo())
269
+ else if (action === 'redo') view.canRedo && this.run(() => this.session!.redo())
270
+ else if (action === 'duplicate') this.duplicatePart(this.selected)
271
+ else if (action === 'remove') this.removePart(this.selected)
272
+ else this.selected = ''
273
+ }
274
+ connectedCallback() {
275
+ super.connectedCallback()
276
+ window.addEventListener('keydown', this.onKey)
277
+ }
278
+ disconnectedCallback() {
279
+ window.removeEventListener('keydown', this.onKey)
280
+ super.disconnectedCallback()
281
+ }
216
282
  protected willUpdate(_changed: PropertyValues) {
217
283
  const view = this.session?.read()
218
284
  if (view?.context !== this.lastContext) {
@@ -230,8 +296,20 @@ export class FigureV3Editor extends LitElement {
230
296
  this.changed()
231
297
  } catch (error) {
232
298
  this.error = error instanceof Error ? error.message : String(error)
299
+ } finally {
300
+ /* Committed or refused, the value in hand is gone: the figure as it stands is drawn again. */
301
+ this.live = NOTHING_LIVE
233
302
  }
234
303
  }
304
+ /** What the canvas draws: the figure with the value in hand when there is one, and without the parts hidden on the workbench. */
305
+ private drawnParts(geometry: Parameters<typeof drawableV3Parts>[0] | undefined, asset: unknown) {
306
+ const parts = (isLive(this.live) && asset ? livePartsOf(asset as V3Asset, this.live) : null) ?? drawableV3Parts(geometry ?? [])
307
+ return parts.filter(body => !this.hiddenParts.has((body as { id: string }).id))
308
+ }
309
+ /** A value in hand: an input's value (design or pose) or a part edit. Nothing is recorded. */
310
+ private hold(values: Record<string, number>, edit?: V3PartEdit) {
311
+ this.live = { values: { ...this.live.values, ...values }, ...(edit ? { edit } : this.live.edit ? { edit: this.live.edit } : {}) }
312
+ }
235
313
  private changed() {
236
314
  this.requestUpdate()
237
315
  this.dispatchEvent(new CustomEvent('v3-changed', { bubbles: true, composed: true }))
@@ -256,35 +334,13 @@ export class FigureV3Editor extends LitElement {
256
334
  this.changed()
257
335
  }
258
336
  }
259
- private add(kind: V3PrimitiveCreation['kind']) {
337
+ private add(kind: V3PaletteKind) {
260
338
  if (!this.session) return
261
339
  this.run(() => {
262
340
  const ids = new Set(this.session!.read().geometry.map(g => g.id))
263
341
  let n = 1
264
342
  while (ids.has(`${kind}-${n}`)) n++
265
- const common = { id: `${kind}-${n}`, position: { x: 0, y: 50, z: 0 } }
266
- const part: V3PrimitiveCreation =
267
- kind === 'box'
268
- ? { ...common, kind, dimensions: { width: 100, height: 100, depth: 100 } }
269
- : kind === 'cylinder'
270
- ? { ...common, kind, dimensions: { radius: 50, length: 100 } }
271
- : kind === 'rounded-box'
272
- ? { ...common, kind, dimensions: { width: 100, height: 100, depth: 100, round: 10 } }
273
- : {
274
- ...common,
275
- position: { x: 0, y: 0, z: 0 },
276
- kind: 'portal',
277
- dimensions: {
278
- left: -100,
279
- right: 100,
280
- bottom: 0,
281
- top: 200,
282
- openingLeft: -70,
283
- openingRight: 70,
284
- openingTop: 170,
285
- depth: 30
286
- }
287
- }
343
+ const part = paletteRecipe(kind, `${kind}-${n}`)
288
344
  this.session!.add(part)
289
345
  this.selected = part.id
290
346
  })
@@ -422,7 +478,7 @@ export class FigureV3Editor extends LitElement {
422
478
  }
423
479
  }
424
480
  /** Size response, fastening and motion for the selected part (parity ④ ⑤ ⑥), as authoring commands. */
425
- private renderLinks(unavailable: boolean) {
481
+ private renderLinks(unavailable: boolean, section: 'shape' | 'places' | 'motion') {
426
482
  let asset
427
483
  try {
428
484
  asset = JSON.parse(this.session!.source())
@@ -437,12 +493,40 @@ export class FigureV3Editor extends LitElement {
437
493
  }
438
494
  return html`<figure-v3-links
439
495
  .asset=${asset}
496
+ .section=${section}
440
497
  .partId=${this.selected}
441
498
  .dims=${dims}
442
499
  ?locked=${unavailable}
443
500
  @v3-command=${(e: CustomEvent) => this.linkCommand(e.detail.action)}
501
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
444
502
  ></figure-v3-links>`
445
503
  }
504
+ /** The figure's controls: try a value, name it, set its range and travel time, keep the pose (parity row ⑥). */
505
+ private renderControls(view: { fields: Array<{ id: string; role?: string; value: number }> }, unavailable: boolean) {
506
+ let asset
507
+ try {
508
+ asset = JSON.parse(this.session!.source())
509
+ } catch {
510
+ return null
511
+ }
512
+ const values = Object.fromEntries(view.fields.filter(f => f.role === 'state').map(f => [f.id, f.value]))
513
+ return html`<figure-v3-controls
514
+ .asset=${asset}
515
+ .values=${values}
516
+ ?locked=${unavailable}
517
+ @v3-state=${(e: CustomEvent) => this.run(() => this.session!.apply([{ kind: 'set-state', input: e.detail.input, unit: e.detail.unit, value: e.detail.value }]))}
518
+ @v3-state-live=${(e: CustomEvent) => this.hold({ [e.detail.input]: e.detail.value })}
519
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
520
+ ></figure-v3-controls>
521
+ <figure-v3-clips
522
+ .asset=${asset}
523
+ .values=${values}
524
+ ?locked=${unavailable}
525
+ @v3-states=${(e: CustomEvent) => this.run(() => this.session!.apply(e.detail.changes))}
526
+ @v3-states-live=${(e: CustomEvent) => this.hold(e.detail.values)}
527
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
528
+ ></figure-v3-clips>`
529
+ }
446
530
  /** One command, one step of the history. A refusal a person can meet here is said in their words. */
447
531
  private linkCommand(action: V3AuthoringAction) {
448
532
  try {
@@ -472,34 +556,164 @@ export class FigureV3Editor extends LitElement {
472
556
  // Rejected edits must also restore the displayed numeric value.
473
557
  input.value = String(this.session!.read().fields.find(i => i.id === field.id)!.value)
474
558
  }
559
+ /** Typing or a held spinner: each number on the way is drawn; the field's change records it. */
560
+ private typing(field: { id: string }, event: Event) {
561
+ const value = (event.target as HTMLInputElement).valueAsNumber
562
+ if (Number.isFinite(value)) this.hold({ [field.id]: value })
563
+ }
564
+ /**
565
+ * Drag a field's label (or an axis letter) sideways to scrub its value, as vector tools do: the figure follows the
566
+ * pointer, and the release records one step. The field's number follows too, so the value in hand is always read.
567
+ */
568
+ private scrub(field: { id: string; unit: V3InputChange['unit']; role?: string; value: number }, down: PointerEvent, unavailable: boolean) {
569
+ if (unavailable || down.button !== 0) return
570
+ const label = down.currentTarget as HTMLElement
571
+ const input = (label.closest('div[cell]')?.querySelector('input') ?? label.nextElementSibling) as HTMLInputElement | null
572
+ if (!input) return
573
+ down.preventDefault()
574
+ label.setPointerCapture(down.pointerId)
575
+ const start = field.value
576
+ let value = start
577
+ const move = (e: PointerEvent) => {
578
+ value = scrubbed(start, e.clientX - down.clientX, scrubStep(field.unit, start, e.shiftKey))
579
+ input.value = String(value)
580
+ this.hold({ [field.id]: value })
581
+ }
582
+ const up = (e: PointerEvent) => {
583
+ label.removeEventListener('pointermove', move)
584
+ label.removeEventListener('pointerup', up)
585
+ label.removeEventListener('pointercancel', up)
586
+ /* A drag the system took away is not the author's value: the field goes back to where it was. */
587
+ if (e.type === 'pointercancel') value = start
588
+ if (value === start) {
589
+ input.value = String(start)
590
+ this.live = NOTHING_LIVE
591
+ return
592
+ }
593
+ input.value = String(value)
594
+ this.changeField(field, { target: input } as unknown as Event)
595
+ }
596
+ label.addEventListener('pointermove', move)
597
+ label.addEventListener('pointerup', up)
598
+ label.addEventListener('pointercancel', up)
599
+ }
600
+ /** The measured range, kept for the revision it was measured at: measuring evaluates the figure at its edges. */
601
+ private range?: { at: string; of: ReturnType<typeof instanceRangeOf> }
602
+ /** The sizes a board may give the figure, and the way to measure them again after its parts changed. */
603
+ private renderInstanceRange(asset: V3Asset | undefined, unavailable: boolean) {
604
+ if (!asset) return nothing
605
+ /* Another figure, or another step of this one, is measured again. */
606
+ const at = `${this.session?.read().context ?? ''}:${this.revision}`
607
+ if (this.range?.at !== at) {
608
+ let of: ReturnType<typeof instanceRangeOf>
609
+ try {
610
+ of = instanceRangeOf(asset)
611
+ } catch {
612
+ of = undefined
613
+ }
614
+ this.range = { at, of }
615
+ }
616
+ const range = this.range.of
617
+ if (!range) return nothing
618
+ const mm = (v: number) => Math.round(v).toLocaleString()
619
+ const differs = rangeDiffers(range.declared, range.measured)
620
+ return html`<section>
621
+ <h3>${i18next.t('figure.label.instance-range')}</h3>
622
+ ${(['x', 'y', 'z'] as const).map(
623
+ axis => html`<div inline-field>
624
+ <label>${axis.toUpperCase()}</label><span>${mm(range.declared[axis].min)} – ${mm(range.declared[axis].max)} mm</span>
625
+ </div>`
626
+ )}
627
+ ${differs
628
+ ? html`<p hint>
629
+ ${i18next.t('figure.text.instance-range-can-widen', {
630
+ ranges: (['x', 'y', 'z'] as const)
631
+ .filter(axis => rangeDiffers(range.declared, { ...range.declared, [axis]: range.measured[axis] }))
632
+ .map(axis => `${axis.toUpperCase()} ${mm(range.measured[axis].min)} – ${mm(range.measured[axis].max)}`)
633
+ .join(' · ')
634
+ })}
635
+ </p>
636
+ <button act ?disabled=${unavailable} @click=${() => this.linkCommand({ kind: 'declare-instance-size', range: range.measured, replace: true })}>
637
+ <md-icon>straighten</md-icon>${i18next.t('figure.button.remeasure-range')}
638
+ </button>`
639
+ : nothing}
640
+ </section>`
641
+ }
642
+ /** Choose a part, from the list, the canvas or a place row. A figure-level tab gives way to the part's properties. */
643
+ private choose(id: string) {
644
+ this.selected = id
645
+ if (id && (this.tab === 'verdict' || this.tab === 'view' || this.tab === 'history')) this.tab = 'properties'
646
+ }
647
+ private asset(): unknown {
648
+ try {
649
+ return JSON.parse(this.session!.source())
650
+ } catch {
651
+ return undefined
652
+ }
653
+ }
654
+ private renderTab(tab: SideTab, icon: string, label: string, badge?: number) {
655
+ return html`<button tab ?on=${this.tab === tab} title=${label} aria-label=${label} @click=${() => (this.tab = tab)}>
656
+ <md-icon>${icon}</md-icon>${badge == null ? '' : html`<span badge>${badge.toLocaleString()}</span>`}
657
+ </button>`
658
+ }
659
+ updated(changed: PropertyValues<this>) {
660
+ super.updated?.(changed)
661
+ const panes = this.renderRoot.querySelector<OxThreePane>('ox-three-pane') ?? undefined
662
+ if (panes !== this.panes) this.panes = panes
663
+ }
664
+ /** Which part the panel speaks of, with the way back to the whole figure. */
665
+ private renderSubject() {
666
+ if (!this.selected) return null
667
+ return html`<div subject>
668
+ <button chip title=${i18next.t('figure.button.whole-figure')} @click=${() => (this.selected = '')}>
669
+ <md-icon>arrow_back</md-icon>${i18next.t('figure.button.whole-figure')}
670
+ </button>
671
+ </div>`
672
+ }
475
673
  render() {
476
674
  const view = this.session?.read()
477
- if (!view) return html`<p>모델을 여는 중입니다.</p>`
675
+ if (!view) return html`<p>${i18next.t('figure.text.opening-model')}</p>`
478
676
  const unavailable = view.loading || !!view.error || view.saving || !view.context
479
677
  const part = view.geometry.find(g => g.id === this.selected)
480
- // Shared/root parameters remain available even when a part is selected.
481
- const own = view.fields.filter(i => this.selected && i.id.startsWith(this.selected + '.'))
482
- const common = view.fields.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')))
483
- const fields = this.selected ? [...own, ...common] : view.fields
484
- return html` <header>
485
- <strong>V3 모델링</strong>
486
- <button ?disabled=${unavailable || !view.canUndo} @click=${() => this.run(() => this.session!.undo())}>
487
- 실행 취소
488
- </button>
489
- <button ?disabled=${unavailable || !view.canRedo} @click=${() => this.run(() => this.session!.redo())}>
490
- 다시 실행
491
- </button>
492
- <button @click=${() => this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')?.fit()}>
493
- 전체 보기
494
- </button>
495
- <span class="status" role="status"
496
- >${view.loading ? '불러오는 중' : view.saving ? '저장 중' : view.dirty || !view.id ? '저장 안 됨' : '저장됨'}</span
497
- >
498
- <button ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>저장</button>
499
- </header>
678
+ /* The controls (state inputs) have their own place in the motion tab; these are the design values. */
679
+ const design = view.fields.filter(i => i.role !== 'state')
680
+ const own = design.filter(i => this.selected && i.id.startsWith(this.selected + '.'))
681
+ const common = design.filter(i => !view.geometry.some(g => i.id.startsWith(g.id + '.')))
682
+ const status = view.loading
683
+ ? 'figure.text.loading-figure'
684
+ : view.saving
685
+ ? 'figure.text.saving-figure'
686
+ : view.dirty || !view.id
687
+ ? 'figure.text.unsaved-changes'
688
+ : 'figure.text.saved'
689
+ const asset = this.asset()
690
+ const tab = this.tab
691
+ return html`<ox-canvas-bar
692
+ history
693
+ fit
694
+ undo-shortcut="Ctrl+Z"
695
+ redo-shortcut="Ctrl+Y"
696
+ ?can-undo=${!unavailable && view.canUndo}
697
+ ?can-redo=${!unavailable && view.canRedo}
698
+ .pane=${this.panes}
699
+ @canvas-undo=${() => this.run(() => this.session!.undo())}
700
+ @canvas-redo=${() => this.run(() => this.session!.redo())}
701
+ @canvas-fit=${() => this.renderRoot.querySelector<FigureV3Canvas>('figure-v3-canvas')?.fit()}
702
+ >
703
+ ${(['edit', 'preview'] as const).map(
704
+ mode => html`<button slot="mode" mode aria-pressed=${String(this.middle === mode)} title=${i18next.t('figure.text.mode-' + mode + '-explained')} @click=${() => (this.middle = mode)}>
705
+ ${i18next.t('figure.label.mode-' + mode)}
706
+ </button>`
707
+ )}
708
+ <span slot="status" class="status" role="status" ?dirty=${status === 'figure.text.unsaved-changes'}>${i18next.t(status)}</span>
709
+ <button slot="commit" save ?disabled=${unavailable || (!view.dirty && !!view.id)} @click=${this.save}>${i18next.t('figure.button.save')}</button>
710
+ </ox-canvas-bar>
500
711
  ${this.error || view.error ? html`<div role="alert" class="error">${this.error || view.error}</div>` : null}
501
- <main>
712
+ <ox-three-pane>
502
713
  <figure-v3-parts
714
+ slot="start"
715
+ .budget=${this.budget}
716
+ @show-maturity=${() => (this.tab = 'verdict')}
503
717
  .parts=${view.geometry.map(
504
718
  (g): V3PartRow => ({
505
719
  id: g.id,
@@ -511,7 +725,7 @@ export class FigureV3Editor extends LitElement {
511
725
  .selected=${this.selected}
512
726
  ?locked=${unavailable}
513
727
  @add-part=${(e: CustomEvent) => this.add(e.detail.kind)}
514
- @select-part=${(e: CustomEvent) => (this.selected = e.detail.id)}
728
+ @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
515
729
  @toggle-part-hidden=${(e: CustomEvent) => this.toggleHiddenPart(e.detail.id)}
516
730
  @set-all-parts-hidden=${(e: CustomEvent) =>
517
731
  (this.hiddenParts = e.detail.hidden ? new Set(view.geometry.map(g => g.id)) : new Set())}
@@ -520,50 +734,145 @@ export class FigureV3Editor extends LitElement {
520
734
  @duplicate-part=${(e: CustomEvent) => this.duplicatePart(e.detail.id)}
521
735
  @remove-part=${(e: CustomEvent) => this.removePart(e.detail.id)}
522
736
  ></figure-v3-parts>
523
- <figure-v3-canvas
524
- .parts=${drawableV3Parts(view.geometry ?? []).filter(body => !this.hiddenParts.has((body as { id: string }).id))}
737
+ ${this.middle === 'preview'
738
+ ? html`<figure-v3-preview
739
+ .asset=${asset}
740
+ .context=${view.context ?? ''}
741
+ .view=${this.view}
742
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
743
+ ></figure-v3-preview>`
744
+ : html`<figure-v3-canvas
745
+ .view=${this.view}
746
+ .parts=${this.drawnParts(view.geometry, asset)}
525
747
  .palette=${view.palette ?? {}}
526
748
  .context=${view.context ?? ''}
527
749
  .selected=${this.selected}
528
750
  .reach=${unavailable ? { translate: false, rotate: false, scale: false } : this.reachOf(this.selected)}
529
751
  @part-transformed=${(e: CustomEvent) => this.transformed(e.detail.id, e.detail.result)}
530
- @part-selected=${(e: CustomEvent<string>) => {
531
- this.selected = e.detail
532
- }}
752
+ @part-selected=${(e: CustomEvent<string>) => this.choose(e.detail)}
533
753
  @render-error=${(e: CustomEvent<Error>) => {
534
754
  this.error = e.detail.message
535
755
  }}
536
- ></figure-v3-canvas>
537
- <aside>
538
- ${this.selected && part
539
- ? html`<label name-field
540
- ><span>${i18next.t('figure.label.name')}</span
541
- ><input
542
- aria-label=${i18next.t('figure.label.name')}
543
- .value=${this.selected}
544
- ?disabled=${unavailable}
545
- @change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
546
- /></label>`
547
- : html`<h3>${this.selected || '모델 입력'}</h3>`}
548
- ${this.renderTools(unavailable)}
549
- ${fields.map(field => html`<label><span>${fieldLabel(field.id)} (${field.unit})${field.role === 'state' ? ' · 동작' : ''}</span><input aria-label=${field.id} type="number" step="any" .value=${String(field.value)} ?disabled=${unavailable} @change=${(e: Event) => this.changeField(field, e)} /></label>`)}
550
- ${
551
- part
552
- ? html`${this.renderLinks(unavailable)}<figure-v3-material
553
- .body=${part}
554
- .palette=${view.palette ?? {}}
555
- ?locked=${unavailable}
556
- @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
557
- ></figure-v3-material>`
558
- : null
559
- }
560
- ${
561
- view.bindings.length
562
- ? html`<h3>배치 연결점</h3>
563
- ${view.bindings.map(b => html`<p>${b.label} · ${b.role}</p>`)}`
564
- : null
565
- }
756
+ ></figure-v3-canvas>`}
757
+ <aside slot="end">
758
+ <div tabs>
759
+ ${this.renderTab('properties', 'tune', i18next.t('figure.label.properties'))}
760
+ ${this.renderTab('capability', 'conveyor_belt', i18next.t('figure.label.tab-capability'))}
761
+ ${this.renderTab('motion', 'animation', i18next.t('figure.label.motion'))}
762
+ ${this.renderTab('verdict', 'insights', i18next.t('figure.label.maturity'), this.instances)}
763
+ ${this.renderTab('view', 'wb_sunny', i18next.t('figure.label.tab-environment'))}
764
+ ${this.renderTab('history', 'history', i18next.t('figure.label.history'))}
765
+ </div>
766
+ <slot name="notice"></slot>
767
+ <div panel ?off=${tab !== 'properties'}>
768
+ ${this.selected && part
769
+ ? html`${this.renderSubject()}
770
+ <section>
771
+ <h3>${i18next.t('figure.label.group-part')}</h3>
772
+ <div inline-field>
773
+ <label>${i18next.t('figure.label.name')}</label>
774
+ <input
775
+ name-field
776
+ aria-label=${i18next.t('figure.label.name')}
777
+ .value=${this.selected}
778
+ ?disabled=${unavailable}
779
+ @change=${(e: Event) => this.renamePart(e.target as HTMLInputElement)}
780
+ />
781
+ </div>
782
+ </section>
783
+ ${this.renderGroups(groupFields(design, this.selected), 'figure.label.group-placement-size', 'figure.label.group-shape', unavailable, this.renderTools(unavailable))}
784
+ ${this.renderLinks(unavailable, 'shape')}
785
+ <figure-v3-material
786
+ .body=${part}
787
+ .palette=${view.palette ?? {}}
788
+ ?locked=${unavailable}
789
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
790
+ @v3-part-live=${(e: CustomEvent) => this.hold({}, e.detail.edit)}
791
+ ></figure-v3-material>`
792
+ : html`<slot name="figure"></slot>
793
+ ${this.renderGroups(groupFields(common), 'figure.label.group-figure-size', 'figure.label.design-inputs', unavailable)}
794
+ ${this.renderInstanceRange(asset as V3Asset | undefined, unavailable)}`}
795
+ </div>
796
+ <div panel ?off=${tab !== 'capability'}>
797
+ ${this.renderSubject()}
798
+ ${this.selected && part ? this.renderLinks(unavailable, 'places') : null}
799
+ <figure-v3-capability
800
+ .asset=${asset}
801
+ .selected=${this.selected}
802
+ ?locked=${unavailable}
803
+ @select-part=${(e: CustomEvent) => this.choose(e.detail.id)}
804
+ @v3-part-edit=${(e: CustomEvent) => this.run(() => this.session!.edit(e.detail.edit))}
805
+ ></figure-v3-capability>
806
+ </div>
807
+ <div panel ?off=${tab !== 'motion'}>
808
+ ${this.renderSubject()}
809
+ ${this.selected && part ? this.renderLinks(unavailable, 'motion') : html`<section><p hint>${i18next.t('figure.text.choose-a-part-to-move')}</p></section>`}
810
+ ${this.renderControls(view, unavailable)}
811
+ </div>
812
+ <div panel flush ?off=${tab !== 'verdict'}><slot name="verdict"></slot></div>
813
+ <div panel flush ?off=${tab !== 'view'}>
814
+ <figure-settings .view=${this.view} @update-view=${(e: CustomEvent) => this.setView(e.detail.view)}></figure-settings>
815
+ </div>
816
+ <div panel flush ?off=${tab !== 'history'}><slot name="history"></slot></div>
566
817
  </aside>
567
- </main>`
818
+ </ox-three-pane>`
819
+ }
820
+ /** Position, rotation and size as rows of three cells; the shape's own measures one to a line, under their title. */
821
+ private renderGroups(
822
+ groups: { vectors: FieldGroup[]; singles: FieldGroup[] },
823
+ vectorTitle: string,
824
+ singleTitle: string,
825
+ unavailable: boolean,
826
+ tools: unknown = null
827
+ ) {
828
+ return html`${groups.vectors.length || tools
829
+ ? html`<section>
830
+ <h3>${i18next.t(vectorTitle)}</h3>
831
+ ${groups.vectors.map(g => this.renderVector(g, unavailable))}
832
+ ${tools ? html`<div vector-row><label>${i18next.t('figure.label.tools')}</label>${tools}</div>` : nothing}
833
+ </section>`
834
+ : nothing}
835
+ ${groups.singles.length
836
+ ? html`<section>
837
+ <h3>${i18next.t(singleTitle)}</h3>
838
+ <div form>${groups.singles.map(g => (g.kind === 'single' ? this.renderSingle(g.name, g.field, unavailable) : nothing))}</div>
839
+ </section>`
840
+ : nothing}`
841
+ }
842
+ private renderVector(group: FieldGroup, unavailable: boolean) {
843
+ if (group.kind !== 'vector') return nothing
844
+ const unit = group.cells.x.unit === 'deg' ? '°' : group.cells.x.unit
845
+ return html`<div vector-row>
846
+ <label>${i18next.t('figure.label.group-' + group.name)}${group.name === 'rotation' ? '' : html` <span unit>${unit}</span>`}</label>
847
+ <div triple>
848
+ ${(['x', 'y', 'z'] as const).map(
849
+ axis => html`<div cell>
850
+ <span axis ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(group.cells[axis] as never, e, unavailable)}>${axis.toUpperCase()}</span>
851
+ <input
852
+ aria-label=${group.cells[axis].id}
853
+ type="number"
854
+ step="any"
855
+ .value=${String(group.cells[axis].value)}
856
+ ?disabled=${unavailable}
857
+ @input=${(e: Event) => this.typing(group.cells[axis] as never, e)}
858
+ @change=${(e: Event) => this.changeField(group.cells[axis] as never, e)}
859
+ />
860
+ </div>`
861
+ )}
862
+ </div>
863
+ </div>`
864
+ }
865
+ private renderSingle(name: string, field: FieldLike, unavailable: boolean) {
866
+ const words = name.split('.').map(part => i18next.t('figure.label.field-' + part, { defaultValue: part }))
867
+ return html`<span ?scrub=${!unavailable} @pointerdown=${(e: PointerEvent) => this.scrub(field as never, e, unavailable)}>${words.join(' · ')} (${field.unit})</span
868
+ ><input
869
+ aria-label=${field.id}
870
+ type="number"
871
+ step="any"
872
+ .value=${String(field.value)}
873
+ ?disabled=${unavailable}
874
+ @input=${(e: Event) => this.typing(field as never, e)}
875
+ @change=${(e: Event) => this.changeField(field as never, e)}
876
+ />`
568
877
  }
569
878
  }