chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
+160 -30
View File
@@ -10,7 +10,9 @@ import logging
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from app.core.render_paths import ensure_group_writable_dir
if TYPE_CHECKING:
from app.models.cad_file import CadFile
@@ -18,6 +20,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class MissingCadResourceError(FileNotFoundError):
"""Terminal CAD resource error that should not be retried by Celery tasks."""
def build_part_colors(
cad_parsed_objects: list[str],
cad_part_materials: list[dict],
@@ -1023,8 +1029,12 @@ def _get_all_settings() -> dict[str, str]:
"blender_eevee_samples": "64",
"thumbnail_format": "jpg",
"blender_smooth_angle": "30",
"cycles_device": "auto",
"cycles_device": "gpu",
"tessellation_engine": "occ",
"scene_linear_deflection": "0.1",
"scene_angular_deflection": "0.1",
"render_linear_deflection": "0.03",
"render_angular_deflection": "0.05",
}
try:
from app.config import settings as app_settings
@@ -1046,6 +1056,23 @@ def _generate_thumbnail(
cad_file_id: str,
upload_dir: str,
part_colors: dict[str, str] | None = None,
*,
renderer: str | None = None,
render_engine: str | None = None,
samples: int | None = None,
width: int | None = None,
height: int | None = None,
transparent_bg: bool | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict[str, str] | None = None,
part_names_ordered: list[str] | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
usd_path: Path | None = None,
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
) -> tuple[Path | None, dict]:
"""Generate thumbnail using the configured renderer.
@@ -1054,12 +1081,20 @@ def _generate_thumbnail(
"""
import time
out_dir = Path(upload_dir) / "thumbnails"
out_dir.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(out_dir)
settings = _get_all_settings()
renderer = settings["thumbnail_renderer"]
fmt = settings["thumbnail_format"] # "jpg" or "png"
requested_renderer = renderer or settings["thumbnail_renderer"]
active_renderer = requested_renderer
fmt = settings["thumbnail_format"] # "jpg" or "png"
ext = "jpg" if fmt == "jpg" else "png"
if requested_renderer == "threejs":
# The historical Three.js thumbnail renderer was removed from the backend.
# Keep the workflow node executable by falling back to the maintained Blender path
# while preserving the requested renderer in the render log for observability.
active_renderer = "blender"
fmt = "png"
ext = "png"
# Clean up any existing thumbnail for this cad_file_id (either extension)
for old_ext in ("png", "jpg"):
@@ -1073,28 +1108,39 @@ def _generate_thumbnail(
# Build the base render_log with the settings snapshot
render_log: dict = {
"renderer": renderer,
"renderer": requested_renderer,
"format": fmt,
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if renderer == "blender":
engine = settings["blender_engine"]
if active_renderer == "blender":
engine = render_engine or settings["blender_engine"]
resolved_samples = int(samples) if samples is not None else int(settings[f"blender_{engine}_samples"])
resolved_width = int(width) if width is not None else 512
resolved_height = int(height) if height is not None else 512
resolved_transparent_bg = bool(transparent_bg) if transparent_bg is not None else False
render_log.update({
"engine": engine,
"samples": int(settings[f"blender_{engine}_samples"]),
"samples": resolved_samples,
"smooth_angle": int(settings["blender_smooth_angle"]),
"cycles_device": settings["cycles_device"],
"width": 512,
"height": 512,
"width": resolved_width,
"height": resolved_height,
"transparent_bg": resolved_transparent_bg,
})
logger.info(f"Thumbnail renderer={renderer}, format={fmt}")
if requested_renderer != active_renderer:
render_log["renderer_backend"] = active_renderer
render_log["renderer_fallback_reason"] = "threejs_renderer_removed_using_blender_compat"
logger.info(f"Thumbnail renderer={requested_renderer}, format={fmt}")
rendered_png: Path | None = None
service_data: dict = {}
if renderer == "blender":
engine = settings["blender_engine"]
samples = int(settings[f"blender_{engine}_samples"])
if active_renderer == "blender":
engine = render_engine or settings["blender_engine"]
resolved_samples = int(samples) if samples is not None else int(settings[f"blender_{engine}_samples"])
resolved_width = int(width) if width is not None else 512
resolved_height = int(height) if height is not None else 512
resolved_transparent_bg = bool(transparent_bg) if transparent_bg is not None else False
from app.services.render_blender import is_blender_available, render_still
if is_blender_available():
@@ -1102,11 +1148,25 @@ def _generate_thumbnail(
service_data = render_still(
step_path=step_path,
output_path=tmp_png,
width=resolved_width,
height=resolved_height,
engine=engine,
samples=samples,
samples=resolved_samples,
smooth_angle=int(settings["blender_smooth_angle"]),
cycles_device=settings["cycles_device"],
transparent_bg=resolved_transparent_bg,
target_collection=target_collection,
material_library_path=material_library_path,
material_map=material_map,
part_names_ordered=part_names_ordered,
lighting_only=lighting_only,
shadow_catcher=shadow_catcher,
tessellation_engine=settings["tessellation_engine"],
usd_path=usd_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=material_override,
tessellation_profile="scene",
)
rendered_png = tmp_png if tmp_png.exists() else None
except Exception as exc:
@@ -1133,8 +1193,7 @@ def _generate_thumbnail(
def _finalise_image(src: Path, dst: Path) -> Path | None:
"""Move src image to dst. When dst has a .webp suffix, convert via Pillow
(quality=90, method=4) for 50-70 % smaller files. Otherwise output PNG."""
"""Move src image to dst, converting the PNG intermediate when needed."""
if dst.suffix.lower() == ".webp":
try:
from PIL import Image
@@ -1148,13 +1207,52 @@ def _finalise_image(src: Path, dst: Path) -> Path | None:
out = dst.with_suffix(".png")
src.rename(out)
return out
if dst.suffix.lower() in {".jpg", ".jpeg"}:
try:
from PIL import Image
img = Image.open(str(src))
if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info):
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(background, img.convert("RGBA")).convert("RGB")
else:
img = img.convert("RGB")
out = dst.with_suffix(".jpg")
img.save(str(out), "JPEG", quality=95, subsampling=0)
src.unlink(missing_ok=True)
return out
except Exception:
logger.warning("JPEG conversion failed — falling back to PNG")
out = dst.with_suffix(".png")
src.rename(out)
return out
out = dst.with_suffix(".png")
src.rename(out)
return out
def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> bool:
def regenerate_cad_thumbnail(
cad_file_id: str,
part_colors: dict[str, str],
*,
renderer: str | None = None,
render_engine: str | None = None,
samples: int | None = None,
width: int | None = None,
height: int | None = None,
transparent_bg: bool | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict[str, str] | None = None,
part_names_ordered: list[str] | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
usd_path: Path | None = None,
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
) -> bool:
"""
Regenerate a thumbnail with per-part colours for an existing CAD file.
@@ -1170,13 +1268,18 @@ def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> b
with Session(db_engine) as session:
cad_file = session.get(CadFile, uuid.UUID(cad_file_id))
if not cad_file:
logger.error(f"CAD file not found: {cad_file_id}")
return False
message = f"CAD file not found: {cad_file_id}"
logger.warning(message)
raise MissingCadResourceError(message)
step_path = Path(cad_file.stored_path)
if not step_path.exists():
logger.error(f"STEP file not found: {step_path}")
return False
message = f"STEP file not found: {step_path}"
logger.warning(message)
cad_file.processing_status = ProcessingStatus.failed
cad_file.error_message = message[:2000]
session.commit()
raise MissingCadResourceError(message)
# Mark as processing so the activity page shows it as active
cad_file.processing_status = ProcessingStatus.processing
@@ -1184,7 +1287,26 @@ def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> b
try:
thumb_path, render_log = _generate_thumbnail(
step_path, cad_file_id, app_settings.upload_dir, part_colors=part_colors
step_path,
cad_file_id,
app_settings.upload_dir,
part_colors=part_colors,
renderer=renderer,
render_engine=render_engine,
samples=samples,
width=width,
height=height,
transparent_bg=transparent_bg,
target_collection=target_collection,
material_library_path=material_library_path,
material_map=material_map,
part_names_ordered=part_names_ordered,
lighting_only=lighting_only,
shadow_catcher=shadow_catcher,
usd_path=usd_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=material_override,
)
if thumb_path:
cad_file.thumbnail_path = str(thumb_path)
@@ -1207,6 +1329,7 @@ def render_to_file(
part_colors: dict[str, str] | None = None,
width: int | None = None,
height: int | None = None,
smooth_angle: int | None = None,
transparent_bg: bool = False,
engine: str | None = None,
samples: int | None = None,
@@ -1234,6 +1357,7 @@ def render_to_file(
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict[str, Any] | None = None,
) -> tuple[bool, dict]:
"""Render a STEP file to a specific output path using current system settings.
@@ -1246,6 +1370,7 @@ def render_to_file(
part_colors: Optional {part_name: hex_color} map.
width: Optional render width (overrides system default).
height: Optional render height (overrides system default).
smooth_angle: Optional auto-smooth angle override in degrees.
transparent_bg: If True and renderer=blender+PNG, render with transparent background.
engine: Optional per-OT engine override ("cycles" | "eevee"), or None for system default.
samples: Optional per-OT samples override, or None for system default.
@@ -1262,7 +1387,7 @@ def render_to_file(
step = Path(step_path)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(out.parent)
settings = _get_all_settings()
renderer = settings["thumbnail_renderer"]
@@ -1284,19 +1409,20 @@ def render_to_file(
if renderer == "blender":
actual_engine = engine or settings["blender_engine"]
actual_samples = samples or int(settings[f"blender_{actual_engine}_samples"])
actual_samples = int(samples) if samples is not None else int(settings[f"blender_{actual_engine}_samples"])
actual_cycles_device = cycles_device or settings["cycles_device"]
actual_smooth_angle = smooth_angle if smooth_angle is not None else int(settings["blender_smooth_angle"])
w = width or 512
h = height or 512
render_log.update({
"engine": actual_engine, "samples": actual_samples,
"smooth_angle": int(settings["blender_smooth_angle"]),
"smooth_angle": actual_smooth_angle,
"cycles_device": actual_cycles_device,
"width": w, "height": h,
})
extra = {
"engine": actual_engine, "samples": actual_samples,
"smooth_angle": int(settings["blender_smooth_angle"]),
"smooth_angle": actual_smooth_angle,
"cycles_device": actual_cycles_device,
"width": w, "height": h,
"transparent_bg": transparent_bg,
@@ -1314,6 +1440,9 @@ def render_to_file(
render_log["lighting_only"] = True
if shadow_catcher:
render_log["shadow_catcher"] = True
if template_inputs:
extra["template_inputs"] = template_inputs
render_log["template_inputs"] = template_inputs
if material_library_path and material_map:
extra["material_library_path"] = material_library_path
extra["material_map"] = material_map
@@ -1349,7 +1478,7 @@ def render_to_file(
output_path=tmp_png,
engine=actual_engine,
samples=actual_samples,
smooth_angle=int(settings["blender_smooth_angle"]),
smooth_angle=actual_smooth_angle,
cycles_device=actual_cycles_device,
width=w, height=h,
transparent_bg=transparent_bg,
@@ -1373,6 +1502,7 @@ def render_to_file(
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=material_override,
template_inputs=template_inputs,
)
rendered_png = tmp_png if tmp_png.exists() else None
except Exception as exc:
@@ -1400,7 +1530,7 @@ def render_to_file(
def _convert_to_gltf(step_path: Path, cad_file_id: str, upload_dir: str) -> Path | None:
"""Convert STEP to glTF for browser 3D viewer."""
out_dir = Path(upload_dir) / "gltf"
out_dir.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(out_dir)
out_path = out_dir / f"{cad_file_id}.gltf"
try: