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:
import pynavis
pynavis.__version__ # '1.0.0'
pynavis.selection # AttributeError: the package re-exports nothingThe 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:
from pynavis import forms, output, toastThat 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.
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.
| Module | What it is for | Import 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.
from pynavis import doc, selection
items = selection.get_items() # the active document
items = selection.get_items(doc.get_doc()) # exactly the same thing, spelled outPass 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.
| Attribute | Type | Meaning |
|---|---|---|
.level | str | 'success', 'error', 'info' or 'warning'. |
.message | str | The one-line headline. |
.detail | str | The second line; often empty. |
.count | int | How 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.
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.
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.
| Function | How you cancel | What 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. |
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.
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.
| Function | Callback signature | Called |
|---|---|---|
clash.snapshot_results | progress(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.progress | progress(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:
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.
| Module | How 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 returnsNoneorFalserather than raising. The one twist isforms.progress, which is modal for the user but not for your script: your loop keeps running inside thewithblock while the window is up. pick.pointandpick.measure_pointalso 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_thenis 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.
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#
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#
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#
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#
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#
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.
from pynavis import clash
for test in clash.walk_tests():
print(test.DisplayName, sum(1 for _ in clash.walk_results(test)))clash.snapshot_results#
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:
| Key | Type | Meaning |
|---|---|---|
id | int | The index in walk order. This is what a plan refers to. |
name | str | The clash display name. |
center | tuple or None | (x, y, z) in model units, or None when the result has no
centre. |
item_a, item_b | str | 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_name | str | Display name of each side's element, '' when unknown. |
level | str or None | Nearest level display name. |
grid | str or None | Nearest grid intersection display name. |
model_a, model_b | str | Source file name, no path, of the model each element came from. |
status | str | The result status name, for example 'New' or 'Resolved'. |
assigned | str | '' when unassigned. |
group | str 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.
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#
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#
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#
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.
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#
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#
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#
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#
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 id | Label | Groups 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 level | The level key, or "No level". |
'grid' | Grid intersection | The grid key, or "No grid". |
'model' | Source model | The pair of source models, as "A.rvt vs B.rvt". |
'status' | Status | The status key. |
'assigned' | Assigned to | The assigned key, or "Unassigned". |
clashgroup.plan#
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 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#
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.
| Key | Type | Meaning |
|---|---|---|
groups | list 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_ids | list of int | Candidates that ended up in no group, sorted ascending. |
skipped_existing | int | How many results were left alone because they were already grouped. |
explanation | str | One line saying what was done. |
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#
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 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.
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#
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.
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#
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#
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#
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.
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#
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#
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#
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.
"""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#
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#
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#
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.
| Keyword | Default | Meaning |
|---|---|---|
keywords, comments, published_for, copyright |
None | Title-metadata strings; left untouched when not given. |
allow_resave | True |
Whether the recipient may resave the published file. |
display_on_open | False |
Whether the properties dialog shows automatically when the file opens. |
expiry | None |
A datetime.datetime after which the file refuses to open, or
None for no expiry. |
exclude_hidden | False | Skip hidden items in the export. |
embed_xrefs | True | Embed cross-referenced files rather than linking them. |
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#
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.
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#
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#
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#
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.
"""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#
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.
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#
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#
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#
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#
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.
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.
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#
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.
| Member | Does |
|---|---|
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
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#
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.
[] 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.
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.
"""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#
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#
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#
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#
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#
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#
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.
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#
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#
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#
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#
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.
| Function | Returns | Notes |
|---|---|---|
new_state | dict | Keys version, document, saved,
cursor (starting at -1) and items. |
serialize | str | Indented, key-sorted JSON. |
deserialize | dict | Raises MemoryFormatError for invalid JSON, a
non-object, a version other than VERSION, or a missing item list. |
apply_entries | dict | Replaces the contents, stamps the save time, resets the cursor to -1. |
memory_root | str | %APPDATA%\pyNavis\memory. |
path_for | str | 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. |
load | dict | The stored state, or a fresh empty one when there is no file. Lets
MemoryFormatError travel when the file exists but is unreadable. |
save | str | Atomic: writes a temp file, then replaces. Returns the path written. |
key | tuple | An entry's identity: lowercased model name plus path id. The set functions use it. |
union | list | All of a, then all of b that is new. Order preserved, deduped. |
difference | list | Entries of a that b does not contain. |
intersection | list | Entries in both, in a's order. |
step | int | 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#
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#
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#
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#
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.
"""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#
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.
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')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#
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#
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#
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#
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#
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#
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#
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.
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.
"""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.
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#
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#
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#
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#
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.
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.
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#
What a finished pick hands back. You never construct one; you read it.
| Attribute | Type | Meaning |
|---|---|---|
.point | tuple | (x, y, z) floats in model units. Always present. |
.normal | tuple or None | The surface normal at the click, or None when there is none. |
.item | ModelItem or None | What was clicked on. |
.snap | str or None | Which snap fired: 'vertex', 'edge',
'line-vertex', 'line-middle',
'arc-center', or None for a free point on a face. |
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#
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.
"""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#
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#
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#
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.
"""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#
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.
"""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#
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#
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#
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#
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.
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#
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(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.
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#
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#
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#
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#
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#
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#
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#
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#
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.
"""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#
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#
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.
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#
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.
| Method | Does |
|---|---|
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.
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#
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#
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:
| Key | Type | Meaning |
|---|---|---|
yaw | float | Radians about Z. 0.0 means square to the world. |
min, max | tuple | The extents in the yaw-rotated local frame, not in world coordinates. |
center | tuple | Also in the local frame. |
size | tuple | (dx, dy, dz), padding already included. |
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
Empties the current selection.
"""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#
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:
| Key | Meaning |
|---|---|
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. |
axis | The direction chosen, or for 'clear' the axis
of the nearest contact found. |
move | Length along that axis, clearance included; 0 for
'clear'. |
vector | (dx, dy, dz) to hand to
geometry.translate. |
gap | Unsigned 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#
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.
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.
"""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#
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.
| Method | Does |
|---|---|
.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.
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#
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 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#
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)
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.
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#
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.
| Key | Type | Meaning |
|---|---|---|
guid | str | Stable identity. |
key | str | Index path such as '0/2/1'. Shape only, goes stale. |
parent_key | str | '' at the root. |
name | str | Display name. |
depth | int | 0 at the top level. |
is_folder | bool | True for a folder. |
has_search | bool | True for a search-based set, False for an explicit/static set
or a folder. |
sets.find, items_of and select#
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 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#
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.
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.
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#
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.
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#
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#
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#
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.
"""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.
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#
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#
Four shorthands for show with the level filled in. Prefer these when the level
is known at the call site.
"""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#
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#
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#
Clears every hidden item in the document, in one undo step.
view.aspect_ratio#
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.
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.
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#
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.
| Key | Type | Meaning |
|---|---|---|
guid | str | Stable identity. This is what edits address. |
key | str | Index path such as '0/2/1'. Shape only, goes stale. |
parent_key | str | The parent's key; '' at the root. |
folder | str | The owning folder path as names, '' at the root. |
name | str | Display name. |
depth | int | 0 at the top level. |
is_folder | bool | True for folders. |
kind | str | 'folder', 'viewpoint' or 'animation'. |
comments | int | How 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#
Pure The rename plan for one operation over the checked rows. Returns three keys:
| Key | Type | Meaning |
|---|---|---|
renames | list of dict | {'key', 'guid', 'old', 'new', 'collision'}. Only changed names
appear; a rename that produces the same string is left out. |
collisions | list 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. |
problems | list 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':
type | Other keys it reads | Behaviour |
|---|---|---|
'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.
"""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#
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#
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#
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#
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#
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#
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.
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#
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#
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#
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.
| Kind | Label | What 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#
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#
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#
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#
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'.
"""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.
| Name | Returns | Notes |
|---|---|---|
VERSION | int | 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_VIEWPOINT | str | The reserved saved-viewpoint name the overrides snapshot is stored under, inside the document itself. |
Result | class | One of the three result classes; what
copy_state and paste_state return. |
state_root | str | %APPDATA%\pyNavis\viewstate. |
store_path | str | The JSON file for one document, derived from doc_path the same way
memory.path_for derives its file names. |
load_store | dict | The whole store for a document; a missing or corrupt file yields {}. |
save_kind | str | Writes one kind's payload into the store, stamped with VERSION and the
time, leaving the other kinds alone. Returns the path written. |
available | list | 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_ok | bool | 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#
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.
from pynavis import xl
xl.write(path, rows, headers=['Object', 'Length'])
xl.write(path, other_rows, headers=['Test', 'Status'], sheet='Clash log')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#
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#
The sheet names in the workbook, in workbook order.
"""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.
| Module | What 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.