This commit is contained in:
1
tools/nodes/__init__.py
Normal file
1
tools/nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tools/nodes/arrangement/__init__.py
Normal file
1
tools/nodes/arrangement/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
130
tools/nodes/arrangement/arrange.py
Normal file
130
tools/nodes/arrangement/arrange.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Materialize the joint calculation and connection plan in node trees."""
|
||||
|
||||
from .branches import calculation_blocks
|
||||
from .dimensions import estimate_node_dimensions, visible_socket
|
||||
from .flow import origin_socket, socket_links
|
||||
from .layout import plan_layout
|
||||
from nodes.common.nodes import prepare_node_group, replace_link_source
|
||||
|
||||
|
||||
def _remove_routes(node_group):
|
||||
previous = [node for node in node_group.nodes if node.bl_idname == "NodeReroute"]
|
||||
|
||||
for node in [node for node in node_group.nodes if node not in previous]:
|
||||
for socket in node.inputs:
|
||||
links = socket_links(socket)
|
||||
if not any(link.from_node in previous for link in links):
|
||||
continue
|
||||
if not socket.is_multi_input:
|
||||
replace_link_source(node_group, links[0], origin_socket(links[0].from_socket))
|
||||
continue
|
||||
sources = [origin_socket(link.from_socket) for link in links]
|
||||
for link in links:
|
||||
node_group.links.remove(link)
|
||||
seen = set()
|
||||
for source in reversed(sources):
|
||||
original = source
|
||||
if source in seen:
|
||||
# Blender coalesces identical direct links; retain repeated inputs.
|
||||
duplicate = node_group.nodes.new("NodeReroute")
|
||||
node_group.links.new(source, duplicate.inputs[0])
|
||||
source = duplicate.outputs[0]
|
||||
seen.add(original)
|
||||
node_group.links.new(source, socket)
|
||||
for node in previous:
|
||||
node_group.nodes.remove(node)
|
||||
|
||||
|
||||
def _localize_inputs(node_group, nodes, blocks, core):
|
||||
"""Read interface values locally in each consuming calculation body."""
|
||||
ids = {node: i for i, node in enumerate(nodes)}
|
||||
block_of = {i: k for k, block in enumerate(blocks) for i in block["members"]}
|
||||
for node in list(nodes):
|
||||
if node.bl_idname != "NodeGroupInput":
|
||||
continue
|
||||
original = ("core", ids[node]) if ids[node] in core else ("block", block_of[ids[node]])
|
||||
uses = list(node_group.links)
|
||||
direct = [ids[edge.to_node] for edge in uses if edge.from_node == node and ids[edge.to_node] in core]
|
||||
if direct:
|
||||
original = ("core", direct[0])
|
||||
local_reads = {original: node}
|
||||
for edge in uses:
|
||||
if edge.from_node != node:
|
||||
continue
|
||||
target = ids[edge.to_node]
|
||||
owner = ("block", block_of[target]) if target in block_of else ("core", target)
|
||||
if owner not in local_reads:
|
||||
clone = node_group.nodes.new("NodeGroupInput")
|
||||
ids[clone] = len(nodes)
|
||||
nodes.append(clone)
|
||||
if owner[0] == "block":
|
||||
blocks[owner[1]]["members"].append(ids[clone])
|
||||
block_of[ids[clone]] = owner[1]
|
||||
else:
|
||||
core.add(ids[clone])
|
||||
local_reads[owner] = clone
|
||||
source = next(
|
||||
socket
|
||||
for socket in local_reads[owner].outputs
|
||||
if socket.identifier == edge.from_socket.identifier
|
||||
)
|
||||
if source != edge.from_socket:
|
||||
replace_link_source(node_group, edge, source)
|
||||
|
||||
|
||||
def arrange_nodes(node_group):
|
||||
"""Arrange calculation bodies and shared channels before creating reroutes."""
|
||||
for frame in [node for node in node_group.nodes if node.bl_idname == "NodeFrame"]:
|
||||
for child in [node for node in node_group.nodes if node.parent == frame]:
|
||||
position = tuple(child.location_absolute)
|
||||
child.parent = None
|
||||
child.location = position
|
||||
node_group.nodes.remove(frame)
|
||||
_remove_routes(node_group)
|
||||
nodes = list(node_group.nodes)
|
||||
if not nodes:
|
||||
return
|
||||
blocks, core = calculation_blocks(nodes, list(node_group.links))
|
||||
_localize_inputs(node_group, nodes, blocks, core)
|
||||
prepare_node_group(node_group)
|
||||
for node in nodes:
|
||||
node.select = False
|
||||
if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath", "FunctionNodeIntegerMath"}:
|
||||
node.hide = all(socket.is_linked for socket in node.inputs if visible_socket(socket))
|
||||
sizes = {i: estimate_node_dimensions(node) for i, node in enumerate(nodes)}
|
||||
edges = list(node_group.links)
|
||||
positions, routes = plan_layout(nodes, edges, sizes, blocks, core)
|
||||
for i, (x, y) in positions.items():
|
||||
nodes[i].location = (x, -y)
|
||||
nodes[i].width = sizes[i].width
|
||||
cache = {}
|
||||
for index, path in routes.items():
|
||||
edge = edges[index]
|
||||
source = edge.from_socket
|
||||
for x, y in path[1:-1]:
|
||||
key = (source, round(x, 4), round(y, 4))
|
||||
reroute = cache.get(key)
|
||||
if reroute is None:
|
||||
reroute = node_group.nodes.new("NodeReroute")
|
||||
reroute.location = (x - 8, -y + 8)
|
||||
node_group.links.new(source, reroute.inputs[0])
|
||||
cache[key] = reroute
|
||||
source = reroute.outputs[0]
|
||||
if len(path) > 2:
|
||||
replace_link_source(node_group, edge, source)
|
||||
for block in blocks:
|
||||
if len(block["members"]) < 5:
|
||||
continue
|
||||
left, top, right, bottom = block["box"]
|
||||
frame = node_group.nodes.new("NodeFrame")
|
||||
frame["arrangement_frame"] = True
|
||||
frame.name = frame.label = block["title"]
|
||||
frame.shrink = False
|
||||
frame.location = (left - 28, -top + 48)
|
||||
frame.width = right - left + 56
|
||||
frame.height = bottom - top + 76
|
||||
for i in block["members"]:
|
||||
absolute = nodes[i].location.copy()
|
||||
nodes[i].parent = frame
|
||||
nodes[i].location = absolute - frame.location
|
||||
prepare_node_group(node_group)
|
||||
80
tools/nodes/arrangement/branches.py
Normal file
80
tools/nodes/arrangement/branches.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Identify connected calculations by their nearest geometry or shader consumers."""
|
||||
|
||||
from collections import defaultdict
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
|
||||
def calculation_blocks(nodes, edges):
|
||||
"""Group fields with equal consumers, then attach their local interface reads."""
|
||||
ids = {node: i for i, node in enumerate(nodes)}
|
||||
parents = {i: set() for i in range(len(nodes))}
|
||||
children = {i: set() for i in parents}
|
||||
for edge in edges:
|
||||
parents[ids[edge.to_node]].add(ids[edge.from_node])
|
||||
children[ids[edge.from_node]].add(ids[edge.to_node])
|
||||
order = list(TopologicalSorter(parents).static_order())
|
||||
core = {
|
||||
i
|
||||
for i, n in enumerate(nodes)
|
||||
if n.bl_idname == "NodeGroupOutput"
|
||||
or n.bl_idname in {"ShaderNodeNormalMap", "ShaderNodeBump", "ShaderNodeDisplacement"}
|
||||
or any(s.type in {"GEOMETRY", "SHADER"} and s.is_linked for s in n.outputs)
|
||||
or getattr(n, "paired_output", None) is not None
|
||||
}
|
||||
inputs = {i for i, n in enumerate(nodes) if n.bl_idname == "NodeGroupInput"}
|
||||
core -= inputs
|
||||
fields = set(range(len(nodes))) - core - inputs
|
||||
destinations = {}
|
||||
for i in reversed(order):
|
||||
destinations[i] = frozenset().union(
|
||||
*(frozenset([j]) if j in core else destinations[j] for j in children[i])
|
||||
)
|
||||
groups = defaultdict(set)
|
||||
for i in fields:
|
||||
groups[destinations[i]].add(i)
|
||||
blocks = []
|
||||
for targets, members in groups.items():
|
||||
pending = set(members)
|
||||
while pending:
|
||||
component = set()
|
||||
queue = [min(pending)]
|
||||
while queue:
|
||||
i = queue.pop()
|
||||
if i not in pending:
|
||||
continue
|
||||
pending.remove(i)
|
||||
component.add(i)
|
||||
queue.extend((parents[i] | children[i]) & pending)
|
||||
target = min(targets) if targets else None
|
||||
ports = list(
|
||||
dict.fromkeys(
|
||||
e.to_socket.name
|
||||
for e in edges
|
||||
if ids[e.from_node] in component and ids[e.to_node] == target
|
||||
)
|
||||
)
|
||||
if ports:
|
||||
title = nodes[target].name + " · " + ", ".join(ports)
|
||||
else:
|
||||
outlets = [i for i in component if children[i] - component]
|
||||
source = (
|
||||
max(outlets, key=lambda i: (len(children[i] - component), i))
|
||||
if outlets
|
||||
else max(component)
|
||||
)
|
||||
title = "共享 · " + nodes[source].name + " → " + str(len(targets)) + " 处计算"
|
||||
blocks.append(dict(title=title, members=sorted(component), targets=sorted(targets)))
|
||||
block_of = {i: k for k, b in enumerate(blocks) for i in b["members"]}
|
||||
# Keep one local original read; additional consumers receive local copies later.
|
||||
for i in sorted(inputs):
|
||||
owners = [block_of[j] for j in sorted(children[i]) if j in block_of]
|
||||
if any(j in core for j in children[i]):
|
||||
core.add(i)
|
||||
elif owners:
|
||||
k = owners[0]
|
||||
blocks[k]["members"].append(i)
|
||||
block_of[i] = k
|
||||
else:
|
||||
core.add(i)
|
||||
|
||||
return blocks, core
|
||||
20
tools/nodes/arrangement/connections.py
Normal file
20
tools/nodes/arrangement/connections.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Socket endpoints for the node preview."""
|
||||
|
||||
from .flow import socket_links
|
||||
|
||||
|
||||
def socket_position(socket, positions, sizes):
|
||||
node = socket.node
|
||||
x, y = positions[node]
|
||||
if node.bl_idname == "NodeReroute":
|
||||
return x + 8, y - 8
|
||||
return x + (sizes[node].width if socket.is_output else 0), y - sizes[node].sockets[socket]
|
||||
|
||||
|
||||
def link_endpoints(link, positions, sizes):
|
||||
start = socket_position(link.from_socket, positions, sizes)
|
||||
x, y = socket_position(link.to_socket, positions, sizes)
|
||||
if link.to_socket.is_multi_input:
|
||||
siblings = socket_links(link.to_socket)
|
||||
y -= (siblings.index(link) - (len(siblings) - 1) / 2) * 8
|
||||
return start, (x, y)
|
||||
93
tools/nodes/arrangement/dimensions.json
Normal file
93
tools/nodes/arrangement/dimensions.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"blender_version": "4.5",
|
||||
"header": 32,
|
||||
"row": 24,
|
||||
"padding": 12,
|
||||
"width": 180,
|
||||
"wide_width": 220,
|
||||
"collapsed_height": 36,
|
||||
"controls": {
|
||||
"FunctionNodeBooleanMath": 1,
|
||||
"FunctionNodeCompare": 2,
|
||||
"FunctionNodeFloatToInt": 1,
|
||||
"FunctionNodeIntegerMath": 1,
|
||||
"FunctionNodeSeparateColor": 1,
|
||||
"GeometryNodeAccumulateField": 2,
|
||||
"GeometryNodeAttributeStatistic": 2,
|
||||
"GeometryNodeAttributeDomainSize": 1,
|
||||
"GeometryNodeBlurAttribute": 1,
|
||||
"GeometryNodeBoundBox": 0,
|
||||
"GeometryNodeCornersOfEdge": 0,
|
||||
"GeometryNodeCornersOfFace": 0,
|
||||
"GeometryNodeDeleteGeometry": 2,
|
||||
"GeometryNodeEdgesOfCorner": 0,
|
||||
"GeometryNodeExtrudeMesh": 1,
|
||||
"GeometryNodeFaceOfCorner": 0,
|
||||
"GeometryNodeFieldAtIndex": 2,
|
||||
"GeometryNodeFieldOnDomain": 2,
|
||||
"GeometryNodeForeachGeometryElementInput": 0,
|
||||
"GeometryNodeForeachGeometryElementOutput": 4,
|
||||
"GeometryNodeFlipFaces": 0,
|
||||
"GeometryNodeGroup": 1,
|
||||
"GeometryNodeImageTexture": 2,
|
||||
"GeometryNodeImageInfo": 0,
|
||||
"GeometryNodeSetMaterial": 0,
|
||||
"GeometryNodeInputIndex": 0,
|
||||
"GeometryNodeInputMeshEdgeNeighbors": 0,
|
||||
"GeometryNodeInputMeshEdgeVertices": 0,
|
||||
"GeometryNodeInputMeshFaceArea": 0,
|
||||
"GeometryNodeInputMeshFaceNeighbors": 0,
|
||||
"GeometryNodeInputMeshIsland": 0,
|
||||
"GeometryNodeInputMeshVertexNeighbors": 0,
|
||||
"GeometryNodeInputNamedAttribute": 1,
|
||||
"GeometryNodeInputNormal": 0,
|
||||
"GeometryNodeInputPosition": 0,
|
||||
"GeometryNodeJoinGeometry": 0,
|
||||
"GeometryNodeMenuSwitch": 3,
|
||||
"GeometryNodeMergeByDistance": 1,
|
||||
"GeometryNodeMeshCube": 0,
|
||||
"GeometryNodeMeshGrid": 0,
|
||||
"GeometryNodeOffsetCornerInFace": 0,
|
||||
"GeometryNodeRemoveAttribute": 1,
|
||||
"GeometryNodeRepeatInput": 0,
|
||||
"GeometryNodeRepeatOutput": 3,
|
||||
"GeometryNodeSampleIndex": 3,
|
||||
"GeometryNodeSampleNearest": 1,
|
||||
"GeometryNodeProximity": 1,
|
||||
"GeometryNodeSampleNearestSurface": 1,
|
||||
"GeometryNodeSeparateGeometry": 1,
|
||||
"GeometryNodeSetMaterialIndex": 0,
|
||||
"GeometryNodeSetPosition": 0,
|
||||
"GeometryNodeSetShadeSmooth": 1,
|
||||
"GeometryNodeSimulationInput": 0,
|
||||
"GeometryNodeSimulationOutput": 3,
|
||||
"GeometryNodeSplitEdges": 0,
|
||||
"GeometryNodeStoreNamedAttribute": 2,
|
||||
"GeometryNodeSwitch": 1,
|
||||
"GeometryNodeTransform": 1,
|
||||
"GeometryNodeVertexOfCorner": 0,
|
||||
"NodeGroupInput": 0,
|
||||
"NodeGroupOutput": 0,
|
||||
"ShaderNodeClamp": 1,
|
||||
"ShaderNodeCombineXYZ": 0,
|
||||
"ShaderNodeMapRange": 3,
|
||||
"ShaderNodeMath": 2,
|
||||
"ShaderNodeMix": 3,
|
||||
"ShaderNodeSeparateXYZ": 0,
|
||||
"ShaderNodeValue": 1,
|
||||
"ShaderNodeVectorMath": 1,
|
||||
"FunctionNodeAlignEulerToVector": 2,
|
||||
"ShaderNodeVectorRotate": 2,
|
||||
"ShaderNodeAttribute": 2,
|
||||
"ShaderNodeFloatCurve": 12,
|
||||
"ShaderNodeBump": 2,
|
||||
"ShaderNodeNormalMap": 2,
|
||||
"ShaderNodeDisplacement": 1,
|
||||
"ShaderNodeBsdfDiffuse": 1,
|
||||
"ShaderNodeBsdfTransparent": 0,
|
||||
"ShaderNodeEmission": 0,
|
||||
"ShaderNodeLightPath": 0,
|
||||
"ShaderNodeMixShader": 0,
|
||||
"ShaderNodeGroup": 1
|
||||
}
|
||||
}
|
||||
65
tools/nodes/arrangement/dimensions.py
Normal file
65
tools/nodes/arrangement/dimensions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Estimate node bounds and socket offsets in unscaled editor units."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RULES = json.loads(Path(__file__).with_suffix(".json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NodeDimensions:
|
||||
width: float
|
||||
height: float
|
||||
sockets: dict
|
||||
|
||||
|
||||
def visible_socket(socket):
|
||||
"""Include only sockets drawn for the node's current operation."""
|
||||
return socket.enabled and not socket.is_unavailable and not socket.hide
|
||||
|
||||
|
||||
def estimate_node_dimensions(node):
|
||||
"""Reserve socket rows, expanded vector values, and node-specific controls."""
|
||||
if node.bl_idname == "NodeFrame":
|
||||
return NodeDimensions(node.width, node.height, {})
|
||||
if node.bl_idname == "NodeReroute":
|
||||
return NodeDimensions(16, 16, {socket: 8 for socket in (*node.inputs, *node.outputs)})
|
||||
inputs = [s for s in node.inputs if visible_socket(s)]
|
||||
outputs = [s for s in node.outputs if visible_socket(s)]
|
||||
width = RULES["wide_width"] if (
|
||||
"Attribute" in node.bl_idname or node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup", "ShaderNodeFloatCurve"}
|
||||
) else RULES["width"]
|
||||
if node.hide:
|
||||
height = max(RULES["collapsed_height"], 16 + 12 * max(len(inputs), len(outputs)))
|
||||
sockets = {
|
||||
socket: (index + 1) * height / (len(side) + 1)
|
||||
for side in (inputs, outputs)
|
||||
for index, socket in enumerate(side)
|
||||
}
|
||||
return NodeDimensions(width, height, sockets)
|
||||
|
||||
# Conservative reserves include spacing between UI sections.
|
||||
controls = RULES["controls"][node.bl_idname]
|
||||
for collection in ("repeat_items", "state_items", "input_items", "main_items", "generation_items", "enum_items"):
|
||||
controls += len(getattr(node, collection, ()))
|
||||
if node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup"} and node.node_tree:
|
||||
controls += sum(item.item_type == "PANEL" for item in node.node_tree.interface.items_tree)
|
||||
cursor = RULES["header"] + RULES["padding"]
|
||||
sockets = {}
|
||||
for socket in outputs:
|
||||
sockets[socket] = cursor + RULES["row"] / 2
|
||||
cursor += RULES["row"]
|
||||
cursor += controls * RULES["row"] + RULES["padding"]
|
||||
for socket in inputs:
|
||||
rows = 1
|
||||
if not socket.is_linked and not socket.hide_value:
|
||||
if socket.type in {"VECTOR", "ROTATION"}:
|
||||
rows = 4
|
||||
elif socket.type == "MATRIX":
|
||||
rows = 5
|
||||
extra = max(0, len(socket.links) - 1) * 8 if socket.is_multi_input else 0
|
||||
sockets[socket] = cursor + RULES["row"] / 2 + extra / 2
|
||||
cursor += rows * RULES["row"] + extra
|
||||
return NodeDimensions(width, cursor + RULES["padding"], sockets)
|
||||
94
tools/nodes/arrangement/flow.py
Normal file
94
tools/nodes/arrangement/flow.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Infer geometry flow and socket relationships for layout and preview."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from .branches import calculation_blocks
|
||||
|
||||
|
||||
SHARED_GEOMETRY_USES = 3
|
||||
|
||||
|
||||
def socket_links(socket):
|
||||
return sorted(socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)
|
||||
|
||||
|
||||
def origin_socket(socket):
|
||||
while socket.node.bl_idname == "NodeReroute":
|
||||
socket = socket.node.inputs[0].links[0].from_socket
|
||||
return socket
|
||||
|
||||
|
||||
def geometry_paths(node_group):
|
||||
"""Trace every geometry input and split paths at forks and merges."""
|
||||
output = next((node for node in node_group.nodes if node.bl_idname == "NodeGroupOutput" and node.is_active_output), None)
|
||||
parents = {}
|
||||
uses = defaultdict(set)
|
||||
for link in node_group.links:
|
||||
if link.from_socket.type == "GEOMETRY" and link.to_node.bl_idname != "NodeReroute":
|
||||
uses[origin_socket(link.from_socket).node].add(link.to_node)
|
||||
paired = {member for node in node_group.nodes if getattr(node, "paired_output", None) is not None
|
||||
for member in (node, node.paired_output)}
|
||||
result_nodes, pending = set(), [*paired, *([output] if output else [])]
|
||||
while pending:
|
||||
node = pending.pop()
|
||||
if node in result_nodes:
|
||||
continue
|
||||
result_nodes.add(node)
|
||||
pending.extend(origin_socket(link.from_socket).node
|
||||
for socket in node.inputs if socket.type == "GEOMETRY" for link in socket.links)
|
||||
shared = {node for node, targets in uses.items()
|
||||
if len(targets) >= SHARED_GEOMETRY_USES and node not in paired
|
||||
and (not any(socket.type == "GEOMETRY" and socket.is_linked for socket in node.inputs)
|
||||
or len(targets & result_nodes) >= SHARED_GEOMETRY_USES)}
|
||||
|
||||
def source_node(link):
|
||||
return link.from_node if link.from_node.get("arrangement_input") else origin_socket(link.from_socket).node
|
||||
|
||||
def collect(node):
|
||||
if node in parents:
|
||||
return
|
||||
parents[node] = list(dict.fromkeys(
|
||||
source_node(link) for socket in node.inputs if socket.type == "GEOMETRY"
|
||||
for link in socket_links(socket)
|
||||
if source_node(link).bl_idname != "NodeGroupInput" and source_node(link) not in shared
|
||||
)) if not node.get("arrangement_input") else []
|
||||
for parent in parents[node]:
|
||||
collect(parent)
|
||||
|
||||
roots = []
|
||||
if output is not None:
|
||||
collect(output)
|
||||
roots.append(output)
|
||||
for node in node_group.nodes:
|
||||
if node in paired and node not in parents:
|
||||
collect(node)
|
||||
roots.append(node)
|
||||
children = {node: set() for node in parents}
|
||||
for node, sources in parents.items():
|
||||
for source in sources:
|
||||
children[source].add(node)
|
||||
paths, assigned, pending = [], set(), roots
|
||||
while pending:
|
||||
node = pending.pop(0)
|
||||
path = []
|
||||
while node not in assigned:
|
||||
path.append(node)
|
||||
assigned.add(node)
|
||||
sources = parents[node]
|
||||
if len(sources) != 1 or len(children[sources[0]]) != 1:
|
||||
pending.extend(source for source in sources if source not in assigned)
|
||||
break
|
||||
node = sources[0]
|
||||
if path:
|
||||
paths.append(list(reversed(path)))
|
||||
return parents, paths
|
||||
|
||||
|
||||
def node_roles(node_group):
|
||||
"""Classify by contribution to the result, not by available socket types."""
|
||||
if node_group.bl_idname == "ShaderNodeTree":
|
||||
nodes = list(node_group.nodes)
|
||||
_, core = calculation_blocks(nodes, list(node_group.links))
|
||||
return {node: "shader" if i in core else "field" for i, node in enumerate(nodes)}
|
||||
core, _ = geometry_paths(node_group)
|
||||
return {node: "geometry" if node in core else "field" for node in node_group.nodes}
|
||||
407
tools/nodes/arrangement/layout.py
Normal file
407
tools/nodes/arrangement/layout.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Plan calculation bodies and shared wire passages in the same column layout."""
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from graphlib import TopologicalSorter
|
||||
from statistics import median
|
||||
|
||||
|
||||
def plan_layout(nodes, edges, size, blocks, core):
|
||||
"""Return node positions and connection paths without modifying Blender data."""
|
||||
ids = {node: i for i, node in enumerate(nodes)}
|
||||
edge_ids = [(ids[e.from_node], ids[e.to_node]) for e in edges]
|
||||
edge_types = [e.from_socket.type for e in edges]
|
||||
block_of = {i: k for k, block in enumerate(blocks) for i in block["members"]}
|
||||
|
||||
def bounds(positions):
|
||||
return (
|
||||
min(x for x, y in positions.values()),
|
||||
min(y for x, y in positions.values()),
|
||||
max(x + size[i].width for i, (x, y) in positions.items()),
|
||||
max(y + size[i].height for i, (x, y) in positions.items()),
|
||||
)
|
||||
|
||||
def endpoint(edge, positions):
|
||||
a, b = ids[edge.from_node], ids[edge.to_node]
|
||||
return (
|
||||
(positions[a][0] + size[a].width, positions[a][1] + size[a].sockets[edge.from_socket]),
|
||||
(positions[b][0], positions[b][1] + size[b].sockets[edge.to_socket]),
|
||||
)
|
||||
|
||||
source_keys = [(a, edges[e].from_socket.identifier) for e, (a, b) in enumerate(edge_ids)]
|
||||
|
||||
def simplify(path):
|
||||
result = []
|
||||
for point in path:
|
||||
if result and math.dist(result[-1], point) < 1e-6:
|
||||
continue
|
||||
while (
|
||||
len(result) > 1
|
||||
and abs(result[-2][1] - result[-1][1]) < 1e-6
|
||||
and abs(point[1] - result[-1][1]) < 1e-6
|
||||
):
|
||||
result.pop()
|
||||
result.append(point)
|
||||
return result
|
||||
|
||||
def leaf(i):
|
||||
return dict(
|
||||
members={i},
|
||||
w=size[i].width,
|
||||
h=size[i].height,
|
||||
positions={i: (0.0, 0.0)},
|
||||
routes={},
|
||||
inputs={
|
||||
e: (0.0, size[i].sockets[edges[e].to_socket]) for e, (a, b) in enumerate(edge_ids) if b == i
|
||||
},
|
||||
outputs={
|
||||
e: (size[i].width, size[i].sockets[edges[e].from_socket])
|
||||
for e, (a, b) in enumerate(edge_ids)
|
||||
if a == i
|
||||
},
|
||||
)
|
||||
|
||||
def plan_scope(items, rank_map=None):
|
||||
"""Reserve relay rows at every crossed column, including nested boundaries."""
|
||||
items = {("item", k): v for k, v in enumerate(items)}
|
||||
owner = {i: k for k, item in items.items() for i in item["members"]}
|
||||
members = set(owner)
|
||||
incoming = [e for e, (a, b) in enumerate(edge_ids) if b in members and a not in members]
|
||||
outgoing = [e for e, (a, b) in enumerate(edge_ids) if a in members and b not in members]
|
||||
if rank_map is not None:
|
||||
incoming = []
|
||||
outgoing = []
|
||||
boundary = {}
|
||||
for e in incoming:
|
||||
boundary["in", e] = ("in", source_keys[e])
|
||||
for e in outgoing:
|
||||
boundary["out", e] = ("out", source_keys[e])
|
||||
for key in set(boundary.values()):
|
||||
items[key] = dict(members=set(), w=0.0, h=32.0, positions={}, routes={}, inputs={}, outputs={})
|
||||
links = []
|
||||
for e, (a, b) in enumerate(edge_ids):
|
||||
if a not in members and b not in members:
|
||||
continue
|
||||
if rank_map is not None and (a not in members or b not in members):
|
||||
continue
|
||||
u = owner[a] if a in members else boundary["in", e]
|
||||
v = owner[b] if b in members else boundary["out", e]
|
||||
if u == v:
|
||||
continue
|
||||
sy = items[u]["outputs"][e][1] if a in members else 16.0
|
||||
ty = items[v]["inputs"][e][1] if b in members else 16.0
|
||||
weight = 3.0 if edge_types[e] in {"GEOMETRY", "SHADER"} else 1.0
|
||||
links.append((e, u, v, sy, ty, weight))
|
||||
parents = {k: set() for k in items}
|
||||
children = {k: set() for k in items}
|
||||
for e, u, v, sy, ty, weight in links:
|
||||
parents[v].add(u)
|
||||
children[u].add(v)
|
||||
topo = list(TopologicalSorter(parents).static_order())
|
||||
earliest = {}
|
||||
for k in topo:
|
||||
earliest[k] = max((earliest[p] + 1 for p in parents[k]), default=0)
|
||||
depth = max(earliest.values(), default=0)
|
||||
ranks = {}
|
||||
for k in reversed(topo):
|
||||
ranks[k] = min((ranks[c] - 1 for c in children[k]), default=depth)
|
||||
for k in items:
|
||||
if k[0] == "in":
|
||||
ranks[k] = 0
|
||||
if rank_map is not None:
|
||||
ranks = {k: rank_map[min(item["members"])] for k, item in items.items()}
|
||||
# Relay vertices are space reservations, not routes fitted to placed nodes.
|
||||
chains = {}
|
||||
arcs = {}
|
||||
for e, u, v, sy, ty, weight in links:
|
||||
chain = [u]
|
||||
for rank in range(ranks[u] + 1, ranks[v]):
|
||||
key = ("relay", source_keys[e], rank)
|
||||
if key not in items:
|
||||
items[key] = dict(
|
||||
members=set(), w=0.0, h=36.0, positions={}, routes={}, inputs={}, outputs={}
|
||||
)
|
||||
ranks[key] = rank
|
||||
chain.append(key)
|
||||
chain.append(v)
|
||||
chains[e] = chain
|
||||
for j, (a, b) in enumerate(zip(chain, chain[1:])):
|
||||
ay = sy if j == 0 else items[a]["h"] / 2
|
||||
by = ty if j == len(chain) - 2 else items[b]["h"] / 2
|
||||
arcs[a, b, ay, by] = max(arcs.get((a, b, ay, by), 0), weight)
|
||||
layers = defaultdict(list)
|
||||
for k in items:
|
||||
layers[ranks[k]].append(k)
|
||||
for rank in range(min(ranks.values()), max(ranks.values()) + 1):
|
||||
layers[rank]
|
||||
families = {}
|
||||
for k, item in items.items():
|
||||
if item["members"]:
|
||||
i = min(item["members"])
|
||||
families[k] = ("block", block_of[i]) if i in block_of else ("core", i)
|
||||
else:
|
||||
families[k] = k
|
||||
for e, chain in chains.items():
|
||||
a, b = edge_ids[e]
|
||||
if a in block_of and block_of.get(a) == block_of.get(b):
|
||||
for k in chain[1:-1]:
|
||||
families[k] = ("block", block_of[a])
|
||||
neighbors = defaultdict(list)
|
||||
for (a, b, ay, by), weight in arcs.items():
|
||||
if a[0] == "relay" and b[0] == "relay":
|
||||
weight *= 30.0
|
||||
neighbors[a].append((b, ay, by, weight))
|
||||
neighbors[b].append((a, by, ay, weight))
|
||||
gap = 64.0
|
||||
|
||||
def separation(a, b):
|
||||
if families[a] != families[b] and (families[a][0] == "block" or families[b][0] == "block"):
|
||||
return 180.0
|
||||
return gap if items[a]["members"] and items[b]["members"] else 36.0
|
||||
|
||||
y = {}
|
||||
for layer in layers.values():
|
||||
cursor = 0.0
|
||||
for k in layer:
|
||||
y[k] = cursor
|
||||
cursor += items[k]["h"] + gap
|
||||
|
||||
def pack(layer, desired, weights):
|
||||
offsets = {}
|
||||
cursor = 0.0
|
||||
previous = None
|
||||
for k in layer:
|
||||
if previous is not None:
|
||||
cursor += items[previous]["h"] + separation(previous, k)
|
||||
offsets[k] = cursor
|
||||
previous = k
|
||||
pools = []
|
||||
for k in layer:
|
||||
pools.append(([k], weights[k], (desired[k] - offsets[k]) * weights[k]))
|
||||
while len(pools) > 1 and pools[-2][2] / pools[-2][1] > pools[-1][2] / pools[-1][1]:
|
||||
b = pools.pop()
|
||||
a = pools.pop()
|
||||
pools.append((a[0] + b[0], a[1] + b[1], a[2] + b[2]))
|
||||
for keys, weight, total in pools:
|
||||
for k in keys:
|
||||
y[k] = total / weight + offsets[k]
|
||||
|
||||
for sweep in range(16):
|
||||
for rank in sorted(layers, reverse=bool(sweep % 2)):
|
||||
layer = layers[rank]
|
||||
desired = {}
|
||||
weights = {}
|
||||
for k in layer:
|
||||
adjacent = [
|
||||
(y[b] + by - ay, weight)
|
||||
for b, ay, by, weight in neighbors[k]
|
||||
if (ranks[b] < rank) != bool(sweep % 2)
|
||||
]
|
||||
if not adjacent:
|
||||
adjacent = [(y[b] + by - ay, weight) for b, ay, by, weight in neighbors[k]]
|
||||
weights[k] = sum(w for value, w in adjacent) or 1.0
|
||||
desired[k] = sum(value * w for value, w in adjacent) / weights[k] if adjacent else y[k]
|
||||
family_centers = defaultdict(list)
|
||||
for k in layer:
|
||||
family_centers[families[k]].append(desired[k] + items[k]["h"] / 2)
|
||||
centers = {key: sum(values) / len(values) for key, values in family_centers.items()}
|
||||
layer.sort(
|
||||
key=lambda k: (
|
||||
centers[families[k]],
|
||||
repr(families[k]),
|
||||
desired[k] + items[k]["h"] / 2,
|
||||
repr(k),
|
||||
)
|
||||
)
|
||||
pack(layer, desired, weights)
|
||||
# Coordinate relaxation holds the planned row order and never moves obstacles
|
||||
# in response to a routed wire. All channels remain participants in the solve.
|
||||
for sweep in range(24):
|
||||
for rank in sorted(layers, reverse=bool(sweep % 2)):
|
||||
desired = {}
|
||||
weights = {}
|
||||
for k in layers[rank]:
|
||||
adj = neighbors[k]
|
||||
weights[k] = sum(w for b, ay, by, w in adj) or 1.0
|
||||
desired[k] = (
|
||||
sum((y[b] + by - ay) * w for b, ay, by, w in adj) / weights[k] if adj else y[k]
|
||||
)
|
||||
pack(layers[rank], desired, weights)
|
||||
top = min(y.values(), default=0.0)
|
||||
y = {k: value - top for k, value in y.items()}
|
||||
widths = {rank: max((items[k]["w"] for k in layer), default=0.0) for rank, layer in layers.items()}
|
||||
x = {}
|
||||
cursor = 0.0
|
||||
for rank in sorted(layers):
|
||||
x[rank] = cursor
|
||||
cursor += widths[rank] + 200.0
|
||||
width = cursor - 200.0
|
||||
if rank_map is not None:
|
||||
x = {rank: planned["columns"][rank] for rank in layers}
|
||||
widths = {rank: planned["column_widths"][rank] for rank in layers}
|
||||
height = max(y[k] + items[k]["h"] for k in items)
|
||||
result = dict(members=members, w=width, h=height, positions={}, routes={}, inputs={}, outputs={})
|
||||
for k, item in items.items():
|
||||
ox, oy = x[ranks[k]], y[k]
|
||||
result["positions"].update({i: (ox + px, oy + py) for i, (px, py) in item["positions"].items()})
|
||||
for e, path in item["routes"].items():
|
||||
result["routes"].setdefault(e, []).append([(ox + px, oy + py) for px, py in path])
|
||||
for e, u, v, sy, ty, weight in links:
|
||||
path = []
|
||||
chain = chains[e]
|
||||
for j, (a, b) in enumerate(zip(chain, chain[1:])):
|
||||
first_y = y[a] + (sy if j == 0 else items[a]["h"] / 2)
|
||||
last_y = y[b] + (ty if j == len(chain) - 2 else items[b]["h"] / 2)
|
||||
first_x = x[ranks[a]] + items[a]["w"]
|
||||
last_x = x[ranks[b]]
|
||||
path.append((first_x, first_y))
|
||||
if items[a]["w"] < widths[ranks[a]]:
|
||||
path.append((x[ranks[a]] + widths[ranks[a]] + 60.0, first_y))
|
||||
path.append((last_x, last_y))
|
||||
result["routes"].setdefault(e, []).append(simplify(path))
|
||||
for e, key in ((e, boundary["in", e]) for e in incoming):
|
||||
result["inputs"][e] = (0.0, y[key] + 16.0)
|
||||
for e, key in ((e, boundary["out", e]) for e in outgoing):
|
||||
result["outputs"][e] = (width, y[key] + 16.0)
|
||||
for e, pieces in result["routes"].items():
|
||||
pieces.sort(key=lambda path: path[0][0])
|
||||
result["routes"][e] = simplify([point for path in pieces for point in path])
|
||||
result["ranks"] = {i: ranks[k] for i, k in owner.items()}
|
||||
result["columns"] = x
|
||||
result["column_widths"] = widths
|
||||
return result
|
||||
|
||||
planned = plan_scope([leaf(i) for i in range(len(nodes))])
|
||||
seed = planned["positions"]
|
||||
for block in blocks:
|
||||
local = plan_scope([leaf(i) for i in block["members"]], rank_map=planned["ranks"])
|
||||
preferred = (
|
||||
sum(seed[i][1] + size[i].height / 2 for i in block["members"]) / len(block["members"])
|
||||
- local["h"] / 2
|
||||
)
|
||||
seed.update({i: (x, y + preferred) for i, (x, y) in local["positions"].items()})
|
||||
planned["routes"].update(
|
||||
{e: [(x, y + preferred) for x, y in path] for e, path in local["routes"].items()}
|
||||
)
|
||||
atoms = []
|
||||
node_atom = {}
|
||||
for members in [b["members"] for b in blocks] + [[i] for i in sorted(core)]:
|
||||
member_set = set(members)
|
||||
internal = [e for e, (a, b) in enumerate(edge_ids) if a in member_set and b in member_set]
|
||||
left, top, right, bottom = bounds({i: seed[i] for i in members})
|
||||
for e in internal:
|
||||
top = min(top, *(p[1] for p in planned["routes"][e]))
|
||||
bottom = max(bottom, *(p[1] for p in planned["routes"][e]))
|
||||
k = len(atoms)
|
||||
atoms.append(
|
||||
dict(
|
||||
members=member_set,
|
||||
x=left,
|
||||
w=right - left,
|
||||
h=bottom - top,
|
||||
preferred=top,
|
||||
internal=internal,
|
||||
bus=False,
|
||||
)
|
||||
)
|
||||
node_atom.update({i: k for i in members})
|
||||
|
||||
external = [e for e, (a, b) in enumerate(edge_ids) if node_atom[a] != node_atom[b]]
|
||||
buses = {}
|
||||
edge_bus = {}
|
||||
for e in external:
|
||||
a, b = edge_ids[e]
|
||||
ra, rb = planned["ranks"][a], planned["ranks"][b]
|
||||
if rb == ra + 1:
|
||||
continue
|
||||
key = source_keys[e]
|
||||
if key not in buses:
|
||||
buses[key] = []
|
||||
buses[key].append(e)
|
||||
for key, links in buses.items():
|
||||
a = edge_ids[links[0]][0]
|
||||
ra = planned["ranks"][a]
|
||||
left = planned["columns"][ra + 1] - 60.0
|
||||
right = max(planned["columns"][planned["ranks"][edge_ids[e][1]]] - 60.0 for e in links)
|
||||
ys = [p[1] for e in links for p in planned["routes"][e][1:-1]]
|
||||
k = len(atoms)
|
||||
atoms.append(
|
||||
dict(
|
||||
members=set(),
|
||||
x=left,
|
||||
w=right - left,
|
||||
h=24.0,
|
||||
preferred=median(ys) - 12.0,
|
||||
internal=[],
|
||||
bus=True,
|
||||
)
|
||||
)
|
||||
edge_bus.update({e: k for e in links})
|
||||
|
||||
# Shapes and long-wire reservations are packed in the same interval constraint
|
||||
# graph. No obstacle test or routing result can change these constraints later.
|
||||
atom_order = sorted(range(len(atoms)), key=lambda k: (atoms[k]["preferred"] + atoms[k]["h"] / 2, k))
|
||||
above = defaultdict(list)
|
||||
below = defaultdict(list)
|
||||
for j, a in enumerate(atom_order):
|
||||
for b in atom_order[j + 1 :]:
|
||||
u, v = atoms[a], atoms[b]
|
||||
if u["x"] < v["x"] + v["w"] + 32 and v["x"] < u["x"] + u["w"] + 32:
|
||||
gap = 32.0 if u["bus"] or v["bus"] else 220.0
|
||||
distance = u["h"] + gap
|
||||
above[b].append((a, distance))
|
||||
below[a].append((b, distance))
|
||||
ay = {}
|
||||
for k in atom_order:
|
||||
ay[k] = max([atoms[k]["preferred"], *(ay[a] + distance for a, distance in above[k])])
|
||||
neighbors = defaultdict(list)
|
||||
for e in external:
|
||||
a, b = edge_ids[e]
|
||||
u, v = node_atom[a], node_atom[b]
|
||||
sy = seed[a][1] + size[a].sockets[edges[e].from_socket] - atoms[u]["preferred"]
|
||||
ty = seed[b][1] + size[b].sockets[edges[e].to_socket] - atoms[v]["preferred"]
|
||||
weight = 3.0 if edge_types[e] in {"GEOMETRY", "SHADER"} else 1.0
|
||||
if e in edge_bus:
|
||||
weight *= 0.15
|
||||
sequence = [(u, sy), (v, ty)] if e not in edge_bus else [(u, sy), (edge_bus[e], 12.0), (v, ty)]
|
||||
for (k, ky), (j, jy) in zip(sequence, sequence[1:]):
|
||||
neighbors[k].append((j, ky, jy, weight))
|
||||
neighbors[j].append((k, jy, ky, weight))
|
||||
for sweep in range(80):
|
||||
for k in atom_order if sweep % 2 else reversed(atom_order):
|
||||
adjacent = neighbors[k]
|
||||
preferred = (
|
||||
sum((ay[j] + jy - ky) * w for j, ky, jy, w in adjacent) / sum(w for j, ky, jy, w in adjacent)
|
||||
if adjacent
|
||||
else ay[k]
|
||||
)
|
||||
low = max((ay[j] + d for j, d in above[k]), default=-1e9)
|
||||
high = min((ay[j] - d for j, d in below[k]), default=1e9)
|
||||
ay[k] = max(low, min(high, preferred))
|
||||
shift = min(ay.values())
|
||||
ay = {k: value - shift for k, value in ay.items()}
|
||||
positions = {i: (seed[i][0], seed[i][1] + ay[k] - atoms[k]["preferred"]) for i, k in node_atom.items()}
|
||||
routes = {}
|
||||
for k, atom in enumerate(atoms):
|
||||
for e in atom["internal"]:
|
||||
routes[e] = [(x, y + ay[k] - atom["preferred"]) for x, y in planned["routes"][e]]
|
||||
for e in external:
|
||||
a, b = edge_ids[e]
|
||||
start, end = endpoint(edges[e], positions)
|
||||
ra, rb = planned["ranks"][a], planned["ranks"][b]
|
||||
exit_x = planned["columns"][ra] + planned["column_widths"][ra] + 60.0
|
||||
entry_x = planned["columns"][rb] - 60.0
|
||||
if e in edge_bus:
|
||||
k = edge_bus[e]
|
||||
level = ay[k] + 12.0
|
||||
path = [start, (exit_x, start[1]), (atoms[k]["x"], level), (entry_x, level), end]
|
||||
else:
|
||||
path = (
|
||||
[start, end]
|
||||
if size[a].width == planned["column_widths"][ra]
|
||||
else [start, (exit_x, start[1]), end]
|
||||
)
|
||||
routes[e] = simplify(path)
|
||||
for block in blocks:
|
||||
block["box"] = bounds({i: positions[i] for i in block["members"]})
|
||||
return positions, routes
|
||||
42
tools/nodes/arrangement/zones.py
Normal file
42
tools/nodes/arrangement/zones.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Identify paired execution zones and their exclusive input calculations."""
|
||||
|
||||
from graphlib import TopologicalSorter
|
||||
|
||||
|
||||
def repeat_regions(node_group, positions, sizes):
|
||||
"""Bound repeat execution and calculations without peripheral routing corners."""
|
||||
regions = []
|
||||
for entry, end, members in node_zones(node_group):
|
||||
if entry.bl_idname != "GeometryNodeRepeatInput":
|
||||
continue
|
||||
nodes = {node for node in members if node.bl_idname not in {"NodeReroute", "NodeFrame"}}
|
||||
regions.append((members, (positions[entry][0] - 60,
|
||||
positions[end][0] + sizes[end].width + 60,
|
||||
max(positions[node][1] for node in nodes) + 60,
|
||||
min(positions[node][1] - sizes[node].height for node in nodes) - 40)))
|
||||
return regions
|
||||
|
||||
|
||||
def node_zones(node_group):
|
||||
"""Return each paired entry, exit, and its data-dependent node membership."""
|
||||
parents = {node: set() for node in node_group.nodes}
|
||||
children = {node: set() for node in node_group.nodes}
|
||||
for link in node_group.links:
|
||||
parents[link.to_node].add(link.from_node)
|
||||
children[link.from_node].add(link.to_node)
|
||||
order = list(TopologicalSorter(parents).static_order())
|
||||
ancestors = {}
|
||||
for node in order:
|
||||
ancestors[node] = parents[node] | set().union(*(ancestors[parent] for parent in parents[node]))
|
||||
zones = []
|
||||
for entry in order:
|
||||
end = getattr(entry, "paired_output", None)
|
||||
if end is None:
|
||||
continue
|
||||
members = {node for node in ancestors[end] if entry in ancestors[node]} | {entry, end}
|
||||
for node in reversed(order):
|
||||
if (node not in members and node in ancestors[end] and children[node]
|
||||
and children[node] <= members and entry not in children[node]):
|
||||
members.add(node)
|
||||
zones.append((entry, end, members))
|
||||
return zones
|
||||
49
tools/nodes/build.py
Normal file
49
tools/nodes/build.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Build, arrange, and save the bundled node library."""
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from .arrangement.arrange import arrange_nodes
|
||||
from nodes.groups.image_plane import build_image_plane_group
|
||||
from nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
from nodes.groups.image_cutout import build_image_cutout_group
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
from nodes.groups.image_layer import build_image_layer_group
|
||||
from nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
from nodes.groups.shadeless import build_shadeless_group
|
||||
from nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
|
||||
|
||||
OUTPUT_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
|
||||
|
||||
|
||||
def build_node_groups():
|
||||
"""Build each asset once in dependency order."""
|
||||
image_plane = build_image_plane_group()
|
||||
return [
|
||||
build_image_depth_panorama_group(),
|
||||
image_plane,
|
||||
build_image_depth_plane_group(image_plane),
|
||||
build_image_relief_plane_group(image_plane),
|
||||
build_image_cutout_group(),
|
||||
build_image_depth_cutout_group(),
|
||||
build_image_cutout_symmetry_group(),
|
||||
build_image_layer_group(),
|
||||
build_image_depth_layer_group(),
|
||||
build_shadeless_group(),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
for node_group in build_node_groups():
|
||||
arrange_nodes(node_group)
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
tools/nodes/check.py
Normal file
97
tools/nodes/check.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Reload saved assets and evaluate geometry in the target Blender process."""
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
|
||||
GEOMETRY_NAMES = {
|
||||
"O Image Depth Panorama",
|
||||
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
|
||||
"O Image Cutout", "O Image Depth Cutout", "O Image Cutout Symmetry",
|
||||
}
|
||||
SHADER_NAMES = {"O Image Layer", "O Image Depth Layer", "O Shadeless"}
|
||||
|
||||
|
||||
def check_asset_library(asset_path):
|
||||
"""Check library completeness and finite, nonempty evaluated geometry."""
|
||||
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
|
||||
assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES
|
||||
target.node_groups = sorted(GEOMETRY_NAMES | SHADER_NAMES)
|
||||
pending, checked = list(target.node_groups), set()
|
||||
while pending:
|
||||
group = pending.pop()
|
||||
if group in checked or group.bl_idname != "GeometryNodeTree":
|
||||
continue
|
||||
checked.add(group)
|
||||
for node in group.nodes:
|
||||
assert node.bl_idname != "GeometryNodeCaptureAttribute", group.name
|
||||
if node.bl_idname == "GeometryNodeGroup" and node.node_tree:
|
||||
pending.append(node.node_tree)
|
||||
depth_image = bpy.data.images.new("Asset Check Depth", width=8, height=8, float_buffer=True)
|
||||
depth_image.colorspace_settings.name = "Non-Color"
|
||||
depth_image.pixels.foreach_set([
|
||||
value for y in range(8) for x in range(8)
|
||||
for value in ((x + 0.5) / 8 - 0.5, (y + 0.5) / 8 - 0.5, 2.0, 1.0)
|
||||
])
|
||||
for node_group in target.node_groups:
|
||||
expected_type = "GeometryNodeTree" if node_group.name in GEOMETRY_NAMES else "ShaderNodeTree"
|
||||
assert node_group.bl_idname == expected_type and node_group.library is not None, node_group.name
|
||||
if expected_type == "ShaderNodeTree":
|
||||
continue
|
||||
mesh = bpy.data.meshes.new("Asset Check Plane")
|
||||
mesh.from_pydata(
|
||||
[(x / 3 - 0.5, 0, y / 3 - 0.5) for y in range(4) for x in range(4)], [],
|
||||
[(y * 4 + x, y * 4 + x + 1, (y + 1) * 4 + x + 1, (y + 1) * 4 + x)
|
||||
for y in range(3) for x in range(3)],
|
||||
)
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for loop in mesh.loops:
|
||||
position = mesh.vertices[loop.vertex_index].co
|
||||
uv.data[loop.index].uv = (position.x + 0.5, position.z + 0.5)
|
||||
profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT")
|
||||
for value in profile.data:
|
||||
value.value = 0.2
|
||||
obj = bpy.data.objects.new("Asset Check", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("Asset Check", "NODES")
|
||||
modifier.node_group = node_group
|
||||
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
|
||||
"Depth Image": depth_image, "Uniform Scale": 1.0, "Depth Split": 0.0}
|
||||
if node_group.name == "O Image Cutout Symmetry":
|
||||
values["Offset"] = 0.5
|
||||
for item in node_group.interface.items_tree:
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name in values:
|
||||
modifier[item.identifier] = values[item.name]
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(result.vertices) and len(result.polygons), node_group.name
|
||||
assert all(isfinite(value) for vertex in result.vertices for value in vertex.co), node_group.name
|
||||
assert not list(modifier.node_warnings), (
|
||||
node_group.name, [warning.message for warning in modifier.node_warnings],
|
||||
)
|
||||
assert not any(
|
||||
"o_anyimage" in attribute.name for attribute in result.attributes
|
||||
), node_group.name
|
||||
assert not any(
|
||||
attribute.name.startswith("_o_") for attribute in result.attributes
|
||||
), node_group.name
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
bpy.data.meshes.remove(mesh)
|
||||
bpy.data.images.remove(depth_image)
|
||||
print(f"Checked {len(target.node_groups)} saved node groups in Blender {bpy.app.version_string}")
|
||||
|
||||
|
||||
def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
check_asset_library(ASSET_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
tools/nodes/cli.py
Normal file
67
tools/nodes/cli.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import argparse
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from tempfile import mkdtemp
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def run(command):
|
||||
print("+", subprocess.list2cmdline([str(part) for part in command]), flush=True)
|
||||
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
|
||||
def require_bpy():
|
||||
if importlib.util.find_spec("bpy") is None:
|
||||
raise RuntimeError(
|
||||
"bpy is required; run with: uv run --group blender node-group ..."
|
||||
)
|
||||
|
||||
|
||||
def run_module(module_name, arguments=()):
|
||||
run((sys.executable, "-m", module_name, *arguments))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build, check, or preview node groups.")
|
||||
commands = parser.add_subparsers(dest="command")
|
||||
for name, help_text in (
|
||||
("build", "Build assets, validate them, and run tests"),
|
||||
("check", "Validate saved assets and run tests"),
|
||||
("preview", "Preview node groups from current source"),
|
||||
):
|
||||
command = commands.add_parser(name, help=help_text)
|
||||
if name == "preview":
|
||||
command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory")
|
||||
command.add_argument("--no-open", action="store_true", help="Save without opening a browser")
|
||||
command.add_argument("--fragment", action="store_true", help="Save an HTML fragment without opening it")
|
||||
else:
|
||||
command.add_argument("--skip-tests", action="store_true", help="Skip pytest, keeping asset validation")
|
||||
options = parser.parse_args()
|
||||
if options.command is None:
|
||||
parser.print_help()
|
||||
return
|
||||
require_bpy()
|
||||
if options.command == "preview":
|
||||
output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve()
|
||||
arguments = ["--output", str(output)]
|
||||
if options.fragment:
|
||||
arguments.append("--fragment")
|
||||
run_module("tools.nodes.preview.export", arguments)
|
||||
print(output)
|
||||
if not options.no_open and not options.fragment:
|
||||
webbrowser.open(output.as_uri())
|
||||
return
|
||||
modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",)
|
||||
for module_name in modules:
|
||||
run_module(module_name)
|
||||
if not options.skip_tests:
|
||||
run((sys.executable, "-m", "pytest", "tests/nodes", "tests/tools/nodes"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
tools/nodes/preview/__init__.py
Normal file
1
tools/nodes/preview/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
140
tools/nodes/preview/export.py
Normal file
140
tools/nodes/preview/export.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Preview node groups with the socket dimension model in a self-contained HTML file."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
from ..arrangement.dimensions import estimate_node_dimensions
|
||||
from ..arrangement.flow import node_roles
|
||||
from ..arrangement.connections import link_endpoints
|
||||
from ..arrangement.zones import node_zones, repeat_regions
|
||||
|
||||
|
||||
def _display_value(value):
|
||||
if isinstance(value, bpy.types.ID):
|
||||
return value.name
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
return str(value)
|
||||
if isinstance(value, (int, float)):
|
||||
return str(round(value, 4))
|
||||
if value is None:
|
||||
return ""
|
||||
try:
|
||||
return ", ".join(str(round(x, 3)) for x in value)
|
||||
except TypeError:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _node_group_data(node_group):
|
||||
nodes = [node for node in node_group.nodes if node.bl_idname != "NodeFrame"]
|
||||
ids = {node: index for index, node in enumerate(nodes)}
|
||||
dimensions = {node: estimate_node_dimensions(node) for node in nodes}
|
||||
roles = node_roles(node_group)
|
||||
records = []
|
||||
for node in nodes:
|
||||
size = dimensions[node]
|
||||
settings = []
|
||||
for name in ("operation", "data_type", "input_type", "domain", "mode", "pattern_mode"):
|
||||
if not hasattr(node, name):
|
||||
continue
|
||||
value = getattr(node, name)
|
||||
prop = node.bl_rna.properties[name]
|
||||
label = prop.enum_items[value].name if prop.type == "ENUM" and value in prop.enum_items else str(value)
|
||||
settings.append(label)
|
||||
title = node.bl_label
|
||||
if node.bl_idname in {"GeometryNodeGroup", "ShaderNodeGroup"}:
|
||||
title = node.node_tree.name
|
||||
elif hasattr(node, "operation") and settings:
|
||||
title = settings[0]
|
||||
sockets = []
|
||||
for socket, offset in size.sockets.items():
|
||||
default = _display_value(socket.default_value) if not socket.is_linked and hasattr(socket, "default_value") else ""
|
||||
sockets.append([socket.name, round(offset, 2), int(socket.is_output), socket.type, default])
|
||||
records.append({"id": ids[node], "name": node.name, "title": title,
|
||||
"type": node.bl_idname, "role": roles[node],
|
||||
"x": round(node.location_absolute.x, 2), "y": round(-node.location_absolute.y, 2),
|
||||
"w": size.width, "h": size.height, "folded": bool(node.hide),
|
||||
"settings": settings, "sockets": sockets})
|
||||
links = []
|
||||
positions = {node: tuple(node.location_absolute) for node in nodes}
|
||||
for link in node_group.links:
|
||||
source, target = link.from_node, link.to_node
|
||||
(x1, y1), (x2, y2) = link_endpoints(link, positions, dimensions)
|
||||
links.append([ids[source], ids[target], *[round(v, 2) for v in (x1, -y1, x2, -y2)], link.from_socket.type])
|
||||
zones = []
|
||||
repeats = {frozenset(members): box for members, box in repeat_regions(node_group, positions, dimensions)}
|
||||
for entry, end, members in node_zones(node_group):
|
||||
if entry.bl_idname != "GeometryNodeRepeatInput":
|
||||
members = {node for node in members if roles[node] == "geometry"} | {entry, end}
|
||||
boxes = [records[ids[node]] for node in members]
|
||||
padding = 60 if entry.bl_idname == "GeometryNodeRepeatInput" else 40
|
||||
left = min(node["x"] for node in boxes) - padding
|
||||
top = min(node["y"] for node in boxes) - 60
|
||||
right = max(node["x"] + node["w"] for node in boxes) + padding
|
||||
bottom = max(node["y"] + node["h"] for node in boxes) + 40
|
||||
if entry.bl_idname == "GeometryNodeRepeatInput":
|
||||
left, right, upper, lower = repeats[frozenset(members)]
|
||||
top, bottom = -upper, -lower
|
||||
zones.append({"title": entry.bl_label.removesuffix(" Input"),
|
||||
"type": entry.bl_idname, "entry": ids[entry], "exit": ids[end],
|
||||
"nodes": sorted(ids[node] for node in members),
|
||||
"x": left, "y": top, "w": right - left, "h": bottom - top})
|
||||
zones.sort(key=lambda zone: zone["w"] * zone["h"], reverse=True)
|
||||
frames = [{"title": node.label or node.name, "x": node.location_absolute.x,
|
||||
"y": -node.location_absolute.y, "w": node.width, "h": node.height,
|
||||
"nodes": [ids[child] for child in nodes if child.parent == node]}
|
||||
for node in node_group.nodes if node.bl_idname == "NodeFrame"]
|
||||
return {"name": node_group.name, "nodes": records, "links": links, "zones": zones, "frames": frames}
|
||||
|
||||
|
||||
def write_node_preview(node_groups, output_path, *, fragment=False):
|
||||
"""Write an interactive preview without changing the supplied node groups."""
|
||||
data = [_node_group_data(node_group) for node_group in node_groups]
|
||||
if not data or any(not node_group["nodes"] for node_group in data):
|
||||
raise ValueError("The preview requires nonempty node groups")
|
||||
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c")
|
||||
markup = Path(__file__).with_name("template.html").read_text(encoding="utf-8").replace("__NODE_DATA__", payload)
|
||||
if not fragment:
|
||||
markup = """<!doctype html>
|
||||
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>节点排列预览</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --background: #191919; --foreground: #ededed;
|
||||
--muted-foreground: #a0a0a0; --border: #404040; }
|
||||
body { margin: 24px; background: var(--background); color: var(--foreground); font: 14px system-ui,sans-serif; }
|
||||
h3 { font-size: 16px; font-weight: 500; }
|
||||
.viz-controls,.viz-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 8px 0; }
|
||||
.form-label { display: flex; align-items: center; gap: 8px; }
|
||||
button,select { padding: 8px; font: inherit; max-width: 100%; }
|
||||
.text-small { font-size: 12px; }
|
||||
@media(max-width:440px) { body { margin: 12px; } .form-label { width: 100%; } select { flex: 1; min-width: 0; } }
|
||||
</style><body>
|
||||
""" + markup + "\n</body></html>"
|
||||
output_path = Path(output_path).resolve()
|
||||
output_path.write_text(markup, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
"""Build and arrange node assets in memory for preview."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True, help="Output HTML path")
|
||||
parser.add_argument("--fragment", action="store_true", help="Write a fragment for an inline preview")
|
||||
args = parser.parse_args()
|
||||
output_path = args.output.resolve()
|
||||
from ..build import build_node_groups
|
||||
from ..arrangement.arrange import arrange_nodes
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
node_groups = build_node_groups()
|
||||
for node_group in node_groups:
|
||||
arrange_nodes(node_group)
|
||||
print(write_node_preview(node_groups, output_path, fragment=args.fragment))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
303
tools/nodes/preview/template.html
Normal file
303
tools/nodes/preview/template.html
Normal file
@@ -0,0 +1,303 @@
|
||||
<div id="node-layout-results">
|
||||
<h3>节点排列 · 尺寸模型预览</h3>
|
||||
<div class="viz-controls">
|
||||
<label class="form-label">节点组
|
||||
<select class="form-select" id="layout-group"></select>
|
||||
</label>
|
||||
<label class="form-label">视野
|
||||
<select class="form-select" id="layout-view">
|
||||
<option value="all">完整排列</option>
|
||||
<option value="input">起点输入附近</option>
|
||||
<option value="middle">中段计算局部</option>
|
||||
<option value="join">Join Geometry 附近</option>
|
||||
<option value="menu">Menu Switch 附近</option>
|
||||
<option value="combine">Combine XYZ 附近</option>
|
||||
<option value="boolean">Boolean Math 附近</option>
|
||||
<option value="sample">Sample Index 附近</option>
|
||||
<option value="zone">成对节点内部范围</option>
|
||||
<option value="output">最终输出附近</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="viz-row">
|
||||
<button class="btn" type="button" id="layout-out" aria-label="缩小">−</button>
|
||||
<button class="btn" type="button" id="layout-in" aria-label="放大">+</button>
|
||||
<span class="text-small tabular-nums" id="layout-scale"></span>
|
||||
<span class="text-small">拖动平移 · 滚轮缩放 · 点击节点查看名称</span>
|
||||
</div>
|
||||
<canvas id="layout-canvas" role="img" aria-label="按实际排列坐标绘制的节点组,尺寸和曲线使用估算模型"></canvas>
|
||||
<div class="viz-row text-small" id="layout-legend">
|
||||
<span><i class="geometry-key"></i>几何与着色连接</span>
|
||||
<span><i class="field-key"></i>字段与参数连接</span>
|
||||
<span id="layout-count"></span>
|
||||
</div>
|
||||
<div class="text-small" id="layout-detail" aria-live="polite">完整排列</div>
|
||||
</div>
|
||||
<style>
|
||||
#node-layout-results { --geometry-color: #00d6a3; --field-color: #535353; --node-body: #282828; --node-text: #ededed; --zone-color: #b986d3; }
|
||||
#node-layout-results canvas { display: block; width: 100%; height: 550px; touch-action: none; cursor: grab; margin: 8px 0; }
|
||||
#node-layout-results canvas:active { cursor: grabbing; }
|
||||
#node-layout-results .geometry-key, #node-layout-results .field-key { display: inline-block; width: 18px; height: 2px; vertical-align: middle; margin-right: 5px; }
|
||||
#node-layout-results .geometry-key { background: var(--geometry-color); }
|
||||
#node-layout-results .field-key { background: var(--muted-foreground); }
|
||||
#node-layout-results #layout-detail { min-height: 2em; overflow-wrap: anywhere; }
|
||||
@media (max-width: 440px) { #node-layout-results canvas { height: 450px; } }
|
||||
</style>
|
||||
<script type="application/json" id="layout-data">__NODE_DATA__</script>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById('node-layout-results');
|
||||
const data = JSON.parse(document.getElementById('layout-data').textContent);
|
||||
const canvas = root.querySelector('#layout-canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
const picker = root.querySelector('#layout-group');
|
||||
const view = root.querySelector('#layout-view');
|
||||
const detail = root.querySelector('#layout-detail');
|
||||
let current = data[0], selected = null, scale = 1, dx = 0, dy = 0, width = 0, height = 0;
|
||||
let dragging = null, moved = false;
|
||||
data.forEach((group, index) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = index; option.textContent = group.name; picker.appendChild(option);
|
||||
});
|
||||
const probe = document.createElement('span');
|
||||
probe.hidden = true; root.appendChild(probe);
|
||||
function color(token) {
|
||||
probe.style.color = `var(${token})`;
|
||||
return getComputedStyle(probe).color;
|
||||
}
|
||||
function bounds(nodes) {
|
||||
return [Math.min(...nodes.map(n => n.x)), Math.min(...nodes.map(n => n.y)),
|
||||
Math.max(...nodes.map(n => n.x + n.w)), Math.max(...nodes.map(n => n.y + n.h))];
|
||||
}
|
||||
function fit(box) {
|
||||
const [left, top, right, bottom] = box;
|
||||
scale = Math.min((width - 36) / (right - left), (height - 36) / (bottom - top));
|
||||
dx = (width - (right + left) * scale) / 2;
|
||||
dy = (height - (bottom + top) * scale) / 2;
|
||||
draw();
|
||||
}
|
||||
function updateRegionViews() {
|
||||
view.querySelectorAll('[data-zone], [data-frame]').forEach(option => option.remove());
|
||||
current.zones.forEach(zone => {
|
||||
const option = document.createElement('option');
|
||||
option.value = `zone:${zone.entry}`; option.dataset.zone = 'true';
|
||||
option.textContent = `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`;
|
||||
view.appendChild(option);
|
||||
});
|
||||
current.frames.forEach((frame, index) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = `frame:${index}`; option.dataset.frame = 'true';
|
||||
option.textContent = `Frame · ${frame.title}`;
|
||||
view.appendChild(option);
|
||||
});
|
||||
}
|
||||
function changeView() {
|
||||
selected = null;
|
||||
if (view.value === 'all') {
|
||||
fit(bounds([...current.nodes, ...current.zones, ...current.frames]));
|
||||
detail.textContent = '完整排列';
|
||||
return;
|
||||
}
|
||||
if (view.value.startsWith('frame:')) {
|
||||
const frame = current.frames[Number(view.value.split(':')[1])];
|
||||
fit(bounds([frame])); detail.textContent = frame.title;
|
||||
return;
|
||||
}
|
||||
if (view.value === 'zone' || view.value.startsWith('zone:')) {
|
||||
if (current.zones.length) {
|
||||
const zones = view.value === 'zone' ? current.zones : current.zones.filter(zone => `zone:${zone.entry}` === view.value);
|
||||
fit(bounds(zones)); detail.textContent = zones.map(zone => `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`).join(' · ');
|
||||
} else {
|
||||
fit(bounds(current.nodes)); detail.textContent = `${current.name} · 当前组无成对节点`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (view.value === 'middle') {
|
||||
const mainNodes = current.nodes.filter(n => n.type !== 'NodeReroute' && n.role !== 'field');
|
||||
const midX = (Math.min(...mainNodes.map(n => n.x)) + Math.max(...mainNodes.map(n => n.x))) / 2;
|
||||
const target = mainNodes.sort((a, b) => Math.abs(a.x - midX) - Math.abs(b.x - midX))[0];
|
||||
fit([target.x - 850, target.y - 560, target.x + 670, target.y + 600]);
|
||||
detail.textContent = `${current.name} · 中段计算局部`;
|
||||
return;
|
||||
}
|
||||
if (view.value === 'input') {
|
||||
const target = current.nodes.filter(n => n.role !== 'field' && n.type !== 'NodeReroute').sort((a, b) => a.x - b.x)[0];
|
||||
fit([target.x - 850, target.y - 650, target.x + 750, target.y + 650]);
|
||||
detail.textContent = `${current.name} · 起点输入附近`; return;
|
||||
}
|
||||
const types = {join: 'GeometryNodeJoinGeometry', menu: 'GeometryNodeMenuSwitch', combine: 'ShaderNodeCombineXYZ', boolean: 'FunctionNodeBooleanMath', sample: 'GeometryNodeSampleIndex', output: 'NodeGroupOutput'};
|
||||
let candidates = current.nodes.filter(n => n.type === types[view.value]);
|
||||
if (view.value === 'menu') {
|
||||
const geometryMenus = candidates.filter(n => n.sockets.some(s => s[2] && s[3] === 'GEOMETRY'));
|
||||
if (geometryMenus.length) candidates = geometryMenus;
|
||||
}
|
||||
if (!candidates.length) {
|
||||
fit(bounds(current.nodes)); detail.textContent = `${current.name} · 当前组无此类节点`; return;
|
||||
}
|
||||
const target = candidates.sort((a, b) => b.x - a.x)[0] || current.nodes.at(-1);
|
||||
fit([target.x - 1100, target.y - 560, target.x + 420, target.y + 600]);
|
||||
detail.textContent = `${target.name} · ${target.title}`;
|
||||
}
|
||||
function label(text, x, y, maxWidth, align = 'left') {
|
||||
if (maxWidth < 24) return;
|
||||
let value = String(text);
|
||||
if (context.measureText(value).width > maxWidth) {
|
||||
while (value.length && context.measureText(value + '…').width > maxWidth) value = value.slice(0, -1);
|
||||
value += '…';
|
||||
}
|
||||
context.textAlign = align;
|
||||
context.fillText(value, x, y);
|
||||
}
|
||||
function draw() {
|
||||
if (!width || !height) return;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
const foreground = color('--foreground');
|
||||
const border = color('--border'), fields = color('--muted-foreground');
|
||||
const geometry = color('--geometry-color'), other = color('--field-color');
|
||||
const font = getComputedStyle(root).fontFamily;
|
||||
const size = Math.max(11, parseFloat(getComputedStyle(root).fontSize) * 0.86);
|
||||
context.font = `400 ${size}px ${font}`;
|
||||
context.textBaseline = 'middle';
|
||||
context.strokeStyle = border; context.lineWidth = 1;
|
||||
context.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||
context.save(); context.beginPath(); context.rect(1, 1, width - 2, height - 2); context.clip();
|
||||
for (const frame of current.frames) {
|
||||
const x = frame.x * scale + dx, y = frame.y * scale + dy;
|
||||
const w = frame.w * scale, h = frame.h * scale;
|
||||
context.fillStyle = '#78909c'; context.globalAlpha = 0.06;
|
||||
context.fillRect(x, y, w, h);
|
||||
context.globalAlpha = 0.5; context.strokeStyle = '#78909c'; context.lineWidth = 1;
|
||||
context.strokeRect(x, y, w, h); context.globalAlpha = 1;
|
||||
if (w > 90 && scale >= 0.15) {
|
||||
context.fillStyle = foreground; label(frame.title, x + 8, y + Math.max(size, 24 * scale), w - 16);
|
||||
}
|
||||
}
|
||||
for (const zone of current.zones) {
|
||||
const x = zone.x * scale + dx, y = zone.y * scale + dy;
|
||||
const w = zone.w * scale, h = zone.h * scale;
|
||||
context.fillStyle = color('--zone-color'); context.globalAlpha = 0.065;
|
||||
const heading = w > 90 && h > size + 12 ? size + 4 : 0;
|
||||
context.beginPath(); context.roundRect(x, y - heading, w, h + heading, Math.min(24 * scale, 12)); context.fill();
|
||||
context.globalAlpha = 0.55; context.strokeStyle = color('--zone-color'); context.lineWidth = 1; context.stroke();
|
||||
context.globalAlpha = 1;
|
||||
if (heading) {
|
||||
context.fillStyle = foreground; label(zone.title, x + 8, y - heading / 2, w - 16);
|
||||
}
|
||||
}
|
||||
for (const edge of current.links) {
|
||||
const [source, target, wx1, wy1, wx2, wy2, type] = edge;
|
||||
const x1 = wx1 * scale + dx, y1 = wy1 * scale + dy;
|
||||
const x2 = wx2 * scale + dx, y2 = wy2 * scale + dy;
|
||||
const bend = Math.max(40, Math.abs(wx2 - wx1) * 0.42) * scale;
|
||||
const active = selected === source || selected === target;
|
||||
context.globalAlpha = selected !== null && !active ? 0.18 : ['GEOMETRY', 'SHADER'].includes(type) ? 0.85 : 0.46;
|
||||
context.strokeStyle = ['GEOMETRY', 'SHADER'].includes(type) ? geometry : fields;
|
||||
context.lineWidth = active ? 2.5 : ['GEOMETRY', 'SHADER'].includes(type) ? 1.35 : 0.8;
|
||||
context.beginPath(); context.moveTo(x1, y1);
|
||||
context.bezierCurveTo(x1 + bend, y1, x2 - bend, y2, x2, y2); context.stroke();
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
for (const node of current.nodes) {
|
||||
const x = node.x * scale + dx, y = node.y * scale + dy, w = node.w * scale, h = node.h * scale;
|
||||
if (x + w < 0 || x > width || y + h < 0 || y > height) continue;
|
||||
const mainNode = node.type === 'NodeReroute'
|
||||
? node.sockets.some(socket => ['GEOMETRY', 'SHADER'].includes(socket[3])) : node.role !== 'field';
|
||||
const hue = mainNode ? geometry : other;
|
||||
if (node.type === 'NodeReroute') {
|
||||
context.fillStyle = hue; context.beginPath();
|
||||
context.arc(x + w / 2, y + h / 2, Math.max(1.5, w / 3), 0, Math.PI * 2); context.fill(); continue;
|
||||
}
|
||||
context.fillStyle = color('--node-body');
|
||||
context.beginPath(); context.roundRect(x, y, w, h, Math.min(node.folded ? h / 2 : 5, 5)); context.fill();
|
||||
context.fillStyle = hue; context.globalAlpha = 0.11; context.fill(); context.globalAlpha = 1;
|
||||
context.strokeStyle = selected === node.id ? foreground : border;
|
||||
context.lineWidth = selected === node.id ? 2 : 0.8; context.stroke();
|
||||
if (!node.folded) {
|
||||
context.fillStyle = hue; context.globalAlpha = 0.26;
|
||||
context.fillRect(x + 0.5, y + 0.5, Math.max(0, w - 1), Math.min(32 * scale, h - 1));
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
if (w >= 78 && h >= size + 5) {
|
||||
context.save(); context.beginPath(); context.rect(x + 3, y, w - 6, h); context.clip();
|
||||
context.fillStyle = color('--node-text');
|
||||
label(node.title, x + 7, node.folded ? y + h / 2 : y + Math.max(size / 2 + 3, 16 * scale), w - 14);
|
||||
if (!node.folded && scale >= 0.52) {
|
||||
for (const [name, offset, output, type, value] of node.sockets) {
|
||||
context.fillStyle = color('--node-text');
|
||||
label(value ? `${name}: ${value}` : name, output ? x + w - 8 : x + 8,
|
||||
y + offset * scale, w - 16, output ? 'right' : 'left');
|
||||
}
|
||||
const outputs = node.sockets.filter(s => s[2]);
|
||||
const firstInput = node.sockets.find(s => !s[2]);
|
||||
let row = (outputs.length ? Math.max(...outputs.map(s => s[1])) + 26 : 56);
|
||||
for (const setting of node.settings) {
|
||||
if (firstInput && row > firstInput[1] - 24) break;
|
||||
context.fillStyle = fields; label(setting, x + 8, y + row * scale, w - 16); row += 24;
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
if (scale >= 0.22) {
|
||||
for (const socket of node.sockets) {
|
||||
context.fillStyle = ['GEOMETRY', 'SHADER'].includes(socket[3]) ? geometry : fields;
|
||||
context.beginPath(); context.arc(socket[2] ? x + w : x, y + socket[1] * scale,
|
||||
Math.max(1.4, 3.6 * scale), 0, Math.PI * 2); context.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
root.querySelector('#layout-scale').textContent = `${Math.round(scale * 100)}%`;
|
||||
root.querySelector('#layout-count').textContent = `${current.nodes.length} 节点 · ${current.links.length} 连接`;
|
||||
canvas.setAttribute('aria-label', `${current.name},${current.nodes.length} 个节点,${current.links.length} 条连接。几何路径与字段支线按函数输出坐标绘制。`);
|
||||
}
|
||||
function zoom(factor, x = width / 2, y = height / 2) {
|
||||
const next = Math.max(0.01, Math.min(2.5, scale * factor));
|
||||
dx = x - (x - dx) * next / scale; dy = y - (y - dy) * next / scale; scale = next; draw();
|
||||
}
|
||||
picker.addEventListener('change', () => {
|
||||
if (view.value.startsWith('zone:')) view.value = 'zone';
|
||||
current = data[Number(picker.value)]; updateRegionViews();
|
||||
if (!view.value) view.value = 'all';
|
||||
changeView();
|
||||
});
|
||||
view.addEventListener('change', changeView);
|
||||
root.querySelector('#layout-in').addEventListener('click', () => zoom(1.5));
|
||||
root.querySelector('#layout-out').addEventListener('click', () => zoom(1 / 1.5));
|
||||
canvas.addEventListener('wheel', event => {
|
||||
event.preventDefault(); const rect = canvas.getBoundingClientRect();
|
||||
zoom(Math.exp(-event.deltaY * 0.0015), event.clientX - rect.left, event.clientY - rect.top);
|
||||
}, { passive: false });
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
dragging = [event.clientX, event.clientY, dx, dy]; moved = false;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
canvas.addEventListener('pointermove', event => {
|
||||
if (!dragging) return;
|
||||
const x = event.clientX - dragging[0], y = event.clientY - dragging[1];
|
||||
if (Math.abs(x) + Math.abs(y) > 4) moved = true;
|
||||
dx = dragging[2] + x; dy = dragging[3] + y; draw();
|
||||
});
|
||||
canvas.addEventListener('pointerup', event => {
|
||||
if (!moved) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = (event.clientX - rect.left - dx) / scale, y = (event.clientY - rect.top - dy) / scale;
|
||||
const node = current.nodes.findLast(n => x >= n.x && x <= n.x + n.w && y >= n.y && y <= n.y + n.h);
|
||||
selected = node ? node.id : null;
|
||||
detail.textContent = node ? `${node.name} · ${node.title} · ${node.role !== 'field' ? '结果主线' : '计算或输入'}${node.settings.length ? ' · ' + node.settings.join(' / ') : ''}` : current.name;
|
||||
draw();
|
||||
}
|
||||
dragging = null;
|
||||
});
|
||||
canvas.addEventListener('pointercancel', () => { dragging = null; });
|
||||
new ResizeObserver(() => {
|
||||
const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); changeView();
|
||||
}).observe(canvas);
|
||||
new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
|
||||
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
|
||||
updateRegionViews();
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user