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
+182 -30
View File
@@ -10,7 +10,9 @@ import { listProducts } from '../api/products'
import { listOutputTypes } from '../api/outputTypes'
import { createOrder } from '../api/orders'
import { estimatePrice } from '../api/pricing'
import { listGlobalRenderPositions } from '../api/renderPositions'
import type { Product, RenderPosition } from '../api/products'
import type { GlobalRenderPosition } from '../api/renderPositions'
import type { OutputType } from '../api/outputTypes'
const formatCurrency = (amount: number) =>
@@ -32,6 +34,8 @@ type WizardStep = 1 | 2 | 3
type OutputSelections = Record<string, Set<string>>
// Maps product_id → Set of position_id
type PositionSelections = Record<string, Set<string>>
// Maps product_id → Set of global_render_position_id
type GlobalPositionSelections = Record<string, Set<string>>
export default function NewProductOrderPage() {
const navigate = useNavigate()
@@ -41,6 +45,7 @@ export default function NewProductOrderPage() {
const [selectedProducts, setSelectedProducts] = useState<Map<string, Product>>(new Map())
const [outputSelections, setOutputSelections] = useState<OutputSelections>({})
const [positionSelections, setPositionSelections] = useState<PositionSelections>({})
const [globalPositionSelections, setGlobalPositionSelections] = useState<GlobalPositionSelections>({})
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -62,14 +67,26 @@ export default function NewProductOrderPage() {
enabled: step >= 2,
})
function initPositionsForProduct(product: Product) {
const { data: allGlobalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) {
// Pre-select all per-product positions (if any)
if ((product.render_positions?.length ?? 0) > 0) {
// Default: all positions selected
setPositionSelections((ps) => ({
...ps,
[product.id]: new Set(product.render_positions!.map((p) => p.id)),
}))
}
// Always pre-select all global positions for every product
if (globals.length > 0) {
setGlobalPositionSelections((gs) => ({
...gs,
[product.id]: new Set(globals.map((g) => g.id)),
}))
}
}
function toggleProduct(product: Product) {
@@ -84,7 +101,7 @@ export default function NewProductOrderPage() {
return next
})
if (willSelect) {
initPositionsForProduct(product)
initPositionsForProduct(product, allGlobalPositions)
}
}
@@ -98,7 +115,7 @@ export default function NewProductOrderPage() {
;(products ?? []).forEach((p) => next.set(p.id, p))
return next
})
toInit.forEach(initPositionsForProduct)
toInit.forEach((p) => initPositionsForProduct(p, allGlobalPositions))
}
function deselectAllFiltered() {
@@ -180,7 +197,7 @@ export default function NewProductOrderPage() {
})
}
// Union of all unique position names across selected products that have positions
// Union of all unique per-product position names across selected products that have per-product positions
const globalPositionNames = useMemo(() => {
const seen = new Set<string>()
const result: string[] = []
@@ -195,6 +212,30 @@ export default function NewProductOrderPage() {
return result
}, [selectedProducts])
// Global positions apply to all selected products
const anyProductUsesGlobalPositions = selectedProducts.size > 0
function toggleGlobalPositionForAll(gpId: string) {
// Count how many selected products have this global position selected
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gpId)) selectedCount++
}
if (eligibleCount === 0) return
const shouldSelect = selectedCount < eligibleCount
setGlobalPositionSelections((prev) => {
const next = { ...prev }
for (const [productId] of selectedProducts) {
const set = new Set(prev[productId] || [])
if (shouldSelect) set.add(gpId)
else set.delete(gpId)
next[productId] = set
}
return next
})
}
function togglePositionGlobal(positionName: string) {
// Count how many products have this position name and how many have it selected
let compatibleCount = 0
@@ -221,47 +262,61 @@ export default function NewProductOrderPage() {
})
}
// Build flat list of order lines for review (Step 3)
// Each (product, outputType, position?) triple becomes one line.
// Build flat list of order lines for review (Step 3).
// Each (product, outputType, position) triple becomes one line.
// Global positions apply to ALL products; per-product positions are additional.
const orderLines = useMemo(() => {
const lines: Array<{
key: string
product: Product
outputType: OutputType
position: RenderPosition | null
globalPosition: GlobalRenderPosition | null
}> = []
for (const [productId, product] of selectedProducts) {
const selectedOts = outputSelections[productId]
if (!selectedOts) continue
const hasPositions = (product.render_positions?.length ?? 0) > 0
for (const otId of selectedOts) {
const ot = allOutputTypes?.find((o) => o.id === otId)
if (!ot) continue
if (hasPositions) {
const selectedPosIds = positionSelections[productId] || new Set()
if (selectedPosIds.size === 0) {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
} else {
for (const posId of selectedPosIds) {
const pos = product.render_positions!.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos })
}
}
const selectedPosIds = positionSelections[productId] || new Set()
const selectedGlobalIds = globalPositionSelections[productId] || new Set()
const hasAny = selectedPosIds.size > 0 || selectedGlobalIds.size > 0
if (!hasAny) {
// No position selected — one unpositioned line
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null, globalPosition: null })
} else {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
// One line per selected global position
for (const gpId of selectedGlobalIds) {
const gp = allGlobalPositions.find((g) => g.id === gpId)
if (gp) lines.push({ key: `${productId}-${otId}-g${gpId}`, product, outputType: ot, position: null, globalPosition: gp })
}
// One line per selected per-product position
for (const posId of selectedPosIds) {
const pos = product.render_positions?.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos, globalPosition: null })
}
}
}
}
return lines
}, [selectedProducts, outputSelections, positionSelections, allOutputTypes])
}, [selectedProducts, outputSelections, positionSelections, globalPositionSelections, allOutputTypes, allGlobalPositions])
function removeLine(productId: string, outputTypeId: string, positionId: string | null) {
function removeLine(productId: string, outputTypeId: string, positionId: string | null, globalPositionId: string | null) {
if (positionId) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(positionId)
return { ...prev, [productId]: set }
})
} else if (globalPositionId) {
setGlobalPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(globalPositionId)
return { ...prev, [productId]: set }
})
} else {
setOutputSelections((prev) => {
const set = new Set(prev[productId] || [])
@@ -322,6 +377,7 @@ export default function NewProductOrderPage() {
product_id: l.product.id,
output_type_id: l.outputType.id,
render_position_id: l.position?.id ?? null,
global_render_position_id: l.globalPosition?.id ?? null,
})),
})
toast.success(`Draft order ${result.order_number} created — review and submit`)
@@ -502,7 +558,7 @@ export default function NewProductOrderPage() {
</p>
{/* Global toggles — apply to all products at once */}
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0) && (
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0 || (anyProductUsesGlobalPositions && allGlobalPositions.length > 0)) && (
<div className="card p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
Apply to all products
@@ -555,10 +611,10 @@ export default function NewProductOrderPage() {
</div>
)}
{/* Perspectives row */}
{/* Perspectives row — per-product positions (for products that have them) */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives</p>
<p className="text-xs text-content-muted mb-1.5">Perspectives (custom)</p>
<div className="flex flex-wrap gap-2">
{globalPositionNames.map((posName) => {
let compatibleCount = 0
@@ -597,6 +653,47 @@ export default function NewProductOrderPage() {
</div>
</div>
)}
{/* Perspectives row — global positions (for products without custom positions) */}
{anyProductUsesGlobalPositions && allGlobalPositions.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives (global)</p>
<div className="flex flex-wrap gap-2">
{allGlobalPositions.map((gp) => {
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gp.id)) selectedCount++
}
const allSel = selectedCount === eligibleCount && eligibleCount > 0
const someSel = selectedCount > 0 && !allSel
return (
<button
key={gp.id}
onClick={() => toggleGlobalPositionForAll(gp.id)}
title={`${selectedCount} / ${eligibleCount} product${eligibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{gp.name}
{gp.is_default && !allSel && <span className="text-xs opacity-60"></span>}
{selectedProducts.size > 1 && eligibleCount > 0 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{eligibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
@@ -610,6 +707,13 @@ export default function NewProductOrderPage() {
onToggle={(otId) => toggleOutputType(product.id, otId)}
selectedPositions={positionSelections[product.id] || new Set()}
onTogglePosition={(posId) => togglePosition(product.id, posId)}
globalPositions={allGlobalPositions}
selectedGlobalPositions={globalPositionSelections[product.id] || new Set()}
onToggleGlobalPosition={(gpId) => setGlobalPositionSelections((prev) => {
const set = new Set(prev[product.id] || [])
if (set.has(gpId)) set.delete(gpId); else set.add(gpId)
return { ...prev, [product.id]: set }
})}
/>
))}
</div>
@@ -685,9 +789,9 @@ export default function NewProductOrderPage() {
<td className="px-4 py-3 text-content-secondary">{line.outputType.name}</td>
<td className="px-4 py-3">
{line.position ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{line.position.name}
</span>
<span className="badge-purple">{line.position.name}</span>
) : line.globalPosition ? (
<span className="badge-purple opacity-70" title="Global position">{line.globalPosition.name}</span>
) : (
<span className="text-content-muted text-xs"></span>
)}
@@ -706,7 +810,7 @@ export default function NewProductOrderPage() {
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null)}
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null, line.globalPosition?.id ?? null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Remove this render job from the order"
>
@@ -771,6 +875,9 @@ function ProductOutputRow({
onToggle,
selectedPositions,
onTogglePosition,
globalPositions,
selectedGlobalPositions,
onToggleGlobalPosition,
}: {
product: Product
compatibleTypes: OutputType[]
@@ -778,6 +885,9 @@ function ProductOutputRow({
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: string) => void
globalPositions: GlobalRenderPosition[]
selectedGlobalPositions: Set<string>
onToggleGlobalPosition: (gpId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
@@ -852,11 +962,11 @@ function ProductOutputRow({
</div>
)}
{/* Render position toggles — only shown if product has positions */}
{/* Per-product custom positions */}
{(product.render_positions?.length ?? 0) > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Render Positions</p>
<p className="text-xs font-medium text-content-muted">Custom Positions</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => product.render_positions!.forEach((p) => !selectedPositions.has(p.id) && onTogglePosition(p.id))}
@@ -895,6 +1005,48 @@ function ProductOutputRow({
</div>
</div>
)}
{/* Global position toggles — always shown for all products */}
{globalPositions.length > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Perspectives</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => globalPositions.forEach((g) => !selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => globalPositions.forEach((g) => selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{globalPositions.map((gp) => {
const active = selectedGlobalPositions.has(gp.id)
return (
<button
key={gp.id}
onClick={() => onToggleGlobalPosition(gp.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{gp.name}
{gp.is_default && <span className="text-xs opacity-60 ml-0.5"></span>}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>