Refactor and enhance SnapForm functionality

- Introduced MODEL_NUMBERS for better model indexing.
- Improved enum_items function to cache results and handle download status.
- Updated ActiveJob class to manage temporary input paths and added displacement_via property.
- Changed IMAGE_DEPTH_PROCESS_RES_METHOD from "lower_bound_resize" to "upper_bound_resize".
- Enhanced RunJob operator to include new properties and improved error messages.
- Modified panel layout for better user experience and added runtime installation checks.
- Updated SnapFormSettings to reflect new model properties and defaults.
- Added temporary input path management in runtime.
- Refined worker functions to improve error handling and messaging.
- Expanded tests to cover new functionality and ensure backward compatibility.
- Fixed packaging script to align with versioning conventions.
This commit is contained in:
2026-08-03 13:47:15 +08:00
parent b02372c457
commit a503786c62
14 changed files with 827 additions and 174 deletions

View File

@@ -146,14 +146,14 @@ class AddonTest(unittest.TestCase):
def test_conversion_dialog_exposes_requested_settings(self):
operator = self.addon.ConvertImageToDepthPlane
self.assertEqual(
operator.__annotations__["snapform_model"]["default"],
"BASE",
self.assertNotIn(
"default",
operator.__annotations__["snapform_model"],
)
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
self.assertEqual(
operator.__annotations__["snapform_process_res"]["default"],
1024,
operator.__annotations__["snapform_process_res_factor"]["default"],
0.5,
)
self.assertTrue(
operator.__annotations__["snapform_delete_original"]["default"]
@@ -165,14 +165,62 @@ class AddonTest(unittest.TestCase):
self.assertEqual(
set(operator.__annotations__),
{
"snapform_depth_source",
"snapform_displacement_via",
"snapform_model",
"snapform_use_ray_pose",
"snapform_process_res",
"snapform_process_res_factor",
"snapform_delete_original",
"snapform_subdivision_level",
},
)
depth_sources = operator.__annotations__["snapform_depth_source"]
self.assertEqual(depth_sources["default"], "ORIGINAL")
self.assertEqual(
[item[0] for item in depth_sources["items"]],
["ESTIMATE", "ORIGINAL"],
)
self.assertEqual(depth_sources["items"][0][1], "AI Generated")
self.assertEqual(depth_sources["name"], "Depth from")
displacement_via = operator.__annotations__[
"snapform_displacement_via"
]
self.assertEqual(displacement_via["default"], "MODIFIER")
self.assertEqual(displacement_via["name"], "Displace via")
self.assertEqual(
[item[0] for item in displacement_via["items"]],
["MODIFIER", "MATERIAL"],
)
draw_source = inspect.getsource(operator.draw)
self.assertIn(
'if self.snapform_depth_source == "ESTIMATE":',
draw_source,
)
self.assertLess(
draw_source.index(
'"snapform_process_res_factor",'
),
draw_source.index(
'layout.prop(self, "snapform_subdivision_level")'
),
)
self.assertIn(
'text=f"Resolution Scale ({process_res} px)"',
draw_source,
)
self.assertIn("estimate_box = layout.box()", draw_source)
self.assertIn(
'estimate_box.prop(self, "snapform_model")',
draw_source,
)
self.assertIn(
'estimate_box.prop(self, "snapform_use_ray_pose")',
draw_source,
)
self.assertNotIn("layout.separator", draw_source)
self.assertNotIn("layout.label", draw_source)
model_items = operator.__annotations__["snapform_model"]["items"]
model_items = model_items(None, None)
self.assertEqual(
[item[0] for item in model_items],
[
@@ -184,6 +232,127 @@ class AddonTest(unittest.TestCase):
],
)
self.assertIn("invoke_props_dialog", inspect.getsource(operator.invoke))
self.assertNotIn(
'"DA3"',
inspect.getsource(operator),
)
def test_depth_conversion_progress_is_provider_neutral(self):
starting_source = inspect.getsource(
self.addon.RunJob._starting_message
)
finish_source = inspect.getsource(self.addon.RunJob._finish)
self.assertIn('return "Starting depth estimation"', starting_source)
self.assertIn('"Depth plane is ready"', finish_source)
self.assertNotIn("DA3 image depth", starting_source)
self.assertNotIn("DA3 depth plane", finish_source)
def test_model_options_mark_only_models_that_are_not_downloaded(self):
items_callback = self.addon.SnapFormSettings.__annotations__["model"][
"items"
]
with patch.object(
self.addon.runtime,
"model_ready",
side_effect=lambda key: key == "BASE",
):
items = items_callback(None, None)
labels = {item[0]: item[1] for item in items}
self.assertEqual(labels["BASE"], "DA3 Base")
self.assertEqual(labels["SMALL"], "DA3 Small (Missing)")
def test_packed_image_is_exported_without_unpacking(self):
operator = self.addon.ConvertImageToDepthPlane()
operator.snapform_depth_source = "ESTIMATE"
operator.snapform_model = "BASE"
operator.snapform_use_ray_pose = True
operator.snapform_process_res_factor = 0.5
operator.snapform_displacement_via = "MATERIAL"
operator.snapform_subdivision_level = 5
operator.snapform_delete_original = True
image = SimpleNamespace(
filepath="",
packed_file=object(),
size=(1920, 1080),
)
context = SimpleNamespace(
object=SimpleNamespace(name="Reference", data=image),
scene=object(),
)
calls = []
self.fake_bpy.ops = SimpleNamespace(
snapform=SimpleNamespace(
run_job=lambda **options: calls.append(options)
or {"RUNNING_MODAL"},
),
)
with TemporaryDirectory() as directory:
packed_path = Path(directory) / "packed.png"
packed_path.touch()
with patch.object(
self.addon.image_depth,
"save_packed_image",
return_value=packed_path,
) as save_packed_image:
result = operator.execute(context)
self.assertEqual(result, {"FINISHED"})
save_packed_image.assert_called_once_with(image, context.scene)
self.assertEqual(calls[0]["snapform_input_path"], str(packed_path))
self.assertEqual(calls[0]["snapform_process_res"], 960)
self.assertEqual(
calls[0]["snapform_displacement_via"],
"MATERIAL",
)
self.assertTrue(calls[0]["snapform_delete_input"])
def test_packed_image_export_writes_a_png_copy(self):
class PackedImage:
def save_render(self, filepath, scene):
self.filepath = filepath
self.scene = scene
self.file_format = scene.render.image_settings.file_format
Path(filepath).write_bytes(b"packed image")
image = PackedImage()
image_settings = SimpleNamespace(file_format="OPEN_EXR")
scene = SimpleNamespace(
render=SimpleNamespace(image_settings=image_settings),
)
with TemporaryDirectory() as directory:
output_path = Path(directory) / "input.png"
with patch.object(
self.addon.runtime,
"temporary_input_path",
return_value=output_path,
):
result = self.addon.image_depth.save_packed_image(image, scene)
self.assertEqual(result, output_path)
self.assertEqual(output_path.read_bytes(), b"packed image")
self.assertEqual(image.filepath, str(output_path))
self.assertIs(image.scene, scene)
self.assertEqual(image.file_format, "PNG")
self.assertEqual(image_settings.file_format, "OPEN_EXR")
def test_temporary_packed_input_is_removed_when_job_closes(self):
closed = []
with TemporaryDirectory() as directory:
input_path = Path(directory) / "packed.png"
input_path.touch()
self.addon.operators._active_job = SimpleNamespace(
log_file=SimpleNamespace(close=lambda: closed.append(True)),
temporary_input_path=input_path,
)
self.addon.operators.close_active_job()
self.assertFalse(input_path.exists())
self.assertEqual(closed, [True])
self.assertIsNone(self.addon.operators.active_job())
def test_image_depth_model_and_ray_pose_are_independent(self):
settings = SimpleNamespace(
@@ -255,7 +424,7 @@ class AddonTest(unittest.TestCase):
)
self.assertEqual(
reconstruct.call_args.kwargs["process_res_method"],
"lower_bound_resize",
"upper_bound_resize",
)
self.assertEqual(
reconstruct.call_args.kwargs["use_ray_pose"],
@@ -279,7 +448,7 @@ class AddonTest(unittest.TestCase):
str(runtime.DEFAULT_STORAGE_ROOT),
)
def test_process_res_method_defaults_to_lower_bound_resize(self):
def test_process_res_method_defaults_to_upper_bound_resize(self):
process_res = self.addon.SnapFormSettings.__annotations__[
"process_res"
]
@@ -290,7 +459,7 @@ class AddonTest(unittest.TestCase):
)
self.assertEqual(process_res["default"], 1024)
self.assertEqual(process_res_method["default"], "lower_bound_resize")
self.assertEqual(process_res_method["default"], "upper_bound_resize")
def test_reconstruct_command_preserves_da3_parameter_names(self):
runtime = importlib.import_module("snapform.runtime")
@@ -404,7 +573,7 @@ class AddonTest(unittest.TestCase):
gs_model = self.addon.SnapFormSettings.__annotations__[
"gs_model"
]
identifiers = [item[0] for item in gs_model["items"]]
identifiers = [item[0] for item in gs_model["items"](None, None)]
self.assertEqual(
identifiers,
@@ -422,13 +591,13 @@ class AddonTest(unittest.TestCase):
self.addon.DebugPanel.draw
)
self.assertEqual(draw_source.count("layout.operator("), 1)
self.assertEqual(draw_source.count("export_row.operator("), 1)
self.assertIn('settings.export_format == "glb"', draw_source)
self.assertIn('settings.export_format == "gs_ply"', draw_source)
self.assertIn('"gs_model" if settings.export_format', draw_source)
self.assertIn("model_row.prop(settings, model_property)", draw_source)
self.assertIn('model_row.label(text="", icon="CHECKMARK")', draw_source)
self.assertIn('model_row.label(text="", icon="ERROR")', draw_source)
self.assertIn("layout.prop(settings, model_property)", draw_source)
self.assertNotIn("model_row", draw_source)
self.assertIn("export_row.enabled = active_job() is None", draw_source)
self.assertNotIn('text="Ready"', draw_source)
self.assertNotIn('text="Missing"', draw_source)
self.assertNotIn("License:", draw_source)
@@ -444,6 +613,11 @@ class AddonTest(unittest.TestCase):
self.assertIn("for model in MODELS.values()", draw_source)
self.assertIn("operator.model_key = model.key", model_source)
self.assertIn(
"download_row.enabled = runtime_ready() and not running",
model_source,
)
self.assertNotIn('label(text="Not downloaded"', model_source)
self.assertNotIn("Isolated Runtime ready", draw_source)
self.assertNotIn("License:", model_source)
self.assertEqual(
@@ -451,6 +625,14 @@ class AddonTest(unittest.TestCase):
"Setup",
)
def test_preferences_install_button_is_disabled_while_busy(self):
draw_source = inspect.getsource(self.addon.SnapFormPreferences.draw)
self.assertIn(
"install_row.enabled = active_job() is None",
draw_source,
)
def test_completed_export_opens_job_directory(self):
calls = []
self.fake_bpy.ops = SimpleNamespace(

View File

@@ -234,7 +234,7 @@ class ImageDepthTest(unittest.TestCase):
self.assertAlmostEqual(value, expected)
def test_source_empty_is_only_removed_when_requested(self):
source = inspect.getsource(self.image_depth.build_depth_plane)
source = inspect.getsource(self.image_depth.finish_depth_plane)
self.assertIn("source_name = source_object.name", source)
self.assertIn("if delete_source:", source)
@@ -243,27 +243,88 @@ class ImageDepthTest(unittest.TestCase):
source,
)
self.assertNotIn("source_object.name =", source)
self.assertIn(
"bpy.data.objects.new(\n source_name,",
source,
)
self.assertIn(
"bpy.data.objects.remove(source_object, do_unlink=True)\n"
" plane_object.name = source_name",
source,
)
def test_original_depth_mode_uses_uv_and_zero_midlevel(self):
source = inspect.getsource(self.image_depth.build_image_depth_plane)
self.assertIn("texture.image = source_object.data", source)
self.assertIn('modifiers.new("Displace", "DISPLACE")', source)
self.assertIn("displace.texture = texture", source)
self.assertIn('displace.texture_coords = "UV"', source)
self.assertNotIn("displace.direction", source)
self.assertNotIn("displace.strength", source)
self.assertIn(
"displace.mid_level = ORIGINAL_DISPLACEMENT_MIDLEVEL",
source,
)
self.assertIn("plane_object.matrix_world =", source)
def test_process_resolution_scales_the_image_long_edge(self):
image = SimpleNamespace(size=(1080, 1920))
self.assertEqual(
self.image_depth.scaled_process_resolution(image, 0.5),
960,
)
def test_mesh_uses_source_object_name(self):
source = inspect.getsource(self.image_depth.create_plane_mesh)
self.assertIn("bpy.data.meshes.new(source_object.name)", source)
def test_depth_data_uses_source_image_name(self):
source_object = SimpleNamespace(
name="obj_img",
data=SimpleNamespace(name="img.png"),
)
self.assertEqual(
self.image_depth.depth_data_name(source_object),
"img.depth",
)
source_object.data.name = "img.png.001"
self.assertEqual(
self.image_depth.depth_data_name(source_object),
"img.001.depth",
)
estimate_source = inspect.getsource(
self.image_depth.build_depth_plane
)
original_source = inspect.getsource(
self.image_depth.build_image_depth_plane
)
self.assertIn("depth_name = depth_data_name(source_object)", estimate_source)
self.assertIn("depth_data_name(source_object)", original_source)
def test_material_uses_source_object_name(self):
da3_source = inspect.getsource(self.image_depth.build_depth_plane)
image_source = inspect.getsource(
self.image_depth.build_image_depth_plane
)
self.assertIn(
"create_material(\n source_name,",
da3_source,
)
self.assertIn("create_material(\n source_name,", image_source)
self.assertNotIn("DA3DepthMaterial", da3_source)
def test_displace_texture_uses_extend(self):
source = inspect.getsource(
self.image_depth.build_depth_plane
)
self.assertIn('depth_name = f"{source_name}.depth"', source)
self.assertIn("depth_name = depth_data_name(source_object)", source)
self.assertIn("create_depth_image(\n depth_name,", source)
self.assertIn("bpy.data.textures.new(\n depth_name,", source)
self.assertIn("bpy.data.textures.new(\n depth_name,", source)
self.assertIn('texture.extension = "EXTEND"', source)
def test_original_image_drives_bump_instead_of_displacement(self):
def test_material_supports_bump_and_optional_displacement(self):
source = inspect.getsource(self.image_depth.create_material)
self.assertIn('nodes.new("ShaderNodeBump")', source)
@@ -297,16 +358,94 @@ class ImageDepthTest(unittest.TestCase):
'curve_points[-1].location = (1.0, 0.1)',
source,
)
self.assertIn(
'shader.inputs["IOR"].default_value = 1.2',
source,
)
self.assertNotIn('shader.inputs["IOR"]', source)
self.assertIn(
'roughness_curve.location = (-300.0, 320.0)',
source,
)
self.assertIn('bump.location = (-300.0, -80.0)', source)
self.assertNotIn("ShaderNodeDisplacement", source)
self.assertIn('nodes.new("ShaderNodeDisplacement")', source)
self.assertIn(
'depth_texture.outputs["Color"],\n'
' displacement.inputs["Height"]',
source,
)
self.assertIn(
'displacement.outputs["Displacement"],\n'
' output.inputs["Displacement"]',
source,
)
self.assertIn(
'displacement.inputs["Midlevel"].default_value',
source,
)
self.assertIn(
'displacement.inputs["Scale"].default_value',
source,
)
self.assertIn("set_displacement_only(material)", source)
def test_displacement_values_follow_depth_source(self):
estimate_source = inspect.getsource(
self.image_depth.build_depth_plane
)
original_source = inspect.getsource(
self.image_depth.build_image_depth_plane
)
self.assertIn(
"displacement_midlevel=DISPLACEMENT_MIDLEVEL",
estimate_source,
)
self.assertIn(
"displacement_scale=DISPLACEMENT_STRENGTH",
estimate_source,
)
self.assertIn(
"displacement_midlevel=ORIGINAL_DISPLACEMENT_MIDLEVEL",
original_source,
)
self.assertIn(
"displacement_scale=ORIGINAL_DISPLACEMENT_STRENGTH",
original_source,
)
self.assertEqual(self.image_depth.DISPLACEMENT_MIDLEVEL, 1.0)
self.assertEqual(self.image_depth.DISPLACEMENT_STRENGTH, -1.0)
self.assertEqual(
self.image_depth.ORIGINAL_DISPLACEMENT_MIDLEVEL,
0.0,
)
self.assertEqual(
self.image_depth.ORIGINAL_DISPLACEMENT_STRENGTH,
1.0,
)
def test_material_displacement_mode_skips_displace_modifier(self):
estimate_source = inspect.getsource(
self.image_depth.build_depth_plane
)
original_source = inspect.getsource(
self.image_depth.build_image_depth_plane
)
for source in (estimate_source, original_source):
self.assertIn('if displacement_via == "MODIFIER":', source)
self.assertIn(
'if displacement_via == "MATERIAL" else None',
source,
)
def test_surface_displacement_is_set_to_displacement_only(self):
class Material:
surface_displacement_method = "BUMP"
material = Material()
self.image_depth.set_displacement_only(material)
self.assertEqual(
material.surface_displacement_method,
"DISPLACEMENT",
)
def test_plane_transform_scales_depth_axis(self):
source = inspect.getsource(self.image_depth.apply_plane_transform)

View File

@@ -29,7 +29,7 @@ class PackTest(unittest.TestCase):
finally:
pack.DIST_DIR = original_dist
self.assertEqual(output_path.name, "SnapForm.v0.9.27.b45.zip")
self.assertEqual(output_path.name, "SnapForm.v0.9.27.zip")
with ZipFile(output_path) as archive:
names = set(archive.namelist())

View File

@@ -43,7 +43,7 @@ class WorkerTest(unittest.TestCase):
source = inspect.getsource(self.worker.reconstruct)
self.assertLess(
source.index('"Starting DA3 runtime"'),
source.index('"Starting AI runtime"'),
source.index("import torch"),
)