Skip to content

Bulk export: unbounded memory growth (docs never closed) + crash on filenames with : (or other illegal chars) + hidden bodies silently skipped #56

Description

@To0wnn

Title: Bulk export: unbounded memory growth (docs never closed) + crash on filenames with : (or other illegal chars) + hidden bodies silently skipped

Body:

I hit a few related issues running a bulk STEP export over a real project folder (~60 documents), all in commands/ExportCommand.py. Wrote small patches for each - sharing them here in case they're useful, happy to open a PR if you'd rather have that.

1. Memory grows unbounded during a bulk export

open_doc() opens and activates each document, but nothing ever closes it again. Fusion keeps every opened document fully loaded, so on a project with ~60 files this grew to 47GB of RAM by the end of the run.

Fix: close each document right after its export attempt, in a finally so it happens even on error/break:

def close_active_doc(wait_for_files=None, timeout=10.0):
    """Close the currently active document without saving.

    ExportManager.execute() isn't documented/confirmed to be fully
    synchronous for the on-disk write - closing the document immediately
    after has been reported elsewhere to crash Fusion or leave a
    truncated export. So when wait_for_files is given (the path(s) just
    written), poll each file's size until it's stable across two checks
    0.5s apart before closing, capped at `timeout` seconds so a failed
    export can't hang the whole batch.
    """
    if wait_for_files:
        if isinstance(wait_for_files, str):
            wait_for_files = [wait_for_files]
        deadline = time.time() + timeout
        for fp in wait_for_files:
            last_size = -1
            while time.time() < deadline:
                try:
                    size = os.path.getsize(fp)
                except OSError:
                    size = -1
                if size >= 0 and size == last_size:
                    break
                last_size = size
                time.sleep(0.5)

    app = adsk.core.Application.get()
    doc = app.activeDocument
    if doc is not None:
        try:
            doc.close(False)
        except Exception:
            pass

export_active_doc() needs to return the list of paths it wrote so the caller can pass them in:

def export_active_doc(folder, file_types, output_name):
    ...
    written_files = []
    for type_name, (export_function, extension) in solid_export_functions.items():
        if is_type_selected(file_types, type_name):
            export_name = folder + output_name + extension
            export_name = dup_check(export_name)
            export_options = export_function(export_name)
            export_mgr.execute(export_options)
            written_files.append(export_name)   # <- add this in each branch (STEP/IGES/SAT/SMT, F3D, STL)
    ...
    return written_files   # <- add at the end

And in export_folder()'s f3d branch, wrap the export call in try/finally:

if file.fileExtension == "f3d":
    open_doc(file)
    written_files = []
    try:
        output_name = get_name(write_version, name_option)
        written_files = export_active_doc(output_folder, file_types, output_name)
    except ValueError as e:
        ao.ui.messageBox(str(e))
    except AttributeError as e:
        ao.ui.messageBox(str(e))
        break
    finally:
        close_active_doc(wait_for_files=written_files)

(Same finally: close_active_doc() in the electronics branch a bit further down - no written_files needed there since it exports one document to one product, or you can wire it through the same way if you want it as strict there too.)

2. Crash on documents with : (or other) illegal filename characters

A document named worm gear 30:1 crashed the whole batch partway through:

RuntimeError: 3 : The file name is not valid.

Neither get_name() nor get_electronics_name() sanitize the document name before it's used as a filename - any of \ / : * ? " < > | in a document name will hit this. Only found it because I happened to have a document named that way; anyone with similarly-named parts (measurements like 30:1, or names with /) will hit the exact same crash without warning.

Fix: sanitize at the source, so every caller is covered:

import re

def sanitize_filename(name):
    return re.sub(r'[\\/:*?"<>|]', '-', name)

Then wrap both functions' return values:

def get_electronics_name(write_version):
    ...
    return sanitize_filename(doc_name)

def get_name(write_version, option):
    ...
    return sanitize_filename(output_name)

3. Hidden bodies/components are silently excluded from export

Fusion's own STEP export only includes visible geometry - a hidden body, or a body nested under a hidden parent occurrence, just doesn't show up in the output, with no warning. Confirmed this is Fusion's export behavior, not something Project-Archiver controls, but since a lot of people archive multi-config or WIP documents where some bodies are deliberately hidden, it's an easy way to end up with an incomplete export without knowing it.

Fix: an opt-in "Include hidden bodies/components?" checkbox (default off, so existing behavior is unchanged unless you turn it on). When enabled, walk the whole component tree before export and turn every light bulb on, then put back exactly what was off afterwards - isVisible/isLightBulbOn don't propagate up the tree on their own, so this has to happen at every level, not just the root:

def show_all_hidden(design):
    """Temporarily make every hidden body/occurrence visible for export.

    Returns the list of objects that were actually OFF, so the caller can
    restore exactly those afterwards and not touch anything that was
    already visible on purpose.
    """
    changed = []

    def visit(component):
        for body in component.bRepBodies:
            if not body.isLightBulbOn:
                changed.append(body)
                body.isLightBulbOn = True
        for occ in component.occurrences:
            if not occ.isLightBulbOn:
                changed.append(occ)
                occ.isLightBulbOn = True
            visit(occ.component)

    visit(design.rootComponent)
    return changed


def restore_hidden(changed):
    """Undo show_all_hidden() - hide exactly what was hidden before."""
    for obj in changed:
        try:
            obj.isLightBulbOn = False
        except Exception:
            pass

Add the checkbox next to the existing "Preserve folder structure?" one:

include_hidden_input = inputs.addBoolValueInput('include_hidden_id', 'Include hidden bodies/components?', True, '', False)
include_hidden_input.isVisible = True

Read it in on_execute alongside folder_preserve, thread it through export_folder()'s signature (including its own recursive call) the same way folder_preserve already is, and in the f3d branch wrap the export with show/restore:

if include_hidden:
    # ao is captured once at the top of export_folder(), before the loop -
    # its .document/.design go stale the moment open_doc() switches the
    # active document for the next file. Make a fresh AppObjects() here
    # instead (it's a cheap wrapper, just reads app.activeDocument).
    hidden_changed = show_all_hidden(AppObjects().design)
output_name = get_name(write_version, name_option)
written_files = export_active_doc(output_folder, file_types, output_name)
...
finally:
    if hidden_changed:
        restore_hidden(hidden_changed)   # before closing, while this doc is still active
    close_active_doc(wait_for_files=written_files)

Why I'm flagging all three together

All three came up in the same bulk export over the same project, and each led me straight to the next while poking at the same file. If it's easier to land these as separate PRs I'm happy to split them up, whatever works best for you.

Tested all three against a real ~60-document project: exports complete, memory stays flat, the 30:1-named document now exports as 30-1 without any error, and a document with a hidden body now exports it in full when the new checkbox is ticked (and exports as before when it's left unticked).

(One correction from my own first pass at #3: I originally called show_all_hidden(ao.design) reusing export_folder()'s own ao, captured once before the loop over all documents starts. That ao.document/ao.design go stale as soon as open_doc() switches the active document to the next file in the batch, so on anything past the very first document it was silently operating on the wrong document - no error, just no visible effect. Fixed by building a fresh AppObjects() right there instead, shown in the snippet above.)

Related existing issues

Looks like both of these have come up before and are still open: #19, #32 (filename sanitization) and #5, #33, #48 (documents not closing / memory). #35 links a fork (o-gent/Project-Archiver) that apparently already fixed both a while back, in case that's easier to pull in than reviewing another patch from scratch - either way, happy to help however's most useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions