From ec667dd56a0b12be5301054d0adde87c426d5337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 13 Mar 2026 10:37:35 +0100 Subject: [PATCH] refactor: remove dead export_gltf.py, cleanup rendering tasks, improve tessellation UI - Remove export_gltf.py (Blender-based GLB export replaced by OCC direct) - Remove unused export_gltf_for_order_line_task - Add Ultra tessellation preset to Admin settings - Improve tessellation preset descriptions and styling - Minor cleanup across media, rendering, and workflow modules Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 - backend/app/core/process_steps.py | 3 +- backend/app/domains/media/models.py | 4 +- backend/app/domains/media/router.py | 1 - backend/app/domains/rendering/tasks.py | 46 --- .../app/domains/rendering/workflow_builder.py | 13 +- .../domains/rendering/workflow_executor.py | 3 +- .../app/domains/rendering/workflow_router.py | 2 - .../tests/domains/test_rendering_service.py | 5 - frontend/src/api/media.ts | 1 - frontend/src/pages/Admin.tsx | 134 ++++--- frontend/src/pages/MediaBrowser.tsx | 4 +- render-worker/scripts/export_gltf.py | 356 ------------------ render-worker/scripts/export_step_to_gltf.py | 11 +- 14 files changed, 106 insertions(+), 478 deletions(-) delete mode 100644 render-worker/scripts/export_gltf.py diff --git a/CLAUDE.md b/CLAUDE.md index b97cbc7..e4d686a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,6 @@ schaefflerautomat/ │ │ ├── blender_render.py # Entry point (68 lines), delegates to _blender_*.py submodules │ │ ├── export_step_to_gltf.py # OCC/GMSH STEP → GLB tessellation │ │ ├── export_step_to_usd.py # OCC STEP → USD canonical scene -│ │ ├── export_gltf.py # Blender: materials, seams, sharp edges on GLB │ │ ├── import_usd.py # Blender: USD import + primvar restoration │ │ ├── still_render.py # Blender still render │ │ └── turntable_render.py # Blender turntable animation diff --git a/backend/app/core/process_steps.py b/backend/app/core/process_steps.py index 245e6c6..6a7267f 100644 --- a/backend/app/core/process_steps.py +++ b/backend/app/core/process_steps.py @@ -27,8 +27,7 @@ class StepName(StrEnum): BLENDER_TURNTABLE = "blender_turntable" OUTPUT_SAVE = "output_save" - # ── GLB / asset export ──────────────────────────────────────────── - EXPORT_GLB_GEOMETRY = "export_glb_geometry" + # ── Asset export ────────────────────────────────────────────────── EXPORT_BLEND = "export_blend" # ── STL cache ──────────────────────────────────────────────────── diff --git a/backend/app/domains/media/models.py b/backend/app/domains/media/models.py index c58c012..0f62c35 100644 --- a/backend/app/domains/media/models.py +++ b/backend/app/domains/media/models.py @@ -14,8 +14,8 @@ class MediaAssetType(str, enum.Enum): turntable = "turntable" stl_low = "stl_low" stl_high = "stl_high" - gltf_geometry = "gltf_geometry" # DEPRECATED: use usd_master — viewer GLB auto-generated as part of USD pipeline - gltf_production = "gltf_production" # DEPRECATED: use usd_master — high-quality production GLB superseded by USD master + gltf_geometry = "gltf_geometry" + gltf_production = "gltf_production" # LEGACY — kept for ORM compatibility with existing DB rows, no longer generated blend_production = "blend_production" usd_master = "usd_master" diff --git a/backend/app/domains/media/router.py b/backend/app/domains/media/router.py index 00b6749..1d65fe7 100644 --- a/backend/app/domains/media/router.py +++ b/backend/app/domains/media/router.py @@ -142,7 +142,6 @@ async def browse_media_assets( # Apply filters _TECHNICAL_TYPES = ( MediaAssetType.gltf_geometry, - MediaAssetType.gltf_production, MediaAssetType.blend_production, MediaAssetType.stl_low, MediaAssetType.stl_high, diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index 52f9a7d..c84a5e7 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -506,52 +506,6 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict: raise self.retry(exc=exc, countdown=30) -@celery_app.task( - bind=True, - name="app.domains.rendering.tasks.export_gltf_for_order_line_task", - queue="asset_pipeline", - max_retries=1, -) -def export_gltf_for_order_line_task(self, order_line_id: str) -> dict: - """Export a geometry GLB directly from STEP via OCC (no STL intermediary). - - Publishes a MediaAsset with asset_type='gltf_geometry'. - """ - import os - import subprocess - import sys - - step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id) - if not step_path_str: - raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}") - - step = Path(step_path_str) - output_path = step.parent / f"{step.stem}_geometry.glb" - scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) - occ_script = scripts_dir / "export_step_to_gltf.py" - - if not occ_script.exists(): - raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}") - - try: - cmd = [ - sys.executable, str(occ_script), - "--step_path", str(step), - "--output_path", str(output_path), - ] - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) - if result.returncode != 0: - raise RuntimeError( - f"export_step_to_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}" - ) - publish_asset.delay(order_line_id, "gltf_geometry", str(output_path)) - logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name) - return {"glb_path": str(output_path), "method": "occ"} - except Exception as exc: - logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc) - raise self.retry(exc=exc, countdown=15) - - @celery_app.task( bind=True, name="app.domains.rendering.tasks.export_blend_for_order_line_task", diff --git a/backend/app/domains/rendering/workflow_builder.py b/backend/app/domains/rendering/workflow_builder.py index 336e50f..1672fbe 100644 --- a/backend/app/domains/rendering/workflow_builder.py +++ b/backend/app/domains/rendering/workflow_builder.py @@ -64,23 +64,16 @@ def _build_multi_angle(order_line_id: str, params: dict): def _build_still_with_exports(order_line_id: str, params: dict): - """Still render + parallel GLB exports (geometry + production quality). + """Still render + .blend export. Pipeline: - render_order_line_still_task → group( - export_gltf_for_order_line_task, - export_blend_for_order_line_task, - ) + render_order_line_still_task → export_blend_for_order_line_task """ from app.domains.rendering.tasks import ( render_order_line_still_task, - export_gltf_for_order_line_task, export_blend_for_order_line_task, ) return chain( render_order_line_still_task.si(order_line_id, **params), - group( - export_gltf_for_order_line_task.si(order_line_id), - export_blend_for_order_line_task.si(order_line_id), - ), + export_blend_for_order_line_task.si(order_line_id), ) diff --git a/backend/app/domains/rendering/workflow_executor.py b/backend/app/domains/rendering/workflow_executor.py index 632ff47..f2f630e 100644 --- a/backend/app/domains/rendering/workflow_executor.py +++ b/backend/app/domains/rendering/workflow_executor.py @@ -45,8 +45,7 @@ STEP_TASK_MAP: dict[StepName, str] = { # ── Order line stills & turntables ────────────────────────────────── StepName.BLENDER_STILL: "app.domains.rendering.tasks.render_order_line_still_task", StepName.BLENDER_TURNTABLE: "app.domains.rendering.tasks.render_turntable_task", - # ── GLB / asset export ─────────────────────────────────────────────── - StepName.EXPORT_GLB_GEOMETRY: "app.domains.rendering.tasks.export_gltf_for_order_line_task", + # ── Asset export ───────────────────────────────────────────────────── StepName.EXPORT_BLEND: "app.domains.rendering.tasks.export_blend_for_order_line_task", # ── Steps without a dedicated standalone task (no mapping) ─────────── # StepName.GLB_BBOX — computed inline inside process_step_file diff --git a/backend/app/domains/rendering/workflow_router.py b/backend/app/domains/rendering/workflow_router.py index f8be3a5..e9fd447 100644 --- a/backend/app/domains/rendering/workflow_router.py +++ b/backend/app/domains/rendering/workflow_router.py @@ -51,7 +51,6 @@ _STEP_CATEGORIES: dict[StepName, StepCategory] = { StepName.BLENDER_STILL: "rendering", StepName.BLENDER_TURNTABLE: "rendering", StepName.OUTPUT_SAVE: "output", - StepName.EXPORT_GLB_GEOMETRY: "output", StepName.EXPORT_BLEND: "output", StepName.STL_CACHE_GENERATE: "processing", StepName.NOTIFY: "output", @@ -72,7 +71,6 @@ _STEP_DESCRIPTIONS: dict[StepName, str] = { StepName.BLENDER_STILL: "Render a production still image (PNG) via Blender HTTP micro-service", StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service", StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record", - StepName.EXPORT_GLB_GEOMETRY: "Export a geometry-only GLB for the 3-D viewer (no materials)", StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset", StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file", StepName.NOTIFY: "Emit a user notification via the audit-log notification channel", diff --git a/backend/tests/domains/test_rendering_service.py b/backend/tests/domains/test_rendering_service.py index 2bd6e47..e932774 100644 --- a/backend/tests/domains/test_rendering_service.py +++ b/backend/tests/domains/test_rendering_service.py @@ -102,11 +102,6 @@ def test_render_order_line_still_task_importable(): assert render_order_line_still_task.queue == "asset_pipeline" -def test_export_gltf_for_order_line_task_importable(): - from app.domains.rendering.tasks import export_gltf_for_order_line_task - assert export_gltf_for_order_line_task.queue == "asset_pipeline" - - def test_export_blend_for_order_line_task_importable(): from app.domains.rendering.tasks import export_blend_for_order_line_task assert export_blend_for_order_line_task.queue == "asset_pipeline" diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts index 47154f3..ca7609c 100644 --- a/frontend/src/api/media.ts +++ b/frontend/src/api/media.ts @@ -7,7 +7,6 @@ export type MediaAssetType = | 'stl_low' | 'stl_high' | 'gltf_geometry' - | 'gltf_production' | 'usd_master' | 'blend_production' diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index acd0802..44a503a 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1454,7 +1454,7 @@ export default function AdminPage() {

Tessellation Quality

- OCC mesh precision for GLB export. Lower values = finer mesh + larger files + slower export. + Controls how STEP geometry is converted to triangle meshes. These settings affect both the 3D viewer and Blender renders.

@@ -1463,48 +1463,70 @@ export default function AdminPage() { const PRESETS = [ { label: 'Draft', - description: 'Fast export, visible faceting on large curves', - color: 'border-amber-400 text-amber-700', + icon: '⚡', + description: 'Fast preview — visible faceting on curved surfaces', + useCase: 'Quick checks, large assemblies', + color: 'border-amber-400', + activeColor: 'border-amber-500 ring-2 ring-amber-200', values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3, render_linear_deflection: 0.05, render_angular_deflection: 0.1 }, }, { label: 'Standard', - description: 'Smooth curves, no fan artifacts — recommended', - color: 'border-blue-400 text-blue-700', + icon: '●', + description: 'Smooth curves, good quality-to-size ratio', + useCase: 'Recommended for most parts', + color: 'border-blue-400', + activeColor: 'border-blue-500 ring-2 ring-blue-200', values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1, render_linear_deflection: 0.03, render_angular_deflection: 0.05 }, }, { label: 'Fine', - description: 'Maximum quality, very large files, slow export', - color: 'border-emerald-400 text-emerald-700', + icon: '◆', + description: 'Near-perfect surfaces, 3-5x larger files', + useCase: 'Close-up renders, small precision parts', + color: 'border-emerald-400', + activeColor: 'border-emerald-500 ring-2 ring-emerald-200', values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05, render_linear_deflection: 0.01, render_angular_deflection: 0.02 }, }, + { + label: 'Ultra', + icon: '★', + description: 'Maximum fidelity, very slow export', + useCase: 'Marketing renders, extreme close-ups', + color: 'border-purple-400', + activeColor: 'border-purple-500 ring-2 ring-purple-200', + values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02, render_linear_deflection: 0.005, render_angular_deflection: 0.01 }, + }, ] const isActive = (preset: typeof PRESETS[0]) => tess.scene_linear_deflection === preset.values.scene_linear_deflection && tess.scene_angular_deflection === preset.values.scene_angular_deflection && tess.render_linear_deflection === preset.values.render_linear_deflection && tess.render_angular_deflection === preset.values.render_angular_deflection + const isCustom = !PRESETS.some(isActive) return (
-

Presets

-
+

Quality Presets

+
{PRESETS.map(preset => ( ))}
+ {isCustom && ( +

Current values don't match any preset (custom configuration)

+ )}
) })()} @@ -1512,27 +1534,40 @@ export default function AdminPage() { {/* Tessellation engine selector */}

Tessellation Engine

-
-
- {[ - { value: 'occ', label: 'OCC BRepMesh', description: 'Default engine. Fast, but produces fan triangles at cylinder seam edges.' }, - { value: 'gmsh', label: 'GMSH Frontal-Delaunay', description: 'Conforming mesh — no fan triangles on cylinders. +10–30% export time. Recommended for cylindrical parts.' }, - ].map(opt => ( - - ))} +
+ {[ + { value: 'occ', label: 'OCC BRepMesh', description: 'Default engine. Fast, but produces fan-shaped triangles at cylinder seam lines.' }, + { value: 'gmsh', label: 'GMSH Frontal-Delaunay', description: 'Uniform mesh — eliminates fan artifacts on cylindrical parts. 10-30% slower. Recommended for bearings.' }, + ].map(opt => ( + + ))} +
+
+ + {/* Explanation of deflection parameters */} +
+

How deflection values work

+
+
+

Linear deflection (mm)

+

Maximum allowed distance between the original curved surface and the generated triangles. A value of 0.1 mm means no triangle edge can deviate more than 0.1 mm from the true surface. Lower values produce smoother curves but more triangles.

+
+
+

Angular deflection (rad)

+

Maximum angle between adjacent triangle normals. Controls how finely curved regions are subdivided. A value of 0.1 rad (~6°) means neighboring triangles can differ by at most ~6°. Primarily affects small fillets and tight curvatures.

@@ -1542,14 +1577,17 @@ export default function AdminPage() { className="text-xs text-accent hover:underline flex items-center gap-1 mt-1" > {showAdvancedTess ? : } - {showAdvancedTess ? 'Hide manual values' : 'Advanced: manual deflection values'} + {showAdvancedTess ? 'Hide manual values' : 'Advanced: edit values manually'} {/* Manual inputs */} {showAdvancedTess && (<>
-

Scene (USD Master)

+
+

3D Viewer + USD Master

+

Used for the interactive 3D viewer GLB and the canonical USD scene file. Optimized for real-time display.

+
rad
-

Used for the USD master + 3D viewer GLB (canonical scene). Smaller = smoother surfaces.

-

Render output

+
+

Blender Render Output

+

Used for final Blender renders (stills, turntables). Higher quality since render time matters more than file size.

+
rad
-

Used for final render output. Smaller = smoother surfaces, larger file sizes.

)} @@ -1983,11 +2022,14 @@ function AssetLibraryPanel() {

Materials

- {lib.catalog.materials.map((m) => ( - - {m} - - ))} + {lib.catalog.materials.map((m) => { + const name = typeof m === 'string' ? m : m.name + return ( + + {name} + + ) + })}
)} diff --git a/frontend/src/pages/MediaBrowser.tsx b/frontend/src/pages/MediaBrowser.tsx index ec386dc..5d9dbcd 100644 --- a/frontend/src/pages/MediaBrowser.tsx +++ b/frontend/src/pages/MediaBrowser.tsx @@ -30,7 +30,6 @@ const TYPE_COLORS: Partial> = { stl_low: 'badge-yellow', stl_high: 'badge-orange', gltf_geometry: 'badge-green', - gltf_production: 'badge-teal', blend_production: 'badge-purple', } @@ -43,7 +42,6 @@ const ASSET_TYPES_MEDIA = [ const ASSET_TYPES_TECHNICAL = [ { value: 'gltf_geometry', label: 'glTF Geometry' }, - { value: 'gltf_production', label: 'glTF Production' }, { value: 'blend_production', label: 'Blend (.blend)' }, { value: 'stl_low', label: 'STL Low' }, { value: 'stl_high', label: 'STL High' }, @@ -76,7 +74,7 @@ function TypeIcon({ type, size = 32 }: { type: MediaAssetType; size?: number }) if (type === 'still' || type === 'thumbnail') return if (type === 'turntable') return if (type === 'stl_low' || type === 'stl_high') return - if (type === 'gltf_geometry' || type === 'gltf_production') return + if (type === 'gltf_geometry') return return } diff --git a/render-worker/scripts/export_gltf.py b/render-worker/scripts/export_gltf.py deleted file mode 100644 index ad00b2f..0000000 --- a/render-worker/scripts/export_gltf.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Blender headless script: export a STEP-derived scene as a production GLB. - -Usage: - blender --background --python export_gltf.py -- \\ - --stl_path /path/to/file.stl \\ - --output_path /path/to/output.glb \\ - [--asset_library_blend /path/to/library.blend] \\ - [--material_map '{"SrcMat": "LibMat"}'] - -The script: -1. Imports the STL file (with mm→m scale). -2. Optionally applies asset library materials from a .blend. -3. Exports as GLB (Draco-compressed if available, otherwise standard). -""" -from __future__ import annotations - -import argparse -import json -import sys -import traceback - -FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial" - - -def parse_args() -> argparse.Namespace: - argv = sys.argv - if "--" not in argv: - print("No arguments after --", file=sys.stderr) - sys.exit(1) - rest = argv[argv.index("--") + 1:] - parser = argparse.ArgumentParser() - parser.add_argument("--glb_path", required=True, - help="Geometry GLB from export_step_to_gltf.py (already in metres)") - parser.add_argument("--output_path", required=True) - parser.add_argument("--asset_library_blend", default=None) - parser.add_argument("--material_map", default="{}") - parser.add_argument("--smooth_angle", type=float, default=30.0, - help="Auto-smooth angle in degrees (default 30)") - parser.add_argument("--mesh_attributes", default="{}", - help="JSON dict from cad_file.mesh_attributes (sharp_edge_pairs etc.)") - return parser.parse_args(rest) - - -def _apply_sharp_edges_from_occ(mesh_objects: list, sharp_edge_pairs: list) -> None: - """Mark edges sharp using OCC vertex-pair data (same approach as blender_render.py). - - sharp_edge_pairs: [[x0,y0,z0],[x1,y1,z1]] in mm. - Blender mesh coords are in metres (×0.001 scale already applied by OCC export). - """ - if not sharp_edge_pairs: - return - - import bmesh - import mathutils - - SCALE = 0.001 # mm → m - TOL = 0.0005 # 0.5 mm tolerance in metres - - # OCC STEP space (Z-up, mm) → Blender (Z-up, m): - # RWGltf applies Z→Y-up, Blender import applies Y→Z-up. - # Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001) - occ_pairs = [] - for pair in sharp_edge_pairs: - v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE)) - v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE)) - occ_pairs.append((v0, v1)) - - marked_total = 0 - for obj in mesh_objects: - bm = bmesh.new() - bm.from_mesh(obj.data) - bm.verts.ensure_lookup_table() - bm.edges.ensure_lookup_table() - - # Build KD-tree in WORLD space — OCC pairs are world coords, but mesh - # vertices are in local space (assembly node transform in GLB hierarchy). - world_mat = obj.matrix_world - kd = mathutils.kdtree.KDTree(len(bm.verts)) - for v in bm.verts: - kd.insert(world_mat @ v.co, v.index) - kd.balance() - - marked = 0 - for v0_occ, v1_occ in occ_pairs: - _co0, idx0, dist0 = kd.find(v0_occ) - _co1, idx1, dist1 = kd.find(v1_occ) - if dist0 > TOL or dist1 > TOL: - continue - if idx0 == idx1: - continue # degenerate — both endpoints map to same vertex - bv0, bv1 = bm.verts[idx0], bm.verts[idx1] - edge = bm.edges.get((bv0, bv1)) or bm.edges.get((bv1, bv0)) - if edge is not None: - # Mark sharp (for normal splitting) AND seam (for UV unwrap). - # Both are needed: sharp controls glTF vertex splits / shading; - # seam defines UV island boundaries for correct UV unwrapping. - edge.smooth = False - edge.seam = True - marked += 1 - - bm.to_mesh(obj.data) - bm.free() - marked_total += marked - - print(f"OCC sharp edges applied: {marked_total} edges marked across {len(mesh_objects)} objects") - - -def main() -> None: - args = parse_args() - material_map: dict = json.loads(args.material_map) - mesh_attributes: dict = json.loads(args.mesh_attributes) - - import bpy # type: ignore[import] - import math as _math - import re as _re - - # Clean scene - bpy.ops.wm.read_factory_settings(use_empty=True) - - # Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up) - bpy.ops.import_scene.gltf(filepath=args.glb_path) - mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"] - print(f"Imported geometry GLB: {args.glb_path} ({len(mesh_objects)} mesh objects)") - - # Read OCC sharp edge pairs embedded by export_step_to_gltf.py into GLB extras. - # Blender 5.0 maps glTF scenes[0].extras as scene custom properties on import. - # These take priority over the mesh_attributes CLI argument (which only has 2 - # endpoints per edge — see V02 refactor for why this matters). - glb_sharp_pairs = bpy.context.scene.get("schaeffler_sharp_edge_pairs") or [] - if glb_sharp_pairs: - print(f"Loaded {len(glb_sharp_pairs)} OCC sharp edge pairs from GLB extras") - - # Remove OCC-baked custom normals from the geometry GLB. - # RWGltf_CafWriter embeds per-corner normals from OCC tessellation as a - # 'custom_normal' attribute (CORNER, INT16_2D). If left in place, Blender's - # glTF exporter re-exports these pre-baked normals unchanged, ignoring our - # shade_smooth_by_angle processing and sharp edge marks entirely. - # Removing this attribute forces Blender to recompute normals from scratch. - cleared_normals = 0 - for obj in mesh_objects: - if "custom_normal" in obj.data.attributes: - obj.data.attributes.remove(obj.data.attributes["custom_normal"]) - cleared_normals += 1 - if cleared_normals: - print(f"Cleared OCC custom_normal attribute from {cleared_normals} mesh objects") - - # Mark sharp edges and seams using the configured angle threshold. - # We use Blender's edit-mode operators (mark_sharp + mark_seam) rather than - # shade_smooth_by_angle alone, because: - # 1. mark_sharp() sets the sharp_edge boolean attribute on edges — the glTF - # exporter creates vertex splits (duplicate vertices with different normals) - # at sharp edges, which is how glTF encodes hard edges. - # 2. mark_seam() ensures UV splits at the same edges (stepper-addon behaviour). - # Note: calc_normals_split() was removed in Blender 5.0 — not needed here - # because export_apply=True triggers vertex splitting automatically. - smooth_rad = _math.radians(args.smooth_angle) - print(f"Marking sharp edges + seams at {args.smooth_angle}° ({smooth_rad:.3f} rad)") - - bpy.ops.object.select_all(action='DESELECT') - total_sharp = 0 - for obj in mesh_objects: - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - - # Set all faces smooth - bpy.ops.object.mode_set(mode='OBJECT') - for poly in obj.data.polygons: - poly.use_smooth = True - - # Enter edit mode, deselect, select sharp edges by angle, mark sharp+seam - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action='DESELECT') - bpy.ops.mesh.edges_select_sharp(sharpness=smooth_rad) - bpy.ops.mesh.mark_sharp() - bpy.ops.mesh.mark_seam() - bpy.ops.object.mode_set(mode='OBJECT') - - # Count how many edges were marked - n_sharp = sum(1 for e in obj.data.edges if e.use_edge_sharp) - total_sharp += n_sharp - obj.select_set(False) - - print(f"Marked {total_sharp} sharp/seam edges across {len(mesh_objects)} objects") - - # Apply OCC sharp edges from GLB extras (V02: dense tessellation segment pairs). - # Prefer GLB-embedded pairs over mesh_attributes CLI argument — the GLB extras - # contain the full tessellated polyline for each sharp B-rep edge (all intermediate - # points), while mesh_attributes only has 2 endpoints per edge (too sparse for - # reliable KD-tree matching). Fall back to mesh_attributes if GLB extras absent. - occ_pairs = list(glb_sharp_pairs) or (mesh_attributes.get("sharp_edge_pairs") or []) - if occ_pairs: - _apply_sharp_edges_from_occ(mesh_objects, occ_pairs) - - # Apply asset library materials if provided. - # link=False (append) is required: the GLTF exporter can only traverse - # local (appended) Principled BSDF node trees to extract PBR values. - # - # Matching strategy (mirrors blender_render.py): - # Build mat_map_lower with BOTH the original key AND the _AF-stripped key, - # so keys like "RingOuter_AF0" match object names "RingOuter" and vice-versa. - # Object names from RWGltf_CafWriter preserve the original STEP part name - # (including any _AF suffixes), so we strip from both sides. - if args.asset_library_blend and material_map: - mat_map_lower: dict = {} - for k, v in material_map.items(): - kl = k.lower().strip() - mat_map_lower[kl] = v - # Also add the _AF-stripped version so either form matches - stripped = kl - prev = None - while prev != stripped: - prev = stripped - stripped = _re.sub(r'_af\d+$', '', stripped) - if stripped != kl: - mat_map_lower.setdefault(stripped, v) - - needed = set(mat_map_lower.values()) - - # Append materials from library (link=False so glTF exporter can read nodes) - appended: dict = {} - for mat_name in needed: - try: - bpy.ops.wm.append( - filepath=f"{args.asset_library_blend}/Material/{mat_name}", - directory=f"{args.asset_library_blend}/Material/", - filename=mat_name, - link=False, - ) - if mat_name in bpy.data.materials: - appended[mat_name] = bpy.data.materials[mat_name] - print(f"Appended material: {mat_name}") - else: - print(f"WARNING: material '{mat_name}' not found in library after append", - file=sys.stderr) - except Exception as exc: - print(f"WARNING: failed to append material '{mat_name}': {exc}", file=sys.stderr) - - if appended: - assigned = 0 - assigned_names: set = set() - for obj in mesh_objects: - # Strip Blender's .001/.002 deduplication suffix - base_name = _re.sub(r'\.\d{3}$', '', obj.name) - # Also strip _AF suffix from object name so both directions match - prev = None - while prev != base_name: - prev = base_name - base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE) - - lower_base = base_name.lower().strip() - mat_name = mat_map_lower.get(lower_base) - - # Prefix fallback for sub-assembly nodes - if not mat_name: - for key, val in sorted(mat_map_lower.items(), key=lambda x: len(x[0]), reverse=True): - if len(key) >= 3 and len(lower_base) >= 3 and ( - lower_base.startswith(key) or key.startswith(lower_base) - ): - mat_name = val - break - - if mat_name and mat_name in appended: - # Make mesh data single-user before modifying material slots; - # otherwise clearing materials on a shared data block removes - # slots from ALL objects that share it. - if obj.data.users > 1: - obj.data = obj.data.copy() - obj.data.materials.clear() - obj.data.materials.append(appended[mat_name]) - assigned += 1 - assigned_names.add(obj.name) - else: - pass # unmatched → will receive FailedMaterial sentinel below - print(f"Material substitution: {assigned}/{len(mesh_objects)} mesh objects assigned") - - # Universal FailedMaterial sentinel: assign SCHAEFFLER_059999_FailedMaterial - # to every mesh object that was not matched by name-based lookup above. - # Replaces the old single-material fallback that only fired when len(appended)==1. - failed_mat = None - try: - bpy.ops.wm.append( - filepath=f"{args.asset_library_blend}/Material/{FAILED_MATERIAL_NAME}", - directory=f"{args.asset_library_blend}/Material/", - filename=FAILED_MATERIAL_NAME, - link=False, - ) - if FAILED_MATERIAL_NAME in bpy.data.materials: - failed_mat = bpy.data.materials[FAILED_MATERIAL_NAME] - print(f"Appended sentinel material: {FAILED_MATERIAL_NAME}") - else: - print(f"WARNING: sentinel '{FAILED_MATERIAL_NAME}' not found in library — " - f"creating in-memory magenta fallback", file=sys.stderr) - except Exception as exc: - print(f"WARNING: failed to append sentinel '{FAILED_MATERIAL_NAME}': {exc}", - file=sys.stderr) - - if failed_mat is None: - # Library append failed: create in-memory magenta so export is never silently wrong - failed_mat = bpy.data.materials.new(name=FAILED_MATERIAL_NAME) - failed_mat.use_nodes = True - bsdf = failed_mat.node_tree.nodes.get("Principled BSDF") - if bsdf: - bsdf.inputs["Base Color"].default_value = (1.0, 0.0, 1.0, 1.0) # magenta - - fallback_count = 0 - for obj in mesh_objects: - if obj.name not in assigned_names: - if obj.data.users > 1: - obj.data = obj.data.copy() - obj.data.materials.clear() - obj.data.materials.append(failed_mat) - fallback_count += 1 - if fallback_count: - print(f"FailedMaterial sentinel: assigned '{FAILED_MATERIAL_NAME}' " - f"to {fallback_count} unmatched objects") - - # Purge orphan data-blocks (palette materials mat_0/mat_1/... from the geometry - # GLB that now have users=0 after library material substitution). - # This prevents stale materials from appearing as duplicates in the export. - try: - bpy.ops.outliner.orphans_purge(do_recursive=True) - except Exception: - pass # non-critical; export proceeds regardless - - # Store the sharp angle in the scene so it is embedded in the GLB extras. - # After importing the production GLB in Blender, running restore_sharp_marks.py - # reads this value and re-applies mark_sharp()+mark_seam() on all mesh objects. - bpy.context.scene["schaeffler_sharp_angle_deg"] = args.smooth_angle - - # Export production GLB with full PBR material data. - # export_extras=True embeds scene custom properties (incl. schaeffler_sharp_angle_deg) - # in the glTF scenes[0].extras JSON field, surviving the round-trip intact. - try: - bpy.ops.export_scene.gltf( - filepath=args.output_path, - export_format="GLB", - export_apply=True, - use_selection=False, - export_materials="EXPORT", - export_image_format="AUTO", - export_extras=True, - ) - except Exception as exc: - print(f"GLB export failed: {exc}", file=sys.stderr) - sys.exit(1) - - print(f"Production GLB exported to {args.output_path}") - - -try: - main() -except SystemExit: - raise -except Exception: - traceback.print_exc() - sys.exit(1) diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py index 331d5f5..498717a 100644 --- a/render-worker/scripts/export_step_to_gltf.py +++ b/render-worker/scripts/export_step_to_gltf.py @@ -468,8 +468,17 @@ def _collect_part_key_map(shape_tool, free_labels) -> dict: if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): name = name_attr.Get().ToExtString() + # Dereference component references to their definition label + # (the definition may itself be an assembly with sub-components) + from OCP.TDF import TDF_Label as _TDF_Label + actual_label = label + if XCAFDoc_ShapeTool.IsReference_s(label): + ref_label = _TDF_Label() + if XCAFDoc_ShapeTool.GetReferredShape_s(label, ref_label): + actual_label = ref_label + components = TDF_LabelSequence() - XCAFDoc_ShapeTool.GetComponents_s(label, components) + XCAFDoc_ShapeTool.GetComponents_s(actual_label, components) xcaf_path = f"{path}/{name}" if name else f"{path}/unnamed"