keyshot-mcp 0.2.1 → 0.4.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/README.md +190 -24
- package/assets/demo/keyshot-mcp-demo.png +0 -0
- package/dist/config.js +5 -0
- package/dist/index.js +62 -6
- package/dist/output-paths.js +55 -0
- package/dist/presets.js +67 -0
- package/dist/queue.js +70 -0
- package/dist/runner.js +107 -45
- package/dist/schemas.js +45 -0
- package/dist/version.js +1 -0
- package/examples/demo/README.md +9 -0
- package/examples/demo/keyshot-mcp-cube.obj +23 -0
- package/package.json +12 -3
- package/presets/materials.json +14 -0
- package/scripts/keyshot-smoke.mjs +67 -0
- package/scripts/keyshot_bridge.py +183 -34
- package/server.json +31 -0
package/dist/runner.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { localFailure } from "./result.js";
|
|
6
|
+
import { normalizeOutputPaths } from "./output-paths.js";
|
|
6
7
|
let queue = Promise.resolve();
|
|
7
8
|
export function runKeyShotSerialized(config, request) {
|
|
8
9
|
const run = queue.then(() => runKeyShot(config, request));
|
|
@@ -10,8 +11,7 @@ export function runKeyShotSerialized(config, request) {
|
|
|
10
11
|
return run;
|
|
11
12
|
}
|
|
12
13
|
async function runKeyShot(config, request) {
|
|
13
|
-
|
|
14
|
-
if (!exeExists) {
|
|
14
|
+
if (isPathLike(config.keyshotHeadlessExe) && !(await exists(config.keyshotHeadlessExe))) {
|
|
15
15
|
return localFailure(`KeyShot headless executable not found: ${config.keyshotHeadlessExe}`);
|
|
16
16
|
}
|
|
17
17
|
const bridgeExists = await exists(config.bridgeScriptPath);
|
|
@@ -23,11 +23,18 @@ async function runKeyShot(config, request) {
|
|
|
23
23
|
}
|
|
24
24
|
await fs.mkdir(config.tmpDir, { recursive: true });
|
|
25
25
|
await fs.mkdir(config.keyshotOutputDir, { recursive: true });
|
|
26
|
+
let normalizedRequest;
|
|
27
|
+
try {
|
|
28
|
+
normalizedRequest = await normalizeOutputPaths(config, request);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return localFailure(errorMessage(error));
|
|
32
|
+
}
|
|
26
33
|
const id = `${Date.now()}-${randomUUID()}`;
|
|
27
34
|
const argsPath = path.join(config.tmpDir, `${id}.args.json`);
|
|
28
35
|
const resultPath = path.join(config.tmpDir, `${id}.result.json`);
|
|
29
36
|
const payload = {
|
|
30
|
-
...
|
|
37
|
+
...normalizedRequest,
|
|
31
38
|
defaults: {
|
|
32
39
|
outputDir: config.keyshotOutputDir,
|
|
33
40
|
},
|
|
@@ -36,56 +43,77 @@ async function runKeyShot(config, request) {
|
|
|
36
43
|
const args = [
|
|
37
44
|
...config.keyshotLicenseArgs,
|
|
38
45
|
"-progress",
|
|
39
|
-
...(
|
|
46
|
+
...(normalizedRequest.scenePath ? [normalizedRequest.scenePath] : []),
|
|
40
47
|
"-script",
|
|
41
48
|
config.bridgeScriptPath,
|
|
42
49
|
argsPath,
|
|
43
50
|
resultPath,
|
|
44
51
|
];
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
try {
|
|
53
|
+
const processResult = await spawnWithTimeout(config.keyshotHeadlessExe, args, config.keyshotTimeoutMs);
|
|
54
|
+
const stdoutTail = tail(processResult.stdout, 6000);
|
|
55
|
+
const stderrTail = tail(processResult.stderr, 6000);
|
|
56
|
+
let parsed = null;
|
|
57
|
+
if (await exists(resultPath)) {
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(await fs.readFile(resultPath, "utf8"));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return localFailure(`Could not parse KeyShot result JSON: ${errorMessage(error)}`, {
|
|
63
|
+
keyshotStdoutTail: stdoutTail,
|
|
64
|
+
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
return localFailure(`Could not
|
|
68
|
+
if (processResult.spawnError) {
|
|
69
|
+
return localFailure(`Could not start KeyShot headless: ${processResult.spawnError}`, {
|
|
55
70
|
keyshotStdoutTail: stdoutTail,
|
|
56
71
|
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
57
72
|
});
|
|
58
73
|
}
|
|
74
|
+
if (processResult.timedOut) {
|
|
75
|
+
return localFailure(`KeyShot timed out after ${config.keyshotTimeoutMs}ms`, {
|
|
76
|
+
data: parsed?.data ?? null,
|
|
77
|
+
outputFiles: parsed?.outputFiles ?? [],
|
|
78
|
+
warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
|
|
79
|
+
keyshotStdoutTail: stdoutTail,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!parsed) {
|
|
83
|
+
return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
|
|
84
|
+
keyshotStdoutTail: stdoutTail,
|
|
85
|
+
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
|
|
89
|
+
if (stderrTail)
|
|
90
|
+
parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
|
|
91
|
+
if (processResult.exitCode !== 0 && parsed.ok) {
|
|
92
|
+
return {
|
|
93
|
+
...parsed,
|
|
94
|
+
ok: false,
|
|
95
|
+
error: `KeyShot exited with code ${processResult.exitCode}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return parsed;
|
|
59
99
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
outputFiles: parsed?.outputFiles ?? [],
|
|
64
|
-
warnings: [...(parsed?.warnings ?? []), ...(stderrTail ? [`stderr: ${stderrTail}`] : [])],
|
|
65
|
-
keyshotStdoutTail: stdoutTail,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
if (!parsed) {
|
|
69
|
-
return localFailure(`KeyShot did not produce a result file. Exit code: ${processResult.exitCode ?? "unknown"}`, {
|
|
70
|
-
keyshotStdoutTail: stdoutTail,
|
|
71
|
-
warnings: stderrTail ? [`stderr: ${stderrTail}`] : [],
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
parsed.keyshotStdoutTail = stdoutTail || parsed.keyshotStdoutTail || "";
|
|
75
|
-
if (stderrTail)
|
|
76
|
-
parsed.warnings = [...parsed.warnings, `stderr: ${stderrTail}`];
|
|
77
|
-
if (processResult.exitCode !== 0 && parsed.ok) {
|
|
78
|
-
return {
|
|
79
|
-
...parsed,
|
|
80
|
-
ok: false,
|
|
81
|
-
error: `KeyShot exited with code ${processResult.exitCode}`,
|
|
82
|
-
};
|
|
100
|
+
finally {
|
|
101
|
+
// Best-effort cleanup so work/tmp does not accumulate args/result files.
|
|
102
|
+
await cleanupTmp([argsPath, resultPath]).catch(() => undefined);
|
|
83
103
|
}
|
|
84
|
-
return parsed;
|
|
85
104
|
}
|
|
86
|
-
function
|
|
105
|
+
async function cleanupTmp(paths) {
|
|
106
|
+
await Promise.all(paths.map(async (filePath) => {
|
|
107
|
+
if (await exists(filePath)) {
|
|
108
|
+
await fs.rm(filePath, { force: true });
|
|
109
|
+
}
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
export const MAX_CAPTURE_CHARS = 64 * 1024;
|
|
113
|
+
export function spawnWithTimeout(command, args, timeoutMs) {
|
|
87
114
|
return new Promise((resolve) => {
|
|
88
115
|
const child = spawn(command, args, {
|
|
116
|
+
detached: process.platform !== "win32",
|
|
89
117
|
windowsHide: true,
|
|
90
118
|
stdio: ["ignore", "pipe", "pipe"],
|
|
91
119
|
});
|
|
@@ -93,30 +121,64 @@ function spawnWithTimeout(command, args, timeoutMs) {
|
|
|
93
121
|
let stderr = "";
|
|
94
122
|
let settled = false;
|
|
95
123
|
let timedOut = false;
|
|
124
|
+
let spawnError = null;
|
|
96
125
|
const timer = setTimeout(() => {
|
|
97
126
|
timedOut = true;
|
|
98
|
-
child.
|
|
127
|
+
terminateProcessTree(child.pid, "SIGTERM");
|
|
99
128
|
setTimeout(() => {
|
|
100
|
-
if (!settled)
|
|
101
|
-
child.
|
|
129
|
+
if (!settled && process.platform !== "win32")
|
|
130
|
+
terminateProcessTree(child.pid, "SIGKILL");
|
|
102
131
|
}, 2500).unref();
|
|
103
132
|
}, timeoutMs);
|
|
104
133
|
child.stdout?.on("data", (chunk) => {
|
|
105
|
-
stdout
|
|
134
|
+
stdout = appendBounded(stdout, String(chunk));
|
|
106
135
|
});
|
|
107
136
|
child.stderr?.on("data", (chunk) => {
|
|
108
|
-
stderr
|
|
137
|
+
stderr = appendBounded(stderr, String(chunk));
|
|
109
138
|
});
|
|
110
139
|
child.on("error", (error) => {
|
|
111
|
-
|
|
140
|
+
spawnError = errorMessage(error);
|
|
141
|
+
stderr = appendBounded(stderr, spawnError);
|
|
112
142
|
});
|
|
113
143
|
child.on("close", (exitCode) => {
|
|
114
144
|
settled = true;
|
|
115
145
|
clearTimeout(timer);
|
|
116
|
-
resolve({ exitCode, stdout, stderr, timedOut });
|
|
146
|
+
resolve({ exitCode, stdout, stderr, timedOut, spawnError });
|
|
117
147
|
});
|
|
118
148
|
});
|
|
119
149
|
}
|
|
150
|
+
function terminateProcessTree(pid, signal) {
|
|
151
|
+
if (!pid)
|
|
152
|
+
return;
|
|
153
|
+
if (process.platform === "win32") {
|
|
154
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
155
|
+
windowsHide: true,
|
|
156
|
+
stdio: "ignore",
|
|
157
|
+
});
|
|
158
|
+
killer.on("error", () => undefined);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
process.kill(-pid, signal);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
try {
|
|
166
|
+
process.kill(pid, signal);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// The process may already have exited.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function appendBounded(current, addition) {
|
|
174
|
+
const combined = current + addition;
|
|
175
|
+
return combined.length <= MAX_CAPTURE_CHARS
|
|
176
|
+
? combined
|
|
177
|
+
: combined.slice(combined.length - MAX_CAPTURE_CHARS);
|
|
178
|
+
}
|
|
179
|
+
function isPathLike(command) {
|
|
180
|
+
return path.isAbsolute(command) || command.includes("/") || command.includes("\\");
|
|
181
|
+
}
|
|
120
182
|
async function exists(filePath) {
|
|
121
183
|
try {
|
|
122
184
|
await fs.access(filePath);
|
package/dist/schemas.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
const optionalPath = z.string().min(1).optional();
|
|
3
|
+
const imageFormat = z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]);
|
|
3
4
|
export const scenePathSchema = z.object({
|
|
4
5
|
scenePath: z.string().min(1),
|
|
5
6
|
});
|
|
7
|
+
export const listCamerasSchema = z.object({
|
|
8
|
+
scenePath: z.string().min(1),
|
|
9
|
+
});
|
|
6
10
|
export const renderSchema = z.object({
|
|
7
11
|
scenePath: z.string().min(1),
|
|
8
12
|
outputPath: optionalPath,
|
|
@@ -13,6 +17,10 @@ export const renderSchema = z.object({
|
|
|
13
17
|
camera: z.string().min(1).optional(),
|
|
14
18
|
format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
|
|
15
19
|
});
|
|
20
|
+
export const renderInputSchema = renderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
21
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
22
|
+
path: ["maxTimeSeconds"],
|
|
23
|
+
});
|
|
16
24
|
export const batchRenderSchema = z.object({
|
|
17
25
|
scenePath: z.string().min(1),
|
|
18
26
|
outputDir: z.string().min(1),
|
|
@@ -24,6 +32,10 @@ export const batchRenderSchema = z.object({
|
|
|
24
32
|
format: z.enum(["png", "jpg", "jpeg", "tif", "tiff", "exr"]).optional(),
|
|
25
33
|
overwrite: z.boolean().optional(),
|
|
26
34
|
});
|
|
35
|
+
export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
36
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
37
|
+
path: ["maxTimeSeconds"],
|
|
38
|
+
});
|
|
27
39
|
export const importModelSchema = z.object({
|
|
28
40
|
modelPath: z.string().min(1),
|
|
29
41
|
baseScenePath: optionalPath,
|
|
@@ -63,3 +75,36 @@ export const saveSceneSchema = z.object({
|
|
|
63
75
|
scenePath: z.string().min(1),
|
|
64
76
|
outputScenePath: z.string().min(1),
|
|
65
77
|
});
|
|
78
|
+
// --- Render queue ---
|
|
79
|
+
export const renderJobSchema = z.object({
|
|
80
|
+
scenePath: z.string().min(1),
|
|
81
|
+
outputPath: optionalPath,
|
|
82
|
+
camera: z.string().min(1).optional(),
|
|
83
|
+
width: z.number().int().positive().optional(),
|
|
84
|
+
height: z.number().int().positive().optional(),
|
|
85
|
+
samples: z.number().int().positive().optional(),
|
|
86
|
+
maxTimeSeconds: z.number().positive().optional(),
|
|
87
|
+
format: imageFormat.optional(),
|
|
88
|
+
});
|
|
89
|
+
export const renderJobInputSchema = renderJobSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
90
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
91
|
+
path: ["maxTimeSeconds"],
|
|
92
|
+
});
|
|
93
|
+
export const renderQueueSchema = z.object({
|
|
94
|
+
jobs: z.array(renderJobSchema).min(1),
|
|
95
|
+
continueOnError: z.boolean().optional(),
|
|
96
|
+
});
|
|
97
|
+
export const renderQueueInputSchema = z.object({
|
|
98
|
+
jobs: z.array(renderJobInputSchema).min(1),
|
|
99
|
+
continueOnError: z.boolean().optional(),
|
|
100
|
+
});
|
|
101
|
+
// --- Material preset library ---
|
|
102
|
+
export const listMaterialPresetsSchema = z.object({});
|
|
103
|
+
export const applyMaterialPresetInputSchema = z.object({
|
|
104
|
+
scenePath: z.string().min(1),
|
|
105
|
+
presetName: z.string().min(1),
|
|
106
|
+
objectName: z.string().min(1).optional(),
|
|
107
|
+
objectPath: z.string().min(1).optional(),
|
|
108
|
+
outputScenePath: z.string().min(1),
|
|
109
|
+
});
|
|
110
|
+
export const applyMaterialPresetSchema = applyMaterialPresetInputSchema.refine((value) => value.objectName || value.objectPath, { message: "Provide objectName or objectPath." });
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.4.2";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# KeyShot smoke-test demo
|
|
2
|
+
|
|
3
|
+
`keyshot-mcp-cube.obj` is original, generated geometry included under this
|
|
4
|
+
repository's MIT license. It contains no customer model or third-party asset.
|
|
5
|
+
|
|
6
|
+
Run `npm run smoke:keyshot` after setting `KEYSHOT_HEADLESS_EXE`. The command
|
|
7
|
+
checks KeyShot, imports the cube, saves and inspects the scene, creates an MCP
|
|
8
|
+
camera, and renders `outputs/demo/keyshot-mcp-demo.png`. Generated `.bip` files
|
|
9
|
+
remain under `outputs/` and are ignored by Git.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# KeyShot MCP generated demo cube
|
|
2
|
+
# Original geometry released under the repository MIT license.
|
|
3
|
+
o KeyShot_MCP_Cube
|
|
4
|
+
v -1.0 -1.0 -1.0
|
|
5
|
+
v 1.0 -1.0 -1.0
|
|
6
|
+
v 1.0 1.0 -1.0
|
|
7
|
+
v -1.0 1.0 -1.0
|
|
8
|
+
v -1.0 -1.0 1.0
|
|
9
|
+
v 1.0 -1.0 1.0
|
|
10
|
+
v 1.0 1.0 1.0
|
|
11
|
+
v -1.0 1.0 1.0
|
|
12
|
+
vn 0.0 0.0 -1.0
|
|
13
|
+
vn 0.0 0.0 1.0
|
|
14
|
+
vn 0.0 -1.0 0.0
|
|
15
|
+
vn 0.0 1.0 0.0
|
|
16
|
+
vn -1.0 0.0 0.0
|
|
17
|
+
vn 1.0 0.0 0.0
|
|
18
|
+
f 1//1 4//1 3//1 2//1
|
|
19
|
+
f 5//2 6//2 7//2 8//2
|
|
20
|
+
f 1//3 2//3 6//3 5//3
|
|
21
|
+
f 4//4 8//4 7//4 3//4
|
|
22
|
+
f 1//5 5//5 8//5 4//5
|
|
23
|
+
f 2//6 3//6 7//6 6//6
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keyshot-mcp",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"mcpName": "io.github.truman-t3/keyshot-mcp",
|
|
4
5
|
"description": "Local and portable MCP server for KeyShot headless scripting.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"homepage": "https://github.com/truman-t3/keyshot-mcp#readme",
|
|
@@ -19,7 +20,9 @@
|
|
|
19
20
|
"prepare": "tsc -p tsconfig.json",
|
|
20
21
|
"start": "node ./dist/index.js",
|
|
21
22
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
22
|
-
"
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"status": "node ./dist/cli/status.js",
|
|
25
|
+
"smoke:keyshot": "npm run build && node ./scripts/keyshot-smoke.mjs"
|
|
23
26
|
},
|
|
24
27
|
"keywords": [
|
|
25
28
|
"mcp",
|
|
@@ -32,7 +35,11 @@
|
|
|
32
35
|
"files": [
|
|
33
36
|
"dist",
|
|
34
37
|
"scripts/keyshot_bridge.py",
|
|
38
|
+
"scripts/keyshot-smoke.mjs",
|
|
35
39
|
"examples",
|
|
40
|
+
"assets/demo",
|
|
41
|
+
"presets",
|
|
42
|
+
"server.json",
|
|
36
43
|
"README.md",
|
|
37
44
|
"CONTRIBUTING.md",
|
|
38
45
|
"SECURITY.md",
|
|
@@ -42,12 +49,14 @@
|
|
|
42
49
|
"engines": {
|
|
43
50
|
"node": ">=20"
|
|
44
51
|
},
|
|
52
|
+
"packageManager": "pnpm@10.17.1",
|
|
45
53
|
"dependencies": {
|
|
46
54
|
"@modelcontextprotocol/sdk": "^1.18.1",
|
|
47
55
|
"zod": "^3.25.76"
|
|
48
56
|
},
|
|
49
57
|
"devDependencies": {
|
|
50
58
|
"@types/node": "^22.17.2",
|
|
51
|
-
"typescript": "^5.9.2"
|
|
59
|
+
"typescript": "^5.9.2",
|
|
60
|
+
"vitest": "^3.2.4"
|
|
52
61
|
}
|
|
53
62
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Brushed Steel": {
|
|
3
|
+
"materialName": "Steel Brushed",
|
|
4
|
+
"description": "Default brushed stainless steel look for metal parts."
|
|
5
|
+
},
|
|
6
|
+
"Matte Black Plastic": {
|
|
7
|
+
"materialName": "Plastic Matte Black",
|
|
8
|
+
"description": "Soft-touch matte black plastic for housings and enclosures."
|
|
9
|
+
},
|
|
10
|
+
"Clear Glass": {
|
|
11
|
+
"materialName": "Glass Clear",
|
|
12
|
+
"description": "Transparent glass for covers and lenses."
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { getConfig } from "../dist/config.js";
|
|
4
|
+
import { runKeyShotSerialized } from "../dist/runner.js";
|
|
5
|
+
|
|
6
|
+
const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
|
+
const config = getConfig();
|
|
8
|
+
const modelPath = path.join(repoRoot, "examples", "demo", "keyshot-mcp-cube.obj");
|
|
9
|
+
|
|
10
|
+
async function run(label, request) {
|
|
11
|
+
process.stdout.write(`${label}... `);
|
|
12
|
+
const result = await runKeyShotSerialized(config, request);
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
process.stdout.write("failed\n");
|
|
15
|
+
throw new Error(`${label}: ${result.error}\n${result.warnings.join("\n")}`);
|
|
16
|
+
}
|
|
17
|
+
process.stdout.write("ok\n");
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const status = await run("1/5 KeyShot status", { operation: "status" });
|
|
22
|
+
const imported = await run("2/5 Import and save generated OBJ", {
|
|
23
|
+
operation: "import_model",
|
|
24
|
+
modelPath,
|
|
25
|
+
outputScenePath: "demo/keyshot-mcp-demo-import.bip",
|
|
26
|
+
});
|
|
27
|
+
const importedScene = imported.outputFiles[0];
|
|
28
|
+
if (!importedScene) throw new Error("Import did not return a saved scene path.");
|
|
29
|
+
|
|
30
|
+
const inspected = await run("3/5 Inspect imported scene", {
|
|
31
|
+
operation: "inspect_scene",
|
|
32
|
+
scenePath: importedScene,
|
|
33
|
+
});
|
|
34
|
+
const objects = inspected.data?.objects;
|
|
35
|
+
if (!Array.isArray(objects) || objects.length === 0) {
|
|
36
|
+
throw new Error("Imported scene did not contain any inspectable objects.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cameraSceneResult = await run("4/5 Create camera and save scene", {
|
|
40
|
+
operation: "set_camera",
|
|
41
|
+
scenePath: importedScene,
|
|
42
|
+
cameraName: "MCP Demo",
|
|
43
|
+
position: [4.5, 3.5, 4.5],
|
|
44
|
+
lookAt: [0, 0, 0],
|
|
45
|
+
up: [0, 1, 0],
|
|
46
|
+
outputScenePath: "demo/keyshot-mcp-demo-camera.bip",
|
|
47
|
+
});
|
|
48
|
+
const cameraScene = cameraSceneResult.outputFiles[0];
|
|
49
|
+
if (!cameraScene) throw new Error("Camera operation did not return a saved scene path.");
|
|
50
|
+
|
|
51
|
+
const rendered = await run("5/5 Render real PNG", {
|
|
52
|
+
operation: "render",
|
|
53
|
+
scenePath: cameraScene,
|
|
54
|
+
camera: "MCP Demo",
|
|
55
|
+
outputPath: "demo/keyshot-mcp-demo.png",
|
|
56
|
+
width: 640,
|
|
57
|
+
height: 480,
|
|
58
|
+
maxTimeSeconds: 8,
|
|
59
|
+
format: "png",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
console.log(JSON.stringify({
|
|
63
|
+
keyshotVersion: status.data?.version ?? null,
|
|
64
|
+
objectCount: objects.length,
|
|
65
|
+
scenePath: cameraScene,
|
|
66
|
+
imagePath: rendered.outputFiles[0] ?? null,
|
|
67
|
+
}, null, 2));
|