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,290 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Loader2, Palette, Layers, EyeOff } from 'lucide-react'
import { toast } from 'sonner'
import api from '../../api/client'
import { savePartMaterials, type PartMaterialMap, type PartMaterialEntry } from '../../api/cad'
// ---------------------------------------------------------------------------
// SCHAEFFLER_COLORS — viewport preview colors for known library materials
// ---------------------------------------------------------------------------
export const SCHAEFFLER_COLORS: Record<string, string> = {
'SCHAEFFLER_010101_Steel-Bare': '#8a9ca8',
'SCHAEFFLER_010102_Steel-Polished': '#b0c4ce',
'SCHAEFFLER_010103_Steel-Brushed': '#9aabb5',
'SCHAEFFLER_010104_Steel-Painted': '#607080',
'SCHAEFFLER_010201_Stainless-Bare': '#adb9bf',
'SCHAEFFLER_010202_Stainless-Polished': '#cdd8dc',
'SCHAEFFLER_010301_Iron-Cast': '#696969',
'SCHAEFFLER_020101_Aluminium-Bare': '#c8c8c8',
'SCHAEFFLER_020102_Aluminium-Anodized': '#b0b8c0',
'SCHAEFFLER_030101_Brass': '#c9a84c',
'SCHAEFFLER_030201_Bronze': '#a07040',
'SCHAEFFLER_040101_Copper': '#b87333',
'SCHAEFFLER_050101_Plastic-Black': '#202020',
'SCHAEFFLER_050102_Plastic-White': '#f0f0f0',
'SCHAEFFLER_050201_Rubber-Black': '#1a1a1a',
'SCHAEFFLER_060101_Ceramic': '#e8dcc8',
'SCHAEFFLER_070101_Glass': '#88bbcc',
}
export function previewColorForEntry(entry: PartMaterialEntry): string {
if (entry.type === 'hex') return entry.value
return SCHAEFFLER_COLORS[entry.value] ?? '#888888'
}
// ---------------------------------------------------------------------------
// MaterialOut — matches GET /api/materials response
// ---------------------------------------------------------------------------
export interface MaterialOut {
id: string
name: string
description: string | null
schaeffler_code: number | null
source: string
}
// ---------------------------------------------------------------------------
// MaterialPanel — floating panel for assigning a material/color to a part
// ---------------------------------------------------------------------------
export type IsolateMode = 'none' | 'ghost' | 'hide'
export interface MaterialPanelProps {
partName: string
cadFileId: string
currentEntry: PartMaterialEntry | undefined
partMaterials: PartMaterialMap
onClose: () => void
isolateMode?: IsolateMode
onIsolateModeChange?: (mode: IsolateMode) => void
}
export default function MaterialPanel({
partName,
cadFileId,
currentEntry,
partMaterials,
onClose,
isolateMode = 'none',
onIsolateModeChange,
}: MaterialPanelProps) {
const queryClient = useQueryClient()
// Fetch all tenant materials (no filter — user sees their full library)
const { data: allMaterials = [] } = useQuery({
queryKey: ['materials'],
queryFn: async () => {
const res = await api.get<MaterialOut[]>('/materials')
return res.data
},
staleTime: 60_000,
})
const [assignType, setAssignType] = useState<'library' | 'hex'>(
currentEntry?.type ?? 'library',
)
const [hexValue, setHexValue] = useState(
currentEntry?.type === 'hex' ? currentEntry.value : '#888888',
)
const [libValue, setLibValue] = useState(
currentEntry?.type === 'library'
? currentEntry.value
: (allMaterials[0]?.name ?? ''),
)
// Set default library value once materials load
useEffect(() => {
if (!libValue && allMaterials.length > 0) setLibValue(allMaterials[0].name)
}, [allMaterials]) // eslint-disable-line react-hooks/exhaustive-deps
const saveMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
toast.success(`Material assigned to "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to save material assignment'),
})
const removeMut = useMutation({
mutationFn: (updated: PartMaterialMap) => savePartMaterials(cadFileId, updated),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['part-materials', cadFileId] })
toast.success(`Assignment removed from "${partName}"`)
onClose()
},
onError: () => toast.error('Failed to remove assignment'),
})
function handleAssign() {
const entry: PartMaterialEntry =
assignType === 'hex'
? { type: 'hex', value: hexValue }
: { type: 'library', value: libValue }
saveMut.mutate({ ...partMaterials, [partName]: entry })
}
function handleRemove() {
const updated = { ...partMaterials }
delete updated[partName]
removeMut.mutate(updated)
}
const isBusy = saveMut.isPending || removeMut.isPending
const previewHex = assignType === 'hex'
? hexValue
: (SCHAEFFLER_COLORS[libValue] ?? '#888888')
return (
<div
className="absolute top-2 left-2 z-30 w-72 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700">
<div className="flex items-center gap-2 min-w-0">
<Palette size={13} className="text-accent shrink-0" />
<span className="text-white text-xs font-semibold truncate" title={partName}>
{partName}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white p-0.5 shrink-0">
<X size={14} />
</button>
</div>
<div className="p-3 space-y-3">
{/* Isolation toggles — ghost or hide all other parts */}
{onIsolateModeChange && (
<div className="flex gap-1.5">
<button
onClick={() => onIsolateModeChange(isolateMode === 'ghost' ? 'none' : 'ghost')}
title="Ghost other parts (semi-transparent)"
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
isolateMode === 'ghost'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<Layers size={11} /> Ghost
</button>
<button
onClick={() => onIsolateModeChange(isolateMode === 'hide' ? 'none' : 'hide')}
title="Hide other parts"
className={`flex-1 flex items-center justify-center gap-1 py-1 rounded text-[11px] font-medium transition-colors ${
isolateMode === 'hide'
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<EyeOff size={11} /> Hide
</button>
</div>
)}
{/* Type tabs */}
<div className="flex rounded-md overflow-hidden border border-gray-700 text-xs">
{(['library', 'hex'] as const).map((t) => (
<button
key={t}
onClick={() => setAssignType(t)}
className={`flex-1 py-1.5 font-medium transition-colors ${
assignType === t
? 'bg-accent text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{t === 'library' ? 'Library Material' : 'Hex Color'}
</button>
))}
</div>
{assignType === 'library' ? (
<div>
<label className="block text-gray-400 text-[11px] mb-1">Material</label>
<select
value={libValue}
onChange={(e) => setLibValue(e.target.value)}
className="w-full bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 focus:outline-none focus:border-accent"
>
{allMaterials.map((m) => (
<option key={m.id} value={m.name}>
{m.name}
{m.description ? `${m.description}` : ''}
</option>
))}
{allMaterials.length === 0 && (
<option value="">No materials found</option>
)}
</select>
</div>
) : (
<div>
<label className="block text-gray-400 text-[11px] mb-1">Hex Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexValue}
onChange={(e) => setHexValue(e.target.value)}
className="w-10 h-8 rounded border border-gray-600 bg-gray-800 cursor-pointer p-0.5"
/>
<input
type="text"
value={hexValue}
onChange={(e) => setHexValue(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-600 text-white text-xs rounded px-2 py-1.5 font-mono focus:outline-none focus:border-accent"
placeholder="#888888"
/>
</div>
</div>
)}
{/* Preview swatch */}
<div className="flex items-center gap-2 text-[11px] text-gray-400">
<div
className="w-4 h-4 rounded-sm border border-gray-600 shrink-0"
style={{ backgroundColor: previewHex }}
/>
<span>Viewport preview color</span>
</div>
{/* Current assignment */}
{currentEntry && (
<div className="flex items-center gap-2 text-[11px] text-gray-400 bg-gray-800/60 rounded px-2 py-1.5">
<div
className="w-3 h-3 rounded-sm shrink-0 border border-gray-600"
style={{ backgroundColor: previewColorForEntry(currentEntry) }}
/>
<span className="truncate">Current: {currentEntry.value}</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
onClick={handleAssign}
disabled={isBusy || (assignType === 'library' && !libValue)}
className="flex-1 px-3 py-1.5 rounded bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs font-medium transition-colors flex items-center justify-center gap-1"
>
{saveMut.isPending && <Loader2 size={11} className="animate-spin" />}
Assign
</button>
{currentEntry && (
<button
onClick={handleRemove}
disabled={isBusy}
className="px-3 py-1.5 rounded bg-gray-700 hover:bg-red-900 disabled:opacity-40 disabled:cursor-not-allowed text-gray-300 hover:text-white text-xs font-medium transition-colors flex items-center gap-1"
>
{removeMut.isPending && <Loader2 size={11} className="animate-spin" />}
Remove
</button>
)}
</div>
</div>
</div>
)
}