@inneranimalmedia/agentsam-sdk 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ blenderBuild,
5
+ blenderExport,
6
+ blenderInspect,
7
+ blenderRenderPreview,
8
+ blenderStatus,
9
+ } from '../lib/cad/index.js';
10
+
11
+ function usage() {
12
+ return `AgentSam programmatic CAD
13
+
14
+ Usage:
15
+ agentsam cad blender status [--blender-bin <path>] [--json]
16
+ agentsam cad blender inspect <model.blend> [--timeout <seconds>] [--json]
17
+ agentsam cad blender build <recipe.json> --out <model.blend> [--input <base.blend>] [--json]
18
+ agentsam cad blender render-preview <model.blend> --out <preview.png> [--camera <name>] [--scene <name>] [--width 1024] [--height 1024] [--json]
19
+ agentsam cad blender export <model.blend> --format <glb|stl|obj> --out <artifact> [--objects <a,b>] [--collection <name>] [--json]
20
+
21
+ Shared options:
22
+ --blender-bin <path> Explicit Blender executable. Otherwise AGENTSAM_BLENDER_BIN, PATH, then common install locations are checked.
23
+ --timeout <seconds> Bounded execution time, 1..600 (default 120).
24
+ --cwd <path> Resolve input/output paths from another directory.
25
+ --json Machine-readable output.
26
+
27
+ The build command consumes a typed recipe; it never evaluates arbitrary Python.`;
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ const opts = { positional: [], json: false, applyModifiers: true };
32
+ const values = new Set([
33
+ '--blender-bin', '--timeout', '--cwd', '--out', '--input', '--camera', '--scene',
34
+ '--width', '--height', '--engine', '--format', '--objects', '--collection',
35
+ ]);
36
+ for (let i = 0; i < argv.length; i += 1) {
37
+ const arg = argv[i];
38
+ if (arg === '--json') opts.json = true;
39
+ else if (arg === '--no-apply-modifiers') opts.applyModifiers = false;
40
+ else if (arg === '--help' || arg === '-h') opts.help = true;
41
+ else if (values.has(arg)) {
42
+ const value = argv[++i];
43
+ if (value == null || value.startsWith('--')) throw new Error(`${arg} requires a value`);
44
+ const key = arg.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
45
+ opts[key] = value;
46
+ } else if (arg.startsWith('-')) throw new Error(`unknown cad option: ${arg}`);
47
+ else opts.positional.push(arg);
48
+ }
49
+ return opts;
50
+ }
51
+
52
+ function output(value, json) {
53
+ if (json) {
54
+ console.log(JSON.stringify(value));
55
+ return;
56
+ }
57
+ if (value.capability === 'blender.status') {
58
+ console.log(value.available
59
+ ? `Blender available: ${value.version || 'unknown version'}\n${value.binary}`
60
+ : `Blender unavailable${value.error ? `: ${value.error}` : ''}`);
61
+ return;
62
+ }
63
+ console.log(JSON.stringify(value, null, 2));
64
+ }
65
+
66
+ function required(value, message) {
67
+ if (!value) throw new Error(message);
68
+ return value;
69
+ }
70
+
71
+ export async function runCad(argv) {
72
+ const engine = argv[0];
73
+ if (!engine || engine === '--help' || engine === '-h') {
74
+ console.log(usage());
75
+ return;
76
+ }
77
+ if (engine !== 'blender') throw new Error(`unsupported CAD engine: ${engine}. Expected blender.`);
78
+
79
+ const action = argv[1];
80
+ const opts = parseArgs(argv.slice(2));
81
+ if (!action || opts.help) {
82
+ console.log(usage());
83
+ return;
84
+ }
85
+
86
+ const cwd = path.resolve(opts.cwd || process.cwd());
87
+ const shared = {
88
+ blenderBin: opts.blenderBin,
89
+ timeoutSeconds: opts.timeout == null ? undefined : Number(opts.timeout),
90
+ cwd,
91
+ };
92
+
93
+ let result;
94
+ if (action === 'status') {
95
+ result = await blenderStatus(shared);
96
+ } else if (action === 'inspect') {
97
+ result = await blenderInspect({ ...shared, input: required(opts.positional[0], 'inspect requires <model.blend>') });
98
+ } else if (action === 'build') {
99
+ const recipeFile = path.resolve(cwd, required(opts.positional[0], 'build requires <recipe.json>'));
100
+ if (!fs.existsSync(recipeFile)) throw new Error(`recipe file not found: ${recipeFile}`);
101
+ let recipe;
102
+ try { recipe = JSON.parse(fs.readFileSync(recipeFile, 'utf8')); }
103
+ catch (error) { throw new Error(`invalid recipe JSON: ${error.message}`); }
104
+ result = await blenderBuild({
105
+ ...shared,
106
+ input: opts.input,
107
+ output: required(opts.out, 'build requires --out <model.blend>'),
108
+ recipe,
109
+ });
110
+ } else if (action === 'render-preview') {
111
+ result = await blenderRenderPreview({
112
+ ...shared,
113
+ input: required(opts.positional[0], 'render-preview requires <model.blend>'),
114
+ output: required(opts.out, 'render-preview requires --out <preview.png>'),
115
+ scene: opts.scene,
116
+ camera: opts.camera,
117
+ width: opts.width == null ? undefined : Number(opts.width),
118
+ height: opts.height == null ? undefined : Number(opts.height),
119
+ engine: opts.engine,
120
+ });
121
+ } else if (action === 'export') {
122
+ result = await blenderExport({
123
+ ...shared,
124
+ input: required(opts.positional[0], 'export requires <model.blend>'),
125
+ output: required(opts.out, 'export requires --out <artifact>'),
126
+ format: required(opts.format, 'export requires --format <glb|stl|obj>'),
127
+ scene: opts.scene,
128
+ objects: opts.objects,
129
+ collection: opts.collection,
130
+ applyModifiers: opts.applyModifiers,
131
+ });
132
+ } else {
133
+ throw new Error(`unknown Blender CAD action: ${action}`);
134
+ }
135
+
136
+ output(result, opts.json);
137
+ return result;
138
+ }
@@ -0,0 +1,340 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { runProcess } from '../../security/process.js';
7
+
8
+ const sdkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
9
+ export const BLENDER_ADAPTER_PATH = path.join(sdkRoot, 'services/cad/blender/adapter.py');
10
+ export const BLENDER_RESULT_PREFIX = 'AGENTSAM_RESULT=';
11
+ export const BLENDER_EXPORT_FORMATS = Object.freeze(['glb', 'stl', 'obj']);
12
+ export const BLENDER_RECIPE_OPS = Object.freeze([
13
+ 'clear_scene',
14
+ 'add',
15
+ 'transform',
16
+ 'duplicate',
17
+ 'delete',
18
+ 'join',
19
+ 'bevel',
20
+ 'solidify',
21
+ 'array',
22
+ 'mirror',
23
+ 'boolean',
24
+ 'material',
25
+ 'assign_material',
26
+ 'add_camera',
27
+ 'add_light',
28
+ ]);
29
+
30
+ function isFile(value, existsSync = fs.existsSync) {
31
+ try { return Boolean(value) && existsSync(value) && fs.statSync(value).isFile(); }
32
+ catch { return false; }
33
+ }
34
+
35
+ function canonicalExecutable(value) {
36
+ try { return fs.realpathSync.native ? fs.realpathSync.native(value) : fs.realpathSync(value); }
37
+ catch { return value; }
38
+ }
39
+
40
+ function pathCandidates(pathEnv, platform) {
41
+ const names = platform === 'win32' ? ['blender.exe', 'blender'] : ['blender'];
42
+ return String(pathEnv || '')
43
+ .split(path.delimiter)
44
+ .filter(Boolean)
45
+ .flatMap(dir => names.map(name => path.join(dir, name)));
46
+ }
47
+
48
+ function windowsInstallCandidates(env = process.env, readdirSync = fs.readdirSync) {
49
+ const roots = [env.ProgramFiles, env['ProgramFiles(x86)'], env.LOCALAPPDATA]
50
+ .filter(Boolean)
51
+ .flatMap(root => [
52
+ path.join(root, 'Blender Foundation'),
53
+ path.join(root, 'Programs', 'Blender Foundation'),
54
+ ]);
55
+ const found = [];
56
+ for (const root of roots) {
57
+ try {
58
+ const entries = readdirSync(root, { withFileTypes: true })
59
+ .filter(entry => entry.isDirectory() && /^Blender(?:\s|$)/i.test(entry.name))
60
+ .sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
61
+ for (const entry of entries) found.push(path.join(root, entry.name, 'blender.exe'));
62
+ found.push(path.join(root, 'blender.exe'));
63
+ } catch { /* optional search root */ }
64
+ }
65
+ return found;
66
+ }
67
+
68
+ export function discoverBlender({
69
+ blenderBin,
70
+ env = process.env,
71
+ platform = process.platform,
72
+ existsSync = fs.existsSync,
73
+ readdirSync = fs.readdirSync,
74
+ } = {}) {
75
+ const explicit = String(blenderBin || '').trim();
76
+ if (explicit) {
77
+ const resolved = path.resolve(explicit);
78
+ if (!isFile(resolved, existsSync)) throw new Error(`Blender binary not found: ${resolved}`);
79
+ return canonicalExecutable(resolved);
80
+ }
81
+
82
+ const configured = String(env.AGENTSAM_BLENDER_BIN || '').trim();
83
+ if (configured) {
84
+ const resolved = path.resolve(configured);
85
+ if (!isFile(resolved, existsSync)) throw new Error(`AGENTSAM_BLENDER_BIN does not exist: ${resolved}`);
86
+ return canonicalExecutable(resolved);
87
+ }
88
+
89
+ const candidates = [
90
+ ...pathCandidates(env.PATH, platform),
91
+ ...(platform === 'win32' ? windowsInstallCandidates(env, readdirSync) : []),
92
+ ...(platform === 'darwin' ? ['/Applications/Blender.app/Contents/MacOS/Blender'] : []),
93
+ ...(platform === 'linux' ? ['/usr/bin/blender', '/usr/local/bin/blender', '/snap/bin/blender'] : []),
94
+ ];
95
+ const found = candidates.find(candidate => isFile(candidate, existsSync));
96
+ return found ? canonicalExecutable(found) : null;
97
+ }
98
+
99
+ export function sha256File(file) {
100
+ const hash = createHash('sha256');
101
+ hash.update(fs.readFileSync(file));
102
+ return hash.digest('hex');
103
+ }
104
+
105
+ function boundedInteger(value, fallback, min, max, label) {
106
+ const parsed = value == null ? fallback : Number(value);
107
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
108
+ throw new Error(`${label} must be an integer from ${min} to ${max}`);
109
+ }
110
+ return parsed;
111
+ }
112
+
113
+ function resolveInputBlend(input, cwd = process.cwd()) {
114
+ const resolved = path.resolve(cwd, String(input || ''));
115
+ if (!String(input || '').trim()) throw new Error('Blender input file is required');
116
+ if (path.extname(resolved).toLowerCase() !== '.blend') throw new Error('Blender input must be a .blend file');
117
+ if (!isFile(resolved)) throw new Error(`Blender input file not found: ${resolved}`);
118
+ return resolved;
119
+ }
120
+
121
+ function resolveOutput(output, expectedExt, cwd = process.cwd(), input = null) {
122
+ if (!String(output || '').trim()) throw new Error('Output path is required');
123
+ const resolved = path.resolve(cwd, output);
124
+ if (path.extname(resolved).toLowerCase() !== expectedExt) {
125
+ throw new Error(`Output must end in ${expectedExt}`);
126
+ }
127
+ if (input && path.resolve(input) === resolved) throw new Error('Blender operations never overwrite the source .blend file');
128
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
129
+ return resolved;
130
+ }
131
+
132
+ export function validateBlenderRecipe(recipe) {
133
+ if (!recipe || typeof recipe !== 'object' || Array.isArray(recipe)) throw new Error('Blender recipe must be a JSON object');
134
+ if (recipe.schema_version !== 1) throw new Error('Blender recipe schema_version must be 1');
135
+ if (!Array.isArray(recipe.operations) || recipe.operations.length < 1) throw new Error('Blender recipe operations must be a non-empty array');
136
+ if (recipe.operations.length > 256) throw new Error('Blender recipe may contain at most 256 operations');
137
+ for (let index = 0; index < recipe.operations.length; index += 1) {
138
+ const operation = recipe.operations[index];
139
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw new Error(`recipe operation ${index} must be an object`);
140
+ const op = String(operation.op || '').trim();
141
+ if (!BLENDER_RECIPE_OPS.includes(op)) throw new Error(`unsupported Blender recipe operation at ${index}: ${op || '<empty>'}`);
142
+ }
143
+ return structuredClone(recipe);
144
+ }
145
+
146
+ export function parseBlenderResult(stdout) {
147
+ const line = String(stdout || '').split(/\r?\n/).reverse().find(value => value.startsWith(BLENDER_RESULT_PREFIX));
148
+ if (!line) throw new Error('Blender did not return an AgentSam result envelope');
149
+ let value;
150
+ try { value = JSON.parse(line.slice(BLENDER_RESULT_PREFIX.length)); }
151
+ catch { throw new Error('Blender returned malformed AgentSam JSON'); }
152
+ if (!value || typeof value !== 'object') throw new Error('Blender returned an invalid AgentSam result envelope');
153
+ return value;
154
+ }
155
+
156
+ export function createBlenderInvocation({ operation, input, requestPath, blenderBin, factoryStartup = false }) {
157
+ const args = ['--background'];
158
+ if (factoryStartup) args.push('--factory-startup');
159
+ if (input) args.push(input);
160
+ args.push('--python', BLENDER_ADAPTER_PATH, '--', '--operation', operation, '--request', requestPath);
161
+ return { command: blenderBin, args };
162
+ }
163
+
164
+ async function invokeBlender({
165
+ operation,
166
+ input = null,
167
+ request = {},
168
+ blenderBin,
169
+ timeoutSeconds = 120,
170
+ cwd = process.cwd(),
171
+ factoryStartup = false,
172
+ runProcessImpl = runProcess,
173
+ }) {
174
+ const binary = discoverBlender({ blenderBin });
175
+ if (!binary) throw new Error('Blender is not installed or could not be discovered; use --blender-bin or AGENTSAM_BLENDER_BIN');
176
+ if (!fs.existsSync(BLENDER_ADAPTER_PATH)) throw new Error(`Bundled Blender adapter is missing: ${BLENDER_ADAPTER_PATH}`);
177
+ const timeout = boundedInteger(timeoutSeconds, 120, 1, 600, 'timeout');
178
+ const requestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-blender-'));
179
+ const requestPath = path.join(requestDir, 'request.json');
180
+ fs.writeFileSync(requestPath, JSON.stringify({ schema_version: 1, ...request }, null, 2), { mode: 0o600 });
181
+ const invocation = createBlenderInvocation({ operation, input, requestPath, blenderBin: binary, factoryStartup });
182
+ const started = Date.now();
183
+ try {
184
+ const proc = await runProcessImpl(invocation.command, invocation.args, {
185
+ cwd,
186
+ timeoutMs: timeout * 1000,
187
+ maxBytes: 4 * 1024 * 1024,
188
+ });
189
+ let result;
190
+ try { result = parseBlenderResult(proc.stdout); }
191
+ catch (error) {
192
+ if (proc.code !== 0) throw new Error(`Blender ${operation} failed with exit ${proc.code}: ${(proc.stderr || proc.stdout || '').slice(-4000)}`);
193
+ throw error;
194
+ }
195
+ if (proc.code !== 0 || result.ok === false) throw new Error(result.error || `Blender ${operation} failed with exit ${proc.code}`);
196
+ return {
197
+ result,
198
+ binary,
199
+ duration_ms: Date.now() - started,
200
+ logs: String(proc.stderr || '').slice(-8000),
201
+ };
202
+ } finally {
203
+ fs.rmSync(requestDir, { recursive: true, force: true });
204
+ }
205
+ }
206
+
207
+ export async function blenderStatus({ blenderBin, runProcessImpl = runProcess } = {}) {
208
+ let binary;
209
+ try { binary = discoverBlender({ blenderBin }); }
210
+ catch (error) {
211
+ return { schema_version: 1, capability: 'blender.status', available: false, binary: null, version: null, execution_lane: 'native', error: error.message };
212
+ }
213
+ if (!binary) return { schema_version: 1, capability: 'blender.status', available: false, binary: null, version: null, execution_lane: 'native' };
214
+ const proc = await runProcessImpl(binary, ['--version'], { timeoutMs: 10_000, maxBytes: 256 * 1024 });
215
+ const version = String(proc.stdout || proc.stderr || '').split(/\r?\n/).find(Boolean)?.trim() || null;
216
+ return { schema_version: 1, capability: 'blender.status', available: proc.code === 0, binary, version, execution_lane: 'native' };
217
+ }
218
+
219
+ function inputReceipt(input) {
220
+ return input ? { path: input, sha256: sha256File(input) } : null;
221
+ }
222
+
223
+ function artifactReceipt(file, format) {
224
+ const stat = fs.statSync(file);
225
+ return { path: file, format, size_bytes: stat.size, sha256: sha256File(file) };
226
+ }
227
+
228
+ export async function blenderInspect({ input, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
229
+ const source = resolveInputBlend(input, cwd);
230
+ const inputInfo = inputReceipt(source);
231
+ const run = await invokeBlender({ operation: 'inspect', input: source, blenderBin, timeoutSeconds, cwd, runProcessImpl });
232
+ return {
233
+ schema_version: 1,
234
+ capability: 'blender.inspect',
235
+ ok: true,
236
+ execution_lane: 'native',
237
+ input: inputInfo,
238
+ blender: { binary: run.binary, version: run.result.blender_version || null },
239
+ scene: run.result.scene,
240
+ duration_ms: run.duration_ms,
241
+ warnings: run.result.warnings || [],
242
+ };
243
+ }
244
+
245
+ export async function blenderBuild({ input, output, recipe, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
246
+ const source = input ? resolveInputBlend(input, cwd) : null;
247
+ const target = resolveOutput(output, '.blend', cwd, source);
248
+ const normalizedRecipe = validateBlenderRecipe(recipe);
249
+ const run = await invokeBlender({
250
+ operation: 'build',
251
+ input: source,
252
+ request: { output: target, recipe: normalizedRecipe },
253
+ blenderBin,
254
+ timeoutSeconds,
255
+ cwd,
256
+ factoryStartup: !source,
257
+ runProcessImpl,
258
+ });
259
+ if (!isFile(target)) throw new Error(`Blender build did not create output: ${target}`);
260
+ return {
261
+ schema_version: 1,
262
+ capability: 'blender.build',
263
+ ok: true,
264
+ execution_lane: 'native',
265
+ input: inputReceipt(source),
266
+ blender: { binary: run.binary, version: run.result.blender_version || null },
267
+ artifact: artifactReceipt(target, 'blend'),
268
+ operations_applied: run.result.operations_applied ?? normalizedRecipe.operations.length,
269
+ objects: run.result.objects || [],
270
+ duration_ms: run.duration_ms,
271
+ warnings: run.result.warnings || [],
272
+ };
273
+ }
274
+
275
+ export async function blenderRenderPreview({ input, output, scene, camera, width = 1024, height = 1024, engine, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
276
+ const source = resolveInputBlend(input, cwd);
277
+ const target = resolveOutput(output, '.png', cwd, source);
278
+ const request = {
279
+ output: target,
280
+ scene: scene || null,
281
+ camera: camera || null,
282
+ width: boundedInteger(width, 1024, 64, 4096, 'width'),
283
+ height: boundedInteger(height, 1024, 64, 4096, 'height'),
284
+ engine: engine || null,
285
+ };
286
+ const run = await invokeBlender({ operation: 'render_preview', input: source, request, blenderBin, timeoutSeconds, cwd, runProcessImpl });
287
+ if (!isFile(target)) throw new Error(`Blender render did not create output: ${target}`);
288
+ return {
289
+ schema_version: 1,
290
+ capability: 'blender.render_preview',
291
+ ok: true,
292
+ execution_lane: 'native',
293
+ input: inputReceipt(source),
294
+ blender: { binary: run.binary, version: run.result.blender_version || null },
295
+ artifact: artifactReceipt(target, 'png'),
296
+ scene: run.result.scene || null,
297
+ camera: run.result.camera || null,
298
+ duration_ms: run.duration_ms,
299
+ warnings: run.result.warnings || [],
300
+ };
301
+ }
302
+
303
+ export async function blenderExport({ input, output, format, scene, objects, collection, applyModifiers = true, blenderBin, timeoutSeconds, cwd = process.cwd(), runProcessImpl } = {}) {
304
+ const source = resolveInputBlend(input, cwd);
305
+ const normalizedFormat = String(format || '').toLowerCase().replace(/^\./, '');
306
+ if (!BLENDER_EXPORT_FORMATS.includes(normalizedFormat)) throw new Error(`Unsupported Blender export format: ${normalizedFormat || '<empty>'}. Expected ${BLENDER_EXPORT_FORMATS.join(', ')}`);
307
+ const target = resolveOutput(output, `.${normalizedFormat}`, cwd, source);
308
+ const selectedObjects = Array.isArray(objects)
309
+ ? objects.map(String).filter(Boolean)
310
+ : String(objects || '').split(',').map(value => value.trim()).filter(Boolean);
311
+ const run = await invokeBlender({
312
+ operation: 'export',
313
+ input: source,
314
+ request: {
315
+ output: target,
316
+ format: normalizedFormat,
317
+ scene: scene || null,
318
+ objects: selectedObjects,
319
+ collection: collection || null,
320
+ apply_modifiers: Boolean(applyModifiers),
321
+ },
322
+ blenderBin,
323
+ timeoutSeconds,
324
+ cwd,
325
+ runProcessImpl,
326
+ });
327
+ if (!isFile(target)) throw new Error(`Blender export did not create output: ${target}`);
328
+ return {
329
+ schema_version: 1,
330
+ capability: 'blender.export',
331
+ ok: true,
332
+ execution_lane: 'native',
333
+ input: inputReceipt(source),
334
+ blender: { binary: run.binary, version: run.result.blender_version || null },
335
+ artifact: artifactReceipt(target, normalizedFormat),
336
+ selected_objects: run.result.selected_objects || [],
337
+ duration_ms: run.duration_ms,
338
+ warnings: run.result.warnings || [],
339
+ };
340
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ BLENDER_ADAPTER_PATH,
3
+ BLENDER_EXPORT_FORMATS,
4
+ BLENDER_RECIPE_OPS,
5
+ blenderBuild,
6
+ blenderExport,
7
+ blenderInspect,
8
+ blenderRenderPreview,
9
+ blenderStatus,
10
+ createBlenderInvocation,
11
+ discoverBlender,
12
+ parseBlenderResult,
13
+ sha256File,
14
+ validateBlenderRecipe,
15
+ } from './blender.js';
@@ -0,0 +1,146 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ BLENDER_ADAPTER_PATH,
8
+ blenderBuild,
9
+ blenderInspect,
10
+ blenderStatus,
11
+ createBlenderInvocation,
12
+ discoverBlender,
13
+ parseBlenderResult,
14
+ validateBlenderRecipe,
15
+ } from '../src/lib/cad/index.js';
16
+
17
+ function fixture(t) {
18
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agentsam-blender-test-'));
19
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
20
+ const binary = path.join(root, process.platform === 'win32' ? 'blender.exe' : 'blender');
21
+ fs.writeFileSync(binary, 'fake blender\n');
22
+ const blend = path.join(root, 'source.blend');
23
+ fs.writeFileSync(blend, 'blend fixture\n');
24
+ return { root, binary, blend };
25
+ }
26
+
27
+ function resultEnvelope(value) {
28
+ return { code: 0, stdout: `Blender startup noise\nAGENTSAM_RESULT=${JSON.stringify(value)}\n`, stderr: '' };
29
+ }
30
+
31
+ test('Blender adapter is packaged at the SDK-owned fixed path', () => {
32
+ assert.equal(path.basename(BLENDER_ADAPTER_PATH), 'adapter.py');
33
+ assert.ok(fs.existsSync(BLENDER_ADAPTER_PATH));
34
+ });
35
+
36
+ test('discoverBlender honors explicit binary before environment discovery', t => {
37
+ const { binary } = fixture(t);
38
+ assert.equal(discoverBlender({ blenderBin: binary, env: { AGENTSAM_BLENDER_BIN: '/wrong' } }), fs.realpathSync(binary));
39
+ assert.throws(() => discoverBlender({ blenderBin: path.join(path.dirname(binary), 'missing') }), /not found/);
40
+ });
41
+
42
+ test('Blender status uses argv execution and returns version without shell parsing', async t => {
43
+ const { binary } = fixture(t);
44
+ const calls = [];
45
+ const result = await blenderStatus({
46
+ blenderBin: binary,
47
+ runProcessImpl: async (command, args) => {
48
+ calls.push({ command, args });
49
+ return { code: 0, stdout: 'Blender 4.5.3 LTS\n', stderr: '' };
50
+ },
51
+ });
52
+ assert.equal(result.available, true);
53
+ assert.equal(result.version, 'Blender 4.5.3 LTS');
54
+ assert.deepEqual(calls, [{ command: fs.realpathSync(binary), args: ['--version'] }]);
55
+ });
56
+
57
+ test('typed Blender recipe rejects arbitrary or unknown operations', () => {
58
+ const valid = validateBlenderRecipe({
59
+ schema_version: 1,
60
+ operations: [
61
+ { op: 'add', primitive: 'cube', name: 'Body', size: 20 },
62
+ { op: 'bevel', object: 'Body', width: 1, segments: 3 },
63
+ ],
64
+ });
65
+ assert.equal(valid.operations.length, 2);
66
+ assert.throws(() => validateBlenderRecipe({ schema_version: 1, operations: [{ op: 'python', code: 'import bpy' }] }), /unsupported Blender recipe operation/);
67
+ });
68
+
69
+ test('Blender invocation is background + fixed adapter + typed request, never shell code', t => {
70
+ const { binary, blend, root } = fixture(t);
71
+ const requestPath = path.join(root, 'request.json');
72
+ const value = createBlenderInvocation({ operation: 'inspect', input: blend, requestPath, blenderBin: binary });
73
+ assert.equal(value.command, binary);
74
+ assert.deepEqual(value.args.slice(0, 2), ['--background', blend]);
75
+ assert.ok(value.args.includes('--python'));
76
+ assert.ok(value.args.includes(BLENDER_ADAPTER_PATH));
77
+ assert.deepEqual(value.args.slice(-4), ['--operation', 'inspect', '--request', requestPath]);
78
+ });
79
+
80
+ test('parseBlenderResult ignores Blender logs and reads the machine envelope', () => {
81
+ assert.deepEqual(parseBlenderResult('noise\nAGENTSAM_RESULT={"ok":true,"value":7}\n'), { ok: true, value: 7 });
82
+ assert.throws(() => parseBlenderResult('noise only'), /did not return/);
83
+ });
84
+
85
+ test('blenderInspect returns source hash and scene evidence without writing source', async t => {
86
+ const { binary, blend } = fixture(t);
87
+ const before = fs.readFileSync(blend, 'utf8');
88
+ const result = await blenderInspect({
89
+ input: blend,
90
+ blenderBin: binary,
91
+ runProcessImpl: async () => resultEnvelope({
92
+ ok: true,
93
+ blender_version: '4.5.3',
94
+ scene: { active: 'Scene', objects: [{ name: 'Body', type: 'MESH' }] },
95
+ warnings: [],
96
+ }),
97
+ });
98
+ assert.equal(result.capability, 'blender.inspect');
99
+ assert.equal(result.scene.objects[0].name, 'Body');
100
+ assert.match(result.input.sha256, /^[a-f0-9]{64}$/);
101
+ assert.equal(fs.readFileSync(blend, 'utf8'), before);
102
+ });
103
+
104
+ test('blenderBuild executes a bounded recipe and produces a hashed .blend artifact', async t => {
105
+ const { binary, root } = fixture(t);
106
+ const output = path.join(root, 'built.blend');
107
+ const recipe = {
108
+ schema_version: 1,
109
+ units: { system: 'METRIC', scale_length: 0.001, length_unit: 'MILLIMETERS' },
110
+ operations: [
111
+ { op: 'clear_scene' },
112
+ { op: 'add', primitive: 'cube', name: 'Body', size: 20 },
113
+ { op: 'add', primitive: 'cylinder', name: 'Hole', radius: 3, depth: 30 },
114
+ { op: 'boolean', object: 'Body', with: 'Hole', operation: 'DIFFERENCE', delete_operand: true },
115
+ ],
116
+ };
117
+ let seenRequest;
118
+ const result = await blenderBuild({
119
+ output,
120
+ recipe,
121
+ blenderBin: binary,
122
+ runProcessImpl: async (_command, args) => {
123
+ const requestPath = args[args.indexOf('--request') + 1];
124
+ seenRequest = JSON.parse(fs.readFileSync(requestPath, 'utf8'));
125
+ fs.writeFileSync(seenRequest.output, 'generated blend artifact\n');
126
+ return resultEnvelope({ ok: true, blender_version: '4.5.3', operations_applied: recipe.operations.length, objects: ['Body'], warnings: [] });
127
+ },
128
+ });
129
+ assert.equal(seenRequest.recipe.operations[3].operation, 'DIFFERENCE');
130
+ assert.equal(result.capability, 'blender.build');
131
+ assert.equal(result.artifact.path, output);
132
+ assert.equal(result.artifact.format, 'blend');
133
+ assert.match(result.artifact.sha256, /^[a-f0-9]{64}$/);
134
+ assert.deepEqual(result.objects, ['Body']);
135
+ });
136
+
137
+ test('Blender build refuses to overwrite an input source .blend', async t => {
138
+ const { binary, blend } = fixture(t);
139
+ await assert.rejects(() => blenderBuild({
140
+ input: blend,
141
+ output: blend,
142
+ blenderBin: binary,
143
+ recipe: { schema_version: 1, operations: [{ op: 'clear_scene' }] },
144
+ runProcessImpl: async () => { throw new Error('runner should not execute'); },
145
+ }), /never overwrite/);
146
+ });