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:
@@ -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
|
||||
|
||||
@@ -155,7 +155,7 @@ def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness(source):
|
||||
from tests.support.planes import create_surface, evaluated as plane_mesh
|
||||
|
||||
obj, set_value, _, inputs, image = create_surface(source, "DEPTH")
|
||||
assert inputs["Boundary Smooth"].default_value == 5
|
||||
assert inputs["Boundary Smooth"].default_value == 2
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
|
||||
yy, xx = np.mgrid[:8, :16]
|
||||
@@ -216,15 +216,18 @@ def test_every_split_node_enables_boundary_smoothing_by_default(source):
|
||||
continue
|
||||
split_groups.append(group.name)
|
||||
smooth = inputs["Boundary Smooth"]
|
||||
assert smooth.default_value == 5
|
||||
assert (smooth.min_value, smooth.max_value) == (0, 20)
|
||||
assert smooth.default_value == (5 if group.name == "O Image Depth Panorama" else 2)
|
||||
assert (smooth.min_value, smooth.max_value) == (1 if "Outline Depth Fix" in inputs else 0, 20)
|
||||
assert smooth.parent.name == "Options"
|
||||
assert not {"Depth Blur Iterations", "Depth Band Rings", "Depth Blur Strength", "Skip Inactive Geometry"} & inputs.keys()
|
||||
depth_mix = next(n for n in group.nodes if n.bl_idname == "ShaderNodeMix"
|
||||
and n.data_type == "FLOAT" and n.inputs[3].is_linked
|
||||
and origin_socket(n.inputs[3].links[0].from_socket).node.bl_idname == "GeometryNodeBlurAttribute")
|
||||
depth_blur = origin_socket(depth_mix.inputs[3].links[0].from_socket).node
|
||||
assert origin_socket(depth_blur.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"
|
||||
depth_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute"
|
||||
and n.data_type == "FLOAT" and n.inputs["Iterations"].is_linked
|
||||
and origin_socket(n.inputs["Iterations"].links[0].from_socket).name == "Boundary Smooth"]
|
||||
assert len(depth_blurs) == 1
|
||||
if "Outline Depth Fix" in inputs:
|
||||
assert inputs["Outline Depth Fix"].default_value == 16
|
||||
assert origin_socket(depth_blurs[0].inputs["Weight"].links[0].from_socket).node.bl_idname == "GeometryNodeSampleIndex"
|
||||
continue
|
||||
depth_bypass = [n for n in group.nodes if n.bl_idname == "GeometryNodeSwitch"
|
||||
and n.input_type == "FLOAT" and n.inputs["Switch"].is_linked
|
||||
and origin_socket(n.inputs["Switch"].links[0].from_socket).name == "Boundary Smooth"]
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_cleaned_sawtooth_smoothing_preserves_face_orientation(iterations):
|
||||
|
||||
@pytest.mark.parametrize("resolution", [12, 24])
|
||||
@pytest.mark.parametrize("aspect", [.5, 2])
|
||||
def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect):
|
||||
def test_split_profile_tapers_within_boundary_band(resolution, aspect):
|
||||
obj, set_value = surface(step=6, resolution=resolution)
|
||||
for vertex in obj.data.vertices:
|
||||
vertex.co.x *= aspect
|
||||
@@ -73,16 +73,11 @@ def test_split_profile_tapers_and_rounding_remains_live(resolution, aspect):
|
||||
band = edge_band(faces, cut)
|
||||
outside = [i for i in range(len(points)) if i not in band]
|
||||
assert outside
|
||||
results = []
|
||||
for amount in (0, .2, .6):
|
||||
set_value("Edge Round", amount)
|
||||
values = profile_values(obj)
|
||||
np.testing.assert_array_equal(values[cut], 0)
|
||||
np.testing.assert_allclose(values[outside], .3)
|
||||
assert np.isfinite(values).all()
|
||||
assert values.max() == pytest.approx(.3)
|
||||
results.append(values)
|
||||
assert np.all(results[2] <= results[0] + 1e-7)
|
||||
values = profile_values(obj)
|
||||
np.testing.assert_array_equal(values[cut], 0)
|
||||
np.testing.assert_allclose(values[outside], .3)
|
||||
assert np.isfinite(values).all()
|
||||
assert values.max() == pytest.approx(.3)
|
||||
set_value("Split Threshold", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
assert len(set(groups.values())) == 3
|
||||
for group in set(groups.values()):
|
||||
validate_node_structure(group)
|
||||
revision = 18 if group == groups["DEPTH_SOLID"] else 11
|
||||
revision = 23 if group == groups["DEPTH_SOLID"] else 12 if group == groups["DEPTH_BALLOON"] else 11
|
||||
assert group.is_modifier and group.get("anyimage_asset_revision") == revision
|
||||
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
|
||||
inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
||||
@@ -36,21 +36,25 @@ def test_cutout_interfaces_and_thickness_modes(source):
|
||||
assert inputs["Double Sided"].default_value
|
||||
if group == groups["DEPTH_SOLID"]:
|
||||
assert np.isclose(inputs["Split Threshold"].default_value, 0.1)
|
||||
assert np.isclose(inputs["Edge Round"].default_value, 0.2)
|
||||
assert inputs["Edge Round"].parent.name == "Options"
|
||||
assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round"
|
||||
assert inputs["Normal Smooth"].default_value == 50
|
||||
assert inputs["Front Inflation"].default_value == 1
|
||||
assert not {"Edge Round", "Normal Smooth"} & inputs.keys()
|
||||
normal_blurs = [n for n in group.nodes if n.bl_idname == "GeometryNodeBlurAttribute" and n.data_type == "FLOAT_VECTOR"
|
||||
and not n.inputs["Iterations"].is_linked and n.inputs["Iterations"].default_value == 100]
|
||||
assert len(normal_blurs) == 2
|
||||
assert inputs["Front Inflation"].default_value == 0
|
||||
assert inputs["Front Inflation"].subtype == "FACTOR"
|
||||
assert (inputs["Front Inflation"].min_value, inputs["Front Inflation"].max_value) == (0, 1)
|
||||
assert inputs["Front Inflation"].parent.name == "Options"
|
||||
assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"}
|
||||
options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"]
|
||||
expected = ["Cleanup Threshold"]
|
||||
if group == groups["DEPTH_SOLID"]:
|
||||
expected.insert(0, "Boundary Smooth")
|
||||
expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
|
||||
expected += ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Outline Depth Fix", "Front Inflation"]
|
||||
assert inputs["Outline Depth Fix"].default_value == 16
|
||||
assert not set(inputs) & {"Ratio Lower", "Ratio Upper", "Weight Blur Iterations", "Weight Update Rounds", "Depth Steps Per Round"}
|
||||
elif group == groups["DEPTH_BALLOON"]:
|
||||
expected += ["Reference Depth"]
|
||||
expected += ["Smooth Weight"]
|
||||
expected += ["Smooth Weight", "Reference Depth"]
|
||||
if group not in (groups["DEPTH_SOLID"], groups["DEPTH_BALLOON"]):
|
||||
expected += ["Smooth Weight"]
|
||||
if group == groups["DEPTH_BALLOON"]:
|
||||
expected += ["Depth Direction"]
|
||||
assert options == expected, (group.name, options)
|
||||
|
||||
@@ -4,9 +4,7 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.nodes.groups.image_depth_cutout import build_volume_fields
|
||||
from tools.nodes.groups.image_depth_cutout import build_volume_rounding
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tools.nodes.common.nodes import sample_field
|
||||
|
||||
|
||||
def store_test_fields(group, geometry, fields):
|
||||
@@ -37,8 +35,7 @@ def set_test_structure(monkeypatch, value):
|
||||
monkeypatch.setattr(image_depth_cutout, "build_front_fields", fields)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("iterations", [0, 50])
|
||||
def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
|
||||
def test_uniform_thickness_follows_the_tilted_front_normal():
|
||||
from scipy.spatial import cKDTree
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
obj, set_value = surface(step=0, resolution=4)
|
||||
@@ -47,7 +44,8 @@ def test_uniform_thickness_follows_the_tilted_front_normal(iterations):
|
||||
pixels[..., 2] = 1 + 0.4 * pixels[..., 0]
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Normal Smooth", iterations)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
front, _ = evaluated(obj)
|
||||
set_value("Thickness", 0.2)
|
||||
points, faces = evaluated(obj)
|
||||
@@ -66,8 +64,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
|
||||
profile[abs(profile) < 1e-7] = 0
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
set_value("Mode", 0)
|
||||
set_value("Edge Round", 0.0)
|
||||
set_value("Normal Smooth", 0)
|
||||
modifier = obj.modifiers[0]
|
||||
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")
|
||||
def amount(value):
|
||||
@@ -85,57 +81,6 @@ def test_balloon_shell_converges_to_front_with_thickness():
|
||||
np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", [0.0, 1.0])
|
||||
def test_rounding_shares_front_and_back_falloff(amount):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
n = 15
|
||||
xy = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)])
|
||||
balloon = 0.4 * np.maximum(0, (1 - xy[:, 0]**2) * (1 - xy[:, 1]**2))
|
||||
depth = 0.2 * xy[:, 0] + 0.3 * xy[:, 1] + 1.4 * balloon
|
||||
faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)]
|
||||
mesh = bpy.data.meshes.new("Round profile")
|
||||
mesh.from_pydata(np.column_stack((xy, depth)), [], faces)
|
||||
mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", balloon)
|
||||
obj = bpy.data.objects.new("Round profile", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree")
|
||||
for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Uniform Thickness", "NodeSocketFloat", 0), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)):
|
||||
socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind)
|
||||
if default is not None:
|
||||
socket.default_value = default
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
fields = build_volume_fields(group)
|
||||
geometry = source.outputs["Geometry"]
|
||||
center = {"center": sample_field(group, geometry, fields["center"], "FLOAT")}
|
||||
mode = group.nodes.new("FunctionNodeInputBool")
|
||||
result, rounded = build_volume_rounding(group, geometry, fields, mode.outputs[0])
|
||||
rounded = {name: sample_field(group, geometry, value, "FLOAT_VECTOR" if name == "normal" else "FLOAT")
|
||||
for name, value in rounded.items()}
|
||||
result = store_test_fields(group, result, {**center, **rounded})
|
||||
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Round", "NODES").node_group = group
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
def values(name):
|
||||
return np.array([v.value for v in result.attributes[name].data])
|
||||
center = values("center")
|
||||
u = balloon / balloon.max()
|
||||
weight = amount * (1 - u**3 * (u * (6*u - 15) + 10))
|
||||
delta = (center + balloon - depth) * weight
|
||||
original = np.maximum(depth - center + balloon, 0.05 * balloon)
|
||||
np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6)
|
||||
np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6)
|
||||
np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6)
|
||||
normals = np.array([v.vector[:] for v in result.attributes["normal"].data])
|
||||
np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6)
|
||||
assert np.max(abs(normals[:, :2])) > 0.1
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_smooth_back_rejects_front_noise_and_preserves_center_depth():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
n = 41
|
||||
@@ -197,7 +142,8 @@ def test_depth_cutout_normal_map_only_affects_front_faces():
|
||||
|
||||
@pytest.mark.parametrize("structure", [0, 1, 4])
|
||||
@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1])
|
||||
def test_uniform_front_inflation_scales_with_distance(thickness, structure, monkeypatch):
|
||||
@pytest.mark.parametrize("inflation", [0.0, 0.0001, 0.5, 1.0])
|
||||
def test_uniform_front_inflation_scales_with_distance(thickness, structure, inflation, monkeypatch):
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed
|
||||
set_test_structure(monkeypatch, structure)
|
||||
obj, set_value = surface(step=0, resolution=10)
|
||||
@@ -208,12 +154,12 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, monk
|
||||
set_value("Thickness", thickness)
|
||||
set_value("Front Inflation", 0.0)
|
||||
baseline, faces = evaluated(obj)
|
||||
set_value("Front Inflation", 1.0)
|
||||
set_value("Front Inflation", inflation)
|
||||
inflated, inflated_faces = evaluated(obj)
|
||||
assert faces == inflated_faces
|
||||
delta = np.linalg.norm(inflated - baseline, axis=1)
|
||||
front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6)
|
||||
np.testing.assert_allclose(delta[front], thickness, atol=2e-6)
|
||||
np.testing.assert_allclose(delta[front], thickness * inflation, atol=2e-6)
|
||||
np.testing.assert_allclose(delta[~front], 0, atol=2e-6)
|
||||
if thickness:
|
||||
assert_closed(inflated_faces)
|
||||
@@ -221,35 +167,6 @@ def test_uniform_front_inflation_scales_with_distance(thickness, structure, monk
|
||||
np.testing.assert_array_equal(inflated, baseline)
|
||||
|
||||
|
||||
def test_normal_smooth_controls_front_and_rear_directions():
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
obj, set_value = surface(step=1, resolution=10)
|
||||
set_value("Front Inflation", 1.0)
|
||||
xy = np.array([v.co[:2] for v in obj.data.vertices])
|
||||
profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1])
|
||||
profile[abs(profile) < 1e-7] = 0
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
group = obj.modifiers[0].node_group
|
||||
set_value("Mode", 0)
|
||||
set_modifier_input(obj.modifiers[0], modifier_input_identifier(group, "Thickness", subtype="NONE"), 1.0)
|
||||
masks = []
|
||||
coordinates = []
|
||||
for iterations in (0, 50):
|
||||
set_value("Normal Smooth", iterations)
|
||||
points, faces = evaluated(obj)
|
||||
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = ev.to_mesh()
|
||||
mask = [v.value for v in mesh.attributes["o_normal_scale"].data]
|
||||
front = {v for face, weight in zip(faces, mask) if weight > 0.5 for v in face}
|
||||
rear = set(range(len(points))) - front
|
||||
ev.to_mesh_clear()
|
||||
coordinates.append(points)
|
||||
masks.append((front, rear))
|
||||
assert masks[0] == masks[1]
|
||||
for ids in masks[0]:
|
||||
assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5
|
||||
|
||||
|
||||
def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch):
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
set_test_structure(monkeypatch, 1)
|
||||
@@ -260,7 +177,6 @@ def test_balloon_suppression_is_stronger_without_a_startup_threshold(monkeypatch
|
||||
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile)
|
||||
group = obj.modifiers[0].node_group
|
||||
set_value("Mode", 0)
|
||||
set_value("Edge Round", 0)
|
||||
thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE")
|
||||
previous = 0
|
||||
for thickness in (0.001, 0.01, 0.1, 0.5, 1):
|
||||
|
||||
@@ -160,8 +160,7 @@ def test_zero_thickness_relief_keeps_far_depth_at_object_space_zero(relief, refe
|
||||
np.testing.assert_array_equal(result[2], base[2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("normal_smooth", [0, 2, 50])
|
||||
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, normal_smooth):
|
||||
def test_curved_shell_smooths_normals_without_shrinking_thickness(surface):
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
obj, set_value, _, _, image = surface
|
||||
@@ -181,7 +180,6 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
|
||||
raw_normals = np.array([v.normal[:] for v in mesh.vertices])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
set_value("Normal Smooth", normal_smooth)
|
||||
thickness = 0.001
|
||||
set_value("Thickness", thickness)
|
||||
points = evaluated(obj)[0]
|
||||
@@ -196,18 +194,15 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
|
||||
edges = np.array(list({tuple(sorted((a, b))) for f in faces
|
||||
for a, b in zip(f, (*f[1:], f[0]))}))
|
||||
roughness = lambda n: np.mean(np.linalg.norm(n[edges[:, 0]] - n[edges[:, 1]], axis=1))
|
||||
if normal_smooth == 0:
|
||||
np.testing.assert_allclose(normals, raw_normals, atol=2e-4)
|
||||
else:
|
||||
assert roughness(normals) < roughness(raw_normals)
|
||||
assert roughness(normals) < roughness(raw_normals)
|
||||
|
||||
|
||||
def test_surface_interface_and_node_structure(surface):
|
||||
obj, _, _, inputs, _ = surface
|
||||
expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
|
||||
"Boundary Smooth", "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
|
||||
"Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold", "Uniform Scale", "Depth Image"]
|
||||
assert list(inputs) == expected
|
||||
assert inputs["Split Threshold"].default_value == 0
|
||||
assert inputs["Split Threshold"].default_value == pytest.approx(0.1)
|
||||
assert inputs["Split Threshold"].min_value == 0
|
||||
assert inputs["Split Threshold"].max_value == 1
|
||||
assert inputs["Split Threshold"].subtype == "FACTOR"
|
||||
@@ -222,10 +217,7 @@ def test_surface_interface_and_node_structure(surface):
|
||||
assert inputs["Smooth Weight"].default_value == 1
|
||||
assert inputs["Smooth Weight"].subtype == "FACTOR"
|
||||
assert inputs["Smooth Weight"].parent.name == "Options"
|
||||
assert inputs["Normal Smooth"].default_value == 50
|
||||
assert inputs["Normal Smooth"].min_value == 0
|
||||
assert inputs["Normal Smooth"].max_value == 50
|
||||
assert inputs["Normal Smooth"].parent.name == "Options"
|
||||
assert not {"Normal Smooth", "Cleanup Threshold"} & inputs.keys()
|
||||
assert inputs["Thickness"].default_value == 0
|
||||
assert inputs["Subdivide"].default_value == 6
|
||||
assert inputs["Depth Scale"].default_value == 1
|
||||
@@ -414,8 +406,7 @@ def test_plane_interface_panels(source, kind):
|
||||
assert [item.name for item in panels] == ["Options", "Data"]
|
||||
controls = ["Reference Depth"]
|
||||
if kind == "DEPTH":
|
||||
controls.insert(0, "Boundary Smooth")
|
||||
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
|
||||
controls = ["Smooth Weight", "Reference Depth", "Boundary Smooth", "Validity Threshold"]
|
||||
else:
|
||||
controls += ["Smooth Weight", "Depth Direction"]
|
||||
assert [item.name for item in panels[0].interface_items] == controls
|
||||
|
||||
@@ -134,7 +134,7 @@ def test_split_input_precedes_smooth_without_cleanup_switch(kind):
|
||||
split = inputs[names.index("Split Threshold")]
|
||||
assert split.parent == inputs[names.index("Smooth")].parent
|
||||
assert split.parent.name != "Options"
|
||||
assert split.default_value == pytest.approx(0.1 if kind == "cutout" else 0)
|
||||
assert split.default_value == pytest.approx(0.1)
|
||||
assert (split.min_value, split.max_value, split.subtype) == (0, 1, "FACTOR")
|
||||
assert "Remove Strip Faces" not in names
|
||||
|
||||
@@ -157,6 +157,7 @@ def test_strips_are_removed_before_projection_and_closed_thickness(kind):
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
set_value("Reference Depth", 11.0)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Split Threshold", 0.5)
|
||||
vertices, faces = evaluated(obj)
|
||||
|
||||
@@ -66,7 +66,7 @@ def test_surface_caps_and_segmented_walls_are_connected(thickness, split):
|
||||
@pytest.mark.parametrize("size", [0.01, 1.0, 100.0])
|
||||
def test_split_mapping_and_uniform_scale(size):
|
||||
obj, set_value = surface(step=2.0, size=size)
|
||||
# Face centers are size/2 apart: gradient is 4.
|
||||
# The normalized ray jump crosses the threshold only at full strength.
|
||||
counts = []
|
||||
for split in (0.0, 0.25, 0.5, 1.0):
|
||||
set_value("Split Threshold", split)
|
||||
@@ -80,12 +80,12 @@ def test_split_mapping_and_uniform_scale(size):
|
||||
assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6
|
||||
|
||||
|
||||
def test_midpoint_selects_gradient_six_and_preserves_faces():
|
||||
obj, set_value = surface(step=3.0)
|
||||
def test_midpoint_selects_relative_ray_jump_and_preserves_faces():
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
assert len(coords) == 18 and len(faces) == 8
|
||||
assert set(np.round(coords[:, 2], 4)) == {0.0, -3.0}
|
||||
assert set(np.round(coords[:, 2], 4)) == {2.0, -2.0}
|
||||
|
||||
|
||||
def test_cleanup_keeps_split_parts_of_a_large_source_island():
|
||||
@@ -102,7 +102,7 @@ def test_cleanup_keeps_split_parts_of_a_large_source_island():
|
||||
|
||||
@pytest.mark.parametrize("depth_scale", [0.0, 0.25, 1.0, 2.0, 4.0])
|
||||
def test_split_topology_uses_depth_before_display_scaling(depth_scale):
|
||||
obj, set_value = surface(step=3.0)
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Depth Scale", depth_scale)
|
||||
@@ -203,7 +203,7 @@ def test_small_positive_split_preserves_unsplit_projection():
|
||||
|
||||
|
||||
def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
obj, set_value = surface(step=3.0)
|
||||
obj, set_value = surface(step=4.0)
|
||||
original, original_faces = evaluated(obj)
|
||||
set_value("Split Threshold", 0.5)
|
||||
coords, faces = evaluated(obj)
|
||||
@@ -212,14 +212,14 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices():
|
||||
before, after = original[source_index], coords[result_index]
|
||||
if obj.data.vertices[source_index].co.x != 1.0:
|
||||
np.testing.assert_allclose(after, before, atol=1e-6)
|
||||
before_depth, after_depth = 1.0 - before[2], 1.0 - after[2]
|
||||
before_depth, after_depth = 3.0 - before[2], 3.0 - after[2]
|
||||
np.testing.assert_allclose(
|
||||
after[:2] / after_depth, before[:2] / before_depth, atol=1e-6
|
||||
)
|
||||
|
||||
|
||||
def test_source_surface_sampling_preserves_depth_on_side_walls():
|
||||
obj, set_value = surface(step=3.0)
|
||||
obj, set_value = surface(step=4.0)
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Thickness", 0.1)
|
||||
coords, faces = evaluated(obj)
|
||||
@@ -286,7 +286,7 @@ def test_zero_center_distance_does_not_split_or_produce_nan():
|
||||
|
||||
|
||||
def test_triangle_corner_weights_do_not_open_sloped_side_walls():
|
||||
obj, set_value = surface(step=3.0)
|
||||
obj, set_value = surface(step=4.0)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(bm, faces=list(bm.faces))
|
||||
@@ -305,7 +305,7 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolution", [1, 2, 4])
|
||||
def test_linear_depth_slope_has_density_independent_selection(resolution):
|
||||
def test_relative_distance_slope_has_density_independent_selection(resolution):
|
||||
obj, set_value = surface(step=0.0, resolution=resolution)
|
||||
output_split_mesh(obj)
|
||||
bm = bmesh.new()
|
||||
@@ -315,10 +315,65 @@ def test_linear_depth_slope_has_density_independent_selection(resolution):
|
||||
bm.free()
|
||||
image = bpy.data.images["Camera"]
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
|
||||
pixels[..., 2] = 1 + 4 * pixels[..., 0]
|
||||
pixels[..., 2] = np.exp(6 * pixels[..., 0])
|
||||
pixels[..., :2] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Split Threshold", 0.5)
|
||||
set_value("Split Threshold", 0.25)
|
||||
assert len(evaluated(obj)[0]) == len(obj.data.vertices)
|
||||
set_value("Split Threshold", 1.0)
|
||||
assert len(evaluated(obj)[0]) > len(obj.data.vertices)
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
@pytest.mark.parametrize("source", ["definition", "asset"])
|
||||
def test_split_uses_ray_distance_and_ignores_absolute_distance_scale(kind, source):
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface(source, "DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0)
|
||||
if source == "asset":
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
with bpy.data.libraries.load(str(PROJECT_ROOT / "src/anyimage/assets/O_AnyImage.blend")) as (_, target):
|
||||
target.node_groups = ["O Image Depth Cutout"]
|
||||
obj.modifiers[0].node_group = target.node_groups[0]
|
||||
image = bpy.data.images["Camera"]
|
||||
output_split_mesh(obj)
|
||||
set_value("Reference Depth", 3)
|
||||
set_value("Split Threshold", 0)
|
||||
width, height = image.size
|
||||
pixels = np.ones((height, width, 4), np.float32)
|
||||
original_count = len(evaluated(obj)[0])
|
||||
set_value("Split Threshold", 1)
|
||||
# Equal axial Z, but a tenfold ray-distance jump: Z-only splitting misses it.
|
||||
pixels[..., :3] = (0, 0, 1)
|
||||
pixels[:, width // 2:, 0] = np.sqrt(99)
|
||||
signature = None
|
||||
for scale in (0.01, 1.0, 100.0):
|
||||
scaled = pixels.copy()
|
||||
scaled[..., :3] *= scale
|
||||
image.pixels.foreach_set(scaled.ravel())
|
||||
image.update()
|
||||
set_value("Depth Image", image)
|
||||
points, faces = evaluated(obj)
|
||||
assert len(points) > original_count
|
||||
if signature is None:
|
||||
signature = points, faces
|
||||
np.testing.assert_array_equal(points, signature[0])
|
||||
assert faces == signature[1]
|
||||
# Equal ray distances with different Z must keep the faces connected.
|
||||
pixels[..., :3] = (0, 0, 10)
|
||||
pixels[:, width // 2:, :3] = (8, 0, 6)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Image", image)
|
||||
assert len(evaluated(obj)[0]) == original_count
|
||||
# Degenerate distances and a zero reference remain finite and connected.
|
||||
pixels[..., :3] = 0
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Reference Depth", 0)
|
||||
points, _ = evaluated(obj)
|
||||
assert len(points) == original_count and np.isfinite(points).all()
|
||||
|
||||
@@ -11,7 +11,7 @@ from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_g
|
||||
from tests.support.nodes import validate_node_structure
|
||||
|
||||
|
||||
def panorama(depth=3, alpha=1, subdivide=3, reference=3):
|
||||
def panorama(depth=3, alpha=1, subdivide=3):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = build_image_depth_panorama_group()
|
||||
assert group.name == "O Image Depth Panorama"
|
||||
@@ -34,7 +34,7 @@ def panorama(depth=3, alpha=1, subdivide=3, reference=3):
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
for name, value in (("Depth Image", image), ("Subdivide", subdivide), ("Reference Distance", reference)):
|
||||
for name, value in (("Depth Image", image), ("Subdivide", subdivide)):
|
||||
set_value(name, value)
|
||||
set_value("Boundary Smooth", 0)
|
||||
return obj, set_value
|
||||
@@ -190,7 +190,7 @@ def test_split_strength_and_distance_scale_invariance():
|
||||
depth[:, 64:] = 7
|
||||
signatures = []
|
||||
for scale in (0.01, 1, 100):
|
||||
obj, set_value = panorama(depth * scale, reference=3 * scale)
|
||||
obj, set_value = panorama(depth * scale)
|
||||
counts = []
|
||||
for split in (0, 0.1, 0.5, 1):
|
||||
set_value("Split Threshold", split)
|
||||
|
||||
104
tests/tools/nodes/test_outline_smoothing.py
Normal file
104
tests/tools/nodes/test_outline_smoothing.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Check adaptive outline batches against a numerical graph reference."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import evaluated
|
||||
from tools.nodes.common.outline_smoothing import smooth_outline_camera
|
||||
from tools.nodes.common.depth_surface import CUT_ATTRIBUTE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [1, 4])
|
||||
def test_outline_batches_update_weights_preserve_split_and_camera_rays(steps):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
points = np.array([(x, y, 0) for y in range(9) for x in range(9)], dtype=float)
|
||||
faces = [(y*9+x, y*9+x+1, (y+1)*9+x+1, (y+1)*9+x) for y in range(8) for x in range(8)]
|
||||
deleted = [i % 8 == 4 for i in range(len(faces))]
|
||||
result_faces = [face for face, remove in zip(faces, deleted) if not remove]
|
||||
cut = np.isin(points[:, 0], [4, 5])
|
||||
depth = np.full(81, 2.0)
|
||||
depth[2] = 20
|
||||
depth[80] = 0
|
||||
camera = np.column_stack((points[:, :2]*depth[:, None]/10, depth))
|
||||
distance = np.linalg.norm(camera, axis=1)
|
||||
mesh = bpy.data.meshes.new("Split outline")
|
||||
mesh.from_pydata(points.tolist(), [], faces)
|
||||
for name, kind, domain, values, prop in (
|
||||
("test_camera", "FLOAT_VECTOR", "POINT", camera.ravel(), "vector"),
|
||||
(CUT_ATTRIBUTE, "FLOAT", "POINT", cut, "value"),
|
||||
("test_deleted", "BOOLEAN", "FACE", deleted, "value"),
|
||||
):
|
||||
mesh.attributes.new(name, kind, domain).data.foreach_set(prop, values)
|
||||
obj = bpy.data.objects.new("Outline", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Adaptive outline test", "GeometryNodeTree")
|
||||
for direction in ("INPUT", "OUTPUT"):
|
||||
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
|
||||
ids = {}
|
||||
for name in ("Outline Depth Fix", "Boundary Smooth"):
|
||||
ids[name] = group.interface.new_socket(name=name, in_out="INPUT", socket_type="NodeSocketInt").identifier
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
attributes = {}
|
||||
for name, kind in (("test_camera", "FLOAT_VECTOR"), ("test_deleted", "BOOLEAN")):
|
||||
node = group.nodes.new("GeometryNodeInputNamedAttribute")
|
||||
node.data_type = kind
|
||||
node.inputs["Name"].default_value = name
|
||||
attributes[name] = node.outputs["Attribute"]
|
||||
delete = group.nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete.domain, delete.mode = "FACE", "ONLY_FACE"
|
||||
group.links.new(source.outputs["Geometry"], delete.inputs["Geometry"])
|
||||
group.links.new(attributes["test_deleted"], delete.inputs["Selection"])
|
||||
scale = group.nodes.new("ShaderNodeValue")
|
||||
scale.outputs[0].default_value = 1
|
||||
result = smooth_outline_camera(group, source.outputs["Geometry"], delete.outputs[0], attributes["test_camera"], scale.outputs[0])
|
||||
position = group.nodes.new("GeometryNodeSetPosition")
|
||||
group.links.new(delete.outputs[0], position.inputs["Geometry"])
|
||||
group.links.new(result, position.inputs["Position"])
|
||||
output = group.nodes.new("NodeGroupOutput")
|
||||
group.links.new(position.outputs[0], output.inputs["Geometry"])
|
||||
modifier = obj.modifiers.new("Outline", "NODES")
|
||||
modifier.node_group = group
|
||||
|
||||
# ONLY_FACE retains the separating column's edges in the current topology.
|
||||
edges = np.array([tuple(e.vertices) for e in mesh.edges])
|
||||
adjacency = np.zeros((81, 81))
|
||||
adjacency[edges[:, 0], edges[:, 1]] = 1
|
||||
adjacency += adjacency.T
|
||||
degree = adjacency.sum(axis=1)
|
||||
def blur(value, weight):
|
||||
return (value + weight*(adjacency @ value))/(1 + weight*degree)
|
||||
counts = Counter(tuple(sorted((a, b))) for f in result_faces for a, b in zip(f, (*f[1:], f[0])))
|
||||
boundary = np.zeros(81)
|
||||
for edge, count in counts.items():
|
||||
if count == 1:
|
||||
boundary[list(edge)] = 1
|
||||
protected = boundary.astype(bool) & cut
|
||||
seed = boundary*(~protected)
|
||||
influence = np.maximum(seed, blur(blur(seed, 1), 1))*(~protected)
|
||||
influence *= depth > 1e-8
|
||||
for rounds, batch in ((0, steps), (16, 0), (16, steps)):
|
||||
modifier[ids["Outline Depth Fix"]] = rounds
|
||||
modifier[ids["Boundary Smooth"]] = batch
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
actual, actual_faces = evaluated(obj)
|
||||
expected = distance.copy()
|
||||
for _ in range(rounds):
|
||||
ratio = abs(expected-blur(expected, .5))
|
||||
t = np.clip((ratio-.25)/.25, 0, 1)
|
||||
weight = t*t*(3-2*t)*influence
|
||||
for _ in range(batch):
|
||||
expected = blur(expected, weight)
|
||||
np.testing.assert_allclose(np.linalg.norm(actual, axis=1), expected, atol=2e-5)
|
||||
assert actual_faces == result_faces
|
||||
np.testing.assert_array_equal(actual[influence == 0], camera[influence == 0].astype(np.float32))
|
||||
if rounds and batch:
|
||||
assert 2 < actual[2, 2] < 15
|
||||
valid = depth > 0
|
||||
np.testing.assert_allclose(actual[valid, :2]/actual[valid, 2:3], camera[valid, :2]/camera[valid, 2:3], atol=1e-6)
|
||||
else:
|
||||
np.testing.assert_array_equal(actual, camera.astype(np.float32))
|
||||
assert not any(n.bl_idname in {"GeometryNodeCaptureAttribute", "GeometryNodeStoreNamedAttribute"} for n in group.nodes)
|
||||
@@ -25,11 +25,11 @@ class NodeAssetsTest(unittest.TestCase):
|
||||
self.assertEqual(len(groups), 7)
|
||||
for group in groups:
|
||||
validate_node_structure(group)
|
||||
positions = {node.name: tuple(node.location) for node in group.nodes
|
||||
positions = {node.name: tuple(node.location_absolute) for node in group.nodes
|
||||
if node.bl_idname not in {"NodeReroute", "NodeGroupInput"}}
|
||||
arrange_nodes(group)
|
||||
for name, position in positions.items():
|
||||
for actual, expected in zip(group.nodes[name].location, position):
|
||||
for actual, expected in zip(group.nodes[name].location_absolute, position):
|
||||
self.assertAlmostEqual(actual, expected, delta=0.01, msg=f"{group.name}: {name}")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user