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,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}
|
asecli/core/__init__.py
ADDED
|
@@ -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)
|