refactor: update depth cutout and surface handling for improved geometry manipulation and clarity
This commit is contained in:
Binary file not shown.
@@ -1,5 +1,10 @@
|
||||
"""Expose the extension and standalone server packages to the test suite."""
|
||||
import sys
|
||||
|
||||
# Import the pinned dependencies before bpy prepends Blender's extension
|
||||
# site-packages, where a half-removed install can shadow the project's own scipy.
|
||||
import scipy # noqa: F401
|
||||
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
|
||||
sys.path[:0] = [str(PROJECT_ROOT / "src"), str(PROJECT_ROOT / "src/anyimage")]
|
||||
|
||||
@@ -113,7 +113,7 @@ def _surface(node_group):
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
values = {"Subdivide": 1, "Thickness": 0.2, "Depth Image": image,
|
||||
"Uniform Scale": 1.0, "Reference Depth": 2.5, "Depth Scale": 0.8,
|
||||
"Normal Smooth": 2, "Depth Split": 0.25,
|
||||
"Edge Turn": 2, "Depth Split": 0.25,
|
||||
"Offset": 0.5}
|
||||
mode = None
|
||||
for item in node_group.interface.items_tree:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Verify split profile shaping and thickness-weighted normals."""
|
||||
"""Verify split profile shaping and displacement direction shaping."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
@@ -6,9 +6,9 @@ import pytest
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface, boundary_neighbors, edge_band
|
||||
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface
|
||||
from tools.nodes.common.cutout_boundary import remove_boundary_triangles
|
||||
from tools.nodes.groups.image_depth_cutout import build_normal_weight, blend_normal
|
||||
from tools.nodes.groups.image_depth_cutout import build_normal_direction, build_normal_weight
|
||||
|
||||
|
||||
def profile_values(obj):
|
||||
@@ -77,63 +77,76 @@ def test_split_profile_tapers_within_boundary_band():
|
||||
set_value("Depth Scale", 1)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
set_value("Depth Split", .1)
|
||||
points, faces = evaluated(obj)
|
||||
boundary = boundary_neighbors(faces)
|
||||
uv = vertex_uv(obj)
|
||||
# The depth step sits at u=0.5, so that column is the split line.
|
||||
cut = [i for i in boundary if abs(uv[i,0] - .5) < 1e-4 and 1e-4 < uv[i,1] < 1 - 1e-4]
|
||||
assert cut
|
||||
band = edge_band(faces, cut)
|
||||
outside = [i for i in range(len(points)) if i not in band]
|
||||
assert outside
|
||||
values = profile_values(obj)
|
||||
uv = vertex_uv(obj)
|
||||
# The depth step sits at u=0.5, so only that column is tapered.
|
||||
cut = (np.abs(uv[:, 0] - .5) < 1e-4) & (uv[:, 1] > 1e-3) & (uv[:, 1] < 1 - 1e-3)
|
||||
assert cut.any()
|
||||
np.testing.assert_array_equal(values[cut], 0)
|
||||
np.testing.assert_allclose(values[outside], .3)
|
||||
untouched = (np.abs(uv[:, 0] - .25) < 1e-3) | (np.abs(uv[:, 0] - .75) < 1e-3)
|
||||
assert untouched.any()
|
||||
np.testing.assert_allclose(values[untouched], .3)
|
||||
assert np.isfinite(values).all()
|
||||
assert values.max() == pytest.approx(.3)
|
||||
set_value("Depth Split", 0)
|
||||
np.testing.assert_allclose(profile_values(obj), .3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
def test_normal_blend_weights_balloon_and_keeps_full_shell_smoothing(mode):
|
||||
obj, set_value = surface(resolution=2)
|
||||
group = obj.modifiers[0].node_group
|
||||
# The production interface renames the Shell thickness socket after building.
|
||||
socket = next(s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"
|
||||
and s.name == "Thickness" and s.subtype == "DISTANCE")
|
||||
socket.name = "Shell Thickness"
|
||||
is_shell = group.nodes.new("FunctionNodeInputBool")
|
||||
is_shell.boolean = bool(mode)
|
||||
weight = build_normal_weight(group, is_shell.outputs[0])
|
||||
raw = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
raw.inputs["Z"].default_value = 1
|
||||
blurred = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
blurred.inputs["X"].default_value = 1
|
||||
result = blend_normal(group, raw.outputs[0], blurred.outputs[0], weight)
|
||||
def sampled(obj, group, value, name):
|
||||
"""Store one field on the points and read it back from the evaluated mesh."""
|
||||
data_type, reader = {"VECTOR": ("FLOAT_VECTOR", "vector"), "VALUE": ("FLOAT", "value")}[value.type]
|
||||
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store.data_type, store.domain = "FLOAT_VECTOR", "POINT"
|
||||
store.inputs["Name"].default_value = "normal_result"
|
||||
store.data_type, store.domain = data_type, "POINT"
|
||||
store.inputs["Name"].default_value = name
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
|
||||
group.links.new(result, store.inputs["Value"])
|
||||
group.links.new(value, store.inputs["Value"])
|
||||
group.links.new(store.outputs[0], next(n for n in group.nodes if n.type == "GROUP_OUTPUT").inputs["Geometry"])
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
mesh = result.to_mesh()
|
||||
try:
|
||||
return np.array([getattr(item, reader) for item in mesh.attributes[name].data])
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
def test_normal_weight_follows_profile_only_in_balloon_mode():
|
||||
obj, _ = surface(resolution=2)
|
||||
group = obj.modifiers[0].node_group
|
||||
is_shell = group.nodes.new("FunctionNodeInputBool")
|
||||
profile = group.nodes.new("ShaderNodeValue")
|
||||
profile.outputs[0].default_value = .25
|
||||
weight = build_normal_weight(group, is_shell.outputs[0], profile.outputs[0])
|
||||
np.testing.assert_allclose(sampled(obj, group, weight, "normal_weight"), .25)
|
||||
is_shell.boolean = True
|
||||
np.testing.assert_allclose(sampled(obj, group, weight, "normal_weight"), 0.0)
|
||||
|
||||
|
||||
def test_normal_direction_turns_the_edge_with_edge_turn():
|
||||
obj, _ = surface(resolution=2)
|
||||
group = obj.modifiers[0].node_group
|
||||
smoothed = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
smoothed.inputs["Z"].default_value = 1
|
||||
average = group.nodes.new("ShaderNodeCombineXYZ")
|
||||
average.inputs["X"].default_value = 1
|
||||
weight = group.nodes.new("ShaderNodeValue")
|
||||
controls = group.nodes.new("NodeGroupInput")
|
||||
direction = build_normal_direction(
|
||||
group, controls, smoothed.outputs[0], average.outputs[0], weight.outputs[0],
|
||||
)
|
||||
modifier = obj.modifiers[0]
|
||||
identifier = socket.identifier if mode else modifier_input_identifier(group, "Thickness", subtype="NONE")
|
||||
reference = 2
|
||||
set_value("Reference Depth", reference)
|
||||
results = []
|
||||
for amount in (0, .001, .01, 1):
|
||||
set_modifier_input(modifier, identifier, amount * (max(reference, 1e-8) if mode else 1))
|
||||
obj.update_tag(refresh={"DATA"}); bpy.context.view_layer.update()
|
||||
ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()); mesh = ev.to_mesh()
|
||||
try:
|
||||
results.append(np.array(mesh.attributes["normal_result"].data[0].vector[:]))
|
||||
finally:
|
||||
ev.to_mesh_clear()
|
||||
np.testing.assert_array_equal(results[-1], [1,0,0])
|
||||
if mode:
|
||||
np.testing.assert_array_equal(results, [[1,0,0]] * len(results))
|
||||
else:
|
||||
np.testing.assert_array_equal(results[0], [0,0,1])
|
||||
assert np.linalg.norm(results[1]-results[0]) < np.linalg.norm(results[2]-results[0]) < .001
|
||||
turn = modifier_input_identifier(group, "Edge Turn")
|
||||
|
||||
def result(weight_value, turn_value):
|
||||
weight.outputs[0].default_value = weight_value
|
||||
set_modifier_input(modifier, turn, turn_value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
return sampled(obj, group, direction, "normal_direction")[0]
|
||||
|
||||
np.testing.assert_allclose(result(0.0, 0.0), (0, 0, 1), atol=1e-6)
|
||||
np.testing.assert_allclose(result(0.0, 1.0), (1, 0, 0), atol=1e-6)
|
||||
np.testing.assert_allclose(result(0.0, 2.0), (2, 0, -1) / np.sqrt(5), atol=1e-6)
|
||||
np.testing.assert_allclose(result(1.0, 2.0), (1, 0, 0), atol=1e-6)
|
||||
|
||||
@@ -7,7 +7,8 @@ from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1])
|
||||
def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness(mode):
|
||||
def test_edge_turn_reshapes_thickness_without_moving_zero_thickness(mode):
|
||||
"""Edge Turn swings the wall direction and leaves zero thickness untouched."""
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
obj, set_value = surface(step=1, resolution=10)
|
||||
@@ -19,24 +20,28 @@ def test_normal_smooth_changes_thickness_direction_without_moving_zero_thickness
|
||||
set_value("Front Inflation", .2)
|
||||
modifier = obj.modifiers[0]
|
||||
thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE" if mode else "NONE")
|
||||
turn = modifier_input_identifier(modifier.node_group, "Edge Turn")
|
||||
|
||||
def result(amount, smoothing):
|
||||
def result(amount, edge_turn):
|
||||
set_modifier_input(modifier, thickness, amount)
|
||||
set_value("Normal Smooth", smoothing)
|
||||
set_modifier_input(modifier, turn, edge_turn)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
return evaluated(obj)
|
||||
|
||||
raw, faces = result(0, 16)
|
||||
smooth, smooth_faces = result(0, 512)
|
||||
np.testing.assert_array_equal(raw, smooth)
|
||||
assert faces == smooth_faces
|
||||
raw, faces = result(1, 16)
|
||||
smooth, smooth_faces = result(1, 512)
|
||||
assert faces == smooth_faces
|
||||
assert np.isfinite(smooth).all()
|
||||
assert np.max(np.abs(raw - smooth)) > 1e-5
|
||||
plain, faces = result(0, 0)
|
||||
turned, turned_faces = result(0, 2)
|
||||
np.testing.assert_array_equal(plain, turned)
|
||||
assert faces == turned_faces
|
||||
plain, faces = result(1, 0)
|
||||
turned, turned_faces = result(1, 2)
|
||||
assert faces == turned_faces
|
||||
assert np.isfinite(turned).all()
|
||||
assert np.max(np.abs(plain - turned)) > 1e-5
|
||||
|
||||
|
||||
def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference():
|
||||
def test_shell_offset_scales_with_thickness_and_ignores_reference():
|
||||
"""A thin Shell moves a distance proportional to its thickness."""
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
obj, set_value = surface(step=0, resolution=12)
|
||||
@@ -49,9 +54,8 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
|
||||
set_value('Thickness', 0.)
|
||||
front, _ = evaluated(obj)
|
||||
|
||||
def offsets(thickness, smoothing, reference=10.):
|
||||
def offsets(thickness, reference=10.):
|
||||
set_value('Thickness', thickness)
|
||||
set_value('Normal Smooth', smoothing)
|
||||
set_value('Reference Depth', reference)
|
||||
points, faces = evaluated(obj)
|
||||
adjusted_front = front + (0, 10 - reference, 0)
|
||||
@@ -63,11 +67,9 @@ def test_thin_shell_normal_smoothing_is_independent_of_thickness_and_reference()
|
||||
order = np.argsort(indices[rear])
|
||||
return ((points[rear] - adjusted_front[indices[rear]]) / thickness)[order], faces
|
||||
|
||||
raw, _ = offsets(.02, 16)
|
||||
smooth, faces = offsets(.02, 512)
|
||||
assert np.max(np.linalg.norm(raw - smooth, axis=1)) > .05
|
||||
thinner, thinner_faces = offsets(.01, 512)
|
||||
distant, _ = offsets(.02, 512, 20.)
|
||||
smooth, faces = offsets(.02)
|
||||
thinner, thinner_faces = offsets(.01)
|
||||
distant, _ = offsets(.02, 20.)
|
||||
np.testing.assert_allclose(thinner, smooth, atol=2e-4)
|
||||
np.testing.assert_allclose(distant, smooth, atol=2e-4)
|
||||
assert thinner_faces == faces
|
||||
|
||||
@@ -174,7 +174,7 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness):
|
||||
obj, set_value = surface()
|
||||
obj.data.attributes.new("o_depth_probe", "FLOAT", "POINT")
|
||||
attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT")
|
||||
obj.data.attributes.new(".o_anyimage_user_weight", "FLOAT", "POINT")
|
||||
obj.data.attributes.new(".o_user_weight", "FLOAT", "POINT")
|
||||
for value in attribute.data:
|
||||
value.value = 0.75
|
||||
set_value("Depth Split", 0.5)
|
||||
@@ -184,10 +184,8 @@ def test_temporary_fields_are_removed_and_user_attributes_survive(thickness):
|
||||
try:
|
||||
assert {a.name for a in mesh.attributes if a.name.startswith(("o_depth_", "o_balloon_surface_"))} == {"o_depth_probe"}
|
||||
assert "UVMap" in mesh.attributes
|
||||
assert ".o_anyimage_user_weight" in mesh.attributes
|
||||
assert "_o_anyimage_depth_cut" not in mesh.attributes
|
||||
assert ".o_anyimage_original_boundary" not in mesh.attributes
|
||||
assert ".o_anyimage_depth_cut" not in mesh.attributes
|
||||
assert ".o_user_weight" in mesh.attributes
|
||||
assert not any("o_anyimage" in a.name for a in mesh.attributes)
|
||||
assert not any(a.name.startswith("_o_") for a in mesh.attributes)
|
||||
assert all(
|
||||
v.value == pytest.approx(0.75)
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_saved_parameter_descriptions_match_definitions():
|
||||
"O Image Depth Plane": {"Subdivide", "Thickness", "Depth Split", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
|
||||
"O Image Relief Plane": {"Subdivide", "Thickness", "Depth Direction", "Depth Offset"},
|
||||
"O Image Cutout": {"Thickness"},
|
||||
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Normal Smooth", "Front Inflation"},
|
||||
"O Image Depth Cutout": {"Thickness", "Depth Split", "Boundary Smooth", "Edge Turn", "Front Inflation"},
|
||||
"O Image Cutout Symmetry": {"Direction", "Offset", "Scale", "Fill Sides", "Smooth"},
|
||||
"O Image Depth Panorama": {"Subdivide", "Depth Scale", "Depth Split", "Dome Radius", "Depth Mask", "Mask Threshold", "Boundary Smooth"},
|
||||
"O Image Layer": {"Alpha Fix", "Normal Scale", "Bump Scale", "Object Space"},
|
||||
@@ -82,8 +82,8 @@ def test_geometry_attribute_contract_includes_nested_groups():
|
||||
allowed = {
|
||||
"UVMap": ("FLOAT2", "CORNER"),
|
||||
"o_balloon": ("FLOAT", "POINT"),
|
||||
"_o_anyimage_depth_cut": ("BOOLEAN", "CORNER"),
|
||||
"_o_solid_normal": ("FLOAT_VECTOR", "POINT"),
|
||||
"_o_depth_cut": ("BOOLEAN", "CORNER"),
|
||||
"_o_front_normal": ("FLOAT_VECTOR", "POINT"),
|
||||
"_o_symmetry_weld": ("BOOLEAN", "FACE"),
|
||||
"o_depth_rotation": ("FLOAT_VECTOR", "FACE"),
|
||||
"o_depth_face": ("FLOAT", "FACE"),
|
||||
|
||||
@@ -26,10 +26,7 @@ def test_depth_attribute_cleanup_has_no_warnings(kind):
|
||||
mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert mesh.vertices
|
||||
assert not {a.name for a in mesh.attributes} & {
|
||||
".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
|
||||
"_o_anyimage_depth_cut",
|
||||
}
|
||||
assert not any("o_anyimage" in a.name for a in mesh.attributes)
|
||||
assert not any(a.name.startswith("_o_") for a in mesh.attributes)
|
||||
assert not list(modifier.node_warnings), [
|
||||
warning.message for warning in modifier.node_warnings
|
||||
|
||||
@@ -58,8 +58,7 @@ def check_asset_library(asset_path):
|
||||
modifier = obj.modifiers.new("Asset Check", "NODES")
|
||||
modifier.node_group = node_group
|
||||
values = {"Subdivide": 0, "Thickness": 0.2, "Reference Depth": 2.0,
|
||||
"Depth Image": depth_image, "Uniform Scale": 1.0, "Normal Smooth": 0,
|
||||
"Depth Split": 0.0}
|
||||
"Depth Image": depth_image, "Uniform Scale": 1.0, "Depth Split": 0.0}
|
||||
if node_group.name == "O Image Cutout Symmetry":
|
||||
values["Offset"] = 0.5
|
||||
for item in node_group.interface.items_tree:
|
||||
@@ -75,10 +74,9 @@ def check_asset_library(asset_path):
|
||||
assert not list(modifier.node_warnings), (
|
||||
node_group.name, [warning.message for warning in modifier.node_warnings],
|
||||
)
|
||||
assert not {attribute.name for attribute in result.attributes} & {
|
||||
".o_anyimage_depth_cut", ".o_anyimage_original_boundary",
|
||||
"_o_anyimage_depth_cut",
|
||||
}, node_group.name
|
||||
assert not any(
|
||||
"o_anyimage" in attribute.name for attribute in result.attributes
|
||||
), node_group.name
|
||||
assert not any(
|
||||
attribute.name.startswith("_o_") for attribute in result.attributes
|
||||
), node_group.name
|
||||
|
||||
@@ -7,6 +7,8 @@ from .smoothing import edge_boundary_field, pinned_smooth
|
||||
|
||||
def _gated(group, value, blocked, *, default):
|
||||
"""Return the value unless the blocked control is true."""
|
||||
if blocked is False:
|
||||
return value
|
||||
node = group.nodes.new("GeometryNodeSwitch")
|
||||
node.input_type = "FLOAT"
|
||||
node.inputs["True"].default_value = default
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Clean cutout boundary triangles and taper the split thickness profile."""
|
||||
|
||||
from .depth_surface import _math
|
||||
from .nodes import (
|
||||
boolean_node, compare_node, evaluate_field, group_input,
|
||||
read_float_attribute, sample_field, store_float_attribute,
|
||||
)
|
||||
from .nodes import boolean_node, compare_node, evaluate_field, read_float_attribute, store_float_attribute
|
||||
from .smoothing import edge_boundary_field
|
||||
from .boundary_smoothing import boundary_influence
|
||||
|
||||
@@ -23,37 +20,10 @@ def remove_boundary_triangles(group, geometry):
|
||||
return delete.outputs["Geometry"]
|
||||
|
||||
|
||||
def original_boundary_field(group, original, rim=None):
|
||||
"""Sample the unsplit outline in UV space after projection and separation."""
|
||||
nodes, links = group.nodes, group.links
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
uv.data_type = "FLOAT_VECTOR"
|
||||
uv.inputs["Name"].default_value = "UVMap"
|
||||
original_uv = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(original, original_uv.inputs["Geometry"])
|
||||
links.new(uv.outputs["Attribute"], original_uv.inputs["Position"])
|
||||
nearest = nodes.new("GeometryNodeSampleNearest")
|
||||
nearest.domain = "POINT"
|
||||
links.new(original_uv.outputs["Geometry"], nearest.inputs["Geometry"])
|
||||
links.new(uv.outputs["Attribute"], nearest.inputs["Sample Position"])
|
||||
if rim is None:
|
||||
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
|
||||
return sample_field(group, original, rim, "FLOAT", index=nearest.outputs["Index"])
|
||||
|
||||
|
||||
def taper_split_profile(group, geometry, original_boundary):
|
||||
"""Taper balloon thickness through the shared two-ring influence builder."""
|
||||
def taper_split_profile(group, geometry, cut_boundary):
|
||||
"""Taper the balloon profile to zero across the Depth Split cuts."""
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
influence = boundary_influence(group, original_boundary)
|
||||
controls = group_input(nodes, {"Depth Split"})
|
||||
has_split = compare_node(group, "GREATER_THAN", controls.outputs["Depth Split"], 0)
|
||||
band = nodes.new("GeometryNodeSwitch")
|
||||
band.input_type = "FLOAT"
|
||||
band.inputs["False"].default_value = 0.0
|
||||
links.new(has_split, band.inputs["Switch"])
|
||||
links.new(influence, band.inputs["True"])
|
||||
band = band.outputs[0]
|
||||
retained = _math(group, "SQRT", _math(group, "MAXIMUM", _math(group, "SUBTRACT", 1, band), 0))
|
||||
retained = _math(group, "SUBTRACT", 1, boundary_influence(group, boundary=cut_boundary))
|
||||
profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained)
|
||||
return store_float_attribute(group, geometry, "o_balloon", profile)
|
||||
|
||||
@@ -7,7 +7,7 @@ from .nodes import (
|
||||
from .smoothing import edge_boundary_field
|
||||
|
||||
|
||||
CUT_ATTRIBUTE = "_o_anyimage_depth_cut"
|
||||
CUT_ATTRIBUTE = "_o_depth_cut"
|
||||
|
||||
|
||||
def _math(group, operation, a, b=None):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import bpy
|
||||
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
|
||||
from ..common.nodes import (
|
||||
interface_socket, group_input, float_input, read_float_attribute, read_vector_attribute,
|
||||
interface_socket, group_input, float_input, boolean_node, read_float_attribute, read_vector_attribute,
|
||||
store_float_attribute, store_vector_attribute, remove_attribute_pattern,
|
||||
prepare_node_group, evaluate_field, compare_node,
|
||||
)
|
||||
@@ -10,62 +10,97 @@ from ..common.boundary_smoothing import smooth_cut_boundary
|
||||
from ..common.cutout import _position_field, _shade_output
|
||||
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera
|
||||
from ..common.smoothing import edge_boundary_field
|
||||
from ..common.cutout_boundary import original_boundary_field, remove_boundary_triangles, taper_split_profile
|
||||
from ..common.cutout_boundary import remove_boundary_triangles, taper_split_profile
|
||||
|
||||
|
||||
NODE_GROUP_NAME = "O Image Depth Cutout"
|
||||
SOLID_NORMAL_ATTRIBUTE = "_o_solid_normal"
|
||||
BALLOON_THICKNESS_SCALE = 2.0
|
||||
FRONT_INFLATION_SCALE = 0.5
|
||||
FRONT_NORMAL_ATTRIBUTE = "_o_front_normal"
|
||||
BALLOON_THICKNESS_BASE = 1.0
|
||||
NORMAL_SMOOTH_ITERATIONS = 256
|
||||
SOLID_WELD_SCALE = 1e-3
|
||||
|
||||
|
||||
def build_normal_weight(group, is_shell):
|
||||
"""Use full shell smoothing and thickness-weighted balloon smoothing."""
|
||||
control = group_input(group.nodes, {"Thickness"})
|
||||
amount = group.nodes.new("GeometryNodeSwitch")
|
||||
amount.input_type = "FLOAT"
|
||||
group.links.new(is_shell, amount.inputs["Switch"])
|
||||
group.links.new(control.outputs["Thickness"], amount.inputs["False"])
|
||||
amount.inputs["True"].default_value = 1
|
||||
ramp = group.nodes.new("ShaderNodeMapRange")
|
||||
ramp.interpolation_type = "SMOOTHSTEP"
|
||||
ramp.clamp = True
|
||||
group.links.new(amount.outputs[0], ramp.inputs["Value"])
|
||||
return ramp.outputs["Result"]
|
||||
def build_normal_weight(group, is_shell, profile):
|
||||
"""Keep shell smoothing and let the balloon profile drive the blend."""
|
||||
weight = group.nodes.new("GeometryNodeSwitch")
|
||||
weight.input_type = "FLOAT"
|
||||
group.links.new(is_shell, weight.inputs["Switch"])
|
||||
group.links.new(profile, weight.inputs["False"])
|
||||
weight.inputs["True"].default_value = 0.0
|
||||
return weight.outputs[0]
|
||||
|
||||
|
||||
def blend_normal(group, original, smoothed, weight):
|
||||
"""Normalize the thickness-weighted blend on the consumer geometry."""
|
||||
mix = group.nodes.new("ShaderNodeMix")
|
||||
mix.data_type = "VECTOR"
|
||||
if isinstance(weight, (int, float)):
|
||||
mix.inputs[0].default_value = weight
|
||||
else:
|
||||
group.links.new(weight, mix.inputs[0])
|
||||
group.links.new(original, mix.inputs[4])
|
||||
group.links.new(smoothed, mix.inputs[5])
|
||||
normalize = group.nodes.new("ShaderNodeVectorMath")
|
||||
def build_profile_weight(group, geometry):
|
||||
"""Normalize the corrected balloon profile into a zero to one blend weight."""
|
||||
profile = read_float_attribute(group, "o_balloon")
|
||||
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
|
||||
statistics.data_type, statistics.domain = "FLOAT", "POINT"
|
||||
group.links.new(geometry, statistics.inputs["Geometry"])
|
||||
group.links.new(profile, statistics.inputs["Attribute"])
|
||||
return _math(
|
||||
group, "DIVIDE", profile,
|
||||
_math(group, "MAXIMUM", statistics.outputs["Max"], 1e-6),
|
||||
)
|
||||
|
||||
|
||||
def build_island_normal(group, normal):
|
||||
"""Average the smoothed normals of one connected mesh island."""
|
||||
nodes, links = group.nodes, group.links
|
||||
island = nodes.new("GeometryNodeInputMeshIsland")
|
||||
accumulate = nodes.new("GeometryNodeAccumulateField")
|
||||
accumulate.data_type, accumulate.domain = "FLOAT_VECTOR", "POINT"
|
||||
links.new(normal, accumulate.inputs["Value"])
|
||||
links.new(island.outputs["Island Index"], accumulate.inputs["Group Index"])
|
||||
normalize = nodes.new("ShaderNodeVectorMath")
|
||||
normalize.operation = "NORMALIZE"
|
||||
group.links.new(mix.outputs["Result"], normalize.inputs[0])
|
||||
return normalize.outputs[0]
|
||||
links.new(accumulate.outputs["Total"], normalize.inputs[0])
|
||||
return normalize.outputs["Vector"]
|
||||
|
||||
|
||||
def build_front_normal(group, normal_weight):
|
||||
"""Smooth the projected surface normals that drive the inflation."""
|
||||
links = group.links
|
||||
def build_smoothed_normals(group):
|
||||
"""Return the smoothed normals and the average normal of each mesh island."""
|
||||
nodes, links = group.nodes, group.links
|
||||
normal = group.nodes.new("GeometryNodeInputNormal")
|
||||
blur = group.nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
links.new(normal.outputs[0], blur.inputs["Value"])
|
||||
links.new(group_input(group.nodes, {"Normal Smooth"}).outputs["Normal Smooth"], blur.inputs["Iterations"])
|
||||
return blend_normal(group, normal.outputs[0], blur.outputs[0], normal_weight)
|
||||
blur.inputs["Iterations"].default_value = NORMAL_SMOOTH_ITERATIONS
|
||||
smoothed = blur.outputs[0]
|
||||
return smoothed, build_island_normal(group, smoothed)
|
||||
|
||||
|
||||
def build_normal_direction(group, controls, smoothed, average, weight):
|
||||
"""Blend the smoothed normal against the island average normal."""
|
||||
nodes, links = group.nodes, group.links
|
||||
difference = group.nodes.new("ShaderNodeVectorMath")
|
||||
difference.operation = "SUBTRACT"
|
||||
links.new(average, difference.inputs[0])
|
||||
links.new(smoothed, difference.inputs[1])
|
||||
lean = _math(
|
||||
group, "MULTIPLY", _math(group, "SUBTRACT", 1.0, weight),
|
||||
controls.outputs["Edge Turn"],
|
||||
)
|
||||
blend = _math(group, "ADD", weight, lean)
|
||||
offset = group.nodes.new("ShaderNodeVectorMath")
|
||||
offset.operation = "SCALE"
|
||||
links.new(difference.outputs["Vector"], offset.inputs[0])
|
||||
links.new(blend, offset.inputs[3])
|
||||
direction = group.nodes.new("ShaderNodeVectorMath")
|
||||
direction.operation = "ADD"
|
||||
links.new(smoothed, direction.inputs[0])
|
||||
links.new(offset.outputs["Vector"], direction.inputs[1])
|
||||
normalize = group.nodes.new("ShaderNodeVectorMath")
|
||||
normalize.operation = "NORMALIZE"
|
||||
links.new(direction.outputs["Vector"], normalize.inputs[0])
|
||||
return normalize.outputs["Vector"]
|
||||
|
||||
|
||||
def build_inflation_amount(group, controls, is_shell):
|
||||
"""Balloon scales the profile, Shell moves a fixed distance."""
|
||||
"""Balloon scales the profile by Depth Scale, Shell moves a fixed distance."""
|
||||
nodes, links = group.nodes, group.links
|
||||
thickness = _math(
|
||||
group, "MULTIPLY", controls.outputs["Thickness"], BALLOON_THICKNESS_SCALE,
|
||||
group, "MULTIPLY", controls.outputs["Thickness"],
|
||||
_math(group, "ADD", BALLOON_THICKNESS_BASE, controls.outputs["Depth Scale"]),
|
||||
)
|
||||
balloon = _math(
|
||||
group, "MULTIPLY", read_float_attribute(group, "o_balloon"), thickness,
|
||||
@@ -89,9 +124,15 @@ def build_solid(group, surface, amount, normal, is_shell, controls, weld_distanc
|
||||
links.new(distance, result.inputs[3])
|
||||
return result.outputs[0]
|
||||
|
||||
# The front leaf moves by Thickness x o_balloon x Front Inflation, independent of
|
||||
# the Depth Scale that scales the rear recession.
|
||||
inflation = _math(
|
||||
group, "MULTIPLY", amount,
|
||||
_math(group, "MULTIPLY", controls.outputs["Front Inflation"], FRONT_INFLATION_SCALE),
|
||||
group, "MULTIPLY",
|
||||
_math(
|
||||
group, "MULTIPLY", read_float_attribute(group, "o_balloon"),
|
||||
controls.outputs["Thickness"],
|
||||
),
|
||||
controls.outputs["Front Inflation"],
|
||||
)
|
||||
front_amount = nodes.new("GeometryNodeSwitch")
|
||||
front_amount.input_type = "FLOAT"
|
||||
@@ -103,17 +144,47 @@ def build_solid(group, surface, amount, normal, is_shell, controls, weld_distanc
|
||||
links.new(surface, front.inputs["Geometry"])
|
||||
links.new(offset(front_amount.outputs[0]), front.inputs["Offset"])
|
||||
|
||||
span = _math(group, "ADD", amount, front_amount.outputs[0])
|
||||
span = _math(group, "MULTIPLY", span, -1.0)
|
||||
# The rear leaf inflates along the negated front direction, straight from the
|
||||
# untouched surface so the front offset cannot drag it along.
|
||||
span = _math(group, "MULTIPLY", amount, -1.0)
|
||||
extrude = nodes.new("GeometryNodeExtrudeMesh")
|
||||
extrude.mode = "FACES"
|
||||
extrude.inputs["Individual"].default_value = False
|
||||
links.new(front.outputs["Geometry"], extrude.inputs["Mesh"])
|
||||
links.new(surface, extrude.inputs["Mesh"])
|
||||
links.new(offset(span), extrude.inputs["Offset"])
|
||||
|
||||
# The rear surface smooths in proportion to its thickness and the balloon profile,
|
||||
# so thin shells and cut boundaries keep their exact positions.
|
||||
thickness = nodes.new("GeometryNodeSwitch")
|
||||
thickness.input_type = "FLOAT"
|
||||
links.new(is_shell, thickness.inputs["Switch"])
|
||||
links.new(controls.outputs["Thickness"], thickness.inputs["False"])
|
||||
links.new(controls.outputs["Shell Thickness"], thickness.inputs["True"])
|
||||
weight = _math(
|
||||
group, "MULTIPLY", _math(group, "MINIMUM", thickness.outputs[0], 1.0),
|
||||
read_float_attribute(group, "o_balloon"),
|
||||
)
|
||||
position = nodes.new("GeometryNodeInputPosition")
|
||||
blur = nodes.new("GeometryNodeBlurAttribute")
|
||||
blur.data_type = "FLOAT_VECTOR"
|
||||
blur.inputs["Weight"].default_value = 1.0
|
||||
links.new(position.outputs["Position"], blur.inputs["Value"])
|
||||
links.new(group_input(nodes, {"Back Smooth"}).outputs["Back Smooth"], blur.inputs["Iterations"])
|
||||
difference = nodes.new("ShaderNodeVectorMath")
|
||||
difference.operation = "SUBTRACT"
|
||||
links.new(blur.outputs["Value"], difference.inputs[0])
|
||||
links.new(position.outputs["Position"], difference.inputs[1])
|
||||
smooth_offset = nodes.new("ShaderNodeVectorMath")
|
||||
smooth_offset.operation = "SCALE"
|
||||
links.new(difference.outputs["Vector"], smooth_offset.inputs[0])
|
||||
links.new(weight, smooth_offset.inputs[3])
|
||||
smoothed = nodes.new("GeometryNodeSetPosition")
|
||||
links.new(extrude.outputs["Mesh"], smoothed.inputs["Geometry"])
|
||||
links.new(smooth_offset.outputs["Vector"], smoothed.inputs["Offset"])
|
||||
|
||||
# Extruding against the normal leaves the rear leaf and wall facing inward.
|
||||
flipped = nodes.new("GeometryNodeFlipFaces")
|
||||
links.new(extrude.outputs["Mesh"], flipped.inputs["Mesh"])
|
||||
links.new(smoothed.outputs["Geometry"], flipped.inputs["Mesh"])
|
||||
rear = store_float_attribute(
|
||||
group, flipped.outputs["Mesh"], NORMAL_REDUCTION_ATTRIBUTE_NAME, 1.0,
|
||||
)
|
||||
@@ -133,7 +204,6 @@ def _build_depth_surface(group, geometry, controls):
|
||||
split = controls.outputs["Depth Split"]
|
||||
image = controls.outputs["Depth Image"]
|
||||
scale = controls.outputs["Uniform Scale"]
|
||||
original = geometry
|
||||
geometry, corner_camera, cut_vertex = split_depth_surface(
|
||||
group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"],
|
||||
controls.outputs["Depth Scale"], triangles=True,
|
||||
@@ -141,6 +211,11 @@ def _build_depth_surface(group, geometry, controls):
|
||||
geometry = remove_boundary_triangles(group, geometry)
|
||||
boundary = edge_boundary_field(group.nodes, group.links)
|
||||
rim = evaluate_field(group, boundary, "FLOAT", "POINT")
|
||||
cut = evaluate_field(
|
||||
group, boolean_node(group, "AND", boundary, cut_vertex), "FLOAT", "POINT",
|
||||
)
|
||||
# Only the Depth Split cuts taper the profile; the outline keeps its thickness.
|
||||
geometry = taper_split_profile(group, geometry, cut)
|
||||
camera = build_surface_camera(
|
||||
group, _position_field(group, image), corner_camera, cut_vertex, split,
|
||||
)
|
||||
@@ -163,8 +238,7 @@ def _build_depth_surface(group, geometry, controls):
|
||||
links.new(projection, merge.inputs["Geometry"])
|
||||
links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"])
|
||||
projection = merge.outputs["Geometry"]
|
||||
projection = taper_split_profile(group, projection, original_boundary_field(group, original, rim))
|
||||
# The mode field is shared by the geometry and thickness choices.
|
||||
# The mode field is shared by the thickness and direction choices.
|
||||
mode = group.nodes.new("GeometryNodeMenuSwitch")
|
||||
mode.data_type = "BOOLEAN"
|
||||
for item, name in zip(mode.enum_items, ("Balloon", "Shell")):
|
||||
@@ -179,23 +253,45 @@ def _build_depth_surface(group, geometry, controls):
|
||||
links.new(projection, statistics.inputs["Geometry"])
|
||||
links.new(amount, statistics.inputs["Attribute"])
|
||||
has_thickness = compare_node(group, "GREATER_THAN", statistics.outputs["Max"], 1e-6)
|
||||
zero_shell = group.nodes.new("GeometryNodeSwitch")
|
||||
zero_shell.input_type = "GEOMETRY"
|
||||
links.new(has_thickness, zero_shell.inputs["Switch"])
|
||||
links.new(projection, zero_shell.inputs["False"])
|
||||
normal = build_front_normal(group, build_normal_weight(group, is_shell))
|
||||
# Both leaves must inflate along one normal, so it is carried across the
|
||||
# extrusion instead of being evaluated again on the moved surface.
|
||||
profile = build_profile_weight(group, projection)
|
||||
smoothed, average = build_smoothed_normals(group)
|
||||
weight = build_normal_weight(group, is_shell, profile)
|
||||
# Material normal strength: the balloon profile gates it, Depth Scale attenuates it.
|
||||
inflated = group.nodes.new("GeometryNodeSwitch")
|
||||
inflated.input_type = "FLOAT"
|
||||
links.new(is_shell, inflated.inputs["Switch"])
|
||||
links.new(profile, inflated.inputs["False"])
|
||||
inflated.inputs["True"].default_value = 1.0
|
||||
normal_strength = group.nodes.new("GeometryNodeSwitch")
|
||||
normal_strength.input_type = "FLOAT"
|
||||
links.new(has_thickness, normal_strength.inputs["Switch"])
|
||||
normal_strength.inputs["False"].default_value = 1.0
|
||||
links.new(inflated.outputs[0], normal_strength.inputs["True"])
|
||||
reduction = _math(
|
||||
group, "SUBTRACT", 1.0,
|
||||
_math(group, "MULTIPLY", controls.outputs["Depth Scale"], normal_strength.outputs[0]),
|
||||
)
|
||||
projection = store_float_attribute(
|
||||
group, projection, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
|
||||
)
|
||||
# The rear leaf inflates along the negated front direction, so one direction is
|
||||
# carried across the extrusion instead of being re-evaluated on the moved surface.
|
||||
projection = store_vector_attribute(
|
||||
group, projection, SOLID_NORMAL_ATTRIBUTE, normal,
|
||||
group, projection, FRONT_NORMAL_ATTRIBUTE,
|
||||
build_normal_direction(group, controls, smoothed, average, weight),
|
||||
)
|
||||
solid = build_solid(
|
||||
group, projection, amount, read_vector_attribute(group, SOLID_NORMAL_ATTRIBUTE),
|
||||
group, projection, amount,
|
||||
read_vector_attribute(group, FRONT_NORMAL_ATTRIBUTE),
|
||||
is_shell, controls,
|
||||
_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-3),
|
||||
_math(group, "MULTIPLY", typical.outputs["Mean"], SOLID_WELD_SCALE),
|
||||
)
|
||||
links.new(solid, zero_shell.inputs["True"])
|
||||
return remove_attribute_pattern(group, zero_shell.outputs[0], "_o_*")
|
||||
shaped = group.nodes.new("GeometryNodeSwitch")
|
||||
shaped.input_type = "GEOMETRY"
|
||||
links.new(has_thickness, shaped.inputs["Switch"])
|
||||
links.new(projection, shaped.inputs["False"])
|
||||
links.new(solid, shaped.inputs["True"])
|
||||
return remove_attribute_pattern(group, shaped.outputs[0], "_o_*")
|
||||
|
||||
|
||||
def build_image_depth_cutout_group():
|
||||
@@ -216,18 +312,18 @@ def build_image_depth_cutout_group():
|
||||
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
|
||||
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
|
||||
normal_smooth = interface_socket(group, 'Normal Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
normal_smooth.default_value, normal_smooth.min_value, normal_smooth.max_value = 512, 16, 512
|
||||
normal_smooth.description = 'Control smoothing of front and rear displacement normals. Higher values produce smoother directions.'
|
||||
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode, reaching half of the balloon thickness. Zero keeps the original front surface.'
|
||||
back_smooth = interface_socket(group, 'Back Smooth', 'INPUT', 'NodeSocketInt', options)
|
||||
back_smooth.default_value, back_smooth.min_value, back_smooth.max_value = 8, 0, 16
|
||||
back_smooth.description = 'Soften the rear surface where the balloon is thick. The effect follows Thickness and the balloon profile, so thin geometry and cut boundaries keep their positions.'
|
||||
float_input(group, 'Edge Turn', 2.0, minimum=-3.0, maximum=3.0, parent=options).description = 'Turn the outline direction: zero follows the smoothed normal, one reaches the island average normal, two mirrors the smoothed normal across it, and higher or negative values keep turning either way.'
|
||||
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode, as a share of the balloon thickness. Zero keeps the original front surface.'
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
data = group.interface.new_panel(name='Data', default_closed=True)
|
||||
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
|
||||
interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
|
||||
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
|
||||
controls = group_input(group.nodes, set())
|
||||
geometry = store_float_attribute(group, controls.outputs['Geometry'], NORMAL_REDUCTION_ATTRIBUTE_NAME, 0.0)
|
||||
geometry = _build_depth_surface(group, geometry, controls)
|
||||
geometry = _build_depth_surface(group, controls.outputs['Geometry'], controls)
|
||||
_shade_output(group, geometry)
|
||||
prepare_node_group(group)
|
||||
for item in group.interface.items_tree:
|
||||
|
||||
Reference in New Issue
Block a user