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:
+6
-39
@@ -67,30 +67,6 @@ export async function getCadObjects(cadFileId: string): Promise<CadObjects> {
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the cached STL for a CAD file as a file-save dialog.
|
||||
* quality: 'low' | 'high'
|
||||
* The backend returns a human-readable filename, but we derive it client-side too.
|
||||
*/
|
||||
export async function downloadStl(cadFileId: string, quality: 'low' | 'high', suggestedName?: string): Promise<void> {
|
||||
const res = await api.get<Blob>(`/cad/${cadFileId}/stl/${quality}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = suggestedName ? `${suggestedName}_${quality}.stl` : `model_${quality}.stl`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function generateStl(cadFileId: string, quality: 'low' | 'high'): Promise<{ task_id: string }> {
|
||||
const res = await api.post<{ task_id: string }>(`/cad/${cadFileId}/generate-stl/${quality}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend to re-queue STEP processing for a CAD file (admin only).
|
||||
* Returns the Celery task_id (or null if the worker is not available).
|
||||
@@ -110,23 +86,14 @@ export interface GenerateGltfResponse {
|
||||
cad_file_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue GLB geometry export from existing STL cache (trimesh, no Blender).
|
||||
* The STL low-quality cache must already exist.
|
||||
*/
|
||||
/** Queue geometry GLB export directly from STEP via OCC (no Blender, no STL). */
|
||||
export async function generateGltfGeometry(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-geometry`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export const exportGltfColored = (id: string): Promise<void> =>
|
||||
api.get(`/cad/${id}/export-gltf-colored`, { responseType: 'blob' }).then(r => {
|
||||
const url = URL.createObjectURL(r.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${id}_colored.glb`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
/** Queue production GLB export (Blender + PBR materials) from geometry GLB. */
|
||||
export async function generateGltfProduction(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-production`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface Product {
|
||||
thumbnail_url: string | null
|
||||
render_image_url: string | null
|
||||
processing_status: string | null
|
||||
stl_cached: string[]
|
||||
cad_parsed_objects: string[] | null
|
||||
cad_mesh_attributes?: {
|
||||
dimensions_mm?: { x: number; y: number; z: number }
|
||||
|
||||
@@ -31,7 +31,7 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
|
||||
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||||
let geo = obj.geometry.clone()
|
||||
if (!geo.index) {
|
||||
// Non-indexed geometry (STL→GLB via trimesh): each triangle has unique vertices,
|
||||
// Non-indexed geometry: each triangle has unique vertices,
|
||||
// so computeVertexNormals() would give per-face normals (flat shading).
|
||||
// mergeVertices() creates an indexed geometry with shared vertices first,
|
||||
// so the subsequent normal computation averages across adjacent faces → smooth.
|
||||
@@ -208,7 +208,7 @@ export default function InlineCadViewer({
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => generateMut.mutate()}
|
||||
disabled={generateMut.isPending || generating}
|
||||
title="Export STL to GLB and load 3D viewer"
|
||||
title="Export geometry GLB from STEP via OCC and load 3D viewer"
|
||||
>
|
||||
<RefreshCw size={12} className={generating ? 'animate-spin' : ''} />
|
||||
{generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'}
|
||||
|
||||
@@ -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 & 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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user