""" Create Layout Locators (Maya) ============================== For each greybox placement group (_USD suffix, case-insensitive), creates a matching transform node (_LOC suffix) whose world transform exactly matches the placement group using Maya's matchTransform command. All locators are grouped under a single LAYOUT_LOCATORS null at the scene root. The LAYOUT_LOCATORS group can then be exported as its own USD file (File -> Export Selection with mayaUSD, or via the script's export function). This gives a clean, geometry-free USD layer containing only named Xform prims with world transforms - perfect for the Houdini assembly stage to read layout data from. Naming convention: armchair_USD -> armchair_LOC book__01_USD -> book__01_LOC book__02_USD -> book__02_LOC The _LOC nodes are siblings of the _USD groups (not children), so freezing transforms on the _USD groups has no effect on them. Scale is deliberately NOT matched - assembly uses published assets at scale 1 regardless of greybox scale. Run modes (set MODE at top): "create" - create/update all locator nodes "verify" - dry run; report what would be created, no writes "export" - create locators then export to USD file "clear" - delete all _LOC nodes and the LAYOUT_LOCATORS group Usage: 1. Lay out greybox in Maya. Don't freeze yet. 2. Run with MODE = "verify" to preview. 3. Run with MODE = "create" to generate locators. (or MODE = "export" to create AND export in one step) 4. Freeze/clean up the _USD groups as normal. (The _LOC nodes are completely unaffected.) 5. Export LAYOUT_LOCATORS as a USD file if not done in step 3. 6. Use that USD in Houdini as the layout manifest source. """ import re import maya.cmds as cmds # ================================================================ # CONFIGURATION # ================================================================ MODE = "create" # "create" | "verify" | "export" | "clear" # Suffix settings - case-insensitive matching PLACEMENT_SUFFIX = "_USD" LOCATOR_SUFFIX = "_LOC" DUPE_SEPARATOR = "__" # book__01_USD -> asset "book", dupe 1 # Root group that holds all locators LOCATOR_ROOT = "LAYOUT_LOCATORS" # Export settings (used in "export" mode) # Update this path before running export EXPORT_PATH = "C:/temp/layout_locators.usda" # ================================================================ # Helpers # ================================================================ def find_placement_groups(): """All transform nodes ending in PLACEMENT_SUFFIX (case-insensitive). Skips shapes and any nodes that are already _LOC nodes.""" suffix_lower = PLACEMENT_SUFFIX.lower() loc_lower = LOCATOR_SUFFIX.lower() return [ n for n in (cmds.ls(type="transform", long=False) or []) if n.lower().endswith(suffix_lower) and not n.lower().endswith(loc_lower) ] def placement_to_locator_name(node_name): """'armchair_USD' -> 'armchair_LOC'. 'book__01_USD' -> 'book__01_LOC'. Case-preserving on the base name.""" base = node_name[:-len(PLACEMENT_SUFFIX)] return base + LOCATOR_SUFFIX def parse_asset_name(node_name): """'book__01_USD' -> ('book', 1). 'armchair_USD' -> ('armchair', 0).""" base = node_name[:-len(PLACEMENT_SUFFIX)] m = re.match(rf"^(.+){re.escape(DUPE_SEPARATOR)}(\d+)$", base) if m: return m.group(1), int(m.group(2)) return base, 0 def get_world_transform_for_display(node): """Return world translate and rotate for logging only.""" t = cmds.xform(node, q=True, ws=True, t=True) r = cmds.xform(node, q=True, ws=True, ro=True) return tuple(t), tuple(r) def ensure_locator_root(): """Get or create the LAYOUT_LOCATORS root group.""" if not cmds.objExists(LOCATOR_ROOT): cmds.group(empty=True, name=LOCATOR_ROOT, world=True) print(f" Created root group: {LOCATOR_ROOT}") return LOCATOR_ROOT # ================================================================ # Modes # ================================================================ def do_create(verify_only=False): label = "VERIFY" if verify_only else "CREATE LOCATORS" print(f"\n{'=' * 60}") print(label) print(f"{'=' * 60}") groups = find_placement_groups() if not groups: print(f" No nodes matching *{PLACEMENT_SUFFIX} found.") return print(f" Found {len(groups)} placement group(s)\n") if not verify_only: root = ensure_locator_root() created = 0 updated = 0 for node in sorted(groups): loc_name = placement_to_locator_name(node) asset_name, dupe = parse_asset_name(node) t, r = get_world_transform_for_display(node) dupe_str = f"#{dupe}" if dupe > 0 else "(orig)" print(f" {node:<45s} -> {loc_name}") print(f" asset={asset_name!r} {dupe_str}") print(f" T = ({t[0]:+.4f}, {t[1]:+.4f}, {t[2]:+.4f})") print(f" R = ({r[0]:+.4f}, {r[1]:+.4f}, {r[2]:+.4f}) deg") if verify_only: print() continue # Create or update the locator node if cmds.objExists(loc_name): print(f" (updating existing '{loc_name}')") updated += 1 else: cmds.group(empty=True, name=loc_name, world=True) cmds.parent(loc_name, root) created += 1 # Match position + rotation from source _USD group. # matchTransform handles rotation order, pivots, and parent # hierarchy correctly in one call. # Scale is deliberately excluded - assembly uses published # assets at scale 1 regardless of greybox scale values. cmds.matchTransform(loc_name, node, pos=True, rot=True, scl=False) # Store asset metadata as string attributes. # mayaUSD exports these as USD attributes on the prim, # which Houdini reads to identify which asset to load. for attr_name, attr_val in [ ("assetName", asset_name), ("assetGroup", node), ("dupeIndex", str(dupe)), ]: if not cmds.attributeQuery(attr_name, node=loc_name, exists=True): cmds.addAttr(loc_name, longName=attr_name, dataType="string") cmds.setAttr(f"{loc_name}.{attr_name}", attr_val, type="string") print() if not verify_only: print(f" Created: {created} Updated: {updated}") print(f" All locators parented under: {LOCATOR_ROOT}") print(f"\n Safe to freeze/zero the _USD groups now.") print(f" The _LOC nodes are unaffected by that operation.") def do_export(): """Create locators then export LAYOUT_LOCATORS as a USD file.""" do_create(verify_only=False) if not cmds.objExists(LOCATOR_ROOT): print(f"\n ERROR: {LOCATOR_ROOT} not found after creation step.") return print(f"\n{'=' * 60}") print(f"EXPORTING USD") print(f"{'=' * 60}") print(f" Export path: {EXPORT_PATH}") import os export_dir = os.path.dirname(EXPORT_PATH) if export_dir: os.makedirs(export_dir, exist_ok=True) cmds.select(LOCATOR_ROOT, replace=True) try: cmds.mayaUSDExport( selection=True, file=EXPORT_PATH, shadingMode="none", exportDisplayColor=False, exportColorSets=False, exportUVs=False, exportSkels="none", exportSkin="none", exportBlendShapes=False, exportVisibility=False, mergeTransformAndShape=True, stripNamespaces=True, worldspace=False, ) print(f" Exported successfully: {EXPORT_PATH}") except Exception as e: print(f"\n mayaUSDExport failed: {e}") print(f"\n Export manually instead:") print(f" 1. Select LAYOUT_LOCATORS in the outliner") print(f" 2. File -> Export Selection") print(f" 3. Set file type to 'USD Export'") print(f" 4. Save to: {EXPORT_PATH}") cmds.select(clear=True) def do_clear(): """Remove all _LOC nodes and the LAYOUT_LOCATORS group.""" print(f"\n{'=' * 60}") print(f"CLEAR LOCATORS") print(f"{'=' * 60}") if cmds.objExists(LOCATOR_ROOT): cmds.delete(LOCATOR_ROOT) print(f" Deleted: {LOCATOR_ROOT}") else: # Fallback: find and delete any stray _LOC nodes suffix_lower = LOCATOR_SUFFIX.lower() loc_nodes = [ n for n in (cmds.ls(type="transform", long=False) or []) if n.lower().endswith(suffix_lower) ] if loc_nodes: for n in loc_nodes: if cmds.objExists(n): cmds.delete(n) print(f" Deleted: {n}") else: print(f" Nothing to clear.") # ================================================================ # Entry point # ================================================================ if MODE == "create": do_create(verify_only=False) elif MODE == "verify": do_create(verify_only=True) elif MODE == "export": do_export() elif MODE == "clear": do_clear() else: cmds.warning( f"Unknown MODE: {MODE!r}. Use 'create', 'verify', 'export', or 'clear'." )