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