Data panel

Excel Sets

Export every selection set to a workbook, one row per condition, or import a workbook back as new sets.

On the ribbonButton, Data panel
NeedsAn open document

What it does#

Round-trips selection sets through an Excel workbook. Export to review a model's sets in a spreadsheet or hand them to someone, edit the rows, and import them back as new sets, or carry a library of standard sets from one federated model to the next.

How to use it#

  1. Click Excel Sets and choose Export or Import.
  2. Export asks where to save an .xlsx and writes one row per search condition. A set with several conditions repeats its folder and name once per condition. A set with no conditions, including a static set, still gets a row with the condition columns blank so it stays visible.
  3. Import asks for a workbook, checks every row before touching the document, prints any problems to the output window and refuses if there are any, and otherwise creates the sets behind a progress window you can cancel.

Limitations#

  • Search sets only travel as their conditions. A static set exports as a name with no conditions and is skipped on import, because its members belong to the original file.
  • Import creates new sets; it does not update sets that already exist with the same name.
  • Property names must match what the target model calls them. A set that searches for "Level" in one exporter's vocabulary finds nothing in another's.

Other uses#

  • An office standard: keep one workbook of the sets every project should have and import it into each new federated model.
  • Review a model's sets without opening the model: the workbook reads as a list.

Source#

The tool's own files, word for word as shipped in Data.panel/Excel_Sets.pushbutton. Every pyNavis tool is an ordinary bundle: copy these into your own extension and change what you like.

script.py Runs on a click. View on GitHub
"""Round-trips selection sets through an Excel workbook: export every set to
one row per condition, or import a workbook back into new sets.

The row<->dict converters (rows_to_spec, spec_to_rows) are pure and have no
Navisworks dependency, so this file doubles as an importable module for
tests: everything below the guard at the bottom only runs when pyNavis
actually launches the button (see the '__commandpath__ in globals()' trick
in the bundle-authoring skill's templates)."""

from pynavis import forms, output, sets, toast, xl

HEADERS = ['Folder', 'Set', 'Category', 'Property', 'Op', 'Value']

# Ops whose compiled condition dict (see pynavis.sets._condition_to_dict)
# carries no 'prop' key at all, and ops that carry no 'value' key - has_category
# has neither prop nor value, has_property/not_has_property have prop but no
# value. Mirrored on both sides of the row<->dict conversion below.
_NO_PROP_OPS = ('has_category',)
_NO_VALUE_OPS = ('has_category', 'has_property', 'not_has_property')


def spec_to_rows(spec):
    """The {'sets': [{'name', 'folder', 'query'}, ...]} shape sets.export_all()
    returns, flattened to plain rows for xl.write: one row per condition
    (folder, set name, category, property, op, value). A set with several
    AND'd conditions repeats its folder and name, one row per condition, in
    condition order - the inverse of rows_to_spec below. A set with no
    conditions at all (an empty AND group, or a non-portable {'items': N}
    static set, which only reaches here because run_export asks for
    export_all(include_static=True)) still gets a single row so it stays
    visible in the workbook, with category/property/op/value blank -
    rows_to_spec skips exactly those rows again on the way back in.
    """
    rows = []
    for entry in spec.get('sets', []):
        folder = entry.get('folder') or ''
        name = entry.get('name', '')
        conditions = _conditions_of(entry)
        if not conditions:
            rows.append([folder, name, '', '', '', ''])
            continue
        for condition in conditions:
            rows.append(_row_for_condition(folder, name, condition))
    return rows


def rows_to_spec(rows):
    """The inverse of spec_to_rows: groups xl-read rows (folder, set,
    category, property, op, value) back into the {'sets': [...]} shape
    sets.import_dict() expects. Consecutive rows sharing the same
    (folder, set) belong to one set's single AND group, in row order - a set
    never splits across a non-adjacent block of rows, matching how
    spec_to_rows lays a set's rows out.

    Returns (spec, skipped): a block whose every row has a blank op yields
    no conditions at all, and is DROPPED from spec rather than emitted as an
    empty query, with skipped counting how many blocks were dropped (the
    caller toasts it, so a silently missing set is still visible). Those
    blocks are exactly the static/explicit sets export_all(include_static=
    True) puts in the workbook for visibility: {'items': N} is a count, not
    a query, so there is nothing to rebuild the set from - an empty query
    would mean "match the whole model", which sets._validate_import rejects
    outright, and rejecting would abort the WHOLE import over rows the user
    never meant to import in the first place.

    Excel has no int/float distinction: a real workbook read with
    pynavis.xl.read hands every numeric cell back as float, so an exported
    int like 42 would otherwise re-import as 42.0 (compiling as FromDouble
    instead of FromInt32, and stringifying as '42.0' instead of '42' for
    contains/wildcard). See _normalize_value: an integral float is
    normalized back to int here on the way in; a genuine decimal (1.5) is
    left as float. This is a one-way, lossy convention (a float that was
    genuinely written as e.g. 3.0 on purpose re-imports as int 3) but it is
    the best available guess given xlsx's single numeric cell type, and it
    is what keeps a plain integer property value searching the same way
    after a round trip through Excel.
    """
    entries = []
    prev_key = None
    for row in rows:
        folder, name, category, prop, op, value = _padded(row)
        key = (folder, name)
        if key != prev_key:
            entry = {'name': name, 'folder': folder or None, 'conditions': []}
            entries.append(entry)
            prev_key = key
        else:
            entry = entries[-1]
        if op:
            entry['conditions'].append(_condition_from_row(category, prop, op, value))

    kept = [entry for entry in entries if entry['conditions']]
    spec = {'sets': [
        {'name': entry['name'], 'folder': entry['folder'],
         'query': {'or': [{'and': entry['conditions']}]}}
        for entry in kept
    ]}
    return spec, len(entries) - len(kept)


def _conditions_of(entry):
    query = entry.get('query')
    if not isinstance(query, dict):
        return []
    conditions = []
    for group in query.get('or', []):
        conditions.extend(group.get('and', []))
    return conditions


def _row_for_condition(folder, name, condition):
    op = condition.get('op', '')
    if op == 'raw':
        # to_dict's marker for a condition it could not reverse-map: not
        # portable (sets._validate_import rejects it on import), but the
        # raw text is worth showing rather than a silently blank row.
        return [folder, name, '', '', op, condition.get('text', '')]
    return [
        folder, name,
        condition.get('category', ''),
        '' if op in _NO_PROP_OPS else condition.get('prop', ''),
        op,
        '' if op in _NO_VALUE_OPS else condition.get('value', ''),
    ]


def _condition_from_row(category, prop, op, value):
    if op == 'raw':
        return {'op': 'raw', 'text': value}
    condition = {'category': category, 'op': op}
    if op not in _NO_PROP_OPS:
        condition['prop'] = prop
    if op not in _NO_VALUE_OPS:
        condition['value'] = _normalize_value(value)
    return condition


def _normalize_value(value):
    """An integral float becomes int; anything else passes through
    unchanged. See rows_to_spec's docstring for why: xl.read hands back
    every numeric cell as float, so this is what makes an exported int
    property value (e.g. a level number, a count) behave the same way after
    a round trip through Excel instead of silently becoming a float."""
    if isinstance(value, float):
        try:
            as_int = int(value)
        except (OverflowError, ValueError):
            return value
        if as_int == value:
            return as_int
    return value


def _padded(row):
    """Exactly 6 cells, missing trailing ones treated as ''."""
    values = list(row) + [''] * 6
    return tuple(values[:6])


def run_export():
    # include_static=True: a static/explicit set exports as {'items': N},
    # which is NOT importable - but leaving it out of the workbook entirely
    # would make a set the user can see in Navisworks silently missing from
    # the sheet. It gets a blank row here for visibility, and rows_to_spec
    # skips those blank rows again on the way back in.
    spec = sets.export_all(include_static=True)
    rows = spec_to_rows(spec)
    if not rows:
        toast.info('No sets to export', 'This document has no selection sets.')
        return

    path = forms.save_file(
        filter='Excel files (*.xlsx)|*.xlsx|All files (*.*)|*.*',
        default_name='selection_sets.xlsx', title='Export sets to Excel')
    if path is None:                                # cancelled: say nothing
        return

    xl.write(path, rows, headers=HEADERS)
    toast.success('Exported %d row(s)' % len(rows), path)


def run_import():
    path = forms.open_file(
        filter='Excel files (*.xlsx)|*.xlsx|All files (*.*)|*.*',
        title='Import sets from Excel')
    if path is None:                                # cancelled: say nothing
        return

    raw_rows = xl.read(path)
    data_rows = raw_rows[1:] if raw_rows and raw_rows[0] == HEADERS else raw_rows
    if not data_rows:
        toast.info('Nothing to import', 'The workbook has no rows.')
        return

    spec, skipped = rows_to_spec(data_rows)
    if not spec['sets']:
        detail = ('%d set(s) had no conditions and were skipped.' % skipped
                  if skipped else 'No rows carried a condition.')
        toast.info('Nothing to import', detail)
        return

    problems = sets._validate_import(spec)
    if problems:
        output.print_table([[p] for p in problems], ['Problem'])
        toast.error('Cannot import', '%d problem(s); see the output window.' % len(problems))
        return

    try:
        with forms.progress('Importing sets', 'Starting') as p:
            def report(done, total):
                p.check()
                p.update(float(done) / total if total else 1.0,
                         '%d of %d' % (done, total))
            created, errors = sets.import_dict(spec, progress=report)
    except forms.Cancelled:                         # cancelled: say nothing
        return

    skipped_note = ('%d set(s) with no conditions skipped' % skipped) if skipped else None
    if errors:
        toast.warning('Created %d set(s), %d failed' % (created, len(errors)), errors[0])
    else:
        toast.success('%d sets created' % created, skipped_note)


def run():
    choice = forms.ask_options(
        'Export the selection sets in this document to an Excel workbook, '
        'or import a workbook back into new sets?', ['Export', 'Import'],
        title='Excel Sets')
    if choice is None:                              # cancelled: say nothing
        return
    if choice == 'Export':
        run_export()
    else:
        run_import()


if '__commandpath__' in globals():
    run()
bundle.yaml The bundle contract: the keys pyNavis reads for this tool. View on GitHub
title: Excel\nSets
tooltip: Exports every selection set to an Excel workbook (one row per condition), or imports a workbook back into new sets.
context: doc