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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user