Repository: partcad/partcad
Pull request #382: refactor: Drop PNG and SVG dependencies
Author: login: sasuke13
Created: 2025-03-11T15:22:00Z | Merged: 2025-03-24T19:56:07Z

## Copilot Summary

<!-- Use GitHub Copilot to generate a summary of your changes here. -->



Diff:
diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt
index d7fb20082..0f83e5773 100644
--- a/.devcontainer/requirements.txt
+++ b/.devcontainer/requirements.txt
@@ -2480,9 +2480,6 @@ regex==2024.11.6 ; python_version >= "3.10" and python_version < "3.13" \
     --hash=sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91
 renderlab==0.1.20230421184216 ; python_version >= "3.10" and python_version < "3.13" \
     --hash=sha256:8a5880b75dd026a534eee47c17b18cd82f0cab6c04355f705f93f7e124bd2028
-reportlab==4.2.5 ; python_version >= "3.10" and python_version < "3.13" \
-    --hash=sha256:5cf35b8fd609b68080ac7bbb0ae1e376104f7d5f7b2d3914c7adc63f2593941f \
-    --hash=sha256:eb2745525a982d9880babb991619e97ac3f661fae30571b7d50387026ca765ee
 requests-toolbelt==1.0.0 ; python_version >= "3.10" and python_version < "3.13" \
     --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \
     --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
@@ -2672,8 +2669,6 @@ strip-ansi==0.1.1 ; python_version >= "3.10" and python_version < "3.13" \
 svgelements==1.9.6 ; python_version >= "3.10" and python_version < "3.13" \
     --hash=sha256:7c02ad6404cd3d1771fd50e40fbfc0550b0893933466f86a6eb815f3ba3f37f7 \
     --hash=sha256:8a5cf2cc066d98e713d5b875b1d6e5eeb9b92e855e835ebd7caab2713ae1dcad
-svglib==1.5.1 ; python_version >= "3.10" and python_version < "3.13" \
-    --hash=sha256:3ae765d3a9409ee60c0fb4d24c2deb6a80617aa927054f5bcd7fc98f0695e587
 svgpathtools==1.6.1 ; python_version >= "3.10" and python_version < "3.13" \
     --hash=sha256:39967f9a817b8a12cc6dd1646fc162d522fca6c3fd5f8c94913c15ee4cb3a906 \
     --hash=sha256:7054e6de1953e295bf565d535d585695453b09f8db4a2f7c4853348732097a3e
diff --git a/.github/workflows/test-dev.yml b/.github/workflows/test-dev.yml
index 9d0a99a75..f184ef8ec 100644
--- a/.github/workflows/test-dev.yml
+++ b/.github/workflows/test-dev.yml
@@ -247,7 +247,7 @@ jobs:
     name: "Run: behave"
     runs-on: ubuntu-24.04
 
-    timeout-minutes: 30
+    timeout-minutes: 60
 
     needs: poetry-install
     permissions:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 60d95d772..6c0f0d8eb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -196,7 +196,7 @@ jobs:
       fail-fast: false
       matrix: ${{ fromJson(needs.set-matrix.outputs.matrix) }}
     runs-on: ${{ matrix.os }}
-    timeout-minutes: 60
+    timeout-minutes: 120
 
     steps:
       - uses: actions/checkout@v4
diff --git a/examples/feature_convert/partcad.yaml b/examples/feature_convert/partcad.yaml
index dffb86ee4..1c7b4334d 100644
--- a/examples/feature_convert/partcad.yaml
+++ b/examples/feature_convert/partcad.yaml
@@ -1,4 +1,6 @@
-desc: PartCAD example project for testing the conversion of various part types, including CadQuery, SCAD, STEP, BREP, STL, 3MF, Build123d, and more. This project demonstrates the use of alias and enrich mechanisms for part conversion.
+desc: PartCAD example project for testing the conversion of various part types, including
+  CadQuery, SCAD, STEP, BREP, STL, 3MF, Build123d, and more. This project demonstrates
+  the use of alias and enrich mechanisms for part conversion.
 
 docs:
   usage: |
@@ -181,3 +183,7 @@ parts:
   cube_alias_enrich_alias:
     type: alias
     source: :cube_enrich_alias
+
+render:
+  png: .
+  svg: .
diff --git a/features/adhoc/convert.feature b/features/adhoc/convert.feature
index f37a002bc..46cb9a8f9 100644
--- a/features/adhoc/convert.feature
+++ b/features/adhoc/convert.feature
@@ -143,7 +143,7 @@ Feature: `pc adhoc convert` command
       """
     When I run "pc adhoc convert invalid.stl test.step --input stl --output step"
     Then the command should exit with a status code of "1"
-    And STDOUT should contain "Failed to convert: Failed to read the STL file"
+    And STDOUT should contain "Failed to convert:"
 
   Scenario: Handle empty input file
     Given a file named "empty.stl" with content:
@@ -151,7 +151,7 @@ Feature: `pc adhoc convert` command
       """
     When I run "pc adhoc convert empty.stl empty.step --input stl --output step"
     Then the command should exit with a status code of "1"
-    And STDOUT should contain "Failed to convert: Failed to read the STL file"
+    And STDOUT should contain "Failed to convert:"
 
   Scenario: Ambiguous input file extension
     Given a file named "test.unknown" with content:
diff --git a/partcad-cli/src/partcad_cli/click/commands/export.py b/partcad-cli/src/partcad_cli/click/commands/export.py
index e07dc02cf..eccd7ee41 100644
--- a/partcad-cli/src/partcad_cli/click/commands/export.py
+++ b/partcad-cli/src/partcad_cli/click/commands/export.py
@@ -1,4 +1,5 @@
 import rich_click as click
+from partcad.context import Context
 import partcad.logging as logging
 import partcad.utils as pc_utils
 
@@ -70,7 +71,7 @@
 )
 @click.argument("object", type=str, required=False)  # Part (default), assembly or scene to test
 @click.pass_obj
-def cli(ctx, create_dirs, output_dir, format, package, recursive, sketch, interface, assembly, scene, object):
+def cli(ctx: Context, create_dirs, output_dir, format, package, recursive, sketch, interface, assembly, scene, object):
     with logging.Process("Export", "this"):
         ctx.option_create_dirs = create_dirs
         package = package if package is not None else ""
diff --git a/partcad-cli/src/partcad_cli/click/commands/render.py b/partcad-cli/src/partcad_cli/click/commands/render.py
index 3d01072bf..55a870ebb 100644
--- a/partcad-cli/src/partcad_cli/click/commands/render.py
+++ b/partcad-cli/src/partcad_cli/click/commands/render.py
@@ -8,6 +8,7 @@
 #
 
 import rich_click as click
+from partcad.context import Context
 import partcad.utils as pc_utils
 import partcad.logging as logging
 
@@ -79,7 +80,7 @@
 )
 @click.argument("object", type=str, required=False)  # Part (default), assembly or scene to test
 @click.pass_obj
-def cli(ctx, create_dirs, output_dir, format, package, recursive, sketch, interface, assembly, scene, object):
+def cli(ctx: Context, create_dirs, output_dir, format, package, recursive, sketch, interface, assembly, scene, object):
     with logging.Process("Render", "this"):
         ctx.option_create_dirs = create_dirs
         package = package if package is not None else "."
diff --git a/partcad-ide-vscode/bundled/tool/lsp_server.py b/partcad-ide-vscode/bundled/tool/lsp_server.py
index 426b3f330..8d8068f13 100644
--- a/partcad-ide-vscode/bundled/tool/lsp_server.py
+++ b/partcad-ide-vscode/bundled/tool/lsp_server.py
@@ -29,6 +29,7 @@
 from typing import Any, Optional, Sequence
 
 from lsp_server_pipe import *
+from partcad.part import Part
 
 
 # **********************************************************
@@ -346,22 +347,9 @@ def export_part(params: lsp.ExecuteCommandParams = None):
     def export_part_internal(package_name, part_name, params):
         global partcad, partcad_ctx
         with partcad.logging.Process("Export", package_name, part_name):
-            part = partcad_ctx.get_part(package_name + ":" + part_name, params)
+            part: Part = partcad_ctx.get_part(package_name + ":" + part_name, params)
             if part:
-                if exportType == "svg":
-                    part.render_svg(filepath=path)
-                if exportType == "png":
-                    part.render_png(filepath=path)
-                if exportType == "step":
-                    part.render_step(filepath=path)
-                if exportType == "stl":
-                    part.render_stl(filepath=path)
-                if exportType == "3mf":
-                    part.render_3mf(filepath=path)
-                if exportType == "threejs":
-                    part.render_threejs(filepath=path)
-                if exportType == "obj":
-                    part.render_obj(filepath=path)
+                part.render(partcad_ctx, exportType, filepath=path)
 
     th = threading.Thread(
         target=export_part_internal,
@@ -391,20 +379,7 @@ def export_assembly_internal(package_name, assembly_name, params):
         with partcad.logging.Process("Export", package_name, assembly_name):
             assembly = partcad_ctx.get_assembly(package_name + ":" + assembly_name, params)
             if assembly:
-                if exportType == "svg":
-                    assembly.render_svg(filepath=path)
-                if exportType == "png":
-                    assembly.render_png(filepath=path)
-                if exportType == "step":
-                    assembly.render_step(filepath=path)
-                if exportType == "stl":
-                    assembly.render_stl(filepath=path)
-                if exportType == "3mf":
-                    assembly.render_3mf(filepath=path)
-                if exportType == "threejs":
-                    assembly.render_threejs(filepath=path)
-                if exportType == "obj":
-                    assembly.render_obj(filepath=path)
+                assembly.render(partcad_ctx, exportType, filepath=path)
 
     th = threading.Thread(
         target=export_assembly_internal,
diff --git a/partcad-ide-vscode/requirements.txt b/partcad-ide-vscode/requirements.txt
index 0c0466400..ba4fd7776 100644
--- a/partcad-ide-vscode/requirements.txt
+++ b/partcad-ide-vscode/requirements.txt
@@ -126,10 +126,6 @@ certifi==2025.1.31 \
     # via
     #   requests
     #   sentry-sdk
-chardet==5.2.0 \
-    --hash=sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7 \
-    --hash=sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970
-    # via reportlab
 charset-normalizer==3.4.1 \
     --hash=sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537 \
     --hash=sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa \
@@ -224,10 +220,6 @@ charset-normalizer==3.4.1 \
     --hash=sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00 \
     --hash=sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616
     # via requests
-cssselect2==0.7.0 \
-    --hash=sha256:1ccd984dab89fc68955043aca4e1b03e0cf29cad9880f6e28e3ba7a74b14aa5a \
-    --hash=sha256:fd23a65bfd444595913f02fc71f6b286c29261e354c41d722ca7a261a49b5969
-    # via svglib
 deprecated==1.2.18 \
     --hash=sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d \
     --hash=sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec
@@ -373,146 +365,6 @@ lsprotocol==2023.0.1 \
     --hash=sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2 \
     --hash=sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d
     # via pygls
-lxml==5.3.0 \
-    --hash=sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e \
-    --hash=sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229 \
-    --hash=sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3 \
-    --hash=sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5 \
-    --hash=sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70 \
-    --hash=sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15 \
-    --hash=sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002 \
-    --hash=sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd \
-    --hash=sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22 \
-    --hash=sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf \
-    --hash=sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22 \
-    --hash=sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832 \
-    --hash=sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727 \
-    --hash=sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e \
-    --hash=sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30 \
-    --hash=sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f \
-    --hash=sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f \
-    --hash=sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51 \
-    --hash=sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4 \
-    --hash=sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de \
-    --hash=sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875 \
-    --hash=sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42 \
-    --hash=sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e \
-    --hash=sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6 \
-    --hash=sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391 \
-    --hash=sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc \
-    --hash=sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b \
-    --hash=sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237 \
-    --hash=sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4 \
-    --hash=sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86 \
-    --hash=sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f \
-    --hash=sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a \
-    --hash=sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8 \
-    --hash=sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f \
-    --hash=sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903 \
-    --hash=sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03 \
-    --hash=sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e \
-    --hash=sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99 \
-    --hash=sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7 \
-    --hash=sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab \
-    --hash=sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d \
-    --hash=sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22 \
-    --hash=sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492 \
-    --hash=sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b \
-    --hash=sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3 \
-    --hash=sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be \
-    --hash=sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469 \
-    --hash=sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f \
-    --hash=sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a \
-    --hash=sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c \
-    --hash=sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a \
-    --hash=sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4 \
-    --hash=sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94 \
-    --hash=sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442 \
-    --hash=sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b \
-    --hash=sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84 \
-    --hash=sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c \
-    --hash=sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9 \
-    --hash=sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1 \
-    --hash=sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be \
-    --hash=sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367 \
-    --hash=sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e \
-    --hash=sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21 \
-    --hash=sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa \
-    --hash=sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16 \
-    --hash=sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d \
-    --hash=sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe \
-    --hash=sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83 \
-    --hash=sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba \
-    --hash=sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040 \
-    --hash=sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763 \
-    --hash=sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8 \
-    --hash=sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff \
-    --hash=sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2 \
-    --hash=sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a \
-    --hash=sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b \
-    --hash=sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce \
-    --hash=sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c \
-    --hash=sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577 \
-    --hash=sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8 \
-    --hash=sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71 \
-    --hash=sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512 \
-    --hash=sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540 \
-    --hash=sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f \
-    --hash=sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2 \
-    --hash=sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a \
-    --hash=sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce \
-    --hash=sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e \
-    --hash=sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2 \
-    --hash=sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27 \
-    --hash=sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1 \
-    --hash=sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d \
-    --hash=sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1 \
-    --hash=sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330 \
-    --hash=sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920 \
-    --hash=sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99 \
-    --hash=sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff \
-    --hash=sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18 \
-    --hash=sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff \
-    --hash=sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c \
-    --hash=sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179 \
-    --hash=sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080 \
-    --hash=sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19 \
-    --hash=sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d \
-    --hash=sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70 \
-    --hash=sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32 \
-    --hash=sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a \
-    --hash=sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2 \
-    --hash=sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79 \
-    --hash=sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3 \
-    --hash=sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5 \
-    --hash=sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f \
-    --hash=sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d \
-    --hash=sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3 \
-    --hash=sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b \
-    --hash=sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753 \
-    --hash=sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9 \
-    --hash=sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957 \
-    --hash=sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033 \
-    --hash=sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb \
-    --hash=sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656 \
-    --hash=sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab \
-    --hash=sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b \
-    --hash=sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d \
-    --hash=sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd \
-    --hash=sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859 \
-    --hash=sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11 \
-    --hash=sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c \
-    --hash=sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a \
-    --hash=sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005 \
-    --hash=sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654 \
-    --hash=sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80 \
-    --hash=sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e \
-    --hash=sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec \
-    --hash=sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7 \
-    --hash=sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965 \
-    --hash=sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945 \
-    --hash=sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8
-    # via svglib
 markupsafe==3.0.2 \
     --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \
     --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \
@@ -704,79 +556,6 @@ packaging==24.2 \
     # via
     #   -r ./requirements.in
     #   opentelemetry-instrumentation
-pillow==11.1.0 \
-    --hash=sha256:015c6e863faa4779251436db398ae75051469f7c903b043a48f078e437656f83 \
-    --hash=sha256:0a2f91f8a8b367e7a57c6e91cd25af510168091fb89ec5146003e424e1558a96 \
-    --hash=sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65 \
-    --hash=sha256:2062ffb1d36544d42fcaa277b069c88b01bb7298f4efa06731a7fd6cc290b81a \
-    --hash=sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352 \
-    --hash=sha256:3362c6ca227e65c54bf71a5f88b3d4565ff1bcbc63ae72c34b07bbb1cc59a43f \
-    --hash=sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20 \
-    --hash=sha256:36ba10b9cb413e7c7dfa3e189aba252deee0602c86c309799da5a74009ac7a1c \
-    --hash=sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114 \
-    --hash=sha256:3a5fe20a7b66e8135d7fd617b13272626a28278d0e578c98720d9ba4b2439d49 \
-    --hash=sha256:3cdcdb0b896e981678eee140d882b70092dac83ac1cdf6b3a60e2216a73f2b91 \
-    --hash=sha256:4637b88343166249fe8aa94e7c4a62a180c4b3898283bb5d3d2fd5fe10d8e4e0 \
-    --hash=sha256:4db853948ce4e718f2fc775b75c37ba2efb6aaea41a1a5fc57f0af59eee774b2 \
-    --hash=sha256:4dd43a78897793f60766563969442020e90eb7847463eca901e41ba186a7d4a5 \
-    --hash=sha256:54251ef02a2309b5eec99d151ebf5c9904b77976c8abdcbce7891ed22df53884 \
-    --hash=sha256:54ce1c9a16a9561b6d6d8cb30089ab1e5eb66918cb47d457bd996ef34182922e \
-    --hash=sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c \
-    --hash=sha256:5bb94705aea800051a743aa4874bb1397d4695fb0583ba5e425ee0328757f196 \
-    --hash=sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756 \
-    --hash=sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861 \
-    --hash=sha256:73ddde795ee9b06257dac5ad42fcb07f3b9b813f8c1f7f870f402f4dc54b5269 \
-    --hash=sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1 \
-    --hash=sha256:7d33d2fae0e8b170b6a6c57400e077412240f6f5bb2a342cf1ee512a787942bb \
-    --hash=sha256:7fdadc077553621911f27ce206ffcbec7d3f8d7b50e0da39f10997e8e2bb7f6a \
-    --hash=sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081 \
-    --hash=sha256:837060a8599b8f5d402e97197d4924f05a2e0d68756998345c829c33186217b1 \
-    --hash=sha256:89dbdb3e6e9594d512780a5a1c42801879628b38e3efc7038094430844e271d8 \
-    --hash=sha256:8c730dc3a83e5ac137fbc92dfcfe1511ce3b2b5d7578315b63dbbb76f7f51d90 \
-    --hash=sha256:8e275ee4cb11c262bd108ab2081f750db2a1c0b8c12c1897f27b160c8bd57bbc \
-    --hash=sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5 \
-    --hash=sha256:93a18841d09bcdd774dcdc308e4537e1f867b3dec059c131fde0327899734aa1 \
-    --hash=sha256:9409c080586d1f683df3f184f20e36fb647f2e0bc3988094d4fd8c9f4eb1b3b3 \
-    --hash=sha256:96f82000e12f23e4f29346e42702b6ed9a2f2fea34a740dd5ffffcc8c539eb35 \
-    --hash=sha256:9aa9aeddeed452b2f616ff5507459e7bab436916ccb10961c4a382cd3e03f47f \
-    --hash=sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c \
-    --hash=sha256:a07dba04c5e22824816b2615ad7a7484432d7f540e6fa86af60d2de57b0fcee2 \
-    --hash=sha256:a3cd561ded2cf2bbae44d4605837221b987c216cff94f49dfeed63488bb228d2 \
-    --hash=sha256:a697cd8ba0383bba3d2d3ada02b34ed268cb548b369943cd349007730c92bddf \
-    --hash=sha256:a76da0a31da6fcae4210aa94fd779c65c75786bc9af06289cd1c184451ef7a65 \
-    --hash=sha256:a85b653980faad27e88b141348707ceeef8a1186f75ecc600c395dcac19f385b \
-    --hash=sha256:a8d65b38173085f24bc07f8b6c505cbb7418009fa1a1fcb111b1f4961814a442 \
-    --hash=sha256:aa8dd43daa836b9a8128dbe7d923423e5ad86f50a7a14dc688194b7be5c0dea2 \
-    --hash=sha256:ab8a209b8485d3db694fa97a896d96dd6533d63c22829043fd9de627060beade \
-    --hash=sha256:abc56501c3fd148d60659aae0af6ddc149660469082859fa7b066a298bde9482 \
-    --hash=sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe \
-    --hash=sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc \
-    --hash=sha256:b20be51b37a75cc54c2c55def3fa2c65bb94ba859dde241cd0a4fd302de5ae0a \
-    --hash=sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec \
-    --hash=sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3 \
-    --hash=sha256:b6123aa4a59d75f06e9dd3dac5bf8bc9aa383121bb3dd9a7a612e05eabc9961a \
-    --hash=sha256:bd165131fd51697e22421d0e467997ad31621b74bfc0b75956608cb2906dda07 \
-    --hash=sha256:bf902d7413c82a1bfa08b06a070876132a5ae6b2388e2712aab3a7cbc02205c6 \
-    --hash=sha256:c12fc111ef090845de2bb15009372175d76ac99969bdf31e2ce9b42e4b8cd88f \
-    --hash=sha256:c1eec9d950b6fe688edee07138993e54ee4ae634c51443cfb7c1e7613322718e \
-    --hash=sha256:c640e5a06869c75994624551f45e5506e4256562ead981cce820d5ab39ae2192 \
-    --hash=sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0 \
-    --hash=sha256:cfd5cd998c2e36a862d0e27b2df63237e67273f2fc78f47445b14e73a810e7e6 \
-    --hash=sha256:d3d8da4a631471dfaf94c10c85f5277b1f8e42ac42bade1ac67da4b4a7359b73 \
-    --hash=sha256:d44ff19eea13ae4acdaaab0179fa68c0c6f2f45d66a4d8ec1eda7d6cecbcc15f \
-    --hash=sha256:dd0052e9db3474df30433f83a71b9b23bd9e4ef1de13d92df21a52c0303b8ab6 \
-    --hash=sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547 \
-    --hash=sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9 \
-    --hash=sha256:e06695e0326d05b06833b40b7ef477e475d0b1ba3a6d27da1bb48c23209bf457 \
-    --hash=sha256:e1abe69aca89514737465752b4bcaf8016de61b3be1397a8fc260ba33321b3a8 \
-    --hash=sha256:e267b0ed063341f3e60acd25c05200df4193e15a4a5807075cd71225a2386e26 \
-    --hash=sha256:e5449ca63da169a2e6068dd0e2fcc8d91f9558aba89ff6d02121ca8ab11e79e5 \
-    --hash=sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab \
-    --hash=sha256:f189805c8be5ca5add39e6f899e6ce2ed824e65fb45f3c28cb2841911da19070 \
-    --hash=sha256:f7955ecf5609dee9442cbface754f2c6e541d9e6eda87fad7f7a989b0bdb9d71 \
-    --hash=sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9 \
-    --hash=sha256:fbd43429d0d7ed6533b25fc993861b8fd512c42d04514a0dd6337fb3ccf22761
-    # via reportlab
 propcache==0.2.1 \
     --hash=sha256:03ff9d3f665769b2a85e6157ac8b439644f2d7fd17615a82fa55739bc97863f4 \
     --hash=sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4 \
@@ -941,10 +720,6 @@ pyyaml==6.0.2 \
     # via
     #   -r ./requirements.in
     #   vyper-config
-reportlab==4.3.0 \
-    --hash=sha256:81e7bb207132c430cdb9d9f41cfdd1e0fbd1b0eb26a0f7def55d39c1680ad345 \
-    --hash=sha256:a90754589bea1c921a745aa981677d2d144f50c690800cda29aafae67c1a8d93
-    # via svglib
 requests==2.32.3 \
     --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
     --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
@@ -1012,15 +787,6 @@ smmap==5.0.2 \
     --hash=sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5 \
     --hash=sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e
     # via gitdb
-svglib==1.5.1 \
-    --hash=sha256:3ae765d3a9409ee60c0fb4d24c2deb6a80617aa927054f5bcd7fc98f0695e587
-    # via -r ./requirements.in
-tinycss2==1.4.0 \
-    --hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
-    --hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
-    # via
-    #   cssselect2
-    #   svglib
 toml==0.10.2 \
     --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
     --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
diff --git a/partcad/src/partcad/actions/part/convert.py b/partcad/src/partcad/actions/part/convert.py
index e3609b9ea..167eb05bf 100644
--- a/partcad/src/partcad/actions/part/convert.py
+++ b/partcad/src/partcad/actions/part/convert.py
@@ -5,19 +5,7 @@
 from partcad.project import Project
 from partcad.utils import resolve_resource_path
 
-
-EXTENSION_MAPPING = {
-    "step": "step",
-    "brep": "brep",
-    "stl": "stl",
-    "3mf": "3mf",
-    "threejs": "json",
-    "obj": "obj",
-    "gltf": "gltf",
-    "cadquery": "py",
-    "build123d": "py",
-    "scad": "scad",
-}
+from partcad.shape import EXTENSION_MAPPING
 
 SHALLOW_COPY_SUFFICIENT_TYPES = ["alias", "enrich"]
 
@@ -147,13 +135,18 @@ def perform_conversion(project: Project, part_name: str, original_type: str,
                        output_dir: Optional[str], dependencies_list: list = []) -> Path:
     """Handles file conversion and updates project configuration."""
     new_ext = EXTENSION_MAPPING.get(target_format, target_format)
+
     if output_dir:
-        output_path = Path(output_dir) / f"{part_name}.{new_ext}"
+        out_dir = Path(output_dir).resolve()
     elif "path" in part_config:
-        existing_path = Path(project.path) / part_config["path"]
-        output_path = existing_path.with_name(f"{part_name}.{new_ext}")
+        out_dir = (Path(project.path) / Path(part_config["path"])).parent
+    else:
+        out_dir = Path(project.path)
+
+    if not output_dir and "path" in part_config and target_format == original_type:
+        output_path = (Path(project.path) / Path(part_config["path"])).resolve()
     else:
-        output_path = Path(project.path) / f"{part_name}.{new_ext}"
+        output_path = out_dir / f"{part_name}.{new_ext}"
 
     if output_path.exists() and source_path.samefile(output_path):
         pc_logging.warning(f"Skipping conversion: source and target paths are identical ({source_path}).")
@@ -167,10 +160,12 @@ def perform_conversion(project: Project, part_name: str, original_type: str,
             shutil.copy2(source_path, output_path)
             dependencies_list += copy_dependencies(project, part_config, output_dir)
         else:
-            project.convert(
+            pc_logging.info(target_format)
+            project.render(
                 sketches=[], interfaces=[], parts=[part_name], assemblies=[],
-                target_format=target_format, output_dir=str(output_path)
+                format=target_format, output_dir=str(out_dir)
             )
+            return output_path
 
     if not output_path.exists():
         raise RuntimeError(f"Conversion failed: output file '{output_path}' was not created.")
@@ -276,11 +271,14 @@ def convert_part_action(project: Project, object_name: str, target_format: Optio
 
         if output_dir:
             try:
-                final_config_path = final_path.relative_to(output_dir)
+                final_config_path = final_path.relative_to(Path(output_dir))
             except ValueError:
                 final_config_path = final_path.name
         else:
-            final_config_path = final_path.name
+            try:
+                final_config_path = final_path.relative_to(project.path)
+            except ValueError:
+                final_config_path = final_path.name
 
         project.update_part_config(part_name, {"type": target_format, "path": str(final_config_path)})
         pc_logging.debug(f"Final updated configuration for '{part_name}': {final_config_path}")
diff --git a/partcad/src/partcad/adhoc/convert.py b/partcad/src/partcad/adhoc/convert.py
index 32cd28b12..839bc8525 100644
--- a/partcad/src/partcad/adhoc/convert.py
+++ b/partcad/src/partcad/adhoc/convert.py
@@ -47,8 +47,9 @@ def convert_cad_file(input_filename: str, input_type: str, output_filename: str,
         if part.errors:
             raise RuntimeError(f"Failed to load the input part: {part.errors}")
 
-        # Export the part to the desired output format
-        export_part(part, output_filename, output_type, ctx)
+        # Render the part to the desired output format
+        part.render(ctx=ctx, format_name=output_type, project=project, filepath=output_filename)
+
     except Exception as e:
         raise RuntimeError(f"Failed to convert: {e.with_traceback(None)}")
     finally:
@@ -73,37 +74,3 @@ def generate_partcad_config(temp_dir: Path, input_type: str, temp_input_path: Pa
     """
     with open(config_path, "w", encoding="utf-8") as config_file:
         config_file.write(config_content)
-
-
-def export_part(part: Part, output_filename: str | Path, output_type: str, ctx: Context) -> None:
-    """
-    Export a part to the desired format.
-
-    Args:
-        part (Part): The part to export.
-        output_filename (Path | str): Path to save the exported file.
-        output_type (str): Format of the exported file.
-        ctx (Context): The context required for export methods.
-    """
-    export_methods: dict[str, str] = {
-        "step": "render_step",
-        "brep": "render_brep",
-        "stl": "render_stl",
-        "3mf": "render_3mf",
-        "threejs": "render_threejs",
-        "obj": "render_obj",
-        "gltf": "render_gltf",
-    }
-
-    export_method = export_methods.get(output_type)
-    if not export_method:
-        raise ValueError(f"Unsupported export format: {output_type}")
-
-    if not hasattr(part, export_method):
-        raise RuntimeError(f"Part does not support export to '{output_type}'.")
-
-    # Ensure output filename is a string
-    output_filename = str(output_filename)
-
-    # Call the appropriate export method, passing the required context and filepath
-    getattr(part, export_method)(ctx=ctx, filepath=output_filename)
diff --git a/partcad/src/partcad/part_factory_feature_ai.py b/partcad/src/partcad/part_factory_feature_ai.py
index d0a3b87cd..f4efeac19 100644
--- a/partcad/src/partcad/part_factory_feature_ai.py
+++ b/partcad/src/partcad/part_factory_feature_ai.py
@@ -17,6 +17,8 @@
 from git import Git
 from datetime import datetime
 
+from partcad.part import Part
+
 from .ai import Ai
 from . import logging as pc_logging
 from .utils import total_size
@@ -684,7 +686,7 @@ def _render_image(self, script, candidate_id):
         part.path = source_path  # Set the path to the temporary script file
         pc_logging.debug("Part created: %.2f KB" % (total_size(part) / 1024.0))
 
-        def render(part):
+        def render(part: Part):
             nonlocal exception_text
             try:
                 coro = part.get_wrapped(self.ctx)
@@ -696,7 +698,8 @@ def render(part):
                         part.show(self.ctx)
                     except Exception as e:
                         pass
-                    part.render_png(self.ctx, None, output_path)
+                    # part.render_png(self.ctx, None, output_path)
+                    part.render(self.ctx, "png", self.project, output_path)
             except Exception as e:
                 part.error("Failed to render the image: %s" % e)
                 exception_text += f"Exception:\n{str(e)}\n"
diff --git a/partcad/src/partcad/part_factory_step.py b/partcad/src/partcad/part_factory_step.py
index c03e717f2..3fef6a6fa 100644
--- a/partcad/src/partcad/part_factory_step.py
+++ b/partcad/src/partcad/part_factory_step.py
@@ -33,134 +33,36 @@
 
 
 class PartFactoryStep(PartFactoryFile):
-    # How many STEP files should be loaded directly simultaneously (without
-    # launching a subprocess), no matter the file size.
-    MIN_SIMPLE_INFLIGHT = 1
-
-    # How big of a STEP file is needed to consider launching a sub-process.
-    MIN_SUBPROCESS_FILE_SIZE = 64 * 1024
-
-    # lock is used to protect changes to the static class members
     lock = threading.Lock()
-    # How many of the current instances are in which mode?
-    count_inflight_simple = 0
-    count_inflight_subprocess = 0
 
     def __init__(self, ctx, source_project, target_project, config, can_create=False):
         with pc_logging.Action("InitSTEP", target_project.name, config["name"]):
-            super().__init__(
-                ctx,
-                source_project,
-                target_project,
-                config,
-                extension=".step",
-                can_create=can_create,
-            )
-            # Complement the config object here if necessary
+            super().__init__(ctx, source_project, target_project, config, extension=".step", can_create=can_create)
             self._create(config)
 
-            self.runtime = None
+            self.runtime = self.ctx.get_python_runtime("3.11")
 
     async def instantiate(self, part):
         await super().instantiate(part)
-
         with pc_logging.Action("STEP", part.project_name, part.name):
-            do_subprocess = False
-
-            pc_logging.debug("STEP path: %s" % part.path)
-            file_size = os.path.getsize(part.path)
-
-            with PartFactoryStep.lock:
-                if (
-                    PartFactoryStep.count_inflight_simple < PartFactoryStep.MIN_SIMPLE_INFLIGHT
-                    or file_size < PartFactoryStep.MIN_SUBPROCESS_FILE_SIZE
-                ):
-                    PartFactoryStep.count_inflight_simple += 1
-                else:
-                    do_subprocess = True
-                    PartFactoryStep.count_inflight_subprocess += 1
-                    if self.runtime == None:
-                        # We don't care about customer preferences much here
-                        # as this is expected to be hermetic.
-                        # Stick to the version where CadQuery is known to work.
-                        self.runtime = self.ctx.get_python_runtime("3.11")
-
-            if do_subprocess:
-                wrapper_path = wrapper.get("step.py")
-
-                request = {"build_parameters": {}}
-                register_ocp_helper()
-                picklestring = pickle.dumps(request)
-                request_serialized = base64.b64encode(picklestring).decode()
-
-                response_serialized, errors = await self.runtime.run_async(
-                    [
-                        wrapper_path,
-                        os.path.abspath(part.path),
-                        os.path.abspath(self.project.config_dir),
-                    ],
-                    request_serialized,
-                )
-                sys.stderr.write(errors)
-
-                response = base64.b64decode(response_serialized)
-                register_ocp_helper()
-                result = pickle.loads(response)
-
-                if not result["success"]:
-                    pc_logging.error(result["exception"])
-                    raise Exception(result["exception"])
-                shape = result["shape"]
-            else:
-                # Thanks for OCP deficiencies in handling of GIL,
-                # as soon as 'importStep()' is called, all of other Python
-                # threads are going to be frozen, so we need to give other
-                # threads an opportunity to spawn processes to stay busy during
-                # the 'importStep()' call.
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                time.sleep(0.0001)
-                # TODO(clairbee): remove sleep calls when GIL is fixed in CQ
-
-                reader = STEPControl_Reader()
-
-                readStatus = reader.ReadFile(part.path)
-                if readStatus != OCP.IFSelect.IFSelect_RetDone:
-                    raise ValueError("STEP File could not be loaded")
-                for i in range(reader.NbRootsForTransfer()):
-                    reader.TransferRoot(i + 1)
-
-                builder = TopoDS_Builder()
-                compound = TopoDS_Compound()
-                builder.MakeCompound(compound)
-                for i in range(reader.NbShapes()):
-                    occ_shape = reader.Shape(i + 1)
-                    builder.Add(compound, occ_shape)
-                shape = compound
-
-            with PartFactoryStep.lock:
-                if do_subprocess:
-                    PartFactoryStep.count_inflight_subprocess -= 1
-                else:
-                    PartFactoryStep.count_inflight_simple -= 1
+            wrapper_path = wrapper.get("step.py")
+            request = {"build_parameters": {}}
+            register_ocp_helper()
+            picklestring = pickle.dumps(request)
+            request_serialized = base64.b64encode(picklestring).decode()
+
+            response_serialized, errors = await self.runtime.run_async(
+                [wrapper_path, os.path.abspath(part.path), os.path.abspath(self.project.config_dir)],
+                request_serialized,
+            )
+            sys.stderr.write(errors)
+            response = base64.b64decode(response_serialized)
+            register_ocp_helper()
+            result = pickle.loads(response)
+            if not result["success"]:
+                pc_logging.error(result["exception"])
+                raise Exception(result["exception"])
+            shape = result["shape"]
 
             self.ctx.stats_parts_instantiated += 1
-
             return shape
diff --git a/partcad/src/partcad/part_factory_stl.py b/partcad/src/partcad/part_factory_stl.py
index 25406286d..16533b132 100644
--- a/partcad/src/partcad/part_factory_stl.py
+++ b/partcad/src/partcad/part_factory_stl.py
@@ -8,92 +8,51 @@
 #
 
 import os
-
-import build123d as b3d
+import base64
+import pickle
+import sys
 
 from .part_factory_file import PartFactoryFile
 from . import logging as pc_logging
+from . import wrapper
 
 
 class PartFactoryStl(PartFactoryFile):
     def __init__(self, ctx, source_project, target_project, config):
         with pc_logging.Action("InitSTL", target_project.name, config["name"]):
-            super().__init__(
-                ctx,
-                source_project,
-                target_project,
-                config,
-                extension=".stl",
-            )
-            # Complement the config object here if necessary
+            super().__init__(ctx, source_project, target_project, config, extension=".stl")
             self._create(config)
 
     async def instantiate(self, part):
         await super().instantiate(part)
 
         with pc_logging.Action("STL", part.project_name, part.name):
-            try:
-                shape = b3d.Mesher().read(self.path)[0].wrapped
-            except:
-                # First, make sure it's not a problem in Mesher
-                # TODO(clairbee): ascii stl files end up here too, but probably don't need to
-
-                from OCP.RWStl import RWStl
-                from OCP.BRep import BRep_Builder
-                from OCP.TopoDS import TopoDS_Face, TopoDS_Shell, TopoDS_Solid
-                from OCP.TopAbs import TopAbs_FACE
-                from OCP.BRepCheck import BRepCheck_Shell, BRepCheck_Solid, BRepCheck_Status
-                from OCP.BRepGProp import BRepGProp
-                from OCP.GProp import GProp_GProps
+            wrapper_path = wrapper.get("stl.py")
+            request = {}
 
-                builder = BRep_Builder()
-                reader = RWStl.ReadFile_s(os.fsdecode(self.path))
+            picklestring = pickle.dumps(request)
+            request_serialized = base64.b64encode(picklestring).decode()
 
-                if not reader:
-                    raise RuntimeError("Failed to read the STL file")
-
-                # Build and check the faces
-                face = TopoDS_Face()
-                builder.MakeFace(face, reader)
-                if face.IsNull():
-                    raise RuntimeError("Failed to read the STL file: Null")
-                if face.ShapeType() != TopAbs_FACE:
-                    raise RuntimeError("Failed to read the STL file: Wrong shape type")
-                if face.Infinite():
-                    raise RuntimeError("Failed to read the STL file: Infinite")
+            runtime = self.ctx.get_python_runtime("3.11")
+            response_serialized, errors = await runtime.run_async(
+                [wrapper_path, os.path.abspath(self.path)],
+                request_serialized,
+            )
 
-                # Build and check the shell
-                shell = TopoDS_Shell()
-                builder.MakeShell(shell)
-                builder.Add(shell, face)
-                shell_check = BRepCheck_Shell(shell)
-                shell_check_result = shell_check.Closed()
-                if shell_check_result != BRepCheck_Status.BRepCheck_NoError:
-                    if shell_check_result == BRepCheck_Status.BRepCheck_NotClosed:
-                        raise RuntimeError("Failed to read the STL file: Shell is not closed")
-                    else:
-                        raise RuntimeError("Failed to read the STL file: Shell check failed")
+            sys.stderr.write(errors)
 
-                # Build and check the solid
-                solid = TopoDS_Solid()
-                builder.MakeSolid(solid)
-                builder.Add(solid, shell)
-                if solid.IsNull():
-                    raise RuntimeError("Failed to read the STL file: Null solid")
-                if solid.Infinite():
-                    raise RuntimeError("Failed to read STL file: Infinite solid")
-                solid_check = BRepCheck_Solid(solid)
-                solid_check.Minimum()
-                statuses = solid_check.Status()
-                if statuses.Size() > 0 and statuses.First() != BRepCheck_Status.BRepCheck_NoError:
-                    raise RuntimeError("Failed to read the STL file: Solid check failed: %s" % statuses.First())
-                gprops = GProp_GProps()
-                BRepGProp.VolumeProperties_s(solid, gprops)
-                if gprops.Mass() <= 0:
-                    raise RuntimeError("Failed to read the STL file: Zero or negative volume")
+            try:
+                response = base64.b64decode(response_serialized)
+                result = pickle.loads(response)
+            except Exception as e:
+                pc_logging.error(f"Failed to deserialize STL wrapper response: {e}")
+                raise
 
-                shape = solid
+            if not result.get("success", False):
+                pc_logging.error(result.get("exception", "Unknown error"))
+                raise Exception(result.get("exception", "Unknown error"))
 
+            shape = result.get("shape")
             self.ctx.stats_parts_instantiated += 1
 
             return shape
diff --git a/partcad/src/partcad/project.py b/partcad/src/partcad/project.py
index 25a50f4ae..f65a03675 100644
--- a/partcad/src/partcad/project.py
+++ b/partcad/src/partcad/project.py
@@ -15,12 +15,15 @@
 import re
 
 # from pprint import pformat
+from rich_click import Path
 import ruamel.yaml
 import threading
 import typing
 
 from typing import Optional, List
 
+from partcad.shape import Shape
+
 from . import consts
 from . import factory
 from . import logging as pc_logging
@@ -64,6 +67,7 @@
 if TYPE_CHECKING:
     from partcad.context import Context
 
+
 class Project(project_config.Configuration):
 
     class InterfaceLock(object):
@@ -1324,188 +1328,107 @@ def test_log_wrapper(self, ctx, tests=[]) -> bool:
 
     async def render_async(
         self,
-        sketches=None,
-        interfaces=None,
-        parts=None,
-        assemblies=None,
-        format=None,
-        output_dir=None,
+        sketches: Optional[List] = None,
+        interfaces: Optional[List] = None,
+        parts: Optional[List] = None,
+        assemblies: Optional[List] = None,
+        format: Optional[str] = None,
+        output_dir: Optional[Path] = None,
     ):
         with pc_logging.Action("RenderPkg", self.name):
             # Override the default output_dir.
             # TODO(clairbee): pass the preference downstream without making a
             # persistent change.
-            if not output_dir is None:
-                if not "render" in self.config_obj or self.config_obj["render"] is None:
-                    self.config_obj["render"] = {}
-                self.config_obj["render"]["output_dir"] = output_dir
-
-            render = self.config_obj.get("render", {})
-            if render is None:
-                render = {}
-
-            # Enumerating all parts and assemblies
-            if sketches is None:
-                sketches = []
-                if "sketches" in self.config_obj and not self.config_obj["sketches"] is None:
-                    sketches = self.config_obj["sketches"].keys()
-            if interfaces is None:
-                interfaces = []
-                if "interfaces" in self.config_obj and not self.config_obj["interfaces"] is None:
-                    interfaces = self.config_obj["interfaces"].keys()
-            if parts is None:
-                parts = []
-                if "parts" in self.config_obj and not self.config_obj["parts"] is None:
-                    parts = self.config_obj["parts"].keys()
-            if assemblies is None:
-                assemblies = []
-                if "assemblies" in self.config_obj and not self.config_obj["assemblies"] is None:
-                    assemblies = self.config_obj["assemblies"].keys()
-
-            # Enumerate
-            shapes = []
-            for name in sketches:
-                shape = self.get_sketch(name)
-                shapes.append(shape)
-            for name in interfaces:
-                shape = self.get_interface(name)
-                # TODO(clairbee): interfaces are not yet renderable.
-                # shapes.append(shape)
-            for name in parts:
-                shape = self.get_part(name)
-                shapes.append(shape)
-            for name in assemblies:
-                shape = self.get_assembly(name)
-                shapes.append(shape)
-
-            # Render
-            tasks = []
 
-            def _should_render_format(
-                format_name: str, shape_render: dict, current_format: typing.Optional[str], shape_kind: str
-            ) -> bool:
-                """Helper function to determine if a format should be rendered"""
-                plural_shape_kind = {
-                    "part": "parts",
-                    "assembly": "assemblies",
-                    "sketch": "sketches",
-                    "interface": "interfaces",
-                    "providers": "providers",
-                }
-                if (
-                    format_name in shape_render
-                    and shape_render[format_name] is not None
-                    and not isinstance(shape_render[format_name], str)
-                    and plural_shape_kind.get(shape_kind, None) in shape_render.get(format_name, {}).get("exclude", [])
-                ):
-                    return False
-                return (current_format is None and format_name in shape_render) or (
-                    current_format is not None and current_format == format_name
-                )
+            if output_dir:
+                self.config_obj.setdefault("render", {})["output_dir"] = output_dir
 
-            # Map of format names to their corresponding async render functions
-            render_formats = {
-                "svg": "render_svg_async",
-                "png": "render_png_async",
-                "step": "render_step_async",
-                "stl": "render_stl_async",
-                "3mf": "render_3mf_async",
-                "threejs": "render_threejs_async",
-                "obj": "render_obj_async",
-                "gltf": "render_gltf_async",
-                "brep": "render_brep_async",
-            }
+            render = self.config_obj.get("render", {})
+            shapes: List[Shape] = self._enumerate_shapes(sketches, interfaces, parts, assemblies)
 
             if None in shapes:
                 raise EmptyShapesError
 
+            tasks = []
+            render_formats = ["svg", "png", "step", "stl", "3mf", "threejs", "obj", "gltf", "brep"]
+
             for shape in shapes:
-                shape_render = copy.copy(render)
-                if "render" in shape.config and shape.config["render"] is not None:
-                    shape_render = render_cfg_merge(shape_render, shape.config["render"])
-
-                # Dynamically check and add tasks for each render format
-                for format_name, render_func_name in render_formats.items():
-                    if _should_render_format(format_name, shape_render, format, shape.kind):
-                        if hasattr(shape, render_func_name):
-                            # skip rendering objects that are not finalized
-                            if not hasattr(shape, "finalized") or shape.finalized:
-                                render_func = getattr(shape, render_func_name)
-                                tasks.append(render_func(self.ctx, self))
-                        else:
-                            pc_logging.warn(f"Shape {shape.kind} does not support {format_name} rendering")
+
+                if not shape.finalized:
+                    pc_logging.warning(f"{shape.name} is not finalized")
+                    continue
+
+                shape_render = render_cfg_merge(copy.copy(render), shape.config.get("render", {}))
+                for format_name in render_formats:
+                    if self._should_render_format(format_name, shape_render, format, shape.kind):
+                        tasks.append(
+                            shape.render_async(
+                                ctx=self.ctx,
+                                format_name=format_name,
+                                project=self,
+                                filepath=None,
+                            )
+                        )
 
             await asyncio.gather(*tasks)
 
             if format == "readme" or (format is None and "readme" in render):
                 self.render_readme_async(render, output_dir)
 
+    def _enumerate_shapes(self, sketches, interfaces, parts, assemblies):
+        def get_keys(name):
+            return list(self.config_obj.get(name, {}).keys()) if name in self.config_obj else []
+
+        sketches = sketches or get_keys("sketches")
+        # interfaces = sketches or get_keys("interfaces")
+        parts = parts or get_keys("parts")
+        assemblies = assemblies or get_keys("assemblies")
+
+        shapes = []
+        for name in sketches:
+            shapes.append(self.get_sketch(name))
+        for name in parts:
+            shapes.append(self.get_part(name))
+        for name in assemblies:
+            shapes.append(self.get_assembly(name))
+        # TODO(clairbee): interfaces are not yet renderable.
+        # for name in interfaces: shapes.append(self.get_interface(name))
+
+        return shapes
+
+    def _should_render_format(
+        self, format_name: str, shape_render: dict, current_format: typing.Optional[str], shape_kind: str
+    ) -> bool:
+        """Helper function to determine if a format should be rendered"""
+        plural_shape_kind = {
+            "part": "parts",
+            "assembly": "assemblies",
+            "sketch": "sketches",
+            "interface": "interfaces",
+            "providers": "providers",
+        }
+        if (
+            format_name in shape_render
+            and shape_render[format_name] is not None
+            and not isinstance(shape_render[format_name], str)
+            and plural_shape_kind.get(shape_kind, None) in shape_render.get(format_name, {}).get("exclude", [])
+        ):
+            return False
+        return (current_format is None and format_name in shape_render) or (
+            current_format is not None and current_format == format_name
+        )
+
     def render(
         self,
-        sketches=None,
-        interfaces=None,
-        parts=None,
-        assemblies=None,
-        format=None,
-        output_dir=None,
+        sketches: Optional[list] = None,
+        interfaces: Optional[list] = None,
+        parts: Optional[list] = None,
+        assemblies: Optional[list] = None,
+        format: Optional[str] = None,
+        output_dir: Optional[Path] = None,
     ):
         asyncio.run(self.render_async(sketches, interfaces, parts, assemblies, format, output_dir))
 
-    async def async_convert(
-        self,
-        sketches: Optional[List[str]] = None,
-        interfaces: Optional[List[str]] = None,
-        parts: Optional[List[str]] = None,
-        assemblies: Optional[List[str]] = None,
-        target_format: Optional[str] = None,
-        output_dir: Optional[str] = None,
-    ) -> None:
-        """Asynchronously convert specified objects to a target format."""
-        if not target_format:
-            raise ValueError("Target format must be specified for conversion.")
-
-        with pc_logging.Action("Convert", self.name):
-            shapes = []
-            if sketches:
-                shapes.extend(self.get_sketch(name) for name in sketches)
-            if interfaces:
-                shapes.extend(self.get_interface(name) for name in interfaces)
-            if parts:
-                shapes.extend(self.get_part(name) for name in parts)
-            if assemblies:
-                shapes.extend(self.get_assembly(name) for name in assemblies)
-
-            pc_logging.info(f"Converting {len(shapes)} object(s) to '{target_format}'.")
-
-            # Prepare async tasks for rendering
-            tasks = []
-            for shape in shapes:
-                render_method = f"render_{target_format}_async"
-                if hasattr(shape, render_method):
-                    tasks.append(getattr(shape, render_method)(self.ctx, self, output_dir))
-                else:
-                    pc_logging.warn(f"Skipping '{shape.name}': {target_format} format not supported")
-
-            try:
-                await asyncio.gather(*tasks)
-            except Exception as e:
-                # Raise a RuntimeError with additional info if any rendering task fails
-                raise RuntimeError(f"Failed to convert to {target_format}: {str(e)}") from e
-
-
-    def convert(
-        self,
-        sketches: Optional[List[str]] = None,
-        interfaces: Optional[List[str]] = None,
-        parts: Optional[List[str]] = None,
-        assemblies: Optional[List[str]] = None,
-        target_format: Optional[str] = None,
-        output_dir: Optional[str] = None,
-    ) -> None:
-        """Synchronous wrapper for async_convert."""
-        asyncio.run(self.async_convert(sketches, interfaces, parts, assemblies, target_format, output_dir))
-
-
     def render_readme_async(self, render_cfg, output_dir):
         if output_dir is None:
             output_dir = self.config_dir
diff --git a/partcad/src/partcad/provider_factory_manufacturer.py b/partcad/src/partcad/provider_factory_manufacturer.py
index 8ca3dfd4f..2b86fa89d 100644
--- a/partcad/src/partcad/provider_factory_manufacturer.py
+++ b/partcad/src/partcad/provider_factory_manufacturer.py
@@ -9,6 +9,8 @@
 
 import tempfile
 
+from partcad.part import Part
+
 from .provider_request_caps import ProviderRequestCaps
 from .provider_request_order import ProviderRequestOrder
 from .provider_request_quote import ProviderRequestQuote
@@ -74,9 +76,9 @@ async def load(self, cart_item: ProviderCartItem):
         caps = await self.provider.get_caps()
         # TODO(clairbee): Make the below more generic
         if "formats" in caps and "step" in caps["formats"]:
-            part = self.ctx.get_part(cart_item.name)
+            part: Part = self.ctx.get_part(cart_item.name)
             filepath = tempfile.mktemp(".step")
-            await part.render_step_async(self.ctx, filepath=filepath)
+            await part.render_async(self.ctx, format_name="step", filepath=filepath)
             with open(filepath, "rb") as f:
                 step = f.read()
             cart_item.add_binary("step", step)
diff --git a/partcad/src/partcad/shape.py b/partcad/src/partcad/shape.py
index 1d36ade13..8babde618 100644
--- a/partcad/src/partcad/shape.py
+++ b/partcad/src/partcad/shape.py
@@ -6,6 +6,9 @@
 #
 # Licensed under Apache License, Version 2.0.
 
+from __future__ import annotations
+from typing import TYPE_CHECKING
+
 import asyncio
 import base64
 import copy
@@ -15,6 +18,7 @@
 import sys
 import tempfile
 import threading
+from typing import Optional
 
 from .cache_hash import CacheHash
 from .render import *
@@ -25,13 +29,32 @@
 from .sync_threads import threadpool_manager
 from . import wrapper
 
+if TYPE_CHECKING:
+    from partcad.context import Context
+    from partcad.project import Project
+
 sys.path.append(os.path.join(os.path.dirname(__file__), "wrappers"))
 from ocp_serialize import register as register_ocp_helper
 
 
+EXTENSION_MAPPING = {
+    "step": "step",
+    "brep": "brep",
+    "stl": "stl",
+    "3mf": "3mf",
+    "threejs": "json",
+    "obj": "obj",
+    "gltf": "json",
+    "cadquery": "py",
+    "build123d": "py",
+    "scad": "scad",
+}
+
+
 class Shape(ShapeConfiguration):
     name: str
     desc: str
+    kind: str
     requirements: dict | list | str
     svg_path: str
     svg_url: str
@@ -407,526 +430,137 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
-
-    async def render_png_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
+    async def render_async(
+        self, ctx: Context, format_name: str, project: Optional[Project] = None, filepath=None, **kwargs
     ) -> None:
-        with pc_logging.Action("RenderPNG", self.project_name, self.name):
-            obj = await self.get_wrapped(ctx)
-            if obj is None:
-                # pc_logging.error("The shape failed to instantiate")
-                self.svg_path = None
-                return
-
-            png_opts, filepath = self.render_getopts("png", ".png", project, filepath)
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+            "svg": ["cadquery-ocp==7.7.2", "ocpsvg==0.3.4", "build123d==0.8.0"],
+            "png": [
+                "cadquery-ocp==7.7.2",
+                "ocpsvg==0.3.4",
+                "build123d==0.8.0",
+                "svglib==1.5.1",
+                "reportlab",
+                "rlpycairo==0.3.0",
+            ],
+            "brep": ["cadquery-ocp==7.7.2"],
+            "step": ["cadquery-ocp==7.7.2"],
+            "stl": ["cadquery-ocp==7.7.2"],
+            "obj": ["cadquery-ocp==7.7.2"],
+            "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+            "gltf": ["cadquery-ocp==7.7.2"],
+            "threejs": ["cadquery-ocp==7.7.2"],
+        }
 
-            if width is None:
-                if "width" in png_opts and not png_opts["width"] is None:
-                    width = png_opts["width"]
-                else:
-                    width = DEFAULT_RENDER_WIDTH
-            if height is None:
-                if "height" in png_opts and not png_opts["height"] is None:
-                    height = png_opts["height"]
-                else:
-                    height = DEFAULT_RENDER_HEIGHT
-            if line_weight is None:
-                if "lineWeight" in png_opts and not png_opts["lineWeight"] is None:
-                    line_weight = png_opts["lineWeight"]
-                else:
-                    line_weight = 1.0
-            if viewport_origin is None:
-                if "viewportOrigin" in png_opts and not png_opts["viewportOrigin"] is None:
-                    viewport_origin = png_opts["viewportOrigin"]
-                else:
-                    viewport_origin = [100, -100, 100]
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
 
-            if not project is None:
-                project.ctx.ensure_dirs_for_file(filepath)
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
 
-            wrapper_path = wrapper.get("render_png.py")
-            request = {
-                "wrapped": obj,
-                "width": width,
-                "height": height,
-                "line_weight": line_weight,
-                "viewport_origin": viewport_origin,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
-
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("svglib==1.5.1")
-            await runtime.ensure_async("reportlab")
-            await runtime.ensure_async("rlpycairo==0.3.0")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
-
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-            if not result["success"]:
-                pc_logging.error("RenderPNG failed: %s:%s: %s" % (self.project_name, self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception("RenderPNG exception: %s" % result["exception"])
-
-    def render_png(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        asyncio.run(self.render_png_async(ctx, project, filepath, width, height, line_weight, viewport_origin))
-
-    async def render_step_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSTEP", self.project_name, self.name):
-            step_opts, filepath = self.render_getopts("step", ".step", project, filepath)
+            if format_name == "gltf":
+                obj = await self.get_build123d(ctx)
+            else:
+                obj = await self.get_wrapped(ctx)
 
-            obj = await self.get_wrapped(ctx)
             if obj is None:
-                raise exception.PartIsEmptyOrFailed(self.name)
-
-            def do_render_step() -> None:
-                nonlocal project, filepath, obj
-                from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
-                from OCP.Interface import Interface_Static
-
-                if not project is None:
-                    project.ctx.ensure_dirs_for_file(filepath)
-
-                pcurves = 1
-                if "write_pcurves" in step_opts and not step_opts["write_pcurves"]:
-                    pcurves = 0
-                precision_mode = step_opts.get("precision_mode", 0)
-
-                writer = STEPControl_Writer()
-                Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves)
-                Interface_Static.SetIVal_s("write.precision.mode", precision_mode)
-                writer.Transfer(obj, STEPControl_AsIs)
-                writer.Write(filepath)
-
-            await threadpool_manager.run(do_render_step)
-
-    def render_step(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_step_async(ctx, project, filepath))
-
-    async def render_brep_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ):
-        with pc_logging.Action("RenderBREP", self.project_name, self.name):
-            brep_opts, filepath = self.render_getopts("brep", ".brep", project, filepath)
-
-            obj = await self.get_wrapped(ctx)
-
-            def do_render_brep() -> None:
-                nonlocal project, filepath, obj
-                from OCP.BRepTools import BRepTools
-
-                if not project is None:
-                    project.ctx.ensure_dirs_for_file(filepath)
-
-                brep_writer = BRepTools()
-                with open(filepath, "wb") as brep_file:
-                    brep_writer.Write_s(obj, brep_file)
-
-            await threadpool_manager.run(do_render_brep)
-
-    def render_brep(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_brep_async(ctx, project, filepath))
-
-    async def render_stl_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-        ascii=None,
-    ) -> None:
-        with pc_logging.Action("RenderSTL", self.project_name, self.name):
-            stl_opts, filepath = self.render_getopts("stl", ".stl", project, filepath)
-
-            if tolerance is None:
-                if "tolerance" in stl_opts and not stl_opts["tolerance"] is None:
-                    tolerance = float(stl_opts["tolerance"])
-                else:
-                    tolerance = 0.1
-
-            if angularTolerance is None:
-                if "angularTolerance" in stl_opts and not stl_opts["angularTolerance"] is None:
-                    angularTolerance = float(stl_opts["angularTolerance"])
-                else:
-                    angularTolerance = 0.1
-
-            if ascii is None:
-                if "ascii" in stl_opts and not stl_opts["ascii"] is None:
-                    ascii = bool(stl_opts["ascii"])
-                else:
-                    ascii = False
-
-            obj = await self.get_wrapped(ctx)
-
-            def do_render_stl() -> None:
-                nonlocal obj, project, filepath, tolerance, angularTolerance, ascii
-                from OCP.BRepMesh import BRepMesh_IncrementalMesh
-                from OCP.StlAPI import StlAPI_Writer
-
-                if not project is None:
-                    project.ctx.ensure_dirs_for_file(filepath)
-
-                BRepMesh_IncrementalMesh(
-                    obj,
-                    theLinDeflection=tolerance,
-                    isRelative=True,
-                    theAngDeflection=angularTolerance,
-                    isInParallel=True,
-                )
-
-                writer = StlAPI_Writer()
-                writer.ASCIIMode = ascii
-                writer.Write(obj, filepath)
-
-            await threadpool_manager.run(do_render_stl)
-
-    def render_stl(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        asyncio.run(self.render_stl_async(ctx, project, filepath, tolerance, angularTolerance))
-
-    async def render_3mf_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        with pc_logging.Action("Render3MF", self.project_name, self.name):
-            threemf_opts, filepath = self.render_getopts("3mf", ".3mf", project, filepath)
-
-            if tolerance is None:
-                if "tolerance" in threemf_opts and not threemf_opts["tolerance"] is None:
-                    tolerance = threemf_opts["tolerance"]
-                else:
-                    tolerance = 0.1
-
-            if angularTolerance is None:
-                if "angularTolerance" in threemf_opts and not threemf_opts["angularTolerance"] is None:
-                    angularTolerance = threemf_opts["angularTolerance"]
-                else:
-                    angularTolerance = 0.1
-
-            obj = await self.get_wrapped(ctx)
+                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
+                return
 
-            if not project is None:
+            if project is not None:
                 project.ctx.ensure_dirs_for_file(filepath)
 
-            wrapper_path = wrapper.get("render_3mf.py")
-            request = {
-                "wrapped": obj,
-                "tolerance": tolerance,
-                "angularTolerance": angularTolerance,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
-
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("cadquery-ocp==7.7.2")
-            await runtime.ensure_async("cadquery==2.5.2")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
-
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-
-            if not result["success"]:
-                pc_logging.error("Render3MF failed: %s: %s" % (self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception(result["exception"])
-
-    def render_3mf(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        asyncio.run(self.render_3mf_async(ctx, project, filepath, tolerance, angularTolerance))
+            formats_to_render = [format_name] if format_name else list(WRAPPER_FORMATS.keys())
 
-    async def render_threejs_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        with pc_logging.Action("RenderThreeJS", self.project_name, self.name):
-            threejs_opts, filepath = self.render_getopts("threejs", ".json", project, filepath)
+            for format in formats_to_render:
+                file_extension = EXTENSION_MAPPING.get(format, format)
+                render_opts, final_filepath = self.render_getopts(format, f".{file_extension}", project, filepath)
+                final_filepath = os.path.abspath(final_filepath)
+                pc_logging.debug(f"Rendering: {self.project_name}:{self.name} for format '{format}'")
 
-            if tolerance is None:
-                if "tolerance" in threejs_opts and not threejs_opts["tolerance"] is None:
-                    tolerance = threejs_opts["tolerance"]
-                else:
-                    tolerance = 0.1
+                wrapper_path = wrapper.get(f"render_{format}.py")
 
-            if angularTolerance is None:
-                if "angularTolerance" in threejs_opts and not threejs_opts["angularTolerance"] is None:
-                    angularTolerance = threejs_opts["angularTolerance"]
-                else:
-                    angularTolerance = 0.1
-
-            obj = await self.get_wrapped(ctx)
-            wrapper_path = wrapper.get("render_threejs.py")
-            request = {
-                "wrapped": obj,
-                "tolerance": tolerance,
-                "angularTolerance": angularTolerance,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
-
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("cadquery-ocp==7.7.2")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
-
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-
-            if not result["success"]:
-                pc_logging.error("RenderThreeJS failed: %s: %s" % (self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception(result["exception"])
-
-    def render_threejs(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        asyncio.run(self.render_threejs_async(ctx, project, filepath, tolerance, angularTolerance))
+                request = {"wrapped": obj}
 
-    async def render_obj_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        with pc_logging.Action("RenderOBJ", self.project_name, self.name):
-            obj_opts, filepath = self.render_getopts("obj", ".obj", project, filepath)
+                if format in ["svg", "png"]:
+                    request["viewport_origin"] = kwargs.get("viewport_origin", [100, -100, 100])
+                    request["line_weight"] = kwargs.get("line_weight", 1.0)
+                    if format == "png":
+                        request["width"] = kwargs.get("width", 512)
+                        request["height"] = kwargs.get("height", 512)
 
-            if tolerance is None:
-                if "tolerance" in obj_opts and not obj_opts["tolerance"] is None:
-                    tolerance = obj_opts["tolerance"]
-                else:
-                    tolerance = 0.1
+                elif format in ["3mf", "obj", "gltf", "stl", "threejs"]:
+                    request["tolerance"] = kwargs.get("tolerance", render_opts.get("tolerance", 0.1))
+                    request["angularTolerance"] = kwargs.get(
+                        "angularTolerance", render_opts.get("angularTolerance", 0.1)
+                    )
+                    if format == "stl":
+                        request["ascii"] = kwargs.get("ascii", render_opts.get("ascii", False))
+                    elif format == "gltf":
+                        request["binary"] = kwargs.get("binary", render_opts.get("binary", False))
 
-            if angularTolerance is None:
-                if "angularTolerance" in obj_opts and not obj_opts["angularTolerance"] is None:
-                    angularTolerance = obj_opts["angularTolerance"]
-                else:
-                    angularTolerance = 0.1
-
-            obj = await self.get_wrapped(ctx)
-            wrapper_path = wrapper.get("render_obj.py")
-            request = {
-                "wrapped": obj,
-                "tolerance": tolerance,
-                "angularTolerance": angularTolerance,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
-
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("cadquery-ocp==7.7.2")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
-
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-
-            if not result["success"]:
-                pc_logging.error("RenderOBJ failed: %s: %s" % (self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception(result["exception"])
-
-    def render_obj(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        asyncio.run(self.render_obj_async(ctx, project, filepath, tolerance, angularTolerance))
+                elif format == "step":
+                    request["write_pcurves"] = kwargs.get("write_pcurves", render_opts.get("write_pcurves", True))
+                    request["precision_mode"] = kwargs.get("precision_mode", render_opts.get("precision_mode", 0))
 
-    async def render_gltf_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        binary=None,
-        tolerance=None,
-        angularTolerance=None,
-    ) -> None:
-        with pc_logging.Action("RenderGLTF", self.project_name, self.name):
-            gltf_opts, filepath = self.render_getopts("gltf", ".json", project, filepath)
+                register_ocp_helper()
 
-            if tolerance is None:
-                if "tolerance" in gltf_opts and not gltf_opts["tolerance"] is None:
-                    tolerance = gltf_opts["tolerance"]
-                else:
-                    tolerance = 0.1
+                picklestring = pickle.dumps(request)
+                request_serialized = base64.b64encode(picklestring).decode()
 
-            if angularTolerance is None:
-                if "angularTolerance" in gltf_opts and not gltf_opts["angularTolerance"] is None:
-                    angularTolerance = gltf_opts["angularTolerance"]
-                else:
-                    angularTolerance = 0.1
+                runtime = ctx.get_python_runtime(version="3.11")
 
-            if binary is None:
-                if "binary" in gltf_opts and not gltf_opts["binary"] is None:
-                    binary = gltf_opts["binary"]
-                else:
-                    binary = False
+                dependencies = WRAPPER_FORMATS[format_name]
+                await asyncio.gather(*(runtime.ensure_async(dep) for dep in dependencies))
 
-            b3d_obj = await self.get_build123d(ctx)
-
-            def do_render_gltf() -> None:
-                nonlocal b3d_obj, project, filepath, tolerance, angularTolerance
-                import build123d as b3d
-
-                b3d.export_gltf(
-                    b3d_obj,
-                    filepath,
-                    binary=binary,
-                    linear_deflection=tolerance,
-                    angular_deflection=angularTolerance,
+                # Run wrapper
+                response_serialized, errors = await runtime.run_async(
+                    [
+                        wrapper_path,
+                        final_filepath,
+                    ],
+                    request_serialized,
                 )
-
-            await threadpool_manager.run(do_render_gltf)
-
-    def render_gltf(
+                sys.stderr.write(errors)
+
+                if errors:
+                    pc_logging.error(f"Wrapper {format_name} stderr:\n{errors}")
+
+                response_lines = response_serialized.strip().splitlines()
+                if not response_lines:
+                    pc_logging.error(f"Empty response from wrapper: {wrapper_path}")
+                    return
+
+                cleaned_response = response_lines[-1].strip()
+
+                # Handle response
+                result = {}
+                try:
+                    response_bytes = base64.b64decode(cleaned_response)
+                    result = pickle.loads(response_bytes)
+                except Exception as e:
+                    pc_logging.error(f"Failed to deserialize response: {e}")
+
+                if not result.get("success", False):
+                    pc_logging.error(
+                        f"Render {format_name.upper()} failed for {self.project_name}:{self.name}: {result.get('exception', 'Unknown error')}"
+                    )
+                if "exception" in result and result["exception"]:
+                    pc_logging.exception(f"Render {format_name.upper()} exception: {result['exception']}")
+
+    def render(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-        tolerance=None,
-        angularTolerance=None,
     ) -> None:
-        asyncio.run(self.render_gltf_async(ctx, project, filepath, tolerance, angularTolerance))
-
-    def render_markdown(self, ctx, project=None, filepath=None) -> None:
-        asyncio.run(self.render_markdown_async(ctx, project, filepath))
-
-    async def get_summary_async(self, project=None):
-        if "summary" in self.config and not self.config["summary"] is None:
-            return self.config["summary"]
-        return None
-
-    def get_summary(self, project=None):
-        return asyncio.run(self.get_summary_async(project))
+        asyncio.run(self.render_async(ctx, format_name, project, filepath))
diff --git a/partcad/src/partcad/shape_ai.py b/partcad/src/partcad/shape_ai.py
index 327668699..aacbc32e2 100644
--- a/partcad/src/partcad/shape_ai.py
+++ b/partcad/src/partcad/shape_ai.py
@@ -29,7 +29,8 @@ async def get_summary_async(self, project=None):
             return configured_summary
 
         image_filename = tempfile.mktemp(".png")
-        await self.render_png_async(project.ctx, project, image_filename)
+        # await self.render_png_async(project.ctx, project, image_filename)
+        await self.render_async(project.ctx, "png", project, image_filename)
 
         prompt = """The attached image is a single-color (the color doesn't
 matter) line drawing of a mechanical design.
diff --git a/partcad/src/partcad/wrappers/wrapper_common.py b/partcad/src/partcad/wrappers/wrapper_common.py
index abb847315..dbfa986ae 100644
--- a/partcad/src/partcad/wrappers/wrapper_common.py
+++ b/partcad/src/partcad/wrappers/wrapper_common.py
@@ -61,19 +61,32 @@ def handle_exception(exc, cqscript=None):
     sys.stderr.write("Error: [")
     sys.stderr.write(str(exc).strip())
     sys.stderr.write("] on the line: [")
+
     tb = exc.__traceback__
-    # Switch to the traceback object that contains the script line number
-    tb = tb.tb_next
-    # Get the filename
-    fname = tb.tb_frame.f_code.co_filename
-    if cqscript is not None and fname == "<cqscript>":
-        fname = cqscript
-
-    # Get the line contents
-    with open(fname, "r") as fp:
-        lines = fp.read().split("\n")
-        if tb.tb_lineno - 1 < len(lines):
-            line = lines[tb.tb_lineno - 1]
-            sys.stderr.write(line.strip())
+    if tb is None:
+        sys.stderr.write("No traceback available")
+    else:
+        # Try to move one level down if available
+        if tb.tb_next is not None:
+            tb = tb.tb_next
+        # Check if tb is still valid
+        try:
+            fname = tb.tb_frame.f_code.co_filename
+            if cqscript is not None and fname == "<cqscript>":
+                fname = cqscript
+
+            # Attempt to read the file and print the specific line
+            try:
+                with open(fname, "r") as fp:
+                    lines = fp.read().split("\n")
+                    if tb.tb_lineno - 1 < len(lines):
+                        line = lines[tb.tb_lineno - 1]
+                        sys.stderr.write(line.strip())
+                    else:
+                        sys.stderr.write("Line number out of range")
+            except Exception as read_err:
+                sys.stderr.write(f"Failed to read file {fname}: {read_err}")
+        except AttributeError:
+            sys.stderr.write("No traceback details available")
     sys.stderr.write("]\n")
     sys.stderr.flush()
diff --git a/partcad/src/partcad/wrappers/wrapper_render_brep.py b/partcad/src/partcad/wrappers/wrapper_render_brep.py
new file mode 100644
index 000000000..0d85c02ee
--- /dev/null
+++ b/partcad/src/partcad/wrappers/wrapper_render_brep.py
@@ -0,0 +1,45 @@
+# This script is executed within the python sandbox environment (python runtime)
+# to read BREP files.
+
+import os
+import sys
+
+# from OCP.BRep import BRep_Builder
+from OCP.BRepTools import BRepTools
+from OCP.TopoDS import TopoDS_Shape
+
+sys.path.append(os.path.dirname(__file__))
+import wrapper_common
+
+
+def process(path, request):
+    try:
+        obj = request.get("wrapped")
+        if obj is None:
+            raise Exception("No wrapped object provided for BREP export")
+
+        if not isinstance(obj, TopoDS_Shape):
+            raise TypeError(f"Object type is incorrect: {type(obj)}")
+
+        if not os.path.isabs(path):
+            path = os.path.abspath(path)
+
+        out_dir = os.path.dirname(path)
+        os.makedirs(out_dir, exist_ok=True)
+
+        brep_writer = BRepTools()
+        brep_writer.Write_s(obj, path)
+
+        # Check that the file was successfully created.
+        if not os.path.exists(path) or os.path.getsize(path) == 0:
+            raise Exception(f"Failed to create BREP file: {path}")
+
+        return {"success": True, "exception": None}
+    except Exception as e:
+        wrapper_common.handle_exception(e)
+        return {"success": False, "exception": str(e)}
+
+if __name__ == "__main__":
+    path, request = wrapper_common.handle_input()
+    response = process(path, request)
+    wrapper_common.handle_output(response)
diff --git a/partcad/src/partcad/wrappers/wrapper_render_gltf.py b/partcad/src/partcad/wrappers/wrapper_render_gltf.py
new file mode 100644
index 000000000..89cdcbb37
--- /dev/null
+++ b/partcad/src/partcad/wrappers/wrapper_render_gltf.py
@@ -0,0 +1,59 @@
+#
+# PartCAD, 2025
+#
+# Licensed under Apache License, Version 2.0.
+#
+
+# This script is executed within a python runtime environment
+# to render a glTF file from wrapped shape
+
+import os
+import sys
+import pickle
+import base64
+
+import build123d as b3d
+
+sys.path.append(os.path.dirname(__file__))
+import wrapper_common
+
+
+def process(path, request):
+    try:
+        obj = request.get("wrapped")
+        if obj is None:
+            raise ValueError("No wrapped object provided for GLTF export")
+
+        tolerance = request.get("tolerance", 0.1)
+        angular_tolerance = request.get("angularTolerance", 0.1)
+        binary = request.get("binary", False)
+
+        if not os.path.isabs(path):
+            path = os.path.abspath(path)
+
+        out_dir = os.path.dirname(path)
+        os.makedirs(out_dir, exist_ok=True)
+
+        b3d.export_gltf(
+            obj,
+            path,
+            binary=binary,
+            linear_deflection=tolerance,
+            angular_deflection=angular_tolerance,
+        )
+
+        # Check if file is successfully created
+        if not os.path.exists(path) or os.path.getsize(path) == 0:
+            raise Exception(f"Failed to create GLTF file: {path}")
+
+        return {"success": True, "exception": None}
+
+    except Exception as e:
+        wrapper_common.handle_exception(e)
+        return {"success": False, "exception": str(e)}
+
+
+if __name__ == "__main__":
+    path, request = wrapper_common.handle_input()
+    response = process(path, request)
+    wrapper_common.handle_output(response)
diff --git a/partcad/src/partcad/wrappers/wrapper_render_step.py b/partcad/src/partcad/wrappers/wrapper_render_step.py
new file mode 100644
index 000000000..3d19d48c4
--- /dev/null
+++ b/partcad/src/partcad/wrappers/wrapper_render_step.py
@@ -0,0 +1,35 @@
+import base64
+import os
+import pickle
+import sys
+sys.path.append(os.path.dirname(__file__))
+import wrapper_common
+
+def process(path, request):
+    try:
+        from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
+        from OCP.Interface import Interface_Static
+
+        obj = request["wrapped"]
+        write_pcurves = 1 if request.get("write_pcurves", True) else 0
+        precision_mode = request.get("precision_mode", 0)
+
+        writer = STEPControl_Writer()
+        Interface_Static.SetIVal_s("write.surfacecurve.mode", write_pcurves)
+        Interface_Static.SetIVal_s("write.precision.mode", precision_mode)
+        writer.Transfer(obj, STEPControl_AsIs)
+        writer.Write(path)
+
+        if not os.path.exists(path) or os.path.getsize(path) == 0:
+            raise Exception(f"Failed to create STEP file: {path}")
+
+        return {"success": True, "exception": None}
+
+    except Exception as e:
+        wrapper_common.handle_exception(e)
+        return {"success": False, "exception": str(e)}
+
+if __name__ == "__main__":
+    path, request = wrapper_common.handle_input()
+    response = process(path, request)
+    wrapper_common.handle_output(response)
diff --git a/partcad/src/partcad/wrappers/wrapper_render_stl.py b/partcad/src/partcad/wrappers/wrapper_render_stl.py
new file mode 100644
index 000000000..2aa28c36c
--- /dev/null
+++ b/partcad/src/partcad/wrappers/wrapper_render_stl.py
@@ -0,0 +1,40 @@
+import os
+import sys
+sys.path.append(os.path.dirname(__file__))
+import wrapper_common
+
+def process(path, request):
+    try:
+        from OCP.BRepMesh import BRepMesh_IncrementalMesh
+        from OCP.StlAPI import StlAPI_Writer
+
+        obj = request["wrapped"]
+        tolerance = request.get("tolerance", 0.1)
+        angular_tolerance = request.get("angularTolerance", 0.1)
+        ascii = request.get("ascii", False)
+
+        BRepMesh_IncrementalMesh(
+            obj,
+            theLinDeflection=tolerance,
+            isRelative=True,
+            theAngDeflection=angular_tolerance,
+            isInParallel=True,
+        )
+
+        writer = StlAPI_Writer()
+        writer.ASCIIMode = ascii
+        success = writer.Write(obj, path)
+
+        if not success or not os.path.exists(path) or os.path.getsize(path) == 0:
+            raise Exception(f"Failed to create STL file: {path}")
+
+        return {"success": True, "exception": None}
+
+    except Exception as e:
+        wrapper_common.handle_exception(e)
+        return {"success": False, "exception": str(e)}
+
+if __name__ == "__main__":
+    path, request = wrapper_common.handle_input()
+    response = process(path, request)
+    wrapper_common.handle_output(response)
diff --git a/partcad/src/partcad/wrappers/wrapper_stl.py b/partcad/src/partcad/wrappers/wrapper_stl.py
new file mode 100644
index 000000000..331a8bb35
--- /dev/null
+++ b/partcad/src/partcad/wrappers/wrapper_stl.py
@@ -0,0 +1,83 @@
+import os
+import sys
+import base64
+import pickle
+import traceback
+
+import build123d as b3d
+
+from OCP.RWStl import RWStl
+from OCP.BRep import BRep_Builder
+from OCP.TopoDS import TopoDS_Face, TopoDS_Shell, TopoDS_Solid
+from OCP.TopAbs import TopAbs_FACE
+from OCP.BRepCheck import BRepCheck_Shell, BRepCheck_Solid, BRepCheck_Status
+from OCP.BRepGProp import BRepGProp
+from OCP.GProp import GProp_GProps
+
+sys.path.append(os.path.dirname(__file__))
+import wrapper_common
+
+def process(path, request):
+    try:
+        try:
+            meshed = b3d.Mesher().read(path)
+            shape = meshed[0].wrapped
+        except Exception:
+            builder = BRep_Builder()
+            reader = RWStl.ReadFile_s(os.fsdecode(path))
+
+            if not reader:
+                raise RuntimeError("Failed to read the STL file")
+
+            # Build and check the faces
+            face = TopoDS_Face()
+            builder.MakeFace(face, reader)
+            if face.IsNull():
+                raise RuntimeError("Failed to read the STL file: Null")
+            if face.ShapeType() != TopAbs_FACE:
+                raise RuntimeError("Failed to read the STL file: Wrong shape type")
+            if face.Infinite():
+                raise RuntimeError("Failed to read the STL file: Infinite")
+
+            # Build and check the shell
+            shell = TopoDS_Shell()
+            builder.MakeShell(shell)
+            builder.Add(shell, face)
+            shell_check = BRepCheck_Shell(shell)
+            shell_check_result = shell_check.Closed()
+            if shell_check_result != BRepCheck_Status.BRepCheck_NoError:
+                if shell_check_result == BRepCheck_Status.BRepCheck_NotClosed:
+                    raise RuntimeError("Failed to read the STL file: Shell is not closed")
+                else:
+                    raise RuntimeError("Failed to read the STL file: Shell check failed")
+
+            # Build and check the solid
+            solid = TopoDS_Solid()
+            builder.MakeSolid(solid)
+            builder.Add(solid, shell)
+            if solid.IsNull():
+                raise RuntimeError("Failed to read the STL file: Null solid")
+            if solid.Infinite():
+                raise RuntimeError("Failed to read STL file: Infinite solid")
+            solid_check = BRepCheck_Solid(solid)
+            solid_check.Minimum()
+            statuses = solid_check.Status()
+            if statuses.Size() > 0 and statuses.First() != BRepCheck_Status.BRepCheck_NoError:
+                raise RuntimeError("Failed to read the STL file: Solid check failed: %s" % statuses.First())
+            gprops = GProp_GProps()
+            BRepGProp.VolumeProperties_s(solid, gprops)
+            if gprops.Mass() <= 0:
+                raise RuntimeError("Failed to read the STL file: Zero or negative volume")
+
+            shape = solid
+
+    except Exception as e:
+        wrapper_common.handle_exception(e)
+        return {"success": False, "exception": str(e), "shape": None}
+
+    return {"success": True, "exception": None, "shape": shape}
+
+if __name__ == "__main__":
+    path, request = wrapper_common.handle_input()
+    result = process(path, request)
+    wrapper_common.handle_output(result)
diff --git a/partcad/tests/unit/test_adhoc_convert.py b/partcad/tests/unit/test_adhoc_convert.py
index 63ed16574..87b5c25a4 100644
--- a/partcad/tests/unit/test_adhoc_convert.py
+++ b/partcad/tests/unit/test_adhoc_convert.py
@@ -3,10 +3,10 @@
 import tempfile
 from pathlib import Path
 from partcad.context import Context
-from partcad.adhoc.convert import convert_cad_file, generate_partcad_config, export_part
+from partcad.adhoc.convert import convert_cad_file, generate_partcad_config
 
 
-OUTPUT_FORMATS = ["stl", "step", "brep", "3mf", "threejs", "obj", "gltf"]
+OUTPUT_FORMATS = ["stl", "step", "brep", "3mf", "threejs", "obj"]
 
 TEST_FILES = {
     "stl": "cube.stl",
@@ -84,37 +84,7 @@ def test_convert_invalid_file():
 
     output_file = Path(temp_dir) / "output.step"
 
-    with pytest.raises(RuntimeError, match="Failed to convert: Failed to read the STL file"):
+    with pytest.raises(RuntimeError, match="Failed to convert:"):
         convert_cad_file(invalid_file, "stl", output_file, "step")
 
     shutil.rmtree(temp_dir)
-
-
-def test_export_part():
-    """Test exporting a part with a supported format."""
-    ctx = Context(root_path="/tmp")
-    part = type("PartMock", (), {"render_step": lambda self, ctx, filepath: Path(filepath).touch()})()
-    output_file = Path(tempfile.mkdtemp()) / "output.step"
-
-    export_part(part, output_file, "step", ctx)
-    assert output_file.exists(), "Exported file does not exist after export."
-
-    shutil.rmtree(output_file.parent)
-
-
-def test_export_part_unsupported_format():
-    """Test exporting a part with an unsupported format."""
-    ctx = Context(root_path="/tmp")
-    part = type("PartMock", (), {})()  # Mock part with no export methods
-
-    with pytest.raises(ValueError, match="Unsupported export format: unknown"):
-        export_part(part, "output.unknown", "unknown", ctx)
-
-
-def test_export_part_missing_method():
-    """Test exporting a part that does not support the requested format."""
-    ctx = Context(root_path="/tmp")
-    part = type("PartMock", (), {})()  # Mock part with no export methods
-
-    with pytest.raises(RuntimeError, match="Part does not support export to 'step'."):
-        export_part(part, "output.step", "step", ctx)
diff --git a/partcad/tests/unit/test_convert_part.py b/partcad/tests/unit/test_convert_part.py
index 9cd266204..f358c9505 100644
--- a/partcad/tests/unit/test_convert_part.py
+++ b/partcad/tests/unit/test_convert_part.py
@@ -7,21 +7,9 @@
 import yaml
 from unittest.mock import patch, Mock
 
+from partcad.shape import EXTENSION_MAPPING
 
-EXTENSION_MAPPING = {
-    "step": "step",
-    "brep": "brep",
-    "stl": "stl",
-    "3mf": "3mf",
-    "threejs": "json",
-    "obj": "obj",
-    "gltf": "gltf",
-    "cadquery": "py",
-    "build123d": "py",
-    "scad": "scad",
-}
-
-ALLOWED_TARGET_FORMATS = {"step", "brep", "stl", "3mf", "threejs", "obj", "gltf"}
+ALLOWED_TARGET_FORMATS = {"step", "brep", "stl", "3mf", "threejs", "obj"}
 
 SOURCE_DIR = Path("./examples/feature_convert")
 
@@ -36,8 +24,6 @@
 }
 
 
-
-
 @pytest.mark.parametrize("source_part", PARTS_CONFIG.keys())
 @pytest.mark.parametrize("target_format", ALLOWED_TARGET_FORMATS)
 def test_full_conversion_matrix(source_part: str, target_format: str, tmp_path: Path):
diff --git a/partcad/tests/unit/test_import_part.py b/partcad/tests/unit/test_import_part.py
index 1b62be315..f0562f310 100644
--- a/partcad/tests/unit/test_import_part.py
+++ b/partcad/tests/unit/test_import_part.py
@@ -6,6 +6,8 @@
 from partcad.context import Context
 from partcad.actions.part import import_part_action
 
+# Mapping file extensions to formats
+from partcad.shape import EXTENSION_MAPPING
 
 SOURCE_DIR = Path("/workspaces/partcad/examples/feature_import")
 
diff --git a/partcad/tests/unit/test_render.py b/partcad/tests/unit/test_render.py
index bf0ed97e9..351777fbf 100644
--- a/partcad/tests/unit/test_render.py
+++ b/partcad/tests/unit/test_render.py
@@ -23,7 +23,8 @@ def test_render_svg_part_1():
     cube = prj.get_part("cube")
     assert cube is not None
     try:
-        cube.render_svg(ctx, project=prj)
+        # cube.render_svg(ctx, project=prj)
+        cube.render(ctx, "svg", prj)
     except Exception as e:
         assert False, "Valid render request caused an exception: %s" % e
 
@@ -36,7 +37,8 @@ def test_render_svg_assy_1():
     assy = prj.get_assembly("logo")
     assert assy is not None
     try:
-        assy.render_svg(ctx, project=prj)
+        # assy.render_svg(ctx, project=prj)
+        assy.render(ctx, "svg", prj)
     except Exception as e:
         assert False, "Valid render request caused an exception: %s" % e
 
@@ -49,7 +51,8 @@ def test_render_svg_assy_2():
     assy = prj.get_assembly("logo_embedded")
     assert assy is not None
     try:
-        assy.render_svg(ctx, project=prj)
+        # assy.render_svg(ctx, project=prj)
+        assy.render(ctx, "svg", prj)
     except Exception as e:
         assert False, "Valid render request caused an exception: %s" % e
 


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>partcad/src/partcad/project.py (2)</summary><blockquote>

`1353-1354`: **Combine nested `if` statements for cleaner readability.**

Currently:
```python
if self._should_render_format(format_name, shape_render, format, shape.kind):
    if not hasattr(shape, "finalized") or shape.finalized:
        ...
```
You can merge them:

```diff
-if self._should_render_format(format_name, shape_render, format, shape.kind):
-    if not hasattr(shape, "finalized") or shape.finalized:
+if (
+    self._should_render_format(format_name, shape_render, format, shape.kind)
+    and (not hasattr(shape, "finalized") or shape.finalized)
):
    ...
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

1353-1354: Use a single `if` statement instead of nested `if` statements

(SIM102)

</details>

</details>

---

`1377-1379`: **Follow Python style best practice by avoiding multiple statements on one line.**

Currently the code is written as:
```python
for name in sketches: shapes.append(self.get_sketch(name))
for name in parts: shapes.append(self.get_part(name))
for name in assemblies: shapes.append(self.get_assembly(name))
```
Extract each loop body to its own indented line for clarity:

```diff
-for name in sketches: shapes.append(self.get_sketch(name))
-for name in parts: shapes.append(self.get_part(name))
-for name in assemblies: shapes.append(self.get_assembly(name))
+for name in sketches:
+    shapes.append(self.get_sketch(name))
+for name in parts:
+    shapes.append(self.get_part(name))
+for name in assemblies:
+    shapes.append(self.get_assembly(name))
```

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

1377-1377: Multiple statements on one line (colon)

(E701)

---

1378-1378: Multiple statements on one line (colon)

(E701)

---

1379-1379: Multiple statements on one line (colon)

(E701)

</details>

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9fa7581c971be1d0aaef75f436a81b9f0ca1920b and 77dfa99722a8c1ee6aad5e0364ca4feb8e6e5ce3.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `partcad/src/partcad/project.py` (4 hunks)
* `partcad/src/partcad/shape.py` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

<details>
<summary>partcad/src/partcad/project.py</summary>

22-22: `partcad.context.Context` imported but unused

Remove unused import: `partcad.context.Context`

(F401)

---

1353-1354: Use a single `if` statement instead of nested `if` statements

(SIM102)

---

1377-1377: Multiple statements on one line (colon)

(E701)

---

1378-1378: Multiple statements on one line (colon)

(E701)

---

1379-1379: Multiple statements on one line (colon)

(E701)

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (65)</summary>

* GitHub Check: Examples (All) (macos-latest, 3.12)
* GitHub Check: Examples (PartCAD) (macos-latest, 3.12)
* GitHub Check: Examples (All) (macos-latest, 3.11)
* GitHub Check: Behave (macos-latest, 3.12)
* GitHub Check: Examples (PartCAD) (macos-latest, 3.11)
* GitHub Check: Examples (All) (macos-latest, 3.10)
* GitHub Check: Examples (PartCAD) (macos-latest, 3.10)
* GitHub Check: Behave (macos-latest, 3.11)
* GitHub Check: Pytest (VS Code extension) (macos-latest, 3.12)
* GitHub Check: Examples (PartCAD) (macos-13, 3.12)
* GitHub Check: Examples (All) (macos-13, 3.12)
* GitHub Check: Behave (macos-latest, 3.10)
* GitHub Check: Pytest (VS Code extension) (macos-latest, 3.11)
* GitHub Check: Examples (PartCAD) (macos-13, 3.11)
* GitHub Check: Examples (All) (macos-13, 3.11)
* GitHub Check: Behave (macos-13, 3.12)
* GitHub Check: Pytest (VS Code extension) (macos-latest, 3.10)
* GitHub Check: Examples (PartCAD) (macos-13, 3.10)
* GitHub Check: Examples (All) (macos-13, 3.10)
* GitHub Check: Pytest (VS Code extension) (macos-13, 3.12)
* GitHub Check: Behave (macos-13, 3.11)
* GitHub Check: Examples (PartCAD) (windows-latest, 3.11)
* GitHub Check: Examples (All) (windows-latest, 3.11)
* GitHub Check: Pytest (VS Code extension) (macos-13, 3.11)
* GitHub Check: Behave (macos-13, 3.10)
* GitHub Check: Examples (PartCAD) (windows-latest, 3.10)
* GitHub Check: Examples (All) (windows-latest, 3.10)
* GitHub Check: Pytest (VS Code extension) (macos-13, 3.10)
* GitHub Check: Behave (windows-latest, 3.11)
* GitHub Check: Examples (PartCAD) (ubuntu-latest, 3.12)
* GitHub Check: Pytest (VS Code extension) (windows-latest, 3.11)
* GitHub Check: Examples (All) (ubuntu-latest, 3.12)
* GitHub Check: Behave (windows-latest, 3.10)
* GitHub Check: Examples (PartCAD) (ubuntu-latest, 3.11)
* GitHub Check: Pytest (VS Code extension) (windows-latest, 3.10)
* GitHub Check: Examples (All) (ubuntu-latest, 3.11)
* GitHub Check: Pytest (VS Code extension) (ubuntu-latest, 3.12)
* GitHub Check: Behave (ubuntu-latest, 3.12)
* GitHub Check: Examples (PartCAD) (ubuntu-latest, 3.10)
* GitHub Check: Pytest (VS Code extension) (ubuntu-latest, 3.11)
* GitHub Check: Examples (All) (ubuntu-latest, 3.10)
* GitHub Check: Behave (ubuntu-latest, 3.11)
* GitHub Check: Pytest (VS Code extension) (ubuntu-latest, 3.10)
* GitHub Check: Behave (ubuntu-latest, 3.10)
* GitHub Check: build (macos-latest, 3.12)
* GitHub Check: Repo //pub (macos-latest, 3.12)
* GitHub Check: build (macos-latest, 3.11)
* GitHub Check: Repo //pub (macos-latest, 3.10)
* GitHub Check: build (macos-latest, 3.10)
* GitHub Check: build (macos-13, 3.12)
* GitHub Check: Repo //pub (macos-13, 3.12)
* GitHub Check: build (macos-13, 3.11)
* GitHub Check: Repo //pub (macos-13, 3.10)
* GitHub Check: build (macos-13, 3.10)
* GitHub Check: Repo //pub (windows-latest-l, 3.10)
* GitHub Check: build (windows-latest, 3.11)
* GitHub Check: Repo //pub (ubuntu-24.04-l, 3.12)
* GitHub Check: build (windows-latest, 3.10)
* GitHub Check: Repo //pub (ubuntu-24.04-l, 3.10)
* GitHub Check: build (ubuntu-latest, 3.12)
* GitHub Check: build (ubuntu-latest, 3.11)
* GitHub Check: Build Docker Containers
* GitHub Check: build (ubuntu-latest, 3.10)
* GitHub Check: Build: Dev Container
* GitHub Check: build (ubuntu-24.04, 3.10)

</details>

<details>
<summary>🔇 Additional comments (7)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (3)</summary>

`18-21`: **The new imports are valid and necessary.**

Static analysis reports `Context` as unused, but it is clearly referenced in the `render()` signature (line 416). This is a false positive; you can safely ignore or mark it as resolved.

---

`422-429`: **Docstring clarity approved.**

The method docstring thoroughly documents the parameters, including optional arguments in `kwargs`. This significantly improves maintainability and clarity.

---

`430-456`: **Consider invalid `filepath` edge cases and potential key collisions.**

1. If `filepath` is `None`, the current logic calls `ctx.run_wrapper(..., filepath, ...)`. This might fail if the wrapper script requires a valid path.  
2. Merging `obj` into `kwargs` (line 436) could risk overwriting a key named `"wrapped"` in `kwargs` if provided by the caller.

You may wish to sanity-check `filepath` and rename `"wrapped"` if there's a chance of collision in `kwargs`.

</details>
<details>
<summary>partcad/src/partcad/project.py (4)</summary>

`15-23`: **Imports are correctly utilized.**

Despite the static analysis warning that `Context` might be unused, it is evidently required by `render_async()` and other methods. The `Shape` import is also correctly referenced.

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 Ruff (0.8.2)</summary>

22-22: `partcad.context.Context` imported but unused

Remove unused import: `partcad.context.Context`

(F401)

</details>

</details>

---

`1325-1331`: **Type hints enhance clarity.**

The explicit `Optional[List]` and `Optional[Path]` parameters are beneficial for readability. This is a solid improvement toward better type safety.

---

`1337-1341`: **Potential side effect on project configuration.**

By doing `self.config_obj.setdefault("render", {})["output_dir"] = output_dir`, the `render` config is permanently mutated if `output_dir` is non-null. Consider whether you want to preserve the user’s choice only for this render session or for subsequent renders as well.

---

`1410-1415`: **Good defaulting for `format` and `output_dir`.**

Allowing these parameters to remain `None` can simplify calls to `render()`. This is consistent with the upstream `render_async()` signature.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-11T15:25:49Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/part_factory_step.py (2)</summary><blockquote>

`40-44`: **Consider removing or downgrading debug logs to avoid production noise.**

Lines 40 and 44 add non-conditional info-level debug logs that may clutter production logs. Converting them to a lower log level (e.g., `debug`) or wrapping them with a condition might reduce unwanted noise.

```diff
- pc_logging.info("debug1")
+ pc_logging.debug("debug1")
```

---

`49-67`: **Add robust error handling for response decoding and shape retrieval.**

The code decodes base64 data and unpickles it, then only checks the `success` field. Consider gracefully handling corrupt or invalid data scenarios (e.g., partial downloads, incomplete content) to avoid unhandled exceptions. You could re-check the decoded result or add a fallback.

</blockquote></details>
<details>
<summary>partcad/src/partcad/shape.py (1)</summary><blockquote>

`601-601`: **Prefer a debug-level log for BREP rendering.**

While logging the action is useful, consider making line 601 conditional or debug-level to avoid spamming logs during normal operations.

```diff
- pc_logging.info("render_brep_async")
+ pc_logging.debug("render_brep_async")
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 77dfa99722a8c1ee6aad5e0364ca4feb8e6e5ce3 and b986cef3016cd997deb2406a8ab0438fd49aef1e.

</details>

<details>
<summary>📒 Files selected for processing (11)</summary>

* `examples/feature_convert/partcad.yaml` (2 hunks)
* `partcad-ide-vscode/bundled/tool/lsp_server.py` (3 hunks)
* `partcad/src/partcad/actions/part/convert.py` (1 hunks)
* `partcad/src/partcad/part_factory_feature_ai.py` (3 hunks)
* `partcad/src/partcad/part_factory_step.py` (1 hunks)
* `partcad/src/partcad/project.py` (5 hunks)
* `partcad/src/partcad/shape.py` (5 hunks)
* `partcad/src/partcad/shape_ai.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_common.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_brep.py` (1 hunks)
* `partcad/tests/unit/test_render.py` (3 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* partcad/src/partcad/project.py

</details>

<details>
<summary>🔇 Additional comments (22)</summary><blockquote>

<details>
<summary>examples/feature_convert/partcad.yaml (1)</summary>

`187-189`: **New render configuration aligns with refactoring goals**

This new `render` section properly configures the output formats (png and svg) to the current directory, supporting the PR's goal of making rendering configurable rather than hardcoded.

</details>
<details>
<summary>partcad/src/partcad/shape_ai.py (1)</summary>

`32-33`: **Good refactoring to use generalized rendering**

The shift from a PNG-specific rendering method to a format-agnostic rendering method improves flexibility while maintaining the same functionality.

</details>
<details>
<summary>partcad/src/partcad/actions/part/convert.py (1)</summary>

`170-176`: **Improved parameter handling in render method**

The refactoring correctly:
1. Replaces the specific `convert` method with the more general `render` method
2. Uses a more intuitive parameter name (`format` instead of `target_format`)
3. Properly handles the output directory (using `None` instead of an empty string when not provided)

This change aligns with the goal of removing direct dependencies on specific formats.

</details>
<details>
<summary>partcad/src/partcad/part_factory_feature_ai.py (3)</summary>

`20-20`: **Good addition of Part import**

Adding the explicit import for Part supports the type annotation added to the render method.

---

`689-689`: **Enhanced type safety with Part annotation**

Adding the type annotation for the Part parameter improves code clarity and enables better IDE support.

---

`701-702`: **Good refactoring to use generalized rendering**

Similar to other files, this properly replaces the PNG-specific rendering method with a more general method that accepts a format parameter, supporting the PR goal of removing direct dependencies on specific formats.

</details>
<details>
<summary>partcad/tests/unit/test_render.py (3)</summary>

`25-26`: **Good refactoring to use the generalized render method!**

The test now uses the new unified `render` method with format parameter "svg" instead of the specific `render_svg` method. This aligns with the updated rendering API design.

---

`38-39`: **Consistent usage of the new render method for assembly rendering.**

Using the generalized `render` method with "svg" format parameter maintains consistency with the updated rendering approach across the codebase.

---

`51-52`: **Appropriately updated embedded assembly rendering.**

The test for embedded assemblies now uses the same generalized `render` method pattern, ensuring consistent behavior across all test cases.

</details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_render_brep.py (5)</summary>

`1-3`: **Good module documentation.**

The header comments clearly explain the purpose of this script for handling BREP files in a sandboxed environment.

---

`15-22`: **Proper error handling for required parameters.**

The function correctly validates that a wrapped object is provided before proceeding with the BREP export operation.

---

`23-31`: **Robust path handling.**

The code ensures absolute paths and creates output directories as needed, which is good practice for file operations.

---

`33-40`: **Complete file validation after writing.**

Good job checking that the file was successfully created and has content, providing clear error messages if the operation fails.

---

`47-50`: **Clean script execution structure.**

The main block correctly uses the common wrapper functions for handling input and output, making the script usable as a standalone tool.

</details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_common.py (1)</summary>

`66-91`: **Significantly improved error handling.**

The enhanced traceback handling improves robustness by:
1. Checking for null traceback before accessing it
2. Moving one level down the traceback chain when available
3. Adding proper exception handling for file operations
4. Providing more informative error messages in different failure scenarios

This will make debugging easier and prevent crashes when handling malformed tracebacks.

</details>
<details>
<summary>partcad-ide-vscode/bundled/tool/lsp_server.py (4)</summary>

`32-32`: **Good addition of explicit import.**

Adding the explicit import for the `Part` class from `partcad.part` supports the type annotations added later in the file.

---

`350-350`: **Enhanced type safety with type annotation.**

Adding the type annotation `part: Part` improves code clarity and enables better IDE support and static type checking.

---

`355-356`: **Updated PNG rendering to use the generalized render method.**

The code now uses the unified `render` method with "png" format parameter instead of the specific `render_png` method, consistent with the project-wide rendering approach refactoring.

---

`400-401`: **Consistent rendering approach for assemblies.**

The assembly export function has been updated to use the same generalized rendering pattern, ensuring consistency across part and assembly rendering.

</details>
<details>
<summary>partcad/src/partcad/shape.py (3)</summary>

`9-36`: **Good use of type hints and forward references.**

Importing `TYPE_CHECKING` alongside `from __future__ import annotations` clarifies type usage and prevents circular dependencies. This aligns well with modern Python typing features.

---

`46-46`: **Explicitly declaring `kind: str` improves readability.**

Adding an explicit type annotation for `kind` is a welcome change; it clarifies the shape’s intended usage and ensures better tooling support.

---

`544-546`: **Decoupling the method signature for rendering by format is a great design choice.**

Passing `format_name` directly to `render()` aligns it with `render_async()` ensuring consistency and modularity across different rendering pipelines.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-14T20:07:53Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (1)</summary><blockquote>

`437-471`: _⚠️ Potential issue_

**[CRITICAL] References to PNG/SVG contradict the PR objective.**  

According to the PR objective "refactor: Drop PNG and SVG dependencies," these entries in `WRAPPER_FORMATS` for `"svg"` and `"png"` remain. Removing or making them optional is necessary to align with the PR scope.  

```diff
 "svg": [
-    "cadquery-ocp==7.7.2",
-    "ocpsvg==0.3.4",
-    "build123d==0.8.0"
 ],
 "png": [
-    "cadquery-ocp==7.7.2",
-    "ocpsvg==0.3.4",
-    "build123d==0.8.0",
-    "svglib==1.5.1",
-    "reportlab",
-    "rlpycairo==0.3.0"
 ],
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (14)</summary><blockquote>

<details>
<summary>partcad/src/partcad/wrappers/wrapper_render_stl.py (4)</summary><blockquote>

`3-3`: **Avoid modifying sys.path if possible.**  
Modifying `sys.path` can lead to unexpected import behavior. Consider using local or absolute imports instead to maintain consistency and clarity in module resolution.  

```diff
- sys.path.append(os.path.dirname(__file__))
+ # from partcad.src.partcad.wrappers import wrapper_common
# Or use a relative import if your project layout supports it:
# from . import wrapper_common
```

---

`14-14`: **Rename variable to avoid shadowing built-in.**  
The use of `ascii` as a variable name masks Python's built-in `ascii` function. Renaming it helps avoid confusion and potential unintended behavior.  

```diff
- ascii = request.get("ascii", False)
+ ascii_mode = request.get("ascii", False)
```

---

`28-29`: **Ensure robust file creation checks.**  
This check is good, but consider verifying write permissions beforehand or handling potential race conditions if multiple processes might write to the same file.  

Do you want me to open an issue about verifying filesystem permissions for robust file creation?

---

`33-35`: **Catch narrower exceptions.**  
Catching `Exception` might obscure specific error types. Consider catching more explicit exceptions (e.g., `ImportError`, `OSError`, `RuntimeError`) to differentiate error handling.

</blockquote></details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_render_step.py (2)</summary><blockquote>

`3-3`: **Consider removing manual sys.path modification.**  
Similar to other wrappers, modifying `sys.path` can cause unexpected side effects. Use relative or absolute imports if feasible.  

```diff
- sys.path.append(os.path.dirname(__file__))
+ # Consider using a relative import or adjusting your project structure
```

---

`17-19`: **Expose configuration options.**  
Requests to set `write.surfacecurve.mode` or `write.precision.mode` are currently numeric. Consider documenting or exposing enumerations/descriptive constants in code to improve readability.

</blockquote></details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_stl.py (3)</summary><blockquote>

`22-25`: **Handle potential empty result from b3d.Mesher.**  
When calling `meshed = b3d.Mesher().read(path)`, there is an assumption of at least one item in `meshed`. If the STL is invalid or empty, indexing `meshed[0]` could fail.  

```diff
        try:
            meshed = b3d.Mesher().read(path)
-           shape = meshed[0].wrapped
+           if meshed and len(meshed) > 0:
+               shape = meshed[0].wrapped
+           else:
+               raise RuntimeError("No shapes were read from the STL file")
```

---

`65-66`: **Check all statuses in BRepCheck_Solid.**  
Only checking the first status may overlook other issues. Consider iterating all statuses for a more robust verification.  

```diff
- if statuses.Size() > 0 and statuses.First() != BRepCheck_Status.BRepCheck_NoError:
+ for i in range(statuses.Size()):
+     if statuses.Value(i+1) != BRepCheck_Status.BRepCheck_NoError:
+         raise RuntimeError(f"Failed to read the STL file: Solid check failed: {statuses.Value(i+1)}")
```

---

`78-78`: **Consider returning more context with success case.**  
Currently, only `shape` is returned. If you need to debug or chain further operations, consider returning additional metadata such as volume or bounding box.

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (3)</summary><blockquote>

`30-31`: **Validate wrapper path and request dictionary usage.**  

Retrieving the Python wrapper script (`"stl.py"`) and preparing a basic request dictionary is an appropriate starting point. Consider adding keys to the request if parameters become necessary in the future.

---

`42-42`: **Confirm stderr usage or switch to logger if needed.**  

Writing directly to `stderr` might cause logs to go to unexpected endpoints in certain environments. If consistent logging is desired, consider capturing these errors using `pc_logging`.

---

`55-55`: **Shape retrieval from the wrapper result.**  

Assigning `shape = result.get("shape")` is direct. If the external process includes more metadata, consider capturing it to enrich debugging or logs.

</blockquote></details>
<details>
<summary>partcad/src/partcad/shape.py (2)</summary><blockquote>

`428-436`: **Docstring provides a helpful overview.**  

The docstring adequately explains parameters and usage. Keep it updated if new features or parameters (e.g., concurrency options) are added.

---

`485-569`: **Iterative multi-format rendering is flexible but be cautious.**  

1. Looping through each format for rendering is flexible, but keep an eye on performance if more formats are introduced or file size grows.  
2. Pickle-based serialization for wrapper calls still poses potential security risks if the external wrapper is compromised.  
3. Logging is thorough, though you may want to consider how partial failures are handled if multiple formats are requested.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between b986cef3016cd997deb2406a8ab0438fd49aef1e and d356c5e236427e6f3758243131743f5344d6d4bc.

</details>

<details>
<summary>📒 Files selected for processing (7)</summary>

* `partcad/src/partcad/actions/part/convert.py` (3 hunks)
* `partcad/src/partcad/part_factory_stl.py` (1 hunks)
* `partcad/src/partcad/shape.py` (5 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_brep.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_step.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_stl.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_stl.py` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (2)</summary>

* partcad/src/partcad/wrappers/wrapper_render_brep.py
* partcad/src/partcad/actions/part/convert.py

</details>

<details>
<summary>🔇 Additional comments (11)</summary><blockquote>

<details>
<summary>partcad/src/partcad/part_factory_stl.py (5)</summary>

`11-13`: **Imports appear appropriate for the wrapper-based approach.**  

These new imports (`base64`, `pickle`, `sys`) are necessary for request/response serialization with the external wrapper. No concerns here.

---

`17-17`: **Wrapper import aligns with externalizing STL processing.**  

Importing `.wrapper` is consistent with the move to delegate STL handling to a separate script. Ensure that the wrapper module is available and properly versioned.

---

`23-23`: **STL extension initialization looks correct.**  

Calling `super().__init__` with `extension=".stl"` is consistent with the class name. No issues identified.

---

`44-49`: **Robust error handling for deserialization.**  

Catching exceptions during the response deserialization is good. Confirm that any partial or corrupted data from the external wrapper is handled gracefully beyond simple logging.

---

`51-53`: **Wrapper failure feedback is clear.**  

Raising an exception if `result["success"]` is false is straightforward. Ensure top-level callers handle this exception properly (e.g., user-facing messaging or fallback logic).

</details>
<details>
<summary>partcad/src/partcad/shape.py (6)</summary>

`9-10`: **Type annotations and imports enhance maintainability.**  

Introducing `from __future__ import annotations`, `TYPE_CHECKING`, and `Optional` clarifies type usage, especially in asynchronous code. No immediate issues spotted.  



Also applies to: 21-21, 33-35

---

`46-46`: **Added `kind: str` attribute.**  

Explicitly defining the `kind` attribute clarifies the shape’s classification. Continue ensuring that all shape subtypes properly assign or override this field if needed.

---

`421-427`: **New `render_async` method signature is well-defined.**  

This asynchronous entry point centralizes rendering. Defining `ctx`, `format_name`, and `project` as typed parameters fosters clarity. Returning `None` is fine for a side-effect-based method.

---

`473-479`: **Log scope for the rendering action is well-defined.**  

Initiating a logging context with the format name in uppercase is a nice detail for readability. No issues here.

---

`482-483`: **Ensuring directories exist is good practice.**  

Calling `project.ctx.ensure_dirs_for_file` helps avoid I/O errors. No remarks.

---

`571-571`: **Synchronous `render()` delegates cleanly to `render_async()`.**  

Bridging synchronous code to async with `asyncio.run` is a standard approach. Provide consistent error handling or logging for calls to the new async method as well.  



Also applies to: 573-575, 578-578

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-15T15:35:44Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (1)</summary><blockquote>

`449-470`: _⚠️ Potential issue_

**PR objective conflict: PNG and SVG dependencies still present.**

The PR objective states "Drop PNG and SVG dependencies", yet the `WRAPPER_FORMATS` dictionary still includes entries for "svg" and "png" with their respective dependencies.



Consider removing the PNG and SVG entries from `WRAPPER_FORMATS` to align with the PR objective:

```diff
 WRAPPER_FORMATS = {
-"svg": [
-    "cadquery-ocp==7.7.2",
-    "ocpsvg==0.3.4",
-    "build123d==0.8.0"
-],
-"png": [
-    "cadquery-ocp==7.7.2",
-    "ocpsvg==0.3.4",
-    "build123d==0.8.0",
-    "svglib==1.5.1",
-    "reportlab",
-    "rlpycairo==0.3.0"
-],
 "brep": ["cadquery-ocp==7.7.2"],
 "step": ["cadquery-ocp==7.7.2"],
 "stl": ["cadquery-ocp==7.7.2"],
 "obj": ["cadquery-ocp==7.7.2"],
 "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
 "gltf": ["cadquery-ocp==7.7.2"],
 "threejs": ["cadquery-ocp==7.7.2"]
}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (3)</summary><blockquote>

`500-517`: **Format-specific parameter handling.**

The code sets appropriate parameters for each format, but the approach is verbose and could be refactored.


Consider using a configuration dictionary to define format-specific parameters:

```diff
-if format in ["svg", "png"]:
-    request["viewport_origin"] = kwargs.get("viewport_origin", [100, -100, 100])
-    request["line_weight"] = kwargs.get("line_weight", 1.0)
-    if format == "png":
-        request["width"] = kwargs.get("width", 512)
-        request["height"] = kwargs.get("height", 512)
-
-elif format in ["3mf", "obj", "gltf", "stl", "threejs"]:
-    request["tolerance"] = kwargs.get("tolerance", render_opts.get("tolerance", 0.1))
-    request["angularTolerance"] = kwargs.get("angularTolerance", render_opts.get("angularTolerance", 0.1))
-    if format == "stl":
-        request["ascii"] = kwargs.get("ascii", render_opts.get("ascii", False))
-    elif format == "gltf":
-        request["binary"] = kwargs.get("binary", render_opts.get("binary", False))
-
-elif format == "step":
-    request["write_pcurves"] = kwargs.get("write_pcurves", render_opts.get("write_pcurves", True))
-    request["precision_mode"] = kwargs.get("precision_mode", render_opts.get("precision_mode", 0))

+FORMAT_PARAMS = {
+    "svg": {
+        "viewport_origin": ([100, -100, 100], True),
+        "line_weight": (1.0, True),
+    },
+    "png": {
+        "viewport_origin": ([100, -100, 100], True),
+        "line_weight": (1.0, True),
+        "width": (512, True),
+        "height": (512, True),
+    },
+    "3mf": {
+        "tolerance": (0.1, False),
+        "angularTolerance": (0.1, False),
+    },
+    "obj": {
+        "tolerance": (0.1, False),
+        "angularTolerance": (0.1, False),
+    },
+    "gltf": {
+        "tolerance": (0.1, False),
+        "angularTolerance": (0.1, False),
+        "binary": (False, False),
+    },
+    "stl": {
+        "tolerance": (0.1, False),
+        "angularTolerance": (0.1, False),
+        "ascii": (False, False),
+    },
+    "threejs": {
+        "tolerance": (0.1, False),
+        "angularTolerance": (0.1, False),
+    },
+    "step": {
+        "write_pcurves": (True, False),
+        "precision_mode": (0, False),
+    },
+}
+
+if format in FORMAT_PARAMS:
+    for param, (default_value, use_kwargs_only) in FORMAT_PARAMS[format].items():
+        if use_kwargs_only:
+            request[param] = kwargs.get(param, default_value)
+        else:
+            request[param] = kwargs.get(param, render_opts.get(param, default_value))
```

This approach makes it easier to add or modify format-specific parameters in the future.

---

`538-562`: **Comprehensive error handling.**

The code handles various error scenarios, including stderr output, empty responses, deserialization errors, and wrapper execution failures. However, the error handling is a bit verbose.


Consider consolidating some of the error handling:

```diff
-if errors:
-    pc_logging.error(f"Wrapper {format_name} stderr:\n{errors}")
-
-response_lines = response_serialized.strip().splitlines()
-if not response_lines:
-    pc_logging.error(f"Empty response from wrapper: {wrapper_path}")
-    return
-
-cleaned_response = response_lines[-1].strip()
-
-# Handle response
-result = {}
-try:
-    response_bytes = base64.b64decode(cleaned_response)
-    result = pickle.loads(response_bytes)
-except Exception as e:
-    pc_logging.error(f"Failed to deserialize response: {e}")
-
-if not result.get("success", False):
-    pc_logging.error(
-        f"Render {format_name.upper()} failed for {self.project_name}:{self.name}: {result.get('exception', 'Unknown error')}"
-    )
-if "exception" in result and result["exception"]:
-    pc_logging.exception(f"Render {format_name.upper()} exception: {result['exception']}")

+if errors:
+    pc_logging.error(f"Wrapper {format_name} stderr:\n{errors}")
+
+result = {}
+try:
+    response_lines = response_serialized.strip().splitlines()
+    if not response_lines:
+        raise ValueError(f"Empty response from wrapper: {wrapper_path}")
+        
+    cleaned_response = response_lines[-1].strip()
+    response_bytes = base64.b64decode(cleaned_response)
+    result = pickle.loads(response_bytes)
+    
+    if not result.get("success", False):
+        error_msg = f"Render {format_name.upper()} failed for {self.project_name}:{self.name}: {result.get('exception', 'Unknown error')}"
+        pc_logging.error(error_msg)
+        if "exception" in result and result["exception"]:
+            pc_logging.exception(f"Render {format_name.upper()} exception: {result['exception']}")
+except Exception as e:
+    pc_logging.error(f"Failed to process wrapper response: {e}")
+    return
```

---

`622-622`: **Excessive logging in render_brep_async.**

The debug log message "render_brep_async" is not very informative and might be unnecessary since the method is already wrapped in a logging.Action block.


Consider removing the redundant log line:

```diff
-pc_logging.info("render_brep_async")
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d356c5e236427e6f3758243131743f5344d6d4bc and 9e85fa703bf7614e28ed8e9d0edbb7d31b34045e.

</details>

<details>
<summary>📒 Files selected for processing (8)</summary>

* `features/adhoc/convert.feature` (1 hunks)
* `partcad/src/partcad/actions/part/convert.py` (4 hunks)
* `partcad/src/partcad/adhoc/convert.py` (1 hunks)
* `partcad/src/partcad/shape.py` (5 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_gltf.py` (1 hunks)
* `partcad/tests/unit/test_adhoc_convert.py` (1 hunks)
* `partcad/tests/unit/test_convert_part.py` (1 hunks)
* `partcad/tests/unit/test_import_part.py` (1 hunks)

</details>

<details>
<summary>✅ Files skipped from review due to trivial changes (1)</summary>

* partcad/tests/unit/test_convert_part.py

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* partcad/src/partcad/actions/part/convert.py

</details>

<details>
<summary>🔇 Additional comments (26)</summary><blockquote>

<details>
<summary>partcad/tests/unit/test_import_part.py (2)</summary>

`12-12`: **Centralized extension mapping improves code organization**

Moving EXTENSION_MAPPING to `partcad.shape` follows the DRY principle by centralizing this mapping in one location rather than duplicating it across multiple test files. This change is aligned with the PR's goal of refactoring the dependencies.

---

`15-15`: **LGTM: Correctly utilizing the imported mapping**

The code now properly uses the imported EXTENSION_MAPPING to generate the list of supported formats, maintaining the same functionality while removing duplication.

</details>
<details>
<summary>partcad/tests/unit/test_adhoc_convert.py (1)</summary>

`87-87`: **Error message expectation updated for consistency**

The test now checks for a more generic error message pattern "Failed to convert:" instead of a specific STL error message. This change aligns with the modifications in the error handling approach across the codebase as part of removing PNG and SVG dependencies.

</details>
<details>
<summary>features/adhoc/convert.feature (2)</summary>

`146-146`: **Simplified error message expectation**

The expected error message has been generalized to "Failed to convert:" instead of including specific details about STL file reading. This change maintains consistency with the updated error handling approach in the codebase.

---

`154-154`: **Consistent error message simplification**

Similar to the previous change, this line also updates the expected error message to the more generic form, ensuring consistency across different error scenarios.

</details>
<details>
<summary>partcad/src/partcad/adhoc/convert.py (1)</summary>

`79-111`: <details>
<summary>❌ Incorrect review comment</summary>

**Verify if export_part function is still needed**

The `export_part` function is no longer called from `convert_cad_file`, but remains in the codebase. It appears to be dead code since the functionality has been moved to the `render` method of the `Part` class.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check if export_part is used elsewhere in the codebase
# If not used, it could be removed in a future PR

rg "export_part\(" --type py
```

Length of output: 619

---



</details>

**Action: Revisit the decision to remove export_part() – it is still in use.**

Based on our search:
- **Usage in Unit Tests:** The function is explicitly called in `partcad/tests/unit/test_adhoc_convert.py`.
- **Additional Reference:** An alternative definition is present in `partcad-ide-vscode/bundled/tool/lsp_server.py`.

These findings indicate that while `convert_cad_file` may no longer be calling export_part(), the function itself remains actively referenced elsewhere. If the goal is to deprecate its functionality in favor of the `render` method on the Part class, then all call sites (including tests and the LSP integration) need to be updated accordingly. Otherwise, the function should be retained and its documentation clarified to reflect its continued use.



> Likely an incorrect or invalid review comment.

</details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_render_gltf.py (10)</summary>

`1-6`: **License header looks appropriate.**

The Apache License 2.0 header is correctly included.

---

`7-9`: **Good documentation.**

Clear explanation of the script's purpose is provided in the comments.

---

`10-19`: **Import statements are well-organized.**

The imports are logically grouped with standard library imports first, followed by third-party imports, and then local imports.

---

`21-26`: **Good error validation in the process function.**

The function properly checks if a wrapped object is provided and raises a descriptive error if not.

---

`27-29`: **Sensible default parameters.**

Appropriate defaults are set for tolerance, angular_tolerance, and binary parameters.

---

`31-35`: **Good path handling.**

The code properly handles both relative and absolute paths, and creates the output directory if needed.

---

`37-43`: **Export parameters properly passed.**

The b3d.export_gltf function is called with all necessary parameters from the request.

---

`45-47`: **Excellent validation of export results.**

The code checks if the file was successfully created and has content, raising an informative exception if the operation failed.

---

`49-53`: **Comprehensive error handling.**

The function returns a structured response with success status and any exception details.

---

`56-59`: **Standard entry point pattern.**

The script follows the standard wrapper pattern used in the project for handling input and output.

</details>
<details>
<summary>partcad/src/partcad/shape.py (10)</summary>

`9-11`: **Good addition of type annotations.**

The addition of type annotations improves code clarity and enables better IDE support and static type checking.



Also applies to: 33-36

---

`41-52`: **Well-organized format extension mapping.**

The `EXTENSION_MAPPING` dictionary provides a clear mapping between format names and file extensions, making the code more maintainable.

---

`433-449`: **Well-documented async rendering method.**

The new `render_async` method is well-documented with a clear docstring explaining its purpose and parameters.

---

`472-475`: **Good directory handling for filepath.**

The code correctly checks if filepath is a directory and adjusts the configuration accordingly.

---

`476-479`: **Format-specific object type selection.**

The code selects the appropriate object type based on the format, using build123d for GLTF rendering or the wrapped object for other formats.

---

`488-493`: **Good handling of multiple formats.**

The code supports rendering multiple formats if no specific format is provided, using the formats defined in `WRAPPER_FORMATS`.

---

`496-498`: **Wrapper path resolution and request initialization.**

The code correctly resolves the wrapper path and initializes the request with the wrapped object.

---

`519-527`: **Serialization and dependency installation.**

The code correctly serializes the request and ensures that the required dependencies are installed for the specified format.

---

`529-536`: **Wrapper execution.**

The code properly executes the wrapper script with the serialized request.

---

`564-571`: **Updated render method.**

The render method has been updated to use the new render_async method, making it more consistent and maintainable.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-16T15:36:27Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>.github/workflows/test.yml (1)</summary><blockquote>

`199-199`: **Extended Timeout for the Test-Behave Job**

The `timeout-minutes` for the test-behave job has been increased to 120 minutes. Please verify that this extension is intentional and necessary to accommodate longer-running tests. If possible, consider adding an inline comment explaining the rationale behind this change to improve future maintainability and clarity.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 9e85fa703bf7614e28ed8e9d0edbb7d31b34045e and 32c64fbe0dccb1f0c27a520bd7a97836b5376a19.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `.github/workflows/test.yml` (1 hunks)

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-16T21:12:48Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (1)</summary><blockquote>

`433-562`: _⚠️ Potential issue_

**PNG and SVG dependencies still present despite PR objective.**

The PR objective mentions dropping PNG and SVG dependencies, but the `WRAPPER_FORMATS` dictionary in the new `render_async` method still includes dependencies for "svg" and "png" formats. These dependencies should be removed or marked as optional to align with the PR objective.


Consider removing the PNG and SVG entries from the `WRAPPER_FORMATS` dictionary or marking them as optional if they're still needed:

```diff
WRAPPER_FORMATS = {
-    "svg": [
-        "cadquery-ocp==7.7.2",
-        "ocpsvg==0.3.4",
-        "build123d==0.8.0"
-    ],
-    "png": [
-        "cadquery-ocp==7.7.2",
-        "ocpsvg==0.3.4",
-        "build123d==0.8.0",
-        "svglib==1.5.1",
-        "reportlab",
-        "rlpycairo==0.3.0"
-    ],
    "brep": ["cadquery-ocp==7.7.2"],
    # Keep other formats...
}
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>partcad/src/partcad/adhoc/convert.py (1)</summary><blockquote>

`79-111`: **Consider removing unused `export_part` function**

The `export_part` function appears to be unused in this file since the code now directly calls `part.render()`. If tests or other modules still reference this function, consider:

1. Creating a thin wrapper that uses the new rendering approach internally
2. Deprecating and eventually removing this function
3. Updating all remaining references to use the direct render approach

This would complete the refactoring and simplify the codebase.

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (2)</summary><blockquote>

`40-41`: **Debugging statement should be removed before merging.**

The debugging log statement on line 40 should be removed before finalizing this PR as it appears to be temporary debug code.


```diff
-            pc_logging.info("debug1")
```

---

`49-68`: <details>
<summary>❓ Verification inconclusive</summary>

**Remove debugging statement and check for potential performance impact.**

The refactored code significantly simplifies the `instantiate` method by removing the complex logic for managing concurrent file loading, which aligns with the goal of dropping dependencies. However:

1. There's another debugging statement on line 49 that should be removed
2. The complete removal of the concurrent processing logic might impact performance


```diff
-            pc_logging.info("debug2")
```


Note the TODO comment at lines 31-32 mentioning GIL performance concerns. Since the code previously had logic for managing concurrent processing that's now removed, verify that this change doesn't negatively impact performance for large STEP files.

---

🏁 Script executed:

```shell
#!/bin/bash
# Look for any performance-related tests or benchmarks that might be affected
rg -A 5 "performance|benchmark" --glob "*.py" | grep -v "test_"
rg -A 5 "test.*step" --glob "*test*.py"
```

Length of output: 2877

---



</details>

**Action Required: Remove Debug Logging and Reassess Performance Impact**

- **Remove Debug Statement:** Please remove the log on line 49 (`pc_logging.info("debug2")`) as it is no longer needed.
- **Performance Consideration:** With the removal of the concurrent processing logic, verify that the code still handles large STEP files efficiently. In particular, note the TODO at lines 31–32 (regarding GIL concerns and potential performance benefits) and consider running additional performance benchmarks if applicable.

</blockquote></details>
<details>
<summary>partcad/src/partcad/shape.py (1)</summary><blockquote>

`433-562`: **Consider further refactoring and code consolidation.**

The new `render_async` method duplicates code patterns with the individual render methods that remain in the file (like `render_gltf_async`, `render_obj_async`, etc.). Consider refactoring these methods to use the centralized `render_async` method or removing them entirely.


In a future PR, you might want to update all the individual `render_X_async` methods to use the new unified `render_async` method, which would further reduce code duplication.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 32c64fbe0dccb1f0c27a520bd7a97836b5376a19 and 39c38efa9f76db41c506ed30f92d51bd9e3e8726.

</details>

<details>
<summary>📒 Files selected for processing (22)</summary>

* `.github/workflows/test.yml` (1 hunks)
* `examples/feature_convert/partcad.yaml` (2 hunks)
* `features/adhoc/convert.feature` (1 hunks)
* `partcad-ide-vscode/bundled/tool/lsp_server.py` (3 hunks)
* `partcad/src/partcad/actions/part/convert.py` (4 hunks)
* `partcad/src/partcad/adhoc/convert.py` (1 hunks)
* `partcad/src/partcad/part_factory_feature_ai.py` (3 hunks)
* `partcad/src/partcad/part_factory_step.py` (1 hunks)
* `partcad/src/partcad/part_factory_stl.py` (1 hunks)
* `partcad/src/partcad/project.py` (5 hunks)
* `partcad/src/partcad/shape.py` (5 hunks)
* `partcad/src/partcad/shape_ai.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_common.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_brep.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_gltf.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_step.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_stl.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_stl.py` (1 hunks)
* `partcad/tests/unit/test_adhoc_convert.py` (1 hunks)
* `partcad/tests/unit/test_convert_part.py` (1 hunks)
* `partcad/tests/unit/test_import_part.py` (1 hunks)
* `partcad/tests/unit/test_render.py` (3 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (17)</summary>

* partcad/src/partcad/shape_ai.py
* partcad/tests/unit/test_adhoc_convert.py
* partcad/tests/unit/test_convert_part.py
* partcad/tests/unit/test_import_part.py
* partcad/src/partcad/wrappers/wrapper_render_step.py
* partcad/src/partcad/part_factory_stl.py
* partcad-ide-vscode/bundled/tool/lsp_server.py
* partcad/src/partcad/actions/part/convert.py
* partcad/src/partcad/wrappers/wrapper_render_stl.py
* partcad/src/partcad/wrappers/wrapper_stl.py
* partcad/src/partcad/wrappers/wrapper_render_brep.py
* partcad/src/partcad/part_factory_feature_ai.py
* partcad/tests/unit/test_render.py
* examples/feature_convert/partcad.yaml
* features/adhoc/convert.feature
* .github/workflows/test.yml
* partcad/src/partcad/project.py

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>partcad/src/partcad/adhoc/convert.py (1)</summary>

`50-52`: **Direct render call implementation looks good**

The code correctly switched to using `part.render()` directly, which aligns with the PR goal of refactoring and simplifying the rendering approach.

</details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (1)</summary>

`44-44`: **LGTM: Simplified runtime initialization.**

The code now directly uses a fixed Python runtime version (3.11) rather than conditional logic, which aligns with the refactoring goals.

</details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_render_gltf.py (1)</summary>

`1-60`: **Well-structured implementation for GLTF rendering.**

This new wrapper script for GLTF rendering follows the established pattern of other wrapper scripts in the project. It correctly handles file paths, error cases, and properly utilizes the build123d library for GLTF export.

I particularly like the explicit check on line 46-47 to verify the file was successfully created. This defensive programming approach will help catch edge cases where the export might silently fail.

</details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_common.py (1)</summary>

`65-90`: **Improved error handling with better defensive checks.**

The updated error handling in the `handle_exception` function is now more robust with multiple defensive checks:
1. Checks if traceback is `None`
2. Moves down one level in the traceback chain when possible
3. Safely handles file reading operations with appropriate error messages
4. Provides clear error output for various failure scenarios

This is a solid improvement to the error reporting system which will help with debugging.

</details>
<details>
<summary>partcad/src/partcad/shape.py (4)</summary>

`9-36`: **Good addition of type hints.**

Adding proper type annotations improves code clarity and enables better IDE support and static type checking. This is a welcome improvement that enhances code quality.

---

`41-52`: **Good organization with EXTENSION_MAPPING.**

Creating a centralized extension mapping dictionary improves maintainability by keeping file extension information in one place, rather than scattered throughout the code. This is a good refactoring approach.

---

`485-486`: **Check if project.ctx is not None before accessing it.**

There's a potential null pointer dereference if `project` is not `None` but `project.ctx` is `None`.


Consider adding an additional check:

```diff
-            if project is not None:
+            if project is not None and project.ctx is not None:
                 project.ctx.ensure_dirs_for_file(filepath)
```

---

`564-571`: **Updated render method properly uses new centralized render_async.**

The `render` method has been updated to call the new `render_async` method, which is a good approach to consolidate rendering logic and reduce code duplication. This aligns with the overall refactoring goal.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-17T16:44:43Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (6)</summary><blockquote>

<details>
<summary>partcad/src/partcad/wrappers/wrapper_common.py (1)</summary><blockquote>

`64-66`: **Ensure clarity in logging statements.**

The new empty line (64) and additional lines (66) set up a more coherent structure for logging. Adding context about the exception type or adding a prefix (e.g. `"Traceback: "`) may help debugging.

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (1)</summary><blockquote>

`40-41`: **Debug logging inclusion.**

Temporary debug logs (e.g. `"debug1"`) can help, but remember to remove or refine these logs before finalizing production code.

</blockquote></details>
<details>
<summary>partcad/src/partcad/adhoc/convert.py (1)</summary><blockquote>

`50-52`: **Direct rendering approach.**

Switching to `part.render(...)` eliminates duplication. Consider removing or refactoring the `export_part` method below if it’s no longer used.

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (3)</summary><blockquote>

`30-31`: **Clarify the request payload.**
Currently, `request = {}` is empty. If additional data is needed, ensure it's properly included before serialization.

---

`42-42`: **Log errors robustly.**
Using `sys.stderr.write(errors)` is acceptable, but consider additional logs if needed.

---

`51-53`: **Raise meaningful exceptions.**
Raising `Exception(result.get("exception"))` is fine, though you may consider a custom exception type for more context.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 39c38efa9f76db41c506ed30f92d51bd9e3e8726 and 09f2b6fa16eb07bc945d7d9ac6075a6478609ba8.

</details>

<details>
<summary>📒 Files selected for processing (22)</summary>

* `.github/workflows/test.yml` (1 hunks)
* `examples/feature_convert/partcad.yaml` (2 hunks)
* `features/adhoc/convert.feature` (1 hunks)
* `partcad-ide-vscode/bundled/tool/lsp_server.py` (3 hunks)
* `partcad/src/partcad/actions/part/convert.py` (4 hunks)
* `partcad/src/partcad/adhoc/convert.py` (1 hunks)
* `partcad/src/partcad/part_factory_feature_ai.py` (3 hunks)
* `partcad/src/partcad/part_factory_step.py` (1 hunks)
* `partcad/src/partcad/part_factory_stl.py` (1 hunks)
* `partcad/src/partcad/project.py` (5 hunks)
* `partcad/src/partcad/shape.py` (5 hunks)
* `partcad/src/partcad/shape_ai.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_common.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_brep.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_gltf.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_step.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_stl.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_stl.py` (1 hunks)
* `partcad/tests/unit/test_adhoc_convert.py` (1 hunks)
* `partcad/tests/unit/test_convert_part.py` (1 hunks)
* `partcad/tests/unit/test_import_part.py` (1 hunks)
* `partcad/tests/unit/test_render.py` (3 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (16)</summary>

* partcad/tests/unit/test_import_part.py
* .github/workflows/test.yml
* partcad/src/partcad/wrappers/wrapper_render_stl.py
* partcad/tests/unit/test_convert_part.py
* partcad/src/partcad/wrappers/wrapper_render_step.py
* partcad/src/partcad/shape_ai.py
* partcad/tests/unit/test_adhoc_convert.py
* partcad/src/partcad/wrappers/wrapper_render_gltf.py
* features/adhoc/convert.feature
* partcad/src/partcad/part_factory_feature_ai.py
* partcad-ide-vscode/bundled/tool/lsp_server.py
* partcad/src/partcad/actions/part/convert.py
* partcad/src/partcad/wrappers/wrapper_render_brep.py
* partcad/tests/unit/test_render.py
* examples/feature_convert/partcad.yaml
* partcad/src/partcad/project.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (2)</summary>

<details>
<summary>partcad/src/partcad/adhoc/convert.py (2)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (1) (1)</summary>

* `render` (564:571)

</details>
<details>
<summary>partcad/src/partcad/context.py (1) (1)</summary>

* `render` (852:857)

</details>

</blockquote></details>
<details>
<summary>partcad/src/partcad/shape.py (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/context.py (3) (3)</summary>

* `Context` (37:883)
* `render_async` (845:850)
* `render` (852:857)

</details>
<details>
<summary>partcad/src/partcad/project.py (3) (3)</summary>

* `Project` (70:1761)
* `render_async` (1331:1372)
* `render` (1415:1424)

</details>
<details>
<summary>partcad/src/partcad/shape_config.py (1) (1)</summary>

* `ShapeConfiguration` (16:54)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (26)</summary><blockquote>

<details>
<summary>partcad/src/partcad/wrappers/wrapper_common.py (4)</summary>

`67-68`: **Check for missing traceback scenario.**

Selecting a `No traceback available` message is a good fallback. Double-check other edge cases (e.g., ephemereal Python-generated frames) to ensure we don’t incorrectly skip.

---

`70-73`: **Refinement of traceback down-level logic.**

The `tb.tb_next` usage is correct. Consider verifying multiple steps in more complex tracebacks if needed, or else confirm that only the immediate next level is relevant.

---

`74-79`: **File reading approach is valid.**

The nested `try` block ensures robust error handling. The user-friendly message about line out-of-range is helpful.

---

`88-90`: **Adequate fallback for file read errors.**

Properly capturing the file read exception (e.g. permission issues, file not found) helps diagnosing user environment. The message is descriptive.

</details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (3)</summary>

`44-44`: **Runtime assignment.**

Assigning `self.runtime = self.ctx.get_python_runtime("3.11")` is a simpler approach. Ensure the chosen version is installed or handled gracefully if not available.

---

`49-59`: **Asynchronous run approach.**

This block runs the step conversion wrapper with `await self.runtime.run_async(...)`. The code is more direct. Confirm that error logs (`errors`) are captured in logs or stored in part data if needed for triage.

---

`60-67`: **Exception handling now raises.**

Good practice to re-raise the wrapper’s exception as a Python exception. This ensures the calling code can handle or log it.

</details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_stl.py (6)</summary>

`1-9`: **Fallback approach for STL read.**

Using both `build123d.Mesher` and `RWStl` broadens compatibility. Good to catch exceptions from the first approach promptly.

---

`23-25`: **Mesher usage.**

`b3d.Mesher().read(path)` aligns with build123d usage. Check large-file performance if used in production.

---

`26-39`: **Fallback reading and shape checks.**

Constructing `TopoDS_Face` from STL input is robust. The stepwise validations for `Null`, shape type, and infinity are thorough.

---

`42-52`: **Shell creation and validation.**

Ensuring the shell is closed prevents invalid geometry from proceeding. The detailed errors help clarity.

---

`55-70`: **Solid creation and checks.**

Comprehensive checks for null solids, infinite solids, and negative volume. This thorough approach avoids subtle geometry issues.

---

`74-78`: **Graceful error return.**

Capturing and returning the error as JSON ensures consistent messaging for external callers.

</details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (6)</summary>

`11-13`: **Validate the necessity of pickle imports.**
If you control both ends of serialization, consider a safer format (e.g., JSON) if there's any chance of external input.

---

`23-23`: **Initialization logic looks good.**
The `super().__init__` call is straightforward and likely correct for establishing the `.stl` extension.

---

`33-34`: **Base64 + pickle usage.**
Again, confirm that data is never untrusted. Consider robust validation or adopting a safer format.

---

`36-37`: **Runtime call for wrapper execution seems fine.**
No immediate issues noted, but keep an eye on error handling for potential race conditions or concurrency if multiple tasks call this method simultaneously.  



Also applies to: 38-40

---

`44-49`: **Deserialization exception handling is sufficient.**
Ensure logs contain enough diagnostic info to troubleshoot issues.

---

`55-55`: **Overall success path looks good.**
The final shape is retrieved without issues.

</details>
<details>
<summary>partcad/src/partcad/shape.py (7)</summary>

`9-10`: **Type hints usage is beneficial.**
The import of `annotations` and `TYPE_CHECKING` plus `Optional` usage clarifies interfaces.  



Also applies to: 21-21

---

`33-35`: **Conditional imports for context/project.**
This is a good pattern to minimize circular references.

---

`41-52`: **Extension mapping looks fine.**
No obvious issues, though confirm if all formats are still required.

---

`58-58`: **Naming matches the domain.**
No concerns with `kind: str`.

---

`433-471`: **Centralized `render_async` approach is consistent.**
Implementation is generally well-structured, but note the discrepancy: the PR aims to drop PNG/SVG dependencies, yet they remain in `WRAPPER_FORMATS`.  

Could you confirm if PNG/SVG references are truly intended for removal here?

---

`485-485`: **Conditional directory creation.**
Adding `if project is not None:` check is correct. Also ensure `project.ctx` is valid.

---

`541-564`: **Response parsing logic is consistent.**
Check for potential empty lines in `response_serialized`; your checks are robust.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-18T08:39:11Z
user: login: clairbee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T17:45:20Z
user: login: clairbee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T17:47:34Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 1**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>partcad/src/partcad/adhoc/convert.py (1)</summary><blockquote>

`50-52`: **Consider removing or refactoring the now-unused `export_part` function.**  
Directly calling `part.render(...)` replaces the old export flow. If `export_part` is no longer used, removing it can reduce confusion and maintenance overhead.


Below is a sample diff to remove the unused function entirely:

```diff
@@ -79,33 +79,6 @@ def generate_partcad_config(temp_dir: Path, input_type: str, temp_input_path: Path) -> None:
 
-def export_part(part: Part, output_filename: str | Path, output_type: str, ctx: Context) -> None:
-    """
-    Export a part to the desired format.
-    ...
-    """
-    export_methods: dict[str, str] = {
-        "step": "render_step",
-        "brep": "render_brep",
-        "stl": "render_stl",
-        "3mf": "render_3mf",
-        "threejs": "render_threejs",
-        "obj": "render_obj",
-        "gltf": "render_gltf",
-    }
-    export_method = export_methods.get(output_type)
-    if not export_method:
-        raise ValueError(f"Unsupported export format: {output_type}")
-    if not hasattr(part, export_method):
-        raise RuntimeError(f"Part does not support export to '{output_type}'.")
-    output_filename = str(output_filename)
-    getattr(part, export_method)(ctx=ctx, filepath=output_filename)
```

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (1)</summary><blockquote>

`36-43`: **Ensure that stderr output is handled appropriately.**  
Logging to stderr may suffice, but if `errors` indicates critical failure, consider additional error handling or skipping further steps.

</blockquote></details>
<details>
<summary>partcad/src/partcad/wrappers/wrapper_common.py (1)</summary><blockquote>

`64-90`: **Consider logging the line number in the "out of range" case.**

This traceback-handling code is robust and does a good job of reporting file read failures and missing tracebacks. As an extra enhancement, you might log the specific line number that's out of range (e.g., `tb.tb_lineno`) to assist debugging.

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (1)</summary><blockquote>

`48-66`: **Consider raising or handling errors from the wrapper output.**

Currently, the code prints `errors` to stderr but does not terminate unless `result["success"]` is `False`. In some scenarios, you might want to fail fast if `errors` is non-empty. Otherwise, this looks good and aligns well with the asynchronous logic.

```diff
+            if errors.strip():
+                raise Exception(f"Runtime wrapper produced errors: {errors}")
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 09f2b6fa16eb07bc945d7d9ac6075a6478609ba8 and 31bbca154b0d4f38202483b337ba46404807ed07.

</details>

<details>
<summary>📒 Files selected for processing (24)</summary>

* `.devcontainer/requirements.txt` (0 hunks)
* `.github/workflows/test.yml` (1 hunks)
* `examples/feature_convert/partcad.yaml` (2 hunks)
* `features/adhoc/convert.feature` (1 hunks)
* `partcad-ide-vscode/bundled/tool/lsp_server.py` (3 hunks)
* `partcad-ide-vscode/requirements.txt` (0 hunks)
* `partcad/src/partcad/actions/part/convert.py` (4 hunks)
* `partcad/src/partcad/adhoc/convert.py` (1 hunks)
* `partcad/src/partcad/part_factory_feature_ai.py` (3 hunks)
* `partcad/src/partcad/part_factory_step.py` (1 hunks)
* `partcad/src/partcad/part_factory_stl.py` (1 hunks)
* `partcad/src/partcad/project.py` (5 hunks)
* `partcad/src/partcad/shape.py` (5 hunks)
* `partcad/src/partcad/shape_ai.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_common.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_brep.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_gltf.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_step.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_stl.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_stl.py` (1 hunks)
* `partcad/tests/unit/test_adhoc_convert.py` (1 hunks)
* `partcad/tests/unit/test_convert_part.py` (1 hunks)
* `partcad/tests/unit/test_import_part.py` (1 hunks)
* `partcad/tests/unit/test_render.py` (3 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (2)</summary>

* .devcontainer/requirements.txt
* partcad-ide-vscode/requirements.txt

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (16)</summary>

* partcad/tests/unit/test_import_part.py
* examples/feature_convert/partcad.yaml
* partcad/tests/unit/test_convert_part.py
* partcad/src/partcad/wrappers/wrapper_render_stl.py
* partcad/src/partcad/shape_ai.py
* .github/workflows/test.yml
* partcad/tests/unit/test_adhoc_convert.py
* partcad-ide-vscode/bundled/tool/lsp_server.py
* partcad/src/partcad/wrappers/wrapper_render_brep.py
* partcad/src/partcad/wrappers/wrapper_render_step.py
* partcad/src/partcad/part_factory_feature_ai.py
* features/adhoc/convert.feature
* partcad/src/partcad/wrappers/wrapper_render_gltf.py
* partcad/src/partcad/actions/part/convert.py
* partcad/tests/unit/test_render.py
* partcad/src/partcad/project.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (3)</summary>

<details>
<summary>partcad/src/partcad/part_factory_stl.py (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/part_factory_file.py (2) (2)</summary>

* `PartFactoryFile` (16-62)
* `instantiate` (59-62)

</details>
<details>
<summary>partcad/src/partcad/context.py (1) (1)</summary>

* `get_python_runtime` (862-874)

</details>
<details>
<summary>partcad/src/partcad/runtime_python.py (1) (1)</summary>

* `run_async` (243-245)

</details>

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (7)</summary><blockquote>

<details>
<summary>partcad/src/partcad/part_factory.py (1) (1)</summary>

* `_create` (42-48)

</details>
<details>
<summary>partcad/src/partcad/context.py (1) (1)</summary>

* `get_python_runtime` (862-874)

</details>
<details>
<summary>partcad/src/partcad/part_factory_cadquery.py (1) (1)</summary>

* `instantiate` (49-153)

</details>
<details>
<summary>partcad/src/partcad/part_factory_build123d.py (1) (1)</summary>

* `instantiate` (54-190)

</details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (1) (1)</summary>

* `instantiate` (26-58)

</details>
<details>
<summary>partcad/src/partcad/part_factory_file.py (1) (1)</summary>

* `instantiate` (59-62)

</details>
<details>
<summary>partcad/src/partcad/runtime.py (1) (1)</summary>

* `run_async` (209-281)

</details>

</blockquote></details>
<details>
<summary>partcad/src/partcad/shape.py (6)</summary><blockquote>

<details>
<summary>partcad/src/partcad/context.py (3) (3)</summary>

* `Context` (37-885)
* `render_async` (847-852)
* `render` (854-859)

</details>
<details>
<summary>partcad/src/partcad/project.py (3) (3)</summary>

* `Project` (70-1760)
* `render_async` (1331-1371)
* `render` (1414-1423)

</details>
<details>
<summary>partcad/src/partcad/wrappers/ocp_serialize.py (1) (1)</summary>

* `register` (227-260)

</details>
<details>
<summary>partcad/src/partcad/shape_config.py (1) (1)</summary>

* `ShapeConfiguration` (16-54)

</details>
<details>
<summary>partcad/src/partcad/sync_threads.py (2) (2)</summary>

* `run_async` (81-85)
* `run` (69-73)

</details>
<details>
<summary>partcad/src/partcad/runtime.py (2) (2)</summary>

* `run_async` (209-281)
* `run` (137-207)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (8)</summary><blockquote>

<details>
<summary>partcad/src/partcad/wrappers/wrapper_stl.py (1)</summary>

`1-84`: **Robust STL processing flow.**  
This file provides a well-structured fallback-based approach for reading STL files. It thoroughly checks for null shapes, infinite geometry, and closed solids, covering a wide range of error conditions. This should improve error reporting and maintain user confidence in shape integrity.

</details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (4)</summary>

`11-13`: **No issues with the new imports.**  
Importing `base64`, `pickle`, and `sys` here is straightforward for serialization/deserialization and logging.

---

`30-34`: **Potential security concern with use of `pickle`.**  
This topic was raised previously regarding external input. If this data can originate from outside the trusted system, consider a safer serialization format (e.g., JSON).

---

`44-49`: **Same serialization concern when unpickling the response.**  
Confirm the wrapper’s output is fully trusted. Otherwise, consider a more secure format to mitigate code injection risks.

---

`51-53`: **Error handling logic looks good.**  
Raising an exception when `success` is false ensures clear feedback to callers that the STL import failed.

</details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (2)</summary>

`40-40`: **No concerns on calling superclass initializer here.**

---

`43-43`: **Verify runtime usage.**

Fetching a Python 3.11 runtime is consistent with the rest of the codebase. Make sure this aligns with your deployment environment so no unexpected version conflicts arise.

Would you like a script to confirm that your CI environment supports Python 3.11?

</details>
<details>
<summary>partcad/src/partcad/shape.py (1)</summary>

`485-486`: **Potential null pointer dereference remains a concern.**


This was raised in a previous review: if `project.ctx` is unexpectedly `None`, `project.ctx.ensure_dirs_for_file(...)` will throw an AttributeError. Double-check that `project.ctx` is guaranteed to exist whenever `project is not None`, or add a check for `project.ctx`.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-20T13:35:02Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 62c5d833700a2bd7ade309d880391641782109bb and 3912ebd90e3bf925169d10911238805701a429a6.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `partcad/src/partcad/actions/part/convert.py` (4 hunks)
* `partcad/src/partcad/project.py` (4 hunks)
* `partcad/src/partcad/shape.py` (4 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* partcad/src/partcad/actions/part/convert.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (2)</summary>

<details>
<summary>partcad/src/partcad/shape.py (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/context.py (3) (3)</summary>

* `Context` (37-885)
* `render_async` (847-852)
* `render` (854-859)

</details>
<details>
<summary>partcad/src/partcad/project.py (3) (3)</summary>

* `Project` (70-1709)
* `render_async` (1331-1371)
* `render` (1414-1423)

</details>
<details>
<summary>partcad/src/partcad/shape_config.py (1) (1)</summary>

* `ShapeConfiguration` (16-54)

</details>

</blockquote></details>
<details>
<summary>partcad/src/partcad/project.py (2)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (2) (2)</summary>

* `Shape` (54-571)
* `render` (564-571)

</details>
<details>
<summary>partcad/src/partcad/exception.py (1) (1)</summary>

* `EmptyShapesError` (15-20)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (14)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (7)</summary>

`9-10`: **Imports for future annotations and type checking look good.**
No issues noted here.  



Also applies to: 21-21, 32-35

---

`40-51`: **Extension mapping appears correct and consistent.**
No further concerns.

---

`57-57`: **Typed attribute for `kind` field confirmed.**
No issues.

---

`472-484`: **Basic structure for rendering setup.**
The logging usage and directory assignment look correct.

---

`487-526`: **Rendering loop with format-based selection.**
Logic and parameter gathering appear coherent.

---

`529-563`: **External process execution and response handling.**
Error logging and base64/pickle usage are fine; no immediate issues found.

---

`565-571`: **Synchronous wrapper for `render_async`.**
Straightforward usage of `asyncio.run`; no issues spotted.

</details>
<details>
<summary>partcad/src/partcad/project.py (7)</summary>

`18-18`: **New import statements.**
Using `rich_click.Path` and direct import of `Shape` is acceptable.  



Also applies to: 25-25

---

`1333-1338`: **Enhanced signature for `render_async` with explicit type hints.**
Improves clarity and maintainability.

---

`1345-1346`: **Conditional output directory handling.**
Updating the render configuration on-the-fly looks correct.

---

`1349-1351`: **Fetching render config and enumerating shapes.**
Raising `EmptyShapesError` if any shape is `None` is a good safety check.

---

`1373-1390`: **Shape enumeration logic.**
Consolidating shapes from sketches, parts, and assemblies is a good approach.

---

`1392-1413`: **Filtering logic for whether a format should be rendered.**
Implementation is consistent and makes sense.

---

`1416-1423`: **Synchronous wrapper for `render_async`.**
Straightforward usage and passes correct arguments.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-22T14:45:53Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>♻️ Duplicate comments (2)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (2)</summary><blockquote>

`449-470`: _⚠️ Potential issue_

**References to PNG/SVG contradict PR objective.**

The WRAPPER_FORMATS dictionary still includes "svg" and "png" entries with their respective dependencies. Given the PR objective "refactor: Drop PNG and SVG dependencies," these references should be removed.



Consider removing these entries or making them optional if they're still needed temporarily:

```diff
WRAPPER_FORMATS = {
-    "svg": [
-        "cadquery-ocp==7.7.2",
-        "ocpsvg==0.3.4",
-        "build123d==0.8.0"
-    ],
-    "png": [
-        "cadquery-ocp==7.7.2",
-        "ocpsvg==0.3.4",
-        "build123d==0.8.0",
-        "svglib==1.5.1",
-        "reportlab",
-        "rlpycairo==0.3.0"
-    ],
    "brep": ["cadquery-ocp==7.7.2"],
    # ... other formats
}
```

---

`487-488`: _⚠️ Potential issue_

**Ensure `project.ctx` is not null before usage.**

There's a potential null pointer dereference if `project` is not `None` but `project.ctx` is `None`. Consider checking that both are non-null:

```diff
-if project is not None:
+if project is not None and project.ctx is not None:
    project.ctx.ensure_dirs_for_file(filepath)
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 3912ebd90e3bf925169d10911238805701a429a6 and f6659780bdc92f0fe09bd4ceea22bf3c5f4da24b.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `partcad/src/partcad/shape.py` (4 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (1)</summary>

<details>
<summary>partcad/src/partcad/shape.py (2)</summary><blockquote>

<details>
<summary>partcad/src/partcad/context.py (4) (4)</summary>

* `Context` (37-885)
* `render_async` (847-852)
* `get_python_runtime` (862-874)
* `render` (854-859)

</details>
<details>
<summary>partcad/src/partcad/project.py (3) (3)</summary>

* `Project` (70-1709)
* `render_async` (1331-1371)
* `render` (1414-1423)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (5)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (5)</summary>

`9-35`: **Appropriate type hints and imports added.**

The addition of proper imports, TYPE_CHECKING, and explicit imports from partcad.context and partcad.project improves code readability and IDE support.

---

`40-52`: **Well-structured extension mapping.**

The EXTENSION_MAPPING dictionary provides a clear mapping between format names and their corresponding file extensions, which helps with file path construction during rendering.

---

`479-479`: **The use of `get_build123d` for gltf format might be problematic.**

As noted in a previous comment, this should likely use `get_wrapped()` instead to maintain consistency with other formats.

---

`541-547`: **Improved error handling.**

The addition of explicit error handling for wrapper errors and empty responses improves robustness of the rendering process.

---

`567-574`: **Clear method signature with appropriate type hints.**

The updated `render` method signature with explicit type hints improves clarity and maintainability.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-22T15:47:02Z
user: login: clairbee
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T02:47:03Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (3)</summary><blockquote>

`449-470`: _⚠️ Potential issue_

**Remove PNG/SVG references to fulfill PR objective.**

The `WRAPPER_FORMATS` dictionary still contains references to "svg" and "png" formats along with their dependencies. This contradicts the PR objective "refactor: Drop PNG and SVG dependencies."

Consider removing these format references entirely or implementing them as optional features with a clear warning mechanism if they're still needed temporarily.



```diff
WRAPPER_FORMATS = {
-        "svg": [
-            "cadquery-ocp==7.7.2",
-            "ocpsvg==0.3.4",
-            "build123d==0.8.0"
-        ],
-        "png": [
-            "cadquery-ocp==7.7.2",
-            "ocpsvg==0.3.4",
-            "build123d==0.8.0",
-            "svglib==1.5.1",
-            "reportlab",
-            "rlpycairo==0.3.0"
-        ],
        "brep": ["cadquery-ocp==7.7.2"],
        "step": ["cadquery-ocp==7.7.2"],
        "stl": ["cadquery-ocp==7.7.2"],
        "obj": ["cadquery-ocp==7.7.2"],
        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
        "gltf": ["cadquery-ocp==7.7.2"],
        "threejs": ["cadquery-ocp==7.7.2"]
    }
```

---

`528-529`: _⚠️ Potential issue_

**Fix format variable inconsistency causing dependency installation issue.**

This line uses `format_name` instead of the current `format` from the loop, which could cause the wrong dependencies to be installed when formats don't match.



```diff
-                dependencies = WRAPPER_FORMATS[format_name]
+                dependencies = WRAPPER_FORMATS[format]
                await asyncio.gather(*(runtime.ensure_async(dep) for dep in dependencies))
```

---

`487-488`: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Add null check for project.ctx before using it.**

The code checks if `project` is not None but doesn't verify if `project.ctx` also exists before accessing it, which could lead to an AttributeError.


```diff
-            if project is not None:
+            if project is not None and hasattr(project, 'ctx'):
                project.ctx.ensure_dirs_for_file(filepath)
```

Let's verify this potential issue:

---

🏁 Script executed:

```shell
#!/bin/bash
# Check for instances of project.ctx access without proper null checks
rg -A 2 "project.ctx" --type py
rg -A 2 "if project is not None:" --type py | grep -A 2 "project.ctx"
```

Length of output: 4728

---



</details>

**Action required: Update the null check in `partcad/src/partcad/shape.py` to verify that `project.ctx` exists.**

The verification confirms that at lines 487–488 the code only checks if `project` is not None and then immediately accesses `project.ctx`. To prevent a possible AttributeError when `ctx` is missing, please update the conditional to check for the existence of `project.ctx`.

- **File:** `partcad/src/partcad/shape.py`
- **Lines:** 487–488
- **Suggested change:**
  ```diff
  -            if project is not None:
  +            if project is not None and hasattr(project, 'ctx'):
  ```
  
This adjustment ensures that the method `ensure_dirs_for_file(filepath)` is only called when both `project` and its `ctx` attribute are available.

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/wrappers/wrapper_stl.py (2)</summary><blockquote>

`17-18`: **Avoid modifying `sys.path` directly.**

Modifying `sys.path` can lead to unpredictable import behavior, especially if used in multiple places. Consider using relative or absolute imports within the package instead.

---

`20-26`: **Log the caught exception for better debugging.**

When the `b3d.Mesher().read(path)` call fails, the inner `except` block silently catches all exceptions. It might be helpful to log the original exception or error message before falling back to `RWStl.ReadFile_s` for easier troubleshooting.  

```diff
 try:
     meshed = b3d.Mesher().read(path)
     shape = meshed[0].wrapped
 except Exception as e:
+    print(f"Mesher read failed: {e}")
     builder = BRep_Builder()
     reader = RWStl.ReadFile_s(os.fsdecode(path))
```

</blockquote></details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (1)</summary><blockquote>

`31-34`: **Future-proof the empty `request` dictionary.**  

Currently, `request` is empty. If future enhancements require passing additional parameters, consider documenting or validating them to avoid confusion down the line.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f6659780bdc92f0fe09bd4ceea22bf3c5f4da24b and 6521064ff99b2fc6c2bc6b655c1b4775389b7822.

</details>

<details>
<summary>📒 Files selected for processing (28)</summary>

* `.devcontainer/requirements.txt` (0 hunks)
* `.github/workflows/test-dev.yml` (1 hunks)
* `.github/workflows/test.yml` (1 hunks)
* `examples/feature_convert/partcad.yaml` (2 hunks)
* `features/adhoc/convert.feature` (1 hunks)
* `partcad-cli/src/partcad_cli/click/commands/export.py` (2 hunks)
* `partcad-cli/src/partcad_cli/click/commands/render.py` (2 hunks)
* `partcad-ide-vscode/bundled/tool/lsp_server.py` (3 hunks)
* `partcad-ide-vscode/requirements.txt` (0 hunks)
* `partcad/src/partcad/actions/part/convert.py` (4 hunks)
* `partcad/src/partcad/adhoc/convert.py` (1 hunks)
* `partcad/src/partcad/part_factory_feature_ai.py` (3 hunks)
* `partcad/src/partcad/part_factory_step.py` (1 hunks)
* `partcad/src/partcad/part_factory_stl.py` (1 hunks)
* `partcad/src/partcad/project.py` (3 hunks)
* `partcad/src/partcad/provider_factory_manufacturer.py` (2 hunks)
* `partcad/src/partcad/shape.py` (4 hunks)
* `partcad/src/partcad/shape_ai.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_common.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_brep.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_gltf.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_step.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_render_stl.py` (1 hunks)
* `partcad/src/partcad/wrappers/wrapper_stl.py` (1 hunks)
* `partcad/tests/unit/test_adhoc_convert.py` (2 hunks)
* `partcad/tests/unit/test_convert_part.py` (1 hunks)
* `partcad/tests/unit/test_import_part.py` (1 hunks)
* `partcad/tests/unit/test_render.py` (3 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (2)</summary>

* .devcontainer/requirements.txt
* partcad-ide-vscode/requirements.txt

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (22)</summary>

* partcad/tests/unit/test_import_part.py
* examples/feature_convert/partcad.yaml
* partcad-cli/src/partcad_cli/click/commands/render.py
* partcad/src/partcad/wrappers/wrapper_render_step.py
* .github/workflows/test-dev.yml
* partcad/tests/unit/test_convert_part.py
* partcad/src/partcad/wrappers/wrapper_render_stl.py
* .github/workflows/test.yml
* partcad/src/partcad/provider_factory_manufacturer.py
* partcad/tests/unit/test_adhoc_convert.py
* partcad/src/partcad/wrappers/wrapper_render_gltf.py
* partcad/src/partcad/wrappers/wrapper_render_brep.py
* partcad/src/partcad/wrappers/wrapper_common.py
* partcad-cli/src/partcad_cli/click/commands/export.py
* features/adhoc/convert.feature
* partcad/src/partcad/shape_ai.py
* partcad-ide-vscode/bundled/tool/lsp_server.py
* partcad/src/partcad/actions/part/convert.py
* partcad/tests/unit/test_render.py
* partcad/src/partcad/part_factory_feature_ai.py
* partcad/src/partcad/part_factory_step.py
* partcad/src/partcad/project.py

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (2)</summary>

<details>
<summary>partcad/src/partcad/part_factory_stl.py (5)</summary><blockquote>

<details>
<summary>partcad/src/partcad/part_factory_file.py (2)</summary>

* `PartFactoryFile` (16-62)
* `instantiate` (59-62)

</details>
<details>
<summary>partcad/src/partcad/logging.py (2)</summary>

* `Action` (167-191)
* `error` (61-63)

</details>
<details>
<summary>partcad/src/partcad/part_factory_step.py (1)</summary>

* `instantiate` (45-68)

</details>
<details>
<summary>partcad/src/partcad/context.py (1)</summary>

* `get_python_runtime` (862-874)

</details>
<details>
<summary>partcad/src/partcad/runtime_python.py (1)</summary>

* `run_async` (243-245)

</details>

</blockquote></details>
<details>
<summary>partcad/src/partcad/shape.py (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/context.py (4)</summary>

* `Context` (37-885)
* `render_async` (847-852)
* `get_python_runtime` (862-874)
* `render` (854-859)

</details>
<details>
<summary>partcad/src/partcad/project.py (3)</summary>

* `Project` (70-1706)
* `render_async` (1328-1368)
* `render` (1411-1420)

</details>
<details>
<summary>partcad/src/partcad/shape_config.py (1)</summary>

* `ShapeConfiguration` (16-54)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (14)</summary><blockquote>

<details>
<summary>partcad/src/partcad/wrappers/wrapper_stl.py (5)</summary>

`27-41`: **Shape face validation looks thorough.**  

The sequence of checks for null, shape type, and infinite surfaces ensures the integrity of the STL’s face geometry.

---

`42-53`: **Comprehensive shell validation.**  

Ensuring the shell is closed is a crucial step in confirming a proper, continuous surface. This rigorous check helps detect incomplete geometry early.

---

`54-73`: **Solid construction and geometric checks are well-handled.**  

The step-by-step validations, zero/negative volume checks, and explicit error raising make the resulting shape more robust and reliable.

---

`74-79`: **Clear error handling approach.**  

Catching any remaining exception and returning a structured error response helps maintain consistent wrapper behavior and simplifies debugging at higher levels.

---

`80-83`: **Command-line interface is straightforward.**  

Using `wrapper_common` for input/output promotes consistency across different wrappers, which is beneficial for maintainability.

</details>
<details>
<summary>partcad/src/partcad/part_factory_stl.py (5)</summary>

`11-13`: **Use of `pickle` can be risky if input is not fully trusted.**  

Deserializing potentially untrusted data may lead to security vulnerabilities. This concern has been raised previously.

---

`23-23`: **Extension handling logic looks good.**  

Using the `.stl` extension in `super().__init__` helps standardize file naming and clarifies file format expectations.

---

`36-40`: **Asynchronous call with managed runtime is well-structured.**  

Leveraging `self.ctx.get_python_runtime("3.11")` is a clean approach to keep Python environments controlled. Error redirection to `errors` also ensures captured output.

---

`44-49`: **Deserialization error handling is appropriate.**  

Catching exceptions during `pickle.loads` and logging them helps debug malformed or unexpected responses from the wrapper.

---

`51-53`: **Raising an exception on wrapper failure is consistent.**  

The code logs the exception message and then re-raises, ensuring the calling context understands the failure.

</details>
<details>
<summary>partcad/src/partcad/shape.py (3)</summary>

`433-565`: **Well-structured centralized rendering method.**

The new `render_async` method effectively centralizes rendering logic for multiple formats, improving code organization and maintainability. Good job implementing a unified approach with proper error handling and response processing.

---

`567-575`: **Good synchronous wrapper for async renderer.**

The updated `render` method properly wraps the new async functionality, maintaining a clean API while leveraging the new centralized rendering implementation.

---

`40-51`: **Well-organized format extension mapping.**

The new `EXTENSION_MAPPING` dictionary provides a clear, centralized way to map format names to file extensions, improving code maintainability and reducing the potential for inconsistencies.

</details>
<details>
<summary>partcad/src/partcad/adhoc/convert.py (1)</summary>

`50-52`: <details>
<summary>✅ Verification successful</summary>

**Direct rendering approach aligns with new architecture.**

The change to directly call `part.render()` instead of using a separate export function streamlines the code and properly aligns with the refactored rendering implementation in the Shape class. This is a positive architectural improvement.

However, note that the previous reviewer pointed out that "Export" and "Render" may have different semantic meanings in your domain. If they truly represent different concepts (export as lossless conversion vs. render as perspective-based visualization), consider whether this change maintains the correct domain semantics.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check if export_part function is still defined elsewhere or referenced
rg "def export_part" --type py
rg "export_part\(" --type py
```

Length of output: 380

---



</details>

**Direct rendering approach aligns with new architecture.**

The changes in `partcad/src/partcad/adhoc/convert.py` now invoke `part.render()` directly, which streamlines the implementation by leveraging the updated rendering logic in the Shape class. The verification confirms that while an `export_part` function still exists (in `partcad-ide-vscode/bundled/tool/lsp_server.py`), it is used in a different context and does not affect this module. 

Please double-check that merging the "export" and "render" semantics in this instance doesn’t compromise domain-specific distinctions (i.e., lossless conversion vs. perspective-based visualization) that might be important elsewhere.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-23T07:39:00Z

Review comments:
diff_hunk: @@ -407,6 +411,49 @@ def render_getopts(
 
         return opts, filepath
 
+    async def render(
+        self,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
+        filepath=None,
+        **kwargs):
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Ensure proper handling when `project` is `None`.**

Inside `render()`, there's a call to `project.ctx.ensure_dirs_for_file(filepath)` (line 434), but `project` is optional. If `project` happens to be `None`, this call will raise an `AttributeError`. Consider adding a protective check to avoid such errors.

```diff
 if obj is not None and project is not None:
     project.ctx.ensure_dirs_for_file(filepath)
```


> Committable suggestion skipped: line range outside the PR's diff.

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 7c48d8d to d356c5e
created_at: 2025-03-11T15:25:49Z
author_association: NONE
diff_hunk: @@ -407,121 +418,135 @@
 
         return opts, filepath
 
-    async def render_svg_async(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+            "svg": [
+                "cadquery-ocp==7.7.2",
+                "ocpsvg==0.3.4",
+                "build123d==0.8.0"
+            ],
+            "png": [
+                "cadquery-ocp==7.7.2",
+                "ocpsvg==0.3.4",
+                "build123d==0.8.0",
+                "svglib==1.5.1",
+                "reportlab",
+                "rlpycairo==0.3.0"
+            ],
+            "brep": [
+                "cadquery-ocp==7.7.2"
+            ],
+            "step": [
+                "cadquery-ocp==7.7.2",
+                "OCP"
+            ],
+            "obj": [
+                "cadquery-ocp==7.7.2"
+            ],
+            "gltf": [
+                "cadquery-ocp==7.7.2"
+            ]
+        }
 
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
+
+            render_cfg = self.config_obj.get("render", {})
+
+            wrapper_path = wrapper.get(f"render_{format_name}.py")
+
+            render_opts, filepath = self.render_getopts(format_name, f".{format_name}", project, filepath)
+            filepath = os.path.abspath(filepath)
 
-    async def render_png_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        with pc_logging.Action("RenderPNG", self.project_name, self.name):
             obj = await self.get_wrapped(ctx)
             if obj is None:
-                # pc_logging.error("The shape failed to instantiate")
-                self.svg_path = None
+                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
                 return
 
-            png_opts, filepath = self.render_getopts("png", ".png", project, filepath)
+            if project is not None:
+                project.ctx.ensure_dirs_for_file(filepath)
 
-            if width is None:
-                if "width" in png_opts and not png_opts["width"] is None:
-                    width = png_opts["width"]
-                else:
-                    width = DEFAULT_RENDER_WIDTH
-            if height is None:
-                if "height" in png_opts and not png_opts["height"] is None:
-                    height = png_opts["height"]
-                else:
-                    height = DEFAULT_RENDER_HEIGHT
-            if line_weight is None:
-                if "lineWeight" in png_opts and not png_opts["lineWeight"] is None:
-                    line_weight = png_opts["lineWeight"]
-                else:
-                    line_weight = 1.0
-            if viewport_origin is None:
-                if "viewportOrigin" in png_opts and not png_opts["viewportOrigin"] is None:
-                    viewport_origin = png_opts["viewportOrigin"]
-                else:
-                    viewport_origin = [100, -100, 100]
+            if format_name in WRAPPER_FORMATS.keys():
+                request = {"wrapped": obj}
+
+                if format_name in ["svg", "png"]:
+                    request.setdefault("viewport_origin", kwargs.get("viewport_origin", [100, -100, 100]))
+                    request.setdefault("line_weight", kwargs.get("line_weight", 1.0))
+                if format_name == "png":
+                    request.setdefault("width", kwargs.get("width", render_opts.get("width", 512)))
+                    request.setdefault("height", kwargs.get("height", render_opts.get("height", 512)))
+
+                request.update(render_opts)
+                request.update(kwargs)
+
+                picklestring = pickle.dumps(request)
+                request_serialized = base64.b64encode(picklestring).decode()
+
+                runtime = ctx.get_python_runtime(version="3.11")
+                for dependency in WRAPPER_FORMATS[format_name]:
+                    await runtime.ensure_async(dependency)
+
+                # Run wrapper
+                response_serialized, errors = await runtime.run_async(
+                    [
+                        wrapper_path,
+                        os.path.abspath(filepath),
+                    ],
+                    request_serialized,
+                )
+                sys.stderr.write(errors)
 
-            if not project is None:
-                project.ctx.ensure_dirs_for_file(filepath)
+                if errors:
+                    pc_logging.error(f"Wrapper {format_name} stderr:\n{errors}")
 
-            wrapper_path = wrapper.get("render_png.py")
-            request = {
-                "wrapped": obj,
-                "width": width,
-                "height": height,
-                "line_weight": line_weight,
-                "viewport_origin": viewport_origin,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
+                # Handle response
+                response = base64.b64decode(response_serialized)
+                result = pickle.loads(response)
 
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("svglib==1.5.1")
-            await runtime.ensure_async("reportlab")
-            await runtime.ensure_async("rlpycairo==0.3.0")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
+                if not result.get("success", False):
+                    pc_logging.error(
+                        f"Render {format_name.upper()} failed for {self.project_name}:{self.name}: {result.get('exception', 'Unknown error')}"
+                    )
+                if "exception" in result and result["exception"]:
+                    pc_logging.exception(f"Render {format_name.upper()} exception: {result['exception']}")
 
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-            if not result["success"]:
-                pc_logging.error("RenderPNG failed: %s:%s: %s" % (self.project_name, self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception("RenderPNG exception: %s" % result["exception"])
+            else:
+                # render_func_name = f"render_{format_name}_async"
+                # render_func = getattr(self, render_func_name, None)
+                # if callable(render_func):
+                #     await render_func(ctx=ctx, project=project, filepath=filepath, **kwargs)
+                # else:
+                #      pc_logging.error(
+                #         f"Render for format '{format_name}' is not supported for {self.project_name}:{self.name}"
+                #     )
+                pc_logging.error(
+                    f"Render for format '{format_name}' is not supported for {self.project_name}:{self.name}"
+                )
 
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Remove or refactor PNG/SVG dependencies to match PR objective.**

Your `WRAPPER_FORMATS` dictionary still references `"svg"` and `"png"`, including libraries like `ocpsvg`, `svglib`, etc. However, the PR objective states “refactor: Drop PNG and SVG dependencies.” Removing or refactoring them might be necessary to fulfill that goal. You could also consider marking them as optional if you still rely on them for internal use.


Below is a sample diff that removes PNG/SVG references to align with the stated PR objective:

```diff
-            "svg": [
-                "cadquery-ocp==7.7.2",
-                "ocpsvg==0.3.4",
-                "build123d==0.8.0"
-            ],
-            "png": [
-                "cadquery-ocp==7.7.2",
-                "ocpsvg==0.3.4",
-                "build123d==0.8.0",
-                "svglib==1.5.1",
-                "reportlab",
-                "rlpycairo==0.3.0"
-            ],
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    async def render_async(
        self,
        ctx: Context,
        format_name: str,
        project: Optional[Project] = None,
        filepath=None,
        **kwargs) -> None:
        """
        Centralized method to render shape via external wrapper.
        Args:
            ctx: Execution context.
            format_name: Render format (e.g., "png", "svg").
            project: Optional project object.
            filepath: Target file path for output.
            kwargs: Additional options (width, height, etc.).
        """
        WRAPPER_FORMATS = {
-            "svg": [
-                "cadquery-ocp==7.7.2",
-                "ocpsvg==0.3.4",
-                "build123d==0.8.0"
-            ],
-            "png": [
-                "cadquery-ocp==7.7.2",
-                "ocpsvg==0.3.4",
-                "build123d==0.8.0",
-                "svglib==1.5.1",
-                "reportlab",
-                "rlpycairo==0.3.0"
-            ],
            "brep": [
                "cadquery-ocp==7.7.2"
            ],
            "step": [
                "cadquery-ocp==7.7.2",
                "OCP"
            ],
            "obj": [
                "cadquery-ocp==7.7.2"
            ],
            "gltf": [
                "cadquery-ocp==7.7.2"
            ]
        }

        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
            if filepath and os.path.isdir(filepath):
                self.config_obj.setdefault("render", {})["output_dir"] = filepath

            render_cfg = self.config_obj.get("render", {})

            wrapper_path = wrapper.get(f"render_{format_name}.py")

            render_opts, filepath = self.render_getopts(format_name, f".{format_name}", project, filepath)
            filepath = os.path.abspath(filepath)

            obj = await self.get_wrapped(ctx)
            if obj is None:
                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
                return

            if project is not None:
                project.ctx.ensure_dirs_for_file(filepath)

            if format_name in WRAPPER_FORMATS.keys():
                request = {"wrapped": obj}

                if format_name in ["svg", "png"]:
                    request.setdefault("viewport_origin", kwargs.get("viewport_origin", [100, -100, 100]))
                    request.setdefault("line_weight", kwargs.get("line_weight", 1.0))
                if format_name == "png":
                    request.setdefault("width", kwargs.get("width", render_opts.get("width", 512)))
                    request.setdefault("height", kwargs.get("height", render_opts.get("height", 512)))

                request.update(render_opts)
                request.update(kwargs)

                picklestring = pickle.dumps(request)
                request_serialized = base64.b64encode(picklestring).decode()

                runtime = ctx.get_python_runtime(version="3.11")
                for dependency in WRAPPER_FORMATS[format_name]:
                    await runtime.ensure_async(dependency)

                # Run wrapper
                response_serialized, errors = await runtime.run_async(
                    [
                        wrapper_path,
                        os.path.abspath(filepath),
                    ],
                    request_serialized,
                )
                sys.stderr.write(errors)

                if errors:
                    pc_logging.error(f"Wrapper {format_name} stderr:\n{errors}")

                # Handle response
                response = base64.b64decode(response_serialized)
                result = pickle.loads(response)

                if not result.get("success", False):
                    pc_logging.error(
                        f"Render {format_name.upper()} failed for {self.project_name}:{self.name}: {result.get('exception', 'Unknown error')}"
                    )
                if "exception" in result and result["exception"]:
                    pc_logging.exception(f"Render {format_name.upper()} exception: {result['exception']}")

            else:
                # render_func_name = f"render_{format_name}_async"
                # render_func = getattr(self, render_func_name, None)
                # if callable(render_func):
                #     await render_func(ctx=ctx, project=project, filepath=filepath, **kwargs)
                # else:
                #      pc_logging.error(
                #         f"Render for format '{format_name}' is not supported for {self.project_name}:{self.name}"
                #     )
                pc_logging.error(
                    f"Render for format '{format_name}' is not supported for {self.project_name}:{self.name}"
                )
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 7c48d8d to d356c5e
created_at: 2025-03-14T20:07:52Z
author_association: NONE
diff_hunk: @@ -0,0 +1,35 @@
+import base64
+import os
+import pickle
+import sys
+sys.path.append(os.path.dirname(__file__))
+import wrapper_common
+
+def process(path, request):
+    try:
+        from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
+        from OCP.Interface import Interface_Static
+
+        obj = request["wrapped"]
+        write_pcurves = 1 if request.get("write_pcurves", True) else 0
+        precision_mode = request.get("precision_mode", 0)
path: partcad/src/partcad/wrappers/wrapper_render_step.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Verify existence of request fields.**  
Accessing `request["wrapped"]` or `request.get("write_pcurves")` without guaranteed presence can raise `KeyError` or lead to unintended defaults.

---

🏁 Script executed:

```shell
#!/bin/bash
# Searching for calls to `process` in the codebase to check if "wrapped" is always provided:
rg -A 4 "process\(.*\)"
```

Length of output: 17583

---



</details>

**Action: Add explicit validation for the 'wrapped' request field**

The implementation in `partcad/src/partcad/wrappers/wrapper_render_step.py` (lines 13–15) directly accesses `request["wrapped"]` without first verifying its presence. This can lead to a `KeyError` if the key is missing. Notably, other wrappers, such as in `wrapper_render_brep.py`, use `request.get("wrapped")` with additional checks to handle missing values. For consistency and to avoid potential runtime exceptions, please add explicit validation for the `"wrapped"` field (and review defaults for `"write_pcurves"` and `"precision_mode"`) prior to usage.

- Verify that the incoming `request` always includes the `"wrapped"` key or handle the case where it is missing.
- Consider following the validation pattern used in `wrapper_render_brep.py` to raise a clear exception or to provide a fallback behavior.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-15T15:35:43Z
author_association: NONE
diff_hunk: @@ -8,92 +8,51 @@
 #
 
 import os
-
-import build123d as b3d
+import base64
+import pickle
+import sys
 
 from .part_factory_file import PartFactoryFile
 from . import logging as pc_logging
+from . import wrapper
 
 
 class PartFactoryStl(PartFactoryFile):
     def __init__(self, ctx, source_project, target_project, config):
         with pc_logging.Action("InitSTL", target_project.name, config["name"]):
-            super().__init__(
-                ctx,
-                source_project,
-                target_project,
-                config,
-                extension=".stl",
-            )
-            # Complement the config object here if necessary
+            super().__init__(ctx, source_project, target_project, config, extension=".stl")
             self._create(config)
 
     async def instantiate(self, part):
         await super().instantiate(part)
 
         with pc_logging.Action("STL", part.project_name, part.name):
-            try:
-                shape = b3d.Mesher().read(self.path)[0].wrapped
-            except:
-                # First, make sure it's not a problem in Mesher
-                # TODO(clairbee): ascii stl files end up here too, but probably don't need to
-
-                from OCP.RWStl import RWStl
-                from OCP.BRep import BRep_Builder
-                from OCP.TopoDS import TopoDS_Face, TopoDS_Shell, TopoDS_Solid
-                from OCP.TopAbs import TopAbs_FACE
-                from OCP.BRepCheck import BRepCheck_Shell, BRepCheck_Solid, BRepCheck_Status
-                from OCP.BRepGProp import BRepGProp
-                from OCP.GProp import GProp_GProps
+            wrapper_path = wrapper.get("stl.py")
+            request = {}
 
-                builder = BRep_Builder()
-                reader = RWStl.ReadFile_s(os.fsdecode(self.path))
+            picklestring = pickle.dumps(request)
+            request_serialized = base64.b64encode(picklestring).decode()
 
-                if not reader:
-                    raise RuntimeError("Failed to read the STL file")
-
-                # Build and check the faces
-                face = TopoDS_Face()
-                builder.MakeFace(face, reader)
-                if face.IsNull():
-                    raise RuntimeError("Failed to read the STL file: Null")
-                if face.ShapeType() != TopAbs_FACE:
-                    raise RuntimeError("Failed to read the STL file: Wrong shape type")
-                if face.Infinite():
-                    raise RuntimeError("Failed to read the STL file: Infinite")
+            runtime = self.ctx.get_python_runtime("3.11")
+            response_serialized, errors = await runtime.run_async(
+                [wrapper_path, os.path.abspath(self.path)],
+                request_serialized,
+            )
path: partcad/src/partcad/part_factory_stl.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Potential security concern with pickle serialization.**  

Although pickling here is presumably internal, loading pickle data from an external process can be risky if that process is not fully trusted. Consider using a more secure serialization format (e.g., JSON) to mitigate malicious payloads.  

```diff
- picklestring = pickle.dumps(request)
- request_serialized = base64.b64encode(picklestring).decode()
+ # Potentially safer approach using JSON to avoid arbitrary code execution risks:
+ import json
+ request_json = json.dumps(request)
+ request_serialized = base64.b64encode(request_json.encode('utf-8')).decode()
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
            # Potentially safer approach using JSON to avoid arbitrary code execution risks:
            import json
            request_json = json.dumps(request)
            request_serialized = base64.b64encode(request_json.encode('utf-8')).decode()

            runtime = self.ctx.get_python_runtime("3.11")
            response_serialized, errors = await runtime.run_async(
                [wrapper_path, os.path.abspath(self.path)],
                request_serialized,
            )
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-15T15:35:43Z
author_association: NONE
diff_hunk: @@ -47,8 +47,9 @@ def convert_cad_file(input_filename: str, input_type: str, output_filename: str,
         if part.errors:
             raise RuntimeError(f"Failed to load the input part: {part.errors}")
 
-        # Export the part to the desired output format
-        export_part(part, output_filename, output_type, ctx)
+        # Render the part to the desired output format
+        part.render(ctx=ctx, format_name=output_type, project=project, filepath=output_filename)
+
path: partcad/src/partcad/adhoc/convert.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Simplified rendering approach with direct method call**

The code now directly calls `part.render()` instead of using the separate `export_part` function. This change streamlines the conversion process and is part of the larger refactoring to remove PNG and SVG dependencies.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check if the render method is properly implemented in the Part class
# and if it handles all formats previously supported by export_part

# Search for the Part class definition and its render method
rg -A 10 "class Part" | grep -A 20 "def render"

# Check for any remaining references to export_part that might need updating
rg "export_part\(" --type py
```

Length of output: 669

---



</details>

**Direct Render Call Confirmed – Update Test References for Consistency**

The change at lines 50–52 in `partcad/src/partcad/adhoc/convert.py` correctly streamlines part rendering by switching to a direct `part.render()` call. However, note that:

- The legacy `export_part` function is still defined in `partcad/src/partcad/adhoc/convert.py`.
- Several unit tests (in `partcad/tests/unit/test_adhoc_convert.py`) continue to call `export_part`.

Please update the tests (or adapt `export_part` as a thin wrapper) so that they align with the new direct rendering approach and ensure that any obsolete code is removed if no longer needed.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-16T15:36:26Z
author_association: NONE
diff_hunk: @@ -407,121 +430,145 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+        "svg": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0"
+        ],
+        "png": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0",
+            "svglib==1.5.1",
+            "reportlab",
+            "rlpycairo==0.3.0"
+        ],
+        "brep": ["cadquery-ocp==7.7.2"],
+        "step": ["cadquery-ocp==7.7.2"],
+        "stl": ["cadquery-ocp==7.7.2"],
+        "obj": ["cadquery-ocp==7.7.2"],
+        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+        "gltf": ["cadquery-ocp==7.7.2"],
+        "threejs": ["cadquery-ocp==7.7.2"]
+    }
+
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
+
+            if format_name == "gltf":
+                obj = await self.get_build123d(ctx)
+            else:
+                obj = await self.get_wrapped(ctx)
 
-    async def render_png_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        with pc_logging.Action("RenderPNG", self.project_name, self.name):
-            obj = await self.get_wrapped(ctx)
             if obj is None:
-                # pc_logging.error("The shape failed to instantiate")
-                self.svg_path = None
+                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
                 return
 
-            png_opts, filepath = self.render_getopts("png", ".png", project, filepath)
+            if project is not None:
+                project.ctx.ensure_dirs_for_file(filepath)
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Potential null pointer dereference.**

There's a potential null pointer dereference if `project` is not `None` but `project.ctx` is `None`.


Consider adding an additional check:

```diff
-if project is not None:
+if project is not None and hasattr(project, 'ctx'):
    project.ctx.ensure_dirs_for_file(filepath)
```

Let's verify this issue:

---

🏁 Script executed:

```shell
#!/bin/bash
# Look for any instance where project.ctx is accessed without a null check
rg -A 2 "project.ctx" --type py
rg -A 2 "if not project is None:" --type py
```

Length of output: 5951

---



</details>

**Action Required: Ensure `project.ctx` is not null before usage**

The search results reveal that in multiple parts of the codebase—including in `partcad/src/partcad/shape.py`—the code only checks that `project` is not `None` before invoking methods on `project.ctx`. This pattern leaves room for a potential null pointer dereference if `project.ctx` is unexpectedly `None`. To prevent this, please update the conditional to also validate that `project.ctx` is not `None`.

**Suggested change in `partcad/src/partcad/shape.py` (Lines: 485-486):**

```diff
-            if project is not None:
-                project.ctx.ensure_dirs_for_file(filepath)
+            if project is not None and project.ctx is not None:
+                project.ctx.ensure_dirs_for_file(filepath)
```

Please apply a similar defensive check wherever `project.ctx` is accessed if there's any chance it hasn't been properly set.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
            if project is not None and project.ctx is not None:
                project.ctx.ensure_dirs_for_file(filepath)
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-16T15:36:26Z
author_association: NONE
diff_hunk: @@ -407,121 +430,145 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+        "svg": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0"
+        ],
+        "png": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0",
+            "svglib==1.5.1",
+            "reportlab",
+            "rlpycairo==0.3.0"
+        ],
+        "brep": ["cadquery-ocp==7.7.2"],
+        "step": ["cadquery-ocp==7.7.2"],
+        "stl": ["cadquery-ocp==7.7.2"],
+        "obj": ["cadquery-ocp==7.7.2"],
+        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+        "gltf": ["cadquery-ocp==7.7.2"],
+        "threejs": ["cadquery-ocp==7.7.2"]
+    }
+
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
+
+            if format_name == "gltf":
+                obj = await self.get_build123d(ctx)
+            else:
+                obj = await self.get_wrapped(ctx)
 
-    async def render_png_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        with pc_logging.Action("RenderPNG", self.project_name, self.name):
-            obj = await self.get_wrapped(ctx)
             if obj is None:
-                # pc_logging.error("The shape failed to instantiate")
-                self.svg_path = None
+                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
                 return
 
-            png_opts, filepath = self.render_getopts("png", ".png", project, filepath)
+            if project is not None:
+                project.ctx.ensure_dirs_for_file(filepath)
 
-            if width is None:
-                if "width" in png_opts and not png_opts["width"] is None:
-                    width = png_opts["width"]
-                else:
-                    width = DEFAULT_RENDER_WIDTH
-            if height is None:
-                if "height" in png_opts and not png_opts["height"] is None:
-                    height = png_opts["height"]
-                else:
-                    height = DEFAULT_RENDER_HEIGHT
-            if line_weight is None:
-                if "lineWeight" in png_opts and not png_opts["lineWeight"] is None:
-                    line_weight = png_opts["lineWeight"]
-                else:
-                    line_weight = 1.0
-            if viewport_origin is None:
-                if "viewportOrigin" in png_opts and not png_opts["viewportOrigin"] is None:
-                    viewport_origin = png_opts["viewportOrigin"]
-                else:
-                    viewport_origin = [100, -100, 100]
+            formats_to_render = [format_name] if format_name else list(WRAPPER_FORMATS.keys())
+
+            for format in formats_to_render:
+                file_extension = EXTENSION_MAPPING.get(format, format)
+                render_opts, final_filepath = self.render_getopts(format, f".{file_extension}", project, filepath)
+                final_filepath = os.path.abspath(final_filepath)
+                pc_logging.debug(f"Rendering: {filepath} for format '{format}'")
+
+                wrapper_path = wrapper.get(f"render_{format}.py")
+
+                request = {"wrapped": obj}
+
+                if format in ["svg", "png"]:
+                    request["viewport_origin"] = kwargs.get("viewport_origin", [100, -100, 100])
+                    request["line_weight"] = kwargs.get("line_weight", 1.0)
+                    if format == "png":
+                        request["width"] = kwargs.get("width", 512)
+                        request["height"] = kwargs.get("height", 512)
+
+                elif format in ["3mf", "obj", "gltf", "stl", "threejs"]:
+                    request["tolerance"] = kwargs.get("tolerance", render_opts.get("tolerance", 0.1))
+                    request["angularTolerance"] = kwargs.get("angularTolerance", render_opts.get("angularTolerance", 0.1))
+                    if format == "stl":
+                        request["ascii"] = kwargs.get("ascii", render_opts.get("ascii", False))
+                    elif format == "gltf":
+                        request["binary"] = kwargs.get("binary", render_opts.get("binary", False))
+
+                elif format == "step":
+                    request["write_pcurves"] = kwargs.get("write_pcurves", render_opts.get("write_pcurves", True))
+                    request["precision_mode"] = kwargs.get("precision_mode", render_opts.get("precision_mode", 0))
+
+                register_ocp_helper()
+
+                picklestring = pickle.dumps(request)
+                request_serialized = base64.b64encode(picklestring).decode()
+
+                runtime = ctx.get_python_runtime(version="3.11")
+                for dependency in WRAPPER_FORMATS[format_name]:
+                    await runtime.ensure_async(dependency)
+
+                # Run wrapper
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Runtime dependencies remain for PNG/SVG.**
Contradicts the goal to drop PNG/SVG. Consider removing or marking them as optional if they must remain temporarily.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-18T08:39:10Z
author_association: NONE
diff_hunk: @@ -47,8 +47,9 @@ def convert_cad_file(input_filename: str, input_type: str, output_filename: str,
         if part.errors:
             raise RuntimeError(f"Failed to load the input part: {part.errors}")
 
-        # Export the part to the desired output format
-        export_part(part, output_filename, output_type, ctx)
+        # Render the part to the desired output format
+        part.render(ctx=ctx, format_name=output_type, project=project, filepath=output_filename)
path: partcad/src/partcad/adhoc/convert.py
user: login: clairbee
body: Not sure about this one. "Export" is a as-lossless-as-possible conversion of 2D to 2D, 3D to 3D etc. "Render" is conversion of anything (2D or 3D) to 2D with such properties as "angle of observation", "perspective" etc.
created_at: 2025-03-19T17:45:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -407,121 +430,145 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+        "svg": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0"
+        ],
+        "png": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0",
+            "svglib==1.5.1",
+            "reportlab",
+            "rlpycairo==0.3.0"
+        ],
+        "brep": ["cadquery-ocp==7.7.2"],
+        "step": ["cadquery-ocp==7.7.2"],
+        "stl": ["cadquery-ocp==7.7.2"],
+        "obj": ["cadquery-ocp==7.7.2"],
+        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+        "gltf": ["cadquery-ocp==7.7.2"],
+        "threejs": ["cadquery-ocp==7.7.2"]
+    }
+
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
+
+            if format_name == "gltf":
+                obj = await self.get_build123d(ctx)
path: partcad/src/partcad/shape.py
user: login: clairbee
body: Wow. I don't think this will work. It most likely has to be get_wrapped() too.
created_at: 2025-03-19T17:47:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -407,526 +430,142 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+        "svg": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0"
+        ],
+        "png": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0",
+            "svglib==1.5.1",
+            "reportlab",
+            "rlpycairo==0.3.0"
+        ],
+        "brep": ["cadquery-ocp==7.7.2"],
+        "step": ["cadquery-ocp==7.7.2"],
+        "stl": ["cadquery-ocp==7.7.2"],
+        "obj": ["cadquery-ocp==7.7.2"],
+        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+        "gltf": ["cadquery-ocp==7.7.2"],
+        "threejs": ["cadquery-ocp==7.7.2"]
+    }
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**References to PNG/SVG contradict PR objective.**
The `WRAPPER_FORMATS` dictionary still includes `"svg"` and `"png"`. Given the PR objective “refactor: Drop PNG and SVG dependencies,” these references should be fully removed or made optional if truly needed.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-22T14:45:52Z
author_association: NONE
diff_hunk: @@ -1327,65 +1330,67 @@ def test_log_wrapper(self, ctx, tests=[]) -> bool:
 
     async def render_async(
         self,
-        sketches=None,
-        interfaces=None,
-        parts=None,
-        assemblies=None,
-        format=None,
-        output_dir=None,
+        sketches: Optional[List] = None,
+        interfaces: Optional[List] = None,
+        parts: Optional[List] = None,
+        assemblies: Optional[List] = None,
+        format: Optional[str] = None,
+        output_dir: Optional[Path] = None,
     ):
         with pc_logging.Action("RenderPkg", self.name):
             # Override the default output_dir.
             # TODO(clairbee): pass the preference downstream without making a
             # persistent change.
-            if not output_dir is None:
-                if not "render" in self.config_obj or self.config_obj["render"] is None:
-                    self.config_obj["render"] = {}
-                self.config_obj["render"]["output_dir"] = output_dir
+
+            if output_dir:
+                self.config_obj.setdefault("render", {})["output_dir"] = output_dir
 
             render = self.config_obj.get("render", {})
-            if render is None:
-                render = {}
-
-            # Enumerating all parts and assemblies
-            if sketches is None:
-                sketches = []
-                if "sketches" in self.config_obj and not self.config_obj["sketches"] is None:
-                    sketches = self.config_obj["sketches"].keys()
-            if interfaces is None:
-                interfaces = []
-                if "interfaces" in self.config_obj and not self.config_obj["interfaces"] is None:
-                    interfaces = self.config_obj["interfaces"].keys()
-            if parts is None:
-                parts = []
-                if "parts" in self.config_obj and not self.config_obj["parts"] is None:
-                    parts = self.config_obj["parts"].keys()
-            if assemblies is None:
-                assemblies = []
-                if "assemblies" in self.config_obj and not self.config_obj["assemblies"] is None:
-                    assemblies = self.config_obj["assemblies"].keys()
-
-            # Enumerate
-            shapes = []
-            for name in sketches:
-                shape = self.get_sketch(name)
-                shapes.append(shape)
-            for name in interfaces:
-                shape = self.get_interface(name)
-                # TODO(clairbee): interfaces are not yet renderable.
-                # shapes.append(shape)
-            for name in parts:
-                shape = self.get_part(name)
-                shapes.append(shape)
-            for name in assemblies:
-                shape = self.get_assembly(name)
-                shapes.append(shape)
-
-            # Render
+            shapes: List[Shape] = self._enumerate_shapes(sketches, interfaces, parts, assemblies)
+
+            if None in shapes:
+                raise EmptyShapesError
+
             tasks = []
+            render_formats = ["svg", "png", "step", "stl", "3mf", "threejs", "obj", "gltf", "brep"]
+
+            for shape in shapes:
+                shape_render = render_cfg_merge(copy.copy(render), shape.config.get("render", {}))
+                for format_name in render_formats:
+                    if self._should_render_format(format_name, shape_render, format, shape.kind):
+                        tasks.append(shape.render_async(
+                            ctx=self.ctx,
+                            format_name=format_name,
+                            project=self,
+                            filepath=None,
+                        ))
+
+            await asyncio.gather(*tasks)
+
+            if format == "readme" or (format is None and "readme" in render):
+                self.render_readme_async(render, output_dir)
path: partcad/src/partcad/project.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**References to PNG/SVG in `render_formats`.**
This conflicts with the PR objective “Drop PNG and SVG dependencies.” Fully remove or modularize these references to align with the stated goal.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-22T14:45:52Z
author_association: NONE
diff_hunk: @@ -407,526 +430,145 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+        "svg": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0"
+        ],
+        "png": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0",
+            "svglib==1.5.1",
+            "reportlab",
+            "rlpycairo==0.3.0"
+        ],
+        "brep": ["cadquery-ocp==7.7.2"],
+        "step": ["cadquery-ocp==7.7.2"],
+        "stl": ["cadquery-ocp==7.7.2"],
+        "obj": ["cadquery-ocp==7.7.2"],
+        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+        "gltf": ["cadquery-ocp==7.7.2"],
+        "threejs": ["cadquery-ocp==7.7.2"]
+    }
+
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
+            pc_logging.info(f"{self.project_name}: {self.name} to {format_name.upper()}")
+
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
+
+            if format_name == "gltf":
+                obj = await self.get_build123d(ctx)
+            else:
+                obj = await self.get_wrapped(ctx)
 
-    async def render_png_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        with pc_logging.Action("RenderPNG", self.project_name, self.name):
-            obj = await self.get_wrapped(ctx)
             if obj is None:
-                # pc_logging.error("The shape failed to instantiate")
-                self.svg_path = None
+                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
                 return
 
-            png_opts, filepath = self.render_getopts("png", ".png", project, filepath)
-
-            if width is None:
-                if "width" in png_opts and not png_opts["width"] is None:
-                    width = png_opts["width"]
-                else:
-                    width = DEFAULT_RENDER_WIDTH
-            if height is None:
-                if "height" in png_opts and not png_opts["height"] is None:
-                    height = png_opts["height"]
-                else:
-                    height = DEFAULT_RENDER_HEIGHT
-            if line_weight is None:
-                if "lineWeight" in png_opts and not png_opts["lineWeight"] is None:
-                    line_weight = png_opts["lineWeight"]
-                else:
-                    line_weight = 1.0
-            if viewport_origin is None:
-                if "viewportOrigin" in png_opts and not png_opts["viewportOrigin"] is None:
-                    viewport_origin = png_opts["viewportOrigin"]
-                else:
-                    viewport_origin = [100, -100, 100]
-
-            if not project is None:
+            if project is not None:
                 project.ctx.ensure_dirs_for_file(filepath)
 
-            wrapper_path = wrapper.get("render_png.py")
-            request = {
-                "wrapped": obj,
-                "width": width,
-                "height": height,
-                "line_weight": line_weight,
-                "viewport_origin": viewport_origin,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
-
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("svglib==1.5.1")
-            await runtime.ensure_async("reportlab")
-            await runtime.ensure_async("rlpycairo==0.3.0")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
-
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-            if not result["success"]:
-                pc_logging.error("RenderPNG failed: %s:%s: %s" % (self.project_name, self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception("RenderPNG exception: %s" % result["exception"])
-
-    def render_png(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        asyncio.run(self.render_png_async(ctx, project, filepath, width, height, line_weight, viewport_origin))
+            formats_to_render = [format_name] if format_name else list(WRAPPER_FORMATS.keys())
 
-    async def render_step_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSTEP", self.project_name, self.name):
-            step_opts, filepath = self.render_getopts("step", ".step", project, filepath)
-
-            obj = await self.get_wrapped(ctx)
-            if obj is None:
-                raise exception.PartIsEmptyOrFailed(self.name)
-
-            def do_render_step() -> None:
-                nonlocal project, filepath, obj
-                from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
-                from OCP.Interface import Interface_Static
+            for format in formats_to_render:
+                file_extension = EXTENSION_MAPPING.get(format, format)
+                render_opts, final_filepath = self.render_getopts(format, f".{file_extension}", project, filepath)
+                final_filepath = os.path.abspath(final_filepath)
+                pc_logging.debug(f"Rendering: {filepath} for format '{format}'")
 
-                if not project is None:
-                    project.ctx.ensure_dirs_for_file(filepath)
+                wrapper_path = wrapper.get(f"render_{format}.py")
 
-                pcurves = 1
-                if "write_pcurves" in step_opts and not step_opts["write_pcurves"]:
-                    pcurves = 0
-                precision_mode = step_opts.get("precision_mode", 0)
-
-                writer = STEPControl_Writer()
-                Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves)
-                Interface_Static.SetIVal_s("write.precision.mode", precision_mode)
-                writer.Transfer(obj, STEPControl_AsIs)
-                writer.Write(filepath)
-
-            await threadpool_manager.run(do_render_step)
-
-    def render_step(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_step_async(ctx, project, filepath))
-
-    async def render_brep_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ):
-        with pc_logging.Action("RenderBREP", self.project_name, self.name):
-            brep_opts, filepath = self.render_getopts("brep", ".brep", project, filepath)
+                request = {"wrapped": obj}
 
-            obj = await self.get_wrapped(ctx)
+                if format in ["svg", "png"]:
+                    request["viewport_origin"] = kwargs.get("viewport_origin", [100, -100, 100])
+                    request["line_weight"] = kwargs.get("line_weight", 1.0)
+                    if format == "png":
+                        request["width"] = kwargs.get("width", 512)
+                        request["height"] = kwargs.get("height", 512)
 
-            def do_render_brep() -> None:
-                nonlocal project, filepath, obj
-                from OCP.BRepTools import BRepTools
+                elif format in ["3mf", "obj", "gltf", "stl", "threejs"]:
+                    request["tolerance"] = kwargs.get("tolerance", render_opts.get("tolerance", 0.1))
+                    request["angularTolerance"] = kwargs.get("angularTolerance", render_opts.get("angularTolerance", 0.1))
+                    if format == "stl":
+                        request["ascii"] = kwargs.get("ascii", render_opts.get("ascii", False))
+                    elif format == "gltf":
+                        request["binary"] = kwargs.get("binary", render_opts.get("binary", False))
 
-                if not project is None:
-                    project.ctx.ensure_dirs_for_file(filepath)
+                elif format == "step":
+                    request["write_pcurves"] = kwargs.get("write_pcurves", render_opts.get("write_pcurves", True))
+                    request["precision_mode"] = kwargs.get("precision_mode", render_opts.get("precision_mode", 0))
 
-                brep_writer = BRepTools()
-                with open(filepath, "wb") as brep_file:
-                    brep_writer.Write_s(obj, brep_file)
+                register_ocp_helper()
 
-            await threadpool_manager.run(do_render_brep)
-
-    def render_brep(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_brep_async(ctx, project, filepath))
-
-    async def render_stl_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        tolerance=None,
-        angularTolerance=None,
-        ascii=None,
-    ) -> None:
-        with pc_logging.Action("RenderSTL", self.project_name, self.name):
-            stl_opts, filepath = self.render_getopts("stl", ".stl", project, filepath)
-
-            if tolerance is None:
-                if "tolerance" in stl_opts and not stl_opts["tolerance"] is None:
-                    tolerance = float(stl_opts["tolerance"])
-                else:
-                    tolerance = 0.1
-
-            if angularTolerance is None:
-                if "angularTolerance" in stl_opts and not stl_opts["angularTolerance"] is None:
-                    angularTolerance = float(stl_opts["angularTolerance"])
-                else:
-                    angularTolerance = 0.1
-
-            if ascii is None:
-                if "ascii" in stl_opts and not stl_opts["ascii"] is None:
-                    ascii = bool(stl_opts["ascii"])
-                else:
-                    ascii = False
+                picklestring = pickle.dumps(request)
+                request_serialized = base64.b64encode(picklestring).decode()
 
-            obj = await self.get_wrapped(ctx)
+                runtime = ctx.get_python_runtime(version="3.11")
 
-            def do_render_stl() -> None:
-                nonlocal obj, project, filepath, tolerance, angularTolerance, ascii
-                from OCP.BRepMesh import BRepMesh_IncrementalMesh
-                from OCP.StlAPI import StlAPI_Writer
+                dependencies = WRAPPER_FORMATS[format_name]
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Inconsistent format reference causing dependency installation issue.**

This line uses `format_name` but should use the current format from the loop (`format`):

```diff
-dependencies = WRAPPER_FORMATS[format_name]
+dependencies = WRAPPER_FORMATS[format]
```

This inconsistency means only dependencies for the first format will be installed, potentially causing failures for other formats in the loop.



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
                dependencies = WRAPPER_FORMATS[format]
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-22T15:47:01Z
author_association: NONE
diff_hunk: @@ -407,526 +430,145 @@ def render_getopts(
 
         return opts, filepath
 
-    async def render_svg_async(
+    async def render_async(
         self,
-        ctx,
-        project=None,
+        ctx: Context,
+        format_name: str,
+        project: Optional[Project] = None,
         filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSVG", self.project_name, self.name):
-            _, filepath = self.render_getopts("svg", ".svg", project, filepath)
-
-            # This creates a temporary file, but it allows to reuse the file
-            # with other consumers of self._get_svg_path()
-            svg_path = await self._get_svg_path(ctx=ctx, project=project)
-            if not svg_path is None and svg_path != filepath:
-                if os.path.exists(svg_path):
-                    shutil.copyfile(svg_path, filepath)
-                else:
-                    pc_logging.error("SVG file was not created by the wrapper")
-
-    def render_svg(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_svg_async(ctx, project, filepath))
+        **kwargs) -> None:
+        """
+        Centralized method to render shape via external wrapper.
+        Args:
+            ctx: Execution context.
+            format_name: Render format (e.g., "png", "svg").
+            project: Optional project object.
+            filepath: Target file path for output.
+            kwargs: Additional options (width, height, etc.).
+        """
+        WRAPPER_FORMATS = {
+        "svg": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0"
+        ],
+        "png": [
+            "cadquery-ocp==7.7.2",
+            "ocpsvg==0.3.4",
+            "build123d==0.8.0",
+            "svglib==1.5.1",
+            "reportlab",
+            "rlpycairo==0.3.0"
+        ],
+        "brep": ["cadquery-ocp==7.7.2"],
+        "step": ["cadquery-ocp==7.7.2"],
+        "stl": ["cadquery-ocp==7.7.2"],
+        "obj": ["cadquery-ocp==7.7.2"],
+        "3mf": ["cadquery-ocp==7.7.2", "cadquery==2.5.2"],
+        "gltf": ["cadquery-ocp==7.7.2"],
+        "threejs": ["cadquery-ocp==7.7.2"]
+    }
+
+        with pc_logging.Action(f"Render{format_name.upper()}", self.project_name, self.name):
+            pc_logging.info(f"{self.project_name}: {self.name} to {format_name.upper()}")
+
+            if filepath and os.path.isdir(filepath):
+                self.config_obj.setdefault("render", {})["output_dir"] = filepath
+
+            if format_name == "gltf":
+                obj = await self.get_build123d(ctx)
+            else:
+                obj = await self.get_wrapped(ctx)
 
-    async def render_png_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        with pc_logging.Action("RenderPNG", self.project_name, self.name):
-            obj = await self.get_wrapped(ctx)
             if obj is None:
-                # pc_logging.error("The shape failed to instantiate")
-                self.svg_path = None
+                pc_logging.error(f"Cannot render '{self.name}': shape is empty")
                 return
 
-            png_opts, filepath = self.render_getopts("png", ".png", project, filepath)
-
-            if width is None:
-                if "width" in png_opts and not png_opts["width"] is None:
-                    width = png_opts["width"]
-                else:
-                    width = DEFAULT_RENDER_WIDTH
-            if height is None:
-                if "height" in png_opts and not png_opts["height"] is None:
-                    height = png_opts["height"]
-                else:
-                    height = DEFAULT_RENDER_HEIGHT
-            if line_weight is None:
-                if "lineWeight" in png_opts and not png_opts["lineWeight"] is None:
-                    line_weight = png_opts["lineWeight"]
-                else:
-                    line_weight = 1.0
-            if viewport_origin is None:
-                if "viewportOrigin" in png_opts and not png_opts["viewportOrigin"] is None:
-                    viewport_origin = png_opts["viewportOrigin"]
-                else:
-                    viewport_origin = [100, -100, 100]
-
-            if not project is None:
+            if project is not None:
                 project.ctx.ensure_dirs_for_file(filepath)
 
-            wrapper_path = wrapper.get("render_png.py")
-            request = {
-                "wrapped": obj,
-                "width": width,
-                "height": height,
-                "line_weight": line_weight,
-                "viewport_origin": viewport_origin,
-            }
-            register_ocp_helper()
-            picklestring = pickle.dumps(request)
-            request_serialized = base64.b64encode(picklestring).decode()
-
-            # We don't care about customer preferences much here
-            # as this is expected to be hermetic.
-            # Stick to the version where CadQuery and build123d are known to work.
-            runtime = ctx.get_python_runtime(version="3.11")
-            await runtime.ensure_async("svglib==1.5.1")
-            await runtime.ensure_async("reportlab")
-            await runtime.ensure_async("rlpycairo==0.3.0")
-            response_serialized, errors = await runtime.run_async(
-                [
-                    wrapper_path,
-                    os.path.abspath(filepath),
-                ],
-                request_serialized,
-            )
-            sys.stderr.write(errors)
-
-            response = base64.b64decode(response_serialized)
-            result = pickle.loads(response)
-            if not result["success"]:
-                pc_logging.error("RenderPNG failed: %s:%s: %s" % (self.project_name, self.name, result["exception"]))
-            if "exception" in result and not result["exception"] is None:
-                pc_logging.exception("RenderPNG exception: %s" % result["exception"])
-
-    def render_png(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-        width=None,
-        height=None,
-        line_weight=None,
-        viewport_origin=None,
-    ) -> None:
-        asyncio.run(self.render_png_async(ctx, project, filepath, width, height, line_weight, viewport_origin))
+            formats_to_render = [format_name] if format_name else list(WRAPPER_FORMATS.keys())
 
-    async def render_step_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        with pc_logging.Action("RenderSTEP", self.project_name, self.name):
-            step_opts, filepath = self.render_getopts("step", ".step", project, filepath)
-
-            obj = await self.get_wrapped(ctx)
-            if obj is None:
-                raise exception.PartIsEmptyOrFailed(self.name)
-
-            def do_render_step() -> None:
-                nonlocal project, filepath, obj
-                from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
-                from OCP.Interface import Interface_Static
+            for format in formats_to_render:
+                file_extension = EXTENSION_MAPPING.get(format, format)
+                render_opts, final_filepath = self.render_getopts(format, f".{file_extension}", project, filepath)
+                final_filepath = os.path.abspath(final_filepath)
+                pc_logging.debug(f"Rendering: {filepath} for format '{format}'")
 
-                if not project is None:
-                    project.ctx.ensure_dirs_for_file(filepath)
+                wrapper_path = wrapper.get(f"render_{format}.py")
 
-                pcurves = 1
-                if "write_pcurves" in step_opts and not step_opts["write_pcurves"]:
-                    pcurves = 0
-                precision_mode = step_opts.get("precision_mode", 0)
-
-                writer = STEPControl_Writer()
-                Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves)
-                Interface_Static.SetIVal_s("write.precision.mode", precision_mode)
-                writer.Transfer(obj, STEPControl_AsIs)
-                writer.Write(filepath)
-
-            await threadpool_manager.run(do_render_step)
-
-    def render_step(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ) -> None:
-        asyncio.run(self.render_step_async(ctx, project, filepath))
-
-    async def render_brep_async(
-        self,
-        ctx,
-        project=None,
-        filepath=None,
-    ):
-        with pc_logging.Action("RenderBREP", self.project_name, self.name):
-            brep_opts, filepath = self.render_getopts("brep", ".brep", project, filepath)
+                request = {"wrapped": obj}
 
-            obj = await self.get_wrapped(ctx)
+                if format in ["svg", "png"]:
+                    request["viewport_origin"] = kwargs.get("viewport_origin", [100, -100, 100])
+                    request["line_weight"] = kwargs.get("line_weight", 1.0)
+                    if format == "png":
+                        request["width"] = kwargs.get("width", 512)
+                        request["height"] = kwargs.get("height", 512)
 
path: partcad/src/partcad/shape.py
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Specify format_name consistently.**

When checking format conditions, you're using the variable `format`, but when accessing WRAPPER_FORMATS, you're using `format_name`. This inconsistency could lead to runtime errors.


```diff
-if format in ["svg", "png"]:
+if format_name in ["svg", "png"]:
    # ...
-elif format in ["3mf", "obj", "gltf", "stl", "threejs"]:
+elif format_name in ["3mf", "obj", "gltf", "stl", "threejs"]:
    # ...
```

The same applies to other format conditions below.


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
                if format_name in ["svg", "png"]:
                    request["viewport_origin"] = kwargs.get("viewport_origin", [100, -100, 100])
                    request["line_weight"] = kwargs.get("line_weight", 1.0)
                    if format == "png":
                        request["width"] = kwargs.get("width", 512)
                        request["height"] = kwargs.get("height", 512)
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->

✅ Addressed in commits 848acb6 to 6521064
created_at: 2025-03-22T15:47:01Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-11T15:22:11Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

This pull request introduces significant updates to the rendering functionality across various classes and files. The `Project` class has updated method signatures with explicit type hints, and shape enumeration logic has been encapsulated in a new private method. The handling of output directories has been simplified. The `Shape` class now features a new asynchronous `render_async` method to centralize rendering via an external wrapper. Additionally, several classes and files have transitioned to a more generalized rendering approach, enhancing maintainability and clarity.

## Changes

| File(s)                                                 | Change Summary                                                                                                                                                                                                                              |
|---------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| partcad/src/partcad/project.py                          | Updated `render_async` and `render` method signatures with explicit type hints; simplified output directory handling using `setdefault`; added `_enumerate_shapes` to consolidate shape gathering logic; refactored rendering tasks.     |
| partcad/src/partcad/shape.py                            | Added an asynchronous `render_async` method to centralize shape rendering via an external wrapper; updated existing `render` method to call `render_async`; manages output directory creation, serializes rendering requests, and handles exceptions. |
| partcad-ide-vscode/bundled/tool/lsp_server.py          | Added type annotations for `part` and `assembly` variables; modified PNG rendering logic to use a generalized `render` method instead of specific PNG rendering calls.                                                                     |
| partcad/src/partcad/actions/part/convert.py            | Replaced `project.convert` call with `project.render`; adjusted parameters for rendering process.                                                                                                                                           |
| partcad/src/partcad/part_factory_feature_ai.py          | Updated `render` method signature to include type hints; changed rendering logic to utilize a more general `render` method.                                                                                                            |
| partcad/src/partcad/part_factory_step.py                | Removed complexity from `PartFactoryStep` class by eliminating concurrent processing checks; simplified control flow for asynchronous execution.                                                                                          |
| partcad/src/partcad/shape_ai.py                         | Updated image rendering method in `get_summary_async` to use the new `render_async` method instead of a specific PNG rendering function.                                                                                                  |
| partcad/src/partcad/wrappers/wrapper_common.py          | Enhanced error handling in `handle_exception` function to provide clearer feedback for missing tracebacks and file reading errors.                                                                                                          |
| partcad/src/partcad/wrappers/wrapper_render_brep.py     | Introduced a new script for handling BREP file reading and writing with a `process` function.                                                                                                                                              |
| partcad/tests/unit/test_render.py                        | Updated rendering method calls in test functions to use the new generalized `render` method instead of specific SVG rendering calls.                                                                                                       |
| examples/feature_convert/partcad.yaml                   | Reformatted `desc` field for readability; added new `render` section specifying output formats for rendering.                                                                                                                            |
| partcad/src/partcad/wrappers/wrapper_render_step.py     | Added a new script for creating STEP files with a `process` function to handle writing.                                                                                                                                                  |
| partcad/src/partcad/wrappers/wrapper_render_stl.py      | Introduced a new script for creating STL files with a `process` function to handle writing.                                                                                                                                               |
| partcad/src/partcad/wrappers/wrapper_stl.py             | Added a new script for processing STL files with a `process` function to handle reading and shape construction.                                                                                                                           |
| partcad/src/partcad/wrappers/wrapper_render_gltf.py     | Introduced a new script to render a glTF file from a wrapped shape with a `process` function.                                                                                                                                                |
| partcad/tests/unit/test_adhoc_convert.py                | Modified expected error message in `test_convert_invalid_file` to allow for more general assertions.                                                                                                                                     |
| partcad/tests/unit/test_convert_part.py                 | Moved `EXTENSION_MAPPING` import from local declaration to import from `partcad.shape`.                                                                                                                                                   |
| partcad/tests/unit/test_import_part.py                  | Moved `EXTENSION_MAPPING` import from local declaration to import from `partcad.shape`.                                                                                                                                                   |
| features/adhoc/convert.feature                           | Simplified expected output messages for invalid STL files and empty input files in the `pc adhoc convert` command feature.                                                                                                               |
| .github/workflows/test.yml                               | Updated `timeout-minutes` setting for `test-behave` job from 60 to 120 minutes.                                                                                                                                                          |
| .devcontainer/requirements.txt                            | Removed `reportlab` package from dependencies.                                                                                                                                                                                             |
| partcad-ide-vscode/requirements.txt                     | Removed several package dependencies including `chardet`, `cssselect2`, `lxml`, `reportlab`, and `pillow`.                                                                                                                               |
| .github/workflows/test-dev.yml                           | Increased `timeout-minutes` for `behave` job from 30 to 60 minutes.                                                                                                                                                                     |
| partcad-cli/src/partcad_cli/click/commands/export.py    | Updated `cli` method signature to include type hint for `ctx` as `Context`.                                                                                                                                                               |
| partcad-cli/src/partcad_cli/click/commands/render.py    | Updated `cli` method signature to include type hint for `ctx` as `Context`.                                                                                                                                                               |
| partcad/src/partcad/provider_factory_manufacturer.py     | Added type annotation for `part` variable in `load` method; updated rendering method call to use a more generic `render_async`.                                                                                                          |

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Lite**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 6521064ff99b2fc6c2bc6b655c1b4775389b7822 and 19d52b54548f49535d1a140c3bafc668f88f07c3.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `partcad/src/partcad/project.py` (3 hunks)
* `partcad/src/partcad/shape.py` (4 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (2)</summary>

<details>
<summary>partcad/src/partcad/shape.py (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/context.py (3)</summary>

* `Context` (37-885)
* `render_async` (847-852)
* `render` (854-859)

</details>
<details>
<summary>partcad/src/partcad/project.py (3)</summary>

* `Project` (71-1715)
* `render_async` (1329-1376)
* `render` (1421-1430)

</details>
<details>
<summary>partcad/src/partcad/shape_config.py (1)</summary>

* `ShapeConfiguration` (16-54)

</details>

</blockquote></details>
<details>
<summary>partcad/src/partcad/project.py (3)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (2)</summary>

* `Shape` (54-566)
* `render` (559-566)

</details>
<details>
<summary>partcad/src/partcad/exception.py (1)</summary>

* `EmptyShapesError` (15-20)

</details>
<details>
<summary>partcad/src/partcad/render.py (1)</summary>

* `render_cfg_merge` (12-23)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (11)</summary><blockquote>

<details>
<summary>partcad/src/partcad/shape.py (6)</summary>

`9-34`: **Good use of type hinting and forward declarations.**

The addition of proper type hints and conditional imports improves code quality and helps prevent circular dependencies. Using TYPE_CHECKING for imports needed only for type annotations is a best practice.

---

`40-51`: **Excellent addition of EXTENSION_MAPPING for format consistency.**

This centralized mapping of formats to file extensions improves maintainability and reduces the risk of inconsistent extension handling throughout the codebase.

---

`433-444`: **Well-documented consolidated rendering method with improved signature.**

The new `render_async` method provides a centralized approach to rendering with proper type hints and documentation. This is a good refactoring that improves the maintainability of the code.

---

`464-477`: **Improved error handling and project configuration.**

The new code properly handles directory paths and provides better error messages when shapes are empty. This enhances usability and diagnostics.

---

`534-558`: **Improved error handling for wrapper responses.**

The error handling for wrapper responses is comprehensive, checking for empty responses and properly logging exceptions. This will make debugging much easier.

---

`559-566`: **Updated render method to use render_async.**

The render method now calls the new render_async method, which centralizes the rendering logic. This is a good refactoring that eliminates code duplication.

</details>
<details>
<summary>partcad/src/partcad/project.py (5)</summary>

`1329-1345`: **Improved render_async signature with type hints.**

The method signature has been updated with clear type hints, making the code more maintainable and self-documenting. The simplified handling of `output_dir` is also a good improvement.

---

`1346-1372`: **Enhanced shape enumeration and task creation logic.**

The new implementation creates rendering tasks more efficiently by iterating through available formats and checking if they should be rendered. This approach is more maintainable and easier to understand than the previous implementation.

---

`1378-1398`: **Well-designed shape enumeration helper method.**

The new `_enumerate_shapes` method encapsulates the logic for gathering shapes to render, making the code more modular and easier to maintain. The comment about interfaces not being renderable is also helpful for future development.

---

`1399-1420`: **Clear and effective format selection logic.**

The `_should_render_format` method provides a clear decision tree for determining whether a format should be rendered. The inclusion of docstrings and the clear plural shape kind mapping make this code highly maintainable.

---

`1421-1430`: **Updated render method with proper type hints.**

The render method now includes proper type hints that match the async version, improving consistency and type safety.

</details>

</blockquote></details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=partcad/partcad&utm_content=382):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-11T15:29:36Z
body: ## [Codecov](https://app.codecov.io/gh/partcad/partcad/pull/382?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) Report
Attention: Patch coverage is `85.63218%` with `25 lines` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/partcad/partcad/pull/382?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) | Patch % | Lines |
|---|---|---|
| [partcad/src/partcad/shape.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fshape.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9zaGFwZS5weQ==) | 82.85% | [12 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |
| [partcad/src/partcad/actions/part/convert.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Factions%2Fpart%2Fconvert.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9hY3Rpb25zL3BhcnQvY29udmVydC5weQ==) | 73.33% | [4 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |
| [partcad/src/partcad/part\_factory\_feature\_ai.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fpart_factory_feature_ai.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wYXJ0X2ZhY3RvcnlfZmVhdHVyZV9haS5weQ==) | 33.33% | [2 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |
| [partcad/src/partcad/part\_factory\_step.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fpart_factory_step.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wYXJ0X2ZhY3Rvcnlfc3RlcC5weQ==) | 87.50% | [2 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |
| [partcad/src/partcad/part\_factory\_stl.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fpart_factory_stl.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wYXJ0X2ZhY3Rvcnlfc3RsLnB5) | 90.90% | [2 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |
| [...rtcad/src/partcad/provider\_factory\_manufacturer.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fprovider_factory_manufacturer.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wcm92aWRlcl9mYWN0b3J5X21hbnVmYWN0dXJlci5weQ==) | 33.33% | [2 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |
| [partcad/src/partcad/shape\_ai.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fshape_ai.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9zaGFwZV9haS5weQ==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) |

| [Files with missing lines](https://app.codecov.io/gh/partcad/partcad/pull/382?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) | Coverage Δ | |
|---|---|---|
| [partcad/src/partcad/adhoc/convert.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fadhoc%2Fconvert.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9hZGhvYy9jb252ZXJ0LnB5) | `90.90% <100.00%> (-1.95%)` | :arrow_down: |
| [partcad/src/partcad/project.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fproject.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wcm9qZWN0LnB5) | `65.45% <100.00%> (-0.39%)` | :arrow_down: |
| [partcad/tests/unit/test\_adhoc\_convert.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Ftests%2Funit%2Ftest_adhoc_convert.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC90ZXN0cy91bml0L3Rlc3RfYWRob2NfY29udmVydC5weQ==) | `97.87% <100.00%> (-0.57%)` | :arrow_down: |
| [partcad/tests/unit/test\_convert\_part.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Ftests%2Funit%2Ftest_convert_part.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC90ZXN0cy91bml0L3Rlc3RfY29udmVydF9wYXJ0LnB5) | `77.77% <100.00%> (ø)` | |
| [partcad/tests/unit/test\_import\_part.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Ftests%2Funit%2Ftest_import_part.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC90ZXN0cy91bml0L3Rlc3RfaW1wb3J0X3BhcnQucHk=) | `100.00% <100.00%> (ø)` | |
| [partcad/tests/unit/test\_render.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Ftests%2Funit%2Ftest_render.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC90ZXN0cy91bml0L3Rlc3RfcmVuZGVyLnB5) | `86.04% <100.00%> (ø)` | |
| [partcad/src/partcad/shape\_ai.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fshape_ai.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9zaGFwZV9haS5weQ==) | `44.44% <0.00%> (ø)` | |
| [partcad/src/partcad/part\_factory\_feature\_ai.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fpart_factory_feature_ai.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wYXJ0X2ZhY3RvcnlfZmVhdHVyZV9haS5weQ==) | `21.88% <33.33%> (+0.23%)` | :arrow_up: |
| [partcad/src/partcad/part\_factory\_step.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fpart_factory_step.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wYXJ0X2ZhY3Rvcnlfc3RlcC5weQ==) | `95.00% <87.50%> (+0.43%)` | :arrow_up: |
| [partcad/src/partcad/part\_factory\_stl.py](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree&filepath=partcad%2Fsrc%2Fpartcad%2Fpart_factory_stl.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad#diff-cGFydGNhZC9zcmMvcGFydGNhZC9wYXJ0X2ZhY3Rvcnlfc3RsLnB5) | `94.11% <90.90%> (+19.92%)` | :arrow_up: |
| ... and [3 more](https://app.codecov.io/gh/partcad/partcad/pull/382?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad) | |

... and [3 files with indirect coverage changes](https://app.codecov.io/gh/partcad/partcad/pull/382/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=partcad)

<details><summary> :rocket: New features to boost your workflow: </summary>

- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE