diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 2256e9e7..583b7ab4 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -108,7 +108,7 @@ Sketches are declared in ``partcad.yaml`` using the following syntax: sketches: : - type: + type: desc: <(optional) textual description> path: <(optional) the source file path, "{sketch name}.{ext}" otherwise> # ... type-specific options ... @@ -178,6 +178,11 @@ Such sketches are declared using the following syntax: ignore-visibility: <(optional) boolean> flip-y: <(optional) boolean> +CAD Scripts +----------- + +See the "CAD Scripts" section in the "Parts" chapter below. + ========== Interfaces ========== diff --git a/examples/produce_sketch_cadquery/README.md b/examples/produce_sketch_cadquery/README.md new file mode 100644 index 00000000..d6c95219 --- /dev/null +++ b/examples/produce_sketch_cadquery/README.md @@ -0,0 +1,21 @@ +# /pub/examples/partcad/produce_sketch_cadquery + +This example demonstrates how to define a sketch using a CadQuery script. + +## Usage +```shell +pc inspect -s sketch +``` + + +## Sketches + +### sketch + + + +
A sample sketch
+ +

+ +*Generated by [PartCAD](https://partcad.org/)* diff --git a/examples/produce_sketch_cadquery/partcad.yaml b/examples/produce_sketch_cadquery/partcad.yaml new file mode 100644 index 00000000..ab8459da --- /dev/null +++ b/examples/produce_sketch_cadquery/partcad.yaml @@ -0,0 +1,16 @@ +desc: This example demonstrates how to define a sketch using a CadQuery script. + +docs: + usage: | + ```shell + pc inspect -s sketch + ``` + +sketches: + sketch: + type: cadquery + desc: A sample sketch + +render: + readme: + svg: diff --git a/examples/produce_sketch_cadquery/sketch.py b/examples/produce_sketch_cadquery/sketch.py new file mode 100644 index 00000000..4af90c62 --- /dev/null +++ b/examples/produce_sketch_cadquery/sketch.py @@ -0,0 +1,11 @@ +import cadquery as cq + +sk1 = ( + cq.Sketch() + .rect(3.0, 4.0) + .push([(0, 0.75), (0, -0.75)]) + .regularPolygon(0.5, 6, 90, mode="s") +) + +result = cq.Workplane("front").placeSketch(sk1) +show_object(result) diff --git a/examples/produce_sketch_cadquery/sketch.svg b/examples/produce_sketch_cadquery/sketch.svg new file mode 100644 index 00000000..a5e113a6 --- /dev/null +++ b/examples/produce_sketch_cadquery/sketch.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/partcad/src/partcad/part_factory_build123d.py b/partcad/src/partcad/part_factory_build123d.py index a0a43f32..b243e1eb 100644 --- a/partcad/src/partcad/part_factory_build123d.py +++ b/partcad/src/partcad/part_factory_build123d.py @@ -95,10 +95,12 @@ async def instantiate(self, part): picklestring = pickle.dumps(request) request_serialized = base64.b64encode(picklestring).decode() + await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("cadquery") await self.runtime.ensure("numpy==1.24.1") + await self.runtime.ensure("numpy-quaternion==2023.0.4") await self.runtime.ensure("nptyping==1.24.1") - await self.runtime.ensure("cadquery") - await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("typing_extensions>=4.6.0,<5") await self.runtime.ensure("build123d") cwd = self.project.config_dir if self.cwd is not None: diff --git a/partcad/src/partcad/part_factory_cadquery.py b/partcad/src/partcad/part_factory_cadquery.py index 802d7481..ec62e887 100644 --- a/partcad/src/partcad/part_factory_cadquery.py +++ b/partcad/src/partcad/part_factory_cadquery.py @@ -85,10 +85,12 @@ async def instantiate(self, part): picklestring = pickle.dumps(request) request_serialized = base64.b64encode(picklestring).decode() + await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("cadquery") await self.runtime.ensure("numpy==1.24.1") + await self.runtime.ensure("numpy-quaternion==2023.0.4") await self.runtime.ensure("nptyping==1.24.1") - await self.runtime.ensure("cadquery") - await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("typing_extensions>=4.6.0,<5") cwd = self.project.config_dir if self.cwd is not None: cwd = os.path.join(self.project.config_dir, self.cwd) diff --git a/partcad/src/partcad/project.py b/partcad/src/partcad/project.py index 5d176d67..2b3b5657 100644 --- a/partcad/src/partcad/project.py +++ b/partcad/src/partcad/project.py @@ -28,6 +28,7 @@ from .sketch_factory_dxf import SketchFactoryDxf from .sketch_factory_svg import SketchFactorySvg from .sketch_factory_build123d import SketchFactoryBuild123d +from .sketch_factory_cadquery import SketchFactoryCadquery from . import part from . import part_config from .part_factory_extrude import PartFactoryExtrude @@ -378,6 +379,8 @@ def init_sketch_by_config(self, config, source_project=None): ) elif config["type"] == "build123d": SketchFactoryBuild123d(self.ctx, source_project, self, config) + elif config["type"] == "cadquery": + SketchFactoryCadquery(self.ctx, source_project, self, config) elif config["type"] == "dxf": SketchFactoryDxf(self.ctx, source_project, self, config) elif config["type"] == "svg": diff --git a/partcad/src/partcad/provider_factory_python.py b/partcad/src/partcad/provider_factory_python.py index df3044f0..26a55fab 100644 --- a/partcad/src/partcad/provider_factory_python.py +++ b/partcad/src/partcad/provider_factory_python.py @@ -133,10 +133,11 @@ async def query_script(self, provider, script_name, request): picklestring = pickle.dumps(request) request_serialized = base64.b64encode(picklestring).decode() + await self.runtime.ensure("ocp-tessellate") await self.runtime.ensure("numpy==1.24.1") + await self.runtime.ensure("numpy-quaternion==2023.0.4") await self.runtime.ensure("nptyping==1.24.1") await self.runtime.ensure("cadquery") - await self.runtime.ensure("ocp-tessellate") cwd = self.project.config_dir if self.cwd is not None: cwd = os.path.join(self.project.config_dir, self.cwd) diff --git a/partcad/src/partcad/sketch_factory_build123d.py b/partcad/src/partcad/sketch_factory_build123d.py index 998e5ceb..96f5adc4 100644 --- a/partcad/src/partcad/sketch_factory_build123d.py +++ b/partcad/src/partcad/sketch_factory_build123d.py @@ -34,6 +34,15 @@ class SketchFactoryBuild123d(SketchFactoryPython): def __init__( self, ctx, source_project, target_project, config, can_create=False ): + python_version = source_project.python_version + if python_version is None: + # Stay one step ahead of the minimum required Python version + python_version = "3.10" + if python_version == "3.12" or python_version == "3.11": + pc_logging.debug( + "Downgrading Python version to 3.10 to avoid compatibility issues with build123d" + ) + python_version = "3.10" with pc_logging.Action( "InitBuild123d", target_project.name, config["name"] ): @@ -43,6 +52,7 @@ def __init__( target_project, config, can_create=can_create, + python_version=python_version, ) # Complement the config object here if necessary self._create(config) @@ -88,10 +98,12 @@ async def instantiate(self, sketch): picklestring = pickle.dumps(request) request_serialized = base64.b64encode(picklestring).decode() + await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("cadquery") await self.runtime.ensure("numpy==1.24.1") + await self.runtime.ensure("numpy-quaternion==2023.0.4") await self.runtime.ensure("nptyping==1.24.1") - await self.runtime.ensure("cadquery") - await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("typing_extensions>=4.6.0,<5") await self.runtime.ensure("build123d") cwd = self.project.config_dir if self.cwd is not None: diff --git a/partcad/src/partcad/sketch_factory_cadquery.py b/partcad/src/partcad/sketch_factory_cadquery.py new file mode 100644 index 00000000..86c876a8 --- /dev/null +++ b/partcad/src/partcad/sketch_factory_cadquery.py @@ -0,0 +1,188 @@ +# +# OpenVMP, 2023 +# +# Author: Roman Kuzmenko +# Created: 2023-08-19 +# +# Licensed under Apache License, Version 2.0. +# + +import base64 +import os +import pickle +import sys + +from OCP.gp import gp_Ax1 +from OCP.TopoDS import ( + TopoDS_Builder, + TopoDS_Compound, + TopoDS_Edge, + TopoDS_Wire, + TopoDS_Face, +) +from OCP.TopLoc import TopLoc_Location + +from .sketch_factory_python import SketchFactoryPython +from . import wrapper +from . import logging as pc_logging + +sys.path.append(os.path.join(os.path.dirname(__file__), "wrappers")) +from cq_serialize import register as register_cq_helper + + +class SketchFactoryCadquery(SketchFactoryPython): + def __init__( + self, ctx, source_project, target_project, config, can_create=False + ): + python_version = source_project.python_version + if python_version is None: + # Stay one step ahead of the minimum required Python version + python_version = "3.10" + if python_version == "3.12" or python_version == "3.11": + pc_logging.debug( + "Downgrading Python version to 3.10 to avoid compatibility issues with CadQuery" + ) + python_version = "3.10" + with pc_logging.Action( + "InitCadQuery", target_project.name, config["name"] + ): + super().__init__( + ctx, + source_project, + target_project, + config, + can_create=can_create, + python_version=python_version, + ) + # Complement the config object here if necessary + self._create(config) + + async def instantiate(self, sketch): + await super().instantiate(sketch) + + with pc_logging.Action("CadQuery", sketch.project_name, sketch.name): + if ( + not os.path.exists(sketch.path) + or os.path.getsize(sketch.path) == 0 + ): + pc_logging.error( + "CadQuery script is empty or does not exist: %s" + % sketch.path + ) + return None + + # Finish initialization of PythonRuntime + # which was too expensive to do in the constructor + await self.prepare_python() + + # Get the path to the wrapper script + # which needs to be executed + wrapper_path = wrapper.get("cadquery.py") + + # Build the request + request = {"build_parameters": {}} + if "parameters" in self.config: + for param_name, param in self.config["parameters"].items(): + request["build_parameters"][param_name] = param["default"] + patch = {} + if "patch" in self.config: + patch.update(self.config["patch"]) + request["patch"] = patch + + # Serialize the request + register_cq_helper() + picklestring = pickle.dumps(request) + request_serialized = base64.b64encode(picklestring).decode() + + await self.runtime.ensure("ocp-tessellate") + await self.runtime.ensure("cadquery") + await self.runtime.ensure("numpy==1.24.1") + await self.runtime.ensure("numpy-quaternion==2023.0.4") + await self.runtime.ensure("nptyping==1.24.1") + await self.runtime.ensure("typing_extensions>=4.6.0,<5") + cwd = self.project.config_dir + if self.cwd is not None: + cwd = os.path.join(self.project.config_dir, self.cwd) + response_serialized, errors = await self.runtime.run( + [ + wrapper_path, + os.path.abspath(sketch.path), + os.path.abspath(cwd), + ], + request_serialized, + ) + if len(errors) > 0: + error_lines = errors.split("\n") + for error_line in error_lines: + sketch.error("%s: %s" % (sketch.name, error_line)) + + try: + # pc_logging.error("Response: %s" % response_serialized) + response = base64.b64decode(response_serialized) + register_cq_helper() + result = pickle.loads(response) + except Exception as e: + sketch.error( + "Exception while deserializing %s: %s" % (sketch.name, e) + ) + return None + + if not result["success"]: + sketch.error("%s: %s" % (sketch.name, result["exception"])) + return None + + self.ctx.stats_sketches_instantiated += 1 + + if result["shapes"] is None: + return None + if len(result["shapes"]) == 0: + return None + if len(result["shapes"]) == 1: + return result["shapes"][0] + + builder = TopoDS_Builder() + compound = TopoDS_Compound() + builder.MakeCompound(compound) + + def process(shapes, components_list): + for shape in shapes: + # pc_logging.info("Returned: %s" % type(shape)) + try: + if shape is None or isinstance(shape, str): + # pc_logging.info("String: %s" % shape) + continue + + if isinstance(shape, list): + child_component_list = list() + process(shape, child_component_list) + components_list.append(child_component_list) + continue + + # TODO(clairbee): add support for the below types + if isinstance(shape, TopLoc_Location) or isinstance( + shape, gp_Ax1 + ): + continue + + if ( + isinstance(shape, TopoDS_Edge) + or isinstance(shape, TopoDS_Wire) + or isinstance(shape, TopoDS_Face) + ): + builder.Add(compound, shape) + components_list.append(shape) + elif False: + # TODO(clairbee) Add all metadata types here + components_list.append(shape) + else: + pc_logging.error( + "Unsupported shape type: %s" % type(shape) + ) + except Exception as e: + pc_logging.error( + "Error adding shape to compound: %s" % e + ) + + process(result["shapes"], sketch.components) + + return compound diff --git a/partcad/src/partcad/sketch_factory_python.py b/partcad/src/partcad/sketch_factory_python.py index d2ff2d8a..f904eca2 100644 --- a/partcad/src/partcad/sketch_factory_python.py +++ b/partcad/src/partcad/sketch_factory_python.py @@ -18,7 +18,13 @@ class SketchFactoryPython(SketchFactoryFile): cwd: str def __init__( - self, ctx, source_project, target_project, config, can_create=False + self, + ctx, + source_project, + target_project, + config, + can_create=False, + python_version=None, ): super().__init__( ctx, @@ -29,7 +35,11 @@ def __init__( can_create=can_create, ) self.cwd = config.get("cwd", None) - self.runtime = self.ctx.get_python_runtime(self.project.python_version) + + if python_version is None: + # TODO(clairbee): stick to a default constant or configured version + python_version = self.project.python_version + self.runtime = self.ctx.get_python_runtime(python_version) async def prepare_python(self): """ diff --git a/partcad/src/partcad/wrappers/cq_serialize.py b/partcad/src/partcad/wrappers/cq_serialize.py index 76c45260..0fef2866 100644 --- a/partcad/src/partcad/wrappers/cq_serialize.py +++ b/partcad/src/partcad/wrappers/cq_serialize.py @@ -287,6 +287,10 @@ def register(): copyreg.pickle( cq.Location, lambda loc: (cq.Location, (loc.wrapped.Transformation(),)) ) + copyreg.pickle( + TopLoc_Location, + lambda loc: (TopLoc_Location, (loc.Transformation(),)), + ) for cls in ( TopoDS_Shape, diff --git a/partcad/src/partcad/wrappers/wrapper_cadquery.py b/partcad/src/partcad/wrappers/wrapper_cadquery.py index eff5671e..c58a5ab2 100644 --- a/partcad/src/partcad/wrappers/wrapper_cadquery.py +++ b/partcad/src/partcad/wrappers/wrapper_cadquery.py @@ -65,6 +65,8 @@ def process(path, request): shapes = [] for result in build_result.results: shape = result.shape + if hasattr(shape, "toOCC"): + shape = shape.toOCC() if hasattr(shape, "val"): shape = shape.val() if hasattr(shape, "toCompound"):