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.

folders
MyTools.extension\
  hooks\
    doc-opened.py           # runs every time a document finishes opening
    selection-changed.py    # runs, debounced, after the selection settles

The 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.

%APPDATA%\pyNavis\logs\pyNavis.log
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 nameFires when
doc-opened.pyA document finishes opening (including the file that loads when Navisworks starts with one on the command line).
doc-closed.pyThe active document closes down to nothing, or a new empty document replaces it.
doc-saved.pyThe active document is saved.
model-appended.pyA model is appended to the active document.
model-removed.pyA model is removed from the active document.
selection-changed.pyThe current selection changes. Debounced.
selection-sets-changed.pyA selection set is added, removed or edited. Debounced.
viewpoints-changed.pyA saved viewpoint or folder is added, removed or edited. The tree, not the view you are looking through.
viewpoint-recalled.pyA saved viewpoint is activated, by clicking it or otherwise. Not debounced: one per recall. __event__['viewpoint'] holds its name.
camera-moved.pyThe view moves: orbit, pan, zoom, walk. Debounced.
app-init.pyThe ribbon finishes building, at boot and after every Reload.
app-closing.pyNavisworks is shutting down.
before-command.pyJust before any pyNavis button's script runs, ribbon click or chord alike.
after-command.pyJust after that script finishes, success or failure.
Three viewpoint events, and the plural is not the obvious one

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.

camera-moved gives you the resting view, not the journey

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.

model-appended and model-removed are inferred from a count

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#

MyTools.extension\hooks\doc-opened.py
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#

PropertyValue
ThreadThe UI thread, synchronously. The Navisworks API is not thread-safe, and hooks run on the same thread every button click does.
While a command runsSkipped. 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.
EngineThe extension's default engine. See above.
Module search pathsThe 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.
OutputThe 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.

GlobalTypeValue
__file__strFull 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.

MyTools.extension\hooks\after-command.py
from pynavis import toast

if __event__['command'] and 'Export' in __event__['command']:
    toast.info('Ran ' + __event__['command'])
One event, start to finish Navisworks API e.g. selection changes Normalised + debounced 250ms for the three chatty ones Every matching hook UI thread, in sequence pyNavis.log never a window A hook that fails 3 times running is disabled until the next Reload; every run past the 500ms soft budget is logged so a slow hook shows up before it is disabled.
Consumers never see the raw Navisworks event: HookRunner, the context gate and the toggle refresh all react to the same normalised, already-debounced signal.

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.

Dragging a selection box fires one hook run, not one per item

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.

%APPDATA%\pyNavis\logs\pyNavis.log
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:

text
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.

A disabled hook is silent after the toast

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 stray print() lands as a log line instead of a window nobody sees. A hook never sets the running command context either, so pynavis.output has nothing well-defined to write to from inside one. Use pynavis.toast for anything the user should notice.
  • No per-hook engine. A hook always runs on its extension's default engine; put the extension on engine: cpython in extension.yaml if 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's lib\ folder and call it from both the hook and an ordinary bundle.