Add new node asset scripts for geometry and shader operations

- Implemented `build_mesh_plane.py` to create a geometry node group for mesh planes with customizable parameters.
- Created `build_shadeless.py` for a shader node group that displays color without scene lighting while preserving shadows and reflections.
- Added `validate.py` to validate the integrity of the newly created node groups and their inputs.
- Introduced `validate_cutout.py` to ensure the functionality of cutout node groups and their dependencies.
- Developed `validate_mesh_plane.py` to test the mesh plane creation and its modifier settings.
This commit is contained in:
2026-08-30 00:17:29 +08:00
parent 7d39fe8c5e
commit 32bcd45d7b
168 changed files with 7213 additions and 13121 deletions

View File

@@ -0,0 +1,13 @@
"""Mesh Plane and Depth Plane conversion operators."""
from .conversion import (
ConvertToDepthPlane,
ConvertToPlane,
GenerateDepthPlane,
)
__all__ = (
"ConvertToDepthPlane",
"ConvertToPlane",
"GenerateDepthPlane",
)

View File

@@ -0,0 +1,209 @@
import bpy
from ..common.image import (
color_data_name,
create_color_image,
depth_data_name,
is_image_empty,
material_data_name,
normal_data_name,
plane_bounds,
)
from ..common.material import create_material
from ..common.node_asset import modifier_input_identifier, set_modifier_input
from ..common.normal_map import bake_tangent_normal_image
from ..common.operator import push_undo
from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object
from .nodes import depth_plane_node_group
def copy_color_image(source_object):
source = source_object.data
image = source.copy()
if getattr(source, "source", None) == "GENERATED" or getattr(
source, "is_dirty", False
):
import numpy as np
pixels = np.empty(len(source.pixels), dtype=np.float32)
source.pixels.foreach_get(pixels)
image.pixels.foreach_set(pixels)
image.update()
image.name = color_data_name(source_object)
return image
def build_mesh_plane(context, source_object, subdivisions, material):
source_name = source_object.name
plane = create_mesh_plane_object(
context,
source_name,
plane_bounds(source_object),
material,
matrix_world=source_object.matrix_world,
subdivisions=subdivisions,
)
for polygon in plane.data.polygons:
polygon.use_smooth = True
plane["anyimage_mesh_shape"] = "PLANE"
for selected in context.selected_objects:
selected.select_set(False)
plane.select_set(True)
context.view_layer.objects.active = plane
bpy.data.objects.remove(source_object, do_unlink=True)
plane.name = source_name
return plane
def load_depth_field(path):
import numpy as np
with np.load(path, allow_pickle=False) as archive:
points = np.asarray(archive["points"], dtype=np.float32)
intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32)
valid = np.asarray(archive["valid"], dtype=bool)
calibration_valid = np.asarray(archive["calibration_valid"], dtype=bool)
image_size = tuple(int(value) for value in archive["image_size"])
reference_depth = float(archive["reference_depth"])
if (
points.ndim != 3
or points.shape[2] != 3
or valid.shape != points.shape[:2]
or calibration_valid.shape != points.shape[:2]
):
raise ValueError("The mesh point field is invalid")
if intrinsics.shape != (3, 3) or not reference_depth > 0.0:
raise ValueError("The mesh camera field is invalid")
return points, intrinsics, valid, calibration_valid, image_size, reference_depth
def anchored_point_field(source_object, field):
import numpy as np
points, intrinsics, valid, _calibration, image_size, reference_depth = field
width, height = image_size
x_min, x_max, y_min, y_max = plane_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
cx, cy = float(intrinsics[0, 2]), float(intrinsics[1, 2])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth)
scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth)
scale_z = float(np.sqrt(scale_x * scale_y))
camera_x = x_min + plane_width * cx / max(width - 1, 1)
camera_y = y_max - plane_height * cy / max(height - 1, 1)
anchored = np.empty_like(points, dtype=np.float32)
anchored[..., 0] = camera_x + points[..., 0] * scale_x
anchored[..., 1] = camera_y - points[..., 1] * scale_y
anchored[..., 2] = (reference_depth - points[..., 2]) * scale_z
return anchored, valid, points[..., 2]
def create_depth_image(source_object, anchored_points):
import numpy as np
values = np.asarray(anchored_points, dtype=np.float32)[..., 2]
height, width = values.shape
pixels = np.empty((height, width, 4), dtype=np.float32)
pixels[:, :, :3] = values[:, :, None]
pixels[:, :, 3] = 1.0
image = bpy.data.images.new(
depth_data_name(source_object),
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def load_normal_image(path, source_object):
image = bpy.data.images.load(str(path), check_existing=False)
image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color"
image.pack()
return image
def build_depth_plane(context, source_object, subdivisions, material, field):
source_name = source_object.name
mesh = create_mesh_plane_mesh(source_name, plane_bounds(source_object))
for polygon in mesh.polygons:
polygon.use_smooth = True
mesh.materials.append(material)
plane = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(plane)
plane.matrix_world = source_object.matrix_world.copy()
plane["anyimage_mesh_shape"] = "DEPTH_PLANE"
node_group = depth_plane_node_group()
modifier = plane.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
depth_image = create_depth_image(
source_object, anchored_point_field(source_object, field)[0]
)
for name, value in (
("Subdivide", max(0, min(10, int(subdivisions)))),
("Material", material),
("Thickness", 0.1),
("Depth Amount", 1.0),
("Depth Image", depth_image),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
for selected in context.selected_objects:
selected.select_set(False)
plane.select_set(True)
context.view_layer.objects.active = plane
bpy.data.objects.remove(source_object, do_unlink=True)
plane.name = source_name
return plane
def build_depth_plane_response(context, result, operator):
source_object = bpy.data.objects.get(operator.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty no longer exists")
color_image = create_color_image(
source_object,
result.directory,
assign_to_source=False,
)
color_image.name = color_data_name(source_object)
normal_path = result.directory / "normal.png"
normal_image = (
load_normal_image(normal_path, source_object)
if normal_path.is_file()
else None
)
material = create_material(
material_data_name(source_object),
color_image,
0.0,
normal_image=normal_image,
scene=context.scene,
)
plane = build_depth_plane(
context,
source_object,
operator.mesh_detail,
material,
load_depth_field(result.file("field")),
)
if normal_image is not None:
bake_tangent_normal_image(
context,
source_object,
plane,
normal_image,
)
push_undo("Convert to Depth Plane")

View File

@@ -0,0 +1,213 @@
import bpy
from ...preferences import addon_preferences, configured_max_ai_input_size
from ...properties import DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ..common.image import (
analysis_source_image,
image_user_settings,
is_image_empty,
material_data_name,
prepare_image_analysis_inputs,
remove_temporary_input,
scaled_process_resolution,
video_total_frames,
)
from ..common.material import create_material
from ..common.operator import (
HIDDEN,
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_model,
source_start_frame,
)
from .build import build_depth_plane_response, build_mesh_plane, copy_color_image
def depth_plane_settings(context):
preferences = addon_preferences(context)
return (
getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY),
float(getattr(preferences, "mesh_process_res_factor", 100.0)),
int(getattr(preferences, "mesh_resolution_level", 5)),
)
class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_depth_plane"
bl_label = "Generate Depth Plane"
job_type = "prepare-geometry-data"
starting_message = "Preparing Depth Plane"
input_path: bpy.props.StringProperty(options=HIDDEN)
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
mesh_detail: bpy.props.IntProperty(default=0, min=0, max=10, options=HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN)
process_res: bpy.props.IntProperty(default=1008, min=1, options=HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN)
max_input_size: bpy.props.IntProperty(
default=DEFAULT_MAX_AI_INPUT_SIZE,
min=MIN_MAX_AI_INPUT_SIZE,
max=MAX_MAX_AI_INPUT_SIZE,
options=HIDDEN,
)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
require_model(self.model)
parameters = {
"input": str(input_path(self.input_path)),
"analysis_input": str(input_path(self.analysis_input_path)),
"shape": "DEPTH_PLANE",
"polygon": None,
"mesh_detail": self.mesh_detail,
"device": production_device().lower(),
"video_frames": 1,
"start_frame": source_start_frame(self.source_object_name),
"max_input_size": int(self.max_input_size),
"refine_selection": False,
"normal_analysis": False,
"invert": False,
}
settings = context.scene.anyimage_settings
if self.model.startswith("MOGE2_"):
parameters.update(
moge2_parameters(
settings,
input_path(self.input_path),
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
)
)
else:
parameters.update(
da3_parameters(
settings,
input_path(self.input_path),
self.model,
self.process_res,
source_start_frame(self.source_object_name),
)
)
parameters.update(shape="DEPTH_PLANE", polygon=None)
return parameters
def response(self, context, result):
build_depth_plane_response(context, result, self)
return "Depth Plane is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
cleanup_temporary_input(
self.analysis_input_path,
self.delete_analysis_input,
)
def start_depth_plane_job(operator, context):
source_object = context.object
if video_total_frames(source_object.data) != 1:
operator.report({"ERROR"}, "Depth Plane currently supports still images")
return {"CANCELLED"}
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
try:
source_path, delete_input, analysis_path, delete_analysis_input = (
prepare_image_analysis_inputs(source_object.data, context.scene)
)
analysis_image = analysis_source_image(source_object.data)
model, process_factor, resolution_level = depth_plane_settings(context)
process_res = (
scaled_process_resolution(analysis_image, process_factor)
if not model.startswith("MOGE2_")
else 1
)
except (RuntimeError, ValueError) as error:
operator.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.anyimage.generate_depth_plane(
input_path=str(source_path),
analysis_input_path=str(analysis_path),
source_object_name=source_object.name,
mesh_detail=operator.mesh_detail,
model=model,
process_res=process_res,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
delete_input=delete_input,
delete_analysis_input=delete_analysis_input,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
if delete_analysis_input:
remove_temporary_input(analysis_path)
return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"}
class ConvertToPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_plane"
bl_label = "Convert to Plane"
bl_description = "Convert the complete Image Empty into a rectangular plane"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10)
@classmethod
def poll(cls, context):
return is_image_empty(context.object)
def execute(self, context):
source_object = context.object
color_image = copy_color_image(source_object)
frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings(
source_object
)
material = create_material(
material_data_name(source_object),
color_image,
1.0,
frame_start=frame_start,
color_frame_offset=frame_offset,
color_frame_duration=frame_duration,
use_cyclic=use_cyclic,
scene=context.scene,
)
build_mesh_plane(context, source_object, self.mesh_detail, material)
return {"FINISHED"}
class ConvertToDepthPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_depth_plane"
bl_label = "Convert to Depth Plane"
bl_description = "Convert the complete Image Empty into a front-projected relief"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10)
@classmethod
def poll(cls, context):
from ...runtime import runtime
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, context, _event):
return self.execute(context)
def execute(self, context):
return start_depth_plane_job(self, context)

View File

@@ -0,0 +1,86 @@
"""Create Mesh Plane objects used by conversion and clipboard paste."""
import bpy
from ..common.node_asset import (
load_mesh_plane_node_group,
modifier_input_identifier,
set_modifier_input,
)
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
def create_mesh_plane_mesh(name, bounds):
x_min, x_max, y_min, y_max = bounds
if x_max <= x_min or y_max <= y_min:
raise ValueError("The plane has invalid dimensions")
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(
(
(x_min, y_min, 0.0),
(x_max, y_min, 0.0),
(x_max, y_max, 0.0),
(x_min, y_max, 0.0),
),
(),
((0, 1, 2, 3),),
)
mesh.update()
uv_layer = mesh.uv_layers.new(name="UVMap")
for loop, uv in zip(
mesh.loops,
((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)),
):
uv_layer.data[loop.index].uv = uv
return mesh
def add_mesh_plane_processing(
plane_object,
material,
subdivisions=0,
thickness=0.0,
):
node_group = load_mesh_plane_node_group()
modifier = plane_object.modifiers.new(
name=MESH_PLANE_NODE_GROUP_NAME,
type="NODES",
)
modifier.node_group = node_group
if hasattr(modifier, "show_group_selector"):
modifier.show_group_selector = False
for name, value in (
("Subdivide", max(0, int(subdivisions))),
("Thickness", max(0.0, float(thickness))),
("Material", material),
):
identifier = modifier_input_identifier(node_group, name)
set_modifier_input(modifier, identifier, value)
return modifier
def create_mesh_plane_object(
context,
name,
bounds,
material,
matrix_world=None,
subdivisions=0,
thickness=0.0,
):
mesh = create_mesh_plane_mesh(name, bounds)
mesh.materials.append(material)
plane = bpy.data.objects.new(name, mesh)
context.collection.objects.link(plane)
if matrix_world is not None:
plane.matrix_world = matrix_world.copy()
add_mesh_plane_processing(
plane,
material,
subdivisions=subdivisions,
thickness=thickness,
)
return plane

View File

@@ -0,0 +1,156 @@
"""Runtime Geometry Nodes for Depth Plane."""
import bpy
from ..common.node_asset import load_mesh_plane_node_group
from ..common.node_graph import group_input, interface_socket
def depth_plane_node_group():
group_name = "O Image Depth Plane"
group = bpy.data.node_groups.get(group_name)
if group is not None:
return group
if group is None:
group = bpy.data.node_groups.new(group_name, "GeometryNodeTree")
else:
group.nodes.clear()
group.interface.clear()
group.is_modifier = True
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value = 7
subdivide.min_value = 0
subdivide.max_value = 10
thickness = interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.1
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
depth_amount = interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat")
depth_amount.default_value = 1.0
depth_amount.min_value = 0.0
depth_image = interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage")
depth_image.hide_in_modifier = True
material = interface_socket(group, "Material", "INPUT", "NodeSocketMaterial")
material.hide_in_modifier = True
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = group_input(
nodes,
(-1850.0, 450.0),
{"Geometry", "Subdivide", "Material"},
)
shape_input = group_input(
nodes,
(-1250.0, 850.0),
{"Thickness", "Depth Amount"},
)
image_input = group_input(nodes, (-1050.0, 50.0), {"Depth Image"})
cleanup_input = group_input(nodes, (650.0, -200.0), {"Thickness"})
group_output = nodes.new("NodeGroupOutput")
group_output.location = (1600.0, 450.0)
mesh_plane = nodes.new("GeometryNodeGroup")
mesh_plane.node_tree = load_mesh_plane_node_group()
mesh_plane.inputs["Thickness"].default_value = 0.0
mesh_plane.location = (-1550.0, 450.0)
links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"])
links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"])
links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"])
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0)
extrude.inputs["Individual"].default_value = False
extrude.location = (-1200.0, 450.0)
links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"])
links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"])
geometry = extrude.outputs["Mesh"]
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
uv.location = (-1000.0, 50.0)
texture = nodes.new("GeometryNodeImageTexture")
texture.location = (-800.0, 50.0)
links.new(image_input.outputs["Depth Image"], texture.inputs["Image"])
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
separate = nodes.new("ShaderNodeSeparateXYZ")
separate.location = (-600.0, 50.0)
links.new(texture.outputs["Color"], separate.inputs["Vector"])
raw_depth = nodes.new("ShaderNodeMath")
raw_depth.operation = "MULTIPLY"
raw_depth.location = (-350.0, 150.0)
links.new(separate.outputs["X"], raw_depth.inputs[0])
links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1])
inward_limit = nodes.new("ShaderNodeMath")
inward_limit.operation = "MULTIPLY"
inward_limit.inputs[1].default_value = -0.98
inward_limit.location = (-350.0, -50.0)
links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0])
clamp = nodes.new("ShaderNodeMath")
clamp.operation = "MAXIMUM"
clamp.location = (-100.0, 50.0)
links.new(raw_depth.outputs[0], clamp.inputs[0])
links.new(inward_limit.outputs[0], clamp.inputs[1])
has_thickness = nodes.new("ShaderNodeMath")
has_thickness.operation = "GREATER_THAN"
has_thickness.inputs[1].default_value = 1e-6
has_thickness.location = (-350.0, -250.0)
links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0])
choose_depth = nodes.new("GeometryNodeSwitch")
choose_depth.input_type = "FLOAT"
choose_depth.location = (150.0, 50.0)
links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"])
links.new(raw_depth.outputs[0], choose_depth.inputs["False"])
links.new(clamp.outputs[0], choose_depth.inputs["True"])
combine = nodes.new("ShaderNodeCombineXYZ")
combine.location = (350.0, 50.0)
links.new(choose_depth.outputs["Output"], combine.inputs["Z"])
front = nodes.new("FunctionNodeBooleanMath")
front.operation = "NOT"
front.location = (150.0, 300.0)
links.new(extrude.outputs["Top"], front.inputs[0])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (550.0, 450.0)
links.new(geometry, set_position.inputs["Geometry"])
links.new(front.outputs[0], set_position.inputs["Selection"])
links.new(combine.outputs["Vector"], set_position.inputs["Offset"])
geometry = set_position.outputs["Geometry"]
zero_thickness = nodes.new("ShaderNodeMath")
zero_thickness.operation = "LESS_THAN"
zero_thickness.inputs[1].default_value = 1e-6
zero_thickness.location = (750.0, -150.0)
links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0])
back_or_side = nodes.new("FunctionNodeBooleanMath")
back_or_side.operation = "OR"
back_or_side.location = (750.0, 150.0)
links.new(extrude.outputs["Top"], back_or_side.inputs[0])
links.new(extrude.outputs["Side"], back_or_side.inputs[1])
delete_selection = nodes.new("FunctionNodeBooleanMath")
delete_selection.operation = "AND"
delete_selection.location = (950.0, 50.0)
links.new(zero_thickness.outputs[0], delete_selection.inputs[0])
links.new(back_or_side.outputs[0], delete_selection.inputs[1])
delete_geometry = nodes.new("GeometryNodeDeleteGeometry")
delete_geometry.domain = "FACE"
delete_geometry.location = (1100.0, 450.0)
links.new(geometry, delete_geometry.inputs["Geometry"])
links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"])
shade_smooth = nodes.new("GeometryNodeSetShadeSmooth")
shade_smooth.domain = "FACE"
shade_smooth.inputs["Shade Smooth"].default_value = True
shade_smooth.location = (1350.0, 450.0)
links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"])
links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"])
group.use_fake_user = True
return group
def plane_node_group(shape):
if shape == "PLANE":
return load_mesh_plane_node_group()
if shape == "DEPTH_PLANE":
return depth_plane_node_group()
raise ValueError(f"Unsupported Plane shape: {shape}")