@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,1402 @@
1
+ /**
2
+ * THE NODE EDITOR — Blender's Shader Editor, READ-ONLY, over the active
3
+ * material's RNA node tree (WORK.md §Blender in the tab is Blender,
4
+ * "Inspection parity", I5; ARCHITECTURE-CORE §Blender north star).
5
+ *
6
+ * OUR PANEL, BLENDER'S DRAWING. Blender's own UI layer — `bl_ui`, the node
7
+ * editor's C++ drawing code — is never run, ported as a UI system, or
8
+ * recorded here. It is READ as the specification of what to draw, and every
9
+ * number it specifies lives in `./blender-node-geometry.ts` with its file and
10
+ * line beside it. This module is an ordinary React component over that data.
11
+ *
12
+ * INSPECTION PARITY, NOT EDITING PARITY. Nothing here writes. A gesture that
13
+ * WOULD edit — dragging a node, dragging a link, typing into a socket's value
14
+ * — is refused BY NAME in the frame's status line rather than quietly doing
15
+ * nothing, which is the ruling's own "never a silent degrade". The only state
16
+ * this view owns is INSPECTION state: which node is looked at, and where the
17
+ * view is panned. Selecting a node here does not write `Node.select` in the
18
+ * engine (that would be a mutation the document saves); the ENGINE's own
19
+ * selection is still drawn, in Blender's own selected outline.
20
+ *
21
+ * WHY SVG AND NOT CANVAS 2D. The tree is a few dozen rounded rects, circles
22
+ * and cubics, and SVG gives three things a 2D context would have to
23
+ * reimplement: the browser's own `C` path segment for `node_link_bezier`'s
24
+ * cubic, real text layout and measurement for the header labels, and hit
25
+ * testing for free on every node and socket. The measured tree this unit was
26
+ * built against is 2 nodes and 37 sockets; a pathological 200-node tree is
27
+ * ~1,200 elements, which is inside what a browser lays out in one frame. The
28
+ * moment a tree is measured slow, the answer is a canvas with the same
29
+ * geometry module behind it — which is why the geometry lives in its own
30
+ * module and this file holds no constants.
31
+ *
32
+ * WHY IT IS THE DRAWER AND NOT A SECOND DOCUMENT. Blender's Shading workspace
33
+ * puts the Shader Editor in the AREA BELOW the 3D viewport, and this host's
34
+ * bottom group (`vgai:bottom-center`) is that area: the Model workspace hides
35
+ * it for exactly this reason, recorded in `workspace-regions.ts` — "Blender's
36
+ * modeling workspace has no timeline strip". The Shading workspace shows it
37
+ * and puts this in it.
38
+ */
39
+
40
+ import type { BlenderNode, BlenderNodeTree } from '@volter/blender-engine/browser/rna';
41
+ import { registerViewVerbs } from '@volter/editor-sdk/views';
42
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
43
+ import {
44
+ blenderNodeTree,
45
+ blenderRnaVersion,
46
+ NODE_VIEW_VERBS,
47
+ subscribeBlenderRna,
48
+ } from '../host/blender-runtime-host';
49
+ import type { NodeViewTransform } from '../src/node-view-state';
50
+ import {
51
+ nodeViewAllRequest,
52
+ nodeViewState,
53
+ nodeViewVersion,
54
+ refuseNodeViewGesture,
55
+ setNodeViewState,
56
+ subscribeNodeView,
57
+ } from '../src/node-view-state';
58
+ import nodePanelTable from './blender.node-panels.json';
59
+ import {
60
+ BASIS_RAD,
61
+ declarationAgrees,
62
+ FRAME_MARGIN,
63
+ type LaidOutNode,
64
+ type LaidOutPanel,
65
+ type LaidOutSocket,
66
+ LINK_WIDTH,
67
+ layoutHasUnreadableRow,
68
+ layoutNode,
69
+ layoutNodeWithPanels,
70
+ linkHandles,
71
+ MUTED_ALPHA_DROP,
72
+ MUTED_BODY_FACTOR,
73
+ MUTED_HEADER_FACTOR,
74
+ mutedToward,
75
+ NODE_DETAIL_ZOOM_MIN,
76
+ NODE_DY,
77
+ NODE_DYS,
78
+ NODE_FILL_PADDING,
79
+ NODE_FILL_RADIUS,
80
+ NODE_HEADER_ICON_SIZE,
81
+ NODE_MARGIN_X,
82
+ NODE_OUTLINE_RADIUS,
83
+ NODE_SOCKSIZE,
84
+ NODE_THEME,
85
+ type NodeDeclaration,
86
+ nodeBodyColor,
87
+ nodeHeaderColor,
88
+ PANEL_HEADER_BUT_PADDING,
89
+ PANEL_HEADER_MARGIN_X,
90
+ PANEL_SUB_BACK,
91
+ PANEL_SUB_BACK_ALPHA,
92
+ PANEL_TRIANGLE,
93
+ PIXEL_SIZE,
94
+ REROUTE_RADIUS,
95
+ rgbFloatsToHex,
96
+ SOCKET_OUTLINE,
97
+ SOCKET_OUTLINE_VIRTUAL,
98
+ SOCKET_OUTLINE_WIDTH,
99
+ socketColor,
100
+ socketDraws,
101
+ UI_TEXT_POINTS,
102
+ UI_UNIT_X,
103
+ } from './blender-node-geometry';
104
+
105
+ export const point = 'workspace.document';
106
+ /** Blender's own name for this editor (`rna_space.cc`'s `SPACE_NODE` item and
107
+ * the editor-type menu): "Shader Editor" for a `ShaderNodeTree`. */
108
+ export const title = 'Shader Editor';
109
+
110
+ // THE VIEW'S PRODUCT DOOR (WORK.md §The core is Code-OSS U8, ruling 1). This
111
+ // view's verbs are published ONCE, here, where the view itself is contributed:
112
+ // under the Code-OSS frame each becomes a `vgai.blender-node-view.<verb>`
113
+ // command the bridge dispatches into the view, and standalone `vgai edit` —
114
+ // which has no command service — reaches the SAME table through the session's
115
+ // `blender-node-view` verb. One table, two doors, which is why the remaining
116
+ // read-only editors (UV Editing, Animation, Texture Paint) add no session verb
117
+ // of their own: they register here instead.
118
+ // THIS LINE RUNS ONCE PER EVALUATION, and a contribution is evaluated more
119
+ // than once per session by design — the tool loader imports it as
120
+ // `?t=<version>`, so a save re-evaluates it against a registry that already
121
+ // holds the view. `registerViewVerbs` recognises the re-evaluation and the
122
+ // newer table wins; measured live 2026-09-19, before it did, the first reload
123
+ // threw and the loader reported "editor app failed to start", taking the whole
124
+ // editor down with the view.
125
+ registerViewVerbs(NODE_VIEW_VERBS);
126
+
127
+ /** The N-panel's width. `bl_ui` sizes a sidebar in `UI_UNIT_X`; the node
128
+ * editor's sidebar opens at the region default, measured in Blender 5.2 at
129
+ * ~16 units. */
130
+ const SIDEBAR_WIDTH = 16 * 20;
131
+
132
+ /** One unit of tree space per CSS px is Blender's zoom 1 — the node editor
133
+ * draws without DPI (`node_intern.hh:332`). */
134
+ const ZOOM_MIN = 0.1;
135
+ const ZOOM_MAX = 4;
136
+
137
+ type View = NodeViewTransform;
138
+
139
+ export default function BlenderNodeEditor() {
140
+ // THE TREE'S FRESHNESS IS THE TREE'S, NOT THE PRESENTED FRAME'S (ruling 3,
141
+ // 2026-09-19). This used to read the properties model's version, which moves
142
+ // on a presented frame — and collapsing a socket panel writes
143
+ // `Node.panel_states[n].is_collapsed`, which presents NOTHING, so the view
144
+ // kept drawing the old tree until the document was reopened (measured by the
145
+ // socket-panel step that shipped the panels). `blenderRnaVersion` is the RNA
146
+ // door's own, bumped by the write, and a frame bumps it too.
147
+ const version = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
148
+ const [tree, setTree] = useState<BlenderNodeTree | null>(null);
149
+ const [error, setError] = useState<string | null>(null);
150
+ const canvas = useRef<HTMLDivElement | null>(null);
151
+ const framed = useRef(false);
152
+ // THE VIEW'S STATE IS THE STORE'S (`../src/node-view-state.ts`), not this
153
+ // component's, because the session verb `blender-node-view` has to reach it:
154
+ // `editor.document.*` is scoped to the active CENTER document and a drawer
155
+ // utility is not one, so a `useState` here would be a surface nothing in the
156
+ // product could read or drive.
157
+ useSyncExternalStore(subscribeNodeView, nodeViewVersion, nodeViewVersion);
158
+ const { transform: view, looked, refusal, size } = nodeViewState();
159
+ const setView = useCallback((next: View | ((current: View) => View)): void => {
160
+ const current = nodeViewState().transform;
161
+ setNodeViewState({ transform: typeof next === 'function' ? next(current) : next });
162
+ }, []);
163
+ const setLooked = useCallback((node: string | null): void => {
164
+ setNodeViewState({ looked: node, refusal: null });
165
+ }, []);
166
+ const setSize = useCallback((next: { w: number; h: number }): void => {
167
+ const current = nodeViewState().size;
168
+ if (current.w === next.w && current.h === next.h) return;
169
+ setNodeViewState({ size: next });
170
+ }, []);
171
+ const refuse = useCallback((text: string) => {
172
+ refuseNodeViewGesture(text);
173
+ }, []);
174
+
175
+ // THE READ. The properties model already re-reads on every presented frame
176
+ // and on every selection change, so its version is exactly the signal "the
177
+ // engine moved"; taking it costs no second frame subscription.
178
+ useEffect(() => {
179
+ let live = true;
180
+ void (async () => {
181
+ try {
182
+ const answer = await blenderNodeTree();
183
+ if (!live) return;
184
+ setTree(answer);
185
+ setError(null);
186
+ } catch (thrown) {
187
+ if (!live) return;
188
+ setError(thrown instanceof Error ? thrown.message : String(thrown));
189
+ }
190
+ })();
191
+ return () => {
192
+ live = false;
193
+ };
194
+ }, [version]);
195
+
196
+ useEffect(() => {
197
+ const element = canvas.current;
198
+ if (!element) return;
199
+ const observer = new ResizeObserver(() => {
200
+ setSize({ w: element.clientWidth, h: element.clientHeight });
201
+ });
202
+ observer.observe(element);
203
+ setSize({ w: element.clientWidth, h: element.clientHeight });
204
+ return () => observer.disconnect();
205
+ }, []);
206
+
207
+ const laid = useMemo(() => layoutTree(tree), [tree]);
208
+
209
+ // PUBLISH WHAT WAS DRAWN. The parity table is read off this, so it measures
210
+ // the shipped drawing rather than re-running the same arithmetic beside it.
211
+ useEffect(() => {
212
+ setNodeViewState({
213
+ drawn: [...laid.frames, ...laid.nodes].map(({ node, laid: box, panels, fallback }) => {
214
+ const first = box.sockets.find((socket) => !socket.panelCollapsed) ?? box.sockets[0];
215
+ return {
216
+ panels: panels.length,
217
+ collapsedPanels: panels.filter((panel) => panel.collapsed).length,
218
+ panelFallback: fallback,
219
+ name: node.name,
220
+ x: box.x,
221
+ yTop: box.yTop,
222
+ yBottom: box.yBottom,
223
+ width: box.width,
224
+ height: box.yTop - box.yBottom,
225
+ sockets: box.sockets.length,
226
+ firstSocketBelowTop: first ? box.yTop - first.y : null,
227
+ header: nodeHeaderColor(node),
228
+ };
229
+ }),
230
+ });
231
+ }, [laid]);
232
+
233
+ const viewAll = useCallback(() => {
234
+ const bounds = treeBounds(laid.nodes);
235
+ if (!bounds) return;
236
+ // `NODE_OT_view_all` pads the fitted bounds; the node editor's own
237
+ // `space_node.cc` uses one `NODE_DY` of margin on each side.
238
+ const zoom = clamp(
239
+ Math.min(size.w / (bounds.w + 2 * NODE_DY), size.h / (bounds.h + 2 * NODE_DY)),
240
+ ZOOM_MIN,
241
+ ZOOM_MAX,
242
+ );
243
+ setNodeViewState({
244
+ transform: { cx: bounds.x + bounds.w / 2, cy: bounds.y - bounds.h / 2, zoom },
245
+ framedAt: Date.now(),
246
+ });
247
+ }, [laid.nodes, size.h, size.w]);
248
+
249
+ // A `blender-node-view` `view-all` — the session's Home. Framing needs the
250
+ // LAID-OUT tree, which only this component has, so the store raises an ask
251
+ // and the view answers it.
252
+ const viewAllAsk = nodeViewAllRequest();
253
+ const answered = useRef(0);
254
+ useEffect(() => {
255
+ if (viewAllAsk === answered.current) return;
256
+ answered.current = viewAllAsk;
257
+ viewAll();
258
+ }, [viewAllAsk, viewAll]);
259
+
260
+ // Blender's node editor opens on View All when a tree is first shown
261
+ // (`node_view.cc`'s `NODE_OT_view_all`, which the space runs on a fresh
262
+ // region). Once a person has panned, it never re-frames under them.
263
+ useEffect(() => {
264
+ if (framed.current || laid.nodes.length === 0 || size.w <= 1) return;
265
+ framed.current = true;
266
+ viewAll();
267
+ }, [laid.nodes.length, size.w, viewAll]);
268
+
269
+ const panning = useRef<{ x: number; y: number; cx: number; cy: number } | null>(null);
270
+ const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
271
+ // Blender pans the node editor with MMB drag (`view2d.cc`'s
272
+ // `VIEW2D_OT_pan`, bound to `MIDDLEMOUSE` in the default keymap).
273
+ if (event.button !== 1) return;
274
+ event.preventDefault();
275
+ (event.target as Element).setPointerCapture?.(event.pointerId);
276
+ panning.current = { x: event.clientX, y: event.clientY, cx: view.cx, cy: view.cy };
277
+ };
278
+ const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
279
+ const start = panning.current;
280
+ if (!start) return;
281
+ setView((current) => ({
282
+ ...current,
283
+ cx: start.cx - (event.clientX - start.x) / current.zoom,
284
+ cy: start.cy + (event.clientY - start.y) / current.zoom,
285
+ }));
286
+ };
287
+ const endPan = () => {
288
+ panning.current = null;
289
+ };
290
+ const onWheel = (event: React.WheelEvent<SVGSVGElement>) => {
291
+ event.preventDefault();
292
+ setView((current) => ({
293
+ ...current,
294
+ zoom: clamp(current.zoom * (event.deltaY < 0 ? 1.1 : 1 / 1.1), ZOOM_MIN, ZOOM_MAX),
295
+ }));
296
+ };
297
+
298
+ const lookedNode = tree?.nodes.find((node) => node.name === looked) ?? null;
299
+ const details = view.zoom > NODE_DETAIL_ZOOM_MIN;
300
+
301
+ return (
302
+ <div
303
+ data-testid="blender-node-editor"
304
+ style={{
305
+ display: 'flex',
306
+ position: 'absolute',
307
+ inset: 0,
308
+ overflow: 'hidden',
309
+ }}
310
+ >
311
+ <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, minHeight: 0 }}>
312
+ <NodeEditorHeader tree={tree} zoom={view.zoom} onViewAll={viewAll} onRefuse={refuse} />
313
+ {/* THE CANVAS BOX. Its height comes from FLEX, never from its own
314
+ content: a `height: 100%` SVG inside an auto-height parent makes
315
+ the parent's height a function of the SVG's, which is a function
316
+ of the parent's — measured live 2026-09-19, that loop settled at
317
+ ~30 px and drew the whole tree in a sliver at 31 % zoom. */}
318
+ <div ref={canvas} style={{ position: 'relative', flex: 1, minHeight: 0 }}>
319
+ <svg
320
+ role="presentation"
321
+ width="100%"
322
+ height="100%"
323
+ onPointerDown={onPointerDown}
324
+ onPointerMove={onPointerMove}
325
+ onPointerUp={endPan}
326
+ onPointerCancel={endPan}
327
+ onWheel={onWheel}
328
+ onContextMenu={(event) => {
329
+ event.preventDefault();
330
+ refuse('The node context menu adds, deletes and reconnects nodes — editing.');
331
+ }}
332
+ style={{
333
+ display: 'block',
334
+ // `draw_background_color`: TH_BACK's RGB with a hard 1.0 alpha
335
+ // (`node_draw.cc:4713-4718`).
336
+ background: NODE_THEME.background,
337
+ cursor: 'default',
338
+ // Blender's dot grid is theme `grid` #303030 at alpha ZERO
339
+ // (`userdef_default_theme.c:661`), so a stock node editor shows NO
340
+ // grid at all. Drawing one would be a mark the reference does not
341
+ // make.
342
+ }}
343
+ >
344
+ <g
345
+ transform={`translate(${size.w / 2} ${size.h / 2}) scale(${view.zoom} ${-view.zoom}) translate(${-view.cx} ${-view.cy})`}
346
+ >
347
+ {/* Frames are drawn BEHIND their children (`node_draw_nodetree`
348
+ walks frames first, `node_draw.cc:4285-4345`). */}
349
+ {laid.frames.map((frame) => (
350
+ <FrameNode key={frame.node.name} node={frame.node} laid={frame.laid} />
351
+ ))}
352
+ {laid.links.map((link) => (
353
+ <Noodle key={link.key} link={link} />
354
+ ))}
355
+ {laid.nodes.map((entry) => (
356
+ <NodeBody
357
+ key={entry.node.name}
358
+ node={entry.node}
359
+ laid={entry.laid}
360
+ panels={entry.panels}
361
+ looked={entry.node.name === looked}
362
+ details={details}
363
+ onLook={() => setLooked(entry.node.name)}
364
+ onRefuse={refuse}
365
+ />
366
+ ))}
367
+ </g>
368
+ </svg>
369
+ </div>
370
+ <StatusLine entries={laid.nodes} error={error} refusal={refusal} />
371
+ </div>
372
+ <NodePanel node={lookedNode} onRefuse={refuse} />
373
+ </div>
374
+ );
375
+ }
376
+
377
+ const HEADER_HEIGHT = 26;
378
+ const STATUS_HEIGHT = 22;
379
+
380
+ /* -------------------------------------------------------------------------- */
381
+
382
+ interface LaidLink {
383
+ readonly key: string;
384
+ readonly from: readonly [number, number];
385
+ readonly to: readonly [number, number];
386
+ readonly fromColor: string;
387
+ readonly toColor: string;
388
+ readonly muted: boolean;
389
+ readonly valid: boolean;
390
+ }
391
+
392
+ /**
393
+ * THE TRACED DECLARATIONS — `blender.node-panels.json`, generated by
394
+ * `blender-node-panels.source.mjs` from Blender's own `declare()` bodies at
395
+ * the engine's pin. 36 node types, the only ones that declare a socket panel;
396
+ * every other node's flattened item list IS its socket list, which is what
397
+ * `layoutNode` already draws.
398
+ */
399
+ const DECLARATIONS = nodePanelTable as Readonly<Record<string, NodeDeclaration>>;
400
+
401
+ interface LaidEntry {
402
+ readonly node: BlenderNode;
403
+ readonly laid: LaidOutNode;
404
+ readonly panels: readonly LaidOutPanel[];
405
+ /** Why this node's panels were NOT drawn, when it declares some. Named in
406
+ * the status line rather than silently drawn flat. */
407
+ readonly fallback: string | null;
408
+ /** The node carries a declaration LAYOUT row, whose drawn height RNA cannot
409
+ * answer (see `layoutNodeWithPanels`' header). */
410
+ readonly approximate: boolean;
411
+ }
412
+
413
+ function layoutTree(tree: BlenderNodeTree | null): {
414
+ nodes: LaidEntry[];
415
+ frames: LaidEntry[];
416
+ links: LaidLink[];
417
+ } {
418
+ if (!tree) return { nodes: [], frames: [], links: [] };
419
+ const placed = new Map<string, LaidOutNode>();
420
+ const entries = tree.nodes.map((node): LaidEntry => {
421
+ const flat = {
422
+ name: node.name,
423
+ location: node.location,
424
+ width: node.width,
425
+ collapsed: node.collapsed,
426
+ inputs: node.inputs,
427
+ outputs: node.outputs,
428
+ };
429
+ // A COLLAPSED node draws no body at all, so its panels are not a question
430
+ // (`node_update_collapsed` never walks the declaration).
431
+ const declaration = node.collapsed ? undefined : DECLARATIONS[node.idname];
432
+ if (node.panelCount > 0 && !declaration) {
433
+ const laid = layoutNode(flat);
434
+ placed.set(node.name, laid);
435
+ return {
436
+ node,
437
+ laid,
438
+ panels: [],
439
+ fallback: node.collapsed
440
+ ? null
441
+ : 'no traced declaration — the trace covers the 36 node types that declare a panel in Blender 5.2.0, and a node group’s panels are runtime data',
442
+ approximate: false,
443
+ };
444
+ }
445
+ if (declaration) {
446
+ const agrees = declarationAgrees(declaration, node.inputs, node.outputs);
447
+ if (agrees.ok) {
448
+ const laid = layoutNodeWithPanels({
449
+ ...flat,
450
+ declaration,
451
+ panelStates: node.panels,
452
+ showOptions: node.showOptions,
453
+ });
454
+ placed.set(node.name, laid);
455
+ return {
456
+ node,
457
+ laid,
458
+ panels: laid.panels,
459
+ fallback: null,
460
+ approximate: layoutHasUnreadableRow(declaration),
461
+ };
462
+ }
463
+ const laid = layoutNode(flat);
464
+ placed.set(node.name, laid);
465
+ return { node, laid, panels: [], fallback: agrees.why, approximate: false };
466
+ }
467
+ const laid = layoutNode(flat);
468
+ placed.set(node.name, laid);
469
+ return { node, laid, panels: [], fallback: null, approximate: false };
470
+ });
471
+ const links: LaidLink[] = tree.links.flatMap((link, index) => {
472
+ const from = placed.get(link.fromNode);
473
+ const to = placed.get(link.toNode);
474
+ if (!from || !to) return [];
475
+ const fromSocket = from.sockets.find((s) => s.output && s.identifier === link.fromSocket);
476
+ const toSocket = to.sockets.find((s) => !s.output && s.identifier === link.toSocket);
477
+ if (!fromSocket || !toSocket) return [];
478
+ return [
479
+ {
480
+ key: `${link.fromNode}.${link.fromSocket}→${link.toNode}.${link.toSocket}#${index}`,
481
+ from: [fromSocket.x, fromSocket.y] as const,
482
+ to: [toSocket.x, toSocket.y] as const,
483
+ fromColor: socketColor(fromSocket.type),
484
+ toColor: socketColor(toSocket.type),
485
+ muted: link.muted,
486
+ valid: link.valid,
487
+ },
488
+ ];
489
+ });
490
+ return {
491
+ nodes: entries.filter((entry) => entry.node.idname !== 'NodeFrame'),
492
+ frames: entries.filter((entry) => entry.node.idname === 'NodeFrame'),
493
+ links,
494
+ };
495
+ }
496
+
497
+ function treeBounds(
498
+ entries: readonly { laid: LaidOutNode }[],
499
+ ): { x: number; y: number; w: number; h: number } | null {
500
+ if (entries.length === 0) return null;
501
+ let minX = Number.POSITIVE_INFINITY;
502
+ let maxX = Number.NEGATIVE_INFINITY;
503
+ let minY = Number.POSITIVE_INFINITY;
504
+ let maxY = Number.NEGATIVE_INFINITY;
505
+ for (const { laid } of entries) {
506
+ minX = Math.min(minX, laid.x);
507
+ maxX = Math.max(maxX, laid.x + laid.width);
508
+ minY = Math.min(minY, laid.yBottom);
509
+ maxY = Math.max(maxY, laid.yTop);
510
+ }
511
+ return { x: minX, y: maxY, w: maxX - minX, h: maxY - minY };
512
+ }
513
+
514
+ const clamp = (value: number, low: number, high: number): number =>
515
+ Math.min(high, Math.max(low, value));
516
+
517
+ /* -------------------------------------------------------------------------- */
518
+
519
+ function NodeBody({
520
+ node,
521
+ laid,
522
+ panels,
523
+ looked,
524
+ details,
525
+ onLook,
526
+ onRefuse,
527
+ }: {
528
+ readonly node: BlenderNode;
529
+ readonly laid: LaidOutNode;
530
+ readonly panels: readonly LaidOutPanel[];
531
+ readonly looked: boolean;
532
+ readonly details: boolean;
533
+ readonly onLook: () => void;
534
+ readonly onRefuse: (text: string) => void;
535
+ }) {
536
+ // A REROUTE is one socket and nothing else (`reroute_node_prepare_for_draw`,
537
+ // `node_draw.cc:3579-3593`).
538
+ if (node.type === 'REROUTE') {
539
+ const socket = laid.sockets[0];
540
+ return (
541
+ <circle
542
+ cx={laid.x}
543
+ cy={laid.yTop}
544
+ r={REROUTE_RADIUS}
545
+ fill={socket ? socketColor(socket.type) : NODE_THEME.wireInner}
546
+ stroke={SOCKET_OUTLINE}
547
+ strokeWidth={SOCKET_OUTLINE_WIDTH}
548
+ onPointerDown={(event) => {
549
+ if (event.button === 0) onLook();
550
+ }}
551
+ />
552
+ );
553
+ }
554
+
555
+ const header = node.muted
556
+ ? mutedToward(nodeHeaderColor(node), MUTED_HEADER_FACTOR)
557
+ : nodeHeaderColor(node);
558
+ const body = node.muted
559
+ ? mutedToward(nodeBodyColor(node), MUTED_BODY_FACTOR)
560
+ : nodeBodyColor(node);
561
+ // `get_color_blend_alpha_4fv(…, -0.2)` — a muted node is drawn slightly
562
+ // transparent "so the wires inside are visible" (`node_draw.cc:2806-2814`).
563
+ const alpha = node.muted ? 1 - MUTED_ALPHA_DROP : 1;
564
+ const outline = node.selected
565
+ ? node.activeOutput || looked
566
+ ? NODE_THEME.active
567
+ : NODE_THEME.select
568
+ : NODE_THEME.outline;
569
+ const outlineOpacity = node.selected ? 1 : NODE_THEME.outlineAlpha;
570
+ const height = laid.yTop - laid.yBottom;
571
+ const label = node.label ?? node.typeLabel;
572
+
573
+ return (
574
+ <g
575
+ opacity={alpha}
576
+ onPointerDown={(event) => {
577
+ if (event.button === 0) onLook();
578
+ }}
579
+ onDoubleClick={() =>
580
+ onRefuse(`Renaming "${node.name}" writes Node.name — editing parity is not the program.`)
581
+ }
582
+ style={{ cursor: 'default' }}
583
+ >
584
+ {laid.collapsed ? (
585
+ // The collapsed node is ONE rounded box in the HEADER colour
586
+ // (`node_draw.cc:3258-3276`), not a header over a body.
587
+ <rect
588
+ x={laid.x - NODE_FILL_PADDING}
589
+ y={-laid.yTop - NODE_FILL_PADDING}
590
+ width={laid.width + 2 * NODE_FILL_PADDING}
591
+ height={height + 2 * NODE_FILL_PADDING}
592
+ rx={NODE_FILL_RADIUS}
593
+ fill={header}
594
+ transform="scale(1 -1)"
595
+ />
596
+ ) : (
597
+ <>
598
+ {/* Body: `{xmin-p, xmax+p, ymin-p, ymax-NODE_DY+p}`, bottom corners
599
+ rounded (`node_draw.cc:3167-3180`). */}
600
+ <rect
601
+ x={laid.x - NODE_FILL_PADDING}
602
+ y={-(laid.yTop - NODE_DY) - NODE_FILL_PADDING}
603
+ width={laid.width + 2 * NODE_FILL_PADDING}
604
+ height={height - NODE_DY + 2 * NODE_FILL_PADDING}
605
+ rx={NODE_FILL_RADIUS}
606
+ fill={body}
607
+ transform="scale(1 -1)"
608
+ />
609
+ {/* Header: `{xmin-p, xmax+p, ymax-NODE_DY-p, ymax+p}`, top corners
610
+ rounded (`node_draw.cc:2925-2935`). Both are drawn as full
611
+ round-rects and the outline covers the seam, which is what
612
+ Blender's two corner-set calls amount to at this radius. */}
613
+ <rect
614
+ x={laid.x - NODE_FILL_PADDING}
615
+ y={-laid.yTop - NODE_FILL_PADDING}
616
+ width={laid.width + 2 * NODE_FILL_PADDING}
617
+ height={NODE_DY + 2 * NODE_FILL_PADDING}
618
+ rx={NODE_FILL_RADIUS}
619
+ fill={header}
620
+ transform="scale(1 -1)"
621
+ />
622
+ <rect
623
+ x={laid.x - NODE_FILL_PADDING}
624
+ y={-(laid.yTop - NODE_DY) - NODE_FILL_PADDING}
625
+ width={laid.width + 2 * NODE_FILL_PADDING}
626
+ height={BASIS_RAD}
627
+ fill={header}
628
+ transform="scale(1 -1)"
629
+ />
630
+ {/* PANEL BACKGROUNDS FIRST — `node_draw_panels_background` runs
631
+ before every other node element "so other node elements can be
632
+ rendered on top" (`node_draw.cc:1913-1914, 3183`). */}
633
+ {panels.map((panel) => (
634
+ <PanelBackground key={`bg:${panel.name}`} panel={panel} laid={laid} />
635
+ ))}
636
+ </>
637
+ )}
638
+ {/* Outline: the node rect grown by `U.pixelsize`, radius BASIS_RAD + 1
639
+ (`node_draw.cc:3184-3211`). */}
640
+ <rect
641
+ x={laid.x - PIXEL_SIZE}
642
+ y={-laid.yTop - PIXEL_SIZE}
643
+ width={laid.width + 2 * PIXEL_SIZE}
644
+ height={height + 2 * PIXEL_SIZE}
645
+ rx={NODE_OUTLINE_RADIUS}
646
+ fill="none"
647
+ stroke={outline}
648
+ strokeOpacity={outlineOpacity}
649
+ strokeWidth={PIXEL_SIZE}
650
+ transform="scale(1 -1)"
651
+ />
652
+ {/* The name: a Label button at `xmin + NODE_MARGIN_X`, height NODE_DY
653
+ (`node_draw.cc:3117-3128`), at `UI_DEFAULT_TEXT_POINTS`. */}
654
+ <text
655
+ x={laid.x + NODE_MARGIN_X}
656
+ y={-(laid.yTop - NODE_DY / 2)}
657
+ transform="scale(1 -1)"
658
+ fill={NODE_THEME.text}
659
+ fontSize={UI_TEXT_POINTS}
660
+ dominantBaseline="central"
661
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
662
+ >
663
+ {label}
664
+ </text>
665
+ {details &&
666
+ laid.sockets.map((socket) => (
667
+ <Socket
668
+ key={`${socket.output ? 'o' : 'i'}:${socket.identifier}`}
669
+ socket={socket}
670
+ node={laid}
671
+ onRefuse={onRefuse}
672
+ />
673
+ ))}
674
+ {/* PANEL HEADERS last, over the sockets their collapsed rows carry —
675
+ `node_draw_panels` is called after the node's own buttons
676
+ (`node_draw.cc:3219`). */}
677
+ {details &&
678
+ panels.map((panel) => (
679
+ <PanelHeader key={`hd:${panel.name}`} panel={panel} laid={laid} onRefuse={onRefuse} />
680
+ ))}
681
+ </g>
682
+ );
683
+ }
684
+
685
+ /**
686
+ * A PANEL'S CONTENT BAND — `node_draw_panels_background`
687
+ * (`node_draw.cc:1914-1957`): `TH_PANEL_SUB_BACK` at 1.5x its own alpha, the
688
+ * full node width, `BASIS_RAD` with NO corners set, from the content's
689
+ * `max_y` down to its `min_y`. The FINAL panel's band instead runs to the
690
+ * node's bottom edge with the two bottom corners rounded, and is painted
691
+ * `depth + 1` times — a literal repeat, which at this alpha is what makes a
692
+ * nested final panel read darker.
693
+ */
694
+ function PanelBackground({
695
+ panel,
696
+ laid,
697
+ }: {
698
+ readonly panel: LaidOutPanel;
699
+ readonly laid: LaidOutNode;
700
+ }) {
701
+ if (panel.contentTop === null || panel.contentBottom === null) return null;
702
+ const bands: { y: number; height: number; radius: number; times: number }[] = [
703
+ {
704
+ y: panel.contentTop,
705
+ height: panel.contentTop - panel.contentBottom,
706
+ radius: 0,
707
+ times: 1,
708
+ },
709
+ ];
710
+ if (panel.fillsNodeEnd) {
711
+ bands.push({
712
+ y: panel.contentBottom,
713
+ height: panel.contentBottom - laid.yBottom,
714
+ radius: BASIS_RAD,
715
+ times: panel.depth + 1,
716
+ });
717
+ }
718
+ return (
719
+ <g style={{ pointerEvents: 'none' }}>
720
+ {bands.flatMap((band, index) =>
721
+ Array.from({ length: band.times }, (_unused, pass) => (
722
+ <rect
723
+ key={`${index}:${pass}`}
724
+ x={laid.x}
725
+ y={-band.y}
726
+ width={laid.width}
727
+ height={Math.max(band.height, 0)}
728
+ rx={band.radius}
729
+ fill={PANEL_SUB_BACK}
730
+ fillOpacity={PANEL_SUB_BACK_ALPHA}
731
+ transform="scale(1 -1)"
732
+ />
733
+ )),
734
+ )}
735
+ </g>
736
+ );
737
+ }
738
+
739
+ /**
740
+ * A PANEL'S HEADER ROW — `node_draw_panels` (`node_draw.cc:1986-2102`).
741
+ *
742
+ * The header itself has NO fill: Blender draws an invisible `ButToggle` over
743
+ * the whole band (`:2030-2048`), then the collapse triangle at
744
+ * `xmin + NODE_MARGIN_X/3`, `U.widget_unit * 0.8` square and centred on
745
+ * `header_center_y`, then — if the panel declares a toggle socket that is not
746
+ * linked — a `UI_UNIT_X`-wide checkbox, then the label. The triangle's own
747
+ * geometry is {@link PANEL_TRIANGLE}, read off Blender's icon sources.
748
+ *
749
+ * CLICKING IT IS AN EDIT and is refused by name: the callback is
750
+ * `panel_state->flag ^= NODE_PANEL_COLLAPSED` followed by
751
+ * `BKE_main_ensure_invariants` (`node_draw.cc:1903-1911`), which is a write
752
+ * to the node tree the document would save.
753
+ */
754
+ function PanelHeader({
755
+ panel,
756
+ laid,
757
+ onRefuse,
758
+ }: {
759
+ readonly panel: LaidOutPanel;
760
+ readonly laid: LaidOutNode;
761
+ readonly onRefuse: (text: string) => void;
762
+ }) {
763
+ const size = NODE_HEADER_ICON_SIZE;
764
+ const scale = size / PANEL_TRIANGLE.cell;
765
+ const cx = laid.x + PANEL_HEADER_MARGIN_X + size / 2;
766
+ const cy = panel.centerY;
767
+ const reach = PANEL_TRIANGLE.reach * scale;
768
+ const spread = PANEL_TRIANGLE.spread * scale;
769
+ // `ICON_RIGHTARROW` when collapsed, `ICON_DOWNARROW_HLT` when open
770
+ // (`node_draw.cc:2037-2043`) — the same chevron, turned a quarter.
771
+ const chevron = panel.collapsed
772
+ ? `M ${cx - reach / 2} ${-(cy + spread)} L ${cx + reach / 2} ${-cy} L ${cx - reach / 2} ${-(cy - spread)}`
773
+ : `M ${cx - spread} ${-(cy + reach / 2)} L ${cx} ${-(cy - reach / 2)} L ${cx + spread} ${-(cy + reach / 2)}`;
774
+ const labelX =
775
+ laid.x +
776
+ PANEL_HEADER_MARGIN_X +
777
+ size +
778
+ PANEL_HEADER_BUT_PADDING +
779
+ (panel.toggle && !panel.toggle.linked ? UI_UNIT_X : 0);
780
+ return (
781
+ <g
782
+ onPointerDown={(event) => {
783
+ if (event.button !== 0) return;
784
+ event.stopPropagation();
785
+ onRefuse(
786
+ `${panel.collapsed ? 'Opening' : 'Collapsing'} "${panel.name}" writes the panel's is_collapsed — editing parity is not the program.`,
787
+ );
788
+ }}
789
+ style={{ cursor: 'default' }}
790
+ >
791
+ {/* The whole band is the button's hit area (`:2030-2048`), and it draws
792
+ nothing: `EmbossType::None`. */}
793
+ <rect
794
+ x={laid.x}
795
+ y={-(cy + NODE_DYS)}
796
+ width={laid.width}
797
+ height={2 * NODE_DYS}
798
+ fill="transparent"
799
+ transform="scale(1 -1)"
800
+ />
801
+ <path
802
+ d={chevron}
803
+ fill="none"
804
+ stroke={NODE_THEME.text}
805
+ strokeWidth={PANEL_TRIANGLE.stroke * scale}
806
+ strokeLinecap="round"
807
+ strokeLinejoin="round"
808
+ transform="scale(1 -1)"
809
+ style={{ pointerEvents: 'none' }}
810
+ />
811
+ {panel.toggle && !panel.toggle.linked && (
812
+ // `uiDefButR(… Checkbox …, "default_value")` (`:2059-2076`), drawn as
813
+ // `wcol_option`'s box: the value is READ here and written nowhere.
814
+ <rect
815
+ x={laid.x + PANEL_HEADER_MARGIN_X + size + PANEL_HEADER_BUT_PADDING}
816
+ y={-(cy + NODE_DYS / 2)}
817
+ width={NODE_DYS}
818
+ height={NODE_DYS}
819
+ rx={0.2 * NODE_DYS}
820
+ fill={panel.toggle.value === true ? '#4772b3' : '#545454'}
821
+ transform="scale(1 -1)"
822
+ style={{ pointerEvents: 'none' }}
823
+ />
824
+ )}
825
+ <text
826
+ x={labelX}
827
+ y={-cy}
828
+ transform="scale(1 -1)"
829
+ fill={NODE_THEME.text}
830
+ fontSize={UI_TEXT_POINTS}
831
+ dominantBaseline="central"
832
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
833
+ >
834
+ {panel.name}
835
+ </text>
836
+ </g>
837
+ );
838
+ }
839
+
840
+ /** Blender's socket mark: a 10×10 box (radius `NODE_SOCKSIZE`) in the socket
841
+ * TYPE's colour with a 1 px `TH_WIRE` outline, shaped by `display_shape`
842
+ * (`node_draw.cc:1824-1857`; the shapes themselves are SDFs in
843
+ * `gpu_shader_2D_node_socket*.glsl`, which is outside the sparse checkout —
844
+ * the silhouettes below are drawn from the enum's own names). */
845
+ function Socket({
846
+ socket,
847
+ node,
848
+ onRefuse,
849
+ }: {
850
+ readonly socket: LaidOutSocket;
851
+ readonly node: LaidOutNode;
852
+ readonly onRefuse: (text: string) => void;
853
+ }) {
854
+ const fill = socketColor(socket.type);
855
+ const stroke = socket.type === 'CUSTOM' ? SOCKET_OUTLINE_VIRTUAL : SOCKET_OUTLINE;
856
+ const r = NODE_SOCKSIZE;
857
+ const base = socket.shape.replace('_DOT', '');
858
+ const dot = socket.shape.endsWith('_DOT');
859
+ const mark =
860
+ base === 'SQUARE' ? (
861
+ <rect
862
+ x={socket.x - r}
863
+ y={-socket.y - r}
864
+ width={2 * r}
865
+ height={2 * r}
866
+ fill={fill}
867
+ stroke={stroke}
868
+ strokeWidth={SOCKET_OUTLINE_WIDTH}
869
+ transform="scale(1 -1)"
870
+ />
871
+ ) : base === 'DIAMOND' ? (
872
+ <polygon
873
+ points={`${socket.x},${-socket.y - r} ${socket.x + r},${-socket.y} ${socket.x},${-socket.y + r} ${socket.x - r},${-socket.y}`}
874
+ fill={fill}
875
+ stroke={stroke}
876
+ strokeWidth={SOCKET_OUTLINE_WIDTH}
877
+ transform="scale(1 -1)"
878
+ />
879
+ ) : (
880
+ <circle
881
+ cx={socket.x}
882
+ cy={-socket.y}
883
+ r={r}
884
+ fill={fill}
885
+ stroke={stroke}
886
+ strokeWidth={SOCKET_OUTLINE_WIDTH}
887
+ transform="scale(1 -1)"
888
+ />
889
+ );
890
+
891
+ return (
892
+ <g
893
+ onPointerDown={(event) => {
894
+ if (event.button !== 0) return;
895
+ event.stopPropagation();
896
+ onRefuse(
897
+ `Dragging from "${socket.name}" would make a link — editing parity is not the program.`,
898
+ );
899
+ }}
900
+ >
901
+ {mark}
902
+ {dot && (
903
+ <circle cx={socket.x} cy={-socket.y} r={r * 0.35} fill={stroke} transform="scale(1 -1)" />
904
+ )}
905
+ {/* A socket inside a COLLAPSED panel draws its MARK on the panel's
906
+ header row and nothing else — `mark_sockets_collapsed_recursive`
907
+ moves the location and sets `SOCK_PANEL_COLLAPSED`
908
+ (`node_draw.cc:1007-1032`), and the row it would have had was never
909
+ flattened, so there is no name and no widget to draw. */}
910
+ {!node.collapsed && !socket.panelCollapsed && (
911
+ <SocketRow socket={socket} node={node} onRefuse={onRefuse} />
912
+ )}
913
+ </g>
914
+ );
915
+ }
916
+
917
+ /**
918
+ * A SOCKET'S ROW — its name, and for an UNLINKED input with a value, the
919
+ * inline widget Blender draws in its place.
920
+ *
921
+ * `node_update_basis_socket` lays the row's widget out at `locx + NODE_DYS`,
922
+ * `NODE_WIDTH(node) - NODE_DY` wide (`node_draw.cc:498-506`) — inset by
923
+ * `NODE_DYS` = 10 on each side, not `NODE_MARGIN_X` — one `UI_UNIT_Y` tall,
924
+ * `LayoutAlign::Expand` for an input and `::Right` for an output (`:518`,
925
+ * `:529`), which is why an output's name is right-aligned against the node's
926
+ * edge and an input's widget fills the row.
927
+ *
928
+ * WHY THE WIDGET IS DRAWN HERE AND NOT BORROWED FROM THE PROPERTIES RAIL.
929
+ * This surface is a ZOOMING tree-space canvas: a DOM control would need a
930
+ * `foreignObject` per socket and would not scale with the view transform, so
931
+ * it would stop being the same drawing at any zoom but 1. The Properties
932
+ * rail's widgets are reused where they belong — the N-panel, which is
933
+ * ordinary unzoomed DOM.
934
+ */
935
+ function SocketRow({
936
+ socket,
937
+ node,
938
+ onRefuse,
939
+ }: {
940
+ readonly socket: LaidOutSocket;
941
+ readonly node: LaidOutNode;
942
+ readonly onRefuse: (text: string) => void;
943
+ }) {
944
+ const inset = NODE_DYS;
945
+ const left = node.x + inset;
946
+ const right = node.x + node.width - inset;
947
+ const centre = -(socket.rowTop - NODE_DY / 2);
948
+ const drawsValue = !socket.output && !socket.linked && !socket.hideValue && socket.value !== null;
949
+
950
+ if (!drawsValue) {
951
+ return (
952
+ <text
953
+ x={socket.output ? right : left}
954
+ y={centre}
955
+ transform="scale(1 -1)"
956
+ textAnchor={socket.output ? 'end' : 'start'}
957
+ fill={NODE_THEME.text}
958
+ fontSize={UI_TEXT_POINTS}
959
+ dominantBaseline="central"
960
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
961
+ >
962
+ {socket.name}
963
+ </text>
964
+ );
965
+ }
966
+
967
+ const isColour = Array.isArray(socket.value) && socket.value.length >= 3;
968
+ return (
969
+ <g
970
+ onPointerDown={(event) => {
971
+ if (event.button !== 0) return;
972
+ event.stopPropagation();
973
+ onRefuse(
974
+ `Changing "${socket.name}" writes the socket's default_value — editing parity is not the program.`,
975
+ );
976
+ }}
977
+ style={{ cursor: 'default' }}
978
+ >
979
+ {/* `wcol_num`/`wcol_numslider`'s field: the widget colour at the theme's
980
+ own roundness (`interface_widgets.cc:3188`, `rad = roundness *
981
+ U.widget_unit`; `.wcol_num.roundness` is 0.2 in the shipped theme,
982
+ the same 4 px BASIS_RAD works out to). */}
983
+ <rect
984
+ x={left}
985
+ y={centre - NODE_DY / 2 + 1}
986
+ width={right - left}
987
+ height={NODE_DY - 2}
988
+ rx={0.2 * NODE_DY}
989
+ fill="#545454"
990
+ transform="scale(1 -1)"
991
+ />
992
+ {/* THE SLIDER'S FILL. A scalar socket with a bounded SOFT range is drawn
993
+ by Blender as `UI_BTYPE_NUM_SLIDER`, whose back is filled to the
994
+ value's proportion of that range (`widget_numslider`,
995
+ `interface_widgets.cc`) — which is why Roughness at 0.5 reads as
996
+ half-filled at a glance and ours read as a text box (sighted read,
997
+ 2026-09-20). No range means no fill, which is also what Blender does
998
+ for an unbounded number. The fill is clipped to the field's own
999
+ rounded rect so a full-value slider does not square off its corners. */}
1000
+ {sliderFraction(socket) !== null && (
1001
+ <>
1002
+ <clipPath id={`sl-${socket.identifier}`}>
1003
+ <rect
1004
+ x={left}
1005
+ y={centre - NODE_DY / 2 + 1}
1006
+ width={right - left}
1007
+ height={NODE_DY - 2}
1008
+ rx={0.2 * NODE_DY}
1009
+ />
1010
+ </clipPath>
1011
+ <rect
1012
+ x={left}
1013
+ y={centre - NODE_DY / 2 + 1}
1014
+ width={(right - left) * (sliderFraction(socket) ?? 0)}
1015
+ height={NODE_DY - 2}
1016
+ fill={NODE_THEME.sliderFill}
1017
+ clipPath={`url(#sl-${socket.identifier})`}
1018
+ transform="scale(1 -1)"
1019
+ />
1020
+ </>
1021
+ )}
1022
+ <text
1023
+ x={left + 6}
1024
+ y={centre}
1025
+ transform="scale(1 -1)"
1026
+ fill={NODE_THEME.text}
1027
+ fontSize={UI_TEXT_POINTS}
1028
+ dominantBaseline="central"
1029
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
1030
+ >
1031
+ {socket.name}
1032
+ </text>
1033
+ {isColour ? (
1034
+ <rect
1035
+ x={right - 34}
1036
+ y={centre - NODE_DY / 2 + 3}
1037
+ width={30}
1038
+ height={NODE_DY - 6}
1039
+ rx={2}
1040
+ fill={rgbFloatsToHex(socket.value as readonly number[])}
1041
+ transform="scale(1 -1)"
1042
+ />
1043
+ ) : (
1044
+ <text
1045
+ x={right - 6}
1046
+ y={centre}
1047
+ transform="scale(1 -1)"
1048
+ textAnchor="end"
1049
+ fill={NODE_THEME.text}
1050
+ fontSize={UI_TEXT_POINTS}
1051
+ dominantBaseline="central"
1052
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
1053
+ >
1054
+ {formatSocketValue(socket.value)}
1055
+ </text>
1056
+ )}
1057
+ </g>
1058
+ );
1059
+ }
1060
+
1061
+ /**
1062
+ * How far along its soft range a scalar socket sits, or `null` when it is not
1063
+ * a slider at all. Blender's own test is the property's soft range being
1064
+ * bounded (`ui_but_is_slider` → `UI_BTYPE_NUM_SLIDER`); the engine reports the
1065
+ * pair only for a bounded scalar, so the presence of the pair IS the test.
1066
+ */
1067
+ function sliderFraction(socket: LaidOutSocket): number | null {
1068
+ const { softMin, softMax, value } = socket;
1069
+ if (softMin === undefined || softMax === undefined) return null;
1070
+ if (typeof value !== 'number' || softMax <= softMin) return null;
1071
+ return Math.min(1, Math.max(0, (value - softMin) / (softMax - softMin)));
1072
+ }
1073
+
1074
+ function formatSocketValue(value: LaidOutSocket['value']): string {
1075
+ if (value === null) return '';
1076
+ if (typeof value === 'boolean') return value ? 'On' : 'Off';
1077
+ if (typeof value === 'number') return value.toFixed(3);
1078
+ if (typeof value === 'string') return value;
1079
+ return value.map((channel) => channel.toFixed(2)).join(', ');
1080
+ }
1081
+
1082
+ /** A noodle. `node_link_bezier_points`'s two handles, drawn as one cubic with
1083
+ * `TH_WIRE` beneath it as the outline (`drawnode.cc:2309`) and the socket
1084
+ * colours through it when Blender's wire-colour overlay is on — which is
1085
+ * `SpaceNodeOverlay.show_wire_color` (`rna_space.cc:8385-8386`), a space
1086
+ * setting this engine has no node editor to carry, so the inner colour is
1087
+ * `TH_WIRE_INNER` exactly as a node editor with the overlay off draws it. */
1088
+ function Noodle({ link }: { readonly link: LaidLink }) {
1089
+ const [h1, h2] = linkHandles(link.from, link.to);
1090
+ const d = `M ${link.from[0]} ${link.from[1]} C ${h1[0]} ${h1[1]}, ${h2[0]} ${h2[1]}, ${link.to[0]} ${link.to[1]}`;
1091
+ const colour = link.valid ? NODE_THEME.wireInner : '#ff2020';
1092
+ return (
1093
+ <g style={{ pointerEvents: 'none' }}>
1094
+ <path d={d} fill="none" stroke={NODE_THEME.wire} strokeWidth={LINK_WIDTH + 2 * PIXEL_SIZE} />
1095
+ <path
1096
+ d={d}
1097
+ fill="none"
1098
+ stroke={colour}
1099
+ strokeWidth={LINK_WIDTH}
1100
+ strokeDasharray={link.muted ? '10 10' : undefined}
1101
+ strokeOpacity={link.muted ? NODE_THEME.dashAlpha : 1}
1102
+ />
1103
+ </g>
1104
+ );
1105
+ }
1106
+
1107
+ /** A frame: its own colour at `TH_NODE_FRAME`'s alpha, radius `BASIS_RAD` with
1108
+ * no 0.5 padding (`frame_node_draw_background`, `node_draw.cc:3749-3772`),
1109
+ * and a centred label `0.5 * margin_top + 0.35 * label_size` below the top
1110
+ * (`node_draw.cc:3474-3484, 3699-3703`). */
1111
+ function FrameNode({ node, laid }: { readonly node: BlenderNode; readonly laid: LaidOutNode }) {
1112
+ const height = Math.max(laid.yTop - laid.yBottom, FRAME_MARGIN * 2);
1113
+ return (
1114
+ <g style={{ pointerEvents: 'none' }}>
1115
+ <rect
1116
+ x={laid.x}
1117
+ y={-laid.yTop}
1118
+ width={laid.width}
1119
+ height={height}
1120
+ rx={BASIS_RAD}
1121
+ fill={node.useCustomColor ? rgbFloatsToHex(node.color) : NODE_THEME.frame}
1122
+ fillOpacity={NODE_THEME.frameAlpha}
1123
+ transform="scale(1 -1)"
1124
+ />
1125
+ {node.label && (
1126
+ <text
1127
+ x={laid.x + laid.width / 2}
1128
+ y={-(laid.yTop - 25)}
1129
+ transform="scale(1 -1)"
1130
+ textAnchor="middle"
1131
+ fill={NODE_THEME.text}
1132
+ fontSize={20}
1133
+ >
1134
+ {node.label}
1135
+ </text>
1136
+ )}
1137
+ </g>
1138
+ );
1139
+ }
1140
+
1141
+ /* -------------------------------------------------------------------------- */
1142
+
1143
+ /**
1144
+ * THE HEADER, per `bl_ui/space_node.py:41-260` — read as a specification and
1145
+ * drawn READ-ONLY, which is where it diverges from Blender by design: a
1146
+ * control that would WRITE is drawn as what it reads and refuses the click by
1147
+ * name. What the shader path's header carries, in order: the tree-type well
1148
+ * (`template_header`, `:55`), the shader-type selector (`snode.shader_type`,
1149
+ * `:62`), the material slot popover and the material ID (`:79-93`), the pin
1150
+ * (`:222-224`), the breadcrumb parent (`:228-230`), snapping
1151
+ * (`tool_settings.use_snap_node`, `:249-252`) and the overlays popover
1152
+ * (`:254-260`).
1153
+ *
1154
+ * There is deliberately NO "Use Nodes" checkbox: measured in the source, 5.2
1155
+ * draws it only for Line Style (`:119`) and Texture (`:133`) trees, never for
1156
+ * a material's.
1157
+ */
1158
+ function NodeEditorHeader({
1159
+ tree,
1160
+ zoom,
1161
+ onViewAll,
1162
+ onRefuse,
1163
+ }: {
1164
+ readonly tree: BlenderNodeTree | null;
1165
+ readonly zoom: number;
1166
+ readonly onViewAll: () => void;
1167
+ readonly onRefuse: (text: string) => void;
1168
+ }) {
1169
+ return (
1170
+ <div
1171
+ style={{
1172
+ display: 'flex',
1173
+ alignItems: 'center',
1174
+ gap: 'var(--vgai-space-2)',
1175
+ height: HEADER_HEIGHT,
1176
+ padding: '0 var(--vgai-space-2)',
1177
+ background: NODE_THEME.background,
1178
+ color: NODE_THEME.text,
1179
+ font: `${UI_TEXT_POINTS}px inherit`,
1180
+ borderBottom: '1px solid #161616',
1181
+ }}
1182
+ >
1183
+ <span data-testid="node-editor-tree-type" style={{ opacity: 0.85 }}>
1184
+ {tree?.typeLabel ?? 'Shader Editor'}
1185
+ </span>
1186
+ <span style={{ opacity: 0.4 }}>·</span>
1187
+ <span data-testid="node-editor-material">{tree?.material ?? 'No material'}</span>
1188
+ <span style={{ flex: 1 }} />
1189
+ <span style={{ opacity: 0.6 }}>{Math.round(zoom * 100)}%</span>
1190
+ <button
1191
+ type="button"
1192
+ className="vgai-btn"
1193
+ data-variant="ghost"
1194
+ data-size="sm"
1195
+ onClick={onViewAll}
1196
+ data-testid="node-editor-view-all"
1197
+ >
1198
+ View All
1199
+ </button>
1200
+ <button
1201
+ type="button"
1202
+ className="vgai-btn"
1203
+ data-variant="ghost"
1204
+ data-size="sm"
1205
+ data-testid="node-editor-use-nodes"
1206
+ onClick={() =>
1207
+ onRefuse('Use Nodes writes Material.use_nodes — editing parity is not the program.')
1208
+ }
1209
+ >
1210
+ Use Nodes
1211
+ </button>
1212
+ </div>
1213
+ );
1214
+ }
1215
+
1216
+ /**
1217
+ * THE STATUS LINE — where a refusal lands, and where anything this view
1218
+ * cannot draw is named rather than silently skipped.
1219
+ *
1220
+ * THE PANEL GAP IS CLOSED, and what is left of it is stated here rather than
1221
+ * hidden. A node's socket-panel MEMBERSHIP is traced from Blender's own
1222
+ * `declare()` bodies (`blender.node-panels.json`) and its COLLAPSED STATE is
1223
+ * read live from `Node.panel_states`, so a Principled BSDF draws the eight
1224
+ * collapsed panel rows Blender draws. Two things still fall back, and both
1225
+ * say so by name: a node whose LIVE socket list disagrees with the traced
1226
+ * declaration (the ruling's own condition), and a node the trace does not
1227
+ * cover at all — a node GROUP, whose panels are its tree interface's runtime
1228
+ * data rather than a declaration.
1229
+ */
1230
+ function StatusLine({
1231
+ entries,
1232
+ error,
1233
+ refusal,
1234
+ }: {
1235
+ readonly entries: readonly LaidEntry[];
1236
+ readonly error: string | null;
1237
+ readonly refusal: string | null;
1238
+ }) {
1239
+ const fell = entries.filter((entry) => entry.fallback !== null);
1240
+ const approximate = entries.filter((entry) => entry.approximate);
1241
+ const panels = entries.reduce((sum, entry) => sum + entry.panels.length, 0);
1242
+ const message = error
1243
+ ? error
1244
+ : refusal
1245
+ ? refusal
1246
+ : fell.length > 0
1247
+ ? `${fell.map((entry) => `"${entry.node.name}" draws its sockets flat: ${entry.fallback}`).join('; ')}.`
1248
+ : approximate.length > 0
1249
+ ? `${approximate.map((entry) => `"${entry.node.name}"`).join(', ')} ${approximate.length === 1 ? 'carries a' : 'carry'} declaration LAYOUT row, whose drawn height RNA cannot answer; one UI unit is reserved for it.`
1250
+ : panels > 0
1251
+ ? `${panels} socket panel${panels === 1 ? '' : 's'} drawn from Blender's own declarations. Read-only: this view inspects the node tree and never writes it.`
1252
+ : 'Read-only: this view inspects the node tree and never writes it.';
1253
+ return (
1254
+ <div
1255
+ data-testid="node-editor-status"
1256
+ style={{
1257
+ flex: '0 0 auto',
1258
+ height: STATUS_HEIGHT,
1259
+ display: 'flex',
1260
+ alignItems: 'center',
1261
+ padding: '0 var(--vgai-space-2)',
1262
+ background: NODE_THEME.background,
1263
+ color: error || refusal ? '#ffa028' : '#888888',
1264
+ font: `${UI_TEXT_POINTS}px inherit`,
1265
+ whiteSpace: 'nowrap',
1266
+ overflow: 'hidden',
1267
+ textOverflow: 'ellipsis',
1268
+ }}
1269
+ >
1270
+ {message}
1271
+ </div>
1272
+ );
1273
+ }
1274
+
1275
+ /**
1276
+ * THE N-PANEL'S NODE TAB — `NODE_PT_active_node_generic`
1277
+ * (`bl_ui/space_node.py:800-841`), in its own order: `name` (`:819`), `label`
1278
+ * (`:820`), the Color heading with `use_custom_color` and `color`
1279
+ * (`:822-834`), then `show_options` (`:837`) and `mute` (`:838`). Blender
1280
+ * 5.2 has no separate `NODE_PT_active_node_color`; the colour controls are
1281
+ * that panel's own sub-column.
1282
+ *
1283
+ * `NODE_PT_active_node_properties` (`:844-857`) is ONE call —
1284
+ * `layout.template_node_inputs(node)` — and that template is C: it draws the
1285
+ * same per-socket widgets the node body draws. The node's own inputs are
1286
+ * listed here with their values for exactly that reason, which is the same
1287
+ * fact through the same door.
1288
+ */
1289
+ function NodePanel({
1290
+ node,
1291
+ onRefuse,
1292
+ }: {
1293
+ readonly node: BlenderNode | null;
1294
+ readonly onRefuse: (text: string) => void;
1295
+ }) {
1296
+ return (
1297
+ <aside
1298
+ data-testid="node-editor-sidebar"
1299
+ style={{
1300
+ width: SIDEBAR_WIDTH,
1301
+ flex: '0 0 auto',
1302
+ overflow: 'auto',
1303
+ // THE SIDEBAR IS PART OF THE NODE EDITOR'S AREA, so it wears the
1304
+ // space's own `TH_BACK` like the header and the canvas do. It used to
1305
+ // paint `--vgai-color-surface-panel`, which is the DOCK's grey
1306
+ // (#303030 under the Blender look): Blender fills every region of a
1307
+ // node editor area with `TH_BACK` and draws panels ON it
1308
+ // (`ED_region_panels`), so the grey was this view wearing its
1309
+ // container's colour. MEASURED 2026-09-19 (the frame walk, beat 5):
1310
+ // at (600,500) — inside the Shader Editor's pane, which a 320px
1311
+ // sidebar had squeezed the canvas out of — the read was #303030 where
1312
+ // the beat named #1a1a1a. The frame's `vgai.nodeEditor.background`
1313
+ // theme colour carries the same traced value for the workbench's own
1314
+ // painting; this is the same number, from the same trace, for the
1315
+ // pixels we paint ourselves.
1316
+ background: NODE_THEME.background,
1317
+ color: NODE_THEME.text,
1318
+ font: `${UI_TEXT_POINTS}px inherit`,
1319
+ borderLeft: '1px solid #161616',
1320
+ padding: 'var(--vgai-space-2)',
1321
+ }}
1322
+ >
1323
+ <div style={{ opacity: 0.6, marginBottom: 'var(--vgai-space-2)' }}>Node</div>
1324
+ {!node ? (
1325
+ // Blender's panel `poll` is `context.active_node is not None`
1326
+ // (`space_node.py:806-808`): with no active node the tab draws nothing.
1327
+ <div style={{ opacity: 0.5 }}>No node looked at.</div>
1328
+ ) : (
1329
+ <>
1330
+ <Row label="Name" value={node.name} onRefuse={onRefuse} />
1331
+ <Row label="Label" value={node.label ?? ''} onRefuse={onRefuse} />
1332
+ <Row
1333
+ label="Color"
1334
+ value={node.useCustomColor ? rgbFloatsToHex(node.color) : 'Off'}
1335
+ onRefuse={onRefuse}
1336
+ swatch={node.useCustomColor ? rgbFloatsToHex(node.color) : undefined}
1337
+ />
1338
+ <Row label="Mute" value={node.muted ? 'On' : 'Off'} onRefuse={onRefuse} />
1339
+ <div
1340
+ style={{
1341
+ opacity: 0.6,
1342
+ margin: 'var(--vgai-space-3) 0 var(--vgai-space-1)',
1343
+ }}
1344
+ >
1345
+ Properties
1346
+ </div>
1347
+ {node.inputs.filter(socketDraws).map((socket) => (
1348
+ <Row
1349
+ key={socket.identifier}
1350
+ label={socket.label ?? socket.name}
1351
+ value={socket.linked ? 'Linked' : formatSocketValue(socket.value)}
1352
+ onRefuse={onRefuse}
1353
+ />
1354
+ ))}
1355
+ </>
1356
+ )}
1357
+ </aside>
1358
+ );
1359
+ }
1360
+
1361
+ function Row({
1362
+ label,
1363
+ value,
1364
+ swatch,
1365
+ onRefuse,
1366
+ }: {
1367
+ readonly label: string;
1368
+ readonly value: string;
1369
+ readonly swatch?: string | undefined;
1370
+ readonly onRefuse: (text: string) => void;
1371
+ }) {
1372
+ return (
1373
+ <div
1374
+ style={{ display: 'flex', alignItems: 'center', gap: 'var(--vgai-space-2)', height: 20 }}
1375
+ onPointerDown={() =>
1376
+ onRefuse(`"${label}" is read here and written nowhere — inspection parity.`)
1377
+ }
1378
+ >
1379
+ <span style={{ flex: '0 0 45%', opacity: 0.75, textAlign: 'right' }}>{label}</span>
1380
+ {swatch && (
1381
+ <span
1382
+ style={{ width: 14, height: 14, borderRadius: 2, background: swatch, flex: '0 0 auto' }}
1383
+ />
1384
+ )}
1385
+ <span
1386
+ style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
1387
+ title={value}
1388
+ >
1389
+ {value}
1390
+ </span>
1391
+ </div>
1392
+ );
1393
+ }
1394
+
1395
+ // THERE IS DELIBERATELY NO `available` GATE. A `workspace.utility` may gate
1396
+ // its tab on session state, and the Network utility does because a game may or
1397
+ // may not expose a networking adapter. This tab's condition is that the
1398
+ // BLENDER BUILD is loaded, and a contribution of this package cannot be
1399
+ // registered unless it is — the same reason `model.layout.ts` gates on the
1400
+ // project declaring a `model` document and nothing else. With no material the
1401
+ // view draws what Blender draws with no tree: the bare background, and the
1402
+ // header saying so.