@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.
- package/LICENSE +1409 -0
- package/README.md +17 -0
- package/contributions/blender-header-menus.tsx +483 -0
- package/contributions/blender-icon-trace.mjs +403 -0
- package/contributions/blender-icons.source.mjs +2925 -0
- package/contributions/blender-node-editor.document.tsx +1402 -0
- package/contributions/blender-node-geometry.ts +1138 -0
- package/contributions/blender-node-panels.source.mjs +485 -0
- package/contributions/blender-outliner-authoring.ts +1729 -0
- package/contributions/blender-outliner-model.ts +389 -0
- package/contributions/blender-palette.source.mjs +319 -0
- package/contributions/blender-properties-model.ts +351 -0
- package/contributions/blender-properties-tab.tsx +100 -0
- package/contributions/blender-properties-view.tsx +1191 -0
- package/contributions/blender-runtime-skin.ts +619 -0
- package/contributions/blender-runtime.document.tsx +232 -0
- package/contributions/blender-timeline-geometry.ts +323 -0
- package/contributions/blender-timeline.document.tsx +1056 -0
- package/contributions/blender-uv-editor.document.tsx +483 -0
- package/contributions/blender-uv-geometry.ts +305 -0
- package/contributions/blender-version.status.tsx +93 -0
- package/contributions/blender.command.ts +102 -0
- package/contributions/blender.icons.json +1247 -0
- package/contributions/blender.icons.traced.json +1561 -0
- package/contributions/blender.keymap.ts +39 -0
- package/contributions/blender.node-panels.json +2436 -0
- package/contributions/blender.palette.json +93 -0
- package/contributions/blender.status.tsx +263 -0
- package/contributions/blender.style.ts +271 -0
- package/contributions/model.layout.ts +53 -0
- package/contributions/models.finder.ts +59 -0
- package/contributions/properties-bone-constraints.inspector.tsx +50 -0
- package/contributions/properties-bone.inspector.tsx +184 -0
- package/contributions/properties-collection.inspector.tsx +96 -0
- package/contributions/properties-constraints.inspector.tsx +69 -0
- package/contributions/properties-data.inspector.tsx +229 -0
- package/contributions/properties-material.inspector.tsx +121 -0
- package/contributions/properties-modifiers.inspector.tsx +74 -0
- package/contributions/properties-object.inspector.tsx +215 -0
- package/contributions/properties-output.inspector.tsx +210 -0
- package/contributions/properties-particles.inspector.tsx +494 -0
- package/contributions/properties-physics.inspector.tsx +614 -0
- package/contributions/properties-render.inspector.tsx +446 -0
- package/contributions/properties-scene.inspector.tsx +174 -0
- package/contributions/properties-texture.inspector.tsx +300 -0
- package/contributions/properties-view-layer.inspector.tsx +145 -0
- package/contributions/properties-world.inspector.tsx +130 -0
- package/contributions/sculpt.layout.ts +25 -0
- package/contributions/shading.layout.ts +99 -0
- package/contributions/texture.layout.ts +16 -0
- package/contributions/uv-editing.layout.ts +93 -0
- package/host/blender-runtime-host.ts +1256 -0
- package/package.json +77 -0
- package/src/layouts.tsx +48 -0
- package/src/looks.ts +14 -0
- package/src/node-view-state.ts +125 -0
- package/src/timeline-view-state.ts +154 -0
- 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.
|