Detail Layer Management Tools: OLOD, OLON & DLT [Python]

I had to do some repetitive visualizations and was in need of those tools - maybe those are helpfull for someone.

Detail Layer Management Tools: OLOD, OLON & DLT

Managing per-detail layer visibility in Rhino layouts is tedious — especially when working with overlapping details or complex layer hierarchies. These three Python scripts streamline the most common tasks.

All scripts work with Rhino 8’s per-viewport layer visibility API and are designed for use within layout detail views.


OLOD — OneLayerOffInDetail

What it does: Pick objects inside an active detail → their layers are turned off only in that detail. Global layer visibility is not affected.

How to use:

  1. Double-click into a detail on a layout page

  2. Run OLOD

  3. Select one or more objects (window/crossing selection works)

  4. Press Enter — the layers of the selected objects are now hidden in this detail only

Why it’s useful: Native Rhino only offers OneLayerOff (global, single pick) or manually toggling the Detail On column in the Layers panel. OLOD combines object-based picking with per-detail scope, making it much faster to configure detail views — especially with deeply nested layer structures.

# -*- coding: utf-8 -*-
# OneLayerOffInDetail (OLOD) v1
# Pick objects in active layout detail -> turn off their layers in this detail only.
# Global layer visibility is NOT affected.
# Requires: active page view with a double-clicked (active) detail.

import Rhino
import scriptcontext as sc

def main():
    # --- Validate: must be in a page view ---
    view = sc.doc.Views.ActiveView
    if view is None or not isinstance(view, Rhino.Display.RhinoPageView):
        print("OLOD: Not in a layout/page view. Switch to a layout first.")
        return

    page_view = view
    vp_id = page_view.ActiveViewportID

    # --- Validate: must be inside a detail (not on the page itself) ---
    details = page_view.GetDetailViews()
    active_detail = None
    for d in details:
        if d.Viewport.Id == vp_id:
            active_detail = d
            break

    if active_detail is None:
        print("OLOD: No active detail. Double-click into a detail first.")
        return

    detail_name = active_detail.Name if active_detail.Name else "(unnamed)"
    print("OLOD: Active detail = '{}'".format(detail_name))

    # --- Pick objects ---
    go = Rhino.Input.Custom.GetObject()
    go.SetCommandPrompt("Select objects to turn off their layers in this detail")
    go.GeometryFilter = Rhino.DocObjects.ObjectType.AnyObject
    go.SubObjectSelect = False
    go.GroupSelect = True
    go.GetMultiple(1, 0)

    if go.CommandResult() != Rhino.Commands.Result.Success:
        print("OLOD: Cancelled.")
        return

    # --- Collect unique layer indices ---
    layer_indices = set()
    for i in range(go.ObjectCount):
        rh_obj = go.Object(i).Object()
        if rh_obj is not None:
            layer_indices.add(rh_obj.Attributes.LayerIndex)

    if not layer_indices:
        print("OLOD: No valid objects selected.")
        return

    # --- Turn off layers in this detail ---
    count = 0
    skipped = []

    for idx in layer_indices:
        # Re-acquire layer (may have been modified in previous iteration)
        layer = sc.doc.Layers[idx]
        if layer is None:
            continue

        full_path = layer.FullPath

        # Cannot turn off the current layer
        if idx == sc.doc.Layers.CurrentLayerIndex:
            skipped.append(full_path + "  [current layer]")
            continue

        # Per-viewport visibility only works on globally visible layers
        if not layer.IsVisible:
            skipped.append(full_path + "  [globally off]")
            continue

        # Already off in this detail?
        if not layer.PerViewportIsVisible(vp_id):
            skipped.append(full_path + "  [already off in detail]")
            continue

        # Turn off in this detail only
        layer.SetPerViewportVisible(vp_id, False)
        layer.SetPerViewportPersistentVisibility(vp_id, False)
        count += 1
        print("  OFF: {}".format(full_path))

    # --- Report ---
    if skipped:
        print("  ---")
        for s in skipped:
            print("  SKIP: {}".format(s))

    print("--- OLOD: {} layer(s) turned off in detail '{}' ---".format(count, detail_name))
    sc.doc.Views.Redraw()


if __name__ == "__main__":
    main()

OLON — OneLayerOnInDetail (Ghost Overlay)

What it does: The reverse of OLOD — but since hidden objects can’t be picked, OLON uses a “ghost mode”: it temporarily shows all hidden layers as selectable geometry while dimming the currently visible layers as locked context. Pick what you want back, and only those layers are restored.

How to use:

  1. Double-click into a detail that has layers turned off

  2. Run OLON

  3. The view switches to ghost mode:

    • Normal color = previously hidden layers (now selectable)

    • Dimmed/gray = currently visible layers (locked, non-selectable)

  4. Pick objects from the hidden layers you want to restore

  5. Press Enter — original state is restored, then only the picked layers are turned back on

Display Mode requirement: For the ghost effect to work visually, enable “Apply these settings to layers” in your Display Mode under Objects → Locked Objects. Set a lock color (e.g. light gray) so locked layers are clearly distinguishable.

Technical notes:

  • Parent layers of hidden sublayers are excluded from locking (Rhino’s lock inheritance would otherwise block sublayer selection)

  • A custom geometry filter ensures only objects on hidden layers can be picked

  • The entire ghost mode is wrapped in try/finally — the original state is always restored, even on cancel or error

# -*- coding: utf-8 -*-
# OneLayerOnInDetail (OLON) v3 — Ghost Overlay
# Shows layers hidden in the active detail as selectable geometry,
# dims currently visible layers as locked context (ghost).
# Pick objects from hidden layers to turn them back on.
#
# v3: Ancestor layers of hidden sublayers are excluded from locking
#     (Rhino lock inheritance would block sublayer selection).
#     Custom geometry filter restricts picks to hidden layers only.

import System
import Rhino
import scriptcontext as sc

def main():
    # --- Validate: page view + active detail ---
    view = sc.doc.Views.ActiveView
    if view is None or not isinstance(view, Rhino.Display.RhinoPageView):
        print("OLON: Not in a layout/page view.")
        return

    page_view = view
    vp_id = page_view.ActiveViewportID

    details = page_view.GetDetailViews()
    active_detail = None
    for d in details:
        if d.Viewport.Id == vp_id:
            active_detail = d
            break

    if active_detail is None:
        print("OLON: No active detail. Double-click into a detail first.")
        return

    detail_name = active_detail.Name if active_detail.Name else "(unnamed)"

    # --- Classify layers: hidden vs visible in this detail ---
    hidden_indices = set()
    visible_indices = []

    for i in range(sc.doc.Layers.Count):
        layer = sc.doc.Layers[i]
        if layer is None or layer.IsDeleted:
            continue
        if not layer.IsVisible:
            continue

        if layer.PerViewportIsVisible(vp_id):
            visible_indices.append(i)
        else:
            hidden_indices.add(i)

    if not hidden_indices:
        print("OLON: No layers are hidden in this detail. Nothing to restore.")
        return

    print("OLON: Detail '{}' — {} hidden layer(s):".format(detail_name, len(hidden_indices)))
    for i in hidden_indices:
        print("  [hidden] {}".format(sc.doc.Layers[i].FullPath))

    # --- Find ancestor layers of hidden sublayers ---
    # These must NOT be locked, or their hidden children become non-selectable
    ancestor_indices = set()
    for i in hidden_indices:
        layer = sc.doc.Layers[i]
        parent_id = layer.ParentLayerId
        while parent_id != System.Guid.Empty:
            parent_layer = sc.doc.Layers.FindId(parent_id)
            if parent_layer is not None and not parent_layer.IsDeleted:
                ancestor_indices.add(parent_layer.Index)
                parent_id = parent_layer.ParentLayerId
            else:
                break

    if ancestor_indices:
        print("  ({} ancestor layer(s) excluded from locking)".format(len(ancestor_indices)))

    # --- Snapshot original lock states ---
    original_locks = {}
    for i in visible_indices:
        original_locks[i] = sc.doc.Layers[i].IsLocked

    picked_layer_indices = set()

    try:
        # === ENTER GHOST MODE ===

        # 1) Lock visible layers EXCEPT ancestors of hidden sublayers
        for i in visible_indices:
            if i in ancestor_indices:
                continue  # Skip — locking would block hidden children
            layer = sc.doc.Layers[i]
            if not layer.IsLocked:
                layer.IsLocked = True
                sc.doc.Layers.Modify(layer, i, False)

        # 2) Show all hidden layers in this detail
        for i in hidden_indices:
            layer = sc.doc.Layers[i]
            layer.SetPerViewportVisible(vp_id, True)
            layer.SetPerViewportPersistentVisibility(vp_id, True)
            sc.doc.Layers.Modify(layer, i, False)

        sc.doc.Views.Redraw()

        print("")
        print("=== GHOST MODE ACTIVE ===")
        print("  Normal color = hidden layers (selectable)")
        print("  Dimmed/gray  = visible layers (locked context)")
        print("  Pick objects to turn ON their layers. Enter=confirm, Esc=cancel")
        print("")

        # --- Pick objects with custom filter ---
        # Only objects on hidden layers are selectable
        def hidden_layer_filter(rh_obj, geo, ci):
            return rh_obj.Attributes.LayerIndex in hidden_indices

        go = Rhino.Input.Custom.GetObject()
        go.SetCommandPrompt("Select objects to turn ON their layers in this detail")
        go.GeometryFilter = Rhino.DocObjects.ObjectType.AnyObject
        go.SubObjectSelect = False
        go.GroupSelect = True
        go.SetCustomGeometryFilter(hidden_layer_filter)
        go.GetMultiple(1, 0)

        if go.CommandResult() == Rhino.Commands.Result.Success:
            for i in range(go.ObjectCount):
                rh_obj = go.Object(i).Object()
                if rh_obj is not None:
                    picked_layer_indices.add(rh_obj.Attributes.LayerIndex)

    finally:
        # === RESTORE ORIGINAL STATE (always runs) ===

        # Restore lock states
        for i, was_locked in original_locks.items():
            layer = sc.doc.Layers[i]
            if layer is not None:
                layer.IsLocked = was_locked
                sc.doc.Layers.Modify(layer, i, False)

        # Re-hide all temporarily shown layers
        for i in hidden_indices:
            layer = sc.doc.Layers[i]
            if layer is not None:
                layer.SetPerViewportVisible(vp_id, False)
                layer.SetPerViewportPersistentVisibility(vp_id, False)
                sc.doc.Layers.Modify(layer, i, False)

    # --- Apply: turn ON only the picked layers ---
    if picked_layer_indices:
        count = 0
        for idx in picked_layer_indices:
            layer = sc.doc.Layers[idx]
            if layer is not None:
                layer.SetPerViewportVisible(vp_id, True)
                layer.SetPerViewportPersistentVisibility(vp_id, True)
                sc.doc.Layers.Modify(layer, idx, False)
                count += 1
                print("  ON: {}".format(layer.FullPath))

        print("--- OLON: {} layer(s) turned on in detail '{}' ---".format(count, detail_name))
    else:
        print("OLON: Cancelled or no hidden-layer objects picked. No changes.")

    sc.doc.Views.Redraw()


if __name__ == "__main__":
    main()

DLT — DetailLayerTransfer

What it does: Copies the complete per-detail layer visibility state from one detail to one or more target details. Push-based: pick source, then pick targets.

How to use:

  1. Be on a layout page (not inside a detail — click the layout background to exit)

  2. Run DLT

  3. Pick the source detail frame

  4. Pick one or more target detail frames → Enter

  5. All per-detail layer ON/OFF settings from the source are applied to the targets (overwrite)

Why it’s useful: When working with overlapping details on the same layout (e.g. separate details for structure, MEP, furniture on the same plan), each detail needs identical or near-identical layer configurations. Setting this up manually means double or triple the work. DLT copies the entire visibility state in one step.

# -*- coding: utf-8 -*-
# DetailLayerTransfer (DLT) v1
# Push: pick source detail -> pick target detail(s) -> transfer per-viewport layer visibility.
# Must be on layout page level (not inside a detail).
# Overwrites target detail visibility with source state.

import Rhino
import scriptcontext as sc

def detail_filter(rh_obj, geo, ci):
    """Custom geometry filter: only allow picking DetailViewObjects."""
    return isinstance(rh_obj, Rhino.DocObjects.DetailViewObject)


def main():
    # --- Validate: must be on a page view ---
    view = sc.doc.Views.ActiveView
    if view is None or not isinstance(view, Rhino.Display.RhinoPageView):
        print("DLT: Not in a layout/page view.")
        return

    page_view = view
    vp_id = page_view.ActiveViewportID

    # --- Check we're on the page level, not inside a detail ---
    details = page_view.GetDetailViews()
    inside_detail = False
    for d in details:
        if d.Viewport.Id == vp_id:
            inside_detail = True
            break

    if inside_detail:
        print("DLT: You are inside a detail. Click the layout background to exit the detail first.")
        return

    if len(details) < 2:
        print("DLT: Need at least 2 details on this layout (found {}).".format(len(details)))
        return

    # --- Pick SOURCE detail ---
    go_src = Rhino.Input.Custom.GetObject()
    go_src.SetCommandPrompt("Pick SOURCE detail frame")
    go_src.SubObjectSelect = False
    go_src.SetCustomGeometryFilter(detail_filter)
    go_src.GetMultiple(1, 1)

    if go_src.CommandResult() != Rhino.Commands.Result.Success:
        print("DLT: Cancelled.")
        return

    src_obj = go_src.Object(0).Object()
    if not isinstance(src_obj, Rhino.DocObjects.DetailViewObject):
        print("DLT: Selected object is not a detail view.")
        return

    src_detail = src_obj
    src_vp_id = src_detail.Viewport.Id
    src_name = src_detail.Name if src_detail.Name else "(unnamed)"
    print("DLT: Source = '{}'".format(src_name))

    # --- Pick TARGET detail(s) ---
    go_tgt = Rhino.Input.Custom.GetObject()
    go_tgt.SetCommandPrompt("Pick TARGET detail frame(s) — Enter when done")
    go_tgt.SubObjectSelect = False
    go_tgt.SetCustomGeometryFilter(detail_filter)
    go_tgt.EnablePreSelect(False, True)
    go_tgt.DeselectAllBeforePostSelect = True
    go_tgt.GetMultiple(1, 0)

    if go_tgt.CommandResult() != Rhino.Commands.Result.Success:
        print("DLT: Cancelled.")
        return

    target_details = []
    for i in range(go_tgt.ObjectCount):
        obj = go_tgt.Object(i).Object()
        if not isinstance(obj, Rhino.DocObjects.DetailViewObject):
            continue
        if obj.Viewport.Id == src_vp_id:
            print("  SKIP: Same as source detail")
            continue
        target_details.append(obj)

    if not target_details:
        print("DLT: No valid target details selected.")
        return

    tgt_names = [t.Name if t.Name else "(unnamed)" for t in target_details]
    print("DLT: {} target(s): {}".format(len(target_details), ", ".join(tgt_names)))

    # --- Read source state and transfer ---
    changes = 0
    layers_changed = 0

    for layer_idx in range(sc.doc.Layers.Count):
        layer = sc.doc.Layers[layer_idx]
        if layer is None or layer.IsDeleted:
            continue
        if not layer.IsVisible:
            continue  # Globally off — no per-detail control

        src_visible = layer.PerViewportIsVisible(src_vp_id)
        layer_touched = False

        for tgt in target_details:
            tgt_vp_id = tgt.Viewport.Id

            # Re-acquire layer (may have been modified by previous target)
            layer = sc.doc.Layers[layer_idx]
            tgt_visible = layer.PerViewportIsVisible(tgt_vp_id)

            if tgt_visible != src_visible:
                layer.SetPerViewportVisible(tgt_vp_id, src_visible)
                layer.SetPerViewportPersistentVisibility(tgt_vp_id, src_visible)
                sc.doc.Layers.Modify(layer, layer_idx, False)
                changes += 1
                layer_touched = True

        if layer_touched:
            layers_changed += 1

    # --- Report ---
    print("--- DLT: {} layer(s), {} change(s) applied ---".format(layers_changed, changes))
    print("    Source: '{}' -> Target(s): {}".format(src_name, ", ".join(tgt_names)))

    sc.doc.Views.Redraw()


if __name__ == "__main__":
    main()

General Notes

  • All three scripts require globally visible layers to function — Rhino’s per-viewport visibility only works on layers that are globally ON. For layout workflows, it’s recommended to keep layers globally visible and use the Model On column to hide them in model space.

  • Alias suggestions: OLOD, OLON, DLT

  • Tested with Rhino 8 / IronPython 2.7

PS: the text was written with AI - because I am still lazy

2 Likes