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.
- asecli/__init__.py +3 -0
- asecli/bridge/__init__.py +40 -0
- asecli/bridge/_editor_port_contract.py +131 -0
- asecli/bridge/_editor_primitives.py +205 -0
- asecli/bridge/_editor_spec_grammar.py +245 -0
- asecli/bridge/_editor_spec_io.py +26 -0
- asecli/bridge/_editor_spec_validation.py +218 -0
- asecli/bridge/_gui_handoff_store.py +78 -0
- asecli/bridge/_gui_project.py +35 -0
- asecli/bridge/_gui_resource_store.py +159 -0
- asecli/bridge/_gui_resource_upgrade.py +122 -0
- asecli/bridge/editor_create.py +173 -0
- asecli/bridge/editor_spec.py +250 -0
- asecli/bridge/graph_geometry.py +249 -0
- asecli/bridge/graph_geometry_parser.py +57 -0
- asecli/bridge/graph_inspect.py +136 -0
- asecli/bridge/gui_handoff.py +100 -0
- asecli/bridge/gui_presentation.py +70 -0
- asecli/bridge/gui_provider_detection.py +197 -0
- asecli/bridge/gui_runtime_probe.py +25 -0
- asecli/bridge/gui_support.py +240 -0
- asecli/bridge/gui_support_resource.py +58 -0
- asecli/bridge/mcp_client.py +231 -0
- asecli/bridge/recompile.py +107 -0
- asecli/bridge/resource_text.py +12 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
- asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
- asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
- asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
- asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
- asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
- asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
- asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
- asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
- asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
- asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
- asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
- asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
- asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
- asecli/bridge/wire_route.py +127 -0
- asecli/checks/__init__.py +10 -0
- asecli/checks/checksum.py +53 -0
- asecli/checks/local_vars.py +135 -0
- asecli/checks/usage.py +141 -0
- asecli/checks/validate.py +129 -0
- asecli/cli/__init__.py +1 -0
- asecli/cli/commands.py +237 -0
- asecli/cli/commentary_command.py +158 -0
- asecli/cli/create_command.py +250 -0
- asecli/cli/custom_gui_command.py +170 -0
- asecli/cli/gui_provider.py +30 -0
- asecli/cli/gui_support_command.py +35 -0
- asecli/cli/io.py +214 -0
- asecli/cli/layout_command.py +213 -0
- asecli/cli/main.py +245 -0
- asecli/cli/recompile_metadata.py +53 -0
- asecli/cli/skill_command.py +121 -0
- asecli/cli/usage_command.py +42 -0
- asecli/core/__init__.py +84 -0
- asecli/core/comment_bounds.py +166 -0
- asecli/core/comment_layout.py +167 -0
- asecli/core/comment_metrics.py +132 -0
- asecli/core/comment_purpose.py +140 -0
- asecli/core/commentary.py +225 -0
- asecli/core/compiled_metadata.py +135 -0
- asecli/core/compiled_properties.py +190 -0
- asecli/core/custom_gui.py +250 -0
- asecli/core/custom_gui_versions.py +35 -0
- asecli/core/fishbone_placement.py +159 -0
- asecli/core/fishbone_topology.py +186 -0
- asecli/core/graph_ops.py +122 -0
- asecli/core/layout.py +174 -0
- asecli/core/layout_audit.py +237 -0
- asecli/core/layout_audit_geometry.py +247 -0
- asecli/core/layout_audit_repeated.py +44 -0
- asecli/core/layout_graph.py +113 -0
- asecli/core/local_vars.py +54 -0
- asecli/core/material_gui_condition.py +68 -0
- asecli/core/material_gui_protocol.py +35 -0
- asecli/core/material_gui_spec.py +191 -0
- asecli/core/meticulous_layout.py +114 -0
- asecli/core/model.py +237 -0
- asecli/core/property_presentation.py +233 -0
- asecli/core/wire_geometry.py +83 -0
- asecli/core/wire_router.py +248 -0
- asecli/schema/__init__.py +49 -0
- asecli/schema/data/observed.json +16 -0
- asecli/schema/data/schemas.json +14106 -0
- asecli/skills/asecli/SKILL.md +275 -0
- asecli/skills/asecli/references/layout-standard.md +99 -0
- asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
- asecli/skills/asecli/references/material-property-standard.md +108 -0
- asecli-0.6.2.dist-info/METADATA +594 -0
- asecli-0.6.2.dist-info/RECORD +98 -0
- asecli-0.6.2.dist-info/WHEEL +4 -0
- asecli-0.6.2.dist-info/entry_points.txt +2 -0
- 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
|