From 4552668e6684b10f32ed51ba8dea611a4322ae43 Mon Sep 17 00:00:00 2001 From: Antony Chan Date: Sun, 22 Feb 2026 15:03:07 -0800 Subject: [PATCH 01/14] Project 3D part to plane Simulate the API from Build123d `project_to_viewpoint`: Given a 3D vector representing the camera position pointing at the origin `(0,0,0)`, render all visible edges/arcs/splines representing the outline of the 3D part projected to the camera. Also render all hidden edges/arcs/splines. Provide and example script `tests/test_projection.py` where a generic part (A bracket with rounded corners and a countersunk hole) is projected to top view, side view, front view, and orthogonal view, and then exported to DXF 2D drawings. Reference: https://build123d.readthedocs.io/en/latest/tech_drawing_tutorial.html --- cadquery/occ_impl/exporters/dxf.py | 1 + cadquery/occ_impl/exporters/svg.py | 81 ++++++------------------------ cadquery/occ_impl/shapes.py | 63 +++++++++++++++++++++++ tests/test_projection.py | 45 +++++++++++++++++ 4 files changed, 124 insertions(+), 66 deletions(-) create mode 100644 tests/test_projection.py diff --git a/cadquery/occ_impl/exporters/dxf.py b/cadquery/occ_impl/exporters/dxf.py index 41ae15224..8180f274f 100644 --- a/cadquery/occ_impl/exporters/dxf.py +++ b/cadquery/occ_impl/exporters/dxf.py @@ -157,6 +157,7 @@ def add_shape(self, shape: Union[WorkplaneLike, Shape], layer: str = "") -> Self plane = shape.plane shape_ = compound(*shape.__iter__()).transformShape(plane.fG) else: + plane = Plane((0, 0, 0)) shape_ = shape general_attributes = {} diff --git a/cadquery/occ_impl/exporters/svg.py b/cadquery/occ_impl/exporters/svg.py index b41dee7b0..2a04290e0 100644 --- a/cadquery/occ_impl/exporters/svg.py +++ b/cadquery/occ_impl/exporters/svg.py @@ -1,13 +1,10 @@ import io as StringIO -from ..shapes import Shape, Compound, TOLERANCE +from ..shapes import Shape, Compound, Edge from ..geom import BoundBox +from ..shapes import projectToViewpoint -from OCP.gp import gp_Ax2, gp_Pnt, gp_Dir -from OCP.BRepLib import BRepLib -from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape -from OCP.HLRAlgo import HLRAlgo_Projector from OCP.GCPnts import GCPnts_QuasiUniformDeflection DISCRETIZATION_TOLERANCE = 1e-3 @@ -106,7 +103,9 @@ def makeSVGedge(e): return cs.getvalue() -def getPaths(visibleShapes, hiddenShapes): +def getPaths( + visibleEdges: list[Edge], hiddenEdges: list[Edge] +) -> tuple[list[str], list[str]]: """ Collects the visible and hidden edges from the CadQuery object. """ @@ -114,18 +113,16 @@ def getPaths(visibleShapes, hiddenShapes): hiddenPaths = [] visiblePaths = [] - for s in visibleShapes: - for e in s.Edges(): - visiblePaths.append(makeSVGedge(e)) + for e in visibleEdges: + visiblePaths.append(makeSVGedge(e)) - for s in hiddenShapes: - for e in s.Edges(): - hiddenPaths.append(makeSVGedge(e)) + for e in hiddenEdges: + hiddenPaths.append(makeSVGedge(e)) return (hiddenPaths, visiblePaths) -def getSVG(shape, opts=None): +def getSVG(shape: Shape, opts=None): """ Export a shape to SVG text. @@ -186,64 +183,16 @@ def getSVG(shape, opts=None): showHidden = bool(d["showHidden"]) focus = float(d["focus"]) if d.get("focus") else None - hlr = HLRBRep_Algo() - hlr.Add(shape.wrapped) - - coordinate_system = gp_Ax2(gp_Pnt(), gp_Dir(*projectionDir)) - - if focus is not None: - projector = HLRAlgo_Projector(coordinate_system, focus) - else: - projector = HLRAlgo_Projector(coordinate_system) - - hlr.Projector(projector) - hlr.Update() - hlr.Hide() - - hlr_shapes = HLRBRep_HLRToShape(hlr) - - visible = [] - - visible_sharp_edges = hlr_shapes.VCompound() - if not visible_sharp_edges.IsNull(): - visible.append(visible_sharp_edges) - - visible_smooth_edges = hlr_shapes.Rg1LineVCompound() - if not visible_smooth_edges.IsNull(): - visible.append(visible_smooth_edges) - - visible_contour_edges = hlr_shapes.OutLineVCompound() - if not visible_contour_edges.IsNull(): - visible.append(visible_contour_edges) - - hidden = [] - - hidden_sharp_edges = hlr_shapes.HCompound() - if not hidden_sharp_edges.IsNull(): - hidden.append(hidden_sharp_edges) - - hidden_contour_edges = hlr_shapes.OutLineHCompound() - if not hidden_contour_edges.IsNull(): - hidden.append(hidden_contour_edges) - - # Fix the underlying geometry - otherwise we will get segfaults - for el in visible: - BRepLib.BuildCurves3d_s(el, TOLERANCE) - for el in hidden: - BRepLib.BuildCurves3d_s(el, TOLERANCE) - - # convert to native CQ objects - visible = list(map(Shape, visible)) - hidden = list(map(Shape, hidden)) - (hiddenPaths, visiblePaths) = getPaths(visible, hidden) + visibleEdges, hiddenEdges = projectToViewpoint(shape, projectionDir, focus) + hiddenPaths, visiblePaths = getPaths(visibleEdges, hiddenEdges) # get bounding box -- these are all in 2D space - bb = Compound.makeCompound(hidden + visible).BoundingBox() + bb = Compound.makeCompound(hiddenEdges + visibleEdges).BoundingBox() # Determine whether the user wants to fit the drawing to the bounding box - if width == None or height == None: + if width is None or height is None: # Fit image to specified width (or height) - if width == None: + if width is None: width = (height - (2.0 * marginTop)) * ( bb.xlen / bb.ylen ) + 2.0 * marginLeft diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 49f2ab667..bb1ceba81 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -158,6 +158,9 @@ BRepAlgoAPI_Check, ) +from OCP.HLRAlgo import HLRAlgo_Projector +from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape + from OCP.Geom import ( Geom_BezierCurve, Geom_ConicalSurface, @@ -6572,3 +6575,63 @@ def closest(s1: Shape, s2: Shape) -> Tuple[Vector, Vector]: assert ext.Perform() return Vector(ext.PointOnShape1(1)), Vector(ext.PointOnShape2(1)) + + +def projectToViewpoint( + shape, projectionDir: tuple[float, float, float], focus: Optional[float] = None, +) -> tuple[list[Edge], list[Edge]]: + hlr = HLRBRep_Algo() + hlr.Add(shape.wrapped) + + coordinate_system = gp_Ax2(gp_Pnt(), gp_Dir(*projectionDir)) + + if focus is not None: + projector = HLRAlgo_Projector(coordinate_system, focus) + else: + projector = HLRAlgo_Projector(coordinate_system) + + hlr.Projector(projector) + hlr.Update() + hlr.Hide() + + hlr_shapes = HLRBRep_HLRToShape(hlr) + + visible = [] + + visible_sharp_edges = hlr_shapes.VCompound() + if not visible_sharp_edges.IsNull(): + visible.append(visible_sharp_edges) + + visible_smooth_edges = hlr_shapes.Rg1LineVCompound() + if not visible_smooth_edges.IsNull(): + visible.append(visible_smooth_edges) + + visible_contour_edges = hlr_shapes.OutLineVCompound() + if not visible_contour_edges.IsNull(): + visible.append(visible_contour_edges) + + hidden = [] + + hidden_sharp_edges = hlr_shapes.HCompound() + if not hidden_sharp_edges.IsNull(): + hidden.append(hidden_sharp_edges) + + hidden_contour_edges = hlr_shapes.OutLineHCompound() + if not hidden_contour_edges.IsNull(): + hidden.append(hidden_contour_edges) + + # Fix the underlying geometry - otherwise we will get segfaults + for el in visible: + BRepLib.BuildCurves3d_s(el, TOLERANCE) + for el in hidden: + BRepLib.BuildCurves3d_s(el, TOLERANCE) + + # convert to native CQ objects + visible = [Shape.cast(s) for s in visible] # s is a TopoDS_Shape (Compound) + hidden = [Shape.cast(s) for s in hidden] + + # Extract edges + visible_edges = [e for c in visible for e in c.Edges()] + hidden_edges = [e for c in hidden for e in c.Edges()] + + return visible_edges, hidden_edges diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 000000000..b6298c27e --- /dev/null +++ b/tests/test_projection.py @@ -0,0 +1,45 @@ +import cadquery as cq +from cadquery.occ_impl.exporters.svg import exportSVG +from cadquery.occ_impl.shapes import Compound, projectToViewpoint +from cadquery import Workplane + +viewpoint = { + "top": (0, 0, 1), + "left": (1, 0, 0), + "front": (0, 1, 0), + "ortho": (1, 1, 1), +} + + +def exportDXF3rdAngleProjection(my_part: Workplane, prefix: str) -> None: + for name, direction in viewpoint.items(): + visible_edges, hidden_edges = projectToViewpoint(my_part.val(), direction) + cq.exporters.exportDXF( + Compound.makeCompound(visible_edges), f"{prefix}{name}.dxf", doc_units=6, + ) + + +def exportSVG3rdAngleProjection(my_part, prefix: str) -> None: + for name, direction in viewpoint.items(): + exportSVG( + my_part, f"{prefix}{name}.svg", opts={"projectionDir": direction,}, + ) + + +if __name__ == "__main__": + # Build the part + width = 10 + depth = 10 + height = 10 + + # !!! Test projection of fillets to arc segments in DXF. !!! + baseplate = cq.Workplane("XY").box(width, depth, height).edges("|Z").fillet(2.0) # + + hole_dia = 3.0 + + # !!! Test projection of countersunk to arc segments in DXF. !!! + drilled = baseplate.faces(">Z").workplane().cskHole(hole_dia, hole_dia * 2, 82.0) # + + # Expected DXF output to be identical to SVG output + exportSVG3rdAngleProjection(drilled, "") + exportDXF3rdAngleProjection(drilled, "") From 7e0a42f4b27f6093e6e5381bce5e6231d9d6b817 Mon Sep 17 00:00:00 2001 From: AU Date: Tue, 7 Apr 2026 16:49:36 +0200 Subject: [PATCH 02/14] do not type check getSVG --- cadquery/occ_impl/exporters/svg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/exporters/svg.py b/cadquery/occ_impl/exporters/svg.py index 2a04290e0..cf0661414 100644 --- a/cadquery/occ_impl/exporters/svg.py +++ b/cadquery/occ_impl/exporters/svg.py @@ -122,7 +122,7 @@ def getPaths( return (hiddenPaths, visiblePaths) -def getSVG(shape: Shape, opts=None): +def getSVG(shape, opts=None): """ Export a shape to SVG text. From d8fb5493f4368831d10cafde9ff7180401775ee1 Mon Sep 17 00:00:00 2001 From: AU Date: Wed, 8 Apr 2026 07:40:08 +0200 Subject: [PATCH 03/14] Another quick mypy fix --- cadquery/occ_impl/shapes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index bb1ceba81..4eae7fc48 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6627,11 +6627,11 @@ def projectToViewpoint( BRepLib.BuildCurves3d_s(el, TOLERANCE) # convert to native CQ objects - visible = [Shape.cast(s) for s in visible] # s is a TopoDS_Shape (Compound) - hidden = [Shape.cast(s) for s in hidden] + visible_ = [Shape.cast(s) for s in visible] # s is a TopoDS_Shape (Compound) + hidden_ = [Shape.cast(s) for s in hidden] # Extract edges - visible_edges = [e for c in visible for e in c.Edges()] - hidden_edges = [e for c in hidden for e in c.Edges()] + visible_edges = [e for c in visible_ for e in c.Edges()] + hidden_edges = [e for c in hidden_ for e in c.Edges()] return visible_edges, hidden_edges From e78011959277e8554821490d79fceb26e127b1e0 Mon Sep 17 00:00:00 2001 From: AU Date: Wed, 8 Apr 2026 18:10:57 +0200 Subject: [PATCH 04/14] Fix unrelated upstream mypy issue --- cadquery/occ_impl/shapes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 4eae7fc48..d2edfa294 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -619,7 +619,7 @@ def geomType(self) -> Geoms: if isinstance(tr, str): rv = tr elif tr is BRepAdaptor_Curve: - rv = geom_LUT_EDGE[tr(self.wrapped).GetType()] + rv = geom_LUT_EDGE[tr(tcast(TopoDS_Edge, self.wrapped)).GetType()] else: rv = geom_LUT_FACE[tr(self.wrapped).GetType()] From 98d8f1afbeb58cd81b57e6572100b0135afb4eed Mon Sep 17 00:00:00 2001 From: AU Date: Tue, 30 Jun 2026 18:05:55 +0200 Subject: [PATCH 05/14] Remove Optional --- cadquery/occ_impl/shapes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index d2edfa294..49c83f596 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6578,7 +6578,7 @@ def closest(s1: Shape, s2: Shape) -> Tuple[Vector, Vector]: def projectToViewpoint( - shape, projectionDir: tuple[float, float, float], focus: Optional[float] = None, + shape, projectionDir: tuple[float, float, float], focus: float|None = None, ) -> tuple[list[Edge], list[Edge]]: hlr = HLRBRep_Algo() hlr.Add(shape.wrapped) From 403b694494c26976bb57517f339ea3372c47e3d8 Mon Sep 17 00:00:00 2001 From: AU Date: Tue, 30 Jun 2026 19:23:52 +0200 Subject: [PATCH 06/14] Black fix --- cadquery/occ_impl/shapes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 49c83f596..5d6124fe1 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6578,7 +6578,7 @@ def closest(s1: Shape, s2: Shape) -> Tuple[Vector, Vector]: def projectToViewpoint( - shape, projectionDir: tuple[float, float, float], focus: float|None = None, + shape, projectionDir: tuple[float, float, float], focus: float | None = None, ) -> tuple[list[Edge], list[Edge]]: hlr = HLRBRep_Algo() hlr.Add(shape.wrapped) From 6f597fe1f6fb0c2851e9b0b6af2245adcf11105e Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:19:08 +0200 Subject: [PATCH 07/14] Refactored the hlr projection function --- cadquery/occ_impl/exporters/svg.py | 4 ++-- cadquery/occ_impl/shapes.py | 20 ++++++++++---------- tests/test_projection.py | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cadquery/occ_impl/exporters/svg.py b/cadquery/occ_impl/exporters/svg.py index cf0661414..b742a6b87 100644 --- a/cadquery/occ_impl/exporters/svg.py +++ b/cadquery/occ_impl/exporters/svg.py @@ -2,7 +2,7 @@ from ..shapes import Shape, Compound, Edge from ..geom import BoundBox -from ..shapes import projectToViewpoint +from ..shapes import hlr from OCP.GCPnts import GCPnts_QuasiUniformDeflection @@ -183,7 +183,7 @@ def getSVG(shape, opts=None): showHidden = bool(d["showHidden"]) focus = float(d["focus"]) if d.get("focus") else None - visibleEdges, hiddenEdges = projectToViewpoint(shape, projectionDir, focus) + visibleEdges, hiddenEdges = hlr(shape, (0, 0, 0), projectionDir, focus) hiddenPaths, visiblePaths = getPaths(visibleEdges, hiddenEdges) # get bounding box -- these are all in 2D space diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index e0db52985..0e3a396bf 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -7976,13 +7976,17 @@ def closest(s1: Shape, s2: Shape) -> tuple[Vector, Vector]: return Vector(ext.PointOnShape1(1)), Vector(ext.PointOnShape2(1)) -def projectToViewpoint( - shape, projectionDir: tuple[float, float, float], focus: float | None = None, -) -> tuple[list[Edge], list[Edge]]: +def hlr( + shape, pnt: VectorLike, dir: VectorLike, focus: float | None = None, +) -> tuple[Compound, Compound]: + """ + Project shape onto a plane defined by pnt and dir and apply hidden line removal. Useful for + generating views for technical drawings. + """ hlr = HLRBRep_Algo() hlr.Add(shape.wrapped) - coordinate_system = gp_Ax2(gp_Pnt(), gp_Dir(*projectionDir)) + coordinate_system = gp_Ax2(Vector(pnt).toPnt(), Vector(dir).toDir()) if focus is not None: projector = HLRAlgo_Projector(coordinate_system, focus) @@ -8025,12 +8029,8 @@ def projectToViewpoint( for el in hidden: BRepLib.BuildCurves3d_s(el, TOLERANCE) - # convert to native CQ objects - visible_ = [Shape.cast(s) for s in visible] # s is a TopoDS_Shape (Compound) - hidden_ = [Shape.cast(s) for s in hidden] - # Extract edges - visible_edges = [e for c in visible_ for e in c.Edges()] - hidden_edges = [e for c in hidden_ for e in c.Edges()] + visible_edges = compound(_compound_or_shape(visible).Edges()) + hidden_edges = compound(_compound_or_shape(hidden).Edges()) return visible_edges, hidden_edges diff --git a/tests/test_projection.py b/tests/test_projection.py index b6298c27e..bc563d191 100644 --- a/tests/test_projection.py +++ b/tests/test_projection.py @@ -1,6 +1,6 @@ import cadquery as cq from cadquery.occ_impl.exporters.svg import exportSVG -from cadquery.occ_impl.shapes import Compound, projectToViewpoint +from cadquery.occ_impl.shapes import Compound, hlr from cadquery import Workplane viewpoint = { @@ -13,7 +13,7 @@ def exportDXF3rdAngleProjection(my_part: Workplane, prefix: str) -> None: for name, direction in viewpoint.items(): - visible_edges, hidden_edges = projectToViewpoint(my_part.val(), direction) + visible_edges, hidden_edges = hlr(my_part.val(), direction) cq.exporters.exportDXF( Compound.makeCompound(visible_edges), f"{prefix}{name}.dxf", doc_units=6, ) From af418fe1f4db6b38f2b3f845b9a3acd62264c09c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:42:45 +0200 Subject: [PATCH 08/14] Move to examples --- tests/test_projection.py => examples/Ex102_HLR.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/test_projection.py => examples/Ex102_HLR.py (100%) diff --git a/tests/test_projection.py b/examples/Ex102_HLR.py similarity index 100% rename from tests/test_projection.py rename to examples/Ex102_HLR.py From d824877a5c4c471935eef52a1ab97412defd5d80 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:19:39 +0200 Subject: [PATCH 09/14] Add DXF export with projection and HLR --- cadquery/occ_impl/exporters/__init__.py | 9 ++--- cadquery/occ_impl/exporters/dxf.py | 41 ++++++++++++++++++++-- examples/Ex102_DXF_with_HLR.py | 36 ++++++++++++++++++++ examples/Ex102_HLR.py | 45 ------------------------- 4 files changed, 76 insertions(+), 55 deletions(-) create mode 100644 examples/Ex102_DXF_with_HLR.py delete mode 100644 examples/Ex102_HLR.py diff --git a/cadquery/occ_impl/exporters/__init__.py b/cadquery/occ_impl/exporters/__init__.py index d27bc7a57..266dd4ac9 100644 --- a/cadquery/occ_impl/exporters/__init__.py +++ b/cadquery/occ_impl/exporters/__init__.py @@ -1,21 +1,16 @@ -import tempfile -import os -import io as StringIO - from typing import IO, Optional, Union, cast, Dict, Any, Iterable from typing_extensions import Literal from OCP.VrmlAPI import VrmlAPI -from ...utils import deprecate from ..shapes import Shape, compound from ...types import UnitLiterals -from .svg import getSVG +from .svg import getSVG, exportSVG from .json import JsonMesh from .amf import AmfWriter from .threemf import ThreeMFWriter -from .dxf import exportDXF, DxfDocument +from .dxf import exportDXF, exportDXFProjection, DxfDocument from .vtk import exportVTP diff --git a/cadquery/occ_impl/exporters/dxf.py b/cadquery/occ_impl/exporters/dxf.py index 8180f274f..0cc69f78b 100644 --- a/cadquery/occ_impl/exporters/dxf.py +++ b/cadquery/occ_impl/exporters/dxf.py @@ -22,8 +22,8 @@ from typing_extensions import Self from ...units import RAD2DEG -from ..shapes import Face, Edge, Shape, Compound, compound -from ..geom import Plane +from ..shapes import Face, Edge, Shape, Compound, compound, hlr +from ..geom import Plane, VectorLike ApproxOptions = Literal["spline", "arc"] @@ -366,7 +366,7 @@ def _dxf_spline(cls, edge: Edge, plane: Plane) -> DxfEntityAttributes: def exportDXF( - w: Union[WorkplaneLike, Shape, Iterable[Shape]], + w: WorkplaneLike | Shape | Iterable[Shape], fname: str, approx: Optional[ApproxOptions] = None, tolerance: float = 1e-3, @@ -395,3 +395,38 @@ def exportDXF( zoom.extents(dxf.msp) dxf.document.saveas(fname) + + +def exportDXFProjection( + w: Union[WorkplaneLike, Shape], + pnt: VectorLike, + dir: VectorLike, + fname: str, + approx: Optional[ApproxOptions] = None, + tolerance: float = 1e-3, + *, + doc_units: int = units.MM, +) -> None: + """ + Export Workplane or shape content to DXF using projections. Works with 3D objects. + + :param w: Workplane to be exported. + :param pnt: Center of projection. + :param dir: Direction of projection. + :param fname: Output filename. + :param approx: Approximation strategy. None means no approximation is applied. + "spline" results in all splines being approximated as cubic splines. "arc" results + in all curves being approximated as arcs and straight segments. + :param tolerance: Approximation tolerance. + :param doc_units: ezdxf document/modelspace :doc:`units ` (in. = ``1``, mm = ``4``). + """ + + shapes = [] + + if isinstance(w, WorkplaneLike): + for s in w.__iter__(): + shapes.append(hlr(s, pnt, dir)[0]) + else: + shapes.append(hlr(w, pnt, dir)[0]) + + exportDXF(shapes, fname, approx, tolerance, doc_units=doc_units) diff --git a/examples/Ex102_DXF_with_HLR.py b/examples/Ex102_DXF_with_HLR.py new file mode 100644 index 000000000..1fc21f5bd --- /dev/null +++ b/examples/Ex102_DXF_with_HLR.py @@ -0,0 +1,36 @@ +from cadquery import Workplane, exporters + +viewpoint = { + "top": (0, 0, 1), + "left": (1, 0, 0), + "front": (0, 1, 0), + "ortho": (1, 1, 1), +} + + +def exportDXF3rdAngleProjection(my_part: Workplane, prefix: str) -> None: + for name, direction in viewpoint.items(): + exporters.exportDXFProjection( + my_part, (0, 0, 0), direction, f"{prefix}{name}.dxf", doc_units=6, + ) + + +def exportSVG3rdAngleProjection(my_part, prefix: str) -> None: + for name, direction in viewpoint.items(): + exporters.exportSVG( + my_part, f"{prefix}{name}.svg", opts={"projectionDir": direction,}, + ) + + +# Build the part +width = 10 +depth = 10 +height = 10 +hole_dia = 3.0 + +baseplate = Workplane("XY").box(width, depth, height).edges("|Z").fillet(2.0) # +drilled = baseplate.faces(">Z").workplane().cskHole(hole_dia, hole_dia * 2, 82.0) # + +# Expected DXF output to be identical to SVG output +exportSVG3rdAngleProjection(drilled, "") +exportDXF3rdAngleProjection(drilled, "") diff --git a/examples/Ex102_HLR.py b/examples/Ex102_HLR.py deleted file mode 100644 index bc563d191..000000000 --- a/examples/Ex102_HLR.py +++ /dev/null @@ -1,45 +0,0 @@ -import cadquery as cq -from cadquery.occ_impl.exporters.svg import exportSVG -from cadquery.occ_impl.shapes import Compound, hlr -from cadquery import Workplane - -viewpoint = { - "top": (0, 0, 1), - "left": (1, 0, 0), - "front": (0, 1, 0), - "ortho": (1, 1, 1), -} - - -def exportDXF3rdAngleProjection(my_part: Workplane, prefix: str) -> None: - for name, direction in viewpoint.items(): - visible_edges, hidden_edges = hlr(my_part.val(), direction) - cq.exporters.exportDXF( - Compound.makeCompound(visible_edges), f"{prefix}{name}.dxf", doc_units=6, - ) - - -def exportSVG3rdAngleProjection(my_part, prefix: str) -> None: - for name, direction in viewpoint.items(): - exportSVG( - my_part, f"{prefix}{name}.svg", opts={"projectionDir": direction,}, - ) - - -if __name__ == "__main__": - # Build the part - width = 10 - depth = 10 - height = 10 - - # !!! Test projection of fillets to arc segments in DXF. !!! - baseplate = cq.Workplane("XY").box(width, depth, height).edges("|Z").fillet(2.0) # - - hole_dia = 3.0 - - # !!! Test projection of countersunk to arc segments in DXF. !!! - drilled = baseplate.faces(">Z").workplane().cskHole(hole_dia, hole_dia * 2, 82.0) # - - # Expected DXF output to be identical to SVG output - exportSVG3rdAngleProjection(drilled, "") - exportDXF3rdAngleProjection(drilled, "") From 6e798eb5329517e233b16092c0a4e460d0b59ae4 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:55:50 +0200 Subject: [PATCH 10/14] Export Shape too --- examples/Ex102_DXF_with_HLR.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/Ex102_DXF_with_HLR.py b/examples/Ex102_DXF_with_HLR.py index 1fc21f5bd..ee893f213 100644 --- a/examples/Ex102_DXF_with_HLR.py +++ b/examples/Ex102_DXF_with_HLR.py @@ -34,3 +34,4 @@ def exportSVG3rdAngleProjection(my_part, prefix: str) -> None: # Expected DXF output to be identical to SVG output exportSVG3rdAngleProjection(drilled, "") exportDXF3rdAngleProjection(drilled, "") +exportDXF3rdAngleProjection(drilled.val(), "shape_") From 1263b15b9c412a5f4d43f1d8ea4afd3641d3940c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:21:59 +0200 Subject: [PATCH 11/14] Make hlr public --- cadquery/func.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cadquery/func.py b/cadquery/func.py index 949bc809b..b2c86bfe0 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -59,6 +59,7 @@ chamfer2D, draft, History, + hlr, ) __all__ = [ @@ -124,4 +125,5 @@ "fillet2D", "draft", "History", + "hlr", ] From 211483be198ae2c47e7acd845b1ee82d4e5232b7 Mon Sep 17 00:00:00 2001 From: Lorenz Neureuter Date: Sat, 11 Jul 2026 18:53:04 -0400 Subject: [PATCH 12/14] Add up direction option to hidden-line projections --- cadquery/occ_impl/exporters/dxf.py | 28 ++++++----- cadquery/occ_impl/exporters/svg.py | 14 ++++-- cadquery/occ_impl/shapes.py | 79 +++++++++++++++++++++++++++--- examples/Ex102_DXF_with_HLR.py | 40 ++++++++------- 4 files changed, 121 insertions(+), 40 deletions(-) diff --git a/cadquery/occ_impl/exporters/dxf.py b/cadquery/occ_impl/exporters/dxf.py index 0cc69f78b..1106fef01 100644 --- a/cadquery/occ_impl/exporters/dxf.py +++ b/cadquery/occ_impl/exporters/dxf.py @@ -1,5 +1,6 @@ """DXF export utilities.""" +from os import PathLike from typing import ( Any, Dict, @@ -398,22 +399,25 @@ def exportDXF( def exportDXFProjection( - w: Union[WorkplaneLike, Shape], - pnt: VectorLike, + s: Union[WorkplaneLike, Shape], + path: PathLike | str, dir: VectorLike, - fname: str, + pnt: VectorLike = (0, 0, 0), approx: Optional[ApproxOptions] = None, tolerance: float = 1e-3, *, + up: Optional[VectorLike] = None, doc_units: int = units.MM, ) -> None: """ - Export Workplane or shape content to DXF using projections. Works with 3D objects. + Export to DXF using projections. Works with 3D objects. - :param w: Workplane to be exported. - :param pnt: Center of projection. + :param s: Shape or Workplane to be exported. + :param path: Output file path. :param dir: Direction of projection. - :param fname: Output filename. + :param pnt: Origin of the projection plane. + :param up: Direction that should appear upward in the projected output. None + preserves OCCT's default in-plane orientation. :param approx: Approximation strategy. None means no approximation is applied. "spline" results in all splines being approximated as cubic splines. "arc" results in all curves being approximated as arcs and straight segments. @@ -423,10 +427,10 @@ def exportDXFProjection( shapes = [] - if isinstance(w, WorkplaneLike): - for s in w.__iter__(): - shapes.append(hlr(s, pnt, dir)[0]) + if isinstance(s, WorkplaneLike): + for s in s.__iter__(): + shapes.append(hlr(s, dir, pnt, up=up).visible) else: - shapes.append(hlr(w, pnt, dir)[0]) + shapes.append(hlr(s, dir, pnt, up=up).visible) - exportDXF(shapes, fname, approx, tolerance, doc_units=doc_units) + exportDXF(shapes, str(path), approx, tolerance, doc_units=doc_units) diff --git a/cadquery/occ_impl/exporters/svg.py b/cadquery/occ_impl/exporters/svg.py index b742a6b87..a1905c1d7 100644 --- a/cadquery/occ_impl/exporters/svg.py +++ b/cadquery/occ_impl/exporters/svg.py @@ -1,6 +1,6 @@ import io as StringIO -from ..shapes import Shape, Compound, Edge +from ..shapes import Compound, Edge from ..geom import BoundBox from ..shapes import hlr @@ -135,8 +135,10 @@ def getSVG(shape, opts=None): marginLeft: Inset margin from the left side of the document. marginTop: Inset margin from the top side of the document. projectionDir: Direction the camera will view the shape from. + up: Direction that should appear upward in the projected output. None + preserves OCCT's default in-plane orientation. showAxes: Whether or not to show the axes indicator, which will only be - visible when the projectionDir is also at the default. + visible when projectionDir and up are also at the defaults. strokeWidth: Width of the line that visible edges are drawn with. strokeColor: Color of the line that visible edges are drawn with. hiddenColor: Color of the line that hidden edges are drawn with. @@ -152,6 +154,7 @@ def getSVG(shape, opts=None): "marginLeft": 200, "marginTop": 20, "projectionDir": (-1.75, 1.1, 5), + "up": None, "showAxes": True, "strokeWidth": -1.0, # -1 = calculated based on unitScale "strokeColor": (0, 0, 0), # RGB 0-255 @@ -176,6 +179,7 @@ def getSVG(shape, opts=None): marginLeft = float(d["marginLeft"]) marginTop = float(d["marginTop"]) projectionDir = tuple(d["projectionDir"]) + up = d["up"] showAxes = bool(d["showAxes"]) strokeWidth = float(d["strokeWidth"]) strokeColor = tuple(d["strokeColor"]) @@ -183,7 +187,9 @@ def getSVG(shape, opts=None): showHidden = bool(d["showHidden"]) focus = float(d["focus"]) if d.get("focus") else None - visibleEdges, hiddenEdges = hlr(shape, (0, 0, 0), projectionDir, focus) + hlr_result = hlr(shape, projectionDir, up=up, focus=focus) + visibleEdges = hlr_result.visible + hiddenEdges = hlr_result.hidden hiddenPaths, visiblePaths = getPaths(visibleEdges, hiddenEdges) # get bounding box -- these are all in 2D space @@ -229,7 +235,7 @@ def getSVG(shape, opts=None): visibleContent += PATHTEMPLATE % p # If the caller wants the axes indicator and is using the default direction, add in the indicator - if showAxes and projectionDir == (-1.75, 1.1, 5): + if showAxes and projectionDir == (-1.75, 1.1, 5) and up is None: axesIndicator = AXES_TEMPLATE % ( {"unitScale": str(unitScale), "textboxY": str(height - 30), "uom": str(uom)} ) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 0e3a396bf..18bfa65cd 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Generator, Iterable, Iterator, Sequence +from dataclasses import dataclass from typing import ( Any, Literal, @@ -7976,17 +7977,83 @@ def closest(s1: Shape, s2: Shape) -> tuple[Vector, Vector]: return Vector(ext.PointOnShape1(1)), Vector(ext.PointOnShape2(1)) +@dataclass(frozen=True) +class HLRResult: + """ + Result of a hidden-line-removal projection. + + The returned plane describes the 2D projection coordinate system: + + * ``plane.xDir`` is screen +X/right in world coordinates. + * ``plane.yDir`` is screen +Y/up in world coordinates. + * ``plane.zDir`` is the projection direction/view-plane normal. + """ + + visible: Compound + hidden: Compound + plane: Plane + + def hlr( - shape, pnt: VectorLike, dir: VectorLike, focus: float | None = None, -) -> tuple[Compound, Compound]: + s: Shape, + dir: VectorLike, + pnt: VectorLike = (0, 0, 0), + up: VectorLike | None = None, + focus: float | None = None, +) -> HLRResult: """ - Project shape onto a plane defined by pnt and dir and apply hidden line removal. Useful for + Project a shape onto a plane and perform hidden-line removal. Useful for generating views for technical drawings. + + ``dir`` defines the projection plane's normal, and ``pnt`` defines its origin. + Orthographic projection is used by default; supplying ``focus`` enables + perspective projection. + + :param s: Shape to project. + :param dir: Projection direction and normal of the projection plane. + :param pnt: Origin of the projection plane. + :param up: Preferred positive Y direction of the projected view. If omitted, + OCCT chooses the in-plane orientation. + :param focus: Focal distance for perspective projection. If omitted, + orthographic projection is used. + :return: Visible and hidden projected edges together with the projection + plane. + :raises ValueError: If ``dir`` or ``up`` is zero, or if ``up`` is parallel + to ``dir``. """ hlr = HLRBRep_Algo() - hlr.Add(shape.wrapped) + hlr.Add(s.wrapped) - coordinate_system = gp_Ax2(Vector(pnt).toPnt(), Vector(dir).toDir()) + origin = Vector(pnt) + normal = Vector(dir) + + if normal.Length == 0.0: + raise ValueError("dir must be nonzero") + + normal = normal.normalized() + + if up is None: + coordinate_system = gp_Ax2(origin.toPnt(), normal.toDir()) + plane = Plane( + origin, xDir=Vector(coordinate_system.XDirection()), normal=normal + ) + else: + up_dir = Vector(up) + + if up_dir.Length == 0.0: + raise ValueError("up must be nonzero") + + up_dir = up_dir.normalized() + x_dir = up_dir.cross(normal) + + if x_dir.Length < 1e-6: + raise ValueError("up must not be parallel to dir") + + x_dir = x_dir.normalized() + plane = Plane(origin, xDir=x_dir, normal=normal) + coordinate_system = gp_Ax2( + plane.origin.toPnt(), plane.zDir.toDir(), plane.xDir.toDir() + ) if focus is not None: projector = HLRAlgo_Projector(coordinate_system, focus) @@ -8033,4 +8100,4 @@ def hlr( visible_edges = compound(_compound_or_shape(visible).Edges()) hidden_edges = compound(_compound_or_shape(hidden).Edges()) - return visible_edges, hidden_edges + return HLRResult(visible_edges, hidden_edges, plane) diff --git a/examples/Ex102_DXF_with_HLR.py b/examples/Ex102_DXF_with_HLR.py index ee893f213..c0f04e1fd 100644 --- a/examples/Ex102_DXF_with_HLR.py +++ b/examples/Ex102_DXF_with_HLR.py @@ -1,24 +1,29 @@ from cadquery import Workplane, exporters - -viewpoint = { - "top": (0, 0, 1), - "left": (1, 0, 0), - "front": (0, 1, 0), - "ortho": (1, 1, 1), +from ezdxf import units + +# Map each view name to its projection direction and the world-space direction +# that should appear upward in the projected view. +viewpoints = { + "top": ((0, 0, 1), (0, 1, 0)), + "left": ((1, 0, 0), (0, 0, 1)), + "front": ((0, 1, 0), (0, 0, 1)), + "ortho": ((1, 1, 1), (0, 0, 1)), } -def exportDXF3rdAngleProjection(my_part: Workplane, prefix: str) -> None: - for name, direction in viewpoint.items(): +def exportDXF3rdAngleProjection(my_part) -> None: + for name, (direction, up) in viewpoints.items(): exporters.exportDXFProjection( - my_part, (0, 0, 0), direction, f"{prefix}{name}.dxf", doc_units=6, + my_part, f"{name}.dxf", direction, up=up, doc_units=units.CM, ) -def exportSVG3rdAngleProjection(my_part, prefix: str) -> None: - for name, direction in viewpoint.items(): +def exportSVG3rdAngleProjection(my_part) -> None: + for name, (direction, up) in viewpoints.items(): exporters.exportSVG( - my_part, f"{prefix}{name}.svg", opts={"projectionDir": direction,}, + my_part, + f"{name}.svg", + opts={"projectionDir": direction, "up": up, "showHidden": False}, ) @@ -28,10 +33,9 @@ def exportSVG3rdAngleProjection(my_part, prefix: str) -> None: height = 10 hole_dia = 3.0 -baseplate = Workplane("XY").box(width, depth, height).edges("|Z").fillet(2.0) # -drilled = baseplate.faces(">Z").workplane().cskHole(hole_dia, hole_dia * 2, 82.0) # +baseplate = Workplane("XY").box(width, depth, height).edges("|Z").fillet(2.0) +drilled = baseplate.faces(">Z").workplane().cskHole(hole_dia, hole_dia * 2, 82.0) -# Expected DXF output to be identical to SVG output -exportSVG3rdAngleProjection(drilled, "") -exportDXF3rdAngleProjection(drilled, "") -exportDXF3rdAngleProjection(drilled.val(), "shape_") +# Export equivalent visible-edge projections to SVG and DXF. +exportSVG3rdAngleProjection(drilled) +exportDXF3rdAngleProjection(drilled) From fdce2c09d04338d2a057c535d41f763cbaebfc18 Mon Sep 17 00:00:00 2001 From: Lorenz Neureuter Date: Sat, 11 Jul 2026 22:57:24 -0400 Subject: [PATCH 13/14] Export HLRResult --- cadquery/func.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cadquery/func.py b/cadquery/func.py index b2c86bfe0..f8d2752c5 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -59,6 +59,7 @@ chamfer2D, draft, History, + HLRResult, hlr, ) @@ -125,5 +126,6 @@ "fillet2D", "draft", "History", + "HLRResult", "hlr", ] From 96009a7ccfb5b4b7b7afa227c330b1c46f3d0e72 Mon Sep 17 00:00:00 2001 From: Lorenz Neureuter Date: Sat, 11 Jul 2026 23:01:43 -0400 Subject: [PATCH 14/14] Add tests --- tests/test_exporters.py | 15 ++++++++++++++ tests/test_free_functions.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index e6bdd555c..0e5355a02 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -880,6 +880,21 @@ def _check_dxf_no_spline(fname): return True +def test_dxf_projection(tmpdir): + + path = tmpdir / "projection.dxf" + s1 = Workplane("XZ").ellipse(1, 2).extrude(1) + + exporters.exportDXFProjection( + s1, path, (1, 1, 1), up=(0, 0, 1), + ) + + dxf = ezdxf.readfile(path) + + assert path.is_file() + assert len(dxf.modelspace()) > 0 + + def test_dxf_approx(tmpdir): pts = [(0, 0), (0, 0.5), (1, 1)] diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index d8380c6c6..db3e8165c 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -53,6 +53,7 @@ fillet2D, draft, isSubshape, + hlr, ) from cadquery.occ_impl.shapes import ( @@ -1460,3 +1461,41 @@ def test_chamfer2D_history(): assert new_edges.edges().size() == 4 assert mod_edges.edges().size() == 4 + + +def test_hlr(): + + s1 = box(1, 1, 1) + res1 = hlr(s1, (1, 1, 1), up=(0, 0, 1)) + + assert res1.visible.Edges() + assert res1.hidden.Edges() + + normal = Vector(1, 1, 1).normalized() + expected_up = (Vector(0, 0, 1) - normal * normal.dot(Vector(0, 0, 1))).normalized() + + assert (res1.plane.zDir - normal).Length == approx(0) + assert (res1.plane.yDir - expected_up).Length == approx(0) + + with raises(ValueError): + hlr(s1, (0, 0, 0)) + + with raises(ValueError): + hlr(s1, (0, 0, 1), up=(0, 0, 0)) + + with raises(ValueError): + hlr(s1, (0, 0, 1), up=(0, 0, 2)) + + s2 = segment((0, 0, 0), (0, 0, 1)) + res2 = hlr(s2, (0, -1, 0), up=(-1, 0, 0)) + assert len(res2.visible.Edges()) == 1 + assert len(res2.hidden.Edges()) == 0 + + bb = res2.visible.Edges()[0].BoundingBox() + assert (bb.xmin, bb.ymin, bb.zmin) == approx((0, 0, 0)) + assert (bb.xmax, bb.ymax, bb.zmax) == approx((1, 0, 0)) + + res3 = hlr(s2, (0, -1, 0), (0, 0, -3), up=(-1, 0, 0)) + bb = res3.visible.Edges()[0].BoundingBox() + assert (bb.xmin, bb.ymin, bb.zmin) == approx((3, 0, 0)) + assert (bb.xmax, bb.ymax, bb.zmax) == approx((4, 0, 0))