@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
@@ -0,0 +1,415 @@
1
+ import {
2
+ compileV3Asset,
3
+ compileV3Document,
4
+ LIMITS,
5
+ SEGMENT_PRESETS,
6
+ V3_BUDGET_KEYS,
7
+ V3_GRADE_SCALE,
8
+ v3CostOf,
9
+ v3GradeOf,
10
+ v3MaterialKeyOf,
11
+ v3ScoreOf,
12
+ v3WorldBoxesOf,
13
+ v3WritersOf
14
+ } from '@hatiolab/figure-model'
15
+ import type { V3Asset, V3AssetGeometry, V3BudgetKey, V3BudgetUse, V3Cost, V3Geometry, V3Score } from '@hatiolab/figure-model'
16
+
17
+ import { capacityFrom } from './scene-budget.js'
18
+ import type { FigureCapacity } from './scene-budget.js'
19
+
20
+ /*
21
+ * The V2 modeller's maturity report, read off a V3 asset (user, 2026-09-24: the V3 tab showed a count and nothing
22
+ * else — 「왜 이렇게 너프되었는지」).
23
+ *
24
+ * V2's report was `figure-report.ts` over `costOf` · `scoreOf` · `costCurveSvg` (figure-model) and `maturity.ts`
25
+ * (figure-ui). Those read a V2 blueprint. This module asks the same questions of the evaluated V3 asset and keeps
26
+ * V2's answers where the question still has one:
27
+ *
28
+ * grade A–D and its scale V2's bounds, over figure-model's `v3ScoreOf`: one screen's target shared per instance
29
+ * (groups, triangles) and V2's declared rule limits (independent, transparent)
30
+ * radar · axes V2's six axes; the two V2 could not measure stay unmeasured, detail has no V3 field
31
+ * drawing cost triangles, distinct shapes, distinct materials
32
+ * trend what grows with the instance count and what the instances share
33
+ *
34
+ * Kept apart from the element so it can be tested without a browser.
35
+ *
36
+ * ## What the V3 renderer shares, which is what the numbers count
37
+ *
38
+ * things-scene draws one mesh per drawn body (`figure-v3-rig.ts`) — no merging by material as V2 did — so a body is
39
+ * a draw call. It shares geometry by provider, dimensions, segments and profile (`figure-v3-geometry.ts`, `keyOf`)
40
+ * and materials by token, preset, transparency and light (`materialFor`). The keys below are those keys; a
41
+ * distinct-shape count that used another key would describe a renderer that does not exist.
42
+ *
43
+ * ## Budgets V3 does not have
44
+ *
45
+ * V2 had five. Two are not measured here, and are not shown as zero:
46
+ * - off-palette surfaces: the V3 material has no surface field, so there is nothing to count;
47
+ * - part count by detail level: V3 does not carry the detail level (chief architect, 2026-09-24, ADR-0087).
48
+ */
49
+
50
+ /*
51
+ The grade, its budgets and its bounds are figure-model's (`v3ScoreOf`), so the catalogue grades a figure as this tab
52
+ does. The grade is the headroom against one screen's target (`LIMITS.screenInstances`), not a draw-call limit.
53
+ */
54
+ export { V3_BUDGET_KEYS, V3_GRADE_SCALE as GRADE_SCALE, v3GradeOf as gradeOf }
55
+ export type { V3BudgetKey, V3BudgetUse, V3Score }
56
+
57
+ export type V3AxisKey = 'scale' | 'consistency' | 'reuse' | 'detail' | 'reactive' | 'readable'
58
+
59
+ /** One maturity axis. The same shape V2's `maturity.ts` gave the report. */
60
+ export interface V3MaturityAxis {
61
+ key: V3AxisKey
62
+ /** 0–1. Absent when the axis cannot be measured. */
63
+ ratio?: number
64
+ reading?: string
65
+ /** A reading that is words, not a number. */
66
+ readingKey?: string
67
+ because?: string
68
+ /** Why the axis is not measured. When present there is no ratio — an unmeasured axis is not a zero. */
69
+ blocked?: string
70
+ }
71
+
72
+ /** Instances on one screen, and what each series costs at that count. */
73
+ export interface V3CurvePoint {
74
+ instances: number
75
+ triangles: number
76
+ drawCalls: number
77
+ /** Vertex buffers: one per distinct shape, shared by every instance of the same size. */
78
+ geometries: number
79
+ materials: number
80
+ }
81
+
82
+ export interface V3Report {
83
+ cost: V3Cost
84
+ capacity: FigureCapacity
85
+ score: V3Score
86
+ /** Distinct shapes and materials among the drawn bodies — V2's 「그리는 비용」 rows. */
87
+ distinctShapes: number
88
+ distinctMaterials: number
89
+ reuse: { distinct: number; parts: number; saved: number }
90
+ axes: V3MaturityAxis[]
91
+ curve: V3CurvePoint[]
92
+ }
93
+
94
+ /** V2's board target for the scale axis: 500 assets on one drawing (design document §4.15). */
95
+ export const BOARD_ASSETS = 500
96
+
97
+ /**
98
+ * The report for one asset, or nothing when no body is drawn — then there is nothing to measure, and the screen
99
+ * says so rather than drawing zeros.
100
+ *
101
+ * Evaluated at the asset's own design and state defaults, as `v3CostOf` is.
102
+ */
103
+ export function v3ReportOf(asset: V3Asset): V3Report | undefined {
104
+ const cost = v3CostOf(asset)
105
+ const capacity = capacityFrom(cost.groups, cost.triangles)
106
+ if (!capacity) return undefined
107
+
108
+ const drawn = compileV3Asset(asset)
109
+ .evaluate()
110
+ .geometry.filter(body => body.visible !== false)
111
+ const plan = compileV3Document(asset.document)
112
+ const writers = v3WritersOf(asset)
113
+
114
+ const shapes = new Set(drawn.map(shapeKeyOf))
115
+ const materials = new Set(drawn.map(materialKeyOf))
116
+
117
+ const score = v3ScoreOf(asset)!
118
+
119
+ const reuse = { distinct: shapes.size, parts: drawn.length, saved: drawn.length - shapes.size }
120
+
121
+ const axes: V3MaturityAxis[] = [
122
+ {
123
+ key: 'scale',
124
+ // The board target filled is 1; room beyond it is better spent on the other axes, so it does not count more.
125
+ ratio: Math.min(1, capacity.instances / BOARD_ASSETS),
126
+ reading: String(capacity.instances),
127
+ because: 'figure.text.maturity-scale-because'
128
+ },
129
+ consistency(asset, materials.size),
130
+ {
131
+ key: 'reuse',
132
+ // A low distinct-to-parts ratio is much reuse; turned over so that more is higher.
133
+ ratio: reuse.parts === 0 ? 0 : 1 - reuse.distinct / reuse.parts,
134
+ reading: String(reuse.saved),
135
+ because: 'figure.text.maturity-reuse-because'
136
+ },
137
+ { key: 'detail', blocked: 'figure.text.maturity-blocked-no-detail-level' },
138
+ { key: 'reactive', blocked: 'figure.text.maturity-blocked-no-binding' },
139
+ readable(asset, plan, drawn, writers)
140
+ ]
141
+
142
+ return {
143
+ cost,
144
+ capacity,
145
+ score,
146
+ distinctShapes: shapes.size,
147
+ distinctMaterials: materials.size,
148
+ reuse,
149
+ axes,
150
+ curve: decadeSteps(1, 10000).map(instances => ({
151
+ instances,
152
+ triangles: cost.triangles * instances,
153
+ drawCalls: cost.groups * instances,
154
+ geometries: shapes.size,
155
+ materials: materials.size
156
+ }))
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The key things-scene shares a body's geometry by (`figure-v3-geometry.ts` `keyOf`): provider, dimensions to a
162
+ * micrometre, segments (12 when unset), and a polygon's corners.
163
+ */
164
+ export function shapeKeyOf(body: V3AssetGeometry | V3Geometry): string {
165
+ if (!('shape' in body)) {
166
+ // A scalar body has no mesh of its own; it is still one drawn body, told apart by what it declares.
167
+ const dims = Object.keys(body.dimensions).sort().map(k => `${k}=${body.dimensions[k]}`).join(',')
168
+ return `${body.provider}|${dims}`
169
+ }
170
+ const shape = body.shape
171
+ const round = (v: number) => Math.round(v * 1e6) / 1e6
172
+ const dims = Object.keys(shape.dimensions)
173
+ .sort()
174
+ .map(k => `${k}=${round(shape.dimensions[k]!)}`)
175
+ .join(',')
176
+ const profile = shape.profile ? '|' + shape.profile.map(p => `${round(p.x)}:${round(p.y)}`).join(';') : ''
177
+ const segments = (body as { segments?: number }).segments ?? 12
178
+ return `${shape.provider}|${dims}|s${segments}${profile}`
179
+ }
180
+
181
+ /**
182
+ * The key things-scene shares a material by (`figure-v3-rig.ts` `materialFor`), within one asset: token, preset,
183
+ * transparency, and the light when it is on. The colour follows from the token, so it is not a second key.
184
+ */
185
+ export function materialKeyOf(body: V3AssetGeometry): string {
186
+ return v3MaterialKeyOf(body)
187
+ }
188
+
189
+ /**
190
+ * Does it look like one set — V2's baselines 3 and 4. V2 checks three things; V3 keeps the same three.
191
+ *
192
+ * Two of them hold by construction in V3: the material has no surface field, so there is no texture, and the
193
+ * format refuses a segment count outside the presets (`compileV3Asset`, `QUALITY`). They are still counted, as
194
+ * V2 counts them, so the ratio means what it meant: one fault in three.
195
+ */
196
+ function consistency(asset: V3Asset, distinctMaterials: number): V3MaturityAxis {
197
+ const textures = 0
198
+ const offPreset = asset.appearance.filter(look => look.segments !== undefined && !SEGMENT_PRESETS.includes(look.segments)).length
199
+ const faults = (textures > 0 ? 1 : 0) + (offPreset > 0 ? 1 : 0) + (distinctMaterials > LIMITS.distinctMaterials ? 1 : 0)
200
+ return {
201
+ key: 'consistency',
202
+ ratio: (3 - faults) / 3,
203
+ readingKey: faults === 0 ? 'figure.text.maturity-consistency-clean' : 'figure.text.maturity-consistency-faults',
204
+ because: 'figure.text.maturity-consistency-because'
205
+ }
206
+ }
207
+
208
+ /** Seen from above, these do not fill their footprint box. V2's list, in V3's provider names. */
209
+ const ROUND_FOOTPRINT: readonly string[] = ['cylinder', 'ellipticCylinder', 'frustum', 'sphere']
210
+
211
+ /**
212
+ * Can it be told apart from above — V2's baseline 1, the same measure.
213
+ *
214
+ * Viewpoint entropy (Vázquez et al.) over the top view, counted by colour region rather than by face so that a
215
+ * finer segment count does not raise it: H = −Σ p·log p over each material's share of the footprint plus the
216
+ * background, divided by log(`LIMITS.distinctMaterials` + 1), the most this measure can reach.
217
+ *
218
+ * The footprint is V2's base box: the space the asset declares when it declares one (ADR-0044's box is V3's
219
+ * occupancy), otherwise the drawn bodies' own extent. Bodies are their axis-aligned world boxes
220
+ * (`v3WorldBoxesOf`, the gate's reading); the higher one covers the lower.
221
+ */
222
+ function readable(
223
+ asset: V3Asset,
224
+ plan: ReturnType<typeof compileV3Document>,
225
+ drawn: V3AssetGeometry[],
226
+ writers: Map<string, string>
227
+ ): V3MaturityAxis {
228
+ const placed = drawn
229
+ .filter(body => 'pose' in body)
230
+ // One placed, drawn body gives exactly one box; boxes are taken one by one to keep each with its material.
231
+ .map(body => ({ body, box: v3WorldBoxesOf([body], writers)[0]! }))
232
+ if (placed.length === 0) return { key: 'readable', blocked: 'figure.text.maturity-blocked-no-parts' }
233
+
234
+ let frame: { x0: number; x1: number; z0: number; z1: number }
235
+ if (asset.occupancy) {
236
+ const values = plan.evaluate({ ...asset.designInputs, ...asset.stateDefaults }).values
237
+ const at = (ref: string) => values[ref] as number
238
+ const b = asset.occupancy.bounds
239
+ frame = { x0: at(b.x.min), x1: at(b.x.max), z0: at(b.z.min), z1: at(b.z.max) }
240
+ } else {
241
+ frame = { x0: Infinity, x1: -Infinity, z0: Infinity, z1: -Infinity }
242
+ for (const { box } of placed) {
243
+ frame.x0 = Math.min(frame.x0, box.centre.x - box.extent.x / 2)
244
+ frame.x1 = Math.max(frame.x1, box.centre.x + box.extent.x / 2)
245
+ frame.z0 = Math.min(frame.z0, box.centre.z - box.extent.z / 2)
246
+ frame.z1 = Math.max(frame.z1, box.centre.z + box.extent.z / 2)
247
+ }
248
+ }
249
+
250
+ const regions = topViewRegions(placed, frame)
251
+ if (regions.total === 0) return { key: 'readable', blocked: 'figure.text.maturity-blocked-no-parts' }
252
+
253
+ const shares = [...regions.byMaterial.values(), regions.background].map(area => area / regions.footprint).filter(share => share > 0)
254
+ const entropy = -shares.reduce((sum, p) => sum + p * Math.log(p), 0)
255
+ const ceiling = Math.log(LIMITS.distinctMaterials + 1)
256
+ return {
257
+ key: 'readable',
258
+ // Clamped both ways: one region alone gives −0, which is not a reading anyone should see.
259
+ ratio: Math.max(0, Math.min(1, entropy / ceiling)),
260
+ reading: String(regions.byMaterial.size),
261
+ because: 'figure.text.maturity-readable-because'
262
+ }
263
+ }
264
+
265
+ /** Which material shows from above, on a 64 × 64 grid over the footprint. V2's `topViewRegions`, over world boxes. */
266
+ function topViewRegions(
267
+ placed: { body: V3AssetGeometry; box: ReturnType<typeof v3WorldBoxesOf>[number] }[],
268
+ frame: { x0: number; x1: number; z0: number; z1: number }
269
+ ) {
270
+ const GRID = 64
271
+ const cells = new Array<string | null>(GRID * GRID).fill(null)
272
+ const height = new Float64Array(GRID * GRID)
273
+ const sx = GRID / Math.max(frame.x1 - frame.x0, 1e-6)
274
+ const sz = GRID / Math.max(frame.z1 - frame.z0, 1e-6)
275
+
276
+ for (const { body, box } of placed) {
277
+ const top = box.centre.y + box.extent.y / 2
278
+ const key = materialKeyOf(body)
279
+ const x0 = Math.max(0, Math.floor((box.centre.x - box.extent.x / 2 - frame.x0) * sx))
280
+ const x1 = Math.min(GRID, Math.ceil((box.centre.x + box.extent.x / 2 - frame.x0) * sx))
281
+ const y0 = Math.max(0, Math.floor((box.centre.z - box.extent.z / 2 - frame.z0) * sz))
282
+ const y1 = Math.min(GRID, Math.ceil((box.centre.z + box.extent.z / 2 - frame.z0) * sz))
283
+ const round = 'shape' in body && ROUND_FOOTPRINT.includes(body.shape.provider)
284
+
285
+ for (let y = y0; y < y1; y++) {
286
+ for (let x = x0; x < x1; x++) {
287
+ // A round body does not fill its box; the corners are cut, as V2 cuts them.
288
+ if (round) {
289
+ const cx = (x + 0.5 - (x0 + x1) / 2) / ((x1 - x0) / 2 || 1)
290
+ const cy = (y + 0.5 - (y0 + y1) / 2) / ((y1 - y0) / 2 || 1)
291
+ if (cx * cx + cy * cy > 1) continue
292
+ }
293
+ const i = y * GRID + x
294
+ if (cells[i] === null || top > height[i]!) {
295
+ cells[i] = key
296
+ height[i] = top
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ const byMaterial = new Map<string, number>()
303
+ let covered = 0
304
+ for (const key of cells) {
305
+ if (key === null) continue
306
+ covered++
307
+ byMaterial.set(key, (byMaterial.get(key) ?? 0) + 1)
308
+ }
309
+ return { byMaterial, total: covered, background: GRID * GRID - covered, footprint: GRID * GRID }
310
+ }
311
+
312
+ /** 1 · 2 · 5 per decade, as V2's trend (`cost-chart.ts`): even steps on a log axis. */
313
+ export function decadeSteps(from: number, to: number): number[] {
314
+ const out: number[] = []
315
+ for (let decade = 1; decade <= to; decade *= 10) {
316
+ for (const m of [1, 2, 5]) {
317
+ const v = decade * m
318
+ if (v >= from && v <= to) out.push(v)
319
+ }
320
+ }
321
+ if (out.length === 0 || out[out.length - 1] !== to) out.push(to)
322
+ return [...new Set(out)].sort((a, b) => a - b)
323
+ }
324
+
325
+ // ─── The trend chart ─────────────────────────────────────────────────────────
326
+
327
+ export type V3CurveSeries = 'triangles' | 'drawCalls' | 'geometries' | 'materials'
328
+
329
+ /**
330
+ * The four lines V2 drew: two that grow with the instance count and two that stay flat — the flat ones are what the
331
+ * instances share. Materials are dashed and drawn last, so a line under them still shows where the two meet.
332
+ */
333
+ export const CURVE_SERIES: readonly { key: V3CurveSeries; flat: boolean; dashed?: boolean }[] = [
334
+ { key: 'triangles', flat: false },
335
+ { key: 'drawCalls', flat: false },
336
+ { key: 'geometries', flat: true },
337
+ { key: 'materials', flat: true, dashed: true }
338
+ ]
339
+
340
+ /** V2's chart size. The chart is shown at this size in a sheet; shrunk into the side, its words are unreadable. */
341
+ export const CURVE_SIZE = { width: 680, height: 380, pad: { top: 52, right: 208, bottom: 44, left: 62 } } as const
342
+
343
+ export interface V3CurveLayout {
344
+ xTicks: { x: number; label: string }[]
345
+ yTicks: { y: number; label: string }[]
346
+ lines: { key: V3CurveSeries; d: string; flat: boolean; dashed: boolean; last: number }[]
347
+ /** The value the flat lines share when they lie on one another, so the screen can say why one line is missing. */
348
+ overlap?: number
349
+ }
350
+
351
+ const log10 = (v: number) => Math.log(Math.max(v, 1)) / Math.LN10
352
+
353
+ /** 1, 10, 100 … written short. */
354
+ export function shortNumber(v: number): string {
355
+ if (v >= 1_000_000) return `${v / 1_000_000}M`
356
+ if (v >= 1_000) return `${v / 1_000}k`
357
+ return String(v)
358
+ }
359
+
360
+ /** Where V2's chart puts each line and tick. Both axes are logarithmic, as V2's are. */
361
+ export function curveLayout(points: V3CurvePoint[]): V3CurveLayout | undefined {
362
+ if (points.length === 0) return undefined
363
+ const { width, height, pad } = CURVE_SIZE
364
+ const minX = points[0]!.instances
365
+ const maxX = points[points.length - 1]!.instances
366
+ let maxY = 1
367
+ for (const p of points) for (const s of CURVE_SERIES) maxY = Math.max(maxY, p[s.key])
368
+
369
+ const plotW = width - pad.left - pad.right
370
+ const plotH = height - pad.top - pad.bottom
371
+ const spanX = Math.max(log10(maxX) - log10(minX), 1e-9)
372
+ const spanY = Math.max(log10(maxY), 1e-9)
373
+ const px = (v: number) => pad.left + ((log10(v) - log10(minX)) / spanX) * plotW
374
+ const py = (v: number) => pad.top + plotH - (log10(v) / spanY) * plotH
375
+
376
+ const yTicks: V3CurveLayout['yTicks'] = []
377
+ for (let e = 0; Math.pow(10, e) <= maxY * 1.001; e++) yTicks.push({ y: py(Math.pow(10, e)), label: shortNumber(Math.pow(10, e)) })
378
+ const xTicks: V3CurveLayout['xTicks'] = []
379
+ for (let e = 0; Math.pow(10, e) <= maxX * 1.001; e++) {
380
+ const v = Math.pow(10, e)
381
+ if (v >= minX) xTicks.push({ x: px(v), label: shortNumber(v) })
382
+ }
383
+
384
+ const last = points[points.length - 1]!
385
+ const lines = CURVE_SERIES.map(s => ({
386
+ key: s.key,
387
+ d: points.map((p, i) => `${i === 0 ? 'M' : 'L'}${px(p.instances).toFixed(1)},${py(p[s.key]).toFixed(1)}`).join(' '),
388
+ flat: s.flat,
389
+ dashed: !!s.dashed,
390
+ last: last[s.key]
391
+ }))
392
+ const flat = new Set(CURVE_SERIES.filter(s => s.flat).map(s => last[s.key]))
393
+ return { xTicks, yTicks, lines, overlap: flat.size === 1 ? [...flat][0] : undefined }
394
+ }
395
+
396
+ /** What the parts list shows under its title: the cost, how many fit, the grade and what decides it. */
397
+ export interface V3BudgetLine {
398
+ triangles: number
399
+ groups: number
400
+ instances: number
401
+ grade: string
402
+ tightest: { key: string; used: number; limit: number }
403
+ }
404
+
405
+ export function budgetOf(report: V3Report | undefined): V3BudgetLine | undefined {
406
+ if (!report) return undefined
407
+ const { tightest } = report.score
408
+ return {
409
+ triangles: report.cost.triangles,
410
+ groups: report.cost.groups,
411
+ instances: report.capacity.instances,
412
+ grade: report.score.grade,
413
+ tightest: { key: tightest.key, used: tightest.used, limit: Math.floor(tightest.limit) }
414
+ }
415
+ }
@@ -0,0 +1,45 @@
1
+ import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
2
+
3
+ import type { V3PaletteKind } from './figure-v3-parts.js'
4
+
5
+ /**
6
+ * What a palette button makes: the part at a starting size a person then changes. Standing on the floor (its lowest
7
+ * point at y 0) where the provider's frame is centred; a portal's frame already rises from its bottom.
8
+ */
9
+ export function paletteRecipe(kind: V3PaletteKind, id: string): V3PrimitiveCreation {
10
+ const standing = (height: number) => ({ id, position: { x: 0, y: height / 2, z: 0 } })
11
+ switch (kind) {
12
+ case 'box':
13
+ return { ...standing(100), kind, dimensions: { width: 100, height: 100, depth: 100 } }
14
+ case 'cylinder':
15
+ return { ...standing(100), kind, dimensions: { radius: 50, length: 100 } }
16
+ case 'rounded-box':
17
+ return { ...standing(100), kind, dimensions: { width: 100, height: 100, depth: 100, round: 10 } }
18
+ case 'sphere':
19
+ return { ...standing(100), kind, dimensions: { radiusX: 50, radiusY: 50, radiusZ: 50 } }
20
+ case 'frustum':
21
+ return { ...standing(100), kind, dimensions: { radiusTop: 0, radiusBottom: 50, height: 100 } }
22
+ case 'polygon':
23
+ return {
24
+ ...standing(100),
25
+ kind,
26
+ dimensions: { height: 100 },
27
+ points: [
28
+ { x: -50, z: -43 },
29
+ { x: 50, z: -43 },
30
+ { x: 0, z: 43 }
31
+ ]
32
+ }
33
+ case 'plate':
34
+ return { ...standing(10), kind: 'box', dimensions: { width: 600, height: 10, depth: 400 } }
35
+ case 'wall':
36
+ return { ...standing(2400), kind: 'box', dimensions: { width: 1000, height: 2400, depth: 100 } }
37
+ case 'portal':
38
+ return {
39
+ id,
40
+ position: { x: 0, y: 0, z: 0 },
41
+ kind: 'portal',
42
+ dimensions: { left: -100, right: 100, bottom: 0, top: 200, openingLeft: -70, openingRight: 70, openingTop: 170, depth: 30 }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,54 @@
1
+ import { compileV3Asset, v3OccupiedBoxesOf, v3WritersOf, V3_INSTANCE_SIZE_INPUTS } from '@hatiolab/figure-model'
2
+ import type { V3Asset } from '@hatiolab/figure-model'
3
+
4
+ /* The preview's arithmetic, apart from the element so it is tested without a browser (see figure-v3-preview.ts). */
5
+
6
+ export const PREVIEW_FACTOR = 2
7
+
8
+ export const PREVIEW_WAYS: readonly { name: string; axes: readonly ('x' | 'y' | 'z')[] }[] = [
9
+ { name: 'base', axes: [] },
10
+ { name: 'x', axes: ['x'] },
11
+ { name: 'y', axes: ['y'] },
12
+ { name: 'z', axes: ['z'] },
13
+ { name: 'x·y', axes: ['x', 'y'] },
14
+ { name: 'x·z', axes: ['x', 'z'] },
15
+ { name: 'y·z', axes: ['y', 'z'] },
16
+ { name: 'x·y·z', axes: ['x', 'y', 'z'] }
17
+ ]
18
+
19
+ /** The size the figure was made at: its instance size when it declares one, else the room its bodies take. */
20
+ export function authoredSizeOf(asset: V3Asset): { x: number; y: number; z: number } {
21
+ const d = asset.designInputs
22
+ const declared = (['x', 'y', 'z'] as const).map(axis => d[V3_INSTANCE_SIZE_INPUTS[axis]])
23
+ if (declared.every(v => typeof v === 'number' && v > 0)) return { x: declared[0]!, y: declared[1]!, z: declared[2]! }
24
+ /* No declared size: the room its bodies take up, so the instance is placed at the size it draws. */
25
+ const boxes = v3OccupiedBoxesOf(compileV3Asset(asset).evaluate().geometry, v3WritersOf(asset))
26
+ const span = (axis: 'x' | 'y' | 'z') =>
27
+ boxes.length ? Math.max(...boxes.map(b => b.centre[axis] + b.extent[axis] / 2)) - Math.min(...boxes.map(b => b.centre[axis] - b.extent[axis] / 2)) : 0
28
+ return { x: Math.max(1, Math.round(span('x'))), y: Math.max(1, Math.round(span('y'))), z: Math.max(1, Math.round(span('z'))) }
29
+ }
30
+
31
+ /** The eight instances: where each stands on the board and how large, in the scene's words. */
32
+ export function previewInstances(type: string, size: { x: number; y: number; z: number }) {
33
+ const COLUMNS = 4
34
+ const cell = { x: size.x * PREVIEW_FACTOR * 1.25, z: size.z * PREVIEW_FACTOR * 1.25 }
35
+ const components = PREVIEW_WAYS.map((way, at) => {
36
+ const grow = (axis: 'x' | 'y' | 'z') => (way.axes.includes(axis) ? PREVIEW_FACTOR : 1)
37
+ const width = Math.round(size.x * grow('x'))
38
+ const height = Math.round(size.z * grow('z'))
39
+ const column = at % COLUMNS
40
+ const row = Math.floor(at / COLUMNS)
41
+ return {
42
+ type,
43
+ id: `at-${way.name}`,
44
+ left: Math.round(column * cell.x + (cell.x - width) / 2),
45
+ top: Math.round(row * cell.z + (cell.z - height) / 2),
46
+ width,
47
+ height,
48
+ /* Always written: without it the height is taken as the plan's mean and "grow y only" would not grow it. */
49
+ depth: Math.round(size.y * grow('y'))
50
+ }
51
+ })
52
+ return { components, board: { width: Math.round(cell.x * COLUMNS), height: Math.round(cell.z * Math.ceil(PREVIEW_WAYS.length / COLUMNS)) } }
53
+ }
54
+
@@ -0,0 +1,159 @@
1
+ import * as THREE from 'three'
2
+ import { buildV3Geometry } from '@hatiolab/things-scene'
3
+
4
+ import type { GizmoResult } from './v3-gizmo.js'
5
+ import type { V3RenderPart } from './v3-renderer.js'
6
+
7
+ /*
8
+ * V3 bodies <-> scene components, in one place (V2's `figure-source.ts` was that place for V2 parts).
9
+ *
10
+ * The modeller stands on a things-scene board in editing mode, as V2's did: each body figure-model evaluates is a
11
+ * `figure-v3-part` component, and what the scene's gizmo writes back comes here to become a change of the part's
12
+ * inputs. The two vocabularies:
13
+ *
14
+ * V3 (asset frame, mm, y up) scene component
15
+ * ──────────────────────────────── ─────────────────────────────────────────────
16
+ * body box extent x · y · z width · depth · height
17
+ * body box centre x left + width/2 - board width/2
18
+ * y zPos + depth/2
19
+ * z top + height/2 - board height/2
20
+ * body rotation (rigid@1, Rx·Ry·Rz) rotationX · -rotation · rotationY (radians; three 'XYZ')
21
+ *
22
+ * The box is the body's own bounding box in its local frame, so a turned body keeps its size and the gizmo's handles
23
+ * sit on its faces. Its centre is not always the body's origin (a portal rises from its bottom), so a turn about the
24
+ * centre moves the origin: the result gives the origin where the turned body puts it.
25
+ */
26
+
27
+ export interface Board {
28
+ width: number
29
+ height: number
30
+ }
31
+
32
+ export interface BodyFrame {
33
+ /** The body's box: its centre in the body's own frame, and its extent. */
34
+ centre: THREE.Vector3
35
+ extent: THREE.Vector3
36
+ rotation: THREE.Matrix4
37
+ origin: THREE.Vector3
38
+ }
39
+
40
+ const round = (n: number) => Math.round(n * 1e6) / 1e6
41
+
42
+ export function frameOf(part: V3RenderPart): BodyFrame | null {
43
+ /* The box things-scene's `figure-v3-part` centres the body on: the same builder, measured the same way. */
44
+ let built: THREE.BufferGeometry | undefined
45
+ try {
46
+ built = buildV3Geometry(part.shape as never, part.segments) as THREE.BufferGeometry | undefined
47
+ } catch {
48
+ return null
49
+ }
50
+ if (!built?.getAttribute('position')) return null
51
+ built.computeBoundingBox()
52
+ const box = built.boundingBox!
53
+ const unit = { centre: box.getCenter(new THREE.Vector3()), extent: box.getSize(new THREE.Vector3()) }
54
+ built.dispose()
55
+ const r = part.pose.r
56
+ const rotation = new THREE.Matrix4().set(r[0]![0]!, r[0]![1]!, r[0]![2]!, 0, r[1]![0]!, r[1]![1]!, r[1]![2]!, 0, r[2]![0]!, r[2]![1]!, r[2]![2]!, 0, 0, 0, 0, 1)
57
+ return { centre: unit.centre, extent: unit.extent, rotation, origin: new THREE.Vector3(...(part.pose.t as [number, number, number])) }
58
+ }
59
+
60
+ /** The colours a body is drawn in, from the palette the modeller resolved (the asset's, over which the theme wins). */
61
+ function colours(part: V3RenderPart, palette: Record<string, string>) {
62
+ const m = part.material
63
+ const glow = m.emissive?.on ? { color: palette[m.emissive.token] ?? palette[m.token] ?? '#9a9a9a', intensity: m.emissive.intensity } : undefined
64
+ return { color: palette[m.token] ?? '#9a9a9a', preset: m.preset, transparent: !!m.transparent, ...(glow ? { glow } : {}) }
65
+ }
66
+
67
+ /** The component state that draws a body where figure-model put it. */
68
+ export function componentOf(part: V3RenderPart, palette: Record<string, string>, board: Board): Record<string, unknown> | null {
69
+ const frame = frameOf(part)
70
+ if (!frame) return null
71
+ const { centre, extent, rotation, origin } = frame
72
+ const world = centre.clone().applyMatrix4(rotation).add(origin)
73
+ const euler = new THREE.Euler().setFromRotationMatrix(rotation, 'XYZ')
74
+ const width = extent.x || 1,
75
+ depth = extent.y || 1,
76
+ height = extent.z || 1
77
+ return {
78
+ type: 'figure-v3-part',
79
+ name: part.id,
80
+ shape: part.shape,
81
+ ...(part.segments !== undefined ? { segments: part.segments } : {}),
82
+ ...colours(part, palette),
83
+ width: round(width),
84
+ depth: round(depth),
85
+ height: round(height),
86
+ left: round(world.x + board.width / 2 - width / 2),
87
+ top: round(world.z + board.height / 2 - height / 2),
88
+ zPos: round(world.y - depth / 2),
89
+ rotationX: round(euler.x),
90
+ rotation: round(-euler.y),
91
+ rotationY: round(euler.z)
92
+ }
93
+ }
94
+
95
+ const num = (v: unknown, fallback = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
96
+
97
+ /**
98
+ * What the gizmo did, in the inputs' terms: where the body's origin is now, how it is turned (degrees, rigid@1's
99
+ * order) and how much each of its own axes grew. Only what changed is given; a component the gizmo did not move gives
100
+ * nothing.
101
+ */
102
+ export function gizmoResultOf(part: V3RenderPart, after: Record<string, unknown>, board: Board): GizmoResult {
103
+ const frame = frameOf(part)
104
+ if (!frame) return {}
105
+ const { centre, extent, rotation, origin } = frame
106
+ const width = num(after.width, extent.x),
107
+ depth = num(after.depth, extent.y),
108
+ height = num(after.height, extent.z)
109
+ const scale = new THREE.Vector3(width / (extent.x || 1), depth / (extent.y || 1), height / (extent.z || 1))
110
+ const world = new THREE.Vector3(num(after.left) + width / 2 - board.width / 2, num(after.zPos) + depth / 2, num(after.top) + height / 2 - board.height / 2)
111
+ const euler = new THREE.Euler(num(after.rotationX), -num(after.rotation), num(after.rotationY), 'XYZ')
112
+ const turnedTo = new THREE.Matrix4().makeRotationFromEuler(euler)
113
+
114
+ const before = new THREE.Quaternion().setFromRotationMatrix(rotation)
115
+ const now = new THREE.Quaternion().setFromEuler(euler)
116
+ const turned = before.angleTo(now) > 1e-4
117
+ const sized = [scale.x, scale.y, scale.z].some(s => Math.abs(s - 1) > 1e-4)
118
+ /* The origin that puts the (scaled, turned) box centre where the scene has it. */
119
+ const newOrigin = world.clone().sub(centre.clone().multiply(scale).applyMatrix4(turnedTo))
120
+ const moved = newOrigin.distanceTo(origin) > 0.5
121
+
122
+ const result: GizmoResult = {}
123
+ if (moved || turned) result.position = newOrigin.toArray() as [number, number, number]
124
+ if (turned) result.rotation = [euler.x, euler.y, euler.z].map(THREE.MathUtils.radToDeg) as [number, number, number]
125
+ if (sized) result.scale = scale.toArray() as [number, number, number]
126
+ return result
127
+ }
128
+
129
+ /** The keys of `next` whose values differ from `current` -- only those are set, so the scene raises no echo. */
130
+ export function changedKeys(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, unknown> | null {
131
+ const diff: Record<string, unknown> = {}
132
+ for (const [key, value] of Object.entries(next)) {
133
+ if (key === 'type') continue
134
+ const was = current[key]
135
+ const same = typeof value === 'object' && value !== null ? JSON.stringify(was) === JSON.stringify(value) : was === value
136
+ if (!same) diff[key] = value
137
+ }
138
+ return Object.keys(diff).length ? diff : null
139
+ }
140
+
141
+ /** A board wide and deep enough for the figure, with room around it; its size only grows while a figure is edited. */
142
+ export function boardFor(parts: readonly V3RenderPart[], at: Board & { depth?: number } = { width: 0, height: 0 }): Board & { depth: number } {
143
+ const box = new THREE.Box3()
144
+ for (const part of parts) {
145
+ const frame = frameOf(part)
146
+ if (!frame) continue
147
+ const half = frame.extent.clone().multiplyScalar(0.5)
148
+ const corners = [-1, 1].flatMap(x => [-1, 1].flatMap(y => [-1, 1].map(z => new THREE.Vector3(x * half.x, y * half.y, z * half.z))))
149
+ for (const corner of corners) box.expandByPoint(corner.add(frame.centre).applyMatrix4(frame.rotation).add(frame.origin))
150
+ }
151
+ const size = box.isEmpty() ? new THREE.Vector3(1000, 1000, 1000) : box.getSize(new THREE.Vector3())
152
+ const reach = (axis: 'x' | 'z') => 2 * Math.max(Math.abs(box.isEmpty() ? 500 : box.min[axis]), Math.abs(box.isEmpty() ? 500 : box.max[axis]))
153
+ return {
154
+ width: Math.max(at.width, Math.ceil(reach('x') * 1.2), 200),
155
+ height: Math.max(at.height, Math.ceil(reach('z') * 1.2), 200),
156
+ /* Only grows, as the plan does: a raised arm made the board taller than it was, and a new board lost the camera. */
157
+ depth: Math.max(at.depth ?? 0, Math.ceil(size.y * 1.1), 200)
158
+ }
159
+ }