diff --git a/cadquery/func.py b/cadquery/func.py index 949bc809b..f8d2752c5 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -59,6 +59,8 @@ chamfer2D, draft, History, + HLRResult, + hlr, ) __all__ = [ @@ -124,4 +126,6 @@ "fillet2D", "draft", "History", + "HLRResult", + "hlr", ] 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 41ae15224..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, @@ -22,8 +23,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"] @@ -157,6 +158,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 = {} @@ -365,7 +367,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, @@ -394,3 +396,41 @@ def exportDXF( zoom.extents(dxf.msp) dxf.document.saveas(fname) + + +def exportDXFProjection( + s: Union[WorkplaneLike, Shape], + path: PathLike | str, + dir: VectorLike, + pnt: VectorLike = (0, 0, 0), + approx: Optional[ApproxOptions] = None, + tolerance: float = 1e-3, + *, + up: Optional[VectorLike] = None, + doc_units: int = units.MM, +) -> None: + """ + Export to DXF using projections. Works with 3D objects. + + :param s: Shape or Workplane to be exported. + :param path: Output file path. + :param dir: Direction of projection. + :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. + :param tolerance: Approximation tolerance. + :param doc_units: ezdxf document/modelspace :doc:`units ` (in. = ``1``, mm = ``4``). + """ + + shapes = [] + + if isinstance(s, WorkplaneLike): + for s in s.__iter__(): + shapes.append(hlr(s, dir, pnt, up=up).visible) + else: + shapes.append(hlr(s, dir, pnt, up=up).visible) + + 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 b41dee7b0..a1905c1d7 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 Compound, Edge from ..geom import BoundBox +from ..shapes import hlr -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,13 +113,11 @@ 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) @@ -138,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. @@ -155,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 @@ -179,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"]) @@ -186,64 +187,18 @@ 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) + 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 - 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 @@ -280,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 86af4afe9..4cdd2cbc4 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, @@ -161,6 +162,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, @@ -8080,3 +8084,129 @@ def closest(s1: Shape, s2: Shape) -> tuple[Vector, Vector]: assert ext.Perform() 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( + s: Shape, + dir: VectorLike, + pnt: VectorLike = (0, 0, 0), + up: VectorLike | None = None, + focus: float | None = None, +) -> HLRResult: + """ + 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(s.wrapped) + + 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) + 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) + + # Extract edges + visible_edges = compound(_compound_or_shape(visible).Edges()) + hidden_edges = compound(_compound_or_shape(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 new file mode 100644 index 000000000..c0f04e1fd --- /dev/null +++ b/examples/Ex102_DXF_with_HLR.py @@ -0,0 +1,41 @@ +from cadquery import Workplane, exporters +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) -> None: + for name, (direction, up) in viewpoints.items(): + exporters.exportDXFProjection( + my_part, f"{name}.dxf", direction, up=up, doc_units=units.CM, + ) + + +def exportSVG3rdAngleProjection(my_part) -> None: + for name, (direction, up) in viewpoints.items(): + exporters.exportSVG( + my_part, + f"{name}.svg", + opts={"projectionDir": direction, "up": up, "showHidden": False}, + ) + + +# 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) + +# Export equivalent visible-edge projections to SVG and DXF. +exportSVG3rdAngleProjection(drilled) +exportDXF3rdAngleProjection(drilled) 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 958363254..64cc43e19 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 ( @@ -1510,3 +1511,41 @@ def _check(op, edges): assert op.modified().vertices().size() == 4 _check(op, edges) + + +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))