@things-factory/figure-ui 10.1.46 → 10.1.48

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