Skip to content

Add STEP Import for Assemblies #1779

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: master
Choose a base branch
from
Open

Add STEP Import for Assemblies #1779

wants to merge 41 commits into from

Conversation

jmwright
Copy link
Member

@jmwright jmwright commented Feb 26, 2025

The goal is to make it possible to round-trip assemblies to and from STEP without loss of data. This data can include:

  • Part location
  • Part color
  • Shape colors
  • Shape colors
  • Shape names (advanced face)

Copy link

codecov bot commented Feb 26, 2025

Codecov Report

Attention: Patch coverage is 96.39640% with 4 lines in your changes missing coverage. Please review.

Project coverage is 95.67%. Comparing base (f07e7e0) to head (c75d0c8).

Files with missing lines Patch % Lines
cadquery/occ_impl/importers/assembly.py 95.95% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #1779    +/-   ##
========================================
  Coverage   95.66%   95.67%            
========================================
  Files          28       29     +1     
  Lines        7431     7538   +107     
  Branches     1122     1140    +18     
========================================
+ Hits         7109     7212   +103     
- Misses        193      194     +1     
- Partials      129      132     +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jmwright
Copy link
Member Author

@adam-urbanczyk In 4045c58 you set the cadquery.Assembly.importStep method up to be a class method, and you construct assy before calling the lower-level method. However, since the Assembly.name property is private and can only be set during instantiation, this creates an issue for me. I need to be able to set the top-level assembly name based on the name set in the STEP file, which requires me to set the name property after instantiation.

We need to decide on the proper way to fix this.

@adam-urbanczyk
Copy link
Member

Hey, I do not understand the issue. You can always do this:

assy = Assembly()
assy.name = '123'

Do you mean that you need to modify AssemblyProtocol to satisify mypy? That also does not sound like an issue to me.

@jmwright
Copy link
Member Author

Correct, mypy complains. I can make that change to make name a public property if you don't see an issue with doing so.

@jmwright
Copy link
Member Author

jmwright commented Jun 10, 2025

@adam-urbanczyk The gold color of the v-slot parts above is because there is no color defined for those components, and cadquery.vis.show is defaulting to a different color than CQ-editor and the 3D viewer in the documentation. So I think it's a non-issue unless we want to standardize on a default color across the visualization platforms.

@lorenzncode @adam-urbanczyk Can you provide the steps to reproduce the location issue with the constrainted door assembly? I cannot reproduce, so we are following different processes somehow. The code below has all the components in the correct location when I show it using cadquery.vis.show.

import cadquery as cq
from cadquery.occ_impl.exporters.assembly import exportStepMeta
from cadquery.vis import show

# Parameters
H = 400
W = 200
D = 350

PROFILE = cq.importers.importDXF("vslot-2020_1.dxf").wires()

SLOT_D = 6
PANEL_T = 3

HANDLE_D = 20
HANDLE_L = 50
HANDLE_W = 4


def make_vslot(l):
    return PROFILE.toPending().extrude(l)


def make_connector():
    rv = (
        cq.Workplane()
        .box(20, 20, 20)
        .faces("<X")
        .workplane()
        .cboreHole(6, 15, 18)
        .faces("<Z")
        .workplane(centerOption="CenterOfMass")
        .cboreHole(6, 15, 18)
    )

    # tag mating faces
    rv.faces(">X").tag("X").end()
    rv.faces(">Z").tag("Z").end()

    return rv


def make_panel(w, h, t, cutout):
    rv = (
        cq.Workplane("XZ")
        .rect(w, h)
        .extrude(t)
        .faces(">Y")
        .vertices()
        .rect(2 * cutout, 2 * cutout)
        .cutThruAll()
        .faces("<Y")
        .workplane()
        .pushPoints([(-w / 3, HANDLE_L / 2), (-w / 3, -HANDLE_L / 2)])
        .hole(3)
    )

    # tag mating edges
    rv.faces(">Y").edges("%CIRCLE").edges(">Z").tag("hole1")
    rv.faces(">Y").edges("%CIRCLE").edges("<Z").tag("hole2")

    return rv


def make_handle(w, h, r):
    pts = ((0, 0), (w, 0), (w, h), (0, h))

    path = cq.Workplane().polyline(pts)

    rv = (
        cq.Workplane("YZ")
        .rect(r, r)
        .sweep(path, transition="round")
        .tag("solid")
        .faces("<X")
        .workplane()
        .faces("<X", tag="solid")
        .hole(r / 1.5)
    )

    # tag mating faces
    rv.faces("<X").faces(">Y").tag("mate1")
    rv.faces("<X").faces("<Y").tag("mate2")

    return rv


# define the elements
door = (
    cq.Assembly()
    .add(make_vslot(H), name="left")
    .add(make_vslot(H), name="right")
    .add(make_vslot(W), name="top")
    .add(make_vslot(W), name="bottom")
    .add(make_connector(), name="con_tl", color=cq.Color("black"))
    .add(make_connector(), name="con_tr", color=cq.Color("black"))
    .add(make_connector(), name="con_bl", color=cq.Color("black"))
    .add(make_connector(), name="con_br", color=cq.Color("black"))
    .add(
        make_panel(W + 2 * SLOT_D, H + 2 * SLOT_D, PANEL_T, SLOT_D),
        name="panel",
        color=cq.Color(0, 0, 1, 0.2),
    )
    .add(
        make_handle(HANDLE_D, HANDLE_L, HANDLE_W),
        name="handle",
        color=cq.Color("yellow"),
    )
)

# define the constraints
(
    door
    # left profile
    .constrain("left@faces@<Z", "con_bl?Z", "Plane")
    .constrain("left@faces@<X", "con_bl?X", "Axis")
    .constrain("left@faces@>Z", "con_tl?Z", "Plane")
    .constrain("left@faces@<X", "con_tl?X", "Axis")
    # top
    .constrain("top@faces@<Z", "con_tl?X", "Plane")
    .constrain("top@faces@<Y", "con_tl@faces@>Y", "Axis")
    # bottom
    .constrain("bottom@faces@<Y", "con_bl@faces@>Y", "Axis")
    .constrain("bottom@faces@>Z", "con_bl?X", "Plane")
    # right connectors
    .constrain("top@faces@>Z", "con_tr@faces@>X", "Plane")
    .constrain("bottom@faces@<Z", "con_br@faces@>X", "Plane")
    .constrain("left@faces@>Z", "con_tr?Z", "Axis")
    .constrain("left@faces@<Z", "con_br?Z", "Axis")
    # right profile
    .constrain("right@faces@>Z", "con_tr@faces@>Z", "Plane")
    .constrain("right@faces@<X", "left@faces@<X", "Axis")
    # panel
    .constrain("left@faces@>X[-4]", "panel@faces@<X", "Plane")
    .constrain("left@faces@>Z", "panel@faces@>Z", "Axis")
    # handle
    .constrain("panel?hole1", "handle?mate1", "Plane")
    .constrain("panel?hole2", "handle?mate2", "Point")
)

# solve
door.solve()

success = exportStepMeta(door, "door.step")

if success:
    imported_assy = cq.Assembly.importStep("door.step")
    show(imported_assy)

@lorenzncode
Copy link
Member

Can you provide the steps to reproduce the location issue

Try export instead of exportStepMeta.

door.export("door.step")

STEP is exported in the assembly tests. I'm getting a ValueError on importStep with one of them.

run the pytest, export step (still uses old save method)

pytest tests/test_assembly.py --basetemp=./tmp -k test_colors_assy1

This fails with ValueError:

from cadquery import Assembly

obj = Assembly.importStep("./tmp/assembly10/chassis0_assy.step")

@adam-urbanczyk
Copy link
Member

adam-urbanczyk commented Jun 11, 2025

Here is mine:

before:
image
after:
image

I also found another issues, without copies the import throws. See last section of the snippet.

#%% imports
from cadquery import Assembly, Location, Color
from cadquery.func import box, rect
from cadquery.vis import show

#%% prepare the model
def make_model(name: str, COPY: bool):
    b = box(1,1,1)
    
    assy = Assembly(name='test_assy')
    assy.add(box(1,2,5), color=Color('green'))
    
    
    
    for v in rect(10,10).vertices():
        assy.add(
            b.copy() if COPY else b,
            loc=Location(v.Center()),
            color=Color('red')
        )
        
    # show(assy)
    
    assy.export(name)
    
    return assy
    
assy_copy = make_model("test_assy_copy.step", True)
assy = make_model("test_assy.step", False)

show(assy)

#%% import the assy with copies

assy_i = Assembly.importStep("test_assy_copy.step")
show(assy_i)

#%% import the assy without copies - this throws

assy_i = Assembly.importStep("test_assy.step")
show(assy_i)

@jmwright
Copy link
Member Author

@adam-urbanczyk @lorenzncode The parent location was not being applied correctly when the original assembly.export method is used. I've fixed that now and pulled in the srgb changes from master, so I think the location and color issues should be fixed.

@adam-urbanczyk
Copy link
Member

Do you plan to fix the second issue I found?

@jmwright
Copy link
Member Author

@adam-urbanczyk Yes, I'll see what I can figure out.

@jmwright
Copy link
Member Author

jmwright commented Jun 13, 2025

@adam-urbanczyk The Assembly.export method is actually duplicating the names throughout the STEP file, so it does not look like it is a problem with this importer. I find 14 different entries with that UUID in the STEP file. I can add a check to protect against duplicate names being read from the STEP file which alters the duplicate names, but I am not sure we should mask the root cause (duplicated names within the STEP file). It seems like the existing behavior of lettingAssembly.add throw the error could be the right thing to do here.

Screenshot from 2025-06-13 12-08-28

In the screenshot: Two variations of the name, one with the _part postfix and one without. Those are the only two variations of the name throughout the STEP file.

@adam-urbanczyk
Copy link
Member

This tool (eDrawings) can import the problematic STEP without copies and the structure LGTM. I think that CQ should be able to handle this as well (and produce identical structure).

image

@jmwright
Copy link
Member Author

How do we do that without breaking things like constraints that use part names? We would have to remove the Assembly.add restriction on duplicate names, correct?

@adam-urbanczyk
Copy link
Member

But subassy names are unique and parts don't have names in cq AFAIR.

@adam-urbanczyk
Copy link
Member

Additional observation:

In [3]: assy_i.objects
Out[3]:
{'a9a08701-4ac0-11f0-a634-c86e08a4b6ef': <cadquery.assembly.Assembly at 0x1b1cf1dcbc0>,
 'a7644761-4ac0-11f0-bcb0-c86e08a4b6ef_part': <cadquery.assembly.Assembly at 0x1b1d123ab10>,
 'a76594bb-4ac0-11f0-9013-c86e08a4b6ef_part': <cadquery.assembly.Assembly at 0x1b1d1238440>,
 'a7668644-4ac0-11f0-91c5-c86e08a4b6ef_part': <cadquery.assembly.Assembly at 0x1b1d123ac30>,
 'a7668645-4ac0-11f0-9a6c-c86e08a4b6ef_part': <cadquery.assembly.Assembly at 0x1b1d123ad20>,
 'a7668646-4ac0-11f0-abef-c86e08a4b6ef_part': <cadquery.assembly.Assembly at 0x1b1d123b680>}

In [4]: assy.objects
Out[4]:
{'test_assy': <cadquery.assembly.Assembly at 0x1b1d11fcc20>,
 'a77ae412-4ac0-11f0-804d-c86e08a4b6ef': <cadquery.assembly.Assembly at 0x1b1d123a090>,
 'a77ae413-4ac0-11f0-ab48-c86e08a4b6ef': <cadquery.assembly.Assembly at 0x1b1d123a480>,
 'a77ae414-4ac0-11f0-b508-c86e08a4b6ef': <cadquery.assembly.Assembly at 0x1b1d123a510>,
 'a77ae415-4ac0-11f0-b410-c86e08a4b6ef': <cadquery.assembly.Assembly at 0x1b1d123a5d0>,
 'a77ae416-4ac0-11f0-b4a6-c86e08a4b6ef': <cadquery.assembly.Assembly at 0x1b1d123a690>}

It seems that the name is taken from the wrong level of the tree.

@jmwright
Copy link
Member Author

jmwright commented Jun 17, 2025

It turns out that the exportStepMeta() method has the same issue of applying names to the wrong level. It tries to flatten the assembly by default, and the name of a child-assembly gets applied to the shape(s) within the child-assembly and not to the assembly itself.

Screenshot from 2025-06-17 14-23-45

I need to create another PR to fix nesting and name handling in exportStepMeta(), and will probably make an option to flatten the assembly (flattening has been asked for by users), but it won't be the default.

I think there are still issues lurking with having duplicate names, but I will fix the name handling in this PR and make sure the nesting works properly. Then I can worry about the unique naming issues.

@adam-urbanczyk
Copy link
Member

I see that you are working on #1858, but wouldn't it be better to fix the issue here iso adding (before the release) more functionality? I'd propose to add a heuristic that detect nodes with one child and _part suffix and take the shape from _part and name from the parent. IMO this does not sound too complicated.

@jmwright
Copy link
Member Author

#1858 was created because there is an oversight within #1782 that causes exportStepMeta to act differently than the standard Assembly.export method. I'm trying to fix that, not adding functionality, other than adding a flatten parameter because I expect it to be asked for eventually.

@jmwright
Copy link
Member Author

The structure looks the same now on the test case that used to fail. The first screenshot is the one that was exported, then the second is that STEP imported, then re-exported.

test_assy

test_assy_reexport

@adam-urbanczyk
Copy link
Member

OK, I'll take one final look.

Copy link
Member

@adam-urbanczyk adam-urbanczyk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, 2nd pass


# Process the color for the shape, which could be of different types
color = Quantity_Color()
cq_color = cq.Color(0.50, 0.50, 0.50)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the magic parameters?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are the RGB values that most of the CQ visualization methods seem to be using as a default with assemblies (gray/silver). The values are not standardized as cadquery.vis.show seems to use a gold color IIRC. CQ-editor uses the gray color though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, default color should be None. See:

class Assembly(object):
    """Nested assembly of Workplane and Shape objects defining their relative positions."""

    loc: Location
    name: str
    color: Optional[Color]
    metadata: Dict[str, Any]

    obj: AssemblyObjects
    parent: Optional["Assembly"]
    children: List["Assembly"]

    objects: Dict[str, "Assembly"]
    constraints: List[Constraint]

loc=cq.Location(parent_location),
)
else:
assy.add(cq.Shape.cast(shape), name=name, color=cq_color)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't this covered. Is parent location not optional?

# Pass down the location or other context as needed
# Add the parent location if it exists
if parent_location is not None:
loc = parent_location * loc
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this suspicious. Assy applies locations in a relative sense. It shouldn't be needed to construct absolute locations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to check, but I think I added this because of nested sub-assemblies

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. If the parent location is not passed through the call stack, nested subassemblies will break. I added the test_nested_subassembly_step_import test to illustrate this. If you disable parent location passing and run that test, it should fail.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, but that is actually suspicious. When you construct an assy you do not need combine (i.e. multiply) the locations explicitly.

@lorenzncode
Copy link
Member

I am still getting a ValueError with a STEP from the tests.

(cqdev) lorenzn@fedora:~/devel/cadquery$ git status -u no
On branch assembly-import
Your branch is up to date with 'upstream/assembly-import'.

nothing to commit, working tree clean
(cqdev) lorenzn@fedora:~/devel/cadquery$ pytest tests/test_assembly.py --basetemp=./tmp2 -k test_colors_assy1 -q --no-summary
...........                                                                                                                                                        [100%]
11 passed, 96 deselected, 13 warnings in 0.58s
sys:1: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
(cqdev) lorenzn@fedora:~/devel/cadquery$ cat test2.py 
from cadquery import Assembly

obj = Assembly.importStep("./tmp2/assembly10/chassis0_assy.step")
(cqdev) lorenzn@fedora:~/devel/cadquery$ python test2.py 
Traceback (most recent call last):
  File "/home/lorenzn/devel/cadquery/test2.py", line 3, in <module>
    obj = Assembly.importStep("./tmp2/assembly10/chassis0_assy.step")
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/lorenzn/devel/cadquery/cadquery/assembly.py", line 622, in importStep
    _importStep(assy, path)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 194, in importStep
    process_label(labels.Value(1))
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 173, in process_label
    process_label(sub_label, loc, name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 149, in process_label
    process_label(ref_label, parent_location, parent_name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 173, in process_label
    process_label(sub_label, loc, name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 149, in process_label
    process_label(ref_label, parent_location, parent_name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 173, in process_label
    process_label(sub_label, loc, name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 149, in process_label
    process_label(ref_label, parent_location, parent_name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 173, in process_label
    process_label(sub_label, loc, name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 149, in process_label
    process_label(ref_label, parent_location, parent_name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 179, in process_label
    _process_simple_shape(label, parent_location, parent_name)
  File "/home/lorenzn/devel/cadquery/cadquery/occ_impl/importers/assembly.py", line 74, in _process_simple_shape
    assy.add(
  File "/home/lorenzn/devel/cadquery/cadquery/assembly.py", line 241, in add
    self.add(assy)
  File "/home/lorenzn/devel/cadquery/cadquery/assembly.py", line 222, in add
    raise ValueError("Unique name is required")
ValueError: Unique name is required

@adam-urbanczyk
Copy link
Member

@jmwright any updates on this PR?

@jmwright
Copy link
Member Author

@adam-urbanczyk Not yet. I got busy with some other things.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants