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

Thanks a lot! I’m giving these a try - especially DLT looks very useful in my case.

Good work, but does Mac Rhino not have:

HideLayersInDetail
ShowLayersInDetail

Detail | Rhino 3-D modeling

haha, you are right, I guess I missed that in the heat…

but as stated the olon in comparison to ShowLayersInDetail; (should) display the hidden elements during the command so a visual-picking is possible instead of using a list of layers.

Hi,
this seems very promising work!

In relations to this:

Would it be possible to save the Layerstates of a detail as object attributes (Key/Value on the Detail object)? And in the case that the layer visibility gets messed up (eg. when using worksession files), it could be quickly restored from that data?

Hi @Toni_Osterlund - I iterated on the script and tried to implement the workflow you need. I am not sure if this fits your needs. I also condensed all the scripts into one master-script with options.

Detail_Layer_Suite.py (37.6 KB)

Here is also a summary and a testing scenario for you (written by AI) please read and test it, best regards, sebowim.

Detail Layer Suite (DLS) - forum post draft


Following up on the OLOD / OLON / DLT scripts posted earlier: I’ve merged everything into one script with a command line menu, added the features discussed in this thread, and built the Save/Restore pair that was requested for the worksession problem.

Download: Detail_Layer_Suite.py (attached). Run it with _-RunPythonScript, pick a tool from the command line menu. Enter repeats the last used tool. A toolbar button can jump straight into one tool via a one-line macro that presets sc.sticky['DLS_mode_preset'] (see the script header).

Fair warning: the merged suite is a fresh build. The Off/On logic is field-tested from the standalone versions, Transfer/Diff/Save/Restore are not yet. Test case below.

The tools

Off - Pick objects inside the active detail, their layers get turned off in this detail only. Global visibility is untouched. New command line toggle Sublayers=Include: picked layers take all their descendant layers along, persistently. Useful because a plain parent-off hides children only effectively; they would pop back the moment the parent is turned on again.

On - The reverse, via a ghost overlay: everything hidden in the active detail is temporarily shown selectable, everything visible is locked and dimmed as context. Pick objects, confirm with Enter, and their layers come back on - including any hidden parent layers, so the picked object actually appears. Esc restores the previous state exactly, including persistent flags of sublayers.

Transfer - Push the layer state of a source detail to other details. Pick targets on the current layout, Enter = all other details on this layout, or use the CrossPage option for a checklist of every detail in the document (the “same setup on EG / OG1 / OG2” case). Command line toggle Mode: Overwrite makes exact clones, MergeOff only transfers the OFF states and keeps whatever you switched on manually in the targets. Works on the persistent per-detail flags, so nested parent/child states survive correctly.

Diff - Compare two details and list every layer whose per-detail visibility differs (A/B, full paths). Read-only, changes nothing. Handy as a sanity check before a Transfer. Works cross-page too.

Save - Stores the per-detail layer state as a user string on the detail object itself (key DetailLayerState, compact JSON, only the OFF deviations). Scope: the active detail, picked details, Enter = all details on the layout, or AllLayouts for the whole document. Because it’s an object attribute, it is saved in the 3dm and travels along when you copy the detail or the layout.

Restore - Reads that user string and re-applies the state deterministically: every globally visible layer gets an explicit on/off, layers created after the save default to on. Layer matching is worksession-aware, in three stages: layer GUID, then exact full path, then a unique :: path-suffix match (for reference layers whose path gained or lost a file prefix between sessions). Anything unresolvable is listed as MISSING; the rest is restored anyway.

Everything runs inside one undo record - a single Ctrl+Z reverts a complete run, including the ghost round trip of the On tool.

Required settings (read this once)

The On tool dims the locked context objects only if your display mode passes lock display settings through to layers. For every display mode you use inside details:

Rhino Options > View > Display Modes > [your mode] > Objects > Locked Objects: enable “Apply these settings to layers” and set the locked appearance (gray and/or transparency) to taste.

Without this the tool still works, but locked context renders in normal colors, which defeats the visual point of the ghost mode. Known Rhino issue: annotations ignore the locked transparency (RH-81991).

Two design rules carried over from the standalone versions: globally hidden layers are never touched (per-detail visibility only applies to globally visible layers; use the “Detail On” column workflow), and the current layer is skipped by the Off tool (constant in the script header if you want to change that).

Test case for the worksession problem (@Toni_Osterlund)

This is the scenario Save/Restore was built for. Background: Rhino does not persist per-detail visibility of layers that come from worksession reference files, neither in the .rws nor in the host 3dm, so it’s gone on every reopen. The suite works around it by storing the state on the detail object in the host file. What I can’t fully test on my side is how your reference layer paths and GUIDs behave across sessions, so here is what to check:

  1. Open your host 3dm with the worksession attached. Go to a layout with at least one detail.
  2. In one detail, turn a handful of reference-file layers off per-detail (Detail On column or the Off tool). If you can, include a nested case: a child layer off while its parent stays on, and vice versa.
  3. Run the suite > Save > option AllLayouts. Then _Save the host file (the data lives in the 3dm).
  4. Close Rhino completely. Reopen the host file and reattach/reload the worksession.
  5. Confirm the known bug first: the per-detail states of the reference layers should be lost/reset.
  6. Run the suite > Restore > AllLayouts.

Then please report back:

  • Did the reference layers return to their saved per-detail state, including the nested parent/child cases?
  • Copy the command line output. Specifically: any MISSING lines, and any NOTE … (matched by path suffix) lines. The suffix notes tell me the full paths of your reference layers changed between sessions and only the fallback matching caught them; MISSING lines tell me even that failed, and I’d need an example of the old vs. new full path.
  • Two optional bonus checks: (a) copy a detail or a whole layout after saving; the pasted copy should carry the saved state (Restore should work on it immediately). (b) rename a reference layer after saving, then Restore in the same session; the GUID matching should still find it.

If the path-suffix fallback turns out not to match how your worksession prefixes the layer paths, that’s a small fix once I see a real example.

possible Roadmap

Pull mode for Transfer (grab a state into the active detail), a Reset tool (clear all per-detail overrides), and, once this moves from Python to a compiled plugin: automatic Save on every document save via RhinoDoc.BeginSaveDocument, plus a DisplayConduit-based ghost rendering that doesn’t depend on the display mode settings above.

Feedback welcome, especially on the untested tools.

@Sebastian_Wimmer

Path of script to run ( Browse ): "C:\[redacted]\Detail_Layer_Suite.py"
Detail Layer Suite (Enter = Off) ( Off  On  Transfer  Diff  Save  Restore ): Save
Pick detail(s) to save - Enter = all details on this layout ( AllLayouts )
  FAILED: '(unnamed)' (could not commit attributes)
  FAILED: '(unnamed)' (could not commit attributes)
  FAILED: '(unnamed)' (could not commit attributes)
  FAILED: '(unnamed)' (could not commit attributes)
  FAILED: '(unnamed)' (could not commit attributes)
--- DLSS: 0 detail state(s) saved (all details on this layout), 5 failed ---
    Note: run _Save afterwards - the data lives in the 3dm file.

I printed out the payload in tool_save function, and it gave me this:

{"v": 1, "saved": "07.08.2026 08:36", "detail": "(unnamed)", "layout": "G327-4-3-05", "off": []}
  FAILED: '(unnamed)' (could not commit attributes)
{"v": 1, "saved": "07.08.2026 08:36", "detail": "(unnamed)", "layout": "G327-4-3-05", "off": []}
  FAILED: '(unnamed)' (could not commit attributes)
{"v": 1, "saved": "07.08.2026 08:36", "detail": "(unnamed)", "layout": "G327-4-3-05", "off": []}
  FAILED: '(unnamed)' (could not commit attributes)
{"v": 1, "saved": "07.08.2026 08:36", "detail": "(unnamed)", "layout": "G327-4-3-05", "off": []}
  FAILED: '(unnamed)' (could not commit attributes)
{"v": 1, "saved": "07.08.2026 08:36", "detail": "(unnamed)", "layout": "G327-4-3-05", "off": [["a60a17cb-c4b6-4b8a-a6b4-d067138da765", "Documentation::Section lines::VA sections"], ["64dde566-0743-4878-bcef-b742b6265bd9", "Documentation::Dimensions::VO3 primaries TOP"], ["099329b9-bbc3-42d9-9735-fa8eb5e5e0c7", "Documentation::Name tag::Roof beams"], ["3af99b07-dda9-45d4-8ac3-84ff352d6b55", "Documentation::Name tag::primaries"], ["cd87527c-f1e7-4dd1-8bbb-f001525b025d", "3D_Roof elements.3dm::Beams::LVL::Primaries"], ["e06bdff6-1850-4dc5-9336-32c8ff4b6cc6", "3D_Roof elements.3dm::Beams::LVL::Roof beams"], ["2f29bea5-df10-444b-8c8d-23d4231753a4", "3D_Roof elements.3dm::Beams::LVL::Element beams"], ["6c95ddce-f613-4df7-9544-1ddcd42fbd2f", "3D_Wood structure_3.3dm"]]}
  FAILED: '(unnamed)' (could not commit attributes)
--- DLSS: 0 detail state(s) saved (all details on this layout), 5 failed ---
    Note: run _Save afterwards - the data lives in the 3dm file.

The data seems to be saved, even though the “FAILED” message.

EDIT:
It seems that detail.Attributes.SetUserString(USERSTRING_KEY, payload) saves the userstring directly, without the need to CommitChanges. I can read the userstrings directly after they are set. I do not know why the commit then fails..

detail.Attributes.SetUserString(USERSTRING_KEY, payload)
print(detail.Attributes.GetUserString(USERSTRING_KEY))

thanks for the feedback, so it worked? even tough, the script assumes an error because it reads out the wrong data for confirmation? here is an updated version..

Detail_Layer_Suite_1.1.py (40.9 KB)