@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,550 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import test from 'node:test';
4
+ import { MAX_BATCH, operationBatchCode, operationCode, preflightCode, renderCode, renderPreflightCode, saveCode, sceneFactsCode } from './blender-operations.mjs';
5
+
6
+ const allowed = ['Cube', 'Camera', 'Lamp'];
7
+
8
+ function runPython(source, input) {
9
+ const result = spawnSync('python', ['-I', '-B', '-c', source], {
10
+ input: JSON.stringify(input), encoding: 'utf8', timeout: 10000,
11
+ });
12
+ assert.ifError(result.error);
13
+ assert.equal(result.status, 0, result.stderr || result.stdout);
14
+ const receipt = result.stdout.split(/\r?\n/).find((line) => line.startsWith('TEST_JSON:'));
15
+ assert.ok(receipt, result.stdout);
16
+ return JSON.parse(receipt.slice('TEST_JSON:'.length));
17
+ }
18
+
19
+ const meshOperation = (primitive = 'cube') => ({
20
+ operation: 'create_mesh', name: 'Cube', primitive, location: [0, 0, 0], scale: [1, 1, 1],
21
+ });
22
+
23
+ test('names accept exact printable ASCII including spaces, reject blank/control/long names', () => {
24
+ for (const name of ['Floor Lamp', ' Cube ', 'x'.repeat(100), String.fromCharCode(...Array.from({ length: 95 }, (_, i) => i + 32))]) {
25
+ assert.doesNotThrow(() => operationCode({ operation: 'delete_object', name }, [name]));
26
+ }
27
+ for (const name of ['', ' ', 'x'.repeat(101), 'Cube\n', 'Cube\r', 'A\tB', 'A\0B', 'A\x7fB', 'A\u00e9B']) {
28
+ assert.throws(() => operationCode({ operation: 'delete_object', name }, [name]), /ASCII/);
29
+ }
30
+ });
31
+
32
+ test('operation schema rejects custom prototypes, inherited fields, and hidden extra keys', () => {
33
+ const valid = { operation: 'delete_object', name: 'Cube' };
34
+ for (const value of [Object.create(valid), new (class { constructor() { Object.assign(this, valid); } })(), [], new Date()]) {
35
+ assert.throws(() => operationCode(value, allowed), /must be an object/);
36
+ }
37
+ assert.doesNotThrow(() => operationCode(Object.assign(Object.create(null), valid), allowed));
38
+ assert.throws(() => operationCode({ ...valid, [Symbol('extra')]: true }, allowed), /extra key/);
39
+ assert.throws(() => operationCode(Object.defineProperty({ ...valid }, 'extra', { value: true }), allowed), /extra key/);
40
+ Object.defineProperty(Object.prototype, 'location', { value: [0, 0, 0], configurable: true });
41
+ try {
42
+ assert.throws(() => operationCode({ operation: 'transform', name: 'Cube' }, allowed), /requires/);
43
+ assert.throws(() => operationCode({ operation: 'create_light', name: 'Lamp', kind: 'POINT', energy: 1 }, allowed), /location is required/);
44
+ } finally {
45
+ delete Object.prototype.location;
46
+ }
47
+ Object.defineProperty(Object.prototype, 'inheritedExtra', { value: true, enumerable: true, configurable: true });
48
+ try {
49
+ assert.throws(() => operationCode(valid, allowed), /inherited key/);
50
+ } finally {
51
+ delete Object.prototype.inheritedExtra;
52
+ }
53
+ assert.throws(() => operationCode({ name: 'Cube' }, allowed), /operation is required/);
54
+ });
55
+
56
+ test('all spatial vectors and scale enforce finite inclusive bounds', () => {
57
+ const operations = [
58
+ [meshOperation(), ['location']],
59
+ [{ operation: 'transform', name: 'Cube', location: [0, 0, 0], rotation: [0, 0, 0] }, ['location', 'rotation']],
60
+ [{ operation: 'create_light', name: 'Lamp', kind: 'POINT', location: [0, 0, 0], energy: 1 }, ['location']],
61
+ [{ operation: 'create_camera', name: 'Camera', location: [0, 0, 0], rotation: [0, 0, 0], lens: 50 }, ['location', 'rotation']],
62
+ ];
63
+ for (const [operation, fields] of operations) {
64
+ for (const field of fields) {
65
+ assert.doesNotThrow(() => operationCode({ ...operation, [field]: [-1e6, 0, 1e6] }, allowed));
66
+ for (const bad of [-1e6 - 1, 1e6 + 1, NaN, Infinity, -Infinity, '1', null, undefined]) {
67
+ for (let axis = 0; axis < 3; axis++) {
68
+ const vector = [0, 0, 0];
69
+ vector[axis] = bad;
70
+ assert.throws(() => operationCode({ ...operation, [field]: vector }, allowed), /finite|bounds/);
71
+ }
72
+ }
73
+ assert.throws(() => operationCode({ ...operation, [field]: Array(3) }, allowed), /finite/);
74
+ assert.throws(() => operationCode({ ...operation, [field]: [0, 0] }, allowed), /array of 3/);
75
+ }
76
+ }
77
+ for (const operation of [meshOperation(), { operation: 'transform', name: 'Cube' }]) {
78
+ assert.doesNotThrow(() => operationCode({ ...operation, scale: [Number.MIN_VALUE, 1, 1e4] }, allowed));
79
+ for (const bad of [0, -1, 1e4 + 1, NaN, Infinity]) {
80
+ assert.throws(() => operationCode({ ...operation, scale: [1, bad, 1] }, allowed), /finite|bounds|positive/);
81
+ }
82
+ }
83
+ });
84
+
85
+ test('energy and positive lens enforce upper bounds', () => {
86
+ const light = { operation: 'create_light', name: 'Lamp', kind: 'POINT', location: [0, 0, 0] };
87
+ const camera = { operation: 'create_camera', name: 'Camera', location: [0, 0, 0], rotation: [0, 0, 0] };
88
+ for (const energy of [0, 1e9]) assert.doesNotThrow(() => operationCode({ ...light, energy }, allowed));
89
+ for (const lens of [Number.MIN_VALUE, 1e4]) assert.doesNotThrow(() => operationCode({ ...camera, lens }, allowed));
90
+ for (const energy of [-1, 1e9 + 1, NaN, Infinity]) assert.throws(() => operationCode({ ...light, energy }, allowed), /finite|bounds/);
91
+ for (const lens of [-1, 0, 1e4 + 1, NaN, Infinity]) assert.throws(() => operationCode({ ...camera, lens }, allowed), /finite|bounds/);
92
+ });
93
+
94
+ test('material vectors retain tighter color bounds and reject sparse components', () => {
95
+ const operation = { operation: 'material', name: 'Cube', color: [0, 1, 0, 1], roughness: 0, metallic: 1 };
96
+ assert.doesNotThrow(() => operationCode(operation, allowed));
97
+ for (const bad of [-0.01, 1.01, NaN, Infinity]) {
98
+ for (const field of ['roughness', 'metallic']) {
99
+ assert.throws(() => operationCode({ ...operation, [field]: bad }, allowed), /finite|bounds/);
100
+ }
101
+ assert.throws(() => operationCode({ ...operation, color: [0, 0, bad, 1] }, allowed), /finite|bounds/);
102
+ }
103
+ assert.throws(() => operationCode({ ...operation, color: Array(4) }, allowed), /finite/);
104
+ });
105
+
106
+ test('operationCode emits bounded create_mesh script', () => {
107
+ const code = operationCode({
108
+ operation: 'create_mesh',
109
+ name: 'Cube',
110
+ primitive: 'cube',
111
+ location: [0, 1, 2],
112
+ scale: [1, 2, 3],
113
+ }, allowed);
114
+ assert.match(code, /HARNESS_JSON:/);
115
+ assert.match(code, /bpy\.data\.meshes\.new/);
116
+ assert.match(code, /mesh\.from_pydata/);
117
+ assert.doesNotMatch(code, /bpy\.ops\.mesh/);
118
+ assert.doesNotMatch(code, /exec\(|eval\(|__import__/);
119
+ });
120
+
121
+ test('operationCode validates extra keys, names, bounds, and enums', () => {
122
+ assert.throws(() => operationCode({ operation: 'delete_object', name: 'Cube', extra: true }, allowed), /extra key/);
123
+ assert.throws(() => operationCode({ operation: 'delete_object', name: 'Other' }, allowed), /not allowed/);
124
+ assert.throws(() => operationCode({ operation: 'render_settings', width: 15, height: 100, samples: 1 }, []), /out of bounds/);
125
+ assert.throws(() => operationCode({ operation: 'create_light', name: 'Lamp', kind: 'AREA', location: [0, 0, 0], energy: -1 }, allowed), /out of bounds/);
126
+ assert.throws(() => operationCode({ operation: 'create_camera', name: 'Camera', location: [0, 0, 0], rotation: [0, 0, 0], lens: 0 }, allowed), /out of bounds/);
127
+ assert.throws(() => operationCode({ operation: 'transform', name: 'Cube' }, allowed), /requires/);
128
+ });
129
+
130
+ test('operationCode includes dynamic enum checks and object mode guards', () => {
131
+ const light = operationCode({ operation: 'create_light', name: 'Lamp', kind: 'POINT', location: [0, 0, 0], energy: 10 }, allowed);
132
+ assert.match(light, /bl_rna\.properties/);
133
+ assert.match(light, /valid_values/);
134
+ assert.match(light, /bpy\.context\.mode != 'OBJECT'/);
135
+ const material = operationCode({ operation: 'material', name: 'Cube', color: [1, 0, 0, 1], roughness: 0.5, metallic: 0 }, allowed);
136
+ assert.match(material, /node\.type == 'BSDF_PRINCIPLED'/);
137
+ });
138
+
139
+ test('render settings accepts empty allow-list', () => {
140
+ const code = operationCode({ operation: 'render_settings', width: 1920, height: 1080, samples: 64 }, []);
141
+ assert.match(code, /resolution_x = 1920/);
142
+ assert.match(code, /HARNESS_JSON:/);
143
+ const ranged = operationCode({ operation: 'render_settings', scene: 'SC_001', width: 1920, height: 1080, samples: 64, frame_start: 1, frame_end: 120, fps: 24, useSequencer: true }, []);
144
+ assert.match(ranged, /bpy\.data\.scenes\.get\("SC_001"\)/);
145
+ assert.match(ranged, /scene\.frame_end = 120/);
146
+ assert.match(ranged, /scene\.render\.fps = 24/);
147
+ assert.match(ranged, /scene\.render\.use_sequencer = True/);
148
+ });
149
+
150
+ test('scene operations validate modes, names, and strip bounds', () => {
151
+ assert.doesNotThrow(() => operationCode({ operation: 'create_scene', name: 'SC_001', mode: 'NEW' }, []));
152
+ assert.doesNotThrow(() => operationCode({ operation: 'create_scene', name: 'SC_002', mode: 'EMPTY', source: 'SC_001' }, []));
153
+ assert.throws(() => operationCode({ operation: 'create_scene', name: 'SC_002', mode: 'EMPTY' }, []), /source is required/);
154
+ assert.throws(() => operationCode({ operation: 'create_scene', name: 'SC_002', mode: 'COPY' }, []), /mode is invalid/);
155
+ assert.throws(() => operationCode({ operation: 'create_scene', name: 'SC_001', mode: 'NEW', source: 'SC_001' }, []), /source is not accepted/);
156
+ assert.throws(() => operationCode({ operation: 'switch_scene', name: '' }, []), /non-blank/);
157
+ assert.doesNotThrow(() => operationCode({ operation: 'delete_scene', name: 'SC_001' }, []));
158
+ assert.doesNotThrow(() => operationCode({ operation: 'duplicate_scene', source: 'SC_001', name: 'SC_002' }, []));
159
+ assert.throws(() => operationCode({ operation: 'duplicate_scene', source: 'SC_001', name: 'SC_001' }, []), /must differ/);
160
+ assert.doesNotThrow(() => operationCode({ operation: 'set_scene_camera', scene: 'SC_001', camera: 'Camera' }, []));
161
+ assert.throws(() => operationCode({ operation: 'set_scene_camera', scene: 'SC_001' }, []), /camera is required/);
162
+ assert.doesNotThrow(() => operationCode({ operation: 'scene_strip', scene: 'SC_MASTER', source: 'SC_001', channel: 1, frame_start: 1, frame_end: 48 }, []));
163
+ assert.throws(() => operationCode({ operation: 'scene_strip', scene: 'SC_MASTER', source: 'SC_001', channel: 0, frame_start: 1 }, []), /out of bounds/);
164
+ assert.throws(() => operationCode({ operation: 'scene_strip', scene: 'SC_MASTER', source: 'SC_001', channel: 1, frame_start: 10, frame_end: 10 }, []), /after frame_start/);
165
+ assert.throws(() => operationCode({ operation: 'check_assets', scene: 'SC_001' }, []), /scene is not supported/);
166
+ });
167
+
168
+ test('scene-aware operations target named scenes and render evidence', () => {
169
+ const mesh = operationCode({ ...meshOperation(), scene: 'SC_001' }, allowed);
170
+ assert.match(mesh, /bpy\.data\.scenes\.get\("SC_001"\)/);
171
+ assert.match(mesh, /target_scene\.collection\.objects\.link/);
172
+ const text = operationCode({ operation: 'text', name: 'Sign', body: 'SC_001', scene: 'SC_001' }, [...allowed, 'Sign']);
173
+ assert.match(text, /target_scene\.collection\.objects\.link/);
174
+ const camera = operationCode({ operation: 'create_camera', name: 'Camera', location: [0, 0, 0], rotation: [0, 0, 0], lens: 50, scene: 'SC_001' }, allowed);
175
+ assert.match(camera, /target_scene\.collection\.objects\.link/);
176
+ const setCamera = operationCode({ operation: 'set_camera', name: 'Camera', scene: 'SC_001' }, allowed);
177
+ assert.match(setCamera, /target_scene\.camera = obj/);
178
+ const duplicate = operationCode({ operation: 'duplicate', name: 'Cube', new_name: 'Copy', scene: 'SC_001' }, [...allowed, 'Copy']);
179
+ assert.match(duplicate, /object does not belong to the target scene/);
180
+ assert.match(duplicate, /target_scene\.collection\.objects\.link/);
181
+ const exported = operationCode({ operation: 'export_scene', format: 'glb', path: 'exports/room.glb', scene: 'SC_001' }, []);
182
+ assert.match(exported, /hashlib\.sha256/);
183
+ assert.match(exported, /"scene": scene_name/);
184
+ const render = renderCode('//project.blend', '//renders/SC_001.png', 'SC_001');
185
+ assert.match(render, /bpy\.data\.scenes\.get\("SC_001"\)/);
186
+ assert.match(render, /bpy\.context\.window\.scene = target_scene/);
187
+ const preflight = renderPreflightCode('//project.blend', 'SC_001');
188
+ assert.match(preflight, /bpy\.data\.scenes\.get\("SC_001"\)/);
189
+ assert.match(preflight, /use_sequencer/);
190
+ assert.match(sceneFactsCode('SC_001'), /scene-facts|"scenes"/);
191
+ assert.match(sceneFactsCode('SC_001'), /"dimensions"/);
192
+ assert.match(sceneFactsCode('SC_001'), /"non_manifold_edges"/);
193
+ });
194
+
195
+ test('batched apply validates one to ten operations', () => {
196
+ const batch = operationBatchCode({ operations: [meshOperation(), { operation: 'transform', name: 'Cube', location: [0, 1, 0] }] }, allowed);
197
+ assert.equal(batch.length, 2);
198
+ assert.equal(MAX_BATCH, 10);
199
+ assert.throws(() => operationBatchCode({ operations: [] }, allowed), /1-10/);
200
+ assert.throws(() => operationBatchCode({ operations: Array.from({ length: 11 }, () => ({ operation: 'delete_object', name: 'Cube' })) }, allowed), /1-10/);
201
+ assert.throws(() => operationBatchCode({ operations: [meshOperation()], extra: true }, allowed), /extra key/);
202
+ });
203
+
204
+ test('camera DOF, light color/temperature, emission, shared materials, and duplicates generate guarded code', () => {
205
+ const camera = operationCode({ operation: 'create_camera', name: 'Camera', location: [0, 0, 0], rotation: [0, 0, 0], lens: 50, dof: { aperture: 2.8, focus_distance: 3, focus_object: 'Cube' } }, allowed);
206
+ assert.match(camera, /use_dof = True/);
207
+ assert.match(camera, /aperture_fstop = 2.8/);
208
+ assert.match(camera, /focus_object/);
209
+ const light = operationCode({ operation: 'create_light', name: 'Lamp', kind: 'AREA', location: [0, 0, 0], energy: 100, color: [1, 0.7, 0.5], temperature: 3200 }, allowed);
210
+ assert.match(light, /light_data\.color/);
211
+ assert.match(light, /light_data\.temperature = 3200/);
212
+ const material = operationCode({ operation: 'material', name: 'Cube', color: [0.2, 0.3, 0.4, 1], roughness: 0.4, metallic: 0.2, emission: { color: [1, 0.5, 0.2, 1], strength: 2 } }, allowed);
213
+ assert.match(material, /Emission Color/);
214
+ assert.match(material, /Emission Strength/);
215
+ const shared = operationCode({ operation: 'shared_material', name: 'Wood', objects: ['Cube', 'Lamp'], color: [1, 1, 1, 1], roughness: 0.5, metallic: 0 }, allowed);
216
+ assert.match(shared, /shared_material/);
217
+ assert.match(shared, /bpy\.data\.materials\.new/);
218
+ const text = operationCode({ operation: 'text', name: 'Sign', body: 'Cozy Room', size: 0.5, extrude: 0.02, align_x: 'CENTER', align_y: 'CENTER', location: [0, 0, 1] }, [...allowed, 'Sign']);
219
+ assert.match(text, /bpy\.data\.curves\.new/);
220
+ assert.match(text, /text_data\.body = "Cozy Room"/);
221
+ const bevel = operationCode({ operation: 'modifier', name: 'Cube', modifier: 'BEVEL', width: 0.02, segments: 2 }, allowed);
222
+ assert.match(bevel, /modifiers\.new/);
223
+ assert.match(bevel, /modifier\.width/);
224
+ const array = operationCode({ operation: 'modifier', name: 'Cube', modifier: 'ARRAY', count: 4, relative_offset: [1, 0, 0], apply: true }, allowed);
225
+ assert.match(array, /modifier\.count = 4/);
226
+ assert.match(array, /modifier_apply/);
227
+ assert.throws(() => operationCode({ operation: 'modifier', name: 'Cube', modifier: 'WAVE' }, allowed), /invalid/);
228
+ const duplicate = operationCode({ operation: 'duplicate', name: 'Cube', new_name: 'Copy', location: [1, 0, 0] }, [...allowed, 'Copy']);
229
+ assert.match(duplicate, /duplicate\.data = obj\.data\.copy\(\)/);
230
+ assert.match(duplicate, /duplicate\.location/);
231
+ });
232
+
233
+ test('asset, image, audio, validation, and export operations generate bounded code', () => {
234
+ const full = [...allowed, 'Imported', 'Linked', 'Reference', 'Rain'];
235
+ const imported = operationCode({
236
+ operation: 'import_asset',
237
+ name: 'Imported',
238
+ path: 'assets/models/model.glb',
239
+ location: [0, 0, 0],
240
+ }, full);
241
+ assert.match(imported, /bpy\.data\.libraries\.load|import_scene\.gltf/);
242
+ assert.match(imported, /creation_parent/);
243
+ const external = operationCode({
244
+ operation: 'import_asset',
245
+ name: 'Imported',
246
+ path: 'assets/external/vendor/asset/model.fbx',
247
+ }, full);
248
+ assert.match(external, /SOURCE\.md/);
249
+ assert.match(external, /LICENSE\.txt/);
250
+ const library = operationCode({
251
+ operation: 'link_library',
252
+ name: 'Linked',
253
+ path: 'assets/models/library.blend',
254
+ link: true,
255
+ }, full);
256
+ assert.match(library, /bpy\.data\.libraries\.load/);
257
+ assert.match(library, /link=True/);
258
+ const reference = operationCode({
259
+ operation: 'image_reference',
260
+ name: 'Reference',
261
+ path: 'Documents/References/mood.png',
262
+ location: [1, 2, 3],
263
+ }, full);
264
+ assert.match(reference, /empty_display_type = 'IMAGE'/);
265
+ assert.match(reference, /hide_render = True/);
266
+ const texture = operationCode({
267
+ operation: 'image_texture',
268
+ name: 'Wood',
269
+ objects: ['Cube'],
270
+ path: 'assets/textures/wood.png',
271
+ roughness: 0.5,
272
+ metallic: 0,
273
+ colorSpace: 'sRGB',
274
+ }, full);
275
+ assert.match(texture, /ShaderNodeTexImage/);
276
+ assert.match(texture, /ShaderNodeTexCoord/);
277
+ assert.match(texture, /colorspace_settings\.name/);
278
+ const hdri = operationCode({
279
+ operation: 'world_hdri',
280
+ path: 'assets/hdri/studio.exr',
281
+ strength: 1.2,
282
+ rotation: 0.5,
283
+ }, full);
284
+ assert.match(hdri, /ShaderNodeTexEnvironment/);
285
+ assert.match(hdri, /ShaderNodeBackground/);
286
+ const audio = operationCode({
287
+ operation: 'import_audio',
288
+ name: 'Rain',
289
+ path: 'assets/audio/rain.ogg',
290
+ volume: 0.6,
291
+ }, full);
292
+ assert.match(audio, /bpy\.data\.sounds\.load/);
293
+ assert.match(audio, /bpy\.data\.speakers\.new/);
294
+ const checked = operationCode({ operation: 'check_assets' }, []);
295
+ assert.match(checked, /blend_paths/);
296
+ assert.match(checked, /missing/);
297
+ const strictChecked = operationCode({ operation: 'check_assets', strict: true }, []);
298
+ assert.match(strictChecked, /missing external files/);
299
+ assert.throws(() => operationCode({ operation: 'check_assets', strict: 'yes' }, []), /boolean/);
300
+ const packed = operationCode({ operation: 'pack_assets' }, []);
301
+ assert.match(packed, /pack_all/);
302
+ const exported = operationCode({ operation: 'export_scene', format: 'glb', path: 'exports/room.glb' }, []);
303
+ assert.match(exported, /export_scene\.gltf/);
304
+ assert.match(exported, /export_format='GLB'/);
305
+ assert.throws(() => operationCode({ operation: 'import_asset', name: 'Imported', path: '../outside.glb' }, full), /stay within/);
306
+ assert.throws(() => operationCode({ operation: 'import_asset', name: 'Imported', path: 'C:\\outside.glb' }, full), /relative/);
307
+ assert.throws(() => operationCode({ operation: 'import_asset', name: 'Imported', path: 'assets/models/model.txt' }, full), /extension/);
308
+ assert.throws(() => operationCode({ operation: 'export_scene', format: 'glb', path: 'exports/room.fbx' }, []), /extension/);
309
+ assert.throws(() => operationCode({ operation: 'export_scene', format: 'glb', path: 'scenes/room.glb' }, []), /under exports/);
310
+ assert.throws(() => operationCode({ operation: 'image_texture', name: 'Wood', objects: ['Cube'], path: 'assets/textures/wood.txt', roughness: 0.5, metallic: 0 }, full), /extension/);
311
+ });
312
+
313
+ test('preflight, save, and render code enforce explicit project paths', () => {
314
+ const preflight = preflightCode('//project.blend');
315
+ assert.match(preflight, /bpy\.utils\.blend_paths\(absolute=True, packed=False, local=True\)/);
316
+ assert.match(preflight, /is_dirty/);
317
+ assert.match(preflight, /HARNESS_JSON:/);
318
+ const save = saveCode('//project.blend');
319
+ assert.match(save, /bpy\.ops\.wm\.save_as_mainfile/);
320
+ assert.match(save, /check_existing=False/);
321
+ assert.match(save, /refusing mismatched existing scene filepath/);
322
+ assert.doesNotMatch(save, /preferences|save_version|save_userpref/);
323
+ const render = renderCode('//project.blend', '//renders/frame.png');
324
+ assert.match(render, /try:/);
325
+ assert.match(render, /finally:/);
326
+ assert.match(render, /write_still=True/);
327
+ assert.doesNotMatch(render, /save_as_mainfile/);
328
+ const camera = renderPreflightCode('//project.blend');
329
+ assert.match(camera, /scene camera is not set/);
330
+ assert.match(camera, /camera\.type != 'CAMERA'/);
331
+ assert.match(camera, /resolution is invalid/);
332
+ assert.throws(() => renderCode('//project.blend', 'C:\\outside.png'), /project-relative/);
333
+ });
334
+
335
+ test('every operation and lifecycle script compiles as Python without importing Blender', () => {
336
+ const operations = [
337
+ ...['cube', 'plane', 'uv_sphere', 'cylinder'].map(meshOperation),
338
+ { operation: 'create_scene', name: 'SC_001', mode: 'NEW' },
339
+ { operation: 'create_scene', name: 'SC_002', mode: 'EMPTY', source: 'SC_001' },
340
+ { operation: 'switch_scene', name: 'SC_001' },
341
+ { operation: 'duplicate_scene', source: 'SC_001', name: 'SC_003' },
342
+ { operation: 'delete_scene', name: 'SC_003' },
343
+ { operation: 'set_scene_camera', scene: 'SC_001', camera: 'Camera' },
344
+ { operation: 'scene_strip', scene: 'SC_MASTER', source: 'SC_001', channel: 1, frame_start: 1, frame_end: 48 },
345
+ { operation: 'text', name: 'Sign', body: 'Cozy Room', size: 0.5, extrude: 0.02, align_x: 'CENTER', align_y: 'CENTER', scene: 'SC_001' },
346
+ { operation: 'modifier', name: 'Cube', modifier: 'BEVEL', width: 0.02, segments: 2 },
347
+ { operation: 'modifier', name: 'Cube', modifier: 'SUBSURF', levels: 1, render_levels: 2 },
348
+ { operation: 'modifier', name: 'Cube', modifier: 'SOLIDIFY', thickness: 0.01, offset: -1 },
349
+ { operation: 'modifier', name: 'Cube', modifier: 'MIRROR', axis: 'X', use_clip: true },
350
+ { operation: 'modifier', name: 'Cube', modifier: 'ARRAY', count: 3, relative_offset: [1, 0, 0], apply: true },
351
+ { operation: 'transform', name: 'Cube', location: [1, 2, 3], rotation: [0, 1, 2], scale: [1, 1, 1] },
352
+ { operation: 'material', name: 'Cube', color: [1, 0, 0, 1], roughness: 0.5, metallic: 0, emission: { color: [1, 0.4, 0.2, 1], strength: 1 } },
353
+ { operation: 'shared_material', name: 'Wood', objects: ['Cube', 'Lamp'], color: [1, 0, 0, 1], roughness: 0.5, metallic: 0 },
354
+ { operation: 'duplicate', name: 'Cube', new_name: 'Copy' },
355
+ { operation: 'create_light', name: 'Lamp', kind: 'AREA', location: [0, 0, 0], energy: 100, color: [1, 0.7, 0.5], temperature: 3200 },
356
+ { operation: 'create_camera', name: 'Camera', location: [0, 0, 0], rotation: [0, 0, 0], lens: 50, dof: { aperture: 2.8, focus_distance: 3, focus_object: 'Cube' } },
357
+ { operation: 'set_camera', name: 'Camera' }, { operation: 'delete_object', name: 'Cube' },
358
+ { operation: 'render_settings', width: 1920, height: 1080, samples: 64 },
359
+ { ...meshOperation(), name: 'Floor "Lamp" \\ 1' },
360
+ { operation: 'import_asset', name: 'Imported', path: 'assets/models/model.glb' },
361
+ { operation: 'link_library', name: 'Linked', path: 'assets/models/library.blend', link: true },
362
+ { operation: 'import_audio', name: 'Rain', path: 'assets/audio/rain.ogg', volume: 0.6 },
363
+ { operation: 'image_reference', name: 'Reference', path: 'Documents/References/mood.png' },
364
+ { operation: 'image_texture', name: 'Wood', objects: ['Cube'], path: 'assets/textures/wood.png', roughness: 0.5, metallic: 0 },
365
+ { operation: 'world_hdri', path: 'assets/hdri/studio.exr', strength: 1.2 },
366
+ { operation: 'check_assets' },
367
+ { operation: 'check_assets', strict: true },
368
+ { operation: 'pack_assets' },
369
+ { operation: 'export_scene', format: 'glb', path: 'exports/room.glb' },
370
+ ];
371
+ const scripts = operations.map((operation) => operationCode(operation, [...allowed, 'Copy', 'Floor "Lamp" \\ 1', 'Sign', 'Imported', 'Linked', 'Rain', 'Reference']));
372
+ scripts.push(preflightCode('//project.blend'), saveCode('//project.blend'), renderPreflightCode('//project.blend'), renderCode('//project.blend', '//renders/frame.png'), renderPreflightCode('//project.blend', 'SC_001'), renderCode('//project.blend', '//renders/frame.png', 'SC_001'), sceneFactsCode('SC_001'));
373
+ const count = runPython(`import json, sys
374
+ scripts = json.load(sys.stdin)
375
+ for code in scripts:
376
+ compile(code, '<generated>', 'exec')
377
+ print('TEST_JSON:' + json.dumps(len(scripts)))`, scripts);
378
+ assert.equal(count, scripts.length);
379
+ });
380
+
381
+ test('generated primitives execute against mock bpy with nondegenerate outward manifold topology', () => {
382
+ const scripts = Object.fromEntries(['cube', 'plane', 'uv_sphere', 'cylinder'].map((primitive) => [primitive, operationCode(meshOperation(primitive), allowed)]));
383
+ const meshes = runPython(`import json, sys, types
384
+ scripts = json.load(sys.stdin)
385
+ result = {}
386
+ class Mesh:
387
+ def from_pydata(self, verts, edges, faces):
388
+ self.verts, self.faces = verts, faces
389
+ def update(self):
390
+ pass
391
+ for primitive, code in scripts.items():
392
+ mesh = Mesh()
393
+ bpy = types.ModuleType('bpy')
394
+ bpy.data = types.SimpleNamespace(
395
+ objects=types.SimpleNamespace(get=lambda name: None, new=lambda name, data: types.SimpleNamespace(data=data)),
396
+ meshes=types.SimpleNamespace(new=lambda name: mesh))
397
+ bpy.context = types.SimpleNamespace(mode='OBJECT', collection=types.SimpleNamespace(objects=types.SimpleNamespace(link=lambda obj: None)))
398
+ sys.modules['bpy'] = bpy
399
+ exec(compile(code, '<geometry>', 'exec'), {})
400
+ result[primitive] = {'verts': mesh.verts, 'faces': mesh.faces}
401
+ print('TEST_JSON:' + json.dumps(result))`, scripts);
402
+ const subtract = (a, b) => a.map((v, i) => v - b[i]);
403
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
404
+ const dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
405
+ for (const [primitive, { verts, faces }] of Object.entries(meshes)) {
406
+ assert.equal(new Set(verts.map((v) => v.map((n) => n.toFixed(10)).join(','))).size, verts.length, `${primitive}: duplicate vertices`);
407
+ const edges = new Map();
408
+ for (const face of faces) {
409
+ assert.equal(new Set(face).size, face.length);
410
+ assert.ok(face.every((index) => Number.isInteger(index) && index >= 0 && index < verts.length));
411
+ const normal = cross(subtract(verts[face[1]], verts[face[0]]), subtract(verts[face[2]], verts[face[0]]));
412
+ assert.ok(dot(normal, normal) > 1e-16, `${primitive}: degenerate face`);
413
+ const center = [0, 1, 2].map((axis) => face.reduce((sum, index) => sum + verts[index][axis], 0) / face.length);
414
+ assert.ok(primitive === 'plane' ? normal[2] > 0 : dot(normal, center) > 0, `${primitive}: inward face`);
415
+ for (let i = 0; i < face.length; i++) {
416
+ const a = face[i], b = face[(i + 1) % face.length];
417
+ const key = [Math.min(a, b), Math.max(a, b)].join(',');
418
+ const uses = edges.get(key) ?? [];
419
+ uses.push(a < b ? 1 : -1);
420
+ edges.set(key, uses);
421
+ }
422
+ }
423
+ if (primitive !== 'plane') {
424
+ assert.ok([...edges.values()].every((uses) => uses.length === 2 && uses[0] + uses[1] === 0), `${primitive}: non-manifold/inconsistent winding`);
425
+ assert.equal(verts.length - edges.size + faces.length, 2, `${primitive}: Euler characteristic`);
426
+ }
427
+ }
428
+ assert.equal(meshes.cylinder.verts.length, 64);
429
+ assert.equal(meshes.cylinder.faces.filter((f) => f.length === 32).length, 2);
430
+ assert.equal(meshes.cylinder.faces.filter((f) => f.length === 4).length, 32);
431
+ assert.equal(meshes.uv_sphere.verts.length, 114);
432
+ assert.equal(meshes.uv_sphere.faces.filter((f) => f.length === 3).length, 32);
433
+ assert.equal(meshes.uv_sphere.faces.filter((f) => f.length === 4).length, 96);
434
+ });
435
+
436
+ test('material mock execution copies shared/read-only meshes and changes only active slot', () => {
437
+ const code = operationCode({ operation: 'material', name: 'Cube', color: [1, 0, 0, 1], roughness: 0.5, metallic: 0 }, allowed);
438
+ assert.doesNotMatch(code, /materials\.clear/);
439
+ const count = runPython(`import json, sys, types
440
+ code = json.load(sys.stdin)
441
+ class Mesh:
442
+ def __init__(self, users, editable, slots):
443
+ self.users, self.is_editable, self.materials = users, editable, list(slots)
444
+ self.copies = 0
445
+ def copy(self):
446
+ self.copies += 1
447
+ return Mesh(1, True, self.materials)
448
+ class Object:
449
+ type = 'MESH'
450
+ def __init__(self, mesh):
451
+ self.data = mesh
452
+ self.active_material_index = 1 if mesh.materials else 0
453
+ @property
454
+ def active_material(self):
455
+ return self.data.materials[self.active_material_index]
456
+ @active_material.setter
457
+ def active_material(self, material):
458
+ assert self.data.is_editable
459
+ self.data.materials[self.active_material_index] = material
460
+ count = 0
461
+ for users, editable in [(2, True), (1, False), (1, True)]:
462
+ for slots in [[], ['first', 'active', 'last']]:
463
+ mesh = Mesh(users, editable, slots)
464
+ obj = Object(mesh)
465
+ inputs = {name: types.SimpleNamespace(default_value=None) for name in ['Base Color', 'Roughness', 'Metallic']}
466
+ node = types.SimpleNamespace(type='BSDF_PRINCIPLED', inputs=inputs)
467
+ material = types.SimpleNamespace(node_tree=types.SimpleNamespace(nodes=[node]))
468
+ bpy = types.ModuleType('bpy')
469
+ bpy.context = types.SimpleNamespace(mode='OBJECT')
470
+ bpy.data = types.SimpleNamespace(objects={'Cube': obj}, materials=types.SimpleNamespace(new=lambda name: material))
471
+ sys.modules['bpy'] = bpy
472
+ exec(compile(code, '<material>', 'exec'), {})
473
+ should_copy = users > 1 or not editable
474
+ assert (obj.data is not mesh) == should_copy
475
+ assert mesh.copies == int(should_copy)
476
+ if should_copy:
477
+ assert mesh.materials == slots
478
+ assert len(obj.data.materials) == (len(slots) or 1)
479
+ if slots:
480
+ assert obj.data.materials[0] == 'first'
481
+ assert obj.data.materials[1] is material
482
+ assert obj.data.materials[2] == 'last'
483
+ assert obj.active_material_index == 1
484
+ else:
485
+ assert obj.data.materials[0] is material
486
+ assert inputs['Base Color'].default_value == [1, 0, 0, 1]
487
+ assert inputs['Roughness'].default_value == 0.5
488
+ assert inputs['Metallic'].default_value == 0
489
+ count += 1
490
+ print('TEST_JSON:' + json.dumps(count))`, code);
491
+ assert.equal(count, 6);
492
+ });
493
+
494
+ test('render mock execution forces PNG and restores settings after success, failure, and unavailable enum', () => {
495
+ const code = renderCode('//project.blend', '//renders/frame.png');
496
+ assert.doesNotMatch(code, /save_as_mainfile|save_userpref/);
497
+ const count = runPython(`import json, sys, types
498
+ code = json.load(sys.stdin)
499
+ class ImageSettings:
500
+ def __init__(self):
501
+ self._format = 'OPEN_EXR'
502
+ self.color_mode = 'RGBA'
503
+ self.color_depth = '32'
504
+ @property
505
+ def file_format(self):
506
+ return self._format
507
+ @file_format.setter
508
+ def file_format(self, value):
509
+ self._format = value
510
+ if value == 'PNG':
511
+ self.color_mode = 'RGB'
512
+ self.color_depth = '8'
513
+ count = 0
514
+ for failure, formats in [(False, ['OPEN_EXR', 'PNG']), (True, ['OPEN_EXR', 'PNG']), (False, ['OPEN_EXR'])]:
515
+ image = ImageSettings()
516
+ render = types.SimpleNamespace(filepath='//original.exr', resolution_percentage=42, image_settings=image, use_file_extension=False)
517
+ calls = []
518
+ def perform_render(*, write_still):
519
+ calls.append(True)
520
+ assert write_still is True
521
+ assert image.file_format == 'PNG'
522
+ assert render.use_file_extension is True
523
+ assert render.filepath == '//renders/frame.png'
524
+ assert render.resolution_percentage == 100
525
+ if failure:
526
+ raise RuntimeError('mock render failure')
527
+ bpy = types.ModuleType('bpy')
528
+ bpy.data = types.SimpleNamespace(filepath='//project.blend')
529
+ bpy.path = types.SimpleNamespace(abspath=lambda path: path)
530
+ bpy.context = types.SimpleNamespace(scene=types.SimpleNamespace(render=render))
531
+ bpy.types = types.SimpleNamespace(ImageFormatSettings=types.SimpleNamespace(bl_rna=types.SimpleNamespace(properties={
532
+ 'file_format': types.SimpleNamespace(enum_items=[types.SimpleNamespace(identifier=value) for value in formats])})))
533
+ bpy.ops = types.SimpleNamespace(render=types.SimpleNamespace(render=perform_render))
534
+ sys.modules['bpy'] = bpy
535
+ try:
536
+ exec(compile(code, '<render>', 'exec'), {})
537
+ assert not failure and 'PNG' in formats
538
+ except RuntimeError as exc:
539
+ assert str(exc) == ('mock render failure' if failure else 'PNG render format is unavailable')
540
+ assert len(calls) == int('PNG' in formats)
541
+ assert image.file_format == 'OPEN_EXR'
542
+ assert image.color_mode == 'RGBA'
543
+ assert image.color_depth == '32'
544
+ assert render.filepath == '//original.exr'
545
+ assert render.resolution_percentage == 42
546
+ assert render.use_file_extension is False
547
+ count += 1
548
+ print('TEST_JSON:' + json.dumps(count))`, code);
549
+ assert.equal(count, 3);
550
+ });