refactor: replace STL intermediary with OCC-native STEP→GLB pipeline

- export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform
  (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method)
- Blender scripts (blender_render.py, still_render.py, turntable_render.py,
  export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m
- step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache,
  replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB
  after thumbnail render
- render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step,
  remove convert_step_to_stl and export_per_part_stls
- domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks
  to use GLB instead of STL
- cad.py: remove STL download/generate endpoints, add generate-gltf-production
- admin.py: generate-missing-stls → generate-missing-geometry-glbs
- Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 16:49:18 +01:00
parent 3eba7b2d37
commit 95cfe0aa93
20 changed files with 809 additions and 1301 deletions
-44
View File
@@ -73,7 +73,6 @@ export default function AdminPage() {
blender_eevee_samples: number
threejs_render_size: number
thumbnail_format: string
stl_quality: string
blender_smooth_angle: number
cycles_device: string
blender_max_concurrent_renders: number
@@ -159,14 +158,6 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const generateMissingStlsMut = useMutation({
mutationFn: () => api.post('/admin/settings/generate-missing-stls'),
onSuccess: (res) => {
toast.success(res.data.message || 'STL generation queued')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
@@ -397,29 +388,6 @@ export default function AdminPage() {
</div>
</div>
{/* STL quality */}
<div className="flex items-center gap-6 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">STL quality</span>
{(['low', 'high'] as const).map((q) => (
<button
key={q}
onClick={() => setBlenderDraft((d) => ({ ...d, stl_quality: q }))}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
blender.stl_quality === q
? 'bg-blue-600 text-white border-blue-600'
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
}`}
>
{q === 'low' ? 'Low (fast)' : 'High (detailed)'}
</button>
))}
<p className="text-xs text-content-muted">
{blender.stl_quality === 'high'
? 'Fine mesh (tol=0.01) — slower STEP→STL, sharper edges.'
: 'Coarse mesh (tol=0.3) — faster, good for previews.'}
</p>
</div>
{/* Smooth by angle */}
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Smooth angle</span>
@@ -704,18 +672,6 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => generateMissingStlsMut.mutate()}
disabled={generateMissingStlsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Queue STL conversion for every low/high quality that is not yet cached on disk"
>
<RefreshCw size={14} className={generateMissingStlsMut.isPending ? 'animate-spin' : ''} />
{generateMissingStlsMut.isPending ? 'Queueing…' : 'Generate Missing STLs'}
</button>
<p className="text-xs text-content-muted">Generates low + high STL files for completed STEP files missing them.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
+3 -3
View File
@@ -96,8 +96,8 @@ export default function CadPreviewPage() {
<Box size={48} className="text-gray-600" />
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
<p className="text-gray-400 text-sm max-w-sm">
Generate a GLB file from the STEP cache to enable the 3D viewer.
The STL cache must exist (process the STEP file first).
Generate a geometry GLB from the STEP file to enable the 3D viewer.
Process the STEP file first to make it available.
</p>
{generating ? (
<div className="flex items-center gap-2 text-gray-300 text-sm">
@@ -116,7 +116,7 @@ export default function CadPreviewPage() {
)}
{generateMutation.isError && (
<p className="text-red-400 text-sm">
Failed to start generation. Check that the STL cache exists.
Failed to start generation. Make sure the STEP file has been processed.
</p>
)}
</div>
+73 -151
View File
@@ -18,7 +18,7 @@ import { listMaterials } from '../api/materials'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore } from '../store/auth'
import { downloadStl, generateStl, generateGltfGeometry, exportGltfColored } from '../api/cad'
import { generateGltfGeometry, generateGltfProduction } from '../api/cad'
import InlineCadViewer from '../components/cad/InlineCadViewer'
function CadStatusBadge({ status }: { status: string | null }) {
@@ -290,11 +290,6 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const exportGltfColoredMut = useMutation({
mutationFn: () => exportGltfColored(product?.cad_file_id!),
onError: () => toast.error('GLB export failed'),
})
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
const POSITION_PRESETS = [
@@ -526,160 +521,87 @@ export default function ProductDetailPage() {
{product.cad_file_id ? (
<div className="space-y-3">
{/* Inline 3D Viewer */}
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
/>
{/* Two-column: viewer left, actions right */}
<div className="flex gap-4 items-start">
{/* Left: Inline 3D Viewer */}
<div className="flex-1 min-w-0">
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
/>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
{isPrivileged && (
<>
<div {...getRootProps()} className="cursor-pointer">
<input {...getInputProps()} />
<button className="btn-secondary text-xs" disabled={cadUploadMut.isPending}>
<Upload size={12} />
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
</button>
</div>
<button
className="btn-secondary text-xs"
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
title="Re-render the thumbnail using the current part materials and the active thumbnail renderer — keeps the existing STEP parse data"
>
<RotateCcw size={12} />
{regenerateMut.isPending ? 'Queuing…' : 'Regenerate thumbnail'}
</button>
<button
className="btn-secondary text-xs"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending}
title="Re-run full STEP processing: re-parse part names, regenerate thumbnail and glTF. Use this after re-uploading a STEP file."
>
<RotateCcw size={12} />
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
</button>
<button
className="btn-secondary text-xs"
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
title="Open interactive 3D viewer in full screen"
>
<Cuboid size={12} />
View Full Screen
</button>
<button
className="btn-secondary text-xs"
onClick={() =>
generateGltfGeometry(product.cad_file_id!)
.then(() => toast.info('GLB geometry export queued'))
.catch(() => toast.error('Failed to queue GLB export'))
}
title="Export geometry-only GLB from cached STL (trimesh, no Blender). Requires STL cache."
>
<Download size={12} />
Generate GLB
</button>
{product?.processing_status === 'completed' && (
<button
onClick={() => exportGltfColoredMut.mutate()}
disabled={exportGltfColoredMut.isPending}
className="btn-secondary flex items-center gap-2 disabled:opacity-40 text-xs"
title="Download GLB with PBR colors from material assignments"
>
<Download size={12} />
{exportGltfColoredMut.isPending ? 'Exporting…' : 'GLB + Colors'}
</button>
)}
<div className="flex items-center gap-1 pl-1 border-l border-border-light">
<p className="text-xs text-content-muted font-medium">STL:</p>
{(['low', 'high'] as const).map((q) =>
product.stl_cached.includes(q) ? (
<button
key={q}
className="btn-secondary text-xs"
onClick={() => downloadStl(product.cad_file_id!, q, product.name_cad_modell || product.name || undefined)}
title={q === 'low' ? 'Coarse mesh, tolerance 0.3 mm' : 'Fine mesh, tolerance 0.01 mm'}
>
<Download size={12} /> {q === 'low' ? 'Low' : 'High'}
</button>
) : (
<button
key={q}
className="btn-secondary text-xs opacity-60"
onClick={() => generateStl(product.cad_file_id!, q).then(() => toast.info(`STL generation queued (${q} quality)`)).catch(() => toast.error('Failed to queue STL generation'))}
title={`${q === 'low' ? 'Low' : 'High'}-quality STL not cached — click to generate`}
>
<RefreshCw size={12} /> Gen {q === 'low' ? 'Low' : 'High'}
</button>
)
)}
</div>
</>
)}
{!isPrivileged && product.cad_file_id && (
{/* Right: Action buttons */}
<div className="flex flex-col gap-2 shrink-0 w-44">
<button
className="btn-secondary text-xs"
className="btn-secondary text-xs w-full justify-start"
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
title="Open interactive 3D viewer in full screen"
>
<Cuboid size={12} />
View Full Screen
</button>
)}
</div>
{/* Mesh attributes */}
{(() => {
// Prefer cad_mesh_attributes (reliably populated by API) over cad_file.mesh_attributes
const mesh_attrs: Record<string, unknown> = (product.cad_mesh_attributes ?? product.cad_file?.mesh_attributes) as Record<string, unknown> ?? {}
if (Object.keys(mesh_attrs).length === 0) return null
const dims = mesh_attrs.dimensions_mm as { x: number; y: number; z: number } | undefined
const bbox = mesh_attrs.bbox as { x?: number; y?: number; z?: number } | undefined
return (
<div className="mt-3 p-3 rounded-md border border-border-default bg-surface-alt">
<p className="text-xs font-semibold text-content-muted mb-2 flex items-center gap-1">
<Ruler size={12} />
Geometry
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{dims != null && (
<>
<span className="text-content-muted">Dimensions</span>
<span>{dims.x.toFixed(1)} × {dims.y.toFixed(1)} × {dims.z.toFixed(1)} mm</span>
</>
)}
{dims == null && bbox != null && (
<>
<span className="text-content-muted">BBox</span>
<span>
{bbox.x?.toFixed(1)} × {bbox.y?.toFixed(1)} × {bbox.z?.toFixed(1)} mm
</span>
</>
)}
{(mesh_attrs.volume_mm3 as number | undefined) != null && (
<>
<span className="text-content-muted">Volume</span>
<span>{((mesh_attrs.volume_mm3 as number) / 1000).toFixed(2)} cm³</span>
</>
)}
{(mesh_attrs.surface_area_mm2 as number | undefined) != null && (
<>
<span className="text-content-muted">Surface</span>
<span>{((mesh_attrs.surface_area_mm2 as number) / 100).toFixed(1)} cm²</span>
</>
)}
{mesh_attrs.suggested_smooth_angle !== undefined && (
<>
<span className="text-content-muted">Sharp angle</span>
<span>{mesh_attrs.suggested_smooth_angle as number}°</span>
</>
)}
</div>
</div>
)
})()}
{isPrivileged && (
<>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
<div {...getRootProps()} className="cursor-pointer">
<input {...getInputProps()} />
<button className="btn-secondary text-xs w-full justify-start" disabled={cadUploadMut.isPending}>
<Upload size={12} />
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
</button>
</div>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
title="Re-render thumbnail with current materials"
>
<RotateCcw size={12} />
{regenerateMut.isPending ? 'Queuing…' : 'Regen thumbnail'}
</button>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending}
title="Re-parse STEP + regenerate thumbnail and glTF"
>
<RotateCcw size={12} />
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
</button>
</div>
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() =>
generateGltfGeometry(product.cad_file_id!)
.then(() => toast.info('Geometry GLB export queued'))
.catch(() => toast.error('Failed to queue GLB export'))
}
title="Export geometry GLB directly from STEP via OCC (no Blender)"
>
<Download size={12} />
Generate Geometry GLB
</button>
<button
className="btn-secondary text-xs w-full justify-start"
onClick={() =>
generateGltfProduction(product.cad_file_id!)
.then(() => toast.info('Production GLB export queued'))
.catch(() => toast.error('Failed to queue production GLB export'))
}
title="Export production GLB with PBR materials via Blender"
>
<Download size={12} />
Generate Production GLB
</button>
</div>
</>
)}
</div>
</div>
{/* Material assignments */}
{isPrivileged && (