feat: Implement Depth Surface Stretch Limit for improved edge culling
- Added a new "Stretch Limit" parameter to the Depth Surface node, allowing for dynamic edge culling based on the ratio of original to projected edge lengths. - Updated the geometry processing to capture original edge lengths before projection and calculate current lengths after projection. - Implemented logic to delete edges and faces that exceed the stretch limit, ensuring cleaner topology. - Enhanced tests to validate the new functionality, including scenarios for edge retention and deletion based on stretch limits. - Updated documentation and node assets to reflect the new parameter and its implications on geometry processing.
This commit is contained in:
@@ -427,6 +427,81 @@ def _position_field(group, depth_image, location):
|
||||
return position_texture.outputs["Color"]
|
||||
|
||||
|
||||
def _capture_edge_length(group, geometry, name, location):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
x, y = location
|
||||
edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices")
|
||||
edge_vertices.location = (x, y + 250.0)
|
||||
edge_length = nodes.new("ShaderNodeVectorMath")
|
||||
edge_length.operation = "DISTANCE"
|
||||
edge_length.location = (x + 220.0, y + 250.0)
|
||||
links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0])
|
||||
links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1])
|
||||
capture = nodes.new("GeometryNodeCaptureAttribute")
|
||||
capture.domain = "POINT"
|
||||
capture.capture_items.clear()
|
||||
capture.capture_items.new("FLOAT", name)
|
||||
capture.location = (x + 440.0, y)
|
||||
links.new(geometry, capture.inputs["Geometry"])
|
||||
links.new(edge_length.outputs["Value"], capture.inputs[name])
|
||||
return capture.outputs["Geometry"], capture.outputs[name], edge_length.outputs["Value"]
|
||||
|
||||
|
||||
def _remove_stretched_points(
|
||||
group,
|
||||
geometry,
|
||||
original_edge_length,
|
||||
current_edge_length,
|
||||
stretch_limit,
|
||||
location,
|
||||
):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
x, y = location
|
||||
|
||||
remaining_ratio = _math(nodes, "DIVIDE", (x + 220.0, y + 300.0))
|
||||
links.new(original_edge_length, remaining_ratio.inputs[0])
|
||||
links.new(current_edge_length, remaining_ratio.inputs[1])
|
||||
stretched = _math(nodes, "LESS_THAN", (x + 450.0, y + 200.0))
|
||||
links.new(remaining_ratio.outputs[0], stretched.inputs[0])
|
||||
links.new(stretch_limit, stretched.inputs[1])
|
||||
delete_stretched = nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete_stretched.domain = "POINT"
|
||||
delete_stretched.mode = "ALL"
|
||||
delete_stretched.location = (x + 680.0, y)
|
||||
links.new(geometry, delete_stretched.inputs["Geometry"])
|
||||
links.new(stretched.outputs[0], delete_stretched.inputs["Selection"])
|
||||
|
||||
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
edge_neighbors.location = (x + 930.0, y + 500.0)
|
||||
loose_edge = _math(nodes, "LESS_THAN", (x + 1150.0, y + 500.0))
|
||||
loose_edge.inputs[1].default_value = 1.0
|
||||
links.new(edge_neighbors.outputs["Face Count"], loose_edge.inputs[0])
|
||||
delete_loose_edges = nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete_loose_edges.domain = "EDGE"
|
||||
delete_loose_edges.mode = "ALL"
|
||||
delete_loose_edges.location = (x + 1370.0, y)
|
||||
links.new(delete_stretched.outputs["Geometry"], delete_loose_edges.inputs["Geometry"])
|
||||
links.new(loose_edge.outputs[0], delete_loose_edges.inputs["Selection"])
|
||||
|
||||
vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors")
|
||||
vertex_neighbors.location = (x + 1620.0, y + 500.0)
|
||||
loose_point = _math(nodes, "LESS_THAN", (x + 1840.0, y + 500.0))
|
||||
loose_point.inputs[1].default_value = 1.0
|
||||
links.new(vertex_neighbors.outputs["Face Count"], loose_point.inputs[0])
|
||||
delete_loose_points = nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete_loose_points.domain = "POINT"
|
||||
delete_loose_points.mode = "ALL"
|
||||
delete_loose_points.location = (x + 2060.0, y)
|
||||
links.new(
|
||||
delete_loose_edges.outputs["Geometry"],
|
||||
delete_loose_points.inputs["Geometry"],
|
||||
)
|
||||
links.new(loose_point.outputs[0], delete_loose_points.inputs["Selection"])
|
||||
return delete_loose_points.outputs["Geometry"]
|
||||
|
||||
|
||||
def _plane_depth(group, direction, reference_depth, location):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
@@ -610,12 +685,19 @@ def _build_depth_surface(
|
||||
geometry,
|
||||
thickness,
|
||||
depth_scale,
|
||||
stretch_limit,
|
||||
uniform_scale,
|
||||
reference_depth,
|
||||
depth_image,
|
||||
):
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
geometry, original_edge_length, current_edge_length = _capture_edge_length(
|
||||
group,
|
||||
geometry,
|
||||
"Original Edge Length",
|
||||
(400.0, 850.0),
|
||||
)
|
||||
camera_position = _position_field(group, depth_image, (-200.0, -200.0))
|
||||
scaled = nodes.new("ShaderNodeVectorMath")
|
||||
scaled.operation = "SCALE"
|
||||
@@ -651,30 +733,56 @@ def _build_depth_surface(
|
||||
links.new(depth_scale, blend.inputs[0])
|
||||
links.new(current_position.outputs["Position"], blend.inputs[4])
|
||||
links.new(projected.outputs["Vector"], blend.inputs[5])
|
||||
blended_axes = nodes.new("ShaderNodeSeparateXYZ")
|
||||
blended_axes.location = (4100.0, -500.0)
|
||||
links.new(blend.outputs["Result"], blended_axes.inputs["Vector"])
|
||||
surface_position = nodes.new("ShaderNodeCombineXYZ")
|
||||
surface_position.location = (4100.0, -200.0)
|
||||
links.new(front.outputs[0], surface_position.inputs["X"])
|
||||
links.new(blended_axes.outputs["Y"], surface_position.inputs["Y"])
|
||||
links.new(blended_axes.outputs["Z"], surface_position.inputs["Z"])
|
||||
project_geometry = nodes.new("GeometryNodeSetPosition")
|
||||
project_geometry.location = (4100.0, 400.0)
|
||||
project_geometry.location = (4400.0, 400.0)
|
||||
links.new(geometry, project_geometry.inputs["Geometry"])
|
||||
links.new(blend.outputs["Result"], project_geometry.inputs["Position"])
|
||||
links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"])
|
||||
|
||||
geometry = _remove_stretched_points(
|
||||
group,
|
||||
project_geometry.outputs["Geometry"],
|
||||
original_edge_length,
|
||||
current_edge_length,
|
||||
stretch_limit,
|
||||
(4700.0, 400.0),
|
||||
)
|
||||
zero_x = nodes.new("ShaderNodeValue")
|
||||
zero_x.location = (7100.0, 100.0)
|
||||
zero_x.outputs["Value"].default_value = 0.0
|
||||
geometry = _set_geometry_x(
|
||||
group,
|
||||
geometry,
|
||||
zero_x.outputs["Value"],
|
||||
(7200.0, 400.0),
|
||||
)
|
||||
|
||||
back = _math(nodes, "ADD", (3900.0, -100.0))
|
||||
links.new(front.outputs[0], back.inputs[0])
|
||||
links.new(thickness, back.inputs[1])
|
||||
shaped = _set_segmented_layer_x(
|
||||
group,
|
||||
project_geometry.outputs["Geometry"],
|
||||
geometry,
|
||||
front.outputs[0],
|
||||
back.outputs[0],
|
||||
thickness,
|
||||
(4400.0, 400.0),
|
||||
(8300.0, 400.0),
|
||||
)
|
||||
front_only = _set_geometry_x(
|
||||
group,
|
||||
project_geometry.outputs["Geometry"],
|
||||
geometry,
|
||||
front.outputs[0],
|
||||
(4600.0, -200.0),
|
||||
(8500.0, -200.0),
|
||||
)
|
||||
geometry = _remove_zero_thickness(
|
||||
group, front_only, shaped, thickness, (7900.0, 400.0)
|
||||
group, front_only, shaped, thickness, (11800.0, 400.0)
|
||||
)
|
||||
return geometry
|
||||
|
||||
@@ -698,6 +806,7 @@ def build_node_group(shape):
|
||||
thickness.description = "Final thickness in scene distance"
|
||||
|
||||
depth_image = None
|
||||
stretch_limit = None
|
||||
if shape == "DEPTH_BALLOON":
|
||||
depth_scale = _float_input(group, "Depth Scale", 1.0)
|
||||
depth_scale.description = "Scale the fitted-plane-corrected metric depth"
|
||||
@@ -734,6 +843,19 @@ def build_node_group(shape):
|
||||
)
|
||||
elif shape == "DEPTH_SURFACE":
|
||||
options = group.interface.new_panel(name="Options", default_closed=True)
|
||||
stretch_limit = _float_input(
|
||||
group,
|
||||
"Stretch Limit",
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
subtype="FACTOR",
|
||||
parent=options,
|
||||
)
|
||||
stretch_limit.description = (
|
||||
"Delete faces when original edge length divided by projected edge "
|
||||
"length falls below this threshold; zero disables culling"
|
||||
)
|
||||
reference_depth = _float_input(
|
||||
group,
|
||||
"Reference Depth",
|
||||
@@ -773,6 +895,7 @@ def build_node_group(shape):
|
||||
"Uniform Scale",
|
||||
"Reference Depth",
|
||||
"Depth Direction",
|
||||
"Stretch Limit",
|
||||
},
|
||||
)
|
||||
geometry = controls.outputs["Geometry"]
|
||||
@@ -805,11 +928,12 @@ def build_node_group(shape):
|
||||
geometry,
|
||||
controls.outputs["Thickness"],
|
||||
controls.outputs["Depth Scale"],
|
||||
controls.outputs["Stretch Limit"],
|
||||
controls.outputs["Uniform Scale"],
|
||||
controls.outputs["Reference Depth"],
|
||||
controls.outputs["Depth Image"],
|
||||
)
|
||||
output_location = (8200.0, 400.0)
|
||||
output_location = (12100.0, 400.0)
|
||||
_localize_group_inputs(group, controls)
|
||||
_shade_output(group, geometry, output_location)
|
||||
group.color_tag = "GEOMETRY"
|
||||
@@ -874,6 +998,19 @@ def build_cutout_modifier_group(shape_groups):
|
||||
parent=options,
|
||||
)
|
||||
smooth.description = "Blend each smoothing pass into the selected shape"
|
||||
stretch_limit = _float_input(
|
||||
group,
|
||||
"Stretch Limit",
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
subtype="FACTOR",
|
||||
parent=options,
|
||||
)
|
||||
stretch_limit.description = (
|
||||
"Delete Depth Surface faces when original edge length divided by projected "
|
||||
"edge length falls below this threshold; zero disables culling"
|
||||
)
|
||||
|
||||
reference_depth = _float_input(
|
||||
group,
|
||||
|
||||
@@ -140,6 +140,7 @@ def main():
|
||||
"Smooth Iterations",
|
||||
"Cleanup",
|
||||
"Smooth",
|
||||
"Stretch Limit",
|
||||
"Reference Depth",
|
||||
"Depth Direction",
|
||||
"Uniform Scale",
|
||||
@@ -162,6 +163,7 @@ def main():
|
||||
"Geometry",
|
||||
"Thickness",
|
||||
"Depth Scale",
|
||||
"Stretch Limit",
|
||||
"Reference Depth",
|
||||
"Uniform Scale",
|
||||
"Depth Image",
|
||||
@@ -183,6 +185,7 @@ def main():
|
||||
assert dependencies == {
|
||||
"Thickness": set(shapes.CUTOUT_SHAPES),
|
||||
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
|
||||
"Stretch Limit": {"DEPTH_SURFACE"},
|
||||
"Double Sided": {"DEPTH_BALLOON"},
|
||||
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
|
||||
"Depth Direction": {"DEPTH_BALLOON"},
|
||||
@@ -232,6 +235,77 @@ def main():
|
||||
)
|
||||
assert cleanup.default_value == 0.0
|
||||
assert cleanup.subtype == "NONE"
|
||||
stretch_limit = next(
|
||||
item
|
||||
for item in wrapper.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
|
||||
)
|
||||
child_stretch_limit = next(
|
||||
item
|
||||
for item in groups["DEPTH_SURFACE"].interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Stretch Limit"
|
||||
)
|
||||
assert stretch_limit.default_value == 0.0
|
||||
assert stretch_limit.min_value == 0.0
|
||||
assert stretch_limit.max_value == 1.0
|
||||
assert stretch_limit.subtype == "FACTOR"
|
||||
assert getattr(stretch_limit.parent, "name", "") == "Options"
|
||||
assert child_stretch_limit.default_value == 0.0
|
||||
assert child_stretch_limit.min_value == 0.0
|
||||
assert child_stretch_limit.max_value == 1.0
|
||||
assert child_stretch_limit.subtype == "FACTOR"
|
||||
assert getattr(child_stretch_limit.parent, "name", "") == "Options"
|
||||
depth_surface_nodes = groups["DEPTH_SURFACE"].nodes
|
||||
captures = [
|
||||
node
|
||||
for node in depth_surface_nodes
|
||||
if node.bl_idname == "GeometryNodeCaptureAttribute"
|
||||
]
|
||||
assert len(captures) == 1
|
||||
assert captures[0].domain == "POINT"
|
||||
assert [item.name for item in captures[0].capture_items] == [
|
||||
"Original Edge Length"
|
||||
]
|
||||
captured_distance = next(
|
||||
link.from_node
|
||||
for link in groups["DEPTH_SURFACE"].links
|
||||
if link.to_node == captures[0]
|
||||
and link.to_socket.name == "Original Edge Length"
|
||||
)
|
||||
assert captured_distance.bl_idname == "ShaderNodeVectorMath"
|
||||
assert captured_distance.operation == "DISTANCE"
|
||||
ratio = next(
|
||||
link.to_node
|
||||
for link in groups["DEPTH_SURFACE"].links
|
||||
if link.from_node == captured_distance
|
||||
and link.to_node.bl_idname == "ShaderNodeMath"
|
||||
and link.to_node.operation == "DIVIDE"
|
||||
)
|
||||
assert any(
|
||||
link.from_node == captures[0]
|
||||
and link.to_node == ratio
|
||||
and link.to_socket == ratio.inputs[0]
|
||||
for link in groups["DEPTH_SURFACE"].links
|
||||
)
|
||||
assert any(
|
||||
link.from_node == captured_distance
|
||||
and link.to_node == ratio
|
||||
and link.to_socket == ratio.inputs[1]
|
||||
for link in groups["DEPTH_SURFACE"].links
|
||||
)
|
||||
samples = [
|
||||
node
|
||||
for node in depth_surface_nodes
|
||||
if node.bl_idname == "GeometryNodeSampleIndex"
|
||||
]
|
||||
assert not samples
|
||||
delete_domains = [
|
||||
node.domain
|
||||
for node in depth_surface_nodes
|
||||
if node.bl_idname == "GeometryNodeDeleteGeometry"
|
||||
]
|
||||
assert delete_domains.count("EDGE") == 1
|
||||
assert delete_domains.count("POINT") == 2
|
||||
cleanup_scale = next(
|
||||
link.to_node
|
||||
for link in wrapper.links
|
||||
@@ -262,7 +336,7 @@ def main():
|
||||
assert not (
|
||||
abs(node.location.x - other.location.x) < 150.0
|
||||
and abs(node.location.y - other.location.y) < 100.0
|
||||
)
|
||||
), (group.name, node.name, other.name)
|
||||
for group_input in (
|
||||
node for node in group_nodes if node.bl_idname == "NodeGroupInput"
|
||||
):
|
||||
@@ -392,7 +466,8 @@ def main():
|
||||
pixels = []
|
||||
for z in range(3):
|
||||
for y in range(3):
|
||||
pixels.extend((-y * 0.5, -z * 0.5, 2.0 + y * 0.25, 0.0))
|
||||
depth = 7.0 if (y, z) == (0, 0) else 2.0 + y * 0.25
|
||||
pixels.extend((-y * 0.5, -z * 0.5, depth, 0.0))
|
||||
point_image.pixels.foreach_set(pixels)
|
||||
for name, value in (
|
||||
("Depth Image", point_image),
|
||||
@@ -417,12 +492,62 @@ def main():
|
||||
)
|
||||
for point, target in zip(
|
||||
coordinates,
|
||||
((y * 0.25, y * 0.5, z * 0.5) for z in range(3) for y in range(3)),
|
||||
(
|
||||
(
|
||||
5.0 if (y, z) == (0, 0) else y * 0.25,
|
||||
y * 0.5,
|
||||
z * 0.5,
|
||||
)
|
||||
for z in range(3)
|
||||
for y in range(3)
|
||||
),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_modifier_input(
|
||||
modifier,
|
||||
modifier_input_identifier(modifier.node_group, "Stretch Limit"),
|
||||
0.6,
|
||||
)
|
||||
depth_surface.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated, result = evaluated_mesh(depth_surface)
|
||||
try:
|
||||
assert 0 < len(result.polygons) < 8
|
||||
face_edges = {
|
||||
tuple(sorted((start, end)))
|
||||
for polygon in result.polygons
|
||||
for start, end in zip(
|
||||
polygon.vertices,
|
||||
(*polygon.vertices[1:], polygon.vertices[0]),
|
||||
)
|
||||
}
|
||||
assert all(tuple(sorted(edge.vertices)) in face_edges for edge in result.edges)
|
||||
face_vertices = {
|
||||
vertex
|
||||
for polygon in result.polygons
|
||||
for vertex in polygon.vertices
|
||||
}
|
||||
assert face_vertices == set(range(len(result.vertices)))
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_modifier_input(
|
||||
modifier,
|
||||
modifier_input_identifier(modifier.node_group, "Depth Scale"),
|
||||
0.0,
|
||||
)
|
||||
depth_surface.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated, result = evaluated_mesh(depth_surface)
|
||||
try:
|
||||
assert len(result.vertices) == 9
|
||||
assert len(result.polygons) == 8
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
print(
|
||||
f"Validated Blender {bpy.app.version_string}: "
|
||||
f"{wrapper.name} wrapping "
|
||||
|
||||
Reference in New Issue
Block a user