Add operators for model downloading and surface generation

- Implemented `DownloadModel` and `DownloadRequiredModels` operators for downloading models.
- Created `ClearModels` operator to clear model resources.
- Added `ConvertToSurface` operator for converting image empties into textured surfaces.
- Introduced `GenerateDa3Surface` and `GenerateMoge2Surface` operators for generating surfaces using DA3 and MoGe-2 models respectively.
- Developed utility functions for handling image mesh data, assigning attributes, and managing geometry nodes.
- Established runtime configuration and post-installation model downloading in `runtime.py`.
This commit is contained in:
2026-08-20 01:56:04 +08:00
parent ee46d98b93
commit 13ef705226
39 changed files with 4729 additions and 3138 deletions

View File

@@ -71,11 +71,32 @@ class AddonTest(unittest.TestCase):
sys.path.insert(0, str(SOURCE_ROOT))
cls.paths_before_import = tuple(sys.path)
cls.addon = importlib.import_module("snapform")
cls.job_runtime = importlib.import_module("snapform.runtime")
cls.model_operators = importlib.import_module("snapform.operators.models")
cls.image_depth = importlib.import_module(
"snapform.blender.depth_plane"
"snapform.operators.material"
)
cls.image_data = importlib.import_module(
"snapform.operators.image"
)
cls.depth_operator = importlib.import_module(
"snapform.operators.depth_plane.operator"
)
cls.image_background = importlib.import_module(
"snapform.operators.background"
)
cls.image_mesh = importlib.import_module(
"snapform.blender.surface"
"snapform.operators.surface"
)
cls.balloon = importlib.import_module("snapform.operators.balloon.operator")
cls.balloon_geometry = importlib.import_module(
"snapform.operators.balloon.geometry"
)
cls.balloon_poisson = importlib.import_module(
"snapform.operators.balloon.poisson"
)
cls.balloon_teddy = importlib.import_module(
"snapform.operators.balloon.teddy"
)
cls.paths_after_import = tuple(sys.path)
cls.fake_bpy = fake_bpy
@@ -113,7 +134,7 @@ class AddonTest(unittest.TestCase):
if hasattr(self.fake_bpy.types.Scene, "snapform_settings"):
del self.fake_bpy.types.Scene.snapform_settings
def test_snapform_has_no_runtime_boundary_modules(self):
def test_runtime_and_operator_support_modules_have_explicit_boundaries(self):
for name in (
"client.py",
"environment.py",
@@ -122,7 +143,12 @@ class AddonTest(unittest.TestCase):
"status_bar.py",
):
self.assertFalse((SOURCE_ROOT / "snapform" / name).exists(), name)
self.assertFalse(hasattr(self.addon, "client"))
self.assertTrue((SOURCE_ROOT / "snapform" / "runtime.py").is_file())
self.assertFalse((SOURCE_ROOT / "snapform" / "job_runtime.py").exists())
self.assertFalse((SOURCE_ROOT / "snapform" / "client").exists())
self.assertFalse((SOURCE_ROOT / "snapform" / "ui").exists())
self.assertFalse((SOURCE_ROOT / "snapform" / "operators.py").exists())
self.assertFalse((SOURCE_ROOT / "snapform" / "blender").exists())
def test_mps_requires_m4_or_newer(self):
properties = self.addon.properties
@@ -184,7 +210,7 @@ class AddonTest(unittest.TestCase):
blendjob = importlib.import_module("blendjob")
self.assertFalse(hasattr(blendjob, "JobRequest"))
def test_job_operators_are_flat_and_use_plain_properties(self):
def test_operator_modules_use_plain_properties(self):
operators = self.addon.operators
for name in (
"SnapFormJobMixin",
@@ -195,18 +221,18 @@ class AddonTest(unittest.TestCase):
"BackgroundRequestMixin",
):
self.assertFalse(hasattr(operators, name), name)
for operator in operators.JOB_OPERATOR_CLASSES:
for operator in operators.CLASSES:
for name in getattr(operator, "__annotations__", {}):
self.assertFalse(name.startswith("snapform_"), (operator, name))
def test_concrete_job_operator_is_the_blender_operator(self):
operator = self.addon.operators.GenerateMoge2Depth
self.assertEqual(operator.bl_idname, "snapform.generate_moge2_depth")
self.assertIn(self.addon.job_runtime.JobOperatorBase, operator.__mro__)
self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__)
self.assertIn(self.fake_bpy.types.Operator, operator.__mro__)
def test_default_request_sends_every_operator_property(self):
class Example(self.addon.job_runtime.JobOperatorBase):
class Example(self.job_runtime.JobOperatorBase):
value: object()
enabled: object()
@@ -219,7 +245,7 @@ class AddonTest(unittest.TestCase):
)
def test_overridden_request_purely_replaces_default_properties(self):
class Example(self.addon.job_runtime.JobOperatorBase):
class Example(self.job_runtime.JobOperatorBase):
value: object()
def request(self, context):
@@ -248,7 +274,7 @@ class AddonTest(unittest.TestCase):
def mark_job_complete(self, _job_id):
pass
class Example(self.addon.job_runtime.JobOperatorBase):
class Example(self.job_runtime.JobOperatorBase):
job_type = "example"
value: object()
@@ -335,7 +361,7 @@ class AddonTest(unittest.TestCase):
(job_type, parameters)
)
)
self.addon.job_runtime.post_install(fake_runtime)
self.job_runtime.post_install(fake_runtime)
self.assertEqual(
calls,
[
@@ -355,7 +381,7 @@ class AddonTest(unittest.TestCase):
RuntimeError,
"MoGe-2 ViT-S Normal: network unavailable; BEN2 Base: network unavailable",
):
self.addon.job_runtime.post_install(
self.job_runtime.post_install(
SimpleNamespace(request=request)
)
@@ -452,7 +478,7 @@ class AddonTest(unittest.TestCase):
def test_required_model_operator_requests_both_models(self):
operator = self.addon.operators.DownloadRequiredModels()
with patch.object(self.addon.operators, "require_environment"):
with patch.object(self.model_operators, "require_environment"):
parameters = operator.request(None)
self.assertEqual(
@@ -494,7 +520,11 @@ class AddonTest(unittest.TestCase):
self.assertEqual(
calls[0],
("snapform.balloon_lasso", True),
)
self.assertIn(
("snapform.download_required_models", True),
calls,
)
self.assertIn(("snapform.convert_to_depth_plane", False), calls)
self.assertIn(("snapform.convert_to_surface", False), calls)
@@ -532,24 +562,336 @@ class AddonTest(unittest.TestCase):
):
menu.draw(None)
self.assertEqual(calls[0], ("snapform.install_environment", True))
self.assertEqual(
calls[1],
("snapform.install_environment_mirror", True),
calls[0],
("snapform.balloon_lasso", True),
)
self.assertIn(("snapform.install_environment", True), calls)
self.assertIn(("snapform.install_environment_mirror", True), calls)
self.assertNotIn(("snapform.download_required_models", True), calls)
self.assertIn(("snapform.convert_to_depth_plane", False), calls)
self.assertIn(("snapform.convert_to_surface", False), calls)
self.assertIn(("snapform.remove_image_background", False), calls)
def test_balloon_contour_is_smooth_and_evenly_resampled(self):
import numpy as np
contour = self.balloon_geometry.smooth_lasso_contour(
((0, 0), (3, 0), (4, 0), (4, 2), (0, 2)),
0.25,
)
closed = np.vstack((contour, contour[0]))
lengths = np.linalg.norm(np.diff(closed, axis=0), axis=1)
self.assertGreater(len(contour), 20)
self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0)
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
def test_balloon_interior_points_have_uniform_spacing(self):
import numpy as np
contour = np.asarray(((0, 0), (4, 0), (4, 4), (0, 4)), dtype=float)
points = self.balloon_geometry.uniform_interior_points(contour, 0.5)
distances = np.linalg.norm(points[:, None] - points[None, :], axis=2)
np.fill_diagonal(distances, np.inf)
nearest = distances.min(axis=1)
self.assertGreater(len(points), 20)
self.assertAlmostEqual(float(np.median(nearest)), 0.5, delta=0.02)
self.assertGreater(float(nearest.min()), 0.49)
def test_balloon_default_sampling_stays_coarse(self):
import numpy as np
polygon = np.asarray(
((100, 100), (900, 100), (950, 500), (800, 900),
(200, 900), (50, 500)),
dtype=float,
)
contour, interior, _spacing = (
self.balloon_geometry.prepare_planar_points(
polygon,
(1000, 1000),
(0.0, 1.0, 0.0, 1.0),
)
)
self.assertLess(len(contour), 150)
self.assertLess(len(interior), 400)
def test_balloon_sampling_uses_world_distance(self):
import numpy as np
polygon = np.asarray(
((0, 0), (1000, 0), (1000, 1000), (0, 1000)),
dtype=float,
)
unit_contour, unit_interior, unit_spacing = (
self.balloon_geometry.prepare_planar_points(
polygon,
(1000, 1000),
(0.0, 1.0, 0.0, 1.0),
)
)
scaled_contour, scaled_interior, scaled_spacing = (
self.balloon_geometry.prepare_planar_points(
polygon,
(1000, 1000),
(0.0, 2.0, 0.0, 2.0),
world_metric=((0.25, 0.0), (0.0, 0.25)),
)
)
self.assertEqual(unit_spacing, 0.1)
self.assertEqual(scaled_spacing, 0.1)
self.assertEqual(len(unit_contour), len(scaled_contour))
self.assertEqual(len(unit_interior), len(scaled_interior))
def test_poisson_balloon_is_zero_on_boundary_and_positive_inside(self):
import numpy as np
points = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
heights = self.balloon_poisson.poisson_balloon_heights(
points, faces, boundary, 0.45
)
self.assertTrue((heights[:4] == 0.0).all())
self.assertGreater(heights[4], 0.0)
def test_poisson_balloon_scales_in_world_units(self):
import numpy as np
points = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
original = self.balloon_poisson.poisson_balloon_heights(
points, faces, boundary, 0.45
)
scaled = self.balloon_poisson.poisson_balloon_heights(
points * 2.0, faces, boundary, 0.45
)
self.assertAlmostEqual(scaled[4], original[4] * 2.0, places=6)
def test_poisson_balloon_makes_wide_regions_thicker(self):
import numpy as np
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
wide = np.asarray(
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
dtype=float,
)
narrow = np.asarray(
((0, 0), (4, 0), (4, 0.6), (0, 0.6), (2, 0.3)),
dtype=float,
)
wide_height = self.balloon_poisson.poisson_balloon_heights(
wide, faces, boundary, 0.45
)[4]
narrow_height = self.balloon_poisson.poisson_balloon_heights(
narrow, faces, boundary, 0.45
)[4]
self.assertGreater(wide_height, narrow_height)
def test_teddy_balloon_uses_chordal_axis_radius(self):
import numpy as np
points = np.asarray(
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
nodes = np.asarray(((1, 1), (3, 1)), dtype=float)
with patch.object(
self.balloon_teddy,
"chordal_axis",
return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))),
):
heights = self.balloon_teddy.teddy_balloon_heights(
points, faces, points[:4], boundary, 0.45
)
self.assertTrue((heights[:4] == 0.0).all())
self.assertAlmostEqual(heights[4], 0.45, places=6)
def test_balloon_dispatches_distinct_height_algorithms(self):
import numpy as np
points = np.asarray(((0, 0),), dtype=float)
boundary = np.asarray((True,))
with patch.object(
self.balloon_geometry,
"poisson_balloon_heights",
return_value=np.asarray((1.0,)),
) as poisson, patch.object(
self.balloon_geometry,
"teddy_balloon_heights",
return_value=np.asarray((2.0,)),
) as teddy:
poisson_result = self.balloon_geometry.balloon_heights(
"POISSON", points, (), points, boundary, 0.45
)
teddy_result = self.balloon_geometry.balloon_heights(
"TEDDY", points, (), points, boundary, 0.45
)
self.assertEqual(poisson_result[0], 1.0)
self.assertEqual(teddy_result[0], 2.0)
poisson.assert_called_once()
teddy.assert_called_once()
def test_balloon_geometry_has_a_shared_closed_seam(self):
from collections import Counter
import numpy as np
planar = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces_2d = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
heights = np.asarray((0, 0, 0, 0, 1), dtype=float)
vertices, faces, uv = self.balloon_geometry.build_closed_geometry(
planar,
faces_2d,
boundary,
heights,
(0.0, 2.0, 0.0, 2.0),
)
edges = Counter()
for face in faces:
for start, end in zip(face, face[1:] + face[:1]):
edges[tuple(sorted((start, end)))] += 1
self.assertEqual(len(vertices), 6)
self.assertEqual(len(vertices), len(uv))
self.assertEqual(uv.dtype, np.float32)
self.assertTrue(edges)
self.assertTrue(all(users == 2 for users in edges.values()))
def test_balloon_controller_and_extract_operator_are_registered(self):
self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
self.assertIn(self.balloon.BalloonLasso, self.addon.CLASSES)
self.assertLess(
self.addon.CLASSES.index(self.balloon.ExtractBalloon),
self.addon.CLASSES.index(self.balloon.BalloonLasso),
)
def test_balloon_redo_properties_use_mode_and_world_resolution(self):
import inspect
spacing, inflation = self.balloon.balloon_geometry_parameters(2.0, 0.2)
self.assertEqual(spacing, 0.2)
self.assertEqual(inflation, 0.9)
operator = self.balloon.ExtractBalloon
self.assertIn("REGISTER", operator.bl_options)
self.assertIn("mode", operator.__annotations__)
self.assertIn("thickness", operator.__annotations__)
self.assertIn("resolution", operator.__annotations__)
self.assertIn("source_matrix_data", operator.__annotations__)
resolution_source = inspect.getsource(self.balloon.resolution_property)
thickness_source = inspect.getsource(self.balloon.thickness_property)
self.assertIn('subtype="DISTANCE"', resolution_source)
self.assertIn('unit="LENGTH"', resolution_source)
self.assertIn("soft_max=5.0", thickness_source)
self.assertNotIn(" max=", thickness_source)
def test_balloon_lasso_passes_hud_and_navigation_events(self):
regions = (
SimpleNamespace(type="WINDOW", x=0, y=0, width=800, height=600),
SimpleNamespace(type="HUD", x=20, y=20, width=240, height=180),
)
context = SimpleNamespace(area=SimpleNamespace(regions=regions))
self.assertTrue(
self.balloon.event_over_interface_region(
context,
SimpleNamespace(mouse_x=40, mouse_y=40),
)
)
self.assertFalse(
self.balloon.event_over_interface_region(
context,
SimpleNamespace(mouse_x=500, mouse_y=400),
)
)
for event_type in (
"MIDDLEMOUSE",
"WHEELUPMOUSE",
"TRACKPADPAN",
"NDOF_MOTION",
):
self.assertIn(event_type, self.balloon.VIEW_NAVIGATION_EVENTS)
self.assertEqual(self.balloon.BalloonLasso.bl_options, {"INTERNAL"})
def test_balloon_lasso_dispatches_individual_redo_operator(self):
import inspect
execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute)
invoke_source = inspect.getsource(self.balloon.BalloonLasso.invoke)
modal_source = inspect.getsource(self.balloon.BalloonLasso.modal)
self.assertIn("json.loads(self.selection_data)", execute_source)
self.assertIn("deserialize_matrix(self.source_matrix_data)", execute_source)
self.assertIn("select_result=not self.keep_source_selected", execute_source)
self.assertIn("serialize_matrix(source_object.matrix_world)", invoke_source)
self.assertIn("bpy.ops.snapform.extract_balloon(", modal_source)
self.assertIn('"EXEC_DEFAULT"', modal_source)
self.assertIn("keep_source_selected=True", modal_source)
self.assertIn('event.type == "ESC"', modal_source)
self.assertIn('return {"RUNNING_MODAL"}', modal_source)
self.assertIn('return {"PASS_THROUGH"}', modal_source)
self.assertIn("event_over_interface_region(context, event)", modal_source)
self.assertNotIn("extract_balloon_object(", modal_source)
def test_balloon_matrix_serialization_keeps_all_components(self):
import json
matrix = tuple(
tuple(row * 4 + column for column in range(4))
for row in range(4)
)
values = json.loads(self.balloon.serialize_matrix(matrix))
self.assertEqual(values, list(range(16)))
def test_extract_balloon_preserves_the_source_object(self):
import inspect
source = inspect.getsource(self.balloon.extract_balloon_object)
self.assertNotIn("objects.remove(source_object", source)
self.assertIn("balloon_name = source_name", source)
self.assertIn("selected_object = balloon if select_result else source_object", source)
self.assertIn("balloon.matrix_world = matrix_world.copy()", source)
self.assertIn("create_material(", source)
self.assertIn("color_image = image.copy()", source)
self.assertIn("color_image.name = color_data_name(source_object)", source)
self.assertIn("balloon_name,\n color_image,\n 0.0,", source)
self.assertIn(
"surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME",
source,
)
self.assertEqual(
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
"O Texture Layer",
)
def test_image_operators_require_environment_and_required_models(self):
context = SimpleNamespace(
object=SimpleNamespace(data=SimpleNamespace()),
)
operators = (
(self.image_depth, self.addon.ConvertToDepthPlane),
(self.image_mesh, self.addon.ConvertToSurface),
(self.image_depth, self.addon.RemoveImageBackground),
(self.depth_operator, self.depth_operator.ConvertToDepthPlane),
(self.image_mesh, self.image_mesh.ConvertToSurface),
(self.image_background, self.image_background.RemoveImageBackground),
)
states = (
@@ -719,8 +1061,8 @@ class AddonTest(unittest.TestCase):
)
with patch.object(
self.addon.operators, "require_environment"
), patch.object(self.addon.operators, "require_model"):
self.depth_operator, "require_environment"
), patch.object(self.depth_operator, "require_model"):
parameters = operator.request(context)
self.assertEqual(parameters["input"], __file__)
@@ -738,7 +1080,7 @@ class AddonTest(unittest.TestCase):
context = object()
with patch.object(
self.addon.operators, "build_moge2_depth_response"
self.depth_operator, "build_moge2_depth_response"
) as build:
operator.response(context, result)
@@ -752,7 +1094,7 @@ class AddonTest(unittest.TestCase):
)
def test_entry_operator_dispatches_plain_properties(self):
operator = self.addon.ConvertToDepthPlane()
operator = self.depth_operator.ConvertToDepthPlane()
operator.model = "MOGE2_VITS_NORMAL"
operator.resolution_level = 7
operator.keep_original = False
@@ -776,7 +1118,7 @@ class AddonTest(unittest.TestCase):
)
with patch.object(
self.image_depth,
self.depth_operator,
"prepare_image_input",
return_value=(Path(__file__), False),
):

View File

@@ -23,25 +23,66 @@ def load_image_depth():
)
fake_bpy.types = SimpleNamespace(Menu=object, Operator=object)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
with patch.dict(sys.modules, {"bpy": fake_bpy}):
specification = importlib.util.spec_from_file_location(
"snapform_image_depth_test",
PROJECT_ROOT
/ "src"
/ "snapform"
/ "blender"
/ "depth_plane.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
fake_bpy.context = SimpleNamespace(scene=None)
module_paths = (
(
"snapform.operators.image",
PROJECT_ROOT / "src" / "snapform" / "operators" / "image.py",
),
(
"snapform.operators.material",
PROJECT_ROOT / "src" / "snapform" / "operators" / "material.py",
),
(
"snapform.operators.depth_plane.data",
PROJECT_ROOT / "src" / "snapform" / "operators" / "depth_plane" / "data.py",
),
(
"snapform.operators.depth_plane.build",
PROJECT_ROOT / "src" / "snapform" / "operators" / "depth_plane" / "build.py",
),
)
packages = {
name: ModuleType(name)
for name in (
"snapform",
"snapform.operators",
"snapform.operators.depth_plane",
)
}
for package in packages.values():
package.__path__ = []
loaded = []
with patch.dict(sys.modules, {"bpy": fake_bpy, **packages}):
for module_name, module_path in module_paths:
specification = importlib.util.spec_from_file_location(
module_name,
module_path,
)
module = importlib.util.module_from_spec(specification)
sys.modules[module_name] = module
specification.loader.exec_module(module)
loaded.append(module)
namespace = SimpleNamespace()
for module in loaded:
for name, value in vars(module).items():
if not name.startswith("__"):
setattr(namespace, name, value)
return namespace
class ImageDepthTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.image_depth = load_image_depth()
def test_material_asset_path_survives_package_split(self):
self.assertEqual(
self.image_depth.node_asset_path(),
PROJECT_ROOT / "src" / "snapform" / "assets" / "O_SnapForm.blend",
)
def test_material_color_space_matches_scene_view_transform(self):
mappings = {
"ACES 1.3": "ACES 1.3 sRGB",
@@ -651,7 +692,7 @@ class ImageDepthTest(unittest.TestCase):
"img.001.depth",
)
def test_moge_map_names_use_source_image_name(self):
def test_moge_normal_name_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
data=SimpleNamespace(name="img.png"),
@@ -661,10 +702,6 @@ class ImageDepthTest(unittest.TestCase):
self.image_depth.normal_data_name(source_object),
"img.normal",
)
self.assertEqual(
self.image_depth.mask_data_name(source_object),
"img.mask",
)
def test_copy_depth_image_renames_still_source(self):
class FakeImage:

View File

@@ -61,8 +61,10 @@ class PackTest(unittest.TestCase):
self.assertIn("__init__.py", names)
self.assertIn("blender_manifest.toml", names)
self.assertIn("blender/depth_plane.py", names)
self.assertIn("blender/surface.py", names)
self.assertIn("operators/depth_plane/operator.py", names)
self.assertIn("operators/image.py", names)
self.assertIn("operators/balloon/operator.py", names)
self.assertIn("operators/surface.py", names)
self.assertIn(
"assets/O_SnapForm.blend",
names,