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,243 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'
import {
listGlobalRenderPositions,
createGlobalRenderPosition,
updateGlobalRenderPosition,
deleteGlobalRenderPosition,
type GlobalRenderPosition,
type GlobalRenderPositionCreate,
} from '../../api/renderPositions'
interface EditState {
id: string | null
name: string
rotation_x: number
rotation_y: number
rotation_z: number
is_default: boolean
sort_order: number
}
const EMPTY_EDIT: EditState = {
id: null,
name: '',
rotation_x: 0,
rotation_y: 0,
rotation_z: 0,
is_default: false,
sort_order: 0,
}
export default function GlobalRenderPositionsPanel() {
const qc = useQueryClient()
const [editing, setEditing] = useState<EditState | null>(null)
const [adding, setAdding] = useState(false)
const { data: positions = [], isLoading } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
const createMut = useMutation({
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false) },
})
const updateMut = useMutation({
mutationFn: ({ id, body }: { id: string; body: Partial<GlobalRenderPositionCreate> }) =>
updateGlobalRenderPosition(id, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setEditing(null) },
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteGlobalRenderPosition(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['global-render-positions'] }),
})
function startEdit(pos: GlobalRenderPosition) {
setAdding(false)
setEditing({
id: pos.id,
name: pos.name,
rotation_x: pos.rotation_x,
rotation_y: pos.rotation_y,
rotation_z: pos.rotation_z,
is_default: pos.is_default,
sort_order: pos.sort_order,
})
}
function saveEdit() {
if (!editing) return
if (editing.id) {
const { id, ...body } = editing
updateMut.mutate({ id, body })
}
}
function saveNew() {
if (!editing) return
const { id, ...body } = editing
createMut.mutate(body)
}
function startAdd() {
setEditing({ ...EMPTY_EDIT, sort_order: positions.length })
setAdding(true)
}
function cancelEdit() {
setEditing(null)
setAdding(false)
}
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) {
if (!editing) return null
return (
<div className="flex flex-col gap-0.5">
<label className="text-xs text-content-muted">{label}</label>
<input
type="number"
step="5"
className="input w-20 text-sm"
value={editing[field]}
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })}
/>
</div>
)
}
if (isLoading) return <p className="text-sm text-content-muted">Loading</p>
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-content-muted">
Global camera rotation presets applied to all products. Per-product positions take priority.
</p>
<button className="btn btn-sm btn-primary flex items-center gap-1" onClick={startAdd}>
<Plus size={14} /> Add position
</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left text-xs text-content-muted">
<th className="pb-1 pr-3">Name</th>
<th className="pb-1 pr-3 text-center">Rot X°</th>
<th className="pb-1 pr-3 text-center">Rot Y°</th>
<th className="pb-1 pr-3 text-center">Rot Z°</th>
<th className="pb-1 pr-3 text-center">Default</th>
<th className="pb-1 pr-3 text-center">Order</th>
<th className="pb-1" />
</tr>
</thead>
<tbody>
{positions.map((pos) => {
const isEditingThis = editing && editing.id === pos.id
return (
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30">
{isEditingThis ? (
<>
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
value={editing!.name}
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
/>
</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
checked={editing!.is_default}
onChange={(e) => setEditing({ ...editing!, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing!.sort_order}
onChange={(e) => setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveEdit} disabled={updateMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</>
) : (
<>
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
<td className="py-1.5 pr-3 text-center">
{pos.is_default && <span className="text-accent text-xs font-medium"></span>}
</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
<td className="py-1.5 flex items-center gap-1">
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
<button
className="btn btn-xs text-red-500"
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
disabled={deleteMut.isPending}
>
<Trash2 size={12} />
</button>
</td>
</>
)}
</tr>
)
})}
{/* New row */}
{adding && editing && (
<tr className="border-b border-border-light bg-surface-alt/20">
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
placeholder="Name"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
/>
</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
checked={editing.is_default}
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing.sort_order}
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveNew} disabled={createMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}