For 16:9 (1280x720), vertical FOV is narrower than horizontal —
products could be clipped top/bottom. Now applies an aspect ratio
correction factor to camera distance: wider formats push camera
further back proportionally.
Still render (_blender_camera.py) already handled this via
min(fov_h, fov_v); cinematic now has equivalent correction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Blender 5.0 renamed Action.fcurves. Now tries fcurves first, falls
back to channels, and wraps in try/except so the render proceeds
even if LINEAR interpolation can't be set.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changes per user feedback:
- Keyframe interpolation: BEZIER → LINEAR (all fcurves set to LINEAR)
- Removed segment 4 (closeup) — now 3 segments only
- Frame count: 480 → 250 (10 seconds at 25fps)
- FPS: 24 → 25
- Easing removed — pure linear interpolation between segment params
- White background by default (World node Color = white)
- Transparent bg still available as override
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for frame progress not appearing in frontend:
1. Added flush=True to all print() calls in cinematic_render.py
2. Set PYTHONUNBUFFERED=1 in subprocess environment
Without these, Python buffers stdout inside Blender, so all frame
progress lines arrive in a batch after the process exits instead
of streaming line-by-line during rendering.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both camera-orbit and object-rotation modes now parse and apply
the --material-override CLI arg, overriding USD primvar lookups
and material_map before assignment — same pattern as still renders.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The initial implementation only overrode the material_map dict in the task,
but the Blender USD primvar path bypassed it. Now:
- Added --material-override named CLI arg parsed in _blender_args.py
- Both Mode A (factory) and Mode B (template) in _blender_scene_setup.py
override usd_material_lookup and material_map when set
- Passed through full chain: task → step_processor → render_blender → CLI → Blender
- Tested: 175-part bearing rendered with single Steel-Bare material (1/1 materials)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause 1: BRepBuilderAPI_Transform(shape, trsf, copy=True) destroys
all Poly_Triangulation data. The mm→m scaling was applied before export,
wiping the tessellation from BRepMesh_IncrementalMesh.
Fix: Remove BRepBuilderAPI_Transform entirely — RWGltf_CafWriter already
handles mm→m conversion and Z-up→Y-up rotation internally.
Root cause 2: RWGltf_CafWriter with MergeFaces=False (the default) fails
to find per-face tessellation from the XCAF component hierarchy, producing
degenerate meshes (~2 vertices per face instead of thousands).
Fix: SetMergeFaces(True) to compose face triangulations into proper
per-shape mesh buffers. Vertex count goes from 1,212 to 46,573.
Also bumps cache key version to v2 to invalidate broken cached GLBs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- @timed_step decorator with wall-clock + RSS tracking (pipeline_logger)
- Blender timing laps for sharp edges and material assignment
- MeshRegistry pattern: eliminate 13 scene.traverse() calls across viewers
- Lazy material cloning (clone-on-first-write in both viewers)
- _pipeline_session context manager: 7 create_engine() → 2 in render_thumbnail
- KD-tree spatial pre-filter for sharp edge marking (bbox-based pruning)
- Batch material library append: N bpy.ops.wm.append → single bpy.data.libraries.load
- GMSH single-session batching: compound all solids into one tessellation call
- Validate part-materials save endpoints against parsed_objects (prevents bogus keys)
- ROADMAP updated with completion status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract Base Color, Metallic, Roughness, Transmission, IOR from Blender
asset library materials via catalog_assets.py. Store in catalog JSON and
serve via /api/asset-libraries/pbr-map endpoint. Frontend viewers apply
PBR properties to Three.js MeshStandardMaterial using hex color strings
(avoiding Three.js ColorManagement sRGB/linear issues).
Key fixes:
- RLS bypass for material alias lookup in pbr-map endpoint
- pbrMap empty guard prevents premature grey fallback in viewers
- Cache-Control: no-cache on pbr-map requests to avoid stale data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrite _traverse_xcaf → _author_xcaf_to_usd that recursively authors
USD prims mirroring the XCAF assembly tree:
- Assembly nodes become UsdGeom.Xform prims with local transforms from
each component label's Location (not composed with parents)
- Leaf shapes get definition-space vertices (face_loc only, no instance
placement) — the USD scene graph composes transforms hierarchically
- Coordinate swap (X,-Z,Y) now authored once as a root Xform on
/Root/Assembly instead of per-vertex transformation
- Sharp/seam edges extracted per-part from definition shape (not global)
This fixes misplaced geometry for sub-assembly parts (e.g. KOMP-EIN
roller cages with -45° Z rotation) that were previously lost by the
flat traversal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Material prims now use resolved canonical names (e.g.
SCHAEFFLER_010101_Steel_Bare) instead of source object names. When
importing USD in Blender, materials show the correct SCHAEFFLER names
directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- export_step_to_usd.py: change stage from Y-up to Z-up, keep (X,-Z,Y)
transform — matches GLB orientation exactly (verified: bounding box match)
- export_glb.py: include canonical_material in resolved_material_assignments
DB field (was being dropped during manifest parsing)
- import_usd.py: use pxr customData read (not string primvars — Blender
ignores those, confirmed by testing)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- export_step_to_usd.py: accept --material_map CLI arg, write
schaeffler:canonicalMaterialName as customData on each Mesh prim,
fix geometry transform (strip shape Location before face exploration,
apply both face_loc and shape_loc sequentially)
- import_usd.py: after Blender USD import, use pxr to read customData
directly from the USD file — builds {part_key: material_name} lookup
(Blender ignores STRING primvars and customData, but pxr reads both)
- _blender_materials.py: add apply_material_library_direct() for exact
dict-based material assignment without name-matching heuristics
- _blender_scene_setup.py: prefer direct USD lookup, fall back to
name-matching for legacy USD files without material metadata
- export_glb.py (generate_usd_master_task): resolve material_map via
material_service.resolve_material_map() and pass to subprocess;
include material hash in cache key for invalidation
- ROADMAP.md: update P5 status, add M5-M7 milestones
Tested: 3/3 parts matched (ans_lfs120), 172/175 parts matched
(F-802007.TR4-D1-H122AG). Previous: 0/25 matched.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The queue handles far more than thumbnails: OCC tessellation, USD master
generation, GLB production, order line renders, and workflow renders.
asset_pipeline better reflects its role as the render-worker's primary queue.
Updated all references in: task decorators, celery_app.py, beat_tasks.py,
docker-compose.yml worker command, worker.py MONITORED_QUEUES, admin.py,
CLAUDE.md, LEARNINGS.md, Dockerfile, helpTexts.ts, test files,
and all .claude/commands/*.md skill files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- beat_tasks.py: import app.models at module level so SQLAlchemy can
resolve relationship("Template") and relationship("User") when domain
models are imported in isolation inside task functions. Fixes all
beat tasks (batch_render_notifications, recover_stuck_cad_files) that
crashed every 60s with mapper initialization error.
- _blender_materials.py: build_mat_map_lower() now adds a slug-normalized
key variant (re.sub([^a-z0-9]+, _, kl)) for each mat_map entry. OCC
part names like 'F-802007_TR4-D1-H122AG' → slug 'f_802007_tr4_d1_h122ag'
now matches USD-imported Blender objects. Existing prefix fallback
(key.startswith(part_key)) catches AF-suffix variants.
- Orders.tsx: kanban drag-to-reject implemented. submitted/processing
cards are draggable (cursor-grab). Rejected column highlights with
red ring on drag-over. Drop opens reject reason modal via createPortal.
Confirm calls rejectOrder() mutation + invalidates orders cache.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dokumentiert drei neue Learnings aus der GE360-HF Wälzkörper-Positions-Untersuchung:
1. BRepMesh auf Compound: Triangulation in Definition-Space, Face-loc = Instance-Placement
2. IsSame() vs IsPartner() für Assembly-Instanz-Deduplizierung
3. Stale GLB-Cache maskiert Code-Fixes — nach Tessellierungs-Änderungen Cache invalidieren
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In _extract_mesh(), BRep_Tool.Triangulation_s(face, face_loc) returns a face_loc
that already encodes the instance's full placement transform when a compound shape
is tessellated with BRepMesh_IncrementalMesh. Applying shape_trsf on top doubled
every rotation/translation, causing multiple roller elements to collapse to the same
wrong world position (e.g. Z(-75°)×2 ≡ Z(+105°)×2 mod 360° → identical positions).
Fix: use elif so shape_loc is only applied as a fallback when face_loc is identity.
Adds seam edge extraction (UV seam primvar) and improves _traverse_xcaf doc.
docs: learning erfasst - OCC face_loc double-transform in compound tessellation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- export_gltf.py: replace single-material fallback (only fired when
len(appended)==1) with a universal sentinel that appends
SCHAEFFLER_059999_FailedMaterial unconditionally and assigns it to
every mesh object not matched by name-based lookup.
Also adds in-memory magenta fallback if library append fails.
Removes 2 temporary [DEBUG] print lines from investigation.
- blender_render.py: add FailedMaterial assignment inside
_apply_material_library() for unmatched parts (was log-only before).
Includes copy-on-write guard (users > 1) matching existing pattern.
Also added alias 'Stahl; Durotect CMT' (semicolon) → Durotect-Blue
to cover STEP files using semicolon separator instead of comma.
Verified: 23/25 objects matched correctly, 2 ISO8734 dowel pins
(empty material) receive SCHAEFFLER_059999_FailedMaterial as sentinel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 — Missing parts (mirror/repeated instances):
- id(solid.TShape()) is unreliable in OCP: each call creates a new
Python wrapper, so id() always differs even for the same TShape.
Replaced with IsSame() for correct TShape-pointer deduplication.
- TopExp_Explorer(SOLID) misses free shells/faces in assemblies.
Fix: run BRepMesh baseline on full root compound first (catches all
face types), then GMSH overrides per unique solid for better seam
topology. REVERSED solids keep BRepMesh to avoid inverted Jacobians.
Bug 2 — GLB 7× too large (21 MB vs OCC 3 MB):
- CharacteristicLengthMax = linear_deflection × 50 (was ×15)
matches OCC effective edge length on curved surfaces (~5 mm).
- MinimumCirclePoints = min(12, ...) (was min(20, ...))
- Result: GMSH 91% of OCC file size (target ≤120% ✓)
Verified on rolling bearing STEP: same 4 skipped nodes as OCC,
25 unique GMSH tessellations (IsSame deduplication), no OOM.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Per-solid iteration prevents OOM on multi-part assemblies (25-part bearing:
2.3GB RAM when processing compound → ~100MB per solid with per-solid approach)
- Fix CharacteristicLengthMax multiplier 5× → 15× and cap MinimumCirclePoints
at 20 (prevents 63-pts/circle on angular_deflection=0.1rad → 231MB → 21MB)
- Geometry task timeout 120s → 600s for large assemblies
- Production task: reuse _geometry.glb when GMSH enabled (no re-tessellation)
and cache _production_geom.glb for OCC (mtime vs STEP check)
- Viewer now prefers production GLB when available (shows correct GMSH mesh)
- GMSH OpenMP multithreading (min(cpu_count,16)) for 4.4× speedup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GMSH defaults to single-threaded meshing. Setting General.NumThreads,
Mesh.MaxNumThreads1D and Mesh.MaxNumThreads2D to min(cpu_count, 16) enables
parallel Frontal-Delaunay surface meshing across all available cores.
Benchmark on 121-face assembly (32-core host, capped at 16 threads):
Before: 12.7s total (9.8s in gmsh.model.mesh.generate)
After: 2.8s total (1.1s in gmsh.model.mesh.generate)
Cap at 16 threads — benchmark showed 16 threads (1.1s) matches or beats auto
(1.6s), likely due to NUMA/coordination overhead above that threshold.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces GMSH as an alternative to OCC BRepMesh for STEP→GLB tessellation.
GMSH produces conforming meshes that eliminate fan triangles at cylinder seam
edges — a structural limitation of OCC BRepMesh that cannot be fixed via
deflection parameters.
Changes:
- render-worker/Dockerfile: install gmsh>=4.15.0 + libglu1-mesa + libxft2
- export_step_to_gltf.py: --tessellation_engine occ|gmsh CLI arg +
_tessellate_with_gmsh() using BRep→GMSH→Poly_Triangulation write-back
- admin.py: tessellation_engine setting (SETTINGS_DEFAULTS, SettingsOut,
SettingsUpdate, validation)
- export_glb.py: pass tessellation_engine to export_step_to_gltf.py CLI in
both geometry and production GLB tasks
- Admin.tsx: radio button UI for OCC vs GMSH selection
Tested: 121 faces meshed, 0 BRepMesh fallback, 649K triangles on sample part.
Clean seam edges for UV unwrap — GMSH respects B-rep periodic face boundaries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add export_extras=True to bpy.ops.export_scene.gltf() call
- Store schaeffler_sharp_angle_deg in scene custom props before export
→ value is embedded in scenes[0].extras in the GLB JSON chunk
→ survives import/export round-trip intact (verified: 30.0 restored)
- Add tools/restore_sharp_marks.py: companion Blender script that reads
the angle from scene.get("schaeffler_sharp_angle_deg") and re-applies
mark_sharp() + mark_seam() on all mesh objects after GLB import
GLB format cannot store per-edge sharp/seam flags natively; the visual
shading is correct via vertex splits. The extras + restore script give
users the ability to reconstruct Edit Mode markers without a second format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The geometry GLB from export_step_to_gltf.py contains a 'custom_normal' attribute
(CORNER, INT16_2D) from OCC tessellation. If left in place, Blender's glTF exporter
re-exports these pre-baked normals unchanged — ignoring shade_smooth_by_angle
processing and our explicit sharp edge marks.
Fix: remove the 'custom_normal' attribute from all imported mesh objects immediately
after GLB import, before applying smooth shading. Also add orphans_purge() before
export to remove palette materials (mat_0/1/2/3) that become users=0 after library
material substitution.
Same custom_normal clearing applied to blender_render.py for thumbnail renders.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sharp edges:
- OCC→Blender coordinate transform was wrong: Blender(X,Y,Z) = OCC(X×0.001, -Z×0.001, Y×0.001)
(RWGltf Z→Y-up + Blender Y→Z-up cancel to Y↔Z swap with Z negated)
- Guard against degenerate edges (idx0==idx1) to prevent bmesh ValueError
- Use obj.matrix_world @ v.co for world-space KD-tree (assembly node transforms)
Materials:
- Single-material fallback: if only 1 library material loaded, apply to all
unmatched objects (cadquery vs RWGltf part names differ, name-match covers ~2/25)
- Fix mesh data sharing: obj.data.copy() before materials.clear() to avoid
clearing shared data blocks
- Use obj.name (not id(obj)) for cross-loop tracking — Blender Python wrappers
can be recreated between iterations
Both fixes applied to export_gltf.py (production GLB) and blender_render.py (thumbnails).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Render pipeline:
- Replace per-object _apply_smooth() loop with _apply_smooth_batch(): selects
all 175 parts, calls shade_smooth_by_angle() ONCE in C → reduces 16s to ~0.2s
- Remove 175 per-part "assigned material to part" log lines (replace with summary)
- Add TIMING_SUMMARY log line at end of every render showing all step durations
- _lap() helper records split times for: template_load, glb_import, rotation,
smooth_shading, material_assign, pre_render_setup, gpu_render
Frontend role checks:
- Add global_admin + tenant_admin to User role type in auth store
- Add isAdmin() and isPrivileged() helper functions
- Fix Admin.tsx, Layout.tsx, Notifications.tsx, OrderDetail.tsx, ProductDetail.tsx,
CostOverviewWidget.tsx — all were checking role === 'admin' but JWT now has
role === 'global_admin' after migration 049 (admin → global_admin backfill)
- This caused Admin page to render completely empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- gpu_probe.py: Blender script that probes OPTIX/CUDA/HIP/ONEAPI and
exits 1 on no GPU — used at startup + on-demand from Admin UI
- blender_render.py, still_render.py, turntable_render.py: emit
RENDER_DEVICE_USED: engine=CYCLES device=GPU|CPU compute_type=...
after GPU activation; exit 2 when CYCLES_DEVICE=gpu and CPU fallback
- render_blender.py: parse RENDER_DEVICE_USED token into render_log
(device_used, compute_type, gpu_fallback); handle exit code 2 as
explicit GPU strict-mode failure
- check_version.py: check_gpu() runs gpu_probe.py at container startup;
CYCLES_DEVICE=gpu aborts startup if no GPU found
- docker-compose.yml: CYCLES_DEVICE=${CYCLES_DEVICE:-auto} env var
- gpu_tasks.py: probe_gpu Celery task on thumbnail_rendering queue;
saves result to system_settings.gpu_probe_last_result; beat every 30min
- worker.py: POST /probe/gpu (trigger) + GET /probe/gpu/result (last result)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 2.3 — Fix render cancellation (real Celery task ID):
- orders.py cancel endpoints: read celery_task_id from render_job_doc
instead of synthetic "render-{line_id}" which was a no-op
- render_order_line_still_task: creates RenderJobDocument at task start,
stores self.request.id as celery_task_id, writes step-level state
(RESOLVE_STEP_PATH → BLENDER_STILL) back to order_lines.render_job_doc
Phase 3.1 — Remove Pillow overlay dead code:
- blender_render.py: deleted 55-line Pillow post-processing block
(lines 798-851, green bar + model name label)
- transparent_bg=True is always passed; the else branch was unreachable
- Removed mention from script docstring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GPU: fix Cycles device activation order — set compute_device_type
BEFORE engine init, re-set AFTER open_mainfile wipes preferences
- GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with
Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts
- Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys
and add prefix fallback in _apply_material_library (blender_render.py)
- Material: production GLB now uses get_material_library_path() which
checks active AssetLibrary instead of empty legacy system setting
- Admin: RenderTemplateTable multi-select output types (M2M frontend)
- Admin: MaterialLibraryPanel replaced with link to Asset Libraries
- UX: move Toaster to top-left to avoid dispatch button overlap
- SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries
- Logging: flush=True on all Blender progress prints, stdout reconfigure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OCC's RWGltf_CafWriter appends _AF0/_AF1 assembly-instance suffixes to
mesh object names when a part appears multiple times in an assembly.
The material matching in export_gltf.py only stripped Blender's .001
suffix, leaving 24/175 GLB objects without materials.
Fix: strip _AFN suffixes via while loop (handles nested _AF0_AF1),
add prefix fallback (longest key wins) as last resort before no-match.
Also improve build_materials_from_excel Jaccard matching:
- Strip _AFN and numeric hash suffixes (-21227) before tokenizing
- Add prefix-based fallback (step 3) before position fallback (step 4)
- Raise threshold 0.3 → 0.35 for better precision
- Guard prefix matches to len >= 5 to prevent trivial false positives
Result: Material substitution: 175/175 mesh objects assigned (was 151/175)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4 root causes fixed:
1. export_colors=False was removed in Blender 4.x — caused every Blender
export to fail (exit 1) and always fall back to trimesh. Remove it.
Blender now runs the full pipeline: materials + sharp edges.
2. GlbModel cloned ref never reset on url change — key={glbBlobUrl} forces
React to remount GlbModel on each new blob URL, resetting the ref so
fresh geometry is always loaded.
3. glbBlobUrl not cleared before re-fetch — setGlbBlobUrl(null) added at
start of downloadUrl effect so spinner shows instead of stale mesh.
4. staleTime: 30_000 delayed picking up new MediaAsset after generation.
Changed to staleTime: 0 so invalidation always triggers immediate refetch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Linked materials are external references — Blender's GLTF exporter cannot
traverse their node trees to extract Principled BSDF PBR values (metallic,
roughness, base color, normal maps). Appended materials are local copies
that the exporter can fully traverse.
Changes:
- asset_library.py: add link=True parameter (default unchanged for renders)
- export_gltf.py: call apply_asset_library_materials with link=False
- export_gltf.py: add export_materials='EXPORT' + export_image_format='AUTO'
to embed textures and ensure full PBR data in the GLB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- bpy.ops.import_mesh.stl → bpy.ops.wm.stl_import (removed in Blender 4.0)
- mesh.use_auto_smooth = True → bpy.ops.object.shade_smooth_by_angle()
(use_auto_smooth removed in Blender 4.1)
- Apply smooth shading to all objects unconditionally after scale, so
GLB is smooth-shaded even when no sharp edge midpoints are present
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug A: Media Library thumbnails were gray because <img src> cannot send
JWT auth headers. Added useAuthBlob() hook (fetch + createObjectURL) in
MediaBrowser.tsx. Also fixed publish_asset Celery task to populate
product_id + cad_file_id on MediaAsset for thumbnail fallback resolution.
Bug B: Product dimensions now shown in Product Details card with Ruler
icon and "from CAD" label when cad_mesh_attributes.dimensions_mm exists.
Bug C: Replaced 128×128 CAD thumbnail with InlineCadViewer component.
Queries gltf_geometry MediaAssets, fetches GLB via auth fetch → blob URL
→ Three.js Canvas with OrbitControls. Falls back to thumbnail + "Load 3D
Model" button. Polling when GLB generation is in progress.
Bug D: trimesh was in [cad] optional extra but Dockerfile only installed
[dev]. Changed to pip install -e ".[dev,cad]" — trimesh now available in
backend container, GLB + Colors export works.
Also added bbox extraction (STL-first numpy parsing) in render_step_thumbnail
and admin "Re-extract CAD Metadata" bulk endpoint.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>