"""Exercise automatic arrangement without changing geometry or publishing assets.""" import bpy import numpy as np import pytest from tools.nodes.groups.image_plane import build_image_plane_group from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group from tests.support.nodes import CUTOUT_BUILDERS from tools.nodes.arrangement.arrange import BRANCH_GAP, FIELD_HEIGHT, ROUTE_RUN, arrange_nodes from tools.nodes.arrangement.flow import geometry_paths, origin_socket, peer_links from tools.nodes.arrangement.dimensions import RULES, estimate_node_dimensions @pytest.fixture(params=("plane", "depth_plane", "relief_plane", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")) def node_group(request): bpy.ops.wm.read_factory_settings(use_empty=True) if request.param == "plane": return build_image_plane_group() if request.param == "depth_plane": return build_image_depth_plane_group(build_image_plane_group()) if request.param == "relief_plane": return build_image_relief_plane_group(build_image_plane_group()) return CUTOUT_BUILDERS[request.param]() def _source(socket): while socket.node.bl_idname == "NodeReroute": socket = socket.node.inputs[0].links[0].from_socket node = socket.node return ("INPUT" if node.bl_idname == "NodeGroupInput" else node.name, socket.identifier) def _value(value): if isinstance(value, (str, int, float, bool)) or value is None: return value if isinstance(value, bpy.types.ID): return value.as_pointer() return tuple(value) def _program(node_group): nodes = {} for node in node_group.nodes: if node.bl_idname in {"NodeReroute", "NodeGroupInput", "NodeFrame"}: continue settings = { name: _value(getattr(node, name)) for name in ("operation", "data_type", "input_type", "domain", "mode", "clamp", "interpolation", "interpolation_type", "extension", "pattern_mode") if hasattr(node, name) } inputs = [ (socket.identifier, _value(socket.default_value) if hasattr(socket, "default_value") else None, [_source(link.from_socket) for link in sorted( socket.links, key=lambda link: link.multi_input_sort_id, reverse=True)]) for socket in node.inputs ] paired = getattr(node, "paired_output", None) nodes[node.name] = (node.bl_idname, settings, inputs, paired.name if paired else None) interface = [ (item.identifier, item.name, item.in_out, item.socket_type, _value(item.default_value) if hasattr(item, "default_value") else None) for item in node_group.interface.items_tree if item.item_type == "SOCKET" ] return nodes, interface def _surface(node_group): mesh = bpy.data.meshes.new("Layout Surface") mesh.from_pydata( [(x - 2, y - 1, 0) for y in range(3) for x in range(5)], [], [(y * 5 + x, y * 5 + x + 1, (y + 1) * 5 + x + 1, (y + 1) * 5 + x) for y in range(2) for x in range(4)], ) uv = mesh.uv_layers.new(name="UVMap") for loop in mesh.loops: x, y, _ = mesh.vertices[loop.vertex_index].co uv.data[loop.index].uv = ((x + 2) / 4, (y + 1) / 2) profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT") for vertex, value in zip(mesh.vertices, profile.data): value.value = 0.25 * (1 - abs(vertex.co.x) / 2) * (1 - abs(vertex.co.y)) mesh.materials.append(bpy.data.materials.new("Image")) obj = bpy.data.objects.new("Layout Surface", mesh) bpy.context.collection.objects.link(obj) modifier = obj.modifiers.new("Surface", "NODES") modifier.node_group = node_group image = bpy.data.images.new("Layout Depth", width=16, height=8, float_buffer=True) image.colorspace_settings.name = "Non-Color" image.alpha_mode = "CHANNEL_PACKED" yy, xx = np.mgrid[:8, :16] pixels = np.ones((8, 16, 4), dtype=np.float32) pixels[..., 0] = ((xx + 0.5) / 16 - 0.5) * 4 pixels[..., 1] = -((yy + 0.5) / 8 - 0.5) * 2 pixels[..., 2] = 2 + xx * 0.02 image.pixels.foreach_set(pixels.ravel()) values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image, "Uniform Scale": 1.0, "Reference Depth": 2.0, "Depth Scale": 0.8, "Smooth": 1, "Normal Smooth": 2, "Split Threshold": 0.25} mode = None for item in node_group.interface.items_tree: if item.item_type != "SOCKET" or item.in_out != "INPUT": continue if item.name in values: modifier[item.identifier] = values[item.name] elif item.name == "Mode": mode = item.identifier def evaluate(): results = [] for choice in range(2 if mode else 1): if mode: modifier[mode] = choice obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) evaluated = result.to_mesh() try: assert len(evaluated.vertices) > 0 results.append((np.array([vertex.co[:] for vertex in evaluated.vertices]), [tuple(face.vertices) for face in evaluated.polygons])) finally: result.to_mesh_clear() return results return evaluate 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() arrange_nodes(node_group) assert _program(node_group) == program 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} 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_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_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 # 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_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 def test_dimensions_include_unlinked_vector_controls_and_collapsed_sockets(): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Dimensions", "GeometryNodeTree") source = node_group.nodes.new("GeometryNodeInputPosition") vector = node_group.nodes.new("ShaderNodeVectorMath") vector.operation = "ADD" expanded = estimate_node_dimensions(vector) node_group.links.new(source.outputs[0], vector.inputs[0]) linked = estimate_node_dimensions(vector) assert expanded.height - linked.height == 3 * RULES["row"] vector.hide = True collapsed = estimate_node_dimensions(vector) assert collapsed.height < linked.height assert all(0 < offset < collapsed.height for offset in collapsed.sockets.values()) def test_local_inputs_preserve_identifiers_with_duplicate_display_names(): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Controls", "GeometryNodeTree") first = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat") second = node_group.interface.new_socket(name="Thickness", in_out="INPUT", socket_type="NodeSocketFloat") first.default_value, second.default_value = 0.25, 0.75 node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") controls = node_group.nodes.new("NodeGroupInput") grid = node_group.nodes.new("GeometryNodeMeshGrid") output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(controls.outputs[first.identifier], grid.inputs["Size X"]) node_group.links.new(controls.outputs[second.identifier], grid.inputs["Size Y"]) node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"]) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program @pytest.mark.parametrize("branch_lengths", [(1, 1), (1, 3), (3, 1), (1, 2, 3)]) def test_geometry_paths_order_join_branches_and_separate_their_shared_input(branch_lengths): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Parallel Geometry", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = node_group.nodes.new("GeometryNodeMeshGrid") join = node_group.nodes.new("GeometryNodeJoinGeometry") output = node_group.nodes.new("NodeGroupOutput") branches = [] for length in branch_lengths: geometry = source.outputs["Mesh"] branch = [] for _ in range(length): position = node_group.nodes.new("GeometryNodeSetPosition") node_group.links.new(geometry, position.inputs["Geometry"]) geometry = position.outputs["Geometry"] branch.append(position) node_group.links.new(geometry, join.inputs["Geometry"]) branches.append(branch) node_group.links.new(source.outputs["Mesh"], join.inputs["Geometry"]) node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) geometry_parents, paths = geometry_paths(node_group) assert set(geometry_parents) == {join, output, *(node for branch in branches for node in branch)} assert [join, output] in paths assert all(branch in paths for branch in branches) assert source not in geometry_parents assert sum(len(path) for path in paths) == len(geometry_parents) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program 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_absolute.y for node in geometry_parents} field = node_group.nodes.new("GeometryNodeInputPosition").outputs["Position"] calculations = [field.node] for _ in range(30): operation = node_group.nodes.new("ShaderNodeVectorMath") operation.operation = "ADD" node_group.links.new(field, operation.inputs[0]) field = operation.outputs["Vector"] calculations.append(operation) node_group.links.new(field, branches[0][0].inputs["Offset"]) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program 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(): from tools.nodes.preview.export import _node_group_data bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Sampled Geometry", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = node_group.nodes.new("GeometryNodeMeshGrid") separate = node_group.nodes.new("GeometryNodeSeparateGeometry") sample = node_group.nodes.new("GeometryNodeSampleIndex") sample.data_type = "FLOAT_VECTOR" position = node_group.nodes.new("GeometryNodeSetPosition") output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(source.outputs["Mesh"], separate.inputs["Geometry"]) node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"]) node_group.links.new(sample.outputs["Value"], position.inputs["Position"]) node_group.links.new(source.outputs["Mesh"], position.inputs["Geometry"]) node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) geometry_parents, paths = geometry_paths(node_group) assert set(geometry_parents) == {source, position, output} assert paths == [[source, position, output]] program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]} assert roles[separate.name] == roles[sample.name] == "field" assert roles[position.name] == "geometry" node_group.links.new(separate.outputs["Selection"], output.inputs["Geometry"]) roles = {node["name"]: node["role"] for node in _node_group_data(node_group)["nodes"]} assert roles[separate.name] == "geometry" def test_shared_field_references_branch_near_their_consumers(): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Shared Field", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = node_group.nodes.new("GeometryNodeInputPosition") geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] consumers = [] for index in range(8): node = node_group.nodes.new("GeometryNodeSetPosition") node_group.links.new(geometry, node.inputs["Geometry"]) if index in (0, 4, 7): node_group.links.new(source.outputs["Position"], node.inputs["Offset"]) consumers.append(node) geometry = node.outputs["Geometry"] output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(geometry, output.inputs["Geometry"]) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program for node in consumers[1:]: branch = node.inputs["Offset"].links[0].from_node assert branch.bl_idname == "NodeReroute" 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_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") @pytest.mark.parametrize("side", ["upper", "lower"]) def test_field_modules_reserve_lane_and_exit_clearance(side): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Branch Rectangles", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = node_group.nodes.new("GeometryNodeMeshGrid") join = node_group.nodes.new("GeometryNodeJoinGeometry") output = node_group.nodes.new("NodeGroupOutput") branches = [] for _ in range(2): geometry, branch = source.outputs["Mesh"], [] for _ in range(5): node = node_group.nodes.new("GeometryNodeSetPosition") node_group.links.new(geometry, node.inputs["Geometry"]) geometry = node.outputs["Geometry"] branch.append(node) branches.append(branch) 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_absolute.y, reverse=True) lane = branches[0 if side == "upper" else -1] 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] branch = [value.node] for _ in range(8): node = node_group.nodes.new("ShaderNodeVectorMath") node.operation = "ADD" node_group.links.new(value, node.inputs[0]) value = node.outputs["Vector"] branch.append(node) node_group.links.new(value, target.inputs["Offset"]) fields.append(branch) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program 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_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_absolute.y + BRANCH_GAP - 0.001 else: 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) if side == "lower": 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_absolute.x + terminal.width < target.location_absolute.x socket = terminal.outputs["Vector"] 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 y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t**2*y2 + t**3*y2 left, right, top, bottom = other assert not np.any((left < x) & (x < right) & (bottom < y) & (y < top)) def test_boolean_operands_follow_socket_order_and_center_the_junction(): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Boolean Operands", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") comparisons = [node_group.nodes.new("FunctionNodeCompare") for _ in range(2)] operation = node_group.nodes.new("FunctionNodeBooleanMath") operation.operation = "OR" grid = node_group.nodes.new("GeometryNodeMeshGrid") output = node_group.nodes.new("NodeGroupOutput") for source, socket in zip(comparisons, operation.inputs): node_group.links.new(source.outputs[0], socket) node_group.links.new(operation.outputs[0], grid.inputs["Size X"]) node_group.links.new(grid.outputs["Mesh"], output.inputs["Geometry"]) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program 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_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): from tools.nodes.preview.export import write_node_preview bpy.ops.wm.read_factory_settings(use_empty=True) node_group = build_image_plane_group() arrange_nodes(node_group) node_group.nodes[0].name = '' program = _program(node_group) 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("") assert '__NODE_DATA__' not in markup assert '' not in markup assert '\\u003c/script>' in markup assert _program(node_group) == program 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(): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Geometry Bypass", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") source = node_group.nodes.new("GeometryNodeMeshGrid") transform = node_group.nodes.new("GeometryNodeTransform") switch = node_group.nodes.new("GeometryNodeSwitch") switch.input_type = "GEOMETRY" output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(source.outputs["Mesh"], transform.inputs["Geometry"]) node_group.links.new(source.outputs["Mesh"], switch.inputs["False"]) node_group.links.new(transform.outputs["Geometry"], switch.inputs["True"]) node_group.links.new(switch.outputs["Output"], output.inputs["Geometry"]) program = _program(node_group) arrange_nodes(node_group) 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_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_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) node_group = bpy.data.node_groups.new("Zone Preview", "GeometryNodeTree") source = node_group.nodes.new("GeometryNodeMeshGrid") entry = node_group.nodes.new(f"GeometryNode{zone_type}Input") end = node_group.nodes.new(f"GeometryNode{zone_type}Output") entry.pair_with_output(end) body = node_group.nodes.new("GeometryNodeSetPosition") node_group.links.new(source.outputs["Mesh"], entry.inputs["Geometry"]) geometry = next(socket for socket in entry.outputs if socket.type == "GEOMETRY") 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) 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"]} 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"] assert zone["y"] <= node["y"] and node["y"] + node["h"] <= zone["y"] + zone["h"] def test_repeat_geometry_collector_stays_in_the_local_body(): 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) entry = node_group.nodes["Repeat Input"] 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_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(): bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Shared Geometry References", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") shared = node_group.nodes.new("GeometryNodeMeshGrid") geometry = node_group.nodes.new("GeometryNodeMeshGrid").outputs["Mesh"] samplers = [] for index in range(10): node = node_group.nodes.new("GeometryNodeSetPosition") node_group.links.new(geometry, node.inputs["Geometry"]) geometry = node.outputs["Geometry"] if index in (0, 5, 9): sample = node_group.nodes.new("GeometryNodeSampleIndex") sample.data_type = "FLOAT_VECTOR" node_group.links.new(shared.outputs["Mesh"], sample.inputs["Geometry"]) node_group.links.new(sample.outputs["Value"], node.inputs["Offset"]) samplers.append(sample) output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(geometry, output.inputs["Geometry"]) program = _program(node_group) arrange_nodes(node_group) for sample in samplers[1:]: branch = sample.inputs["Geometry"].links[0].from_node assert branch.bl_idname == "NodeReroute" 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) assert _program(node_group) == program def test_reused_geometry_processing_keeps_its_upstream_result_path(): from tools.nodes.arrangement.flow import node_roles bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Reused Processing", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") grid = node_group.nodes.new("GeometryNodeMeshGrid") transform = node_group.nodes.new("GeometryNodeTransform") position = node_group.nodes.new("GeometryNodeSetPosition") output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(grid.outputs["Mesh"], transform.inputs["Geometry"]) node_group.links.new(transform.outputs["Geometry"], position.inputs["Geometry"]) node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) for name in ("Selection", "Offset"): separate = node_group.nodes.new("GeometryNodeSeparateGeometry") sample = node_group.nodes.new("GeometryNodeSampleIndex") node_group.links.new(transform.outputs["Geometry"], separate.inputs["Geometry"]) node_group.links.new(separate.outputs["Selection"], sample.inputs["Geometry"]) node_group.links.new(sample.outputs["Value"], position.inputs[name]) arrange_nodes(node_group) roles = node_roles(node_group) assert all(roles[node] == "geometry" for node in (grid, transform, position, output)) 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"}) def test_geometry_preparation_shared_by_independent_paths_becomes_a_public_input(): from tools.nodes.arrangement.flow import node_roles bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Public Prepared Geometry", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") grid = node_group.nodes.new("GeometryNodeMeshGrid") prepare = node_group.nodes.new("GeometryNodeTransform") join = node_group.nodes.new("GeometryNodeJoinGeometry") output = node_group.nodes.new("NodeGroupOutput") node_group.links.new(grid.outputs["Mesh"], prepare.inputs["Geometry"]) branches = [] for _ in range(3): node = node_group.nodes.new("GeometryNodeSetPosition") node_group.links.new(prepare.outputs["Geometry"], node.inputs["Geometry"]) node_group.links.new(node.outputs["Geometry"], join.inputs["Geometry"]) branches.append(node) node_group.links.new(join.outputs["Geometry"], output.inputs["Geometry"]) program = _program(node_group) arrange_nodes(node_group) assert _program(node_group) == program roles = node_roles(node_group) assert roles[grid] == roles[prepare] == "field" assert all(roles[node] == "geometry" for node in branches) assert prepare.location_absolute.x + prepare.width < min(node.location_absolute.x for node in branches) 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 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_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_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 in {"NodeReroute", "NodeFrame"}: continue 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 start, target = link_endpoints(link, positions, sizes) if abs(start[1] - target[1]) > 0.01: continue for node in node_group.nodes: 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_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(): from tools.nodes.arrangement.connections import link_endpoints bpy.ops.wm.read_factory_settings(use_empty=True) node_group = bpy.data.node_groups.new("Expression Passages", "GeometryNodeTree") node_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") grid = node_group.nodes.new("GeometryNodeMeshGrid") position = node_group.nodes.new("GeometryNodeSetPosition") output = node_group.nodes.new("NodeGroupOutput") seed = node_group.nodes.new("GeometryNodeInputIndex") previous = node_group.nodes.new("GeometryNodeInputMeshFaceArea").outputs[0] fields = [seed, previous.node] for _ in range(24): operation = node_group.nodes.new("ShaderNodeMath") operation.operation = "ADD" node_group.links.new(previous, operation.inputs[0]) node_group.links.new(seed.outputs[0], operation.inputs[1]) previous = operation.outputs[0] fields.append(operation) node_group.links.new(grid.outputs["Mesh"], position.inputs["Geometry"]) node_group.links.new(previous, position.inputs["Offset"]) node_group.links.new(position.outputs["Geometry"], output.inputs["Geometry"]) 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_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: continue (x1, y1), (x2, y2) = link_endpoints(link, positions, sizes) bend = max(40, abs(x2 - x1) * 0.42) for t in np.linspace(0, 1, 100): x = (1-t)**3*x1 + 3*(1-t)**2*t*(x1+bend) + 3*(1-t)*t*t*(x2-bend) + t**3*x2 y = (1-t)**3*y1 + 3*(1-t)**2*t*y1 + 3*(1-t)*t*t*y2 + t**3*y2 for node in fields: if node in {link.from_node, link.to_node}: 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