Add SnapForm panels, preferences, properties, and runtime management

- Implemented RuntimeModelsPanel and DebugPanel for UI integration in Blender.
- Created SnapFormPreferences to manage user settings for storage and runtime configuration.
- Defined SnapFormSettings to hold various properties related to input, model selection, and export options.
- Developed runtime management functions for installation, model downloading, and reconstruction commands.
- Added status bar functionality to display job progress and status in the Blender UI.
- Implemented worker script for handling model downloads and depth reconstruction tasks.
- Introduced unit tests for download progress reporting and image depth extraction functionalities.
This commit is contained in:
2026-08-03 10:13:33 +08:00
parent 9586b35440
commit b02372c457
26 changed files with 1849 additions and 181 deletions

View File

@@ -32,12 +32,17 @@ class AddonTest(unittest.TestCase):
PropertyGroup=object,
Scene=type("Scene", (), {}),
STATUSBAR_HT_header=SimpleNamespace(
prepend=lambda draw: cls.status_bar_draws.append(draw),
append=lambda draw: cls.status_bar_draws.append(draw),
remove=lambda draw: cls.status_bar_draws.remove(draw),
),
VIEW3D_MT_object_context_menu=SimpleNamespace(
append=lambda draw: cls.context_menu_draws.append(draw),
remove=lambda draw: cls.context_menu_draws.remove(draw),
),
)
cls.registered = []
cls.status_bar_draws = []
cls.context_menu_draws = []
fake_bpy.utils = SimpleNamespace(
register_class=cls.registered.append,
unregister_class=cls.registered.remove,
@@ -49,7 +54,7 @@ class AddonTest(unittest.TestCase):
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
sys.modules["bpy"] = fake_bpy
sys.path.insert(0, str(SOURCE_ROOT))
cls.addon = importlib.import_module("omoo_da3")
cls.addon = importlib.import_module("snapform")
cls.fake_bpy = fake_bpy
@classmethod
@@ -61,15 +66,16 @@ class AddonTest(unittest.TestCase):
@staticmethod
def remove_modules():
for module_name in tuple(sys.modules):
if module_name == "omoo_da3" or module_name.startswith("omoo_da3."):
if module_name == "snapform" or module_name.startswith("snapform."):
sys.modules.pop(module_name)
def tearDown(self):
self.registered.clear()
self.status_bar_draws.clear()
self.context_menu_draws.clear()
self.addon.operators._active_job = None
if hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings"):
del self.fake_bpy.types.Scene.omoo_da3_settings
if hasattr(self.fake_bpy.types.Scene, "snapform_settings"):
del self.fake_bpy.types.Scene.snapform_settings
def test_uses_extension_manifest_instead_of_legacy_bl_info(self):
self.assertFalse(hasattr(self.addon, "bl_info"))
@@ -79,40 +85,215 @@ class AddonTest(unittest.TestCase):
self.assertEqual(self.registered, list(self.addon.CLASSES))
self.assertTrue(
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
)
self.assertEqual(
self.status_bar_draws,
[self.addon.draw_status_bar],
)
self.assertEqual(
self.context_menu_draws,
[self.addon.draw_image_context_menu],
)
self.addon.unregister()
self.assertEqual(self.registered, [])
self.assertFalse(
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
)
self.assertEqual(self.status_bar_draws, [])
self.assertEqual(self.context_menu_draws, [])
def test_registers_depth_plane_operator_without_cache_rebuild(self):
self.assertIn(
self.addon.ConvertImageToDepthPlane,
self.addon.CLASSES,
)
self.assertIn(self.addon.BuildDepthPlane, self.addon.CLASSES)
self.assertEqual(
self.addon.BuildDepthPlane.bl_options,
{"INTERNAL"},
)
self.assertFalse(hasattr(self.addon, "ImportDepthPlane"))
def test_completed_depth_job_uses_undoable_build_operator(self):
source = inspect.getsource(
self.addon.RunJob._build_depth_plane
)
self.assertIn("bpy.ops.snapform.build_depth_plane(", source)
self.assertIn('if "FINISHED" not in result:', source)
self.assertIn(
'bpy.ops.ed.undo_push(message="Convert to Depth Plane")',
source,
)
self.assertIn('if "FINISHED" not in undo_result:', source)
def test_image_context_menu_only_exposes_conversion(self):
draw_source = inspect.getsource(
self.addon.draw_image_context_menu
)
self.assertIn(
'"snapform.convert_image_to_depth_plane"',
draw_source,
)
self.assertIn('operator_context = "INVOKE_DEFAULT"', draw_source)
self.assertNotIn("import_depth_plane", draw_source)
self.assertNotIn("Rebuild", draw_source)
def test_conversion_dialog_exposes_requested_settings(self):
operator = self.addon.ConvertImageToDepthPlane
self.assertEqual(
operator.__annotations__["snapform_model"]["default"],
"BASE",
)
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
self.assertEqual(
operator.__annotations__["snapform_process_res"]["default"],
1024,
)
self.assertTrue(
operator.__annotations__["snapform_delete_original"]["default"]
)
self.assertEqual(
operator.__annotations__["snapform_subdivision_level"]["default"],
5,
)
self.assertEqual(
set(operator.__annotations__),
{
"snapform_model",
"snapform_use_ray_pose",
"snapform_process_res",
"snapform_delete_original",
"snapform_subdivision_level",
},
)
model_items = operator.__annotations__["snapform_model"]["items"]
self.assertEqual(
[item[0] for item in model_items],
[
"SMALL",
"BASE",
"LARGE_1_1",
"GIANT_1_1",
"NESTED_GIANT_LARGE_1_1",
],
)
self.assertIn("invoke_props_dialog", inspect.getsource(operator.invoke))
def test_image_depth_model_and_ray_pose_are_independent(self):
settings = SimpleNamespace(
input_path="debug-input.png",
export_format="gs_ply",
model="BASE",
gs_model="GIANT_1_1",
)
operator = self.addon.RunJob()
operator.job_type = self.addon.operators.IMAGE_DEPTH_JOB
operator.snapform_source_object_name = "Reference"
operator.snapform_process_res = 336
with TemporaryDirectory() as directory:
input_path = Path(directory) / "input.png"
input_path.touch()
operator.snapform_input_path = str(input_path)
paths = {
"log": Path(directory) / "job.log",
"status": Path(directory) / "job.json",
"result": Path(directory) / "result",
}
for snapform_model, snapform_ray_pose in (
("SMALL", True),
("BASE", False),
("LARGE_1_1", True),
("GIANT_1_1", False),
("NESTED_GIANT_LARGE_1_1", True),
):
with self.subTest(model=snapform_model), patch.object(
self.addon.runtime,
"new_job_paths",
return_value=paths,
), patch.object(
self.addon.runtime,
"runtime_ready",
return_value=True,
), patch.object(
self.addon.runtime,
"model_ready",
return_value=True,
) as model_ready, patch.object(
self.addon.runtime,
"reconstruct_command",
return_value=["worker"],
) as reconstruct:
operator.snapform_model_key = snapform_model
operator.snapform_use_ray_pose = snapform_ray_pose
command, prepared_paths = operator._prepare_command(
None,
settings,
)
self.assertEqual(command, ["worker"])
self.assertEqual(prepared_paths, paths)
model_ready.assert_called_once_with(snapform_model)
self.assertEqual(reconstruct.call_args.args[1], "mini_npz")
self.assertEqual(
reconstruct.call_args.kwargs["input_path"],
input_path,
)
self.assertEqual(
reconstruct.call_args.kwargs["model_key"],
snapform_model,
)
self.assertEqual(
reconstruct.call_args.kwargs["process_res"],
336,
)
self.assertEqual(
reconstruct.call_args.kwargs["process_res_method"],
"lower_bound_resize",
)
self.assertEqual(
reconstruct.call_args.kwargs["use_ray_pose"],
snapform_ray_pose,
)
def test_preferences_use_package_identifier(self):
self.assertEqual(
self.addon.OMOO_DA3_preferences.bl_idname,
"omoo_da3",
self.addon.SnapFormPreferences.bl_idname,
"snapform",
)
def test_default_storage_is_shared_in_user_home(self):
runtime = importlib.import_module("omoo_da3.runtime")
runtime = importlib.import_module("snapform.runtime")
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".omoo_da3")
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".snapform")
self.assertEqual(
self.addon.OMOO_DA3_preferences.__annotations__["storage_root"][
self.addon.SnapFormPreferences.__annotations__["storage_root"][
"default"
],
str(runtime.DEFAULT_STORAGE_ROOT),
)
def test_process_res_method_defaults_to_lower_bound_resize(self):
process_res = self.addon.SnapFormSettings.__annotations__[
"process_res"
]
process_res_method = (
self.addon.SnapFormSettings.__annotations__[
"process_res_method"
]
)
self.assertEqual(process_res["default"], 1024)
self.assertEqual(process_res_method["default"], "lower_bound_resize")
def test_reconstruct_command_preserves_da3_parameter_names(self):
runtime = importlib.import_module("omoo_da3.runtime")
runtime = importlib.import_module("snapform.runtime")
settings = SimpleNamespace(
input_path="input.png",
model="GIANT_1_1",
@@ -159,8 +340,56 @@ class AddonTest(unittest.TestCase):
with self.subTest(parameter=parameter):
self.assertIn(parameter, command)
def test_reconstruct_command_accepts_image_depth_overrides(self):
runtime = importlib.import_module("snapform.runtime")
settings = SimpleNamespace(
input_path="input.png",
model="BASE",
gs_model="GIANT_1_1",
process_res=504,
process_res_method="upper_bound_resize",
conf_thresh_percentile=40.0,
num_max_points=1_000_000,
show_cameras=True,
gs_views_interval=1,
video_frames=16,
device="AUTO",
use_ray_pose=False,
ref_view_strategy="saddle_balanced",
)
with patch.object(
runtime,
"runtime_python",
return_value=Path("python"),
), patch.object(
runtime,
"model_directory",
return_value=Path("large-model"),
) as snapform_model_directory:
snapform_command = runtime.reconstruct_command(
settings,
"mini_npz",
Path("status.json"),
Path("result"),
model_key="BASE",
process_res=252,
process_res_method="lower_bound_resize",
use_ray_pose=False,
)
snapform_model_directory.assert_called_once_with("BASE")
snapform_process_res_index = snapform_command.index("--process-res")
self.assertEqual(snapform_command[snapform_process_res_index + 1], "252")
snapform_process_res_method_index = snapform_command.index("--process-res-method")
self.assertEqual(
snapform_command[snapform_process_res_method_index + 1],
"lower_bound_resize",
)
snapform_ray_pose_index = snapform_command.index("--use-ray-pose")
self.assertEqual(snapform_command[snapform_ray_pose_index + 1], "0")
def test_export_format_property_includes_all_da3_formats(self):
export_format = self.addon.OMOO_DA3_PG_settings.__annotations__[
export_format = self.addon.SnapFormSettings.__annotations__[
"export_format"
]
identifiers = [item[0] for item in export_format["items"]]
@@ -172,7 +401,7 @@ class AddonTest(unittest.TestCase):
self.assertEqual(export_format["default"], "glb")
def test_gs_model_property_only_includes_gaussian_models(self):
gs_model = self.addon.OMOO_DA3_PG_settings.__annotations__[
gs_model = self.addon.SnapFormSettings.__annotations__[
"gs_model"
]
identifiers = [item[0] for item in gs_model["items"]]
@@ -185,12 +414,12 @@ class AddonTest(unittest.TestCase):
def test_debug_panel_is_closed_by_default(self):
self.assertIn(
"DEFAULT_CLOSED",
self.addon.OMOO_DA3_PT_debug.bl_options,
self.addon.DebugPanel.bl_options,
)
def test_debug_panel_uses_one_export_button_and_dynamic_settings(self):
draw_source = inspect.getsource(
self.addon.OMOO_DA3_PT_debug.draw
self.addon.DebugPanel.draw
)
self.assertEqual(draw_source.count("layout.operator("), 1)
@@ -207,10 +436,10 @@ class AddonTest(unittest.TestCase):
def test_runtime_models_panel_assigns_each_download_target(self):
draw_source = inspect.getsource(
self.addon.OMOO_DA3_PT_runtime_models.draw
self.addon.RuntimeModelsPanel.draw
)
model_source = inspect.getsource(
self.addon.OMOO_DA3_PT_runtime_models._draw_model
self.addon.RuntimeModelsPanel._draw_model
)
self.assertIn("for model in MODELS.values()", draw_source)
@@ -218,7 +447,7 @@ class AddonTest(unittest.TestCase):
self.assertNotIn("Isolated Runtime ready", draw_source)
self.assertNotIn("License:", model_source)
self.assertEqual(
self.addon.OMOO_DA3_PT_runtime_models.bl_label,
self.addon.RuntimeModelsPanel.bl_label,
"Setup",
)
@@ -257,7 +486,7 @@ class AddonTest(unittest.TestCase):
self.addon.operators._active_job = SimpleNamespace()
context = SimpleNamespace(
scene=SimpleNamespace(
omoo_da3_settings=SimpleNamespace(
snapform_settings=SimpleNamespace(
status="Processing",
progress=0.25,
),
@@ -271,23 +500,92 @@ class AddonTest(unittest.TestCase):
self.assertEqual(
progress_calls,
[{"factor": 0.25, "type": "BAR", "text": "25%"}],
[{"factor": 0.25, "type": "BAR", "text": "Processing"}],
)
self.assertEqual(
operator_calls,
[("omoo_da3.cancel_job", {"text": "", "icon": "X"})],
[("snapform.cancel_job", {"text": "", "icon": "X"})],
)
def test_image_depth_status_uses_same_progress_layout(self):
progress_calls = []
operator_calls = []
class Layout:
def row(self, align=False):
return self
def label(self, **_options):
return None
def progress(self, **options):
progress_calls.append(options)
def operator(self, operator, **options):
operator_calls.append((operator, options))
self.addon.operators._active_job = SimpleNamespace(
kind=self.addon.operators.IMAGE_DEPTH_JOB,
)
context = SimpleNamespace(
scene=SimpleNamespace(
snapform_settings=SimpleNamespace(
status="Estimating depth",
progress=0.25,
),
),
)
self.addon.draw_status_bar(
SimpleNamespace(layout=Layout()),
context,
)
self.assertEqual(
progress_calls,
[
{
"factor": 0.25,
"type": "BAR",
"text": "Estimating depth",
}
],
)
self.assertEqual(
operator_calls,
[("snapform.cancel_job", {"text": "", "icon": "X"})],
)
def test_progress_is_appended_before_cancel_button(self):
register_source = inspect.getsource(self.addon.register)
draw_source = inspect.getsource(self.addon.draw_status_bar)
self.assertIn("STATUSBAR_HT_header.append", register_source)
self.assertNotIn("STATUSBAR_HT_header.prepend", register_source)
self.assertLess(
draw_source.index("progress_row.progress("),
draw_source.index('"snapform.cancel_job"'),
)
self.assertIn("progress_row.ui_units_x = 12.0", draw_source)
self.assertNotIn('label(text="SnapForm"', draw_source)
def test_ui_redraw_targets_status_bar_and_view_3d_areas(self):
redrawn = []
status_regions = (
SimpleNamespace(
tag_redraw=lambda: redrawn.append("status-region"),
),
)
areas = (
SimpleNamespace(
type="STATUSBAR",
tag_redraw=lambda: redrawn.append("status"),
regions=status_regions,
),
SimpleNamespace(
type="VIEW_3D",
tag_redraw=lambda: redrawn.append("view"),
regions=(),
),
)
context = SimpleNamespace(
@@ -298,25 +596,46 @@ class AddonTest(unittest.TestCase):
),
)
self.addon.status_bar.redraw_ui(context)
self.addon.status_bar.redraw_ui(context)
self.assertEqual(redrawn, ["status", "view"])
def test_forced_ui_redraw_swaps_window_immediately(self):
calls = []
self.fake_bpy.ops = SimpleNamespace(
wm=SimpleNamespace(
redraw_timer=lambda **options: calls.append(options),
),
self.assertEqual(
redrawn,
[
"status",
"status-region",
"view",
"status",
"status-region",
"view",
],
)
def test_force_flag_does_not_trigger_window_draw_and_swap(self):
redrawn = []
context = SimpleNamespace(
window=SimpleNamespace(),
window_manager=SimpleNamespace(windows=()),
window_manager=SimpleNamespace(
windows=(
SimpleNamespace(
screen=SimpleNamespace(
areas=(
SimpleNamespace(
type="STATUSBAR",
tag_redraw=lambda: redrawn.append(
"status"
),
regions=(),
),
),
),
),
),
),
)
self.addon.status_bar.redraw_ui(context, force=True)
self.assertEqual(
calls,
[{"type": "DRAW_WIN_SWAP", "iterations": 1}],
)
self.assertEqual(redrawn, ["status"])
source = inspect.getsource(self.addon.status_bar)
self.assertNotIn("redraw_timer", source)
self.assertNotIn("DRAW_WIN_SWAP", source)