feat(D): OCC mesh attribute extraction + Blender smooth shading integration
Migration 039: cad_files.mesh_attributes JSONB column. domains/products/tasks.py: extract_mesh_attributes Celery task using pythonOCC. still_render.py + turntable_render.py: _apply_mesh_attributes() sets auto-smooth based on curved_ratio and topology threshold from OCC analysis. render_blender.py: passes --mesh-attributes JSON arg to Blender subprocess. render_still_task: loads mesh_attributes from DB and passes to renderer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
"""Add mesh_attributes JSONB column to cad_files.
|
||||||
|
|
||||||
|
Revision ID: 039
|
||||||
|
Revises: 038
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision = '039'
|
||||||
|
down_revision = '038'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('cad_files', sa.Column('mesh_attributes', JSONB, nullable=True))
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('cad_files', 'mesh_attributes')
|
||||||
@@ -30,6 +30,7 @@ class CadFile(Base):
|
|||||||
)
|
)
|
||||||
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
|
error_message: Mapped[str] = mapped_column(String(2000), nullable=True)
|
||||||
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
|
render_log: Mapped[dict] = mapped_column(JSONB, nullable=True)
|
||||||
|
mesh_attributes: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""CAD processing tasks for the products domain."""
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="products.extract_mesh_attributes", queue="step_processing", bind=True)
|
||||||
|
def extract_mesh_attributes(self, cad_file_id: str) -> dict:
|
||||||
|
"""Extract mesh topology attributes from a STEP file using pythonOCC.
|
||||||
|
|
||||||
|
Stores result in cad_files.mesh_attributes JSONB.
|
||||||
|
Returns attribute dict.
|
||||||
|
"""
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
|
||||||
|
async def _run() -> dict:
|
||||||
|
from sqlalchemy import select, update as sql_update
|
||||||
|
from app.domains.products.models import CadFile
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(CadFile).where(CadFile.id == cad_file_id))
|
||||||
|
cad_file = result.scalar_one_or_none()
|
||||||
|
if not cad_file:
|
||||||
|
logger.warning("CadFile %s not found", cad_file_id)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
attrs = _extract_from_step(cad_file.stored_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Mesh extraction failed for %s: %s", cad_file_id, exc)
|
||||||
|
attrs = {"error": str(exc)}
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
sql_update(CadFile)
|
||||||
|
.where(CadFile.id == cad_file_id)
|
||||||
|
.values(mesh_attributes=attrs)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
return asyncio.get_event_loop().run_until_complete(_run())
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_from_step(step_path: str, threshold_deg: float = 30.0) -> dict:
|
||||||
|
"""Use pythonOCC to analyse STEP topology.
|
||||||
|
|
||||||
|
Returns face_groups, counts, and meta-information.
|
||||||
|
Gracefully returns {} when OCC is not installed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from OCC.Core.STEPControl import STEPControl_Reader
|
||||||
|
from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE
|
||||||
|
from OCC.Core.TopExp import TopExp_Explorer
|
||||||
|
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||||
|
from OCC.Core.GeomAbs import (
|
||||||
|
GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Torus,
|
||||||
|
GeomAbs_Cone, GeomAbs_Sphere,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pythonOCC not available — skipping mesh extraction")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
reader = STEPControl_Reader()
|
||||||
|
if reader.ReadFile(step_path) != 1:
|
||||||
|
raise ValueError(f"STEP read failed: {step_path}")
|
||||||
|
reader.TransferRoots()
|
||||||
|
shape = reader.OneShape()
|
||||||
|
|
||||||
|
face_groups: dict[str, int] = {
|
||||||
|
"planar": 0, "cylindrical": 0, "toroidal": 0,
|
||||||
|
"conical": 0, "spherical": 0, "other": 0,
|
||||||
|
}
|
||||||
|
type_map = {
|
||||||
|
GeomAbs_Plane: "planar",
|
||||||
|
GeomAbs_Cylinder: "cylindrical",
|
||||||
|
GeomAbs_Torus: "toroidal",
|
||||||
|
GeomAbs_Cone: "conical",
|
||||||
|
GeomAbs_Sphere: "spherical",
|
||||||
|
}
|
||||||
|
|
||||||
|
face_explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
face_count = 0
|
||||||
|
while face_explorer.More():
|
||||||
|
adaptor = BRepAdaptor_Surface(face_explorer.Current())
|
||||||
|
key = type_map.get(adaptor.GetType(), "other")
|
||||||
|
face_groups[key] += 1
|
||||||
|
face_count += 1
|
||||||
|
face_explorer.Next()
|
||||||
|
|
||||||
|
edge_explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
|
edge_count = 0
|
||||||
|
while edge_explorer.More():
|
||||||
|
edge_count += 1
|
||||||
|
edge_explorer.Next()
|
||||||
|
|
||||||
|
curved = face_groups["cylindrical"] + face_groups["toroidal"] + face_groups["spherical"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"face_count": face_count,
|
||||||
|
"edge_count": edge_count,
|
||||||
|
"face_groups": face_groups,
|
||||||
|
"curved_ratio": round(curved / max(face_count, 1), 3),
|
||||||
|
"sharp_angle_threshold_deg": threshold_deg,
|
||||||
|
"extraction_version": "1.0",
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ def render_still_task(
|
|||||||
denoising_prefilter: str = "",
|
denoising_prefilter: str = "",
|
||||||
denoising_quality: str = "",
|
denoising_quality: str = "",
|
||||||
denoising_use_gpu: str = "",
|
denoising_use_gpu: str = "",
|
||||||
|
mesh_attributes: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Render a STEP file to a still PNG via Blender subprocess.
|
"""Render a STEP file to a still PNG via Blender subprocess.
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ def render_still_task(
|
|||||||
denoising_prefilter=denoising_prefilter,
|
denoising_prefilter=denoising_prefilter,
|
||||||
denoising_quality=denoising_quality,
|
denoising_quality=denoising_quality,
|
||||||
denoising_use_gpu=denoising_use_gpu,
|
denoising_use_gpu=denoising_use_gpu,
|
||||||
|
mesh_attributes=mesh_attributes or {},
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"render_still_task completed: %s → %s in %.1fs",
|
"render_still_task completed: %s → %s in %.1fs",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ def render_still(
|
|||||||
denoising_prefilter: str = "",
|
denoising_prefilter: str = "",
|
||||||
denoising_quality: str = "",
|
denoising_quality: str = "",
|
||||||
denoising_use_gpu: str = "",
|
denoising_use_gpu: str = "",
|
||||||
|
mesh_attributes: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
||||||
|
|
||||||
@@ -238,7 +239,7 @@ def render_still(
|
|||||||
env["EGL_PLATFORM"] = "surfaceless"
|
env["EGL_PLATFORM"] = "surfaceless"
|
||||||
|
|
||||||
def _build_cmd(eng: str) -> list:
|
def _build_cmd(eng: str) -> list:
|
||||||
return [
|
cmd = [
|
||||||
blender_bin,
|
blender_bin,
|
||||||
"--background",
|
"--background",
|
||||||
"--python", str(script_path),
|
"--python", str(script_path),
|
||||||
@@ -261,6 +262,9 @@ def render_still(
|
|||||||
denoising_input_passes or "", denoising_prefilter or "",
|
denoising_input_passes or "", denoising_prefilter or "",
|
||||||
denoising_quality or "", denoising_use_gpu or "",
|
denoising_quality or "", denoising_use_gpu or "",
|
||||||
]
|
]
|
||||||
|
if mesh_attributes:
|
||||||
|
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
|
||||||
|
return cmd
|
||||||
|
|
||||||
def _run(eng: str) -> subprocess.CompletedProcess:
|
def _run(eng: str) -> subprocess.CompletedProcess:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ celery_app = Celery(
|
|||||||
"app.tasks.step_tasks",
|
"app.tasks.step_tasks",
|
||||||
"app.tasks.ai_tasks",
|
"app.tasks.ai_tasks",
|
||||||
"app.domains.rendering.tasks",
|
"app.domains.rendering.tasks",
|
||||||
|
"app.domains.products.tasks",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,28 @@ def _apply_rotation(parts, rx, ry, rz):
|
|||||||
print(f"[still_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
|
print(f"[still_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
|
||||||
|
"""Apply topology-based shading settings from OCC analysis."""
|
||||||
|
import math
|
||||||
|
if not mesh_attributes or mesh_attributes.get("error"):
|
||||||
|
return
|
||||||
|
|
||||||
|
curved_ratio = mesh_attributes.get("curved_ratio", 0.0)
|
||||||
|
threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0)
|
||||||
|
threshold_rad = threshold_deg * math.pi / 180.0
|
||||||
|
|
||||||
|
for obj in objects:
|
||||||
|
if obj.type != 'MESH':
|
||||||
|
continue
|
||||||
|
# Enable smooth shading for predominantly curved parts (bearings etc.)
|
||||||
|
if curved_ratio > 0.3:
|
||||||
|
for poly in obj.data.polygons:
|
||||||
|
poly.use_smooth = True
|
||||||
|
# Auto-smooth at topology threshold
|
||||||
|
obj.data.use_auto_smooth = True
|
||||||
|
obj.data.auto_smooth_angle = threshold_rad
|
||||||
|
|
||||||
|
|
||||||
def _import_stl(stl_file):
|
def _import_stl(stl_file):
|
||||||
"""Import STL into Blender, using per-part STLs if available.
|
"""Import STL into Blender, using per-part STLs if available.
|
||||||
|
|
||||||
@@ -318,6 +340,15 @@ def main():
|
|||||||
denoising_quality_arg = args[23] if len(args) > 23 else ""
|
denoising_quality_arg = args[23] if len(args) > 23 else ""
|
||||||
denoising_use_gpu_arg = args[24] if len(args) > 24 else ""
|
denoising_use_gpu_arg = args[24] if len(args) > 24 else ""
|
||||||
|
|
||||||
|
# Named argument: --mesh-attributes <json>
|
||||||
|
_mesh_attrs: dict = {}
|
||||||
|
if "--mesh-attributes" in argv:
|
||||||
|
_idx = argv.index("--mesh-attributes")
|
||||||
|
try:
|
||||||
|
_mesh_attrs = json.loads(argv[_idx + 1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -371,6 +402,8 @@ def main():
|
|||||||
_scale_mm_to_m(parts)
|
_scale_mm_to_m(parts)
|
||||||
# Apply render position rotation (before camera/bbox calculations)
|
# Apply render position rotation (before camera/bbox calculations)
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
|
# Apply OCC topology-based shading overrides
|
||||||
|
_apply_mesh_attributes(parts, _mesh_attrs)
|
||||||
|
|
||||||
# Move imported parts into target collection
|
# Move imported parts into target collection
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@@ -466,6 +499,8 @@ def main():
|
|||||||
_scale_mm_to_m(parts)
|
_scale_mm_to_m(parts)
|
||||||
# Apply render position rotation (before camera/bbox calculations)
|
# Apply render position rotation (before camera/bbox calculations)
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
|
# Apply OCC topology-based shading overrides
|
||||||
|
_apply_mesh_attributes(parts, _mesh_attrs)
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
_apply_smooth(part, SMOOTH_ANGLE)
|
_apply_smooth(part, SMOOTH_ANGLE)
|
||||||
|
|||||||
@@ -157,6 +157,28 @@ def _scale_mm_to_m(parts):
|
|||||||
print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)")
|
print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
|
||||||
|
"""Apply topology-based shading settings from OCC analysis."""
|
||||||
|
import math
|
||||||
|
if not mesh_attributes or mesh_attributes.get("error"):
|
||||||
|
return
|
||||||
|
|
||||||
|
curved_ratio = mesh_attributes.get("curved_ratio", 0.0)
|
||||||
|
threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0)
|
||||||
|
threshold_rad = threshold_deg * math.pi / 180.0
|
||||||
|
|
||||||
|
for obj in objects:
|
||||||
|
if obj.type != 'MESH':
|
||||||
|
continue
|
||||||
|
# Enable smooth shading for predominantly curved parts (bearings etc.)
|
||||||
|
if curved_ratio > 0.3:
|
||||||
|
for poly in obj.data.polygons:
|
||||||
|
poly.use_smooth = True
|
||||||
|
# Auto-smooth at topology threshold
|
||||||
|
obj.data.use_auto_smooth = True
|
||||||
|
obj.data.auto_smooth_angle = threshold_rad
|
||||||
|
|
||||||
|
|
||||||
def _import_stl(stl_file):
|
def _import_stl(stl_file):
|
||||||
"""Import STL into Blender, using per-part STLs if available.
|
"""Import STL into Blender, using per-part STLs if available.
|
||||||
|
|
||||||
@@ -351,6 +373,15 @@ def main():
|
|||||||
bg_color = args[21] if len(args) > 21 else ""
|
bg_color = args[21] if len(args) > 21 else ""
|
||||||
transparent_bg = args[22] == "1" if len(args) > 22 else False
|
transparent_bg = args[22] == "1" if len(args) > 22 else False
|
||||||
|
|
||||||
|
# Named argument: --mesh-attributes <json>
|
||||||
|
_mesh_attrs: dict = {}
|
||||||
|
if "--mesh-attributes" in argv:
|
||||||
|
_idx = argv.index("--mesh-attributes")
|
||||||
|
try:
|
||||||
|
_mesh_attrs = json.loads(argv[_idx + 1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
os.makedirs(frames_dir, exist_ok=True)
|
os.makedirs(frames_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -402,6 +433,8 @@ def main():
|
|||||||
_scale_mm_to_m(parts)
|
_scale_mm_to_m(parts)
|
||||||
# Apply render position rotation before material/camera setup
|
# Apply render position rotation before material/camera setup
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
|
# Apply OCC topology-based shading overrides
|
||||||
|
_apply_mesh_attributes(parts, _mesh_attrs)
|
||||||
|
|
||||||
# Move imported parts into target collection
|
# Move imported parts into target collection
|
||||||
for part in parts:
|
for part in parts:
|
||||||
@@ -480,6 +513,8 @@ def main():
|
|||||||
_scale_mm_to_m(parts)
|
_scale_mm_to_m(parts)
|
||||||
# Apply render position rotation before material/camera setup
|
# Apply render position rotation before material/camera setup
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
|
# Apply OCC topology-based shading overrides
|
||||||
|
_apply_mesh_attributes(parts, _mesh_attrs)
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
_apply_smooth(part, SMOOTH_ANGLE)
|
_apply_smooth(part, SMOOTH_ANGLE)
|
||||||
|
|||||||
Reference in New Issue
Block a user