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
asecli/__init__.py
ADDED
|
@@ -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
|