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

384
src/snapform/operators.py Normal file
View File

@@ -0,0 +1,384 @@
import subprocess
from dataclasses import dataclass
from pathlib import Path
import bpy
from . import runtime
from .models import get_model, selected_model_key
RECONSTRUCTION_JOB = "RECONSTRUCT"
IMAGE_DEPTH_JOB = "IMAGE_DEPTH"
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
IMAGE_DEPTH_PROCESS_RES_METHOD = "lower_bound_resize"
def open_job_directory(result_directory):
result_directory = Path(result_directory)
if not result_directory.is_dir():
raise RuntimeError(f"Job directory does not exist: {result_directory}")
result = bpy.ops.wm.path_open(filepath=str(result_directory))
if "FINISHED" not in result:
raise RuntimeError(f"Unable to open job directory: {result_directory}")
@dataclass
class ActiveJob:
kind: str
process: subprocess.Popen
log_file: object
log_path: Path
status_path: Path
result_directory: Path
export_format: str = ""
source_object_name: str = ""
process_res: int = IMAGE_DEPTH_DEFAULT_PROCESS_RES
subdivision_level: int = 5
delete_source: bool = True
cancelled: bool = False
_active_job = None
def active_job():
return _active_job
def cancel_active_job():
global _active_job
if _active_job is None:
return
_active_job.cancelled = True
if _active_job.process.poll() is None:
_active_job.process.terminate()
def close_active_job():
global _active_job
if _active_job is None:
return
_active_job.log_file.close()
_active_job = None
class RunJob(bpy.types.Operator):
bl_idname = "snapform.run_job"
bl_label = "Run SnapForm Job"
bl_options = {"INTERNAL"}
job_type: bpy.props.EnumProperty(
items=(
("INSTALL_RUNTIME", "Install Runtime", ""),
("DOWNLOAD_MODEL", "Download Model", ""),
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
),
options={"HIDDEN", "SKIP_SAVE"},
)
model_key: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_input_path: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_source_object_name: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_model_key: bpy.props.StringProperty(
default="BASE",
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_use_ray_pose: bpy.props.BoolProperty(
default=True,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_process_res: bpy.props.IntProperty(
default=IMAGE_DEPTH_DEFAULT_PROCESS_RES,
min=14,
max=2016,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_delete_source: bpy.props.BoolProperty(
default=True,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_subdivision_level: bpy.props.IntProperty(
default=5,
min=0,
max=10,
options={"HIDDEN", "SKIP_SAVE"},
)
def execute(self, context):
global _active_job
if active_job() is not None:
self.report({"WARNING"}, "Another SnapForm task is running")
return {"CANCELLED"}
settings = context.scene.snapform_settings
try:
command, paths = self._prepare_command(context, settings)
except (OSError, RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
log_file = paths["log"].open("w", encoding="utf-8")
try:
process = subprocess.Popen(
command,
stdout=log_file,
stderr=subprocess.STDOUT,
cwd=runtime.storage_root(),
creationflags=runtime.creation_flags(),
)
except OSError as error:
log_file.close()
self.report({"ERROR"}, f"Unable to start DA3 task: {error}")
return {"CANCELLED"}
_active_job = ActiveJob(
kind=self.job_type,
process=process,
log_file=log_file,
log_path=paths["log"],
status_path=paths["status"],
result_directory=paths["result"],
export_format=(
settings.export_format if self.job_type == RECONSTRUCTION_JOB
else "mini_npz" if self.job_type == IMAGE_DEPTH_JOB
else ""
),
source_object_name=self.snapform_source_object_name,
process_res=self.snapform_process_res,
subdivision_level=self.snapform_subdivision_level,
delete_source=self.snapform_delete_source,
)
settings.status = self._starting_message(settings)
settings.progress = 0.0
self._timer = context.window_manager.event_timer_add(
0.25,
window=context.window,
)
context.window_manager.modal_handler_add(self)
self._redraw_ui(context, force=True)
return {"RUNNING_MODAL"}
def _prepare_command(self, _context, settings):
paths = runtime.new_job_paths(self.job_type)
if self.job_type == "INSTALL_RUNTIME":
self._require_online_access()
return runtime.install_runtime_command(paths["status"]), paths
if not runtime.runtime_ready():
raise RuntimeError("Install the SnapForm Runtime first")
if self.job_type == "DOWNLOAD_MODEL":
self._require_online_access()
model = get_model(self.model_key)
return (
runtime.download_model_command(model.key, paths["status"]),
paths,
)
configured_input = (
self.snapform_input_path
if self.job_type == IMAGE_DEPTH_JOB
else settings.input_path
)
input_path = Path(bpy.path.abspath(configured_input))
if not input_path.is_file():
raise ValueError("Select a valid image or video file")
if self.job_type not in {RECONSTRUCTION_JOB, IMAGE_DEPTH_JOB}:
raise RuntimeError(f"Unsupported task type: {self.job_type}")
export_format = (
"mini_npz"
if self.job_type == IMAGE_DEPTH_JOB
else settings.export_format
)
use_ray_pose = None
process_res = None
model_key = selected_model_key(settings, export_format)
if self.job_type == IMAGE_DEPTH_JOB:
model_key = self.snapform_model_key
use_ray_pose = self.snapform_use_ray_pose
process_res = self.snapform_process_res
model = get_model(model_key)
if not runtime.model_ready(model_key):
raise RuntimeError(f"Download {model.label} first")
if export_format == "gs_ply" and not model.supports_gs:
raise RuntimeError(
"PLY export requires a DA3 Giant or Nested Giant model"
)
return (
runtime.reconstruct_command(
settings,
export_format,
paths["status"],
paths["result"],
input_path=input_path,
model_key=model_key,
process_res=process_res,
process_res_method=(
IMAGE_DEPTH_PROCESS_RES_METHOD
if self.job_type == IMAGE_DEPTH_JOB
else None
),
use_ray_pose=use_ray_pose,
),
paths,
)
def _require_online_access(self):
if not bpy.app.online_access:
raise RuntimeError(
"Online Access is disabled in Blender Preferences"
)
def _starting_message(self, settings):
labels = {
"INSTALL_RUNTIME": "Starting Runtime installation",
"DOWNLOAD_MODEL": "Starting model download",
}
if self.job_type == RECONSTRUCTION_JOB:
return f"Starting DA3 {settings.export_format} export"
if self.job_type == IMAGE_DEPTH_JOB:
return "Starting DA3 image depth conversion"
if self.job_type == "DOWNLOAD_MODEL":
return f"Starting {get_model(self.model_key).label} download"
return labels[self.job_type]
def modal(self, context, event):
if event.type == "ESC":
cancel_active_job()
if event.type != "TIMER":
return {"PASS_THROUGH"}
job = active_job()
if job is None:
self._remove_timer(context)
return {"CANCELLED"}
self._update_status(context, job)
return_code = job.process.poll()
if return_code is None:
return {"RUNNING_MODAL"}
return self._finish(context, job, return_code)
def _update_status(self, context, job):
status = runtime.read_status(job.status_path)
if status is None:
return
settings = context.scene.snapform_settings
settings.progress = float(status.get("progress", settings.progress))
settings.status = status.get("message", settings.status)
self._redraw_ui(context)
def _finish(self, context, job, return_code):
settings = context.scene.snapform_settings
self._remove_timer(context)
job.log_file.close()
if job.cancelled:
settings.status = "Task cancelled"
settings.progress = 0.0
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
status = runtime.read_status(job.status_path) or {}
error = status.get("error")
if return_code != 0 or error:
settings.status = "Task failed"
settings.progress = 0.0
self.report(
{"ERROR"},
f"{error or 'DA3 subprocess failed'}; log: {job.log_path}",
)
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
if job.kind == RECONSTRUCTION_JOB:
try:
open_job_directory(job.result_directory)
except Exception as open_error:
self.report({"WARNING"}, str(open_error))
self.report(
{"INFO"},
f"Exported {job.export_format} to {job.result_directory}",
)
if job.kind == IMAGE_DEPTH_JOB:
try:
settings.status = "Building depth plane"
settings.progress = 0.96
self._redraw_ui(context, force=True)
self._build_depth_plane(context, job)
except (OSError, RuntimeError, ValueError) as import_error:
settings.status = "Depth import failed"
settings.progress = 0.0
self.report({"ERROR"}, str(import_error))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
status["message"] = "DA3 depth plane is ready"
settings.status = status.get("message", "Task complete")
settings.progress = 1.0
close_active_job()
self._redraw_ui(context, force=True)
return {"FINISHED"}
def _build_depth_plane(self, context, job):
npz_path = (
job.result_directory
/ "exports"
/ "mini_npz"
/ "results.npz"
)
result = bpy.ops.snapform.build_depth_plane(
snapform_source_object_name=job.source_object_name,
snapform_npz_path=str(npz_path),
snapform_subdivision_level=job.subdivision_level,
snapform_delete_source=job.delete_source,
)
if "FINISHED" not in result:
raise RuntimeError("Unable to build the depth plane")
undo_result = bpy.ops.ed.undo_push(message="Convert to Depth Plane")
if "FINISHED" not in undo_result:
raise RuntimeError("Unable to create the depth plane Undo step")
def _remove_timer(self, context):
timer = getattr(self, "_timer", None)
if timer is None:
return
context.window_manager.event_timer_remove(timer)
self._timer = None
def _redraw_ui(self, context, force=False):
from .status_bar import redraw_ui
redraw_ui(context, force=force)
def cancel(self, context):
cancel_active_job()
self._remove_timer(context)
self._redraw_ui(context, force=True)
class CancelJob(bpy.types.Operator):
bl_idname = "snapform.cancel_job"
bl_label = "Cancel SnapForm Task"
@classmethod
def poll(cls, _context):
return active_job() is not None
def execute(self, _context):
cancel_active_job()
return {"FINISHED"}