keyshot-mcp 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -32,6 +32,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
32
32
  - Import a model into a scene.
33
33
  - Apply a material to an object.
34
34
  - Create or update a camera.
35
+ - Apply reusable standard or custom camera presets.
35
36
  - Set an environment when the installed KeyShot version exposes that function.
36
37
  - Save a scene to a new file.
37
38
 
@@ -68,7 +69,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
68
69
  npm install -g keyshot-mcp
69
70
  ```
70
71
 
71
- The current release line is `0.5.0`. The published package is the easiest option
72
+ The current release line is `0.6.0`. The published package is the easiest option
72
73
  when you only want to use the MCP server. You still need KeyShot Studio and a
73
74
  local KeyShot license.
74
75
 
@@ -92,7 +93,7 @@ Use exactly one of these approaches:
92
93
  **No installation, run with npx**:
93
94
 
94
95
  ```json
95
- {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
96
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.6.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
96
97
  ```
97
98
 
98
99
  **Run from a cloned source directory** (after `npm install && npm run build`):
@@ -106,11 +107,11 @@ Use exactly one of these approaches:
106
107
  If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
107
108
 
108
109
  ```text
109
- Install KeyShot MCP 0.5.0 from npm and configure it in my MCP client.
110
+ Install KeyShot MCP 0.6.0 from npm and configure it in my MCP client.
110
111
 
111
112
  Please:
112
113
  1. Find my KeyShot headless executable path.
113
- 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.5.0.
114
+ 2. Add a keyshot MCP server using npx -y keyshot-mcp@0.6.0.
114
115
  3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
115
116
  4. Test the setup by running the keyshot_status tool.
116
117
  5. Tell me the exact config that was added and whether the status check passed.
@@ -208,6 +209,11 @@ Find every camera in this KeyShot scene and render all views to the all-cameras
208
209
  output folder. Continue if one camera fails and report the result for each view.
209
210
  ```
210
211
 
212
+ ```text
213
+ List the available camera presets, apply the Isometric preset as a camera named
214
+ "Catalog Isometric", save a new scene, and render a preview from that camera.
215
+ ```
216
+
211
217
  ## Environment Variables
212
218
 
213
219
  - `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
@@ -215,6 +221,7 @@ output folder. Continue if one camera fails and report the result for each view.
215
221
  - `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
216
222
  - `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
217
223
  - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`: allow output paths outside `KEYSHOT_OUTPUT_DIR`. Default: `false`.
224
+ - `KEYSHOT_CAMERA_PRESETS`: camera preset JSON file. Default: `presets/cameras.json`.
218
225
 
219
226
  By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
220
227
  are placed there automatically. Set the compatibility switch to `true` only when
@@ -237,6 +244,8 @@ or the other, not both.
237
244
  - `keyshot_list_material_presets`: list presets from the material preset library
238
245
  - `keyshot_apply_material_preset`: apply a named preset from the library to an object
239
246
  - `keyshot_set_camera`
247
+ - `keyshot_list_camera_presets`: list standard and custom camera presets
248
+ - `keyshot_apply_camera_preset`: create or update a named camera from a preset
240
249
  - `keyshot_set_environment`
241
250
  - `keyshot_save_scene`
242
251
 
@@ -265,6 +274,30 @@ looks instead of remembering exact KeyShot material names. Default location:
265
274
  Each preset must have a `materialName` or a `materialPath`. Use
266
275
  `keyshot_list_material_presets` to see what is available.
267
276
 
277
+ ### Camera preset library
278
+
279
+ The built-in `presets/cameras.json` provides Front, Back, Left, Right, Top,
280
+ Bottom, and Isometric standard views. Use `keyshot_list_camera_presets` to list
281
+ them and `keyshot_apply_camera_preset` to create or update a named camera.
282
+
283
+ Set `KEYSHOT_CAMERA_PRESETS` to a user-managed JSON file for custom presets:
284
+
285
+ ```json
286
+ {
287
+ "Front": { "standardView": "front" },
288
+ "Hero": {
289
+ "position": [4.5, 3.5, 4.5],
290
+ "lookAt": [0, 0, 0],
291
+ "up": [0, 1, 0],
292
+ "description": "Custom absolute product camera"
293
+ }
294
+ }
295
+ ```
296
+
297
+ A preset file may use the object format shown above or an array whose entries
298
+ include a `name`. Each preset must contain either one supported `standardView`,
299
+ or both `position` and `lookAt`. Preset files are read-only to this MCP server.
300
+
268
301
  ## MCP Prompts and Resources
269
302
 
270
303
  - Prompt: `keyshot_product_render`
@@ -287,7 +320,7 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
287
320
 
288
321
  - [x] Auto-discover and batch render all cameras in a scene
289
322
  - [x] Material preset library
290
- - [ ] Camera preset templates
323
+ - [x] Camera preset templates
291
324
  - [x] Sequential render queue
292
325
  - [x] Safer output directory restrictions
293
326
  - [ ] More tested KeyShot versions
@@ -341,6 +374,7 @@ MIT
341
374
  - 把模型导入场景。
342
375
  - 给对象替换材质。
343
376
  - 创建或更新相机。
377
+ - 应用可复用的标准或自定义相机预设。
344
378
  - 在当前 KeyShot 版本支持时设置环境。
345
379
  - 把场景保存为新文件。
346
380
 
@@ -377,7 +411,7 @@ MIT
377
411
  npm install -g keyshot-mcp
378
412
  ```
379
413
 
380
- 当前发布版本为 `0.5.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
414
+ 当前发布版本为 `0.6.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
381
415
  电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
382
416
 
383
417
  ### 从源码安装
@@ -400,7 +434,7 @@ npm run build
400
434
  **免安装,直接使用 npx**:
401
435
 
402
436
  ```json
403
- {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.5.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
437
+ {"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.6.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
404
438
  ```
405
439
 
406
440
  **使用本地源码**(先运行 `npm install && npm run build`):
@@ -414,11 +448,11 @@ npm run build
414
448
  如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
415
449
 
416
450
  ```text
417
- 请帮我安装 KeyShot MCP 0.5.0,并添加到我的 MCP 客户端。
451
+ 请帮我安装 KeyShot MCP 0.6.0,并添加到我的 MCP 客户端。
418
452
 
419
453
  请你:
420
454
  1. 查找我电脑上的 KeyShot headless 可执行文件路径。
421
- 2. 使用 npx -y keyshot-mcp@0.5.0 添加 keyshot MCP server。
455
+ 2. 使用 npx -y keyshot-mcp@0.6.0 添加 keyshot MCP server。
422
456
  3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
423
457
  4. 用 keyshot_status 工具测试是否配置成功。
424
458
  5. 最后告诉我实际添加的配置,以及状态检查是否通过。
@@ -516,6 +550,11 @@ examples/codex.example.json
516
550
  某个相机失败时继续处理其他相机,最后告诉我每个视角的结果。
517
551
  ```
518
552
 
553
+ ```text
554
+ 列出可用的相机预设,把 Isometric 等距预设应用为名为“目录等距视图”的相机,
555
+ 保存一份新场景,并用这个相机渲染预览图。
556
+ ```
557
+
519
558
  ## 环境变量
520
559
 
521
560
  - `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
@@ -524,6 +563,7 @@ examples/codex.example.json
524
563
  - `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
525
564
  - `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
526
565
  - `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
566
+ - `KEYSHOT_CAMERA_PRESETS`:相机预设 JSON 文件路径,默认 `presets/cameras.json`。
527
567
 
528
568
  默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
529
569
  只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
@@ -544,6 +584,8 @@ examples/codex.example.json
544
584
  - `keyshot_list_material_presets`:列出材质预设库中的预设。
545
585
  - `keyshot_apply_material_preset`:把预设库中的某个命名材质应用到物体上。
546
586
  - `keyshot_set_camera`:设置相机。
587
+ - `keyshot_list_camera_presets`:列出标准和自定义相机预设。
588
+ - `keyshot_apply_camera_preset`:用预设创建或更新命名相机。
547
589
  - `keyshot_set_environment`:设置环境。
548
590
  - `keyshot_save_scene`:保存场景。
549
591
 
@@ -569,6 +611,30 @@ examples/codex.example.json
569
611
 
570
612
  每个预设必须包含 `materialName` 或 `materialPath`。用 `keyshot_list_material_presets` 查看有哪些预设。
571
613
 
614
+ ### 相机预设库
615
+
616
+ 内置的 `presets/cameras.json` 包含 Front、Back、Left、Right、Top、Bottom 和
617
+ Isometric 七个 KeyShot 标准视角。使用 `keyshot_list_camera_presets` 查看预设,
618
+ 使用 `keyshot_apply_camera_preset` 创建或更新命名相机。
619
+
620
+ 把 `KEYSHOT_CAMERA_PRESETS` 指向用户自己的 JSON 文件即可添加自定义预设:
621
+
622
+ ```json
623
+ {
624
+ "正视图": { "standardView": "front" },
625
+ "产品主视角": {
626
+ "position": [4.5, 3.5, 4.5],
627
+ "lookAt": [0, 0, 0],
628
+ "up": [0, 1, 0],
629
+ "description": "自定义绝对坐标相机"
630
+ }
631
+ }
632
+ ```
633
+
634
+ 预设文件既可以使用上面的对象格式,也可以使用每项包含 `name` 的数组格式。
635
+ 每个预设只能选择一种形式:填写一个受支持的 `standardView`,或者同时填写
636
+ `position` 与 `lookAt`。MCP 只读取预设文件,不会自动修改它。
637
+
572
638
  ## MCP 提示词和资源
573
639
 
574
640
  - 提示词:`keyshot_product_render`
@@ -591,7 +657,7 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
591
657
 
592
658
  - [x] 自动发现并批量渲染场景中的所有相机
593
659
  - [x] 材质预设库
594
- - [ ] 相机预设模板
660
+ - [x] 相机预设模板
595
661
  - [x] 顺序渲染队列
596
662
  - [x] 更安全的输出目录限制
597
663
  - [ ] 测试更多 KeyShot 版本
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs/promises";
2
+ export const STANDARD_CAMERA_VIEWS = [
3
+ "front",
4
+ "back",
5
+ "left",
6
+ "right",
7
+ "top",
8
+ "bottom",
9
+ "isometric",
10
+ ];
11
+ export async function loadCameraPresets(config) {
12
+ let raw;
13
+ try {
14
+ raw = await fs.readFile(config.cameraPresetsPath, "utf8");
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(raw);
22
+ }
23
+ catch {
24
+ throw new Error(`Camera presets file is not valid JSON: ${config.cameraPresetsPath}`);
25
+ }
26
+ return normalizeCameraPresets(parsed);
27
+ }
28
+ export function normalizeCameraPresets(parsed) {
29
+ const entries = [];
30
+ if (Array.isArray(parsed)) {
31
+ for (const item of parsed) {
32
+ if (!item || typeof item !== "object")
33
+ continue;
34
+ const record = item;
35
+ if (typeof record.name !== "string" || !record.name)
36
+ continue;
37
+ const preset = toCameraPreset(record.name, record);
38
+ if (preset)
39
+ entries.push(preset);
40
+ }
41
+ }
42
+ else if (parsed && typeof parsed === "object") {
43
+ for (const [name, value] of Object.entries(parsed)) {
44
+ if (!value || typeof value !== "object")
45
+ continue;
46
+ const preset = toCameraPreset(name, value);
47
+ if (preset)
48
+ entries.push(preset);
49
+ }
50
+ }
51
+ return entries;
52
+ }
53
+ export function findCameraPreset(presets, name) {
54
+ const lower = name.toLowerCase();
55
+ return (presets.find((preset) => preset.name === name) ??
56
+ presets.find((preset) => preset.name.toLowerCase() === lower));
57
+ }
58
+ function toCameraPreset(name, record) {
59
+ const description = typeof record.description === "string" ? record.description : undefined;
60
+ const standardView = normalizeStandardView(record.standardView);
61
+ const position = toVector(record.position);
62
+ const lookAt = toVector(record.lookAt);
63
+ const up = toVector(record.up);
64
+ const hasStandard = standardView !== undefined;
65
+ const hasAbsolute = position !== undefined || lookAt !== undefined || up !== undefined;
66
+ if (hasStandard && !hasAbsolute) {
67
+ return { name, type: "standard", standardView, description };
68
+ }
69
+ if (!hasStandard && position && lookAt) {
70
+ return { name, type: "absolute", position, lookAt, up, description };
71
+ }
72
+ return undefined;
73
+ }
74
+ function normalizeStandardView(value) {
75
+ if (typeof value !== "string")
76
+ return undefined;
77
+ const normalized = value.toLowerCase();
78
+ return STANDARD_CAMERA_VIEWS.find((view) => view === normalized);
79
+ }
80
+ function toVector(value) {
81
+ if (!Array.isArray(value) || value.length !== 3)
82
+ return undefined;
83
+ if (!value.every((item) => typeof item === "number" && Number.isFinite(item)))
84
+ return undefined;
85
+ return [value[0], value[1], value[2]];
86
+ }
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ export function getConfig() {
15
15
  tmpDir: path.join(projectRoot, "work", "tmp"),
16
16
  bridgeScriptPath: path.join(projectRoot, "scripts", "keyshot_bridge.py"),
17
17
  materialPresetsPath: path.resolve(process.env.KEYSHOT_MATERIAL_PRESETS ?? path.join(projectRoot, "presets", "materials.json")),
18
+ cameraPresetsPath: path.resolve(process.env.KEYSHOT_CAMERA_PRESETS ?? path.join(projectRoot, "presets", "cameras.json")),
18
19
  };
19
20
  }
20
21
  function parseBoolean(value) {
package/dist/index.js CHANGED
@@ -6,8 +6,9 @@ import { toolResponse, localFailure } from "./result.js";
6
6
  import { runKeyShotSerialized } from "./runner.js";
7
7
  import { runRenderQueue } from "./queue.js";
8
8
  import { loadMaterialPresets, findMaterialPreset } from "./presets.js";
9
+ import { loadCameraPresets, findCameraPreset } from "./camera-presets.js";
9
10
  import { VERSION } from "./version.js";
10
- import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
11
+ import { applyMaterialSchema, applyMaterialInputSchema, applyMaterialPresetInputSchema, applyMaterialPresetSchema, applyCameraPresetSchema, batchRenderSchema, batchRenderInputSchema, importModelSchema, listCamerasSchema, listMaterialPresetsSchema, listCameraPresetsSchema, renderQueueSchema, renderQueueInputSchema, renderAllCamerasSchema, renderAllCamerasInputSchema, renderSchema, renderInputSchema, saveSceneSchema, scenePathSchema, setCameraSchema, setEnvironmentSchema, } from "./schemas.js";
11
12
  const config = getConfig();
12
13
  const server = new McpServer({
13
14
  name: "keyshot-mcp",
@@ -125,6 +126,58 @@ server.tool("keyshot_apply_material_preset", "Apply a named material preset (fro
125
126
  }));
126
127
  });
127
128
  server.tool("keyshot_set_camera", "Create or update a camera from position/look-at vectors and save the resulting scene.", setCameraSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_camera", ...args })));
129
+ server.tool("keyshot_list_camera_presets", "List standard and custom camera presets from presets/cameras.json or KEYSHOT_CAMERA_PRESETS.", listCameraPresetsSchema.shape, async () => {
130
+ try {
131
+ const presets = await loadCameraPresets(config);
132
+ return toolResponse({
133
+ ok: true,
134
+ data: { presets, count: presets.length, source: config.cameraPresetsPath },
135
+ outputFiles: [],
136
+ warnings: presets.length === 0 ? ["No valid camera presets found."] : [],
137
+ keyshotStdoutTail: "",
138
+ error: null,
139
+ });
140
+ }
141
+ catch (error) {
142
+ return toolResponse(localFailure(errorMessage(error)));
143
+ }
144
+ });
145
+ server.tool("keyshot_apply_camera_preset", "Create or update a named camera from a standard or custom camera preset and save the scene.", applyCameraPresetSchema.shape, async (args) => {
146
+ let presets;
147
+ try {
148
+ presets = await loadCameraPresets(config);
149
+ }
150
+ catch (error) {
151
+ return toolResponse(localFailure(errorMessage(error)));
152
+ }
153
+ const preset = findCameraPreset(presets, args.presetName);
154
+ if (!preset) {
155
+ const available = presets.map((entry) => entry.name).join(", ") || "(none)";
156
+ return toolResponse(localFailure(`Camera preset not found: "${args.presetName}". Available: ${available}`));
157
+ }
158
+ const request = preset.type === "standard"
159
+ ? {
160
+ operation: "set_standard_camera",
161
+ scenePath: args.scenePath,
162
+ standardView: preset.standardView,
163
+ cameraName: args.cameraName ?? preset.name,
164
+ outputScenePath: args.outputScenePath,
165
+ }
166
+ : {
167
+ operation: "set_camera",
168
+ scenePath: args.scenePath,
169
+ cameraName: args.cameraName ?? preset.name,
170
+ position: preset.position,
171
+ lookAt: preset.lookAt,
172
+ up: preset.up,
173
+ outputScenePath: args.outputScenePath,
174
+ };
175
+ const result = await runKeyShotSerialized(config, request);
176
+ if (result.data && typeof result.data === "object") {
177
+ result.data = { presetName: preset.name, presetType: preset.type, ...result.data };
178
+ }
179
+ return toolResponse(result);
180
+ });
128
181
  server.tool("keyshot_set_environment", "Set a scene environment by name or file when supported by KeyShot headless scripting.", setEnvironmentSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "set_environment", ...args })));
129
182
  server.tool("keyshot_save_scene", "Save a KeyShot scene to a new path.", saveSceneSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, { operation: "save_scene", ...args })));
130
183
  const transport = new StdioServerTransport();
package/dist/schemas.js CHANGED
@@ -79,6 +79,13 @@ export const setCameraSchema = z.object({
79
79
  distance: z.number().positive().optional(),
80
80
  outputScenePath: z.string().min(1),
81
81
  });
82
+ export const listCameraPresetsSchema = z.object({});
83
+ export const applyCameraPresetSchema = z.object({
84
+ scenePath: z.string().min(1),
85
+ presetName: z.string().min(1),
86
+ cameraName: z.string().min(1).optional(),
87
+ outputScenePath: z.string().min(1),
88
+ });
82
89
  export const setEnvironmentSchema = z.object({
83
90
  scenePath: z.string().min(1),
84
91
  environmentName: z.string().min(1).optional(),
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.5.0";
1
+ export const VERSION = "0.6.0";
@@ -4,7 +4,7 @@
4
4
  repository's MIT license. It contains no customer model or third-party asset.
5
5
 
6
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 two MCP
8
- cameras, automatically discovers them, and renders both views under
7
+ checks KeyShot, imports the cube, saves and inspects the scene, applies the
8
+ built-in Front and Isometric camera presets, automatically discovers them, and renders both views under
9
9
  `outputs/demo/all-cameras`. Generated `.bip` and PNG files remain under
10
10
  `outputs/` and are ignored by Git.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keyshot-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "mcpName": "io.github.truman-t3/keyshot-mcp",
5
5
  "description": "Local and portable MCP server for KeyShot headless scripting.",
6
6
  "type": "module",
@@ -0,0 +1,30 @@
1
+ {
2
+ "Front": {
3
+ "standardView": "front",
4
+ "description": "Front standard view / 正视图"
5
+ },
6
+ "Back": {
7
+ "standardView": "back",
8
+ "description": "Back standard view / 后视图"
9
+ },
10
+ "Left": {
11
+ "standardView": "left",
12
+ "description": "Left standard view / 左视图"
13
+ },
14
+ "Right": {
15
+ "standardView": "right",
16
+ "description": "Right standard view / 右视图"
17
+ },
18
+ "Top": {
19
+ "standardView": "top",
20
+ "description": "Top standard view / 顶视图"
21
+ },
22
+ "Bottom": {
23
+ "standardView": "bottom",
24
+ "description": "Bottom standard view / 底视图"
25
+ },
26
+ "Isometric": {
27
+ "standardView": "isometric",
28
+ "description": "Isometric standard view / 等距视图"
29
+ }
30
+ }
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { findCameraPreset, loadCameraPresets } from "../dist/camera-presets.js";
5
6
  import { getConfig } from "../dist/config.js";
6
7
  import { runKeyShotSerialized } from "../dist/runner.js";
7
8
 
@@ -20,6 +21,21 @@ async function run(label, request) {
20
21
  return result;
21
22
  }
22
23
 
24
+ async function applyStandardPreset(label, scenePath, presetName, outputScenePath) {
25
+ const presets = await loadCameraPresets(config);
26
+ const preset = findCameraPreset(presets, presetName);
27
+ if (!preset || preset.type !== "standard") {
28
+ throw new Error(`Standard camera preset not found: ${presetName}`);
29
+ }
30
+ return run(label, {
31
+ operation: "set_standard_camera",
32
+ scenePath,
33
+ cameraName: preset.name,
34
+ standardView: preset.standardView,
35
+ outputScenePath,
36
+ });
37
+ }
38
+
23
39
  const status = await run("1/7 KeyShot status", { operation: "status" });
24
40
  const imported = await run("2/7 Import and save generated OBJ", {
25
41
  operation: "import_model",
@@ -38,29 +54,23 @@ if (!Array.isArray(objects) || objects.length === 0) {
38
54
  throw new Error("Imported scene did not contain any inspectable objects.");
39
55
  }
40
56
 
41
- const firstCameraResult = await run("4/7 Create first camera and save scene", {
42
- operation: "set_camera",
43
- scenePath: importedScene,
44
- cameraName: "MCP Front",
45
- position: [4.5, 3.5, 4.5],
46
- lookAt: [0, 0, 0],
47
- up: [0, 1, 0],
48
- outputScenePath: "demo/keyshot-mcp-demo-camera-front.bip",
49
- });
57
+ const firstCameraResult = await applyStandardPreset(
58
+ "4/7 Apply Front camera preset",
59
+ importedScene,
60
+ "Front",
61
+ "demo/keyshot-mcp-demo-preset-front.bip",
62
+ );
50
63
  const firstCameraScene = firstCameraResult.outputFiles[0];
51
- if (!firstCameraScene) throw new Error("First camera operation did not return a saved scene path.");
64
+ if (!firstCameraScene) throw new Error("Front preset did not return a saved scene path.");
52
65
 
53
- const secondCameraResult = await run("5/7 Create second camera and save scene", {
54
- operation: "set_camera",
55
- scenePath: firstCameraScene,
56
- cameraName: "MCP Angle",
57
- position: [6.0, 2.5, 3.0],
58
- lookAt: [0, 0, 0],
59
- up: [0, 1, 0],
60
- outputScenePath: "demo/keyshot-mcp-demo-camera-all.bip",
61
- });
66
+ const secondCameraResult = await applyStandardPreset(
67
+ "5/7 Apply Isometric camera preset",
68
+ firstCameraScene,
69
+ "Isometric",
70
+ "demo/keyshot-mcp-demo-presets.bip",
71
+ );
62
72
  const cameraScene = secondCameraResult.outputFiles[0];
63
- if (!cameraScene) throw new Error("Second camera operation did not return a saved scene path.");
73
+ if (!cameraScene) throw new Error("Isometric preset did not return a saved scene path.");
64
74
 
65
75
  const rendered = await run("6/7 Discover and render every camera", {
66
76
  operation: "render_all_cameras",
@@ -80,10 +90,10 @@ if (!renderData || renderData.failed !== 0 || renderData.succeeded < 2) {
80
90
  }
81
91
 
82
92
  const namedResults = renderData.results.filter(
83
- (entry) => entry.camera === "MCP Front" || entry.camera === "MCP Angle",
93
+ (entry) => entry.camera === "Front" || entry.camera === "Isometric",
84
94
  );
85
95
  if (namedResults.length !== 2 || namedResults.some((entry) => !entry.ok)) {
86
- throw new Error("The two MCP demo cameras were not both rendered successfully.");
96
+ throw new Error("The Front and Isometric preset cameras were not both rendered successfully.");
87
97
  }
88
98
  const hashes = await Promise.all(namedResults.map(async (entry) =>
89
99
  crypto.createHash("sha256").update(await fs.readFile(entry.outputPath)).digest("hex")
@@ -103,5 +113,5 @@ console.log(JSON.stringify({
103
113
  scenePath: cameraScene,
104
114
  cameraCount: renderData.total,
105
115
  renderedImages: rendered.outputFiles,
106
- verifiedCameras: namedResults.map((entry) => entry.camera),
116
+ verifiedPresets: namedResults.map((entry) => entry.camera),
107
117
  }, null, 2));
@@ -45,6 +45,8 @@ def main():
45
45
  data = apply_material(payload, output_files, warnings)
46
46
  elif operation == "set_camera":
47
47
  data = set_camera(payload, output_files, warnings)
48
+ elif operation == "set_standard_camera":
49
+ data = set_standard_camera(payload, output_files, warnings)
48
50
  elif operation == "set_environment":
49
51
  data = set_environment(payload, output_files, warnings)
50
52
  elif operation == "save_scene":
@@ -469,6 +471,54 @@ def set_camera(payload, output_files, warnings):
469
471
  }
470
472
 
471
473
 
474
+ def set_standard_camera(payload, output_files, warnings):
475
+ output_scene_path = payload.get("outputScenePath")
476
+ camera_name = payload.get("cameraName") or "MCP Camera"
477
+ standard_view = str(payload.get("standardView") or "").lower()
478
+ view_constants = {
479
+ "front": "VIEW_FRONT",
480
+ "back": "VIEW_BACK",
481
+ "left": "VIEW_LEFT",
482
+ "right": "VIEW_RIGHT",
483
+ "top": "VIEW_TOP",
484
+ "bottom": "VIEW_BOTTOM",
485
+ "isometric": "VIEW_ISOMETRIC",
486
+ }
487
+
488
+ constant_name = view_constants.get(standard_view)
489
+ if constant_name is None:
490
+ raise RuntimeError("Unsupported standard camera view: %s" % standard_view)
491
+ if not hasattr(lux, "setStandardView"):
492
+ raise RuntimeError("KeyShot lux.setStandardView is not available.")
493
+ if not hasattr(lux, constant_name):
494
+ raise RuntimeError("KeyShot camera constant is not available: %s" % constant_name)
495
+
496
+ if camera_name in camera_names():
497
+ if not hasattr(lux, "setCamera"):
498
+ raise RuntimeError("KeyShot cannot activate the existing camera: %s" % camera_name)
499
+ call_non_false_variants("activate camera", lambda: lux.setCamera(camera_name))
500
+ else:
501
+ call_non_false_variants(
502
+ "create camera",
503
+ lambda: lux.newCamera(camera_name),
504
+ lambda: lux.createCamera(camera_name),
505
+ )
506
+
507
+ call_variants("set standard camera view", lambda: lux.setStandardView(getattr(lux, constant_name)))
508
+ call_variants(
509
+ "save standard camera",
510
+ lambda: lux.saveCamera(),
511
+ lambda: lux.saveCamera(camera_name),
512
+ )
513
+ save_to(output_scene_path)
514
+ output_files.append(output_scene_path)
515
+ return {
516
+ "cameraName": camera_name,
517
+ "standardView": standard_view,
518
+ "savedScene": output_scene_path,
519
+ }
520
+
521
+
472
522
  def set_environment(payload, output_files, warnings):
473
523
  output_scene_path = payload.get("outputScenePath")
474
524
  environment_name = payload.get("environmentName")
@@ -602,6 +652,19 @@ def call_variants(label, *callbacks):
602
652
  raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
603
653
 
604
654
 
655
+ def call_non_false_variants(label, *callbacks):
656
+ errors = []
657
+ for callback in callbacks:
658
+ try:
659
+ value = callback()
660
+ if value is not False:
661
+ return value
662
+ errors.append("returned false")
663
+ except Exception as exc:
664
+ errors.append(str(exc))
665
+ raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
666
+
667
+
605
668
  def first_success(*callbacks, default=None):
606
669
  for callback in callbacks:
607
670
  try:
package/server.json CHANGED
@@ -9,12 +9,12 @@
9
9
  "url": "https://github.com/truman-t3/keyshot-mcp",
10
10
  "source": "github"
11
11
  },
12
- "version": "0.5.0",
12
+ "version": "0.6.0",
13
13
  "packages": [
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "keyshot-mcp",
17
- "version": "0.5.0",
17
+ "version": "0.6.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -24,6 +24,12 @@
24
24
  "description": "Absolute path to KeyShot headless, or a command available on PATH.",
25
25
  "isRequired": true,
26
26
  "isSecret": false
27
+ },
28
+ {
29
+ "name": "KEYSHOT_CAMERA_PRESETS",
30
+ "description": "Optional path to a user-managed camera preset JSON file.",
31
+ "isRequired": false,
32
+ "isSecret": false
27
33
  }
28
34
  ]
29
35
  }