@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.
- package/.kilo/agent/blender-grader.md +197 -0
- package/.kilo/agent/blender-worker.md +127 -0
- package/.kilo/blender-project-harness/config.example.json +71 -0
- package/.kilo/blender-project-harness/tools/harness/EXPECTED_FLOW.md +656 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.mjs +882 -0
- package/.kilo/blender-project-harness/tools/harness/blender-operations.test.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/blender.mjs +301 -0
- package/.kilo/blender-project-harness/tools/harness/blender.test.mjs +87 -0
- package/.kilo/blender-project-harness/tools/harness/cli.mjs +127 -0
- package/.kilo/blender-project-harness/tools/harness/documents.mjs +201 -0
- package/.kilo/blender-project-harness/tools/harness/download.mjs +94 -0
- package/.kilo/blender-project-harness/tools/harness/download.test.mjs +63 -0
- package/.kilo/blender-project-harness/tools/harness/engine.mjs +1866 -0
- package/.kilo/blender-project-harness/tools/harness/engine.test.mjs +2219 -0
- package/.kilo/blender-project-harness/tools/harness/intake.mjs +571 -0
- package/.kilo/blender-project-harness/tools/harness/intake.test.mjs +181 -0
- package/.kilo/blender-project-harness/tools/harness/package-lock.json +1227 -0
- package/.kilo/blender-project-harness/tools/harness/package.json +15 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.mjs +550 -0
- package/.kilo/blender-project-harness/tools/harness/plugin.test.mjs +526 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.mjs +222 -0
- package/.kilo/blender-project-harness/tools/harness/proxy.test.mjs +217 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.mjs +83 -0
- package/.kilo/blender-project-harness/tools/harness/scaffold.test.mjs +74 -0
- package/.kilo/blender-project-harness/tools/harness/server.mjs +24 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.mjs +196 -0
- package/.kilo/blender-project-harness/tools/harness/shell-policy.test.mjs +68 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/ASSETS.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/DECISIONS.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/01-project-brief.md +71 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Goals/README.md +17 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/INTAKE.md +8 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/README.md +57 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REQUIREMENTS.md +64 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/REVIEW.md +29 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/References/README.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Reviews/README.md +48 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/TASKSTRACKER.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/Tasks/README.md +4 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/Documents/WORKFLOW.md +36 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/README.md +130 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/README.md +31 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/audio/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/README.md +43 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/external/SOURCE_TEMPLATE.md +18 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/hdri/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/models/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/assets/textures/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/backups/README.md +5 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/exports/README.md +11 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/README.md +7 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/final/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/renders/wip/.gitkeep +0 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scenes/README.md +10 -0
- package/.kilo/blender-project-harness/tools/harness/templates/project/scripts/README.md +16 -0
- package/.kilo/blender-project-harness/tools/harness/test-fixture.mjs +91 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.mjs +30 -0
- package/.kilo/blender-project-harness/tools/harness/workspace.test.mjs +31 -0
- package/.kilo/blender-project-harness/tools/test_blender_tools.py +427 -0
- package/.kilo/plugins/blender-workflow.js +3 -0
- package/.kilo/skills/blender-assets/SKILL.md +99 -0
- package/.kilo/skills/blender-mcp/SKILL.md +147 -0
- package/.kilo/skills/blender-mcp/references/README.md +22 -0
- package/.kilo/skills/blender-mcp/references/animation.md +83 -0
- package/.kilo/skills/blender-mcp/references/bpy.md +97 -0
- package/.kilo/skills/blender-mcp/references/level-design.md +53 -0
- package/.kilo/skills/blender-mcp/references/materials.md +80 -0
- package/.kilo/skills/blender-mcp/references/retopology.md +104 -0
- package/.kilo/skills/blender-mcp/references/rigging.md +123 -0
- package/.kilo/skills/blender-mcp/references/scene.md +72 -0
- package/.kilo/skills/blender-project-goal/SKILL.md +115 -0
- package/.kilo/skills/blender-project-grading/SKILL.md +184 -0
- package/.kilo/skills/blender-project-readme/SKILL.md +46 -0
- package/.kilo/skills/blender-project-requirements/SKILL.md +84 -0
- package/.kilo/skills/blender-project-scaffold/SKILL.md +59 -0
- package/.kilo/skills/blender-project-workflow/SKILL.md +96 -0
- package/.kilo/skills/blender-subagents/SKILL.md +204 -0
- package/.kilo/skills/blender-workflow-harness/SKILL.md +187 -0
- package/AGENTS.md +234 -0
- package/Blender/README.md +30 -0
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/THIRD_PARTY_NOTICES.md +51 -0
- package/bin/blender-project-harness.mjs +4 -0
- package/installer/cli.mjs +116 -0
- package/installer/install.mjs +325 -0
- package/installer/install.test.mjs +134 -0
- 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
|
+
});
|