diff --git a/.gitignore b/.gitignore
index 520913ed..e31ec5f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,7 +98,7 @@ uv.lock
# Live-demo artefacts produced by scripts/demo_sketches.py
out/
-# Live demo recordings: the raw frames and the README demo's stills are
-# working files; the GIFs and the call transcript are what gets published.
+# Live demo recordings: the raw frames and the captioned stills are working
+# files; only the GIFs (and a demo's call transcript) are published.
docs/assets/demos/*/recording/
-docs/assets/demos/readme/*.png
+docs/assets/demos/**/*.png
diff --git a/README.md b/README.md
index 4bccbc7a..1735d137 100644
--- a/README.md
+++ b/README.md
@@ -62,28 +62,31 @@ Each GIF is a live test driving SolidWorks 2026 through the MCP tools, with the
+  A 3D-printable monitor stand from a prompt, then printed |

verify_model passes a block and names what is wrong |
 An assembly: parts, mates and interference |
- A cut reports the volume it removed |
+ A cut reports the volume it removed |
 A revolve reports the volume it added |
 A round part's size is exact |
- Reading the model keeps the selection |
+ Reading the model keeps the selection |
 A full drawing package from one call |
 Setting the sheet scale |
- A BOM on an assembly drawing |
+ A BOM on an assembly drawing |
 Each model dimension shown once |
 Clearing every overlap |
- Material and mass |
+ Material and mass |
 Listing dimensions and measuring a face |
 Flat pattern of a sheet-metal part |
+
+
 Custom properties |
diff --git a/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-01.png b/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-01.png
deleted file mode 100644
index 0ffb8b7c..00000000
Binary files a/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-01.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-02.png b/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-02.png
deleted file mode 100644
index e580a2e5..00000000
Binary files a/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-02.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-03.png b/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-03.png
deleted file mode 100644
index 9ea8a724..00000000
Binary files a/docs/assets/demos/drawing-dimensions/test_arrange_dimensions_clears_every_overlap-03.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-dimensions/test_insert_model_dimensions_shows_each_dimension_once-01.png b/docs/assets/demos/drawing-dimensions/test_insert_model_dimensions_shows_each_dimension_once-01.png
deleted file mode 100644
index ca5e6581..00000000
Binary files a/docs/assets/demos/drawing-dimensions/test_insert_model_dimensions_shows_each_dimension_once-01.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-dimensions/test_insert_model_dimensions_shows_each_dimension_once-02.png b/docs/assets/demos/drawing-dimensions/test_insert_model_dimensions_shows_each_dimension_once-02.png
deleted file mode 100644
index 456cb419..00000000
Binary files a/docs/assets/demos/drawing-dimensions/test_insert_model_dimensions_shows_each_dimension_once-02.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-01.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-01.png
deleted file mode 100644
index 8e8375ae..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-01.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-02.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-02.png
deleted file mode 100644
index 06542535..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-02.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-03.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-03.png
deleted file mode 100644
index ca138728..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-03.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-04.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-04.png
deleted file mode 100644
index d8ce0e84..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-04.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-05.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-05.png
deleted file mode 100644
index 8500df5f..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-05.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-06.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-06.png
deleted file mode 100644
index 0f41f050..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-06.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-07.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-07.png
deleted file mode 100644
index 06fe2878..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-07.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-08.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-08.png
deleted file mode 100644
index 6ff3a5c3..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-08.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-09.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-09.png
deleted file mode 100644
index 6dca750f..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-09.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-10.png b/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-10.png
deleted file mode 100644
index dcb8cbfd..00000000
Binary files a/docs/assets/demos/drawing-package/test_generate_drawing_package_on_a_block-10.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_insert_bom_table_lists_both_parts-01.png b/docs/assets/demos/drawing-package/test_insert_bom_table_lists_both_parts-01.png
deleted file mode 100644
index 25ba7612..00000000
Binary files a/docs/assets/demos/drawing-package/test_insert_bom_table_lists_both_parts-01.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_insert_bom_table_lists_both_parts-02.png b/docs/assets/demos/drawing-package/test_insert_bom_table_lists_both_parts-02.png
deleted file mode 100644
index 3e671944..00000000
Binary files a/docs/assets/demos/drawing-package/test_insert_bom_table_lists_both_parts-02.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_set_sheet_scale_halves_the_views-01.png b/docs/assets/demos/drawing-package/test_set_sheet_scale_halves_the_views-01.png
deleted file mode 100644
index 19a2006e..00000000
Binary files a/docs/assets/demos/drawing-package/test_set_sheet_scale_halves_the_views-01.png and /dev/null differ
diff --git a/docs/assets/demos/drawing-package/test_set_sheet_scale_halves_the_views-02.png b/docs/assets/demos/drawing-package/test_set_sheet_scale_halves_the_views-02.png
deleted file mode 100644
index e5f69eca..00000000
Binary files a/docs/assets/demos/drawing-package/test_set_sheet_scale_halves_the_views-02.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_custom_property_round_trip-01.png b/docs/assets/demos/engineering-data/test_custom_property_round_trip-01.png
deleted file mode 100644
index 656feedc..00000000
Binary files a/docs/assets/demos/engineering-data/test_custom_property_round_trip-01.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-01.png b/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-01.png
deleted file mode 100644
index 905356fe..00000000
Binary files a/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-01.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-02.png b/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-02.png
deleted file mode 100644
index a3819aa1..00000000
Binary files a/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-02.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-03.png b/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-03.png
deleted file mode 100644
index 02a84291..00000000
Binary files a/docs/assets/demos/engineering-data/test_dimensions_list_and_measure-03.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_export_flat_pattern_refuses_a_solid_part-01.png b/docs/assets/demos/engineering-data/test_export_flat_pattern_refuses_a_solid_part-01.png
deleted file mode 100644
index 572977c4..00000000
Binary files a/docs/assets/demos/engineering-data/test_export_flat_pattern_refuses_a_solid_part-01.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_export_flat_pattern_writes_a_dxf-01.png b/docs/assets/demos/engineering-data/test_export_flat_pattern_writes_a_dxf-01.png
deleted file mode 100644
index df5d8599..00000000
Binary files a/docs/assets/demos/engineering-data/test_export_flat_pattern_writes_a_dxf-01.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_export_flat_pattern_writes_a_dxf-02.png b/docs/assets/demos/engineering-data/test_export_flat_pattern_writes_a_dxf-02.png
deleted file mode 100644
index 9c94b314..00000000
Binary files a/docs/assets/demos/engineering-data/test_export_flat_pattern_writes_a_dxf-02.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-01.png b/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-01.png
deleted file mode 100644
index f6da0733..00000000
Binary files a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-01.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-02.png b/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-02.png
deleted file mode 100644
index 306ec9d4..00000000
Binary files a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-02.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-03.png b/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-03.png
deleted file mode 100644
index 09dfe1ba..00000000
Binary files a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-03.png and /dev/null differ
diff --git a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-04.png b/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-04.png
deleted file mode 100644
index a2bfe397..00000000
Binary files a/docs/assets/demos/engineering-data/test_material_applies_and_changes_mass-04.png and /dev/null differ
diff --git a/docs/assets/demos/monitor-stand/monitor_stand_printing.gif b/docs/assets/demos/monitor-stand/monitor_stand_printing.gif
new file mode 100644
index 00000000..083abac2
Binary files /dev/null and b/docs/assets/demos/monitor-stand/monitor_stand_printing.gif differ
diff --git a/docs/assets/demos/monitor-stand/test_monitor_stand_from_prompt_to_part-motion.gif b/docs/assets/demos/monitor-stand/test_monitor_stand_from_prompt_to_part-motion.gif
new file mode 100644
index 00000000..620060ea
Binary files /dev/null and b/docs/assets/demos/monitor-stand/test_monitor_stand_from_prompt_to_part-motion.gif differ
diff --git a/docs/assets/demos/verify-model/test_assembly_counts_components_mates_and_interference-01.png b/docs/assets/demos/verify-model/test_assembly_counts_components_mates_and_interference-01.png
deleted file mode 100644
index 0877e51f..00000000
Binary files a/docs/assets/demos/verify-model/test_assembly_counts_components_mates_and_interference-01.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_cut_reports_the_volume_it_removed-01.png b/docs/assets/demos/verify-model/test_cut_reports_the_volume_it_removed-01.png
deleted file mode 100644
index 64865651..00000000
Binary files a/docs/assets/demos/verify-model/test_cut_reports_the_volume_it_removed-01.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_reading_the_model_keeps_the_selection-01.png b/docs/assets/demos/verify-model/test_reading_the_model_keeps_the_selection-01.png
deleted file mode 100644
index ffd39789..00000000
Binary files a/docs/assets/demos/verify-model/test_reading_the_model_keeps_the_selection-01.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_revolve_reports_the_volume_it_added-01.png b/docs/assets/demos/verify-model/test_revolve_reports_the_volume_it_added-01.png
deleted file mode 100644
index 17e38760..00000000
Binary files a/docs/assets/demos/verify-model/test_revolve_reports_the_volume_it_added-01.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_round_part_size_is_exact-01.png b/docs/assets/demos/verify-model/test_round_part_size_is_exact-01.png
deleted file mode 100644
index 7fed57e7..00000000
Binary files a/docs/assets/demos/verify-model/test_round_part_size_is_exact-01.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-01.png b/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-01.png
deleted file mode 100644
index c90c4c58..00000000
Binary files a/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-01.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-02.png b/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-02.png
deleted file mode 100644
index 39ae0ad8..00000000
Binary files a/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-02.png and /dev/null differ
diff --git a/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-03.png b/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-03.png
deleted file mode 100644
index 629e7865..00000000
Binary files a/docs/assets/demos/verify-model/test_verify_model_passes_a_block_and_names_what_is_wrong-03.png and /dev/null differ
diff --git a/docs/demos.md b/docs/demos.md
index b5c2decf..ab35dccc 100644
--- a/docs/demos.md
+++ b/docs/demos.md
@@ -8,13 +8,193 @@ captioned beneath the SolidWorks window.
Each entry has:
- **what it shows**, and what the run checked;
-- **the prompt**: the real one for the prompt-to-drawing demo. The other demos
- are live tests that call the tools directly, so their prompt is an example
- of what you would type to get the same calls;
+- **the prompt**: the real one for the prompt-to-drawing and monitor-stand
+ demos. The other demos are live tests that call the tools directly, so
+ their prompt is an example of what you would type to get the same calls;
- **the tool calls**, in order;
- **the source**: the test that made the GIF. Run any of them yourself (see
[Run a demo yourself](#run-a-demo-yourself)).
+## From a prompt to a printable monitor stand {#monitor-stand}
+
+
+
+The recording opens on the prompts, then replays the build as the script
+below runs it, at the sizes the stand was finished at by hand (see
+"Finished by hand"): 24 tool calls, each captioned with its result, and the
+finished part in an isometric view. It is sped up five times.
+
+**Printed.** The finished stand on the printer, in PETG, printed on its side
+with tree supports (sped up):
+
+
+
+**Prompts.** A real Claude Code session with the server connected; the
+user's words, unedited.
+
+> Ok, so this is fairly simple, so please make sure to reason through all
+> the pieces and steps to make sure the tool calls make sense. I'm looking
+> to make a simple square(ish) monitor stand with 4 legs, and large fillets
+> on all corners where the legs meet the base where the monitor stand will
+> rest on, think mini-table with an arch on 3 sides, front, left and right,
+> but not the back.
+>
+> The monitor base itself is 170mm "deep" in the y direction if the positive
+> X-axis is on your left and running left and right of you, and 173.71mm
+> "wide" in the x-axis, and we'll want to add 1 cm to each side, extrude
+> that some amount that makes sense for PETG, let's say 1.5 cm? I want it to
+> look proportional, now the legs should be around 4 cm square on each
+> corner, and I want the height to be roughly 50-55mm in total height from
+> the bottom of the legs to the top of the monitor base
+
+The agent replied with a plan and two questions (what the back should be,
+and where to save). The answers: a plain square opening at the back, and a
+folder for the part. After the build, one follow-up:
+
+> can you make a shallow cut lip 1-2 mm wider than the actual x and y
+> dimentions of the top where the display monitor would be so it sits "in"
+> the monitor stand 3-4 mm.
+
+**What it shows.** A 3D-printable part designed from a description, with
+every step checked against a hand calculation as it was built. The agent
+turns "roughly" and "around" into numbers and says why. It works out every
+sketch and each fillet edge's position before the first call. It asks only
+what it can't decide alone: the back of the stand and where to save.
+
+| Step | SolidWorks reads back | Hand calculation |
+| --- | --- | --- |
+| Block | 2 024 269.5 mm³, 193.71 x 55 x 190 mm | 193.71 x 190 x 55 |
+| Front/back opening | −864 196 mm³ | 113.71 x 40 x 190 |
+| Left/right opening | −352 000 mm³; 808 073.5 mm³ left | 110 x 40 x 80; top plate + four legs |
+| Six R25 arch fillets | 843 259.2 mm³ | +6 x (1 − π/4) x 25² x 40 = 840 264 (0.4 % low: the blend where the front and side arches meet at each front leg's inner corner is left out) |
+| Pocket | −90 666.36 mm³; 752 592.9 mm³ final | 175.71 x 172 x 3 |
+
+**The plan the agent made**
+
+1. Top plate 193.71 x 190 x 15 mm (the monitor base plus 10 mm each
+ side), legs 40 x 40 mm, 55 mm overall: the top of the range, so R25
+ arches still leave 15 mm of straight leg and about 60 mm of flat
+ ceiling in each opening.
+2. Extrude the full footprint 55 mm, then cut the front/back opening and
+ the left/right opening through the block, 40 mm high; four legs remain.
+3. One R25 fillet on the six edges where a leg's inner face meets the
+ underside, picked by each edge's midpoint: two for the front arch, four
+ for the sides. The back legs' edges facing the back stay sharp.
+4. The follow-up: a pocket 1 mm larger than the monitor base on each side
+ (175.71 x 172 mm), 3 mm deep, on a plane at the top face.
+5. SolidWorks' Y axis is up, so the prompt's "deep" runs along Z. Every
+ sketch is centred on the origin, so no sketch axis can mirror anything.
+
+**The tool calls**
+
+1. **Part**: `create_part`, `set_units` mm.
+2. **Block**: `create_sketch` Top, `add_rectangle`, `exit_sketch`,
+ `create_extrusion` 55 mm.
+3. **Openings**: on Front, then on Right, `create_sketch`,
+ `add_rectangle`, `exit_sketch` and `create_cut_extrude`
+ `ThroughAllBoth`.
+4. **Arches**: one `add_fillet` R25 on six edges.
+5. **Check and save**: `rebuild_model`, `verify_model`, `save_part`.
+6. **Pocket** (the follow-up): `list_open_documents` (the stand is still
+ the active part), `create_reference_plane`, then `create_sketch`,
+ `add_rectangle`, `exit_sketch`, `create_cut_extrude` 3 mm, and
+ `rebuild_model`, `verify_model`, `save_part` again.
+
+??? info "All 28 calls with their arguments"
+
+ | # | Tool | Arguments |
+ | --- | --- | --- |
+ | 1 | `create_part` | `{"name": "MonitorStand", "units": "mm"}` |
+ | 2 | `set_units` | `{"unit_system": "mm"}` |
+ | 3 | `create_sketch` | `{"plane": "Top"}` |
+ | 4 | `add_rectangle` | `{"x1": -96.855, "y1": -95, "x2": 96.855, "y2": 95}` |
+ | 5 | `exit_sketch` | `{}` |
+ | 6 | `create_extrusion` | `{"sketch_name": "Sketch1", "depth": 55}` |
+ | 7 | `create_sketch` | `{"plane": "Front"}` |
+ | 8 | `add_rectangle` | `{"x1": -56.855, "y1": 0, "x2": 56.855, "y2": 40}` |
+ | 9 | `exit_sketch` | `{}` |
+ | 10 | `create_cut_extrude` | `{"sketch_name": "Sketch2", "depth": 200, "end_condition": "ThroughAllBoth"}` |
+ | 11 | `create_sketch` | `{"plane": "Right"}` |
+ | 12 | `add_rectangle` | `{"x1": -55, "y1": 0, "x2": 55, "y2": 40}` |
+ | 13 | `exit_sketch` | `{}` |
+ | 14 | `create_cut_extrude` | `{"sketch_name": "Sketch3", "depth": 200, "end_condition": "ThroughAllBoth"}` |
+ | 15 | `add_fillet` | `{"radius": 25, "edge_names": ["0.056855,0.040,0.075", "-0.056855,0.040,0.075", "0.076855,0.040,0.055", "-0.076855,0.040,0.055", "0.076855,0.040,-0.055", "-0.076855,0.040,-0.055"]}` |
+ | 16 | `rebuild_model` | `{}` |
+ | 17 | `verify_model` | `{"bounding_box_mm": [193.71, 55, 190], "volume_mm3": 840264, "solid_body_count": 1, "feature_count": 4, "relative_tolerance": 0.005}` |
+ | 18 | `save_part` | `{"file_path": "MonitorStand.SLDPRT"}` |
+ | 19 | `list_open_documents` | `{}` |
+ | 20 | `create_reference_plane` | `{"reference": "Top Plane", "offset": 55}` |
+ | 21 | `create_sketch` | `{"plane": "Plane1"}` |
+ | 22 | `add_rectangle` | `{"x1": -87.855, "y1": -86, "x2": 87.855, "y2": 86}` |
+ | 23 | `exit_sketch` | `{}` |
+ | 24 | `create_cut_extrude` | `{"sketch_name": "Sketch4", "depth": 3, "end_condition": "Blind"}` |
+ | 25 | `rebuild_model` | `{}` |
+ | 26 | `verify_model` | `{"bounding_box_mm": [193.71, 55, 190], "volume_mm3": 752592.9, "solid_body_count": 1, "relative_tolerance": 0.001}` |
+ | 27 | `save_part` | `{}` |
+ | 28 | `verify_model` | `{"feature_count": 6}` |
+
+ The fillet's edges are midpoints in metres; the other lengths are mm.
+ The save path is shortened to the file name here.
+
+!!! tip "Printing it"
+ The pocket is cut into the top face, so the stand needs some support
+ either way up: top-down with support in the pocket, or legs-down with
+ support under the arches. The print recording above has it on its side,
+ with tree supports.
+
+**Finished by hand.** The user then made three changes in SolidWorks:
+
+| Change | Session | By hand |
+| --- | --- | --- |
+| Overall height (`D1@Boss-Extrude1`) | 55 mm | 66 mm, so the top is 26 mm and the legs stay 40 mm |
+| Pocket depth (`D1@Cut-Extrude3`) | 3 mm | 11.46 mm, leaving a 14.54 mm floor under the monitor |
+| Pocket plane (`Plane1`) | Top Plane offset 55 mm | The stand's top face, offset 0, so it follows the height |
+
+Asked to read the part, the agent found the changes with `list_features`,
+`list_model_dimensions` (per feature) and `verify_model`. A 26 mm top with
+the same legs, arches and pocket outline, cut 11.46 mm deep, comes to
+901 762 mm³ by hand. SolidWorks measures 901 769 mm³.
+
+The script below was then updated to reproduce this part: 66 mm, a 26 mm
+top and an 11.46 mm pocket are its defaults. One thing can't be matched:
+the tools can only offset a plane from a named plane, so the script puts
+the pocket's plane at Top Plane offset by the height. The solid is the
+same, but the plane doesn't follow a height edited by hand. To change the
+height, rerun the script.
+
+**Rebuild it, or at another size.** The script replays the
+calls with every dimension an option, and checks the result the same way.
+At its defaults it makes the 24 calls in the recording; the session's own
+28 calls are listed above.
+
+```powershell
+# The finished stand
+.\.venv\Scripts\python.exe scripts\demo_monitor_stand.py --out "C:\path\to\MonitorStand.SLDPRT"
+
+# The stand as the session left it
+.\.venv\Scripts\python.exe scripts\demo_monitor_stand.py --out "C:\path\to\MonitorStand.SLDPRT" `
+ --height 55 --top 15 --pocket-depth 3
+
+# Taller arches and a shallower pocket, over the saved part, with an STL
+.\.venv\Scripts\python.exe scripts\demo_monitor_stand.py --out "C:\path\to\MonitorStand.SLDPRT" `
+ --height 70 --fillet 30 --pocket-depth 8 --replace --stl
+```
+
+Run it with `--help` for every option: monitor base size, margin, top, leg,
+height, fillet, pocket clearance and depth.
+
+To record the demo again:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File tests\scripts\live_demo.ps1 `
+ -Test tests/live/test_live_sw_monitor_stand_demo.py `
+ -CaptureDir docs/assets/demos/monitor-stand -Record -PromptSeconds 12
+```
+
+**Source:** [`tests/live/test_live_sw_monitor_stand_demo.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/tests/live/test_live_sw_monitor_stand_demo.py),
+which runs the build in
+[`scripts/demo_monitor_stand.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/scripts/demo_monitor_stand.py)
+
## From a prompt to an assembly drawing {#prompt-to-drawing}

@@ -461,6 +641,7 @@ the test harness:
| [`scripts/demo_sweep_loft.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/scripts/demo_sweep_loft.py) | A lofted cone and a swept coil spring in one multibody part |
| [`scripts/demo_analysis.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/scripts/demo_analysis.py) | Model info, features, configurations and mass properties read back, and a dimension read and changed |
| [`scripts/demo_export.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/scripts/demo_export.py) | One part exported to STEP, STL, IGES and PNG |
+| [`scripts/demo_monitor_stand.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/scripts/demo_monitor_stand.py) | The [arched monitor stand](#monitor-stand), as finished or at any size, through the MCP tools |
| [`scripts/demo_save_validation.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/scripts/demo_save_validation.py) | Saving refuses a missing folder and an existing file unless overwrite is set |
| [`demo_recording_heat_insert.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/docs/getting-started/tutorial-parts/demo_recording_heat_insert.py) | A recording-friendly build that prints the prompt and its own source first |
| [`list_features_assembly_demo.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/docs/getting-started/tutorial-parts/list_features_assembly_demo.py) | `list_features` on a part and an assembly from the SolidWorks sample set |
diff --git a/scripts/demo_monitor_stand.py b/scripts/demo_monitor_stand.py
new file mode 100644
index 00000000..804d9b3d
--- /dev/null
+++ b/scripts/demo_monitor_stand.py
@@ -0,0 +1,393 @@
+r"""Build the arched monitor stand through the MCP tools.
+
+This replays the tool calls an agent made from the prompt on the Demos page
+(``docs/demos.md#monitor-stand``), with every dimension a command-line
+option, so the stand can be rebuilt at a new size in one run.
+
+The defaults are the stand as finished by hand after that session: 66 mm
+tall with a 26 mm top, so the legs stay 40 mm, and an 11.46 mm pocket that
+leaves a 14.54 mm floor under the monitor. The session built it 55 mm tall
+with a 3 mm pocket (``--height 55 --top 15 --pocket-depth 3``).
+
+The one difference from the hand-finished part: there, the pocket's plane
+sits on the top face, so it follows the height if the height is edited in
+SolidWorks. The tools can only offset a plane from a named plane, so here
+it is Top Plane offset by the height. The solid is the same; to change the
+height, rerun the script rather than editing the extrusion by hand.
+
+The stand is a top plate on four square legs. The openings at the front,
+left and right are arched (large fillets where the legs meet the top); the
+back opening stays square, for cables. A shallow pocket in the top, a
+little larger than the monitor's base, holds the monitor in place.
+
+Build sequence (SolidWorks axes: X wide, Y up, Z deep, front = +Z)
+-------------------------------------------------------------------
+1. ``create_part``, ``set_units`` mm.
+2. Top Plane: a W x D rectangle about the origin, extruded H up: a block.
+3. Front Plane: the front/back opening, cut through both ways.
+4. Right Plane: the left/right opening, cut through both ways. Four legs
+ remain under the top.
+5. One ``add_fillet`` on the six edges where a leg's inner face meets the
+ underside, picked by each edge's midpoint (metres): two for the front
+ arch, four for the side arches. The back legs' edges facing the back
+ opening are left sharp.
+6. The pocket: ``create_reference_plane`` on the top face (Top Plane
+ offset by the height), a rectangle the base's size plus the clearance
+ on each side, and a blind cut down into the top.
+7. ``rebuild_model``, then ``verify_model`` against a hand calculation.
+8. ``save_part`` and, with ``--stl``, ``export_stl``.
+
+Every sketch is symmetric about the origin, so the Top and Right planes'
+sketch axes (which run along -Z) cannot mirror anything.
+
+Run on Windows with SolidWorks open, from the repository root::
+
+ .\.venv\Scripts\python.exe scripts\demo_monitor_stand.py --out "C:\path\to\MonitorStand.SLDPRT"
+
+Change a dimension and rebuild over the saved part::
+
+ .\.venv\Scripts\python.exe scripts\demo_monitor_stand.py --out "C:\path\to\MonitorStand.SLDPRT" --height 70 --fillet 30 --pocket-depth 8 --replace --stl
+
+``--replace`` closes the part if it is open in SolidWorks (unsaved changes
+to it are lost) and overwrites the file. ``--mock`` runs every call against
+the mock adapter instead, without SolidWorks.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import math
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "src"))
+
+
+@dataclass(frozen=True)
+class Stand:
+ """The stand's dimensions, all in mm."""
+
+ base_width: float = 173.71 # the monitor base, along X
+ base_depth: float = 170.0 # the monitor base, along Z
+ margin: float = 10.0 # added on every side of the monitor base
+ top: float = 26.0 # top plate thickness
+ height: float = 66.0 # floor to top face
+ leg: float = 40.0 # square leg size
+ fillet: float = 25.0 # arch fillet radius
+ pocket_clearance: float = 1.0 # pocket larger than the base, each side
+ pocket_depth: float = 11.46 # how far the monitor sits into the top
+
+ @property
+ def pocket_width(self) -> float:
+ """Pocket size along X."""
+ return self.base_width + 2 * self.pocket_clearance
+
+ @property
+ def pocket_length(self) -> float:
+ """Pocket size along Z."""
+ return self.base_depth + 2 * self.pocket_clearance
+
+ @property
+ def width(self) -> float:
+ """Overall width along X."""
+ return self.base_width + 2 * self.margin
+
+ @property
+ def depth(self) -> float:
+ """Overall depth along Z."""
+ return self.base_depth + 2 * self.margin
+
+ @property
+ def leg_height(self) -> float:
+ """Height of the legs, floor to the underside of the top."""
+ return self.height - self.top
+
+ def problems(self) -> list[str]:
+ """What makes these dimensions unbuildable; empty when they are fine."""
+ found = []
+ if min(self.width, self.depth, self.top, self.height, self.leg) <= 0:
+ found.append("every size must be positive")
+ if self.leg_height <= 0:
+ found.append(f"height {self.height:g} must exceed top {self.top:g}")
+ if 2 * self.leg >= min(self.width, self.depth):
+ found.append(f"two {self.leg:g} mm legs do not fit across the stand")
+ if self.fillet <= 0 or self.fillet >= self.leg_height:
+ found.append(
+ f"fillet {self.fillet:g} must be above 0 and below the "
+ f"{self.leg_height:g} mm leg height"
+ )
+ opening = min(self.width, self.depth) - 2 * self.leg
+ if 2 * self.fillet >= opening:
+ found.append(
+ f"two {self.fillet:g} mm fillets do not fit in the "
+ f"{opening:g} mm opening"
+ )
+ if self.pocket_clearance < 0:
+ found.append("pocket clearance cannot be negative")
+ if self.pocket_width >= self.width or self.pocket_length >= self.depth:
+ found.append("the pocket leaves no rim: lower the clearance or raise the margin")
+ if self.pocket_depth <= 0 or self.pocket_depth >= self.top:
+ found.append(
+ f"pocket depth {self.pocket_depth:g} must be above 0 and below "
+ f"the {self.top:g} mm top"
+ )
+ return found
+
+ def expected_volume_mm3(self) -> float:
+ """Top plate plus four legs plus six fillets, less the pocket.
+
+ Leaves out the small blend where the front and side arches meet at
+ each front leg's inner corner, so it reads about 0.3 % low for the
+ default stand; the check allows 1 %.
+ """
+ plate = self.width * self.depth * self.top
+ legs = 4 * self.leg**2 * self.leg_height
+ fillets = 6 * (1 - math.pi / 4) * self.fillet**2 * self.leg
+ pocket = self.pocket_width * self.pocket_length * self.pocket_depth
+ return plate + legs + fillets - pocket
+
+ def arch_edges_m(self) -> list[str]:
+ """Midpoints of the six arch edges, as 'x,y,z' in metres."""
+ y = self.leg_height
+ front_x = self.width / 2 - self.leg # a front leg's inner X face
+ front_z = self.depth / 2 - self.leg / 2 # along that leg's depth
+ side_x = self.width / 2 - self.leg / 2 # along a leg's width
+ side_z = self.depth / 2 - self.leg # a leg's inner Z face
+ points = [
+ (front_x, y, front_z), # front arch, right leg
+ (-front_x, y, front_z), # front arch, left leg
+ (side_x, y, side_z), # right arch, front leg
+ (-side_x, y, side_z), # left arch, front leg
+ (side_x, y, -side_z), # right arch, back leg
+ (-side_x, y, -side_z), # left arch, back leg
+ ]
+ return [",".join(f"{v / 1000:.6f}" for v in p) for p in points]
+
+
+class Session:
+ """Calls registered MCP tools by name, prints each, and keeps a transcript."""
+
+ def __init__(self, tools: dict[str, Any]) -> None:
+ self.tools = tools
+ self.calls: list[dict[str, Any]] = []
+
+ async def __call__(self, name: str, args: dict[str, Any] | None = None) -> dict:
+ """Call one tool and require success."""
+ shown = json.dumps(args or {}, ensure_ascii=False)
+ print(f"{len(self.calls) + 1:>2}. {name}({shown})")
+ result = await (self.tools[name]() if args is None else self.tools[name](args))
+ self.calls.append({"tool": name, "args": args or {}})
+ if result.get("status") != "success":
+ raise RuntimeError(f"{name} failed: {result.get('message')}")
+ print(f" -> {result.get('message')}")
+ return result
+
+
+async def _tools(adapter: Any) -> dict[str, Any]:
+ """Register the tool families the build uses; name -> tool function."""
+ from fastmcp import FastMCP
+
+ from solidworks_mcp.tools.analysis import register_analysis_tools
+ from solidworks_mcp.tools.export import register_export_tools
+ from solidworks_mcp.tools.file_management import register_file_management_tools
+ from solidworks_mcp.tools.modeling import register_modeling_tools
+ from solidworks_mcp.tools.sketching import register_sketching_tools
+
+ mcp = FastMCP("monitor-stand-demo")
+ for register in (
+ register_modeling_tools,
+ register_sketching_tools,
+ register_analysis_tools,
+ register_file_management_tools,
+ register_export_tools,
+ ):
+ await register(mcp, adapter, {})
+ return {t.name: t.fn for t in await mcp.list_tools()}
+
+
+async def _close_if_open(call: Session, out: Path) -> None:
+ """Close the saved stand without saving, if SolidWorks has it open."""
+ listed = await call("list_open_documents")
+ for doc in listed.get("documents", []):
+ if Path(doc.get("path") or "").resolve() == out.resolve():
+ await call("activate_document", {"title_or_path": str(out)})
+ await call("close_model", {"save": False})
+
+
+def _mm(value: float) -> float:
+ """Round a length to 0.0001 mm so the calls read cleanly."""
+ return round(value, 4)
+
+
+async def _centred_rectangle(call: Session, plane: str, width: float, length: float) -> None:
+ """Sketch a rectangle centred on the origin of ``plane`` and exit the sketch."""
+ await call("create_sketch", {"plane": plane})
+ await call(
+ "add_rectangle",
+ {"x1": -_mm(width / 2), "y1": -_mm(length / 2), "x2": _mm(width / 2), "y2": _mm(length / 2)},
+ )
+ await call("exit_sketch")
+
+
+async def build(stand: Stand, out: Path, replace: bool, stl: bool, call: Session) -> None:
+ """Run the build's tool calls in order."""
+ if replace:
+ await _close_if_open(call, out)
+
+ await call("create_part", {"name": out.stem, "units": "mm"})
+ await call("set_units", {"unit_system": "mm"})
+
+ # Block: the full footprint, extruded to the full height.
+ await _centred_rectangle(call, "Top", stand.width, stand.depth)
+ await call("create_extrusion", {"sketch_name": "Sketch1", "depth": stand.height})
+
+ # Front/back opening, between the legs along X.
+ inner_x = _mm(stand.width / 2 - stand.leg)
+ await call("create_sketch", {"plane": "Front"})
+ await call(
+ "add_rectangle",
+ {"x1": -inner_x, "y1": 0, "x2": inner_x, "y2": _mm(stand.leg_height)},
+ )
+ await call("exit_sketch")
+ cut_depth = math.ceil(max(stand.width, stand.depth) + 10)
+ await call(
+ "create_cut_extrude",
+ {"sketch_name": "Sketch2", "depth": cut_depth, "end_condition": "ThroughAllBoth"},
+ )
+
+ # Left/right opening, between the legs along Z.
+ inner_z = _mm(stand.depth / 2 - stand.leg)
+ await call("create_sketch", {"plane": "Right"})
+ await call(
+ "add_rectangle",
+ {"x1": -inner_z, "y1": 0, "x2": inner_z, "y2": _mm(stand.leg_height)},
+ )
+ await call("exit_sketch")
+ await call(
+ "create_cut_extrude",
+ {"sketch_name": "Sketch3", "depth": cut_depth, "end_condition": "ThroughAllBoth"},
+ )
+
+ # Arches: front, left and right. The back stays square.
+ await call("add_fillet", {"radius": stand.fillet, "edge_names": stand.arch_edges_m()})
+
+ # Pocket: a plane on the top face, the base's outline plus clearance,
+ # cut down into the top. The cut finds the material itself.
+ plane = await call("create_reference_plane", {"reference": "Top Plane", "offset": stand.height})
+ await _centred_rectangle(call, plane["plane"]["name"], stand.pocket_width, stand.pocket_length)
+ await call(
+ "create_cut_extrude",
+ {"sketch_name": "Sketch4", "depth": stand.pocket_depth, "end_condition": "Blind"},
+ )
+
+ await call("rebuild_model")
+ verdict = await call(
+ "verify_model",
+ {
+ "bounding_box_mm": [_mm(stand.width), _mm(stand.height), _mm(stand.depth)],
+ "volume_mm3": round(stand.expected_volume_mm3(), 1),
+ "solid_body_count": 1,
+ "feature_count": 6, # extrude, two cuts, fillet, plane, pocket
+ "relative_tolerance": 0.01,
+ },
+ )
+ if verdict.get("verdict") != "pass":
+ failed = [c["name"] for c in verdict.get("checks", []) if c.get("passed") is not True]
+ raise RuntimeError(f"verify_model did not pass: {failed}")
+
+ await call("save_part", {"file_path": str(out), "overwrite": replace})
+ if stl:
+ await call(
+ "export_stl",
+ {"file_path": str(out.with_suffix(".stl")), "format_type": "stl"},
+ )
+
+
+def _parse(argv: list[str] | None) -> argparse.Namespace:
+ d = Stand()
+ p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
+ p.add_argument("--out", type=Path, required=True, help="where to save the .SLDPRT")
+ p.add_argument("--base-width", type=float, default=d.base_width, help="monitor base width, X (mm)")
+ p.add_argument("--base-depth", type=float, default=d.base_depth, help="monitor base depth, Z (mm)")
+ p.add_argument("--margin", type=float, default=d.margin, help="added on each side (mm)")
+ p.add_argument("--top", type=float, default=d.top, help="top plate thickness (mm)")
+ p.add_argument("--height", type=float, default=d.height, help="overall height (mm)")
+ p.add_argument("--leg", type=float, default=d.leg, help="square leg size (mm)")
+ p.add_argument("--fillet", type=float, default=d.fillet, help="arch fillet radius (mm)")
+ p.add_argument(
+ "--pocket-clearance", type=float, default=d.pocket_clearance,
+ help="pocket larger than the base on each side (mm)",
+ )
+ p.add_argument("--pocket-depth", type=float, default=d.pocket_depth, help="pocket depth (mm)")
+ p.add_argument("--replace", action="store_true", help="close the open part and overwrite the file")
+ p.add_argument("--stl", action="store_true", help="also export an STL beside the part")
+ p.add_argument("--calls-json", type=Path, help="write the tool calls made to this file")
+ p.add_argument("--mock", action="store_true", help="use the mock adapter, not SolidWorks")
+ return p.parse_args(argv)
+
+
+async def _run(args: argparse.Namespace, stand: Stand) -> list[dict[str, Any]]:
+ if args.mock:
+ from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter
+
+ adapter: Any = MockSolidWorksAdapter({})
+ else:
+ from solidworks_mcp.adapters.pywin32_adapter import PyWin32Adapter
+
+ adapter = PyWin32Adapter({})
+ await adapter.connect()
+ try:
+ call = Session(await _tools(adapter))
+ await build(stand, args.out, args.replace, args.stl, call)
+ return call.calls
+ finally:
+ await adapter.disconnect()
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Parse options, build the stand, and report what was made."""
+ args = _parse(argv)
+ stand = Stand(
+ base_width=args.base_width,
+ base_depth=args.base_depth,
+ margin=args.margin,
+ top=args.top,
+ height=args.height,
+ leg=args.leg,
+ fillet=args.fillet,
+ pocket_clearance=args.pocket_clearance,
+ pocket_depth=args.pocket_depth,
+ )
+ if problems := stand.problems():
+ print("Can't build this stand:\n " + "\n ".join(problems), file=sys.stderr)
+ return 2
+ if args.out.suffix.upper() != ".SLDPRT":
+ print("--out must end in .SLDPRT", file=sys.stderr)
+ return 2
+ if args.out.exists() and not args.replace:
+ print(f"{args.out} exists; pass --replace to rebuild over it", file=sys.stderr)
+ return 2
+
+ print(
+ f"Stand {stand.width:g} x {stand.depth:g} x {stand.height:g} mm, "
+ f"{stand.top:g} mm top, {stand.leg:g} mm legs, R{stand.fillet:g} arches, "
+ f"{stand.pocket_width:g} x {stand.pocket_length:g} x {stand.pocket_depth:g} mm pocket"
+ )
+ try:
+ calls = asyncio.run(_run(args, stand))
+ except RuntimeError as exc:
+ print(f"Stopped: {exc}", file=sys.stderr)
+ return 1
+ if args.calls_json:
+ args.calls_json.write_text(json.dumps(calls, indent=2), encoding="utf-8")
+ print(f"\nDone: {len(calls)} tool calls. Saved {args.out}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/solidworks_mcp/tools/export.py b/src/solidworks_mcp/tools/export.py
index b0b03ae8..86d3efa6 100644
--- a/src/solidworks_mcp/tools/export.py
+++ b/src/solidworks_mcp/tools/export.py
@@ -349,6 +349,19 @@ def model_post_init(self, __context: Any) -> None:
self.recursive = True
+def _file_input(input_data: Any, format_type: str) -> Any:
+ """Validate a plain-dict argument as ``ExportFileInput``.
+
+ Over MCP the arguments arrive already validated; a script calling the
+ tool function directly passes a dict, which has no ``file_path``
+ attribute. The tool's own format fills ``format_type`` when the dict
+ leaves it out. Models, including the per-format aliases, pass through.
+ """
+ if isinstance(input_data, dict):
+ return normalize_input({"format_type": format_type, **input_data}, ExportFileInput)
+ return input_data
+
+
async def register_export_tools(
mcp: FastMCP, adapter: SolidWorksAdapter, config: dict[str, Any]
) -> int:
@@ -416,15 +429,11 @@ async def export_step(input_data: ExportFileInput) -> dict[str, Any]:
- Recommended for supplier collaboration and CNC machining
- Supports assemblies, parts, and surface models
"""
- if hasattr(input_data, "model_dump"):
- payload = input_data.model_dump()
- file_path = input_data.file_path
- else:
- payload = dict(input_data)
- file_path = str(payload.get("file_path") or "")
+ input_data = _file_input(input_data, "step")
+ file_path = input_data.file_path
if hasattr(adapter, "export_step"):
- result = await adapter.export_step(payload)
+ result = await adapter.export_step(input_data.model_dump())
else:
result = await adapter.export_file(file_path, "step")
@@ -486,6 +495,7 @@ async def export_iges(input_data: ExportFileInput) -> dict[str, Any]:
- Use STEP format for newer systems when possible
- Common in aerospace and automotive surface design
"""
+ input_data = _file_input(input_data, "iges")
if hasattr(adapter, "export_iges"):
result = await adapter.export_iges(input_data.model_dump())
else:
@@ -549,6 +559,7 @@ async def export_stl(input_data: ExportFileInput) -> dict[str, Any]:
- Binary format is more compact than ASCII
- Essential for 3D printing and rapid prototyping workflows
"""
+ input_data = _file_input(input_data, "stl")
if hasattr(adapter, "export_stl"):
result = await adapter.export_stl(input_data.model_dump())
else:
@@ -618,6 +629,7 @@ async def export_pdf(input_data: ExportFileInput) -> dict[str, Any]:
- Can include multiple sheets from drawing files
- Supports password protection and access controls
"""
+ input_data = _file_input(input_data, "pdf")
if hasattr(adapter, "export_pdf"):
result = await adapter.export_pdf(input_data.model_dump())
else:
@@ -683,6 +695,7 @@ async def export_dwg(input_data: ExportFileInput) -> dict[str, Any]:
- Version compatibility important for legacy AutoCAD systems
- Preserves dimensions, annotations, and drawing structure
"""
+ input_data = _file_input(input_data, "dwg")
if hasattr(adapter, "export_dwg"):
result = await adapter.export_dwg(input_data.model_dump())
else:
diff --git a/tests/live/evidence.py b/tests/live/evidence.py
index cdfa775b..3ae9decd 100644
--- a/tests/live/evidence.py
+++ b/tests/live/evidence.py
@@ -61,6 +61,7 @@ def evidence(
call: str | None = None,
picture: Any = None,
quick: bool = False,
+ max_lines: int = _CAPTION_MAX_LINES,
) -> None:
"""Print one proof, hold if asked, then capture it if asked.
@@ -76,13 +77,15 @@ def evidence(
exported PDF's page from :func:`pdf_page`.
quick: Print it only: no caption in a recording, no hold and no
still, for a run of small steps the recording shows as motion.
+ max_lines: Caption lines kept before the rest is cut to ``...``;
+ raise it for a caption that must show in full, such as a prompt.
"""
_print(label, payload, call)
from tests.live import recorder
if quick:
return
- recorder.note_caption(label, payload, call)
+ recorder.note_caption(label, payload, call, max_lines)
try:
hold = float(os.getenv("SW_DEMO_HOLD_SECONDS", "0"))
except ValueError:
@@ -92,7 +95,7 @@ def evidence(
capture_dir = os.getenv("SW_DEMO_CAPTURE_DIR")
if capture_dir:
try:
- _capture(Path(capture_dir), label, payload, call, picture)
+ _capture(Path(capture_dir), label, payload, call, picture, max_lines)
except Exception as exc: # noqa: BLE001 - a picture never fails a test
print(f"[evidence] (capture skipped: {exc})", flush=True)
@@ -213,7 +216,11 @@ def _solidworks_window_image() -> Any:
def _caption_lines(
- label: str, payload: Any, call: str | None = None, chars: int = _CAPTION_WIDTH_CHARS
+ label: str,
+ payload: Any,
+ call: str | None = None,
+ chars: int = _CAPTION_WIDTH_CHARS,
+ max_lines: int = _CAPTION_MAX_LINES,
) -> list[tuple[str, str]]:
"""Wrap the label, call and a readable payload for the caption panel.
@@ -231,8 +238,8 @@ def _caption_lines(
body = pprint.pformat(payload, width=chars, sort_dicts=False)
for raw in body.splitlines():
lines.extend(("code", w) for w in textwrap.wrap(raw, chars))
- if len(lines) > _CAPTION_MAX_LINES:
- lines = lines[: _CAPTION_MAX_LINES - 1] + [("tag", "...")]
+ if len(lines) > max_lines:
+ lines = lines[: max_lines - 1] + [("tag", "...")]
return lines
@@ -257,7 +264,13 @@ def _draw_code(draw: Any, x: float, y: int, text: str, font: Any) -> None:
x += draw.textlength(value, font=font)
-def caption_panel(width: int, label: str, payload: Any, call: str | None) -> Any:
+def caption_panel(
+ width: int,
+ label: str,
+ payload: Any,
+ call: str | None,
+ max_lines: int = _CAPTION_MAX_LINES,
+) -> Any:
"""The evidence caption drawn as a dark panel ``width`` pixels wide."""
from PIL import Image, ImageDraw, ImageFont
@@ -268,7 +281,7 @@ def caption_panel(width: int, label: str, payload: Any, call: str | None) -> Any
font = bold = ImageFont.load_default()
# Consolas is 0.55 em wide: wrap at what fits across the panel.
chars = max(40, int((width - 24) / (0.55 * _CAPTION_FONT_SIZE)))
- lines = _caption_lines(label, payload, call, chars)
+ lines = _caption_lines(label, payload, call, chars, max_lines)
height = 16 + _CAPTION_LINE_HEIGHT * len(lines)
panel = Image.new("RGB", (width, height), _BACKGROUND)
draw = ImageDraw.Draw(panel)
@@ -301,7 +314,12 @@ def pdf_page(path: str | Path, width: int = 1800) -> Any:
def _capture(
- directory: Path, label: str, payload: Any, call: str | None, picture: Any = None
+ directory: Path,
+ label: str,
+ payload: Any,
+ call: str | None,
+ picture: Any = None,
+ max_lines: int = _CAPTION_MAX_LINES,
) -> None:
"""Save the SolidWorks window (or ``picture``) with the evidence beneath it."""
from PIL import Image
@@ -309,7 +327,7 @@ def _capture(
shot = picture if picture is not None else _solidworks_window_image()
if shot.width < _MIN_FRAME_WIDTH:
shot = Image.new("RGB", (_MIN_FRAME_WIDTH, 0))
- panel = caption_panel(shot.width, label, payload, call)
+ panel = caption_panel(shot.width, label, payload, call, max_lines)
frame = Image.new("RGB", (shot.width, shot.height + panel.height))
frame.paste(shot, (0, 0))
frame.paste(panel, (0, shot.height))
diff --git a/tests/live/recorder.py b/tests/live/recorder.py
index 65c245ef..028541f6 100644
--- a/tests/live/recorder.py
+++ b/tests/live/recorder.py
@@ -76,12 +76,17 @@ def stop(self) -> None:
self._thread.join(timeout=10)
(self.directory / f"end-{self._ms():08d}").touch()
- def caption(self, label: str, payload: Any, call: str | None) -> None:
+ def caption(
+ self, label: str, payload: Any, call: str | None, max_lines: int | None = None
+ ) -> None:
"""Save an evidence caption, shown under every frame from now on."""
from tests.live.evidence import caption_panel
width = self._width or 1280
- panel = caption_panel(width, label, payload, call)
+ if max_lines is None:
+ panel = caption_panel(width, label, payload, call)
+ else:
+ panel = caption_panel(width, label, payload, call, max_lines)
panel.save(self.directory / f"caption-{self._ms():08d}.png")
def _run(self) -> None:
@@ -157,11 +162,16 @@ def stop(recorder: Recorder) -> None:
print(f"[recorder] {recorder.frames} frames in {recorder.directory}", flush=True)
-def note_caption(label: str, payload: Any, call: str | None) -> None:
- """Stamp an evidence caption into the running recording, if there is one."""
+def note_caption(
+ label: str, payload: Any, call: str | None, max_lines: int | None = None
+) -> None:
+ """Stamp an evidence caption into the running recording, if there is one.
+
+ ``max_lines`` overrides the caption's usual line limit (see ``evidence``).
+ """
if _ACTIVE is None:
return
try:
- _ACTIVE.caption(label, payload, call)
+ _ACTIVE.caption(label, payload, call, max_lines)
except Exception as exc: # noqa: BLE001 - a recording never fails a test
print(f"[recorder] caption skipped: {exc}", flush=True)
diff --git a/tests/live/test_live_sw_monitor_stand_demo.py b/tests/live/test_live_sw_monitor_stand_demo.py
new file mode 100644
index 00000000..1cc0763f
--- /dev/null
+++ b/tests/live/test_live_sw_monitor_stand_demo.py
@@ -0,0 +1,184 @@
+"""The monitor stand demo: a prompt, the tool calls that answer it, the part.
+
+One live test, recorded for the Demos page (``docs/demos.md#monitor-stand``).
+It opens on the user's real prompts, then runs every tool call of
+``scripts/demo_monitor_stand.py``'s ``build()`` (the same function, so the
+demo and the script cannot drift apart) through the registered MCP tools,
+each call a captioned frame. The stand is built at the script's defaults,
+the part as the user finished it: 66 mm tall, a 26 mm top, R25 arches and an
+11.46 mm pocket. ``build()`` checks it with ``verify_model`` against a hand
+calculation before saving and exporting an STL. The last frame is the part
+in an isometric view.
+
+Record it with::
+
+ powershell -ExecutionPolicy Bypass -File tests\\scripts\\live_demo.ps1 `
+ -Test tests/live/test_live_sw_monitor_stand_demo.py `
+ -CaptureDir docs/assets/demos/monitor-stand -Record -PromptSeconds 12
+
+Collected only with ``SOLIDWORKS_MCP_RUN_REAL_INTEGRATION=1`` on Windows (see
+``tests/live/conftest.py``).
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+import sys
+import textwrap
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from tests.live.evidence import evidence, starting
+
+_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "demo_monitor_stand.py"
+
+#: The user's words, unedited: the request, then the follow-up after the build.
+PROMPT = (
+ "Ok, so this is fairly simple, so please make sure to reason through all the "
+ "pieces and steps to make sure the tool calls make sense. I'm looking to make a "
+ "simple square(ish) monitor stand with 4 legs, and large fillets on all corners "
+ "where the legs meet the base where the monitor stand will rest on, think "
+ "mini-table with an arch on 3 sides, front, left and right, but not the back. "
+ 'The monitor base itself is 170mm "deep" in the y direction if the positive '
+ "X-axis is on your left and running left and right of you, and 173.71mm "
+ '"wide" in the x-axis, and we\'ll want to add 1 cm to each side, extrude that '
+ "some amount that makes sense for PETG, let's say 1.5 cm? I want it to look "
+ "proportional, now the legs should be around 4 cm square on each corner, and I "
+ "want the height to be roughly 50-55mm in total height from the bottom of the "
+ "legs to the top of the monitor base"
+)
+FOLLOW_UP = (
+ "can you make a shallow cut lip 1-2 mm wider than the actual x and y dimentions "
+ 'of the top where the display monitor would be so it sits "in" the monitor '
+ "stand 3-4 mm."
+)
+FINISHED_BY_HAND = (
+ "Then finished by hand: 66 mm tall (a 26 mm top) and an 11.46 mm pocket. "
+ "This replays the build at those sizes."
+)
+#: The prompt caption is shown in full, however narrow the window: lines fit
+#: the narrowest frame (1280 px, 76 characters, less the list's quotes).
+_PROMPT_MAX_LINES = 26
+_PROMPT_WRAP_CHARS = 70
+
+#: swStandardViews_e (swconst.tlb, SW 2026): the isometric view.
+_ISO_VIEW = 7
+#: Calls after which the camera turns to the isometric view: from the first
+#: solid on, the cuts, arches and pocket are seen in 3D.
+_LOOK_AFTER = {"create_extrusion"}
+
+
+def _load_script() -> Any:
+ """Import ``scripts/demo_monitor_stand.py``, which is not a package."""
+ spec = importlib.util.spec_from_file_location("demo_monitor_stand", _SCRIPT)
+ assert spec is not None and spec.loader is not None, _SCRIPT
+ module = importlib.util.module_from_spec(spec)
+ sys.modules.setdefault("demo_monitor_stand", module)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _prompt_lines() -> list[str]:
+ """The prompt, the follow-up and the hand edits, wrapped for the caption."""
+ lines = textwrap.wrap(PROMPT, _PROMPT_WRAP_CHARS)
+ lines += [""] + textwrap.wrap("Then: " + FOLLOW_UP, _PROMPT_WRAP_CHARS)
+ lines += [""] + textwrap.wrap(FINISHED_BY_HAND, _PROMPT_WRAP_CHARS)
+ return lines
+
+
+def _look(adapter: Any, view: int) -> None:
+ """Point the camera for the demo: a standard view, zoomed to fit.
+
+ Staging for the recording, not a tool call: an agent does not need it.
+ """
+ model = adapter.currentModel
+ adapter._attempt(lambda: model.ShowNamedView2("", view), default=None)
+ adapter._zoom_to_fit(model)
+
+
+def _brief(result: dict[str, Any]) -> dict[str, Any]:
+ """The part of a reply worth a caption: its message and what it measured."""
+ shown: dict[str, Any] = {"message": result.get("message")}
+ change = result.get("measured_change")
+ if change:
+ shown["volume_delta_mm3"] = change.get("volume_delta_mm3")
+ shown["volume_after_mm3"] = change.get("volume_after_mm3")
+ shown["bounding_box_after_mm"] = change.get("bounding_box_after_mm")
+ if "verdict" in result:
+ shown["verdict"] = result["verdict"]
+ shown["checks"] = [
+ {k: c[k] for k in ("name", "expected", "actual", "passed") if k in c}
+ for c in result.get("checks", [])
+ ]
+ return shown
+
+
+class _Session:
+ """Calls the registered MCP tools; each call is a captioned frame.
+
+ Takes the place of the script's ``Session`` in ``build()``. Paths under
+ the test's folder are shown relative to it, so no local path is
+ published in a caption.
+ """
+
+ def __init__(self, tools: dict[str, Any], adapter: Any, folder: Path) -> None:
+ self.tools = tools
+ self.adapter = adapter
+ self.prefix = str(folder) + os.sep
+ self.calls: list[dict[str, Any]] = []
+ self.results: dict[str, dict[str, Any]] = {}
+
+ async def __call__(
+ self, name: str, args: dict[str, Any] | None = None
+ ) -> dict[str, Any]:
+ """Call one tool, caption it, and require success."""
+ label = f"tool call {len(self.calls) + 1}: {name}"
+ shown = f"{name}({json.dumps(args or {}, ensure_ascii=False)})"
+ shown = shown.replace(json.dumps(self.prefix)[1:-1], "")
+ starting(label, shown)
+ tool = self.tools[name]
+ result: dict[str, Any] = await (tool() if args is None else tool(args))
+ self.calls.append({"tool": name, "args": args or {}})
+ self.results[name] = result
+ assert result.get("status") == "success", f"{name}: {result.get('message')}"
+ if name in _LOOK_AFTER:
+ _look(self.adapter, _ISO_VIEW)
+ brief = _brief(result)
+ brief["message"] = str(brief["message"]).replace(self.prefix, "")
+ evidence(label, brief, call=shown)
+ return result
+
+
+@pytest.mark.asyncio
+@pytest.mark.timeout(1200) # paced for a demo: a hold on every frame
+async def test_monitor_stand_from_prompt_to_part(scratch: Any, tmp_path: Path) -> None:
+ """The arched monitor stand from the user's prompt, checked, saved and exported."""
+ script = _load_script()
+ stand = script.Stand()
+ adapter = scratch.adapter
+ call = _Session(await script._tools(adapter), adapter, tmp_path)
+ out = tmp_path / "MonitorStand.SLDPRT"
+ scratch.written_files += [str(out), str(out.with_suffix(".stl"))]
+
+ evidence("the prompt", _prompt_lines(), max_lines=_PROMPT_MAX_LINES)
+
+ await script.build(stand, out, replace=False, stl=True, call=call)
+
+ assert out.is_file(), out
+ assert out.with_suffix(".stl").is_file()
+ check = call.results["verify_model"]
+ assert check["verdict"] == "pass", check
+
+ _look(adapter, _ISO_VIEW)
+ evidence(
+ f"the finished stand, isometric: {len(call.calls)} tool calls, all successful",
+ {
+ "size_mm": check["measured"]["bounding_box_mm"]["size"],
+ "volume_mm3": round(check["measured"]["volume_mm3"], 1),
+ "saved": [out.name, out.with_suffix(".stl").name],
+ },
+ )
diff --git a/tests/scripts/live_demo.ps1 b/tests/scripts/live_demo.ps1
index 55fbc654..0f6a598d 100644
--- a/tests/scripts/live_demo.ps1
+++ b/tests/scripts/live_demo.ps1
@@ -23,12 +23,16 @@
#
# -Trace prints every SolidWorks call with a timestamp as it starts and ends,
# so a sound or flash in SolidWorks can be matched to the call behind it.
+#
+# -PromptSeconds sets how long the GIFs hold their first frame, the prompt
+# (default 4).
param(
[Parameter(Mandatory = $true)][string]$Test,
[string]$CaptureDir = "",
[switch]$Record,
[switch]$Trace,
- [double]$Speed = 5.0
+ [double]$Speed = 5.0,
+ [double]$PromptSeconds = 4.0
)
Set-Location (Resolve-Path "$PSScriptRoot\..\..")
@@ -63,7 +67,7 @@ if ($Record) {
}
if ($CaptureDir -and $Record) {
- & .\.venv\Scripts\python.exe tests\scripts\make_demo_gif.py $CaptureDir --recording --speed $Speed
+ & .\.venv\Scripts\python.exe tests\scripts\make_demo_gif.py $CaptureDir --recording --speed $Speed --prompt-seconds $PromptSeconds
} elseif ($CaptureDir) {
- & .\.venv\Scripts\python.exe tests\scripts\make_demo_gif.py $CaptureDir
+ & .\.venv\Scripts\python.exe tests\scripts\make_demo_gif.py $CaptureDir --prompt-seconds $PromptSeconds
}
diff --git a/tests/scripts/make_demo_gif.py b/tests/scripts/make_demo_gif.py
index 02c9e917..72beecd2 100644
--- a/tests/scripts/make_demo_gif.py
+++ b/tests/scripts/make_demo_gif.py
@@ -33,8 +33,13 @@
_CAPTION_BACKGROUND = (24, 24, 28)
-def build_gifs(directory: Path, width: int, seconds: float) -> list[Path]:
- """Write one GIF per test found in ``directory``; return their paths."""
+def build_gifs(
+ directory: Path, width: int, seconds: float, prompt_seconds: float = 4.0
+) -> list[Path]:
+ """Write one GIF per test found in ``directory``; return their paths.
+
+ The first frame, the prompt, shows for ``prompt_seconds``.
+ """
frames: dict[str, list[Path]] = defaultdict(list)
for path in sorted(directory.glob("*.png")):
match = _FRAME.match(path.name)
@@ -61,7 +66,7 @@ def build_gifs(directory: Path, width: int, seconds: float) -> list[Path]:
save_all=True,
append_images=images[1:],
duration=[
- int((_PROMPT_SECONDS if i == 0 else seconds) * 1000)
+ int((prompt_seconds if i == 0 else seconds) * 1000)
for i in range(len(images))
],
loop=0,
@@ -75,10 +80,6 @@ def build_gifs(directory: Path, width: int, seconds: float) -> list[Path]:
_STAMP = re.compile(r"^(?P