Reference

Python API reference

Every public function in the pynavis library, with its exact signature, return value and failure behaviour.

The pynavis library is what your script talks to instead of the raw Navisworks .NET API. It is a flat set of small modules, each one covering a single surface: the selection, the document, the clash tests, the saved viewpoints, the output window, the dialogs. Nothing is a class you have to construct; almost everything is a function you call.

This page is the complete public surface. If a name is not on this page, it is either private or it does not exist.

Importing pynavis#

Importing the package itself gives you exactly one thing:

python
import pynavis

pynavis.__version__          # '1.0.0'
pynavis.selection            # AttributeError: the package re-exports nothing

The package deliberately holds no Navisworks references, so it imports and unit-tests outside Navisworks entirely. Everything useful lives in a submodule, and the house import style is to name the submodules you want:

python
from pynavis import forms, output, toast

That reads well at the call site (toast.success(...), output.print_table(...)) and it keeps the API-bound modules out of your script until you actually ask for one. Importing pynavis.selection outside a Navisworks session raises a clear ImportError rather than a raw CLR loader failure; the pure modules import anywhere.

Underscore modules are off limits

pynavis._api, _atomic, _com, _util, _markdown, _clashsnap, _clashapply, _repl and _history are internal. They change without notice, they have no compatibility promise, and several of them load assemblies as a side effect of being imported. Everything they do that you need is re-exposed by a public module. See the private modules at the foot of this page for what each one is, so you can recognise them in a traceback.

The modules at a glance#

The third column is the important one when you are writing tests or a tool that runs before a model is open: it says whether importing the module requires a live Navisworks session. Modules marked no still contain functions that need a document; they just do not fail at import time.

ModuleWhat it is forImport needs Navisworks
app The application: active document, GUI, API version.Yes
clash Clash Detective tests, results, summaries, and applying a grouping plan.Yes
clashgroup The pure grouping engine: rules, chaining, clustering, naming.No
clashtest Create, edit, run and delete Clash Detective tests, plus a batch status writer.No
doc The active document: title, file name, saved viewpoints, item properties.Yes
export Save and publish NWD files, and a best-effort viewpoint image export.No
forms Modal dialogs: alert, confirm, prompts, pickers, cancellable progress, custom windows.No
geometry The world-space triangles of a model item, and moving items by a vector.No
memory Calculator-style selection memory, one register per document, kept on disk.No
output The output window: HTML, markdown, tables, charts, images, progress, element links.No
overlay Labelled lines drawn into the 3D view, redrawn every frame.No
panes Show, hide and count pyNavis dock panels, and generate extra panel slots.No
pick Ask the user to click a point in the 3D view, with snapping.No
props Read model item properties as plain values, and write custom property tabs.No
script The running command: session variables, per-command settings and data, a logger, plus Reload.No
section Section planes fitted to a selection, and the pure geometry behind them.No
selection Read, replace and clear the current selection.Yes
separation How far one mesh must slide along an axis to stop touching another. Pure maths.No
sets Build and run search queries, and read and write search and selection sets.No
settings Per-tool JSON settings for the Shift+Click convention.No
toast Non-blocking status messages in the corner of the window.No
view Zoom onto the selection, isolate items, unhide everything.Yes
viewpoints Saved-viewpoint snapshot, rename and reorganise engine.No
viewstate Copy and paste section planes, hidden items and appearance overrides, per document.No
xl Reading and writing .xlsx workbooks, no Excel or Navisworks required.No

Three badges appear throughout the page: Pure means the function needs no Navisworks at all and can be unit-tested anywhere; Needs Navisworks means it reads or writes a document; Blocks means it opens a modal window and your script stops until the user answers.

Conventions that cut across every module#

Six conventions explain most of the surprises people hit. They are worth reading once before the module listings, because the modules differ from each other in ways that look arbitrary until you know why.

doc=None means the active document#

Every function that touches a document takes an optional trailing doc parameter, always last, always defaulting to None. None resolves to Application.ActiveDocument at call time.

python
from pynavis import doc, selection

items = selection.get_items()               # the active document
items = selection.get_items(doc.get_doc())  # exactly the same thing, spelled out

Pass a document explicitly when you already hold one and want to be certain the active document cannot change underneath a long loop. Otherwise leave it out: the default is what every shipped bundle uses.

Three Result classes, one shape#

memory.Result, section.Result and viewstate.Result have the same constructor and the same four attributes, but they are three separate classes in three separate modules. There is no shared base class, and isinstance(r, memory.Result) is False for a section or viewstate result.

memory.Result(level, message, detail='', count=0)
section.Result(level, message, detail='', count=0)
viewstate.Result(level, message, detail='', count=0)
AttributeTypeMeaning
.levelstr 'success', 'error', 'info' or 'warning'.
.messagestrThe one-line headline.
.detailstrThe second line; often empty.
.countintHow many things the action affected; 0 when nothing did.

They exist for one reason: to be handed straight to a toast. The first three attributes line up with toast.show exactly, so every action function ends the same way.

python
from pynavis import memory, toast

result = memory.memorize()
toast.show(result.level, result.message, result.detail)

Cancellation differs by module, and it is a real trap#

Two modules support cancelling a long job, and they do it in opposite ways. A third, forms.progress, raises an exception of its own.

clash.Cancelled and forms.Cancelled are unrelated classes

They share a name and nothing else. clash.Cancelled comes out of snapshot_results when its progress callback returned False; forms.Cancelled comes out of a progress window's check() after the user clicked Cancel. Neither catches the other, and there is no base class in common, so catch the one the call you made can actually raise.

FunctionHow you cancelWhat happens
clash.snapshot_results The progress callback returns exactly False. Raises clash.Cancelled. The partial read is discarded.
viewpoints.apply_renames, viewpoints.apply_deletes A separate cancelled() predicate returns true. The loop stops and returns the partial counts. Nothing is raised, nothing is rolled back.
Returning None is not cancelling

clash.snapshot_results tests the callback's return value with == False, not not value. A callback written for side effects, which is nearly all of them, returns None implicitly, and None == False is False, so the read carries on. That is deliberate: it means the same tick function is safe to pass to apply_plan, which ignores return values entirely. Cancel by returning the literal False and nothing else.

python
from pynavis import clash, output

def tick(count):
    output.progress(0.0, 'Read %d clashes' % count)
    # return False here to abort; returning None carries on

try:
    rows = clash.snapshot_results(test, progress=tick)
except clash.Cancelled:
    rows = []

Progress callback signatures differ by function#

There is no single progress protocol. Four shapes exist, and passing the wrong one raises a TypeError partway through a long job, which is the worst possible moment.

FunctionCallback signatureCalled
clash.snapshot_resultsprogress(count_so_far) Every 500 results, and once more at the end.
clash.apply_plan, clash.ungroup progress(fraction), 0.0 to 1.0 Roughly every 500 results copied, and always once with 1.0 at the end.
viewpoints.apply_renames, viewpoints.apply_deletes progress(done, total) Every 200 entries, and once with (total, total) at the end.
props.set_custom, props.remove_custom, sets.import_dict progress(done, total) After every item, 1-based, so the last call is (total, total).
output.progressprogress(fraction, label='') This one is the sink, not a callback: you call it.

The usual pattern is to adapt in a one-line lambda at the call site rather than writing a callback that tries to serve two shapes:

python
from pynavis import clash, output, viewpoints

clash.apply_plan(test, plan, progress=lambda f: output.progress(f, 'Grouping'))
viewpoints.apply_deletes(guids, progress=lambda done, total: output.progress(
    float(done) / total, 'Deleting'))

Error channels differ by module#

Nothing in the library uses a single error convention, because the modules answer to different callers. Know which one you are in.

ModuleHow failure arrives
clash Raises. Cancelled from snapshot_results; ValueError from apply_plan and ungroup when the test cannot be found in the tests tree.
clashgroup Raises ValueError('unknown rule: ...') for a rule id it does not know. Nothing else raises.
memory Every action function returns an error-level Result for expected failures and never raises. The exception is deserialize, which raises MemoryFormatError, and load, which lets that error travel.
section Every action function returns an error-level or warning-level Result. The geometry walk degrades through three point sources rather than failing.
viewpoints The apply_* functions return (count, errors) where errors is a list of message strings. One bad entry never stops the rest.
settings load swallows everything and hands back your defaults. save can raise IOError or OSError.
pick A cancelled pick is None, never an exception. The only thing that raises is Unavailable, and only from point and point_then, when the pick plugin is not loaded.
overlay Returns False, and logs, when the overlay plugin is not loaded. Never raises: the drawing is decoration, and losing it must not fail the tool.
geometry Returns an empty list for every failure, with the reason handed to the note callback. Never raises.
view zoom_selected returns False and aspect_ratio returns None rather than raising, because a camera move is never worth failing a tool that already did its job.

What blocks and what does not#

  • Only forms.* blocks. Every dialog and picker in it is modal: the script stops on that line until the user answers, and a cancelled dialog returns None or False rather than raising. The one twist is forms.progress, which is modal for the user but not for your script: your loop keeps running inside the with block while the window is up.
  • pick.point and pick.measure_point also block, without a window: your script stops until the user clicks in the 3D view or cancels. The view itself stays live, so they can orbit and zoom while they aim. pick.point_then is the non-blocking form, and the one to use from a panel or a modeless window.
  • toast.* never blocks and never takes focus. A toast appears in the corner and dismisses itself, which is why it is the right surface for a one-line status.
  • output.* writes without blocking. The output window is docked; writing to it does not stop your script and does not steal focus.
One script at a time

Your script runs on the UI thread, so anything long freezes Navisworks until it finishes. That is what the progress conventions above are for. Never move document work onto a background thread: the Navisworks API is not thread-safe.

app: the Navisworks application#

Needs Navisworks Four thin readers for the application object itself. You need this module when you want the main window handle, the main document as distinct from the active one, or the exact API version you are running against. Importing it loads the Navisworks .NET API, so it fails outside a session.

app.get_doc and app.get_main_doc#

app.get_doc()
app.get_main_doc()

get_doc() returns Application.ActiveDocument. It is never None while Navisworks is running, but it may be an empty document with no models in it, so check doc.get_filename() or the model count before assuming there is something to work on.

get_main_doc() returns Application.MainDocument, the document belonging to the main window. The two differ only when a secondary document is active.

app.get_gui#

app.get_gui()

The GuiApplication object, which is where the main window handle lives. Use it when you need to parent a window of your own to Navisworks.

app.get_api_version#

app.get_api_version()

The version string of the loaded Autodesk.Navisworks.Api assembly, for example '23.0.1234.5'. The first number is the API generation, which is what you branch on when an API changed between Navisworks releases. This is the assembly version, not the marketing year.

clash: Clash Detective data#

Needs Navisworks Everything to do with Clash Detective: walking the tests, reading their results into plain dictionaries, counting and summarising them, and committing a grouping plan back. Importing this module loads both the Navisworks API and the Autodesk.Navisworks.Clash assembly.

The division of labour matters. Reading never touches the document. Writing rebuilds the whole test on a detached copy and commits it in a single TestsReplaceWithCopy, because the per-edit API methods cost roughly the same however small the edit, and on a 350,000-clash model one edit per group plus one per clash works out at days of frozen UI. The rebuild takes about twelve seconds.

clash.get_clash#

clash.get_clash(doc=None)

The document's DocumentClash part. Everything else in this module goes through it; you need it directly only to reach parts of the Clash API this module does not wrap.

clash.walk_tests and clash.walk_results#

clash.walk_tests(doc=None)
clash.walk_results(test)

Both are generators. walk_tests yields every ClashTest in the document, flattening test folders away. walk_results yields every leaf ClashResult of one test, flattening result groups away, in depth-first order. That order is load-bearing: it is the same order snapshot_results numbers its ids in, which is what lets a plan built from a snapshot be applied to the live tree.

walk_results takes a test, not a document, so it has no doc parameter.

python
from pynavis import clash

for test in clash.walk_tests():
    print(test.DisplayName, sum(1 for _ in clash.walk_results(test)))

clash.snapshot_results#

clash.snapshot_results(test, doc=None, progress=None)

Reads a test into a list of plain dictionaries, one per leaf result, in walk order. This is the bridge to clashgroup: the snapshot contains no Navisworks objects at all, so the grouping engine that consumes it is pure Python and tests anywhere.

progress is called with the number of results read so far, about every 500 results and once at the end. Returning exactly False raises clash.Cancelled; see cancellation.

Each row has these keys:

KeyTypeMeaning
idint The index in walk order. This is what a plan refers to.
namestrThe clash display name.
centertuple or None (x, y, z) in model units, or None when the result has no centre.
item_a, item_bstr Stable key for the element on each side: its instance GUID, or a hash-derived key when there is no GUID. '' when the element is unknown.
item_a_name, item_b_namestr Display name of each side's element, '' when unknown.
levelstr or NoneNearest level display name.
gridstr or NoneNearest grid intersection display name.
model_a, model_bstr Source file name, no path, of the model each element came from.
statusstr The result status name, for example 'New' or 'Resolved'.
assignedstr'' when unassigned.
groupstr or None Name of the existing group holding this result, None at the test root.

The element on each side is the composite item when the test merged composites, otherwise the first object ancestor of the geometry leaf. That is the level people mean by "the element", not the raw triangle owner.

Level and grid are memoised per 0.25 m cell

Grid intersection lookup is by far the most expensive call in the read, and neighbouring clashes resolve to the same answer, so the result is shared across a quarter-metre cell. A clash sitting within 0.25 m of a level or grid boundary can therefore be attributed to the neighbouring level or grid. This is a deliberate accuracy-for-speed trade, and it is not display-only: it moves the level and grid rules and the names Smart grouping gives its clusters. A document with no grid system skips the lookup entirely and leaves both keys None.

clash.count_results#

clash.count_results(test)

Returns (total, grouped): how many leaf results the test has, and how many of them sit inside a result group. Far cheaper than a snapshot when all you want is to decide whether grouping is worth offering.

clash.summarize#

clash.summarize(doc=None)

One dictionary per clash test, with keys 'name', 'total' and 'by_status'. by_status maps each status name to its count, so a test with nothing resolved simply has no 'Resolved' key. Use .get(name, 0) when you tabulate it.

clash.apply_plan#

clash.apply_plan(test, plan, doc=None, keep_existing=True, progress=None)

Applies a plan from clashgroup to a test in a single document edit. Returns None.

The plan's ids refer to the walk order of this test as snapshot_results saw it, so the plan and the test must be a matched pair. With keep_existing=False, planned leftovers land back at the test root and pre-existing groups dissolve. Individual clashes are never renamed. New groups carry GROUP_MARKER as a comment so ungroup can recognise them later.

progress is called with a fraction from 0.0 to 1.0, and always once with 1.0 at the end. Return values are ignored; this function cannot be cancelled, because a half-applied rebuild is not a state worth producing.

Raises ValueError when the test cannot be located in the tests tree.

The test wrapper is dead after this call

apply_plan commits through TestsReplaceWithCopy, which retires the wrapper object you passed in. Reading anything from test afterwards, DisplayName included, gets you a disposed object. Read what you need before the call:

name = test.DisplayName          # read it first
clash.apply_plan(test, plan)
toast.success('Grouped %s' % name)

clash.ungroup#

clash.ungroup(test, doc=None, ours_only=True, progress=None)

Dissolves result groups and returns how many were removed, as an int. With the default ours_only=True only groups carrying GROUP_MARKER are dissolved, so hand-made groups survive; pass False to flatten every group. Nothing is renamed.

Only top-level groups are considered. A dissolved group's nested subgroups dissolve with it rather than surfacing as groups of their own. When there is nothing to dissolve the function calls progress(1.0) and returns 0 without touching the document.

Like apply_plan, this commits once and kills the test wrapper, and it raises ValueError when the test cannot be located.

clash.units_to_meters and clash.has_grid#

clash.units_to_meters(doc=None)
clash.has_grid(doc=None)

units_to_meters returns how many metres one model unit is, so a proximity tolerance the user typed in metres can be converted into the units a snapshot's center is expressed in. Unknown units return 1.0.

has_grid returns True when the document has an active grid system, which is exactly when the level and grid grouping rules can produce anything. Grey those rules out when it is False rather than letting the user pick a rule that puts everything in "No grid".

clash.GROUP_MARKER and clash.Cancelled#

clash.GROUP_MARKER = 'Created by pyNavis Smart Clash Grouper'
clash.Cancelled(Exception)

GROUP_MARKER is the comment body stamped on every group this module creates. It is what makes ungroup(ours_only=True) able to tell a generated group from one someone made by hand. Do not change it in your own tools, and do not put it on groups you create by other means unless you want them treated as disposable.

Cancelled is raised only by snapshot_results, only when the progress callback returned exactly False.

clashgroup: the pure grouping engine#

Pure No Navisworks imports at all. This module turns a list of snapshot dictionaries into a grouping plan: which clashes belong together, what each group should be called, and what is left over. It never touches a document, which is why the whole grouping strategy can be developed and tested without Navisworks open.

clashgroup.RULES#

clashgroup.RULES = [(id, label), ...]

The rule catalogue as (id, label) pairs, in the order a dropdown should show them. Build your UI from this list rather than hard-coding ids, so a new rule appears everywhere at once.

Rule idLabelGroups by
'item'Root-cause element (auto side) The element on whichever side collapses the clashes into fewer distinct elements.
'item_a'Element (side A)The side-A element.
'item_b'Element (side B)The side-B element.
'proximity'Proximity cluster Single-linkage spatial clusters within tolerance.
'level'Nearest levelThe level key, or "No level".
'grid'Grid intersectionThe grid key, or "No grid".
'model'Source modelThe pair of source models, as "A.rvt vs B.rvt".
'status'StatusThe status key.
'assigned'Assigned toThe assigned key, or "Unassigned".

clashgroup.plan#

clashgroup.plan(results, rule_ids, keep_existing=True, tolerance=6.0, min_size=1)

Chained-rule grouping. Each rule in rule_ids subdivides the groups the previous rule produced, so ['level', 'item'] gives you per-element groups inside each level, named Level 2 / Duct-441. Adjacent repeated labels are collapsed, so a cluster inside "L1" that is itself named "L1" comes out as L1, not L1 / L1.

tolerance is in model units and only the 'proximity' rule reads it. Groups with fewer than min_size members are left ungrouped rather than created. Every candidate lands in exactly one group or in ungrouped_ids: rules never drop a clash.

With keep_existing=True, results that already sit in a group are not candidates at all and are counted in skipped_existing.

An empty rule_ids sends every candidate to ungrouped_ids and explains itself with 'No rules selected.'. An unknown rule id raises ValueError('unknown rule: ...'), with the offending id in its repr form.

The bad-rule error is lazy

The rule id is only tested while a result is being classified, so a plan over an empty candidate list returns normally even with a nonsense rule in it. Validate rule ids against RULES when you accept them, rather than relying on plan to complain.

clashgroup.smart_plan#

clashgroup.smart_plan(results, keep_existing=True, tolerance=6.0)

Zero-configuration grouping, returning the same plan dictionary. It picks the side, A or B, whose elements collapse the most clashes into the fewest elements, groups by that side's root-cause element, then proximity-clusters everything that came out as a single-clash element. Anything still alone after that stays individual.

The explanation it returns names the side it chose and its dominant model, which is worth showing: it is the one line that tells a user why the grouping came out the way it did.

The plan dictionary#

Both functions return the same four keys.

KeyTypeMeaning
groupslist of dict {'name': str, 'ids': [int]}, largest group first, ties broken by earliest id. Duplicate names are deduped with ' (2)', ' (3)' and so on.
ungrouped_idslist of int Candidates that ended up in no group, sorted ascending.
skipped_existingint How many results were left alone because they were already grouped.
explanationstrOne line saying what was done.
python
from pynavis import clash, clashgroup, toast

test = next(clash.walk_tests())
rows = clash.snapshot_results(test)
plan = clashgroup.smart_plan(rows, tolerance=6.0 / clash.units_to_meters())

name = test.DisplayName                      # the wrapper dies in apply_plan
clash.apply_plan(test, plan)
toast.success('%d group(s) in %s' % (len(plan['groups']), name), plan['explanation'])

clashtest: authoring and running clash tests#

Pure at import. Creates, edits, runs and deletes Clash Detective tests, and batch-writes result status. The API half lazy-imports clash inside each function, so this module never fails to import outside a session; clash itself cannot be imported outside one. This module is about the tests themselves; use clash to read their results and clashgroup to group them.

clashtest.create#

clashtest.create(name, a, b, tolerance_m=0.001, test_type='hard', doc=None)

Needs Navisworks Creates a new test at the root of the tests tree comparing selections a and b, and returns the tree-resident ClashTest. a and b each accept a ModelItemCollection, any iterable of ModelItem, or a search/selection set name or path string, resolved the same way sets.find plus sets.items_of would, raising ValueError when the name does not resolve.

tolerance_m is always in metres, matching every other pynavis distance parameter, and is converted to the document's model units before being written. test_type is one of 'hard', 'clearance' or 'duplicate'; an unrecognised value raises ValueError naming the valid options, checked before any Navisworks import so a typo fails without needing a live document.

The returned test is a fresh reference, resolved by GUID

The live TestsAddCopy copies your test's definition into the tree rather than taking ownership of the object you built, so create hands back a different object: the one entry in the tests collection whose GUID was not there before the add. It cannot assume the new test lands last, because the root tests collection can also hold test folders. Hold onto create's return value, not the object you passed the selections into, for every later call.

python
from pynavis import clashtest, toast

test = clashtest.create('Structure vs MEP', 'Structure', 'MEP',
                        tolerance_m=0.025, test_type='hard')
clashtest.run(test)
toast.success('Created and ran %s' % test.DisplayName)

clashtest.edit#

clashtest.edit(test, name=None, tolerance_m=None, doc=None)

Needs Navisworks Edits a test's definition, its name and its tolerance, and returns test unchanged (the live object is edited in place). tolerance_m is again in metres. Passing neither argument is a no-op. test must be a live, tree-resident ClashTest, the same object clash.walk_tests yields or create returns, never a detached copy.

TestsEditTestFromCopy is definition-only: never use it to restructure results

Any edit that touches tolerance goes through TestsEditTestFromCopy, which silently ignores anything under the test's results tree, exactly right for a definition edit like this one. It is not the tool for grouping or ungrouping results: that work belongs to clash.apply_plan and clash.ungroup, which rebuild a detached copy and commit with the different, results-aware TestsReplaceWithCopy. clashtest.edit only ever touches DisplayName and Tolerance; a name-only edit skips the copy entirely and calls the single-call TestsEditDisplayName instead, the cheap path.

clashtest.run, run_all and clear_results#

clashtest.run(test, doc=None)
clashtest.run_all(doc=None)
clashtest.clear_results(test, doc=None)

Needs Navisworks run runs one test; run_all runs every test in the document. clear_results empties one test's results without deleting the test itself. All three take a live, tree-resident test the same way edit does, and none of them return anything.

clashtest.delete#

clashtest.delete(test, doc=None)

Needs Navisworks Deletes a root-level test. A test filed inside a test folder raises ValueError up front, naming the test, rather than letting the underlying call fail with an opaque .NET error: deleting a test inside a folder needs a different overload this module does not expose in v1.

clashtest.set_status#

clashtest.set_status(results, status, doc=None)

Needs Navisworks Batch-sets every result in results to status, one of 'new', 'active', 'reviewed', 'approved' or 'resolved', inside a single transaction, and returns how many were edited. An unrecognised status raises ValueError naming the valid options.

Each result's existing AssignedTo is read back and passed straight through, so a status-only edit never clobbers an assignment: the underlying call takes an assignee alongside the status, and there is no overload that edits status alone.

python
from pynavis import clash, clashtest, toast

test = next(clash.walk_tests())
new_results = [r for r in clash.walk_results(test) if str(r.Status) == 'New']
count = clashtest.set_status(new_results, 'reviewed')
toast.success('Marked %d result(s) reviewed' % count)

clashtest.summary#

clashtest.summary(doc=None)

Needs Navisworks The same summary clash.summarize returns; reused directly rather than duplicated here.

doc: the active document#

Needs Navisworks Metadata about the open document, a walk over its saved viewpoints, and property lookup on a model item. Importing this module loads the Navisworks API.

doc.get_doc, doc.get_title and doc.get_filename#

doc.get_doc()
doc.get_title(doc=None)
doc.get_filename(doc=None)

get_doc() is the same active document app.get_doc() returns; it exists here so a script that only needs the document does not have to import app.

get_title() is the document title, which for a saved document is the file name without its path. get_filename() is the full path, and it is an empty string for a document that has never been saved. Test for the empty string, not for None.

doc.walk_saved_viewpoints#

doc.walk_saved_viewpoints(doc=None)

A generator yielding (folder_names, saved_item) for every saved viewpoint and animation, depth-first, with viewpoint folders flattened away. folder_names is the list of ancestor folder display names, empty for a top-level item.

Only folders are descended into. An animation is a group item too, but it comes out as one item rather than as its individual cuts, which is what you want when you are listing or exporting.

This is the read-only, one-item-per-row view. When you want to edit the tree, use viewpoints.snapshot instead: it keeps folders as rows and carries the GUIDs the edit functions address items by.

python
"""Lists every saved viewpoint with the folder path it lives in."""
from pynavis import doc, output

rows = []
for folders, item in doc.walk_saved_viewpoints():
    rows.append(('/'.join(folders) or '(top level)', item.DisplayName))

output.print_table(rows, ['Folder', 'Viewpoint'])

doc.get_property#

doc.get_property(item, category_name, property_name)

Finds a property on a model item by its two display names and returns the VariantData, or None when the item has no such property. The names are the ones shown in the Properties window, so get_property(item, 'Element', 'Name') reads what you see there.

VariantData is not a Python value: read it through its own accessors, for example value.ToDisplayString() for text or value.ToDouble() when you know the type. Always guard for None, because a missing property is the normal case across a federated model.

export: NWD save, publish and viewpoint images#

Pure at import. Saves and publishes NWD files, and drives a best-effort viewpoint image export. Importing the module never needs a live session; every function lazy-imports what it needs.

export.save_nwd#

export.save_nwd(path, doc=None, version=None)

Needs Navisworks Saves the document as an NWD at path. version=None, the default, saves in the current format. Passing a four-digit year like 2023 saves in that release's file format instead; an unsupported year raises ValueError naming every year the running Navisworks actually supports, before any file is written.

export.save_nwd(path)                 # current format
export.save_nwd(path, version=2023)   # Navisworks 2023 format

export.publish_nwd#

export.publish_nwd(path, doc=None, **props)

Needs Navisworks Publishes the document as an NWD at path, the same file-menu action as save_nwd plus a title-metadata record and export scoping. An unknown keyword, or an expiry that is not a datetime.datetime, raises ValueError before any Navisworks import.

KeywordDefaultMeaning
keywords, comments, published_for, copyright NoneTitle-metadata strings; left untouched when not given.
allow_resaveTrue Whether the recipient may resave the published file.
display_on_openFalse Whether the properties dialog shows automatically when the file opens.
expiryNone A datetime.datetime after which the file refuses to open, or None for no expiry.
exclude_hiddenFalseSkip hidden items in the export.
embed_xrefsTrueEmbed cross-referenced files rather than linking them.
python
import datetime

from pynavis import export, toast

export.publish_nwd(path, published_for='Coordination review',
                   expiry=datetime.datetime(2027, 1, 1))
toast.success('Published', path)

export.viewpoint_image#

export.viewpoint_image(path, doc=None, width=1920, height=1080)

Needs Navisworks Writes the current view as an image at path. Any failure, a raised exception or a non-success export status, is reported as a single RuntimeError naming path.

Best-effort: the width and height option names are a guess

There is no .NET API route to an image export at all; this drives the same COM image-export plugin the file-menu action uses. The plugin's options are a schemaless name/value list with nothing to reflect, so this function's 'width'/'height' option names are the best available guess, not a confirmed fact, and have not been checked against a live export. If an exported image comes out at the plugin's own default size instead of width by height, that guess is what needs correcting.

forms: modal dialogs#

Blocks Dialogs, pickers, a cancellable progress window and custom XAML windows, all backed by the runtime's Fluent WPF windows: rounded corners, dark-mode titlebar, the Windows accent colour. No Navisworks API is touched, so these work even while a model is still loading, and the module imports outside a document.

Every one of them is modal. Your script stops on that line until the user answers. Cancelling is never an error: the answer comes back as None or False, and a well-behaved script exits quietly rather than toasting about it.

forms.alert and forms.confirm#

forms.alert(message, title='pyNavis')
forms.confirm(message, title='pyNavis')

alert shows a message with a single dismiss button and returns None. confirm asks a Yes/No question and returns True on Yes. Both coerce message with str(), so passing a number or a result object will not raise.

Use alert sparingly. A one-line status belongs in a toast, which does not steal focus; an alert is for something the user must acknowledge before anything else happens.

forms.ask_string#

forms.ask_string(prompt, default='', title='pyNavis')

A single-line text prompt. Returns the entered string, or None when the user cancels. An empty string is a real answer and is not the same as a cancel, so test if answer is not None rather than if answer when blank input is meaningful.

forms.save_file and forms.open_file#

forms.save_file(filter='CSV files (*.csv)|*.csv|All files (*.*)|*.*', default_name='', title='Save file')
forms.open_file(filter='All files (*.*)|*.*', title='Open file')

Standard Windows file dialogs. Both return the chosen path as a string, or None on cancel. filter uses the usual Win32 form, pairs of description and pattern separated by pipes.

save_file takes a default_name; open_file does not, because there is nothing to pre-name.

python
"""Writes the selection to CSV, asking first."""
from pynavis import forms, selection, toast

items = selection.get_items()
if not items:
    toast.error('Nothing selected')
elif forms.confirm('Export %d item(s) to CSV?' % len(items), 'Export'):
    path = forms.save_file(default_name='selection.csv')
    if path:                                  # None means cancelled: say nothing
        with open(path, 'w') as handle:
            for item in items:
                handle.write('%s\n' % item.DisplayName)
        toast.success('Exported %d item(s)' % len(items), path)

forms.select_from_list#

forms.select_from_list(items, title='Select', multiselect=False, prompt=None)

A searchable list picker. items is a list of strings, or of (label, value) pairs when the value you want back is not the label shown. Typing in the search box filters the list live.

Returns the picked value, or a list of values with multiselect=True, or None on cancel. A cancelled multiselect still comes back as None, never an empty list, so the two stay distinguishable.

python
from pynavis import clash, forms

tests = list(clash.walk_tests())
picked = forms.select_from_list(
    [(t.DisplayName, t) for t in tests], title='Pick a clash test')
if picked is not None:
    ...

names = ['structure.rvt', 'architecture.rvt', 'mep.rvt']
chosen = forms.select_from_list(names, title='Models to export', multiselect=True)
if chosen is not None:                      # None means cancelled
    for name in chosen:
        ...

forms.ask_options#

forms.ask_options(prompt, options, title='pyNavis')

A one-click choice between a handful of options, shown as buttons rather than a dropdown. Returns the chosen entry from options itself, not its index, or None on cancel.

Passing prompt=None asks for the quick-switch look instead: no title bar and no question, just the buttons in a hairline shell, for choices whose button labels are self-explanatory. Esc, or clicking anywhere outside the window, cancels it. Use a prompt whenever the labels alone would leave the user guessing what the choice does.

mode = forms.ask_options('Group by:', ['Level', 'Grid', 'Model'])
if mode is not None:
    ...

state = forms.ask_options(None, ['Section State', 'Hidden Items'])   # chromeless

forms.ask_number#

forms.ask_number(prompt, default=None, min_value=None, max_value=None, title='pyNavis')

A numeric prompt with live validation: the dialog itself refuses OK on a value outside [min_value, max_value], so your script never has to re-check the range. Returns a float, or None on cancel. Leave min_value or max_value out to leave that side open.

tolerance = forms.ask_number('Proximity tolerance (m):', default=6.0, min_value=0.0)
if tolerance is not None:
    ...

forms.pick_folder#

forms.pick_folder(title='Select folder', initial=None)

The standard Windows folder browser. Returns the chosen path, or None on cancel. initial is where the dialog opens; leave it out for the system default.

forms.progress#

forms.progress(title, label='')
forms.Cancelled(Exception)

A context manager showing a modal, cancellable progress window. It is distinct from output.progress: this one blocks the rest of Navisworks and carries its own Cancel button, which is what a batch the user started on purpose, and may want to stop partway through, calls for.

python
from pynavis import forms

with forms.progress('Exporting', 'Starting') as p:
    for index, item in enumerate(items):
        p.check()                                  # raises forms.Cancelled on Cancel
        export_one(item)
        p.update(float(index) / len(items), '%d of %d' % (index, len(items)))

The object bound by with ... as p has two methods. p.update(fraction, label=None) reports progress and also returns False once the user has clicked Cancel, for a loop that would rather test a return value than catch an exception. p.check() is the exception form: it raises forms.Cancelled the moment Cancel has been clicked, which is usually the cleaner shape when the loop has a single exit point.

label=None leaves the current label alone

p.update's label default is None, not an empty string, and None means keep whatever the window is already showing, not clear it. That matters right after a Cancel click: the window relabels itself to "Cancelling...", and a plain p.update(fraction) with no label must not wipe that back out. Pass an explicit empty string, p.update(fraction, ''), on the rare occasion you actually want to blank the label.

The window closes on every path out of the with block: normal completion, a Cancelled you let propagate, or any other exception. Catch Cancelled outside the block to report a clean stop rather than letting it read as a crash.

try:
    with forms.progress('Exporting') as p:
        for index, item in enumerate(items):
            p.check()
            export_one(item)
            p.update(float(index) / len(items))
except forms.Cancelled:
    toast.info('Export stopped early')
else:
    toast.success('Exported %d item(s)' % len(items))

forms.WPFWindow#

forms.WPFWindow(xaml_file)

Loads a .xaml file shipped inside the current command's bundle folder and wraps it as a live WPF window. Reach for this once chained prompts stop being enough, the step the cookbook takes for a settings form with several fields on it at once.

MemberDoes
win.find(name) The named XAML element, or None. Wraps FindName.
win[name] Same lookup, but raises KeyError instead of returning None when the name is not in the XAML, which is usually what you want at setup time.
win.show_dialog() Shows the window modally, owned by Navisworks and themed with the runtime's chrome, and returns its DialogResult.
win.close(result=None) Closes the window. Passing True or False first sets DialogResult, which is what show_dialog() returns to the caller.

xaml_file resolves relative to script.get_command_path(), the same folder icon.png and bundle.yaml live in, unless you pass an absolute path. A missing file raises IOError immediately, at construction time, rather than failing later on a lookup.

win = forms.WPFWindow('layout.xaml')
win['OkButton'].Click += lambda s, e: win.close(True)
win['CancelButton'].Click += lambda s, e: win.close(False)
if win.show_dialog():
    name = win['NameBox'].Text
You are back in raw WPF here

Once the window is loaded, every element it hands back is a live .NET object: set .Text, wire .Click, read .IsChecked, the way you would in any WPF code-behind. WPFWindow only solves loading the file and threading the runtime's dialog chrome onto it; it is not a data-binding layer.

geometry: the triangles of a model item#

Pure at import. One function, and the only route in the library to an item's actual faces rather than its bounding box. The walk itself is the COM primitives walk that section already tamed, reused here so the matrix-layout detection, the per-instance fragment filtering and the swallowed-exception guard are the same code in both places. What comes back is plain tuples, so anything you do with it afterwards is pure Python.

geometry.world_triangles#

geometry.world_triangles(item, budget=200000, note=None)

Needs Navisworks Every triangle of item and its geometry-carrying descendants, in world coordinates, as [((x, y, z), (x, y, z), (x, y, z)), ...]. Coordinates are model units.

budget is a ceiling on declared primitives: an item that declares more than that comes back as [] rather than spending the time. Pass a callable as note to receive one string per reason the walk gave up, which is the only way to tell the three empty cases apart.

An empty list means unknown, never "no faces"

[] comes back for three different reasons: the item declares more primitives than budget, the COM walk yielded nothing, or no matrix layout fitted. None of them means the item has no geometry. Treat an empty result as "could not measure" and fall back to item.BoundingBox(), rather than reporting zero faces to the user.

IronPython only

The walk subclasses a COM interface to receive vertices, which only the IronPython engine can do. A bundle calling this must leave engine: at its default or set it to ironpython; under CPython the callback cannot be built and you get an empty list with a note. This is the same constraint section carries, for the same reason.

python
"""Reports how many triangles the first selected object is made of."""
from pynavis import geometry, selection, toast

items = selection.get_items()
if not items:
    toast.info('Nothing selected', 'Pick one object and run this again.')
else:
    notes = []
    triangles = geometry.world_triangles(items[0], note=notes.append)
    if triangles:
        toast.success('%d triangle(s)' % len(triangles), items[0].DisplayName)
    else:
        toast.warning('Could not read the geometry', notes[0] if notes else '')

geometry.translate#

geometry.translate(items, vector, doc=None, name='Move items')

Needs Navisworks Slides items by (dx, dy, dz) world units, as the permanent transform Item Tools > Transform makes: it composes with any transform already on them, is saved in the NWF, shows in Item Tools, and undoes as one step named name. The source file is never touched. Returns how many items were moved; an empty items does nothing and returns 0.

geometry.reset_transform#

geometry.reset_transform(items, doc=None, name='Reset transform')

Needs Navisworks Removes every permanent transform override from items, putting them back where the source file has them, in one undo step. Returns how many items were reset.

memory: calculator-style selection memory#

Pure at import. The module splits cleanly in two: a pure half covering state, file naming, set maths and the cursor, which imports nothing from Navisworks; and an API half that imports the API lazily, inside the functions that need it. So import pynavis.memory works anywhere, and only the action functions require a document.

The model is a pocket calculator's memory: one register per document, stored on disk under %APPDATA%\pyNavis\memory\, with M+, M- and MR equivalents. An entry identifies one model item as {'m': source file name, 'i': model index, 'p': PathId string}.

memory.Result, MemoryFormatError and the constants#

memory.VERSION = 1
memory.MAX_CONTENTS_ROWS = 500
memory.MemoryFormatError(Exception)
memory.Result(level, message, detail='', count=0)

VERSION is the on-disk format version; deserialize refuses anything else. MAX_CONTENTS_ROWS caps how many rows contents resolves, because resolving path ids is the expensive part and nobody reads a 50,000-row table.

Result is one of the three result classes, and is not the same class as section.Result or viewstate.Result. MemoryFormatError is the one exception this module raises.

memory.memorize, memory.recall and memory.clear#

memory.memorize(doc=None)
memory.recall(doc=None)
memory.clear(doc=None)

Needs Navisworks The three core actions, each returning a Result. memorize sets the memory to the current selection; recall sets the selection to the memory; clear empties it.

None of them raises for an expected failure. An empty selection, an empty memory, or a memory whose items belong to a document that is not open all come back as an error-level Result with a detail line explaining what to do next. recall returns an info-level result when it found some but not all of the memorized items, with the count in .count and the missing models named in .detail.

memory.add, memory.subtract and memory.intersect#

memory.add(doc=None)
memory.subtract(doc=None)
memory.intersect(doc=None)

Needs Navisworks Set maths between the memory and the current selection: memory plus selection, memory minus selection, memory intersected with selection. Each returns a Result whose message carries the new size and the signed delta, for example Memory: 214 items (+37). An operation that empties the memory returns a warning-level result rather than a success.

memory.step_next and memory.step_prev#

memory.step_next(doc=None)
memory.step_prev(doc=None)

Needs Navisworks Walk through the memory one item at a time, wrapping at both ends. The returned Result is info-level and reads Item 4 of 27, with the item's display name as the detail.

These change the selection and move the camera

Stepping is not a read. Each call replaces the current selection with the single item it landed on and flies the camera to it. The zoom is best effort: if it fails, the step still happens. Entries that no longer resolve in this document are skipped rather than stepped onto, so the position counter counts resolvable items, not stored ones.

memory.contents and memory.contents_html#

memory.contents(doc=None)
memory.contents_html(rows, state)

contents returns (state, rows). state is the raw memory state dictionary; rows is a list of (model_name, display_name, link_html) triples, capped at MAX_CONTENTS_ROWS. For an entry that no longer resolves, the display name is '' and the link is None.

contents_html renders those rows as the table the Show tool prints, including a header line naming the document and the time the memory was saved, and a footer when the list was truncated. It is built by hand rather than through output.table_html, because the element links are live markup that a table builder would escape.

state, rows = memory.contents()
output.print_html(memory.contents_html(rows, state))

memory.save_as_set#

memory.save_as_set(name, doc=None)

Needs Navisworks Promotes the memory to a native Navisworks selection set with the given name, and returns a Result. Items that no longer resolve are dropped and counted in the detail line. An empty memory, or one where nothing resolves, returns an error-level result and creates no set.

memory.purge_targets and memory.purge#

memory.purge_targets(root=None)
memory.purge(root=None)

Pure purge_targets lists the memory files directly inside the root, matched against the exact naming pattern this module writes. It is never recursive and never returns anything that is not a memory file, which is what makes purge safe to point at a folder.

purge deletes every one of them, for every document, and returns a Result: success when all went, warning when some could not be deleted with the survivors named, info when there was nothing to purge.

memory.encode_items and memory.resolve_entries#

memory.encode_items(items, doc)
memory.resolve_entries(entries, doc)

Needs Navisworks The two halves of the persistence bridge, and the only functions here whose doc is required rather than optional. encode_items turns model items into entries. resolve_entries turns entries back into (items, missing_count, missing_model_names).

Resolution is deliberately forgiving about model order. The stored model index is only trusted when the model sitting at that index still has the recorded source file name; otherwise the model is found by name. Appending a model to a federated file, or reordering the models, therefore does not break a memory saved before the change.

The pure half: state, storage and set maths#

Pure None of these touch Navisworks, and all of them are usable from your own tools when you want the same on-disk format.

memory.new_state(document='', items=None, saved='')
memory.serialize(state)
memory.deserialize(text)
memory.apply_entries(state, entries)
memory.memory_root()
memory.path_for(document_path, root)
memory.load(document_path, root=None)
memory.save(state, root=None)
memory.key(entry)
memory.union(a, b)
memory.difference(a, b)
memory.intersection(a, b)
memory.step(cursor, count, delta)
FunctionReturnsNotes
new_statedict Keys version, document, saved, cursor (starting at -1) and items.
serializestrIndented, key-sorted JSON.
deserializedict Raises MemoryFormatError for invalid JSON, a non-object, a version other than VERSION, or a missing item list.
apply_entriesdict Replaces the contents, stamps the save time, resets the cursor to -1.
memory_rootstr%APPDATA%\pyNavis\memory.
path_forstr File name derived from the lowercased document path plus an 8-character hash, so same-named files in different folders do not collide and a differently cased path still lands on the same file. Unsaved documents share untitled.
loaddict The stored state, or a fresh empty one when there is no file. Lets MemoryFormatError travel when the file exists but is unreadable.
savestr Atomic: writes a temp file, then replaces. Returns the path written.
keytuple An entry's identity: lowercased model name plus path id. The set functions use it.
unionlistAll of a, then all of b that is new. Order preserved, deduped.
differencelistEntries of a that b does not contain.
intersectionlistEntries in both, in a's order.
stepint The next index, wrapping at both ends. -1 when count is zero or less.

output: the output window#

Pure at import. The module itself pulls in nothing but the internal markdown converter, so it imports anywhere; the print_* and progress functions then talk to the host's current output window, and table_html and format_table are pure and testable on their own.

Output is rich HTML rendered in a docked window. On a machine without the WebView2 runtime it degrades to readable plain text, and scripts never have to care which they got. Writing does not block and does not steal focus.

output.print_html, output.print_md and output.print_table#

output.print_html(html)
output.print_md(markdown)
output.print_table(rows, headers)

print_html renders an HTML fragment as-is; it is the escape hatch and the only one of the three that will render markup you pass it.

print_md renders markdown: # to ### headers, **bold**, *italic*, `code`, fenced blocks, - lists, [text](url) links and paragraphs. Everything is HTML-escaped before the markdown is converted, so raw markup inside your text can never become live HTML. That makes it the safe default for anything containing model data.

print_table renders table_html(rows, headers). Rows shorter than headers are padded, longer rows are truncated to the header count.

output.progress#

output.progress(fraction, label='')

Shows or updates the output window's progress bar. fraction runs from 0.0 to 1.0, and 1.0 completes and clears it. This is the sink every progress callback in the library is meant to feed; see progress callbacks for the shapes you need to adapt from.

output.element_link(item, label)

Returns the HTML for a clickable link that selects the given model item in the model when clicked. It returns a string, so you have to print it yourself, and it must go through print_html rather than print_md or print_table, both of which escape their input.

output.print_html('Worst clash: ' + output.element_link(item, item.DisplayName))

output.table_html and output.format_table#

output.table_html(rows, headers)
output.format_table(rows, headers)

Pure Both take any cell types and stringify them, both pad short rows, and neither touches the window, so both are unit-testable anywhere.

table_html returns the table markup print_table prints. Numeric columns are tagged so the stylesheet right-aligns and tabulates them, and a column counts as numeric only when every non-empty cell in it parses as a number. Alignment therefore follows the data rather than the column's position, which is why a column of ids that happens to contain one 'n/a' renders left-aligned.

format_table returns an aligned plain-text table with a dashed header rule. Reach for it when you are writing to a file or a log rather than to the window.

python
"""Prints a clash summary to the output window."""
from pynavis import clash, output

output.print_md('## Clash summary')

rows = [(t['name'], t['total'], t['by_status'].get('New', 0))
        for t in clash.summarize()]
output.print_table(rows, ['Test', 'Results', 'New'])

output.chart_bar, chart_line, chart_pie and chart_doughnut#

output.chart_bar(labels, values, title=None)
output.chart_line(labels, series, title=None)
output.chart_pie(labels, values, title=None)
output.chart_doughnut(labels, values, title=None)

Theme-aware SVG chart cards, drawn from the same palette as the rest of the window (--chart-1 through --chart-6, cycling for a seventh series and beyond) and printed through print_html under the hood, so each call opens or appends to the output window exactly like any other print_* function.

chart_bar, chart_pie and chart_doughnut take one series: parallel labels and values lists. chart_line takes several at once, as series, a dict of {name: [values]} sharing one labels axis. chart_doughnut is chart_pie with a hole in the middle; both take the same arguments.

python
from pynavis import clash, output

summaries = clash.summarize()
output.chart_bar(
    [s['name'] for s in summaries],
    [s['total'] for s in summaries],
    title='Clashes per test')

output.chart_line(
    ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
    {'New': [412, 380, 210, 96], 'Resolved': [0, 88, 240, 340]},
    title='Clash trend')
A longer series does not skew the scale

chart_line shares one label axis across every series. When a series has more values than labels, only the leading values up to the label count are drawn, and the vertical scale is computed from that same truncated set, never from the full series. A long, never-plotted tail therefore cannot inflate the peak and compress the lines you can actually see.

output.print_image#

output.print_image(path, caption=None)

Embeds a PNG, JPEG, GIF or SVG file in the output window as a data URI, with an optional caption underneath. Raises ValueError for any other extension.

output.print_image(path, caption='Section cut through Level 3')

output.print_code#

output.print_code(text)

A monospace, horizontally scrolling code block. The text is escaped, so it is safe for anything you generate, including a stack trace or the raw markup behind an export.

output.save#

output.save(path)

Writes everything printed so far as a standalone HTML file, transcript and all, that opens correctly in a browser with no pyNavis or Navisworks running. Use it when a report needs to leave the machine, for example to attach to an email.

output.set_title#

output.set_title(text)

Retitles the output window. The default title is pyNavis - <button title>; call this when a report is about something more specific than the button that produced it, for example the document name or the test just summarised.

overlay: lines drawn in the 3D view#

Pure at import; it needs only the PyNavis.Runtime assembly, never the Navisworks API. An overlay item is a set of world-coordinate line segments plus an optional label, redrawn on every frame by the pyNavis overlay plugin, so it follows orbit, pan and zoom instead of sitting still on the glass. Points are plain (x, y, z) tuples in model units.

Every function returns True when the overlay plugin is loaded and False, with a line in the log, when it is not. A False is what a loader that has not been restarted since pyNavis was updated looks like: your script's own result is still correct, only the drawing is missing, so report the result and carry on rather than treating it as a failure.

overlay.add#

overlay.add(tag, segments, label=None, label_at=None, anchor=None)

Draws segments under tag, replacing any earlier item with that same tag. segments is a list of (start, end, dashed) triples, where start and end are points and dashed is a boolean. label is drawn only when label_at is given too; one without the other is ignored.

The tag is the whole identity model. There is no handle to hold and no item to update: draw again under the same tag to replace what is there, and pass the tag to clear to remove it.

overlay.dimension#

overlay.dimension(tag, start, end, label, extension_to=None, anchor=None)

The common case spelled once: a solid line from start to end with label pinned at its midpoint, plus a dashed extension line from end to extension_to when you pass one. It is add underneath and obeys the same tag rule.

The anchor argument#

Both drawing functions take an optional anchor, a (first, end) pair of points. An item given one belongs to the native point-to-point measurement between those two points, and it disappears on the first frame where that measurement changes or is cleared. An item with no anchor stays until you clear it.

That is what lets a tool annotate the user's own measurement and then get out of the way on its own, with nothing left to clean up. Pair it with pick.measure_points, which asks the user for that measurement and leaves it on screen for the drawing to hang from.

overlay.clear and overlay.redraw#

overlay.clear(tag=None)
overlay.redraw()

clear removes the items under tag, or every item when tag is None. redraw asks the view to repaint its overlay once pending events have run.

Nothing appears until you call redraw

Adding, replacing or clearing an item changes the registry, not the screen. Without a redraw() the change shows up only when the user nudges the view, which reads as the tool having done nothing. Finish every batch of overlay calls with one redraw(); one call covers however many items you touched.

python
"""Draws a labelled dimension between the two ends of a straight run."""
from pynavis import overlay, toast

start = (12.0, 4.0, 3.2)
end = (28.5, 4.0, 3.2)
length = 16.5

drawn = overlay.dimension('run-length', start, end, '%.2f m' % length)
overlay.redraw()

if drawn:
    toast.success('Run is %.2f m' % length)
else:
    toast.warning('Run is %.2f m' % length,
                  'Restart Navisworks to finish updating pyNavis.')

panes: dock panels#

Pure at import. Reads and writes the live pane registry, so importing it needs only the PyNavis.Runtime assembly, present wherever pyNavis scripts run. A panel is a *.dockpane bundle: pane.xaml for content, an optional script.py that receives the panel as __pane__, and a ribbon toggle that follows its visibility. See Buttons, stacks and pulldowns for the bundle shape.

Unsafe with no argument from a panel's own event handler

Every function below defaults bundle_key to whichever pyNavis command most recently ran, not to the panel whose handler is calling it. That default is correct while script.py itself is still executing, but a Click handler wired up inside it fires later, after the user may have run other tools. From inside a panel's own event handler, pass bundle_key explicitly, or use __pane__.Visible instead.

panes.show, panes.hide and panes.toggle#

panes.show(bundle_key=None)
panes.hide(bundle_key=None)
panes.toggle(bundle_key=None)

show opens the panel and brings it to the front of its dock tab; hide closes it; toggle does whichever of the two the current state calls for. All three return False when the bundle holds no slot - genuine overflow, or a slot minted this session that is still waiting on the restart that registers it - and True otherwise.

from pynavis import panes

panes.show('MyExt.tab/MyPanel.panel/Live_View.dockpane')

panes.is_visible#

panes.is_visible(bundle_key=None)

True while the panel is on screen. Reads the same live state a Navisworks close button or a workspace change updates directly, so it is never stale between clicks.

panes.slot_of and panes.slot_count#

panes.slot_of(bundle_key=None)
panes.slot_count()

slot_of is the 1-based panel slot a bundle claimed, or 0 when every slot was already taken by the time it was resolved. slot_count is how many panel slots this Navisworks session has in total: five shipped, plus any generated by add_slots in a previous session.

panes.add_slots#

panes.add_slots(count)

Generates count additional panel slots into a satellite assembly and returns the new total slot count. This is what the Panel slots button on the pyNavis tab calls. The new slots do not exist as loadable plugin types until Navisworks restarts - Navisworks only discovers dock panes by scanning types at startup - so a bundle that claims one of them on the next Reload still shows "ready after restart" until then.

Locked once loaded, then it needs a manual deletion

In the session that minted it, the satellite is just a file: call add_slots again before the restart and it is simply rewritten with the new count. The lock begins with the restart that activates it: Navisworks loads every DLL the bundle declares at startup to scan it for attributed types, so from then on the satellite is loaded and locked for the whole process, whether or not a sixth panel was ever opened, and restarting does not release it, because the restart loads it straight back. From that point the slot count cannot be changed from inside Navisworks at all.

add_slots raises rather than silently doing nothing, and it names the remedy: close Navisworks, delete PyNavisPanes.dll from %APPDATA%\Autodesk\ApplicationPlugins\pyNavis.bundle\Contents\<year>, start it again, then set the count you want. Nothing here needs administrator rights: the satellite is written beside the loader inside your own profile, and registered in the bundle's PackageContents.xml by add_slots itself.

pick: ask the user to click a point#

Pure at import; it needs the PyNavis.Runtime assembly and WPF, never the Navisworks API. The pick runs on the pyNavis pick tool, a Navisworks tool plugin that borrows the document's tool for the length of one click and hands it straight back. It is meant to feel like the native Measure tool: the same vertex and edge snapping, the same measure cursors, a marker under the pointer showing what would be picked, and the view still orbits and pans while the user lines the shot up.

Cancelling is never an error. Esc, a right-click, or the user choosing another tool ends the pick, and you get None. The one thing that raises is Unavailable.

Navisworks 2023 and 2024 pick the surface only

The snapping pick API arrived in Navisworks 2025. On 2023 and 2024 a pick still works and still returns the point under the cursor, but hit.snap is always None and hit.normal is always None, and the cursor shows no vertex or edge markers. Write tools so that both stay optional.

pick.Hit#

pick.Hit(point, normal, item, snap)

What a finished pick hands back. You never construct one; you read it.

AttributeTypeMeaning
.pointtuple (x, y, z) floats in model units. Always present.
.normaltuple or None The surface normal at the click, or None when there is none.
.itemModelItem or NoneWhat was clicked on.
.snapstr or None Which snap fired: 'vertex', 'edge', 'line-vertex', 'line-middle', 'arc-center', or None for a free point on a face.
Only point is guaranteed

Everything except .point can be None, and measure_point returns None for all three of them every time. Normals in particular are unreliable on an edge: if you need a direction, derive it from two picked points rather than trusting one normal.

pick.point#

pick.point(prompt=DEFAULT_PROMPT)

Blocks Needs Navisworks Blocks until the user clicks, then returns a Hit, or None on cancel. The prompt is shown as an info toast alongside CANCEL_HINT, so the way out is never a guess.

It blocks by pumping a nested WPF dispatcher frame, which is the Python half only: the runtime underneath is event-based. If a nested message loop ever misbehaves in a Navisworks release, point_then is the fallback and the change is a library edit and a Reload, not a rebuild.

python
"""Copies the coordinates of a picked point to the clipboard."""
from pynavis import pick, script, toast

hit = pick.point('Click a point to copy its coordinates')
if hit is not None:                            # None means cancelled: say nothing
    x, y, z = hit.point
    text = '%.3f, %.3f, %.3f' % (x, y, z)
    script.clipboard_copy(text)
    toast.success('Point copied', text)

pick.point_then and pick.cancel#

pick.point_then(callback, prompt=DEFAULT_PROMPT)
pick.cancel()

Needs Navisworks point_then starts a pick and returns immediately, calling callback(hit_or_None) when the pick ends. It returns the session object, which answers session.HasEnded while the user is still lining the click up. This is the form to use from a dock panel or a modeless window, where blocking the UI thread is not an option.

cancel() ends whatever pick is running. The callback still fires, with None, so a script that cleans up in its callback still gets the chance. Starting a second pick cancels the first the same way.

pick.measure_point#

pick.measure_point(prompt=DEFAULT_PROMPT)

Blocks Needs Navisworks The same question asked through the host's own Measure tool instead. The document is put into Measure, Point to Point, so the snapping, the cursors and the marker are Navisworks' own rather than an imitation of them, and nothing depends on the pick plugin being loaded at all. The click is read back off the current measurement, the measurement is reset so the next click starts a new one, and the tool the user had is restored.

The price is that only hit.point is known: a measurement carries no normal, item or snap kind, so those three are always None. Reach for this when the pick plugin may not be deployed, or when a point is genuinely all you need.

pick.measure_points#

pick.measure_points(prompt=DEFAULT_PROMPT, second_prompt=SECOND_PROMPT)

Blocks Needs Navisworks The same Measure, Point to Point drive, asked for a whole line. It returns (first, end) as two 3-tuples once the user has clicked both points, or None on Esc, a right-click or a change of tool. prompt is toasted at the start and second_prompt the moment the first point lands. Any measurement already on screen is dropped before it starts, because a caller asking for a line wants a new one.

Unlike measure_point, the finished measurement is the deliverable: it stays on screen with its native readout and the document stays on the Measure tool, so the caller can draw next to it with an anchored overlay.dimension and the user can measure again straight away. A first point left behind by a cancel is dropped so the next click does not close it into a line. Face Distance is the reference user.

python
"""Reports the straight-line length of a measurement the user makes on request."""
from pynavis import pick, toast

line = pick.measure_points('Click the first point', 'Now click the second point')
if line is None:
    return                              # cancelled: say nothing
(x1, y1, z1), (x2, y2, z2) = line
toast.success('Measured', '%.3f' % ((x2 - x1) ** 2 + (y2 - y1) ** 2 + (z2 - z1) ** 2) ** 0.5)

pick.Unavailable and the constants#

pick.Unavailable(RuntimeError)
pick.DEFAULT_PROMPT = 'Click a point in the view'
pick.CANCEL_HINT = 'Esc or right-click to cancel.'
pick.SECOND_PROMPT = 'Now click the second point'
pick.MEASURE_POLL_MS = 50

Unavailable is raised by point and point_then, and only by them, when the running loader has no pick plugin in it. Navisworks scans for plugins only at startup, so the remedy is always a restart; the exception's message says exactly that and is safe to toast as it stands. measure_point and measure_points never raise it, because they use no plugin.

MEASURE_POLL_MS is how often measure_point and measure_points read the measurement back while they wait, since the API offers no change event to subscribe to.

python
"""Picks a point, falling back to the native Measure tool when the plugin is missing."""
from pynavis import pick, toast

try:
    hit = pick.point('Click the point to measure from')
except pick.Unavailable:
    hit = pick.measure_point('Click the point to measure from')

if hit is not None:
    toast.success('Picked', '%.3f, %.3f, %.3f' % hit.point)

pick.measured_click#

pick.measured_click(before, after)

Pure What one poll of the measurement says happened. before and after are each a (first, end) pair whose members are a 3-tuple or None. It returns ('waiting', None), ('cancelled', None) or ('point', (x, y, z)).

It is the decision measure_point makes on every tick, pulled out so it can be tested without Navisworks: a click either completes a half-finished measurement, becoming its end point, or starts a new one, becoming its first point, and a measurement that was there and is now gone is the user's right-click saying never mind. You need it directly only if you are polling the measurement yourself.

pick.measured_line#

pick.measured_line(before, after)

Pure The same reading for a whole measurement, the decision measure_points makes on every tick. It returns ('waiting', None), ('first', (x, y, z)) when a first point has landed and the end is still to come, ('line', (first, end)) once both are there, or ('cancelled', None) when a measurement that was there is gone. A pair that was already complete when the pick began reads as waiting: it is the stale line the pick was started over, not an answer.

props: model item properties#

Pure at import. Reads every property on a ModelItem as a plain Python value instead of a raw VariantData, and writes custom property tabs. Reading needs no lazy import of its own: PropertyCategories access is plain attribute access on an item you already hold, the same access doc.get_property uses. This module supersedes doc.get_property for new code: props.get hands back an already-coerced value, so callers no longer need to know VariantData's own accessors.

props.categories#

props.categories(item)

Needs Navisworks [(display_name, name), ...] for every property category on item, in the item's own order.

props.get, props.all_of and props.value_map#

props.get(item, category, prop, by_display=True)
props.all_of(item)
props.value_map(item, spec)

Needs Navisworks get returns the coerced Python value of one property, or None when the category or property is not present. by_display=True, the default, looks up by the display names shown in the Properties window, matching doc.get_property; by_display=False switches to the internal name instead.

The coerced value is a str, int, float, bool, datetime.datetime, or an (x, y, z) float tuple for a point property. Anything this module does not specifically recognise falls back to the property's own display-string text.

all_of returns every property on item as a flat, table-ready list of {'category', 'property', 'value'} rows, by display name, in the item's own category and property order. It is the shape output.print_table or an Excel export wants.

value_map pulls several named properties into one dict at once, from spec = {key: (category, prop), ...}. A pair not present on item maps to None, same as get. It always looks up by display name; call get directly for a by_display=False lookup.

python
from pynavis import output, props, selection, toast

items = selection.get_items()
if not items:
    toast.info('Nothing selected')
else:
    rows = [[item.DisplayName, props.get(item, 'Item', 'Type'),
             props.get(item, 'Element', 'Level')] for item in items]
    output.print_table(rows, ['Object', 'Type', 'Level'])

props.get_custom, set_custom and remove_custom#

props.get_custom(item, tab_name)
props.set_custom(items, tab_name, values, doc=None, progress=None)
props.remove_custom(items, tab_name, doc=None, progress=None)

Needs Navisworks A user-written property tab, the kind you see as its own row in the Properties window under a tab name you chose. get_custom returns {name: value} for the tab, or None when item does not carry it; it reads through the same PropertyCategories access get/all_of use, because once written a custom tab shows up there like any other category.

set_custom and remove_custom are the write side, and writing properties cannot go through the .NET API at all: both lazy-import the COM bridge instead. Both batch over items inside one transaction, so a bulk write is one undo entry, and both accept an optional progress(i, total) called after every item (1-based). Both return (count, errors): a per-item exception, which a COM callback can otherwise lose silently, is caught and recorded rather than aborting the rest of the batch.

set_custom replaces the whole tab, it does not merge

set_custom(items, tab_name, values) writes values as the entire tab_name tab: one write call per item, and that call carries exactly the names in values. An existing same-named tab on an item is replaced outright. A property that was on the tab before but is left out of values this time disappears, it does not survive from an earlier write. Read the tab back with get_custom first, merge in your own new values, and pass the whole merged dict to set_custom when you mean to add one field to an existing tab.

values is {name: str/int/float/bool}. An empty or whitespace-only name, or a value of any other type, raises ValueError naming the offender before any Navisworks import. The properties are written in name order, not dictionary order, so two equivalent calls produce the same tab.

python
from pynavis import props, selection, toast

items = selection.get_items()
if not items:
    toast.info('Nothing selected')
else:
    written, errors = props.set_custom(items, 'pyNavis', {'Checked By': 'ME', 'Rev': 3})
    if errors:
        toast.warning('Tagged %d, %d failed' % (written, len(errors)), errors[0])
    else:
        toast.success('Tagged %d object(s)' % written)

script: the running command#

Pure at import. The runtime records the command context on a host singleton before every run and this module reads it back. It needs only the PyNavis.Runtime assembly, which is loaded wherever pyNavis scripts run, never the Navisworks API.

Most of what this module exposes is also injected into your script's scope as __pynavis__ and friends; see The script environment. Use this module when you are inside an imported helper module rather than in script.py itself, where the injected globals are not in scope.

script.get_script_path, get_command_path and get_title#

script.get_script_path()
script.get_command_path()
script.get_title()

get_script_path() is the full path of the executing script.py. get_command_path() is the bundle folder that contains it, which is where icon.png, bundle.yaml and any data files you ship alongside your tool live. get_title() is the resolved title, after bundle.yaml, __title__ and the folder name have been considered.

import os
from pynavis import script

template = os.path.join(script.get_command_path(), 'report.html')

script.get_host and script.get_host_version#

script.get_host()
script.get_host_version()

get_host() returns the host object itself, the same one injected as __pynavis__, and works even outside a script scope. get_host_version() is the pyNavis runtime assembly version as a string. Note that this is the runtime version, which is not the same number as pynavis.__version__, the library version.

script.is_dark_theme#

script.is_dark_theme()

True when the host UI is rendering dark. Theme anything you draw yourself with it, and pick the matching icon variant when you build a window of your own.

script.reload_pynavis#

script.reload_pynavis()

Rescans every extension folder and rebuilds the ribbon, with no Navisworks restart. This is what the Reload button on the pyNavis panel calls. Calling it from your own script is legitimate, for example after a tool has written a new bundle to disk.

script.get_toggle_state and script.set_toggle_state#

script.get_toggle_state()
script.set_toggle_state(on)

The session-scoped boolean behind a *.toggle bundle. get_toggle_state() is True while this toggle is on; set_toggle_state(on) sets it and flips the ribbon button between the bundle's on and off art immediately. See toggle: a button with on/off state for the bundle shape and its icon files.

Both act on the bundle that is currently running, which only means something inside that toggle's own script.py or config.py: there is no way to address a specific toggle from a hook, from startup.py or from another bundle. The state lives for the session, surviving Reload but not a restart. A toggle that must come back on after a restart stores the flag with get_config/save_config and re-applies it on its own first click of the new session.

from pynavis import script, toast

on = not script.get_toggle_state()
script.set_toggle_state(on)
toast.info('Snap ' + ('on' if on else 'off'))

script.get_envvar and script.set_envvar#

script.get_envvar(name, default=None)
script.set_envvar(name, value)

A small key/value store shared by every pyNavis script for the rest of the Navisworks session. Use it to pass a value from one tool to another without going through a file, for example remembering the last folder a user picked so a second tool can default to it.

get_envvar returns default when name was never set. set_envvar(name, None) removes it. Nothing here survives a restart; a value that must outlive the session belongs in get_config or settings instead.

from pynavis import script

last_folder = script.get_envvar('last_export_folder', default=None)
...
script.set_envvar('last_export_folder', chosen_folder)

script.clipboard_copy, script.clipboard_text, script.open_url, script.show_in_explorer and script.get_bundle_file#

script.clipboard_copy(text)
script.clipboard_text()
script.open_url(url)
script.show_in_explorer(path)
script.get_bundle_file(name)

Five small conveniences that reach outside Navisworks. clipboard_copy puts text on the Windows clipboard and clipboard_text reads it back, returning '' when the clipboard holds no text or cannot be read at all. That read never raises: the clipboard belongs to the whole machine, so another application holding it open must not take your tool down with it, which matters for a tool that checks it on every run. open_url opens a URL in the default browser. show_in_explorer opens Explorer at a file, with that file selected, or at a folder when the path is not a file. All three return None.

get_bundle_file(name) is the full path of a file that sits beside script.py in the current command's bundle folder, for example a template or a lookup table shipped with the tool, or None when no such file exists. It is what forms.WPFWindow uses internally to find a bundle-relative XAML file.

from pynavis import script, toast

path = write_report()
script.show_in_explorer(path)
script.clipboard_copy(path)
toast.success('Report saved', 'Path copied to the clipboard.')

script.get_config, script.save_config and script.reset_config#

script.get_config(defaults)
script.save_config(values)
script.reset_config()

A settings store scoped to the current command automatically, so a tool no longer has to invent and thread a settings key of its own the way settings.load / settings.save require. The key is derived from the bundle's own location, so two bundles never collide and nobody has to write it down.

get_config(defaults) is settings.load with the key filled in; save_config(values) is settings.save the same way. reset_config() deletes the saved file, if there is one, so the next get_config call returns fresh defaults.

config.py
"""Options for this tool, via the per-bundle config store."""
from pynavis import forms, script, toast

DEFAULTS = {'category': 'Item', 'property': 'Type'}

values = script.get_config(DEFAULTS)
category = forms.ask_string('Property category:', values['category'])
if category is not None:
    values['category'] = category
    script.save_config(values)
    toast.success('Saved')

Reach for pynavis.settings instead when two bundles must deliberately share one settings file, or when you want to hand-pick a key that survives a bundle being renamed or moved. get_config/save_config trade that control for never having to think about the key at all.

All three resolve the key at the moment they are called, which is the right answer only while script.py is still running. From a dialog handler, a panel handler or a timer, use script.bind() instead.

script.get_data_file, get_document_data_file, store_data, load_data and data_exists#

script.get_data_file(suffix='json')
script.get_document_data_file(suffix='json')
script.store_data(key, value, per_document=False)
script.load_data(key, default=None, per_document=False)
script.data_exists(key, per_document=False)

A small per-tool JSON store for data that is not really a setting, the kind of thing get_config/save_config is not the right place for, such as a cache or a result you want to reuse the next time the tool runs. store_data, load_data and data_exists address one JSON-serialisable value at a time by its own key inside that file.

With per_document=False, the default, the data file is shared across every document, one file per tool. With per_document=True there is one file per tool per document, keyed off doc.get_filename(), which needs a live Navisworks session: get_document_data_file and any call with per_document=True only work in-host, never in a standalone test.

get_data_file and get_document_data_file return the path of the underlying file itself, for when you want to read or write it directly instead of through the key-based helpers.

Like the config helpers, these resolve the running command when they are called, so from a handler that fires after script.py has returned, go through script.bind().

from pynavis import script

if script.data_exists('audit_cache', per_document=True):
    cached = script.load_data('audit_cache', per_document=True)
    missing = cached['missing']
else:
    missing = full_audit()                     # your own slow scan
    script.store_data('audit_cache', {'missing': missing}, per_document=True)

script.bind and script.BoundScript#

script.bind()
script.BoundScript(own)

Pins this command's identity so the settings and data helpers keep working in code that outlives script.py. Call bind() at the top of your script and use the object it returns from then on.

The module-level helpers are wrong in anything that fires later

get_config, save_config, reset_config, store_data, load_data and data_exists ask the runtime "which command is running?" at the moment they are called, and the runtime goes on answering with the most recently run command long after it finished. That is correct while script.py is still executing and wrong in everything that fires afterwards: a modeless dialog's Click handler, a dock panel's handler, a timer callback. By then the user may have run another tool, and the call reads or overwrites that tool's settings. A BoundScript took the answer while it was still correct.

The bound object carries the same six methods, minus the doc-free ones you do not need: get_config(defaults), save_config(values), reset_config(), store_data(key, value, per_document=False), load_data(key, default=None, per_document=False) and data_exists(key, per_document=False). They behave exactly like the module-level ones, against the command that was running when bind() was called.

from pynavis import forms, script

me = script.bind()                         # at the top, while the answer is still right

win = forms.WPFWindow('layout.xaml')

def on_close(sender, args):                # fires long after script.py returned
    me.save_config({'width': win.window.Width})

win.window.Closed += on_close
win.show_dialog()

The same rule applies to panes, for the same reason and with the same fix: pass the bundle key explicitly rather than letting it default.

script.get_logger#

script.get_logger()

A levelled logger for the current command, distinct from a toast or the output window: it always writes to the pyNavis log file, and its warning and error calls also print as a coloured line in the output window, but only when one is already open. Calling get_logger() more than once in the same run returns the same logger, keyed by the name the lines are logged under.

Each line is labelled with whoever is running: a button's title for an ordinary run, hook <extension>:<event> inside a hook, and startup <extension> inside startup.py. Hooks and startup.py are not commands and have no title of their own, so they used to be labelled with whichever button the user last clicked - a hook line reading [Reload] because Reload happened to be the last thing pressed.

MethodDoes
logger.debug(message)Log file only, and only above the threshold.
logger.info(message)Log file only.
logger.success(message) Log file only. Tagged separately from info so a sink can style it, but it is never filtered out by the level threshold.
logger.warning(message) Log file, plus an amber line in an open output window.
logger.error(message) Log file, plus a red line in an open output window.
logger.set_level(level) Lowest level that gets through: 'debug', 'info', 'warning' or 'error'. Starts at 'info', so debug calls are silent until you lower it.
from pynavis import script

log = script.get_logger()
log.set_level('debug')
log.debug('Starting export of %d item(s)' % len(items))
...
if missing:
    log.warning('%d item(s) had no source model' % len(missing))

Reach for this over a toast when you want a permanent record on disk, and over output.print_* when the message is a diagnostic for you rather than a report for the user: a logger call never opens the output window on its own, it only adds a line to one that is already open.

section: section planes fitted to a selection#

Pure at import. Navisworks' own Fit Section to Selection gives you a world-aligned box, so a duct run on the diagonal gets a box far larger than the run and four planes that meet it at an angle. This module fits the box to the objects instead: the minimum-area rectangle of their footprint, rotated about Z only so the horizontal cut stays level, written to the six individual clip planes rather than to Box mode, which is what lets a user switch one plane off afterwards without losing the fit.

The pure half works on plain tuples and runs anywhere. The API half lazy-imports Navisworks, so importing the module never needs it.

Section bundles must run on IronPython

The triangle walk subclasses a COM interface, which only the IronPython engine can do. A bundle that calls collect_points, fit_to_selection or plan_to_selection must leave engine: at its default or set it explicitly to ironpython. Under CPython the walk cannot be built and the fit silently degrades to bounding boxes, which produces a box square to the world: exactly what this module exists to avoid.

section.Result and the section constants#

section.TOOL = 'section_planes'
section.DEFAULTS = {'padding_mm': 150.0, 'snap_degrees': 1.5}
section.KEEP_INWARD = True
section.TRIANGLE_BUDGET = 150000
section.Result(level, message, detail='', count=0)

TOOL and DEFAULTS are the settings key and defaults for settings.load, so a config script reads and writes the same store the shipped tool does. KEEP_INWARD is the empirical constant deciding which way a clip plane's normal points to keep the volume behind it; the .NET API documents neither the sign nor the Custom alignment. TRIANGLE_BUDGET is where the triangle walk stops paying for itself and the fit drops to fragment boxes.

Result is another of the three result classes, and is not memory.Result or viewstate.Result.

section.fit#

section.fit(points, padding=0.0, snap_degrees=1.5, snap_ratio=0.01)

Pure The oriented box for a list of world (x, y, z) points, or None when points is empty. padding grows all six faces, so every size gains twice the padding.

The box is a dictionary:

KeyTypeMeaning
yawfloatRadians about Z. 0.0 means square to the world.
min, maxtuple The extents in the yaw-rotated local frame, not in world coordinates.
centertupleAlso in the local frame.
sizetuple(dx, dy, dz), padding already included.
The box is local, not world

Every extent is measured along the box's own axes. Feed center, min or max to anything expecting world coordinates and you will place geometry in the wrong spot whenever yaw is non-zero. Put a point or a direction back into world coordinates with to_world, or get the enclosing world-aligned box with world_aabb.

section.min_area_yaw and section.convex_hull#

section.min_area_yaw(points, snap_degrees=1.5, snap_ratio=0.01)
section.convex_hull(points)

Pure min_area_yaw returns the yaw in radians of the smallest-area rectangle enclosing the points' XY footprint, always in [0, pi/2) because a rectangle repeats every 90 degrees. It uses rotating calipers over the convex hull, which is exact rather than a search: the minimum-area rectangle always has a side flush with a hull edge.

It returns 0.0 when the fit is not worth having: within snap_degrees of an axis, or no more than snap_ratio better than the axis-aligned box. Without that snap, anything orthogonal comes out two degrees askew and every plane the user then drags is off-square for no gain.

convex_hull is the counter-clockwise hull of (x, y) points by Andrew's monotone chain. Collinear points are dropped and the first point is not repeated, so every edge it returns is a real edge.

section.faces, section.to_world and section.world_aabb#

section.faces(box, inward=None)
section.to_world(box, point)
section.world_aabb(box)

Pure faces returns six (origin, normal) world pairs, one per box face, in the fixed order +X, -X, +Y, -Y, top, bottom. Each origin sits at the centre of its face. inward=None means use KEEP_INWARD; with the normal pointing inward it points at the volume the section keeps.

to_world puts a point from the box's local frame back into world coordinates. It is equally correct for direction vectors, because the transform is a rotation about Z with no translation in it.

world_aabb returns the world axis-aligned (min, max) enclosing the box's eight corners: what ZoomBox and ClipPlaneSet.Range want, since both take a plain box.

section.compact and section.agrees#

section.compact(points)
section.agrees(points, box_min, box_max, slack=0.05)

Pure compact reduces a point list to what fit actually reads: the XY hull plus the lowest and highest points. The fit is identical and the memory is bounded, which is what lets the geometry walk run over a huge selection without piling up millions of vertices.

agrees returns True when the collected points land inside a given bounding box, give or take slack as a fraction of the box's longest side. It is the guard on the COM walk: the storage order of the local-to-world matrix is undocumented, and getting it wrong does not raise, it silently scatters points, which would section the model somewhere the user did not select.

section.pick_source#

section.pick_source(primitive_count, has_com, budget=TRIANGLE_BUDGET)

Pure Returns 'triangles', 'fragments' or 'boxes'. Triangles when COM is available and the selection is within budget; fragments when it is over budget; boxes when there is no COM at all or nothing has geometry.

section.describe#

section.describe(box, count, source, unit_scale)

Pure The toast detail line: what was fitted, its size in metres, and how far the box had to turn, plus a note when the fit came from a blunter source. unit_scale is document units per metre.

section.collect_points#

section.collect_points(items, notes=None)

Needs Navisworks Returns (points, source), where source is one of the three pick_source strings. It degrades in one direction only: triangles, then fragment boxes, then item bounding boxes. Anything that throws, or that lands outside the API's own bounding box, drops a level rather than sectioning the model somewhere wrong.

Pass a list as notes to collect the reason for every drop. That matters, because the bottom tier cannot produce an angle at all: the corners of axis-aligned boxes are themselves axis-aligned, so the fit comes out square to the world however the objects run. A silent fallback there looks like the tool working when it has actually given up.

section.fit_to_selection, plan_to_selection and clear#

section.fit_to_selection(items, values, doc=None)
section.plan_to_selection(items, values, doc=None)
section.clear(doc=None)

Needs Navisworks The three actions, each returning a Result. values is the settings dictionary, normally settings.load(section.TOOL, section.DEFAULTS).

fit_to_selection fits the six planes to the selection and switches them on. plan_to_selection does the same, then looks straight down at the box in orthographic projection with the box's own up direction, so the objects sit square on screen instead of skewed. clear switches sectioning off without disturbing the camera, and returns an info-level result when it was already off.

Both fitting actions return a warning, not a success, when the fit fell back to bounding boxes: the box cannot be turned in that case, so the planes come out square to the world and the tool has quietly done nothing Navisworks' own Fit Section to Selection does not already do. The reason is in .detail. A view offering fewer than six clip planes also returns a warning, with .count holding how many were written.

selection: the current selection#

Needs Navisworks Three functions, and the module most tools open with. Importing it loads the Navisworks API.

selection.get_items#

selection.get_items(doc=None)

The currently selected items as a plain Python list of ModelItem, not a live .NET collection. It is a snapshot: changing the selection afterwards does not change the list you hold. An empty selection gives an empty list, so if not items is the idiomatic guard.

selection.set_items#

selection.set_items(items, doc=None)

Replaces the current selection with the given items. This is a replace, not an add: pass get_items() + extras when you mean to extend. Any iterable of ModelItem works.

selection.clear#

selection.clear(doc=None)

Empties the current selection.

python
"""Replaces the selection with every geometry item beneath it."""
from pynavis import selection, toast

items = selection.get_items()
if not items:
    toast.error('Nothing selected', 'Pick a branch of the tree first.')
else:
    expanded = []
    for item in items:
        for node in item.DescendantsAndSelf:
            if node.HasGeometry:
                expanded.append(node)
    selection.set_items(expanded)
    toast.success('Selected %d geometry item(s)' % len(expanded))

separation: sliding one mesh clear of another#

Pure throughout: plain triangle tuples in, plain dicts out, no Navisworks import anywhere. Give it two meshes as geometry.world_triangles returns them and it answers how far the first must slide along a world axis before the two share no point. Resolve Clash is the reference user: it feeds the two selected objects in and applies the answer with geometry.translate.

The answer is exact for the chosen direction and holds for any shape, convex or not. Every pair of triangles, one from each mesh, is in contact for one closed range of travel along the axis; the required move is the first travel past every range that reaches back to zero. The ranges come from the contact events seen down the axis (a vertex over a face, a vertex on an edge, two edges crossing), so a fitting clipping the corner of a rotated wall is solved on the faces that meet, not on bounding boxes. Touching counts as contact, the way a hard clash at zero tolerance does. Two solids where one sits entirely inside the other share no surface point and read as clear, as they do in Clash Detective.

separation.resolve#

separation.resolve(mover, obstacle, axes=None, clearance=0.0, eps=None, budget_seconds=None)

The shortest axis-aligned move that takes mover clear of obstacle by at least clearance, measured along the axis moved. axes is a subset of separation.AXES, which is ('+x', '-x', '+y', '-y', '+z', '-z'); None tries all six, tightest bounding-box bound first so later axes can give up as soon as they cannot win. Returns:

KeyMeaning
status'clash' when the meshes touch or cross now; 'tight' when they are apart, but by less than the clearance along some axis; 'clear' when there is nothing to do.
axisThe direction chosen, or for 'clear' the axis of the nearest contact found.
moveLength along that axis, clearance included; 0 for 'clear'.
vector(dx, dy, dz) to hand to geometry.translate.
gapUnsigned distance along axis to the nearest contact before moving: 0 when touching, None when the meshes never meet along it.
candidates[(axis, move, exact), ...] shortest first. exact is False for an axis abandoned once it could not beat the best, whose move is then only a lower bound.

Raises ValueError when either mesh is empty, and separation.TooDetailed once the pair walk has run for budget_seconds. eps is the touching tolerance; leave it None and it is scaled from the coordinates, which matters because COM vertices are float32 and a point on a face far from the origin carries real noise.

separation.along#

separation.along(mover, obstacle, axis, eps=None, clearance=0.0, abort_at=None, deadline=None)

One axis on its own: {'axis', 'move', 'contact', 'gap', 'exact'}. contact is True when the meshes touch or cross before moving. abort_at stops the walk, with exact False, as soon as a contact already in progress runs to at least that travel; deadline is a time.time() value past which TooDetailed is raised.

Clearance is along the axis moved

A clearance of 25 mm means the mover ends 25 mm from the obstacle in the direction it travelled. It says nothing about the other two axes: an object slid sideways out of a beam keeps whatever vertical relationship it had. Auto direction picks the shortest move, and "shortest" already includes the clearance.

python
"""Lifts the first selected object clear of the second and says by how much."""
from pynavis import geometry, selection, separation, toast

items = selection.get_items()
if len(items) != 2:
    toast.info('Select two objects', 'The first moves, the second stays.')
else:
    mover = geometry.world_triangles(items[0])
    obstacle = geometry.world_triangles(items[1])
    if not mover or not obstacle:
        toast.error('Could not read the geometry')
    else:
        result = separation.resolve(mover, obstacle, axes=('+z',), clearance=0.025)
        if result['status'] == 'clear':
            toast.info('Already clear')
        else:
            geometry.translate([items[0]], result['vector'], name='Lift clear')
            toast.success('Moved up by %.3f' % result['move'])

sets: search queries and selection sets#

Pure at import. Builds search queries with a fluent builder, compiles and runs them, and reads and writes the saved search-and-selection-sets tree. The pure half, the query model behind query(), has no Navisworks dependency; the API half lazy-imports Navisworks inside each function, so importing this module never needs a live session.

sets.query#

sets.query()

Pure A new fluent query builder. Chain calls to build up one or more AND groups of conditions, ORed together, then call .build() to get a validated query object you can pass to run or create.

MethodDoes
.prop(category, name) / .and_prop(category, name) Starts a condition on a property. The two are aliases; use whichever reads better at the call site.
.category(name) A complete has_category condition by itself, no property or operator needed.
.equals(value) / .not_equals(value) Completes the pending condition, exact match or its negation.
.contains(text) / .wildcard(pattern) Substring match, or a wildcard pattern match, against the display string.
.gt(n) / .ge(n) / .lt(n) / .le(n) Numeric comparison.
.exists() / .missing() Completes the pending condition with no value: the property is present, or it is not.
.ignore_case() Case-insensitive match, applied to the last condition added to the current group. Raises ValueError if a .prop() is still waiting for its operator, rather than silently flagging the condition before it.
.or_group() Closes the current AND group and starts a new one, ORed against it.
.build() Flushes the current group, validates every condition, and returns the query. Raises ValueError listing every problem found.

Starting a new .prop/.category before completing the previous one with an operator, or calling an operator with nothing started, raises ValueError immediately rather than building a malformed condition.

python
from pynavis import sets

q = (sets.query()
     .prop('Item', 'Type').contains('Duct')
     .and_prop('Element', 'Level').equals('Level 2')
     .build())

sets.compile_search and sets.run#

sets.compile_search(query_or_dict, doc=None, scope=None)
sets.run(query_or_dict, doc=None, prune=False, scope=None)

Needs Navisworks query_or_dict accepts a query built with query(), an already-built query object, or the plain dict shape {'or': [{'and': [condition, ...]}, ...]}.

compile_search turns it into a list of native Search objects, one per OR group. run is the one you normally reach for: it compiles, runs every group, and returns the union of every group's matches as one ModelItemCollection. prune=True forces every compiled search's prune-below-match on, in addition to whatever the query itself already carries; it only ever turns pruning on, never off.

scope confines the search to an iterable of ModelItem, or a ModelItemCollection, and everything beneath them, instead of the whole model. On a large federated model that is the difference between a search costing seconds and one costing microseconds. Leave it out to search everything.

A fresh Search is scoped to nothing, which is why this module exists

A newly constructed native Search carries an empty selection, which scopes it to nothing at all, so its own FindAll returns zero matches for any predicate, even one every item in the model satisfies. compile_search calls SelectAll() on every search it builds to set a selection source meaning "everything", which is what the Find Items window scopes to and what a saved search set has to store to keep resolving as the model changes. Build your searches through this module rather than by hand, or you will chase a search that silently matches nothing.

sets.find_first#

sets.find_first(query_or_dict, doc=None, scope=None)

Needs Navisworks The first ModelItem a query matches, or None. It is run's short-circuiting counterpart and much cheaper when one hit is all you need: FindAll always walks the whole model, because it cannot know it has seen the last match, while this stops at the first hit. Measured on a federated model of 1.75 million id-carrying items, 3.0 seconds per FindAll against a mean of 0.80 seconds per first-match walk, and the saving grows the earlier in the walk the item sits.

OR groups are tried in order and the first group that hits wins, so this is "the first match of the first group that matches", not "the earliest match across all groups". With one group, which is the usual case, the two are the same thing.

from pynavis import sets, selection, toast

q = sets.query().prop('Element', 'Mark').equals('D-104').build()
hit = sets.find_first(q)
if hit is None:
    toast.warning('No object is marked D-104')
else:
    selection.set_items([hit])
    toast.success('Found', hit.DisplayName)
OR groups become several separate searches, not one

The native search API ANDs every condition inside one condition list; it has no OR of its own. An OR in a pynavis query, from .or_group() or a second entry under 'or' in the dict shape, therefore compiles to multiple Search objects, and run unions their results in Python. That is why run can express an OR at all, and it is exactly what sets.create and sets.update cannot do: a native selection set holds only one condition list, so they accept a query with exactly one OR group and raise ValueError, naming the group count, for anything wider. Split a multi-group query into one set per group instead of trying to save it as one.

python
from pynavis import sets, selection, toast

q = (sets.query().prop('Item', 'Type').contains('Duct').build())
items = sets.run(q)
selection.set_items(list(items))
toast.success('%d item(s) matched' % len(items))

sets.snapshot#

sets.snapshot(doc=None)

Needs Navisworks The document's saved search-and-selection-sets tree as a list of dictionaries, in depth-first tree order, the same shape viewpoints.snapshot uses for the saved-viewpoints tree.

KeyTypeMeaning
guidstrStable identity.
keystrIndex path such as '0/2/1'. Shape only, goes stale.
parent_keystr'' at the root.
namestrDisplay name.
depthint0 at the top level.
is_folderboolTrue for a folder.
has_searchbool True for a search-based set, False for an explicit/static set or a folder.

sets.find, items_of and select#

sets.find(name_or_path, doc=None)
sets.items_of(saved_set, doc=None)
sets.select(saved_set_or_items, doc=None)

Needs Navisworks find returns the saved set or folder at a path, or None. A bare name with no / is the first match anywhere in the tree, depth first; a 'Folder/Sub/Name' path walks each named folder in turn and matches only that folder's direct children for the last segment.

A literal slash in a name is written backslash-slash

A path segment containing its own / is written 'A\/B': a backslash immediately before a slash is a literal character, not a path separator, so find('A\\/B') looks for one set named A/B, not a set named B inside a folder named A. Any other / in the path separates folder segments as usual.

This escape convention applies to path lookups only, meaning find and the folder argument of create/ create_folder. Everywhere else names are raw: export_all's folder and name values, and the names import_dict reads back, carry the display name exactly as it is, unescaped. A set genuinely named A/B therefore exports as A/B and re-imports as two folder levels, not as one set with a slash in its name.

items_of returns the ModelItemCollection a saved set resolves to. It raises ValueError for a folder, which has no items of its own, only children.

select sets the document's current selection to a saved set, resolved through items_of, or to a ModelItemCollection you already have, for example from run, passed straight through.

sets.create, update, rename, delete and create_folder#

sets.create(name, source, folder=None, doc=None)
sets.update(name_or_item, source, doc=None)
sets.rename(name_or_item, new_name, doc=None)
sets.delete(name_or_item, doc=None)
sets.create_folder(path, doc=None)

Needs Navisworks create adds a new set under folder (a '/'-separated path, missing segments created along the way) or at the root when folder=None, and returns the new set. source is either a query, compiled to a search set, or an iterable of ModelItem/a ModelItemCollection, which becomes a static/explicit set instead. The name is deduped against every existing set and folder name in the whole document, appending ' (2)' and so on, because find's bare-name lookup matches the first hit anywhere in the tree, so a colliding name would be ambiguous for a later lookup. The returned set, like the one update and rename return, is the live item as it sits in the tree, so it can be passed straight on to rename, update, delete or items_of.

update replaces an existing set's content in place, same name, same position, same GUID, accepting either name_or_item resolved through find or a live saved item. It raises ValueError for a name that does not resolve or for a folder. Both create and update apply the same single-OR-group rule described in the trap above.

rename changes a set's or folder's display name in place, same position, same GUID. delete removes a set or folder, and everything under a folder. create_folder creates, or reuses, every folder along a nested '/'-separated path and returns the deepest folder; existing segments are reused rather than duplicated, checked against the live tree at each step rather than a stale snapshot.

GUIDs survive rename and update

Both go through a create-copy-and-swap under the hood, since the underlying API has no single-call rename or content-replace for a saved set. This module restores the original item's GUID onto the replacement before swapping it in, so any GUID a caller stashed, or export_all's own rows, keeps resolving to the same logical set after a rename or update rather than a fresh one. This is best-effort: if the live API ever refuses the GUID reassignment, the rename or update itself still lands, just with the copy's own GUID instead of the original.

python
from pynavis import sets, toast

q = sets.query().prop('Item', 'Type').contains('Duct').build()
saved = sets.create('Ducts', q, folder='Coordination/MEP')
toast.success('Saved "%s"' % saved.DisplayName, '%d item(s)' % len(sets.items_of(saved)))

sets.to_dict, export_all and import_dict#

sets.to_dict(saved_item, doc=None)
sets.export_all(doc=None, include_static=False)
sets.import_dict(data, doc=None, replace=False, progress=None)

Needs Navisworks to_dict turns one search set's compiled conditions back into the query dict shape, always one OR group, since a native set holds one condition list. A static/explicit set comes back as {'items': N} instead, a count rather than a portable query. export_all walks the whole tree and returns {'sets': [{'name', 'folder', 'query'}, ...]}, the shape import_dict expects; a non-portable {'items': N} entry is left out, so an export can be handed straight back to import_dict unfiltered. Pass include_static=True to get those {'name', 'folder', 'items'} entries back when the export is for showing the whole tree rather than re-importing it, as the shipped Sets from Excel tool does; import_dict rejects such an entry, so filter them out yourself if you take that route.

import_dict batch-creates sets from that shape, all inside one transaction, and validates the whole payload before touching the document, so a bad entry anywhere aborts the import rather than leaving a half-applied batch. replace=True updates an existing same-named set in place via update instead of creating a deduped sibling. Returns (created_count, errors): an entry that fails inside the transaction is recorded and skipped rather than aborting the rest.

An entry whose query holds an empty condition group is rejected by that validation, along with the missing names, {'items': N} markers, raw conditions and multi-group queries above. A group with nothing in it compiles to a search with no conditions, which matches the whole model, so a blank row in a spreadsheet would otherwise import as a match-everything set. Drop such rows before importing rather than sending them through.

ignore_case and prune do not survive a to_dict/export_all round trip

A query you build yourself with .ignore_case() keeps that flag through its own to_dict(). But sets.to_dict/export_all read an already compiled, live search condition back out of the document, and there is nothing on a compiled condition this module can read to tell whether IgnoreStringValueCase() was applied to it. A case-insensitive condition therefore round-trips through export_all and back through import_dict as a plain, case-sensitive one.

prune and locations are the same shape of gap one level up, and they lose the flag even earlier: Query.to_dict() serialises only {'or': [...]}, so a query's prune-below-match setting and its location scope are dropped by the query's own round trip through to_dict/from_dict, and therefore by every set export and import as well. A set saved from a pruning query re-imports as a non-pruning one. Pass prune=True to sets.run at the call site when you need it rather than expecting it to travel with the data.

All of these are known gaps, not bugs to route around with a workaround: do not rely on an exported set's case sensitivity or pruning matching what is in the document.

A condition the reverse mapping cannot recognise becomes {'op': 'raw', 'text': condition.ToString()}, visible in an export for inspection but rejected by import_dict: it carries only display text, not a value that could be rebuilt.

settings: per-tool user settings#

Pure Standard library only, no Navisworks import at all, so it works in either engine and outside Navisworks entirely. One JSON file per tool under %APPDATA%\pyNavis\settings\.

The convention is that a bundle's config.py writes the settings and its script.py reads them back, with the defaults declared identically in both. See Click actions and config.py for the wider pattern and Settings for the store itself.

settings.load#

settings.load(tool, defaults)

The tool's saved settings merged over defaults. Never raises. A missing file, an unreadable file, invalid JSON, or a file whose top level is not an object all yield a fresh copy of your defaults. You always get a dictionary with exactly your keys in it, so downstream code needs no guards.

settings.save#

settings.save(tool, values)

Writes values as indented, key-sorted JSON, creating the settings folder if it does not exist, and returns None. The write is atomic, the same way memory.save is: the JSON goes to a temp file beside the target and is then moved over it, so a crash, a full disk or a scanner mid-write leaves the previous settings intact rather than a truncated file. Only the keys you pass survive, so pass the whole dictionary, not the one key you changed.

save can raise IOError or OSError, for instance when the profile folder is read-only. It is the one function in this module you may want to wrap.

settings.merge and settings.path_for#

settings.merge(defaults, stored)
settings.path_for(tool)

merge overlays stored onto defaults and drops any key that is not in defaults, so a setting you removed in a later version of your tool never leaks back in from an old file. It is what load uses internally, and it is exposed so you can apply the same rule to settings you read yourself.

path_for returns the JSON file path for a tool key such as 'smart_clash_grouper'. Useful for telling the user where their settings live, and for deleting the file to reset a tool.

python
"""Reads this tool's options, falling back to defaults."""
from pynavis import settings

TOOL = 'my_exporter'
DEFAULTS = {'include_hidden': False, 'decimals': 3}

values = settings.load(TOOL, DEFAULTS)       # always exactly these two keys
decimals = int(values['decimals'])

toast: non-blocking status#

Pure at import; needs only the PyNavis.Runtime assembly, never the Navisworks API. A toast appears in the corner of the Navisworks window and dismisses itself. It never blocks and never takes focus, which is what makes it right for tools that run constantly.

Toast, never print

A one-line status belongs in a toast. print() opens the output window, and an output window containing one line of text is worse than no feedback at all. Use output when you genuinely have a report to show.

toast.show#

toast.show(level, message, detail=None)

level is one of 'success', 'error', 'info' or 'warning': green, red, blue and amber respectively. All three arguments are coerced with str(), except that detail=None stays None and renders as a single-line toast.

This is the form to use with a Result, since its .level, .message and .detail line up one for one.

toast.success, toast.error, toast.info and toast.warning#

toast.success(message, detail=None)
toast.error(message, detail=None)
toast.info(message, detail=None)
toast.warning(message, detail=None)

Four shorthands for show with the level filled in. Prefer these when the level is known at the call site.

python
"""Reports what happened, in one line, without opening a window."""
from pynavis import selection, toast

items = selection.get_items()
if not items:
    toast.info('Nothing selected', 'Pick something in the model and run this again.')
elif len(items) > 1000:
    toast.warning('%d item(s) selected' % len(items), 'That is a large selection.')
else:
    toast.success('%d item(s) selected' % len(items))

view: camera and visibility#

Needs Navisworks Three moves a tool makes right after it changes the selection, frame it, isolate it or put the model back, plus one reader for the viewport itself. Every Navisworks import is deferred into the function bodies, so importing the module outside a session is safe even though calling into it is not.

view.zoom_selected#

view.zoom_selected(doc=None)

Zooms the current view onto the current selection, and returns True when the view moved. There is no .NET route to this, only the COM InwOpState10 object, so a host that refuses gives False and a line in the log rather than an exception: a tool that already selected the right elements has done its job, and failing it over the camera would be the worse outcome. doc is accepted for symmetry with the rest of the library; the COM state always acts on the active document.

view.isolate#

view.isolate(items, doc=None)

Hides everything except items, and returns how many were kept. Navisworks resolves visibility at the leaf, so this hides every root item and then unhides the wanted ones, both inside a single viewpoints.transaction: Ctrl+Z restores the model in one step, and Navisworks' own Unhide All clears it too. An empty items is a no-op rather than a whole-model blackout.

view.unhide_all#

view.unhide_all(doc=None)

Clears every hidden item in the document, in one undo step.

view.aspect_ratio#

view.aspect_ratio(doc=None)

The render window's width over its height, as a float, or None when there is no usable value. You need it whenever you turn a horizontal field of view into the vertical angle Navisworks actually stores, because the whole conversion divides by this number.

A viewpoint copy hands back a stale ratio, so this refreshes it first

Reading AspectRatio off a viewpoint copy can give you the value that copy was made with rather than the window's current one, and a ratio out by a thousandth puts the lens out by a fraction of a degree. aspect_ratio commits the current viewpoint back onto itself first, which is what makes Navisworks refresh the ratio from the real render window. Nothing moves on screen, because the viewpoint written is the one already showing.

None covers zero, a negative, infinity and NaN alike, so treat it as "skip the field of view" rather than dividing by a number you cannot trust.

from pynavis import selection, sets, toast, view

found = sets.run({'or': [{'and': [
    {'category': 'Item', 'prop': 'Type', 'op': 'equals', 'value': 'Wall'}]}]})
if found.Count:
    selection.set_items(found)
    view.isolate(found)
    view.zoom_selected()
    toast.success('Isolated %d wall(s)' % found.Count, 'Unhide All puts the model back.')

viewpoints: saved viewpoints#

Pure at import. A pure planning half that works on plain snapshot rows and runs anywhere, plus an API half that lazy-imports Navisworks, so importing the module never needs a session. This is the module behind bulk rename, delete, sort and move over the saved-viewpoints tree.

GUIDs address items, index paths describe shape

Every row carries both a guid and a key like '0/2/1'. The GUID is what edits address. Index paths describe the tree's shape only, and they go stale the moment anything moves: delete one viewpoint and every key after it now points at a different item. Plan with keys, because that is what a checkbox tree gives you, and apply with GUIDs, which is what every apply_* function does. Never resolve an edit by index path against a tree you snapshotted earlier: you will rename or delete something the user did not pick.

viewpoints.snapshot#

viewpoints.snapshot(doc=None)

Needs Navisworks The whole saved-viewpoints tree as a list of dictionaries, in depth-first tree order. Folders appear as rows of their own, unlike doc.walk_saved_viewpoints. Numbering operations rely on that order.

KeyTypeMeaning
guidstrStable identity. This is what edits address.
keystrIndex path such as '0/2/1'. Shape only, goes stale.
parent_keystrThe parent's key; '' at the root.
folderstrThe owning folder path as names, '' at the root.
namestrDisplay name.
depthint0 at the top level.
is_folderboolTrue for folders.
kindstr'folder', 'viewpoint' or 'animation'.
commentsintHow many comments the item carries; 0 when it cannot be read.

Only folders are descended into. An animation is a group item whose children are its cuts, and it must come out as one row, never as its keyframes.

viewpoints.plan_renames#

viewpoints.plan_renames(rows, checked_keys, op)

Pure The rename plan for one operation over the checked rows. Returns three keys:

KeyTypeMeaning
renameslist of dict {'key', 'guid', 'old', 'new', 'collision'}. Only changed names appear; a rename that produces the same string is left out.
collisionslist of str Human-readable reports: two siblings ending up with the same name, or a name renamed to nothing. The offending entries also have 'collision': True.
problemslist of str Reasons the plan could not be computed at all, such as an invalid regular expression or an unknown operation type.

Nothing is ever auto-fixed. Collisions and problems are reported so the caller can block Apply, which is the behaviour you want: silently appending "(2)" to a viewpoint someone named deliberately is worse than refusing.

op is a dictionary keyed by 'type':

typeOther keys it readsBehaviour
'replace'find, replace, regex Plain substring replace, or a regular expression when regex is truthy. An empty find changes nothing.
'affix'prefix, suffix Wraps the existing name. Either may be omitted.
'number'pattern, start, pad Replaces the name entirely. pattern defaults to '{n}', start to 1, pad to 0. The counter follows tree order. Folders are skipped, and skipped rows do not consume a number.
'case'mode 'title' or 'upper'; anything else, including a missing mode, means lower case.

A bad regular expression or an unrecognised type does not raise. It comes back as a message in problems with empty renames, so a dialog can show the error live as the user types.

python
"""Prefixes every checked viewpoint with a discipline code."""
from pynavis import toast, viewpoints

rows = viewpoints.snapshot()
keys = [row['key'] for row in rows if not row['is_folder']]

plan = viewpoints.plan_renames(rows, keys, {'type': 'affix', 'prefix': 'ME-'})

if plan['problems'] or plan['collisions']:
    toast.error('Nothing renamed', (plan['problems'] + plan['collisions'])[0])
else:
    applied, errors = viewpoints.apply_renames(plan['renames'])
    toast.success('Renamed %d viewpoint(s)' % applied,
                  '%d failed' % len(errors) if errors else '')

viewpoints.plan_deletes and viewpoints.guids_for#

viewpoints.plan_deletes(rows, checked_keys)
viewpoints.guids_for(rows, keys)

Pure plan_deletes reduces a set of checked rows to the minimal set of things to remove, returning {'keys', 'guids', 'count'}: the minimal keys in tree order, their GUIDs in the same order, and the total number of rows that will disappear once folder contents are counted.

The folder rule is the important part. A checked folder stands in for its contents only when every descendant is checked too. A folder with even one unchecked descendant survives, and only its checked descendants are deleted. That is what stops a half-checked folder from taking its unchecked children down with it.

guids_for maps a set of keys to their GUIDs in tree order, skipping keys that are not in rows. Use it when you have keys from somewhere other than a delete plan.

viewpoints.sort_ops, validate_move and delete_order#

viewpoints.sort_ops(entries)
viewpoints.validate_move(rows, checked_keys, target_key)
viewpoints.delete_order(keys)

Pure sort_ops takes a sibling list of (name, is_folder) pairs and returns the move steps, (from_index, to_index) in sequence, that reorder it to folders first then A to Z case-insensitively, with ties keeping their current order. The steps are computed against a simulation, so applying them one by one through the API lands exactly the simulated order.

validate_move returns None when the move is legal, otherwise a reason string suitable for showing the user. The target must be the root, spelled '', or a folder; and a folder can never move into its own subtree.

delete_order sorts keys so that deleting front to back never shifts a later index path: numerically descending, deepest siblings first. You need it only if you delete by index path yourself; apply_deletes works by GUID and does not.

viewpoints.transaction#

viewpoints.transaction(name, doc=None)

Needs Navisworks A context manager wrapping a bulk edit in one Navisworks transaction, so thousands of changes land as a single undo entry instead of thousands. It commits on a clean exit; on an error it disposes without committing and lets the error travel.

A document already inside a transaction is left alone, since nested transactions are not a thing here, and a document that cannot start one still runs the body, just unbatched. So it is always safe to wrap.

apply_renames, apply_deletes and apply_moves already use it internally. Use it yourself when you are making your own bulk edits to the tree.

with viewpoints.transaction('Tidy viewpoints'):
    viewpoints.create_folder('Coordination')
    viewpoints.apply_renames(plan['renames'])

viewpoints.apply_renames#

viewpoints.apply_renames(renames, doc=None, progress=None, cancelled=None)

Needs Navisworks Applies the renames list from plan_renames and returns (applied_count, errors), where errors is a list of message strings. One bad entry never stops the rest.

Every entry is resolved by GUID. The document can change while a dialog is open, and an item that has since disappeared adds an error rather than raising.

progress(done, total) is called every 200 entries and once with (total, total) at the end. cancelled() is checked before each entry; returning true simply stops the loop and returns the partial count. Nothing is rolled back: the renames already applied stay, inside the single undo entry the transaction created.

viewpoints.apply_deletes#

viewpoints.apply_deletes(guids, doc=None, progress=None, cancelled=None)

Needs Navisworks Deletes items by GUID, returning (removed_count, errors). Takes the guids list straight out of plan_deletes.

Because GUIDs stay valid however the tree shifts underneath, the order of removal cannot make a later entry address the wrong item. That is exactly the failure that index paths invite, and it is why delete_order is not needed here.

Progress and cancellation behave as they do for apply_renames.

viewpoints.apply_sort#

viewpoints.apply_sort(rows, folder_keys=None, doc=None)

Needs Navisworks Sorts the root and every folder, or only the folders you name in folder_keys, using sort_ops. Returns (moved_count, errors). Deepest folders sort first, so the index paths of parents still to be resolved never shift underneath.

Sorting is not one undo step

apply_sort is the one apply_* function not wrapped in a transaction, so every individual move is its own undo entry. Sorting a large tree therefore fills the undo stack. Wrap the call in viewpoints.transaction yourself when you want it to undo in one go.

viewpoints.apply_moves#

viewpoints.apply_moves(rows, checked_keys, target_key, doc=None)

Needs Navisworks Moves the checked set into the target folder, with '' meaning the top level, and returns (moved_count, errors). It applies the same folder-collapse rule as plan_deletes, so a fully checked folder moves as one thing rather than as its contents.

Items already sitting directly in the target are skipped. Every step re-resolves by GUID, because each move shifts index paths and the keys can only be trusted before the first mutation. If the target folder itself disappears mid-run, the function returns immediately with what it managed and one error.

Call validate_move first: apply_moves does not check for a folder being moved into its own subtree.

viewpoints.create_folder#

viewpoints.create_folder(name, parent_key='', doc=None)

Needs Navisworks Adds an empty viewpoint folder under the parent, with '' meaning the top level. Returns None. This is one of the few places that resolves by index path rather than GUID, so take a fresh snapshot before using a parent_key you obtained earlier.

viewpoints.resolve#

viewpoints.resolve(saved, guid_text)

Needs Navisworks The live SavedItem for a snapshot GUID, or None when it is gone. saved is the document's SavedViewpoints part. Every apply_* function uses this internally; you need it directly when you want to reach a viewpoint's own API surface, for example to read its camera or add a comment.

viewstate: copy and paste view state#

Pure at import. The engine behind the Copy State and Paste State buttons: three kinds of view state, each copied to disk per document and pasted back later, including in another Navisworks session of the same document.

KindLabelWhat it carries
'section'Section State The active clip plane set: mode, every plane's position, or the section box.
'hidden'Hidden Items The topmost hidden items. Pasting is an exact restore: everything else is unhidden.
'overrides'Appearance Overrides Permanent colour and transparency overrides, snapshotted by Navisworks itself into a reserved saved viewpoint inside the document.

The hidden payload stores entries in the memory entry schema and resolves them the same way, so a renamed or reordered model falls back to matching by source file name, and items that no longer resolve are counted, never fatal. Section and hidden state live in %APPDATA%\pyNavis\viewstate, one JSON file per document, so they survive a Navisworks restart. Overrides are different: no API, COM included, exposes applied overrides item by item, so copy stores Navisworks' own opaque snapshot (SavedViewpoints.CaptureRuntimeOverrides()) inside the document as a saved viewpoint named .pyNavis Copied Overrides. It shows in the Saved Viewpoints tree, and it survives across sessions only when the file is saved. Copy and paste report through the module's own Result (level, message, detail), made to be toasted like memory's.

viewstate.copy_state#

viewstate.copy_state(kind, doc=None)

Needs Navisworks Reads one kind of state off the active document, replacing that kind's previous copy and leaving the other kinds alone. Copying an empty state is valid and useful: copying 'hidden' with nothing hidden means pasting later unhides everything. Two exceptions refuse with an info Result ("Nothing to copy") and store nothing: 'section' with sectioning off, and 'overrides' with no appearance overrides applied. An overrides copy that could not be marked materials-only downgrades to a warning Result saying pasting may also restore hidden items.

viewstate.paste_state#

viewstate.paste_state(kind, doc=None)

Needs Navisworks Applies a copied kind back onto the active document as one undo step. 'section' rewrites the clip plane set; 'hidden' resets all hiding then hides the copied items; 'overrides' applies the snapshot viewpoint and immediately puts the camera and section planes back where they were, so only colours and transparency change. With nothing copied for the kind it returns an error Result and touches nothing.

viewstate.available_kinds#

viewstate.available_kinds(doc=None)

Needs Navisworks The kinds actually copied for the active document, in canonical order: what a paste picker should offer. A malformed or newer-versioned entry on disk is invisible rather than an error. Overrides count as available when the .pyNavis Copied Overrides viewpoint exists anywhere in the document's saved viewpoints tree.

viewstate.KINDS, label_for, kind_for, describe#

viewstate.KINDS = ('section', 'hidden', 'overrides')

Pure label_for(kind) gives the picker caption for a kind, kind_for(label) comes back the other way (None for an unknown caption), and describe(kind, payload) is the short toast detail saying what a payload carries, such as '6 planes' or '12 hidden items'.

python
"""Copies whichever state the user picks."""
from pynavis import forms, toast, viewstate

label = forms.ask_options(
    None,                                      # chromeless quick switch
    [viewstate.label_for(kind) for kind in viewstate.KINDS],
    title='Copy State',
)
if label is not None:                          # None means cancelled: say nothing
    result = viewstate.copy_state(viewstate.kind_for(label))
    toast.show(result.level, result.message, result.detail)

The pure half: the on-disk store#

Pure The storage layer underneath copy_state/paste_state, exposed for a tool that wants to read or write the same files. None of these touch Navisworks.

viewstate.VERSION = 1
viewstate.OVERRIDES_VIEWPOINT = '.pyNavis Copied Overrides'
viewstate.Result(level, message, detail='', count=0)
viewstate.state_root()
viewstate.store_path(doc_path, root=None)
viewstate.load_store(doc_path, root=None)
viewstate.save_kind(doc_path, kind, payload, root=None)
viewstate.available(store)
viewstate.range_ok(rng)
NameReturnsNotes
VERSIONint The on-disk entry version. available only counts an entry stamped with exactly this version, so a newer file degrades to "nothing copied" rather than erroring.
OVERRIDES_VIEWPOINTstr The reserved saved-viewpoint name the overrides snapshot is stored under, inside the document itself.
Resultclass One of the three result classes; what copy_state and paste_state return.
state_rootstr %APPDATA%\pyNavis\viewstate.
store_pathstr The JSON file for one document, derived from doc_path the same way memory.path_for derives its file names.
load_storedict The whole store for a document; a missing or corrupt file yields {}.
save_kindstr Writes one kind's payload into the store, stamped with VERSION and the time, leaving the other kinds alone. Returns the path written.
availablelist The kinds a loaded store actually carries, in canonical order. An entry counts only when it is shaped the way save_kind wrote it; a malformed or future-versioned entry is invisible, never an error.
range_okbool Whether a stored clip range is a usable [[min x, y, z], [max x, y, z]] box. Navisworks represents a never-set range as an inverted box and throws when one is assigned back, so an inverted or malformed range means "carry no range", never an error.

xl: reading and writing .xlsx workbooks#

Pure Standard library only, zipfile and xml.dom.minidom, nothing more. It writes a minimal but valid OpenXML workbook and reads both what it writes itself and files produced by Excel, shared strings table included, so it runs unmodified on IronPython 3.4 and CPython alike and needs neither Navisworks nor Excel installed on the machine that runs it.

xl.write#

xl.write(path, rows, headers=None, sheet='Sheet1')

Writes rows, each a list of str, int, float, bool or None cells, to path as an .xlsx workbook. When headers is given it is written as a bold first row.

Writing to a path that already holds a workbook adds the named sheet, or replaces it when a sheet by that name already exists, and every other sheet keeps its name, position and cell values. It works by reading the whole existing workbook back and rebuilding the package from scratch, which is simple and correct at the sizes a pyNavis script deals with, but it is a values-only round trip, not a byte-preserving one, which is why the header styling has the limit called out below.

python
from pynavis import xl

xl.write(path, rows, headers=['Object', 'Length'])
xl.write(path, other_rows, headers=['Test', 'Status'], sheet='Clash log')
Rewriting a file loses every other sheet's formatting, not just its bold header

Because write rebuilds the whole package from the values read hands back, a sheet written earlier with a bold header row comes back from a later write to a different sheet without that bold row, and a number that was written as int comes back as float. Only the sheet named in the current call gets fresh formatting, from the headers you pass it this time. Pass headers= on every write to every sheet whose header row must stay bold, rather than relying on an earlier write to have set it, and do not depend on an integer round tripping through a sheet write did not touch this time.

xl.read#

xl.read(path, sheet=None)

Rows from the named sheet, or the first sheet when sheet is None. Numbers come back as float, booleans as True or False, and everything else, inline and shared strings alike, as str. A row's length follows the highest column it uses; gaps within that span read back as ''.

Raises ValueError when the workbook has no sheets, or when sheet names one that is not in it.

xl.sheets#

xl.sheets(path)

The sheet names in the workbook, in workbook order.

python
"""Exports a per-model row to a workbook, adding to it if it already exists."""
from pynavis import doc, xl

document = doc.get_doc()
rows = [[model.SourceFileName, model.RootItem.DisplayName]
        for model in document.Models]

xl.write(path, rows, headers=['Source file', 'Model'], sheet='Models')

The private modules#

Everything below is internal. It exists in the package, it will appear in tracebacks, and it is not part of the API. Do not import any of it from a bundle: these modules change without notice, and several load assemblies as a side effect of import.

ModuleWhat it is
pynavis._api Loads the Navisworks .NET API for the other modules and raises a clear ImportError outside a session. Seeing this name in a traceback means you imported an API-bound module outside Navisworks.
pynavis._atomic Write-to-temp-then-replace text writes, so a crash mid-write never destroys the file being replaced. It is what makes settings.save and memory.save atomic.
pynavis._charts The SVG chart renderers behind output.chart_bar, chart_line, chart_pie and chart_doughnut.
pynavis._clashapply Pure tree-layout planning behind clash.apply_plan and clash.ungroup.
pynavis._clashsnap The pure memo helpers that make clash.snapshot_results viable on a large test.
pynavis._com The COM bridge, chiefly for writing properties, which the .NET API cannot do. Importing it loads the COM interop assemblies.
pynavis._datafiles The per-tool JSON storage behind script.store_data, load_data and data_exists.
pynavis._history Command history for the pyNavis console.
pynavis._log The level-filtered Logger class behind script.get_logger.
pynavis._markdown The minimal markdown to HTML converter behind output.print_md, including the escaping.
pynavis._query The pure query spec model (Condition, Query, Builder) behind sets.query. You hold a Builder instance the moment you call sets.query(), but you never import this module by name.
pynavis._repl REPL-style execution for the console, so a trailing expression echoes its value.
pynavis._util Pure tree-flattening helpers shared by clash and doc.
pynavis._xlsx The OpenXML package builder and parser behind pynavis.xl.

If you find yourself wanting something one of these does, that is worth reporting rather than importing: the public module it sits behind is where it should surface.