@punal_manalan/blender-project-harness 0.0.0-stage → 1.0.2

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 (91) hide show
  1. package/.kilo/agent/blender-grader.md +197 -0
  2. package/.kilo/agent/blender-worker.md +127 -0
  3. package/.kilo/blender-project-harness/config.example.json +71 -0
  4. package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
  5. package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
  6. package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
  7. package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
  8. package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
  9. package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
  10. package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
  11. package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
  12. package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
  13. package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
  14. package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
  15. package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
  16. package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
  17. package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
  18. package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
  19. package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
  20. package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
  21. package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
  22. package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
  23. package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
  24. package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
  25. package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
  26. package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
  27. package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
  28. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
  29. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
  30. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
  31. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
  32. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
  33. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
  34. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
  35. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
  36. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
  37. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
  38. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
  39. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
  40. package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
  41. package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
  42. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
  43. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
  44. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
  45. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
  46. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
  47. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
  48. package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
  49. package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
  50. package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
  51. package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
  52. package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
  53. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
  54. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
  55. package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
  56. package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
  57. package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
  58. package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
  59. package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
  60. package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
  61. package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
  62. package/.kilo/plugins/blender-workflow.js +3 -0
  63. package/.kilo/skills/blender-assets/SKILL.md +99 -0
  64. package/.kilo/skills/blender-mcp/SKILL.md +147 -0
  65. package/.kilo/skills/blender-mcp/references/README.md +22 -0
  66. package/.kilo/skills/blender-mcp/references/animation.md +83 -0
  67. package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
  68. package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
  69. package/.kilo/skills/blender-mcp/references/materials.md +80 -0
  70. package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
  71. package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
  72. package/.kilo/skills/blender-mcp/references/scene.md +72 -0
  73. package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
  74. package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
  75. package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
  76. package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
  77. package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
  78. package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
  79. package/.kilo/skills/blender-subagents/SKILL.md +204 -0
  80. package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
  81. package/AGENTS.md +234 -0
  82. package/Blender/README.md +30 -0
  83. package/CHANGELOG.md +22 -0
  84. package/LICENSE +21 -0
  85. package/README.md +368 -2
  86. package/THIRD_PARTY_NOTICES.md +51 -0
  87. package/bin/blender-project-harness.mjs +4 -0
  88. package/installer/cli.mjs +116 -0
  89. package/installer/install.mjs +325 -0
  90. package/installer/install.test.mjs +134 -0
  91. package/package.json +58 -6
@@ -0,0 +1,882 @@
1
+ const OPERATION_NAMES = new Set([
2
+ 'create_mesh',
3
+ 'create_scene',
4
+ 'switch_scene',
5
+ 'delete_scene',
6
+ 'duplicate_scene',
7
+ 'set_scene_camera',
8
+ 'scene_strip',
9
+ 'text',
10
+ 'modifier',
11
+ 'transform',
12
+ 'material',
13
+ 'shared_material',
14
+ 'duplicate',
15
+ 'create_light',
16
+ 'create_camera',
17
+ 'set_camera',
18
+ 'render_settings',
19
+ 'delete_object',
20
+ 'import_asset',
21
+ 'link_library',
22
+ 'import_audio',
23
+ 'image_reference',
24
+ 'image_texture',
25
+ 'world_hdri',
26
+ 'check_assets',
27
+ 'pack_assets',
28
+ 'export_scene',
29
+ ]);
30
+
31
+ const PRIMITIVES = new Set(['cube', 'uv_sphere', 'plane', 'cylinder']);
32
+ const SCENE_MODES = new Set(['NEW', 'EMPTY', 'LINK_COPY', 'FULL_COPY']);
33
+ const SCENE_OPERATIONS = new Set(['create_mesh', 'text', 'modifier', 'transform', 'material', 'shared_material', 'duplicate', 'create_light', 'create_camera', 'set_camera', 'render_settings', 'delete_object', 'import_asset', 'link_library', 'import_audio', 'image_reference', 'image_texture', 'world_hdri', 'export_scene', 'set_scene_camera', 'scene_strip']);
34
+ const LIGHT_KINDS = new Set(['POINT', 'AREA', 'SUN', 'SPOT']);
35
+ const MODIFIER_KINDS = new Set(['BEVEL', 'SUBSURF', 'SOLIDIFY', 'MIRROR', 'ARRAY']);
36
+ const TEXT_ALIGN_X = new Set(['LEFT', 'CENTER', 'RIGHT', 'JUSTIFY', 'FLUSH']);
37
+ const TEXT_ALIGN_Y = new Set(['TOP', 'TOP_BASELINE', 'CENTER', 'BOTTOM_BASELINE', 'BOTTOM']);
38
+ const NAME_RE = /^[\x20-\x7e]+(?![\s\S])/;
39
+ const MAX_BATCH = 10;
40
+ const MODEL_EXTENSIONS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.usd', '.usdz', '.blend']);
41
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.tif', '.tiff', '.bmp']);
42
+ const HDRI_EXTENSIONS = new Set(['.hdr', '.exr']);
43
+ const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.ogg', '.flac']);
44
+ const EXPORT_EXTENSIONS = new Map([['glb', '.glb'], ['gltf', '.gltf'], ['fbx', '.fbx'], ['usd', '.usd'], ['obj', '.obj']]);
45
+
46
+ function fail(message) {
47
+ throw new TypeError(message);
48
+ }
49
+
50
+ function isPlainObject(value) {
51
+ if (value === null || typeof value !== 'object') return false;
52
+ const prototype = Object.getPrototypeOf(value);
53
+ return prototype === Object.prototype || prototype === null;
54
+ }
55
+
56
+ function strictObject(value, keys, label) {
57
+ if (!isPlainObject(value)) fail(`${label} must be an object`);
58
+ const allowed = new Set([...keys, 'scene']);
59
+ for (const key of Reflect.ownKeys(value)) {
60
+ if (!allowed.has(key)) fail(`${label} has extra key: ${String(key)}`);
61
+ }
62
+ for (const key in value) {
63
+ if (!Object.hasOwn(value, key)) fail(`${label} has inherited key: ${key}`);
64
+ }
65
+ }
66
+
67
+ function requireKeys(value, keys, label) {
68
+ for (const key of keys) {
69
+ if (!Object.hasOwn(value, key)) fail(`${label}.${key} is required`);
70
+ }
71
+ }
72
+
73
+ function validateString(value, label) {
74
+ if (typeof value !== 'string' || value.length > 100 || !NAME_RE.test(value) || value.trim().length === 0) {
75
+ fail(`${label} must be a non-blank printable ASCII string of at most 100 characters`);
76
+ }
77
+ return value;
78
+ }
79
+
80
+ function validateText(value, label) {
81
+ if (typeof value !== 'string' || value.length === 0 || value.length > 1000 || value.includes('\0')) {
82
+ fail(`${label} must be a non-empty string of at most 1000 characters`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ function validateFiniteNumber(value, label, minimum = -Infinity, maximum = Infinity, positive = false) {
88
+ if (typeof value !== 'number' || !Number.isFinite(value)) fail(`${label} must be finite`);
89
+ if (value < minimum || value > maximum || (positive && value <= 0)) {
90
+ fail(`${label} is out of bounds`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function validateVector(value, label, length, minimum = -1e6, maximum = 1e6) {
96
+ if (!Array.isArray(value) || value.length !== length) fail(`${label} must be an array of ${length} numbers`);
97
+ return Array.from(value, (item, index) => validateFiniteNumber(item, `${label}[${index}]`, minimum, maximum));
98
+ }
99
+
100
+ function validateAllowedObjects(allowedObjects) {
101
+ if (!Array.isArray(allowedObjects)) fail('allowedObjects must be an array');
102
+ const names = allowedObjects.map((name, index) => validateString(name, `allowedObjects[${index}]`));
103
+ if (new Set(names).size !== names.length) fail('allowedObjects must not contain duplicates');
104
+ return names;
105
+ }
106
+
107
+ function validateAllowedName(name, allowed, label = 'name') {
108
+ validateString(name, label);
109
+ if (!allowed.includes(name)) fail(`${label} is not allowed: ${name}`);
110
+ }
111
+
112
+ function validateBoolean(value, label) {
113
+ if (typeof value !== 'boolean') fail(`${label} must be a boolean`);
114
+ return value;
115
+ }
116
+
117
+ function validateProjectRelativePath(value, label, directories, extensions) {
118
+ if (typeof value !== 'string' || value.length === 0 || value.length > 300 || value.includes('\0')) {
119
+ fail(`${label} must be a non-empty project-relative path`);
120
+ }
121
+ if (value.startsWith('/') || value.startsWith('\\') || /^[A-Za-z]:/.test(value) || value.includes('\\')) {
122
+ fail(`${label} must be a relative forward-slash project path`);
123
+ }
124
+ const segments = value.split('/');
125
+ if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
126
+ fail(`${label} must stay within the project`);
127
+ }
128
+ if (!directories.some((directory) => value.startsWith(`${directory}/`) && value.length > directory.length + 1)) {
129
+ fail(`${label} must be under ${directories.join(', ')}`);
130
+ }
131
+ const dot = value.lastIndexOf('.');
132
+ const extension = dot < 0 ? '' : value.slice(dot).toLowerCase();
133
+ if (!extensions.has(extension)) fail(`${label} has an unsupported extension`);
134
+ return value;
135
+ }
136
+
137
+ function validateOptionalTransform(operation, kind) {
138
+ if (Object.hasOwn(operation, 'location')) validateVector(operation.location, `${kind}.location`, 3);
139
+ if (Object.hasOwn(operation, 'rotation')) validateVector(operation.rotation, `${kind}.rotation`, 3);
140
+ if (Object.hasOwn(operation, 'scale')) {
141
+ validateVector(operation.scale, `${kind}.scale`, 3, 0, 1e4);
142
+ if (operation.scale.some((value) => value === 0)) fail(`${kind}.scale values must be positive`);
143
+ }
144
+ }
145
+
146
+ function validateImagePath(operation, label, directories, extensions = IMAGE_EXTENSIONS) {
147
+ return validateProjectRelativePath(operation.path, label, directories, extensions);
148
+ }
149
+
150
+ function validateEmission(value, label) {
151
+ if (value === undefined) return;
152
+ if (!isPlainObject(value)) fail(`${label} must be an object`);
153
+ strictObject(value, ['color', 'strength'], label);
154
+ requireKeys(value, ['color', 'strength'], label);
155
+ validateVector(value.color, `${label}.color`, 4, 0, 1);
156
+ validateFiniteNumber(value.strength, `${label}.strength`, 0, 1e6);
157
+ }
158
+
159
+ function validateMaterialValues(operation, kind) {
160
+ validateVector(operation.color, `${kind}.color`, 4, 0, 1);
161
+ validateFiniteNumber(operation.roughness, `${kind}.roughness`, 0, 1);
162
+ validateFiniteNumber(operation.metallic, `${kind}.metallic`, 0, 1);
163
+ validateEmission(operation.emission, `${kind}.emission`);
164
+ }
165
+
166
+ function quote(value) {
167
+ return JSON.stringify(value);
168
+ }
169
+
170
+ function jsonReceipt(operation, name = null) {
171
+ const operationLiteral = JSON.stringify(operation);
172
+ const nameLiteral = name === null ? 'None' : JSON.stringify(name);
173
+ return `print("HARNESS_JSON:" + json.dumps({"ok": True, "operation": ${operationLiteral}, "name": ${nameLiteral}}, separators=(",", ":")))`;
174
+ }
175
+
176
+ function script(body, imports = 'import bpy\nimport json\nimport os\n') {
177
+ return `${imports}\ntry:\n${body.split('\n').map((line) => ` ${line}`).join('\n')}\nexcept Exception as exc:\n print("HARNESS_JSON:" + json.dumps({"ok": False, "error": str(exc)}, separators=(",", ":")))\n raise\n`;
178
+ }
179
+
180
+ function hasScene(operation) {
181
+ return Object.hasOwn(operation, 'scene');
182
+ }
183
+
184
+ function sceneSetup(operation) {
185
+ if (!hasScene(operation)) return 'target_scene = bpy.context.scene';
186
+ return `target_scene = bpy.data.scenes.get(${quote(operation.scene)})\nif target_scene is None:\n raise RuntimeError("scene not found")`;
187
+ }
188
+
189
+ function sceneLink(operation, variable) {
190
+ return hasScene(operation)
191
+ ? `target_scene.collection.objects.link(${variable})`
192
+ : `bpy.context.collection.objects.link(${variable})`;
193
+ }
194
+
195
+ function sceneObjectGuard(operation, variable = 'obj') {
196
+ if (!hasScene(operation)) return '';
197
+ return `${sceneSetup(operation)}\nif target_scene.objects.get(${variable}.name) is not ${variable}:\n raise RuntimeError("object does not belong to the target scene")`;
198
+ }
199
+
200
+ function activeSceneExpression() {
201
+ return 'bpy.context.window.scene if bpy.context.window is not None else bpy.context.scene';
202
+ }
203
+
204
+ function objectLookup(name) {
205
+ return `obj = bpy.data.objects.get(${quote(name)})\nif obj is None:\n raise RuntimeError("object not found")`;
206
+ }
207
+
208
+ function objectModeGuard() {
209
+ return `if bpy.context.mode != 'OBJECT':\n raise RuntimeError("operation requires Object Mode")`;
210
+ }
211
+
212
+ function enumValues(rnaType, propertyName) {
213
+ return `enum_prop = ${rnaType}.bl_rna.properties[${quote(propertyName)}]\nvalid_values = {item.identifier for item in enum_prop.enum_items}`;
214
+ }
215
+
216
+ function validateOperation(operation, allowedObjects) {
217
+ const allowed = validateAllowedObjects(allowedObjects);
218
+ if (!isPlainObject(operation)) fail('operation must be an object');
219
+ requireKeys(operation, ['operation'], 'operation');
220
+ const kind = validateString(operation.operation, 'operation.operation');
221
+ if (!OPERATION_NAMES.has(kind)) fail(`unsupported operation: ${kind}`);
222
+
223
+ switch (kind) {
224
+ case 'create_scene': {
225
+ strictObject(operation, ['operation', 'name', 'mode', 'source', 'activate'], kind);
226
+ requireKeys(operation, ['name', 'mode'], kind);
227
+ validateString(operation.name, `${kind}.name`);
228
+ validateString(operation.mode, `${kind}.mode`);
229
+ if (!SCENE_MODES.has(operation.mode)) fail(`${kind}.mode is invalid`);
230
+ if (operation.mode !== 'NEW') {
231
+ requireKeys(operation, ['source'], kind);
232
+ validateString(operation.source, `${kind}.source`);
233
+ if (operation.source === operation.name) fail(`${kind}.source and name must differ`);
234
+ } else if (Object.hasOwn(operation, 'source')) {
235
+ fail(`${kind}.source is not accepted with NEW`);
236
+ }
237
+ if (Object.hasOwn(operation, 'activate')) validateBoolean(operation.activate, `${kind}.activate`);
238
+ break;
239
+ }
240
+ case 'switch_scene': {
241
+ strictObject(operation, ['operation', 'name'], kind);
242
+ requireKeys(operation, ['name'], kind);
243
+ validateString(operation.name, `${kind}.name`);
244
+ break;
245
+ }
246
+ case 'delete_scene': {
247
+ strictObject(operation, ['operation', 'name'], kind);
248
+ requireKeys(operation, ['name'], kind);
249
+ validateString(operation.name, `${kind}.name`);
250
+ break;
251
+ }
252
+ case 'duplicate_scene': {
253
+ strictObject(operation, ['operation', 'source', 'name'], kind);
254
+ requireKeys(operation, ['source', 'name'], kind);
255
+ validateString(operation.source, `${kind}.source`);
256
+ validateString(operation.name, `${kind}.name`);
257
+ if (operation.source === operation.name) fail(`${kind}.source and name must differ`);
258
+ break;
259
+ }
260
+ case 'set_scene_camera': {
261
+ strictObject(operation, ['operation', 'scene', 'camera'], kind);
262
+ requireKeys(operation, ['scene', 'camera'], kind);
263
+ validateString(operation.scene, `${kind}.scene`);
264
+ validateString(operation.camera, `${kind}.camera`);
265
+ break;
266
+ }
267
+ case 'scene_strip': {
268
+ strictObject(operation, ['operation', 'scene', 'source', 'name', 'channel', 'frame_start', 'frame_end'], kind);
269
+ requireKeys(operation, ['scene', 'source'], kind);
270
+ validateString(operation.scene, `${kind}.scene`);
271
+ validateString(operation.source, `${kind}.source`);
272
+ if (operation.scene === operation.source) fail(`${kind}.scene and source must differ`);
273
+ if (Object.hasOwn(operation, 'name')) validateString(operation.name, `${kind}.name`);
274
+ if (!Number.isInteger(operation.channel)) fail(`${kind}.channel must be an integer`);
275
+ validateFiniteNumber(operation.channel, `${kind}.channel`, 1, 128);
276
+ if (!Number.isInteger(operation.frame_start)) fail(`${kind}.frame_start must be an integer`);
277
+ validateFiniteNumber(operation.frame_start, `${kind}.frame_start`, 0, 1e7);
278
+ if (Object.hasOwn(operation, 'frame_end')) {
279
+ if (!Number.isInteger(operation.frame_end)) fail(`${kind}.frame_end must be an integer`);
280
+ validateFiniteNumber(operation.frame_end, `${kind}.frame_end`, 1, 1e7);
281
+ if (operation.frame_end <= operation.frame_start) fail(`${kind}.frame_end must be after frame_start`);
282
+ }
283
+ break;
284
+ }
285
+ case 'create_mesh': {
286
+ strictObject(operation, ['operation', 'name', 'primitive', 'location', 'scale'], kind);
287
+ requireKeys(operation, ['name', 'primitive', 'location', 'scale'], kind);
288
+ validateAllowedName(operation.name, allowed);
289
+ validateString(operation.primitive, `${kind}.primitive`);
290
+ if (!PRIMITIVES.has(operation.primitive)) fail(`${kind}.primitive is invalid`);
291
+ validateVector(operation.location, `${kind}.location`, 3);
292
+ validateVector(operation.scale, `${kind}.scale`, 3, 0, 1e4);
293
+ if (operation.scale.some((value) => value === 0)) fail(`${kind}.scale values must be positive`);
294
+ break;
295
+ }
296
+ case 'text': {
297
+ strictObject(operation, ['operation', 'name', 'body', 'size', 'extrude', 'align_x', 'align_y', 'location', 'rotation', 'scale'], kind);
298
+ requireKeys(operation, ['name', 'body'], kind);
299
+ validateAllowedName(operation.name, allowed);
300
+ validateText(operation.body, `${kind}.body`);
301
+ if (Object.hasOwn(operation, 'size')) validateFiniteNumber(operation.size, `${kind}.size`, 0.01, 100);
302
+ if (Object.hasOwn(operation, 'extrude')) validateFiniteNumber(operation.extrude, `${kind}.extrude`, 0, 10);
303
+ if (Object.hasOwn(operation, 'align_x')) {
304
+ validateString(operation.align_x, `${kind}.align_x`);
305
+ if (!TEXT_ALIGN_X.has(operation.align_x)) fail(`${kind}.align_x is invalid`);
306
+ }
307
+ if (Object.hasOwn(operation, 'align_y')) {
308
+ validateString(operation.align_y, `${kind}.align_y`);
309
+ if (!TEXT_ALIGN_Y.has(operation.align_y)) fail(`${kind}.align_y is invalid`);
310
+ }
311
+ validateOptionalTransform(operation, kind);
312
+ break;
313
+ }
314
+ case 'modifier': {
315
+ strictObject(operation, ['operation', 'name', 'modifier', 'apply', 'width', 'segments', 'limit_method', 'angle_limit', 'levels', 'render_levels', 'thickness', 'offset', 'use_rim', 'axis', 'use_clip', 'count', 'relative_offset'], kind);
316
+ requireKeys(operation, ['name', 'modifier'], kind);
317
+ validateAllowedName(operation.name, allowed);
318
+ validateString(operation.modifier, `${kind}.modifier`);
319
+ if (!MODIFIER_KINDS.has(operation.modifier)) fail(`${kind}.modifier is invalid`);
320
+ if (Object.hasOwn(operation, 'apply')) validateBoolean(operation.apply, `${kind}.apply`);
321
+ if (operation.modifier === 'BEVEL') {
322
+ if (Object.hasOwn(operation, 'width')) validateFiniteNumber(operation.width, `${kind}.width`, 0, 1000);
323
+ if (Object.hasOwn(operation, 'segments')) {
324
+ if (!Number.isInteger(operation.segments)) fail(`${kind}.segments must be an integer`);
325
+ validateFiniteNumber(operation.segments, `${kind}.segments`, 1, 32);
326
+ }
327
+ if (Object.hasOwn(operation, 'limit_method') && !['NONE', 'ANGLE', 'WEIGHT', 'VGROUP'].includes(operation.limit_method)) fail(`${kind}.limit_method is invalid`);
328
+ if (Object.hasOwn(operation, 'angle_limit')) validateFiniteNumber(operation.angle_limit, `${kind}.angle_limit`, 0, Math.PI);
329
+ }
330
+ if (operation.modifier === 'SUBSURF') {
331
+ for (const field of ['levels', 'render_levels']) {
332
+ if (Object.hasOwn(operation, field)) {
333
+ if (!Number.isInteger(operation[field])) fail(`${kind}.${field} must be an integer`);
334
+ validateFiniteNumber(operation[field], `${kind}.${field}`, 0, 6);
335
+ }
336
+ }
337
+ }
338
+ if (operation.modifier === 'SOLIDIFY') {
339
+ if (Object.hasOwn(operation, 'thickness')) validateFiniteNumber(operation.thickness, `${kind}.thickness`, -1000, 1000);
340
+ if (Object.hasOwn(operation, 'offset')) validateFiniteNumber(operation.offset, `${kind}.offset`, -1, 1);
341
+ if (Object.hasOwn(operation, 'use_rim')) validateBoolean(operation.use_rim, `${kind}.use_rim`);
342
+ }
343
+ if (operation.modifier === 'MIRROR') {
344
+ if (Object.hasOwn(operation, 'axis') && !['X', 'Y', 'Z'].includes(operation.axis)) fail(`${kind}.axis is invalid`);
345
+ if (Object.hasOwn(operation, 'use_clip')) validateBoolean(operation.use_clip, `${kind}.use_clip`);
346
+ }
347
+ if (operation.modifier === 'ARRAY') {
348
+ if (Object.hasOwn(operation, 'count')) {
349
+ if (!Number.isInteger(operation.count)) fail(`${kind}.count must be an integer`);
350
+ validateFiniteNumber(operation.count, `${kind}.count`, 1, 100);
351
+ }
352
+ if (Object.hasOwn(operation, 'relative_offset')) validateVector(operation.relative_offset, `${kind}.relative_offset`, 3);
353
+ }
354
+ break;
355
+ }
356
+ case 'transform': {
357
+ strictObject(operation, ['operation', 'name', 'location', 'rotation', 'scale'], kind);
358
+ requireKeys(operation, ['name'], kind);
359
+ validateAllowedName(operation.name, allowed);
360
+ const fields = ['location', 'rotation', 'scale'].filter((field) => Object.hasOwn(operation, field));
361
+ if (fields.length === 0) fail(`${kind} requires location, rotation, or scale`);
362
+ if (Object.hasOwn(operation, 'location')) validateVector(operation.location, `${kind}.location`, 3);
363
+ if (Object.hasOwn(operation, 'rotation')) validateVector(operation.rotation, `${kind}.rotation`, 3);
364
+ if (Object.hasOwn(operation, 'scale')) {
365
+ validateVector(operation.scale, `${kind}.scale`, 3, 0, 1e4);
366
+ if (operation.scale.some((value) => value === 0)) fail(`${kind}.scale values must be positive`);
367
+ }
368
+ break;
369
+ }
370
+ case 'material': {
371
+ strictObject(operation, ['operation', 'name', 'color', 'roughness', 'metallic', 'emission'], kind);
372
+ requireKeys(operation, ['name', 'color', 'roughness', 'metallic'], kind);
373
+ validateAllowedName(operation.name, allowed);
374
+ validateMaterialValues(operation, kind);
375
+ break;
376
+ }
377
+ case 'shared_material': {
378
+ strictObject(operation, ['operation', 'name', 'objects', 'color', 'roughness', 'metallic', 'emission'], kind);
379
+ requireKeys(operation, ['name', 'objects', 'color', 'roughness', 'metallic'], kind);
380
+ validateString(operation.name, `${kind}.name`);
381
+ if (!Array.isArray(operation.objects) || operation.objects.length < 1 || operation.objects.length > 50) fail(`${kind}.objects must contain 1-50 object names`);
382
+ const objects = operation.objects.map((name, index) => validateString(name, `${kind}.objects[${index}]`));
383
+ if (new Set(objects).size !== objects.length) fail(`${kind}.objects must not contain duplicates`);
384
+ for (const name of objects) validateAllowedName(name, allowed, `${kind}.objects`);
385
+ validateMaterialValues(operation, kind);
386
+ break;
387
+ }
388
+ case 'duplicate': {
389
+ strictObject(operation, ['operation', 'name', 'new_name', 'location', 'rotation', 'scale'], kind);
390
+ requireKeys(operation, ['name', 'new_name'], kind);
391
+ validateAllowedName(operation.name, allowed);
392
+ validateAllowedName(operation.new_name, allowed, `${kind}.new_name`);
393
+ if (Object.hasOwn(operation, 'location')) validateVector(operation.location, `${kind}.location`, 3);
394
+ if (Object.hasOwn(operation, 'rotation')) validateVector(operation.rotation, `${kind}.rotation`, 3);
395
+ if (Object.hasOwn(operation, 'scale')) {
396
+ validateVector(operation.scale, `${kind}.scale`, 3, 0, 1e4);
397
+ if (operation.scale.some((value) => value === 0)) fail(`${kind}.scale values must be positive`);
398
+ }
399
+ break;
400
+ }
401
+ case 'create_light': {
402
+ strictObject(operation, ['operation', 'name', 'kind', 'location', 'energy', 'color', 'temperature'], kind);
403
+ requireKeys(operation, ['name', 'kind', 'location', 'energy'], kind);
404
+ validateAllowedName(operation.name, allowed);
405
+ validateString(operation.kind, `${kind}.kind`);
406
+ if (!LIGHT_KINDS.has(operation.kind)) fail(`${kind}.kind is invalid`);
407
+ validateVector(operation.location, `${kind}.location`, 3);
408
+ validateFiniteNumber(operation.energy, `${kind}.energy`, 0, 1e9);
409
+ if (Object.hasOwn(operation, 'color')) validateVector(operation.color, `${kind}.color`, 3, 0, 1);
410
+ if (Object.hasOwn(operation, 'temperature')) {
411
+ if (!Number.isInteger(operation.temperature)) fail(`${kind}.temperature must be an integer`);
412
+ validateFiniteNumber(operation.temperature, `${kind}.temperature`, 1000, 15000);
413
+ }
414
+ break;
415
+ }
416
+ case 'create_camera': {
417
+ strictObject(operation, ['operation', 'name', 'location', 'rotation', 'lens', 'dof'], kind);
418
+ requireKeys(operation, ['name', 'location', 'rotation', 'lens'], kind);
419
+ validateAllowedName(operation.name, allowed);
420
+ validateVector(operation.location, `${kind}.location`, 3);
421
+ validateVector(operation.rotation, `${kind}.rotation`, 3);
422
+ validateFiniteNumber(operation.lens, `${kind}.lens`, 0, 1e4, true);
423
+ if (Object.hasOwn(operation, 'dof')) {
424
+ if (!isPlainObject(operation.dof)) fail(`${kind}.dof must be an object`);
425
+ strictObject(operation.dof, ['aperture', 'focus_distance', 'focus_object'], `${kind}.dof`);
426
+ requireKeys(operation.dof, ['aperture', 'focus_distance'], `${kind}.dof`);
427
+ validateFiniteNumber(operation.dof.aperture, `${kind}.dof.aperture`, 0.1, 64);
428
+ validateFiniteNumber(operation.dof.focus_distance, `${kind}.dof.focus_distance`, 0, 1e6);
429
+ if (Object.hasOwn(operation.dof, 'focus_object')) validateAllowedName(operation.dof.focus_object, allowed, `${kind}.dof.focus_object`);
430
+ }
431
+ break;
432
+ }
433
+ case 'set_camera':
434
+ case 'delete_object': {
435
+ strictObject(operation, ['operation', 'name'], kind);
436
+ requireKeys(operation, ['name'], kind);
437
+ validateAllowedName(operation.name, allowed);
438
+ break;
439
+ }
440
+ case 'import_asset':
441
+ case 'link_library': {
442
+ const keys = ['operation', 'name', 'path', 'location', 'rotation', 'scale', 'link'];
443
+ strictObject(operation, keys, kind);
444
+ requireKeys(operation, ['name', 'path'], kind);
445
+ validateAllowedName(operation.name, allowed);
446
+ validateProjectRelativePath(operation.path, `${kind}.path`, ['assets/models', 'assets/external', 'scenes'], kind === 'link_library' ? new Set(['.blend']) : MODEL_EXTENSIONS);
447
+ validateOptionalTransform(operation, kind);
448
+ if (Object.hasOwn(operation, 'link')) validateBoolean(operation.link, `${kind}.link`);
449
+ if (kind === 'import_asset' && operation.link && !operation.path.toLowerCase().endsWith('.blend')) {
450
+ fail('import_asset.link requires a .blend path');
451
+ }
452
+ break;
453
+ }
454
+ case 'import_audio': {
455
+ strictObject(operation, ['operation', 'name', 'path', 'location', 'volume'], kind);
456
+ requireKeys(operation, ['name', 'path'], kind);
457
+ validateAllowedName(operation.name, allowed);
458
+ validateProjectRelativePath(operation.path, `${kind}.path`, ['assets/audio', 'assets/external'], AUDIO_EXTENSIONS);
459
+ if (Object.hasOwn(operation, 'location')) validateVector(operation.location, `${kind}.location`, 3);
460
+ if (Object.hasOwn(operation, 'volume')) validateFiniteNumber(operation.volume, `${kind}.volume`, 0, 1);
461
+ break;
462
+ }
463
+ case 'image_reference': {
464
+ strictObject(operation, ['operation', 'name', 'path', 'location', 'rotation', 'scale'], kind);
465
+ requireKeys(operation, ['name', 'path'], kind);
466
+ validateAllowedName(operation.name, allowed);
467
+ validateImagePath(operation, `${kind}.path`, ['Documents/References', 'assets/textures', 'assets/hdri', 'assets/external']);
468
+ validateOptionalTransform(operation, kind);
469
+ break;
470
+ }
471
+ case 'image_texture': {
472
+ strictObject(operation, ['operation', 'name', 'objects', 'path', 'roughness', 'metallic', 'colorSpace', 'emission'], kind);
473
+ requireKeys(operation, ['name', 'objects', 'path', 'roughness', 'metallic'], kind);
474
+ validateString(operation.name, `${kind}.name`);
475
+ if (!Array.isArray(operation.objects) || operation.objects.length < 1 || operation.objects.length > 50) fail(`${kind}.objects must contain 1-50 object names`);
476
+ const objects = operation.objects.map((name, index) => validateString(name, `${kind}.objects[${index}]`));
477
+ if (new Set(objects).size !== objects.length) fail(`${kind}.objects must not contain duplicates`);
478
+ for (const name of objects) validateAllowedName(name, allowed, `${kind}.objects`);
479
+ validateImagePath(operation, `${kind}.path`, ['assets/textures', 'assets/hdri', 'assets/external']);
480
+ validateFiniteNumber(operation.roughness, `${kind}.roughness`, 0, 1);
481
+ validateFiniteNumber(operation.metallic, `${kind}.metallic`, 0, 1);
482
+ if (Object.hasOwn(operation, 'colorSpace') && !['sRGB', 'Non-Color'].includes(operation.colorSpace)) fail(`${kind}.colorSpace is invalid`);
483
+ validateEmission(operation.emission, `${kind}.emission`);
484
+ break;
485
+ }
486
+ case 'world_hdri': {
487
+ strictObject(operation, ['operation', 'path', 'strength', 'rotation'], kind);
488
+ requireKeys(operation, ['path'], kind);
489
+ validateImagePath(operation, `${kind}.path`, ['assets/hdri', 'assets/external'], HDRI_EXTENSIONS);
490
+ if (Object.hasOwn(operation, 'strength')) validateFiniteNumber(operation.strength, `${kind}.strength`, 0, 1000);
491
+ if (Object.hasOwn(operation, 'rotation')) validateFiniteNumber(operation.rotation, `${kind}.rotation`, -1e6, 1e6);
492
+ break;
493
+ }
494
+ case 'check_assets': {
495
+ strictObject(operation, ['operation', 'strict'], kind);
496
+ if (Object.hasOwn(operation, 'strict')) validateBoolean(operation.strict, `${kind}.strict`);
497
+ break;
498
+ }
499
+ case 'pack_assets': {
500
+ strictObject(operation, ['operation'], kind);
501
+ break;
502
+ }
503
+ case 'export_scene': {
504
+ strictObject(operation, ['operation', 'format', 'path'], kind);
505
+ requireKeys(operation, ['format', 'path'], kind);
506
+ validateString(operation.format, `${kind}.format`);
507
+ if (!EXPORT_EXTENSIONS.has(operation.format)) fail(`${kind}.format is invalid`);
508
+ validateProjectRelativePath(operation.path, `${kind}.path`, ['exports'], new Set([EXPORT_EXTENSIONS.get(operation.format)]));
509
+ break;
510
+ }
511
+ case 'render_settings': {
512
+ strictObject(operation, ['operation', 'width', 'height', 'samples', 'frame_start', 'frame_end', 'fps', 'useSequencer'], kind);
513
+ requireKeys(operation, ['width', 'height', 'samples'], kind);
514
+ for (const field of ['width', 'height', 'samples', 'frame_start', 'frame_end', 'fps']) {
515
+ if (Object.hasOwn(operation, field) && !Number.isInteger(operation[field])) fail(`${kind}.${field} must be an integer`);
516
+ }
517
+ validateFiniteNumber(operation.width, `${kind}.width`, 16, 4096);
518
+ validateFiniteNumber(operation.height, `${kind}.height`, 16, 4096);
519
+ validateFiniteNumber(operation.samples, `${kind}.samples`, 1, 1024);
520
+ if (Object.hasOwn(operation, 'frame_start')) validateFiniteNumber(operation.frame_start, `${kind}.frame_start`, 0, 1e7);
521
+ if (Object.hasOwn(operation, 'frame_end')) validateFiniteNumber(operation.frame_end, `${kind}.frame_end`, 1, 1e7);
522
+ if (Object.hasOwn(operation, 'frame_start') && Object.hasOwn(operation, 'frame_end') && operation.frame_end < operation.frame_start) fail(`${kind}.frame_end must not precede frame_start`);
523
+ if (Object.hasOwn(operation, 'fps')) validateFiniteNumber(operation.fps, `${kind}.fps`, 1, 240);
524
+ if (Object.hasOwn(operation, 'useSequencer')) validateBoolean(operation.useSequencer, `${kind}.useSequencer`);
525
+ break;
526
+ }
527
+ default:
528
+ fail(`unsupported operation: ${kind}`);
529
+ }
530
+ if (Object.hasOwn(operation, 'scene')) {
531
+ if (!SCENE_OPERATIONS.has(kind)) fail(`${kind}.scene is not supported`);
532
+ validateString(operation.scene, `${kind}.scene`);
533
+ }
534
+ return { kind, allowed };
535
+ }
536
+
537
+ function creationGuard(name) {
538
+ return `if bpy.data.objects.get(${quote(name)}) is not None:\n raise RuntimeError("object already exists")`;
539
+ }
540
+
541
+ function createSceneCode(operation) {
542
+ const previous = `previous_scene = ${activeSceneExpression()}`;
543
+ const source = operation.mode === 'NEW'
544
+ ? ''
545
+ : `source_scene = bpy.data.scenes.get(${quote(operation.source)})\nif source_scene is None:\n raise RuntimeError("source scene not found")\nif bpy.context.window is None:\n raise RuntimeError("scene creation requires a window context")\nbpy.context.window.scene = source_scene`;
546
+ const restore = operation.activate
547
+ ? `if bpy.context.window is None:\n raise RuntimeError("scene activation requires a window context")\nbpy.context.window.scene = created_scene`
548
+ : `if bpy.context.window is not None:\n bpy.context.window.scene = previous_scene`;
549
+ return `${objectModeGuard()}\n${previous}\n${source}\nbpy.ops.scene.new(type=${quote(operation.mode)})\ncreated_scene = bpy.context.scene\nif created_scene is None:\n raise RuntimeError("scene creation produced no scene")\ncreated_scene.name = ${quote(operation.name)}\n${restore}\n${jsonReceipt('create_scene', operation.name)}`;
550
+ }
551
+
552
+ function switchSceneCode(operation) {
553
+ return `${objectModeGuard()}\ntarget_scene = bpy.data.scenes.get(${quote(operation.name)})\nif target_scene is None:\n raise RuntimeError("scene not found")\nif bpy.context.window is None:\n raise RuntimeError("scene switching requires a window context")\nbpy.context.window.scene = target_scene\n${jsonReceipt('switch_scene', operation.name)}`;
554
+ }
555
+
556
+ function deleteSceneCode(operation) {
557
+ return `${objectModeGuard()}\ntarget_scene = bpy.data.scenes.get(${quote(operation.name)})\nif target_scene is None:\n raise RuntimeError("scene not found")\nif len(bpy.data.scenes) <= 1:\n raise RuntimeError("cannot delete the last scene")\nif target_scene == bpy.context.scene:\n raise RuntimeError("switch away from the scene before deleting it")\nfor other in bpy.data.scenes:\n editor = other.sequence_editor\n if editor is not None:\n strips = getattr(editor, 'strips', None)\n if strips is None:\n strips = getattr(editor, 'sequences', [])\n for strip in strips:\n if getattr(strip, 'scene', None) == target_scene:\n raise RuntimeError("scene is referenced by a Scene Strip")\nbpy.data.scenes.remove(target_scene, do_unlink=True)\n${jsonReceipt('delete_scene', operation.name)}`;
558
+ }
559
+
560
+ function duplicateSceneCode(operation) {
561
+ const previous = `previous_scene = ${activeSceneExpression()}`;
562
+ return `${objectModeGuard()}\n${previous}\nsource_scene = bpy.data.scenes.get(${quote(operation.source)})\nif source_scene is None:\n raise RuntimeError("source scene not found")\nif bpy.context.window is None:\n raise RuntimeError("scene duplication requires a window context")\nbpy.context.window.scene = source_scene\nbpy.ops.scene.new(type='FULL_COPY')\ncreated_scene = bpy.context.scene\nif created_scene is None:\n raise RuntimeError("scene duplication produced no scene")\ncreated_scene.name = ${quote(operation.name)}\nbpy.context.window.scene = previous_scene\n${jsonReceipt('duplicate_scene', operation.name)}`;
563
+ }
564
+
565
+ function setSceneCameraCode(operation) {
566
+ return `${objectModeGuard()}\ntarget_scene = bpy.data.scenes.get(${quote(operation.scene)})\nif target_scene is None:\n raise RuntimeError("scene not found")\ncamera = bpy.data.objects.get(${quote(operation.camera)})\nif camera is None:\n raise RuntimeError("camera object not found")\nif camera.type != 'CAMERA':\n raise RuntimeError("object is not a camera")\nif target_scene.objects.get(camera.name) is not camera:\n raise RuntimeError("camera does not belong to the target scene")\ntarget_scene.camera = camera\n${jsonReceipt('set_scene_camera', operation.camera)}`;
567
+ }
568
+
569
+ function sceneStripCode(operation) {
570
+ const name = operation.name ?? operation.source;
571
+ const end = Object.hasOwn(operation, 'frame_end')
572
+ ? `strip.frame_final_end = ${operation.frame_end}`
573
+ : '';
574
+ return `${objectModeGuard()}\ntarget_scene = bpy.data.scenes.get(${quote(operation.scene)})\nif target_scene is None:\n raise RuntimeError("scene not found")\nsource_scene = bpy.data.scenes.get(${quote(operation.source)})\nif source_scene is None:\n raise RuntimeError("source scene not found")\nif target_scene.sequence_editor is None:\n target_scene.sequence_editor_create()\nstrips = getattr(target_scene.sequence_editor, 'strips', None)\nif strips is None:\n strips = getattr(target_scene.sequence_editor, 'sequences', None)\nif strips is None:\n raise RuntimeError("sequencer strip collection is unavailable")\nstrip = strips.new_scene(${quote(name)}, source_scene, ${operation.channel}, ${operation.frame_start})\n${end}\n${jsonReceipt('scene_strip', name)}`;
575
+ }
576
+
577
+ function sceneFactsCode(sceneName = null) {
578
+ const selection = sceneName === null
579
+ ? 'scenes = list(bpy.data.scenes)'
580
+ : `target_scene = bpy.data.scenes.get(${quote(sceneName)})\nif target_scene is None:\n raise RuntimeError("scene not found")\nscenes = [target_scene]`;
581
+ return `import bpy\nimport bmesh\nimport json\n${selection}\ndef scene_strips(scene):\n editor = scene.sequence_editor\n if editor is None:\n return []\n strips = getattr(editor, 'strips', None)\n if strips is None:\n strips = getattr(editor, 'sequences', [])\n return list(strips)\ndef object_facts(obj):\n facts = {\n "name": obj.name,\n "type": obj.type,\n "location": [round(value, 6) for value in obj.location],\n "rotation": [round(value, 6) for value in obj.rotation_euler],\n "scale": [round(value, 6) for value in obj.scale],\n "dimensions": [round(value, 6) for value in obj.dimensions],\n "materials": [slot.material.name if slot.material is not None else None for slot in obj.material_slots],\n "modifiers": [modifier.type for modifier in obj.modifiers],\n }\n if obj.type == 'MESH':\n mesh = obj.data\n bm = bmesh.new()\n try:\n bm.from_mesh(mesh)\n facts["mesh"] = {\n "vertices": len(mesh.vertices),\n "edges": len(mesh.edges),\n "faces": len(mesh.polygons),\n "ngons": sum(1 for polygon in mesh.polygons if len(polygon.vertices) > 4),\n "non_manifold_edges": sum(1 for edge in bm.edges if not edge.is_manifold),\n "loose_vertices": sum(1 for vert in bm.verts if not vert.link_edges),\n "degenerate_faces": sum(1 for face in bm.faces if face.calc_area() < 1e-9),\n }\n finally:\n bm.free()\n return facts\npayload = []\nfor scene in scenes:\n payload.append({\n "name": scene.name,\n "objects": [object_facts(obj) for obj in list(scene.objects)[:1000]],\n "collections": [collection.name for collection in list(scene.collection.children_recursive)[:500]],\n "camera": scene.camera.name if scene.camera is not None else None,\n "resolution": [scene.render.resolution_x, scene.render.resolution_y],\n "engine": scene.render.engine,\n "frame_start": scene.frame_start,\n "frame_end": scene.frame_end,\n "fps": scene.render.fps,\n "film_transparent": bool(getattr(scene.render, "film_transparent", False)),\n "color_management": {"view_transform": scene.view_settings.view_transform, "look": scene.view_settings.look, "exposure": scene.view_settings.exposure, "gamma": scene.view_settings.gamma},\n "samples": getattr(getattr(scene, "eevee", None), "taa_render_samples", None),\n "use_sequencer": bool(getattr(scene.render, "use_sequencer", False)),\n "strips": [{"name": strip.name, "type": strip.type, "scene": getattr(getattr(strip, "scene", None), "name", None), "frame_start": strip.frame_start, "frame_end": strip.frame_final_end} for strip in scene_strips(scene)[:500]],\n })\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "scenes": payload}, separators=(",", ":")))`;
582
+ }
583
+
584
+ function createMeshCode(operation) {
585
+ const primitive = {
586
+ cube: `verts = [(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)]\nfaces = [(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)]`,
587
+ plane: `verts = [(-1, -1, 0), (1, -1, 0), (1, 1, 0), (-1, 1, 0)]\nfaces = [(0, 1, 2, 3)]`,
588
+ cylinder: `from math import sin, cos, pi\nsegments = 32\nverts = [(cos(2 * pi * segment / segments), sin(2 * pi * segment / segments), z) for z in (-1, 1) for segment in range(segments)]\nfaces = [tuple(reversed(range(segments))), tuple(range(segments, 2 * segments))]\nfor segment in range(segments):\n next_segment = (segment + 1) % segments\n faces.append((segment, next_segment, segments + next_segment, segments + segment))`,
589
+ uv_sphere: `from math import sin, cos, pi\nverts = [(0, 0, 1)]\nfaces = []\nsegments, rings = 16, 8\nfor ring in range(1, rings):\n phi = pi * ring / rings\n for segment in range(segments):\n theta = 2 * pi * segment / segments\n verts.append((sin(phi) * cos(theta), sin(phi) * sin(theta), cos(phi)))\nbottom = len(verts)\nverts.append((0, 0, -1))\nlast_ring = 1 + (rings - 2) * segments\nfor segment in range(segments):\n next_segment = (segment + 1) % segments\n faces.append((0, 1 + segment, 1 + next_segment))\n faces.append((bottom, last_ring + next_segment, last_ring + segment))\nfor ring in range(rings - 2):\n for segment in range(segments):\n next_segment = (segment + 1) % segments\n a = 1 + ring * segments + segment\n b = 1 + ring * segments + next_segment\n c = 1 + (ring + 1) * segments + next_segment\n d = 1 + (ring + 1) * segments + segment\n faces.append((a, d, c, b))`,
590
+ }[operation.primitive];
591
+ const name = quote(operation.name);
592
+ return `${objectModeGuard()}\n${hasScene(operation) ? `${sceneSetup(operation)}\n` : ''}${creationGuard(operation.name)}\n${primitive}\nmesh = bpy.data.meshes.new(${name} + "_Mesh")\nmesh.from_pydata(verts, [], faces)\nmesh.update()\nobj = bpy.data.objects.new(${name}, mesh)\n${sceneLink(operation, 'obj')}\nobj.location = ${quote(operation.location)}\nobj.scale = ${quote(operation.scale)}\n${jsonReceipt('create_mesh', operation.name)}`;
593
+ }
594
+
595
+ function textCode(operation) {
596
+ const size = operation.size ?? 1;
597
+ const extrude = operation.extrude ?? 0;
598
+ const alignX = operation.align_x ?? 'CENTER';
599
+ const alignY = operation.align_y ?? 'CENTER';
600
+ return `${objectModeGuard()}\n${hasScene(operation) ? `${sceneSetup(operation)}\n` : ''}${creationGuard(operation.name)}\ntext_data = bpy.data.curves.new(${quote(operation.name)} + "_Text", type='FONT')\ntext_data.body = ${quote(operation.body)}\ntext_data.size = ${size}\ntext_data.extrude = ${extrude}\ntext_data.align_x = ${quote(alignX)}\ntext_data.align_y = ${quote(alignY)}\ntext = bpy.data.objects.new(${quote(operation.name)}, text_data)\n${sceneLink(operation, 'text')}\n${transformCode(operation, 'text')}\n${jsonReceipt('text', operation.name)}`;
601
+ }
602
+
603
+ function modifierCode(operation) {
604
+ const settings = [];
605
+ if (operation.modifier === 'BEVEL') {
606
+ if (Object.hasOwn(operation, 'width')) settings.push(`modifier.width = ${operation.width}`);
607
+ if (Object.hasOwn(operation, 'segments')) settings.push(`modifier.segments = ${operation.segments}`);
608
+ if (Object.hasOwn(operation, 'limit_method')) settings.push(`modifier.limit_method = ${quote(operation.limit_method)}`);
609
+ if (Object.hasOwn(operation, 'angle_limit')) settings.push(`modifier.angle_limit = ${operation.angle_limit}`);
610
+ }
611
+ if (operation.modifier === 'SUBSURF') {
612
+ if (Object.hasOwn(operation, 'levels')) settings.push(`modifier.levels = ${operation.levels}`);
613
+ if (Object.hasOwn(operation, 'render_levels')) settings.push(`modifier.render_levels = ${operation.render_levels}`);
614
+ }
615
+ if (operation.modifier === 'SOLIDIFY') {
616
+ if (Object.hasOwn(operation, 'thickness')) settings.push(`modifier.thickness = ${operation.thickness}`);
617
+ if (Object.hasOwn(operation, 'offset')) settings.push(`modifier.offset = ${operation.offset}`);
618
+ if (Object.hasOwn(operation, 'use_rim')) settings.push(`modifier.use_rim = ${operation.use_rim ? 'True' : 'False'}`);
619
+ }
620
+ if (operation.modifier === 'MIRROR') {
621
+ const axis = operation.axis ?? 'X';
622
+ settings.push(`modifier.use_axis[0] = ${axis === 'X' ? 'True' : 'False'}`);
623
+ settings.push(`modifier.use_axis[1] = ${axis === 'Y' ? 'True' : 'False'}`);
624
+ settings.push(`modifier.use_axis[2] = ${axis === 'Z' ? 'True' : 'False'}`);
625
+ if (Object.hasOwn(operation, 'use_clip')) settings.push(`modifier.use_clip = ${operation.use_clip ? 'True' : 'False'}`);
626
+ }
627
+ if (operation.modifier === 'ARRAY') {
628
+ if (Object.hasOwn(operation, 'count')) settings.push(`modifier.count = ${operation.count}`);
629
+ if (Object.hasOwn(operation, 'relative_offset')) {
630
+ settings.push(`modifier.use_relative_offset = True`);
631
+ settings.push(`modifier.relative_offset_displace = ${quote(operation.relative_offset)}`);
632
+ }
633
+ }
634
+ const apply = operation.apply === true
635
+ ? `bpy.context.view_layer.objects.active = obj\nobj.select_set(True)\nbpy.ops.object.modifier_apply(modifier=modifier.name)`
636
+ : '';
637
+ return `${objectModeGuard()}\n${objectLookup(operation.name)}\n${sceneObjectGuard(operation)}\nif obj.type != 'MESH':\n raise RuntimeError("modifier requires a mesh object")\nmodifier = obj.modifiers.new(${quote(operation.name + ' ' + operation.modifier)}, ${quote(operation.modifier)})\n${settings.join('\n')}\n${apply}\n${jsonReceipt('modifier', operation.name)}`;
638
+ }
639
+
640
+ function assetPathCode(path, { file = true } = {}) {
641
+ return `asset_path = os.path.abspath(bpy.path.abspath("//" + ${quote(path)}))\n${file ? 'if not os.path.isfile(asset_path):\n raise RuntimeError("asset file not found")' : ''}\nnormalized_asset = asset_path.replace("\\\\", "/")\nif "/assets/external/" in normalized_asset:\n search_dir = os.path.dirname(asset_path)\n while search_dir and not (os.path.isfile(os.path.join(search_dir, "SOURCE.md")) and os.path.isfile(os.path.join(search_dir, "LICENSE.txt"))):\n if os.path.basename(search_dir) == "external":\n raise RuntimeError("external asset requires SOURCE.md and LICENSE.txt in its asset folder")\n parent_dir = os.path.dirname(search_dir)\n if parent_dir == search_dir:\n raise RuntimeError("external asset requires SOURCE.md and LICENSE.txt in its asset folder")\n search_dir = parent_dir`;
642
+ }
643
+
644
+ function transformCode(operation, target) {
645
+ const fields = [];
646
+ if (Object.hasOwn(operation, 'location')) fields.push(`${target}.location = ${quote(operation.location)}`);
647
+ if (Object.hasOwn(operation, 'rotation')) fields.push(`${target}.rotation_euler = ${quote(operation.rotation)}`);
648
+ if (Object.hasOwn(operation, 'scale')) fields.push(`${target}.scale = ${quote(operation.scale)}`);
649
+ return fields.join('\n');
650
+ }
651
+
652
+ function parentCode(operation) {
653
+ return `creation_parent = bpy.data.objects.new(${quote(operation.name)}, None)\ncreation_parent.empty_display_type = 'PLAIN_AXES'\n${sceneLink(operation, 'creation_parent')}`;
654
+ }
655
+
656
+ function importAssetCode(operation, { libraryOnly = false } = {}) {
657
+ const link = libraryOnly ? operation.link !== false : Boolean(operation.link);
658
+ const targetScene = hasScene(operation) ? 'target_scene' : 'bpy.context.scene';
659
+ const targetCollection = hasScene(operation) ? 'target_scene.collection' : 'bpy.context.collection';
660
+ const loadLibrary = `with bpy.data.libraries.load(asset_path, link=${link ? 'True' : 'False'}) as (data_from, data_to):\n data_to.objects = list(data_from.objects)\nimported = [obj for obj in data_to.objects if obj is not None]\nfor obj in imported:\n ${targetCollection}.objects.link(obj)`;
661
+ const operators = `existing_objects = set(${targetScene}.objects)\nsuffix = os.path.splitext(asset_path)[1].lower()\nif suffix in ('.glb', '.gltf'):\n if not hasattr(bpy.ops.import_scene, 'gltf'):\n raise RuntimeError("glTF import is unavailable")\n bpy.ops.import_scene.gltf(filepath=asset_path)\nelif suffix == '.fbx':\n if not hasattr(bpy.ops.import_scene, 'fbx'):\n raise RuntimeError("FBX import is unavailable")\n bpy.ops.import_scene.fbx(filepath=asset_path)\nelif suffix == '.usd' or suffix == '.usdz':\n if not hasattr(bpy.ops.wm, 'usd_import'):\n raise RuntimeError("USD import is unavailable")\n bpy.ops.wm.usd_import(filepath=asset_path)\nelif suffix == '.obj':\n if hasattr(bpy.ops.wm, 'obj_import'):\n bpy.ops.wm.obj_import(filepath=asset_path)\n elif hasattr(bpy.ops.import_scene, 'obj'):\n bpy.ops.import_scene.obj(filepath=asset_path)\n else:\n raise RuntimeError("OBJ import is unavailable")\nelse:\n raise RuntimeError("unsupported asset extension")\nimported = [obj for obj in ${targetScene}.objects if obj not in existing_objects]`;
662
+ const parentImported = `for obj in imported:\n if obj.parent is None:\n obj.parent = creation_parent\nif not imported:\n raise RuntimeError("asset import produced no objects")`;
663
+ const core = libraryOnly
664
+ ? `${creationGuard(operation.name)}\n${assetPathCode(operation.path)}\n${parentCode(operation)}\n${loadLibrary}\n${parentImported}`
665
+ : `${creationGuard(operation.name)}\n${assetPathCode(operation.path)}\n${parentCode(operation)}\nfor obj in list(bpy.context.selected_objects):\n obj.select_set(False)\nif os.path.splitext(asset_path)[1].lower() == '.blend':\n${loadLibrary.split('\n').map((line) => ` ${line}`).join('\n')}\nelse:\n${operators.split('\n').map((line) => ` ${line}`).join('\n')}\n${parentImported}`;
666
+ const prefix = hasScene(operation)
667
+ ? `${sceneSetup(operation)}\nprevious_scene = ${activeSceneExpression()}\nif bpy.context.window is None:\n raise RuntimeError("scene-aware import requires a window context")\nbpy.context.window.scene = target_scene\n`
668
+ : '';
669
+ const restore = hasScene(operation)
670
+ ? `\nfinally:\n bpy.context.window.scene = previous_scene`
671
+ : '';
672
+ const body = `${objectModeGuard()}\n${prefix}${hasScene(operation) ? 'try:\n' : ''}${core.split('\n').map((line) => ` ${line}`).join('\n')}${hasScene(operation) ? '\n' + transformCode(operation, 'creation_parent').split('\n').map((line) => ` ${line}`).join('\n') + '\n ' + jsonReceipt(libraryOnly ? 'link_library' : 'import_asset', operation.name) + restore : '\n' + transformCode(operation, 'creation_parent') + '\n' + jsonReceipt(libraryOnly ? 'link_library' : 'import_asset', operation.name)}`;
673
+ return body;
674
+ }
675
+
676
+ function imageReferenceCode(operation) {
677
+ return `${objectModeGuard()}\n${hasScene(operation) ? `${sceneSetup(operation)}\n` : ''}${creationGuard(operation.name)}\n${assetPathCode(operation.path)}\nimage = bpy.data.images.load(asset_path, check_existing=True)\nreference = bpy.data.objects.new(${quote(operation.name)}, None)\nreference.empty_display_type = 'IMAGE'\nreference.empty_display_size = 1.0\nreference.data = image\nreference.show_in_front = True\nreference.hide_render = True\n${sceneLink(operation, 'reference')}\n${transformCode(operation, 'reference')}\n${jsonReceipt('image_reference', operation.name)}`;
678
+ }
679
+
680
+ function imageTextureCode(operation) {
681
+ const colorSpace = operation.colorSpace ?? 'sRGB';
682
+ const emission = operation.emission ? `\nemission_socket = bsdf.inputs.get('Emission Color')\nif emission_socket is None:\n emission_socket = bsdf.inputs.get('Emission')\nif emission_socket is None:\n raise RuntimeError("Principled emission input not found")\nemission_socket.default_value = ${quote(operation.emission.color)}\nbsdf.inputs['Emission Strength'].default_value = ${operation.emission.strength}` : '';
683
+ const assign = operation.objects.map((name) => `${objectLookup(name)}\n${sceneObjectGuard(operation)}\nif obj.type != 'MESH':\n raise RuntimeError("image_texture requires mesh objects")\nif obj.data.users > 1 or not obj.data.is_editable:\n obj.data = obj.data.copy()\nif obj.data.materials:\n obj.active_material = material\nelse:\n obj.data.materials.append(material)`).join('\n');
684
+ return `${objectModeGuard()}\n${assetPathCode(operation.path)}\nimage = bpy.data.images.load(asset_path, check_existing=True)\nimage.colorspace_settings.name = ${quote(colorSpace)}\nmaterial = bpy.data.materials.new(${quote(operation.name)} + "_Material")\nmaterial.use_nodes = True\nnodes = material.node_tree.nodes\nlinks = material.node_tree.links\nbsdf = next((node for node in nodes if node.type == 'BSDF_PRINCIPLED'), None)\nif bsdf is None:\n raise RuntimeError("Principled BSDF node not found")\ntexture = nodes.new('ShaderNodeTexImage')\ntexture.image = image\ntexture.interpolation = 'Linear'\ncoordinates = nodes.new('ShaderNodeTexCoord')\nlinks.new(coordinates.outputs['Generated'], texture.inputs['Vector'])\nlinks.new(texture.outputs['Color'], bsdf.inputs['Base Color'])\nbsdf.inputs['Roughness'].default_value = ${operation.roughness}\nbsdf.inputs['Metallic'].default_value = ${operation.metallic}${emission}\n${assign}\n${jsonReceipt('image_texture')}`;
685
+ }
686
+
687
+ function worldHdriCode(operation) {
688
+ const strength = operation.strength ?? 1;
689
+ const rotation = operation.rotation ?? 0;
690
+ const scene = hasScene(operation) ? `${sceneSetup(operation)}\nscene = target_scene` : 'scene = bpy.context.scene';
691
+ return `${scene}\n${assetPathCode(operation.path)}\nimage = bpy.data.images.load(asset_path, check_existing=True)\nworld = scene.world\nif world is None:\n world = bpy.data.worlds.new("World")\n scene.world = world\nworld.use_nodes = True\nnodes = world.node_tree.nodes\nlinks = world.node_tree.links\nnodes.clear()\noutput = nodes.new('ShaderNodeOutputWorld')\nbackground = nodes.new('ShaderNodeBackground')\nenvironment = nodes.new('ShaderNodeTexEnvironment')\nmapping = nodes.new('ShaderNodeMapping')\ncoordinates = nodes.new('ShaderNodeTexCoord')\nenvironment.image = image\nmapping.inputs['Rotation'].default_value = (0, 0, ${rotation})\nlinks.new(coordinates.outputs['Generated'], mapping.inputs['Vector'])\nlinks.new(mapping.outputs['Vector'], environment.inputs['Vector'])\nlinks.new(environment.outputs['Color'], background.inputs['Color'])\nbackground.inputs['Strength'].default_value = ${strength}\nlinks.new(background.outputs['Background'], output.inputs['Surface'])\n${jsonReceipt('world_hdri')}`;
692
+ }
693
+
694
+ function checkAssetsCode(operation) {
695
+ const strict = operation.strict === true;
696
+ return `external = bpy.utils.blend_paths(absolute=True, packed=False, local=True)\nmissing = [path for path in external if not os.path.exists(path)]\n${strict ? 'if missing:\n raise RuntimeError("missing external files: " + ", ".join(missing))\n' : ''}packed = [image.name for image in bpy.data.images if image.packed_file is not None]\nlibraries = [{"path": library.filepath, "packed": bool(getattr(library, "packed_file", None))} for library in bpy.data.libraries]\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "operation": "check_assets", "strict": ${strict ? 'True' : 'False'}, "external": external, "missing": missing, "packed": packed, "libraries": libraries}, separators=(",", ":")))`;
697
+ }
698
+
699
+ function packAssetsCode() {
700
+ return `bpy.ops.file.pack_all()\npacked = [image.name for image in bpy.data.images if image.packed_file is not None]\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "operation": "pack_assets", "packed": packed}, separators=(",", ":")))`;
701
+ }
702
+
703
+ function exportSceneCode(operation) {
704
+ const format = operation.format;
705
+ const code = {
706
+ glb: `if not hasattr(bpy.ops.export_scene, 'gltf'):\n raise RuntimeError("glTF export is unavailable")\nbpy.ops.export_scene.gltf(filepath=export_path, export_format='GLB', use_selection=False)`,
707
+ gltf: `if not hasattr(bpy.ops.export_scene, 'gltf'):\n raise RuntimeError("glTF export is unavailable")\nbpy.ops.export_scene.gltf(filepath=export_path, export_format='GLTF_SEPARATE', use_selection=False)`,
708
+ fbx: `if not hasattr(bpy.ops.export_scene, 'fbx'):\n raise RuntimeError("FBX export is unavailable")\nbpy.ops.export_scene.fbx(filepath=export_path, use_selection=False)`,
709
+ usd: `if not hasattr(bpy.ops.wm, 'usd_export'):\n raise RuntimeError("USD export is unavailable")\nbpy.ops.wm.usd_export(filepath=export_path, selected_objects_only=False)`,
710
+ obj: `if hasattr(bpy.ops.wm, 'obj_export'):\n bpy.ops.wm.obj_export(filepath=export_path, export_selected_objects=False)\nelif hasattr(bpy.ops.export_scene, 'obj'):\n bpy.ops.export_scene.obj(filepath=export_path, use_selection=False)\nelse:\n raise RuntimeError("OBJ export is unavailable")`,
711
+ }[format];
712
+ const scene = hasScene(operation)
713
+ ? `${sceneSetup(operation)}\nprevious_scene = ${activeSceneExpression()}\nif bpy.context.window is None:\n raise RuntimeError("scene-aware export requires a window context")\nbpy.context.window.scene = target_scene\nscene_name = target_scene.name`
714
+ : 'scene_name = bpy.context.scene.name';
715
+ const restore = hasScene(operation) ? '\nif bpy.context.window is not None:\n bpy.context.window.scene = previous_scene' : '';
716
+ return `${scene}\nimport hashlib\nexport_path = os.path.abspath(bpy.path.abspath("//" + ${quote(operation.path)}))\nos.makedirs(os.path.dirname(export_path), exist_ok=True)\n${code}\nif not os.path.exists(export_path):\n raise RuntimeError("export produced no file")\nwith open(export_path, 'rb') as export_file:\n export_bytes = export_file.read()\n${restore}\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "operation": "export_scene", "format": ${quote(format)}, "path": ${quote(operation.path)}, "bytes": os.path.getsize(export_path), "sha256": hashlib.sha256(export_bytes).hexdigest(), "scene": scene_name}, separators=(",", ":")))`;
717
+ }
718
+
719
+ function importAudioCode(operation) {
720
+ const volume = operation.volume ?? 1;
721
+ return `${objectModeGuard()}\n${hasScene(operation) ? `${sceneSetup(operation)}\n` : ''}${creationGuard(operation.name)}\n${assetPathCode(operation.path)}\nsound = bpy.data.sounds.load(asset_path, check_existing=True)\nspeaker_data = bpy.data.speakers.new(${quote(operation.name)} + "_Speaker")\nspeaker_data.sound = sound\nspeaker_data.volume = ${volume}\nspeaker = bpy.data.objects.new(${quote(operation.name)}, speaker_data)\n${sceneLink(operation, 'speaker')}\n${Object.hasOwn(operation, 'location') ? `speaker.location = ${quote(operation.location)}` : ''}\n${jsonReceipt('import_audio', operation.name)}`;
722
+ }
723
+
724
+ function operationBody(operation) {
725
+ switch (operation.operation) {
726
+ case 'create_scene':
727
+ return createSceneCode(operation);
728
+ case 'switch_scene':
729
+ return switchSceneCode(operation);
730
+ case 'delete_scene':
731
+ return deleteSceneCode(operation);
732
+ case 'duplicate_scene':
733
+ return duplicateSceneCode(operation);
734
+ case 'set_scene_camera':
735
+ return setSceneCameraCode(operation);
736
+ case 'scene_strip':
737
+ return sceneStripCode(operation);
738
+ case 'import_asset':
739
+ return importAssetCode(operation);
740
+ case 'link_library':
741
+ return importAssetCode(operation, { libraryOnly: true });
742
+ case 'import_audio':
743
+ return importAudioCode(operation);
744
+ case 'image_reference':
745
+ return imageReferenceCode(operation);
746
+ case 'image_texture':
747
+ return imageTextureCode(operation);
748
+ case 'world_hdri':
749
+ return worldHdriCode(operation);
750
+ case 'check_assets':
751
+ return checkAssetsCode(operation);
752
+ case 'pack_assets':
753
+ return packAssetsCode();
754
+ case 'export_scene':
755
+ return exportSceneCode(operation);
756
+ case 'create_mesh':
757
+ return createMeshCode(operation);
758
+ case 'text':
759
+ return textCode(operation);
760
+ case 'modifier':
761
+ return modifierCode(operation);
762
+ case 'transform': {
763
+ const fields = [];
764
+ if (Object.hasOwn(operation, 'location')) fields.push(`obj.location = ${quote(operation.location)}`);
765
+ if (Object.hasOwn(operation, 'rotation')) fields.push(`obj.rotation_euler = ${quote(operation.rotation)}`);
766
+ if (Object.hasOwn(operation, 'scale')) fields.push(`obj.scale = ${quote(operation.scale)}`);
767
+ return `${objectModeGuard()}\n${objectLookup(operation.name)}\n${sceneObjectGuard(operation)}\n${fields.join('\n')}\n${jsonReceipt('transform', operation.name)}`;
768
+ }
769
+ case 'material': {
770
+ const emission = operation.emission ? `\nemission_socket = bsdf.inputs.get('Emission Color')\nif emission_socket is None:\n emission_socket = bsdf.inputs.get('Emission')\nif emission_socket is None:\n raise RuntimeError("Principled emission input not found")\nemission_socket.default_value = ${quote(operation.emission.color)}\nbsdf.inputs['Emission Strength'].default_value = ${operation.emission.strength}` : '';
771
+ return `${objectModeGuard()}\n${objectLookup(operation.name)}\n${sceneObjectGuard(operation)}\nif obj.type not in ('MESH', 'FONT', 'CURVE'):\n raise RuntimeError("material requires mesh, text, or curve object")\nmaterial = bpy.data.materials.new(${quote(operation.name)} + "_Material")\nmaterial.use_nodes = True\nbsdf = next((node for node in material.node_tree.nodes if node.type == 'BSDF_PRINCIPLED'), None)\nif bsdf is None:\n raise RuntimeError("Principled BSDF node not found")\nbsdf.inputs['Base Color'].default_value = ${quote(operation.color)}\nbsdf.inputs['Roughness'].default_value = ${operation.roughness}\nbsdf.inputs['Metallic'].default_value = ${operation.metallic}${emission}\nif obj.data.users > 1 or not obj.data.is_editable:\n obj.data = obj.data.copy()\nif obj.data.materials:\n obj.active_material = material\nelse:\n obj.data.materials.append(material)\n${jsonReceipt('material', operation.name)}`;
772
+ }
773
+ case 'shared_material': {
774
+ const emission = operation.emission ? `\nemission_socket = bsdf.inputs.get('Emission Color')\nif emission_socket is None:\n emission_socket = bsdf.inputs.get('Emission')\nif emission_socket is None:\n raise RuntimeError("Principled emission input not found")\nemission_socket.default_value = ${quote(operation.emission.color)}\nbsdf.inputs['Emission Strength'].default_value = ${operation.emission.strength}` : '';
775
+ const assign = operation.objects.map((name) => `${objectLookup(name)}\n${sceneObjectGuard(operation)}\nif obj.type not in ('MESH', 'FONT', 'CURVE'):\n raise RuntimeError("shared_material requires mesh, text, or curve objects")\nif obj.data.users > 1 or not obj.data.is_editable:\n obj.data = obj.data.copy()\nif obj.data.materials:\n obj.active_material = material\nelse:\n obj.data.materials.append(material)`).join('\n');
776
+ return `${objectModeGuard()}\nmaterial = bpy.data.materials.new(${quote(operation.name)} + "_Material")\nmaterial.use_nodes = True\nbsdf = next((node for node in material.node_tree.nodes if node.type == 'BSDF_PRINCIPLED'), None)\nif bsdf is None:\n raise RuntimeError("Principled BSDF node not found")\nbsdf.inputs['Base Color'].default_value = ${quote(operation.color)}\nbsdf.inputs['Roughness'].default_value = ${operation.roughness}\nbsdf.inputs['Metallic'].default_value = ${operation.metallic}${emission}\n${assign}\n${jsonReceipt('shared_material', operation.name)}`;
777
+ }
778
+ case 'duplicate': {
779
+ const overrides = [];
780
+ if (Object.hasOwn(operation, 'location')) overrides.push(`duplicate.location = ${quote(operation.location)}`);
781
+ if (Object.hasOwn(operation, 'rotation')) overrides.push(`duplicate.rotation_euler = ${quote(operation.rotation)}`);
782
+ if (Object.hasOwn(operation, 'scale')) overrides.push(`duplicate.scale = ${quote(operation.scale)}`);
783
+ return `${objectModeGuard()}\n${objectLookup(operation.name)}\n${sceneObjectGuard(operation)}\n${creationGuard(operation.new_name)}\nduplicate = obj.copy()\nduplicate.data = obj.data.copy()\nduplicate.name = ${quote(operation.new_name)}\n${sceneLink(operation, 'duplicate')}\n${overrides.join('\n')}\n${jsonReceipt('duplicate', operation.new_name)}`;
784
+ }
785
+ case 'create_light': {
786
+ const color = Object.hasOwn(operation, 'color') ? `\nlight_data.color = ${quote(operation.color)}` : '';
787
+ const temperature = Object.hasOwn(operation, 'temperature') ? `\nif not hasattr(light_data, 'temperature'):\n raise RuntimeError("light temperature unavailable")\nlight_data.temperature = ${operation.temperature}\nif hasattr(light_data, 'use_temperature'):\n light_data.use_temperature = True` : '';
788
+ return `${objectModeGuard()}\n${hasScene(operation) ? `${sceneSetup(operation)}\n` : ''}${creationGuard(operation.name)}\nlight_enum = ${quote(operation.kind)}\n${enumValues('bpy.types.Light', 'type')}\nif light_enum not in valid_values:\n raise RuntimeError("unsupported light type")\nlight_data = bpy.data.lights.new(${quote(operation.name)} + "_Light", type=light_enum)\nlight_data.energy = ${operation.energy}${color}${temperature}\nlight = bpy.data.objects.new(${quote(operation.name)}, light_data)\n${sceneLink(operation, 'light')}\nlight.location = ${quote(operation.location)}\n${jsonReceipt('create_light', operation.name)}`;
789
+ }
790
+ case 'create_camera': {
791
+ const dof = operation.dof ? `\ncamera_data.dof.use_dof = True\ncamera_data.dof.aperture_fstop = ${operation.dof.aperture}\ncamera_data.dof.focus_distance = ${operation.dof.focus_distance}${Object.hasOwn(operation.dof, 'focus_object') ? `\nfocus_object = bpy.data.objects.get(${quote(operation.dof.focus_object)})\nif focus_object is None:\n raise RuntimeError("DOF focus object not found")\ncamera_data.dof.focus_object = focus_object` : ''}` : '';
792
+ return `${objectModeGuard()}\n${hasScene(operation) ? `${sceneSetup(operation)}\n` : ''}${creationGuard(operation.name)}\ncamera_data = bpy.data.cameras.new(${quote(operation.name)} + "_Camera")\ncamera_data.lens = ${operation.lens}\ncamera = bpy.data.objects.new(${quote(operation.name)}, camera_data)\n${sceneLink(operation, 'camera')}\ncamera.location = ${quote(operation.location)}\ncamera.rotation_euler = ${quote(operation.rotation)}${dof}\n${jsonReceipt('create_camera', operation.name)}`;
793
+ }
794
+ case 'set_camera':
795
+ return `${objectModeGuard()}\n${objectLookup(operation.name)}\n${sceneObjectGuard(operation)}\nif obj.type != 'CAMERA':\n raise RuntimeError("set_camera requires camera object")\n${hasScene(operation) ? 'target_scene' : 'bpy.context.scene'}.camera = obj\n${jsonReceipt('set_camera', operation.name)}`;
796
+ case 'render_settings': {
797
+ const scene = hasScene(operation) ? `${sceneSetup(operation)}\nscene = target_scene` : 'scene = bpy.context.scene';
798
+ const extras = [];
799
+ if (Object.hasOwn(operation, 'frame_start')) extras.push(`scene.frame_start = ${operation.frame_start}`);
800
+ if (Object.hasOwn(operation, 'frame_end')) extras.push(`scene.frame_end = ${operation.frame_end}`);
801
+ if (Object.hasOwn(operation, 'fps')) extras.push(`scene.render.fps = ${operation.fps}`);
802
+ if (Object.hasOwn(operation, 'useSequencer')) extras.push(`scene.render.use_sequencer = ${operation.useSequencer ? 'True' : 'False'}`);
803
+ return `${scene}\nscene.render.resolution_x = ${operation.width}\nscene.render.resolution_y = ${operation.height}\nscene.render.resolution_percentage = 100\nif hasattr(scene, 'cycles') and hasattr(scene.cycles, 'samples'):\n scene.cycles.samples = ${operation.samples}\nif hasattr(scene, 'eevee') and hasattr(scene.eevee, 'taa_render_samples'):\n scene.eevee.taa_render_samples = ${operation.samples}\n${extras.join('\n')}\n${jsonReceipt('render_settings')}`;
804
+ }
805
+ case 'delete_object':
806
+ return `${objectModeGuard()}\n${objectLookup(operation.name)}\n${sceneObjectGuard(operation)}\n${hasScene(operation) ? `${sceneSetup(operation)}\nfor collection in [target_scene.collection, *target_scene.collection.children_recursive]:\n if obj.name in collection.objects:\n collection.objects.unlink(obj)\nif not obj.users_collection:\n bpy.data.objects.remove(obj, do_unlink=True)` : 'bpy.data.objects.remove(obj, do_unlink=True)'}\n${jsonReceipt('delete_object', operation.name)}`;
807
+ default:
808
+ fail(`unsupported operation: ${operation.operation}`);
809
+ }
810
+ }
811
+
812
+ function operationCode(operation, allowedObjects) {
813
+ validateOperation(operation, allowedObjects);
814
+ const body = operationBody(operation);
815
+ return script(body);
816
+ }
817
+
818
+ function operationBatchCode(payload, allowedObjects) {
819
+ if (!isPlainObject(payload)) fail('apply payload must be an object');
820
+ if (!Object.hasOwn(payload, 'operations')) return [operationCode(payload, allowedObjects)];
821
+ strictObject(payload, ['operations'], 'apply payload');
822
+ if (!Array.isArray(payload.operations) || payload.operations.length < 1 || payload.operations.length > MAX_BATCH) {
823
+ fail(`apply payload.operations must contain 1-${MAX_BATCH} operations`);
824
+ }
825
+ return payload.operations.map((operation, index) => {
826
+ try { return operationCode(operation, allowedObjects); }
827
+ catch (error) { throw new TypeError(`apply payload.operations[${index}]: ${error.message}`); }
828
+ });
829
+ }
830
+
831
+ function validateProjectPath(value, label) {
832
+ if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) fail(`${label} must be non-empty path`);
833
+ if (!value.toLowerCase().endsWith('.blend')) fail(`${label} must end with .blend`);
834
+ return value;
835
+ }
836
+
837
+ function validateRenderPath(value) {
838
+ if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) fail('renderPath must be non-empty path');
839
+ if (value.startsWith('//') === false) fail('renderPath must be project-relative (//...)');
840
+ if (value.split(/[\\/]/).includes('..')) fail('renderPath must stay within project');
841
+ return value;
842
+ }
843
+
844
+ function preflightCode(scenePath) {
845
+ validateProjectPath(scenePath, 'scenePath');
846
+ const pathLiteral = quote(scenePath);
847
+ // blend_paths documents referenced paths, not exhaustive sequence-frame/UDIM-tile expansion.
848
+ // This existence check excludes packed data and linked-library paths (local=True).
849
+ return `import bpy\nimport json\nimport os\n\nexpected = ${pathLiteral}\ncurrent = bpy.data.filepath\nif current and os.path.normcase(os.path.abspath(bpy.path.abspath(current))) != os.path.normcase(os.path.abspath(bpy.path.abspath(expected))):\n raise RuntimeError("scene filepath mismatch")\nexternal = bpy.utils.blend_paths(absolute=True, packed=False, local=True)\nmissing = [path for path in external if not os.path.exists(path)]\nreceipt = {\n "ok": True,\n "filepath": current,\n "is_dirty": bool(bpy.data.is_dirty),\n "version": list(bpy.app.version),\n "packed": sum(1 for image in bpy.data.images if image.packed_file is not None),\n "external": external,\n "missing": missing,\n}\nprint("HARNESS_JSON:" + json.dumps(receipt, separators=(",", ":")))\n`;
850
+ }
851
+
852
+ function saveCode(scenePath) {
853
+ validateProjectPath(scenePath, 'scenePath');
854
+ const pathLiteral = quote(scenePath);
855
+ return `import bpy\nimport json\nimport os\n\nexpected = ${pathLiteral}\ncurrent = bpy.data.filepath\nif current and os.path.normcase(os.path.abspath(bpy.path.abspath(current))) != os.path.normcase(os.path.abspath(bpy.path.abspath(expected))):\n raise RuntimeError("refusing mismatched existing scene filepath")\nbpy.ops.wm.save_as_mainfile(filepath=expected, check_existing=False)\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "filepath": bpy.data.filepath}, separators=(",", ":")))\n`;
856
+ }
857
+
858
+ function sceneSelectionCode(sceneName) {
859
+ if (!sceneName) return 'target_scene = bpy.context.scene';
860
+ return `target_scene = bpy.data.scenes.get(${quote(sceneName)})\nif target_scene is None:\n raise RuntimeError("scene not found")`;
861
+ }
862
+
863
+ function renderPreflightCode(scenePath, sceneName = null) {
864
+ validateProjectPath(scenePath, 'scenePath');
865
+ return `import bpy\nimport json\nimport os\n\nexpected = ${quote(scenePath)}\ncurrent = bpy.data.filepath\nif not current or os.path.normcase(os.path.abspath(bpy.path.abspath(current))) != os.path.normcase(os.path.abspath(bpy.path.abspath(expected))):\n raise RuntimeError("scene filepath mismatch")\n${sceneSelectionCode(sceneName)}\nif not getattr(target_scene, 'render', None):\n raise RuntimeError("scene render settings unavailable")\nuse_sequencer = bool(getattr(target_scene.render, 'use_sequencer', False)) and target_scene.sequence_editor is not None\ncamera = target_scene.camera\nif camera is None and not use_sequencer:\n raise RuntimeError("scene camera is not set")\nif camera is not None and camera.type != 'CAMERA':\n raise RuntimeError("scene camera is not a camera object")\nif int(target_scene.render.resolution_x) < 16 or int(target_scene.render.resolution_y) < 16:\n raise RuntimeError("render resolution is invalid")\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "scene": getattr(target_scene, "name", None), "camera": camera.name if camera is not None else None, "use_sequencer": use_sequencer, "resolution": [target_scene.render.resolution_x, target_scene.render.resolution_y]}, separators=(",", ":")))\n`;
866
+ }
867
+
868
+ function renderCode(scenePath, renderPath, sceneName = null) {
869
+ validateProjectPath(scenePath, 'scenePath');
870
+ validateRenderPath(renderPath);
871
+ const sceneLiteral = quote(scenePath);
872
+ const renderLiteral = quote(renderPath);
873
+ const switchCode = sceneName
874
+ ? `previous_scene = ${activeSceneExpression()}\nif bpy.context.window is None:\n raise RuntimeError("scene-aware render requires a window context")\nbpy.context.window.scene = target_scene`
875
+ : '';
876
+ const restore = sceneName
877
+ ? `\n if bpy.context.window is not None:\n bpy.context.window.scene = previous_scene`
878
+ : '';
879
+ return `import bpy\nimport json\nimport os\n\nexpected = ${sceneLiteral}\ncurrent = bpy.data.filepath\nif not current or os.path.normcase(os.path.abspath(bpy.path.abspath(current))) != os.path.normcase(os.path.abspath(bpy.path.abspath(expected))):\n raise RuntimeError("scene filepath mismatch")\n${sceneSelectionCode(sceneName)}\n${switchCode}\nimage_settings = target_scene.render.image_settings\n${enumValues('bpy.types.ImageFormatSettings', 'file_format')}\nif 'PNG' not in valid_values:\n raise RuntimeError("PNG render format is unavailable")\nold_filepath = target_scene.render.filepath\nold_resolution_percentage = target_scene.render.resolution_percentage\nold_file_format = image_settings.file_format\nold_color_mode = image_settings.color_mode\nold_color_depth = image_settings.color_depth\nold_use_file_extension = target_scene.render.use_file_extension\ntry:\n image_settings.file_format = 'PNG'\n target_scene.render.use_file_extension = True\n target_scene.render.filepath = ${renderLiteral}\n target_scene.render.resolution_percentage = 100\n bpy.ops.render.render(write_still=True)\nfinally:\n image_settings.file_format = old_file_format\n image_settings.color_mode = old_color_mode\n image_settings.color_depth = old_color_depth\n target_scene.render.use_file_extension = old_use_file_extension\n target_scene.render.filepath = old_filepath\n target_scene.render.resolution_percentage = old_resolution_percentage${restore}\nprint("HARNESS_JSON:" + json.dumps({"ok": True, "scene": getattr(target_scene, "name", None), "filepath": ${renderLiteral}}, separators=(",", ":")))\n`;
880
+ }
881
+
882
+ export { MAX_BATCH, OPERATION_NAMES, PRIMITIVES, operationBatchCode, operationCode, preflightCode, renderCode, renderPreflightCode, saveCode, sceneFactsCode };