feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan

Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
@@ -0,0 +1,230 @@
import { useState } from 'react'
import { X, Cpu, ChevronDown, ChevronUp, Zap } from 'lucide-react'
import type { RenderLog } from '../../api/orders'
interface Props {
open: boolean
onClose: () => void
title: string
renderLog: RenderLog | null | undefined
renderStartedAt?: string | null
renderCompletedAt?: string | null
}
function formatBytes(n?: number | null): string {
if (n == null) return '—'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
function formatDuration(s?: number | null): string {
if (s == null) return '—'
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rem = (s % 60).toFixed(0)
return `${m}m ${rem}s`
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
{children}
</p>
)
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 text-sm py-0.5">
<span className="text-content-muted shrink-0">{label}</span>
<span className="text-content font-medium text-right">{value ?? '—'}</span>
</div>
)
}
function BoolPill({ value, trueLabel = 'Yes', falseLabel = 'No' }: { value: boolean | undefined; trueLabel?: string; falseLabel?: string }) {
if (value == null) return <span className="text-content-muted"></span>
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${value ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'}`}>
{value ? trueLabel : falseLabel}
</span>
)
}
export default function RenderInfoModal({
open,
onClose,
title,
renderLog,
renderStartedAt,
renderCompletedAt,
}: Props) {
const [logExpanded, setLogExpanded] = useState(false)
if (!open) return null
const rl = renderLog
const isAnimation = rl?.type === 'turntable'
const hasTemplate = !!rl?.template
const hasTimestamps = !!(renderStartedAt || renderCompletedAt)
const hasLog = (rl?.log_lines?.length ?? 0) > 0
const hasError = !!rl?.error
const engineLabel = rl?.engine_used || rl?.engine || '—'
const device = rl?.device_used
const isGpu = device?.toLowerCase().includes('gpu')
const isCpu = device?.toLowerCase().includes('cpu')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-light sticky top-0"
style={{ backgroundColor: 'var(--color-bg-surface)' }}>
<h2 className="font-semibold text-content">{title} Render Info</h2>
<button
onClick={onClose}
className="text-content-muted hover:text-content transition-colors p-1 rounded"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{/* Error */}
{hasError && (
<div className="rounded-md p-3 text-sm" style={{ backgroundColor: 'var(--color-status-error-bg)', color: 'var(--color-status-error-text)' }}>
<p className="font-semibold mb-1">Render Error</p>
<pre className="whitespace-pre-wrap text-xs font-mono break-all">{rl!.error}</pre>
</div>
)}
{/* Render Settings */}
{rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Render Settings</SectionHeader>
<div className="space-y-0.5">
{rl.renderer && <Row label="Renderer" value={rl.renderer} />}
<Row label="Engine" value={engineLabel} />
{device && (
<Row
label="Device"
value={
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${
isGpu
? 'bg-status-success-bg text-status-success-text'
: isCpu
? 'bg-status-warning-bg text-status-warning-text'
: 'bg-surface-muted text-content-muted'
}`}>
{isGpu ? <Zap size={10} /> : <Cpu size={10} />}
{device}
</span>
}
/>
)}
{rl.samples != null && <Row label="Samples" value={rl.samples} />}
{rl.compute_type && <Row label="Compute Type" value={rl.compute_type} />}
{rl.gpu_fallback != null && (
<Row label="GPU Fallback" value={<BoolPill value={rl.gpu_fallback} trueLabel="Yes (CPU used)" falseLabel="No" />} />
)}
{rl.format && <Row label="Format" value={rl.format.toUpperCase()} />}
{rl.parts_count != null && <Row label="Parts" value={rl.parts_count} />}
{rl.stl_quality && <Row label="STL Quality" value={rl.stl_quality} />}
{rl.smooth_angle != null && <Row label="Smooth Angle" value={`${rl.smooth_angle}°`} />}
</div>
</div>
)}
{/* Timing */}
{rl && (rl.total_duration_s != null || rl.stl_duration_s != null || rl.render_duration_s != null) && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Timing</SectionHeader>
<div className="space-y-0.5">
<Row label="Total" value={formatDuration(rl.total_duration_s)} />
{rl.stl_duration_s != null && <Row label="STL Conversion" value={formatDuration(rl.stl_duration_s)} />}
{rl.render_duration_s != null && <Row label="Render" value={formatDuration(rl.render_duration_s)} />}
{isAnimation && rl.ffmpeg_duration_s != null && <Row label="FFmpeg" value={formatDuration(rl.ffmpeg_duration_s)} />}
{isAnimation && rl.frame_count != null && <Row label="Frames" value={rl.frame_count} />}
{isAnimation && rl.fps != null && <Row label="FPS" value={rl.fps} />}
</div>
</div>
)}
{/* Files */}
{rl && (rl.output_size_bytes != null || rl.stl_size_bytes != null) && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Files</SectionHeader>
<div className="space-y-0.5">
{rl.output_size_bytes != null && <Row label="Output File" value={formatBytes(rl.output_size_bytes)} />}
{rl.stl_size_bytes != null && <Row label="STL Cache" value={formatBytes(rl.stl_size_bytes)} />}
</div>
</div>
)}
{/* Template */}
{hasTemplate && rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Template</SectionHeader>
<div className="space-y-0.5">
<Row label="Path" value={<span className="font-mono text-xs break-all">{rl.template}</span>} />
{rl.lighting_only != null && <Row label="Lighting Only" value={<BoolPill value={rl.lighting_only} />} />}
{rl.shadow_catcher != null && <Row label="Shadow Catcher" value={<BoolPill value={rl.shadow_catcher} />} />}
{rl.material_replace != null && <Row label="Material Replace" value={<BoolPill value={rl.material_replace} />} />}
</div>
</div>
)}
{/* Timestamps */}
{hasTimestamps && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<SectionHeader>Timestamps</SectionHeader>
<div className="space-y-0.5">
{renderStartedAt && <Row label="Started" value={new Date(renderStartedAt).toLocaleString()} />}
{renderCompletedAt && <Row label="Completed" value={new Date(renderCompletedAt).toLocaleString()} />}
</div>
</div>
)}
{/* Blender Log */}
{hasLog && rl && (
<div className="rounded-md p-4" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<button
onClick={() => setLogExpanded((v) => !v)}
className="flex items-center gap-2 w-full text-left"
>
<SectionHeader>Blender Log</SectionHeader>
<span className="ml-auto text-content-muted">
{logExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{logExpanded && (
<pre className="mt-2 text-xs font-mono whitespace-pre-wrap break-all max-h-64 overflow-y-auto text-content-secondary leading-relaxed">
{rl.log_lines!.join('\n')}
</pre>
)}
{!logExpanded && (
<p className="text-xs text-content-muted mt-1">{rl.log_lines!.length} lines click to expand</p>
)}
</div>
)}
{!rl && (
<p className="text-sm text-content-muted text-center py-4">No render metadata available.</p>
)}
</div>
</div>
</div>
)
}