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,53 @@
1
+ """Preserve ASECLI property metadata across real ASE recompilation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ..core import (
8
+ AseFile,
9
+ graph_custom_editor,
10
+ inspect_custom_gui,
11
+ MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES,
12
+ resolve_property_node,
13
+ set_custom_editor,
14
+ set_property_metadata_attribute,
15
+ sync_compiled_property_metadata,
16
+ )
17
+
18
+
19
+ def snapshot_recompile_metadata(path: str) -> dict | None:
20
+ if not os.path.isfile(path):
21
+ return None
22
+ try:
23
+ ase_file = AseFile.from_path(path)
24
+ state = inspect_custom_gui(ase_file)
25
+ except (OSError, ValueError):
26
+ return None
27
+ properties = []
28
+ for prop in state["properties"]:
29
+ attributes = [
30
+ item["raw"] for item in prop["attributes"]
31
+ if item["type"] in MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES
32
+ ]
33
+ if attributes:
34
+ properties.append({"name": prop["property_name"], "attributes": attributes})
35
+ editor = graph_custom_editor(ase_file.graph)
36
+ if not properties and editor is None:
37
+ return None
38
+ return {"editor": editor, "properties": properties}
39
+
40
+
41
+ def restore_recompile_metadata(path: str, snapshot: dict) -> tuple[AseFile, int]:
42
+ ase_file = AseFile.from_path(path)
43
+ editor = snapshot.get("editor")
44
+ if editor:
45
+ set_custom_editor(ase_file, editor)
46
+ restored = 0
47
+ for prop in snapshot["properties"]:
48
+ node = resolve_property_node(ase_file.graph, property_name=prop["name"])
49
+ for raw in prop["attributes"]:
50
+ set_property_metadata_attribute(ase_file.graph, node.node_id, raw)
51
+ restored += 1
52
+ sync_compiled_property_metadata(ase_file)
53
+ return ase_file, restored
@@ -0,0 +1,121 @@
1
+ """Install the packaged ASECLI Agent Skill into a Codex skill directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ from .commands import CliError
12
+
13
+ SKILL_NAME = "asecli"
14
+
15
+
16
+ def _source_root() -> Path:
17
+ """Return the skill shipped in the wheel, with a source-tree fallback."""
18
+ packaged = Path(__file__).resolve().parents[1] / "skills" / SKILL_NAME
19
+ if (packaged / "SKILL.md").is_file():
20
+ return packaged
21
+ source_tree = Path(__file__).resolve().parents[3] / "skills" / SKILL_NAME
22
+ if (source_tree / "SKILL.md").is_file():
23
+ return source_tree
24
+ raise RuntimeError("packaged ASECLI Skill is missing")
25
+
26
+
27
+ def _skill_root(raw_root: str | None) -> Path:
28
+ if raw_root:
29
+ return Path(raw_root).expanduser()
30
+ codex_home = os.environ.get("CODEX_HOME")
31
+ return Path(codex_home).expanduser() / "skills" if codex_home else Path.home() / ".codex" / "skills"
32
+
33
+
34
+ def _regular_files(root: Path) -> list[Path]:
35
+ if root.is_symlink() or not root.is_dir():
36
+ raise ValueError(f"skill directory is not a regular directory: {root}")
37
+ files = sorted(path for path in root.rglob("*") if path.is_file())
38
+ if not files or not (root / "SKILL.md").is_file():
39
+ raise ValueError(f"skill directory is incomplete: {root}")
40
+ if any(path.is_symlink() for path in files):
41
+ raise ValueError(f"skill directory contains a symbolic link: {root}")
42
+ return files
43
+
44
+
45
+ def _tree_digest(root: Path) -> str:
46
+ digest = hashlib.sha256()
47
+ for path in _regular_files(root):
48
+ relative = path.relative_to(root).as_posix().encode("utf-8")
49
+ digest.update(len(relative).to_bytes(4, "big"))
50
+ digest.update(relative)
51
+ content = path.read_bytes()
52
+ digest.update(len(content).to_bytes(8, "big"))
53
+ digest.update(content)
54
+ return digest.hexdigest()
55
+
56
+
57
+ def _ensure_regular_parent(root: Path) -> None:
58
+ root.mkdir(parents=True, exist_ok=True)
59
+ current = root
60
+ while current != current.parent:
61
+ if current.is_symlink():
62
+ raise ValueError(f"skill root contains a symbolic link: {current}")
63
+ current = current.parent
64
+
65
+
66
+ def install_skill(skill_root: str | None = None) -> dict:
67
+ """Install the versioned skill once; refuse to replace unrelated content."""
68
+ source = _source_root()
69
+ source_digest = _tree_digest(source)
70
+ root = _skill_root(skill_root)
71
+ _ensure_regular_parent(root)
72
+ target = root / SKILL_NAME
73
+ if target.exists() or target.is_symlink():
74
+ try:
75
+ installed_digest = _tree_digest(target)
76
+ except ValueError as exc:
77
+ raise FileExistsError(f"existing Skill is unsafe or incomplete: {target}") from exc
78
+ if installed_digest == source_digest:
79
+ return {
80
+ "installed": False,
81
+ "status": "already_installed",
82
+ "skill": SKILL_NAME,
83
+ "path": str(target),
84
+ "digest": source_digest,
85
+ }
86
+ raise FileExistsError(
87
+ f"existing Skill differs from ASECLI {SKILL_NAME!r}: {target}; "
88
+ "refusing to overwrite it"
89
+ )
90
+
91
+ staging = root / f".{SKILL_NAME}.staging-{uuid.uuid4().hex}"
92
+ try:
93
+ staging.mkdir()
94
+ for source_path in _regular_files(source):
95
+ relative = source_path.relative_to(source)
96
+ destination = staging / relative
97
+ destination.parent.mkdir(parents=True, exist_ok=True)
98
+ shutil.copyfile(source_path, destination)
99
+ if _tree_digest(staging) != source_digest:
100
+ raise RuntimeError("copied Skill digest does not match packaged source")
101
+ os.replace(staging, target)
102
+ except Exception:
103
+ if staging.exists() and not staging.is_symlink():
104
+ shutil.rmtree(staging)
105
+ raise
106
+ return {
107
+ "installed": True,
108
+ "status": "installed",
109
+ "skill": SKILL_NAME,
110
+ "path": str(target),
111
+ "digest": source_digest,
112
+ }
113
+
114
+
115
+ def cmd_install_skill(args) -> dict:
116
+ try:
117
+ return install_skill(args.skill_root)
118
+ except FileExistsError as exc:
119
+ raise CliError("SKILL_INSTALL_CONFLICT", str(exc)) from exc
120
+ except (OSError, RuntimeError, ValueError) as exc:
121
+ raise CliError("SKILL_INSTALL_ERROR", str(exc)) from exc
@@ -0,0 +1,42 @@
1
+ """CLI composition for liveness audit and externally safe node removal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..checks import external_references_for_node, usage_audit
6
+ from ..core import remove_node
7
+ from .commands import CliError, _load, _save
8
+
9
+
10
+ def cmd_remove_node(args) -> dict:
11
+ f = _load(args.file)
12
+ target = f.graph.node_by_id(args.node)
13
+ if target is None:
14
+ raise CliError("NOT_FOUND", f"node {args.node} not found")
15
+ if not args.force_external:
16
+ try:
17
+ references = external_references_for_node(args.file, target)
18
+ except ValueError as exc:
19
+ raise CliError("USAGE_ERROR", str(exc))
20
+ if references:
21
+ raise CliError(
22
+ "EXTERNAL_REFERENCE",
23
+ f"node {args.node} property {target.raw_fields[7]} is referenced outside the ASE graph",
24
+ {"node_id": args.node, "property_name": target.raw_fields[7], "references": references},
25
+ )
26
+ try:
27
+ wires = remove_node(f.graph, args.node)
28
+ except KeyError as exc:
29
+ raise CliError("NOT_FOUND", str(exc))
30
+ except ValueError as exc:
31
+ raise CliError("VALIDATION_ERROR", str(exc))
32
+ saved = _save(f, args.file, args.write)
33
+ return {"removed_node": args.node, "removed_wires": wires, **saved}
34
+
35
+
36
+ def cmd_graph_audit(args) -> dict:
37
+ f = _load(args.file)
38
+ try:
39
+ audit = usage_audit(args.file, f.graph)
40
+ except ValueError as exc:
41
+ raise CliError("USAGE_ERROR", str(exc))
42
+ return {"file": args.file, **audit}
@@ -0,0 +1,84 @@
1
+ from .graph_ops import connect, disconnect, next_free_node_id, node_from_schema, remove_node, set_node_field
2
+ from .custom_gui import (
3
+ ASECLI_GUI_EDITOR,
4
+ MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES,
5
+ MZGUI_EDITOR,
6
+ CUSTOM_EDITOR_SUGGESTIONS,
7
+ PROPERTY_METADATA_ATTRIBUTE_TYPES,
8
+ SUPPORTED_GUI_EDITORS,
9
+ compiled_custom_editor,
10
+ decode_custom_unicode,
11
+ decode_foldout_title,
12
+ encode_custom_unicode,
13
+ encode_foldout_title,
14
+ graph_custom_editor,
15
+ inspect_custom_gui,
16
+ is_material_property_node,
17
+ main_master_node,
18
+ parse_property_metadata_attribute,
19
+ read_property_metadata_tail,
20
+ remove_property_metadata_attribute,
21
+ semantic_attribute,
22
+ set_custom_editor,
23
+ set_property_metadata_attribute,
24
+ )
25
+ from .property_presentation import (
26
+ contains_han,
27
+ require_managed_property_presentation,
28
+ require_property_presentation,
29
+ set_property_display_name,
30
+ )
31
+ from .compiled_metadata import sync_compiled_property_metadata
32
+ from .commentary import (
33
+ COMMENTARY_TYPE,
34
+ create_comment_group,
35
+ inspect_comment_groups,
36
+ parse_commentary_node,
37
+ )
38
+ from .comment_bounds import comment_containment_issues, refit_comment_groups
39
+ from .layout import layout_positions, tidy
40
+ from .meticulous_layout import MeticulousLayoutPlan, apply_meticulous_layout, meticulous_layout_positions
41
+ from .layout_audit import REPORT_SCHEMA, audit_meticulous_layout
42
+ from .comment_layout import refit_comments_for_layout
43
+ from .comment_purpose import govern_comment_purposes
44
+ from .local_vars import (
45
+ GET_LOCAL_VAR_TYPE,
46
+ REGISTER_LOCAL_VAR_TYPE,
47
+ local_var_edges,
48
+ parse_get_local_var,
49
+ parse_register_local_var,
50
+ )
51
+ from .wire_router import WIRE_NODE_TYPE, logical_wire_manifest
52
+ from .material_gui_spec import apply_material_gui_spec, material_property_nodes, resolve_property_node
53
+ from .material_gui_condition import (
54
+ ENABLE_IF_ATTRIBUTE,
55
+ ENABLE_IF_OPERATORS,
56
+ enable_if_attribute,
57
+ parse_enable_if_arguments,
58
+ validate_enable_if,
59
+ )
60
+ from .model import AseFile, AseGraph, NodeLine, WireLine, _parse_node_line, parse_graph_text, parse_node_line
61
+
62
+ __all__ = [
63
+ "AseFile", "AseGraph", "NodeLine", "WireLine", "parse_graph_text", "parse_node_line",
64
+ "connect", "disconnect", "next_free_node_id", "node_from_schema", "remove_node", "set_node_field",
65
+ "layout_positions", "tidy", "MeticulousLayoutPlan", "apply_meticulous_layout",
66
+ "meticulous_layout_positions", "REPORT_SCHEMA", "audit_meticulous_layout",
67
+ "govern_comment_purposes", "refit_comments_for_layout",
68
+ "ASECLI_GUI_EDITOR", "MZGUI_EDITOR", "CUSTOM_EDITOR_SUGGESTIONS", "PROPERTY_METADATA_ATTRIBUTE_TYPES", "MANAGED_PROPERTY_METADATA_ATTRIBUTE_TYPES",
69
+ "SUPPORTED_GUI_EDITORS",
70
+ "compiled_custom_editor", "decode_custom_unicode", "decode_foldout_title",
71
+ "encode_custom_unicode", "encode_foldout_title", "graph_custom_editor",
72
+ "inspect_custom_gui", "is_material_property_node", "main_master_node", "parse_property_metadata_attribute", "read_property_metadata_tail",
73
+ "contains_han", "require_managed_property_presentation", "require_property_presentation", "set_property_display_name",
74
+ "remove_property_metadata_attribute", "semantic_attribute", "set_custom_editor", "set_property_metadata_attribute",
75
+ "sync_compiled_property_metadata",
76
+ "apply_material_gui_spec", "material_property_nodes", "resolve_property_node",
77
+ "ENABLE_IF_ATTRIBUTE", "ENABLE_IF_OPERATORS", "enable_if_attribute",
78
+ "parse_enable_if_arguments", "validate_enable_if",
79
+ "COMMENTARY_TYPE", "comment_containment_issues", "create_comment_group",
80
+ "inspect_comment_groups", "parse_commentary_node", "refit_comment_groups",
81
+ "GET_LOCAL_VAR_TYPE", "REGISTER_LOCAL_VAR_TYPE", "local_var_edges",
82
+ "parse_get_local_var", "parse_register_local_var",
83
+ "WIRE_NODE_TYPE", "logical_wire_manifest",
84
+ ]
@@ -0,0 +1,166 @@
1
+ """Editor-accurate containment checks and resizing for ASE Comment frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .commentary import (
6
+ NodeBounds,
7
+ _node_bounds,
8
+ _number,
9
+ _rect_overlap,
10
+ inspect_comment_groups,
11
+ parse_commentary_node,
12
+ )
13
+ from .model import AseGraph
14
+
15
+
16
+ def comment_containment_issues(
17
+ graph: AseGraph,
18
+ node_bounds: dict[str, NodeBounds],
19
+ *,
20
+ tolerance: float = 0.01,
21
+ ) -> list[dict]:
22
+ """Report members outside frames and unrelated Comment frames that overlap."""
23
+ issues: list[dict] = []
24
+ groups = inspect_comment_groups(graph)
25
+ for group in groups:
26
+ frame = (
27
+ group["position"]["x"], group["position"]["y"],
28
+ group["position"]["x"] + group["width"],
29
+ group["position"]["y"] + group["height"],
30
+ )
31
+ for member_id in group["members"]:
32
+ member = graph.node_by_id(member_id)
33
+ if member is None:
34
+ continue
35
+ bounds = _node_bounds(member, node_bounds)
36
+ overflow = {
37
+ "left": max(0.0, frame[0] - bounds[0]),
38
+ "top": max(0.0, frame[1] - bounds[1]),
39
+ "right": max(0.0, bounds[2] - frame[2]),
40
+ "bottom": max(0.0, bounds[3] - frame[3]),
41
+ }
42
+ overflow = {key: round(value, 3) for key, value in overflow.items() if value > tolerance}
43
+ if overflow:
44
+ issues.append({
45
+ "code": "COMMENT_MEMBER_OUTSIDE_FRAME",
46
+ "severity": "warning",
47
+ "comment_id": group["node_id"],
48
+ "comment_title": group["title"],
49
+ "member_id": member_id,
50
+ "overflow": overflow,
51
+ "message": f"comment {group['node_id']} does not fully contain member {member_id}",
52
+ })
53
+ group_ids = {group["node_id"] for group in groups}
54
+ children = {
55
+ group["node_id"]: [member_id for member_id in group["members"] if member_id in group_ids]
56
+ for group in groups
57
+ }
58
+
59
+ def is_ancestor(ancestor_id: str, descendant_id: str) -> bool:
60
+ stack = list(children[ancestor_id])
61
+ visited: set[str] = set()
62
+ while stack:
63
+ current = stack.pop()
64
+ if current == descendant_id:
65
+ return True
66
+ if current in visited:
67
+ raise ValueError(f"comment membership contains a cycle at node {current}")
68
+ visited.add(current)
69
+ stack.extend(children[current])
70
+ return False
71
+
72
+ frames = {
73
+ group["node_id"]: (
74
+ group["position"]["x"],
75
+ group["position"]["y"],
76
+ group["position"]["x"] + group["width"],
77
+ group["position"]["y"] + group["height"],
78
+ )
79
+ for group in groups
80
+ }
81
+ for index, left in enumerate(groups):
82
+ for right in groups[index + 1:]:
83
+ left_id, right_id = left["node_id"], right["node_id"]
84
+ if is_ancestor(left_id, right_id) or is_ancestor(right_id, left_id):
85
+ continue
86
+ overlap = _rect_overlap(frames[left_id], frames[right_id], tolerance=tolerance)
87
+ if overlap is not None:
88
+ issues.append({
89
+ "code": "COMMENT_GROUP_OVERLAP",
90
+ "severity": "warning",
91
+ "comment_ids": [left_id, right_id],
92
+ "comment_titles": [left["title"], right["title"]],
93
+ "overlap": overlap,
94
+ "message": f"unrelated comment groups {left_id} and {right_id} overlap",
95
+ })
96
+ return issues
97
+
98
+
99
+ def refit_comment_groups(
100
+ graph: AseGraph,
101
+ node_bounds: dict[str, NodeBounds],
102
+ *,
103
+ padding: float = 50.0,
104
+ group_ids: list[str] | None = None,
105
+ ) -> list[dict]:
106
+ """Resize frames around live bounds without moving members."""
107
+ if not 0 <= padding <= 1000:
108
+ raise ValueError("padding must be between 0 and 1000")
109
+ groups = {group["node_id"]: group for group in inspect_comment_groups(graph)}
110
+ selected = set(groups) if group_ids is None else {str(value) for value in group_ids}
111
+ missing = sorted(selected - set(groups), key=_node_id_sort_key)
112
+ if missing:
113
+ raise KeyError(f"comment node(s) not found: {', '.join(missing)}")
114
+ changes: list[dict] = []
115
+ fitted: set[str] = set()
116
+ visiting: set[str] = set()
117
+
118
+ def fit(group_id: str) -> None:
119
+ if group_id in fitted:
120
+ return
121
+ if group_id in visiting:
122
+ raise ValueError(f"comment membership contains a cycle at node {group_id}")
123
+ visiting.add(group_id)
124
+ group = groups[group_id]
125
+ bounds: list[NodeBounds] = []
126
+ for member_id in group["members"]:
127
+ member = graph.node_by_id(member_id)
128
+ if member is None:
129
+ raise KeyError(f"node {member_id} not found")
130
+ if member_id in groups and member_id in selected:
131
+ fit(member_id)
132
+ bounds.append(_node_bounds(member, node_bounds))
133
+ if not bounds:
134
+ raise ValueError(f"comment {group_id} has no members to fit")
135
+ min_x, min_y = min(item[0] for item in bounds), min(item[1] for item in bounds)
136
+ max_x, max_y = max(item[2] for item in bounds), max(item[3] for item in bounds)
137
+ after = {
138
+ "position": {"x": min_x - padding, "y": min_y - padding},
139
+ "width": max(100.0, max_x - min_x + 2 * padding),
140
+ "height": max(100.0, max_y - min_y + 2 * padding),
141
+ }
142
+ before = {
143
+ "position": dict(group["position"]),
144
+ "width": group["width"], "height": group["height"],
145
+ }
146
+ node = graph.node_by_id(group_id)
147
+ assert node is not None
148
+ node.raw_fields[3] = f"{_number(after['position']['x'])},{_number(after['position']['y'])}"
149
+ node.raw_fields[6], node.raw_fields[7] = _number(after["width"]), _number(after["height"])
150
+ graph.replace_node(node)
151
+ groups[group_id] = parse_commentary_node(node)
152
+ if before != after:
153
+ changes.append({
154
+ "comment_id": group_id, "title": group["title"],
155
+ "before": before, "after": after,
156
+ })
157
+ visiting.remove(group_id)
158
+ fitted.add(group_id)
159
+
160
+ for group_id in sorted(selected, key=_node_id_sort_key):
161
+ fit(group_id)
162
+ return changes
163
+
164
+
165
+ def _node_id_sort_key(value: str) -> tuple[int, int | str]:
166
+ return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)
@@ -0,0 +1,167 @@
1
+ """Recursive Comment fitting and quality metrics for meticulous layout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Mapping, Protocol
6
+
7
+ from .commentary import _number, inspect_comment_groups, parse_commentary_node
8
+ from .model import AseGraph
9
+
10
+ SIDE_PADDING = 30.0
11
+ TOP_PADDING = 48.0
12
+ BOTTOM_PADDING = 30.0
13
+ MODULE_GAP = 96.0
14
+
15
+
16
+ class Geometry(Protocol):
17
+ width: float
18
+ height: float
19
+
20
+
21
+ def refit_comments_for_layout(
22
+ graph: AseGraph,
23
+ geometry: Mapping[str, Geometry],
24
+ positions: Mapping[str, tuple[float, float]],
25
+ ) -> list[dict]:
26
+ """Fit Comments, then move unrelated groups apart as intact composites."""
27
+ if not isinstance(positions, dict):
28
+ raise ValueError("layout positions must be mutable while fitting Comment groups")
29
+ original = {
30
+ group["node_id"]: {
31
+ "position": dict(group["position"]), "width": group["width"], "height": group["height"],
32
+ }
33
+ for group in inspect_comment_groups(graph)
34
+ }
35
+
36
+ def fit_all() -> dict[str, dict]:
37
+ groups = {group["node_id"]: group for group in inspect_comment_groups(graph)}
38
+ fitted: set[str] = set()
39
+ visiting: set[str] = set()
40
+
41
+ def bounds(member_id: str):
42
+ if member_id in groups:
43
+ fit(member_id)
44
+ group = groups[member_id]
45
+ x, y = group["position"].values()
46
+ return x, y, x + group["width"], y + group["height"]
47
+ if member_id not in geometry or member_id not in positions:
48
+ raise ValueError(f"layout geometry missing Comment member {member_id}")
49
+ x, y = positions[member_id]
50
+ return x, y, x + geometry[member_id].width, y + geometry[member_id].height
51
+
52
+ def fit(group_id: str) -> None:
53
+ if group_id in fitted:
54
+ return
55
+ if group_id in visiting:
56
+ raise ValueError(f"comment membership contains a cycle at node {group_id}")
57
+ visiting.add(group_id)
58
+ group = groups[group_id]
59
+ if not group["members"]:
60
+ raise ValueError(f"comment {group_id} has no members to fit")
61
+ member_bounds = [bounds(member_id) for member_id in group["members"]]
62
+ left = min(rect[0] for rect in member_bounds) - SIDE_PADDING
63
+ top = min(rect[1] for rect in member_bounds) - TOP_PADDING
64
+ right = max(rect[2] for rect in member_bounds) + SIDE_PADDING
65
+ bottom = max(rect[3] for rect in member_bounds) + BOTTOM_PADDING
66
+ node = graph.node_by_id(group_id)
67
+ if node is None:
68
+ raise ValueError(f"comment node {group_id} not found")
69
+ node.raw_fields[3] = f"{_number(left)},{_number(top)}"
70
+ node.raw_fields[6] = _number(max(100.0, right - left))
71
+ node.raw_fields[7] = _number(max(100.0, bottom - top))
72
+ graph.replace_node(node)
73
+ groups[group_id] = parse_commentary_node(node)
74
+ visiting.remove(group_id)
75
+ fitted.add(group_id)
76
+
77
+ for group_id in sorted(groups, key=_id_key):
78
+ fit(group_id)
79
+ return groups
80
+
81
+ groups = fit_all()
82
+ max_moves = max(1, len(groups) * len(groups))
83
+ for _ in range(max_moves):
84
+ conflict = _first_group_channel_conflict(groups)
85
+ if conflict is None:
86
+ break
87
+ upper_id, lower_id, dy = conflict
88
+ del upper_id
89
+ _move_comment_members(graph, groups, lower_id, dy, positions)
90
+ groups = fit_all()
91
+ else:
92
+ raise ValueError("unable to separate unrelated Comment groups")
93
+
94
+ final = {group["node_id"]: group for group in inspect_comment_groups(graph)}
95
+ changes = []
96
+ for group_id in sorted(final, key=_id_key):
97
+ group = final[group_id]
98
+ after = {
99
+ "position": dict(group["position"]), "width": group["width"], "height": group["height"],
100
+ }
101
+ if original[group_id] != after:
102
+ changes.append({"comment_id": group_id, "before": original[group_id], "after": after})
103
+ return changes
104
+
105
+
106
+ def _first_group_channel_conflict(groups: dict[str, dict]):
107
+ parent_of = {
108
+ member: group["node_id"]
109
+ for group in groups.values() for member in group["members"] if member in groups
110
+ }
111
+
112
+ def ancestors(group_id: str) -> set[str]:
113
+ result = set()
114
+ current = parent_of.get(group_id)
115
+ while current is not None:
116
+ if current in result:
117
+ raise ValueError(f"comment membership contains a cycle at node {current}")
118
+ result.add(current)
119
+ current = parent_of.get(current)
120
+ return result
121
+
122
+ ordered = sorted(
123
+ groups.values(),
124
+ key=lambda group: (group["position"]["y"], group["position"]["x"], _id_key(group["node_id"])),
125
+ )
126
+ for index, upper in enumerate(ordered):
127
+ upper_id = upper["node_id"]
128
+ upper_ancestors = ancestors(upper_id)
129
+ upper_rect = _group_rect(upper)
130
+ for lower in ordered[index + 1:]:
131
+ lower_id = lower["node_id"]
132
+ if lower_id in upper_ancestors or upper_id in ancestors(lower_id):
133
+ continue
134
+ lower_rect = _group_rect(lower)
135
+ x_overlap = not (
136
+ upper_rect[2] + MODULE_GAP <= lower_rect[0]
137
+ or lower_rect[2] + MODULE_GAP <= upper_rect[0]
138
+ )
139
+ y_clear = upper_rect[3] + MODULE_GAP <= lower_rect[1]
140
+ if x_overlap and not y_clear:
141
+ return upper_id, lower_id, upper_rect[3] + MODULE_GAP - lower_rect[1]
142
+ return None
143
+
144
+
145
+ def _move_comment_members(graph, groups, group_id: str, dy: float, positions: dict) -> None:
146
+ for member_id in groups[group_id]["members"]:
147
+ if member_id in groups:
148
+ _move_comment_members(graph, groups, member_id, dy, positions)
149
+ continue
150
+ if member_id not in positions:
151
+ raise ValueError(f"layout positions missing Comment member {member_id}")
152
+ x, y = positions[member_id]
153
+ positions[member_id] = (x, round(y + dy, 1))
154
+ node = graph.node_by_id(member_id)
155
+ if node is None:
156
+ raise ValueError(f"comment member {member_id} not found")
157
+ node.raw_fields[3] = f"{_number(x)},{_number(round(y + dy, 1))}"
158
+ graph.replace_node(node)
159
+
160
+
161
+ def _group_rect(group):
162
+ x, y = group["position"].values()
163
+ return x, y, x + group["width"], y + group["height"]
164
+
165
+
166
+ def _id_key(value: str) -> tuple[int, int | str]:
167
+ return (0, int(value)) if value.lstrip("-").isdigit() else (1, value)