@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.
- package/client/modeller/angle-dial.ts +70 -0
- package/client/modeller/figure-settings.ts +328 -0
- package/client/modeller/figure-v3-capability.ts +137 -0
- package/client/modeller/figure-v3-clips.ts +253 -0
- package/client/modeller/figure-v3-controls.ts +212 -0
- package/client/modeller/figure-v3-links.ts +150 -79
- package/client/modeller/figure-v3-material.ts +108 -35
- package/client/modeller/figure-v3-parts.ts +96 -3
- package/client/modeller/figure-v3-preview.ts +326 -0
- package/client/modeller/figure-v3-properties.ts +29 -57
- package/client/modeller/figure-v3-verdict.ts +598 -60
- package/client/modeller/figure-view.ts +152 -0
- package/client/modeller/side-panel-styles.ts +326 -0
- package/client/modeller/v3-canvas.ts +221 -263
- package/client/modeller/v3-clips.ts +89 -0
- package/client/modeller/v3-controls.ts +54 -0
- package/client/modeller/v3-editor.ts +532 -223
- package/client/modeller/v3-fields.ts +62 -0
- package/client/modeller/v3-help.ts +41 -0
- package/client/modeller/v3-keys.ts +34 -0
- package/client/modeller/v3-links.ts +126 -10
- package/client/modeller/v3-live.ts +79 -0
- package/client/modeller/v3-maturity.ts +415 -0
- package/client/modeller/v3-palette.ts +45 -0
- package/client/modeller/v3-preview-model.ts +54 -0
- package/client/modeller/v3-stage-model.ts +159 -0
- package/client/pages/figure-list-page.ts +4 -4
- package/client/pages/figure-v3-modeller-page.ts +66 -134
- package/dist-client/modeller/angle-dial.d.ts +32 -0
- package/dist-client/modeller/angle-dial.js +41 -0
- package/dist-client/modeller/angle-dial.js.map +1 -0
- package/dist-client/modeller/figure-settings.d.ts +39 -0
- package/dist-client/modeller/figure-settings.js +317 -0
- package/dist-client/modeller/figure-settings.js.map +1 -0
- package/dist-client/modeller/figure-v3-capability.d.ts +22 -0
- package/dist-client/modeller/figure-v3-capability.js +141 -0
- package/dist-client/modeller/figure-v3-capability.js.map +1 -0
- package/dist-client/modeller/figure-v3-clips.d.ts +31 -0
- package/dist-client/modeller/figure-v3-clips.js +268 -0
- package/dist-client/modeller/figure-v3-clips.js.map +1 -0
- package/dist-client/modeller/figure-v3-controls.d.ts +36 -0
- package/dist-client/modeller/figure-v3-controls.js +226 -0
- package/dist-client/modeller/figure-v3-controls.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +10 -2
- package/dist-client/modeller/figure-v3-links.js +134 -79
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-material.d.ts +5 -2
- package/dist-client/modeller/figure-v3-material.js +109 -35
- package/dist-client/modeller/figure-v3-material.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +10 -0
- package/dist-client/modeller/figure-v3-parts.js +88 -3
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-preview.d.ts +34 -0
- package/dist-client/modeller/figure-v3-preview.js +322 -0
- package/dist-client/modeller/figure-v3-preview.js.map +1 -0
- package/dist-client/modeller/figure-v3-properties.d.ts +2 -2
- package/dist-client/modeller/figure-v3-properties.js +29 -57
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/figure-v3-verdict.d.ts +31 -17
- package/dist-client/modeller/figure-v3-verdict.js +579 -59
- package/dist-client/modeller/figure-v3-verdict.js.map +1 -1
- package/dist-client/modeller/figure-view.d.ts +118 -0
- package/dist-client/modeller/figure-view.js +78 -0
- package/dist-client/modeller/figure-view.js.map +1 -0
- package/dist-client/modeller/side-panel-styles.d.ts +23 -0
- package/dist-client/modeller/side-panel-styles.js +326 -0
- package/dist-client/modeller/side-panel-styles.js.map +1 -0
- package/dist-client/modeller/v3-canvas.d.ts +29 -37
- package/dist-client/modeller/v3-canvas.js +188 -260
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-clips.d.ts +39 -0
- package/dist-client/modeller/v3-clips.js +58 -0
- package/dist-client/modeller/v3-clips.js.map +1 -0
- package/dist-client/modeller/v3-controls.d.ts +28 -0
- package/dist-client/modeller/v3-controls.js +28 -0
- package/dist-client/modeller/v3-controls.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +58 -1
- package/dist-client/modeller/v3-editor.js +556 -218
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +30 -0
- package/dist-client/modeller/v3-fields.js +46 -0
- package/dist-client/modeller/v3-fields.js.map +1 -0
- package/dist-client/modeller/v3-help.d.ts +18 -0
- package/dist-client/modeller/v3-help.js +37 -0
- package/dist-client/modeller/v3-help.js.map +1 -0
- package/dist-client/modeller/v3-keys.d.ts +14 -0
- package/dist-client/modeller/v3-keys.js +23 -0
- package/dist-client/modeller/v3-keys.js.map +1 -0
- package/dist-client/modeller/v3-links.d.ts +44 -2
- package/dist-client/modeller/v3-links.js +125 -8
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +31 -0
- package/dist-client/modeller/v3-live.js +58 -0
- package/dist-client/modeller/v3-live.js.map +1 -0
- package/dist-client/modeller/v3-maturity.d.ts +120 -0
- package/dist-client/modeller/v3-maturity.js +317 -0
- package/dist-client/modeller/v3-maturity.js.map +1 -0
- package/dist-client/modeller/v3-palette.d.ts +7 -0
- package/dist-client/modeller/v3-palette.js +42 -0
- package/dist-client/modeller/v3-palette.js.map +1 -0
- package/dist-client/modeller/v3-preview-model.d.ts +32 -0
- package/dist-client/modeller/v3-preview-model.js +48 -0
- package/dist-client/modeller/v3-preview-model.js.map +1 -0
- package/dist-client/modeller/v3-stage-model.d.ts +31 -0
- package/dist-client/modeller/v3-stage-model.js +121 -0
- package/dist-client/modeller/v3-stage-model.js.map +1 -0
- package/dist-client/pages/figure-list-page.js +4 -4
- package/dist-client/pages/figure-list-page.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +2 -6
- package/dist-client/pages/figure-v3-modeller-page.js +64 -136
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.chk.tsbuildinfo +1 -0
- package/dist-client/tsconfig.scratch-fm.tsbuildinfo +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/test/v3-clips.test.ts +70 -0
- package/test/v3-controls.test.ts +68 -0
- package/test/v3-fields.test.ts +62 -0
- package/test/v3-gizmo.test.ts +3 -5
- package/test/v3-keys.test.ts +37 -0
- package/test/v3-layout.test.ts +78 -0
- package/test/v3-links.test.ts +118 -7
- package/test/v3-live.test.ts +99 -0
- package/test/v3-material.test.ts +6 -0
- package/test/v3-maturity.test.ts +268 -0
- package/test/v3-parts-wiring.test.ts +3 -1
- package/test/v3-preview.test.ts +42 -0
- package/test/v3-properties.test.ts +4 -2
- package/test/v3-scene-canvas.test.ts +85 -0
- package/test/v3-stage-model.test.ts +25 -0
- package/test/v3-theme.test.ts +16 -25
- package/test/v3-verdict.test.ts +12 -6
- package/test/v3-view.test.ts +20 -0
- package/translations/en.json +98 -5
- package/translations/ja.json +100 -7
- package/translations/ko.json +98 -5
- package/translations/ms.json +100 -7
- 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
|
+
}
|