Practice
Cookbook
Complete, runnable recipes for the jobs a Navisworks tool actually has to do, with the sharp edge on each one named.
Every recipe on this page is a whole script.py. Copy one into a
.pushbutton folder, click Reload, and it runs. Nothing is elided, nothing is
pseudocode, and every function called here exists in the pynavis library
shipped with your install.
They are all built the same way, because tools that touch a model have a shape. Read something, guard against the case where there is nothing to read, compute an answer without touching the API, write the answer back in one go, then say what happened.
Working with the selection#
Act on the current selection#
Almost every tool starts here, and the first thing it must handle is an empty selection. A tool that throws a traceback because nothing was picked is a tool people stop using.
"""Reports the bounding box of the current selection."""
from pynavis import selection, toast
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
box = items[0].BoundingBox()
for item in items[1:]:
box.Extend(item.BoundingBox())
size = box.Size
toast.success(
'%d item(s) selected' % len(items),
'%.2f x %.2f x %.2f' % (size.X, size.Y, size.Z))selection.get_items() returns a plain Python list, never None,
and it is empty when nothing is selected. That makes if not items: the whole
guard. The list is a snapshot: changing the selection afterwards does not change the list you
are holding.
A one-line status belongs in a toast. print() materialises the
output window, so a script whose only output is a single print gives the user a
window with one line in it. Use the output window when you have a report to show; use a toast
when you have a sentence.
Filter a selection by a property value#
Properties are addressed by their display names, the ones you see in the
Properties palette, and doc.get_property returns None when the
category or the property is not on that item. That happens constantly in a federated model,
so it is the normal case rather than an error.
"""Keeps only the selected objects whose Item / Type contains a given word."""
from pynavis import doc, forms, selection, toast
CATEGORY = 'Item'
PROPERTY = 'Type'
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
wanted = forms.ask_string(
'Keep objects whose %s contains:' % PROPERTY, title='Filter selection')
if wanted is not None and wanted.strip():
needle = wanted.strip().lower()
kept = []
for item in items:
value = doc.get_property(item, CATEGORY, PROPERTY)
if value is None:
continue
if needle in value.ToDisplayString().lower():
kept.append(item)
if kept:
selection.set_items(kept)
toast.success('Kept %d of %d' % (len(kept), len(items)))
else:
toast.warning('Nothing matched', 'The selection is unchanged.')get_property hands back a Navisworks VariantData, not a string.
ToDisplayString() is the safe way to read one whatever its underlying type is,
which saves you from branching on IsDisplayString, IsDouble and the
rest.
pynavis.props supersedes doc.get_property: props.get
does the same lookup and hands back an already-coerced Python value, so there is no
VariantData to unwrap and no ToDisplayString() to remember. The
recipe below uses it. doc.get_property stays for the tools already written
against it, and several recipes further down this page still show it for that reason.
Read properties as plain Python values#
props.get(item, category, prop) returns a str, int,
float, bool, a datetime or an (x, y, z)
tuple, whichever the property actually holds, and None when the item does not
carry it. That is the whole difference: a number comes back as a number, so you compare it
instead of parsing it.
"""Selects every object whose Length is over a limit, reading properties as values."""
from pynavis import props, selection, toast
LIMIT = 6.0
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
kept = []
unknown = 0
for item in items:
length = props.get(item, 'Item', 'Length')
if not isinstance(length, float):
unknown += 1 # missing, or not a number
elif length > LIMIT:
kept.append(item)
if kept:
selection.set_items(kept)
toast.success('%d object(s) over %g m' % (len(kept), LIMIT),
'%d had no usable Length.' % unknown if unknown else '')
else:
toast.warning('Nothing matched', 'The selection is unchanged.')Two siblings cover the bulk cases. props.all_of(item) returns every property on
an item as flat {'category', 'property', 'value'} rows, which is the shape
output.print_table and a spreadsheet export both want.
props.value_map(item, spec) pulls several named properties into one dict in a
single call, from a {key: (category, prop)} spec, which is how you build one report
row per object without a column's worth of lookups in the loop.
"""Builds one report row per selected object from a named set of properties."""
from pynavis import output, props, selection, toast
SPEC = {
'Type': ('Item', 'Type'),
'Level': ('Element', 'Level'),
'Mark': ('Element', 'Mark'),
}
COLUMNS = ['Type', 'Level', 'Mark']
items = selection.get_items()
if not items:
toast.info('Nothing selected')
else:
rows = []
for item in items:
values = props.value_map(item, SPEC)
rows.append([item.DisplayName]
+ ['' if values[key] is None else values[key] for key in COLUMNS])
output.print_table(rows, ['Object'] + COLUMNS)props.set_custom(items, tab_name, values) writes values as the
whole tab on every item, so a field that was on the tab before and is left out
of values this time disappears. To add one field to an existing tab, read it back
with props.get_custom(item, tab_name) first, merge your value in, and pass the
merged dictionary. It returns (written, errors), because a failure inside the
underlying write can otherwise vanish without a trace, and it batches the whole list into one
undo entry.
forms.ask_string, forms.save_file and
forms.open_file return None when the user cancels, and
forms.confirm returns False. Do nothing at all in that branch. No
toast, no output window, no “operation cancelled”. The user cancelled on purpose
and telling them so is noise.
Replace the selection with a computed subset#
selection.set_items takes any iterable of ModelItem and replaces
the current selection wholesale. It builds the .NET collection for you, so you never have to
think about ModelItemCollection.
"""Reduces the selection to one representative object per source model."""
from pynavis import doc, selection, toast
items = selection.get_items()
if not items:
toast.info('Nothing selected')
else:
document = doc.get_doc()
models = document.Models
seen = {}
for item in items:
index = models.CreatePathId(item).ModelIndex
if index not in seen:
seen[index] = item
selection.set_items(list(seen.values()))
toast.success('%d model(s) represented' % len(seen),
'Reduced from %d item(s).' % len(items))selection.clear() is the other half of the pair. There is no
“add to selection” call: read the current items, extend the list, and set the
whole thing back.
Isolate what you found, then frame it#
Finding the right objects is half a tool. Showing them is the other half.
pynavis.view has the three moves a tool makes straight after it changes the
selection.
"""Isolates the current selection and zooms to it."""
from pynavis import selection, toast, view
items = selection.get_items()
if not items:
view.unhide_all()
toast.info('Nothing selected', 'Everything is visible again.')
else:
kept = view.isolate(items)
view.zoom_selected()
toast.success('Isolated %d item(s)' % kept, 'Unhide All puts the model back.')view.isolate hides every root item and then unhides the ones you passed, both
inside one transaction, so Ctrl+Z restores the model in a single step and the host's own Unhide
All clears it too. An empty list is a no-op rather than a whole-model blackout.
view.zoom_selected returns False and logs when the host refuses the
camera move; carry on either way, because a tool that selected the right objects has already
done its job.
Keep a selection in memory and do set maths on it#
pynavis.memory is a pocket calculator's memory for model items: one register per
document, kept on disk under %APPDATA%\pyNavis\memory, with M+, M- and MR
equivalents. Every action returns a Result you hand straight to a toast, so a
button script is two lines.
"""Adds the current selection to the document's memory register."""
from pynavis import memory, toast
result = memory.add()
toast.show(result.level, result.message, result.detail)memory.memorize() sets the register to the selection,
memory.recall() sets the selection to the register, and
memory.add(), memory.subtract() and memory.intersect()
are the set maths between the two. memory.clear() empties it. None of them raises
for an expected failure: an empty selection or an empty register comes back as an error-level
Result with a detail line saying what to do next.
Two more are worth a button of their own. memory.step_next() and
memory.step_prev() walk the register one item at a time, wrapping at both ends,
and memory.save_as_set(name) promotes the whole register to a native Navisworks
selection set.
memory.step_next() replaces the current selection with the single item it
landed on and flies the camera to it. Entries that no longer resolve in this document are
skipped rather than stepped onto, so the "item 4 of 27" counter counts resolvable items, not
stored ones. The register itself survives a Navisworks restart, because it lives in a file
keyed off the document path, not in the session.
Reading the model#
List every model and its source file#
A federated model is a stack of files, and the first question anyone asks of a new one is what is actually in it.
"""Lists the loaded models and the files they came from."""
from pynavis import doc, output, toast
document = doc.get_doc()
rows = []
for index, model in enumerate(document.Models):
rows.append([index, model.RootItem.DisplayName, model.SourceFileName])
if not rows:
toast.info('This document has no models loaded.')
else:
output.print_md('## %s' % (doc.get_title() or 'Untitled document'))
output.print_table(rows, ['#', 'Model', 'Source file'])The Navisworks collection indexer walks the collection to reach position n, so a
loop over node.Children[i] is quadratic and gets ruinous somewhere in the low
thousands. Always write for child in node.Children: and use
enumerate when you need the position. The same applies to
SelectedItems, Models and a clash test's results.
Report the distinct values of a property#
Counting the distinct values of one property across a selection is how you find out whether a model is tagged consistently. It is also the smallest useful report.
"""Counts the distinct values of one property across the selection."""
from pynavis import doc, output, selection, toast
CATEGORY = 'Item'
PROPERTY = 'Type'
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
counts = {}
for item in items:
value = doc.get_property(item, CATEGORY, PROPERTY)
text = value.ToDisplayString() if value is not None else '(not set)'
counts[text] = counts.get(text, 0) + 1
rows = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
output.print_md('## %s / %s' % (CATEGORY, PROPERTY))
output.print_table([[text, count] for text, count in rows],
[PROPERTY, 'Count'])
output.print_md('%d distinct value(s) across %d object(s).'
% (len(rows), len(items)))Sorting by (-count, text) rather than by count alone keeps the order stable
between runs, which matters when the user is comparing two exports.
Walk saved viewpoints with their folder path#
doc.walk_saved_viewpoints() flattens the saved-viewpoints tree depth first
and yields (folder_names, saved_item). folder_names is the list of
ancestor folder names, empty for a top-level item.
"""Lists every saved viewpoint with the folder it lives in."""
from pynavis import doc, output, toast
rows = []
for folders, item in doc.walk_saved_viewpoints():
rows.append([
'/'.join(folders) or '(top level)',
item.DisplayName,
item.GetType().Name,
])
if not rows:
toast.info('This document has no saved viewpoints.')
else:
output.print_table(rows, ['Folder', 'Name', 'Kind'])
output.print_md('%d saved item(s).' % len(rows))The walk descends into folders only. A saved animation is a group node too, but it is
yielded as a single item rather than being unpacked into its keyframes, which is what you
want in a listing. Test with item.GetType().Name or check for a
Viewpoint attribute if you need to tell the kinds apart.
Producing output#
Export the selection to CSV#
The save dialog, the silent cancel, the write, the toast. This is the pattern for every export tool you will write.
"""Exports the selected objects and one property to a CSV file."""
import csv
from pynavis import doc, forms, selection, toast
CATEGORY = 'Item'
PROPERTY = 'Type'
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
default_name = (doc.get_title() or 'selection') + '.csv'
path = forms.save_file(default_name=default_name,
title='Export selection')
if path is not None: # None means cancelled: say nothing
with open(path, 'w') as handle:
writer = csv.writer(handle, lineterminator='\n')
writer.writerow(['Name', PROPERTY, 'Source model'])
for item in items:
value = doc.get_property(item, CATEGORY, PROPERTY)
writer.writerow([
item.DisplayName,
value.ToDisplayString() if value is not None else '',
item.Model.SourceFileName if item.Model is not None else '',
])
toast.success('Exported %d row(s)' % len(items), path)Pass lineterminator='\n' to csv.writer. Without it the module
writes \r\n into a file already opened in text mode and every row ends up with a
blank line after it.
Show a table in the output window#
output.print_table(rows, headers) escapes your cells, styles the table to
match the window, and works out its own alignment. A column is treated as numeric when every
non-empty cell in it parses as a number, so numbers right-align and tabulate without you
declaring anything.
"""Reports the object count and combined length per source model."""
from pynavis import doc, output, selection, toast
items = selection.get_items()
if not items:
toast.info('Nothing selected')
else:
document = doc.get_doc()
models = document.Models
per_model = {}
for item in items:
index = models.CreatePathId(item).ModelIndex
name = models[index].SourceFileName
count, length = per_model.get(name, (0, 0.0))
value = doc.get_property(item, 'Item', 'Length')
try:
length += float(value.ToDisplayString()) if value is not None else 0.0
except ValueError:
pass
per_model[name] = (count + 1, length)
rows = [[name, count, '%.2f' % length]
for name, (count, length) in sorted(per_model.items())]
output.print_table(rows, ['Source model', 'Objects', 'Length'])Two sibling calls round the surface out. output.print_md renders headings,
bold, inline code, lists and links, which is enough for the prose around a table.
output.format_table returns a plain-text table you can hand to
print, which is what you want when the report is going to be pasted into an
email.
Link back to the model from the output window#
A report that names an object is useful. A report where clicking the row selects the
object is a different tool. output.element_link(item, label) returns an HTML
string for a link that selects that ModelItem, which you embed in your own
markup and hand to output.print_html.
"""Lists the selected objects, each with a link that reselects it."""
from pynavis import output, selection, toast
def escape(text):
return (str(text).replace('&', '&')
.replace('<', '&lt;')
.replace('>', '&gt;'))
items = selection.get_items()
if not items:
toast.info('Nothing selected')
else:
cells = ''.join(
'<tr><td>%s</td><td>%s</td></tr>'
% (escape(item.DisplayName), output.element_link(item, 'Show'))
for item in items[:200])
output.print_html(
'<table class="pynavis"><thead><tr>'
'<th>Object</th><th></th></tr></thead>'
'<tbody>%s</tbody></table>' % cells)
if len(items) > 200:
output.print_md('... and %d more.' % (len(items) - 200))print_table escapes every cell. print_html does not, because the
whole point of it is that you are supplying markup. Any model-derived text you interpolate
must be escaped first, and object names in a real federated model do contain angle brackets
and ampersands. Cap the row count too: a link table of fifty thousand rows is not a report.
Report progress on a long loop#
Your script owns the UI thread for its whole run, so a long loop freezes Navisworks.
output.progress(fraction, label) paints a progress bar in the output window and
gives the user something other than a white window to look at.
"""Checks every selected object for a missing property."""
from pynavis import doc, output, selection, toast
CATEGORY = 'Item'
PROPERTY = 'Type'
items = selection.get_items()
total = len(items)
if not total:
toast.info('Nothing selected')
else:
missing = []
for index, item in enumerate(items):
if index % 100 == 0:
output.progress(float(index) / total,
'Checking %d of %d' % (index, total))
if doc.get_property(item, CATEGORY, PROPERTY) is None:
missing.append(item.DisplayName)
output.progress(1.0)
if not missing:
toast.success('All %d object(s) have %s' % (total, PROPERTY))
else:
output.print_md('## %d object(s) missing %s' % (len(missing), PROPERTY))
output.print_table([[name] for name in missing[:500]], ['Object'])Update every hundredth iteration rather than every iteration: repainting is not free, and
on a large selection the paints cost more than the work. output.progress(1.0)
completes the bar, and you should always call it, including on the paths that end early.
The Navisworks API is not thread-safe and your script already runs on the UI thread. Moving the loop onto a background thread to keep the window responsive corrupts the document or crashes the process. If a job is genuinely too long to hold the UI, cut the work, not the thread.
Working in the 3D view#
Ask the user to click a point#
pynavis.pick borrows the document's tool for the length of one click and hands
it straight back. It feels like the native Measure tool on purpose: the same vertex and edge
snapping, the same cursors, a marker under the pointer, and the view still orbits and pans
while the user lines the shot up.
"""Measures the straight-line distance between two picked points."""
import math
from pynavis import pick, toast
first = pick.point('Click the first point')
if first is not None: # None means cancelled: say nothing
second = pick.point('Click the second point')
if second is not None:
distance = math.sqrt(sum((a - b) ** 2 for a, b in zip(first.point, second.point)))
toast.success('%.3f in model units' % distance,
'Snapped to %s and %s.' % (first.snap or 'a face',
second.snap or 'a face'))A Hit always has .point, an (x, y, z) tuple of floats.
.normal, .item and .snap may each be None,
so guard them the way the toast above does. Cancelling with Esc, a right-click or by choosing
another tool gives None, and that branch should say nothing at all.
Navisworks scans for plugins only at startup, so a loader updated in place has no pick tool
in it until the next restart. pick.point and pick.point_then raise
pick.Unavailable in that state, and the exception's own message is the remedy, so
it is safe to toast as it stands. pick.measure_point asks the same question
through the host's own Measure tool and needs no plugin, at the price of knowing only the
point: no normal, no item, no snap kind.
try: hit = pick.point('Click a point') except pick.Unavailable as reason: toast.warning('Using the Measure tool instead', str(reason)) hit = pick.measure_point('Click a point')
From a dock panel or a modeless window, blocking the UI thread is not an option, so use
pick.point_then(callback) instead. It starts the pick, returns at once, and calls
callback(hit_or_None) when the pick ends. pick.cancel() ends a
running pick from elsewhere; the callback still fires, with None.
Draw the answer into the view#
A measurement the user has to read off a toast is worse than one drawn where they are
looking. pynavis.overlay holds world-coordinate line segments that the overlay
plugin redraws every frame, so they follow orbit and zoom instead of sitting on the glass.
"""Picks two points and draws a labelled dimension between them."""
import math
from pynavis import overlay, pick, toast
TAG = 'quick-dimension'
first = pick.point('Click the first point')
if first is None:
overlay.clear(TAG) # cancelled: take the last one down
overlay.redraw()
else:
second = pick.point('Click the second point')
if second is not None:
distance = math.sqrt(sum((a - b) ** 2 for a, b in zip(first.point, second.point)))
label = '%.3f' % distance
drawn = overlay.dimension(TAG, first.point, second.point, label)
overlay.redraw()
if drawn:
toast.success('Dimension drawn', label)
else:
toast.success('Distance is %s' % label,
'Restart Navisworks to finish updating pyNavis.')The tag is the whole identity model: drawing again under the same tag replaces what was
there, and overlay.clear(tag) takes it down. overlay.clear() with no
argument removes everything. Nothing reaches the screen until overlay.redraw(), so
end every batch with one call to it.
Pass anchor=(first_point, end_point) to overlay.add or
overlay.dimension and the item belongs to the native point-to-point measurement
between those two points: it disappears on the first frame that measurement changes or is
cleared. Paired with pick.measure_points, which asks the user for that measurement
and leaves it on screen, it lets a tool annotate the user's own measuring and then get out of
the way with nothing left to tidy up.
Every overlay call returns False when the overlay plugin is not loaded, which
is what an un-restarted loader looks like. Report your own result anyway, the way the recipe
above does: the number is still right, only the drawing is missing.
Measure real faces, not bounding boxes#
geometry.world_triangles(item) is the one route in the library to an object's
actual faces. It returns world-coordinate triangles as plain tuples, so everything you do with
them afterwards is pure Python and testable.
"""Reports the total surface area of the selected objects."""
from pynavis import geometry, selection, toast
def area(triangle):
(ax, ay, az), (bx, by, bz), (cx, cy, cz) = triangle
ux, uy, uz = bx - ax, by - ay, bz - az
vx, vy, vz = cx - ax, cy - ay, cz - az
nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
return 0.5 * (nx * nx + ny * ny + nz * nz) ** 0.5
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
notes = []
total = 0.0
measured = 0
for item in items:
triangles = geometry.world_triangles(item, note=notes.append)
if triangles:
total += sum(area(t) for t in triangles)
measured += 1
if not measured:
toast.warning('Could not read any geometry', notes[0] if notes else '')
else:
toast.success('%.2f square model units' % total,
'%d of %d object(s) measured.' % (measured, len(items)))world_triangles returns [] when the item declares more primitives
than its budget, when the walk yields nothing, or when no matrix layout fitted.
None of those means "no faces", so never report zero. Pass a callable as note, as
above, and you get one string per reason.
The walk subclasses a COM interface to receive vertices, which only the IronPython engine
can do, so a bundle calling it must leave engine: at its default or set it to
ironpython. pynavis.section carries the same constraint for the same
reason.
Copy view state now, paste it after a restart#
pynavis.viewstate carries three kinds of view state between sessions of the
same document: 'section', the active clip planes; 'hidden', which
items are hidden; and 'overrides', the permanent colour and transparency
overrides. Each is copied and pasted independently.
"""Copies whichever kind of view 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)"""Pastes a copied view state, offering only the kinds actually available."""
from pynavis import forms, toast, viewstate
kinds = viewstate.available_kinds()
if not kinds:
toast.info('Nothing copied', 'Run Copy state first.')
elif len(kinds) == 1:
result = viewstate.paste_state(kinds[0])
toast.show(result.level, result.message, result.detail)
else:
label = forms.ask_options(None, [viewstate.label_for(kind) for kind in kinds],
title='Paste state')
if label is not None:
result = viewstate.paste_state(viewstate.kind_for(label))
toast.show(result.level, result.message, result.detail)Section and hidden state live in one JSON file per document under
%APPDATA%\pyNavis\viewstate, so they survive a Navisworks restart. Overrides are
different: no API exposes applied overrides item by item, so a copy stores the host's own
opaque snapshot inside the document as a reserved saved viewpoint named
.pyNavis Copied Overrides. It shows up in the Saved Viewpoints tree, and it
travels with the file only if the file is saved.
viewstate.paste_state('hidden') clears all hiding first and then hides the
copied items, so anything hidden since the copy becomes visible again. That is the point, but
it surprises people who expected it to add. Copying an empty state is valid and useful for
exactly this reason: copy 'hidden' with nothing hidden and a later paste unhides
everything.
Asking the user#
Confirm a destructive action#
Anything that deletes, overwrites or edits the document in bulk gets a confirmation that says how much, in the user's units, before it happens.
"""Removes every empty saved-viewpoint folder, after confirming."""
from pynavis import forms, toast, viewpoints
rows = viewpoints.snapshot()
parents = set(row['parent_key'] for row in rows)
empty = [row for row in rows if row['is_folder'] and row['key'] not in parents]
if not empty:
toast.info('No empty viewpoint folders')
else:
question = ('Delete %d empty viewpoint folder(s)?\n\n'
'This changes the document.' % len(empty))
if forms.confirm(question, 'Tidy viewpoints'):
removed, errors = viewpoints.apply_deletes(
[row['guid'] for row in empty])
if errors:
toast.warning('Deleted %d folder(s), %d failed.'
% (removed, len(errors)), errors[0])
else:
toast.success('Deleted %d empty folder(s)' % removed)forms.confirm returns True on Yes and False on
everything else. There is no third state, so if forms.confirm(...): with no
else is the complete handling.
Ask for a value with a default and validate it#
forms.ask_string is a single-line prompt with no validation of its own. Loop
until the answer is usable, and let cancel out of the loop untouched.
"""Selects every object longer than a distance you type."""
from pynavis import doc, forms, selection, toast
limit = None
while limit is None:
answer = forms.ask_string('Minimum length in metres:',
default='6', title='Select long objects')
if answer is None:
break # cancelled: change nothing, say nothing
try:
limit = float(answer)
if limit <= 0:
raise ValueError()
except ValueError:
limit = None
forms.alert('Enter a positive number.', 'Select long objects')
if limit is not None:
kept = []
for item in selection.get_items():
value = doc.get_property(item, 'Item', 'Length')
if value is None:
continue
try:
if float(value.ToDisplayString()) >= limit:
kept.append(item)
except ValueError:
pass
if kept:
selection.set_items(kept)
toast.success('%d object(s) at or over %g m' % (len(kept), limit))
else:
toast.warning('Nothing matched', 'The selection is unchanged.')The break leaves limit as None, and the
if limit is not None: below it does the rest. That is the whole cancel path, and
it produces no user-visible output at all.
A settings dialog in config.py, read back by script.py#
A bundle can ship a config.py beside script.py. Shift-clicking
the button runs it instead, on the same engine and with the same globals. Settings live in
%APPDATA%\pyNavis\settings\<tool>.json, one file per tool key.
Put the tool key and the defaults in a module beside both scripts, because the bundle folder is on the import path for its own scripts:
"""Shared settings contract for Property export."""
TOOL = 'property_export'
DEFAULTS = {'category': 'Item', 'property': 'Type'}"""Options for Property export."""
from pynavis import forms, settings, toast
import prefs
values = settings.load(prefs.TOOL, prefs.DEFAULTS)
category = forms.ask_string('Property category:', values['category'],
'Property export')
if category is not None:
name = forms.ask_string('Property name:', values['property'],
'Property export')
if name is not None:
if not category.strip() or not name.strip():
toast.error('Both fields are required', 'Nothing was saved.')
else:
settings.save(prefs.TOOL, {'category': category.strip(),
'property': name.strip()})
toast.success('Saved', '%s / %s' % (category.strip(), name.strip()))"""Exports the selection with the property chosen in Shift+Click."""
from pynavis import doc, output, selection, settings, toast
import prefs
values = settings.load(prefs.TOOL, prefs.DEFAULTS)
items = selection.get_items()
if not items:
toast.info('Nothing selected',
'Shift+Click this button to choose the property.')
else:
rows = []
for item in items:
value = doc.get_property(item, values['category'], values['property'])
rows.append([item.DisplayName,
value.ToDisplayString() if value is not None else ''])
output.print_table(rows, ['Object', values['property']])settings.load never raises. A missing file, an empty file, a corrupt file or
a file holding something that is not an object all yield a fresh copy of your defaults, and
keys you have since dropped from DEFAULTS are discarded rather than leaking back
in. That means you can change your defaults between releases without writing a migration.
Two chained prompts is the cheap version. When a tool has more than a couple of options, build a real WPF window with the runtime's design-system helpers, the way the shipped Section Fit tool does. Talking to the user covers that surface, and Click actions and config.py covers the Shift+Click contract in full.
Bulk edits#
Rename saved viewpoints in one undo step#
Thousands of individual edits produce thousands of undo entries, and a user who wants to
back out has to press Ctrl+Z until their hand hurts.
viewpoints.transaction(name) wraps the whole batch into one.
"""Strips a leading 'VP ' from every saved viewpoint name."""
from pynavis import doc, toast, viewpoints
document = doc.get_doc()
saved = document.SavedViewpoints
rows = viewpoints.snapshot(document)
targets = [row for row in rows
if not row['is_folder'] and row['name'].startswith('VP ')]
if not targets:
toast.info('No viewpoint names start with "VP ".')
else:
renamed = 0
with viewpoints.transaction('Strip VP prefix', document):
for row in targets:
item = viewpoints.resolve(saved, row['guid'])
if item is None:
continue # gone since the snapshot: skip it
saved.EditDisplayName(item, row['name'][3:])
renamed += 1
toast.success('Renamed %d viewpoint(s)' % renamed, 'One undo step.')The context manager commits on a clean exit and disposes without committing if the body raises, so a failure halfway through leaves the document as it was. A document already inside a transaction is left alone, and a document that cannot start one still runs the body, unbatched, rather than failing.
The plan and apply split#
The library ships a better version of the same job. viewpoints.plan_renames
is pure: it takes snapshot rows, the keys the user checked, and an operation, and returns a
plan. It touches nothing. viewpoints.apply_renames writes the plan, inside its
own transaction, resolving every entry by GUID.
"""Prefixes every saved viewpoint with a code you type."""
from pynavis import forms, output, toast, viewpoints
rows = viewpoints.snapshot()
if not rows:
toast.info('This document has no saved viewpoints.')
else:
code = forms.ask_string('Prefix to add:', default='A-', title='Prefix viewpoints')
if code:
keys = [row['key'] for row in rows if not row['is_folder']]
plan = viewpoints.plan_renames(rows, keys,
{'type': 'affix', 'prefix': code})
if plan['problems']:
toast.error('Cannot rename', plan['problems'][0])
elif plan['collisions']:
toast.error('%d name collision(s)' % len(plan['collisions']),
plan['collisions'][0])
elif not plan['renames']:
toast.info('Nothing to rename', 'Every name already has that prefix.')
else:
def tick(done, count):
output.progress(float(done) / count,
'Renaming %d of %d' % (done, count))
applied, errors = viewpoints.apply_renames(plan['renames'],
progress=tick)
output.progress(1.0)
if errors:
toast.warning('Renamed %d, %d failed.' % (applied, len(errors)),
errors[0])
else:
toast.success('Renamed %d viewpoint(s)' % applied)The plan reports collisions instead of fixing them: two siblings that would end up with
the same name, or a name that would end up empty. Nothing is ever auto-corrected, so your UI
can block Apply and show the user exactly what is wrong. The operation dictionary supports
'replace' (with an optional regex flag),
'affix', 'number' and 'case'.
A snapshot row carries both a guid and a key like
'0/2/1'. The key describes tree shape and goes stale the moment anything moves,
which in practice means the moment your first edit lands. Every apply function in
viewpoints re-resolves by GUID for exactly that reason, and so should any loop
you write yourself.
Delete only the checked subset#
viewpoints.plan_deletes works out the minimal set of items to remove. A
checked folder stands in for its contents only when every descendant is checked too;
otherwise the folder survives and only the checked descendants go. The returned
count is how many rows will actually disappear, folder contents included, which
is the number to put in the confirmation.
"""Deletes every saved viewpoint whose name contains TEMP."""
from pynavis import forms, output, toast, viewpoints
rows = viewpoints.snapshot()
checked = [row['key'] for row in rows if 'TEMP' in row['name'].upper()]
if not checked:
toast.info('Nothing matched', 'No saved item has TEMP in its name.')
else:
plan = viewpoints.plan_deletes(rows, checked)
question = ('Delete %d saved item(s)?\n\n'
'This removes them from the document.' % plan['count'])
if forms.confirm(question, 'Delete viewpoints'):
def tick(done, count):
output.progress(float(done) / count,
'Deleting %d of %d' % (done, count))
removed, errors = viewpoints.apply_deletes(
plan['guids'], progress=tick if plan['count'] > 500 else None)
output.progress(1.0)
if errors:
toast.warning('Deleted %d item(s), %d failed.'
% (removed, len(errors)), errors[0])
else:
toast.success('Deleted %d item(s)' % removed)plan['count'] and len(plan['guids']) are different numbers on
purpose: the first is what vanishes from the user's tree, the second is how many delete calls
you make. Confirm with the first and iterate the second.
Summarise every clash test#
clash.summarize() returns one dictionary per test with 'name',
'total' and 'by_status', a mapping of status name to count. It
reads only, so it is safe to run on anything.
"""Summarises every Clash Detective test by result status."""
from pynavis import forms, output, toast
try:
from pynavis import clash
except ImportError:
clash = None
STATUSES = ['New', 'Active', 'Reviewed', 'Approved', 'Resolved']
if clash is None:
forms.alert('Clash Detective is not available in this Navisworks edition.')
else:
summaries = clash.summarize()
if not summaries:
toast.info('This document has no clash tests.')
else:
headers = ['Test', 'Total'] + STATUSES
rows = [[s['name'], s['total']]
+ [s['by_status'].get(status, 0) for status in STATUSES]
for s in summaries]
output.print_md('## Clash summary')
output.print_table(rows, headers)
output.print_md('%d test(s), %d result(s) in total.'
% (len(summaries), sum(s['total'] for s in summaries)))The guarded import is worth keeping. pynavis.clash loads the Clash Detective
assembly at import time, which is not present in every Navisworks edition, and a bare
ImportError traceback is a worse message than the alert above.
clash.apply_plan commits a rebuilt copy of the test, which leaves the wrapper
object you passed in disposed along with every result you got from it. Read anything the
report needs, starting with test.DisplayName, before you call it.
ModelItem references survive, because they live in the model rather than in the
test.
Group a clash test, without freezing Navisworks#
Grouping is the plan-and-apply split again, and here it is not a style preference: it is the
difference between twelve seconds and days. clash.snapshot_results reads the test
into plain dictionaries, clashgroup turns those into a plan with no Navisworks
import anywhere in it, and clash.apply_plan commits the whole plan as one
document edit.
"""Groups the chosen clash test by root cause, in one edit."""
from pynavis import clash, clashgroup, forms, output, toast
tests = list(clash.walk_tests())
if not tests:
toast.info('This document has no clash tests.')
else:
test = forms.select_from_list([(t.DisplayName, t) for t in tests],
title='Group a clash test')
if test is not None: # None means cancelled: say nothing
total, grouped = clash.count_results(test)
if not total:
toast.info('That test has no results', 'Run it first.')
else:
name = test.DisplayName # read it BEFORE apply_plan
rows = clash.snapshot_results(
test, progress=lambda done: output.progress(
float(done) / total, 'Read %d of %d' % (done, total)))
plan = clashgroup.smart_plan(rows, tolerance=6.0 / clash.units_to_meters())
clash.apply_plan(test, plan,
progress=lambda f: output.progress(f, 'Grouping'))
output.progress(1.0)
toast.success('%d group(s) in %s' % (len(plan['groups']), name),
plan['explanation'])clashgroup.smart_plan is the zero-configuration route: it picks whichever side,
A or B, collapses the most clashes into the fewest elements, groups by that side's root-cause
element, then proximity-clusters whatever came out as a single-clash element. Its
explanation is the one line that tells a user why the grouping came out the way it
did, so show it. clashgroup.plan(rows, rule_ids) is the explicit form, where each
rule in turn subdivides the groups the previous rule made, and
clashgroup.RULES is the catalogue of (id, label) pairs to build a
dropdown from rather than hard-coding ids.
tolerance is measured in whatever unit the snapshot's center
coordinates are in, which is model units, not metres. Divide the metres the user typed by
clash.units_to_meters(), as the recipe does, or a six-metre cluster becomes six
millimetres on a millimetre model.
clash.apply_plan commits through a whole-test replace, which retires the
test object you passed in. Read DisplayName, and anything else the
report needs, before the call. clash.ungroup(test) is the other direction, and
dissolves only groups pyNavis created unless you pass ours_only=False.
Cancelling a long read is a shape of its own here.
clash.snapshot_results stops when the progress callback returns
exactly False, and raises clash.Cancelled; returning
None, which a callback written for side effects does implicitly, means carry on.
That is deliberate, because the same tick function then works unchanged on
apply_plan, which ignores return values entirely.
Build a query, save it as a set, and round-trip it through Excel#
pynavis.sets is three things joined end to end: a fluent query builder that
needs no live document, a way to run that query and save it as a native search set, and a
plain dict shape the same query can travel through, out to a spreadsheet and back in again.
The recipe below does all three in order.
"""Finds long ducts, saves the search as a set, and exports it to Excel."""
from pynavis import forms, sets, toast, xl
q = (sets.query()
.prop('Item', 'Type').contains('Duct')
.and_prop('Element', 'Length').gt(6.0)
.build())
items = sets.run(q)
if not items:
toast.info('Nothing matched', 'No duct is over 6 m.')
else:
saved = sets.create('Long ducts', q, folder='Coordination/MEP')
toast.success('Saved "%s"' % saved.DisplayName, '%d item(s)' % len(items))
path = forms.save_file(
filter='Excel files (*.xlsx)|*.xlsx|All files (*.*)|*.*',
default_name='long_ducts.xlsx', title='Export the set')
if path is not None: # None means cancelled: say nothing
conditions = sets.to_dict(saved)['or'][0]['and']
rows = [[c.get('category', ''), c.get('prop', ''), c['op'], c.get('value', '')]
for c in conditions]
xl.write(path, rows, headers=['Category', 'Property', 'Op', 'Value'])sets.query() returns a fresh builder. Each .prop(category, name)
starts a condition, and the operator that follows, .contains,
.equals, .gt and the rest, completes it into the current AND group.
.build() validates every condition and raises ValueError, listing
every problem, before you ever touch the document.
sets.run(q) compiles the query and returns the matches as a plain
ModelItemCollection, which is what you want for a quick check.
sets.create(name, q, folder=...) is the save step: it compiles the same query
into a native search set and files it under the given folder, creating any missing folder
segments along the way, so the set keeps updating itself as the model changes, the way every
set in the Sets window does.
A native search set can only ever hold one condition list. Calling .or_group()
on the builder to add a second, ORed group works fine for sets.run, which unions
the groups' results in Python, but sets.create and sets.update raise
ValueError the moment a query has more than one group. Save one set per group
instead of trying to save the whole OR as a single set.
The export step is deliberately plain: sets.to_dict(saved) turns the saved
set's compiled conditions back into the same {'or': [{'and': [...]}]} shape the
builder produces, and the script flattens that one AND group into spreadsheet rows by hand.
The shipped Sets from Excel tool, on the Tools panel, does the same thing for
every set in the document at once, plus the matching import: it flattens
sets.export_all() to one row per condition and rebuilds
sets.import_dict()'s shape from rows sharing a folder and set name, so it is the
reference to copy from for a fuller round trip than the recipe above needs.
"""Re-imports the sets a previous run exported, replacing any of the same name."""
from pynavis import forms, sets, toast, xl
path = forms.open_file(
filter='Excel files (*.xlsx)|*.xlsx|All files (*.*)|*.*',
title='Import sets from Excel')
if path is not None: # None means cancelled: say nothing
spec = {'sets': [{
'name': 'Long ducts', 'folder': 'Coordination/MEP',
'query': {'or': [{'and': [
{'category': c[0], 'prop': c[1], 'op': c[2], 'value': c[3]}
for c in xl.read(path)[1:] if c[2]
]}]},
}]}
created, errors = sets.import_dict(spec, replace=True)
if errors:
toast.warning('Imported %d, %d failed' % (created, len(errors)), errors[0])
else:
toast.success('Imported %d set(s)' % created)Every numeric cell xl.read hands back is a float, so a condition
value that started as the integer 3 comes back as 3.0. The shipped
Sets from Excel tool normalises an integral float back to int on the way in for
exactly this reason; a hand-rolled import like the sketch above should do the same before
handing a value to import_dict. Separately, sets.to_dict and
sets.export_all cannot recover whether a condition was built with
.ignore_case(): reading a compiled search condition back out of the document
carries no trace of it, so a case-insensitive condition always round-trips as a plain,
case-sensitive one.
Structuring a bigger tool#
Split pure logic from API calls#
This is the single most important architectural decision in a pyNavis tool, and the
library is built this way throughout. clashgroup has no Navisworks import at
all. viewpoints.plan_renames, plan_deletes and
sort_ops are pure. The geometry half of section,
convex_hull through fit, is pure. In every case the API-bound half
is a thin shell that reads the document into plain data, calls the pure half, and writes the
answer back.
The worked example below is a length-check tool. The pure half is a module beside the script; the API half is the script.
"""Pure rules for the length check. No pynavis import, no Navisworks."""
def plan_flags(rows, limit):
"""Which rows break the length limit.
rows are (name, length_or_None) pairs. Returns
{'over': [(name, length)], 'unknown': [name], 'checked': int}.
"""
over = []
unknown = []
for name, length in rows:
if length is None:
unknown.append(name)
elif length > limit:
over.append((name, length))
over.sort(key=lambda pair: -pair[1])
return {'over': over, 'unknown': unknown, 'checked': len(rows)}
def summary(plan, limit):
"""One sentence describing a plan."""
if not plan['over']:
return 'All %d object(s) are within %g m.' % (plan['checked'], limit)
return ('%d of %d object(s) exceed %g m; the longest is %.2f m.'
% (len(plan['over']), plan['checked'], limit, plan['over'][0][1]))"""Flags selected objects longer than the configured limit."""
from pynavis import doc, output, selection, toast
import rules
LIMIT = 6.0
def read_rows(items):
"""The only function here that touches the API."""
rows = []
for item in items:
value = doc.get_property(item, 'Item', 'Length')
try:
length = float(value.ToDisplayString()) if value is not None else None
except ValueError:
length = None
rows.append((item.DisplayName, length))
return rows
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
plan = rules.plan_flags(read_rows(items), LIMIT)
if not plan['over']:
toast.success(rules.summary(plan, LIMIT))
else:
output.print_md('## Length check')
output.print_table([[name, '%.2f' % length]
for name, length in plan['over']],
['Object', 'Length'])
output.print_md(rules.summary(plan, LIMIT))rules.py imports nothing from pynavis and nothing from .NET, so
it runs under pytest on a build machine with no Navisworks on it. Every branch
that decides anything is in there. script.py holds the two things you cannot
test that way, reading the model and writing the report, and neither of them makes a
decision.
import rules
def test_flags_only_what_is_over():
plan = rules.plan_flags([('a', 4.0), ('b', 9.0), ('c', None)], 6.0)
assert [name for name, _ in plan['over']] == ['b']
assert plan['unknown'] == ['c']
assert plan['checked'] == 3Share code across bundles with the extension lib folder#
A lib folder directly under your .extension folder is added to
the import path of every script in that extension. It is where anything used by more than one
bundle belongs.
MyTools.extension\
lib\
naming.py # importable from every bundle in this extension
geometry.py
MyTools.tab\
Quality.panel\
Check.pushbutton\
script.py # import naming
rules.py # importable from this bundle only
Fix.pushbutton\
script.py # import naming"""Checks object names against the naming standard."""
import naming # from MyTools.extension\lib
import rules # from this bundle folder
from pynavis import output, selection, toastTwo search paths are on every script: its own bundle folder first, then the extension's
lib folder. Nothing else in the extension is importable, so a module in one
bundle is invisible to another. That is deliberate; sharing goes through lib.
The runtime drops your bundle modules and your lib modules from the module
cache before every run, so editing naming.py and clicking the button again picks
up the change immediately. That is the same rule that applies to script.py
itself. Editing the pynavis library is different and does need a Reload, and so
does anything the parser reads: bundle.yaml, icons, folder names and
shortcuts.
The Result plus toast idiom#
Once a tool's logic lives in a module, that module needs a way to say what happened
without knowing how the answer will be shown. The library's answer is a small result object:
a level, a message, a detail line, and whatever counts are useful. It is exactly what
toast.show takes.
"""What an action did, ready for a button script to report."""
class Result(object):
def __init__(self, level, message, detail='', count=0):
self.level = level # 'success' | 'error' | 'info' | 'warning'
self.message = message
self.detail = detail
self.count = count
def ok(message, detail='', count=0):
return Result('success', message, detail, count)
def nothing(message, detail=''):
return Result('info', message, detail)"""Bulk tagging, returning a Result instead of touching the UI."""
import result
from pynavis import doc, selection
def tag_selection(value):
items = selection.get_items()
if not items:
return result.nothing('Nothing selected',
'Pick some objects and run this again.')
document = doc.get_doc()
tagged = 0
for item in items:
document.Models.OverridePermanentColor([item], value)
tagged += 1
return result.ok('Tagged %d object(s)' % tagged, count=tagged)"""Tags the current selection."""
import tagging
from pynavis import toast
from Autodesk.Navisworks.Api import Color
outcome = tagging.tag_selection(Color(1.0, 0.4, 0.0))
toast.show(outcome.level, outcome.message, outcome.detail)The button script is three lines, and it is three lines for every tool in the family. That
is what the shipped Memory and Section tools look like:
memory.memorize(), memory.recall(),
section.fit_to_selection(...) and section.clear() all return a
result object, and every one of their button scripts is a call followed by
toast.show(result.level, result.message, result.detail).
The payoff is that the decision about which level to use moves into the module,
where it can be tested, and out of the script, where it cannot. A module that returns
Result('warning', ...) for a partial success has that behaviour pinned by a
test; a script that chooses between toast.warning and toast.success
has it pinned by nothing.
Config stores, long jobs and custom windows#
A settings-backed tool without a shared key module#
script.get_config and script.save_config are
settings.load/settings.save with the tool key worked out for you
from the running bundle's own location, so a small tool no longer needs a shared module just
to hold a TOOL constant both scripts agree on, the way the earlier
config.py recipe on this page does with prefs.py.
"""Options for Property export."""
from pynavis import forms, script, toast
DEFAULTS = {'category': 'Item', 'property': 'Type'}
values = script.get_config(DEFAULTS)
category = forms.ask_string('Property category:', values['category'], 'Property export')
if category is not None:
name = forms.ask_string('Property name:', values['property'], 'Property export')
if name is not None:
if not category.strip() or not name.strip():
toast.error('Both fields are required', 'Nothing was saved.')
else:
script.save_config({'category': category.strip(), 'property': name.strip()})
toast.success('Saved', '%s / %s' % (category.strip(), name.strip()))"""Exports the selection with the property chosen in Shift+Click."""
from pynavis import doc, output, script, selection, toast
DEFAULTS = {'category': 'Item', 'property': 'Type'}
values = script.get_config(DEFAULTS)
items = selection.get_items()
if not items:
toast.info('Nothing selected',
'Shift+Click this button to choose the property.')
else:
rows = []
for item in items:
value = doc.get_property(item, values['category'], values['property'])
rows.append([item.DisplayName,
value.ToDisplayString() if value is not None else ''])
output.print_table(rows, ['Object', values['property']])Both scripts repeat DEFAULTS rather than sharing a module, which is fine at
this size, since the tool key is no longer a place the two scripts have to agree on by hand:
get_config and save_config both derive it from the bundle they run
in. Once a tool grows past one file's worth of logic, move DEFAULTS into a module
beside both scripts anyway, so it is written once.
The key get_config/save_config use is derived from the running
bundle's own path, so two different buttons can never collide, but it also means two bundles
can never deliberately share one settings file the way a run button and a report button
sometimes want to. Reach for pynavis.settings with an explicit TOOL
constant when that sharing is the point.
Save settings from a handler that fires after the script has returned#
script.get_config and friends ask the runtime "which command is running?" at
the moment they are called, and the runtime goes on answering with the most recent command long
after it finished. That is the right answer while script.py is still executing and
the wrong one in everything that fires later: a modeless dialog's 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.
script.bind() takes the answer while it is still correct and hands back an
object carrying the same six methods.
"""Remembers the window size, saved from the window's own Closed handler."""
from pynavis import forms, script
DEFAULTS = {'width': 360.0, 'height': 220.0}
me = script.bind() # while the answer is still right
values = me.get_config(DEFAULTS)
win = forms.WPFWindow('layout.xaml')
win.window.Width = values['width']
win.window.Height = values['height']
def on_closed(sender, args):
"""Fires after script.py has already returned."""
me.save_config({'width': win.window.Width, 'height': win.window.Height})
win.window.Closed += on_closed
win['OkButton'].Click += lambda s, e: win.close(True)
win.show_dialog()The bound object carries get_config, save_config,
reset_config, store_data, load_data and
data_exists, each behaving exactly like the module-level version but against the
command that was running when bind() was called. The same trap, and the same
shape of fix, applies to pynavis.panes: pass the bundle key explicitly from a
handler rather than letting it default.
A cancellable batch with a progress window and an Excel report#
forms.progress is the surface for a batch the user asked for and might want to
stop partway through: unlike output.progress it blocks the rest of Navisworks and
carries its own Cancel button. Pair it with pynavis.xl when the result belongs in
a spreadsheet rather than the output window.
"""Checks every selected object's length against a limit and writes an Excel report."""
from pynavis import doc, forms, selection, toast, xl
LIMIT = 6.0
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick some objects and run this again.')
else:
path = forms.save_file(
filter='Excel files (*.xlsx)|*.xlsx|All files (*.*)|*.*',
default_name='length-check.xlsx', title='Save length report')
if path is not None: # None means cancelled: say nothing
over = []
checked = 0
try:
with forms.progress('Checking lengths', 'Starting') as p:
for item in items:
p.check() # raises forms.Cancelled on Cancel
value = doc.get_property(item, 'Item', 'Length')
if value is not None:
try:
length = float(value.ToDisplayString())
if length > LIMIT:
over.append([item.DisplayName, length])
except ValueError:
pass
checked += 1
p.update(float(checked) / len(items),
'%d of %d' % (checked, len(items)))
except forms.Cancelled:
xl.write(path, over, headers=['Object', 'Length'], sheet='Over limit')
toast.warning('Stopped after %d of %d' % (checked, len(items)),
'%d row(s) written so far.' % len(over))
else:
xl.write(path, over, headers=['Object', 'Length'], sheet='Over limit')
toast.success('%d of %d object(s) over %g m' % (len(over), len(items), LIMIT), path)xl.write runs on both the normal and the cancelled path, because a partial
report of what was checked before Cancel is still useful; it is the message that changes, not
whether the file gets written. forms.progress pumps the UI itself, so there is no
need for the every-hundredth-iteration throttling output.progress needs in a tight
loop.
p.check() raises inside the with forms.progress(...) block, which
closes the progress window on its way out, then keeps propagating. Catch it outside the
with, the way the try/except above does. Catching it
inside the block would leave the window open for the rest of the script.
A XAML dialog bundle#
Once a settings form has more than a couple of fields, forms.WPFWindow loads a
.xaml file shipped in the bundle folder and wires it up as a live window, instead
of chaining forms.ask_string prompts.
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Export options" Width="360" Height="220"
WindowStartupLocation="CenterOwner">
<StackPanel Margin="16">
<TextBlock Text="Decimal places" Margin="0,0,0,4"/>
<TextBox x:Name="DecimalsBox" Margin="0,0,0,12"/>
<CheckBox x:Name="HiddenCheck" Content="Include hidden items" Margin="0,0,0,16"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="CancelButton" Content="Cancel" Width="80" Margin="0,0,8,0"/>
<Button x:Name="OkButton" Content="OK" Width="80" IsDefault="True"/>
</StackPanel>
</StackPanel>
</Window>"""Options dialog built from layout.xaml instead of chained prompts."""
from pynavis import forms, script, toast
DEFAULTS = {'decimals': 2, 'include_hidden': False}
values = script.get_config(DEFAULTS)
win = forms.WPFWindow('layout.xaml')
win['DecimalsBox'].Text = str(values['decimals'])
win['HiddenCheck'].IsChecked = values['include_hidden']
win['OkButton'].Click += lambda s, e: win.close(True)
win['CancelButton'].Click += lambda s, e: win.close(False)
if win.show_dialog():
try:
decimals = int(win['DecimalsBox'].Text)
except ValueError:
toast.error('Decimal places must be a whole number')
else:
script.save_config({
'decimals': max(0, min(6, decimals)),
'include_hidden': bool(win['HiddenCheck'].IsChecked),
})
toast.success('Saved')forms.WPFWindow('layout.xaml') resolves the file against the running command's
own bundle folder, the same one icon.png lives in, so layout.xaml
sits right beside config.py and script.py with no path to get
wrong.
Export.pushbutton\
script.py
config.py # Shift+Click: opens layout.xaml
layout.xaml
bundle.yaml
icon.pngwin['OkButton'].Click += ... only works once the element exists, which is right
after WPFWindow(...) returns. Set every initial value and every event handler
before calling show_dialog(); nothing you do to the window after that call
returns takes effect until the next run.
Build a dock panel#
A *.dockpane bundle is not a script that runs and returns: it is a real
Navisworks dock panel. pane.xaml supplies the content, an optional
script.py runs once when the panel is built, and the ribbon gets a toggle button
that follows the panel open and closed instead of a button that fires and finishes.
This recipe is the same working bundle pyNavis ships as its own dockpane smoke test
(Smoke.extension/Smoke.tab/Panes.panel/01_Pane_Demo.dockpane), with two strings
swapped for ones that read better as teaching material instead of as a smoke-test label: the
tooltip below and the script.py docstring. Everything else, including every
element name and every line of behavior, matches the shipped bundle exactly.
title: Pane Demo
tooltip: A dockpane with live content<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="16">
<TextBlock x:Name="Headline" FontSize="16" FontWeight="SemiBold" Text="Pane demo" />
<TextBlock x:Name="Built" FontSize="11.5" Margin="0,4,0,0" Text="Built at (waiting)" />
<TextBlock x:Name="Sizes" FontSize="11.5" Text="Size: (waiting)" />
<TextBox x:Name="Typing" Margin="0,8,0,0" Padding="6,4,6,4" Text="Click here and type" />
<TextBlock x:Name="Keys" FontSize="11.5" Margin="0,4,0,0" Text="Keys seen: 0" />
<Button x:Name="Close" Margin="0,12,0,0" Padding="8,4,8,4" Content="Hide this panel" />
</StackPanel>"""Wires up pane.xaml when the panel is built."""
import datetime
pane = __pane__
pane.Find('Built').Text = 'Built at ' + datetime.datetime.now().strftime('%H:%M:%S')
typed = [0]
def on_key(sender, args):
typed[0] += 1
pane.Find('Keys').Text = 'Keys seen: %d (last %s)' % (typed[0], args.Key)
def on_size(sender, args):
pane.Find('Sizes').Text = 'Size: %d x %d' % (args.NewSize.Width, args.NewSize.Height)
def on_close(sender, args):
pane.Visible = False
pane.Find('Typing').PreviewKeyDown += on_key
pane.Content.SizeChanged += on_size
pane.Find('Close').Click += on_closePane Demo.dockpane\
bundle.yaml
pane.xaml
script.py
icon.png__pane__ is the only new global: a small proxy with Find(name) for
any named element in pane.xaml, Content for the root element itself,
read-only Title and BundleKey, and a get/set Visible for
showing or hiding the panel from code. script.py runs once, when the panel's
content is built, not on every click the way a pushbutton's does; wire event handlers here and
let them do the ongoing work.
Root the file in a Grid, StackPanel or similar, never
<Window>. A Window root is unwrapped as a fallback so it still
loads, but it is not the supported form: a dock panel is not a window and shipping one invites
surprises later. Start from a plain element.
Five panel slots ship with pyNavis. A new *.dockpane bundle claims a free one on
the next Reload, exactly like any other new bundle, and keeps that slot for good: Navisworks
remembers a pane's dock position, size and open state per slot, not per bundle, so reinstalling
the extension later restores the same layout. Once every slot is claimed, generating more with
the Panel slots button (or pynavis.panes.add_slots(count)) needs a full Navisworks
restart before the new slots exist, because Navisworks only discovers dock panes at startup. A
dockpane that loses the scramble for a slot still gets its ribbon toggle; clicking it toasts
that no slot is free instead of doing nothing.
Open a panel from an ordinary button#
A panel does not have to be opened from its own ribbon toggle.
pynavis.panes shows, hides and counts panels by bundle key, which is the bundle's
path inside its extension, so any tool can bring its own panel up when it has something to put
in it.
"""Runs a check, then opens the results panel to show it."""
from pynavis import panes, script, toast
PANEL = 'MyTools.tab/Quality.panel/Results.dockpane'
findings = run_check() # your own work, returning ModelItems
script.store_data('last_findings', [item.DisplayName for item in findings])
if panes.show(PANEL):
toast.success('%d finding(s)' % len(findings), 'Shown in the Results panel.')
else:
toast.warning('%d finding(s)' % len(findings),
'The Results panel has no slot yet. Restart Navisworks.')The panel and the button are separate runs, so what they share has to go through
script.store_data, which takes JSON-serialisable values only: names and ids
travel, ModelItem objects do not.
panes.hide(key) and panes.toggle(key) are the other two moves, and
panes.is_visible(key) reads the live state, which a Navisworks close button or a
workspace change updates directly, so it is never stale between clicks. All of them return
False when the bundle holds no slot. panes.slot_count() is how many
slots this session has, and panes.slot_of(key) is the 1-based slot a bundle
claimed, or 0 when every slot was taken.
The bundle_key argument defaults to whichever pyNavis command most recently
ran, not to the panel whose handler is calling. That default is right while
script.py is still executing and wrong in a Click handler wired up
inside it, which fires later, after the user may have run other tools. From a panel's own
handler, pass the key explicitly or use __pane__.Visible instead, which always
means this panel.