Implement arrangement framework for node groups

- Introduced `frames.py` to manage field complex calculations and their arrangement within node groups.
- Added functionality to create and remove field frames based on node arrangements.
- Developed `passages.py` to find unobstructed paths for connections around calculation frames.
- Implemented `references.py` to route shared results on peripheral tracks, ensuring efficient distribution of outputs.
- Created `sections.py` to reserve space for field calculations adjacent to geometry consumers.
- Added `sources.py` to position shared calculation sources effectively beside their distribution channels.
- Enhanced outline smoothing with `outline_smoothing.py` to adapt camera depth along original outlines.
This commit is contained in:
2026-09-12 12:19:07 +08:00
parent dd32ccdc44
commit 5b2dc59f79
57 changed files with 2253 additions and 700 deletions

View File

@@ -44,7 +44,7 @@ def _value(value):
def _program(node_group):
nodes = {}
for node in node_group.nodes:
if node.bl_idname in {"NodeReroute", "NodeGroupInput"}:
if node.bl_idname in {"NodeReroute", "NodeGroupInput", "NodeFrame"}:
continue
settings = {
name: _value(getattr(node, name))
@@ -129,6 +129,8 @@ def _surface(node_group):
def test_arrangement_preserves_each_asset_program(node_group):
from tools.nodes.arrangement.connections import curve_footprint, link_endpoints
program = _program(node_group)
evaluate = _surface(node_group)
geometry = evaluate()
@@ -138,27 +140,67 @@ def test_arrangement_preserves_each_asset_program(node_group):
for node in node_group.nodes:
if node.bl_idname == "NodeGroupInput":
targets = {link.to_node for socket in node.outputs for link in socket.links}
assert len(targets) == 1
while ports := {target for target in targets if target.bl_idname == "NodeReroute"}:
targets.difference_update(ports)
targets.update(link.to_node for port in ports for link in port.outputs[0].links)
assert len(targets) == 1, node.name
assert all(socket.hide for socket in node.outputs if not socket.is_linked)
for (before_points, before_faces), (after_points, after_faces) in zip(geometry, evaluate()):
np.testing.assert_allclose(after_points, before_points, atol=1e-6)
assert after_faces == before_faces
positions = {
node.name: tuple(node.location) for node in node_group.nodes
node.name: tuple(node.location_absolute) for node in node_group.nodes
if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}
}
arrange_nodes(node_group)
assert _program(node_group) == program
assert {name: tuple(node_group.nodes[name].location) for name in positions} == positions
assert {name: tuple(node_group.nodes[name].location_absolute) for name in positions} == positions
locations = {node: tuple(node.location_absolute) for node in node_group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
tracks = []
for link in node_group.links:
if (link.from_socket.type != "GEOMETRY" and link.from_node.bl_idname != "NodeReroute"
and link.to_node.get("arrangement_reference")):
start, end = link_endpoints(link, locations, sizes)
assert end[0] >= start[0], (link.from_node.name, link.to_node.name)
if link.from_socket.type != "GEOMETRY":
continue
start, end = link_endpoints(link, locations, sizes)
consumers, pending = set(), [link.to_node]
while pending:
node = pending.pop()
if node in consumers:
continue
consumers.add(node)
if node.bl_idname == "NodeReroute":
pending.extend(edge.to_node for edge in node.outputs[0].links)
allowed = {origin_socket(link.from_socket).node.parent, *(node.parent for node in consumers)}
footprint = curve_footprint(start, end)
for frame in node_group.nodes:
if frame.bl_idname != "NodeFrame" or frame in allowed:
continue
x, y = locations[frame]
assert not any(left < x + frame.width - 0.01 and right > x + 0.01
and top > y - frame.height + 0.01 and bottom < y - 0.01
for left, right, top, bottom in footprint), (link.from_node.name, link.to_node.name, frame.name)
if abs(start[1] - end[1]) < 0.01:
assert end[0] - start[0] >= 20, (link.from_node.name, link.to_node.name)
tracks.append((start, end))
for index, (start, end) in enumerate(tracks):
for other_start, other_end in tracks[index + 1:]:
assert (abs(start[1] - other_start[1]) >= 1
or min(end[0], other_end[0]) - max(start[0], other_start[0]) <= 1)
for node in node_group.nodes:
sources = [link.from_socket for link in peer_links(node)]
if len(sources) < 2:
continue
levels = [socket.node.location.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources]
# Measure branch geometry rather than obstacle-avoidance waypoints.
sources = [origin_socket(socket) if socket.node.get("arrangement_reference") else socket for socket in sources]
levels = [socket.node.location_absolute.y - estimate_node_dimensions(socket.node).sockets[socket] for socket in sources]
size = estimate_node_dimensions(node)
output = next(socket for socket in node.outputs if socket in size.sockets)
assert node.location.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name
assert node.location_absolute.y - size.sockets[output] == pytest.approx((min(levels) + max(levels)) / 2, abs=0.001), node.name
assert all(first >= second - 0.001 for first, second in zip(levels, levels[1:])), node.name
@@ -227,12 +269,12 @@ def test_geometry_paths_order_join_branches_and_separate_their_shared_input(bran
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
levels = [link.from_node.location.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket]
levels = [link.from_node.location_absolute.y - estimate_node_dimensions(link.from_node).sockets[link.from_socket]
for link in peer_links(join)]
assert levels == sorted(levels, reverse=True)
if branch_lengths == (1, 2, 3):
heights = {node: node.location.y for node in geometry_parents}
heights = {node: node.location_absolute.y for node in geometry_parents}
field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"]
calculations = [field.node]
for _ in range(30):
@@ -245,10 +287,24 @@ def test_geometry_paths_order_join_branches_and_separate_their_shared_input(bran
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
assert {node: node.location.y for node in geometry_parents} == heights
assert max(node.location.y for node in calculations) - min(
node.location.y - estimate_node_dimensions(node).height for node in calculations
) <= FIELD_HEIGHT
assert {node: node.location_absolute.y for node in geometry_parents} == heights
assert max(node.location_absolute.y for node in calculations) - min(
node.location_absolute.y - estimate_node_dimensions(node).height for node in calculations
) <= FIELD_HEIGHT * 3
assert max(node.location_absolute.x + node.width for node in calculations) - min(
node.location_absolute.x for node in calculations
) < 4000
frames = {node.parent for node in calculations}
assert len(frames) == 1
frame = frames.pop()
assert frame is not None and frame.bl_idname == "NodeFrame"
from tools.nodes.preview.export import _node_group_data
data = _node_group_data(node_group)
record = next(item for item in data["frames"] if item["title"] == frame.label)
for index in record["nodes"]:
node = data["nodes"][index]
assert record["x"] <= node["x"] < node["x"] + node["w"] <= record["x"] + record["w"] + 0.01
assert record["y"] <= node["y"] < node["y"] + node["h"] <= record["y"] + record["h"] + 0.01
def test_geometry_used_for_field_sampling_stays_with_the_field_branch():
@@ -304,10 +360,10 @@ def test_shared_field_references_branch_near_their_consumers():
for node in consumers[1:]:
branch = node.inputs["Offset"].links[0].from_node
assert branch.bl_idname == "NodeReroute"
assert node.location.x - branch.location.x >= ROUTE_RUN
assert node.location_absolute.x - branch.location_absolute.x >= ROUTE_RUN
assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1
for node in node_group.nodes)
assert all(link.to_node.location.x > link.from_node.location.x
assert all(link.to_node.location_absolute.x > link.from_node.location_absolute.x
for link in node_group.links
if link.from_node.bl_idname == link.to_node.bl_idname == "NodeReroute")
@@ -332,9 +388,9 @@ def test_field_modules_reserve_lane_and_exit_clearance(side):
node_group.links.new(geometry, join.inputs["Geometry"])
node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"])
arrange_nodes(node_group)
branches.sort(key=lambda branch: branch[0].location.y, reverse=True)
branches.sort(key=lambda branch: branch[0].location_absolute.y, reverse=True)
lane = branches[0 if side == "upper" else -1]
heights = [node.location.y for branch in branches for node in branch]
heights = [node.location_absolute.y for branch in branches for node in branch]
fields = []
for target in (lane[1], lane[3]):
value = node_group.nodes.new("GeometryNodeInputPosition").outputs[0]
@@ -350,20 +406,20 @@ def test_field_modules_reserve_lane_and_exit_clearance(side):
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
assert [node.location.y for branch in branches for node in branch] == heights
boxes = [(min(node.location.x for node in branch),
max(node.location.x + estimate_node_dimensions(node).width for node in branch),
max(node.location.y for node in branch),
min(node.location.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields]
assert [node.location_absolute.y for branch in branches for node in branch] == heights
boxes = [(min(node.location_absolute.x for node in branch),
max(node.location_absolute.x + estimate_node_dimensions(node).width for node in branch),
max(node.location_absolute.y for node in branch),
min(node.location_absolute.y - estimate_node_dimensions(node).height for node in branch)) for branch in fields]
core, _ = geometry_paths(node_group)
core_left = min(node.location.x for node in core)
core_left = min(node.location_absolute.x for node in core)
for _, right, top, bottom in boxes:
if right + BRANCH_GAP <= core_left + 0.001:
continue
if side == "upper":
assert bottom >= lane[0].location.y + BRANCH_GAP - 0.001
assert bottom >= lane[0].location_absolute.y + BRANCH_GAP - 0.001
else:
assert top <= lane[0].location.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001
assert top <= lane[0].location_absolute.y - estimate_node_dimensions(lane[0]).height - BRANCH_GAP + 0.001
first, second = boxes
assert (first[1] + BRANCH_GAP <= second[0] + 0.001 or second[1] + BRANCH_GAP <= first[0] + 0.001
or first[3] >= second[2] + BRANCH_GAP - 0.001 or second[3] >= first[2] + BRANCH_GAP - 0.001)
@@ -371,12 +427,12 @@ def test_field_modules_reserve_lane_and_exit_clearance(side):
assert any(right + BRANCH_GAP <= core_left + 0.001 for _, right, _, _ in boxes)
for branch, target, other in zip(fields, (lane[1], lane[3]), reversed(boxes)):
terminal = branch[-1]
assert terminal.location.x + terminal.width < target.location.x
assert terminal.location_absolute.x + terminal.width < target.location_absolute.x
socket = terminal.outputs["Vector"]
x1 = terminal.location.x + terminal.width
y1 = terminal.location.y - estimate_node_dimensions(terminal).sockets[socket]
x2 = target.location.x
y2 = target.location.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]]
x1 = terminal.location_absolute.x + terminal.width
y1 = terminal.location_absolute.y - estimate_node_dimensions(terminal).sockets[socket]
x2 = target.location_absolute.x
y2 = target.location_absolute.y - estimate_node_dimensions(target).sockets[target.inputs["Offset"]]
bend = max(40, (x2 - x1) * 0.42)
t = np.linspace(0, 1, 200)
x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t**2*(x2-bend) + t**3*x2
@@ -401,10 +457,10 @@ def test_boolean_operands_follow_socket_order_and_center_the_junction():
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
levels = [node.location.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons]
levels = [node.location_absolute.y - estimate_node_dimensions(node).sockets[node.outputs[0]] for node in comparisons]
assert levels[0] > levels[1]
assert comparisons[0].location.x + comparisons[0].width == comparisons[1].location.x + comparisons[1].width
assert operation.location.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2)
assert comparisons[0].location_absolute.x + comparisons[0].width == comparisons[1].location_absolute.x + comparisons[1].width
assert operation.location_absolute.y - estimate_node_dimensions(operation).sockets[operation.outputs[0]] == pytest.approx(sum(levels) / 2)
def test_preview_is_read_only_and_escapes_node_names(tmp_path):
@@ -415,7 +471,7 @@ def test_preview_is_read_only_and_escapes_node_names(tmp_path):
arrange_nodes(node_group)
node_group.nodes[0].name = '</script><script>alert("node")</script>'
program = _program(node_group)
positions = {node.name: tuple(node.location) for node in node_group.nodes}
positions = {node.name: tuple(node.location_absolute) for node in node_group.nodes}
destination = write_node_preview([node_group], tmp_path / "nodes.html")
markup = destination.read_text(encoding="utf-8")
assert markup.startswith("<!doctype html>")
@@ -423,7 +479,7 @@ def test_preview_is_read_only_and_escapes_node_names(tmp_path):
assert '<script>alert("node")</script>' not in markup
assert '\\u003c/script>' in markup
assert _program(node_group) == program
assert {node.name: tuple(node.location) for node in node_group.nodes} == positions
assert {node.name: tuple(node.location_absolute) for node in node_group.nodes} == positions
def test_geometry_bypass_has_two_level_corners_next_to_processing():
@@ -444,14 +500,14 @@ def test_geometry_bypass_has_two_level_corners_next_to_processing():
exit_port = switch.inputs["False"].links[0].from_node
entry_port = exit_port.inputs[0].links[0].from_node
assert entry_port.bl_idname == exit_port.bl_idname == "NodeReroute"
assert entry_port.location.y == exit_port.location.y
assert source.location.x + source.width < entry_port.location.x < exit_port.location.x < switch.location.x
assert exit_port.location.x + 16 == pytest.approx(transform.location.x + transform.width)
assert entry_port.location_absolute.y == exit_port.location_absolute.y
assert source.location_absolute.x + source.width < entry_port.location_absolute.x < exit_port.location_absolute.x < switch.location_absolute.x
assert exit_port.location_absolute.x + 16 == pytest.approx(transform.location_absolute.x + transform.width)
assert _program(node_group) == program
@pytest.mark.parametrize("zone_type", ["Repeat", "Simulation", "ForeachGeometryElement"])
def test_preview_zone_contains_body_and_excludes_shared_external_sources(zone_type):
def test_preview_zone_includes_repeat_value_branch_with_side_clearance(zone_type):
from tools.nodes.preview.export import _node_group_data
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -466,11 +522,25 @@ def test_preview_zone_contains_body_and_excludes_shared_external_sources(zone_ty
node_group.links.new(geometry, body.inputs["Geometry"])
geometry_input = next(socket for socket in end.inputs if socket.type == "GEOMETRY")
node_group.links.new(body.outputs["Geometry"], geometry_input)
entry.location, body.location, end.location = (0, 0), (300, 0), (700, 0)
sample = node_group.nodes.new("GeometryNodeSampleIndex")
sample.data_type = "FLOAT_VECTOR"
node_group.links.new(geometry, sample.inputs["Geometry"])
node_group.links.new(sample.outputs["Value"], body.inputs["Position"])
sample.location_absolute = (-1200, -400)
entry.location_absolute, body.location_absolute, end.location_absolute = (0, 0), (300, 0), (700, 0)
if zone_type == "Repeat":
entry.location_absolute = (-1600, 0)
data = _node_group_data(node_group)
zone = data["zones"][0]
names = {data["nodes"][index]["name"] for index in zone["nodes"]}
assert names == {entry.name, body.name, end.name}
expected = {entry.name, body.name, end.name}
if zone_type == "Repeat":
expected.add(sample.name)
assert names == expected
padding = 60 if zone_type == "Repeat" else 40
boxes = [data["nodes"][index] for index in zone["nodes"]]
assert min(node["x"] for node in boxes) - zone["x"] == padding
assert zone["x"] + zone["w"] - max(node["x"] + node["w"] for node in boxes) == padding
for index in zone["nodes"]:
node = data["nodes"][index]
assert zone["x"] <= node["x"] and node["x"] + node["w"] <= zone["x"] + zone["w"]
@@ -487,9 +557,108 @@ def test_repeat_geometry_collector_stays_in_the_local_body():
end = entry.paired_output
collector = node_group.nodes["Join Geometry.001"]
assert collector in next(members for start, _, members in node_zones(node_group) if start == entry)
assert entry.location.x < collector.location.x < end.location.x
assert abs(collector.location.y - entry.location.y) <= FIELD_HEIGHT + BRANCH_GAP
assert entry.location.y == end.location.y
assert entry.location_absolute.x < collector.location_absolute.x < end.location_absolute.x
assert abs(collector.location_absolute.y - entry.location_absolute.y) <= FIELD_HEIGHT + BRANCH_GAP
assert entry.location_absolute.y == end.location_absolute.y
@pytest.mark.parametrize("length", [1, 20])
def test_repeat_places_value_nodes_between_its_boundaries(length):
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = bpy.data.node_groups.new("Compact Repeat", "GeometryNodeTree")
node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = node_group.nodes.new("GeometryNodeMeshGrid")
entry = node_group.nodes.new("GeometryNodeRepeatInput")
end = node_group.nodes.new("GeometryNodeRepeatOutput")
entry.pair_with_output(end)
body = node_group.nodes.new("GeometryNodeSetPosition")
output = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"])
node_group.links.new(entry.outputs["Geometry"], body.inputs["Geometry"])
node_group.links.new(body.outputs["Geometry"], end.inputs["Geometry"])
node_group.links.new(end.outputs["Geometry"], output.inputs["Geometry"])
sample = node_group.nodes.new("GeometryNodeSampleIndex")
sample.data_type = "FLOAT_VECTOR"
node_group.links.new(entry.outputs["Geometry"], sample.inputs["Geometry"])
value = sample.outputs["Value"]
for _ in range(length):
operation = node_group.nodes.new("ShaderNodeVectorMath")
operation.operation = "NORMALIZE"
node_group.links.new(value, operation.inputs[0])
value = operation.outputs[0]
node_group.links.new(value, body.inputs["Position"])
program = _program(node_group)
arrange_nodes(node_group)
from tools.nodes.arrangement.zones import node_zones
members = next(members for start, _, members in node_zones(node_group) if start == entry)
for node in members - {entry, end}:
if node.bl_idname == "NodeReroute":
continue
assert node.location_absolute.x >= entry.location_absolute.x + entry.width + 159.99
assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x - 159.99
# The exit also reserves the short run needed by its lower geometry input.
assert 100 <= end.location_absolute.x - body.location_absolute.x - body.width <= 200
assert entry.location_absolute.y == body.location_absolute.y == end.location_absolute.y
assert _program(node_group) == program
def test_depth_cutout_repeat_precedes_downstream_geometry_through_shared_fields():
from tools.nodes.arrangement.flow import graph
from tools.nodes.arrangement.zones import node_zones
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = CUTOUT_BUILDERS["DEPTH_SOLID"]()
arrange_nodes(node_group)
core, _ = geometry_paths(node_group)
_, _, _, ancestors = graph(node_group)
entries = [node for node in core if node.bl_idname == "GeometryNodeRepeatInput"]
assert entries
for entry, end, members in node_zones(node_group):
for node in members - {entry, end}:
if node.bl_idname == "NodeReroute":
assert entry.location_absolute.x <= node.location_absolute.x, node.name
assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x + end.width, node.name
continue
assert node.location_absolute.x >= entry.location_absolute.x + entry.width + 159.99, node.name
assert node.location_absolute.x + estimate_node_dimensions(node).width <= end.location_absolute.x - 159.99, node.name
spans = []
for entry in entries:
end = entry.paired_output
body = origin_socket(end.inputs["Geometry"].links[0].from_socket).node
assert body.bl_idname == "GeometryNodeSetPosition"
spans.append(end.location_absolute.x - entry.location_absolute.x)
assert spans[-1] > 1000
value = origin_socket(body.inputs["Position"].links[0].from_socket).node
assert 100 <= body.location_absolute.x - value.location_absolute.x - estimate_node_dimensions(value).width <= 1000
for node in core:
if end in ancestors[node]:
assert end.location_absolute.x + end.width < node.location_absolute.x, node.name
assert max(spans) > 1000
from tools.nodes.arrangement.zones import repeat_regions
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.passages import _intersects
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
for members, box in repeat_regions(node_group, positions, sizes):
left, right, top, bottom = box
for node in node_group.nodes:
if node in members or node.bl_idname == "NodeFrame":
continue
x, y = positions[node]
assert x + sizes[node].width <= left or x >= right or y <= bottom or y - sizes[node].height >= top, node.name
for link in node_group.links:
source = origin_socket(link.from_socket).node
consumers, pending = set(), [link.to_node]
while pending:
node = pending.pop()
if node in consumers:
continue
consumers.add(node)
if node.bl_idname == "NodeReroute":
pending.extend(edge.to_node for edge in node.outputs[0].links)
if source not in members and not consumers & members:
assert not _intersects(*link_endpoints(link, positions, sizes), [box]), (source.name, link.to_node.name)
def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
@@ -516,7 +685,7 @@ def test_shared_geometry_uses_one_forward_trunk_with_late_branches():
for sample in samplers[1:]:
branch = sample.inputs["Geometry"].links[0].from_node
assert branch.bl_idname == "NodeReroute"
assert branch.location.x > shared.location.x + (sample.location.x - shared.location.x) / 2
assert branch.location_absolute.x > shared.location_absolute.x + (sample.location_absolute.x - shared.location_absolute.x) / 2
assert any(node.bl_idname == "NodeReroute" and len(node.outputs[0].links) > 1
for node in node_group.nodes if node.inputs and node.inputs[0].is_linked
and origin_socket(node.inputs[0].links[0].from_socket).node == shared)
@@ -545,7 +714,7 @@ def test_reused_geometry_processing_keeps_its_upstream_result_path():
arrange_nodes(node_group)
roles = node_roles(node_group)
assert all(roles[node] == "geometry" for node in (grid, transform, position, output))
assert grid.location.y == transform.location.y == position.location.y == output.location.y
assert grid.location_absolute.y == transform.location_absolute.y == position.location_absolute.y == output.location_absolute.y
assert all(roles[node] == "field" for node in node_group.nodes
if node.bl_idname in {"GeometryNodeSampleIndex", "GeometryNodeSeparateGeometry"})
@@ -574,59 +743,54 @@ def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input
roles = node_roles(node_group)
assert roles[grid] == roles[prepare] == "field"
assert all(roles[node] == "geometry" for node in branches)
assert prepare.location.x + prepare.width < min(node.location.x for node in branches)
assert prepare.location_absolute.x + prepare.width < min(node.location_absolute.x for node in branches)
def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends():
def test_shared_source_moves_towards_consumers_when_space_is_available():
from tools.nodes.arrangement.sources import place_shared_sources
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Shared Source Placement", "GeometryNodeTree")
source = group.nodes.new("ShaderNodeMath")
target = group.nodes.new("ShaderNodeMath")
group.links.new(source.outputs[0], target.inputs[0])
positions = {source: (-10000, 0), target: (2000, 0)}
sizes = {node: estimate_node_dimensions(node) for node in positions}
bodies = [({node}, (x, x + sizes[node].width, y, y - sizes[node].height))
for node, (x, y) in positions.items()]
place_shared_sources(bodies, {source}, positions, sizes, gap=160)
assert 0 < positions[source][0] < positions[target][0] - sizes[source].width
def test_repeat_value_branches_leave_geometry_corridors_clear():
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.zones import node_zones
bpy.ops.wm.read_factory_settings(use_empty=True)
node_group = CUTOUT_BUILDERS["SOLID"]()
arrange_nodes(node_group)
core, _ = geometry_paths(node_group)
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
positions = {node: tuple(node.location) for node in node_group.nodes}
checked = 0
for _, _, members in node_zones(node_group):
for link in node_group.links:
if link.from_node not in members or link.to_node not in members:
continue
if (link.from_node in core) == (link.to_node in core):
continue
start, end = link_endpoints(link, positions, sizes)
assert end[0] - start[0] >= abs(end[1] - start[1]) * 0.8 - 0.01, (link.from_node.name, link.to_node.name)
checked += 1
assert checked > 0
entry = node_group.nodes["Repeat Input.001"]
end = entry.paired_output
assert end.location.x - entry.location.x < 6500
source = entry.outputs["Geometry"]
assert len(source.links) == 1
branch = source.links[0].to_node
while branch.bl_idname == "NodeReroute" and len(branch.outputs[0].links) == 1:
branch = branch.outputs[0].links[0].to_node
assert branch.bl_idname == "NodeReroute"
assert len(branch.outputs[0].links) > 1
deliveries = [link for link in branch.outputs[0].links
if link.to_node not in core and link.to_node.bl_idname != "NodeReroute"]
assert deliveries
for link in deliveries:
start, target = link_endpoints(link, positions, sizes)
assert target[0] - start[0] == pytest.approx(max(ROUTE_RUN, abs(target[1] - start[1]) * 0.8), abs=0.01)
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
frames = [node for node in node_group.nodes if node.bl_idname == "NodeFrame"]
for index, frame in enumerate(frames):
left, top = positions[frame]
for other in frames[index + 1:]:
x, y = positions[other]
assert (left + frame.width <= x or x + other.width <= left
or top - frame.height >= y or y - other.height >= top)
for port in core:
if not port.get("arrangement_input") or port.outputs[0].type != "GEOMETRY":
continue
source = origin_socket(port.outputs[0]).node
if source not in core:
continue
left = source.location.x + sizes[source].width
right, level = port.location.x + 8, port.location.y - 8
left = source.location_absolute.x + sizes[source].width
right, level = port.location_absolute.x + 8, port.location_absolute.y - 8
for node in node_group.nodes:
if node in core or node.bl_idname == "NodeReroute":
if node in core or node.bl_idname in {"NodeReroute", "NodeFrame"}:
continue
if left < node.location.x + sizes[node].width and node.location.x < right:
assert not node.location.y - sizes[node].height < level < node.location.y, node.name
if left < node.location_absolute.x + sizes[node].width and node.location_absolute.x < right:
assert not node.location_absolute.y - sizes[node].height < level < node.location_absolute.y, node.name
for link in node_group.links:
if link.from_socket.type != "GEOMETRY":
continue
@@ -634,10 +798,10 @@ def test_repeat_field_connectors_reserve_horizontal_run_at_both_ends():
if abs(start[1] - target[1]) > 0.01:
continue
for node in node_group.nodes:
if node in core or node.bl_idname == "NodeReroute" or node in {link.from_node, link.to_node}:
if node in core or node.bl_idname in {"NodeReroute", "NodeFrame"} or node in {link.from_node, link.to_node}:
continue
if start[0] < node.location.x + sizes[node].width and node.location.x < target[0]:
assert not node.location.y - sizes[node].height < start[1] < node.location.y, node.name
if start[0] < node.location_absolute.x + sizes[node].width and node.location_absolute.x < target[0]:
assert not node.location_absolute.y - sizes[node].height < start[1] < node.location_absolute.y, node.name
def test_expression_skip_links_have_passages_between_intermediate_nodes():
@@ -652,7 +816,7 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
seed = node_group.nodes.new("GeometryNodeInputIndex")
previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0]
fields = [seed, previous.node]
for _ in range(7):
for _ in range(24):
operation = node_group.nodes.new("ShaderNodeMath")
operation.operation = "ADD"
node_group.links.new(previous, operation.inputs[0])
@@ -665,8 +829,11 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
program = _program(node_group)
arrange_nodes(node_group)
assert _program(node_group) == program
operations = [node for node in fields if node.bl_idname == "ShaderNodeMath"]
assert all(first.location_absolute.x < last.location_absolute.x
for first, last in zip(operations, operations[1:]))
sizes = {node: estimate_node_dimensions(node) for node in node_group.nodes}
positions = {node: tuple(node.location) for node in node_group.nodes}
positions = {node: tuple(node.location_absolute) for node in node_group.nodes}
assert any(node.get("arrangement_branch") for node in node_group.nodes)
for link in node_group.links:
if origin_socket(link.from_socket).node not in fields:
@@ -681,3 +848,443 @@ def test_expression_skip_links_have_passages_between_intermediate_nodes():
continue
left, top = positions[node]
assert not (left < x < left + sizes[node].width and top - sizes[node].height < y < top), node.name
@pytest.mark.parametrize("offset", [260, 3500])
def test_geometry_reference_tracks_have_slanted_entry_and_exit(offset):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import TRACK_GAP, route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Slanted References", "GeometryNodeTree")
for index in range(2):
source = group.nodes.new("GeometryNodeMeshGrid")
source.location = (index * offset, index * 3200 if offset > 260 else 0)
for x, y in ((1800, 900), (2600, -900)):
target = group.nodes.new("GeometryNodeSampleIndex")
target.location = (x + index * offset, y - (index * 3000 if offset > 260 else 0))
group.links.new(source.outputs["Mesh"], target.inputs["Geometry"])
program = _program(group)
route_references(group, [])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
tracks = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
dx, dy = end[0] - start[0], end[1] - start[1]
if abs(dy) > 100:
assert abs(dx) >= abs(dy) * 0.79
elif abs(dy) < 0.01 and abs(dx) > 100:
assert dx > 0
tracks.append((min(start[0], end[0]), max(start[0], end[0]), start[1], origin_socket(link.from_socket)))
assert tracks
for index, (left, right, y, source) in enumerate(tracks):
for a, b, level, other in tracks[index + 1:]:
if source != other and left < b and a < right:
assert abs(y - level) >= TRACK_GAP - 0.01
@pytest.mark.parametrize("source_x", [0, 3000])
def test_geometry_reference_trunk_never_retraces_itself(source_x):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Direct Return", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
source.location = (source_x, 0)
for x, y in ((0, -800), (600, -1000)):
target = group.nodes.new("GeometryNodeSampleIndex")
target.location = (x, y)
group.links.new(source.outputs["Mesh"], target.inputs["Geometry"])
program = _program(group)
route_references(group, [])
assert _program(group) == program
corners = [node for node in group.nodes if node.bl_idname == "NodeReroute"]
assert corners
if source_x:
assert all(node.location_absolute.x < source.location_absolute.x for node in corners)
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
segments = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01:
assert end[0] >= start[0]
segments.append((start, end))
for index, (start, end) in enumerate(segments):
for other_start, other_end in segments[index + 1:]:
assert (abs(start[1] - other_start[1]) >= 0.01
or min(end[0], other_end[0]) <= max(start[0], other_start[0]))
def test_return_curve_stays_apart_from_the_nearly_horizontal_main_path():
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import TRACK_GAP, route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Parallel Return", "GeometryNodeTree")
first = group.nodes.new("GeometryNodeMeshGrid")
source = group.nodes.new("GeometryNodeTransform")
consumer = group.nodes.new("GeometryNodeSampleIndex")
first.location = (0, 0)
source.location = (5000, 0)
consumer.location = (2000, -400)
group.links.new(first.outputs["Mesh"], source.inputs["Geometry"])
group.links.new(source.outputs["Geometry"], consumer.inputs["Geometry"])
program = _program(group)
route_references(group, [])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
main = link_endpoints(source.inputs["Geometry"].links[0], positions, sizes)
start, end = link_endpoints(source.outputs["Geometry"].links[0], positions, sizes)
midpoint_y = (start[1] + end[1]) / 2
assert (midpoint_y <= min(point[1] for point in main) - TRACK_GAP
or midpoint_y >= max(point[1] for point in main) + TRACK_GAP)
def test_geometry_trunk_clears_the_frame_of_its_own_consumers():
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.frames import FRAME_PADDING, FRAME_HEADING
from tools.nodes.arrangement.references import FRAME_CLEARANCE, route_references
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Frame Trunk Clearance", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
consumers = []
for x, y in ((6000, 900), (8000, -900)):
target = group.nodes.new("GeometryNodeSampleIndex")
target.location = (x, y)
group.links.new(source.outputs["Mesh"], target.inputs["Geometry"])
consumers.append(target)
body = (5800, 8500, 1000, -1200)
program = _program(group)
route_references(group, [(set(consumers), body)])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
horizontal = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01 and abs(start[0] - end[0]) > 100:
horizontal.append((start, end))
if min(start[0], end[0]) < body[1] + FRAME_PADDING and max(start[0], end[0]) > body[0] - FRAME_PADDING:
assert (start[1] >= body[2] + FRAME_HEADING + FRAME_CLEARANCE - 0.01
or start[1] <= body[3] - FRAME_PADDING - FRAME_CLEARANCE + 0.01)
assert horizontal
def test_independent_selection_and_offset_calculations_get_separate_frames():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Independent Inputs", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
position = group.nodes.new("GeometryNodeSetPosition")
output = group.nodes.new("NodeGroupOutput")
group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"])
group.links.new(position.outputs["Geometry"], output.inputs["Geometry"])
branches = []
for socket, seed_type, node_type, operation in (
("Selection", "GeometryNodeInputIndex", "ShaderNodeMath", "ABSOLUTE"),
("Offset", "GeometryNodeInputPosition", "ShaderNodeVectorMath", "NORMALIZE"),
):
value = group.nodes.new(seed_type).outputs[0]
branch = [value.node]
for _ in range(12):
node = group.nodes.new(node_type)
node.operation = operation
group.links.new(value, node.inputs[0])
value = node.outputs[0]
branch.append(node)
group.links.new(value, position.inputs[socket])
branches.append(branch)
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
frames = [branch[0].parent for branch in branches]
assert all(frame is not None for frame in frames) and frames[0] != frames[1]
for branch, frame in zip(branches, frames):
assert all(node.parent == frame for node in branch)
@pytest.mark.parametrize("sample_geometry", [False, True])
def test_main_path_reserves_only_space_needed_after_geometry_source(sample_geometry):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Local Field Delivery", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
grid = group.nodes.new("GeometryNodeMeshGrid")
transform = group.nodes.new("GeometryNodeTransform")
consumer = group.nodes.new("GeometryNodeSetPosition")
output = group.nodes.new("NodeGroupOutput")
group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"])
group.links.new(transform.outputs["Geometry"], consumer.inputs["Geometry"])
group.links.new(consumer.outputs["Geometry"], output.inputs["Geometry"])
arrange_nodes(group)
original_x = consumer.location_absolute.x
sample = group.nodes.new("GeometryNodeSampleIndex" if sample_geometry else "GeometryNodeInputPosition")
if sample_geometry:
sample.data_type = "FLOAT_VECTOR"
group.links.new(transform.outputs["Geometry"], sample.inputs["Geometry"])
value = sample.outputs[0]
for _ in range(18):
node = group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
group.links.new(value, node.inputs[0])
value = node.outputs[0]
group.links.new(value, consumer.inputs["Offset"])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
if sample_geometry:
assert consumer.location_absolute.x > original_x + 700
assert sample.location_absolute.x >= transform.location_absolute.x + transform.width
else:
assert consumer.location_absolute.x == original_x
assert 100 <= consumer.location_absolute.x - value.node.location_absolute.x - value.node.width <= 1000
assert consumer.inputs["Geometry"].links[0].from_node == transform
@pytest.mark.parametrize("socket_type", ["FLOAT", "VECTOR", "BOOLEAN", "RGBA", "ROTATION", "GEOMETRY"])
def test_shared_results_use_one_peripheral_pipe(socket_type):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.references import route_references, FRAME_CLEARANCE
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Peripheral Results", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeSwitch")
source.input_type = socket_type
consumers = []
for x, y in ((2400, 500), (4800, -500), (7200, 200)):
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = socket_type
node.location = (x, y)
group.links.new(source.outputs["Output"], node.inputs["False"])
consumers.append(node)
program = _program(group)
route_references(group, [(set(consumers), (2200, 7400, 900, -900))])
assert _program(group) == program
assert len(source.outputs["Output"].links) == 1
assert all(node.inputs["False"].links[0].from_node.bl_idname == "NodeReroute" for node in consumers)
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
horizontal = []
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
if abs(start[1] - end[1]) < 0.01:
assert end[0] > start[0]
assert start[1] >= 900 + FRAME_CLEARANCE or start[1] <= -900 - FRAME_CLEARANCE
horizontal.append(link)
assert horizontal
@pytest.mark.parametrize("consumes_frame", [False, True])
def test_geometry_only_enters_frames_that_consume_it(consumes_frame):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.passages import route_field_passages
from tools.nodes.arrangement.frames import FRAME_PADDING, FRAME_HEADING
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Frame Passage", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
consumer = group.nodes.new("GeometryNodeSampleIndex")
consumer.location = (2500 if consumes_frame else 5000, 0)
group.links.new(source.outputs["Mesh"], consumer.inputs["Geometry"])
members = {group.nodes.new("ShaderNodeMath") for _ in range(12)}
if consumes_frame:
members.add(consumer)
program = _program(group)
route_field_passages(group, [(members, (2000, 3000, 400, -600))])
assert _program(group) == program
if consumes_frame:
assert consumer.inputs["Geometry"].links[0].from_node == source
return
assert consumer.inputs["Geometry"].links[0].from_node.bl_idname == "NodeReroute"
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
for link in group.links:
(x, y), (u, v) = link_endpoints(link, positions, sizes)
assert u > x
bend = max(40, abs(u - x) * 0.42)
for t in np.linspace(0, 1, 65):
px = (1-t)**3*x + 3*(1-t)**2*t*(x+bend) + 3*(1-t)*t*t*(u-bend) + t**3*u
py = (1-t)**3*y + 3*(1-t)**2*t*y + 3*(1-t)*t*t*v + t**3*v
assert not (2000-FRAME_PADDING < px < 3000+FRAME_PADDING
and -600-FRAME_PADDING < py < 400+FRAME_HEADING)
@pytest.mark.parametrize("shared", [False, True])
@pytest.mark.parametrize("obstacle", [False, True])
def test_reference_detour_removes_reversal_but_preserves_shared_junction(shared, obstacle):
from tools.nodes.arrangement.connections import link_endpoints
from tools.nodes.arrangement.passages import route_field_passages
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Reference Detour", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
consumer = group.nodes.new("GeometryNodeSampleIndex")
consumer.location = (5000, -1000)
corner = group.nodes.new("NodeReroute")
corner["arrangement_reference"] = True
corner.location = (6000, 0)
approach = group.nodes.new("NodeReroute")
approach["arrangement_reference"] = True
approach.location = (1000, 0)
group.links.new(source.outputs["Mesh"], approach.inputs[0])
group.links.new(approach.outputs[0], corner.inputs[0])
group.links.new(corner.outputs[0], consumer.inputs["Geometry"])
if shared:
other = group.nodes.new("GeometryNodeSampleIndex")
other.location = (7000, -1000)
group.links.new(corner.outputs[0], other.inputs["Geometry"])
members = {group.nodes.new("ShaderNodeMath") for _ in range(12)} if obstacle else set()
program = _program(group)
route_field_passages(group, [(members, (2000, 3000, 400, -1400))] if obstacle else [])
assert _program(group) == program
if shared:
assert corner in group.nodes.values()
else:
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
assert all(link_endpoints(link, positions, sizes)[1][0] > link_endpoints(link, positions, sizes)[0][0]
for link in group.links)
def test_value_passage_keeps_forward_entrance_when_consumer_is_left():
from tools.nodes.arrangement.passages import route_field_passages
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Forward Value Entrance", "GeometryNodeTree")
source = group.nodes.new("ShaderNodeMath")
consumer = group.nodes.new("ShaderNodeMath")
consumer.location = (-3000, -1500)
corner = group.nodes.new("NodeReroute")
corner["arrangement_reference"] = True
corner.location = (1000, 0)
group.links.new(source.outputs[0], corner.inputs[0])
group.links.new(corner.outputs[0], consumer.inputs[0])
route_field_passages(group, [])
assert source.outputs[0].links[0].to_node == corner
def test_obstacle_detour_starts_to_the_right_of_a_value_source():
from tools.nodes.arrangement.passages import _passage
path = _passage((0, 0), (100, -2000), [(-300, 300, -300, -1600)], [], forward_start=True)
assert path[0][0] > 0
def test_compact_control_stays_near_switch_without_crossing_geometry_bypass():
from tools.nodes.arrangement.connections import curve_footprint, link_endpoints
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Compact Switch Control", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("GeometryNodeMeshGrid")
transform = group.nodes.new("GeometryNodeTransform")
switch = group.nodes.new("GeometryNodeSwitch")
switch.input_type = "GEOMETRY"
control = group.nodes.new("GeometryNodeMenuSwitch")
control.data_type = "BOOLEAN"
output = group.nodes.new("NodeGroupOutput")
group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"])
group.links.new(source.outputs["Mesh"], switch.inputs["False"])
group.links.new(transform.outputs["Geometry"], switch.inputs["True"])
group.links.new(control.outputs[0], switch.inputs["Switch"])
group.links.new(switch.outputs[0], output.inputs[0])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
start, end = link_endpoints(switch.inputs["Switch"].links[0], positions, sizes)
assert 100 <= end[0] - start[0] <= 160
control_curve = curve_footprint(start, end)
for link in group.links:
if link.from_socket.type != "GEOMETRY":
continue
geometry_curve = curve_footprint(*link_endpoints(link, positions, sizes))
assert not any(a < d and b > c and top > lower and bottom < upper
for a, b, top, bottom in control_curve
for c, d, upper, lower in geometry_curve)
def test_long_geometry_return_does_not_swing_over_later_processing():
from tools.nodes.arrangement.connections import curve_footprint, link_endpoints
from tools.nodes.arrangement.passages import route_field_passages
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Long Geometry Return", "GeometryNodeTree")
source = group.nodes.new("GeometryNodeMeshGrid")
source.location = (17000, 0)
consumer = group.nodes.new("GeometryNodeSampleIndex")
consumer.location = (0, -100)
group.links.new(source.outputs["Mesh"], consumer.inputs["Geometry"])
program = _program(group)
route_field_passages(group, [])
assert _program(group) == program
positions = {node: tuple(node.location_absolute) for node in group.nodes}
sizes = {node: estimate_node_dimensions(node) for node in group.nodes}
source_right = positions[source][0] + sizes[source].width
for link in group.links:
start, end = link_endpoints(link, positions, sizes)
assert end[0] < start[0]
assert max(box[1] for box in curve_footprint(start, end)) < source_right + 160
def test_reused_value_for_inputs_of_one_node_stays_local():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Local Shared Value", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("ShaderNodeMath")
cube = group.nodes.new("GeometryNodeMeshCube")
output = group.nodes.new("NodeGroupOutput")
for name in ("Vertices X", "Vertices Y", "Vertices Z"):
group.links.new(source.outputs[0], cube.inputs[name])
group.links.new(cube.outputs["Mesh"], output.inputs[0])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
assert len(source.outputs[0].links) == 3
assert all(link.to_node == cube for link in source.outputs[0].links)
def test_shared_calculation_sits_outside_consumers_and_starts_its_pipe_forward():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = bpy.data.node_groups.new("Peripheral Calculation", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("GeometryNodeInputPosition")
producers = [source]
for _ in range(3):
node = group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
group.links.new(source.outputs[0], node.inputs[0])
producers.append(node)
source = node
geometry = group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"]
for _ in range(3):
value = source.outputs[0]
for _ in range(12):
node = group.nodes.new("ShaderNodeVectorMath")
node.operation = "ADD"
group.links.new(value, node.inputs[0])
value = node.outputs[0]
consumer = group.nodes.new("GeometryNodeSetPosition")
group.links.new(geometry, consumer.inputs["Geometry"])
group.links.new(value, consumer.inputs["Offset"])
geometry = consumer.outputs["Geometry"]
output = group.nodes.new("NodeGroupOutput")
group.links.new(geometry, output.inputs["Geometry"])
program = _program(group)
arrange_nodes(group)
assert _program(group) == program
frames = [node for node in group.nodes if node.bl_idname == "NodeFrame"]
assert len(frames) == 3
top = max(node.location_absolute.y for node in producers)
bottom = min(node.location_absolute.y - estimate_node_dimensions(node).height for node in producers)
assert (bottom >= max(frame.location_absolute.y for frame in frames)
or top <= min(frame.location_absolute.y - frame.height for frame in frames))
assert len(source.outputs[0].links) == 1
entrance = source.outputs[0].links[0].to_node
assert entrance.bl_idname == "NodeReroute"
assert entrance.location_absolute.x > source.location_absolute.x + estimate_node_dimensions(source).width