@volter/editor-blender 0.1.0

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 (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -0,0 +1,305 @@
1
+ /**
2
+ * THE UV EDITOR'S GEOMETRY AND COLOUR, every constant read from Blender's own
3
+ * source at the engine's pin (5.2.0, `fbe6228777e7`) — WORK.md §Blender in the
4
+ * tab is Blender, "Inspection parity", I5; ARCHITECTURE-CORE §Blender north
5
+ * star ("the reference is Blender's SOURCE as well as its frames").
6
+ *
7
+ * The drawing this describes is `MeshUVs` — the overlay class that REPLACED
8
+ * the 5.2-era `overlay_edit_uv.cc`, which is why that filename does not exist
9
+ * in the checkout — at `draw/engines/overlay/overlay_mesh.hh:483-760`, plus
10
+ * the four shaders it binds
11
+ * (`shaders/overlay_edit_uv_{edges_frag,verts_vert,verts_frag,faces_vert,face_dots_vert}.glsl`),
12
+ * `space_image.cc`'s `image_create` for the space's own defaults and
13
+ * `userdef_default_theme.c`'s `.space_image` for the colours.
14
+ *
15
+ * ## What is NOT mode-gated, and why every flag below reads "unselected"
16
+ *
17
+ * Blender draws UVs only when `show_uv_edit_ = space_mode_is_uv &&
18
+ * object_mode_is_edit` (`overlay_mesh.hh:593`). Both halves belong to its UI
19
+ * layer, which this editor never runs (orchestrator ruling 1, 2026-09-19:
20
+ * "inspection is not mode-gated"). So the view reads the DATA in whatever mode
21
+ * the engine is in and names the mode in its status line.
22
+ *
23
+ * The same ruling's second half decides the selection colours: "the view reads
24
+ * the flags the data carries and draws everything unselected when the flags
25
+ * are absent". MEASURED at this pin: `MeshUVLoopLayer`
26
+ * (`makesrna/intern/rna_mesh.cc:2380-2456`) declares `uv`, `pin`, `name`,
27
+ * `active`, `active_render` and `active_clone` — and NO vertex or edge
28
+ * selection at all, because UV selection lives in the BMesh an edit-mode
29
+ * session holds. `pin` IS declared, so pins are drawn; everything else takes
30
+ * its unselected branch, and the SELECTED constants below are recorded beside
31
+ * them rather than dropped, because a colour that is never reached is still
32
+ * the specification of the one that is.
33
+ */
34
+
35
+ // ------------------------------------------------------------------- colours
36
+
37
+ /**
38
+ * `.space_image` (`release/datafiles/userdef/userdef_default_theme.c:578-608`),
39
+ * whole. Alpha is carried where Blender carries it: `face` is `0x0a` and
40
+ * `face_select` `0x3c` out of 255, which is what makes a UV island read as a
41
+ * wash rather than a fill.
42
+ */
43
+ export const UV_THEME = {
44
+ /** `.back` `RGBA(0x30303000)` (`:580`) — alpha ZERO, so the space paints no
45
+ * backdrop of its own and the region's own surface shows. The node editor's
46
+ * `.back` is the same shape (`:661`), and U8's ruling 2 is what gives this
47
+ * view a surface colour of its own instead. */
48
+ back: '#303030',
49
+ backAlpha: 0,
50
+ /** `.grid` `RGBA(0x303030ff)` (`:586`) — FULL alpha, unlike the node
51
+ * editor's. See `UV_GRID_OPEN` below for what that measures to. */
52
+ grid: '#303030',
53
+ /** `.wire_edit` (`:587`) — the unselected edge under the OUTLINE line style,
54
+ * halved by the shader when `use_edge_select` is on
55
+ * (`overlay_edit_uv_edges_frag.glsl:27`). */
56
+ wireEdit: '#c0c0c0',
57
+ /** `.vertex_select` (`:588`) — `theme.colors.vert_select`, the fill of a
58
+ * selected UV vertex (`overlay_edit_uv_verts_vert.glsl:20`). */
59
+ vertexSelect: '#ff8500',
60
+ /** `.edge_select` (`:589`) — mixed into every line style by
61
+ * `selection_fac` (`overlay_edit_uv_edges_frag.glsl:31,41,46,50`). */
62
+ edgeSelect: '#ff8500',
63
+ /** `.face` `RGBA(0xffffff0a)` (`:590`) — the unselected island wash. */
64
+ face: '#ffffff',
65
+ faceAlpha: 0x0a / 255,
66
+ /** `.face_select` `RGBA(0xff85003c)` (`:591`). */
67
+ faceSelect: '#ff8500',
68
+ faceSelectAlpha: 0x3c / 255,
69
+ /** `.editmesh_active` `RGBA(0xffffff40)` (`:596`) — the ACTIVE face, which
70
+ * `overlay_edit_uv_faces_vert.glsl:25` puts over the selected colour. */
71
+ editmeshActive: '#ffffff',
72
+ editmeshActiveAlpha: 0x40 / 255,
73
+ /** `.uv_shadow` (`:606`) — the SHADOW line style, which is what the
74
+ * wireframe pass draws outside UV edit mode
75
+ * (`edit_uv_line_style_from_space_image`, `overlay_mesh.hh:1091-1111`).
76
+ * **That is this view's line style**, because this view is never in UV edit
77
+ * mode: `sima->mode == SI_MODE_UV` is a space flag, and there is no space. */
78
+ uvShadow: '#707070',
79
+ /** `.preview_back` (`:601`). Recorded whole because the theme block is, and
80
+ * named as NOT USED here: it is `TH_PREVIEW_BACK`, the backdrop behind a
81
+ * node/texture preview, not the UV tile's. */
82
+ previewBack: '#727272',
83
+ /** `TH_VERTEX` and `TH_FACEDOT` and `TH_WIRE` are NOT DECLARED in
84
+ * `.space_image` — checked key by key against `:578-608`. `resources.cc`
85
+ * resolves each straight off the active space's `ThemeSpace`
86
+ * (`:407-408`, `:361-362`, and the facedot case beside them), so the C
87
+ * struct's zero stands and all three are BLACK — the same reading I4
88
+ * recorded for `TH_VERTEX_UNREFERENCED`. Both shaders force alpha to 1 over
89
+ * that zero (`…verts_vert.glsl:19`: `float4(color.rgb, 1.0f)`;
90
+ * `…face_dots_vert.glsl:18`: `float4(theme.colors.wire.rgb, 1.0f)`), so an
91
+ * unselected UV vertex and an unselected face dot are opaque black. */
92
+ vertex: '#000000',
93
+ faceDot: '#000000',
94
+ wire: '#000000',
95
+ } as const;
96
+
97
+ /** `.space_image`'s SIZES, the same block (`:592-595`). */
98
+ export const UV_SIZES = {
99
+ /** `.vertex_size = 3` (`:592`). */
100
+ vertexSize: 3,
101
+ /** `.edge_width = 1` (`:593`). */
102
+ edgeWidth: 1,
103
+ /** `.outline_width = 1` (`:594`). */
104
+ outlineWidth: 1,
105
+ /** `.facedot_size = 3` (`:595`). */
106
+ facedotSize: 3,
107
+ } as const;
108
+
109
+ // ------------------------------------------------------------------ geometry
110
+
111
+ /**
112
+ * `UI_SCALE_FAC` is 1 here, for the reason I5's node work already measured:
113
+ * `U.widget_unit` is `int(roundf(18 * scale_factor)) + 2 * pixelsize` = 20 at
114
+ * scale 1 (`windowmanager/intern/wm_window.cc:779`), which is the value every
115
+ * node constant divides by — so the session's scale factor is 1 and every
116
+ * `* UI_SCALE_FAC` below is the identity.
117
+ */
118
+ export const UI_SCALE_FAC = 1;
119
+
120
+ /**
121
+ * A UV VERTEX DOT'S DIAMETER IN PIXELS.
122
+ * `(TH_VERTEX_SIZE * UI_SCALE_FAC + 1.5) * √2` (`overlay_mesh.hh:709-712`) —
123
+ * 3 → **6.364**. The `√2` is there because the point sprite is a SQUARE whose
124
+ * inscribed circle must still hold the dot after the outline, and the 1.5 is
125
+ * the outline's own room.
126
+ */
127
+ export const UV_VERT_DOT_SIZE = (UV_SIZES.vertexSize * UI_SCALE_FAC + 1.5) * Math.SQRT2;
128
+
129
+ /**
130
+ * The outline ring inside that dot: `outline_width` 0.75 pushed as a shader
131
+ * constant (`overlay_mesh.hh:713`) — NOT the theme's `.outline_width` 1, which
132
+ * is a different number for a different mark, and the two are recorded
133
+ * together here because reading one for the other is the obvious mistake.
134
+ * `…verts_vert.glsl:33-39` turns it into four concentric radii; at
135
+ * `radius = dot_size/2` the pure fill ends at `radius - 0.75 - 1`.
136
+ */
137
+ export const UV_VERT_OUTLINE_WIDTH = 0.75;
138
+
139
+ /** A FACE DOT'S DIAMETER: `TH_FACEDOT_SIZE * UI_SCALE_FAC`
140
+ * (`overlay_mesh.hh:727-729`) — 3, with no `√2` and no outline. */
141
+ export const UV_FACEDOT_SIZE = UV_SIZES.facedotSize * UI_SCALE_FAC;
142
+
143
+ /** A DASHED EDGE'S PERIOD: `4.0f * UI_SCALE_FAC` (`overlay_mesh.hh:670`) — 4
144
+ * pixels, of which the shader inks the first HALF
145
+ * (`…edges_frag.glsl:38`: `fract(line_distance / dash_length) < 0.5`). */
146
+ export const UV_DASH_LENGTH = 4 * UI_SCALE_FAC;
147
+
148
+ /**
149
+ * THE THREE OPACITIES, and all three default to 1.0 —
150
+ * `image_create` (`editors/space_image/space_image.cc:116-118`) sets
151
+ * `uv_opacity`, `uv_face_opacity` and `uv_edge_opacity` to `1.0f` each.
152
+ *
153
+ * WHICH ONE APPLIES IS THE MODE QUESTION AGAIN: the face pass takes
154
+ * `uv_opacity` when `object_mode_is_edit && space_mode_is_uv` and
155
+ * `uv_face_opacity` otherwise (`overlay_mesh.hh:741-743`), and the wireframe
156
+ * pass always takes `uv_edge_opacity` (`:672`). Since all three are 1.0 the
157
+ * branch cannot change a pixel here, which is why the view takes the
158
+ * NOT-in-edit-mode branch (the honest one) and the number is the same.
159
+ *
160
+ * The one thing the branch DOES change is the final multiply: an object that
161
+ * is not the active edit-mode object has its alpha quartered
162
+ * (`…edges_frag.glsl:71`, `…faces_vert.glsl:26`: `alpha * 0.25`). The view
163
+ * draws ONE object — the active one — so it takes the full-alpha branch, and
164
+ * says so.
165
+ */
166
+ export const UV_OPACITY = 1;
167
+ export const UV_FACE_OPACITY = 1;
168
+ export const UV_EDGE_OPACITY = 1;
169
+
170
+ /**
171
+ * THE TILE GRID. `image_create` (`space_image.cc:129-133`) sets
172
+ * `tile_grid_shape` to `1, 1` and `custom_grid_subdiv` to `10, 10`, so the
173
+ * stock drawing is the 0–1 tile with a ten-by-ten subdivision. The step is
174
+ * `1 / custom_grid_subdiv[i]` under `SI_GRID_SHAPE_FIXED`
175
+ * (`image_draw.cc:575-576`).
176
+ */
177
+ export const UV_TILE_GRID: readonly [number, number] = [1, 1];
178
+ export const UV_GRID_SUBDIV: readonly [number, number] = [10, 10];
179
+
180
+ /**
181
+ * THE GRID'S VISIBILITY IS THE ONE THING THE SOURCE HERE CANNOT SETTLE, and it
182
+ * is named rather than guessed at.
183
+ *
184
+ * `.space_image.grid` is `#303030` at FULL alpha (`:586`) — unlike the node
185
+ * editor's, whose alpha is zero, which is why that view draws no grid at all.
186
+ * But the image region is cleared by `DRW_draw_view`
187
+ * (`space_image.cc:758`), the overlay ENGINE's own clear, and
188
+ * `draw/engines/overlay`'s grid pass is not in this checkout's sparse set. So
189
+ * the source states the grid's colour and not the value it sits on, and
190
+ * `#303030` on this view's own `#303030` surface is INVISIBLE — measured, not
191
+ * assumed. The view draws the grid at the theme's colour anyway, because
192
+ * drawing a different one would be inventing a mark; the contrast is the
193
+ * first thing the owed sighted frame-beside-frame read settles.
194
+ */
195
+ export const UV_GRID_OPEN =
196
+ "Blender's `.space_image.grid` is #303030 at full alpha " +
197
+ "(userdef_default_theme.c:586) and this view's surface is #303030, so the tile " +
198
+ "grid draws at the theme's colour and is invisible against it. The image " +
199
+ "region's own clear is the overlay engine's (DRW_draw_view, space_image.cc:758), " +
200
+ 'which is not in the source checkout — the sighted read settles the contrast.';
201
+
202
+ /**
203
+ * THE LINE STYLE THIS VIEW DRAWS, and why it is not the default one.
204
+ *
205
+ * `edit_uv_line_style_from_space_image` (`overlay_mesh.hh:1091-1111`) returns
206
+ * one of five: with `sima->mode == SI_MODE_UV` it maps `dt_uv` — whose enum is
207
+ * `OUTLINE, DASH, BLACK, WHITE` in that order (`rna_space.cc:4010-4016`) and
208
+ * whose `image_create` leaves it at 0, so Blender's stock UV editor draws
209
+ * **OUTLINE** — and OTHERWISE it returns `SHADOW`.
210
+ *
211
+ * There is no `SpaceImage` here and therefore no `mode`, so the honest branch
212
+ * is the else: **SHADOW**, `theme.colors.uv_shadow` `#707070`
213
+ * (`…edges_frag.glsl:48-50`). The OUTLINE constants are carried beside it
214
+ * because that is what the frame-beside-frame read will be against once a
215
+ * mode-carrying surface exists.
216
+ */
217
+ export const UV_LINE_STYLES = {
218
+ outline: {
219
+ /** `mix(wire_edit, edge_select, selection_fac)` with `use_edge_select`
220
+ * off (`…edges_frag.glsl:34`), over a black outer line (`:36`). */
221
+ inner: UV_THEME.wireEdit,
222
+ outer: '#000000',
223
+ },
224
+ dash: { inner: '#595959', outer: null, period: UV_DASH_LENGTH },
225
+ black: { inner: '#000000', outer: null },
226
+ white: { inner: '#ffffff', outer: null },
227
+ shadow: { inner: UV_THEME.uvShadow, outer: null },
228
+ } as const;
229
+
230
+ /** `0.35` in the DASH style's `float4(float3(0.35f), 1.0f)`
231
+ * (`…edges_frag.glsl:39`) is a LINEAR value, and every other colour in that
232
+ * shader comes from the theme as sRGB — so the dash's grey is `0.35`
233
+ * linear = **#595959** sRGB, which is what `UV_LINE_STYLES.dash.inner`
234
+ * carries. (`0.35^(1/2.4)` with the sRGB knee: 0.3487 → 0.6236 → 159.) */
235
+ export const UV_DASH_LINEAR_GREY = 0.35;
236
+
237
+ /**
238
+ * THE EDGE'S DRAWN WIDTH. `…edges_frag.glsl:53-54` inks out to
239
+ * `max(theme.sizes.edge - 0.5, 0)` and fades over the next
240
+ * `max(theme.sizes.edge, 1)` — with `.edge_width` 1 that is a 0.5-px core in a
241
+ * 1-px falloff either side of the line's centre, so the mark reads as a
242
+ * **1 px** line with a soft edge. The view draws 1 px.
243
+ */
244
+ export const UV_EDGE_DRAWN_WIDTH = Math.max(UV_SIZES.edgeWidth, 1);
245
+
246
+ /** A PIN is not a theme colour: `…verts_vert.glsl:15` declares
247
+ * `constexpr float4 pinned_col = float4(1.0f, 0.0f, 0.0f, 1.0f)` with a
248
+ * `/* TODO: Theme? *\/` beside it, so a pinned UV vertex is a hard **red**,
249
+ * fill AND outline (`:20-21`). */
250
+ export const UV_PIN_COLOR = '#ff0000';
251
+
252
+ // -------------------------------------------------------------- the decoding
253
+
254
+ /** One base64 payload as bytes. The doors ship typed-array bytes because a UV
255
+ * layout is `loops` corners and JSON numbers would be four times the wire for
256
+ * the same values — the shape I4's weights already established. */
257
+ export function uvBytes(base64: string): Uint8Array {
258
+ const binary = atob(base64);
259
+ const bytes = new Uint8Array(binary.length);
260
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
261
+ return bytes;
262
+ }
263
+
264
+ export function uvFloat32(base64: string): Float32Array {
265
+ const bytes = uvBytes(base64);
266
+ return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
267
+ }
268
+
269
+ export function uvUint32(base64: string): Uint32Array {
270
+ const bytes = uvBytes(base64);
271
+ return new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
272
+ }
273
+
274
+ /** An `#rrggbb` plus an alpha as an `rgba()` — SVG's `fill-opacity` would do
275
+ * for a fill, but an alpha that belongs to the COLOUR (Blender's `face` is
276
+ * `0xffffff0a`, one value) is clearer carried with it. */
277
+ export function uvRgba(hex: string, alpha: number): string {
278
+ const n = Number.parseInt(hex.slice(1), 16);
279
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${Math.round(alpha * 1000) / 1000})`;
280
+ }
281
+
282
+ /** THE VIEW'S OWN CHROME, as named constants rather than inline literals —
283
+ * `.space_image.text` `#e6e6e6` (`userdef_default_theme.c:582`) for the
284
+ * status line, and Blender's selection orange `#ed5700` (the palette's
285
+ * `viewport.selection`, itself derived from `.space_view3d.select`) for a
286
+ * refusal, which is the same hue I2's rail already wears for "this is the
287
+ * one you are looking at". They live here because a `@volter/editor-blender` view may
288
+ * not import the editor's theme (the package's host-import pin is ZERO) and
289
+ * because the style-token ratchet counts LITERALS in a `.tsx`, which is
290
+ * exactly the right pressure: a colour with a citation is a constant. */
291
+ export const UV_CHROME = {
292
+ text: '#e6e6e6',
293
+ refusal: '#ed5700',
294
+ /** `.space_image`'s own separator value: the palette's `region.outliner`
295
+ * neighbour, one level under the surface, which is what every other panel
296
+ * edge in this look already draws. */
297
+ rule: '#232323',
298
+ /** The status line's own box. Blender's editor footers are one text row in
299
+ * a `widget_unit`-tall strip; at `U.widget_unit` 20 (`wm_window.cc:779`)
300
+ * and 11-px text that is 3 px of padding either side of the row, and the
301
+ * 12-px gap is the same `NODE_MARGIN_X/3` rhythm the node view's labels
302
+ * already use. Named here for the reason the colours are. */
303
+ statusPadding: '3px 8px',
304
+ statusGap: 12,
305
+ } as const;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The VERSION at the far right of the status bar (`workspace.status`).
3
+ *
4
+ * Blender's bottom row ends with a status glyph and `5.2.0` — the app's own
5
+ * version, always on screen, so "which build am I looking at" is never a
6
+ * menu trip. This is that, for this editor.
7
+ *
8
+ * WHERE THE NUMBER COMES FROM. The open project's manifest declares the
9
+ * engine it is built against, and the host door exposes it as
10
+ * `editorHost().project.engineVersion()` — the exact field
11
+ * `ProjectHeader.tsx` renders as `v{engineVersion}`. This reuses that one
12
+ * source rather than importing a `package.json`: the version that matters to
13
+ * a person looking at a project is the one the project is pinned to, and a
14
+ * second reading could disagree with the header's.
15
+ *
16
+ * It is read through the SDK's host door and NOT through `@editor/*`, and
17
+ * its two inline styles spell the theme tokens directly
18
+ * (`var(--vgai-space-2)`, `var(--vgai-font-md)`) rather than importing the
19
+ * host's `fontSizeVar`/`spaceVar` handles: `@volter/editor-blender` is pinned at ZERO
20
+ * host imports (`validate-editor-closure.mjs`), the first package to reach
21
+ * that destination, and a token NAME is the published contract while the
22
+ * handle object is the host's own sugar. The widget kit's move to
23
+ * `@volter/editor-sdk/widgets` is deferred (WORK.md §Skews as packages, item 5);
24
+ * until it lands, a package styling inline writes the `var()`.
25
+ *
26
+ * Nothing before a project is open (there is no version to tell the truth
27
+ * about yet), per the registry contract's "an item that is currently
28
+ * meaningless returns null itself".
29
+ *
30
+ * The connection half of Blender's pair is the host's own job and already
31
+ * exists (`status-contributions.tsx`'s `editor-lease` item, which is quiet
32
+ * while the editor is served and speaks when it is not); this contributes
33
+ * only the version, and the host's counters are untouched.
34
+ */
35
+
36
+ import { editorHost } from '@volter/editor-sdk/host';
37
+ import { useSyncExternalStore } from 'react';
38
+
39
+ export const point = 'workspace.status';
40
+ export const title = 'Version';
41
+ export const align = 'right';
42
+ /** The back of the contributed band: last item on the right, as in Blender.
43
+ * `tool-loader.ts`'s `MAX_DECLARED_ORDER` is 998. */
44
+ export const order = 998;
45
+
46
+ export default function BlenderVersionStatus() {
47
+ const { project } = editorHost();
48
+ const version = useSyncExternalStore(
49
+ project.subscribe,
50
+ project.engineVersion,
51
+ project.engineVersion,
52
+ );
53
+ if (version === null) return null;
54
+ return (
55
+ <span
56
+ className="vgai-status-copy"
57
+ data-testid="status-blender-version"
58
+ data-engine-version={version}
59
+ // NO COLOUR AND NO `sm`. Measured on the native-2x frames, Blender's
60
+ // `5.2.0` is `#878787` (135, a flat plateau — 53 px at 134 and 33 at 135
61
+ // across the digits) with a 9.0 CSS cap height (18 device). 135 is the
62
+ // DIMMEST text in Blender's window and the same ink its mouse hints take
63
+ // at the other end of the band, so the band is one voice and this
64
+ // inherits it rather than pinning `content.muted` (194) and becoming the
65
+ // loudest thing in the quietest strip. The cap puts the size on the
66
+ // `md`/`base` rung (11px -> 8.8), not `sm` (10px -> 8.0).
67
+ style={{
68
+ display: 'inline-flex',
69
+ alignItems: 'center',
70
+ gap: 'var(--vgai-space-2)',
71
+ fontSize: 'var(--vgai-font-md)',
72
+ }}
73
+ title={`This project is built against engine ${version}`}
74
+ >
75
+ {/* Blender's thin rule between the status glyph and the number, measured
76
+ on `modeling.png` at device x 3369..3371, y 2069..2097: 1.5 CSS wide,
77
+ 14.5 CSS tall, and inked 134 — the TEXT's own colour, not a boundary.
78
+ It was drawn here at `boundary.strong` (#3c3c3c, 60), which against a
79
+ #171717 band is all but invisible; `currentColor` is both the measured
80
+ colour and the one that cannot drift from the number beside it. The
81
+ height rung closest to 14.5 is `icon-md` (14). */}
82
+ <span
83
+ aria-hidden="true"
84
+ style={{
85
+ width: 'var(--vgai-stroke-resting)',
86
+ height: 'var(--vgai-icon-md)',
87
+ background: 'currentColor',
88
+ }}
89
+ />
90
+ {version}
91
+ </span>
92
+ );
93
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * THE BLENDER VERBS of the session wire (`@volter/editor-sdk/commands`, a
3
+ * `workspace.command` contribution): `blender-start`, `blender-stop`,
4
+ * `blender-execute`, `blender-scene-info`, `blender-object-info`,
5
+ * `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
6
+ * `blender-list-files`, `blender-status`, the RNA door's `blender-rna`,
7
+ * `blender-rna-context` and `blender-rna-set`, and the tree door's
8
+ * `blender-outliner` and `blender-outliner-set`.
9
+ *
10
+ * These left `command-listener.ts`'s switch (and their rows left
11
+ * `command-table.ts`) when Blender's editor half left the host — WORK.md
12
+ * §The workbench, item D. `vgai blender-mcp` is transport only: every
13
+ * `execute_blender_code`, `get_scene_info`, `get_object_info` and
14
+ * `get_viewport_screenshot` still arrives as the same `blender-*` command
15
+ * through the same relay, and is answered here instead of there.
16
+ *
17
+ * EACH ROW CARRIES ITS OWN RELAY BUDGET, transcribed from the host table it
18
+ * left — the page reports the rows it registered and the server sizes its
19
+ * wait from them, so a contributed verb never falls to the generic 5s
20
+ * default (the failure `command-table.ts`'s header records). Blender
21
+ * runs in this tab's worker; a chunk can model for a while (the engine
22
+ * boots on the first call), and every one presents a frame.
23
+ *
24
+ * THIS LANE HOLDS NO HOST INTERNAL (2026-09-19, WORK.md §Skews as packages
25
+ * under the Code-OSS frame, item 6). It took the shell store from
26
+ * `@editor/shell-store-door` to hand to the runtime host, which needed it for
27
+ * one call — presenting the Model document — and that call is now
28
+ * `host.workspace.open`, an address the door routes. A handler's whole context
29
+ * is the command it is given plus `@volter/editor-sdk/host`.
30
+ */
31
+ import type {
32
+ CommandContribution,
33
+ CommandDerivedRefresh,
34
+ CommandSpec,
35
+ } from '@volter/editor-sdk/commands';
36
+ import { handleBlenderCommand } from '../host/blender-runtime-host';
37
+
38
+ export const point = 'workspace.command';
39
+
40
+ /** One Blender verb: its budget, and the lane's one handler. A row with no
41
+ * `timeoutMs` takes the host's own generic budget — the value the rows it
42
+ * left spelled as `noDerivedRefresh()`'s default, and not restated here so
43
+ * the two cannot drift. `derivedRefresh` IS always stated: the registry's
44
+ * default for a contributed row is `'always'`, and only the two verbs that
45
+ * change what the Model document shows owe a derivation.
46
+ */
47
+ const verb = (derivedRefresh: CommandDerivedRefresh, timeoutMs?: number): CommandSpec => ({
48
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
49
+ derivedRefresh,
50
+ handle: (cmd) => handleBlenderCommand(cmd as { type: string; [key: string]: unknown }),
51
+ });
52
+
53
+ export const commands: CommandContribution['commands'] = {
54
+ 'blender-start': verb('none', 120_000),
55
+ 'blender-stop': verb('none'),
56
+ 'blender-execute': verb('always', 30 * 60_000),
57
+ 'blender-scene-info': verb('none', 60_000),
58
+ 'blender-object-info': verb('none', 60_000),
59
+ 'blender-screenshot-view': verb('always', 60_000),
60
+ 'blender-read-file': verb('none', 60_000),
61
+ 'blender-write-file': verb('none', 60_000),
62
+ 'blender-list-files': verb('none'),
63
+ // THE RNA DOOR (WORK.md §Blender in the tab is Blender, "Inspection
64
+ // parity", I1). Two READS — a datablock's `bl_rna.properties` and the
65
+ // Properties context — and one WRITE. The reads present nothing, so they
66
+ // owe no derivation; `blender-rna-set` mutates the model and the session
67
+ // presents the frame, so it derives like `blender-execute`.
68
+ 'blender-rna': verb('none', 60_000),
69
+ 'blender-rna-context': verb('none', 60_000),
70
+ 'blender-rna-set': verb('always', 60_000),
71
+ // THE TREE DOOR (I3): Blender's View Layer tree, and one restriction column
72
+ // written. Same split — the read presents nothing; the column write mutates
73
+ // the model and presents, so it derives.
74
+ 'blender-outliner': verb('none', 60_000),
75
+ // THE NODE-TREE DOOR (I5). A READ, so it presents nothing and owes no
76
+ // derivation; there is deliberately no writer beside it.
77
+ 'blender-node-tree': verb('none', 60_000),
78
+ // THE UV DOOR (I5, UV Editing). A READ of one mesh's UV layout; pinning,
79
+ // unwrapping and selecting are edits and there is no writer beside it.
80
+ 'blender-uv-layout': verb('none', 60_000),
81
+ // THE RIG AND CLIP DOORS (the Timeline). Both READS — the skin binding a
82
+ // `THREE.SkinnedMesh` is built from and the action as three.js tracks — so
83
+ // neither presents and neither owes a derivation. There is deliberately no
84
+ // writer beside them: keying, moving a key and setting a range are edits,
85
+ // and the ONE write the Timeline makes (`scene.frame_current`, once on
86
+ // pause and at scrub-end) goes through `blender-rna-set`, which already
87
+ // exists and already presents.
88
+ 'blender-rig': verb('none', 60_000),
89
+ 'blender-action-clip': verb('none', 120_000),
90
+ // THE NODE VIEW'S OWN ACTIONS as a session verb — the keyboard ruling's
91
+ // `vgai.*`-command-per-action pattern, and the only way the drawer's view
92
+ // can be read or driven at all: `editor.document.*` is scoped to the active
93
+ // CENTER document by its own contract, and a utility is not one. Read-only
94
+ // over the MODEL: `look` and the view transform are inspection state, and a
95
+ // gesture that would edit is answered with the same refusal the pointer
96
+ // handlers give.
97
+ 'blender-node-view': verb('none'),
98
+ 'blender-outliner-set': verb('always', 60_000),
99
+ // A read of whether this tab HAS a session; it never starts one — and the
100
+ // one verb that answers before a project session exists.
101
+ 'blender-status': verb('none'),
102
+ };