keyshot-mcp 0.4.2 → 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 +110 -14
- package/dist/camera-presets.js +86 -0
- package/dist/config.js +1 -0
- package/dist/index.js +58 -1
- package/dist/schemas.js +22 -0
- package/dist/version.js +1 -1
- package/examples/demo/README.md +4 -3
- package/package.json +1 -1
- package/presets/cameras.json +30 -0
- package/scripts/keyshot-smoke.mjs +69 -19
- package/scripts/keyshot_bridge.py +200 -26
- package/server.json +8 -2
package/README.md
CHANGED
|
@@ -28,9 +28,11 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
28
28
|
- Inspect a `.bip` or supported KeyShot scene file.
|
|
29
29
|
- Render a scene to an image.
|
|
30
30
|
- Batch render multiple named cameras from one scene.
|
|
31
|
+
- Automatically discover and render every camera in one scene.
|
|
31
32
|
- Import a model into a scene.
|
|
32
33
|
- Apply a material to an object.
|
|
33
34
|
- Create or update a camera.
|
|
35
|
+
- Apply reusable standard or custom camera presets.
|
|
34
36
|
- Set an environment when the installed KeyShot version exposes that function.
|
|
35
37
|
- Save a scene to a new file.
|
|
36
38
|
|
|
@@ -67,7 +69,7 @@ This project does not include KeyShot, does not bypass licensing, and does not s
|
|
|
67
69
|
npm install -g keyshot-mcp
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
The current release line is `0.
|
|
72
|
+
The current release line is `0.6.0`. The published package is the easiest option
|
|
71
73
|
when you only want to use the MCP server. You still need KeyShot Studio and a
|
|
72
74
|
local KeyShot license.
|
|
73
75
|
|
|
@@ -91,7 +93,7 @@ Use exactly one of these approaches:
|
|
|
91
93
|
**No installation, run with npx**:
|
|
92
94
|
|
|
93
95
|
```json
|
|
94
|
-
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.
|
|
96
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.6.0"],"env":{"KEYSHOT_HEADLESS_EXE":"/absolute/path/to/keyshot_headless"}}}}
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
**Run from a cloned source directory** (after `npm install && npm run build`):
|
|
@@ -105,11 +107,11 @@ Use exactly one of these approaches:
|
|
|
105
107
|
If you use Codex or another coding agent, you can copy this prompt and let the agent install the MCP server for you:
|
|
106
108
|
|
|
107
109
|
```text
|
|
108
|
-
Install KeyShot MCP 0.
|
|
110
|
+
Install KeyShot MCP 0.6.0 from npm and configure it in my MCP client.
|
|
109
111
|
|
|
110
112
|
Please:
|
|
111
113
|
1. Find my KeyShot headless executable path.
|
|
112
|
-
2. Add a keyshot MCP server using npx -y keyshot-mcp@0.
|
|
114
|
+
2. Add a keyshot MCP server using npx -y keyshot-mcp@0.6.0.
|
|
113
115
|
3. Set KEYSHOT_HEADLESS_EXE to the detected keyshot_headless path.
|
|
114
116
|
4. Test the setup by running the keyshot_status tool.
|
|
115
117
|
5. Tell me the exact config that was added and whether the status check passed.
|
|
@@ -202,6 +204,16 @@ List the cameras in this KeyShot scene, create or update a camera named "Hero",
|
|
|
202
204
|
save the scene to a new file, and render one preview from that camera.
|
|
203
205
|
```
|
|
204
206
|
|
|
207
|
+
```text
|
|
208
|
+
Find every camera in this KeyShot scene and render all views to the all-cameras
|
|
209
|
+
output folder. Continue if one camera fails and report the result for each view.
|
|
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
|
+
|
|
205
217
|
## Environment Variables
|
|
206
218
|
|
|
207
219
|
- `KEYSHOT_HEADLESS_EXE`: path to `keyshot_headless` or `keyshot_headless.exe`.
|
|
@@ -209,6 +221,7 @@ save the scene to a new file, and render one preview from that camera.
|
|
|
209
221
|
- `KEYSHOT_LICENSE_ARGS`: optional KeyShot headless license arguments. Empty by default.
|
|
210
222
|
- `KEYSHOT_TIMEOUT_MS`: operation timeout in milliseconds. Default: `600000`.
|
|
211
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`.
|
|
212
225
|
|
|
213
226
|
By default every output file must stay inside `KEYSHOT_OUTPUT_DIR`. Relative paths
|
|
214
227
|
are placed there automatically. Set the compatibility switch to `true` only when
|
|
@@ -224,15 +237,27 @@ or the other, not both.
|
|
|
224
237
|
- `keyshot_list_cameras`: list available camera names in a scene (handy before batch rendering)
|
|
225
238
|
- `keyshot_render`: render a single image
|
|
226
239
|
- `keyshot_batch_render`
|
|
240
|
+
- `keyshot_render_all_cameras`: discover and render every camera in a scene; continues after individual failures by default
|
|
227
241
|
- `keyshot_render_queue`: run several render jobs sequentially (stops at first failure unless `continueOnError`)
|
|
228
242
|
- `keyshot_import_model`
|
|
229
243
|
- `keyshot_apply_material`
|
|
230
244
|
- `keyshot_list_material_presets`: list presets from the material preset library
|
|
231
245
|
- `keyshot_apply_material_preset`: apply a named preset from the library to an object
|
|
232
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
|
|
233
249
|
- `keyshot_set_environment`
|
|
234
250
|
- `keyshot_save_scene`
|
|
235
251
|
|
|
252
|
+
### Render every camera automatically
|
|
253
|
+
|
|
254
|
+
`keyshot_render_all_cameras` discovers camera names from the open scene and renders
|
|
255
|
+
them in one KeyShot headless session. `continueOnError` defaults to `true`, so a
|
|
256
|
+
failed camera is reported without blocking the remaining views. Safe duplicate
|
|
257
|
+
filenames receive `-2`, `-3`, and later suffixes. Existing files are preserved
|
|
258
|
+
unless `overwrite` is set to `true`. KeyShot's internal, non-activatable
|
|
259
|
+
`last_active` placeholder is reported and skipped.
|
|
260
|
+
|
|
236
261
|
### Material preset library
|
|
237
262
|
|
|
238
263
|
`keyshot_apply_material_preset` reads a small JSON registry so you can reuse named
|
|
@@ -249,6 +274,30 @@ looks instead of remembering exact KeyShot material names. Default location:
|
|
|
249
274
|
Each preset must have a `materialName` or a `materialPath`. Use
|
|
250
275
|
`keyshot_list_material_presets` to see what is available.
|
|
251
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
|
+
|
|
252
301
|
## MCP Prompts and Resources
|
|
253
302
|
|
|
254
303
|
- Prompt: `keyshot_product_render`
|
|
@@ -269,9 +318,9 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
|
|
|
269
318
|
|
|
270
319
|
## Roadmap
|
|
271
320
|
|
|
272
|
-
- [
|
|
321
|
+
- [x] Auto-discover and batch render all cameras in a scene
|
|
273
322
|
- [x] Material preset library
|
|
274
|
-
- [
|
|
323
|
+
- [x] Camera preset templates
|
|
275
324
|
- [x] Sequential render queue
|
|
276
325
|
- [x] Safer output directory restrictions
|
|
277
326
|
- [ ] More tested KeyShot versions
|
|
@@ -283,7 +332,7 @@ KeyShot's Python `lux` API changes across versions. This server keeps the MCP in
|
|
|
283
332
|

|
|
284
333
|
|
|
285
334
|
The demo uses only the generated geometry in `examples/demo`. To reproduce the
|
|
286
|
-
status, import, inspect, camera, save, and PNG-render workflow locally:
|
|
335
|
+
status, import, inspect, two-camera creation, save, discovery, and PNG-render workflow locally:
|
|
287
336
|
|
|
288
337
|
```bash
|
|
289
338
|
npm run smoke:keyshot
|
|
@@ -321,9 +370,11 @@ MIT
|
|
|
321
370
|
- 检查 `.bip` 或 KeyShot 支持的场景文件。
|
|
322
371
|
- 把场景渲染成图片。
|
|
323
372
|
- 从同一个场景批量渲染多个指定相机。
|
|
373
|
+
- 自动发现并渲染场景中的全部相机。
|
|
324
374
|
- 把模型导入场景。
|
|
325
375
|
- 给对象替换材质。
|
|
326
376
|
- 创建或更新相机。
|
|
377
|
+
- 应用可复用的标准或自定义相机预设。
|
|
327
378
|
- 在当前 KeyShot 版本支持时设置环境。
|
|
328
379
|
- 把场景保存为新文件。
|
|
329
380
|
|
|
@@ -360,7 +411,7 @@ MIT
|
|
|
360
411
|
npm install -g keyshot-mcp
|
|
361
412
|
```
|
|
362
413
|
|
|
363
|
-
当前发布版本为 `0.
|
|
414
|
+
当前发布版本为 `0.6.0`。如果你只是想使用 MCP 服务,直接安装 npm 包最简单。
|
|
364
415
|
电脑仍需要安装 KeyShot Studio,并且已经配置好本地 KeyShot 授权。
|
|
365
416
|
|
|
366
417
|
### 从源码安装
|
|
@@ -383,7 +434,7 @@ npm run build
|
|
|
383
434
|
**免安装,直接使用 npx**:
|
|
384
435
|
|
|
385
436
|
```json
|
|
386
|
-
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.
|
|
437
|
+
{"mcpServers":{"keyshot":{"command":"npx","args":["-y","keyshot-mcp@0.6.0"],"env":{"KEYSHOT_HEADLESS_EXE":"C:/KeyShot/bin/keyshot_headless.exe"}}}}
|
|
387
438
|
```
|
|
388
439
|
|
|
389
440
|
**使用本地源码**(先运行 `npm install && npm run build`):
|
|
@@ -397,11 +448,11 @@ npm run build
|
|
|
397
448
|
如果你使用 Codex 或其他编程 Agent,可以复制下面这段话,让 Agent 帮你安装和配置 MCP:
|
|
398
449
|
|
|
399
450
|
```text
|
|
400
|
-
请帮我安装 KeyShot MCP 0.
|
|
451
|
+
请帮我安装 KeyShot MCP 0.6.0,并添加到我的 MCP 客户端。
|
|
401
452
|
|
|
402
453
|
请你:
|
|
403
454
|
1. 查找我电脑上的 KeyShot headless 可执行文件路径。
|
|
404
|
-
2. 使用 npx -y keyshot-mcp@0.
|
|
455
|
+
2. 使用 npx -y keyshot-mcp@0.6.0 添加 keyshot MCP server。
|
|
405
456
|
3. 把 KEYSHOT_HEADLESS_EXE 设置为检测到的 keyshot_headless 路径。
|
|
406
457
|
4. 用 keyshot_status 工具测试是否配置成功。
|
|
407
458
|
5. 最后告诉我实际添加的配置,以及状态检查是否通过。
|
|
@@ -494,6 +545,16 @@ examples/codex.example.json
|
|
|
494
545
|
把场景保存为新文件,并用这个相机渲染一张预览图。
|
|
495
546
|
```
|
|
496
547
|
|
|
548
|
+
```text
|
|
549
|
+
自动找出这个 KeyShot 场景里的全部相机,把所有视角渲染到 all-cameras 文件夹。
|
|
550
|
+
某个相机失败时继续处理其他相机,最后告诉我每个视角的结果。
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
```text
|
|
554
|
+
列出可用的相机预设,把 Isometric 等距预设应用为名为“目录等距视图”的相机,
|
|
555
|
+
保存一份新场景,并用这个相机渲染预览图。
|
|
556
|
+
```
|
|
557
|
+
|
|
497
558
|
## 环境变量
|
|
498
559
|
|
|
499
560
|
- `KEYSHOT_HEADLESS_EXE`:`keyshot_headless` 或 `keyshot_headless.exe` 的路径。
|
|
@@ -502,6 +563,7 @@ examples/codex.example.json
|
|
|
502
563
|
- `KEYSHOT_TIMEOUT_MS`:单次操作超时时间,单位毫秒,默认 `600000`。
|
|
503
564
|
- `KEYSHOT_MATERIAL_PRESETS`:材质预设库 JSON 文件路径,默认 `presets/materials.json`。
|
|
504
565
|
- `KEYSHOT_ALLOW_EXTERNAL_OUTPUTS`:是否允许写到默认输出目录之外,默认 `false`。
|
|
566
|
+
- `KEYSHOT_CAMERA_PRESETS`:相机预设 JSON 文件路径,默认 `presets/cameras.json`。
|
|
505
567
|
|
|
506
568
|
默认情况下,所有输出图片和场景都必须写入 `KEYSHOT_OUTPUT_DIR`,相对路径会自动放入该目录。
|
|
507
569
|
只有确实需要写到外部目录时才设置为 `true`;输入场景和模型路径不受此限制。
|
|
@@ -515,15 +577,25 @@ examples/codex.example.json
|
|
|
515
577
|
- `keyshot_list_cameras`:列出场景中所有相机名称(批量渲染前很有用)。
|
|
516
578
|
- `keyshot_render`:渲染单张图片。
|
|
517
579
|
- `keyshot_batch_render`:批量渲染多个相机视角。
|
|
580
|
+
- `keyshot_render_all_cameras`:自动发现并渲染场景中的全部相机,默认单个视角失败后继续。
|
|
518
581
|
- `keyshot_render_queue`:顺序执行多个渲染任务(默认遇错即停,设置 `continueOnError` 可继续)。
|
|
519
582
|
- `keyshot_import_model`:导入模型。
|
|
520
583
|
- `keyshot_apply_material`:替换材质。
|
|
521
584
|
- `keyshot_list_material_presets`:列出材质预设库中的预设。
|
|
522
585
|
- `keyshot_apply_material_preset`:把预设库中的某个命名材质应用到物体上。
|
|
523
586
|
- `keyshot_set_camera`:设置相机。
|
|
587
|
+
- `keyshot_list_camera_presets`:列出标准和自定义相机预设。
|
|
588
|
+
- `keyshot_apply_camera_preset`:用预设创建或更新命名相机。
|
|
524
589
|
- `keyshot_set_environment`:设置环境。
|
|
525
590
|
- `keyshot_save_scene`:保存场景。
|
|
526
591
|
|
|
592
|
+
### 自动渲染全部相机
|
|
593
|
+
|
|
594
|
+
`keyshot_render_all_cameras` 会在同一次 KeyShot 无界面运行中自动读取场景相机并逐个渲染。
|
|
595
|
+
`continueOnError` 默认是 `true`,因此单个相机失败不会阻止其他视角;同名安全文件会自动添加
|
|
596
|
+
`-2`、`-3` 等编号。除非设置 `overwrite: true`,否则不会覆盖已有图片。KeyShot 内部不能激活的
|
|
597
|
+
`last_active` 占位项会被列出并跳过。
|
|
598
|
+
|
|
527
599
|
### 材质预设库
|
|
528
600
|
|
|
529
601
|
`keyshot_apply_material_preset` 会读取一个小的 JSON 注册表,让你用"好记的名字"复用材质,
|
|
@@ -539,6 +611,30 @@ examples/codex.example.json
|
|
|
539
611
|
|
|
540
612
|
每个预设必须包含 `materialName` 或 `materialPath`。用 `keyshot_list_material_presets` 查看有哪些预设。
|
|
541
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
|
+
|
|
542
638
|
## MCP 提示词和资源
|
|
543
639
|
|
|
544
640
|
- 提示词:`keyshot_product_render`
|
|
@@ -559,9 +655,9 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
|
|
|
559
655
|
|
|
560
656
|
## 路线图
|
|
561
657
|
|
|
562
|
-
- [
|
|
658
|
+
- [x] 自动发现并批量渲染场景中的所有相机
|
|
563
659
|
- [x] 材质预设库
|
|
564
|
-
- [
|
|
660
|
+
- [x] 相机预设模板
|
|
565
661
|
- [x] 顺序渲染队列
|
|
566
662
|
- [x] 更安全的输出目录限制
|
|
567
663
|
- [ ] 测试更多 KeyShot 版本
|
|
@@ -573,7 +669,7 @@ KeyShot 的 Python `lux` API 会随版本变化。这个 MCP 会尽量保持对
|
|
|
573
669
|

|
|
574
670
|
|
|
575
671
|
Demo 只使用 `examples/demo` 中项目自己生成的几何体,不包含客户模型或第三方素材。
|
|
576
|
-
设置好 `KEYSHOT_HEADLESS_EXE`
|
|
672
|
+
设置好 `KEYSHOT_HEADLESS_EXE` 后可复现状态检查、导入、检查场景、创建两个相机、自动发现、保存和真实 PNG 渲染:
|
|
577
673
|
|
|
578
674
|
```bash
|
|
579
675
|
npm run smoke: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, 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",
|
|
@@ -72,6 +73,10 @@ server.tool("keyshot_batch_render", "Render multiple named cameras from one KeyS
|
|
|
72
73
|
const parsed = batchRenderInputSchema.parse(args);
|
|
73
74
|
return toolResponse(await runKeyShotSerialized(config, { operation: "batch_render", ...parsed }));
|
|
74
75
|
});
|
|
76
|
+
server.tool("keyshot_render_all_cameras", "Discover every camera in one KeyShot scene and render each view into an output directory.", renderAllCamerasSchema.shape, async (args) => {
|
|
77
|
+
const parsed = renderAllCamerasInputSchema.parse(args);
|
|
78
|
+
return toolResponse(await runKeyShotSerialized(config, { operation: "render_all_cameras", ...parsed }));
|
|
79
|
+
});
|
|
75
80
|
server.tool("keyshot_import_model", "Import a model into an optional base scene and save the resulting scene.", importModelSchema.shape, async (args) => toolResponse(await runKeyShotSerialized(config, {
|
|
76
81
|
operation: "import_model",
|
|
77
82
|
...args,
|
|
@@ -121,6 +126,58 @@ server.tool("keyshot_apply_material_preset", "Apply a named material preset (fro
|
|
|
121
126
|
}));
|
|
122
127
|
});
|
|
123
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
|
+
});
|
|
124
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 })));
|
|
125
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 })));
|
|
126
183
|
const transport = new StdioServerTransport();
|
package/dist/schemas.js
CHANGED
|
@@ -36,6 +36,21 @@ export const batchRenderInputSchema = batchRenderSchema.refine((value) => !(valu
|
|
|
36
36
|
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
37
37
|
path: ["maxTimeSeconds"],
|
|
38
38
|
});
|
|
39
|
+
export const renderAllCamerasSchema = z.object({
|
|
40
|
+
scenePath: z.string().min(1),
|
|
41
|
+
outputDir: z.string().min(1),
|
|
42
|
+
width: z.number().int().positive().optional(),
|
|
43
|
+
height: z.number().int().positive().optional(),
|
|
44
|
+
samples: z.number().int().positive().optional(),
|
|
45
|
+
maxTimeSeconds: z.number().positive().optional(),
|
|
46
|
+
format: imageFormat.optional(),
|
|
47
|
+
overwrite: z.boolean().optional(),
|
|
48
|
+
continueOnError: z.boolean().default(true),
|
|
49
|
+
});
|
|
50
|
+
export const renderAllCamerasInputSchema = renderAllCamerasSchema.refine((value) => !(value.samples !== undefined && value.maxTimeSeconds !== undefined), {
|
|
51
|
+
message: "Choose either samples or maxTimeSeconds, not both.",
|
|
52
|
+
path: ["maxTimeSeconds"],
|
|
53
|
+
});
|
|
39
54
|
export const importModelSchema = z.object({
|
|
40
55
|
modelPath: z.string().min(1),
|
|
41
56
|
baseScenePath: optionalPath,
|
|
@@ -64,6 +79,13 @@ export const setCameraSchema = z.object({
|
|
|
64
79
|
distance: z.number().positive().optional(),
|
|
65
80
|
outputScenePath: z.string().min(1),
|
|
66
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
|
+
});
|
|
67
89
|
export const setEnvironmentSchema = z.object({
|
|
68
90
|
scenePath: z.string().min(1),
|
|
69
91
|
environmentName: z.string().min(1).optional(),
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.6.0";
|
package/examples/demo/README.md
CHANGED
|
@@ -4,6 +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,
|
|
8
|
-
camera, and renders
|
|
9
|
-
|
|
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
|
+
`outputs/demo/all-cameras`. Generated `.bip` and PNG files remain under
|
|
10
|
+
`outputs/` and are ignored by Git.
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { findCameraPreset, loadCameraPresets } from "../dist/camera-presets.js";
|
|
3
6
|
import { getConfig } from "../dist/config.js";
|
|
4
7
|
import { runKeyShotSerialized } from "../dist/runner.js";
|
|
5
8
|
|
|
@@ -18,8 +21,23 @@ async function run(label, request) {
|
|
|
18
21
|
return result;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
+
|
|
39
|
+
const status = await run("1/7 KeyShot status", { operation: "status" });
|
|
40
|
+
const imported = await run("2/7 Import and save generated OBJ", {
|
|
23
41
|
operation: "import_model",
|
|
24
42
|
modelPath,
|
|
25
43
|
outputScenePath: "demo/keyshot-mcp-demo-import.bip",
|
|
@@ -27,7 +45,7 @@ const imported = await run("2/5 Import and save generated OBJ", {
|
|
|
27
45
|
const importedScene = imported.outputFiles[0];
|
|
28
46
|
if (!importedScene) throw new Error("Import did not return a saved scene path.");
|
|
29
47
|
|
|
30
|
-
const inspected = await run("3/
|
|
48
|
+
const inspected = await run("3/7 Inspect imported scene", {
|
|
31
49
|
operation: "inspect_scene",
|
|
32
50
|
scenePath: importedScene,
|
|
33
51
|
});
|
|
@@ -36,32 +54,64 @@ if (!Array.isArray(objects) || objects.length === 0) {
|
|
|
36
54
|
throw new Error("Imported scene did not contain any inspectable objects.");
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
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
|
+
);
|
|
63
|
+
const firstCameraScene = firstCameraResult.outputFiles[0];
|
|
64
|
+
if (!firstCameraScene) throw new Error("Front preset did not return a saved scene path.");
|
|
65
|
+
|
|
66
|
+
const secondCameraResult = await applyStandardPreset(
|
|
67
|
+
"5/7 Apply Isometric camera preset",
|
|
68
|
+
firstCameraScene,
|
|
69
|
+
"Isometric",
|
|
70
|
+
"demo/keyshot-mcp-demo-presets.bip",
|
|
71
|
+
);
|
|
72
|
+
const cameraScene = secondCameraResult.outputFiles[0];
|
|
73
|
+
if (!cameraScene) throw new Error("Isometric preset did not return a saved scene path.");
|
|
50
74
|
|
|
51
|
-
const rendered = await run("
|
|
52
|
-
operation: "
|
|
75
|
+
const rendered = await run("6/7 Discover and render every camera", {
|
|
76
|
+
operation: "render_all_cameras",
|
|
53
77
|
scenePath: cameraScene,
|
|
54
|
-
|
|
55
|
-
outputPath: "demo/keyshot-mcp-demo.png",
|
|
78
|
+
outputDir: "demo/all-cameras",
|
|
56
79
|
width: 640,
|
|
57
80
|
height: 480,
|
|
58
81
|
maxTimeSeconds: 8,
|
|
59
82
|
format: "png",
|
|
83
|
+
overwrite: true,
|
|
84
|
+
continueOnError: true,
|
|
60
85
|
});
|
|
61
86
|
|
|
87
|
+
const renderData = rendered.data;
|
|
88
|
+
if (!renderData || renderData.failed !== 0 || renderData.succeeded < 2) {
|
|
89
|
+
throw new Error("All-camera render did not produce at least two successful camera views.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const namedResults = renderData.results.filter(
|
|
93
|
+
(entry) => entry.camera === "Front" || entry.camera === "Isometric",
|
|
94
|
+
);
|
|
95
|
+
if (namedResults.length !== 2 || namedResults.some((entry) => !entry.ok)) {
|
|
96
|
+
throw new Error("The Front and Isometric preset cameras were not both rendered successfully.");
|
|
97
|
+
}
|
|
98
|
+
const hashes = await Promise.all(namedResults.map(async (entry) =>
|
|
99
|
+
crypto.createHash("sha256").update(await fs.readFile(entry.outputPath)).digest("hex")
|
|
100
|
+
));
|
|
101
|
+
if (hashes[0] === hashes[1]) {
|
|
102
|
+
throw new Error("The two demo camera renders are identical; expected different viewpoints.");
|
|
103
|
+
}
|
|
104
|
+
const minimumSizes = await Promise.all(namedResults.map(async (entry) => (await fs.stat(entry.outputPath)).size));
|
|
105
|
+
if (minimumSizes.some((size) => size < 10000)) {
|
|
106
|
+
throw new Error("A demo camera render is unexpectedly small and may be blank.");
|
|
107
|
+
}
|
|
108
|
+
process.stdout.write("7/7 Verify two different camera images... ok\n");
|
|
109
|
+
|
|
62
110
|
console.log(JSON.stringify({
|
|
63
111
|
keyshotVersion: status.data?.version ?? null,
|
|
64
112
|
objectCount: objects.length,
|
|
65
113
|
scenePath: cameraScene,
|
|
66
|
-
|
|
114
|
+
cameraCount: renderData.total,
|
|
115
|
+
renderedImages: rendered.outputFiles,
|
|
116
|
+
verifiedPresets: namedResults.map((entry) => entry.camera),
|
|
67
117
|
}, null, 2));
|
|
@@ -37,12 +37,16 @@ def main():
|
|
|
37
37
|
data = render(payload, output_files, warnings)
|
|
38
38
|
elif operation == "batch_render":
|
|
39
39
|
data = batch_render(payload, output_files, warnings)
|
|
40
|
+
elif operation == "render_all_cameras":
|
|
41
|
+
data = render_all_cameras(payload, output_files, warnings)
|
|
40
42
|
elif operation == "import_model":
|
|
41
43
|
data = import_model(payload, output_files, warnings)
|
|
42
44
|
elif operation == "apply_material":
|
|
43
45
|
data = apply_material(payload, output_files, warnings)
|
|
44
46
|
elif operation == "set_camera":
|
|
45
47
|
data = set_camera(payload, output_files, warnings)
|
|
48
|
+
elif operation == "set_standard_camera":
|
|
49
|
+
data = set_standard_camera(payload, output_files, warnings)
|
|
46
50
|
elif operation == "set_environment":
|
|
47
51
|
data = set_environment(payload, output_files, warnings)
|
|
48
52
|
elif operation == "save_scene":
|
|
@@ -50,7 +54,10 @@ def main():
|
|
|
50
54
|
else:
|
|
51
55
|
raise RuntimeError("Unsupported operation: %s" % operation)
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
operation_error = None
|
|
58
|
+
if operation == "render_all_cameras" and data.get("failed", 0) > 0:
|
|
59
|
+
operation_error = "%s of %s camera render(s) failed." % (data["failed"], data["total"])
|
|
60
|
+
write_result(result_path, operation_error is None, data, output_files, warnings, operation_error)
|
|
54
61
|
except Exception as exc:
|
|
55
62
|
warnings.append(traceback.format_exc())
|
|
56
63
|
write_result(result_path, False, None, output_files, warnings, str(exc))
|
|
@@ -99,6 +106,11 @@ def inspect_scene():
|
|
|
99
106
|
|
|
100
107
|
|
|
101
108
|
def list_cameras():
|
|
109
|
+
names = camera_names()
|
|
110
|
+
return {"cameras": names, "count": len(names)}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def camera_names():
|
|
102
114
|
raw = safe_list_call("getCameras")
|
|
103
115
|
names = []
|
|
104
116
|
for camera in raw:
|
|
@@ -113,8 +125,8 @@ def list_cameras():
|
|
|
113
125
|
)
|
|
114
126
|
if name is None:
|
|
115
127
|
name = repr(camera)
|
|
116
|
-
names.append(name)
|
|
117
|
-
return
|
|
128
|
+
names.append(str(name))
|
|
129
|
+
return names
|
|
118
130
|
|
|
119
131
|
|
|
120
132
|
def render(payload, output_files, warnings):
|
|
@@ -214,6 +226,100 @@ def batch_render(payload, output_files, warnings):
|
|
|
214
226
|
}
|
|
215
227
|
|
|
216
228
|
|
|
229
|
+
def render_all_cameras(payload, output_files, warnings):
|
|
230
|
+
scene_path = payload.get("scenePath")
|
|
231
|
+
output_dir = payload.get("outputDir")
|
|
232
|
+
discovered_cameras = camera_names()
|
|
233
|
+
excluded_cameras = [name for name in discovered_cameras if name.lower() == "last_active"]
|
|
234
|
+
cameras = [name for name in discovered_cameras if name.lower() != "last_active"]
|
|
235
|
+
width = payload.get("width") or 1920
|
|
236
|
+
height = payload.get("height") or 1080
|
|
237
|
+
image_format = payload.get("format") or "png"
|
|
238
|
+
overwrite = bool(payload.get("overwrite", False))
|
|
239
|
+
continue_on_error = payload.get("continueOnError", True) is not False
|
|
240
|
+
|
|
241
|
+
if not scene_path:
|
|
242
|
+
raise RuntimeError("scenePath is required")
|
|
243
|
+
if not output_dir:
|
|
244
|
+
raise RuntimeError("outputDir is required")
|
|
245
|
+
if not cameras:
|
|
246
|
+
raise RuntimeError("No cameras were found in the scene.")
|
|
247
|
+
|
|
248
|
+
if excluded_cameras:
|
|
249
|
+
warnings.append("Skipped KeyShot internal camera placeholder(s): %s" % ", ".join(excluded_cameras))
|
|
250
|
+
|
|
251
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
252
|
+
results = []
|
|
253
|
+
used_stems = set()
|
|
254
|
+
stopped = False
|
|
255
|
+
|
|
256
|
+
for index, camera in enumerate(cameras):
|
|
257
|
+
output_path = unique_camera_output_path(output_dir, camera, image_format, used_stems)
|
|
258
|
+
if stopped:
|
|
259
|
+
results.append(camera_render_result(index, camera, output_path, False, None, True))
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
if os.path.exists(output_path) and not overwrite:
|
|
264
|
+
raise RuntimeError("Output already exists and overwrite is false: %s" % output_path)
|
|
265
|
+
|
|
266
|
+
render_payload = dict(payload)
|
|
267
|
+
render_payload["camera"] = camera
|
|
268
|
+
render_payload["outputPath"] = output_path
|
|
269
|
+
render_payload["width"] = width
|
|
270
|
+
render_payload["height"] = height
|
|
271
|
+
render_payload["format"] = image_format
|
|
272
|
+
rendered = render(render_payload, output_files, warnings)
|
|
273
|
+
results.append(camera_render_result(index, camera, rendered.get("rendered"), True, None, False))
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
error = str(exc)
|
|
276
|
+
warnings.append("Camera %s (%s) failed: %s" % (index, camera, error))
|
|
277
|
+
results.append(camera_render_result(index, camera, output_path, False, error, False))
|
|
278
|
+
if not continue_on_error:
|
|
279
|
+
stopped = True
|
|
280
|
+
|
|
281
|
+
succeeded = len([item for item in results if item["ok"]])
|
|
282
|
+
failed = len([item for item in results if not item["ok"] and not item["skipped"]])
|
|
283
|
+
skipped = len([item for item in results if item["skipped"]])
|
|
284
|
+
return {
|
|
285
|
+
"scenePath": scene_path,
|
|
286
|
+
"outputDir": output_dir,
|
|
287
|
+
"cameras": cameras,
|
|
288
|
+
"excludedCameras": excluded_cameras,
|
|
289
|
+
"total": len(cameras),
|
|
290
|
+
"succeeded": succeeded,
|
|
291
|
+
"failed": failed,
|
|
292
|
+
"skipped": skipped,
|
|
293
|
+
"continueOnError": continue_on_error,
|
|
294
|
+
"results": results,
|
|
295
|
+
"width": width,
|
|
296
|
+
"height": height,
|
|
297
|
+
"format": image_format,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def camera_render_result(index, camera, output_path, ok, error, skipped):
|
|
302
|
+
return {
|
|
303
|
+
"index": index,
|
|
304
|
+
"camera": camera,
|
|
305
|
+
"outputPath": output_path,
|
|
306
|
+
"ok": ok,
|
|
307
|
+
"error": error,
|
|
308
|
+
"skipped": skipped,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def unique_camera_output_path(output_dir, camera, image_format, used_stems):
|
|
313
|
+
base = safe_filename(camera)
|
|
314
|
+
stem = base
|
|
315
|
+
suffix = 2
|
|
316
|
+
while stem.lower() in used_stems:
|
|
317
|
+
stem = "%s-%s" % (base, suffix)
|
|
318
|
+
suffix += 1
|
|
319
|
+
used_stems.add(stem.lower())
|
|
320
|
+
return os.path.join(output_dir, "%s.%s" % (stem, image_format))
|
|
321
|
+
|
|
322
|
+
|
|
217
323
|
def import_model(payload, output_files, warnings):
|
|
218
324
|
model_path = payload.get("modelPath")
|
|
219
325
|
output_scene_path = payload.get("outputScenePath")
|
|
@@ -298,6 +404,7 @@ def set_camera(payload, output_files, warnings):
|
|
|
298
404
|
|
|
299
405
|
if position is None or look_at is None:
|
|
300
406
|
raise RuntimeError("position and lookAt are required")
|
|
407
|
+
direction = tuple(look_at[index] - position[index] for index in range(3))
|
|
301
408
|
|
|
302
409
|
camera = first_camera_object(
|
|
303
410
|
lambda: lux.getCamera(camera_name),
|
|
@@ -305,47 +412,53 @@ def set_camera(payload, output_files, warnings):
|
|
|
305
412
|
lambda: lux.createCamera(camera_name),
|
|
306
413
|
)
|
|
307
414
|
|
|
308
|
-
if camera is None and hasattr(lux, "saveCamera"):
|
|
309
|
-
try:
|
|
310
|
-
# Current KeyShot versions expose cameras as names rather than camera
|
|
311
|
-
# objects. saveCamera creates the named camera and setCamera activates it.
|
|
312
|
-
call_variants(
|
|
313
|
-
"create named camera",
|
|
314
|
-
lambda: lux.saveCamera(camera_name),
|
|
315
|
-
lambda: lux.saveCamera(),
|
|
316
|
-
)
|
|
317
|
-
if hasattr(lux, "setCamera"):
|
|
318
|
-
call_variants("activate camera", lambda: lux.setCamera(camera_name))
|
|
319
|
-
except RuntimeError:
|
|
320
|
-
pass
|
|
321
|
-
|
|
322
415
|
if camera is not None:
|
|
323
416
|
# Object-level API is available: drive the camera object directly.
|
|
324
|
-
call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
|
|
325
417
|
call_variants("set camera look-at", lambda: camera.setLookAt(tuple(look_at)))
|
|
418
|
+
call_variants("set camera position", lambda: camera.setPosition(tuple(position)))
|
|
419
|
+
if hasattr(camera, "setDirection"):
|
|
420
|
+
call_variants("set camera direction", lambda: camera.setDirection(direction))
|
|
326
421
|
call_variants("set camera up", lambda: camera.setUp(tuple(up)))
|
|
327
422
|
else:
|
|
328
423
|
# No camera object could be obtained: fall back to the lux-level setters,
|
|
329
|
-
# which
|
|
330
|
-
#
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
lambda: lux.
|
|
334
|
-
lambda: lux.setCameraPosition(tuple(position)),
|
|
335
|
-
lambda: lux.setCameraPosition(camera_name, tuple(position)),
|
|
336
|
-
)
|
|
424
|
+
# which operate on the active camera. Existing named cameras are activated
|
|
425
|
+
# first. Missing cameras are saved under their new name after the transform
|
|
426
|
+
# is set; saveCamera is a snapshot operation, not a camera constructor.
|
|
427
|
+
if camera_name in camera_names() and hasattr(lux, "setCamera"):
|
|
428
|
+
call_variants("activate camera", lambda: lux.setCamera(camera_name))
|
|
337
429
|
call_variants(
|
|
338
430
|
"set camera look-at",
|
|
339
431
|
lambda: lux.setCameraLookAt(pt=tuple(look_at)),
|
|
340
432
|
lambda: lux.setCameraLookAt(0, tuple(look_at)),
|
|
341
433
|
lambda: lux.setCameraLookAt(camera_name, tuple(look_at)),
|
|
342
434
|
)
|
|
435
|
+
call_variants(
|
|
436
|
+
"set camera position",
|
|
437
|
+
lambda: lux.setCameraPosition(pos=tuple(position)),
|
|
438
|
+
lambda: lux.setCameraPosition(tuple(position)),
|
|
439
|
+
lambda: lux.setCameraPosition(camera_name, tuple(position)),
|
|
440
|
+
)
|
|
441
|
+
if hasattr(lux, "setCameraDirection"):
|
|
442
|
+
call_variants(
|
|
443
|
+
"set camera direction",
|
|
444
|
+
lambda: lux.setCameraDirection(dir=direction),
|
|
445
|
+
lambda: lux.setCameraDirection(direction),
|
|
446
|
+
)
|
|
343
447
|
call_variants(
|
|
344
448
|
"set camera up",
|
|
345
449
|
lambda: lux.setCameraUp(up=tuple(up)),
|
|
346
450
|
lambda: lux.setCameraUp(tuple(up)),
|
|
347
451
|
lambda: lux.setCameraUp(camera_name, tuple(up)),
|
|
348
452
|
)
|
|
453
|
+
if hasattr(lux, "saveCamera"):
|
|
454
|
+
try:
|
|
455
|
+
call_variants(
|
|
456
|
+
"save named camera",
|
|
457
|
+
lambda: lux.saveCamera(),
|
|
458
|
+
lambda: lux.saveCamera(camera_name),
|
|
459
|
+
)
|
|
460
|
+
except RuntimeError:
|
|
461
|
+
warnings.append("The active camera was updated, but this KeyShot version could not save the named view.")
|
|
349
462
|
|
|
350
463
|
save_to(output_scene_path)
|
|
351
464
|
output_files.append(output_scene_path)
|
|
@@ -358,6 +471,54 @@ def set_camera(payload, output_files, warnings):
|
|
|
358
471
|
}
|
|
359
472
|
|
|
360
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
|
+
|
|
361
522
|
def set_environment(payload, output_files, warnings):
|
|
362
523
|
output_scene_path = payload.get("outputScenePath")
|
|
363
524
|
environment_name = payload.get("environmentName")
|
|
@@ -491,6 +652,19 @@ def call_variants(label, *callbacks):
|
|
|
491
652
|
raise RuntimeError("%s is unsupported or failed: %s" % (label, " | ".join(errors)))
|
|
492
653
|
|
|
493
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
|
+
|
|
494
668
|
def first_success(*callbacks, default=None):
|
|
495
669
|
for callback in callbacks:
|
|
496
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.
|
|
12
|
+
"version": "0.6.0",
|
|
13
13
|
"packages": [
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"identifier": "keyshot-mcp",
|
|
17
|
-
"version": "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
|
}
|