Bundles
Hooks
Scripts that run themselves, triggered by something happening in Navisworks rather than by a click.
Every tool covered so far runs because someone pressed a button. A hook runs because a
document opened, the selection changed, or Navisworks is shutting down. It lives in a fixed
folder at the root of an extension, named after the event it answers, and needs no
bundle.yaml and no ribbon presence at all.
MyTools.extension\
hooks\
doc-opened.py # runs every time a document finishes opening
selection-changed.py # runs, debounced, after the selection settlesThe file name, minus .py, must match one of fourteen event names exactly. A
name that does not match is not silently ignored the way an unrecognised bundle suffix is;
discovery logs it and moves on.
Hook 'D:\NavisTools\MyTools.extension\hooks\doc-open.py': unknown event 'doc-open' - skipped.
Valid names: doc-opened, doc-closed, doc-saved, model-appended, model-removed,
selection-changed, selection-sets-changed, viewpoints-changed, viewpoint-recalled,
camera-moved, app-init, app-closing, before-command, after-command.The fourteen events#
| File name | Fires when |
|---|---|
doc-opened.py | A document finishes opening (including the file that loads when Navisworks starts with one on the command line). |
doc-closed.py | The active document closes down to nothing, or a new empty document replaces it. |
doc-saved.py | The active document is saved. |
model-appended.py | A model is appended to the active document. |
model-removed.py | A model is removed from the active document. |
selection-changed.py | The current selection changes. Debounced. |
selection-sets-changed.py | A selection set is added, removed or edited. Debounced. |
viewpoints-changed.py | A saved viewpoint or folder is added, removed or edited. The tree, not the view you are looking through. |
viewpoint-recalled.py | A saved viewpoint is activated, by clicking
it or otherwise. Not debounced: one per recall. __event__['viewpoint'] holds
its name. |
camera-moved.py | The view moves: orbit, pan, zoom, walk. Debounced. |
app-init.py | The ribbon finishes building, at boot and after every Reload. |
app-closing.py | Navisworks is shutting down. |
before-command.py | Just before any pyNavis button's script runs, ribbon click or chord alike. |
after-command.py | Just after that script finishes, success or failure. |
viewpoints-changed is the saved-viewpoints tree being edited, so it
fires when someone adds, renames or deletes a saved view - not when the view on screen
changes. The one that fires when a saved view is activated is the singular
viewpoint-recalled, and the one that fires when the camera itself moves is
camera-moved. Leaving a saved view is not a recall of anything: orbiting away
from one fires camera-moved only, and viewpoint-recalled stays
quiet until another saved view is activated.
Choosing the wrong one costs you more than usual here, because the mistake is invisible: all three names are valid, so nothing is logged. You simply get a hook that never runs.
The underlying Navisworks event fires continuously while the user drags, so
camera-moved is debounced by 250ms like the selection events. One long orbit
produces one run, once the user stops. That is what makes it usable at all - undebounced, it
would run your Python script many times a second - but it does mean you cannot use it to
follow motion frame by frame. If you see a burst of runs from a single gesture, something is
wrong with the debounce, not with your script.
These are the runtime's own normalised names, not a re-export of raw Navisworks API events.
A model going from zero to one model, for instance, is doc-opened plus
model-appended, never a separate "first model" event.
The Navisworks API reports only that the model collection changed, not which model changed.
The runtime compares the model count before and after: the new count is checked against the
old one with >=, so a rise or a tie fires model-appended,
and only a fall fires model-removed. A same-count swap (one model out, a
different one in) therefore reads as model-appended, not a no-op. If the swap
happens as two separate collection changes (a remove, then an append), both events fire in
turn.
What a hook script looks like#
from pynavis import doc, toast
toast.info('Opened ' + (doc.get_title() or 'untitled'))A hook is a script like any other: top-level code (or a main() behind the
usual if __name__ == '__main__': guard, which both engines make true), the same
engines, the same pynavis library. It runs on the
extension's default engine, resolved the same way a bundle's does: the extension's own
engine: in extension.yaml, or ironpython if that is
absent. There is no per-hook engine: override.
The contract#
| Property | Value |
|---|---|
| Thread | The UI thread, synchronously. The Navisworks API is not thread-safe, and hooks run on the same thread every button click does. |
| While a command runs | Skipped. Every event except before-command and
after-command runs no hooks while a pyNavis command is running, so a script
that pumps the message loop cannot have its own run re-entered; each skip is logged, and
events that arrive after the command ends behave normally again. |
| Engine | The extension's default engine. See above. |
| Module search paths | The hooks\ folder, then the extension's
lib\ if it has one. A bundle's own folder is not on a hook's path and a
hook's folder is not on a bundle's. |
| Output | The pyNavis log, never an output window. There is no ribbon button
behind a hook for a window to be titled after, so writes that would normally open one are
routed to %APPDATA%\pyNavis\logs\pyNavis.log instead. Every line is labelled
with the hook that wrote it ([hook MyTools:doc-opened]), and
pynavis.script.get_logger() names its lines the same way, so hook output
never reads as if it came from the last button clicked. |
The injected globals#
Three names, not the usual five: a hook has no bundle folder, no resolved title and no
build-time run, so __commandpath__, __title__ and
__selfinit__ do not exist.
| Global | Type | Value |
|---|---|---|
__file__ | str | Full path of the hook script. |
__pynavis__ | host object | The runtime host singleton, same object pynavis.script.get_host()
returns. |
__event__ | dict | The event that triggered this run:
{'name': ..., 'command': ..., 'viewpoint': ...}. |
__event__['name'] is the kebab-case event name from the table above, always
present. __event__['command'] is the clicked button's bundle key
(the same key that identifies it in
config.json's shortcut bindings), present only for before-command
and after-command; it is None for every other event.
__event__['viewpoint'] is the recalled saved view's name, present only for
viewpoint-recalled and None everywhere else. It is captured at the
moment the event is raised rather than read back when your script runs, so it names the view
that was actually recalled even if the user has clicked on to another one since.
from pynavis import toast
if __event__['command'] and 'Export' in __event__['command']:
toast.info('Ran ' + __event__['command'])Debouncing#
selection-changed, selection-sets-changed and
camera-moved are the three events a user can fire dozens of times a second, so
all three are debounced 250 milliseconds before a hook ever sees them: the timer restarts on
every raw change and only fires once activity settles. Every other event dispatches
immediately.
Without the debounce, a marquee select over a thousand items would try to run every
selection-changed hook a thousand times in the time it takes to release the
mouse. With it, the hook sees the selection exactly once, after it stops moving.
The soft time budget#
A hook has no hard timeout: nothing kills a slow one mid-run. There is a soft budget of 500 milliseconds instead. A hook that runs longer is not stopped, but every such run is logged so a hook that is quietly getting in the way of the UI thread shows up.
Hook MyTools:doc-opened took 812ms (soft budget 500ms) - keep hooks fast.Because hooks run synchronously on the UI thread, a hook that is genuinely slow makes Navisworks feel slow every time its event fires. Keep the work small: toast a status, write a setting, kick off something that reports back later. Do not open a modal dialog or run a multi-second loop from inside one.
Three failures and it is disabled#
A hook that raises, or whose engine returns a failure result, is not retried differently next time; it just runs again on the next matching event. Three consecutive failures disable it for the rest of the session, and the runtime says so once, with a toast:
Hook MyTools:doc-opened disabled after 3 failures
Fix the script, then Reload pyNavis to re-enable it.Every failure, disabling or not, is written to the log with the full traceback. A success
in between resets the count: two failures followed by one success does not carry over toward
the third strike. Reload always re-enables every hook, whether or not the
underlying bug was fixed, because HookRunner.Configure rebuilds the hook list and
resets health tracking from scratch on every reload.
Once disabled, the event still fires and every other hook still runs; the broken one is simply skipped with no further message until Reload. If a hook you expect to see stops producing output, check the log for "disabled after 3 failures" before assuming the event itself did not fire.
What you cannot do from a hook#
- No output window. Unlike an ordinary bundle, a hook's
print()does not open one: its output and error streams are both wired to the log, so a strayprint()lands as a log line instead of a window nobody sees. A hook never sets the running command context either, sopynavis.outputhas nothing well-defined to write to from inside one. Usepynavis.toastfor anything the user should notice. - No per-hook engine. A hook always runs on its extension's default
engine; put the extension on
engine: cpythoninextension.yamlif every hook in it needs CPython. - No
config.py, no icon, no shortcut, no ribbon presence at all. A hook is pure automation. If you also want a manual trigger for the same logic, put the shared work in a module under the extension'slib\folder and call it from both the hook and an ordinary bundle.