Super Pin
Description
Pins elements in Revit, records their position, and warns you if they are ever moved.
## Setup
Place the four files into your pyRevit extension using the folder structure below. Replace `<Your Extension>` and `<Your Panel>` with whatever names your extension and panel use.<Your Extension>.extension/
|
|-- lib/
| |-- super_pin_revert.py
|
|-- hooks/
| |-- doc-changed.py
|
|-- <Any>.tab/
|-- <Your Panel>.panel/
|-- Super Pin.pushbutton/
|-- script.py
|-- bundle.yaml
After placing all files, reload pyRevit (or restart Revit) to register the hook and button.
> **If your extension already has a `doc-changed.py` hook**, you will need to merge the contents of this one into your existing hook rather than replacing it.
## What Each File Does
| `script.py` | `Super Pin.pushbutton/` | The button. Click to pin + track elements. Shift+Click to remove elements from tracking. Also creates the ExternalEvent that enables automatic move-back.
| `bundle.yaml` | `Super Pin.pushbutton/` | Optional button metadata (tooltip, title).
| `doc-changed.py` | `hooks/` | pyRevit hook that fires on every document change. Detects when a tracked element moves and prompts the user to undo, allow, or stop tracking.
| `super_pin_revert.py` | `lib/` | ExternalEvent handler that moves elements back to their recorded position. Used by both the button and the hook. ## Sharing Tracking Data Across a Team
By default, each user's tracking data is stored locally at:%APPDATA%/<FOLDER_NAME>/<USERNAME>/super_pin_locks.json
This means only the user who pinned an element will be warned if it moves. To share tracking data so the whole team is protected, change the `_STORE_DIR` and `_STORE_FILE` variables to a shared network path that every team member can read and write.
> **Important:** You must update these variables in **all three `.py` files**.
### Option A: A shared folder on your server
One shared folder, one JSON file, all documents keyed inside it.
```python
_STORE_DIR = r'\\SERVER\BIM\Super Pin'
_STORE_FILE = os.path.join(_STORE_DIR, 'super_pin_locks.json')
```
### Option B: Next to the Revit model
Puts the lock file in the same folder as the model. Keeps tracking data next to the project it belongs to, but requires the model to be saved to a path everyone can access.
```python
_STORE_DIR = os.path.dirname(doc.PathName) if doc.PathName else <fallback>
_STORE_FILE = os.path.join(_STORE_DIR, 'super_pin_locks.json')
```
### Requirements for shared storage
- Every team member has read/write access to the folder.
- The path is the same for all users (use a UNC path like `\\SERVER\...`, not a mapped drive letter that may differ between machines).
- The `_STORE_DIR` / `_STORE_FILE` values match in all three `.py` files.
## Notes
- Each Revit document gets its own key inside the JSON file (the saved file path, or document title if unsaved).- The "Undo the move" option requires the ExternalEvent to be created first. This happens automatically the first time you click Super Pin in a session. If you move a tracked element before ever clicking the button, the revert will fall back to asking you to Ctrl+Z.
- This tool is self-contained. It has no dependencies beyond pyRevit and the Revit API.
Code
pyRevit# -*- coding: utf-8 -*-
"""
Click to pin + track elements.
Shift+Click to remove elements from tracking.
Also creates the ExternalEvent that enables automatic
move-back, so click it once per Revit session before
relying on revert.
"""
__title__ = "Super\nPin"
__author__ = "bimgineer/SWE-eml"
min_revit_version: 2025
# TODO: test other Revit versions
__doc__ = ("Pin element(s) and track them. Pre-select or pick elements — "
"Super Pin pins them in Revit and records their position; "
"you are warned if one is ever moved.")
import os
import io
import json
import copy
from pyrevit import revit, forms
from Autodesk.Revit.UI.Selection import ObjectType
from Autodesk.Revit.Exceptions import OperationCanceledException
from Autodesk.Revit.DB import ElementId
from System.Collections.Generic import List
uidoc = revit.uidoc
doc = revit.doc
# ── Storage ──────────────────────────────────────────────────────────────────
_STORE_DIR = os.path.join(
os.environ.get('APPDATA', os.path.expanduser('~')),
'FOLDER_NAME',
os.environ.get('USERNAME', 'user'),
)
_STORE_FILE = os.path.join(_STORE_DIR, 'super_pin_locks.json')
def _doc_key():
try:
path = doc.PathName
except Exception:
path = None
return path or doc.Title
def _load():
"""Return {uid: record} for this document."""
try:
with io.open(_STORE_FILE, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception:
return {}
return data.get(_doc_key(), {})
def _load_all():
try:
with io.open(_STORE_FILE, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except Exception:
pass
return {}
def _save_all(data):
if not os.path.isdir(_STORE_DIR):
os.makedirs(_STORE_DIR)
tmp = _STORE_FILE + '.tmp'
with io.open(tmp, 'w', encoding='utf-8') as fh:
fh.write(json.dumps(data, indent=2, ensure_ascii=False))
if os.path.isfile(_STORE_FILE):
os.remove(_STORE_FILE)
os.rename(tmp, _STORE_FILE)
def _add_lock(element, user):
"""Register element as tracked. Returns True if newly added."""
data = copy.deepcopy(_load_all())
locks = data.setdefault(_doc_key(), {})
uid = element.UniqueId
already = uid in locks
locks[uid] = {
'name': _display_name(element),
'sig': _location_sig(element),
'user': user,
}
_save_all(data)
return not already
def _remove_lock(uid):
"""Remove a tracked element. Returns True if something was removed."""
data = copy.deepcopy(_load_all())
key = _doc_key()
locks = data.get(key)
if not locks or uid not in locks:
return False
del locks[uid]
if not locks:
del data[key]
_save_all(data)
return True
# ── Helpers ──────────────────────────────────────────────────────────────────
def _display_name(element):
try:
if element.Category:
return element.Category.Name + ' : ' + element.Name
return element.Name
except Exception:
return 'Element {0}'.format(element.Id)
def _location_sig(element):
"""Compact location signature: [x,y,z] for points, [x1..z2] for curves."""
try:
loc = element.Location
except Exception:
loc = None
if loc is not None:
pt = getattr(loc, 'Point', None)
if pt is not None:
return [pt.X, pt.Y, pt.Z]
curve = getattr(loc, 'Curve', None)
if curve is not None:
try:
p0 = curve.GetEndPoint(0)
p1 = curve.GetEndPoint(1)
return [p0.X, p0.Y, p0.Z, p1.X, p1.Y, p1.Z]
except Exception:
pass
for view_arg in (None, doc.ActiveView):
try:
bb = element.get_BoundingBox(view_arg)
if bb is not None:
mn, mx = bb.Min, bb.Max
return [(mn.X + mx.X) / 2.0,
(mn.Y + mx.Y) / 2.0,
(mn.Z + mx.Z) / 2.0]
except Exception:
pass
return None
def _current_selection():
try:
return list(uidoc.Selection.GetElementIds())
except Exception:
return []
def _restore_selection(ids):
if ids:
try:
uidoc.Selection.SetElementIds(List[ElementId](list(ids)))
except Exception:
pass
def _pick_targets(preselected):
if preselected:
return [e for e in (doc.GetElement(i) for i in preselected) if e]
try:
ref = uidoc.Selection.PickObject(
ObjectType.Element,
'Select an element to pin and track.'
)
except OperationCanceledException:
return []
el = doc.GetElement(ref.ElementId)
return [el] if el else []
def _username():
try:
name = doc.Application.Username
if name:
return name
except Exception:
pass
return os.environ.get('USERNAME', 'unknown')
# ── Pin + track ──────────────────────────────────────────────────────────────
def pin_and_track(targets, user):
pinned = 0
already = 0
errors = []
for el in targets:
try:
if not el.Pinned:
with revit.Transaction('Super Pin'):
el.Pinned = True
except Exception as ex:
errors.append(_display_name(el))
continue
if _add_lock(el, user):
pinned += 1
else:
already += 1
parts = []
if pinned:
parts.append('{0} element(s) pinned and tracked.'.format(pinned))
if already:
parts.append('{0} already tracked.'.format(already))
if errors:
parts.append('Could not pin: ' + ', '.join(errors))
forms.alert('\n'.join(parts), title='Super Pin')
# ── Manage (Shift+Click) ────────────────────────────────────────────────────
def manage(original_selection):
locks = _load()
if not locks:
forms.alert('No elements are currently tracked.', title='Super Pin')
return
try:
if original_selection:
picked = [e for e in (doc.GetElement(i) for i in original_selection)
if e]
else:
try:
refs = uidoc.Selection.PickObjects(
ObjectType.Element,
'Select tracked element(s) to stop protecting.'
)
except OperationCanceledException:
return
picked = [e for e in (doc.GetElement(r.ElementId) for r in refs)
if e]
tracked = [e for e in picked if e.UniqueId in locks]
if not tracked:
forms.alert('None of the selected elements are tracked.',
title='Super Pin')
return
names = '\n'.join(
u' • {0}'.format(_display_name(e)) for e in tracked)
choice = forms.alert(
u'Stop tracking?\n\n{0}'.format(names),
title='Super Pin',
options=['Remove from tracking (keep pinned)',
'Remove and unpin',
'Cancel']
)
if not choice or choice == 'Cancel':
return
unpin = (choice == 'Remove and unpin')
removed = 0
for e in tracked:
if _remove_lock(e.UniqueId):
removed += 1
if unpin:
try:
if e.Pinned:
with revit.Transaction('Super Pin — unpin'):
e.Pinned = False
except Exception:
pass
forms.alert('{0} element(s) removed from tracking.'.format(removed),
title='Super Pin')
finally:
_restore_selection(original_selection)
# ── Entry point ──────────────────────────────────────────────────────────────
def main():
if doc is None:
forms.alert('No active document.', title='Super Pin')
return
try:
import super_pin_revert
super_pin_revert.ensure_event()
except Exception:
pass
original_selection = _current_selection()
if bool(globals().get('__shiftclick__', False)):
manage(original_selection)
return
targets = _pick_targets(original_selection)
if not targets:
_restore_selection(original_selection)
return
pin_and_track(targets, _username())
main()
# -*- coding: utf-8 -*-
"""
super_pin_revert.py
--------------------
ExternalEvent-based "move back" for Super Pin.
Self-contained — no external library imports. The doc-changed hook queues
elements via request_revert(); Revit invokes the handler at idle in a valid
API context where a transaction can be opened.
ensure_event() must be called OUTSIDE any Revit event callback (e.g. from
the Super Pin pushbutton or a startup script) before the hook can revert.
"""
from Autodesk.Revit.UI import IExternalEventHandler, ExternalEvent
from Autodesk.Revit.DB import XYZ, Line, ElementTransformUtils, Transaction
_MOVE_TOL = 1e-4
_revert_event = None
_revert_handler = None
_pending_reverts = []
# ── Location helpers ─────────────────────────────────────────────────────────
def _current_sig(element, view=None):
"""Return [x,y,z] or [x1..z2] for the element's current position."""
try:
loc = element.Location
except Exception:
loc = None
if loc is not None:
pt = getattr(loc, 'Point', None)
if pt is not None:
return [pt.X, pt.Y, pt.Z]
curve = getattr(loc, 'Curve', None)
if curve is not None:
try:
p0 = curve.GetEndPoint(0)
p1 = curve.GetEndPoint(1)
return [p0.X, p0.Y, p0.Z, p1.X, p1.Y, p1.Z]
except Exception:
return None
for view_arg in (None, view):
try:
bb = element.get_BoundingBox(view_arg)
if bb is not None:
mn, mx = bb.Min, bb.Max
return [(mn.X + mx.X) / 2.0,
(mn.Y + mx.Y) / 2.0,
(mn.Z + mx.Z) / 2.0]
except Exception:
pass
return None
def _ref_point(sig):
if sig and len(sig) >= 3:
return (sig[0], sig[1], sig[2])
return None
# ── Restore helpers ──────────────────────────────────────────────────────────
def _restore_curve(el, orig_sig):
"""Rebuild a straight LocationCurve between its two logged endpoints."""
if not orig_sig or len(orig_sig) < 6:
return False
loc = getattr(el, 'Location', None)
curve = getattr(loc, 'Curve', None) if loc is not None else None
if curve is None or not isinstance(curve, Line):
return False
p0 = XYZ(orig_sig[0], orig_sig[1], orig_sig[2])
p1 = XYZ(orig_sig[3], orig_sig[4], orig_sig[5])
try:
loc.Curve = Line.CreateBound(p0, p1)
return True
except Exception:
return False
def _revert_one(doc, item):
"""Restore a single element to its logged location (inside a live txn)."""
el = doc.GetElement(item['uid'])
if el is None:
return
orig_sig = item['sig']
target = _ref_point(orig_sig)
current = _ref_point(_current_sig(el))
if target is None or current is None:
return
was_pinned = False
try:
was_pinned = bool(el.Pinned)
if was_pinned:
el.Pinned = False
except Exception:
pass
try:
if not _restore_curve(el, orig_sig):
dx = target[0] - current[0]
dy = target[1] - current[1]
dz = target[2] - current[2]
if (abs(dx) >= _MOVE_TOL or
abs(dy) >= _MOVE_TOL or
abs(dz) >= _MOVE_TOL):
ElementTransformUtils.MoveElement(doc, el.Id, XYZ(dx, dy, dz))
except Exception:
pass
try:
if was_pinned:
el.Pinned = True
except Exception:
pass
# ── ExternalEvent handler ────────────────────────────────────────────────────
class _RevertHandler(IExternalEventHandler):
def GetName(self):
return 'Super Pin — revert move'
def Execute(self, uiapp):
try:
global _pending_reverts
items = _pending_reverts
_pending_reverts = []
if not items:
return
uidoc = uiapp.ActiveUIDocument
if uidoc is None:
return
doc = uidoc.Document
t = Transaction(doc, 'Super Pin — revert move')
t.Start()
try:
for item in items:
_revert_one(doc, item)
t.Commit()
except Exception:
if t.HasStarted() and not t.HasEnded():
t.RollBack()
except Exception:
pass
# ── Public API ───────────────────────────────────────────────────────────────
def ensure_event():
"""Create the ExternalEvent once. Must be called outside a callback."""
global _revert_event, _revert_handler
if _revert_event is None:
_revert_handler = _RevertHandler()
_revert_event = ExternalEvent.Create(_revert_handler)
return _revert_event
def is_ready():
return _revert_event is not None
def request_revert(items):
"""Queue (uid, orig_sig) pairs and Raise the event.
Returns True if raised, False if the event was never created."""
for uid, orig_sig in items:
_pending_reverts.append({'uid': uid, 'sig': orig_sig})
if _revert_event is None:
try:
ensure_event()
except Exception:
pass
if _revert_event is None:
return False
_revert_event.Raise()
return True
# -*- coding: utf-8 -*-
"""
doc-changed.py — pyRevit hook for Super Pin move detection.
Self-contained: reads super_pin_locks.json directly.
When a tracked element moves beyond tolerance, prompts the user to
undo, allow, or stop tracking.
"""
import os
import sys
import io
import json
import copy
from pyrevit import EXEC_PARAMS
from Autodesk.Revit.UI import (
TaskDialog,
TaskDialogIcon,
TaskDialogResult,
TaskDialogCommandLinkId,
)
_EXT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_LIB_DIR = os.path.join(_EXT_DIR, 'lib')
if _LIB_DIR not in sys.path:
sys.path.insert(0, _LIB_DIR)
# ── Storage ──────────────────────────────────────────────────────────────────
_STORE_FILE = os.path.join(
os.environ.get('APPDATA', os.path.expanduser('~')),
'FOLDER_NAME',
os.environ.get('USERNAME', 'user'),
'super_pin_locks.json',
)
_MOVE_TOL = 1e-4
_cache = {'key': None, 'data': None}
def _load_all():
try:
st = os.stat(_STORE_FILE)
except OSError:
_cache['key'] = None
_cache['data'] = None
return {}
key = (st.st_mtime, st.st_size)
if _cache['data'] is not None and _cache['key'] == key:
return _cache['data']
try:
with io.open(_STORE_FILE, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if not isinstance(data, dict):
data = {}
except Exception:
return {}
_cache['key'] = key
_cache['data'] = data
return data
def _save_all(data):
store_dir = os.path.dirname(_STORE_FILE)
if not os.path.isdir(store_dir):
os.makedirs(store_dir)
tmp = _STORE_FILE + '.tmp'
with io.open(tmp, 'w', encoding='utf-8') as fh:
fh.write(json.dumps(data, indent=2, ensure_ascii=False))
if os.path.isfile(_STORE_FILE):
os.remove(_STORE_FILE)
os.rename(tmp, _STORE_FILE)
try:
st = os.stat(_STORE_FILE)
_cache['key'] = (st.st_mtime, st.st_size)
_cache['data'] = data
except OSError:
_cache['key'] = None
_cache['data'] = None
def _doc_key(doc):
try:
path = doc.PathName
except Exception:
path = None
if path:
return path
try:
return doc.Title
except Exception:
return 'unknown'
# ── Location helpers ─────────────────────────────────────────────────────────
def _location_sig(element, view=None):
try:
loc = element.Location
except Exception:
loc = None
if loc is not None:
pt = getattr(loc, 'Point', None)
if pt is not None:
return [pt.X, pt.Y, pt.Z]
curve = getattr(loc, 'Curve', None)
if curve is not None:
try:
p0 = curve.GetEndPoint(0)
p1 = curve.GetEndPoint(1)
return [p0.X, p0.Y, p0.Z, p1.X, p1.Y, p1.Z]
except Exception:
return None
for view_arg in (None, view):
try:
bb = element.get_BoundingBox(view_arg)
if bb is not None:
mn, mx = bb.Min, bb.Max
return [(mn.X + mx.X) / 2.0,
(mn.Y + mx.Y) / 2.0,
(mn.Z + mx.Z) / 2.0]
except Exception:
pass
return None
def _sigs_differ(a, b):
if not a or not b or len(a) != len(b):
return bool(a) != bool(b)
for x, y in zip(a, b):
if abs(x - y) > _MOVE_TOL:
return True
return False
# ── Hook logic ───────────────────────────────────────────────────────────────
def _run():
args = EXEC_PARAMS.event_args
if args is None:
return
doc = args.GetDocument()
if doc is None:
return
all_data = _load_all()
locks = all_data.get(_doc_key(doc), {})
if not locks:
return
modified_ids = list(args.GetModifiedElementIds())
if not modified_ids:
return
try:
active_view = doc.ActiveView
except Exception:
active_view = None
moved = []
for eid in modified_ids:
el = doc.GetElement(eid)
if el is None:
continue
uid = el.UniqueId
rec = locks.get(uid)
if rec is None:
continue
orig_sig = rec.get('sig')
new_sig = _location_sig(el, active_view)
if _sigs_differ(orig_sig, new_sig):
moved.append((uid, new_sig, orig_sig, rec.get('name') or uid,
rec.get('user')))
if not moved:
return
# ── Prompt ───────────────────────────────────────────────────────────────
lines = []
for _, _, _, name, user in moved:
if user:
lines.append(u' • {0} — pinned by {1}'.format(name, user))
else:
lines.append(u' • {0}'.format(name))
dlg = TaskDialog('Super Pin')
dlg.MainIcon = TaskDialogIcon.TaskDialogIconWarning
dlg.MainInstruction = 'A tracked element was moved'
dlg.MainContent = (
u'The following element(s) changed position:\n\n{0}\n\n'
u'These were pinned to protect their placement.'
.format('\n'.join(lines))
)
dlg.AllowCancellation = True
dlg.AddCommandLink(
TaskDialogCommandLinkId.CommandLink1,
'Undo the move',
'Restore the original position.'
)
dlg.AddCommandLink(
TaskDialogCommandLinkId.CommandLink2,
'Allow the move',
'Keep the new position and update the tracked baseline.'
)
dlg.AddCommandLink(
TaskDialogCommandLinkId.CommandLink3,
'Stop tracking',
'Remove from the tracking list and allow future moves.'
)
dlg.DefaultButton = TaskDialogResult.CommandLink1
result = dlg.Show()
if result == TaskDialogResult.CommandLink2:
# Allow the move — update baselines to the new position.
data = copy.deepcopy(_load_all())
key = _doc_key(doc)
doc_locks = data.get(key, {})
for uid, new_sig, _, _, _ in moved:
rec = doc_locks.get(uid)
if rec is not None:
rec['sig'] = new_sig
_save_all(data)
elif result == TaskDialogResult.CommandLink3:
# Stop tracking — remove from the lock store.
data = copy.deepcopy(_load_all())
key = _doc_key(doc)
doc_locks = data.get(key, {})
for uid, _, _, _, _ in moved:
doc_locks.pop(uid, None)
if not doc_locks:
data.pop(key, None)
_save_all(data)
else:
# Undo the move (CommandLink1 or ESC).
try:
import super_pin_revert
items = [(uid, orig_sig)
for uid, _, orig_sig, _, _ in moved]
if not super_pin_revert.request_revert(items):
TaskDialog.Show(
'Super Pin',
'Could not revert automatically.\n\n'
'Press Ctrl+Z to undo the move, then reload pyRevit.'
)
except Exception:
TaskDialog.Show(
'Super Pin',
'Could not revert automatically.\n\n'
'Press Ctrl+Z to undo the move, then reload pyRevit.'
)
try:
_run()
except Exception:
pass
title:
en_us: |-
Super
Pin
author: bimgineer/SWE-eml
min_revit_version = 2025
tooltip:
en_us: |-
Pin and track element(s) so you are warned if they move.
Select elements (or pick one after clicking), and Super Pin will pin
them in Revit and record their position.