@things-factory/figure-ui 10.1.46 → 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 +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 +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 +531 -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 +93 -9
  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 +5 -2
  45. package/dist-client/modeller/figure-v3-links.js +64 -82
  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 +555 -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 +30 -1
  90. package/dist-client/modeller/v3-links.js +94 -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 -1
  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 +77 -6
  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 +84 -5
  135. package/translations/ja.json +86 -7
  136. package/translations/ko.json +84 -5
  137. package/translations/ms.json +86 -7
  138. package/translations/zh.json +86 -7
@@ -1,66 +1,83 @@
1
1
  import '@material/web/icon/icon.js'
2
2
 
3
- import { css, html, LitElement, nothing } from 'lit'
3
+ import { css, html, LitElement, nothing, svg } from 'lit'
4
4
  import { customElement, property, state } from 'lit/decorators.js'
5
5
 
6
6
  import { i18next, localize } from '@operato/i18n'
7
- import { parseV3Asset, v3CostOf } from '@hatiolab/figure-model'
7
+ import { ScrollbarStyles } from '@operato/styles'
8
+ import { LIMITS, parseV3Asset, V3_PER_INSTANCE, v3CostOf } from '@hatiolab/figure-model'
8
9
 
9
10
  import { inspectFigureDraft } from '../graphql/index.js'
10
11
  import type { FigureInspection } from '../types.js'
11
12
  import { gateFindingStyles, renderGateFinding } from './gate-findings.js'
12
- import { capacityFrom, MEASURED } from './scene-budget.js'
13
- import type { FigureCapacity } from './scene-budget.js'
13
+ import { MEASURED } from './scene-budget.js'
14
+ import { SidePanelStyles } from './side-panel-styles.js'
15
+ import { budgetOf, CURVE_SIZE, curveLayout, GRADE_SCALE, shortNumber, v3ReportOf } from './v3-maturity.js'
16
+ import type { V3BudgetUse, V3MaturityAxis, V3Report } from './v3-maturity.js'
14
17
 
15
18
  /** How long an edit settles before the server is asked again. One question per pause, not one per keystroke. */
16
19
  export const VERDICT_SETTLE_MS = 400
17
20
 
18
21
  /**
19
- * 판정 — the V3 modeller's verdict while editing (parity row ①).
22
+ * 성숙도 — the V3 modeller's verdict while editing (parity row ①), laid out as V2's report (`figure-report.ts`).
20
23
  *
21
- * The V2 modeller shows, as the author edits, how many of the figure fit on one screen and what would stop it
22
- * being published. The V3 page showed neither: the author learned what blocked a release only after saving, in the
23
- * release panel. This tab answers both from the source being edited.
24
+ * V2's report showed, as the author edits: how many fit on one screen with the grade beside it and the budget that
25
+ * decided the grade, the grade scale, six maturity axes as a radar and a list, the budget bars, the drawing cost,
26
+ * the reuse trend, and the findings. The first V3 tab kept only the count and the findings (user, 2026-09-24:
27
+ * 「분석부분도 많이 부족하고, 왜 이렇게 너프되었는지」). This is V2's report again, over V3 numbers.
24
28
  *
25
- * The count is computed here, from `v3CostOf` and the same `capacityFrom` the catalogue cards use, so the tab and
26
- * the card cannot disagree. The findings are not: they come from the server's release door (`inspectFigureDraft`),
27
- * the same door and the same sentences as the release panel. A second judgement on the screen would say something
28
- * different the first time either changed.
29
+ * ## What is computed where
29
30
  *
30
- * No grade. V2's grade measures V2 reuse; nothing measures it for V3, so the tab shows no letter rather than one
31
- * nothing computed.
31
+ * The numbers come from `v3-maturity.ts`, which reads the evaluated asset the way the V3 renderer draws it — the
32
+ * screen calls and shows, it does not count. The count uses the same `capacityFrom` the catalogue cards use, so the
33
+ * tab and the card cannot disagree.
34
+ *
35
+ * The findings do not come from here: they come from the server's release door (`inspectFigureDraft`), the same
36
+ * door and the same sentences as the release panel. A second judgement on the screen would say something different
37
+ * the first time either changed. V2's report drew its findings from `validate`; V3's release judgement is the
38
+ * server's, so that section is the server's answer.
39
+ *
40
+ * ## The grade
41
+ *
42
+ * figure-model's `v3ScoreOf`, which the catalogue uses too: headroom against placing `LIMITS.screenInstances` of the
43
+ * figure on one screen (chief architect, 2026-09-24) -- the screen's measured groups and its triangle target shared
44
+ * per instance -- and V2's rule limits for independently moved parts and transparent materials, decided by the
45
+ * tightest, on V2's scale. The two V2 budgets V3 has no field for are left out and the tab says so.
32
46
  */
47
+ /** A per-instance share of the screen is not a whole number; what an instance may draw is the whole part of it. */
48
+ const limitOf = (use: V3BudgetUse) => Math.floor(use.limit).toLocaleString()
49
+
33
50
  @customElement('figure-v3-verdict')
34
51
  export class FigureV3Verdict extends localize(i18next)(LitElement) {
35
52
  static styles = [
53
+ ScrollbarStyles,
54
+ SidePanelStyles,
36
55
  gateFindingStyles,
37
56
  css`
38
57
  :host {
39
58
  display: flex;
40
59
  flex-direction: column;
41
- gap: 14px;
42
- padding: 12px;
43
- overflow: auto;
44
- font-size: 0.76rem;
45
- color: var(--md-sys-color-on-surface);
60
+ overflow-y: auto;
46
61
  }
47
- section > h3 {
48
- margin: 0 0 6px 0;
49
- font-size: var(--ui-t-micro, 11px);
50
- font-weight: 700;
51
- letter-spacing: 0.04em;
52
- text-transform: uppercase;
53
- color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
54
- }
55
- p {
56
- margin: 4px 0 0;
62
+ section p {
63
+ margin: 0;
57
64
  }
65
+
66
+ /* ── How many fit ───────────────────────────────────────────────
67
+ The value of low poly is how many can stand on one screen, so that answer leads. The grade says whether
68
+ the shape keeps inside its declared limits, so it is the small badge beside it. */
58
69
  p[lead],
59
70
  p[basis],
60
71
  p[quiet] {
72
+ font-size: 0.74rem;
73
+ line-height: 1.55;
61
74
  color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
62
75
  }
63
- div[count] {
76
+ p[basis] em {
77
+ font-style: normal;
78
+ color: var(--md-sys-color-tertiary);
79
+ }
80
+ div[verdict] {
64
81
  display: flex;
65
82
  align-items: baseline;
66
83
  gap: 6px;
@@ -77,6 +94,268 @@ export class FigureV3Verdict extends localize(i18next)(LitElement) {
77
94
  font-weight: 600;
78
95
  color: var(--md-sys-color-primary);
79
96
  }
97
+ /* The badge says what it measures (headroom), so its number is not read as a score out of 100. */
98
+ span[grade] {
99
+ display: flex;
100
+ align-items: baseline;
101
+ gap: 5px;
102
+ margin-left: auto;
103
+ padding: 3px 10px;
104
+ font-size: 0.76rem;
105
+ font-weight: 700;
106
+ color: var(--md-sys-color-on-primary-container);
107
+ background-color: var(--md-sys-color-primary-container);
108
+ border-radius: 999em;
109
+ }
110
+ span[grade] small {
111
+ display: inline;
112
+ margin: 0;
113
+ font-size: 0.68rem;
114
+ font-weight: 500;
115
+ color: inherit;
116
+ }
117
+ /* C keeps within the limit. Red is for over it (D) only; red on a kept limit asks for a fix nothing needs. */
118
+ span[grade][tight] {
119
+ color: var(--md-sys-color-on-tertiary-container);
120
+ background-color: var(--md-sys-color-tertiary-container);
121
+ }
122
+ span[grade][over] {
123
+ color: var(--md-sys-color-on-error-container);
124
+ background-color: var(--md-sys-color-error-container);
125
+ }
126
+ p[because] {
127
+ font-size: 0.78rem;
128
+ line-height: 1.6;
129
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
130
+ }
131
+ p[because] strong {
132
+ color: var(--md-sys-color-on-surface);
133
+ }
134
+ /* The grade scale. Not folded away: it is compared against every time, not learned once. */
135
+ p[scale] {
136
+ display: flex;
137
+ flex-wrap: wrap;
138
+ gap: 2px 10px;
139
+ font-size: 0.7rem;
140
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
141
+ }
142
+ p[scale] b {
143
+ font-weight: 700;
144
+ }
145
+ p[scale] span[now] {
146
+ color: var(--md-sys-color-on-surface);
147
+ }
148
+ p[scale] span[now] b {
149
+ color: var(--md-sys-color-primary);
150
+ }
151
+
152
+ /* ── Maturity radar ─────────────────────────────────────────────
153
+ Only the measured axes are drawn; an unmeasured one drawn as a spoke reads as a dent, which is a zero. */
154
+ div[radar] {
155
+ display: flex;
156
+ justify-content: center;
157
+ padding: 2px 0 6px 0;
158
+ color: var(--md-sys-color-primary);
159
+ }
160
+ div[radar] svg {
161
+ width: 100%;
162
+ max-width: 240px;
163
+ height: auto;
164
+ }
165
+ div[radar] polygon[web] {
166
+ fill: none;
167
+ stroke: var(--md-sys-color-outline-variant);
168
+ stroke-width: 0.6;
169
+ }
170
+ div[radar] line[spoke] {
171
+ stroke: var(--md-sys-color-outline-variant);
172
+ stroke-width: 0.6;
173
+ }
174
+ div[radar] polygon[shape] {
175
+ fill: color-mix(in srgb, var(--md-sys-color-primary) 22%, transparent);
176
+ stroke: var(--md-sys-color-primary);
177
+ stroke-width: 1.4;
178
+ stroke-linejoin: round;
179
+ }
180
+ div[radar] circle[dot] {
181
+ fill: var(--md-sys-color-primary);
182
+ }
183
+ div[radar] text {
184
+ font-size: 5.2px;
185
+ fill: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
186
+ }
187
+ p[unmeasured] {
188
+ font-size: 0.7rem;
189
+ line-height: 1.5;
190
+ color: var(--md-sys-color-tertiary);
191
+ }
192
+
193
+ /* ── Axes and budgets ───────────────────────────────────────────
194
+ Not merged into one number: merged, 「what to fix」 disappears. */
195
+ ul[axes],
196
+ ul[budgets] {
197
+ margin: 0;
198
+ padding: 0;
199
+ list-style: none;
200
+ display: flex;
201
+ flex-direction: column;
202
+ gap: 7px;
203
+ }
204
+ ul[axes] li,
205
+ ul[budgets] li {
206
+ display: grid;
207
+ grid-template-columns: 1fr auto;
208
+ gap: 2px 8px;
209
+ font-size: 0.75rem;
210
+ }
211
+ ul[axes] li[blocked] {
212
+ opacity: 0.7;
213
+ }
214
+ ul[axes] em {
215
+ grid-column: 1 / -1;
216
+ font-style: normal;
217
+ font-size: 0.68rem;
218
+ line-height: 1.5;
219
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
220
+ }
221
+ ul[axes] li[blocked] em {
222
+ color: var(--md-sys-color-tertiary);
223
+ }
224
+ span[reading] {
225
+ font-family: var(--mono-font, monospace);
226
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
227
+ }
228
+ div[bar] {
229
+ grid-column: 1 / -1;
230
+ height: 4px;
231
+ background-color: var(--md-sys-color-surface-container-highest);
232
+ border-radius: 2px;
233
+ overflow: hidden;
234
+ }
235
+ div[bar] > i {
236
+ display: block;
237
+ height: 100%;
238
+ background-color: var(--md-sys-color-primary);
239
+ }
240
+ div[bar] > i[tight] {
241
+ background-color: var(--md-sys-color-error);
242
+ }
243
+
244
+ /* ── Drawing cost ─────────────────────────────────────────────── */
245
+ dl[metrics] {
246
+ margin: 0;
247
+ display: grid;
248
+ grid-template-columns: 1fr auto;
249
+ gap: 4px 8px;
250
+ font-size: 0.78rem;
251
+ }
252
+ dl[metrics] dt {
253
+ color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
254
+ }
255
+ dl[metrics] dd {
256
+ margin: 0;
257
+ text-align: right;
258
+ font-family: var(--mono-font, monospace);
259
+ }
260
+
261
+ /* ── Trend ──────────────────────────────────────────────────────
262
+ The chart's natural size is 680px; in a 300px side its words are 4px. The side holds a button, the chart
263
+ opens at its own size. */
264
+ button[expand] {
265
+ justify-self: start;
266
+ padding: 4px 10px;
267
+ font: inherit;
268
+ font-size: 0.74rem;
269
+ color: var(--md-sys-color-on-surface);
270
+ background: none;
271
+ border: 1px solid var(--md-sys-color-outline-variant);
272
+ border-radius: 6px;
273
+ cursor: pointer;
274
+ }
275
+ button[expand]:hover {
276
+ background-color: var(--md-sys-color-surface-container);
277
+ }
278
+ div[sheet] {
279
+ position: fixed;
280
+ inset: 0;
281
+ z-index: 10;
282
+ display: flex;
283
+ align-items: center;
284
+ justify-content: center;
285
+ padding: 12px;
286
+ background-color: color-mix(in srgb, var(--md-sys-color-scrim, black) 32%, transparent);
287
+ }
288
+ div[sheet] > figure {
289
+ max-width: 100%;
290
+ max-height: 100%;
291
+ margin: 0;
292
+ padding: 12px;
293
+ overflow: auto;
294
+ color: var(--md-sys-color-on-surface);
295
+ background-color: var(--md-sys-color-surface-container-lowest);
296
+ border-radius: 6px;
297
+ box-shadow: 0 8px 32px color-mix(in srgb, var(--md-sys-color-shadow, black) 28%, transparent);
298
+ }
299
+ div[sheet] svg {
300
+ display: block;
301
+ /* Not shrunk below its natural size — a narrow window scrolls instead. */
302
+ min-width: 680px;
303
+ font-family: inherit;
304
+ font-size: 11px;
305
+ fill: currentColor;
306
+ }
307
+ div[sheet] svg [grid] {
308
+ stroke: currentColor;
309
+ stroke-opacity: 0.12;
310
+ }
311
+ div[sheet] svg [muted] {
312
+ opacity: 0.55;
313
+ }
314
+ div[sheet] svg path[series] {
315
+ fill: none;
316
+ stroke-width: 2;
317
+ stroke-linejoin: round;
318
+ }
319
+ div[sheet] svg [series='triangles'] {
320
+ stroke: var(--md-sys-color-error);
321
+ fill: var(--md-sys-color-error);
322
+ }
323
+ div[sheet] svg [series='drawCalls'] {
324
+ stroke: var(--md-sys-color-tertiary);
325
+ fill: var(--md-sys-color-tertiary);
326
+ }
327
+ div[sheet] svg [series='geometries'] {
328
+ stroke: var(--md-sys-color-primary);
329
+ fill: var(--md-sys-color-primary);
330
+ }
331
+ div[sheet] svg [series='materials'] {
332
+ stroke: var(--md-sys-color-secondary);
333
+ fill: var(--md-sys-color-secondary);
334
+ }
335
+ div[sheet] svg path[series],
336
+ div[sheet] svg line[series] {
337
+ fill: none;
338
+ }
339
+ /* A legend value is written in its line's colour, not outlined in it. */
340
+ div[sheet] svg text[series] {
341
+ stroke: none;
342
+ }
343
+ div[sheet] figcaption {
344
+ display: flex;
345
+ align-items: center;
346
+ justify-content: flex-end;
347
+ }
348
+ div[sheet] button[close] {
349
+ padding: 5px 14px;
350
+ font: inherit;
351
+ color: var(--md-sys-color-on-surface);
352
+ background: none;
353
+ border: 1px solid var(--md-sys-color-outline-variant);
354
+ border-radius: 999em;
355
+ cursor: pointer;
356
+ }
357
+
358
+ /* ── Release verdict ───────────────────────────────────────────── */
80
359
  p[clear] {
81
360
  color: var(--md-sys-color-primary);
82
361
  font-weight: 600;
@@ -105,9 +384,12 @@ export class FigureV3Verdict extends localize(i18next)(LitElement) {
105
384
  /** Changing the type changes the verdict (a temporary type is refused), so it is a reason to ask again. */
106
385
  @property({ type: String }) figureType?: string
107
386
 
108
- @state() private capacity?: FigureCapacity
387
+ /** The report over the source being edited. Absent when nothing is drawn, or the format refuses the source. */
388
+ @state() private report?: V3Report
109
389
  /** Bodies in the asset, drawn or hidden. With none there is nothing to fit the space to, so the button is not offered. */
110
390
  @state() private bodies = 0
391
+ /** Whether the trend chart is open at its own size. */
392
+ @state() private expanded = false
111
393
  @state() private inspection?: FigureInspection
112
394
  @state() private failure = ''
113
395
  @state() private asking = false
@@ -127,19 +409,21 @@ export class FigureV3Verdict extends localize(i18next)(LitElement) {
127
409
  }
128
410
 
129
411
  private measure() {
130
- let capacity: FigureCapacity | undefined
412
+ let report: V3Report | undefined
131
413
  let bodies = 0
132
414
  try {
133
- const cost = this.source ? v3CostOf(parseV3Asset(this.source)) : undefined
134
- capacity = cost ? capacityFrom(cost.groups, cost.triangles) : undefined
415
+ const asset = this.source ? parseV3Asset(this.source) : undefined
416
+ const cost = asset ? v3CostOf(asset) : undefined
135
417
  bodies = cost ? cost.groups + cost.hidden : 0
418
+ report = asset ? v3ReportOf(asset) : undefined
136
419
  } catch {
137
- /* A source the format refuses has no count; the server's finding below says why. */
138
- capacity = undefined
420
+ /* A source the format refuses has no report; the server's finding below says why. */
421
+ report = undefined
139
422
  }
140
- this.capacity = capacity
423
+ this.report = report
141
424
  this.bodies = bodies
142
- this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: capacity?.instances } }))
425
+ if (!report) this.expanded = false
426
+ this.dispatchEvent(new CustomEvent('capacity-changed', { detail: { instances: report?.capacity.instances, budget: budgetOf(report) } }))
143
427
  }
144
428
 
145
429
  private schedule() {
@@ -171,38 +455,292 @@ export class FigureV3Verdict extends localize(i18next)(LitElement) {
171
455
  }
172
456
 
173
457
  render() {
174
- return html`${this.renderCount()} ${this.renderGate()}`
458
+ /*
459
+ With nothing drawn the report is guidance, not a verdict: the sections below have meaning only over parts,
460
+ and a number drawn from nothing reads as zero, which reads as bad. The release verdict stays — a new figure
461
+ is already refused for its undeclared space, and the way out is offered there.
462
+ */
463
+ const report = this.report
464
+ if (!report) {
465
+ return html`
466
+ <section>
467
+ <h3>${i18next.t('figure.label.maturity')}</h3>
468
+ <p quiet>${i18next.t('figure.text.report-appears-when-you-add-a-part')}</p>
469
+ </section>
470
+ ${this.renderGate()}
471
+ `
472
+ }
473
+ return html`
474
+ ${this.renderVerdict(report)} ${this.renderAxes(report)} ${this.renderBudgets(report)} ${this.renderMetrics(report)}
475
+ ${this.renderCurve()} ${this.renderGate()} ${this.renderSheet(report)}
476
+ `
175
477
  }
176
478
 
177
- private renderCount() {
178
- const capacity = this.capacity
479
+ /**
480
+ * The head — how many fit, with the grade beside it, the budget that decided it, the scale, and the basis.
481
+ *
482
+ * The count's denominator is measured (2026-09-18, Apple M5) and says so with the machine and the date; the
483
+ * triangle side is still the design document's target and says that.
484
+ */
485
+ private renderVerdict(report: V3Report) {
486
+ const { capacity, score } = report
179
487
  return html`
180
488
  <section>
181
489
  <h3>${i18next.t('figure.label.maturity')}</h3>
182
- <p lead>${i18next.t('figure.text.how-many-fit')}</p>
183
- ${capacity
184
- ? html`
185
- <div count>
186
- <span capacity>${capacity.instances.toLocaleString()}</span>
187
- <span unit>${i18next.t('figure.text.instances-unit')}</span>
188
- </div>
189
- <p basis>
190
- ${i18next.t('figure.text.bound-by-' + capacity.bound, {
191
- per: capacity.per.toLocaleString(),
192
- budget: capacity.budget.toLocaleString()
193
- })}
194
- <em>
195
- ${capacity.bound === 'groups'
196
- ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
197
- : i18next.t('figure.text.budget-not-measured-yet')}
198
- </em>
199
- </p>
490
+ <p lead>${i18next.t('figure.label.axis-scale')} — ${i18next.t('figure.text.how-many-fit')}</p>
491
+ <div verdict>
492
+ <span capacity>${capacity.instances.toLocaleString()}</span>
493
+ <span unit>${i18next.t('figure.text.instances-unit')}</span>
494
+ <span
495
+ grade
496
+ ?tight=${score.grade === 'C'}
497
+ ?over=${score.grade === 'D'}
498
+ title=${i18next.t('figure.text.grade-explained', { target: LIMITS.screenInstances })}
499
+ >
500
+ ${score.grade}
501
+ <small>${i18next.t('figure.text.headroom-percent', { percent: score.score })}</small>
502
+ </span>
503
+ </div>
504
+ <p because>
505
+ ${i18next.t('figure.text.grade-decided-by')}
506
+ <strong>${i18next.t('figure.label.budget-' + score.tightest.key)}</strong>
507
+ — ${score.tightest.used.toLocaleString()} / ${limitOf(score.tightest)} (${Math.round(score.tightest.ratio * 100)}%)
508
+ </p>
509
+ <p basis>
510
+ ${i18next.t('figure.text.grade-target', {
511
+ target: LIMITS.screenInstances,
512
+ groups: Math.floor(V3_PER_INSTANCE.groups),
513
+ triangles: Math.floor(V3_PER_INSTANCE.triangles).toLocaleString()
514
+ })}
515
+ </p>
516
+ ${this.renderGradeScale(score.grade)}
517
+ <p basis>
518
+ ${i18next.t('figure.text.bound-by-' + capacity.bound, {
519
+ per: capacity.per.toLocaleString(),
520
+ budget: capacity.budget.toLocaleString()
521
+ })}
522
+ <em>
523
+ ${capacity.bound === 'groups'
524
+ ? i18next.t('figure.text.budget-measured-on', { gpu: MEASURED.gpu, on: MEASURED.on })
525
+ : i18next.t('figure.text.budget-not-measured-yet')}
526
+ </em>
527
+ </p>
528
+ </section>
529
+ `
530
+ }
531
+
532
+ /** A · B · C · D and where this figure stands, drawn from the bounds the grade is computed with. */
533
+ private renderGradeScale(now: string) {
534
+ const bounds = [...GRADE_SCALE.map(([grade, bound]) => [grade, `≤ ${bound * 100}%`]), ['D', '> 100%']]
535
+ return html`
536
+ <p scale>
537
+ <span>${i18next.t('figure.text.grade-scale-lead')}</span>
538
+ ${bounds.map(([grade, bound]) => html`<span ?now=${grade === now}><b>${grade}</b> ${bound}</span>`)}
539
+ </p>
540
+ `
541
+ }
542
+
543
+ /**
544
+ * Maturity by axis — a radar of the measured axes, a line naming the unmeasured ones, and the list.
545
+ *
546
+ * Scale is left out of the list: it leads the tab, and the same number twice reads as two different things.
547
+ */
548
+ private renderAxes(report: V3Report) {
549
+ const measured = report.axes.filter(axis => axis.ratio !== undefined)
550
+ const unmeasured = report.axes.filter(axis => axis.ratio === undefined)
551
+ return html`
552
+ <section>
553
+ <h3>${i18next.t('figure.label.by-axis')}</h3>
554
+ <div radar>${this.renderRadar(measured)}</div>
555
+ ${unmeasured.length
556
+ ? html`<p unmeasured>
557
+ ${i18next.t('figure.text.axes-not-measurable-yet', {
558
+ axes: unmeasured.map(axis => i18next.t('figure.label.axis-' + axis.key)).join(' · ')
559
+ })}
560
+ </p>`
561
+ : nothing}
562
+ <ul axes>
563
+ ${report.axes.filter(axis => axis.key !== 'scale').map(axis => this.renderAxis(axis))}
564
+ </ul>
565
+ </section>
566
+ `
567
+ }
568
+
569
+ /** V2's radar: rings at a third, two thirds and full, short names so the left edge is not cut. */
570
+ private renderRadar(axes: V3MaturityAxis[]) {
571
+ const cx = 60
572
+ const cy = 46
573
+ const r = 28
574
+ const at = (index: number, ratio: number) => {
575
+ const angle = -Math.PI / 2 + (index * 2 * Math.PI) / axes.length
576
+ return [cx + r * ratio * Math.cos(angle), cy + r * ratio * Math.sin(angle)]
577
+ }
578
+ const clamp = (ratio?: number) => Math.max(0.04, Math.min(1, ratio ?? 0))
579
+ const points = axes.map((axis, index) => at(index, clamp(axis.ratio)).map(n => n.toFixed(1)).join(',')).join(' ')
580
+
581
+ return svg`
582
+ <svg viewBox="0 0 120 96" aria-hidden="true">
583
+ ${[0.33, 0.66, 1].map(
584
+ ring => svg`<polygon web points=${axes.map((_, i) => at(i, ring).map(n => n.toFixed(1)).join(',')).join(' ')}></polygon>`
585
+ )}
586
+ ${axes.map((_, index) => {
587
+ const [x, y] = at(index, 1)
588
+ return svg`<line spoke x1=${cx} y1=${cy} x2=${x.toFixed(1)} y2=${y.toFixed(1)}></line>`
589
+ })}
590
+ <polygon shape points=${points}></polygon>
591
+ ${axes.map((axis, index) => {
592
+ const [x, y] = at(index, clamp(axis.ratio))
593
+ return svg`<circle dot cx=${x.toFixed(1)} cy=${y.toFixed(1)} r="1.4"></circle>`
594
+ })}
595
+ ${axes.map((axis, index) => {
596
+ const [x, y] = at(index, 1.28)
597
+ const anchor = x < cx - 3 ? 'end' : x > cx + 3 ? 'start' : 'middle'
598
+ return svg`<text x=${x.toFixed(1)} y=${(y + 1.8).toFixed(1)} text-anchor=${anchor}>${i18next.t('figure.label.axis-' + axis.key + '-short')}</text>`
599
+ })}
600
+ </svg>
601
+ `
602
+ }
603
+
604
+ private renderAxis(axis: V3MaturityAxis) {
605
+ const name = i18next.t('figure.label.axis-' + axis.key)
606
+ if (axis.blocked) {
607
+ return html`
608
+ <li blocked>
609
+ <span>${name}</span>
610
+ <span reading>${i18next.t('figure.text.not-measurable-yet')}</span>
611
+ <em>${i18next.t(axis.blocked)}</em>
612
+ </li>
613
+ `
614
+ }
615
+ const ratio = Math.max(0, Math.min(1, axis.ratio ?? 0))
616
+ return html`
617
+ <li>
618
+ <span>${name}</span>
619
+ <span reading>${axis.readingKey ? i18next.t(axis.readingKey) : (axis.reading ?? '')}</span>
620
+ <div bar><i style="width:${Math.round(ratio * 100)}%"></i></div>
621
+ ${axis.because ? html`<em>${i18next.t(axis.because)}</em>` : nothing}
622
+ </li>
623
+ `
624
+ }
625
+
626
+ /** The budget bars. The one closest to its limit is coloured — it decided the grade. */
627
+ private renderBudgets(report: V3Report) {
628
+ const { score } = report
629
+ return html`
630
+ <section>
631
+ <h3>${i18next.t('figure.label.budgets')}</h3>
632
+ <ul budgets>
633
+ ${score.budgets.map(
634
+ use => html`
635
+ <li>
636
+ <span>${i18next.t('figure.label.budget-' + use.key)}</span>
637
+ <span>${use.used.toLocaleString()} / ${limitOf(use)}</span>
638
+ <div bar><i ?tight=${use.key === score.tightest.key} style="width:${Math.round(Math.min(1, use.ratio) * 100)}%"></i></div>
639
+ </li>
200
640
  `
201
- : html`<p quiet>${i18next.t('figure.text.maturity-blocked-no-parts')}</p>`}
641
+ )}
642
+ </ul>
643
+ <p quiet>${i18next.t('figure.text.budgets-not-in-v3')}</p>
202
644
  </section>
203
645
  `
204
646
  }
205
647
 
648
+ private renderMetrics(report: V3Report) {
649
+ return html`
650
+ <section>
651
+ <h3>${i18next.t('figure.label.weight')}</h3>
652
+ <dl metrics>
653
+ <dt>${i18next.t('figure.label.triangles')}</dt>
654
+ <dd>${report.cost.triangles.toLocaleString()}</dd>
655
+ <dt>${i18next.t('figure.label.distinct-shapes')}</dt>
656
+ <dd>${report.distinctShapes}</dd>
657
+ <dt>${i18next.t('figure.label.distinct-materials')}</dt>
658
+ <dd>${report.distinctMaterials}</dd>
659
+ </dl>
660
+ </section>
661
+ `
662
+ }
663
+
664
+ private renderCurve() {
665
+ return html`
666
+ <section>
667
+ <h3>${i18next.t('figure.label.reuse-trend')}</h3>
668
+ <button expand @click=${() => (this.expanded = true)}>${i18next.t('figure.button.see-the-trend-chart')}</button>
669
+ </section>
670
+ `
671
+ }
672
+
673
+ /**
674
+ * The trend at its own size — V2's chart: both axes logarithmic, the growing lines against the flat ones, and a
675
+ * legend with each line's value at the last count.
676
+ */
677
+ private renderSheet(report: V3Report) {
678
+ if (!this.expanded) return nothing
679
+ const layout = curveLayout(report.curve)
680
+ if (!layout) return nothing
681
+ const { width, height, pad } = CURVE_SIZE
682
+ const plotW = width - pad.left - pad.right
683
+ const plotH = height - pad.top - pad.bottom
684
+ const last = report.curve[report.curve.length - 1]!
685
+ const label = (key: string) =>
686
+ i18next.t(
687
+ {
688
+ triangles: 'figure.label.triangles',
689
+ drawCalls: 'figure.label.draw-calls',
690
+ geometries: 'figure.label.vertex-buffers',
691
+ materials: 'figure.label.distinct-materials'
692
+ }[key]!
693
+ )
694
+
695
+ return html`
696
+ <div sheet @click=${() => (this.expanded = false)}>
697
+ <figure @click=${(e: Event) => e.stopPropagation()}>
698
+ ${svg`
699
+ <svg viewBox="0 0 ${width} ${height}" width=${width} height=${height} role="img">
700
+ <text x=${pad.left} y="22" font-size="13" font-weight="600">${i18next.t('figure.text.trend-chart-title')}</text>
701
+ <text x=${pad.left} y="38" muted>${i18next.t('figure.text.trend-chart-lead')}</text>
702
+ ${layout.yTicks.map(
703
+ tick => svg`
704
+ <line grid x1=${pad.left} y1=${tick.y.toFixed(1)} x2=${(pad.left + plotW).toFixed(1)} y2=${tick.y.toFixed(1)}></line>
705
+ <text muted x=${pad.left - 8} y=${(tick.y + 3.5).toFixed(1)} text-anchor="end">${tick.label}</text>
706
+ `
707
+ )}
708
+ ${layout.xTicks.map(
709
+ tick => svg`
710
+ <line grid x1=${tick.x.toFixed(1)} y1=${pad.top} x2=${tick.x.toFixed(1)} y2=${(pad.top + plotH).toFixed(1)}></line>
711
+ <text muted x=${tick.x.toFixed(1)} y=${(pad.top + plotH + 16).toFixed(1)} text-anchor="middle">${tick.label}</text>
712
+ `
713
+ )}
714
+ <text muted x=${(pad.left + plotW / 2).toFixed(1)} y=${height - 8} text-anchor="middle">${i18next.t('figure.label.instance-count')}</text>
715
+ ${layout.lines.map(
716
+ line => svg`<path series=${line.key} d=${line.d} stroke-dasharray=${line.dashed ? '3 7' : 'none'}></path>`
717
+ )}
718
+ <g transform="translate(${width - pad.right + 14}, ${pad.top - 6})">
719
+ <text x="0" y="0" font-weight="600">${i18next.t('figure.text.trend-at-count', { n: shortNumber(last.instances) })}</text>
720
+ ${layout.lines.map((line, i) => {
721
+ const y = 24 + i * 34
722
+ return svg`
723
+ <line series=${line.key} x1="0" y1=${y - 4} x2="18" y2=${y - 4} stroke-width="2" stroke-dasharray=${line.dashed ? '3 7' : 'none'}></line>
724
+ <text x="24" y=${y}>${label(line.key)}</text>
725
+ <text series=${line.key} x="24" y=${y + 12} font-weight="600">
726
+ ${line.last.toLocaleString()}${line.flat ? ` · ${i18next.t('figure.text.trend-stays')}` : ''}
727
+ </text>
728
+ `
729
+ })}
730
+ ${layout.overlap !== undefined
731
+ ? svg`<text muted x="0" y=${24 + layout.lines.length * 34 + 6}>${i18next.t('figure.text.trend-flat-lines-overlap', { value: layout.overlap.toLocaleString() })}</text>`
732
+ : nothing}
733
+ </g>
734
+ </svg>
735
+ `}
736
+ <figcaption>
737
+ <button close @click=${() => (this.expanded = false)}>${i18next.t('figure.button.close')}</button>
738
+ </figcaption>
739
+ </figure>
740
+ </div>
741
+ `
742
+ }
743
+
206
744
  private renderGate() {
207
745
  const findings = this.inspection?.findings ?? []
208
746
  const blocking = findings.filter(finding => finding.blocking)