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,132 @@
1
+ """Whitespace, outlier, and positioned-bound metrics for ASE Comments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Mapping, Protocol
6
+
7
+ from .commentary import inspect_comment_groups
8
+ from .model import AseGraph
9
+
10
+ SIDE_PADDING = 30.0
11
+ TOP_PADDING = 48.0
12
+ BOTTOM_PADDING = 30.0
13
+ WHITESPACE_TARGET = 0.55
14
+ WHITESPACE_ERROR = 0.65
15
+ HORIZONTAL_FOOTPRINT = 48.0
16
+ VERTICAL_FOOTPRINT = 16.0
17
+
18
+
19
+ class Geometry(Protocol):
20
+ width: float
21
+ height: float
22
+
23
+
24
+ def comment_layout_metrics(
25
+ graph: AseGraph,
26
+ geometry: Mapping[str, Geometry],
27
+ positions: Mapping[str, tuple[float, float]],
28
+ ) -> tuple[list[dict], list[dict]]:
29
+ groups = {group["node_id"]: group for group in inspect_comment_groups(graph)}
30
+
31
+ def bounds(member_id: str) -> tuple[float, float, float, float]:
32
+ if member_id in groups:
33
+ group = groups[member_id]
34
+ x, y = group["position"].values()
35
+ return x, y, x + group["width"], y + group["height"]
36
+ x, y = positions[member_id]
37
+ return x, y, x + geometry[member_id].width, y + geometry[member_id].height
38
+
39
+ whitespace, outliers = [], []
40
+ for group_id in sorted(groups, key=_id_key):
41
+ group = groups[group_id]
42
+ frame_area = group["width"] * group["height"]
43
+ member_bounds = [(member_id, bounds(member_id)) for member_id in group["members"]]
44
+ content_rect = (
45
+ group["position"]["x"] + SIDE_PADDING,
46
+ group["position"]["y"] + TOP_PADDING,
47
+ group["position"]["x"] + group["width"] - SIDE_PADDING,
48
+ group["position"]["y"] + group["height"] - BOTTOM_PADDING,
49
+ )
50
+ footprints = [
51
+ _clip((
52
+ rect[0] - HORIZONTAL_FOOTPRINT, rect[1] - VERTICAL_FOOTPRINT,
53
+ rect[2] + HORIZONTAL_FOOTPRINT, rect[3] + VERTICAL_FOOTPRINT,
54
+ ), content_rect)
55
+ for _, rect in member_bounds
56
+ ]
57
+ occupied = _rect_union_area([rect for rect in footprints if _area(rect) > 0])
58
+ ratio = max(0.0, 1.0 - min(1.0, occupied / frame_area))
59
+ whitespace.append({
60
+ "comment_id": group_id,
61
+ "ratio": round(ratio, 4),
62
+ "status": "error" if ratio > WHITESPACE_ERROR else (
63
+ "warning" if ratio > WHITESPACE_TARGET else "passed"
64
+ ),
65
+ })
66
+ if len(member_bounds) < 2:
67
+ continue
68
+ full = _union(rect for _, rect in member_bounds)
69
+ full_area = _area(full)
70
+ for member_id, _ in member_bounds:
71
+ remaining = [rect for item_id, rect in member_bounds if item_id != member_id]
72
+ reduction = 0.0 if full_area == 0 else 1.0 - _area(_union(remaining)) / full_area
73
+ if reduction > 0.35:
74
+ outliers.append({
75
+ "comment_id": group_id, "member_id": member_id,
76
+ "area_reduction_if_removed": round(reduction, 4),
77
+ })
78
+ return whitespace, outliers
79
+
80
+
81
+ def positioned_bounds(geometry, positions):
82
+ return {
83
+ node_id: (x, y, geometry[node_id].width, geometry[node_id].height)
84
+ for node_id, (x, y) in positions.items() if node_id in geometry
85
+ }
86
+
87
+
88
+ def _union(rects):
89
+ values = list(rects)
90
+ return (
91
+ min(rect[0] for rect in values), min(rect[1] for rect in values),
92
+ max(rect[2] for rect in values), max(rect[3] for rect in values),
93
+ )
94
+
95
+
96
+ def _area(rect):
97
+ return max(0.0, rect[2] - rect[0]) * max(0.0, rect[3] - rect[1])
98
+
99
+
100
+ def _clip(rect, bounds):
101
+ return (
102
+ max(rect[0], bounds[0]), max(rect[1], bounds[1]),
103
+ min(rect[2], bounds[2]), min(rect[3], bounds[3]),
104
+ )
105
+
106
+
107
+ def _rect_union_area(rects):
108
+ if not rects:
109
+ return 0.0
110
+ xs = sorted({value for rect in rects for value in (rect[0], rect[2])})
111
+ area = 0.0
112
+ for left, right in zip(xs, xs[1:]):
113
+ spans = sorted(
114
+ (rect[1], rect[3]) for rect in rects
115
+ if rect[0] < right and rect[2] > left and rect[3] > rect[1]
116
+ )
117
+ if right <= left or not spans:
118
+ continue
119
+ start, end = spans[0]
120
+ covered = 0.0
121
+ for top, bottom in spans[1:]:
122
+ if top > end:
123
+ covered += end - start
124
+ start, end = top, bottom
125
+ else:
126
+ end = max(end, bottom)
127
+ area += (right - left) * (covered + end - start)
128
+ return area
129
+
130
+
131
+ def _id_key(value: str):
132
+ return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)
@@ -0,0 +1,140 @@
1
+ """Conservative purpose-title inference for existing ASE Comment frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Mapping, Protocol
7
+
8
+ from .commentary import inspect_comment_groups, parse_commentary_node
9
+ from .local_vars import REGISTER_LOCAL_VAR_TYPE, parse_register_local_var
10
+ from .model import AseGraph
11
+
12
+ _PLACEHOLDER_TITLE = re.compile(
13
+ r"^(?:comment|group|组|处理\s*\d*|临时(?:组)?\s*\d*)$", re.IGNORECASE,
14
+ )
15
+
16
+
17
+ class Geometry(Protocol):
18
+ node_title: str
19
+ input_port_labels: dict[str, str] | None
20
+
21
+
22
+ def govern_comment_purposes(
23
+ graph: AseGraph,
24
+ geometry: Mapping[str, Geometry],
25
+ *,
26
+ apply: bool,
27
+ ) -> list[dict]:
28
+ """Preserve meaningful titles and infer only missing algorithm purposes."""
29
+ groups = {group["node_id"]: group for group in inspect_comment_groups(graph)}
30
+
31
+ def leaf_members(group_id: str, visiting: set[str] | None = None) -> set[str]:
32
+ visiting = set() if visiting is None else visiting
33
+ if group_id in visiting:
34
+ raise ValueError(f"comment membership contains a cycle at node {group_id}")
35
+ visiting.add(group_id)
36
+ result = set()
37
+ for member in groups[group_id]["members"]:
38
+ if member in groups:
39
+ result.update(leaf_members(member, visiting))
40
+ else:
41
+ result.add(member)
42
+ visiting.remove(group_id)
43
+ return result
44
+
45
+ results = []
46
+ for group_id in sorted(groups, key=_id_key):
47
+ group = groups[group_id]
48
+ title = group["title"].strip()
49
+ if title and not _PLACEHOLDER_TITLE.fullmatch(title):
50
+ results.append({"comment_id": group_id, "status": "existing", "title": title})
51
+ continue
52
+ suggestion, source = _infer_comment_title(graph, geometry, leaf_members(group_id))
53
+ if suggestion is None:
54
+ results.append({
55
+ "comment_id": group_id,
56
+ "status": "unresolved",
57
+ "title": group["title"],
58
+ "reason": "no_unique_register_consumer_or_algorithm_sink",
59
+ })
60
+ continue
61
+ results.append({
62
+ "comment_id": group_id,
63
+ "status": "inferred",
64
+ "title": group["title"],
65
+ "suggested_title": suggestion,
66
+ "source": source,
67
+ })
68
+ if apply:
69
+ node = graph.node_by_id(group_id)
70
+ if node is None:
71
+ raise ValueError(f"comment node {group_id} not found")
72
+ count = int(node.raw_fields[9])
73
+ node.raw_fields[10 + count] = suggestion
74
+ graph.replace_node(node)
75
+ groups[group_id] = parse_commentary_node(node)
76
+ return results
77
+
78
+
79
+ def _infer_comment_title(graph: AseGraph, geometry, members: set[str]):
80
+ registers = []
81
+ for member in sorted(members, key=_id_key):
82
+ node = graph.node_by_id(member)
83
+ if node is not None and node.type_name == REGISTER_LOCAL_VAR_TYPE:
84
+ try:
85
+ name = parse_register_local_var(node)["name"].strip()
86
+ except ValueError:
87
+ continue
88
+ if name:
89
+ registers.append(name)
90
+ if len(registers) == 1:
91
+ return _safe_title(f"生成 {registers[0]}"), "register_local_var"
92
+
93
+ outgoing = [
94
+ wire for wire in graph.wires
95
+ if wire.out_node in members and wire.in_node not in members
96
+ ]
97
+ consumers = {(wire.in_node, wire.in_port) for wire in outgoing}
98
+ if len(consumers) == 1:
99
+ consumer_id, in_port = next(iter(consumers))
100
+ consumer = _node_label(graph, geometry, consumer_id)
101
+ labels = getattr(geometry.get(consumer_id), "input_port_labels", {}) or {}
102
+ port_label = str(labels.get(in_port, "")).strip()
103
+ if _meaningful_label(consumer):
104
+ if _meaningful_label(port_label):
105
+ return _safe_title(f"为「{consumer}」计算「{port_label}」"), "external_consumer_port"
106
+ return _safe_title(f"为「{consumer}」计算输入"), "external_consumer"
107
+
108
+ internal_sources = {
109
+ wire.out_node for wire in graph.wires
110
+ if wire.out_node in members and wire.in_node in members
111
+ }
112
+ sinks = sorted(members - internal_sources, key=_id_key)
113
+ real_sinks = [item for item in sinks if _meaningful_label(_node_label(graph, geometry, item))]
114
+ if len(real_sinks) == 1:
115
+ return _safe_title(f"计算「{_node_label(graph, geometry, real_sinks[0])}」"), "local_algorithm_sink"
116
+ return None, None
117
+
118
+
119
+ def _node_label(graph, geometry, node_id: str) -> str:
120
+ title = str(getattr(geometry.get(node_id), "node_title", "") or "").strip()
121
+ if title:
122
+ return title
123
+ node = graph.node_by_id(node_id)
124
+ if node is None:
125
+ return ""
126
+ return re.sub(r"Node$", "", node.type_name.rsplit(".", 1)[-1]).strip()
127
+
128
+
129
+ def _meaningful_label(value: str) -> bool:
130
+ return bool(value.strip()) and value.strip().lower() not in {
131
+ "node", "input", "output", "function", "property", "comment", "group",
132
+ }
133
+
134
+
135
+ def _safe_title(value: str) -> str:
136
+ return value.replace(";", ",").replace("\r", " ").replace("\n", " ")[:4096]
137
+
138
+
139
+ def _id_key(value: str):
140
+ return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)
@@ -0,0 +1,225 @@
1
+ """Native ASE CommentaryNode inspection and semantic grouping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .graph_ops import next_free_node_id
6
+ from .model import AseGraph, NodeLine
7
+
8
+
9
+ COMMENTARY_TYPE = "AmplifyShaderEditor.CommentaryNode"
10
+ DEFAULT_NODE_WIDTH = 200.0
11
+ DEFAULT_NODE_HEIGHT = 120.0
12
+ NodeBounds = tuple[float, float, float, float]
13
+
14
+
15
+ def parse_commentary_node(node: NodeLine) -> dict:
16
+ """Decode the variable-length ASE 1.9.6.2 CommentaryNode payload."""
17
+ if node.type_name != COMMENTARY_TYPE:
18
+ raise ValueError(f"node {node.node_id} is not a CommentaryNode")
19
+ fields = node.raw_fields
20
+ if len(fields) < 14:
21
+ raise ValueError(f"CommentaryNode {node.node_id} has too few serialized fields")
22
+ try:
23
+ width = float(fields[6])
24
+ height = float(fields[7])
25
+ count = int(fields[9])
26
+ except ValueError as exc:
27
+ raise ValueError(f"CommentaryNode {node.node_id} has invalid size or member count") from exc
28
+ if count < 0 or len(fields) != 14 + count:
29
+ raise ValueError(f"CommentaryNode {node.node_id} member count does not match its payload")
30
+ members = fields[10 : 10 + count]
31
+ if len(set(members)) != len(members):
32
+ raise ValueError(f"CommentaryNode {node.node_id} contains duplicate member ids")
33
+ x, y = _position(node)
34
+ if width <= 0 or height <= 0:
35
+ raise ValueError(f"CommentaryNode {node.node_id} must have positive width and height")
36
+ return {
37
+ "node_id": node.node_id,
38
+ "position": {"x": x, "y": y},
39
+ "width": width,
40
+ "height": height,
41
+ "note": fields[8],
42
+ "members": members,
43
+ "title": fields[10 + count],
44
+ "color": fields[11 + count],
45
+ }
46
+
47
+
48
+ def inspect_comment_groups(graph: AseGraph) -> list[dict]:
49
+ return [parse_commentary_node(node) for node in graph.nodes if node.type_name == COMMENTARY_TYPE]
50
+
51
+
52
+ def create_comment_group(
53
+ graph: AseGraph,
54
+ member_ids: list[str],
55
+ title: str,
56
+ *,
57
+ note: str = "Comment",
58
+ padding: float = 50.0,
59
+ node_id: int | None = None,
60
+ node_bounds: dict[str, NodeBounds] | None = None,
61
+ ) -> dict:
62
+ """Create one native Comment frame around nodes without moving them or their wires."""
63
+ _require_supported_graph(graph)
64
+ _validate_text(title, "title", allow_empty=False)
65
+ _validate_text(note, "note", allow_empty=True)
66
+ if not member_ids:
67
+ raise ValueError("a comment group requires at least one member node")
68
+ normalized = [str(value) for value in member_ids]
69
+ if len(set(normalized)) != len(normalized):
70
+ raise ValueError("comment group member ids must be unique")
71
+ if not 0 <= padding <= 1000:
72
+ raise ValueError("padding must be between 0 and 1000")
73
+
74
+ members: list[NodeLine] = []
75
+ for member_id in normalized:
76
+ member = graph.node_by_id(member_id)
77
+ if member is None:
78
+ raise KeyError(f"node {member_id} not found")
79
+ members.append(member)
80
+
81
+ new_id = next_free_node_id(graph) if node_id is None else node_id
82
+ if new_id < 0 or graph.node_by_id(str(new_id)) is not None:
83
+ raise ValueError(f"comment node id {new_id} is invalid or already exists")
84
+ if str(new_id) in normalized:
85
+ raise ValueError("a comment group cannot contain itself")
86
+ _validate_membership(graph, normalized)
87
+
88
+ bounds = [_node_bounds(node, node_bounds) for node in members]
89
+ min_x = min(item[0] for item in bounds)
90
+ min_y = min(item[1] for item in bounds)
91
+ max_x = max(item[2] for item in bounds)
92
+ max_y = max(item[3] for item in bounds)
93
+ x = min_x - padding
94
+ y = min_y - padding
95
+ width = max(100.0, max_x - min_x + 2 * padding)
96
+ height = max(100.0, max_y - min_y + 2 * padding)
97
+ fields = [
98
+ "Node", COMMENTARY_TYPE, str(new_id), f"{_number(x)},{_number(y)}", "Inherit", "False",
99
+ _number(width), _number(height), note, str(len(normalized)), *normalized,
100
+ title, "1,1,1,1", "0", "0",
101
+ ]
102
+ node = NodeLine(type_name=COMMENTARY_TYPE, node_id=str(new_id), raw_fields=fields)
103
+ _validate_new_group_overlap(graph, node, normalized)
104
+ graph.add_node(node)
105
+ return parse_commentary_node(node)
106
+
107
+
108
+ def _validate_membership(graph: AseGraph, member_ids: list[str]) -> None:
109
+ groups = inspect_comment_groups(graph)
110
+ parent_of: dict[str, str] = {}
111
+ children: dict[str, list[str]] = {}
112
+ for group in groups:
113
+ children[group["node_id"]] = group["members"]
114
+ for member_id in group["members"]:
115
+ if member_id in parent_of:
116
+ raise ValueError(f"node {member_id} already belongs to multiple comment groups")
117
+ parent_of[member_id] = group["node_id"]
118
+ selected = set(member_ids)
119
+ for member_id in member_ids:
120
+ stack = list(children.get(member_id, []))
121
+ visited: set[str] = set()
122
+ while stack:
123
+ descendant = stack.pop()
124
+ if descendant in visited:
125
+ raise ValueError("existing comment groups contain a membership cycle")
126
+ visited.add(descendant)
127
+ if descendant in selected:
128
+ raise ValueError("select a nested comment frame or its children, not both")
129
+ stack.extend(children.get(descendant, []))
130
+ for member_id in member_ids:
131
+ if member_id in parent_of:
132
+ raise ValueError(f"node {member_id} already belongs to comment group {parent_of[member_id]}")
133
+
134
+
135
+ def _node_bounds(
136
+ node: NodeLine,
137
+ measured: dict[str, NodeBounds] | None = None,
138
+ ) -> NodeBounds:
139
+ if measured is not None and node.node_id in measured and node.type_name != COMMENTARY_TYPE:
140
+ x, y, width, height = measured[node.node_id]
141
+ if width < 0 or height < 0:
142
+ raise ValueError(f"node {node.node_id} has invalid measured bounds")
143
+ return x, y, x + width, y + height
144
+ x, y = _position(node)
145
+ if node.type_name == COMMENTARY_TYPE:
146
+ group = parse_commentary_node(node)
147
+ width, height = group["width"], group["height"]
148
+ else:
149
+ width, height = DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT
150
+ return x, y, x + width, y + height
151
+
152
+
153
+ def _rect_overlap(
154
+ left: NodeBounds,
155
+ right: NodeBounds,
156
+ *,
157
+ tolerance: float = 0.01,
158
+ ) -> dict[str, float] | None:
159
+ overlap_left = max(left[0], right[0])
160
+ overlap_top = max(left[1], right[1])
161
+ width = min(left[2], right[2]) - overlap_left
162
+ height = min(left[3], right[3]) - overlap_top
163
+ if width <= tolerance or height <= tolerance:
164
+ return None
165
+ return {
166
+ "x": round(overlap_left, 3),
167
+ "y": round(overlap_top, 3),
168
+ "width": round(width, 3),
169
+ "height": round(height, 3),
170
+ }
171
+
172
+
173
+ def _validate_new_group_overlap(graph: AseGraph, candidate: NodeLine, member_ids: list[str]) -> None:
174
+ """Reject partial/sibling overlap while allowing explicit full-containment nesting."""
175
+ groups = {group["node_id"]: group for group in inspect_comment_groups(graph)}
176
+ allowed_nested: set[str] = set()
177
+ stack = [member_id for member_id in member_ids if member_id in groups]
178
+ while stack:
179
+ group_id = stack.pop()
180
+ if group_id in allowed_nested:
181
+ continue
182
+ allowed_nested.add(group_id)
183
+ stack.extend(member_id for member_id in groups[group_id]["members"] if member_id in groups)
184
+
185
+ candidate_bounds = _node_bounds(candidate)
186
+ for group_id in sorted(groups, key=lambda value: (0, int(value)) if value.lstrip("-").isdigit() else (1, value)):
187
+ if group_id in allowed_nested:
188
+ continue
189
+ existing = graph.node_by_id(group_id)
190
+ assert existing is not None
191
+ overlap = _rect_overlap(candidate_bounds, _node_bounds(existing))
192
+ if overlap is not None:
193
+ raise ValueError(f"comment group would overlap unrelated comment group {group_id}")
194
+
195
+
196
+ def _position(node: NodeLine) -> tuple[float, float]:
197
+ try:
198
+ x_raw, y_raw = node.raw_fields[3].split(",")
199
+ return float(x_raw), float(y_raw)
200
+ except (IndexError, ValueError) as exc:
201
+ raise ValueError(f"node {node.node_id} has invalid x,y position") from exc
202
+
203
+
204
+ def _validate_text(value: str, label: str, *, allow_empty: bool) -> None:
205
+ if not isinstance(value, str):
206
+ raise ValueError(f"comment {label} must be a string")
207
+ if len(value) > 4096:
208
+ raise ValueError(f"comment {label} must not exceed 4096 characters")
209
+ if any(char in value for char in (";", "\r", "\n")):
210
+ raise ValueError(f"comment {label} contains a forbidden serialization character")
211
+ if not allow_empty and not value.strip():
212
+ raise ValueError(f"comment {label} must not be empty")
213
+
214
+
215
+ def _require_supported_graph(graph: AseGraph) -> None:
216
+ try:
217
+ version = int(graph.version)
218
+ except ValueError as exc:
219
+ raise ValueError(f"unsupported non-numeric ASE graph version: {graph.version!r}") from exc
220
+ if version <= 12002:
221
+ raise ValueError(f"graph version {graph.version} predates the supported CommentaryNode format")
222
+
223
+
224
+ def _number(value: float) -> str:
225
+ return str(int(value)) if value.is_integer() else format(value, ".6g")
@@ -0,0 +1,135 @@
1
+ """Post-ASE synchronization for managed ShaderLab property metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .custom_gui import (
8
+ MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES,
9
+ PROPERTY_METADATA_ATTRIBUTE_TYPES,
10
+ is_material_property_node,
11
+ parse_property_metadata_attribute,
12
+ read_property_metadata_tail,
13
+ )
14
+ from .compiled_properties import inspect_compiled_properties
15
+ from .model import AseFile
16
+
17
+
18
+ _MANAGED_ATTRIBUTE_TYPES = frozenset(
19
+ MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES
20
+ )
21
+ _KNOWN_TEMPLATE_COMPILED_ONLY_PROPERTIES = {
22
+ "2992e84f91cbeb14eab234972e07ea9d": frozenset({
23
+ "_TessPhongStrength",
24
+ "_TessValue",
25
+ "_TessMin",
26
+ "_TessMax",
27
+ "_TessEdgeLength",
28
+ "_TessMaxDisp",
29
+ }),
30
+ }
31
+
32
+
33
+ def hide_known_template_compiled_only_properties(
34
+ ase_file: AseFile, template_guid: str
35
+ ) -> list[dict]:
36
+ """Hide exact, versioned template controls that have no graph PropertyNode.
37
+
38
+ Unknown compiled-only properties remain visible so the presentation
39
+ contract can reject them instead of silently hiding user-owned data.
40
+ """
41
+ compiled, error = inspect_compiled_properties(ase_file.prefix)
42
+ if error is not None:
43
+ raise ValueError(f"compiled ShaderLab properties are unavailable: {error}")
44
+ known = _KNOWN_TEMPLATE_COMPILED_ONLY_PROPERTIES.get(template_guid, frozenset())
45
+ graph_names = {
46
+ node.raw_fields[7]
47
+ for node in ase_file.graph.nodes
48
+ if is_material_property_node(node)
49
+ }
50
+ changes: list[dict] = []
51
+ prefix = ase_file.prefix
52
+ for prop in compiled:
53
+ property_name = prop["property_name"]
54
+ if prop["hidden"] or property_name in graph_names or property_name not in known:
55
+ continue
56
+ pattern = re.compile(
57
+ rf"(?m)^(?P<indent>[ \t]*)(?P<attributes>(?:\[[^\]\r\n]*\][ \t]*)*)"
58
+ rf"(?P<name>{re.escape(property_name)})(?P<suffix>[ \t]*\()"
59
+ )
60
+ matches = list(pattern.finditer(prefix))
61
+ if len(matches) != 1:
62
+ raise ValueError(
63
+ f"expected one compiled ShaderLab declaration for {property_name!r}, found {len(matches)}"
64
+ )
65
+ match = matches[0]
66
+ replacement = (
67
+ match.group("indent")
68
+ + "[HideInInspector] "
69
+ + match.group("attributes")
70
+ + match.group("name")
71
+ + match.group("suffix")
72
+ )
73
+ prefix = prefix[: match.start()] + replacement + prefix[match.end() :]
74
+ changes.append({
75
+ "kind": "compiled_template_property_hidden",
76
+ "property_name": property_name,
77
+ "template_guid": template_guid,
78
+ })
79
+ ase_file.prefix = prefix
80
+ return changes
81
+
82
+
83
+ def sync_compiled_property_metadata(ase_file: AseFile) -> list[dict]:
84
+ """Mirror graph metadata into compiled properties after an ASE save.
85
+
86
+ Some editor builds discard custom PropertyNode attributes while saving.
87
+ Unity's ShaderGUI reads decorators from the compiled ``Properties`` block,
88
+ so restore the managed attributes there while preserving unrelated ones.
89
+ """
90
+ changes: list[dict] = []
91
+ prefix = ase_file.prefix
92
+ for node in ase_file.graph.nodes:
93
+ if not is_material_property_node(node):
94
+ continue
95
+ property_name = node.raw_fields[7]
96
+ tail = read_property_metadata_tail(ase_file.graph, node)
97
+ managed = [
98
+ raw for raw in tail.attributes
99
+ if parse_property_metadata_attribute(raw)["type"] in _MANAGED_ATTRIBUTE_TYPES
100
+ ]
101
+ pattern = re.compile(
102
+ rf"(?m)^(?P<indent>[ \t]*)(?P<attributes>(?:\[[^\]\r\n]*\][ \t]*)*)"
103
+ rf"(?P<name>{re.escape(property_name)})(?P<suffix>[ \t]*\()"
104
+ )
105
+ matches = list(pattern.finditer(prefix))
106
+ if len(matches) != 1:
107
+ raise ValueError(
108
+ f"expected one compiled ShaderLab declaration for {property_name!r}, found {len(matches)}"
109
+ )
110
+ match = matches[0]
111
+ existing = re.findall(r"\[[^\]\r\n]*\]", match.group("attributes"))
112
+ preserved = [raw for raw in existing if not _is_managed(raw)]
113
+ attributes = [*preserved, *managed]
114
+ replacement = (
115
+ match.group("indent")
116
+ + ((" ".join(attributes) + " ") if attributes else "")
117
+ + match.group("name")
118
+ + match.group("suffix")
119
+ )
120
+ if match.group(0) == replacement:
121
+ continue
122
+ prefix = prefix[: match.start()] + replacement + prefix[match.end() :]
123
+ changes.append({
124
+ "kind": "compiled_property_metadata", "node_id": node.node_id,
125
+ "property_name": property_name, "before": existing, "after": attributes,
126
+ })
127
+ ase_file.prefix = prefix
128
+ return changes
129
+
130
+
131
+ def _is_managed(raw: str) -> bool:
132
+ try:
133
+ return parse_property_metadata_attribute(raw)["type"] in _MANAGED_ATTRIBUTE_TYPES
134
+ except ValueError:
135
+ return False