asecli 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. asecli/__init__.py +3 -0
  2. asecli/bridge/__init__.py +40 -0
  3. asecli/bridge/_editor_port_contract.py +131 -0
  4. asecli/bridge/_editor_primitives.py +205 -0
  5. asecli/bridge/_editor_spec_grammar.py +245 -0
  6. asecli/bridge/_editor_spec_io.py +26 -0
  7. asecli/bridge/_editor_spec_validation.py +218 -0
  8. asecli/bridge/_gui_handoff_store.py +78 -0
  9. asecli/bridge/_gui_project.py +35 -0
  10. asecli/bridge/_gui_resource_store.py +159 -0
  11. asecli/bridge/_gui_resource_upgrade.py +122 -0
  12. asecli/bridge/editor_create.py +173 -0
  13. asecli/bridge/editor_spec.py +250 -0
  14. asecli/bridge/graph_geometry.py +249 -0
  15. asecli/bridge/graph_geometry_parser.py +57 -0
  16. asecli/bridge/graph_inspect.py +136 -0
  17. asecli/bridge/gui_handoff.py +100 -0
  18. asecli/bridge/gui_presentation.py +70 -0
  19. asecli/bridge/gui_provider_detection.py +197 -0
  20. asecli/bridge/gui_runtime_probe.py +25 -0
  21. asecli/bridge/gui_support.py +240 -0
  22. asecli/bridge/gui_support_resource.py +58 -0
  23. asecli/bridge/mcp_client.py +231 -0
  24. asecli/bridge/recompile.py +107 -0
  25. asecli/bridge/resource_text.py +12 -0
  26. asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
  27. asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
  28. asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
  29. asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
  30. asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
  31. asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
  32. asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
  33. asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
  34. asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
  35. asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
  36. asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
  37. asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
  38. asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
  39. asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
  40. asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
  41. asecli/bridge/wire_route.py +127 -0
  42. asecli/checks/__init__.py +10 -0
  43. asecli/checks/checksum.py +53 -0
  44. asecli/checks/local_vars.py +135 -0
  45. asecli/checks/usage.py +141 -0
  46. asecli/checks/validate.py +129 -0
  47. asecli/cli/__init__.py +1 -0
  48. asecli/cli/commands.py +237 -0
  49. asecli/cli/commentary_command.py +158 -0
  50. asecli/cli/create_command.py +250 -0
  51. asecli/cli/custom_gui_command.py +170 -0
  52. asecli/cli/gui_provider.py +30 -0
  53. asecli/cli/gui_support_command.py +35 -0
  54. asecli/cli/io.py +214 -0
  55. asecli/cli/layout_command.py +213 -0
  56. asecli/cli/main.py +245 -0
  57. asecli/cli/recompile_metadata.py +53 -0
  58. asecli/cli/skill_command.py +121 -0
  59. asecli/cli/usage_command.py +42 -0
  60. asecli/core/__init__.py +84 -0
  61. asecli/core/comment_bounds.py +166 -0
  62. asecli/core/comment_layout.py +167 -0
  63. asecli/core/comment_metrics.py +132 -0
  64. asecli/core/comment_purpose.py +140 -0
  65. asecli/core/commentary.py +225 -0
  66. asecli/core/compiled_metadata.py +135 -0
  67. asecli/core/compiled_properties.py +190 -0
  68. asecli/core/custom_gui.py +250 -0
  69. asecli/core/custom_gui_versions.py +35 -0
  70. asecli/core/fishbone_placement.py +159 -0
  71. asecli/core/fishbone_topology.py +186 -0
  72. asecli/core/graph_ops.py +122 -0
  73. asecli/core/layout.py +174 -0
  74. asecli/core/layout_audit.py +237 -0
  75. asecli/core/layout_audit_geometry.py +247 -0
  76. asecli/core/layout_audit_repeated.py +44 -0
  77. asecli/core/layout_graph.py +113 -0
  78. asecli/core/local_vars.py +54 -0
  79. asecli/core/material_gui_condition.py +68 -0
  80. asecli/core/material_gui_protocol.py +35 -0
  81. asecli/core/material_gui_spec.py +191 -0
  82. asecli/core/meticulous_layout.py +114 -0
  83. asecli/core/model.py +237 -0
  84. asecli/core/property_presentation.py +233 -0
  85. asecli/core/wire_geometry.py +83 -0
  86. asecli/core/wire_router.py +248 -0
  87. asecli/schema/__init__.py +49 -0
  88. asecli/schema/data/observed.json +16 -0
  89. asecli/schema/data/schemas.json +14106 -0
  90. asecli/skills/asecli/SKILL.md +275 -0
  91. asecli/skills/asecli/references/layout-standard.md +99 -0
  92. asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
  93. asecli/skills/asecli/references/material-property-standard.md +108 -0
  94. asecli-0.6.2.dist-info/METADATA +594 -0
  95. asecli-0.6.2.dist-info/RECORD +98 -0
  96. asecli-0.6.2.dist-info/WHEEL +4 -0
  97. asecli-0.6.2.dist-info/entry_points.txt +2 -0
  98. asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,54 @@
1
+ """ASE Register/Get Local Var semantic references."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .model import AseGraph, NodeLine
6
+
7
+
8
+ REGISTER_LOCAL_VAR_TYPE = "AmplifyShaderEditor.RegisterLocalVarNode"
9
+ GET_LOCAL_VAR_TYPE = "AmplifyShaderEditor.GetLocalVarNode"
10
+
11
+
12
+ def parse_register_local_var(node: NodeLine) -> dict:
13
+ if node.type_name != REGISTER_LOCAL_VAR_TYPE:
14
+ raise ValueError(f"node {node.node_id} is not a RegisterLocalVarNode")
15
+ if len(node.raw_fields) < 16:
16
+ raise ValueError(f"RegisterLocalVarNode {node.node_id} has too few serialized fields")
17
+ return {
18
+ "node_id": node.node_id,
19
+ "name": node.raw_fields[6],
20
+ "data_type": node.raw_fields[15],
21
+ }
22
+
23
+
24
+ def parse_get_local_var(node: NodeLine) -> dict:
25
+ if node.type_name != GET_LOCAL_VAR_TYPE:
26
+ raise ValueError(f"node {node.node_id} is not a GetLocalVarNode")
27
+ if len(node.raw_fields) < 15:
28
+ raise ValueError(f"GetLocalVarNode {node.node_id} has too few serialized fields")
29
+ return {
30
+ "node_id": node.node_id,
31
+ "register_id": node.raw_fields[6],
32
+ "name": node.raw_fields[7],
33
+ "data_type": node.raw_fields[14],
34
+ }
35
+
36
+
37
+ def local_var_edges(graph: AseGraph) -> list[tuple[str, str]]:
38
+ """Return valid semantic data-flow edges as ``Register -> Get``."""
39
+ registers = {
40
+ node.node_id
41
+ for node in graph.nodes
42
+ if node.type_name == REGISTER_LOCAL_VAR_TYPE
43
+ }
44
+ edges = []
45
+ for node in graph.nodes:
46
+ if node.type_name != GET_LOCAL_VAR_TYPE:
47
+ continue
48
+ try:
49
+ item = parse_get_local_var(node)
50
+ except ValueError:
51
+ continue
52
+ if item["register_id"] in registers:
53
+ edges.append((item["register_id"], node.node_id))
54
+ return edges
@@ -0,0 +1,68 @@
1
+ """Portable conditional-enable metadata for MZGUI-compatible inspectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+
8
+
9
+ ENABLE_IF_ATTRIBUTE = "EnableIfMzgui"
10
+ ENABLE_IF_OPERATORS = (
11
+ "Less",
12
+ "LessEqual",
13
+ "Equal",
14
+ "NotEqual",
15
+ "GreaterEqual",
16
+ "Greater",
17
+ )
18
+ _PROPERTY_NAME = re.compile(r"^_[A-Za-z][A-Za-z0-9_]{0,127}$")
19
+
20
+
21
+ def enable_if_attribute(property_name: str, operator: str, value: int | float) -> str:
22
+ """Encode one conditional-enable rule as a ShaderLab property attribute."""
23
+ condition = validate_enable_if(
24
+ {"property": property_name, "operator": operator, "value": value}
25
+ )
26
+ number = format(condition["value"], ".9g")
27
+ return (
28
+ f'[{ENABLE_IF_ATTRIBUTE}({condition["property"]},'
29
+ f'{condition["operator"]},{number})]'
30
+ )
31
+
32
+
33
+ def parse_enable_if_arguments(arguments: str) -> dict:
34
+ """Decode the comma-separated payload stored by EnableIfMzgui."""
35
+ parts = [part.strip() for part in arguments.split(",")]
36
+ if len(parts) != 3:
37
+ raise ValueError("EnableIfMzgui requires property, operator, and numeric value")
38
+ try:
39
+ value = float(parts[2])
40
+ except ValueError as exc:
41
+ raise ValueError("EnableIfMzgui value must be a finite number") from exc
42
+ return validate_enable_if(
43
+ {"property": parts[0], "operator": parts[1], "value": value}
44
+ )
45
+
46
+
47
+ def validate_enable_if(value: object) -> dict:
48
+ """Validate a JSON-compatible conditional-enable object."""
49
+ if not isinstance(value, dict):
50
+ raise ValueError("enabled_if must be an object")
51
+ unknown = set(value) - {"property", "operator", "value"}
52
+ if unknown:
53
+ raise ValueError(f"enabled_if has unknown keys: {sorted(unknown)}")
54
+ property_name = value.get("property")
55
+ if not isinstance(property_name, str) or not _PROPERTY_NAME.fullmatch(property_name):
56
+ raise ValueError("enabled_if.property must be an underscored Shader property name")
57
+ operator = value.get("operator", "Equal")
58
+ if operator not in ENABLE_IF_OPERATORS:
59
+ raise ValueError(
60
+ "enabled_if.operator must be one of " + ", ".join(ENABLE_IF_OPERATORS)
61
+ )
62
+ number = value.get("value")
63
+ if isinstance(number, bool) or not isinstance(number, (int, float)):
64
+ raise ValueError("enabled_if.value must be a finite number")
65
+ number = float(number)
66
+ if not math.isfinite(number):
67
+ raise ValueError("enabled_if.value must be a finite number")
68
+ return {"property": property_name, "operator": operator, "value": number}
@@ -0,0 +1,35 @@
1
+ """Shared MZGUI metadata protocol and legacy migration aliases."""
2
+
3
+ PROPERTY_METADATA_ATTRIBUTE_TYPES = (
4
+ "FoldoutMzgui",
5
+ "TooltipMzgui",
6
+ "HelpBoxMzgui",
7
+ "EnableIfMzgui",
8
+ )
9
+ LEGACY_PROPERTY_METADATA_ATTRIBUTE_TYPES = (
10
+ "ASECLIFoldout",
11
+ "ASECLITooltip",
12
+ "ASECLIHelpBox",
13
+ "ASECLIEnableIf",
14
+ )
15
+ _LEGACY_BY_CANONICAL = dict(
16
+ zip(PROPERTY_METADATA_ATTRIBUTE_TYPES, LEGACY_PROPERTY_METADATA_ATTRIBUTE_TYPES)
17
+ )
18
+ _CANONICAL_BY_ATTRIBUTE = {
19
+ **{name: name for name in PROPERTY_METADATA_ATTRIBUTE_TYPES},
20
+ **{legacy: canonical for canonical, legacy in _LEGACY_BY_CANONICAL.items()},
21
+ }
22
+ MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES = frozenset(_CANONICAL_BY_ATTRIBUTE)
23
+
24
+
25
+ def canonical_property_metadata_type(type_name: str) -> str | None:
26
+ """Return the public MZGUI type for a public or historical ASECLI type."""
27
+ return _CANONICAL_BY_ATTRIBUTE.get(type_name)
28
+
29
+
30
+ def equivalent_property_metadata_types(type_name: str) -> frozenset[str]:
31
+ """Return the MZGUI type and its one legacy ASECLI alias."""
32
+ canonical = canonical_property_metadata_type(type_name)
33
+ if canonical is None:
34
+ return frozenset()
35
+ return frozenset((canonical, _LEGACY_BY_CANONICAL[canonical]))
@@ -0,0 +1,191 @@
1
+ """Declarative, atomic material-property ordering and ASECLI metadata specs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .custom_gui import (
6
+ SUPPORTED_GUI_EDITORS,
7
+ graph_custom_editor,
8
+ is_material_property_node,
9
+ remove_property_metadata_attribute,
10
+ semantic_attribute,
11
+ set_custom_editor,
12
+ set_property_metadata_attribute,
13
+ )
14
+ from .custom_gui_versions import require_property_metadata_tail_version
15
+ from .material_gui_condition import enable_if_attribute, validate_enable_if
16
+ from .model import AseFile, AseGraph, NodeLine
17
+ from .property_presentation import set_property_display_name
18
+
19
+
20
+ _TOP_KEYS = {"editor", "reorder", "properties"}
21
+ _PROPERTY_KEYS = {
22
+ "name", "node", "display_name", "group", "help", "tooltip", "enabled_if"
23
+ }
24
+ _SEMANTIC_TYPES = {
25
+ "group": "FoldoutMzgui",
26
+ "help": "HelpBoxMzgui",
27
+ "tooltip": "TooltipMzgui",
28
+ }
29
+
30
+
31
+ def material_property_nodes(graph: AseGraph) -> list[NodeLine]:
32
+ return [node for node in graph.nodes if is_material_property_node(node)]
33
+
34
+
35
+ def resolve_property_node(
36
+ graph: AseGraph,
37
+ *,
38
+ node_id: str | None = None,
39
+ property_name: str | None = None,
40
+ ) -> NodeLine:
41
+ """Resolve an exported property by node id or exact ShaderLab property name."""
42
+ if (node_id is None) == (property_name is None):
43
+ raise ValueError("provide exactly one of node id or property name")
44
+ if node_id is not None:
45
+ node = graph.node_by_id(node_id)
46
+ if node is None:
47
+ raise KeyError(f"node {node_id} not found")
48
+ if not is_material_property_node(node):
49
+ raise ValueError(f"node {node_id} is not an exported PropertyNode")
50
+ return node
51
+ matches = [node for node in material_property_nodes(graph) if node.raw_fields[7] == property_name]
52
+ if len(matches) != 1:
53
+ raise ValueError(f"expected one exported property named {property_name!r}, found {len(matches)}")
54
+ return matches[0]
55
+
56
+
57
+ def apply_material_gui_spec(ase_file: AseFile, spec: dict) -> list[dict]:
58
+ """Apply a declarative property order/group/tooltip/optional-help spec in memory."""
59
+ if not isinstance(spec, dict):
60
+ raise ValueError("material GUI spec root must be a JSON object")
61
+ unknown = set(spec) - _TOP_KEYS
62
+ if unknown:
63
+ raise ValueError(f"unknown material GUI spec keys: {sorted(unknown)}")
64
+ entries = spec.get("properties", [])
65
+ if not isinstance(entries, list):
66
+ raise ValueError("material GUI spec 'properties' must be an array")
67
+ reorder = spec.get("reorder", False)
68
+ if not isinstance(reorder, bool):
69
+ raise ValueError("material GUI spec 'reorder' must be boolean")
70
+
71
+ changes: list[dict] = []
72
+ editor = spec.get("editor")
73
+ if editor is not None:
74
+ if not isinstance(editor, str):
75
+ raise ValueError("material GUI spec 'editor' must be a class-name string")
76
+ changes.append(set_custom_editor(ase_file, editor))
77
+
78
+ resolved: list[tuple[NodeLine, dict]] = []
79
+ seen: set[str] = set()
80
+ has_additions = False
81
+ for index, entry in enumerate(entries):
82
+ if not isinstance(entry, dict):
83
+ raise ValueError(f"properties[{index}] must be an object")
84
+ unknown = set(entry) - _PROPERTY_KEYS
85
+ if unknown:
86
+ raise ValueError(f"properties[{index}] has unknown keys: {sorted(unknown)}")
87
+ has_name = "name" in entry
88
+ has_node = "node" in entry
89
+ if has_name == has_node:
90
+ raise ValueError(f"properties[{index}] must provide exactly one of 'name' or 'node'")
91
+ if has_name and not isinstance(entry["name"], str):
92
+ raise ValueError(f"properties[{index}] field 'name' must be a string")
93
+ if has_node and not isinstance(entry["node"], (str, int)):
94
+ raise ValueError(f"properties[{index}] field 'node' must be a string or integer")
95
+ node = resolve_property_node(
96
+ ase_file.graph,
97
+ node_id=str(entry["node"]) if has_node else None,
98
+ property_name=entry.get("name"),
99
+ )
100
+ if node.node_id in seen:
101
+ raise ValueError(f"property node {node.node_id} appears more than once in spec")
102
+ seen.add(node.node_id)
103
+ resolved.append((node, entry))
104
+ has_additions |= any(entry.get(key) is not None for key in _SEMANTIC_TYPES if key in entry)
105
+ has_additions |= entry.get("enabled_if") is not None
106
+
107
+ active_editor = graph_custom_editor(ase_file.graph)
108
+ if has_additions and active_editor not in SUPPORTED_GUI_EDITORS:
109
+ supported = ", ".join(sorted(SUPPORTED_GUI_EDITORS))
110
+ raise ValueError(f"MZGUI-compatible property metadata requires a selected material GUI: {supported}")
111
+ if reorder and not resolved:
112
+ raise ValueError("reorder=true requires at least one property entry")
113
+ if reorder:
114
+ require_property_metadata_tail_version(ase_file.graph)
115
+ changes.extend(_reorder_properties(ase_file.graph, [node for node, _ in resolved]))
116
+
117
+ for node, entry in resolved:
118
+ if "display_name" in entry:
119
+ display_name = entry["display_name"]
120
+ if not isinstance(display_name, str):
121
+ raise ValueError(
122
+ f"property {node.raw_fields[7]} field 'display_name' must be a string"
123
+ )
124
+ changes.append(set_property_display_name(ase_file, node.node_id, display_name))
125
+ for key, type_name in _SEMANTIC_TYPES.items():
126
+ if key not in entry:
127
+ continue
128
+ value = entry[key]
129
+ if value is None:
130
+ changes.append(remove_property_metadata_attribute(ase_file.graph, node.node_id, type_name))
131
+ elif isinstance(value, str):
132
+ changes.append(
133
+ set_property_metadata_attribute(ase_file.graph, node.node_id, semantic_attribute(type_name, value))
134
+ )
135
+ else:
136
+ raise ValueError(f"property {node.raw_fields[7]} field {key!r} must be string or null")
137
+ if "enabled_if" in entry:
138
+ condition = entry["enabled_if"]
139
+ if condition is None:
140
+ changes.append(
141
+ remove_property_metadata_attribute(
142
+ ase_file.graph, node.node_id, "EnableIfMzgui"
143
+ )
144
+ )
145
+ else:
146
+ validated = validate_enable_if(condition)
147
+ changes.append(
148
+ set_property_metadata_attribute(
149
+ ase_file.graph,
150
+ node.node_id,
151
+ enable_if_attribute(
152
+ validated["property"],
153
+ validated["operator"],
154
+ validated["value"],
155
+ ),
156
+ )
157
+ )
158
+ return changes
159
+
160
+
161
+ def _reorder_properties(graph: AseGraph, requested: list[NodeLine]) -> list[dict]:
162
+ all_nodes = material_property_nodes(graph)
163
+ requested_ids = {node.node_id for node in requested}
164
+ instruction_order = {node.node_id: index for index, node in enumerate(all_nodes)}
165
+
166
+ def current_order(node: NodeLine) -> tuple[int, int]:
167
+ try:
168
+ value = int(node.raw_fields[9])
169
+ except (IndexError, ValueError):
170
+ value = 2**31 - 1
171
+ return value, instruction_order[node.node_id]
172
+
173
+ remaining = sorted((node for node in all_nodes if node.node_id not in requested_ids), key=current_order)
174
+ changes = []
175
+ for order, node in enumerate([*requested, *remaining]):
176
+ before = node.raw_fields[9]
177
+ after = str(order)
178
+ if before == after:
179
+ continue
180
+ node.raw_fields[9] = after
181
+ graph.replace_node(node)
182
+ changes.append(
183
+ {
184
+ "kind": "property_order",
185
+ "node_id": node.node_id,
186
+ "property_name": node.raw_fields[7],
187
+ "before": before,
188
+ "after": after,
189
+ }
190
+ )
191
+ return changes
@@ -0,0 +1,114 @@
1
+ """Public recursive fishbone layout plan and application API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Mapping, Protocol
7
+
8
+ from .commentary import inspect_comment_groups
9
+ from .fishbone_placement import place_fishbone
10
+ from .fishbone_topology import build_fishbone_topology
11
+ from .layout import apply_positions
12
+ from .layout_graph import id_key, node_rect
13
+ from .model import AseGraph
14
+ from .wire_router import WIRE_NODE_TYPE, plan_wire_routes
15
+
16
+
17
+ class Geometry(Protocol):
18
+ width: float
19
+ height: float
20
+ title_height: float
21
+ input_ports: dict[str, tuple[float, float]]
22
+ output_ports: dict[str, tuple[float, float]]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class MeticulousLayoutPlan:
27
+ positions: dict[str, tuple[float, float]]
28
+ primary_parent: dict[str, str]
29
+ children: dict[str, tuple[str, ...]]
30
+ ordered_children: dict[str, tuple[str, ...]]
31
+ spine_child: dict[str, str]
32
+ roots: tuple[str, ...]
33
+ depths: dict[str, int]
34
+ stage_output_x: dict[str, float]
35
+ subtree_bounds: dict[str, tuple[float, float, float, float]]
36
+ horizontal_edges: tuple[tuple[str, str, str, str], ...]
37
+ secondary_edges: tuple[tuple[str, str, str, str], ...]
38
+ comment_snapshot: tuple[tuple[str, tuple[str, ...], str], ...]
39
+ wire_route_changes: tuple[dict, ...] = ()
40
+
41
+
42
+ def meticulous_layout_positions(
43
+ graph: AseGraph,
44
+ geometry: Mapping[str, Geometry],
45
+ *,
46
+ route_wires: bool = False,
47
+ ) -> MeticulousLayoutPlan:
48
+ topology = build_fishbone_topology(graph, geometry)
49
+ positions, roots, stage_output_x = place_fishbone(graph, geometry, topology)
50
+ subtree_bounds = {}
51
+ for node_id in sorted(topology.ids, key=id_key):
52
+ members = _descendants(node_id, topology.children)
53
+ rects = [
54
+ node_rect(member, positions, geometry)
55
+ for member in members if member in positions and member in geometry
56
+ ]
57
+ subtree_bounds[node_id] = _union_bounds(rects)
58
+ horizontal_edges = tuple(sorted(
59
+ (
60
+ child, topology.primary_wire[(child, parent)].out_port,
61
+ parent, topology.primary_wire[(child, parent)].in_port,
62
+ )
63
+ for parent, child in topology.spine_child.items()
64
+ if topology.group_of.get(parent) == topology.group_of.get(child)
65
+ ))
66
+ primary_keys = set(topology.primary_parent.items())
67
+ secondary = tuple(sorted(
68
+ (wire.out_node, wire.out_port, wire.in_node, wire.in_port)
69
+ for wire in topology.collapsed_wires
70
+ if wire.out_node in topology.ids and wire.in_node in topology.ids
71
+ and (wire.out_node, wire.in_node) not in primary_keys
72
+ ))
73
+ comments = tuple(sorted(
74
+ (group["node_id"], tuple(group["members"]), group["color"])
75
+ for group in inspect_comment_groups(graph)
76
+ ))
77
+ route_changes = plan_wire_routes(graph, geometry, positions) if route_wires else ()
78
+ return MeticulousLayoutPlan(
79
+ positions=positions,
80
+ primary_parent=topology.primary_parent,
81
+ children=topology.children,
82
+ ordered_children=topology.children,
83
+ spine_child=topology.spine_child,
84
+ roots=tuple(roots),
85
+ depths=topology.depths,
86
+ stage_output_x=stage_output_x,
87
+ subtree_bounds=subtree_bounds,
88
+ horizontal_edges=horizontal_edges,
89
+ secondary_edges=secondary,
90
+ comment_snapshot=comments,
91
+ wire_route_changes=route_changes,
92
+ )
93
+
94
+
95
+ def apply_meticulous_layout(graph: AseGraph, plan: MeticulousLayoutPlan) -> int:
96
+ return apply_positions(graph, plan.positions)
97
+
98
+
99
+ def _union_bounds(rects):
100
+ values = list(rects)
101
+ return (
102
+ min(rect[0] for rect in values), min(rect[1] for rect in values),
103
+ max(rect[2] for rect in values), max(rect[3] for rect in values),
104
+ )
105
+
106
+
107
+ def _descendants(node_id: str, children: Mapping[str, tuple[str, ...]]) -> set[str]:
108
+ result, stack = {node_id}, list(children.get(node_id, ()))
109
+ while stack:
110
+ child = stack.pop()
111
+ if child not in result:
112
+ result.add(child)
113
+ stack.extend(children.get(child, ()))
114
+ return result