Hi all,
Draw order comes up here regularly, and the thing that always got me is that Rhino gives you four commands to change it and no way to see what any of them actually did.
This started as a personal fix — hatches kept swallowing my massing model — and ended up being something my office now uses, so posting in case it’s useful to anyone else.
Credit where it’s due: the mechanics came almost entirely from @Helvetosaur’s explanations in the Draw Order Overview thread. That display order is a plain integer rather than a three-state thing, that explicitly assigned values override layer order, and that unsupported object types sit permanently at the 0 baseline. His SetObjDrawOrder.py and SetObjDisplayOrderOSD.py already cover much of this ground — this is a different interface on the same idea, not a replacement. The OSD script draws order numbers onto the objects themselves, which mine doesn’t do.
What it does:
— Modeless Eto window, stays open while you work
— Live readout: select anything and it shows the current draw order, or the range and band if the selection is mixed
— Five named tiers (Front / Ahead / Middle / Behind / Back) at +20 / +10 / 0 / −10 / −20, click to assign to the selection
— +1 / −1 nudge buttons for resolving two objects sitting on the same level
— Click a tier with nothing selected and it selects everything in that band instead. Nearest-tier matching, so objects nudged off the exact value are still found
The tier scheme is deliberately built around 0 as the baseline where surfaces, meshes and blocks are stuck. Negative draws behind the model, positive in front. Framing it that way is what finally made the whole thing click for me, and for the colleagues I handed it to.
Tiers, values, colours and tooltips are a single editable list at the top of the file. Band boundaries derive from it automatically, so adding a sixth tier or changing the spacing needs no other edits.
Written and tested on Rhino 7, Windows, IronPython 2.7. There are no Python 2-only idioms left in it, so it should run under Rhino 8/9 CPython too — but I haven’t tested that and would appreciate a report if anyone tries.
Feedback on the Eto side is welcome, it’s my first modeless form. I hope this one helps out a few!
Full code
# DrawOrderPalette.py
# Tier presets, +/-1 nudge, live selection readout, select-by-tier.
#
# Tier buttons do one of two things depending on context:
# something selected -> assign that tier to the selection
# nothing selected -> select everything whose NEAREST tier is that one,
# so objects you have nudged off the exact value
# still belong to their tier and stay findable
import Rhino
import Rhino.UI
import Eto.Forms as forms
import Eto.Drawing as drawing
import scriptcontext as sc
# ---------------------------------------------------------------------------
# EDIT THIS BLOCK ONLY.
# (name, draw order value, swatch RGB, tooltip)
# Listed front-to-back, so the top row draws on top. 3D volumes and blocks
# are permanently pinned at 0 and cannot be moved - that is the Middle plane.
# Band boundaries are derived automatically, so changing spacing or adding a
# sixth tier needs no other edits.
# ---------------------------------------------------------------------------
PRESETS = [
("Front", 20, (192, 57, 43), "Text and dimensions - always on top"),
("Ahead", 10, (230, 126, 34), "Linework that must sit over 3D volumes"),
("Middle", 0, ( 39, 135, 75), "Default plane - sits with volumes and blocks"),
("Behind", -10, ( 46, 134, 171), "Pattern hatches - behind volumes"),
("Back", -20, ( 31, 58, 147), "Solid fills and site base - furthest back"),
]
POLL_SECONDS = 0.3 # raise to 0.5-1.0 if it feels sluggish on a big file
BIG_SELECTION = 500 # ask before selecting more than this many objects
STICKY_KEY = "DrawOrderPalette"
# ascending list of tier values, derived from PRESETS
TIER_VALUES = sorted(set(p[1] for p in PRESETS))
TIER_NAMES = dict((p[1], p[0]) for p in PRESETS)
def forget_instance():
"""Drop the stored window reference.
Written to work under both IronPython 2.7 (Rhino 6/7) and CPython 3
(Rhino 8/9) - has_key() and .Remove() only exist on the former."""
try:
if STICKY_KEY in sc.sticky:
del sc.sticky[STICKY_KEY]
except:
pass
def nearest_tier(order):
"""Which tier value is this draw order closest to?
Exact midpoints round forward (towards the front)."""
best = TIER_VALUES[0]
best_d = abs(order - best)
for v in TIER_VALUES[1:]:
d = abs(order - v)
if d < best_d or (d == best_d and v > best):
best, best_d = v, d
return best
# ---------------------------------------------------------------------------
# Object types that actually support draw order.
# Surfaces, meshes, blocks and text dots are excluded - they silently no-op.
# ---------------------------------------------------------------------------
OT = Rhino.DocObjects.ObjectType
SUPPORTED = (OT.Curve, OT.Point, OT.Hatch, OT.Annotation, OT.Detail)
def active_doc():
# Modeless forms outlive the script, so scriptcontext.doc can go stale.
return Rhino.RhinoDoc.ActiveDoc
def supported_selection():
doc = active_doc()
if doc is None:
return []
objs = doc.Objects.GetSelectedObjects(False, False)
return [o for o in objs if o.ObjectType in SUPPORTED]
def all_supported_objects():
"""Every selectable draw-order-capable object in the document.
Hidden and locked objects are skipped - they cannot be selected anyway."""
doc = active_doc()
if doc is None:
return []
s = Rhino.DocObjects.ObjectEnumeratorSettings()
s.NormalObjects = True
s.LockedObjects = False
s.HiddenObjects = False
s.IncludeLights = False
s.IncludeGrips = False
return [o for o in doc.Objects.GetObjectList(s) if o.ObjectType in SUPPORTED]
def write_order(objs, func, undo_label):
"""func(current_order) -> new_order. Applies to every object passed in."""
doc = active_doc()
if doc is None or not objs:
return
undo = doc.BeginUndoRecord(undo_label)
try:
for o in objs:
attr = o.Attributes.Duplicate()
attr.DisplayOrder = func(o.Attributes.DisplayOrder)
doc.Objects.ModifyAttributes(o, attr, True)
finally:
doc.EndUndoRecord(undo)
doc.Views.Redraw()
# ---------------------------------------------------------------------------
class DrawOrderPalette(forms.Form):
def __init__(self):
self.Title = "Draw Order"
self.Padding = drawing.Padding(8)
self.Resizable = False
self.Maximizable = False
self.Minimizable = False
self.Topmost = True
self.m_readout = forms.Label(Text="Nothing selected")
self.m_readout.Font = drawing.Font(drawing.SystemFont.Bold, 9)
self.m_hint = forms.Label(
Text="Nothing selected - a tier click selects that tier")
self.m_hint.TextColor = drawing.Color.FromArgb(120, 120, 120)
tiers = forms.DynamicLayout()
tiers.Spacing = drawing.Size(6, 3)
for name, value, rgb, tip in PRESETS:
tiers.AddRow(self._swatch(rgb),
self._tier_button(name, value, tip),
forms.Label(Text=self._fmt(value)))
minus = forms.Button(Text="-1")
minus.ToolTip = "Nudge selection one step back"
minus.Click += self.on_minus
plus = forms.Button(Text="+1")
plus.ToolTip = "Nudge selection one step forward"
plus.Click += self.on_plus
nudge = forms.DynamicLayout()
nudge.Spacing = drawing.Size(6, 0)
nudge.AddRow(minus, plus)
reset = forms.Button(Text="Reset to 0")
reset.ToolTip = "Clear the override - back to the Middle plane"
reset.Click += self.on_reset
close = forms.Button(Text="Close")
close.Click += self.on_close_click
footer = forms.DynamicLayout()
footer.Spacing = drawing.Size(6, 0)
footer.AddRow(reset, close)
main = forms.DynamicLayout()
main.Spacing = drawing.Size(0, 8)
main.AddRow(self.m_readout)
main.AddRow(tiers)
main.AddRow(nudge)
main.AddRow(footer)
main.AddRow(self.m_hint)
self.Content = main
self.m_timer = forms.UITimer()
self.m_timer.Interval = POLL_SECONDS
self.m_timer.Elapsed += self.on_tick
self.m_timer.Start()
self.Closed += self.on_closed
# -- widgets ------------------------------------------------------------
def _fmt(self, value):
# show the sign so the ordering reads at a glance
return "%+d" % value if value != 0 else "0"
def _swatch(self, rgb):
d = forms.Drawable()
d.Size = drawing.Size(14, 14)
d.BackgroundColor = drawing.Color.FromArgb(rgb[0], rgb[1], rgb[2])
return d
def _tier_button(self, name, value, tip):
b = forms.Button(Text=name)
b.Width = 115
b.ToolTip = tip + " | click with nothing selected to select this band"
# default-arg trick pins the value inside the closure
b.Click += lambda s, e, v=value: self.on_preset(v)
return b
# -- selection ----------------------------------------------------------
def select_tier(self, value):
"""Selects everything whose nearest tier is this one, not just
objects sitting on the exact value."""
doc = active_doc()
if doc is None:
return
matches = [o for o in all_supported_objects()
if nearest_tier(o.Attributes.DisplayOrder) == value]
if not matches:
self.m_hint.Text = "Nothing in the %s band" % TIER_NAMES[value]
return
if len(matches) > BIG_SELECTION:
answer = forms.MessageBox.Show(
self,
"Select %d objects?\n\nLarge selections slow the live readout."
% len(matches),
"Draw Order",
forms.MessageBoxButtons.YesNo,
forms.MessageBoxType.Question)
if answer != forms.DialogResult.Yes:
return
doc.Objects.UnselectAll()
for o in matches:
o.Select(True)
doc.Views.Redraw()
# -- handlers -----------------------------------------------------------
def on_tick(self, sender, e):
objs = supported_selection()
if not objs:
self.m_readout.Text = "Nothing selected"
self.m_hint.Text = "Nothing selected - a tier click selects that band"
return
self.m_hint.Text = "A tier click assigns to the selection"
orders = [o.Attributes.DisplayOrder for o in objs]
lo, hi = min(orders), max(orders)
if lo == hi:
self.m_readout.Text = "%d object(s) - %s (%s)" % (
len(objs), self._fmt(lo), TIER_NAMES.get(lo, "off-tier"))
else:
bands = set(nearest_tier(v) for v in orders)
if len(bands) == 1:
band = list(bands)[0]
self.m_readout.Text = "%d object(s) - %s to %s (%s band)" % (
len(objs), self._fmt(lo), self._fmt(hi), TIER_NAMES[band])
else:
self.m_readout.Text = "%d object(s) - mixed %s to %s" % (
len(objs), self._fmt(lo), self._fmt(hi))
def on_preset(self, value):
if not supported_selection():
self.select_tier(value) # nothing selected -> select band
else:
write_order(supported_selection(),
lambda cur: value, "Set draw order")
self.on_tick(None, None)
def on_plus(self, sender, e):
write_order(supported_selection(),
lambda cur: cur + 1, "Nudge draw order")
self.on_tick(None, None)
def on_minus(self, sender, e):
write_order(supported_selection(),
lambda cur: cur - 1, "Nudge draw order")
self.on_tick(None, None)
def on_reset(self, sender, e):
write_order(supported_selection(),
lambda cur: 0, "Reset draw order")
self.on_tick(None, None)
def on_close_click(self, sender, e):
self.Close()
def on_closed(self, sender, e):
try:
self.m_timer.Stop()
except:
pass
forget_instance()
# ---------------------------------------------------------------------------
def main():
# close an existing instance so you never end up with two
if STICKY_KEY in sc.sticky:
try:
sc.sticky[STICKY_KEY].Close()
except:
pass
forget_instance()
form = DrawOrderPalette()
form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
form.Show()
# keeping a reference stops the garbage collector eating the window
sc.sticky[STICKY_KEY] = form
main()
The file is attached
Anton
DrawOrderPalette.py (10.6 KB)