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
asecli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AseCLI — agent-native toolkit for Amplify Shader Editor assets."""
2
+
3
+ __version__ = "0.6.2"
@@ -0,0 +1,40 @@
1
+ """Compile bridge (TASK-0011): MCP for Unity primary transport."""
2
+
3
+ from .mcp_client import McpClient, McpError
4
+ from .editor_create import create_shader_via_mcp
5
+ from .editor_spec import EditorGraphSpec, SpecError, load_editor_graph_spec, route_create_backend
6
+ from .graph_inspect import measure_node_bounds_via_mcp
7
+ from .graph_geometry import EditorNodeGeometry, inspect_graph_geometry_via_mcp
8
+ from .gui_support import (
9
+ ASECLI_GUI_EDITOR,
10
+ GUI_SUPPORT_ASSET_PATH,
11
+ MZGUI_EDITOR,
12
+ inspect_gui_support,
13
+ install_gui_support,
14
+ )
15
+ from .gui_handoff import handoff_gui_support
16
+ from .gui_runtime_probe import probe_native_mzgui_via_mcp
17
+ from .recompile import recompile_via_mcp
18
+ from .wire_route import apply_wire_routes_via_mcp
19
+
20
+ __all__ = [
21
+ "EditorGraphSpec",
22
+ "ASECLI_GUI_EDITOR",
23
+ "GUI_SUPPORT_ASSET_PATH",
24
+ "McpClient",
25
+ "McpError",
26
+ "MZGUI_EDITOR",
27
+ "SpecError",
28
+ "create_shader_via_mcp",
29
+ "load_editor_graph_spec",
30
+ "inspect_gui_support",
31
+ "handoff_gui_support",
32
+ "install_gui_support",
33
+ "probe_native_mzgui_via_mcp",
34
+ "measure_node_bounds_via_mcp",
35
+ "EditorNodeGeometry",
36
+ "inspect_graph_geometry_via_mcp",
37
+ "recompile_via_mcp",
38
+ "route_create_backend",
39
+ "apply_wire_routes_via_mcp",
40
+ ]
@@ -0,0 +1,131 @@
1
+ """ASE 1.9.6.2 port contracts exposed by EditorGraphSpec v1..v3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._editor_primitives import DYNAMIC_NUMERIC, PRIMITIVE_OPS
6
+ from .editor_spec import ConnectionSpec, NodeSpec, SpecError, TemplateSpec
7
+
8
+
9
+ _NUMERIC_TYPES = frozenset({"INT", "FLOAT", "FLOAT2", "FLOAT3", "FLOAT4", "COLOR"})
10
+ _MATRIX_TYPES = frozenset({"FLOAT3x3", "FLOAT4x4"})
11
+ _DYNAMIC_NUMERIC = DYNAMIC_NUMERIC
12
+ _URP_UNLIT_GUID = "2992e84f91cbeb14eab234972e07ea9d"
13
+ _URP_UNLIT_MASTER_INPUTS = {
14
+ 0: "FLOAT3", # Baked Albedo
15
+ 1: "FLOAT3", # Baked Emission
16
+ 2: "FLOAT3", # Color
17
+ 3: "FLOAT", # Alpha
18
+ 4: "FLOAT", # Alpha Clip Threshold
19
+ 5: "FLOAT3", # Vertex Offset / Position
20
+ 6: "FLOAT3", # Vertex Normal
21
+ 7: "FLOAT", # Alpha Clip Threshold Shadow
22
+ }
23
+ _STATIC_INPUT_PORTS = {
24
+ "WorldPosInputsNode": {},
25
+ "TextureCoordinatesNode": {0: "FLOAT2", 1: "FLOAT2", 2: "SAMPLER2D"},
26
+ "BreakToComponentsNode": {0: _DYNAMIC_NUMERIC},
27
+ "SimpleAddOpNode": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC},
28
+ "SimpleSubtractOpNode": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC},
29
+ "SimpleMultiplyOpNode": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC},
30
+ "SimpleDivideOpNode": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC},
31
+ "SimpleMinOpNode": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC},
32
+ "SimpleMaxOpNode": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC},
33
+ "SaturateNode": {0: _DYNAMIC_NUMERIC},
34
+ "OneMinusNode": {0: _DYNAMIC_NUMERIC},
35
+ "LerpOp": {0: _DYNAMIC_NUMERIC, 1: _DYNAMIC_NUMERIC, 2: _DYNAMIC_NUMERIC},
36
+ "TexturePropertyNode": {},
37
+ "RangedFloatNode": {},
38
+ "Matrix4X4Node": {},
39
+ "Vector4Node": {},
40
+ "SamplerNode": {
41
+ 0: "SAMPLER2D", 1: "FLOAT2", 2: "FLOAT", 3: "FLOAT2",
42
+ 4: "FLOAT2", 5: "FLOAT", 6: "FLOAT", 7: "SAMPLERSTATE",
43
+ },
44
+ }
45
+ _STATIC_OUTPUT_PORTS = {
46
+ "WorldPosInputsNode": {0: "FLOAT3", 1: "FLOAT", 2: "FLOAT", 3: "FLOAT"},
47
+ "TextureCoordinatesNode": {0: "FLOAT2", 1: "FLOAT", 2: "FLOAT", 3: "FLOAT", 4: "FLOAT"},
48
+ "BreakToComponentsNode": {port: "FLOAT" for port in range(16)},
49
+ "SimpleAddOpNode": {0: _DYNAMIC_NUMERIC},
50
+ "SimpleSubtractOpNode": {0: _DYNAMIC_NUMERIC},
51
+ "SimpleMultiplyOpNode": {0: _DYNAMIC_NUMERIC},
52
+ "SimpleDivideOpNode": {0: _DYNAMIC_NUMERIC},
53
+ "SimpleMinOpNode": {0: _DYNAMIC_NUMERIC},
54
+ "SimpleMaxOpNode": {0: _DYNAMIC_NUMERIC},
55
+ "SaturateNode": {0: _DYNAMIC_NUMERIC},
56
+ "OneMinusNode": {0: _DYNAMIC_NUMERIC},
57
+ "LerpOp": {0: _DYNAMIC_NUMERIC},
58
+ "TexturePropertyNode": {0: "SAMPLER2D", 1: "SAMPLERSTATE"},
59
+ "RangedFloatNode": {0: "FLOAT"},
60
+ "Matrix4X4Node": {0: "FLOAT4x4"},
61
+ "Vector4Node": {0: "FLOAT4", 1: "FLOAT", 2: "FLOAT", 3: "FLOAT", 4: "FLOAT"},
62
+ "SamplerNode": {
63
+ 0: "COLOR", 1: "FLOAT", 2: "FLOAT", 3: "FLOAT", 4: "FLOAT", 5: "FLOAT3",
64
+ },
65
+ }
66
+
67
+
68
+ def validate_connections(
69
+ template: TemplateSpec,
70
+ nodes: tuple[NodeSpec, ...],
71
+ connections: tuple[ConnectionSpec, ...],
72
+ ) -> None:
73
+ by_alias = {node.alias: node for node in nodes}
74
+ for index, connection in enumerate(connections):
75
+ source = by_alias[connection.source.node]
76
+ source_type = _output_ports(source).get(connection.source.port)
77
+ if source_type is None:
78
+ raise SpecError(
79
+ f"connections[{index}] source port {connection.source.port} does not exist on {source.alias}"
80
+ )
81
+
82
+ if connection.destination.node == "master":
83
+ if template.guid != _URP_UNLIT_GUID:
84
+ raise SpecError(
85
+ f"connections[{index}] template guid has no verified master-port contract"
86
+ )
87
+ destination_ports = _URP_UNLIT_MASTER_INPUTS
88
+ else:
89
+ destination_ports = _input_ports(by_alias[connection.destination.node])
90
+ destination_type = destination_ports.get(connection.destination.port)
91
+ if destination_type is None:
92
+ raise SpecError(
93
+ f"connections[{index}] destination port {connection.destination.port} does not exist on "
94
+ f"{connection.destination.node}"
95
+ )
96
+ if not _compatible_port_types(source_type, destination_type):
97
+ raise SpecError(
98
+ f"connections[{index}] incompatible port types: {source_type} to {destination_type}"
99
+ )
100
+
101
+
102
+ def _input_ports(node: NodeSpec) -> dict[int, str]:
103
+ if node.kind == "custom_expression":
104
+ return {index: item.type for index, item in enumerate(node.inputs)}
105
+ if node.kind == "primitive":
106
+ return PRIMITIVE_OPS[node.op][1]
107
+ if node.kind == "recipe":
108
+ return {index: item.type for index, item in enumerate(node.inputs)}
109
+ return _STATIC_INPUT_PORTS[node.ase_type]
110
+
111
+
112
+ def _output_ports(node: NodeSpec) -> dict[int, str]:
113
+ if node.kind == "custom_expression":
114
+ assert node.output_type is not None
115
+ return {0: node.output_type}
116
+ if node.kind == "primitive":
117
+ return PRIMITIVE_OPS[node.op][2]
118
+ if node.kind == "recipe":
119
+ assert node.output_type is not None
120
+ return {0: node.output_type}
121
+ return _STATIC_OUTPUT_PORTS[node.ase_type]
122
+
123
+
124
+ def _compatible_port_types(source: str, destination: str) -> bool:
125
+ if source == _DYNAMIC_NUMERIC:
126
+ return destination == _DYNAMIC_NUMERIC or destination in _NUMERIC_TYPES or destination in _MATRIX_TYPES
127
+ if destination == _DYNAMIC_NUMERIC:
128
+ return source in _NUMERIC_TYPES or source in _MATRIX_TYPES
129
+ if source in _NUMERIC_TYPES and destination in _NUMERIC_TYPES:
130
+ return True
131
+ return source == destination
@@ -0,0 +1,205 @@
1
+ """Algorithm-primitive closure (op -> ASE class + port contract) for EditorGraphSpec v3.
2
+
3
+ This module is the single source of truth shared by the model (manifest
4
+ synthesis), the port-contract validator, the spec validator, and (mirrored in)
5
+ the fixed C# executor. It must import nothing from the rest of the package so
6
+ both ``editor_spec`` and ``_editor_port_contract`` may depend on it without a
7
+ cycle.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ import re
14
+ from typing import Any
15
+
16
+ DYNAMIC_NUMERIC = "DYNAMIC_NUMERIC"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PrimitiveSpec:
21
+ id: str
22
+ op: str
23
+ args: tuple[str, ...] = ()
24
+ value: str | None = None
25
+
26
+ def to_dict(self) -> dict:
27
+ result: dict[str, Any] = {"id": self.id, "op": self.op}
28
+ if self.op == "const":
29
+ result["value"] = self.value
30
+ else:
31
+ result["args"] = list(self.args)
32
+ return result
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ExpansionSpec:
37
+ primitives: tuple[PrimitiveSpec, ...]
38
+ output: str
39
+
40
+ def to_dict(self) -> dict:
41
+ return {
42
+ "primitives": [item.to_dict() for item in self.primitives],
43
+ "output": self.output,
44
+ }
45
+
46
+
47
+ _DYN = DYNAMIC_NUMERIC
48
+ _FLOAT = "FLOAT"
49
+ _FLOAT2 = "FLOAT2"
50
+ _FLOAT3 = "FLOAT3"
51
+ _FLOAT4 = "FLOAT4"
52
+ _COLOR = "COLOR"
53
+ _INT = "INT"
54
+ _SAMPLER2D = "SAMPLER2D"
55
+
56
+ # op -> (ASE class name, input ports, output ports).
57
+ # Dynamic ports use DYNAMIC_NUMERIC; fixed dimensions are spelled out.
58
+ # Arity here is the authoritative ASE 1.9.6.2 runtime shape, verified against
59
+ # the local schema dump and AmplifyShaderEditor source.
60
+ PRIMITIVE_OPS: dict[str, tuple[str, dict[int, str], dict[int, str]]] = {
61
+ # 3.1 Math / trigonometry (almost all dynamic).
62
+ "add": ("SimpleAddOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
63
+ "sub": ("SimpleSubtractOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
64
+ "mul": ("SimpleMultiplyOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
65
+ "div": ("SimpleDivideOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
66
+ "min": ("SimpleMinOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
67
+ "max": ("SimpleMaxOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
68
+ "remainder": ("SimpleRemainderNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
69
+ "saturate": ("SaturateNode", {0: _DYN}, {0: _DYN}),
70
+ "one_minus": ("OneMinusNode", {0: _DYN}, {0: _DYN}),
71
+ "negate": ("NegateNode", {0: _DYN}, {0: _DYN}),
72
+ "abs": ("AbsOpNode", {0: _DYN}, {0: _DYN}),
73
+ "floor": ("FloorOpNode", {0: _DYN}, {0: _DYN}),
74
+ "ceil": ("CeilOpNode", {0: _DYN}, {0: _DYN}),
75
+ "fract": ("FractNode", {0: _DYN}, {0: _DYN}),
76
+ "round": ("RoundOpNode", {0: _DYN}, {0: _DYN}),
77
+ "trunc": ("TruncOpNode", {0: _DYN}, {0: _DYN}),
78
+ "sign": ("SignOpNode", {0: _DYN}, {0: _DYN}),
79
+ "sqrt": ("SqrtOpNode", {0: _DYN}, {0: _DYN}),
80
+ "rsqrt": ("RSqrtOpNode", {0: _DYN}, {0: _DYN}),
81
+ "rcp": ("ReciprocalOpNode", {0: _DYN}, {0: _DYN}),
82
+ "normalize": ("NormalizeNode", {0: _DYN}, {0: _DYN}),
83
+ "length": ("LengthOpNode", {0: _DYN}, {0: _FLOAT}),
84
+ "clamp": ("ClampOpNode", {0: _DYN, 1: _DYN, 2: _DYN}, {0: _DYN}),
85
+ "step": ("StepOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
86
+ "smoothstep": ("SmoothstepOpNode", {0: _DYN, 1: _DYN, 2: _DYN}, {0: _DYN}),
87
+ "lerp": ("LerpOp", {0: _DYN, 1: _DYN, 2: _DYN}, {0: _DYN}),
88
+ "pow": ("PowerNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
89
+ "exp": ("ExpOpNode", {0: _DYN}, {0: _DYN}),
90
+ "exp2": ("Exp2OpNode", {0: _DYN}, {0: _DYN}),
91
+ "log": ("LogOpNode", {0: _DYN}, {0: _DYN}),
92
+ "log2": ("Log2OpNode", {0: _DYN}, {0: _DYN}),
93
+ "log10": ("Log10OpNode", {0: _DYN}, {0: _DYN}),
94
+ "sin": ("SinOpNode", {0: _DYN}, {0: _DYN}),
95
+ "cos": ("CosOpNode", {0: _DYN}, {0: _DYN}),
96
+ "tan": ("TanOpNode", {0: _DYN}, {0: _DYN}),
97
+ "asin": ("ASinOpNode", {0: _DYN}, {0: _DYN}),
98
+ "acos": ("ACosOpNode", {0: _DYN}, {0: _DYN}),
99
+ "atan": ("ATanOpNode", {0: _DYN}, {0: _DYN}),
100
+ "atan2": ("ATan2OpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
101
+ "sinh": ("SinhOpNode", {0: _DYN}, {0: _DYN}),
102
+ "cosh": ("CoshOpNode", {0: _DYN}, {0: _DYN}),
103
+ "tanh": ("TanhOpNode", {0: _DYN}, {0: _DYN}),
104
+ "radians": ("RadiansOpNode", {0: _DYN}, {0: _DYN}),
105
+ "degrees": ("DegreesOpNode", {0: _DYN}, {0: _DYN}),
106
+ "fmod": ("FmodOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
107
+ "remap": ("TFHCRemapNode", {0: _DYN, 1: _DYN, 2: _DYN, 3: _DYN, 4: _DYN}, {0: _DYN}),
108
+ "dot": ("DotProductOpNode", {0: _DYN, 1: _DYN}, {0: _FLOAT}),
109
+ "cross": ("CrossProductOpNode", {0: _FLOAT3, 1: _FLOAT3}, {0: _FLOAT3}),
110
+ "distance": ("DistanceOpNode", {0: _DYN, 1: _DYN}, {0: _FLOAT}),
111
+ "reflect": ("ReflectOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
112
+ "refract": ("RefractOpVec", {0: _DYN, 1: _DYN, 2: _DYN}, {0: _DYN}),
113
+ # ASE RotateAboutAxisNode takes 4 inputs (axis, angle, pivot, position).
114
+ "rotate_axis": ("RotateAboutAxisNode",
115
+ {0: _FLOAT3, 1: _FLOAT, 2: _FLOAT3, 3: _FLOAT3}, {0: _FLOAT3}),
116
+
117
+ # 3.2 Vector / channel / constant.
118
+ "append": ("DynamicAppendNode", {0: _FLOAT, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT}, {0: _FLOAT4}),
119
+ "swizzle": ("SwizzleNode", {0: _DYN}, {0: _DYN}),
120
+ "component_mask": ("ComponentMaskNode", {0: _DYN}, {0: _DYN}),
121
+ "split": ("BreakToComponentsNode", {0: _DYN}, {port: _FLOAT for port in range(16)}),
122
+ # "const" is shape-dispatched at runtime; not a fixed ASE class.
123
+
124
+ # 3.3 Image / color / logic.
125
+ "luminance": ("LuminanceNode", {0: _DYN}, {0: _DYN}),
126
+ "desaturate": ("DesaturateOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
127
+ "contrast": ("SimpleContrastOpNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
128
+ "posterize": ("PosterizeNode", {0: _COLOR, 1: _INT}, {0: _COLOR}),
129
+ "rgb_to_hsv": ("RGBToHSVNode", {0: _FLOAT3}, {0: _FLOAT3, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT}),
130
+ "hsv_to_rgb": ("HSVToRGBNode", {0: _FLOAT, 1: _FLOAT, 2: _FLOAT},
131
+ {0: _FLOAT3, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT}),
132
+ "gamma_to_linear": ("GammaToLinearNode", {0: _DYN}, {0: _DYN}),
133
+ "linear_to_gamma": ("LinearToGammaNode", {0: _DYN}, {0: _DYN}),
134
+ "not": ("NotNode", {0: _DYN}, {0: _DYN}),
135
+ "nand": ("NandNode", {0: _DYN, 1: _DYN}, {0: _DYN}),
136
+ # ASE AllOpNode / AnyOpNode are unary "all/any components non-zero".
137
+ "and": ("AllOpNode", {0: _DYN}, {0: _DYN}),
138
+ "or": ("AnyOpNode", {0: _DYN}, {0: _DYN}),
139
+ # ASE StaticSwitch is a 9-input keyword toggle; ConditionalIfNode is a 5-input compare.
140
+ "select": ("StaticSwitch", {port: _FLOAT for port in range(9)}, {0: _DYN}),
141
+
142
+ # 3.4 Input / context primitives.
143
+ "texcoord": ("TextureCoordinatesNode",
144
+ {0: _FLOAT2, 1: _FLOAT2, 2: _SAMPLER2D},
145
+ {0: _FLOAT2, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT, 4: _FLOAT}),
146
+ "world_position": ("WorldPosInputsNode", {}, {0: _FLOAT3, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT}),
147
+ "time": ("SimpleTimeNode", {0: _FLOAT}, {0: _FLOAT}),
148
+ "vertex_color": ("VertexColorNode", {}, {0: _COLOR, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT, 4: _FLOAT}),
149
+ "vertex_id": ("VertexIdVariableNode", {}, {0: _INT}),
150
+ "instance_id": ("InstanceIdNode", {}, {0: _INT}),
151
+ "world_normal": ("WorldNormalVector", {0: _FLOAT3}, {0: _FLOAT3, 1: _FLOAT, 2: _FLOAT, 3: _FLOAT}),
152
+ }
153
+
154
+
155
+ _VEC_CONST = re.compile(r"^\s*float([234])\s*\(\s*(.+?)\s*\)\s*$")
156
+ _NUMBER = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$")
157
+
158
+
159
+ def const_class(value: str) -> str:
160
+ """ASE class for a const literal, dispatched by HLSL shape."""
161
+ if _NUMBER.fullmatch(value.strip()):
162
+ return "RangedFloatNode"
163
+ match = _VEC_CONST.match(value)
164
+ if match:
165
+ return {"2": "Vector2Node", "3": "Vector3Node", "4": "Vector4Node"}[match.group(1)]
166
+ raise ValueError(f"unsupported const literal shape: {value!r}")
167
+
168
+
169
+ def const_output_type(value: str) -> str:
170
+ if _NUMBER.fullmatch(value.strip()):
171
+ return "FLOAT"
172
+ match = _VEC_CONST.match(value)
173
+ if match:
174
+ return "FLOAT" + match.group(1)
175
+ raise ValueError(f"unsupported const literal shape: {value!r}")
176
+
177
+
178
+ def primitive_class(op: str) -> str | None:
179
+ """ASE class for an op, or None when the op is not in the closure."""
180
+ entry = PRIMITIVE_OPS.get(op)
181
+ return entry[0] if entry else None
182
+
183
+
184
+ def is_known_op(op: str) -> bool:
185
+ return op in PRIMITIVE_OPS
186
+
187
+
188
+ def expansion_classes(primitives: object) -> list[str] | None:
189
+ """Resolve each recipe expansion primitive to its ASE class.
190
+
191
+ Returns ``None`` when any primitive cannot be resolved (which signals the
192
+ executor to fall back to ``CustomExpressionNode``), otherwise a list of ASE
193
+ class names aligned with the primitive order.
194
+ """
195
+ classes: list[str] = []
196
+ for primitive in primitives: # type: ignore[union-attr]
197
+ op = primitive.op
198
+ if op == "const":
199
+ classes.append(const_class(primitive.value))
200
+ continue
201
+ cls = primitive_class(op)
202
+ if cls is None:
203
+ return None
204
+ classes.append(cls)
205
+ return classes
@@ -0,0 +1,245 @@
1
+ """Shared grammar constants, helpers and v3 parsing for EditorGraphSpec validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Any
8
+
9
+ from ._editor_primitives import ExpansionSpec, PrimitiveSpec, const_output_type, is_known_op
10
+ from .editor_spec import InputSpec, NodeSpec, SpecError
11
+
12
+
13
+ GENERIC_NODE_TYPES = frozenset({"WorldPosInputsNode", "TextureCoordinatesNode", "BreakToComponentsNode", "SimpleAddOpNode", "SimpleSubtractOpNode", "SimpleMultiplyOpNode", "SimpleDivideOpNode", "SimpleMinOpNode", "SimpleMaxOpNode", "SaturateNode", "OneMinusNode", "LerpOp"})
14
+ PROPERTY_NODE_TYPES = frozenset({"TexturePropertyNode", "RangedFloatNode", "Matrix4X4Node", "Vector4Node"})
15
+ PROPERTY_TYPES = frozenset({"Constant", "Property", "InstancedProperty", "Global"})
16
+ SUPPORTED_PRIMITIVES_VERSIONS = frozenset({1})
17
+ PRECISION_TYPES = frozenset({"Float", "Half", "Inherit"})
18
+ INPUT_TYPES = frozenset(
19
+ {
20
+ "INT", "FLOAT", "FLOAT2", "FLOAT3", "FLOAT4", "FLOAT3x3", "FLOAT4x4",
21
+ "SAMPLER1D", "SAMPLER2D", "SAMPLER3D", "SAMPLERCUBE", "SAMPLER2DARRAY", "SAMPLERSTATE",
22
+ }
23
+ )
24
+ OUTPUT_TYPES = frozenset({"INT", "FLOAT", "FLOAT2", "FLOAT3", "FLOAT4", "FLOAT3x3", "FLOAT4x4"})
25
+ _ROOT_KEYS = frozenset({"version", "template", "nodes", "connections", "primitives_version"})
26
+ _TEMPLATE_KEYS = frozenset({"guid", "shader_name"})
27
+ _COMMON_NODE_KEYS = frozenset({"alias", "kind", "position"})
28
+ _NODE_KEYS_V1 = {
29
+ "node": _COMMON_NODE_KEYS | {"type"},
30
+ "property": _COMMON_NODE_KEYS | {"type", "property_name", "inspector_name", "parameter_type"},
31
+ "sampler": _COMMON_NODE_KEYS | {"property_name", "inspector_name", "parameter_type"},
32
+ "custom_expression": _COMMON_NODE_KEYS | {"name", "code", "output_type", "inputs"},
33
+ }
34
+ _NODE_KEYS_V2 = {
35
+ **_NODE_KEYS_V1,
36
+ "node": _NODE_KEYS_V1["node"] | {"precision"},
37
+ "property": _NODE_KEYS_V1["property"] | {"help", "tooltip", "enabled_if", "precision", "default", "min", "max"},
38
+ "sampler": _NODE_KEYS_V1["sampler"] | {"help", "tooltip", "enabled_if", "precision"},
39
+ }
40
+ _NODE_KEYS_V3 = {
41
+ **_NODE_KEYS_V2,
42
+ "primitive": _COMMON_NODE_KEYS | {"op"},
43
+ "recipe": _COMMON_NODE_KEYS | {"recipe", "code", "output_type", "inputs", "expansion"},
44
+ }
45
+ _ALIAS = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
46
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
47
+ _PROPERTY_NAME = re.compile(r"^_[A-Za-z][A-Za-z0-9_]{0,126}$")
48
+ _GUID = re.compile(r"^[0-9a-fA-F]{32}$")
49
+ _CSHARP_MARKERS = re.compile(
50
+ r"(?:\busing\s+(?:System|Unity)|\bUnityEditor\b|\bUnityEngine\b|"
51
+ r"\bSystem\.(?:IO|Reflection|Diagnostics|Runtime|Net)\b|\bDllImport\b|^\s*#r\b)", re.MULTILINE,
52
+ )
53
+
54
+
55
+ def parse_precision(obj: dict, label: str) -> str | None:
56
+ if "precision" not in obj:
57
+ return None
58
+ precision = obj.get("precision")
59
+ if not isinstance(precision, str) or precision not in PRECISION_TYPES:
60
+ raise SpecError(f"{label} precision must be one of {sorted(PRECISION_TYPES)}")
61
+ return precision
62
+
63
+
64
+ def parse_primitive(obj: dict, label: str, alias: str, position: tuple[float, float]) -> NodeSpec:
65
+ op = _string(obj.get("op"), f"{label} op", 64)
66
+ if not is_known_op(op):
67
+ raise SpecError(f"{label} op {op!r} is not in the algorithm-primitive closure")
68
+ return NodeSpec(alias, "primitive", position, op=op)
69
+
70
+
71
+ def parse_recipe(obj: dict, label: str, alias: str, position: tuple[float, float]) -> NodeSpec:
72
+ recipe = _string(obj.get("recipe"), f"{label} recipe", 64)
73
+ code = _safe_text(obj.get("code"), f"{label} code", 65535, True)
74
+ if any(char in code for char in ("@", "$")) or _CSHARP_MARKERS.search(code):
75
+ raise SpecError(f"{label} code contains an ASE delimiter or forbidden C# runtime marker")
76
+ output_type = obj.get("output_type")
77
+ if output_type not in OUTPUT_TYPES:
78
+ raise SpecError(f"{label} output_type must be one of {sorted(OUTPUT_TYPES)}")
79
+ raw_inputs = obj.get("inputs")
80
+ if not isinstance(raw_inputs, list) or not raw_inputs or len(raw_inputs) > 32:
81
+ raise SpecError(f"{label} inputs must contain 1 through 32 entries")
82
+ inputs = tuple(parse_input(item, label, i) for i, item in enumerate(raw_inputs))
83
+ input_names = {item.name for item in inputs}
84
+ if len(input_names) != len(inputs):
85
+ raise SpecError(f"{label} input name must be unique")
86
+ expansion = _parse_expansion(obj.get("expansion"), label, inputs)
87
+ return NodeSpec(alias, "recipe", position, recipe=recipe, code=code,
88
+ output_type=output_type, inputs=inputs, expansion=expansion)
89
+
90
+
91
+ def _parse_expansion(value: Any, label: str, inputs: tuple[InputSpec, ...]) -> ExpansionSpec:
92
+ obj = _object(value, f"{label} expansion")
93
+ _unknown(obj, frozenset({"primitives", "output"}), f"{label} expansion")
94
+ raw = obj.get("primitives")
95
+ if not isinstance(raw, list) or not raw:
96
+ raise SpecError(f"{label} expansion.primitives must be a non-empty array")
97
+ primitives = tuple(_parse_primitive_entry(item, label, i) for i, item in enumerate(raw))
98
+ ids = [item.id for item in primitives]
99
+ if len(set(ids)) != len(ids):
100
+ raise SpecError(f"{label} expansion primitive id must be unique")
101
+ input_names = {item.name for item in inputs}
102
+ declared: set[str] = set(input_names)
103
+ for primitive in primitives:
104
+ if primitive.op == "const":
105
+ if primitive.args:
106
+ raise SpecError(f"{label} expansion const primitive must not declare args")
107
+ if primitive.value is None:
108
+ raise SpecError(f"{label} expansion const primitive must declare value")
109
+ try:
110
+ const_output_type(primitive.value)
111
+ except ValueError as exc:
112
+ raise SpecError(f"{label} expansion const value {exc}") from exc
113
+ else:
114
+ if primitive.value is not None:
115
+ raise SpecError(f"{label} expansion non-const primitive must not declare value")
116
+ for arg in primitive.args:
117
+ if arg not in declared:
118
+ raise SpecError(f"{label} expansion arg {arg!r} references an undeclared id or input")
119
+ declared.add(primitive.id)
120
+ output = obj.get("output")
121
+ if not isinstance(output, str) or not output or output not in declared:
122
+ raise SpecError(f"{label} expansion.output must reference a declared id or input name")
123
+ return ExpansionSpec(primitives=primitives, output=output)
124
+
125
+
126
+ def _parse_primitive_entry(value: Any, node_label: str, index: int) -> PrimitiveSpec:
127
+ label = f"{node_label} expansion.primitives[{index}]"
128
+ obj = _object(value, label)
129
+ _unknown(obj, frozenset({"id", "op", "args", "value"}), label)
130
+ pid = _string(obj.get("id"), f"{label} id", 64)
131
+ if not _IDENTIFIER.fullmatch(pid):
132
+ raise SpecError(f"{label} id must be an HLSL identifier")
133
+ op = _string(obj.get("op"), f"{label} op", 64)
134
+ args = obj.get("args")
135
+ if args is None:
136
+ arg_tuple: tuple[str, ...] = ()
137
+ elif isinstance(args, list):
138
+ arg_tuple = tuple(_string(item, f"{label} args", 64) for item in args)
139
+ else:
140
+ raise SpecError(f"{label} args must be an array")
141
+ value = obj.get("value")
142
+ if value is not None and not isinstance(value, str):
143
+ raise SpecError(f"{label} value must be a string")
144
+ return PrimitiveSpec(id=pid, op=op, args=arg_tuple, value=value)
145
+
146
+
147
+ def parse_property_semantics(
148
+ obj: dict, label: str, kind: str, node_type: str,
149
+ ) -> tuple[Any, float | None, float | None]:
150
+ default = obj.get("default")
151
+ minimum = obj.get("min")
152
+ maximum = obj.get("max")
153
+ if kind == "sampler":
154
+ if default is not None or minimum is not None or maximum is not None:
155
+ raise SpecError(f"{label} sampler does not accept default/min/max")
156
+ return None, None, None
157
+ if node_type == "RangedFloatNode":
158
+ if default is not None:
159
+ if isinstance(default, bool) or not isinstance(default, (int, float)) or not math.isfinite(float(default)):
160
+ raise SpecError(f"{label} default must be a finite number")
161
+ default = float(default)
162
+ if (minimum is None) != (maximum is None):
163
+ raise SpecError(f"{label} min and max must appear together")
164
+ if minimum is not None:
165
+ if isinstance(minimum, bool) or not isinstance(minimum, (int, float)):
166
+ raise SpecError(f"{label} min must be a number")
167
+ if isinstance(maximum, bool) or not isinstance(maximum, (int, float)):
168
+ raise SpecError(f"{label} max must be a number")
169
+ minimum = float(minimum)
170
+ maximum = float(maximum)
171
+ if minimum > maximum:
172
+ raise SpecError(f"{label} min must not exceed max")
173
+ return default, minimum, maximum
174
+ if node_type == "Vector4Node":
175
+ if default is not None:
176
+ if not isinstance(default, list) or len(default) != 4 or any(
177
+ isinstance(item, bool) or not isinstance(item, (int, float)) for item in default
178
+ ):
179
+ raise SpecError(f"{label} default must be a 4-number array")
180
+ default = [float(item) for item in default]
181
+ if minimum is not None or maximum is not None:
182
+ raise SpecError(f"{label} min/max only apply to RangedFloatNode")
183
+ return default, None, None
184
+ if default is not None or minimum is not None or maximum is not None:
185
+ raise SpecError(f"{label} default/min/max only apply to RangedFloatNode or Vector4Node")
186
+ return None, None, None
187
+
188
+
189
+ def parse_input(value: Any, node_label: str, index: int) -> InputSpec:
190
+ label = f"{node_label}.inputs[{index}]"
191
+ obj = _object(value, label)
192
+ _unknown(obj, frozenset({"name", "type"}), label)
193
+ name = _string(obj.get("name"), f"{label} name", 64)
194
+ if not _IDENTIFIER.fullmatch(name):
195
+ raise SpecError(f"{label} name must be an HLSL identifier")
196
+ data_type = obj.get("type")
197
+ if data_type not in INPUT_TYPES:
198
+ raise SpecError(f"{label} type must be one of {sorted(INPUT_TYPES)}")
199
+ return InputSpec(name=name, type=data_type)
200
+
201
+
202
+ def object_dict(value: Any, label: str) -> dict:
203
+ if not isinstance(value, dict):
204
+ raise SpecError(f"{label} must be a JSON object")
205
+ return value
206
+
207
+
208
+ def reject_unknown(value: dict, allowed: frozenset[str] | set[str], label: str) -> None:
209
+ unknown = set(value) - set(allowed)
210
+ if unknown:
211
+ raise SpecError(f"{label} has unknown keys: {sorted(unknown)}")
212
+
213
+
214
+ def require_string(value: Any, label: str, limit: int) -> str:
215
+ if not isinstance(value, str) or not value or len(value) > limit:
216
+ raise SpecError(f"{label} must be a non-empty string of at most {limit} characters")
217
+ return value
218
+
219
+
220
+ def safe_text(value: Any, label: str, limit: int, allow_newline: bool) -> str:
221
+ text = require_string(value, label, limit)
222
+ if "\x00" in text or (not allow_newline and any(char in text for char in ("\r", "\n", ";"))):
223
+ raise SpecError(f"{label} contains a forbidden control character or separator")
224
+ return text
225
+
226
+
227
+ def position(value: Any, label: str) -> tuple[float, float]:
228
+ if not isinstance(value, list) or len(value) != 2:
229
+ raise SpecError(f"{label} must be [x, y]")
230
+ result = []
231
+ for item in value:
232
+ if isinstance(item, bool) or not isinstance(item, (int, float)):
233
+ raise SpecError(f"{label} coordinates must be numbers")
234
+ number = float(item)
235
+ if not math.isfinite(number) or abs(number) > 1_000_000:
236
+ raise SpecError(f"{label} coordinates must be finite and within +/-1000000")
237
+ result.append(number)
238
+ return result[0], result[1]
239
+
240
+
241
+ _object = object_dict
242
+ _unknown = reject_unknown
243
+ _string = require_string
244
+ _safe_text = safe_text
245
+ _position = position
@@ -0,0 +1,26 @@
1
+ """IO and routing helpers for EditorGraphSpec, split out to bound LOC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .editor_spec import EditorGraphSpec, SpecError
9
+
10
+
11
+ def load_editor_graph_spec(path: str | Path) -> EditorGraphSpec:
12
+ try:
13
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
14
+ except FileNotFoundError:
15
+ raise
16
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
17
+ raise SpecError(f"cannot read EditorGraphSpec JSON: {exc}") from exc
18
+ return EditorGraphSpec.from_dict(value)
19
+
20
+
21
+ def route_create_backend(requested: str, spec: EditorGraphSpec | None) -> str:
22
+ if requested not in {"text", "editor", "auto"}:
23
+ raise SpecError(f"unknown create backend: {requested}")
24
+ if requested == "auto":
25
+ return "editor" if spec is not None else "text"
26
+ return requested