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 promptThe monitor stand being 3D printed
A 3D-printable monitor stand from a prompt, then printed
verify_model passes a block and names what is wrong
verify_model passes a block and names what is wrong
An assembly: parts, mates and interference
An assembly: parts, mates and interference
A cut reports the volume it removed
A cut reports the volume it removed
A cut reports the volume it removed
A cut reports the volume it removed
A revolve reports the volume it added
A revolve reports the volume it added
A round part's size is exact
A round part's size is exact
Reading the model keeps the selection
Reading the model keeps the selection
Reading the model keeps the selection
Reading the model keeps the selection
A full drawing package from one call
A full drawing package from one call
Setting the sheet scale
Setting the sheet scale
A BOM on an assembly drawing
A BOM on an assembly drawing
A BOM on an assembly drawing
A BOM on an assembly drawing
Each model dimension shown once
Each model dimension shown once
Clearing every overlap
Clearing every overlap
Material and mass
Material and mass
Material and mass
Material and mass
Listing dimensions and measuring a face
Listing dimensions and measuring a face
Flat pattern of a sheet-metal part
Flat pattern of a sheet-metal part
Custom properties
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 user's prompt, then an arched monitor stand built in SolidWorks through the MCP tools: a block, two cuts leaving four legs, arches at the front and sides, a pocket for the monitor's base, checked, saved and exported as an STL, ending in an isometric view](assets/demos/monitor-stand/test_monitor_stand_from_prompt_to_part-motion.gif) + +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): + +![The monitor stand being 3D printed on its side in black PETG, with tree supports under the arches](assets/demos/monitor-stand/monitor_stand_printing.gif) + +**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} ![A fully dimensioned plate, its drawing with GD&T, two plates mated 10 mm apart and the assembly drawing with a BOM, built in SolidWorks through the MCP tools](assets/demos/readme/test_plate_stack_from_prompt_to_drawing-motion.gif) @@ -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"^(?Pframe|caption|end)-(?P\d{8})(?:\.png)?$") -#: How long the first frame (the prompt and the plan) shows. -_PROMPT_SECONDS = 4.0 - - def _timeline(folder: Path) -> tuple[list, list, int]: """A recording's frames and captions as ``(ms, path)``, and its end time.""" frames, captions, end = [], [], 0 @@ -114,11 +115,13 @@ def build_recording( speed: float, max_hold: float, min_caption: float = 0.6, + prompt_seconds: float = 4.0, ) -> Path | None: """One recording as a GIF; ``None`` when it holds no frames. A frame where a new caption appears shows for at least ``min_caption`` - seconds, so a tool call can be read however fast the rest plays. + seconds, so a tool call can be read however fast the rest plays; the + first caption, the prompt, for at least ``prompt_seconds``. """ frames, captions, end = _timeline(folder) if not frames: @@ -160,7 +163,7 @@ def scaled(path: Path) -> Image.Image: if caption_path is not None and caption_path != _latest(captions, ms - 1): # The first caption is the prompt: give it time to be read. first = caption_path == captions[0][1] - seconds = max(seconds, _PROMPT_SECONDS if first else min_caption) + seconds = max(seconds, prompt_seconds if first else min_caption) images.append(frame.quantize(palette=palette, dither=Image.Dither.NONE)) durations.append(max(20, int(seconds * 1000))) images[0].save( @@ -199,6 +202,7 @@ def build_recordings( speed: float, max_hold: float, min_caption: float = 0.6, + prompt_seconds: float = 4.0, ) -> list[Path]: """``-motion.gif`` for every recording under ``directory/recording``.""" root = directory / "recording" @@ -213,6 +217,7 @@ def build_recordings( speed, max_hold, min_caption, + prompt_seconds, ) if gif is not None: written.append(gif) @@ -240,11 +245,22 @@ def main() -> None: default=0.6, help="shortest a frame with a new caption shows (s)", ) + parser.add_argument( + "--prompt-seconds", + type=float, + default=4.0, + help="how long the first frame, the prompt, shows (s)", + ) args = parser.parse_args() - paths = build_gifs(args.directory, args.width, args.seconds) + paths = build_gifs(args.directory, args.width, args.seconds, args.prompt_seconds) if args.recording: paths += build_recordings( - args.directory, args.width, args.speed, args.max_hold, args.min_caption + args.directory, + args.width, + args.speed, + args.max_hold, + args.min_caption, + args.prompt_seconds, ) for path in paths: print(f"{path} ({path.stat().st_size // 1024} KB)") diff --git a/tests/solidworks_mcp/tools/test_export.py b/tests/solidworks_mcp/tools/test_export.py index 5835ebbe..e41755ec 100644 --- a/tests/solidworks_mcp/tools/test_export.py +++ b/tests/solidworks_mcp/tools/test_export.py @@ -737,6 +737,25 @@ async def test_export_tools_fallback_and_aliases(mcp_server, mock_config): assert error_result["status"] == "error" +@pytest.mark.asyncio +@pytest.mark.parametrize("adapter_cls", [_ExportFallbackAdapter, _ExportNativeAdapter]) +@pytest.mark.parametrize("fmt", ["step", "iges", "stl", "pdf", "dwg"]) +async def test_export_tools_accept_a_plain_dict(mcp_server, mock_config, adapter_cls, fmt): + """A dict argument, as a script calling the tool function passes, works. + + Over MCP the arguments arrive as ``ExportFileInput``; called directly + they are a dict, which used to fail on ``input_data.file_path``. + """ + await register_export_tools(mcp_server, adapter_cls(), mock_config) + + result = await (await _tool(mcp_server, f"export_{fmt}"))( + input_data={"file_path": f"a.{fmt}", "format_type": fmt} + ) + + assert result["status"] == "success" + assert f"a.{fmt}" in result["message"] + + @pytest.mark.asyncio async def test_export_tools_native_adapter_methods(mcp_server, mock_config): """Test export tools native adapter methods."""