@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,485 @@
1
+ /**
2
+ * TRACING A NODE'S SOCKET PANELS FROM BLENDER'S OWN DECLARATIONS — the table
3
+ * that closes I5's one named gap (WORK.md §Blender in the tab is Blender,
4
+ * "Inspection parity", I5; RULED 2026-09-19: "socket panels are traced from
5
+ * Blender's source, like the icons and the palette").
6
+ *
7
+ * ## Why a trace and not a read
8
+ *
9
+ * `Node.panel_states` is RNA's whole answer about a node's socket panels: a
10
+ * `persistent_uid` and `is_collapsed`, and nothing else
11
+ * (`rna_nodetree.cc:9289-9299`, `:9430-9434`). Which SOCKETS belong to a
12
+ * panel, what a panel is CALLED, and where a panel sits among the node's
13
+ * items all live in the node's C++ declaration — the `root_items` list that
14
+ * `node_update_basis_from_declaration` (`node_draw.cc:1086-1218`) walks — and
15
+ * bpy exposes no part of it. Measured on the engine: a
16
+ * `ShaderNodeBsdfPrincipled` answers 8 panel states and not one of its
17
+ * `NodeSocket`s carries a panel field.
18
+ *
19
+ * So the membership is READ FROM THE SOURCE, exactly as the icons are traced
20
+ * out of `release/datafiles/icons_svg/*.svg` (`blender-icon-trace.mjs`) and
21
+ * the palette out of `userdef_default_theme.c` (`blender-palette.source.mjs`).
22
+ * The reference is Blender's SOURCE as well as its frames
23
+ * (ARCHITECTURE-CORE §Blender north star), and a declaration is source.
24
+ *
25
+ * ## What is traced, and what deliberately is not
26
+ *
27
+ * ONLY node types that DECLARE A PANEL. A node with no panel is already laid
28
+ * out correctly by the flat path — its flattened item list is its socket list
29
+ * — so a table row for it would carry no information and would have to be
30
+ * kept in step with 558 files instead of 36. Measured at Blender 5.2.0
31
+ * (`fbe6228777e7`): 558 `node_*.cc` files, **36** of which call `add_panel`
32
+ * in a declaration (17 composite, 11 geometry, 5 shader, 3 function).
33
+ *
34
+ * TWO `add_panel` CALL SITES ARE EXCLUDED AND THE REASON IS STATED.
35
+ * `intern/node_declaration.cc:474` is the builder method itself, not a
36
+ * declaration. `intern/node_common.cc:485` is the NODE GROUP's declare, which
37
+ * builds its panels in a LOOP over `ntree->tree_interface` — a group's panels
38
+ * are runtime data, not a static declaration, and RNA *can* answer them
39
+ * (`NodeTree.interface`), so tracing them would be tracing the wrong thing.
40
+ * A group node therefore has no row here and keeps the flat fallback.
41
+ *
42
+ * ## The grammar this reads
43
+ *
44
+ * A declaration body is a straight-line sequence of builder calls. Measured
45
+ * across the 36 files, a panel is introduced in exactly two spellings —
46
+ * `PanelDeclarationBuilder &<var> = <recv>.add_panel("Name")` (49 sites) and
47
+ * `auto &<var> = <recv>.add_panel("Name")` (17 sites) — and everything else
48
+ * is `<recv>.add_input<…>("Name"[, "Identifier"])`,
49
+ * `<recv>.add_output<…>(…)`, `<recv>.add_layout(…)`,
50
+ * `<recv>.add_default_layout()` and `<recv>.add_separator()`, where `<recv>`
51
+ * is either the root builder's own parameter or a bound panel variable. That
52
+ * is the whole grammar, and this file parses that and nothing more.
53
+ *
54
+ * A body with CONTROL FLOW is parsed straight through — both arms of an
55
+ * `if`/`else` contribute items, in source order — and the entry is marked
56
+ * `conditional`. That is deliberately a LOUD approximation rather than a
57
+ * clever one: the consumer checks the traced item list against the engine's
58
+ * LIVE socket list before it uses it, and a node whose real declaration took
59
+ * the other arm simply disagrees and falls back to flat with its frame
60
+ * warning. A static parser cannot evaluate `b.tree_or_null()`, and pretending
61
+ * otherwise is how a drawing quietly stops being a measurement.
62
+ *
63
+ * ## Running it
64
+ *
65
+ * ```
66
+ * node packages/blender/contributions/blender-node-panels.source.mjs \
67
+ * [--checkout <path>] [--check]
68
+ * ```
69
+ * writes (or, with `--check`, verifies) `blender.node-panels.json` beside
70
+ * this file. The checkout defaults to `~/volter/blender-src`, the sparse
71
+ * checkout pinned at the engine's own commit; `source/blender/nodes` is one
72
+ * of the paths it must carry (see the checkout recipe's file map).
73
+ *
74
+ * ## Licence
75
+ *
76
+ * What lands in the JSON is a table of PANEL NAMES and SOCKET NAMES read out
77
+ * of GPL-2.0-or-later sources. `@vgai/blender` is AGPL-3.0-only, which that
78
+ * is compatible with (relicense to GPL-3.0, then §13), and the package
79
+ * already carries Blender itself — the same standing this file's sibling
80
+ * `blender-icon-trace.mjs` records for the icon paths. Nothing traced here
81
+ * may be copied into an Apache-2.0 or MIT part of this repo.
82
+ */
83
+
84
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
85
+ import { homedir } from 'node:os';
86
+ import { join, relative } from 'node:path';
87
+ import { fileURLToPath } from 'node:url';
88
+
89
+ const DEFAULT_CHECKOUT = join(homedir(), 'volter', 'blender-src');
90
+ const NODES_ROOT = join('source', 'blender', 'nodes');
91
+ const OUT = fileURLToPath(new URL('./blender.node-panels.json', import.meta.url));
92
+
93
+ /** The two `add_panel` call sites that are not a node's declaration — see the
94
+ * header. Both are named rather than filtered by a shape rule, so a third one
95
+ * appearing upstream shows up as a new row instead of vanishing. */
96
+ const NOT_A_DECLARATION = new Set([
97
+ join(NODES_ROOT, 'intern', 'node_common.cc'),
98
+ join(NODES_ROOT, 'intern', 'node_declaration.cc'),
99
+ ]);
100
+
101
+ /* ----------------------------------------------------------------- scanning */
102
+
103
+ /**
104
+ * Comments out, strings kept. A C++ scanner rather than a regex because a
105
+ * `//` inside a string literal (`"http://…"`) and a `"` inside a comment both
106
+ * occur in this tree, and either one defeats the regex form.
107
+ */
108
+ function stripComments(source) {
109
+ let out = '';
110
+ for (let index = 0; index < source.length; index++) {
111
+ const two = source.slice(index, index + 2);
112
+ if (two === '//') {
113
+ while (index < source.length && source[index] !== '\n') index++;
114
+ out += '\n';
115
+ continue;
116
+ }
117
+ if (two === '/*') {
118
+ index += 2;
119
+ while (index < source.length && source.slice(index, index + 2) !== '*/') {
120
+ if (source[index] === '\n') out += '\n';
121
+ index++;
122
+ }
123
+ index += 1;
124
+ continue;
125
+ }
126
+ if (source[index] === '"') {
127
+ out += '"';
128
+ index++;
129
+ while (index < source.length && source[index] !== '"') {
130
+ if (source[index] === '\\') {
131
+ out += source[index] + (source[index + 1] ?? '');
132
+ index += 2;
133
+ continue;
134
+ }
135
+ out += source[index];
136
+ index++;
137
+ }
138
+ out += '"';
139
+ continue;
140
+ }
141
+ out += source[index];
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** The body of the block whose `{` is at `open`, without the braces. */
147
+ function blockAt(source, open) {
148
+ let depth = 0;
149
+ for (let index = open; index < source.length; index++) {
150
+ if (source[index] === '{') depth++;
151
+ else if (source[index] === '}') {
152
+ depth--;
153
+ if (depth === 0) return source.slice(open + 1, index);
154
+ }
155
+ }
156
+ throw new Error('unbalanced block');
157
+ }
158
+
159
+ /**
160
+ * A block split into STATEMENTS, with control-flow blocks flattened in place.
161
+ *
162
+ * `;` at nesting depth 0 ends a statement; a `{ … }` that closes back to
163
+ * depth 0 is spliced in as its own statements, which is what makes an
164
+ * `if`/`else` body contribute its items in source order (and what makes the
165
+ * entry `conditional` — see the header).
166
+ */
167
+ function statementsOf(block) {
168
+ const out = [];
169
+ let current = '';
170
+ let depth = 0;
171
+ let sawControl = false;
172
+ for (let index = 0; index < block.length; index++) {
173
+ const char = block[index];
174
+ if (char === '"') {
175
+ const end = block.indexOf('"', index + 1);
176
+ current += block.slice(index, end + 1);
177
+ index = end;
178
+ continue;
179
+ }
180
+ if (char === '(' || char === '[') depth++;
181
+ else if (char === ')' || char === ']') depth--;
182
+ else if (char === '{') {
183
+ if (depth === 0) {
184
+ // A control-flow or scope block at statement level. Its HEAD (the
185
+ // `if (…)`, the `for (…)`) is discarded and its body is flattened in.
186
+ const inner = blockAt(block, index);
187
+ if (/\b(if|else|for|while|switch|do)\b/.test(current)) sawControl = true;
188
+ const nested = statementsOf(inner);
189
+ out.push(...nested.statements);
190
+ sawControl ||= nested.sawControl;
191
+ index += inner.length + 1;
192
+ current = '';
193
+ continue;
194
+ }
195
+ depth++;
196
+ } else if (char === '}') depth--;
197
+ else if (char === ';' && depth === 0) {
198
+ out.push(current);
199
+ current = '';
200
+ continue;
201
+ }
202
+ current += char;
203
+ }
204
+ if (current.trim() !== '') out.push(current);
205
+ return { statements: out, sawControl };
206
+ }
207
+
208
+ /* ----------------------------------------------------------------- the walk */
209
+
210
+ /**
211
+ * The string literals in the FIRST call of a chain, and only that call.
212
+ *
213
+ * `open` is the index of its `(`. Taking the balanced argument list rather
214
+ * than "every quoted run in the statement" is not a nicety: the very first
215
+ * node measured, `Base Color`, chains
216
+ * `.description("Color of the material used for …")`, and a whole-statement
217
+ * scan reads that sentence as the socket's identifier.
218
+ */
219
+ function firstStrings(call, open) {
220
+ let depth = 0;
221
+ let end = call.length;
222
+ for (let index = open; index < call.length; index++) {
223
+ const char = call[index];
224
+ if (char === '"') {
225
+ index = call.indexOf('"', index + 1);
226
+ if (index < 0) break;
227
+ continue;
228
+ }
229
+ if (char === '(') depth++;
230
+ else if (char === ')') {
231
+ depth--;
232
+ if (depth === 0) {
233
+ end = index;
234
+ break;
235
+ }
236
+ }
237
+ }
238
+ const args = call.slice(open + 1, end);
239
+ const found = [];
240
+ const each = /"((?:[^"\\]|\\.)*)"/g;
241
+ for (let hit = each.exec(args); hit !== null; hit = each.exec(args)) found.push(hit[1]);
242
+ return found;
243
+ }
244
+
245
+ /**
246
+ * ONE DECLARATION BODY → the `root_items` tree Blender's own
247
+ * `NodeDeclaration` holds, in declaration order.
248
+ *
249
+ * Each item is `{kind}` where kind is `input` | `output` | `panel` |
250
+ * `layout` | `separator`, matching the five `flat_item::Type`s
251
+ * `make_flat_node_items` produces (`node_draw.cc:798-831`). A panel carries
252
+ * its `name`, its `defaultClosed` and its own `items`.
253
+ */
254
+ function parseDeclaration(body, rootParam) {
255
+ const root = { items: [] };
256
+ /** Builder variable → the item list it appends to. The root builder's own
257
+ * parameter is bound first, which is how `b.add_input(…)` lands at root. */
258
+ const lists = new Map([[rootParam, root]]);
259
+ const { statements, sawControl } = statementsOf(body);
260
+ let customSocketOrder = false;
261
+ const unknown = [];
262
+
263
+ for (const statement of statements) {
264
+ // PREPROCESSOR LINES ARE NOT STATEMENTS and they carry no `;`, so a
265
+ // `#define SOCK_BASE_COLOR_ID 0` between two `add_input` calls otherwise
266
+ // glues itself to the front of the next one and defeats the anchored
267
+ // match below. Principled has one after every socket.
268
+ const text = statement
269
+ .split('\n')
270
+ .filter((line) => !/^\s*#/.test(line))
271
+ .join('\n')
272
+ .replace(/\s+/g, ' ')
273
+ .trim();
274
+ if (text === '') continue;
275
+ if (new RegExp(`\\b${rootParam}\\.use_custom_socket_order\\s*\\(`).test(text)) {
276
+ customSocketOrder = true;
277
+ continue;
278
+ }
279
+ // `PanelDeclarationBuilder &name = recv.add_panel(…)` / `auto &name = …`.
280
+ const bind = /^(?:const\s+)?(?:PanelDeclarationBuilder|auto)\s*&\s*(\w+)\s*=\s*([\s\S]*)$/.exec(
281
+ text,
282
+ );
283
+ const binding = bind ? bind[1] : null;
284
+ const call = bind ? bind[2] : text;
285
+ const head = /^(\w+)\s*\.\s*(\w+)\s*(?:<[^(]*>)?\s*\(/.exec(call);
286
+ if (!head) continue;
287
+ const [, receiver, method] = head;
288
+ const open = call.indexOf('(', head[0].length - 1);
289
+ const owner = lists.get(receiver);
290
+ if (!owner) continue;
291
+
292
+ if (method === 'add_panel') {
293
+ const names = firstStrings(call, open);
294
+ const panel = { kind: 'panel', name: names[0] ?? '', items: [] };
295
+ // `PanelDeclaration::default_collapsed` (`NOD_node_declaration.hh:505`),
296
+ // set by `.default_closed(bool)`. Absent means false.
297
+ if (/\.default_closed\s*\(\s*true\s*\)/.test(call)) panel.defaultClosed = true;
298
+ owner.items.push(panel);
299
+ if (binding) lists.set(binding, panel);
300
+ continue;
301
+ }
302
+ if (method === 'add_input' || method === 'add_output') {
303
+ const names = firstStrings(call, open);
304
+ if (names.length === 0) continue;
305
+ // EVERY OPTIONAL FLAG IS OMITTED WHEN IT IS FALSE, and `identifier` when
306
+ // it equals the name. The table is 36 node types and is meant to be
307
+ // READ in review; spelling four `false`s per socket triples it for no
308
+ // information (105 KB against 47 KB, measured).
309
+ const socket = {
310
+ kind: method === 'add_input' ? 'input' : 'output',
311
+ name: names[0],
312
+ };
313
+ // `add_input(name, identifier)` — the identifier defaults to the name
314
+ // (`DeclarationListBuilder::add_socket`), and the identifier is what a
315
+ // live `NodeSocket` is matched on.
316
+ if (names[1] && names[1] !== names[0]) socket.identifier = names[1];
317
+ // `.panel_toggle()` makes this the panel's HEADER checkbox rather than a
318
+ // row of its own — `PanelDeclaration::panel_input_decl()`
319
+ // (`node_declaration.cc:528-542`) takes the panel's FIRST item when it
320
+ // is a panel-toggle boolean input.
321
+ if (/\.panel_toggle\s*\(/.test(call)) socket.panelToggle = true;
322
+ // `.available(<expr>)` — a socket whose availability is COMPUTED. It is
323
+ // recorded rather than resolved: the consumer matches against the LIVE
324
+ // socket list, where the expression has already been evaluated.
325
+ if (/\.available\s*\(/.test(call)) socket.conditional = true;
326
+ // `SocketDeclaration::align_with_previous_socket` (`:196`): this socket
327
+ // shares the previous socket's ROW instead of taking one
328
+ // (`add_flat_items_for_socket`, `node_draw.cc:704-717`).
329
+ if (/\.align_with_previous\s*\(\s*(?:true\s*)?\)/.test(call)) {
330
+ socket.alignWithPrevious = true;
331
+ }
332
+ owner.items.push(socket);
333
+ continue;
334
+ }
335
+ if (method === 'add_layout' || method === 'add_default_layout') {
336
+ owner.items.push({ kind: 'layout' });
337
+ continue;
338
+ }
339
+ if (method === 'add_separator') {
340
+ owner.items.push({ kind: 'separator' });
341
+ continue;
342
+ }
343
+ if (method.startsWith('add_')) unknown.push(`${receiver}.${method}`);
344
+ }
345
+ return { items: root.items, customSocketOrder, conditional: sawControl, unknown };
346
+ }
347
+
348
+ /* ------------------------------------------------------------ the registry */
349
+
350
+ /**
351
+ * The node types a file registers, each with the declaration function it
352
+ * points `ntype.declare` at.
353
+ *
354
+ * `<x>_node_type_base(&ntype, "IDNAME"_ustr, …)` names the type and
355
+ * `ntype.declare = <ns>::<fn>` names the body. Where one file registers two
356
+ * types from two nested namespaces with the SAME function name
357
+ * (`node_geo_foreach_geometry_element.cc` is the measured case), the body is
358
+ * the definition of that name nearest ABOVE the register function — which is
359
+ * what C++ name lookup resolves to from inside the namespace.
360
+ */
361
+ function registrationsOf(source) {
362
+ const declares = [];
363
+ const declareEach =
364
+ /(?:static\s+)?void\s+(\w+)\s*\(\s*(?:const\s+)?NodeDeclarationBuilder\s*&\s*(\w+)\s*\)\s*\{/g;
365
+ for (let hit = declareEach.exec(source); hit !== null; hit = declareEach.exec(source)) {
366
+ declares.push({
367
+ name: hit[1],
368
+ param: hit[2],
369
+ at: hit.index,
370
+ body: blockAt(source, hit.index + hit[0].length - 1),
371
+ });
372
+ }
373
+ const out = [];
374
+ // THREE SPELLINGS, measured across the tree: `static void node_register()`
375
+ // with `NOD_REGISTER_NODE(node_register)` (411 sites, the modern one),
376
+ // `static void register_node()` (5) and the legacy
377
+ // `void register_node_type_sh_*()` (the shader tree's). A function is a
378
+ // registration when its body calls `<x>_node_type_base(&ntype, "IDNAME")`,
379
+ // which is the test below; these names are only how the body is FOUND.
380
+ const registerEach =
381
+ /(?:static\s+)?void\s+(register_node_type_\w+|node_register|register_node)\s*\(\s*\)\s*\{/g;
382
+ for (let hit = registerEach.exec(source); hit !== null; hit = registerEach.exec(source)) {
383
+ const body = blockAt(source, hit.index + hit[0].length - 1);
384
+ const idname = /_node_type_base\s*\(\s*&\s*ntype\s*,\s*"([^"]+)"/.exec(body);
385
+ const declare = /ntype\.declare\s*=\s*(?:[\w:]+::)?(\w+)\s*;/.exec(body);
386
+ if (!idname || !declare) continue;
387
+ const candidates = declares.filter(
388
+ (entry) => entry.name === declare[1] && entry.at < hit.index,
389
+ );
390
+ const chosen = candidates[candidates.length - 1];
391
+ if (!chosen) continue;
392
+ out.push({ idname: idname[1], declare: chosen });
393
+ }
394
+ return out;
395
+ }
396
+
397
+ /* -------------------------------------------------------------------- main */
398
+
399
+ function* walk(directory) {
400
+ for (const entry of readdirSync(directory)) {
401
+ const path = join(directory, entry);
402
+ if (statSync(path).isDirectory()) yield* walk(path);
403
+ else if (/^node_.*\.cc$/.test(entry)) yield path;
404
+ }
405
+ }
406
+
407
+ function build(checkout) {
408
+ const root = join(checkout, NODES_ROOT);
409
+ const table = {};
410
+ const report = { files: 0, withPanels: 0, types: 0, conditional: [], unknown: new Set() };
411
+ for (const path of [...walk(root)].sort()) {
412
+ report.files++;
413
+ const key = relative(checkout, path);
414
+ if (NOT_A_DECLARATION.has(key)) continue;
415
+ const raw = readFileSync(path, 'utf8');
416
+ if (!raw.includes('add_panel')) continue;
417
+ const source = stripComments(raw);
418
+ let traced = 0;
419
+ for (const { idname, declare } of registrationsOf(source)) {
420
+ const parsed = parseDeclaration(declare.body, declare.param);
421
+ if (!parsed.items.some((item) => item.kind === 'panel')) continue;
422
+ traced++;
423
+ report.types++;
424
+ if (parsed.conditional) report.conditional.push(idname);
425
+ for (const name of parsed.unknown) report.unknown.add(name);
426
+ table[idname] = {
427
+ // `is_node_panels_supported` is `decl->use_custom_socket_order`
428
+ // (`node_draw.cc:343-346`), which is what puts the node on the
429
+ // DECLARATION layout path at all. A panel-declaring node that does not
430
+ // set it is a contradiction worth carrying rather than hiding.
431
+ customSocketOrder: parsed.customSocketOrder,
432
+ ...(parsed.conditional ? { conditional: true } : {}),
433
+ items: parsed.items,
434
+ };
435
+ }
436
+ if (traced > 0) report.withPanels++;
437
+ }
438
+ return { table, report };
439
+ }
440
+
441
+ function main() {
442
+ const argv = process.argv.slice(2);
443
+ const checkoutFlag = argv.indexOf('--checkout');
444
+ const checkout = checkoutFlag >= 0 ? argv[checkoutFlag + 1] : DEFAULT_CHECKOUT;
445
+ const { table, report } = build(checkout);
446
+ const ordered = Object.fromEntries(Object.entries(table).sort(([a], [b]) => (a < b ? -1 : 1)));
447
+ const text = `${JSON.stringify(ordered, null, 2)}\n`;
448
+ if (argv.includes('--check')) {
449
+ const have = readFileSync(OUT, 'utf8');
450
+ if (have !== text) {
451
+ console.error('blender.node-panels.json is STALE — re-run this script without --check.');
452
+ process.exit(1);
453
+ }
454
+ console.log(
455
+ `blender.node-panels.json — up to date (${Object.keys(ordered).length} node types)`,
456
+ );
457
+ return;
458
+ }
459
+ writeFileSync(OUT, text);
460
+ const panels = Object.values(ordered).reduce((sum, entry) => sum + countPanels(entry.items), 0);
461
+ console.log(
462
+ `blender.node-panels.json — ${Object.keys(ordered).length} node types, ${panels} panels, ` +
463
+ `from ${report.withPanels} of ${report.files} node_*.cc files`,
464
+ );
465
+ if (report.conditional.length > 0) {
466
+ console.log(
467
+ ` conditional declarations (parsed straight through): ${report.conditional.join(', ')}`,
468
+ );
469
+ }
470
+ if (report.unknown.size > 0) {
471
+ console.log(` builder calls this grammar does not know: ${[...report.unknown].join(', ')}`);
472
+ }
473
+ }
474
+
475
+ function countPanels(items) {
476
+ let count = 0;
477
+ for (const item of items) {
478
+ if (item.kind === 'panel') count += 1 + countPanels(item.items);
479
+ }
480
+ return count;
481
+ }
482
+
483
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main();
484
+
485
+ export { build, parseDeclaration, registrationsOf, statementsOf, stripComments };