Skip to content

Commit 33bf83e

Browse files
jmwrightadam-urbanczyklorenzncode
authored
Added code for simplified and fused assembly export to STEP (#1261)
* Added code for simplified and fused assembly export to STEP * Changed toFused to use OCCT history API instead of the cut method * Black fix * Handle a null label due to a deleted face from the fuse * Handle generated faces * Allow a face to be null because of something other than IsDeleted * Added assembly STEP export doucmentation * Handle assemblies that have only one shape in them * Better check before getting a null label and commented out generated face addition for now * Appended CAF to new toSimplified and toFused method names for export * Removed unneeded __iter__ call * Trying to figure out mypy Literal for STEP export mode * Switched to using literals for the export mode * Switching to built-in CQ method for creating a compound * Added glue and fuzzy tolerance settings for fused assembly * Use the built-in assembly name, otherwise a UUID * Pulled latest master and fixed black errors * Started using location of assembly parent part object for compound * Handle nested assemblies * Added nested assembly export tests * mypy apparently could not handle the one-liner inside toShapeList * Attempting to fix remaining mypy errors * Added single part fused export, fuzzy_tol and glue setting tests * Removed simplified STEP export method and addressed some comments on the PR * Fixed export mode literal typing omission * Fix black error * Fixing mypy error * Added ability to handle a top level shape passed during Assembly initialization * Simplified assembly handling while still keeping top level assembly objects * Attempting to round out tests * Update cadquery/assembly.py Co-authored-by: AU <[email protected]> * Update cadquery/occ_impl/assembly.py Co-authored-by: AU <[email protected]> * Simplification for latest OCP Co-authored-by: AU <[email protected]> * Fixed tests for typing change * Use f string in exception Co-authored-by: AU <[email protected]> * Capitalization and f string suggestions * Fix black check * Simplified code by using traverse to walk the assembly structure * Removed toShapeList * Trying to avoid an error that only seems to be happening in CI * Needed to put the check if a face was deleted on the fuse op * Investigate the failures on appveyor * Reworked and simplified adding of modified and generated faces * Added null checks for labels after trying to add a subshape * Fixed color handling for some faces during fuse * Comment out on_finish * Apply locations to shapes * Fix failing tests * Better testcase * Copy explicitly * Fix single shape export * Mypy fix * Add (initial) test of fused STEP export mode colors * toFusedCAF - handle single compound special case, use existing assembly name, add more tests * Removed performance statement in assembly exporter docstring * Updated docstring for glue As suggested by @lorenzncode * Doc tweaks * More assembly export mode doc tweaks * Correct the export mode keyword arg name in example * Include example of exporters.export opt dict * Changed the name exportMode to mode --------- Co-authored-by: AU <[email protected]> Co-authored-by: Lorenz Neureuter <[email protected]>
1 parent 3b9ead1 commit 33bf83e

File tree

8 files changed

+744
-28
lines changed

8 files changed

+744
-28
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ nested.stl
3232
out1.3mf
3333
out2.3mf
3434
out3.3mf
35+
orig.dxf

appveyor.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,8 @@ test_script:
3838

3939
on_success:
4040
- codecov
41+
42+
#on_finish:
43+
# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1'))
44+
# - sh: export APPVEYOR_SSH_BLOCK=true
45+
# - sh: curl -sflL 'https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-ssh.sh' | bash -e -

cadquery/assembly.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
from functools import reduce
2-
from typing import Union, Optional, List, Dict, Any, overload, Tuple, Iterator, cast
2+
from typing import (
3+
Union,
4+
Optional,
5+
List,
6+
Dict,
7+
Any,
8+
overload,
9+
Tuple,
10+
Iterator,
11+
cast,
12+
get_args,
13+
)
314
from typing_extensions import Literal
415
from typish import instance_of
516
from uuid import uuid1 as uuid
@@ -21,6 +32,8 @@
2132
exportVTKJS,
2233
exportVRML,
2334
exportGLTF,
35+
STEPExportModeLiterals,
36+
ExportModes,
2437
)
2538

2639
from .selectors import _expression_grammar as _selector_grammar
@@ -436,6 +449,7 @@ def save(
436449
self,
437450
path: str,
438451
exportType: Optional[ExportLiterals] = None,
452+
mode: STEPExportModeLiterals = "default",
439453
tolerance: float = 0.1,
440454
angularTolerance: float = 0.1,
441455
**kwargs,
@@ -451,6 +465,10 @@ def save(
451465
See :meth:`~cadquery.occ_impl.exporters.assembly.exportAssembly`.
452466
"""
453467

468+
# Make sure the export mode setting is correct
469+
if mode not in get_args(STEPExportModeLiterals):
470+
raise ValueError(f"Unknown assembly export mode {mode} for STEP")
471+
454472
if exportType is None:
455473
t = path.split(".")[-1].upper()
456474
if t in ("STEP", "XML", "VRML", "VTKJS", "GLTF", "STL"):
@@ -459,7 +477,7 @@ def save(
459477
raise ValueError("Unknown extension, specify export type explicitly")
460478

461479
if exportType == "STEP":
462-
exportAssembly(self, path, **kwargs)
480+
exportAssembly(self, path, mode, **kwargs)
463481
elif exportType == "XML":
464482
exportCAF(self, path)
465483
elif exportType == "VRML":

cadquery/occ_impl/assembly.py

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
from typing import Union, Iterable, Tuple, Dict, overload, Optional, Any, List
1+
from typing import Union, Iterable, Tuple, Dict, overload, Optional, Any, List, cast
22
from typing_extensions import Protocol
33
from math import degrees
44

55
from OCP.TDocStd import TDocStd_Document
66
from OCP.TCollection import TCollection_ExtendedString
7-
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType
7+
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType, XCAFDoc_ColorGen
88
from OCP.XCAFApp import XCAFApp_Application
99
from OCP.TDataStd import TDataStd_Name
1010
from OCP.TDF import TDF_Label
1111
from OCP.TopLoc import TopLoc_Location
1212
from OCP.Quantity import Quantity_ColorRGBA
13+
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
14+
from OCP.TopTools import TopTools_ListOfShape
15+
from OCP.BOPAlgo import BOPAlgo_GlueEnum
16+
from OCP.TopoDS import TopoDS_Shape
1317

1418
from vtkmodules.vtkRenderingCore import (
1519
vtkActor,
@@ -112,6 +116,10 @@ def parent(self) -> Optional["AssemblyProtocol"]:
112116
def color(self) -> Optional[Color]:
113117
...
114118

119+
@property
120+
def obj(self) -> AssemblyObjects:
121+
...
122+
115123
@property
116124
def shapes(self) -> Iterable[Shape]:
117125
...
@@ -289,3 +297,117 @@ def toJSON(
289297
rv.extend(toJSON(child, loc, color, tolerance))
290298

291299
return rv
300+
301+
302+
def toFusedCAF(
303+
assy: AssemblyProtocol, glue: bool = False, tol: Optional[float] = None,
304+
) -> Tuple[TDF_Label, TDocStd_Document]:
305+
"""
306+
Converts the assembly to a fused compound and saves that within the document
307+
to be exported in a way that preserves the face colors. Because of the use of
308+
boolean operations in this method, performance may be slow in some cases.
309+
310+
:param assy: Assembly that is being converted to a fused compound for the document.
311+
"""
312+
313+
# Prepare the document
314+
app = XCAFApp_Application.GetApplication_s()
315+
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
316+
app.InitDocument(doc)
317+
318+
# Shape and color tools
319+
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
320+
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
321+
322+
# To fuse the parts of the assembly together
323+
fuse_op = BRepAlgoAPI_Fuse()
324+
args = TopTools_ListOfShape()
325+
tools = TopTools_ListOfShape()
326+
327+
# If there is only one solid, there is no reason to fuse, and it will likely cause problems anyway
328+
top_level_shape = None
329+
330+
# Walk the entire assembly, collecting the located shapes and colors
331+
shapes: List[Shape] = []
332+
colors = []
333+
334+
def extract_shapes(assy, parent_loc=None, parent_color=None):
335+
336+
loc = parent_loc * assy.loc if parent_loc else assy.loc
337+
color = assy.color if assy.color else parent_color
338+
339+
for shape in assy.shapes:
340+
shapes.append(shape.moved(loc).copy())
341+
colors.append(color)
342+
343+
for ch in assy.children:
344+
extract_shapes(ch, loc, color)
345+
346+
extract_shapes(assy)
347+
348+
# Initialize with a dummy value for mypy
349+
top_level_shape = cast(TopoDS_Shape, None)
350+
351+
# If the tools are empty, it means we only had a single shape and do not need to fuse
352+
if not shapes:
353+
raise Exception(f"Error: Assembly {assy.name} has no shapes.")
354+
elif len(shapes) == 1:
355+
# There is only one shape and we only need to make sure it is a Compound
356+
# This seems to be needed to be able to add subshapes (i.e. faces) correctly
357+
sh = shapes[0]
358+
if sh.ShapeType() != "Compound":
359+
top_level_shape = Compound.makeCompound((sh,)).wrapped
360+
elif sh.ShapeType() == "Compound":
361+
sh = sh.fuse(glue=glue, tol=tol)
362+
top_level_shape = Compound.makeCompound((sh,)).wrapped
363+
shapes = [sh]
364+
else:
365+
# Set the shape lists up so that the fuse operation can be performed
366+
args.Append(shapes[0].wrapped)
367+
368+
for shape in shapes[1:]:
369+
tools.Append(shape.wrapped)
370+
371+
# Allow the caller to configure the fuzzy and glue settings
372+
if tol:
373+
fuse_op.SetFuzzyValue(tol)
374+
if glue:
375+
fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift)
376+
377+
fuse_op.SetArguments(args)
378+
fuse_op.SetTools(tools)
379+
fuse_op.Build()
380+
381+
top_level_shape = fuse_op.Shape()
382+
383+
# Add the fused shape as the top level object in the document
384+
top_level_lbl = shape_tool.AddShape(top_level_shape, False)
385+
TDataStd_Name.Set_s(top_level_lbl, TCollection_ExtendedString(assy.name))
386+
387+
# Walk the assembly->part->shape->face hierarchy and add subshapes for all the faces
388+
for color, shape in zip(colors, shapes):
389+
for face in shape.Faces():
390+
# See if the face can be treated as-is
391+
cur_lbl = shape_tool.AddSubShape(top_level_lbl, face.wrapped)
392+
if color and not cur_lbl.IsNull() and not fuse_op.IsDeleted(face.wrapped):
393+
color_tool.SetColor(cur_lbl, color.wrapped, XCAFDoc_ColorGen)
394+
395+
# Handle any modified faces
396+
modded_list = fuse_op.Modified(face.wrapped)
397+
398+
for mod in modded_list:
399+
# Add the face as a subshape and set its color to match the parent assembly component
400+
cur_lbl = shape_tool.AddSubShape(top_level_lbl, mod)
401+
if color and not cur_lbl.IsNull() and not fuse_op.IsDeleted(mod):
402+
color_tool.SetColor(cur_lbl, color.wrapped, XCAFDoc_ColorGen)
403+
404+
# Handle any generated faces
405+
gen_list = fuse_op.Generated(face.wrapped)
406+
407+
for gen in gen_list:
408+
# Add the face as a subshape and set its color to match the parent assembly component
409+
cur_lbl = shape_tool.AddSubShape(top_level_lbl, gen)
410+
if color and not cur_lbl.IsNull():
411+
color_tool.SetColor(cur_lbl, color.wrapped, XCAFDoc_ColorGen)
412+
413+
return top_level_lbl, doc

cadquery/occ_impl/exporters/assembly.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os.path
2+
import uuid
23

34
from tempfile import TemporaryDirectory
45
from shutil import make_archive
56
from itertools import chain
7+
from typing_extensions import Literal
68

79
from vtkmodules.vtkIOExport import vtkJSONSceneExporter, vtkVRMLExporter
810
from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow
@@ -23,20 +25,41 @@
2325
from OCP.Message import Message_ProgressRange
2426
from OCP.Interface import Interface_Static
2527

26-
from ..assembly import AssemblyProtocol, toCAF, toVTK
28+
from ..assembly import AssemblyProtocol, toCAF, toVTK, toFusedCAF
2729
from ..geom import Location
2830

2931

30-
def exportAssembly(assy: AssemblyProtocol, path: str, **kwargs) -> bool:
32+
class ExportModes:
33+
DEFAULT = "default"
34+
FUSED = "fused"
35+
36+
37+
STEPExportModeLiterals = Literal["default", "fused"]
38+
39+
40+
def exportAssembly(
41+
assy: AssemblyProtocol,
42+
path: str,
43+
mode: STEPExportModeLiterals = "default",
44+
**kwargs
45+
) -> bool:
3146
"""
3247
Export an assembly to a STEP file.
3348
3449
kwargs is used to provide optional keyword arguments to configure the exporter.
3550
3651
:param assy: assembly
3752
:param path: Path and filename for writing
53+
:param mode: STEP export mode. The options are "default", and "fused" (a single fused compound).
54+
It is possible that fused mode may exhibit low performance.
55+
:param fuzzy_tol: OCCT fuse operation tolerance setting used only for fused assembly export.
56+
:type fuzzy_tol: float
57+
:param glue: Enable gluing mode for improved performance during fused assembly export.
58+
This option should only be used for non-intersecting shapes or those that are only touching or partially overlapping.
59+
Note that when glue is enabled, the resulting fused shape may be invalid if shapes are intersecting in an incompatible way.
60+
Defaults to False.
61+
:type glue: bool
3862
:param write_pcurves: Enable or disable writing parametric curves to the STEP file. Default True.
39-
4063
If False, writes STEP file without pcurves. This decreases the size of the resulting STEP file.
4164
:type write_pcurves: bool
4265
:param precision_mode: Controls the uncertainty value for STEP entities. Specify -1, 0, or 1. Default 0.
@@ -49,8 +72,17 @@ def exportAssembly(assy: AssemblyProtocol, path: str, **kwargs) -> bool:
4972
if "write_pcurves" in kwargs and not kwargs["write_pcurves"]:
5073
pcurves = 0
5174
precision_mode = kwargs["precision_mode"] if "precision_mode" in kwargs else 0
75+
fuzzy_tol = kwargs["fuzzy_tol"] if "fuzzy_tol" in kwargs else None
76+
glue = kwargs["glue"] if "glue" in kwargs else False
77+
78+
# Use the assembly name if the user set it
79+
assembly_name = assy.name if assy.name else str(uuid.uuid1())
5280

53-
_, doc = toCAF(assy, True)
81+
# Handle the doc differently based on which mode we are using
82+
if mode == "fused":
83+
_, doc = toFusedCAF(assy, glue, fuzzy_tol)
84+
else: # Includes "default"
85+
_, doc = toCAF(assy, True)
5486

5587
session = XSControl_WorkSession()
5688
writer = STEPCAFControl_Writer(session, False)

0 commit comments

Comments
 (0)